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}