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