1use crate::presentation::instrument::InstrumentType;
2use crate::presentation::market::MarketState;
3use crate::presentation::order::{Direction, OrderType, Status, TimeInForce};
4use crate::presentation::serialization::string_as_float_opt;
5use lightstreamer_rs::subscription::ItemUpdate;
6use pretty_simple_display::{DebugPretty, DisplaySimple};
7use serde::{Deserialize, Serialize};
8use std::collections::HashMap;
9use std::ops::Add;
10
11#[derive(DebugPretty, DisplaySimple, Clone, Deserialize, Serialize)]
13pub struct AccountInfo {
14 pub accounts: Vec<Account>,
16}
17
18#[derive(DebugPretty, DisplaySimple, Clone, Deserialize, Serialize)]
20pub struct Account {
21 #[serde(rename = "accountId")]
23 pub account_id: String,
24 #[serde(rename = "accountName")]
26 pub account_name: String,
27 #[serde(rename = "accountType")]
29 pub account_type: String,
30 pub balance: AccountBalance,
32 pub currency: String,
34 pub status: String,
36 pub preferred: bool,
38}
39
40#[derive(DebugPretty, DisplaySimple, Clone, Deserialize, Serialize)]
42pub struct AccountBalance {
43 pub balance: f64,
45 pub deposit: f64,
47 #[serde(rename = "profitLoss")]
49 pub profit_loss: f64,
50 pub available: f64,
52}
53
54#[derive(DebugPretty, DisplaySimple, Clone, Deserialize, Serialize)]
56pub struct ActivityMetadata {
57 pub paging: Option<ActivityPaging>,
59}
60
61#[derive(DebugPretty, DisplaySimple, Clone, Deserialize, Serialize)]
63pub struct ActivityPaging {
64 pub size: Option<i32>,
66 pub next: Option<String>,
68}
69
70#[repr(u8)]
72#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, DisplaySimple, Deserialize, Serialize)]
73#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
74pub enum ActivityType {
75 EditStopAndLimit,
77 Position,
79 System,
81 WorkingOrder,
83}
84
85#[derive(Debug, Clone, DisplaySimple, Deserialize, Serialize)]
87pub struct Activity {
88 pub date: String,
90 #[serde(rename = "dealId", default)]
92 pub deal_id: Option<String>,
93 #[serde(default)]
95 pub epic: Option<String>,
96 #[serde(default)]
98 pub period: Option<String>,
99 #[serde(rename = "dealReference", default)]
101 pub deal_reference: Option<String>,
102 #[serde(rename = "type")]
104 pub activity_type: ActivityType,
105 #[serde(default)]
107 pub status: Option<Status>,
108 #[serde(default)]
110 pub description: Option<String>,
111 #[serde(default)]
114 pub details: Option<ActivityDetails>,
115 #[serde(default)]
117 pub channel: Option<String>,
118 #[serde(default)]
120 pub currency: Option<String>,
121 #[serde(default)]
123 pub level: Option<String>,
124}
125
126#[derive(Debug, Clone, DisplaySimple, Deserialize, Serialize)]
129pub struct ActivityDetails {
130 #[serde(rename = "dealReference", default)]
132 pub deal_reference: Option<String>,
133 #[serde(default)]
135 pub actions: Vec<ActivityAction>,
136 #[serde(rename = "marketName", default)]
138 pub market_name: Option<String>,
139 #[serde(rename = "goodTillDate", default)]
141 pub good_till_date: Option<String>,
142 #[serde(default)]
144 pub currency: Option<String>,
145 #[serde(default)]
147 pub size: Option<f64>,
148 #[serde(default)]
150 pub direction: Option<Direction>,
151 #[serde(default)]
153 pub level: Option<f64>,
154 #[serde(rename = "stopLevel", default)]
156 pub stop_level: Option<f64>,
157 #[serde(rename = "stopDistance", default)]
159 pub stop_distance: Option<f64>,
160 #[serde(rename = "guaranteedStop", default)]
162 pub guaranteed_stop: Option<bool>,
163 #[serde(rename = "trailingStopDistance", default)]
165 pub trailing_stop_distance: Option<f64>,
166 #[serde(rename = "trailingStep", default)]
168 pub trailing_step: Option<f64>,
169 #[serde(rename = "limitLevel", default)]
171 pub limit_level: Option<f64>,
172 #[serde(rename = "limitDistance", default)]
174 pub limit_distance: Option<f64>,
175}
176
177#[repr(u8)]
179#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash, DisplaySimple, Deserialize, Serialize)]
180#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
181pub enum ActionType {
182 LimitOrderDeleted,
184 LimitOrderFilled,
186 LimitOrderOpened,
188 LimitOrderRolled,
190 PositionClosed,
192 PositionDeleted,
194 PositionOpened,
196 PositionPartiallyClosed,
198 PositionRolled,
200 StopLimitAmended,
202 StopOrderAmended,
204 StopOrderDeleted,
206 StopOrderFilled,
208 StopOrderOpened,
210 StopOrderRolled,
212 Unknown,
214 WorkingOrderDeleted,
216}
217
218#[derive(DebugPretty, DisplaySimple, Clone, Deserialize, Serialize)]
220#[serde(rename_all = "camelCase")]
221pub struct ActivityAction {
222 pub action_type: ActionType,
224 pub affected_deal_id: Option<String>,
226}
227
228#[derive(DebugPretty, Clone, DisplaySimple, Serialize, Deserialize)]
230pub struct Position {
231 pub position: PositionDetails,
233 pub market: PositionMarket,
235 pub pnl: Option<f64>,
237}
238
239impl Position {
240 #[must_use]
272 pub fn pnl(&self) -> f64 {
273 if let Some(pnl) = self.pnl {
274 pnl
275 } else {
276 match self.position.direction {
277 Direction::Buy => {
278 let value = self.position.size * self.position.level;
279 let current_value = self.position.size * self.market.bid.unwrap_or(value);
280 current_value - value
281 }
282 Direction::Sell => {
283 let value = self.position.size * self.position.level;
284 let current_value = self.position.size * self.market.offer.unwrap_or(value);
285 value - current_value
286 }
287 }
288 }
289 }
290
291 pub fn update_pnl(&mut self) {
318 let pnl = match self.position.direction {
319 Direction::Buy => {
320 let value = self.position.size * self.position.level;
321 let current_value = self.position.size * self.market.bid.unwrap_or(value);
322 current_value - value
323 }
324 Direction::Sell => {
325 let value = self.position.size * self.position.level;
326 let current_value = self.position.size * self.market.offer.unwrap_or(value);
327 value - current_value
328 }
329 };
330 self.pnl = Some(pnl);
331 }
332}
333
334impl Position {
335 #[must_use]
348 #[inline]
349 pub fn is_call(&self) -> bool {
350 self.market.instrument_name.contains("CALL")
351 }
352
353 #[must_use]
365 #[inline]
366 pub fn is_put(&self) -> bool {
367 self.market.instrument_name.contains("PUT")
368 }
369}
370
371impl Add for Position {
372 type Output = Position;
373
374 fn add(self, other: Position) -> Position {
381 debug_assert_eq!(
382 self.market.epic, other.market.epic,
383 "cannot add positions from different markets"
384 );
385 Position {
386 position: self.position + other.position,
387 market: self.market,
388 pnl: match (self.pnl, other.pnl) {
389 (Some(a), Some(b)) => Some(a + b),
390 (Some(a), None) => Some(a),
391 (None, Some(b)) => Some(b),
392 (None, None) => None,
393 },
394 }
395 }
396}
397
398#[derive(DebugPretty, DisplaySimple, Clone, Deserialize, Serialize)]
400pub struct PositionDetails {
401 #[serde(rename = "contractSize")]
403 pub contract_size: f64,
404 #[serde(rename = "createdDate")]
406 pub created_date: String,
407 #[serde(rename = "createdDateUTC")]
409 pub created_date_utc: String,
410 #[serde(rename = "dealId")]
412 pub deal_id: String,
413 #[serde(rename = "dealReference")]
415 pub deal_reference: String,
416 pub direction: Direction,
418 #[serde(rename = "limitLevel")]
420 pub limit_level: Option<f64>,
421 pub level: f64,
423 pub size: f64,
425 #[serde(rename = "stopLevel")]
427 pub stop_level: Option<f64>,
428 #[serde(rename = "trailingStep")]
430 pub trailing_step: Option<f64>,
431 #[serde(rename = "trailingStopDistance")]
433 pub trailing_stop_distance: Option<f64>,
434 pub currency: String,
436 #[serde(rename = "controlledRisk")]
438 pub controlled_risk: bool,
439 #[serde(rename = "limitedRiskPremium")]
441 pub limited_risk_premium: Option<f64>,
442}
443
444impl Add for PositionDetails {
445 type Output = PositionDetails;
446
447 fn add(self, other: PositionDetails) -> PositionDetails {
448 let (contract_size, size) = if self.direction != other.direction {
449 (
450 (self.contract_size - other.contract_size).abs(),
451 (self.size - other.size).abs(),
452 )
453 } else {
454 (
455 self.contract_size + other.contract_size,
456 self.size + other.size,
457 )
458 };
459
460 PositionDetails {
461 contract_size,
462 created_date: self.created_date,
463 created_date_utc: self.created_date_utc,
464 deal_id: self.deal_id,
465 deal_reference: self.deal_reference,
466 direction: self.direction,
467 limit_level: other.limit_level.or(self.limit_level),
468 level: (self.level + other.level) / 2.0, size,
470 stop_level: other.stop_level.or(self.stop_level),
471 trailing_step: other.trailing_step.or(self.trailing_step),
472 trailing_stop_distance: other.trailing_stop_distance.or(self.trailing_stop_distance),
473 currency: self.currency.clone(),
474 controlled_risk: self.controlled_risk || other.controlled_risk,
475 limited_risk_premium: other.limited_risk_premium.or(self.limited_risk_premium),
476 }
477 }
478}
479
480#[derive(DebugPretty, DisplaySimple, Clone, Deserialize, Serialize)]
482pub struct PositionMarket {
483 #[serde(rename = "instrumentName")]
485 pub instrument_name: String,
486 pub expiry: String,
488 pub epic: String,
490 #[serde(rename = "instrumentType")]
492 pub instrument_type: String,
493 #[serde(rename = "lotSize")]
495 pub lot_size: f64,
496 pub high: Option<f64>,
498 pub low: Option<f64>,
500 #[serde(rename = "percentageChange")]
502 pub percentage_change: f64,
503 #[serde(rename = "netChange")]
505 pub net_change: f64,
506 pub bid: Option<f64>,
508 pub offer: Option<f64>,
510 #[serde(rename = "updateTime")]
512 pub update_time: String,
513 #[serde(rename = "updateTimeUTC")]
515 pub update_time_utc: String,
516 #[serde(rename = "delayTime")]
518 pub delay_time: i64,
519 #[serde(rename = "streamingPricesAvailable")]
521 pub streaming_prices_available: bool,
522 #[serde(rename = "marketStatus")]
524 pub market_status: String,
525 #[serde(rename = "scalingFactor")]
527 pub scaling_factor: i64,
528}
529
530impl PositionMarket {
531 pub fn is_call(&self) -> bool {
544 self.instrument_name.contains("CALL")
545 }
546
547 pub fn is_put(&self) -> bool {
559 self.instrument_name.contains("PUT")
560 }
561}
562
563#[derive(DebugPretty, Clone, DisplaySimple, Deserialize, Serialize)]
565pub struct WorkingOrder {
566 #[serde(rename = "workingOrderData")]
568 pub working_order_data: WorkingOrderData,
569 #[serde(rename = "marketData")]
571 pub market_data: AccountMarketData,
572}
573
574#[derive(DebugPretty, Clone, DisplaySimple, Deserialize, Serialize)]
576pub struct WorkingOrderData {
577 #[serde(rename = "dealId")]
579 pub deal_id: String,
580 pub direction: Direction,
582 pub epic: String,
584 #[serde(rename = "orderSize")]
586 pub order_size: f64,
587 #[serde(rename = "orderLevel")]
589 pub order_level: f64,
590 #[serde(rename = "timeInForce")]
592 pub time_in_force: TimeInForce,
593 #[serde(rename = "goodTillDate")]
595 pub good_till_date: Option<String>,
596 #[serde(rename = "goodTillDateISO")]
598 pub good_till_date_iso: Option<String>,
599 #[serde(rename = "createdDate")]
601 pub created_date: String,
602 #[serde(rename = "createdDateUTC")]
604 pub created_date_utc: String,
605 #[serde(rename = "guaranteedStop")]
607 pub guaranteed_stop: bool,
608 #[serde(rename = "orderType")]
610 pub order_type: OrderType,
611 #[serde(rename = "stopDistance")]
613 pub stop_distance: Option<f64>,
614 #[serde(rename = "limitDistance")]
616 pub limit_distance: Option<f64>,
617 #[serde(rename = "currencyCode")]
619 pub currency_code: String,
620 pub dma: bool,
622 #[serde(rename = "limitedRiskPremium")]
624 pub limited_risk_premium: Option<f64>,
625 #[serde(rename = "limitLevel", default)]
627 pub limit_level: Option<f64>,
628 #[serde(rename = "stopLevel", default)]
630 pub stop_level: Option<f64>,
631 #[serde(rename = "dealReference", default)]
633 pub deal_reference: Option<String>,
634}
635
636#[derive(DebugPretty, Clone, DisplaySimple, Deserialize, Serialize)]
638pub struct AccountMarketData {
639 #[serde(rename = "instrumentName")]
641 pub instrument_name: String,
642 #[serde(rename = "exchangeId")]
644 pub exchange_id: String,
645 pub expiry: String,
647 #[serde(rename = "marketStatus")]
649 pub market_status: MarketState,
650 pub epic: String,
652 #[serde(rename = "instrumentType")]
654 pub instrument_type: InstrumentType,
655 #[serde(rename = "lotSize")]
657 pub lot_size: f64,
658 pub high: Option<f64>,
660 pub low: Option<f64>,
662 #[serde(rename = "percentageChange")]
664 pub percentage_change: f64,
665 #[serde(rename = "netChange")]
667 pub net_change: f64,
668 pub bid: Option<f64>,
670 pub offer: Option<f64>,
672 #[serde(rename = "updateTime")]
674 pub update_time: String,
675 #[serde(rename = "updateTimeUTC")]
677 pub update_time_utc: String,
678 #[serde(rename = "delayTime")]
680 pub delay_time: i64,
681 #[serde(rename = "streamingPricesAvailable")]
683 pub streaming_prices_available: bool,
684 #[serde(rename = "scalingFactor")]
686 pub scaling_factor: i64,
687}
688
689impl AccountMarketData {
690 pub fn is_call(&self) -> bool {
703 self.instrument_name.contains("CALL")
704 }
705
706 pub fn is_put(&self) -> bool {
718 self.instrument_name.contains("PUT")
719 }
720}
721
722#[derive(DebugPretty, Clone, DisplaySimple, Deserialize, Serialize)]
724pub struct TransactionMetadata {
725 #[serde(rename = "pageData")]
727 pub page_data: PageData,
728 pub size: i32,
730}
731
732#[derive(DebugPretty, Clone, DisplaySimple, Deserialize, Serialize)]
734pub struct PageData {
735 #[serde(rename = "pageNumber")]
737 pub page_number: i32,
738 #[serde(rename = "pageSize")]
740 pub page_size: i32,
741 #[serde(rename = "totalPages")]
743 pub total_pages: i32,
744}
745
746#[derive(DebugPretty, DisplaySimple, Clone, Deserialize, Serialize)]
748pub struct AccountTransaction {
749 pub date: String,
751 #[serde(rename = "dateUtc")]
753 pub date_utc: String,
754 #[serde(rename = "openDateUtc")]
756 pub open_date_utc: String,
757 #[serde(rename = "instrumentName")]
759 pub instrument_name: String,
760 pub period: String,
762 #[serde(rename = "profitAndLoss")]
764 pub profit_and_loss: String,
765 #[serde(rename = "transactionType")]
767 pub transaction_type: String,
768 pub reference: String,
770 #[serde(rename = "openLevel")]
772 pub open_level: String,
773 #[serde(rename = "closeLevel")]
775 pub close_level: String,
776 pub size: String,
778 pub currency: String,
780 #[serde(rename = "cashTransaction")]
782 pub cash_transaction: bool,
783}
784
785impl AccountTransaction {
786 pub fn is_call(&self) -> bool {
799 self.instrument_name.contains("CALL")
800 }
801
802 pub fn is_put(&self) -> bool {
814 self.instrument_name.contains("PUT")
815 }
816}
817
818#[derive(DebugPretty, DisplaySimple, Clone, Serialize, Deserialize, Default)]
820pub struct AccountData {
821 pub item_name: String,
823 pub item_pos: i32,
825 pub fields: AccountFields,
827 pub changed_fields: AccountFields,
829 pub is_snapshot: bool,
831}
832
833#[derive(DebugPretty, DisplaySimple, Clone, Serialize, Deserialize, Default)]
835pub struct AccountFields {
836 #[serde(rename = "PNL")]
837 #[serde(with = "string_as_float_opt")]
838 #[serde(skip_serializing_if = "Option::is_none")]
839 pnl: Option<f64>,
840
841 #[serde(rename = "DEPOSIT")]
842 #[serde(with = "string_as_float_opt")]
843 #[serde(skip_serializing_if = "Option::is_none")]
844 deposit: Option<f64>,
845
846 #[serde(rename = "AVAILABLE_CASH")]
847 #[serde(with = "string_as_float_opt")]
848 #[serde(skip_serializing_if = "Option::is_none")]
849 available_cash: Option<f64>,
850
851 #[serde(rename = "PNL_LR")]
852 #[serde(with = "string_as_float_opt")]
853 #[serde(skip_serializing_if = "Option::is_none")]
854 pnl_lr: Option<f64>,
855
856 #[serde(rename = "PNL_NLR")]
857 #[serde(with = "string_as_float_opt")]
858 #[serde(skip_serializing_if = "Option::is_none")]
859 pnl_nlr: Option<f64>,
860
861 #[serde(rename = "FUNDS")]
862 #[serde(with = "string_as_float_opt")]
863 #[serde(skip_serializing_if = "Option::is_none")]
864 funds: Option<f64>,
865
866 #[serde(rename = "MARGIN")]
867 #[serde(with = "string_as_float_opt")]
868 #[serde(skip_serializing_if = "Option::is_none")]
869 margin: Option<f64>,
870
871 #[serde(rename = "MARGIN_LR")]
872 #[serde(with = "string_as_float_opt")]
873 #[serde(skip_serializing_if = "Option::is_none")]
874 margin_lr: Option<f64>,
875
876 #[serde(rename = "MARGIN_NLR")]
877 #[serde(with = "string_as_float_opt")]
878 #[serde(skip_serializing_if = "Option::is_none")]
879 margin_nlr: Option<f64>,
880
881 #[serde(rename = "AVAILABLE_TO_DEAL")]
882 #[serde(with = "string_as_float_opt")]
883 #[serde(skip_serializing_if = "Option::is_none")]
884 available_to_deal: Option<f64>,
885
886 #[serde(rename = "EQUITY")]
887 #[serde(with = "string_as_float_opt")]
888 #[serde(skip_serializing_if = "Option::is_none")]
889 equity: Option<f64>,
890
891 #[serde(rename = "EQUITY_USED")]
892 #[serde(with = "string_as_float_opt")]
893 #[serde(skip_serializing_if = "Option::is_none")]
894 equity_used: Option<f64>,
895}
896
897impl AccountData {
898 pub fn from_item_update(item_update: &ItemUpdate) -> Result<Self, String> {
906 let item_name = item_update.item_name.clone().unwrap_or_default();
908
909 let item_pos = item_update.item_pos as i32;
911
912 let is_snapshot = item_update.is_snapshot;
914
915 let fields = Self::create_account_fields(&item_update.fields)?;
917
918 let mut changed_fields_map: HashMap<String, Option<String>> = HashMap::new();
920 for (key, value) in &item_update.changed_fields {
921 changed_fields_map.insert(key.clone(), Some(value.clone()));
922 }
923 let changed_fields = Self::create_account_fields(&changed_fields_map)?;
924
925 Ok(AccountData {
926 item_name,
927 item_pos,
928 fields,
929 changed_fields,
930 is_snapshot,
931 })
932 }
933
934 fn create_account_fields(
942 fields_map: &HashMap<String, Option<String>>,
943 ) -> Result<AccountFields, String> {
944 let get_field = |key: &str| -> Option<String> { fields_map.get(key).cloned().flatten() };
946
947 let parse_float = |key: &str| -> Result<Option<f64>, String> {
949 match get_field(key) {
950 Some(val) if !val.is_empty() => val
951 .parse::<f64>()
952 .map(Some)
953 .map_err(|_| format!("Failed to parse {key} as float: {val}")),
954 _ => Ok(None),
955 }
956 };
957
958 Ok(AccountFields {
959 pnl: parse_float("PNL")?,
960 deposit: parse_float("DEPOSIT")?,
961 available_cash: parse_float("AVAILABLE_CASH")?,
962 pnl_lr: parse_float("PNL_LR")?,
963 pnl_nlr: parse_float("PNL_NLR")?,
964 funds: parse_float("FUNDS")?,
965 margin: parse_float("MARGIN")?,
966 margin_lr: parse_float("MARGIN_LR")?,
967 margin_nlr: parse_float("MARGIN_NLR")?,
968 available_to_deal: parse_float("AVAILABLE_TO_DEAL")?,
969 equity: parse_float("EQUITY")?,
970 equity_used: parse_float("EQUITY_USED")?,
971 })
972 }
973}
974
975impl From<&ItemUpdate> for AccountData {
976 fn from(item_update: &ItemUpdate) -> Self {
977 Self::from_item_update(item_update).unwrap_or_else(|_| AccountData::default())
978 }
979}
980
981#[cfg(test)]
982mod tests {
983 use super::*;
984 use crate::presentation::order::Direction;
985
986 fn sample_position_details(direction: Direction, level: f64, size: f64) -> PositionDetails {
987 PositionDetails {
988 contract_size: 1.0,
989 created_date: "2025/10/30 18:13:53:000".to_string(),
990 created_date_utc: "2025-10-30T17:13:53".to_string(),
991 deal_id: "DIAAAAVJNQPWZAG".to_string(),
992 deal_reference: "RZ0RQ1K8V1S1JN2".to_string(),
993 direction,
994 limit_level: None,
995 level,
996 size,
997 stop_level: None,
998 trailing_step: None,
999 trailing_stop_distance: None,
1000 currency: "USD".to_string(),
1001 controlled_risk: false,
1002 limited_risk_premium: None,
1003 }
1004 }
1005
1006 fn sample_market(bid: Option<f64>, offer: Option<f64>) -> PositionMarket {
1007 PositionMarket {
1008 instrument_name: "US 500 6910 PUT ($1)".to_string(),
1009 expiry: "DEC-25".to_string(),
1010 epic: "OP.D.OTCSPX3.6910P.IP".to_string(),
1011 instrument_type: "UNKNOWN".to_string(),
1012 lot_size: 1.0,
1013 high: Some(153.43),
1014 low: Some(147.42),
1015 percentage_change: 0.61,
1016 net_change: 6895.38,
1017 bid,
1018 offer,
1019 update_time: "05:55:59".to_string(),
1020 update_time_utc: "05:55:59".to_string(),
1021 delay_time: 0,
1022 streaming_prices_available: true,
1023 market_status: "TRADEABLE".to_string(),
1024 scaling_factor: 1,
1025 }
1026 }
1027
1028 #[test]
1029 fn pnl_sell_uses_offer_and_matches_sample_data() {
1030 let details = sample_position_details(Direction::Sell, 155.14, 1.0);
1034 let market = sample_market(Some(151.32), Some(152.82));
1035 let position = Position {
1036 position: details,
1037 market,
1038 pnl: None,
1039 };
1040
1041 let pnl = position.pnl();
1042 assert!((pnl - 2.32).abs() < 1e-9, "expected 2.32, got {}", pnl);
1043 }
1044
1045 #[test]
1046 fn pnl_buy_uses_bid_and_computes_difference() {
1047 let details = sample_position_details(Direction::Buy, 155.14, 1.0);
1050 let market = sample_market(Some(151.32), Some(152.82));
1051 let position = Position {
1052 position: details,
1053 market,
1054 pnl: None,
1055 };
1056
1057 let pnl = position.pnl();
1058 assert!((pnl + 3.82).abs() < 1e-9, "expected -3.82, got {}", pnl);
1059 }
1060
1061 #[test]
1062 fn pnl_field_overrides_calculation_when_present() {
1063 let details = sample_position_details(Direction::Sell, 155.14, 1.0);
1064 let market = sample_market(Some(151.32), Some(152.82));
1065 let position = Position {
1067 position: details,
1068 market,
1069 pnl: Some(10.0),
1070 };
1071 assert_eq!(position.pnl(), 10.0);
1072 }
1073
1074 #[test]
1075 fn pnl_sell_is_zero_when_offer_missing() {
1076 let details = sample_position_details(Direction::Sell, 155.14, 1.0);
1078 let market = sample_market(Some(151.32), None);
1079 let position = Position {
1080 position: details,
1081 market,
1082 pnl: None,
1083 };
1084 assert!((position.pnl() - 0.0).abs() < 1e-12);
1085 }
1086
1087 #[test]
1088 fn pnl_buy_is_zero_when_bid_missing() {
1089 let details = sample_position_details(Direction::Buy, 155.14, 1.0);
1091 let market = sample_market(None, Some(152.82));
1092 let position = Position {
1093 position: details,
1094 market,
1095 pnl: None,
1096 };
1097 assert!((position.pnl() - 0.0).abs() < 1e-12);
1098 }
1099}