Skip to main content

ig_client/presentation/
chart.rs

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/// Time scale for chart data aggregation
10#[repr(u8)]
11#[derive(Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash, Default)]
12pub enum ChartScale {
13    /// Second-level aggregation
14    #[serde(rename = "SECOND")]
15    Second,
16    /// One-minute aggregation
17    #[serde(rename = "1MINUTE")]
18    OneMinute,
19    /// Five-minute aggregation
20    #[serde(rename = "5MINUTE")]
21    FiveMinute,
22    /// Hourly aggregation
23    #[serde(rename = "HOUR")]
24    Hour,
25    /// Tick-by-tick data (no aggregation)
26    #[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)]
51/// Chart data structure that represents price chart information
52/// Contains both tick and candle data depending on the chart scale
53pub struct ChartData {
54    /// The full Lightstreamer item name (e.g., `CHART:EPIC:TIMESCALE`)
55    pub item_name: String,
56    /// The 1-based position of the item in the subscription
57    pub item_pos: usize,
58    /// Resolved chart scale for this update (derived from the item name)
59    #[serde(default)]
60    pub scale: ChartScale, // Derived from the third segment of the item name
61    /// All current field values for the item
62    pub fields: ChartFields,
63    /// Only the fields that changed in this update
64    pub changed_fields: ChartFields,
65    /// Whether this update is part of the initial snapshot
66    pub is_snapshot: bool,
67}
68
69/// Chart field data containing price, volume, and timestamp information
70#[derive(DebugPretty, DisplaySimple, Clone, Serialize, Deserialize, Default)]
71pub struct ChartFields {
72    // Common fields for both chart types
73    #[serde(rename = "LTV")]
74    #[serde(with = "string_as_float_opt")]
75    #[serde(default)]
76    /// Last traded volume for the period (or tick)
77    pub last_traded_volume: Option<f64>,
78
79    #[serde(rename = "TTV")]
80    #[serde(with = "string_as_float_opt")]
81    #[serde(default)]
82    /// Incremental trading volume for the period
83    pub incremental_trading_volume: Option<f64>,
84
85    #[serde(rename = "UTM")]
86    #[serde(with = "string_as_int_opt")]
87    #[serde(default)]
88    /// Update time for the data point, in epoch milliseconds (UTC).
89    ///
90    /// Typed as `i64` to preserve integer epoch-millis exactly (float parsing
91    /// would risk silent rounding on large values).
92    pub update_time: Option<i64>,
93
94    #[serde(rename = "DAY_OPEN_MID")]
95    #[serde(with = "string_as_float_opt")]
96    #[serde(default)]
97    /// Day opening mid price
98    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    /// Day net change in mid price
104    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    /// Day percentage change in mid price
110    pub day_percentage_change_mid: Option<f64>,
111
112    #[serde(rename = "DAY_HIGH")]
113    #[serde(with = "string_as_float_opt")]
114    #[serde(default)]
115    /// Day high price
116    pub day_high: Option<f64>,
117
118    #[serde(rename = "DAY_LOW")]
119    #[serde(with = "string_as_float_opt")]
120    #[serde(default)]
121    /// Day low price
122    pub day_low: Option<f64>,
123
124    // Fields specific to TICK
125    #[serde(rename = "BID")]
126    #[serde(with = "string_as_float_opt")]
127    #[serde(default)]
128    /// Current bid price (for tick data)
129    pub bid: Option<f64>,
130
131    #[serde(rename = "OFR")]
132    #[serde(with = "string_as_float_opt")]
133    #[serde(default)]
134    /// Current offer/ask price (for tick data)
135    pub offer: Option<f64>,
136
137    #[serde(rename = "LTP")]
138    #[serde(with = "string_as_float_opt")]
139    #[serde(default)]
140    /// Last traded price (for tick data)
141    pub last_traded_price: Option<f64>,
142
143    // Fields specific to CANDLE
144    #[serde(rename = "OFR_OPEN")]
145    #[serde(with = "string_as_float_opt")]
146    #[serde(default)]
147    /// Offer opening price for the candle period
148    pub offer_open: Option<f64>,
149
150    #[serde(rename = "OFR_HIGH")]
151    #[serde(with = "string_as_float_opt")]
152    #[serde(default)]
153    /// Offer high price for the candle period
154    pub offer_high: Option<f64>,
155
156    #[serde(rename = "OFR_LOW")]
157    #[serde(with = "string_as_float_opt")]
158    #[serde(default)]
159    /// Offer low price for the candle period
160    pub offer_low: Option<f64>,
161
162    #[serde(rename = "OFR_CLOSE")]
163    #[serde(with = "string_as_float_opt")]
164    #[serde(default)]
165    /// Offer closing price for the candle period
166    pub offer_close: Option<f64>,
167
168    #[serde(rename = "BID_OPEN")]
169    #[serde(with = "string_as_float_opt")]
170    #[serde(default)]
171    /// Bid opening price for the candle period
172    pub bid_open: Option<f64>,
173
174    #[serde(rename = "BID_HIGH")]
175    #[serde(with = "string_as_float_opt")]
176    #[serde(default)]
177    /// Bid high price for the candle period
178    pub bid_high: Option<f64>,
179
180    #[serde(rename = "BID_LOW")]
181    #[serde(with = "string_as_float_opt")]
182    #[serde(default)]
183    /// Bid low price for the candle period
184    pub bid_low: Option<f64>,
185
186    #[serde(rename = "BID_CLOSE")]
187    #[serde(with = "string_as_float_opt")]
188    #[serde(default)]
189    /// Bid closing price for the candle period
190    pub bid_close: Option<f64>,
191
192    #[serde(rename = "LTP_OPEN")]
193    #[serde(with = "string_as_float_opt")]
194    #[serde(default)]
195    /// Last traded price opening for the candle period
196    pub ltp_open: Option<f64>,
197
198    #[serde(rename = "LTP_HIGH")]
199    #[serde(with = "string_as_float_opt")]
200    #[serde(default)]
201    /// Last traded price high for the candle period
202    pub ltp_high: Option<f64>,
203
204    #[serde(rename = "LTP_LOW")]
205    #[serde(with = "string_as_float_opt")]
206    #[serde(default)]
207    /// Last traded price low for the candle period
208    pub ltp_low: Option<f64>,
209
210    #[serde(rename = "LTP_CLOSE")]
211    #[serde(with = "string_as_float_opt")]
212    #[serde(default)]
213    /// Last traded price closing for the candle period
214    pub ltp_close: Option<f64>,
215
216    #[serde(rename = "CONS_END")]
217    #[serde(with = "string_as_bool_opt")]
218    #[serde(default)]
219    /// Candle-completed flag: 1 when the candle has ended, 0 while it is still
220    /// forming. This is IG's `CONS_END` wire field, a 0/1 boolean rather than a
221    /// timestamp.
222    pub candle_end: Option<bool>,
223
224    #[serde(rename = "CONS_TICK_COUNT")]
225    #[serde(with = "string_as_float_opt")]
226    #[serde(default)]
227    /// Number of ticks consolidated in this candle
228    pub candle_tick_count: Option<f64>,
229}
230
231impl ChartData {
232    /// Builds a [`ChartData`] from pre-extracted streaming fields.
233    ///
234    /// This is transport-agnostic: it takes plain field maps rather than a
235    /// Lightstreamer `ItemUpdate`, so the presentation layer carries no
236    /// dependency on the streaming transport. The `ItemUpdate` adapter lives in
237    /// [`crate::application::streaming_convert`]. The chart scale is derived from
238    /// the third `:`-separated segment of the item name (`CHART:{epic}:{scale}`).
239    ///
240    /// # Arguments
241    /// * `item_name` - Subscription item name (`None` when subscribed by position)
242    /// * `item_pos` - 1-based position of the item in the subscription
243    /// * `is_snapshot` - Whether this update is a snapshot
244    /// * `fields` - Current field values for the item
245    /// * `changed_fields` - Field values that changed in this update
246    ///
247    /// # Returns
248    /// A Result containing either the parsed ChartData or an error message
249    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        // Determine the chart scale from the third `:`-separated segment of the
257        // item name (`CHART:{epic}:{scale}`). IG epics never contain `:`, so the
258        // scale is always the third segment. An unrecognized scale is logged and
259        // falls back to the default rather than silently defaulting.
260        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        // Convert fields
281        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    // Helper method to create ChartFields from a HashMap
295    fn create_chart_fields(
296        fields_map: &HashMap<String, Option<String>>,
297    ) -> Result<ChartFields, String> {
298        // Helper function to safely get a field value
299        let get_field = |key: &str| -> Option<String> { fields_map.get(key).cloned().flatten() };
300
301        // Helper function to parse float values
302        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        // Helper function to parse integer values (e.g. epoch-millis timestamps).
313        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        // Parse the CONS_END candle-completed flag: IG sends "0" / "1"
324        // (1 = candle ended, 0 = still forming). An empty string is treated as
325        // None.
326        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            // Common fields
335            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            // Fields specific to TICK
345            bid: parse_float("BID")?,
346            offer: parse_float("OFR")?,
347            last_traded_price: parse_float("LTP")?,
348
349            // Fields specific to CANDLE
350            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    /// Checks if these chart data are of type TICK
368    #[must_use]
369    #[inline]
370    pub fn is_tick(&self) -> bool {
371        matches!(self.scale, ChartScale::Tick)
372    }
373
374    /// Checks if these chart data are of type CANDLE (any time scale)
375    #[must_use]
376    #[inline]
377    pub fn is_candle(&self) -> bool {
378        !self.is_tick()
379    }
380
381    /// Gets the time scale of the data
382    #[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        // An unrecognized scale segment falls back to the default (Tick) and
528        // logs a warning.
529        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}