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/// One page returned by [`Client::query_orders`](crate::Client::query_orders).
960#[derive(Clone, Debug, Deserialize, PartialEq)]
961#[non_exhaustive]
962#[serde(rename_all = "camelCase")]
963pub struct OrderPage {
964    /// Orders in provider-selected page order.
965    #[serde(default, deserialize_with = "null_to_empty")]
966    pub orders: Vec<Order>,
967    /// Total matching order count when requested and supplied by the provider.
968    #[serde(default)]
969    pub total_count: Option<i32>,
970}
971
972/// A `ProjectX` order.
973#[derive(Clone, Debug, Deserialize, PartialEq)]
974#[non_exhaustive]
975#[serde(rename_all = "camelCase")]
976pub struct Order {
977    /// Provider order identifier.
978    pub id: OrderId,
979    /// Provider account.
980    pub account_id: AccountId,
981    /// Provider contract.
982    pub contract_id: ContractId,
983    /// Provider symbol, when included by the endpoint.
984    #[serde(default)]
985    pub symbol_id: Option<SymbolId>,
986    /// Provider creation timestamp.
987    pub creation_timestamp: Timestamp,
988    /// Provider update timestamp.
989    pub update_timestamp: Timestamp,
990    /// Provider order status.
991    pub status: OrderStatus,
992    /// Provider order type.
993    #[serde(rename = "type")]
994    pub order_type: OrderType,
995    /// Order side.
996    pub side: Side,
997    /// Ordered quantity.
998    pub size: i32,
999    /// Optional limit price.
1000    #[serde(default, with = "crate::decimal_serde::option")]
1001    pub limit_price: Option<Decimal>,
1002    /// Optional stop price.
1003    #[serde(default, with = "crate::decimal_serde::option")]
1004    pub stop_price: Option<Decimal>,
1005    /// Optional cumulative filled quantity.
1006    #[serde(default)]
1007    pub fill_volume: Option<i32>,
1008    /// Optional average fill price.
1009    #[serde(default, with = "crate::decimal_serde::option")]
1010    pub filled_price: Option<Decimal>,
1011    /// Optional caller tag.
1012    #[serde(default)]
1013    pub custom_tag: Option<String>,
1014    /// Optional trailing distance in provider ticks.
1015    #[serde(default)]
1016    pub trail_distance: Option<i32>,
1017    /// Optional trailing distance in price units (`ticks * tick size`).
1018    ///
1019    /// Order searches return a distance here. Placement and modification
1020    /// instead accept an absolute price level; do not reuse this value as
1021    /// their `trail_price` input.
1022    #[serde(default, with = "crate::decimal_serde::option")]
1023    pub trail_price: Option<Decimal>,
1024    /// Parent order for a bracket child, when supplied.
1025    #[serde(default)]
1026    pub parent_order_id: Option<OrderId>,
1027    /// Provider-linked peer order, when supplied.
1028    #[serde(default)]
1029    pub linked_order_id: Option<OrderId>,
1030}
1031
1032/// Validation failures while constructing a provider request.
1033#[derive(Clone, Copy, Debug, Eq, Error, PartialEq)]
1034#[non_exhaustive]
1035pub enum RequestValidationError {
1036    /// An order placement quantity was zero or negative.
1037    #[error("order size must be positive")]
1038    NonPositiveOrderSize,
1039    /// A trailing-stop placement omitted its absolute starting price level.
1040    #[error("trailing-stop placement requires an absolute trail price")]
1041    MissingTrailPrice,
1042    /// A replacement quantity was zero or negative.
1043    #[error("replacement order size must be positive")]
1044    NonPositiveReplacementSize,
1045    /// An order modification contained no replacement values.
1046    #[error("order modification requires at least one replacement value")]
1047    EmptyModification,
1048    /// A bracket distance was zero or negative.
1049    #[error("bracket ticks must be positive")]
1050    NonPositiveBracketTicks,
1051    /// An order request used an undocumented or unknown provider type code.
1052    #[error("unsupported order type code {code}")]
1053    UnsupportedOrderType {
1054        /// Unrecognized provider wire code.
1055        code: i32,
1056    },
1057    /// An order request used a provider side code unknown to this crate version.
1058    #[error("unsupported order side code {code}")]
1059    UnsupportedOrderSide {
1060        /// Unrecognized provider wire code.
1061        code: i32,
1062    },
1063    /// An order query used a provider status code unknown to this crate version.
1064    #[error("unsupported order status code {code}")]
1065    UnsupportedOrderStatus {
1066        /// Unrecognized provider wire code.
1067        code: i32,
1068    },
1069    /// A v2 order query requested a zero or negative page size.
1070    #[error("order-query page size must be positive")]
1071    NonPositiveOrderPageSize,
1072    /// A v2 order query requested a negative page offset.
1073    #[error("order-query page offset must not be negative")]
1074    NegativeOrderPageOffset,
1075    /// A historical-bar unit count was zero or negative.
1076    #[error("historical-bar unit number must be positive")]
1077    NonPositiveHistoryUnitNumber,
1078    /// A historical-bar limit exceeded the provider-supported range.
1079    #[error("historical-bar limit must be between 1 and 20,000")]
1080    HistoryLimitOutOfRange,
1081    /// A historical-bar range ended at or before its start.
1082    #[error("historical-bar end time must be later than its start time")]
1083    HistoryRangeNotIncreasing,
1084    /// An order or trade search ended at or before its start.
1085    #[error("search end time must be later than its start time")]
1086    SearchRangeNotIncreasing,
1087    /// A partial-close quantity was zero or negative.
1088    #[error("partial-close size must be positive")]
1089    NonPositivePartialCloseSize,
1090}
1091
1092/// `ProjectX` bracket-leg configuration.
1093///
1094/// Placement accepts bracket legs only in the account's Auto OCO Brackets
1095/// mode. Position Brackets mode rejects them with provider code `2`, even
1096/// though the response can include an ID for the rejected order record.
1097#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
1098#[serde(rename_all = "camelCase")]
1099pub struct Bracket {
1100    /// Distance in provider ticks.
1101    ticks: i32,
1102    /// Bracket order type.
1103    #[serde(rename = "type")]
1104    order_type: OrderType,
1105}
1106
1107impl Bracket {
1108    /// Creates a bracket leg with a positive distance in ticks.
1109    ///
1110    /// # Errors
1111    ///
1112    /// Returns an error when `ticks` is zero or negative, or when `order_type`
1113    /// is not documented by the provider for bracket requests.
1114    pub fn new(ticks: i32, order_type: OrderType) -> Result<Self, RequestValidationError> {
1115        if ticks <= 0 {
1116            return Err(RequestValidationError::NonPositiveBracketTicks);
1117        }
1118        validate_request_order_type(order_type)?;
1119        Ok(Self { ticks, order_type })
1120    }
1121
1122    /// Returns the distance in provider ticks.
1123    #[must_use]
1124    pub const fn ticks(&self) -> i32 {
1125        self.ticks
1126    }
1127
1128    /// Returns the bracket order type.
1129    #[must_use]
1130    pub const fn order_type(&self) -> OrderType {
1131        self.order_type
1132    }
1133}
1134
1135/// Order placement parameters.
1136///
1137/// Construct this request with [`PlaceOrder::builder`], which prevents an
1138/// invalid non-positive quantity or trailing stop without a starting price
1139/// from reaching the transport.
1140#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
1141#[serde(rename_all = "camelCase")]
1142pub struct PlaceOrder {
1143    /// Provider account.
1144    account_id: AccountId,
1145    /// Provider contract.
1146    contract_id: ContractId,
1147    /// Order type.
1148    #[serde(rename = "type")]
1149    order_type: OrderType,
1150    /// Order side.
1151    side: Side,
1152    /// Order quantity.
1153    size: i32,
1154    /// Optional limit price.
1155    #[serde(
1156        skip_serializing_if = "Option::is_none",
1157        with = "crate::decimal_serde::option"
1158    )]
1159    limit_price: Option<Decimal>,
1160    /// Optional stop price.
1161    #[serde(
1162        skip_serializing_if = "Option::is_none",
1163        with = "crate::decimal_serde::option"
1164    )]
1165    stop_price: Option<Decimal>,
1166    /// Absolute starting price level, required for trailing-stop orders.
1167    #[serde(
1168        skip_serializing_if = "Option::is_none",
1169        with = "crate::decimal_serde::option"
1170    )]
1171    trail_price: Option<Decimal>,
1172    /// Optional caller tag. It must be unique within the account.
1173    #[serde(skip_serializing_if = "Option::is_none")]
1174    custom_tag: Option<String>,
1175    /// Optional stop-loss bracket.
1176    #[serde(skip_serializing_if = "Option::is_none")]
1177    stop_loss_bracket: Option<Bracket>,
1178    /// Optional take-profit bracket.
1179    #[serde(skip_serializing_if = "Option::is_none")]
1180    take_profit_bracket: Option<Bracket>,
1181}
1182
1183impl PlaceOrder {
1184    /// Starts a validated order-placement request.
1185    pub fn builder(
1186        account_id: AccountId,
1187        contract_id: ContractId,
1188        order_type: OrderType,
1189        side: Side,
1190        quantity: i32,
1191    ) -> PlaceOrderBuilder {
1192        PlaceOrderBuilder {
1193            account_id,
1194            contract_id,
1195            order_type,
1196            side,
1197            size: quantity,
1198            limit_price: None,
1199            stop_price: None,
1200            trail_price: None,
1201            custom_tag: None,
1202            stop_loss_bracket: None,
1203            take_profit_bracket: None,
1204        }
1205    }
1206
1207    /// Returns the provider account.
1208    #[must_use]
1209    pub const fn account_id(&self) -> AccountId {
1210        self.account_id
1211    }
1212
1213    /// Borrows the provider contract.
1214    #[must_use]
1215    pub const fn contract_id(&self) -> &ContractId {
1216        &self.contract_id
1217    }
1218
1219    /// Returns the order type.
1220    #[must_use]
1221    pub const fn order_type(&self) -> OrderType {
1222        self.order_type
1223    }
1224
1225    /// Returns the order side.
1226    #[must_use]
1227    pub const fn side(&self) -> Side {
1228        self.side
1229    }
1230
1231    /// Returns the positive order quantity.
1232    #[must_use]
1233    pub const fn size(&self) -> i32 {
1234        self.size
1235    }
1236
1237    /// Returns the optional limit price.
1238    #[must_use]
1239    pub const fn limit_price(&self) -> Option<Decimal> {
1240        self.limit_price
1241    }
1242
1243    /// Returns the optional stop price.
1244    #[must_use]
1245    pub const fn stop_price(&self) -> Option<Decimal> {
1246        self.stop_price
1247    }
1248
1249    /// Returns the absolute starting price level for a trailing-stop order.
1250    ///
1251    /// This input differs from the distance returned in [`Order::trail_price`].
1252    #[must_use]
1253    pub const fn trail_price(&self) -> Option<Decimal> {
1254        self.trail_price
1255    }
1256
1257    /// Borrows the optional caller tag.
1258    #[must_use]
1259    pub fn custom_tag(&self) -> Option<&str> {
1260        self.custom_tag.as_deref()
1261    }
1262
1263    /// Borrows the optional stop-loss bracket.
1264    #[must_use]
1265    pub const fn stop_loss_bracket(&self) -> Option<&Bracket> {
1266        self.stop_loss_bracket.as_ref()
1267    }
1268
1269    /// Borrows the optional take-profit bracket.
1270    #[must_use]
1271    pub const fn take_profit_bracket(&self) -> Option<&Bracket> {
1272        self.take_profit_bracket.as_ref()
1273    }
1274}
1275
1276/// Builder for a validated [`PlaceOrder`].
1277#[derive(Clone, Debug)]
1278#[must_use = "a PlaceOrderBuilder does nothing until build is called"]
1279pub struct PlaceOrderBuilder {
1280    account_id: AccountId,
1281    contract_id: ContractId,
1282    order_type: OrderType,
1283    side: Side,
1284    size: i32,
1285    limit_price: Option<Decimal>,
1286    stop_price: Option<Decimal>,
1287    trail_price: Option<Decimal>,
1288    custom_tag: Option<String>,
1289    stop_loss_bracket: Option<Bracket>,
1290    take_profit_bracket: Option<Bracket>,
1291}
1292
1293impl PlaceOrderBuilder {
1294    /// Sets the optional limit price.
1295    pub const fn limit_price(mut self, limit_price: Decimal) -> Self {
1296        self.limit_price = Some(limit_price);
1297        self
1298    }
1299
1300    /// Sets the optional stop price.
1301    pub const fn stop_price(mut self, stop_price: Decimal) -> Self {
1302        self.stop_price = Some(stop_price);
1303        self
1304    }
1305
1306    /// Sets the absolute starting price level required for a trailing stop.
1307    ///
1308    /// The provider derives a fixed tick distance from its last traded price
1309    /// when it receives the request, dropping fractional ticks. It checks tick
1310    /// alignment, quote availability, and a maximum distance of 1,000 ticks.
1311    /// These checks require provider state and are not performed by this builder.
1312    /// See the [placement reference](https://gateway.docs.projectx.com/docs/api-reference/order/order-place/).
1313    pub const fn trail_price(mut self, trail_price: Decimal) -> Self {
1314        self.trail_price = Some(trail_price);
1315        self
1316    }
1317
1318    /// Sets the optional caller tag, which must be unique within the account.
1319    pub fn custom_tag(mut self, custom_tag: impl Into<String>) -> Self {
1320        self.custom_tag = Some(custom_tag.into());
1321        self
1322    }
1323
1324    /// Sets the optional stop-loss bracket.
1325    ///
1326    /// Requires the account's Auto OCO Brackets mode; see [`Bracket`].
1327    pub fn stop_loss_bracket(mut self, stop_loss_bracket: Bracket) -> Self {
1328        self.stop_loss_bracket = Some(stop_loss_bracket);
1329        self
1330    }
1331
1332    /// Sets the optional take-profit bracket.
1333    ///
1334    /// Requires the account's Auto OCO Brackets mode; see [`Bracket`].
1335    pub fn take_profit_bracket(mut self, take_profit_bracket: Bracket) -> Self {
1336        self.take_profit_bracket = Some(take_profit_bracket);
1337        self
1338    }
1339
1340    /// Validates and builds the order-placement request.
1341    ///
1342    /// # Errors
1343    ///
1344    /// Returns an error when the order quantity is zero or negative, when its
1345    /// order type is undocumented for placement, or when its side code is
1346    /// unknown to this crate version. Returns
1347    /// [`RequestValidationError::MissingTrailPrice`] when a trailing stop has
1348    /// no absolute starting price level.
1349    pub fn build(self) -> Result<PlaceOrder, RequestValidationError> {
1350        if self.size <= 0 {
1351            return Err(RequestValidationError::NonPositiveOrderSize);
1352        }
1353        validate_request_order_type(self.order_type)?;
1354        if let Side::Unknown(code) = self.side {
1355            return Err(RequestValidationError::UnsupportedOrderSide { code });
1356        }
1357        if self.order_type == OrderType::TrailingStop && self.trail_price.is_none() {
1358            return Err(RequestValidationError::MissingTrailPrice);
1359        }
1360        Ok(PlaceOrder {
1361            account_id: self.account_id,
1362            contract_id: self.contract_id,
1363            order_type: self.order_type,
1364            side: self.side,
1365            size: self.size,
1366            limit_price: self.limit_price,
1367            stop_price: self.stop_price,
1368            trail_price: self.trail_price,
1369            custom_tag: self.custom_tag,
1370            stop_loss_bracket: self.stop_loss_bracket,
1371            take_profit_bracket: self.take_profit_bracket,
1372        })
1373    }
1374}
1375
1376fn validate_request_order_type(order_type: OrderType) -> Result<(), RequestValidationError> {
1377    match order_type {
1378        OrderType::Limit
1379        | OrderType::Market
1380        | OrderType::Stop
1381        | OrderType::TrailingStop
1382        | OrderType::JoinBid
1383        | OrderType::JoinAsk => Ok(()),
1384        OrderType::StopLimit => Err(RequestValidationError::UnsupportedOrderType { code: 3 }),
1385        OrderType::Unknown(code) => Err(RequestValidationError::UnsupportedOrderType { code }),
1386    }
1387}
1388
1389/// Successful order-placement result.
1390#[derive(Clone, Debug, Eq, PartialEq)]
1391#[non_exhaustive]
1392pub struct OrderResponse {
1393    /// Provider order identifier.
1394    pub order_id: OrderId,
1395}
1396
1397/// Order cancellation parameters.
1398#[derive(Clone, Debug, Serialize)]
1399#[serde(rename_all = "camelCase")]
1400pub struct CancelOrder {
1401    /// Provider account.
1402    pub account_id: AccountId,
1403    /// Provider order.
1404    pub order_id: OrderId,
1405}
1406
1407/// Order modification parameters.
1408///
1409/// Construct this request with [`ModifyOrder::builder`], which requires at
1410/// least one replacement value and rejects non-positive replacement sizes.
1411#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
1412#[serde(rename_all = "camelCase")]
1413pub struct ModifyOrder {
1414    /// Provider account.
1415    account_id: AccountId,
1416    /// Provider order.
1417    order_id: OrderId,
1418    /// Optional replacement quantity.
1419    #[serde(skip_serializing_if = "Option::is_none")]
1420    size: Option<i32>,
1421    /// Optional replacement limit price.
1422    #[serde(
1423        skip_serializing_if = "Option::is_none",
1424        with = "crate::decimal_serde::option"
1425    )]
1426    limit_price: Option<Decimal>,
1427    /// Optional replacement stop price.
1428    #[serde(
1429        skip_serializing_if = "Option::is_none",
1430        with = "crate::decimal_serde::option"
1431    )]
1432    stop_price: Option<Decimal>,
1433    /// Optional absolute replacement price level for a trailing stop.
1434    #[serde(
1435        skip_serializing_if = "Option::is_none",
1436        with = "crate::decimal_serde::option"
1437    )]
1438    trail_price: Option<Decimal>,
1439}
1440
1441impl ModifyOrder {
1442    /// Starts a validated order-modification request.
1443    pub const fn builder(account_id: AccountId, order_id: OrderId) -> ModifyOrderBuilder {
1444        ModifyOrderBuilder {
1445            account_id,
1446            order_id,
1447            size: None,
1448            limit_price: None,
1449            stop_price: None,
1450            trail_price: None,
1451        }
1452    }
1453
1454    /// Returns the provider account.
1455    #[must_use]
1456    pub const fn account_id(&self) -> AccountId {
1457        self.account_id
1458    }
1459
1460    /// Returns the provider order.
1461    #[must_use]
1462    pub const fn order_id(&self) -> OrderId {
1463        self.order_id
1464    }
1465
1466    /// Returns the optional positive replacement quantity.
1467    #[must_use]
1468    pub const fn size(&self) -> Option<i32> {
1469        self.size
1470    }
1471
1472    /// Returns the optional replacement limit price.
1473    #[must_use]
1474    pub const fn limit_price(&self) -> Option<Decimal> {
1475        self.limit_price
1476    }
1477
1478    /// Returns the optional replacement stop price.
1479    #[must_use]
1480    pub const fn stop_price(&self) -> Option<Decimal> {
1481        self.stop_price
1482    }
1483
1484    /// Returns the optional absolute replacement price level for a trailing stop.
1485    ///
1486    /// This input differs from the distance returned in [`Order::trail_price`].
1487    #[must_use]
1488    pub const fn trail_price(&self) -> Option<Decimal> {
1489        self.trail_price
1490    }
1491}
1492
1493/// Builder for a validated [`ModifyOrder`].
1494#[derive(Clone, Copy, Debug)]
1495#[must_use = "a ModifyOrderBuilder does nothing until build is called"]
1496pub struct ModifyOrderBuilder {
1497    account_id: AccountId,
1498    order_id: OrderId,
1499    size: Option<i32>,
1500    limit_price: Option<Decimal>,
1501    stop_price: Option<Decimal>,
1502    trail_price: Option<Decimal>,
1503}
1504
1505impl ModifyOrderBuilder {
1506    /// Sets the replacement quantity.
1507    pub const fn size(mut self, size: i32) -> Self {
1508        self.size = Some(size);
1509        self
1510    }
1511
1512    /// Sets the replacement limit price.
1513    pub const fn limit_price(mut self, limit_price: Decimal) -> Self {
1514        self.limit_price = Some(limit_price);
1515        self
1516    }
1517
1518    /// Sets the replacement stop price.
1519    pub const fn stop_price(mut self, stop_price: Decimal) -> Self {
1520        self.stop_price = Some(stop_price);
1521        self
1522    }
1523
1524    /// Sets an absolute replacement price level for a trailing-stop order.
1525    ///
1526    /// The provider recalculates the tick distance using its last traded price
1527    /// at modification time. Tick alignment and quote availability are checked
1528    /// by the provider, but modification has no maximum-distance check. Supplying
1529    /// the distance from [`Order::trail_price`] can therefore be accepted with
1530    /// an unintended trail. Omit this setter to leave the trail unchanged.
1531    /// See the [modification reference](https://gateway.docs.projectx.com/docs/api-reference/order/order-modify/).
1532    pub const fn trail_price(mut self, trail_price: Decimal) -> Self {
1533        self.trail_price = Some(trail_price);
1534        self
1535    }
1536
1537    /// Validates and builds the order-modification request.
1538    ///
1539    /// # Errors
1540    ///
1541    /// Returns [`RequestValidationError::NonPositiveReplacementSize`] when a
1542    /// replacement quantity is zero or negative, or
1543    /// [`RequestValidationError::EmptyModification`] when no replacement value was
1544    /// supplied.
1545    pub fn build(self) -> Result<ModifyOrder, RequestValidationError> {
1546        if self.size.is_some_and(|size| size <= 0) {
1547            return Err(RequestValidationError::NonPositiveReplacementSize);
1548        }
1549        if self.size.is_none()
1550            && self.limit_price.is_none()
1551            && self.stop_price.is_none()
1552            && self.trail_price.is_none()
1553        {
1554            return Err(RequestValidationError::EmptyModification);
1555        }
1556        Ok(ModifyOrder {
1557            account_id: self.account_id,
1558            order_id: self.order_id,
1559            size: self.size,
1560            limit_price: self.limit_price,
1561            stop_price: self.stop_price,
1562            trail_price: self.trail_price,
1563        })
1564    }
1565}
1566
1567/// Position close parameters.
1568#[derive(Clone, Debug, Serialize)]
1569#[serde(rename_all = "camelCase")]
1570pub struct CloseContract {
1571    /// Provider account.
1572    pub account_id: AccountId,
1573    /// Provider contract.
1574    pub contract_id: ContractId,
1575}
1576
1577/// Partial-position close parameters.
1578#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
1579#[serde(rename_all = "camelCase")]
1580pub struct PartialCloseContract {
1581    /// Provider account.
1582    account_id: AccountId,
1583    /// Provider contract.
1584    contract_id: ContractId,
1585    /// Positive quantity to close.
1586    size: i32,
1587}
1588
1589impl PartialCloseContract {
1590    /// Creates a partial-position close with a positive quantity.
1591    ///
1592    /// # Errors
1593    ///
1594    /// Returns [`RequestValidationError::NonPositivePartialCloseSize`] when
1595    /// `size` is zero or negative.
1596    pub fn new(
1597        account_id: AccountId,
1598        contract_id: ContractId,
1599        size: i32,
1600    ) -> Result<Self, RequestValidationError> {
1601        if size <= 0 {
1602            return Err(RequestValidationError::NonPositivePartialCloseSize);
1603        }
1604        Ok(Self {
1605            account_id,
1606            contract_id,
1607            size,
1608        })
1609    }
1610
1611    /// Returns the provider account.
1612    #[must_use]
1613    pub const fn account_id(&self) -> AccountId {
1614        self.account_id
1615    }
1616
1617    /// Borrows the provider contract.
1618    #[must_use]
1619    pub const fn contract_id(&self) -> &ContractId {
1620        &self.contract_id
1621    }
1622
1623    /// Returns the positive quantity to close.
1624    #[must_use]
1625    pub const fn size(&self) -> i32 {
1626        self.size
1627    }
1628}
1629
1630/// A `ProjectX` open position.
1631#[derive(Clone, Debug, Deserialize, PartialEq)]
1632#[non_exhaustive]
1633#[serde(rename_all = "camelCase")]
1634pub struct Position {
1635    /// Provider position identifier.
1636    pub id: PositionId,
1637    /// Provider account.
1638    pub account_id: AccountId,
1639    /// Provider contract.
1640    pub contract_id: ContractId,
1641    /// Provider contract display name, when supplied.
1642    #[serde(default)]
1643    pub contract_display_name: Option<String>,
1644    /// Provider creation timestamp.
1645    pub creation_timestamp: Timestamp,
1646    /// Provider position-type code.
1647    #[serde(rename = "type")]
1648    pub position_type: PositionType,
1649    /// Signed or directional provider quantity.
1650    pub size: i32,
1651    /// Average entry price.
1652    #[serde(with = "crate::decimal_serde")]
1653    pub average_price: Decimal,
1654}
1655
1656/// Trade search parameters.
1657#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
1658#[serde(rename_all = "camelCase")]
1659pub struct TradeSearch {
1660    /// Provider account.
1661    account_id: AccountId,
1662    /// Absolute range start.
1663    start_timestamp: Timestamp,
1664    /// Optional absolute range end.
1665    #[serde(skip_serializing_if = "Option::is_none")]
1666    end_timestamp: Option<Timestamp>,
1667}
1668
1669/// Trade search parameters with independently optional timestamp bounds.
1670///
1671/// Construct this request with [`TradeQuery::builder`]. Omitting both bounds
1672/// requests every trade available for the selected account.
1673#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
1674#[serde(rename_all = "camelCase")]
1675pub struct TradeQuery {
1676    /// Provider account.
1677    account_id: AccountId,
1678    /// Optional absolute range start.
1679    #[serde(skip_serializing_if = "Option::is_none")]
1680    start_timestamp: Option<Timestamp>,
1681    /// Optional absolute range end.
1682    #[serde(skip_serializing_if = "Option::is_none")]
1683    end_timestamp: Option<Timestamp>,
1684}
1685
1686impl TradeQuery {
1687    /// Starts a trade query for an account with no timestamp bounds.
1688    pub const fn builder(account_id: AccountId) -> TradeQueryBuilder {
1689        TradeQueryBuilder {
1690            account_id,
1691            start_timestamp: None,
1692            end_timestamp: None,
1693        }
1694    }
1695
1696    /// Returns the provider account.
1697    #[must_use]
1698    pub const fn account_id(&self) -> AccountId {
1699        self.account_id
1700    }
1701
1702    /// Returns the optional lower timestamp bound.
1703    #[must_use]
1704    pub const fn start_timestamp(&self) -> Option<Timestamp> {
1705        self.start_timestamp
1706    }
1707
1708    /// Returns the optional upper timestamp bound.
1709    #[must_use]
1710    pub const fn end_timestamp(&self) -> Option<Timestamp> {
1711        self.end_timestamp
1712    }
1713}
1714
1715/// Builder for a validated [`TradeQuery`].
1716#[derive(Clone, Copy, Debug)]
1717#[must_use = "a TradeQueryBuilder does nothing until build is called"]
1718pub struct TradeQueryBuilder {
1719    account_id: AccountId,
1720    start_timestamp: Option<Timestamp>,
1721    end_timestamp: Option<Timestamp>,
1722}
1723
1724impl TradeQueryBuilder {
1725    /// Sets the optional lower timestamp bound.
1726    pub const fn start_timestamp(mut self, start_timestamp: Timestamp) -> Self {
1727        self.start_timestamp = Some(start_timestamp);
1728        self
1729    }
1730
1731    /// Sets the optional upper timestamp bound.
1732    pub const fn end_timestamp(mut self, end_timestamp: Timestamp) -> Self {
1733        self.end_timestamp = Some(end_timestamp);
1734        self
1735    }
1736
1737    /// Validates and builds the trade query.
1738    ///
1739    /// # Errors
1740    ///
1741    /// Returns [`RequestValidationError::SearchRangeNotIncreasing`] when both
1742    /// bounds are present and the end is not later than the start.
1743    pub fn build(self) -> Result<TradeQuery, RequestValidationError> {
1744        validate_search_range(self.start_timestamp, self.end_timestamp)?;
1745        Ok(TradeQuery {
1746            account_id: self.account_id,
1747            start_timestamp: self.start_timestamp,
1748            end_timestamp: self.end_timestamp,
1749        })
1750    }
1751}
1752
1753impl TradeSearch {
1754    /// Creates a validated execution search.
1755    ///
1756    /// # Errors
1757    ///
1758    /// Returns [`RequestValidationError::SearchRangeNotIncreasing`] when an
1759    /// end timestamp is present and is not later than the start.
1760    pub fn new(
1761        account_id: AccountId,
1762        start_timestamp: Timestamp,
1763        end_timestamp: Option<Timestamp>,
1764    ) -> Result<Self, RequestValidationError> {
1765        validate_search_range(Some(start_timestamp), end_timestamp)?;
1766        Ok(Self {
1767            account_id,
1768            start_timestamp,
1769            end_timestamp,
1770        })
1771    }
1772
1773    /// Returns the provider account.
1774    #[must_use]
1775    pub const fn account_id(&self) -> AccountId {
1776        self.account_id
1777    }
1778
1779    /// Returns the range start.
1780    #[must_use]
1781    pub const fn start_timestamp(&self) -> Timestamp {
1782        self.start_timestamp
1783    }
1784
1785    /// Returns the optional range end.
1786    #[must_use]
1787    pub const fn end_timestamp(&self) -> Option<Timestamp> {
1788        self.end_timestamp
1789    }
1790}
1791
1792fn validate_search_range(
1793    start_timestamp: Option<Timestamp>,
1794    end_timestamp: Option<Timestamp>,
1795) -> Result<(), RequestValidationError> {
1796    if start_timestamp
1797        .zip(end_timestamp)
1798        .is_some_and(|(start, end)| end <= start)
1799    {
1800        Err(RequestValidationError::SearchRangeNotIncreasing)
1801    } else {
1802        Ok(())
1803    }
1804}
1805
1806/// A `ProjectX` execution trade.
1807#[derive(Clone, Debug, Deserialize, PartialEq)]
1808#[non_exhaustive]
1809#[serde(rename_all = "camelCase")]
1810pub struct Trade {
1811    /// Provider trade identifier.
1812    pub id: TradeId,
1813    /// Provider account.
1814    pub account_id: AccountId,
1815    /// Provider contract.
1816    pub contract_id: ContractId,
1817    /// Provider creation timestamp.
1818    pub creation_timestamp: Timestamp,
1819    /// Execution price.
1820    #[serde(with = "crate::decimal_serde")]
1821    pub price: Decimal,
1822    /// Optional realized P&L.
1823    #[serde(default, with = "crate::decimal_serde::option")]
1824    pub profit_and_loss: Option<Decimal>,
1825    /// Provider fees.
1826    #[serde(with = "crate::decimal_serde")]
1827    pub fees: Decimal,
1828    /// Optional provider commissions, separate from fees.
1829    #[serde(default, with = "crate::decimal_serde::option")]
1830    pub commissions: Option<Decimal>,
1831    /// Execution side.
1832    pub side: Side,
1833    /// Execution quantity.
1834    pub size: i32,
1835    /// Whether the provider voided this trade.
1836    pub voided: bool,
1837    /// Originating order.
1838    pub order_id: OrderId,
1839}
1840
1841/// Sparse quote update from the market hub.
1842///
1843/// The provider may send only the fields that changed. Callers that need a
1844/// consolidated snapshot must merge updates by symbol and preserve `None` as
1845/// unavailable data rather than substituting a zero price or volume.
1846#[derive(Clone, Debug, Deserialize, PartialEq)]
1847#[non_exhaustive]
1848#[serde(rename_all = "camelCase")]
1849pub struct MarketQuote {
1850    /// Provider symbol identifier.
1851    #[serde(alias = "symbol")]
1852    pub raw_symbol: SymbolId,
1853    /// Human-readable symbol name, when supplied.
1854    #[serde(default)]
1855    pub symbol_name: Option<String>,
1856    /// Last trade price, when supplied by this update.
1857    #[serde(default, with = "crate::decimal_serde::option")]
1858    pub last_price: Option<Decimal>,
1859    /// Best bid price, when supplied by this update.
1860    #[serde(default, with = "crate::decimal_serde::option")]
1861    pub best_bid: Option<Decimal>,
1862    /// Best ask price, when supplied by this update.
1863    #[serde(default, with = "crate::decimal_serde::option")]
1864    pub best_ask: Option<Decimal>,
1865    /// Session price change, when supplied by this update.
1866    #[serde(default, with = "crate::decimal_serde::option")]
1867    pub change: Option<Decimal>,
1868    /// Session percent change, when supplied by this update.
1869    #[serde(default, with = "crate::decimal_serde::option")]
1870    pub change_percent: Option<Decimal>,
1871    /// Session open, when supplied by this update.
1872    #[serde(default, with = "crate::decimal_serde::option")]
1873    pub open: Option<Decimal>,
1874    /// Session high, when supplied by this update.
1875    #[serde(default, with = "crate::decimal_serde::option")]
1876    pub high: Option<Decimal>,
1877    /// Session low, when supplied by this update.
1878    #[serde(default, with = "crate::decimal_serde::option")]
1879    pub low: Option<Decimal>,
1880    /// Session cumulative volume, when supplied by this update.
1881    #[serde(default)]
1882    pub volume: Option<i64>,
1883    /// Provider last-updated timestamp.
1884    pub last_updated: Timestamp,
1885    /// Event timestamp, when supplied separately from [`Self::last_updated`].
1886    #[serde(default)]
1887    pub timestamp: Option<Timestamp>,
1888}
1889
1890/// Depth-of-market update from the market hub.
1891#[derive(Clone, Debug, Deserialize, PartialEq)]
1892#[non_exhaustive]
1893#[serde(rename_all = "camelCase")]
1894pub struct MarketDepth {
1895    /// Provider symbol identifier, when supplied.
1896    #[serde(default, alias = "symbolId")]
1897    pub symbol_id: Option<SymbolId>,
1898    /// Event timestamp.
1899    pub timestamp: Timestamp,
1900    /// Provider depth event code.
1901    #[serde(rename = "type")]
1902    pub depth_type: DepthType,
1903    /// Price level.
1904    #[serde(with = "crate::decimal_serde")]
1905    pub price: Decimal,
1906    /// Incremental volume for the update.
1907    pub volume: i64,
1908    /// Resting volume after the update.
1909    pub current_volume: i64,
1910    /// Zero-based level index, when supplied.
1911    #[serde(default)]
1912    pub index: Option<i32>,
1913}
1914
1915/// Trade print from the market hub.
1916#[derive(Clone, Debug, Deserialize, PartialEq)]
1917#[non_exhaustive]
1918#[serde(rename_all = "camelCase")]
1919pub struct MarketTrade {
1920    /// Provider symbol identifier.
1921    pub symbol_id: SymbolId,
1922    /// Trade price.
1923    #[serde(with = "crate::decimal_serde")]
1924    pub price: Decimal,
1925    /// Event timestamp.
1926    pub timestamp: Timestamp,
1927    /// Provider aggressor classification.
1928    #[serde(rename = "type")]
1929    pub trade_type: TradeLogType,
1930    /// Trade quantity.
1931    pub volume: i64,
1932}
1933
1934/// Successful response for an operation without a result body.
1935#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1936#[non_exhaustive]
1937pub struct OperationResponse;
1938
1939#[derive(Debug)]
1940pub(crate) enum Envelope<T> {
1941    Accepted(T),
1942    Rejected { error_code: i32 },
1943    InconsistentStatus { success: bool, error_code: i32 },
1944}
1945
1946impl<'de, T> Deserialize<'de> for Envelope<T>
1947where
1948    T: serde::de::DeserializeOwned,
1949{
1950    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
1951    where
1952        D: serde::Deserializer<'de>,
1953    {
1954        use serde::de::Error as _;
1955
1956        let mut object = BTreeMap::<String, Box<RawValue>>::deserialize(deserializer)?;
1957        let success = object
1958            .remove("success")
1959            .ok_or_else(|| D::Error::custom("provider response success flag is missing"))
1960            .and_then(|value| serde_json::from_str(value.get()).map_err(D::Error::custom))?;
1961        let error_code = object
1962            .remove("errorCode")
1963            .ok_or_else(|| D::Error::custom("provider response error code is missing"))
1964            .and_then(|value| serde_json::from_str(value.get()).map_err(D::Error::custom))?;
1965        object.remove("errorMessage");
1966        if success != (error_code == 0) {
1967            return Ok(Self::InconsistentStatus {
1968                success,
1969                error_code,
1970            });
1971        }
1972        if !success {
1973            return Ok(Self::Rejected { error_code });
1974        }
1975        let mut body_json = String::from("{");
1976        for (index, (key, value)) in object.into_iter().enumerate() {
1977            if index > 0 {
1978                body_json.push(',');
1979            }
1980            body_json.push_str(&serde_json::to_string(&key).map_err(D::Error::custom)?);
1981            body_json.push(':');
1982            body_json.push_str(value.get());
1983        }
1984        body_json.push('}');
1985        let body = serde_json::from_str(&body_json).map_err(D::Error::custom)?;
1986        Ok(Self::Accepted(body))
1987    }
1988}
1989
1990#[derive(Debug, Deserialize)]
1991pub(crate) struct AccountsBody {
1992    #[serde(default, deserialize_with = "null_to_empty")]
1993    pub(crate) accounts: Vec<Account>,
1994}
1995
1996#[derive(Debug, Deserialize)]
1997pub(crate) struct ContractsBody {
1998    #[serde(default, deserialize_with = "null_to_empty")]
1999    pub(crate) contracts: Vec<Contract>,
2000}
2001
2002#[derive(Debug, Deserialize)]
2003pub(crate) struct ContractBody {
2004    pub(crate) contract: Contract,
2005}
2006
2007#[derive(Debug, Deserialize)]
2008pub(crate) struct BarsBody {
2009    #[serde(default, deserialize_with = "null_to_empty")]
2010    pub(crate) bars: Vec<Bar>,
2011}
2012
2013#[derive(Debug, Deserialize)]
2014pub(crate) struct OrdersBody {
2015    #[serde(default, deserialize_with = "null_to_empty")]
2016    pub(crate) orders: Vec<Order>,
2017}
2018
2019#[derive(Debug, Deserialize)]
2020pub(crate) struct OrderBody {
2021    pub(crate) order: Order,
2022}
2023
2024#[derive(Debug, Deserialize)]
2025#[serde(rename_all = "camelCase")]
2026pub(crate) struct PlaceOrderBody {
2027    pub(crate) order_id: Option<OrderId>,
2028}
2029
2030#[derive(Debug, Deserialize)]
2031pub(crate) struct PositionsBody {
2032    #[serde(default, deserialize_with = "null_to_empty")]
2033    pub(crate) positions: Vec<Position>,
2034}
2035
2036#[derive(Debug, Deserialize)]
2037pub(crate) struct TradesBody {
2038    #[serde(default, deserialize_with = "null_to_empty")]
2039    pub(crate) trades: Vec<Trade>,
2040}
2041
2042#[derive(Debug, Deserialize)]
2043pub(crate) struct EmptyBody {}
2044
2045pub(crate) fn null_to_empty<'de, D, T>(deserializer: D) -> Result<Vec<T>, D::Error>
2046where
2047    D: serde::Deserializer<'de>,
2048    T: Deserialize<'de>,
2049{
2050    Ok(Option::<Vec<T>>::deserialize(deserializer)?.unwrap_or_default())
2051}
2052
2053#[cfg(test)]
2054mod tests {
2055    use super::*;
2056
2057    macro_rules! assert_empty_list {
2058        ($body:ty, $field:ident, $json:literal) => {{
2059            let envelope: Envelope<$body> = serde_json::from_str($json)
2060                .unwrap_or_else(|error| panic!("fixture envelope must decode: {error}"));
2061            let Envelope::Accepted(body) = envelope else {
2062                panic!("fixture envelope must be accepted");
2063            };
2064            assert!(body.$field.is_empty());
2065        }};
2066    }
2067
2068    #[test]
2069    fn optional_list_bodies_normalize_missing_and_null_to_empty() {
2070        assert_empty_list!(
2071            AccountsBody,
2072            accounts,
2073            r#"{"success":true,"errorCode":0,"accounts":null}"#
2074        );
2075        assert_empty_list!(
2076            ContractsBody,
2077            contracts,
2078            r#"{"success":true,"errorCode":0}"#
2079        );
2080        assert_empty_list!(
2081            OrdersBody,
2082            orders,
2083            r#"{"success":true,"errorCode":0,"orders":null}"#
2084        );
2085        assert_empty_list!(
2086            OrderPage,
2087            orders,
2088            r#"{"success":true,"errorCode":0,"orders":null}"#
2089        );
2090        assert_empty_list!(
2091            PositionsBody,
2092            positions,
2093            r#"{"success":true,"errorCode":0}"#
2094        );
2095        assert_empty_list!(
2096            TradesBody,
2097            trades,
2098            r#"{"success":true,"errorCode":0,"trades":null}"#
2099        );
2100    }
2101
2102    #[test]
2103    fn rejected_envelope_does_not_require_an_endpoint_body() {
2104        let envelope: Envelope<AccountsBody> =
2105            serde_json::from_str(r#"{"success":false,"errorCode":17,"errorMessage":"synthetic"}"#)
2106                .unwrap_or_else(|error| panic!("rejection envelope must decode: {error}"));
2107
2108        assert!(matches!(envelope, Envelope::Rejected { error_code: 17 }));
2109    }
2110
2111    #[test]
2112    fn envelope_requires_a_consistent_provider_status() {
2113        assert!(
2114            serde_json::from_str::<Envelope<AccountsBody>>(r#"{"success":true,"accounts":[]}"#)
2115                .is_err()
2116        );
2117        for (json, success, error_code) in [
2118            (r#"{"success":true,"errorCode":17}"#, true, 17),
2119            (r#"{"success":false,"errorCode":0}"#, false, 0),
2120        ] {
2121            let envelope: Envelope<AccountsBody> = serde_json::from_str(json)
2122                .unwrap_or_else(|error| panic!("inconsistent envelope must decode: {error}"));
2123            assert!(matches!(
2124                envelope,
2125                Envelope::InconsistentStatus {
2126                    success: actual_success,
2127                    error_code: actual_error_code,
2128                } if actual_success == success && actual_error_code == error_code
2129            ));
2130        }
2131    }
2132}