Skip to main content

rustrade_execution/
position.rs

1use chrono::{DateTime, Utc};
2use derive_more::Constructor;
3use rust_decimal::Decimal;
4use serde::{Deserialize, Serialize};
5
6/// An open position in an instrument whose holding is tracked separately from cash balances:
7/// perpetuals, futures, options, and equities.
8///
9/// A holding a venue reports as an asset balance instead, such as spot crypto, has no
10/// `Position`.
11#[derive(
12    Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Deserialize, Serialize, Constructor,
13)]
14pub struct Position {
15    /// Signed quantity: positive = long, negative = short, zero = flat.
16    ///
17    /// Using signed quantity is the industry standard for positions and avoids
18    /// a separate `side` field.
19    pub quantity: Decimal,
20
21    /// Average entry price, quoted as the instrument's orders are priced: for an option or a
22    /// future, per unit of the underlying, not multiplied by the contract size. `None` if position
23    /// is flat or entry price unavailable.
24    pub entry_price: Option<Decimal>,
25
26    /// Unrealized PnL in quote currency. `None` if not provided by exchange.
27    pub unrealized_pnl: Option<Decimal>,
28
29    /// Margin/collateral allocated to this position. `None` if not applicable.
30    pub margin_used: Option<Decimal>,
31
32    /// Liquidation price. `None` for cross-margin or if not provided.
33    pub liquidation_price: Option<Decimal>,
34
35    /// Leverage setting. `None` if not applicable (e.g., spot-margin).
36    pub leverage: Option<Decimal>,
37    /// Carry the venue has charged or paid on this position since it opened, such as perpetual
38    /// funding, signed: positive = received, negative = paid. It is in the asset the venue settles
39    /// it in, which for a perpetual is its margin asset. `None` if the venue does not report it.
40    ///
41    /// Of the built-in clients only Hyperliquid reports it, from its `cumFunding.sinceOpen` with
42    /// the sign flipped (Hyperliquid counts funding paid as positive).
43    pub carry: Option<Decimal>,
44
45    /// Exchange timestamp when this position state was reported.
46    pub time_exchange: DateTime<Utc>,
47}
48
49impl Position {
50    /// Returns true if this position is flat (zero quantity).
51    pub fn is_flat(&self) -> bool {
52        self.quantity.is_zero()
53    }
54
55    /// Returns true if this is a long position (positive quantity).
56    pub fn is_long(&self) -> bool {
57        self.quantity > Decimal::ZERO
58    }
59
60    /// Returns true if this is a short position (negative quantity). A negative zero is flat, not
61    /// short.
62    pub fn is_short(&self) -> bool {
63        self.quantity < Decimal::ZERO
64    }
65
66    /// Returns the absolute position size.
67    pub fn abs_quantity(&self) -> Decimal {
68        self.quantity.abs()
69    }
70}
71
72/// What a venue reported about its position in one instrument, in an
73/// [`InstrumentAccountSnapshot`](crate::InstrumentAccountSnapshot).
74///
75/// Only [`Flat`](Self::Flat) and [`Open`](Self::Open) are claims about the position. A consumer
76/// comparing the venue's position with its own (the engine does, see
77/// `EngineOutput::PositionDrift` in `rustrade`) must compare only those, and read nothing into
78/// [`Unreported`](Self::Unreported) or into an instrument the snapshot does not list.
79#[derive(Debug, Clone, Default, PartialEq, Eq, PartialOrd, Ord, Hash, Deserialize, Serialize)]
80pub enum PositionReport {
81    /// The venue does not report a position for this instrument: it does not report positions
82    /// at all, it reports this holding as an asset balance instead (such as spot crypto), or
83    /// this read could not establish it.
84    #[default]
85    Unreported,
86    /// The venue reports positions for this instrument and holds none.
87    Flat,
88    /// The venue's open position. A client must not report a zero quantity as open: build it
89    /// with [`Self::from_position`], which reports one as [`Flat`](Self::Flat).
90    Open(Position),
91}
92
93impl PositionReport {
94    /// Report a position the venue returned: [`Flat`](Self::Flat) when its quantity is zero,
95    /// otherwise [`Open`](Self::Open).
96    pub fn from_position(position: Position) -> Self {
97        if position.is_flat() {
98            Self::Flat
99        } else {
100            Self::Open(position)
101        }
102    }
103
104    /// The open position, if any.
105    pub fn open(&self) -> Option<&Position> {
106        match self {
107            Self::Open(position) => Some(position),
108            Self::Unreported | Self::Flat => None,
109        }
110    }
111
112    /// The venue's signed quantity: zero when [`Flat`](Self::Flat), `None` when
113    /// [`Unreported`](Self::Unreported).
114    pub fn quantity(&self) -> Option<Decimal> {
115        match self {
116            Self::Unreported => None,
117            Self::Flat => Some(Decimal::ZERO),
118            Self::Open(position) => Some(position.quantity),
119        }
120    }
121
122    /// Whether this is [`Unreported`](Self::Unreported).
123    pub fn is_unreported(&self) -> bool {
124        matches!(self, Self::Unreported)
125    }
126}
127
128#[cfg(test)]
129#[allow(clippy::unwrap_used)] // Test code: panicking on a bad fixture is acceptable
130mod tests {
131    use super::*;
132    use rust_decimal_macros::dec;
133
134    /// The snapshot field is absent when unreported, `"Flat"` when flat and `{"Open": ...}` when
135    /// open, and an absent field reads back as unreported.
136    #[test]
137    fn instrument_snapshot_position_serde() {
138        use crate::InstrumentAccountSnapshot;
139        use rustrade_instrument::{
140            asset::name::AssetNameExchange, exchange::ExchangeId,
141            instrument::name::InstrumentNameExchange,
142        };
143        type Snapshot =
144            InstrumentAccountSnapshot<ExchangeId, AssetNameExchange, InstrumentNameExchange>;
145
146        let open = Position::new(
147            dec!(-2),
148            Some(dec!(10)),
149            None,
150            None,
151            None,
152            None,
153            None,
154            Utc::now(),
155        );
156        for (position, expected) in [
157            (PositionReport::Unreported, None),
158            (PositionReport::Flat, Some(serde_json::json!("Flat"))),
159            (
160                PositionReport::Open(open.clone()),
161                Some(serde_json::json!({ "Open": serde_json::to_value(&open).unwrap() })),
162            ),
163        ] {
164            let snapshot = Snapshot::new("x".into(), vec![], false, position, None);
165            let json = serde_json::to_value(&snapshot).unwrap();
166            assert_eq!(json.get("position").cloned(), expected);
167            assert_eq!(serde_json::from_value::<Snapshot>(json).unwrap(), snapshot);
168        }
169    }
170
171    #[test]
172    fn position_report_from_position_reports_zero_as_flat() {
173        let now = Utc::now();
174        let open = Position::new(dec!(-2), None, None, None, None, None, None, now);
175        assert_eq!(
176            PositionReport::from_position(open.clone()),
177            PositionReport::Open(open.clone())
178        );
179        for zero in [dec!(0), -dec!(0)] {
180            let flat = Position::new(zero, None, None, None, None, None, None, now);
181            assert_eq!(PositionReport::from_position(flat), PositionReport::Flat);
182        }
183        assert_eq!(PositionReport::Open(open).quantity(), Some(dec!(-2)));
184        assert_eq!(PositionReport::Flat.quantity(), Some(Decimal::ZERO));
185        assert_eq!(PositionReport::Unreported.quantity(), None);
186    }
187
188    #[test]
189    fn test_position_side_detection() {
190        let now = Utc::now();
191
192        let long = Position::new(dec!(1.5), None, None, None, None, None, None, now);
193        assert!(long.is_long());
194        assert!(!long.is_short());
195        assert!(!long.is_flat());
196
197        let short = Position::new(dec!(-1.5), None, None, None, None, None, None, now);
198        assert!(!short.is_long());
199        assert!(short.is_short());
200        assert!(!short.is_flat());
201
202        // Negative zero, as `Decimal` can produce from arithmetic or parsing "-0", is flat too.
203        for zero in [dec!(0), -dec!(0)] {
204            let flat = Position::new(zero, None, None, None, None, None, None, now);
205            assert!(!flat.is_long(), "{zero:?}");
206            assert!(!flat.is_short(), "{zero:?}");
207            assert!(flat.is_flat(), "{zero:?}");
208        }
209    }
210
211    #[test]
212    fn test_abs_quantity() {
213        let now = Utc::now();
214        let short = Position::new(dec!(-2.5), None, None, None, None, None, None, now);
215        assert_eq!(short.abs_quantity(), dec!(2.5));
216    }
217}