1use super::ServiceContext;
2use super::correlation_id::require_client_style_id;
3use super::scope;
4use super::unary;
5use crate::codecs::decode::{
6 get_trigger_from_proto, trigger_events_list_from_proto, trigger_mutation_from_cancel,
7 trigger_mutation_from_create, trigger_mutation_from_modify, trigger_mutation_from_pause,
8 trigger_mutation_from_resume, triggers_list_from_proto,
9};
10use crate::codecs::scalars::id_to_u64;
11use crate::connect::triggers::v1::TriggersServiceClient;
12use crate::errors::{Error, Result};
13use crate::models::{
14 CreateOrderType, CreateSide, CreateTimeInForce, CreateTriggerParams, CreateTriggerType,
15 FeeAsset, ListTriggersOpts, ModifyTriggerParams, Trigger, TriggerEvent, TriggerEventsList,
16 TriggerMutationResult, TriggersList,
17};
18use crate::proto::orders::v1::{FeeAsset as ProtoFeeAsset, SelfTradePreventionMode, Side};
19use crate::proto::triggers::v1::{
20 CancelTriggerRequest, ConditionalChildExecution, ConditionalTrigger, CreateTriggerRequest,
21 GetTriggerRequest, LadderTrigger, ListTriggerEventsRequest, ListTriggersRequest,
22 ModifyTriggerRequest, PauseTriggerRequest, ResumeTriggerRequest, TrailingStopTrigger,
23 TriggerIntent, TriggerLimitFok, TriggerLimitGtc, TriggerLimitIoc, TwapLimitGtc, TwapTrigger,
24 conditional_child_execution, modify_trigger_request, trailing_stop_trigger, trigger_intent,
25 twap_trigger,
26};
27use crate::types::{resolve_price_ticks, resolve_qty_scaled};
28
29#[derive(Clone)]
30pub struct TriggersService {
31 ctx: ServiceContext,
32}
33
34impl TriggersService {
35 pub fn new(ctx: ServiceContext) -> Self {
36 Self { ctx }
37 }
38
39 fn client(&self) -> TriggersServiceClient<crate::transport::SharedTransport> {
40 TriggersServiceClient::new(
41 self.ctx.factory.transport(),
42 self.ctx.factory.connect_config(),
43 )
44 }
45
46 pub async fn list(&self, req: ListTriggersRequest) -> Result<TriggersList> {
47 let client = self.client();
48 let resp = unary::await_auth(
49 &self.ctx.factory,
50 "/triggers.v1.TriggersService/ListTriggers",
51 req,
52 |req, opts| client.list_triggers_with_options(req, opts),
53 )
54 .await?
55 .into_owned();
56 Ok(triggers_list_from_proto(&resp))
57 }
58
59 pub async fn list_with(&self, opts: ListTriggersOpts) -> Result<TriggersList> {
60 use crate::codecs::decode::trigger_status_from_label;
61 let mut req = ListTriggersRequest {
62 limit: if opts.limit == 0 { 50 } else { opts.limit },
63 ..Default::default()
64 };
65 if let Some(symbol) = opts.symbol {
66 req.symbol = symbol;
67 }
68 if let Some(token) = opts.page_token {
69 req.page_token = token;
70 }
71 req.subaccount_id = scope::optional_subaccount(&self.ctx, opts.subaccount_id)?;
72 for label in &opts.status {
73 let status = trigger_status_from_label(label).map_err(Error::validation)?;
74 req.status.push(status.into());
75 }
76 self.list(req).await
77 }
78
79 pub async fn get(&self, req: GetTriggerRequest) -> Result<Option<Trigger>> {
80 let client = self.client();
81 let resp = unary::await_auth(
82 &self.ctx.factory,
83 "/triggers.v1.TriggersService/GetTrigger",
84 req,
85 |req, opts| client.get_trigger_with_options(req, opts),
86 )
87 .await?
88 .into_owned();
89 Ok(get_trigger_from_proto(&resp))
90 }
91
92 pub async fn get_by_id(
94 &self,
95 trigger_id: &str,
96 subaccount_id: Option<u64>,
97 ) -> Result<Option<Trigger>> {
98 self.get(GetTriggerRequest {
99 trigger_id: id_to_u64(trigger_id, "trigger_id")?,
100 subaccount_id,
101 ..Default::default()
102 })
103 .await
104 }
105
106 fn encode_create_params(&self, params: &CreateTriggerParams) -> Result<CreateTriggerRequest> {
107 let scale = self
108 .ctx
109 .catalogs
110 .base_quantity_scale_for_symbol(¶ms.symbol)
111 .or(params.qty.scale())
112 .ok_or_else(|| {
113 Error::validation(format!(
114 "quantity scale for {:?} is unavailable; await client.wait_for_catalogs() before creating triggers, or pass a scaled Quantity",
115 params.symbol
116 ))
117 })?;
118 let qty = resolve_qty_scaled(
119 ¶ms.qty,
120 scale,
121 Some(¶ms.symbol),
122 self.ctx.catalogs.symbol_id_for_symbol(¶ms.symbol),
123 )?;
124 let mut intent = TriggerIntent {
125 symbol: params.symbol.clone(),
126 qty_scaled: qty,
127 ..Default::default()
128 };
129 intent.client_trigger_id =
130 require_client_style_id(¶ms.client_trigger_id, "client_trigger_id")?;
131 if let Some(asset) = params.fee_asset {
132 intent.fee_asset = Self::fee_asset(asset, params.side)?.into();
133 }
134 if let Some(mode) = params.self_trade_prevention_mode.as_deref() {
135 intent.self_trade_prevention_mode = Self::stp_mode(mode)?.into();
136 }
137 if params.trigger_price_source.is_some() {
138 return Err(Error::validation(
139 "trigger_price_source is not supported by the current wire contract",
140 ));
141 }
142
143 let side = match params.side {
144 CreateSide::Buy => Side::Buy,
145 CreateSide::Sell => Side::Sell,
146 };
147
148 intent.strategy = Some(match params.trigger_type {
149 CreateTriggerType::StopLoss | CreateTriggerType::TakeProfit => {
150 let trigger_price_ticks = params
151 .trigger_price
152 .as_ref()
153 .ok_or_else(|| Error::validation("stop/take-profit requires trigger_price"))
154 .and_then(|price| resolve_price_ticks(price, Some(¶ms.symbol)))?;
155 if trigger_price_ticks <= 0 {
156 return Err(Error::validation("trigger_price must be positive"));
157 }
158 let mut cond = ConditionalTrigger {
159 trigger_price_ticks,
160 side: side.into(),
161 ..Default::default()
162 };
163 *cond.child.get_or_insert_default() = Self::encode_conditional_child(params)?;
164 if matches!(params.trigger_type, CreateTriggerType::StopLoss) {
165 trigger_intent::Strategy::StopLoss(Box::new(cond))
166 } else {
167 trigger_intent::Strategy::TakeProfit(Box::new(cond))
168 }
169 }
170 CreateTriggerType::TrailingStop => {
171 if !matches!(params.side, CreateSide::Sell) {
173 return Err(Error::validation("trailing_stop only supports side=sell"));
174 }
175 let mut trailing = TrailingStopTrigger::default();
176 if params.trailing_distance_ticks.is_some()
177 == params.trailing_distance_bps.is_some()
178 {
179 return Err(Error::validation(
180 "trailing_stop requires exactly one of trailing_distance_ticks or trailing_distance_bps",
181 ));
182 }
183 if params.max_slippage_ticks.is_some() && params.max_slippage_bps.is_some() {
184 return Err(Error::validation(
185 "trailing_stop allows at most one of max_slippage_ticks or max_slippage_bps",
186 ));
187 }
188 if let Some(ticks) = params.trailing_distance_ticks {
189 if ticks <= 0 {
190 return Err(Error::validation(
191 "trailing_distance_ticks must be positive",
192 ));
193 }
194 trailing.trailing_distance =
195 Some(trailing_stop_trigger::TrailingDistance::TrailingDistanceTicks(ticks));
196 } else if let Some(bps) = params.trailing_distance_bps {
197 if bps <= 0 {
198 return Err(Error::validation("trailing_distance_bps must be positive"));
199 }
200 trailing.trailing_distance =
201 Some(trailing_stop_trigger::TrailingDistance::TrailingDistanceBps(bps));
202 } else {
203 return Err(Error::validation(
204 "trailing_stop requires trailing_distance_ticks or trailing_distance_bps",
205 ));
206 }
207 if let Some(price) = params.activation_price.as_ref() {
208 trailing.activation_price_ticks =
209 resolve_price_ticks(price, Some(¶ms.symbol))?;
210 }
211 if let Some(ticks) = params.max_slippage_ticks {
212 if ticks <= 0 {
213 return Err(Error::validation("max_slippage_ticks must be positive"));
214 }
215 trailing.max_slippage =
216 Some(trailing_stop_trigger::MaxSlippage::MaxSlippageTicks(ticks));
217 } else if let Some(bps) = params.max_slippage_bps {
218 if bps <= 0 {
219 return Err(Error::validation("max_slippage_bps must be positive"));
220 }
221 trailing.max_slippage =
222 Some(trailing_stop_trigger::MaxSlippage::MaxSlippageBps(bps));
223 }
224 trigger_intent::Strategy::TrailingStop(Box::new(trailing))
225 }
226 CreateTriggerType::Twap => {
227 let duration_ms = params
228 .twap_duration_ms
229 .filter(|value| *value > 0)
230 .ok_or_else(|| Error::validation("twap requires positive duration_ms"))?;
231 let slice_interval_ms = params
232 .twap_slice_interval_ms
233 .filter(|value| *value > 0)
234 .ok_or_else(|| Error::validation("twap requires positive slice_interval_ms"))?;
235 if slice_interval_ms > duration_ms {
236 return Err(Error::validation(
237 "twap slice_interval_ms must not exceed duration_ms",
238 ));
239 }
240 let mut twap = TwapTrigger {
241 side: side.into(),
242 duration_ms,
243 slice_interval_ms,
244 ..Default::default()
245 };
246 twap.execution = Some(match params.order_type {
247 CreateOrderType::Market => twap_trigger::Execution::MarketIoc(Box::default()),
248 CreateOrderType::Limit => {
249 let price = params.limit_price.as_ref().ok_or_else(|| {
250 Error::validation("twap limit slices require limit_price")
251 })?;
252 twap_trigger::Execution::LimitGtc(Box::new(TwapLimitGtc {
253 price_ticks: resolve_price_ticks(price, Some(¶ms.symbol))?,
254 ..Default::default()
255 }))
256 }
257 });
258 trigger_intent::Strategy::Twap(Box::new(twap))
259 }
260 CreateTriggerType::Ladder => {
261 if let Some(dist) = params.ladder_distribution.as_deref() {
262 let dist = dist.trim().to_ascii_lowercase();
263 if !dist.is_empty() && dist != "linear" {
264 return Err(Error::validation(
265 "ladder only supports linear distribution",
266 ));
267 }
268 }
269 let price_min_ticks = params
270 .ladder_price_min
271 .as_ref()
272 .ok_or_else(|| Error::validation("ladder requires ladder_price_min"))
273 .and_then(|price| resolve_price_ticks(price, Some(¶ms.symbol)))?;
274 let price_max_ticks = params
275 .ladder_price_max
276 .as_ref()
277 .ok_or_else(|| Error::validation("ladder requires ladder_price_max"))
278 .and_then(|price| resolve_price_ticks(price, Some(¶ms.symbol)))?;
279 let levels = params
280 .ladder_levels
281 .filter(|value| *value > 0)
282 .ok_or_else(|| Error::validation("ladder requires positive ladder_levels"))?;
283 if price_min_ticks <= 0 || price_max_ticks <= price_min_ticks {
284 return Err(Error::validation(
285 "ladder prices must be positive and max must exceed min",
286 ));
287 }
288 let ladder = LadderTrigger {
289 side: side.into(),
290 post_only: params.post_only,
291 price_min_ticks,
292 price_max_ticks,
293 levels,
294 ..Default::default()
295 };
296 trigger_intent::Strategy::Ladder(Box::new(ladder))
297 }
298 });
299
300 let mut req = CreateTriggerRequest {
301 subaccount_id: scope::optional_subaccount(&self.ctx, params.subaccount_id)?,
302 ..Default::default()
303 };
304 *req.trigger.get_or_insert_default() = intent;
305 Ok(req)
306 }
307
308 fn encode_conditional_child(params: &CreateTriggerParams) -> Result<ConditionalChildExecution> {
311 let execution = match params.order_type {
312 CreateOrderType::Market => {
313 if params.post_only {
314 return Err(Error::validation(
315 "post_only is only valid for limit GTC executions",
316 ));
317 }
318 conditional_child_execution::Execution::MarketIoc(Box::default())
319 }
320 CreateOrderType::Limit => {
321 let price = params
322 .limit_price
323 .as_ref()
324 .ok_or_else(|| Error::validation("limit trigger requires limit_price"))?;
325 let price_ticks = resolve_price_ticks(price, Some(¶ms.symbol))?;
326 match params.time_in_force {
327 Some(CreateTimeInForce::Ioc) => {
328 if params.post_only {
329 return Err(Error::validation(
330 "post_only is only valid for limit GTC executions",
331 ));
332 }
333 conditional_child_execution::Execution::LimitIoc(Box::new(
334 TriggerLimitIoc {
335 price_ticks,
336 ..Default::default()
337 },
338 ))
339 }
340 Some(CreateTimeInForce::Fok) => {
341 if params.post_only {
342 return Err(Error::validation(
343 "post_only is only valid for limit GTC executions",
344 ));
345 }
346 conditional_child_execution::Execution::LimitFok(Box::new(
347 TriggerLimitFok {
348 price_ticks,
349 ..Default::default()
350 },
351 ))
352 }
353 _ => conditional_child_execution::Execution::LimitGtc(Box::new(
355 TriggerLimitGtc {
356 price_ticks,
357 post_only: params.post_only,
358 ..Default::default()
359 },
360 )),
361 }
362 }
363 };
364 Ok(ConditionalChildExecution {
365 execution: Some(execution),
366 ..Default::default()
367 })
368 }
369
370 fn encode_modify_params(&self, params: &ModifyTriggerParams) -> Result<ModifyTriggerRequest> {
371 if params.trigger_price.is_none()
372 && params.limit_price.is_none()
373 && params.activation_price.is_none()
374 && params.trailing_distance_ticks.is_none()
375 && params.trailing_distance_bps.is_none()
376 && params.max_slippage_ticks.is_none()
377 && params.max_slippage_bps.is_none()
378 {
379 return Err(Error::validation(
380 "modify requires at least one of trigger_price, limit_price, trailing_distance_ticks, trailing_distance_bps, activation_price, max_slippage_ticks, or max_slippage_bps",
381 ));
382 }
383 let mut req = ModifyTriggerRequest {
384 trigger_id: id_to_u64(¶ms.trigger_id, "trigger_id")?,
385 subaccount_id: scope::optional_subaccount(&self.ctx, params.subaccount_id)?,
386 ..Default::default()
387 };
388 if let Some(price) = params.trigger_price.as_ref() {
389 req.trigger_price_ticks = Some(resolve_price_ticks(price, None)?);
390 }
391 if let Some(price) = params.limit_price.as_ref() {
392 req.limit_price_ticks = Some(resolve_price_ticks(price, None)?);
393 }
394 if let Some(price) = params.activation_price.as_ref() {
395 req.activation_price_ticks = Some(resolve_price_ticks(price, None)?);
396 }
397 if params.trailing_distance_ticks.is_some() && params.trailing_distance_bps.is_some() {
398 return Err(Error::validation(
399 "modify allows at most one trailing distance representation",
400 ));
401 }
402 if params.max_slippage_ticks.is_some() && params.max_slippage_bps.is_some() {
403 return Err(Error::validation(
404 "modify allows at most one max slippage representation",
405 ));
406 }
407 if let Some(ticks) = params.trailing_distance_ticks {
408 if ticks <= 0 {
409 return Err(Error::validation(
410 "trailing_distance_ticks must be positive",
411 ));
412 }
413 req.trailing_distance =
414 Some(modify_trigger_request::TrailingDistance::TrailingDistanceTicks(ticks));
415 } else if let Some(bps) = params.trailing_distance_bps {
416 if bps <= 0 {
417 return Err(Error::validation("trailing_distance_bps must be positive"));
418 }
419 req.trailing_distance =
420 Some(modify_trigger_request::TrailingDistance::TrailingDistanceBps(bps));
421 }
422 if let Some(ticks) = params.max_slippage_ticks {
423 if ticks <= 0 {
424 return Err(Error::validation("max_slippage_ticks must be positive"));
425 }
426 req.max_slippage = Some(modify_trigger_request::MaxSlippage::MaxSlippageTicks(ticks));
427 } else if let Some(bps) = params.max_slippage_bps {
428 if bps <= 0 {
429 return Err(Error::validation("max_slippage_bps must be positive"));
430 }
431 req.max_slippage = Some(modify_trigger_request::MaxSlippage::MaxSlippageBps(bps));
432 }
433 Ok(req)
434 }
435
436 fn fee_asset(asset: FeeAsset, side: CreateSide) -> Result<ProtoFeeAsset> {
437 match (asset, side) {
438 (FeeAsset::Quote, _) => Ok(ProtoFeeAsset::Quote),
439 (FeeAsset::Base, CreateSide::Buy) => Ok(ProtoFeeAsset::Base),
440 (FeeAsset::Base, CreateSide::Sell) => Err(Error::validation(
441 "fee_asset=base is only valid for BUY triggers",
442 )),
443 }
444 }
445
446 fn stp_mode(label: &str) -> Result<SelfTradePreventionMode> {
447 match label.to_ascii_lowercase().as_str() {
448 "expire_taker" => Ok(SelfTradePreventionMode::ExpireTaker),
449 "expire_maker" => Ok(SelfTradePreventionMode::ExpireMaker),
450 "expire_both" => Ok(SelfTradePreventionMode::ExpireBoth),
451 _ => Err(Error::validation(
452 "self_trade_prevention_mode must be expire_taker, expire_maker, or expire_both",
453 )),
454 }
455 }
456
457 pub async fn create(&self, params: CreateTriggerParams) -> Result<TriggerMutationResult> {
459 self.ctx.wait_for_catalogs().await?;
460 let req = self.encode_create_params(¶ms)?;
461 let client = self.client();
462 let resp = unary::await_auth(
463 &self.ctx.factory,
464 "/triggers.v1.TriggersService/CreateTrigger",
465 req,
466 |req, opts| client.create_trigger_with_options(req, opts),
467 )
468 .await?
469 .into_owned();
470 trigger_mutation_from_create(&resp)
471 }
472
473 pub async fn cancel(&self, req: CancelTriggerRequest) -> Result<TriggerMutationResult> {
474 let client = self.client();
475 let resp = unary::await_auth(
476 &self.ctx.factory,
477 "/triggers.v1.TriggersService/CancelTrigger",
478 req,
479 |req, opts| client.cancel_trigger_with_options(req, opts),
480 )
481 .await?
482 .into_owned();
483 trigger_mutation_from_cancel(&resp)
484 }
485
486 pub async fn cancel_by_id(
488 &self,
489 trigger_id: &str,
490 subaccount_id: Option<u64>,
491 ) -> Result<TriggerMutationResult> {
492 self.cancel(CancelTriggerRequest {
493 trigger_id: id_to_u64(trigger_id, "trigger_id")?,
494 subaccount_id,
495 ..Default::default()
496 })
497 .await
498 }
499
500 pub async fn pause(&self, req: PauseTriggerRequest) -> Result<TriggerMutationResult> {
501 let client = self.client();
502 let resp = unary::await_auth(
503 &self.ctx.factory,
504 "/triggers.v1.TriggersService/PauseTrigger",
505 req,
506 |req, opts| client.pause_trigger_with_options(req, opts),
507 )
508 .await?
509 .into_owned();
510 trigger_mutation_from_pause(&resp)
511 }
512
513 pub async fn pause_by_id(
515 &self,
516 trigger_id: &str,
517 subaccount_id: Option<u64>,
518 ) -> Result<TriggerMutationResult> {
519 self.pause(PauseTriggerRequest {
520 trigger_id: id_to_u64(trigger_id, "trigger_id")?,
521 subaccount_id,
522 ..Default::default()
523 })
524 .await
525 }
526
527 pub async fn resume(&self, req: ResumeTriggerRequest) -> Result<TriggerMutationResult> {
528 let client = self.client();
529 let resp = unary::await_auth(
530 &self.ctx.factory,
531 "/triggers.v1.TriggersService/ResumeTrigger",
532 req,
533 |req, opts| client.resume_trigger_with_options(req, opts),
534 )
535 .await?
536 .into_owned();
537 trigger_mutation_from_resume(&resp)
538 }
539
540 pub async fn resume_by_id(
542 &self,
543 trigger_id: &str,
544 subaccount_id: Option<u64>,
545 ) -> Result<TriggerMutationResult> {
546 self.resume(ResumeTriggerRequest {
547 trigger_id: id_to_u64(trigger_id, "trigger_id")?,
548 subaccount_id,
549 ..Default::default()
550 })
551 .await
552 }
553
554 pub async fn modify(&self, params: ModifyTriggerParams) -> Result<TriggerMutationResult> {
556 let req = self.encode_modify_params(¶ms)?;
557 let client = self.client();
558 let resp = unary::await_auth(
559 &self.ctx.factory,
560 "/triggers.v1.TriggersService/ModifyTrigger",
561 req,
562 |req, opts| client.modify_trigger_with_options(req, opts),
563 )
564 .await?
565 .into_owned();
566 trigger_mutation_from_modify(&resp)
567 }
568
569 pub async fn list_events(&self, req: ListTriggerEventsRequest) -> Result<TriggerEventsList> {
570 let client = self.client();
571 let resp = unary::await_auth(
572 &self.ctx.factory,
573 "/triggers.v1.TriggersService/ListTriggerEvents",
574 req,
575 |req, opts| client.list_trigger_events_with_options(req, opts),
576 )
577 .await?
578 .into_owned();
579 Ok(trigger_events_list_from_proto(&resp))
580 }
581
582 pub async fn subscribe(
584 &self,
585 account_id: Option<&str>,
586 ) -> Result<crate::realtime::TypedSubscription<Trigger>> {
587 let account = scope::resolve_account_id(&self.ctx, account_id)?;
588 let channel = format!("private:spot:triggers:{account}:proto");
589 self.ctx
590 .realtime
591 .subscribe_proto(&channel, crate::codecs::decode::trigger_from_bytes)
592 .await
593 }
594
595 pub async fn subscribe_events(
597 &self,
598 account_id: Option<&str>,
599 ) -> Result<crate::realtime::TypedSubscription<TriggerEvent>> {
600 let account = scope::resolve_account_id(&self.ctx, account_id)?;
601 let channel = format!("private:spot:triggers:events:{account}:proto");
602 self.ctx
603 .realtime
604 .subscribe_proto(&channel, crate::codecs::decode::trigger_event_from_bytes)
605 .await
606 }
607}
608
609#[cfg(test)]
610mod tests {
611 use super::*;
612 use buffa::Message;
613 use serde_json::json;
614
615 fn client() -> crate::Client {
616 let client = crate::Client::new(crate::Config {
617 hydrate_catalogs: false,
618 ..Default::default()
619 })
620 .unwrap();
621 client
622 .catalogs
623 .hydrate_spot_config_json(json!({
624 "pairs": [{
625 "symbol": "BTC-USDT",
626 "symbol_id": 7,
627 "base_quantity_scale": 8
628 }]
629 }))
630 .expect("hydrate");
631 client
632 }
633
634 fn create_params(
635 qty: crate::Quantity,
636 trigger_price: crate::Price,
637 limit_price: crate::Price,
638 ) -> CreateTriggerParams {
639 CreateTriggerParams {
640 symbol: "BTC-USDT".into(),
641 trigger_type: CreateTriggerType::StopLoss,
642 side: CreateSide::Sell,
643 order_type: CreateOrderType::Limit,
644 qty,
645 trigger_price: Some(trigger_price),
646 limit_price: Some(limit_price),
647 trigger_price_source: None,
648 time_in_force: Some(CreateTimeInForce::Gtc),
649 subaccount_id: None,
650 client_trigger_id: "trigger-equivalence".into(),
651 post_only: false,
652 activation_price: None,
653 trailing_distance_ticks: None,
654 trailing_distance_bps: None,
655 max_slippage_ticks: None,
656 max_slippage_bps: None,
657 twap_duration_ms: None,
658 twap_slice_interval_ms: None,
659 ladder_price_min: None,
660 ladder_price_max: None,
661 ladder_levels: None,
662 ladder_distribution: None,
663 fee_asset: None,
664 self_trade_prevention_mode: None,
665 }
666 }
667
668 #[test]
669 fn decimal_and_scaled_trigger_encode_identically() {
670 let client = client();
671 let decimal = create_params(
672 crate::Quantity::from_decimal_str("0.1", 8, Some("BTC-USDT".into()), Some(7)).unwrap(),
673 crate::Price::from_decimal_str("49000", Some("BTC-USDT".into())).unwrap(),
674 crate::Price::from_decimal_str("48950", Some("BTC-USDT".into())).unwrap(),
675 );
676 let scaled = create_params(
677 crate::Quantity::from_scaled(
678 10_000_000,
679 Some(8),
680 crate::QuantityDomain::OrderBase,
681 Some("BTC-USDT".into()),
682 Some(7),
683 )
684 .unwrap(),
685 crate::Price::from_ticks(49_000_000_000, Some("BTC-USDT".into())).unwrap(),
686 crate::Price::from_ticks(48_950_000_000, Some("BTC-USDT".into())).unwrap(),
687 );
688
689 let decimal_wire = client.triggers.encode_create_params(&decimal).unwrap();
690 let scaled_wire = client.triggers.encode_create_params(&scaled).unwrap();
691 assert_eq!(decimal_wire.encode_to_vec(), scaled_wire.encode_to_vec());
692 }
693
694 #[test]
695 fn conditional_trigger_rejects_post_only_outside_limit_gtc() {
696 let client = client();
697 let qty =
698 crate::Quantity::from_decimal_str("0.1", 8, Some("BTC-USDT".into()), Some(7)).unwrap();
699 let trigger_price =
700 crate::Price::from_decimal_str("49000", Some("BTC-USDT".into())).unwrap();
701 let limit_price = crate::Price::from_decimal_str("48950", Some("BTC-USDT".into())).unwrap();
702 let base = create_params(qty, trigger_price, limit_price.clone());
703
704 for (order_type, time_in_force, child_limit_price) in [
705 (CreateOrderType::Market, None, None),
706 (
707 CreateOrderType::Limit,
708 Some(CreateTimeInForce::Ioc),
709 Some(limit_price.clone()),
710 ),
711 (
712 CreateOrderType::Limit,
713 Some(CreateTimeInForce::Fok),
714 Some(limit_price.clone()),
715 ),
716 ] {
717 let params = CreateTriggerParams {
718 post_only: true,
719 order_type,
720 time_in_force,
721 limit_price: child_limit_price,
722 ..base.clone()
723 };
724 let err = client.triggers.encode_create_params(¶ms).unwrap_err();
725 assert!(err.to_string().contains("limit GTC"), "{err}");
726 }
727 }
728
729 #[test]
730 fn trailing_stop_rejects_buy() {
731 let client = client();
732 let mut params = create_params(
733 crate::Quantity::from_decimal_str("0.1", 8, Some("BTC-USDT".into()), Some(7)).unwrap(),
734 crate::Price::from_decimal_str("49000", Some("BTC-USDT".into())).unwrap(),
735 crate::Price::from_decimal_str("48950", Some("BTC-USDT".into())).unwrap(),
736 );
737 params.trigger_type = CreateTriggerType::TrailingStop;
738 params.side = CreateSide::Buy;
739 params.trailing_distance_bps = Some(100);
740 let err = client.triggers.encode_create_params(¶ms).unwrap_err();
741 assert!(err.to_string().contains("only supports side=sell"), "{err}");
742 }
743
744 #[test]
745 fn trailing_stop_sell_encodes_trailing_strategy() {
746 use crate::proto::triggers::v1::trigger_intent::Strategy;
747
748 let client = client();
749 let mut params = create_params(
750 crate::Quantity::from_decimal_str("0.1", 8, Some("BTC-USDT".into()), Some(7)).unwrap(),
751 crate::Price::from_decimal_str("49000", Some("BTC-USDT".into())).unwrap(),
752 crate::Price::from_decimal_str("48950", Some("BTC-USDT".into())).unwrap(),
753 );
754 params.trigger_type = CreateTriggerType::TrailingStop;
755 params.side = CreateSide::Sell;
756 params.trailing_distance_bps = Some(100);
757 params.trigger_price = None;
758 params.limit_price = None;
759 let wire = client.triggers.encode_create_params(¶ms).unwrap();
760 let intent = wire.trigger.expect("trigger intent");
761 assert!(
762 matches!(intent.strategy, Some(Strategy::TrailingStop(_))),
763 "expected TrailingStop strategy, got {:?}",
764 intent.strategy
765 );
766 }
767
768 #[test]
769 fn trigger_create_rejects_missing_ids_fields_and_ambiguous_oneofs() {
770 let client = client();
771 let base = create_params(
772 crate::Quantity::from_decimal_str("0.1", 8, Some("BTC-USDT".into()), Some(7)).unwrap(),
773 crate::Price::from_decimal_str("49000", Some("BTC-USDT".into())).unwrap(),
774 crate::Price::from_decimal_str("48950", Some("BTC-USDT".into())).unwrap(),
775 );
776
777 let mut missing_id = base.clone();
778 missing_id.client_trigger_id = " ".into();
779 assert!(client.triggers.encode_create_params(&missing_id).is_err());
780
781 let mut missing_trigger_price = base.clone();
782 missing_trigger_price.trigger_price = None;
783 assert!(
784 client
785 .triggers
786 .encode_create_params(&missing_trigger_price)
787 .is_err()
788 );
789
790 let mut unsupported_source = base.clone();
791 unsupported_source.trigger_price_source = Some("mark".into());
792 assert!(
793 client
794 .triggers
795 .encode_create_params(&unsupported_source)
796 .is_err()
797 );
798
799 let mut trailing = base.clone();
800 trailing.trigger_type = CreateTriggerType::TrailingStop;
801 trailing.trigger_price = None;
802 trailing.limit_price = None;
803 trailing.trailing_distance_ticks = Some(10);
804 trailing.trailing_distance_bps = Some(10);
805 assert!(client.triggers.encode_create_params(&trailing).is_err());
806
807 let mut twap = base.clone();
808 twap.trigger_type = CreateTriggerType::Twap;
809 assert!(client.triggers.encode_create_params(&twap).is_err());
810
811 let mut ladder = base;
812 ladder.trigger_type = CreateTriggerType::Ladder;
813 assert!(client.triggers.encode_create_params(&ladder).is_err());
814 }
815
816 #[test]
817 fn lifecycle_helpers_accept_base58_trigger_ids() {
818 let client = client();
819 let encoded = bs58::encode(42_u64.to_be_bytes()).into_string();
820 let params = ModifyTriggerParams {
821 trigger_id: encoded,
822 trailing_distance_bps: Some(50),
823 ..modify_params(None, None, None)
824 };
825 let wire = client.triggers.encode_modify_params(¶ms).unwrap();
826 assert_eq!(wire.trigger_id, 42);
827 }
828
829 #[test]
830 fn trigger_modify_requires_a_patch() {
831 let client = client();
832 let params = ModifyTriggerParams {
833 trigger_id: "1".into(),
834 subaccount_id: None,
835 trigger_price: None,
836 limit_price: None,
837 activation_price: None,
838 trailing_distance_ticks: None,
839 trailing_distance_bps: None,
840 max_slippage_ticks: None,
841 max_slippage_bps: None,
842 };
843 assert!(client.triggers.encode_modify_params(¶ms).is_err());
844
845 let nonpositive = ModifyTriggerParams {
846 trailing_distance_bps: Some(0),
847 ..params
848 };
849 assert!(client.triggers.encode_modify_params(&nonpositive).is_err());
850 }
851
852 #[test]
853 fn public_trigger_ids_convert_for_lifecycle_helpers() {
854 let encoded = bs58::encode(42_u64.to_be_bytes()).into_string();
855 assert_eq!(id_to_u64(&encoded, "trigger_id").unwrap(), 42);
856 assert!(id_to_u64("not a trigger id", "trigger_id").is_err());
857 }
858
859 fn modify_params(
860 trigger_price: Option<crate::Price>,
861 limit_price: Option<crate::Price>,
862 activation_price: Option<crate::Price>,
863 ) -> ModifyTriggerParams {
864 ModifyTriggerParams {
865 trigger_id: "1".into(),
866 subaccount_id: None,
867 trigger_price,
868 limit_price,
869 activation_price,
870 trailing_distance_ticks: None,
871 trailing_distance_bps: None,
872 max_slippage_ticks: None,
873 max_slippage_bps: None,
874 }
875 }
876
877 #[test]
878 fn decimal_and_scaled_trigger_modify_encode_identically() {
879 let client = client();
880 let decimal = modify_params(
881 Some(crate::Price::from_decimal_str("49000", Some("BTC-USDT".into())).unwrap()),
882 Some(crate::Price::from_decimal_str("48950", Some("BTC-USDT".into())).unwrap()),
883 Some(crate::Price::from_decimal_str("49500", Some("BTC-USDT".into())).unwrap()),
884 );
885 let scaled = modify_params(
886 Some(crate::Price::from_ticks(49_000_000_000, Some("BTC-USDT".into())).unwrap()),
887 Some(crate::Price::from_ticks(48_950_000_000, Some("BTC-USDT".into())).unwrap()),
888 Some(crate::Price::from_ticks(49_500_000_000, Some("BTC-USDT".into())).unwrap()),
889 );
890 let decimal_wire = client.triggers.encode_modify_params(&decimal).unwrap();
891 let scaled_wire = client.triggers.encode_modify_params(&scaled).unwrap();
892 assert_eq!(decimal_wire.encode_to_vec(), scaled_wire.encode_to_vec());
893 }
894}