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