Skip to main content

projectx_client/
models.rs

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