mirror of
https://github.com/0xrsydn/idx-cli.git
synced 2026-08-07 01:33:52 +00:00
feat(fundamental): add fundamental analysis suite (growth, valuation, risk, compare)
- Add Fundamentals type and MarketDataProvider::fundamentals() trait method - Add Yahoo quoteSummary endpoint parser (/v10/finance/quoteSummary) - Add analysis/fundamental.rs with exact signal thresholds ported from idx-mcp: - GrowthReport (revenue/earnings growth with signals) - ValuationReport (PE, PB, ROE, margin, EV/EBITDA with signals) - RiskReport (D/E, current ratio, ROA with signals) - FundamentalReport (composite with overall health signal) - Wire CLI subcommands: stocks growth/valuation/risk/fundamental/compare - Add table + JSON rendering for all report types - Add mock fixture (quotesummary_bbca.json) - 41 tests passing (22 unit + 19 integration) - Known: Yahoo quoteSummary returns 401 from datacenter IPs (needs crumb auth)
This commit is contained in:
parent
3e5869af67
commit
0d5b4b4755
10 changed files with 1459 additions and 32 deletions
120
tests/cli.rs
120
tests/cli.rs
|
|
@ -1,4 +1,5 @@
|
|||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use assert_cmd::Command;
|
||||
use predicates::prelude::*;
|
||||
|
|
@ -7,21 +8,38 @@ fn bin() -> Command {
|
|||
Command::new(assert_cmd::cargo::cargo_bin!("idx-cli"))
|
||||
}
|
||||
|
||||
fn test_env_dir(name: &str) -> std::path::PathBuf {
|
||||
fn test_env_dir(name: &str) -> PathBuf {
|
||||
let dir = std::env::temp_dir().join(format!("idx-cli-it-{name}-{}", std::process::id()));
|
||||
let _ = fs::remove_dir_all(&dir);
|
||||
fs::create_dir_all(&dir).expect("create temp dir");
|
||||
dir
|
||||
}
|
||||
|
||||
fn bin_with_root(root: &Path) -> Command {
|
||||
let config_home = root.join("config");
|
||||
let cache_home = root.join("cache");
|
||||
fs::create_dir_all(&config_home).expect("create config dir");
|
||||
fs::create_dir_all(&cache_home).expect("create cache dir");
|
||||
|
||||
let mut cmd = bin();
|
||||
cmd.env("XDG_CONFIG_HOME", &config_home);
|
||||
cmd.env("XDG_CACHE_HOME", &cache_home);
|
||||
cmd
|
||||
}
|
||||
|
||||
fn test_bin(name: &str) -> Command {
|
||||
let root = test_env_dir(name);
|
||||
bin_with_root(&root)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn help_works() {
|
||||
bin().arg("--help").assert().success();
|
||||
test_bin("help").arg("--help").assert().success();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn version_prints_cargo_version() {
|
||||
bin()
|
||||
test_bin("version")
|
||||
.arg("version")
|
||||
.assert()
|
||||
.success()
|
||||
|
|
@ -30,7 +48,7 @@ fn version_prints_cargo_version() {
|
|||
|
||||
#[test]
|
||||
fn quote_table_with_mock_contains_expected_columns() {
|
||||
bin()
|
||||
test_bin("quote-table")
|
||||
.env("IDX_USE_MOCK_PROVIDER", "1")
|
||||
.env("IDX_CACHE_QUOTE_TTL", "0")
|
||||
.args(["stocks", "quote", "BBCA"])
|
||||
|
|
@ -43,7 +61,7 @@ fn quote_table_with_mock_contains_expected_columns() {
|
|||
|
||||
#[test]
|
||||
fn quote_with_mock_provider_json() {
|
||||
bin()
|
||||
test_bin("quote-json")
|
||||
.env("IDX_USE_MOCK_PROVIDER", "1")
|
||||
.args(["-o", "json", "stocks", "quote", "BBCA"])
|
||||
.assert()
|
||||
|
|
@ -54,7 +72,7 @@ fn quote_with_mock_provider_json() {
|
|||
|
||||
#[test]
|
||||
fn history_with_mock_provider_table_contains_columns() {
|
||||
bin()
|
||||
test_bin("history-table")
|
||||
.env("IDX_USE_MOCK_PROVIDER", "1")
|
||||
.args(["stocks", "history", "BBCA", "--period", "1mo"])
|
||||
.assert()
|
||||
|
|
@ -66,7 +84,7 @@ fn history_with_mock_provider_table_contains_columns() {
|
|||
|
||||
#[test]
|
||||
fn technical_with_mock_provider_table_contains_expected_rows() {
|
||||
bin()
|
||||
test_bin("technical-table")
|
||||
.env("IDX_USE_MOCK_PROVIDER", "1")
|
||||
.args(["stocks", "technical", "BBCA"])
|
||||
.assert()
|
||||
|
|
@ -78,7 +96,7 @@ fn technical_with_mock_provider_table_contains_expected_rows() {
|
|||
|
||||
#[test]
|
||||
fn technical_with_mock_provider_json_contains_fields() {
|
||||
bin()
|
||||
test_bin("technical-json")
|
||||
.env("IDX_USE_MOCK_PROVIDER", "1")
|
||||
.args(["-o", "json", "stocks", "technical", "BBCA"])
|
||||
.assert()
|
||||
|
|
@ -88,9 +106,77 @@ fn technical_with_mock_provider_json_contains_fields() {
|
|||
.stdout(predicate::str::contains("\"signals\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn growth_with_mock_provider_table_contains_expected_rows() {
|
||||
test_bin("growth-table")
|
||||
.env("IDX_USE_MOCK_PROVIDER", "1")
|
||||
.args(["stocks", "growth", "BBCA"])
|
||||
.assert()
|
||||
.success()
|
||||
.stdout(predicate::str::contains("Growth Analysis"))
|
||||
.stdout(predicate::str::contains("Revenue Growth"))
|
||||
.stdout(predicate::str::contains("Overall"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn growth_with_mock_provider_json_contains_fields() {
|
||||
test_bin("growth-json")
|
||||
.env("IDX_USE_MOCK_PROVIDER", "1")
|
||||
.args(["-o", "json", "stocks", "growth", "BBCA"])
|
||||
.assert()
|
||||
.success()
|
||||
.stdout(predicate::str::contains("\"revenue_growth\""))
|
||||
.stdout(predicate::str::contains("\"overall_signal\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn valuation_with_mock_provider_table_contains_expected_rows() {
|
||||
test_bin("valuation-table")
|
||||
.env("IDX_USE_MOCK_PROVIDER", "1")
|
||||
.args(["stocks", "valuation", "BBCA"])
|
||||
.assert()
|
||||
.success()
|
||||
.stdout(predicate::str::contains("Valuation"))
|
||||
.stdout(predicate::str::contains("P/E"))
|
||||
.stdout(predicate::str::contains("Overall"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn risk_with_mock_provider_table_contains_expected_rows() {
|
||||
test_bin("risk-table")
|
||||
.env("IDX_USE_MOCK_PROVIDER", "1")
|
||||
.args(["stocks", "risk", "BBCA"])
|
||||
.assert()
|
||||
.success()
|
||||
.stdout(predicate::str::contains("Risk"))
|
||||
.stdout(predicate::str::contains("Debt/Equity"))
|
||||
.stdout(predicate::str::contains("Overall"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fundamental_with_mock_provider_table_contains_expected_rows() {
|
||||
test_bin("fundamental-table")
|
||||
.env("IDX_USE_MOCK_PROVIDER", "1")
|
||||
.args(["stocks", "fundamental", "BBCA"])
|
||||
.assert()
|
||||
.success()
|
||||
.stdout(predicate::str::contains("Fundamental"))
|
||||
.stdout(predicate::str::contains("Overall"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compare_with_mock_provider_table_contains_resolved_symbol() {
|
||||
test_bin("compare-table")
|
||||
.env("IDX_USE_MOCK_PROVIDER", "1")
|
||||
.args(["stocks", "compare", "BBCA,BBRI"])
|
||||
.assert()
|
||||
.success()
|
||||
.stdout(predicate::str::contains("BBCA.JK"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_path_prints_path() {
|
||||
bin()
|
||||
test_bin("config-path")
|
||||
.args(["config", "path"])
|
||||
.assert()
|
||||
.success()
|
||||
|
|
@ -102,7 +188,7 @@ fn config_init_creates_file() {
|
|||
let root = test_env_dir("config-init");
|
||||
let config_home = root.join("cfg");
|
||||
|
||||
bin()
|
||||
bin_with_root(&root)
|
||||
.env("XDG_CONFIG_HOME", &config_home)
|
||||
.args(["config", "init"])
|
||||
.assert()
|
||||
|
|
@ -116,13 +202,13 @@ fn cache_info_and_clear_do_not_crash() {
|
|||
let root = test_env_dir("cache");
|
||||
let cache_home = root.join("cache");
|
||||
|
||||
bin()
|
||||
bin_with_root(&root)
|
||||
.env("XDG_CACHE_HOME", &cache_home)
|
||||
.args(["cache", "info"])
|
||||
.assert()
|
||||
.success();
|
||||
|
||||
bin()
|
||||
bin_with_root(&root)
|
||||
.env("XDG_CACHE_HOME", &cache_home)
|
||||
.args(["cache", "clear"])
|
||||
.assert()
|
||||
|
|
@ -134,7 +220,7 @@ fn serves_stale_cache_on_provider_failure_with_warning() {
|
|||
let root = test_env_dir("stale");
|
||||
let cache_home = root.join("cache");
|
||||
|
||||
bin()
|
||||
bin_with_root(&root)
|
||||
.env("XDG_CACHE_HOME", &cache_home)
|
||||
.env("IDX_USE_MOCK_PROVIDER", "1")
|
||||
.env("IDX_CACHE_QUOTE_TTL", "0")
|
||||
|
|
@ -142,7 +228,7 @@ fn serves_stale_cache_on_provider_failure_with_warning() {
|
|||
.assert()
|
||||
.success();
|
||||
|
||||
bin()
|
||||
bin_with_root(&root)
|
||||
.env("XDG_CACHE_HOME", &cache_home)
|
||||
.env("IDX_USE_MOCK_PROVIDER", "1")
|
||||
.env("IDX_CACHE_QUOTE_TTL", "0")
|
||||
|
|
@ -158,7 +244,7 @@ fn technical_serves_stale_cache_on_provider_failure_with_warning() {
|
|||
let root = test_env_dir("technical-stale");
|
||||
let cache_home = root.join("cache");
|
||||
|
||||
bin()
|
||||
bin_with_root(&root)
|
||||
.env("XDG_CACHE_HOME", &cache_home)
|
||||
.env("IDX_USE_MOCK_PROVIDER", "1")
|
||||
.env("IDX_CACHE_QUOTE_TTL", "0")
|
||||
|
|
@ -166,7 +252,7 @@ fn technical_serves_stale_cache_on_provider_failure_with_warning() {
|
|||
.assert()
|
||||
.success();
|
||||
|
||||
bin()
|
||||
bin_with_root(&root)
|
||||
.env("XDG_CACHE_HOME", &cache_home)
|
||||
.env("IDX_USE_MOCK_PROVIDER", "1")
|
||||
.env("IDX_CACHE_QUOTE_TTL", "0")
|
||||
|
|
@ -179,7 +265,7 @@ fn technical_serves_stale_cache_on_provider_failure_with_warning() {
|
|||
|
||||
#[test]
|
||||
fn invalid_symbol_returns_non_zero() {
|
||||
bin()
|
||||
test_bin("invalid-symbol")
|
||||
.env("IDX_USE_MOCK_PROVIDER", "1")
|
||||
.env("IDX_MOCK_ERROR", "1")
|
||||
.args(["stocks", "quote", "INVALID"])
|
||||
|
|
|
|||
82
tests/fixtures/quotesummary_bbca.json
vendored
Normal file
82
tests/fixtures/quotesummary_bbca.json
vendored
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
{
|
||||
"quoteSummary": {
|
||||
"result": [
|
||||
{
|
||||
"defaultKeyStatistics": {
|
||||
"forwardPE": {
|
||||
"raw": 23.1,
|
||||
"fmt": "23.10"
|
||||
},
|
||||
"priceToBook": {
|
||||
"raw": 4.6,
|
||||
"fmt": "4.60"
|
||||
},
|
||||
"enterpriseValue": {
|
||||
"raw": 1245000000000000,
|
||||
"fmt": "1.25T"
|
||||
},
|
||||
"trailingEps": {
|
||||
"raw": 384.25,
|
||||
"fmt": "384.25"
|
||||
},
|
||||
"forwardEps": {
|
||||
"raw": 410.18,
|
||||
"fmt": "410.18"
|
||||
}
|
||||
},
|
||||
"financialData": {
|
||||
"trailingPE": {
|
||||
"raw": 25.4,
|
||||
"fmt": "25.40"
|
||||
},
|
||||
"marketCap": {
|
||||
"raw": 1215200000000000,
|
||||
"fmt": "1.22T"
|
||||
},
|
||||
"currentPrice": {
|
||||
"raw": 9875,
|
||||
"fmt": "9,875.00"
|
||||
},
|
||||
"returnOnEquity": {
|
||||
"raw": 0.202,
|
||||
"fmt": "20.20%"
|
||||
},
|
||||
"returnOnAssets": {
|
||||
"raw": 0.038,
|
||||
"fmt": "3.80%"
|
||||
},
|
||||
"profitMargins": {
|
||||
"raw": 0.385,
|
||||
"fmt": "38.50%"
|
||||
},
|
||||
"revenueGrowth": {
|
||||
"raw": 0.118,
|
||||
"fmt": "11.80%"
|
||||
},
|
||||
"earningsGrowth": {
|
||||
"raw": 0.121,
|
||||
"fmt": "12.10%"
|
||||
},
|
||||
"debtToEquity": {
|
||||
"raw": 18.5,
|
||||
"fmt": "18.50"
|
||||
},
|
||||
"currentRatio": {
|
||||
"raw": 1.21,
|
||||
"fmt": "1.21"
|
||||
},
|
||||
"ebitda": {
|
||||
"raw": 58500000000000,
|
||||
"fmt": "58.50B"
|
||||
},
|
||||
"totalRevenue": {
|
||||
"raw": 147900000000000,
|
||||
"fmt": "147.90B"
|
||||
}
|
||||
},
|
||||
"incomeStatementHistory": {}
|
||||
}
|
||||
],
|
||||
"error": null
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue