|
1 | 1 | #![cfg(feature = "render")] |
| 2 | +use price_chart_wasm::app::{current_interval, visible_range_by_time}; |
2 | 3 | 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 | +}; |
4 | 7 | use price_chart_wasm::infrastructure::rendering::renderer::dummy_renderer; |
5 | 8 | wasm_bindgen_test::wasm_bindgen_test_configure!(run_in_browser); |
6 | 9 | #[test] |
@@ -32,3 +35,95 @@ fn historical_sma20_rendered() { |
32 | 35 |
|
33 | 36 | assert!(!sma20_vertices.is_empty()); |
34 | 37 | } |
| 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