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
115impl Position {
116 pub fn new(
118 side: PositionSide,
119 entry_timestamp: i64,
120 entry_price: f64,
121 quantity: f64,
122 entry_commission: f64,
123 entry_signal: Signal,
124 ) -> Self {
125 Self::new_with_tax(
126 side,
127 entry_timestamp,
128 entry_price,
129 quantity,
130 entry_commission,
131 0.0,
132 entry_signal,
133 )
134 }
135
136 pub(crate) fn new_with_tax(
138 side: PositionSide,
139 entry_timestamp: i64,
140 entry_price: f64,
141 quantity: f64,
142 entry_commission: f64,
143 entry_transaction_tax: f64,
144 entry_signal: Signal,
145 ) -> Self {
146 let bracket_stop_loss_pct = entry_signal.bracket_stop_loss_pct;
147 let bracket_take_profit_pct = entry_signal.bracket_take_profit_pct;
148 let bracket_trailing_stop_pct = entry_signal.bracket_trailing_stop_pct;
149 Self {
150 side,
151 entry_timestamp,
152 entry_price,
153 quantity,
154 entry_quantity: quantity,
155 entry_commission,
156 entry_transaction_tax,
157 entry_signal,
158 dividend_income: 0.0,
159 unreinvested_dividends: 0.0,
160 scale_in_count: 0,
161 partial_close_count: 0,
162 bracket_stop_loss_pct,
163 bracket_take_profit_pct,
164 bracket_trailing_stop_pct,
165 }
166 }
167
168 pub fn current_value(&self, current_price: f64) -> f64 {
181 match self.side {
182 PositionSide::Long => self.quantity * current_price,
183 PositionSide::Short => -(self.quantity * current_price),
184 }
185 }
186
187 pub fn unrealized_pnl(&self, current_price: f64) -> f64 {
189 let initial_value = self.entry_price * self.entry_quantity;
190 let current_value = self.current_value(current_price);
191
192 let gross_pnl = match self.side {
193 PositionSide::Long => current_value - initial_value,
194 PositionSide::Short => {
200 (self.entry_price * self.entry_quantity) - (current_price * self.quantity)
201 }
202 };
203 gross_pnl - self.entry_commission - self.entry_transaction_tax + self.unreinvested_dividends
204 }
205
206 pub fn unrealized_return_pct(&self, current_price: f64) -> f64 {
208 let entry_value = self.entry_price * self.entry_quantity;
209 if entry_value == 0.0 {
210 return 0.0;
211 }
212 let pnl = self.unrealized_pnl(current_price);
213 (pnl / entry_value) * 100.0
214 }
215
216 pub fn is_profitable(&self, current_price: f64) -> bool {
218 self.unrealized_pnl(current_price) > 0.0
219 }
220
221 pub fn is_long(&self) -> bool {
223 matches!(self.side, PositionSide::Long)
224 }
225
226 pub fn is_short(&self) -> bool {
228 matches!(self.side, PositionSide::Short)
229 }
230
231 pub fn credit_dividend(&mut self, income: f64, close_price: f64, reinvest: bool) {
246 if reinvest && income > 0.0 && close_price > 0.0 {
247 self.quantity += income / close_price;
248 } else {
249 self.unreinvested_dividends += income;
250 }
251 self.dividend_income += income;
252 }
253
254 pub fn scale_in(
268 &mut self,
269 fill_price: f64,
270 additional_qty: f64,
271 commission: f64,
272 entry_tax: f64,
273 ) {
274 if additional_qty <= 0.0 {
275 return;
276 }
277
278 let old_value = self.entry_price * self.quantity;
279 let new_value = fill_price * additional_qty;
280 let total_qty = self.quantity + additional_qty;
281
282 self.entry_price = (old_value + new_value) / total_qty;
283 self.quantity = total_qty;
284 self.entry_quantity = total_qty;
286 self.entry_commission += commission;
289 self.entry_transaction_tax += entry_tax;
290 self.scale_in_count += 1;
291 }
292
293 #[must_use = "the returned Trade must be used to update cash and record the partial close"]
317 pub fn partial_close(
318 &mut self,
319 fraction: f64,
320 exit_ts: i64,
321 exit_price: f64,
322 commission: f64,
323 exit_tax: f64,
324 signal: Signal,
325 ) -> Trade {
326 let fraction = fraction.clamp(0.0, 1.0);
327 let qty_closed = self.quantity * fraction;
328 let qty_remaining = self.quantity - qty_closed;
329
330 let div_income = self.dividend_income * fraction;
332 let unreinvested = self.unreinvested_dividends * fraction;
333 let entry_comm_slice = self.entry_commission * fraction;
334 let entry_tax_slice = self.entry_transaction_tax * fraction;
335
336 self.quantity = qty_remaining;
339 self.entry_quantity = qty_remaining;
340 self.dividend_income -= div_income;
341 self.unreinvested_dividends -= unreinvested;
342 self.entry_commission -= entry_comm_slice;
343 self.entry_transaction_tax -= entry_tax_slice;
344
345 let gross_pnl = match self.side {
346 PositionSide::Long => (exit_price - self.entry_price) * qty_closed,
347 PositionSide::Short => (self.entry_price - exit_price) * qty_closed,
348 };
349 let partial_commission = entry_comm_slice + commission;
350 let partial_tax = entry_tax_slice + exit_tax;
351
352 let pnl = gross_pnl - partial_commission - partial_tax + unreinvested;
353 let entry_value = self.entry_price * qty_closed;
354 let return_pct = if entry_value > 0.0 {
355 (pnl / entry_value) * 100.0
356 } else {
357 0.0
358 };
359
360 let seq = self.partial_close_count;
361 self.partial_close_count += 1;
362
363 Trade {
364 side: self.side,
365 entry_timestamp: self.entry_timestamp,
366 exit_timestamp: exit_ts,
367 entry_price: self.entry_price,
368 exit_price,
369 quantity: qty_closed,
370 entry_quantity: qty_closed,
371 commission: partial_commission,
372 transaction_tax: partial_tax,
373 pnl,
374 return_pct,
375 dividend_income: div_income,
376 unreinvested_dividends: unreinvested,
377 entry_signal: self.entry_signal.clone(),
378 exit_signal: signal,
379 tags: self.entry_signal.tags.clone(),
380 is_partial: true,
381 scale_sequence: seq,
382 }
383 }
384
385 pub fn close(
390 self,
391 exit_timestamp: i64,
392 exit_price: f64,
393 exit_commission: f64,
394 exit_signal: Signal,
395 ) -> Trade {
396 self.close_with_tax(
397 exit_timestamp,
398 exit_price,
399 exit_commission,
400 0.0,
401 exit_signal,
402 )
403 }
404
405 pub(crate) fn close_with_tax(
407 self,
408 exit_timestamp: i64,
409 exit_price: f64,
410 exit_commission: f64,
411 exit_transaction_tax: f64,
412 exit_signal: Signal,
413 ) -> Trade {
414 let total_commission = self.entry_commission + exit_commission;
415 let total_transaction_tax = self.entry_transaction_tax + exit_transaction_tax;
416
417 let initial_value = self.entry_price * self.entry_quantity;
418 let exit_value = exit_price * self.quantity;
419
420 let gross_pnl = match self.side {
421 PositionSide::Long => exit_value - initial_value,
422 PositionSide::Short => initial_value - exit_value,
423 };
424 let pnl =
425 gross_pnl - total_commission - total_transaction_tax + self.unreinvested_dividends;
426
427 let entry_value = self.entry_price * self.entry_quantity;
428 let return_pct = if entry_value > 0.0 {
429 (pnl / entry_value) * 100.0
430 } else {
431 0.0
432 };
433
434 Trade {
435 side: self.side,
436 entry_timestamp: self.entry_timestamp,
437 exit_timestamp,
438 entry_price: self.entry_price,
439 exit_price,
440 quantity: self.quantity,
441 entry_quantity: self.entry_quantity,
442 commission: total_commission,
443 transaction_tax: total_transaction_tax,
444 pnl,
445 return_pct,
446 dividend_income: self.dividend_income,
447 unreinvested_dividends: self.unreinvested_dividends,
448 tags: self.entry_signal.tags.clone(),
449 entry_signal: self.entry_signal,
450 exit_signal,
451 is_partial: false,
452 scale_sequence: 0,
453 }
454 }
455}
456
457#[non_exhaustive]
459#[derive(Debug, Clone, Serialize, Deserialize)]
460pub struct Trade {
461 pub side: PositionSide,
463
464 pub entry_timestamp: i64,
466
467 pub exit_timestamp: i64,
469
470 pub entry_price: f64,
472
473 pub exit_price: f64,
475
476 pub quantity: f64,
478
479 #[serde(default)]
481 pub entry_quantity: f64,
482
483 pub commission: f64,
485
486 #[serde(default)]
493 pub transaction_tax: f64,
494
495 pub pnl: f64,
497
498 pub return_pct: f64,
500
501 pub dividend_income: f64,
503
504 #[serde(default)]
507 pub unreinvested_dividends: f64,
508
509 pub entry_signal: Signal,
511
512 pub exit_signal: Signal,
514
515 #[serde(default)]
523 pub tags: Vec<String>,
524
525 #[serde(default)]
531 pub is_partial: bool,
532
533 #[serde(default)]
538 pub scale_sequence: usize,
539}
540
541impl Trade {
542 pub fn is_profitable(&self) -> bool {
544 self.pnl > 0.0
545 }
546
547 pub fn is_loss(&self) -> bool {
549 self.pnl < 0.0
550 }
551
552 pub fn is_long(&self) -> bool {
554 matches!(self.side, PositionSide::Long)
555 }
556
557 pub fn is_short(&self) -> bool {
559 matches!(self.side, PositionSide::Short)
560 }
561
562 pub fn duration_secs(&self) -> i64 {
564 self.exit_timestamp - self.entry_timestamp
565 }
566
567 pub fn entry_value(&self) -> f64 {
569 self.entry_price * self.entry_quantity
570 }
571
572 pub fn exit_value(&self) -> f64 {
574 self.exit_price * self.quantity
575 }
576}
577
578#[cfg(test)]
579mod tests {
580 use super::*;
581
582 fn make_entry_signal() -> Signal {
583 Signal::long(1000, 100.0)
584 }
585
586 fn make_exit_signal() -> Signal {
587 Signal::exit(2000, 110.0)
588 }
589
590 #[test]
591 fn test_position_long_profit() {
592 let pos = Position::new(
593 PositionSide::Long,
594 1000,
595 100.0,
596 10.0,
597 1.0, make_entry_signal(),
599 );
600
601 let pnl = pos.unrealized_pnl(110.0);
603 assert!((pnl - 99.0).abs() < 0.01);
605 assert!(pos.is_profitable(110.0));
606 }
607
608 #[test]
609 fn test_position_long_loss() {
610 let pos = Position::new(
611 PositionSide::Long,
612 1000,
613 100.0,
614 10.0,
615 1.0,
616 make_entry_signal(),
617 );
618
619 let pnl = pos.unrealized_pnl(90.0);
621 assert!((pnl - (-101.0)).abs() < 0.01);
623 assert!(!pos.is_profitable(90.0));
624 }
625
626 #[test]
627 fn test_position_short_profit() {
628 let pos = Position::new(
629 PositionSide::Short,
630 1000,
631 100.0,
632 10.0,
633 1.0,
634 Signal::short(1000, 100.0),
635 );
636
637 let pnl = pos.unrealized_pnl(90.0);
639 assert!((pnl - 99.0).abs() < 0.01);
641 assert!(pos.is_profitable(90.0));
642 }
643
644 #[test]
645 fn test_position_close_to_trade() {
646 let pos = Position::new(
647 PositionSide::Long,
648 1000,
649 100.0,
650 10.0,
651 1.0,
652 make_entry_signal(),
653 );
654
655 let trade = pos.close(2000, 110.0, 1.0, make_exit_signal());
656
657 assert_eq!(trade.entry_price, 100.0);
658 assert_eq!(trade.exit_price, 110.0);
659 assert_eq!(trade.quantity, 10.0);
660 assert_eq!(trade.commission, 2.0); assert!((trade.pnl - 98.0).abs() < 0.01);
663 assert!(trade.is_profitable());
664 assert!(trade.is_long());
665 assert_eq!(trade.duration_secs(), 1000);
666 }
667
668 #[test]
669 fn test_credit_dividend_no_reinvest() {
670 let mut pos = Position::new(
671 PositionSide::Long,
672 1000,
673 100.0,
674 10.0,
675 0.0,
676 make_entry_signal(),
677 );
678 pos.credit_dividend(5.0, 110.0, false);
679 assert!((pos.dividend_income - 5.0).abs() < 1e-10);
680 assert!((pos.quantity - 10.0).abs() < 1e-10); }
682
683 #[test]
684 fn test_credit_dividend_reinvest() {
685 let mut pos = Position::new(
686 PositionSide::Long,
687 1000,
688 100.0,
689 10.0,
690 0.0,
691 make_entry_signal(),
692 );
693 pos.credit_dividend(10.0, 110.0, true);
695 assert!((pos.dividend_income - 10.0).abs() < 1e-10);
696 let expected_qty = 10.0 + 10.0 / 110.0;
697 assert!((pos.quantity - expected_qty).abs() < 1e-10);
698 }
699
700 #[test]
701 fn test_credit_dividend_zero_price_no_reinvest() {
702 let mut pos = Position::new(
703 PositionSide::Long,
704 1000,
705 100.0,
706 10.0,
707 0.0,
708 make_entry_signal(),
709 );
710 pos.credit_dividend(5.0, 0.0, true);
712 assert!((pos.dividend_income - 5.0).abs() < 1e-10);
713 assert!((pos.quantity - 10.0).abs() < 1e-10); }
715
716 #[test]
717 fn test_credit_dividend_short_is_negative_and_not_reinvested() {
718 let mut pos = Position::new(
719 PositionSide::Short,
720 1000,
721 100.0,
722 10.0,
723 0.0,
724 make_entry_signal(),
725 );
726
727 pos.credit_dividend(-5.0, 110.0, true);
729
730 assert!((pos.dividend_income + 5.0).abs() < 1e-10);
731 assert!((pos.quantity - 10.0).abs() < 1e-10);
732 }
733
734 #[test]
735 fn test_trade_return_pct() {
736 let pos = Position::new(
737 PositionSide::Long,
738 1000,
739 100.0,
740 10.0,
741 0.0,
742 make_entry_signal(),
743 );
744
745 let trade = pos.close(2000, 110.0, 0.0, make_exit_signal());
746
747 assert!((trade.return_pct - 10.0).abs() < 0.01);
749 }
750
751 #[test]
754 fn test_scale_in_updates_weighted_avg_price() {
755 let mut pos = Position::new(
757 PositionSide::Long,
758 1000,
759 100.0,
760 10.0,
761 0.0,
762 make_entry_signal(),
763 );
764
765 pos.scale_in(120.0, 10.0, 0.0, 0.0);
767
768 assert!((pos.entry_price - 110.0).abs() < 1e-10);
770 assert!((pos.quantity - 20.0).abs() < 1e-10);
771 assert!((pos.entry_quantity - 20.0).abs() < 1e-10);
773 assert_eq!(pos.scale_in_count, 1);
774 }
775
776 #[test]
777 fn test_scale_in_commission_accumulated() {
778 let mut pos = Position::new(
779 PositionSide::Long,
780 1000,
781 100.0,
782 10.0,
783 2.0, make_entry_signal(),
785 );
786
787 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); }
793
794 #[test]
795 fn test_scale_in_multiple_tranches() {
796 let mut pos = Position::new(
797 PositionSide::Long,
798 1000,
799 100.0,
800 10.0,
801 0.0,
802 make_entry_signal(),
803 );
804
805 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);
809 assert!((pos.quantity - 30.0).abs() < 1e-10);
810 assert_eq!(pos.scale_in_count, 2);
811 }
812
813 #[test]
816 fn test_partial_close_reduces_quantity() {
817 let mut pos = Position::new(
818 PositionSide::Long,
819 1000,
820 100.0,
821 10.0,
822 0.0,
823 make_entry_signal(),
824 );
825
826 let trade = pos.partial_close(0.5, 2000, 110.0, 0.0, 0.0, make_exit_signal());
827
828 assert!((pos.quantity - 5.0).abs() < 1e-10);
830 assert!((pos.entry_quantity - 5.0).abs() < 1e-10);
832 assert!((trade.quantity - 5.0).abs() < 1e-10);
833 assert!(trade.is_partial);
834 assert_eq!(trade.scale_sequence, 0);
835 }
836
837 #[test]
838 fn test_partial_close_pnl_is_proportional() {
839 let mut pos = Position::new(
840 PositionSide::Long,
841 1000,
842 100.0,
843 10.0,
844 0.0,
845 make_entry_signal(),
846 );
847
848 let trade = pos.partial_close(0.5, 2000, 120.0, 0.0, 0.0, make_exit_signal());
851
852 assert!((trade.pnl - 100.0).abs() < 1e-10);
853 assert!((trade.return_pct - 20.0).abs() < 0.01);
854 }
855
856 #[test]
857 fn test_partial_close_sequence_increments() {
858 let mut pos = Position::new(
859 PositionSide::Long,
860 1000,
861 100.0,
862 20.0,
863 0.0,
864 make_entry_signal(),
865 );
866
867 let t1 = pos.partial_close(0.25, 1000, 110.0, 0.0, 0.0, make_exit_signal());
868 let t2 = pos.partial_close(0.25, 2000, 115.0, 0.0, 0.0, make_exit_signal());
869
870 assert_eq!(t1.scale_sequence, 0);
871 assert_eq!(t2.scale_sequence, 1);
872 assert!(t1.is_partial);
873 assert!(t2.is_partial);
874 assert!((pos.quantity - 11.25).abs() < 1e-10);
876 }
877
878 #[test]
879 fn test_partial_close_full_fraction_closes_position() {
880 let mut pos = Position::new(
881 PositionSide::Long,
882 1000,
883 100.0,
884 10.0,
885 0.0,
886 make_entry_signal(),
887 );
888
889 let trade = pos.partial_close(1.0, 2000, 110.0, 0.0, 0.0, make_exit_signal());
891
892 assert!((pos.quantity - 0.0).abs() < 1e-10);
893 assert!((trade.quantity - 10.0).abs() < 1e-10);
894 assert!(trade.is_partial);
895 }
896
897 #[test]
898 fn test_close_after_scale_in_uses_correct_cost_basis() {
899 let mut pos = Position::new(
907 PositionSide::Long,
908 1000,
909 100.0,
910 10.0,
911 0.0,
912 make_entry_signal(),
913 );
914
915 pos.scale_in(120.0, 10.0, 0.0, 0.0);
916 assert!((pos.entry_price - 110.0).abs() < 1e-10);
917
918 let trade = pos.close(2000, 115.0, 0.0, make_exit_signal());
919
920 assert!(
922 (trade.pnl - 100.0).abs() < 1e-6,
923 "expected pnl=100.0, got {:.6} (entry_quantity not synced after scale_in?)",
924 trade.pnl
925 );
926 assert!((trade.quantity - 20.0).abs() < 1e-10);
927 assert!(!trade.is_partial);
928 }
929
930 #[test]
931 fn test_close_after_partial_close_uses_remaining_cost_basis() {
932 let mut pos = Position::new(
939 PositionSide::Long,
940 1000,
941 100.0,
942 20.0,
943 0.0,
944 make_entry_signal(),
945 );
946
947 let _partial = pos.partial_close(0.5, 1500, 110.0, 0.0, 0.0, make_exit_signal());
948 assert!((pos.entry_quantity - 10.0).abs() < 1e-10);
949
950 let trade = pos.close(2000, 120.0, 0.0, make_exit_signal());
951
952 assert!(
953 (trade.pnl - 200.0).abs() < 1e-6,
954 "expected pnl=200.0, got {:.6} (entry_quantity not synced after partial_close?)",
955 trade.pnl
956 );
957 assert!(!trade.is_partial);
958 }
959
960 #[test]
961 fn test_scale_in_then_partial_close_full_exit() {
962 let mut pos = Position::new(
964 PositionSide::Long,
965 1000,
966 100.0,
967 10.0,
968 0.0,
969 make_entry_signal(),
970 );
971
972 pos.scale_in(120.0, 10.0, 0.0, 0.0);
973 let partial_trade = pos.partial_close(0.5, 2000, 130.0, 0.0, 0.0, make_exit_signal());
977 assert!((partial_trade.pnl - 200.0).abs() < 1e-10);
979 assert!((pos.quantity - 10.0).abs() < 1e-10);
980
981 let final_trade = pos.close(3000, 140.0, 0.0, make_exit_signal());
983 assert!((final_trade.pnl - 300.0).abs() < 1e-10);
985 assert!(!final_trade.is_partial);
986 }
987}