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 #[allow(deprecated)]
507 fn encode_trailing_stop(
508 stop: &TrailingStop,
509 symbol: Option<&str>,
510 ) -> Result<TrailingStopPolicy> {
511 if stop.trigger_price_source.is_some() {
514 return Err(Error::validation(
515 "attached risk always uses last trade; trigger_price_source cannot be supplied",
516 ));
517 }
518 if stop.order_type.is_some() {
519 return Err(Error::validation(
520 "attached trailing_stop child is always market; order_type cannot be supplied",
521 ));
522 }
523 let mut proto = TrailingStopPolicy::default();
524 if let Some(activation) = stop.activation_price.as_ref() {
525 proto.activation_price_ticks = resolve_price_ticks(activation, symbol)?;
526 }
527 proto.trailing_distance = Some(match stop.distance {
528 TrailingDistance::Ticks(v) => {
529 if v <= 0 {
530 return Err(Error::validation(
531 "trailing_distance_ticks must be positive",
532 ));
533 }
534 trailing_stop_policy::TrailingDistance::TrailingDistanceTicks(v)
535 }
536 TrailingDistance::Bps(v) => {
537 if v <= 0 {
538 return Err(Error::validation("trailing_distance_bps must be positive"));
539 }
540 trailing_stop_policy::TrailingDistance::TrailingDistanceBps(v)
541 }
542 });
543 if let Some(slip) = stop.max_slippage {
544 proto.max_slippage = Some(match slip {
545 MaxSlippage::Ticks(v) => {
546 if v <= 0 {
547 return Err(Error::validation("max_slippage_ticks must be positive"));
548 }
549 trailing_stop_policy::MaxSlippage::MaxSlippageTicks(v)
550 }
551 MaxSlippage::Bps(v) => {
552 if v <= 0 {
553 return Err(Error::validation("max_slippage_bps must be positive"));
554 }
555 trailing_stop_policy::MaxSlippage::MaxSlippageBps(v)
556 }
557 });
558 }
559 Ok(proto)
560 }
561
562 fn encode_attached_risk(risk: &AttachedRisk, symbol: Option<&str>) -> Result<RiskPolicy> {
563 if risk.stop_loss.is_some() && risk.trailing_stop.is_some() {
564 return Err(Error::validation(
565 "attached_risk allows at most one of stop_loss or trailing_stop",
566 ));
567 }
568 if risk.take_profit.is_none() && risk.stop_loss.is_none() && risk.trailing_stop.is_none() {
569 return Err(Error::validation(
570 "attached_risk requires take_profit and/or a stop leg",
571 ));
572 }
573 let mut proto = RiskPolicy {
574 oco: risk.oco,
575 ..Default::default()
576 };
577 if let Some(tp) = risk.take_profit.as_ref() {
578 *proto.take_profit.get_or_insert_default() = Self::encode_take_profit(tp, symbol)?;
579 }
580 if let Some(sl) = risk.stop_loss.as_ref() {
581 proto.stop_leg = Some(risk_policy::StopLeg::StopLoss(Box::new(
582 Self::encode_stop_loss(sl, symbol)?,
583 )));
584 } else if let Some(ts) = risk.trailing_stop.as_ref() {
585 proto.stop_leg = Some(risk_policy::StopLeg::TrailingStop(Box::new(
586 Self::encode_trailing_stop(ts, symbol)?,
587 )));
588 }
589 Ok(proto)
590 }
591
592 fn new_mutation_request_id(prefix: &str) -> Result<String> {
599 let mut random = [0_u8; 6];
600 OsRng
601 .try_fill_bytes(&mut random)
602 .map_err(|err| Error::transport(format!("secure randomness unavailable: {err}")))?;
603 Ok(format!("{prefix}-{}", hex::encode(random)))
604 }
605
606 fn coalesce_request_id(value: Option<String>, prefix: &str) -> Result<String> {
608 if let Some(id) = optional_request_id(value.as_deref())? {
609 return Ok(id);
610 }
611 Self::new_mutation_request_id(prefix)
612 }
613
614 fn modify_behavior(label: &str) -> Result<ModifyBehavior> {
615 match label.to_ascii_lowercase().as_str() {
616 "amend_or_replace" => Ok(ModifyBehavior::AmendOrReplace),
617 "amend_only" => Ok(ModifyBehavior::AmendOnly),
618 "replace_only" => Ok(ModifyBehavior::ReplaceOnly),
619 _ => Err(Error::validation(
620 "behavior must be amend_or_replace, amend_only, or replace_only",
621 )),
622 }
623 }
624
625 fn encode_modify_params(&self, params: ModifyOrderParams) -> Result<ModifyOrderRequest> {
626 if params.new_price.is_none()
627 && params.new_qty.is_none()
628 && params.new_attached_risk.is_none()
629 {
630 return Err(Error::validation(
631 "modify requires new_price, new_qty, and/or new_attached_risk",
632 ));
633 }
634 let scale = self.require_quantity_scale(
635 ¶ms.symbol,
636 params.new_qty.as_ref().and_then(Quantity::scale),
637 )?;
638 let mut req = ModifyOrderRequest {
639 subaccount_id: scope::optional_subaccount(&self.ctx, params.subaccount_id)?,
640 request_id: Self::coalesce_request_id(params.request_id, "mod")?,
641 key: Some(Self::encode_modify_order_key(¶ms.key)?),
642 ..Default::default()
643 };
644 if let Some(price) = params.new_price.as_ref() {
645 req.new_price_ticks = Some(resolve_price_ticks(price, Some(¶ms.symbol))?);
646 }
647 if let Some(qty) = params.new_qty.as_ref() {
648 req.new_qty_scaled = Some(resolve_qty_scaled(
649 qty,
650 scale,
651 Some(¶ms.symbol),
652 self.ctx.catalogs.symbol_id_for_symbol(¶ms.symbol),
653 )?);
654 }
655 if let Some(risk) = params.new_attached_risk.as_ref() {
656 *req.new_attached_risk.get_or_insert_default() =
657 Self::encode_attached_risk(risk, Some(¶ms.symbol))?;
658 }
659 if let Some(behavior) = params.behavior.as_deref() {
660 req.behavior = Self::modify_behavior(behavior)?.into();
661 }
662 if let Some(ncid) = optional_client_order_id(params.new_client_order_id.as_deref())? {
663 req.new_client_order_id = ncid;
664 }
665 Ok(req)
666 }
667
668 pub async fn create(&self, params: CreateOrderParams) -> Result<OrderMutationResult> {
670 self.ctx.wait_for_catalogs().await?;
671 let req = self.encode_create_params(¶ms)?;
672 let client = self.write_client();
673 let resp = unary::await_auth(
674 &self.ctx.factory,
675 "/orders.v1.OrdersService/CreateOrder",
676 req,
677 |req, opts| client.create_order_with_options(req, opts),
678 )
679 .await?
680 .into_owned();
681 order_mutation_from_create(&resp)
682 }
683
684 pub async fn preview(&self, params: PreviewOrderParams) -> Result<PreviewOrderResult> {
692 self.ctx.wait_for_catalogs().await?;
693 let req = self.encode_preview_params(¶ms)?;
694 let client = self.write_client();
695 let resp = unary::await_auth(
696 &self.ctx.factory,
697 "/orders.v1.OrdersService/PreviewOrder",
698 req,
699 |req, opts| client.preview_order_with_options(req, opts),
700 )
701 .await?
702 .into_owned();
703 let base_scale = self.require_quantity_scale(¶ms.symbol, None)?;
705 preview_order_from_proto(
706 &resp,
707 base_scale,
708 ¶ms.symbol,
709 self.ctx.catalogs.symbol_id_for_symbol(¶ms.symbol),
710 )
711 }
712
713 fn encode_preview_params(&self, params: &PreviewOrderParams) -> Result<PreviewOrderRequest> {
714 let create = CreateOrderParams {
718 symbol: params.symbol.clone(),
719 side: params.side,
720 order_type: params.order_type,
721 quantity: params.quantity.clone(),
722 max_quote_debit_scaled: params.max_quote_debit_scaled.clone(),
723 price: params.price.clone(),
724 time_in_force: params.time_in_force,
725 client_order_id: params.client_order_id.clone(),
726 subaccount_id: params.subaccount_id,
727 post_only: params.post_only,
728 market_client_ref_price: params.market_client_ref_price.clone(),
729 fee_asset: params.fee_asset,
730 self_trade_prevention: params.self_trade_prevention,
731 market_max_slippage: params.market_max_slippage,
732 attached_risk: params.attached_risk.clone(),
733 };
734 let order = self.order_intent_from_params(&create)?;
735 let mut req = PreviewOrderRequest {
736 subaccount_id: scope::optional_subaccount(&self.ctx, params.subaccount_id)?,
737 ..Default::default()
738 };
739 *req.order.get_or_insert_default() = order;
740 Ok(req)
741 }
742
743 pub async fn batch_create(
748 &self,
749 items: Vec<CreateOrderParams>,
750 subaccount_id: Option<u64>,
751 request_id: Option<String>,
752 ) -> Result<BatchCreateOrdersResult> {
753 Self::validate_batch_size("batch_create", items.len())?;
754 self.ctx.wait_for_catalogs().await?;
755 let mut encoded = Vec::with_capacity(items.len());
756 for item in &items {
757 encoded.push(self.order_intent_from_params(item)?);
758 }
759 let req = BatchCreateOrdersRequest {
760 subaccount_id: scope::optional_subaccount(&self.ctx, subaccount_id)?,
761 request_id: Self::coalesce_request_id(request_id, "batch-create")?,
762 items: encoded,
763 ..Default::default()
764 };
765 let client = self.write_client();
766 let resp = unary::await_auth(
767 &self.ctx.factory,
768 "/orders.v1.OrdersService/BatchCreateOrders",
769 req,
770 |req, opts| client.batch_create_orders_with_options(req, opts),
771 )
772 .await?
773 .into_owned();
774 batch_create_from_proto(&resp)
775 }
776
777 pub async fn batch_cancel(
782 &self,
783 items: Vec<BatchCancelItem>,
784 subaccount_id: Option<u64>,
785 request_id: Option<String>,
786 ) -> Result<BatchCancelOrdersResult> {
787 Self::validate_batch_size("batch_cancel", items.len())?;
788 let mut proto_items = Vec::with_capacity(items.len());
789 for item in items {
790 let mut proto = ProtoBatchCancelItem::default();
791 match &item.key {
792 OrderKey::OrderId(oid) => {
793 proto.order_id = id_to_u64(oid, "order_id")?;
794 }
795 OrderKey::ClientOrderId(cid) => {
796 proto.client_order_id = require_client_style_id(cid, "client_order_id")?;
797 }
798 }
799 if let Some(sid) = item.symbol_id {
800 proto.symbol_id = sid;
801 }
802 proto_items.push(proto);
803 }
804 let req = BatchCancelOrdersRequest {
805 subaccount_id: scope::optional_subaccount(&self.ctx, subaccount_id)?,
806 request_id: Self::coalesce_request_id(request_id, "batch-cancel")?,
807 items: proto_items,
808 ..Default::default()
809 };
810 let client = self.write_client();
811 let resp = unary::await_auth(
812 &self.ctx.factory,
813 "/orders.v1.OrdersService/BatchCancelOrders",
814 req,
815 |req, opts| client.batch_cancel_orders_with_options(req, opts),
816 )
817 .await?
818 .into_owned();
819 batch_cancel_from_proto(&resp)
820 }
821
822 pub async fn batch_replace(
827 &self,
828 items: Vec<BatchReplaceItem>,
829 symbol: &str,
830 subaccount_id: Option<u64>,
831 request_id: Option<String>,
832 ) -> Result<BatchReplaceOrdersResult> {
833 Self::validate_batch_size("batch_replace", items.len())?;
834 self.ctx.wait_for_catalogs().await?;
835 let symbol_id = self
836 .ctx
837 .catalogs
838 .symbol_id_for_symbol(symbol)
839 .ok_or_else(|| {
840 Error::validation(format!(
841 "unknown symbol {symbol}; call hydrate_catalogs / get_spot_config first"
842 ))
843 })?;
844 let scale = Self::resolve_batch_replace_scale(&self.ctx.catalogs, symbol)?;
845 let mut proto_items = Vec::with_capacity(items.len());
846 for item in items {
847 if item.new_price.is_none()
848 && item.new_qty.is_none()
849 && item.new_attached_risk.is_none()
850 {
851 return Err(Error::validation(
852 "each batch item requires new_price, new_qty, and/or new_attached_risk",
853 ));
854 }
855 let mut proto = ProtoBatchReplaceOrderItem {
856 key: Some(Self::encode_batch_replace_key(&item.key)?),
857 ..Default::default()
858 };
859 if let Some(price) = item.new_price.as_ref() {
860 proto.new_price_ticks = Some(resolve_price_ticks(price, Some(symbol))?);
861 }
862 if let Some(qty) = item.new_qty.as_ref() {
863 proto.new_qty_scaled = Some(resolve_qty_scaled(
864 qty,
865 scale,
866 Some(symbol),
867 Some(symbol_id),
868 )?);
869 }
870 if let Some(risk) = item.new_attached_risk.as_ref() {
871 *proto.new_attached_risk.get_or_insert_default() =
872 Self::encode_attached_risk(risk, Some(symbol))?;
873 }
874 if let Some(ncid) = optional_client_order_id(item.new_client_order_id.as_deref())? {
875 proto.new_client_order_id = ncid;
876 }
877 proto_items.push(proto);
878 }
879 let req = BatchReplaceOrdersRequest {
880 subaccount_id: scope::optional_subaccount(&self.ctx, subaccount_id)?,
881 symbol_id,
882 request_id: Self::coalesce_request_id(request_id, "batch-replace")?,
883 items: proto_items,
884 ..Default::default()
885 };
886 let client = self.write_client();
887 let resp = unary::await_auth(
888 &self.ctx.factory,
889 "/orders.v1.OrdersService/BatchReplaceOrders",
890 req,
891 |req, opts| client.batch_replace_orders_with_options(req, opts),
892 )
893 .await?
894 .into_owned();
895 batch_replace_from_proto(&resp)
896 }
897
898 pub async fn get_batch_replace_status(
900 &self,
901 batch_request_id: &str,
902 subaccount_id: Option<u64>,
903 ) -> Result<BatchReplaceStatusResult> {
904 let req = GetBatchReplaceStatusRequest {
905 subaccount_id: scope::optional_subaccount(&self.ctx, subaccount_id)?,
906 batch_request_id: id_to_u64(batch_request_id, "batch_request_id")?,
907 ..Default::default()
908 };
909 let client = self.read_client();
910 let resp = unary::await_auth(
911 &self.ctx.factory,
912 "/orders.v1.OrdersReadService/GetBatchReplaceStatus",
913 req,
914 |req, opts| client.get_batch_replace_status_with_options(req, opts),
915 )
916 .await?
917 .into_owned();
918 batch_replace_status_from_proto(&resp)
919 }
920
921 pub async fn cancel_all_after(
926 &self,
927 timeout_sec: u32,
928 symbol: Option<&str>,
929 subaccount_id: Option<u64>,
930 request_id: Option<String>,
931 ) -> Result<CancelAllAfterResult> {
932 let req = CancelAllAfterRequest {
933 subaccount_id: scope::optional_subaccount(&self.ctx, subaccount_id)?,
934 timeout_sec,
935 symbol: symbol.unwrap_or("").to_owned(),
936 request_id: Self::coalesce_request_id(request_id, "cancel-after")?,
937 ..Default::default()
938 };
939 let client = self.write_client();
940 let resp = unary::await_auth(
941 &self.ctx.factory,
942 "/orders.v1.OrdersService/CancelAllAfter",
943 req,
944 |req, opts| client.cancel_all_after_with_options(req, opts),
945 )
946 .await?
947 .into_owned();
948 cancel_all_after_from_proto(&resp)
949 }
950
951 pub async fn cancel(&self, req: CancelOrderRequest) -> Result<OrderMutationResult> {
952 let client = self.write_client();
953 let resp = unary::await_auth(
954 &self.ctx.factory,
955 "/orders.v1.OrdersService/CancelOrder",
956 req,
957 |req, opts| client.cancel_order_with_options(req, opts),
958 )
959 .await?
960 .into_owned();
961 order_mutation_from_cancel(&resp)
962 }
963
964 pub async fn cancel_with(&self, params: CancelOrderParams) -> Result<OrderMutationResult> {
965 if params.symbol_id.is_none() && params.symbol.is_some() {
968 self.ctx.wait_for_catalogs().await?;
969 }
970 let symbol_id = Self::resolve_cancel_symbol_id(
971 &self.ctx.catalogs,
972 params.symbol.as_deref(),
973 params.symbol_id,
974 )?;
975 let req = CancelOrderRequest {
976 symbol_id,
977 subaccount_id: scope::optional_subaccount(&self.ctx, params.subaccount_id)?,
978 key: Some(Self::encode_cancel_order_key(¶ms.key)?),
979 ..Default::default()
980 };
981 self.cancel(req).await
982 }
983
984 fn resolve_cancel_symbol_id(
985 catalogs: &crate::catalogs::Manager,
986 symbol: Option<&str>,
987 symbol_id: Option<u32>,
988 ) -> Result<u32> {
989 match (symbol, symbol_id) {
990 (None, None) => Ok(0),
991 (_, Some(0)) => Err(Error::validation(
992 "symbol_id must be non-zero when explicitly supplied",
993 )),
994 (Some(_), Some(_)) => Err(Error::validation(
995 "cancel accepts symbol or symbol_id, not both",
996 )),
997 (None, Some(symbol_id)) => Ok(symbol_id),
998 (Some(symbol), None) => catalogs.symbol_id_for_symbol(symbol).ok_or_else(|| {
999 Error::validation(format!(
1000 "unknown symbol {symbol}; call hydrate_catalogs / get_spot_config first"
1001 ))
1002 }),
1003 }
1004 }
1005
1006 pub async fn cancel_by_client_order_id(
1007 &self,
1008 client_order_id: &str,
1009 symbol: Option<&str>,
1010 subaccount_id: Option<u64>,
1011 ) -> Result<OrderMutationResult> {
1012 self.cancel_with(CancelOrderParams {
1013 key: OrderKey::ClientOrderId(client_order_id.to_owned()),
1014 symbol: symbol.map(|s| s.to_owned()),
1015 symbol_id: None,
1016 subaccount_id,
1017 })
1018 .await
1019 }
1020
1021 pub async fn cancel_by_order_id(
1022 &self,
1023 order_id: &str,
1024 subaccount_id: Option<u64>,
1025 ) -> Result<OrderMutationResult> {
1026 self.cancel_with(CancelOrderParams {
1027 key: OrderKey::OrderId(order_id.to_owned()),
1028 symbol: None,
1029 symbol_id: None,
1030 subaccount_id,
1031 })
1032 .await
1033 }
1034
1035 pub async fn cancel_all(
1040 &self,
1041 symbol: Option<&str>,
1042 dry_run: bool,
1043 subaccount_id: Option<u64>,
1044 ) -> Result<CancelAllOrdersResult> {
1045 self.cancel_all_with(CancelAllOpts {
1046 symbol: symbol.map(|s| s.to_owned()),
1047 dry_run,
1048 subaccount_id,
1049 ..Default::default()
1050 })
1051 .await
1052 }
1053
1054 pub async fn cancel_all_with(&self, opts: CancelAllOpts) -> Result<CancelAllOrdersResult> {
1059 let mut req = CancelAllOrdersRequest {
1060 subaccount_id: scope::optional_subaccount(&self.ctx, opts.subaccount_id)?,
1061 symbol: opts.symbol.unwrap_or_default(),
1062 dry_run: opts.dry_run,
1063 request_id: Self::coalesce_request_id(opts.request_id, "cancel-all")?,
1064 ..Default::default()
1065 };
1066 if let Some(side) = opts.side.as_deref() {
1067 req.side = Self::parse_side(side)?.into();
1068 }
1069 let client = self.write_client();
1070 let resp = unary::await_auth(
1071 &self.ctx.factory,
1072 "/orders.v1.OrdersService/CancelAllOrders",
1073 req,
1074 |req, opts| client.cancel_all_orders_with_options(req, opts),
1075 )
1076 .await?
1077 .into_owned();
1078 cancel_all_from_proto(&resp)
1079 }
1080
1081 fn parse_side(side: &str) -> Result<Side> {
1082 match side.to_ascii_lowercase().as_str() {
1083 "buy" => Ok(Side::Buy),
1084 "sell" => Ok(Side::Sell),
1085 _ => Err(Error::validation("side must be buy or sell")),
1086 }
1087 }
1088
1089 pub async fn modify(&self, params: ModifyOrderParams) -> Result<ModifyOrderResult> {
1095 self.ctx.wait_for_catalogs().await?;
1096 let req = self.encode_modify_params(params)?;
1097 let client = self.write_client();
1098 let resp = unary::await_auth(
1099 &self.ctx.factory,
1100 "/orders.v1.OrdersService/ModifyOrder",
1101 req,
1102 |req, opts| client.modify_order_with_options(req, opts),
1103 )
1104 .await?
1105 .into_owned();
1106 modify_order_from_proto(&resp)
1107 }
1108
1109 pub fn create_params(
1110 symbol: impl Into<String>,
1111 side: CreateSide,
1112 order_type: CreateOrderType,
1113 quantity: Quantity,
1114 price: Option<Price>,
1115 client_order_id: Option<&str>,
1116 ) -> CreateOrderParams {
1117 let client_order_id = client_order_id
1118 .map(str::trim)
1119 .filter(|s| !s.is_empty())
1120 .map(|s| s.to_owned());
1121 CreateOrderParams {
1122 symbol: symbol.into(),
1123 side,
1124 order_type,
1125 quantity: Some(quantity),
1126 max_quote_debit_scaled: None,
1127 price,
1128 time_in_force: None,
1129 client_order_id,
1130 subaccount_id: None,
1131 post_only: None,
1132 market_client_ref_price: None,
1133 fee_asset: None,
1134 self_trade_prevention: None,
1135 market_max_slippage: None,
1136 attached_risk: None,
1137 }
1138 }
1139
1140 pub(crate) fn resolve_batch_replace_scale(
1142 catalogs: &crate::catalogs::Manager,
1143 symbol: &str,
1144 ) -> Result<u32> {
1145 catalogs.base_quantity_scale_for_symbol(symbol).ok_or_else(|| {
1146 Error::validation(format!(
1147 "quantity scale for {symbol:?} is unavailable; await client.wait_for_catalogs() before placing orders"
1148 ))
1149 })
1150 }
1151
1152 pub async fn subscribe(
1154 &self,
1155 account_id: Option<&str>,
1156 ) -> Result<crate::realtime::TypedSubscription<Order>> {
1157 let account = scope::resolve_account_id(&self.ctx, account_id)?;
1158 let channel = format!("private:spot:orders:{account}:proto");
1159 self.ctx
1160 .realtime
1161 .subscribe_proto(&channel, crate::codecs::decode::order_from_bytes)
1162 .await
1163 }
1164}
1165
1166#[derive(Clone)]
1167pub struct TradesService {
1168 ctx: ServiceContext,
1169}
1170
1171impl TradesService {
1172 pub fn new(ctx: ServiceContext) -> Self {
1173 Self { ctx }
1174 }
1175
1176 pub async fn list(
1177 &self,
1178 subaccount_id: Option<u64>,
1179 limit: Option<u32>,
1180 ) -> Result<UserTradesList> {
1181 let req = GetUserTradesRequest {
1182 subaccount_id: scope::optional_subaccount(&self.ctx, subaccount_id)?,
1183 limit,
1184 ..Default::default()
1185 };
1186 let client = OrdersReadServiceClient::new(
1187 self.ctx.factory.transport(),
1188 self.ctx.factory.connect_config(),
1189 );
1190 let resp = unary::await_auth(
1191 &self.ctx.factory,
1192 "/orders.v1.OrdersReadService/GetUserTrades",
1193 req,
1194 |req, opts| client.get_user_trades_with_options(req, opts),
1195 )
1196 .await?
1197 .into_owned();
1198 Ok(user_trades_list_from_proto(&resp))
1199 }
1200
1201 pub async fn subscribe(
1203 &self,
1204 account_id: Option<&str>,
1205 ) -> Result<crate::realtime::TypedSubscription<UserTrade>> {
1206 let account = scope::resolve_account_id(&self.ctx, account_id)?;
1207 let channel = format!("private:spot:trades:{account}:proto");
1208 self.ctx
1209 .realtime
1210 .subscribe_proto(&channel, crate::codecs::decode::user_trade_from_bytes)
1211 .await
1212 }
1213}
1214
1215fn order_trades_projection_complete(result: &GetOrderResult) -> bool {
1216 let Some(order) = result.order.as_ref() else {
1217 return false;
1218 };
1219 if !matches!(order.status.as_str(), "filled" | "canceled" | "rejected") {
1220 return false;
1221 }
1222 let Some(cum) = order.cum_qty.as_ref() else {
1223 return false;
1224 };
1225 let cum = cum.as_scaled();
1226 if cum == 0 {
1227 return true;
1228 }
1229 let mut trade_sum = 0_i64;
1230 for trade in &result.trades {
1231 let Some(qty) = trade.qty.as_ref() else {
1232 return false;
1233 };
1234 let Some(sum) = trade_sum.checked_add(qty.as_scaled()) else {
1235 return false;
1236 };
1237 trade_sum = sum;
1238 }
1239 trade_sum == cum
1240}
1241
1242#[cfg(test)]
1243mod tests {
1244 use super::*;
1245 use crate::codecs::scalars::format_id;
1246 use buffa::Message;
1247 use serde_json::json;
1248
1249 fn client() -> crate::Client {
1250 let client = crate::Client::new(crate::Config {
1251 hydrate_catalogs: false,
1252 ..Default::default()
1253 })
1254 .unwrap();
1255 client
1256 .catalogs
1257 .hydrate_spot_config_json(json!({
1258 "pairs": [{
1259 "symbol": "BTC-USDT",
1260 "symbol_id": 7,
1261 "base_quantity_scale": 8,
1262 "quote_quantity_scale": 6
1263 }]
1264 }))
1265 .expect("hydrate");
1266 client
1267 }
1268
1269 fn create_params(quantity: Quantity, price: Price) -> CreateOrderParams {
1270 CreateOrderParams {
1271 symbol: "BTC-USDT".into(),
1272 side: CreateSide::Buy,
1273 order_type: CreateOrderType::Limit,
1274 quantity: Some(quantity),
1275 max_quote_debit_scaled: None,
1276 price: Some(price),
1277 time_in_force: Some(CreateTimeInForce::Gtc),
1278 client_order_id: Some("order-equivalence".into()),
1279 subaccount_id: None,
1280 post_only: Some(true),
1281 market_client_ref_price: None,
1282 fee_asset: None,
1283 self_trade_prevention: None,
1284 market_max_slippage: None,
1285 attached_risk: None,
1286 }
1287 }
1288
1289 #[test]
1290 fn decimal_and_scaled_create_encode_identically() {
1291 let client = client();
1292 let decimal = create_params(
1293 Quantity::from_decimal_str("0.1", 8, Some("BTC-USDT".into()), Some(7)).unwrap(),
1294 Price::from_decimal_str("50000", Some("BTC-USDT".into())).unwrap(),
1295 );
1296 let scaled = create_params(
1297 Quantity::from_scaled(
1298 10_000_000,
1299 Some(8),
1300 crate::QuantityDomain::OrderBase,
1301 Some("BTC-USDT".into()),
1302 Some(7),
1303 )
1304 .unwrap(),
1305 Price::from_ticks(50_000_000_000, Some("BTC-USDT".into())).unwrap(),
1306 );
1307
1308 let decimal_wire = client.orders.encode_create_params(&decimal).unwrap();
1309 let scaled_wire = client.orders.encode_create_params(&scaled).unwrap();
1310 assert_eq!(decimal_wire.encode_to_vec(), scaled_wire.encode_to_vec());
1311 }
1312
1313 fn modify_params(new_price: Option<Price>, new_qty: Option<Quantity>) -> ModifyOrderParams {
1314 ModifyOrderParams {
1315 symbol: "BTC-USDT".into(),
1316 key: OrderKey::OrderId("1".into()),
1317 subaccount_id: None,
1318 request_id: Some("modify-equivalence".into()),
1319 new_price,
1320 new_qty,
1321 new_attached_risk: None,
1322 behavior: Some("amend_or_replace".into()),
1323 new_client_order_id: None,
1324 }
1325 }
1326
1327 #[test]
1328 fn decimal_and_scaled_modify_encode_identically() {
1329 let client = client();
1330 let decimal = modify_params(
1331 Some(Price::from_decimal_str("50001", Some("BTC-USDT".into())).unwrap()),
1332 Some(Quantity::from_decimal_str("0.2", 8, Some("BTC-USDT".into()), Some(7)).unwrap()),
1333 );
1334 let scaled = modify_params(
1335 Some(Price::from_ticks(50_001_000_000, Some("BTC-USDT".into())).unwrap()),
1336 Some(
1337 Quantity::from_scaled(
1338 20_000_000,
1339 Some(8),
1340 crate::QuantityDomain::OrderBase,
1341 Some("BTC-USDT".into()),
1342 Some(7),
1343 )
1344 .unwrap(),
1345 ),
1346 );
1347
1348 let decimal_wire = client.orders.encode_modify_params(decimal).unwrap();
1349 let scaled_wire = client.orders.encode_modify_params(scaled).unwrap();
1350 assert_eq!(decimal_wire.encode_to_vec(), scaled_wire.encode_to_vec());
1351 }
1352
1353 #[test]
1354 fn batch_replace_requires_catalog_quantity_scale() {
1355 let catalogs = crate::catalogs::Manager::new();
1356 let err = OrdersService::resolve_batch_replace_scale(&catalogs, "BTC-USDT").unwrap_err();
1357 assert!(
1358 err.to_string().contains("quantity scale"),
1359 "unexpected error: {err}"
1360 );
1361 }
1362
1363 #[test]
1364 fn batch_replace_uses_symbol_catalog_quantity_scale() {
1365 let client = client();
1366 assert_eq!(
1367 OrdersService::resolve_batch_replace_scale(&client.catalogs, "BTC-USDT").unwrap(),
1368 8
1369 );
1370 }
1371
1372 #[test]
1373 fn modify_validates_key_and_patch() {
1374 let client = client();
1375 let empty_key = ModifyOrderParams {
1376 key: OrderKey::ClientOrderId(String::new()),
1377 ..modify_params(Some(Price::from_ticks(1, None).unwrap()), None)
1378 };
1379 assert!(client.orders.encode_modify_params(empty_key).is_err());
1380
1381 let no_patch = modify_params(None, None);
1382 assert!(client.orders.encode_modify_params(no_patch).is_err());
1383 }
1384
1385 #[test]
1386 #[allow(deprecated)]
1387 fn attached_risk_encodes_on_create_and_modify() {
1388 use crate::models::{AttachedRisk, RiskLeg, TriggerPriceSourceKind};
1389
1390 let client = client();
1391 let risk = AttachedRisk {
1392 take_profit: Some(RiskLeg {
1393 trigger_price: Price::from_ticks(51_000_000_000, Some("BTC-USDT".into())).unwrap(),
1394 trigger_price_source: None,
1395 order_type: Some(CreateOrderType::Market),
1396 limit_price: None,
1397 }),
1398 stop_loss: Some(RiskLeg {
1399 trigger_price: Price::from_ticks(49_000_000_000, Some("BTC-USDT".into())).unwrap(),
1400 trigger_price_source: None,
1401 order_type: Some(CreateOrderType::Limit),
1402 limit_price: Some(
1403 Price::from_ticks(48_900_000_000, Some("BTC-USDT".into())).unwrap(),
1404 ),
1405 }),
1406 trailing_stop: None,
1407 oco: true,
1408 };
1409
1410 let mut create = create_params(
1411 Quantity::from_scaled(
1412 10_000_000,
1413 Some(8),
1414 crate::QuantityDomain::OrderBase,
1415 Some("BTC-USDT".into()),
1416 Some(7),
1417 )
1418 .unwrap(),
1419 Price::from_ticks(50_000_000_000, Some("BTC-USDT".into())).unwrap(),
1420 );
1421 create.attached_risk = Some(risk.clone());
1422 let create_wire = client.orders.encode_create_params(&create).unwrap();
1423 let order = create_wire.order.as_option().unwrap();
1424 assert!(order.attached_risk.is_set());
1425 assert!(order.attached_risk.as_option().unwrap().oco);
1426
1427 let mut modify = modify_params(None, None);
1428 modify.new_attached_risk = Some(risk);
1429 let modify_wire = client.orders.encode_modify_params(modify).unwrap();
1430 assert!(modify_wire.new_attached_risk.is_set());
1431
1432 let mut unsupported = create;
1433 unsupported
1434 .attached_risk
1435 .as_mut()
1436 .unwrap()
1437 .take_profit
1438 .as_mut()
1439 .unwrap()
1440 .trigger_price_source = Some(TriggerPriceSourceKind::IndexPrice);
1441 let err = client
1442 .orders
1443 .encode_create_params(&unsupported)
1444 .unwrap_err();
1445 assert!(matches!(&err, Error::Validation(_)));
1446 assert!(err.to_string().contains("always uses last trade"));
1447 }
1448
1449 #[test]
1450 #[allow(deprecated)]
1451 fn attached_trailing_stop_validates_positive_fields_and_rejects_silent_compat() {
1452 use crate::models::{
1453 AttachedRisk, MaxSlippage, TrailingDistance, TrailingStop, TriggerPriceSourceKind,
1454 };
1455
1456 let client = client();
1457 let base = create_params(
1458 Quantity::from_scaled(
1459 10_000_000,
1460 Some(8),
1461 crate::QuantityDomain::OrderBase,
1462 Some("BTC-USDT".into()),
1463 Some(7),
1464 )
1465 .unwrap(),
1466 Price::from_ticks(50_000_000_000, Some("BTC-USDT".into())).unwrap(),
1467 );
1468
1469 let mut zero_distance = base.clone();
1470 zero_distance.attached_risk = Some(AttachedRisk {
1471 trailing_stop: Some(TrailingStop {
1472 distance: TrailingDistance::Ticks(0),
1473 activation_price: None,
1474 trigger_price_source: None,
1475 order_type: None,
1476 max_slippage: None,
1477 }),
1478 ..Default::default()
1479 });
1480 let err = client
1481 .orders
1482 .encode_create_params(&zero_distance)
1483 .unwrap_err();
1484 assert!(err.to_string().contains("trailing_distance_ticks must be positive"));
1485
1486 let mut zero_slip = base.clone();
1487 zero_slip.attached_risk = Some(AttachedRisk {
1488 trailing_stop: Some(TrailingStop {
1489 distance: TrailingDistance::Bps(25),
1490 activation_price: None,
1491 trigger_price_source: None,
1492 order_type: None,
1493 max_slippage: Some(MaxSlippage::Ticks(0)),
1494 }),
1495 ..Default::default()
1496 });
1497 let err = client.orders.encode_create_params(&zero_slip).unwrap_err();
1498 assert!(err.to_string().contains("max_slippage_ticks must be positive"));
1499
1500 let mut with_source = base.clone();
1501 with_source.attached_risk = Some(AttachedRisk {
1502 trailing_stop: Some(TrailingStop {
1503 distance: TrailingDistance::Bps(25),
1504 activation_price: None,
1505 trigger_price_source: Some(TriggerPriceSourceKind::IndexPrice),
1506 order_type: None,
1507 max_slippage: None,
1508 }),
1509 ..Default::default()
1510 });
1511 let err = client
1512 .orders
1513 .encode_create_params(&with_source)
1514 .unwrap_err();
1515 assert!(err.to_string().contains("always uses last trade"));
1516
1517 let mut with_order_type = base;
1518 with_order_type.attached_risk = Some(AttachedRisk {
1519 trailing_stop: Some(TrailingStop {
1520 distance: TrailingDistance::Bps(25),
1521 activation_price: None,
1522 trigger_price_source: None,
1523 order_type: Some(CreateOrderType::Limit),
1524 max_slippage: None,
1525 }),
1526 ..Default::default()
1527 });
1528 let err = client
1529 .orders
1530 .encode_create_params(&with_order_type)
1531 .unwrap_err();
1532 assert!(err.to_string().contains("always market"));
1533 }
1534
1535 #[test]
1536 fn preview_encodes_full_order_intent() {
1537 let client = client();
1538 let create = create_params(
1539 Quantity::from_scaled(
1540 10_000_000,
1541 Some(8),
1542 crate::QuantityDomain::OrderBase,
1543 Some("BTC-USDT".into()),
1544 Some(7),
1545 )
1546 .unwrap(),
1547 Price::from_ticks(50_000_000_000, Some("BTC-USDT".into())).unwrap(),
1548 );
1549 let preview = PreviewOrderParams {
1550 symbol: create.symbol.clone(),
1551 side: create.side,
1552 order_type: create.order_type,
1553 quantity: create.quantity.clone(),
1554 max_quote_debit_scaled: None,
1555 price: create.price.clone(),
1556 time_in_force: create.time_in_force,
1557 client_order_id: Some("preview-cid".into()),
1558 subaccount_id: Some(9),
1559 post_only: create.post_only,
1560 market_client_ref_price: None,
1561 fee_asset: Some(FeeAsset::Quote),
1562 self_trade_prevention: Some(OrderSelfTradePrevention::ExpireTaker),
1563 market_max_slippage: None,
1564 attached_risk: None,
1565 };
1566 let wire = client.orders.encode_preview_params(&preview).unwrap();
1567 assert_eq!(wire.subaccount_id, Some(9));
1568 let intent = wire.order.as_option().expect("preview order intent");
1569 assert_eq!(intent.symbol, "BTC-USDT");
1570 assert_eq!(intent.side.as_known(), Some(Side::Buy));
1571 assert_eq!(intent.client_order_id, "preview-cid");
1572 assert!(matches!(
1573 intent.sizing,
1574 Some(order_intent::Sizing::BaseQtyScaled(10_000_000))
1575 ));
1576 assert!(matches!(
1577 intent.execution,
1578 Some(order_intent::Execution::LimitGtc(_))
1579 ));
1580 assert_eq!(
1581 intent.self_trade_prevention_mode.as_known(),
1582 Some(SelfTradePreventionMode::ExpireTaker)
1583 );
1584 }
1585
1586 #[test]
1587 fn create_allows_omitted_client_order_id_and_encodes_market_maker_controls() {
1588 let client = client();
1589 let mut params = create_params(
1590 Quantity::from_scaled(
1591 10_000_000,
1592 Some(8),
1593 crate::QuantityDomain::OrderBase,
1594 Some("BTC-USDT".into()),
1595 Some(7),
1596 )
1597 .unwrap(),
1598 Price::from_ticks(50_000_000_000, Some("BTC-USDT".into())).unwrap(),
1599 );
1600 params.client_order_id = None;
1601 let omitted = client.orders.encode_create_params(¶ms).unwrap();
1602 assert!(
1603 omitted
1604 .order
1605 .as_option()
1606 .unwrap()
1607 .client_order_id
1608 .is_empty()
1609 );
1610
1611 params.client_order_id = Some(" ".into());
1612 let whitespace = client.orders.encode_create_params(¶ms).unwrap();
1613 assert!(
1614 whitespace
1615 .order
1616 .as_option()
1617 .unwrap()
1618 .client_order_id
1619 .is_empty()
1620 );
1621
1622 params.client_order_id = Some("mm-create-1".into());
1623 params.order_type = CreateOrderType::Market;
1624 params.price = None;
1625 params.post_only = None;
1626 params.fee_asset = Some(FeeAsset::Base);
1627 params.self_trade_prevention = Some(OrderSelfTradePrevention::ExpireBoth);
1628 params.market_max_slippage = Some(MaxSlippage::Bps(25));
1629 let wire = client.orders.encode_create_params(¶ms).unwrap();
1630 let intent = wire.order.as_option().unwrap();
1631 assert_eq!(intent.fee_asset.as_known(), Some(ProtoFeeAsset::Base));
1632 assert_eq!(
1633 intent.self_trade_prevention_mode.as_known(),
1634 Some(SelfTradePreventionMode::ExpireBoth)
1635 );
1636 let Some(order_intent::Execution::MarketIoc(market)) = intent.execution.as_ref() else {
1637 panic!("expected market execution");
1638 };
1639 assert!(matches!(
1640 market.max_slippage,
1641 Some(market_ioc::MaxSlippage::MaxSlippageBps(25))
1642 ));
1643
1644 params.price = Some(Price::from_ticks(1, None).unwrap());
1645 let err = client.orders.encode_create_params(¶ms).unwrap_err();
1646 assert!(
1647 err.to_string().contains("price is not valid for market"),
1648 "unexpected error: {err}"
1649 );
1650
1651 params.price = None;
1652 params.market_max_slippage = Some(MaxSlippage::Ticks(0));
1653 assert!(client.orders.encode_create_params(¶ms).is_err());
1654 }
1655
1656 #[test]
1657 fn create_encodes_quote_budget_sizing_and_rejects_ambiguous_sizing() {
1658 let client = client();
1659 let mut params = create_params(
1660 Quantity::from_scaled(
1661 10_000_000,
1662 Some(8),
1663 crate::QuantityDomain::OrderBase,
1664 Some("BTC-USDT".into()),
1665 Some(7),
1666 )
1667 .unwrap(),
1668 Price::from_ticks(50_000_000_000, Some("BTC-USDT".into())).unwrap(),
1669 );
1670 params.quantity = None;
1671 params.max_quote_debit_scaled = Some(
1672 Quantity::from_quote_scaled(5_000_000, 6, Some("BTC-USDT".into()), Some(7)).unwrap(),
1673 );
1674 let wire = client.orders.encode_create_params(¶ms).unwrap();
1675 let intent = wire.order.as_option().unwrap();
1676 assert!(matches!(
1677 intent.sizing,
1678 Some(order_intent::Sizing::MaxQuoteDebitScaled(5_000_000))
1679 ));
1680
1681 params.quantity = Some(
1682 Quantity::from_scaled(
1683 10_000_000,
1684 Some(8),
1685 crate::QuantityDomain::OrderBase,
1686 Some("BTC-USDT".into()),
1687 Some(7),
1688 )
1689 .unwrap(),
1690 );
1691 assert!(client.orders.encode_create_params(¶ms).is_err());
1692
1693 params.quantity = None;
1694 params.max_quote_debit_scaled =
1695 Some(Quantity::from_quote_scaled(5_000_000, 8, None, None).unwrap());
1696 let err = client.orders.encode_create_params(¶ms).unwrap_err();
1697 assert!(err.to_string().contains("scale mismatch"));
1698 }
1699
1700 #[test]
1701 fn batch_size_guard_rejects_empty_and_more_than_twenty() {
1702 assert!(OrdersService::validate_batch_size("batch_create", 1).is_ok());
1703 assert!(OrdersService::validate_batch_size("batch_create", 20).is_ok());
1704 assert!(
1705 OrdersService::validate_batch_size("batch_create", 0)
1706 .unwrap_err()
1707 .to_string()
1708 .contains("at least one")
1709 );
1710 assert!(
1711 OrdersService::validate_batch_size("batch_create", 21)
1712 .unwrap_err()
1713 .to_string()
1714 .contains("at most 20")
1715 );
1716 }
1717
1718 #[test]
1719 fn create_rejects_invalid_client_order_id_before_wire() {
1720 let client = client();
1721 let mut params = create_params(
1722 Quantity::from_scaled(
1723 10_000_000,
1724 Some(8),
1725 crate::QuantityDomain::OrderBase,
1726 Some("BTC-USDT".into()),
1727 Some(7),
1728 )
1729 .unwrap(),
1730 Price::from_ticks(50_000_000_000, Some("BTC-USDT".into())).unwrap(),
1731 );
1732
1733 params.client_order_id = Some("bad id".into());
1734 let err = client.orders.encode_create_params(¶ms).unwrap_err();
1735 assert!(err.to_string().contains("invalid characters"));
1736
1737 params.client_order_id = Some("a".repeat(37));
1738 let err = client.orders.encode_create_params(¶ms).unwrap_err();
1739 assert!(err.to_string().contains("1 to 36"));
1740
1741 params.client_order_id = Some("ok-id_1.2:3/4".into());
1742 assert!(client.orders.encode_create_params(¶ms).is_ok());
1743
1744 let err = OrdersService::coalesce_request_id(Some("bad id".into()), "mod").unwrap_err();
1745 assert!(err.to_string().contains("invalid characters"));
1746 let err = OrdersService::coalesce_request_id(Some("r".repeat(65)), "mod").unwrap_err();
1747 assert!(err.to_string().contains("1 to 64"));
1748 }
1749
1750 #[tokio::test]
1751 async fn singular_order_methods_reject_invalid_client_order_id_before_transport() {
1752 let client = client();
1753 let err = client
1754 .orders
1755 .cancel_by_client_order_id("bad id!", None, None)
1756 .await
1757 .unwrap_err();
1758 assert!(matches!(err, Error::Validation(_)));
1759 assert!(err.to_string().contains("invalid characters"));
1760
1761 let err = client
1762 .orders
1763 .get(OrderKey::ClientOrderId("bad id!".into()), None)
1764 .await
1765 .unwrap_err();
1766 assert!(matches!(err, Error::Validation(_)));
1767 assert!(err.to_string().contains("invalid characters"));
1768 }
1769
1770 #[test]
1771 fn cancel_symbol_routing_distinguishes_omitted_and_invalid_inputs() {
1772 let client = client();
1773 assert_eq!(
1774 OrdersService::resolve_cancel_symbol_id(&client.catalogs, None, None).unwrap(),
1775 0
1776 );
1777 assert_eq!(
1778 OrdersService::resolve_cancel_symbol_id(&client.catalogs, Some("BTC-USDT"), None)
1779 .unwrap(),
1780 7
1781 );
1782
1783 for err in [
1784 OrdersService::resolve_cancel_symbol_id(&client.catalogs, Some("UNKNOWN-USDT"), None)
1785 .unwrap_err(),
1786 OrdersService::resolve_cancel_symbol_id(&client.catalogs, None, Some(0)).unwrap_err(),
1787 OrdersService::resolve_cancel_symbol_id(&client.catalogs, Some("BTC-USDT"), Some(7))
1788 .unwrap_err(),
1789 ] {
1790 assert!(matches!(err, Error::Validation(_)));
1791 }
1792 }
1793
1794 #[tokio::test]
1795 async fn cancel_rejects_unknown_supplied_symbol_before_transport() {
1796 let client = client();
1797 let err = client
1798 .orders
1799 .cancel_with(CancelOrderParams {
1800 key: OrderKey::OrderId(format_id(9)),
1801 symbol: Some("UNKNOWN-USDT".into()),
1802 symbol_id: None,
1803 subaccount_id: None,
1804 })
1805 .await
1806 .unwrap_err();
1807 assert!(matches!(&err, Error::Validation(_)));
1808 assert!(err.to_string().contains("unknown symbol"));
1809 }
1810
1811 #[test]
1812 fn mutation_request_ids_are_generated_when_omitted_like_go_python_typescript() {
1813 for prefix in [
1814 "cancel-all",
1815 "cancel-after",
1816 "mod",
1817 "batch-create",
1818 "batch-cancel",
1819 "batch-replace",
1820 ] {
1821 let generated = OrdersService::coalesce_request_id(None, prefix).unwrap();
1822 assert!(
1823 generated.starts_with(&format!("{prefix}-")),
1824 "unexpected generated id for {prefix}: {generated}"
1825 );
1826 assert_eq!(generated.len(), prefix.len() + 1 + 12);
1827
1828 let blank = OrdersService::coalesce_request_id(Some(" ".into()), prefix).unwrap();
1829 assert!(blank.starts_with(&format!("{prefix}-")));
1830 assert_ne!(generated, blank);
1831 }
1832
1833 assert_eq!(
1834 OrdersService::coalesce_request_id(Some(" retry-mod-1 ".into()), "mod").unwrap(),
1835 "retry-mod-1"
1836 );
1837 assert_eq!(
1838 OrdersService::coalesce_request_id(Some("same-retry".into()), "batch-create").unwrap(),
1839 "same-retry"
1840 );
1841 }
1842
1843 #[test]
1844 fn wait_helper_detects_trade_projection_complete() {
1845 let incomplete = GetOrderResult {
1846 order: Some(Order {
1847 order_id: "1".into(),
1848 symbol_id: 7,
1849 client_order_id: "c".into(),
1850 side: "buy".into(),
1851 status: "filled".into(),
1852 order_type: "market".into(),
1853 tif: "ioc".into(),
1854 orig_qty: None,
1855 cum_qty: Some(
1856 Quantity::from_scaled(
1857 100,
1858 Some(8),
1859 crate::QuantityDomain::OrderBase,
1860 None,
1861 None,
1862 )
1863 .unwrap(),
1864 ),
1865 leaves_qty: None,
1866 price: None,
1867 avg_px: None,
1868 created_ts_ns: String::new(),
1869 version: 1,
1870 post_only: false,
1871 fee_asset: "quote".into(),
1872 submitted_max_quote_debit_scaled: None,
1873 attached_risk: None,
1874 }),
1875 trades: vec![],
1876 };
1877 assert!(!order_trades_projection_complete(&incomplete));
1878
1879 let open_unfilled = GetOrderResult {
1880 order: Some(Order {
1881 status: "working".into(),
1882 cum_qty: Some(
1883 Quantity::from_scaled(0, Some(8), crate::QuantityDomain::OrderBase, None, None)
1884 .unwrap(),
1885 ),
1886 ..incomplete.order.clone().unwrap()
1887 }),
1888 trades: vec![],
1889 };
1890 assert!(
1891 !order_trades_projection_complete(&open_unfilled),
1892 "an unfilled working order is not a stable projection"
1893 );
1894
1895 let complete = GetOrderResult {
1896 order: Some(Order {
1897 status: "filled".into(),
1898 ..incomplete.order.clone().unwrap()
1899 }),
1900 trades: vec![UserTrade {
1901 symbol_id: 7,
1902 match_id: "m".into(),
1903 order_id: "1".into(),
1904 side: "buy".into(),
1905 is_maker: false,
1906 price: None,
1907 qty: Some(
1908 Quantity::from_scaled(
1909 100,
1910 Some(8),
1911 crate::QuantityDomain::OrderBase,
1912 None,
1913 None,
1914 )
1915 .unwrap(),
1916 ),
1917 fee_scaled: "0".into(),
1918 fee_asset: "quote".into(),
1919 referral_share_scaled: "0".into(),
1920 ts_ns: String::new(),
1921 }],
1922 };
1923 assert!(order_trades_projection_complete(&complete));
1924 }
1925}