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