Skip to main content

ig_client/application/
streaming_convert.rs

1/******************************************************************************
2   Author: Joaquín Béjar García
3   Email: jb@taunais.com
4   Date: 20/10/25
5******************************************************************************/
6
7//! Adapters from a Lightstreamer update to the presentation-layer DTOs.
8//!
9//! The presentation DTOs stay transport-agnostic: they expose pure
10//! `from_fields(..)` constructors that take plain field maps. This module is the
11//! only place that depends on `lightstreamer_rs`; it reads an
12//! [`ItemUpdate`](lightstreamer_rs::ItemUpdate) and
13//! feeds the extracted metadata / field maps into those pure constructors.
14//!
15//! # The seam
16//!
17//! [`ItemUpdate`](lightstreamer_rs::ItemUpdate) is the streaming crate's own
18//! type: it borrows from the subscription schema, has no public constructor, and
19//! models a field value as [`FieldValue`](lightstreamer_rs::FieldValue) rather
20//! than a string. [`StreamingUpdate`](crate::application::streaming_convert::StreamingUpdate) is this crate's
21//! owned, constructible mirror of it, and every conversion below goes through
22//! it. That keeps the DTO parsers testable without a live session, and keeps
23//! the null-versus-empty distinction explicit at exactly one place:
24//! `FieldValue::Null` becomes `None`, `FieldValue::Text(s)` becomes
25//! `Some(s)` — including `Some("")` for a field the server deliberately sent
26//! empty.
27//!
28//! Both a `Result`-returning function (for callers that want to observe parse
29//! failures) and the `From` conversions (which degrade to a default on failure,
30//! preserving the previous streaming behaviour) are provided per type.
31
32use crate::error::AppError;
33use crate::presentation::account::AccountData;
34use crate::presentation::chart::ChartData;
35use crate::presentation::market::{MarketFields, PresentationMarketData};
36use crate::presentation::price::PriceData;
37use crate::presentation::trade::TradeData;
38use lightstreamer_rs::{FieldValue, ItemUpdate};
39use pretty_simple_display::{DebugPretty, DisplaySimple};
40use serde::{Deserialize, Serialize};
41use std::collections::HashMap;
42
43/// One Lightstreamer item update, owned and free of borrowed schema state.
44///
45/// This is the boundary type between `lightstreamer-rs` and this crate's
46/// presentation DTOs. It is deliberately constructible by hand so that the
47/// field parsers can be exercised from captured IG payloads without a live
48/// session — the streaming crate's own [`ItemUpdate`] cannot be built outside
49/// that crate.
50///
51/// A `None` field value means the server said the field has *no* value
52/// (`FieldValue::Null`); `Some("")` means it sent an empty one. The two are
53/// different answers and are kept apart.
54#[derive(DebugPretty, DisplaySimple, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
55pub struct StreamingUpdate {
56    /// The item name the subscription declared for this position, when it
57    /// declared one. `None` for a server-resolved item group, where the
58    /// protocol never transmits names.
59    pub item_name: Option<String>,
60    /// The item's 1-based position within the subscription.
61    pub item_pos: usize,
62    /// Whether this update carries snapshot content rather than a live change.
63    pub is_snapshot: bool,
64    /// The complete state of the item after this update, keyed by field name.
65    pub fields: HashMap<String, Option<String>>,
66    /// Only the fields this update actually carried a value for.
67    pub changed_fields: HashMap<String, Option<String>>,
68}
69
70/// Converts a [`FieldValue`] into the `Option<String>` the pure field parsers
71/// consume: null becomes absent, text (empty or not) becomes present.
72#[must_use]
73#[inline]
74fn field_value_as_option(value: FieldValue<'_>) -> Option<String> {
75    match value {
76        FieldValue::Null => None,
77        FieldValue::Text(text) => Some(text.to_owned()),
78    }
79}
80
81impl From<&ItemUpdate> for StreamingUpdate {
82    fn from(update: &ItemUpdate) -> Self {
83        let fields = update
84            .fields()
85            .map(|field| {
86                (
87                    field.name().to_owned(),
88                    field_value_as_option(field.value()),
89                )
90            })
91            .collect();
92        let changed_fields = update
93            .changed_fields()
94            .map(|field| {
95                (
96                    field.name().to_owned(),
97                    field_value_as_option(field.value()),
98                )
99            })
100            .collect();
101
102        Self {
103            item_name: update.declared_item_name().map(str::to_owned),
104            item_pos: update.item_index(),
105            is_snapshot: update.is_snapshot(),
106            fields,
107            changed_fields,
108        }
109    }
110}
111
112/// Converts a streaming update into a [`PriceData`].
113///
114/// # Errors
115/// Returns [`AppError::Deserialization`] when a field fails to parse (e.g. a
116/// non-numeric price or an unknown dealing flag). The message names the
117/// offending field and value.
118#[must_use = "the parse result must be handled"]
119pub fn price_data_from_item_update(update: &StreamingUpdate) -> Result<PriceData, AppError> {
120    PriceData::from_fields(
121        update.item_name.as_deref(),
122        update.item_pos,
123        update.is_snapshot,
124        &update.fields,
125        &update.changed_fields,
126    )
127    .map_err(AppError::Deserialization)
128}
129
130impl From<&StreamingUpdate> for PriceData {
131    fn from(update: &StreamingUpdate) -> Self {
132        price_data_from_item_update(update).unwrap_or_else(|e| {
133            tracing::warn!(error = %e, "failed to convert streaming update to PriceData, returning default");
134            PriceData::default()
135        })
136    }
137}
138
139impl From<&ItemUpdate> for PriceData {
140    fn from(update: &ItemUpdate) -> Self {
141        Self::from(&StreamingUpdate::from(update))
142    }
143}
144
145/// Converts a streaming update into a [`PresentationMarketData`].
146///
147/// # Errors
148/// Returns [`AppError::Deserialization`] when a field fails to parse (e.g. an
149/// unknown market state or an invalid `MARKET_DELAY` value).
150#[must_use = "the parse result must be handled"]
151pub fn market_data_from_item_update(
152    update: &StreamingUpdate,
153) -> Result<PresentationMarketData, AppError> {
154    PresentationMarketData::from_fields(
155        update.item_name.as_deref(),
156        update.item_pos,
157        update.is_snapshot,
158        &update.fields,
159        &update.changed_fields,
160    )
161    .map_err(AppError::Deserialization)
162}
163
164impl From<&StreamingUpdate> for PresentationMarketData {
165    fn from(update: &StreamingUpdate) -> Self {
166        market_data_from_item_update(update).unwrap_or_else(|_| PresentationMarketData {
167            item_name: String::new(),
168            item_pos: 0,
169            fields: MarketFields::default(),
170            changed_fields: MarketFields::default(),
171            is_snapshot: false,
172        })
173    }
174}
175
176impl From<&ItemUpdate> for PresentationMarketData {
177    fn from(update: &ItemUpdate) -> Self {
178        Self::from(&StreamingUpdate::from(update))
179    }
180}
181
182/// Converts a streaming update into a [`ChartData`].
183///
184/// # Errors
185/// Returns [`AppError::Deserialization`] when a field fails to parse (e.g. a
186/// non-numeric candle value).
187#[must_use = "the parse result must be handled"]
188pub fn chart_data_from_item_update(update: &StreamingUpdate) -> Result<ChartData, AppError> {
189    ChartData::from_fields(
190        update.item_name.as_deref(),
191        update.item_pos,
192        update.is_snapshot,
193        &update.fields,
194        &update.changed_fields,
195    )
196    .map_err(AppError::Deserialization)
197}
198
199impl From<&StreamingUpdate> for ChartData {
200    fn from(update: &StreamingUpdate) -> Self {
201        chart_data_from_item_update(update).unwrap_or_default()
202    }
203}
204
205impl From<&ItemUpdate> for ChartData {
206    fn from(update: &ItemUpdate) -> Self {
207        Self::from(&StreamingUpdate::from(update))
208    }
209}
210
211/// Converts a streaming update into a [`TradeData`].
212///
213/// # Errors
214/// Returns [`AppError::Deserialization`] when the embedded OPU / WOU JSON
215/// payload fails to parse.
216#[must_use = "the parse result must be handled"]
217pub fn trade_data_from_item_update(update: &StreamingUpdate) -> Result<TradeData, AppError> {
218    TradeData::from_fields(
219        update.item_name.as_deref(),
220        update.item_pos,
221        update.is_snapshot,
222        &update.fields,
223        &update.changed_fields,
224    )
225    .map_err(AppError::Deserialization)
226}
227
228impl From<&StreamingUpdate> for TradeData {
229    fn from(update: &StreamingUpdate) -> Self {
230        trade_data_from_item_update(update).unwrap_or_default()
231    }
232}
233
234impl From<&ItemUpdate> for TradeData {
235    fn from(update: &ItemUpdate) -> Self {
236        Self::from(&StreamingUpdate::from(update))
237    }
238}
239
240/// Converts a streaming update into an [`AccountData`].
241///
242/// # Errors
243/// Returns [`AppError::Deserialization`] when a field fails to parse (e.g. a
244/// non-numeric P&L or margin value).
245#[must_use = "the parse result must be handled"]
246pub fn account_data_from_item_update(update: &StreamingUpdate) -> Result<AccountData, AppError> {
247    AccountData::from_fields(
248        update.item_name.as_deref(),
249        update.item_pos,
250        update.is_snapshot,
251        &update.fields,
252        &update.changed_fields,
253    )
254    .map_err(AppError::Deserialization)
255}
256
257impl From<&StreamingUpdate> for AccountData {
258    fn from(update: &StreamingUpdate) -> Self {
259        account_data_from_item_update(update).unwrap_or_else(|_| AccountData::default())
260    }
261}
262
263impl From<&ItemUpdate> for AccountData {
264    fn from(update: &ItemUpdate) -> Self {
265        Self::from(&StreamingUpdate::from(update))
266    }
267}
268
269#[cfg(test)]
270mod tests {
271    use super::*;
272    use crate::presentation::order::{Direction, OrderType, Status, TimeInForce};
273
274    /// Builds an update carrying a single TRADE field, mirroring how the
275    /// Lightstreamer TRADE subscription delivers OPU / WOU / CONFIRMS payloads.
276    ///
277    /// `item_name`, `item_pos` and `is_snapshot` are set to distinctive,
278    /// obviously-synthetic values so tests can assert whether they survive the
279    /// parse (they are dropped on the silent-default path — see the malformed
280    /// test).
281    fn trade_item_update(field_key: &str, field_value: &str) -> StreamingUpdate {
282        let mut fields = HashMap::new();
283        fields.insert(field_key.to_string(), Some(field_value.to_string()));
284        StreamingUpdate {
285            item_name: Some("TRADE:SYNTHETIC".to_string()),
286            item_pos: 2,
287            fields,
288            changed_fields: HashMap::new(),
289            is_snapshot: true,
290        }
291    }
292
293    // Realistic, sanitized OPU payload. Field names mirror IG's Open Position
294    // Update stream (camelCase); the deal ids are obviously synthetic and carry
295    // no secret material.
296    const SANITIZED_OPU_JSON: &str = r#"{
297        "dealReference": "REF_SYNTHETIC_0001",
298        "dealId": "DIAAAAF00SYNTH01",
299        "dealIdOrigin": "DIAAAAF00SYNTH01",
300        "direction": "BUY",
301        "epic": "IX.D.DAX.DAILY.IP",
302        "status": "OPEN",
303        "dealStatus": "ACCEPTED",
304        "level": "18000.5",
305        "size": "1.0",
306        "currency": "EUR",
307        "timestamp": "2024-01-15T10:30:00.000",
308        "channel": "PublicRestOTC",
309        "expiry": "DFB"
310    }"#;
311
312    // Realistic, sanitized WOU payload. Field names mirror IG's Working Order
313    // Update stream (camelCase); the deal ids are obviously synthetic.
314    const SANITIZED_WOU_JSON: &str = r#"{
315        "dealReference": "REF_SYNTHETIC_0002",
316        "dealId": "DIAAAAF00SYNTH02",
317        "direction": "SELL",
318        "epic": "CS.D.EURUSD.CFD.IP",
319        "status": "OPEN",
320        "dealStatus": "ACCEPTED",
321        "level": "1.1000",
322        "size": "10000",
323        "currency": "USD",
324        "timestamp": "2024-01-15T10:31:00.000",
325        "channel": "PublicRestOTC",
326        "expiry": "-",
327        "stopDistance": "10.5",
328        "limitDistance": "20.0",
329        "guaranteedStop": false,
330        "orderType": "LIMIT",
331        "timeInForce": "GOOD_TILL_CANCELLED",
332        "goodTillDate": ""
333    }"#;
334
335    #[test]
336    fn test_trade_data_from_item_update_parses_open_position_update() {
337        let item = trade_item_update("OPU", SANITIZED_OPU_JSON);
338
339        let result = trade_data_from_item_update(&item);
340        assert!(result.is_ok(), "OPU payload should parse: {result:?}");
341        let data = result.expect("OPU parse checked ok above");
342
343        // Update metadata is preserved on the success path.
344        assert_eq!(data.item_name, "TRADE:SYNTHETIC");
345        assert_eq!(data.item_pos, 2);
346        assert!(data.is_snapshot);
347
348        let opu = data.fields.opu.expect("OPU should be populated");
349        assert_eq!(opu.deal_reference.as_deref(), Some("REF_SYNTHETIC_0001"));
350        assert_eq!(opu.deal_id.as_deref(), Some("DIAAAAF00SYNTH01"));
351        assert_eq!(opu.deal_id_origin.as_deref(), Some("DIAAAAF00SYNTH01"));
352        assert_eq!(opu.direction, Some(Direction::Buy));
353        assert_eq!(opu.epic.as_deref(), Some("IX.D.DAX.DAILY.IP"));
354        assert_eq!(opu.status, Some(Status::Open));
355        assert_eq!(opu.deal_status, Some(Status::Accepted));
356        assert_eq!(opu.level, Some(18000.5));
357        assert_eq!(opu.size, Some(1.0));
358        assert_eq!(opu.currency.as_deref(), Some("EUR"));
359        assert_eq!(opu.expiry.as_deref(), Some("DFB"));
360
361        // No WOU or CONFIRMS present in this update.
362        assert!(data.fields.wou.is_none());
363        assert!(data.fields.confirms.is_none());
364    }
365
366    #[test]
367    fn test_trade_data_from_item_update_parses_working_order_update() {
368        let item = trade_item_update("WOU", SANITIZED_WOU_JSON);
369
370        let result = trade_data_from_item_update(&item);
371        assert!(result.is_ok(), "WOU payload should parse: {result:?}");
372        let data = result.expect("WOU parse checked ok above");
373
374        let wou = data.fields.wou.expect("WOU should be populated");
375        assert_eq!(wou.deal_reference.as_deref(), Some("REF_SYNTHETIC_0002"));
376        assert_eq!(wou.deal_id.as_deref(), Some("DIAAAAF00SYNTH02"));
377        assert_eq!(wou.direction, Some(Direction::Sell));
378        assert_eq!(wou.epic.as_deref(), Some("CS.D.EURUSD.CFD.IP"));
379        assert_eq!(wou.status, Some(Status::Open));
380        assert_eq!(wou.deal_status, Some(Status::Accepted));
381        assert_eq!(wou.level, Some(1.1000));
382        assert_eq!(wou.size, Some(10000.0));
383        assert_eq!(wou.currency.as_deref(), Some("USD"));
384        assert_eq!(wou.stop_distance, Some(10.5));
385        assert_eq!(wou.limit_distance, Some(20.0));
386        assert_eq!(wou.guaranteed_stop, Some(false));
387        assert_eq!(wou.order_type, Some(OrderType::Limit));
388        assert_eq!(wou.time_in_force, Some(TimeInForce::GoodTillCancelled));
389        // Empty string good-till-date degrades to None.
390        assert!(wou.good_till_date.is_none());
391
392        // No OPU or CONFIRMS present in this update.
393        assert!(data.fields.opu.is_none());
394        assert!(data.fields.confirms.is_none());
395    }
396
397    #[test]
398    fn test_trade_data_from_item_update_malformed_opu_is_error_on_direct_path() {
399        let item = trade_item_update("OPU", "{ this is not valid json ");
400
401        // The direct parse path surfaces the failure as an Err.
402        let result = trade_data_from_item_update(&item);
403        assert!(
404            result.is_err(),
405            "malformed OPU JSON must surface an error on the direct parse path"
406        );
407    }
408
409    #[test]
410    fn test_trade_data_from_impl_swallows_malformed_opu_into_default() {
411        // KNOWN GAP (pinned, do not "fix" here): `From<&ItemUpdate>` funnels any
412        // parse failure through `.unwrap_or_default()`, so a malformed TRADE
413        // payload is silently swallowed into `TradeData::default()` instead of
414        // being surfaced. This test PINS that current behavior so a future change
415        // to make the swallow deliberate (e.g. logging, or propagating the error)
416        // is a conscious, reviewed decision rather than an accidental regression.
417        //
418        // TODO(streaming): decide whether `From<&ItemUpdate>` should log the
419        // dropped payload at WARN or expose the parse error instead of defaulting.
420        let item = trade_item_update("OPU", "{ this is not valid json ");
421
422        let data = TradeData::from(&item);
423
424        // Everything degrades to the Default, including the update metadata that
425        // was present on the source ItemUpdate (item_name / item_pos / snapshot).
426        assert!(
427            data.item_name.is_empty(),
428            "silent-default path discards item_name"
429        );
430        assert_eq!(data.item_pos, 0, "silent-default path discards item_pos");
431        assert!(
432            !data.is_snapshot,
433            "silent-default path discards is_snapshot"
434        );
435        assert!(data.fields.opu.is_none());
436        assert!(data.fields.wou.is_none());
437        assert!(data.fields.confirms.is_none());
438    }
439
440    #[test]
441    fn test_price_data_from_item_update_defaults_on_unknown_dealing_flag() {
442        // An unknown dealing flag makes the pure parser fail; the `From` impl
443        // degrades to `PriceData::default()` while the direct fn surfaces the Err.
444        let mut fields = HashMap::new();
445        fields.insert("DLG_FLAG".to_string(), Some("NOT_A_FLAG".to_string()));
446        let item = StreamingUpdate {
447            item_name: Some("PRICE:CS.D.EURUSD.MINI.IP".to_string()),
448            item_pos: 1,
449            fields,
450            changed_fields: HashMap::new(),
451            is_snapshot: true,
452        };
453
454        assert!(price_data_from_item_update(&item).is_err());
455        // The `From` impl degrades to the default: metadata from the source
456        // update is discarded (matching the pre-refactor silent-default path).
457        let data = PriceData::from(&item);
458        assert!(data.item_name.is_empty());
459        assert_eq!(data.item_pos, 0);
460        assert!(!data.is_snapshot);
461        assert!(data.fields.dealing_flag.is_none());
462    }
463
464    // --- The seam type itself ----------------------------------------------
465
466    #[test]
467    fn test_field_value_null_and_empty_text_are_distinct() {
468        // The whole reason `StreamingUpdate` stores `Option<String>` rather
469        // than `String`: TLCP distinguishes "no value" from "the empty string",
470        // and the seam must not flatten one into the other.
471        assert_eq!(field_value_as_option(FieldValue::Null), None);
472        assert_eq!(
473            field_value_as_option(FieldValue::Text("")),
474            Some(String::new())
475        );
476        assert_eq!(
477            field_value_as_option(FieldValue::Text("1.2345")),
478            Some("1.2345".to_string())
479        );
480    }
481
482    #[test]
483    fn test_streaming_update_serde_round_trip() {
484        let mut fields = HashMap::new();
485        fields.insert("BID".to_string(), Some("18000.5".to_string()));
486        // A null field and an empty-text field must both survive the round trip
487        // as themselves.
488        fields.insert("OFFER".to_string(), None);
489        fields.insert("MARKET_STATE".to_string(), Some(String::new()));
490
491        let mut changed_fields = HashMap::new();
492        changed_fields.insert("BID".to_string(), Some("18000.5".to_string()));
493
494        let update = StreamingUpdate {
495            item_name: Some("MARKET:IX.D.DAX.DAILY.IP".to_string()),
496            item_pos: 3,
497            is_snapshot: true,
498            fields,
499            changed_fields,
500        };
501
502        let json = serde_json::to_string(&update).expect("StreamingUpdate should serialize");
503        let decoded: StreamingUpdate =
504            serde_json::from_str(&json).expect("StreamingUpdate should deserialize");
505
506        assert_eq!(decoded, update);
507        assert_eq!(decoded.fields.get("OFFER"), Some(&None));
508        assert_eq!(
509            decoded.fields.get("MARKET_STATE"),
510            Some(&Some(String::new()))
511        );
512    }
513}