Skip to content

Commit 4ce92de

Browse files
committed
Finalize MA buckets and extend rendering test
1 parent c6eb4c2 commit 4ce92de

File tree

2 files changed

+118
-11
lines changed

2 files changed

+118
-11
lines changed

src/domain/chart/entities.rs

Lines changed: 22 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
use super::value_objects::{ChartType, Viewport};
22
use crate::domain::market_data::services::{Aggregator, IchimokuData};
33
use crate::domain::market_data::{Candle, CandleSeries, MovingAverageEngine, TimeInterval, Volume};
4-
use std::collections::HashMap;
4+
use std::collections::{HashMap, HashSet};
55

66
/// Domain entity - Chart
77
#[derive(Debug, Clone)]
@@ -13,6 +13,7 @@ pub struct Chart {
1313
pub indicators: Vec<Indicator>,
1414
pub ichimoku: IchimokuData,
1515
pub ma_engines: HashMap<TimeInterval, MovingAverageEngine>,
16+
open_buckets: HashSet<TimeInterval>,
1617
}
1718

1819
impl Chart {
@@ -45,6 +46,7 @@ impl Chart {
4546
indicators: Vec::new(),
4647
ichimoku: IchimokuData::default(),
4748
ma_engines,
49+
open_buckets: HashSet::new(),
4850
}
4951
}
5052

