1use serde::{Deserialize, Serialize};
4
5use super::signal::Signal;
6
7#[non_exhaustive]
9#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
10pub enum PositionSide {
11 Long,
13 Short,
15}
16
17impl std::fmt::Display for PositionSide {
18 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
19 match self {
20 Self::Long => write!(f, "LONG"),
21 Self::Short => write!(f, "SHORT"),
22 }
23 }
24}
25
26#[non_exhaustive]
28#[derive(Debug, Clone, Serialize, Deserialize)]
29pub struct Position {
30 pub side: PositionSide,
32
33 pub entry_timestamp: i64,
35
36 pub entry_price: f64,
38
39 pub quantity: f64,
41
42 #[serde(default)]
44 pub entry_quantity: f64,
45
46 pub entry_commission: f64,
48
49 #[serde(default)]
51 pub entry_transaction_tax: f64,
52
53 pub entry_signal: Signal,
55
56 pub dividend_income: f64,
61
62 #[serde(default)]
65 pub unreinvested_dividends: f64,
66
67 #[serde(default)]
72 pub scale_in_count: usize,
73
74 #[serde(default)]
79 pub partial_close_count: usize,
80
81 #[serde(default)]
90 pub bracket_stop_loss_pct: Option<f64>,
91
92 #[serde(default)]
101 pub bracket_take_profit_pct: Option<f64>,
102
103 #[serde(default)]
112 pub bracket_trailing_stop_pct: Option<f64>,
113
114 #[serde(default)]
120 pub financing_cost_accrued: f64,
121}
122
123impl Position {
124 pub fn new(
126 side: PositionSide,
127 entry_timestamp: i64,
128 entry_price: f64,
129 quantity: f64,
130 entry_commission: f64,
131 entry_signal: Signal,
132 ) -> Self {
133 Self::new_with_tax(
134 side,
135 entry_timestamp,
136 entry_price,
137 quantity,
138 entry_commission,
139 0.0,
140 entry_signal,
141 )
142 }
143
144 pub(crate) fn new_with_tax(
146 side: PositionSide,
147 entry_timestamp: i64,
148 entry_price: f64,
149 quantity: f64,
150 entry_commission: f64,
151 entry_transaction_tax: f64,
152 entry_signal: Signal,
153 ) -> Self {
154 let bracket_stop_loss_pct = entry_signal.bracket_stop_loss_pct;
155 let bracket_take_profit_pct = entry_signal.bracket_take_profit_pct;
156 let bracket_trailing_stop_pct = entry_signal.bracket_trailing_stop_pct;
157 Self {
158 side,
159 entry_timestamp,
160 entry_price,
161 quantity,
162 entry_quantity: quantity,
163 entry_commission,
164 entry_transaction_tax,
165 entry_signal,
166 dividend_income: 0.0,
167 unreinvested_dividends: 0.0,
168 scale_in_count: 0,
169 partial_close_count: 0,
170 bracket_stop_loss_pct,
171 bracket_take_profit_pct,
172 bracket_trailing_stop_pct,
173 financing_cost_accrued: 0.0,
174 }
175 }
176
177 pub fn current_value(&self, current_price: f64) -> f64 {
190 match self.side {
191 PositionSide::Long => self.quantity * current_price,
192 PositionSide::Short => -(self.quantity * current_price),
193 }
194 }
195
196 pub(crate) fn accrue_financing_cost(&mut self, fee: f64) {
198 self.financing_cost_accrued += fee;
199 }
200
201 pub fn unrealized_pnl(&self, current_price: f64) -> f64 {
203 let initial_value = self.entry_price * self.entry_quantity;
204 let current_value = self.current_value(current_price);
205
206 let gross_pnl = match self.side {
207 PositionSide::Long => current_value - initial_value,
208 PositionSide::Short => {
214 (self.entry_price * self.entry_quantity) - (current_price * self.quantity)
215 }
216 };
217 gross_pnl - self.entry_commission - self.entry_transaction_tax + self.unreinvested_dividends
218 - self.financing_cost_accrued
219 }
220
221 pub fn unrealized_return_pct(&self, current_price: f64) -> f64 {
223 let entry_value = self.entry_price * self.entry_quantity;
224 if entry_value == 0.0 {
225 return 0.0;
226 }
227 let pnl = self.unrealized_pnl(current_price);
228 (pnl / entry_value) * 100.0
229 }
230
231 pub fn is_profitable(&self, current_price: f64) -> bool {
233 self.unrealized_pnl(current_price) > 0.0
234 }
235
236 pub fn is_long(&self) -> bool {
238 matches!(self.side, PositionSide::Long)
239 }
240
241 pub fn is_short(&self) -> bool {
243 matches!(self.side, PositionSide::Short)
244 }
245
246 pub fn credit_dividend(&mut self, income: f64, close_price: f64, reinvest: bool) {
261 if reinvest && income > 0.0 && close_price > 0.0 {
262 self.quantity += income / close_price;
263 } else {
264 self.unreinvested_dividends += income;
265 }
266 self.dividend_income += income;
267 }
268
269 pub fn scale_in(
283 &mut self,
284 fill_price: f64,
285 additional_qty: f64,
286 commission: f64,
287 entry_tax: f64,
288 ) {
289 if additional_qty <= 0.0 {
290 return;
291 }
292
293 let old_value = self.entry_price * self.entry_quantity;
296 let new_value = fill_price * additional_qty;
297
298 self.entry_quantity += additional_qty;
299 self.entry_price = (old_value + new_value) / self.entry_quantity;
300 self.quantity += additional_qty;
301 self.entry_commission += commission;
304 self.entry_transaction_tax += entry_tax;
305 self.scale_in_count += 1;
306 }
307
308 #[must_use = "the returned Trade must be used to update cash and record the partial close"]
333 pub fn partial_close(
334 &mut self,
335 fraction: f64,
336 exit_ts: i64,
337 exit_price: f64,
338 commission: f64,
339 exit_tax: f64,
340 signal: Signal,
341 ) -> Trade {
342 let fraction = fraction.clamp(0.0, 1.0);
343 let qty_closed = self.quantity * fraction;
344 let qty_remaining = self.quantity - qty_closed;
345 let entry_qty_closed = self.entry_quantity * fraction;
346
347 let div_income = self.dividend_income * fraction;
349 let unreinvested = self.unreinvested_dividends * fraction;
350 let entry_comm_slice = self.entry_commission * fraction;
351 let entry_tax_slice = self.entry_transaction_tax * fraction;
352 let financing_slice = self.financing_cost_accrued * fraction;
353
354 self.quantity = qty_remaining;
357 self.entry_quantity -= entry_qty_closed;
358 self.dividend_income -= div_income;
359 self.unreinvested_dividends -= unreinvested;
360 self.entry_commission -= entry_comm_slice;
361 self.entry_transaction_tax -= entry_tax_slice;
362 self.financing_cost_accrued -= financing_slice;
363
364 let gross_pnl = match self.side {
365 PositionSide::Long => exit_price * qty_closed - self.entry_price * entry_qty_closed,
366 PositionSide::Short => self.entry_price * entry_qty_closed - exit_price * qty_closed,
367 };
368 let partial_commission = entry_comm_slice + commission;
369 let partial_tax = entry_tax_slice + exit_tax;
370
371 let pnl = gross_pnl - partial_commission - partial_tax + unreinvested - financing_slice;
372 let entry_value = self.entry_price * entry_qty_closed;
373 let return_pct = if entry_value > 0.0 {
374 (pnl / entry_value) * 100.0
375 } else {
376 0.0
377 };
378
379 let seq = self.partial_close_count;
380 self.partial_close_count += 1;
381
382 Trade {
383 side: self.side,
384 entry_timestamp: self.entry_timestamp,
385 exit_timestamp: exit_ts,
386 entry_price: self.entry_price,
387 exit_price,
388 quantity: qty_closed,
389 entry_quantity: entry_qty_closed,
390 commission: partial_commission,
391 transaction_tax: partial_tax,
392 pnl,
393 return_pct,
394 dividend_income: div_income,
395 unreinvested_dividends: unreinvested,
396 financing_cost: financing_slice,
397 entry_signal: self.entry_signal.clone(),
398 exit_signal: signal,
399 tags: self.entry_signal.tags.clone(),
400 is_partial: true,
401 scale_sequence: seq,
402 }
403 }
404
405 pub fn close(
410 self,
411 exit_timestamp: i64,
412 exit_price: f64,
413 exit_commission: f64,
414 exit_signal: Signal,
415 ) -> Trade {
416 self.close_with_tax(
417 exit_timestamp,
418 exit_price,
419 exit_commission,
420 0.0,
421 exit_signal,
422 )
423 }
424
425 pub(crate) fn close_with_tax(
427 self,
428 exit_timestamp: i64,
429 exit_price: f64,
430 exit_commission: f64,
431 exit_transaction_tax: f64,
432 exit_signal: Signal,
433 ) -> Trade {
434 let total_commission = self.entry_commission + exit_commission;
435 let total_transaction_tax = self.entry_transaction_tax + exit_transaction_tax;
436
437 let initial_value = self.entry_price * self.entry_quantity;
438 let exit_value = exit_price * self.quantity;
439
440 let gross_pnl = match self.side {
441 PositionSide::Long => exit_value - initial_value,
442 PositionSide::Short => initial_value - exit_value,
443 };
444 let pnl = gross_pnl - total_commission - total_transaction_tax
445 + self.unreinvested_dividends
446 - self.financing_cost_accrued;
447
448 let entry_value = self.entry_price * self.entry_quantity;
449 let return_pct = if entry_value > 0.0 {
450 (pnl / entry_value) * 100.0
451 } else {
452 0.0
453 };
454
455 Trade {
456 side: self.side,
457 entry_timestamp: self.entry_timestamp,
458 exit_timestamp,
459 entry_price: self.entry_price,
460 exit_price,
461 quantity: self.quantity,
462 entry_quantity: self.entry_quantity,
463 commission: total_commission,
464 transaction_tax: total_transaction_tax,
465 pnl,
466 return_pct,
467 dividend_income: self.dividend_income,
468 unreinvested_dividends: self.unreinvested_dividends,
469 financing_cost: self.financing_cost_accrued,
470 tags: self.entry_signal.tags.clone(),
471 entry_signal: self.entry_signal,
472 exit_signal,
473 is_partial: false,
474 scale_sequence: 0,
475 }
476 }
477}
478
479#[non_exhaustive]
481#[derive(Debug, Clone, Serialize, Deserialize)]
482pub struct Trade {
483 pub side: PositionSide,
485
486 pub entry_timestamp: i64,
488
489 pub exit_timestamp: i64,
491
492 pub entry_price: f64,
494
495 pub exit_price: f64,
497
498 pub quantity: f64,
500
501 #[serde(default)]
503 pub entry_quantity: f64,
504
505 pub commission: f64,
507
508 #[serde(default)]
515 pub transaction_tax: f64,
516
517 pub pnl: f64,
519
520 pub return_pct: f64,
522
523 pub dividend_income: f64,
525
526 #[serde(default)]
529 pub unreinvested_dividends: f64,
530
531 #[serde(default)]
534 pub financing_cost: f64,
535
536 pub entry_signal: Signal,
538
539 pub exit_signal: Signal,
541
542 #[serde(default)]
550 pub tags: Vec<String>,
551
552 #[serde(default)]
558 pub is_partial: bool,
559
560 #[serde(default)]
565 pub scale_sequence: usize,
566}
567
568impl Trade {
569 pub fn is_profitable(&self) -> bool {
571 self.pnl > 0.0
572 }
573
574 pub fn is_loss(&self) -> bool {
576 self.pnl < 0.0
577 }
578
579 pub fn is_long(&self) -> bool {
581 matches!(self.side, PositionSide::Long)
582 }
583
584 pub fn is_short(&self) -> bool {
586 matches!(self.side, PositionSide::Short)
587 }
588
589 pub fn duration_secs(&self) -> i64 {
591 self.exit_timestamp - self.entry_timestamp
592 }
593
594 pub fn entry_value(&self) -> f64 {
596 self.entry_price * self.entry_quantity
597 }
598
599 pub fn exit_value(&self) -> f64 {
601 self.exit_price * self.quantity
602 }
603}
604
605#[cfg(test)]
606mod tests {
607 use super::*;
608
609 fn make_entry_signal() -> Signal {
610 Signal::long(1000, 100.0)
611 }
612
613 fn make_exit_signal() -> Signal {
614 Signal::exit(2000, 110.0)
615 }
616
617 #[test]
618 fn test_position_long_profit() {
619 let pos = Position::new(
620 PositionSide::Long,
621 1000,
622 100.0,
623 10.0,
624 1.0, make_entry_signal(),
626 );
627
628 let pnl = pos.unrealized_pnl(110.0);
630 assert!((pnl - 99.0).abs() < 0.01);
632 assert!(pos.is_profitable(110.0));
633 }
634
635 #[test]
636 fn test_position_long_loss() {
637 let pos = Position::new(
638 PositionSide::Long,
639 1000,
640 100.0,
641 10.0,
642 1.0,
643 make_entry_signal(),
644 );
645
646 let pnl = pos.unrealized_pnl(90.0);
648 assert!((pnl - (-101.0)).abs() < 0.01);
650 assert!(!pos.is_profitable(90.0));
651 }
652
653 #[test]
654 fn test_position_short_profit() {
655 let pos = Position::new(
656 PositionSide::Short,
657 1000,
658 100.0,
659 10.0,
660 1.0,
661 Signal::short(1000, 100.0),
662 );
663
664 let pnl = pos.unrealized_pnl(90.0);
666 assert!((pnl - 99.0).abs() < 0.01);
668 assert!(pos.is_profitable(90.0));
669 }
670
671 #[test]
672 fn test_position_close_to_trade() {
673 let pos = Position::new(
674 PositionSide::Long,
675 1000,
676 100.0,
677 10.0,
678 1.0,
679 make_entry_signal(),
680 );
681
682 let trade = pos.close(2000, 110.0, 1.0, make_exit_signal());
683
684 assert_eq!(trade.entry_price, 100.0);
685 assert_eq!(trade.exit_price, 110.0);
686 assert_eq!(trade.quantity, 10.0);
687 assert_eq!(trade.commission, 2.0); assert!((trade.pnl - 98.0).abs() < 0.01);
690 assert!(trade.is_profitable());
691 assert!(trade.is_long());
692 assert_eq!(trade.duration_secs(), 1000);
693 }
694
695 #[test]
696 fn test_credit_dividend_no_reinvest() {
697 let mut pos = Position::new(
698 PositionSide::Long,
699 1000,
700 100.0,
701 10.0,
702 0.0,
703 make_entry_signal(),
704 );
705 pos.credit_dividend(5.0, 110.0, false);
706 assert!((pos.dividend_income - 5.0).abs() < 1e-10);
707 assert!((pos.quantity - 10.0).abs() < 1e-10); }
709
710 #[test]
711 fn test_credit_dividend_reinvest() {
712 let mut pos = Position::new(
713 PositionSide::Long,
714 1000,
715 100.0,
716 10.0,
717 0.0,
718 make_entry_signal(),
719 );
720 pos.credit_dividend(10.0, 110.0, true);
722 assert!((pos.dividend_income - 10.0).abs() < 1e-10);
723 let expected_qty = 10.0 + 10.0 / 110.0;
724 assert!((pos.quantity - expected_qty).abs() < 1e-10);
725 }
726
727 #[test]
728 fn test_credit_dividend_zero_price_no_reinvest() {
729 let mut pos = Position::new(
730 PositionSide::Long,
731 1000,
732 100.0,
733 10.0,
734 0.0,
735 make_entry_signal(),
736 );
737 pos.credit_dividend(5.0, 0.0, true);
739 assert!((pos.dividend_income - 5.0).abs() < 1e-10);
740 assert!((pos.quantity - 10.0).abs() < 1e-10); }
742
743 #[test]
744 fn test_credit_dividend_short_is_negative_and_not_reinvested() {
745 let mut pos = Position::new(
746 PositionSide::Short,
747 1000,
748 100.0,
749 10.0,
750 0.0,
751 make_entry_signal(),
752 );
753
754 pos.credit_dividend(-5.0, 110.0, true);
756
757 assert!((pos.dividend_income + 5.0).abs() < 1e-10);
758 assert!((pos.quantity - 10.0).abs() < 1e-10);
759 }
760
761 #[test]
762 fn test_trade_return_pct() {
763 let pos = Position::new(
764 PositionSide::Long,
765 1000,
766 100.0,
767 10.0,
768 0.0,
769 make_entry_signal(),
770 );
771
772 let trade = pos.close(2000, 110.0, 0.0, make_exit_signal());
773
774 assert!((trade.return_pct - 10.0).abs() < 0.01);
776 }
777
778 #[test]
781 fn test_scale_in_updates_weighted_avg_price() {
782 let mut pos = Position::new(
784 PositionSide::Long,
785 1000,
786 100.0,
787 10.0,
788 0.0,
789 make_entry_signal(),
790 );
791
792 pos.scale_in(120.0, 10.0, 0.0, 0.0);
794
795 assert!((pos.entry_price - 110.0).abs() < 1e-10);
797 assert!((pos.quantity - 20.0).abs() < 1e-10);
798 assert!((pos.entry_quantity - 20.0).abs() < 1e-10);
800 assert_eq!(pos.scale_in_count, 1);
801 }
802
803 #[test]
804 fn test_scale_in_commission_accumulated() {
805 let mut pos = Position::new(
806 PositionSide::Long,
807 1000,
808 100.0,
809 10.0,
810 2.0, make_entry_signal(),
812 );
813
814 pos.scale_in(110.0, 5.0, 1.5, 0.25); assert!((pos.entry_commission - 3.5).abs() < 1e-10); assert!((pos.entry_transaction_tax - 0.25).abs() < 1e-10); }
820
821 #[test]
822 fn test_scale_in_multiple_tranches() {
823 let mut pos = Position::new(
824 PositionSide::Long,
825 1000,
826 100.0,
827 10.0,
828 0.0,
829 make_entry_signal(),
830 );
831
832 pos.scale_in(110.0, 10.0, 0.0, 0.0); pos.scale_in(120.0, 10.0, 0.0, 0.0); assert!((pos.entry_price - 110.0).abs() < 1e-10);
836 assert!((pos.quantity - 30.0).abs() < 1e-10);
837 assert_eq!(pos.scale_in_count, 2);
838 }
839
840 #[test]
843 fn test_partial_close_reduces_quantity() {
844 let mut pos = Position::new(
845 PositionSide::Long,
846 1000,
847 100.0,
848 10.0,
849 0.0,
850 make_entry_signal(),
851 );
852
853 let trade = pos.partial_close(0.5, 2000, 110.0, 0.0, 0.0, make_exit_signal());
854
855 assert!((pos.quantity - 5.0).abs() < 1e-10);
857 assert!((pos.entry_quantity - 5.0).abs() < 1e-10);
859 assert!((trade.quantity - 5.0).abs() < 1e-10);
860 assert!(trade.is_partial);
861 assert_eq!(trade.scale_sequence, 0);
862 }
863
864 #[test]
865 fn test_partial_close_pnl_is_proportional() {
866 let mut pos = Position::new(
867 PositionSide::Long,
868 1000,
869 100.0,
870 10.0,
871 0.0,
872 make_entry_signal(),
873 );
874
875 let trade = pos.partial_close(0.5, 2000, 120.0, 0.0, 0.0, make_exit_signal());
878
879 assert!((trade.pnl - 100.0).abs() < 1e-10);
880 assert!((trade.return_pct - 20.0).abs() < 0.01);
881 }
882
883 #[test]
884 fn test_partial_close_sequence_increments() {
885 let mut pos = Position::new(
886 PositionSide::Long,
887 1000,
888 100.0,
889 20.0,
890 0.0,
891 make_entry_signal(),
892 );
893
894 let t1 = pos.partial_close(0.25, 1000, 110.0, 0.0, 0.0, make_exit_signal());
895 let t2 = pos.partial_close(0.25, 2000, 115.0, 0.0, 0.0, make_exit_signal());
896
897 assert_eq!(t1.scale_sequence, 0);
898 assert_eq!(t2.scale_sequence, 1);
899 assert!(t1.is_partial);
900 assert!(t2.is_partial);
901 assert!((pos.quantity - 11.25).abs() < 1e-10);
903 }
904
905 #[test]
906 fn test_partial_close_full_fraction_closes_position() {
907 let mut pos = Position::new(
908 PositionSide::Long,
909 1000,
910 100.0,
911 10.0,
912 0.0,
913 make_entry_signal(),
914 );
915
916 let trade = pos.partial_close(1.0, 2000, 110.0, 0.0, 0.0, make_exit_signal());
918
919 assert!((pos.quantity - 0.0).abs() < 1e-10);
920 assert!((trade.quantity - 10.0).abs() < 1e-10);
921 assert!(trade.is_partial);
922 }
923
924 #[test]
925 fn test_close_after_scale_in_uses_correct_cost_basis() {
926 let mut pos = Position::new(
934 PositionSide::Long,
935 1000,
936 100.0,
937 10.0,
938 0.0,
939 make_entry_signal(),
940 );
941
942 pos.scale_in(120.0, 10.0, 0.0, 0.0);
943 assert!((pos.entry_price - 110.0).abs() < 1e-10);
944
945 let trade = pos.close(2000, 115.0, 0.0, make_exit_signal());
946
947 assert!(
949 (trade.pnl - 100.0).abs() < 1e-6,
950 "expected pnl=100.0, got {:.6} (entry_quantity not synced after scale_in?)",
951 trade.pnl
952 );
953 assert!((trade.quantity - 20.0).abs() < 1e-10);
954 assert!(!trade.is_partial);
955 }
956
957 #[test]
958 fn test_close_after_partial_close_uses_remaining_cost_basis() {
959 let mut pos = Position::new(
966 PositionSide::Long,
967 1000,
968 100.0,
969 20.0,
970 0.0,
971 make_entry_signal(),
972 );
973
974 let _partial = pos.partial_close(0.5, 1500, 110.0, 0.0, 0.0, make_exit_signal());
975 assert!((pos.entry_quantity - 10.0).abs() < 1e-10);
976
977 let trade = pos.close(2000, 120.0, 0.0, make_exit_signal());
978
979 assert!(
980 (trade.pnl - 200.0).abs() < 1e-6,
981 "expected pnl=200.0, got {:.6} (entry_quantity not synced after partial_close?)",
982 trade.pnl
983 );
984 assert!(!trade.is_partial);
985 }
986
987 #[test]
988 fn test_scale_in_then_partial_close_full_exit() {
989 let mut pos = Position::new(
991 PositionSide::Long,
992 1000,
993 100.0,
994 10.0,
995 0.0,
996 make_entry_signal(),
997 );
998
999 pos.scale_in(120.0, 10.0, 0.0, 0.0);
1000 let partial_trade = pos.partial_close(0.5, 2000, 130.0, 0.0, 0.0, make_exit_signal());
1004 assert!((partial_trade.pnl - 200.0).abs() < 1e-10);
1006 assert!((pos.quantity - 10.0).abs() < 1e-10);
1007
1008 let final_trade = pos.close(3000, 140.0, 0.0, make_exit_signal());
1010 assert!((final_trade.pnl - 300.0).abs() < 1e-10);
1012 assert!(!final_trade.is_partial);
1013 }
1014
1015 #[test]
1018 fn test_scale_in_after_reinvested_dividend_matches_cash_gain() {
1019 let mut pos = Position::new(
1020 PositionSide::Long,
1021 1000,
1022 100.0,
1023 10.0,
1024 0.0,
1025 make_entry_signal(),
1026 );
1027 let outlay = 10.0 * 100.0;
1028
1029 pos.credit_dividend(100.0, 110.0, true);
1030 pos.scale_in(110.0, 10.0, 0.0, 0.0);
1031 let outlay = outlay + 10.0 * 110.0;
1032
1033 let trade = pos.close(2000, 110.0, 0.0, make_exit_signal());
1034 let proceeds = trade.exit_value();
1035
1036 assert!((trade.pnl - (proceeds - outlay)).abs() < 1e-6);
1037 }
1038
1039 #[test]
1040 fn test_partial_close_after_reinvested_dividend_matches_cash_gain() {
1041 let mut pos = Position::new(
1042 PositionSide::Long,
1043 1000,
1044 100.0,
1045 10.0,
1046 0.0,
1047 make_entry_signal(),
1048 );
1049 let outlay = 10.0 * 100.0;
1050
1051 pos.credit_dividend(100.0, 110.0, true);
1052
1053 let partial = pos.partial_close(0.5, 1500, 110.0, 0.0, 0.0, make_exit_signal());
1054 let final_trade = pos.close(2000, 110.0, 0.0, make_exit_signal());
1055 let proceeds = partial.exit_value() + final_trade.exit_value();
1056
1057 assert!(((partial.pnl + final_trade.pnl) - (proceeds - outlay)).abs() < 1e-6);
1058 }
1059}