1use crate::presentation::serialization::{
2 string_as_bool_opt, string_as_float_opt, string_as_int_opt,
3};
4use pretty_simple_display::{DebugPretty, DisplaySimple};
5use serde::{Deserialize, Serialize};
6use std::{collections::HashMap, fmt};
7use tracing::warn;
8
9#[repr(u8)]
11#[derive(Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash, Default)]
12pub enum ChartScale {
13 #[serde(rename = "SECOND")]
15 Second,
16 #[serde(rename = "1MINUTE")]
18 OneMinute,
19 #[serde(rename = "5MINUTE")]
21 FiveMinute,
22 #[serde(rename = "HOUR")]
24 Hour,
25 #[serde(rename = "TICK")]
27 #[default]
28 Tick,
29}
30
31impl fmt::Debug for ChartScale {
32 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
33 let s = match self {
34 ChartScale::Second => "SECOND",
35 ChartScale::OneMinute => "1MINUTE",
36 ChartScale::FiveMinute => "5MINUTE",
37 ChartScale::Hour => "HOUR",
38 ChartScale::Tick => "TICK",
39 };
40 write!(f, "{}", s)
41 }
42}
43
44impl fmt::Display for ChartScale {
45 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
46 write!(f, "{:?}", self)
47 }
48}
49
50#[derive(DebugPretty, DisplaySimple, Clone, Serialize, Deserialize, Default)]
51pub struct ChartData {
54 pub item_name: String,
56 pub item_pos: usize,
58 #[serde(default)]
60 pub scale: ChartScale, pub fields: ChartFields,
63 pub changed_fields: ChartFields,
65 pub is_snapshot: bool,
67}
68
69#[derive(DebugPretty, DisplaySimple, Clone, Serialize, Deserialize, Default)]
71pub struct ChartFields {
72 #[serde(rename = "LTV")]
74 #[serde(with = "string_as_float_opt")]
75 #[serde(default)]
76 pub last_traded_volume: Option<f64>,
78
79 #[serde(rename = "TTV")]
80 #[serde(with = "string_as_float_opt")]
81 #[serde(default)]
82 pub incremental_trading_volume: Option<f64>,
84
85 #[serde(rename = "UTM")]
86 #[serde(with = "string_as_int_opt")]
87 #[serde(default)]
88 pub update_time: Option<i64>,
93
94 #[serde(rename = "DAY_OPEN_MID")]
95 #[serde(with = "string_as_float_opt")]
96 #[serde(default)]
97 pub day_open_mid: Option<f64>,
99
100 #[serde(rename = "DAY_NET_CHG_MID")]
101 #[serde(with = "string_as_float_opt")]
102 #[serde(default)]
103 pub day_net_change_mid: Option<f64>,
105
106 #[serde(rename = "DAY_PERC_CHG_MID")]
107 #[serde(with = "string_as_float_opt")]
108 #[serde(default)]
109 pub day_percentage_change_mid: Option<f64>,
111
112 #[serde(rename = "DAY_HIGH")]
113 #[serde(with = "string_as_float_opt")]
114 #[serde(default)]
115 pub day_high: Option<f64>,
117
118 #[serde(rename = "DAY_LOW")]
119 #[serde(with = "string_as_float_opt")]
120 #[serde(default)]
121 pub day_low: Option<f64>,
123
124 #[serde(rename = "BID")]
126 #[serde(with = "string_as_float_opt")]
127 #[serde(default)]
128 pub bid: Option<f64>,
130
131 #[serde(rename = "OFR")]
132 #[serde(with = "string_as_float_opt")]
133 #[serde(default)]
134 pub offer: Option<f64>,
136
137 #[serde(rename = "LTP")]
138 #[serde(with = "string_as_float_opt")]
139 #[serde(default)]
140 pub last_traded_price: Option<f64>,
142
143 #[serde(rename = "OFR_OPEN")]
145 #[serde(with = "string_as_float_opt")]
146 #[serde(default)]
147 pub offer_open: Option<f64>,
149
150 #[serde(rename = "OFR_HIGH")]
151 #[serde(with = "string_as_float_opt")]
152 #[serde(default)]
153 pub offer_high: Option<f64>,
155
156 #[serde(rename = "OFR_LOW")]
157 #[serde(with = "string_as_float_opt")]
158 #[serde(default)]
159 pub offer_low: Option<f64>,
161
162 #[serde(rename = "OFR_CLOSE")]
163 #[serde(with = "string_as_float_opt")]
164 #[serde(default)]
165 pub offer_close: Option<f64>,
167
168 #[serde(rename = "BID_OPEN")]
169 #[serde(with = "string_as_float_opt")]
170 #[serde(default)]
171 pub bid_open: Option<f64>,
173
174 #[serde(rename = "BID_HIGH")]
175 #[serde(with = "string_as_float_opt")]
176 #[serde(default)]
177 pub bid_high: Option<f64>,
179
180 #[serde(rename = "BID_LOW")]
181 #[serde(with = "string_as_float_opt")]
182 #[serde(default)]
183 pub bid_low: Option<f64>,
185
186 #[serde(rename = "BID_CLOSE")]
187 #[serde(with = "string_as_float_opt")]
188 #[serde(default)]
189 pub bid_close: Option<f64>,
191
192 #[serde(rename = "LTP_OPEN")]
193 #[serde(with = "string_as_float_opt")]
194 #[serde(default)]
195 pub ltp_open: Option<f64>,
197
198 #[serde(rename = "LTP_HIGH")]
199 #[serde(with = "string_as_float_opt")]
200 #[serde(default)]
201 pub ltp_high: Option<f64>,
203
204 #[serde(rename = "LTP_LOW")]
205 #[serde(with = "string_as_float_opt")]
206 #[serde(default)]
207 pub ltp_low: Option<f64>,
209
210 #[serde(rename = "LTP_CLOSE")]
211 #[serde(with = "string_as_float_opt")]
212 #[serde(default)]
213 pub ltp_close: Option<f64>,
215
216 #[serde(rename = "CONS_END")]
217 #[serde(with = "string_as_bool_opt")]
218 #[serde(default)]
219 pub candle_end: Option<bool>,
223
224 #[serde(rename = "CONS_TICK_COUNT")]
225 #[serde(with = "string_as_float_opt")]
226 #[serde(default)]
227 pub candle_tick_count: Option<f64>,
229}
230
231impl ChartData {
232 pub fn from_fields(
250 item_name: Option<&str>,
251 item_pos: usize,
252 is_snapshot: bool,
253 fields: &HashMap<String, Option<String>>,
254 changed_fields: &HashMap<String, Option<String>>,
255 ) -> Result<Self, String> {
256 let scale = match item_name {
261 Some(name) => match name.split(':').nth(2) {
262 Some("TICK") => ChartScale::Tick,
263 Some("SECOND") => ChartScale::Second,
264 Some("1MINUTE") => ChartScale::OneMinute,
265 Some("5MINUTE") => ChartScale::FiveMinute,
266 Some("HOUR") => ChartScale::Hour,
267 other => {
268 warn!(
269 item_name = %name,
270 scale = ?other,
271 "unrecognized chart scale in item name; defaulting to {:?}",
272 ChartScale::default()
273 );
274 ChartScale::default()
275 }
276 },
277 None => ChartScale::default(),
278 };
279
280 let parsed_fields = Self::create_chart_fields(fields)?;
282 let parsed_changed_fields = Self::create_chart_fields(changed_fields)?;
283
284 Ok(ChartData {
285 item_name: item_name.unwrap_or_default().to_string(),
286 item_pos,
287 scale,
288 fields: parsed_fields,
289 changed_fields: parsed_changed_fields,
290 is_snapshot,
291 })
292 }
293
294 fn create_chart_fields(
296 fields_map: &HashMap<String, Option<String>>,
297 ) -> Result<ChartFields, String> {
298 let get_field = |key: &str| -> Option<String> { fields_map.get(key).cloned().flatten() };
300
301 let parse_float = |key: &str| -> Result<Option<f64>, String> {
303 match get_field(key) {
304 Some(val) if !val.is_empty() => val
305 .parse::<f64>()
306 .map(Some)
307 .map_err(|_| format!("Failed to parse {key} as float: {val}")),
308 _ => Ok(None),
309 }
310 };
311
312 let parse_int = |key: &str| -> Result<Option<i64>, String> {
314 match get_field(key) {
315 Some(val) if !val.is_empty() => val
316 .parse::<i64>()
317 .map(Some)
318 .map_err(|_| format!("Failed to parse {key} as integer: {val}")),
319 _ => Ok(None),
320 }
321 };
322
323 let candle_end = match get_field("CONS_END").as_deref() {
327 Some("0") => Some(false),
328 Some("1") => Some(true),
329 Some("") | None => None,
330 Some(val) => return Err(format!("Invalid CONS_END value: {val}")),
331 };
332
333 Ok(ChartFields {
334 last_traded_volume: parse_float("LTV")?,
336 incremental_trading_volume: parse_float("TTV")?,
337 update_time: parse_int("UTM")?,
338 day_open_mid: parse_float("DAY_OPEN_MID")?,
339 day_net_change_mid: parse_float("DAY_NET_CHG_MID")?,
340 day_percentage_change_mid: parse_float("DAY_PERC_CHG_MID")?,
341 day_high: parse_float("DAY_HIGH")?,
342 day_low: parse_float("DAY_LOW")?,
343
344 bid: parse_float("BID")?,
346 offer: parse_float("OFR")?,
347 last_traded_price: parse_float("LTP")?,
348
349 offer_open: parse_float("OFR_OPEN")?,
351 offer_high: parse_float("OFR_HIGH")?,
352 offer_low: parse_float("OFR_LOW")?,
353 offer_close: parse_float("OFR_CLOSE")?,
354 bid_open: parse_float("BID_OPEN")?,
355 bid_high: parse_float("BID_HIGH")?,
356 bid_low: parse_float("BID_LOW")?,
357 bid_close: parse_float("BID_CLOSE")?,
358 ltp_open: parse_float("LTP_OPEN")?,
359 ltp_high: parse_float("LTP_HIGH")?,
360 ltp_low: parse_float("LTP_LOW")?,
361 ltp_close: parse_float("LTP_CLOSE")?,
362 candle_end,
363 candle_tick_count: parse_float("CONS_TICK_COUNT")?,
364 })
365 }
366
367 #[must_use]
369 #[inline]
370 pub fn is_tick(&self) -> bool {
371 matches!(self.scale, ChartScale::Tick)
372 }
373
374 #[must_use]
376 #[inline]
377 pub fn is_candle(&self) -> bool {
378 !self.is_tick()
379 }
380
381 #[must_use]
383 #[inline]
384 pub fn get_scale(&self) -> &ChartScale {
385 &self.scale
386 }
387}
388
389#[cfg(test)]
390mod tests {
391 use super::*;
392
393 #[test]
394 fn test_chart_scale_default() {
395 let scale = ChartScale::default();
396 assert_eq!(scale, ChartScale::Tick);
397 }
398
399 #[test]
400 fn test_chart_scale_debug() {
401 assert_eq!(format!("{:?}", ChartScale::Second), "SECOND");
402 assert_eq!(format!("{:?}", ChartScale::OneMinute), "1MINUTE");
403 assert_eq!(format!("{:?}", ChartScale::FiveMinute), "5MINUTE");
404 assert_eq!(format!("{:?}", ChartScale::Hour), "HOUR");
405 assert_eq!(format!("{:?}", ChartScale::Tick), "TICK");
406 }
407
408 #[test]
409 fn test_chart_scale_display() {
410 assert_eq!(format!("{}", ChartScale::Second), "SECOND");
411 assert_eq!(format!("{}", ChartScale::Tick), "TICK");
412 }
413
414 #[test]
415 fn test_chart_scale_serialization() {
416 let scale = ChartScale::OneMinute;
417 let json = serde_json::to_string(&scale).expect("serialize failed");
418 assert_eq!(json, "\"1MINUTE\"");
419
420 let deserialized: ChartScale = serde_json::from_str(&json).expect("deserialize failed");
421 assert_eq!(deserialized, ChartScale::OneMinute);
422 }
423
424 #[test]
425 fn test_chart_data_default() {
426 let data = ChartData::default();
427 assert!(data.item_name.is_empty());
428 assert_eq!(data.item_pos, 0);
429 assert_eq!(data.scale, ChartScale::Tick);
430 assert!(!data.is_snapshot);
431 }
432
433 #[test]
434 fn test_chart_data_is_tick() {
435 let data = ChartData {
436 scale: ChartScale::Tick,
437 ..Default::default()
438 };
439 assert!(data.is_tick());
440 assert!(!data.is_candle());
441 }
442
443 #[test]
444 fn test_chart_data_is_candle() {
445 let data = ChartData {
446 scale: ChartScale::OneMinute,
447 ..Default::default()
448 };
449 assert!(!data.is_tick());
450 assert!(data.is_candle());
451
452 let data_hour = ChartData {
453 scale: ChartScale::Hour,
454 ..Default::default()
455 };
456 assert!(data_hour.is_candle());
457 }
458
459 #[test]
460 fn test_chart_data_get_scale() {
461 let data = ChartData {
462 scale: ChartScale::FiveMinute,
463 ..Default::default()
464 };
465 assert_eq!(*data.get_scale(), ChartScale::FiveMinute);
466 }
467
468 #[test]
469 fn test_chart_fields_default() {
470 let fields = ChartFields::default();
471 assert!(fields.bid.is_none());
472 assert!(fields.offer.is_none());
473 assert!(fields.last_traded_price.is_none());
474 assert!(fields.day_high.is_none());
475 assert!(fields.day_low.is_none());
476 }
477
478 #[test]
479 fn test_chart_fields_creation() {
480 let fields = ChartFields {
481 bid: Some(100.5),
482 offer: Some(101.0),
483 last_traded_price: Some(100.75),
484 day_high: Some(102.0),
485 day_low: Some(99.0),
486 ..Default::default()
487 };
488 assert_eq!(fields.bid, Some(100.5));
489 assert_eq!(fields.offer, Some(101.0));
490 assert_eq!(fields.last_traded_price, Some(100.75));
491 }
492
493 #[test]
494 fn test_chart_scale_equality() {
495 assert_eq!(ChartScale::Tick, ChartScale::Tick);
496 assert_ne!(ChartScale::Tick, ChartScale::Hour);
497 }
498
499 #[test]
500 fn test_chart_scale_hash() {
501 use std::collections::HashSet;
502 let mut set = HashSet::new();
503 set.insert(ChartScale::Tick);
504 set.insert(ChartScale::Tick);
505 assert_eq!(set.len(), 1);
506 }
507
508 #[test]
509 fn test_from_fields_derives_known_scale_from_item_name() {
510 let fields = HashMap::new();
511 let changed = HashMap::new();
512 let data = ChartData::from_fields(Some("CHART:EPIC:SECOND"), 1, false, &fields, &changed)
513 .expect("from_fields should succeed");
514 assert_eq!(data.scale, ChartScale::Second);
515 assert_eq!(data.item_name, "CHART:EPIC:SECOND");
516
517 let data =
518 ChartData::from_fields(Some("CHART:IX.D.DAX.IP:HOUR"), 1, false, &fields, &changed)
519 .expect("from_fields should succeed");
520 assert_eq!(data.scale, ChartScale::Hour);
521 }
522
523 #[test]
524 fn test_from_fields_unknown_scale_defaults_to_tick() {
525 let fields = HashMap::new();
526 let changed = HashMap::new();
527 let data = ChartData::from_fields(Some("CHART:EPIC:WEEKLY"), 1, false, &fields, &changed)
530 .expect("from_fields should succeed");
531 assert_eq!(data.scale, ChartScale::default());
532 assert_eq!(data.scale, ChartScale::Tick);
533 }
534
535 #[test]
536 fn test_from_fields_no_item_name_defaults_to_tick() {
537 let fields = HashMap::new();
538 let changed = HashMap::new();
539 let data = ChartData::from_fields(None, 1, false, &fields, &changed)
540 .expect("from_fields should succeed");
541 assert_eq!(data.scale, ChartScale::default());
542 assert!(data.item_name.is_empty());
543 }
544}