kestrel-chartkit 0.11.3

High-performance Rust technical analysis library for indicator math, market regime classification, composite scoring, and SVG visualization.
Documentation
use std::collections::{HashMap, VecDeque};

use crate::model::Bar;

use super::smoothing::{crossed_over, crossed_under, Ema, ExtremeWindow};
use super::{Indicator, IndicatorAlert, IndicatorOutput};

/// Money Flow Index, smoothed, with a signal line.
///
/// Per bar the raw money flow is `typical * volume`, `typical = (high + low + close) / 3`; it counts
/// as positive when the typical price rose against the previous bar, negative when it fell, and not
/// at all on the first bar or when unchanged. Over the last `mfi_len` bars
/// `raw = 100 - 100 / (1 + positive / negative)`, with `50` without any flow, `100` without negative
/// and `0` without positive flow. **`value` is the line**, `Ema(avg_len)` of the raw MFI with the
/// first-sample seed; `extra["signal"]` is `Ema(sig_len)` of the line. Alerts require volume.
///
/// First output: with the `mfi_len`-th bar. [`Indicator::reset`] clears the window and averages.
pub struct Mfi {
    mfi_len: usize,
    mid_line: f64,
    oversold: f64,
    overbought: f64,
    require_extreme_zone: bool,

    prev_src: Option<f64>,
    flow_window: VecDeque<(f64, f64, f64)>,
    pos_sum: f64,
    neg_sum: f64,
    vol_sum: f64,

    mfi_avg: Ema,
    signal_avg: Ema,
    extreme_window: ExtremeWindow,
    prev_mfi_line: Option<f64>,
    prev_signal: Option<f64>,
    bars_seen: usize,

    alerts: MfiAlerts,
}

#[derive(Debug, Clone, Copy, PartialEq, Default)]
pub struct MfiAlerts {
    pub bull_extreme: bool,
    pub bear_extreme: bool,
    pub bull_mid_cross: bool,
    pub bear_mid_cross: bool,
    pub extreme_strength: f64,
}

impl Mfi {
    #[allow(clippy::too_many_arguments)]
    pub fn new(
        mfi_len: usize,
        avg_len: usize,
        sig_len: usize,
        mid_line: f64,
        overbought: f64,
        oversold: f64,
        lookback_extreme: usize,
        require_extreme_zone: bool,
    ) -> Self {
        Self {
            mfi_len,
            mid_line,
            oversold,
            overbought,
            require_extreme_zone,
            prev_src: None,
            flow_window: VecDeque::with_capacity(mfi_len),
            pos_sum: 0.0,
            neg_sum: 0.0,
            vol_sum: 0.0,
            mfi_avg: Ema::new(avg_len),
            signal_avg: Ema::new(sig_len),
            extreme_window: ExtremeWindow::new(lookback_extreme),
            prev_mfi_line: None,
            prev_signal: None,
            bars_seen: 0,
            alerts: MfiAlerts::default(),
        }
    }

    pub fn with_defaults() -> Self {
        Self::new(14, 3, 3, 50.0, 80.0, 20.0, 5, true)
    }
}

impl Indicator for Mfi {
    fn name(&self) -> &str {
        "mfi"
    }

    fn warmup_period(&self) -> usize {
        self.mfi_len
    }

    fn on_bar(&mut self, bar: &Bar) -> Option<IndicatorOutput> {
        self.alerts = MfiAlerts::default();
        self.bars_seen += 1;

        let src = bar.typical_price();
        let raw_flow = src * bar.volume;
        let (pos_flow, neg_flow) = match self.prev_src {
            None => (0.0, 0.0),
            Some(prev) if src > prev => (raw_flow, 0.0),
            Some(prev) if src < prev => (0.0, raw_flow),
            Some(_) => (0.0, 0.0),
        };
        self.prev_src = Some(src);

        if self.flow_window.len() == self.mfi_len {
            let (old_pos, old_neg, old_vol) = self.flow_window.pop_front().unwrap();
            self.pos_sum -= old_pos;
            self.neg_sum -= old_neg;
            self.vol_sum -= old_vol;
        }
        self.flow_window.push_back((pos_flow, neg_flow, bar.volume));
        self.pos_sum += pos_flow;
        self.neg_sum += neg_flow;
        self.vol_sum += bar.volume;

        if self.flow_window.len() < self.mfi_len {
            return None;
        }

        let has_volume = self.vol_sum > 0.0;
        let total_flow = self.pos_sum + self.neg_sum;
        let raw_mfi = if total_flow == 0.0 {
            50.0
        } else if self.neg_sum == 0.0 {
            100.0
        } else if self.pos_sum == 0.0 {
            0.0
        } else {
            100.0 - 100.0 / (1.0 + self.pos_sum / self.neg_sum)
        };

        let mfi_line = self.mfi_avg.update(raw_mfi)?;
        let signal = self.signal_avg.update(mfi_line)?;

        let extreme = self.extreme_window.push(mfi_line);
        let was_oversold = extreme
            .map(|(low, _)| low <= self.oversold)
            .unwrap_or(false);
        let was_overbought = extreme
            .map(|(_, high)| high >= self.overbought)
            .unwrap_or(false);

        if let (Some(prev_mfi), Some(prev_sig)) = (self.prev_mfi_line, self.prev_signal) {
            let bull_cross = crossed_over(prev_mfi, prev_sig, mfi_line, signal);
            let bear_cross = crossed_under(prev_mfi, prev_sig, mfi_line, signal);
            self.alerts.bull_extreme =
                has_volume && bull_cross && (!self.require_extreme_zone || was_oversold);
            self.alerts.bear_extreme =
                has_volume && bear_cross && (!self.require_extreme_zone || was_overbought);
            self.alerts.bull_mid_cross =
                has_volume && crossed_over(prev_mfi, self.mid_line, mfi_line, self.mid_line);
            self.alerts.bear_mid_cross =
                has_volume && crossed_under(prev_mfi, self.mid_line, mfi_line, self.mid_line);

            let (lowest, highest) = extreme.unwrap_or((mfi_line, mfi_line));
            self.alerts.extreme_strength = if self.alerts.bull_extreme {
                ((self.oversold - lowest) / self.oversold.abs()).clamp(0.0, 1.0)
            } else if self.alerts.bear_extreme {
                ((highest - self.overbought) / self.overbought.abs()).clamp(0.0, 1.0)
            } else {
                0.0
            };
        }
        self.prev_mfi_line = Some(mfi_line);
        self.prev_signal = Some(signal);

        let mut extra = HashMap::new();
        extra.insert("signal".to_string(), signal);

        Some(IndicatorOutput::with_extra(mfi_line, extra))
    }

    fn reset(&mut self) {
        self.prev_src = None;
        self.flow_window.clear();
        self.pos_sum = 0.0;
        self.neg_sum = 0.0;
        self.vol_sum = 0.0;
        self.mfi_avg.reset();
        self.signal_avg.reset();
        self.extreme_window.reset();
        self.prev_mfi_line = None;
        self.prev_signal = None;
        self.bars_seen = 0;
        self.alerts = MfiAlerts::default();
    }

    fn alerts(&self) -> Vec<IndicatorAlert> {
        let a = self.alerts;
        let mut out = Vec::new();
        if a.bull_extreme {
            out.push(IndicatorAlert {
                kind: "bull_extreme".to_string(),
                note: "MFI · BULL CROSS OVERSOLD".to_string(),
                strength: a.extreme_strength,
            });
        }
        if a.bear_extreme {
            out.push(IndicatorAlert {
                kind: "bear_extreme".to_string(),
                note: "MFI · BEAR CROSS OVERBOUGHT".to_string(),
                strength: a.extreme_strength,
            });
        }
        if a.bull_mid_cross {
            out.push(IndicatorAlert {
                kind: "bull_mid_cross".to_string(),
                note: "MFI · CROSS ABOVE 50".to_string(),
                strength: 1.0,
            });
        }
        if a.bear_mid_cross {
            out.push(IndicatorAlert {
                kind: "bear_mid_cross".to_string(),
                note: "MFI · CROSS BELOW 50".to_string(),
                strength: 1.0,
            });
        }
        out
    }
}