Skip to main content

rust_okx/api/trade/requests/
algo.rs

1use serde::Serialize;
2
3use crate::model::{
4    RequestValidationError, ValidateRequest, at_least_one, decimal_string_range, exactly_one,
5    max_length, non_empty, non_negative_decimal_string, one_of, optional_non_empty,
6    optional_one_of, optional_unsigned_integer_string, positive_decimal_string,
7    positive_unsigned_integer_string, range_u64, reject_when_present, require_when,
8    validate_client_request_id,
9};
10
11const PLACE_ALGO_ORDER_TYPES: &[&str] = &[
12    "conditional",
13    "oco",
14    "chase",
15    "trigger",
16    "move_order_stop",
17    "twap",
18    "smart_iceberg",
19];
20const QUERY_ALGO_ORDER_TYPES: &[&str] = &[
21    "conditional",
22    "oco",
23    "chase",
24    "trigger",
25    "move_order_stop",
26    "iceberg",
27    "twap",
28    "smart_iceberg",
29];
30const PRICE_TYPES: &[&str] = &["last", "index", "mark"];
31
32/// One attached take-profit/stop-loss definition for an algo order.
33#[derive(Debug, Clone, Default, Serialize)]
34pub struct AttachedAlgoOrderRequest {
35    #[serde(rename = "attachAlgoClOrdId", skip_serializing_if = "Option::is_none")]
36    attach_algo_cl_ord_id: Option<String>,
37    #[serde(rename = "tpTriggerPx", skip_serializing_if = "Option::is_none")]
38    tp_trigger_px: Option<String>,
39    #[serde(rename = "tpTriggerRatio", skip_serializing_if = "Option::is_none")]
40    tp_trigger_ratio: Option<String>,
41    #[serde(rename = "tpTriggerPxType", skip_serializing_if = "Option::is_none")]
42    tp_trigger_px_type: Option<String>,
43    #[serde(rename = "tpOrdPx", skip_serializing_if = "Option::is_none")]
44    tp_ord_px: Option<String>,
45    #[serde(rename = "slTriggerPx", skip_serializing_if = "Option::is_none")]
46    sl_trigger_px: Option<String>,
47    #[serde(rename = "slTriggerRatio", skip_serializing_if = "Option::is_none")]
48    sl_trigger_ratio: Option<String>,
49    #[serde(rename = "slTriggerPxType", skip_serializing_if = "Option::is_none")]
50    sl_trigger_px_type: Option<String>,
51    #[serde(rename = "slOrdPx", skip_serializing_if = "Option::is_none")]
52    sl_ord_px: Option<String>,
53}
54
55impl AttachedAlgoOrderRequest {
56    /// Create an empty attached TP/SL definition.
57    pub fn new() -> Self {
58        Self::default()
59    }
60
61    /// Set the client-supplied attached algo ID.
62    pub fn client_algo_order_id(mut self, value: impl Into<String>) -> Self {
63        self.attach_algo_cl_ord_id = Some(value.into());
64        self
65    }
66
67    /// Configure take profit using a trigger price.
68    pub fn take_profit(
69        mut self,
70        trigger_px: impl Into<String>,
71        order_px: impl Into<String>,
72    ) -> Self {
73        self.tp_trigger_px = Some(trigger_px.into());
74        self.tp_ord_px = Some(order_px.into());
75        self
76    }
77
78    /// Configure take profit using a trigger ratio.
79    pub fn take_profit_ratio(
80        mut self,
81        trigger_ratio: impl Into<String>,
82        order_px: impl Into<String>,
83    ) -> Self {
84        self.tp_trigger_ratio = Some(trigger_ratio.into());
85        self.tp_ord_px = Some(order_px.into());
86        self
87    }
88
89    /// Set the take-profit trigger price source.
90    pub fn take_profit_price_type(mut self, value: impl Into<String>) -> Self {
91        self.tp_trigger_px_type = Some(value.into());
92        self
93    }
94
95    /// Configure stop loss using a trigger price.
96    pub fn stop_loss(mut self, trigger_px: impl Into<String>, order_px: impl Into<String>) -> Self {
97        self.sl_trigger_px = Some(trigger_px.into());
98        self.sl_ord_px = Some(order_px.into());
99        self
100    }
101
102    /// Configure stop loss using a trigger ratio.
103    pub fn stop_loss_ratio(
104        mut self,
105        trigger_ratio: impl Into<String>,
106        order_px: impl Into<String>,
107    ) -> Self {
108        self.sl_trigger_ratio = Some(trigger_ratio.into());
109        self.sl_ord_px = Some(order_px.into());
110        self
111    }
112
113    /// Set the stop-loss trigger price source.
114    pub fn stop_loss_price_type(mut self, value: impl Into<String>) -> Self {
115        self.sl_trigger_px_type = Some(value.into());
116        self
117    }
118}
119
120impl ValidateRequest for AttachedAlgoOrderRequest {
121    fn validate(&self) -> Result<(), RequestValidationError> {
122        validate_client_request_id("attachAlgoClOrdId", self.attach_algo_cl_ord_id.as_deref())?;
123        optional_non_empty("tpTriggerPx", self.tp_trigger_px.as_deref())?;
124        if let Some(value) = self.tp_trigger_ratio.as_deref() {
125            signed_decimal_string("tpTriggerRatio", value)?;
126        }
127        optional_one_of(
128            "tpTriggerPxType",
129            self.tp_trigger_px_type.as_deref(),
130            PRICE_TYPES,
131            "last, index, or mark",
132        )?;
133        optional_non_empty("tpOrdPx", self.tp_ord_px.as_deref())?;
134        optional_non_empty("slTriggerPx", self.sl_trigger_px.as_deref())?;
135        if let Some(value) = self.sl_trigger_ratio.as_deref() {
136            signed_decimal_string("slTriggerRatio", value)?;
137        }
138        optional_one_of(
139            "slTriggerPxType",
140            self.sl_trigger_px_type.as_deref(),
141            PRICE_TYPES,
142            "last, index, or mark",
143        )?;
144        optional_non_empty("slOrdPx", self.sl_ord_px.as_deref())?;
145
146        if self.tp_trigger_px.is_some() && self.tp_trigger_ratio.is_some() {
147            return Err(RequestValidationError::MutuallyExclusive {
148                fields: "tpTriggerPx, tpTriggerRatio",
149            });
150        }
151        if self.sl_trigger_px.is_some() && self.sl_trigger_ratio.is_some() {
152            return Err(RequestValidationError::MutuallyExclusive {
153                fields: "slTriggerPx, slTriggerRatio",
154            });
155        }
156
157        let has_tp_trigger = self.tp_trigger_px.is_some() || self.tp_trigger_ratio.is_some();
158        let has_sl_trigger = self.sl_trigger_px.is_some() || self.sl_trigger_ratio.is_some();
159        at_least_one(
160            "take-profit fields, stop-loss fields",
161            &[has_tp_trigger, has_sl_trigger],
162        )?;
163        if has_tp_trigger && self.tp_ord_px.is_none() {
164            return Err(RequestValidationError::RequiredWhen {
165                field: "tpOrdPx",
166                condition: "an attached take-profit trigger is present",
167            });
168        }
169        if has_sl_trigger && self.sl_ord_px.is_none() {
170            return Err(RequestValidationError::RequiredWhen {
171                field: "slOrdPx",
172                condition: "an attached stop-loss trigger is present",
173            });
174        }
175        Ok(())
176    }
177}
178
179/// One trigger definition used by a `smart_iceberg` request.
180#[derive(Debug, Clone, Serialize)]
181pub struct SmartIcebergTriggerRequest {
182    #[serde(rename = "triggerAction")]
183    trigger_action: String,
184    #[serde(rename = "triggerStrategy")]
185    trigger_strategy: String,
186    #[serde(rename = "triggerPx", skip_serializing_if = "Option::is_none")]
187    trigger_px: Option<String>,
188    #[serde(rename = "triggerCond", skip_serializing_if = "Option::is_none")]
189    trigger_cond: Option<String>,
190    #[serde(skip_serializing_if = "Option::is_none")]
191    timeframe: Option<String>,
192    #[serde(skip_serializing_if = "Option::is_none")]
193    thold: Option<String>,
194    #[serde(rename = "timePeriod", skip_serializing_if = "Option::is_none")]
195    time_period: Option<String>,
196}
197
198impl SmartIcebergTriggerRequest {
199    /// Create a start trigger with strategy `instant`, `price`, or `rsi`.
200    pub fn new(trigger_strategy: impl Into<String>) -> Self {
201        Self {
202            trigger_action: "start".to_owned(),
203            trigger_strategy: trigger_strategy.into(),
204            trigger_px: None,
205            trigger_cond: None,
206            timeframe: None,
207            thold: None,
208            time_period: None,
209        }
210    }
211
212    /// Set a price trigger and optional crossing condition.
213    pub fn price(mut self, trigger_px: impl Into<String>) -> Self {
214        self.trigger_px = Some(trigger_px.into());
215        self
216    }
217
218    /// Set the trigger condition.
219    pub fn condition(mut self, value: impl Into<String>) -> Self {
220        self.trigger_cond = Some(value.into());
221        self
222    }
223
224    /// Set the RSI candle timeframe.
225    pub fn timeframe(mut self, value: impl Into<String>) -> Self {
226        self.timeframe = Some(value.into());
227        self
228    }
229
230    /// Set the RSI threshold from 1 through 100.
231    pub fn threshold(mut self, value: impl Into<String>) -> Self {
232        self.thold = Some(value.into());
233        self
234    }
235
236    /// Set the RSI period. OKX currently fixes this value at `14`.
237    pub fn time_period(mut self, value: impl Into<String>) -> Self {
238        self.time_period = Some(value.into());
239        self
240    }
241}
242
243impl ValidateRequest for SmartIcebergTriggerRequest {
244    fn validate(&self) -> Result<(), RequestValidationError> {
245        one_of("triggerAction", &self.trigger_action, &["start"], "start")?;
246        one_of(
247            "triggerStrategy",
248            &self.trigger_strategy,
249            &["instant", "price", "rsi"],
250            "instant, price, or rsi",
251        )?;
252        optional_non_empty("triggerPx", self.trigger_px.as_deref())?;
253        optional_one_of(
254            "triggerCond",
255            self.trigger_cond.as_deref(),
256            &["cross_up", "cross_down", "above", "below", "cross"],
257            "cross_up, cross_down, above, below, or cross",
258        )?;
259        optional_one_of(
260            "timeframe",
261            self.timeframe.as_deref(),
262            &["3m", "5m", "15m", "30m", "1H", "4H", "1D"],
263            "3m, 5m, 15m, 30m, 1H, 4H, or 1D",
264        )?;
265
266        if let Some(threshold) = self.thold.as_deref() {
267            positive_unsigned_integer_string("thold", threshold)?;
268            let parsed =
269                threshold
270                    .parse::<u64>()
271                    .map_err(|_| RequestValidationError::InvalidFormat {
272                        field: "thold",
273                        expected: "an integer from 1 through 100",
274                    })?;
275            range_u64("thold", parsed, 1, 100)?;
276        }
277        if let Some(period) = self.time_period.as_deref() {
278            one_of("timePeriod", period, &["14"], "14")?;
279        }
280
281        match self.trigger_strategy.as_str() {
282            "instant" => {
283                reject_when_present(
284                    "triggerPx",
285                    self.trigger_px.as_ref(),
286                    "triggerStrategy is instant",
287                )?;
288                reject_when_present(
289                    "triggerCond",
290                    self.trigger_cond.as_ref(),
291                    "triggerStrategy is instant",
292                )?;
293                reject_when_present(
294                    "timeframe",
295                    self.timeframe.as_ref(),
296                    "triggerStrategy is instant",
297                )?;
298                reject_when_present("thold", self.thold.as_ref(), "triggerStrategy is instant")?;
299                reject_when_present(
300                    "timePeriod",
301                    self.time_period.as_ref(),
302                    "triggerStrategy is instant",
303                )?;
304            }
305            "price" => {
306                require_when(
307                    "triggerPx",
308                    self.trigger_px.as_deref(),
309                    "triggerStrategy is price",
310                )?;
311                reject_when_present(
312                    "timeframe",
313                    self.timeframe.as_ref(),
314                    "triggerStrategy is price",
315                )?;
316                reject_when_present("thold", self.thold.as_ref(), "triggerStrategy is price")?;
317                reject_when_present(
318                    "timePeriod",
319                    self.time_period.as_ref(),
320                    "triggerStrategy is price",
321                )?;
322            }
323            "rsi" => {
324                reject_when_present(
325                    "triggerPx",
326                    self.trigger_px.as_ref(),
327                    "triggerStrategy is rsi",
328                )?;
329            }
330            _ => {}
331        }
332        Ok(())
333    }
334}
335
336/// Request body for `POST /api/v5/trade/order-algo`.
337#[derive(Debug, Clone, Serialize)]
338pub struct AlgoOrderRequest {
339    #[serde(rename = "instId")]
340    inst_id: String,
341    #[serde(rename = "tdMode")]
342    td_mode: String,
343    side: String,
344    #[serde(rename = "ordType")]
345    ord_type: String,
346    #[serde(skip_serializing_if = "Option::is_none")]
347    sz: Option<String>,
348    #[serde(skip_serializing_if = "Option::is_none")]
349    ccy: Option<String>,
350    #[serde(rename = "posSide", skip_serializing_if = "Option::is_none")]
351    pos_side: Option<String>,
352    #[serde(rename = "reduceOnly", skip_serializing_if = "Option::is_none")]
353    reduce_only: Option<bool>,
354    #[serde(rename = "algoClOrdId", skip_serializing_if = "Option::is_none")]
355    algo_cl_ord_id: Option<String>,
356    #[serde(rename = "tgtCcy", skip_serializing_if = "Option::is_none")]
357    tgt_ccy: Option<String>,
358    #[serde(rename = "closeFraction", skip_serializing_if = "Option::is_none")]
359    close_fraction: Option<String>,
360    #[serde(rename = "tradeQuoteCcy", skip_serializing_if = "Option::is_none")]
361    trade_quote_ccy: Option<String>,
362    #[serde(rename = "triggerPx", skip_serializing_if = "Option::is_none")]
363    trigger_px: Option<String>,
364    #[serde(rename = "orderPx", skip_serializing_if = "Option::is_none")]
365    order_px: Option<String>,
366    #[serde(rename = "advanceOrdType", skip_serializing_if = "Option::is_none")]
367    advance_ord_type: Option<String>,
368    #[serde(rename = "triggerPxType", skip_serializing_if = "Option::is_none")]
369    trigger_px_type: Option<String>,
370    #[serde(rename = "tpTriggerPx", skip_serializing_if = "Option::is_none")]
371    tp_trigger_px: Option<String>,
372    #[serde(rename = "tpTriggerPxType", skip_serializing_if = "Option::is_none")]
373    tp_trigger_px_type: Option<String>,
374    #[serde(rename = "tpOrdPx", skip_serializing_if = "Option::is_none")]
375    tp_ord_px: Option<String>,
376    #[serde(rename = "tpOrdKind", skip_serializing_if = "Option::is_none")]
377    tp_ord_kind: Option<String>,
378    #[serde(rename = "slTriggerPx", skip_serializing_if = "Option::is_none")]
379    sl_trigger_px: Option<String>,
380    #[serde(rename = "slTriggerPxType", skip_serializing_if = "Option::is_none")]
381    sl_trigger_px_type: Option<String>,
382    #[serde(rename = "slOrdPx", skip_serializing_if = "Option::is_none")]
383    sl_ord_px: Option<String>,
384    #[serde(rename = "cxlOnClosePos", skip_serializing_if = "Option::is_none")]
385    cxl_on_close_pos: Option<bool>,
386    #[serde(rename = "attachAlgoOrds", skip_serializing_if = "Option::is_none")]
387    attach_algo_ords: Option<Vec<AttachedAlgoOrderRequest>>,
388    #[serde(rename = "callbackRatio", skip_serializing_if = "Option::is_none")]
389    callback_ratio: Option<String>,
390    #[serde(rename = "callbackSpread", skip_serializing_if = "Option::is_none")]
391    callback_spread: Option<String>,
392    #[serde(rename = "activePx", skip_serializing_if = "Option::is_none")]
393    active_px: Option<String>,
394    #[serde(rename = "chaseType", skip_serializing_if = "Option::is_none")]
395    chase_type: Option<String>,
396    #[serde(rename = "chaseVal", skip_serializing_if = "Option::is_none")]
397    chase_val: Option<String>,
398    #[serde(rename = "maxChaseType", skip_serializing_if = "Option::is_none")]
399    max_chase_type: Option<String>,
400    #[serde(rename = "maxChaseVal", skip_serializing_if = "Option::is_none")]
401    max_chase_val: Option<String>,
402    #[serde(rename = "pxVar", skip_serializing_if = "Option::is_none")]
403    px_var: Option<String>,
404    #[serde(rename = "pxSpread", skip_serializing_if = "Option::is_none")]
405    px_spread: Option<String>,
406    #[serde(rename = "szLimit", skip_serializing_if = "Option::is_none")]
407    sz_limit: Option<String>,
408    #[serde(rename = "pxLimit", skip_serializing_if = "Option::is_none")]
409    px_limit: Option<String>,
410    #[serde(rename = "timeInterval", skip_serializing_if = "Option::is_none")]
411    time_interval: Option<String>,
412    #[serde(rename = "lmtOrderNumber", skip_serializing_if = "Option::is_none")]
413    lmt_order_number: Option<String>,
414    #[serde(skip_serializing_if = "Option::is_none")]
415    aggressiveness: Option<String>,
416    #[serde(rename = "triggerParams", skip_serializing_if = "Option::is_none")]
417    trigger_params: Option<Vec<SmartIcebergTriggerRequest>>,
418    #[serde(skip_serializing_if = "Option::is_none")]
419    tag: Option<String>,
420}
421
422impl AlgoOrderRequest {
423    /// Create an algo-order request with a quantity.
424    pub fn new(
425        inst_id: impl Into<String>,
426        td_mode: impl Into<String>,
427        side: impl Into<String>,
428        ord_type: impl Into<String>,
429        sz: impl Into<String>,
430    ) -> Self {
431        Self {
432            inst_id: inst_id.into(),
433            td_mode: td_mode.into(),
434            side: side.into(),
435            ord_type: ord_type.into(),
436            sz: Some(sz.into()),
437            ccy: None,
438            pos_side: None,
439            reduce_only: None,
440            algo_cl_ord_id: None,
441            tgt_ccy: None,
442            close_fraction: None,
443            trade_quote_ccy: None,
444            trigger_px: None,
445            order_px: None,
446            advance_ord_type: None,
447            trigger_px_type: None,
448            tp_trigger_px: None,
449            tp_trigger_px_type: None,
450            tp_ord_px: None,
451            tp_ord_kind: None,
452            sl_trigger_px: None,
453            sl_trigger_px_type: None,
454            sl_ord_px: None,
455            cxl_on_close_pos: None,
456            attach_algo_ords: None,
457            callback_ratio: None,
458            callback_spread: None,
459            active_px: None,
460            chase_type: None,
461            chase_val: None,
462            max_chase_type: None,
463            max_chase_val: None,
464            px_var: None,
465            px_spread: None,
466            sz_limit: None,
467            px_limit: None,
468            time_interval: None,
469            lmt_order_number: None,
470            aggressiveness: None,
471            trigger_params: None,
472            tag: None,
473        }
474    }
475
476    /// Replace `sz` with the only currently supported full-close fraction, `1`.
477    pub fn full_close(mut self) -> Self {
478        self.sz = None;
479        self.close_fraction = Some("1".to_owned());
480        self
481    }
482
483    /// Set the margin currency.
484    pub fn currency(mut self, value: impl Into<String>) -> Self {
485        self.ccy = Some(value.into());
486        self
487    }
488
489    /// Set the position side (`long`, `short`, or `net`).
490    pub fn position_side(mut self, value: impl Into<String>) -> Self {
491        self.pos_side = Some(value.into());
492        self
493    }
494
495    /// Set the reduce-only flag.
496    pub fn reduce_only(mut self, value: bool) -> Self {
497        self.reduce_only = Some(value);
498        self
499    }
500
501    /// Set the client-supplied algo ID.
502    pub fn client_algo_order_id(mut self, value: impl Into<String>) -> Self {
503        self.algo_cl_ord_id = Some(value.into());
504        self
505    }
506
507    /// Set the Spot quantity unit (`base_ccy` or `quote_ccy`).
508    pub fn target_currency(mut self, value: impl Into<String>) -> Self {
509        self.tgt_ccy = Some(value.into());
510        self
511    }
512
513    /// Set the quote currency used for Spot trading.
514    pub fn trade_quote_currency(mut self, value: impl Into<String>) -> Self {
515        self.trade_quote_ccy = Some(value.into());
516        self
517    }
518
519    /// Configure a trigger order's trigger and execution prices.
520    pub fn trigger(mut self, px: impl Into<String>, order_px: impl Into<String>) -> Self {
521        self.trigger_px = Some(px.into());
522        self.order_px = Some(order_px.into());
523        self
524    }
525
526    /// Set trigger execution type (`fok` or `ioc`).
527    pub fn advance_order_type(mut self, value: impl Into<String>) -> Self {
528        self.advance_ord_type = Some(value.into());
529        self
530    }
531
532    /// Set trigger price type (`last`, `index`, or `mark`).
533    pub fn trigger_price_type(mut self, value: impl Into<String>) -> Self {
534        self.trigger_px_type = Some(value.into());
535        self
536    }
537
538    /// Configure take-profit trigger and order prices.
539    pub fn take_profit(
540        mut self,
541        trigger_px: impl Into<String>,
542        order_px: impl Into<String>,
543    ) -> Self {
544        self.tp_trigger_px = Some(trigger_px.into());
545        self.tp_ord_px = Some(order_px.into());
546        self
547    }
548
549    /// Configure a limit take-profit order that does not require a trigger price.
550    pub fn limit_take_profit(mut self, order_px: impl Into<String>) -> Self {
551        self.tp_ord_kind = Some("limit".to_owned());
552        self.tp_ord_px = Some(order_px.into());
553        self
554    }
555
556    /// Set the take-profit trigger source.
557    pub fn take_profit_price_type(mut self, value: impl Into<String>) -> Self {
558        self.tp_trigger_px_type = Some(value.into());
559        self
560    }
561
562    /// Configure stop-loss trigger and order prices.
563    pub fn stop_loss(mut self, trigger_px: impl Into<String>, order_px: impl Into<String>) -> Self {
564        self.sl_trigger_px = Some(trigger_px.into());
565        self.sl_ord_px = Some(order_px.into());
566        self
567    }
568
569    /// Set the stop-loss trigger source.
570    pub fn stop_loss_price_type(mut self, value: impl Into<String>) -> Self {
571        self.sl_trigger_px_type = Some(value.into());
572        self
573    }
574
575    /// Associate TP/SL cancellation with a fully closed position.
576    pub fn cancel_on_close_position(mut self, value: bool) -> Self {
577        self.cxl_on_close_pos = Some(value);
578        self
579    }
580
581    /// Attach TP/SL definitions to the triggered order.
582    pub fn attached_algo_orders(mut self, values: Vec<AttachedAlgoOrderRequest>) -> Self {
583        self.attach_algo_ords = Some(values);
584        self
585    }
586
587    /// Set a trailing-order callback ratio.
588    pub fn callback_ratio(mut self, value: impl Into<String>) -> Self {
589        self.callback_ratio = Some(value.into());
590        self
591    }
592
593    /// Set a trailing-order callback spread.
594    pub fn callback_spread(mut self, value: impl Into<String>) -> Self {
595        self.callback_spread = Some(value.into());
596        self
597    }
598
599    /// Set a trailing-order activation price.
600    pub fn active_price(mut self, value: impl Into<String>) -> Self {
601        self.active_px = Some(value.into());
602        self
603    }
604
605    /// Configure chase type and value.
606    pub fn chase(mut self, chase_type: impl Into<String>, chase_val: impl Into<String>) -> Self {
607        self.chase_type = Some(chase_type.into());
608        self.chase_val = Some(chase_val.into());
609        self
610    }
611
612    /// Configure the optional maximum chase type and value.
613    pub fn maximum_chase(
614        mut self,
615        chase_type: impl Into<String>,
616        chase_val: impl Into<String>,
617    ) -> Self {
618        self.max_chase_type = Some(chase_type.into());
619        self.max_chase_val = Some(chase_val.into());
620        self
621    }
622
623    /// Configure TWAP strategy fields using a percentage variance.
624    pub fn twap_by_variance(
625        mut self,
626        px_var: impl Into<String>,
627        sz_limit: impl Into<String>,
628        px_limit: impl Into<String>,
629        time_interval: impl Into<String>,
630    ) -> Self {
631        self.px_var = Some(px_var.into());
632        self.px_spread = None;
633        self.sz_limit = Some(sz_limit.into());
634        self.px_limit = Some(px_limit.into());
635        self.time_interval = Some(time_interval.into());
636        self
637    }
638
639    /// Configure TWAP strategy fields using an absolute price spread.
640    pub fn twap_by_spread(
641        mut self,
642        px_spread: impl Into<String>,
643        sz_limit: impl Into<String>,
644        px_limit: impl Into<String>,
645        time_interval: impl Into<String>,
646    ) -> Self {
647        self.px_var = None;
648        self.px_spread = Some(px_spread.into());
649        self.sz_limit = Some(sz_limit.into());
650        self.px_limit = Some(px_limit.into());
651        self.time_interval = Some(time_interval.into());
652        self
653    }
654
655    /// Configure required Smart Iceberg execution fields.
656    pub fn smart_iceberg(
657        mut self,
658        sz_limit: impl Into<String>,
659        lmt_order_number: impl Into<String>,
660        aggressiveness: impl Into<String>,
661    ) -> Self {
662        self.sz_limit = Some(sz_limit.into());
663        self.lmt_order_number = Some(lmt_order_number.into());
664        self.aggressiveness = Some(aggressiveness.into());
665        self
666    }
667
668    /// Set an optional Smart Iceberg price limit.
669    pub fn price_limit(mut self, value: impl Into<String>) -> Self {
670        self.px_limit = Some(value.into());
671        self
672    }
673
674    /// Set Smart Iceberg trigger parameters.
675    pub fn smart_iceberg_triggers(mut self, values: Vec<SmartIcebergTriggerRequest>) -> Self {
676        self.trigger_params = Some(values);
677        self
678    }
679
680    /// Set an order tag of at most 16 ASCII alphanumeric characters.
681    pub fn tag(mut self, value: impl Into<String>) -> Self {
682        self.tag = Some(value.into());
683        self
684    }
685}
686
687impl ValidateRequest for AlgoOrderRequest {
688    fn validate(&self) -> Result<(), RequestValidationError> {
689        non_empty("instId", &self.inst_id)?;
690        one_of(
691            "tdMode",
692            &self.td_mode,
693            &["cash", "cross", "isolated", "spot_isolated"],
694            "cash, cross, isolated, or spot_isolated",
695        )?;
696        one_of("side", &self.side, &["buy", "sell"], "buy or sell")?;
697        one_of(
698            "ordType",
699            &self.ord_type,
700            PLACE_ALGO_ORDER_TYPES,
701            "conditional, oco, chase, trigger, move_order_stop, twap, or smart_iceberg",
702        )?;
703        optional_non_empty("sz", self.sz.as_deref())?;
704        if let Some(sz) = self.sz.as_deref() {
705            positive_decimal_string("sz", sz)?;
706        }
707        optional_non_empty("closeFraction", self.close_fraction.as_deref())?;
708        exactly_one(
709            "sz, closeFraction",
710            &[self.sz.is_some(), self.close_fraction.is_some()],
711        )?;
712        if let Some(value) = self.close_fraction.as_deref() {
713            one_of("closeFraction", value, &["1"], "1")?;
714            if !matches!(self.ord_type.as_str(), "conditional" | "oco") {
715                return Err(RequestValidationError::NotApplicable {
716                    field: "closeFraction",
717                    condition: "ordType is not conditional or oco",
718                });
719            }
720            if self.pos_side.as_deref() == Some("net") && self.reduce_only != Some(true) {
721                return Err(RequestValidationError::RequiredWhen {
722                    field: "reduceOnly=true",
723                    condition: "closeFraction is used with posSide=net",
724                });
725            }
726        }
727        optional_non_empty("ccy", self.ccy.as_deref())?;
728        if self.ccy.is_some() && matches!(self.td_mode.as_str(), "cash" | "spot_isolated") {
729            return Err(RequestValidationError::NotApplicable {
730                field: "ccy",
731                condition: "tdMode is cash or spot_isolated",
732            });
733        }
734        optional_one_of(
735            "posSide",
736            self.pos_side.as_deref(),
737            &["long", "short", "net"],
738            "long, short, or net",
739        )?;
740        if self.pos_side.is_some() && matches!(self.td_mode.as_str(), "cash" | "spot_isolated") {
741            return Err(RequestValidationError::NotApplicable {
742                field: "posSide",
743                condition: "tdMode is cash or spot_isolated",
744            });
745        }
746        if self.reduce_only.is_some() && matches!(self.td_mode.as_str(), "cash" | "spot_isolated") {
747            return Err(RequestValidationError::NotApplicable {
748                field: "reduceOnly",
749                condition: "tdMode is cash or spot_isolated",
750            });
751        }
752        validate_client_request_id("algoClOrdId", self.algo_cl_ord_id.as_deref())?;
753        optional_one_of(
754            "tgtCcy",
755            self.tgt_ccy.as_deref(),
756            &["base_ccy", "quote_ccy"],
757            "base_ccy or quote_ccy",
758        )?;
759        if self.tgt_ccy.is_some()
760            && (self.ord_type != "conditional"
761                || self.side != "buy"
762                || !matches!(self.td_mode.as_str(), "cash" | "spot_isolated"))
763        {
764            return Err(RequestValidationError::NotApplicable {
765                field: "tgtCcy",
766                condition: "the request is not a SPOT market-buy conditional order",
767            });
768        }
769        optional_non_empty("tradeQuoteCcy", self.trade_quote_ccy.as_deref())?;
770        if self.trade_quote_ccy.is_some()
771            && !matches!(self.td_mode.as_str(), "cash" | "spot_isolated")
772        {
773            return Err(RequestValidationError::NotApplicable {
774                field: "tradeQuoteCcy",
775                condition: "tdMode is not cash or spot_isolated",
776            });
777        }
778        optional_one_of(
779            "triggerPxType",
780            self.trigger_px_type.as_deref(),
781            PRICE_TYPES,
782            "last, index, or mark",
783        )?;
784        if self.trigger_px_type.is_some()
785            && matches!(self.td_mode.as_str(), "cash" | "spot_isolated")
786            && self.trigger_px_type.as_deref() != Some("last")
787        {
788            return Err(RequestValidationError::InvalidFormat {
789                field: "triggerPxType",
790                expected: "last for SPOT instruments",
791            });
792        }
793        optional_one_of(
794            "advanceOrdType",
795            self.advance_ord_type.as_deref(),
796            &["fok", "ioc"],
797            "fok or ioc",
798        )?;
799        optional_one_of(
800            "tpTriggerPxType",
801            self.tp_trigger_px_type.as_deref(),
802            PRICE_TYPES,
803            "last, index, or mark",
804        )?;
805        optional_one_of(
806            "tpOrdKind",
807            self.tp_ord_kind.as_deref(),
808            &["condition", "limit"],
809            "condition or limit",
810        )?;
811        optional_one_of(
812            "slTriggerPxType",
813            self.sl_trigger_px_type.as_deref(),
814            PRICE_TYPES,
815            "last, index, or mark",
816        )?;
817        validate_take_profit(
818            self.tp_trigger_px.as_deref(),
819            self.tp_ord_px.as_deref(),
820            self.tp_ord_kind.as_deref(),
821        )?;
822        validate_price_pair(
823            "slTriggerPx",
824            self.sl_trigger_px.as_deref(),
825            "slOrdPx",
826            self.sl_ord_px.as_deref(),
827        )?;
828        if self.cxl_on_close_pos == Some(true) && self.reduce_only != Some(true) {
829            return Err(RequestValidationError::RequiredWhen {
830                field: "reduceOnly=true",
831                condition: "cxlOnClosePos is true",
832            });
833        }
834        if let Some(values) = self.attach_algo_ords.as_deref() {
835            if values.is_empty() {
836                return Err(RequestValidationError::LengthOutOfRange {
837                    field: "attachAlgoOrds",
838                    min: 1,
839                    max: usize::MAX,
840                });
841            }
842            for value in values {
843                value.validate()?;
844                validate_attached_ratio_direction(&self.side, value)?;
845            }
846        }
847        if let Some(tag) = self.tag.as_deref() {
848            non_empty("tag", tag)?;
849            max_length("tag", tag, 16)?;
850            if !tag.bytes().all(|byte| byte.is_ascii_alphanumeric()) {
851                return Err(RequestValidationError::InvalidFormat {
852                    field: "tag",
853                    expected: "1-16 ASCII alphanumeric characters",
854                });
855            }
856        }
857
858        match self.ord_type.as_str() {
859            "trigger" => {
860                require_pair(
861                    "triggerPx",
862                    self.trigger_px.as_deref(),
863                    "orderPx",
864                    self.order_px.as_deref(),
865                    "ordType is trigger",
866                )?;
867            }
868            "conditional" => {
869                at_least_one(
870                    "take-profit fields, stop-loss fields",
871                    &[
872                        self.tp_trigger_px.is_some() || self.tp_ord_px.is_some(),
873                        self.sl_trigger_px.is_some(),
874                    ],
875                )?;
876            }
877            "oco" => {
878                require_take_profit(
879                    self.tp_trigger_px.as_deref(),
880                    self.tp_ord_px.as_deref(),
881                    self.tp_ord_kind.as_deref(),
882                    "ordType is oco",
883                )?;
884                require_pair(
885                    "slTriggerPx",
886                    self.sl_trigger_px.as_deref(),
887                    "slOrdPx",
888                    self.sl_ord_px.as_deref(),
889                    "ordType is oco",
890                )?;
891            }
892            "move_order_stop" => {
893                exactly_one(
894                    "callbackRatio, callbackSpread",
895                    &[
896                        self.callback_ratio.is_some(),
897                        self.callback_spread.is_some(),
898                    ],
899                )?;
900                if let Some(value) = self.callback_ratio.as_deref() {
901                    positive_decimal_string("callbackRatio", value)?;
902                }
903                if let Some(value) = self.callback_spread.as_deref() {
904                    positive_decimal_string("callbackSpread", value)?;
905                }
906                if let Some(value) = self.active_px.as_deref() {
907                    positive_decimal_string("activePx", value)?;
908                }
909            }
910            "chase" => {
911                optional_one_of(
912                    "chaseType",
913                    self.chase_type.as_deref(),
914                    &["distance", "ratio"],
915                    "distance or ratio",
916                )?;
917                if let Some(value) = self.chase_val.as_deref() {
918                    non_negative_decimal_string("chaseVal", value)?;
919                }
920                optional_one_of(
921                    "maxChaseType",
922                    self.max_chase_type.as_deref(),
923                    &["distance", "ratio"],
924                    "distance or ratio",
925                )?;
926                if let Some(value) = self.max_chase_val.as_deref() {
927                    non_negative_decimal_string("maxChaseVal", value)?;
928                }
929                if self.max_chase_type.is_some() != self.max_chase_val.is_some() {
930                    return Err(RequestValidationError::RequiredWhen {
931                        field: if self.max_chase_type.is_some() {
932                            "maxChaseVal"
933                        } else {
934                            "maxChaseType"
935                        },
936                        condition: "the other maximum-chase field is present",
937                    });
938                }
939            }
940            "twap" => {
941                exactly_one(
942                    "pxVar, pxSpread",
943                    &[self.px_var.is_some(), self.px_spread.is_some()],
944                )?;
945                if let Some(value) = self.px_var.as_deref() {
946                    decimal_string_range("pxVar", value, 0.0001, 0.01, "0.0001", "0.01")?;
947                }
948                if let Some(value) = self.px_spread.as_deref() {
949                    non_negative_decimal_string("pxSpread", value)?;
950                }
951                let sz_limit =
952                    require_value("szLimit", self.sz_limit.as_deref(), "ordType is twap")?;
953                positive_decimal_string("szLimit", sz_limit)?;
954                let px_limit =
955                    require_value("pxLimit", self.px_limit.as_deref(), "ordType is twap")?;
956                non_negative_decimal_string("pxLimit", px_limit)?;
957                let interval = require_value(
958                    "timeInterval",
959                    self.time_interval.as_deref(),
960                    "ordType is twap",
961                )?;
962                positive_unsigned_integer_string("timeInterval", interval)?;
963            }
964            "smart_iceberg" => {
965                let sz_limit = require_value(
966                    "szLimit",
967                    self.sz_limit.as_deref(),
968                    "ordType is smart_iceberg",
969                )?;
970                positive_decimal_string("szLimit", sz_limit)?;
971                let split_count = require_value(
972                    "lmtOrderNumber",
973                    self.lmt_order_number.as_deref(),
974                    "ordType is smart_iceberg",
975                )?;
976                positive_unsigned_integer_string("lmtOrderNumber", split_count)?;
977                let aggressiveness = require_value(
978                    "aggressiveness",
979                    self.aggressiveness.as_deref(),
980                    "ordType is smart_iceberg",
981                )?;
982                one_of(
983                    "aggressiveness",
984                    aggressiveness,
985                    &["radical", "mid", "conservative"],
986                    "radical, mid, or conservative",
987                )?;
988                if let Some(value) = self.px_limit.as_deref() {
989                    non_negative_decimal_string("pxLimit", value)?;
990                }
991                if let Some(values) = self.trigger_params.as_deref() {
992                    for value in values {
993                        value.validate()?;
994                    }
995                }
996            }
997            _ => {}
998        }
999
1000        validate_full_close_execution(self)?;
1001        validate_target_currency_execution(self)?;
1002        validate_strategy_applicability(self)
1003    }
1004}
1005
1006fn signed_decimal_string(field: &'static str, value: &str) -> Result<f64, RequestValidationError> {
1007    non_empty(field, value)?;
1008    if value.starts_with('+')
1009        || value.contains(['e', 'E'])
1010        || value == "-"
1011        || value.starts_with('.')
1012        || value.ends_with('.')
1013        || value.matches('-').count() > 1
1014        || (value.contains('-') && !value.starts_with('-'))
1015    {
1016        return Err(RequestValidationError::InvalidFormat {
1017            field,
1018            expected: "a finite decimal string without scientific notation",
1019        });
1020    }
1021    let unsigned = value.strip_prefix('-').unwrap_or(value);
1022    if unsigned.is_empty()
1023        || !unsigned
1024            .bytes()
1025            .all(|byte| byte.is_ascii_digit() || byte == b'.')
1026        || unsigned.matches('.').count() > 1
1027    {
1028        return Err(RequestValidationError::InvalidFormat {
1029            field,
1030            expected: "a finite decimal string without scientific notation",
1031        });
1032    }
1033    let parsed = value
1034        .parse::<f64>()
1035        .map_err(|_| RequestValidationError::InvalidFormat {
1036            field,
1037            expected: "a finite decimal string without scientific notation",
1038        })?;
1039    if !parsed.is_finite() {
1040        return Err(RequestValidationError::InvalidFormat {
1041            field,
1042            expected: "a finite decimal string without scientific notation",
1043        });
1044    }
1045    Ok(parsed)
1046}
1047
1048fn validate_attached_ratio_direction(
1049    side: &str,
1050    request: &AttachedAlgoOrderRequest,
1051) -> Result<(), RequestValidationError> {
1052    if let Some(value) = request.tp_trigger_ratio.as_deref() {
1053        let parsed = signed_decimal_string("tpTriggerRatio", value)?;
1054        let valid = if side == "buy" {
1055            parsed > 0.0
1056        } else {
1057            parsed > -1.0 && parsed < 0.0
1058        };
1059        if !valid {
1060            return Err(RequestValidationError::InvalidFormat {
1061                field: "tpTriggerRatio",
1062                expected: "greater than 0 for buy orders, or between -1 and 0 for sell orders",
1063            });
1064        }
1065    }
1066    if let Some(value) = request.sl_trigger_ratio.as_deref() {
1067        let parsed = signed_decimal_string("slTriggerRatio", value)?;
1068        let valid = if side == "buy" {
1069            parsed > 0.0 && parsed < 1.0
1070        } else {
1071            parsed > 0.0
1072        };
1073        if !valid {
1074            return Err(RequestValidationError::InvalidFormat {
1075                field: "slTriggerRatio",
1076                expected: "between 0 and 1 for buy orders, or greater than 0 for sell orders",
1077            });
1078        }
1079    }
1080    Ok(())
1081}
1082
1083fn validate_full_close_execution(request: &AlgoOrderRequest) -> Result<(), RequestValidationError> {
1084    if request.close_fraction.is_none() {
1085        return Ok(());
1086    }
1087    for (field, value) in [
1088        ("tpOrdPx", request.tp_ord_px.as_deref()),
1089        ("slOrdPx", request.sl_ord_px.as_deref()),
1090    ] {
1091        if value.is_some_and(|value| value != "-1") {
1092            return Err(RequestValidationError::InvalidFormat {
1093                field,
1094                expected: "-1 when closeFraction is used",
1095            });
1096        }
1097    }
1098    Ok(())
1099}
1100
1101fn validate_target_currency_execution(
1102    request: &AlgoOrderRequest,
1103) -> Result<(), RequestValidationError> {
1104    if request.tgt_ccy.is_none() {
1105        return Ok(());
1106    }
1107    if request.tp_ord_px.as_deref() != Some("-1") && request.sl_ord_px.as_deref() != Some("-1") {
1108        return Err(RequestValidationError::RequiredWhen {
1109            field: "tpOrdPx or slOrdPx set to -1",
1110            condition: "tgtCcy is present",
1111        });
1112    }
1113    Ok(())
1114}
1115
1116fn require_value<'a>(
1117    field: &'static str,
1118    value: Option<&'a str>,
1119    condition: &'static str,
1120) -> Result<&'a str, RequestValidationError> {
1121    value.ok_or(RequestValidationError::RequiredWhen { field, condition })
1122}
1123
1124fn validate_take_profit(
1125    trigger_px: Option<&str>,
1126    order_px: Option<&str>,
1127    order_kind: Option<&str>,
1128) -> Result<(), RequestValidationError> {
1129    optional_non_empty("tpTriggerPx", trigger_px)?;
1130    optional_non_empty("tpOrdPx", order_px)?;
1131    if order_kind == Some("limit") {
1132        if order_px.is_none() {
1133            return Err(RequestValidationError::RequiredWhen {
1134                field: "tpOrdPx",
1135                condition: "tpOrdKind is limit",
1136            });
1137        }
1138        return Ok(());
1139    }
1140    if trigger_px.is_some() != order_px.is_some() {
1141        return Err(RequestValidationError::RequiredWhen {
1142            field: if trigger_px.is_some() {
1143                "tpOrdPx"
1144            } else {
1145                "tpTriggerPx"
1146            },
1147            condition: "the paired take-profit price field is present",
1148        });
1149    }
1150    Ok(())
1151}
1152
1153fn require_take_profit(
1154    trigger_px: Option<&str>,
1155    order_px: Option<&str>,
1156    order_kind: Option<&str>,
1157    condition: &'static str,
1158) -> Result<(), RequestValidationError> {
1159    if order_kind == Some("limit") {
1160        require_when("tpOrdPx", order_px, condition)?;
1161    } else {
1162        require_pair("tpTriggerPx", trigger_px, "tpOrdPx", order_px, condition)?;
1163    }
1164    validate_take_profit(trigger_px, order_px, order_kind)
1165}
1166
1167fn validate_price_pair(
1168    left_field: &'static str,
1169    left: Option<&str>,
1170    right_field: &'static str,
1171    right: Option<&str>,
1172) -> Result<(), RequestValidationError> {
1173    optional_non_empty(left_field, left)?;
1174    optional_non_empty(right_field, right)?;
1175    if left.is_some() != right.is_some() {
1176        return Err(RequestValidationError::RequiredWhen {
1177            field: if left.is_some() {
1178                right_field
1179            } else {
1180                left_field
1181            },
1182            condition: "the paired algo price field is present",
1183        });
1184    }
1185    Ok(())
1186}
1187
1188fn require_pair(
1189    left_field: &'static str,
1190    left: Option<&str>,
1191    right_field: &'static str,
1192    right: Option<&str>,
1193    condition: &'static str,
1194) -> Result<(), RequestValidationError> {
1195    require_when(left_field, left, condition)?;
1196    require_when(right_field, right, condition)?;
1197    validate_price_pair(left_field, left, right_field, right)
1198}
1199
1200fn validate_strategy_applicability(
1201    request: &AlgoOrderRequest,
1202) -> Result<(), RequestValidationError> {
1203    let ord_type = request.ord_type.as_str();
1204    if ord_type != "trigger" {
1205        reject_when_present(
1206            "triggerPx",
1207            request.trigger_px.as_ref(),
1208            "ordType is not trigger",
1209        )?;
1210        reject_when_present(
1211            "orderPx",
1212            request.order_px.as_ref(),
1213            "ordType is not trigger",
1214        )?;
1215        reject_when_present(
1216            "advanceOrdType",
1217            request.advance_ord_type.as_ref(),
1218            "ordType is not trigger",
1219        )?;
1220        reject_when_present(
1221            "attachAlgoOrds",
1222            request.attach_algo_ords.as_ref(),
1223            "ordType is not trigger",
1224        )?;
1225    }
1226    if ord_type != "move_order_stop" {
1227        reject_when_present(
1228            "callbackRatio",
1229            request.callback_ratio.as_ref(),
1230            "ordType is not move_order_stop",
1231        )?;
1232        reject_when_present(
1233            "callbackSpread",
1234            request.callback_spread.as_ref(),
1235            "ordType is not move_order_stop",
1236        )?;
1237        reject_when_present(
1238            "activePx",
1239            request.active_px.as_ref(),
1240            "ordType is not move_order_stop",
1241        )?;
1242    }
1243    if ord_type != "chase" {
1244        reject_when_present(
1245            "chaseType",
1246            request.chase_type.as_ref(),
1247            "ordType is not chase",
1248        )?;
1249        reject_when_present(
1250            "chaseVal",
1251            request.chase_val.as_ref(),
1252            "ordType is not chase",
1253        )?;
1254        reject_when_present(
1255            "maxChaseType",
1256            request.max_chase_type.as_ref(),
1257            "ordType is not chase",
1258        )?;
1259        reject_when_present(
1260            "maxChaseVal",
1261            request.max_chase_val.as_ref(),
1262            "ordType is not chase",
1263        )?;
1264    }
1265    if ord_type != "twap" {
1266        reject_when_present("pxVar", request.px_var.as_ref(), "ordType is not twap")?;
1267        reject_when_present(
1268            "pxSpread",
1269            request.px_spread.as_ref(),
1270            "ordType is not twap",
1271        )?;
1272        reject_when_present(
1273            "timeInterval",
1274            request.time_interval.as_ref(),
1275            "ordType is not twap",
1276        )?;
1277    }
1278    if !matches!(ord_type, "twap" | "smart_iceberg") {
1279        reject_when_present(
1280            "szLimit",
1281            request.sz_limit.as_ref(),
1282            "ordType is not twap or smart_iceberg",
1283        )?;
1284        reject_when_present(
1285            "pxLimit",
1286            request.px_limit.as_ref(),
1287            "ordType is not twap or smart_iceberg",
1288        )?;
1289    }
1290    if ord_type != "smart_iceberg" {
1291        reject_when_present(
1292            "lmtOrderNumber",
1293            request.lmt_order_number.as_ref(),
1294            "ordType is not smart_iceberg",
1295        )?;
1296        reject_when_present(
1297            "aggressiveness",
1298            request.aggressiveness.as_ref(),
1299            "ordType is not smart_iceberg",
1300        )?;
1301        reject_when_present(
1302            "triggerParams",
1303            request.trigger_params.as_ref(),
1304            "ordType is not smart_iceberg",
1305        )?;
1306    }
1307    if !matches!(ord_type, "conditional" | "oco") {
1308        reject_when_present(
1309            "tgtCcy",
1310            request.tgt_ccy.as_ref(),
1311            "ordType is not conditional or oco",
1312        )?;
1313        reject_when_present(
1314            "cxlOnClosePos",
1315            request.cxl_on_close_pos.as_ref(),
1316            "ordType is not conditional or oco",
1317        )?;
1318    }
1319    Ok(())
1320}
1321
1322/// Request body for one entry in `POST /api/v5/trade/cancel-algos`.
1323#[derive(Debug, Clone, Serialize)]
1324pub struct CancelAlgoOrderRequest {
1325    #[serde(rename = "algoId", skip_serializing_if = "Option::is_none")]
1326    algo_id: Option<String>,
1327    #[serde(rename = "algoClOrdId", skip_serializing_if = "Option::is_none")]
1328    algo_cl_ord_id: Option<String>,
1329    #[serde(rename = "instId")]
1330    inst_id: String,
1331}
1332
1333impl CancelAlgoOrderRequest {
1334    /// Create a cancellation using an OKX algo ID.
1335    pub fn new(algo_id: impl Into<String>, inst_id: impl Into<String>) -> Self {
1336        Self {
1337            algo_id: Some(algo_id.into()),
1338            algo_cl_ord_id: None,
1339            inst_id: inst_id.into(),
1340        }
1341    }
1342
1343    /// Create a cancellation using a client-supplied algo ID.
1344    pub fn by_client_algo_order_id(
1345        algo_cl_ord_id: impl Into<String>,
1346        inst_id: impl Into<String>,
1347    ) -> Self {
1348        Self {
1349            algo_id: None,
1350            algo_cl_ord_id: Some(algo_cl_ord_id.into()),
1351            inst_id: inst_id.into(),
1352        }
1353    }
1354}
1355
1356impl ValidateRequest for CancelAlgoOrderRequest {
1357    fn validate(&self) -> Result<(), RequestValidationError> {
1358        non_empty("instId", &self.inst_id)?;
1359        optional_non_empty("algoId", self.algo_id.as_deref())?;
1360        validate_client_request_id("algoClOrdId", self.algo_cl_ord_id.as_deref())?;
1361        at_least_one(
1362            "algoId, algoClOrdId",
1363            &[self.algo_id.is_some(), self.algo_cl_ord_id.is_some()],
1364        )
1365    }
1366}
1367
1368/// Request body for `POST /api/v5/trade/amend-algos`.
1369#[derive(Debug, Clone, Serialize)]
1370pub struct AmendAlgoOrderRequest {
1371    #[serde(rename = "instId")]
1372    inst_id: String,
1373    #[serde(rename = "algoId", skip_serializing_if = "Option::is_none")]
1374    algo_id: Option<String>,
1375    #[serde(rename = "algoClOrdId", skip_serializing_if = "Option::is_none")]
1376    algo_cl_ord_id: Option<String>,
1377    #[serde(rename = "reqId", skip_serializing_if = "Option::is_none")]
1378    req_id: Option<String>,
1379    #[serde(rename = "newSz", skip_serializing_if = "Option::is_none")]
1380    new_sz: Option<String>,
1381    #[serde(rename = "newTpTriggerPx", skip_serializing_if = "Option::is_none")]
1382    new_tp_trigger_px: Option<String>,
1383    #[serde(rename = "newTpOrdPx", skip_serializing_if = "Option::is_none")]
1384    new_tp_ord_px: Option<String>,
1385    #[serde(rename = "newTpTriggerPxType", skip_serializing_if = "Option::is_none")]
1386    new_tp_trigger_px_type: Option<String>,
1387    #[serde(rename = "newSlTriggerPx", skip_serializing_if = "Option::is_none")]
1388    new_sl_trigger_px: Option<String>,
1389    #[serde(rename = "newSlOrdPx", skip_serializing_if = "Option::is_none")]
1390    new_sl_ord_px: Option<String>,
1391    #[serde(rename = "newSlTriggerPxType", skip_serializing_if = "Option::is_none")]
1392    new_sl_trigger_px_type: Option<String>,
1393    #[serde(rename = "cxlOnFail", skip_serializing_if = "Option::is_none")]
1394    cancel_on_fail: Option<bool>,
1395}
1396
1397impl AmendAlgoOrderRequest {
1398    /// Create an amendment for an instrument; add an algo identifier next.
1399    pub fn new(inst_id: impl Into<String>) -> Self {
1400        Self {
1401            inst_id: inst_id.into(),
1402            algo_id: None,
1403            algo_cl_ord_id: None,
1404            req_id: None,
1405            new_sz: None,
1406            new_tp_trigger_px: None,
1407            new_tp_ord_px: None,
1408            new_tp_trigger_px_type: None,
1409            new_sl_trigger_px: None,
1410            new_sl_ord_px: None,
1411            new_sl_trigger_px_type: None,
1412            cancel_on_fail: None,
1413        }
1414    }
1415
1416    /// Identify the order by OKX algo ID.
1417    pub fn algo_id(mut self, value: impl Into<String>) -> Self {
1418        self.algo_id = Some(value.into());
1419        self
1420    }
1421
1422    /// Identify the order by client-supplied algo ID.
1423    pub fn client_algo_order_id(mut self, value: impl Into<String>) -> Self {
1424        self.algo_cl_ord_id = Some(value.into());
1425        self
1426    }
1427
1428    /// Set a client request ID of up to 32 ASCII alphanumeric characters.
1429    pub fn request_id(mut self, value: impl Into<String>) -> Self {
1430        self.req_id = Some(value.into());
1431        self
1432    }
1433
1434    /// Amend the order size.
1435    pub fn new_size(mut self, value: impl Into<String>) -> Self {
1436        self.new_sz = Some(value.into());
1437        self
1438    }
1439
1440    /// Amend take-profit prices.
1441    pub fn take_profit(
1442        mut self,
1443        trigger_px: impl Into<String>,
1444        order_px: impl Into<String>,
1445    ) -> Self {
1446        self.new_tp_trigger_px = Some(trigger_px.into());
1447        self.new_tp_ord_px = Some(order_px.into());
1448        self
1449    }
1450
1451    /// Set the amended take-profit trigger price source.
1452    pub fn take_profit_price_type(mut self, value: impl Into<String>) -> Self {
1453        self.new_tp_trigger_px_type = Some(value.into());
1454        self
1455    }
1456
1457    /// Delete the take-profit definition by sending the documented `0` sentinel.
1458    pub fn delete_take_profit(mut self) -> Self {
1459        self.new_tp_trigger_px = Some("0".to_owned());
1460        self.new_tp_ord_px = None;
1461        self.new_tp_trigger_px_type = None;
1462        self
1463    }
1464
1465    /// Amend stop-loss prices.
1466    pub fn stop_loss(mut self, trigger_px: impl Into<String>, order_px: impl Into<String>) -> Self {
1467        self.new_sl_trigger_px = Some(trigger_px.into());
1468        self.new_sl_ord_px = Some(order_px.into());
1469        self
1470    }
1471
1472    /// Set the amended stop-loss trigger price source.
1473    pub fn stop_loss_price_type(mut self, value: impl Into<String>) -> Self {
1474        self.new_sl_trigger_px_type = Some(value.into());
1475        self
1476    }
1477
1478    /// Delete the stop-loss definition by sending the documented `0` sentinel.
1479    pub fn delete_stop_loss(mut self) -> Self {
1480        self.new_sl_trigger_px = Some("0".to_owned());
1481        self.new_sl_ord_px = None;
1482        self.new_sl_trigger_px_type = None;
1483        self
1484    }
1485
1486    /// Cancel the original order automatically when amendment fails.
1487    pub fn cancel_on_fail(mut self, value: bool) -> Self {
1488        self.cancel_on_fail = Some(value);
1489        self
1490    }
1491}
1492
1493impl ValidateRequest for AmendAlgoOrderRequest {
1494    fn validate(&self) -> Result<(), RequestValidationError> {
1495        non_empty("instId", &self.inst_id)?;
1496        optional_non_empty("algoId", self.algo_id.as_deref())?;
1497        validate_client_request_id("algoClOrdId", self.algo_cl_ord_id.as_deref())?;
1498        validate_client_request_id("reqId", self.req_id.as_deref())?;
1499        at_least_one(
1500            "algoId, algoClOrdId",
1501            &[self.algo_id.is_some(), self.algo_cl_ord_id.is_some()],
1502        )?;
1503        at_least_one(
1504            "newSz, take-profit amendment fields, stop-loss amendment fields",
1505            &[
1506                self.new_sz.is_some(),
1507                self.new_tp_trigger_px.is_some()
1508                    || self.new_tp_ord_px.is_some()
1509                    || self.new_tp_trigger_px_type.is_some(),
1510                self.new_sl_trigger_px.is_some()
1511                    || self.new_sl_ord_px.is_some()
1512                    || self.new_sl_trigger_px_type.is_some(),
1513            ],
1514        )?;
1515        if let Some(new_sz) = self.new_sz.as_deref() {
1516            positive_decimal_string("newSz", new_sz)?;
1517        }
1518        validate_amended_tp_sl(
1519            "newTpTriggerPx",
1520            self.new_tp_trigger_px.as_deref(),
1521            "newTpOrdPx",
1522            self.new_tp_ord_px.as_deref(),
1523            "newTpTriggerPxType",
1524            self.new_tp_trigger_px_type.as_deref(),
1525        )?;
1526        validate_amended_tp_sl(
1527            "newSlTriggerPx",
1528            self.new_sl_trigger_px.as_deref(),
1529            "newSlOrdPx",
1530            self.new_sl_ord_px.as_deref(),
1531            "newSlTriggerPxType",
1532            self.new_sl_trigger_px_type.as_deref(),
1533        )
1534    }
1535}
1536
1537fn validate_amended_tp_sl(
1538    trigger_field: &'static str,
1539    trigger_px: Option<&str>,
1540    order_field: &'static str,
1541    order_px: Option<&str>,
1542    type_field: &'static str,
1543    trigger_type: Option<&str>,
1544) -> Result<(), RequestValidationError> {
1545    optional_non_empty(trigger_field, trigger_px)?;
1546    optional_non_empty(order_field, order_px)?;
1547    optional_one_of(
1548        type_field,
1549        trigger_type,
1550        PRICE_TYPES,
1551        "last, index, or mark",
1552    )?;
1553
1554    let deleting = trigger_px == Some("0") || order_px == Some("0");
1555    if deleting {
1556        return Ok(());
1557    }
1558
1559    if trigger_px.is_some() != order_px.is_some() {
1560        return Err(RequestValidationError::RequiredWhen {
1561            field: if trigger_px.is_some() {
1562                order_field
1563            } else {
1564                trigger_field
1565            },
1566            condition: "the paired amended TP/SL price field is present",
1567        });
1568    }
1569    if trigger_px.is_some() && trigger_type.is_none() {
1570        return Err(RequestValidationError::RequiredWhen {
1571            field: type_field,
1572            condition: "a TP/SL trigger is added or amended",
1573        });
1574    }
1575    Ok(())
1576}
1577
1578/// Query parameters shared by pending and historical algo orders.
1579#[derive(Debug, Clone, Serialize)]
1580pub struct AlgoOrderListRequest {
1581    #[serde(rename = "ordType")]
1582    ord_type: String,
1583    #[serde(rename = "algoId", skip_serializing_if = "Option::is_none")]
1584    algo_id: Option<String>,
1585    #[serde(rename = "instType", skip_serializing_if = "Option::is_none")]
1586    inst_type: Option<String>,
1587    #[serde(rename = "instId", skip_serializing_if = "Option::is_none")]
1588    inst_id: Option<String>,
1589    #[serde(skip_serializing_if = "Option::is_none")]
1590    after: Option<String>,
1591    #[serde(skip_serializing_if = "Option::is_none")]
1592    before: Option<String>,
1593    #[serde(skip_serializing_if = "Option::is_none")]
1594    limit: Option<u32>,
1595}
1596
1597impl AlgoOrderListRequest {
1598    /// Create a query for one documented algo order type.
1599    pub fn new(ord_type: impl Into<String>) -> Self {
1600        Self {
1601            ord_type: ord_type.into(),
1602            algo_id: None,
1603            inst_type: None,
1604            inst_id: None,
1605            after: None,
1606            before: None,
1607            limit: None,
1608        }
1609    }
1610
1611    /// Filter by algo ID.
1612    pub fn algo_id(mut self, value: impl Into<String>) -> Self {
1613        self.algo_id = Some(value.into());
1614        self
1615    }
1616
1617    /// Filter by instrument type.
1618    pub fn inst_type(mut self, value: impl Into<String>) -> Self {
1619        self.inst_type = Some(value.into());
1620        self
1621    }
1622
1623    /// Filter by instrument ID.
1624    pub fn inst_id(mut self, value: impl Into<String>) -> Self {
1625        self.inst_id = Some(value.into());
1626        self
1627    }
1628
1629    /// Return records before this algo-ID cursor.
1630    pub fn after(mut self, value: impl Into<String>) -> Self {
1631        self.after = Some(value.into());
1632        self
1633    }
1634
1635    /// Return records after this algo-ID cursor.
1636    pub fn before(mut self, value: impl Into<String>) -> Self {
1637        self.before = Some(value.into());
1638        self
1639    }
1640
1641    /// Set the number of rows, from 1 through 100.
1642    pub fn limit(mut self, value: u32) -> Self {
1643        self.limit = Some(value);
1644        self
1645    }
1646}
1647
1648fn validate_query_algo_order_type(value: &str) -> Result<(), RequestValidationError> {
1649    if matches!(value, "conditional,oco" | "oco,conditional") {
1650        return Ok(());
1651    }
1652
1653    one_of(
1654        "ordType",
1655        value,
1656        QUERY_ALGO_ORDER_TYPES,
1657        "conditional, oco, chase, trigger, move_order_stop, iceberg, twap, smart_iceberg, or conditional,oco",
1658    )
1659}
1660
1661impl ValidateRequest for AlgoOrderListRequest {
1662    fn validate(&self) -> Result<(), RequestValidationError> {
1663        validate_query_algo_order_type(&self.ord_type)?;
1664        optional_non_empty("algoId", self.algo_id.as_deref())?;
1665        optional_one_of(
1666            "instType",
1667            self.inst_type.as_deref(),
1668            &["SPOT", "MARGIN", "SWAP", "FUTURES"],
1669            "SPOT, MARGIN, SWAP, or FUTURES",
1670        )?;
1671        optional_non_empty("instId", self.inst_id.as_deref())?;
1672        optional_unsigned_integer_string("after", self.after.as_deref())?;
1673        optional_unsigned_integer_string("before", self.before.as_deref())?;
1674        if let Some(limit) = self.limit {
1675            range_u64("limit", u64::from(limit), 1, 100)?;
1676        }
1677        Ok(())
1678    }
1679}
1680
1681/// Query parameters for historical algo orders.
1682#[derive(Debug, Clone, Serialize)]
1683pub struct AlgoOrderHistoryRequest {
1684    #[serde(flatten)]
1685    common: AlgoOrderListRequest,
1686    #[serde(skip_serializing_if = "Option::is_none")]
1687    state: Option<String>,
1688}
1689
1690impl AlgoOrderHistoryRequest {
1691    /// Create a history query for one documented algo order type.
1692    pub fn new(ord_type: impl Into<String>) -> Self {
1693        Self {
1694            common: AlgoOrderListRequest::new(ord_type),
1695            state: None,
1696        }
1697    }
1698
1699    /// Filter by historical state.
1700    pub fn state(mut self, value: impl Into<String>) -> Self {
1701        self.state = Some(value.into());
1702        self
1703    }
1704
1705    /// Filter by OKX algo order ID.
1706    pub fn algo_id(mut self, value: impl Into<String>) -> Self {
1707        self.common = self.common.algo_id(value);
1708        self
1709    }
1710
1711    /// Filter by instrument type.
1712    pub fn inst_type(mut self, value: impl Into<String>) -> Self {
1713        self.common = self.common.inst_type(value);
1714        self
1715    }
1716
1717    /// Filter by instrument ID.
1718    pub fn inst_id(mut self, value: impl Into<String>) -> Self {
1719        self.common = self.common.inst_id(value);
1720        self
1721    }
1722
1723    /// Return records before this cursor.
1724    pub fn after(mut self, value: impl Into<String>) -> Self {
1725        self.common = self.common.after(value);
1726        self
1727    }
1728
1729    /// Return records after this cursor.
1730    pub fn before(mut self, value: impl Into<String>) -> Self {
1731        self.common = self.common.before(value);
1732        self
1733    }
1734
1735    /// Set the number of rows, from 1 through 100.
1736    pub fn limit(mut self, value: u32) -> Self {
1737        self.common = self.common.limit(value);
1738        self
1739    }
1740}
1741
1742impl ValidateRequest for AlgoOrderHistoryRequest {
1743    fn validate(&self) -> Result<(), RequestValidationError> {
1744        self.common.validate()?;
1745        optional_one_of(
1746            "state",
1747            self.state.as_deref(),
1748            &["effective", "canceled", "order_failed"],
1749            "effective, canceled, or order_failed",
1750        )?;
1751        at_least_one(
1752            "state, algoId",
1753            &[self.state.is_some(), self.common.algo_id.is_some()],
1754        )
1755    }
1756}
1757
1758/// Query parameters for `GET /api/v5/trade/order-algo`.
1759#[derive(Debug, Clone, Default, Serialize)]
1760pub struct AlgoOrderDetailsRequest {
1761    #[serde(rename = "algoId", skip_serializing_if = "Option::is_none")]
1762    algo_id: Option<String>,
1763    #[serde(rename = "algoClOrdId", skip_serializing_if = "Option::is_none")]
1764    algo_cl_ord_id: Option<String>,
1765}
1766
1767impl AlgoOrderDetailsRequest {
1768    /// Query by OKX algo ID.
1769    pub fn by_algo_id(value: impl Into<String>) -> Self {
1770        Self {
1771            algo_id: Some(value.into()),
1772            algo_cl_ord_id: None,
1773        }
1774    }
1775
1776    /// Query by client-supplied algo ID.
1777    pub fn by_client_algo_order_id(value: impl Into<String>) -> Self {
1778        Self {
1779            algo_id: None,
1780            algo_cl_ord_id: Some(value.into()),
1781        }
1782    }
1783}
1784
1785impl ValidateRequest for AlgoOrderDetailsRequest {
1786    fn validate(&self) -> Result<(), RequestValidationError> {
1787        optional_non_empty("algoId", self.algo_id.as_deref())?;
1788        validate_client_request_id("algoClOrdId", self.algo_cl_ord_id.as_deref())?;
1789        at_least_one(
1790            "algoId, algoClOrdId",
1791            &[self.algo_id.is_some(), self.algo_cl_ord_id.is_some()],
1792        )
1793    }
1794}
1795
1796#[cfg(test)]
1797mod tests {
1798    use super::*;
1799
1800    #[test]
1801    fn trigger_order_requires_trigger_prices() {
1802        let request = AlgoOrderRequest::new("BTC-USDT", "cash", "buy", "trigger", "1");
1803        assert!(request.validate().is_err());
1804    }
1805
1806    #[test]
1807    fn pending_query_validates_limit() {
1808        let request = AlgoOrderListRequest::new("conditional").limit(101);
1809        assert!(request.validate().is_err());
1810    }
1811
1812    #[test]
1813    fn pending_query_accepts_combined_conditional_and_oco_type() {
1814        let request = AlgoOrderListRequest::new("conditional,oco");
1815        assert!(request.validate().is_ok());
1816    }
1817
1818    #[test]
1819    fn history_query_requires_state_or_algo_id() {
1820        assert!(
1821            AlgoOrderHistoryRequest::new("conditional")
1822                .validate()
1823                .is_err()
1824        );
1825        assert!(
1826            AlgoOrderHistoryRequest::new("conditional")
1827                .state("effective")
1828                .validate()
1829                .is_ok()
1830        );
1831        assert!(
1832            AlgoOrderHistoryRequest::new("conditional")
1833                .algo_id("123")
1834                .validate()
1835                .is_ok()
1836        );
1837    }
1838
1839    #[test]
1840    fn twap_official_example_validates_and_serializes() {
1841        let request = AlgoOrderRequest::new("BTC-USDT-SWAP", "cross", "buy", "twap", "10")
1842            .position_side("net")
1843            .twap_by_spread("10", "10", "100", "10");
1844
1845        request.validate().unwrap();
1846        assert_eq!(
1847            serde_json::to_value(&request).unwrap(),
1848            serde_json::json!({
1849                "instId": "BTC-USDT-SWAP",
1850                "tdMode": "cross",
1851                "side": "buy",
1852                "ordType": "twap",
1853                "sz": "10",
1854                "posSide": "net",
1855                "pxSpread": "10",
1856                "szLimit": "10",
1857                "pxLimit": "100",
1858                "timeInterval": "10"
1859            })
1860        );
1861    }
1862
1863    #[test]
1864    fn smart_iceberg_official_example_validates_and_serializes() {
1865        let trigger = SmartIcebergTriggerRequest::new("price")
1866            .price("90000")
1867            .condition("cross_down");
1868        let request = AlgoOrderRequest::new("BTC-USDT", "cash", "buy", "smart_iceberg", "100")
1869            .smart_iceberg("10", "5", "conservative")
1870            .price_limit("95000")
1871            .smart_iceberg_triggers(vec![trigger]);
1872
1873        request.validate().unwrap();
1874        assert_eq!(
1875            serde_json::to_value(&request).unwrap(),
1876            serde_json::json!({
1877                "instId": "BTC-USDT",
1878                "tdMode": "cash",
1879                "side": "buy",
1880                "ordType": "smart_iceberg",
1881                "sz": "100",
1882                "szLimit": "10",
1883                "pxLimit": "95000",
1884                "lmtOrderNumber": "5",
1885                "aggressiveness": "conservative",
1886                "triggerParams": [{
1887                    "triggerAction": "start",
1888                    "triggerStrategy": "price",
1889                    "triggerPx": "90000",
1890                    "triggerCond": "cross_down"
1891                }]
1892            })
1893        );
1894    }
1895
1896    #[test]
1897    fn full_close_requires_market_execution_prices() {
1898        let invalid = AlgoOrderRequest::new("BTC-USDT-SWAP", "cross", "sell", "conditional", "1")
1899            .full_close()
1900            .position_side("net")
1901            .reduce_only(true)
1902            .stop_loss("50000", "49900");
1903        assert!(invalid.validate().is_err());
1904
1905        let valid = AlgoOrderRequest::new("BTC-USDT-SWAP", "cross", "sell", "conditional", "1")
1906            .full_close()
1907            .position_side("net")
1908            .reduce_only(true)
1909            .stop_loss("50000", "-1");
1910        assert!(valid.validate().is_ok());
1911    }
1912
1913    #[test]
1914    fn attached_ratios_follow_order_side_ranges() {
1915        let sell_take_profit = AttachedAlgoOrderRequest::new().take_profit_ratio("-0.3", "-1");
1916        let valid = AlgoOrderRequest::new("BTC-USDT-SWAP", "cross", "sell", "trigger", "1")
1917            .trigger("50000", "-1")
1918            .attached_algo_orders(vec![sell_take_profit]);
1919        assert!(valid.validate().is_ok());
1920
1921        let invalid_ratio = AttachedAlgoOrderRequest::new().take_profit_ratio("0.3", "-1");
1922        let invalid = AlgoOrderRequest::new("BTC-USDT-SWAP", "cross", "sell", "trigger", "1")
1923            .trigger("50000", "-1")
1924            .attached_algo_orders(vec![invalid_ratio]);
1925        assert!(invalid.validate().is_err());
1926    }
1927
1928    #[test]
1929    fn amend_allows_both_identifiers_and_documented_delete_sentinel() {
1930        let request = AmendAlgoOrderRequest::new("BTC-USDT")
1931            .algo_id("1")
1932            .client_algo_order_id("client1")
1933            .delete_take_profit();
1934        request.validate().unwrap();
1935        assert_eq!(
1936            serde_json::to_value(&request).unwrap(),
1937            serde_json::json!({
1938                "instId": "BTC-USDT",
1939                "algoId": "1",
1940                "algoClOrdId": "client1",
1941                "newTpTriggerPx": "0"
1942            })
1943        );
1944    }
1945
1946    #[test]
1947    fn smart_iceberg_instant_trigger_rejects_strategy_fields() {
1948        let trigger = SmartIcebergTriggerRequest::new("instant").price("90000");
1949        assert!(trigger.validate().is_err());
1950    }
1951
1952    #[test]
1953    fn algo_details_requires_identifier() {
1954        assert!(AlgoOrderDetailsRequest::default().validate().is_err());
1955    }
1956}