Skip to main content

nautilus_architect_ax/http/
models.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2026 Nautech Systems Pty Ltd. All rights reserved.
3//  https://nautechsystems.io
4//
5//  Licensed under the GNU Lesser General Public License Version 3.0 (the "License");
6//  You may not use this file except in compliance with the License.
7//  You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html
8//
9//  Unless required by applicable law or agreed to in writing, software
10//  distributed under the License is distributed on an "AS IS" BASIS,
11//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12//  See the License for the specific language governing permissions and
13//  limitations under the License.
14// -------------------------------------------------------------------------------------------------
15
16//! Data transfer objects for deserializing Ax HTTP API payloads.
17
18use ahash::AHashMap;
19use jiff::{Timestamp, civil::Date};
20use nautilus_core::string::secret::SecretString;
21use rust_decimal::Decimal;
22use serde::{Deserialize, Serialize};
23use strum::{AsRefStr, Display};
24use ustr::Ustr;
25use zeroize::{Zeroize, ZeroizeOnDrop};
26
27use crate::common::{
28    enums::{
29        AxCandleWidth, AxCategory, AxFundingSlotStatus, AxFundingVariant, AxInstrumentState,
30        AxOrderSide, AxOrderStatus, AxTimeInForce,
31    },
32    parse::{
33        deserialize_decimal_or_zero, deserialize_optional_decimal,
34        deserialize_optional_decimal_from_str, serialize_decimal_as_str,
35        serialize_optional_decimal_as_str,
36    },
37};
38
39/// Default instrument state when not provided by API.
40fn default_instrument_state() -> AxInstrumentState {
41    AxInstrumentState::Open
42}
43
44/// An account entry within a [`AxWhoAmI`] response.
45///
46/// Fee rates and close-only state are per account rather than per user.
47///
48/// # References
49/// - <https://docs.architect.exchange/api-reference/user-management/get-whoami>
50#[derive(Clone, Debug, Serialize, Deserialize)]
51#[serde(rename_all = "snake_case")]
52pub struct AxWhoAmIAccount {
53    /// Account identifier.
54    pub id: Ustr,
55    /// Account display name.
56    pub name: Option<String>,
57    /// Whether the account is in close-only mode.
58    pub is_close_only: bool,
59    /// Maker fee rate; absent when the venue supplies no rate, which is distinct from zero.
60    #[serde(default, deserialize_with = "deserialize_optional_decimal_from_str")]
61    pub maker_fee: Option<Decimal>,
62    /// Taker fee rate; absent when the venue supplies no rate, which is distinct from zero.
63    #[serde(default, deserialize_with = "deserialize_optional_decimal_from_str")]
64    pub taker_fee: Option<Decimal>,
65    /// Whether the account may list its own state.
66    pub can_list: bool,
67    /// Whether the account may read venue state.
68    pub can_read: bool,
69    /// Whether the account may set risk limits.
70    pub can_set_limits: bool,
71    /// Whether the account may reduce or close existing positions.
72    pub can_reduce_or_close: bool,
73    /// Whether the account may open new positions.
74    pub can_trade: bool,
75}
76
77/// Response payload returned by `GET /whoami`.
78///
79/// # References
80/// - <https://docs.architect.exchange/api-reference/user-management/get-whoami>
81#[derive(Clone, Debug, Serialize, Deserialize)]
82#[serde(rename_all = "snake_case")]
83pub struct AxWhoAmI {
84    /// User identifier.
85    pub id: Ustr,
86    /// Username for the account.
87    pub username: Ustr,
88    /// Account creation timestamp.
89    pub created_at: Timestamp,
90    /// Whether two-factor authentication is required.
91    pub require_2fa: bool,
92    /// Whether the user has completed onboarding.
93    pub is_onboarded: bool,
94    /// Whether the account is frozen.
95    pub is_frozen: bool,
96    /// Whether the user has admin privileges.
97    pub is_admin: bool,
98    /// Accounts the credentials can act on.
99    #[serde(default)]
100    pub accounts: Vec<AxWhoAmIAccount>,
101    /// Human-readable alias for the user (optional).
102    #[serde(default)]
103    pub pseudonym: Option<String>,
104    /// Reference code for fiat deposits (optional).
105    #[serde(default)]
106    pub fiat_deposit_code: Option<String>,
107}
108
109/// Individual instrument definition.
110///
111/// # References
112/// - <https://docs.architect.exchange/api-reference/symbols-instruments/get-instruments>
113#[derive(Clone, Debug, Serialize, Deserialize)]
114#[serde(rename_all = "snake_case")]
115pub struct AxInstrument {
116    /// Trading symbol for the instrument.
117    pub symbol: Ustr,
118    /// Umbrella product shared by sibling contracts.
119    #[serde(default)]
120    pub product: Option<Ustr>,
121    /// Current trading state of the instrument (defaults to Open if not provided).
122    #[serde(default = "default_instrument_state")]
123    pub state: AxInstrumentState,
124    /// Contract multiplier.
125    #[serde(deserialize_with = "deserialize_decimal_or_zero")]
126    pub multiplier: Decimal,
127    /// Minimum order size.
128    #[serde(deserialize_with = "deserialize_decimal_or_zero")]
129    pub minimum_order_size: Decimal,
130    /// Price tick size.
131    #[serde(deserialize_with = "deserialize_decimal_or_zero")]
132    pub tick_size: Decimal,
133    /// Quote currency symbol.
134    pub quote_currency: Ustr,
135    /// Funding settlement currency.
136    pub funding_settlement_currency: Ustr,
137    /// Instrument category (e.g. fx, equities, metals).
138    pub category: AxCategory,
139    /// Maintenance margin percentage.
140    #[serde(deserialize_with = "deserialize_decimal_or_zero")]
141    pub maintenance_margin_pct: Decimal,
142    /// Initial margin percentage.
143    #[serde(deserialize_with = "deserialize_decimal_or_zero")]
144    pub initial_margin_pct: Decimal,
145    /// Contract mark price description (optional).
146    #[serde(default)]
147    pub contract_mark_price: Option<String>,
148    /// Contract size description (optional).
149    #[serde(default)]
150    pub contract_size: Option<String>,
151    /// Instrument description (optional).
152    #[serde(default)]
153    pub description: Option<String>,
154    /// Contract expiration; absent for perpetual contracts.
155    #[serde(default)]
156    pub expiration: Option<Timestamp>,
157    /// Funding calendar schedule (optional).
158    #[serde(default)]
159    pub funding_calendar_schedule: Option<String>,
160    /// Funding frequency (optional).
161    #[serde(default)]
162    pub funding_frequency: Option<String>,
163    /// Lower cap for funding rate percentage (optional).
164    #[serde(default, deserialize_with = "deserialize_optional_decimal_from_str")]
165    pub funding_rate_cap_lower_pct: Option<Decimal>,
166    /// Upper cap for funding rate percentage (optional).
167    #[serde(default, deserialize_with = "deserialize_optional_decimal_from_str")]
168    pub funding_rate_cap_upper_pct: Option<Decimal>,
169    /// Lower deviation percentage for price bands (optional).
170    #[serde(default, deserialize_with = "deserialize_optional_decimal_from_str")]
171    pub price_band_lower_deviation_pct: Option<Decimal>,
172    /// Upper deviation percentage for price bands (optional).
173    #[serde(default, deserialize_with = "deserialize_optional_decimal_from_str")]
174    pub price_band_upper_deviation_pct: Option<Decimal>,
175    /// Price bands configuration (optional).
176    #[serde(default)]
177    pub price_bands: Option<String>,
178    /// Price quotation format (optional).
179    #[serde(default)]
180    pub price_quotation: Option<String>,
181    /// Underlying benchmark price description (optional).
182    #[serde(default)]
183    pub underlying_benchmark_price: Option<String>,
184    /// Price scale.
185    #[serde(default)]
186    pub price_scale: Option<i64>,
187    /// Additional product specs.
188    #[serde(default)]
189    pub additional_product_specs: Option<serde_json::Map<String, serde_json::Value>>,
190    /// Time the instrument was delisted.
191    #[serde(default)]
192    pub delisted_at: Option<Timestamp>,
193    /// Whether new orders and modifications are blocked ahead of delisting.
194    #[serde(default)]
195    pub is_closing: bool,
196    /// Whether the instrument publishes live funding estimates.
197    #[serde(default)]
198    pub estimated_funding_supported: bool,
199    /// Funding schedule calendar description.
200    #[serde(default)]
201    pub funding_schedule_calendar_description: Option<String>,
202    /// Funding schedule time description.
203    #[serde(default)]
204    pub funding_schedule_time_description: Option<String>,
205    /// Funding schedule.
206    #[serde(default)]
207    pub funding_schedule: Option<AxFundingSchedule>,
208    /// Trading schedule.
209    #[serde(default)]
210    pub trading_schedule: Option<AxTradingSchedule>,
211}
212
213/// Time of day in the containing schedule's timezone.
214#[derive(Clone, Debug, Serialize, Deserialize)]
215pub struct AxTimeOfDay {
216    pub hours: u32,
217    pub minutes: u32,
218    #[serde(default)]
219    pub seconds: u32,
220}
221
222/// Recurring funding time with ISO weekday numbers in [1, 7].
223#[derive(Clone, Debug, Serialize, Deserialize)]
224pub struct AxFundingTime {
225    pub days_of_week: Vec<u32>,
226    pub time_of_day: AxTimeOfDay,
227}
228
229/// Funding schedule override for a date in the schedule's timezone.
230#[derive(Clone, Debug, Serialize, Deserialize)]
231pub struct AxFundingException {
232    pub date: Date,
233    pub times: Vec<AxTimeOfDay>,
234    pub reason: Option<String>,
235}
236
237/// Machine-readable funding schedule.
238#[derive(Clone, Debug, Serialize, Deserialize)]
239pub struct AxFundingSchedule {
240    pub timezone: Ustr,
241    pub times: Vec<AxFundingTime>,
242    pub exceptions: Vec<AxFundingException>,
243}
244
245/// Trading session segment with ISO weekday numbers in [1, 7].
246#[derive(Clone, Debug, Serialize, Deserialize)]
247pub struct AxTradingSegment {
248    pub days_of_week: Vec<u32>,
249    pub time_of_day: AxTimeOfDay,
250    pub duration_seconds: u64,
251    pub state: AxInstrumentState,
252    pub hide_market_data: bool,
253    pub expire_all_orders: bool,
254}
255
256/// Machine-readable trading schedule.
257#[derive(Clone, Debug, Serialize, Deserialize)]
258pub struct AxTradingSchedule {
259    pub segments: Vec<AxTradingSegment>,
260}
261
262/// Availability of a live funding estimate.
263#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
264#[serde(rename_all = "snake_case")]
265pub enum AxEstimatedFundingStatus {
266    Ready,
267    SettlementPending,
268    Unavailable,
269    #[serde(other)]
270    Unknown,
271}
272
273/// Live funding estimate carried by a ticker.
274#[derive(Clone, Debug, Serialize, Deserialize)]
275pub struct AxEstimatedFundingRate {
276    pub symbol: Ustr,
277    pub status: AxEstimatedFundingStatus,
278    pub timestamp: Timestamp,
279    pub reason: Option<String>,
280    #[serde(default, deserialize_with = "deserialize_optional_decimal_from_str")]
281    pub funding_rate: Option<Decimal>,
282    #[serde(default, deserialize_with = "deserialize_optional_decimal_from_str")]
283    pub funding_amount: Option<Decimal>,
284    #[serde(default, deserialize_with = "deserialize_optional_decimal_from_str")]
285    pub benchmark_price: Option<Decimal>,
286    #[serde(default, deserialize_with = "deserialize_optional_decimal_from_str")]
287    pub settlement_price: Option<Decimal>,
288}
289
290/// Response payload returned by `GET /instruments`.
291///
292/// # References
293/// - <https://docs.architect.exchange/api-reference/symbols-instruments/get-instruments>
294#[derive(Clone, Debug, Serialize, Deserialize)]
295#[serde(rename_all = "snake_case")]
296pub struct AxInstrumentsResponse {
297    /// List of instruments.
298    pub instruments: Vec<AxInstrument>,
299}
300
301/// Individual balance entry.
302///
303/// # References
304/// - <https://docs.architect.exchange/api-reference/portfolio-management/get-balances>
305#[derive(Clone, Debug, Serialize, Deserialize)]
306#[serde(rename_all = "snake_case")]
307pub struct AxBalance {
308    /// Asset symbol.
309    pub symbol: Ustr,
310    /// Available balance amount.
311    #[serde(deserialize_with = "deserialize_decimal_or_zero")]
312    pub amount: Decimal,
313    /// Account identifier.
314    #[serde(default)]
315    pub account_id: Option<Ustr>,
316}
317
318/// Response payload returned by `GET /balances`.
319///
320/// # References
321/// - <https://docs.architect.exchange/api-reference/portfolio-management/get-balances>
322#[derive(Clone, Debug, Serialize, Deserialize)]
323#[serde(rename_all = "snake_case")]
324pub struct AxBalancesResponse {
325    /// List of balances.
326    pub balances: Vec<AxBalance>,
327    /// USD borrowed against the account.
328    #[serde(default, deserialize_with = "deserialize_optional_decimal_from_str")]
329    pub usd_borrow: Option<Decimal>,
330}
331
332/// Individual position entry.
333///
334/// # References
335/// - <https://docs.architect.exchange/api-reference/portfolio-management/get-positions>
336#[derive(Clone, Debug, Serialize, Deserialize)]
337#[serde(rename_all = "snake_case")]
338pub struct AxPosition {
339    /// Account identifier.
340    pub account_id: Ustr,
341    /// Instrument symbol.
342    pub symbol: Ustr,
343    /// Signed quantity (positive for long, negative for short).
344    pub signed_quantity: i64,
345    /// Signed notional value.
346    #[serde(deserialize_with = "deserialize_decimal_or_zero")]
347    pub signed_notional: Decimal,
348    /// Position timestamp.
349    pub timestamp: Timestamp,
350    /// Realized profit and loss.
351    #[serde(deserialize_with = "deserialize_decimal_or_zero")]
352    pub realized_pnl: Decimal,
353    /// Signed cost basis.
354    #[serde(default, deserialize_with = "deserialize_optional_decimal_from_str")]
355    pub signed_cost_basis: Option<Decimal>,
356}
357
358/// Response payload returned by `GET /positions`.
359///
360/// # References
361/// - <https://docs.architect.exchange/api-reference/portfolio-management/get-positions>
362#[derive(Clone, Debug, Serialize, Deserialize)]
363#[serde(rename_all = "snake_case")]
364pub struct AxPositionsResponse {
365    /// List of positions.
366    pub positions: Vec<AxPosition>,
367}
368
369/// Individual ticker entry.
370///
371/// # References
372/// - <https://docs.architect.exchange/api-reference/marketdata/get-ticker>
373#[derive(Clone, Debug, Serialize, Deserialize)]
374#[serde(rename_all = "snake_case")]
375pub struct AxTicker {
376    /// Instrument symbol.
377    #[serde(rename = "s")]
378    pub symbol: Ustr,
379    /// Best bid price.
380    #[serde(
381        default,
382        rename = "bp",
383        deserialize_with = "deserialize_optional_decimal"
384    )]
385    pub bid: Option<Decimal>,
386    /// Best ask price.
387    #[serde(
388        default,
389        rename = "ap",
390        deserialize_with = "deserialize_optional_decimal"
391    )]
392    pub ask: Option<Decimal>,
393    /// Last trade price.
394    #[serde(
395        default,
396        rename = "p",
397        deserialize_with = "deserialize_optional_decimal"
398    )]
399    pub last: Option<Decimal>,
400    /// Mark price.
401    #[serde(
402        default,
403        rename = "m",
404        deserialize_with = "deserialize_optional_decimal"
405    )]
406    pub mark: Option<Decimal>,
407    /// 24-hour volume.
408    #[serde(
409        default,
410        rename = "v",
411        deserialize_with = "deserialize_optional_decimal"
412    )]
413    pub volume_24h: Option<Decimal>,
414    /// 24-hour high price.
415    #[serde(
416        default,
417        rename = "h",
418        deserialize_with = "deserialize_optional_decimal"
419    )]
420    pub high_24h: Option<Decimal>,
421    /// 24-hour low price.
422    #[serde(
423        default,
424        rename = "l",
425        deserialize_with = "deserialize_optional_decimal"
426    )]
427    pub low_24h: Option<Decimal>,
428    /// Timestamp seconds.
429    #[serde(default)]
430    pub ts: Option<i64>,
431    /// Timestamp nanosecond component.
432    #[serde(default)]
433    pub tn: Option<i64>,
434    /// Last trade quantity.
435    #[serde(default, rename = "q")]
436    pub last_quantity: Option<u64>,
437    /// Open interest.
438    #[serde(default, rename = "oi")]
439    pub open_interest: Option<i64>,
440    /// Instrument state.
441    #[serde(default, rename = "i")]
442    pub instrument_state: Option<AxInstrumentState>,
443    /// Price band lower limit.
444    #[serde(
445        default,
446        rename = "pl",
447        deserialize_with = "deserialize_optional_decimal"
448    )]
449    pub price_band_lower: Option<Decimal>,
450    /// Price band upper limit.
451    #[serde(
452        default,
453        rename = "pu",
454        deserialize_with = "deserialize_optional_decimal"
455    )]
456    pub price_band_upper: Option<Decimal>,
457    /// Last settlement price.
458    #[serde(
459        default,
460        rename = "lsp",
461        deserialize_with = "deserialize_optional_decimal"
462    )]
463    pub last_settlement_price: Option<Decimal>,
464    /// Last settlement time as epoch seconds.
465    #[serde(default, rename = "lst")]
466    pub last_settlement_time: Option<i64>,
467    /// Opening price for the ticker window.
468    #[serde(
469        default,
470        rename = "o",
471        deserialize_with = "deserialize_optional_decimal_from_str"
472    )]
473    pub open: Option<Decimal>,
474    /// Estimated funding.
475    #[serde(default, rename = "ef")]
476    pub estimated_funding: Option<AxEstimatedFundingRate>,
477}
478
479/// Response payload returned by `GET /tickers`.
480///
481/// # References
482/// - <https://docs.architect.exchange/api-reference/marketdata/get-tickers>
483#[derive(Clone, Debug, Serialize, Deserialize)]
484#[serde(rename_all = "snake_case")]
485pub struct AxTickersResponse {
486    /// List of tickers.
487    pub tickers: Vec<AxTicker>,
488    /// Total matching records.
489    pub total_count: i64,
490    /// Applied limit.
491    pub limit: i32,
492    /// Applied offset.
493    pub offset: i32,
494}
495
496/// Response payload returned by `GET /ticker`.
497///
498/// # References
499/// - <https://docs.architect.exchange/api-reference/marketdata/get-ticker>
500#[derive(Clone, Debug, Serialize, Deserialize)]
501#[serde(rename_all = "snake_case")]
502pub struct AxTickerResponse {
503    /// The ticker data.
504    pub ticker: AxTicker,
505}
506
507/// Response payload returned by `POST /authenticate`.
508///
509/// # References
510/// - <https://docs.architect.exchange/api-reference/user-management/authenticate>
511#[derive(Debug, Clone, Serialize, Deserialize, Zeroize, ZeroizeOnDrop)]
512#[serde(rename_all = "snake_case")]
513pub struct AxAuthenticateResponse {
514    /// Session token for authenticated requests.
515    pub token: SecretString,
516}
517
518impl AxAuthenticateResponse {
519    /// Consumes the response and returns the session token.
520    #[must_use]
521    pub fn into_token(mut self) -> SecretString {
522        std::mem::take(&mut self.token)
523    }
524}
525
526/// Response payload returned by `POST /place-order`.
527///
528/// # References
529/// - <https://docs.architect.exchange/api-reference/order-management/place-order>
530#[derive(Clone, Debug, Serialize, Deserialize)]
531pub struct AxPlaceOrderResponse {
532    /// Order ID of the placed order.
533    pub oid: String,
534}
535
536/// Response payload returned by `POST /cancel-order`.
537///
538/// # References
539/// - <https://docs.architect.exchange/api-reference/order-management/cancel-order>
540#[derive(Clone, Debug, Serialize, Deserialize)]
541pub struct AxCancelOrderResponse {
542    /// Whether the cancel request has been accepted.
543    pub cxl_rx: bool,
544}
545
546/// Individual trade entry from the REST API.
547///
548/// # References
549/// - <https://docs.architect.exchange/api-reference/marketdata/get-trades>
550#[derive(Clone, Debug, Serialize, Deserialize)]
551pub struct AxRestTrade {
552    /// Timestamp (Unix epoch seconds).
553    pub ts: i64,
554    /// Nanosecond component of the timestamp.
555    pub tn: i64,
556    /// Trade price (decimal string).
557    #[serde(deserialize_with = "deserialize_decimal_or_zero")]
558    pub p: Decimal,
559    /// Trade quantity.
560    pub q: i64,
561    /// Symbol.
562    pub s: Ustr,
563    /// Trade direction (aggressor side).
564    pub d: AxOrderSide,
565}
566
567/// Response payload returned by `GET /trades`.
568///
569/// # References
570/// - <https://docs.architect.exchange/api-reference/marketdata/get-trades>
571#[derive(Clone, Debug, Serialize, Deserialize)]
572pub struct AxTradesResponse {
573    /// List of trades.
574    pub trades: Vec<AxRestTrade>,
575}
576
577/// Individual price level in the order book.
578///
579/// # References
580/// - <https://docs.architect.exchange/api-reference/marketdata/get-book>
581#[derive(Clone, Debug, Serialize, Deserialize)]
582pub struct AxBookLevel {
583    /// Price (decimal string).
584    #[serde(deserialize_with = "deserialize_decimal_or_zero")]
585    pub p: Decimal,
586    /// Quantity at this price level.
587    pub q: i64,
588    /// Individual order IDs (Level 3 only).
589    #[serde(default)]
590    pub o: Option<Vec<i64>>,
591}
592
593/// Order book snapshot.
594///
595/// # References
596/// - <https://docs.architect.exchange/api-reference/marketdata/get-book>
597#[derive(Clone, Debug, Serialize, Deserialize)]
598pub struct AxBook {
599    /// Timestamp (Unix epoch seconds).
600    pub ts: i64,
601    /// Nanosecond component of the timestamp.
602    pub tn: i64,
603    /// Symbol.
604    pub s: Ustr,
605    /// Bid levels (best to worst).
606    pub b: Vec<AxBookLevel>,
607    /// Ask levels (best to worst).
608    pub a: Vec<AxBookLevel>,
609}
610
611/// Response payload returned by `GET /book`.
612///
613/// # References
614/// - <https://docs.architect.exchange/api-reference/marketdata/get-book>
615#[derive(Clone, Debug, Serialize, Deserialize)]
616pub struct AxBookResponse {
617    /// The order book snapshot.
618    pub book: AxBook,
619}
620
621/// Detailed order status from single-order lookup.
622///
623/// # References
624/// - <https://docs.architect.exchange/api-reference/order-management/get-order-status>
625#[derive(Clone, Debug, Serialize, Deserialize)]
626pub struct AxOrderStatusDetail {
627    /// Trading symbol.
628    pub symbol: Ustr,
629    /// Order ID.
630    pub order_id: String,
631    /// Current order state.
632    pub state: AxOrderStatus,
633    /// Client order ID.
634    #[serde(default)]
635    pub clord_id: Option<u64>,
636    /// Filled quantity.
637    #[serde(default)]
638    pub filled_quantity: Option<i64>,
639    /// Remaining quantity.
640    #[serde(default)]
641    pub remaining_quantity: Option<i64>,
642    /// Reject reason.
643    #[serde(default)]
644    pub reject_reason: Option<AxOrderRejectReason>,
645    /// Reject message.
646    #[serde(default)]
647    pub reject_message: Option<String>,
648}
649
650/// Response payload returned by `GET /order-status`.
651///
652/// # References
653/// - <https://docs.architect.exchange/api-reference/order-management/get-order-status>
654#[derive(Clone, Debug, Serialize, Deserialize)]
655pub struct AxOrderStatusQueryResponse {
656    /// The order status detail.
657    pub status: AxOrderStatusDetail,
658}
659
660/// Reason for order rejection from the exchange.
661///
662/// # References
663/// - <https://docs.architect.exchange/api-reference/order-management/get-orders>
664#[derive(Clone, Copy, Debug, Display, Eq, PartialEq, Hash, AsRefStr, Serialize, Deserialize)]
665#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
666#[strum(serialize_all = "SCREAMING_SNAKE_CASE")]
667pub enum AxOrderRejectReason {
668    CloseOnly,
669    InsufficientMargin,
670    MaxOpenOrdersExceeded,
671    UnknownSymbol,
672    ExchangeClosed,
673    IncorrectQuantity,
674    InvalidPriceIncrement,
675    IncorrectOrderType,
676    PriceOutOfBounds,
677    NoLiquidity,
678    InsufficientCreditLimit,
679    OriginalOrderTerminated,
680    DuplicateClientOrderId,
681    #[serde(other)]
682    Unknown,
683}
684
685impl AxOrderRejectReason {
686    /// Formats a venue reject reason for events and reports: the known enum
687    /// spelling when available, otherwise the order text. Returns `None` when
688    /// neither carries a usable reason.
689    #[must_use]
690    pub fn reason_str(reason: Option<Self>, text: Option<&str>) -> Option<String> {
691        if let Some(known) = reason.filter(|r| !matches!(r, Self::Unknown)) {
692            return Some(known.to_string());
693        }
694
695        text.filter(|text| !text.trim().is_empty())
696            .map(str::to_string)
697    }
698}
699
700/// Post-only repricing behavior from the latest place or replace request.
701#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
702pub enum AxRepriceBehavior {
703    #[serde(rename = "rej")]
704    Reject,
705    #[serde(rename = "bo")]
706    BackOff,
707    #[serde(rename = "tbl")]
708    TopOfBook,
709    #[serde(other)]
710    Unknown,
711}
712
713/// Detailed order entry from historical orders query.
714///
715/// # References
716/// - <https://docs.architect.exchange/api-reference/order-management/get-orders>
717#[derive(Clone, Debug, Serialize, Deserialize)]
718pub struct AxOrderDetail {
719    /// Timestamp (Unix epoch seconds).
720    pub ts: i64,
721    /// Nanosecond component.
722    #[serde(default)]
723    pub tn: i64,
724    /// Order ID.
725    pub oid: String,
726    /// Account ID.
727    #[serde(default)]
728    pub aid: Option<Ustr>,
729    /// User ID.
730    pub u: Ustr,
731    /// Symbol.
732    pub s: Ustr,
733    /// Price.
734    #[serde(deserialize_with = "deserialize_decimal_or_zero")]
735    pub p: Decimal,
736    /// Order quantity.
737    pub q: u64,
738    /// Executed quantity.
739    pub xq: u64,
740    /// Remaining quantity.
741    pub rq: u64,
742    /// Order state.
743    pub o: AxOrderStatus,
744    /// Order side.
745    pub d: AxOrderSide,
746    /// Time in force.
747    pub tif: AxTimeInForce,
748    /// Client order ID.
749    #[serde(default)]
750    pub cid: Option<u64>,
751    /// Reject reason.
752    #[serde(default)]
753    pub r: Option<AxOrderRejectReason>,
754    /// Order tag.
755    #[serde(default)]
756    pub tag: Option<String>,
757    /// Text note.
758    #[serde(default)]
759    pub txt: Option<String>,
760    /// Whether the order is post-only.
761    #[serde(default)]
762    pub po: bool,
763    /// Repricing behavior from the latest place or replace, effective only when `po` is true.
764    #[serde(default)]
765    pub rb: Option<AxRepriceBehavior>,
766}
767
768/// Response payload returned by `GET /orders`.
769///
770/// # References
771/// - <https://docs.architect.exchange/api-reference/order-management/get-orders>
772#[derive(Clone, Debug, Serialize, Deserialize)]
773pub struct AxOrdersResponse {
774    /// List of order details.
775    pub orders: Vec<AxOrderDetail>,
776    /// Total matching records (for pagination).
777    #[serde(default)]
778    pub total_count: Option<i64>,
779    /// Applied limit.
780    #[serde(default)]
781    pub limit: Option<i32>,
782    /// Applied offset.
783    #[serde(default)]
784    pub offset: Option<i32>,
785    /// Next page cursor.
786    #[serde(default)]
787    pub next_cursor: Option<String>,
788}
789
790/// Response payload returned by `POST /initial-margin-requirement`.
791///
792/// # References
793/// - <https://docs.architect.exchange/api-reference/order-management/calculate-initial-margin-requirement>
794#[derive(Clone, Debug, Serialize, Deserialize)]
795pub struct AxInitialMarginRequirementResponse {
796    /// Initial margin requirement.
797    #[serde(deserialize_with = "deserialize_decimal_or_zero")]
798    pub im: Decimal,
799    /// Initial margin percentage.
800    #[serde(default, deserialize_with = "deserialize_optional_decimal_from_str")]
801    pub im_pct: Option<Decimal>,
802    /// Contract multiplier.
803    #[serde(default, deserialize_with = "deserialize_optional_decimal_from_str")]
804    pub mult: Option<Decimal>,
805    /// Signed position quantity.
806    #[serde(default)]
807    pub pos: Option<i64>,
808}
809
810/// Individual open order entry.
811///
812/// # References
813/// - <https://docs.architect.exchange/api-reference/order-management/get-open-orders>
814#[derive(Clone, Debug, Serialize, Deserialize)]
815pub struct AxOpenOrder {
816    /// Trade number.
817    pub tn: i64,
818    /// Timestamp (Unix epoch).
819    pub ts: i64,
820    /// Order side: "B" (buy) or "S" (sell).
821    pub d: AxOrderSide,
822    /// Order status.
823    pub o: AxOrderStatus,
824    /// Order ID.
825    pub oid: String,
826    /// Price.
827    #[serde(deserialize_with = "deserialize_decimal_or_zero")]
828    pub p: Decimal,
829    /// Quantity.
830    pub q: u64,
831    /// Remaining quantity.
832    pub rq: u64,
833    /// Symbol.
834    pub s: Ustr,
835    /// Time in force.
836    pub tif: AxTimeInForce,
837    /// User ID.
838    pub u: Ustr,
839    /// Executed quantity.
840    pub xq: u64,
841    /// Optional client ID for order correlation.
842    #[serde(default)]
843    pub cid: Option<u64>,
844    /// Optional order tag.
845    #[serde(default)]
846    pub tag: Option<String>,
847    /// Whether the order is post-only.
848    #[serde(default)]
849    pub po: bool,
850    /// Repricing behavior from the latest place or replace, effective only when `po` is true.
851    #[serde(default)]
852    pub rb: Option<AxRepriceBehavior>,
853    /// Account identifier.
854    #[serde(default)]
855    pub aid: Option<Ustr>,
856    /// Order rejection reason.
857    #[serde(default)]
858    pub r: Option<AxOrderRejectReason>,
859    /// Additional venue explanation.
860    #[serde(default)]
861    pub txt: Option<String>,
862}
863
864/// Response payload returned by `GET /open-orders`.
865///
866/// # References
867/// - <https://docs.architect.exchange/api-reference/order-management/get-open-orders>
868#[derive(Clone, Debug, Serialize, Deserialize)]
869pub struct AxOpenOrdersResponse {
870    /// List of open orders.
871    pub orders: Vec<AxOpenOrder>,
872    /// Total matching records.
873    pub total_count: i64,
874    /// Applied limit.
875    pub limit: i32,
876    /// Applied offset.
877    pub offset: i32,
878}
879
880/// Individual fill/trade entry.
881///
882/// # References
883/// - <https://docs.architect.exchange/api-reference/portfolio-management/get-fills>
884#[derive(Clone, Debug, Serialize, Deserialize)]
885#[serde(rename_all = "snake_case")]
886pub struct AxFill {
887    /// Trade ID (execution identifier).
888    pub trade_id: String,
889    /// Order ID.
890    pub order_id: Option<String>,
891    /// Fee amount.
892    #[serde(deserialize_with = "deserialize_decimal_or_zero")]
893    pub fee: Decimal,
894    /// Whether this was a taker order.
895    pub is_taker: bool,
896    /// Whether this fill was generated by an off-book block trade.
897    pub is_block_trade: Option<bool>,
898    /// Whether this fill was generated by final contract settlement.
899    pub is_final_settlement: Option<bool>,
900    /// Execution price.
901    #[serde(deserialize_with = "deserialize_decimal_or_zero")]
902    pub price: Decimal,
903    /// Executed quantity (always non-negative).
904    pub quantity: u64,
905    /// Order side.
906    pub side: AxOrderSide,
907    /// Instrument symbol.
908    pub symbol: Ustr,
909    /// Execution timestamp.
910    pub timestamp: Timestamp,
911    /// Account identifier.
912    pub account_id: Ustr,
913    /// Realized PnL for this fill.
914    #[serde(default, deserialize_with = "deserialize_optional_decimal_from_str")]
915    pub realized_pnl: Option<Decimal>,
916}
917
918/// Response payload returned by `GET /fills`.
919///
920/// # References
921/// - <https://docs.architect.exchange/api-reference/portfolio-management/get-fills>
922#[derive(Clone, Debug, Serialize, Deserialize)]
923#[serde(rename_all = "snake_case")]
924pub struct AxFillsResponse {
925    /// List of fills.
926    pub fills: Vec<AxFill>,
927    /// Total matching records, when supplied by AX.
928    #[serde(default)]
929    pub total_count: Option<i64>,
930    /// Applied limit, when supplied by AX.
931    #[serde(default)]
932    pub limit: Option<i32>,
933    /// Cursor for the next page, when one exists.
934    #[serde(default)]
935    pub next_cursor: Option<String>,
936}
937
938/// Individual candle/OHLCV entry.
939///
940/// # References
941/// - <https://docs.architect.exchange/api-reference/marketdata/get-candles>
942#[derive(Clone, Debug, Serialize, Deserialize)]
943#[serde(rename_all = "snake_case")]
944pub struct AxCandle {
945    /// Instrument symbol.
946    pub symbol: Ustr,
947    /// Candle timestamp (Unix epoch seconds).
948    pub ts: i64,
949    /// Open price.
950    #[serde(deserialize_with = "deserialize_decimal_or_zero")]
951    pub open: Decimal,
952    /// High price.
953    #[serde(deserialize_with = "deserialize_decimal_or_zero")]
954    pub high: Decimal,
955    /// Low price.
956    #[serde(deserialize_with = "deserialize_decimal_or_zero")]
957    pub low: Decimal,
958    /// Close price.
959    #[serde(deserialize_with = "deserialize_decimal_or_zero")]
960    pub close: Decimal,
961    /// Buy volume.
962    pub buy_volume: u64,
963    /// Sell volume.
964    pub sell_volume: u64,
965    /// Total volume.
966    pub volume: u64,
967    /// Candle width/interval.
968    pub width: AxCandleWidth,
969}
970
971/// Response payload returned by `GET /candles`.
972///
973/// # References
974/// - <https://docs.architect.exchange/api-reference/marketdata/get-candles>
975#[derive(Clone, Debug, Serialize, Deserialize)]
976#[serde(rename_all = "snake_case")]
977pub struct AxCandlesResponse {
978    /// List of candles.
979    pub candles: Vec<AxCandle>,
980}
981
982/// Response payload returned by `GET /candles/current` and `GET /candles/last`.
983///
984/// # References
985/// - <https://docs.architect.exchange/api-reference/marketdata/get-current-candle>
986/// - <https://docs.architect.exchange/api-reference/marketdata/get-last-candle>
987#[derive(Clone, Debug, Serialize, Deserialize)]
988#[serde(rename_all = "snake_case")]
989pub struct AxCandleResponse {
990    /// The candle data.
991    pub candle: AxCandle,
992}
993
994/// Individual funding rate entry.
995///
996/// # References
997/// - <https://docs.architect.exchange/api-reference/marketdata/get-funding-rates>
998#[derive(Clone, Debug, Serialize, Deserialize)]
999#[serde(rename_all = "snake_case")]
1000pub struct AxFundingRate {
1001    /// Instrument symbol.
1002    pub symbol: Ustr,
1003    /// Timestamp in nanoseconds.
1004    pub timestamp_ns: i64,
1005    /// Funding rate.
1006    #[serde(deserialize_with = "deserialize_decimal_or_zero")]
1007    pub funding_rate: Decimal,
1008    /// Funding amount.
1009    #[serde(default, deserialize_with = "deserialize_optional_decimal_from_str")]
1010    pub funding_amount: Option<Decimal>,
1011    /// Benchmark price.
1012    #[serde(default, deserialize_with = "deserialize_optional_decimal_from_str")]
1013    pub benchmark_price: Option<Decimal>,
1014    /// Settlement price.
1015    #[serde(deserialize_with = "deserialize_decimal_or_zero")]
1016    pub settlement_price: Decimal,
1017}
1018
1019/// Response payload returned by `GET /funding-rates`.
1020///
1021/// # References
1022/// - <https://docs.architect.exchange/api-reference/marketdata/get-funding-rates>
1023#[derive(Clone, Debug, Serialize, Deserialize)]
1024#[serde(rename_all = "snake_case")]
1025pub struct AxFundingRatesResponse {
1026    /// List of funding rates.
1027    pub funding_rates: Vec<AxFundingRate>,
1028    /// Total matching records, when supplied by AX.
1029    #[serde(default)]
1030    pub total_count: Option<i64>,
1031    /// Applied limit, when supplied by AX.
1032    #[serde(default)]
1033    pub limit: Option<i32>,
1034    /// Cursor for the next page, when one exists.
1035    #[serde(default)]
1036    pub next_cursor: Option<String>,
1037}
1038
1039/// One funding slot of a trading day, as returned by `GET /funding-slots`.
1040///
1041/// # References
1042/// - <https://docs.architect.exchange/api-reference>
1043#[derive(Clone, Debug, Serialize, Deserialize)]
1044#[serde(rename_all = "snake_case")]
1045pub struct AxFundingSlot {
1046    /// 1-based position within the day's schedule.
1047    pub index: i32,
1048    /// Scheduled settlement time of the slot.
1049    pub funding_time: Timestamp,
1050    /// Slot settlement state.
1051    pub status: AxFundingSlotStatus,
1052    /// True when the rate was clamped by the symbol's funding rate cap.
1053    pub capped: bool,
1054    /// Mark-price TWAP over the slot, when available.
1055    #[serde(default, deserialize_with = "deserialize_optional_decimal_from_str")]
1056    pub mark_twap: Option<Decimal>,
1057    /// Underlying-price TWAP over the slot, when available.
1058    #[serde(default, deserialize_with = "deserialize_optional_decimal_from_str")]
1059    pub underlying_twap: Option<Decimal>,
1060    /// Premium of the mark TWAP over the underlying TWAP, in basis points.
1061    #[serde(default, deserialize_with = "deserialize_optional_decimal_from_str")]
1062    pub premium_bps: Option<Decimal>,
1063    /// Slot funding rate in basis points; positive means longs pay shorts.
1064    #[serde(default, deserialize_with = "deserialize_optional_decimal_from_str")]
1065    pub funding_rate_bps: Option<Decimal>,
1066    /// Why a skipped slot did not settle; present only on skipped slots.
1067    #[serde(default)]
1068    pub reason: Option<String>,
1069}
1070
1071/// Response payload returned by `GET /funding-slots`.
1072///
1073/// A full trading day of funding slots with running totals. `daily_close`
1074/// symbols report a single slot.
1075///
1076/// # References
1077/// - <https://docs.architect.exchange/api-reference>
1078#[derive(Clone, Debug, Serialize, Deserialize)]
1079#[serde(rename_all = "snake_case")]
1080pub struct AxFundingSlotsResponse {
1081    /// Instrument symbol.
1082    pub symbol: Ustr,
1083    /// Trading day the schedule covers.
1084    pub date: Date,
1085    /// IANA name of the funding schedule's timezone.
1086    pub timezone: Ustr,
1087    /// How the symbol's funding accrues over the day.
1088    pub variant: AxFundingVariant,
1089    /// Number of funding slots scheduled on `date`; 0 on holidays and weekends.
1090    pub interval_count: i32,
1091    /// Per-slot cap on the funding rate in basis points, when configured.
1092    #[serde(default, deserialize_with = "deserialize_optional_decimal_from_str")]
1093    pub cap_bps: Option<Decimal>,
1094    /// Funding slots for the day.
1095    pub slots: Vec<AxFundingSlot>,
1096    /// Sum of realized slot rates so far, in basis points.
1097    #[serde(deserialize_with = "deserialize_decimal_or_zero")]
1098    pub realized_sum_bps: Decimal,
1099    /// Projected end-of-day total in basis points: realized plus remaining projections.
1100    #[serde(deserialize_with = "deserialize_decimal_or_zero")]
1101    pub projected_eod_bps: Decimal,
1102    /// Minutes between intraday funding slots, when supplied by AX.
1103    #[serde(default)]
1104    pub slot_interval_minutes: Option<u32>,
1105}
1106
1107/// Per-symbol risk metrics.
1108///
1109/// # References
1110/// - <https://docs.architect.exchange/api-reference/portfolio-management/get-risk-snapshot>
1111#[derive(Clone, Debug, Serialize, Deserialize)]
1112#[serde(rename_all = "snake_case")]
1113pub struct AxPerSymbolRisk {
1114    /// Signed quantity (positive for long, negative for short).
1115    pub signed_quantity: i64,
1116    /// Signed notional value.
1117    #[serde(deserialize_with = "deserialize_decimal_or_zero")]
1118    pub signed_notional: Decimal,
1119    /// Average entry price.
1120    #[serde(default, deserialize_with = "deserialize_optional_decimal_from_str")]
1121    pub average_price: Option<Decimal>,
1122    /// Liquidation price.
1123    #[serde(default, deserialize_with = "deserialize_optional_decimal_from_str")]
1124    pub liquidation_price: Option<Decimal>,
1125    /// Initial margin required for the position.
1126    #[serde(deserialize_with = "deserialize_decimal_or_zero")]
1127    pub initial_margin_required_position: Decimal,
1128    /// Initial margin required for open orders.
1129    #[serde(deserialize_with = "deserialize_decimal_or_zero")]
1130    pub initial_margin_required_open_orders: Decimal,
1131    /// Total initial margin required.
1132    #[serde(deserialize_with = "deserialize_decimal_or_zero")]
1133    pub initial_margin_required_total: Decimal,
1134    /// Maintenance margin required.
1135    #[serde(deserialize_with = "deserialize_decimal_or_zero")]
1136    pub maintenance_margin_required: Decimal,
1137    /// Unrealized P&L.
1138    #[serde(deserialize_with = "deserialize_decimal_or_zero")]
1139    pub unrealized_pnl: Decimal,
1140    /// Signed cost basis.
1141    #[serde(default, deserialize_with = "deserialize_optional_decimal_from_str")]
1142    pub signed_cost_basis: Option<Decimal>,
1143    /// Signed USD notional.
1144    #[serde(default, deserialize_with = "deserialize_optional_decimal_from_str")]
1145    pub signed_usd_notional: Option<Decimal>,
1146    /// Mark price.
1147    #[serde(default, deserialize_with = "deserialize_optional_decimal_from_str")]
1148    pub mark_price: Option<Decimal>,
1149}
1150
1151/// Risk snapshot data.
1152///
1153/// # References
1154/// - <https://docs.architect.exchange/api-reference/portfolio-management/get-risk-snapshot>
1155#[derive(Clone, Debug, Serialize, Deserialize)]
1156#[serde(rename_all = "snake_case")]
1157pub struct AxRiskSnapshot {
1158    /// USD account balance.
1159    #[serde(deserialize_with = "deserialize_decimal_or_zero")]
1160    pub balance_usd: Decimal,
1161    /// Total equity value.
1162    #[serde(deserialize_with = "deserialize_decimal_or_zero")]
1163    pub equity: Decimal,
1164    /// Available initial margin.
1165    #[serde(deserialize_with = "deserialize_decimal_or_zero")]
1166    pub initial_margin_available: Decimal,
1167    /// Margin required for open orders.
1168    #[serde(deserialize_with = "deserialize_decimal_or_zero")]
1169    pub initial_margin_required_for_open_orders: Decimal,
1170    /// Margin required for positions.
1171    #[serde(deserialize_with = "deserialize_decimal_or_zero")]
1172    pub initial_margin_required_for_positions: Decimal,
1173    /// Total initial margin requirement.
1174    #[serde(deserialize_with = "deserialize_decimal_or_zero")]
1175    pub initial_margin_required_total: Decimal,
1176    /// Available maintenance margin.
1177    #[serde(deserialize_with = "deserialize_decimal_or_zero")]
1178    pub maintenance_margin_available: Decimal,
1179    /// Required maintenance margin.
1180    #[serde(deserialize_with = "deserialize_decimal_or_zero")]
1181    pub maintenance_margin_required: Decimal,
1182    /// Unrealized profit/loss.
1183    #[serde(deserialize_with = "deserialize_decimal_or_zero")]
1184    pub unrealized_pnl: Decimal,
1185    /// Snapshot timestamp.
1186    pub timestamp_ns: Timestamp,
1187    /// Account identifier.
1188    pub account_id: Ustr,
1189    /// Per-symbol risk data.
1190    #[serde(default)]
1191    pub per_symbol: AHashMap<Ustr, AxPerSymbolRisk>,
1192}
1193
1194/// Response payload returned by `GET /risk-snapshot`.
1195///
1196/// # References
1197/// - <https://docs.architect.exchange/api-reference/portfolio-management/get-risk-snapshot>
1198#[derive(Clone, Debug, Serialize, Deserialize)]
1199#[serde(rename_all = "snake_case")]
1200pub struct AxRiskSnapshotResponse {
1201    /// The risk snapshot data.
1202    pub risk_snapshot: AxRiskSnapshot,
1203}
1204
1205/// Individual transaction entry.
1206///
1207/// # References
1208/// - <https://docs.architect.exchange/api-reference/portfolio-management/get-transactions>
1209#[derive(Clone, Debug, Serialize, Deserialize)]
1210#[serde(rename_all = "snake_case")]
1211pub struct AxTransaction {
1212    /// Account identifier.
1213    pub account_id: Ustr,
1214    /// Transaction amount.
1215    #[serde(deserialize_with = "deserialize_decimal_or_zero")]
1216    pub amount: Decimal,
1217    /// Unique event identifier.
1218    pub event_id: String,
1219    /// Asset symbol.
1220    pub symbol: Ustr,
1221    /// Transaction timestamp.
1222    pub timestamp: Timestamp,
1223    /// Type of transaction.
1224    pub transaction_type: Ustr,
1225    /// User who initiated the transaction, when available.
1226    #[serde(default)]
1227    pub initiated_by_user_id: Option<Ustr>,
1228    /// Optional reference identifier.
1229    #[serde(default)]
1230    pub reference_id: Option<String>,
1231}
1232
1233/// Response payload returned by `GET /transactions`.
1234///
1235/// # References
1236/// - <https://docs.architect.exchange/api-reference/portfolio-management/get-transactions>
1237#[derive(Clone, Debug, Serialize, Deserialize)]
1238#[serde(rename_all = "snake_case")]
1239pub struct AxTransactionsResponse {
1240    /// List of transactions.
1241    pub transactions: Vec<AxTransaction>,
1242    /// Total matching records.
1243    #[serde(default)]
1244    pub total_count: Option<i64>,
1245    /// Applied limit.
1246    #[serde(default)]
1247    pub limit: Option<i32>,
1248    /// Next page cursor.
1249    #[serde(default)]
1250    pub next_cursor: Option<String>,
1251}
1252
1253/// Request body for `POST /authenticate` using API key and secret.
1254///
1255/// # References
1256/// - <https://docs.architect.exchange/api-reference/user-management/authenticate>
1257#[derive(Debug, Clone, Serialize, Deserialize, Zeroize)]
1258#[serde(rename_all = "snake_case")]
1259pub struct AuthenticateApiKeyRequest {
1260    /// API key.
1261    pub api_key: SecretString,
1262    /// API secret.
1263    pub api_secret: SecretString,
1264    /// Token expiration in seconds.
1265    pub expiration_seconds: i32,
1266}
1267
1268impl AuthenticateApiKeyRequest {
1269    /// Creates a new [`AuthenticateApiKeyRequest`].
1270    #[must_use]
1271    pub fn new(
1272        api_key: impl Into<SecretString>,
1273        api_secret: impl Into<SecretString>,
1274        expiration_seconds: i32,
1275    ) -> Self {
1276        Self {
1277            api_key: api_key.into(),
1278            api_secret: api_secret.into(),
1279            expiration_seconds,
1280        }
1281    }
1282}
1283
1284/// Request body for `POST /place-order`.
1285///
1286/// # References
1287/// - <https://docs.architect.exchange/api-reference/order-management/place-order>
1288#[derive(Clone, Debug, Serialize, Deserialize)]
1289pub struct PlaceOrderRequest {
1290    /// Order side: "B" (buy) or "S" (sell).
1291    pub d: AxOrderSide,
1292    /// Order price (limit price).
1293    #[serde(serialize_with = "serialize_decimal_as_str")]
1294    pub p: Decimal,
1295    /// Post-only flag (maker-or-cancel).
1296    pub po: bool,
1297    /// Order quantity in contracts.
1298    pub q: u64,
1299    /// Order symbol.
1300    pub s: Ustr,
1301    /// Time in force.
1302    pub tif: AxTimeInForce,
1303    /// Optional order tag (max 10 alphanumeric characters).
1304    #[serde(skip_serializing_if = "Option::is_none")]
1305    pub tag: Option<String>,
1306}
1307
1308impl PlaceOrderRequest {
1309    /// Creates a new [`PlaceOrderRequest`] for the AX priced order shape.
1310    #[must_use]
1311    pub fn new(
1312        side: AxOrderSide,
1313        price: Decimal,
1314        quantity: u64,
1315        symbol: Ustr,
1316        time_in_force: AxTimeInForce,
1317        post_only: bool,
1318    ) -> Self {
1319        Self {
1320            d: side,
1321            p: price,
1322            po: post_only,
1323            q: quantity,
1324            s: symbol,
1325            tif: time_in_force,
1326            tag: None,
1327        }
1328    }
1329
1330    /// Sets the optional order tag.
1331    #[must_use]
1332    pub fn with_tag(mut self, tag: impl Into<String>) -> Self {
1333        self.tag = Some(tag.into());
1334        self
1335    }
1336}
1337
1338/// Request body for `POST /preview-aggressive-limit-order`.
1339///
1340/// # References
1341/// - <https://docs.architect.exchange/api-reference/marketdata/preview-aggressive-limit-order>
1342#[derive(Clone, Debug, Serialize, Deserialize)]
1343pub struct PreviewAggressiveLimitOrderRequest {
1344    /// Trading symbol.
1345    pub symbol: Ustr,
1346    /// Order quantity in contracts.
1347    pub quantity: u64,
1348    /// Order side: "B" (buy) or "S" (sell).
1349    pub side: AxOrderSide,
1350}
1351
1352impl PreviewAggressiveLimitOrderRequest {
1353    /// Creates a new [`PreviewAggressiveLimitOrderRequest`].
1354    #[must_use]
1355    pub fn new(symbol: Ustr, quantity: u64, side: AxOrderSide) -> Self {
1356        Self {
1357            symbol,
1358            quantity,
1359            side,
1360        }
1361    }
1362}
1363
1364/// Response payload returned by `POST /preview-aggressive-limit-order`.
1365///
1366/// # References
1367/// - <https://docs.architect.exchange/api-reference/marketdata/preview-aggressive-limit-order>
1368#[derive(Clone, Debug, Serialize, Deserialize)]
1369pub struct AxPreviewAggressiveLimitOrderResponse {
1370    /// Quantity that would be filled at the aggressive price.
1371    pub filled_quantity: u64,
1372    /// Quantity that cannot be filled (insufficient book depth).
1373    pub remaining_quantity: u64,
1374    /// The aggressive limit price ("take through" price), or None if no liquidity.
1375    #[serde(default, deserialize_with = "deserialize_optional_decimal_from_str")]
1376    pub limit_price: Option<Decimal>,
1377    /// Volume-weighted average price of expected fills.
1378    #[serde(default, deserialize_with = "deserialize_optional_decimal_from_str")]
1379    pub vwap: Option<Decimal>,
1380}
1381
1382/// Request body for `POST /cancel-order`.
1383///
1384/// # References
1385/// - <https://docs.architect.exchange/api-reference/order-management/cancel-order>
1386#[derive(Clone, Debug, Serialize, Deserialize)]
1387pub struct CancelOrderRequest {
1388    /// Order ID to cancel.
1389    pub oid: String,
1390}
1391
1392impl CancelOrderRequest {
1393    /// Creates a new [`CancelOrderRequest`].
1394    #[must_use]
1395    pub fn new(order_id: impl Into<String>) -> Self {
1396        Self {
1397            oid: order_id.into(),
1398        }
1399    }
1400}
1401
1402/// Request body for `POST /replace-order`.
1403///
1404/// Replaces (amends) an existing order. Unspecified optional fields inherit
1405/// from the original order. The exchange returns a new order ID.
1406///
1407/// # References
1408/// - <https://docs.architect.exchange/api-reference/order-management/replace-order>
1409#[derive(Clone, Debug, Serialize, Deserialize)]
1410pub struct ReplaceOrderRequest {
1411    /// Order ID to replace.
1412    pub oid: String,
1413    /// New limit price (optional, inherits from original if omitted).
1414    #[serde(
1415        skip_serializing_if = "Option::is_none",
1416        serialize_with = "serialize_optional_decimal_as_str"
1417    )]
1418    pub p: Option<Decimal>,
1419    /// New quantity in contracts (optional, inherits from original if omitted).
1420    #[serde(skip_serializing_if = "Option::is_none")]
1421    pub q: Option<u64>,
1422    /// New post-only flag (optional, inherits from original if omitted).
1423    #[serde(skip_serializing_if = "Option::is_none")]
1424    pub po: Option<bool>,
1425    /// New time-in-force (optional, inherits from original if omitted).
1426    #[serde(skip_serializing_if = "Option::is_none")]
1427    pub tif: Option<AxTimeInForce>,
1428}
1429
1430impl ReplaceOrderRequest {
1431    /// Creates a new [`ReplaceOrderRequest`] with only the order ID.
1432    ///
1433    /// Use the builder methods to set the fields to amend.
1434    #[must_use]
1435    pub fn new(order_id: impl Into<String>) -> Self {
1436        Self {
1437            oid: order_id.into(),
1438            p: None,
1439            q: None,
1440            po: None,
1441            tif: None,
1442        }
1443    }
1444
1445    /// Sets the new limit price.
1446    #[must_use]
1447    pub fn with_price(mut self, price: Decimal) -> Self {
1448        self.p = Some(price);
1449        self
1450    }
1451
1452    /// Sets the new quantity.
1453    #[must_use]
1454    pub fn with_quantity(mut self, quantity: u64) -> Self {
1455        self.q = Some(quantity);
1456        self
1457    }
1458}
1459
1460/// Response payload returned by `POST /replace-order`.
1461///
1462/// # References
1463/// - <https://docs.architect.exchange/api-reference/order-management/replace-order>
1464#[derive(Clone, Debug, Serialize, Deserialize)]
1465pub struct AxReplaceOrderResponse {
1466    /// New order ID assigned to the replacement order.
1467    pub oid: String,
1468}
1469
1470/// Request body for `POST /cancel-all-orders`.
1471///
1472/// # References
1473/// - <https://docs.architect.exchange/api-reference/order-management/place-order>
1474#[derive(Clone, Debug, Default, Serialize, Deserialize)]
1475pub struct CancelAllOrdersRequest {
1476    /// Optional account ID. AX infers the session account when omitted.
1477    #[serde(skip_serializing_if = "Option::is_none")]
1478    pub account_id: Option<Ustr>,
1479    /// Optional symbol filter - only cancel orders for this symbol.
1480    #[serde(skip_serializing_if = "Option::is_none")]
1481    pub symbol: Option<Ustr>,
1482}
1483
1484impl CancelAllOrdersRequest {
1485    /// Creates a new [`CancelAllOrdersRequest`] to cancel all orders.
1486    #[must_use]
1487    pub fn new() -> Self {
1488        Self::default()
1489    }
1490
1491    /// Sets the account filter.
1492    #[must_use]
1493    pub fn with_account_id(mut self, account_id: Ustr) -> Self {
1494        self.account_id = Some(account_id);
1495        self
1496    }
1497
1498    /// Sets the symbol filter.
1499    #[must_use]
1500    pub fn with_symbol(mut self, symbol: Ustr) -> Self {
1501        self.symbol = Some(symbol);
1502        self
1503    }
1504}
1505
1506/// Response payload returned by `POST /cancel-all-orders`.
1507///
1508/// # References
1509/// - <https://docs.architect.exchange/api-reference/order-management/place-order>
1510#[derive(Clone, Debug, Serialize, Deserialize)]
1511pub struct AxCancelAllOrdersResponse {}
1512
1513#[cfg(test)]
1514mod tests {
1515    use rstest::rstest;
1516    use rust_decimal_macros::dec;
1517    use serde_json::json;
1518
1519    use super::*;
1520    use crate::websocket::{
1521        messages::{AxOrdersWsFrame, AxWsOrderEvent},
1522        parse::parse_order_message,
1523    };
1524
1525    fn assert_zeroize_on_drop<T: ZeroizeOnDrop>() {}
1526
1527    #[rstest]
1528    fn test_deserialize_authenticate_response() {
1529        let json = include_str!("../../test_data/http_authenticate.json");
1530        let response: AxAuthenticateResponse = serde_json::from_str(json).unwrap();
1531        assert!(response.token.expose_secret().starts_with("test-token"));
1532    }
1533
1534    #[rstest]
1535    fn test_reason_str_prefers_known_reason_over_text() {
1536        assert_eq!(
1537            AxOrderRejectReason::reason_str(
1538                Some(AxOrderRejectReason::PriceOutOfBounds),
1539                Some("order price is above the exchange's maximum price limit"),
1540            ),
1541            Some("PRICE_OUT_OF_BOUNDS".to_string()),
1542        );
1543    }
1544
1545    #[rstest]
1546    fn test_reason_str_falls_back_to_text() {
1547        assert_eq!(
1548            AxOrderRejectReason::reason_str(
1549                Some(AxOrderRejectReason::Unknown),
1550                Some("risk blocked")
1551            ),
1552            Some("risk blocked".to_string()),
1553        );
1554        assert_eq!(
1555            AxOrderRejectReason::reason_str(None, Some("risk blocked")),
1556            Some("risk blocked".to_string()),
1557        );
1558    }
1559
1560    #[rstest]
1561    fn test_reason_str_rejects_blank_text() {
1562        assert_eq!(AxOrderRejectReason::reason_str(None, Some("")), None);
1563        assert_eq!(AxOrderRejectReason::reason_str(None, Some("   ")), None);
1564        assert_eq!(AxOrderRejectReason::reason_str(None, None), None);
1565        assert_eq!(
1566            AxOrderRejectReason::reason_str(Some(AxOrderRejectReason::Unknown), None),
1567            None,
1568        );
1569    }
1570
1571    #[rstest]
1572    fn test_serialize_cancel_all_orders_request() {
1573        let request = CancelAllOrdersRequest::new()
1574            .with_account_id(Ustr::from("account-1"))
1575            .with_symbol(Ustr::from("XAU-PERP"));
1576
1577        let value = serde_json::to_value(request).unwrap();
1578
1579        assert_eq!(value["account_id"], "account-1");
1580        assert_eq!(value["symbol"], "XAU-PERP");
1581        assert!(value.get("execution_venue").is_none());
1582    }
1583
1584    #[rstest]
1585    fn test_deserialize_whoami_response() {
1586        let json = include_str!("../../test_data/http_get_whoami.json");
1587
1588        let response: AxWhoAmI = serde_json::from_str(json).unwrap();
1589
1590        assert_eq!(response.id, "01JBXR-7QK2-0000");
1591        assert_eq!(response.username, "trader@example.com");
1592        assert_eq!(response.pseudonym.as_deref(), Some("quiet-amber-heron"));
1593        assert_eq!(
1594            response.created_at,
1595            "2025-12-18T02:20:42.675817Z".parse::<Timestamp>().unwrap()
1596        );
1597        assert!(!response.require_2fa);
1598        assert!(response.is_onboarded);
1599        assert!(!response.is_frozen);
1600        assert!(!response.is_admin);
1601        assert_eq!(
1602            response.fiat_deposit_code.as_deref(),
1603            Some("01JBXR7QK20000Y")
1604        );
1605        assert_eq!(response.accounts.len(), 1);
1606
1607        let account = &response.accounts[0];
1608
1609        assert_eq!(account.id, "01JBXR-7QK2-0000");
1610        assert_eq!(account.name.as_deref(), Some("trader@example.com"));
1611        assert!(!account.is_close_only);
1612        assert_eq!(account.maker_fee, Some(dec!(0.0002)));
1613        assert_eq!(account.taker_fee, Some(dec!(0.0025)));
1614        assert!(account.can_list);
1615        assert!(account.can_read);
1616        assert!(account.can_set_limits);
1617        assert!(account.can_reduce_or_close);
1618        assert!(account.can_trade);
1619    }
1620
1621    #[rstest]
1622    #[case(json!(""), None)]
1623    #[case(json!(null), None)]
1624    #[case(json!("0"), Some(Decimal::ZERO))]
1625    #[case(json!("0.0002"), Some(dec!(0.0002)))]
1626    fn test_deserialize_whoami_account_fee_distinguishes_absent_from_zero(
1627        #[case] wire_value: serde_json::Value,
1628        #[case] expected: Option<Decimal>,
1629    ) {
1630        // A zero rate is valid, so an absent rate must not deserialize to zero
1631        let json = json!({
1632            "id": "01JBXR-7QK2-0000",
1633            "name": "trader@example.com",
1634            "is_close_only": false,
1635            "maker_fee": wire_value,
1636            "taker_fee": wire_value,
1637            "can_list": true,
1638            "can_read": true,
1639            "can_set_limits": true,
1640            "can_reduce_or_close": true,
1641            "can_trade": true,
1642        })
1643        .to_string();
1644
1645        let account: AxWhoAmIAccount = serde_json::from_str(&json).unwrap();
1646
1647        assert_eq!(account.maker_fee, expected);
1648        assert_eq!(account.taker_fee, expected);
1649    }
1650
1651    #[rstest]
1652    fn test_deserialize_whoami_account_rejects_malformed_fee() {
1653        let json = json!({
1654            "id": "01JBXR-7QK2-0000",
1655            "name": "trader@example.com",
1656            "is_close_only": false,
1657            "maker_fee": "not-a-decimal",
1658            "taker_fee": "0.0025",
1659            "can_list": true,
1660            "can_read": true,
1661            "can_set_limits": true,
1662            "can_reduce_or_close": true,
1663            "can_trade": true,
1664        })
1665        .to_string();
1666
1667        let error = serde_json::from_str::<AxWhoAmIAccount>(&json).unwrap_err();
1668
1669        assert!(
1670            error.to_string().contains("Invalid decimal"),
1671            "unexpected error: {error}"
1672        );
1673    }
1674
1675    #[rstest]
1676    fn test_deserialize_whoami_response_without_optional_profile_fields() {
1677        let json = json!({
1678            "id": "01JBXR-7QK2-0001",
1679            "username": "sub@example.com",
1680            "created_at": "2025-12-18T02:20:42.675817Z",
1681            "is_onboarded": true,
1682            "is_frozen": false,
1683            "is_admin": false,
1684            "require_2fa": true,
1685            "accounts": [],
1686        })
1687        .to_string();
1688
1689        let response: AxWhoAmI = serde_json::from_str(&json).unwrap();
1690
1691        assert!(response.require_2fa);
1692        assert_eq!(response.pseudonym, None);
1693        assert_eq!(response.fiat_deposit_code, None);
1694        assert!(response.accounts.is_empty());
1695    }
1696
1697    #[rstest]
1698    fn test_deserialize_instruments_response() {
1699        let json = include_str!("../../test_data/http_get_instruments.json");
1700        let response: AxInstrumentsResponse = serde_json::from_str(json).unwrap();
1701        assert_eq!(response.instruments.len(), 3);
1702        assert_eq!(response.instruments[0].symbol, "EURUSD-PERP");
1703    }
1704
1705    #[rstest]
1706    fn test_deserialize_balances_response() {
1707        let json = include_str!("../../test_data/http_get_balances.json");
1708        let response: AxBalancesResponse = serde_json::from_str(json).unwrap();
1709        assert_eq!(response.balances.len(), 3);
1710        assert_eq!(response.balances[0].symbol, "USD");
1711    }
1712
1713    #[rstest]
1714    fn test_deserialize_positions_response() {
1715        let json = include_str!("../../test_data/http_get_positions.json");
1716        let response: AxPositionsResponse = serde_json::from_str(json).unwrap();
1717        assert_eq!(response.positions.len(), 2);
1718        assert_eq!(response.positions[0].symbol, "BTC-PERP");
1719        assert_eq!(response.positions[1].signed_quantity, -5);
1720    }
1721
1722    #[rstest]
1723    fn test_deserialize_tickers_response() {
1724        let json = include_str!("../../test_data/http_get_tickers.json");
1725        let response: AxTickersResponse = serde_json::from_str(json).unwrap();
1726        assert_eq!(response.tickers.len(), 3);
1727        assert_eq!(response.total_count, 3);
1728        assert_eq!(response.limit, 100);
1729        assert_eq!(response.offset, 0);
1730        assert_eq!(response.tickers[0].symbol, "EURUSD-PERP");
1731        assert!(response.tickers[0].bid.is_some());
1732        assert!(response.tickers[2].bid.is_none());
1733    }
1734
1735    #[rstest]
1736    fn test_deserialize_funding_rates_response() {
1737        let json = include_str!("../../test_data/http_get_funding_rates.json");
1738        let response: AxFundingRatesResponse = serde_json::from_str(json).unwrap();
1739        assert_eq!(response.funding_rates.len(), 2);
1740        assert_eq!(response.funding_rates[0].symbol, "JPYUSD-PERP");
1741    }
1742
1743    #[rstest]
1744    fn test_deserialize_funding_slots_response() {
1745        let json = include_str!("../../test_data/http_get_funding_slots.json");
1746        let response: AxFundingSlotsResponse = serde_json::from_str(json).unwrap();
1747        assert_eq!(response.symbol, "EURUSD-PERP");
1748        assert_eq!(response.date, Date::new(2026, 7, 6).unwrap());
1749        assert_eq!(response.timezone, "America/New_York");
1750        assert_eq!(response.variant, AxFundingVariant::IntradayTwap);
1751        assert_eq!(response.interval_count, 4);
1752        assert_eq!(
1753            response.cap_bps.map(|d| d.to_string()),
1754            Some("5.0".to_string())
1755        );
1756        assert_eq!(response.slots.len(), 4);
1757
1758        let first = &response.slots[0];
1759        assert_eq!(first.index, 1);
1760        assert_eq!(first.status, AxFundingSlotStatus::Realized);
1761        assert!(!first.capped);
1762        assert_eq!(
1763            first.funding_rate_bps.map(|d| d.to_string()),
1764            Some("0.0921".to_string())
1765        );
1766        assert!(first.reason.is_none());
1767
1768        let capped = &response.slots[1];
1769        assert!(capped.capped);
1770        assert_eq!(
1771            capped.funding_rate_bps.map(|d| d.to_string()),
1772            Some("5.0000".to_string())
1773        );
1774
1775        let projected = &response.slots[2];
1776        assert_eq!(projected.status, AxFundingSlotStatus::Projected);
1777
1778        let skipped = &response.slots[3];
1779        assert_eq!(skipped.status, AxFundingSlotStatus::Skipped);
1780        assert!(skipped.mark_twap.is_none());
1781        assert!(skipped.funding_rate_bps.is_none());
1782        assert_eq!(skipped.reason.as_deref(), Some("holiday"));
1783
1784        assert_eq!(response.realized_sum_bps.to_string(), "5.0921");
1785        assert_eq!(response.projected_eod_bps.to_string(), "5.1842");
1786    }
1787
1788    #[rstest]
1789    fn test_funding_variant_and_slot_status_deserialization() {
1790        let daily: AxFundingVariant =
1791            serde_json::from_value(serde_json::json!("daily_close")).unwrap();
1792        let twap: AxFundingVariant =
1793            serde_json::from_value(serde_json::json!("intraday_twap")).unwrap();
1794        assert_eq!(daily, AxFundingVariant::DailyClose);
1795        assert_eq!(twap, AxFundingVariant::IntradayTwap);
1796
1797        let statuses = [
1798            ("realized", AxFundingSlotStatus::Realized),
1799            ("projected", AxFundingSlotStatus::Projected),
1800            ("skipped", AxFundingSlotStatus::Skipped),
1801            ("pending", AxFundingSlotStatus::Pending),
1802        ];
1803
1804        for (raw, expected) in statuses {
1805            let parsed: AxFundingSlotStatus =
1806                serde_json::from_value(serde_json::json!(raw)).unwrap();
1807            assert_eq!(parsed, expected);
1808        }
1809    }
1810
1811    #[rstest]
1812    #[case::reject(Some("rej"), Some(AxRepriceBehavior::Reject))]
1813    #[case::back_off(Some("bo"), Some(AxRepriceBehavior::BackOff))]
1814    #[case::top_of_book(Some("tbl"), Some(AxRepriceBehavior::TopOfBook))]
1815    #[case::unknown(Some("future_behavior"), Some(AxRepriceBehavior::Unknown))]
1816    #[case::null(None, None)]
1817    fn test_deserialize_order_reprice_behavior(
1818        #[case] raw: Option<&str>,
1819        #[case] expected: Option<AxRepriceBehavior>,
1820        #[values(true, false)] post_only: bool,
1821    ) {
1822        let mut history: serde_json::Value =
1823            serde_json::from_str(include_str!("../../test_data/http_get_orders.json")).unwrap();
1824        let mut open: serde_json::Value =
1825            serde_json::from_str(include_str!("../../test_data/http_get_open_orders.json"))
1826                .unwrap();
1827
1828        for response in [&mut history, &mut open] {
1829            response["orders"][0]["po"] = serde_json::json!(post_only);
1830            response["orders"][0]["rb"] = serde_json::json!(raw);
1831        }
1832
1833        let history: AxOrdersResponse = serde_json::from_value(history).unwrap();
1834        let open: AxOpenOrdersResponse = serde_json::from_value(open).unwrap();
1835
1836        assert_eq!(history.orders[0].po, post_only);
1837        assert_eq!(history.orders[0].rb, expected);
1838        assert_eq!(open.orders[0].po, post_only);
1839        assert_eq!(open.orders[0].rb, expected);
1840        assert_eq!(history.orders[1].rb, None);
1841        assert_eq!(open.orders[1].rb, None);
1842    }
1843
1844    #[rstest]
1845    fn test_deserialize_open_orders_response() {
1846        let json = include_str!("../../test_data/http_get_open_orders.json");
1847        let response: AxOpenOrdersResponse = serde_json::from_str(json).unwrap();
1848        assert_eq!(response.orders.len(), 2);
1849        assert_eq!(response.orders[0].oid, "O-01ARZ3NDEKTSV4RRFFQ69G5FAV");
1850        assert_eq!(response.orders[0].d, AxOrderSide::Buy);
1851        assert_eq!(response.orders[0].o, AxOrderStatus::Accepted);
1852        assert_eq!(response.orders[1].xq, 300);
1853        assert_eq!(response.total_count, 2);
1854        assert_eq!(response.limit, 100);
1855        assert_eq!(response.offset, 0);
1856        assert!(response.orders[0].po);
1857        assert_eq!(response.orders[0].rb, Some(AxRepriceBehavior::Reject));
1858        assert!(!response.orders[1].po);
1859        assert_eq!(response.orders[1].rb, None);
1860    }
1861
1862    #[rstest]
1863    fn test_deserialize_fills_response() {
1864        let json = include_str!("../../test_data/http_get_fills.json");
1865        let response: AxFillsResponse = serde_json::from_str(json).unwrap();
1866        assert_eq!(response.fills.len(), 2);
1867        assert_eq!(response.fills[0].side, AxOrderSide::Buy);
1868        assert!(response.fills[0].is_taker);
1869        assert!(!response.fills[1].is_taker);
1870        assert_eq!(response.fills[0].is_block_trade, Some(false));
1871        assert_eq!(response.fills[0].is_final_settlement, Some(false));
1872        assert_eq!(response.total_count, Some(2));
1873        assert_eq!(response.limit, Some(100));
1874        assert_eq!(response.next_cursor, None);
1875    }
1876
1877    #[rstest]
1878    fn test_deserialize_candles_response() {
1879        let json = include_str!("../../test_data/http_get_candles.json");
1880        let response: AxCandlesResponse = serde_json::from_str(json).unwrap();
1881        assert_eq!(response.candles.len(), 2);
1882        assert_eq!(response.candles[0].symbol, "EURUSD-PERP");
1883        assert_eq!(response.candles[0].width, AxCandleWidth::Minutes1);
1884    }
1885
1886    #[rstest]
1887    fn test_deserialize_candle_response() {
1888        let json = include_str!("../../test_data/http_get_candle.json");
1889        let response: AxCandleResponse = serde_json::from_str(json).unwrap();
1890        assert_eq!(response.candle.symbol, "EURUSD-PERP");
1891        assert_eq!(response.candle.width, AxCandleWidth::Minutes1);
1892    }
1893
1894    #[rstest]
1895    fn test_deserialize_risk_snapshot_response() {
1896        let json = include_str!("../../test_data/http_get_risk_snapshot.json");
1897        let response: AxRiskSnapshotResponse = serde_json::from_str(json).unwrap();
1898        assert_eq!(
1899            response.risk_snapshot.account_id,
1900            Ustr::from("3c90c3cc-0d44-4b50-8888-8dd25736052a")
1901        );
1902        assert_eq!(response.risk_snapshot.per_symbol.len(), 2);
1903        assert!(
1904            response
1905                .risk_snapshot
1906                .per_symbol
1907                .contains_key(&Ustr::from("EURUSD-PERP"))
1908        );
1909        assert_eq!(
1910            response.risk_snapshot.per_symbol[&Ustr::from("GBPUSD-PERP")].average_price,
1911            None
1912        );
1913    }
1914
1915    #[rstest]
1916    fn test_deserialize_transactions_response() {
1917        let json = include_str!("../../test_data/http_get_transactions.json");
1918        let response: AxTransactionsResponse = serde_json::from_str(json).unwrap();
1919        assert_eq!(response.transactions.len(), 2);
1920        assert_eq!(response.total_count, Some(2));
1921        assert_eq!(response.limit, Some(100));
1922        assert_eq!(response.transactions[0].account_id, Ustr::from("account-1"));
1923        assert_eq!(response.transactions[0].transaction_type, "deposit");
1924        assert!(response.transactions[0].initiated_by_user_id.is_some());
1925        assert!(response.transactions[1].reference_id.is_none());
1926    }
1927
1928    #[rstest]
1929    fn test_deserialize_preview_aggressive_limit_order_response() {
1930        let json = include_str!("../../test_data/http_preview_aggressive_limit_order.json");
1931        let response: AxPreviewAggressiveLimitOrderResponse = serde_json::from_str(json).unwrap();
1932        assert_eq!(response.filled_quantity, 1000);
1933        assert_eq!(response.remaining_quantity, 0);
1934        assert!(response.limit_price.is_some());
1935        assert!(response.vwap.is_some());
1936    }
1937
1938    #[rstest]
1939    fn test_deserialize_place_order_response() {
1940        let json = include_str!("../../test_data/http_place_order.json");
1941        let response: AxPlaceOrderResponse = serde_json::from_str(json).unwrap();
1942        assert_eq!(response.oid, "O-01ARZ3NDEKTSV4RRFFQ69G5FAV");
1943    }
1944
1945    #[rstest]
1946    fn test_deserialize_cancel_order_response() {
1947        let json = include_str!("../../test_data/http_cancel_order.json");
1948        let response: AxCancelOrderResponse = serde_json::from_str(json).unwrap();
1949        assert!(response.cxl_rx);
1950    }
1951
1952    #[rstest]
1953    fn test_deserialize_cancel_all_orders_response() {
1954        let json = include_str!("../../test_data/http_cancel_all_orders.json");
1955        let _response: AxCancelAllOrdersResponse = serde_json::from_str(json).unwrap();
1956    }
1957
1958    #[rstest]
1959    fn test_deserialize_trades_response() {
1960        let json = include_str!("../../test_data/http_get_trades.json");
1961        let response: AxTradesResponse = serde_json::from_str(json).unwrap();
1962        assert_eq!(response.trades.len(), 2);
1963        assert_eq!(response.trades[0].s, "EURUSD-PERP");
1964        assert_eq!(response.trades[0].d, AxOrderSide::Buy);
1965        assert_eq!(response.trades[0].q, 100);
1966        assert_eq!(response.trades[1].d, AxOrderSide::Sell);
1967    }
1968
1969    #[rstest]
1970    fn test_deserialize_book_response() {
1971        let json = include_str!("../../test_data/http_get_book.json");
1972        let response: AxBookResponse = serde_json::from_str(json).unwrap();
1973        assert_eq!(response.book.s, "EURUSD-PERP");
1974        assert_eq!(response.book.b.len(), 3);
1975        assert_eq!(response.book.a.len(), 3);
1976        assert_eq!(response.book.b[0].q, 500);
1977        assert_eq!(response.book.a[0].q, 400);
1978    }
1979
1980    #[rstest]
1981    fn test_deserialize_order_status_query_response() {
1982        let json = include_str!("../../test_data/http_get_order_status.json");
1983        let response: AxOrderStatusQueryResponse = serde_json::from_str(json).unwrap();
1984        assert_eq!(response.status.symbol, "EURUSD-PERP");
1985        assert_eq!(response.status.order_id, "O-01ARZ3NDEKTSV4RRFFQ69G5FAV");
1986        assert_eq!(response.status.state, AxOrderStatus::PartiallyFilled);
1987        assert_eq!(response.status.clord_id, Some(12345));
1988        assert_eq!(response.status.filled_quantity, Some(300));
1989        assert_eq!(response.status.remaining_quantity, Some(700));
1990        assert_eq!(response.status.reject_reason, None);
1991        assert_eq!(response.status.reject_message, None);
1992    }
1993
1994    #[rstest]
1995    fn test_deserialize_orders_response() {
1996        let json = include_str!("../../test_data/http_get_orders.json");
1997        let response: AxOrdersResponse = serde_json::from_str(json).unwrap();
1998        assert_eq!(response.orders.len(), 2);
1999        assert_eq!(response.total_count, Some(2));
2000        assert_eq!(response.limit, Some(100));
2001        assert_eq!(response.next_cursor, None);
2002        assert_eq!(response.orders[0].aid.as_deref(), Some("account-1"));
2003        assert_eq!(response.orders[0].o, AxOrderStatus::PartiallyFilled);
2004        assert_eq!(response.orders[0].xq, 300);
2005        assert_eq!(response.orders[1].o, AxOrderStatus::Filled);
2006        assert_eq!(response.orders[1].d, AxOrderSide::Sell);
2007        assert!(response.orders[0].po);
2008        assert_eq!(response.orders[0].rb, Some(AxRepriceBehavior::Reject));
2009        assert!(!response.orders[1].po);
2010        assert_eq!(response.orders[1].rb, None);
2011    }
2012
2013    #[rstest]
2014    fn test_deserialize_initial_margin_requirement_response() {
2015        let json = include_str!("../../test_data/http_initial_margin_requirement.json");
2016        let response: AxInitialMarginRequirementResponse = serde_json::from_str(json).unwrap();
2017        assert_eq!(response.im, Decimal::new(125050, 2));
2018    }
2019
2020    #[rstest]
2021    fn test_deserialize_replace_order_response() {
2022        let json = include_str!("../../test_data/http_replace_order.json");
2023        let response: AxReplaceOrderResponse = serde_json::from_str(json).unwrap();
2024        assert_eq!(response.oid, "O-01ARZ3NDEKTSV4RRFFQ69G5NEW");
2025    }
2026
2027    #[rstest]
2028    fn test_replace_order_request_serialization() {
2029        let request = ReplaceOrderRequest::new("O-01ARZ3NDEKTSV4RRFFQ69G5FAV")
2030            .with_price(Decimal::new(10550, 4))
2031            .with_quantity(200);
2032
2033        let json = serde_json::to_value(&request).unwrap();
2034        assert_eq!(json["oid"], "O-01ARZ3NDEKTSV4RRFFQ69G5FAV");
2035        assert_eq!(json["p"], "1.0550");
2036        assert_eq!(json["q"], 200);
2037        assert!(json.get("po").is_none());
2038        assert!(json.get("tif").is_none());
2039        assert!(json.get("trigger_price").is_none());
2040    }
2041
2042    #[rstest]
2043    fn test_replace_order_request_minimal() {
2044        let request = ReplaceOrderRequest::new("O-TEST");
2045        let json = serde_json::to_value(&request).unwrap();
2046        assert_eq!(json["oid"], "O-TEST");
2047        assert!(json.get("p").is_none());
2048        assert!(json.get("q").is_none());
2049    }
2050
2051    #[rstest]
2052    fn test_authenticate_request_serializes_and_redacts_debug() {
2053        let request = AuthenticateApiKeyRequest::new("api-key-token", "api-secret-value", 3600);
2054
2055        let json = serde_json::to_value(&request).unwrap();
2056        let formatted = format!("{request:?}");
2057
2058        assert_eq!(json["api_key"], "api-key-token");
2059        assert_eq!(json["api_secret"], "api-secret-value");
2060        assert_eq!(json["expiration_seconds"], 3600);
2061        assert_eq!(
2062            formatted,
2063            "AuthenticateApiKeyRequest { api_key: <redacted>, api_secret: <redacted>, expiration_seconds: 3600 }",
2064        );
2065        assert!(!formatted.contains("api-key-token"));
2066        assert!(!formatted.contains("api-secret-value"));
2067    }
2068
2069    #[rstest]
2070    fn test_authenticate_response_redacts_debug() {
2071        assert_zeroize_on_drop::<AxAuthenticateResponse>();
2072
2073        let response = AxAuthenticateResponse {
2074            token: SecretString::from("session-token-value"),
2075        };
2076
2077        let formatted = format!("{response:?}");
2078
2079        assert_eq!(formatted, "AxAuthenticateResponse { token: <redacted> }");
2080        assert!(!formatted.contains("session-token-value"));
2081    }
2082
2083    #[rstest]
2084    #[case(false)]
2085    #[case(true)]
2086    fn test_optional_account_names_and_funding_prices(#[case] explicit_null: bool) {
2087        let mut who: serde_json::Value =
2088            serde_json::from_str(include_str!("../../test_data/http_get_whoami.json")).unwrap();
2089        let mut funding: serde_json::Value =
2090            serde_json::from_str(include_str!("../../test_data/http_get_funding_rates.json"))
2091                .unwrap();
2092
2093        for (value, field) in [
2094            (&mut who["accounts"][0], "name"),
2095            (&mut funding["funding_rates"][0], "funding_amount"),
2096        ] {
2097            if explicit_null {
2098                value[field] = serde_json::Value::Null;
2099            } else {
2100                value.as_object_mut().unwrap().remove(field);
2101            }
2102        }
2103
2104        if explicit_null {
2105            funding["funding_rates"][0]["benchmark_price"] = serde_json::Value::Null;
2106        } else {
2107            funding["funding_rates"][0]
2108                .as_object_mut()
2109                .unwrap()
2110                .remove("benchmark_price");
2111        }
2112
2113        let account: AxWhoAmI = serde_json::from_value(who.clone()).unwrap();
2114        who.as_object_mut().unwrap().remove("accounts");
2115        let without_accounts: AxWhoAmI = serde_json::from_value(who).unwrap();
2116        let rates: AxFundingRatesResponse = serde_json::from_value(funding).unwrap();
2117
2118        assert_eq!(account.accounts[0].name, None);
2119        assert!(without_accounts.accounts.is_empty());
2120        assert_eq!(rates.funding_rates[0].funding_amount, None);
2121        assert_eq!(rates.funding_rates[0].benchmark_price, None);
2122    }
2123
2124    #[rstest]
2125    #[case(
2126        "ORIGINAL_ORDER_TERMINATED",
2127        AxOrderRejectReason::OriginalOrderTerminated
2128    )]
2129    #[case(
2130        "DUPLICATE_CLIENT_ORDER_ID",
2131        AxOrderRejectReason::DuplicateClientOrderId
2132    )]
2133    fn test_additional_reject_reasons(#[case] wire: &str, #[case] expected: AxOrderRejectReason) {
2134        let value = serde_json::Value::String(wire.to_owned());
2135        let reason: AxOrderRejectReason = serde_json::from_value(value.clone()).unwrap();
2136        assert_eq!(reason, expected);
2137        assert_eq!(serde_json::to_value(reason).unwrap(), value);
2138        assert_eq!(
2139            AxOrderRejectReason::reason_str(Some(reason), None),
2140            Some(wire.to_owned())
2141        );
2142    }
2143
2144    #[rstest]
2145    #[case(include_str!("../../test_data/captured/whoami.json"), captured_response::<AxWhoAmI>)]
2146    #[case(include_str!("../../test_data/captured/balances.json"), captured_response::<AxBalancesResponse>)]
2147    #[case(include_str!("../../test_data/captured/positions.json"), captured_response::<AxPositionsResponse>)]
2148    #[case(include_str!("../../test_data/captured/risk.json"), captured_response::<AxRiskSnapshotResponse>)]
2149    #[case(include_str!("../../test_data/captured/instruments.json"), captured_response::<AxInstrumentsResponse>)]
2150    #[case(include_str!("../../test_data/captured/ticker.json"), captured_response::<AxTickerResponse>)]
2151    #[case(include_str!("../../test_data/captured/tickers.json"), captured_response::<AxTickersResponse>)]
2152    #[case(include_str!("../../test_data/captured/book.json"), captured_response::<AxBookResponse>)]
2153    #[case(include_str!("../../test_data/captured/trades.json"), captured_response::<AxTradesResponse>)]
2154    #[case(include_str!("../../test_data/captured/candles.json"), captured_response::<AxCandlesResponse>)]
2155    #[case(include_str!("../../test_data/captured/current-candle.json"), captured_response::<AxCandleResponse>)]
2156    #[case(include_str!("../../test_data/captured/last-candle.json"), captured_response::<AxCandleResponse>)]
2157    #[case(include_str!("../../test_data/captured/funding-rates.json"), captured_response::<AxFundingRatesResponse>)]
2158    #[case(include_str!("../../test_data/captured/funding-slots.json"), captured_response::<AxFundingSlotsResponse>)]
2159    #[case(include_str!("../../test_data/captured/transactions.json"), captured_response::<AxTransactionsResponse>)]
2160    #[case(include_str!("../../test_data/captured/fills.json"), captured_response::<AxFillsResponse>)]
2161    #[case(include_str!("../../test_data/captured/orders.json"), captured_response::<AxOrdersResponse>)]
2162    #[case(include_str!("../../test_data/captured/open-orders.json"), captured_response::<AxOpenOrdersResponse>)]
2163    #[case(include_str!("../../test_data/captured/open-reject.json"), captured_response::<AxOpenOrdersResponse>)]
2164    #[case(include_str!("../../test_data/captured/open-backoff.json"), captured_response::<AxOpenOrdersResponse>)]
2165    #[case(include_str!("../../test_data/captured/open-top.json"), captured_response::<AxOpenOrdersResponse>)]
2166    #[case(include_str!("../../test_data/captured/open-default.json"), captured_response::<AxOpenOrdersResponse>)]
2167    #[case(include_str!("../../test_data/captured/open-inactive.json"), captured_response::<AxOpenOrdersResponse>)]
2168    #[case(include_str!("../../test_data/captured/fills-live.json"), captured_response::<AxFillsResponse>)]
2169    #[case(include_str!("../../test_data/captured/orders-live.json"), captured_response::<AxOrdersResponse>)]
2170    #[case(include_str!("../../test_data/captured/positions-long.json"), captured_response::<AxPositionsResponse>)]
2171    #[case(include_str!("../../test_data/captured/positions-short.json"), captured_response::<AxPositionsResponse>)]
2172    #[case(include_str!("../../test_data/captured/initial-margin.json"), captured_response::<AxInitialMarginRequirementResponse>)]
2173    #[case(include_str!("../../test_data/captured/preview.json"), captured_response::<AxPreviewAggressiveLimitOrderResponse>)]
2174    #[case(include_str!("../../test_data/captured/order-status-canceled.json"), captured_response::<AxOrderStatusQueryResponse>)]
2175    #[case(include_str!("../../test_data/captured/order-status-filled.json"), captured_response::<AxOrderStatusQueryResponse>)]
2176    #[case(include_str!("../../test_data/captured/order-status-replaced.json"), captured_response::<AxOrderStatusQueryResponse>)]
2177    #[case(include_str!("../../test_data/captured/ws-orders-acknowledged.json"), captured_order_event)]
2178    #[case(include_str!("../../test_data/captured/ws-orders-replaced.json"), captured_order_event)]
2179    #[case(include_str!("../../test_data/captured/ws-orders-canceled.json"), captured_order_event)]
2180    #[case(include_str!("../../test_data/captured/ws-orders-expired.json"), captured_order_event)]
2181    #[case(include_str!("../../test_data/captured/ws-orders-rejected.json"), captured_order_event)]
2182    #[case(include_str!("../../test_data/captured/ws-orders-filled-b.json"), captured_order_event)]
2183    #[case(include_str!("../../test_data/captured/ws-orders-filled-s.json"), captured_order_event)]
2184    fn test_captured_response_preserves_every_field(
2185        #[case] raw: &str,
2186        #[case] decode: fn(&serde_json::Value) -> serde_json::Value,
2187    ) {
2188        let mut expected: serde_json::Value = serde_json::from_str(raw).unwrap();
2189        let actual = decode(&expected);
2190        if expected["t"] == "c" && expected["xr"] == "" {
2191            expected["xr"] = "UNKNOWN".into();
2192        }
2193
2194        assert_captured_fields(&expected, &actual, "$");
2195    }
2196
2197    fn captured_response<T: serde::de::DeserializeOwned + Serialize>(
2198        raw: &serde_json::Value,
2199    ) -> serde_json::Value {
2200        serde_json::to_value(serde_json::from_value::<T>(raw.clone()).unwrap()).unwrap()
2201    }
2202
2203    fn captured_order_event(raw: &serde_json::Value) -> serde_json::Value {
2204        let AxOrdersWsFrame::Event(event) = parse_order_message(&raw.to_string()).unwrap() else {
2205            panic!("expected order event");
2206        };
2207
2208        let (tag, actual) = match *event {
2209            AxWsOrderEvent::Acknowledged(message) => ("n", serde_json::to_value(message)),
2210            AxWsOrderEvent::Replaced(message) => ("r", serde_json::to_value(message)),
2211            AxWsOrderEvent::Canceled(message) => ("c", serde_json::to_value(message)),
2212            AxWsOrderEvent::Expired(message) => ("x", serde_json::to_value(message)),
2213            AxWsOrderEvent::Rejected(message) => ("j", serde_json::to_value(message)),
2214            AxWsOrderEvent::Filled(message) => ("f", serde_json::to_value(message)),
2215            AxWsOrderEvent::PartiallyFilled(message) => ("p", serde_json::to_value(message)),
2216            AxWsOrderEvent::DoneForDay(message) => ("d", serde_json::to_value(message)),
2217            AxWsOrderEvent::CancelRejected(message) => ("e", serde_json::to_value(message)),
2218            AxWsOrderEvent::Heartbeat => panic!("expected lifecycle event"),
2219        };
2220
2221        let mut actual = actual.unwrap();
2222        actual["t"] = tag.into();
2223        actual
2224    }
2225
2226    fn assert_captured_fields(
2227        expected: &serde_json::Value,
2228        actual: &serde_json::Value,
2229        path: &str,
2230    ) {
2231        match expected {
2232            serde_json::Value::Object(fields) => {
2233                for (key, value) in fields {
2234                    let child = actual
2235                        .get(key)
2236                        .unwrap_or_else(|| panic!("missing {path}.{key}"));
2237                    assert_captured_fields(value, child, &format!("{path}.{key}"));
2238                }
2239            }
2240            serde_json::Value::Array(values) => {
2241                let children = actual.as_array().unwrap();
2242                assert_eq!(children.len(), values.len(), "{path}");
2243
2244                for (i, (value, child)) in values.iter().zip(children).enumerate() {
2245                    assert_captured_fields(value, child, &format!("{path}[{i}]"));
2246                }
2247            }
2248            serde_json::Value::String(value) if expected != actual => {
2249                let text = actual
2250                    .as_str()
2251                    .unwrap_or_else(|| panic!("expected string at {path}: {actual}"));
2252
2253                if let Ok(decimal) = value.parse::<rust_decimal::Decimal>() {
2254                    assert_eq!(
2255                        text.parse::<rust_decimal::Decimal>().unwrap(),
2256                        decimal,
2257                        "{path}"
2258                    );
2259                } else if let Ok(timestamp) = value.parse::<Timestamp>() {
2260                    assert_eq!(text.parse::<Timestamp>().unwrap(), timestamp, "{path}");
2261                } else {
2262                    assert_eq!(expected, actual, "{path}");
2263                }
2264            }
2265            serde_json::Value::Number(value) if actual.is_string() => {
2266                assert_eq!(
2267                    value.to_string().parse::<rust_decimal::Decimal>().unwrap(),
2268                    actual
2269                        .as_str()
2270                        .unwrap()
2271                        .parse::<rust_decimal::Decimal>()
2272                        .unwrap(),
2273                    "{path}"
2274                );
2275            }
2276            _ => assert_eq!(expected, actual, "{path}"),
2277        }
2278    }
2279}