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
38    /// Exchange timestamp when this position state was reported.
39    pub time_exchange: DateTime<Utc>,
40}
41
42impl Position {
43    /// Returns true if this position is flat (zero quantity).
44    pub fn is_flat(&self) -> bool {
45        self.quantity.is_zero()
46    }
47
48    /// Returns true if this is a long position (positive quantity).
49    pub fn is_long(&self) -> bool {
50        self.quantity > Decimal::ZERO
51    }
52
53    /// Returns true if this is a short position (negative quantity). A negative zero is flat, not
54    /// short.
55    pub fn is_short(&self) -> bool {
56        self.quantity < Decimal::ZERO
57    }
58
59    /// Returns the absolute position size.
60    pub fn abs_quantity(&self) -> Decimal {
61        self.quantity.abs()
62    }
63}
64
65/// What a venue reported about its position in one instrument, in an
66/// [`InstrumentAccountSnapshot`](crate::InstrumentAccountSnapshot).
67///
68/// Only [`Flat`](Self::Flat) and [`Open`](Self::Open) are claims about the position. A consumer
69/// comparing the venue's position with its own (the engine does, see
70/// `EngineOutput::PositionDrift` in `rustrade`) must compare only those, and read nothing into
71/// [`Unreported`](Self::Unreported) or into an instrument the snapshot does not list.
72#[derive(Debug, Clone, Default, PartialEq, Eq, PartialOrd, Ord, Hash, Deserialize, Serialize)]
73pub enum PositionReport {
74    /// The venue does not report a position for this instrument: it does not report positions
75    /// at all, it reports this holding as an asset balance instead (such as spot crypto), or
76    /// this read could not establish it.
77    #[default]
78    Unreported,
79    /// The venue reports positions for this instrument and holds none.
80    Flat,
81    /// The venue's open position. A client must not report a zero quantity as open: build it
82    /// with [`Self::from_position`], which reports one as [`Flat`](Self::Flat).
83    Open(Position),
84}
85
86impl PositionReport {
87    /// Report a position the venue returned: [`Flat`](Self::Flat) when its quantity is zero,
88    /// otherwise [`Open`](Self::Open).
89    pub fn from_position(position: Position) -> Self {
90        if position.is_flat() {
91            Self::Flat
92        } else {
93            Self::Open(position)
94        }
95    }
96
97    /// The open position, if any.
98    pub fn open(&self) -> Option<&Position> {
99        match self {
100            Self::Open(position) => Some(position),
101            Self::Unreported | Self::Flat => None,
102        }
103    }
104
105    /// The venue's signed quantity: zero when [`Flat`](Self::Flat), `None` when
106    /// [`Unreported`](Self::Unreported).
107    pub fn quantity(&self) -> Option<Decimal> {
108        match self {
109            Self::Unreported => None,
110            Self::Flat => Some(Decimal::ZERO),
111            Self::Open(position) => Some(position.quantity),
112        }
113    }
114
115    /// Whether this is [`Unreported`](Self::Unreported).
116    pub fn is_unreported(&self) -> bool {
117        matches!(self, Self::Unreported)
118    }
119}
120
121#[cfg(test)]
122#[allow(clippy::unwrap_used)] // Test code: panicking on a bad fixture is acceptable
123mod tests {
124    use super::*;
125    use rust_decimal_macros::dec;
126
127    /// The snapshot field is absent when unreported, `"Flat"` when flat and `{"Open": ...}` when
128    /// open, and an absent field reads back as unreported.
129    #[test]
130    fn instrument_snapshot_position_serde() {
131        use crate::InstrumentAccountSnapshot;
132        use rustrade_instrument::{
133            asset::name::AssetNameExchange, exchange::ExchangeId,
134            instrument::name::InstrumentNameExchange,
135        };
136        type Snapshot =
137            InstrumentAccountSnapshot<ExchangeId, AssetNameExchange, InstrumentNameExchange>;
138
139        let open = Position::new(dec!(-2), Some(dec!(10)), None, None, None, None, Utc::now());
140        for (position, expected) in [
141            (PositionReport::Unreported, None),
142            (PositionReport::Flat, Some(serde_json::json!("Flat"))),
143            (
144                PositionReport::Open(open.clone()),
145                Some(serde_json::json!({ "Open": serde_json::to_value(&open).unwrap() })),
146            ),
147        ] {
148            let snapshot = Snapshot::new("x".into(), vec![], false, position, None);
149            let json = serde_json::to_value(&snapshot).unwrap();
150            assert_eq!(json.get("position").cloned(), expected);
151            assert_eq!(serde_json::from_value::<Snapshot>(json).unwrap(), snapshot);
152        }
153    }
154
155    #[test]
156    fn position_report_from_position_reports_zero_as_flat() {
157        let now = Utc::now();
158        let open = Position::new(dec!(-2), None, None, None, None, None, now);
159        assert_eq!(
160            PositionReport::from_position(open.clone()),
161            PositionReport::Open(open.clone())
162        );
163        for zero in [dec!(0), -dec!(0)] {
164            let flat = Position::new(zero, None, None, None, None, None, now);
165            assert_eq!(PositionReport::from_position(flat), PositionReport::Flat);
166        }
167        assert_eq!(PositionReport::Open(open).quantity(), Some(dec!(-2)));
168        assert_eq!(PositionReport::Flat.quantity(), Some(Decimal::ZERO));
169        assert_eq!(PositionReport::Unreported.quantity(), None);
170    }
171
172    #[test]
173    fn test_position_side_detection() {
174        let now = Utc::now();
175
176        let long = Position::new(dec!(1.5), None, None, None, None, None, now);
177        assert!(long.is_long());
178        assert!(!long.is_short());
179        assert!(!long.is_flat());
180
181        let short = Position::new(dec!(-1.5), None, None, None, None, None, now);
182        assert!(!short.is_long());
183        assert!(short.is_short());
184        assert!(!short.is_flat());
185
186        // Negative zero, as `Decimal` can produce from arithmetic or parsing "-0", is flat too.
187        for zero in [dec!(0), -dec!(0)] {
188            let flat = Position::new(zero, None, None, None, None, None, now);
189            assert!(!flat.is_long(), "{zero:?}");
190            assert!(!flat.is_short(), "{zero:?}");
191            assert!(flat.is_flat(), "{zero:?}");
192        }
193    }
194
195    #[test]
196    fn test_abs_quantity() {
197        let now = Utc::now();
198        let short = Position::new(dec!(-2.5), None, None, None, None, None, now);
199        assert_eq!(short.abs_quantity(), dec!(2.5));
200    }
201}