use crate::presentation::order::{Direction, OrderType, Status, TimeInForce};
use crate::presentation::serialization::{option_string_empty_as_none, string_as_float_opt};
use pretty_simple_display::{DebugPretty, DisplaySimple};
use serde::{Deserialize, Serialize};
use serde_json;
use std::collections::HashMap;
#[derive(DebugPretty, DisplaySimple, Clone, Serialize, Deserialize, Default)]
pub struct TradeData {
pub item_name: String,
pub item_pos: usize,
pub fields: TradeFields,
pub changed_fields: TradeFields,
pub is_snapshot: bool,
}
#[derive(DebugPretty, DisplaySimple, Clone, Serialize, Deserialize, Default)]
#[serde(rename_all = "UPPERCASE")]
pub struct TradeFields {
#[serde(skip_serializing_if = "Option::is_none")]
pub confirms: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub opu: Option<OpenPositionUpdate>,
#[serde(skip_serializing_if = "Option::is_none")]
pub wou: Option<WorkingOrderUpdate>,
}
#[derive(DebugPretty, DisplaySimple, Clone, Serialize, Deserialize, Default)]
pub struct OpenPositionUpdate {
#[serde(rename = "dealReference")]
#[serde(with = "option_string_empty_as_none")]
#[serde(default)]
pub deal_reference: Option<String>,
#[serde(rename = "dealId")]
#[serde(with = "option_string_empty_as_none")]
#[serde(default)]
pub deal_id: Option<String>,
#[serde(default)]
pub direction: Option<Direction>,
#[serde(default)]
pub epic: Option<String>,
#[serde(default)]
pub status: Option<Status>,
#[serde(rename = "dealStatus")]
#[serde(default)]
pub deal_status: Option<Status>,
#[serde(with = "string_as_float_opt")]
#[serde(default)]
pub level: Option<f64>,
#[serde(with = "string_as_float_opt")]
#[serde(default)]
pub size: Option<f64>,
#[serde(with = "option_string_empty_as_none")]
#[serde(default)]
pub currency: Option<String>,
#[serde(with = "option_string_empty_as_none")]
#[serde(default)]
pub timestamp: Option<String>,
#[serde(with = "option_string_empty_as_none")]
#[serde(default)]
pub channel: Option<String>,
#[serde(with = "option_string_empty_as_none")]
#[serde(default)]
pub expiry: Option<String>,
#[serde(rename = "dealIdOrigin")]
#[serde(with = "option_string_empty_as_none")]
#[serde(default)]
pub deal_id_origin: Option<String>,
#[serde(rename = "stopLevel")]
#[serde(with = "string_as_float_opt")]
#[serde(default)]
pub stop_level: Option<f64>,
#[serde(rename = "limitLevel")]
#[serde(with = "string_as_float_opt")]
#[serde(default)]
pub limit_level: Option<f64>,
#[serde(rename = "guaranteedStop")]
#[serde(default)]
pub guaranteed_stop: Option<bool>,
#[serde(rename = "trailingStep")]
#[serde(with = "string_as_float_opt")]
#[serde(default)]
pub trailing_step: Option<f64>,
#[serde(rename = "trailingStopDistance")]
#[serde(with = "string_as_float_opt")]
#[serde(default)]
pub trailing_stop_distance: Option<f64>,
}
#[derive(DebugPretty, DisplaySimple, Clone, Serialize, Deserialize, Default)]
pub struct WorkingOrderUpdate {
#[serde(rename = "dealReference")]
#[serde(with = "option_string_empty_as_none")]
#[serde(default)]
pub deal_reference: Option<String>,
#[serde(rename = "dealId")]
#[serde(with = "option_string_empty_as_none")]
#[serde(default)]
pub deal_id: Option<String>,
#[serde(default)]
pub direction: Option<Direction>,
#[serde(with = "option_string_empty_as_none")]
#[serde(default)]
pub epic: Option<String>,
#[serde(default)]
pub status: Option<Status>,
#[serde(rename = "dealStatus")]
#[serde(default)]
pub deal_status: Option<Status>,
#[serde(with = "string_as_float_opt")]
#[serde(default)]
pub level: Option<f64>,
#[serde(with = "string_as_float_opt")]
#[serde(default)]
pub size: Option<f64>,
#[serde(with = "option_string_empty_as_none")]
#[serde(default)]
pub currency: Option<String>,
#[serde(with = "option_string_empty_as_none")]
#[serde(default)]
pub timestamp: Option<String>,
#[serde(with = "option_string_empty_as_none")]
#[serde(default)]
pub channel: Option<String>,
#[serde(with = "option_string_empty_as_none")]
#[serde(default)]
pub expiry: Option<String>,
#[serde(rename = "stopDistance")]
#[serde(with = "string_as_float_opt")]
#[serde(default)]
pub stop_distance: Option<f64>,
#[serde(rename = "limitDistance")]
#[serde(with = "string_as_float_opt")]
#[serde(default)]
pub limit_distance: Option<f64>,
#[serde(rename = "guaranteedStop")]
#[serde(default)]
pub guaranteed_stop: Option<bool>,
#[serde(rename = "orderType")]
#[serde(default)]
pub order_type: Option<OrderType>,
#[serde(rename = "timeInForce")]
#[serde(default)]
pub time_in_force: Option<TimeInForce>,
#[serde(rename = "goodTillDate")]
#[serde(with = "option_string_empty_as_none")]
#[serde(default)]
pub good_till_date: Option<String>,
}
impl TradeData {
pub fn from_fields(
item_name: Option<&str>,
item_pos: usize,
is_snapshot: bool,
fields: &HashMap<String, Option<String>>,
changed_fields: &HashMap<String, Option<String>>,
) -> Result<Self, String> {
let fields = Self::create_trade_fields(fields)?;
let changed_fields = Self::create_trade_fields(changed_fields)?;
Ok(TradeData {
item_name: item_name.unwrap_or_default().to_string(),
item_pos,
fields,
changed_fields,
is_snapshot,
})
}
fn create_trade_fields(
fields_map: &HashMap<String, Option<String>>,
) -> Result<TradeFields, String> {
let get_field = |key: &str| -> Option<String> {
let field = fields_map.get(key).cloned().flatten();
match field {
Some(ref s) if s.is_empty() => None,
_ => field,
}
};
let confirms = get_field("CONFIRMS");
let opu_str = get_field("OPU");
let opu = if let Some(opu_json) = opu_str {
if !opu_json.is_empty() {
match serde_json::from_str::<OpenPositionUpdate>(&opu_json) {
Ok(parsed_opu) => Some(parsed_opu),
Err(e) => return Err(format!("Failed to parse OPU JSON: {e}")),
}
} else {
None
}
} else {
None
};
let wou_str = get_field("WOU");
let wou = if let Some(wou_json) = wou_str {
if !wou_json.is_empty() {
match serde_json::from_str::<WorkingOrderUpdate>(&wou_json) {
Ok(parsed_wou) => Some(parsed_wou),
Err(e) => return Err(format!("Failed to parse WOU JSON: {e}")),
}
} else {
None
}
} else {
None
};
Ok(TradeFields { confirms, opu, wou })
}
}
#[cfg(test)]
mod tests {
use super::*;
const SANITIZED_OPU_JSON: &str = r#"{
"dealReference": "REF_SYNTHETIC_0001",
"dealId": "DIAAAAF00SYNTH01",
"dealIdOrigin": "DIAAAAF00SYNTH01",
"direction": "BUY",
"epic": "IX.D.DAX.DAILY.IP",
"status": "OPEN",
"dealStatus": "ACCEPTED",
"level": "18000.5",
"size": "1.0",
"currency": "EUR",
"timestamp": "2024-01-15T10:30:00.000",
"channel": "PublicRestOTC",
"expiry": "DFB"
}"#;
#[test]
fn test_create_trade_fields_parses_opu_json() {
let mut map: HashMap<String, Option<String>> = HashMap::new();
map.insert("OPU".to_string(), Some(SANITIZED_OPU_JSON.to_string()));
let result = TradeData::create_trade_fields(&map);
assert!(
result.is_ok(),
"create_trade_fields should parse OPU: {result:?}"
);
let fields = result.expect("OPU parse checked ok above");
let opu = fields.opu.expect("OPU should be populated");
assert_eq!(opu.deal_id.as_deref(), Some("DIAAAAF00SYNTH01"));
assert_eq!(opu.level, Some(18000.5));
}
#[test]
fn test_open_position_update_captures_stop_limit_and_trailing_fields() {
let json = r#"{
"dealReference": "REF_SYNTHETIC_0002",
"dealId": "DIAAAAF00SYNTH02",
"dealIdOrigin": "DIAAAAF00SYNTH02",
"direction": "SELL",
"epic": "IX.D.DAX.DAILY.IP",
"status": "OPEN",
"dealStatus": "ACCEPTED",
"level": "18010.0",
"size": "2.0",
"currency": "EUR",
"timestamp": "2024-01-15T10:31:00.000",
"channel": "PublicRestOTC",
"expiry": "DFB",
"stopLevel": "18100.0",
"limitLevel": "17900.0",
"guaranteedStop": true,
"trailingStep": "5.0",
"trailingStopDistance": "50.0"
}"#;
let opu: OpenPositionUpdate = serde_json::from_str(json).expect("deserialize failed");
assert_eq!(opu.stop_level, Some(18100.0));
assert_eq!(opu.limit_level, Some(17900.0));
assert_eq!(opu.guaranteed_stop, Some(true));
assert_eq!(opu.trailing_step, Some(5.0));
assert_eq!(opu.trailing_stop_distance, Some(50.0));
let out = serde_json::to_string(&opu).expect("serialize failed");
let reparsed: OpenPositionUpdate =
serde_json::from_str(&out).expect("re-deserialize failed");
assert_eq!(reparsed.stop_level, Some(18100.0));
assert_eq!(reparsed.limit_level, Some(17900.0));
assert_eq!(reparsed.guaranteed_stop, Some(true));
assert_eq!(reparsed.trailing_step, Some(5.0));
assert_eq!(reparsed.trailing_stop_distance, Some(50.0));
}
#[test]
fn test_open_position_update_new_fields_absent_default_to_none() {
let opu: OpenPositionUpdate =
serde_json::from_str(SANITIZED_OPU_JSON).expect("deserialize failed");
assert!(opu.stop_level.is_none());
assert!(opu.limit_level.is_none());
assert!(opu.guaranteed_stop.is_none());
assert!(opu.trailing_step.is_none());
assert!(opu.trailing_stop_distance.is_none());
}
#[test]
fn test_trade_data_default() {
let data = TradeData::default();
assert!(data.item_name.is_empty());
assert_eq!(data.item_pos, 0);
assert!(!data.is_snapshot);
}
#[test]
fn test_trade_fields_default() {
let fields = TradeFields::default();
assert!(fields.confirms.is_none());
assert!(fields.opu.is_none());
assert!(fields.wou.is_none());
}
#[test]
fn test_open_position_update_default() {
let opu = OpenPositionUpdate::default();
assert!(opu.deal_reference.is_none());
assert!(opu.deal_id.is_none());
assert!(opu.direction.is_none());
assert!(opu.epic.is_none());
assert!(opu.status.is_none());
assert!(opu.level.is_none());
assert!(opu.size.is_none());
}
#[test]
fn test_working_order_update_default() {
let wou = WorkingOrderUpdate::default();
assert!(wou.deal_reference.is_none());
assert!(wou.deal_id.is_none());
assert!(wou.direction.is_none());
assert!(wou.epic.is_none());
assert!(wou.status.is_none());
assert!(wou.level.is_none());
assert!(wou.size.is_none());
}
#[test]
fn test_open_position_update_creation() {
let opu = OpenPositionUpdate {
deal_reference: Some("REF123".to_string()),
deal_id: Some("DEAL456".to_string()),
direction: Some(Direction::Buy),
epic: Some("IX.D.DAX.DAILY.IP".to_string()),
status: Some(Status::Open),
level: Some(18000.0),
size: Some(1.0),
currency: Some("EUR".to_string()),
..Default::default()
};
assert_eq!(opu.deal_reference, Some("REF123".to_string()));
assert_eq!(opu.deal_id, Some("DEAL456".to_string()));
assert_eq!(opu.direction, Some(Direction::Buy));
assert_eq!(opu.level, Some(18000.0));
}
#[test]
fn test_working_order_update_creation() {
let wou = WorkingOrderUpdate {
deal_reference: Some("WO_REF".to_string()),
deal_id: Some("WO_DEAL".to_string()),
direction: Some(Direction::Sell),
epic: Some("CS.D.EURUSD.CFD.IP".to_string()),
status: Some(Status::Amended),
level: Some(1.1000),
size: Some(10000.0),
order_type: Some(OrderType::Limit),
time_in_force: Some(TimeInForce::GoodTillCancelled),
..Default::default()
};
assert_eq!(wou.deal_reference, Some("WO_REF".to_string()));
assert_eq!(wou.direction, Some(Direction::Sell));
assert_eq!(wou.order_type, Some(OrderType::Limit));
}
#[test]
fn test_trade_data_creation() {
let data = TradeData {
item_name: "TRADE:123".to_string(),
item_pos: 1,
fields: TradeFields::default(),
changed_fields: TradeFields::default(),
is_snapshot: true,
};
assert_eq!(data.item_name, "TRADE:123");
assert_eq!(data.item_pos, 1);
assert!(data.is_snapshot);
}
#[test]
fn test_trade_fields_with_confirms() {
let fields = TradeFields {
confirms: Some("confirmed".to_string()),
opu: None,
wou: None,
};
assert_eq!(fields.confirms, Some("confirmed".to_string()));
}
#[test]
fn test_open_position_update_serialization() {
let opu = OpenPositionUpdate {
deal_id: Some("DEAL123".to_string()),
level: Some(100.5),
..Default::default()
};
let json = serde_json::to_string(&opu).expect("serialize failed");
assert!(json.contains("DEAL123"));
}
#[test]
fn test_working_order_update_serialization() {
let wou = WorkingOrderUpdate {
deal_id: Some("WO123".to_string()),
level: Some(50.0),
..Default::default()
};
let json = serde_json::to_string(&wou).expect("serialize failed");
assert!(json.contains("WO123"));
}
}