Skip to main content

polyester/services/
orders.rs

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    /// Poll [`Self::get`] until the order is terminal and projected trade
186    /// quantities sum to order `cum_qty`.
187    ///
188    /// GetOrder can report `cum_qty` before every fill is visible on the trades
189    /// list. Prefer this helper after fills
190    /// instead of treating a single get as final trade projection.
191    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    /// Build the transport-independent [`OrderIntent`] shared by single and batch
270    /// create. The flat public params (`order_type`/`time_in_force`/`post_only`)
271    /// are mapped onto the appropriate execution variant.
272    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 (&params.quantity, &params.max_quote_debit_scaled) {
324            (Some(quantity), None) => {
325                let scale = self.require_quantity_scale(&params.symbol, quantity.scale())?;
326                order_intent::Sizing::BaseQtyScaled(resolve_qty_scaled(
327                    quantity,
328                    scale,
329                    Some(&params.symbol),
330                    self.ctx.catalogs.symbol_id_for_symbol(&params.symbol),
331                )?)
332            }
333            (None, Some(max_quote_debit)) => {
334                let scale = self.require_quote_quantity_scale(&params.symbol)?;
335                order_intent::Sizing::MaxQuoteDebitScaled(resolve_quote_qty_scaled(
336                    max_quote_debit,
337                    scale,
338                    Some(&params.symbol),
339                    self.ctx.catalogs.symbol_id_for_symbol(&params.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(&params.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(&params.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                    // gtc or unspecified
428                    _ => 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(&params.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    /// Map the flat public [`RiskLeg`] (`order_type`/`limit_price`) onto a child
454    /// [`RiskExecution`] variant.
455    #[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        // `trigger_price_source`/`order_type` were dropped from the trailing-stop
511        // policy wire; the child is an implicit market execution.
512        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    /// Generate a cryptographically random mutation request id (`prefix-<12 hex chars>`).
564    ///
565    /// Matches Go/Python (`cancel-all-<hex>`, `mod-<hex>`, …) and TypeScript (UUID when omitted):
566    /// generate once per logical mutation. For retries after an ambiguous failure, provide and
567    /// reuse a caller-owned stable `request_id` instead of calling this again — a blind retry
568    /// that omits `request_id` mints a *new* id and is not an idempotent replay.
569    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    /// Prefer a trimmed caller-provided request id; otherwise generate one (TS/Go/Python parity).
578    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            &params.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(&params.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(&params.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(&params.symbol),
623                self.ctx.catalogs.symbol_id_for_symbol(&params.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(&params.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    /// Place an order. Quantity and price must be `Quantity` / `Price` wrappers.
640    pub async fn create(&self, params: CreateOrderParams) -> Result<OrderMutationResult> {
641        self.ctx.wait_for_catalogs().await?;
642        let req = self.encode_create_params(&params)?;
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    /// Check whether an order intent is currently admissible without submitting
656    /// it.
657    ///
658    /// Returns admission status, optional typed rejection detail, and any
659    /// sizing / protected price-bound values resolved during evaluation. A
660    /// preview is advisory; account, market, and policy inputs may change, and
661    /// create always evaluates the intent again.
662    pub async fn preview(&self, params: PreviewOrderParams) -> Result<PreviewOrderResult> {
663        self.ctx.wait_for_catalogs().await?;
664        let req = self.encode_preview_params(&params)?;
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        // Base scale is only needed when the host resolved a base quantity.
675        let base_scale = self.require_quantity_scale(&params.symbol, None)?;
676        preview_order_from_proto(
677            &resp,
678            base_scale,
679            &params.symbol,
680            self.ctx.catalogs.symbol_id_for_symbol(&params.symbol),
681        )
682    }
683
684    fn encode_preview_params(&self, params: &PreviewOrderParams) -> Result<PreviewOrderRequest> {
685        // Preview uses the same OrderIntent contract as CreateOrder. The host
686        // runs an admissibility check only — no hold is placed and any
687        // client_order_id is accepted but not claimed.
688        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    /// Batch-create orders.
715    ///
716    /// A `request_id` is generated when omitted (TypeScript/Go/Python parity). Provide a stable
717    /// non-empty value when retrying the same logical batch — omitting it on retry mints a new id.
718    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    /// Batch-cancel orders.
749    ///
750    /// A `request_id` is generated when omitted (TypeScript/Go/Python parity). Provide a stable
751    /// non-empty value when retrying the same logical batch — omitting it on retry mints a new id.
752    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    /// Replace multiple same-symbol orders and return their admission receipt.
794    ///
795    /// Poll [`Self::get_batch_replace_status`] using the returned
796    /// `batch_request_id` for recoverable execution finality.
797    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    /// Get durable execution status for an admitted batch replacement.
870    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    /// Schedules cancel-all-after for the account scope.
893    ///
894    /// A `request_id` is generated when omitted (TypeScript/Go/Python parity). Provide a stable
895    /// non-empty value when retrying the same logical cancel-all-after.
896    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        // A targeted cancel without symbol metadata can route through the
937        // order directory, so avoid waiting for catalogs in that case.
938        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(&params.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    /// Cancels all matching open orders for the account scope (optional symbol / dry-run).
1007    ///
1008    /// A `request_id` is generated when omitted (TypeScript/Go/Python parity). Provide a stable
1009    /// non-empty value via [`cancel_all_with`] when retrying the same logical bulk cancellation.
1010    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    /// Cancels all matching open orders with full options.
1026    ///
1027    /// A `request_id` is generated when omitted or blank. Provide a stable non-empty value when
1028    /// retrying the same logical bulk cancellation.
1029    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    /// Modify an order. `new_price` / `new_qty` must be `Price` / `Quantity` wrappers.
1061    ///
1062    /// A `request_id` is generated when omitted (TypeScript/Go/Python parity). Provide a stable
1063    /// non-empty value when retrying the same logical modification — omitting it on retry mints a
1064    /// new id and is not an idempotent replay.
1065    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    /// Resolve the catalog quantity scale for a same-symbol batch replace.
1112    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    /// Subscribe to private order updates for an account.
1124    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    /// Subscribe to private user trade updates (requires `realtime` feature).
1173    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(&params).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(&params).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(&params).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(&params).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(&params).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(&params).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(&params).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(&params).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(&params).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(&params).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(&params).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}