mirror of
https://github.com/0xrsydn/idx-cli.git
synced 2026-08-07 01:33:52 +00:00
feat(analysis): add technical analysis module and stocks technical command
- Add analysis module: SMA, EMA, RSI(14), MACD(12,26,9), volume ratio - Add signal interpretation: bullish/bearish/neutral with consensus voting - Wire up 'stocks technical <SYMBOL>' CLI subcommand - Table output with colored signals + JSON output support - Cache/offline/stale-cache fallback (same pattern as quote/history) - Fetch 1 year of daily data for SMA200 coverage (~250 trading days) - Add TechnicalReport, MacdSnapshot, VolumeSnapshot structs - Add 4 new unit tests + 3 integration tests (30 total passing)
This commit is contained in:
parent
9e0560f3c1
commit
9182f25a01
8 changed files with 723 additions and 6 deletions
2
src/analysis/mod.rs
Normal file
2
src/analysis/mod.rs
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
pub mod signals;
|
||||||
|
pub mod technical;
|
||||||
102
src/analysis/signals.rs
Normal file
102
src/analysis/signals.rs
Normal file
|
|
@ -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<f64>) -> 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<f64>, sma200: Option<f64>) -> 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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
212
src/analysis/technical.rs
Normal file
212
src/analysis/technical.rs
Normal file
|
|
@ -0,0 +1,212 @@
|
||||||
|
use serde::Serialize;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub struct MacdResult {
|
||||||
|
pub macd_line: Vec<Option<f64>>,
|
||||||
|
pub signal_line: Vec<Option<f64>>,
|
||||||
|
pub histogram: Vec<Option<f64>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn sma(data: &[f64], period: usize) -> Vec<Option<f64>> {
|
||||||
|
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<Option<f64>> {
|
||||||
|
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::<f64>() / 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<Option<f64>> {
|
||||||
|
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::<f64>() / 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<f64> {
|
||||||
|
if period == 0 || volumes.len() < period {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let start = volumes.len() - period;
|
||||||
|
let avg = volumes[start..].iter().sum::<f64>() / 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<f64> = (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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,11 +1,15 @@
|
||||||
use clap::{Args, Subcommand};
|
use clap::{Args, Subcommand};
|
||||||
|
|
||||||
|
use crate::analysis::signals::{self, Signal, TechnicalSignal};
|
||||||
|
use crate::analysis::technical;
|
||||||
use crate::api::MarketDataProvider;
|
use crate::api::MarketDataProvider;
|
||||||
use crate::api::types::{Interval, Period};
|
use crate::api::types::{Interval, Ohlc, Period};
|
||||||
use crate::cache::Cache;
|
use crate::cache::Cache;
|
||||||
use crate::config::IdxConfig;
|
use crate::config::IdxConfig;
|
||||||
use crate::error::IdxError;
|
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)]
|
#[derive(Debug, Args)]
|
||||||
#[command(about = "Stock data and analysis")]
|
#[command(about = "Stock data and analysis")]
|
||||||
|
|
@ -36,6 +40,14 @@ pub enum StocksSubcommand {
|
||||||
#[arg(long, value_enum, default_value_t = Interval::Day)]
|
#[arg(long, value_enum, default_value_t = Interval::Day)]
|
||||||
interval: Interval,
|
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(
|
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::<TechnicalReport>("technical", &resolved)?
|
||||||
|
{
|
||||||
|
return render_technical(&report, &config.output, config.no_color);
|
||||||
|
}
|
||||||
|
if offline {
|
||||||
|
let stale = cache
|
||||||
|
.get_stale::<TechnicalReport>("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::<TechnicalReport>("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<TechnicalReport, IdxError> {
|
||||||
|
let latest = history
|
||||||
|
.last()
|
||||||
|
.ok_or_else(|| IdxError::ParseError(format!("no history available for {symbol}")))?;
|
||||||
|
let closes: Vec<f64> = history.iter().map(|item| item.close as f64).collect();
|
||||||
|
let volumes: Vec<f64> = 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<f64>]) -> Option<f64> {
|
||||||
|
values.iter().rev().find_map(|value| *value)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn previous_value(values: &[Option<f64>]) -> Option<f64> {
|
||||||
|
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<f64> {
|
||||||
|
if period == 0 || values.len() < period {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let start = values.len() - period;
|
||||||
|
Some(values[start..].iter().sum::<f64>() / 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<Ohlc> = (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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
pub mod analysis;
|
||||||
mod api;
|
mod api;
|
||||||
mod cache;
|
mod cache;
|
||||||
mod cli;
|
mod cli;
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,11 @@
|
||||||
pub mod json;
|
pub mod json;
|
||||||
pub mod table;
|
pub mod table;
|
||||||
|
|
||||||
|
use chrono::NaiveDate;
|
||||||
use clap::ValueEnum;
|
use clap::ValueEnum;
|
||||||
use serde::Serialize;
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use crate::analysis::signals::TechnicalSignal;
|
||||||
use crate::api::types::{Ohlc, Quote};
|
use crate::api::types::{Ohlc, Quote};
|
||||||
use crate::error::IdxError;
|
use crate::error::IdxError;
|
||||||
|
|
||||||
|
|
@ -15,6 +17,34 @@ pub enum OutputFormat {
|
||||||
Json,
|
Json,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct TechnicalReport {
|
||||||
|
pub symbol: String,
|
||||||
|
pub as_of: NaiveDate,
|
||||||
|
pub current_price: i64,
|
||||||
|
pub sma20: Option<f64>,
|
||||||
|
pub sma50: Option<f64>,
|
||||||
|
pub sma200: Option<f64>,
|
||||||
|
pub rsi14: Option<f64>,
|
||||||
|
pub macd: MacdSnapshot,
|
||||||
|
pub volume: VolumeSnapshot,
|
||||||
|
pub signals: TechnicalSignal,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct MacdSnapshot {
|
||||||
|
pub line: Option<f64>,
|
||||||
|
pub signal: Option<f64>,
|
||||||
|
pub histogram: Option<f64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct VolumeSnapshot {
|
||||||
|
pub current: u64,
|
||||||
|
pub average20: Option<f64>,
|
||||||
|
pub ratio20: Option<f64>,
|
||||||
|
}
|
||||||
|
|
||||||
pub fn render_quotes(
|
pub fn render_quotes(
|
||||||
quotes: &[Quote],
|
quotes: &[Quote],
|
||||||
format: &OutputFormat,
|
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) {
|
pub fn emit_error(err: &IdxError, format: &OutputFormat) {
|
||||||
match format {
|
match format {
|
||||||
OutputFormat::Table => eprintln!("Error: {err}"),
|
OutputFormat::Table => eprintln!("Error: {err}"),
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,10 @@
|
||||||
use comfy_table::{Cell, Color, ContentArrangement, Table, presets::UTF8_FULL};
|
use comfy_table::{Cell, Color, ContentArrangement, Table, presets::UTF8_FULL};
|
||||||
use owo_colors::OwoColorize;
|
use owo_colors::OwoColorize;
|
||||||
|
|
||||||
|
use crate::analysis::signals::Signal;
|
||||||
use crate::api::types::{Ohlc, Quote};
|
use crate::api::types::{Ohlc, Quote};
|
||||||
use crate::error::IdxError;
|
use crate::error::IdxError;
|
||||||
|
use crate::output::TechnicalReport;
|
||||||
|
|
||||||
pub fn format_idr(value: i64) -> String {
|
pub fn format_idr(value: i64) -> String {
|
||||||
let chars: Vec<char> = value.to_string().chars().rev().collect();
|
let chars: Vec<char> = value.to_string().chars().rev().collect();
|
||||||
|
|
@ -94,13 +96,160 @@ pub fn print_history(symbol: &str, history: &[Ohlc]) -> Result<(), IdxError> {
|
||||||
Ok(())
|
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<f64>) -> String {
|
||||||
|
value
|
||||||
|
.map(|v| format_idr(v.round() as i64))
|
||||||
|
.unwrap_or_else(|| "-".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn format_float(value: Option<f64>, 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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::{format_idr, format_u64};
|
use super::{format_idr, format_signal, format_u64};
|
||||||
|
use crate::analysis::signals::Signal;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn formats_idr_numbers() {
|
fn formats_idr_numbers() {
|
||||||
assert_eq!(format_idr(9875), "9,875");
|
assert_eq!(format_idr(9875), "9,875");
|
||||||
assert_eq!(format_u64(1_215_200_000_000_000), "1,215,200,000,000,000");
|
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");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
52
tests/cli.rs
52
tests/cli.rs
|
|
@ -1,10 +1,10 @@
|
||||||
use std::fs;
|
use std::fs;
|
||||||
|
|
||||||
use assert_cmd::{Command, cargo::cargo_bin};
|
use assert_cmd::Command;
|
||||||
use predicates::prelude::*;
|
use predicates::prelude::*;
|
||||||
|
|
||||||
fn bin() -> Command {
|
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 {
|
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"));
|
.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]
|
#[test]
|
||||||
fn config_path_prints_path() {
|
fn config_path_prints_path() {
|
||||||
bin()
|
bin()
|
||||||
|
|
@ -129,6 +153,30 @@ fn serves_stale_cache_on_provider_failure_with_warning() {
|
||||||
.stderr(predicate::str::contains("warning: network failed"));
|
.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]
|
#[test]
|
||||||
fn invalid_symbol_returns_non_zero() {
|
fn invalid_symbol_returns_non_zero() {
|
||||||
bin()
|
bin()
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue