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, Serialize};
20
21pub type OrderRequestOpen<ExchangeKey = ExchangeIndex, InstrumentKey = InstrumentIndex> =
22    OrderEvent<RequestOpen, ExchangeKey, InstrumentKey>;
23
24pub type OrderRequestCancel<ExchangeKey = ExchangeIndex, InstrumentKey = InstrumentIndex> =
25    OrderEvent<RequestCancel, ExchangeKey, InstrumentKey>;
26
27pub type OrderResponseCancel<
28    ExchangeKey = ExchangeIndex,
29    AssetKey = AssetIndex,
30    InstrumentKey = InstrumentIndex,
31> = OrderEvent<Result<Cancelled, OrderError<AssetKey, InstrumentKey>>, ExchangeKey, InstrumentKey>;
32
33pub type UnindexedOrderResponseCancel =
34    OrderResponseCancel<ExchangeId, AssetNameExchange, InstrumentNameExchange>;
35
36/// Parameters for opening a new order.
37///
38/// # Warning: `reduce_only` Default Behavior
39///
40/// The `reduce_only` field defaults to `false`, which means:
41/// - A `Sell` order defaults to `SellToOpen` (open short / write option)
42/// - A `Buy` order defaults to `BuyToOpen` (open long)
43///
44/// **For closing positions, callers MUST explicitly set `reduce_only: true`.**
45/// Failure to do so on non-crypto instruments (equities, options) will:
46/// - On non-margin accounts: result in a 422 rejection from the exchange
47/// - On margin accounts: silently open a short position instead of closing the long
48///
49/// The `close_open_positions_with_market_orders` helper sets this correctly. A caller building
50/// its own closing request must set it with [`with_reduce_only`](Self::with_reduce_only).
51///
52/// `#[non_exhaustive]`, so a field can be added without breaking a caller. Outside this crate,
53/// build one with [`RequestOpen::new`] and the `with_*` methods, and change a copy by assigning
54/// its public fields.
55#[non_exhaustive]
56#[derive(Debug, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Deserialize, Serialize)]
57pub struct RequestOpen {
58    pub side: Side,
59    /// Limit price for the order. Required for Limit/StopLimit orders, and for
60    /// TrailingStopFixedLimit orders, whose limit stays fixed while the trigger trails.
61    /// `None` for Market/Stop/TrailingStop orders (which execute at market price when triggered),
62    /// and for TrailingStopLimit orders, whose limit price follows from their stop and
63    /// `limit_offset`. A price given with a Market order anyway is ignored, by the simulated venue
64    /// and by the live clients that accept market orders.
65    pub price: Option<Decimal>,
66    pub quantity: Decimal,
67    pub kind: OrderKind,
68    pub time_in_force: TimeInForce,
69    /// Target `PositionId` for this order in `OmsMode::Hedging`.
70    ///
71    /// For opening orders: the position this fill should open or add to.
72    /// For closing orders: the position this fill should reduce or close.
73    /// In `OmsMode::Netting`, leave as `None` (ignored).
74    #[serde(default)]
75    pub position_id: Option<PositionId>,
76    /// Constrain this order to only reduce existing positions, never open new ones.
77    ///
78    /// Used by exchanges that require explicit open/close intent (Alpaca, Interactive
79    /// Brokers, Schwab). Adapters derive venue-specific semantics from this flag + `side`:
80    /// - `reduce_only=false, Buy`  → BuyToOpen (open long / add to long)
81    /// - `reduce_only=false, Sell` → SellToOpen (open short / write option)
82    /// - `reduce_only=true, Buy`   → BuyToClose (close short)
83    /// - `reduce_only=true, Sell`  → SellToClose (close long)
84    ///
85    /// Exchanges that infer intent from positions (Binance, Deribit) may map this to
86    /// their `reduceOnly` parameter or ignore it entirely.
87    ///
88    /// The `rustrade` engine enforces it on fills, whatever the venue did with it: a fill of a
89    /// reduce-only order never grows or flips the engine's position. A fill larger than the
90    /// opposite position is cut so the position lands flat, and the excess is reported rather
91    /// than applied. See [`Trade::holding_move`](crate::trade::Trade::holding_move).
92    #[serde(default)]
93    pub reduce_only: bool,
94
95    /// Market state observed at the instant this request was created, if any was sampled.
96    ///
97    /// This is **decision-time provenance**, not an instruction to the venue. It records what the
98    /// sender was looking at when it chose to trade, which is the one price a fill can be measured
99    /// against after the fact — implementation shortfall is the difference between this and the
100    /// price actually obtained.
101    ///
102    /// # Live venues must ignore this field
103    ///
104    /// No live exchange adapter reads it and none should: a venue prices a fill from its own book,
105    /// and an adapter that forwarded these prices would be sending the client's view of the market
106    /// back as an instruction. Every adapter in this crate leaves it untouched.
107    ///
108    /// # A simulated venue fills from it only when it has no book of its own
109    ///
110    /// [`SimulatedVenue`](crate::exchange::mock::SimulatedVenue) reads this **only** in
111    /// [`VenueRegime::RequestPriced`](crate::exchange::mock::venue::VenueRegime::RequestPriced), where it
112    /// has no market feed and this snapshot is its only price source. That is the regime
113    /// [`MockExecution`](crate::client::mock::MockExecution) and
114    /// [`MockExchange`](crate::exchange::mock::MockExchange) use: the request's snapshot is priced
115    /// through the venue's [`FillModel`](crate::fill::FillModel), and without it a market order
116    /// has no limit price to fall back on — the venue ignores `price` on an
117    /// [`OrderKind::Market`] — so the venue can only reject.
118    ///
119    /// A [`MarketDriven`](crate::exchange::mock::venue::VenueRegime::MarketDriven) venue ignores it and
120    /// prices from its own book, like a live one. This field is then decision-time provenance
121    /// alone, and the difference between it and the price obtained is implementation shortfall.
122    ///
123    /// Sampling it at creation rather than at fill time is deliberate. It is the one instant with a
124    /// well-defined position on a simulated timeline *for the sender*: everything the sender had
125    /// seen by then is in it, and nothing it had not seen can be. What makes it safe for a venue to
126    /// hold a book instead is that its driver paces the feed — routing each market event to the
127    /// venue before the client that reacts to it sees the same event, and booking each request at
128    /// the instant it arrives. A venue that sampled a stream it shares with the engine but does not
129    /// pace could fill against prices from arbitrarily far in the future, which is why the regime
130    /// is a property of how the venue is driven rather than a configuration flag.
131    ///
132    /// # `None` versus an empty snapshot
133    ///
134    /// `None` means no snapshot was taken — a live path, or a caller that constructed the request
135    /// directly. `Some` holding a [`MarketSnapshot::is_empty`] snapshot means one was taken and the
136    /// instrument had no price yet, which is what a cold start looks like. A `RequestPriced`
137    /// simulated venue distinguishes the two in its rejection, because the fixes differ.
138    ///
139    /// `#[serde(default)]` so requests serialised before this field existed still load.
140    #[serde(default)]
141    pub market: Option<MarketSnapshot>,
142
143    /// What this order does to a margin account's loans, or `None` to take the client's default.
144    ///
145    /// Only a margin client or a simulated venue with spot margin acts on a borrow or a repay; see
146    /// [`MarginEffect`] for the three values. A client that cannot honour a `Some` refuses the
147    /// order unsent with
148    /// [`OrderError::UnsupportedMarginEffect`]:
149    /// a cash or spot-only account takes `Some(NoSideEffect)` alone, and a client that cannot
150    /// promise it (account-level margin, or implicit borrowing as under Hyperliquid's portfolio
151    /// margin) takes no `Some` at all, `NoSideEffect` included. A caller that sets an effect on
152    /// every order must leave it `None` for those venues.
153    ///
154    /// `#[serde(default)]` so requests serialised before this field existed still load.
155    #[serde(default)]
156    pub margin_effect: Option<MarginEffect>,
157}
158
159impl RequestOpen {
160    /// A request with no [`position_id`](Self::position_id), not
161    /// [`reduce_only`](Self::reduce_only), and no [`market`](Self::market) snapshot. Set those
162    /// with the `with_*` methods.
163    pub fn new(
164        side: Side,
165        price: Option<Decimal>,
166        quantity: Decimal,
167        kind: OrderKind,
168        time_in_force: TimeInForce,
169    ) -> Self {
170        Self {
171            side,
172            price,
173            quantity,
174            kind,
175            time_in_force,
176            position_id: None,
177            reduce_only: false,
178            market: None,
179            margin_effect: None,
180        }
181    }
182
183    /// This request with the [`position_id`](Self::position_id) it targets in
184    /// `OmsMode::Hedging`.
185    #[must_use]
186    pub fn with_position_id(self, position_id: PositionId) -> Self {
187        Self {
188            position_id: Some(position_id),
189            ..self
190        }
191    }
192
193    /// This request with its [`reduce_only`](Self::reduce_only) flag set to `reduce_only`.
194    #[must_use]
195    pub fn with_reduce_only(self, reduce_only: bool) -> Self {
196        Self {
197            reduce_only,
198            ..self
199        }
200    }
201
202    /// This request with the [`market`](Self::market) its sender observed when it created it.
203    #[must_use]
204    pub fn with_market(self, market: MarketSnapshot) -> Self {
205        Self {
206            market: Some(market),
207            ..self
208        }
209    }
210
211    /// This request with its [`margin_effect`](Self::margin_effect) set to `margin_effect`,
212    /// instead of the client's default.
213    #[must_use]
214    pub fn with_margin_effect(self, margin_effect: MarginEffect) -> Self {
215        Self {
216            margin_effect: Some(margin_effect),
217            ..self
218        }
219    }
220}
221
222#[derive(
223    Debug, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Default, Deserialize, Serialize, Constructor,
224)]
225pub struct RequestCancel {
226    /// How the venue addresses the order to cancel, when that is known.
227    ///
228    /// Three cases, and a client must distinguish all three:
229    /// - `None` -- the order is still in flight, so the venue has acknowledged nothing and there
230    ///   is no identifier to cancel by.
231    /// - `Some(VenueOrderId::Assigned(id))` -- cancel by the venue's identifier.
232    /// - `Some(VenueOrderId::ClientAssigned)` -- the venue accepted the order without assigning an
233    ///   identifier; cancel by the `ClientOrderId` in the request's key.
234    ///
235    /// Collapsing the last two into `None` would leave a client unable to tell "nothing to cancel"
236    /// from "cancel by client id".
237    pub id: Option<VenueOrderId>,
238}
239
240#[cfg(test)]
241mod tests {
242    use super::*;
243    use rust_decimal_macros::dec;
244
245    fn limit() -> RequestOpen {
246        RequestOpen::new(
247            Side::Sell,
248            Some(dec!(101)),
249            dec!(2),
250            OrderKind::Limit,
251            TimeInForce::GoodUntilCancelled { post_only: false },
252        )
253    }
254
255    #[test]
256    fn new_sets_the_given_fields_and_defaults_the_rest() {
257        let request = limit();
258
259        assert_eq!(request.side, Side::Sell);
260        assert_eq!(request.price, Some(dec!(101)));
261        assert_eq!(request.quantity, dec!(2));
262        assert_eq!(request.kind, OrderKind::Limit);
263        assert_eq!(
264            request.time_in_force,
265            TimeInForce::GoodUntilCancelled { post_only: false }
266        );
267        assert_eq!(request.position_id, None);
268        assert!(!request.reduce_only);
269        assert_eq!(request.market, None);
270        assert_eq!(request.margin_effect, None);
271    }
272
273    #[test]
274    #[allow(clippy::unwrap_used)] // Test code: panics are the correct failure mode
275    fn a_request_serialised_without_a_margin_effect_still_loads() {
276        let mut json = serde_json::to_value(limit()).unwrap();
277        json.as_object_mut().unwrap().remove("margin_effect");
278
279        let request: RequestOpen = serde_json::from_value(json).unwrap();
280
281        assert_eq!(request.margin_effect, None);
282    }
283
284    #[test]
285    fn with_methods_set_only_their_own_field() {
286        // Every optional field away from its default, so a builder that reset another field
287        // would show.
288        let set = RequestOpen {
289            position_id: Some(PositionId::new("a")),
290            reduce_only: true,
291            market: Some(MarketSnapshot::from_last_price(Some(dec!(100)))),
292            margin_effect: Some(MarginEffect::Borrow),
293            ..limit()
294        };
295
296        assert_eq!(
297            set.clone().with_position_id(PositionId::new("b")),
298            RequestOpen {
299                position_id: Some(PositionId::new("b")),
300                ..set.clone()
301            }
302        );
303        assert_eq!(
304            set.clone().with_reduce_only(false),
305            RequestOpen {
306                reduce_only: false,
307                ..set.clone()
308            }
309        );
310        let market = MarketSnapshot::from_last_price(Some(dec!(200)));
311        assert_eq!(
312            set.clone().with_market(market),
313            RequestOpen {
314                market: Some(market),
315                ..set.clone()
316            }
317        );
318        assert_eq!(
319            set.clone().with_margin_effect(MarginEffect::Repay),
320            RequestOpen {
321                margin_effect: Some(MarginEffect::Repay),
322                ..set
323            }
324        );
325
326        // And each sets its field from the default.
327        assert_eq!(
328            limit().with_position_id(PositionId::NETTING).position_id,
329            Some(PositionId::NETTING)
330        );
331        assert!(limit().with_reduce_only(true).reduce_only);
332        assert_eq!(limit().with_market(market).market, Some(market));
333        assert_eq!(
334            limit()
335                .with_margin_effect(MarginEffect::Repay)
336                .margin_effect,
337            Some(MarginEffect::Repay)
338        );
339    }
340}