Skip to main content

wickra_backtest_core/
portfolio.rs

1//! Cash/position accounting for one signed position (long `qty > 0` or short
2//! `qty < 0`).
3//!
4//! Fills are charged a taker fee on notional and slippage is applied to the fill
5//! price by the engine before it calls [`Portfolio::enter`] / [`Portfolio::exit`].
6//! The signed-quantity convention makes the same `enter`/`exit` cash maths work
7//! for both sides: `PnL` is always `qty * (exit − entry) − fees`.
8
9use serde::Serialize;
10
11/// A completed round-trip trade.
12#[derive(Debug, Clone, PartialEq, Serialize)]
13pub struct Trade {
14    /// Entry bar time.
15    pub entry_time: i64,
16    /// Exit bar time.
17    pub exit_time: i64,
18    /// Entry fill price.
19    pub entry_price: f64,
20    /// Exit fill price.
21    pub exit_price: f64,
22    /// Position quantity.
23    pub qty: f64,
24    /// Realised `PnL` net of fees.
25    pub pnl: f64,
26    /// Return on the entry notional, in percent.
27    pub return_pct: f64,
28    /// Why the position was closed (`signal` / `stop_loss` / `take_profit` / `end`).
29    pub reason: String,
30}
31
32/// Long-only portfolio: cash plus at most one open position.
33#[derive(Debug, Clone)]
34pub struct Portfolio {
35    /// Free cash.
36    pub cash: f64,
37    /// Open quantity (`0.0` when flat).
38    pub qty: f64,
39    /// Fill price of the open position.
40    pub entry_price: f64,
41    /// Entry time of the open position.
42    pub entry_time: i64,
43    /// Fees paid on entry of the open position (carried so exit can net them).
44    entry_fee: f64,
45    /// Completed trades.
46    pub trades: Vec<Trade>,
47    /// Total fees paid across the run.
48    pub fees_paid: f64,
49}
50
51impl Portfolio {
52    /// Create a portfolio with `cash` of starting capital.
53    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    /// Whether a position is open (long `qty > 0` or short `qty < 0`).
66    pub fn in_position(&self) -> bool {
67        self.qty.abs() > f64::EPSILON
68    }
69
70    /// `true` for a long position, `false` for a short.
71    pub fn is_long(&self) -> bool {
72        self.qty > 0.0
73    }
74
75    /// Mark-to-market equity at `mark` price.
76    pub fn equity(&self, mark: f64) -> f64 {
77        self.cash + self.qty * mark
78    }
79
80    /// Apply a funding `payment` to cash (positive = paid out, e.g. a long
81    /// paying positive funding; negative = received). Also recorded in
82    /// `fees_paid` so the report's total cost reflects funding.
83    pub fn apply_funding(&mut self, payment: f64) {
84        self.cash -= payment;
85        self.fees_paid += payment;
86    }
87
88    /// Open a long of `qty` at `price`, paying `fee`.
89    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    /// Close the open position at `price`, paying `fee`, recording a [`Trade`].
99    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); // buy 10 @ 10, fee 1 -> cash 1000-100-1=899
133        assert!(pf.in_position());
134        assert!((pf.cash - 899.0).abs() < 1e-9);
135        pf.exit(12.0, 2, 1.0, "signal"); // sell 10 @ 12, fee 1 -> cash 899+120-1=1018
136        assert!(!pf.in_position());
137        assert!((pf.cash - 1018.0).abs() < 1e-9);
138        let t = &pf.trades[0];
139        // pnl = 10*(12-10) - 1 - 1 = 18
140        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); // short 10 @ 10 -> receive 100 -> cash 1100
148        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"); // buy back 10 @ 8 -> pay 80 -> cash 1020
152        assert!((pf.cash - 1020.0).abs() < 1e-9);
153        let t = &pf.trades[0];
154        // pnl = -10*(8-10) = 20
155        assert!((t.pnl - 20.0).abs() < 1e-9);
156        assert!((t.return_pct - 20.0).abs() < 1e-9); // 20 / (10*10) * 100
157    }
158}