1use super::ServiceContext;
2use super::correlation_id::{
3 optional_client_order_id, optional_request_id, require_client_style_id,
4};
5use super::scope;
6use super::unary;
7use crate::codecs::decode::{
8 batch_cancel_from_proto, batch_create_from_proto, batch_replace_from_proto,
9 batch_replace_status_from_proto, cancel_all_after_from_proto, cancel_all_from_proto,
10 get_order_from_proto, modify_order_from_proto, order_mutation_from_cancel,
11 order_mutation_from_create, orders_list_from_history, orders_list_from_open,
12 preview_order_from_proto, user_trades_list_from_proto,
13};
14use crate::codecs::scalars::id_to_u64;
15use crate::connect::orders::v1::{OrdersReadServiceClient, OrdersServiceClient};
16use crate::errors::{Error, Result};
17use crate::models::{
18 AttachedRisk, BatchCancelItem, BatchCancelOrdersResult, BatchCreateOrdersResult,
19 BatchReplaceItem, BatchReplaceOrdersResult, BatchReplaceStatusResult, CancelAllAfterResult,
20 CancelAllOpts, CancelAllOrdersResult, CancelOrderParams, CreateOrderParams, CreateOrderType,
21 CreateSide, CreateTimeInForce, FeeAsset, GetOrderOpts, GetOrderResult, ListOpenOrdersOpts,
22 ListOrderHistoryOpts, MaxSlippage, ModifyOrderParams, ModifyOrderResult, Order, OrderKey,
23 OrderMutationResult, OrderSelfTradePrevention, OrdersList, PreviewOrderParams,
24 PreviewOrderResult, RiskLeg, TrailingDistance, TrailingStop, UserTrade, UserTradesList,
25};
26use crate::proto::orders::v1::{
27 BatchCancelItem as ProtoBatchCancelItem, BatchCancelOrdersRequest, BatchCreateOrdersRequest,
28 BatchReplaceOrderItem as ProtoBatchReplaceOrderItem, BatchReplaceOrdersRequest,
29 CancelAllAfterRequest, CancelAllOrdersRequest, CancelOrderRequest, CreateOrderRequest,
30 FeeAsset as ProtoFeeAsset, GetBatchReplaceStatusRequest, GetOpenOrdersRequest,
31 GetOrderHistoryRequest, GetOrderRequest, GetUserTradesRequest, LimitFok, LimitGtc, LimitIoc,
32 MarketIoc, ModifyBehavior, ModifyOrderRequest, OrderIntent, PreviewOrderRequest, RiskExecution,
33 RiskLimitGtc, RiskPolicy, SelfTradePreventionMode, Side, StopLossPolicy, TakeProfitPolicy,
34 TrailingStopPolicy, batch_replace_order_item, cancel_order_request, get_order_request,
35 market_ioc, modify_order_request, order_intent, risk_execution, risk_policy,
36 trailing_stop_policy,
37};
38use crate::types::{
39 Price, Quantity, resolve_price_ticks, resolve_qty_scaled, resolve_quote_qty_scaled,
40};
41use rand_core::{OsRng, RngCore};
42use std::time::Duration;
43
44#[derive(Clone)]
45pub struct OrdersService {
46 ctx: ServiceContext,
47}
48
49impl OrdersService {
50 const MAX_BATCH_ITEMS: usize = 20;
51
52 pub fn new(ctx: ServiceContext) -> Self {
53 Self { ctx }
54 }
55
56 fn write_client(&self) -> OrdersServiceClient<crate::transport::SharedTransport> {
57 OrdersServiceClient::new(
58 self.ctx.factory.transport(),
59 self.ctx.factory.connect_config(),
60 )
61 }
62
63 fn read_client(&self) -> OrdersReadServiceClient<crate::transport::SharedTransport> {
64 OrdersReadServiceClient::new(
65 self.ctx.factory.transport(),
66 self.ctx.factory.connect_config(),
67 )
68 }
69
70 pub async fn list_open(&self, subaccount_id: Option<u64>) -> Result<OrdersList> {
71 self.list_open_with(ListOpenOrdersOpts {
72 subaccount_id,
73 ..Default::default()
74 })
75 .await
76 }
77
78 pub async fn list_open_with(&self, opts: ListOpenOrdersOpts) -> Result<OrdersList> {
79 let req = GetOpenOrdersRequest {
80 subaccount_id: scope::optional_subaccount(&self.ctx, opts.subaccount_id)?,
81 page_token: opts.page_token.unwrap_or_default(),
82 limit: opts.limit,
83 include_attached_risk: Some(opts.include_attached_risk),
84 include_attached_risk_state: Some(opts.include_attached_risk_state),
85 ..Default::default()
86 };
87 let client = self.read_client();
88 let resp = unary::await_auth(
89 &self.ctx.factory,
90 "/orders.v1.OrdersReadService/GetOpenOrders",
91 req,
92 |req, opts| client.get_open_orders_with_options(req, opts),
93 )
94 .await?
95 .into_owned();
96 Ok(orders_list_from_open(&resp))
97 }
98
99 pub async fn list_history(
100 &self,
101 subaccount_id: Option<u64>,
102 limit: Option<u32>,
103 ) -> Result<OrdersList> {
104 self.list_history_with(ListOrderHistoryOpts {
105 subaccount_id,
106 limit,
107 ..Default::default()
108 })
109 .await
110 }
111
112 pub async fn list_history_with(&self, opts: ListOrderHistoryOpts) -> Result<OrdersList> {
113 let mut symbol_ids = Vec::new();
114 if let Some(sid) = opts.symbol_id {
115 if sid == 0 {
116 return Err(Error::validation(
117 "symbol_id must be non-zero when explicitly supplied",
118 ));
119 }
120 symbol_ids.push(sid);
121 } else if let Some(ref symbol) = opts.symbol {
122 let resolved = self
123 .ctx
124 .catalogs
125 .symbol_id_for_symbol(symbol)
126 .ok_or_else(|| {
127 Error::validation(format!(
128 "unknown symbol {symbol}; call hydrate_catalogs / get_spot_config first"
129 ))
130 })?;
131 symbol_ids.push(resolved);
132 }
133 let req = GetOrderHistoryRequest {
134 subaccount_id: scope::optional_subaccount(&self.ctx, opts.subaccount_id)?,
135 symbol_id: symbol_ids,
136 page_token: opts.page_token.unwrap_or_default(),
137 limit: opts.limit,
138 include_attached_risk: Some(opts.include_attached_risk),
139 include_attached_risk_state: Some(opts.include_attached_risk_state),
140 ..Default::default()
141 };
142 let client = self.read_client();
143 let resp = unary::await_auth(
144 &self.ctx.factory,
145 "/orders.v1.OrdersReadService/GetOrderHistory",
146 req,
147 |req, opts| client.get_order_history_with_options(req, opts),
148 )
149 .await?
150 .into_owned();
151 Ok(orders_list_from_history(&resp))
152 }
153
154 pub async fn get(&self, key: OrderKey, subaccount_id: Option<u64>) -> Result<GetOrderResult> {
155 self.get_with(GetOrderOpts {
156 key,
157 subaccount_id,
158 include_attached_risk: false,
159 include_attached_risk_state: false,
160 })
161 .await
162 }
163
164 pub async fn get_with(&self, opts: GetOrderOpts) -> Result<GetOrderResult> {
165 let key = Some(Self::encode_get_order_key(&opts.key)?);
166 let req = GetOrderRequest {
167 subaccount_id: scope::optional_subaccount(&self.ctx, opts.subaccount_id)?,
168 key,
169 include_attached_risk: Some(opts.include_attached_risk),
170 include_attached_risk_state: Some(opts.include_attached_risk_state),
171 ..Default::default()
172 };
173 let client = self.read_client();
174 let resp = unary::await_auth(
175 &self.ctx.factory,
176 "/orders.v1.OrdersReadService/GetOrder",
177 req,
178 |req, opts| client.get_order_with_options(req, opts),
179 )
180 .await?
181 .into_owned();
182 Ok(get_order_from_proto(&resp))
183 }
184
185 pub async fn wait_for_order_trades_complete(
192 &self,
193 key: OrderKey,
194 timeout: Duration,
195 ) -> Result<GetOrderResult> {
196 let timeout = if timeout.is_zero() {
197 Duration::from_secs(15)
198 } else {
199 timeout
200 };
201 let deadline = tokio::time::Instant::now() + timeout;
202 loop {
203 let last = tokio::time::timeout_at(deadline, self.get(key.clone(), None))
204 .await
205 .map_err(|_| {
206 Error::transport(format!(
207 "timed out waiting for order trades to match cum_qty (key={key:?})"
208 ))
209 })??;
210 if order_trades_projection_complete(&last) {
211 return Ok(last);
212 }
213 if tokio::time::Instant::now() >= deadline {
214 return Err(Error::transport(format!(
215 "timed out waiting for order trades to match cum_qty (key={key:?})"
216 )));
217 }
218 tokio::time::sleep_until(
219 deadline.min(tokio::time::Instant::now() + Duration::from_millis(100)),
220 )
221 .await;
222 }
223 }
224
225 fn encode_get_order_key(key: &OrderKey) -> Result<get_order_request::Key> {
226 match key {
227 OrderKey::OrderId(oid) => {
228 Ok(get_order_request::Key::OrderId(id_to_u64(oid, "order_id")?))
229 }
230 OrderKey::ClientOrderId(cid) => Ok(get_order_request::Key::ClientOrderId(
231 require_client_style_id(cid, "client_order_id")?,
232 )),
233 }
234 }
235
236 fn encode_cancel_order_key(key: &OrderKey) -> Result<cancel_order_request::Key> {
237 match key {
238 OrderKey::OrderId(oid) => Ok(cancel_order_request::Key::OrderId(id_to_u64(
239 oid, "order_id",
240 )?)),
241 OrderKey::ClientOrderId(cid) => Ok(cancel_order_request::Key::ClientOrderId(
242 require_client_style_id(cid, "client_order_id")?,
243 )),
244 }
245 }
246
247 fn encode_modify_order_key(key: &OrderKey) -> Result<modify_order_request::Key> {
248 match key {
249 OrderKey::OrderId(oid) => Ok(modify_order_request::Key::OrderId(id_to_u64(
250 oid, "order_id",
251 )?)),
252 OrderKey::ClientOrderId(cid) => Ok(modify_order_request::Key::ClientOrderId(
253 require_client_style_id(cid, "client_order_id")?,
254 )),
255 }
256 }
257
258 fn encode_batch_replace_key(key: &OrderKey) -> Result<batch_replace_order_item::Key> {
259 match key {
260 OrderKey::OrderId(oid) => Ok(batch_replace_order_item::Key::OrderId(id_to_u64(
261 oid, "order_id",
262 )?)),
263 OrderKey::ClientOrderId(cid) => Ok(batch_replace_order_item::Key::ClientOrderId(
264 require_client_style_id(cid, "client_order_id")?,
265 )),
266 }
267 }
268
269 fn require_quantity_scale(&self, symbol: &str, qty_scale: Option<u32>) -> Result<u32> {
273 if let Some(scale) = self.ctx.catalogs.base_quantity_scale_for_symbol(symbol) {
274 return Ok(scale);
275 }
276 if let Some(scale) = qty_scale {
277 return Ok(scale);
278 }
279 Err(Error::validation(format!(
280 "quantity scale for {symbol:?} is unavailable; await client.wait_for_catalogs() before placing orders, or pass a scaled Quantity"
281 )))
282 }
283
284 fn require_quote_quantity_scale(&self, symbol: &str) -> Result<u32> {
285 self.ctx
286 .catalogs
287 .quote_quantity_scale_for_symbol(symbol)
288 .ok_or_else(|| {
289 Error::validation(format!(
290 "quote quantity scale for {symbol:?} is unavailable; await client.wait_for_catalogs() before using a quote-debit budget"
291 ))
292 })
293 }
294
295 fn validate_batch_size(operation: &str, len: usize) -> Result<()> {
296 if len == 0 {
297 return Err(Error::validation(format!(
298 "{operation} requires at least one item"
299 )));
300 }
301 if len > Self::MAX_BATCH_ITEMS {
302 return Err(Error::validation(format!(
303 "{operation} accepts at most {} items; received {len}",
304 Self::MAX_BATCH_ITEMS
305 )));
306 }
307 Ok(())
308 }
309
310 fn order_intent_from_params(&self, params: &CreateOrderParams) -> Result<OrderIntent> {
311 let mut intent = OrderIntent {
312 symbol: params.symbol.clone(),
313 side: match params.side {
314 CreateSide::Buy => Side::Buy.into(),
315 CreateSide::Sell => Side::Sell.into(),
316 },
317 ..Default::default()
318 };
319 if let Some(client_order_id) = optional_client_order_id(params.client_order_id.as_deref())?
320 {
321 intent.client_order_id = client_order_id;
322 }
323 intent.sizing = Some(match (¶ms.quantity, ¶ms.max_quote_debit_scaled) {
324 (Some(quantity), None) => {
325 let scale = self.require_quantity_scale(¶ms.symbol, quantity.scale())?;
326 order_intent::Sizing::BaseQtyScaled(resolve_qty_scaled(
327 quantity,
328 scale,
329 Some(¶ms.symbol),
330 self.ctx.catalogs.symbol_id_for_symbol(¶ms.symbol),
331 )?)
332 }
333 (None, Some(max_quote_debit)) => {
334 let scale = self.require_quote_quantity_scale(¶ms.symbol)?;
335 order_intent::Sizing::MaxQuoteDebitScaled(resolve_quote_qty_scaled(
336 max_quote_debit,
337 scale,
338 Some(¶ms.symbol),
339 self.ctx.catalogs.symbol_id_for_symbol(¶ms.symbol),
340 )?)
341 }
342 (Some(_), Some(_)) | (None, None) => {
343 return Err(Error::validation(
344 "set exactly one of quantity or max_quote_debit_scaled",
345 ));
346 }
347 });
348 intent.fee_asset = match params.fee_asset.unwrap_or(FeeAsset::Quote) {
349 FeeAsset::Quote => ProtoFeeAsset::Quote.into(),
350 FeeAsset::Base if matches!(params.side, CreateSide::Buy) => ProtoFeeAsset::Base.into(),
351 FeeAsset::Base => {
352 return Err(Error::validation(
353 "fee_asset=base is only valid for BUY orders",
354 ));
355 }
356 };
357 intent.self_trade_prevention_mode = match params
358 .self_trade_prevention
359 .unwrap_or(OrderSelfTradePrevention::ExpireMaker)
360 {
361 OrderSelfTradePrevention::ExpireTaker => SelfTradePreventionMode::ExpireTaker.into(),
362 OrderSelfTradePrevention::ExpireMaker => SelfTradePreventionMode::ExpireMaker.into(),
363 OrderSelfTradePrevention::ExpireBoth => SelfTradePreventionMode::ExpireBoth.into(),
364 };
365 let post_only = params.post_only.unwrap_or(false);
366 intent.execution = Some(match params.order_type {
367 CreateOrderType::Market => {
368 if post_only {
369 return Err(Error::validation(
370 "post_only is not supported for market orders",
371 ));
372 }
373 if params.price.is_some() {
374 return Err(Error::validation(
375 "price is not valid for market orders; use market_client_ref_price for a reservation reference",
376 ));
377 }
378 let mut market = MarketIoc::default();
379 if let Some(ref_price) = params.market_client_ref_price.as_ref() {
380 market.client_ref_price_ticks =
381 resolve_price_ticks(ref_price, Some(¶ms.symbol))?;
382 }
383 market.max_slippage = match params.market_max_slippage {
384 Some(MaxSlippage::Ticks(value)) if value > 0 => {
385 Some(market_ioc::MaxSlippage::MaxSlippageTicks(value))
386 }
387 Some(MaxSlippage::Bps(value)) if value > 0 => {
388 Some(market_ioc::MaxSlippage::MaxSlippageBps(value))
389 }
390 Some(_) => {
391 return Err(Error::validation("market_max_slippage must be positive"));
392 }
393 None => None,
394 };
395 order_intent::Execution::MarketIoc(Box::new(market))
396 }
397 CreateOrderType::Limit => {
398 let price = params.price.as_ref().ok_or_else(|| {
399 Error::validation(
400 "price is required for limit orders (use Price::from_decimal or Price::from_ticks)",
401 )
402 })?;
403 let price_ticks = resolve_price_ticks(price, Some(¶ms.symbol))?;
404 match params.time_in_force {
405 Some(CreateTimeInForce::Ioc) => {
406 if post_only {
407 return Err(Error::validation(
408 "post_only is not supported for ioc limit orders",
409 ));
410 }
411 order_intent::Execution::LimitIoc(Box::new(LimitIoc {
412 price_ticks,
413 ..Default::default()
414 }))
415 }
416 Some(CreateTimeInForce::Fok) => {
417 if post_only {
418 return Err(Error::validation(
419 "post_only is not supported for fok limit orders",
420 ));
421 }
422 order_intent::Execution::LimitFok(Box::new(LimitFok {
423 price_ticks,
424 ..Default::default()
425 }))
426 }
427 _ => order_intent::Execution::LimitGtc(Box::new(LimitGtc {
429 price_ticks,
430 post_only,
431 ..Default::default()
432 })),
433 }
434 }
435 });
436 if let Some(risk) = params.attached_risk.as_ref() {
437 *intent.attached_risk.get_or_insert_default() =
438 Self::encode_attached_risk(risk, Some(¶ms.symbol))?;
439 }
440 Ok(intent)
441 }
442
443 fn encode_create_params(&self, params: &CreateOrderParams) -> Result<CreateOrderRequest> {
444 let order = self.order_intent_from_params(params)?;
445 let mut req = CreateOrderRequest {
446 subaccount_id: scope::optional_subaccount(&self.ctx, params.subaccount_id)?,
447 ..Default::default()
448 };
449 *req.order.get_or_insert_default() = order;
450 Ok(req)
451 }
452
453 #[allow(deprecated)]
456 fn encode_risk_child(leg: &RiskLeg, symbol: Option<&str>) -> Result<RiskExecution> {
457 if leg.trigger_price_source.is_some() {
458 return Err(Error::validation(
459 "attached risk always uses last trade; trigger_price_source cannot be supplied",
460 ));
461 }
462 let child_ty = leg.order_type.unwrap_or(CreateOrderType::Market);
463 let execution = match (child_ty, leg.limit_price.as_ref()) {
464 (CreateOrderType::Market, None) => risk_execution::Execution::MarketIoc(Box::default()),
465 (CreateOrderType::Market, Some(_)) => {
466 return Err(Error::validation(
467 "attached_risk MARKET child must not set limit_price",
468 ));
469 }
470 (CreateOrderType::Limit, Some(price)) => {
471 risk_execution::Execution::LimitGtc(Box::new(RiskLimitGtc {
472 price_ticks: resolve_price_ticks(price, symbol)?,
473 ..Default::default()
474 }))
475 }
476 (CreateOrderType::Limit, None) => {
477 return Err(Error::validation(
478 "attached_risk LIMIT child requires limit_price",
479 ));
480 }
481 };
482 Ok(RiskExecution {
483 execution: Some(execution),
484 ..Default::default()
485 })
486 }
487
488 fn encode_take_profit(leg: &RiskLeg, symbol: Option<&str>) -> Result<TakeProfitPolicy> {
489 let mut policy = TakeProfitPolicy {
490 trigger_price_ticks: resolve_price_ticks(&leg.trigger_price, symbol)?,
491 ..Default::default()
492 };
493 *policy.child.get_or_insert_default() = Self::encode_risk_child(leg, symbol)?;
494 Ok(policy)
495 }
496
497 fn encode_stop_loss(leg: &RiskLeg, symbol: Option<&str>) -> Result<StopLossPolicy> {
498 let mut policy = StopLossPolicy {
499 trigger_price_ticks: resolve_price_ticks(&leg.trigger_price, symbol)?,
500 ..Default::default()
501 };
502 *policy.child.get_or_insert_default() = Self::encode_risk_child(leg, symbol)?;
503 Ok(policy)
504 }
505
506 fn encode_trailing_stop(
507 stop: &TrailingStop,
508 symbol: Option<&str>,
509 ) -> Result<TrailingStopPolicy> {
510 let mut proto = TrailingStopPolicy::default();
513 if let Some(activation) = stop.activation_price.as_ref() {
514 proto.activation_price_ticks = resolve_price_ticks(activation, symbol)?;
515 }
516 proto.trailing_distance = Some(match stop.distance {
517 TrailingDistance::Ticks(v) => {
518 trailing_stop_policy::TrailingDistance::TrailingDistanceTicks(v)
519 }
520 TrailingDistance::Bps(v) => {
521 trailing_stop_policy::TrailingDistance::TrailingDistanceBps(v)
522 }
523 });
524 if let Some(slip) = stop.max_slippage {
525 proto.max_slippage = Some(match slip {
526 MaxSlippage::Ticks(v) => trailing_stop_policy::MaxSlippage::MaxSlippageTicks(v),
527 MaxSlippage::Bps(v) => trailing_stop_policy::MaxSlippage::MaxSlippageBps(v),
528 });
529 }
530 Ok(proto)
531 }
532
533 fn encode_attached_risk(risk: &AttachedRisk, symbol: Option<&str>) -> Result<RiskPolicy> {
534 if risk.stop_loss.is_some() && risk.trailing_stop.is_some() {
535 return Err(Error::validation(
536 "attached_risk allows at most one of stop_loss or trailing_stop",
537 ));
538 }
539 if risk.take_profit.is_none() && risk.stop_loss.is_none() && risk.trailing_stop.is_none() {
540 return Err(Error::validation(
541 "attached_risk requires take_profit and/or a stop leg",
542 ));
543 }
544 let mut proto = RiskPolicy {
545 oco: risk.oco,
546 ..Default::default()
547 };
548 if let Some(tp) = risk.take_profit.as_ref() {
549 *proto.take_profit.get_or_insert_default() = Self::encode_take_profit(tp, symbol)?;
550 }
551 if let Some(sl) = risk.stop_loss.as_ref() {
552 proto.stop_leg = Some(risk_policy::StopLeg::StopLoss(Box::new(
553 Self::encode_stop_loss(sl, symbol)?,
554 )));
555 } else if let Some(ts) = risk.trailing_stop.as_ref() {
556 proto.stop_leg = Some(risk_policy::StopLeg::TrailingStop(Box::new(
557 Self::encode_trailing_stop(ts, symbol)?,
558 )));
559 }
560 Ok(proto)
561 }
562
563 fn new_mutation_request_id(prefix: &str) -> Result<String> {
570 let mut random = [0_u8; 6];
571 OsRng
572 .try_fill_bytes(&mut random)
573 .map_err(|err| Error::transport(format!("secure randomness unavailable: {err}")))?;
574 Ok(format!("{prefix}-{}", hex::encode(random)))
575 }
576
577 fn coalesce_request_id(value: Option<String>, prefix: &str) -> Result<String> {
579 if let Some(id) = optional_request_id(value.as_deref())? {
580 return Ok(id);
581 }
582 Self::new_mutation_request_id(prefix)
583 }
584
585 fn modify_behavior(label: &str) -> Result<ModifyBehavior> {
586 match label.to_ascii_lowercase().as_str() {
587 "amend_or_replace" => Ok(ModifyBehavior::AmendOrReplace),
588 "amend_only" => Ok(ModifyBehavior::AmendOnly),
589 "replace_only" => Ok(ModifyBehavior::ReplaceOnly),
590 _ => Err(Error::validation(
591 "behavior must be amend_or_replace, amend_only, or replace_only",
592 )),
593 }
594 }
595
596 fn encode_modify_params(&self, params: ModifyOrderParams) -> Result<ModifyOrderRequest> {
597 if params.new_price.is_none()
598 && params.new_qty.is_none()
599 && params.new_attached_risk.is_none()
600 {
601 return Err(Error::validation(
602 "modify requires new_price, new_qty, and/or new_attached_risk",
603 ));
604 }
605 let scale = self.require_quantity_scale(
606 ¶ms.symbol,
607 params.new_qty.as_ref().and_then(Quantity::scale),
608 )?;
609 let mut req = ModifyOrderRequest {
610 subaccount_id: scope::optional_subaccount(&self.ctx, params.subaccount_id)?,
611 request_id: Self::coalesce_request_id(params.request_id, "mod")?,
612 key: Some(Self::encode_modify_order_key(¶ms.key)?),
613 ..Default::default()
614 };
615 if let Some(price) = params.new_price.as_ref() {
616 req.new_price_ticks = Some(resolve_price_ticks(price, Some(¶ms.symbol))?);
617 }
618 if let Some(qty) = params.new_qty.as_ref() {
619 req.new_qty_scaled = Some(resolve_qty_scaled(
620 qty,
621 scale,
622 Some(¶ms.symbol),
623 self.ctx.catalogs.symbol_id_for_symbol(¶ms.symbol),
624 )?);
625 }
626 if let Some(risk) = params.new_attached_risk.as_ref() {
627 *req.new_attached_risk.get_or_insert_default() =
628 Self::encode_attached_risk(risk, Some(¶ms.symbol))?;
629 }
630 if let Some(behavior) = params.behavior.as_deref() {
631 req.behavior = Self::modify_behavior(behavior)?.into();
632 }
633 if let Some(ncid) = optional_client_order_id(params.new_client_order_id.as_deref())? {
634 req.new_client_order_id = ncid;
635 }
636 Ok(req)
637 }
638
639 pub async fn create(&self, params: CreateOrderParams) -> Result<OrderMutationResult> {
641 self.ctx.wait_for_catalogs().await?;
642 let req = self.encode_create_params(¶ms)?;
643 let client = self.write_client();
644 let resp = unary::await_auth(
645 &self.ctx.factory,
646 "/orders.v1.OrdersService/CreateOrder",
647 req,
648 |req, opts| client.create_order_with_options(req, opts),
649 )
650 .await?
651 .into_owned();
652 order_mutation_from_create(&resp)
653 }
654
655 pub async fn preview(&self, params: PreviewOrderParams) -> Result<PreviewOrderResult> {
659 self.ctx.wait_for_catalogs().await?;
660 let req = self.encode_preview_params(¶ms)?;
661 let client = self.write_client();
662 let resp = unary::await_auth(
663 &self.ctx.factory,
664 "/orders.v1.OrdersService/PreviewOrder",
665 req,
666 |req, opts| client.preview_order_with_options(req, opts),
667 )
668 .await?
669 .into_owned();
670 let base_scale = self.require_quantity_scale(¶ms.symbol, None)?;
671 let quote_scale = self.require_quote_quantity_scale(¶ms.symbol)?;
672 preview_order_from_proto(
673 &resp,
674 base_scale,
675 quote_scale,
676 ¶ms.symbol,
677 self.ctx.catalogs.symbol_id_for_symbol(¶ms.symbol),
678 )
679 }
680
681 fn encode_preview_params(&self, params: &PreviewOrderParams) -> Result<PreviewOrderRequest> {
682 let create = CreateOrderParams {
686 symbol: params.symbol.clone(),
687 side: params.side,
688 order_type: params.order_type,
689 quantity: params.quantity.clone(),
690 max_quote_debit_scaled: params.max_quote_debit_scaled.clone(),
691 price: params.price.clone(),
692 time_in_force: params.time_in_force,
693 client_order_id: params.client_order_id.clone(),
694 subaccount_id: params.subaccount_id,
695 post_only: params.post_only,
696 market_client_ref_price: params.market_client_ref_price.clone(),
697 fee_asset: params.fee_asset,
698 self_trade_prevention: params.self_trade_prevention,
699 market_max_slippage: params.market_max_slippage,
700 attached_risk: params.attached_risk.clone(),
701 };
702 let order = self.order_intent_from_params(&create)?;
703 let mut req = PreviewOrderRequest {
704 subaccount_id: scope::optional_subaccount(&self.ctx, params.subaccount_id)?,
705 ..Default::default()
706 };
707 *req.order.get_or_insert_default() = order;
708 Ok(req)
709 }
710
711 pub async fn batch_create(
716 &self,
717 items: Vec<CreateOrderParams>,
718 subaccount_id: Option<u64>,
719 request_id: Option<String>,
720 ) -> Result<BatchCreateOrdersResult> {
721 Self::validate_batch_size("batch_create", items.len())?;
722 self.ctx.wait_for_catalogs().await?;
723 let mut encoded = Vec::with_capacity(items.len());
724 for item in &items {
725 encoded.push(self.order_intent_from_params(item)?);
726 }
727 let req = BatchCreateOrdersRequest {
728 subaccount_id: scope::optional_subaccount(&self.ctx, subaccount_id)?,
729 request_id: Self::coalesce_request_id(request_id, "batch-create")?,
730 items: encoded,
731 ..Default::default()
732 };
733 let client = self.write_client();
734 let resp = unary::await_auth(
735 &self.ctx.factory,
736 "/orders.v1.OrdersService/BatchCreateOrders",
737 req,
738 |req, opts| client.batch_create_orders_with_options(req, opts),
739 )
740 .await?
741 .into_owned();
742 batch_create_from_proto(&resp)
743 }
744
745 pub async fn batch_cancel(
750 &self,
751 items: Vec<BatchCancelItem>,
752 subaccount_id: Option<u64>,
753 request_id: Option<String>,
754 ) -> Result<BatchCancelOrdersResult> {
755 Self::validate_batch_size("batch_cancel", items.len())?;
756 let mut proto_items = Vec::with_capacity(items.len());
757 for item in items {
758 let mut proto = ProtoBatchCancelItem::default();
759 match &item.key {
760 OrderKey::OrderId(oid) => {
761 proto.order_id = id_to_u64(oid, "order_id")?;
762 }
763 OrderKey::ClientOrderId(cid) => {
764 proto.client_order_id = require_client_style_id(cid, "client_order_id")?;
765 }
766 }
767 if let Some(sid) = item.symbol_id {
768 proto.symbol_id = sid;
769 }
770 proto_items.push(proto);
771 }
772 let req = BatchCancelOrdersRequest {
773 subaccount_id: scope::optional_subaccount(&self.ctx, subaccount_id)?,
774 request_id: Self::coalesce_request_id(request_id, "batch-cancel")?,
775 items: proto_items,
776 ..Default::default()
777 };
778 let client = self.write_client();
779 let resp = unary::await_auth(
780 &self.ctx.factory,
781 "/orders.v1.OrdersService/BatchCancelOrders",
782 req,
783 |req, opts| client.batch_cancel_orders_with_options(req, opts),
784 )
785 .await?
786 .into_owned();
787 batch_cancel_from_proto(&resp)
788 }
789
790 pub async fn batch_replace(
795 &self,
796 items: Vec<BatchReplaceItem>,
797 symbol: &str,
798 subaccount_id: Option<u64>,
799 request_id: Option<String>,
800 ) -> Result<BatchReplaceOrdersResult> {
801 Self::validate_batch_size("batch_replace", items.len())?;
802 self.ctx.wait_for_catalogs().await?;
803 let symbol_id = self
804 .ctx
805 .catalogs
806 .symbol_id_for_symbol(symbol)
807 .ok_or_else(|| {
808 Error::validation(format!(
809 "unknown symbol {symbol}; call hydrate_catalogs / get_spot_config first"
810 ))
811 })?;
812 let scale = Self::resolve_batch_replace_scale(&self.ctx.catalogs, symbol)?;
813 let mut proto_items = Vec::with_capacity(items.len());
814 for item in items {
815 if item.new_price.is_none()
816 && item.new_qty.is_none()
817 && item.new_attached_risk.is_none()
818 {
819 return Err(Error::validation(
820 "each batch item requires new_price, new_qty, and/or new_attached_risk",
821 ));
822 }
823 let mut proto = ProtoBatchReplaceOrderItem {
824 key: Some(Self::encode_batch_replace_key(&item.key)?),
825 ..Default::default()
826 };
827 if let Some(price) = item.new_price.as_ref() {
828 proto.new_price_ticks = Some(resolve_price_ticks(price, Some(symbol))?);
829 }
830 if let Some(qty) = item.new_qty.as_ref() {
831 proto.new_qty_scaled = Some(resolve_qty_scaled(
832 qty,
833 scale,
834 Some(symbol),
835 Some(symbol_id),
836 )?);
837 }
838 if let Some(risk) = item.new_attached_risk.as_ref() {
839 *proto.new_attached_risk.get_or_insert_default() =
840 Self::encode_attached_risk(risk, Some(symbol))?;
841 }
842 if let Some(ncid) = optional_client_order_id(item.new_client_order_id.as_deref())? {
843 proto.new_client_order_id = ncid;
844 }
845 proto_items.push(proto);
846 }
847 let req = BatchReplaceOrdersRequest {
848 subaccount_id: scope::optional_subaccount(&self.ctx, subaccount_id)?,
849 symbol_id,
850 request_id: Self::coalesce_request_id(request_id, "batch-replace")?,
851 items: proto_items,
852 ..Default::default()
853 };
854 let client = self.write_client();
855 let resp = unary::await_auth(
856 &self.ctx.factory,
857 "/orders.v1.OrdersService/BatchReplaceOrders",
858 req,
859 |req, opts| client.batch_replace_orders_with_options(req, opts),
860 )
861 .await?
862 .into_owned();
863 batch_replace_from_proto(&resp)
864 }
865
866 pub async fn get_batch_replace_status(
868 &self,
869 batch_request_id: &str,
870 subaccount_id: Option<u64>,
871 ) -> Result<BatchReplaceStatusResult> {
872 let req = GetBatchReplaceStatusRequest {
873 subaccount_id: scope::optional_subaccount(&self.ctx, subaccount_id)?,
874 batch_request_id: id_to_u64(batch_request_id, "batch_request_id")?,
875 ..Default::default()
876 };
877 let client = self.read_client();
878 let resp = unary::await_auth(
879 &self.ctx.factory,
880 "/orders.v1.OrdersReadService/GetBatchReplaceStatus",
881 req,
882 |req, opts| client.get_batch_replace_status_with_options(req, opts),
883 )
884 .await?
885 .into_owned();
886 batch_replace_status_from_proto(&resp)
887 }
888
889 pub async fn cancel_all_after(
894 &self,
895 timeout_sec: u32,
896 symbol: Option<&str>,
897 subaccount_id: Option<u64>,
898 request_id: Option<String>,
899 ) -> Result<CancelAllAfterResult> {
900 let req = CancelAllAfterRequest {
901 subaccount_id: scope::optional_subaccount(&self.ctx, subaccount_id)?,
902 timeout_sec,
903 symbol: symbol.unwrap_or("").to_owned(),
904 request_id: Self::coalesce_request_id(request_id, "cancel-after")?,
905 ..Default::default()
906 };
907 let client = self.write_client();
908 let resp = unary::await_auth(
909 &self.ctx.factory,
910 "/orders.v1.OrdersService/CancelAllAfter",
911 req,
912 |req, opts| client.cancel_all_after_with_options(req, opts),
913 )
914 .await?
915 .into_owned();
916 cancel_all_after_from_proto(&resp)
917 }
918
919 pub async fn cancel(&self, req: CancelOrderRequest) -> Result<OrderMutationResult> {
920 let client = self.write_client();
921 let resp = unary::await_auth(
922 &self.ctx.factory,
923 "/orders.v1.OrdersService/CancelOrder",
924 req,
925 |req, opts| client.cancel_order_with_options(req, opts),
926 )
927 .await?
928 .into_owned();
929 order_mutation_from_cancel(&resp)
930 }
931
932 pub async fn cancel_with(&self, params: CancelOrderParams) -> Result<OrderMutationResult> {
933 if params.symbol_id.is_none() && params.symbol.is_some() {
936 self.ctx.wait_for_catalogs().await?;
937 }
938 let symbol_id = Self::resolve_cancel_symbol_id(
939 &self.ctx.catalogs,
940 params.symbol.as_deref(),
941 params.symbol_id,
942 )?;
943 let req = CancelOrderRequest {
944 symbol_id,
945 subaccount_id: scope::optional_subaccount(&self.ctx, params.subaccount_id)?,
946 key: Some(Self::encode_cancel_order_key(¶ms.key)?),
947 ..Default::default()
948 };
949 self.cancel(req).await
950 }
951
952 fn resolve_cancel_symbol_id(
953 catalogs: &crate::catalogs::Manager,
954 symbol: Option<&str>,
955 symbol_id: Option<u32>,
956 ) -> Result<u32> {
957 match (symbol, symbol_id) {
958 (None, None) => Ok(0),
959 (_, Some(0)) => Err(Error::validation(
960 "symbol_id must be non-zero when explicitly supplied",
961 )),
962 (Some(_), Some(_)) => Err(Error::validation(
963 "cancel accepts symbol or symbol_id, not both",
964 )),
965 (None, Some(symbol_id)) => Ok(symbol_id),
966 (Some(symbol), None) => catalogs.symbol_id_for_symbol(symbol).ok_or_else(|| {
967 Error::validation(format!(
968 "unknown symbol {symbol}; call hydrate_catalogs / get_spot_config first"
969 ))
970 }),
971 }
972 }
973
974 pub async fn cancel_by_client_order_id(
975 &self,
976 client_order_id: &str,
977 symbol: Option<&str>,
978 subaccount_id: Option<u64>,
979 ) -> Result<OrderMutationResult> {
980 self.cancel_with(CancelOrderParams {
981 key: OrderKey::ClientOrderId(client_order_id.to_owned()),
982 symbol: symbol.map(|s| s.to_owned()),
983 symbol_id: None,
984 subaccount_id,
985 })
986 .await
987 }
988
989 pub async fn cancel_by_order_id(
990 &self,
991 order_id: &str,
992 subaccount_id: Option<u64>,
993 ) -> Result<OrderMutationResult> {
994 self.cancel_with(CancelOrderParams {
995 key: OrderKey::OrderId(order_id.to_owned()),
996 symbol: None,
997 symbol_id: None,
998 subaccount_id,
999 })
1000 .await
1001 }
1002
1003 pub async fn cancel_all(
1008 &self,
1009 symbol: Option<&str>,
1010 dry_run: bool,
1011 subaccount_id: Option<u64>,
1012 ) -> Result<CancelAllOrdersResult> {
1013 self.cancel_all_with(CancelAllOpts {
1014 symbol: symbol.map(|s| s.to_owned()),
1015 dry_run,
1016 subaccount_id,
1017 ..Default::default()
1018 })
1019 .await
1020 }
1021
1022 pub async fn cancel_all_with(&self, opts: CancelAllOpts) -> Result<CancelAllOrdersResult> {
1027 let mut req = CancelAllOrdersRequest {
1028 subaccount_id: scope::optional_subaccount(&self.ctx, opts.subaccount_id)?,
1029 symbol: opts.symbol.unwrap_or_default(),
1030 dry_run: opts.dry_run,
1031 request_id: Self::coalesce_request_id(opts.request_id, "cancel-all")?,
1032 ..Default::default()
1033 };
1034 if let Some(side) = opts.side.as_deref() {
1035 req.side = Self::parse_side(side)?.into();
1036 }
1037 let client = self.write_client();
1038 let resp = unary::await_auth(
1039 &self.ctx.factory,
1040 "/orders.v1.OrdersService/CancelAllOrders",
1041 req,
1042 |req, opts| client.cancel_all_orders_with_options(req, opts),
1043 )
1044 .await?
1045 .into_owned();
1046 cancel_all_from_proto(&resp)
1047 }
1048
1049 fn parse_side(side: &str) -> Result<Side> {
1050 match side.to_ascii_lowercase().as_str() {
1051 "buy" => Ok(Side::Buy),
1052 "sell" => Ok(Side::Sell),
1053 _ => Err(Error::validation("side must be buy or sell")),
1054 }
1055 }
1056
1057 pub async fn modify(&self, params: ModifyOrderParams) -> Result<ModifyOrderResult> {
1063 self.ctx.wait_for_catalogs().await?;
1064 let req = self.encode_modify_params(params)?;
1065 let client = self.write_client();
1066 let resp = unary::await_auth(
1067 &self.ctx.factory,
1068 "/orders.v1.OrdersService/ModifyOrder",
1069 req,
1070 |req, opts| client.modify_order_with_options(req, opts),
1071 )
1072 .await?
1073 .into_owned();
1074 modify_order_from_proto(&resp)
1075 }
1076
1077 pub fn create_params(
1078 symbol: impl Into<String>,
1079 side: CreateSide,
1080 order_type: CreateOrderType,
1081 quantity: Quantity,
1082 price: Option<Price>,
1083 client_order_id: Option<&str>,
1084 ) -> CreateOrderParams {
1085 let client_order_id = client_order_id
1086 .map(str::trim)
1087 .filter(|s| !s.is_empty())
1088 .map(|s| s.to_owned());
1089 CreateOrderParams {
1090 symbol: symbol.into(),
1091 side,
1092 order_type,
1093 quantity: Some(quantity),
1094 max_quote_debit_scaled: None,
1095 price,
1096 time_in_force: None,
1097 client_order_id,
1098 subaccount_id: None,
1099 post_only: None,
1100 market_client_ref_price: None,
1101 fee_asset: None,
1102 self_trade_prevention: None,
1103 market_max_slippage: None,
1104 attached_risk: None,
1105 }
1106 }
1107
1108 pub(crate) fn resolve_batch_replace_scale(
1110 catalogs: &crate::catalogs::Manager,
1111 symbol: &str,
1112 ) -> Result<u32> {
1113 catalogs.base_quantity_scale_for_symbol(symbol).ok_or_else(|| {
1114 Error::validation(format!(
1115 "quantity scale for {symbol:?} is unavailable; await client.wait_for_catalogs() before placing orders"
1116 ))
1117 })
1118 }
1119
1120 pub async fn subscribe(
1122 &self,
1123 account_id: Option<&str>,
1124 ) -> Result<crate::realtime::TypedSubscription<Order>> {
1125 let account = scope::resolve_account_id(&self.ctx, account_id)?;
1126 let channel = format!("private:spot:orders:{account}:proto");
1127 self.ctx
1128 .realtime
1129 .subscribe_proto(&channel, crate::codecs::decode::order_from_bytes)
1130 .await
1131 }
1132}
1133
1134#[derive(Clone)]
1135pub struct TradesService {
1136 ctx: ServiceContext,
1137}
1138
1139impl TradesService {
1140 pub fn new(ctx: ServiceContext) -> Self {
1141 Self { ctx }
1142 }
1143
1144 pub async fn list(
1145 &self,
1146 subaccount_id: Option<u64>,
1147 limit: Option<u32>,
1148 ) -> Result<UserTradesList> {
1149 let req = GetUserTradesRequest {
1150 subaccount_id: scope::optional_subaccount(&self.ctx, subaccount_id)?,
1151 limit,
1152 ..Default::default()
1153 };
1154 let client = OrdersReadServiceClient::new(
1155 self.ctx.factory.transport(),
1156 self.ctx.factory.connect_config(),
1157 );
1158 let resp = unary::await_auth(
1159 &self.ctx.factory,
1160 "/orders.v1.OrdersReadService/GetUserTrades",
1161 req,
1162 |req, opts| client.get_user_trades_with_options(req, opts),
1163 )
1164 .await?
1165 .into_owned();
1166 Ok(user_trades_list_from_proto(&resp))
1167 }
1168
1169 pub async fn subscribe(
1171 &self,
1172 account_id: Option<&str>,
1173 ) -> Result<crate::realtime::TypedSubscription<UserTrade>> {
1174 let account = scope::resolve_account_id(&self.ctx, account_id)?;
1175 let channel = format!("private:spot:trades:{account}:proto");
1176 self.ctx
1177 .realtime
1178 .subscribe_proto(&channel, crate::codecs::decode::user_trade_from_bytes)
1179 .await
1180 }
1181}
1182
1183fn order_trades_projection_complete(result: &GetOrderResult) -> bool {
1184 let Some(order) = result.order.as_ref() else {
1185 return false;
1186 };
1187 if !matches!(order.status.as_str(), "filled" | "canceled" | "rejected") {
1188 return false;
1189 }
1190 let Some(cum) = order.cum_qty.as_ref() else {
1191 return false;
1192 };
1193 let cum = cum.as_scaled();
1194 if cum == 0 {
1195 return true;
1196 }
1197 let mut trade_sum = 0_i64;
1198 for trade in &result.trades {
1199 let Some(qty) = trade.qty.as_ref() else {
1200 return false;
1201 };
1202 let Some(sum) = trade_sum.checked_add(qty.as_scaled()) else {
1203 return false;
1204 };
1205 trade_sum = sum;
1206 }
1207 trade_sum == cum
1208}
1209
1210#[cfg(test)]
1211mod tests {
1212 use super::*;
1213 use crate::codecs::scalars::format_id;
1214 use buffa::Message;
1215 use serde_json::json;
1216
1217 fn client() -> crate::Client {
1218 let client = crate::Client::new(crate::Config {
1219 hydrate_catalogs: false,
1220 ..Default::default()
1221 })
1222 .unwrap();
1223 client
1224 .catalogs
1225 .hydrate_spot_config_json(json!({
1226 "pairs": [{
1227 "symbol": "BTC-USDT",
1228 "symbol_id": 7,
1229 "base_quantity_scale": 8,
1230 "quote_quantity_scale": 6
1231 }]
1232 }))
1233 .expect("hydrate");
1234 client
1235 }
1236
1237 fn create_params(quantity: Quantity, price: Price) -> CreateOrderParams {
1238 CreateOrderParams {
1239 symbol: "BTC-USDT".into(),
1240 side: CreateSide::Buy,
1241 order_type: CreateOrderType::Limit,
1242 quantity: Some(quantity),
1243 max_quote_debit_scaled: None,
1244 price: Some(price),
1245 time_in_force: Some(CreateTimeInForce::Gtc),
1246 client_order_id: Some("order-equivalence".into()),
1247 subaccount_id: None,
1248 post_only: Some(true),
1249 market_client_ref_price: None,
1250 fee_asset: None,
1251 self_trade_prevention: None,
1252 market_max_slippage: None,
1253 attached_risk: None,
1254 }
1255 }
1256
1257 #[test]
1258 fn decimal_and_scaled_create_encode_identically() {
1259 let client = client();
1260 let decimal = create_params(
1261 Quantity::from_decimal_str("0.1", 8, Some("BTC-USDT".into()), Some(7)).unwrap(),
1262 Price::from_decimal_str("50000", Some("BTC-USDT".into())).unwrap(),
1263 );
1264 let scaled = create_params(
1265 Quantity::from_scaled(
1266 10_000_000,
1267 Some(8),
1268 crate::QuantityDomain::OrderBase,
1269 Some("BTC-USDT".into()),
1270 Some(7),
1271 )
1272 .unwrap(),
1273 Price::from_ticks(50_000_000_000, Some("BTC-USDT".into())).unwrap(),
1274 );
1275
1276 let decimal_wire = client.orders.encode_create_params(&decimal).unwrap();
1277 let scaled_wire = client.orders.encode_create_params(&scaled).unwrap();
1278 assert_eq!(decimal_wire.encode_to_vec(), scaled_wire.encode_to_vec());
1279 }
1280
1281 fn modify_params(new_price: Option<Price>, new_qty: Option<Quantity>) -> ModifyOrderParams {
1282 ModifyOrderParams {
1283 symbol: "BTC-USDT".into(),
1284 key: OrderKey::OrderId("1".into()),
1285 subaccount_id: None,
1286 request_id: Some("modify-equivalence".into()),
1287 new_price,
1288 new_qty,
1289 new_attached_risk: None,
1290 behavior: Some("amend_or_replace".into()),
1291 new_client_order_id: None,
1292 }
1293 }
1294
1295 #[test]
1296 fn decimal_and_scaled_modify_encode_identically() {
1297 let client = client();
1298 let decimal = modify_params(
1299 Some(Price::from_decimal_str("50001", Some("BTC-USDT".into())).unwrap()),
1300 Some(Quantity::from_decimal_str("0.2", 8, Some("BTC-USDT".into()), Some(7)).unwrap()),
1301 );
1302 let scaled = modify_params(
1303 Some(Price::from_ticks(50_001_000_000, Some("BTC-USDT".into())).unwrap()),
1304 Some(
1305 Quantity::from_scaled(
1306 20_000_000,
1307 Some(8),
1308 crate::QuantityDomain::OrderBase,
1309 Some("BTC-USDT".into()),
1310 Some(7),
1311 )
1312 .unwrap(),
1313 ),
1314 );
1315
1316 let decimal_wire = client.orders.encode_modify_params(decimal).unwrap();
1317 let scaled_wire = client.orders.encode_modify_params(scaled).unwrap();
1318 assert_eq!(decimal_wire.encode_to_vec(), scaled_wire.encode_to_vec());
1319 }
1320
1321 #[test]
1322 fn batch_replace_requires_catalog_quantity_scale() {
1323 let catalogs = crate::catalogs::Manager::new();
1324 let err = OrdersService::resolve_batch_replace_scale(&catalogs, "BTC-USDT").unwrap_err();
1325 assert!(
1326 err.to_string().contains("quantity scale"),
1327 "unexpected error: {err}"
1328 );
1329 }
1330
1331 #[test]
1332 fn batch_replace_uses_symbol_catalog_quantity_scale() {
1333 let client = client();
1334 assert_eq!(
1335 OrdersService::resolve_batch_replace_scale(&client.catalogs, "BTC-USDT").unwrap(),
1336 8
1337 );
1338 }
1339
1340 #[test]
1341 fn modify_validates_key_and_patch() {
1342 let client = client();
1343 let empty_key = ModifyOrderParams {
1344 key: OrderKey::ClientOrderId(String::new()),
1345 ..modify_params(Some(Price::from_ticks(1, None).unwrap()), None)
1346 };
1347 assert!(client.orders.encode_modify_params(empty_key).is_err());
1348
1349 let no_patch = modify_params(None, None);
1350 assert!(client.orders.encode_modify_params(no_patch).is_err());
1351 }
1352
1353 #[test]
1354 #[allow(deprecated)]
1355 fn attached_risk_encodes_on_create_and_modify() {
1356 use crate::models::{AttachedRisk, RiskLeg, TriggerPriceSourceKind};
1357
1358 let client = client();
1359 let risk = AttachedRisk {
1360 take_profit: Some(RiskLeg {
1361 trigger_price: Price::from_ticks(51_000_000_000, Some("BTC-USDT".into())).unwrap(),
1362 trigger_price_source: None,
1363 order_type: Some(CreateOrderType::Market),
1364 limit_price: None,
1365 }),
1366 stop_loss: Some(RiskLeg {
1367 trigger_price: Price::from_ticks(49_000_000_000, Some("BTC-USDT".into())).unwrap(),
1368 trigger_price_source: None,
1369 order_type: Some(CreateOrderType::Limit),
1370 limit_price: Some(
1371 Price::from_ticks(48_900_000_000, Some("BTC-USDT".into())).unwrap(),
1372 ),
1373 }),
1374 trailing_stop: None,
1375 oco: true,
1376 };
1377
1378 let mut create = create_params(
1379 Quantity::from_scaled(
1380 10_000_000,
1381 Some(8),
1382 crate::QuantityDomain::OrderBase,
1383 Some("BTC-USDT".into()),
1384 Some(7),
1385 )
1386 .unwrap(),
1387 Price::from_ticks(50_000_000_000, Some("BTC-USDT".into())).unwrap(),
1388 );
1389 create.attached_risk = Some(risk.clone());
1390 let create_wire = client.orders.encode_create_params(&create).unwrap();
1391 let order = create_wire.order.as_option().unwrap();
1392 assert!(order.attached_risk.is_set());
1393 assert!(order.attached_risk.as_option().unwrap().oco);
1394
1395 let mut modify = modify_params(None, None);
1396 modify.new_attached_risk = Some(risk);
1397 let modify_wire = client.orders.encode_modify_params(modify).unwrap();
1398 assert!(modify_wire.new_attached_risk.is_set());
1399
1400 let mut unsupported = create;
1401 unsupported
1402 .attached_risk
1403 .as_mut()
1404 .unwrap()
1405 .take_profit
1406 .as_mut()
1407 .unwrap()
1408 .trigger_price_source = Some(TriggerPriceSourceKind::IndexPrice);
1409 let err = client
1410 .orders
1411 .encode_create_params(&unsupported)
1412 .unwrap_err();
1413 assert!(matches!(&err, Error::Validation(_)));
1414 assert!(err.to_string().contains("always uses last trade"));
1415 }
1416
1417 #[test]
1418 fn preview_encodes_full_order_intent() {
1419 let client = client();
1420 let create = create_params(
1421 Quantity::from_scaled(
1422 10_000_000,
1423 Some(8),
1424 crate::QuantityDomain::OrderBase,
1425 Some("BTC-USDT".into()),
1426 Some(7),
1427 )
1428 .unwrap(),
1429 Price::from_ticks(50_000_000_000, Some("BTC-USDT".into())).unwrap(),
1430 );
1431 let preview = PreviewOrderParams {
1432 symbol: create.symbol.clone(),
1433 side: create.side,
1434 order_type: create.order_type,
1435 quantity: create.quantity.clone(),
1436 max_quote_debit_scaled: None,
1437 price: create.price.clone(),
1438 time_in_force: create.time_in_force,
1439 client_order_id: Some("preview-cid".into()),
1440 subaccount_id: Some(9),
1441 post_only: create.post_only,
1442 market_client_ref_price: None,
1443 fee_asset: Some(FeeAsset::Quote),
1444 self_trade_prevention: Some(OrderSelfTradePrevention::ExpireTaker),
1445 market_max_slippage: None,
1446 attached_risk: None,
1447 };
1448 let wire = client.orders.encode_preview_params(&preview).unwrap();
1449 assert_eq!(wire.subaccount_id, Some(9));
1450 let intent = wire.order.as_option().expect("preview order intent");
1451 assert_eq!(intent.symbol, "BTC-USDT");
1452 assert_eq!(intent.side.as_known(), Some(Side::Buy));
1453 assert_eq!(intent.client_order_id, "preview-cid");
1454 assert!(matches!(
1455 intent.sizing,
1456 Some(order_intent::Sizing::BaseQtyScaled(10_000_000))
1457 ));
1458 assert!(matches!(
1459 intent.execution,
1460 Some(order_intent::Execution::LimitGtc(_))
1461 ));
1462 assert_eq!(
1463 intent.self_trade_prevention_mode.as_known(),
1464 Some(SelfTradePreventionMode::ExpireTaker)
1465 );
1466 }
1467
1468 #[test]
1469 fn create_allows_omitted_client_order_id_and_encodes_market_maker_controls() {
1470 let client = client();
1471 let mut params = create_params(
1472 Quantity::from_scaled(
1473 10_000_000,
1474 Some(8),
1475 crate::QuantityDomain::OrderBase,
1476 Some("BTC-USDT".into()),
1477 Some(7),
1478 )
1479 .unwrap(),
1480 Price::from_ticks(50_000_000_000, Some("BTC-USDT".into())).unwrap(),
1481 );
1482 params.client_order_id = None;
1483 let omitted = client.orders.encode_create_params(¶ms).unwrap();
1484 assert!(
1485 omitted
1486 .order
1487 .as_option()
1488 .unwrap()
1489 .client_order_id
1490 .is_empty()
1491 );
1492
1493 params.client_order_id = Some(" ".into());
1494 let whitespace = client.orders.encode_create_params(¶ms).unwrap();
1495 assert!(
1496 whitespace
1497 .order
1498 .as_option()
1499 .unwrap()
1500 .client_order_id
1501 .is_empty()
1502 );
1503
1504 params.client_order_id = Some("mm-create-1".into());
1505 params.order_type = CreateOrderType::Market;
1506 params.price = None;
1507 params.post_only = None;
1508 params.fee_asset = Some(FeeAsset::Base);
1509 params.self_trade_prevention = Some(OrderSelfTradePrevention::ExpireBoth);
1510 params.market_max_slippage = Some(MaxSlippage::Bps(25));
1511 let wire = client.orders.encode_create_params(¶ms).unwrap();
1512 let intent = wire.order.as_option().unwrap();
1513 assert_eq!(intent.fee_asset.as_known(), Some(ProtoFeeAsset::Base));
1514 assert_eq!(
1515 intent.self_trade_prevention_mode.as_known(),
1516 Some(SelfTradePreventionMode::ExpireBoth)
1517 );
1518 let Some(order_intent::Execution::MarketIoc(market)) = intent.execution.as_ref() else {
1519 panic!("expected market execution");
1520 };
1521 assert!(matches!(
1522 market.max_slippage,
1523 Some(market_ioc::MaxSlippage::MaxSlippageBps(25))
1524 ));
1525
1526 params.price = Some(Price::from_ticks(1, None).unwrap());
1527 let err = client.orders.encode_create_params(¶ms).unwrap_err();
1528 assert!(
1529 err.to_string().contains("price is not valid for market"),
1530 "unexpected error: {err}"
1531 );
1532
1533 params.price = None;
1534 params.market_max_slippage = Some(MaxSlippage::Ticks(0));
1535 assert!(client.orders.encode_create_params(¶ms).is_err());
1536 }
1537
1538 #[test]
1539 fn create_encodes_quote_budget_sizing_and_rejects_ambiguous_sizing() {
1540 let client = client();
1541 let mut params = create_params(
1542 Quantity::from_scaled(
1543 10_000_000,
1544 Some(8),
1545 crate::QuantityDomain::OrderBase,
1546 Some("BTC-USDT".into()),
1547 Some(7),
1548 )
1549 .unwrap(),
1550 Price::from_ticks(50_000_000_000, Some("BTC-USDT".into())).unwrap(),
1551 );
1552 params.quantity = None;
1553 params.max_quote_debit_scaled = Some(
1554 Quantity::from_quote_scaled(5_000_000, 6, Some("BTC-USDT".into()), Some(7)).unwrap(),
1555 );
1556 let wire = client.orders.encode_create_params(¶ms).unwrap();
1557 let intent = wire.order.as_option().unwrap();
1558 assert!(matches!(
1559 intent.sizing,
1560 Some(order_intent::Sizing::MaxQuoteDebitScaled(5_000_000))
1561 ));
1562
1563 params.quantity = Some(
1564 Quantity::from_scaled(
1565 10_000_000,
1566 Some(8),
1567 crate::QuantityDomain::OrderBase,
1568 Some("BTC-USDT".into()),
1569 Some(7),
1570 )
1571 .unwrap(),
1572 );
1573 assert!(client.orders.encode_create_params(¶ms).is_err());
1574
1575 params.quantity = None;
1576 params.max_quote_debit_scaled =
1577 Some(Quantity::from_quote_scaled(5_000_000, 8, None, None).unwrap());
1578 let err = client.orders.encode_create_params(¶ms).unwrap_err();
1579 assert!(err.to_string().contains("scale mismatch"));
1580 }
1581
1582 #[test]
1583 fn batch_size_guard_rejects_empty_and_more_than_twenty() {
1584 assert!(OrdersService::validate_batch_size("batch_create", 1).is_ok());
1585 assert!(OrdersService::validate_batch_size("batch_create", 20).is_ok());
1586 assert!(
1587 OrdersService::validate_batch_size("batch_create", 0)
1588 .unwrap_err()
1589 .to_string()
1590 .contains("at least one")
1591 );
1592 assert!(
1593 OrdersService::validate_batch_size("batch_create", 21)
1594 .unwrap_err()
1595 .to_string()
1596 .contains("at most 20")
1597 );
1598 }
1599
1600 #[test]
1601 fn create_rejects_invalid_client_order_id_before_wire() {
1602 let client = client();
1603 let mut params = create_params(
1604 Quantity::from_scaled(
1605 10_000_000,
1606 Some(8),
1607 crate::QuantityDomain::OrderBase,
1608 Some("BTC-USDT".into()),
1609 Some(7),
1610 )
1611 .unwrap(),
1612 Price::from_ticks(50_000_000_000, Some("BTC-USDT".into())).unwrap(),
1613 );
1614
1615 params.client_order_id = Some("bad id".into());
1616 let err = client.orders.encode_create_params(¶ms).unwrap_err();
1617 assert!(err.to_string().contains("invalid characters"));
1618
1619 params.client_order_id = Some("a".repeat(37));
1620 let err = client.orders.encode_create_params(¶ms).unwrap_err();
1621 assert!(err.to_string().contains("1 to 36"));
1622
1623 params.client_order_id = Some("ok-id_1.2:3/4".into());
1624 assert!(client.orders.encode_create_params(¶ms).is_ok());
1625
1626 let err = OrdersService::coalesce_request_id(Some("bad id".into()), "mod").unwrap_err();
1627 assert!(err.to_string().contains("invalid characters"));
1628 let err = OrdersService::coalesce_request_id(Some("r".repeat(65)), "mod").unwrap_err();
1629 assert!(err.to_string().contains("1 to 64"));
1630 }
1631
1632 #[tokio::test]
1633 async fn singular_order_methods_reject_invalid_client_order_id_before_transport() {
1634 let client = client();
1635 let err = client
1636 .orders
1637 .cancel_by_client_order_id("bad id!", None, None)
1638 .await
1639 .unwrap_err();
1640 assert!(matches!(err, Error::Validation(_)));
1641 assert!(err.to_string().contains("invalid characters"));
1642
1643 let err = client
1644 .orders
1645 .get(OrderKey::ClientOrderId("bad id!".into()), None)
1646 .await
1647 .unwrap_err();
1648 assert!(matches!(err, Error::Validation(_)));
1649 assert!(err.to_string().contains("invalid characters"));
1650 }
1651
1652 #[test]
1653 fn cancel_symbol_routing_distinguishes_omitted_and_invalid_inputs() {
1654 let client = client();
1655 assert_eq!(
1656 OrdersService::resolve_cancel_symbol_id(&client.catalogs, None, None).unwrap(),
1657 0
1658 );
1659 assert_eq!(
1660 OrdersService::resolve_cancel_symbol_id(&client.catalogs, Some("BTC-USDT"), None)
1661 .unwrap(),
1662 7
1663 );
1664
1665 for err in [
1666 OrdersService::resolve_cancel_symbol_id(&client.catalogs, Some("UNKNOWN-USDT"), None)
1667 .unwrap_err(),
1668 OrdersService::resolve_cancel_symbol_id(&client.catalogs, None, Some(0)).unwrap_err(),
1669 OrdersService::resolve_cancel_symbol_id(&client.catalogs, Some("BTC-USDT"), Some(7))
1670 .unwrap_err(),
1671 ] {
1672 assert!(matches!(err, Error::Validation(_)));
1673 }
1674 }
1675
1676 #[tokio::test]
1677 async fn cancel_rejects_unknown_supplied_symbol_before_transport() {
1678 let client = client();
1679 let err = client
1680 .orders
1681 .cancel_with(CancelOrderParams {
1682 key: OrderKey::OrderId(format_id(9)),
1683 symbol: Some("UNKNOWN-USDT".into()),
1684 symbol_id: None,
1685 subaccount_id: None,
1686 })
1687 .await
1688 .unwrap_err();
1689 assert!(matches!(&err, Error::Validation(_)));
1690 assert!(err.to_string().contains("unknown symbol"));
1691 }
1692
1693 #[test]
1694 fn mutation_request_ids_are_generated_when_omitted_like_go_python_typescript() {
1695 for prefix in [
1696 "cancel-all",
1697 "cancel-after",
1698 "mod",
1699 "batch-create",
1700 "batch-cancel",
1701 "batch-replace",
1702 ] {
1703 let generated = OrdersService::coalesce_request_id(None, prefix).unwrap();
1704 assert!(
1705 generated.starts_with(&format!("{prefix}-")),
1706 "unexpected generated id for {prefix}: {generated}"
1707 );
1708 assert_eq!(generated.len(), prefix.len() + 1 + 12);
1709
1710 let blank = OrdersService::coalesce_request_id(Some(" ".into()), prefix).unwrap();
1711 assert!(blank.starts_with(&format!("{prefix}-")));
1712 assert_ne!(generated, blank);
1713 }
1714
1715 assert_eq!(
1716 OrdersService::coalesce_request_id(Some(" retry-mod-1 ".into()), "mod").unwrap(),
1717 "retry-mod-1"
1718 );
1719 assert_eq!(
1720 OrdersService::coalesce_request_id(Some("same-retry".into()), "batch-create").unwrap(),
1721 "same-retry"
1722 );
1723 }
1724
1725 #[test]
1726 fn wait_helper_detects_trade_projection_complete() {
1727 let incomplete = GetOrderResult {
1728 order: Some(Order {
1729 order_id: "1".into(),
1730 symbol_id: 7,
1731 client_order_id: "c".into(),
1732 side: "buy".into(),
1733 status: "filled".into(),
1734 order_type: "market".into(),
1735 tif: "ioc".into(),
1736 orig_qty: None,
1737 cum_qty: Some(
1738 Quantity::from_scaled(
1739 100,
1740 Some(8),
1741 crate::QuantityDomain::OrderBase,
1742 None,
1743 None,
1744 )
1745 .unwrap(),
1746 ),
1747 leaves_qty: None,
1748 price: None,
1749 avg_px: None,
1750 created_ts_ns: String::new(),
1751 version: 1,
1752 post_only: false,
1753 fee_asset: "quote".into(),
1754 submitted_max_quote_debit_scaled: None,
1755 attached_risk: None,
1756 }),
1757 trades: vec![],
1758 };
1759 assert!(!order_trades_projection_complete(&incomplete));
1760
1761 let open_unfilled = GetOrderResult {
1762 order: Some(Order {
1763 status: "working".into(),
1764 cum_qty: Some(
1765 Quantity::from_scaled(0, Some(8), crate::QuantityDomain::OrderBase, None, None)
1766 .unwrap(),
1767 ),
1768 ..incomplete.order.clone().unwrap()
1769 }),
1770 trades: vec![],
1771 };
1772 assert!(
1773 !order_trades_projection_complete(&open_unfilled),
1774 "an unfilled working order is not a stable projection"
1775 );
1776
1777 let complete = GetOrderResult {
1778 order: Some(Order {
1779 status: "filled".into(),
1780 ..incomplete.order.clone().unwrap()
1781 }),
1782 trades: vec![UserTrade {
1783 symbol_id: 7,
1784 match_id: "m".into(),
1785 order_id: "1".into(),
1786 side: "buy".into(),
1787 is_maker: false,
1788 price: None,
1789 qty: Some(
1790 Quantity::from_scaled(
1791 100,
1792 Some(8),
1793 crate::QuantityDomain::OrderBase,
1794 None,
1795 None,
1796 )
1797 .unwrap(),
1798 ),
1799 fee_scaled: "0".into(),
1800 fee_asset: "quote".into(),
1801 referral_share_scaled: "0".into(),
1802 ts_ns: String::new(),
1803 }],
1804 };
1805 assert!(order_trades_projection_complete(&complete));
1806 }
1807}