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!(
1485 err.to_string()
1486 .contains("trailing_distance_ticks must be positive")
1487 );
1488
1489 let mut zero_slip = base.clone();
1490 zero_slip.attached_risk = Some(AttachedRisk {
1491 trailing_stop: Some(TrailingStop {
1492 distance: TrailingDistance::Bps(25),
1493 activation_price: None,
1494 trigger_price_source: None,
1495 order_type: None,
1496 max_slippage: Some(MaxSlippage::Ticks(0)),
1497 }),
1498 ..Default::default()
1499 });
1500 let err = client.orders.encode_create_params(&zero_slip).unwrap_err();
1501 assert!(
1502 err.to_string()
1503 .contains("max_slippage_ticks must be positive")
1504 );
1505
1506 let mut with_source = base.clone();
1507 with_source.attached_risk = Some(AttachedRisk {
1508 trailing_stop: Some(TrailingStop {
1509 distance: TrailingDistance::Bps(25),
1510 activation_price: None,
1511 trigger_price_source: Some(TriggerPriceSourceKind::IndexPrice),
1512 order_type: None,
1513 max_slippage: None,
1514 }),
1515 ..Default::default()
1516 });
1517 let err = client
1518 .orders
1519 .encode_create_params(&with_source)
1520 .unwrap_err();
1521 assert!(err.to_string().contains("always uses last trade"));
1522
1523 let mut with_order_type = base;
1524 with_order_type.attached_risk = Some(AttachedRisk {
1525 trailing_stop: Some(TrailingStop {
1526 distance: TrailingDistance::Bps(25),
1527 activation_price: None,
1528 trigger_price_source: None,
1529 order_type: Some(CreateOrderType::Limit),
1530 max_slippage: None,
1531 }),
1532 ..Default::default()
1533 });
1534 let err = client
1535 .orders
1536 .encode_create_params(&with_order_type)
1537 .unwrap_err();
1538 assert!(err.to_string().contains("always market"));
1539 }
1540
1541 #[test]
1542 fn preview_encodes_full_order_intent() {
1543 let client = client();
1544 let create = create_params(
1545 Quantity::from_scaled(
1546 10_000_000,
1547 Some(8),
1548 crate::QuantityDomain::OrderBase,
1549 Some("BTC-USDT".into()),
1550 Some(7),
1551 )
1552 .unwrap(),
1553 Price::from_ticks(50_000_000_000, Some("BTC-USDT".into())).unwrap(),
1554 );
1555 let preview = PreviewOrderParams {
1556 symbol: create.symbol.clone(),
1557 side: create.side,
1558 order_type: create.order_type,
1559 quantity: create.quantity.clone(),
1560 max_quote_debit_scaled: None,
1561 price: create.price.clone(),
1562 time_in_force: create.time_in_force,
1563 client_order_id: Some("preview-cid".into()),
1564 subaccount_id: Some(9),
1565 post_only: create.post_only,
1566 market_client_ref_price: None,
1567 fee_asset: Some(FeeAsset::Quote),
1568 self_trade_prevention: Some(OrderSelfTradePrevention::ExpireTaker),
1569 market_max_slippage: None,
1570 attached_risk: None,
1571 };
1572 let wire = client.orders.encode_preview_params(&preview).unwrap();
1573 assert_eq!(wire.subaccount_id, Some(9));
1574 let intent = wire.order.as_option().expect("preview order intent");
1575 assert_eq!(intent.symbol, "BTC-USDT");
1576 assert_eq!(intent.side.as_known(), Some(Side::Buy));
1577 assert_eq!(intent.client_order_id, "preview-cid");
1578 assert!(matches!(
1579 intent.sizing,
1580 Some(order_intent::Sizing::BaseQtyScaled(10_000_000))
1581 ));
1582 assert!(matches!(
1583 intent.execution,
1584 Some(order_intent::Execution::LimitGtc(_))
1585 ));
1586 assert_eq!(
1587 intent.self_trade_prevention_mode.as_known(),
1588 Some(SelfTradePreventionMode::ExpireTaker)
1589 );
1590 }
1591
1592 #[test]
1593 fn create_allows_omitted_client_order_id_and_encodes_market_maker_controls() {
1594 let client = client();
1595 let mut params = create_params(
1596 Quantity::from_scaled(
1597 10_000_000,
1598 Some(8),
1599 crate::QuantityDomain::OrderBase,
1600 Some("BTC-USDT".into()),
1601 Some(7),
1602 )
1603 .unwrap(),
1604 Price::from_ticks(50_000_000_000, Some("BTC-USDT".into())).unwrap(),
1605 );
1606 params.client_order_id = None;
1607 let omitted = client.orders.encode_create_params(¶ms).unwrap();
1608 assert!(
1609 omitted
1610 .order
1611 .as_option()
1612 .unwrap()
1613 .client_order_id
1614 .is_empty()
1615 );
1616
1617 params.client_order_id = Some(" ".into());
1618 let whitespace = client.orders.encode_create_params(¶ms).unwrap();
1619 assert!(
1620 whitespace
1621 .order
1622 .as_option()
1623 .unwrap()
1624 .client_order_id
1625 .is_empty()
1626 );
1627
1628 params.client_order_id = Some("mm-create-1".into());
1629 params.order_type = CreateOrderType::Market;
1630 params.price = None;
1631 params.post_only = None;
1632 params.fee_asset = Some(FeeAsset::Base);
1633 params.self_trade_prevention = Some(OrderSelfTradePrevention::ExpireBoth);
1634 params.market_max_slippage = Some(MaxSlippage::Bps(25));
1635 let wire = client.orders.encode_create_params(¶ms).unwrap();
1636 let intent = wire.order.as_option().unwrap();
1637 assert_eq!(intent.fee_asset.as_known(), Some(ProtoFeeAsset::Base));
1638 assert_eq!(
1639 intent.self_trade_prevention_mode.as_known(),
1640 Some(SelfTradePreventionMode::ExpireBoth)
1641 );
1642 let Some(order_intent::Execution::MarketIoc(market)) = intent.execution.as_ref() else {
1643 panic!("expected market execution");
1644 };
1645 assert!(matches!(
1646 market.max_slippage,
1647 Some(market_ioc::MaxSlippage::MaxSlippageBps(25))
1648 ));
1649
1650 params.price = Some(Price::from_ticks(1, None).unwrap());
1651 let err = client.orders.encode_create_params(¶ms).unwrap_err();
1652 assert!(
1653 err.to_string().contains("price is not valid for market"),
1654 "unexpected error: {err}"
1655 );
1656
1657 params.price = None;
1658 params.market_max_slippage = Some(MaxSlippage::Ticks(0));
1659 assert!(client.orders.encode_create_params(¶ms).is_err());
1660 }
1661
1662 #[test]
1663 fn create_encodes_quote_budget_sizing_and_rejects_ambiguous_sizing() {
1664 let client = client();
1665 let mut params = create_params(
1666 Quantity::from_scaled(
1667 10_000_000,
1668 Some(8),
1669 crate::QuantityDomain::OrderBase,
1670 Some("BTC-USDT".into()),
1671 Some(7),
1672 )
1673 .unwrap(),
1674 Price::from_ticks(50_000_000_000, Some("BTC-USDT".into())).unwrap(),
1675 );
1676 params.quantity = None;
1677 params.max_quote_debit_scaled = Some(
1678 Quantity::from_quote_scaled(5_000_000, 6, Some("BTC-USDT".into()), Some(7)).unwrap(),
1679 );
1680 let wire = client.orders.encode_create_params(¶ms).unwrap();
1681 let intent = wire.order.as_option().unwrap();
1682 assert!(matches!(
1683 intent.sizing,
1684 Some(order_intent::Sizing::MaxQuoteDebitScaled(5_000_000))
1685 ));
1686
1687 params.quantity = Some(
1688 Quantity::from_scaled(
1689 10_000_000,
1690 Some(8),
1691 crate::QuantityDomain::OrderBase,
1692 Some("BTC-USDT".into()),
1693 Some(7),
1694 )
1695 .unwrap(),
1696 );
1697 assert!(client.orders.encode_create_params(¶ms).is_err());
1698
1699 params.quantity = None;
1700 params.max_quote_debit_scaled =
1701 Some(Quantity::from_quote_scaled(5_000_000, 8, None, None).unwrap());
1702 let err = client.orders.encode_create_params(¶ms).unwrap_err();
1703 assert!(err.to_string().contains("scale mismatch"));
1704 }
1705
1706 #[test]
1707 fn batch_size_guard_rejects_empty_and_more_than_twenty() {
1708 assert!(OrdersService::validate_batch_size("batch_create", 1).is_ok());
1709 assert!(OrdersService::validate_batch_size("batch_create", 20).is_ok());
1710 assert!(
1711 OrdersService::validate_batch_size("batch_create", 0)
1712 .unwrap_err()
1713 .to_string()
1714 .contains("at least one")
1715 );
1716 assert!(
1717 OrdersService::validate_batch_size("batch_create", 21)
1718 .unwrap_err()
1719 .to_string()
1720 .contains("at most 20")
1721 );
1722 }
1723
1724 #[test]
1725 fn create_rejects_invalid_client_order_id_before_wire() {
1726 let client = client();
1727 let mut params = create_params(
1728 Quantity::from_scaled(
1729 10_000_000,
1730 Some(8),
1731 crate::QuantityDomain::OrderBase,
1732 Some("BTC-USDT".into()),
1733 Some(7),
1734 )
1735 .unwrap(),
1736 Price::from_ticks(50_000_000_000, Some("BTC-USDT".into())).unwrap(),
1737 );
1738
1739 params.client_order_id = Some("bad id".into());
1740 let err = client.orders.encode_create_params(¶ms).unwrap_err();
1741 assert!(err.to_string().contains("invalid characters"));
1742
1743 params.client_order_id = Some("a".repeat(37));
1744 let err = client.orders.encode_create_params(¶ms).unwrap_err();
1745 assert!(err.to_string().contains("1 to 36"));
1746
1747 params.client_order_id = Some("ok-id_1.2:3/4".into());
1748 assert!(client.orders.encode_create_params(¶ms).is_ok());
1749
1750 let err = OrdersService::coalesce_request_id(Some("bad id".into()), "mod").unwrap_err();
1751 assert!(err.to_string().contains("invalid characters"));
1752 let err = OrdersService::coalesce_request_id(Some("r".repeat(65)), "mod").unwrap_err();
1753 assert!(err.to_string().contains("1 to 64"));
1754 }
1755
1756 #[tokio::test]
1757 async fn singular_order_methods_reject_invalid_client_order_id_before_transport() {
1758 let client = client();
1759 let err = client
1760 .orders
1761 .cancel_by_client_order_id("bad id!", None, None)
1762 .await
1763 .unwrap_err();
1764 assert!(matches!(err, Error::Validation(_)));
1765 assert!(err.to_string().contains("invalid characters"));
1766
1767 let err = client
1768 .orders
1769 .get(OrderKey::ClientOrderId("bad id!".into()), None)
1770 .await
1771 .unwrap_err();
1772 assert!(matches!(err, Error::Validation(_)));
1773 assert!(err.to_string().contains("invalid characters"));
1774 }
1775
1776 #[test]
1777 fn cancel_symbol_routing_distinguishes_omitted_and_invalid_inputs() {
1778 let client = client();
1779 assert_eq!(
1780 OrdersService::resolve_cancel_symbol_id(&client.catalogs, None, None).unwrap(),
1781 0
1782 );
1783 assert_eq!(
1784 OrdersService::resolve_cancel_symbol_id(&client.catalogs, Some("BTC-USDT"), None)
1785 .unwrap(),
1786 7
1787 );
1788
1789 for err in [
1790 OrdersService::resolve_cancel_symbol_id(&client.catalogs, Some("UNKNOWN-USDT"), None)
1791 .unwrap_err(),
1792 OrdersService::resolve_cancel_symbol_id(&client.catalogs, None, Some(0)).unwrap_err(),
1793 OrdersService::resolve_cancel_symbol_id(&client.catalogs, Some("BTC-USDT"), Some(7))
1794 .unwrap_err(),
1795 ] {
1796 assert!(matches!(err, Error::Validation(_)));
1797 }
1798 }
1799
1800 #[tokio::test]
1801 async fn cancel_rejects_unknown_supplied_symbol_before_transport() {
1802 let client = client();
1803 let err = client
1804 .orders
1805 .cancel_with(CancelOrderParams {
1806 key: OrderKey::OrderId(format_id(9)),
1807 symbol: Some("UNKNOWN-USDT".into()),
1808 symbol_id: None,
1809 subaccount_id: None,
1810 })
1811 .await
1812 .unwrap_err();
1813 assert!(matches!(&err, Error::Validation(_)));
1814 assert!(err.to_string().contains("unknown symbol"));
1815 }
1816
1817 #[test]
1818 fn mutation_request_ids_are_generated_when_omitted_like_go_python_typescript() {
1819 for prefix in [
1820 "cancel-all",
1821 "cancel-after",
1822 "mod",
1823 "batch-create",
1824 "batch-cancel",
1825 "batch-replace",
1826 ] {
1827 let generated = OrdersService::coalesce_request_id(None, prefix).unwrap();
1828 assert!(
1829 generated.starts_with(&format!("{prefix}-")),
1830 "unexpected generated id for {prefix}: {generated}"
1831 );
1832 assert_eq!(generated.len(), prefix.len() + 1 + 12);
1833
1834 let blank = OrdersService::coalesce_request_id(Some(" ".into()), prefix).unwrap();
1835 assert!(blank.starts_with(&format!("{prefix}-")));
1836 assert_ne!(generated, blank);
1837 }
1838
1839 assert_eq!(
1840 OrdersService::coalesce_request_id(Some(" retry-mod-1 ".into()), "mod").unwrap(),
1841 "retry-mod-1"
1842 );
1843 assert_eq!(
1844 OrdersService::coalesce_request_id(Some("same-retry".into()), "batch-create").unwrap(),
1845 "same-retry"
1846 );
1847 }
1848
1849 #[test]
1850 fn wait_helper_detects_trade_projection_complete() {
1851 let incomplete = GetOrderResult {
1852 order: Some(Order {
1853 order_id: "1".into(),
1854 symbol_id: 7,
1855 client_order_id: "c".into(),
1856 side: "buy".into(),
1857 status: "filled".into(),
1858 order_type: "market".into(),
1859 tif: "ioc".into(),
1860 orig_qty: None,
1861 cum_qty: Some(
1862 Quantity::from_scaled(
1863 100,
1864 Some(8),
1865 crate::QuantityDomain::OrderBase,
1866 None,
1867 None,
1868 )
1869 .unwrap(),
1870 ),
1871 leaves_qty: None,
1872 price: None,
1873 avg_px: None,
1874 created_ts_ns: String::new(),
1875 version: 1,
1876 post_only: false,
1877 fee_asset: "quote".into(),
1878 submitted_max_quote_debit_scaled: None,
1879 attached_risk: None,
1880 }),
1881 trades: vec![],
1882 };
1883 assert!(!order_trades_projection_complete(&incomplete));
1884
1885 let open_unfilled = GetOrderResult {
1886 order: Some(Order {
1887 status: "working".into(),
1888 cum_qty: Some(
1889 Quantity::from_scaled(0, Some(8), crate::QuantityDomain::OrderBase, None, None)
1890 .unwrap(),
1891 ),
1892 ..incomplete.order.clone().unwrap()
1893 }),
1894 trades: vec![],
1895 };
1896 assert!(
1897 !order_trades_projection_complete(&open_unfilled),
1898 "an unfilled working order is not a stable projection"
1899 );
1900
1901 let complete = GetOrderResult {
1902 order: Some(Order {
1903 status: "filled".into(),
1904 ..incomplete.order.clone().unwrap()
1905 }),
1906 trades: vec![UserTrade {
1907 symbol_id: 7,
1908 match_id: "m".into(),
1909 order_id: "1".into(),
1910 side: "buy".into(),
1911 is_maker: false,
1912 price: None,
1913 qty: Some(
1914 Quantity::from_scaled(
1915 100,
1916 Some(8),
1917 crate::QuantityDomain::OrderBase,
1918 None,
1919 None,
1920 )
1921 .unwrap(),
1922 ),
1923 fee_amount_e18: "0".into(),
1924 fee_asset: "quote".into(),
1925 referral_share_amount_e18: "0".into(),
1926 ts_ns: String::new(),
1927 fee_is_rebate: false,
1928 }],
1929 };
1930 assert!(order_trades_projection_complete(&complete));
1931 }
1932}