df-hyperliquid-core 0.0.9-beta

Hyperliquid SDK for Rust: markets, account, trading, transfers, earn and deploy, with exact decimals and signing. Native and wasm.
Documentation
//! An account on the outcome books: the sides it holds, read from `spotClearinghouseState`, the
//! authoritative answer, and its outcome fills and resting orders read from the rows of every
//! market. A structural fill (a split, merge, negation or settlement) is never volume.

use std::{convert::Infallible, fmt, str::FromStr};

use crate::account::history::{fills, orders};
use crate::account::holdings::spot::SpotState;
use crate::markets::outcomes::coin::Coin;
use crate::markets::outcomes::px::{Px, Qty, SignedQty};
use crate::trade::{Cloid, Side};
use crate::utils::{Dec, Round};

/// What a fill was, by its `dir`.
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum Direction {
    Buy,
    Sell,
    SplitOutcome,
    MergeOutcome,
    NegateOutcome,
    MergeQuestion,
    Settlement,
    /// Anything else, kept as written.
    Other(String),
}

impl Direction {
    #[must_use]
    pub fn read(text: &str) -> Direction {
        match text.trim() {
            "Buy" => Direction::Buy,
            "Sell" => Direction::Sell,
            "Split Outcome" => Direction::SplitOutcome,
            "Merge Outcome" => Direction::MergeOutcome,
            "Negate Outcome" => Direction::NegateOutcome,
            "Merge Question" => Direction::MergeQuestion,
            "Settlement" => Direction::Settlement,
            other => Direction::Other(other.to_owned()),
        }
    }

    #[must_use]
    pub fn slug(&self) -> &str {
        match self {
            Direction::Buy => "buy",
            Direction::Sell => "sell",
            Direction::SplitOutcome => "split_outcome",
            Direction::MergeOutcome => "merge_outcome",
            Direction::NegateOutcome => "negate_outcome",
            Direction::MergeQuestion => "merge_question",
            Direction::Settlement => "settlement",
            Direction::Other(raw) => raw,
        }
    }

    /// Crossed a book: traded volume.
    #[must_use]
    pub const fn is_trade(&self) -> bool {
        matches!(self, Direction::Buy | Direction::Sell)
    }

    /// A complete set minted or burned against the protocol: never volume.
    #[must_use]
    pub const fn is_structural(&self) -> bool {
        matches!(
            self,
            Direction::SplitOutcome | Direction::MergeOutcome | Direction::NegateOutcome | Direction::MergeQuestion
        )
    }

    /// Paid out at settlement: never volume.
    #[must_use]
    pub const fn is_settlement(&self) -> bool {
        matches!(self, Direction::Settlement)
    }
}

impl FromStr for Direction {
    type Err = Infallible;

    fn from_str(text: &str) -> Result<Self, Self::Err> {
        Ok(Direction::read(text))
    }
}

impl fmt::Display for Direction {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.write_str(self.slug())
    }
}

/// One outcome fill, read from a `userFills` or `userFillsByTime` row. Perp and spot rows come in
/// the same answer; [`Fill::read`] skips them.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Fill {
    pub coin: Coin,
    pub side: Side,
    pub direction: Direction,
    /// As reported: a structural fill's `1/n` is finer than the grid.
    pub price: Dec,
    pub size: Qty,
    pub at_ms: u64,
    pub start_position: SignedQty,
    /// After this fill.
    pub position: SignedQty,
    pub closed_pnl: Dec,
    /// Everything billed, the builder's fee included.
    pub fee: Dec,
    pub fee_token: String,
    pub builder_fee: Option<Dec>,
    /// True for an order that took, false for a resting one that was taken.
    pub crossed: bool,
    pub oid: u64,
    pub tid: u64,
    pub hash: String,
    pub cloid: Option<String>,
}

