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