Skip to main content

rustrade_execution/order/
request.rs

1use crate::{
2    error::OrderError,
3    margin::MarginEffect,
4    market::MarketSnapshot,
5    order::{
6        OrderEvent, OrderKind, TimeInForce,
7        id::{PositionId, VenueOrderId},
8        state::Cancelled,
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#[cfg(test)]
387mod tests {
388    use super::*;
389    use rust_decimal_macros::dec;
390
391    fn limit() -> RequestOpen {
392        RequestOpen::new(
393            Side::Sell,
394            Some(dec!(101)),
395            dec!(2),
396            OrderKind::Limit,
397            TimeInForce::GoodUntilCancelled { post_only: false },
398        )
399    }
400
401    #[test]
402    fn new_sets_the_given_fields_and_defaults_the_rest() {
403        let request = limit();
404
405        assert_eq!(request.side, Side::Sell);
406        assert_eq!(request.price, Some(dec!(101)));
407        assert_eq!(request.quantity, dec!(2));
408        assert_eq!(request.kind, OrderKind::Limit);
409        assert_eq!(
410            request.time_in_force,
411            TimeInForce::GoodUntilCancelled { post_only: false }
412        );
413        assert_eq!(request.position_id, None);
414        assert_eq!(request.reduce_only, ReduceOnly::No);
415        assert_eq!(request.market, None);
416        assert_eq!(request.margin_effect, None);
417    }
418
419    #[test]
420    #[allow(clippy::unwrap_used)] // Test code: panics are the correct failure mode
421    fn a_request_serialised_without_a_margin_effect_still_loads() {
422        let mut json = serde_json::to_value(limit()).unwrap();
423        json.as_object_mut().unwrap().remove("margin_effect");
424
425        let request: RequestOpen = serde_json::from_value(json).unwrap();
426
427        assert_eq!(request.margin_effect, None);
428    }
429
430    #[test]
431    #[allow(clippy::unwrap_used)] // Test code: panics are the correct failure mode
432    fn reduce_only_round_trips_by_name_and_loads_a_flag() {
433        for (reduce_only, name) in [
434            (ReduceOnly::No, "no"),
435            (ReduceOnly::Position, "position"),
436            (ReduceOnly::PositionAndFreeBase, "position_and_free_base"),
437        ] {
438            let json = serde_json::to_value(limit().with_reduce_only(reduce_only)).unwrap();
439            assert_eq!(json["reduce_only"], name);
440            let loaded: RequestOpen = serde_json::from_value(json).unwrap();
441            assert_eq!(loaded.reduce_only, reduce_only);
442        }
443
444        for (flag, reduce_only) in [(false, ReduceOnly::No), (true, ReduceOnly::Position)] {
445            let mut json = serde_json::to_value(limit()).unwrap();
446            json["reduce_only"] = flag.into();
447            let loaded: RequestOpen = serde_json::from_value(json).unwrap();
448            assert_eq!(loaded.reduce_only, reduce_only);
449        }
450
451        let mut json = serde_json::to_value(limit()).unwrap();
452        json.as_object_mut().unwrap().remove("reduce_only");
453        let loaded: RequestOpen = serde_json::from_value(json).unwrap();
454        assert_eq!(loaded.reduce_only, ReduceOnly::No);
455
456        let mut json = serde_json::to_value(limit()).unwrap();
457        json["reduce_only"] = "sometimes".into();
458        let error = serde_json::from_value::<RequestOpen>(json).unwrap_err();
459        assert!(error.to_string().contains("sometimes"), "{error}");
460
461        // Neither a name nor a flag: the error says what would have been read.
462        let mut json = serde_json::to_value(limit()).unwrap();
463        json["reduce_only"] = 1.into();
464        let error = serde_json::from_value::<RequestOpen>(json).unwrap_err();
465        assert!(
466            error
467                .to_string()
468                .contains(r#""no", "position", "position_and_free_base", or a bool"#),
469            "{error}"
470        );
471    }
472
473    /// A deserializer that reads its inner one as a binary format would: not human-readable, so
474    /// it reads a value by the shape asked for, as an enum.
475    struct NotHumanReadable<D>(D);
476
477    impl<'de, D: Deserializer<'de>> Deserializer<'de> for NotHumanReadable<D> {
478        type Error = D::Error;
479
480        fn deserialize_any<V: serde::de::Visitor<'de>>(
481            self,
482            _: V,
483        ) -> Result<V::Value, Self::Error> {
484            Err(serde::de::Error::custom(
485                "a binary format cannot deserialize_any",
486            ))
487        }
488
489        fn deserialize_enum<V: serde::de::Visitor<'de>>(
490            self,
491            name: &'static str,
492            variants: &'static [&'static str],
493            visitor: V,
494        ) -> Result<V::Value, Self::Error> {
495            self.0.deserialize_enum(name, variants, visitor)
496        }
497
498        fn is_human_readable(&self) -> bool {
499            false
500        }
501
502        serde::forward_to_deserialize_any! {
503            bool i8 i16 i32 i64 i128 u8 u16 u32 u64 u128 f32 f64 char str string bytes byte_buf
504            option unit unit_struct newtype_struct seq tuple tuple_struct map struct identifier
505            ignored_any
506        }
507    }
508
509    #[test]
510    #[allow(clippy::unwrap_used)] // Test code: panics are the correct failure mode
511    fn a_binary_format_reads_reduce_only_as_a_variant() {
512        for reduce_only in [
513            ReduceOnly::No,
514            ReduceOnly::Position,
515            ReduceOnly::PositionAndFreeBase,
516        ] {
517            let written = serde_json::to_value(reduce_only).unwrap();
518            assert_eq!(
519                ReduceOnly::deserialize(NotHumanReadable(&written)).unwrap(),
520                reduce_only
521            );
522        }
523        // A flag has no variant to read.
524        assert!(ReduceOnly::deserialize(NotHumanReadable(&serde_json::Value::Bool(true))).is_err());
525    }
526
527    /// A binary format stores a variant's index, not its name, so the indices are pinned: a
528    /// reordered value would read stored data back as another.
529    #[test]
530    #[allow(clippy::unwrap_used)] // Test code: panics are the correct failure mode
531    fn a_binary_format_reads_reduce_only_by_its_variant_index() {
532        use serde::de::value::{Error, U32Deserializer};
533
534        let read =
535            |index| ReduceOnly::deserialize(NotHumanReadable(U32Deserializer::<Error>::new(index)));
536        assert_eq!(read(0).unwrap(), ReduceOnly::No);
537        assert_eq!(read(1).unwrap(), ReduceOnly::Position);
538        assert_eq!(read(2).unwrap(), ReduceOnly::PositionAndFreeBase);
539        assert!(read(3).is_err());
540    }
541
542    #[test]
543    fn only_no_is_not_reduce_only() {
544        assert!(!ReduceOnly::No.is_reduce_only());
545        assert!(ReduceOnly::Position.is_reduce_only());
546        assert!(ReduceOnly::PositionAndFreeBase.is_reduce_only());
547    }
548
549    #[test]
550    fn with_methods_set_only_their_own_field() {
551        // Every optional field away from its default, so a builder that reset another field
552        // would show.
553        let set = RequestOpen {
554            position_id: Some(PositionId::new("a")),
555            reduce_only: ReduceOnly::Position,
556            market: Some(MarketSnapshot::from_last_price(Some(dec!(100)))),
557            margin_effect: Some(MarginEffect::Borrow),
558            ..limit()
559        };
560
561        assert_eq!(
562            set.clone().with_position_id(PositionId::new("b")),
563            RequestOpen {
564                position_id: Some(PositionId::new("b")),
565                ..set.clone()
566            }
567        );
568        assert_eq!(
569            set.clone()
570                .with_reduce_only(ReduceOnly::PositionAndFreeBase),
571            RequestOpen {
572                reduce_only: ReduceOnly::PositionAndFreeBase,
573                ..set.clone()
574            }
575        );
576        let market = MarketSnapshot::from_last_price(Some(dec!(200)));
577        assert_eq!(
578            set.clone().with_market(market),
579            RequestOpen {
580                market: Some(market),
581                ..set.clone()
582            }
583        );
584        assert_eq!(
585            set.clone().with_margin_effect(MarginEffect::Repay),
586            RequestOpen {
587                margin_effect: Some(MarginEffect::Repay),
588                ..set
589            }
590        );
591
592        // And each sets its field from the default.
593        assert_eq!(
594            limit().with_position_id(PositionId::NETTING).position_id,
595            Some(PositionId::NETTING)
596        );
597        assert_eq!(
598            limit().with_reduce_only(ReduceOnly::Position).reduce_only,
599            ReduceOnly::Position
600        );
601        assert_eq!(limit().with_market(market).market, Some(market));
602        assert_eq!(
603            limit()
604                .with_margin_effect(MarginEffect::Repay)
605                .margin_effect,
606            Some(MarginEffect::Repay)
607        );
608    }
609}