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!(order_from_proto(&missing_distance).attached_risk.is_none());
835
836 let sl_msg = ProtoOrder {
838 order_id: 2,
839 symbol_id: 1,
840 attached_risk: ProtoAttachedRisk {
841 stop_loss: crate::proto::orders::v1::AttachedRiskStopLoss {
842 policy: crate::proto::orders::v1::StopLossPolicy {
843 trigger_price_ticks: 4900,
844 child: RiskExecution {
845 execution: Some(risk_execution::Execution::LimitGtc(Box::new(
846 RiskLimitGtc {
847 price_ticks: 4890,
848 ..Default::default()
849 },
850 ))),
851 ..Default::default()
852 }
853 .into(),
854 ..Default::default()
855 }
856 .into(),
857 ..Default::default()
858 }
859 .into(),
860 ..Default::default()
861 }
862 .into(),
863 ..Default::default()
864 };
865 let sl = order_from_proto(&sl_msg)
866 .attached_risk
867 .expect("attached_risk")
868 .stop_loss
869 .expect("stop_loss");
870 assert_eq!(sl.trigger_price.as_ticks(), 4900);
871 assert_eq!(sl.order_type, Some(CreateOrderType::Limit));
872 assert_eq!(sl.limit_price.as_ref().unwrap().as_ticks(), 4890);
873 }
874
875 #[test]
876 fn filled_order_preserves_zero_leaves_and_cum_qty() {
877 let msg = ProtoOrder {
878 order_id: 1,
879 symbol_id: 1,
880 orig_qty_scaled: 100,
881 cum_qty_scaled: 100,
882 leaves_qty_scaled: 0,
883 ..Default::default()
884 };
885 let order = order_from_proto(&msg);
886 assert_eq!(order.cum_qty.as_ref().map(|q| q.as_scaled()), Some(100));
887 assert_eq!(order.leaves_qty.as_ref().map(|q| q.as_scaled()), Some(0));
888 }
889
890 #[test]
891 fn orders_list_from_open_proto() {
892 let msg = GetOpenOrdersResponse {
893 orders: vec![ProtoOrder {
894 order_id: 1,
895 symbol_id: 1,
896 side: Side::Sell.into(),
897 ..Default::default()
898 }],
899 next_page_token: "tok".into(),
900 ..Default::default()
901 };
902 let result = orders_list_from_open(&msg);
903 assert_eq!(result.orders.len(), 1);
904 assert_eq!(result.next_page_token, "tok");
905 assert_eq!(result.orders[0].side, "sell");
906 }
907
908 #[test]
909 fn get_order_includes_trades() {
910 let msg = GetOrderResponse {
911 order: ProtoOrder {
912 order_id: 7,
913 symbol_id: 2,
914 ..Default::default()
915 }
916 .into(),
917 trades: vec![ProtoUserTrade {
918 symbol_id: 2,
919 match_id: 99,
920 order_id: 7,
921 side: Side::Buy.into(),
922 fee_amount_e18: crate::proto::polyester::r#type::v1::U128 {
923 hi: 0,
924 lo: 5,
925 ..Default::default()
926 }
927 .into(),
928 fee_asset: crate::proto::orders::v1::FeeAsset::Base.into(),
929 referral_share_amount_e18: crate::proto::polyester::r#type::v1::U128 {
930 hi: 0,
931 lo: 2,
932 ..Default::default()
933 }
934 .into(),
935 fee_is_rebate: true,
936 ..Default::default()
937 }],
938 ..Default::default()
939 };
940 let result = get_order_from_proto(&msg);
941 assert_eq!(result.order.as_ref().unwrap().order_id, format_uint64_id(7));
942 assert_eq!(result.trades.len(), 1);
943 assert_eq!(result.trades[0].match_id, "99");
944 assert_eq!(result.trades[0].fee_amount_e18, "5");
945 assert_eq!(result.trades[0].fee_asset, "base");
946 assert_eq!(result.trades[0].referral_share_amount_e18, "2");
947 assert!(result.trades[0].fee_is_rebate);
948 }
949
950 #[test]
951 fn modify_and_mutation_results() {
952 use crate::proto::orders::v1::{
953 CancelOrderResponse, CreateOrderResponse, ModifyActionTaken, ModifyOrderResponse,
954 };
955 let modified = modify_order_from_proto(&ModifyOrderResponse {
956 action_taken: ModifyActionTaken::Amended.into(),
957 old_order_id: 10,
958 final_order_id: 11,
959 code: "ok".into(),
960 ..Default::default()
961 })
962 .unwrap();
963 assert_eq!(modified.action_taken, "amended");
964 assert_eq!(modified.old_order_id, format_uint64_id(10));
965 assert_eq!(modified.final_order_id, format_uint64_id(11));
966
967 let created = order_mutation_from_create(&CreateOrderResponse {
968 order_id: 42,
969 client_order_id: "coid-1".into(),
970 ..Default::default()
971 })
972 .unwrap();
973 assert_eq!(created.status, "accepted");
974 assert_eq!(created.order_id, format_uint64_id(42));
975 assert_eq!(created.client_order_id, "coid-1");
976
977 let created_without_client_id = order_mutation_from_create(&CreateOrderResponse {
978 order_id: 43,
979 client_order_id: String::new(),
980 ..Default::default()
981 })
982 .unwrap();
983 assert_eq!(created_without_client_id.order_id, format_uint64_id(43));
984 assert!(created_without_client_id.client_order_id.is_empty());
985
986 let cancelled = order_mutation_from_cancel(&CancelOrderResponse {
987 status: "cancelled".into(),
988 order_id: 42,
989 ..Default::default()
990 })
991 .unwrap();
992 assert_eq!(cancelled.status, "cancelled");
993 assert_eq!(cancelled.order_id, format_uint64_id(42));
994 assert!(cancelled.client_order_id.is_empty());
995 }
996
997 #[test]
998 fn singular_order_mutations_reject_empty_success_responses() {
999 use crate::proto::orders::v1::{
1000 CancelOrderResponse, CreateOrderResponse, ModifyOrderResponse,
1001 };
1002 assert!(order_mutation_from_create(&CreateOrderResponse::default()).is_err());
1003 assert!(order_mutation_from_cancel(&CancelOrderResponse::default()).is_err());
1004 assert!(modify_order_from_proto(&ModifyOrderResponse::default()).is_err());
1005 }
1006
1007 #[test]
1008 fn preview_order_surfaces_admission_sizing_and_protection() {
1009 use crate::proto::orders::v1::{
1010 ErrorCode, ErrorDetail, FieldViolation, PreviewOrderResponse,
1011 };
1012 use buffa_types::google::protobuf::Timestamp;
1013
1014 let preview = preview_order_from_proto(
1015 &PreviewOrderResponse {
1016 admissible: Some(false),
1017 rejection: ErrorDetail {
1018 code: ErrorCode::BadQty.into(),
1019 violations: vec![FieldViolation {
1020 field_path: "order.base_qty_scaled".into(),
1021 rule_id: "qty.min".into(),
1022 message: "quantity below minimum".into(),
1023 ..Default::default()
1024 }],
1025 ..Default::default()
1026 }
1027 .into(),
1028 resolved_base_qty_scaled: Some(100),
1029 protected_price_bound_ticks: Some(5_000),
1030 evaluated_at: Timestamp {
1031 seconds: 1,
1032 nanos: 250_000_000,
1033 ..Default::default()
1034 }
1035 .into(),
1036 ..Default::default()
1037 },
1038 8,
1039 "BTC-USDT",
1040 Some(1),
1041 )
1042 .unwrap();
1043 assert_eq!(preview.admissible, Some(false));
1044 let rejection = preview.rejection.expect("rejection");
1045 assert_eq!(rejection.code, "BAD_QTY");
1046 assert_eq!(rejection.violations.len(), 1);
1047 assert_eq!(rejection.violations[0].field_path, "order.base_qty_scaled");
1048 assert_eq!(
1049 preview
1050 .resolved_base_qty
1051 .as_ref()
1052 .map(|qty| qty.as_scaled()),
1053 Some(100)
1054 );
1055 assert_eq!(
1056 preview
1057 .protected_price_bound
1058 .as_ref()
1059 .map(|price| price.as_ticks()),
1060 Some(5_000)
1061 );
1062 assert_eq!(preview.evaluated_at_ms, 1_250);
1063 }
1064
1065 #[test]
1066 fn preview_order_preserves_unknown_rejection_code() {
1067 use crate::proto::orders::v1::{ErrorDetail, PreviewOrderResponse};
1068 use buffa_types::google::protobuf::Timestamp;
1069
1070 let preview = preview_order_from_proto(
1071 &PreviewOrderResponse {
1072 admissible: Some(false),
1073 rejection: ErrorDetail {
1074 code: buffa::EnumValue::from(999),
1075 ..Default::default()
1076 }
1077 .into(),
1078 resolved_base_qty_scaled: Some(0),
1079 protected_price_bound_ticks: Some(0),
1080 evaluated_at: Timestamp {
1081 seconds: 1,
1082 ..Default::default()
1083 }
1084 .into(),
1085 ..Default::default()
1086 },
1087 8,
1088 "BTC-USDT",
1089 Some(1),
1090 )
1091 .unwrap();
1092 assert_eq!(
1093 preview.rejection.as_ref().map(|r| r.code.as_str()),
1094 Some("UNKNOWN_ERROR_CODE(999)")
1095 );
1096 assert_eq!(
1097 preview
1098 .resolved_base_qty
1099 .as_ref()
1100 .map(|qty| qty.as_scaled()),
1101 Some(0)
1102 );
1103 assert_eq!(
1104 preview
1105 .protected_price_bound
1106 .as_ref()
1107 .map(|price| price.as_ticks()),
1108 Some(0)
1109 );
1110 assert_eq!(preview.evaluated_at_ms, 1_000);
1111 }
1112
1113 #[test]
1114 fn preview_order_rejects_missing_evaluated_at() {
1115 use crate::proto::orders::v1::PreviewOrderResponse;
1116
1117 let err =
1118 preview_order_from_proto(&PreviewOrderResponse::default(), 8, "BTC-USDT", Some(1))
1119 .expect_err("missing evaluated_at must fail");
1120 assert!(err.to_string().contains("missing evaluated_at"));
1121 }
1122
1123 #[test]
1124 fn batch_create_from_proto_maps_counts() {
1125 use crate::proto::orders::v1::{
1126 BatchCreateAccepted, BatchCreateResultItem as ProtoItem, batch_create_result_item,
1127 };
1128 let msg = BatchCreateOrdersResponse {
1129 results: vec![ProtoItem {
1130 client_order_id: "c1".into(),
1131 outcome: Some(batch_create_result_item::Outcome::Accepted(Box::new(
1132 BatchCreateAccepted {
1133 order_id: 9,
1134 ..Default::default()
1135 },
1136 ))),
1137 ..Default::default()
1138 }],
1139 accepted_count: 1,
1140 rejected_count: 0,
1141 ..Default::default()
1142 };
1143 let result = batch_create_from_proto(&msg).expect("valid batch response");
1144 assert_eq!(result.accepted_count, 1);
1145 assert_eq!(result.results[0].status, "accepted");
1146 assert_eq!(result.results[0].order_id, format_uint64_id(9));
1147 assert_eq!(result.results[0].client_order_id, "c1");
1148 }
1149
1150 #[test]
1151 fn batch_create_rejects_missing_outcome() {
1152 use crate::proto::orders::v1::BatchCreateResultItem as ProtoItem;
1153
1154 let msg = BatchCreateOrdersResponse {
1155 results: vec![ProtoItem {
1156 client_order_id: "ambiguous".into(),
1157 ..Default::default()
1158 }],
1159 ..Default::default()
1160 };
1161
1162 let err = batch_create_from_proto(&msg).expect_err("missing outcome must fail closed");
1163 assert!(matches!(&err, Error::ResponseContract { .. }));
1164 assert!(!err.is_retryable());
1165 assert!(err.mutation_outcome_unknown());
1166 assert!(err.to_string().contains("neither accepted nor rejected"));
1167 }
1168
1169 #[test]
1170 fn batch_create_preserves_unknown_rejection_code() {
1171 use crate::proto::orders::v1::{
1172 BatchCreateRejected, BatchCreateResultItem as ProtoItem, ErrorDetail,
1173 batch_create_result_item,
1174 };
1175
1176 let msg = BatchCreateOrdersResponse {
1177 results: vec![ProtoItem {
1178 client_order_id: "unknown-code".into(),
1179 outcome: Some(batch_create_result_item::Outcome::Rejected(Box::new(
1180 BatchCreateRejected {
1181 error: ErrorDetail {
1182 code: buffa::EnumValue::from(999),
1183 ..Default::default()
1184 }
1185 .into(),
1186 ..Default::default()
1187 },
1188 ))),
1189 ..Default::default()
1190 }],
1191 accepted_count: 0,
1192 rejected_count: 1,
1193 ..Default::default()
1194 };
1195
1196 let result = batch_create_from_proto(&msg).expect("unknown code stays observable");
1197 assert_eq!(result.results[0].code, "UNKNOWN_ERROR_CODE(999)");
1198 }
1199
1200 #[test]
1201 fn batch_create_rejects_count_mismatch() {
1202 use crate::proto::orders::v1::{
1203 BatchCreateAccepted, BatchCreateResultItem as ProtoItem, batch_create_result_item,
1204 };
1205
1206 let msg = BatchCreateOrdersResponse {
1207 results: vec![ProtoItem {
1208 client_order_id: "accepted".into(),
1209 outcome: Some(batch_create_result_item::Outcome::Accepted(Box::new(
1210 BatchCreateAccepted {
1211 order_id: 9,
1212 ..Default::default()
1213 },
1214 ))),
1215 ..Default::default()
1216 }],
1217 accepted_count: 0,
1218 rejected_count: 1,
1219 ..Default::default()
1220 };
1221
1222 let err = batch_create_from_proto(&msg).expect_err("count mismatch must fail closed");
1223 assert!(matches!(&err, Error::ResponseContract { .. }));
1224 assert!(!err.is_retryable());
1225 assert!(err.mutation_outcome_unknown());
1226 assert!(err.to_string().contains("response counts"));
1227 }
1228
1229 #[test]
1230 fn batch_cancel_rejects_count_mismatch_and_unknown_status() {
1231 use crate::proto::orders::v1::BatchCancelResultItem as ProtoItem;
1232
1233 let mismatch = BatchCancelOrdersResponse {
1234 results: vec![ProtoItem {
1235 status: "accepted".into(),
1236 order_id: 9,
1237 ..Default::default()
1238 }],
1239 accepted_count: 0,
1240 rejected_count: 1,
1241 ..Default::default()
1242 };
1243 let err = batch_cancel_from_proto(&mismatch).expect_err("count mismatch must fail closed");
1244 assert!(matches!(&err, Error::ResponseContract { .. }));
1245 assert!(!err.is_retryable());
1246 assert!(err.mutation_outcome_unknown());
1247 assert!(err.to_string().contains("response counts"));
1248
1249 let unknown = BatchCancelOrdersResponse {
1250 results: vec![ProtoItem {
1251 status: "maybe".into(),
1252 ..Default::default()
1253 }],
1254 accepted_count: 1,
1255 ..Default::default()
1256 };
1257 let err = batch_cancel_from_proto(&unknown).expect_err("unknown status must fail closed");
1258 assert!(err.to_string().contains("unknown item status"));
1259 }
1260
1261 #[test]
1262 fn batch_replace_reconciles_admission_counts_and_decodes_status() {
1263 use crate::proto::orders::v1::{
1264 BatchReplaceAdmissionItem as ProtoAdmissionItem, BatchReplaceAdmissionStatus,
1265 BatchReplaceItemAdmissionStatus, BatchReplaceOrdersResponse, BatchReplacePhase,
1266 BatchReplaceStatusItem as ProtoStatusItem, GetBatchReplaceStatusResponse, OrderStatus,
1267 };
1268
1269 let valid = BatchReplaceOrdersResponse {
1270 batch_request_id: 9,
1271 status: BatchReplaceAdmissionStatus::PartiallyAdmitted.into(),
1272 results: vec![
1273 ProtoAdmissionItem {
1274 item_index: 0,
1275 status: BatchReplaceItemAdmissionStatus::Admitted.into(),
1276 old_order_id: 1,
1277 replacement_order_id: 2,
1278 ..Default::default()
1279 },
1280 ProtoAdmissionItem {
1281 item_index: 1,
1282 status: BatchReplaceItemAdmissionStatus::Rejected.into(),
1283 old_order_id: 3,
1284 code: "REJECTED".into(),
1285 ..Default::default()
1286 },
1287 ],
1288 accepted_count: 1,
1289 rejected_count: 1,
1290 ..Default::default()
1291 };
1292 let decoded = batch_replace_from_proto(&valid).expect("consistent response");
1293 assert_eq!(decoded.batch_request_id, format_uint64_id(9));
1294 assert_eq!(decoded.status, "partially_admitted");
1295 assert_eq!(decoded.accepted_count, 1);
1296 assert_eq!(decoded.rejected_count, 1);
1297 assert_eq!(decoded.results[0].status, "admitted");
1298
1299 let mismatch = BatchReplaceOrdersResponse {
1300 accepted_count: 2,
1301 ..valid.clone()
1302 };
1303 let err = batch_replace_from_proto(&mismatch).expect_err("count mismatch must fail closed");
1304 assert!(matches!(&err, Error::ResponseContract { .. }));
1305 assert!(!err.is_retryable());
1306 assert!(err.mutation_outcome_unknown());
1307 assert!(err.to_string().contains("response counts"));
1308
1309 let status = batch_replace_status_from_proto(&GetBatchReplaceStatusResponse {
1310 batch_request_id: 9,
1311 admission_status: BatchReplaceAdmissionStatus::Admitted.into(),
1312 items: vec![
1313 ProtoStatusItem {
1314 item_index: 0,
1315 phase: BatchReplacePhase::Working.into(),
1316 old_order_id: 1,
1317 replacement_order_id: 2,
1318 order_status: OrderStatus::Working.into(),
1319 ..Default::default()
1320 },
1321 ProtoStatusItem {
1322 item_index: 1,
1323 phase: BatchReplacePhase::Terminal.into(),
1324 old_order_id: 3,
1325 replacement_order_id: 4,
1326 order_status: OrderStatus::Filled.into(),
1327 ..Default::default()
1328 },
1329 ],
1330 accepted_count: 2,
1331 rejected_count: 0,
1332 ..Default::default()
1333 })
1334 .expect("known phases decode");
1335 assert_eq!(status.admission_status, "admitted");
1336 assert_eq!(status.items[0].phase, "working");
1337 assert_eq!(status.items[1].phase, "terminal");
1338 assert!(status.is_settled());
1339
1340 let mismatch = GetBatchReplaceStatusResponse {
1341 batch_request_id: 9,
1342 admission_status: BatchReplaceAdmissionStatus::PartiallyAdmitted.into(),
1343 items: vec![
1344 ProtoStatusItem {
1345 item_index: 0,
1346 phase: BatchReplacePhase::Admitted.into(),
1347 ..Default::default()
1348 },
1349 ProtoStatusItem {
1350 item_index: 1,
1351 phase: BatchReplacePhase::Rejected.into(),
1352 ..Default::default()
1353 },
1354 ],
1355 accepted_count: 2,
1356 rejected_count: 0,
1357 ..Default::default()
1358 };
1359 let err =
1360 batch_replace_status_from_proto(&mismatch).expect_err("status counts must reconcile");
1361 assert!(matches!(err, Error::ResponseContract { .. }));
1362 assert!(err.mutation_outcome_unknown());
1363 assert!(err.to_string().contains("response counts"));
1364 }
1365
1366 #[test]
1367 fn cancel_all_requires_known_status() {
1368 let valid = CancelAllOrdersResponse {
1369 status: "submitted".into(),
1370 matched_orders: 2,
1371 submitted_cancels: 2,
1372 failed_cancels: 0,
1373 ..Default::default()
1374 };
1375 let decoded = cancel_all_from_proto(&valid).expect("submitted is valid");
1376 assert_eq!(decoded.status, "submitted");
1377 assert_eq!(decoded.matched_orders, 2);
1378
1379 let dry_run = CancelAllOrdersResponse {
1380 status: "dry_run".into(),
1381 matched_orders: 3,
1382 ..Default::default()
1383 };
1384 assert!(cancel_all_from_proto(&dry_run).is_ok());
1385
1386 let mismatched = CancelAllOrdersResponse {
1387 status: "submitted".into(),
1388 matched_orders: 3,
1389 submitted_cancels: 1,
1390 failed_cancels: 1,
1391 ..Default::default()
1392 };
1393 let err = cancel_all_from_proto(&mismatched).expect_err("cancel-all counts must reconcile");
1394 assert!(matches!(&err, Error::ResponseContract { .. }));
1395 assert!(!err.is_retryable());
1396 assert!(err.mutation_outcome_unknown());
1397 assert!(err.to_string().contains("response counts"));
1398
1399 for status in ["", "ok", "maybe", "accepted"] {
1400 let bad = CancelAllOrdersResponse {
1401 status: status.into(),
1402 matched_orders: 1,
1403 ..Default::default()
1404 };
1405 let err = cancel_all_from_proto(&bad).expect_err("unknown cancel-all status");
1406 assert!(matches!(&err, Error::ResponseContract { .. }));
1407 assert!(!err.is_retryable());
1408 assert!(err.mutation_outcome_unknown());
1409 assert!(err.to_string().contains("unknown status"));
1410 }
1411 }
1412
1413 #[test]
1414 fn cancel_all_after_requires_known_status() {
1415 let armed = CancelAllAfterResponse {
1416 status: "armed".into(),
1417 effective_timeout_sec: 30,
1418 expires_at_ts_ns: 99,
1419 ..Default::default()
1420 };
1421 let decoded = cancel_all_after_from_proto(&armed).expect("armed is valid");
1422 assert_eq!(decoded.status, "armed");
1423 assert_eq!(decoded.effective_timeout_sec, 30);
1424 assert_eq!(decoded.expires_at_ts_ns, "99");
1425
1426 let disabled = CancelAllAfterResponse {
1427 status: "disabled".into(),
1428 ..Default::default()
1429 };
1430 assert!(cancel_all_after_from_proto(&disabled).is_ok());
1431
1432 for status in ["", "ok", "submitted", "maybe"] {
1433 let bad = CancelAllAfterResponse {
1434 status: status.into(),
1435 effective_timeout_sec: 10,
1436 ..Default::default()
1437 };
1438 let err =
1439 cancel_all_after_from_proto(&bad).expect_err("unknown cancel-all-after status");
1440 assert!(err.to_string().contains("unknown status"));
1441 }
1442 }
1443}