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