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