Skip to main content

rustrade_execution/order/
request.rs

1use crate::{
2    error::OrderError,
3    margin::MarginEffect,
4    market::MarketSnapshot,
5    order::{
6        Order, OrderEvent, OrderKind, TimeInForce,
7        id::{PositionId, VenueOrderId},
8        state::{Cancelled, UnindexedOrderState},
9    },
10};
11use derive_more::Constructor;
12use rust_decimal::Decimal;
13use rustrade_instrument::{
14    Side,
15    asset::{AssetIndex, name::AssetNameExchange},
16    exchange::{ExchangeId, ExchangeIndex},
17    instrument::{InstrumentIndex, name::InstrumentNameExchange},
18};
19use serde::{Deserialize, Deserializer, Serialize};
20use std::fmt;
21
22pub type OrderRequestOpen<ExchangeKey = ExchangeIndex, InstrumentKey = InstrumentIndex> =
23    OrderEvent<RequestOpen, ExchangeKey, InstrumentKey>;
24
25pub type OrderRequestCancel<ExchangeKey = ExchangeIndex, InstrumentKey = InstrumentIndex> =
26    OrderEvent<RequestCancel, ExchangeKey, InstrumentKey>;
27
28pub type OrderResponseCancel<
29    ExchangeKey = ExchangeIndex,
30    AssetKey = AssetIndex,
31    InstrumentKey = InstrumentIndex,
32> = OrderEvent<Result<Cancelled, OrderError<AssetKey, InstrumentKey>>, ExchangeKey, InstrumentKey>;
33
34pub type UnindexedOrderResponseCancel =
35    OrderResponseCancel<ExchangeId, AssetNameExchange, InstrumentNameExchange>;
36
37/// Parameters for opening a new order.
38///
39/// # Warning: `reduce_only` Default Behavior
40///
41/// The `reduce_only` field defaults to [`ReduceOnly::No`], which means:
42/// - A `Sell` order defaults to `SellToOpen` (open short / write option)
43/// - A `Buy` order defaults to `BuyToOpen` (open long)
44///
45/// **For closing positions, callers MUST explicitly set `reduce_only` to
46/// [`ReduceOnly::Position`]** (or [`ReduceOnly::PositionAndFreeBase`]).
47/// Failure to do so on non-crypto instruments (equities, options) will:
48/// - On non-margin accounts: result in a 422 rejection from the exchange
49/// - On margin accounts: silently open a short position instead of closing the long
50///
51/// The `close_open_positions_with_market_orders` helper sets this correctly. A caller building
52/// its own closing request must set it with [`with_reduce_only`](Self::with_reduce_only).
53///
54/// `#[non_exhaustive]`, so a field can be added without breaking a caller. Outside this crate,
55/// build one with [`RequestOpen::new`] and the `with_*` methods, and change a copy by assigning
56/// its public fields.
57#[non_exhaustive]
58#[derive(Debug, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Deserialize, Serialize)]
59pub struct RequestOpen {
60    pub side: Side,
61    /// Limit price for the order. Required for Limit/StopLimit orders, and for
62    /// TrailingStopFixedLimit orders, whose limit stays fixed while the trigger trails.
63    /// `None` for Market/Stop/TrailingStop orders (which execute at market price when triggered),
64    /// and for TrailingStopLimit orders, whose limit price follows from their stop and
65    /// `limit_offset`. A price given with a Market order anyway is ignored, by the simulated venue
66    /// and by the live clients that accept market orders.
67    pub price: Option<Decimal>,
68    pub quantity: Decimal,
69    pub kind: OrderKind,
70    pub time_in_force: TimeInForce,
71    /// Target `PositionId` for this order in `OmsMode::Hedging`.
72    ///
73    /// For opening orders: the position this fill should open or add to.
74    /// For closing orders: the position this fill should reduce or close.
75    /// In `OmsMode::Netting`, leave as `None` (ignored).
76    #[serde(default)]
77    pub position_id: Option<PositionId>,
78    /// Constrain this order to only reduce existing positions, never open new ones: see
79    /// [`ReduceOnly`].
80    ///
81    /// Used by exchanges that require explicit open/close intent (Alpaca, Interactive
82    /// Brokers, Schwab). Adapters derive venue-specific semantics from
83    /// [`ReduceOnly::is_reduce_only`] + `side`, so both reduce-only values map alike:
84    /// - not reduce-only, Buy  → BuyToOpen (open long / add to long)
85    /// - not reduce-only, Sell → SellToOpen (open short / write option)
86    /// - reduce-only, Buy      → BuyToClose (close short)
87    /// - reduce-only, Sell     → SellToClose (close long)
88    ///
89    /// Exchanges that infer intent from positions (Binance, Deribit) may map this to
90    /// their `reduceOnly` parameter or ignore it entirely.
91    ///
92    /// The `rustrade` engine enforces it on fills, whatever the venue did with it: a fill of a
93    /// reduce-only order never grows or flips the engine's position. A fill larger than the
94    /// opposite position is cut so the position lands flat, and the excess is reported rather
95    /// than applied. See [`Trade::holding_move`](crate::trade::Trade::holding_move).
96    ///
97    /// `#[serde(default)]` so requests serialised before this field existed still load, and a
98    /// `true` or `false` serialised before it was a [`ReduceOnly`] loads as
99    /// [`ReduceOnly::Position`] or [`ReduceOnly::No`].
100    #[serde(default)]
101    pub reduce_only: ReduceOnly,
102
103    /// Market state observed at the instant this request was created, if any was sampled.
104    ///
105    /// This is **decision-time provenance**, not an instruction to the venue. It records what the
106    /// sender was looking at when it chose to trade, which is the one price a fill can be measured
107    /// against after the fact — implementation shortfall is the difference between this and the
108    /// price actually obtained.
109    ///
110    /// # Live venues must ignore this field
111    ///
112    /// No live exchange adapter reads it and none should: a venue prices a fill from its own book,
113    /// and an adapter that forwarded these prices would be sending the client's view of the market
114    /// back as an instruction. Every adapter in this crate leaves it untouched.
115    ///
116    /// # A simulated venue fills from it only when it has no book of its own
117    ///
118    /// [`SimulatedVenue`](crate::exchange::mock::SimulatedVenue) reads this **only** in
119    /// [`VenueRegime::RequestPriced`](crate::exchange::mock::venue::VenueRegime::RequestPriced), where it
120    /// has no market feed and this snapshot is its only price source. That is the regime
121    /// [`MockExecution`](crate::client::mock::MockExecution) and
122    /// [`MockExchange`](crate::exchange::mock::MockExchange) use: the request's snapshot is priced
123    /// through the venue's [`FillModel`](crate::fill::FillModel), and without it a market order
124    /// has no limit price to fall back on — the venue ignores `price` on an
125    /// [`OrderKind::Market`] — so the venue can only reject.
126    ///
127    /// A [`MarketDriven`](crate::exchange::mock::venue::VenueRegime::MarketDriven) venue ignores it and
128    /// prices from its own book, like a live one. This field is then decision-time provenance
129    /// alone, and the difference between it and the price obtained is implementation shortfall.
130    ///
131    /// Sampling it at creation rather than at fill time is deliberate. It is the one instant with a
132    /// well-defined position on a simulated timeline *for the sender*: everything the sender had
133    /// seen by then is in it, and nothing it had not seen can be. What makes it safe for a venue to
134    /// hold a book instead is that its driver paces the feed — routing each market event to the
135    /// venue before the client that reacts to it sees the same event, and booking each request at
136    /// the instant it arrives. A venue that sampled a stream it shares with the engine but does not
137    /// pace could fill against prices from arbitrarily far in the future, which is why the regime
138    /// is a property of how the venue is driven rather than a configuration flag.
139    ///
140    /// # `None` versus an empty snapshot
141    ///
142    /// `None` means no snapshot was taken — a live path, or a caller that constructed the request
143    /// directly. `Some` holding a [`MarketSnapshot::is_empty`] snapshot means one was taken and the
144    /// instrument had no price yet, which is what a cold start looks like. A `RequestPriced`
145    /// simulated venue distinguishes the two in its rejection, because the fixes differ.
146    ///
147    /// `#[serde(default)]` so requests serialised before this field existed still load.
148    #[serde(default)]
149    pub market: Option<MarketSnapshot>,
150
151    /// What this order does to a margin account's loans, or `None` to take the client's default.
152    ///
153    /// Only a margin client or a simulated venue with spot margin acts on a borrow or a repay; see
154    /// [`MarginEffect`] for the three values. A client that cannot honour a `Some` refuses the
155    /// order unsent with
156    /// [`OrderError::UnsupportedMarginEffect`]:
157    /// a cash or spot-only account takes `Some(NoSideEffect)` alone, and a client that cannot
158    /// promise it (account-level margin, or implicit borrowing as under Hyperliquid's portfolio
159    /// margin) takes no `Some` at all, `NoSideEffect` included. A caller that sets an effect on
160    /// every order must leave it `None` for those venues.
161    ///
162    /// `#[serde(default)]` so requests serialised before this field existed still load.
163    #[serde(default)]
164    pub margin_effect: Option<MarginEffect>,
165}
166
167impl RequestOpen {
168    /// A request with no [`position_id`](Self::position_id), not
169    /// [`reduce_only`](Self::reduce_only), and no [`market`](Self::market) snapshot. Set those
170    /// with the `with_*` methods.
171    pub fn new(
172        side: Side,
173        price: Option<Decimal>,
174        quantity: Decimal,
175        kind: OrderKind,
176        time_in_force: TimeInForce,
177    ) -> Self {
178        Self {
179            side,
180            price,
181            quantity,
182            kind,
183            time_in_force,
184            position_id: None,
185            reduce_only: ReduceOnly::No,
186            market: None,
187            margin_effect: None,
188        }
189    }
190
191    /// This request with the [`position_id`](Self::position_id) it targets in
192    /// `OmsMode::Hedging`.
193    #[must_use]
194    pub fn with_position_id(self, position_id: PositionId) -> Self {
195        Self {
196            position_id: Some(position_id),
197            ..self
198        }
199    }
200
201    /// This request with its [`reduce_only`](Self::reduce_only) set to `reduce_only`.
202    #[must_use]
203    pub fn with_reduce_only(self, reduce_only: ReduceOnly) -> Self {
204        Self {
205            reduce_only,
206            ..self
207        }
208    }
209
210    /// This request with the [`market`](Self::market) its sender observed when it created it.
211    #[must_use]
212    pub fn with_market(self, market: MarketSnapshot) -> Self {
213        Self {
214            market: Some(market),
215            ..self
216        }
217    }
218
219    /// This request with its [`margin_effect`](Self::margin_effect) set to `margin_effect`,
220    /// instead of the client's default.
221    #[must_use]
222    pub fn with_margin_effect(self, margin_effect: MarginEffect) -> Self {
223        Self {
224            margin_effect: Some(margin_effect),
225            ..self
226        }
227    }
228}
229
230/// Whether an open order may only reduce the position it closes, and whether it may also trade
231/// the account's free base beyond it. Set on [`RequestOpen::reduce_only`].
232///
233/// A venue that takes an open or close intent, or a reduce-only flag, is sent the same for both
234/// reduce-only values: see [`Self::is_reduce_only`]. They differ in how the `rustrade` engine
235/// books a fill larger than the position. It cuts the fill so the position lands flat either way
236/// (see [`Trade::holding_move`](crate::trade::Trade::holding_move)), and then:
237/// - for [`Position`](Self::Position), reads the excess as a mistake: logged at WARN, and
238///   counted as a reduce-only fill cut;
239/// - for [`PositionAndFreeBase`](Self::PositionAndFreeBase) on a spot instrument, reads it as a
240///   trade of free base the order was sized to make, when the fill moved against the position or
241///   the position was flat: logged at INFO, and counted apart. A fill that only grows the
242///   position is read as a mistake, as for [`Position`](Self::Position).
243///
244/// # Serialised form
245/// `"no"`, `"position"` or `"position_and_free_base"`. In a human-readable format such as JSON, a
246/// `false` or `true`, the form before this type replaced a flag, loads as [`No`](Self::No) or
247/// [`Position`](Self::Position). A binary format, which cannot say which of the two it holds,
248/// reads the variant alone, as serde derives it.
249///
250/// `#[non_exhaustive]`, so a value can be added without breaking a caller. An adapter reads
251/// [`Self::is_reduce_only`] rather than matching the values.
252#[non_exhaustive]
253#[derive(Debug, Clone, Copy, Default, Eq, PartialEq, Ord, PartialOrd, Hash)]
254pub enum ReduceOnly {
255    /// The order may open or grow a position.
256    #[default]
257    No,
258    /// The order only reduces the position it closes. Excess over the position is a mistake.
259    Position,
260    /// The order reduces the position it closes, and may trade base beyond it out of, or into,
261    /// the account's free balance. Meant for spot instruments, spot margin included.
262    ///
263    /// The excess must continue the way the order reduces: past the position, or from flat.
264    /// - A sell above a long, or from flat, sells free base: the base-fee remainder that a spot
265    ///   position leaves out (see the `rustrade` engine's `EngineState::free_base`), or base the
266    ///   account holds for another reason.
267    /// - A buy above a short, or from flat, buys base the account owes outside the position, as
268    ///   one sized to cover a short's loan and its interest in base does.
269    ///
270    /// Size it from the base the account holds, or owes, outside the position, so the excess
271    /// never sells more than the account has. The engine does not check the size: it takes the
272    /// excess as the order was sized. A fill that only grows the position, a buy while long or a
273    /// sell while short, reduces nothing, and is booked as for [`Position`](Self::Position).
274    ///
275    /// A venue that enforces reduce-only itself, rather than ignoring it, may refuse or trim
276    /// the excess. On a derivative, which has no free base, the engine books excess as for
277    /// [`Position`](Self::Position).
278    PositionAndFreeBase,
279}
280
281impl ReduceOnly {
282    /// Whether the order may only reduce a position: anything but [`No`](Self::No). The intent
283    /// or flag an adapter sends the venue.
284    pub const fn is_reduce_only(self) -> bool {
285        !matches!(self, Self::No)
286    }
287}
288
289/// [`ReduceOnly`]'s serialised form: every value is written through it, and read back through it
290/// in a format that is not human-readable. A binary format stores a variant's index, so a value
291/// is only ever added at the end, and never reordered or removed.
292#[derive(Deserialize, Serialize)]
293#[serde(rename = "ReduceOnly", rename_all = "snake_case")]
294enum ReduceOnlyVariant {
295    No,
296    Position,
297    PositionAndFreeBase,
298}
299
300// Matched exhaustively, so a value added to `ReduceOnly` does not build until it is added here.
301impl From<ReduceOnly> for ReduceOnlyVariant {
302    fn from(reduce_only: ReduceOnly) -> Self {
303        match reduce_only {
304            ReduceOnly::No => Self::No,
305            ReduceOnly::Position => Self::Position,
306            ReduceOnly::PositionAndFreeBase => Self::PositionAndFreeBase,
307        }
308    }
309}
310
311impl From<ReduceOnlyVariant> for ReduceOnly {
312    fn from(variant: ReduceOnlyVariant) -> Self {
313        match variant {
314            ReduceOnlyVariant::No => Self::No,
315            ReduceOnlyVariant::Position => Self::Position,
316            ReduceOnlyVariant::PositionAndFreeBase => Self::PositionAndFreeBase,
317        }
318    }
319}
320
321impl Serialize for ReduceOnly {
322    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
323        ReduceOnlyVariant::from(*self).serialize(serializer)
324    }
325}
326
327impl<'de> Deserialize<'de> for ReduceOnly {
328    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
329        struct ReduceOnlyVisitor;
330
331        impl serde::de::Visitor<'_> for ReduceOnlyVisitor {
332            type Value = ReduceOnly;
333
334            fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
335                formatter.write_str(r#""no", "position", "position_and_free_base", or a bool"#)
336            }
337
338            fn visit_bool<E: serde::de::Error>(self, reduce_only: bool) -> Result<Self::Value, E> {
339                Ok(if reduce_only {
340                    ReduceOnly::Position
341                } else {
342                    ReduceOnly::No
343                })
344            }
345
346            fn visit_str<E: serde::de::Error>(self, name: &str) -> Result<Self::Value, E> {
347                match name {
348                    "no" => Ok(ReduceOnly::No),
349                    "position" => Ok(ReduceOnly::Position),
350                    "position_and_free_base" => Ok(ReduceOnly::PositionAndFreeBase),
351                    _ => Err(E::unknown_variant(
352                        name,
353                        &["no", "position", "position_and_free_base"],
354                    )),
355                }
356            }
357        }
358
359        // Only a self-describing format can say whether it holds a flag or a name.
360        if deserializer.is_human_readable() {
361            deserializer.deserialize_any(ReduceOnlyVisitor)
362        } else {
363            ReduceOnlyVariant::deserialize(deserializer).map(Self::from)
364        }
365    }
366}
367
368#[derive(
369    Debug, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Default, Deserialize, Serialize, Constructor,
370)]
371pub struct RequestCancel {
372    /// How the venue addresses the order to cancel, when that is known.
373    ///
374    /// Three cases, and a client must distinguish all three:
375    /// - `None` -- the order is still in flight, so the venue has acknowledged nothing and there
376    ///   is no identifier to cancel by.
377    /// - `Some(VenueOrderId::Assigned(id))` -- cancel by the venue's identifier.
378    /// - `Some(VenueOrderId::ClientAssigned)` -- the venue accepted the order without assigning an
379    ///   identifier; cancel by the `ClientOrderId` in the request's key.
380    ///
381    /// Collapsing the last two into `None` would leave a client unable to tell "nothing to cancel"
382    /// from "cancel by client id".
383    pub id: Option<VenueOrderId>,
384}
385
386/// Cancels on one exchange, and the opens to send only once every one of them has been answered.
387///
388/// For a pair of requests that must not race: an exit that a resting stop would block or flip, or a
389/// stop moved to a new trigger without two ever resting at once. Requests sent separately reach the
390/// venue in no guaranteed order. A group's opens are held back until each of its cancels has its
391/// answer, and are then sent, or dropped, as [`on_cancel_failure`](Self::on_cancel_failure) says.
392///
393/// # What is and is not guaranteed
394/// - **Order.** No open leaves before every cancel in its group has been answered. The cancels go
395///   out together, and so do the opens.
396/// - **Not atomic.** Between the cancels' answers and the opens' arrival, nothing in the group
397///   rests: a stop being moved leaves its position unprotected for one round trip to the venue.
398///   A group of one cancel and one open that the client can send as one venue request (see
399///   [`ExecutionClient::cancel_replace`](crate::client::ExecutionClient::cancel_replace)) is sent
400///   that way, which takes that round trip out. The venue still cancels before it places, so even
401///   then the two never rest together and the open is not placed atomically with the cancel.
402/// - **Only within the group.** Requests outside it, including other groups, are not ordered
403///   against it.
404/// - **A held open is in flight.** It has not left yet, so a cancel sent for it while it is held
405///   reaches the venue first and finds no order, as for any open whose request is still in
406///   flight.
407/// - **One answer per leg.** Each cancel is answered as a cancel sent alone would be. Each open is
408///   answered as an open sent alone would be, or, when it is dropped, with an inactive order whose
409///   error is [`NotPlacedReason::CancelFailed`](crate::error::NotPlacedReason::CancelFailed).
410///
411/// # Valid groups
412/// At least one cancel, at least one open, and every leg on one exchange: see
413/// [`exchange`](Self::exchange).
414///
415/// `#[non_exhaustive]`, so a field can be added without breaking a caller. Outside this crate,
416/// build one with [`CancelThenOpen::new`] and [`with_on_cancel_failure`](Self::with_on_cancel_failure).
417#[non_exhaustive]
418#[derive(Debug, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Deserialize, Serialize)]
419pub struct CancelThenOpen<ExchangeKey = ExchangeIndex, InstrumentKey = InstrumentIndex> {
420    /// The cancels to send first, together.
421    pub cancels: Vec<OrderRequestCancel<ExchangeKey, InstrumentKey>>,
422    /// The opens to send once every cancel has been answered.
423    pub opens: Vec<OrderRequestOpen<ExchangeKey, InstrumentKey>>,
424    /// What to do with the opens when a cancel fails.
425    ///
426    /// `#[serde(default)]`, so a group serialised without it loads with the safe default.
427    #[serde(default)]
428    pub on_cancel_failure: OnCancelFailure,
429}
430
431impl<ExchangeKey, InstrumentKey> CancelThenOpen<ExchangeKey, InstrumentKey> {
432    /// Send `cancels`, then `opens` once every cancel has been answered, dropping the opens if a
433    /// cancel fails ([`OnCancelFailure::DropOpens`]).
434    pub fn new(
435        cancels: Vec<OrderRequestCancel<ExchangeKey, InstrumentKey>>,
436        opens: Vec<OrderRequestOpen<ExchangeKey, InstrumentKey>>,
437    ) -> Self {
438        Self {
439            cancels,
440            opens,
441            on_cancel_failure: OnCancelFailure::default(),
442        }
443    }
444
445    /// Set what to do with the opens when a cancel fails.
446    #[must_use]
447    pub fn with_on_cancel_failure(self, on_cancel_failure: OnCancelFailure) -> Self {
448        Self {
449            on_cancel_failure,
450            ..self
451        }
452    }
453
454    /// The exchange every leg is on.
455    ///
456    /// # Errors
457    /// A [`CancelThenOpenError`] if the group has no cancel, has no open, or names more than one
458    /// exchange.
459    pub fn exchange(&self) -> Result<&ExchangeKey, CancelThenOpenError>
460    where
461        ExchangeKey: PartialEq,
462    {
463        let first = self.cancels.first().ok_or(CancelThenOpenError::NoCancels)?;
464        if self.opens.is_empty() {
465            return Err(CancelThenOpenError::NoOpens);
466        }
467        let exchange = &first.key.exchange;
468        let one_exchange = self
469            .cancels
470            .iter()
471            .map(|cancel| &cancel.key.exchange)
472            .chain(self.opens.iter().map(|open| &open.key.exchange))
473            .all(|leg| leg == exchange);
474        if one_exchange {
475            Ok(exchange)
476        } else {
477            Err(CancelThenOpenError::MixedExchanges)
478        }
479    }
480}
481
482/// What a [`CancelThenOpen`] group does with its opens when one of its cancels fails.
483///
484/// A cancel fails when its answer is any error: the order already filled, already cancelled or
485/// expired, not found, a rejection, or a timeout. The likeliest failure is that the order has
486/// already filled, which is exactly when sending an exit or a replacement is wrong, hence the
487/// default.
488///
489/// Deliberately exhaustive: a new policy is a breaking change, so no code that sequences a group
490/// falls back to one it was not written for.
491#[derive(
492    Debug, Clone, Copy, Eq, PartialEq, Ord, PartialOrd, Hash, Default, Deserialize, Serialize,
493)]
494pub enum OnCancelFailure {
495    /// Send none of the opens. Each is answered with an inactive order whose error is
496    /// [`NotPlacedReason::CancelFailed`](crate::error::NotPlacedReason::CancelFailed), naming the
497    /// cancel that failed.
498    #[default]
499    DropOpens,
500    /// Send the opens anyway, once every cancel has been answered.
501    SendOpens,
502}
503
504/// Why a [`CancelThenOpen`] group is not valid.
505#[non_exhaustive]
506#[derive(
507    Debug,
508    Clone,
509    Copy,
510    Eq,
511    PartialEq,
512    Ord,
513    PartialOrd,
514    Hash,
515    Deserialize,
516    Serialize,
517    thiserror::Error,
518)]
519pub enum CancelThenOpenError {
520    /// The group has no cancel, so there is nothing for its opens to wait on.
521    #[error("a CancelThenOpen group needs at least one cancel")]
522    NoCancels,
523    /// The group has no open.
524    #[error("a CancelThenOpen group needs at least one open")]
525    NoOpens,
526    /// The legs name more than one exchange. A group is sequenced by one exchange's execution
527    /// link, which cannot order requests sent through another.
528    #[error("every leg of a CancelThenOpen group must be on one exchange")]
529    MixedExchanges,
530}
531
532/// The answer to a cancel and an open that a venue carried out as one request: see
533/// [`ExecutionClient::cancel_replace`](crate::client::ExecutionClient::cancel_replace).
534///
535/// One answer per leg, each as the leg sent alone would have been answered.
536#[derive(Debug, Clone, PartialEq)]
537pub struct CancelReplaceAnswer {
538    /// The cancel's answer, as [`cancel_order`](crate::client::ExecutionClient::cancel_order)
539    /// would have given it.
540    pub cancel: UnindexedOrderResponseCancel,
541    /// The open's answer, as [`open_order`](crate::client::ExecutionClient::open_order) would
542    /// have given it.
543    ///
544    /// `None` only when the cancel failed under [`OnCancelFailure::DropOpens`] and the venue did
545    /// not attempt the open. The open was not placed, and is answered as a dropped open of a
546    /// [`CancelThenOpen`] group is.
547    pub open: Option<Order<ExchangeId, InstrumentNameExchange, UnindexedOrderState>>,
548}
549
550#[cfg(test)]
551mod tests {
552    use super::*;
553    use crate::order::{
554        OrderKey,
555        id::{ClientOrderId, StrategyId},
556    };
557    use rust_decimal_macros::dec;
558
559    fn limit() -> RequestOpen {
560        RequestOpen::new(
561            Side::Sell,
562            Some(dec!(101)),
563            dec!(2),
564            OrderKind::Limit,
565            TimeInForce::GoodUntilCancelled { post_only: false },
566        )
567    }
568
569    #[test]
570    fn new_sets_the_given_fields_and_defaults_the_rest() {
571        let request = limit();
572
573        assert_eq!(request.side, Side::Sell);
574        assert_eq!(request.price, Some(dec!(101)));
575        assert_eq!(request.quantity, dec!(2));
576        assert_eq!(request.kind, OrderKind::Limit);
577        assert_eq!(
578            request.time_in_force,
579            TimeInForce::GoodUntilCancelled { post_only: false }
580        );
581        assert_eq!(request.position_id, None);
582        assert_eq!(request.reduce_only, ReduceOnly::No);
583        assert_eq!(request.market, None);
584        assert_eq!(request.margin_effect, None);
585    }
586
587    #[test]
588    #[allow(clippy::unwrap_used)] // Test code: panics are the correct failure mode
589    fn a_request_serialised_without_a_margin_effect_still_loads() {
590        let mut json = serde_json::to_value(limit()).unwrap();
591        json.as_object_mut().unwrap().remove("margin_effect");
592
593        let request: RequestOpen = serde_json::from_value(json).unwrap();
594
595        assert_eq!(request.margin_effect, None);
596    }
597
598    #[test]
599    #[allow(clippy::unwrap_used)] // Test code: panics are the correct failure mode
600    fn reduce_only_round_trips_by_name_and_loads_a_flag() {
601        for (reduce_only, name) in [
602            (ReduceOnly::No, "no"),
603            (ReduceOnly::Position, "position"),
604            (ReduceOnly::PositionAndFreeBase, "position_and_free_base"),
605        ] {
606            let json = serde_json::to_value(limit().with_reduce_only(reduce_only)).unwrap();
607            assert_eq!(json["reduce_only"], name);
608            let loaded: RequestOpen = serde_json::from_value(json).unwrap();
609            assert_eq!(loaded.reduce_only, reduce_only);
610        }
611
612        for (flag, reduce_only) in [(false, ReduceOnly::No), (true, ReduceOnly::Position)] {
613            let mut json = serde_json::to_value(limit()).unwrap();
614            json["reduce_only"] = flag.into();
615            let loaded: RequestOpen = serde_json::from_value(json).unwrap();
616            assert_eq!(loaded.reduce_only, reduce_only);
617        }
618
619        let mut json = serde_json::to_value(limit()).unwrap();
620        json.as_object_mut().unwrap().remove("reduce_only");
621        let loaded: RequestOpen = serde_json::from_value(json).unwrap();
622        assert_eq!(loaded.reduce_only, ReduceOnly::No);
623
624        let mut json = serde_json::to_value(limit()).unwrap();
625        json["reduce_only"] = "sometimes".into();
626        let error = serde_json::from_value::<RequestOpen>(json).unwrap_err();
627        assert!(error.to_string().contains("sometimes"), "{error}");
628
629        // Neither a name nor a flag: the error says what would have been read.
630        let mut json = serde_json::to_value(limit()).unwrap();
631        json["reduce_only"] = 1.into();
632        let error = serde_json::from_value::<RequestOpen>(json).unwrap_err();
633        assert!(
634            error
635                .to_string()
636                .contains(r#""no", "position", "position_and_free_base", or a bool"#),
637            "{error}"
638        );
639    }
640
641    /// A deserializer that reads its inner one as a binary format would: not human-readable, so
642    /// it reads a value by the shape asked for, as an enum.
643    struct NotHumanReadable<D>(D);
644
645    impl<'de, D: Deserializer<'de>> Deserializer<'de> for NotHumanReadable<D> {
646        type Error = D::Error;
647
648        fn deserialize_any<V: serde::de::Visitor<'de>>(
649            self,
650            _: V,
651        ) -> Result<V::Value, Self::Error> {
652            Err(serde::de::Error::custom(
653                "a binary format cannot deserialize_any",
654            ))
655        }
656
657        fn deserialize_enum<V: serde::de::Visitor<'de>>(
658            self,
659            name: &'static str,
660            variants: &'static [&'static str],
661            visitor: V,
662        ) -> Result<V::Value, Self::Error> {
663            self.0.deserialize_enum(name, variants, visitor)
664        }
665
666        fn is_human_readable(&self) -> bool {
667            false
668        }
669
670        serde::forward_to_deserialize_any! {
671            bool i8 i16 i32 i64 i128 u8 u16 u32 u64 u128 f32 f64 char str string bytes byte_buf
672            option unit unit_struct newtype_struct seq tuple tuple_struct map struct identifier
673            ignored_any
674        }
675    }
676
677    #[test]
678    #[allow(clippy::unwrap_used)] // Test code: panics are the correct failure mode
679    fn a_binary_format_reads_reduce_only_as_a_variant() {
680        for reduce_only in [
681            ReduceOnly::No,
682            ReduceOnly::Position,
683            ReduceOnly::PositionAndFreeBase,
684        ] {
685            let written = serde_json::to_value(reduce_only).unwrap();
686            assert_eq!(
687                ReduceOnly::deserialize(NotHumanReadable(&written)).unwrap(),
688                reduce_only
689            );
690        }
691        // A flag has no variant to read.
692        assert!(ReduceOnly::deserialize(NotHumanReadable(&serde_json::Value::Bool(true))).is_err());
693    }
694
695    /// A binary format stores a variant's index, not its name, so the indices are pinned: a
696    /// reordered value would read stored data back as another.
697    #[test]
698    #[allow(clippy::unwrap_used)] // Test code: panics are the correct failure mode
699    fn a_binary_format_reads_reduce_only_by_its_variant_index() {
700        use serde::de::value::{Error, U32Deserializer};
701
702        let read =
703            |index| ReduceOnly::deserialize(NotHumanReadable(U32Deserializer::<Error>::new(index)));
704        assert_eq!(read(0).unwrap(), ReduceOnly::No);
705        assert_eq!(read(1).unwrap(), ReduceOnly::Position);
706        assert_eq!(read(2).unwrap(), ReduceOnly::PositionAndFreeBase);
707        assert!(read(3).is_err());
708    }
709
710    #[test]
711    fn only_no_is_not_reduce_only() {
712        assert!(!ReduceOnly::No.is_reduce_only());
713        assert!(ReduceOnly::Position.is_reduce_only());
714        assert!(ReduceOnly::PositionAndFreeBase.is_reduce_only());
715    }
716
717    #[test]
718    fn with_methods_set_only_their_own_field() {
719        // Every optional field away from its default, so a builder that reset another field
720        // would show.
721        let set = RequestOpen {
722            position_id: Some(PositionId::new("a")),
723            reduce_only: ReduceOnly::Position,
724            market: Some(MarketSnapshot::from_last_price(Some(dec!(100)))),
725            margin_effect: Some(MarginEffect::Borrow),
726            ..limit()
727        };
728
729        assert_eq!(
730            set.clone().with_position_id(PositionId::new("b")),
731            RequestOpen {
732                position_id: Some(PositionId::new("b")),
733                ..set.clone()
734            }
735        );
736        assert_eq!(
737            set.clone()
738                .with_reduce_only(ReduceOnly::PositionAndFreeBase),
739            RequestOpen {
740                reduce_only: ReduceOnly::PositionAndFreeBase,
741                ..set.clone()
742            }
743        );
744        let market = MarketSnapshot::from_last_price(Some(dec!(200)));
745        assert_eq!(
746            set.clone().with_market(market),
747            RequestOpen {
748                market: Some(market),
749                ..set.clone()
750            }
751        );
752        assert_eq!(
753            set.clone().with_margin_effect(MarginEffect::Repay),
754            RequestOpen {
755                margin_effect: Some(MarginEffect::Repay),
756                ..set
757            }
758        );
759
760        // And each sets its field from the default.
761        assert_eq!(
762            limit().with_position_id(PositionId::NETTING).position_id,
763            Some(PositionId::NETTING)
764        );
765        assert_eq!(
766            limit().with_reduce_only(ReduceOnly::Position).reduce_only,
767            ReduceOnly::Position
768        );
769        assert_eq!(limit().with_market(market).market, Some(market));
770        assert_eq!(
771            limit()
772                .with_margin_effect(MarginEffect::Repay)
773                .margin_effect,
774            Some(MarginEffect::Repay)
775        );
776    }
777
778    fn cancel_on(exchange: ExchangeId) -> OrderRequestCancel<ExchangeId, InstrumentNameExchange> {
779        OrderRequestCancel {
780            key: OrderKey {
781                exchange,
782                instrument: InstrumentNameExchange::new("btc_usdt"),
783                strategy: StrategyId::new("test"),
784                cid: ClientOrderId::new("stop"),
785            },
786            state: RequestCancel { id: None },
787        }
788    }
789
790    fn open_on(exchange: ExchangeId) -> OrderRequestOpen<ExchangeId, InstrumentNameExchange> {
791        OrderRequestOpen {
792            key: OrderKey {
793                exchange,
794                instrument: InstrumentNameExchange::new("btc_usdt"),
795                strategy: StrategyId::new("test"),
796                cid: ClientOrderId::new("exit"),
797            },
798            state: limit(),
799        }
800    }
801
802    /// A group is valid with at least one cancel and one open, all on one exchange, whichever leg
803    /// names another.
804    #[test]
805    fn cancel_then_open_exchange_validates_the_group() {
806        use ExchangeId::{BinanceSpot as A, Kraken as B};
807
808        let group = |cancels: Vec<ExchangeId>, opens: Vec<ExchangeId>| {
809            CancelThenOpen::new(
810                cancels.into_iter().map(cancel_on).collect(),
811                opens.into_iter().map(open_on).collect(),
812            )
813        };
814        assert_eq!(group(vec![A, A], vec![A, A]).exchange(), Ok(&A));
815        assert_eq!(
816            group(vec![], vec![A]).exchange(),
817            Err(CancelThenOpenError::NoCancels)
818        );
819        assert_eq!(
820            group(vec![A], vec![]).exchange(),
821            Err(CancelThenOpenError::NoOpens)
822        );
823        for (cancels, opens) in [
824            (vec![A, B], vec![A]),
825            (vec![A], vec![A, B]),
826            (vec![A], vec![B]),
827        ] {
828            assert_eq!(
829                group(cancels.clone(), opens.clone()).exchange(),
830                Err(CancelThenOpenError::MixedExchanges),
831                "{cancels:?} then {opens:?}"
832            );
833        }
834    }
835
836    /// A group drops its opens on a failed cancel unless told otherwise, including one
837    /// deserialised without the field.
838    #[test]
839    #[allow(clippy::unwrap_used)] // Test code: panics are the correct failure mode
840    fn cancel_then_open_drops_opens_by_default() {
841        let group = CancelThenOpen::new(
842            vec![cancel_on(ExchangeId::BinanceSpot)],
843            vec![open_on(ExchangeId::BinanceSpot)],
844        );
845        assert_eq!(group.on_cancel_failure, OnCancelFailure::DropOpens);
846        assert_eq!(
847            group
848                .clone()
849                .with_on_cancel_failure(OnCancelFailure::SendOpens)
850                .on_cancel_failure,
851            OnCancelFailure::SendOpens
852        );
853
854        let mut json = serde_json::to_value(&group).unwrap();
855        json.as_object_mut().unwrap().remove("on_cancel_failure");
856        let loaded: CancelThenOpen<ExchangeId, InstrumentNameExchange> =
857            serde_json::from_value(json).unwrap();
858        assert_eq!(loaded, group);
859    }
860}