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:
Ciphercat 2026-03-05 21:27:38 +00:00
commit 9182f25a01
8 changed files with 723 additions and 6 deletions

View file

@ -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()