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