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    /// Resolve an order's executable size, price bound, and estimated fees
656    /// without submitting it. A preview is advisory; re-preview or submit
657    /// promptly when the market is moving.
658    pub async fn preview(&self, params: PreviewOrderParams) -> Result<PreviewOrderResult> {
659        self.ctx.wait_for_catalogs().await?;
660        let req = self.encode_preview_params(&params)?;
661        let client = self.write_client();
662        let resp = unary::await_auth(
663            &self.ctx.factory,
664            "/orders.v1.OrdersService/PreviewOrder",
665            req,
666            |req, opts| client.preview_order_with_options(req, opts),
667        )
668        .await?
669        .into_owned();
670        let base_scale = self.require_quantity_scale(&params.symbol, None)?;
671        let quote_scale = self.require_quote_quantity_scale(&params.symbol)?;
672        preview_order_from_proto(
673            &resp,
674            base_scale,
675            quote_scale,
676            &params.symbol,
677            self.ctx.catalogs.symbol_id_for_symbol(&params.symbol),
678        )
679    }
680
681    fn encode_preview_params(&self, params: &PreviewOrderParams) -> Result<PreviewOrderRequest> {
682        // Preview uses the same OrderIntent contract as CreateOrder. The host
683        // runs an admissibility check only — no hold is placed and any
684        // client_order_id is accepted but not claimed.
685        let create = CreateOrderParams {
686            symbol: params.symbol.clone(),
687            side: params.side,
688            order_type: params.order_type,
689            quantity: params.quantity.clone(),
690            max_quote_debit_scaled: params.max_quote_debit_scaled.clone(),
691            price: params.price.clone(),
692            time_in_force: params.time_in_force,
693            client_order_id: params.client_order_id.clone(),
694            subaccount_id: params.subaccount_id,
695            post_only: params.post_only,
696            market_client_ref_price: params.market_client_ref_price.clone(),
697            fee_asset: params.fee_asset,
698            self_trade_prevention: params.self_trade_prevention,
699            market_max_slippage: params.market_max_slippage,
700            attached_risk: params.attached_risk.clone(),
701        };
702        let order = self.order_intent_from_params(&create)?;
703        let mut req = PreviewOrderRequest {
704            subaccount_id: scope::optional_subaccount(&self.ctx, params.subaccount_id)?,
705            ..Default::default()
706        };
707        *req.order.get_or_insert_default() = order;
708        Ok(req)
709    }
710
711    /// Batch-create orders.
712    ///
713    /// A `request_id` is generated when omitted (TypeScript/Go/Python parity). Provide a stable
714    /// non-empty value when retrying the same logical batch — omitting it on retry mints a new id.
715    pub async fn batch_create(
716        &self,
717        items: Vec<CreateOrderParams>,
718        subaccount_id: Option<u64>,
719        request_id: Option<String>,
720    ) -> Result<BatchCreateOrdersResult> {
721        Self::validate_batch_size("batch_create", items.len())?;
722        self.ctx.wait_for_catalogs().await?;
723        let mut encoded = Vec::with_capacity(items.len());
724        for item in &items {
725            encoded.push(self.order_intent_from_params(item)?);
726        }
727        let req = BatchCreateOrdersRequest {
728            subaccount_id: scope::optional_subaccount(&self.ctx, subaccount_id)?,
729            request_id: Self::coalesce_request_id(request_id, "batch-create")?,
730            items: encoded,
731            ..Default::default()
732        };
733        let client = self.write_client();
734        let resp = unary::await_auth(
735            &self.ctx.factory,
736            "/orders.v1.OrdersService/BatchCreateOrders",
737            req,
738            |req, opts| client.batch_create_orders_with_options(req, opts),
739        )
740        .await?
741        .into_owned();
742        batch_create_from_proto(&resp)
743    }
744
745    /// Batch-cancel orders.
746    ///
747    /// A `request_id` is generated when omitted (TypeScript/Go/Python parity). Provide a stable
748    /// non-empty value when retrying the same logical batch — omitting it on retry mints a new id.
749    pub async fn batch_cancel(
750        &self,
751        items: Vec<BatchCancelItem>,
752        subaccount_id: Option<u64>,
753        request_id: Option<String>,
754    ) -> Result<BatchCancelOrdersResult> {
755        Self::validate_batch_size("batch_cancel", items.len())?;
756        let mut proto_items = Vec::with_capacity(items.len());
757        for item in items {
758            let mut proto = ProtoBatchCancelItem::default();
759            match &item.key {
760                OrderKey::OrderId(oid) => {
761                    proto.order_id = id_to_u64(oid, "order_id")?;
762                }
763                OrderKey::ClientOrderId(cid) => {
764                    proto.client_order_id = require_client_style_id(cid, "client_order_id")?;
765                }
766            }
767            if let Some(sid) = item.symbol_id {
768                proto.symbol_id = sid;
769            }
770            proto_items.push(proto);
771        }
772        let req = BatchCancelOrdersRequest {
773            subaccount_id: scope::optional_subaccount(&self.ctx, subaccount_id)?,
774            request_id: Self::coalesce_request_id(request_id, "batch-cancel")?,
775            items: proto_items,
776            ..Default::default()
777        };
778        let client = self.write_client();
779        let resp = unary::await_auth(
780            &self.ctx.factory,
781            "/orders.v1.OrdersService/BatchCancelOrders",
782            req,
783            |req, opts| client.batch_cancel_orders_with_options(req, opts),
784        )
785        .await?
786        .into_owned();
787        batch_cancel_from_proto(&resp)
788    }
789
790    /// Replace multiple same-symbol orders and return their admission receipt.
791    ///
792    /// Poll [`Self::get_batch_replace_status`] using the returned
793    /// `batch_request_id` for recoverable execution finality.
794    pub async fn batch_replace(
795        &self,
796        items: Vec<BatchReplaceItem>,
797        symbol: &str,
798        subaccount_id: Option<u64>,
799        request_id: Option<String>,
800    ) -> Result<BatchReplaceOrdersResult> {
801        Self::validate_batch_size("batch_replace", items.len())?;
802        self.ctx.wait_for_catalogs().await?;
803        let symbol_id = self
804            .ctx
805            .catalogs
806            .symbol_id_for_symbol(symbol)
807            .ok_or_else(|| {
808                Error::validation(format!(
809                    "unknown symbol {symbol}; call hydrate_catalogs / get_spot_config first"
810                ))
811            })?;
812        let scale = Self::resolve_batch_replace_scale(&self.ctx.catalogs, symbol)?;
813        let mut proto_items = Vec::with_capacity(items.len());
814        for item in items {
815            if item.new_price.is_none()
816                && item.new_qty.is_none()
817                && item.new_attached_risk.is_none()
818            {
819                return Err(Error::validation(
820                    "each batch item requires new_price, new_qty, and/or new_attached_risk",
821                ));
822            }
823            let mut proto = ProtoBatchReplaceOrderItem {
824                key: Some(Self::encode_batch_replace_key(&item.key)?),
825                ..Default::default()
826            };
827            if let Some(price) = item.new_price.as_ref() {
828                proto.new_price_ticks = Some(resolve_price_ticks(price, Some(symbol))?);
829            }
830            if let Some(qty) = item.new_qty.as_ref() {
831                proto.new_qty_scaled = Some(resolve_qty_scaled(
832                    qty,
833                    scale,
834                    Some(symbol),
835                    Some(symbol_id),
836                )?);
837            }
838            if let Some(risk) = item.new_attached_risk.as_ref() {
839                *proto.new_attached_risk.get_or_insert_default() =
840                    Self::encode_attached_risk(risk, Some(symbol))?;
841            }
842            if let Some(ncid) = optional_client_order_id(item.new_client_order_id.as_deref())? {
843                proto.new_client_order_id = ncid;
844            }
845            proto_items.push(proto);
846        }
847        let req = BatchReplaceOrdersRequest {
848            subaccount_id: scope::optional_subaccount(&self.ctx, subaccount_id)?,
849            symbol_id,
850            request_id: Self::coalesce_request_id(request_id, "batch-replace")?,
851            items: proto_items,
852            ..Default::default()
853        };
854        let client = self.write_client();
855        let resp = unary::await_auth(
856            &self.ctx.factory,
857            "/orders.v1.OrdersService/BatchReplaceOrders",
858            req,
859            |req, opts| client.batch_replace_orders_with_options(req, opts),
860        )
861        .await?
862        .into_owned();
863        batch_replace_from_proto(&resp)
864    }
865
866    /// Get durable execution status for an admitted batch replacement.
867    pub async fn get_batch_replace_status(
868        &self,
869        batch_request_id: &str,
870        subaccount_id: Option<u64>,
871    ) -> Result<BatchReplaceStatusResult> {
872        let req = GetBatchReplaceStatusRequest {
873            subaccount_id: scope::optional_subaccount(&self.ctx, subaccount_id)?,
874            batch_request_id: id_to_u64(batch_request_id, "batch_request_id")?,
875            ..Default::default()
876        };
877        let client = self.read_client();
878        let resp = unary::await_auth(
879            &self.ctx.factory,
880            "/orders.v1.OrdersReadService/GetBatchReplaceStatus",
881            req,
882            |req, opts| client.get_batch_replace_status_with_options(req, opts),
883        )
884        .await?
885        .into_owned();
886        batch_replace_status_from_proto(&resp)
887    }
888
889    /// Schedules cancel-all-after for the account scope.
890    ///
891    /// A `request_id` is generated when omitted (TypeScript/Go/Python parity). Provide a stable
892    /// non-empty value when retrying the same logical cancel-all-after.
893    pub async fn cancel_all_after(
894        &self,
895        timeout_sec: u32,
896        symbol: Option<&str>,
897        subaccount_id: Option<u64>,
898        request_id: Option<String>,
899    ) -> Result<CancelAllAfterResult> {
900        let req = CancelAllAfterRequest {
901            subaccount_id: scope::optional_subaccount(&self.ctx, subaccount_id)?,
902            timeout_sec,
903            symbol: symbol.unwrap_or("").to_owned(),
904            request_id: Self::coalesce_request_id(request_id, "cancel-after")?,
905            ..Default::default()
906        };
907        let client = self.write_client();
908        let resp = unary::await_auth(
909            &self.ctx.factory,
910            "/orders.v1.OrdersService/CancelAllAfter",
911            req,
912            |req, opts| client.cancel_all_after_with_options(req, opts),
913        )
914        .await?
915        .into_owned();
916        cancel_all_after_from_proto(&resp)
917    }
918
919    pub async fn cancel(&self, req: CancelOrderRequest) -> Result<OrderMutationResult> {
920        let client = self.write_client();
921        let resp = unary::await_auth(
922            &self.ctx.factory,
923            "/orders.v1.OrdersService/CancelOrder",
924            req,
925            |req, opts| client.cancel_order_with_options(req, opts),
926        )
927        .await?
928        .into_owned();
929        order_mutation_from_cancel(&resp)
930    }
931
932    pub async fn cancel_with(&self, params: CancelOrderParams) -> Result<OrderMutationResult> {
933        // A targeted cancel without symbol metadata can route through the
934        // order directory, so avoid waiting for catalogs in that case.
935        if params.symbol_id.is_none() && params.symbol.is_some() {
936            self.ctx.wait_for_catalogs().await?;
937        }
938        let symbol_id = Self::resolve_cancel_symbol_id(
939            &self.ctx.catalogs,
940            params.symbol.as_deref(),
941            params.symbol_id,
942        )?;
943        let req = CancelOrderRequest {
944            symbol_id,
945            subaccount_id: scope::optional_subaccount(&self.ctx, params.subaccount_id)?,
946            key: Some(Self::encode_cancel_order_key(&params.key)?),
947            ..Default::default()
948        };
949        self.cancel(req).await
950    }
951
952    fn resolve_cancel_symbol_id(
953        catalogs: &crate::catalogs::Manager,
954        symbol: Option<&str>,
955        symbol_id: Option<u32>,
956    ) -> Result<u32> {
957        match (symbol, symbol_id) {
958            (None, None) => Ok(0),
959            (_, Some(0)) => Err(Error::validation(
960                "symbol_id must be non-zero when explicitly supplied",
961            )),
962            (Some(_), Some(_)) => Err(Error::validation(
963                "cancel accepts symbol or symbol_id, not both",
964            )),
965            (None, Some(symbol_id)) => Ok(symbol_id),
966            (Some(symbol), None) => catalogs.symbol_id_for_symbol(symbol).ok_or_else(|| {
967                Error::validation(format!(
968                    "unknown symbol {symbol}; call hydrate_catalogs / get_spot_config first"
969                ))
970            }),
971        }
972    }
973
974    pub async fn cancel_by_client_order_id(
975        &self,
976        client_order_id: &str,
977        symbol: Option<&str>,
978        subaccount_id: Option<u64>,
979    ) -> Result<OrderMutationResult> {
980        self.cancel_with(CancelOrderParams {
981            key: OrderKey::ClientOrderId(client_order_id.to_owned()),
982            symbol: symbol.map(|s| s.to_owned()),
983            symbol_id: None,
984            subaccount_id,
985        })
986        .await
987    }
988
989    pub async fn cancel_by_order_id(
990        &self,
991        order_id: &str,
992        subaccount_id: Option<u64>,
993    ) -> Result<OrderMutationResult> {
994        self.cancel_with(CancelOrderParams {
995            key: OrderKey::OrderId(order_id.to_owned()),
996            symbol: None,
997            symbol_id: None,
998            subaccount_id,
999        })
1000        .await
1001    }
1002
1003    /// Cancels all matching open orders for the account scope (optional symbol / dry-run).
1004    ///
1005    /// A `request_id` is generated when omitted (TypeScript/Go/Python parity). Provide a stable
1006    /// non-empty value via [`cancel_all_with`] when retrying the same logical bulk cancellation.
1007    pub async fn cancel_all(
1008        &self,
1009        symbol: Option<&str>,
1010        dry_run: bool,
1011        subaccount_id: Option<u64>,
1012    ) -> Result<CancelAllOrdersResult> {
1013        self.cancel_all_with(CancelAllOpts {
1014            symbol: symbol.map(|s| s.to_owned()),
1015            dry_run,
1016            subaccount_id,
1017            ..Default::default()
1018        })
1019        .await
1020    }
1021
1022    /// Cancels all matching open orders with full options.
1023    ///
1024    /// A `request_id` is generated when omitted or blank. Provide a stable non-empty value when
1025    /// retrying the same logical bulk cancellation.
1026    pub async fn cancel_all_with(&self, opts: CancelAllOpts) -> Result<CancelAllOrdersResult> {
1027        let mut req = CancelAllOrdersRequest {
1028            subaccount_id: scope::optional_subaccount(&self.ctx, opts.subaccount_id)?,
1029            symbol: opts.symbol.unwrap_or_default(),
1030            dry_run: opts.dry_run,
1031            request_id: Self::coalesce_request_id(opts.request_id, "cancel-all")?,
1032            ..Default::default()
1033        };
1034        if let Some(side) = opts.side.as_deref() {
1035            req.side = Self::parse_side(side)?.into();
1036        }
1037        let client = self.write_client();
1038        let resp = unary::await_auth(
1039            &self.ctx.factory,
1040            "/orders.v1.OrdersService/CancelAllOrders",
1041            req,
1042            |req, opts| client.cancel_all_orders_with_options(req, opts),
1043        )
1044        .await?
1045        .into_owned();
1046        cancel_all_from_proto(&resp)
1047    }
1048
1049    fn parse_side(side: &str) -> Result<Side> {
1050        match side.to_ascii_lowercase().as_str() {
1051            "buy" => Ok(Side::Buy),
1052            "sell" => Ok(Side::Sell),
1053            _ => Err(Error::validation("side must be buy or sell")),
1054        }
1055    }
1056
1057    /// Modify an order. `new_price` / `new_qty` must be `Price` / `Quantity` wrappers.
1058    ///
1059    /// A `request_id` is generated when omitted (TypeScript/Go/Python parity). Provide a stable
1060    /// non-empty value when retrying the same logical modification — omitting it on retry mints a
1061    /// new id and is not an idempotent replay.
1062    pub async fn modify(&self, params: ModifyOrderParams) -> Result<ModifyOrderResult> {
1063        self.ctx.wait_for_catalogs().await?;
1064        let req = self.encode_modify_params(params)?;
1065        let client = self.write_client();
1066        let resp = unary::await_auth(
1067            &self.ctx.factory,
1068            "/orders.v1.OrdersService/ModifyOrder",
1069            req,
1070            |req, opts| client.modify_order_with_options(req, opts),
1071        )
1072        .await?
1073        .into_owned();
1074        modify_order_from_proto(&resp)
1075    }
1076
1077    pub fn create_params(
1078        symbol: impl Into<String>,
1079        side: CreateSide,
1080        order_type: CreateOrderType,
1081        quantity: Quantity,
1082        price: Option<Price>,
1083        client_order_id: Option<&str>,
1084    ) -> CreateOrderParams {
1085        let client_order_id = client_order_id
1086            .map(str::trim)
1087            .filter(|s| !s.is_empty())
1088            .map(|s| s.to_owned());
1089        CreateOrderParams {
1090            symbol: symbol.into(),
1091            side,
1092            order_type,
1093            quantity: Some(quantity),
1094            max_quote_debit_scaled: None,
1095            price,
1096            time_in_force: None,
1097            client_order_id,
1098            subaccount_id: None,
1099            post_only: None,
1100            market_client_ref_price: None,
1101            fee_asset: None,
1102            self_trade_prevention: None,
1103            market_max_slippage: None,
1104            attached_risk: None,
1105        }
1106    }
1107
1108    /// Resolve the catalog quantity scale for a same-symbol batch replace.
1109    pub(crate) fn resolve_batch_replace_scale(
1110        catalogs: &crate::catalogs::Manager,
1111        symbol: &str,
1112    ) -> Result<u32> {
1113        catalogs.base_quantity_scale_for_symbol(symbol).ok_or_else(|| {
1114            Error::validation(format!(
1115                "quantity scale for {symbol:?} is unavailable; await client.wait_for_catalogs() before placing orders"
1116            ))
1117        })
1118    }
1119
1120    /// Subscribe to private order updates for an account.
1121    pub async fn subscribe(
1122        &self,
1123        account_id: Option<&str>,
1124    ) -> Result<crate::realtime::TypedSubscription<Order>> {
1125        let account = scope::resolve_account_id(&self.ctx, account_id)?;
1126        let channel = format!("private:spot:orders:{account}:proto");
1127        self.ctx
1128            .realtime
1129            .subscribe_proto(&channel, crate::codecs::decode::order_from_bytes)
1130            .await
1131    }
1132}
1133
1134#[derive(Clone)]
1135pub struct TradesService {
1136    ctx: ServiceContext,
1137}
1138
1139impl TradesService {
1140    pub fn new(ctx: ServiceContext) -> Self {
1141        Self { ctx }
1142    }
1143
1144    pub async fn list(
1145        &self,
1146        subaccount_id: Option<u64>,
1147        limit: Option<u32>,
1148    ) -> Result<UserTradesList> {
1149        let req = GetUserTradesRequest {
1150            subaccount_id: scope::optional_subaccount(&self.ctx, subaccount_id)?,
1151            limit,
1152            ..Default::default()
1153        };
1154        let client = OrdersReadServiceClient::new(
1155            self.ctx.factory.transport(),
1156            self.ctx.factory.connect_config(),
1157        );
1158        let resp = unary::await_auth(
1159            &self.ctx.factory,
1160            "/orders.v1.OrdersReadService/GetUserTrades",
1161            req,
1162            |req, opts| client.get_user_trades_with_options(req, opts),
1163        )
1164        .await?
1165        .into_owned();
1166        Ok(user_trades_list_from_proto(&resp))
1167    }
1168
1169    /// Subscribe to private user trade updates (requires `realtime` feature).
1170    pub async fn subscribe(
1171        &self,
1172        account_id: Option<&str>,
1173    ) -> Result<crate::realtime::TypedSubscription<UserTrade>> {
1174        let account = scope::resolve_account_id(&self.ctx, account_id)?;
1175        let channel = format!("private:spot:trades:{account}:proto");
1176        self.ctx
1177            .realtime
1178            .subscribe_proto(&channel, crate::codecs::decode::user_trade_from_bytes)
1179            .await
1180    }
1181}
1182
1183fn order_trades_projection_complete(result: &GetOrderResult) -> bool {
1184    let Some(order) = result.order.as_ref() else {
1185        return false;
1186    };
1187    if !matches!(order.status.as_str(), "filled" | "canceled" | "rejected") {
1188        return false;
1189    }
1190    let Some(cum) = order.cum_qty.as_ref() else {
1191        return false;
1192    };
1193    let cum = cum.as_scaled();
1194    if cum == 0 {
1195        return true;
1196    }
1197    let mut trade_sum = 0_i64;
1198    for trade in &result.trades {
1199        let Some(qty) = trade.qty.as_ref() else {
1200            return false;
1201        };
1202        let Some(sum) = trade_sum.checked_add(qty.as_scaled()) else {
1203            return false;
1204        };
1205        trade_sum = sum;
1206    }
1207    trade_sum == cum
1208}
1209
1210#[cfg(test)]
1211mod tests {
1212    use super::*;
1213    use crate::codecs::scalars::format_id;
1214    use buffa::Message;
1215    use serde_json::json;
1216
1217    fn client() -> crate::Client {
1218        let client = crate::Client::new(crate::Config {
1219            hydrate_catalogs: false,
1220            ..Default::default()
1221        })
1222        .unwrap();
1223        client
1224            .catalogs
1225            .hydrate_spot_config_json(json!({
1226                "pairs": [{
1227                    "symbol": "BTC-USDT",
1228                    "symbol_id": 7,
1229                    "base_quantity_scale": 8,
1230                    "quote_quantity_scale": 6
1231                }]
1232            }))
1233            .expect("hydrate");
1234        client
1235    }
1236
1237    fn create_params(quantity: Quantity, price: Price) -> CreateOrderParams {
1238        CreateOrderParams {
1239            symbol: "BTC-USDT".into(),
1240            side: CreateSide::Buy,
1241            order_type: CreateOrderType::Limit,
1242            quantity: Some(quantity),
1243            max_quote_debit_scaled: None,
1244            price: Some(price),
1245            time_in_force: Some(CreateTimeInForce::Gtc),
1246            client_order_id: Some("order-equivalence".into()),
1247            subaccount_id: None,
1248            post_only: Some(true),
1249            market_client_ref_price: None,
1250            fee_asset: None,
1251            self_trade_prevention: None,
1252            market_max_slippage: None,
1253            attached_risk: None,
1254        }
1255    }
1256
1257    #[test]
1258    fn decimal_and_scaled_create_encode_identically() {
1259        let client = client();
1260        let decimal = create_params(
1261            Quantity::from_decimal_str("0.1", 8, Some("BTC-USDT".into()), Some(7)).unwrap(),
1262            Price::from_decimal_str("50000", Some("BTC-USDT".into())).unwrap(),
1263        );
1264        let scaled = create_params(
1265            Quantity::from_scaled(
1266                10_000_000,
1267                Some(8),
1268                crate::QuantityDomain::OrderBase,
1269                Some("BTC-USDT".into()),
1270                Some(7),
1271            )
1272            .unwrap(),
1273            Price::from_ticks(50_000_000_000, Some("BTC-USDT".into())).unwrap(),
1274        );
1275
1276        let decimal_wire = client.orders.encode_create_params(&decimal).unwrap();
1277        let scaled_wire = client.orders.encode_create_params(&scaled).unwrap();
1278        assert_eq!(decimal_wire.encode_to_vec(), scaled_wire.encode_to_vec());
1279    }
1280
1281    fn modify_params(new_price: Option<Price>, new_qty: Option<Quantity>) -> ModifyOrderParams {
1282        ModifyOrderParams {
1283            symbol: "BTC-USDT".into(),
1284            key: OrderKey::OrderId("1".into()),
1285            subaccount_id: None,
1286            request_id: Some("modify-equivalence".into()),
1287            new_price,
1288            new_qty,
1289            new_attached_risk: None,
1290            behavior: Some("amend_or_replace".into()),
1291            new_client_order_id: None,
1292        }
1293    }
1294
1295    #[test]
1296    fn decimal_and_scaled_modify_encode_identically() {
1297        let client = client();
1298        let decimal = modify_params(
1299            Some(Price::from_decimal_str("50001", Some("BTC-USDT".into())).unwrap()),
1300            Some(Quantity::from_decimal_str("0.2", 8, Some("BTC-USDT".into()), Some(7)).unwrap()),
1301        );
1302        let scaled = modify_params(
1303            Some(Price::from_ticks(50_001_000_000, Some("BTC-USDT".into())).unwrap()),
1304            Some(
1305                Quantity::from_scaled(
1306                    20_000_000,
1307                    Some(8),
1308                    crate::QuantityDomain::OrderBase,
1309                    Some("BTC-USDT".into()),
1310                    Some(7),
1311                )
1312                .unwrap(),
1313            ),
1314        );
1315
1316        let decimal_wire = client.orders.encode_modify_params(decimal).unwrap();
1317        let scaled_wire = client.orders.encode_modify_params(scaled).unwrap();
1318        assert_eq!(decimal_wire.encode_to_vec(), scaled_wire.encode_to_vec());
1319    }
1320
1321    #[test]
1322    fn batch_replace_requires_catalog_quantity_scale() {
1323        let catalogs = crate::catalogs::Manager::new();
1324        let err = OrdersService::resolve_batch_replace_scale(&catalogs, "BTC-USDT").unwrap_err();
1325        assert!(
1326            err.to_string().contains("quantity scale"),
1327            "unexpected error: {err}"
1328        );
1329    }
1330
1331    #[test]
1332    fn batch_replace_uses_symbol_catalog_quantity_scale() {
1333        let client = client();
1334        assert_eq!(
1335            OrdersService::resolve_batch_replace_scale(&client.catalogs, "BTC-USDT").unwrap(),
1336            8
1337        );
1338    }
1339
1340    #[test]
1341    fn modify_validates_key_and_patch() {
1342        let client = client();
1343        let empty_key = ModifyOrderParams {
1344            key: OrderKey::ClientOrderId(String::new()),
1345            ..modify_params(Some(Price::from_ticks(1, None).unwrap()), None)
1346        };
1347        assert!(client.orders.encode_modify_params(empty_key).is_err());
1348
1349        let no_patch = modify_params(None, None);
1350        assert!(client.orders.encode_modify_params(no_patch).is_err());
1351    }
1352
1353    #[test]
1354    #[allow(deprecated)]
1355    fn attached_risk_encodes_on_create_and_modify() {
1356        use crate::models::{AttachedRisk, RiskLeg, TriggerPriceSourceKind};
1357
1358        let client = client();
1359        let risk = AttachedRisk {
1360            take_profit: Some(RiskLeg {
1361                trigger_price: Price::from_ticks(51_000_000_000, Some("BTC-USDT".into())).unwrap(),
1362                trigger_price_source: None,
1363                order_type: Some(CreateOrderType::Market),
1364                limit_price: None,
1365            }),
1366            stop_loss: Some(RiskLeg {
1367                trigger_price: Price::from_ticks(49_000_000_000, Some("BTC-USDT".into())).unwrap(),
1368                trigger_price_source: None,
1369                order_type: Some(CreateOrderType::Limit),
1370                limit_price: Some(
1371                    Price::from_ticks(48_900_000_000, Some("BTC-USDT".into())).unwrap(),
1372                ),
1373            }),
1374            trailing_stop: None,
1375            oco: true,
1376        };
1377
1378        let mut create = create_params(
1379            Quantity::from_scaled(
1380                10_000_000,
1381                Some(8),
1382                crate::QuantityDomain::OrderBase,
1383                Some("BTC-USDT".into()),
1384                Some(7),
1385            )
1386            .unwrap(),
1387            Price::from_ticks(50_000_000_000, Some("BTC-USDT".into())).unwrap(),
1388        );
1389        create.attached_risk = Some(risk.clone());
1390        let create_wire = client.orders.encode_create_params(&create).unwrap();
1391        let order = create_wire.order.as_option().unwrap();
1392        assert!(order.attached_risk.is_set());
1393        assert!(order.attached_risk.as_option().unwrap().oco);
1394
1395        let mut modify = modify_params(None, None);
1396        modify.new_attached_risk = Some(risk);
1397        let modify_wire = client.orders.encode_modify_params(modify).unwrap();
1398        assert!(modify_wire.new_attached_risk.is_set());
1399
1400        let mut unsupported = create;
1401        unsupported
1402            .attached_risk
1403            .as_mut()
1404            .unwrap()
1405            .take_profit
1406            .as_mut()
1407            .unwrap()
1408            .trigger_price_source = Some(TriggerPriceSourceKind::IndexPrice);
1409        let err = client
1410            .orders
1411            .encode_create_params(&unsupported)
1412            .unwrap_err();
1413        assert!(matches!(&err, Error::Validation(_)));
1414        assert!(err.to_string().contains("always uses last trade"));
1415    }
1416
1417    #[test]
1418    fn preview_encodes_full_order_intent() {
1419        let client = client();
1420        let create = create_params(
1421            Quantity::from_scaled(
1422                10_000_000,
1423                Some(8),
1424                crate::QuantityDomain::OrderBase,
1425                Some("BTC-USDT".into()),
1426                Some(7),
1427            )
1428            .unwrap(),
1429            Price::from_ticks(50_000_000_000, Some("BTC-USDT".into())).unwrap(),
1430        );
1431        let preview = PreviewOrderParams {
1432            symbol: create.symbol.clone(),
1433            side: create.side,
1434            order_type: create.order_type,
1435            quantity: create.quantity.clone(),
1436            max_quote_debit_scaled: None,
1437            price: create.price.clone(),
1438            time_in_force: create.time_in_force,
1439            client_order_id: Some("preview-cid".into()),
1440            subaccount_id: Some(9),
1441            post_only: create.post_only,
1442            market_client_ref_price: None,
1443            fee_asset: Some(FeeAsset::Quote),
1444            self_trade_prevention: Some(OrderSelfTradePrevention::ExpireTaker),
1445            market_max_slippage: None,
1446            attached_risk: None,
1447        };
1448        let wire = client.orders.encode_preview_params(&preview).unwrap();
1449        assert_eq!(wire.subaccount_id, Some(9));
1450        let intent = wire.order.as_option().expect("preview order intent");
1451        assert_eq!(intent.symbol, "BTC-USDT");
1452        assert_eq!(intent.side.as_known(), Some(Side::Buy));
1453        assert_eq!(intent.client_order_id, "preview-cid");
1454        assert!(matches!(
1455            intent.sizing,
1456            Some(order_intent::Sizing::BaseQtyScaled(10_000_000))
1457        ));
1458        assert!(matches!(
1459            intent.execution,
1460            Some(order_intent::Execution::LimitGtc(_))
1461        ));
1462        assert_eq!(
1463            intent.self_trade_prevention_mode.as_known(),
1464            Some(SelfTradePreventionMode::ExpireTaker)
1465        );
1466    }
1467
1468    #[test]
1469    fn create_allows_omitted_client_order_id_and_encodes_market_maker_controls() {
1470        let client = client();
1471        let mut params = create_params(
1472            Quantity::from_scaled(
1473                10_000_000,
1474                Some(8),
1475                crate::QuantityDomain::OrderBase,
1476                Some("BTC-USDT".into()),
1477                Some(7),
1478            )
1479            .unwrap(),
1480            Price::from_ticks(50_000_000_000, Some("BTC-USDT".into())).unwrap(),
1481        );
1482        params.client_order_id = None;
1483        let omitted = client.orders.encode_create_params(&params).unwrap();
1484        assert!(
1485            omitted
1486                .order
1487                .as_option()
1488                .unwrap()
1489                .client_order_id
1490                .is_empty()
1491        );
1492
1493        params.client_order_id = Some(" ".into());
1494        let whitespace = client.orders.encode_create_params(&params).unwrap();
1495        assert!(
1496            whitespace
1497                .order
1498                .as_option()
1499                .unwrap()
1500                .client_order_id
1501                .is_empty()
1502        );
1503
1504        params.client_order_id = Some("mm-create-1".into());
1505        params.order_type = CreateOrderType::Market;
1506        params.price = None;
1507        params.post_only = None;
1508        params.fee_asset = Some(FeeAsset::Base);
1509        params.self_trade_prevention = Some(OrderSelfTradePrevention::ExpireBoth);
1510        params.market_max_slippage = Some(MaxSlippage::Bps(25));
1511        let wire = client.orders.encode_create_params(&params).unwrap();
1512        let intent = wire.order.as_option().unwrap();
1513        assert_eq!(intent.fee_asset.as_known(), Some(ProtoFeeAsset::Base));
1514        assert_eq!(
1515            intent.self_trade_prevention_mode.as_known(),
1516            Some(SelfTradePreventionMode::ExpireBoth)
1517        );
1518        let Some(order_intent::Execution::MarketIoc(market)) = intent.execution.as_ref() else {
1519            panic!("expected market execution");
1520        };
1521        assert!(matches!(
1522            market.max_slippage,
1523            Some(market_ioc::MaxSlippage::MaxSlippageBps(25))
1524        ));
1525
1526        params.price = Some(Price::from_ticks(1, None).unwrap());
1527        let err = client.orders.encode_create_params(&params).unwrap_err();
1528        assert!(
1529            err.to_string().contains("price is not valid for market"),
1530            "unexpected error: {err}"
1531        );
1532
1533        params.price = None;
1534        params.market_max_slippage = Some(MaxSlippage::Ticks(0));
1535        assert!(client.orders.encode_create_params(&params).is_err());
1536    }
1537
1538    #[test]
1539    fn create_encodes_quote_budget_sizing_and_rejects_ambiguous_sizing() {
1540        let client = client();
1541        let mut params = create_params(
1542            Quantity::from_scaled(
1543                10_000_000,
1544                Some(8),
1545                crate::QuantityDomain::OrderBase,
1546                Some("BTC-USDT".into()),
1547                Some(7),
1548            )
1549            .unwrap(),
1550            Price::from_ticks(50_000_000_000, Some("BTC-USDT".into())).unwrap(),
1551        );
1552        params.quantity = None;
1553        params.max_quote_debit_scaled = Some(
1554            Quantity::from_quote_scaled(5_000_000, 6, Some("BTC-USDT".into()), Some(7)).unwrap(),
1555        );
1556        let wire = client.orders.encode_create_params(&params).unwrap();
1557        let intent = wire.order.as_option().unwrap();
1558        assert!(matches!(
1559            intent.sizing,
1560            Some(order_intent::Sizing::MaxQuoteDebitScaled(5_000_000))
1561        ));
1562
1563        params.quantity = Some(
1564            Quantity::from_scaled(
1565                10_000_000,
1566                Some(8),
1567                crate::QuantityDomain::OrderBase,
1568                Some("BTC-USDT".into()),
1569                Some(7),
1570            )
1571            .unwrap(),
1572        );
1573        assert!(client.orders.encode_create_params(&params).is_err());
1574
1575        params.quantity = None;
1576        params.max_quote_debit_scaled =
1577            Some(Quantity::from_quote_scaled(5_000_000, 8, None, None).unwrap());
1578        let err = client.orders.encode_create_params(&params).unwrap_err();
1579        assert!(err.to_string().contains("scale mismatch"));
1580    }
1581
1582    #[test]
1583    fn batch_size_guard_rejects_empty_and_more_than_twenty() {
1584        assert!(OrdersService::validate_batch_size("batch_create", 1).is_ok());
1585        assert!(OrdersService::validate_batch_size("batch_create", 20).is_ok());
1586        assert!(
1587            OrdersService::validate_batch_size("batch_create", 0)
1588                .unwrap_err()
1589                .to_string()
1590                .contains("at least one")
1591        );
1592        assert!(
1593            OrdersService::validate_batch_size("batch_create", 21)
1594                .unwrap_err()
1595                .to_string()
1596                .contains("at most 20")
1597        );
1598    }
1599
1600    #[test]
1601    fn create_rejects_invalid_client_order_id_before_wire() {
1602        let client = client();
1603        let mut params = create_params(
1604            Quantity::from_scaled(
1605                10_000_000,
1606                Some(8),
1607                crate::QuantityDomain::OrderBase,
1608                Some("BTC-USDT".into()),
1609                Some(7),
1610            )
1611            .unwrap(),
1612            Price::from_ticks(50_000_000_000, Some("BTC-USDT".into())).unwrap(),
1613        );
1614
1615        params.client_order_id = Some("bad id".into());
1616        let err = client.orders.encode_create_params(&params).unwrap_err();
1617        assert!(err.to_string().contains("invalid characters"));
1618
1619        params.client_order_id = Some("a".repeat(37));
1620        let err = client.orders.encode_create_params(&params).unwrap_err();
1621        assert!(err.to_string().contains("1 to 36"));
1622
1623        params.client_order_id = Some("ok-id_1.2:3/4".into());
1624        assert!(client.orders.encode_create_params(&params).is_ok());
1625
1626        let err = OrdersService::coalesce_request_id(Some("bad id".into()), "mod").unwrap_err();
1627        assert!(err.to_string().contains("invalid characters"));
1628        let err = OrdersService::coalesce_request_id(Some("r".repeat(65)), "mod").unwrap_err();
1629        assert!(err.to_string().contains("1 to 64"));
1630    }
1631
1632    #[tokio::test]
1633    async fn singular_order_methods_reject_invalid_client_order_id_before_transport() {
1634        let client = client();
1635        let err = client
1636            .orders
1637            .cancel_by_client_order_id("bad id!", None, None)
1638            .await
1639            .unwrap_err();
1640        assert!(matches!(err, Error::Validation(_)));
1641        assert!(err.to_string().contains("invalid characters"));
1642
1643        let err = client
1644            .orders
1645            .get(OrderKey::ClientOrderId("bad id!".into()), None)
1646            .await
1647            .unwrap_err();
1648        assert!(matches!(err, Error::Validation(_)));
1649        assert!(err.to_string().contains("invalid characters"));
1650    }
1651
1652    #[test]
1653    fn cancel_symbol_routing_distinguishes_omitted_and_invalid_inputs() {
1654        let client = client();
1655        assert_eq!(
1656            OrdersService::resolve_cancel_symbol_id(&client.catalogs, None, None).unwrap(),
1657            0
1658        );
1659        assert_eq!(
1660            OrdersService::resolve_cancel_symbol_id(&client.catalogs, Some("BTC-USDT"), None)
1661                .unwrap(),
1662            7
1663        );
1664
1665        for err in [
1666            OrdersService::resolve_cancel_symbol_id(&client.catalogs, Some("UNKNOWN-USDT"), None)
1667                .unwrap_err(),
1668            OrdersService::resolve_cancel_symbol_id(&client.catalogs, None, Some(0)).unwrap_err(),
1669            OrdersService::resolve_cancel_symbol_id(&client.catalogs, Some("BTC-USDT"), Some(7))
1670                .unwrap_err(),
1671        ] {
1672            assert!(matches!(err, Error::Validation(_)));
1673        }
1674    }
1675
1676    #[tokio::test]
1677    async fn cancel_rejects_unknown_supplied_symbol_before_transport() {
1678        let client = client();
1679        let err = client
1680            .orders
1681            .cancel_with(CancelOrderParams {
1682                key: OrderKey::OrderId(format_id(9)),
1683                symbol: Some("UNKNOWN-USDT".into()),
1684                symbol_id: None,
1685                subaccount_id: None,
1686            })
1687            .await
1688            .unwrap_err();
1689        assert!(matches!(&err, Error::Validation(_)));
1690        assert!(err.to_string().contains("unknown symbol"));
1691    }
1692
1693    #[test]
1694    fn mutation_request_ids_are_generated_when_omitted_like_go_python_typescript() {
1695        for prefix in [
1696            "cancel-all",
1697            "cancel-after",
1698            "mod",
1699            "batch-create",
1700            "batch-cancel",
1701            "batch-replace",
1702        ] {
1703            let generated = OrdersService::coalesce_request_id(None, prefix).unwrap();
1704            assert!(
1705                generated.starts_with(&format!("{prefix}-")),
1706                "unexpected generated id for {prefix}: {generated}"
1707            );
1708            assert_eq!(generated.len(), prefix.len() + 1 + 12);
1709
1710            let blank = OrdersService::coalesce_request_id(Some("  ".into()), prefix).unwrap();
1711            assert!(blank.starts_with(&format!("{prefix}-")));
1712            assert_ne!(generated, blank);
1713        }
1714
1715        assert_eq!(
1716            OrdersService::coalesce_request_id(Some(" retry-mod-1 ".into()), "mod").unwrap(),
1717            "retry-mod-1"
1718        );
1719        assert_eq!(
1720            OrdersService::coalesce_request_id(Some("same-retry".into()), "batch-create").unwrap(),
1721            "same-retry"
1722        );
1723    }
1724
1725    #[test]
1726    fn wait_helper_detects_trade_projection_complete() {
1727        let incomplete = GetOrderResult {
1728            order: Some(Order {
1729                order_id: "1".into(),
1730                symbol_id: 7,
1731                client_order_id: "c".into(),
1732                side: "buy".into(),
1733                status: "filled".into(),
1734                order_type: "market".into(),
1735                tif: "ioc".into(),
1736                orig_qty: None,
1737                cum_qty: Some(
1738                    Quantity::from_scaled(
1739                        100,
1740                        Some(8),
1741                        crate::QuantityDomain::OrderBase,
1742                        None,
1743                        None,
1744                    )
1745                    .unwrap(),
1746                ),
1747                leaves_qty: None,
1748                price: None,
1749                avg_px: None,
1750                created_ts_ns: String::new(),
1751                version: 1,
1752                post_only: false,
1753                fee_asset: "quote".into(),
1754                submitted_max_quote_debit_scaled: None,
1755                attached_risk: None,
1756            }),
1757            trades: vec![],
1758        };
1759        assert!(!order_trades_projection_complete(&incomplete));
1760
1761        let open_unfilled = GetOrderResult {
1762            order: Some(Order {
1763                status: "working".into(),
1764                cum_qty: Some(
1765                    Quantity::from_scaled(0, Some(8), crate::QuantityDomain::OrderBase, None, None)
1766                        .unwrap(),
1767                ),
1768                ..incomplete.order.clone().unwrap()
1769            }),
1770            trades: vec![],
1771        };
1772        assert!(
1773            !order_trades_projection_complete(&open_unfilled),
1774            "an unfilled working order is not a stable projection"
1775        );
1776
1777        let complete = GetOrderResult {
1778            order: Some(Order {
1779                status: "filled".into(),
1780                ..incomplete.order.clone().unwrap()
1781            }),
1782            trades: vec![UserTrade {
1783                symbol_id: 7,
1784                match_id: "m".into(),
1785                order_id: "1".into(),
1786                side: "buy".into(),
1787                is_maker: false,
1788                price: None,
1789                qty: Some(
1790                    Quantity::from_scaled(
1791                        100,
1792                        Some(8),
1793                        crate::QuantityDomain::OrderBase,
1794                        None,
1795                        None,
1796                    )
1797                    .unwrap(),
1798                ),
1799                fee_scaled: "0".into(),
1800                fee_asset: "quote".into(),
1801                referral_share_scaled: "0".into(),
1802                ts_ns: String::new(),
1803            }],
1804        };
1805        assert!(order_trades_projection_complete(&complete));
1806    }
1807}