Skip to main content

df_hyperliquid_core/trade/
ticket.rs

1//! What every order ticket shares: the market's rules ([`Market`]), how much ([`Amount`]), why
2//! nothing is sent ([`Refusal`]), and the checks each order type runs. A price never rounds
3//! against the trader and a size rounds toward zero.
4
5use crate::trade::builder::BuilderSetting;
6use crate::trade::error::TradeError;
7use crate::trade::orders::scale::{LEAST_LEVELS, MOST_LEVELS};
8use crate::trade::orders::{Order, PlaceOrder, Tif, Tpsl};
9use crate::utils::rounding::Grid;
10use crate::utils::{Asset, AssetKind, Dec, Round};
11
12/// What the ticket knows of one market, from the meta the caller read: its asset id, its grid,
13/// and for a perp its leverage ceiling and whether it takes cross margin. HIP-4 outcomes have
14/// orders of their own in [`crate::markets::outcomes`].
15#[derive(Debug, Clone, Copy, PartialEq, Eq)]
16pub struct Market {
17    asset: Asset,
18    grid: Grid,
19    max_leverage: Option<u32>,
20    allows_cross: bool,
21}
22
23impl Market {
24    /// A perp, main dex or HIP-3: its `szDecimals` and `maxLeverage` in
25    /// `meta`, and whether it takes cross (not `onlyIsolated`, and no
26    /// `marginMode` that forbids it). `None` for an id that names no perp.
27    #[must_use]
28    pub const fn perp(asset: Asset, sz_decimals: u32, max_leverage: u32, allows_cross: bool) -> Option<Market> {
29        match asset.kind() {
30            AssetKind::Perp | AssetKind::Hip3Perp => {
31                Some(Market { asset, grid: Grid::perp(sz_decimals), max_leverage: Some(max_leverage), allows_cross })
32            }
33            AssetKind::Spot | AssetKind::Outcome => None,
34        }
35    }
36
37    /// A spot pair, by its base token's `szDecimals` in `spotMeta`. `None`
38    /// for an id that names no pair.
39    #[must_use]
40    pub const fn spot(asset: Asset, sz_decimals: u32) -> Option<Market> {
41        match asset.kind() {
42            AssetKind::Spot => {
43                Some(Market { asset, grid: Grid::spot(sz_decimals), max_leverage: None, allows_cross: false })
44            }
45            AssetKind::Perp | AssetKind::Hip3Perp | AssetKind::Outcome => None,
46        }
47    }
48
49    #[must_use]
50    pub const fn asset(&self) -> Asset {
51        self.asset
52    }
53
54    #[must_use]
55    pub const fn grid(&self) -> Grid {
56        self.grid
57    }
58
59    /// `None` on spot, which takes no leverage.
60    #[must_use]
61    pub const fn max_leverage(&self) -> Option<u32> {
62        self.max_leverage
63    }
64
65    #[must_use]
66    pub const fn allows_cross(&self) -> bool {
67        self.allows_cross
68    }
69
70    /// Spot has balances, not positions: nothing that closes, protects or
71    /// funds one.
72    #[must_use]
73    pub const fn is_spot(&self) -> bool {
74        self.max_leverage.is_none()
75    }
76}
77
78/// How much: a size in the base, or a notional in the quote that is cut to
79/// a size at the order's price.
80#[derive(Debug, Clone, Copy, PartialEq, Eq)]
81pub enum Amount {
82    Size(Dec),
83    Notional(Dec),
84}
85
86impl Amount {
87    /// The size on `grid` at `price`, cut toward zero.
88    pub(crate) fn size_on(self, grid: Grid, price: Dec) -> Result<Dec, Refusal> {
89        let raw = match self {
90            Amount::Size(size) => positive(size, "a size")?,
91            Amount::Notional(notional) => positive(notional, "a notional")?
92                .checked_div(price, 12, Round::Down)
93                .ok_or(Refusal::Trade(TradeError::NoPrice(price)))?,
94        };
95        grid.size(raw).ok_or(Refusal::Trade(TradeError::NoSize(raw)))
96    }
97}
98
99/// Why the ticket sends nothing.
100#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
101pub enum Refusal {
102    #[error(transparent)]
103    Trade(#[from] TradeError),
104    #[error("{what} above zero is needed, not {value}")]
105    NotPositive { what: &'static str, value: Dec },
106    #[error("{size} at {price} is worth {}, under the venue's minimum of {minimum}", worth(.size, .price))]
107    UnderMinimum { size: Dec, price: Dec, minimum: Dec },
108    #[error("a limit is never FrontendMarket: a market order is Price::Market, bounded from the mid")]
109    MarketTif,
110    #[error("a scale rests on the book, so Gtc or Alo, not {}", .0.wire())]
111    ScaleTif(Tif),
112    #[error("a scale has {LEAST_LEVELS} to {MOST_LEVELS} levels, not {0}")]
113    Levels(u32),
114    #[error("a modify that does not always place is Alo or Gtc, not {}", .0.wire())]
115    ModifyTif(Tif),
116    #[error("spot takes no {0}")]
117    Spot(&'static str),
118    #[error("a notional needs a reference price to be sized at")]
119    NoReference,
120    #[error("a {} at {trigger} is on the wrong side of an entry at {entry}", tpsl_words(.tpsl))]
121    ExitSide { tpsl: Tpsl, trigger: Dec, entry: Dec },
122    #[error("there is no position here")]
123    NoPosition,
124    #[error("closing {size} is more than the position of {position}")]
125    CloseTooLarge { size: Dec, position: Dec },
126    #[error("leverage is a whole number from 1 to {most}, not {asked}")]
127    Leverage { asked: u32, most: u32 },
128    #[error("this market is isolated only")]
129    IsolatedOnly,
130    #[error("this position is cross; margin moves on isolated ones")]
131    CrossPosition,
132    #[error("order {0} is a trigger: change its trigger instead")]
133    NotALimit(u64),
134    #[error("order {0} is not a take profit or stop loss")]
135    NotATrigger(u64),
136    #[error("order {oid} is a {kind} trigger, which this build cannot modify")]
137    UnknownTrigger { oid: u64, kind: String },
138    #[error("order {0} has a size of its own; only a position's take profit or stop loss covers the whole position")]
139    NotWholePosition(u64),
140}
141
142fn worth(size: &Dec, price: &Dec) -> String {
143    size.checked_mul(*price).map_or_else(|| "too much to count".to_owned(), Dec::to_wire)
144}
145
146const fn tpsl_words(tpsl: &Tpsl) -> &'static str {
147    match tpsl {
148        Tpsl::TakeProfit => "take profit",
149        Tpsl::StopLoss => "stop loss",
150    }
151}
152
153pub(crate) fn positive(value: Dec, what: &'static str) -> Result<Dec, Refusal> {
154    match value.is_positive() {
155        true => Ok(value),
156        false => Err(Refusal::NotPositive { what, value }),
157    }
158}
159
160pub(crate) fn at_least(size: Dec, price: Dec, minimum: Dec) -> Result<(), Refusal> {
161    match price.checked_mul(size).is_some_and(|worth| worth >= minimum) {
162        true => Ok(()),
163        false => Err(Refusal::UnderMinimum { size, price, minimum }),
164    }
165}
166
167/// `orders` through the builder, held to their strictest cap.
168pub(crate) fn routed(place: PlaceOrder, builder: Option<&BuilderSetting>) -> Result<PlaceOrder, Refusal> {
169    let Some(setting) = builder else { return Ok(place) };
170    match setting.on_assets(place.orders().iter().map(Order::asset)).map_err(TradeError::from)? {
171        Some(builder) => Ok(place.through(builder)),
172        None => Ok(place),
173    }
174}
175
176#[cfg(test)]
177#[path = "tests/ticket.rs"]
178mod tests;