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