diff --git a/src/domain/chart/entities.rs b/src/domain/chart/entities.rs index 9836431..5da8d2e 100644 --- a/src/domain/chart/entities.rs +++ b/src/domain/chart/entities.rs @@ -99,12 +99,15 @@ impl Chart { if let Some(base) = self.series.get_mut(&TimeInterval::TwoSeconds) { let latest_ts = base.latest().map(|c| c.timestamp.value()); + let is_update = latest_ts == Some(candle.timestamp.value()); let is_new_candle = latest_ts.is_none_or(|ts| candle.timestamp.value() > ts); base.add_candle(candle.clone()); - if is_new_candle - && let Some(engine) = self.ma_engines.get_mut(&TimeInterval::TwoSeconds) - { - engine.update_on_close(candle.ohlcv.close.value()); + if let Some(engine) = self.ma_engines.get_mut(&TimeInterval::TwoSeconds) { + if is_new_candle { + engine.update_on_close(candle.ohlcv.close.value()); + } else if is_update { + engine.replace_last_close(candle.ohlcv.close.value()); + } } } self.update_aggregates(candle); @@ -202,6 +205,7 @@ impl Chart { let latest_ts = series.latest().map(|c| c.timestamp.value()); if latest_ts == Some(bucket_start) { + let mut new_close = None; if let Some(last) = series.latest_mut() { if candle.ohlcv.high > last.ohlcv.high { last.ohlcv.high = candle.ohlcv.high; @@ -212,6 +216,12 @@ impl Chart { last.ohlcv.close = candle.ohlcv.close; last.ohlcv.volume = Volume::from(last.ohlcv.volume.value() + candle.ohlcv.volume.value()); + new_close = Some(last.ohlcv.close.value()); + } + if let Some(close) = new_close + && let Some(engine) = self.ma_engines.get_mut(interval) + { + engine.replace_last_close(close); } continue; } diff --git a/src/domain/market_data/indicator_engine.rs b/src/domain/market_data/indicator_engine.rs index 73a355a..0090623 100644 --- a/src/domain/market_data/indicator_engine.rs +++ b/src/domain/market_data/indicator_engine.rs @@ -79,6 +79,25 @@ impl MovingAverageEngine { } } + #[inline] + fn replace_sma( + win: &mut VecDeque, + sum: &mut f64, + period: usize, + close: f64, + out: &mut [Price], + ) { + if let Some(last_value) = win.back_mut() { + *sum += close - *last_value; + *last_value = close; + if win.len() == period + && let Some(last_price) = out.last_mut() + { + *last_price = Price::from(*sum / period as f64); + } + } + } + #[inline] fn update_ema(last: &mut Option, alpha: f64, close: f64, out: &mut Vec) { let val = match last { @@ -89,6 +108,21 @@ impl MovingAverageEngine { out.push(Price::from(val)); } + #[inline] + fn replace_ema(last: &mut Option, alpha: f64, close: f64, out: &mut [Price]) { + if out.is_empty() { + *last = Some(close); + return; + } + + let prev = if out.len() >= 2 { out[out.len() - 2].value() } else { close }; + let val = alpha * close + (1.0 - alpha) * prev; + *last = Some(val); + if let Some(last_price) = out.last_mut() { + *last_price = Price::from(val); + } + } + /// Update indicators when a candle closes pub fn update_on_close(&mut self, close: f64) { Self::update_sma( @@ -129,4 +163,41 @@ impl MovingAverageEngine { pub fn data(&self) -> &MovingAveragesData { &self.data } + + /// Replace the latest close value, adjusting SMA/EMA sequences + pub fn replace_last_close(&mut self, close: f64) { + Self::replace_sma( + &mut self.sma20_win, + &mut self.sma20_sum, + 20, + close, + self.data.sma_20.as_mut_slice(), + ); + Self::replace_sma( + &mut self.sma50_win, + &mut self.sma50_sum, + 50, + close, + self.data.sma_50.as_mut_slice(), + ); + Self::replace_sma( + &mut self.sma200_win, + &mut self.sma200_sum, + 200, + close, + self.data.sma_200.as_mut_slice(), + ); + Self::replace_ema( + &mut self.ema12_last, + self.alpha12, + close, + self.data.ema_12.as_mut_slice(), + ); + Self::replace_ema( + &mut self.ema26_last, + self.alpha26, + close, + self.data.ema_26.as_mut_slice(), + ); + } } diff --git a/tests/moving_average_updates.rs b/tests/moving_average_updates.rs new file mode 100644 index 0000000..81e7a51 --- /dev/null +++ b/tests/moving_average_updates.rs @@ -0,0 +1,51 @@ +#![cfg(feature = "render")] + +use price_chart_wasm::domain::chart::Chart; +use price_chart_wasm::domain::chart::value_objects::ChartType; +use price_chart_wasm::domain::market_data::{ + Candle, OHLCV, Price, TimeInterval, Timestamp, Volume, indicator_engine::MovingAverageEngine, +}; +use wasm_bindgen_test::*; + +wasm_bindgen_test::wasm_bindgen_test_configure!(run_in_browser); + +fn candle(ts: u64, close: f64) -> Candle { + Candle::new( + Timestamp::from(ts), + OHLCV::new( + Price::from(close), + Price::from(close), + Price::from(close), + Price::from(close), + Volume::from(1.0), + ), + ) +} + +#[wasm_bindgen_test] +fn partial_realtime_updates_refresh_mas() { + let mut chart = Chart::new("test".into(), ChartType::Candlestick, 256); + + for i in 0..19_u64 { + chart.add_realtime_candle(candle(i * 2_000, (i + 1) as f64)); + } + + let last_ts = 19 * 2_000; + chart.add_realtime_candle(candle(last_ts, 20.0)); + chart.add_realtime_candle(candle(last_ts, 40.0)); + + let mut expected = MovingAverageEngine::new(); + for close in 1..=19 { + expected.update_on_close(close as f64); + } + expected.update_on_close(40.0); + + let base_engine = chart.ma_engines.get(&TimeInterval::TwoSeconds).expect("base engine"); + assert_eq!(base_engine.data().sma_20.last(), expected.data().sma_20.last()); + assert_eq!(base_engine.data().ema_12.last(), expected.data().ema_12.last()); + assert_eq!(base_engine.data().ema_26.last(), expected.data().ema_26.last()); + + let minute_engine = chart.ma_engines.get(&TimeInterval::OneMinute).expect("minute engine"); + assert_eq!(minute_engine.data().ema_12.last().map(|price| price.value()), Some(40.0)); + assert_eq!(minute_engine.data().ema_26.last().map(|price| price.value()), Some(40.0)); +}