Skip to main content

hyper_ta/
technical_analysis.rs

1use serde::{Deserialize, Serialize};
2
3use crate::candle::Candle;
4
5// ---------------------------------------------------------------------------
6// Extraction helpers
7// ---------------------------------------------------------------------------
8
9fn extract_closes(candles: &[Candle]) -> Vec<f64> {
10    candles.iter().map(|c| c.close).collect()
11}
12fn extract_highs(candles: &[Candle]) -> Vec<f64> {
13    candles.iter().map(|c| c.high).collect()
14}
15fn extract_lows(candles: &[Candle]) -> Vec<f64> {
16    candles.iter().map(|c| c.low).collect()
17}
18fn extract_volumes(candles: &[Candle]) -> Vec<f64> {
19    candles.iter().map(|c| c.volume).collect()
20}
21
22fn last_finite(values: &[f64]) -> Option<f64> {
23    values.last().filter(|v| v.is_finite()).copied()
24}
25
26fn to_option_series(values: Vec<f64>) -> Vec<Option<f64>> {
27    values
28        .into_iter()
29        .map(|v| if v.is_finite() { Some(v) } else { None })
30        .collect()
31}
32
33// ---------------------------------------------------------------------------
34// TechnicalIndicators
35// ---------------------------------------------------------------------------
36
37/// Aggregated technical indicator values computed from a series of candles.
38#[derive(Serialize, Deserialize, Clone, Debug)]
39#[serde(rename_all = "camelCase")]
40pub struct TechnicalIndicators {
41    pub sma_20: Option<f64>,
42    pub sma_50: Option<f64>,
43    pub ema_12: Option<f64>,
44    pub ema_20: Option<f64>,
45    pub ema_26: Option<f64>,
46    pub ema_50: Option<f64>,
47    pub rsi_14: Option<f64>,
48    pub macd_line: Option<f64>,
49    pub macd_signal: Option<f64>,
50    pub macd_histogram: Option<f64>,
51    pub bb_upper: Option<f64>,
52    pub bb_middle: Option<f64>,
53    pub bb_lower: Option<f64>,
54    pub atr_14: Option<f64>,
55    // Trend
56    pub adx_14: Option<f64>,
57    // Momentum
58    pub stoch_k: Option<f64>,
59    pub stoch_d: Option<f64>,
60    pub cci_20: Option<f64>,
61    pub williams_r_14: Option<f64>,
62    // Volume
63    pub obv: Option<f64>,
64    pub mfi_14: Option<f64>,
65    // Rate of Change
66    pub roc_12: Option<f64>,
67    // Donchian Channel
68    pub donchian_upper_20: Option<f64>,
69    pub donchian_lower_20: Option<f64>,
70    pub donchian_upper_10: Option<f64>,
71    pub donchian_lower_10: Option<f64>,
72    // Z-Score
73    pub close_zscore_20: Option<f64>,
74    pub volume_zscore_20: Option<f64>,
75    // Historical Volatility
76    pub hv_20: Option<f64>,
77    pub hv_60: Option<f64>,
78    // Keltner Channel
79    pub kc_upper_20: Option<f64>,
80    pub kc_lower_20: Option<f64>,
81    // SuperTrend
82    pub supertrend_value: Option<f64>,
83    pub supertrend_direction: Option<f64>,
84    // VWAP
85    pub vwap: Option<f64>,
86    // Directional Indicators
87    pub plus_di_14: Option<f64>,
88    pub minus_di_14: Option<f64>,
89}
90
91impl TechnicalIndicators {
92    /// Return an instance where every field is `None`.
93    pub fn empty() -> Self {
94        Self {
95            sma_20: None,
96            sma_50: None,
97            ema_12: None,
98            ema_20: None,
99            ema_26: None,
100            ema_50: None,
101            rsi_14: None,
102            macd_line: None,
103            macd_signal: None,
104            macd_histogram: None,
105            bb_upper: None,
106            bb_middle: None,
107            bb_lower: None,
108            atr_14: None,
109            adx_14: None,
110            stoch_k: None,
111            stoch_d: None,
112            cci_20: None,
113            williams_r_14: None,
114            obv: None,
115            mfi_14: None,
116            roc_12: None,
117            donchian_upper_20: None,
118            donchian_lower_20: None,
119            donchian_upper_10: None,
120            donchian_lower_10: None,
121            close_zscore_20: None,
122            volume_zscore_20: None,
123            hv_20: None,
124            hv_60: None,
125            kc_upper_20: None,
126            kc_lower_20: None,
127            supertrend_value: None,
128            supertrend_direction: None,
129            vwap: None,
130            plus_di_14: None,
131            minus_di_14: None,
132        }
133    }
134}
135
136// ---------------------------------------------------------------------------
137// Core calculation
138// ---------------------------------------------------------------------------
139
140/// Calculate all technical indicators from a slice of candles.
141///
142/// The candles are expected to be in chronological order (oldest first).
143/// Indicators that require more data than available will be `None`.
144#[deprecated(note = "Use hyper_ta::dynamic::calculate_snapshot() with TaEngine instead")]
145pub fn calculate_indicators(candles: &[Candle]) -> TechnicalIndicators {
146    if candles.is_empty() {
147        return TechnicalIndicators::empty();
148    }
149
150    let sma_20 = compute_sma(candles, 20);
151    let sma_50 = compute_sma(candles, 50);
152    let ema_12 = compute_ema(candles, 12);
153    let ema_20 = compute_ema(candles, 20);
154    let ema_26 = compute_ema(candles, 26);
155    let ema_50 = compute_ema(candles, 50);
156    let rsi_14 = compute_rsi(candles, 14);
157    let (macd_line, macd_signal, macd_histogram) = compute_macd(candles, 12, 26, 9);
158    let (bb_upper, bb_middle, bb_lower) = compute_bollinger_bands(candles, 20, 2.0);
159    let atr_14 = compute_atr(candles, 14);
160    let adx_14 = compute_adx(candles, 14);
161    let (stoch_k, stoch_d) = compute_stochastic(candles, 14, 3, 3);
162    let cci_20 = compute_cci(candles, 20);
163    let williams_r_14 = compute_williams_r(candles, 14);
164    let obv = compute_obv(candles);
165    let mfi_14 = compute_mfi(candles, 14);
166    let roc_12 = compute_roc(candles, 12);
167    let (donchian_upper_20, donchian_lower_20) = compute_donchian(candles, 20);
168    let (donchian_upper_10, donchian_lower_10) = compute_donchian(candles, 10);
169    let close_zscore_20 = compute_zscore_close(candles, 20);
170    let volume_zscore_20 = compute_zscore_volume(candles, 20);
171    let hv_20 = compute_hv(candles, 20);
172    let hv_60 = compute_hv(candles, 60);
173    let (kc_upper_20, kc_lower_20) = compute_keltner(candles, 20, 1.5);
174    let (supertrend_value, supertrend_direction) = compute_supertrend(candles, 10, 3.0);
175    let vwap = compute_vwap(candles);
176    let (plus_di_14, minus_di_14) = compute_di(candles, 14);
177
178    TechnicalIndicators {
179        sma_20,
180        sma_50,
181        ema_12,
182        ema_20,
183        ema_26,
184        ema_50,
185        rsi_14,
186        macd_line,
187        macd_signal,
188        macd_histogram,
189        bb_upper,
190        bb_middle,
191        bb_lower,
192        atr_14,
193        adx_14,
194        stoch_k,
195        stoch_d,
196        cci_20,
197        williams_r_14,
198        obv,
199        mfi_14,
200        roc_12,
201        donchian_upper_20,
202        donchian_lower_20,
203        donchian_upper_10,
204        donchian_lower_10,
205        close_zscore_20,
206        volume_zscore_20,
207        hv_20,
208        hv_60,
209        kc_upper_20,
210        kc_lower_20,
211        supertrend_value,
212        supertrend_direction,
213        vwap,
214        plus_di_14,
215        minus_di_14,
216    }
217}
218
219// ---------------------------------------------------------------------------
220// Individual indicator computations using motosan-ta-math
221// ---------------------------------------------------------------------------
222
223/// Compute Simple Moving Average over close prices.
224fn compute_sma(candles: &[Candle], period: usize) -> Option<f64> {
225    if candles.len() < period {
226        return None;
227    }
228    let closes = extract_closes(candles);
229    let result = motosan_ta_math::indicators::sma(&closes, period);
230    last_finite(&result)
231}
232
233/// Compute Exponential Moving Average over close prices.
234fn compute_ema(candles: &[Candle], period: usize) -> Option<f64> {
235    if candles.len() < period {
236        return None;
237    }
238    let closes = extract_closes(candles);
239    let result = motosan_ta_math::indicators::ema(&closes, period);
240    last_finite(&result)
241}
242
243/// Compute RSI using Wilder's smoothing.
244fn compute_rsi(candles: &[Candle], period: usize) -> Option<f64> {
245    if candles.len() < period + 1 {
246        return None;
247    }
248    let closes = extract_closes(candles);
249    let result = motosan_ta_math::indicators::rsi(&closes, period);
250    last_finite(&result)
251}
252
253/// Compute MACD (line, signal, histogram).
254fn compute_macd(
255    candles: &[Candle],
256    fast: usize,
257    slow: usize,
258    signal: usize,
259) -> (Option<f64>, Option<f64>, Option<f64>) {
260    if candles.len() < slow {
261        return (None, None, None);
262    }
263    let closes = extract_closes(candles);
264    let (line, sig, hist) = motosan_ta_math::indicators::macd(&closes, fast, slow, signal);
265    (last_finite(&line), last_finite(&sig), last_finite(&hist))
266}
267
268/// Compute Bollinger Bands (upper, middle, lower).
269fn compute_bollinger_bands(
270    candles: &[Candle],
271    period: usize,
272    sigma: f64,
273) -> (Option<f64>, Option<f64>, Option<f64>) {
274    if candles.len() < period {
275        return (None, None, None);
276    }
277    let closes = extract_closes(candles);
278    let (upper, middle, lower) =
279        motosan_ta_math::indicators::bollinger_bands(&closes, period, sigma);
280    (
281        last_finite(&upper),
282        last_finite(&middle),
283        last_finite(&lower),
284    )
285}
286
287/// Compute Average True Range.
288fn compute_atr(candles: &[Candle], period: usize) -> Option<f64> {
289    if candles.len() < period + 1 {
290        return None;
291    }
292    let highs = extract_highs(candles);
293    let lows = extract_lows(candles);
294    let closes = extract_closes(candles);
295    let result = motosan_ta_math::indicators::atr(&highs, &lows, &closes, period);
296    last_finite(&result)
297}
298
299/// Compute ADX (Average Directional Index).
300fn compute_adx(candles: &[Candle], period: usize) -> Option<f64> {
301    if candles.len() < period * 2 + 1 {
302        return None;
303    }
304    let highs = extract_highs(candles);
305    let lows = extract_lows(candles);
306    let closes = extract_closes(candles);
307    let result = motosan_ta_math::indicators::adx(&highs, &lows, &closes, period);
308    last_finite(&result)
309}
310
311/// Compute Stochastic Oscillator (%K and %D).
312///
313/// Uses motosan-ta-math for raw %K, then applies SMA smoothing for %K and %D.
314/// The library's %D output has a NaN bug when the input K vector contains NaN
315/// padding, so we compute %D ourselves.
316fn compute_stochastic(
317    candles: &[Candle],
318    period: usize,
319    k_smooth: usize,
320    d_period: usize,
321) -> (Option<f64>, Option<f64>) {
322    if candles.len() < period + k_smooth + d_period - 2 {
323        return (None, None);
324    }
325    let highs = extract_highs(candles);
326    let lows = extract_lows(candles);
327    let closes = extract_closes(candles);
328    // Get raw %K from library (d parameter unused since %D is broken)
329    let (raw_k, _) =
330        motosan_ta_math::indicators::stochastic(&highs, &lows, &closes, period, d_period);
331
332    // Collect finite raw %K values for smoothing
333    let finite_k: Vec<f64> = raw_k.iter().copied().filter(|v| v.is_finite()).collect();
334    if finite_k.len() < k_smooth {
335        return (None, None);
336    }
337
338    // Smooth raw %K with SMA(k_smooth) to get %K
339    let smoothed_k = motosan_ta_math::indicators::sma(&finite_k, k_smooth);
340    let finite_smoothed_k: Vec<f64> = smoothed_k
341        .iter()
342        .copied()
343        .filter(|v| v.is_finite())
344        .collect();
345    if finite_smoothed_k.is_empty() {
346        return (None, None);
347    }
348
349    let last_k = *finite_smoothed_k.last().unwrap();
350
351    // %D = SMA of smoothed %K over d_period
352    if finite_smoothed_k.len() < d_period {
353        return (Some(last_k), None);
354    }
355    let d_window = &finite_smoothed_k[(finite_smoothed_k.len() - d_period)..];
356    let last_d = d_window.iter().sum::<f64>() / d_period as f64;
357
358    (Some(last_k), Some(last_d))
359}
360
361/// Compute Commodity Channel Index (CCI).
362fn compute_cci(candles: &[Candle], period: usize) -> Option<f64> {
363    if candles.len() < period {
364        return None;
365    }
366    let highs = extract_highs(candles);
367    let lows = extract_lows(candles);
368    let closes = extract_closes(candles);
369    let result = motosan_ta_math::indicators::cci(&highs, &lows, &closes, period);
370    last_finite(&result)
371}
372
373/// Compute Williams %R.
374fn compute_williams_r(candles: &[Candle], period: usize) -> Option<f64> {
375    if candles.len() < period {
376        return None;
377    }
378    let highs = extract_highs(candles);
379    let lows = extract_lows(candles);
380    let closes = extract_closes(candles);
381    let result = motosan_ta_math::indicators::williams_r(&highs, &lows, &closes, period);
382    last_finite(&result)
383}
384
385/// Compute On Balance Volume (OBV).
386fn compute_obv(candles: &[Candle]) -> Option<f64> {
387    if candles.len() < 2 {
388        return None;
389    }
390    let closes = extract_closes(candles);
391    let volumes = extract_volumes(candles);
392    let result = motosan_ta_math::indicators::obv(&closes, &volumes);
393    last_finite(&result)
394}
395
396/// Compute Money Flow Index (MFI).
397fn compute_mfi(candles: &[Candle], period: usize) -> Option<f64> {
398    if candles.len() < period + 1 {
399        return None;
400    }
401    let highs = extract_highs(candles);
402    let lows = extract_lows(candles);
403    let closes = extract_closes(candles);
404    let volumes = extract_volumes(candles);
405    let result = motosan_ta_math::indicators::mfi(&highs, &lows, &closes, &volumes, period);
406    last_finite(&result)
407}
408
409/// Compute Rate of Change (ROC).
410fn compute_roc(candles: &[Candle], period: usize) -> Option<f64> {
411    if candles.len() < period + 1 {
412        return None;
413    }
414    let closes = extract_closes(candles);
415    let result = motosan_ta_math::indicators::roc(&closes, period);
416    last_finite(&result)
417}
418
419/// Compute Donchian Channel (highest high and lowest low over period).
420fn compute_donchian(candles: &[Candle], period: usize) -> (Option<f64>, Option<f64>) {
421    if candles.len() < period {
422        return (None, None);
423    }
424    let highs = extract_highs(candles);
425    let lows = extract_lows(candles);
426    let (upper, lower) = motosan_ta_math::indicators::donchian(&highs, &lows, period);
427    (last_finite(&upper), last_finite(&lower))
428}
429
430/// Compute Z-Score of close prices over a rolling window.
431fn compute_zscore_close(candles: &[Candle], period: usize) -> Option<f64> {
432    if candles.len() < period {
433        return None;
434    }
435    let closes = extract_closes(candles);
436    let result = motosan_ta_math::indicators::statistics::zscore(&closes, period);
437    last_finite(&result)
438}
439
440/// Compute Z-Score of volume over a rolling window.
441fn compute_zscore_volume(candles: &[Candle], period: usize) -> Option<f64> {
442    if candles.len() < period {
443        return None;
444    }
445    let volumes = extract_volumes(candles);
446    let result = motosan_ta_math::indicators::statistics::zscore(&volumes, period);
447    last_finite(&result)
448}
449
450/// Compute Historical Volatility (annualized std of log returns).
451fn compute_hv(candles: &[Candle], period: usize) -> Option<f64> {
452    if candles.len() < period + 1 {
453        return None;
454    }
455    let closes = extract_closes(candles);
456    let result = motosan_ta_math::indicators::statistics::hv(&closes, period);
457    last_finite(&result)
458}
459
460/// Compute Keltner Channel.
461fn compute_keltner(candles: &[Candle], period: usize, mult: f64) -> (Option<f64>, Option<f64>) {
462    if candles.len() < period + 1 {
463        return (None, None);
464    }
465    let highs = extract_highs(candles);
466    let lows = extract_lows(candles);
467    let closes = extract_closes(candles);
468    let (upper, lower) = motosan_ta_math::indicators::keltner(&highs, &lows, &closes, period, mult);
469    (last_finite(&upper), last_finite(&lower))
470}
471
472/// Compute SuperTrend indicator.
473fn compute_supertrend(candles: &[Candle], period: usize, mult: f64) -> (Option<f64>, Option<f64>) {
474    if candles.len() < period + 1 {
475        return (None, None);
476    }
477    let highs = extract_highs(candles);
478    let lows = extract_lows(candles);
479    let closes = extract_closes(candles);
480    let (value, direction) =
481        motosan_ta_math::indicators::supertrend(&highs, &lows, &closes, period, mult);
482    (last_finite(&value), last_finite(&direction))
483}
484
485/// Compute VWAP (Volume Weighted Average Price).
486fn compute_vwap(candles: &[Candle]) -> Option<f64> {
487    if candles.is_empty() {
488        return None;
489    }
490    let highs = extract_highs(candles);
491    let lows = extract_lows(candles);
492    let closes = extract_closes(candles);
493    let volumes = extract_volumes(candles);
494    let result = motosan_ta_math::indicators::vwap(&highs, &lows, &closes, &volumes);
495    last_finite(&result)
496}
497
498/// Compute +DI and -DI (Directional Indicators).
499fn compute_di(candles: &[Candle], period: usize) -> (Option<f64>, Option<f64>) {
500    if candles.len() < period * 2 + 1 {
501        return (None, None);
502    }
503    let highs = extract_highs(candles);
504    let lows = extract_lows(candles);
505    let closes = extract_closes(candles);
506    let (plus_di, minus_di) = motosan_ta_math::indicators::di(&highs, &lows, &closes, period);
507    (last_finite(&plus_di), last_finite(&minus_di))
508}
509
510// ---------------------------------------------------------------------------
511// Chart indicator series calculations
512// ---------------------------------------------------------------------------
513
514/// Compute full SMA series over close prices.
515pub fn compute_sma_series(candles: &[Candle], period: usize) -> Vec<Option<f64>> {
516    if candles.len() < period {
517        return vec![None; candles.len()];
518    }
519    let closes = extract_closes(candles);
520    to_option_series(motosan_ta_math::indicators::sma(&closes, period))
521}
522
523/// Compute full EMA series over close prices.
524pub fn compute_ema_series(candles: &[Candle], period: usize) -> Vec<Option<f64>> {
525    if candles.len() < period {
526        return vec![None; candles.len()];
527    }
528    let closes = extract_closes(candles);
529    to_option_series(motosan_ta_math::indicators::ema(&closes, period))
530}
531
532/// Compute full Bollinger Bands series. Returns (upper, middle, lower).
533#[allow(clippy::type_complexity)]
534pub fn compute_bb_series(
535    candles: &[Candle],
536    period: usize,
537    sigma: f64,
538) -> (Vec<Option<f64>>, Vec<Option<f64>>, Vec<Option<f64>>) {
539    let n = candles.len();
540    if n < period {
541        return (vec![None; n], vec![None; n], vec![None; n]);
542    }
543    let closes = extract_closes(candles);
544    let (upper, middle, lower) =
545        motosan_ta_math::indicators::bollinger_bands(&closes, period, sigma);
546    (
547        to_option_series(upper),
548        to_option_series(middle),
549        to_option_series(lower),
550    )
551}
552
553/// Compute full RSI series.
554pub fn compute_rsi_series(candles: &[Candle], period: usize) -> Vec<Option<f64>> {
555    if candles.len() < period + 1 {
556        return vec![None; candles.len()];
557    }
558    let closes = extract_closes(candles);
559    to_option_series(motosan_ta_math::indicators::rsi(&closes, period))
560}
561
562/// Compute full MACD series. Returns (macd_line, signal, histogram).
563#[allow(clippy::type_complexity)]
564pub fn compute_macd_series(
565    candles: &[Candle],
566    fast: usize,
567    slow: usize,
568    signal: usize,
569) -> (Vec<Option<f64>>, Vec<Option<f64>>, Vec<Option<f64>>) {
570    let n = candles.len();
571    if n < slow {
572        return (vec![None; n], vec![None; n], vec![None; n]);
573    }
574    let closes = extract_closes(candles);
575    let (line, sig, hist) = motosan_ta_math::indicators::macd(&closes, fast, slow, signal);
576    (
577        to_option_series(line),
578        to_option_series(sig),
579        to_option_series(hist),
580    )
581}
582
583/// Compute full Stochastic Oscillator series (%K and %D).
584pub fn compute_stochastic_series(
585    candles: &[Candle],
586    period: usize,
587    k_smooth: usize,
588    d_period: usize,
589) -> (Vec<Option<f64>>, Vec<Option<f64>>) {
590    let n = candles.len();
591    if n < period + k_smooth + d_period - 2 {
592        return (vec![None; n], vec![None; n]);
593    }
594    let highs = extract_highs(candles);
595    let lows = extract_lows(candles);
596    let closes = extract_closes(candles);
597
598    // Get raw %K from library
599    let (raw_k, _) =
600        motosan_ta_math::indicators::stochastic(&highs, &lows, &closes, period, d_period);
601
602    // Smooth raw %K with SMA(k_smooth)
603    // First, compute SMA of raw_k manually respecting NaN
604    let mut k_result = vec![None; n];
605    let mut d_result = vec![None; n];
606
607    // Compute smoothed %K: SMA(k_smooth) of raw %K
608    for i in (period - 1 + k_smooth - 1)..n {
609        let window = &raw_k[(i + 1 - k_smooth)..=i];
610        if window.iter().all(|v| v.is_finite()) {
611            let sk = window.iter().sum::<f64>() / k_smooth as f64;
612            k_result[i] = Some(sk);
613        }
614    }
615
616    // Compute %D: SMA(d_period) of smoothed %K
617    let smoothed_k_vals: Vec<(usize, f64)> = k_result
618        .iter()
619        .enumerate()
620        .filter_map(|(i, v)| v.map(|val| (i, val)))
621        .collect();
622
623    for window_end in (d_period - 1)..smoothed_k_vals.len() {
624        let window_start = window_end + 1 - d_period;
625        let d_val: f64 = smoothed_k_vals[window_start..=window_end]
626            .iter()
627            .map(|(_, v)| v)
628            .sum::<f64>()
629            / d_period as f64;
630        let candle_idx = smoothed_k_vals[window_end].0;
631        d_result[candle_idx] = Some(d_val);
632    }
633
634    (k_result, d_result)
635}
636
637/// Compute full CCI series.
638pub fn compute_cci_series(candles: &[Candle], period: usize) -> Vec<Option<f64>> {
639    if candles.len() < period {
640        return vec![None; candles.len()];
641    }
642    let highs = extract_highs(candles);
643    let lows = extract_lows(candles);
644    let closes = extract_closes(candles);
645    to_option_series(motosan_ta_math::indicators::cci(
646        &highs, &lows, &closes, period,
647    ))
648}
649
650/// Compute full Williams %R series.
651pub fn compute_williams_r_series(candles: &[Candle], period: usize) -> Vec<Option<f64>> {
652    if candles.len() < period {
653        return vec![None; candles.len()];
654    }
655    let highs = extract_highs(candles);
656    let lows = extract_lows(candles);
657    let closes = extract_closes(candles);
658    to_option_series(motosan_ta_math::indicators::williams_r(
659        &highs, &lows, &closes, period,
660    ))
661}
662
663/// Compute full OBV series.
664pub fn compute_obv_series(candles: &[Candle]) -> Vec<Option<f64>> {
665    if candles.len() < 2 {
666        return vec![None; candles.len()];
667    }
668    let closes = extract_closes(candles);
669    let volumes = extract_volumes(candles);
670    to_option_series(motosan_ta_math::indicators::obv(&closes, &volumes))
671}
672
673/// Compute full MFI series.
674pub fn compute_mfi_series(candles: &[Candle], period: usize) -> Vec<Option<f64>> {
675    if candles.len() < period + 1 {
676        return vec![None; candles.len()];
677    }
678    let highs = extract_highs(candles);
679    let lows = extract_lows(candles);
680    let closes = extract_closes(candles);
681    let volumes = extract_volumes(candles);
682    to_option_series(motosan_ta_math::indicators::mfi(
683        &highs, &lows, &closes, &volumes, period,
684    ))
685}
686
687/// Compute full Donchian Channel series. Returns (upper, lower).
688pub fn compute_donchian_series(
689    candles: &[Candle],
690    period: usize,
691) -> (Vec<Option<f64>>, Vec<Option<f64>>) {
692    let n = candles.len();
693    if n < period {
694        return (vec![None; n], vec![None; n]);
695    }
696    let highs = extract_highs(candles);
697    let lows = extract_lows(candles);
698    let (upper, lower) = motosan_ta_math::indicators::donchian(&highs, &lows, period);
699    (to_option_series(upper), to_option_series(lower))
700}
701
702/// Compute full Keltner Channel series. Returns (upper, lower).
703pub fn compute_keltner_series(
704    candles: &[Candle],
705    period: usize,
706    mult: f64,
707) -> (Vec<Option<f64>>, Vec<Option<f64>>) {
708    let n = candles.len();
709    if n < period + 1 {
710        return (vec![None; n], vec![None; n]);
711    }
712    let highs = extract_highs(candles);
713    let lows = extract_lows(candles);
714    let closes = extract_closes(candles);
715    let (upper, lower) = motosan_ta_math::indicators::keltner(&highs, &lows, &closes, period, mult);
716    (to_option_series(upper), to_option_series(lower))
717}
718
719/// Compute full SuperTrend series. Returns (value, direction).
720pub fn compute_supertrend_series(
721    candles: &[Candle],
722    period: usize,
723    mult: f64,
724) -> (Vec<Option<f64>>, Vec<Option<f64>>) {
725    let n = candles.len();
726    if n < period + 1 {
727        return (vec![None; n], vec![None; n]);
728    }
729    let highs = extract_highs(candles);
730    let lows = extract_lows(candles);
731    let closes = extract_closes(candles);
732    let (value, direction) =
733        motosan_ta_math::indicators::supertrend(&highs, &lows, &closes, period, mult);
734    (to_option_series(value), to_option_series(direction))
735}
736
737/// Compute full VWAP series (cumulative from start).
738pub fn compute_vwap_series(candles: &[Candle]) -> Vec<Option<f64>> {
739    if candles.is_empty() {
740        return vec![];
741    }
742    let highs = extract_highs(candles);
743    let lows = extract_lows(candles);
744    let closes = extract_closes(candles);
745    let volumes = extract_volumes(candles);
746    to_option_series(motosan_ta_math::indicators::vwap(
747        &highs, &lows, &closes, &volumes,
748    ))
749}
750
751/// Calculate chart indicator series based on a list of indicator names.
752///
753/// Supported indicator names:
754/// - `ema_20`, `ema_50`, `sma_20`, `sma_50`
755/// - `bb_20_2` (returns `bb_upper`, `bb_middle`, `bb_lower`)
756/// - `rsi_14`
757/// - `macd` (returns `macd_line`, `macd_signal`, `macd_histogram`)
758/// - `stochastic` (returns `stoch_k`, `stoch_d`)
759/// - `cci_20`
760/// - `williams_r_14`
761/// - `obv`
762/// - `mfi_14`
763pub fn calculate_chart_indicator_series(
764    candles: &[Candle],
765    indicators: &[String],
766) -> std::collections::HashMap<String, Vec<Option<f64>>> {
767    let mut result = std::collections::HashMap::new();
768
769    for indicator in indicators {
770        match indicator.as_str() {
771            "ema_20" => {
772                result.insert("ema_20".to_string(), compute_ema_series(candles, 20));
773            }
774            "ema_50" => {
775                result.insert("ema_50".to_string(), compute_ema_series(candles, 50));
776            }
777            "sma_20" => {
778                result.insert("sma_20".to_string(), compute_sma_series(candles, 20));
779            }
780            "sma_50" => {
781                result.insert("sma_50".to_string(), compute_sma_series(candles, 50));
782            }
783            "bb_20_2" => {
784                let (upper, middle, lower) = compute_bb_series(candles, 20, 2.0);
785                result.insert("bb_upper".to_string(), upper);
786                result.insert("bb_middle".to_string(), middle);
787                result.insert("bb_lower".to_string(), lower);
788            }
789            "rsi_14" => {
790                result.insert("rsi_14".to_string(), compute_rsi_series(candles, 14));
791            }
792            "macd" => {
793                let (line, signal, hist) = compute_macd_series(candles, 12, 26, 9);
794                result.insert("macd_line".to_string(), line);
795                result.insert("macd_signal".to_string(), signal);
796                result.insert("macd_histogram".to_string(), hist);
797            }
798            "stochastic" => {
799                let (k, d) = compute_stochastic_series(candles, 14, 3, 3);
800                result.insert("stoch_k".to_string(), k);
801                result.insert("stoch_d".to_string(), d);
802            }
803            "cci_20" => {
804                result.insert("cci_20".to_string(), compute_cci_series(candles, 20));
805            }
806            "williams_r_14" => {
807                result.insert(
808                    "williams_r_14".to_string(),
809                    compute_williams_r_series(candles, 14),
810                );
811            }
812            "obv" => {
813                result.insert("obv".to_string(), compute_obv_series(candles));
814            }
815            "mfi_14" => {
816                result.insert("mfi_14".to_string(), compute_mfi_series(candles, 14));
817            }
818            "donchian_20" => {
819                let (upper, lower) = compute_donchian_series(candles, 20);
820                result.insert("donchian_upper_20".to_string(), upper);
821                result.insert("donchian_lower_20".to_string(), lower);
822            }
823            "donchian_10" => {
824                let (upper, lower) = compute_donchian_series(candles, 10);
825                result.insert("donchian_upper_10".to_string(), upper);
826                result.insert("donchian_lower_10".to_string(), lower);
827            }
828            "keltner_20" => {
829                let (upper, lower) = compute_keltner_series(candles, 20, 1.5);
830                result.insert("kc_upper_20".to_string(), upper);
831                result.insert("kc_lower_20".to_string(), lower);
832            }
833            "supertrend" => {
834                let (value, direction) = compute_supertrend_series(candles, 10, 3.0);
835                result.insert("supertrend_value".to_string(), value);
836                result.insert("supertrend_direction".to_string(), direction);
837            }
838            "vwap" => {
839                result.insert("vwap".to_string(), compute_vwap_series(candles));
840            }
841            _ => {
842                // Unknown indicator, skip
843            }
844        }
845    }
846
847    result
848}
849
850// ---------------------------------------------------------------------------
851// Technical Summary Formatting (for Claude prompt injection)
852// ---------------------------------------------------------------------------
853
854/// Interpret the RSI value into a human-readable zone label.
855fn rsi_zone(rsi: f64) -> &'static str {
856    if rsi >= 70.0 {
857        "overbought"
858    } else if rsi <= 30.0 {
859        "oversold"
860    } else if rsi >= 60.0 {
861        "bullish"
862    } else if rsi <= 40.0 {
863        "bearish"
864    } else {
865        "neutral"
866    }
867}
868
869/// Interpret Stochastic %K into a zone label.
870fn stoch_zone(k: f64) -> &'static str {
871    if k >= 80.0 {
872        "overbought zone"
873    } else if k <= 20.0 {
874        "oversold zone"
875    } else {
876        "neutral"
877    }
878}
879
880/// Interpret MACD histogram direction.
881fn macd_cross_signal(histogram: f64) -> &'static str {
882    if histogram > 0.0 {
883        "bullish"
884    } else if histogram < 0.0 {
885        "bearish"
886    } else {
887        "neutral"
888    }
889}
890
891/// Describe the price position relative to Bollinger Bands.
892fn bb_position_label(price: f64, upper: f64, lower: f64) -> &'static str {
893    if price >= upper {
894        "at upper band"
895    } else if price <= lower {
896        "at lower band"
897    } else {
898        "within bands"
899    }
900}
901
902/// Format a concise technical analysis summary suitable for injection into a
903/// Claude prompt.
904///
905/// The `symbol` parameter is used in the header (e.g. "BTC-PERP").
906/// An optional `current_price` can be passed to add Bollinger Band position context.
907///
908/// # Example output
909///
910/// ```text
911/// Technical Analysis (BTC-PERP):
912///   Trend: SMA20=67,450 EMA12=67,320 MACD=+120 (bullish)
913///   Momentum: RSI=62 (neutral) Stoch=78 (overbought zone)
914///   Volatility: BB[66,800 - 67,500 - 68,200] ATR=350
915/// ```
916pub fn format_technical_summary(
917    symbol: &str,
918    indicators: &TechnicalIndicators,
919    current_price: Option<f64>,
920) -> String {
921    let mut sections = String::with_capacity(512);
922
923    // --- Trend line ---
924    {
925        let mut parts: Vec<String> = Vec::new();
926
927        if let Some(v) = indicators.sma_20 {
928            parts.push(format!("SMA20={}", format_price(v)));
929        }
930        if let Some(v) = indicators.ema_12 {
931            parts.push(format!("EMA12={}", format_price(v)));
932        }
933        if let Some(hist) = indicators.macd_histogram {
934            let sign = if hist >= 0.0 { "+" } else { "" };
935            let cross = macd_cross_signal(hist);
936            parts.push(format!("MACD={}{} ({})", sign, format_price(hist), cross));
937        }
938        if let Some(v) = indicators.adx_14 {
939            let strength = if v >= 25.0 { "strong" } else { "weak" };
940            parts.push(format!("ADX={:.0} ({})", v, strength));
941        }
942
943        if !parts.is_empty() {
944            sections.push_str(&format!("  Trend: {}\n", parts.join(" ")));
945        }
946    }
947
948    // --- Momentum line ---
949    {
950        let mut parts: Vec<String> = Vec::new();
951
952        if let Some(v) = indicators.rsi_14 {
953            parts.push(format!("RSI={:.0} ({})", v, rsi_zone(v)));
954        }
955        if let Some(k) = indicators.stoch_k {
956            parts.push(format!("Stoch={:.0} ({})", k, stoch_zone(k)));
957        }
958        if let Some(v) = indicators.cci_20 {
959            let label = if v > 100.0 {
960                "overbought"
961            } else if v < -100.0 {
962                "oversold"
963            } else {
964                "neutral"
965            };
966            parts.push(format!("CCI={:.0} ({})", v, label));
967        }
968        if let Some(v) = indicators.williams_r_14 {
969            let label = if v > -20.0 {
970                "overbought"
971            } else if v < -80.0 {
972                "oversold"
973            } else {
974                "neutral"
975            };
976            parts.push(format!("WR={:.0} ({})", v, label));
977        }
978        if let Some(v) = indicators.mfi_14 {
979            let label = if v >= 80.0 {
980                "overbought"
981            } else if v <= 20.0 {
982                "oversold"
983            } else {
984                "neutral"
985            };
986            parts.push(format!("MFI={:.0} ({})", v, label));
987        }
988
989        if !parts.is_empty() {
990            sections.push_str(&format!("  Momentum: {}\n", parts.join(" ")));
991        }
992    }
993
994    // --- Volatility line ---
995    {
996        let mut parts: Vec<String> = Vec::new();
997
998        if let (Some(bl), Some(bm), Some(bu)) = (
999            indicators.bb_lower,
1000            indicators.bb_middle,
1001            indicators.bb_upper,
1002        ) {
1003            let mut bb = format!(
1004                "BB[{} - {} - {}]",
1005                format_price(bl),
1006                format_price(bm),
1007                format_price(bu)
1008            );
1009            if let Some(price) = current_price {
1010                bb.push_str(&format!(" ({})", bb_position_label(price, bu, bl)));
1011            }
1012            parts.push(bb);
1013        }
1014        if let Some(v) = indicators.atr_14 {
1015            parts.push(format!("ATR={}", format_price(v)));
1016        }
1017        if let Some(v) = indicators.hv_20 {
1018            parts.push(format!("HV20={:.1}%", v * 100.0));
1019        }
1020        if let (Some(kcu), Some(kcl)) = (indicators.kc_upper_20, indicators.kc_lower_20) {
1021            parts.push(format!("KC[{} - {}]", format_price(kcl), format_price(kcu)));
1022        }
1023
1024        if !parts.is_empty() {
1025            sections.push_str(&format!("  Volatility: {}\n", parts.join(" ")));
1026        }
1027    }
1028
1029    // --- Channel / Trend line ---
1030    {
1031        let mut parts: Vec<String> = Vec::new();
1032
1033        if let (Some(du), Some(dl)) = (indicators.donchian_upper_20, indicators.donchian_lower_20) {
1034            parts.push(format!(
1035                "Donchian20[{} - {}]",
1036                format_price(dl),
1037                format_price(du)
1038            ));
1039        }
1040        if let (Some(sv), Some(sd)) = (indicators.supertrend_value, indicators.supertrend_direction)
1041        {
1042            let dir_label = if sd > 0.0 { "bullish" } else { "bearish" };
1043            parts.push(format!("SuperTrend={} ({})", format_price(sv), dir_label));
1044        }
1045        if let Some(v) = indicators.vwap {
1046            parts.push(format!("VWAP={}", format_price(v)));
1047        }
1048        if let Some(v) = indicators.roc_12 {
1049            parts.push(format!("ROC={:.1}%", v));
1050        }
1051        if let (Some(pdi), Some(mdi)) = (indicators.plus_di_14, indicators.minus_di_14) {
1052            parts.push(format!("+DI={:.0} -DI={:.0}", pdi, mdi));
1053        }
1054
1055        if !parts.is_empty() {
1056            sections.push_str(&format!("  Channels: {}\n", parts.join(" ")));
1057        }
1058    }
1059
1060    // Return empty string when no indicator sections have content
1061    if sections.is_empty() {
1062        return String::new();
1063    }
1064
1065    format!("Technical Analysis ({}):\n{}", symbol, sections)
1066}
1067
1068/// Format a price value: use comma-separated thousands for values >= 1000,
1069/// otherwise use 2 decimal places.
1070fn format_price(v: f64) -> String {
1071    let abs = v.abs();
1072    if abs >= 1000.0 {
1073        // Format with comma separators, no decimals
1074        let sign = if v < 0.0 { "-" } else { "" };
1075        let rounded = abs.round() as u64;
1076        let s = rounded.to_string();
1077        let mut result = String::new();
1078        for (i, c) in s.chars().rev().enumerate() {
1079            if i > 0 && i % 3 == 0 {
1080                result.push(',');
1081            }
1082            result.push(c);
1083        }
1084        format!("{}{}", sign, result.chars().rev().collect::<String>())
1085    } else if abs >= 1.0 {
1086        format!("{:.2}", v)
1087    } else {
1088        // Small values: show more precision
1089        format!("{:.4}", v)
1090    }
1091}
1092
1093// ---------------------------------------------------------------------------
1094// Tests
1095// ---------------------------------------------------------------------------
1096
1097#[cfg(test)]
1098#[allow(deprecated)]
1099mod tests {
1100    use super::*;
1101
1102    /// Helper: build a Candle from OHLCV values.
1103    fn candle(open: f64, high: f64, low: f64, close: f64, volume: f64) -> Candle {
1104        Candle {
1105            time: 1735689600, // 2026-01-01 00:00 UTC epoch seconds
1106            open,
1107            high,
1108            low,
1109            close,
1110            volume,
1111        }
1112    }
1113
1114    /// Helper: build candles from close prices only (OHLC all = close).
1115    fn candles_from_closes(closes: &[f64]) -> Vec<Candle> {
1116        closes.iter().map(|&c| candle(c, c, c, c, 1000.0)).collect()
1117    }
1118
1119    /// Helper: build realistic candles with some spread around close.
1120    fn realistic_candles(n: usize) -> Vec<Candle> {
1121        let mut candles = Vec::with_capacity(n);
1122        let mut price = 100.0;
1123        for i in 0..n {
1124            let change = ((i as f64) * 0.7).sin() * 2.0;
1125            price += change;
1126            let high = price + 1.5;
1127            let low = price - 1.5;
1128            candles.push(candle(
1129                price - 0.5,
1130                high,
1131                low,
1132                price,
1133                1000.0 + i as f64 * 10.0,
1134            ));
1135        }
1136        candles
1137    }
1138
1139    // -----------------------------------------------------------------------
1140    // Empty / insufficient data
1141    // -----------------------------------------------------------------------
1142
1143    #[test]
1144    fn test_empty_candles_returns_all_none() {
1145        let result = calculate_indicators(&[]);
1146        assert!(result.sma_20.is_none());
1147        assert!(result.sma_50.is_none());
1148        assert!(result.ema_12.is_none());
1149        assert!(result.ema_26.is_none());
1150        assert!(result.rsi_14.is_none());
1151        assert!(result.macd_line.is_none());
1152        assert!(result.macd_signal.is_none());
1153        assert!(result.macd_histogram.is_none());
1154        assert!(result.bb_upper.is_none());
1155        assert!(result.bb_middle.is_none());
1156        assert!(result.bb_lower.is_none());
1157        assert!(result.atr_14.is_none());
1158        assert!(result.adx_14.is_none());
1159        assert!(result.stoch_k.is_none());
1160        assert!(result.stoch_d.is_none());
1161        assert!(result.cci_20.is_none());
1162        assert!(result.williams_r_14.is_none());
1163        assert!(result.obv.is_none());
1164        assert!(result.mfi_14.is_none());
1165        assert!(result.roc_12.is_none());
1166        assert!(result.donchian_upper_20.is_none());
1167        assert!(result.donchian_lower_20.is_none());
1168        assert!(result.donchian_upper_10.is_none());
1169        assert!(result.donchian_lower_10.is_none());
1170        assert!(result.close_zscore_20.is_none());
1171        assert!(result.volume_zscore_20.is_none());
1172        assert!(result.hv_20.is_none());
1173        assert!(result.hv_60.is_none());
1174        assert!(result.kc_upper_20.is_none());
1175        assert!(result.kc_lower_20.is_none());
1176        assert!(result.supertrend_value.is_none());
1177        assert!(result.supertrend_direction.is_none());
1178        assert!(result.vwap.is_none());
1179        assert!(result.plus_di_14.is_none());
1180        assert!(result.minus_di_14.is_none());
1181    }
1182
1183    #[test]
1184    fn test_single_candle_returns_all_none() {
1185        let candles = candles_from_closes(&[100.0]);
1186        let result = calculate_indicators(&candles);
1187        assert!(result.sma_20.is_none());
1188        assert!(result.ema_12.is_none());
1189        assert!(result.rsi_14.is_none());
1190        assert!(result.macd_line.is_none());
1191        assert!(result.bb_upper.is_none());
1192        assert!(result.atr_14.is_none());
1193    }
1194
1195    #[test]
1196    fn test_insufficient_data_for_sma50() {
1197        let candles = candles_from_closes(&vec![100.0; 30]);
1198        let result = calculate_indicators(&candles);
1199        // 30 candles: enough for SMA-20 but not SMA-50
1200        assert!(result.sma_20.is_some());
1201        assert!(result.sma_50.is_none());
1202    }
1203
1204    // -----------------------------------------------------------------------
1205    // SMA tests
1206    // -----------------------------------------------------------------------
1207
1208    #[test]
1209    fn test_sma_constant_prices() {
1210        // If all prices are 100, SMA should be 100.
1211        let candles = candles_from_closes(&vec![100.0; 25]);
1212        let result = calculate_indicators(&candles);
1213        assert!((result.sma_20.unwrap() - 100.0).abs() < 1e-6);
1214    }
1215
1216    #[test]
1217    fn test_sma_20_known_values() {
1218        // 20 prices: 1, 2, 3, ..., 20. SMA-20 = average = 10.5
1219        let closes: Vec<f64> = (1..=20).map(|x| x as f64).collect();
1220        let candles = candles_from_closes(&closes);
1221        let result = calculate_indicators(&candles);
1222        assert!((result.sma_20.unwrap() - 10.5).abs() < 1e-4);
1223    }
1224
1225    #[test]
1226    fn test_sma_50_known_values() {
1227        // 50 prices: 1, 2, ..., 50. SMA-50 = 25.5
1228        let closes: Vec<f64> = (1..=50).map(|x| x as f64).collect();
1229        let candles = candles_from_closes(&closes);
1230        let result = calculate_indicators(&candles);
1231        assert!((result.sma_50.unwrap() - 25.5).abs() < 1e-4);
1232    }
1233
1234    // -----------------------------------------------------------------------
1235    // EMA tests
1236    // -----------------------------------------------------------------------
1237
1238    #[test]
1239    fn test_ema_constant_prices() {
1240        let candles = candles_from_closes(&vec![50.0; 30]);
1241        let result = calculate_indicators(&candles);
1242        assert!((result.ema_12.unwrap() - 50.0).abs() < 1e-6);
1243        assert!((result.ema_26.unwrap() - 50.0).abs() < 1e-6);
1244    }
1245
1246    #[test]
1247    fn test_ema_12_faster_than_ema_26_on_uptrend() {
1248        // On an uptrend, EMA-12 should be higher than EMA-26 (faster reacts more).
1249        let closes: Vec<f64> = (1..=30).map(|x| x as f64).collect();
1250        let candles = candles_from_closes(&closes);
1251        let result = calculate_indicators(&candles);
1252        let ema12 = result.ema_12.unwrap();
1253        let ema26 = result.ema_26.unwrap();
1254        assert!(
1255            ema12 > ema26,
1256            "EMA-12 ({}) should be > EMA-26 ({}) on uptrend",
1257            ema12,
1258            ema26
1259        );
1260    }
1261
1262    // -----------------------------------------------------------------------
1263    // RSI tests
1264    // -----------------------------------------------------------------------
1265
1266    #[test]
1267    fn test_rsi_all_gains() {
1268        // Steadily rising prices -> RSI should be close to 100.
1269        let closes: Vec<f64> = (0..20).map(|x| 100.0 + x as f64).collect();
1270        let candles = candles_from_closes(&closes);
1271        let result = calculate_indicators(&candles);
1272        let rsi = result.rsi_14.unwrap();
1273        assert!(
1274            rsi > 95.0,
1275            "RSI should be near 100 for all-gains, got {}",
1276            rsi
1277        );
1278    }
1279
1280    #[test]
1281    fn test_rsi_all_losses() {
1282        // Steadily falling prices -> RSI should be close to 0.
1283        let closes: Vec<f64> = (0..20).map(|x| 200.0 - x as f64).collect();
1284        let candles = candles_from_closes(&closes);
1285        let result = calculate_indicators(&candles);
1286        let rsi = result.rsi_14.unwrap();
1287        assert!(
1288            rsi < 5.0,
1289            "RSI should be near 0 for all-losses, got {}",
1290            rsi
1291        );
1292    }
1293
1294    #[test]
1295    fn test_rsi_flat_market() {
1296        // No price changes -> RSI should be 100 (0 losses).
1297        // Actually with 0 gain and 0 loss the avg_loss = 0, so RSI = 100.
1298        let candles = candles_from_closes(&vec![100.0; 20]);
1299        let result = calculate_indicators(&candles);
1300        let rsi = result.rsi_14.unwrap();
1301        assert!(
1302            (rsi - 100.0).abs() < 1e-6 || rsi.is_nan() == false,
1303            "RSI for flat market should be 100 or NaN, got {}",
1304            rsi
1305        );
1306    }
1307
1308    #[test]
1309    fn test_rsi_range() {
1310        // RSI should always be between 0 and 100.
1311        let candles = realistic_candles(50);
1312        let result = calculate_indicators(&candles);
1313        let rsi = result.rsi_14.unwrap();
1314        assert!(rsi >= 0.0 && rsi <= 100.0, "RSI out of range: {}", rsi);
1315    }
1316
1317    // -----------------------------------------------------------------------
1318    // MACD tests
1319    // -----------------------------------------------------------------------
1320
1321    #[test]
1322    fn test_macd_with_enough_data() {
1323        let candles = realistic_candles(50);
1324        let result = calculate_indicators(&candles);
1325        assert!(result.macd_line.is_some(), "MACD line should be computed");
1326        assert!(
1327            result.macd_signal.is_some(),
1328            "MACD signal should be computed"
1329        );
1330        assert!(
1331            result.macd_histogram.is_some(),
1332            "MACD histogram should be computed"
1333        );
1334    }
1335
1336    #[test]
1337    fn test_macd_histogram_is_line_minus_signal() {
1338        let candles = realistic_candles(50);
1339        let result = calculate_indicators(&candles);
1340        let line = result.macd_line.unwrap();
1341        let signal = result.macd_signal.unwrap();
1342        let histogram = result.macd_histogram.unwrap();
1343        assert!(
1344            (histogram - (line - signal)).abs() < 1e-4,
1345            "Histogram ({}) should equal line ({}) - signal ({})",
1346            histogram,
1347            line,
1348            signal
1349        );
1350    }
1351
1352    #[test]
1353    fn test_macd_constant_price() {
1354        // With constant prices, MACD line and signal should both be ~0.
1355        let candles = candles_from_closes(&vec![100.0; 50]);
1356        let result = calculate_indicators(&candles);
1357        let line = result.macd_line.unwrap();
1358        let signal = result.macd_signal.unwrap();
1359        assert!(line.abs() < 1e-4, "MACD line should be ~0, got {}", line);
1360        assert!(
1361            signal.abs() < 1e-4,
1362            "MACD signal should be ~0, got {}",
1363            signal
1364        );
1365    }
1366
1367    // -----------------------------------------------------------------------
1368    // Bollinger Bands tests
1369    // -----------------------------------------------------------------------
1370
1371    #[test]
1372    fn test_bb_with_enough_data() {
1373        let candles = realistic_candles(30);
1374        let result = calculate_indicators(&candles);
1375        assert!(result.bb_upper.is_some());
1376        assert!(result.bb_middle.is_some());
1377        assert!(result.bb_lower.is_some());
1378    }
1379
1380    #[test]
1381    fn test_bb_upper_gt_middle_gt_lower() {
1382        let candles = realistic_candles(30);
1383        let result = calculate_indicators(&candles);
1384        let upper = result.bb_upper.unwrap();
1385        let middle = result.bb_middle.unwrap();
1386        let lower = result.bb_lower.unwrap();
1387        assert!(
1388            upper >= middle && middle >= lower,
1389            "Expected upper ({}) >= middle ({}) >= lower ({})",
1390            upper,
1391            middle,
1392            lower
1393        );
1394    }
1395
1396    #[test]
1397    fn test_bb_constant_price_bands_converge() {
1398        // Constant price -> std dev = 0, so upper = middle = lower.
1399        let candles = candles_from_closes(&vec![100.0; 30]);
1400        let result = calculate_indicators(&candles);
1401        let upper = result.bb_upper.unwrap();
1402        let middle = result.bb_middle.unwrap();
1403        let lower = result.bb_lower.unwrap();
1404        assert!((upper - middle).abs() < 1e-4, "Upper should == middle");
1405        assert!((middle - lower).abs() < 1e-4, "Middle should == lower");
1406    }
1407
1408    // -----------------------------------------------------------------------
1409    // ATR tests
1410    // -----------------------------------------------------------------------
1411
1412    #[test]
1413    fn test_atr_with_enough_data() {
1414        let candles = realistic_candles(20);
1415        let result = calculate_indicators(&candles);
1416        assert!(
1417            result.atr_14.is_some(),
1418            "ATR should be computed with 20 candles"
1419        );
1420    }
1421
1422    #[test]
1423    fn test_atr_positive() {
1424        let candles = realistic_candles(30);
1425        let result = calculate_indicators(&candles);
1426        let atr = result.atr_14.unwrap();
1427        assert!(atr > 0.0, "ATR should be positive, got {}", atr);
1428    }
1429
1430    #[test]
1431    fn test_atr_constant_price() {
1432        // If OHLC are all the same and don't change, true range is 0, ATR is 0.
1433        let candles = candles_from_closes(&vec![100.0; 20]);
1434        let result = calculate_indicators(&candles);
1435        let atr = result.atr_14.unwrap();
1436        assert!(
1437            atr.abs() < 1e-6,
1438            "ATR should be 0 for constant prices, got {}",
1439            atr
1440        );
1441    }
1442
1443    // -----------------------------------------------------------------------
1444    // Serialization test
1445    // -----------------------------------------------------------------------
1446
1447    #[test]
1448    fn test_technical_indicators_serialization() {
1449        let candles = realistic_candles(60);
1450        let result = calculate_indicators(&candles);
1451        let json = serde_json::to_value(&result).unwrap();
1452
1453        // Check camelCase serialization
1454        assert!(json.get("sma20").is_some());
1455        assert!(json.get("sma50").is_some());
1456        assert!(json.get("ema12").is_some());
1457        assert!(json.get("ema26").is_some());
1458        assert!(json.get("rsi14").is_some());
1459        assert!(json.get("macdLine").is_some());
1460        assert!(json.get("macdSignal").is_some());
1461        assert!(json.get("macdHistogram").is_some());
1462        assert!(json.get("bbUpper").is_some());
1463        assert!(json.get("bbMiddle").is_some());
1464        assert!(json.get("bbLower").is_some());
1465        assert!(json.get("atr14").is_some());
1466    }
1467
1468    #[test]
1469    fn test_technical_indicators_empty_serialization() {
1470        let result = TechnicalIndicators::empty();
1471        let json = serde_json::to_value(&result).unwrap();
1472        // All fields should be null
1473        assert!(json.get("sma20").unwrap().is_null());
1474        assert!(json.get("rsi14").unwrap().is_null());
1475        assert!(json.get("macdLine").unwrap().is_null());
1476    }
1477
1478    // -----------------------------------------------------------------------
1479    // Full integration
1480    // -----------------------------------------------------------------------
1481
1482    #[test]
1483    fn test_calculate_indicators_all_populated_with_enough_data() {
1484        let candles = realistic_candles(60);
1485        let result = calculate_indicators(&candles);
1486        // With 60 candles all indicators should have values
1487        assert!(result.sma_20.is_some(), "sma_20 should be present");
1488        assert!(result.sma_50.is_some(), "sma_50 should be present");
1489        assert!(result.ema_12.is_some(), "ema_12 should be present");
1490        assert!(result.ema_26.is_some(), "ema_26 should be present");
1491        assert!(result.rsi_14.is_some(), "rsi_14 should be present");
1492        assert!(result.macd_line.is_some(), "macd_line should be present");
1493        assert!(
1494            result.macd_signal.is_some(),
1495            "macd_signal should be present"
1496        );
1497        assert!(
1498            result.macd_histogram.is_some(),
1499            "macd_histogram should be present"
1500        );
1501        assert!(result.bb_upper.is_some(), "bb_upper should be present");
1502        assert!(result.bb_middle.is_some(), "bb_middle should be present");
1503        assert!(result.bb_lower.is_some(), "bb_lower should be present");
1504        assert!(result.atr_14.is_some(), "atr_14 should be present");
1505    }
1506
1507    // -----------------------------------------------------------------------
1508    // ADX tests
1509    // -----------------------------------------------------------------------
1510
1511    #[test]
1512    fn test_adx_with_enough_data() {
1513        let candles = realistic_candles(60);
1514        let result = calculate_indicators(&candles);
1515        assert!(
1516            result.adx_14.is_some(),
1517            "ADX should be computed with 60 candles"
1518        );
1519    }
1520
1521    #[test]
1522    fn test_adx_range() {
1523        let candles = realistic_candles(60);
1524        let result = calculate_indicators(&candles);
1525        let adx = result.adx_14.unwrap();
1526        assert!(
1527            adx >= 0.0 && adx <= 100.0,
1528            "ADX should be between 0 and 100, got {}",
1529            adx
1530        );
1531    }
1532
1533    #[test]
1534    fn test_adx_insufficient_data() {
1535        let candles = realistic_candles(20);
1536        let result = calculate_indicators(&candles);
1537        // 20 candles is not enough for ADX-14 (needs 14*2+1=29)
1538        assert!(
1539            result.adx_14.is_none(),
1540            "ADX should be None with only 20 candles"
1541        );
1542    }
1543
1544    // -----------------------------------------------------------------------
1545    // Stochastic Oscillator tests
1546    // -----------------------------------------------------------------------
1547
1548    #[test]
1549    fn test_stochastic_with_enough_data() {
1550        let candles = realistic_candles(30);
1551        let result = calculate_indicators(&candles);
1552        assert!(result.stoch_k.is_some(), "Stoch %K should be computed");
1553        assert!(result.stoch_d.is_some(), "Stoch %D should be computed");
1554    }
1555
1556    #[test]
1557    fn test_stochastic_range() {
1558        let candles = realistic_candles(30);
1559        let result = calculate_indicators(&candles);
1560        let k = result.stoch_k.unwrap();
1561        let d = result.stoch_d.unwrap();
1562        assert!(
1563            k >= 0.0 && k <= 100.0,
1564            "Stoch %K should be 0-100, got {}",
1565            k
1566        );
1567        assert!(
1568            d >= 0.0 && d <= 100.0,
1569            "Stoch %D should be 0-100, got {}",
1570            d
1571        );
1572    }
1573
1574    #[test]
1575    fn test_stochastic_at_high() {
1576        // If the last several candles close at a very high price, %K should be near 100
1577        let mut candles = realistic_candles(20);
1578        // Make the last 3 candles close at very high price (smoothing window is 3)
1579        for c in candles.iter_mut().rev().take(3) {
1580            c.close = 200.0;
1581            c.high = 200.0;
1582        }
1583        let result = calculate_indicators(&candles);
1584        let k = result.stoch_k.unwrap();
1585        assert!(
1586            k > 90.0,
1587            "Stoch %K should be high when recent closes are at top, got {}",
1588            k
1589        );
1590    }
1591
1592    // -----------------------------------------------------------------------
1593    // CCI tests
1594    // -----------------------------------------------------------------------
1595
1596    #[test]
1597    fn test_cci_with_enough_data() {
1598        let candles = realistic_candles(30);
1599        let result = calculate_indicators(&candles);
1600        assert!(
1601            result.cci_20.is_some(),
1602            "CCI should be computed with 30 candles"
1603        );
1604    }
1605
1606    #[test]
1607    fn test_cci_constant_price() {
1608        let candles = candles_from_closes(&vec![100.0; 30]);
1609        let result = calculate_indicators(&candles);
1610        let cci = result.cci_20.unwrap();
1611        assert!(
1612            cci.abs() < 1e-6,
1613            "CCI should be 0 for constant prices, got {}",
1614            cci
1615        );
1616    }
1617
1618    #[test]
1619    fn test_cci_insufficient_data() {
1620        let candles = candles_from_closes(&vec![100.0; 15]);
1621        let result = calculate_indicators(&candles);
1622        assert!(
1623            result.cci_20.is_none(),
1624            "CCI-20 should be None with 15 candles"
1625        );
1626    }
1627
1628    // -----------------------------------------------------------------------
1629    // Williams %R tests
1630    // -----------------------------------------------------------------------
1631
1632    #[test]
1633    fn test_williams_r_with_enough_data() {
1634        let candles = realistic_candles(20);
1635        let result = calculate_indicators(&candles);
1636        assert!(
1637            result.williams_r_14.is_some(),
1638            "Williams %R should be computed"
1639        );
1640    }
1641
1642    #[test]
1643    fn test_williams_r_range() {
1644        let candles = realistic_candles(30);
1645        let result = calculate_indicators(&candles);
1646        let wr = result.williams_r_14.unwrap();
1647        assert!(
1648            wr >= -100.0 && wr <= 0.0,
1649            "Williams %R should be -100 to 0, got {}",
1650            wr
1651        );
1652    }
1653
1654    #[test]
1655    fn test_williams_r_at_high() {
1656        // If close is at the highest high of the lookback window, Williams %R should be ~0
1657        let mut candles = realistic_candles(20);
1658        // Set the last candle's close and high above all others in the last 14 candles
1659        let window_highest = candles[(candles.len() - 14)..]
1660            .iter()
1661            .map(|c| c.high)
1662            .fold(f64::NEG_INFINITY, f64::max);
1663        let last = candles.last_mut().unwrap();
1664        last.close = window_highest + 10.0;
1665        last.high = window_highest + 10.0;
1666        let result = calculate_indicators(&candles);
1667        let wr = result.williams_r_14.unwrap();
1668        assert!(
1669            wr.abs() < 1e-4,
1670            "Williams %R should be ~0 at high, got {}",
1671            wr
1672        );
1673    }
1674
1675    // -----------------------------------------------------------------------
1676    // OBV tests
1677    // -----------------------------------------------------------------------
1678
1679    #[test]
1680    fn test_obv_with_data() {
1681        let candles = realistic_candles(20);
1682        let result = calculate_indicators(&candles);
1683        assert!(result.obv.is_some(), "OBV should be computed");
1684    }
1685
1686    #[test]
1687    fn test_obv_uptrend_positive() {
1688        // Steadily rising prices -> OBV should be positive (all volume added)
1689        let closes: Vec<f64> = (1..=20).map(|x| 100.0 + x as f64).collect();
1690        let candles: Vec<Candle> = closes
1691            .iter()
1692            .map(|&c| candle(c, c + 1.0, c - 1.0, c, 1000.0))
1693            .collect();
1694        let result = calculate_indicators(&candles);
1695        let obv = result.obv.unwrap();
1696        assert!(obv > 0.0, "OBV should be positive in uptrend, got {}", obv);
1697    }
1698
1699    #[test]
1700    fn test_obv_downtrend_negative() {
1701        // Steadily falling prices -> OBV should be negative
1702        let closes: Vec<f64> = (1..=20).map(|x| 200.0 - x as f64).collect();
1703        let candles: Vec<Candle> = closes
1704            .iter()
1705            .map(|&c| candle(c, c + 1.0, c - 1.0, c, 1000.0))
1706            .collect();
1707        let result = calculate_indicators(&candles);
1708        let obv = result.obv.unwrap();
1709        assert!(
1710            obv < 0.0,
1711            "OBV should be negative in downtrend, got {}",
1712            obv
1713        );
1714    }
1715
1716    #[test]
1717    fn test_obv_flat_is_zero() {
1718        // Constant prices -> OBV should be 0
1719        let candles = candles_from_closes(&vec![100.0; 20]);
1720        let result = calculate_indicators(&candles);
1721        let obv = result.obv.unwrap();
1722        assert!(
1723            obv.abs() < 1e-6,
1724            "OBV should be 0 for flat prices, got {}",
1725            obv
1726        );
1727    }
1728
1729    // -----------------------------------------------------------------------
1730    // MFI tests
1731    // -----------------------------------------------------------------------
1732
1733    #[test]
1734    fn test_mfi_with_enough_data() {
1735        let candles = realistic_candles(20);
1736        let result = calculate_indicators(&candles);
1737        assert!(
1738            result.mfi_14.is_some(),
1739            "MFI should be computed with 20 candles"
1740        );
1741    }
1742
1743    #[test]
1744    fn test_mfi_range() {
1745        let candles = realistic_candles(30);
1746        let result = calculate_indicators(&candles);
1747        let mfi = result.mfi_14.unwrap();
1748        assert!(
1749            mfi >= 0.0 && mfi <= 100.0,
1750            "MFI should be 0-100, got {}",
1751            mfi
1752        );
1753    }
1754
1755    #[test]
1756    fn test_mfi_all_up() {
1757        // Steadily rising prices -> MFI should be 100 (all positive flow)
1758        let closes: Vec<f64> = (1..=20).map(|x| 100.0 + x as f64).collect();
1759        let candles: Vec<Candle> = closes
1760            .iter()
1761            .map(|&c| candle(c - 0.5, c + 1.0, c - 1.0, c, 1000.0))
1762            .collect();
1763        let result = calculate_indicators(&candles);
1764        let mfi = result.mfi_14.unwrap();
1765        assert!(mfi > 95.0, "MFI should be near 100 for all-up, got {}", mfi);
1766    }
1767
1768    #[test]
1769    fn test_mfi_insufficient_data() {
1770        let candles = realistic_candles(10);
1771        let result = calculate_indicators(&candles);
1772        assert!(
1773            result.mfi_14.is_none(),
1774            "MFI-14 should be None with 10 candles"
1775        );
1776    }
1777
1778    // -----------------------------------------------------------------------
1779    // New indicators serialization
1780    // -----------------------------------------------------------------------
1781
1782    #[test]
1783    fn test_new_indicators_serialization() {
1784        let candles = realistic_candles(60);
1785        let result = calculate_indicators(&candles);
1786        let json = serde_json::to_value(&result).unwrap();
1787
1788        assert!(json.get("adx14").is_some(), "adx14 should be in JSON");
1789        assert!(json.get("stochK").is_some(), "stochK should be in JSON");
1790        assert!(json.get("stochD").is_some(), "stochD should be in JSON");
1791        assert!(json.get("cci20").is_some(), "cci20 should be in JSON");
1792        assert!(
1793            json.get("williamsR14").is_some(),
1794            "williamsR14 should be in JSON"
1795        );
1796        assert!(json.get("obv").is_some(), "obv should be in JSON");
1797        assert!(json.get("mfi14").is_some(), "mfi14 should be in JSON");
1798    }
1799
1800    // -----------------------------------------------------------------------
1801    // Updated integration test
1802    // -----------------------------------------------------------------------
1803
1804    #[test]
1805    fn test_all_new_indicators_populated_with_enough_data() {
1806        let candles = realistic_candles(60);
1807        let result = calculate_indicators(&candles);
1808        assert!(result.adx_14.is_some(), "adx_14 should be present");
1809        assert!(result.stoch_k.is_some(), "stoch_k should be present");
1810        assert!(result.stoch_d.is_some(), "stoch_d should be present");
1811        assert!(result.cci_20.is_some(), "cci_20 should be present");
1812        assert!(
1813            result.williams_r_14.is_some(),
1814            "williams_r_14 should be present"
1815        );
1816        assert!(result.obv.is_some(), "obv should be present");
1817        assert!(result.mfi_14.is_some(), "mfi_14 should be present");
1818    }
1819
1820    // -----------------------------------------------------------------------
1821    // format_technical_summary tests
1822    // -----------------------------------------------------------------------
1823
1824    #[test]
1825    fn test_format_technical_summary_empty_indicators() {
1826        let indicators = TechnicalIndicators::empty();
1827        let result = format_technical_summary("BTC-PERP", &indicators, None);
1828        // With all None, should return empty string (no header, no sections)
1829        assert!(result.is_empty());
1830    }
1831
1832    #[test]
1833    fn test_format_technical_summary_full_indicators() {
1834        let indicators = TechnicalIndicators {
1835            sma_20: Some(67450.0),
1836            sma_50: Some(66000.0),
1837            ema_12: Some(67320.0),
1838            ema_20: Some(67100.0),
1839            ema_26: Some(66800.0),
1840            ema_50: Some(65500.0),
1841            rsi_14: Some(62.0),
1842            macd_line: Some(520.0),
1843            macd_signal: Some(400.0),
1844            macd_histogram: Some(120.0),
1845            bb_upper: Some(68200.0),
1846            bb_middle: Some(67500.0),
1847            bb_lower: Some(66800.0),
1848            atr_14: Some(350.0),
1849            adx_14: Some(28.0),
1850            stoch_k: Some(78.0),
1851            stoch_d: Some(72.0),
1852            cci_20: Some(45.0),
1853            williams_r_14: Some(-35.0),
1854            obv: Some(1000000.0),
1855            mfi_14: Some(55.0),
1856            roc_12: Some(5.2),
1857            donchian_upper_20: Some(68500.0),
1858            donchian_lower_20: Some(65500.0),
1859            donchian_upper_10: Some(68000.0),
1860            donchian_lower_10: Some(66000.0),
1861            close_zscore_20: Some(1.2),
1862            volume_zscore_20: Some(0.5),
1863            hv_20: Some(0.35),
1864            hv_60: Some(0.40),
1865            kc_upper_20: Some(68100.0),
1866            kc_lower_20: Some(66900.0),
1867            supertrend_value: Some(66500.0),
1868            supertrend_direction: Some(1.0),
1869            vwap: Some(67400.0),
1870            plus_di_14: Some(25.0),
1871            minus_di_14: Some(18.0),
1872        };
1873        let result = format_technical_summary("BTC-PERP", &indicators, Some(67600.0));
1874
1875        assert!(result.contains("Technical Analysis (BTC-PERP):"));
1876        // Trend line
1877        assert!(result.contains("SMA20=67,450"));
1878        assert!(result.contains("EMA12=67,320"));
1879        assert!(result.contains("MACD=+120"));
1880        assert!(result.contains("(bullish)"));
1881        assert!(result.contains("ADX=28 (strong)"));
1882        // Momentum line
1883        assert!(result.contains("RSI=62 (bullish)"));
1884        assert!(result.contains("Stoch=78 (neutral)"));
1885        assert!(result.contains("CCI=45 (neutral)"));
1886        assert!(result.contains("WR=-35 (neutral)"));
1887        assert!(result.contains("MFI=55 (neutral)"));
1888        // Volatility line
1889        assert!(result.contains("BB[66,800 - 67,500 - 68,200]"));
1890        assert!(result.contains("within bands"));
1891        assert!(result.contains("ATR=350"));
1892    }
1893
1894    #[test]
1895    fn test_format_technical_summary_rsi_zones() {
1896        let mut ind = TechnicalIndicators::empty();
1897
1898        // Overbought
1899        ind.rsi_14 = Some(75.0);
1900        let result = format_technical_summary("ETH-PERP", &ind, None);
1901        assert!(result.contains("RSI=75 (overbought)"));
1902
1903        // Oversold
1904        ind.rsi_14 = Some(25.0);
1905        let result = format_technical_summary("ETH-PERP", &ind, None);
1906        assert!(result.contains("RSI=25 (oversold)"));
1907
1908        // Bullish
1909        ind.rsi_14 = Some(63.0);
1910        let result = format_technical_summary("ETH-PERP", &ind, None);
1911        assert!(result.contains("RSI=63 (bullish)"));
1912
1913        // Bearish
1914        ind.rsi_14 = Some(35.0);
1915        let result = format_technical_summary("ETH-PERP", &ind, None);
1916        assert!(result.contains("RSI=35 (bearish)"));
1917    }
1918
1919    #[test]
1920    fn test_format_technical_summary_macd_bearish() {
1921        let mut ind = TechnicalIndicators::empty();
1922        ind.macd_histogram = Some(-50.0);
1923        let result = format_technical_summary("SOL-PERP", &ind, None);
1924        assert!(result.contains("(bearish)"));
1925    }
1926
1927    #[test]
1928    fn test_format_technical_summary_stoch_zones() {
1929        let mut ind = TechnicalIndicators::empty();
1930
1931        ind.stoch_k = Some(85.0);
1932        let result = format_technical_summary("BTC-PERP", &ind, None);
1933        assert!(result.contains("Stoch=85 (overbought zone)"));
1934
1935        ind.stoch_k = Some(15.0);
1936        let result = format_technical_summary("BTC-PERP", &ind, None);
1937        assert!(result.contains("Stoch=15 (oversold zone)"));
1938    }
1939
1940    #[test]
1941    fn test_format_technical_summary_bb_position() {
1942        let mut ind = TechnicalIndicators::empty();
1943        ind.bb_upper = Some(100.0);
1944        ind.bb_middle = Some(95.0);
1945        ind.bb_lower = Some(90.0);
1946
1947        // At upper band
1948        let result = format_technical_summary("X", &ind, Some(101.0));
1949        assert!(result.contains("at upper band"));
1950
1951        // At lower band
1952        let result = format_technical_summary("X", &ind, Some(89.0));
1953        assert!(result.contains("at lower band"));
1954
1955        // Within bands
1956        let result = format_technical_summary("X", &ind, Some(95.0));
1957        assert!(result.contains("within bands"));
1958    }
1959
1960    #[test]
1961    fn test_format_technical_summary_adx_weak() {
1962        let mut ind = TechnicalIndicators::empty();
1963        ind.adx_14 = Some(15.0);
1964        let result = format_technical_summary("BTC-PERP", &ind, None);
1965        assert!(result.contains("ADX=15 (weak)"));
1966    }
1967
1968    #[test]
1969    fn test_format_price_formatting() {
1970        assert_eq!(format_price(67450.0), "67,450");
1971        assert_eq!(format_price(1234567.0), "1,234,567");
1972        assert_eq!(format_price(350.0), "350.00");
1973        assert_eq!(format_price(0.0045), "0.0045");
1974        assert_eq!(format_price(-1500.0), "-1,500");
1975    }
1976
1977    #[test]
1978    fn test_format_technical_summary_with_real_candles() {
1979        // Use realistic candle data to compute indicators, then format
1980        let candles = realistic_candles(60);
1981        let indicators = calculate_indicators(&candles);
1982        let last_price = candles.last().unwrap().close;
1983        let result = format_technical_summary("TEST-PERP", &indicators, Some(last_price));
1984
1985        // Should have header and at least some sections
1986        assert!(result.contains("Technical Analysis (TEST-PERP):"));
1987        assert!(result.contains("Trend:"));
1988        assert!(result.contains("Momentum:"));
1989        assert!(result.contains("Volatility:"));
1990    }
1991
1992    // -----------------------------------------------------------------------
1993    // ROC tests (#207)
1994    // -----------------------------------------------------------------------
1995
1996    #[test]
1997    fn test_roc_known_value() {
1998        // ROC = (110 - 100) / 100 * 100 = 10%
1999        let mut closes = vec![100.0; 13];
2000        closes[12] = 110.0;
2001        let candles = candles_from_closes(&closes);
2002        let result = compute_roc(&candles, 12);
2003        assert!((result.unwrap() - 10.0).abs() < 1e-6);
2004    }
2005
2006    #[test]
2007    fn test_roc_negative() {
2008        let mut closes = vec![100.0; 13];
2009        closes[12] = 90.0;
2010        let candles = candles_from_closes(&closes);
2011        let result = compute_roc(&candles, 12);
2012        assert!((result.unwrap() - (-10.0)).abs() < 1e-6);
2013    }
2014
2015    #[test]
2016    fn test_roc_insufficient_data() {
2017        let candles = candles_from_closes(&vec![100.0; 5]);
2018        assert!(compute_roc(&candles, 12).is_none());
2019    }
2020
2021    #[test]
2022    fn test_roc_in_calculate_indicators() {
2023        let candles = realistic_candles(20);
2024        let result = calculate_indicators(&candles);
2025        assert!(
2026            result.roc_12.is_some(),
2027            "ROC-12 should be computed with 20 candles"
2028        );
2029    }
2030
2031    // -----------------------------------------------------------------------
2032    // Donchian Channel tests (#208)
2033    // -----------------------------------------------------------------------
2034
2035    #[test]
2036    fn test_donchian_known_values() {
2037        let candles: Vec<Candle> = (1..=20)
2038            .map(|i| candle(i as f64, i as f64 + 0.5, i as f64 - 0.5, i as f64, 1000.0))
2039            .collect();
2040        let (upper, lower) = compute_donchian(&candles, 20);
2041        assert!((upper.unwrap() - 20.5).abs() < 1e-6); // highest high
2042        assert!((lower.unwrap() - 0.5).abs() < 1e-6); // lowest low
2043    }
2044
2045    #[test]
2046    fn test_donchian_insufficient_data() {
2047        let candles = realistic_candles(5);
2048        let (u, l) = compute_donchian(&candles, 20);
2049        assert!(u.is_none());
2050        assert!(l.is_none());
2051    }
2052
2053    #[test]
2054    fn test_donchian_in_calculate_indicators() {
2055        let candles = realistic_candles(25);
2056        let result = calculate_indicators(&candles);
2057        assert!(result.donchian_upper_20.is_some());
2058        assert!(result.donchian_lower_20.is_some());
2059        assert!(result.donchian_upper_10.is_some());
2060        assert!(result.donchian_lower_10.is_some());
2061    }
2062
2063    // -----------------------------------------------------------------------
2064    // Z-Score tests (#209)
2065    // -----------------------------------------------------------------------
2066
2067    #[test]
2068    fn test_zscore_close_constant() {
2069        let candles = candles_from_closes(&vec![100.0; 20]);
2070        let result = compute_zscore_close(&candles, 20);
2071        assert!(
2072            (result.unwrap()).abs() < 1e-6,
2073            "Z-Score should be 0 for constant prices"
2074        );
2075    }
2076
2077    #[test]
2078    fn test_zscore_close_known() {
2079        // 20 candles: 1..=20. mean=10.5, std=sqrt(sum((x-10.5)^2)/20)
2080        // last value=20, zscore=(20-10.5)/std
2081        let closes: Vec<f64> = (1..=20).map(|x| x as f64).collect();
2082        let candles = candles_from_closes(&closes);
2083        let z = compute_zscore_close(&candles, 20).unwrap();
2084        // Verify it's positive (last value is above mean)
2085        assert!(
2086            z > 0.0,
2087            "Z-Score should be positive when last > mean, got {}",
2088            z
2089        );
2090    }
2091
2092    #[test]
2093    fn test_zscore_volume_constant() {
2094        let candles = candles_from_closes(&vec![100.0; 20]);
2095        // candles_from_closes uses volume=1000 for all
2096        let result = compute_zscore_volume(&candles, 20);
2097        assert!(
2098            (result.unwrap()).abs() < 1e-6,
2099            "Volume Z-Score should be 0 for constant volume"
2100        );
2101    }
2102
2103    #[test]
2104    fn test_zscore_insufficient_data() {
2105        let candles = candles_from_closes(&vec![100.0; 5]);
2106        assert!(compute_zscore_close(&candles, 20).is_none());
2107        assert!(compute_zscore_volume(&candles, 20).is_none());
2108    }
2109
2110    // -----------------------------------------------------------------------
2111    // Historical Volatility tests (#210)
2112    // -----------------------------------------------------------------------
2113
2114    #[test]
2115    fn test_hv_constant_price() {
2116        let candles = candles_from_closes(&vec![100.0; 25]);
2117        let result = compute_hv(&candles, 20);
2118        // log returns are all 0, so HV = 0
2119        assert!(
2120            (result.unwrap()).abs() < 1e-6,
2121            "HV should be 0 for constant prices"
2122        );
2123    }
2124
2125    #[test]
2126    fn test_hv_positive_for_varying() {
2127        let candles = realistic_candles(25);
2128        let result = compute_hv(&candles, 20);
2129        assert!(
2130            result.unwrap() > 0.0,
2131            "HV should be positive for varying prices"
2132        );
2133    }
2134
2135    #[test]
2136    fn test_hv_insufficient_data() {
2137        let candles = candles_from_closes(&vec![100.0; 15]);
2138        assert!(compute_hv(&candles, 20).is_none());
2139    }
2140
2141    #[test]
2142    fn test_hv_in_calculate_indicators() {
2143        let candles = realistic_candles(65);
2144        let result = calculate_indicators(&candles);
2145        assert!(result.hv_20.is_some(), "HV-20 should be present");
2146        assert!(result.hv_60.is_some(), "HV-60 should be present");
2147    }
2148
2149    // -----------------------------------------------------------------------
2150    // Keltner Channel tests (#211)
2151    // -----------------------------------------------------------------------
2152
2153    #[test]
2154    fn test_keltner_with_enough_data() {
2155        let candles = realistic_candles(30);
2156        let (upper, lower) = compute_keltner(&candles, 20, 1.5);
2157        assert!(upper.is_some(), "KC upper should be computed");
2158        assert!(lower.is_some(), "KC lower should be computed");
2159    }
2160
2161    #[test]
2162    fn test_keltner_upper_gt_lower() {
2163        let candles = realistic_candles(30);
2164        let (upper, lower) = compute_keltner(&candles, 20, 1.5);
2165        assert!(
2166            upper.unwrap() > lower.unwrap(),
2167            "KC upper should be > lower"
2168        );
2169    }
2170
2171    #[test]
2172    fn test_keltner_constant_price() {
2173        // ATR=0 for constant prices, so upper=lower=ema
2174        let candles = candles_from_closes(&vec![100.0; 25]);
2175        let (upper, lower) = compute_keltner(&candles, 20, 1.5);
2176        let u = upper.unwrap();
2177        let l = lower.unwrap();
2178        assert!(
2179            (u - l).abs() < 1e-4,
2180            "KC bands should converge for constant prices"
2181        );
2182    }
2183
2184    #[test]
2185    fn test_keltner_insufficient_data() {
2186        let candles = candles_from_closes(&vec![100.0; 10]);
2187        let (u, l) = compute_keltner(&candles, 20, 1.5);
2188        assert!(u.is_none());
2189        assert!(l.is_none());
2190    }
2191
2192    // -----------------------------------------------------------------------
2193    // SuperTrend tests (#212)
2194    // -----------------------------------------------------------------------
2195
2196    #[test]
2197    fn test_supertrend_with_enough_data() {
2198        let candles = realistic_candles(30);
2199        let (value, direction) = compute_supertrend(&candles, 10, 3.0);
2200        assert!(value.is_some(), "SuperTrend value should be computed");
2201        assert!(
2202            direction.is_some(),
2203            "SuperTrend direction should be computed"
2204        );
2205    }
2206
2207    #[test]
2208    fn test_supertrend_direction_valid() {
2209        let candles = realistic_candles(30);
2210        let (_, direction) = compute_supertrend(&candles, 10, 3.0);
2211        let dir = direction.unwrap();
2212        assert!(
2213            dir == 1.0 || dir == -1.0,
2214            "SuperTrend direction should be 1.0 or -1.0, got {}",
2215            dir
2216        );
2217    }
2218
2219    #[test]
2220    fn test_supertrend_insufficient_data() {
2221        let candles = realistic_candles(5);
2222        let (v, d) = compute_supertrend(&candles, 10, 3.0);
2223        assert!(v.is_none());
2224        assert!(d.is_none());
2225    }
2226
2227    #[test]
2228    fn test_supertrend_uptrend_bullish() {
2229        // Strong uptrend: SuperTrend should be bullish (direction=1.0)
2230        let closes: Vec<f64> = (0..30).map(|i| 100.0 + i as f64 * 2.0).collect();
2231        let candles: Vec<Candle> = closes
2232            .iter()
2233            .map(|&c| candle(c - 0.5, c + 1.0, c - 1.0, c, 1000.0))
2234            .collect();
2235        let (_, direction) = compute_supertrend(&candles, 10, 3.0);
2236        assert_eq!(
2237            direction.unwrap(),
2238            1.0,
2239            "SuperTrend should be bullish in uptrend"
2240        );
2241    }
2242
2243    // -----------------------------------------------------------------------
2244    // VWAP tests (#213)
2245    // -----------------------------------------------------------------------
2246
2247    #[test]
2248    fn test_vwap_constant_price() {
2249        let candles = candles_from_closes(&vec![100.0; 20]);
2250        let result = compute_vwap(&candles);
2251        // typical price = (100+100+100)/3 = 100
2252        assert!((result.unwrap() - 100.0).abs() < 1e-6);
2253    }
2254
2255    #[test]
2256    fn test_vwap_empty() {
2257        let result = compute_vwap(&[]);
2258        assert!(result.is_none());
2259    }
2260
2261    #[test]
2262    fn test_vwap_single_candle() {
2263        let c = candle(100.0, 110.0, 90.0, 105.0, 1000.0);
2264        let result = compute_vwap(&[c]);
2265        // tp = (110+90+105)/3 = 101.666...
2266        let expected = (110.0 + 90.0 + 105.0) / 3.0;
2267        assert!((result.unwrap() - expected).abs() < 1e-4);
2268    }
2269
2270    #[test]
2271    fn test_vwap_in_calculate_indicators() {
2272        let candles = realistic_candles(20);
2273        let result = calculate_indicators(&candles);
2274        assert!(result.vwap.is_some(), "VWAP should be computed");
2275    }
2276
2277    // -----------------------------------------------------------------------
2278    // +DI/-DI tests (#214)
2279    // -----------------------------------------------------------------------
2280
2281    #[test]
2282    fn test_di_with_enough_data() {
2283        let candles = realistic_candles(60);
2284        let (plus_di, minus_di) = compute_di(&candles, 14);
2285        assert!(plus_di.is_some(), "+DI should be computed");
2286        assert!(minus_di.is_some(), "-DI should be computed");
2287    }
2288
2289    #[test]
2290    fn test_di_range() {
2291        let candles = realistic_candles(60);
2292        let (plus_di, minus_di) = compute_di(&candles, 14);
2293        let pdi = plus_di.unwrap();
2294        let mdi = minus_di.unwrap();
2295        assert!(pdi >= 0.0, "+DI should be >= 0, got {}", pdi);
2296        assert!(mdi >= 0.0, "-DI should be >= 0, got {}", mdi);
2297    }
2298
2299    #[test]
2300    fn test_di_insufficient_data() {
2301        let candles = realistic_candles(20);
2302        let (p, m) = compute_di(&candles, 14);
2303        assert!(p.is_none());
2304        assert!(m.is_none());
2305    }
2306
2307    #[test]
2308    fn test_di_in_calculate_indicators() {
2309        let candles = realistic_candles(60);
2310        let result = calculate_indicators(&candles);
2311        assert!(result.plus_di_14.is_some(), "plus_di_14 should be present");
2312        assert!(
2313            result.minus_di_14.is_some(),
2314            "minus_di_14 should be present"
2315        );
2316    }
2317
2318    // -----------------------------------------------------------------------
2319    // Phase 1 integration test
2320    // -----------------------------------------------------------------------
2321
2322    #[test]
2323    fn test_all_phase1_indicators_populated_with_enough_data() {
2324        let candles = realistic_candles(80);
2325        let result = calculate_indicators(&candles);
2326        assert!(result.roc_12.is_some(), "roc_12");
2327        assert!(result.donchian_upper_20.is_some(), "donchian_upper_20");
2328        assert!(result.donchian_lower_20.is_some(), "donchian_lower_20");
2329        assert!(result.donchian_upper_10.is_some(), "donchian_upper_10");
2330        assert!(result.donchian_lower_10.is_some(), "donchian_lower_10");
2331        assert!(result.close_zscore_20.is_some(), "close_zscore_20");
2332        assert!(result.volume_zscore_20.is_some(), "volume_zscore_20");
2333        assert!(result.hv_20.is_some(), "hv_20");
2334        assert!(result.hv_60.is_some(), "hv_60");
2335        assert!(result.kc_upper_20.is_some(), "kc_upper_20");
2336        assert!(result.kc_lower_20.is_some(), "kc_lower_20");
2337        assert!(result.supertrend_value.is_some(), "supertrend_value");
2338        assert!(
2339            result.supertrend_direction.is_some(),
2340            "supertrend_direction"
2341        );
2342        assert!(result.vwap.is_some(), "vwap");
2343        assert!(result.plus_di_14.is_some(), "plus_di_14");
2344        assert!(result.minus_di_14.is_some(), "minus_di_14");
2345    }
2346
2347    #[test]
2348    fn test_phase1_indicators_serialization() {
2349        let candles = realistic_candles(80);
2350        let result = calculate_indicators(&candles);
2351        let json = serde_json::to_value(&result).unwrap();
2352
2353        assert!(json.get("roc12").is_some(), "roc12 in JSON");
2354        assert!(
2355            json.get("donchianUpper20").is_some(),
2356            "donchianUpper20 in JSON"
2357        );
2358        assert!(
2359            json.get("donchianLower20").is_some(),
2360            "donchianLower20 in JSON"
2361        );
2362        assert!(json.get("closeZscore20").is_some(), "closeZscore20 in JSON");
2363        assert!(
2364            json.get("volumeZscore20").is_some(),
2365            "volumeZscore20 in JSON"
2366        );
2367        assert!(json.get("hv20").is_some(), "hv20 in JSON");
2368        assert!(json.get("hv60").is_some(), "hv60 in JSON");
2369        assert!(json.get("kcUpper20").is_some(), "kcUpper20 in JSON");
2370        assert!(json.get("kcLower20").is_some(), "kcLower20 in JSON");
2371        assert!(
2372            json.get("supertrendValue").is_some(),
2373            "supertrendValue in JSON"
2374        );
2375        assert!(
2376            json.get("supertrendDirection").is_some(),
2377            "supertrendDirection in JSON"
2378        );
2379        assert!(json.get("vwap").is_some(), "vwap in JSON");
2380        assert!(json.get("plusDi14").is_some(), "plusDi14 in JSON");
2381        assert!(json.get("minusDi14").is_some(), "minusDi14 in JSON");
2382    }
2383
2384    // -----------------------------------------------------------------------
2385    // Additional RSI edge-case tests (issue #270)
2386    // -----------------------------------------------------------------------
2387
2388    #[test]
2389    fn test_rsi_alternating_gains_losses() {
2390        // Alternating +1 / -1 changes should yield RSI near 50.
2391        let mut closes = vec![100.0];
2392        for i in 1..30 {
2393            if i % 2 == 0 {
2394                closes.push(closes[i - 1] + 1.0);
2395            } else {
2396                closes.push(closes[i - 1] - 1.0);
2397            }
2398        }
2399        let candles = candles_from_closes(&closes);
2400        let result = calculate_indicators(&candles);
2401        let rsi = result.rsi_14.unwrap();
2402        assert!(
2403            rsi > 30.0 && rsi < 70.0,
2404            "RSI for alternating market should be near 50, got {}",
2405            rsi
2406        );
2407    }
2408
2409    #[test]
2410    fn test_rsi_exactly_period_plus_one() {
2411        // Minimum data for RSI-14: exactly 15 candles
2412        let closes: Vec<f64> = (0..15).map(|x| 100.0 + x as f64).collect();
2413        let candles = candles_from_closes(&closes);
2414        let result = calculate_indicators(&candles);
2415        assert!(
2416            result.rsi_14.is_some(),
2417            "RSI should compute with exactly period+1 candles"
2418        );
2419    }
2420
2421    #[test]
2422    fn test_rsi_period_candles_insufficient() {
2423        // 14 candles is not enough (need 15 for RSI-14)
2424        let closes: Vec<f64> = (0..14).map(|x| 100.0 + x as f64).collect();
2425        let candles = candles_from_closes(&closes);
2426        let result = calculate_indicators(&candles);
2427        assert!(
2428            result.rsi_14.is_none(),
2429            "RSI should be None with only 14 candles"
2430        );
2431    }
2432
2433    #[test]
2434    fn test_rsi_large_spike_then_flat() {
2435        // Large spike followed by flat prices
2436        let mut closes = vec![100.0, 200.0]; // +100 spike
2437        for _ in 2..25 {
2438            closes.push(200.0); // flat after spike
2439        }
2440        let candles = candles_from_closes(&closes);
2441        let result = calculate_indicators(&candles);
2442        let rsi = result.rsi_14.unwrap();
2443        // After initial gain then flat, RSI should be high but decay toward 100
2444        assert!(
2445            rsi > 50.0,
2446            "RSI after spike then flat should still be elevated, got {}",
2447            rsi
2448        );
2449    }
2450
2451    // -----------------------------------------------------------------------
2452    // Additional MACD edge-case tests (issue #270)
2453    // -----------------------------------------------------------------------
2454
2455    #[test]
2456    fn test_macd_uptrend_positive_line() {
2457        // Strong uptrend: MACD line should be positive (fast EMA > slow EMA)
2458        let closes: Vec<f64> = (0..50).map(|x| 100.0 + x as f64 * 2.0).collect();
2459        let candles = candles_from_closes(&closes);
2460        let result = calculate_indicators(&candles);
2461        let line = result.macd_line.unwrap();
2462        assert!(
2463            line > 0.0,
2464            "MACD line should be positive in uptrend, got {}",
2465            line
2466        );
2467    }
2468
2469    #[test]
2470    fn test_macd_downtrend_negative_line() {
2471        // Strong downtrend: MACD line should be negative
2472        let closes: Vec<f64> = (0..50).map(|x| 200.0 - x as f64 * 2.0).collect();
2473        let candles = candles_from_closes(&closes);
2474        let result = calculate_indicators(&candles);
2475        let line = result.macd_line.unwrap();
2476        assert!(
2477            line < 0.0,
2478            "MACD line should be negative in downtrend, got {}",
2479            line
2480        );
2481    }
2482
2483    #[test]
2484    fn test_macd_insufficient_data_boundary() {
2485        // Exactly 25 candles (less than slow=26): MACD should be None
2486        let closes: Vec<f64> = (0..25).map(|x| 100.0 + x as f64).collect();
2487        let candles = candles_from_closes(&closes);
2488        let result = calculate_indicators(&candles);
2489        assert!(
2490            result.macd_line.is_none(),
2491            "MACD should be None with only 25 candles"
2492        );
2493    }
2494
2495    #[test]
2496    fn test_macd_exactly_slow_period() {
2497        // Exactly 26 candles: MACD should compute
2498        let closes: Vec<f64> = (0..26).map(|x| 100.0 + x as f64).collect();
2499        let candles = candles_from_closes(&closes);
2500        let result = calculate_indicators(&candles);
2501        assert!(
2502            result.macd_line.is_some(),
2503            "MACD should compute with exactly 26 candles"
2504        );
2505    }
2506
2507    // -----------------------------------------------------------------------
2508    // Indicator boundary / edge-case tests (issue #270)
2509    // -----------------------------------------------------------------------
2510
2511    #[test]
2512    fn test_bollinger_bands_high_volatility() {
2513        // Prices swing wildly: bands should be wide
2514        let mut closes = Vec::new();
2515        for i in 0..30 {
2516            if i % 2 == 0 {
2517                closes.push(100.0 + 20.0);
2518            } else {
2519                closes.push(100.0 - 20.0);
2520            }
2521        }
2522        let candles = candles_from_closes(&closes);
2523        let result = calculate_indicators(&candles);
2524        let upper = result.bb_upper.unwrap();
2525        let lower = result.bb_lower.unwrap();
2526        let bandwidth = upper - lower;
2527        assert!(
2528            bandwidth > 10.0,
2529            "BB bandwidth should be wide for volatile data, got {}",
2530            bandwidth
2531        );
2532    }
2533
2534    #[test]
2535    fn test_atr_high_volatility_vs_low() {
2536        // High volatility candles should produce larger ATR than low volatility
2537        let high_vol: Vec<Candle> = (0..20)
2538            .map(|i| Candle {
2539                time: i as u64,
2540                open: 100.0,
2541                high: 120.0,
2542                low: 80.0,
2543                close: 100.0,
2544                volume: 1000.0,
2545            })
2546            .collect();
2547        let low_vol: Vec<Candle> = (0..20)
2548            .map(|i| Candle {
2549                time: i as u64,
2550                open: 100.0,
2551                high: 101.0,
2552                low: 99.0,
2553                close: 100.0,
2554                volume: 1000.0,
2555            })
2556            .collect();
2557
2558        let atr_high = calculate_indicators(&high_vol).atr_14.unwrap();
2559        let atr_low = calculate_indicators(&low_vol).atr_14.unwrap();
2560        assert!(
2561            atr_high > atr_low,
2562            "ATR for high-vol ({}) should exceed ATR for low-vol ({})",
2563            atr_high,
2564            atr_low
2565        );
2566    }
2567
2568    #[test]
2569    fn test_obv_single_candle_insufficient() {
2570        let candles = candles_from_closes(&[100.0]);
2571        let result = calculate_indicators(&candles);
2572        assert!(result.obv.is_none(), "OBV needs at least 2 candles");
2573    }
2574
2575    #[test]
2576    fn test_vwap_zero_volume() {
2577        // Zero volume across all candles: VWAP should be None
2578        let candles: Vec<Candle> = (0..10)
2579            .map(|i| Candle {
2580                time: i as u64,
2581                open: 100.0,
2582                high: 105.0,
2583                low: 95.0,
2584                close: 100.0,
2585                volume: 0.0,
2586            })
2587            .collect();
2588        let result = calculate_indicators(&candles);
2589        assert!(
2590            result.vwap.is_none(),
2591            "VWAP should be None when total volume is 0"
2592        );
2593    }
2594
2595    #[test]
2596    fn test_williams_r_at_lowest_low() {
2597        // Close at the lowest low: Williams %R should be -100
2598        let candles = vec![
2599            Candle {
2600                time: 0,
2601                open: 110.0,
2602                high: 120.0,
2603                low: 100.0,
2604                close: 100.0,
2605                volume: 1000.0,
2606            },
2607            Candle {
2608                time: 1,
2609                open: 110.0,
2610                high: 120.0,
2611                low: 100.0,
2612                close: 100.0,
2613                volume: 1000.0,
2614            },
2615            Candle {
2616                time: 2,
2617                open: 110.0,
2618                high: 120.0,
2619                low: 100.0,
2620                close: 100.0,
2621                volume: 1000.0,
2622            },
2623            Candle {
2624                time: 3,
2625                open: 110.0,
2626                high: 120.0,
2627                low: 100.0,
2628                close: 100.0,
2629                volume: 1000.0,
2630            },
2631            Candle {
2632                time: 4,
2633                open: 110.0,
2634                high: 120.0,
2635                low: 100.0,
2636                close: 100.0,
2637                volume: 1000.0,
2638            },
2639            Candle {
2640                time: 5,
2641                open: 110.0,
2642                high: 120.0,
2643                low: 100.0,
2644                close: 100.0,
2645                volume: 1000.0,
2646            },
2647            Candle {
2648                time: 6,
2649                open: 110.0,
2650                high: 120.0,
2651                low: 100.0,
2652                close: 100.0,
2653                volume: 1000.0,
2654            },
2655            Candle {
2656                time: 7,
2657                open: 110.0,
2658                high: 120.0,
2659                low: 100.0,
2660                close: 100.0,
2661                volume: 1000.0,
2662            },
2663            Candle {
2664                time: 8,
2665                open: 110.0,
2666                high: 120.0,
2667                low: 100.0,
2668                close: 100.0,
2669                volume: 1000.0,
2670            },
2671            Candle {
2672                time: 9,
2673                open: 110.0,
2674                high: 120.0,
2675                low: 100.0,
2676                close: 100.0,
2677                volume: 1000.0,
2678            },
2679            Candle {
2680                time: 10,
2681                open: 110.0,
2682                high: 120.0,
2683                low: 100.0,
2684                close: 100.0,
2685                volume: 1000.0,
2686            },
2687            Candle {
2688                time: 11,
2689                open: 110.0,
2690                high: 120.0,
2691                low: 100.0,
2692                close: 100.0,
2693                volume: 1000.0,
2694            },
2695            Candle {
2696                time: 12,
2697                open: 110.0,
2698                high: 120.0,
2699                low: 100.0,
2700                close: 100.0,
2701                volume: 1000.0,
2702            },
2703            Candle {
2704                time: 13,
2705                open: 110.0,
2706                high: 120.0,
2707                low: 100.0,
2708                close: 100.0,
2709                volume: 1000.0,
2710            },
2711        ];
2712        let result = calculate_indicators(&candles);
2713        let wr = result.williams_r_14.unwrap();
2714        assert!(
2715            (wr - (-100.0)).abs() < 1e-6,
2716            "Williams %R should be -100 when close equals lowest low, got {}",
2717            wr
2718        );
2719    }
2720
2721    #[test]
2722    fn test_cci_zero_mean_deviation() {
2723        // All typical prices equal -> mean deviation = 0 -> CCI = 0
2724        let candles: Vec<Candle> = (0..25)
2725            .map(|i| Candle {
2726                time: i as u64,
2727                open: 100.0,
2728                high: 100.0,
2729                low: 100.0,
2730                close: 100.0,
2731                volume: 1000.0,
2732            })
2733            .collect();
2734        let result = calculate_indicators(&candles);
2735        let cci = result.cci_20.unwrap();
2736        assert!(
2737            cci.abs() < 1e-6,
2738            "CCI should be 0 when all typical prices are equal, got {}",
2739            cci
2740        );
2741    }
2742
2743    #[test]
2744    fn test_roc_zero_past_price() {
2745        // Past price = 0 should return None (division by zero guard)
2746        let mut candles: Vec<Candle> = (0..15)
2747            .map(|i| Candle {
2748                time: i as u64,
2749                open: 0.0,
2750                high: 0.0,
2751                low: 0.0,
2752                close: 0.0,
2753                volume: 1000.0,
2754            })
2755            .collect();
2756        // Make the last candle non-zero close
2757        candles.last_mut().unwrap().close = 100.0;
2758        let result = calculate_indicators(&candles);
2759        // motosan-ta-math may return NaN or Inf for division by zero,
2760        // which last_finite filters out as None
2761        assert!(
2762            result.roc_12.is_none(),
2763            "ROC should be None when past price is 0"
2764        );
2765    }
2766}