impl Fill {
    /// `None` for a row that is no outcome fill, or does not read.
    #[must_use]
    pub fn read(raw: &fills::Fill) -> Option<Fill> {
        if !raw.coin.starts_with('#') {
            return None;
        }
        let coin = Coin::parse(&raw.coin)?;
        let size = Qty::from_dec(raw.sz).ok()?;
        let start_position = SignedQty::from_dec(raw.start_position).ok()?;
        let moved = match raw.side {
            Side::Buy => SignedQty::long(size),
            Side::Sell => SignedQty::short(size),
        };
        Some(Fill {
            coin,
            side: raw.side,
            direction: Direction::read(&raw.dir),
            price: raw.px,
            size,
            at_ms: raw.time,
            start_position,
            position: start_position.saturating_add(moved),
            closed_pnl: raw.closed_pnl,
            fee: raw.fee,
            fee_token: raw.fee_token.clone(),
            builder_fee: raw.builder_fee,
            crossed: raw.crossed,
            oid: raw.oid,
            tid: raw.tid,
            hash: raw.hash.clone(),
            cloid: raw.cloid.clone(),
        })
    }

    /// Size times price, in the quote token.
    #[must_use]
    pub fn notional(&self) -> Dec {
        self.size.to_dec().checked_mul(self.price).unwrap_or(Dec::ZERO)
    }

    /// What the venue billed, the builder's fee taken out.
    #[must_use]
    pub fn venue_fee(&self) -> Option<Dec> {
        self.fee.checked_sub(self.builder_fee.unwrap_or(Dec::ZERO))
    }
}

/// An outcome order resting on the book, read from an `openOrders` row.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct OpenOrder {
    pub coin: Coin,
    pub side: Side,
    pub price: Px,
    /// What still rests.
    pub size: Qty,
    pub original_size: Qty,
    pub placed_ms: u64,
    pub oid: u64,
    pub cloid: Option<Cloid>,
}

impl OpenOrder {
    /// `None` for a row that is no outcome order, or does not read.
    #[must_use]
    pub fn read(raw: &orders::OpenOrder) -> Option<OpenOrder> {
        if !raw.coin.starts_with('#') {
            return None;
        }
        Some(OpenOrder {
            coin: Coin::parse(&raw.coin)?,
            side: raw.side,
            price: Px::from_dec(raw.limit_px).ok()?,
            size: Qty::from_dec(raw.sz).ok()?,
            original_size: Qty::from_dec(raw.orig_sz).ok()?,
            placed_ms: raw.timestamp,
            oid: raw.oid,
            cloid: raw.cloid,
        })
    }

    /// What the book has already taken.
    #[must_use]
    pub const fn filled(&self) -> Qty {
        self.original_size.saturating_sub(self.size)
    }
}

/// One outcome side held, as the spot side reports it.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Holding {
    pub coin: Coin,
    /// Everything held, `hold` included.
    pub total: Dec,
    /// What resting sells hold.
    pub hold: Dec,
    /// What it cost.
    pub entry_ntl: Dec,
}

impl Holding {
    /// What no order holds.
    #[must_use]
    pub fn free(&self) -> Dec {
        self.total.checked_sub(self.hold).unwrap_or(Dec::ZERO).max(Dec::ZERO)
    }

    /// What it cost a contract.
    #[must_use]
    pub fn average(&self) -> Option<Dec> {
        match self.total.is_positive() {
            true => self.entry_ntl.checked_div(self.total, 8, Round::Nearest),
            false => None,
        }
    }
}

/// The outcome sides the account holds, in the venue's order. A row with
/// nothing in it, and every row that is no outcome side, is left out.
#[must_use]
pub fn holdings(state: &SpotState) -> Vec<Holding> {
    state
        .balances
        .iter()
        .filter(|row| !(row.total.is_zero() && row.hold.is_zero()))
        .filter_map(|row| {
            Some(Holding { coin: Coin::parse(&row.coin)?, total: row.total, hold: row.hold, entry_ntl: row.entry_ntl })
        })
        .collect()
}

#[cfg(test)]
#[path = "tests/outcomes.rs"]
mod tests;