1use 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#[derive(DebugPretty, DisplaySimple, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
55pub struct StreamingUpdate {
56 pub item_name: Option<String>,
60 pub item_pos: usize,
62 pub is_snapshot: bool,
64 pub fields: HashMap<String, Option<String>>,
66 pub changed_fields: HashMap<String, Option<String>>,
68}
69
70#[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#[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#[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#[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#[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#[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 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 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 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 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 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 assert!(wou.good_till_date.is_none());
391
392 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 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 let item = trade_item_update("OPU", "{ this is not valid json ");
421
422 let data = TradeData::from(&item);
423
424 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 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 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 #[test]
467 fn test_field_value_null_and_empty_text_are_distinct() {
468 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 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}