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
41 changes: 24 additions & 17 deletions src/domain/chart/entities.rs
Original file line number Diff line number Diff line change
Expand Up @@ -50,9 +50,10 @@ impl Chart {

pub fn add_candle(&mut self, candle: Candle) {
if let Some(base) = self.series.get_mut(&TimeInterval::TwoSeconds) {
let prev = base.count();
let latest_ts = base.latest().map(|c| c.timestamp.value());
let is_new_candle = latest_ts.is_none_or(|ts| candle.timestamp.value() > ts);
base.add_candle(candle.clone());
if base.count() > prev
if is_new_candle
&& let Some(engine) = self.ma_engines.get_mut(&TimeInterval::TwoSeconds)
{
engine.update_on_close(candle.ohlcv.close.value());
Expand Down Expand Up @@ -97,9 +98,10 @@ impl Chart {
let is_empty = self.get_candle_count() == 0;

if let Some(base) = self.series.get_mut(&TimeInterval::TwoSeconds) {
let prev = base.count();
let latest_ts = base.latest().map(|c| c.timestamp.value());
let is_new_candle = latest_ts.is_none_or(|ts| candle.timestamp.value() > ts);
base.add_candle(candle.clone());
if base.count() > prev
if is_new_candle
&& let Some(engine) = self.ma_engines.get_mut(&TimeInterval::TwoSeconds)
{
engine.update_on_close(candle.ohlcv.close.value());
Expand Down Expand Up @@ -198,26 +200,31 @@ impl Chart {
let bucket_start =
candle.timestamp.value() / interval.duration_ms() * interval.duration_ms();

if let Some(last) = series.latest_mut()
&& last.timestamp.value() == bucket_start
{
if candle.ohlcv.high > last.ohlcv.high {
last.ohlcv.high = candle.ohlcv.high;
}
if candle.ohlcv.low < last.ohlcv.low {
last.ohlcv.low = candle.ohlcv.low;
let latest_ts = series.latest().map(|c| c.timestamp.value());
if latest_ts == Some(bucket_start) {
if let Some(last) = series.latest_mut() {
if candle.ohlcv.high > last.ohlcv.high {
last.ohlcv.high = candle.ohlcv.high;
}
if candle.ohlcv.low < last.ohlcv.low {
last.ohlcv.low = candle.ohlcv.low;
}
last.ohlcv.close = candle.ohlcv.close;
last.ohlcv.volume =
Volume::from(last.ohlcv.volume.value() + candle.ohlcv.volume.value());
}
last.ohlcv.close = candle.ohlcv.close;
last.ohlcv.volume =
Volume::from(last.ohlcv.volume.value() + candle.ohlcv.volume.value());
continue;
}

let is_new_bucket = latest_ts.is_none_or(|ts| bucket_start > ts);
let was_full = series.count() == series.capacity();
let oldest_before = series.get_candles().front().map(|c| c.timestamp.value());
let new_candle = Aggregator::aggregate(std::slice::from_ref(&candle), *interval)
.unwrap_or_else(|| candle.clone());
let prev = series.count();
series.add_candle(new_candle.clone());
if series.count() > prev
let oldest_after = series.get_candles().front().map(|c| c.timestamp.value());
let replaced_oldest = was_full && oldest_before != oldest_after;
if (is_new_bucket || replaced_oldest)
&& let Some(engine) = self.ma_engines.get_mut(interval)
{
engine.update_on_close(new_candle.ohlcv.close.value());
Expand Down
81 changes: 81 additions & 0 deletions tests/moving_average_overflow.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
#![cfg(feature = "render")]

use price_chart_wasm::domain::{
chart::{Chart, value_objects::ChartType},
market_data::{Candle, OHLCV, Price, TimeInterval, Timestamp, Volume},
};
use price_chart_wasm::infrastructure::rendering::renderer::dummy_renderer;

#[test]
fn moving_averages_continue_after_capacity_overflow() {
let max_candles = 40usize;
let total_candles = max_candles * 30 + 60;
let mut chart = Chart::new("ma-overflow".to_string(), ChartType::Candlestick, max_candles);

let base_interval_ms = TimeInterval::TwoSeconds.duration_ms();
let minute_interval_ms = TimeInterval::OneMinute.duration_ms();

let mut minute_closes = Vec::new();
let mut current_bucket: Option<u64> = None;

for i in 0..total_candles {
let timestamp = i as u64 * base_interval_ms;
let close = i as f64;
let candle = Candle::new(
Timestamp::from_millis(timestamp),
OHLCV::new(
Price::from(close),
Price::from(close),
Price::from(close),
Price::from(close),
Volume::from(1.0),
),
);

let bucket_start = timestamp / minute_interval_ms * minute_interval_ms;
match current_bucket {
Some(active) if active == bucket_start => {
if let Some(last_close) = minute_closes.last_mut() {
*last_close = close;
}
}
_ => {
current_bucket = Some(bucket_start);
minute_closes.push(close);
}
}

chart.add_candle(candle);
}

chart.update_viewport_for_data();

let base_engine = chart.ma_engines.get(&TimeInterval::TwoSeconds).expect("base engine");
let base_data = base_engine.data();
assert_eq!(base_data.ema_12.len(), total_candles);
let expected_base_sma20_len = total_candles - 20 + 1;
assert_eq!(base_data.sma_20.len(), expected_base_sma20_len);
let last_index = total_candles - 1;
let expected_base_avg = ((last_index - 19) as f64 + last_index as f64) / 2.0;
let base_last = base_data.sma_20.last().expect("latest SMA20 value for base interval");
assert!((base_last.value() - expected_base_avg).abs() < 1e-9);

let minute_engine = chart.ma_engines.get(&TimeInterval::OneMinute).expect("minute engine");
let minute_data = minute_engine.data();
assert_eq!(minute_data.ema_12.len(), minute_closes.len());
let expected_minute_sma_len = minute_closes.len() - 20 + 1;
assert_eq!(minute_data.sma_20.len(), expected_minute_sma_len);
let minute_avg: f64 =
minute_closes[minute_closes.len() - 20..].iter().copied().sum::<f64>() / 20.0;
let minute_last = minute_data.sma_20.last().expect("latest SMA20 value for minute interval");
assert!((minute_last.value() - minute_avg).abs() < 1e-9);

let renderer = dummy_renderer();
let (_, vertices, _) = renderer.create_geometry_for_test(&chart);
for color in 2..=6 {
assert!(
vertices.iter().any(|v| (v.color_type - color as f32).abs() < f32::EPSILON),
"missing indicator vertices for color {color}"
);
}
}
Loading