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