Skip to main content

questrade_client/
api_types.rs

1//! Serde types for Questrade REST API request and response bodies.
2
3use serde::{Deserialize, Deserializer, Serialize};
4
5/// Deserializes a JSON null as 0.0, leaving numeric values unchanged.
6/// Questrade occasionally returns null for numeric fields on certain positions.
7fn null_as_zero<'de, D: Deserializer<'de>>(d: D) -> Result<f64, D::Error> {
8    Ok(Option::<f64>::deserialize(d)?.unwrap_or(0.0))
9}
10
11// ---------- Server time ----------
12
13/// Response wrapper for `GET /v1/time`.
14#[derive(Debug, Deserialize)]
15pub struct ServerTimeResponse {
16    /// Current server timestamp as an ISO 8601 string.
17    pub time: String,
18}
19
20// ---------- Symbol search ----------
21
22/// Response wrapper for `GET /v1/symbols/search`.
23#[derive(Debug, Deserialize)]
24#[serde(rename_all = "camelCase")]
25pub struct SymbolSearchResponse {
26    /// Matching symbol results.
27    pub symbols: Vec<SymbolResult>,
28}
29
30/// A single result from a symbol search.
31#[derive(Debug, Deserialize)]
32#[serde(rename_all = "camelCase")]
33pub struct SymbolResult {
34    /// Ticker symbol (e.g. `"AAPL"`).
35    pub symbol: String,
36    /// Questrade internal symbol ID.
37    pub symbol_id: u64,
38    /// Human-readable security name (e.g. `"Apple Inc."`).
39    pub description: String,
40    /// Security type: `"Stock"`, `"Option"`, `"ETF"`, etc.
41    pub security_type: String,
42    /// Primary listing exchange (e.g. `"NASDAQ"`).
43    pub listing_exchange: String,
44}
45
46// ---------- Quotes ----------
47
48/// Response wrapper for `GET /v1/markets/quotes/:id`.
49#[derive(Debug, Deserialize)]
50#[serde(rename_all = "camelCase")]
51pub struct QuoteResponse {
52    /// Real-time level 1 quotes for the requested symbols.
53    pub quotes: Vec<Quote>,
54}
55
56/// Real-time level 1 quote for an equity symbol.
57#[derive(Debug, Deserialize)]
58#[serde(rename_all = "camelCase")]
59pub struct Quote {
60    /// Ticker symbol.
61    pub symbol: String,
62    /// Questrade internal symbol ID.
63    pub symbol_id: u64,
64    /// Best bid price; `None` if no bid is available.
65    pub bid_price: Option<f64>,
66    /// Best ask price; `None` if no ask is available.
67    pub ask_price: Option<f64>,
68    /// Last trade price; `None` outside trading hours or on no prints.
69    pub last_trade_price: Option<f64>,
70    /// Total session volume in shares.
71    pub volume: Option<u64>,
72    /// Session open price.
73    pub open_price: Option<f64>,
74    /// Session high price.
75    pub high_price: Option<f64>,
76    /// Session low price.
77    pub low_price: Option<f64>,
78}
79
80// ---------- Option chain structure ----------
81
82/// Response wrapper for `GET /v1/symbols/:id/options`.
83#[derive(Debug, Deserialize)]
84#[serde(rename_all = "camelCase")]
85pub struct OptionChainResponse {
86    /// Option expiry groups for the underlying symbol.
87    pub option_chain: Vec<OptionExpiry>,
88}
89
90/// Option contracts grouped by expiry date.
91#[derive(Debug, Deserialize)]
92#[serde(rename_all = "camelCase")]
93pub struct OptionExpiry {
94    /// Expiry date as an ISO 8601 string (e.g. `"2026-03-21T00:00:00.000000-05:00"`).
95    pub expiry_date: String,
96    /// Human-readable description for this expiry.
97    pub description: String,
98    /// Exchange where these options are listed.
99    pub listing_exchange: String,
100    /// Exercise style: `"American"` or `"European"`.
101    pub option_exercise_type: String,
102    /// Option chains grouped by root symbol (usually one per expiry).
103    pub chain_per_root: Vec<ChainPerRoot>,
104}
105
106/// Option contracts for a single root symbol within an expiry.
107#[derive(Debug, Deserialize)]
108#[serde(rename_all = "camelCase")]
109pub struct ChainPerRoot {
110    /// Option root symbol (usually matches the underlying ticker).
111    pub option_root: String,
112    /// Contract multiplier (typically 100 for equity options).
113    pub multiplier: Option<u32>,
114    /// Strike-level call/put symbol ID pairs.
115    pub chain_per_strike_price: Vec<ChainPerStrike>,
116}
117
118/// Call and put symbol IDs at a single strike price.
119#[derive(Debug, Deserialize)]
120#[serde(rename_all = "camelCase")]
121pub struct ChainPerStrike {
122    /// Strike price.
123    pub strike_price: f64,
124    /// Questrade symbol ID for the call option at this strike.
125    pub call_symbol_id: u64,
126    /// Questrade symbol ID for the put option at this strike.
127    pub put_symbol_id: u64,
128}
129
130// ---------- Option quotes ----------
131
132/// Request body for `POST /v1/markets/quotes/options`.
133#[derive(Debug, Serialize)]
134#[serde(rename_all = "camelCase")]
135pub struct OptionQuoteRequest {
136    /// Option symbol IDs to fetch quotes for (max 100 per request).
137    pub option_ids: Vec<u64>,
138}
139
140/// Response from `POST /v1/markets/quotes/options`.
141#[derive(Debug, Deserialize)]
142#[serde(rename_all = "camelCase")]
143pub struct OptionQuoteResponse {
144    /// Quotes for the requested option symbol IDs.
145    pub option_quotes: Vec<OptionQuote>,
146}
147
148/// Real-time quote and Greeks for a single option contract.
149#[derive(Debug, Deserialize)]
150#[serde(rename_all = "camelCase")]
151pub struct OptionQuote {
152    /// Underlying ticker symbol (e.g. `"AAPL"`).
153    pub underlying: String,
154    /// Questrade symbol ID of the underlying equity.
155    pub underlying_id: u64,
156    /// Option ticker symbol.
157    pub symbol: String,
158    /// Questrade symbol ID for this option contract.
159    pub symbol_id: u64,
160    /// Best bid price; `None` if no bid is available.
161    pub bid_price: Option<f64>,
162    /// Best ask price; `None` if no ask is available.
163    pub ask_price: Option<f64>,
164    /// Last trade price.
165    pub last_trade_price: Option<f64>,
166    /// Total session volume in contracts.
167    pub volume: Option<u64>,
168    /// Open interest — number of outstanding contracts.
169    pub open_interest: Option<u64>,
170    /// Implied volatility as a decimal (e.g. `0.30` = 30%).
171    pub volatility: Option<f64>,
172    /// Delta Greek (rate of change of option price vs. underlying price).
173    pub delta: Option<f64>,
174    /// Gamma Greek (rate of change of delta vs. underlying price).
175    pub gamma: Option<f64>,
176    /// Theta Greek — daily time decay of the option price.
177    pub theta: Option<f64>,
178    /// Vega Greek (sensitivity to implied volatility changes).
179    pub vega: Option<f64>,
180    /// Rho Greek (sensitivity to interest rate changes).
181    pub rho: Option<f64>,
182    /// Strike price. May be absent; prefer values derived from the chain structure.
183    pub strike_price: Option<f64>,
184    /// Expiry date string. May be absent; prefer values derived from the chain structure.
185    pub expiry_date: Option<String>,
186    /// Option type: `"Call"` or `"Put"`. May be absent.
187    pub option_type: Option<String>,
188    /// Volume-weighted average price.
189    #[serde(rename = "VWAP")]
190    pub vwap: Option<f64>,
191    /// Whether trading in this option is currently halted.
192    pub is_halted: Option<bool>,
193    /// Number of contracts at the best bid.
194    pub bid_size: Option<u64>,
195    /// Number of contracts at the best ask.
196    pub ask_size: Option<u64>,
197}
198
199// ---------- Accounts ----------
200
201/// Response wrapper for `GET /v1/accounts`.
202#[derive(Debug, Deserialize)]
203#[serde(rename_all = "camelCase")]
204pub struct AccountsResponse {
205    /// Brokerage accounts associated with the authenticated user.
206    pub accounts: Vec<Account>,
207}
208
209/// A Questrade brokerage account.
210#[derive(Debug, Clone, Deserialize)]
211#[serde(rename_all = "camelCase")]
212pub struct Account {
213    /// Account type: `"TFSA"`, `"RRSP"`, `"Margin"`, etc.
214    #[serde(rename = "type")]
215    pub account_type: String,
216    /// Account number string (used as `account_id` in subsequent API calls).
217    pub number: String,
218    /// Account status: `"Active"`, `"Closed"`, etc.
219    pub status: String,
220    /// Whether this is the user's primary account.
221    #[serde(default)]
222    pub is_primary: bool,
223}
224
225// ---------- Positions ----------
226
227/// Response wrapper for `GET /v1/accounts/:id/positions`.
228#[derive(Debug, Deserialize)]
229#[serde(rename_all = "camelCase")]
230pub struct PositionsResponse {
231    /// Current open positions in the account.
232    pub positions: Vec<PositionItem>,
233}
234
235/// A single open position in a Questrade account.
236#[derive(Debug, Clone, Deserialize)]
237#[serde(rename_all = "camelCase")]
238pub struct PositionItem {
239    /// Ticker symbol.
240    pub symbol: String,
241    /// Questrade internal symbol ID.
242    pub symbol_id: u64,
243    /// Number of shares or contracts currently held. Questrade may return
244    /// `null` for this field; it is deserialized as `0.0` in that case.
245    #[serde(deserialize_with = "null_as_zero")]
246    pub open_quantity: f64,
247    /// Current market value of the position.
248    pub current_market_value: Option<f64>,
249    /// Current market price per share or contract.
250    pub current_price: Option<f64>,
251    /// Average cost basis per share or contract. Deserializes `null` as `0.0`.
252    #[serde(deserialize_with = "null_as_zero")]
253    pub average_entry_price: f64,
254    /// Realized P&L on closed portions of the position.
255    pub closed_pnl: Option<f64>,
256    /// Unrealized P&L on the remaining open position.
257    pub open_pnl: Option<f64>,
258    /// Total cost basis for the position. Deserializes `null` as `0.0`.
259    #[serde(deserialize_with = "null_as_zero")]
260    pub total_cost: f64,
261}
262
263// ---------- Account activities ----------
264
265/// Response wrapper for `GET /v1/accounts/:id/activities`.
266#[derive(Debug, Deserialize)]
267#[serde(rename_all = "camelCase")]
268pub struct ActivitiesResponse {
269    /// Activity items for the requested date range.
270    pub activities: Vec<ActivityItem>,
271}
272
273/// A single account activity (execution, dividend, deposit, etc.).
274#[derive(Debug, Clone, Deserialize, Serialize)]
275#[serde(rename_all = "camelCase")]
276pub struct ActivityItem {
277    /// ISO datetime of the trade (e.g. `"2024-10-23T00:00:00.000000-04:00"`).
278    pub trade_date: String,
279    /// Date the transaction was recorded (may differ from `trade_date`).
280    #[serde(default)]
281    pub transaction_date: Option<String>,
282    /// T+1/T+2 settlement date.
283    #[serde(default)]
284    pub settlement_date: Option<String>,
285    /// Human-readable description (e.g. `"SELL 1 AAPL Jan 17 '25 $200 Put"`).
286    #[serde(default)]
287    pub description: Option<String>,
288    /// Action type: `"Buy"`, `"Sell"`, `"SellShort"`, `"BuyToCover"`, etc.
289    pub action: String,
290    /// Ticker symbol for this activity.
291    pub symbol: String,
292    /// Questrade internal symbol ID.
293    pub symbol_id: u64,
294    /// Number of shares or contracts involved in the activity.
295    pub quantity: f64,
296    /// Execution price per share or contract.
297    pub price: f64,
298    /// Gross amount before commission. Zero if not provided.
299    #[serde(default)]
300    pub gross_amount: f64,
301    /// Commission charged (typically negative). Zero if not applicable.
302    #[serde(default)]
303    pub commission: f64,
304    /// Net cash impact on the account (gross amount + commission).
305    pub net_amount: f64,
306    /// Settlement currency (e.g. `"CAD"`, `"USD"`). `None` if not provided.
307    #[serde(default)]
308    pub currency: Option<String>,
309    /// Activity category: `"Trades"`, `"Dividends"`, `"Deposits"`, etc.
310    #[serde(rename = "type")]
311    pub activity_type: String,
312}
313
314// ---------- Balances ----------
315
316/// Current and start-of-day balances for a Questrade account.
317///
318/// Returned by `GET /v1/accounts/:id/balances`.
319#[derive(Debug, Clone, Deserialize)]
320#[serde(rename_all = "camelCase")]
321pub struct AccountBalances {
322    /// Real-time balances broken down by currency.
323    pub per_currency_balances: Vec<PerCurrencyBalance>,
324    /// Real-time balances combined across currencies (expressed in CAD).
325    pub combined_balances: Vec<CombinedBalance>,
326    /// Start-of-day balances broken down by currency.
327    pub sod_per_currency_balances: Vec<PerCurrencyBalance>,
328    /// Start-of-day balances combined across currencies (expressed in CAD).
329    pub sod_combined_balances: Vec<CombinedBalance>,
330}
331
332/// Balance snapshot for a single currency.
333#[derive(Debug, Clone, Deserialize)]
334#[serde(rename_all = "camelCase")]
335pub struct PerCurrencyBalance {
336    /// Currency code (`"CAD"` or `"USD"`).
337    pub currency: String,
338    /// Cash balance in this currency.
339    pub cash: f64,
340    /// Total market value of securities denominated in this currency.
341    pub market_value: f64,
342    /// Total account equity in this currency (cash + market value).
343    pub total_equity: f64,
344    /// Available buying power.
345    pub buying_power: f64,
346    /// Maintenance excess (equity above the margin maintenance requirement).
347    pub maintenance_excess: f64,
348    /// Whether the values are real-time (`true`) or delayed (`false`).
349    pub is_real_time: bool,
350}
351
352/// Combined balance across all currencies, expressed in a single currency.
353#[derive(Debug, Clone, Deserialize)]
354#[serde(rename_all = "camelCase")]
355pub struct CombinedBalance {
356    /// Currency in which combined values are expressed (typically `"CAD"`).
357    pub currency: String,
358    /// Combined cash balance.
359    pub cash: f64,
360    /// Combined market value of all securities.
361    pub market_value: f64,
362    /// Combined total equity (cash + market value).
363    pub total_equity: f64,
364    /// Combined available buying power.
365    pub buying_power: f64,
366    /// Combined maintenance excess.
367    pub maintenance_excess: f64,
368    /// Whether the values are real-time (`true`) or delayed (`false`).
369    pub is_real_time: bool,
370}
371
372// ---------- Markets ----------
373
374/// Response wrapper for `GET /v1/markets`.
375#[derive(Debug, Deserialize)]
376pub struct MarketsResponse {
377    /// Metadata for each market / exchange.
378    pub markets: Vec<MarketInfo>,
379}
380
381/// Metadata for a single market / exchange returned by `GET /v1/markets`.
382#[derive(Debug, Clone, Deserialize, Serialize)]
383#[serde(rename_all = "camelCase")]
384pub struct MarketInfo {
385    /// Market or exchange name (e.g. `"NYSE"`, `"TSX"`).
386    pub name: String,
387    /// Trading currency (e.g. `"USD"`, `"CAD"`).
388    #[serde(default)]
389    pub currency: Option<String>,
390    /// Regular session open time (ISO 8601).
391    #[serde(default)]
392    pub start_time: Option<String>,
393    /// Regular session close time (ISO 8601).
394    #[serde(default)]
395    pub end_time: Option<String>,
396    /// Extended (pre-market) session open time (ISO 8601).
397    #[serde(default)]
398    pub extended_start_time: Option<String>,
399    /// Extended (after-hours) session close time (ISO 8601).
400    #[serde(default)]
401    pub extended_end_time: Option<String>,
402    /// Current open/closed status snapshot; `None` if not available.
403    #[serde(default)]
404    pub snapshot: Option<MarketSnapshot>,
405}
406
407/// Real-time open/closed status for a market.
408#[derive(Debug, Clone, Deserialize, Serialize)]
409#[serde(rename_all = "camelCase")]
410pub struct MarketSnapshot {
411    /// Whether the market is currently open for regular trading.
412    pub is_open: bool,
413    /// Quote delay in minutes (`0` = real-time).
414    #[serde(default)]
415    pub delay: u32,
416}
417
418// ---------- Symbol detail ----------
419
420/// Response wrapper for `GET /v1/symbols/:id`.
421#[derive(Debug, Deserialize)]
422pub struct SymbolDetailResponse {
423    /// Full details for the requested symbol (typically one entry).
424    pub symbols: Vec<SymbolDetail>,
425}
426
427/// Full symbol details returned by `GET /v1/symbols/:id`.
428#[derive(Debug, Clone, Deserialize)]
429#[serde(rename_all = "camelCase")]
430pub struct SymbolDetail {
431    /// Ticker symbol (e.g. `"AAPL"`).
432    pub symbol: String,
433    /// Questrade internal symbol ID.
434    pub symbol_id: u64,
435    /// Human-readable company or security name.
436    pub description: String,
437    /// Security type: `"Stock"`, `"Option"`, `"ETF"`, etc.
438    pub security_type: String,
439    /// Primary listing exchange.
440    pub listing_exchange: String,
441    /// Trading currency (e.g. `"USD"`, `"CAD"`).
442    pub currency: String,
443    /// Whether the security is tradable through Questrade.
444    pub is_tradable: bool,
445    /// Whether real-time quotes are available.
446    pub is_quotable: bool,
447    /// Whether listed options exist for this security.
448    pub has_options: bool,
449    /// Previous trading day's closing price.
450    pub prev_day_close_price: Option<f64>,
451    /// 52-week high price.
452    pub high_price52: Option<f64>,
453    /// 52-week low price.
454    pub low_price52: Option<f64>,
455    /// 3-month average daily volume in shares.
456    pub average_vol3_months: Option<u64>,
457    /// 20-day average daily volume in shares.
458    pub average_vol20_days: Option<u64>,
459    /// Total shares outstanding.
460    pub outstanding_shares: Option<u64>,
461    /// Trailing twelve-month earnings per share.
462    pub eps: Option<f64>,
463    /// Price-to-earnings ratio.
464    pub pe: Option<f64>,
465    /// Annual dividend per share.
466    pub dividend: Option<f64>,
467    /// Annual dividend yield as a percentage (e.g. `0.53` = 0.53%).
468    #[serde(rename = "yield")]
469    pub dividend_yield: Option<f64>,
470    /// Most recent ex-dividend date (ISO 8601).
471    pub ex_date: Option<String>,
472    /// Most recent dividend payment date (ISO 8601).
473    pub dividend_date: Option<String>,
474    /// Market capitalisation.
475    pub market_cap: Option<f64>,
476    /// GICS sector name (e.g. `"Technology"`).
477    pub industry_sector: Option<String>,
478    /// GICS industry group name.
479    pub industry_group: Option<String>,
480    /// GICS sub-industry name.
481    pub industry_sub_group: Option<String>,
482    /// For option symbols: `"Call"` or `"Put"`.
483    pub option_type: Option<String>,
484    /// For option symbols: expiry date (ISO 8601).
485    pub option_expiry: Option<String>,
486    /// For option symbols: strike price.
487    pub option_strike_price: Option<f64>,
488    /// For option symbols: exercise style — `"American"` or `"European"`.
489    pub option_exercise_type: Option<String>,
490}
491
492// ---------- Orders ----------
493
494/// Filter for order state when querying `GET /v1/accounts/:id/orders`.
495#[derive(Debug, Clone, Copy, Serialize)]
496pub enum OrderStateFilter {
497    /// Return all orders regardless of state.
498    All,
499    /// Return only open (working) orders.
500    Open,
501    /// Return only closed (filled, canceled, expired) orders.
502    Closed,
503}
504
505impl std::fmt::Display for OrderStateFilter {
506    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
507        match self {
508            Self::All => write!(f, "All"),
509            Self::Open => write!(f, "Open"),
510            Self::Closed => write!(f, "Closed"),
511        }
512    }
513}
514
515/// Response wrapper for `GET /v1/accounts/:id/orders`.
516#[derive(Debug, Deserialize)]
517#[serde(rename_all = "camelCase")]
518pub struct OrdersResponse {
519    /// Orders matching the query filters.
520    pub orders: Vec<OrderItem>,
521}
522
523/// A single order from the Questrade orders endpoint.
524#[derive(Debug, Clone, Deserialize, Serialize)]
525#[serde(rename_all = "camelCase")]
526pub struct OrderItem {
527    /// Internal order identifier.
528    pub id: u64,
529    /// Ticker symbol (e.g. `"AAPL"`).
530    pub symbol: String,
531    /// Questrade internal symbol ID.
532    pub symbol_id: u64,
533    /// Total quantity of the order.
534    #[serde(default)]
535    pub total_quantity: f64,
536    /// Quantity still open (unfilled).
537    #[serde(default)]
538    pub open_quantity: f64,
539    /// Quantity that has been filled.
540    #[serde(default)]
541    pub filled_quantity: f64,
542    /// Quantity that was canceled.
543    #[serde(default)]
544    pub canceled_quantity: f64,
545    /// Order side: `"Buy"`, `"Sell"`, `"BuyToOpen"`, `"SellToClose"`, etc.
546    pub side: String,
547    /// Order type: `"Market"`, `"Limit"`, `"Stop"`, `"StopLimit"`, etc.
548    pub order_type: String,
549    /// Limit price, if applicable.
550    #[serde(default)]
551    pub limit_price: Option<f64>,
552    /// Stop price, if applicable.
553    #[serde(default)]
554    pub stop_price: Option<f64>,
555    /// Average execution price across all fills.
556    #[serde(default)]
557    pub avg_exec_price: Option<f64>,
558    /// Price of the last execution.
559    #[serde(default)]
560    pub last_exec_price: Option<f64>,
561    /// Commission charged for the order.
562    #[serde(default)]
563    pub commission_charged: f64,
564    /// Current order state (e.g. `"Executed"`, `"Canceled"`, `"Pending"`, etc.).
565    pub state: String,
566    /// Time in force: `"Day"`, `"GoodTillCanceled"`, `"GoodTillDate"`, etc.
567    pub time_in_force: String,
568    /// ISO 8601 datetime when the order was created.
569    pub creation_time: String,
570    /// ISO 8601 datetime of the last update to the order.
571    pub update_time: String,
572    /// Questrade staff annotations.
573    #[serde(default)]
574    pub notes: Option<String>,
575    /// Whether the order is all-or-none.
576    #[serde(default)]
577    pub is_all_or_none: bool,
578    /// Whether the order is anonymous.
579    #[serde(default)]
580    pub is_anonymous: bool,
581    /// Order group ID for bracket orders.
582    #[serde(default)]
583    pub order_group_id: Option<u64>,
584    /// Chain ID linking related orders.
585    #[serde(default)]
586    pub chain_id: Option<u64>,
587}
588
589// ---------- Executions ----------
590
591/// Response wrapper for `GET /v1/accounts/:id/executions`.
592#[derive(Debug, Deserialize)]
593#[serde(rename_all = "camelCase")]
594pub struct ExecutionsResponse {
595    /// Execution records for the requested date range.
596    pub executions: Vec<Execution>,
597}
598
599/// A single trade execution (fill-level detail) from Questrade.
600#[derive(Debug, Clone, Deserialize, Serialize)]
601#[serde(rename_all = "camelCase")]
602pub struct Execution {
603    /// Ticker symbol (e.g. `"AAPL"`).
604    pub symbol: String,
605    /// Questrade internal symbol ID.
606    pub symbol_id: u64,
607    /// Number of shares or contracts filled.
608    pub quantity: f64,
609    /// Client side of the order: `"Buy"`, `"Sell"`, etc.
610    pub side: String,
611    /// Execution price per share or contract.
612    pub price: f64,
613    /// Internal execution identifier.
614    pub id: u64,
615    /// Internal order identifier.
616    pub order_id: u64,
617    /// Internal order chain identifier.
618    pub order_chain_id: u64,
619    /// Identifier of the execution at the originating exchange.
620    #[serde(default)]
621    pub exchange_exec_id: Option<String>,
622    /// Execution timestamp (ISO 8601).
623    pub timestamp: String,
624    /// Manual notes from Trade Desk staff (empty string if none).
625    #[serde(default)]
626    pub notes: Option<String>,
627    /// Trading venue where the execution originated (e.g. `"LAMP"`).
628    #[serde(default)]
629    pub venue: Option<String>,
630    /// Total cost: price × quantity.
631    #[serde(default, deserialize_with = "null_as_zero")]
632    pub total_cost: f64,
633    /// Trade Desk order placement commission.
634    #[serde(default, deserialize_with = "null_as_zero")]
635    pub order_placement_commission: f64,
636    /// Questrade commission.
637    #[serde(default, deserialize_with = "null_as_zero")]
638    pub commission: f64,
639    /// Venue liquidity execution fee.
640    #[serde(default, deserialize_with = "null_as_zero")]
641    pub execution_fee: f64,
642    /// SEC fee on US security sales.
643    #[serde(default, deserialize_with = "null_as_zero")]
644    pub sec_fee: f64,
645    /// TSX/Canadian execution fee.
646    #[serde(default, deserialize_with = "null_as_zero")]
647    pub canadian_execution_fee: f64,
648    /// Parent order identifier (0 if none).
649    #[serde(default)]
650    pub parent_id: u64,
651}
652
653// ---------- Candles ----------
654
655/// Response wrapper for `GET /v1/markets/candles/:id`.
656#[derive(Debug, Deserialize)]
657#[serde(rename_all = "camelCase")]
658pub struct CandleResponse {
659    /// OHLCV price bars in chronological order.
660    pub candles: Vec<Candle>,
661}
662
663/// A single OHLCV price bar.
664#[derive(Debug, Deserialize)]
665pub struct Candle {
666    /// Bar open time (ISO 8601).
667    pub start: String,
668    /// Bar close time (ISO 8601).
669    pub end: String,
670    /// Open price.
671    pub open: f64,
672    /// High price.
673    pub high: f64,
674    /// Low price.
675    pub low: f64,
676    /// Closing price.
677    pub close: f64,
678    /// Volume traded during the bar.
679    pub volume: u64,
680}
681
682#[cfg(test)]
683mod tests {
684    use super::*;
685
686    #[test]
687    fn markets_response_deserializes_from_questrade_json() {
688        // Representative response from GET /v1/markets (real Questrade format).
689        let json = r#"{
690            "markets": [
691                {
692                    "name": "NYSE",
693                    "tradingVenues": ["NYSE"],
694                    "defaultTradingVenue": "NYSE",
695                    "primaryOrderRoutes": ["NYSE"],
696                    "secondaryOrderRoutes": [],
697                    "level1Feeds": ["NYSE"],
698                    "level2Feeds": [],
699                    "extendedStartTime": "2026-02-21T08:00:00.000000-05:00",
700                    "startTime": "2026-02-21T09:30:00.000000-05:00",
701                    "endTime": "2026-02-21T16:00:00.000000-05:00",
702                    "extendedEndTime": "2026-02-21T20:00:00.000000-05:00",
703                    "currency": "USD",
704                    "snapshot": { "isOpen": true, "delay": 0 }
705                },
706                {
707                    "name": "TSX",
708                    "tradingVenues": ["TSX"],
709                    "defaultTradingVenue": "TSX",
710                    "primaryOrderRoutes": ["TSX"],
711                    "secondaryOrderRoutes": [],
712                    "level1Feeds": ["TSX"],
713                    "level2Feeds": [],
714                    "extendedStartTime": "2026-02-21T08:00:00.000000-05:00",
715                    "startTime": "2026-02-21T09:30:00.000000-05:00",
716                    "endTime": "2026-02-21T16:00:00.000000-05:00",
717                    "extendedEndTime": "2026-02-21T17:00:00.000000-05:00",
718                    "currency": "CAD",
719                    "snapshot": null
720                }
721            ]
722        }"#;
723
724        let resp: MarketsResponse = serde_json::from_str(json).unwrap();
725        assert_eq!(resp.markets.len(), 2);
726
727        let nyse = &resp.markets[0];
728        assert_eq!(nyse.name, "NYSE");
729        assert_eq!(nyse.currency.as_deref(), Some("USD"));
730        assert_eq!(
731            nyse.start_time.as_deref(),
732            Some("2026-02-21T09:30:00.000000-05:00")
733        );
734        assert_eq!(
735            nyse.end_time.as_deref(),
736            Some("2026-02-21T16:00:00.000000-05:00")
737        );
738        let snap = nyse.snapshot.as_ref().unwrap();
739        assert!(snap.is_open);
740        assert_eq!(snap.delay, 0);
741
742        // TSX has snapshot: null — should deserialise to None.
743        let tsx = &resp.markets[1];
744        assert_eq!(tsx.name, "TSX");
745        assert!(tsx.snapshot.is_none());
746    }
747
748    #[test]
749    fn account_balances_deserializes_from_questrade_json() {
750        let json = r#"{
751            "perCurrencyBalances": [
752                {
753                    "currency": "CAD",
754                    "cash": 10000.0,
755                    "marketValue": 50000.0,
756                    "totalEquity": 60000.0,
757                    "buyingPower": 60000.0,
758                    "maintenanceExcess": 60000.0,
759                    "isRealTime": false
760                }
761            ],
762            "combinedBalances": [
763                {
764                    "currency": "CAD",
765                    "cash": 10000.0,
766                    "marketValue": 50000.0,
767                    "totalEquity": 60000.0,
768                    "buyingPower": 60000.0,
769                    "maintenanceExcess": 60000.0,
770                    "isRealTime": false
771                }
772            ],
773            "sodPerCurrencyBalances": [
774                {
775                    "currency": "CAD",
776                    "cash": 9000.0,
777                    "marketValue": 49000.0,
778                    "totalEquity": 58000.0,
779                    "buyingPower": 58000.0,
780                    "maintenanceExcess": 58000.0,
781                    "isRealTime": false
782                }
783            ],
784            "sodCombinedBalances": [
785                {
786                    "currency": "CAD",
787                    "cash": 9000.0,
788                    "marketValue": 49000.0,
789                    "totalEquity": 58000.0,
790                    "buyingPower": 58000.0,
791                    "maintenanceExcess": 58000.0,
792                    "isRealTime": false
793                }
794            ]
795        }"#;
796
797        let balances: AccountBalances = serde_json::from_str(json).unwrap();
798        assert_eq!(balances.per_currency_balances.len(), 1);
799        let cad = &balances.per_currency_balances[0];
800        assert_eq!(cad.currency, "CAD");
801        assert_eq!(cad.cash, 10000.0);
802        assert_eq!(cad.market_value, 50000.0);
803        assert_eq!(cad.total_equity, 60000.0);
804        assert!(!cad.is_real_time);
805        assert_eq!(balances.combined_balances.len(), 1);
806        assert_eq!(balances.sod_per_currency_balances.len(), 1);
807        assert_eq!(balances.sod_combined_balances.len(), 1);
808    }
809
810    #[test]
811    fn symbol_detail_deserializes_from_questrade_json() {
812        let json = r#"{
813            "symbols": [
814                {
815                    "symbol": "AAPL",
816                    "symbolId": 8049,
817                    "description": "Apple Inc.",
818                    "securityType": "Stock",
819                    "listingExchange": "NASDAQ",
820                    "currency": "USD",
821                    "isTradable": true,
822                    "isQuotable": true,
823                    "hasOptions": true,
824                    "prevDayClosePrice": 182.50,
825                    "highPrice52": 199.62,
826                    "lowPrice52": 124.17,
827                    "averageVol3Months": 52000000,
828                    "averageVol20Days": 50000000,
829                    "outstandingShares": 15700000000,
830                    "eps": 6.14,
831                    "pe": 29.74,
832                    "dividend": 0.96,
833                    "yield": 0.53,
834                    "exDate": "2023-11-10T00:00:00.000000-05:00",
835                    "dividendDate": "2023-11-16T00:00:00.000000-05:00",
836                    "marketCap": 2866625000000.0,
837                    "industrySector": "Technology",
838                    "industryGroup": "Technology Hardware, Storage & Peripherals",
839                    "industrySubGroup": "Other",
840                    "optionType": null,
841                    "optionExpiry": null,
842                    "optionStrikePrice": null,
843                    "optionExerciseType": null
844                }
845            ]
846        }"#;
847
848        let resp: SymbolDetailResponse = serde_json::from_str(json).unwrap();
849        assert_eq!(resp.symbols.len(), 1);
850        let s = &resp.symbols[0];
851        assert_eq!(s.symbol, "AAPL");
852        assert_eq!(s.symbol_id, 8049);
853        assert_eq!(s.description, "Apple Inc.");
854        assert_eq!(s.security_type, "Stock");
855        assert_eq!(s.listing_exchange, "NASDAQ");
856        assert_eq!(s.currency, "USD");
857        assert!(s.is_tradable);
858        assert!(s.is_quotable);
859        assert!(s.has_options);
860        assert_eq!(s.prev_day_close_price, Some(182.50));
861        assert_eq!(s.high_price52, Some(199.62));
862        assert_eq!(s.low_price52, Some(124.17));
863        assert_eq!(s.eps, Some(6.14));
864        assert_eq!(s.dividend_yield, Some(0.53));
865        assert_eq!(s.industry_sector.as_deref(), Some("Technology"));
866        assert!(s.option_type.is_none());
867        assert!(s.option_expiry.is_none());
868    }
869
870    #[test]
871    fn orders_response_deserializes_from_questrade_json() {
872        let json = r#"{
873            "orders": [
874                {
875                    "id": 173577870,
876                    "symbol": "AAPL",
877                    "symbolId": 8049,
878                    "totalQuantity": 100,
879                    "openQuantity": 0,
880                    "filledQuantity": 100,
881                    "canceledQuantity": 0,
882                    "side": "Buy",
883                    "orderType": "Limit",
884                    "limitPrice": 150.50,
885                    "stopPrice": null,
886                    "avgExecPrice": 150.25,
887                    "lastExecPrice": 150.25,
888                    "commissionCharged": 4.95,
889                    "state": "Executed",
890                    "timeInForce": "Day",
891                    "creationTime": "2026-02-20T10:30:00.000000-05:00",
892                    "updateTime": "2026-02-20T10:31:15.000000-05:00",
893                    "notes": null,
894                    "isAllOrNone": false,
895                    "isAnonymous": false,
896                    "orderGroupId": 0,
897                    "chainId": 173577870
898                },
899                {
900                    "id": 173600001,
901                    "symbol": "MSFT",
902                    "symbolId": 9291,
903                    "totalQuantity": 50,
904                    "openQuantity": 50,
905                    "filledQuantity": 0,
906                    "canceledQuantity": 0,
907                    "side": "Buy",
908                    "orderType": "Limit",
909                    "limitPrice": 400.00,
910                    "stopPrice": null,
911                    "avgExecPrice": null,
912                    "lastExecPrice": null,
913                    "commissionCharged": 0,
914                    "state": "Pending",
915                    "timeInForce": "GoodTillCanceled",
916                    "creationTime": "2026-02-21T09:45:00.000000-05:00",
917                    "updateTime": "2026-02-21T09:45:00.000000-05:00",
918                    "notes": "Staff note here",
919                    "isAllOrNone": true,
920                    "isAnonymous": false
921                }
922            ]
923        }"#;
924
925        let resp: OrdersResponse = serde_json::from_str(json).unwrap();
926        assert_eq!(resp.orders.len(), 2);
927
928        // Fully filled order
929        let o1 = &resp.orders[0];
930        assert_eq!(o1.id, 173577870);
931        assert_eq!(o1.symbol, "AAPL");
932        assert_eq!(o1.symbol_id, 8049);
933        assert_eq!(o1.total_quantity, 100.0);
934        assert_eq!(o1.open_quantity, 0.0);
935        assert_eq!(o1.filled_quantity, 100.0);
936        assert_eq!(o1.canceled_quantity, 0.0);
937        assert_eq!(o1.side, "Buy");
938        assert_eq!(o1.order_type, "Limit");
939        assert_eq!(o1.limit_price, Some(150.50));
940        assert!(o1.stop_price.is_none());
941        assert_eq!(o1.avg_exec_price, Some(150.25));
942        assert_eq!(o1.last_exec_price, Some(150.25));
943        assert_eq!(o1.commission_charged, 4.95);
944        assert_eq!(o1.state, "Executed");
945        assert_eq!(o1.time_in_force, "Day");
946        assert!(o1.notes.is_none());
947        assert!(!o1.is_all_or_none);
948        assert_eq!(o1.chain_id, Some(173577870));
949
950        // Pending order with optional fields missing
951        let o2 = &resp.orders[1];
952        assert_eq!(o2.id, 173600001);
953        assert_eq!(o2.symbol, "MSFT");
954        assert_eq!(o2.state, "Pending");
955        assert_eq!(o2.time_in_force, "GoodTillCanceled");
956        assert!(o2.avg_exec_price.is_none());
957        assert!(o2.last_exec_price.is_none());
958        assert_eq!(o2.commission_charged, 0.0);
959        assert_eq!(o2.notes.as_deref(), Some("Staff note here"));
960        assert!(o2.is_all_or_none);
961        // Missing orderGroupId/chainId should default to None
962        assert!(o2.order_group_id.is_none());
963        assert!(o2.chain_id.is_none());
964    }
965
966    #[test]
967    fn execution_deserializes_from_questrade_json() {
968        let json = r#"{
969            "executions": [
970                {
971                    "symbol": "AAPL",
972                    "symbolId": 8049,
973                    "quantity": 10,
974                    "side": "Buy",
975                    "price": 536.87,
976                    "id": 53817310,
977                    "orderId": 177106005,
978                    "orderChainId": 17710600,
979                    "exchangeExecId": "XS1771060050147",
980                    "timestamp": "2014-03-31T13:38:29.000000-04:00",
981                    "notes": "",
982                    "venue": "LAMP",
983                    "totalCost": 5368.7,
984                    "orderPlacementCommission": 0,
985                    "commission": 4.95,
986                    "executionFee": 0,
987                    "secFee": 0,
988                    "canadianExecutionFee": 0,
989                    "parentId": 0
990                }
991            ]
992        }"#;
993
994        let resp: ExecutionsResponse = serde_json::from_str(json).unwrap();
995        assert_eq!(resp.executions.len(), 1);
996
997        let e = &resp.executions[0];
998        assert_eq!(e.symbol, "AAPL");
999        assert_eq!(e.symbol_id, 8049);
1000        assert_eq!(e.quantity, 10.0);
1001        assert_eq!(e.side, "Buy");
1002        assert_eq!(e.price, 536.87);
1003        assert_eq!(e.id, 53817310);
1004        assert_eq!(e.order_id, 177106005);
1005        assert_eq!(e.order_chain_id, 17710600);
1006        assert_eq!(e.exchange_exec_id.as_deref(), Some("XS1771060050147"));
1007        assert_eq!(e.timestamp, "2014-03-31T13:38:29.000000-04:00");
1008        assert_eq!(e.venue.as_deref(), Some("LAMP"));
1009        assert_eq!(e.total_cost, 5368.7);
1010        assert_eq!(e.commission, 4.95);
1011        assert_eq!(e.execution_fee, 0.0);
1012        assert_eq!(e.sec_fee, 0.0);
1013        assert_eq!(e.parent_id, 0);
1014    }
1015}