From f0c2dfa081ea1328bd9b59adf506799073a4214f Mon Sep 17 00:00:00 2001 From: Alexey <4681325+qqrm@users.noreply.github.com> Date: Thu, 18 Sep 2025 08:59:41 +0300 Subject: [PATCH] Finalize MA buckets and extend rendering test --- src/domain/chart/entities.rs | 32 ++++++++---- tests/historical_ma_render.rs | 97 ++++++++++++++++++++++++++++++++++- 2 files changed, 118 insertions(+), 11 deletions(-) diff --git a/src/domain/chart/entities.rs b/src/domain/chart/entities.rs index 9836431..1518540 100644 --- a/src/domain/chart/entities.rs +++ b/src/domain/chart/entities.rs @@ -1,7 +1,7 @@ use super::value_objects::{ChartType, Viewport}; use crate::domain::market_data::services::{Aggregator, IchimokuData}; use crate::domain::market_data::{Candle, CandleSeries, MovingAverageEngine, TimeInterval, Volume}; -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; /// Domain entity - Chart #[derive(Debug, Clone)] @@ -13,6 +13,7 @@ pub struct Chart { pub indicators: Vec, pub ichimoku: IchimokuData, pub ma_engines: HashMap, + open_buckets: HashSet, } impl Chart { @@ -45,6 +46,7 @@ impl Chart { indicators: Vec::new(), ichimoku: IchimokuData::default(), ma_engines, + open_buckets: HashSet::new(), } } @@ -79,6 +81,7 @@ impl Chart { for e in self.ma_engines.values_mut() { *e = MovingAverageEngine::new(); } + self.open_buckets.clear(); for candle in candles { if let Some(base) = self.series.get_mut(&TimeInterval::TwoSeconds) { @@ -213,21 +216,30 @@ impl Chart { last.ohlcv.volume = Volume::from(last.ohlcv.volume.value() + candle.ohlcv.volume.value()); } + self.open_buckets.insert(*interval); 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()); - series.add_candle(new_candle.clone()); - 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 previous_close = if is_new_bucket { + series.latest().map(|c| c.ohlcv.close.value()) + } else { + None + }; + + if is_new_bucket + && self.open_buckets.remove(interval) + && let Some(close) = previous_close && let Some(engine) = self.ma_engines.get_mut(interval) { - engine.update_on_close(new_candle.ohlcv.close.value()); + engine.update_on_close(close); + } + + let new_candle = Aggregator::aggregate(std::slice::from_ref(&candle), *interval) + .unwrap_or_else(|| candle.clone()); + series.add_candle(new_candle); + if is_new_bucket { + self.open_buckets.insert(*interval); } } } diff --git a/tests/historical_ma_render.rs b/tests/historical_ma_render.rs index 9e819d8..cefbc9d 100644 --- a/tests/historical_ma_render.rs +++ b/tests/historical_ma_render.rs @@ -1,6 +1,9 @@ #![cfg(feature = "render")] +use price_chart_wasm::app::{current_interval, visible_range_by_time}; use price_chart_wasm::domain::chart::{Chart, value_objects::ChartType}; -use price_chart_wasm::domain::market_data::{Candle, OHLCV, Price, Timestamp, Volume}; +use price_chart_wasm::domain::market_data::{ + Candle, OHLCV, Price, TimeInterval, Timestamp, Volume, +}; use price_chart_wasm::infrastructure::rendering::renderer::dummy_renderer; wasm_bindgen_test::wasm_bindgen_test_configure!(run_in_browser); #[test] @@ -32,3 +35,95 @@ fn historical_sma20_rendered() { assert!(!sma20_vertices.is_empty()); } + +#[test] +fn minute_ma_tracks_closed_buckets() { + const BASE_PRICE: f64 = 100.0; + const MINUTES: u64 = 15; + const EMA_PERIOD: usize = 12; + + fn minute_batch(minute: u64, price: f64) -> Vec { + (0..30) + .map(|i| { + let ts = minute * 60_000 + i * 2_000; + Candle::new( + Timestamp::from_millis(ts), + OHLCV::new( + Price::from(price), + Price::from(price + 0.5), + Price::from(price - 0.5), + Price::from(price), + Volume::from(1.0), + ), + ) + }) + .collect() + } + + let mut candles = Vec::new(); + for minute in 0..MINUTES { + candles.extend(minute_batch(minute, BASE_PRICE + minute as f64)); + } + + let mut chart = Chart::new("hist-minute-ma".to_string(), ChartType::Candlestick, 1000); + chart.set_historical_data(candles); + + let minute_series = chart.get_series(TimeInterval::OneMinute).expect("minute series available"); + let aggregated: Vec = minute_series.get_candles().iter().cloned().collect(); + assert_eq!(aggregated.len(), MINUTES as usize); + + let closes: Vec = aggregated.iter().map(|c| c.ohlcv.close.value()).collect(); + for (idx, close) in closes.iter().enumerate() { + let expected = BASE_PRICE + idx as f64; + assert!((close - expected).abs() < f64::EPSILON); + } + + assert!(closes.len() > 1, "need at least one closed bucket"); + let closed_closes = &closes[..closes.len() - 1]; + + let engine = + chart.ma_engines.get(&TimeInterval::OneMinute).expect("ma engine for minute interval"); + let ema12 = &engine.data().ema_12; + assert_eq!(ema12.len(), closed_closes.len()); + + let alpha = 2.0 / (EMA_PERIOD as f64 + 1.0); + let mut expected_ema = Vec::with_capacity(closed_closes.len()); + let mut last = closed_closes[0]; + expected_ema.push(last); + for &close in closed_closes.iter().skip(1) { + last = alpha * close + (1.0 - alpha) * last; + expected_ema.push(last); + } + + for (calc, exp) in ema12.iter().zip(expected_ema.iter()) { + assert!((calc.value() - *exp).abs() < 1e-6); + } + + let prev_interval = current_interval().get_untracked(); + current_interval().set(TimeInterval::OneMinute); + let renderer = dummy_renderer(); + let (_, vertices, _) = renderer.create_geometry_for_test(&chart); + current_interval().set(prev_interval); + + let ema_vertices: Vec<_> = vertices + .iter() + .filter(|v| { + (v.element_type - 2.0).abs() < f32::EPSILON && (v.color_type - 5.0).abs() < f32::EPSILON + }) + .collect(); + + let (start_idx, visible_len) = visible_range_by_time(&aggregated, &chart.viewport, 1.0); + let period_offset = EMA_PERIOD - 1; + let drawn_points = ema12 + .iter() + .enumerate() + .filter(|(idx, _)| { + let candle_idx = idx + period_offset; + *candle_idx >= start_idx && *candle_idx < start_idx + visible_len + }) + .count(); + + assert!(drawn_points >= 2, "EMA line should have at least two points"); + let expected_segments = drawn_points - 1; + assert_eq!(ema_vertices.len(), expected_segments * 6); +}