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#[derive(DebugPretty, DisplaySimple, Clone, Serialize, Deserialize, Default)]
11pub struct TradeData {
12 pub item_name: String,
14 pub item_pos: usize,
16 pub fields: TradeFields,
18 pub changed_fields: TradeFields,
20 pub is_snapshot: bool,
22}
23
24#[derive(DebugPretty, DisplaySimple, Clone, Serialize, Deserialize, Default)]
26#[serde(rename_all = "UPPERCASE")]
27pub struct TradeFields {
28 #[serde(skip_serializing_if = "Option::is_none")]
30 pub confirms: Option<String>,
31 #[serde(skip_serializing_if = "Option::is_none")]
33 pub opu: Option<OpenPositionUpdate>,
34 #[serde(skip_serializing_if = "Option::is_none")]
36 pub wou: Option<WorkingOrderUpdate>,
37}
38
39#[derive(DebugPretty, DisplaySimple, Clone, Serialize, Deserialize, Default)]
41pub struct OpenPositionUpdate {
42 #[serde(rename = "dealReference")]
44 #[serde(with = "option_string_empty_as_none")]
45 #[serde(default)]
46 pub deal_reference: Option<String>,
47 #[serde(rename = "dealId")]
49 #[serde(with = "option_string_empty_as_none")]
50 #[serde(default)]
51 pub deal_id: Option<String>,
52 #[serde(default)]
54 pub direction: Option<Direction>,
55 #[serde(default)]
57 pub epic: Option<String>,
58 #[serde(default)]
60 pub status: Option<Status>,
61 #[serde(rename = "dealStatus")]
63 #[serde(default)]
64 pub deal_status: Option<Status>,
65 #[serde(with = "string_as_float_opt")]
67 #[serde(default)]
68 pub level: Option<f64>,
69 #[serde(with = "string_as_float_opt")]
71 #[serde(default)]
72 pub size: Option<f64>,
73 #[serde(with = "option_string_empty_as_none")]
75 #[serde(default)]
76 pub currency: Option<String>,
77 #[serde(with = "option_string_empty_as_none")]
79 #[serde(default)]
80 pub timestamp: Option<String>,
81 #[serde(with = "option_string_empty_as_none")]
83 #[serde(default)]
84 pub channel: Option<String>,
85 #[serde(with = "option_string_empty_as_none")]
87 #[serde(default)]
88 pub expiry: Option<String>,
89 #[serde(rename = "dealIdOrigin")]
91 #[serde(with = "option_string_empty_as_none")]
92 #[serde(default)]
93 pub deal_id_origin: Option<String>,
94 #[serde(rename = "stopLevel")]
96 #[serde(with = "string_as_float_opt")]
97 #[serde(default)]
98 pub stop_level: Option<f64>,
99 #[serde(rename = "limitLevel")]
101 #[serde(with = "string_as_float_opt")]
102 #[serde(default)]
103 pub limit_level: Option<f64>,
104 #[serde(rename = "guaranteedStop")]
106 #[serde(default)]
107 pub guaranteed_stop: Option<bool>,
108 #[serde(rename = "trailingStep")]
110 #[serde(with = "string_as_float_opt")]
111 #[serde(default)]
112 pub trailing_step: Option<f64>,
113 #[serde(rename = "trailingStopDistance")]
115 #[serde(with = "string_as_float_opt")]
116 #[serde(default)]
117 pub trailing_stop_distance: Option<f64>,
118}
119
120#[derive(DebugPretty, DisplaySimple, Clone, Serialize, Deserialize, Default)]
122pub struct WorkingOrderUpdate {
123 #[serde(rename = "dealReference")]
125 #[serde(with = "option_string_empty_as_none")]
126 #[serde(default)]
127 pub deal_reference: Option<String>,
128 #[serde(rename = "dealId")]
130 #[serde(with = "option_string_empty_as_none")]
131 #[serde(default)]
132 pub deal_id: Option<String>,
133 #[serde(default)]
135 pub direction: Option<Direction>,
136 #[serde(with = "option_string_empty_as_none")]
138 #[serde(default)]
139 pub epic: Option<String>,
140 #[serde(default)]
142 pub status: Option<Status>,
143 #[serde(rename = "dealStatus")]
145 #[serde(default)]
146 pub deal_status: Option<Status>,
147 #[serde(with = "string_as_float_opt")]
149 #[serde(default)]
150 pub level: Option<f64>,
151 #[serde(with = "string_as_float_opt")]
153 #[serde(default)]
154 pub size: Option<f64>,
155 #[serde(with = "option_string_empty_as_none")]
157 #[serde(default)]
158 pub currency: Option<String>,
159 #[serde(with = "option_string_empty_as_none")]
161 #[serde(default)]
162 pub timestamp: Option<String>,
163 #[serde(with = "option_string_empty_as_none")]
165 #[serde(default)]
166 pub channel: Option<String>,
167 #[serde(with = "option_string_empty_as_none")]
169 #[serde(default)]
170 pub expiry: Option<String>,
171 #[serde(rename = "stopDistance")]
173 #[serde(with = "string_as_float_opt")]
174 #[serde(default)]
175 pub stop_distance: Option<f64>,
176 #[serde(rename = "limitDistance")]
178 #[serde(with = "string_as_float_opt")]
179 #[serde(default)]
180 pub limit_distance: Option<f64>,
181 #[serde(rename = "guaranteedStop")]
183 #[serde(default)]
184 pub guaranteed_stop: Option<bool>,
185 #[serde(rename = "orderType")]
187 #[serde(default)]
188 pub order_type: Option<OrderType>,
189 #[serde(rename = "timeInForce")]
191 #[serde(default)]
192 pub time_in_force: Option<TimeInForce>,
193 #[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 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 fn create_trade_fields(
238 fields_map: &HashMap<String, Option<String>>,
239 ) -> Result<TradeFields, String> {
240 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 let confirms = get_field("CONFIRMS");
251
252 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 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 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 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 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 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 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}