Skip to main content

polyester/services/
triggers.rs

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    /// Retrieve a trigger using the public string ID returned by create and list calls.
93    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(&params.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            &params.qty,
120            scale,
121            Some(&params.symbol),
122            self.ctx.catalogs.symbol_id_for_symbol(&params.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(&params.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(&params.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                // The wire strategy has no side field and executes as SELL.
172                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(&params.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(&params.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(&params.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(&params.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    /// Map flat (`order_type`, `time_in_force`, `limit_price`, `post_only`) params
309    /// onto a stop-loss / take-profit child execution variant.
310    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(&params.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                    // gtc or unspecified
354                    _ => 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(&params.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    /// Create a trigger. Prices/qty must be `Price` / `Quantity` wrappers.
458    pub async fn create(&self, params: CreateTriggerParams) -> Result<TriggerMutationResult> {
459        self.ctx.wait_for_catalogs().await?;
460        let req = self.encode_create_params(&params)?;
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    /// Cancel a trigger using the public string ID returned by create and list calls.
487    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    /// Pause a trigger using the public string ID returned by create and list calls.
514    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    /// Resume a trigger using the public string ID returned by create and list calls.
541    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    /// Modify a trigger. Price fields must be `Price` wrappers.
555    pub async fn modify(&self, params: ModifyTriggerParams) -> Result<TriggerMutationResult> {
556        let req = self.encode_modify_params(&params)?;
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    /// Subscribe to private trigger updates (requires `realtime` feature).
583    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    /// Subscribe to private trigger events (requires `realtime` feature).
596    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(&params).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(&params).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(&params).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(&params).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(&params).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}