1use crate::model::{Bar, BarClock, Instrument, Timeframe};
4use crate::studies::{Indicator, IndicatorValues, InputKind, InputValue, StudyCandle, StudyInput};
5use egui::Color32;
6
7#[derive(Clone)]
18pub struct HigherIntervalCandles {
19 interval: Timeframe,
20 clock: BarClock,
21 instrument: Instrument,
22 extended_hours: bool,
23 candles: Vec<StudyCandle>,
24 values: IndicatorValues,
25 colors: [Color32; 2],
26 visible: bool,
27}
28
29impl HigherIntervalCandles {
30 pub fn new(interval: Timeframe) -> Self {
32 Self {
33 interval,
34 clock: BarClock::new(Timeframe::Min1),
35 instrument: Instrument::new(""),
36 extended_hours: true,
37 candles: Vec::new(),
38 values: IndicatorValues::new(),
39 colors: [
40 crate::studies::palette::DEEP_TEAL,
41 crate::studies::palette::BRIGHT_RED,
42 ],
43 visible: true,
44 }
45 }
46
47 fn intervals(&self) -> Vec<Timeframe> {
48 let mut intervals = Timeframe::all();
49 if !intervals.contains(&self.interval) {
50 intervals.push(self.interval);
51 }
52 intervals
53 }
54 fn calculate_from(&mut self, data: &[Bar], start: usize) {
55 self.values.truncate(start);
56 self.values
57 .resize(data.len(), crate::studies::IndicatorValue::None);
58 self.candles.truncate(
59 self.candles
60 .partition_point(|candle| candle.bars.end <= start),
61 );
62 if start == 0 && !data.is_empty() {
63 self.values.set_value(1, 0, f64::NAN);
64 }
65 if self.clock.timeframe().is_none_or(|source| {
66 source.duration_ms() == 0 || self.interval.duration_ms() <= source.duration_ms()
67 }) {
68 return;
69 }
70 let indices: Vec<_> = data
72 .iter()
73 .enumerate()
74 .skip(start)
75 .filter(|(_, bar)| crate::data::merge::valid(bar))
76 .map(|(i, _)| i)
77 .collect();
78 let bars: Vec<_> = indices.iter().map(|&i| data[i].clone()).collect();
79 let Some(periods) = crate::data::resample_periods(
80 &bars,
81 self.clock.clone(),
82 self.clock.for_timeframe(self.interval),
83 &self.instrument,
84 self.extended_hours,
85 ) else {
86 if start > 0 {
87 self.calculate_from(data, 0);
88 }
89 return;
90 };
91 for (range, bar) in periods {
92 let plot = usize::from(bar.close < bar.open);
93 for &i in &indices[range.clone()] {
94 self.values.set_value(plot, i, bar.close);
95 }
96 self.candles.push(StudyCandle {
97 bars: indices[range.start]..indices[range.end - 1] + 1,
98 bar,
99 plot,
100 });
101 }
102 }
103}
104
105impl Default for HigherIntervalCandles {
106 fn default() -> Self {
107 Self::new(Timeframe::Day1)
108 }
109}
110
111impl Indicator for HigherIntervalCandles {
112 fn name(&self) -> &str {
113 "Higher Interval Candles"
114 }
115 fn desc(&self) -> &str {
116 "Higher-interval OHLC candles built from chart bars"
117 }
118 fn inputs(&self) -> Vec<StudyInput> {
119 let intervals = self.intervals();
120 vec![
121 StudyInput::new(
122 "interval",
123 "Interval",
124 InputValue::Choice(intervals.iter().position(|t| *t == self.interval).unwrap()),
125 InputKind::Choice(intervals.iter().map(ToString::to_string).collect()),
126 )
127 .with_default(InputValue::Choice(
128 intervals
129 .iter()
130 .position(|t| *t == Timeframe::Day1)
131 .unwrap(),
132 )),
133 StudyInput::new(
134 "extended_hours",
135 "Include extended hours",
136 InputValue::Toggle(self.extended_hours),
137 InputKind::Toggle,
138 )
139 .with_default(InputValue::Toggle(true)),
140 ]
141 }
142 fn set_input(&mut self, id: &str, value: InputValue) -> Result<(), crate::studies::StudyError> {
143 match (id, value) {
144 ("interval", InputValue::Choice(index)) => {
145 self.interval = *self
146 .intervals()
147 .get(index)
148 .ok_or_else(|| crate::studies::StudyError::invalid_input(id))?
149 }
150 ("extended_hours", InputValue::Toggle(enabled)) => self.extended_hours = enabled,
151 _ => return Err(crate::studies::StudyError::rejected(&self.inputs(), id)),
152 }
153 self.candles.clear();
154 self.values.clear();
155 Ok(())
156 }
157 fn set_timeframe(&mut self, timeframe: Timeframe) {
158 self.clock = self.clock.for_timeframe(timeframe);
159 }
160 fn set_bar_clock(&mut self, clock: &BarClock) -> bool {
161 if self.clock == *clock {
162 return false;
163 }
164 self.clock = clock.clone();
165 true
166 }
167 fn set_instrument(&mut self, instrument: Option<&Instrument>) -> bool {
168 let next = instrument.cloned().unwrap_or_else(|| Instrument::new(""));
169 let changed =
170 self.instrument.time_zone != next.time_zone || self.instrument.session != next.session;
171 self.instrument = next;
172 changed
173 }
174 fn calculate(&mut self, data: &[Bar]) {
175 self.calculate_from(data, 0);
176 }
177 fn update(&mut self, data: &[Bar], from: usize) {
178 let start = self
179 .candles
180 .last()
181 .filter(|candle| {
182 from > 0
183 && from >= candle.bars.start
184 && from <= self.values.len()
185 && candle.bars.start < data.len()
186 })
187 .map_or(0, |candle| candle.bars.start);
188 self.calculate_from(data, start);
189 }
190 fn is_incremental(&self) -> bool {
191 !self.candles.is_empty()
192 }
193
194 fn values(&self) -> &IndicatorValues {
195 &self.values
196 }
197 fn candles(&self) -> &[StudyCandle] {
198 &self.candles
199 }
200 fn line_names(&self) -> Vec<String> {
201 vec!["Up".into(), "Down".into()]
202 }
203 fn colors(&self) -> Vec<Color32> {
204 self.colors.to_vec()
205 }
206 fn set_colors(&mut self, colors: Vec<Color32>) {
207 for (old, new) in self.colors.iter_mut().zip(colors) {
208 *old = new;
209 }
210 }
211 fn is_visible(&self) -> bool {
212 self.visible
213 }
214 fn set_visible(&mut self, visible: bool) {
215 self.visible = visible;
216 }
217 fn clone_box(&self) -> Box<dyn Indicator> {
218 Box::new(self.clone())
219 }
220}
221
222#[cfg(all(test, feature = "timezones"))]
223mod tests {
224 use super::*;
225 use crate::model::{Session, SessionWindow, TimezoneId};
226 use chrono::{NaiveTime, Weekday};
227
228 #[test]
229 fn daily_candles_cover_session_gaps_and_replace_the_forming_period() {
230 let clock = |h| NaiveTime::from_hms_opt(h, 0, 0).unwrap();
231 let instrument = Instrument::new("OVERNIGHT")
232 .with_time_zone(TimezoneId::named("America/New_York").unwrap())
233 .with_session(Session::new([SessionWindow::new(
234 [Weekday::Fri, Weekday::Mon, Weekday::Tue],
235 clock(18),
236 clock(17),
237 )]));
238 let bar =
239 |time: &str, [o, h, l, c]: [f64; 4]| Bar::new(time.parse().unwrap(), o, h, l, c, 1.0);
240 let mut data = vec![
241 bar("2024-03-07T23:00:00Z", [17.0, 18.0, 9.0, 12.0]),
242 bar("2024-03-08T02:00:00Z", [12.0, 15.0, 11.0, 14.0]),
243 bar("2024-03-08T21:00:00Z", [14.0, 16.0, 10.0, 11.0]),
244 bar("2024-03-10T22:00:00Z", [11.0, 12.0, 7.0, 8.0]),
246 bar("2024-03-11T03:00:00Z", [8.0, 10.0, 6.0, 9.0]),
247 bar("2024-03-11T22:00:00Z", [9.0, 11.0, 8.0, 10.0]),
249 ];
250 let mut study = crate::studies::IndicatorFactory::new()
251 .create("Higher Interval Candles")
252 .unwrap();
253 study.set_timeframe(Timeframe::Hour1);
254 study.set_instrument(Some(&instrument));
255 study.calculate(&data);
256 let check = |study: &dyn Indicator, expected: &[[f64; 4]]| {
257 assert_eq!(study.candles().len(), 3);
258 assert_eq!(study.values().len(), expected.len());
259 for (i, expected) in expected.iter().enumerate() {
260 let candle = study
261 .candles()
262 .iter()
263 .find(|c| c.bars.contains(&i))
264 .unwrap();
265 let bar = &candle.bar;
266 assert_eq!(
267 [bar.open, bar.high, bar.low, bar.close],
268 *expected,
269 "source bar {i}"
270 );
271 assert_eq!(study.values().value(candle.plot, i), Some(bar.close));
272 }
273 };
274 let mut expected = vec![[17.0, 18.0, 9.0, 11.0]; 3];
275 expected.extend([[11.0, 12.0, 6.0, 9.0]; 2]);
276 expected.push([9.0, 11.0, 8.0, 10.0]);
277 check(study.as_ref(), &expected);
278 assert_eq!(
279 study
280 .candles()
281 .iter()
282 .map(|c| c.bar.time.to_rfc3339())
283 .collect::<Vec<_>>(),
284 [
285 "2024-03-08T00:00:00+00:00",
286 "2024-03-11T00:00:00+00:00",
287 "2024-03-12T00:00:00+00:00"
288 ]
289 );
290 data[5] = bar("2024-03-11T22:00:00Z", [9.0, 10.0, 5.0, 6.0]);
292 study.update(&data, 5);
293 expected[5] = [9.0, 10.0, 5.0, 6.0];
294 check(study.as_ref(), &expected);
295 assert_eq!(study.values().value(0, 5), None);
296 data.push(bar("2024-03-11T23:00:00Z", [6.0, 12.0, 4.0, 11.0]));
297 study.update(&data, 6);
298 expected[5] = [9.0, 12.0, 4.0, 11.0];
299 expected.push(expected[5]);
300 check(study.as_ref(), &expected);
301 assert_eq!(study.values().value(1, 5), None);
302 #[cfg(feature = "widget")]
303 {
304 use crate::chart::coords::ChartMapping;
305 use crate::chart::rendering::{IndicatorRenderer, RenderContext};
306 use crate::scales::{PriceMap, PriceRange, PriceScaleMode, PriceTransform};
307 use crate::studies::ConfiguredStudy;
308 use egui::{Pos2, Rect, Shape};
309 let mut options = crate::config::ChartOptions::default();
310 options.time_scale.timezone =
311 crate::scales::TimezoneMode::Timezone("Asia/Tokyo".parse().unwrap());
312 let mut chart = crate::widget::ChartBuilder::new()
313 .with_chart_options(options)
314 .build();
315 chart.set_timeframe(Timeframe::Day1);
316 chart.set_instrument(Some(
317 Instrument::new("TOKYO").with_time_zone(TimezoneId::named("Asia/Tokyo").unwrap()),
318 ));
319 let monday = Bar::new(
320 "2024-03-10T15:00:00Z".parse().unwrap(),
321 10.0,
322 13.0,
323 9.0,
324 12.0,
325 1.0,
326 );
327 chart.set_data(crate::widget::SeriesId::MAIN, vec![monday.clone()]);
328 let id = chart.add_indicator(Box::new(HigherIntervalCandles::new(Timeframe::Week1)));
329 assert_eq!(
330 chart
331 .indicators()
332 .find(|(candidate, _)| *candidate == id)
333 .unwrap()
334 .1
335 .candles()[0]
336 .bar
337 .time,
338 monday.time
339 );
340 let start: chrono::DateTime<chrono::Utc> = "2024-03-08T00:00:00Z".parse().unwrap();
343 let bars: Vec<_> = (0..3)
344 .map(|i| {
345 Bar::new(
346 start + chrono::TimeDelta::hours(i),
347 10.0,
348 18.0,
349 2.0,
350 15.0,
351 1.0,
352 )
353 })
354 .collect();
355 for (mode, inverted, y_open, y_close, y_high, y_low) in [
356 (
357 PriceScaleMode::Normal,
358 false,
359 50.0_f32,
360 25.0_f32,
361 10.0,
362 90.0,
363 ),
364 (PriceScaleMode::Normal, true, 50.0, 75.0, 90.0, 10.0),
365 (
367 PriceScaleMode::Logarithmic,
368 false,
369 50.0,
370 41.195435,
371 37.236374,
372 84.9485,
373 ),
374 ] {
375 let mut study = ConfiguredStudy::new(Box::new(HigherIntervalCandles::default()));
376 study.set_timeframe(Timeframe::Hour1);
377 study.calculate(&bars);
378 let mut settings = study.settings();
379 settings.plots[0].color = Color32::BLUE;
380 study.apply_settings(&[], settings).unwrap();
381 let mut state =
382 crate::studies::ChartState::new(crate::model::BarData::from_bars(bars.clone()));
383 state.time_scale_mut().set_width(40.0);
384 state.time_scale_mut().set_bar_spacing(40.0);
385 state.price_scale_mut().options_mut().mode = mode;
386 let (transform, _) = state.fit_price_scale_with_series(
387 [&study as &dyn Indicator],
388 None,
389 false,
390 None,
391 None,
392 std::iter::empty(),
393 std::iter::empty(),
394 );
395 let fitted = state.price_scale().range();
396 assert!(
397 transform.to_price(fitted.min) <= 2.0 && transform.to_price(fitted.max) >= 18.0,
398 "the visible portion of a period must fit its complete wick"
399 );
400 let rect = Rect::from_min_max(Pos2::ZERO, Pos2::new(200.0, 100.0));
401 let range = if mode == PriceScaleMode::Normal {
402 PriceRange::new(0.0, 20.0)
403 } else {
404 PriceRange::new(0.0, 100.0_f64.ln())
405 };
406 let price = PriceMap::new(
407 PriceTransform { mode, base: 1.0 },
408 range,
409 0.0,
410 100.0,
411 inverted,
412 );
413 let coords = ChartMapping::new(rect, 40.0, 0, 3, 0.0, price);
414 let output = egui::Context::default().run_ui(egui::RawInput::default(), |ui| {
415 IndicatorRenderer::render_study(
416 &RenderContext::new(ui.painter(), rect),
417 &study,
418 &bars,
419 0,
420 &price,
421 &coords,
422 );
423 });
424 let body = output.shapes.iter().find_map(|shape| match &shape.shape {
425 Shape::Rect(body) if body.stroke.color == Color32::BLUE => Some(body.rect),
426 _ => None,
427 });
428 let wick = output.shapes.iter().find_map(|shape| match &shape.shape {
429 Shape::LineSegment { points, stroke } if stroke.color == Color32::BLUE => {
430 Some(*points)
431 }
432 _ => None,
433 });
434 output.drop_without_applying_deltas();
435 let body = body.expect("period body must replace the close-price line");
436 let wick = wick.expect("period wick");
437 for (actual, expected) in [
438 (body.left(), 41.0),
439 (body.right(), 157.0),
440 (body.top(), y_open.min(y_close)),
441 (body.bottom(), y_open.max(y_close)),
442 (wick[0].x, 99.0),
443 (wick[0].y, y_high),
444 (wick[1].y, y_low),
445 ] {
446 assert!(
447 (actual - expected).abs() < 0.001,
448 "{mode:?}, inverted {inverted}: {actual} != {expected}"
449 );
450 }
451 }
452 }
453 }
454}