Skip to main content

polyester/codecs/decode/
orders.rs

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