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#[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 pub fn new() -> Self {
58 Self::default()
59 }
60
61 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 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 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 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 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 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 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#[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 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 pub fn price(mut self, trigger_px: impl Into<String>) -> Self {
214 self.trigger_px = Some(trigger_px.into());
215 self
216 }
217
218 pub fn condition(mut self, value: impl Into<String>) -> Self {
220 self.trigger_cond = Some(value.into());
221 self
222 }
223
224 pub fn timeframe(mut self, value: impl Into<String>) -> Self {
226 self.timeframe = Some(value.into());
227 self
228 }
229
230 pub fn threshold(mut self, value: impl Into<String>) -> Self {
232 self.thold = Some(value.into());
233 self
234 }
235
236 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#[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 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 pub fn full_close(mut self) -> Self {
478 self.sz = None;
479 self.close_fraction = Some("1".to_owned());
480 self
481 }
482
483 pub fn currency(mut self, value: impl Into<String>) -> Self {
485 self.ccy = Some(value.into());
486 self
487 }
488
489 pub fn position_side(mut self, value: impl Into<String>) -> Self {
491 self.pos_side = Some(value.into());
492 self
493 }
494
495 pub fn reduce_only(mut self, value: bool) -> Self {
497 self.reduce_only = Some(value);
498 self
499 }
500
501 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 pub fn target_currency(mut self, value: impl Into<String>) -> Self {
509 self.tgt_ccy = Some(value.into());
510 self
511 }
512
513 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 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 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 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 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 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 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 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 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 pub fn cancel_on_close_position(mut self, value: bool) -> Self {
577 self.cxl_on_close_pos = Some(value);
578 self
579 }
580
581 pub fn attached_algo_orders(mut self, values: Vec<AttachedAlgoOrderRequest>) -> Self {
583 self.attach_algo_ords = Some(values);
584 self
585 }
586
587 pub fn callback_ratio(mut self, value: impl Into<String>) -> Self {
589 self.callback_ratio = Some(value.into());
590 self
591 }
592
593 pub fn callback_spread(mut self, value: impl Into<String>) -> Self {
595 self.callback_spread = Some(value.into());
596 self
597 }
598
599 pub fn active_price(mut self, value: impl Into<String>) -> Self {
601 self.active_px = Some(value.into());
602 self
603 }
604
605 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 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 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 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 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 pub fn price_limit(mut self, value: impl Into<String>) -> Self {
670 self.px_limit = Some(value.into());
671 self
672 }
673
674 pub fn smart_iceberg_triggers(mut self, values: Vec<SmartIcebergTriggerRequest>) -> Self {
676 self.trigger_params = Some(values);
677 self
678 }
679
680 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#[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 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 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#[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 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 pub fn algo_id(mut self, value: impl Into<String>) -> Self {
1418 self.algo_id = Some(value.into());
1419 self
1420 }
1421
1422 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 pub fn request_id(mut self, value: impl Into<String>) -> Self {
1430 self.req_id = Some(value.into());
1431 self
1432 }
1433
1434 pub fn new_size(mut self, value: impl Into<String>) -> Self {
1436 self.new_sz = Some(value.into());
1437 self
1438 }
1439
1440 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 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 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 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 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 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 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#[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 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 pub fn algo_id(mut self, value: impl Into<String>) -> Self {
1613 self.algo_id = Some(value.into());
1614 self
1615 }
1616
1617 pub fn inst_type(mut self, value: impl Into<String>) -> Self {
1619 self.inst_type = Some(value.into());
1620 self
1621 }
1622
1623 pub fn inst_id(mut self, value: impl Into<String>) -> Self {
1625 self.inst_id = Some(value.into());
1626 self
1627 }
1628
1629 pub fn after(mut self, value: impl Into<String>) -> Self {
1631 self.after = Some(value.into());
1632 self
1633 }
1634
1635 pub fn before(mut self, value: impl Into<String>) -> Self {
1637 self.before = Some(value.into());
1638 self
1639 }
1640
1641 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#[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 pub fn new(ord_type: impl Into<String>) -> Self {
1693 Self {
1694 common: AlgoOrderListRequest::new(ord_type),
1695 state: None,
1696 }
1697 }
1698
1699 pub fn state(mut self, value: impl Into<String>) -> Self {
1701 self.state = Some(value.into());
1702 self
1703 }
1704
1705 pub fn algo_id(mut self, value: impl Into<String>) -> Self {
1707 self.common = self.common.algo_id(value);
1708 self
1709 }
1710
1711 pub fn inst_type(mut self, value: impl Into<String>) -> Self {
1713 self.common = self.common.inst_type(value);
1714 self
1715 }
1716
1717 pub fn inst_id(mut self, value: impl Into<String>) -> Self {
1719 self.common = self.common.inst_id(value);
1720 self
1721 }
1722
1723 pub fn after(mut self, value: impl Into<String>) -> Self {
1725 self.common = self.common.after(value);
1726 self
1727 }
1728
1729 pub fn before(mut self, value: impl Into<String>) -> Self {
1731 self.common = self.common.before(value);
1732 self
1733 }
1734
1735 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#[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 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 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}