1use super::time_formatter::TimeFormatter;
8use super::timescale_marks::{
9 TickMark, TickMarkGenerator, TickMarkGeneratorConfig, TickMarkType, TickMarkWeight,
10 keep_spaced_by,
11};
12use crate::model::Bar;
13use chrono::{DateTime, Duration, Utc};
14
15#[derive(Debug, Clone, Copy, PartialEq)]
17pub struct TickMarkLayout {
18 pub width: f32,
20 pub bar_spacing: f32,
22 pub min_spacing: f32,
24 pub uniform_distribution: bool,
27}
28
29pub trait HorizontalScale: Send + Sync {
36 fn is_numeric(&self) -> bool {
40 false
41 }
42
43 fn tick_marks(
50 &self,
51 points: &[(DateTime<Utc>, usize)],
52 layout: &TickMarkLayout,
53 ) -> Vec<TickMark>;
54
55 fn format_crosshair(&self, key: DateTime<Utc>, bar_seconds: i64) -> String;
58}
59
60impl HorizontalScale for Box<dyn TimeFormatter> {
62 fn tick_marks(
63 &self,
64 points: &[(DateTime<Utc>, usize)],
65 layout: &TickMarkLayout,
66 ) -> Vec<TickMark> {
67 let (Some(first), Some(last)) = (points.first(), points.last()) else {
68 return Vec::new();
69 };
70 let bar_duration = median_bar_duration(points.iter().map(|point| point.0));
72 let zoom_factor = (layout.bar_spacing / 8.0).clamp(0.5, 2.5);
73 let config = TickMarkGeneratorConfig {
74 min_spacing: layout.min_spacing,
75 max_marks: ((layout.width / layout.min_spacing).ceil() as usize + 2).clamp(6, 24),
76 show_subseconds: bar_duration < Duration::seconds(1),
77 use_24_hour: true,
78 target_density: if bar_duration < Duration::days(1) {
80 1.0
81 } else {
82 (1.0 * zoom_factor).clamp(0.6, 2.5)
83 },
84 };
85 TickMarkGenerator::with_formatter(config, (**self).clone_box())
86 .with_uniform_distribution(layout.uniform_distribution)
87 .generate_marks(first.0, last.0, layout.width, points)
88 }
89
90 fn format_crosshair(&self, key: DateTime<Utc>, bar_seconds: i64) -> String {
91 (**self).format_crosshair(key, bar_seconds)
92 }
93}
94
95pub(crate) fn median_bar_duration(times: impl Iterator<Item = DateTime<Utc>>) -> Duration {
98 let mut previous = None;
99 let mut deltas_ms: Vec<_> = times
100 .filter_map(|time| {
101 let delta = (time - previous.replace(time)?).num_milliseconds().abs();
102 (delta > 0).then_some(delta)
103 })
104 .collect();
105 deltas_ms.sort_unstable();
106 Duration::milliseconds(
107 deltas_ms
108 .get(deltas_ms.len() / 2)
109 .copied()
110 .unwrap_or(60_000),
111 )
112}
113
114pub fn key_time(key: f64) -> Option<DateTime<Utc>> {
119 let nanos = (key * 1e9).round();
120 (nanos.abs() < i64::MAX as f64).then(|| DateTime::from_timestamp_nanos(nanos as i64))
121}
122
123pub fn time_key(time: DateTime<Utc>) -> f64 {
125 time.timestamp() as f64 + f64::from(time.timestamp_subsec_nanos()) / 1e9
126}
127
128#[derive(Debug, Clone, Copy, PartialEq, Eq)]
135pub struct PriceHorizontalScale {
136 pub precision: usize,
138}
139
140impl Default for PriceHorizontalScale {
141 fn default() -> Self {
142 Self { precision: 2 }
143 }
144}
145
146impl HorizontalScale for PriceHorizontalScale {
147 fn is_numeric(&self) -> bool {
148 true
149 }
150
151 fn tick_marks(
152 &self,
153 points: &[(DateTime<Utc>, usize)],
154 layout: &TickMarkLayout,
155 ) -> Vec<TickMark> {
156 let weight = |price: f64| {
157 [
158 (100.0, 8),
159 (50.0, 7),
160 (25.0, 6),
161 (10.0, 5),
162 (5.0, 4),
163 (1.0, 3),
164 (0.5, 1),
165 ]
166 .into_iter()
167 .find(|(step, _)| price % step == 0.0)
168 .map_or(0, |(_, weight)| weight)
169 };
170 weighted_marks(points, layout, weight, |price| {
171 format!("{price:.*}", self.precision)
172 })
173 }
174
175 fn format_crosshair(&self, key: DateTime<Utc>, _bar_seconds: i64) -> String {
176 format!("{:.*}", self.precision, time_key(key))
177 }
178}
179
180#[derive(Debug, Clone, Copy, PartialEq, Eq)]
189pub struct MaturityScale {
190 pub base_resolution: u32,
192 pub minimum_range: u32,
194 pub start: u32,
196}
197
198impl Default for MaturityScale {
199 fn default() -> Self {
201 Self {
202 base_resolution: 1,
203 minimum_range: 120,
204 start: 0,
205 }
206 }
207}
208
209impl MaturityScale {
210 pub fn whitespace(&self, last: f64) -> Vec<Bar> {
215 let step = f64::from(self.base_resolution.max(1));
216 let minimum = f64::from(self.minimum_range);
217 let end = if last.is_finite() {
218 minimum.max(last.min(12_000.0))
219 } else {
220 minimum
221 };
222 (0..)
223 .map(|slot| f64::from(self.start) + f64::from(slot) * step)
224 .take_while(|months| *months <= end)
225 .filter_map(key_time)
226 .map(Bar::whitespace)
227 .collect()
228 }
229
230 fn format(months: f64) -> String {
231 let (years, rest) = ((months / 12.0).floor(), months % 12.0);
232 if months < 12.0 {
233 format!("{months}M")
234 } else if rest == 0.0 {
235 format!("{years}Y")
236 } else {
237 format!("{years}Y{rest}M")
238 }
239 }
240}
241
242impl HorizontalScale for MaturityScale {
243 fn is_numeric(&self) -> bool {
244 true
245 }
246
247 fn tick_marks(
248 &self,
249 points: &[(DateTime<Utc>, usize)],
250 layout: &TickMarkLayout,
251 ) -> Vec<TickMark> {
252 let weight = |months: f64| {
253 [
254 (120.0, 10),
255 (60.0, 9),
256 (36.0, 8),
257 (12.0, 7),
258 (6.0, 6),
259 (3.0, 5),
260 (1.0, 4),
261 ]
262 .into_iter()
263 .find(|(step, _)| months % step == 0.0)
264 .map_or(0, |(_, weight)| weight)
265 };
266 weighted_marks(points, layout, weight, Self::format)
267 }
268
269 fn format_crosshair(&self, key: DateTime<Utc>, _bar_seconds: i64) -> String {
270 Self::format(time_key(key))
271 }
272}
273
274fn weighted_marks(
278 points: &[(DateTime<Utc>, usize)],
279 layout: &TickMarkLayout,
280 weight: impl Fn(f64) -> u8,
281 label: impl Fn(f64) -> String,
282) -> Vec<TickMark> {
283 let marks = points
284 .iter()
285 .map(|&(time, index)| TickMark {
286 time,
287 mark_type: TickMarkType::Time,
288 weight: TickMarkWeight::new(weight(time_key(time))),
289 label: String::new(),
290 index,
291 bold: false,
292 })
293 .collect();
294 let mut kept = keep_spaced_by(
295 marks,
296 f64::from(layout.min_spacing / layout.bar_spacing),
297 layout.uniform_distribution,
298 |mark| time_key(mark.time),
299 );
300 let heaviest = kept.iter().map(|mark| mark.weight).max();
301 for mark in &mut kept {
302 mark.bold = Some(mark.weight) == heaviest;
303 mark.label = label(time_key(mark.time));
304 }
305 kept.sort_by_key(|mark| mark.index);
306 kept
307}
308
309#[cfg(test)]
310mod tests {
311 use super::*;
312
313 #[test]
316 fn numeric_scales_encode_keys_and_label_the_roundest_in_reach() {
317 for key in [4512.5, 101.1, -0.5, 0.0, 360.0] {
318 assert_eq!(
319 key_time(key).map(time_key),
320 Some(key),
321 "round trip of {key}"
322 );
323 }
324 for key in [f64::NAN, f64::INFINITY, 1e10] {
325 assert_eq!(key_time(key), None, "{key}");
326 }
327
328 let price = PriceHorizontalScale { precision: 0 };
329 let strikes = [4400.0, 4450.0, 4500.0, 4525.0, 4550.0, 4575.0, 4600.0];
330 let months: Vec<f64> = (0..=36).map(f64::from).collect();
331 type Case<'a> = (
333 &'a dyn HorizontalScale,
334 &'a [f64],
335 f32,
336 f32,
337 bool,
338 &'a [&'a str],
339 &'a [&'a str],
340 );
341 let cases: [Case; 4] = [
342 (
344 &price,
345 &strikes,
346 1.6,
347 80.0,
348 false,
349 &["4400", "4450", "4500", "4550", "4600"],
350 &["4400", "4500", "4600"],
351 ),
352 (
355 &price,
356 &[100.0, 110.0, 140.0],
357 2.0,
358 40.0,
359 false,
360 &["100", "140"],
361 &["100"],
362 ),
363 (
364 &price,
365 &[100.0, 110.0, 140.0],
366 2.0,
367 40.0,
368 true,
369 &["100"],
370 &["100"],
371 ),
372 (
374 &MaturityScale::default(),
375 &months,
376 10.0,
377 60.0,
378 false,
379 &["0M", "6M", "1Y", "1Y6M", "2Y", "2Y6M", "3Y"],
380 &["0M"],
381 ),
382 ];
383 for (scale, keys, bar_spacing, min_spacing, uniform_distribution, labels, bold) in cases {
384 let points: Vec<_> = keys
385 .iter()
386 .enumerate()
387 .map(|(index, &key)| (key_time(key).unwrap(), index))
388 .collect();
389 let layout = TickMarkLayout {
390 width: 800.0,
391 bar_spacing,
392 min_spacing,
393 uniform_distribution,
394 };
395 let marks = scale.tick_marks(&points, &layout);
396 let shown: Vec<_> = marks.iter().map(|mark| mark.label.as_str()).collect();
397 assert_eq!(shown, labels);
398 let heavy: Vec<_> = marks
399 .iter()
400 .filter(|mark| mark.bold)
401 .map(|mark| mark.label.as_str())
402 .collect();
403 assert_eq!(heavy, bold);
404 }
405
406 let at = |key| key_time(key).unwrap();
407 assert_eq!(
408 PriceHorizontalScale::default().format_crosshair(at(4512.5), 0),
409 "4512.50"
410 );
411 let maturity = MaturityScale::default();
412 for (months, label) in [(3.0, "3M"), (30.0, "2Y6M"), (360.0, "30Y")] {
413 assert_eq!(maturity.format_crosshair(at(months), 0), label);
414 }
415
416 let mut quarterly = MaturityScale {
417 base_resolution: 3,
418 minimum_range: 12,
419 start: 0,
420 };
421 for (minimum, last, slots) in [
423 (12, 7.0, 12.0),
424 (12, 20.0, 18.0),
425 (12, 12_003.0, 12_000.0),
426 (12, 1e12, 12_000.0),
427 (12, f64::INFINITY, 12.0),
428 (12, f64::NAN, 12.0),
429 (24_000, 1e12, 24_000.0),
430 ] {
431 quarterly.minimum_range = minimum;
432 let expected: Vec<_> = (0..=(slots as u32 / 3))
433 .map(|slot| at(f64::from(slot * 3)))
434 .collect();
435 let times: Vec<_> = quarterly
436 .whitespace(last)
437 .iter()
438 .map(|bar| bar.time)
439 .collect();
440 assert_eq!(times.len(), expected.len(), "whitespace to {last}");
441 assert_eq!(times, expected, "whitespace to {last}");
442 }
443 }
444}