1use 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#[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: 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 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
230pub fn order_mutation_from_create(msg: &CreateOrderResponse) -> Result<OrderMutationResult> {
233 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
352pub 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" | "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 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 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}