Skip to main content

polyester/codecs/decode/
triggers.rs

1//! Trigger response decoders.
2
3use super::enums::enum_value_side;
4use super::money::{decode_price_ticks, decode_qty_scaled};
5use crate::codecs::scalars::format_uint64_id;
6use crate::models::{
7    Trigger, TriggerDetails, TriggerEvent, TriggerEventsList, TriggerLadderDetails,
8    TriggerMutationResult, TriggerStopDetails, TriggerTrailingDetails, TriggerTwapDetails,
9    TriggersList,
10};
11use crate::proto::orders::v1::{
12    FeeAsset, SelfTradePreventionMode, TriggerDirection, TriggerPriceSource,
13};
14use crate::proto::triggers::v1::{
15    CancelTriggerResponse, ConditionalTrigger, CreateTriggerResponse, GetTriggerResponse,
16    LadderDistribution, ListTriggerEventsResponse, ListTriggersResponse, ModifyTriggerResponse,
17    PauseTriggerResponse, ResumeTriggerResponse, Trigger as ProtoTrigger,
18    TriggerEvent as ProtoTriggerEvent, TriggerEventType, TriggerStatus, TriggerType,
19    conditional_child_execution, trigger, twap_trigger,
20};
21use crate::types::Price;
22use buffa::Enumeration;
23use buffa_types::google::protobuf::Timestamp;
24
25pub fn trigger_status_label(status: TriggerStatus) -> &'static str {
26    match status {
27        TriggerStatus::StatusCreated => "created",
28        TriggerStatus::StatusArmed => "armed",
29        TriggerStatus::StatusRunning => "running",
30        TriggerStatus::StatusCompleted => "completed",
31        TriggerStatus::StatusCanceled => "cancelled",
32        TriggerStatus::StatusFailed => "failed",
33        TriggerStatus::StatusPaused => "paused",
34        TriggerStatus::StatusUnspecified => "",
35    }
36}
37
38/// Parse a trigger status filter label into a proto enum.
39pub fn trigger_status_from_label(label: &str) -> Result<TriggerStatus, String> {
40    match label.trim().to_ascii_lowercase().as_str() {
41        "created" => Ok(TriggerStatus::StatusCreated),
42        "armed" => Ok(TriggerStatus::StatusArmed),
43        "running" => Ok(TriggerStatus::StatusRunning),
44        "completed" => Ok(TriggerStatus::StatusCompleted),
45        "cancelled" | "canceled" => Ok(TriggerStatus::StatusCanceled),
46        "failed" => Ok(TriggerStatus::StatusFailed),
47        "paused" => Ok(TriggerStatus::StatusPaused),
48        other => Err(format!(
49            "invalid trigger status {other:?}; expected one of: created, armed, running, completed, cancelled, failed, paused"
50        )),
51    }
52}
53
54fn enum_value_trigger_status(value: buffa::EnumValue<TriggerStatus>) -> String {
55    value
56        .as_known()
57        .map(trigger_status_label)
58        .map(str::to_owned)
59        .unwrap_or_else(|| format!("UNKNOWN({})", value.to_i32()))
60}
61
62fn trigger_price_source_label(value: buffa::EnumValue<TriggerPriceSource>) -> String {
63    match value.as_known() {
64        Some(TriggerPriceSource::LastPrice) => "last".to_owned(),
65        Some(TriggerPriceSource::IndexPrice) => "index".to_owned(),
66        Some(TriggerPriceSource::MarkPrice) => "mark".to_owned(),
67        Some(_) => String::new(),
68        None => format!("UNKNOWN({})", value.to_i32()),
69    }
70}
71
72fn trigger_direction_label(value: buffa::EnumValue<TriggerDirection>) -> String {
73    match value.as_known() {
74        Some(TriggerDirection::Above) => "above".to_owned(),
75        Some(TriggerDirection::Below) => "below".to_owned(),
76        Some(_) => String::new(),
77        None => format!("UNKNOWN({})", value.to_i32()),
78    }
79}
80
81fn fee_asset_label(value: buffa::EnumValue<FeeAsset>) -> String {
82    match value.as_known() {
83        Some(FeeAsset::Quote) => "quote".to_owned(),
84        Some(FeeAsset::Base) => "base".to_owned(),
85        Some(_) => String::new(),
86        None => format!("UNKNOWN({})", value.to_i32()),
87    }
88}
89
90fn stp_mode_label(value: buffa::EnumValue<SelfTradePreventionMode>) -> String {
91    match value.as_known() {
92        Some(SelfTradePreventionMode::ExpireTaker) => "expire_taker".to_owned(),
93        Some(SelfTradePreventionMode::ExpireMaker) => "expire_maker".to_owned(),
94        Some(SelfTradePreventionMode::ExpireBoth) => "expire_both".to_owned(),
95        Some(_) => String::new(),
96        None => format!("UNKNOWN({})", value.to_i32()),
97    }
98}
99
100fn ladder_distribution_label(value: buffa::EnumValue<LadderDistribution>) -> String {
101    match value.as_known() {
102        Some(LadderDistribution::Linear) => "linear".to_owned(),
103        Some(LadderDistribution::Geometric) => "geometric".to_owned(),
104        Some(LadderDistribution::WeightedFavorable) => "weighted_favorable".to_owned(),
105        Some(_) => String::new(),
106        None => format!("UNKNOWN({})", value.to_i32()),
107    }
108}
109
110fn clone_timestamp(ts: Option<&Timestamp>) -> Option<Timestamp> {
111    ts.map(|t| Timestamp {
112        seconds: t.seconds,
113        nanos: t.nanos,
114        ..Default::default()
115    })
116}
117
118fn trigger_details_from_proto(
119    msg: &ProtoTrigger,
120    symbol: Option<String>,
121    symbol_id_opt: Option<u32>,
122) -> Option<TriggerDetails> {
123    match msg.runtime_details.as_ref() {
124        Some(trigger::RuntimeDetails::Stop(stop)) => {
125            Some(TriggerDetails::Stop(TriggerStopDetails {
126                trigger_price: decode_price_ticks(stop.trigger_price_ticks, symbol.clone()),
127                trigger_price_source: trigger_price_source_label(stop.trigger_price_source),
128                trigger_direction: trigger_direction_label(stop.trigger_direction),
129            }))
130        }
131        Some(trigger::RuntimeDetails::Trailing(trailing)) => {
132            Some(TriggerDetails::Trailing(TriggerTrailingDetails {
133                trailing_distance: if trailing.trailing_distance_ticks > 0 {
134                    decode_price_ticks(trailing.trailing_distance_ticks, symbol.clone())
135                } else {
136                    None
137                },
138                trailing_distance_bps: trailing.trailing_distance_bps,
139                activation_price: if trailing.activation_price_ticks > 0 {
140                    decode_price_ticks(trailing.activation_price_ticks, symbol.clone())
141                } else {
142                    None
143                },
144                peak_price: if trailing.peak_price_ticks > 0 {
145                    decode_price_ticks(trailing.peak_price_ticks, symbol.clone())
146                } else {
147                    None
148                },
149                trough_price: if trailing.trough_price_ticks > 0 {
150                    decode_price_ticks(trailing.trough_price_ticks, symbol.clone())
151                } else {
152                    None
153                },
154                max_slippage: if trailing.max_slippage_ticks > 0 {
155                    decode_price_ticks(i64::from(trailing.max_slippage_ticks), symbol.clone())
156                } else {
157                    None
158                },
159                max_slippage_bps: trailing.max_slippage_bps,
160                trigger_price_source: trigger_price_source_label(trailing.trigger_price_source),
161                trigger_direction: trigger_direction_label(trailing.trigger_direction),
162            }))
163        }
164        Some(trigger::RuntimeDetails::TwapState(twap)) => {
165            Some(TriggerDetails::Twap(TriggerTwapDetails {
166                twap_duration_ms: twap.twap_duration_ms,
167                twap_slice_interval_ms: twap.twap_slice_interval_ms,
168                slice_idx: twap.slice_idx,
169                slice_count: twap.slice_count,
170                executed_qty: decode_qty_scaled(
171                    twap.executed_qty_scaled,
172                    None,
173                    symbol.clone(),
174                    symbol_id_opt,
175                ),
176            }))
177        }
178        Some(trigger::RuntimeDetails::LadderState(ladder)) => {
179            Some(TriggerDetails::Ladder(TriggerLadderDetails {
180                ladder_price_min: if ladder.ladder_price_min_ticks > 0 {
181                    decode_price_ticks(ladder.ladder_price_min_ticks, symbol.clone())
182                } else {
183                    None
184                },
185                ladder_price_max: if ladder.ladder_price_max_ticks > 0 {
186                    decode_price_ticks(ladder.ladder_price_max_ticks, symbol.clone())
187                } else {
188                    None
189                },
190                ladder_levels: ladder.ladder_levels,
191                ladder_distribution: ladder_distribution_label(ladder.ladder_distribution),
192            }))
193        }
194        None => None,
195    }
196}
197
198fn trigger_price_from_details(details: &Option<TriggerDetails>) -> Option<Price> {
199    match details {
200        Some(TriggerDetails::Stop(stop)) => stop.trigger_price.clone(),
201        _ => None,
202    }
203}
204
205/// Flat public trigger fields derived from the immutable `Configuration` oneof.
206#[derive(Default)]
207struct TriggerConfigProjection {
208    trigger_type: String,
209    side: String,
210    order_type: String,
211    time_in_force: String,
212    post_only: bool,
213    limit_price: Option<Price>,
214    trigger_price: Option<Price>,
215}
216
217/// Derive flat child fields (side/order_type/tif/post_only/limit_price) from a
218/// stop-loss / take-profit `ConditionalTrigger` configuration.
219fn conditional_child_projection(
220    cond: &ConditionalTrigger,
221    symbol: Option<String>,
222) -> (String, String, String, bool, Option<Price>) {
223    let side = enum_value_side(cond.side).to_owned();
224    let mut order_type = String::new();
225    let mut time_in_force = String::new();
226    let mut post_only = false;
227    let mut limit_price = None;
228    if let Some(child) = cond.child.as_option() {
229        match child.execution.as_ref() {
230            Some(conditional_child_execution::Execution::MarketIoc(_)) => {
231                order_type = "market".to_owned();
232                time_in_force = "ioc".to_owned();
233            }
234            Some(conditional_child_execution::Execution::LimitGtc(limit)) => {
235                order_type = "limit".to_owned();
236                time_in_force = "gtc".to_owned();
237                post_only = limit.post_only;
238                limit_price = decode_price_ticks(limit.price_ticks, symbol);
239            }
240            Some(conditional_child_execution::Execution::LimitIoc(limit)) => {
241                order_type = "limit".to_owned();
242                time_in_force = "ioc".to_owned();
243                limit_price = decode_price_ticks(limit.price_ticks, symbol);
244            }
245            Some(conditional_child_execution::Execution::LimitFok(limit)) => {
246                order_type = "limit".to_owned();
247                time_in_force = "fok".to_owned();
248                limit_price = decode_price_ticks(limit.price_ticks, symbol);
249            }
250            None => {}
251        }
252    }
253    (side, order_type, time_in_force, post_only, limit_price)
254}
255
256/// Derive the flat public trigger fields (type/side/order_type/tif/post_only/
257/// limit_price/trigger_price) from the immutable `Configuration` oneof.
258fn trigger_config_projection(
259    msg: &ProtoTrigger,
260    symbol: Option<String>,
261) -> TriggerConfigProjection {
262    let mut proj = TriggerConfigProjection::default();
263    match msg.configuration.as_ref() {
264        Some(trigger::Configuration::StopLoss(cond)) => {
265            proj.trigger_type = "stop_loss".to_owned();
266            let (side, order_type, tif, post_only, limit_price) =
267                conditional_child_projection(cond, symbol.clone());
268            proj.side = side;
269            proj.order_type = order_type;
270            proj.time_in_force = tif;
271            proj.post_only = post_only;
272            proj.limit_price = limit_price;
273            if cond.trigger_price_ticks != 0 {
274                proj.trigger_price = decode_price_ticks(cond.trigger_price_ticks, symbol);
275            }
276        }
277        Some(trigger::Configuration::TakeProfit(cond)) => {
278            proj.trigger_type = "take_profit".to_owned();
279            let (side, order_type, tif, post_only, limit_price) =
280                conditional_child_projection(cond, symbol.clone());
281            proj.side = side;
282            proj.order_type = order_type;
283            proj.time_in_force = tif;
284            proj.post_only = post_only;
285            proj.limit_price = limit_price;
286            if cond.trigger_price_ticks != 0 {
287                proj.trigger_price = decode_price_ticks(cond.trigger_price_ticks, symbol);
288            }
289        }
290        Some(trigger::Configuration::TrailingStop(trailing)) => {
291            // Trailing stop is market-IOC; side is carried on the wire (standalone
292            // creates are SELL; attached risk may be either side opposite parent).
293            proj.trigger_type = "trailing_stop".to_owned();
294            proj.side = enum_value_side(trailing.side).to_owned();
295            if proj.side.is_empty() {
296                proj.side = "sell".to_owned();
297            }
298            proj.order_type = "market".to_owned();
299            proj.time_in_force = "ioc".to_owned();
300        }
301        Some(trigger::Configuration::Twap(twap)) => {
302            proj.trigger_type = "twap".to_owned();
303            proj.side = enum_value_side(twap.side).to_owned();
304            match twap.execution.as_ref() {
305                Some(twap_trigger::Execution::LimitGtc(limit)) => {
306                    proj.order_type = "limit".to_owned();
307                    proj.time_in_force = "gtc".to_owned();
308                    proj.limit_price = decode_price_ticks(limit.price_ticks, symbol);
309                }
310                Some(twap_trigger::Execution::MarketIoc(_)) => {
311                    proj.order_type = "market".to_owned();
312                    proj.time_in_force = "ioc".to_owned();
313                }
314                None => {}
315            }
316        }
317        Some(trigger::Configuration::Ladder(ladder)) => {
318            proj.trigger_type = "ladder".to_owned();
319            proj.side = enum_value_side(ladder.side).to_owned();
320            proj.order_type = "limit".to_owned();
321            proj.time_in_force = "gtc".to_owned();
322            proj.post_only = ladder.post_only;
323        }
324        None => {}
325    }
326    proj
327}
328
329pub fn trigger_from_proto(msg: &ProtoTrigger) -> Trigger {
330    let symbol_id = msg.symbol_id;
331    let symbol_id_opt = if symbol_id == 0 {
332        None
333    } else {
334        Some(symbol_id)
335    };
336    let symbol = if msg.symbol.is_empty() {
337        None
338    } else {
339        Some(msg.symbol.clone())
340    };
341    let details = trigger_details_from_proto(msg, symbol.clone(), symbol_id_opt);
342    let proj = trigger_config_projection(msg, symbol.clone());
343    // Fall back to stop runtime details for the trigger-price convenience field.
344    let trigger_price = proj
345        .trigger_price
346        .clone()
347        .or_else(|| trigger_price_from_details(&details));
348    Trigger {
349        trigger_id: format_uint64_id(msg.trigger_id),
350        subaccount_id: format_uint64_id(msg.subaccount_id),
351        symbol_id,
352        symbol: msg.symbol.clone(),
353        trigger_type: proj.trigger_type,
354        status: enum_value_trigger_status(msg.status),
355        parent_order_id: msg.parent_order_id.map(format_uint64_id),
356        side: proj.side,
357        order_type: proj.order_type,
358        time_in_force: proj.time_in_force,
359        qty: decode_qty_scaled(msg.qty_scaled, None, symbol.clone(), symbol_id_opt),
360        limit_price: proj.limit_price,
361        fee_asset: fee_asset_label(msg.fee_asset),
362        self_trade_prevention_mode: stp_mode_label(msg.self_trade_prevention_mode),
363        post_only: proj.post_only,
364        trigger_price,
365        client_trigger_id: msg.client_trigger_id.clone(),
366        created_at: clone_timestamp(msg.created_at.as_option()),
367        updated_at: clone_timestamp(msg.updated_at.as_option()),
368        armed_at: clone_timestamp(msg.armed_at.as_option()),
369        completed_at: clone_timestamp(msg.completed_at.as_option()),
370        details,
371    }
372}
373
374pub fn triggers_list_from_proto(msg: &ListTriggersResponse) -> TriggersList {
375    let triggers: Vec<_> = msg.triggers.iter().map(trigger_from_proto).collect();
376    let total = triggers.len();
377    TriggersList {
378        triggers,
379        total,
380        next_page_token: msg.next_page_token.clone(),
381    }
382}
383
384pub fn get_trigger_from_proto(msg: &GetTriggerResponse) -> Option<Trigger> {
385    msg.trigger.as_option().map(trigger_from_proto)
386}
387
388fn trigger_mutation(
389    trigger_id: u64,
390    status: buffa::EnumValue<TriggerStatus>,
391) -> crate::errors::Result<TriggerMutationResult> {
392    let status = enum_value_trigger_status(status);
393    if trigger_id == 0 || status.is_empty() {
394        return Err(crate::Error::transport(
395            "invalid trigger mutation response: missing trigger_id or status",
396        ));
397    }
398    Ok(TriggerMutationResult {
399        trigger_id: format_uint64_id(trigger_id),
400        client_trigger_id: String::new(),
401        status,
402    })
403}
404
405/// `CreateTriggerResponse` acknowledges admission only and no longer carries a
406/// status field; synthesize `"accepted"`.
407pub fn trigger_mutation_from_create(
408    msg: &CreateTriggerResponse,
409) -> crate::errors::Result<TriggerMutationResult> {
410    if msg.trigger_id == 0 || msg.client_trigger_id.trim().is_empty() {
411        return Err(crate::Error::transport(
412            "invalid CreateTrigger response: missing trigger_id or client_trigger_id",
413        ));
414    }
415    Ok(TriggerMutationResult {
416        trigger_id: format_uint64_id(msg.trigger_id),
417        client_trigger_id: msg.client_trigger_id.clone(),
418        status: "accepted".to_owned(),
419    })
420}
421
422pub fn trigger_mutation_from_cancel(
423    msg: &CancelTriggerResponse,
424) -> crate::errors::Result<TriggerMutationResult> {
425    trigger_mutation(msg.trigger_id, msg.status)
426}
427
428pub fn trigger_mutation_from_pause(
429    msg: &PauseTriggerResponse,
430) -> crate::errors::Result<TriggerMutationResult> {
431    trigger_mutation(msg.trigger_id, msg.status)
432}
433
434pub fn trigger_mutation_from_resume(
435    msg: &ResumeTriggerResponse,
436) -> crate::errors::Result<TriggerMutationResult> {
437    trigger_mutation(msg.trigger_id, msg.status)
438}
439
440pub fn trigger_mutation_from_modify(
441    msg: &ModifyTriggerResponse,
442) -> crate::errors::Result<TriggerMutationResult> {
443    trigger_mutation(msg.trigger_id, msg.status)
444}
445
446fn trigger_event_type_label(value: buffa::EnumValue<TriggerEventType>) -> String {
447    match value.as_known() {
448        Some(TriggerEventType::EventFired) => "fired".to_owned(),
449        Some(TriggerEventType::EventCanceled) => "canceled".to_owned(),
450        Some(TriggerEventType::EventUpdated) => "updated".to_owned(),
451        Some(TriggerEventType::EventUnspecified) => String::new(),
452        None => format!("UNKNOWN({})", value.to_i32()),
453    }
454}
455
456fn trigger_type_label(value: buffa::EnumValue<TriggerType>) -> String {
457    match value.as_known() {
458        Some(TriggerType::TriggerTypeUnspecified) => String::new(),
459        Some(known) => known
460            .proto_name()
461            .trim_start_matches("TRIGGER_TYPE_")
462            .to_ascii_lowercase(),
463        None => format!("UNKNOWN({})", value.to_i32()),
464    }
465}
466
467/// Parse a trigger event type filter label into a proto enum.
468pub fn trigger_event_type_from_label(label: &str) -> Result<TriggerEventType, String> {
469    match label.trim().to_ascii_lowercase().as_str() {
470        "fired" => Ok(TriggerEventType::EventFired),
471        "canceled" | "cancelled" => Ok(TriggerEventType::EventCanceled),
472        "updated" => Ok(TriggerEventType::EventUpdated),
473        other => Err(format!(
474            "invalid trigger event type {other:?}; expected one of: fired, canceled, updated"
475        )),
476    }
477}
478
479pub fn trigger_event_from_proto(msg: &ProtoTriggerEvent) -> TriggerEvent {
480    TriggerEvent {
481        trigger_id: format_uint64_id(msg.trigger_id),
482        subaccount_id: if msg.subaccount_id == 0 {
483            String::new()
484        } else {
485            format_uint64_id(msg.subaccount_id)
486        },
487        symbol_id: msg.symbol_id,
488        trigger_type: trigger_type_label(msg.trigger_type),
489        event_type: trigger_event_type_label(msg.event_type),
490        ts_ns: if msg.ts_ns == 0 {
491            String::new()
492        } else {
493            msg.ts_ns.to_string()
494        },
495        child_seq: msg.child_seq,
496        child_order_id: if msg.child_order_id == 0 {
497            String::new()
498        } else {
499            format_uint64_id(msg.child_order_id)
500        },
501        fire_price: decode_price_ticks(msg.fire_price_ticks, None),
502        reason: msg.reason.clone(),
503    }
504}
505
506pub fn trigger_events_list_from_proto(msg: &ListTriggerEventsResponse) -> TriggerEventsList {
507    TriggerEventsList {
508        events: msg.events.iter().map(trigger_event_from_proto).collect(),
509        next_page_token: msg.next_page_token.clone(),
510    }
511}
512
513#[cfg(test)]
514mod tests {
515    use super::*;
516    use crate::proto::orders::v1::Side;
517    use crate::proto::triggers::v1::{
518        ConditionalChildExecution, ConditionalTrigger, GetTriggerResponse,
519        ListTriggerEventsResponse, ListTriggersResponse, StopDetails, TriggerEventType,
520        TriggerLimitGtc, TriggerType,
521    };
522
523    #[test]
524    fn trigger_from_proto_projects_attached_trailing_stop_side_and_parent() {
525        use crate::proto::triggers::v1::TrailingStopTrigger;
526
527        let msg = ProtoTrigger {
528            trigger_id: 77,
529            subaccount_id: 9,
530            symbol_id: 3,
531            symbol: "ETH-USDT".into(),
532            status: TriggerStatus::StatusArmed.into(),
533            parent_order_id: Some(9001),
534            qty_scaled: 100,
535            client_trigger_id: "trail-attached".into(),
536            configuration: Some(trigger::Configuration::TrailingStop(Box::new(
537                TrailingStopTrigger {
538                    side: Side::Buy.into(),
539                    trailing_distance: Some(
540                        crate::proto::triggers::v1::trailing_stop_trigger::TrailingDistance::TrailingDistanceBps(
541                            50,
542                        ),
543                    ),
544                    ..Default::default()
545                },
546            ))),
547            ..Default::default()
548        };
549        let t = trigger_from_proto(&msg);
550        assert_eq!(t.trigger_type, "trailing_stop");
551        assert_eq!(t.side, "buy");
552        assert_eq!(t.order_type, "market");
553        assert_eq!(t.time_in_force, "ioc");
554        assert_eq!(t.parent_order_id, Some(format_uint64_id(9001)));
555    }
556
557    #[test]
558    fn trigger_from_proto_maps_status_and_stop_price() {
559        let msg = ProtoTrigger {
560            trigger_id: 42,
561            subaccount_id: 9,
562            symbol_id: 3,
563            symbol: "ETH-USDT".into(),
564            status: TriggerStatus::StatusArmed.into(),
565            qty_scaled: 100,
566            client_trigger_id: "cid".into(),
567            configuration: Some(trigger::Configuration::StopLoss(Box::new(
568                ConditionalTrigger {
569                    trigger_price_ticks: 5000,
570                    side: Side::Buy.into(),
571                    child: ConditionalChildExecution {
572                        execution: Some(conditional_child_execution::Execution::LimitGtc(
573                            Box::new(TriggerLimitGtc {
574                                price_ticks: 4990,
575                                post_only: true,
576                                ..Default::default()
577                            }),
578                        )),
579                        ..Default::default()
580                    }
581                    .into(),
582                    ..Default::default()
583                },
584            ))),
585            runtime_details: Some(trigger::RuntimeDetails::Stop(Box::new(StopDetails {
586                trigger_price_ticks: 5000,
587                ..Default::default()
588            }))),
589            ..Default::default()
590        };
591        let t = trigger_from_proto(&msg);
592        assert_eq!(t.trigger_id, format_uint64_id(42));
593        assert_eq!(t.subaccount_id, format_uint64_id(9));
594        assert_eq!(t.trigger_type, "stop_loss");
595        assert_eq!(t.status, "armed");
596        assert_eq!(t.side, "buy");
597        assert_eq!(t.order_type, "limit");
598        assert_eq!(t.time_in_force, "gtc");
599        assert!(t.post_only);
600        assert_eq!(t.limit_price.as_ref().unwrap().as_ticks(), 4990);
601        assert_eq!(t.qty.as_ref().unwrap().as_scaled(), 100);
602        assert_eq!(t.trigger_price.as_ref().unwrap().as_ticks(), 5000);
603        assert_eq!(t.client_trigger_id, "cid");
604        assert!(matches!(t.details, Some(TriggerDetails::Stop(_))));
605    }
606
607    #[test]
608    fn trigger_from_proto_projects_twap_executed_qty() {
609        use crate::proto::triggers::v1::{TwapDetails, TwapTrigger};
610
611        let msg = ProtoTrigger {
612            trigger_id: 11,
613            symbol_id: 1,
614            symbol: "BTC-USDT".into(),
615            status: TriggerStatus::StatusRunning.into(),
616            qty_scaled: 100_000_000,
617            client_trigger_id: "twap-1".into(),
618            configuration: Some(trigger::Configuration::Twap(Box::new(TwapTrigger {
619                side: Side::Buy.into(),
620                duration_ms: 60_000,
621                slice_interval_ms: 5_000,
622                execution: Some(twap_trigger::Execution::MarketIoc(Box::default())),
623                ..Default::default()
624            }))),
625            runtime_details: Some(trigger::RuntimeDetails::TwapState(Box::new(TwapDetails {
626                twap_duration_ms: 60_000,
627                twap_slice_interval_ms: 5_000,
628                slice_idx: 2,
629                slice_count: 12,
630                executed_qty_scaled: 25_000_000,
631                ..Default::default()
632            }))),
633            ..Default::default()
634        };
635        let t = trigger_from_proto(&msg);
636        assert_eq!(t.trigger_type, "twap");
637        assert_eq!(t.side, "buy");
638        assert_eq!(t.order_type, "market");
639        let Some(TriggerDetails::Twap(twap)) = t.details.as_ref() else {
640            panic!("expected twap details");
641        };
642        assert_eq!(twap.slice_idx, 2);
643        assert_eq!(twap.slice_count, 12);
644        assert_eq!(twap.executed_qty.as_ref().unwrap().as_scaled(), 25_000_000);
645    }
646
647    #[test]
648    fn trigger_status_from_label_validates() {
649        assert_eq!(
650            trigger_status_from_label("armed").unwrap(),
651            TriggerStatus::StatusArmed
652        );
653        assert_eq!(
654            trigger_status_from_label("cancelled").unwrap(),
655            TriggerStatus::StatusCanceled
656        );
657        assert!(trigger_status_from_label("nope").is_err());
658    }
659
660    #[test]
661    fn singular_trigger_mutations_reject_empty_success_responses() {
662        assert!(trigger_mutation_from_create(&CreateTriggerResponse::default()).is_err());
663        assert!(trigger_mutation_from_cancel(&CancelTriggerResponse::default()).is_err());
664        assert!(trigger_mutation_from_pause(&PauseTriggerResponse::default()).is_err());
665        assert!(trigger_mutation_from_resume(&ResumeTriggerResponse::default()).is_err());
666        assert!(trigger_mutation_from_modify(&ModifyTriggerResponse::default()).is_err());
667
668        let created = trigger_mutation_from_create(&CreateTriggerResponse {
669            trigger_id: 7,
670            client_trigger_id: "stable-trigger".into(),
671            ..Default::default()
672        })
673        .unwrap();
674        assert_eq!(created.client_trigger_id, "stable-trigger");
675    }
676
677    #[test]
678    fn triggers_list_and_get() {
679        let listed = triggers_list_from_proto(&ListTriggersResponse {
680            triggers: vec![ProtoTrigger {
681                trigger_id: 1,
682                symbol_id: 1,
683                ..Default::default()
684            }],
685            next_page_token: "trig-page-2".into(),
686            ..Default::default()
687        });
688        assert_eq!(listed.triggers.len(), 1);
689        assert_eq!(listed.total, 1);
690        assert_eq!(listed.next_page_token, "trig-page-2");
691
692        let got = get_trigger_from_proto(&GetTriggerResponse {
693            trigger: ProtoTrigger {
694                trigger_id: 3,
695                symbol_id: 1,
696                ..Default::default()
697            }
698            .into(),
699            ..Default::default()
700        });
701        assert_eq!(got.unwrap().trigger_id, format_uint64_id(3));
702    }
703
704    #[test]
705    fn trigger_events_list_keeps_next_page_token() {
706        let listed = trigger_events_list_from_proto(&ListTriggerEventsResponse {
707            events: vec![ProtoTriggerEvent {
708                trigger_id: 1,
709                subaccount_id: 9,
710                symbol_id: 2,
711                trigger_type: TriggerType::TakeProfit.into(),
712                event_type: TriggerEventType::EventFired.into(),
713                ts_ns: 123,
714                child_seq: 3,
715                child_order_id: 77,
716                fire_price_ticks: 100,
717                reason: "hit".into(),
718                ..Default::default()
719            }],
720            next_page_token: "evt-page-2".into(),
721            ..Default::default()
722        });
723        assert_eq!(listed.events.len(), 1);
724        assert_eq!(listed.next_page_token, "evt-page-2");
725        let event = &listed.events[0];
726        assert_eq!(event.event_type, "fired");
727        assert_eq!(event.trigger_type, "take_profit");
728        assert_eq!(event.subaccount_id, format_uint64_id(9));
729        assert_eq!(event.child_seq, 3);
730        assert_eq!(event.child_order_id, format_uint64_id(77));
731        assert_eq!(event.fire_price.as_ref().unwrap().as_ticks(), 100);
732        assert_eq!(event.reason, "hit");
733    }
734
735    #[test]
736    fn trigger_event_preserves_unknown_event_type_number() {
737        let event = trigger_event_from_proto(&ProtoTriggerEvent {
738            trigger_id: 1,
739            event_type: buffa::EnumValue::Unknown(321),
740            ..Default::default()
741        });
742        assert_eq!(event.event_type, "UNKNOWN(321)");
743    }
744
745    #[test]
746    fn trigger_event_type_from_label_validates() {
747        assert_eq!(
748            trigger_event_type_from_label("fired").unwrap(),
749            TriggerEventType::EventFired
750        );
751        assert_eq!(
752            trigger_event_type_from_label("canceled").unwrap(),
753            TriggerEventType::EventCanceled
754        );
755        assert!(trigger_event_type_from_label("nope").is_err());
756    }
757}