wickra_backtest_core/
portfolio.rs1use serde::Serialize;
10
11#[derive(Debug, Clone, PartialEq, Serialize)]
13pub struct Trade {
14 pub entry_time: i64,
16 pub exit_time: i64,
18 pub entry_price: f64,
20 pub exit_price: f64,
22 pub qty: f64,
24 pub pnl: f64,
26 pub return_pct: f64,
28 pub reason: String,
30}
31
32#[derive(Debug, Clone)]
34pub struct Portfolio {
35 pub cash: f64,
37 pub qty: f64,
39 pub entry_price: f64,
41 pub entry_time: i64,
43 entry_fee: f64,
45 pub trades: Vec<Trade>,
47 pub fees_paid: f64,
49}
50
51impl Portfolio {
52 pub fn new(cash: f64) -> Self {
54 Self {
55 cash,
56 qty: 0.0,
57 entry_price: 0.0,
58 entry_time: 0,
59 entry_fee: 0.0,
60 trades: Vec::new(),
61 fees_paid: 0.0,
62 }
63 }
64
65 pub fn in_position(&self) -> bool {
67 self.qty.abs() > f64::EPSILON
68 }
69
70 pub fn is_long(&self) -> bool {
72 self.qty > 0.0
73 }
74
75 pub fn equity(&self, mark: f64) -> f64 {
77 self.cash + self.qty * mark
78 }
79
80 pub fn apply_funding(&mut self, payment: f64) {
84 self.cash -= payment;
85 self.fees_paid += payment;
86 }
87
88 pub fn enter(&mut self, qty: f64, price: f64, time: i64, fee: f64) {
90 self.cash -= qty * price + fee;
91 self.qty = qty;
92 self.entry_price = price;
93 self.entry_time = time;
94 self.entry_fee = fee;
95 self.fees_paid += fee;
96 }
97
98 pub fn exit(&mut self, price: f64, time: i64, fee: f64, reason: &str) {
100 let qty = self.qty;
101 self.cash += qty * price - fee;
102 self.fees_paid += fee;
103 let notional = qty.abs() * self.entry_price;
104 let pnl = qty * (price - self.entry_price) - self.entry_fee - fee;
105 let return_pct = if notional.abs() < f64::EPSILON {
106 0.0
107 } else {
108 pnl / notional * 100.0
109 };
110 self.trades.push(Trade {
111 entry_time: self.entry_time,
112 exit_time: time,
113 entry_price: self.entry_price,
114 exit_price: price,
115 qty,
116 pnl,
117 return_pct,
118 reason: reason.to_string(),
119 });
120 self.qty = 0.0;
121 self.entry_fee = 0.0;
122 }
123}
124
125#[cfg(test)]
126mod tests {
127 use super::*;
128
129 #[test]
130 fn round_trip_pnl_nets_fees() {
131 let mut pf = Portfolio::new(1000.0);
132 pf.enter(10.0, 10.0, 1, 1.0); assert!(pf.in_position());
134 assert!((pf.cash - 899.0).abs() < 1e-9);
135 pf.exit(12.0, 2, 1.0, "signal"); assert!(!pf.in_position());
137 assert!((pf.cash - 1018.0).abs() < 1e-9);
138 let t = &pf.trades[0];
139 assert!((t.pnl - 18.0).abs() < 1e-9);
141 assert!((pf.fees_paid - 2.0).abs() < 1e-9);
142 }
143
144 #[test]
145 fn short_round_trip_profits_when_price_falls() {
146 let mut pf = Portfolio::new(1000.0);
147 pf.enter(-10.0, 10.0, 1, 0.0); assert!(pf.in_position());
149 assert!(!pf.is_long());
150 assert!((pf.cash - 1100.0).abs() < 1e-9);
151 pf.exit(8.0, 2, 0.0, "signal"); assert!((pf.cash - 1020.0).abs() < 1e-9);
153 let t = &pf.trades[0];
154 assert!((t.pnl - 20.0).abs() < 1e-9);
156 assert!((t.return_pct - 20.0).abs() < 1e-9); }
158}