1use 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
38pub 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#[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
217fn 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
256fn 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 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 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
405pub 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
467pub 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}