Skip to main content

df_lighter_core/trade/orders/
intent.rs

1//! An order in decimals, as a trader asks for it, and the [`Order`] it signs as on a market's grid:
2//!
3//! - limit: good-till-time for 28 days unless it says when it lapses; post-only on request;
4//!   immediate-or-cancel with no expiry.
5//! - market: immediate-or-cancel, no expiry; its price is the worst it fills at, the reference
6//!   (the book's best price on the other side, say) moved 2% against it unless it asks for other
7//!   slippage, at most 10%. Or a worst price of its own, held within 10% of a reference if given.
8//! - stop-loss and take-profit: fire when the mark crosses the trigger, as a market order bounded
9//!   2% past the trigger (immediate-or-cancel), or as a limit (good-till-time); 28-day expiry.
10//! - TWAP: market slices every 30 seconds until its end, 5 minutes to 24 hours away, none worse
11//!   than its worst price.
12//!
13//! Sizes are in the base asset, or in the quote (sized at the order's own price, else its
14//! trigger, else the reference, and cut down), or the whole position for a reduce-only order. The
15//! market's least size and value ([`OrderIntent::minimums`]) bind resting orders only, so an order
16//! that takes liquidity (closing a dust position, say) is not held to them. Nothing reads a clock:
17//! the caller passes now.
18
19use crate::trade::error::TradeError;
20use crate::trade::orders::fit::{Fit, worst_price};
21use crate::trade::orders::{MOST_EXPIRY, Order};
22use crate::utils::time::{DAY_MS, MAX_ORDER_EXPIRY_MS, MIN_ORDER_EXPIRY_MS, MINUTE_MS, RESTING_MS};
23use crate::utils::{ClientOrderIndex, Dec, Grid, MarketId, OrderKind, Side, TimeInForce};
24
25/// A market order's slippage when it asks for none: 2%.
26pub const DEFAULT_SLIPPAGE_BPS: u32 = 200;
27/// The most slippage a market order takes: 10%, Lighter's own client limit.
28pub const MAX_SLIPPAGE_BPS: u32 = 1_000;
29/// How far past its trigger a stop or take-profit that fires at market may fill: 2%.
30pub const TRIGGER_SLIPPAGE_BPS: u32 = 200;
31/// A TWAP runs at least 5 minutes...
32pub const TWAP_LEAST_MS: u64 = 5 * MINUTE_MS;
33/// ...and at most 24 hours.
34pub const TWAP_MOST_MS: u64 = DAY_MS;
35
36/// How much an order is for.
37#[derive(Debug, Clone, Copy, PartialEq, Eq)]
38pub enum Size {
39    /// In the base asset: 0.01 BTC.
40    Base(Dec),
41    /// In the collateral: 500 USDC, sized at the order's price and cut down onto the grid.
42    Quote(Dec),
43    /// The whole position, whatever it is when the order fills: a reduce-only order with no size
44    /// (`base_amount` 0).
45    Position,
46}
47
48impl From<Dec> for Size {
49    fn from(base: Dec) -> Size {
50        Size::Base(base)
51    }
52}
53
54/// Which way a trigger order exits a position.
55#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
56pub enum Exit {
57    /// Fires when the mark moves against the position: a sell's below, a buy's above.
58    StopLoss,
59    /// Fires when the mark moves for it.
60    TakeProfit,
61}
62
63#[derive(Debug, Clone, Copy, PartialEq, Eq)]
64enum Pricing {
65    Limit(Dec),
66    /// The worst price comes from the reference and the slippage.
67    Market,
68    MarketAt(Dec),
69    Trigger {
70        exit: Exit,
71        trigger: Dec,
72        limit: Option<Dec>,
73    },
74    Twap {
75        worst: Dec,
76        until: u64,
77    },
78}
79
80/// An order before it is on the grid. Build one with a constructor, adjust it, then
81/// [`OrderIntent::build`] it for a market's [`Grid`] at a time.
82#[derive(Debug, Clone, Copy, PartialEq, Eq)]
83pub struct OrderIntent {
84    market: MarketId,
85    side: Side,
86    size: Size,
87    pricing: Pricing,
88    tif: Option<TimeInForce>,
89    expiry: Option<u64>,
90    slippage_bps: Option<u32>,
91    reference: Option<Dec>,
92    reduce_only: bool,
93    client_index: Option<ClientOrderIndex>,
94    fit: Fit,
95    least: Option<(Dec, Dec)>,
96}
97
98impl OrderIntent {
99    fn new(market: MarketId, side: Side, size: Size, pricing: Pricing) -> OrderIntent {
100        OrderIntent {
101            market,
102            side,
103            size,
104            pricing,
105            tif: None,
106            expiry: None,
107            slippage_bps: None,
108            reference: None,
109            reduce_only: false,
110            client_index: None,
111            fit: Fit::Exact,
112            least: None,
113        }
114    }
115
116    /// A limit at `price`, resting good-till-time for 28 days.
117    pub fn limit(market: MarketId, side: Side, size: impl Into<Size>, price: Dec) -> OrderIntent {
118        OrderIntent::new(market, side, size.into(), Pricing::Limit(price))
119    }
120
121    /// A market order bounded [`DEFAULT_SLIPPAGE_BPS`] past `reference` (the best price on the
122    /// other side of the book, or the mark) unless [`OrderIntent::slippage_bps`] says otherwise.
123    pub fn market(market: MarketId, side: Side, size: impl Into<Size>, reference: Dec) -> OrderIntent {
124        let mut intent = OrderIntent::new(market, side, size.into(), Pricing::Market);
125        intent.reference = Some(reference);
126        intent
127    }
128
129    /// A market order that fills no worse than `worst`.
130    pub fn market_at(market: MarketId, side: Side, size: impl Into<Size>, worst: Dec) -> OrderIntent {
131        OrderIntent::new(market, side, size.into(), Pricing::MarketAt(worst))
132    }
133
134    /// A stop that fires at market when the mark crosses `trigger`, filling no worse than
135    /// [`TRIGGER_SLIPPAGE_BPS`] past it.
136    pub fn stop_loss(market: MarketId, side: Side, size: impl Into<Size>, trigger: Dec) -> OrderIntent {
137        OrderIntent::trigger(market, side, size.into(), Exit::StopLoss, trigger, None)
138    }
139
140    /// A stop that places a limit at `limit` when the mark crosses `trigger`.
141    pub fn stop_loss_limit(
142        market: MarketId,
143        side: Side,
144        size: impl Into<Size>,
145        trigger: Dec,
146        limit: Dec,
147    ) -> OrderIntent {
148        OrderIntent::trigger(market, side, size.into(), Exit::StopLoss, trigger, Some(limit))
149    }
150
151    /// A take-profit that fires at market when the mark crosses `trigger`.
152    pub fn take_profit(market: MarketId, side: Side, size: impl Into<Size>, trigger: Dec) -> OrderIntent {
153        OrderIntent::trigger(market, side, size.into(), Exit::TakeProfit, trigger, None)
154    }
155
156    /// A take-profit that places a limit at `limit` when the mark crosses `trigger`.
157    pub fn take_profit_limit(
158        market: MarketId,
159        side: Side,
160        size: impl Into<Size>,
161        trigger: Dec,
162        limit: Dec,
163    ) -> OrderIntent {
164        OrderIntent::trigger(market, side, size.into(), Exit::TakeProfit, trigger, Some(limit))
165    }
166
167    /// A stop or take-profit, at market (`limit` `None`) or as a limit.
168    pub fn trigger(
169        market: MarketId,
170        side: Side,
171        size: impl Into<Size>,
172        exit: Exit,
173        trigger: Dec,
174        limit: Option<Dec>,
175    ) -> OrderIntent {
176        OrderIntent::new(market, side, size.into(), Pricing::Trigger { exit, trigger, limit })
177    }
178
179    /// A TWAP: market slices every 30 seconds until `until` (unix milliseconds, 5 minutes to 24
180    /// hours away), none filling worse than `worst`.
181    pub fn twap(market: MarketId, side: Side, size: impl Into<Size>, worst: Dec, until: u64) -> OrderIntent {
182        OrderIntent::new(market, side, size.into(), Pricing::Twap { worst, until })
183    }
184
185    /// Rests, and is cancelled rather than take liquidity. For a limit (or a stop's or
186    /// take-profit's limit).
187    #[must_use]
188    pub const fn post_only(mut self) -> OrderIntent {
189        self.tif = Some(TimeInForce::PostOnly);
190        self
191    }
192
193    /// Fills what it can at once and cancels the rest, with no expiry. For a limit.
194    #[must_use]
195    pub const fn ioc(mut self) -> OrderIntent {
196        self.tif = Some(TimeInForce::ImmediateOrCancel);
197        self
198    }
199
200    /// Rests until filled, cancelled or its expiry: a limit's default.
201    #[must_use]
202    pub const fn good_till_time(mut self) -> OrderIntent {
203        self.tif = Some(TimeInForce::GoodTillTime);
204        self
205    }
206
207    /// Lapses at `at`, unix milliseconds 5 minutes to 30 days after the time it is built at,
208    /// rather than 28 days after it. Not for an order that fills at once.
209    #[must_use]
210    pub const fn expires_at(mut self, at: u64) -> OrderIntent {
211        self.expiry = Some(at);
212        self
213    }
214
215    /// Only ever shrinks the position.
216    #[must_use]
217    pub const fn reduce_only(mut self) -> OrderIntent {
218        self.reduce_only = true;
219        self
220    }
221
222    /// Placed under `index`, to cancel or modify it by ([`super::ClientIndices`] hands them out).
223    #[must_use]
224    pub const fn client_index(mut self, index: ClientOrderIndex) -> OrderIntent {
225        self.client_index = Some(index);
226        self
227    }
228
229    /// How far past its reference (a market order) or its trigger (a stop or take-profit at
230    /// market) it may fill, in basis points; at most [`MAX_SLIPPAGE_BPS`].
231    #[must_use]
232    pub const fn slippage_bps(mut self, bps: u32) -> OrderIntent {
233        self.slippage_bps = Some(bps);
234        self
235    }
236
237    /// A price to hold the order against: a market order's worst price must be within 10% of it,
238    /// and a quote-sized order with no price of its own is sized at it.
239    #[must_use]
240    pub const fn reference(mut self, reference: Dec) -> OrderIntent {
241        self.reference = Some(reference);
242        self
243    }
244
245    /// How a size or price finer than the grid is put on it: [`Fit::Exact`] (refused) unless set.
246    #[must_use]
247    pub const fn fit(mut self, fit: Fit) -> OrderIntent {
248        self.fit = fit;
249        self
250    }
251
252    /// The market's least size and least value (`min_base_amount`, `min_quote_amount` in
253    /// `orderBookDetails`), which a resting order must meet.
254    #[must_use]
255    pub const fn minimums(mut self, least_size: Dec, least_value: Dec) -> OrderIntent {
256        self.least = Some((least_size, least_value));
257        self
258    }
259
260    #[must_use]
261    pub const fn market_id(&self) -> MarketId {
262        self.market
263    }
264
265    #[must_use]
266    pub const fn side(&self) -> Side {
267        self.side
268    }
269
270    #[must_use]
271    pub const fn size(&self) -> Size {
272        self.size
273    }
274
275    /// The order on `grid` at `now_ms`, checked as lighter-go checks it.
276    pub fn build(&self, grid: Grid, now_ms: u64) -> Result<Order, TradeError> {
277        let (kind, time_in_force) = self.kind()?;
278        self.check_slippage()?;
279        let fit = self.fit;
280        let (price, trigger) = match self.pricing {
281            Pricing::Limit(price) => (fit.price(grid, price, self.side)?, None),
282            Pricing::Market => {
283                let reference = self.reference.ok_or(TradeError::NoPrice("a market order"))?;
284                let bps = self.slippage_bps.unwrap_or(DEFAULT_SLIPPAGE_BPS);
285                (fit_bound(grid, self.side, reference, bps)?, None)
286            }
287            Pricing::MarketAt(worst) => {
288                let price = fit.price(grid, worst, self.side)?;
289                if let Some(reference) = self.reference {
290                    let most = worst_price(grid, self.side, reference, MAX_SLIPPAGE_BPS)?;
291                    let worst = grid.price_of(price);
292                    let too_far = match self.side {
293                        Side::Bid => worst > most,
294                        Side::Ask => worst < most,
295                    };
296                    if too_far {
297                        return Err(TradeError::TooFar { worst, reference });
298                    }
299                }
300                (price, None)
301            }
302            Pricing::Trigger { trigger, limit, .. } => {
303                let trigger = fit.trigger(grid, trigger)?;
304                let price = match limit {
305                    Some(limit) => fit.price(grid, limit, self.side)?,
306                    None => {
307                        let bps = self.slippage_bps.unwrap_or(TRIGGER_SLIPPAGE_BPS);
308                        fit_bound(grid, self.side, grid.price_of(trigger), bps)?
309                    }
310                };
311                (price, Some(trigger))
312            }
313            Pricing::Twap { worst, .. } => (fit.price(grid, worst, self.side)?, None),
314        };
315        let base_amount = self.base_amount(grid, price, trigger)?;
316        let order = Order {
317            market: self.market,
318            client_index: self.client_index,
319            base_amount,
320            price,
321            side: self.side,
322            kind,
323            time_in_force,
324            reduce_only: self.reduce_only,
325            trigger_price: trigger.unwrap_or(0),
326            expiry: 0,
327        };
328        let order = Order { expiry: self.expiry(&order, now_ms)?, ..order };
329        self.check_minimums(&order, grid)?;
330        order.check()?;
331        Ok(order)
332    }
333
334    /// The kind and time in force; a time in force the kind does not take is refused.
335    fn kind(&self) -> Result<(OrderKind, TimeInForce), TradeError> {
336        let only = |tif: TimeInForce, why: &'static str| match self.tif {
337            None => Ok(tif),
338            Some(asked) if asked == tif => Ok(tif),
339            Some(_) => Err(TradeError::NotFor(why)),
340        };
341        Ok(match self.pricing {
342            Pricing::Limit(_) => (OrderKind::Limit, self.tif.unwrap_or(TimeInForce::GoodTillTime)),
343            Pricing::Market | Pricing::MarketAt(_) => {
344                (OrderKind::Market, only(TimeInForce::ImmediateOrCancel, "a market order is immediate-or-cancel")?)
345            }
346            Pricing::Trigger { exit, limit: None, .. } => {
347                let kind = match exit {
348                    Exit::StopLoss => OrderKind::StopLoss,
349                    Exit::TakeProfit => OrderKind::TakeProfit,
350                };
351                (kind, only(TimeInForce::ImmediateOrCancel, "a stop or take-profit at market is immediate-or-cancel")?)
352            }
353            Pricing::Trigger { exit, limit: Some(_), .. } => {
354                let kind = match exit {
355                    Exit::StopLoss => OrderKind::StopLossLimit,
356                    Exit::TakeProfit => OrderKind::TakeProfitLimit,
357                };
358                (kind, self.tif.unwrap_or(TimeInForce::GoodTillTime))
359            }
360            Pricing::Twap { .. } => (OrderKind::Twap, only(TimeInForce::GoodTillTime, "a TWAP is good-till-time")?),
361        })
362    }
363
364    /// A slippage only where there is something to slip from, and at most 10%.
365    fn check_slippage(&self) -> Result<(), TradeError> {
366        let Some(bps) = self.slippage_bps else { return Ok(()) };
367        match self.pricing {
368            Pricing::Market | Pricing::Trigger { limit: None, .. } => {}
369            _ => return Err(TradeError::NotFor("slippage is for a market order or a stop or take-profit at market")),
370        }
371        match bps > MAX_SLIPPAGE_BPS {
372            true => Err(TradeError::Slippage(bps)),
373            false => Ok(()),
374        }
375    }
376
377    /// `base_amount`: the size on the grid, a quote sized at the order's price (else its trigger,
378    /// else the reference), or none for the whole position.
379    fn base_amount(&self, grid: Grid, price: u32, trigger: Option<u32>) -> Result<u64, TradeError> {
380        match self.size {
381            Size::Base(size) => self.fit.size(grid, size),
382            Size::Quote(quote) => {
383                let at = match self.pricing {
384                    Pricing::Market => self.reference.ok_or(TradeError::NoPrice("a quote-sized market order"))?,
385                    Pricing::Trigger { limit: None, .. } => grid.price_of(trigger.unwrap_or(price)),
386                    _ => grid.price_of(price),
387                };
388                self.fit.size_for(grid, quote, at)
389            }
390            Size::Position if self.reduce_only => Ok(0),
391            Size::Position => Err(TradeError::NotFor("the whole position is a size for a reduce-only order")),
392        }
393    }
394
395    /// The expiry its kind takes: none for an order that fills at once, the TWAP's end, the asked
396    /// one inside the venue's window, else 28 days from now.
397    fn expiry(&self, order: &Order, now_ms: u64) -> Result<u64, TradeError> {
398        let immediate = matches!(order.kind, OrderKind::Market)
399            || matches!((order.kind, order.time_in_force), (OrderKind::Limit, TimeInForce::ImmediateOrCancel));
400        if immediate {
401            return match self.expiry {
402                None => Ok(0),
403                Some(_) => Err(TradeError::NotFor("an order that fills at once has no expiry")),
404            };
405        }
406        if let Pricing::Twap { until, .. } = self.pricing {
407            if self.expiry.is_some() {
408                return Err(TradeError::NotFor("a TWAP lapses when it ends"));
409            }
410            return within("OrderExpiry", "a TWAP's end", until, now_ms, TWAP_LEAST_MS, TWAP_MOST_MS);
411        }
412        match self.expiry {
413            Some(at) => within("OrderExpiry", "an expiry", at, now_ms, MIN_ORDER_EXPIRY_MS, MAX_ORDER_EXPIRY_MS),
414            None => Ok(now_ms.saturating_add(RESTING_MS)),
415        }
416    }
417
418    /// A resting order's size and value against the market's least.
419    fn check_minimums(&self, order: &Order, grid: Grid) -> Result<(), TradeError> {
420        let Some((least_size, least_value)) = self.least else { return Ok(()) };
421        if order.takes() || order.base_amount == 0 {
422            return Ok(());
423        }
424        let size = order.size(grid);
425        if size < least_size {
426            return Err(TradeError::BelowLeastSize { size, least: least_size });
427        }
428        let value = size.checked_mul(order.limit(grid)).ok_or(TradeError::NoPrice("the order's value"))?;
429        if value < least_value {
430            return Err(TradeError::BelowLeastValue { value, least: least_value });
431        }
432        Ok(())
433    }
434}
435
436/// A worst price off `reference`, as the integer it signs as.
437fn fit_bound(grid: Grid, side: Side, reference: Dec, bps: u32) -> Result<u32, TradeError> {
438    let bound = worst_price(grid, side, reference, bps)?;
439    crate::trade::error::units("Price", grid.price_units(bound))
440}
441
442/// `at` when it is `least_ms` to `most_ms` after `now`; else refused, naming `field`.
443pub(crate) fn within(
444    field: &'static str,
445    what: &'static str,
446    at: u64,
447    now: u64,
448    least_ms: u64,
449    most_ms: u64,
450) -> Result<u64, TradeError> {
451    match at.checked_sub(now) {
452        Some(ahead) if (least_ms..=most_ms).contains(&ahead) && at <= MOST_EXPIRY => Ok(at),
453        _ => Err(TradeError::Expiry { field, what, at, now, least_ms, most_ms }),
454    }
455}
456
457#[cfg(test)]
458#[path = "tests/intent.rs"]
459mod tests;