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