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                // Standalone trailing stops remain SELL market-IOC. Attached
172                // trailing risk may use either side (opposite the parent) and is
173                // encoded via order AttachedRisk, not this create path.
174                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(&params.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(&params.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(&params.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(&params.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    /// Map flat (`order_type`, `time_in_force`, `limit_price`, `post_only`) params
314    /// onto a stop-loss / take-profit child execution variant.
315    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(&params.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                    // gtc or unspecified
359                    _ => 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(&params.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    /// Create a trigger. Prices/qty must be `Price` / `Quantity` wrappers.
463    pub async fn create(&self, params: CreateTriggerParams) -> Result<TriggerMutationResult> {
464        self.ctx.wait_for_catalogs().await?;
465        let req = self.encode_create_params(&params)?;
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    /// Cancel a trigger using the public string ID returned by create and list calls.
492    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    /// Pause a trigger using the public string ID returned by create and list calls.
519    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    /// Resume a trigger using the public string ID returned by create and list calls.
546    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    /// Modify a trigger. Price fields must be `Price` wrappers.
560    pub async fn modify(&self, params: ModifyTriggerParams) -> Result<TriggerMutationResult> {
561        let req = self.encode_modify_params(&params)?;
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    /// Subscribe to private trigger updates (requires `realtime` feature).
588    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    /// Subscribe to private trigger events (requires `realtime` feature).
601    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(&params).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(&params).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(&params).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(&params).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(&params).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}