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