Skip to main content

projectx_client/
models.rs

1// SPDX-FileCopyrightText: 2026 Kevin Monaghan
2// SPDX-License-Identifier: MIT-0
3
4//! Provider-native request and response models.
5
6use std::collections::BTreeMap;
7
8use rust_decimal::Decimal;
9use serde::{Deserialize, Serialize};
10use serde_json::value::RawValue;
11use serde_repr::{Deserialize_repr, Serialize_repr};
12use thiserror::Error;
13
14use crate::{
15    AccountId, ContractId, OrderId, PositionId, ProviderDate, SymbolId, Timestamp, TradeId,
16};
17
18/// A `ProjectX` order side.
19#[derive(Clone, Copy, Debug, Eq, PartialEq)]
20#[non_exhaustive]
21pub enum Side {
22    /// Bid (buy).
23    Bid,
24    /// Ask (sell).
25    Ask,
26    /// Provider code not known to this crate version.
27    Unknown(i32),
28}
29
30impl Side {
31    /// Returns the provider's numeric wire code.
32    #[must_use]
33    pub const fn code(self) -> i32 {
34        match self {
35            Self::Bid => 0,
36            Self::Ask => 1,
37            Self::Unknown(code) => code,
38        }
39    }
40}
41
42impl Serialize for Side {
43    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
44    where
45        S: serde::Serializer,
46    {
47        serializer.serialize_i32(self.code())
48    }
49}
50
51impl<'de> Deserialize<'de> for Side {
52    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
53    where
54        D: serde::Deserializer<'de>,
55    {
56        Ok(match i32::deserialize(deserializer)? {
57            0 => Self::Bid,
58            1 => Self::Ask,
59            code => Self::Unknown(code),
60        })
61    }
62}
63
64/// A `ProjectX` order type.
65#[derive(Clone, Copy, Debug, Eq, PartialEq)]
66#[non_exhaustive]
67pub enum OrderType {
68    /// Limit order.
69    Limit,
70    /// Market order.
71    Market,
72    /// Stop-limit response code.
73    ///
74    /// The current provider request reference does not document this type for
75    /// order placement or bracket creation, so validated request builders
76    /// reject it while response decoding preserves the wire value.
77    StopLimit,
78    /// Stop order.
79    Stop,
80    /// Trailing-stop order.
81    TrailingStop,
82    /// Join the best bid.
83    JoinBid,
84    /// Join the best ask.
85    JoinAsk,
86    /// Provider code not known to this crate version.
87    Unknown(i32),
88}
89
90impl OrderType {
91    /// Returns the provider's numeric wire code.
92    #[must_use]
93    pub const fn code(self) -> i32 {
94        match self {
95            Self::Limit => 1,
96            Self::Market => 2,
97            Self::StopLimit => 3,
98            Self::Stop => 4,
99            Self::TrailingStop => 5,
100            Self::JoinBid => 6,
101            Self::JoinAsk => 7,
102            Self::Unknown(code) => code,
103        }
104    }
105}
106
107impl Serialize for OrderType {
108    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
109    where
110        S: serde::Serializer,
111    {
112        serializer.serialize_i32(self.code())
113    }
114}
115
116impl<'de> Deserialize<'de> for OrderType {
117    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
118    where
119        D: serde::Deserializer<'de>,
120    {
121        Ok(match i32::deserialize(deserializer)? {
122            1 => Self::Limit,
123            2 => Self::Market,
124            3 => Self::StopLimit,
125            4 => Self::Stop,
126            5 => Self::TrailingStop,
127            6 => Self::JoinBid,
128            7 => Self::JoinAsk,
129            code => Self::Unknown(code),
130        })
131    }
132}
133
134/// A `ProjectX` order lifecycle status.
135#[derive(Clone, Copy, Debug, Eq, PartialEq)]
136#[non_exhaustive]
137pub enum OrderStatus {
138    /// Provider sentinel indicating no lifecycle status.
139    None,
140    /// Working order.
141    Open,
142    /// Completely filled order.
143    Filled,
144    /// Cancelled order.
145    Cancelled,
146    /// Expired order.
147    Expired,
148    /// Provider-rejected order.
149    Rejected,
150    /// Order awaiting activation or acknowledgement.
151    Pending,
152    /// Order awaiting cancellation.
153    PendingCancellation,
154    /// Suspended order, including inactive bracket children.
155    Suspended,
156    /// Provider code not known to this crate version.
157    Unknown(i32),
158}
159
160/// Field used to sort an [`OrderQuery`] result page.
161///
162/// This enum is request-only: response order is represented by the returned
163/// [`OrderPage::orders`] sequence.
164#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize_repr)]
165#[non_exhaustive]
166#[repr(i32)]
167pub enum OrderSortBy {
168    /// Sort by order creation time.
169    CreatedAt = 0,
170    /// Sort by provider order identifier.
171    Id = 1,
172}
173
174/// Direction used to sort an [`OrderQuery`] result page.
175#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize_repr)]
176#[non_exhaustive]
177#[repr(i32)]
178pub enum OrderSortDirection {
179    /// Ascending order.
180    Ascending = 0,
181    /// Descending order.
182    Descending = 1,
183}
184
185impl OrderStatus {
186    /// Returns the provider's numeric wire code.
187    #[must_use]
188    pub const fn code(self) -> i32 {
189        match self {
190            Self::None => 0,
191            Self::Open => 1,
192            Self::Filled => 2,
193            Self::Cancelled => 3,
194            Self::Expired => 4,
195            Self::Rejected => 5,
196            Self::Pending => 6,
197            Self::PendingCancellation => 7,
198            Self::Suspended => 8,
199            Self::Unknown(code) => code,
200        }
201    }
202}
203
204impl Serialize for OrderStatus {
205    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
206    where
207        S: serde::Serializer,
208    {
209        serializer.serialize_i32(self.code())
210    }
211}
212
213impl<'de> Deserialize<'de> for OrderStatus {
214    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
215    where
216        D: serde::Deserializer<'de>,
217    {
218        Ok(match i32::deserialize(deserializer)? {
219            0 => Self::None,
220            1 => Self::Open,
221            2 => Self::Filled,
222            3 => Self::Cancelled,
223            4 => Self::Expired,
224            5 => Self::Rejected,
225            6 => Self::Pending,
226            7 => Self::PendingCancellation,
227            8 => Self::Suspended,
228            code => Self::Unknown(code),
229        })
230    }
231}
232
233/// A `ProjectX` market-trade aggressor classification.
234#[derive(Clone, Copy, Debug, Eq, PartialEq)]
235#[non_exhaustive]
236pub enum TradeLogType {
237    /// Buyer-initiated trade.
238    Buy,
239    /// Seller-initiated trade.
240    Sell,
241    /// Provider code not known to this crate version.
242    Unknown(i32),
243}
244
245impl TradeLogType {
246    /// Returns the provider's numeric wire code.
247    #[must_use]
248    pub const fn code(self) -> i32 {
249        match self {
250            Self::Buy => 0,
251            Self::Sell => 1,
252            Self::Unknown(code) => code,
253        }
254    }
255}
256
257impl Serialize for TradeLogType {
258    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
259    where
260        S: serde::Serializer,
261    {
262        serializer.serialize_i32(self.code())
263    }
264}
265
266impl<'de> Deserialize<'de> for TradeLogType {
267    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
268    where
269        D: serde::Deserializer<'de>,
270    {
271        Ok(match i32::deserialize(deserializer)? {
272            0 => Self::Buy,
273            1 => Self::Sell,
274            code => Self::Unknown(code),
275        })
276    }
277}
278
279/// A `ProjectX` position direction.
280#[derive(Clone, Copy, Debug, Eq, PartialEq)]
281#[non_exhaustive]
282pub enum PositionType {
283    /// No directional position.
284    Undefined,
285    /// Net long position.
286    Long,
287    /// Net short position.
288    Short,
289    /// Provider code not known to this crate version.
290    Unknown(i32),
291}
292
293impl PositionType {
294    /// Returns the provider's numeric wire code.
295    #[must_use]
296    pub const fn code(self) -> i32 {
297        match self {
298            Self::Undefined => 0,
299            Self::Long => 1,
300            Self::Short => 2,
301            Self::Unknown(code) => code,
302        }
303    }
304}
305
306impl Serialize for PositionType {
307    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
308    where
309        S: serde::Serializer,
310    {
311        serializer.serialize_i32(self.code())
312    }
313}
314
315impl<'de> Deserialize<'de> for PositionType {
316    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
317    where
318        D: serde::Deserializer<'de>,
319    {
320        Ok(match i32::deserialize(deserializer)? {
321            0 => Self::Undefined,
322            1 => Self::Long,
323            2 => Self::Short,
324            code => Self::Unknown(code),
325        })
326    }
327}
328
329/// A `ProjectX` depth-of-market update kind.
330#[derive(Clone, Copy, Debug, Eq, PartialEq)]
331#[non_exhaustive]
332pub enum DepthType {
333    /// Provider sentinel with no book mutation.
334    Unknown,
335    /// Resting ask level.
336    Ask,
337    /// Resting bid level.
338    Bid,
339    /// Best ask update.
340    BestAsk,
341    /// Best bid update.
342    BestBid,
343    /// Trade notification carried on the depth stream.
344    Trade,
345    /// Full book reset.
346    Reset,
347    /// Session-low notification.
348    Low,
349    /// Session-high notification.
350    High,
351    /// New best bid.
352    NewBestBid,
353    /// New best ask.
354    NewBestAsk,
355    /// Fill notification carried on the depth stream.
356    Fill,
357    /// Provider code not known to this crate version.
358    UnknownCode(i32),
359}
360
361impl DepthType {
362    /// Returns the provider's numeric wire code.
363    #[must_use]
364    pub const fn code(self) -> i32 {
365        match self {
366            Self::Unknown => 0,
367            Self::Ask => 1,
368            Self::Bid => 2,
369            Self::BestAsk => 3,
370            Self::BestBid => 4,
371            Self::Trade => 5,
372            Self::Reset => 6,
373            Self::Low => 7,
374            Self::High => 8,
375            Self::NewBestBid => 9,
376            Self::NewBestAsk => 10,
377            Self::Fill => 11,
378            Self::UnknownCode(code) => code,
379        }
380    }
381}
382
383impl Serialize for DepthType {
384    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
385    where
386        S: serde::Serializer,
387    {
388        serializer.serialize_i32(self.code())
389    }
390}
391
392impl<'de> Deserialize<'de> for DepthType {
393    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
394    where
395        D: serde::Deserializer<'de>,
396    {
397        Ok(match i32::deserialize(deserializer)? {
398            0 => Self::Unknown,
399            1 => Self::Ask,
400            2 => Self::Bid,
401            3 => Self::BestAsk,
402            4 => Self::BestBid,
403            5 => Self::Trade,
404            6 => Self::Reset,
405            7 => Self::Low,
406            8 => Self::High,
407            9 => Self::NewBestBid,
408            10 => Self::NewBestAsk,
409            11 => Self::Fill,
410            code => Self::UnknownCode(code),
411        })
412    }
413}
414
415/// Historical-bar aggregation unit.
416///
417/// The provider's `Unspecified = 0` sentinel is intentionally omitted so a
418/// request must select a concrete aggregation.
419#[derive(Clone, Copy, Debug, Deserialize_repr, Eq, PartialEq, Serialize_repr)]
420#[non_exhaustive]
421#[repr(i32)]
422pub enum BarUnit {
423    /// Seconds.
424    Second = 1,
425    /// Minutes.
426    Minute = 2,
427    /// Hours.
428    Hour = 3,
429    /// Days.
430    Day = 4,
431    /// Weeks.
432    Week = 5,
433    /// Months.
434    Month = 6,
435    /// Individual trades (ticks).
436    Tick = 7,
437}
438
439/// A `ProjectX` account.
440#[derive(Clone, Debug, Deserialize, PartialEq)]
441#[non_exhaustive]
442#[serde(rename_all = "camelCase")]
443pub struct Account {
444    /// Provider account identifier.
445    pub id: AccountId,
446    /// Provider display name.
447    pub name: String,
448    /// Current account balance, when included by the endpoint.
449    #[serde(default, with = "crate::decimal_serde::option")]
450    pub balance: Option<Decimal>,
451    /// Whether the provider permits trading.
452    pub can_trade: bool,
453    /// Whether the provider marks the account visible.
454    pub is_visible: bool,
455    /// Whether this is a simulated account, when included by the endpoint.
456    #[serde(default)]
457    pub simulated: Option<bool>,
458}
459
460/// A `ProjectX` futures contract.
461#[derive(Clone, Debug, Deserialize, PartialEq)]
462#[non_exhaustive]
463#[serde(rename_all = "camelCase")]
464pub struct Contract {
465    /// Provider contract identifier.
466    pub id: ContractId,
467    /// Provider short name.
468    pub name: String,
469    /// Human-readable description.
470    pub description: String,
471    /// Minimum price increment.
472    #[serde(with = "crate::decimal_serde")]
473    pub tick_size: Decimal,
474    /// Monetary value of one tick.
475    #[serde(with = "crate::decimal_serde")]
476    pub tick_value: Decimal,
477    /// Whether this is the provider's active contract.
478    pub active_contract: bool,
479    /// Provider root symbol identifier.
480    pub symbol_id: SymbolId,
481}
482
483/// Contract search parameters.
484#[derive(Clone, Debug, Serialize)]
485#[serde(rename_all = "camelCase")]
486pub struct SearchContracts {
487    /// Whether to search the live-data catalog.
488    pub live: bool,
489    /// Provider search text.
490    pub search_text: String,
491}
492
493/// Historical-bar request parameters.
494///
495/// Construct this request with [`HistoryRequest::builder`]. The builder starts
496/// with one unit per bar, the provider maximum of 20,000 bars, and partial bars
497/// excluded; each default can be overridden explicitly.
498#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
499#[serde(rename_all = "camelCase")]
500pub struct HistoryRequest {
501    /// Explicit provider contract.
502    contract_id: ContractId,
503    /// Whether to use the live-data subscription.
504    live: bool,
505    /// Absolute range start.
506    start_time: Timestamp,
507    /// Absolute range end.
508    end_time: Timestamp,
509    /// Aggregation unit.
510    unit: BarUnit,
511    /// Number of units per bar.
512    unit_number: i32,
513    /// Maximum number of bars, up to the provider limit of 20,000.
514    limit: i32,
515    /// Whether to include the current partial bar.
516    include_partial_bar: bool,
517}
518
519impl HistoryRequest {
520    /// Starts a validated historical-bar request.
521    pub fn builder(
522        contract_id: ContractId,
523        live: bool,
524        start_time: Timestamp,
525        end_time: Timestamp,
526        unit: BarUnit,
527    ) -> HistoryRequestBuilder {
528        HistoryRequestBuilder {
529            contract_id,
530            live,
531            start_time,
532            end_time,
533            unit,
534            unit_number: 1,
535            limit: 20_000,
536            include_partial_bar: false,
537        }
538    }
539
540    /// Borrows the provider contract.
541    #[must_use]
542    pub const fn contract_id(&self) -> &ContractId {
543        &self.contract_id
544    }
545
546    /// Returns whether the live-data subscription is selected.
547    #[must_use]
548    pub const fn is_live(&self) -> bool {
549        self.live
550    }
551
552    /// Returns the absolute range start.
553    #[must_use]
554    pub const fn start_time(&self) -> Timestamp {
555        self.start_time
556    }
557
558    /// Returns the absolute range end.
559    #[must_use]
560    pub const fn end_time(&self) -> Timestamp {
561        self.end_time
562    }
563
564    /// Returns the aggregation unit.
565    #[must_use]
566    pub const fn unit(&self) -> BarUnit {
567        self.unit
568    }
569
570    /// Returns the positive number of units per bar.
571    #[must_use]
572    pub const fn unit_number(&self) -> i32 {
573        self.unit_number
574    }
575
576    /// Returns the requested bar limit in `1..=20_000`.
577    #[must_use]
578    pub const fn limit(&self) -> i32 {
579        self.limit
580    }
581
582    /// Returns whether the current partial bar is requested.
583    #[must_use]
584    pub const fn includes_partial_bar(&self) -> bool {
585        self.include_partial_bar
586    }
587}
588
589/// Builder for a validated [`HistoryRequest`].
590#[derive(Clone, Debug)]
591#[must_use = "a HistoryRequestBuilder does nothing until build is called"]
592pub struct HistoryRequestBuilder {
593    contract_id: ContractId,
594    live: bool,
595    start_time: Timestamp,
596    end_time: Timestamp,
597    unit: BarUnit,
598    unit_number: i32,
599    limit: i32,
600    include_partial_bar: bool,
601}
602
603impl HistoryRequestBuilder {
604    /// Sets the positive number of units per bar.
605    pub const fn unit_number(mut self, unit_number: i32) -> Self {
606        self.unit_number = unit_number;
607        self
608    }
609
610    /// Sets the maximum number of bars in `1..=20_000`.
611    pub const fn limit(mut self, limit: i32) -> Self {
612        self.limit = limit;
613        self
614    }
615
616    /// Selects whether to include the current partial bar.
617    pub const fn include_partial_bar(mut self, include: bool) -> Self {
618        self.include_partial_bar = include;
619        self
620    }
621
622    /// Validates and builds the historical-bar request.
623    ///
624    /// # Errors
625    ///
626    /// Returns an error when the range does not increase, the unit number is
627    /// non-positive, or the limit falls outside `1..=20_000`.
628    pub fn build(self) -> Result<HistoryRequest, RequestValidationError> {
629        if self.start_time >= self.end_time {
630            return Err(RequestValidationError::HistoryRangeNotIncreasing);
631        }
632        if self.unit_number <= 0 {
633            return Err(RequestValidationError::NonPositiveHistoryUnitNumber);
634        }
635        if !(1..=20_000).contains(&self.limit) {
636            return Err(RequestValidationError::HistoryLimitOutOfRange);
637        }
638        Ok(HistoryRequest {
639            contract_id: self.contract_id,
640            live: self.live,
641            start_time: self.start_time,
642            end_time: self.end_time,
643            unit: self.unit,
644            unit_number: self.unit_number,
645            limit: self.limit,
646            include_partial_bar: self.include_partial_bar,
647        })
648    }
649}
650
651/// A historical OHLCV bar.
652#[derive(Clone, Debug, Deserialize, PartialEq)]
653#[non_exhaustive]
654pub struct Bar {
655    /// Provider timestamp.
656    pub t: Timestamp,
657    /// Open price.
658    #[serde(with = "crate::decimal_serde")]
659    pub o: Decimal,
660    /// High price.
661    #[serde(with = "crate::decimal_serde")]
662    pub h: Decimal,
663    /// Low price.
664    #[serde(with = "crate::decimal_serde")]
665    pub l: Decimal,
666    /// Close price.
667    #[serde(with = "crate::decimal_serde")]
668    pub c: Decimal,
669    /// Provider volume units.
670    pub v: i64,
671    /// Optional provider business date.
672    #[serde(default)]
673    pub d: Option<ProviderDate>,
674    /// Optional provider aggregate key.
675    #[serde(default)]
676    pub k: Option<i64>,
677}
678
679/// Historical order search parameters.
680#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
681#[serde(rename_all = "camelCase")]
682pub struct OrderSearch {
683    /// Provider account.
684    account_id: AccountId,
685    /// Absolute range start.
686    start_timestamp: Timestamp,
687    /// Optional absolute range end.
688    #[serde(skip_serializing_if = "Option::is_none")]
689    end_timestamp: Option<Timestamp>,
690}
691
692impl OrderSearch {
693    /// Creates a validated historical order search.
694    ///
695    /// # Errors
696    ///
697    /// Returns [`RequestValidationError::SearchRangeNotIncreasing`] when an
698    /// end timestamp is not later than the start timestamp.
699    pub fn new(
700        account_id: AccountId,
701        start_timestamp: Timestamp,
702        end_timestamp: Option<Timestamp>,
703    ) -> Result<Self, RequestValidationError> {
704        validate_search_range(Some(start_timestamp), end_timestamp)?;
705        Ok(Self {
706            account_id,
707            start_timestamp,
708            end_timestamp,
709        })
710    }
711
712    /// Returns the provider account.
713    #[must_use]
714    pub const fn account_id(&self) -> AccountId {
715        self.account_id
716    }
717
718    /// Returns the range start.
719    #[must_use]
720    pub const fn start_timestamp(&self) -> Timestamp {
721        self.start_timestamp
722    }
723
724    /// Returns the optional range end.
725    #[must_use]
726    pub const fn end_timestamp(&self) -> Option<Timestamp> {
727        self.end_timestamp
728    }
729}
730
731/// Filtered, paginated order-query parameters.
732///
733/// Construct this request with [`OrderQuery::builder`]. Unlike
734/// [`Client::search_open_orders`](crate::Client::search_open_orders), the v2
735/// query can explicitly include [`OrderStatus::Suspended`] bracket children.
736#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
737#[serde(rename_all = "camelCase")]
738pub struct OrderQuery {
739    filter: OrderFilter,
740    #[serde(skip_serializing_if = "Option::is_none")]
741    page_size: Option<i32>,
742    #[serde(skip_serializing_if = "Option::is_none")]
743    page_offset: Option<i32>,
744    #[serde(skip_serializing_if = "Option::is_none")]
745    sort_by: Option<OrderSortBy>,
746    #[serde(skip_serializing_if = "Option::is_none")]
747    sort_direction: Option<OrderSortDirection>,
748    #[serde(skip_serializing_if = "Option::is_none")]
749    include_total_count: Option<bool>,
750}
751
752#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
753#[serde(rename_all = "camelCase")]
754struct OrderFilter {
755    account_id: AccountId,
756    #[serde(skip_serializing_if = "Vec::is_empty")]
757    statuses: Vec<OrderStatus>,
758    #[serde(skip_serializing_if = "Option::is_none")]
759    contract_id: Option<ContractId>,
760    #[serde(skip_serializing_if = "Option::is_none")]
761    created_after: Option<Timestamp>,
762    #[serde(skip_serializing_if = "Option::is_none")]
763    created_before: Option<Timestamp>,
764}
765
766impl OrderQuery {
767    /// Starts a validated v2 order query for an account.
768    pub fn builder(account_id: AccountId) -> OrderQueryBuilder {
769        OrderQueryBuilder {
770            account_id,
771            statuses: Vec::new(),
772            contract_id: None,
773            created_after: None,
774            created_before: None,
775            page_size: None,
776            page_offset: None,
777            sort_by: None,
778            sort_direction: None,
779            include_total_count: None,
780        }
781    }
782
783    /// Returns the provider account being queried.
784    #[must_use]
785    pub const fn account_id(&self) -> AccountId {
786        self.filter.account_id
787    }
788
789    /// Borrows the requested lifecycle statuses.
790    #[must_use]
791    pub fn statuses(&self) -> &[OrderStatus] {
792        &self.filter.statuses
793    }
794
795    /// Borrows the optional provider contract filter.
796    #[must_use]
797    pub const fn contract_id(&self) -> Option<&ContractId> {
798        self.filter.contract_id.as_ref()
799    }
800
801    /// Returns the optional lower creation-time bound.
802    #[must_use]
803    pub const fn created_after(&self) -> Option<Timestamp> {
804        self.filter.created_after
805    }
806
807    /// Returns the optional upper creation-time bound.
808    #[must_use]
809    pub const fn created_before(&self) -> Option<Timestamp> {
810        self.filter.created_before
811    }
812
813    /// Returns the optional positive page size.
814    #[must_use]
815    pub const fn page_size(&self) -> Option<i32> {
816        self.page_size
817    }
818
819    /// Returns the optional non-negative page offset.
820    #[must_use]
821    pub const fn page_offset(&self) -> Option<i32> {
822        self.page_offset
823    }
824
825    /// Returns the optional sort field.
826    #[must_use]
827    pub const fn sort_by(&self) -> Option<OrderSortBy> {
828        self.sort_by
829    }
830
831    /// Returns the optional sort direction.
832    #[must_use]
833    pub const fn sort_direction(&self) -> Option<OrderSortDirection> {
834        self.sort_direction
835    }
836
837    /// Returns the optional total-count request flag.
838    #[must_use]
839    pub const fn include_total_count(&self) -> Option<bool> {
840        self.include_total_count
841    }
842}
843
844/// Builder for a validated [`OrderQuery`].
845#[derive(Clone, Debug)]
846#[must_use = "an OrderQueryBuilder does nothing until build is called"]
847pub struct OrderQueryBuilder {
848    account_id: AccountId,
849    statuses: Vec<OrderStatus>,
850    contract_id: Option<ContractId>,
851    created_after: Option<Timestamp>,
852    created_before: Option<Timestamp>,
853    page_size: Option<i32>,
854    page_offset: Option<i32>,
855    sort_by: Option<OrderSortBy>,
856    sort_direction: Option<OrderSortDirection>,
857    include_total_count: Option<bool>,
858}
859
860impl OrderQueryBuilder {
861    /// Replaces the lifecycle-status filter.
862    pub fn statuses(mut self, statuses: impl IntoIterator<Item = OrderStatus>) -> Self {
863        self.statuses = statuses.into_iter().collect();
864        self
865    }
866
867    /// Restricts results to one provider contract.
868    pub fn contract_id(mut self, contract_id: ContractId) -> Self {
869        self.contract_id = Some(contract_id);
870        self
871    }
872
873    /// Sets the lower creation-time bound.
874    pub const fn created_after(mut self, created_after: Timestamp) -> Self {
875        self.created_after = Some(created_after);
876        self
877    }
878
879    /// Sets the upper creation-time bound.
880    pub const fn created_before(mut self, created_before: Timestamp) -> Self {
881        self.created_before = Some(created_before);
882        self
883    }
884
885    /// Sets the positive number of orders requested per page.
886    pub const fn page_size(mut self, page_size: i32) -> Self {
887        self.page_size = Some(page_size);
888        self
889    }
890
891    /// Sets the non-negative result offset.
892    pub const fn page_offset(mut self, page_offset: i32) -> Self {
893        self.page_offset = Some(page_offset);
894        self
895    }
896
897    /// Selects the result sort field.
898    pub const fn sort_by(mut self, sort_by: OrderSortBy) -> Self {
899        self.sort_by = Some(sort_by);
900        self
901    }
902
903    /// Selects the result sort direction.
904    pub const fn sort_direction(mut self, sort_direction: OrderSortDirection) -> Self {
905        self.sort_direction = Some(sort_direction);
906        self
907    }
908
909    /// Selects whether the response should include a total matching count.
910    pub const fn include_total_count(mut self, include: bool) -> Self {
911        self.include_total_count = Some(include);
912        self
913    }
914
915    /// Validates and builds the v2 order query.
916    ///
917    /// # Errors
918    ///
919    /// Returns an error for an unknown request-status code, a creation range
920    /// that does not increase, a non-positive page size, or a negative page
921    /// offset.
922    pub fn build(self) -> Result<OrderQuery, RequestValidationError> {
923        if let Some(code) = self.statuses.iter().find_map(|status| match status {
924            OrderStatus::Unknown(code) => Some(*code),
925            _ => None,
926        }) {
927            return Err(RequestValidationError::UnsupportedOrderStatus { code });
928        }
929        if self
930            .created_after
931            .zip(self.created_before)
932            .is_some_and(|(after, before)| after >= before)
933        {
934            return Err(RequestValidationError::SearchRangeNotIncreasing);
935        }
936        if self.page_size.is_some_and(|size| size <= 0) {
937            return Err(RequestValidationError::NonPositiveOrderPageSize);
938        }
939        if self.page_offset.is_some_and(|offset| offset < 0) {
940            return Err(RequestValidationError::NegativeOrderPageOffset);
941        }
942        Ok(OrderQuery {
943            filter: OrderFilter {
944                account_id: self.account_id,
945                statuses: self.statuses,
946                contract_id: self.contract_id,
947                created_after: self.created_after,
948                created_before: self.created_before,
949            },
950            page_size: self.page_size,
951            page_offset: self.page_offset,
952            sort_by: self.sort_by,
953            sort_direction: self.sort_direction,
954            include_total_count: self.include_total_count,
955        })
956    }
957}
958
959/// A provider list field read from a successful response body.
960///
961/// Successful responses distinguish an explicit list from a body that merely
962/// lacks the field: the provider may omit a list field or serialize it as
963/// JSON `null` while still reporting success, and an explicit empty array is
964/// authoritative evidence that the listed set is empty. Both absent shapes
965/// decode to [`ProviderList::Absent`]; every explicit array decodes to
966/// [`ProviderList::Listed`], including the empty array. Only
967/// [`ProviderList::Listed`] rows are evidence a consumer may flatten from.
968///
969/// # Examples
970///
971/// ```
972/// use projectx_client::ProviderList;
973///
974/// let listed: ProviderList<u8> = ProviderList::Listed(Vec::new());
975/// assert!(listed.is_explicitly_empty());
976/// assert_eq!(listed.as_listed(), Some(&[][..]));
977/// assert_eq!(listed.into_listed(), Some(Vec::new()));
978///
979/// let absent: ProviderList<u8> = ProviderList::Absent;
980/// assert!(!absent.is_listed());
981/// assert_eq!(absent.as_listed(), None);
982/// assert_eq!(absent.into_listed(), None);
983/// ```
984#[derive(Clone, Debug, Default, Eq, PartialEq)]
985pub enum ProviderList<T> {
986    /// The response carried the field with an explicit array, possibly empty.
987    Listed(Vec<T>),
988    /// The response omitted the field or serialized it as JSON `null`.
989    #[default]
990    Absent,
991}
992
993impl<T> ProviderList<T> {
994    /// Returns the explicit rows by reference when the provider listed them.
995    #[must_use]
996    pub fn as_listed(&self) -> Option<&[T]> {
997        match self {
998            Self::Listed(rows) => Some(rows),
999            Self::Absent => None,
1000        }
1001    }
1002
1003    /// Returns the explicit rows when the provider listed them.
1004    #[must_use]
1005    pub fn into_listed(self) -> Option<Vec<T>> {
1006        match self {
1007            Self::Listed(rows) => Some(rows),
1008            Self::Absent => None,
1009        }
1010    }
1011
1012    /// Returns the explicit rows, or an empty vector when the field was absent.
1013    ///
1014    /// This discards the distinction between an authoritative empty list and
1015    /// an absent field; prefer matching on the list when that distinction
1016    /// matters.
1017    #[must_use]
1018    pub fn unwrap_or_empty(self) -> Vec<T> {
1019        self.into_listed().unwrap_or_default()
1020    }
1021
1022    /// Returns whether the response carried the field explicitly.
1023    #[must_use]
1024    pub fn is_listed(&self) -> bool {
1025        matches!(self, Self::Listed(_))
1026    }
1027
1028    /// Returns whether the response carried an authoritative empty array.
1029    ///
1030    /// This is true only for an explicit empty list; an absent field is not
1031    /// evidence of emptiness.
1032    #[must_use]
1033    pub fn is_explicitly_empty(&self) -> bool {
1034        matches!(self, Self::Listed(rows) if rows.is_empty())
1035    }
1036}
1037
1038impl<T> From<ProviderList<T>> for Option<Vec<T>> {
1039    fn from(list: ProviderList<T>) -> Self {
1040        list.into_listed()
1041    }
1042}
1043
1044impl<'de, T> Deserialize<'de> for ProviderList<T>
1045where
1046    T: Deserialize<'de>,
1047{
1048    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
1049    where
1050        D: serde::Deserializer<'de>,
1051    {
1052        match Option::<Vec<T>>::deserialize(deserializer)? {
1053            Some(rows) => Ok(Self::Listed(rows)),
1054            None => Ok(Self::Absent),
1055        }
1056    }
1057}
1058
1059/// One page returned by [`Client::query_orders`](crate::Client::query_orders).
1060#[derive(Clone, Debug, Deserialize, PartialEq)]
1061#[non_exhaustive]
1062#[serde(rename_all = "camelCase")]
1063pub struct OrderPage {
1064    /// Orders in provider-selected page order.
1065    #[serde(default)]
1066    pub orders: ProviderList<Order>,
1067    /// Total matching order count when requested and supplied by the provider.
1068    #[serde(default)]
1069    pub total_count: Option<i32>,
1070}
1071
1072/// A `ProjectX` order.
1073#[derive(Clone, Debug, Deserialize, PartialEq)]
1074#[non_exhaustive]
1075#[serde(rename_all = "camelCase")]
1076pub struct Order {
1077    /// Provider order identifier.
1078    pub id: OrderId,
1079    /// Provider account.
1080    pub account_id: AccountId,
1081    /// Provider contract.
1082    pub contract_id: ContractId,
1083    /// Provider symbol, when included by the endpoint.
1084    #[serde(default)]
1085    pub symbol_id: Option<SymbolId>,
1086    /// Provider creation timestamp.
1087    pub creation_timestamp: Timestamp,
1088    /// Provider update timestamp.
1089    pub update_timestamp: Timestamp,
1090    /// Provider order status.
1091    pub status: OrderStatus,
1092    /// Provider order type.
1093    #[serde(rename = "type")]
1094    pub order_type: OrderType,
1095    /// Order side.
1096    pub side: Side,
1097    /// Ordered quantity.
1098    pub size: i32,
1099    /// Optional limit price.
1100    #[serde(default, with = "crate::decimal_serde::option")]
1101    pub limit_price: Option<Decimal>,
1102    /// Optional stop price.
1103    #[serde(default, with = "crate::decimal_serde::option")]
1104    pub stop_price: Option<Decimal>,
1105    /// Optional cumulative filled quantity.
1106    #[serde(default)]
1107    pub fill_volume: Option<i32>,
1108    /// Optional average fill price.
1109    #[serde(default, with = "crate::decimal_serde::option")]
1110    pub filled_price: Option<Decimal>,
1111    /// Optional caller tag.
1112    #[serde(default)]
1113    pub custom_tag: Option<String>,
1114    /// Optional trailing distance in provider ticks.
1115    #[serde(default)]
1116    pub trail_distance: Option<i32>,
1117    /// Optional trailing distance in price units (`ticks * tick size`).
1118    ///
1119    /// Order searches return a distance here. Placement and modification
1120    /// instead accept an absolute price level; do not reuse this value as
1121    /// their `trail_price` input.
1122    #[serde(default, with = "crate::decimal_serde::option")]
1123    pub trail_price: Option<Decimal>,
1124    /// Parent order for a bracket child, when supplied.
1125    #[serde(default)]
1126    pub parent_order_id: Option<OrderId>,
1127    /// Provider-linked peer order, when supplied.
1128    #[serde(default)]
1129    pub linked_order_id: Option<OrderId>,
1130}
1131
1132/// Validation failures while constructing a provider request.
1133#[derive(Clone, Copy, Debug, Eq, Error, PartialEq)]
1134#[non_exhaustive]
1135pub enum RequestValidationError {
1136    /// An order placement quantity was zero or negative.
1137    #[error("order size must be positive")]
1138    NonPositiveOrderSize,
1139    /// A trailing-stop placement omitted its absolute starting price level.
1140    #[error("trailing-stop placement requires an absolute trail price")]
1141    MissingTrailPrice,
1142    /// A replacement quantity was zero or negative.
1143    #[error("replacement order size must be positive")]
1144    NonPositiveReplacementSize,
1145    /// An order modification contained no replacement values.
1146    #[error("order modification requires at least one replacement value")]
1147    EmptyModification,
1148    /// A bracket distance was zero or negative.
1149    #[error("bracket ticks must be positive")]
1150    NonPositiveBracketTicks,
1151    /// An order request used an undocumented or unknown provider type code.
1152    #[error("unsupported order type code {code}")]
1153    UnsupportedOrderType {
1154        /// Unrecognized provider wire code.
1155        code: i32,
1156    },
1157    /// An order request used a provider side code unknown to this crate version.
1158    #[error("unsupported order side code {code}")]
1159    UnsupportedOrderSide {
1160        /// Unrecognized provider wire code.
1161        code: i32,
1162    },
1163    /// An order query used a provider status code unknown to this crate version.
1164    #[error("unsupported order status code {code}")]
1165    UnsupportedOrderStatus {
1166        /// Unrecognized provider wire code.
1167        code: i32,
1168    },
1169    /// A v2 order query requested a zero or negative page size.
1170    #[error("order-query page size must be positive")]
1171    NonPositiveOrderPageSize,
1172    /// A v2 order query requested a negative page offset.
1173    #[error("order-query page offset must not be negative")]
1174    NegativeOrderPageOffset,
1175    /// A historical-bar unit count was zero or negative.
1176    #[error("historical-bar unit number must be positive")]
1177    NonPositiveHistoryUnitNumber,
1178    /// A historical-bar limit exceeded the provider-supported range.
1179    #[error("historical-bar limit must be between 1 and 20,000")]
1180    HistoryLimitOutOfRange,
1181    /// A historical-bar range ended at or before its start.
1182    #[error("historical-bar end time must be later than its start time")]
1183    HistoryRangeNotIncreasing,
1184    /// An order or trade search ended at or before its start.
1185    #[error("search end time must be later than its start time")]
1186    SearchRangeNotIncreasing,
1187    /// A partial-close quantity was zero or negative.
1188    #[error("partial-close size must be positive")]
1189    NonPositivePartialCloseSize,
1190}
1191
1192/// `ProjectX` bracket-leg configuration.
1193///
1194/// Placement accepts bracket legs only in the account's Auto OCO Brackets
1195/// mode. Position Brackets mode rejects them with provider code `2`, even
1196/// though the response can include an ID for the rejected order record.
1197#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
1198#[serde(rename_all = "camelCase")]
1199pub struct Bracket {
1200    /// Distance in provider ticks.
1201    ticks: i32,
1202    /// Bracket order type.
1203    #[serde(rename = "type")]
1204    order_type: OrderType,
1205}
1206
1207impl Bracket {
1208    /// Creates a bracket leg with a positive distance in ticks.
1209    ///
1210    /// # Errors
1211    ///
1212    /// Returns an error when `ticks` is zero or negative, or when `order_type`
1213    /// is not documented by the provider for bracket requests.
1214    pub fn new(ticks: i32, order_type: OrderType) -> Result<Self, RequestValidationError> {
1215        if ticks <= 0 {
1216            return Err(RequestValidationError::NonPositiveBracketTicks);
1217        }
1218        validate_request_order_type(order_type)?;
1219        Ok(Self { ticks, order_type })
1220    }
1221
1222    /// Returns the distance in provider ticks.
1223    #[must_use]
1224    pub const fn ticks(&self) -> i32 {
1225        self.ticks
1226    }
1227
1228    /// Returns the bracket order type.
1229    #[must_use]
1230    pub const fn order_type(&self) -> OrderType {
1231        self.order_type
1232    }
1233}
1234
1235/// Order placement parameters.
1236///
1237/// Construct this request with [`PlaceOrder::builder`], which prevents an
1238/// invalid non-positive quantity or trailing stop without a starting price
1239/// from reaching the transport.
1240#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
1241#[serde(rename_all = "camelCase")]
1242pub struct PlaceOrder {
1243    /// Provider account.
1244    account_id: AccountId,
1245    /// Provider contract.
1246    contract_id: ContractId,
1247    /// Order type.
1248    #[serde(rename = "type")]
1249    order_type: OrderType,
1250    /// Order side.
1251    side: Side,
1252    /// Order quantity.
1253    size: i32,
1254    /// Optional limit price.
1255    #[serde(
1256        skip_serializing_if = "Option::is_none",
1257        with = "crate::decimal_serde::option"
1258    )]
1259    limit_price: Option<Decimal>,
1260    /// Optional stop price.
1261    #[serde(
1262        skip_serializing_if = "Option::is_none",
1263        with = "crate::decimal_serde::option"
1264    )]
1265    stop_price: Option<Decimal>,
1266    /// Absolute starting price level, required for trailing-stop orders.
1267    #[serde(
1268        skip_serializing_if = "Option::is_none",
1269        with = "crate::decimal_serde::option"
1270    )]
1271    trail_price: Option<Decimal>,
1272    /// Optional caller tag. It must be unique within the account.
1273    #[serde(skip_serializing_if = "Option::is_none")]
1274    custom_tag: Option<String>,
1275    /// Optional stop-loss bracket.
1276    #[serde(skip_serializing_if = "Option::is_none")]
1277    stop_loss_bracket: Option<Bracket>,
1278    /// Optional take-profit bracket.
1279    #[serde(skip_serializing_if = "Option::is_none")]
1280    take_profit_bracket: Option<Bracket>,
1281}
1282
1283impl PlaceOrder {
1284    /// Starts a validated order-placement request.
1285    pub fn builder(
1286        account_id: AccountId,
1287        contract_id: ContractId,
1288        order_type: OrderType,
1289        side: Side,
1290        quantity: i32,
1291    ) -> PlaceOrderBuilder {
1292        PlaceOrderBuilder {
1293            account_id,
1294            contract_id,
1295            order_type,
1296            side,
1297            size: quantity,
1298            limit_price: None,
1299            stop_price: None,
1300            trail_price: None,
1301            custom_tag: None,
1302            stop_loss_bracket: None,
1303            take_profit_bracket: None,
1304        }
1305    }
1306
1307    /// Returns the provider account.
1308    #[must_use]
1309    pub const fn account_id(&self) -> AccountId {
1310        self.account_id
1311    }
1312
1313    /// Borrows the provider contract.
1314    #[must_use]
1315    pub const fn contract_id(&self) -> &ContractId {
1316        &self.contract_id
1317    }
1318
1319    /// Returns the order type.
1320    #[must_use]
1321    pub const fn order_type(&self) -> OrderType {
1322        self.order_type
1323    }
1324
1325    /// Returns the order side.
1326    #[must_use]
1327    pub const fn side(&self) -> Side {
1328        self.side
1329    }
1330
1331    /// Returns the positive order quantity.
1332    #[must_use]
1333    pub const fn size(&self) -> i32 {
1334        self.size
1335    }
1336
1337    /// Returns the optional limit price.
1338    #[must_use]
1339    pub const fn limit_price(&self) -> Option<Decimal> {
1340        self.limit_price
1341    }
1342
1343    /// Returns the optional stop price.
1344    #[must_use]
1345    pub const fn stop_price(&self) -> Option<Decimal> {
1346        self.stop_price
1347    }
1348
1349    /// Returns the absolute starting price level for a trailing-stop order.
1350    ///
1351    /// This input differs from the distance returned in [`Order::trail_price`].
1352    #[must_use]
1353    pub const fn trail_price(&self) -> Option<Decimal> {
1354        self.trail_price
1355    }
1356
1357    /// Borrows the optional caller tag.
1358    #[must_use]
1359    pub fn custom_tag(&self) -> Option<&str> {
1360        self.custom_tag.as_deref()
1361    }
1362
1363    /// Borrows the optional stop-loss bracket.
1364    #[must_use]
1365    pub const fn stop_loss_bracket(&self) -> Option<&Bracket> {
1366        self.stop_loss_bracket.as_ref()
1367    }
1368
1369    /// Borrows the optional take-profit bracket.
1370    #[must_use]
1371    pub const fn take_profit_bracket(&self) -> Option<&Bracket> {
1372        self.take_profit_bracket.as_ref()
1373    }
1374}
1375
1376/// Builder for a validated [`PlaceOrder`].
1377#[derive(Clone, Debug)]
1378#[must_use = "a PlaceOrderBuilder does nothing until build is called"]
1379pub struct PlaceOrderBuilder {
1380    account_id: AccountId,
1381    contract_id: ContractId,
1382    order_type: OrderType,
1383    side: Side,
1384    size: i32,
1385    limit_price: Option<Decimal>,
1386    stop_price: Option<Decimal>,
1387    trail_price: Option<Decimal>,
1388    custom_tag: Option<String>,
1389    stop_loss_bracket: Option<Bracket>,
1390    take_profit_bracket: Option<Bracket>,
1391}
1392
1393impl PlaceOrderBuilder {
1394    /// Sets the optional limit price.
1395    pub const fn limit_price(mut self, limit_price: Decimal) -> Self {
1396        self.limit_price = Some(limit_price);
1397        self
1398    }
1399
1400    /// Sets the optional stop price.
1401    pub const fn stop_price(mut self, stop_price: Decimal) -> Self {
1402        self.stop_price = Some(stop_price);
1403        self
1404    }
1405
1406    /// Sets the absolute starting price level required for a trailing stop.
1407    ///
1408    /// The provider derives a fixed tick distance from its last traded price
1409    /// when it receives the request, dropping fractional ticks. It checks tick
1410    /// alignment, quote availability, and a maximum distance of 1,000 ticks.
1411    /// These checks require provider state and are not performed by this builder.
1412    /// See the [placement reference](https://gateway.docs.projectx.com/docs/api-reference/order/order-place/).
1413    pub const fn trail_price(mut self, trail_price: Decimal) -> Self {
1414        self.trail_price = Some(trail_price);
1415        self
1416    }
1417
1418    /// Sets the optional caller tag, which must be unique within the account.
1419    pub fn custom_tag(mut self, custom_tag: impl Into<String>) -> Self {
1420        self.custom_tag = Some(custom_tag.into());
1421        self
1422    }
1423
1424    /// Sets the optional stop-loss bracket.
1425    ///
1426    /// Requires the account's Auto OCO Brackets mode; see [`Bracket`].
1427    pub fn stop_loss_bracket(mut self, stop_loss_bracket: Bracket) -> Self {
1428        self.stop_loss_bracket = Some(stop_loss_bracket);
1429        self
1430    }
1431
1432    /// Sets the optional take-profit bracket.
1433    ///
1434    /// Requires the account's Auto OCO Brackets mode; see [`Bracket`].
1435    pub fn take_profit_bracket(mut self, take_profit_bracket: Bracket) -> Self {
1436        self.take_profit_bracket = Some(take_profit_bracket);
1437        self
1438    }
1439
1440    /// Validates and builds the order-placement request.
1441    ///
1442    /// # Errors
1443    ///
1444    /// Returns an error when the order quantity is zero or negative, when its
1445    /// order type is undocumented for placement, or when its side code is
1446    /// unknown to this crate version. Returns
1447    /// [`RequestValidationError::MissingTrailPrice`] when a trailing stop has
1448    /// no absolute starting price level.
1449    pub fn build(self) -> Result<PlaceOrder, RequestValidationError> {
1450        if self.size <= 0 {
1451            return Err(RequestValidationError::NonPositiveOrderSize);
1452        }
1453        validate_request_order_type(self.order_type)?;
1454        if let Side::Unknown(code) = self.side {
1455            return Err(RequestValidationError::UnsupportedOrderSide { code });
1456        }
1457        if self.order_type == OrderType::TrailingStop && self.trail_price.is_none() {
1458            return Err(RequestValidationError::MissingTrailPrice);
1459        }
1460        Ok(PlaceOrder {
1461            account_id: self.account_id,
1462            contract_id: self.contract_id,
1463            order_type: self.order_type,
1464            side: self.side,
1465            size: self.size,
1466            limit_price: self.limit_price,
1467            stop_price: self.stop_price,
1468            trail_price: self.trail_price,
1469            custom_tag: self.custom_tag,
1470            stop_loss_bracket: self.stop_loss_bracket,
1471            take_profit_bracket: self.take_profit_bracket,
1472        })
1473    }
1474}
1475
1476fn validate_request_order_type(order_type: OrderType) -> Result<(), RequestValidationError> {
1477    match order_type {
1478        OrderType::Limit
1479        | OrderType::Market
1480        | OrderType::Stop
1481        | OrderType::TrailingStop
1482        | OrderType::JoinBid
1483        | OrderType::JoinAsk => Ok(()),
1484        OrderType::StopLimit => Err(RequestValidationError::UnsupportedOrderType { code: 3 }),
1485        OrderType::Unknown(code) => Err(RequestValidationError::UnsupportedOrderType { code }),
1486    }
1487}
1488
1489/// Successful order-placement result.
1490#[derive(Clone, Debug, Eq, PartialEq)]
1491#[non_exhaustive]
1492pub struct OrderResponse {
1493    /// Provider order identifier.
1494    pub order_id: OrderId,
1495}
1496
1497/// Order cancellation parameters.
1498#[derive(Clone, Debug, Serialize)]
1499#[serde(rename_all = "camelCase")]
1500pub struct CancelOrder {
1501    /// Provider account.
1502    pub account_id: AccountId,
1503    /// Provider order.
1504    pub order_id: OrderId,
1505}
1506
1507/// Order modification parameters.
1508///
1509/// Construct this request with [`ModifyOrder::builder`], which requires at
1510/// least one replacement value and rejects non-positive replacement sizes.
1511#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
1512#[serde(rename_all = "camelCase")]
1513pub struct ModifyOrder {
1514    /// Provider account.
1515    account_id: AccountId,
1516    /// Provider order.
1517    order_id: OrderId,
1518    /// Optional replacement quantity.
1519    #[serde(skip_serializing_if = "Option::is_none")]
1520    size: Option<i32>,
1521    /// Optional replacement limit price.
1522    #[serde(
1523        skip_serializing_if = "Option::is_none",
1524        with = "crate::decimal_serde::option"
1525    )]
1526    limit_price: Option<Decimal>,
1527    /// Optional replacement stop price.
1528    #[serde(
1529        skip_serializing_if = "Option::is_none",
1530        with = "crate::decimal_serde::option"
1531    )]
1532    stop_price: Option<Decimal>,
1533    /// Optional absolute replacement price level for a trailing stop.
1534    #[serde(
1535        skip_serializing_if = "Option::is_none",
1536        with = "crate::decimal_serde::option"
1537    )]
1538    trail_price: Option<Decimal>,
1539}
1540
1541impl ModifyOrder {
1542    /// Starts a validated order-modification request.
1543    pub const fn builder(account_id: AccountId, order_id: OrderId) -> ModifyOrderBuilder {
1544        ModifyOrderBuilder {
1545            account_id,
1546            order_id,
1547            size: None,
1548            limit_price: None,
1549            stop_price: None,
1550            trail_price: None,
1551        }
1552    }
1553
1554    /// Returns the provider account.
1555    #[must_use]
1556    pub const fn account_id(&self) -> AccountId {
1557        self.account_id
1558    }
1559
1560    /// Returns the provider order.
1561    #[must_use]
1562    pub const fn order_id(&self) -> OrderId {
1563        self.order_id
1564    }
1565
1566    /// Returns the optional positive replacement quantity.
1567    #[must_use]
1568    pub const fn size(&self) -> Option<i32> {
1569        self.size
1570    }
1571
1572    /// Returns the optional replacement limit price.
1573    #[must_use]
1574    pub const fn limit_price(&self) -> Option<Decimal> {
1575        self.limit_price
1576    }
1577
1578    /// Returns the optional replacement stop price.
1579    #[must_use]
1580    pub const fn stop_price(&self) -> Option<Decimal> {
1581        self.stop_price
1582    }
1583
1584    /// Returns the optional absolute replacement price level for a trailing stop.
1585    ///
1586    /// This input differs from the distance returned in [`Order::trail_price`].
1587    #[must_use]
1588    pub const fn trail_price(&self) -> Option<Decimal> {
1589        self.trail_price
1590    }
1591}
1592
1593/// Builder for a validated [`ModifyOrder`].
1594#[derive(Clone, Copy, Debug)]
1595#[must_use = "a ModifyOrderBuilder does nothing until build is called"]
1596pub struct ModifyOrderBuilder {
1597    account_id: AccountId,
1598    order_id: OrderId,
1599    size: Option<i32>,
1600    limit_price: Option<Decimal>,
1601    stop_price: Option<Decimal>,
1602    trail_price: Option<Decimal>,
1603}
1604
1605impl ModifyOrderBuilder {
1606    /// Sets the replacement quantity.
1607    pub const fn size(mut self, size: i32) -> Self {
1608        self.size = Some(size);
1609        self
1610    }
1611
1612    /// Sets the replacement limit price.
1613    pub const fn limit_price(mut self, limit_price: Decimal) -> Self {
1614        self.limit_price = Some(limit_price);
1615        self
1616    }
1617
1618    /// Sets the replacement stop price.
1619    pub const fn stop_price(mut self, stop_price: Decimal) -> Self {
1620        self.stop_price = Some(stop_price);
1621        self
1622    }
1623
1624    /// Sets an absolute replacement price level for a trailing-stop order.
1625    ///
1626    /// The provider recalculates the tick distance using its last traded price
1627    /// at modification time. Tick alignment and quote availability are checked
1628    /// by the provider, but modification has no maximum-distance check. Supplying
1629    /// the distance from [`Order::trail_price`] can therefore be accepted with
1630    /// an unintended trail. Omit this setter to leave the trail unchanged.
1631    /// See the [modification reference](https://gateway.docs.projectx.com/docs/api-reference/order/order-modify/).
1632    pub const fn trail_price(mut self, trail_price: Decimal) -> Self {
1633        self.trail_price = Some(trail_price);
1634        self
1635    }
1636
1637    /// Validates and builds the order-modification request.
1638    ///
1639    /// # Errors
1640    ///
1641    /// Returns [`RequestValidationError::NonPositiveReplacementSize`] when a
1642    /// replacement quantity is zero or negative, or
1643    /// [`RequestValidationError::EmptyModification`] when no replacement value was
1644    /// supplied.
1645    pub fn build(self) -> Result<ModifyOrder, RequestValidationError> {
1646        if self.size.is_some_and(|size| size <= 0) {
1647            return Err(RequestValidationError::NonPositiveReplacementSize);
1648        }
1649        if self.size.is_none()
1650            && self.limit_price.is_none()
1651            && self.stop_price.is_none()
1652            && self.trail_price.is_none()
1653        {
1654            return Err(RequestValidationError::EmptyModification);
1655        }
1656        Ok(ModifyOrder {
1657            account_id: self.account_id,
1658            order_id: self.order_id,
1659            size: self.size,
1660            limit_price: self.limit_price,
1661            stop_price: self.stop_price,
1662            trail_price: self.trail_price,
1663        })
1664    }
1665}
1666
1667/// Position close parameters.
1668#[derive(Clone, Debug, Serialize)]
1669#[serde(rename_all = "camelCase")]
1670pub struct CloseContract {
1671    /// Provider account.
1672    pub account_id: AccountId,
1673    /// Provider contract.
1674    pub contract_id: ContractId,
1675}
1676
1677/// Partial-position close parameters.
1678#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
1679#[serde(rename_all = "camelCase")]
1680pub struct PartialCloseContract {
1681    /// Provider account.
1682    account_id: AccountId,
1683    /// Provider contract.
1684    contract_id: ContractId,
1685    /// Positive quantity to close.
1686    size: i32,
1687}
1688
1689impl PartialCloseContract {
1690    /// Creates a partial-position close with a positive quantity.
1691    ///
1692    /// # Errors
1693    ///
1694    /// Returns [`RequestValidationError::NonPositivePartialCloseSize`] when
1695    /// `size` is zero or negative.
1696    pub fn new(
1697        account_id: AccountId,
1698        contract_id: ContractId,
1699        size: i32,
1700    ) -> Result<Self, RequestValidationError> {
1701        if size <= 0 {
1702            return Err(RequestValidationError::NonPositivePartialCloseSize);
1703        }
1704        Ok(Self {
1705            account_id,
1706            contract_id,
1707            size,
1708        })
1709    }
1710
1711    /// Returns the provider account.
1712    #[must_use]
1713    pub const fn account_id(&self) -> AccountId {
1714        self.account_id
1715    }
1716
1717    /// Borrows the provider contract.
1718    #[must_use]
1719    pub const fn contract_id(&self) -> &ContractId {
1720        &self.contract_id
1721    }
1722
1723    /// Returns the positive quantity to close.
1724    #[must_use]
1725    pub const fn size(&self) -> i32 {
1726        self.size
1727    }
1728}
1729
1730/// A `ProjectX` open position.
1731#[derive(Clone, Debug, Deserialize, PartialEq)]
1732#[non_exhaustive]
1733#[serde(rename_all = "camelCase")]
1734pub struct Position {
1735    /// Provider position identifier.
1736    pub id: PositionId,
1737    /// Provider account.
1738    pub account_id: AccountId,
1739    /// Provider contract.
1740    pub contract_id: ContractId,
1741    /// Provider contract display name, when supplied.
1742    #[serde(default)]
1743    pub contract_display_name: Option<String>,
1744    /// Provider creation timestamp.
1745    pub creation_timestamp: Timestamp,
1746    /// Provider position-type code.
1747    #[serde(rename = "type")]
1748    pub position_type: PositionType,
1749    /// Signed or directional provider quantity.
1750    pub size: i32,
1751    /// Average entry price.
1752    #[serde(with = "crate::decimal_serde")]
1753    pub average_price: Decimal,
1754}
1755
1756/// Trade search parameters.
1757#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
1758#[serde(rename_all = "camelCase")]
1759pub struct TradeSearch {
1760    /// Provider account.
1761    account_id: AccountId,
1762    /// Absolute range start.
1763    start_timestamp: Timestamp,
1764    /// Optional absolute range end.
1765    #[serde(skip_serializing_if = "Option::is_none")]
1766    end_timestamp: Option<Timestamp>,
1767}
1768
1769/// Trade search parameters with independently optional timestamp bounds.
1770///
1771/// Construct this request with [`TradeQuery::builder`]. Omitting both bounds
1772/// requests every trade available for the selected account.
1773#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
1774#[serde(rename_all = "camelCase")]
1775pub struct TradeQuery {
1776    /// Provider account.
1777    account_id: AccountId,
1778    /// Optional absolute range start.
1779    #[serde(skip_serializing_if = "Option::is_none")]
1780    start_timestamp: Option<Timestamp>,
1781    /// Optional absolute range end.
1782    #[serde(skip_serializing_if = "Option::is_none")]
1783    end_timestamp: Option<Timestamp>,
1784}
1785
1786impl TradeQuery {
1787    /// Starts a trade query for an account with no timestamp bounds.
1788    pub const fn builder(account_id: AccountId) -> TradeQueryBuilder {
1789        TradeQueryBuilder {
1790            account_id,
1791            start_timestamp: None,
1792            end_timestamp: None,
1793        }
1794    }
1795
1796    /// Returns the provider account.
1797    #[must_use]
1798    pub const fn account_id(&self) -> AccountId {
1799        self.account_id
1800    }
1801
1802    /// Returns the optional lower timestamp bound.
1803    #[must_use]
1804    pub const fn start_timestamp(&self) -> Option<Timestamp> {
1805        self.start_timestamp
1806    }
1807
1808    /// Returns the optional upper timestamp bound.
1809    #[must_use]
1810    pub const fn end_timestamp(&self) -> Option<Timestamp> {
1811        self.end_timestamp
1812    }
1813}
1814
1815/// Builder for a validated [`TradeQuery`].
1816#[derive(Clone, Copy, Debug)]
1817#[must_use = "a TradeQueryBuilder does nothing until build is called"]
1818pub struct TradeQueryBuilder {
1819    account_id: AccountId,
1820    start_timestamp: Option<Timestamp>,
1821    end_timestamp: Option<Timestamp>,
1822}
1823
1824impl TradeQueryBuilder {
1825    /// Sets the optional lower timestamp bound.
1826    pub const fn start_timestamp(mut self, start_timestamp: Timestamp) -> Self {
1827        self.start_timestamp = Some(start_timestamp);
1828        self
1829    }
1830
1831    /// Sets the optional upper timestamp bound.
1832    pub const fn end_timestamp(mut self, end_timestamp: Timestamp) -> Self {
1833        self.end_timestamp = Some(end_timestamp);
1834        self
1835    }
1836
1837    /// Validates and builds the trade query.
1838    ///
1839    /// # Errors
1840    ///
1841    /// Returns [`RequestValidationError::SearchRangeNotIncreasing`] when both
1842    /// bounds are present and the end is not later than the start.
1843    pub fn build(self) -> Result<TradeQuery, RequestValidationError> {
1844        validate_search_range(self.start_timestamp, self.end_timestamp)?;
1845        Ok(TradeQuery {
1846            account_id: self.account_id,
1847            start_timestamp: self.start_timestamp,
1848            end_timestamp: self.end_timestamp,
1849        })
1850    }
1851}
1852
1853impl TradeSearch {
1854    /// Creates a validated execution search.
1855    ///
1856    /// # Errors
1857    ///
1858    /// Returns [`RequestValidationError::SearchRangeNotIncreasing`] when an
1859    /// end timestamp is present and is not later than the start.
1860    pub fn new(
1861        account_id: AccountId,
1862        start_timestamp: Timestamp,
1863        end_timestamp: Option<Timestamp>,
1864    ) -> Result<Self, RequestValidationError> {
1865        validate_search_range(Some(start_timestamp), end_timestamp)?;
1866        Ok(Self {
1867            account_id,
1868            start_timestamp,
1869            end_timestamp,
1870        })
1871    }
1872
1873    /// Returns the provider account.
1874    #[must_use]
1875    pub const fn account_id(&self) -> AccountId {
1876        self.account_id
1877    }
1878
1879    /// Returns the range start.
1880    #[must_use]
1881    pub const fn start_timestamp(&self) -> Timestamp {
1882        self.start_timestamp
1883    }
1884
1885    /// Returns the optional range end.
1886    #[must_use]
1887    pub const fn end_timestamp(&self) -> Option<Timestamp> {
1888        self.end_timestamp
1889    }
1890}
1891
1892fn validate_search_range(
1893    start_timestamp: Option<Timestamp>,
1894    end_timestamp: Option<Timestamp>,
1895) -> Result<(), RequestValidationError> {
1896    if start_timestamp
1897        .zip(end_timestamp)
1898        .is_some_and(|(start, end)| end <= start)
1899    {
1900        Err(RequestValidationError::SearchRangeNotIncreasing)
1901    } else {
1902        Ok(())
1903    }
1904}
1905
1906/// A `ProjectX` execution trade.
1907#[derive(Clone, Debug, Deserialize, PartialEq)]
1908#[non_exhaustive]
1909#[serde(rename_all = "camelCase")]
1910pub struct Trade {
1911    /// Provider trade identifier.
1912    pub id: TradeId,
1913    /// Provider account.
1914    pub account_id: AccountId,
1915    /// Provider contract.
1916    pub contract_id: ContractId,
1917    /// Provider creation timestamp.
1918    pub creation_timestamp: Timestamp,
1919    /// Execution price.
1920    #[serde(with = "crate::decimal_serde")]
1921    pub price: Decimal,
1922    /// Optional realized P&L.
1923    #[serde(default, with = "crate::decimal_serde::option")]
1924    pub profit_and_loss: Option<Decimal>,
1925    /// Provider fees.
1926    #[serde(with = "crate::decimal_serde")]
1927    pub fees: Decimal,
1928    /// Optional provider commissions, separate from fees.
1929    #[serde(default, with = "crate::decimal_serde::option")]
1930    pub commissions: Option<Decimal>,
1931    /// Execution side.
1932    pub side: Side,
1933    /// Execution quantity.
1934    pub size: i32,
1935    /// Whether the provider voided this trade.
1936    pub voided: bool,
1937    /// Originating order.
1938    pub order_id: OrderId,
1939}
1940
1941/// Sparse quote update from the market hub.
1942///
1943/// The provider may send only the fields that changed. Callers that need a
1944/// consolidated snapshot must merge updates by symbol and preserve `None` as
1945/// unavailable data rather than substituting a zero price or volume.
1946#[derive(Clone, Debug, Deserialize, PartialEq)]
1947#[non_exhaustive]
1948#[serde(rename_all = "camelCase")]
1949pub struct MarketQuote {
1950    /// Provider symbol identifier.
1951    #[serde(alias = "symbol")]
1952    pub raw_symbol: SymbolId,
1953    /// Human-readable symbol name, when supplied.
1954    #[serde(default)]
1955    pub symbol_name: Option<String>,
1956    /// Last trade price, when supplied by this update.
1957    #[serde(default, with = "crate::decimal_serde::option")]
1958    pub last_price: Option<Decimal>,
1959    /// Best bid price, when supplied by this update.
1960    #[serde(default, with = "crate::decimal_serde::option")]
1961    pub best_bid: Option<Decimal>,
1962    /// Best ask price, when supplied by this update.
1963    #[serde(default, with = "crate::decimal_serde::option")]
1964    pub best_ask: Option<Decimal>,
1965    /// Session price change, when supplied by this update.
1966    #[serde(default, with = "crate::decimal_serde::option")]
1967    pub change: Option<Decimal>,
1968    /// Session percent change, when supplied by this update.
1969    #[serde(default, with = "crate::decimal_serde::option")]
1970    pub change_percent: Option<Decimal>,
1971    /// Session open, when supplied by this update.
1972    #[serde(default, with = "crate::decimal_serde::option")]
1973    pub open: Option<Decimal>,
1974    /// Session high, when supplied by this update.
1975    #[serde(default, with = "crate::decimal_serde::option")]
1976    pub high: Option<Decimal>,
1977    /// Session low, when supplied by this update.
1978    #[serde(default, with = "crate::decimal_serde::option")]
1979    pub low: Option<Decimal>,
1980    /// Session cumulative volume, when supplied by this update.
1981    #[serde(default)]
1982    pub volume: Option<i64>,
1983    /// Provider last-updated timestamp.
1984    pub last_updated: Timestamp,
1985    /// Event timestamp, when supplied separately from [`Self::last_updated`].
1986    #[serde(default)]
1987    pub timestamp: Option<Timestamp>,
1988}
1989
1990/// Depth-of-market update from the market hub.
1991#[derive(Clone, Debug, Deserialize, PartialEq)]
1992#[non_exhaustive]
1993#[serde(rename_all = "camelCase")]
1994pub struct MarketDepth {
1995    /// Provider symbol identifier, when supplied.
1996    #[serde(default, alias = "symbolId")]
1997    pub symbol_id: Option<SymbolId>,
1998    /// Event timestamp.
1999    pub timestamp: Timestamp,
2000    /// Provider depth event code.
2001    #[serde(rename = "type")]
2002    pub depth_type: DepthType,
2003    /// Price level.
2004    #[serde(with = "crate::decimal_serde")]
2005    pub price: Decimal,
2006    /// Incremental volume for the update.
2007    pub volume: i64,
2008    /// Resting volume after the update.
2009    pub current_volume: i64,
2010    /// Zero-based level index, when supplied.
2011    #[serde(default)]
2012    pub index: Option<i32>,
2013}
2014
2015/// Trade print from the market hub.
2016#[derive(Clone, Debug, Deserialize, PartialEq)]
2017#[non_exhaustive]
2018#[serde(rename_all = "camelCase")]
2019pub struct MarketTrade {
2020    /// Provider symbol identifier.
2021    pub symbol_id: SymbolId,
2022    /// Trade price.
2023    #[serde(with = "crate::decimal_serde")]
2024    pub price: Decimal,
2025    /// Event timestamp.
2026    pub timestamp: Timestamp,
2027    /// Provider aggressor classification.
2028    #[serde(rename = "type")]
2029    pub trade_type: TradeLogType,
2030    /// Trade quantity.
2031    pub volume: i64,
2032}
2033
2034/// Successful response for an operation without a result body.
2035#[derive(Clone, Copy, Debug, Eq, PartialEq)]
2036#[non_exhaustive]
2037pub struct OperationResponse;
2038
2039/// A decoded provider response envelope.
2040///
2041/// `T` is the endpoint body read from an accepted response. `R` is the
2042/// endpoint-specific detail read from the remaining fields of a rejection;
2043/// [`EmptyBody`] ignores them.
2044#[derive(Debug)]
2045pub(crate) enum Envelope<T, R = EmptyBody> {
2046    Accepted(T),
2047    Rejected {
2048        error_code: i32,
2049        error_message: Option<String>,
2050        detail: R,
2051    },
2052    InconsistentStatus {
2053        success: bool,
2054        error_code: i32,
2055    },
2056}
2057
2058impl<'de, T, R> Deserialize<'de> for Envelope<T, R>
2059where
2060    T: serde::de::DeserializeOwned,
2061    R: serde::de::DeserializeOwned,
2062{
2063    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
2064    where
2065        D: serde::Deserializer<'de>,
2066    {
2067        use serde::de::Error as _;
2068
2069        let mut object = BTreeMap::<String, Box<RawValue>>::deserialize(deserializer)?;
2070        let success = object
2071            .remove("success")
2072            .ok_or_else(|| D::Error::custom("provider response success flag is missing"))
2073            .and_then(|value| serde_json::from_str(value.get()).map_err(D::Error::custom))?;
2074        let error_code = object
2075            .remove("errorCode")
2076            .ok_or_else(|| D::Error::custom("provider response error code is missing"))
2077            .and_then(|value| serde_json::from_str(value.get()).map_err(D::Error::custom))?;
2078        let error_message = object.remove("errorMessage");
2079        if success != (error_code == 0) {
2080            return Ok(Self::InconsistentStatus {
2081                success,
2082                error_code,
2083            });
2084        }
2085        if !success {
2086            let error_message = error_message
2087                .map(|value| serde_json::from_str::<Option<String>>(value.get()))
2088                .transpose()
2089                .map_err(D::Error::custom)?
2090                .flatten();
2091            let detail = decode_remaining(object).map_err(D::Error::custom)?;
2092            return Ok(Self::Rejected {
2093                error_code,
2094                error_message,
2095                detail,
2096            });
2097        }
2098        let body = decode_remaining(object).map_err(D::Error::custom)?;
2099        Ok(Self::Accepted(body))
2100    }
2101}
2102
2103/// Decodes the envelope fields left after the status fields were removed.
2104fn decode_remaining<T>(object: BTreeMap<String, Box<RawValue>>) -> serde_json::Result<T>
2105where
2106    T: serde::de::DeserializeOwned,
2107{
2108    let mut body_json = String::from("{");
2109    for (index, (key, value)) in object.into_iter().enumerate() {
2110        if index > 0 {
2111            body_json.push(',');
2112        }
2113        body_json.push_str(&serde_json::to_string(&key)?);
2114        body_json.push(':');
2115        body_json.push_str(value.get());
2116    }
2117    body_json.push('}');
2118    serde_json::from_str(&body_json)
2119}
2120
2121#[derive(Debug, Deserialize)]
2122pub(crate) struct AccountsBody {
2123    #[serde(default)]
2124    pub(crate) accounts: ProviderList<Account>,
2125}
2126
2127#[derive(Debug, Deserialize)]
2128pub(crate) struct ContractsBody {
2129    #[serde(default)]
2130    pub(crate) contracts: ProviderList<Contract>,
2131}
2132
2133#[derive(Debug, Deserialize)]
2134pub(crate) struct ContractBody {
2135    pub(crate) contract: Contract,
2136}
2137
2138#[derive(Debug, Deserialize)]
2139pub(crate) struct BarsBody {
2140    #[serde(default)]
2141    pub(crate) bars: ProviderList<Bar>,
2142}
2143
2144#[derive(Debug, Deserialize)]
2145pub(crate) struct OrdersBody {
2146    #[serde(default)]
2147    pub(crate) orders: ProviderList<Order>,
2148}
2149
2150#[derive(Debug, Deserialize)]
2151pub(crate) struct OrderBody {
2152    pub(crate) order: Order,
2153}
2154
2155#[derive(Debug, Deserialize)]
2156#[serde(rename_all = "camelCase")]
2157pub(crate) struct PlaceOrderBody {
2158    pub(crate) order_id: Option<OrderId>,
2159}
2160
2161#[derive(Debug, Deserialize)]
2162pub(crate) struct PositionsBody {
2163    #[serde(default)]
2164    pub(crate) positions: ProviderList<Position>,
2165}
2166
2167#[derive(Debug, Deserialize)]
2168pub(crate) struct TradesBody {
2169    #[serde(default)]
2170    pub(crate) trades: ProviderList<Trade>,
2171}
2172
2173#[derive(Debug, Deserialize)]
2174pub(crate) struct EmptyBody {}
2175
2176#[cfg(test)]
2177mod tests {
2178    use super::*;
2179
2180    macro_rules! assert_absent {
2181        ($body:ty, $field:ident, $json:literal) => {{
2182            let envelope: Envelope<$body> = serde_json::from_str($json)
2183                .unwrap_or_else(|error| panic!("fixture envelope must decode: {error}"));
2184            let Envelope::Accepted(body) = envelope else {
2185                panic!("fixture envelope must be accepted");
2186            };
2187            assert_eq!(body.$field, ProviderList::Absent);
2188        }};
2189    }
2190
2191    #[test]
2192    fn list_bodies_keep_explicit_lists_distinct_from_missing_and_null() {
2193        assert_absent!(
2194            AccountsBody,
2195            accounts,
2196            r#"{"success":true,"errorCode":0,"accounts":null}"#
2197        );
2198        assert_absent!(
2199            ContractsBody,
2200            contracts,
2201            r#"{"success":true,"errorCode":0}"#
2202        );
2203        assert_absent!(
2204            OrdersBody,
2205            orders,
2206            r#"{"success":true,"errorCode":0,"orders":null}"#
2207        );
2208        assert_absent!(
2209            OrderPage,
2210            orders,
2211            r#"{"success":true,"errorCode":0,"orders":null}"#
2212        );
2213        assert_absent!(
2214            PositionsBody,
2215            positions,
2216            r#"{"success":true,"errorCode":0}"#
2217        );
2218        assert_absent!(
2219            TradesBody,
2220            trades,
2221            r#"{"success":true,"errorCode":0,"trades":null}"#
2222        );
2223    }
2224
2225    #[test]
2226    fn list_bodies_list_explicit_arrays_including_the_empty_array() {
2227        assert_eq!(
2228            serde_json::from_str::<PositionsBody>(r#"{"positions":[]}"#)
2229                .unwrap_or_else(|error| panic!("explicit empty list must decode: {error}"))
2230                .positions,
2231            ProviderList::Listed(Vec::new())
2232        );
2233    }
2234
2235    #[test]
2236    fn rejected_envelope_does_not_require_an_endpoint_body() {
2237        let envelope: Envelope<AccountsBody> =
2238            serde_json::from_str(r#"{"success":false,"errorCode":17,"errorMessage":"synthetic"}"#)
2239                .unwrap_or_else(|error| panic!("rejection envelope must decode: {error}"));
2240
2241        assert!(matches!(
2242            envelope,
2243            Envelope::Rejected {
2244                error_code: 17,
2245                error_message: Some(ref message),
2246                ..
2247            } if message == "synthetic"
2248        ));
2249    }
2250
2251    #[test]
2252    fn rejected_envelope_reads_a_null_or_absent_message_as_none() {
2253        for json in [
2254            r#"{"success":false,"errorCode":17,"errorMessage":null}"#,
2255            r#"{"success":false,"errorCode":17}"#,
2256        ] {
2257            let envelope: Envelope<AccountsBody> = serde_json::from_str(json)
2258                .unwrap_or_else(|error| panic!("rejection envelope must decode: {error}"));
2259            assert!(matches!(
2260                envelope,
2261                Envelope::Rejected {
2262                    error_code: 17,
2263                    error_message: None,
2264                    ..
2265                }
2266            ));
2267        }
2268    }
2269
2270    #[test]
2271    fn rejected_envelope_refuses_a_message_that_is_not_text() {
2272        assert!(
2273            serde_json::from_str::<Envelope<AccountsBody>>(
2274                r#"{"success":false,"errorCode":17,"errorMessage":17}"#
2275            )
2276            .is_err()
2277        );
2278    }
2279
2280    #[test]
2281    fn rejected_placement_envelope_reads_the_order_record() {
2282        let envelope: Envelope<PlaceOrderBody, PlaceOrderBody> =
2283            serde_json::from_str(r#"{"orderId":84,"success":false,"errorCode":2}"#)
2284                .unwrap_or_else(|error| panic!("placement rejection must decode: {error}"));
2285        let Envelope::Rejected { detail, .. } = envelope else {
2286            panic!("placement rejection must stay a rejection");
2287        };
2288        assert_eq!(detail.order_id.map(OrderId::get), Some(84));
2289        assert!(
2290            serde_json::from_str::<Envelope<PlaceOrderBody, PlaceOrderBody>>(
2291                r#"{"orderId":"not-an-id","success":false,"errorCode":2}"#
2292            )
2293            .is_err()
2294        );
2295    }
2296
2297    #[test]
2298    fn envelope_requires_a_consistent_provider_status() {
2299        assert!(
2300            serde_json::from_str::<Envelope<AccountsBody>>(r#"{"success":true,"accounts":[]}"#)
2301                .is_err()
2302        );
2303        for (json, success, error_code) in [
2304            (r#"{"success":true,"errorCode":17}"#, true, 17),
2305            (r#"{"success":false,"errorCode":0}"#, false, 0),
2306        ] {
2307            let envelope: Envelope<AccountsBody> = serde_json::from_str(json)
2308                .unwrap_or_else(|error| panic!("inconsistent envelope must decode: {error}"));
2309            assert!(matches!(
2310                envelope,
2311                Envelope::InconsistentStatus {
2312                    success: actual_success,
2313                    error_code: actual_error_code,
2314                } if actual_success == success && actual_error_code == error_code
2315            ));
2316        }
2317    }
2318}