Skip to main content

finance_query/backtesting/
position.rs

1//! Position and trade types for tracking open and closed positions.
2
3use serde::{Deserialize, Serialize};
4
5use super::signal::Signal;
6
7/// Position direction
8#[non_exhaustive]
9#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
10pub enum PositionSide {
11    /// Long position (profit when price rises)
12    Long,
13    /// Short position (profit when price falls)
14    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/// An open position
27#[non_exhaustive]
28#[derive(Debug, Clone, Serialize, Deserialize)]
29pub struct Position {
30    /// Position direction
31    pub side: PositionSide,
32
33    /// Entry timestamp
34    pub entry_timestamp: i64,
35
36    /// Entry price (after slippage)
37    pub entry_price: f64,
38
39    /// Number of shares/units
40    pub quantity: f64,
41
42    /// Entry commission paid
43    pub entry_commission: f64,
44
45    /// Signal that triggered entry
46    pub entry_signal: Signal,
47}
48
49impl Position {
50    /// Create a new position
51    pub fn new(
52        side: PositionSide,
53        entry_timestamp: i64,
54        entry_price: f64,
55        quantity: f64,
56        entry_commission: f64,
57        entry_signal: Signal,
58    ) -> Self {
59        Self {
60            side,
61            entry_timestamp,
62            entry_price,
63            quantity,
64            entry_commission,
65            entry_signal,
66        }
67    }
68
69    /// Calculate current value at given price
70    pub fn current_value(&self, current_price: f64) -> f64 {
71        self.quantity * current_price
72    }
73
74    /// Calculate unrealized P&L at given price (before exit commission)
75    pub fn unrealized_pnl(&self, current_price: f64) -> f64 {
76        let gross_pnl = match self.side {
77            PositionSide::Long => (current_price - self.entry_price) * self.quantity,
78            PositionSide::Short => (self.entry_price - current_price) * self.quantity,
79        };
80        gross_pnl - self.entry_commission
81    }
82
83    /// Calculate unrealized return percentage
84    pub fn unrealized_return_pct(&self, current_price: f64) -> f64 {
85        let entry_value = self.entry_price * self.quantity;
86        if entry_value == 0.0 {
87            return 0.0;
88        }
89        let pnl = self.unrealized_pnl(current_price);
90        (pnl / entry_value) * 100.0
91    }
92
93    /// Check if position is profitable at given price
94    pub fn is_profitable(&self, current_price: f64) -> bool {
95        self.unrealized_pnl(current_price) > 0.0
96    }
97
98    /// Check if this is a long position
99    pub fn is_long(&self) -> bool {
100        matches!(self.side, PositionSide::Long)
101    }
102
103    /// Check if this is a short position
104    pub fn is_short(&self) -> bool {
105        matches!(self.side, PositionSide::Short)
106    }
107
108    /// Close this position and create a Trade
109    pub fn close(
110        self,
111        exit_timestamp: i64,
112        exit_price: f64,
113        exit_commission: f64,
114        exit_signal: Signal,
115    ) -> Trade {
116        let total_commission = self.entry_commission + exit_commission;
117
118        let gross_pnl = match self.side {
119            PositionSide::Long => (exit_price - self.entry_price) * self.quantity,
120            PositionSide::Short => (self.entry_price - exit_price) * self.quantity,
121        };
122        let pnl = gross_pnl - total_commission;
123
124        let entry_value = self.entry_price * self.quantity;
125        let return_pct = if entry_value > 0.0 {
126            (pnl / entry_value) * 100.0
127        } else {
128            0.0
129        };
130
131        Trade {
132            side: self.side,
133            entry_timestamp: self.entry_timestamp,
134            exit_timestamp,
135            entry_price: self.entry_price,
136            exit_price,
137            quantity: self.quantity,
138            commission: total_commission,
139            pnl,
140            return_pct,
141            entry_signal: self.entry_signal,
142            exit_signal,
143        }
144    }
145}
146
147/// A completed trade (closed position)
148#[non_exhaustive]
149#[derive(Debug, Clone, Serialize, Deserialize)]
150pub struct Trade {
151    /// Trade direction
152    pub side: PositionSide,
153
154    /// Entry timestamp
155    pub entry_timestamp: i64,
156
157    /// Exit timestamp
158    pub exit_timestamp: i64,
159
160    /// Entry price
161    pub entry_price: f64,
162
163    /// Exit price
164    pub exit_price: f64,
165
166    /// Number of shares/units
167    pub quantity: f64,
168
169    /// Total commission (entry + exit)
170    pub commission: f64,
171
172    /// Realized P&L (after commission)
173    pub pnl: f64,
174
175    /// Return as percentage
176    pub return_pct: f64,
177
178    /// Signal that triggered entry
179    pub entry_signal: Signal,
180
181    /// Signal that triggered exit
182    pub exit_signal: Signal,
183}
184
185impl Trade {
186    /// Check if trade was profitable
187    pub fn is_profitable(&self) -> bool {
188        self.pnl > 0.0
189    }
190
191    /// Check if trade was a loss
192    pub fn is_loss(&self) -> bool {
193        self.pnl < 0.0
194    }
195
196    /// Check if this was a long trade
197    pub fn is_long(&self) -> bool {
198        matches!(self.side, PositionSide::Long)
199    }
200
201    /// Check if this was a short trade
202    pub fn is_short(&self) -> bool {
203        matches!(self.side, PositionSide::Short)
204    }
205
206    /// Get trade duration in seconds
207    pub fn duration_secs(&self) -> i64 {
208        self.exit_timestamp - self.entry_timestamp
209    }
210
211    /// Get entry value (cost basis)
212    pub fn entry_value(&self) -> f64 {
213        self.entry_price * self.quantity
214    }
215
216    /// Get exit value
217    pub fn exit_value(&self) -> f64 {
218        self.exit_price * self.quantity
219    }
220}
221
222#[cfg(test)]
223mod tests {
224    use super::*;
225
226    fn make_entry_signal() -> Signal {
227        Signal::long(1000, 100.0)
228    }
229
230    fn make_exit_signal() -> Signal {
231        Signal::exit(2000, 110.0)
232    }
233
234    #[test]
235    fn test_position_long_profit() {
236        let pos = Position::new(
237            PositionSide::Long,
238            1000,
239            100.0,
240            10.0,
241            1.0, // $1 commission
242            make_entry_signal(),
243        );
244
245        // Price goes up to 110
246        let pnl = pos.unrealized_pnl(110.0);
247        // (110 - 100) * 10 - 1 = 99
248        assert!((pnl - 99.0).abs() < 0.01);
249        assert!(pos.is_profitable(110.0));
250    }
251
252    #[test]
253    fn test_position_long_loss() {
254        let pos = Position::new(
255            PositionSide::Long,
256            1000,
257            100.0,
258            10.0,
259            1.0,
260            make_entry_signal(),
261        );
262
263        // Price goes down to 90
264        let pnl = pos.unrealized_pnl(90.0);
265        // (90 - 100) * 10 - 1 = -101
266        assert!((pnl - (-101.0)).abs() < 0.01);
267        assert!(!pos.is_profitable(90.0));
268    }
269
270    #[test]
271    fn test_position_short_profit() {
272        let pos = Position::new(
273            PositionSide::Short,
274            1000,
275            100.0,
276            10.0,
277            1.0,
278            Signal::short(1000, 100.0),
279        );
280
281        // Price goes down to 90 (profit for short)
282        let pnl = pos.unrealized_pnl(90.0);
283        // (100 - 90) * 10 - 1 = 99
284        assert!((pnl - 99.0).abs() < 0.01);
285        assert!(pos.is_profitable(90.0));
286    }
287
288    #[test]
289    fn test_position_close_to_trade() {
290        let pos = Position::new(
291            PositionSide::Long,
292            1000,
293            100.0,
294            10.0,
295            1.0,
296            make_entry_signal(),
297        );
298
299        let trade = pos.close(2000, 110.0, 1.0, make_exit_signal());
300
301        assert_eq!(trade.entry_price, 100.0);
302        assert_eq!(trade.exit_price, 110.0);
303        assert_eq!(trade.quantity, 10.0);
304        assert_eq!(trade.commission, 2.0); // 1 + 1
305        // (110 - 100) * 10 - 2 = 98
306        assert!((trade.pnl - 98.0).abs() < 0.01);
307        assert!(trade.is_profitable());
308        assert!(trade.is_long());
309        assert_eq!(trade.duration_secs(), 1000);
310    }
311
312    #[test]
313    fn test_trade_return_pct() {
314        let pos = Position::new(
315            PositionSide::Long,
316            1000,
317            100.0,
318            10.0,
319            0.0,
320            make_entry_signal(),
321        );
322
323        let trade = pos.close(2000, 110.0, 0.0, make_exit_signal());
324
325        // Entry value = 1000, PnL = 100, return = 10%
326        assert!((trade.return_pct - 10.0).abs() < 0.01);
327    }
328}