mirror of
https://github.com/0xrsydn/idx-cli.git
synced 2026-11-07 09:45:36 +00:00
refactor: split into idx-core library and idx-cli binary workspace
Phase 2 of docs/DESIGN.md, no behavior change: - crates/idx-core (library): api, analysis, cache, config file handling and provider selection, curl_impersonate, error, ownership, runtime, plus the shared test fixtures. clap derives are behind an optional `clap` feature so the library does not require clap. - crates/idx-cli (binary `idx`): cli/, output/, and IdxConfig (output format and color resolution). Package and binary names are unchanged. - config.rs is split: file read/write and validation move to core; a CLI test keeps core's OUTPUT_VALUES in sync with OutputSetting. - ownership::db::open_db/db_path drop their unused IdxConfig argument. - One workspace version; shared dependency versions; no third-party version changes in Cargo.lock. LICENSE is symlinked into each crate. - flake: workspace version, new source paths, and a per-crate package check (idx-cli is built from its tarball against packaged idx-core, since idx-core is not on crates.io yet). CI cache key, release path filter, and the golden whitespace-hook exclusion follow the new layout. Parity: all 143 golden outputs match byte for byte without regeneration; 157 core + 17 CLI unit tests (173 before plus the new OUTPUT_VALUES sync test), 62 CLI tests pass. Live A/B against main: 16/16 stocks cases identical; ownership discover got IDX 403 on both (exit 4 vs 1 is the phase 1 exit-code change). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
22b6dabaeb
commit
15947de51a
238 changed files with 544 additions and 375 deletions
468
crates/idx-core/src/analysis/fundamental.rs
Normal file
468
crates/idx-core/src/analysis/fundamental.rs
Normal file
|
|
@ -0,0 +1,468 @@
|
|||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::api::types::Fundamentals;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct GrowthReport {
|
||||
pub revenue_growth: Option<f64>,
|
||||
pub earnings_growth: Option<f64>,
|
||||
pub revenue_growth_pct: Option<f64>,
|
||||
pub earnings_growth_pct: Option<f64>,
|
||||
pub revenue_signal: String,
|
||||
pub earnings_signal: String,
|
||||
pub overall_signal: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ValuationReport {
|
||||
pub pe_trailing: Option<f64>,
|
||||
pub pe_forward: Option<f64>,
|
||||
pub pb: Option<f64>,
|
||||
pub roe: Option<f64>,
|
||||
pub roe_pct: Option<f64>,
|
||||
pub net_margin: Option<f64>,
|
||||
pub net_margin_pct: Option<f64>,
|
||||
pub ev_ebitda: Option<f64>,
|
||||
pub pe_signal: String,
|
||||
pub pb_signal: String,
|
||||
pub roe_signal: String,
|
||||
pub margin_signal: String,
|
||||
pub ev_ebitda_signal: String,
|
||||
pub overall_signal: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct RiskReport {
|
||||
pub debt_to_equity: Option<f64>,
|
||||
pub current_ratio: Option<f64>,
|
||||
pub roa: Option<f64>,
|
||||
pub roa_pct: Option<f64>,
|
||||
pub de_signal: String,
|
||||
pub current_ratio_signal: String,
|
||||
pub overall_signal: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct FundamentalReport {
|
||||
pub symbol: String,
|
||||
pub growth: GrowthReport,
|
||||
pub valuation: ValuationReport,
|
||||
pub risk: RiskReport,
|
||||
pub overall_signal: String,
|
||||
}
|
||||
|
||||
pub fn analyze_growth(fundamentals: &Fundamentals) -> GrowthReport {
|
||||
let revenue_signal = growth_signal(fundamentals.revenue_growth);
|
||||
let earnings_signal = growth_signal(fundamentals.earnings_growth);
|
||||
let positive = ["strong", "moderate"];
|
||||
let negative = ["declining", "contracting"];
|
||||
|
||||
let overall_signal = if revenue_signal == "no data" && earnings_signal == "no data" {
|
||||
"no data"
|
||||
} else if positive.contains(&revenue_signal) && positive.contains(&earnings_signal) {
|
||||
"growing"
|
||||
} else if negative.contains(&revenue_signal) && negative.contains(&earnings_signal) {
|
||||
"shrinking"
|
||||
} else if revenue_signal == "no data" || earnings_signal == "no data" {
|
||||
let other = if earnings_signal == "no data" {
|
||||
revenue_signal
|
||||
} else {
|
||||
earnings_signal
|
||||
};
|
||||
if negative.contains(&other) {
|
||||
"mixed"
|
||||
} else {
|
||||
"incomplete data"
|
||||
}
|
||||
} else {
|
||||
"mixed"
|
||||
};
|
||||
|
||||
GrowthReport {
|
||||
revenue_growth: fundamentals.revenue_growth,
|
||||
earnings_growth: fundamentals.earnings_growth,
|
||||
revenue_growth_pct: ratio_pct(fundamentals.revenue_growth),
|
||||
earnings_growth_pct: ratio_pct(fundamentals.earnings_growth),
|
||||
revenue_signal: revenue_signal.to_string(),
|
||||
earnings_signal: earnings_signal.to_string(),
|
||||
overall_signal: overall_signal.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn analyze_valuation(fundamentals: &Fundamentals) -> ValuationReport {
|
||||
let ev_ebitda = match (fundamentals.enterprise_value, fundamentals.ebitda) {
|
||||
(Some(ev), Some(ebitda)) if ebitda > 0 => Some(round2(ev as f64 / ebitda as f64)),
|
||||
_ => None,
|
||||
};
|
||||
|
||||
let pe_signal = pe_signal(fundamentals.trailing_pe);
|
||||
let pb_signal = pb_signal(fundamentals.price_to_book);
|
||||
let ev_ebitda_signal = ev_ebitda_signal(ev_ebitda);
|
||||
let cheap = ["deep value", "undervalued"];
|
||||
let rich = ["premium", "expensive"];
|
||||
let price_signals: Vec<&str> = [pe_signal, pb_signal, ev_ebitda_signal]
|
||||
.into_iter()
|
||||
.filter(|signal| *signal != "no data")
|
||||
.collect();
|
||||
|
||||
let overall_signal = if price_signals.is_empty() {
|
||||
"no data"
|
||||
} else {
|
||||
let cheap_count = price_signals
|
||||
.iter()
|
||||
.filter(|signal| cheap.contains(signal))
|
||||
.count();
|
||||
let rich_count = price_signals
|
||||
.iter()
|
||||
.filter(|signal| rich.contains(signal))
|
||||
.count();
|
||||
|
||||
if cheap_count > price_signals.len() / 2 {
|
||||
"undervalued"
|
||||
} else if rich_count > price_signals.len() / 2 {
|
||||
"expensive"
|
||||
} else {
|
||||
"fairly valued"
|
||||
}
|
||||
};
|
||||
|
||||
ValuationReport {
|
||||
pe_trailing: fundamentals.trailing_pe,
|
||||
pe_forward: fundamentals.forward_pe,
|
||||
pb: fundamentals.price_to_book,
|
||||
roe: fundamentals.return_on_equity,
|
||||
roe_pct: ratio_pct(fundamentals.return_on_equity),
|
||||
net_margin: fundamentals.profit_margins,
|
||||
net_margin_pct: ratio_pct(fundamentals.profit_margins),
|
||||
ev_ebitda,
|
||||
pe_signal: pe_signal.to_string(),
|
||||
pb_signal: pb_signal.to_string(),
|
||||
roe_signal: roe_signal(fundamentals.return_on_equity).to_string(),
|
||||
margin_signal: margin_signal(fundamentals.profit_margins).to_string(),
|
||||
ev_ebitda_signal: ev_ebitda_signal.to_string(),
|
||||
overall_signal: overall_signal.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn analyze_risk(fundamentals: &Fundamentals) -> RiskReport {
|
||||
let de_signal = de_signal(fundamentals.debt_to_equity);
|
||||
let current_ratio_signal = cr_signal(fundamentals.current_ratio);
|
||||
|
||||
let overall_signal = if de_signal == "no data" && current_ratio_signal == "no data" {
|
||||
"no data"
|
||||
} else if de_signal == "no data" || current_ratio_signal == "no data" {
|
||||
"incomplete data"
|
||||
} else if de_signal == "highly leveraged" || current_ratio_signal == "weak" {
|
||||
"high risk"
|
||||
} else if de_signal == "conservative" && matches!(current_ratio_signal, "strong" | "adequate") {
|
||||
"low risk"
|
||||
} else {
|
||||
"moderate risk"
|
||||
};
|
||||
|
||||
RiskReport {
|
||||
debt_to_equity: fundamentals.debt_to_equity,
|
||||
current_ratio: fundamentals.current_ratio,
|
||||
roa: fundamentals.return_on_assets,
|
||||
roa_pct: ratio_pct(fundamentals.return_on_assets),
|
||||
de_signal: de_signal.to_string(),
|
||||
current_ratio_signal: current_ratio_signal.to_string(),
|
||||
overall_signal: overall_signal.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn analyze_fundamental(symbol: &str, fundamentals: &Fundamentals) -> FundamentalReport {
|
||||
let growth = analyze_growth(fundamentals);
|
||||
let valuation = analyze_valuation(fundamentals);
|
||||
let risk = analyze_risk(fundamentals);
|
||||
|
||||
let overall_signal = if growth.overall_signal == "growing"
|
||||
&& valuation.overall_signal != "expensive"
|
||||
&& risk.overall_signal != "high risk"
|
||||
{
|
||||
"healthy"
|
||||
} else if growth.overall_signal == "shrinking" && risk.overall_signal == "high risk" {
|
||||
"weak"
|
||||
} else if [
|
||||
&growth.overall_signal,
|
||||
&valuation.overall_signal,
|
||||
&risk.overall_signal,
|
||||
]
|
||||
.into_iter()
|
||||
.all(|signal| *signal == "no data")
|
||||
{
|
||||
"no data"
|
||||
} else {
|
||||
"mixed"
|
||||
};
|
||||
|
||||
FundamentalReport {
|
||||
symbol: symbol.to_string(),
|
||||
growth,
|
||||
valuation,
|
||||
risk,
|
||||
overall_signal: overall_signal.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn growth_signal(value: Option<f64>) -> &'static str {
|
||||
let Some(value) = value else {
|
||||
return "no data";
|
||||
};
|
||||
if value >= 0.20 {
|
||||
"strong"
|
||||
} else if value >= 0.10 {
|
||||
"moderate"
|
||||
} else if value >= 0.0 {
|
||||
"slow"
|
||||
} else if value >= -0.10 {
|
||||
"declining"
|
||||
} else {
|
||||
"contracting"
|
||||
}
|
||||
}
|
||||
|
||||
fn pe_signal(value: Option<f64>) -> &'static str {
|
||||
let Some(value) = value else {
|
||||
return "no data";
|
||||
};
|
||||
if value <= 0.0 {
|
||||
"no data"
|
||||
} else if value < 8.0 {
|
||||
"deep value"
|
||||
} else if value < 15.0 {
|
||||
"undervalued"
|
||||
} else if value < 25.0 {
|
||||
"fairly valued"
|
||||
} else if value < 40.0 {
|
||||
"premium"
|
||||
} else {
|
||||
"expensive"
|
||||
}
|
||||
}
|
||||
|
||||
fn pb_signal(value: Option<f64>) -> &'static str {
|
||||
let Some(value) = value else {
|
||||
return "no data";
|
||||
};
|
||||
if value <= 0.0 {
|
||||
"no data"
|
||||
} else if value < 1.0 {
|
||||
"deep value"
|
||||
} else if value < 2.0 {
|
||||
"undervalued"
|
||||
} else if value < 4.0 {
|
||||
"fairly valued"
|
||||
} else {
|
||||
"expensive"
|
||||
}
|
||||
}
|
||||
|
||||
fn roe_signal(value: Option<f64>) -> &'static str {
|
||||
let Some(value) = value else {
|
||||
return "no data";
|
||||
};
|
||||
if value >= 0.20 {
|
||||
"excellent"
|
||||
} else if value >= 0.15 {
|
||||
"strong"
|
||||
} else if value >= 0.10 {
|
||||
"adequate"
|
||||
} else if value >= 0.0 {
|
||||
"weak"
|
||||
} else {
|
||||
"negative"
|
||||
}
|
||||
}
|
||||
|
||||
fn margin_signal(value: Option<f64>) -> &'static str {
|
||||
let Some(value) = value else {
|
||||
return "no data";
|
||||
};
|
||||
if value >= 0.20 {
|
||||
"excellent"
|
||||
} else if value >= 0.10 {
|
||||
"healthy"
|
||||
} else if value >= 0.05 {
|
||||
"adequate"
|
||||
} else if value >= 0.0 {
|
||||
"thin"
|
||||
} else {
|
||||
"negative"
|
||||
}
|
||||
}
|
||||
|
||||
fn ev_ebitda_signal(value: Option<f64>) -> &'static str {
|
||||
let Some(value) = value else {
|
||||
return "no data";
|
||||
};
|
||||
if value <= 0.0 {
|
||||
"no data"
|
||||
} else if value < 8.0 {
|
||||
"undervalued"
|
||||
} else if value < 14.0 {
|
||||
"fairly valued"
|
||||
} else if value < 20.0 {
|
||||
"premium"
|
||||
} else {
|
||||
"expensive"
|
||||
}
|
||||
}
|
||||
|
||||
fn de_signal(value: Option<f64>) -> &'static str {
|
||||
let Some(value) = value else {
|
||||
return "no data";
|
||||
};
|
||||
if value < 0.0 {
|
||||
"negative equity"
|
||||
} else if value < 50.0 {
|
||||
"conservative"
|
||||
} else if value < 100.0 {
|
||||
"moderate"
|
||||
} else if value < 200.0 {
|
||||
"leveraged"
|
||||
} else {
|
||||
"highly leveraged"
|
||||
}
|
||||
}
|
||||
|
||||
fn cr_signal(value: Option<f64>) -> &'static str {
|
||||
let Some(value) = value else {
|
||||
return "no data";
|
||||
};
|
||||
if value >= 2.0 {
|
||||
"strong"
|
||||
} else if value >= 1.5 {
|
||||
"adequate"
|
||||
} else if value >= 1.0 {
|
||||
"tight"
|
||||
} else {
|
||||
"weak"
|
||||
}
|
||||
}
|
||||
|
||||
fn ratio_pct(value: Option<f64>) -> Option<f64> {
|
||||
value.map(|value| round2(value * 100.0))
|
||||
}
|
||||
|
||||
fn round2(value: f64) -> f64 {
|
||||
(value * 100.0).round() / 100.0
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
Fundamentals, analyze_fundamental, analyze_growth, analyze_risk, analyze_valuation,
|
||||
cr_signal, de_signal, ev_ebitda_signal, growth_signal, margin_signal, pb_signal, pe_signal,
|
||||
roe_signal,
|
||||
};
|
||||
|
||||
fn sample_fundamentals() -> Fundamentals {
|
||||
Fundamentals {
|
||||
trailing_pe: Some(12.5),
|
||||
forward_pe: Some(11.0),
|
||||
price_to_book: Some(1.8),
|
||||
return_on_equity: Some(0.18),
|
||||
profit_margins: Some(0.12),
|
||||
return_on_assets: Some(0.06),
|
||||
revenue_growth: Some(0.12),
|
||||
earnings_growth: Some(0.22),
|
||||
debt_to_equity: Some(40.0),
|
||||
current_ratio: Some(1.6),
|
||||
enterprise_value: Some(120),
|
||||
ebitda: Some(15),
|
||||
market_cap: Some(100),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn growth_signal_thresholds_match_python() {
|
||||
assert_eq!(growth_signal(None), "no data");
|
||||
assert_eq!(growth_signal(Some(0.20)), "strong");
|
||||
assert_eq!(growth_signal(Some(0.10)), "moderate");
|
||||
assert_eq!(growth_signal(Some(0.0)), "slow");
|
||||
assert_eq!(growth_signal(Some(-0.10)), "declining");
|
||||
assert_eq!(growth_signal(Some(-0.11)), "contracting");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn valuation_signal_thresholds_match_python() {
|
||||
assert_eq!(pe_signal(None), "no data");
|
||||
assert_eq!(pe_signal(Some(7.9)), "deep value");
|
||||
assert_eq!(pe_signal(Some(14.9)), "undervalued");
|
||||
assert_eq!(pe_signal(Some(24.9)), "fairly valued");
|
||||
assert_eq!(pe_signal(Some(39.9)), "premium");
|
||||
assert_eq!(pe_signal(Some(40.0)), "expensive");
|
||||
|
||||
assert_eq!(pb_signal(Some(0.9)), "deep value");
|
||||
assert_eq!(pb_signal(Some(1.9)), "undervalued");
|
||||
assert_eq!(pb_signal(Some(3.9)), "fairly valued");
|
||||
assert_eq!(pb_signal(Some(4.0)), "expensive");
|
||||
|
||||
assert_eq!(roe_signal(Some(0.20)), "excellent");
|
||||
assert_eq!(roe_signal(Some(0.15)), "strong");
|
||||
assert_eq!(roe_signal(Some(0.10)), "adequate");
|
||||
assert_eq!(roe_signal(Some(0.0)), "weak");
|
||||
assert_eq!(roe_signal(Some(-0.01)), "negative");
|
||||
|
||||
assert_eq!(margin_signal(Some(0.20)), "excellent");
|
||||
assert_eq!(margin_signal(Some(0.10)), "healthy");
|
||||
assert_eq!(margin_signal(Some(0.05)), "adequate");
|
||||
assert_eq!(margin_signal(Some(0.0)), "thin");
|
||||
assert_eq!(margin_signal(Some(-0.01)), "negative");
|
||||
|
||||
assert_eq!(ev_ebitda_signal(Some(7.9)), "undervalued");
|
||||
assert_eq!(ev_ebitda_signal(Some(13.9)), "fairly valued");
|
||||
assert_eq!(ev_ebitda_signal(Some(19.9)), "premium");
|
||||
assert_eq!(ev_ebitda_signal(Some(20.0)), "expensive");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn risk_signal_thresholds_match_python() {
|
||||
assert_eq!(de_signal(None), "no data");
|
||||
assert_eq!(de_signal(Some(-1.0)), "negative equity");
|
||||
assert_eq!(de_signal(Some(49.9)), "conservative");
|
||||
assert_eq!(de_signal(Some(99.9)), "moderate");
|
||||
assert_eq!(de_signal(Some(199.9)), "leveraged");
|
||||
assert_eq!(de_signal(Some(200.0)), "highly leveraged");
|
||||
|
||||
assert_eq!(cr_signal(None), "no data");
|
||||
assert_eq!(cr_signal(Some(2.0)), "strong");
|
||||
assert_eq!(cr_signal(Some(1.5)), "adequate");
|
||||
assert_eq!(cr_signal(Some(1.0)), "tight");
|
||||
assert_eq!(cr_signal(Some(0.99)), "weak");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn report_overalls_match_python_logic() {
|
||||
let fundamentals = sample_fundamentals();
|
||||
|
||||
let growth = analyze_growth(&fundamentals);
|
||||
assert_eq!(growth.overall_signal, "growing");
|
||||
|
||||
let valuation = analyze_valuation(&fundamentals);
|
||||
assert_eq!(valuation.overall_signal, "undervalued");
|
||||
assert_eq!(valuation.ev_ebitda, Some(8.0));
|
||||
|
||||
let risk = analyze_risk(&fundamentals);
|
||||
assert_eq!(risk.overall_signal, "low risk");
|
||||
|
||||
let fundamental = analyze_fundamental("BBCA.JK", &fundamentals);
|
||||
assert_eq!(fundamental.overall_signal, "healthy");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn incomplete_growth_and_high_risk_paths_match_python_logic() {
|
||||
let mut fundamentals = sample_fundamentals();
|
||||
fundamentals.revenue_growth = None;
|
||||
fundamentals.earnings_growth = Some(0.05);
|
||||
fundamentals.debt_to_equity = Some(250.0);
|
||||
fundamentals.current_ratio = Some(0.9);
|
||||
|
||||
let growth = analyze_growth(&fundamentals);
|
||||
assert_eq!(growth.overall_signal, "incomplete data");
|
||||
|
||||
let risk = analyze_risk(&fundamentals);
|
||||
assert_eq!(risk.overall_signal, "high risk");
|
||||
}
|
||||
}
|
||||
3
crates/idx-core/src/analysis/mod.rs
Normal file
3
crates/idx-core/src/analysis/mod.rs
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
pub mod fundamental;
|
||||
pub mod signals;
|
||||
pub mod technical;
|
||||
102
crates/idx-core/src/analysis/signals.rs
Normal file
102
crates/idx-core/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
crates/idx-core/src/analysis/technical.rs
Normal file
212
crates/idx-core/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);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue