use std::{fmt::Display, num::NonZero};
use crate::{
Indicator, IndicatorConfig, IndicatorConfigBuilder, PriceSource,
internals::{BarAction, BarState, EmaCore},
};
#[derive(PartialEq, Eq, Hash, Clone, Copy, Debug)]
pub struct MacdConfig {
fast_length: usize,
slow_length: usize,
signal_length: usize,
source: PriceSource,
}
impl IndicatorConfig for MacdConfig {
type Builder = MacdConfigBuilder;
fn builder() -> Self::Builder {
MacdConfigBuilder::new()
}
fn source(&self) -> PriceSource {
self.source
}
fn convergence(&self) -> usize {
self.slow_length
}
fn to_builder(&self) -> Self::Builder {
MacdConfigBuilder {
fast_length: Some(self.fast_length),
slow_length: Some(self.slow_length),
signal_length: Some(self.signal_length),
source: self.source,
}
}
}
impl MacdConfig {
#[must_use]
pub fn close(fast: NonZero<usize>, slow: NonZero<usize>, signal: NonZero<usize>) -> Self {
Self::builder()
.fast_length(fast)
.slow_length(slow)
.signal_length(signal)
.build()
}
#[must_use]
#[allow(clippy::missing_panics_doc)]
pub fn default_close() -> Self {
Self::close(
NonZero::new(12).unwrap(),
NonZero::new(26).unwrap(),
NonZero::new(9).unwrap(),
)
}
#[must_use]
pub fn fast_length(&self) -> usize {
self.fast_length
}
#[must_use]
pub fn slow_length(&self) -> usize {
self.slow_length
}
#[must_use]
pub fn signal_length(&self) -> usize {
self.signal_length
}
#[must_use]
pub fn full_convergence(&self) -> usize {
self.convergence() + self.signal_length - 1
}
}
impl Default for MacdConfig {
fn default() -> Self {
Self {
fast_length: 12,
slow_length: 26,
signal_length: 9,
source: PriceSource::Close,
}
}
}
impl Display for MacdConfig {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"MacdConfig({}, {}, {}, {})",
self.fast_length, self.slow_length, self.signal_length, self.source
)
}
}
pub struct MacdConfigBuilder {
fast_length: Option<usize>,
slow_length: Option<usize>,
signal_length: Option<usize>,
source: PriceSource,
}
impl MacdConfigBuilder {
fn new() -> Self {
Self {
fast_length: None,
slow_length: None,
signal_length: None,
source: PriceSource::Close,
}
}
#[must_use]
pub fn fast_length(mut self, length: NonZero<usize>) -> Self {
self.fast_length.replace(length.get());
self
}
#[must_use]
pub fn slow_length(mut self, length: NonZero<usize>) -> Self {
self.slow_length.replace(length.get());
self
}
#[must_use]
pub fn signal_length(mut self, length: NonZero<usize>) -> Self {
self.signal_length.replace(length.get());
self
}
}
impl IndicatorConfigBuilder<MacdConfig> for MacdConfigBuilder {
fn source(mut self, source: PriceSource) -> Self {
self.source = source;
self
}
fn build(self) -> MacdConfig {
let fast = self.fast_length.expect("fast_length is required");
let slow = self.slow_length.expect("slow_length is required");
assert!(fast < slow, "fast_length must be less than slow_length");
MacdConfig {
fast_length: fast,
slow_length: slow,
signal_length: self.signal_length.expect("signal_length is required"),
source: self.source,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct MacdValue {
macd: f64,
signal: Option<f64>,
histogram: Option<f64>,
}
impl MacdValue {
#[inline]
#[must_use]
pub fn macd(&self) -> f64 {
self.macd
}
#[inline]
#[must_use]
pub fn signal(&self) -> Option<f64> {
self.signal
}
#[inline]
#[must_use]
pub fn histogram(&self) -> Option<f64> {
self.histogram
}
}
impl Display for MacdValue {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "MacdValue(m: {}", self.macd)?;
match self.signal {
Some(v) => write!(f, ", s: {v}")?,
None => write!(f, ", s: -")?,
}
match self.histogram {
Some(v) => write!(f, ", h: {v}")?,
None => write!(f, ", h: -")?,
}
write!(f, ")")
}
}
#[derive(Clone, Debug)]
pub struct Macd {
fast: EmaCore,
slow: EmaCore,
signal: EmaCore,
config: MacdConfig,
current: Option<MacdValue>,
bar_state: BarState,
}
impl Indicator for Macd {
type Config = MacdConfig;
type Output = MacdValue;
fn new(config: Self::Config) -> Self {
Self {
config,
fast: EmaCore::new(config.fast_length()),
slow: EmaCore::new(config.slow_length()),
signal: EmaCore::new(config.signal_length()),
current: None,
bar_state: BarState::new(config.source()),
}
}
fn compute(&mut self, ohlcv: &impl crate::Ohlcv) -> Option<Self::Output> {
let signal_value = match self.bar_state.handle(ohlcv) {
BarAction::Advance(price) => {
let fast_value = self.fast.push(price);
let slow_value = self.slow.push(price);
match (fast_value, slow_value) {
(Some(fast), Some(slow)) => {
let macd = fast - slow;
let signal = self.signal.push(macd);
Some((macd, signal))
}
_ => None,
}
}
BarAction::Repaint(price) => {
let fast_value = self.fast.replace(price);
let slow_value = self.slow.replace(price);
match (fast_value, slow_value) {
(Some(fast), Some(slow)) => {
let macd = fast - slow;
let signal = self.signal.replace(macd);
Some((macd, signal))
}
_ => None,
}
}
};
self.current = match signal_value {
Some((macd, signal)) => Some(MacdValue {
macd,
signal,
histogram: signal.map(|v| macd - v),
}),
_ => None,
};
self.current
}
#[inline]
fn value(&self) -> Option<Self::Output> {
self.current
}
}
impl Display for Macd {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"MACD({}, {}, {}, {})",
self.config.fast_length,
self.config.slow_length,
self.config.signal_length,
self.config.source
)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::test_util::{Bar, bar, nz};
fn macd_3_6_4() -> Macd {
Macd::new(MacdConfig::close(nz(3), nz(6), nz(4)))
}
fn macd_default() -> Macd {
Macd::new(MacdConfig::default_close())
}
fn feed_sequential(macd: &mut Macd, n: u64) {
for i in 1..=n {
#[allow(clippy::cast_precision_loss)]
macd.compute(&bar(i as f64 * 10.0, i));
}
}
mod convergence {
use super::*;
#[test]
fn none_before_slow_ema_converges() {
let mut macd = macd_3_6_4();
for i in 1..=5 {
#[allow(clippy::cast_precision_loss)]
let f = i as f64;
assert_eq!(
macd.compute(&bar(f * 10.0, i)),
None,
"expected None at bar {i}"
);
}
}
#[test]
fn macd_line_available_at_slow_length() {
let mut macd = macd_3_6_4();
for i in 1..=5 {
#[allow(clippy::cast_precision_loss)]
macd.compute(&bar(i as f64 * 10.0, i));
}
let val = macd.compute(&bar(60.0, 6));
assert!(val.is_some(), "MACD should produce output at bar 6");
}
#[test]
fn signal_none_during_signal_seed() {
let mut macd = macd_3_6_4();
for i in 1..=7 {
#[allow(clippy::cast_precision_loss)]
macd.compute(&bar(i as f64 * 10.0, i));
}
let val = macd.value().unwrap();
assert!(val.signal().is_none(), "signal should be None during seed");
assert!(
val.histogram().is_none(),
"histogram should be None during seed"
);
}
#[test]
fn full_output_at_slow_plus_signal_minus_one() {
let mut macd = macd_3_6_4();
for i in 1..=9 {
#[allow(clippy::cast_precision_loss)]
macd.compute(&bar(i as f64 * 10.0, i));
}
let val = macd.value().unwrap();
assert!(val.signal().is_some(), "signal should be Some at bar 9");
assert!(
val.histogram().is_some(),
"histogram should be Some at bar 9"
);
}
#[test]
fn value_none_before_convergence() {
let macd = macd_3_6_4();
assert_eq!(macd.value(), None);
}
#[test]
fn value_matches_last_compute() {
let mut macd = macd_3_6_4();
feed_sequential(&mut macd, 10);
let computed = macd.compute(&bar(110.0, 11));
assert_eq!(macd.value(), computed);
}
}
mod macd_line {
use super::*;
#[test]
fn equals_fast_minus_slow() {
let mut macd = macd_3_6_4();
let mut fast = crate::Ema::new(crate::EmaConfig::close(nz(3)));
let mut slow = crate::Ema::new(crate::EmaConfig::close(nz(6)));
let prices = [10.0, 25.0, 18.0, 30.0, 22.0, 35.0, 28.0, 40.0, 33.0, 45.0];
for (i, &p) in prices.iter().enumerate() {
let b = bar(p, (i + 1) as u64);
macd.compute(&b);
fast.compute(&b);
slow.compute(&b);
if let (Some(f), Some(s)) = (fast.value(), slow.value()) {
let expected_macd = f - s;
let actual = macd.value().unwrap().macd();
assert!(
(actual - expected_macd).abs() < 1e-10,
"MACD line mismatch at bar {}: expected {expected_macd}, got {actual}",
i + 1
);
}
}
}
#[test]
fn zero_when_constant_price() {
let mut macd = macd_3_6_4();
for i in 1..=10 {
macd.compute(&bar(50.0, i));
}
let val = macd.value().unwrap();
assert!(
(val.macd()).abs() < 1e-10,
"MACD should be 0 for constant price"
);
}
}
mod signal_line {
use super::*;
#[test]
fn signal_is_ema_of_macd_line() {
let mut macd = macd_3_6_4();
let mut macd_values: Vec<f64> = Vec::new();
let prices = [
10.0, 25.0, 18.0, 30.0, 22.0, 35.0, 28.0, 40.0, 33.0, 45.0, 38.0, 50.0, 43.0, 55.0,
48.0,
];
for (i, &p) in prices.iter().enumerate() {
macd.compute(&bar(p, (i + 1) as u64));
if let Some(val) = macd.value() {
macd_values.push(val.macd());
}
}
assert!(macd_values.len() >= 4, "need at least 4 MACD values");
let sma_seed: f64 = macd_values[..4].iter().sum::<f64>() / 4.0;
let alpha = 2.0 / 5.0;
let mut expected_signal = sma_seed;
for &mv in &macd_values[4..] {
expected_signal = alpha * mv + (1.0 - alpha) * expected_signal;
}
let last_val = macd.value().unwrap();
let actual_signal = last_val.signal().unwrap();
assert!(
(actual_signal - expected_signal).abs() < 1e-10,
"signal mismatch: expected {expected_signal}, got {actual_signal}"
);
}
#[test]
fn zero_when_constant_price() {
let mut macd = macd_3_6_4();
for i in 1..=15 {
macd.compute(&bar(50.0, i));
}
let val = macd.value().unwrap();
assert!((val.signal().unwrap()).abs() < 1e-10);
}
}
mod histogram {
use super::*;
#[test]
fn equals_macd_minus_signal() {
let mut macd = macd_3_6_4();
for i in 1..=15 {
#[allow(clippy::cast_precision_loss)]
let f = i as f64;
macd.compute(&bar(f * 10.0 + f.sin() * 5.0, i));
}
let val = macd.value().unwrap();
if let (Some(sig), Some(hist)) = (val.signal(), val.histogram()) {
let expected = val.macd() - sig;
assert!(
(hist - expected).abs() < 1e-10,
"histogram should equal macd - signal"
);
}
}
#[test]
fn zero_when_constant_price() {
let mut macd = macd_3_6_4();
for i in 1..=15 {
macd.compute(&bar(50.0, i));
}
let val = macd.value().unwrap();
assert!((val.histogram().unwrap()).abs() < 1e-10);
}
}
mod repaint {
use super::*;
#[test]
fn forming_bar_updates_value() {
let mut macd = macd_3_6_4();
feed_sequential(&mut macd, 9);
let original = macd.compute(&bar(100.0, 10));
let repainted = macd.compute(&bar(200.0, 10));
assert_ne!(original, repainted, "repaint should change the value");
}
#[test]
fn repaint_matches_clean() {
let mut repainted = macd_3_6_4();
feed_sequential(&mut repainted, 9);
repainted.compute(&bar(100.0, 10));
repainted.compute(&bar(150.0, 10)); let val_repaint = repainted.compute(&bar(110.0, 11));
let mut clean = macd_3_6_4();
feed_sequential(&mut clean, 9);
clean.compute(&bar(150.0, 10));
let val_clean = clean.compute(&bar(110.0, 11));
assert_eq!(val_repaint, val_clean);
}
#[test]
fn multiple_repaints_stable() {
let mut macd = macd_3_6_4();
feed_sequential(&mut macd, 9);
macd.compute(&bar(100.0, 10));
macd.compute(&bar(120.0, 10));
macd.compute(&bar(140.0, 10));
let final_val = macd.compute(&bar(130.0, 10));
let mut clean = macd_3_6_4();
feed_sequential(&mut clean, 9);
let expected = clean.compute(&bar(130.0, 10));
assert_eq!(final_val, expected);
}
#[test]
fn repaint_during_signal_seed() {
let mut macd = macd_3_6_4();
feed_sequential(&mut macd, 7);
let original = macd.compute(&bar(80.0, 8));
let repainted = macd.compute(&bar(90.0, 8));
assert_ne!(original, repainted);
assert!(macd.value().unwrap().signal().is_none());
}
#[test]
fn repaint_then_signal_seed_completes_correctly() {
let mut repainted = macd_3_6_4();
feed_sequential(&mut repainted, 7);
repainted.compute(&bar(80.0, 8));
repainted.compute(&bar(85.0, 8)); let val_r = repainted.compute(&bar(90.0, 9)); let mut clean = macd_3_6_4();
feed_sequential(&mut clean, 7);
clean.compute(&bar(85.0, 8));
let val_c = clean.compute(&bar(90.0, 9));
assert_eq!(val_r, val_c);
}
}
mod edge_cases {
use super::*;
#[test]
fn constant_price_all_zeros() {
let mut macd = macd_3_6_4();
for i in 1..=20 {
macd.compute(&bar(100.0, i));
}
let val = macd.value().unwrap();
assert!((val.macd()).abs() < 1e-10);
assert!((val.signal().unwrap()).abs() < 1e-10);
assert!((val.histogram().unwrap()).abs() < 1e-10);
}
#[test]
fn trending_up_positive_macd() {
let mut macd = macd_3_6_4();
for i in 1..=15 {
#[allow(clippy::cast_precision_loss)]
macd.compute(&bar(i as f64 * 10.0, i));
}
assert!(macd.value().unwrap().macd() > 0.0);
}
#[test]
fn trending_down_negative_macd() {
let mut macd = macd_3_6_4();
for i in 1..=15 {
#[allow(clippy::cast_precision_loss)]
macd.compute(&bar(200.0 - i as f64 * 10.0, i));
}
assert!(macd.value().unwrap().macd() < 0.0);
}
}
mod clone {
use super::*;
#[test]
fn produces_independent_state() {
let mut macd = macd_3_6_4();
feed_sequential(&mut macd, 10);
let mut cloned = macd.clone();
let orig = macd.compute(&bar(200.0, 11)).unwrap();
let clone = cloned.compute(&bar(50.0, 11)).unwrap();
assert!(
(orig.macd() - clone.macd()).abs() > 1e-10,
"divergent inputs should give different MACD"
);
}
}
mod config {
use super::*;
use std::collections::HashSet;
#[test]
fn convergence_equals_slow_length() {
let config = MacdConfig::default_close();
assert_eq!(config.convergence(), 26);
let config = MacdConfig::close(nz(3), nz(6), nz(4));
assert_eq!(config.convergence(), 6);
}
#[test]
fn full_convergence_includes_signal() {
let config = MacdConfig::default_close();
assert_eq!(config.full_convergence(), 34);
let config = MacdConfig::close(nz(3), nz(6), nz(4));
assert_eq!(config.full_convergence(), 9);
}
#[test]
fn default_is_12_26_9() {
let config = MacdConfig::default_close();
assert_eq!(config.fast_length(), 12);
assert_eq!(config.slow_length(), 26);
assert_eq!(config.signal_length(), 9);
}
#[test]
fn custom_lengths() {
let config = MacdConfig::close(nz(5), nz(15), nz(7));
assert_eq!(config.fast_length(), 5);
assert_eq!(config.slow_length(), 15);
assert_eq!(config.signal_length(), 7);
}
#[test]
fn default_source_is_close() {
let config = MacdConfig::default_close();
assert_eq!(config.source(), PriceSource::Close);
}
#[test]
#[should_panic(expected = "fast_length must be less than slow_length")]
fn panics_when_fast_equals_slow() {
let _ = MacdConfig::close(nz(12), nz(12), nz(9));
}
#[test]
#[should_panic(expected = "fast_length must be less than slow_length")]
fn panics_when_fast_greater_than_slow() {
let _ = MacdConfig::close(nz(26), nz(12), nz(9));
}
#[test]
fn eq_and_hash() {
let a = MacdConfig::default_close();
let b = MacdConfig::default_close();
let c = MacdConfig::close(nz(5), nz(15), nz(7));
assert_eq!(a, b);
assert_ne!(a, c);
let mut set = HashSet::new();
set.insert(a);
assert!(set.contains(&b));
assert!(!set.contains(&c));
}
#[test]
fn to_builder_roundtrip() {
let config = MacdConfig::close(nz(12), nz(26), nz(9));
assert_eq!(config.to_builder().build(), config);
}
}
mod display {
use super::*;
#[test]
fn display_config() {
let config = MacdConfig::default_close();
assert_eq!(config.to_string(), "MacdConfig(12, 26, 9, Close)");
}
#[test]
fn display_macd() {
let macd = macd_default();
assert_eq!(macd.to_string(), "MACD(12, 26, 9, Close)");
}
#[test]
fn display_value_full() {
let val = MacdValue {
macd: 1.5,
signal: Some(1.2),
histogram: Some(0.3),
};
let s = val.to_string();
assert!(s.contains("1.5"));
assert!(s.contains("1.2"));
assert!(s.contains("0.3"));
}
#[test]
fn display_value_partial() {
let val = MacdValue {
macd: 1.5,
signal: None,
histogram: None,
};
let s = val.to_string();
assert!(s.contains("1.5"));
assert!(s.contains('-'));
}
}
mod price_source {
use super::*;
#[test]
fn uses_configured_source() {
let config = MacdConfig::builder()
.fast_length(nz(3))
.slow_length(nz(6))
.signal_length(nz(4))
.source(PriceSource::HL2)
.build();
let mut macd = Macd::new(config);
for i in 1..=10 {
#[allow(clippy::cast_precision_loss)]
let f = i as f64;
let b = Bar::new(
f * 10.0, f * 12.0, f * 8.0, f * 11.0, )
.at(i);
macd.compute(&b);
}
let mut macd_close = Macd::new(MacdConfig::close(nz(3), nz(6), nz(4)));
for i in 1..=10 {
#[allow(clippy::cast_precision_loss)]
let hl2 = i as f64 * 10.0;
macd_close.compute(&bar(hl2, i));
}
let val_hl2 = macd.value().unwrap();
let val_close = macd_close.value().unwrap();
assert!(
(val_hl2.macd() - val_close.macd()).abs() < 1e-10,
"HL2 source should match close source fed with HL2 values"
);
}
}
#[cfg(debug_assertions)]
mod invariants {
use super::*;
#[test]
#[should_panic(expected = "open_time must be non-decreasing")]
fn panics_on_decreasing_open_time() {
let mut macd = macd_3_6_4();
macd.compute(&bar(10.0, 2));
macd.compute(&bar(12.0, 1));
}
}
}