1use super::enums::{
4 enum_value_fee_asset, enum_value_order_status, enum_value_order_type, enum_value_side,
5 enum_value_time_in_force,
6};
7use super::money::{decode_price_ticks, decode_qty_scaled, decode_qty_scaled_allow_zero};
8use crate::codecs::scalars::format_uint64_id;
9use crate::errors::{Error, Result};
10use crate::models::{
11 AttachedRisk, BatchCancelOrdersResult, BatchCancelResultItem, BatchCreateOrdersResult,
12 BatchCreateResultItem, BatchReplaceAdmissionItem, BatchReplaceOrdersResult,
13 BatchReplaceStatusItem, BatchReplaceStatusResult, CancelAllAfterResult, CancelAllOrdersResult,
14 CreateOrderType, GetOrderResult, MaxSlippage, ModifyOrderResult, Order, OrderMutationResult,
15 OrdersList, PreviewOrderResult, RiskLeg, TrailingDistance, TrailingStop, UserTrade,
16 UserTradesList,
17};
18use crate::proto::orders::v1::{
19 AttachedRisk as ProtoAttachedRisk, BatchCancelOrdersResponse, BatchCreateOrdersResponse,
20 BatchReplaceAdmissionStatus, BatchReplaceItemAdmissionStatus, BatchReplaceOrdersResponse,
21 BatchReplacePhase, CancelAllAfterResponse, CancelAllOrdersResponse, CancelOrderResponse,
22 CreateOrderResponse, GetBatchReplaceStatusResponse, GetOpenOrdersResponse,
23 GetOrderHistoryResponse, GetOrderResponse, GetUserTradesResponse, ModifyOrderResponse,
24 Order as ProtoOrder, PreviewOrderResponse, RiskExecution, StopLossPolicy, TakeProfitPolicy,
25 TrailingStopPolicy, UserTrade as ProtoUserTrade, batch_create_result_item, risk_execution,
26 trailing_stop_policy,
27};
28use crate::types::{Quantity, QuantityDomain};
29use buffa::Enumeration;
30
31pub fn order_from_proto(msg: &ProtoOrder) -> Order {
32 let symbol_id = msg.symbol_id;
33 let symbol_id_opt = if symbol_id == 0 {
34 None
35 } else {
36 Some(symbol_id)
37 };
38 Order {
39 order_id: format_uint64_id(msg.order_id),
40 symbol_id,
41 client_order_id: msg.client_order_id.clone(),
42 side: enum_value_side(msg.side).to_owned(),
43 status: enum_value_order_status(msg.status).to_owned(),
44 order_type: enum_value_order_type(msg.order_type).to_owned(),
45 tif: enum_value_time_in_force(msg.time_in_force).to_owned(),
46 orig_qty: decode_qty_scaled(msg.orig_qty_scaled, None, None, symbol_id_opt),
47 cum_qty: decode_qty_scaled_allow_zero(msg.cum_qty_scaled, None, None, symbol_id_opt),
48 leaves_qty: decode_qty_scaled_allow_zero(msg.leaves_qty_scaled, None, None, symbol_id_opt),
49 price: decode_price_ticks(msg.price_ticks, None),
50 avg_px: decode_price_ticks(msg.avg_price_ticks, None),
51 created_ts_ns: if msg.created_ts_ns == 0 {
52 String::new()
53 } else {
54 msg.created_ts_ns.to_string()
55 },
56 version: msg.version,
57 post_only: msg.post_only,
58 fee_asset: enum_value_fee_asset(msg.fee_asset),
59 submitted_max_quote_debit_scaled: msg.submitted_max_quote_debit_scaled,
60 attached_risk: msg
61 .attached_risk
62 .as_option()
63 .and_then(attached_risk_from_proto),
64 }
65}
66
67#[allow(deprecated)]
71fn decode_risk_leg(trigger_price_ticks: i64, child: Option<&RiskExecution>) -> Option<RiskLeg> {
72 if trigger_price_ticks == 0 {
73 return None;
74 }
75 let mut order_type = None;
76 let mut limit_price = None;
77 if let Some(child) = child {
78 match child.execution.as_ref() {
79 Some(risk_execution::Execution::MarketIoc(_)) => {
80 order_type = Some(CreateOrderType::Market);
81 }
82 Some(risk_execution::Execution::LimitGtc(limit)) => {
83 order_type = Some(CreateOrderType::Limit);
84 limit_price = decode_price_ticks(limit.price_ticks, None);
85 }
86 None => {}
87 }
88 }
89 Some(RiskLeg {
90 trigger_price: decode_price_ticks(trigger_price_ticks, None)?,
91 trigger_price_source: None,
92 order_type,
93 limit_price,
94 })
95}
96
97fn risk_leg_from_take_profit(policy: &TakeProfitPolicy) -> Option<RiskLeg> {
98 decode_risk_leg(policy.trigger_price_ticks, policy.child.as_option())
99}
100
101fn risk_leg_from_stop_loss(policy: &StopLossPolicy) -> Option<RiskLeg> {
102 decode_risk_leg(policy.trigger_price_ticks, policy.child.as_option())
103}
104
105fn trailing_stop_from_policy(policy: &TrailingStopPolicy) -> TrailingStop {
106 let distance = match policy.trailing_distance.as_ref() {
107 Some(trailing_stop_policy::TrailingDistance::TrailingDistanceTicks(v)) => {
108 TrailingDistance::Ticks(*v)
109 }
110 Some(trailing_stop_policy::TrailingDistance::TrailingDistanceBps(v)) => {
111 TrailingDistance::Bps(*v)
112 }
113 None => TrailingDistance::Ticks(0),
114 };
115 let max_slippage = match policy.max_slippage.as_ref() {
116 Some(trailing_stop_policy::MaxSlippage::MaxSlippageTicks(v)) => {
117 Some(MaxSlippage::Ticks(*v))
118 }
119 Some(trailing_stop_policy::MaxSlippage::MaxSlippageBps(v)) => Some(MaxSlippage::Bps(*v)),
120 None => None,
121 };
122 TrailingStop {
123 distance,
124 activation_price: if policy.activation_price_ticks > 0 {
125 decode_price_ticks(policy.activation_price_ticks, None)
126 } else {
127 None
128 },
129 trigger_price_source: None,
132 order_type: None,
133 max_slippage,
134 }
135}
136
137fn attached_risk_from_proto(msg: &ProtoAttachedRisk) -> Option<AttachedRisk> {
138 let take_profit = msg
139 .take_profit
140 .as_option()
141 .and_then(|leg| leg.policy.as_option().and_then(risk_leg_from_take_profit));
142 let trailing_stop = msg
143 .trailing_stop
144 .as_option()
145 .and_then(|leg| leg.policy.as_option().map(trailing_stop_from_policy));
146 let stop_loss = if trailing_stop.is_some() {
148 None
149 } else {
150 msg.stop_loss
151 .as_option()
152 .and_then(|leg| leg.policy.as_option().and_then(risk_leg_from_stop_loss))
153 };
154 if take_profit.is_none() && stop_loss.is_none() && trailing_stop.is_none() {
155 return None;
156 }
157 Some(AttachedRisk {
158 take_profit,
159 stop_loss,
160 trailing_stop,
161 oco: msg.oco,
162 })
163}
164
165pub fn orders_list_from_open(msg: &GetOpenOrdersResponse) -> OrdersList {
166 orders_list(&msg.orders, &msg.next_page_token)
167}
168
169pub fn orders_list_from_history(msg: &GetOrderHistoryResponse) -> OrdersList {
170 orders_list(&msg.orders, &msg.next_page_token)
171}
172
173fn orders_list(orders: &[ProtoOrder], next_page_token: &str) -> OrdersList {
174 OrdersList {
175 orders: orders.iter().map(order_from_proto).collect(),
176 next_page_token: next_page_token.to_owned(),
177 }
178}
179
180pub fn user_trade_from_proto(msg: &ProtoUserTrade) -> UserTrade {
181 let symbol_id = msg.symbol_id;
182 let symbol_id_opt = if symbol_id == 0 {
183 None
184 } else {
185 Some(symbol_id)
186 };
187 UserTrade {
188 symbol_id,
189 match_id: if msg.match_id == 0 {
190 String::new()
191 } else {
192 msg.match_id.to_string()
193 },
194 order_id: format_uint64_id(msg.order_id),
195 side: enum_value_side(msg.side).to_owned(),
196 is_maker: msg.is_maker,
197 price: decode_price_ticks(msg.price_ticks, None),
198 qty: decode_qty_scaled(msg.qty_scaled, None, None, symbol_id_opt),
199 fee_scaled: if msg.fee_scaled == 0 {
200 String::new()
201 } else {
202 msg.fee_scaled.to_string()
203 },
204 fee_asset: enum_value_fee_asset(msg.fee_asset),
205 referral_share_scaled: if msg.referral_share_scaled == 0 {
206 String::new()
207 } else {
208 msg.referral_share_scaled.to_string()
209 },
210 ts_ns: if msg.ts_ns == 0 {
211 String::new()
212 } else {
213 msg.ts_ns.to_string()
214 },
215 }
216}
217
218pub fn user_trades_list_from_proto(msg: &GetUserTradesResponse) -> UserTradesList {
219 UserTradesList {
220 trades: msg.trades.iter().map(user_trade_from_proto).collect(),
221 next_page_token: msg.next_page_token.clone(),
222 }
223}
224
225pub fn get_order_from_proto(msg: &GetOrderResponse) -> GetOrderResult {
226 let order = msg.order.as_option().map(order_from_proto);
227 let trades = msg.trades.iter().map(user_trade_from_proto).collect();
228 GetOrderResult { order, trades }
229}
230
231pub fn order_mutation_from_create(msg: &CreateOrderResponse) -> Result<OrderMutationResult> {
234 if msg.order_id == 0 {
236 return Err(Error::response_contract("CreateOrder", "missing order_id"));
237 }
238 let mut result = order_mutation("accepted", msg.order_id, &msg.client_order_id);
239 result.resolved_base_qty = decode_qty_scaled(msg.resolved_base_qty_scaled, None, None, None);
240 result.submitted_max_quote_debit_scaled = msg.submitted_max_quote_debit_scaled;
241 Ok(result)
242}
243
244pub fn order_mutation_from_cancel(msg: &CancelOrderResponse) -> Result<OrderMutationResult> {
245 if msg.order_id == 0 || msg.status.trim().is_empty() {
246 return Err(Error::response_contract(
247 "CancelOrder",
248 "missing order_id or status",
249 ));
250 }
251 Ok(order_mutation(&msg.status, msg.order_id, ""))
252}
253
254fn order_mutation(status: &str, order_id: u64, client_order_id: &str) -> OrderMutationResult {
255 OrderMutationResult {
256 status: status.to_owned(),
257 order_id: format_uint64_id(order_id),
258 client_order_id: client_order_id.to_owned(),
259 resolved_base_qty: None,
260 submitted_max_quote_debit_scaled: None,
261 }
262}
263
264pub fn preview_order_from_proto(
265 msg: &PreviewOrderResponse,
266 base_scale: u32,
267 quote_scale: u32,
268 symbol: &str,
269 symbol_id: Option<u32>,
270) -> Result<PreviewOrderResult> {
271 let fee_asset = enum_value_fee_asset(msg.fee_asset);
272 let (fee_domain, fee_scale) = match fee_asset.as_str() {
273 "base" => (QuantityDomain::OrderBase, base_scale),
274 "quote" => (QuantityDomain::OrderQuote, quote_scale),
275 other => {
276 return Err(Error::response_contract(
277 "PreviewOrder",
278 format!("unknown fee_asset {other:?}"),
279 ));
280 }
281 };
282 let symbol = Some(symbol.to_owned());
283 Ok(PreviewOrderResult {
284 resolved_base_qty: decode_qty_scaled(
285 msg.resolved_base_qty_scaled,
286 Some(base_scale),
287 symbol.clone(),
288 symbol_id,
289 ),
290 price_bound: decode_price_ticks(msg.price_bound_ticks, symbol.clone()),
291 estimated_quote_debit: Quantity::from_scaled(
292 msg.estimated_quote_debit_scaled,
293 Some(quote_scale),
294 QuantityDomain::OrderQuote,
295 symbol.clone(),
296 symbol_id,
297 )?,
298 estimated_fee: Quantity::from_scaled(
299 msg.estimated_fee_scaled,
300 Some(fee_scale),
301 fee_domain,
302 symbol.clone(),
303 symbol_id,
304 )?,
305 estimated_net_base_qty: decode_qty_scaled(
306 msg.estimated_net_base_qty_scaled,
307 Some(base_scale),
308 symbol,
309 symbol_id,
310 ),
311 fee_asset,
312 fresh_at_ts_ns: msg.fresh_at_ts_ns,
313 })
314}
315
316pub fn modify_order_from_proto(msg: &ModifyOrderResponse) -> Result<ModifyOrderResult> {
317 let action_taken = modify_action_name(msg.action_taken);
318 if action_taken.is_empty() || msg.old_order_id == 0 || msg.final_order_id == 0 {
319 return Err(Error::response_contract(
320 "ModifyOrder",
321 "missing action_taken, old_order_id, or final_order_id",
322 ));
323 }
324 Ok(ModifyOrderResult {
325 action_taken,
326 old_order_id: format_uint64_id(msg.old_order_id),
327 final_order_id: format_uint64_id(msg.final_order_id),
328 code: msg.code.clone(),
329 })
330}
331
332fn modify_action_name(
333 action: buffa::EnumValue<crate::proto::orders::v1::ModifyActionTaken>,
334) -> String {
335 use crate::proto::orders::v1::ModifyActionTaken;
336 match action.as_known() {
337 Some(ModifyActionTaken::Amended) => "amended".to_owned(),
338 Some(ModifyActionTaken::Replaced) => "replaced".to_owned(),
339 Some(_) => String::new(),
340 None => format!("UNKNOWN({})", action.to_i32()),
341 }
342}
343
344pub fn cancel_all_from_proto(msg: &CancelAllOrdersResponse) -> Result<CancelAllOrdersResult> {
345 let status = msg.status.trim();
346 if status.is_empty()
347 || !(status.eq_ignore_ascii_case("submitted") || status.eq_ignore_ascii_case("dry_run"))
348 {
349 return Err(Error::response_contract(
350 "CancelAllOrders",
351 format!("unknown status {:?}", msg.status),
352 ));
353 }
354 if status.eq_ignore_ascii_case("submitted")
355 && msg
356 .submitted_cancels
357 .checked_add(msg.failed_cancels)
358 .filter(|total| *total == msg.matched_orders)
359 .is_none()
360 {
361 return Err(Error::response_contract(
362 "CancelAllOrders",
363 format!(
364 "response counts mismatch: matched {}, submitted {}, failed {}",
365 msg.matched_orders, msg.submitted_cancels, msg.failed_cancels
366 ),
367 ));
368 }
369 if status.eq_ignore_ascii_case("dry_run")
370 && (msg.submitted_cancels != 0 || msg.failed_cancels != 0)
371 {
372 return Err(Error::response_contract(
373 "CancelAllOrders",
374 format!(
375 "dry_run reported submitted or failed cancels: submitted {}, failed {}",
376 msg.submitted_cancels, msg.failed_cancels
377 ),
378 ));
379 }
380 Ok(CancelAllOrdersResult {
381 status: msg.status.clone(),
382 matched_orders: msg.matched_orders,
383 submitted_cancels: msg.submitted_cancels,
384 failed_cancels: msg.failed_cancels,
385 })
386}
387
388pub fn batch_create_from_proto(msg: &BatchCreateOrdersResponse) -> Result<BatchCreateOrdersResult> {
391 let mut accepted = 0usize;
392 let mut rejected = 0usize;
393 let results = msg
394 .results
395 .iter()
396 .map(|item| {
397 let mut out = BatchCreateResultItem {
398 status: String::new(),
399 order_id: String::new(),
400 client_order_id: item.client_order_id.clone(),
401 code: String::new(),
402 };
403 match item.outcome.as_ref() {
404 Some(batch_create_result_item::Outcome::Accepted(value)) => {
405 accepted += 1;
406 out.status = "accepted".to_owned();
407 out.order_id = format_uint64_id(value.order_id);
408 }
409 Some(batch_create_result_item::Outcome::Rejected(value)) => {
410 rejected += 1;
411 out.status = "rejected".to_owned();
412 out.code = value
413 .error
414 .as_option()
415 .map(|err| match err.code.as_known() {
416 Some(code) => code.proto_name().to_owned(),
417 None => format!("UNKNOWN_ERROR_CODE({})", err.code.to_i32()),
418 })
419 .unwrap_or_else(|| "ERROR_CODE_UNSPECIFIED".to_owned());
420 }
421 None => {
422 return Err(Error::response_contract(
423 "BatchCreateOrders",
424 format!(
425 "item {:?} has neither accepted nor rejected outcome",
426 item.client_order_id
427 ),
428 ));
429 }
430 }
431 Ok(out)
432 })
433 .collect::<Result<Vec<_>>>()?;
434
435 if accepted != msg.accepted_count as usize
436 || rejected != msg.rejected_count as usize
437 || accepted + rejected != results.len()
438 {
439 return Err(Error::response_contract(
440 "BatchCreateOrders",
441 format!(
442 "response counts mismatch: decoded {accepted} accepted/{rejected} rejected for {} results, server reported {}/{}",
443 results.len(),
444 msg.accepted_count,
445 msg.rejected_count
446 ),
447 ));
448 }
449
450 Ok(BatchCreateOrdersResult {
451 results,
452 accepted_count: msg.accepted_count,
453 rejected_count: msg.rejected_count,
454 })
455}
456
457pub fn batch_cancel_from_proto(msg: &BatchCancelOrdersResponse) -> Result<BatchCancelOrdersResult> {
458 let mut accepted = 0u32;
459 let mut rejected = 0u32;
460 let mut results = Vec::with_capacity(msg.results.len());
461 for item in &msg.results {
462 if item.status.eq_ignore_ascii_case("accepted") {
463 accepted += 1;
464 } else if item.status.eq_ignore_ascii_case("rejected") {
465 rejected += 1;
466 } else {
467 return Err(Error::response_contract(
468 "BatchCancelOrders",
469 format!("unknown item status {:?}", item.status),
470 ));
471 }
472 results.push(BatchCancelResultItem {
473 status: item.status.clone(),
474 order_id: format_uint64_id(item.order_id),
475 client_order_id: item.client_order_id.clone(),
476 code: item.code.clone(),
477 });
478 }
479 if accepted != msg.accepted_count
480 || rejected != msg.rejected_count
481 || usize::try_from(accepted + rejected).ok() != Some(results.len())
482 {
483 return Err(Error::response_contract(
484 "BatchCancelOrders",
485 format!(
486 "response counts mismatch: decoded {accepted} accepted/{rejected} rejected for {} results, server reported {}/{}",
487 results.len(),
488 msg.accepted_count,
489 msg.rejected_count
490 ),
491 ));
492 }
493 Ok(BatchCancelOrdersResult {
494 results,
495 accepted_count: msg.accepted_count,
496 rejected_count: msg.rejected_count,
497 })
498}
499
500pub fn batch_replace_from_proto(
501 msg: &BatchReplaceOrdersResponse,
502) -> Result<BatchReplaceOrdersResult> {
503 if msg.batch_request_id == 0 {
504 return Err(Error::response_contract(
505 "BatchReplaceOrders",
506 "missing batch_request_id",
507 ));
508 }
509 let status = batch_replace_admission_status_name(msg.status).ok_or_else(|| {
510 Error::response_contract(
511 "BatchReplaceOrders",
512 format!("unknown admission status {}", msg.status.to_i32()),
513 )
514 })?;
515 let mut accepted = 0u32;
516 let mut rejected = 0u32;
517 let mut results = Vec::with_capacity(msg.results.len());
518 for item in &msg.results {
519 let item_status =
520 batch_replace_item_admission_status_name(item.status).ok_or_else(|| {
521 Error::response_contract(
522 "BatchReplaceOrders",
523 format!("unknown item status {}", item.status.to_i32()),
524 )
525 })?;
526 match item_status {
527 "admitted" => accepted += 1,
528 "rejected" => rejected += 1,
529 _ => unreachable!("known item admission status"),
530 }
531 results.push(BatchReplaceAdmissionItem {
532 item_index: item.item_index,
533 status: item_status.to_owned(),
534 old_order_id: format_uint64_id(item.old_order_id),
535 replacement_order_id: format_uint64_id(item.replacement_order_id),
536 client_order_id: item.client_order_id.clone(),
537 code: item.code.clone(),
538 });
539 }
540 if accepted != msg.accepted_count
541 || rejected != msg.rejected_count
542 || accepted
543 .checked_add(rejected)
544 .and_then(|count| usize::try_from(count).ok())
545 != Some(results.len())
546 {
547 return Err(Error::response_contract(
548 "BatchReplaceOrders",
549 format!(
550 "response counts mismatch: decoded {accepted} accepted/{rejected} rejected for {} results, server reported {}/{}",
551 results.len(),
552 msg.accepted_count,
553 msg.rejected_count
554 ),
555 ));
556 }
557 Ok(BatchReplaceOrdersResult {
558 batch_request_id: format_uint64_id(msg.batch_request_id),
559 status: status.to_owned(),
560 results,
561 accepted_count: msg.accepted_count,
562 rejected_count: msg.rejected_count,
563 accepted_ts_ns: msg.accepted_ts_ns,
564 })
565}
566
567pub fn batch_replace_status_from_proto(
568 msg: &GetBatchReplaceStatusResponse,
569) -> Result<BatchReplaceStatusResult> {
570 if msg.batch_request_id == 0 {
571 return Err(Error::response_contract(
572 "GetBatchReplaceStatus",
573 "missing batch_request_id",
574 ));
575 }
576 let admission_status =
577 batch_replace_admission_status_name(msg.admission_status).ok_or_else(|| {
578 Error::response_contract(
579 "GetBatchReplaceStatus",
580 format!("unknown admission status {}", msg.admission_status.to_i32()),
581 )
582 })?;
583 let mut admitted = 0u32;
584 let mut rejected = 0u32;
585 let mut items = Vec::with_capacity(msg.items.len());
586 for item in &msg.items {
587 let phase = batch_replace_phase_name(item.phase).ok_or_else(|| {
588 Error::response_contract(
589 "GetBatchReplaceStatus",
590 format!("unknown batch replace phase {}", item.phase.to_i32()),
591 )
592 })?;
593 match phase {
594 "admitted" => admitted += 1,
595 "rejected" => rejected += 1,
596 "working" | "terminal" => admitted += 1,
599 _ => unreachable!("known batch replace phase"),
600 }
601 items.push(BatchReplaceStatusItem {
602 item_index: item.item_index,
603 phase: phase.to_owned(),
604 old_order_id: format_uint64_id(item.old_order_id),
605 replacement_order_id: format_uint64_id(item.replacement_order_id),
606 order_status: enum_value_order_status(item.order_status).to_owned(),
607 code: item.code.clone(),
608 updated_ts_ns: item.updated_ts_ns,
609 });
610 }
611 if admitted != msg.accepted_count
612 || rejected != msg.rejected_count
613 || admitted
614 .checked_add(rejected)
615 .and_then(|count| usize::try_from(count).ok())
616 != Some(items.len())
617 {
618 return Err(Error::response_contract(
619 "GetBatchReplaceStatus",
620 format!(
621 "response counts mismatch: decoded {admitted} admitted/{rejected} rejected for {} items, server reported {}/{}",
622 items.len(),
623 msg.accepted_count,
624 msg.rejected_count
625 ),
626 ));
627 }
628 Ok(BatchReplaceStatusResult {
629 batch_request_id: format_uint64_id(msg.batch_request_id),
630 admission_status: admission_status.to_owned(),
631 items,
632 accepted_count: msg.accepted_count,
633 rejected_count: msg.rejected_count,
634 accepted_ts_ns: msg.accepted_ts_ns,
635 updated_ts_ns: msg.updated_ts_ns,
636 })
637}
638
639fn batch_replace_admission_status_name(
640 status: buffa::EnumValue<BatchReplaceAdmissionStatus>,
641) -> Option<&'static str> {
642 match status.as_known() {
643 Some(BatchReplaceAdmissionStatus::Admitted) => Some("admitted"),
644 Some(BatchReplaceAdmissionStatus::PartiallyAdmitted) => Some("partially_admitted"),
645 Some(BatchReplaceAdmissionStatus::Rejected) => Some("rejected"),
646 _ => None,
647 }
648}
649
650fn batch_replace_item_admission_status_name(
651 status: buffa::EnumValue<BatchReplaceItemAdmissionStatus>,
652) -> Option<&'static str> {
653 match status.as_known() {
654 Some(BatchReplaceItemAdmissionStatus::Admitted) => Some("admitted"),
655 Some(BatchReplaceItemAdmissionStatus::Rejected) => Some("rejected"),
656 _ => None,
657 }
658}
659
660fn batch_replace_phase_name(phase: buffa::EnumValue<BatchReplacePhase>) -> Option<&'static str> {
661 match phase.as_known() {
662 Some(BatchReplacePhase::Admitted) => Some("admitted"),
663 Some(BatchReplacePhase::Working) => Some("working"),
664 Some(BatchReplacePhase::Rejected) => Some("rejected"),
665 Some(BatchReplacePhase::Terminal) => Some("terminal"),
666 _ => None,
667 }
668}
669
670pub fn cancel_all_after_from_proto(msg: &CancelAllAfterResponse) -> Result<CancelAllAfterResult> {
671 let status = msg.status.trim();
672 if status.is_empty()
673 || !(status.eq_ignore_ascii_case("armed") || status.eq_ignore_ascii_case("disabled"))
674 {
675 return Err(Error::response_contract(
676 "CancelAllAfter",
677 format!("unknown status {:?}", msg.status),
678 ));
679 }
680 Ok(CancelAllAfterResult {
681 status: msg.status.clone(),
682 effective_timeout_sec: msg.effective_timeout_sec,
683 expires_at_ts_ns: if msg.expires_at_ts_ns == 0 {
684 String::new()
685 } else {
686 msg.expires_at_ts_ns.to_string()
687 },
688 })
689}
690
691#[cfg(test)]
692mod tests {
693 use super::*;
694 use crate::codecs::scalars::format_uint64_id;
695 use crate::proto::orders::v1::{
696 GetOpenOrdersResponse, GetOrderResponse, OrderStatus, OrderType, Side, TimeInForce,
697 };
698
699 #[test]
700 fn order_from_proto_maps_enums_and_ids() {
701 let msg = ProtoOrder {
702 order_id: 42,
703 symbol_id: 3,
704 client_order_id: "coid-1".into(),
705 side: Side::Buy.into(),
706 status: OrderStatus::Working.into(),
707 order_type: OrderType::Limit.into(),
708 time_in_force: TimeInForce::Gtc.into(),
709 orig_qty_scaled: 100,
710 cum_qty_scaled: 10,
711 leaves_qty_scaled: 90,
712 price_ticks: 5000,
713 avg_price_ticks: 4990,
714 created_ts_ns: 1_700_000_000_000,
715 post_only: true,
716 ..Default::default()
717 };
718 let order = order_from_proto(&msg);
719 assert_eq!(order.order_id, format_uint64_id(42));
720 assert_eq!(order.side, "buy");
721 assert_eq!(order.status, "working");
722 assert_eq!(order.order_type, "limit");
723 assert_eq!(order.tif, "gtc");
724 assert!(order.post_only);
725 assert!(order.attached_risk.is_none());
726 assert_eq!(order.orig_qty.as_ref().unwrap().as_scaled(), 100);
727 let mut msg2 = msg;
728 msg2.version = 7;
729 let order2 = order_from_proto(&msg2);
730 assert_eq!(order2.version, 7);
731 }
732
733 #[test]
734 #[allow(deprecated)]
735 fn order_from_proto_maps_attached_risk_policy() {
736 use crate::models::{CreateOrderType, TrailingDistance};
737 use crate::proto::orders::v1::{
738 AttachedRisk as ProtoAttachedRisk, AttachedRiskTakeProfit, AttachedRiskTrailingStop,
739 RiskExecution, RiskLimitGtc, TakeProfitPolicy, TrailingStopPolicy, risk_execution,
740 trailing_stop_policy,
741 };
742
743 let msg = ProtoOrder {
744 order_id: 1,
745 symbol_id: 1,
746 post_only: false,
747 attached_risk: ProtoAttachedRisk {
748 take_profit: AttachedRiskTakeProfit {
749 policy: TakeProfitPolicy {
750 trigger_price_ticks: 6000,
751 child: RiskExecution {
752 execution: Some(risk_execution::Execution::MarketIoc(Box::default())),
753 ..Default::default()
754 }
755 .into(),
756 ..Default::default()
757 }
758 .into(),
759 ..Default::default()
760 }
761 .into(),
762 trailing_stop: AttachedRiskTrailingStop {
763 policy: TrailingStopPolicy {
764 activation_price_ticks: 5500,
765 trailing_distance: Some(
766 trailing_stop_policy::TrailingDistance::TrailingDistanceBps(25),
767 ),
768 max_slippage: Some(trailing_stop_policy::MaxSlippage::MaxSlippageTicks(10)),
769 ..Default::default()
770 }
771 .into(),
772 ..Default::default()
773 }
774 .into(),
775 oco: true,
776 ..Default::default()
777 }
778 .into(),
779 ..Default::default()
780 };
781 let order = order_from_proto(&msg);
782 let risk = order.attached_risk.expect("attached_risk");
783 assert!(risk.oco);
784 let tp = risk.take_profit.expect("take_profit");
785 assert_eq!(tp.trigger_price.as_ticks(), 6000);
786 assert!(tp.trigger_price_source.is_none());
788 assert_eq!(tp.order_type, Some(CreateOrderType::Market));
789 assert!(tp.limit_price.is_none());
790 assert!(risk.stop_loss.is_none());
791 let trailing = risk.trailing_stop.expect("trailing_stop");
792 assert_eq!(trailing.distance, TrailingDistance::Bps(25));
793 assert_eq!(
794 trailing.max_slippage,
795 Some(crate::models::MaxSlippage::Ticks(10))
796 );
797 assert_eq!(trailing.activation_price.as_ref().unwrap().as_ticks(), 5500);
798 assert!(trailing.trigger_price_source.is_none());
799 assert!(trailing.order_type.is_none());
800
801 let sl_msg = ProtoOrder {
803 order_id: 2,
804 symbol_id: 1,
805 attached_risk: ProtoAttachedRisk {
806 stop_loss: crate::proto::orders::v1::AttachedRiskStopLoss {
807 policy: crate::proto::orders::v1::StopLossPolicy {
808 trigger_price_ticks: 4900,
809 child: RiskExecution {
810 execution: Some(risk_execution::Execution::LimitGtc(Box::new(
811 RiskLimitGtc {
812 price_ticks: 4890,
813 ..Default::default()
814 },
815 ))),
816 ..Default::default()
817 }
818 .into(),
819 ..Default::default()
820 }
821 .into(),
822 ..Default::default()
823 }
824 .into(),
825 ..Default::default()
826 }
827 .into(),
828 ..Default::default()
829 };
830 let sl = order_from_proto(&sl_msg)
831 .attached_risk
832 .expect("attached_risk")
833 .stop_loss
834 .expect("stop_loss");
835 assert_eq!(sl.trigger_price.as_ticks(), 4900);
836 assert_eq!(sl.order_type, Some(CreateOrderType::Limit));
837 assert_eq!(sl.limit_price.as_ref().unwrap().as_ticks(), 4890);
838 }
839
840 #[test]
841 fn filled_order_preserves_zero_leaves_and_cum_qty() {
842 let msg = ProtoOrder {
843 order_id: 1,
844 symbol_id: 1,
845 orig_qty_scaled: 100,
846 cum_qty_scaled: 100,
847 leaves_qty_scaled: 0,
848 ..Default::default()
849 };
850 let order = order_from_proto(&msg);
851 assert_eq!(order.cum_qty.as_ref().map(|q| q.as_scaled()), Some(100));
852 assert_eq!(order.leaves_qty.as_ref().map(|q| q.as_scaled()), Some(0));
853 }
854
855 #[test]
856 fn orders_list_from_open_proto() {
857 let msg = GetOpenOrdersResponse {
858 orders: vec![ProtoOrder {
859 order_id: 1,
860 symbol_id: 1,
861 side: Side::Sell.into(),
862 ..Default::default()
863 }],
864 next_page_token: "tok".into(),
865 ..Default::default()
866 };
867 let result = orders_list_from_open(&msg);
868 assert_eq!(result.orders.len(), 1);
869 assert_eq!(result.next_page_token, "tok");
870 assert_eq!(result.orders[0].side, "sell");
871 }
872
873 #[test]
874 fn get_order_includes_trades() {
875 let msg = GetOrderResponse {
876 order: ProtoOrder {
877 order_id: 7,
878 symbol_id: 2,
879 ..Default::default()
880 }
881 .into(),
882 trades: vec![ProtoUserTrade {
883 symbol_id: 2,
884 match_id: 99,
885 order_id: 7,
886 side: Side::Buy.into(),
887 fee_scaled: 5,
888 fee_asset: crate::proto::orders::v1::FeeAsset::Base.into(),
889 referral_share_scaled: 2,
890 ..Default::default()
891 }],
892 ..Default::default()
893 };
894 let result = get_order_from_proto(&msg);
895 assert_eq!(result.order.as_ref().unwrap().order_id, format_uint64_id(7));
896 assert_eq!(result.trades.len(), 1);
897 assert_eq!(result.trades[0].match_id, "99");
898 assert_eq!(result.trades[0].fee_scaled, "5");
899 assert_eq!(result.trades[0].fee_asset, "base");
900 assert_eq!(result.trades[0].referral_share_scaled, "2");
901 }
902
903 #[test]
904 fn modify_and_mutation_results() {
905 use crate::proto::orders::v1::{
906 CancelOrderResponse, CreateOrderResponse, ModifyActionTaken, ModifyOrderResponse,
907 };
908 let modified = modify_order_from_proto(&ModifyOrderResponse {
909 action_taken: ModifyActionTaken::Amended.into(),
910 old_order_id: 10,
911 final_order_id: 11,
912 code: "ok".into(),
913 ..Default::default()
914 })
915 .unwrap();
916 assert_eq!(modified.action_taken, "amended");
917 assert_eq!(modified.old_order_id, format_uint64_id(10));
918 assert_eq!(modified.final_order_id, format_uint64_id(11));
919
920 let created = order_mutation_from_create(&CreateOrderResponse {
921 order_id: 42,
922 client_order_id: "coid-1".into(),
923 ..Default::default()
924 })
925 .unwrap();
926 assert_eq!(created.status, "accepted");
927 assert_eq!(created.order_id, format_uint64_id(42));
928 assert_eq!(created.client_order_id, "coid-1");
929
930 let created_without_client_id = order_mutation_from_create(&CreateOrderResponse {
931 order_id: 43,
932 client_order_id: String::new(),
933 ..Default::default()
934 })
935 .unwrap();
936 assert_eq!(created_without_client_id.order_id, format_uint64_id(43));
937 assert!(created_without_client_id.client_order_id.is_empty());
938
939 let cancelled = order_mutation_from_cancel(&CancelOrderResponse {
940 status: "cancelled".into(),
941 order_id: 42,
942 ..Default::default()
943 })
944 .unwrap();
945 assert_eq!(cancelled.status, "cancelled");
946 assert_eq!(cancelled.order_id, format_uint64_id(42));
947 assert!(cancelled.client_order_id.is_empty());
948 }
949
950 #[test]
951 fn singular_order_mutations_reject_empty_success_responses() {
952 use crate::proto::orders::v1::{
953 CancelOrderResponse, CreateOrderResponse, ModifyOrderResponse,
954 };
955 assert!(order_mutation_from_create(&CreateOrderResponse::default()).is_err());
956 assert!(order_mutation_from_cancel(&CancelOrderResponse::default()).is_err());
957 assert!(modify_order_from_proto(&ModifyOrderResponse::default()).is_err());
958 }
959
960 #[test]
961 fn preview_order_surfaces_resolved_sizing_and_fee_asset() {
962 use crate::proto::orders::v1::{FeeAsset, PreviewOrderResponse};
963
964 let preview = preview_order_from_proto(
965 &PreviewOrderResponse {
966 resolved_base_qty_scaled: 100,
967 price_bound_ticks: 5_000,
968 estimated_quote_debit_scaled: 510,
969 estimated_fee_scaled: 1,
970 estimated_net_base_qty_scaled: 99,
971 fee_asset: FeeAsset::Base.into(),
972 fresh_at_ts_ns: 42,
973 ..Default::default()
974 },
975 8,
976 6,
977 "BTC-USDT",
978 Some(1),
979 )
980 .unwrap();
981 assert_eq!(
982 preview
983 .resolved_base_qty
984 .as_ref()
985 .map(|qty| qty.as_scaled()),
986 Some(100)
987 );
988 assert_eq!(
989 preview.price_bound.as_ref().map(|price| price.as_ticks()),
990 Some(5_000)
991 );
992 assert_eq!(preview.estimated_quote_debit.as_scaled(), 510);
993 assert_eq!(
994 preview.estimated_quote_debit.domain(),
995 QuantityDomain::OrderQuote
996 );
997 assert_eq!(preview.estimated_quote_debit.scale(), Some(6));
998 assert_eq!(preview.estimated_fee.as_scaled(), 1);
999 assert_eq!(preview.estimated_fee.domain(), QuantityDomain::OrderBase);
1000 assert_eq!(preview.estimated_fee.scale(), Some(8));
1001 assert_eq!(preview.fee_asset, "base");
1002 assert_eq!(preview.fresh_at_ts_ns, 42);
1003 }
1004
1005 #[test]
1006 fn batch_create_from_proto_maps_counts() {
1007 use crate::proto::orders::v1::{
1008 BatchCreateAccepted, BatchCreateResultItem as ProtoItem, batch_create_result_item,
1009 };
1010 let msg = BatchCreateOrdersResponse {
1011 results: vec![ProtoItem {
1012 client_order_id: "c1".into(),
1013 outcome: Some(batch_create_result_item::Outcome::Accepted(Box::new(
1014 BatchCreateAccepted {
1015 order_id: 9,
1016 ..Default::default()
1017 },
1018 ))),
1019 ..Default::default()
1020 }],
1021 accepted_count: 1,
1022 rejected_count: 0,
1023 ..Default::default()
1024 };
1025 let result = batch_create_from_proto(&msg).expect("valid batch response");
1026 assert_eq!(result.accepted_count, 1);
1027 assert_eq!(result.results[0].status, "accepted");
1028 assert_eq!(result.results[0].order_id, format_uint64_id(9));
1029 assert_eq!(result.results[0].client_order_id, "c1");
1030 }
1031
1032 #[test]
1033 fn batch_create_rejects_missing_outcome() {
1034 use crate::proto::orders::v1::BatchCreateResultItem as ProtoItem;
1035
1036 let msg = BatchCreateOrdersResponse {
1037 results: vec![ProtoItem {
1038 client_order_id: "ambiguous".into(),
1039 ..Default::default()
1040 }],
1041 ..Default::default()
1042 };
1043
1044 let err = batch_create_from_proto(&msg).expect_err("missing outcome must fail closed");
1045 assert!(matches!(&err, Error::ResponseContract { .. }));
1046 assert!(!err.is_retryable());
1047 assert!(err.mutation_outcome_unknown());
1048 assert!(err.to_string().contains("neither accepted nor rejected"));
1049 }
1050
1051 #[test]
1052 fn batch_create_preserves_unknown_rejection_code() {
1053 use crate::proto::orders::v1::{
1054 BatchCreateRejected, BatchCreateResultItem as ProtoItem, ErrorDetail,
1055 batch_create_result_item,
1056 };
1057
1058 let msg = BatchCreateOrdersResponse {
1059 results: vec![ProtoItem {
1060 client_order_id: "unknown-code".into(),
1061 outcome: Some(batch_create_result_item::Outcome::Rejected(Box::new(
1062 BatchCreateRejected {
1063 error: ErrorDetail {
1064 code: buffa::EnumValue::from(999),
1065 ..Default::default()
1066 }
1067 .into(),
1068 ..Default::default()
1069 },
1070 ))),
1071 ..Default::default()
1072 }],
1073 accepted_count: 0,
1074 rejected_count: 1,
1075 ..Default::default()
1076 };
1077
1078 let result = batch_create_from_proto(&msg).expect("unknown code stays observable");
1079 assert_eq!(result.results[0].code, "UNKNOWN_ERROR_CODE(999)");
1080 }
1081
1082 #[test]
1083 fn batch_create_rejects_count_mismatch() {
1084 use crate::proto::orders::v1::{
1085 BatchCreateAccepted, BatchCreateResultItem as ProtoItem, batch_create_result_item,
1086 };
1087
1088 let msg = BatchCreateOrdersResponse {
1089 results: vec![ProtoItem {
1090 client_order_id: "accepted".into(),
1091 outcome: Some(batch_create_result_item::Outcome::Accepted(Box::new(
1092 BatchCreateAccepted {
1093 order_id: 9,
1094 ..Default::default()
1095 },
1096 ))),
1097 ..Default::default()
1098 }],
1099 accepted_count: 0,
1100 rejected_count: 1,
1101 ..Default::default()
1102 };
1103
1104 let err = batch_create_from_proto(&msg).expect_err("count mismatch must fail closed");
1105 assert!(matches!(&err, Error::ResponseContract { .. }));
1106 assert!(!err.is_retryable());
1107 assert!(err.mutation_outcome_unknown());
1108 assert!(err.to_string().contains("response counts"));
1109 }
1110
1111 #[test]
1112 fn batch_cancel_rejects_count_mismatch_and_unknown_status() {
1113 use crate::proto::orders::v1::BatchCancelResultItem as ProtoItem;
1114
1115 let mismatch = BatchCancelOrdersResponse {
1116 results: vec![ProtoItem {
1117 status: "accepted".into(),
1118 order_id: 9,
1119 ..Default::default()
1120 }],
1121 accepted_count: 0,
1122 rejected_count: 1,
1123 ..Default::default()
1124 };
1125 let err = batch_cancel_from_proto(&mismatch).expect_err("count mismatch must fail closed");
1126 assert!(matches!(&err, Error::ResponseContract { .. }));
1127 assert!(!err.is_retryable());
1128 assert!(err.mutation_outcome_unknown());
1129 assert!(err.to_string().contains("response counts"));
1130
1131 let unknown = BatchCancelOrdersResponse {
1132 results: vec![ProtoItem {
1133 status: "maybe".into(),
1134 ..Default::default()
1135 }],
1136 accepted_count: 1,
1137 ..Default::default()
1138 };
1139 let err = batch_cancel_from_proto(&unknown).expect_err("unknown status must fail closed");
1140 assert!(err.to_string().contains("unknown item status"));
1141 }
1142
1143 #[test]
1144 fn batch_replace_reconciles_admission_counts_and_decodes_status() {
1145 use crate::proto::orders::v1::{
1146 BatchReplaceAdmissionItem as ProtoAdmissionItem, BatchReplaceAdmissionStatus,
1147 BatchReplaceItemAdmissionStatus, BatchReplaceOrdersResponse, BatchReplacePhase,
1148 BatchReplaceStatusItem as ProtoStatusItem, GetBatchReplaceStatusResponse, OrderStatus,
1149 };
1150
1151 let valid = BatchReplaceOrdersResponse {
1152 batch_request_id: 9,
1153 status: BatchReplaceAdmissionStatus::PartiallyAdmitted.into(),
1154 results: vec![
1155 ProtoAdmissionItem {
1156 item_index: 0,
1157 status: BatchReplaceItemAdmissionStatus::Admitted.into(),
1158 old_order_id: 1,
1159 replacement_order_id: 2,
1160 ..Default::default()
1161 },
1162 ProtoAdmissionItem {
1163 item_index: 1,
1164 status: BatchReplaceItemAdmissionStatus::Rejected.into(),
1165 old_order_id: 3,
1166 code: "REJECTED".into(),
1167 ..Default::default()
1168 },
1169 ],
1170 accepted_count: 1,
1171 rejected_count: 1,
1172 ..Default::default()
1173 };
1174 let decoded = batch_replace_from_proto(&valid).expect("consistent response");
1175 assert_eq!(decoded.batch_request_id, format_uint64_id(9));
1176 assert_eq!(decoded.status, "partially_admitted");
1177 assert_eq!(decoded.accepted_count, 1);
1178 assert_eq!(decoded.rejected_count, 1);
1179 assert_eq!(decoded.results[0].status, "admitted");
1180
1181 let mismatch = BatchReplaceOrdersResponse {
1182 accepted_count: 2,
1183 ..valid.clone()
1184 };
1185 let err = batch_replace_from_proto(&mismatch).expect_err("count mismatch must fail closed");
1186 assert!(matches!(&err, Error::ResponseContract { .. }));
1187 assert!(!err.is_retryable());
1188 assert!(err.mutation_outcome_unknown());
1189 assert!(err.to_string().contains("response counts"));
1190
1191 let status = batch_replace_status_from_proto(&GetBatchReplaceStatusResponse {
1192 batch_request_id: 9,
1193 admission_status: BatchReplaceAdmissionStatus::Admitted.into(),
1194 items: vec![
1195 ProtoStatusItem {
1196 item_index: 0,
1197 phase: BatchReplacePhase::Working.into(),
1198 old_order_id: 1,
1199 replacement_order_id: 2,
1200 order_status: OrderStatus::Working.into(),
1201 ..Default::default()
1202 },
1203 ProtoStatusItem {
1204 item_index: 1,
1205 phase: BatchReplacePhase::Terminal.into(),
1206 old_order_id: 3,
1207 replacement_order_id: 4,
1208 order_status: OrderStatus::Filled.into(),
1209 ..Default::default()
1210 },
1211 ],
1212 accepted_count: 2,
1213 rejected_count: 0,
1214 ..Default::default()
1215 })
1216 .expect("known phases decode");
1217 assert_eq!(status.admission_status, "admitted");
1218 assert_eq!(status.items[0].phase, "working");
1219 assert_eq!(status.items[1].phase, "terminal");
1220 assert!(status.is_settled());
1221
1222 let mismatch = GetBatchReplaceStatusResponse {
1223 batch_request_id: 9,
1224 admission_status: BatchReplaceAdmissionStatus::PartiallyAdmitted.into(),
1225 items: vec![
1226 ProtoStatusItem {
1227 item_index: 0,
1228 phase: BatchReplacePhase::Admitted.into(),
1229 ..Default::default()
1230 },
1231 ProtoStatusItem {
1232 item_index: 1,
1233 phase: BatchReplacePhase::Rejected.into(),
1234 ..Default::default()
1235 },
1236 ],
1237 accepted_count: 2,
1238 rejected_count: 0,
1239 ..Default::default()
1240 };
1241 let err =
1242 batch_replace_status_from_proto(&mismatch).expect_err("status counts must reconcile");
1243 assert!(matches!(err, Error::ResponseContract { .. }));
1244 assert!(err.mutation_outcome_unknown());
1245 assert!(err.to_string().contains("response counts"));
1246 }
1247
1248 #[test]
1249 fn cancel_all_requires_known_status() {
1250 let valid = CancelAllOrdersResponse {
1251 status: "submitted".into(),
1252 matched_orders: 2,
1253 submitted_cancels: 2,
1254 failed_cancels: 0,
1255 ..Default::default()
1256 };
1257 let decoded = cancel_all_from_proto(&valid).expect("submitted is valid");
1258 assert_eq!(decoded.status, "submitted");
1259 assert_eq!(decoded.matched_orders, 2);
1260
1261 let dry_run = CancelAllOrdersResponse {
1262 status: "dry_run".into(),
1263 matched_orders: 3,
1264 ..Default::default()
1265 };
1266 assert!(cancel_all_from_proto(&dry_run).is_ok());
1267
1268 let mismatched = CancelAllOrdersResponse {
1269 status: "submitted".into(),
1270 matched_orders: 3,
1271 submitted_cancels: 1,
1272 failed_cancels: 1,
1273 ..Default::default()
1274 };
1275 let err = cancel_all_from_proto(&mismatched).expect_err("cancel-all counts must reconcile");
1276 assert!(matches!(&err, Error::ResponseContract { .. }));
1277 assert!(!err.is_retryable());
1278 assert!(err.mutation_outcome_unknown());
1279 assert!(err.to_string().contains("response counts"));
1280
1281 for status in ["", "ok", "maybe", "accepted"] {
1282 let bad = CancelAllOrdersResponse {
1283 status: status.into(),
1284 matched_orders: 1,
1285 ..Default::default()
1286 };
1287 let err = cancel_all_from_proto(&bad).expect_err("unknown cancel-all status");
1288 assert!(matches!(&err, Error::ResponseContract { .. }));
1289 assert!(!err.is_retryable());
1290 assert!(err.mutation_outcome_unknown());
1291 assert!(err.to_string().contains("unknown status"));
1292 }
1293 }
1294
1295 #[test]
1296 fn cancel_all_after_requires_known_status() {
1297 let armed = CancelAllAfterResponse {
1298 status: "armed".into(),
1299 effective_timeout_sec: 30,
1300 expires_at_ts_ns: 99,
1301 ..Default::default()
1302 };
1303 let decoded = cancel_all_after_from_proto(&armed).expect("armed is valid");
1304 assert_eq!(decoded.status, "armed");
1305 assert_eq!(decoded.effective_timeout_sec, 30);
1306 assert_eq!(decoded.expires_at_ts_ns, "99");
1307
1308 let disabled = CancelAllAfterResponse {
1309 status: "disabled".into(),
1310 ..Default::default()
1311 };
1312 assert!(cancel_all_after_from_proto(&disabled).is_ok());
1313
1314 for status in ["", "ok", "submitted", "maybe"] {
1315 let bad = CancelAllAfterResponse {
1316 status: status.into(),
1317 effective_timeout_sec: 10,
1318 ..Default::default()
1319 };
1320 let err =
1321 cancel_all_after_from_proto(&bad).expect_err("unknown cancel-all-after status");
1322 assert!(err.to_string().contains("unknown status"));
1323 }
1324 }
1325}