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