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