Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 14 additions & 4 deletions src/domain/chart/entities.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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;
Expand All @@ -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;
}
Expand Down
71 changes: 71 additions & 0 deletions src/domain/market_data/indicator_engine.rs
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,25 @@ impl MovingAverageEngine {
}
}

#[inline]
fn replace_sma(
win: &mut VecDeque<f64>,
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<f64>, alpha: f64, close: f64, out: &mut Vec<Price>) {
let val = match last {
Expand All @@ -89,6 +108,21 @@ impl MovingAverageEngine {
out.push(Price::from(val));
}

#[inline]
fn replace_ema(last: &mut Option<f64>, 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(
Expand Down Expand Up @@ -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(),
);
}
}
51 changes: 51 additions & 0 deletions tests/moving_average_updates.rs
Original file line number Diff line number Diff line change
@@ -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));
}
Loading