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