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