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 pretty_simple_display::{DebugPretty, DisplaySimple};
6use serde::{Deserialize, Serialize};
7use std::collections::HashMap;
8use std::ops::Add;
9
10#[must_use]
17#[inline]
18fn is_call_name(name: &str) -> bool {
19 name.contains("CALL")
20}
21
22#[must_use]
28#[inline]
29fn is_put_name(name: &str) -> bool {
30 name.contains("PUT")
31}
32
33#[derive(DebugPretty, DisplaySimple, Clone, Deserialize, Serialize)]
35pub struct AccountInfo {
36 pub accounts: Vec<Account>,
38}
39
40#[derive(DebugPretty, DisplaySimple, Clone, Deserialize, Serialize)]
42pub struct Account {
43 #[serde(rename = "accountId")]
45 pub account_id: String,
46 #[serde(rename = "accountName")]
48 pub account_name: String,
49 #[serde(rename = "accountType")]
51 pub account_type: String,
52 pub balance: AccountBalance,
54 pub currency: String,
56 pub status: String,
58 pub preferred: bool,
60}
61
62#[derive(DebugPretty, DisplaySimple, Clone, Deserialize, Serialize)]
64pub struct AccountBalance {
65 pub balance: f64,
67 pub deposit: f64,
69 #[serde(rename = "profitLoss")]
71 pub profit_loss: f64,
72 pub available: f64,
74}
75
76#[derive(DebugPretty, DisplaySimple, Clone, Deserialize, Serialize)]
78pub struct ActivityMetadata {
79 pub paging: Option<ActivityPaging>,
81}
82
83#[derive(DebugPretty, DisplaySimple, Clone, Deserialize, Serialize)]
85pub struct ActivityPaging {
86 pub size: Option<i64>,
88 pub next: Option<String>,
90}
91
92#[repr(u8)]
94#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, DisplaySimple, Deserialize, Serialize)]
95#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
96pub enum ActivityType {
97 EditStopAndLimit,
99 Position,
101 System,
103 WorkingOrder,
105}
106
107#[derive(Debug, Clone, DisplaySimple, Deserialize, Serialize)]
109pub struct Activity {
110 pub date: String,
112 #[serde(rename = "dealId", default)]
114 pub deal_id: Option<String>,
115 #[serde(default)]
117 pub epic: Option<String>,
118 #[serde(default)]
120 pub period: Option<String>,
121 #[serde(rename = "dealReference", default)]
123 pub deal_reference: Option<String>,
124 #[serde(rename = "type")]
126 pub activity_type: ActivityType,
127 #[serde(default)]
129 pub status: Option<Status>,
130 #[serde(default)]
132 pub description: Option<String>,
133 #[serde(default)]
136 pub details: Option<ActivityDetails>,
137 #[serde(default)]
139 pub channel: Option<String>,
140 #[serde(default)]
142 pub currency: Option<String>,
143 #[serde(default)]
145 pub level: Option<String>,
146}
147
148#[derive(Debug, Clone, DisplaySimple, Deserialize, Serialize)]
151pub struct ActivityDetails {
152 #[serde(rename = "dealReference", default)]
154 pub deal_reference: Option<String>,
155 #[serde(default)]
157 pub actions: Vec<ActivityAction>,
158 #[serde(rename = "marketName", default)]
160 pub market_name: Option<String>,
161 #[serde(rename = "goodTillDate", default)]
163 pub good_till_date: Option<String>,
164 #[serde(default)]
166 pub currency: Option<String>,
167 #[serde(default)]
169 pub size: Option<f64>,
170 #[serde(default)]
172 pub direction: Option<Direction>,
173 #[serde(default)]
175 pub level: Option<f64>,
176 #[serde(rename = "stopLevel", default)]
178 pub stop_level: Option<f64>,
179 #[serde(rename = "stopDistance", default)]
181 pub stop_distance: Option<f64>,
182 #[serde(rename = "guaranteedStop", default)]
184 pub guaranteed_stop: Option<bool>,
185 #[serde(rename = "trailingStopDistance", default)]
187 pub trailing_stop_distance: Option<f64>,
188 #[serde(rename = "trailingStep", default)]
190 pub trailing_step: Option<f64>,
191 #[serde(rename = "limitLevel", default)]
193 pub limit_level: Option<f64>,
194 #[serde(rename = "limitDistance", default)]
196 pub limit_distance: Option<f64>,
197}
198
199#[repr(u8)]
201#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash, DisplaySimple, Deserialize, Serialize)]
202#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
203pub enum ActionType {
204 LimitOrderDeleted,
206 LimitOrderFilled,
208 LimitOrderOpened,
210 LimitOrderRolled,
212 PositionClosed,
214 PositionDeleted,
216 PositionOpened,
218 PositionPartiallyClosed,
220 PositionRolled,
222 StopLimitAmended,
224 StopOrderAmended,
226 StopOrderDeleted,
228 StopOrderFilled,
230 StopOrderOpened,
232 StopOrderRolled,
234 Unknown,
236 WorkingOrderDeleted,
238}
239
240#[derive(DebugPretty, DisplaySimple, Clone, Deserialize, Serialize)]
242#[serde(rename_all = "camelCase")]
243pub struct ActivityAction {
244 pub action_type: ActionType,
246 pub affected_deal_id: Option<String>,
248}
249
250#[derive(DebugPretty, Clone, DisplaySimple, Serialize, Deserialize)]
252pub struct Position {
253 pub position: PositionDetails,
255 pub market: PositionMarket,
257 pub pnl: Option<f64>,
259}
260
261impl Position {
262 #[must_use]
284 pub fn pnl(&self) -> f64 {
285 self.pnl
286 .unwrap_or_else(|| self.pnl_checked().unwrap_or(0.0))
287 }
288
289 #[must_use]
303 pub fn pnl_checked(&self) -> Option<f64> {
304 let current_price = match self.position.direction {
305 Direction::Buy => self.market.bid?,
306 Direction::Sell => self.market.offer?,
307 };
308 let price_diff = match self.position.direction {
309 Direction::Buy => current_price - self.position.level,
310 Direction::Sell => self.position.level - current_price,
311 };
312 Some(price_diff * self.position.size)
313 }
314
315 pub fn update_pnl(&mut self) {
328 self.pnl = Some(self.pnl_checked().unwrap_or(0.0));
331 }
332}
333
334impl Position {
335 #[must_use]
348 #[inline]
349 pub fn is_call(&self) -> bool {
350 is_call_name(&self.market.instrument_name)
351 }
352
353 #[must_use]
365 #[inline]
366 pub fn is_put(&self) -> bool {
367 is_put_name(&self.market.instrument_name)
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, direction) = if self.direction != other.direction {
449 let direction = if other.size > self.size {
453 other.direction
454 } else {
455 self.direction
456 };
457 (
458 (self.contract_size - other.contract_size).abs(),
459 (self.size - other.size).abs(),
460 direction,
461 )
462 } else {
463 (
464 self.contract_size + other.contract_size,
465 self.size + other.size,
466 self.direction,
467 )
468 };
469
470 PositionDetails {
471 contract_size,
472 created_date: self.created_date,
473 created_date_utc: self.created_date_utc,
474 deal_id: self.deal_id,
475 deal_reference: self.deal_reference,
476 direction,
477 limit_level: other.limit_level.or(self.limit_level),
478 level: (self.level + other.level) / 2.0, size,
480 stop_level: other.stop_level.or(self.stop_level),
481 trailing_step: other.trailing_step.or(self.trailing_step),
482 trailing_stop_distance: other.trailing_stop_distance.or(self.trailing_stop_distance),
483 currency: self.currency.clone(),
484 controlled_risk: self.controlled_risk || other.controlled_risk,
485 limited_risk_premium: other.limited_risk_premium.or(self.limited_risk_premium),
486 }
487 }
488}
489
490#[derive(DebugPretty, DisplaySimple, Clone, Deserialize, Serialize)]
492pub struct PositionMarket {
493 #[serde(rename = "instrumentName")]
495 pub instrument_name: String,
496 pub expiry: String,
498 pub epic: String,
500 #[serde(rename = "instrumentType")]
502 pub instrument_type: InstrumentType,
503 #[serde(rename = "lotSize")]
505 pub lot_size: f64,
506 pub high: Option<f64>,
508 pub low: Option<f64>,
510 #[serde(rename = "percentageChange")]
512 pub percentage_change: f64,
513 #[serde(rename = "netChange")]
515 pub net_change: f64,
516 pub bid: Option<f64>,
518 pub offer: Option<f64>,
520 #[serde(rename = "updateTime")]
522 pub update_time: String,
523 #[serde(rename = "updateTimeUTC")]
525 pub update_time_utc: String,
526 #[serde(rename = "delayTime")]
528 pub delay_time: i64,
529 #[serde(rename = "streamingPricesAvailable")]
531 pub streaming_prices_available: bool,
532 #[serde(rename = "marketStatus")]
534 pub market_status: MarketState,
535 #[serde(rename = "scalingFactor")]
537 pub scaling_factor: i64,
538}
539
540impl PositionMarket {
541 #[must_use]
554 #[inline]
555 pub fn is_call(&self) -> bool {
556 is_call_name(&self.instrument_name)
557 }
558
559 #[must_use]
571 #[inline]
572 pub fn is_put(&self) -> bool {
573 is_put_name(&self.instrument_name)
574 }
575}
576
577#[derive(DebugPretty, Clone, DisplaySimple, Deserialize, Serialize)]
579pub struct WorkingOrder {
580 #[serde(rename = "workingOrderData")]
582 pub working_order_data: WorkingOrderData,
583 #[serde(rename = "marketData")]
585 pub market_data: AccountMarketData,
586}
587
588#[derive(DebugPretty, Clone, DisplaySimple, Deserialize, Serialize)]
590pub struct WorkingOrderData {
591 #[serde(rename = "dealId")]
593 pub deal_id: String,
594 pub direction: Direction,
596 pub epic: String,
598 #[serde(rename = "orderSize")]
600 pub order_size: f64,
601 #[serde(rename = "orderLevel")]
603 pub order_level: f64,
604 #[serde(rename = "timeInForce")]
606 pub time_in_force: TimeInForce,
607 #[serde(rename = "goodTillDate")]
609 pub good_till_date: Option<String>,
610 #[serde(rename = "goodTillDateISO")]
612 pub good_till_date_iso: Option<String>,
613 #[serde(rename = "createdDate")]
615 pub created_date: String,
616 #[serde(rename = "createdDateUTC")]
618 pub created_date_utc: String,
619 #[serde(rename = "guaranteedStop")]
621 pub guaranteed_stop: bool,
622 #[serde(rename = "orderType")]
624 pub order_type: OrderType,
625 #[serde(rename = "stopDistance")]
627 pub stop_distance: Option<f64>,
628 #[serde(rename = "limitDistance")]
630 pub limit_distance: Option<f64>,
631 #[serde(rename = "currencyCode")]
633 pub currency_code: String,
634 pub dma: bool,
636 #[serde(rename = "limitedRiskPremium")]
638 pub limited_risk_premium: Option<f64>,
639 #[serde(rename = "limitLevel", default)]
641 pub limit_level: Option<f64>,
642 #[serde(rename = "stopLevel", default)]
644 pub stop_level: Option<f64>,
645 #[serde(rename = "dealReference", default)]
647 pub deal_reference: Option<String>,
648}
649
650#[derive(DebugPretty, Clone, DisplaySimple, Deserialize, Serialize)]
652pub struct AccountMarketData {
653 #[serde(rename = "instrumentName")]
655 pub instrument_name: String,
656 #[serde(rename = "exchangeId")]
658 pub exchange_id: String,
659 pub expiry: String,
661 #[serde(rename = "marketStatus")]
663 pub market_status: MarketState,
664 pub epic: String,
666 #[serde(rename = "instrumentType")]
668 pub instrument_type: InstrumentType,
669 #[serde(rename = "lotSize")]
671 pub lot_size: f64,
672 pub high: Option<f64>,
674 pub low: Option<f64>,
676 #[serde(rename = "percentageChange")]
678 pub percentage_change: f64,
679 #[serde(rename = "netChange")]
681 pub net_change: f64,
682 pub bid: Option<f64>,
684 pub offer: Option<f64>,
686 #[serde(rename = "updateTime")]
688 pub update_time: String,
689 #[serde(rename = "updateTimeUTC")]
691 pub update_time_utc: String,
692 #[serde(rename = "delayTime")]
694 pub delay_time: i64,
695 #[serde(rename = "streamingPricesAvailable")]
697 pub streaming_prices_available: bool,
698 #[serde(rename = "scalingFactor")]
700 pub scaling_factor: i64,
701}
702
703impl AccountMarketData {
704 #[must_use]
717 #[inline]
718 pub fn is_call(&self) -> bool {
719 is_call_name(&self.instrument_name)
720 }
721
722 #[must_use]
734 #[inline]
735 pub fn is_put(&self) -> bool {
736 is_put_name(&self.instrument_name)
737 }
738}
739
740#[derive(DebugPretty, Clone, DisplaySimple, Deserialize, Serialize)]
742pub struct TransactionMetadata {
743 #[serde(rename = "pageData")]
745 pub page_data: PageData,
746 pub size: i64,
748}
749
750#[derive(DebugPretty, Clone, DisplaySimple, Deserialize, Serialize)]
752pub struct PageData {
753 #[serde(rename = "pageNumber")]
755 pub page_number: i64,
756 #[serde(rename = "pageSize")]
758 pub page_size: i64,
759 #[serde(rename = "totalPages")]
761 pub total_pages: i64,
762}
763
764#[derive(DebugPretty, DisplaySimple, Clone, Deserialize, Serialize)]
766pub struct AccountTransaction {
767 pub date: String,
769 #[serde(rename = "dateUtc")]
771 pub date_utc: String,
772 #[serde(rename = "openDateUtc")]
774 pub open_date_utc: String,
775 #[serde(rename = "instrumentName")]
777 pub instrument_name: String,
778 pub period: String,
780 #[serde(rename = "profitAndLoss")]
782 pub profit_and_loss: String,
783 #[serde(rename = "transactionType")]
785 pub transaction_type: String,
786 pub reference: String,
788 #[serde(rename = "openLevel")]
790 pub open_level: String,
791 #[serde(rename = "closeLevel")]
793 pub close_level: String,
794 pub size: String,
796 pub currency: String,
798 #[serde(rename = "cashTransaction")]
800 pub cash_transaction: bool,
801}
802
803impl AccountTransaction {
804 #[must_use]
817 #[inline]
818 pub fn is_call(&self) -> bool {
819 is_call_name(&self.instrument_name)
820 }
821
822 #[must_use]
834 #[inline]
835 pub fn is_put(&self) -> bool {
836 is_put_name(&self.instrument_name)
837 }
838}
839
840#[derive(DebugPretty, DisplaySimple, Clone, Serialize, Deserialize, Default)]
842pub struct AccountData {
843 pub item_name: String,
845 pub item_pos: usize,
847 pub fields: AccountFields,
849 pub changed_fields: AccountFields,
851 pub is_snapshot: bool,
853}
854
855#[derive(DebugPretty, DisplaySimple, Clone, Serialize, Deserialize, Default)]
857pub struct AccountFields {
858 #[serde(rename = "PNL")]
859 #[serde(with = "string_as_float_opt")]
860 #[serde(skip_serializing_if = "Option::is_none")]
861 pnl: Option<f64>,
862
863 #[serde(rename = "DEPOSIT")]
864 #[serde(with = "string_as_float_opt")]
865 #[serde(skip_serializing_if = "Option::is_none")]
866 deposit: Option<f64>,
867
868 #[serde(rename = "AVAILABLE_CASH")]
869 #[serde(with = "string_as_float_opt")]
870 #[serde(skip_serializing_if = "Option::is_none")]
871 available_cash: Option<f64>,
872
873 #[serde(rename = "PNL_LR")]
874 #[serde(with = "string_as_float_opt")]
875 #[serde(skip_serializing_if = "Option::is_none")]
876 pnl_lr: Option<f64>,
877
878 #[serde(rename = "PNL_NLR")]
879 #[serde(with = "string_as_float_opt")]
880 #[serde(skip_serializing_if = "Option::is_none")]
881 pnl_nlr: Option<f64>,
882
883 #[serde(rename = "FUNDS")]
884 #[serde(with = "string_as_float_opt")]
885 #[serde(skip_serializing_if = "Option::is_none")]
886 funds: Option<f64>,
887
888 #[serde(rename = "MARGIN")]
889 #[serde(with = "string_as_float_opt")]
890 #[serde(skip_serializing_if = "Option::is_none")]
891 margin: Option<f64>,
892
893 #[serde(rename = "MARGIN_LR")]
894 #[serde(with = "string_as_float_opt")]
895 #[serde(skip_serializing_if = "Option::is_none")]
896 margin_lr: Option<f64>,
897
898 #[serde(rename = "MARGIN_NLR")]
899 #[serde(with = "string_as_float_opt")]
900 #[serde(skip_serializing_if = "Option::is_none")]
901 margin_nlr: Option<f64>,
902
903 #[serde(rename = "AVAILABLE_TO_DEAL")]
904 #[serde(with = "string_as_float_opt")]
905 #[serde(skip_serializing_if = "Option::is_none")]
906 available_to_deal: Option<f64>,
907
908 #[serde(rename = "EQUITY")]
909 #[serde(with = "string_as_float_opt")]
910 #[serde(skip_serializing_if = "Option::is_none")]
911 equity: Option<f64>,
912
913 #[serde(rename = "EQUITY_USED")]
914 #[serde(with = "string_as_float_opt")]
915 #[serde(skip_serializing_if = "Option::is_none")]
916 equity_used: Option<f64>,
917}
918
919impl AccountData {
920 pub fn from_fields(
937 item_name: Option<&str>,
938 item_pos: usize,
939 is_snapshot: bool,
940 fields: &HashMap<String, Option<String>>,
941 changed_fields: &HashMap<String, Option<String>>,
942 ) -> Result<Self, String> {
943 let fields = Self::create_account_fields(fields)?;
944 let changed_fields = Self::create_account_fields(changed_fields)?;
945
946 Ok(AccountData {
947 item_name: item_name.unwrap_or_default().to_string(),
948 item_pos,
949 fields,
950 changed_fields,
951 is_snapshot,
952 })
953 }
954
955 fn create_account_fields(
963 fields_map: &HashMap<String, Option<String>>,
964 ) -> Result<AccountFields, String> {
965 let get_field = |key: &str| -> Option<String> { fields_map.get(key).cloned().flatten() };
967
968 let parse_float = |key: &str| -> Result<Option<f64>, String> {
970 match get_field(key) {
971 Some(val) if !val.is_empty() => val
972 .parse::<f64>()
973 .map(Some)
974 .map_err(|_| format!("Failed to parse {key} as float: {val}")),
975 _ => Ok(None),
976 }
977 };
978
979 Ok(AccountFields {
980 pnl: parse_float("PNL")?,
981 deposit: parse_float("DEPOSIT")?,
982 available_cash: parse_float("AVAILABLE_CASH")?,
983 pnl_lr: parse_float("PNL_LR")?,
984 pnl_nlr: parse_float("PNL_NLR")?,
985 funds: parse_float("FUNDS")?,
986 margin: parse_float("MARGIN")?,
987 margin_lr: parse_float("MARGIN_LR")?,
988 margin_nlr: parse_float("MARGIN_NLR")?,
989 available_to_deal: parse_float("AVAILABLE_TO_DEAL")?,
990 equity: parse_float("EQUITY")?,
991 equity_used: parse_float("EQUITY_USED")?,
992 })
993 }
994}
995
996#[cfg(test)]
997mod tests {
998 use super::*;
999 use crate::presentation::order::Direction;
1000 use crate::utils::finance::calculate_pnl;
1001
1002 fn sample_position_details(direction: Direction, level: f64, size: f64) -> PositionDetails {
1003 PositionDetails {
1004 contract_size: 1.0,
1005 created_date: "2025/10/30 18:13:53:000".to_string(),
1006 created_date_utc: "2025-10-30T17:13:53".to_string(),
1007 deal_id: "DIAAAAVJNQPWZAG".to_string(),
1008 deal_reference: "RZ0RQ1K8V1S1JN2".to_string(),
1009 direction,
1010 limit_level: None,
1011 level,
1012 size,
1013 stop_level: None,
1014 trailing_step: None,
1015 trailing_stop_distance: None,
1016 currency: "USD".to_string(),
1017 controlled_risk: false,
1018 limited_risk_premium: None,
1019 }
1020 }
1021
1022 fn sample_market(bid: Option<f64>, offer: Option<f64>) -> PositionMarket {
1023 PositionMarket {
1024 instrument_name: "US 500 6910 PUT ($1)".to_string(),
1025 expiry: "DEC-25".to_string(),
1026 epic: "OP.D.OTCSPX3.6910P.IP".to_string(),
1027 instrument_type: InstrumentType::Unknown,
1028 lot_size: 1.0,
1029 high: Some(153.43),
1030 low: Some(147.42),
1031 percentage_change: 0.61,
1032 net_change: 6895.38,
1033 bid,
1034 offer,
1035 update_time: "05:55:59".to_string(),
1036 update_time_utc: "05:55:59".to_string(),
1037 delay_time: 0,
1038 streaming_prices_available: true,
1039 market_status: MarketState::Tradeable,
1040 scaling_factor: 1,
1041 }
1042 }
1043
1044 #[test]
1045 fn pnl_sell_uses_offer_and_matches_sample_data() {
1046 let details = sample_position_details(Direction::Sell, 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 - 2.32).abs() < 1e-9, "expected 2.32, got {}", pnl);
1059 }
1060
1061 #[test]
1062 fn pnl_buy_uses_bid_and_computes_difference() {
1063 let details = sample_position_details(Direction::Buy, 155.14, 1.0);
1066 let market = sample_market(Some(151.32), Some(152.82));
1067 let position = Position {
1068 position: details,
1069 market,
1070 pnl: None,
1071 };
1072
1073 let pnl = position.pnl();
1074 assert!((pnl + 3.82).abs() < 1e-9, "expected -3.82, got {}", pnl);
1075 }
1076
1077 #[test]
1078 fn pnl_field_overrides_calculation_when_present() {
1079 let details = sample_position_details(Direction::Sell, 155.14, 1.0);
1080 let market = sample_market(Some(151.32), Some(152.82));
1081 let position = Position {
1083 position: details,
1084 market,
1085 pnl: Some(10.0),
1086 };
1087 assert_eq!(position.pnl(), 10.0);
1088 }
1089
1090 #[test]
1091 fn pnl_sell_is_zero_when_offer_missing() {
1092 let details = sample_position_details(Direction::Sell, 155.14, 1.0);
1094 let market = sample_market(Some(151.32), None);
1095 let position = Position {
1096 position: details,
1097 market,
1098 pnl: None,
1099 };
1100 assert!((position.pnl() - 0.0).abs() < 1e-12);
1101 }
1102
1103 #[test]
1104 fn pnl_buy_is_zero_when_bid_missing() {
1105 let details = sample_position_details(Direction::Buy, 155.14, 1.0);
1107 let market = sample_market(None, Some(152.82));
1108 let position = Position {
1109 position: details,
1110 market,
1111 pnl: None,
1112 };
1113 assert!((position.pnl() - 0.0).abs() < 1e-12);
1114 }
1115
1116 #[test]
1117 fn pnl_missing_price_with_size_two_is_zero_not_inflated() {
1118 for direction in [Direction::Buy, Direction::Sell] {
1122 let details = sample_position_details(direction, 155.14, 2.0);
1123 let market = sample_market(None, None);
1124 let position = Position {
1125 position: details,
1126 market,
1127 pnl: None,
1128 };
1129 assert!(position.pnl_checked().is_none());
1130 assert!(
1131 position.pnl().abs() < 1e-12,
1132 "size-2 missing-price pnl must be 0, got {}",
1133 position.pnl()
1134 );
1135 }
1136 }
1137
1138 #[test]
1139 fn pnl_size_two_with_price_scales_with_size() {
1140 let details = sample_position_details(Direction::Buy, 100.0, 2.0);
1142 let market = sample_market(Some(105.0), None);
1143 let position = Position {
1144 position: details,
1145 market,
1146 pnl: None,
1147 };
1148 assert!((position.pnl() - 10.0).abs() < 1e-12);
1149 assert!((calculate_pnl(&position).expect("has bid") - 10.0).abs() < 1e-12);
1150 }
1151
1152 #[test]
1153 fn netting_opposite_directions_takes_larger_side() {
1154 let buy = sample_position_details(Direction::Buy, 100.0, 2.0);
1156 let sell = sample_position_details(Direction::Sell, 102.0, 3.0);
1157 let net = buy.clone() + sell.clone();
1158 assert_eq!(net.direction, Direction::Sell);
1159 assert!((net.size - 1.0).abs() < 1e-12);
1160
1161 let net2 = sell + buy;
1163 assert_eq!(net2.direction, Direction::Sell);
1164 assert!((net2.size - 1.0).abs() < 1e-12);
1165 }
1166
1167 #[test]
1168 fn test_transaction_metadata_deserialize_and_roundtrip() {
1169 let json = r#"{
1172 "pageData": { "pageNumber": 1, "pageSize": 20, "totalPages": 3 },
1173 "size": 42
1174 }"#;
1175
1176 let meta: TransactionMetadata = serde_json::from_str(json).expect("deserialize failed");
1177 assert_eq!(meta.size, 42);
1178 assert_eq!(meta.page_data.page_number, 1);
1179 assert_eq!(meta.page_data.page_size, 20);
1180 assert_eq!(meta.page_data.total_pages, 3);
1181
1182 let serialized = serde_json::to_string(&meta).expect("serialize failed");
1183 let re: TransactionMetadata =
1184 serde_json::from_str(&serialized).expect("re-deserialize failed");
1185 assert_eq!(re.size, 42);
1186 assert_eq!(re.page_data.total_pages, 3);
1187 assert!(serialized.contains("\"pageNumber\":1"));
1188 assert!(serialized.contains("\"pageSize\":20"));
1189 assert!(serialized.contains("\"totalPages\":3"));
1190 }
1191
1192 #[test]
1193 fn test_activity_paging_deserialize_and_roundtrip() {
1194 let json = r#"{ "size": 10, "next": "/history/activity?from=X&to=Y" }"#;
1196
1197 let paging: ActivityPaging = serde_json::from_str(json).expect("deserialize failed");
1198 assert_eq!(paging.size, Some(10));
1199 assert_eq!(
1200 paging.next.as_deref(),
1201 Some("/history/activity?from=X&to=Y")
1202 );
1203
1204 let serialized = serde_json::to_string(&paging).expect("serialize failed");
1205 let re: ActivityPaging = serde_json::from_str(&serialized).expect("re-deserialize failed");
1206 assert_eq!(re.size, Some(10));
1207 assert_eq!(re.next.as_deref(), Some("/history/activity?from=X&to=Y"));
1208 }
1209}