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}