@@ -79,6 +81,7 @@ impl Chart {
7981
for e in self.ma_engines.values_mut() {
8082
*e = MovingAverageEngine::new();
8183
}
84+
self.open_buckets.clear();
8285

8386
for candle in candles {
8487
if let Some(base) = self.series.get_mut(&TimeInterval::TwoSeconds) {
@@ -223,21 +226,30 @@ impl Chart {
223226
{
224227
engine.replace_last_close(close);
225228
}
229+
self.open_buckets.insert(*interval);
226230
continue;
227231
}
228232

229233
let is_new_bucket = latest_ts.is_none_or(|ts| bucket_start > ts);
230-
let was_full = series.count() == series.capacity();
231-
let oldest_before = series.get_candles().front().map(|c| c.timestamp.value());
232-
let new_candle = Aggregator::aggregate(std::slice::from_ref(&candle), *interval)
233-
.unwrap_or_else(|| candle.clone());
234-
series.add_candle(new_candle.clone());
235-
let oldest_after = series.get_candles().front().map(|c| c.timestamp.value());
236-
let replaced_oldest = was_full && oldest_before != oldest_after;
237-
if (is_new_bucket || replaced_oldest)
234+
let previous_close = if is_new_bucket {
235+
series.latest().map(|c| c.ohlcv.close.value())
236+
} else {
237+
None
238+
};
239+
240+
if is_new_bucket
241+
&& self.open_buckets.remove(interval)
242+
&& let Some(close) = previous_close
238243
&& let Some(engine) = self.ma_engines.get_mut(interval)
239244
{
240-
engine.update_on_close(new_candle.ohlcv.close.value());
245+
engine.update_on_close(close);
246+
}
247+
248+
let new_candle = Aggregator::aggregate(std::slice::from_ref(&candle), *interval)
249+
.unwrap_or_else(|| candle.clone());
250+
series.add_candle(new_candle);
251+
if is_new_bucket {
252+
self.open_buckets.insert(*interval);
241253
}
242254
}
243255
}

tests/historical_ma_render.rs

Lines changed: 96 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
#![cfg(feature = "render")]
2+
use price_chart_wasm::app::{current_interval, visible_range_by_time};
23
use price_chart_wasm::domain::chart::{Chart, value_objects::ChartType};
3-
use price_chart_wasm::domain::market_data::{Candle, OHLCV, Price, Timestamp, Volume};
4+
use price_chart_wasm::domain::market_data::{
5+
Candle, OHLCV, Price, TimeInterval, Timestamp, Volume,
6+
};
47
use price_chart_wasm::infrastructure::rendering::renderer::dummy_renderer;
58
wasm_bindgen_test::wasm_bindgen_test_configure!(run_in_browser);
69
#[test]
@@ -32,3 +35,95 @@ fn historical_sma20_rendered() {
3235

3336
assert!(!sma20_vertices.is_empty());
3437
}
38+
39+
#[test]
40+
fn minute_ma_tracks_closed_buckets() {
41+
const BASE_PRICE: f64 = 100.0;
42+
const MINUTES: u64 = 15;
43+
const EMA_PERIOD: usize = 12;
44+
45+
fn minute_batch(minute: u64, price: f64) -> Vec<Candle> {
46+
(0..30)
47+
.map(|i| {
48+
let ts = minute * 60_000 + i * 2_000;
49+
Candle::new(
50+
Timestamp::from_millis(ts),
51+
OHLCV::new(
52+
Price::from(price),
53+
Price::from(price + 0.5),
54+
Price::from(price - 0.5),
55+
Price::from(price),
56+
Volume::from(1.0),
57+
),
58+
)
59+
})
60+
.collect()
61+
}
62+
63+
let mut candles = Vec::new();
64+
for minute in 0..MINUTES {
65+
candles.extend(minute_batch(minute, BASE_PRICE + minute as f64));
66+
}
67+
68+
let mut chart = Chart::new("hist-minute-ma".to_string(), ChartType::Candlestick, 1000);
69+
chart.set_historical_data(candles);
70+
71+
let minute_series = chart.get_series(TimeInterval::OneMinute).expect("minute series available");
72+
let aggregated: Vec<Candle> = minute_series.get_candles().iter().cloned().collect();
73+
assert_eq!(aggregated.len(), MINUTES as usize);
74+
75+
let closes: Vec<f64> = aggregated.iter().map(|c| c.ohlcv.close.value()).collect();
76+
for (idx, close) in closes.iter().enumerate() {
77+
let expected = BASE_PRICE + idx as f64;
78+
assert!((close - expected).abs() < f64::EPSILON);
79+
}
80+
81+
assert!(closes.len() > 1, "need at least one closed bucket");
82+
let closed_closes = &closes[..closes.len() - 1];
83+
84+
let engine =
85+
chart.ma_engines.get(&TimeInterval::OneMinute).expect("ma engine for minute interval");
86+
let ema12 = &engine.data().ema_12;
87+
assert_eq!(ema12.len(), closed_closes.len());
88+
89+
let alpha = 2.0 / (EMA_PERIOD as f64 + 1.0);
90+
let mut expected_ema = Vec::with_capacity(closed_closes.len());
91+
let mut last = closed_closes[0];
92+
expected_ema.push(last);
93+
for &close in closed_closes.iter().skip(1) {
94+
last = alpha * close + (1.0 - alpha) * last;
95+
expected_ema.push(last);
96+
}
97+
98+
for (calc, exp) in ema12.iter().zip(expected_ema.iter()) {
99+
assert!((calc.value() - *exp).abs() < 1e-6);
100+
}
101+
102+
let prev_interval = current_interval().get_untracked();
103+
current_interval().set(TimeInterval::OneMinute);
104+
let renderer = dummy_renderer();
105+
let (_, vertices, _) = renderer.create_geometry_for_test(&chart);
106+
current_interval().set(prev_interval);
107+
108+
let ema_vertices: Vec<_> = vertices
109+
.iter()
110+
.filter(|v| {
111+
(v.element_type - 2.0).abs() < f32::EPSILON && (v.color_type - 5.0).abs() < f32::EPSILON
112+
})
113+
.collect();
114+
115+
let (start_idx, visible_len) = visible_range_by_time(&aggregated, &chart.viewport, 1.0);
116+
let period_offset = EMA_PERIOD - 1;
117+
let drawn_points = ema12
118+
.iter()
119+
.enumerate()
120+
.filter(|(idx, _)| {
121+
let candle_idx = idx + period_offset;
122+
*candle_idx >= start_idx && *candle_idx < start_idx + visible_len
123+
})
124+
.count();
125+
126+
assert!(drawn_points >= 2, "EMA line should have at least two points");
127+
let expected_segments = drawn_points - 1;
128+
assert_eq!(ema_vertices.len(), expected_segments * 6);
129+
}

0 commit comments

Comments
 (0)