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