Skip to main content

ig_client/presentation/
trade.rs

1use crate::presentation::order::{Direction, OrderType, Status, TimeInForce};
2use crate::presentation::serialization::{option_string_empty_as_none, string_as_float_opt};
3use pretty_simple_display::{DebugPretty, DisplaySimple};
4use serde::{Deserialize, Serialize};
5use serde_json;
6use std::collections::HashMap;
7
8/// Main structure for trade data received from the IG Markets API
9/// Contains information about trades, positions and working orders
10#[derive(DebugPretty, DisplaySimple, Clone, Serialize, Deserialize, Default)]
11pub struct TradeData {
12    /// Name of the subscribed item
13    pub item_name: String,
14    /// Position of the item in the subscription
15    pub item_pos: usize,
16    /// Trade fields data
17    pub fields: TradeFields,
18    /// Changed fields data
19    pub changed_fields: TradeFields,
20    /// Whether this is a snapshot
21    pub is_snapshot: bool,
22}
23
24/// Main fields for a trade update, containing core trade data.
25#[derive(DebugPretty, DisplaySimple, Clone, Serialize, Deserialize, Default)]
26#[serde(rename_all = "UPPERCASE")]
27pub struct TradeFields {
28    /// Optional confirmation details for the trade.
29    #[serde(skip_serializing_if = "Option::is_none")]
30    pub confirms: Option<String>,
31    /// Optional open position update details.
32    #[serde(skip_serializing_if = "Option::is_none")]
33    pub opu: Option<OpenPositionUpdate>,
34    /// Optional working order update details.
35    #[serde(skip_serializing_if = "Option::is_none")]
36    pub wou: Option<WorkingOrderUpdate>,
37}
38
39/// Structure representing details of an open position update.
40#[derive(DebugPretty, DisplaySimple, Clone, Serialize, Deserialize, Default)]
41pub struct OpenPositionUpdate {
42    /// Unique deal reference for the open position.
43    #[serde(rename = "dealReference")]
44    #[serde(with = "option_string_empty_as_none")]
45    #[serde(default)]
46    pub deal_reference: Option<String>,
47    /// Unique deal identifier for the position.
48    #[serde(rename = "dealId")]
49    #[serde(with = "option_string_empty_as_none")]
50    #[serde(default)]
51    pub deal_id: Option<String>,
52    /// Direction of the trade position (buy or sell).
53    #[serde(default)]
54    pub direction: Option<Direction>,
55    /// Epic identifier for the instrument.
56    #[serde(default)]
57    pub epic: Option<String>,
58    /// Status of the position.
59    #[serde(default)]
60    pub status: Option<Status>,
61    /// Deal status of the position.
62    #[serde(rename = "dealStatus")]
63    #[serde(default)]
64    pub deal_status: Option<Status>,
65    /// Price level of the position.
66    #[serde(with = "string_as_float_opt")]
67    #[serde(default)]
68    pub level: Option<f64>,
69    /// Position size.
70    #[serde(with = "string_as_float_opt")]
71    #[serde(default)]
72    pub size: Option<f64>,
73    /// Currency of the position.
74    #[serde(with = "option_string_empty_as_none")]
75    #[serde(default)]
76    pub currency: Option<String>,
77    /// Timestamp of the position update.
78    #[serde(with = "option_string_empty_as_none")]
79    #[serde(default)]
80    pub timestamp: Option<String>,
81    /// Channel through which the update was received.
82    #[serde(with = "option_string_empty_as_none")]
83    #[serde(default)]
84    pub channel: Option<String>,
85    /// Expiry date of the position, if applicable.
86    #[serde(with = "option_string_empty_as_none")]
87    #[serde(default)]
88    pub expiry: Option<String>,
89    /// Original deal identifier for the position.
90    #[serde(rename = "dealIdOrigin")]
91    #[serde(with = "option_string_empty_as_none")]
92    #[serde(default)]
93    pub deal_id_origin: Option<String>,
94    /// Price level of the stop loss, if set (points).
95    #[serde(rename = "stopLevel")]
96    #[serde(with = "string_as_float_opt")]
97    #[serde(default)]
98    pub stop_level: Option<f64>,
99    /// Price level of the limit (take profit), if set (points).
100    #[serde(rename = "limitLevel")]
101    #[serde(with = "string_as_float_opt")]
102    #[serde(default)]
103    pub limit_level: Option<f64>,
104    /// Whether the stop is a guaranteed stop.
105    #[serde(rename = "guaranteedStop")]
106    #[serde(default)]
107    pub guaranteed_stop: Option<bool>,
108    /// Trailing stop step increment, if a trailing stop is set (points).
109    #[serde(rename = "trailingStep")]
110    #[serde(with = "string_as_float_opt")]
111    #[serde(default)]
112    pub trailing_step: Option<f64>,
113    /// Trailing stop distance from the current level, if set (points).
114    #[serde(rename = "trailingStopDistance")]
115    #[serde(with = "string_as_float_opt")]
116    #[serde(default)]
117    pub trailing_stop_distance: Option<f64>,
118}
119
120/// Structure representing details of a working order update.
121#[derive(DebugPretty, DisplaySimple, Clone, Serialize, Deserialize, Default)]
122pub struct WorkingOrderUpdate {
123    /// Unique deal reference for the working order.
124    #[serde(rename = "dealReference")]
125    #[serde(with = "option_string_empty_as_none")]
126    #[serde(default)]
127    pub deal_reference: Option<String>,
128    /// Unique deal identifier for the working order.
129    #[serde(rename = "dealId")]
130    #[serde(with = "option_string_empty_as_none")]
131    #[serde(default)]
132    pub deal_id: Option<String>,
133    /// Direction of the working order (buy or sell).
134    #[serde(default)]
135    pub direction: Option<Direction>,
136    /// Epic identifier for the working order instrument.
137    #[serde(with = "option_string_empty_as_none")]
138    #[serde(default)]
139    pub epic: Option<String>,
140    /// Status of the working order.
141    #[serde(default)]
142    pub status: Option<Status>,
143    /// Deal status of the working order.
144    #[serde(rename = "dealStatus")]
145    #[serde(default)]
146    pub deal_status: Option<Status>,
147    /// Price level at which the working order is set.
148    #[serde(with = "string_as_float_opt")]
149    #[serde(default)]
150    pub level: Option<f64>,
151    /// Working order size.
152    #[serde(with = "string_as_float_opt")]
153    #[serde(default)]
154    pub size: Option<f64>,
155    /// Currency of the working order.
156    #[serde(with = "option_string_empty_as_none")]
157    #[serde(default)]
158    pub currency: Option<String>,
159    /// Timestamp of the working order update.
160    #[serde(with = "option_string_empty_as_none")]
161    #[serde(default)]
162    pub timestamp: Option<String>,
163    /// Channel through which the working order update was received.
164    #[serde(with = "option_string_empty_as_none")]
165    #[serde(default)]
166    pub channel: Option<String>,
167    /// Expiry date of the working order.
168    #[serde(with = "option_string_empty_as_none")]
169    #[serde(default)]
170    pub expiry: Option<String>,
171    /// Stop distance for guaranteed stop orders.
172    #[serde(rename = "stopDistance")]
173    #[serde(with = "string_as_float_opt")]
174    #[serde(default)]
175    pub stop_distance: Option<f64>,
176    /// Limit distance for guaranteed stop orders.
177    #[serde(rename = "limitDistance")]
178    #[serde(with = "string_as_float_opt")]
179    #[serde(default)]
180    pub limit_distance: Option<f64>,
181    /// Whether the stop is guaranteed.
182    #[serde(rename = "guaranteedStop")]
183    #[serde(default)]
184    pub guaranteed_stop: Option<bool>,
185    /// Type of the order (e.g., market, limit).
186    #[serde(rename = "orderType")]
187    #[serde(default)]
188    pub order_type: Option<OrderType>,
189    /// Time in force for the order.
190    #[serde(rename = "timeInForce")]
191    #[serde(default)]
192    pub time_in_force: Option<TimeInForce>,
193    /// Good till date for the working order.
194    #[serde(rename = "goodTillDate")]
195    #[serde(with = "option_string_empty_as_none")]
196    #[serde(default)]
197    pub good_till_date: Option<String>,
198}
199
200impl TradeData {
201    /// Builds a [`TradeData`] from pre-extracted streaming fields.
202    ///
203    /// This is transport-agnostic: it takes plain field maps rather than a
204    /// Lightstreamer `ItemUpdate`, so the presentation layer carries no
205    /// dependency on the streaming transport. The `ItemUpdate` adapter lives in
206    /// [`crate::application::streaming_convert`].
207    ///
208    /// # Arguments
209    /// * `item_name` - Subscription item name (`None` when subscribed by position)
210    /// * `item_pos` - 1-based position of the item in the subscription
211    /// * `is_snapshot` - Whether this update is a snapshot
212    /// * `fields` - Current field values for the item
213    /// * `changed_fields` - Field values that changed in this update
214    ///
215    /// # Returns
216    /// A Result containing either the parsed TradeData or an error message
217    pub fn from_fields(
218        item_name: Option<&str>,
219        item_pos: usize,
220        is_snapshot: bool,
221        fields: &HashMap<String, Option<String>>,
222        changed_fields: &HashMap<String, Option<String>>,
223    ) -> Result<Self, String> {
224        let fields = Self::create_trade_fields(fields)?;
225        let changed_fields = Self::create_trade_fields(changed_fields)?;
226
227        Ok(TradeData {
228            item_name: item_name.unwrap_or_default().to_string(),
229            item_pos,
230            fields,
231            changed_fields,
232            is_snapshot,
233        })
234    }
235
236    // Helper method to create TradeFields from a HashMap
237    fn create_trade_fields(
238        fields_map: &HashMap<String, Option<String>>,
239    ) -> Result<TradeFields, String> {
240        // Helper function to safely get a field value
241        let get_field = |key: &str| -> Option<String> {
242            let field = fields_map.get(key).cloned().flatten();
243            match field {
244                Some(ref s) if s.is_empty() => None,
245                _ => field,
246            }
247        };
248
249        // Parse CONFIRMS
250        let confirms = get_field("CONFIRMS");
251
252        // Parse OPU
253        let opu_str = get_field("OPU");
254        let opu = if let Some(opu_json) = opu_str {
255            if !opu_json.is_empty() {
256                match serde_json::from_str::<OpenPositionUpdate>(&opu_json) {
257                    Ok(parsed_opu) => Some(parsed_opu),
258                    Err(e) => return Err(format!("Failed to parse OPU JSON: {e}")),
259                }
260            } else {
261                None
262            }
263        } else {
264            None
265        };
266        // Parse WOU
267        let wou_str = get_field("WOU");
268        let wou = if let Some(wou_json) = wou_str {
269            if !wou_json.is_empty() {
270                match serde_json::from_str::<WorkingOrderUpdate>(&wou_json) {
271                    Ok(parsed_wou) => Some(parsed_wou),
272                    Err(e) => return Err(format!("Failed to parse WOU JSON: {e}")),
273                }
274            } else {
275                None
276            }
277        } else {
278            None
279        };
280
281        Ok(TradeFields { confirms, opu, wou })
282    }
283}
284
285#[cfg(test)]
286mod tests {
287    use super::*;
288
289    // Realistic, sanitized OPU payload. Field names mirror IG's Open Position
290    // Update stream (camelCase); the deal ids are obviously synthetic and carry
291    // no secret material.
292    const SANITIZED_OPU_JSON: &str = r#"{
293        "dealReference": "REF_SYNTHETIC_0001",
294        "dealId": "DIAAAAF00SYNTH01",
295        "dealIdOrigin": "DIAAAAF00SYNTH01",
296        "direction": "BUY",
297        "epic": "IX.D.DAX.DAILY.IP",
298        "status": "OPEN",
299        "dealStatus": "ACCEPTED",
300        "level": "18000.5",
301        "size": "1.0",
302        "currency": "EUR",
303        "timestamp": "2024-01-15T10:30:00.000",
304        "channel": "PublicRestOTC",
305        "expiry": "DFB"
306    }"#;
307
308    #[test]
309    fn test_create_trade_fields_parses_opu_json() {
310        // Exercise the JSON-parse helper directly (independent of ItemUpdate).
311        let mut map: HashMap<String, Option<String>> = HashMap::new();
312        map.insert("OPU".to_string(), Some(SANITIZED_OPU_JSON.to_string()));
313
314        let result = TradeData::create_trade_fields(&map);
315        assert!(
316            result.is_ok(),
317            "create_trade_fields should parse OPU: {result:?}"
318        );
319        let fields = result.expect("OPU parse checked ok above");
320        let opu = fields.opu.expect("OPU should be populated");
321        assert_eq!(opu.deal_id.as_deref(), Some("DIAAAAF00SYNTH01"));
322        assert_eq!(opu.level, Some(18000.5));
323    }
324
325    #[test]
326    fn test_open_position_update_captures_stop_limit_and_trailing_fields() {
327        // Realistic OPU payload for a position carrying a stop, a limit and a
328        // trailing stop. Deal ids are synthetic; the numeric fields arrive as
329        // strings in the OPU JSON and are parsed via `string_as_float_opt`.
330        let json = r#"{
331            "dealReference": "REF_SYNTHETIC_0002",
332            "dealId": "DIAAAAF00SYNTH02",
333            "dealIdOrigin": "DIAAAAF00SYNTH02",
334            "direction": "SELL",
335            "epic": "IX.D.DAX.DAILY.IP",
336            "status": "OPEN",
337            "dealStatus": "ACCEPTED",
338            "level": "18010.0",
339            "size": "2.0",
340            "currency": "EUR",
341            "timestamp": "2024-01-15T10:31:00.000",
342            "channel": "PublicRestOTC",
343            "expiry": "DFB",
344            "stopLevel": "18100.0",
345            "limitLevel": "17900.0",
346            "guaranteedStop": true,
347            "trailingStep": "5.0",
348            "trailingStopDistance": "50.0"
349        }"#;
350
351        let opu: OpenPositionUpdate = serde_json::from_str(json).expect("deserialize failed");
352        assert_eq!(opu.stop_level, Some(18100.0));
353        assert_eq!(opu.limit_level, Some(17900.0));
354        assert_eq!(opu.guaranteed_stop, Some(true));
355        assert_eq!(opu.trailing_step, Some(5.0));
356        assert_eq!(opu.trailing_stop_distance, Some(50.0));
357
358        // Round-trip leg: compare structs, not JSON text.
359        let out = serde_json::to_string(&opu).expect("serialize failed");
360        let reparsed: OpenPositionUpdate =
361            serde_json::from_str(&out).expect("re-deserialize failed");
362        assert_eq!(reparsed.stop_level, Some(18100.0));
363        assert_eq!(reparsed.limit_level, Some(17900.0));
364        assert_eq!(reparsed.guaranteed_stop, Some(true));
365        assert_eq!(reparsed.trailing_step, Some(5.0));
366        assert_eq!(reparsed.trailing_stop_distance, Some(50.0));
367    }
368
369    #[test]
370    fn test_open_position_update_new_fields_absent_default_to_none() {
371        // The original OPU shape (no stop/limit/trailing keys) must still parse,
372        // with the added fields defaulting to `None`.
373        let opu: OpenPositionUpdate =
374            serde_json::from_str(SANITIZED_OPU_JSON).expect("deserialize failed");
375        assert!(opu.stop_level.is_none());
376        assert!(opu.limit_level.is_none());
377        assert!(opu.guaranteed_stop.is_none());
378        assert!(opu.trailing_step.is_none());
379        assert!(opu.trailing_stop_distance.is_none());
380    }
381
382    #[test]
383    fn test_trade_data_default() {
384        let data = TradeData::default();
385        assert!(data.item_name.is_empty());
386        assert_eq!(data.item_pos, 0);
387        assert!(!data.is_snapshot);
388    }
389
390    #[test]
391    fn test_trade_fields_default() {
392        let fields = TradeFields::default();
393        assert!(fields.confirms.is_none());
394        assert!(fields.opu.is_none());
395        assert!(fields.wou.is_none());
396    }
397
398    #[test]
399    fn test_open_position_update_default() {
400        let opu = OpenPositionUpdate::default();
401        assert!(opu.deal_reference.is_none());
402        assert!(opu.deal_id.is_none());
403        assert!(opu.direction.is_none());
404        assert!(opu.epic.is_none());
405        assert!(opu.status.is_none());
406        assert!(opu.level.is_none());
407        assert!(opu.size.is_none());
408    }
409
410    #[test]
411    fn test_working_order_update_default() {
412        let wou = WorkingOrderUpdate::default();
413        assert!(wou.deal_reference.is_none());
414        assert!(wou.deal_id.is_none());
415        assert!(wou.direction.is_none());
416        assert!(wou.epic.is_none());
417        assert!(wou.status.is_none());
418        assert!(wou.level.is_none());
419        assert!(wou.size.is_none());
420    }
421
422    #[test]
423    fn test_open_position_update_creation() {
424        let opu = OpenPositionUpdate {
425            deal_reference: Some("REF123".to_string()),
426            deal_id: Some("DEAL456".to_string()),
427            direction: Some(Direction::Buy),
428            epic: Some("IX.D.DAX.DAILY.IP".to_string()),
429            status: Some(Status::Open),
430            level: Some(18000.0),
431            size: Some(1.0),
432            currency: Some("EUR".to_string()),
433            ..Default::default()
434        };
435        assert_eq!(opu.deal_reference, Some("REF123".to_string()));
436        assert_eq!(opu.deal_id, Some("DEAL456".to_string()));
437        assert_eq!(opu.direction, Some(Direction::Buy));
438        assert_eq!(opu.level, Some(18000.0));
439    }
440
441    #[test]
442    fn test_working_order_update_creation() {
443        let wou = WorkingOrderUpdate {
444            deal_reference: Some("WO_REF".to_string()),
445            deal_id: Some("WO_DEAL".to_string()),
446            direction: Some(Direction::Sell),
447            epic: Some("CS.D.EURUSD.CFD.IP".to_string()),
448            status: Some(Status::Amended),
449            level: Some(1.1000),
450            size: Some(10000.0),
451            order_type: Some(OrderType::Limit),
452            time_in_force: Some(TimeInForce::GoodTillCancelled),
453            ..Default::default()
454        };
455        assert_eq!(wou.deal_reference, Some("WO_REF".to_string()));
456        assert_eq!(wou.direction, Some(Direction::Sell));
457        assert_eq!(wou.order_type, Some(OrderType::Limit));
458    }
459
460    #[test]
461    fn test_trade_data_creation() {
462        let data = TradeData {
463            item_name: "TRADE:123".to_string(),
464            item_pos: 1,
465            fields: TradeFields::default(),
466            changed_fields: TradeFields::default(),
467            is_snapshot: true,
468        };
469        assert_eq!(data.item_name, "TRADE:123");
470        assert_eq!(data.item_pos, 1);
471        assert!(data.is_snapshot);
472    }
473
474    #[test]
475    fn test_trade_fields_with_confirms() {
476        let fields = TradeFields {
477            confirms: Some("confirmed".to_string()),
478            opu: None,
479            wou: None,
480        };
481        assert_eq!(fields.confirms, Some("confirmed".to_string()));
482    }
483
484    #[test]
485    fn test_open_position_update_serialization() {
486        let opu = OpenPositionUpdate {
487            deal_id: Some("DEAL123".to_string()),
488            level: Some(100.5),
489            ..Default::default()
490        };
491        let json = serde_json::to_string(&opu).expect("serialize failed");
492        assert!(json.contains("DEAL123"));
493    }
494
495    #[test]
496    fn test_working_order_update_serialization() {
497        let wou = WorkingOrderUpdate {
498            deal_id: Some("WO123".to_string()),
499            level: Some(50.0),
500            ..Default::default()
501        };
502        let json = serde_json::to_string(&wou).expect("serialize failed");
503        assert!(json.contains("WO123"));
504    }
505}