ig_client/presentation/
chart.rs

1use crate::presentation::serialization::string_as_float_opt;
2use lightstreamer_rs::subscription::ItemUpdate;
3use serde::{Deserialize, Serialize};
4use std::collections::HashMap;
5use std::fmt;
6
7/// Time scale for chart data aggregation
8#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
9pub enum ChartScale {
10    /// Second-level aggregation
11    #[serde(rename = "SECOND")]
12    Second,
13    /// One-minute aggregation
14    #[serde(rename = "1MINUTE")]
15    OneMinute,
16    /// Five-minute aggregation
17    #[serde(rename = "5MINUTE")]
18    FiveMinute,
19    /// Hourly aggregation
20    #[serde(rename = "HOUR")]
21    Hour,
22    /// Tick-by-tick data (no aggregation)
23    #[serde(rename = "TICK")]
24    #[default]
25    Tick,
26}
27
28#[derive(Debug, Clone, Serialize, Deserialize, Default)]
29/// Chart data structure that represents price chart information
30/// Contains both tick and candle data depending on the chart scale
31pub struct ChartData {
32    item_name: String,
33    item_pos: i32,
34    #[serde(default)]
35    scale: ChartScale, // Derived from the item name or the {scale} field
36    fields: ChartFields,
37    changed_fields: ChartFields,
38    is_snapshot: bool,
39}
40
41/// Chart field data containing price, volume, and timestamp information
42#[derive(Debug, Clone, Serialize, Deserialize, Default)]
43pub struct ChartFields {
44    // Common fields for both chart types
45    #[serde(rename = "LTV")]
46    #[serde(with = "string_as_float_opt")]
47    #[serde(default)]
48    last_traded_volume: Option<f64>,
49
50    #[serde(rename = "TTV")]
51    #[serde(with = "string_as_float_opt")]
52    #[serde(default)]
53    incremental_trading_volume: Option<f64>,
54
55    #[serde(rename = "UTM")]
56    #[serde(with = "string_as_float_opt")]
57    #[serde(default)]
58    update_time: Option<f64>,
59
60    #[serde(rename = "DAY_OPEN_MID")]
61    #[serde(with = "string_as_float_opt")]
62    #[serde(default)]
63    day_open_mid: Option<f64>,
64
65    #[serde(rename = "DAY_NET_CHG_MID")]
66    #[serde(with = "string_as_float_opt")]
67    #[serde(default)]
68    day_net_change_mid: Option<f64>,
69
70    #[serde(rename = "DAY_PERC_CHG_MID")]
71    #[serde(with = "string_as_float_opt")]
72    #[serde(default)]
73    day_percentage_change_mid: Option<f64>,
74
75    #[serde(rename = "DAY_HIGH")]
76    #[serde(with = "string_as_float_opt")]
77    #[serde(default)]
78    day_high: Option<f64>,
79
80    #[serde(rename = "DAY_LOW")]
81    #[serde(with = "string_as_float_opt")]
82    #[serde(default)]
83    day_low: Option<f64>,
84
85    // Fields specific to TICK
86    #[serde(rename = "BID")]
87    #[serde(with = "string_as_float_opt")]
88    #[serde(default)]
89    bid: Option<f64>,
90
91    #[serde(rename = "OFR")]
92    #[serde(with = "string_as_float_opt")]
93    #[serde(default)]
94    offer: Option<f64>,
95
96    #[serde(rename = "LTP")]
97    #[serde(with = "string_as_float_opt")]
98    #[serde(default)]
99    last_traded_price: Option<f64>,
100
101    // Fields specific to CANDLE
102    #[serde(rename = "OFR_OPEN")]
103    #[serde(with = "string_as_float_opt")]
104    #[serde(default)]
105    offer_open: Option<f64>,
106
107    #[serde(rename = "OFR_HIGH")]
108    #[serde(with = "string_as_float_opt")]
109    #[serde(default)]
110    offer_high: Option<f64>,
111
112    #[serde(rename = "OFR_LOW")]
113    #[serde(with = "string_as_float_opt")]
114    #[serde(default)]
115    offer_low: Option<f64>,
116
117    #[serde(rename = "OFR_CLOSE")]
118    #[serde(with = "string_as_float_opt")]
119    #[serde(default)]
120    offer_close: Option<f64>,
121
122    #[serde(rename = "BID_OPEN")]
123    #[serde(with = "string_as_float_opt")]
124    #[serde(default)]
125    bid_open: Option<f64>,
126
127    #[serde(rename = "BID_HIGH")]
128    #[serde(with = "string_as_float_opt")]
129    #[serde(default)]
130    bid_high: Option<f64>,
131
132    #[serde(rename = "BID_LOW")]
133    #[serde(with = "string_as_float_opt")]
134    #[serde(default)]
135    bid_low: Option<f64>,
136
137    #[serde(rename = "BID_CLOSE")]
138    #[serde(with = "string_as_float_opt")]
139    #[serde(default)]
140    bid_close: Option<f64>,
141
142    #[serde(rename = "LTP_OPEN")]
143    #[serde(with = "string_as_float_opt")]
144    #[serde(default)]
145    ltp_open: Option<f64>,
146
147    #[serde(rename = "LTP_HIGH")]
148    #[serde(with = "string_as_float_opt")]
149    #[serde(default)]
150    ltp_high: Option<f64>,
151
152    #[serde(rename = "LTP_LOW")]
153    #[serde(with = "string_as_float_opt")]
154    #[serde(default)]
155    ltp_low: Option<f64>,
156
157    #[serde(rename = "LTP_CLOSE")]
158    #[serde(with = "string_as_float_opt")]
159    #[serde(default)]
160    ltp_close: Option<f64>,
161
162    #[serde(rename = "CONS_END")]
163    #[serde(with = "string_as_float_opt")]
164    #[serde(default)]
165    candle_end: Option<f64>,
166
167    #[serde(rename = "CONS_TICK_COUNT")]
168    #[serde(with = "string_as_float_opt")]
169    #[serde(default)]
170    candle_tick_count: Option<f64>,
171}
172
173impl ChartData {
174    /// Converts a Lightstreamer ItemUpdate to a ChartData object
175    ///
176    /// # Arguments
177    ///
178    /// * `item_update` - The ItemUpdate from Lightstreamer containing chart data
179    ///
180    /// # Returns
181    ///
182    /// A Result containing either the parsed ChartData or an error message
183    pub fn from_item_update(item_update: &ItemUpdate) -> Result<Self, String> {
184        // Extract the item_name, defaulting to an empty string if None
185        let item_name = item_update.item_name.clone().unwrap_or_default();
186
187        // Determine the chart scale from the item name
188        let scale = if let Some(item_name) = &item_update.item_name {
189            if item_name.ends_with(":TICK") {
190                ChartScale::Tick
191            } else if item_name.ends_with(":SECOND") {
192                ChartScale::Second
193            } else if item_name.ends_with(":1MINUTE") {
194                ChartScale::OneMinute
195            } else if item_name.ends_with(":5MINUTE") {
196                ChartScale::FiveMinute
197            } else if item_name.ends_with(":HOUR") {
198                ChartScale::Hour
199            } else {
200                // Try to determine the scale from a {scale} field if it exists
201                match item_update.fields.get("{scale}").and_then(|s| s.as_ref()) {
202                    Some(s) if s == "SECOND" => ChartScale::Second,
203                    Some(s) if s == "1MINUTE" => ChartScale::OneMinute,
204                    Some(s) if s == "5MINUTE" => ChartScale::FiveMinute,
205                    Some(s) if s == "HOUR" => ChartScale::Hour,
206                    _ => ChartScale::Tick, // Default
207                }
208            }
209        } else {
210            ChartScale::default()
211        };
212
213        // Convert item_pos from usize to i32
214        let item_pos = item_update.item_pos as i32;
215
216        // Extract is_snapshot
217        let is_snapshot = item_update.is_snapshot;
218
219        // Convert fields
220        let fields = Self::create_chart_fields(&item_update.fields)?;
221
222        // Convert changed_fields by first creating a HashMap<String, Option<String>>
223        let mut changed_fields_map: HashMap<String, Option<String>> = HashMap::new();
224        for (key, value) in &item_update.changed_fields {
225            changed_fields_map.insert(key.clone(), Some(value.clone()));
226        }
227        let changed_fields = Self::create_chart_fields(&changed_fields_map)?;
228
229        Ok(ChartData {
230            item_name,
231            item_pos,
232            scale,
233            fields,
234            changed_fields,
235            is_snapshot,
236        })
237    }
238
239    // Helper method to create ChartFields from a HashMap
240    fn create_chart_fields(
241        fields_map: &HashMap<String, Option<String>>,
242    ) -> Result<ChartFields, String> {
243        // Helper function to safely get a field value
244        let get_field = |key: &str| -> Option<String> { fields_map.get(key).cloned().flatten() };
245
246        // Helper function to parse float values
247        let parse_float = |key: &str| -> Result<Option<f64>, String> {
248            match get_field(key) {
249                Some(val) if !val.is_empty() => val
250                    .parse::<f64>()
251                    .map(Some)
252                    .map_err(|_| format!("Failed to parse {key} as float: {val}")),
253                _ => Ok(None),
254            }
255        };
256
257        Ok(ChartFields {
258            // Common fields
259            last_traded_volume: parse_float("LTV")?,
260            incremental_trading_volume: parse_float("TTV")?,
261            update_time: parse_float("UTM")?,
262            day_open_mid: parse_float("DAY_OPEN_MID")?,
263            day_net_change_mid: parse_float("DAY_NET_CHG_MID")?,
264            day_percentage_change_mid: parse_float("DAY_PERC_CHG_MID")?,
265            day_high: parse_float("DAY_HIGH")?,
266            day_low: parse_float("DAY_LOW")?,
267
268            // Fields specific to TICK
269            bid: parse_float("BID")?,
270            offer: parse_float("OFR")?,
271            last_traded_price: parse_float("LTP")?,
272
273            // Fields specific to CANDLE
274            offer_open: parse_float("OFR_OPEN")?,
275            offer_high: parse_float("OFR_HIGH")?,
276            offer_low: parse_float("OFR_LOW")?,
277            offer_close: parse_float("OFR_CLOSE")?,
278            bid_open: parse_float("BID_OPEN")?,
279            bid_high: parse_float("BID_HIGH")?,
280            bid_low: parse_float("BID_LOW")?,
281            bid_close: parse_float("BID_CLOSE")?,
282            ltp_open: parse_float("LTP_OPEN")?,
283            ltp_high: parse_float("LTP_HIGH")?,
284            ltp_low: parse_float("LTP_LOW")?,
285            ltp_close: parse_float("LTP_CLOSE")?,
286            candle_end: parse_float("CONS_END")?,
287            candle_tick_count: parse_float("CONS_TICK_COUNT")?,
288        })
289    }
290
291    /// Checks if these chart data are of type TICK
292    pub fn is_tick(&self) -> bool {
293        matches!(self.scale, ChartScale::Tick)
294    }
295
296    /// Checks if these chart data are of type CANDLE (any time scale)
297    pub fn is_candle(&self) -> bool {
298        !self.is_tick()
299    }
300
301    /// Gets the time scale of the data
302    pub fn get_scale(&self) -> &ChartScale {
303        &self.scale
304    }
305}
306
307impl fmt::Display for ChartData {
308    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
309        let json = serde_json::to_string(self).map_err(|_| fmt::Error)?;
310        write!(f, "{json}")
311    }
312}
313
314impl From<&ItemUpdate> for ChartData {
315    fn from(item_update: &ItemUpdate) -> Self {
316        Self::from_item_update(item_update).unwrap_or_default()
317    }
318}