Skip to main content

wickra_exchange_core/
positions.rs

1//! Derivatives position state.
2//!
3//! On a perpetual/futures account a [`Position`] is the net exposure in one
4//! market: a side, a magnitude, an entry and mark price, leverage and margin
5//! mode. Venues report unrealized PnL, but it is also computable from the entry
6//! and mark prices — which the paper and replay exchanges need — so that
7//! calculation lives here, tested.
8
9use crate::options::MarginMode;
10use crate::symbol::Symbol;
11use crate::types::OrderSide;
12use rust_decimal::Decimal;
13use serde::{Deserialize, Serialize};
14
15/// The direction of a position.
16#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
17pub enum PositionSide {
18    /// Long: profits when the price rises.
19    Long,
20    /// Short: profits when the price falls.
21    Short,
22}
23
24impl PositionSide {
25    /// `+1` for long, `-1` for short.
26    #[must_use]
27    pub fn sign(self) -> Decimal {
28        match self {
29            PositionSide::Long => Decimal::ONE,
30            PositionSide::Short => Decimal::NEGATIVE_ONE,
31        }
32    }
33
34    /// Which side of a hedged account an order acts on.
35    ///
36    /// In [`PositionMode::Hedge`](crate::PositionMode) a symbol carries a long
37    /// and a short position at once, so an order has to name the one it means.
38    /// [`OrderRequest`](crate::OrderRequest) does not carry that name, and does
39    /// not need to: buying opens the long side or closes the short one, and
40    /// selling does the reverse. `reduce_only` is what separates the two.
41    ///
42    /// | side | `reduce_only` | acts on |
43    /// |------|---------------|---------|
44    /// | buy  | `false`       | long, opening |
45    /// | sell | `false`       | short, opening |
46    /// | buy  | `true`        | short, closing |
47    /// | sell | `true`        | long, closing |
48    ///
49    /// Every venue that supports hedged accounts spells this the same way under
50    /// a different field name, so the mapping lives here once.
51    #[must_use]
52    pub fn for_order(side: OrderSide, reduce_only: bool) -> Self {
53        match (side, reduce_only) {
54            (OrderSide::Buy, false) | (OrderSide::Sell, true) => PositionSide::Long,
55            (OrderSide::Sell, false) | (OrderSide::Buy, true) => PositionSide::Short,
56        }
57    }
58}
59
60/// A net position in one derivatives market.
61#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
62pub struct Position {
63    /// The market.
64    pub symbol: Symbol,
65    /// Long or short.
66    pub side: PositionSide,
67    /// Position size in base units (magnitude, always positive).
68    pub quantity: Decimal,
69    /// Average entry price.
70    pub entry_price: Decimal,
71    /// Current mark price.
72    pub mark_price: Decimal,
73    /// Account leverage for this position.
74    pub leverage: Decimal,
75    /// Unrealized PnL as reported by the venue.
76    pub unrealized_pnl: Decimal,
77    /// Cross or isolated margin.
78    pub margin_mode: MarginMode,
79}
80
81impl Position {
82    /// Signed size: positive for long, negative for short.
83    #[must_use]
84    pub fn signed_quantity(&self) -> Decimal {
85        self.quantity * self.side.sign()
86    }
87
88    /// Notional value at the mark price (`quantity * mark_price`).
89    #[must_use]
90    pub fn notional(&self) -> Decimal {
91        self.quantity * self.mark_price
92    }
93
94    /// Unrealized PnL computed from entry and mark prices:
95    /// `(mark - entry) * signed_quantity`. Used by the paper/replay exchanges
96    /// where the venue does not supply it.
97    #[must_use]
98    pub fn computed_unrealized_pnl(&self) -> Decimal {
99        (self.mark_price - self.entry_price) * self.signed_quantity()
100    }
101}
102
103#[cfg(test)]
104mod tests {
105    use super::*;
106    use rust_decimal_macros::dec;
107
108    fn position(side: PositionSide, entry: Decimal, mark: Decimal) -> Position {
109        Position {
110            symbol: Symbol::new("BTC", "USDT"),
111            side,
112            quantity: dec!(2),
113            entry_price: entry,
114            mark_price: mark,
115            leverage: dec!(10),
116            unrealized_pnl: dec!(0),
117            margin_mode: MarginMode::Cross,
118        }
119    }
120
121    #[test]
122    fn hedge_side_follows_side_and_reduce_only() {
123        // Opening: the side of the order is the side of the position.
124        assert_eq!(
125            PositionSide::for_order(OrderSide::Buy, false),
126            PositionSide::Long
127        );
128        assert_eq!(
129            PositionSide::for_order(OrderSide::Sell, false),
130            PositionSide::Short
131        );
132        // Closing: the order takes the opposite side of the position it reduces.
133        assert_eq!(
134            PositionSide::for_order(OrderSide::Buy, true),
135            PositionSide::Short
136        );
137        assert_eq!(
138            PositionSide::for_order(OrderSide::Sell, true),
139            PositionSide::Long
140        );
141    }
142
143    #[test]
144    fn side_sign() {
145        assert_eq!(PositionSide::Long.sign(), dec!(1));
146        assert_eq!(PositionSide::Short.sign(), dec!(-1));
147    }
148
149    #[test]
150    fn signed_quantity_and_notional() {
151        let long = position(PositionSide::Long, dec!(100), dec!(110));
152        assert_eq!(long.signed_quantity(), dec!(2));
153        assert_eq!(long.notional(), dec!(220));
154
155        let short = position(PositionSide::Short, dec!(100), dec!(110));
156        assert_eq!(short.signed_quantity(), dec!(-2));
157        assert_eq!(short.notional(), dec!(220));
158    }
159
160    #[test]
161    fn computed_pnl_for_both_sides() {
162        // Long up 10 on 2 units = +20.
163        let long = position(PositionSide::Long, dec!(100), dec!(110));
164        assert_eq!(long.computed_unrealized_pnl(), dec!(20));
165        // Short, price up 10 = -20.
166        let short = position(PositionSide::Short, dec!(100), dec!(110));
167        assert_eq!(short.computed_unrealized_pnl(), dec!(-20));
168        // Short, price down 10 = +20.
169        let short_win = position(PositionSide::Short, dec!(100), dec!(90));
170        assert_eq!(short_win.computed_unrealized_pnl(), dec!(20));
171    }
172}