Skip to main content

ig_client/presentation/
chart.rs

1use crate::presentation::serialization::string_as_float_opt;
2use lightstreamer_rs::subscription::ItemUpdate;
3use pretty_simple_display::{DebugPretty, DisplaySimple};
4use serde::{Deserialize, Serialize};
5use std::{collections::HashMap, fmt};
6
7/// Time scale for chart data aggregation
8#[repr(u8)]
9#[derive(Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash, Default)]
10pub enum ChartScale {
11    /// Second-level aggregation
12    #[serde(rename = "SECOND")]
13    Second,
14    /// One-minute aggregation
15    #[serde(rename = "1MINUTE")]
16    OneMinute,
17    /// Five-minute aggregation
18    #[serde(rename = "5MINUTE")]
19    FiveMinute,
20    /// Hourly aggregation
21    #[serde(rename = "HOUR")]
22    Hour,
23    /// Tick-by-tick data (no aggregation)
24    #[serde(rename = "TICK")]
25    #[default]
26    Tick,
27}
28
29impl fmt::Debug for ChartScale {
30    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
31        let s = match self {
32            ChartScale::Second => "SECOND",
33            ChartScale::OneMinute => "1MINUTE",
34            ChartScale::FiveMinute => "5MINUTE",
35            ChartScale::Hour => "HOUR",
36            ChartScale::Tick => "TICK",
37        };
38        write!(f, "{}", s)
39    }
40}
41
42impl fmt::Display for ChartScale {
43    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
44        write!(f, "{:?}", self)
45    }
46}
47
48#[derive(DebugPretty, DisplaySimple, Clone, Serialize, Deserialize, Default)]
49/// Chart data structure that represents price chart information
50/// Contains both tick and candle data depending on the chart scale
51pub struct ChartData {
52    /// The full Lightstreamer item name (e.g., `CHART:EPIC:TIMESCALE`)
53    pub item_name: String,
54    /// The 1-based position of the item in the subscription
55    pub item_pos: i32,
56    /// Resolved chart scale for this update (derived from item name or `scale`)
57    #[serde(default)]
58    pub scale: ChartScale, // Derived from the item name or the {scale} field
59    /// All current field values for the item
60    pub fields: ChartFields,
61    /// Only the fields that changed in this update
62    pub changed_fields: ChartFields,
63    /// Whether this update is part of the initial snapshot
64    pub is_snapshot: bool,
65}
66
67/// Chart field data containing price, volume, and timestamp information
68#[derive(DebugPretty, DisplaySimple, Clone, Serialize, Deserialize, Default)]
69pub struct ChartFields {
70    // Common fields for both chart types
71    #[serde(rename = "LTV")]
72    #[serde(with = "string_as_float_opt")]
73    #[serde(default)]
74    /// Last traded volume for the period (or tick)
75    pub last_traded_volume: Option<f64>,
76
77    #[serde(rename = "TTV")]
78    #[serde(with = "string_as_float_opt")]
79    #[serde(default)]
80    /// Incremental trading volume for the period
81    pub incremental_trading_volume: Option<f64>,
82
83    #[serde(rename = "UTM")]
84    #[serde(with = "string_as_float_opt")]
85    #[serde(default)]
86    /// Update time timestamp for the data point
87    pub update_time: Option<f64>,
88
89    #[serde(rename = "DAY_OPEN_MID")]
90    #[serde(with = "string_as_float_opt")]
91    #[serde(default)]
92    /// Day opening mid price
93    pub day_open_mid: Option<f64>,
94
95    #[serde(rename = "DAY_NET_CHG_MID")]
96    #[serde(with = "string_as_float_opt")]
97    #[serde(default)]
98    /// Day net change in mid price
99    pub day_net_change_mid: Option<f64>,
100
101    #[serde(rename = "DAY_PERC_CHG_MID")]
102    #[serde(with = "string_as_float_opt")]
103    #[serde(default)]
104    /// Day percentage change in mid price
105    pub day_percentage_change_mid: Option<f64>,
106
107    #[serde(rename = "DAY_HIGH")]
108    #[serde(with = "string_as_float_opt")]
109    #[serde(default)]
110    /// Day high price
111    pub day_high: Option<f64>,
112
113    #[serde(rename = "DAY_LOW")]
114    #[serde(with = "string_as_float_opt")]
115    #[serde(default)]
116    /// Day low price
117    pub day_low: Option<f64>,
118
119    // Fields specific to TICK
120    #[serde(rename = "BID")]
121    #[serde(with = "string_as_float_opt")]
122    #[serde(default)]
123    /// Current bid price (for tick data)
124    pub bid: Option<f64>,
125
126    #[serde(rename = "OFR")]
127    #[serde(with = "string_as_float_opt")]
128    #[serde(default)]
129    /// Current offer/ask price (for tick data)
130    pub offer: Option<f64>,
131
132    #[serde(rename = "LTP")]
133    #[serde(with = "string_as_float_opt")]
134    #[serde(default)]
135    /// Last traded price (for tick data)
136    pub last_traded_price: Option<f64>,
137
138    // Fields specific to CANDLE
139    #[serde(rename = "OFR_OPEN")]
140    #[serde(with = "string_as_float_opt")]
141    #[serde(default)]
142    /// Offer opening price for the candle period
143    pub offer_open: Option<f64>,
144
145    #[serde(rename = "OFR_HIGH")]
146    #[serde(with = "string_as_float_opt")]
147    #[serde(default)]
148    /// Offer high price for the candle period
149    pub offer_high: Option<f64>,
150
151    #[serde(rename = "OFR_LOW")]
152    #[serde(with = "string_as_float_opt")]
153    #[serde(default)]
154    /// Offer low price for the candle period
155    pub offer_low: Option<f64>,
156
157    #[serde(rename = "OFR_CLOSE")]
158    #[serde(with = "string_as_float_opt")]
159    #[serde(default)]
160    /// Offer closing price for the candle period
161    pub offer_close: Option<f64>,
162
163    #[serde(rename = "BID_OPEN")]
164    #[serde(with = "string_as_float_opt")]
165    #[serde(default)]
166    /// Bid opening price for the candle period
167    pub bid_open: Option<f64>,
168
169    #[serde(rename = "BID_HIGH")]
170    #[serde(with = "string_as_float_opt")]
171    #[serde(default)]
172    /// Bid high price for the candle period
173    pub bid_high: Option<f64>,
174
175    #[serde(rename = "BID_LOW")]
176    #[serde(with = "string_as_float_opt")]
177    #[serde(default)]
178    /// Bid low price for the candle period
179    pub bid_low: Option<f64>,
180
181    #[serde(rename = "BID_CLOSE")]
182    #[serde(with = "string_as_float_opt")]
183    #[serde(default)]
184    /// Bid closing price for the candle period
185    pub bid_close: Option<f64>,
186
187    #[serde(rename = "LTP_OPEN")]
188    #[serde(with = "string_as_float_opt")]
189    #[serde(default)]
190    /// Last traded price opening for the candle period
191    pub ltp_open: Option<f64>,
192
193    #[serde(rename = "LTP_HIGH")]
194    #[serde(with = "string_as_float_opt")]
195    #[serde(default)]
196    /// Last traded price high for the candle period
197    pub ltp_high: Option<f64>,
198
199    #[serde(rename = "LTP_LOW")]
200    #[serde(with = "string_as_float_opt")]
201    #[serde(default)]
202    /// Last traded price low for the candle period
203    pub ltp_low: Option<f64>,
204
205    #[serde(rename = "LTP_CLOSE")]
206    #[serde(with = "string_as_float_opt")]
207    #[serde(default)]
208    /// Last traded price closing for the candle period
209    pub ltp_close: Option<f64>,
210
211    #[serde(rename = "CONS_END")]
212    #[serde(with = "string_as_float_opt")]
213    #[serde(default)]
214    /// Candle end timestamp
215    pub candle_end: Option<f64>,
216
217    #[serde(rename = "CONS_TICK_COUNT")]
218    #[serde(with = "string_as_float_opt")]
219    #[serde(default)]
220    /// Number of ticks consolidated in this candle
221    pub candle_tick_count: Option<f64>,
222}
223
224impl ChartData {
225    /// Converts a Lightstreamer ItemUpdate to a ChartData object
226    ///
227    /// # Arguments
228    ///
229    /// * `item_update` - The ItemUpdate from Lightstreamer containing chart data
230    ///
231    /// # Returns
232    ///
233    /// A Result containing either the parsed ChartData or an error message
234    pub fn from_item_update(item_update: &ItemUpdate) -> Result<Self, String> {
235        // Extract the item_name, defaulting to an empty string if None
236        let item_name = item_update.item_name.clone().unwrap_or_default();
237
238        // Determine the chart scale from the item name
239        let scale = if let Some(item_name) = &item_update.item_name {
240            if item_name.ends_with(":TICK") {
241                ChartScale::Tick
242            } else if item_name.ends_with(":SECOND") {
243                ChartScale::Second
244            } else if item_name.ends_with(":1MINUTE") {
245                ChartScale::OneMinute
246            } else if item_name.ends_with(":5MINUTE") {
247                ChartScale::FiveMinute
248            } else if item_name.ends_with(":HOUR") {
249                ChartScale::Hour
250            } else {
251                // Try to determine the scale from a {scale} field if it exists
252                match item_update.fields.get("{scale}").and_then(|s| s.as_ref()) {
253                    Some(s) if s == "SECOND" => ChartScale::Second,
254                    Some(s) if s == "1MINUTE" => ChartScale::OneMinute,
255                    Some(s) if s == "5MINUTE" => ChartScale::FiveMinute,
256                    Some(s) if s == "HOUR" => ChartScale::Hour,
257                    _ => ChartScale::Tick, // Default
258                }
259            }
260        } else {
261            ChartScale::default()
262        };
263
264        // Convert item_pos from usize to i32
265        let item_pos = item_update.item_pos as i32;
266
267        // Extract is_snapshot
268        let is_snapshot = item_update.is_snapshot;
269
270        // Convert fields
271        let fields = Self::create_chart_fields(&item_update.fields)?;
272
273        // Convert changed_fields by first creating a HashMap<String, Option<String>>
274        let mut changed_fields_map: HashMap<String, Option<String>> = HashMap::new();
275        for (key, value) in &item_update.changed_fields {
276            changed_fields_map.insert(key.clone(), Some(value.clone()));
277        }
278        let changed_fields = Self::create_chart_fields(&changed_fields_map)?;
279
280        Ok(ChartData {
281            item_name,
282            item_pos,
283            scale,
284            fields,
285            changed_fields,
286            is_snapshot,
287        })
288    }
289
290    // Helper method to create ChartFields from a HashMap
291    fn create_chart_fields(
292        fields_map: &HashMap<String, Option<String>>,
293    ) -> Result<ChartFields, String> {
294        // Helper function to safely get a field value
295        let get_field = |key: &str| -> Option<String> { fields_map.get(key).cloned().flatten() };
296
297        // Helper function to parse float values
298        let parse_float = |key: &str| -> Result<Option<f64>, String> {
299            match get_field(key) {
300                Some(val) if !val.is_empty() => val
301                    .parse::<f64>()
302                    .map(Some)
303                    .map_err(|_| format!("Failed to parse {key} as float: {val}")),
304                _ => Ok(None),
305            }
306        };
307
308        Ok(ChartFields {
309            // Common fields
310            last_traded_volume: parse_float("LTV")?,
311            incremental_trading_volume: parse_float("TTV")?,
312            update_time: parse_float("UTM")?,
313            day_open_mid: parse_float("DAY_OPEN_MID")?,
314            day_net_change_mid: parse_float("DAY_NET_CHG_MID")?,
315            day_percentage_change_mid: parse_float("DAY_PERC_CHG_MID")?,
316            day_high: parse_float("DAY_HIGH")?,
317            day_low: parse_float("DAY_LOW")?,
318
319            // Fields specific to TICK
320            bid: parse_float("BID")?,
321            offer: parse_float("OFR")?,
322            last_traded_price: parse_float("LTP")?,
323
324            // Fields specific to CANDLE
325            offer_open: parse_float("OFR_OPEN")?,
326            offer_high: parse_float("OFR_HIGH")?,
327            offer_low: parse_float("OFR_LOW")?,
328            offer_close: parse_float("OFR_CLOSE")?,
329            bid_open: parse_float("BID_OPEN")?,
330            bid_high: parse_float("BID_HIGH")?,
331            bid_low: parse_float("BID_LOW")?,
332            bid_close: parse_float("BID_CLOSE")?,
333            ltp_open: parse_float("LTP_OPEN")?,
334            ltp_high: parse_float("LTP_HIGH")?,
335            ltp_low: parse_float("LTP_LOW")?,
336            ltp_close: parse_float("LTP_CLOSE")?,
337            candle_end: parse_float("CONS_END")?,
338            candle_tick_count: parse_float("CONS_TICK_COUNT")?,
339        })
340    }
341
342    /// Checks if these chart data are of type TICK
343    pub fn is_tick(&self) -> bool {
344        matches!(self.scale, ChartScale::Tick)
345    }
346
347    /// Checks if these chart data are of type CANDLE (any time scale)
348    pub fn is_candle(&self) -> bool {
349        !self.is_tick()
350    }
351
352    /// Gets the time scale of the data
353    pub fn get_scale(&self) -> &ChartScale {
354        &self.scale
355    }
356}
357
358impl From<&ItemUpdate> for ChartData {
359    fn from(item_update: &ItemUpdate) -> Self {
360        Self::from_item_update(item_update).unwrap_or_default()
361    }
362}
363
364#[cfg(test)]
365mod tests {
366    use super::*;
367
368    #[test]
369    fn test_chart_scale_default() {
370        let scale = ChartScale::default();
371        assert_eq!(scale, ChartScale::Tick);
372    }
373
374    #[test]
375    fn test_chart_scale_debug() {
376        assert_eq!(format!("{:?}", ChartScale::Second), "SECOND");
377        assert_eq!(format!("{:?}", ChartScale::OneMinute), "1MINUTE");
378        assert_eq!(format!("{:?}", ChartScale::FiveMinute), "5MINUTE");
379        assert_eq!(format!("{:?}", ChartScale::Hour), "HOUR");
380        assert_eq!(format!("{:?}", ChartScale::Tick), "TICK");
381    }
382
383    #[test]
384    fn test_chart_scale_display() {
385        assert_eq!(format!("{}", ChartScale::Second), "SECOND");
386        assert_eq!(format!("{}", ChartScale::Tick), "TICK");
387    }
388
389    #[test]
390    fn test_chart_scale_serialization() {
391        let scale = ChartScale::OneMinute;
392        let json = serde_json::to_string(&scale).expect("serialize failed");
393        assert_eq!(json, "\"1MINUTE\"");
394
395        let deserialized: ChartScale = serde_json::from_str(&json).expect("deserialize failed");
396        assert_eq!(deserialized, ChartScale::OneMinute);
397    }
398
399    #[test]
400    fn test_chart_data_default() {
401        let data = ChartData::default();
402        assert!(data.item_name.is_empty());
403        assert_eq!(data.item_pos, 0);
404        assert_eq!(data.scale, ChartScale::Tick);
405        assert!(!data.is_snapshot);
406    }
407
408    #[test]
409    fn test_chart_data_is_tick() {
410        let data = ChartData {
411            scale: ChartScale::Tick,
412            ..Default::default()
413        };
414        assert!(data.is_tick());
415        assert!(!data.is_candle());
416    }
417
418    #[test]
419    fn test_chart_data_is_candle() {
420        let data = ChartData {
421            scale: ChartScale::OneMinute,
422            ..Default::default()
423        };
424        assert!(!data.is_tick());
425        assert!(data.is_candle());
426
427        let data_hour = ChartData {
428            scale: ChartScale::Hour,
429            ..Default::default()
430        };
431        assert!(data_hour.is_candle());
432    }
433
434    #[test]
435    fn test_chart_data_get_scale() {
436        let data = ChartData {
437            scale: ChartScale::FiveMinute,
438            ..Default::default()
439        };
440        assert_eq!(*data.get_scale(), ChartScale::FiveMinute);
441    }
442
443    #[test]
444    fn test_chart_fields_default() {
445        let fields = ChartFields::default();
446        assert!(fields.bid.is_none());
447        assert!(fields.offer.is_none());
448        assert!(fields.last_traded_price.is_none());
449        assert!(fields.day_high.is_none());
450        assert!(fields.day_low.is_none());
451    }
452
453    #[test]
454    fn test_chart_fields_creation() {
455        let fields = ChartFields {
456            bid: Some(100.5),
457            offer: Some(101.0),
458            last_traded_price: Some(100.75),
459            day_high: Some(102.0),
460            day_low: Some(99.0),
461            ..Default::default()
462        };
463        assert_eq!(fields.bid, Some(100.5));
464        assert_eq!(fields.offer, Some(101.0));
465        assert_eq!(fields.last_traded_price, Some(100.75));
466    }
467
468    #[test]
469    fn test_chart_scale_equality() {
470        assert_eq!(ChartScale::Tick, ChartScale::Tick);
471        assert_ne!(ChartScale::Tick, ChartScale::Hour);
472    }
473
474    #[test]
475    fn test_chart_scale_hash() {
476        use std::collections::HashSet;
477        let mut set = HashSet::new();
478        set.insert(ChartScale::Tick);
479        set.insert(ChartScale::Tick);
480        assert_eq!(set.len(), 1);
481    }
482}