Skip to main content

polyester/codecs/decode/
orders.rs

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