Skip to main content

df_hyperliquid_core/account/
outcomes.rs

1//! An account on the outcome books: what it holds, how it got there, and what rests. Holdings come
2//! from `spotClearinghouseState`, the authoritative answer; fills corroborate them, and structural
3//! fills (splits, merges, negations, settlements) are never counted as volume.
4
5use std::{
6    collections::{BTreeMap, BTreeSet},
7    convert::Infallible,
8    fmt,
9    str::FromStr,
10};
11
12use crate::account::order::Cloid;
13use crate::markets::outcomes::coin::Coin;
14use crate::markets::outcomes::px::{Px, Qty, SignedQty};
15use crate::trade::Side;
16use crate::utils::{Dec, Round};
17
18/// What a fill was, by its `dir`.
19#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
20pub enum Direction {
21    Buy,
22    Sell,
23    SplitOutcome,
24    MergeOutcome,
25    NegateOutcome,
26    MergeQuestion,
27    Settlement,
28    /// Anything else, kept as written.
29    Other(String),
30}
31
32impl Direction {
33    #[must_use]
34    pub fn read(text: &str) -> Direction {
35        match text.trim() {
36            "Buy" => Direction::Buy,
37            "Sell" => Direction::Sell,
38            "Split Outcome" => Direction::SplitOutcome,
39            "Merge Outcome" => Direction::MergeOutcome,
40            "Negate Outcome" => Direction::NegateOutcome,
41            "Merge Question" => Direction::MergeQuestion,
42            "Settlement" => Direction::Settlement,
43            other => Direction::Other(other.to_owned()),
44        }
45    }
46
47    #[must_use]
48    pub fn slug(&self) -> &str {
49        match self {
50            Direction::Buy => "buy",
51            Direction::Sell => "sell",
52            Direction::SplitOutcome => "split_outcome",
53            Direction::MergeOutcome => "merge_outcome",
54            Direction::NegateOutcome => "negate_outcome",
55            Direction::MergeQuestion => "merge_question",
56            Direction::Settlement => "settlement",
57            Direction::Other(raw) => raw,
58        }
59    }
60
61    /// Crossed a book: traded volume.
62    #[must_use]
63    pub const fn is_trade(&self) -> bool {
64        matches!(self, Direction::Buy | Direction::Sell)
65    }
66
67    /// A complete set minted or burned against the protocol: never volume.
68    #[must_use]
69    pub const fn is_structural(&self) -> bool {
70        matches!(
71            self,
72            Direction::SplitOutcome | Direction::MergeOutcome | Direction::NegateOutcome | Direction::MergeQuestion
73        )
74    }
75
76    /// Paid out at settlement: never volume.
77    #[must_use]
78    pub const fn is_settlement(&self) -> bool {
79        matches!(self, Direction::Settlement)
80    }
81}
82
83impl FromStr for Direction {
84    type Err = Infallible;
85
86    fn from_str(text: &str) -> Result<Self, Self::Err> {
87        Ok(Direction::read(text))
88    }
89}
90
91impl fmt::Display for Direction {
92    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
93        f.write_str(self.slug())
94    }
95}
96
97/// One row of `userFills` or `userFillsByTime`, as written. Perp and spot rows come in the same
98/// answer; [`Fill::read`] skips them.
99pub type RawFill = crate::account::fill::Fill;
100
101/// One outcome fill, read.
102#[derive(Debug, Clone, PartialEq, Eq)]
103pub struct Fill {
104    pub coin: Coin,
105    pub side: Side,
106    pub direction: Direction,
107    /// As reported: a structural fill's `1/n` is finer than the grid.
108    pub price: Dec,
109    pub size: Qty,
110    pub at_ms: u64,
111    pub start_position: SignedQty,
112    /// After this fill.
113    pub position: SignedQty,
114    pub closed_pnl: Dec,
115    /// Everything billed, the builder's fee included.
116    pub fee: Dec,
117    pub fee_token: String,
118    pub builder_fee: Option<Dec>,
119    /// True for an order that took, false for a resting one that was taken.
120    pub crossed: bool,
121    pub oid: u64,
122    pub tid: u64,
123    pub hash: String,
124    pub cloid: Option<String>,
125}
126
127impl Fill {
128    /// `None` for a row that is no outcome fill, or does not read.
129    #[must_use]
130    pub fn read(raw: &RawFill) -> Option<Fill> {
131        if !raw.coin.starts_with('#') {
132            return None;
133        }
134        let coin = Coin::parse(&raw.coin)?;
135        let size = Qty::from_dec(raw.sz).ok()?;
136        let start_position = SignedQty::from_dec(raw.start_position).ok()?;
137        let moved = match raw.side {
138            Side::Buy => SignedQty::long(size),
139            Side::Sell => SignedQty::short(size),
140        };
141        Some(Fill {
142            coin,
143            side: raw.side,
144            direction: Direction::read(&raw.dir),
145            price: raw.px,
146            size,
147            at_ms: raw.time,
148            start_position,
149            position: start_position.saturating_add(moved),
150            closed_pnl: raw.closed_pnl,
151            fee: raw.fee,
152            fee_token: raw.fee_token.clone(),
153            builder_fee: raw.builder_fee,
154            crossed: raw.crossed,
155            oid: raw.oid,
156            tid: raw.tid,
157            hash: raw.hash.clone(),
158            cloid: raw.cloid.clone(),
159        })
160    }
161
162    /// Size times price, in the quote token.
163    #[must_use]
164    pub fn notional(&self) -> Dec {
165        self.size.to_dec().checked_mul(self.price).unwrap_or(Dec::ZERO)
166    }
167
168    /// What the venue billed, the builder's fee taken out.
169    #[must_use]
170    pub fn venue_fee(&self) -> Option<Dec> {
171        self.fee.checked_sub(self.builder_fee.unwrap_or(Dec::ZERO))
172    }
173}
174
175/// One row of `openOrders` or `frontendOpenOrders`, as written.
176pub type RawOrder = crate::account::order::OpenOrder;
177
178/// An outcome order resting on the book.
179#[derive(Debug, Clone, PartialEq, Eq)]
180pub struct OpenOrder {
181    pub coin: Coin,
182    pub side: Side,
183    pub price: Px,
184    /// What still rests.
185    pub size: Qty,
186    pub original_size: Qty,
187    pub placed_ms: u64,
188    pub oid: u64,
189    pub cloid: Option<Cloid>,
190}
191
192impl OpenOrder {
193    /// `None` for a row that is no outcome order, or does not read.
194    #[must_use]
195    pub fn read(raw: &RawOrder) -> Option<OpenOrder> {
196        if !raw.coin.starts_with('#') {
197            return None;
198        }
199        Some(OpenOrder {
200            coin: Coin::parse(&raw.coin)?,
201            side: raw.side,
202            price: Px::from_dec(raw.limit_px).ok()?,
203            size: Qty::from_dec(raw.sz).ok()?,
204            original_size: Qty::from_dec(raw.orig_sz).ok()?,
205            placed_ms: raw.timestamp,
206            oid: raw.oid,
207            cloid: raw.cloid,
208        })
209    }
210
211    /// What the book has already taken.
212    #[must_use]
213    pub const fn filled(&self) -> Qty {
214        self.original_size.saturating_sub(self.size)
215    }
216}
217
218/// What fills say is held in one side.
219#[derive(Debug, Clone, PartialEq, Eq)]
220pub struct Position {
221    pub coin: Coin,
222    /// From the newest fill alone.
223    pub size: SignedQty,
224    /// When that fill was.
225    pub fixed_at_ms: u64,
226    pub realized_pnl: Dec,
227    pub fees: Dec,
228    pub fills: usize,
229}
230
231/// The positions fills imply, largest first. Fills sharing a millisecond keep
232/// the order they came in, newest first, as the venue sends them.
233#[must_use]
234pub fn positions(fills: &[Fill]) -> Vec<Position> {
235    let mut newest_first: Vec<&Fill> = fills.iter().collect();
236    newest_first.sort_by_key(|fill| std::cmp::Reverse(fill.at_ms));
237    let mut found: BTreeMap<Coin, Position> = BTreeMap::new();
238    for fill in newest_first {
239        let position = found.entry(fill.coin).or_insert_with(|| Position {
240            coin: fill.coin,
241            size: fill.position,
242            fixed_at_ms: fill.at_ms,
243            realized_pnl: Dec::ZERO,
244            fees: Dec::ZERO,
245            fills: 0,
246        });
247        position.realized_pnl = position.realized_pnl.checked_add(fill.closed_pnl).unwrap_or(position.realized_pnl);
248        position.fees = position.fees.checked_add(fill.fee).unwrap_or(position.fees);
249        position.fills += 1;
250    }
251    let mut positions: Vec<Position> = found.into_values().collect();
252    positions.sort_by(|a, b| b.size.abs().cmp(&a.size.abs()).then_with(|| a.coin.cmp(&b.coin)));
253    positions
254}
255
256/// A window of fills summed, structural and settled notional kept apart from
257/// what was traded.
258#[derive(Debug, Clone, PartialEq, Eq)]
259pub struct Summary {
260    pub fills: usize,
261    /// Outcomes touched.
262    pub markets: usize,
263    pub trades: usize,
264    pub traded_volume: Dec,
265    pub structural_volume: Dec,
266    pub settled_volume: Dec,
267    pub realized_pnl: Dec,
268    pub fees: Dec,
269    /// Trades that rested and were taken.
270    pub made: usize,
271    /// Trades that took.
272    pub taken: usize,
273    pub by_direction: BTreeMap<String, usize>,
274    pub first_ms: Option<u64>,
275    pub last_ms: Option<u64>,
276}
277
278#[must_use]
279pub fn summarize(fills: &[Fill]) -> Summary {
280    let add = |sum: Dec, more: Dec| sum.checked_add(more).unwrap_or(sum);
281    let mut summary = Summary {
282        fills: fills.len(),
283        markets: 0,
284        trades: 0,
285        traded_volume: Dec::ZERO,
286        structural_volume: Dec::ZERO,
287        settled_volume: Dec::ZERO,
288        realized_pnl: Dec::ZERO,
289        fees: Dec::ZERO,
290        made: 0,
291        taken: 0,
292        by_direction: BTreeMap::new(),
293        first_ms: None,
294        last_ms: None,
295    };
296    let mut outcomes = BTreeSet::new();
297    for fill in fills {
298        outcomes.insert(fill.coin.outcome());
299        summary.realized_pnl = add(summary.realized_pnl, fill.closed_pnl);
300        summary.fees = add(summary.fees, fill.fee);
301        if fill.direction.is_trade() {
302            summary.trades += 1;
303            summary.traded_volume = add(summary.traded_volume, fill.notional());
304            match fill.crossed {
305                true => summary.taken += 1,
306                false => summary.made += 1,
307            }
308        } else if fill.direction.is_structural() {
309            summary.structural_volume = add(summary.structural_volume, fill.notional());
310        } else if fill.direction.is_settlement() {
311            summary.settled_volume = add(summary.settled_volume, fill.notional());
312        }
313        *summary.by_direction.entry(fill.direction.slug().to_owned()).or_default() += 1;
314        summary.first_ms = Some(summary.first_ms.map_or(fill.at_ms, |first| first.min(fill.at_ms)));
315        summary.last_ms = Some(summary.last_ms.map_or(fill.at_ms, |last| last.max(fill.at_ms)));
316    }
317    summary.markets = outcomes.len();
318    summary
319}
320
321/// `spotClearinghouseState`, the rows an outcome holding is read from (outcome rows carry no
322/// `token`).
323pub type SpotBalances = crate::account::clearinghouse::SpotState;
324
325pub type SpotRow = crate::account::clearinghouse::SpotBalance;
326
327/// One outcome side held, as the spot side reports it.
328#[derive(Debug, Clone, PartialEq, Eq)]
329pub struct Holding {
330    pub coin: Coin,
331    /// Everything held, `hold` included.
332    pub total: Dec,
333    /// What resting sells hold.
334    pub hold: Dec,
335    /// What it cost.
336    pub entry_ntl: Dec,
337}
338
339impl Holding {
340    /// What no order holds.
341    #[must_use]
342    pub fn free(&self) -> Dec {
343        self.total.checked_sub(self.hold).unwrap_or(Dec::ZERO).max(Dec::ZERO)
344    }
345
346    /// What it cost a contract.
347    #[must_use]
348    pub fn average(&self) -> Option<Dec> {
349        match self.total.is_positive() {
350            true => self.entry_ntl.checked_div(self.total, 8, Round::Nearest),
351            false => None,
352        }
353    }
354}
355
356/// The outcome sides the account holds, in the venue's order. A row with
357/// nothing in it, and every row that is no outcome side, is left out.
358#[must_use]
359pub fn holdings(state: &SpotBalances) -> Vec<Holding> {
360    state
361        .balances
362        .iter()
363        .filter(|row| !(row.total.is_zero() && row.hold.is_zero()))
364        .filter_map(|row| {
365            Some(Holding { coin: Coin::parse(&row.coin)?, total: row.total, hold: row.hold, entry_ntl: row.entry_ntl })
366        })
367        .collect()
368}
369
370/// What the fills say of a holding.
371#[derive(Debug, Clone, Copy, PartialEq, Eq)]
372pub enum Check {
373    Agrees,
374    /// The fill window does not reach the side's last trade; the balance
375    /// stands.
376    Disagrees {
377        from_fills: SignedQty,
378    },
379    /// No fill in the window touches it.
380    Unseen,
381}
382
383/// Each holding beside what fills derive for it.
384#[must_use]
385pub fn corroborate<'a>(holdings: &'a [Holding], positions: &[Position]) -> Vec<(&'a Holding, Check)> {
386    holdings
387        .iter()
388        .map(|holding| {
389            let check = match positions.iter().find(|position| position.coin == holding.coin) {
390                None => Check::Unseen,
391                Some(position) if !position.size.is_short() && position.size.abs().to_dec() == holding.total => {
392                    Check::Agrees
393                }
394                Some(position) => Check::Disagrees { from_fills: position.size },
395            };
396            (holding, check)
397        })
398        .collect()
399}
400
401#[cfg(test)]
402#[path = "tests/outcomes.rs"]
403mod tests;