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 tick offset was zero.
1149    ///
1150    /// The name predates signed tick offsets and is retained for
1151    /// compatibility: only zero is invalid, and the sign is the caller's.
1152    #[error("bracket ticks must not be zero")]
1153    NonPositiveBracketTicks,
1154    /// An order request used an undocumented or unknown provider type code.
1155    #[error("unsupported order type code {code}")]
1156    UnsupportedOrderType {
1157        /// Unrecognized provider wire code.
1158        code: i32,
1159    },
1160    /// An order request used a provider side code unknown to this crate version.
1161    #[error("unsupported order side code {code}")]
1162    UnsupportedOrderSide {
1163        /// Unrecognized provider wire code.
1164        code: i32,
1165    },
1166    /// An order query used a provider status code unknown to this crate version.
1167    #[error("unsupported order status code {code}")]
1168    UnsupportedOrderStatus {
1169        /// Unrecognized provider wire code.
1170        code: i32,
1171    },
1172    /// A v2 order query requested a zero or negative page size.
1173    #[error("order-query page size must be positive")]
1174    NonPositiveOrderPageSize,
1175    /// A v2 order query requested a negative page offset.
1176    #[error("order-query page offset must not be negative")]
1177    NegativeOrderPageOffset,
1178    /// A historical-bar unit count was zero or negative.
1179    #[error("historical-bar unit number must be positive")]
1180    NonPositiveHistoryUnitNumber,
1181    /// A historical-bar limit exceeded the provider-supported range.
1182    #[error("historical-bar limit must be between 1 and 20,000")]
1183    HistoryLimitOutOfRange,
1184    /// A historical-bar range ended at or before its start.
1185    #[error("historical-bar end time must be later than its start time")]
1186    HistoryRangeNotIncreasing,
1187    /// An order or trade search ended at or before its start.
1188    #[error("search end time must be later than its start time")]
1189    SearchRangeNotIncreasing,
1190    /// A partial-close quantity was zero or negative.
1191    #[error("partial-close size must be positive")]
1192    NonPositivePartialCloseSize,
1193}
1194
1195/// `ProjectX` bracket-leg configuration.
1196///
1197/// Placement accepts bracket legs only in the account's Auto OCO Brackets
1198/// mode. Position Brackets mode rejects them with provider code `2`, even
1199/// though the response can include an ID for the rejected order record.
1200///
1201/// The tick offset is signed: the provider applies its sign against the
1202/// entry side and the protective leg. A long stop loss requires a negative
1203/// count — the provider rejects the positive form with code `2`, "Ticks
1204/// should be less than zero when longing" — and the provider reference does
1205/// not enumerate the sign for every side and leg. This crate therefore
1206/// neither infers nor rewrites the sign: the caller chooses it, and the
1207/// nonzero value is preserved exactly through accessors and serialization.
1208#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
1209#[serde(rename_all = "camelCase")]
1210pub struct Bracket {
1211    /// Signed distance in provider ticks; the caller chooses the sign.
1212    ticks: i32,
1213    /// Bracket order type.
1214    #[serde(rename = "type")]
1215    order_type: OrderType,
1216}
1217
1218impl Bracket {
1219    /// Creates a bracket leg with a nonzero signed distance in ticks.
1220    ///
1221    /// # Errors
1222    ///
1223    /// Returns an error when `ticks` is zero, or when `order_type`
1224    /// is not documented by the provider for bracket requests.
1225    pub fn new(ticks: i32, order_type: OrderType) -> Result<Self, RequestValidationError> {
1226        if ticks == 0 {
1227            return Err(RequestValidationError::NonPositiveBracketTicks);
1228        }
1229        validate_request_order_type(order_type)?;
1230        Ok(Self { ticks, order_type })
1231    }
1232
1233    /// Returns the signed distance in provider ticks.
1234    #[must_use]
1235    pub const fn ticks(&self) -> i32 {
1236        self.ticks
1237    }
1238
1239    /// Returns the bracket order type.
1240    #[must_use]
1241    pub const fn order_type(&self) -> OrderType {
1242        self.order_type
1243    }
1244}
1245
1246/// Order placement parameters.
1247///
1248/// Construct this request with [`PlaceOrder::builder`], which prevents an
1249/// invalid non-positive quantity or trailing stop without a starting price
1250/// from reaching the transport.
1251#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
1252#[serde(rename_all = "camelCase")]
1253pub struct PlaceOrder {
1254    /// Provider account.
1255    account_id: AccountId,
1256    /// Provider contract.
1257    contract_id: ContractId,
1258    /// Order type.
1259    #[serde(rename = "type")]
1260    order_type: OrderType,
1261    /// Order side.
1262    side: Side,
1263    /// Order quantity.
1264    size: i32,
1265    /// Optional limit price.
1266    #[serde(
1267        skip_serializing_if = "Option::is_none",
1268        with = "crate::decimal_serde::option"
1269    )]
1270    limit_price: Option<Decimal>,
1271    /// Optional stop price.
1272    #[serde(
1273        skip_serializing_if = "Option::is_none",
1274        with = "crate::decimal_serde::option"
1275    )]
1276    stop_price: Option<Decimal>,
1277    /// Absolute starting price level, required for trailing-stop orders.
1278    #[serde(
1279        skip_serializing_if = "Option::is_none",
1280        with = "crate::decimal_serde::option"
1281    )]
1282    trail_price: Option<Decimal>,
1283    /// Optional caller tag. It must be unique within the account.
1284    #[serde(skip_serializing_if = "Option::is_none")]
1285    custom_tag: Option<String>,
1286    /// Optional stop-loss bracket.
1287    #[serde(skip_serializing_if = "Option::is_none")]
1288    stop_loss_bracket: Option<Bracket>,
1289    /// Optional take-profit bracket.
1290    #[serde(skip_serializing_if = "Option::is_none")]
1291    take_profit_bracket: Option<Bracket>,
1292}
1293
1294impl PlaceOrder {
1295    /// Starts a validated order-placement request.
1296    pub fn builder(
1297        account_id: AccountId,
1298        contract_id: ContractId,
1299        order_type: OrderType,
1300        side: Side,
1301        quantity: i32,
1302    ) -> PlaceOrderBuilder {
1303        PlaceOrderBuilder {
1304            account_id,
1305            contract_id,
1306            order_type,
1307            side,
1308            size: quantity,
1309            limit_price: None,
1310            stop_price: None,
1311            trail_price: None,
1312            custom_tag: None,
1313            stop_loss_bracket: None,
1314            take_profit_bracket: None,
1315        }
1316    }
1317
1318    /// Returns the provider account.
1319    #[must_use]
1320    pub const fn account_id(&self) -> AccountId {
1321        self.account_id
1322    }
1323
1324    /// Borrows the provider contract.
1325    #[must_use]
1326    pub const fn contract_id(&self) -> &ContractId {
1327        &self.contract_id
1328    }
1329
1330    /// Returns the order type.
1331    #[must_use]
1332    pub const fn order_type(&self) -> OrderType {
1333        self.order_type
1334    }
1335
1336    /// Returns the order side.
1337    #[must_use]
1338    pub const fn side(&self) -> Side {
1339        self.side
1340    }
1341
1342    /// Returns the positive order quantity.
1343    #[must_use]
1344    pub const fn size(&self) -> i32 {
1345        self.size
1346    }
1347
1348    /// Returns the optional limit price.
1349    #[must_use]
1350    pub const fn limit_price(&self) -> Option<Decimal> {
1351        self.limit_price
1352    }
1353
1354    /// Returns the optional stop price.
1355    #[must_use]
1356    pub const fn stop_price(&self) -> Option<Decimal> {
1357        self.stop_price
1358    }
1359
1360    /// Returns the absolute starting price level for a trailing-stop order.
1361    ///
1362    /// This input differs from the distance returned in [`Order::trail_price`].
1363    #[must_use]
1364    pub const fn trail_price(&self) -> Option<Decimal> {
1365        self.trail_price
1366    }
1367
1368    /// Borrows the optional caller tag.
1369    #[must_use]
1370    pub fn custom_tag(&self) -> Option<&str> {
1371        self.custom_tag.as_deref()
1372    }
1373
1374    /// Borrows the optional stop-loss bracket.
1375    #[must_use]
1376    pub const fn stop_loss_bracket(&self) -> Option<&Bracket> {
1377        self.stop_loss_bracket.as_ref()
1378    }
1379
1380    /// Borrows the optional take-profit bracket.
1381    #[must_use]
1382    pub const fn take_profit_bracket(&self) -> Option<&Bracket> {
1383        self.take_profit_bracket.as_ref()
1384    }
1385}
1386
1387/// Builder for a validated [`PlaceOrder`].
1388#[derive(Clone, Debug)]
1389#[must_use = "a PlaceOrderBuilder does nothing until build is called"]
1390pub struct PlaceOrderBuilder {
1391    account_id: AccountId,
1392    contract_id: ContractId,
1393    order_type: OrderType,
1394    side: Side,
1395    size: i32,
1396    limit_price: Option<Decimal>,
1397    stop_price: Option<Decimal>,
1398    trail_price: Option<Decimal>,
1399    custom_tag: Option<String>,
1400    stop_loss_bracket: Option<Bracket>,
1401    take_profit_bracket: Option<Bracket>,
1402}
1403
1404impl PlaceOrderBuilder {
1405    /// Sets the optional limit price.
1406    pub const fn limit_price(mut self, limit_price: Decimal) -> Self {
1407        self.limit_price = Some(limit_price);
1408        self
1409    }
1410
1411    /// Sets the optional stop price.
1412    pub const fn stop_price(mut self, stop_price: Decimal) -> Self {
1413        self.stop_price = Some(stop_price);
1414        self
1415    }
1416
1417    /// Sets the absolute starting price level required for a trailing stop.
1418    ///
1419    /// The provider derives a fixed tick distance from its last traded price
1420    /// when it receives the request, dropping fractional ticks. It checks tick
1421    /// alignment, quote availability, and a maximum distance of 1,000 ticks.
1422    /// These checks require provider state and are not performed by this builder.
1423    /// See the [placement reference](https://gateway.docs.projectx.com/docs/api-reference/order/order-place/).
1424    pub const fn trail_price(mut self, trail_price: Decimal) -> Self {
1425        self.trail_price = Some(trail_price);
1426        self
1427    }
1428
1429    /// Sets the optional caller tag, which must be unique within the account.
1430    pub fn custom_tag(mut self, custom_tag: impl Into<String>) -> Self {
1431        self.custom_tag = Some(custom_tag.into());
1432        self
1433    }
1434
1435    /// Sets the optional stop-loss bracket.
1436    ///
1437    /// Requires the account's Auto OCO Brackets mode; see [`Bracket`].
1438    pub fn stop_loss_bracket(mut self, stop_loss_bracket: Bracket) -> Self {
1439        self.stop_loss_bracket = Some(stop_loss_bracket);
1440        self
1441    }
1442
1443    /// Sets the optional take-profit bracket.
1444    ///
1445    /// Requires the account's Auto OCO Brackets mode; see [`Bracket`].
1446    pub fn take_profit_bracket(mut self, take_profit_bracket: Bracket) -> Self {
1447        self.take_profit_bracket = Some(take_profit_bracket);
1448        self
1449    }
1450
1451    /// Validates and builds the order-placement request.
1452    ///
1453    /// # Errors
1454    ///
1455    /// Returns an error when the order quantity is zero or negative, when its
1456    /// order type is undocumented for placement, or when its side code is
1457    /// unknown to this crate version. Returns
1458    /// [`RequestValidationError::MissingTrailPrice`] when a trailing stop has
1459    /// no absolute starting price level.
1460    pub fn build(self) -> Result<PlaceOrder, RequestValidationError> {
1461        if self.size <= 0 {
1462            return Err(RequestValidationError::NonPositiveOrderSize);
1463        }
1464        validate_request_order_type(self.order_type)?;
1465        if let Side::Unknown(code) = self.side {
1466            return Err(RequestValidationError::UnsupportedOrderSide { code });
1467        }
1468        if self.order_type == OrderType::TrailingStop && self.trail_price.is_none() {
1469            return Err(RequestValidationError::MissingTrailPrice);
1470        }
1471        Ok(PlaceOrder {
1472            account_id: self.account_id,
1473            contract_id: self.contract_id,
1474            order_type: self.order_type,
1475            side: self.side,
1476            size: self.size,
1477            limit_price: self.limit_price,
1478            stop_price: self.stop_price,
1479            trail_price: self.trail_price,
1480            custom_tag: self.custom_tag,
1481            stop_loss_bracket: self.stop_loss_bracket,
1482            take_profit_bracket: self.take_profit_bracket,
1483        })
1484    }
1485}
1486
1487fn validate_request_order_type(order_type: OrderType) -> Result<(), RequestValidationError> {
1488    match order_type {
1489        OrderType::Limit
1490        | OrderType::Market
1491        | OrderType::Stop
1492        | OrderType::TrailingStop
1493        | OrderType::JoinBid
1494        | OrderType::JoinAsk => Ok(()),
1495        OrderType::StopLimit => Err(RequestValidationError::UnsupportedOrderType { code: 3 }),
1496        OrderType::Unknown(code) => Err(RequestValidationError::UnsupportedOrderType { code }),
1497    }
1498}
1499
1500/// Successful order-placement result.
1501#[derive(Clone, Debug, Eq, PartialEq)]
1502#[non_exhaustive]
1503pub struct OrderResponse {
1504    /// Provider order identifier.
1505    pub order_id: OrderId,
1506}
1507
1508/// Order cancellation parameters.
1509#[derive(Clone, Debug, Serialize)]
1510#[serde(rename_all = "camelCase")]
1511pub struct CancelOrder {
1512    /// Provider account.
1513    pub account_id: AccountId,
1514    /// Provider order.
1515    pub order_id: OrderId,
1516}
1517
1518/// Order modification parameters.
1519///
1520/// Construct this request with [`ModifyOrder::builder`], which requires at
1521/// least one replacement value and rejects non-positive replacement sizes.
1522#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
1523#[serde(rename_all = "camelCase")]
1524pub struct ModifyOrder {
1525    /// Provider account.
1526    account_id: AccountId,
1527    /// Provider order.
1528    order_id: OrderId,
1529    /// Optional replacement quantity.
1530    #[serde(skip_serializing_if = "Option::is_none")]
1531    size: Option<i32>,
1532    /// Optional replacement limit price.
1533    #[serde(
1534        skip_serializing_if = "Option::is_none",
1535        with = "crate::decimal_serde::option"
1536    )]
1537    limit_price: Option<Decimal>,
1538    /// Optional replacement stop price.
1539    #[serde(
1540        skip_serializing_if = "Option::is_none",
1541        with = "crate::decimal_serde::option"
1542    )]
1543    stop_price: Option<Decimal>,
1544    /// Optional absolute replacement price level for a trailing stop.
1545    #[serde(
1546        skip_serializing_if = "Option::is_none",
1547        with = "crate::decimal_serde::option"
1548    )]
1549    trail_price: Option<Decimal>,
1550}
1551
1552impl ModifyOrder {
1553    /// Starts a validated order-modification request.
1554    pub const fn builder(account_id: AccountId, order_id: OrderId) -> ModifyOrderBuilder {
1555        ModifyOrderBuilder {
1556            account_id,
1557            order_id,
1558            size: None,
1559            limit_price: None,
1560            stop_price: None,
1561            trail_price: None,
1562        }
1563    }
1564
1565    /// Returns the provider account.
1566    #[must_use]
1567    pub const fn account_id(&self) -> AccountId {
1568        self.account_id
1569    }
1570
1571    /// Returns the provider order.
1572    #[must_use]
1573    pub const fn order_id(&self) -> OrderId {
1574        self.order_id
1575    }
1576
1577    /// Returns the optional positive replacement quantity.
1578    #[must_use]
1579    pub const fn size(&self) -> Option<i32> {
1580        self.size
1581    }
1582
1583    /// Returns the optional replacement limit price.
1584    #[must_use]
1585    pub const fn limit_price(&self) -> Option<Decimal> {
1586        self.limit_price
1587    }
1588
1589    /// Returns the optional replacement stop price.
1590    #[must_use]
1591    pub const fn stop_price(&self) -> Option<Decimal> {
1592        self.stop_price
1593    }
1594
1595    /// Returns the optional absolute replacement price level for a trailing stop.
1596    ///
1597    /// This input differs from the distance returned in [`Order::trail_price`].
1598    #[must_use]
1599    pub const fn trail_price(&self) -> Option<Decimal> {
1600        self.trail_price
1601    }
1602}
1603
1604/// Builder for a validated [`ModifyOrder`].
1605#[derive(Clone, Copy, Debug)]
1606#[must_use = "a ModifyOrderBuilder does nothing until build is called"]
1607pub struct ModifyOrderBuilder {
1608    account_id: AccountId,
1609    order_id: OrderId,
1610    size: Option<i32>,
1611    limit_price: Option<Decimal>,
1612    stop_price: Option<Decimal>,
1613    trail_price: Option<Decimal>,
1614}
1615
1616impl ModifyOrderBuilder {
1617    /// Sets the replacement quantity.
1618    pub const fn size(mut self, size: i32) -> Self {
1619        self.size = Some(size);
1620        self
1621    }
1622
1623    /// Sets the replacement limit price.
1624    pub const fn limit_price(mut self, limit_price: Decimal) -> Self {
1625        self.limit_price = Some(limit_price);
1626        self
1627    }
1628
1629    /// Sets the replacement stop price.
1630    pub const fn stop_price(mut self, stop_price: Decimal) -> Self {
1631        self.stop_price = Some(stop_price);
1632        self
1633    }
1634
1635    /// Sets an absolute replacement price level for a trailing-stop order.
1636    ///
1637    /// The provider recalculates the tick distance using its last traded price
1638    /// at modification time. Tick alignment and quote availability are checked
1639    /// by the provider, but modification has no maximum-distance check. Supplying
1640    /// the distance from [`Order::trail_price`] can therefore be accepted with
1641    /// an unintended trail. Omit this setter to leave the trail unchanged.
1642    /// See the [modification reference](https://gateway.docs.projectx.com/docs/api-reference/order/order-modify/).
1643    pub const fn trail_price(mut self, trail_price: Decimal) -> Self {
1644        self.trail_price = Some(trail_price);
1645        self
1646    }
1647
1648    /// Validates and builds the order-modification request.
1649    ///
1650    /// # Errors
1651    ///
1652    /// Returns [`RequestValidationError::NonPositiveReplacementSize`] when a
1653    /// replacement quantity is zero or negative, or
1654    /// [`RequestValidationError::EmptyModification`] when no replacement value was
1655    /// supplied.
1656    pub fn build(self) -> Result<ModifyOrder, RequestValidationError> {
1657        if self.size.is_some_and(|size| size <= 0) {
1658            return Err(RequestValidationError::NonPositiveReplacementSize);
1659        }
1660        if self.size.is_none()
1661            && self.limit_price.is_none()
1662            && self.stop_price.is_none()
1663            && self.trail_price.is_none()
1664        {
1665            return Err(RequestValidationError::EmptyModification);
1666        }
1667        Ok(ModifyOrder {
1668            account_id: self.account_id,
1669            order_id: self.order_id,
1670            size: self.size,
1671            limit_price: self.limit_price,
1672            stop_price: self.stop_price,
1673            trail_price: self.trail_price,
1674        })
1675    }
1676}
1677
1678/// Position close parameters.
1679#[derive(Clone, Debug, Serialize)]
1680#[serde(rename_all = "camelCase")]
1681pub struct CloseContract {
1682    /// Provider account.
1683    pub account_id: AccountId,
1684    /// Provider contract.
1685    pub contract_id: ContractId,
1686}
1687
1688/// Partial-position close parameters.
1689#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
1690#[serde(rename_all = "camelCase")]
1691pub struct PartialCloseContract {
1692    /// Provider account.
1693    account_id: AccountId,
1694    /// Provider contract.
1695    contract_id: ContractId,
1696    /// Positive quantity to close.
1697    size: i32,
1698}
1699
1700impl PartialCloseContract {
1701    /// Creates a partial-position close with a positive quantity.
1702    ///
1703    /// # Errors
1704    ///
1705    /// Returns [`RequestValidationError::NonPositivePartialCloseSize`] when
1706    /// `size` is zero or negative.
1707    pub fn new(
1708        account_id: AccountId,
1709        contract_id: ContractId,
1710        size: i32,
1711    ) -> Result<Self, RequestValidationError> {
1712        if size <= 0 {
1713            return Err(RequestValidationError::NonPositivePartialCloseSize);
1714        }
1715        Ok(Self {
1716            account_id,
1717            contract_id,
1718            size,
1719        })
1720    }
1721
1722    /// Returns the provider account.
1723    #[must_use]
1724    pub const fn account_id(&self) -> AccountId {
1725        self.account_id
1726    }
1727
1728    /// Borrows the provider contract.
1729    #[must_use]
1730    pub const fn contract_id(&self) -> &ContractId {
1731        &self.contract_id
1732    }
1733
1734    /// Returns the positive quantity to close.
1735    #[must_use]
1736    pub const fn size(&self) -> i32 {
1737        self.size
1738    }
1739}
1740
1741/// A `ProjectX` open position.
1742#[derive(Clone, Debug, Deserialize, PartialEq)]
1743#[non_exhaustive]
1744#[serde(rename_all = "camelCase")]
1745pub struct Position {
1746    /// Provider position identifier.
1747    pub id: PositionId,
1748    /// Provider account.
1749    pub account_id: AccountId,
1750    /// Provider contract.
1751    pub contract_id: ContractId,
1752    /// Provider contract display name, when supplied.
1753    #[serde(default)]
1754    pub contract_display_name: Option<String>,
1755    /// Provider creation timestamp.
1756    pub creation_timestamp: Timestamp,
1757    /// Provider position-type code.
1758    #[serde(rename = "type")]
1759    pub position_type: PositionType,
1760    /// Signed or directional provider quantity.
1761    pub size: i32,
1762    /// Average entry price.
1763    #[serde(with = "crate::decimal_serde")]
1764    pub average_price: Decimal,
1765}
1766
1767/// Trade search parameters.
1768#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
1769#[serde(rename_all = "camelCase")]
1770pub struct TradeSearch {
1771    /// Provider account.
1772    account_id: AccountId,
1773    /// Absolute range start.
1774    start_timestamp: Timestamp,
1775    /// Optional absolute range end.
1776    #[serde(skip_serializing_if = "Option::is_none")]
1777    end_timestamp: Option<Timestamp>,
1778}
1779
1780/// Trade search parameters with independently optional timestamp bounds.
1781///
1782/// Construct this request with [`TradeQuery::builder`]. Omitting both bounds
1783/// requests every trade available for the selected account.
1784#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
1785#[serde(rename_all = "camelCase")]
1786pub struct TradeQuery {
1787    /// Provider account.
1788    account_id: AccountId,
1789    /// Optional absolute range start.
1790    #[serde(skip_serializing_if = "Option::is_none")]
1791    start_timestamp: Option<Timestamp>,
1792    /// Optional absolute range end.
1793    #[serde(skip_serializing_if = "Option::is_none")]
1794    end_timestamp: Option<Timestamp>,
1795}
1796
1797impl TradeQuery {
1798    /// Starts a trade query for an account with no timestamp bounds.
1799    pub const fn builder(account_id: AccountId) -> TradeQueryBuilder {
1800        TradeQueryBuilder {
1801            account_id,
1802            start_timestamp: None,
1803            end_timestamp: None,
1804        }
1805    }
1806
1807    /// Returns the provider account.
1808    #[must_use]
1809    pub const fn account_id(&self) -> AccountId {
1810        self.account_id
1811    }
1812
1813    /// Returns the optional lower timestamp bound.
1814    #[must_use]
1815    pub const fn start_timestamp(&self) -> Option<Timestamp> {
1816        self.start_timestamp
1817    }
1818
1819    /// Returns the optional upper timestamp bound.
1820    #[must_use]
1821    pub const fn end_timestamp(&self) -> Option<Timestamp> {
1822        self.end_timestamp
1823    }
1824}
1825
1826/// Builder for a validated [`TradeQuery`].
1827#[derive(Clone, Copy, Debug)]
1828#[must_use = "a TradeQueryBuilder does nothing until build is called"]
1829pub struct TradeQueryBuilder {
1830    account_id: AccountId,
1831    start_timestamp: Option<Timestamp>,
1832    end_timestamp: Option<Timestamp>,
1833}
1834
1835impl TradeQueryBuilder {
1836    /// Sets the optional lower timestamp bound.
1837    pub const fn start_timestamp(mut self, start_timestamp: Timestamp) -> Self {
1838        self.start_timestamp = Some(start_timestamp);
1839        self
1840    }
1841
1842    /// Sets the optional upper timestamp bound.
1843    pub const fn end_timestamp(mut self, end_timestamp: Timestamp) -> Self {
1844        self.end_timestamp = Some(end_timestamp);
1845        self
1846    }
1847
1848    /// Validates and builds the trade query.
1849    ///
1850    /// # Errors
1851    ///
1852    /// Returns [`RequestValidationError::SearchRangeNotIncreasing`] when both
1853    /// bounds are present and the end is not later than the start.
1854    pub fn build(self) -> Result<TradeQuery, RequestValidationError> {
1855        validate_search_range(self.start_timestamp, self.end_timestamp)?;
1856        Ok(TradeQuery {
1857            account_id: self.account_id,
1858            start_timestamp: self.start_timestamp,
1859            end_timestamp: self.end_timestamp,
1860        })
1861    }
1862}
1863
1864impl TradeSearch {
1865    /// Creates a validated execution search.
1866    ///
1867    /// # Errors
1868    ///
1869    /// Returns [`RequestValidationError::SearchRangeNotIncreasing`] when an
1870    /// end timestamp is present and is not later than the start.
1871    pub fn new(
1872        account_id: AccountId,
1873        start_timestamp: Timestamp,
1874        end_timestamp: Option<Timestamp>,
1875    ) -> Result<Self, RequestValidationError> {
1876        validate_search_range(Some(start_timestamp), end_timestamp)?;
1877        Ok(Self {
1878            account_id,
1879            start_timestamp,
1880            end_timestamp,
1881        })
1882    }
1883
1884    /// Returns the provider account.
1885    #[must_use]
1886    pub const fn account_id(&self) -> AccountId {
1887        self.account_id
1888    }
1889
1890    /// Returns the range start.
1891    #[must_use]
1892    pub const fn start_timestamp(&self) -> Timestamp {
1893        self.start_timestamp
1894    }
1895
1896    /// Returns the optional range end.
1897    #[must_use]
1898    pub const fn end_timestamp(&self) -> Option<Timestamp> {
1899        self.end_timestamp
1900    }
1901}
1902
1903fn validate_search_range(
1904    start_timestamp: Option<Timestamp>,
1905    end_timestamp: Option<Timestamp>,
1906) -> Result<(), RequestValidationError> {
1907    if start_timestamp
1908        .zip(end_timestamp)
1909        .is_some_and(|(start, end)| end <= start)
1910    {
1911        Err(RequestValidationError::SearchRangeNotIncreasing)
1912    } else {
1913        Ok(())
1914    }
1915}
1916
1917/// A `ProjectX` execution trade.
1918#[derive(Clone, Debug, Deserialize, PartialEq)]
1919#[non_exhaustive]
1920#[serde(rename_all = "camelCase")]
1921pub struct Trade {
1922    /// Provider trade identifier.
1923    pub id: TradeId,
1924    /// Provider account.
1925    pub account_id: AccountId,
1926    /// Provider contract.
1927    pub contract_id: ContractId,
1928    /// Provider creation timestamp.
1929    pub creation_timestamp: Timestamp,
1930    /// Execution price.
1931    #[serde(with = "crate::decimal_serde")]
1932    pub price: Decimal,
1933    /// Optional realized P&L.
1934    #[serde(default, with = "crate::decimal_serde::option")]
1935    pub profit_and_loss: Option<Decimal>,
1936    /// Provider fees.
1937    #[serde(with = "crate::decimal_serde")]
1938    pub fees: Decimal,
1939    /// Optional provider commissions, separate from fees.
1940    #[serde(default, with = "crate::decimal_serde::option")]
1941    pub commissions: Option<Decimal>,
1942    /// Execution side.
1943    pub side: Side,
1944    /// Execution quantity.
1945    pub size: i32,
1946    /// Whether the provider voided this trade.
1947    pub voided: bool,
1948    /// Originating order.
1949    pub order_id: OrderId,
1950}
1951
1952/// Sparse quote update from the market hub.
1953///
1954/// The provider may send only the fields that changed. Callers that need a
1955/// consolidated snapshot must merge updates by symbol and preserve `None` as
1956/// unavailable data rather than substituting a zero price or volume.
1957#[derive(Clone, Debug, Deserialize, PartialEq)]
1958#[non_exhaustive]
1959#[serde(rename_all = "camelCase")]
1960pub struct MarketQuote {
1961    /// Provider symbol identifier.
1962    #[serde(alias = "symbol")]
1963    pub raw_symbol: SymbolId,
1964    /// Human-readable symbol name, when supplied.
1965    #[serde(default)]
1966    pub symbol_name: Option<String>,
1967    /// Last trade price, when supplied by this update.
1968    #[serde(default, with = "crate::decimal_serde::option")]
1969    pub last_price: Option<Decimal>,
1970    /// Best bid price, when supplied by this update.
1971    #[serde(default, with = "crate::decimal_serde::option")]
1972    pub best_bid: Option<Decimal>,
1973    /// Best ask price, when supplied by this update.
1974    #[serde(default, with = "crate::decimal_serde::option")]
1975    pub best_ask: Option<Decimal>,
1976    /// Session price change, when supplied by this update.
1977    #[serde(default, with = "crate::decimal_serde::option")]
1978    pub change: Option<Decimal>,
1979    /// Session percent change, when supplied by this update.
1980    #[serde(default, with = "crate::decimal_serde::option")]
1981    pub change_percent: Option<Decimal>,
1982    /// Session open, when supplied by this update.
1983    #[serde(default, with = "crate::decimal_serde::option")]
1984    pub open: Option<Decimal>,
1985    /// Session high, when supplied by this update.
1986    #[serde(default, with = "crate::decimal_serde::option")]
1987    pub high: Option<Decimal>,
1988    /// Session low, when supplied by this update.
1989    #[serde(default, with = "crate::decimal_serde::option")]
1990    pub low: Option<Decimal>,
1991    /// Session cumulative volume, when supplied by this update.
1992    #[serde(default)]
1993    pub volume: Option<i64>,
1994    /// Provider last-updated timestamp.
1995    pub last_updated: Timestamp,
1996    /// Event timestamp, when supplied separately from [`Self::last_updated`].
1997    #[serde(default)]
1998    pub timestamp: Option<Timestamp>,
1999}
2000
2001/// Depth-of-market update from the market hub.
2002#[derive(Clone, Debug, Deserialize, PartialEq)]
2003#[non_exhaustive]
2004#[serde(rename_all = "camelCase")]
2005pub struct MarketDepth {
2006    /// Provider symbol identifier, when supplied.
2007    #[serde(default, alias = "symbolId")]
2008    pub symbol_id: Option<SymbolId>,
2009    /// Event timestamp.
2010    pub timestamp: Timestamp,
2011    /// Provider depth event code.
2012    #[serde(rename = "type")]
2013    pub depth_type: DepthType,
2014    /// Price level.
2015    #[serde(with = "crate::decimal_serde")]
2016    pub price: Decimal,
2017    /// Incremental volume for the update.
2018    pub volume: i64,
2019    /// Resting volume after the update.
2020    pub current_volume: i64,
2021    /// Zero-based level index, when supplied.
2022    #[serde(default)]
2023    pub index: Option<i32>,
2024}
2025
2026/// Trade print from the market hub.
2027#[derive(Clone, Debug, Deserialize, PartialEq)]
2028#[non_exhaustive]
2029#[serde(rename_all = "camelCase")]
2030pub struct MarketTrade {
2031    /// Provider symbol identifier.
2032    pub symbol_id: SymbolId,
2033    /// Trade price.
2034    #[serde(with = "crate::decimal_serde")]
2035    pub price: Decimal,
2036    /// Event timestamp.
2037    pub timestamp: Timestamp,
2038    /// Provider aggressor classification.
2039    #[serde(rename = "type")]
2040    pub trade_type: TradeLogType,
2041    /// Trade quantity.
2042    pub volume: i64,
2043}
2044
2045/// Successful response for an operation without a result body.
2046#[derive(Clone, Copy, Debug, Eq, PartialEq)]
2047#[non_exhaustive]
2048pub struct OperationResponse;
2049
2050/// A decoded provider response envelope.
2051///
2052/// `T` is the endpoint body read from an accepted response. `R` is the
2053/// endpoint-specific detail read from the remaining fields of a rejection;
2054/// [`EmptyBody`] ignores them.
2055#[derive(Debug)]
2056pub(crate) enum Envelope<T, R = EmptyBody> {
2057    Accepted(T),
2058    Rejected {
2059        error_code: i32,
2060        error_message: Option<String>,
2061        detail: R,
2062    },
2063    InconsistentStatus {
2064        success: bool,
2065        error_code: i32,
2066    },
2067}
2068
2069impl<'de, T, R> Deserialize<'de> for Envelope<T, R>
2070where
2071    T: serde::de::DeserializeOwned,
2072    R: serde::de::DeserializeOwned,
2073{
2074    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
2075    where
2076        D: serde::Deserializer<'de>,
2077    {
2078        use serde::de::Error as _;
2079
2080        let mut object = BTreeMap::<String, Box<RawValue>>::deserialize(deserializer)?;
2081        let success = object
2082            .remove("success")
2083            .ok_or_else(|| D::Error::custom("provider response success flag is missing"))
2084            .and_then(|value| serde_json::from_str(value.get()).map_err(D::Error::custom))?;
2085        let error_code = object
2086            .remove("errorCode")
2087            .ok_or_else(|| D::Error::custom("provider response error code is missing"))
2088            .and_then(|value| serde_json::from_str(value.get()).map_err(D::Error::custom))?;
2089        let error_message = object.remove("errorMessage");
2090        if success != (error_code == 0) {
2091            return Ok(Self::InconsistentStatus {
2092                success,
2093                error_code,
2094            });
2095        }
2096        if !success {
2097            let error_message = error_message
2098                .map(|value| serde_json::from_str::<Option<String>>(value.get()))
2099                .transpose()
2100                .map_err(D::Error::custom)?
2101                .flatten();
2102            let detail = decode_remaining(object).map_err(D::Error::custom)?;
2103            return Ok(Self::Rejected {
2104                error_code,
2105                error_message,
2106                detail,
2107            });
2108        }
2109        let body = decode_remaining(object).map_err(D::Error::custom)?;
2110        Ok(Self::Accepted(body))
2111    }
2112}
2113
2114/// Decodes the envelope fields left after the status fields were removed.
2115fn decode_remaining<T>(object: BTreeMap<String, Box<RawValue>>) -> serde_json::Result<T>
2116where
2117    T: serde::de::DeserializeOwned,
2118{
2119    let mut body_json = String::from("{");
2120    for (index, (key, value)) in object.into_iter().enumerate() {
2121        if index > 0 {
2122            body_json.push(',');
2123        }
2124        body_json.push_str(&serde_json::to_string(&key)?);
2125        body_json.push(':');
2126        body_json.push_str(value.get());
2127    }
2128    body_json.push('}');
2129    serde_json::from_str(&body_json)
2130}
2131
2132#[derive(Debug, Deserialize)]
2133pub(crate) struct AccountsBody {
2134    #[serde(default)]
2135    pub(crate) accounts: ProviderList<Account>,
2136}
2137
2138#[derive(Debug, Deserialize)]
2139pub(crate) struct ContractsBody {
2140    #[serde(default)]
2141    pub(crate) contracts: ProviderList<Contract>,
2142}
2143
2144#[derive(Debug, Deserialize)]
2145pub(crate) struct ContractBody {
2146    pub(crate) contract: Contract,
2147}
2148
2149#[derive(Debug, Deserialize)]
2150pub(crate) struct BarsBody {
2151    #[serde(default)]
2152    pub(crate) bars: ProviderList<Bar>,
2153}
2154
2155#[derive(Debug, Deserialize)]
2156pub(crate) struct OrdersBody {
2157    #[serde(default)]
2158    pub(crate) orders: ProviderList<Order>,
2159}
2160
2161#[derive(Debug, Deserialize)]
2162pub(crate) struct OrderBody {
2163    pub(crate) order: Order,
2164}
2165
2166#[derive(Debug, Deserialize)]
2167#[serde(rename_all = "camelCase")]
2168pub(crate) struct PlaceOrderBody {
2169    pub(crate) order_id: Option<OrderId>,
2170}
2171
2172#[derive(Debug, Deserialize)]
2173pub(crate) struct PositionsBody {
2174    #[serde(default)]
2175    pub(crate) positions: ProviderList<Position>,
2176}
2177
2178#[derive(Debug, Deserialize)]
2179pub(crate) struct TradesBody {
2180    #[serde(default)]
2181    pub(crate) trades: ProviderList<Trade>,
2182}
2183
2184#[derive(Debug, Deserialize)]
2185pub(crate) struct EmptyBody {}
2186
2187#[cfg(test)]
2188mod tests {
2189    use super::*;
2190
2191    macro_rules! assert_absent {
2192        ($body:ty, $field:ident, $json:literal) => {{
2193            let envelope: Envelope<$body> = serde_json::from_str($json)
2194                .unwrap_or_else(|error| panic!("fixture envelope must decode: {error}"));
2195            let Envelope::Accepted(body) = envelope else {
2196                panic!("fixture envelope must be accepted");
2197            };
2198            assert_eq!(body.$field, ProviderList::Absent);
2199        }};
2200    }
2201
2202    #[test]
2203    fn list_bodies_keep_explicit_lists_distinct_from_missing_and_null() {
2204        assert_absent!(
2205            AccountsBody,
2206            accounts,
2207            r#"{"success":true,"errorCode":0,"accounts":null}"#
2208        );
2209        assert_absent!(
2210            ContractsBody,
2211            contracts,
2212            r#"{"success":true,"errorCode":0}"#
2213        );
2214        assert_absent!(
2215            OrdersBody,
2216            orders,
2217            r#"{"success":true,"errorCode":0,"orders":null}"#
2218        );
2219        assert_absent!(
2220            OrderPage,
2221            orders,
2222            r#"{"success":true,"errorCode":0,"orders":null}"#
2223        );
2224        assert_absent!(
2225            PositionsBody,
2226            positions,
2227            r#"{"success":true,"errorCode":0}"#
2228        );
2229        assert_absent!(
2230            TradesBody,
2231            trades,
2232            r#"{"success":true,"errorCode":0,"trades":null}"#
2233        );
2234    }
2235
2236    #[test]
2237    fn list_bodies_list_explicit_arrays_including_the_empty_array() {
2238        assert_eq!(
2239            serde_json::from_str::<PositionsBody>(r#"{"positions":[]}"#)
2240                .unwrap_or_else(|error| panic!("explicit empty list must decode: {error}"))
2241                .positions,
2242            ProviderList::Listed(Vec::new())
2243        );
2244    }
2245
2246    #[test]
2247    fn rejected_envelope_does_not_require_an_endpoint_body() {
2248        let envelope: Envelope<AccountsBody> =
2249            serde_json::from_str(r#"{"success":false,"errorCode":17,"errorMessage":"synthetic"}"#)
2250                .unwrap_or_else(|error| panic!("rejection envelope must decode: {error}"));
2251
2252        assert!(matches!(
2253            envelope,
2254            Envelope::Rejected {
2255                error_code: 17,
2256                error_message: Some(ref message),
2257                ..
2258            } if message == "synthetic"
2259        ));
2260    }
2261
2262    #[test]
2263    fn rejected_envelope_reads_a_null_or_absent_message_as_none() {
2264        for json in [
2265            r#"{"success":false,"errorCode":17,"errorMessage":null}"#,
2266            r#"{"success":false,"errorCode":17}"#,
2267        ] {
2268            let envelope: Envelope<AccountsBody> = serde_json::from_str(json)
2269                .unwrap_or_else(|error| panic!("rejection envelope must decode: {error}"));
2270            assert!(matches!(
2271                envelope,
2272                Envelope::Rejected {
2273                    error_code: 17,
2274                    error_message: None,
2275                    ..
2276                }
2277            ));
2278        }
2279    }
2280
2281    #[test]
2282    fn rejected_envelope_refuses_a_message_that_is_not_text() {
2283        assert!(
2284            serde_json::from_str::<Envelope<AccountsBody>>(
2285                r#"{"success":false,"errorCode":17,"errorMessage":17}"#
2286            )
2287            .is_err()
2288        );
2289    }
2290
2291    #[test]
2292    fn rejected_placement_envelope_reads_the_order_record() {
2293        let envelope: Envelope<PlaceOrderBody, PlaceOrderBody> =
2294            serde_json::from_str(r#"{"orderId":84,"success":false,"errorCode":2}"#)
2295                .unwrap_or_else(|error| panic!("placement rejection must decode: {error}"));
2296        let Envelope::Rejected { detail, .. } = envelope else {
2297            panic!("placement rejection must stay a rejection");
2298        };
2299        assert_eq!(detail.order_id.map(OrderId::get), Some(84));
2300        assert!(
2301            serde_json::from_str::<Envelope<PlaceOrderBody, PlaceOrderBody>>(
2302                r#"{"orderId":"not-an-id","success":false,"errorCode":2}"#
2303            )
2304            .is_err()
2305        );
2306    }
2307
2308    #[test]
2309    fn envelope_requires_a_consistent_provider_status() {
2310        assert!(
2311            serde_json::from_str::<Envelope<AccountsBody>>(r#"{"success":true,"accounts":[]}"#)
2312                .is_err()
2313        );
2314        for (json, success, error_code) in [
2315            (r#"{"success":true,"errorCode":17}"#, true, 17),
2316            (r#"{"success":false,"errorCode":0}"#, false, 0),
2317        ] {
2318            let envelope: Envelope<AccountsBody> = serde_json::from_str(json)
2319                .unwrap_or_else(|error| panic!("inconsistent envelope must decode: {error}"));
2320            assert!(matches!(
2321                envelope,
2322                Envelope::InconsistentStatus {
2323                    success: actual_success,
2324                    error_code: actual_error_code,
2325                } if actual_success == success && actual_error_code == error_code
2326            ));
2327        }
2328    }
2329}