Skip to main content

df_hyperliquid_core/trade/
ticket.rs

1//! The ticket: what a trader means, turned into the exact action to sign or the reason it cannot
2//! be sent, from the market's rules ([`Market`]). A price never rounds against the trader, a size
3//! rounds toward zero, and a market order is `FrontendMarket`, never `Gtc`.
4
5use crate::account::orders::FrontendOrder;
6use crate::trade::builder::BuilderSetting;
7use crate::trade::error::TradeError;
8use crate::trade::leverage::{MarginMode, UpdateIsolatedMargin, UpdateLeverage};
9use crate::trade::modify::{BatchModify, Modify, OrderRef};
10use crate::trade::order::{Cloid, Grouping, Order, PlaceOrder, Side, Tif, Tpsl};
11use crate::trade::twap::Twap;
12use crate::utils::rounding::{Grid, MINIMUM_ORDER_VALUE};
13use crate::utils::{Asset, AssetKind, Dec, Round};
14
15/// The slippage a market take profit or stop loss takes past its trigger:
16/// the venue's TP/SL page gives market TP/SL orders a tolerance of 10%.
17pub const TRIGGER_SLIPPAGE: Dec = Dec::lit::<1>(1);
18
19/// The least a TWAP may total, in the quote: the venue's order types page.
20pub const MINIMUM_TWAP_VALUE: Dec = Dec::from_int(100);
21
22/// The fewest levels a scale has.
23pub const LEAST_LEVELS: u32 = 2;
24
25/// The most levels a scale has.
26pub const MOST_LEVELS: u32 = 50;
27
28/// What the ticket knows of one market, from the meta the caller read: its asset id, its grid,
29/// and for a perp its leverage ceiling and whether it takes cross margin. HIP-4 outcomes have
30/// orders of their own in [`crate::markets::outcomes`].
31#[derive(Debug, Clone, Copy, PartialEq, Eq)]
32pub struct Market {
33    asset: Asset,
34    grid: Grid,
35    max_leverage: Option<u32>,
36    allows_cross: bool,
37}
38
39impl Market {
40    /// A perp, main dex or HIP-3: its `szDecimals` and `maxLeverage` in
41    /// `meta`, and whether it takes cross (not `onlyIsolated`, and no
42    /// `marginMode` that forbids it). `None` for an id that names no perp.
43    #[must_use]
44    pub const fn perp(asset: Asset, sz_decimals: u32, max_leverage: u32, allows_cross: bool) -> Option<Market> {
45        match asset.kind() {
46            AssetKind::Perp | AssetKind::Hip3Perp => {
47                Some(Market { asset, grid: Grid::perp(sz_decimals), max_leverage: Some(max_leverage), allows_cross })
48            }
49            AssetKind::Spot | AssetKind::Outcome => None,
50        }
51    }
52
53    /// A spot pair, by its base token's `szDecimals` in `spotMeta`. `None`
54    /// for an id that names no pair.
55    #[must_use]
56    pub const fn spot(asset: Asset, sz_decimals: u32) -> Option<Market> {
57        match asset.kind() {
58            AssetKind::Spot => {
59                Some(Market { asset, grid: Grid::spot(sz_decimals), max_leverage: None, allows_cross: false })
60            }
61            AssetKind::Perp | AssetKind::Hip3Perp | AssetKind::Outcome => None,
62        }
63    }
64
65    #[must_use]
66    pub const fn asset(&self) -> Asset {
67        self.asset
68    }
69
70    #[must_use]
71    pub const fn grid(&self) -> Grid {
72        self.grid
73    }
74
75    /// `None` on spot, which takes no leverage.
76    #[must_use]
77    pub const fn max_leverage(&self) -> Option<u32> {
78        self.max_leverage
79    }
80
81    #[must_use]
82    pub const fn allows_cross(&self) -> bool {
83        self.allows_cross
84    }
85
86    /// Spot has balances, not positions: nothing that closes, protects or
87    /// funds one.
88    #[must_use]
89    pub const fn is_spot(&self) -> bool {
90        self.max_leverage.is_none()
91    }
92}
93
94/// How much: a size in the base, or a notional in the quote that is cut to
95/// a size at the order's price.
96#[derive(Debug, Clone, Copy, PartialEq, Eq)]
97pub enum Amount {
98    Size(Dec),
99    Notional(Dec),
100}
101
102impl Amount {
103    /// The size on `grid` at `price`, cut toward zero.
104    fn size_on(self, grid: Grid, price: Dec) -> Result<Dec, Refusal> {
105        let raw = match self {
106            Amount::Size(size) => positive(size, "a size")?,
107            Amount::Notional(notional) => positive(notional, "a notional")?
108                .checked_div(price, 12, Round::Down)
109                .ok_or(Refusal::Trade(TradeError::NoPrice(price)))?,
110        };
111        grid.size(raw).ok_or(Refusal::Trade(TradeError::NoSize(raw)))
112    }
113}
114
115/// At what price an order goes.
116#[derive(Debug, Clone, Copy, PartialEq, Eq)]
117pub enum Price {
118    /// `FrontendMarket`, bounded `slippage` (a fraction, 0.01 for 1%) past
119    /// `reference`: the mid, or else the mark.
120    Market { reference: Dec, slippage: Dec },
121    /// A limit at `price`, resting as `tif` says. Never `FrontendMarket`.
122    Limit { price: Dec, tif: Tif },
123}
124
125/// How a take profit or stop loss fills once its trigger fires.
126#[derive(Debug, Clone, Copy, PartialEq, Eq)]
127pub enum ExitPrice {
128    /// At market, no worse than `slippage` past the trigger.
129    Market { slippage: Dec },
130    /// A limit at this price.
131    Limit(Dec),
132}
133
134/// A take profit or stop loss: where it fires and how it fills.
135#[derive(Debug, Clone, Copy, PartialEq, Eq)]
136pub struct Exit {
137    pub trigger: Dec,
138    pub price: ExitPrice,
139}
140
141impl Exit {
142    /// At market, within the venue's own [`TRIGGER_SLIPPAGE`].
143    #[must_use]
144    pub const fn market(trigger: Dec) -> Exit {
145        Exit::market_within(trigger, TRIGGER_SLIPPAGE)
146    }
147
148    /// At market, within `slippage`.
149    #[must_use]
150    pub const fn market_within(trigger: Dec, slippage: Dec) -> Exit {
151        Exit { trigger, price: ExitPrice::Market { slippage } }
152    }
153
154    /// A limit at `price` once `trigger` fires.
155    #[must_use]
156    pub const fn limit(trigger: Dec, price: Dec) -> Exit {
157        Exit { trigger, price: ExitPrice::Limit(price) }
158    }
159}
160
161/// An order to place: a market or limit entry, with its take profit and stop
162/// loss when it has them.
163#[derive(Debug, Clone, Copy, PartialEq, Eq)]
164pub struct OrderIntent {
165    pub side: Side,
166    pub amount: Amount,
167    pub price: Price,
168    pub reduce_only: bool,
169    pub cloid: Option<Cloid>,
170    pub take_profit: Option<Exit>,
171    pub stop_loss: Option<Exit>,
172}
173
174impl OrderIntent {
175    #[must_use]
176    pub const fn market(side: Side, amount: Amount, reference: Dec, slippage: Dec) -> OrderIntent {
177        OrderIntent::at(side, amount, Price::Market { reference, slippage })
178    }
179
180    #[must_use]
181    pub const fn limit(side: Side, amount: Amount, price: Dec, tif: Tif) -> OrderIntent {
182        OrderIntent::at(side, amount, Price::Limit { price, tif })
183    }
184
185    const fn at(side: Side, amount: Amount, price: Price) -> OrderIntent {
186        OrderIntent { side, amount, price, reduce_only: false, cloid: None, take_profit: None, stop_loss: None }
187    }
188
189    #[must_use]
190    pub const fn with_cloid(mut self, cloid: Cloid) -> OrderIntent {
191        self.cloid = Some(cloid);
192        self
193    }
194
195    /// Placed with the entry, the other way, once it fills.
196    #[must_use]
197    pub const fn take_profit(mut self, exit: Exit) -> OrderIntent {
198        self.take_profit = Some(exit);
199        self
200    }
201
202    /// Placed with the entry, the other way, once it fills.
203    #[must_use]
204    pub const fn stop_loss(mut self, exit: Exit) -> OrderIntent {
205        self.stop_loss = Some(exit);
206        self
207    }
208}
209
210/// How much of a position a take profit or stop loss closes.
211#[derive(Debug, Clone, Copy, PartialEq, Eq)]
212pub enum Covers {
213    /// A fixed size.
214    Size(Amount),
215    /// The whole position, whatever it is when the trigger fires: size 0,
216    /// grouped `positionTpsl`.
217    Position,
218}
219
220/// A take profit or stop loss on its own. `side` is the side that closes:
221/// a sell for a long.
222#[derive(Debug, Clone, Copy, PartialEq, Eq)]
223pub struct TriggerIntent {
224    pub side: Side,
225    pub size: Covers,
226    pub tpsl: Tpsl,
227    pub exit: Exit,
228    pub cloid: Option<Cloid>,
229}
230
231impl TriggerIntent {
232    #[must_use]
233    pub const fn new(side: Side, size: Covers, tpsl: Tpsl, exit: Exit) -> TriggerIntent {
234        TriggerIntent { side, size, tpsl, exit, cloid: None }
235    }
236
237    #[must_use]
238    pub const fn with_cloid(mut self, cloid: Cloid) -> TriggerIntent {
239        self.cloid = Some(cloid);
240        self
241    }
242}
243
244/// A scale: `levels` resting orders evenly spaced from `from` to `to`, the
245/// size split evenly between them.
246#[derive(Debug, Clone, Copy, PartialEq, Eq)]
247pub struct Scale {
248    pub side: Side,
249    /// The total, in the base.
250    pub size: Dec,
251    pub from: Dec,
252    pub to: Dec,
253    pub levels: u32,
254    pub tif: Tif,
255    pub reduce_only: bool,
256}
257
258impl Scale {
259    #[must_use]
260    pub const fn new(side: Side, size: Dec, from: Dec, to: Dec, levels: u32, tif: Tif) -> Scale {
261        Scale { side, size, from, to, levels, tif, reduce_only: false }
262    }
263}
264
265/// A TWAP: the venue slices it into an order every thirty seconds for
266/// `minutes`. `reference` prices a notional and the venue's minimum.
267#[derive(Debug, Clone, Copy, PartialEq, Eq)]
268pub struct TwapIntent {
269    pub side: Side,
270    pub amount: Amount,
271    pub minutes: u32,
272    pub randomize: bool,
273    pub reduce_only: bool,
274    pub reference: Option<Dec>,
275}
276
277impl TwapIntent {
278    #[must_use]
279    pub const fn new(side: Side, amount: Amount, minutes: u32) -> TwapIntent {
280        TwapIntent { side, amount, minutes, randomize: false, reduce_only: false, reference: None }
281    }
282
283    /// Priced at `reference`, the mid or else the mark.
284    #[must_use]
285    pub const fn at(mut self, reference: Dec) -> TwapIntent {
286        self.reference = Some(reference);
287        self
288    }
289}
290
291/// A position closed at market: all of it, or `size` of it.
292#[derive(Debug, Clone, Copy, PartialEq, Eq)]
293pub struct Close {
294    /// The position, signed as `clearinghouseState` writes `szi`: below
295    /// zero is short.
296    pub position: Dec,
297    pub size: Option<Dec>,
298    pub reference: Dec,
299    pub slippage: Dec,
300    pub cloid: Option<Cloid>,
301}
302
303impl Close {
304    /// The whole `position`, bounded `slippage` past `reference`.
305    #[must_use]
306    pub const fn new(position: Dec, reference: Dec, slippage: Dec) -> Close {
307        Close { position, size: None, reference, slippage, cloid: None }
308    }
309
310    #[must_use]
311    pub const fn with_cloid(mut self, cloid: Cloid) -> Close {
312        self.cloid = Some(cloid);
313        self
314    }
315}
316
317/// What a resting order becomes.
318#[derive(Debug, Clone, Copy, PartialEq, Eq)]
319pub enum Change {
320    /// A limit order's new price, size and time in force.
321    Limit { price: Dec, size: Amount, tif: Tif },
322    /// A take profit's or stop loss's new trigger, price and size.
323    Trigger { trigger: Dec, price: ExitPrice, size: Covers },
324}
325
326/// Why the ticket sends nothing.
327#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
328pub enum Refusal {
329    #[error(transparent)]
330    Trade(#[from] TradeError),
331    #[error("{what} above zero is needed, not {value}")]
332    NotPositive { what: &'static str, value: Dec },
333    #[error("{size} at {price} is worth {}, under the venue's minimum of {minimum}", worth(.size, .price))]
334    UnderMinimum { size: Dec, price: Dec, minimum: Dec },
335    #[error("a limit is never FrontendMarket: a market order is Price::Market, bounded from the mid")]
336    MarketTif,
337    #[error("a scale rests on the book, so Gtc or Alo, not {}", .0.wire())]
338    ScaleTif(Tif),
339    #[error("a scale has {LEAST_LEVELS} to {MOST_LEVELS} levels, not {0}")]
340    Levels(u32),
341    #[error("a modify that does not always place is Alo or Gtc, not {}", .0.wire())]
342    ModifyTif(Tif),
343    #[error("spot takes no {0}")]
344    Spot(&'static str),
345    #[error("a notional needs a reference price to be sized at")]
346    NoReference,
347    #[error("a {} at {trigger} is on the wrong side of an entry at {entry}", tpsl_words(.tpsl))]
348    ExitSide { tpsl: Tpsl, trigger: Dec, entry: Dec },
349    #[error("there is no position here")]
350    NoPosition,
351    #[error("closing {size} is more than the position of {position}")]
352    CloseTooLarge { size: Dec, position: Dec },
353    #[error("leverage is a whole number from 1 to {most}, not {asked}")]
354    Leverage { asked: u32, most: u32 },
355    #[error("this market is isolated only")]
356    IsolatedOnly,
357    #[error("this position is cross; margin moves on isolated ones")]
358    CrossPosition,
359    #[error("order {0} is a trigger: change its trigger instead")]
360    NotALimit(u64),
361    #[error("order {0} is not a take profit or stop loss")]
362    NotATrigger(u64),
363    #[error("order {oid} is a {kind} trigger, which this build cannot modify")]
364    UnknownTrigger { oid: u64, kind: String },
365    #[error("order {0} has a size of its own; only a position's take profit or stop loss covers the whole position")]
366    NotWholePosition(u64),
367}
368
369fn worth(size: &Dec, price: &Dec) -> String {
370    size.checked_mul(*price).map_or_else(|| "too much to count".to_owned(), Dec::to_wire)
371}
372
373const fn tpsl_words(tpsl: &Tpsl) -> &'static str {
374    match tpsl {
375        Tpsl::TakeProfit => "take profit",
376        Tpsl::StopLoss => "stop loss",
377    }
378}
379
380fn positive(value: Dec, what: &'static str) -> Result<Dec, Refusal> {
381    match value.is_positive() {
382        true => Ok(value),
383        false => Err(Refusal::NotPositive { what, value }),
384    }
385}
386
387fn at_least(size: Dec, price: Dec, minimum: Dec) -> Result<(), Refusal> {
388    match price.checked_mul(size).is_some_and(|worth| worth >= minimum) {
389        true => Ok(()),
390        false => Err(Refusal::UnderMinimum { size, price, minimum }),
391    }
392}
393
394/// A trigger on the grid: to the nearest tick, since where it fires favours
395/// neither side.
396fn trigger_on(grid: Grid, trigger: Dec) -> Result<Dec, Refusal> {
397    let trigger = positive(trigger, "a trigger price")?;
398    grid.tick(trigger, Round::Nearest).ok_or(Refusal::Trade(TradeError::NoPrice(trigger)))
399}
400
401/// A limit on the grid for `side`, never against the trader.
402fn limit_on(grid: Grid, price: Dec, side: Side, what: &'static str) -> Result<Dec, Refusal> {
403    let price = positive(price, what)?;
404    grid.price(price, side).ok_or(Refusal::Trade(TradeError::NoPrice(price)))
405}
406
407/// The price a trigger order carries: its bound past the trigger at market,
408/// or its limit.
409fn fill_price(grid: Grid, trigger: Dec, price: ExitPrice, side: Side) -> Result<(Dec, bool), Refusal> {
410    match price {
411        ExitPrice::Market { slippage } => Ok((grid.market_bound(trigger, slippage, side)?, true)),
412        ExitPrice::Limit(price) => Ok((limit_on(grid, price, side, "a limit price")?, false)),
413    }
414}
415
416/// A reduce-only trigger closing `size` on `side`.
417fn exit_order(market: &Market, side: Side, size: Dec, tpsl: Tpsl, exit: Exit) -> Result<Order, Refusal> {
418    let trigger = trigger_on(market.grid, exit.trigger)?;
419    let (price, is_market) = fill_price(market.grid, trigger, exit.price, side)?;
420    Ok(Order::trigger(market.asset, side, price, size, trigger, tpsl, is_market).reduce_only())
421}
422
423/// `orders` through the builder, held to their strictest cap.
424fn routed(place: PlaceOrder, builder: Option<&BuilderSetting>) -> Result<PlaceOrder, Refusal> {
425    let Some(setting) = builder else { return Ok(place) };
426    match setting.on_assets(place.orders().iter().map(Order::asset)).map_err(TradeError::from)? {
427        Some(builder) => Ok(place.through(builder)),
428        None => Ok(place),
429    }
430}
431
432/// Whether `exit` fires on the side of `entry` it should: above it for a
433/// long's take profit and a short's stop, below it otherwise.
434fn on_its_side(side: Side, tpsl: Tpsl, trigger: Dec, entry: Dec) -> bool {
435    match (side, tpsl) {
436        (Side::Buy, Tpsl::TakeProfit) | (Side::Sell, Tpsl::StopLoss) => trigger > entry,
437        (Side::Buy, Tpsl::StopLoss) | (Side::Sell, Tpsl::TakeProfit) => trigger < entry,
438    }
439}
440
441/// An `order`: the entry, and its take profit and stop loss grouped `normalTpsl` when it has them.
442/// Each is worth at least [`MINIMUM_ORDER_VALUE`] in the quote, valued at its limit, at the
443/// reference at market, or at the trigger.
444pub fn order(market: &Market, intent: &OrderIntent, builder: Option<&BuilderSetting>) -> Result<PlaceOrder, Refusal> {
445    let has_exits = intent.take_profit.is_some() || intent.stop_loss.is_some();
446    if market.is_spot() && intent.reduce_only {
447        return Err(Refusal::Spot("reduce only"));
448    }
449    if market.is_spot() && has_exits {
450        return Err(Refusal::Spot("a take profit or stop loss"));
451    }
452    let grid = market.grid;
453    let side = intent.side;
454    let (price, valued_at, tif) = match intent.price {
455        Price::Market { reference, slippage } => {
456            let reference = positive(reference, "a reference price")?;
457            (grid.market_bound(reference, slippage, side)?, reference, Tif::FrontendMarket)
458        }
459        Price::Limit { tif: Tif::FrontendMarket, .. } => return Err(Refusal::MarketTif),
460        Price::Limit { price, tif } => {
461            let price = limit_on(grid, price, side, "a price")?;
462            (price, price, tif)
463        }
464    };
465    let size = intent.amount.size_on(grid, valued_at)?;
466    at_least(size, valued_at, MINIMUM_ORDER_VALUE)?;
467    let mut entry = match tif {
468        Tif::FrontendMarket => Order::market(market.asset, side, price, size),
469        tif => Order::limit(market.asset, side, price, size, tif),
470    };
471    if intent.reduce_only {
472        entry = entry.reduce_only();
473    }
474    if let Some(cloid) = intent.cloid {
475        entry = entry.with_cloid(cloid);
476    }
477    let mut orders = vec![entry];
478    for (tpsl, exit) in [(Tpsl::TakeProfit, intent.take_profit), (Tpsl::StopLoss, intent.stop_loss)] {
479        let Some(exit) = exit else { continue };
480        if !on_its_side(side, tpsl, exit.trigger, valued_at) {
481            return Err(Refusal::ExitSide { tpsl, trigger: exit.trigger, entry: valued_at });
482        }
483        at_least(size, trigger_on(grid, exit.trigger)?, MINIMUM_ORDER_VALUE)?;
484        orders.push(exit_order(market, side.other(), size, tpsl, exit)?);
485    }
486    let grouping = match has_exits {
487        true => Grouping::NormalTpsl,
488        false => Grouping::Na,
489    };
490    routed(PlaceOrder::new(orders).grouped(grouping), builder)
491}
492
493/// A take profit or stop loss on its own: reduce only, a fixed size grouped
494/// `na`, or the whole position at size 0 grouped `positionTpsl`.
495pub fn trigger(
496    market: &Market,
497    intent: &TriggerIntent,
498    builder: Option<&BuilderSetting>,
499) -> Result<PlaceOrder, Refusal> {
500    if market.is_spot() {
501        return Err(Refusal::Spot("a take profit or stop loss"));
502    }
503    let (size, grouping) = match intent.size {
504        Covers::Position => (Dec::ZERO, Grouping::PositionTpsl),
505        Covers::Size(amount) => {
506            let trigger = trigger_on(market.grid, intent.exit.trigger)?;
507            let size = amount.size_on(market.grid, trigger)?;
508            at_least(size, trigger, MINIMUM_ORDER_VALUE)?;
509            (size, Grouping::Na)
510        }
511    };
512    let mut order = exit_order(market, intent.side, size, intent.tpsl, intent.exit)?;
513    if let Some(cloid) = intent.cloid {
514        order = order.with_cloid(cloid);
515    }
516    routed(PlaceOrder::one(order).grouped(grouping), builder)
517}
518
519/// A scale: one `order` with an order a level. The last level is `to` itself.
520pub fn scale(market: &Market, intent: &Scale, builder: Option<&BuilderSetting>) -> Result<PlaceOrder, Refusal> {
521    if market.is_spot() && intent.reduce_only {
522        return Err(Refusal::Spot("reduce only"));
523    }
524    if !matches!(intent.tif, Tif::Gtc | Tif::Alo) {
525        return Err(Refusal::ScaleTif(intent.tif));
526    }
527    let levels = intent.levels;
528    if !(LEAST_LEVELS..=MOST_LEVELS).contains(&levels) {
529        return Err(Refusal::Levels(levels));
530    }
531    let grid = market.grid;
532    let total = positive(intent.size, "a size")?;
533    let from = positive(intent.from, "a from price")?;
534    let to = positive(intent.to, "a to price")?;
535    let count = Dec::from_int(i64::from(levels));
536    let raw = total.checked_div(count, 12, Round::Down).ok_or(Refusal::Trade(TradeError::NoSize(total)))?;
537    let each = grid.size(raw).ok_or(Refusal::Trade(TradeError::NoSize(raw)))?;
538    let span = to.checked_sub(from).ok_or(Refusal::Trade(TradeError::NoPrice(to)))?;
539    let step = span
540        .checked_div(Dec::from_int(i64::from(levels - 1)), 12, Round::Down)
541        .ok_or(Refusal::Trade(TradeError::NoPrice(to)))?;
542    let mut orders = Vec::new();
543    for at in 0..levels {
544        let raw = match at + 1 == levels {
545            true => to,
546            false => step
547                .checked_mul(Dec::from_int(i64::from(at)))
548                .and_then(|moved| from.checked_add(moved))
549                .ok_or(Refusal::Trade(TradeError::NoPrice(from)))?,
550        };
551        let price = limit_on(grid, raw, intent.side, "a level's price")?;
552        at_least(each, price, MINIMUM_ORDER_VALUE)?;
553        let order = Order::limit(market.asset, intent.side, price, each, intent.tif);
554        orders.push(match intent.reduce_only {
555            true => order.reduce_only(),
556            false => order,
557        });
558    }
559    routed(PlaceOrder::new(orders), builder)
560}
561
562/// A `twapOrder`. It carries no builder: the venue gives it no field for one.
563pub fn twap(market: &Market, intent: &TwapIntent) -> Result<Twap, Refusal> {
564    if market.is_spot() && intent.reduce_only {
565        return Err(Refusal::Spot("reduce only"));
566    }
567    let size = match (intent.amount, intent.reference) {
568        (Amount::Notional(_), None) => return Err(Refusal::NoReference),
569        (amount, Some(reference)) => amount.size_on(market.grid, positive(reference, "a reference price")?)?,
570        (Amount::Size(size), None) => {
571            let size = positive(size, "a size")?;
572            market.grid.size(size).ok_or(Refusal::Trade(TradeError::NoSize(size)))?
573        }
574    };
575    if let Some(reference) = intent.reference {
576        at_least(size, reference, MINIMUM_TWAP_VALUE)?;
577    }
578    let twap = Twap::new(market.asset, intent.side, size, intent.minutes)?;
579    let twap = match intent.reduce_only {
580        true => twap.reduce_only(),
581        false => twap,
582    };
583    Ok(match intent.randomize {
584        true => twap.randomized(),
585        false => twap,
586    })
587}
588
589/// A resting order replaced in place, as `frontendOpenOrders` lists it. Its
590/// side and reduce only stay; a take profit or stop loss always places, as
591/// the venue requires of a trigger.
592pub fn modify(market: &Market, order: &FrontendOrder, change: &Change) -> Result<BatchModify, Refusal> {
593    let grid = market.grid;
594    let side = order.side;
595    let new = match *change {
596        Change::Limit { .. } if order.is_trigger => return Err(Refusal::NotALimit(order.oid)),
597        Change::Limit { tif, .. } if !matches!(tif, Tif::Alo | Tif::Gtc) => return Err(Refusal::ModifyTif(tif)),
598        Change::Limit { price, size, tif } => {
599            let price = limit_on(grid, price, side, "a new price")?;
600            let size = size.size_on(grid, price)?;
601            at_least(size, price, MINIMUM_ORDER_VALUE)?;
602            Order::limit(market.asset, side, price, size, tif)
603        }
604        Change::Trigger { .. } if !order.is_trigger => return Err(Refusal::NotATrigger(order.oid)),
605        Change::Trigger { trigger, price, size } => {
606            let Some(tpsl) = order.tpsl() else {
607                return Err(Refusal::UnknownTrigger { oid: order.oid, kind: order.order_type.word().to_lowercase() });
608            };
609            let trigger = trigger_on(grid, trigger)?;
610            let size = match size {
611                Covers::Position if order.sz.is_zero() => Dec::ZERO,
612                Covers::Position => return Err(Refusal::NotWholePosition(order.oid)),
613                Covers::Size(amount) => {
614                    let size = amount.size_on(grid, trigger)?;
615                    at_least(size, trigger, MINIMUM_ORDER_VALUE)?;
616                    size
617                }
618            };
619            let (price, is_market) = fill_price(grid, trigger, price, side)?;
620            Order::trigger(market.asset, side, price, size, trigger, tpsl, is_market)
621        }
622    };
623    let new = match order.reduce_only {
624        true => new.reduce_only(),
625        false => new,
626    };
627    Ok(BatchModify::new(vec![Modify::new(OrderRef::Oid(order.oid), new)]))
628}
629
630/// A position closed at market: reduce only, the other way, `FrontendMarket`.
631/// A whole position closes whatever it is worth; part of one is held to the
632/// minimum as any order is.
633pub fn close(market: &Market, intent: &Close, builder: Option<&BuilderSetting>) -> Result<PlaceOrder, Refusal> {
634    if market.is_spot() {
635        return Err(Refusal::Spot("a position"));
636    }
637    if intent.position.is_zero() {
638        return Err(Refusal::NoPosition);
639    }
640    let side = match intent.position.is_positive() {
641        true => Side::Sell,
642        false => Side::Buy,
643    };
644    let held = intent.position.abs();
645    let wanted = match intent.size {
646        None => held,
647        Some(size) if positive(size, "a size")? > held => {
648            return Err(Refusal::CloseTooLarge { size, position: held });
649        }
650        Some(size) => size,
651    };
652    let size = market.grid.size(wanted).ok_or(Refusal::Trade(TradeError::NoSize(wanted)))?;
653    let reference = positive(intent.reference, "a reference price")?;
654    let bound = market.grid.market_bound(reference, intent.slippage, side)?;
655    if size != held {
656        at_least(size, reference, MINIMUM_ORDER_VALUE)?;
657    }
658    let order = Order::market(market.asset, side, bound, size).reduce_only();
659    let order = match intent.cloid {
660        Some(cloid) => order.with_cloid(cloid),
661        None => order,
662    };
663    routed(PlaceOrder::one(order), builder)
664}
665
666/// `updateLeverage`, held to the market's ceiling and margin mode.
667pub fn leverage(market: &Market, mode: MarginMode, leverage: u32) -> Result<UpdateLeverage, Refusal> {
668    let Some(most) = market.max_leverage else {
669        return Err(Refusal::Spot("leverage"));
670    };
671    if leverage == 0 || leverage > most {
672        return Err(Refusal::Leverage { asked: leverage, most });
673    }
674    if mode == MarginMode::Cross && !market.allows_cross {
675        return Err(Refusal::IsolatedOnly);
676    }
677    Ok(UpdateLeverage::new(market.asset, mode, leverage)?)
678}
679
680/// `updateIsolatedMargin` on an isolated `position` (signed `szi`): `usdc`
681/// above zero adds margin, below zero takes it out.
682pub fn margin(market: &Market, position: Dec, mode: MarginMode, usdc: Dec) -> Result<UpdateIsolatedMargin, Refusal> {
683    if market.is_spot() {
684        return Err(Refusal::Spot("margin"));
685    }
686    if position.is_zero() {
687        return Err(Refusal::NoPosition);
688    }
689    if mode == MarginMode::Cross {
690        return Err(Refusal::CrossPosition);
691    }
692    let side = match position.is_positive() {
693        true => Side::Buy,
694        false => Side::Sell,
695    };
696    Ok(UpdateIsolatedMargin::new(market.asset, side, usdc)?)
697}
698
699#[cfg(test)]
700#[path = "tests/ticket.rs"]
701mod tests;