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: String,
55    /// Account display name.
56    pub name: 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: String,
86    /// Username for the account.
87    pub username: String,
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    pub accounts: Vec<AxWhoAmIAccount>,
100    /// Human-readable alias for the user (optional).
101    #[serde(default)]
102    pub pseudonym: Option<String>,
103    /// Reference code for fiat deposits (optional).
104    #[serde(default)]
105    pub fiat_deposit_code: Option<String>,
106}
107
108/// Individual instrument definition.
109///
110/// # References
111/// - <https://docs.architect.exchange/api-reference/symbols-instruments/get-instruments>
112#[derive(Clone, Debug, Serialize, Deserialize)]
113#[serde(rename_all = "snake_case")]
114pub struct AxInstrument {
115    /// Trading symbol for the instrument.
116    pub symbol: Ustr,
117    /// Umbrella product shared by sibling contracts.
118    #[serde(default)]
119    pub product: Option<Ustr>,
120    /// Current trading state of the instrument (defaults to Open if not provided).
121    #[serde(default = "default_instrument_state")]
122    pub state: AxInstrumentState,
123    /// Contract multiplier.
124    #[serde(deserialize_with = "deserialize_decimal_or_zero")]
125    pub multiplier: Decimal,
126    /// Minimum order size.
127    #[serde(deserialize_with = "deserialize_decimal_or_zero")]
128    pub minimum_order_size: Decimal,
129    /// Price tick size.
130    #[serde(deserialize_with = "deserialize_decimal_or_zero")]
131    pub tick_size: Decimal,
132    /// Quote currency symbol.
133    pub quote_currency: Ustr,
134    /// Funding settlement currency.
135    pub funding_settlement_currency: Ustr,
136    /// Instrument category (e.g. fx, equities, metals).
137    pub category: AxCategory,
138    /// Maintenance margin percentage.
139    #[serde(deserialize_with = "deserialize_decimal_or_zero")]
140    pub maintenance_margin_pct: Decimal,
141    /// Initial margin percentage.
142    #[serde(deserialize_with = "deserialize_decimal_or_zero")]
143    pub initial_margin_pct: Decimal,
144    /// Contract mark price description (optional).
145    #[serde(default)]
146    pub contract_mark_price: Option<String>,
147    /// Contract size description (optional).
148    #[serde(default)]
149    pub contract_size: Option<String>,
150    /// Instrument description (optional).
151    #[serde(default)]
152    pub description: Option<String>,
153    /// Contract expiration; absent for perpetual contracts.
154    #[serde(default)]
155    pub expiration: Option<Timestamp>,
156    /// Funding calendar schedule (optional).
157    #[serde(default)]
158    pub funding_calendar_schedule: Option<String>,
159    /// Funding frequency (optional).
160    #[serde(default)]
161    pub funding_frequency: Option<String>,
162    /// Lower cap for funding rate percentage (optional).
163    #[serde(default, deserialize_with = "deserialize_optional_decimal_from_str")]
164    pub funding_rate_cap_lower_pct: Option<Decimal>,
165    /// Upper cap for funding rate percentage (optional).
166    #[serde(default, deserialize_with = "deserialize_optional_decimal_from_str")]
167    pub funding_rate_cap_upper_pct: Option<Decimal>,
168    /// Lower deviation percentage for price bands (optional).
169    #[serde(default, deserialize_with = "deserialize_optional_decimal_from_str")]
170    pub price_band_lower_deviation_pct: Option<Decimal>,
171    /// Upper deviation percentage for price bands (optional).
172    #[serde(default, deserialize_with = "deserialize_optional_decimal_from_str")]
173    pub price_band_upper_deviation_pct: Option<Decimal>,
174    /// Price bands configuration (optional).
175    #[serde(default)]
176    pub price_bands: Option<String>,
177    /// Price quotation format (optional).
178    #[serde(default)]
179    pub price_quotation: Option<String>,
180    /// Underlying benchmark price description (optional).
181    #[serde(default)]
182    pub underlying_benchmark_price: Option<String>,
183}
184
185/// Response payload returned by `GET /instruments`.
186///
187/// # References
188/// - <https://docs.architect.exchange/api-reference/symbols-instruments/get-instruments>
189#[derive(Clone, Debug, Serialize, Deserialize)]
190#[serde(rename_all = "snake_case")]
191pub struct AxInstrumentsResponse {
192    /// List of instruments.
193    pub instruments: Vec<AxInstrument>,
194}
195
196/// Individual balance entry.
197///
198/// # References
199/// - <https://docs.architect.exchange/api-reference/portfolio-management/get-balances>
200#[derive(Clone, Debug, Serialize, Deserialize)]
201#[serde(rename_all = "snake_case")]
202pub struct AxBalance {
203    /// Asset symbol.
204    pub symbol: Ustr,
205    /// Available balance amount.
206    #[serde(deserialize_with = "deserialize_decimal_or_zero")]
207    pub amount: Decimal,
208}
209
210/// Response payload returned by `GET /balances`.
211///
212/// # References
213/// - <https://docs.architect.exchange/api-reference/portfolio-management/get-balances>
214#[derive(Clone, Debug, Serialize, Deserialize)]
215#[serde(rename_all = "snake_case")]
216pub struct AxBalancesResponse {
217    /// List of balances.
218    pub balances: Vec<AxBalance>,
219}
220
221/// Individual position entry.
222///
223/// # References
224/// - <https://docs.architect.exchange/api-reference/portfolio-management/get-positions>
225#[derive(Clone, Debug, Serialize, Deserialize)]
226#[serde(rename_all = "snake_case")]
227pub struct AxPosition {
228    /// Account identifier.
229    pub account_id: Ustr,
230    /// Instrument symbol.
231    pub symbol: Ustr,
232    /// Signed quantity (positive for long, negative for short).
233    pub signed_quantity: i64,
234    /// Signed notional value.
235    #[serde(deserialize_with = "deserialize_decimal_or_zero")]
236    pub signed_notional: Decimal,
237    /// Position timestamp.
238    pub timestamp: Timestamp,
239    /// Realized profit and loss.
240    #[serde(deserialize_with = "deserialize_decimal_or_zero")]
241    pub realized_pnl: Decimal,
242}
243
244/// Response payload returned by `GET /positions`.
245///
246/// # References
247/// - <https://docs.architect.exchange/api-reference/portfolio-management/get-positions>
248#[derive(Clone, Debug, Serialize, Deserialize)]
249#[serde(rename_all = "snake_case")]
250pub struct AxPositionsResponse {
251    /// List of positions.
252    pub positions: Vec<AxPosition>,
253}
254
255/// Individual ticker entry.
256///
257/// # References
258/// - <https://docs.architect.exchange/api-reference/marketdata/get-ticker>
259#[derive(Clone, Debug, Serialize, Deserialize)]
260#[serde(rename_all = "snake_case")]
261pub struct AxTicker {
262    /// Instrument symbol.
263    #[serde(rename = "s")]
264    pub symbol: Ustr,
265    /// Best bid price.
266    #[serde(
267        default,
268        rename = "bp",
269        deserialize_with = "deserialize_optional_decimal"
270    )]
271    pub bid: Option<Decimal>,
272    /// Best ask price.
273    #[serde(
274        default,
275        rename = "ap",
276        deserialize_with = "deserialize_optional_decimal"
277    )]
278    pub ask: Option<Decimal>,
279    /// Last trade price.
280    #[serde(
281        default,
282        rename = "p",
283        deserialize_with = "deserialize_optional_decimal"
284    )]
285    pub last: Option<Decimal>,
286    /// Mark price.
287    #[serde(
288        default,
289        rename = "m",
290        deserialize_with = "deserialize_optional_decimal"
291    )]
292    pub mark: Option<Decimal>,
293    /// 24-hour volume.
294    #[serde(
295        default,
296        rename = "v",
297        deserialize_with = "deserialize_optional_decimal"
298    )]
299    pub volume_24h: Option<Decimal>,
300    /// 24-hour high price.
301    #[serde(
302        default,
303        rename = "h",
304        deserialize_with = "deserialize_optional_decimal"
305    )]
306    pub high_24h: Option<Decimal>,
307    /// 24-hour low price.
308    #[serde(
309        default,
310        rename = "l",
311        deserialize_with = "deserialize_optional_decimal"
312    )]
313    pub low_24h: Option<Decimal>,
314    /// Timestamp seconds.
315    #[serde(default)]
316    pub ts: Option<i64>,
317    /// Timestamp nanosecond component.
318    #[serde(default)]
319    pub tn: Option<i64>,
320    /// Last trade quantity.
321    #[serde(default, rename = "q")]
322    pub last_quantity: Option<u64>,
323    /// Open interest.
324    #[serde(default, rename = "oi")]
325    pub open_interest: Option<i64>,
326    /// Instrument state.
327    #[serde(default, rename = "i")]
328    pub instrument_state: Option<AxInstrumentState>,
329    /// Price band lower limit.
330    #[serde(
331        default,
332        rename = "pl",
333        deserialize_with = "deserialize_optional_decimal"
334    )]
335    pub price_band_lower: Option<Decimal>,
336    /// Price band upper limit.
337    #[serde(
338        default,
339        rename = "pu",
340        deserialize_with = "deserialize_optional_decimal"
341    )]
342    pub price_band_upper: Option<Decimal>,
343    /// Last settlement price.
344    #[serde(
345        default,
346        rename = "lsp",
347        deserialize_with = "deserialize_optional_decimal"
348    )]
349    pub last_settlement_price: Option<Decimal>,
350    /// Last settlement time as epoch seconds.
351    #[serde(default, rename = "lst")]
352    pub last_settlement_time: Option<i64>,
353}
354
355/// Response payload returned by `GET /tickers`.
356///
357/// # References
358/// - <https://docs.architect.exchange/api-reference/marketdata/get-tickers>
359#[derive(Clone, Debug, Serialize, Deserialize)]
360#[serde(rename_all = "snake_case")]
361pub struct AxTickersResponse {
362    /// List of tickers.
363    pub tickers: Vec<AxTicker>,
364    /// Total matching records.
365    pub total_count: i64,
366    /// Applied limit.
367    pub limit: i32,
368    /// Applied offset.
369    pub offset: i32,
370}
371
372/// Response payload returned by `GET /ticker`.
373///
374/// # References
375/// - <https://docs.architect.exchange/api-reference/marketdata/get-ticker>
376#[derive(Clone, Debug, Serialize, Deserialize)]
377#[serde(rename_all = "snake_case")]
378pub struct AxTickerResponse {
379    /// The ticker data.
380    pub ticker: AxTicker,
381}
382
383/// Response payload returned by `POST /authenticate`.
384///
385/// # References
386/// - <https://docs.architect.exchange/api-reference/user-management/authenticate>
387#[derive(Debug, Clone, Serialize, Deserialize, Zeroize, ZeroizeOnDrop)]
388#[serde(rename_all = "snake_case")]
389pub struct AxAuthenticateResponse {
390    /// Session token for authenticated requests.
391    pub token: SecretString,
392}
393
394impl AxAuthenticateResponse {
395    /// Consumes the response and returns the session token.
396    #[must_use]
397    pub fn into_token(mut self) -> SecretString {
398        std::mem::take(&mut self.token)
399    }
400}
401
402/// Response payload returned by `POST /place-order`.
403///
404/// # References
405/// - <https://docs.architect.exchange/api-reference/order-management/place-order>
406#[derive(Clone, Debug, Serialize, Deserialize)]
407pub struct AxPlaceOrderResponse {
408    /// Order ID of the placed order.
409    pub oid: String,
410}
411
412/// Response payload returned by `POST /cancel-order`.
413///
414/// # References
415/// - <https://docs.architect.exchange/api-reference/order-management/cancel-order>
416#[derive(Clone, Debug, Serialize, Deserialize)]
417pub struct AxCancelOrderResponse {
418    /// Whether the cancel request has been accepted.
419    pub cxl_rx: bool,
420}
421
422/// Individual trade entry from the REST API.
423///
424/// # References
425/// - <https://docs.architect.exchange/api-reference/marketdata/get-trades>
426#[derive(Clone, Debug, Serialize, Deserialize)]
427pub struct AxRestTrade {
428    /// Timestamp (Unix epoch seconds).
429    pub ts: i64,
430    /// Nanosecond component of the timestamp.
431    pub tn: i64,
432    /// Trade price (decimal string).
433    #[serde(deserialize_with = "deserialize_decimal_or_zero")]
434    pub p: Decimal,
435    /// Trade quantity.
436    pub q: i64,
437    /// Symbol.
438    pub s: Ustr,
439    /// Trade direction (aggressor side).
440    pub d: AxOrderSide,
441}
442
443/// Response payload returned by `GET /trades`.
444///
445/// # References
446/// - <https://docs.architect.exchange/api-reference/marketdata/get-trades>
447#[derive(Clone, Debug, Serialize, Deserialize)]
448pub struct AxTradesResponse {
449    /// List of trades.
450    pub trades: Vec<AxRestTrade>,
451}
452
453/// Individual price level in the order book.
454///
455/// # References
456/// - <https://docs.architect.exchange/api-reference/marketdata/get-book>
457#[derive(Clone, Debug, Serialize, Deserialize)]
458pub struct AxBookLevel {
459    /// Price (decimal string).
460    #[serde(deserialize_with = "deserialize_decimal_or_zero")]
461    pub p: Decimal,
462    /// Quantity at this price level.
463    pub q: i64,
464    /// Individual order IDs (Level 3 only).
465    #[serde(default)]
466    pub o: Option<Vec<i64>>,
467}
468
469/// Order book snapshot.
470///
471/// # References
472/// - <https://docs.architect.exchange/api-reference/marketdata/get-book>
473#[derive(Clone, Debug, Serialize, Deserialize)]
474pub struct AxBook {
475    /// Timestamp (Unix epoch seconds).
476    pub ts: i64,
477    /// Nanosecond component of the timestamp.
478    pub tn: i64,
479    /// Symbol.
480    pub s: Ustr,
481    /// Bid levels (best to worst).
482    pub b: Vec<AxBookLevel>,
483    /// Ask levels (best to worst).
484    pub a: Vec<AxBookLevel>,
485}
486
487/// Response payload returned by `GET /book`.
488///
489/// # References
490/// - <https://docs.architect.exchange/api-reference/marketdata/get-book>
491#[derive(Clone, Debug, Serialize, Deserialize)]
492pub struct AxBookResponse {
493    /// The order book snapshot.
494    pub book: AxBook,
495}
496
497/// Detailed order status from single-order lookup.
498///
499/// # References
500/// - <https://docs.architect.exchange/api-reference/order-management/get-order-status>
501#[derive(Clone, Debug, Serialize, Deserialize)]
502pub struct AxOrderStatusDetail {
503    /// Trading symbol.
504    pub symbol: Ustr,
505    /// Order ID.
506    pub order_id: String,
507    /// Current order state.
508    pub state: AxOrderStatus,
509    /// Client order ID.
510    #[serde(default)]
511    pub clord_id: Option<u64>,
512    /// Filled quantity.
513    #[serde(default)]
514    pub filled_quantity: Option<i64>,
515    /// Remaining quantity.
516    #[serde(default)]
517    pub remaining_quantity: Option<i64>,
518    /// Reject reason.
519    #[serde(default)]
520    pub reject_reason: Option<AxOrderRejectReason>,
521    /// Reject message.
522    #[serde(default)]
523    pub reject_message: Option<String>,
524}
525
526/// Response payload returned by `GET /order-status`.
527///
528/// # References
529/// - <https://docs.architect.exchange/api-reference/order-management/get-order-status>
530#[derive(Clone, Debug, Serialize, Deserialize)]
531pub struct AxOrderStatusQueryResponse {
532    /// The order status detail.
533    pub status: AxOrderStatusDetail,
534}
535
536/// Reason for order rejection from the exchange.
537///
538/// # References
539/// - <https://docs.architect.exchange/api-reference/order-management/get-orders>
540#[derive(Clone, Copy, Debug, Display, Eq, PartialEq, Hash, AsRefStr, Serialize, Deserialize)]
541#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
542#[strum(serialize_all = "SCREAMING_SNAKE_CASE")]
543pub enum AxOrderRejectReason {
544    CloseOnly,
545    InsufficientMargin,
546    MaxOpenOrdersExceeded,
547    UnknownSymbol,
548    ExchangeClosed,
549    IncorrectQuantity,
550    InvalidPriceIncrement,
551    IncorrectOrderType,
552    PriceOutOfBounds,
553    NoLiquidity,
554    InsufficientCreditLimit,
555    #[serde(other)]
556    Unknown,
557}
558
559/// Detailed order entry from historical orders query.
560///
561/// # References
562/// - <https://docs.architect.exchange/api-reference/order-management/get-orders>
563#[derive(Clone, Debug, Serialize, Deserialize)]
564pub struct AxOrderDetail {
565    /// Timestamp (Unix epoch seconds).
566    pub ts: i64,
567    /// Nanosecond component.
568    #[serde(default)]
569    pub tn: i64,
570    /// Order ID.
571    pub oid: String,
572    /// Account ID.
573    #[serde(default)]
574    pub aid: Option<String>,
575    /// User ID.
576    pub u: String,
577    /// Symbol.
578    pub s: Ustr,
579    /// Price.
580    #[serde(deserialize_with = "deserialize_decimal_or_zero")]
581    pub p: Decimal,
582    /// Order quantity.
583    pub q: u64,
584    /// Executed quantity.
585    pub xq: u64,
586    /// Remaining quantity.
587    pub rq: u64,
588    /// Order state.
589    pub o: AxOrderStatus,
590    /// Order side.
591    pub d: AxOrderSide,
592    /// Time in force.
593    pub tif: AxTimeInForce,
594    /// Client order ID.
595    #[serde(default)]
596    pub cid: Option<u64>,
597    /// Reject reason.
598    #[serde(default)]
599    pub r: Option<AxOrderRejectReason>,
600    /// Order tag.
601    #[serde(default)]
602    pub tag: Option<String>,
603    /// Text note.
604    #[serde(default)]
605    pub txt: Option<String>,
606    /// Whether the order is post-only.
607    #[serde(default)]
608    pub po: bool,
609}
610
611/// Response payload returned by `GET /orders`.
612///
613/// # References
614/// - <https://docs.architect.exchange/api-reference/order-management/get-orders>
615#[derive(Clone, Debug, Serialize, Deserialize)]
616pub struct AxOrdersResponse {
617    /// List of order details.
618    pub orders: Vec<AxOrderDetail>,
619    /// Total matching records (for pagination).
620    #[serde(default)]
621    pub total_count: Option<i64>,
622    /// Applied limit.
623    #[serde(default)]
624    pub limit: Option<i32>,
625    /// Applied offset.
626    #[serde(default)]
627    pub offset: Option<i32>,
628    /// Next page cursor.
629    #[serde(default)]
630    pub next_cursor: Option<String>,
631}
632
633/// Response payload returned by `POST /initial-margin-requirement`.
634///
635/// # References
636/// - <https://docs.architect.exchange/api-reference/order-management/calculate-initial-margin-requirement>
637#[derive(Clone, Debug, Serialize, Deserialize)]
638pub struct AxInitialMarginRequirementResponse {
639    /// Initial margin requirement.
640    #[serde(deserialize_with = "deserialize_decimal_or_zero")]
641    pub im: Decimal,
642}
643
644/// Individual open order entry.
645///
646/// # References
647/// - <https://docs.architect.exchange/api-reference/order-management/get-open-orders>
648#[derive(Clone, Debug, Serialize, Deserialize)]
649pub struct AxOpenOrder {
650    /// Trade number.
651    pub tn: i64,
652    /// Timestamp (Unix epoch).
653    pub ts: i64,
654    /// Order side: "B" (buy) or "S" (sell).
655    pub d: AxOrderSide,
656    /// Order status.
657    pub o: AxOrderStatus,
658    /// Order ID.
659    pub oid: String,
660    /// Price.
661    #[serde(deserialize_with = "deserialize_decimal_or_zero")]
662    pub p: Decimal,
663    /// Quantity.
664    pub q: u64,
665    /// Remaining quantity.
666    pub rq: u64,
667    /// Symbol.
668    pub s: Ustr,
669    /// Time in force.
670    pub tif: AxTimeInForce,
671    /// User ID.
672    pub u: String,
673    /// Executed quantity.
674    pub xq: u64,
675    /// Optional client ID for order correlation.
676    #[serde(default)]
677    pub cid: Option<u64>,
678    /// Optional order tag.
679    #[serde(default)]
680    pub tag: Option<String>,
681    /// Whether the order is post-only.
682    #[serde(default)]
683    pub po: bool,
684}
685
686/// Response payload returned by `GET /open-orders`.
687///
688/// # References
689/// - <https://docs.architect.exchange/api-reference/order-management/get-open-orders>
690#[derive(Clone, Debug, Serialize, Deserialize)]
691pub struct AxOpenOrdersResponse {
692    /// List of open orders.
693    pub orders: Vec<AxOpenOrder>,
694    /// Total matching records.
695    pub total_count: i64,
696    /// Applied limit.
697    pub limit: i32,
698    /// Applied offset.
699    pub offset: i32,
700}
701
702/// Individual fill/trade entry.
703///
704/// # References
705/// - <https://docs.architect.exchange/api-reference/portfolio-management/get-fills>
706#[derive(Clone, Debug, Serialize, Deserialize)]
707#[serde(rename_all = "snake_case")]
708pub struct AxFill {
709    /// Trade ID (execution identifier).
710    pub trade_id: String,
711    /// Order ID.
712    pub order_id: Option<String>,
713    /// Fee amount.
714    #[serde(deserialize_with = "deserialize_decimal_or_zero")]
715    pub fee: Decimal,
716    /// Whether this was a taker order.
717    pub is_taker: bool,
718    /// Whether this fill was generated by an off-book block trade.
719    pub is_block_trade: Option<bool>,
720    /// Whether this fill was generated by final contract settlement.
721    pub is_final_settlement: Option<bool>,
722    /// Execution price.
723    #[serde(deserialize_with = "deserialize_decimal_or_zero")]
724    pub price: Decimal,
725    /// Executed quantity (always non-negative).
726    pub quantity: u64,
727    /// Order side.
728    pub side: AxOrderSide,
729    /// Instrument symbol.
730    pub symbol: Ustr,
731    /// Execution timestamp.
732    pub timestamp: Timestamp,
733    /// Account identifier.
734    pub account_id: Ustr,
735    /// Realized PnL for this fill.
736    #[serde(default, deserialize_with = "deserialize_optional_decimal_from_str")]
737    pub realized_pnl: Option<Decimal>,
738}
739
740/// Response payload returned by `GET /fills`.
741///
742/// # References
743/// - <https://docs.architect.exchange/api-reference/portfolio-management/get-fills>
744#[derive(Clone, Debug, Serialize, Deserialize)]
745#[serde(rename_all = "snake_case")]
746pub struct AxFillsResponse {
747    /// List of fills.
748    pub fills: Vec<AxFill>,
749    /// Total matching records, when supplied by AX.
750    #[serde(default)]
751    pub total_count: Option<i64>,
752    /// Applied limit, when supplied by AX.
753    #[serde(default)]
754    pub limit: Option<i32>,
755    /// Cursor for the next page, when one exists.
756    #[serde(default)]
757    pub next_cursor: Option<String>,
758}
759
760/// Individual candle/OHLCV entry.
761///
762/// # References
763/// - <https://docs.architect.exchange/api-reference/marketdata/get-candles>
764#[derive(Clone, Debug, Serialize, Deserialize)]
765#[serde(rename_all = "snake_case")]
766pub struct AxCandle {
767    /// Instrument symbol.
768    pub symbol: Ustr,
769    /// Candle timestamp (Unix epoch seconds).
770    pub ts: i64,
771    /// Open price.
772    #[serde(deserialize_with = "deserialize_decimal_or_zero")]
773    pub open: Decimal,
774    /// High price.
775    #[serde(deserialize_with = "deserialize_decimal_or_zero")]
776    pub high: Decimal,
777    /// Low price.
778    #[serde(deserialize_with = "deserialize_decimal_or_zero")]
779    pub low: Decimal,
780    /// Close price.
781    #[serde(deserialize_with = "deserialize_decimal_or_zero")]
782    pub close: Decimal,
783    /// Buy volume.
784    pub buy_volume: u64,
785    /// Sell volume.
786    pub sell_volume: u64,
787    /// Total volume.
788    pub volume: u64,
789    /// Candle width/interval.
790    pub width: AxCandleWidth,
791}
792
793/// Response payload returned by `GET /candles`.
794///
795/// # References
796/// - <https://docs.architect.exchange/api-reference/marketdata/get-candles>
797#[derive(Clone, Debug, Serialize, Deserialize)]
798#[serde(rename_all = "snake_case")]
799pub struct AxCandlesResponse {
800    /// List of candles.
801    pub candles: Vec<AxCandle>,
802}
803
804/// Response payload returned by `GET /candles/current` and `GET /candles/last`.
805///
806/// # References
807/// - <https://docs.architect.exchange/api-reference/marketdata/get-current-candle>
808/// - <https://docs.architect.exchange/api-reference/marketdata/get-last-candle>
809#[derive(Clone, Debug, Serialize, Deserialize)]
810#[serde(rename_all = "snake_case")]
811pub struct AxCandleResponse {
812    /// The candle data.
813    pub candle: AxCandle,
814}
815
816/// Individual funding rate entry.
817///
818/// # References
819/// - <https://docs.architect.exchange/api-reference/marketdata/get-funding-rates>
820#[derive(Clone, Debug, Serialize, Deserialize)]
821#[serde(rename_all = "snake_case")]
822pub struct AxFundingRate {
823    /// Instrument symbol.
824    pub symbol: Ustr,
825    /// Timestamp in nanoseconds.
826    pub timestamp_ns: i64,
827    /// Funding rate.
828    #[serde(deserialize_with = "deserialize_decimal_or_zero")]
829    pub funding_rate: Decimal,
830    /// Funding amount.
831    #[serde(deserialize_with = "deserialize_decimal_or_zero")]
832    pub funding_amount: Decimal,
833    /// Benchmark price.
834    #[serde(deserialize_with = "deserialize_decimal_or_zero")]
835    pub benchmark_price: Decimal,
836    /// Settlement price.
837    #[serde(deserialize_with = "deserialize_decimal_or_zero")]
838    pub settlement_price: Decimal,
839}
840
841/// Response payload returned by `GET /funding-rates`.
842///
843/// # References
844/// - <https://docs.architect.exchange/api-reference/marketdata/get-funding-rates>
845#[derive(Clone, Debug, Serialize, Deserialize)]
846#[serde(rename_all = "snake_case")]
847pub struct AxFundingRatesResponse {
848    /// List of funding rates.
849    pub funding_rates: Vec<AxFundingRate>,
850    /// Total matching records, when supplied by AX.
851    #[serde(default)]
852    pub total_count: Option<i64>,
853    /// Applied limit, when supplied by AX.
854    #[serde(default)]
855    pub limit: Option<i32>,
856    /// Cursor for the next page, when one exists.
857    #[serde(default)]
858    pub next_cursor: Option<String>,
859}
860
861/// One funding slot of a trading day, as returned by `GET /funding-slots`.
862///
863/// # References
864/// - <https://docs.architect.exchange/api-reference>
865#[derive(Clone, Debug, Serialize, Deserialize)]
866#[serde(rename_all = "snake_case")]
867pub struct AxFundingSlot {
868    /// 1-based position within the day's schedule.
869    pub index: i32,
870    /// Scheduled settlement time of the slot.
871    pub funding_time: Timestamp,
872    /// Slot settlement state.
873    pub status: AxFundingSlotStatus,
874    /// True when the rate was clamped by the symbol's funding rate cap.
875    pub capped: bool,
876    /// Mark-price TWAP over the slot, when available.
877    #[serde(default, deserialize_with = "deserialize_optional_decimal_from_str")]
878    pub mark_twap: Option<Decimal>,
879    /// Underlying-price TWAP over the slot, when available.
880    #[serde(default, deserialize_with = "deserialize_optional_decimal_from_str")]
881    pub underlying_twap: Option<Decimal>,
882    /// Premium of the mark TWAP over the underlying TWAP, in basis points.
883    #[serde(default, deserialize_with = "deserialize_optional_decimal_from_str")]
884    pub premium_bps: Option<Decimal>,
885    /// Slot funding rate in basis points; positive means longs pay shorts.
886    #[serde(default, deserialize_with = "deserialize_optional_decimal_from_str")]
887    pub funding_rate_bps: Option<Decimal>,
888    /// Why a skipped slot did not settle; present only on skipped slots.
889    #[serde(default)]
890    pub reason: Option<String>,
891}
892
893/// Response payload returned by `GET /funding-slots`.
894///
895/// A full trading day of funding slots with running totals. `daily_close`
896/// symbols report a single slot.
897///
898/// # References
899/// - <https://docs.architect.exchange/api-reference>
900#[derive(Clone, Debug, Serialize, Deserialize)]
901#[serde(rename_all = "snake_case")]
902pub struct AxFundingSlotsResponse {
903    /// Instrument symbol.
904    pub symbol: Ustr,
905    /// Trading day the schedule covers.
906    pub date: Date,
907    /// IANA name of the funding schedule's timezone.
908    pub timezone: String,
909    /// How the symbol's funding accrues over the day.
910    pub variant: AxFundingVariant,
911    /// Number of funding slots scheduled on `date`; 0 on holidays and weekends.
912    pub interval_count: i32,
913    /// Per-slot cap on the funding rate in basis points, when configured.
914    #[serde(default, deserialize_with = "deserialize_optional_decimal_from_str")]
915    pub cap_bps: Option<Decimal>,
916    /// Funding slots for the day.
917    pub slots: Vec<AxFundingSlot>,
918    /// Sum of realized slot rates so far, in basis points.
919    #[serde(deserialize_with = "deserialize_decimal_or_zero")]
920    pub realized_sum_bps: Decimal,
921    /// Projected end-of-day total in basis points: realized plus remaining projections.
922    #[serde(deserialize_with = "deserialize_decimal_or_zero")]
923    pub projected_eod_bps: Decimal,
924}
925
926/// Per-symbol risk metrics.
927///
928/// # References
929/// - <https://docs.architect.exchange/api-reference/portfolio-management/get-risk-snapshot>
930#[derive(Clone, Debug, Serialize, Deserialize)]
931#[serde(rename_all = "snake_case")]
932pub struct AxPerSymbolRisk {
933    /// Signed quantity (positive for long, negative for short).
934    pub signed_quantity: i64,
935    /// Signed notional value.
936    #[serde(deserialize_with = "deserialize_decimal_or_zero")]
937    pub signed_notional: Decimal,
938    /// Average entry price.
939    #[serde(default, deserialize_with = "deserialize_optional_decimal_from_str")]
940    pub average_price: Option<Decimal>,
941    /// Liquidation price.
942    #[serde(default, deserialize_with = "deserialize_optional_decimal_from_str")]
943    pub liquidation_price: Option<Decimal>,
944    /// Initial margin required for the position.
945    #[serde(deserialize_with = "deserialize_decimal_or_zero")]
946    pub initial_margin_required_position: Decimal,
947    /// Initial margin required for open orders.
948    #[serde(deserialize_with = "deserialize_decimal_or_zero")]
949    pub initial_margin_required_open_orders: Decimal,
950    /// Total initial margin required.
951    #[serde(deserialize_with = "deserialize_decimal_or_zero")]
952    pub initial_margin_required_total: Decimal,
953    /// Maintenance margin required.
954    #[serde(deserialize_with = "deserialize_decimal_or_zero")]
955    pub maintenance_margin_required: Decimal,
956    /// Unrealized P&L.
957    #[serde(deserialize_with = "deserialize_decimal_or_zero")]
958    pub unrealized_pnl: Decimal,
959}
960
961/// Risk snapshot data.
962///
963/// # References
964/// - <https://docs.architect.exchange/api-reference/portfolio-management/get-risk-snapshot>
965#[derive(Clone, Debug, Serialize, Deserialize)]
966#[serde(rename_all = "snake_case")]
967pub struct AxRiskSnapshot {
968    /// USD account balance.
969    #[serde(deserialize_with = "deserialize_decimal_or_zero")]
970    pub balance_usd: Decimal,
971    /// Total equity value.
972    #[serde(deserialize_with = "deserialize_decimal_or_zero")]
973    pub equity: Decimal,
974    /// Available initial margin.
975    #[serde(deserialize_with = "deserialize_decimal_or_zero")]
976    pub initial_margin_available: Decimal,
977    /// Margin required for open orders.
978    #[serde(deserialize_with = "deserialize_decimal_or_zero")]
979    pub initial_margin_required_for_open_orders: Decimal,
980    /// Margin required for positions.
981    #[serde(deserialize_with = "deserialize_decimal_or_zero")]
982    pub initial_margin_required_for_positions: Decimal,
983    /// Total initial margin requirement.
984    #[serde(deserialize_with = "deserialize_decimal_or_zero")]
985    pub initial_margin_required_total: Decimal,
986    /// Available maintenance margin.
987    #[serde(deserialize_with = "deserialize_decimal_or_zero")]
988    pub maintenance_margin_available: Decimal,
989    /// Required maintenance margin.
990    #[serde(deserialize_with = "deserialize_decimal_or_zero")]
991    pub maintenance_margin_required: Decimal,
992    /// Unrealized profit/loss.
993    #[serde(deserialize_with = "deserialize_decimal_or_zero")]
994    pub unrealized_pnl: Decimal,
995    /// Snapshot timestamp.
996    pub timestamp_ns: Timestamp,
997    /// Account identifier.
998    pub account_id: Ustr,
999    /// Per-symbol risk data.
1000    #[serde(default)]
1001    pub per_symbol: AHashMap<String, AxPerSymbolRisk>,
1002}
1003
1004/// Response payload returned by `GET /risk-snapshot`.
1005///
1006/// # References
1007/// - <https://docs.architect.exchange/api-reference/portfolio-management/get-risk-snapshot>
1008#[derive(Clone, Debug, Serialize, Deserialize)]
1009#[serde(rename_all = "snake_case")]
1010pub struct AxRiskSnapshotResponse {
1011    /// The risk snapshot data.
1012    pub risk_snapshot: AxRiskSnapshot,
1013}
1014
1015/// Individual transaction entry.
1016///
1017/// # References
1018/// - <https://docs.architect.exchange/api-reference/portfolio-management/get-transactions>
1019#[derive(Clone, Debug, Serialize, Deserialize)]
1020#[serde(rename_all = "snake_case")]
1021pub struct AxTransaction {
1022    /// Account identifier.
1023    pub account_id: Ustr,
1024    /// Transaction amount.
1025    #[serde(deserialize_with = "deserialize_decimal_or_zero")]
1026    pub amount: Decimal,
1027    /// Unique event identifier.
1028    pub event_id: String,
1029    /// Asset symbol.
1030    pub symbol: Ustr,
1031    /// Transaction timestamp.
1032    pub timestamp: Timestamp,
1033    /// Type of transaction.
1034    pub transaction_type: Ustr,
1035    /// User who initiated the transaction, when available.
1036    #[serde(default)]
1037    pub initiated_by_user_id: Option<String>,
1038    /// Optional reference identifier.
1039    #[serde(default)]
1040    pub reference_id: Option<String>,
1041}
1042
1043/// Response payload returned by `GET /transactions`.
1044///
1045/// # References
1046/// - <https://docs.architect.exchange/api-reference/portfolio-management/get-transactions>
1047#[derive(Clone, Debug, Serialize, Deserialize)]
1048#[serde(rename_all = "snake_case")]
1049pub struct AxTransactionsResponse {
1050    /// List of transactions.
1051    pub transactions: Vec<AxTransaction>,
1052    /// Total matching records.
1053    #[serde(default)]
1054    pub total_count: Option<i64>,
1055    /// Applied limit.
1056    #[serde(default)]
1057    pub limit: Option<i32>,
1058    /// Next page cursor.
1059    #[serde(default)]
1060    pub next_cursor: Option<String>,
1061}
1062
1063/// Request body for `POST /authenticate` using API key and secret.
1064///
1065/// # References
1066/// - <https://docs.architect.exchange/api-reference/user-management/authenticate>
1067#[derive(Debug, Clone, Serialize, Deserialize, Zeroize)]
1068#[serde(rename_all = "snake_case")]
1069pub struct AuthenticateApiKeyRequest {
1070    /// API key.
1071    pub api_key: SecretString,
1072    /// API secret.
1073    pub api_secret: SecretString,
1074    /// Token expiration in seconds.
1075    pub expiration_seconds: i32,
1076}
1077
1078impl AuthenticateApiKeyRequest {
1079    /// Creates a new [`AuthenticateApiKeyRequest`].
1080    #[must_use]
1081    pub fn new(
1082        api_key: impl Into<SecretString>,
1083        api_secret: impl Into<SecretString>,
1084        expiration_seconds: i32,
1085    ) -> Self {
1086        Self {
1087            api_key: api_key.into(),
1088            api_secret: api_secret.into(),
1089            expiration_seconds,
1090        }
1091    }
1092}
1093
1094/// Request body for `POST /place-order`.
1095///
1096/// # References
1097/// - <https://docs.architect.exchange/api-reference/order-management/place-order>
1098#[derive(Clone, Debug, Serialize, Deserialize)]
1099pub struct PlaceOrderRequest {
1100    /// Order side: "B" (buy) or "S" (sell).
1101    pub d: AxOrderSide,
1102    /// Order price (limit price).
1103    #[serde(serialize_with = "serialize_decimal_as_str")]
1104    pub p: Decimal,
1105    /// Post-only flag (maker-or-cancel).
1106    pub po: bool,
1107    /// Order quantity in contracts.
1108    pub q: u64,
1109    /// Order symbol.
1110    pub s: Ustr,
1111    /// Time in force.
1112    pub tif: AxTimeInForce,
1113    /// Optional order tag (max 10 alphanumeric characters).
1114    #[serde(skip_serializing_if = "Option::is_none")]
1115    pub tag: Option<String>,
1116}
1117
1118impl PlaceOrderRequest {
1119    /// Creates a new [`PlaceOrderRequest`] for the AX priced order shape.
1120    #[must_use]
1121    pub fn new(
1122        side: AxOrderSide,
1123        price: Decimal,
1124        quantity: u64,
1125        symbol: Ustr,
1126        time_in_force: AxTimeInForce,
1127        post_only: bool,
1128    ) -> Self {
1129        Self {
1130            d: side,
1131            p: price,
1132            po: post_only,
1133            q: quantity,
1134            s: symbol,
1135            tif: time_in_force,
1136            tag: None,
1137        }
1138    }
1139
1140    /// Sets the optional order tag.
1141    #[must_use]
1142    pub fn with_tag(mut self, tag: impl Into<String>) -> Self {
1143        self.tag = Some(tag.into());
1144        self
1145    }
1146}
1147
1148/// Request body for `POST /preview-aggressive-limit-order`.
1149///
1150/// # References
1151/// - <https://docs.architect.exchange/api-reference/marketdata/preview-aggressive-limit-order>
1152#[derive(Clone, Debug, Serialize, Deserialize)]
1153pub struct PreviewAggressiveLimitOrderRequest {
1154    /// Trading symbol.
1155    pub symbol: Ustr,
1156    /// Order quantity in contracts.
1157    pub quantity: u64,
1158    /// Order side: "B" (buy) or "S" (sell).
1159    pub side: AxOrderSide,
1160}
1161
1162impl PreviewAggressiveLimitOrderRequest {
1163    /// Creates a new [`PreviewAggressiveLimitOrderRequest`].
1164    #[must_use]
1165    pub fn new(symbol: Ustr, quantity: u64, side: AxOrderSide) -> Self {
1166        Self {
1167            symbol,
1168            quantity,
1169            side,
1170        }
1171    }
1172}
1173
1174/// Response payload returned by `POST /preview-aggressive-limit-order`.
1175///
1176/// # References
1177/// - <https://docs.architect.exchange/api-reference/marketdata/preview-aggressive-limit-order>
1178#[derive(Clone, Debug, Serialize, Deserialize)]
1179pub struct AxPreviewAggressiveLimitOrderResponse {
1180    /// Quantity that would be filled at the aggressive price.
1181    pub filled_quantity: u64,
1182    /// Quantity that cannot be filled (insufficient book depth).
1183    pub remaining_quantity: u64,
1184    /// The aggressive limit price ("take through" price), or None if no liquidity.
1185    #[serde(default, deserialize_with = "deserialize_optional_decimal_from_str")]
1186    pub limit_price: Option<Decimal>,
1187    /// Volume-weighted average price of expected fills.
1188    #[serde(default, deserialize_with = "deserialize_optional_decimal_from_str")]
1189    pub vwap: Option<Decimal>,
1190}
1191
1192/// Request body for `POST /cancel-order`.
1193///
1194/// # References
1195/// - <https://docs.architect.exchange/api-reference/order-management/cancel-order>
1196#[derive(Clone, Debug, Serialize, Deserialize)]
1197pub struct CancelOrderRequest {
1198    /// Order ID to cancel.
1199    pub oid: String,
1200}
1201
1202impl CancelOrderRequest {
1203    /// Creates a new [`CancelOrderRequest`].
1204    #[must_use]
1205    pub fn new(order_id: impl Into<String>) -> Self {
1206        Self {
1207            oid: order_id.into(),
1208        }
1209    }
1210}
1211
1212/// Request body for `POST /replace-order`.
1213///
1214/// Replaces (amends) an existing order. Unspecified optional fields inherit
1215/// from the original order. The exchange returns a new order ID.
1216///
1217/// # References
1218/// - <https://docs.architect.exchange/api-reference/order-management/replace-order>
1219#[derive(Clone, Debug, Serialize, Deserialize)]
1220pub struct ReplaceOrderRequest {
1221    /// Order ID to replace.
1222    pub oid: String,
1223    /// New limit price (optional, inherits from original if omitted).
1224    #[serde(
1225        skip_serializing_if = "Option::is_none",
1226        serialize_with = "serialize_optional_decimal_as_str"
1227    )]
1228    pub p: Option<Decimal>,
1229    /// New quantity in contracts (optional, inherits from original if omitted).
1230    #[serde(skip_serializing_if = "Option::is_none")]
1231    pub q: Option<u64>,
1232    /// New post-only flag (optional, inherits from original if omitted).
1233    #[serde(skip_serializing_if = "Option::is_none")]
1234    pub po: Option<bool>,
1235    /// New time-in-force (optional, inherits from original if omitted).
1236    #[serde(skip_serializing_if = "Option::is_none")]
1237    pub tif: Option<AxTimeInForce>,
1238}
1239
1240impl ReplaceOrderRequest {
1241    /// Creates a new [`ReplaceOrderRequest`] with only the order ID.
1242    ///
1243    /// Use the builder methods to set the fields to amend.
1244    #[must_use]
1245    pub fn new(order_id: impl Into<String>) -> Self {
1246        Self {
1247            oid: order_id.into(),
1248            p: None,
1249            q: None,
1250            po: None,
1251            tif: None,
1252        }
1253    }
1254
1255    /// Sets the new limit price.
1256    #[must_use]
1257    pub fn with_price(mut self, price: Decimal) -> Self {
1258        self.p = Some(price);
1259        self
1260    }
1261
1262    /// Sets the new quantity.
1263    #[must_use]
1264    pub fn with_quantity(mut self, quantity: u64) -> Self {
1265        self.q = Some(quantity);
1266        self
1267    }
1268}
1269
1270/// Response payload returned by `POST /replace-order`.
1271///
1272/// # References
1273/// - <https://docs.architect.exchange/api-reference/order-management/replace-order>
1274#[derive(Clone, Debug, Serialize, Deserialize)]
1275pub struct AxReplaceOrderResponse {
1276    /// New order ID assigned to the replacement order.
1277    pub oid: String,
1278}
1279
1280/// Request body for `POST /cancel-all-orders`.
1281///
1282/// # References
1283/// - <https://docs.architect.exchange/api-reference/order-management/place-order>
1284#[derive(Clone, Debug, Default, Serialize, Deserialize)]
1285pub struct CancelAllOrdersRequest {
1286    /// Optional account ID. AX infers the session account when omitted.
1287    #[serde(skip_serializing_if = "Option::is_none")]
1288    pub account_id: Option<Ustr>,
1289    /// Optional symbol filter - only cancel orders for this symbol.
1290    #[serde(skip_serializing_if = "Option::is_none")]
1291    pub symbol: Option<Ustr>,
1292}
1293
1294impl CancelAllOrdersRequest {
1295    /// Creates a new [`CancelAllOrdersRequest`] to cancel all orders.
1296    #[must_use]
1297    pub fn new() -> Self {
1298        Self::default()
1299    }
1300
1301    /// Sets the account filter.
1302    #[must_use]
1303    pub fn with_account_id(mut self, account_id: Ustr) -> Self {
1304        self.account_id = Some(account_id);
1305        self
1306    }
1307
1308    /// Sets the symbol filter.
1309    #[must_use]
1310    pub fn with_symbol(mut self, symbol: Ustr) -> Self {
1311        self.symbol = Some(symbol);
1312        self
1313    }
1314}
1315
1316/// Response payload returned by `POST /cancel-all-orders`.
1317///
1318/// # References
1319/// - <https://docs.architect.exchange/api-reference/order-management/place-order>
1320#[derive(Clone, Debug, Serialize, Deserialize)]
1321pub struct AxCancelAllOrdersResponse {}
1322
1323#[cfg(test)]
1324mod tests {
1325    use rstest::rstest;
1326    use rust_decimal_macros::dec;
1327    use serde_json::json;
1328
1329    use super::*;
1330
1331    fn assert_zeroize_on_drop<T: ZeroizeOnDrop>() {}
1332
1333    #[rstest]
1334    fn test_deserialize_authenticate_response() {
1335        let json = include_str!("../../test_data/http_authenticate.json");
1336        let response: AxAuthenticateResponse = serde_json::from_str(json).unwrap();
1337        assert!(response.token.expose_secret().starts_with("test-token"));
1338    }
1339
1340    #[rstest]
1341    fn test_serialize_cancel_all_orders_request() {
1342        let request = CancelAllOrdersRequest::new()
1343            .with_account_id(Ustr::from("account-1"))
1344            .with_symbol(Ustr::from("XAU-PERP"));
1345
1346        let value = serde_json::to_value(request).unwrap();
1347
1348        assert_eq!(value["account_id"], "account-1");
1349        assert_eq!(value["symbol"], "XAU-PERP");
1350        assert!(value.get("execution_venue").is_none());
1351    }
1352
1353    #[rstest]
1354    fn test_deserialize_whoami_response() {
1355        let json = include_str!("../../test_data/http_get_whoami.json");
1356
1357        let response: AxWhoAmI = serde_json::from_str(json).unwrap();
1358
1359        assert_eq!(response.id, "01JBXR-7QK2-0000");
1360        assert_eq!(response.username, "trader@example.com");
1361        assert_eq!(response.pseudonym.as_deref(), Some("quiet-amber-heron"));
1362        assert_eq!(
1363            response.created_at,
1364            "2025-12-18T02:20:42.675817Z".parse::<Timestamp>().unwrap()
1365        );
1366        assert!(!response.require_2fa);
1367        assert!(response.is_onboarded);
1368        assert!(!response.is_frozen);
1369        assert!(!response.is_admin);
1370        assert_eq!(
1371            response.fiat_deposit_code.as_deref(),
1372            Some("01JBXR7QK20000Y")
1373        );
1374        assert_eq!(response.accounts.len(), 1);
1375
1376        let account = &response.accounts[0];
1377
1378        assert_eq!(account.id, "01JBXR-7QK2-0000");
1379        assert_eq!(account.name, "trader@example.com");
1380        assert!(!account.is_close_only);
1381        assert_eq!(account.maker_fee, Some(dec!(0.0002)));
1382        assert_eq!(account.taker_fee, Some(dec!(0.0025)));
1383        assert!(account.can_list);
1384        assert!(account.can_read);
1385        assert!(account.can_set_limits);
1386        assert!(account.can_reduce_or_close);
1387        assert!(account.can_trade);
1388    }
1389
1390    #[rstest]
1391    #[case(json!(""), None)]
1392    #[case(json!(null), None)]
1393    #[case(json!("0"), Some(Decimal::ZERO))]
1394    #[case(json!("0.0002"), Some(dec!(0.0002)))]
1395    fn test_deserialize_whoami_account_fee_distinguishes_absent_from_zero(
1396        #[case] wire_value: serde_json::Value,
1397        #[case] expected: Option<Decimal>,
1398    ) {
1399        // A zero rate is valid, so an absent rate must not deserialize to zero
1400        let json = json!({
1401            "id": "01JBXR-7QK2-0000",
1402            "name": "trader@example.com",
1403            "is_close_only": false,
1404            "maker_fee": wire_value,
1405            "taker_fee": wire_value,
1406            "can_list": true,
1407            "can_read": true,
1408            "can_set_limits": true,
1409            "can_reduce_or_close": true,
1410            "can_trade": true,
1411        })
1412        .to_string();
1413
1414        let account: AxWhoAmIAccount = serde_json::from_str(&json).unwrap();
1415
1416        assert_eq!(account.maker_fee, expected);
1417        assert_eq!(account.taker_fee, expected);
1418    }
1419
1420    #[rstest]
1421    fn test_deserialize_whoami_account_rejects_malformed_fee() {
1422        let json = json!({
1423            "id": "01JBXR-7QK2-0000",
1424            "name": "trader@example.com",
1425            "is_close_only": false,
1426            "maker_fee": "not-a-decimal",
1427            "taker_fee": "0.0025",
1428            "can_list": true,
1429            "can_read": true,
1430            "can_set_limits": true,
1431            "can_reduce_or_close": true,
1432            "can_trade": true,
1433        })
1434        .to_string();
1435
1436        let error = serde_json::from_str::<AxWhoAmIAccount>(&json).unwrap_err();
1437
1438        assert!(
1439            error.to_string().contains("Invalid decimal"),
1440            "unexpected error: {error}"
1441        );
1442    }
1443
1444    #[rstest]
1445    fn test_deserialize_whoami_response_without_optional_profile_fields() {
1446        let json = json!({
1447            "id": "01JBXR-7QK2-0001",
1448            "username": "sub@example.com",
1449            "created_at": "2025-12-18T02:20:42.675817Z",
1450            "is_onboarded": true,
1451            "is_frozen": false,
1452            "is_admin": false,
1453            "require_2fa": true,
1454            "accounts": [],
1455        })
1456        .to_string();
1457
1458        let response: AxWhoAmI = serde_json::from_str(&json).unwrap();
1459
1460        assert!(response.require_2fa);
1461        assert_eq!(response.pseudonym, None);
1462        assert_eq!(response.fiat_deposit_code, None);
1463        assert!(response.accounts.is_empty());
1464    }
1465
1466    #[rstest]
1467    fn test_deserialize_instruments_response() {
1468        let json = include_str!("../../test_data/http_get_instruments.json");
1469        let response: AxInstrumentsResponse = serde_json::from_str(json).unwrap();
1470        assert_eq!(response.instruments.len(), 3);
1471        assert_eq!(response.instruments[0].symbol, "EURUSD-PERP");
1472    }
1473
1474    #[rstest]
1475    fn test_deserialize_balances_response() {
1476        let json = include_str!("../../test_data/http_get_balances.json");
1477        let response: AxBalancesResponse = serde_json::from_str(json).unwrap();
1478        assert_eq!(response.balances.len(), 3);
1479        assert_eq!(response.balances[0].symbol, "USD");
1480    }
1481
1482    #[rstest]
1483    fn test_deserialize_positions_response() {
1484        let json = include_str!("../../test_data/http_get_positions.json");
1485        let response: AxPositionsResponse = serde_json::from_str(json).unwrap();
1486        assert_eq!(response.positions.len(), 2);
1487        assert_eq!(response.positions[0].symbol, "BTC-PERP");
1488        assert_eq!(response.positions[1].signed_quantity, -5);
1489    }
1490
1491    #[rstest]
1492    fn test_deserialize_tickers_response() {
1493        let json = include_str!("../../test_data/http_get_tickers.json");
1494        let response: AxTickersResponse = serde_json::from_str(json).unwrap();
1495        assert_eq!(response.tickers.len(), 3);
1496        assert_eq!(response.total_count, 3);
1497        assert_eq!(response.limit, 100);
1498        assert_eq!(response.offset, 0);
1499        assert_eq!(response.tickers[0].symbol, "EURUSD-PERP");
1500        assert!(response.tickers[0].bid.is_some());
1501        assert!(response.tickers[2].bid.is_none());
1502    }
1503
1504    #[rstest]
1505    fn test_deserialize_funding_rates_response() {
1506        let json = include_str!("../../test_data/http_get_funding_rates.json");
1507        let response: AxFundingRatesResponse = serde_json::from_str(json).unwrap();
1508        assert_eq!(response.funding_rates.len(), 2);
1509        assert_eq!(response.funding_rates[0].symbol, "JPYUSD-PERP");
1510    }
1511
1512    #[rstest]
1513    fn test_deserialize_funding_slots_response() {
1514        let json = include_str!("../../test_data/http_get_funding_slots.json");
1515        let response: AxFundingSlotsResponse = serde_json::from_str(json).unwrap();
1516        assert_eq!(response.symbol, "EURUSD-PERP");
1517        assert_eq!(response.date, Date::new(2026, 7, 6).unwrap());
1518        assert_eq!(response.timezone, "America/New_York");
1519        assert_eq!(response.variant, AxFundingVariant::IntradayTwap);
1520        assert_eq!(response.interval_count, 4);
1521        assert_eq!(
1522            response.cap_bps.map(|d| d.to_string()),
1523            Some("5.0".to_string())
1524        );
1525        assert_eq!(response.slots.len(), 4);
1526
1527        let first = &response.slots[0];
1528        assert_eq!(first.index, 1);
1529        assert_eq!(first.status, AxFundingSlotStatus::Realized);
1530        assert!(!first.capped);
1531        assert_eq!(
1532            first.funding_rate_bps.map(|d| d.to_string()),
1533            Some("0.0921".to_string())
1534        );
1535        assert!(first.reason.is_none());
1536
1537        let capped = &response.slots[1];
1538        assert!(capped.capped);
1539        assert_eq!(
1540            capped.funding_rate_bps.map(|d| d.to_string()),
1541            Some("5.0000".to_string())
1542        );
1543
1544        let projected = &response.slots[2];
1545        assert_eq!(projected.status, AxFundingSlotStatus::Projected);
1546
1547        let skipped = &response.slots[3];
1548        assert_eq!(skipped.status, AxFundingSlotStatus::Skipped);
1549        assert!(skipped.mark_twap.is_none());
1550        assert!(skipped.funding_rate_bps.is_none());
1551        assert_eq!(skipped.reason.as_deref(), Some("holiday"));
1552
1553        assert_eq!(response.realized_sum_bps.to_string(), "5.0921");
1554        assert_eq!(response.projected_eod_bps.to_string(), "5.1842");
1555    }
1556
1557    #[rstest]
1558    fn test_funding_variant_and_slot_status_deserialization() {
1559        let daily: AxFundingVariant =
1560            serde_json::from_value(serde_json::json!("daily_close")).unwrap();
1561        let twap: AxFundingVariant =
1562            serde_json::from_value(serde_json::json!("intraday_twap")).unwrap();
1563        assert_eq!(daily, AxFundingVariant::DailyClose);
1564        assert_eq!(twap, AxFundingVariant::IntradayTwap);
1565
1566        let statuses = [
1567            ("realized", AxFundingSlotStatus::Realized),
1568            ("projected", AxFundingSlotStatus::Projected),
1569            ("skipped", AxFundingSlotStatus::Skipped),
1570            ("pending", AxFundingSlotStatus::Pending),
1571        ];
1572
1573        for (raw, expected) in statuses {
1574            let parsed: AxFundingSlotStatus =
1575                serde_json::from_value(serde_json::json!(raw)).unwrap();
1576            assert_eq!(parsed, expected);
1577        }
1578    }
1579
1580    #[rstest]
1581    fn test_deserialize_open_orders_response() {
1582        let json = include_str!("../../test_data/http_get_open_orders.json");
1583        let response: AxOpenOrdersResponse = serde_json::from_str(json).unwrap();
1584        assert_eq!(response.orders.len(), 2);
1585        assert_eq!(response.orders[0].oid, "O-01ARZ3NDEKTSV4RRFFQ69G5FAV");
1586        assert_eq!(response.orders[0].d, AxOrderSide::Buy);
1587        assert_eq!(response.orders[0].o, AxOrderStatus::Accepted);
1588        assert_eq!(response.orders[1].xq, 300);
1589        assert_eq!(response.total_count, 2);
1590        assert_eq!(response.limit, 100);
1591        assert_eq!(response.offset, 0);
1592    }
1593
1594    #[rstest]
1595    fn test_deserialize_fills_response() {
1596        let json = include_str!("../../test_data/http_get_fills.json");
1597        let response: AxFillsResponse = serde_json::from_str(json).unwrap();
1598        assert_eq!(response.fills.len(), 2);
1599        assert_eq!(response.fills[0].side, AxOrderSide::Buy);
1600        assert!(response.fills[0].is_taker);
1601        assert!(!response.fills[1].is_taker);
1602        assert_eq!(response.fills[0].is_block_trade, Some(false));
1603        assert_eq!(response.fills[0].is_final_settlement, Some(false));
1604        assert_eq!(response.total_count, Some(2));
1605        assert_eq!(response.limit, Some(100));
1606        assert_eq!(response.next_cursor, None);
1607    }
1608
1609    #[rstest]
1610    fn test_deserialize_candles_response() {
1611        let json = include_str!("../../test_data/http_get_candles.json");
1612        let response: AxCandlesResponse = serde_json::from_str(json).unwrap();
1613        assert_eq!(response.candles.len(), 2);
1614        assert_eq!(response.candles[0].symbol, "EURUSD-PERP");
1615        assert_eq!(response.candles[0].width, AxCandleWidth::Minutes1);
1616    }
1617
1618    #[rstest]
1619    fn test_deserialize_candle_response() {
1620        let json = include_str!("../../test_data/http_get_candle.json");
1621        let response: AxCandleResponse = serde_json::from_str(json).unwrap();
1622        assert_eq!(response.candle.symbol, "EURUSD-PERP");
1623        assert_eq!(response.candle.width, AxCandleWidth::Minutes1);
1624    }
1625
1626    #[rstest]
1627    fn test_deserialize_risk_snapshot_response() {
1628        let json = include_str!("../../test_data/http_get_risk_snapshot.json");
1629        let response: AxRiskSnapshotResponse = serde_json::from_str(json).unwrap();
1630        assert_eq!(
1631            response.risk_snapshot.account_id,
1632            Ustr::from("3c90c3cc-0d44-4b50-8888-8dd25736052a")
1633        );
1634        assert_eq!(response.risk_snapshot.per_symbol.len(), 2);
1635        assert!(
1636            response
1637                .risk_snapshot
1638                .per_symbol
1639                .contains_key("EURUSD-PERP")
1640        );
1641        assert_eq!(
1642            response.risk_snapshot.per_symbol["GBPUSD-PERP"].average_price,
1643            None
1644        );
1645    }
1646
1647    #[rstest]
1648    fn test_deserialize_transactions_response() {
1649        let json = include_str!("../../test_data/http_get_transactions.json");
1650        let response: AxTransactionsResponse = serde_json::from_str(json).unwrap();
1651        assert_eq!(response.transactions.len(), 2);
1652        assert_eq!(response.total_count, Some(2));
1653        assert_eq!(response.limit, Some(100));
1654        assert_eq!(response.transactions[0].account_id, Ustr::from("account-1"));
1655        assert_eq!(response.transactions[0].transaction_type, "deposit");
1656        assert!(response.transactions[0].initiated_by_user_id.is_some());
1657        assert!(response.transactions[1].reference_id.is_none());
1658    }
1659
1660    #[rstest]
1661    fn test_deserialize_preview_aggressive_limit_order_response() {
1662        let json = include_str!("../../test_data/http_preview_aggressive_limit_order.json");
1663        let response: AxPreviewAggressiveLimitOrderResponse = serde_json::from_str(json).unwrap();
1664        assert_eq!(response.filled_quantity, 1000);
1665        assert_eq!(response.remaining_quantity, 0);
1666        assert!(response.limit_price.is_some());
1667        assert!(response.vwap.is_some());
1668    }
1669
1670    #[rstest]
1671    fn test_deserialize_place_order_response() {
1672        let json = include_str!("../../test_data/http_place_order.json");
1673        let response: AxPlaceOrderResponse = serde_json::from_str(json).unwrap();
1674        assert_eq!(response.oid, "O-01ARZ3NDEKTSV4RRFFQ69G5FAV");
1675    }
1676
1677    #[rstest]
1678    fn test_deserialize_cancel_order_response() {
1679        let json = include_str!("../../test_data/http_cancel_order.json");
1680        let response: AxCancelOrderResponse = serde_json::from_str(json).unwrap();
1681        assert!(response.cxl_rx);
1682    }
1683
1684    #[rstest]
1685    fn test_deserialize_cancel_all_orders_response() {
1686        let json = include_str!("../../test_data/http_cancel_all_orders.json");
1687        let _response: AxCancelAllOrdersResponse = serde_json::from_str(json).unwrap();
1688    }
1689
1690    #[rstest]
1691    fn test_deserialize_trades_response() {
1692        let json = include_str!("../../test_data/http_get_trades.json");
1693        let response: AxTradesResponse = serde_json::from_str(json).unwrap();
1694        assert_eq!(response.trades.len(), 2);
1695        assert_eq!(response.trades[0].s, "EURUSD-PERP");
1696        assert_eq!(response.trades[0].d, AxOrderSide::Buy);
1697        assert_eq!(response.trades[0].q, 100);
1698        assert_eq!(response.trades[1].d, AxOrderSide::Sell);
1699    }
1700
1701    #[rstest]
1702    fn test_deserialize_book_response() {
1703        let json = include_str!("../../test_data/http_get_book.json");
1704        let response: AxBookResponse = serde_json::from_str(json).unwrap();
1705        assert_eq!(response.book.s, "EURUSD-PERP");
1706        assert_eq!(response.book.b.len(), 3);
1707        assert_eq!(response.book.a.len(), 3);
1708        assert_eq!(response.book.b[0].q, 500);
1709        assert_eq!(response.book.a[0].q, 400);
1710    }
1711
1712    #[rstest]
1713    fn test_deserialize_order_status_query_response() {
1714        let json = include_str!("../../test_data/http_get_order_status.json");
1715        let response: AxOrderStatusQueryResponse = serde_json::from_str(json).unwrap();
1716        assert_eq!(response.status.symbol, "EURUSD-PERP");
1717        assert_eq!(response.status.order_id, "O-01ARZ3NDEKTSV4RRFFQ69G5FAV");
1718        assert_eq!(response.status.state, AxOrderStatus::PartiallyFilled);
1719        assert_eq!(response.status.clord_id, Some(12345));
1720        assert_eq!(response.status.filled_quantity, Some(300));
1721        assert_eq!(response.status.remaining_quantity, Some(700));
1722        assert_eq!(response.status.reject_reason, None);
1723        assert_eq!(response.status.reject_message, None);
1724    }
1725
1726    #[rstest]
1727    fn test_deserialize_orders_response() {
1728        let json = include_str!("../../test_data/http_get_orders.json");
1729        let response: AxOrdersResponse = serde_json::from_str(json).unwrap();
1730        assert_eq!(response.orders.len(), 2);
1731        assert_eq!(response.total_count, Some(2));
1732        assert_eq!(response.limit, Some(100));
1733        assert_eq!(response.next_cursor, None);
1734        assert_eq!(response.orders[0].aid.as_deref(), Some("account-1"));
1735        assert_eq!(response.orders[0].o, AxOrderStatus::PartiallyFilled);
1736        assert_eq!(response.orders[0].xq, 300);
1737        assert_eq!(response.orders[1].o, AxOrderStatus::Filled);
1738        assert_eq!(response.orders[1].d, AxOrderSide::Sell);
1739    }
1740
1741    #[rstest]
1742    fn test_deserialize_initial_margin_requirement_response() {
1743        let json = include_str!("../../test_data/http_initial_margin_requirement.json");
1744        let response: AxInitialMarginRequirementResponse = serde_json::from_str(json).unwrap();
1745        assert_eq!(response.im, Decimal::new(125050, 2));
1746    }
1747
1748    #[rstest]
1749    fn test_deserialize_replace_order_response() {
1750        let json = include_str!("../../test_data/http_replace_order.json");
1751        let response: AxReplaceOrderResponse = serde_json::from_str(json).unwrap();
1752        assert_eq!(response.oid, "O-01ARZ3NDEKTSV4RRFFQ69G5NEW");
1753    }
1754
1755    #[rstest]
1756    fn test_replace_order_request_serialization() {
1757        let request = ReplaceOrderRequest::new("O-01ARZ3NDEKTSV4RRFFQ69G5FAV")
1758            .with_price(Decimal::new(10550, 4))
1759            .with_quantity(200);
1760
1761        let json = serde_json::to_value(&request).unwrap();
1762        assert_eq!(json["oid"], "O-01ARZ3NDEKTSV4RRFFQ69G5FAV");
1763        assert_eq!(json["p"], "1.0550");
1764        assert_eq!(json["q"], 200);
1765        assert!(json.get("po").is_none());
1766        assert!(json.get("tif").is_none());
1767        assert!(json.get("trigger_price").is_none());
1768    }
1769
1770    #[rstest]
1771    fn test_replace_order_request_minimal() {
1772        let request = ReplaceOrderRequest::new("O-TEST");
1773        let json = serde_json::to_value(&request).unwrap();
1774        assert_eq!(json["oid"], "O-TEST");
1775        assert!(json.get("p").is_none());
1776        assert!(json.get("q").is_none());
1777    }
1778
1779    #[rstest]
1780    fn test_authenticate_request_serializes_and_redacts_debug() {
1781        let request = AuthenticateApiKeyRequest::new("api-key-token", "api-secret-value", 3600);
1782
1783        let json = serde_json::to_value(&request).unwrap();
1784        let formatted = format!("{request:?}");
1785
1786        assert_eq!(json["api_key"], "api-key-token");
1787        assert_eq!(json["api_secret"], "api-secret-value");
1788        assert_eq!(json["expiration_seconds"], 3600);
1789        assert_eq!(
1790            formatted,
1791            "AuthenticateApiKeyRequest { api_key: <redacted>, api_secret: <redacted>, expiration_seconds: 3600 }",
1792        );
1793        assert!(!formatted.contains("api-key-token"));
1794        assert!(!formatted.contains("api-secret-value"));
1795    }
1796
1797    #[rstest]
1798    fn test_authenticate_response_redacts_debug() {
1799        assert_zeroize_on_drop::<AxAuthenticateResponse>();
1800
1801        let response = AxAuthenticateResponse {
1802            token: SecretString::from("session-token-value"),
1803        };
1804
1805        let formatted = format!("{response:?}");
1806
1807        assert_eq!(formatted, "AxAuthenticateResponse { token: <redacted> }");
1808        assert!(!formatted.contains("session-token-value"));
1809    }
1810}