use serde::{Deserialize, Serialize};
use crate::candle::Candle;
fn extract_closes(candles: &[Candle]) -> Vec<f64> {
candles.iter().map(|c| c.close).collect()
}
fn extract_highs(candles: &[Candle]) -> Vec<f64> {
candles.iter().map(|c| c.high).collect()
}
fn extract_lows(candles: &[Candle]) -> Vec<f64> {
candles.iter().map(|c| c.low).collect()
}
fn extract_volumes(candles: &[Candle]) -> Vec<f64> {
candles.iter().map(|c| c.volume).collect()
}
fn last_finite(values: &[f64]) -> Option<f64> {
values.last().filter(|v| v.is_finite()).copied()
}
fn to_option_series(values: Vec<f64>) -> Vec<Option<f64>> {
values
.into_iter()
.map(|v| if v.is_finite() { Some(v) } else { None })
.collect()
}
#[derive(Serialize, Deserialize, Clone, Debug)]
#[serde(rename_all = "camelCase")]
pub struct TechnicalIndicators {
pub sma_20: Option<f64>,
pub sma_50: Option<f64>,
pub ema_12: Option<f64>,
pub ema_20: Option<f64>,
pub ema_26: Option<f64>,
pub ema_50: Option<f64>,
pub rsi_14: Option<f64>,
pub macd_line: Option<f64>,
pub macd_signal: Option<f64>,
pub macd_histogram: Option<f64>,
pub bb_upper: Option<f64>,
pub bb_middle: Option<f64>,
pub bb_lower: Option<f64>,
pub atr_14: Option<f64>,
pub adx_14: Option<f64>,
pub stoch_k: Option<f64>,
pub stoch_d: Option<f64>,
pub cci_20: Option<f64>,
pub williams_r_14: Option<f64>,
pub obv: Option<f64>,
pub mfi_14: Option<f64>,
pub roc_12: Option<f64>,
pub donchian_upper_20: Option<f64>,
pub donchian_lower_20: Option<f64>,
pub donchian_upper_10: Option<f64>,
pub donchian_lower_10: Option<f64>,
pub close_zscore_20: Option<f64>,
pub volume_zscore_20: Option<f64>,
pub hv_20: Option<f64>,
pub hv_60: Option<f64>,
pub kc_upper_20: Option<f64>,
pub kc_lower_20: Option<f64>,
pub supertrend_value: Option<f64>,
pub supertrend_direction: Option<f64>,
pub vwap: Option<f64>,
pub plus_di_14: Option<f64>,
pub minus_di_14: Option<f64>,
}
impl TechnicalIndicators {
pub fn empty() -> Self {
Self {
sma_20: None,
sma_50: None,
ema_12: None,
ema_20: None,
ema_26: None,
ema_50: None,
rsi_14: None,
macd_line: None,
macd_signal: None,
macd_histogram: None,
bb_upper: None,
bb_middle: None,
bb_lower: None,
atr_14: None,
adx_14: None,
stoch_k: None,
stoch_d: None,
cci_20: None,
williams_r_14: None,
obv: None,
mfi_14: None,
roc_12: None,
donchian_upper_20: None,
donchian_lower_20: None,
donchian_upper_10: None,
donchian_lower_10: None,
close_zscore_20: None,
volume_zscore_20: None,
hv_20: None,
hv_60: None,
kc_upper_20: None,
kc_lower_20: None,
supertrend_value: None,
supertrend_direction: None,
vwap: None,
plus_di_14: None,
minus_di_14: None,
}
}
}
#[deprecated(note = "Use hyper_ta::dynamic::calculate_snapshot() with TaEngine instead")]
pub fn calculate_indicators(candles: &[Candle]) -> TechnicalIndicators {
if candles.is_empty() {
return TechnicalIndicators::empty();
}
let sma_20 = compute_sma(candles, 20);
let sma_50 = compute_sma(candles, 50);
let ema_12 = compute_ema(candles, 12);
let ema_20 = compute_ema(candles, 20);
let ema_26 = compute_ema(candles, 26);
let ema_50 = compute_ema(candles, 50);
let rsi_14 = compute_rsi(candles, 14);
let (macd_line, macd_signal, macd_histogram) = compute_macd(candles, 12, 26, 9);
let (bb_upper, bb_middle, bb_lower) = compute_bollinger_bands(candles, 20, 2.0);
let atr_14 = compute_atr(candles, 14);
let adx_14 = compute_adx(candles, 14);
let (stoch_k, stoch_d) = compute_stochastic(candles, 14, 3, 3);
let cci_20 = compute_cci(candles, 20);
let williams_r_14 = compute_williams_r(candles, 14);
let obv = compute_obv(candles);
let mfi_14 = compute_mfi(candles, 14);
let roc_12 = compute_roc(candles, 12);
let (donchian_upper_20, donchian_lower_20) = compute_donchian(candles, 20);
let (donchian_upper_10, donchian_lower_10) = compute_donchian(candles, 10);
let close_zscore_20 = compute_zscore_close(candles, 20);
let volume_zscore_20 = compute_zscore_volume(candles, 20);
let hv_20 = compute_hv(candles, 20);
let hv_60 = compute_hv(candles, 60);
let (kc_upper_20, kc_lower_20) = compute_keltner(candles, 20, 1.5);
let (supertrend_value, supertrend_direction) = compute_supertrend(candles, 10, 3.0);
let vwap = compute_vwap(candles);
let (plus_di_14, minus_di_14) = compute_di(candles, 14);
TechnicalIndicators {
sma_20,
sma_50,
ema_12,
ema_20,
ema_26,
ema_50,
rsi_14,
macd_line,
macd_signal,
macd_histogram,
bb_upper,
bb_middle,
bb_lower,
atr_14,
adx_14,
stoch_k,
stoch_d,
cci_20,
williams_r_14,
obv,
mfi_14,
roc_12,
donchian_upper_20,
donchian_lower_20,
donchian_upper_10,
donchian_lower_10,
close_zscore_20,
volume_zscore_20,
hv_20,
hv_60,
kc_upper_20,
kc_lower_20,
supertrend_value,
supertrend_direction,
vwap,
plus_di_14,
minus_di_14,
}
}
fn compute_sma(candles: &[Candle], period: usize) -> Option<f64> {
if candles.len() < period {
return None;
}
let closes = extract_closes(candles);
let result = motosan_ta_math::indicators::sma(&closes, period);
last_finite(&result)
}
fn compute_ema(candles: &[Candle], period: usize) -> Option<f64> {
if candles.len() < period {
return None;
}
let closes = extract_closes(candles);
let result = motosan_ta_math::indicators::ema(&closes, period);
last_finite(&result)
}
fn compute_rsi(candles: &[Candle], period: usize) -> Option<f64> {
if candles.len() < period + 1 {
return None;
}
let closes = extract_closes(candles);
let result = motosan_ta_math::indicators::rsi(&closes, period);
last_finite(&result)
}
fn compute_macd(
candles: &[Candle],
fast: usize,
slow: usize,
signal: usize,
) -> (Option<f64>, Option<f64>, Option<f64>) {
if candles.len() < slow {
return (None, None, None);
}
let closes = extract_closes(candles);
let (line, sig, hist) = motosan_ta_math::indicators::macd(&closes, fast, slow, signal);
(last_finite(&line), last_finite(&sig), last_finite(&hist))
}
fn compute_bollinger_bands(
candles: &[Candle],
period: usize,
sigma: f64,
) -> (Option<f64>, Option<f64>, Option<f64>) {
if candles.len() < period {
return (None, None, None);
}
let closes = extract_closes(candles);
let (upper, middle, lower) =
motosan_ta_math::indicators::bollinger_bands(&closes, period, sigma);
(
last_finite(&upper),
last_finite(&middle),
last_finite(&lower),
)
}
fn compute_atr(candles: &[Candle], period: usize) -> Option<f64> {
if candles.len() < period + 1 {
return None;
}
let highs = extract_highs(candles);
let lows = extract_lows(candles);
let closes = extract_closes(candles);
let result = motosan_ta_math::indicators::atr(&highs, &lows, &closes, period);
last_finite(&result)
}
fn compute_adx(candles: &[Candle], period: usize) -> Option<f64> {
if candles.len() < period * 2 + 1 {
return None;
}
let highs = extract_highs(candles);
let lows = extract_lows(candles);
let closes = extract_closes(candles);
let result = motosan_ta_math::indicators::adx(&highs, &lows, &closes, period);
last_finite(&result)
}
fn compute_stochastic(
candles: &[Candle],
period: usize,
k_smooth: usize,
d_period: usize,
) -> (Option<f64>, Option<f64>) {
if candles.len() < period + k_smooth + d_period - 2 {
return (None, None);
}
let highs = extract_highs(candles);
let lows = extract_lows(candles);
let closes = extract_closes(candles);
let (raw_k, _) =
motosan_ta_math::indicators::stochastic(&highs, &lows, &closes, period, d_period);
let finite_k: Vec<f64> = raw_k.iter().copied().filter(|v| v.is_finite()).collect();
if finite_k.len() < k_smooth {
return (None, None);
}
let smoothed_k = motosan_ta_math::indicators::sma(&finite_k, k_smooth);
let finite_smoothed_k: Vec<f64> = smoothed_k
.iter()
.copied()
.filter(|v| v.is_finite())
.collect();
if finite_smoothed_k.is_empty() {
return (None, None);
}
let last_k = *finite_smoothed_k.last().unwrap();
if finite_smoothed_k.len() < d_period {
return (Some(last_k), None);
}
let d_window = &finite_smoothed_k[(finite_smoothed_k.len() - d_period)..];
let last_d = d_window.iter().sum::<f64>() / d_period as f64;
(Some(last_k), Some(last_d))
}
fn compute_cci(candles: &[Candle], period: usize) -> Option<f64> {
if candles.len() < period {
return None;
}
let highs = extract_highs(candles);
let lows = extract_lows(candles);
let closes = extract_closes(candles);
let result = motosan_ta_math::indicators::cci(&highs, &lows, &closes, period);
last_finite(&result)
}
fn compute_williams_r(candles: &[Candle], period: usize) -> Option<f64> {
if candles.len() < period {
return None;
}
let highs = extract_highs(candles);
let lows = extract_lows(candles);
let closes = extract_closes(candles);
let result = motosan_ta_math::indicators::williams_r(&highs, &lows, &closes, period);
last_finite(&result)
}
fn compute_obv(candles: &[Candle]) -> Option<f64> {
if candles.len() < 2 {
return None;
}
let closes = extract_closes(candles);
let volumes = extract_volumes(candles);
let result = motosan_ta_math::indicators::obv(&closes, &volumes);
last_finite(&result)
}
fn compute_mfi(candles: &[Candle], period: usize) -> Option<f64> {
if candles.len() < period + 1 {
return None;
}
let highs = extract_highs(candles);
let lows = extract_lows(candles);
let closes = extract_closes(candles);
let volumes = extract_volumes(candles);
let result = motosan_ta_math::indicators::mfi(&highs, &lows, &closes, &volumes, period);
last_finite(&result)
}
fn compute_roc(candles: &[Candle], period: usize) -> Option<f64> {
if candles.len() < period + 1 {
return None;
}
let closes = extract_closes(candles);
let result = motosan_ta_math::indicators::roc(&closes, period);
last_finite(&result)
}
fn compute_donchian(candles: &[Candle], period: usize) -> (Option<f64>, Option<f64>) {
if candles.len() < period {
return (None, None);
}
let highs = extract_highs(candles);
let lows = extract_lows(candles);
let (upper, lower) = motosan_ta_math::indicators::donchian(&highs, &lows, period);
(last_finite(&upper), last_finite(&lower))
}
fn compute_zscore_close(candles: &[Candle], period: usize) -> Option<f64> {
if candles.len() < period {
return None;
}
let closes = extract_closes(candles);
let result = motosan_ta_math::indicators::statistics::zscore(&closes, period);
last_finite(&result)
}
fn compute_zscore_volume(candles: &[Candle], period: usize) -> Option<f64> {
if candles.len() < period {
return None;
}
let volumes = extract_volumes(candles);
let result = motosan_ta_math::indicators::statistics::zscore(&volumes, period);
last_finite(&result)
}
fn compute_hv(candles: &[Candle], period: usize) -> Option<f64> {
if candles.len() < period + 1 {
return None;
}
let closes = extract_closes(candles);
let result = motosan_ta_math::indicators::statistics::hv(&closes, period);
last_finite(&result)
}
fn compute_keltner(candles: &[Candle], period: usize, mult: f64) -> (Option<f64>, Option<f64>) {
if candles.len() < period + 1 {
return (None, None);
}
let highs = extract_highs(candles);
let lows = extract_lows(candles);
let closes = extract_closes(candles);
let (upper, lower) = motosan_ta_math::indicators::keltner(&highs, &lows, &closes, period, mult);
(last_finite(&upper), last_finite(&lower))
}
fn compute_supertrend(candles: &[Candle], period: usize, mult: f64) -> (Option<f64>, Option<f64>) {
if candles.len() < period + 1 {
return (None, None);
}
let highs = extract_highs(candles);
let lows = extract_lows(candles);
let closes = extract_closes(candles);
let (value, direction) =
motosan_ta_math::indicators::supertrend(&highs, &lows, &closes, period, mult);
(last_finite(&value), last_finite(&direction))
}
fn compute_vwap(candles: &[Candle]) -> Option<f64> {
if candles.is_empty() {
return None;
}
let highs = extract_highs(candles);
let lows = extract_lows(candles);
let closes = extract_closes(candles);
let volumes = extract_volumes(candles);
let result = motosan_ta_math::indicators::vwap(&highs, &lows, &closes, &volumes);
last_finite(&result)
}
fn compute_di(candles: &[Candle], period: usize) -> (Option<f64>, Option<f64>) {
if candles.len() < period * 2 + 1 {
return (None, None);
}
let highs = extract_highs(candles);
let lows = extract_lows(candles);
let closes = extract_closes(candles);
let (plus_di, minus_di) = motosan_ta_math::indicators::di(&highs, &lows, &closes, period);
(last_finite(&plus_di), last_finite(&minus_di))
}
pub fn compute_sma_series(candles: &[Candle], period: usize) -> Vec<Option<f64>> {
if candles.len() < period {
return vec![None; candles.len()];
}
let closes = extract_closes(candles);
to_option_series(motosan_ta_math::indicators::sma(&closes, period))
}
pub fn compute_ema_series(candles: &[Candle], period: usize) -> Vec<Option<f64>> {
if candles.len() < period {
return vec![None; candles.len()];
}
let closes = extract_closes(candles);
to_option_series(motosan_ta_math::indicators::ema(&closes, period))
}
#[allow(clippy::type_complexity)]
pub fn compute_bb_series(
candles: &[Candle],
period: usize,
sigma: f64,
) -> (Vec<Option<f64>>, Vec<Option<f64>>, Vec<Option<f64>>) {
let n = candles.len();
if n < period {
return (vec![None; n], vec![None; n], vec![None; n]);
}
let closes = extract_closes(candles);
let (upper, middle, lower) =
motosan_ta_math::indicators::bollinger_bands(&closes, period, sigma);
(
to_option_series(upper),
to_option_series(middle),
to_option_series(lower),
)
}
pub fn compute_rsi_series(candles: &[Candle], period: usize) -> Vec<Option<f64>> {
if candles.len() < period + 1 {
return vec![None; candles.len()];
}
let closes = extract_closes(candles);
to_option_series(motosan_ta_math::indicators::rsi(&closes, period))
}
#[allow(clippy::type_complexity)]
pub fn compute_macd_series(
candles: &[Candle],
fast: usize,
slow: usize,
signal: usize,
) -> (Vec<Option<f64>>, Vec<Option<f64>>, Vec<Option<f64>>) {
let n = candles.len();
if n < slow {
return (vec![None; n], vec![None; n], vec![None; n]);
}
let closes = extract_closes(candles);
let (line, sig, hist) = motosan_ta_math::indicators::macd(&closes, fast, slow, signal);
(
to_option_series(line),
to_option_series(sig),
to_option_series(hist),
)
}
pub fn compute_stochastic_series(
candles: &[Candle],
period: usize,
k_smooth: usize,
d_period: usize,
) -> (Vec<Option<f64>>, Vec<Option<f64>>) {
let n = candles.len();
if n < period + k_smooth + d_period - 2 {
return (vec![None; n], vec![None; n]);
}
let highs = extract_highs(candles);
let lows = extract_lows(candles);
let closes = extract_closes(candles);
let (raw_k, _) =
motosan_ta_math::indicators::stochastic(&highs, &lows, &closes, period, d_period);
let mut k_result = vec![None; n];
let mut d_result = vec![None; n];
for i in (period - 1 + k_smooth - 1)..n {
let window = &raw_k[(i + 1 - k_smooth)..=i];
if window.iter().all(|v| v.is_finite()) {
let sk = window.iter().sum::<f64>() / k_smooth as f64;
k_result[i] = Some(sk);
}
}
let smoothed_k_vals: Vec<(usize, f64)> = k_result
.iter()
.enumerate()
.filter_map(|(i, v)| v.map(|val| (i, val)))
.collect();
for window_end in (d_period - 1)..smoothed_k_vals.len() {
let window_start = window_end + 1 - d_period;
let d_val: f64 = smoothed_k_vals[window_start..=window_end]
.iter()
.map(|(_, v)| v)
.sum::<f64>()
/ d_period as f64;
let candle_idx = smoothed_k_vals[window_end].0;
d_result[candle_idx] = Some(d_val);
}
(k_result, d_result)
}
pub fn compute_cci_series(candles: &[Candle], period: usize) -> Vec<Option<f64>> {
if candles.len() < period {
return vec![None; candles.len()];
}
let highs = extract_highs(candles);
let lows = extract_lows(candles);
let closes = extract_closes(candles);
to_option_series(motosan_ta_math::indicators::cci(
&highs, &lows, &closes, period,
))
}
pub fn compute_williams_r_series(candles: &[Candle], period: usize) -> Vec<Option<f64>> {
if candles.len() < period {
return vec![None; candles.len()];
}
let highs = extract_highs(candles);
let lows = extract_lows(candles);
let closes = extract_closes(candles);
to_option_series(motosan_ta_math::indicators::williams_r(
&highs, &lows, &closes, period,
))
}
pub fn compute_obv_series(candles: &[Candle]) -> Vec<Option<f64>> {
if candles.len() < 2 {
return vec![None; candles.len()];
}
let closes = extract_closes(candles);
let volumes = extract_volumes(candles);
to_option_series(motosan_ta_math::indicators::obv(&closes, &volumes))
}
pub fn compute_mfi_series(candles: &[Candle], period: usize) -> Vec<Option<f64>> {
if candles.len() < period + 1 {
return vec![None; candles.len()];
}
let highs = extract_highs(candles);
let lows = extract_lows(candles);
let closes = extract_closes(candles);
let volumes = extract_volumes(candles);
to_option_series(motosan_ta_math::indicators::mfi(
&highs, &lows, &closes, &volumes, period,
))
}
pub fn compute_donchian_series(
candles: &[Candle],
period: usize,
) -> (Vec<Option<f64>>, Vec<Option<f64>>) {
let n = candles.len();
if n < period {
return (vec![None; n], vec![None; n]);
}
let highs = extract_highs(candles);
let lows = extract_lows(candles);
let (upper, lower) = motosan_ta_math::indicators::donchian(&highs, &lows, period);
(to_option_series(upper), to_option_series(lower))
}
pub fn compute_keltner_series(
candles: &[Candle],
period: usize,
mult: f64,
) -> (Vec<Option<f64>>, Vec<Option<f64>>) {
let n = candles.len();
if n < period + 1 {
return (vec![None; n], vec![None; n]);
}
let highs = extract_highs(candles);
let lows = extract_lows(candles);
let closes = extract_closes(candles);
let (upper, lower) = motosan_ta_math::indicators::keltner(&highs, &lows, &closes, period, mult);
(to_option_series(upper), to_option_series(lower))
}
pub fn compute_supertrend_series(
candles: &[Candle],
period: usize,
mult: f64,
) -> (Vec<Option<f64>>, Vec<Option<f64>>) {
let n = candles.len();
if n < period + 1 {
return (vec![None; n], vec![None; n]);
}
let highs = extract_highs(candles);
let lows = extract_lows(candles);
let closes = extract_closes(candles);
let (value, direction) =
motosan_ta_math::indicators::supertrend(&highs, &lows, &closes, period, mult);
(to_option_series(value), to_option_series(direction))
}
pub fn compute_vwap_series(candles: &[Candle]) -> Vec<Option<f64>> {
if candles.is_empty() {
return vec![];
}
let highs = extract_highs(candles);
let lows = extract_lows(candles);
let closes = extract_closes(candles);
let volumes = extract_volumes(candles);
to_option_series(motosan_ta_math::indicators::vwap(
&highs, &lows, &closes, &volumes,
))
}
pub fn calculate_chart_indicator_series(
candles: &[Candle],
indicators: &[String],
) -> std::collections::HashMap<String, Vec<Option<f64>>> {
let mut result = std::collections::HashMap::new();
for indicator in indicators {
match indicator.as_str() {
"ema_20" => {
result.insert("ema_20".to_string(), compute_ema_series(candles, 20));
}
"ema_50" => {
result.insert("ema_50".to_string(), compute_ema_series(candles, 50));
}
"sma_20" => {
result.insert("sma_20".to_string(), compute_sma_series(candles, 20));
}
"sma_50" => {
result.insert("sma_50".to_string(), compute_sma_series(candles, 50));
}
"bb_20_2" => {
let (upper, middle, lower) = compute_bb_series(candles, 20, 2.0);
result.insert("bb_upper".to_string(), upper);
result.insert("bb_middle".to_string(), middle);
result.insert("bb_lower".to_string(), lower);
}
"rsi_14" => {
result.insert("rsi_14".to_string(), compute_rsi_series(candles, 14));
}
"macd" => {
let (line, signal, hist) = compute_macd_series(candles, 12, 26, 9);
result.insert("macd_line".to_string(), line);
result.insert("macd_signal".to_string(), signal);
result.insert("macd_histogram".to_string(), hist);
}
"stochastic" => {
let (k, d) = compute_stochastic_series(candles, 14, 3, 3);
result.insert("stoch_k".to_string(), k);
result.insert("stoch_d".to_string(), d);
}
"cci_20" => {
result.insert("cci_20".to_string(), compute_cci_series(candles, 20));
}
"williams_r_14" => {
result.insert(
"williams_r_14".to_string(),
compute_williams_r_series(candles, 14),
);
}
"obv" => {
result.insert("obv".to_string(), compute_obv_series(candles));
}
"mfi_14" => {
result.insert("mfi_14".to_string(), compute_mfi_series(candles, 14));
}
"donchian_20" => {
let (upper, lower) = compute_donchian_series(candles, 20);
result.insert("donchian_upper_20".to_string(), upper);
result.insert("donchian_lower_20".to_string(), lower);
}
"donchian_10" => {
let (upper, lower) = compute_donchian_series(candles, 10);
result.insert("donchian_upper_10".to_string(), upper);
result.insert("donchian_lower_10".to_string(), lower);
}
"keltner_20" => {
let (upper, lower) = compute_keltner_series(candles, 20, 1.5);
result.insert("kc_upper_20".to_string(), upper);
result.insert("kc_lower_20".to_string(), lower);
}
"supertrend" => {
let (value, direction) = compute_supertrend_series(candles, 10, 3.0);
result.insert("supertrend_value".to_string(), value);
result.insert("supertrend_direction".to_string(), direction);
}
"vwap" => {
result.insert("vwap".to_string(), compute_vwap_series(candles));
}
_ => {
}
}
}
result
}
fn rsi_zone(rsi: f64) -> &'static str {
if rsi >= 70.0 {
"overbought"
} else if rsi <= 30.0 {
"oversold"
} else if rsi >= 60.0 {
"bullish"
} else if rsi <= 40.0 {
"bearish"
} else {
"neutral"
}
}
fn stoch_zone(k: f64) -> &'static str {
if k >= 80.0 {
"overbought zone"
} else if k <= 20.0 {
"oversold zone"
} else {
"neutral"
}
}
fn macd_cross_signal(histogram: f64) -> &'static str {
if histogram > 0.0 {
"bullish"
} else if histogram < 0.0 {
"bearish"
} else {
"neutral"
}
}
fn bb_position_label(price: f64, upper: f64, lower: f64) -> &'static str {
if price >= upper {
"at upper band"
} else if price <= lower {
"at lower band"
} else {
"within bands"
}
}
pub fn format_technical_summary(
symbol: &str,
indicators: &TechnicalIndicators,
current_price: Option<f64>,
) -> String {
let mut sections = String::with_capacity(512);
{
let mut parts: Vec<String> = Vec::new();
if let Some(v) = indicators.sma_20 {
parts.push(format!("SMA20={}", format_price(v)));
}
if let Some(v) = indicators.ema_12 {
parts.push(format!("EMA12={}", format_price(v)));
}
if let Some(hist) = indicators.macd_histogram {
let sign = if hist >= 0.0 { "+" } else { "" };
let cross = macd_cross_signal(hist);
parts.push(format!("MACD={}{} ({})", sign, format_price(hist), cross));
}
if let Some(v) = indicators.adx_14 {
let strength = if v >= 25.0 { "strong" } else { "weak" };
parts.push(format!("ADX={:.0} ({})", v, strength));
}
if !parts.is_empty() {
sections.push_str(&format!(" Trend: {}\n", parts.join(" ")));
}
}
{
let mut parts: Vec<String> = Vec::new();
if let Some(v) = indicators.rsi_14 {
parts.push(format!("RSI={:.0} ({})", v, rsi_zone(v)));
}
if let Some(k) = indicators.stoch_k {
parts.push(format!("Stoch={:.0} ({})", k, stoch_zone(k)));
}
if let Some(v) = indicators.cci_20 {
let label = if v > 100.0 {
"overbought"
} else if v < -100.0 {
"oversold"
} else {
"neutral"
};
parts.push(format!("CCI={:.0} ({})", v, label));
}
if let Some(v) = indicators.williams_r_14 {
let label = if v > -20.0 {
"overbought"
} else if v < -80.0 {
"oversold"
} else {
"neutral"
};
parts.push(format!("WR={:.0} ({})", v, label));
}
if let Some(v) = indicators.mfi_14 {
let label = if v >= 80.0 {
"overbought"
} else if v <= 20.0 {
"oversold"
} else {
"neutral"
};
parts.push(format!("MFI={:.0} ({})", v, label));
}
if !parts.is_empty() {
sections.push_str(&format!(" Momentum: {}\n", parts.join(" ")));
}
}
{
let mut parts: Vec<String> = Vec::new();
if let (Some(bl), Some(bm), Some(bu)) = (
indicators.bb_lower,
indicators.bb_middle,
indicators.bb_upper,
) {
let mut bb = format!(
"BB[{} - {} - {}]",
format_price(bl),
format_price(bm),
format_price(bu)
);
if let Some(price) = current_price {
bb.push_str(&format!(" ({})", bb_position_label(price, bu, bl)));
}
parts.push(bb);
}
if let Some(v) = indicators.atr_14 {
parts.push(format!("ATR={}", format_price(v)));
}
if let Some(v) = indicators.hv_20 {
parts.push(format!("HV20={:.1}%", v * 100.0));
}
if let (Some(kcu), Some(kcl)) = (indicators.kc_upper_20, indicators.kc_lower_20) {
parts.push(format!("KC[{} - {}]", format_price(kcl), format_price(kcu)));
}
if !parts.is_empty() {
sections.push_str(&format!(" Volatility: {}\n", parts.join(" ")));
}
}
{
let mut parts: Vec<String> = Vec::new();
if let (Some(du), Some(dl)) = (indicators.donchian_upper_20, indicators.donchian_lower_20) {
parts.push(format!(
"Donchian20[{} - {}]",
format_price(dl),
format_price(du)
));
}
if let (Some(sv), Some(sd)) = (indicators.supertrend_value, indicators.supertrend_direction)
{
let dir_label = if sd > 0.0 { "bullish" } else { "bearish" };
parts.push(format!("SuperTrend={} ({})", format_price(sv), dir_label));
}
if let Some(v) = indicators.vwap {
parts.push(format!("VWAP={}", format_price(v)));
}
if let Some(v) = indicators.roc_12 {
parts.push(format!("ROC={:.1}%", v));
}
if let (Some(pdi), Some(mdi)) = (indicators.plus_di_14, indicators.minus_di_14) {
parts.push(format!("+DI={:.0} -DI={:.0}", pdi, mdi));
}
if !parts.is_empty() {
sections.push_str(&format!(" Channels: {}\n", parts.join(" ")));
}
}
if sections.is_empty() {
return String::new();
}
format!("Technical Analysis ({}):\n{}", symbol, sections)
}
fn format_price(v: f64) -> String {
let abs = v.abs();
if abs >= 1000.0 {
let sign = if v < 0.0 { "-" } else { "" };
let rounded = abs.round() as u64;
let s = rounded.to_string();
let mut result = String::new();
for (i, c) in s.chars().rev().enumerate() {
if i > 0 && i % 3 == 0 {
result.push(',');
}
result.push(c);
}
format!("{}{}", sign, result.chars().rev().collect::<String>())
} else if abs >= 1.0 {
format!("{:.2}", v)
} else {
format!("{:.4}", v)
}
}
#[cfg(test)]
#[allow(deprecated)]
mod tests {
use super::*;
fn candle(open: f64, high: f64, low: f64, close: f64, volume: f64) -> Candle {
Candle {
time: 1735689600, open,
high,
low,
close,
volume,
}
}
fn candles_from_closes(closes: &[f64]) -> Vec<Candle> {
closes.iter().map(|&c| candle(c, c, c, c, 1000.0)).collect()
}
fn realistic_candles(n: usize) -> Vec<Candle> {
let mut candles = Vec::with_capacity(n);
let mut price = 100.0;
for i in 0..n {
let change = ((i as f64) * 0.7).sin() * 2.0;
price += change;
let high = price + 1.5;
let low = price - 1.5;
candles.push(candle(
price - 0.5,
high,
low,
price,
1000.0 + i as f64 * 10.0,
));
}
candles
}
#[test]
fn test_empty_candles_returns_all_none() {
let result = calculate_indicators(&[]);
assert!(result.sma_20.is_none());
assert!(result.sma_50.is_none());
assert!(result.ema_12.is_none());
assert!(result.ema_26.is_none());
assert!(result.rsi_14.is_none());
assert!(result.macd_line.is_none());
assert!(result.macd_signal.is_none());
assert!(result.macd_histogram.is_none());
assert!(result.bb_upper.is_none());
assert!(result.bb_middle.is_none());
assert!(result.bb_lower.is_none());
assert!(result.atr_14.is_none());
assert!(result.adx_14.is_none());
assert!(result.stoch_k.is_none());
assert!(result.stoch_d.is_none());
assert!(result.cci_20.is_none());
assert!(result.williams_r_14.is_none());
assert!(result.obv.is_none());
assert!(result.mfi_14.is_none());
assert!(result.roc_12.is_none());
assert!(result.donchian_upper_20.is_none());
assert!(result.donchian_lower_20.is_none());
assert!(result.donchian_upper_10.is_none());
assert!(result.donchian_lower_10.is_none());
assert!(result.close_zscore_20.is_none());
assert!(result.volume_zscore_20.is_none());
assert!(result.hv_20.is_none());
assert!(result.hv_60.is_none());
assert!(result.kc_upper_20.is_none());
assert!(result.kc_lower_20.is_none());
assert!(result.supertrend_value.is_none());
assert!(result.supertrend_direction.is_none());
assert!(result.vwap.is_none());
assert!(result.plus_di_14.is_none());
assert!(result.minus_di_14.is_none());
}
#[test]
fn test_single_candle_returns_all_none() {
let candles = candles_from_closes(&[100.0]);
let result = calculate_indicators(&candles);
assert!(result.sma_20.is_none());
assert!(result.ema_12.is_none());
assert!(result.rsi_14.is_none());
assert!(result.macd_line.is_none());
assert!(result.bb_upper.is_none());
assert!(result.atr_14.is_none());
}
#[test]
fn test_insufficient_data_for_sma50() {
let candles = candles_from_closes(&vec![100.0; 30]);
let result = calculate_indicators(&candles);
assert!(result.sma_20.is_some());
assert!(result.sma_50.is_none());
}
#[test]
fn test_sma_constant_prices() {
let candles = candles_from_closes(&vec![100.0; 25]);
let result = calculate_indicators(&candles);
assert!((result.sma_20.unwrap() - 100.0).abs() < 1e-6);
}
#[test]
fn test_sma_20_known_values() {
let closes: Vec<f64> = (1..=20).map(|x| x as f64).collect();
let candles = candles_from_closes(&closes);
let result = calculate_indicators(&candles);
assert!((result.sma_20.unwrap() - 10.5).abs() < 1e-4);
}
#[test]
fn test_sma_50_known_values() {
let closes: Vec<f64> = (1..=50).map(|x| x as f64).collect();
let candles = candles_from_closes(&closes);
let result = calculate_indicators(&candles);
assert!((result.sma_50.unwrap() - 25.5).abs() < 1e-4);
}
#[test]
fn test_ema_constant_prices() {
let candles = candles_from_closes(&vec![50.0; 30]);
let result = calculate_indicators(&candles);
assert!((result.ema_12.unwrap() - 50.0).abs() < 1e-6);
assert!((result.ema_26.unwrap() - 50.0).abs() < 1e-6);
}
#[test]
fn test_ema_12_faster_than_ema_26_on_uptrend() {
let closes: Vec<f64> = (1..=30).map(|x| x as f64).collect();
let candles = candles_from_closes(&closes);
let result = calculate_indicators(&candles);
let ema12 = result.ema_12.unwrap();
let ema26 = result.ema_26.unwrap();
assert!(
ema12 > ema26,
"EMA-12 ({}) should be > EMA-26 ({}) on uptrend",
ema12,
ema26
);
}
#[test]
fn test_rsi_all_gains() {
let closes: Vec<f64> = (0..20).map(|x| 100.0 + x as f64).collect();
let candles = candles_from_closes(&closes);
let result = calculate_indicators(&candles);
let rsi = result.rsi_14.unwrap();
assert!(
rsi > 95.0,
"RSI should be near 100 for all-gains, got {}",
rsi
);
}
#[test]
fn test_rsi_all_losses() {
let closes: Vec<f64> = (0..20).map(|x| 200.0 - x as f64).collect();
let candles = candles_from_closes(&closes);
let result = calculate_indicators(&candles);
let rsi = result.rsi_14.unwrap();
assert!(
rsi < 5.0,
"RSI should be near 0 for all-losses, got {}",
rsi
);
}
#[test]
fn test_rsi_flat_market() {
let candles = candles_from_closes(&vec![100.0; 20]);
let result = calculate_indicators(&candles);
let rsi = result.rsi_14.unwrap();
assert!(
(rsi - 100.0).abs() < 1e-6 || rsi.is_nan() == false,
"RSI for flat market should be 100 or NaN, got {}",
rsi
);
}
#[test]
fn test_rsi_range() {
let candles = realistic_candles(50);
let result = calculate_indicators(&candles);
let rsi = result.rsi_14.unwrap();
assert!(rsi >= 0.0 && rsi <= 100.0, "RSI out of range: {}", rsi);
}
#[test]
fn test_macd_with_enough_data() {
let candles = realistic_candles(50);
let result = calculate_indicators(&candles);
assert!(result.macd_line.is_some(), "MACD line should be computed");
assert!(
result.macd_signal.is_some(),
"MACD signal should be computed"
);
assert!(
result.macd_histogram.is_some(),
"MACD histogram should be computed"
);
}
#[test]
fn test_macd_histogram_is_line_minus_signal() {
let candles = realistic_candles(50);
let result = calculate_indicators(&candles);
let line = result.macd_line.unwrap();
let signal = result.macd_signal.unwrap();
let histogram = result.macd_histogram.unwrap();
assert!(
(histogram - (line - signal)).abs() < 1e-4,
"Histogram ({}) should equal line ({}) - signal ({})",
histogram,
line,
signal
);
}
#[test]
fn test_macd_constant_price() {
let candles = candles_from_closes(&vec![100.0; 50]);
let result = calculate_indicators(&candles);
let line = result.macd_line.unwrap();
let signal = result.macd_signal.unwrap();
assert!(line.abs() < 1e-4, "MACD line should be ~0, got {}", line);
assert!(
signal.abs() < 1e-4,
"MACD signal should be ~0, got {}",
signal
);
}
#[test]
fn test_bb_with_enough_data() {
let candles = realistic_candles(30);
let result = calculate_indicators(&candles);
assert!(result.bb_upper.is_some());
assert!(result.bb_middle.is_some());
assert!(result.bb_lower.is_some());
}
#[test]
fn test_bb_upper_gt_middle_gt_lower() {
let candles = realistic_candles(30);
let result = calculate_indicators(&candles);
let upper = result.bb_upper.unwrap();
let middle = result.bb_middle.unwrap();
let lower = result.bb_lower.unwrap();
assert!(
upper >= middle && middle >= lower,
"Expected upper ({}) >= middle ({}) >= lower ({})",
upper,
middle,
lower
);
}
#[test]
fn test_bb_constant_price_bands_converge() {
let candles = candles_from_closes(&vec![100.0; 30]);
let result = calculate_indicators(&candles);
let upper = result.bb_upper.unwrap();
let middle = result.bb_middle.unwrap();
let lower = result.bb_lower.unwrap();
assert!((upper - middle).abs() < 1e-4, "Upper should == middle");
assert!((middle - lower).abs() < 1e-4, "Middle should == lower");
}
#[test]
fn test_atr_with_enough_data() {
let candles = realistic_candles(20);
let result = calculate_indicators(&candles);
assert!(
result.atr_14.is_some(),
"ATR should be computed with 20 candles"
);
}
#[test]
fn test_atr_positive() {
let candles = realistic_candles(30);
let result = calculate_indicators(&candles);
let atr = result.atr_14.unwrap();
assert!(atr > 0.0, "ATR should be positive, got {}", atr);
}
#[test]
fn test_atr_constant_price() {
let candles = candles_from_closes(&vec![100.0; 20]);
let result = calculate_indicators(&candles);
let atr = result.atr_14.unwrap();
assert!(
atr.abs() < 1e-6,
"ATR should be 0 for constant prices, got {}",
atr
);
}
#[test]
fn test_technical_indicators_serialization() {
let candles = realistic_candles(60);
let result = calculate_indicators(&candles);
let json = serde_json::to_value(&result).unwrap();
assert!(json.get("sma20").is_some());
assert!(json.get("sma50").is_some());
assert!(json.get("ema12").is_some());
assert!(json.get("ema26").is_some());
assert!(json.get("rsi14").is_some());
assert!(json.get("macdLine").is_some());
assert!(json.get("macdSignal").is_some());
assert!(json.get("macdHistogram").is_some());
assert!(json.get("bbUpper").is_some());
assert!(json.get("bbMiddle").is_some());
assert!(json.get("bbLower").is_some());
assert!(json.get("atr14").is_some());
}
#[test]
fn test_technical_indicators_empty_serialization() {
let result = TechnicalIndicators::empty();
let json = serde_json::to_value(&result).unwrap();
assert!(json.get("sma20").unwrap().is_null());
assert!(json.get("rsi14").unwrap().is_null());
assert!(json.get("macdLine").unwrap().is_null());
}
#[test]
fn test_calculate_indicators_all_populated_with_enough_data() {
let candles = realistic_candles(60);
let result = calculate_indicators(&candles);
assert!(result.sma_20.is_some(), "sma_20 should be present");
assert!(result.sma_50.is_some(), "sma_50 should be present");
assert!(result.ema_12.is_some(), "ema_12 should be present");
assert!(result.ema_26.is_some(), "ema_26 should be present");
assert!(result.rsi_14.is_some(), "rsi_14 should be present");
assert!(result.macd_line.is_some(), "macd_line should be present");
assert!(
result.macd_signal.is_some(),
"macd_signal should be present"
);
assert!(
result.macd_histogram.is_some(),
"macd_histogram should be present"
);
assert!(result.bb_upper.is_some(), "bb_upper should be present");
assert!(result.bb_middle.is_some(), "bb_middle should be present");
assert!(result.bb_lower.is_some(), "bb_lower should be present");
assert!(result.atr_14.is_some(), "atr_14 should be present");
}
#[test]
fn test_adx_with_enough_data() {
let candles = realistic_candles(60);
let result = calculate_indicators(&candles);
assert!(
result.adx_14.is_some(),
"ADX should be computed with 60 candles"
);
}
#[test]
fn test_adx_range() {
let candles = realistic_candles(60);
let result = calculate_indicators(&candles);
let adx = result.adx_14.unwrap();
assert!(
adx >= 0.0 && adx <= 100.0,
"ADX should be between 0 and 100, got {}",
adx
);
}
#[test]
fn test_adx_insufficient_data() {
let candles = realistic_candles(20);
let result = calculate_indicators(&candles);
assert!(
result.adx_14.is_none(),
"ADX should be None with only 20 candles"
);
}
#[test]
fn test_stochastic_with_enough_data() {
let candles = realistic_candles(30);
let result = calculate_indicators(&candles);
assert!(result.stoch_k.is_some(), "Stoch %K should be computed");
assert!(result.stoch_d.is_some(), "Stoch %D should be computed");
}
#[test]
fn test_stochastic_range() {
let candles = realistic_candles(30);
let result = calculate_indicators(&candles);
let k = result.stoch_k.unwrap();
let d = result.stoch_d.unwrap();
assert!(
k >= 0.0 && k <= 100.0,
"Stoch %K should be 0-100, got {}",
k
);
assert!(
d >= 0.0 && d <= 100.0,
"Stoch %D should be 0-100, got {}",
d
);
}
#[test]
fn test_stochastic_at_high() {
let mut candles = realistic_candles(20);
for c in candles.iter_mut().rev().take(3) {
c.close = 200.0;
c.high = 200.0;
}
let result = calculate_indicators(&candles);
let k = result.stoch_k.unwrap();
assert!(
k > 90.0,
"Stoch %K should be high when recent closes are at top, got {}",
k
);
}
#[test]
fn test_cci_with_enough_data() {
let candles = realistic_candles(30);
let result = calculate_indicators(&candles);
assert!(
result.cci_20.is_some(),
"CCI should be computed with 30 candles"
);
}
#[test]
fn test_cci_constant_price() {
let candles = candles_from_closes(&vec![100.0; 30]);
let result = calculate_indicators(&candles);
let cci = result.cci_20.unwrap();
assert!(
cci.abs() < 1e-6,
"CCI should be 0 for constant prices, got {}",
cci
);
}
#[test]
fn test_cci_insufficient_data() {
let candles = candles_from_closes(&vec![100.0; 15]);
let result = calculate_indicators(&candles);
assert!(
result.cci_20.is_none(),
"CCI-20 should be None with 15 candles"
);
}
#[test]
fn test_williams_r_with_enough_data() {
let candles = realistic_candles(20);
let result = calculate_indicators(&candles);
assert!(
result.williams_r_14.is_some(),
"Williams %R should be computed"
);
}
#[test]
fn test_williams_r_range() {
let candles = realistic_candles(30);
let result = calculate_indicators(&candles);
let wr = result.williams_r_14.unwrap();
assert!(
wr >= -100.0 && wr <= 0.0,
"Williams %R should be -100 to 0, got {}",
wr
);
}
#[test]
fn test_williams_r_at_high() {
let mut candles = realistic_candles(20);
let window_highest = candles[(candles.len() - 14)..]
.iter()
.map(|c| c.high)
.fold(f64::NEG_INFINITY, f64::max);
let last = candles.last_mut().unwrap();
last.close = window_highest + 10.0;
last.high = window_highest + 10.0;
let result = calculate_indicators(&candles);
let wr = result.williams_r_14.unwrap();
assert!(
wr.abs() < 1e-4,
"Williams %R should be ~0 at high, got {}",
wr
);
}
#[test]
fn test_obv_with_data() {
let candles = realistic_candles(20);
let result = calculate_indicators(&candles);
assert!(result.obv.is_some(), "OBV should be computed");
}
#[test]
fn test_obv_uptrend_positive() {
let closes: Vec<f64> = (1..=20).map(|x| 100.0 + x as f64).collect();
let candles: Vec<Candle> = closes
.iter()
.map(|&c| candle(c, c + 1.0, c - 1.0, c, 1000.0))
.collect();
let result = calculate_indicators(&candles);
let obv = result.obv.unwrap();
assert!(obv > 0.0, "OBV should be positive in uptrend, got {}", obv);
}
#[test]
fn test_obv_downtrend_negative() {
let closes: Vec<f64> = (1..=20).map(|x| 200.0 - x as f64).collect();
let candles: Vec<Candle> = closes
.iter()
.map(|&c| candle(c, c + 1.0, c - 1.0, c, 1000.0))
.collect();
let result = calculate_indicators(&candles);
let obv = result.obv.unwrap();
assert!(
obv < 0.0,
"OBV should be negative in downtrend, got {}",
obv
);
}
#[test]
fn test_obv_flat_is_zero() {
let candles = candles_from_closes(&vec![100.0; 20]);
let result = calculate_indicators(&candles);
let obv = result.obv.unwrap();
assert!(
obv.abs() < 1e-6,
"OBV should be 0 for flat prices, got {}",
obv
);
}
#[test]
fn test_mfi_with_enough_data() {
let candles = realistic_candles(20);
let result = calculate_indicators(&candles);
assert!(
result.mfi_14.is_some(),
"MFI should be computed with 20 candles"
);
}
#[test]
fn test_mfi_range() {
let candles = realistic_candles(30);
let result = calculate_indicators(&candles);
let mfi = result.mfi_14.unwrap();
assert!(
mfi >= 0.0 && mfi <= 100.0,
"MFI should be 0-100, got {}",
mfi
);
}
#[test]
fn test_mfi_all_up() {
let closes: Vec<f64> = (1..=20).map(|x| 100.0 + x as f64).collect();
let candles: Vec<Candle> = closes
.iter()
.map(|&c| candle(c - 0.5, c + 1.0, c - 1.0, c, 1000.0))
.collect();
let result = calculate_indicators(&candles);
let mfi = result.mfi_14.unwrap();
assert!(mfi > 95.0, "MFI should be near 100 for all-up, got {}", mfi);
}
#[test]
fn test_mfi_insufficient_data() {
let candles = realistic_candles(10);
let result = calculate_indicators(&candles);
assert!(
result.mfi_14.is_none(),
"MFI-14 should be None with 10 candles"
);
}
#[test]
fn test_new_indicators_serialization() {
let candles = realistic_candles(60);
let result = calculate_indicators(&candles);
let json = serde_json::to_value(&result).unwrap();
assert!(json.get("adx14").is_some(), "adx14 should be in JSON");
assert!(json.get("stochK").is_some(), "stochK should be in JSON");
assert!(json.get("stochD").is_some(), "stochD should be in JSON");
assert!(json.get("cci20").is_some(), "cci20 should be in JSON");
assert!(
json.get("williamsR14").is_some(),
"williamsR14 should be in JSON"
);
assert!(json.get("obv").is_some(), "obv should be in JSON");
assert!(json.get("mfi14").is_some(), "mfi14 should be in JSON");
}
#[test]
fn test_all_new_indicators_populated_with_enough_data() {
let candles = realistic_candles(60);
let result = calculate_indicators(&candles);
assert!(result.adx_14.is_some(), "adx_14 should be present");
assert!(result.stoch_k.is_some(), "stoch_k should be present");
assert!(result.stoch_d.is_some(), "stoch_d should be present");
assert!(result.cci_20.is_some(), "cci_20 should be present");
assert!(
result.williams_r_14.is_some(),
"williams_r_14 should be present"
);
assert!(result.obv.is_some(), "obv should be present");
assert!(result.mfi_14.is_some(), "mfi_14 should be present");
}
#[test]
fn test_format_technical_summary_empty_indicators() {
let indicators = TechnicalIndicators::empty();
let result = format_technical_summary("BTC-PERP", &indicators, None);
assert!(result.is_empty());
}
#[test]
fn test_format_technical_summary_full_indicators() {
let indicators = TechnicalIndicators {
sma_20: Some(67450.0),
sma_50: Some(66000.0),
ema_12: Some(67320.0),
ema_20: Some(67100.0),
ema_26: Some(66800.0),
ema_50: Some(65500.0),
rsi_14: Some(62.0),
macd_line: Some(520.0),
macd_signal: Some(400.0),
macd_histogram: Some(120.0),
bb_upper: Some(68200.0),
bb_middle: Some(67500.0),
bb_lower: Some(66800.0),
atr_14: Some(350.0),
adx_14: Some(28.0),
stoch_k: Some(78.0),
stoch_d: Some(72.0),
cci_20: Some(45.0),
williams_r_14: Some(-35.0),
obv: Some(1000000.0),
mfi_14: Some(55.0),
roc_12: Some(5.2),
donchian_upper_20: Some(68500.0),
donchian_lower_20: Some(65500.0),
donchian_upper_10: Some(68000.0),
donchian_lower_10: Some(66000.0),
close_zscore_20: Some(1.2),
volume_zscore_20: Some(0.5),
hv_20: Some(0.35),
hv_60: Some(0.40),
kc_upper_20: Some(68100.0),
kc_lower_20: Some(66900.0),
supertrend_value: Some(66500.0),
supertrend_direction: Some(1.0),
vwap: Some(67400.0),
plus_di_14: Some(25.0),
minus_di_14: Some(18.0),
};
let result = format_technical_summary("BTC-PERP", &indicators, Some(67600.0));
assert!(result.contains("Technical Analysis (BTC-PERP):"));
assert!(result.contains("SMA20=67,450"));
assert!(result.contains("EMA12=67,320"));
assert!(result.contains("MACD=+120"));
assert!(result.contains("(bullish)"));
assert!(result.contains("ADX=28 (strong)"));
assert!(result.contains("RSI=62 (bullish)"));
assert!(result.contains("Stoch=78 (neutral)"));
assert!(result.contains("CCI=45 (neutral)"));
assert!(result.contains("WR=-35 (neutral)"));
assert!(result.contains("MFI=55 (neutral)"));
assert!(result.contains("BB[66,800 - 67,500 - 68,200]"));
assert!(result.contains("within bands"));
assert!(result.contains("ATR=350"));
}
#[test]
fn test_format_technical_summary_rsi_zones() {
let mut ind = TechnicalIndicators::empty();
ind.rsi_14 = Some(75.0);
let result = format_technical_summary("ETH-PERP", &ind, None);
assert!(result.contains("RSI=75 (overbought)"));
ind.rsi_14 = Some(25.0);
let result = format_technical_summary("ETH-PERP", &ind, None);
assert!(result.contains("RSI=25 (oversold)"));
ind.rsi_14 = Some(63.0);
let result = format_technical_summary("ETH-PERP", &ind, None);
assert!(result.contains("RSI=63 (bullish)"));
ind.rsi_14 = Some(35.0);
let result = format_technical_summary("ETH-PERP", &ind, None);
assert!(result.contains("RSI=35 (bearish)"));
}
#[test]
fn test_format_technical_summary_macd_bearish() {
let mut ind = TechnicalIndicators::empty();
ind.macd_histogram = Some(-50.0);
let result = format_technical_summary("SOL-PERP", &ind, None);
assert!(result.contains("(bearish)"));
}
#[test]
fn test_format_technical_summary_stoch_zones() {
let mut ind = TechnicalIndicators::empty();
ind.stoch_k = Some(85.0);
let result = format_technical_summary("BTC-PERP", &ind, None);
assert!(result.contains("Stoch=85 (overbought zone)"));
ind.stoch_k = Some(15.0);
let result = format_technical_summary("BTC-PERP", &ind, None);
assert!(result.contains("Stoch=15 (oversold zone)"));
}
#[test]
fn test_format_technical_summary_bb_position() {
let mut ind = TechnicalIndicators::empty();
ind.bb_upper = Some(100.0);
ind.bb_middle = Some(95.0);
ind.bb_lower = Some(90.0);
let result = format_technical_summary("X", &ind, Some(101.0));
assert!(result.contains("at upper band"));
let result = format_technical_summary("X", &ind, Some(89.0));
assert!(result.contains("at lower band"));
let result = format_technical_summary("X", &ind, Some(95.0));
assert!(result.contains("within bands"));
}
#[test]
fn test_format_technical_summary_adx_weak() {
let mut ind = TechnicalIndicators::empty();
ind.adx_14 = Some(15.0);
let result = format_technical_summary("BTC-PERP", &ind, None);
assert!(result.contains("ADX=15 (weak)"));
}
#[test]
fn test_format_price_formatting() {
assert_eq!(format_price(67450.0), "67,450");
assert_eq!(format_price(1234567.0), "1,234,567");
assert_eq!(format_price(350.0), "350.00");
assert_eq!(format_price(0.0045), "0.0045");
assert_eq!(format_price(-1500.0), "-1,500");
}
#[test]
fn test_format_technical_summary_with_real_candles() {
let candles = realistic_candles(60);
let indicators = calculate_indicators(&candles);
let last_price = candles.last().unwrap().close;
let result = format_technical_summary("TEST-PERP", &indicators, Some(last_price));
assert!(result.contains("Technical Analysis (TEST-PERP):"));
assert!(result.contains("Trend:"));
assert!(result.contains("Momentum:"));
assert!(result.contains("Volatility:"));
}
#[test]
fn test_roc_known_value() {
let mut closes = vec![100.0; 13];
closes[12] = 110.0;
let candles = candles_from_closes(&closes);
let result = compute_roc(&candles, 12);
assert!((result.unwrap() - 10.0).abs() < 1e-6);
}
#[test]
fn test_roc_negative() {
let mut closes = vec![100.0; 13];
closes[12] = 90.0;
let candles = candles_from_closes(&closes);
let result = compute_roc(&candles, 12);
assert!((result.unwrap() - (-10.0)).abs() < 1e-6);
}
#[test]
fn test_roc_insufficient_data() {
let candles = candles_from_closes(&vec![100.0; 5]);
assert!(compute_roc(&candles, 12).is_none());
}
#[test]
fn test_roc_in_calculate_indicators() {
let candles = realistic_candles(20);
let result = calculate_indicators(&candles);
assert!(
result.roc_12.is_some(),
"ROC-12 should be computed with 20 candles"
);
}
#[test]
fn test_donchian_known_values() {
let candles: Vec<Candle> = (1..=20)
.map(|i| candle(i as f64, i as f64 + 0.5, i as f64 - 0.5, i as f64, 1000.0))
.collect();
let (upper, lower) = compute_donchian(&candles, 20);
assert!((upper.unwrap() - 20.5).abs() < 1e-6); assert!((lower.unwrap() - 0.5).abs() < 1e-6); }
#[test]
fn test_donchian_insufficient_data() {
let candles = realistic_candles(5);
let (u, l) = compute_donchian(&candles, 20);
assert!(u.is_none());
assert!(l.is_none());
}
#[test]
fn test_donchian_in_calculate_indicators() {
let candles = realistic_candles(25);
let result = calculate_indicators(&candles);
assert!(result.donchian_upper_20.is_some());
assert!(result.donchian_lower_20.is_some());
assert!(result.donchian_upper_10.is_some());
assert!(result.donchian_lower_10.is_some());
}
#[test]
fn test_zscore_close_constant() {
let candles = candles_from_closes(&vec![100.0; 20]);
let result = compute_zscore_close(&candles, 20);
assert!(
(result.unwrap()).abs() < 1e-6,
"Z-Score should be 0 for constant prices"
);
}
#[test]
fn test_zscore_close_known() {
let closes: Vec<f64> = (1..=20).map(|x| x as f64).collect();
let candles = candles_from_closes(&closes);
let z = compute_zscore_close(&candles, 20).unwrap();
assert!(
z > 0.0,
"Z-Score should be positive when last > mean, got {}",
z
);
}
#[test]
fn test_zscore_volume_constant() {
let candles = candles_from_closes(&vec![100.0; 20]);
let result = compute_zscore_volume(&candles, 20);
assert!(
(result.unwrap()).abs() < 1e-6,
"Volume Z-Score should be 0 for constant volume"
);
}
#[test]
fn test_zscore_insufficient_data() {
let candles = candles_from_closes(&vec![100.0; 5]);
assert!(compute_zscore_close(&candles, 20).is_none());
assert!(compute_zscore_volume(&candles, 20).is_none());
}
#[test]
fn test_hv_constant_price() {
let candles = candles_from_closes(&vec![100.0; 25]);
let result = compute_hv(&candles, 20);
assert!(
(result.unwrap()).abs() < 1e-6,
"HV should be 0 for constant prices"
);
}
#[test]
fn test_hv_positive_for_varying() {
let candles = realistic_candles(25);
let result = compute_hv(&candles, 20);
assert!(
result.unwrap() > 0.0,
"HV should be positive for varying prices"
);
}
#[test]
fn test_hv_insufficient_data() {
let candles = candles_from_closes(&vec![100.0; 15]);
assert!(compute_hv(&candles, 20).is_none());
}
#[test]
fn test_hv_in_calculate_indicators() {
let candles = realistic_candles(65);
let result = calculate_indicators(&candles);
assert!(result.hv_20.is_some(), "HV-20 should be present");
assert!(result.hv_60.is_some(), "HV-60 should be present");
}
#[test]
fn test_keltner_with_enough_data() {
let candles = realistic_candles(30);
let (upper, lower) = compute_keltner(&candles, 20, 1.5);
assert!(upper.is_some(), "KC upper should be computed");
assert!(lower.is_some(), "KC lower should be computed");
}
#[test]
fn test_keltner_upper_gt_lower() {
let candles = realistic_candles(30);
let (upper, lower) = compute_keltner(&candles, 20, 1.5);
assert!(
upper.unwrap() > lower.unwrap(),
"KC upper should be > lower"
);
}
#[test]
fn test_keltner_constant_price() {
let candles = candles_from_closes(&vec![100.0; 25]);
let (upper, lower) = compute_keltner(&candles, 20, 1.5);
let u = upper.unwrap();
let l = lower.unwrap();
assert!(
(u - l).abs() < 1e-4,
"KC bands should converge for constant prices"
);
}
#[test]
fn test_keltner_insufficient_data() {
let candles = candles_from_closes(&vec![100.0; 10]);
let (u, l) = compute_keltner(&candles, 20, 1.5);
assert!(u.is_none());
assert!(l.is_none());
}
#[test]
fn test_supertrend_with_enough_data() {
let candles = realistic_candles(30);
let (value, direction) = compute_supertrend(&candles, 10, 3.0);
assert!(value.is_some(), "SuperTrend value should be computed");
assert!(
direction.is_some(),
"SuperTrend direction should be computed"
);
}
#[test]
fn test_supertrend_direction_valid() {
let candles = realistic_candles(30);
let (_, direction) = compute_supertrend(&candles, 10, 3.0);
let dir = direction.unwrap();
assert!(
dir == 1.0 || dir == -1.0,
"SuperTrend direction should be 1.0 or -1.0, got {}",
dir
);
}
#[test]
fn test_supertrend_insufficient_data() {
let candles = realistic_candles(5);
let (v, d) = compute_supertrend(&candles, 10, 3.0);
assert!(v.is_none());
assert!(d.is_none());
}
#[test]
fn test_supertrend_uptrend_bullish() {
let closes: Vec<f64> = (0..30).map(|i| 100.0 + i as f64 * 2.0).collect();
let candles: Vec<Candle> = closes
.iter()
.map(|&c| candle(c - 0.5, c + 1.0, c - 1.0, c, 1000.0))
.collect();
let (_, direction) = compute_supertrend(&candles, 10, 3.0);
assert_eq!(
direction.unwrap(),
1.0,
"SuperTrend should be bullish in uptrend"
);
}
#[test]
fn test_vwap_constant_price() {
let candles = candles_from_closes(&vec![100.0; 20]);
let result = compute_vwap(&candles);
assert!((result.unwrap() - 100.0).abs() < 1e-6);
}
#[test]
fn test_vwap_empty() {
let result = compute_vwap(&[]);
assert!(result.is_none());
}
#[test]
fn test_vwap_single_candle() {
let c = candle(100.0, 110.0, 90.0, 105.0, 1000.0);
let result = compute_vwap(&[c]);
let expected = (110.0 + 90.0 + 105.0) / 3.0;
assert!((result.unwrap() - expected).abs() < 1e-4);
}
#[test]
fn test_vwap_in_calculate_indicators() {
let candles = realistic_candles(20);
let result = calculate_indicators(&candles);
assert!(result.vwap.is_some(), "VWAP should be computed");
}
#[test]
fn test_di_with_enough_data() {
let candles = realistic_candles(60);
let (plus_di, minus_di) = compute_di(&candles, 14);
assert!(plus_di.is_some(), "+DI should be computed");
assert!(minus_di.is_some(), "-DI should be computed");
}
#[test]
fn test_di_range() {
let candles = realistic_candles(60);
let (plus_di, minus_di) = compute_di(&candles, 14);
let pdi = plus_di.unwrap();
let mdi = minus_di.unwrap();
assert!(pdi >= 0.0, "+DI should be >= 0, got {}", pdi);
assert!(mdi >= 0.0, "-DI should be >= 0, got {}", mdi);
}
#[test]
fn test_di_insufficient_data() {
let candles = realistic_candles(20);
let (p, m) = compute_di(&candles, 14);
assert!(p.is_none());
assert!(m.is_none());
}
#[test]
fn test_di_in_calculate_indicators() {
let candles = realistic_candles(60);
let result = calculate_indicators(&candles);
assert!(result.plus_di_14.is_some(), "plus_di_14 should be present");
assert!(
result.minus_di_14.is_some(),
"minus_di_14 should be present"
);
}
#[test]
fn test_all_phase1_indicators_populated_with_enough_data() {
let candles = realistic_candles(80);
let result = calculate_indicators(&candles);
assert!(result.roc_12.is_some(), "roc_12");
assert!(result.donchian_upper_20.is_some(), "donchian_upper_20");
assert!(result.donchian_lower_20.is_some(), "donchian_lower_20");
assert!(result.donchian_upper_10.is_some(), "donchian_upper_10");
assert!(result.donchian_lower_10.is_some(), "donchian_lower_10");
assert!(result.close_zscore_20.is_some(), "close_zscore_20");
assert!(result.volume_zscore_20.is_some(), "volume_zscore_20");
assert!(result.hv_20.is_some(), "hv_20");
assert!(result.hv_60.is_some(), "hv_60");
assert!(result.kc_upper_20.is_some(), "kc_upper_20");
assert!(result.kc_lower_20.is_some(), "kc_lower_20");
assert!(result.supertrend_value.is_some(), "supertrend_value");
assert!(
result.supertrend_direction.is_some(),
"supertrend_direction"
);
assert!(result.vwap.is_some(), "vwap");
assert!(result.plus_di_14.is_some(), "plus_di_14");
assert!(result.minus_di_14.is_some(), "minus_di_14");
}
#[test]
fn test_phase1_indicators_serialization() {
let candles = realistic_candles(80);
let result = calculate_indicators(&candles);
let json = serde_json::to_value(&result).unwrap();
assert!(json.get("roc12").is_some(), "roc12 in JSON");
assert!(
json.get("donchianUpper20").is_some(),
"donchianUpper20 in JSON"
);
assert!(
json.get("donchianLower20").is_some(),
"donchianLower20 in JSON"
);
assert!(json.get("closeZscore20").is_some(), "closeZscore20 in JSON");
assert!(
json.get("volumeZscore20").is_some(),
"volumeZscore20 in JSON"
);
assert!(json.get("hv20").is_some(), "hv20 in JSON");
assert!(json.get("hv60").is_some(), "hv60 in JSON");
assert!(json.get("kcUpper20").is_some(), "kcUpper20 in JSON");
assert!(json.get("kcLower20").is_some(), "kcLower20 in JSON");
assert!(
json.get("supertrendValue").is_some(),
"supertrendValue in JSON"
);
assert!(
json.get("supertrendDirection").is_some(),
"supertrendDirection in JSON"
);
assert!(json.get("vwap").is_some(), "vwap in JSON");
assert!(json.get("plusDi14").is_some(), "plusDi14 in JSON");
assert!(json.get("minusDi14").is_some(), "minusDi14 in JSON");
}
#[test]
fn test_rsi_alternating_gains_losses() {
let mut closes = vec![100.0];
for i in 1..30 {
if i % 2 == 0 {
closes.push(closes[i - 1] + 1.0);
} else {
closes.push(closes[i - 1] - 1.0);
}
}
let candles = candles_from_closes(&closes);
let result = calculate_indicators(&candles);
let rsi = result.rsi_14.unwrap();
assert!(
rsi > 30.0 && rsi < 70.0,
"RSI for alternating market should be near 50, got {}",
rsi
);
}
#[test]
fn test_rsi_exactly_period_plus_one() {
let closes: Vec<f64> = (0..15).map(|x| 100.0 + x as f64).collect();
let candles = candles_from_closes(&closes);
let result = calculate_indicators(&candles);
assert!(
result.rsi_14.is_some(),
"RSI should compute with exactly period+1 candles"
);
}
#[test]
fn test_rsi_period_candles_insufficient() {
let closes: Vec<f64> = (0..14).map(|x| 100.0 + x as f64).collect();
let candles = candles_from_closes(&closes);
let result = calculate_indicators(&candles);
assert!(
result.rsi_14.is_none(),
"RSI should be None with only 14 candles"
);
}
#[test]
fn test_rsi_large_spike_then_flat() {
let mut closes = vec![100.0, 200.0]; for _ in 2..25 {
closes.push(200.0); }
let candles = candles_from_closes(&closes);
let result = calculate_indicators(&candles);
let rsi = result.rsi_14.unwrap();
assert!(
rsi > 50.0,
"RSI after spike then flat should still be elevated, got {}",
rsi
);
}
#[test]
fn test_macd_uptrend_positive_line() {
let closes: Vec<f64> = (0..50).map(|x| 100.0 + x as f64 * 2.0).collect();
let candles = candles_from_closes(&closes);
let result = calculate_indicators(&candles);
let line = result.macd_line.unwrap();
assert!(
line > 0.0,
"MACD line should be positive in uptrend, got {}",
line
);
}
#[test]
fn test_macd_downtrend_negative_line() {
let closes: Vec<f64> = (0..50).map(|x| 200.0 - x as f64 * 2.0).collect();
let candles = candles_from_closes(&closes);
let result = calculate_indicators(&candles);
let line = result.macd_line.unwrap();
assert!(
line < 0.0,
"MACD line should be negative in downtrend, got {}",
line
);
}
#[test]
fn test_macd_insufficient_data_boundary() {
let closes: Vec<f64> = (0..25).map(|x| 100.0 + x as f64).collect();
let candles = candles_from_closes(&closes);
let result = calculate_indicators(&candles);
assert!(
result.macd_line.is_none(),
"MACD should be None with only 25 candles"
);
}
#[test]
fn test_macd_exactly_slow_period() {
let closes: Vec<f64> = (0..26).map(|x| 100.0 + x as f64).collect();
let candles = candles_from_closes(&closes);
let result = calculate_indicators(&candles);
assert!(
result.macd_line.is_some(),
"MACD should compute with exactly 26 candles"
);
}
#[test]
fn test_bollinger_bands_high_volatility() {
let mut closes = Vec::new();
for i in 0..30 {
if i % 2 == 0 {
closes.push(100.0 + 20.0);
} else {
closes.push(100.0 - 20.0);
}
}
let candles = candles_from_closes(&closes);
let result = calculate_indicators(&candles);
let upper = result.bb_upper.unwrap();
let lower = result.bb_lower.unwrap();
let bandwidth = upper - lower;
assert!(
bandwidth > 10.0,
"BB bandwidth should be wide for volatile data, got {}",
bandwidth
);
}
#[test]
fn test_atr_high_volatility_vs_low() {
let high_vol: Vec<Candle> = (0..20)
.map(|i| Candle {
time: i as u64,
open: 100.0,
high: 120.0,
low: 80.0,
close: 100.0,
volume: 1000.0,
})
.collect();
let low_vol: Vec<Candle> = (0..20)
.map(|i| Candle {
time: i as u64,
open: 100.0,
high: 101.0,
low: 99.0,
close: 100.0,
volume: 1000.0,
})
.collect();
let atr_high = calculate_indicators(&high_vol).atr_14.unwrap();
let atr_low = calculate_indicators(&low_vol).atr_14.unwrap();
assert!(
atr_high > atr_low,
"ATR for high-vol ({}) should exceed ATR for low-vol ({})",
atr_high,
atr_low
);
}
#[test]
fn test_obv_single_candle_insufficient() {
let candles = candles_from_closes(&[100.0]);
let result = calculate_indicators(&candles);
assert!(result.obv.is_none(), "OBV needs at least 2 candles");
}
#[test]
fn test_vwap_zero_volume() {
let candles: Vec<Candle> = (0..10)
.map(|i| Candle {
time: i as u64,
open: 100.0,
high: 105.0,
low: 95.0,
close: 100.0,
volume: 0.0,
})
.collect();
let result = calculate_indicators(&candles);
assert!(
result.vwap.is_none(),
"VWAP should be None when total volume is 0"
);
}
#[test]
fn test_williams_r_at_lowest_low() {
let candles = vec![
Candle {
time: 0,
open: 110.0,
high: 120.0,
low: 100.0,
close: 100.0,
volume: 1000.0,
},
Candle {
time: 1,
open: 110.0,
high: 120.0,
low: 100.0,
close: 100.0,
volume: 1000.0,
},
Candle {
time: 2,
open: 110.0,
high: 120.0,
low: 100.0,
close: 100.0,
volume: 1000.0,
},
Candle {
time: 3,
open: 110.0,
high: 120.0,
low: 100.0,
close: 100.0,
volume: 1000.0,
},
Candle {
time: 4,
open: 110.0,
high: 120.0,
low: 100.0,
close: 100.0,
volume: 1000.0,
},
Candle {
time: 5,
open: 110.0,
high: 120.0,
low: 100.0,
close: 100.0,
volume: 1000.0,
},
Candle {
time: 6,
open: 110.0,
high: 120.0,
low: 100.0,
close: 100.0,
volume: 1000.0,
},
Candle {
time: 7,
open: 110.0,
high: 120.0,
low: 100.0,
close: 100.0,
volume: 1000.0,
},
Candle {
time: 8,
open: 110.0,
high: 120.0,
low: 100.0,
close: 100.0,
volume: 1000.0,
},
Candle {
time: 9,
open: 110.0,
high: 120.0,
low: 100.0,
close: 100.0,
volume: 1000.0,
},
Candle {
time: 10,
open: 110.0,
high: 120.0,
low: 100.0,
close: 100.0,
volume: 1000.0,
},
Candle {
time: 11,
open: 110.0,
high: 120.0,
low: 100.0,
close: 100.0,
volume: 1000.0,
},
Candle {
time: 12,
open: 110.0,
high: 120.0,
low: 100.0,
close: 100.0,
volume: 1000.0,
},
Candle {
time: 13,
open: 110.0,
high: 120.0,
low: 100.0,
close: 100.0,
volume: 1000.0,
},
];
let result = calculate_indicators(&candles);
let wr = result.williams_r_14.unwrap();
assert!(
(wr - (-100.0)).abs() < 1e-6,
"Williams %R should be -100 when close equals lowest low, got {}",
wr
);
}
#[test]
fn test_cci_zero_mean_deviation() {
let candles: Vec<Candle> = (0..25)
.map(|i| Candle {
time: i as u64,
open: 100.0,
high: 100.0,
low: 100.0,
close: 100.0,
volume: 1000.0,
})
.collect();
let result = calculate_indicators(&candles);
let cci = result.cci_20.unwrap();
assert!(
cci.abs() < 1e-6,
"CCI should be 0 when all typical prices are equal, got {}",
cci
);
}
#[test]
fn test_roc_zero_past_price() {
let mut candles: Vec<Candle> = (0..15)
.map(|i| Candle {
time: i as u64,
open: 0.0,
high: 0.0,
low: 0.0,
close: 0.0,
volume: 1000.0,
})
.collect();
candles.last_mut().unwrap().close = 100.0;
let result = calculate_indicators(&candles);
assert!(
result.roc_12.is_none(),
"ROC should be None when past price is 0"
);
}
}