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