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// ---------- Strategy quotes ----------
200
201/// A single leg in a multi-leg option strategy variant.
202#[derive(Debug, Clone, Serialize)]
203#[serde(rename_all = "camelCase")]
204pub struct StrategyLeg {
205    /// Option symbol ID for this leg.
206    pub symbol_id: u64,
207    /// Order side: `"Buy"` or `"Sell"`.
208    pub action: String,
209    /// Ratio of this leg in the strategy (e.g. 1 for a standard spread).
210    pub ratio: u32,
211}
212
213/// A strategy variant to quote, containing one or more legs.
214#[derive(Debug, Clone, Serialize)]
215#[serde(rename_all = "camelCase")]
216pub struct StrategyVariantRequest {
217    /// Caller-assigned ID, echoed in the response for matching.
218    pub variant_id: u32,
219    /// Strategy type (e.g. `"Custom"`).
220    pub strategy: String,
221    /// Legs comprising this strategy variant.
222    pub legs: Vec<StrategyLeg>,
223}
224
225/// Request body for `POST /v1/markets/quotes/strategies`.
226#[derive(Debug, Clone, Serialize)]
227#[serde(rename_all = "camelCase")]
228pub struct StrategyQuoteRequest {
229    /// Strategy variants to fetch combined quotes for.
230    pub variants: Vec<StrategyVariantRequest>,
231}
232
233/// Response from `POST /v1/markets/quotes/strategies`.
234#[derive(Debug, Deserialize)]
235#[serde(rename_all = "camelCase")]
236pub struct StrategyQuotesResponse {
237    /// Combined quotes for each requested strategy variant.
238    pub strategy_quotes: Vec<StrategyQuote>,
239}
240
241/// Combined quote and Greeks for a multi-leg option strategy.
242#[derive(Debug, Deserialize)]
243#[serde(rename_all = "camelCase")]
244pub struct StrategyQuote {
245    /// Echoed variant ID from the request.
246    pub variant_id: u32,
247    /// Best bid price for the strategy; `None` if unavailable.
248    pub bid_price: Option<f64>,
249    /// Best ask price for the strategy; `None` if unavailable.
250    pub ask_price: Option<f64>,
251    /// Underlying ticker symbol.
252    pub underlying: String,
253    /// Questrade internal symbol ID of the underlying.
254    pub underlying_id: u64,
255    /// Session open price for the strategy.
256    pub open_price: Option<f64>,
257    /// Implied volatility as a decimal (e.g. `0.30` = 30%).
258    pub volatility: Option<f64>,
259    /// Delta Greek.
260    pub delta: Option<f64>,
261    /// Gamma Greek.
262    pub gamma: Option<f64>,
263    /// Theta Greek — daily time decay.
264    pub theta: Option<f64>,
265    /// Vega Greek — sensitivity to IV changes.
266    pub vega: Option<f64>,
267    /// Rho Greek — sensitivity to interest rate changes.
268    pub rho: Option<f64>,
269    /// Whether the quote data is real-time.
270    pub is_real_time: bool,
271}
272
273// ---------- Accounts ----------
274
275/// Response wrapper for `GET /v1/accounts`.
276#[derive(Debug, Deserialize)]
277#[serde(rename_all = "camelCase")]
278pub struct AccountsResponse {
279    /// Brokerage accounts associated with the authenticated user.
280    pub accounts: Vec<Account>,
281}
282
283/// A Questrade brokerage account.
284#[derive(Debug, Clone, Deserialize)]
285#[serde(rename_all = "camelCase")]
286pub struct Account {
287    /// Account type: `"TFSA"`, `"RRSP"`, `"Margin"`, etc.
288    #[serde(rename = "type")]
289    pub account_type: String,
290    /// Account number string (used as `account_id` in subsequent API calls).
291    pub number: String,
292    /// Account status: `"Active"`, `"Closed"`, etc.
293    pub status: String,
294    /// Whether this is the user's primary account.
295    #[serde(default)]
296    pub is_primary: bool,
297}
298
299// ---------- Positions ----------
300
301/// Response wrapper for `GET /v1/accounts/:id/positions`.
302#[derive(Debug, Deserialize)]
303#[serde(rename_all = "camelCase")]
304pub struct PositionsResponse {
305    /// Current open positions in the account.
306    pub positions: Vec<PositionItem>,
307}
308
309/// A single open position in a Questrade account.
310#[derive(Debug, Clone, Deserialize)]
311#[serde(rename_all = "camelCase")]
312pub struct PositionItem {
313    /// Ticker symbol.
314    pub symbol: String,
315    /// Questrade internal symbol ID.
316    pub symbol_id: u64,
317    /// Number of shares or contracts currently held. Questrade may return
318    /// `null` for this field; it is deserialized as `0.0` in that case.
319    #[serde(deserialize_with = "null_as_zero")]
320    pub open_quantity: f64,
321    /// Current market value of the position.
322    pub current_market_value: Option<f64>,
323    /// Current market price per share or contract.
324    pub current_price: Option<f64>,
325    /// Average cost basis per share or contract. Deserializes `null` as `0.0`.
326    #[serde(deserialize_with = "null_as_zero")]
327    pub average_entry_price: f64,
328    /// Realized P&L on closed portions of the position.
329    pub closed_pnl: Option<f64>,
330    /// Unrealized P&L on the remaining open position.
331    pub open_pnl: Option<f64>,
332    /// Total cost basis for the position. Deserializes `null` as `0.0`.
333    #[serde(deserialize_with = "null_as_zero")]
334    pub total_cost: f64,
335}
336
337// ---------- Account activities ----------
338
339/// Response wrapper for `GET /v1/accounts/:id/activities`.
340#[derive(Debug, Deserialize)]
341#[serde(rename_all = "camelCase")]
342pub struct ActivitiesResponse {
343    /// Activity items for the requested date range.
344    pub activities: Vec<ActivityItem>,
345}
346
347/// A single account activity (execution, dividend, deposit, etc.).
348#[derive(Debug, Clone, Deserialize, Serialize)]
349#[serde(rename_all = "camelCase")]
350pub struct ActivityItem {
351    /// ISO datetime of the trade (e.g. `"2024-10-23T00:00:00.000000-04:00"`).
352    pub trade_date: String,
353    /// Date the transaction was recorded (may differ from `trade_date`).
354    #[serde(default)]
355    pub transaction_date: Option<String>,
356    /// T+1/T+2 settlement date.
357    #[serde(default)]
358    pub settlement_date: Option<String>,
359    /// Human-readable description (e.g. `"SELL 1 AAPL Jan 17 '25 $200 Put"`).
360    #[serde(default)]
361    pub description: Option<String>,
362    /// Action type: `"Buy"`, `"Sell"`, `"SellShort"`, `"BuyToCover"`, etc.
363    pub action: String,
364    /// Ticker symbol for this activity.
365    pub symbol: String,
366    /// Questrade internal symbol ID.
367    pub symbol_id: u64,
368    /// Number of shares or contracts involved in the activity.
369    pub quantity: f64,
370    /// Execution price per share or contract.
371    pub price: f64,
372    /// Gross amount before commission. Zero if not provided.
373    #[serde(default)]
374    pub gross_amount: f64,
375    /// Commission charged (typically negative). Zero if not applicable.
376    #[serde(default)]
377    pub commission: f64,
378    /// Net cash impact on the account (gross amount + commission).
379    pub net_amount: f64,
380    /// Settlement currency (e.g. `"CAD"`, `"USD"`). `None` if not provided.
381    #[serde(default)]
382    pub currency: Option<String>,
383    /// Activity category: `"Trades"`, `"Dividends"`, `"Deposits"`, etc.
384    #[serde(rename = "type")]
385    pub activity_type: String,
386}
387
388// ---------- Balances ----------
389
390/// Current and start-of-day balances for a Questrade account.
391///
392/// Returned by `GET /v1/accounts/:id/balances`.
393#[derive(Debug, Clone, Deserialize)]
394#[serde(rename_all = "camelCase")]
395pub struct AccountBalances {
396    /// Real-time balances broken down by currency.
397    pub per_currency_balances: Vec<PerCurrencyBalance>,
398    /// Real-time balances combined across currencies (expressed in CAD).
399    pub combined_balances: Vec<CombinedBalance>,
400    /// Start-of-day balances broken down by currency.
401    pub sod_per_currency_balances: Vec<PerCurrencyBalance>,
402    /// Start-of-day balances combined across currencies (expressed in CAD).
403    pub sod_combined_balances: Vec<CombinedBalance>,
404}
405
406/// Balance snapshot for a single currency.
407#[derive(Debug, Clone, Deserialize)]
408#[serde(rename_all = "camelCase")]
409pub struct PerCurrencyBalance {
410    /// Currency code (`"CAD"` or `"USD"`).
411    pub currency: String,
412    /// Cash balance in this currency.
413    pub cash: f64,
414    /// Total market value of securities denominated in this currency.
415    pub market_value: f64,
416    /// Total account equity in this currency (cash + market value).
417    pub total_equity: f64,
418    /// Available buying power.
419    pub buying_power: f64,
420    /// Maintenance excess (equity above the margin maintenance requirement).
421    pub maintenance_excess: f64,
422    /// Whether the values are real-time (`true`) or delayed (`false`).
423    pub is_real_time: bool,
424}
425
426/// Combined balance across all currencies, expressed in a single currency.
427#[derive(Debug, Clone, Deserialize)]
428#[serde(rename_all = "camelCase")]
429pub struct CombinedBalance {
430    /// Currency in which combined values are expressed (typically `"CAD"`).
431    pub currency: String,
432    /// Combined cash balance.
433    pub cash: f64,
434    /// Combined market value of all securities.
435    pub market_value: f64,
436    /// Combined total equity (cash + market value).
437    pub total_equity: f64,
438    /// Combined available buying power.
439    pub buying_power: f64,
440    /// Combined maintenance excess.
441    pub maintenance_excess: f64,
442    /// Whether the values are real-time (`true`) or delayed (`false`).
443    pub is_real_time: bool,
444}
445
446// ---------- Markets ----------
447
448/// Response wrapper for `GET /v1/markets`.
449#[derive(Debug, Deserialize)]
450pub struct MarketsResponse {
451    /// Metadata for each market / exchange.
452    pub markets: Vec<MarketInfo>,
453}
454
455/// Metadata for a single market / exchange returned by `GET /v1/markets`.
456#[derive(Debug, Clone, Deserialize, Serialize)]
457#[serde(rename_all = "camelCase")]
458pub struct MarketInfo {
459    /// Market or exchange name (e.g. `"NYSE"`, `"TSX"`).
460    pub name: String,
461    /// Trading currency (e.g. `"USD"`, `"CAD"`).
462    #[serde(default)]
463    pub currency: Option<String>,
464    /// Regular session open time (ISO 8601).
465    #[serde(default)]
466    pub start_time: Option<String>,
467    /// Regular session close time (ISO 8601).
468    #[serde(default)]
469    pub end_time: Option<String>,
470    /// Extended (pre-market) session open time (ISO 8601).
471    #[serde(default)]
472    pub extended_start_time: Option<String>,
473    /// Extended (after-hours) session close time (ISO 8601).
474    #[serde(default)]
475    pub extended_end_time: Option<String>,
476    /// Current open/closed status snapshot; `None` if not available.
477    #[serde(default)]
478    pub snapshot: Option<MarketSnapshot>,
479}
480
481/// Real-time open/closed status for a market.
482#[derive(Debug, Clone, Deserialize, Serialize)]
483#[serde(rename_all = "camelCase")]
484pub struct MarketSnapshot {
485    /// Whether the market is currently open for regular trading.
486    pub is_open: bool,
487    /// Quote delay in minutes (`0` = real-time).
488    #[serde(default)]
489    pub delay: u32,
490}
491
492// ---------- Symbol detail ----------
493
494/// Response wrapper for `GET /v1/symbols/:id`.
495#[derive(Debug, Deserialize)]
496pub struct SymbolDetailResponse {
497    /// Full details for the requested symbol (typically one entry).
498    pub symbols: Vec<SymbolDetail>,
499}
500
501/// Full symbol details returned by `GET /v1/symbols/:id`.
502#[derive(Debug, Clone, Deserialize)]
503#[serde(rename_all = "camelCase")]
504pub struct SymbolDetail {
505    /// Ticker symbol (e.g. `"AAPL"`).
506    pub symbol: String,
507    /// Questrade internal symbol ID.
508    pub symbol_id: u64,
509    /// Human-readable company or security name.
510    pub description: String,
511    /// Security type: `"Stock"`, `"Option"`, `"ETF"`, etc.
512    pub security_type: String,
513    /// Primary listing exchange.
514    pub listing_exchange: String,
515    /// Trading currency (e.g. `"USD"`, `"CAD"`).
516    pub currency: String,
517    /// Whether the security is tradable through Questrade.
518    pub is_tradable: bool,
519    /// Whether real-time quotes are available.
520    pub is_quotable: bool,
521    /// Whether listed options exist for this security.
522    pub has_options: bool,
523    /// Previous trading day's closing price.
524    pub prev_day_close_price: Option<f64>,
525    /// 52-week high price.
526    pub high_price52: Option<f64>,
527    /// 52-week low price.
528    pub low_price52: Option<f64>,
529    /// 3-month average daily volume in shares.
530    pub average_vol3_months: Option<u64>,
531    /// 20-day average daily volume in shares.
532    pub average_vol20_days: Option<u64>,
533    /// Total shares outstanding.
534    pub outstanding_shares: Option<u64>,
535    /// Trailing twelve-month earnings per share.
536    pub eps: Option<f64>,
537    /// Price-to-earnings ratio.
538    pub pe: Option<f64>,
539    /// Annual dividend per share.
540    pub dividend: Option<f64>,
541    /// Annual dividend yield as a percentage (e.g. `0.53` = 0.53%).
542    #[serde(rename = "yield")]
543    pub dividend_yield: Option<f64>,
544    /// Most recent ex-dividend date (ISO 8601).
545    pub ex_date: Option<String>,
546    /// Most recent dividend payment date (ISO 8601).
547    pub dividend_date: Option<String>,
548    /// Market capitalisation.
549    pub market_cap: Option<f64>,
550    /// GICS sector name (e.g. `"Technology"`).
551    pub industry_sector: Option<String>,
552    /// GICS industry group name.
553    pub industry_group: Option<String>,
554    /// GICS sub-industry name.
555    pub industry_sub_group: Option<String>,
556    /// For option symbols: `"Call"` or `"Put"`.
557    pub option_type: Option<String>,
558    /// For option symbols: expiry date (ISO 8601).
559    pub option_expiry: Option<String>,
560    /// For option symbols: strike price.
561    pub option_strike_price: Option<f64>,
562    /// For option symbols: exercise style — `"American"` or `"European"`.
563    pub option_exercise_type: Option<String>,
564}
565
566// ---------- Orders ----------
567
568/// Filter for order state when querying `GET /v1/accounts/:id/orders`.
569#[derive(Debug, Clone, Copy, Serialize)]
570pub enum OrderStateFilter {
571    /// Return all orders regardless of state.
572    All,
573    /// Return only open (working) orders.
574    Open,
575    /// Return only closed (filled, canceled, expired) orders.
576    Closed,
577}
578
579impl std::fmt::Display for OrderStateFilter {
580    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
581        match self {
582            Self::All => write!(f, "All"),
583            Self::Open => write!(f, "Open"),
584            Self::Closed => write!(f, "Closed"),
585        }
586    }
587}
588
589/// Response wrapper for `GET /v1/accounts/:id/orders`.
590#[derive(Debug, Deserialize)]
591#[serde(rename_all = "camelCase")]
592pub struct OrdersResponse {
593    /// Orders matching the query filters.
594    pub orders: Vec<OrderItem>,
595}
596
597/// A single order from the Questrade orders endpoint.
598#[derive(Debug, Clone, Deserialize, Serialize)]
599#[serde(rename_all = "camelCase")]
600pub struct OrderItem {
601    /// Internal order identifier.
602    pub id: u64,
603    /// Ticker symbol (e.g. `"AAPL"`).
604    pub symbol: String,
605    /// Questrade internal symbol ID.
606    pub symbol_id: u64,
607    /// Total quantity of the order.
608    #[serde(default)]
609    pub total_quantity: f64,
610    /// Quantity still open (unfilled).
611    #[serde(default)]
612    pub open_quantity: f64,
613    /// Quantity that has been filled.
614    #[serde(default)]
615    pub filled_quantity: f64,
616    /// Quantity that was canceled.
617    #[serde(default)]
618    pub canceled_quantity: f64,
619    /// Order side: `"Buy"`, `"Sell"`, `"BuyToOpen"`, `"SellToClose"`, etc.
620    pub side: String,
621    /// Order type: `"Market"`, `"Limit"`, `"Stop"`, `"StopLimit"`, etc.
622    pub order_type: String,
623    /// Limit price, if applicable.
624    #[serde(default)]
625    pub limit_price: Option<f64>,
626    /// Stop price, if applicable.
627    #[serde(default)]
628    pub stop_price: Option<f64>,
629    /// Average execution price across all fills.
630    #[serde(default)]
631    pub avg_exec_price: Option<f64>,
632    /// Price of the last execution.
633    #[serde(default)]
634    pub last_exec_price: Option<f64>,
635    /// Commission charged for the order.
636    #[serde(default)]
637    pub commission_charged: f64,
638    /// Current order state (e.g. `"Executed"`, `"Canceled"`, `"Pending"`, etc.).
639    pub state: String,
640    /// Time in force: `"Day"`, `"GoodTillCanceled"`, `"GoodTillDate"`, etc.
641    pub time_in_force: String,
642    /// ISO 8601 datetime when the order was created.
643    pub creation_time: String,
644    /// ISO 8601 datetime of the last update to the order.
645    pub update_time: String,
646    /// Questrade staff annotations.
647    #[serde(default)]
648    pub notes: Option<String>,
649    /// Whether the order is all-or-none.
650    #[serde(default)]
651    pub is_all_or_none: bool,
652    /// Whether the order is anonymous.
653    #[serde(default)]
654    pub is_anonymous: bool,
655    /// Order group ID for bracket orders.
656    #[serde(default)]
657    pub order_group_id: Option<u64>,
658    /// Chain ID linking related orders.
659    #[serde(default)]
660    pub chain_id: Option<u64>,
661}
662
663// ---------- Executions ----------
664
665/// Response wrapper for `GET /v1/accounts/:id/executions`.
666#[derive(Debug, Deserialize)]
667#[serde(rename_all = "camelCase")]
668pub struct ExecutionsResponse {
669    /// Execution records for the requested date range.
670    pub executions: Vec<Execution>,
671}
672
673/// A single trade execution (fill-level detail) from Questrade.
674#[derive(Debug, Clone, Deserialize, Serialize)]
675#[serde(rename_all = "camelCase")]
676pub struct Execution {
677    /// Ticker symbol (e.g. `"AAPL"`).
678    pub symbol: String,
679    /// Questrade internal symbol ID.
680    pub symbol_id: u64,
681    /// Number of shares or contracts filled.
682    pub quantity: f64,
683    /// Client side of the order: `"Buy"`, `"Sell"`, etc.
684    pub side: String,
685    /// Execution price per share or contract.
686    pub price: f64,
687    /// Internal execution identifier.
688    pub id: u64,
689    /// Internal order identifier.
690    pub order_id: u64,
691    /// Internal order chain identifier.
692    pub order_chain_id: u64,
693    /// Identifier of the execution at the originating exchange.
694    #[serde(default)]
695    pub exchange_exec_id: Option<String>,
696    /// Execution timestamp (ISO 8601).
697    pub timestamp: String,
698    /// Manual notes from Trade Desk staff (empty string if none).
699    #[serde(default)]
700    pub notes: Option<String>,
701    /// Trading venue where the execution originated (e.g. `"LAMP"`).
702    #[serde(default)]
703    pub venue: Option<String>,
704    /// Total cost: price × quantity.
705    #[serde(default, deserialize_with = "null_as_zero")]
706    pub total_cost: f64,
707    /// Trade Desk order placement commission.
708    #[serde(default, deserialize_with = "null_as_zero")]
709    pub order_placement_commission: f64,
710    /// Questrade commission.
711    #[serde(default, deserialize_with = "null_as_zero")]
712    pub commission: f64,
713    /// Venue liquidity execution fee.
714    #[serde(default, deserialize_with = "null_as_zero")]
715    pub execution_fee: f64,
716    /// SEC fee on US security sales.
717    #[serde(default, deserialize_with = "null_as_zero")]
718    pub sec_fee: f64,
719    /// TSX/Canadian execution fee.
720    #[serde(default, deserialize_with = "null_as_zero")]
721    pub canadian_execution_fee: f64,
722    /// Parent order identifier (0 if none).
723    #[serde(default)]
724    pub parent_id: u64,
725}
726
727// ---------- Candles ----------
728
729/// Response wrapper for `GET /v1/markets/candles/:id`.
730#[derive(Debug, Deserialize)]
731#[serde(rename_all = "camelCase")]
732pub struct CandleResponse {
733    /// OHLCV price bars in chronological order.
734    pub candles: Vec<Candle>,
735}
736
737/// A single OHLCV price bar.
738#[derive(Debug, Deserialize)]
739pub struct Candle {
740    /// Bar open time (ISO 8601).
741    pub start: String,
742    /// Bar close time (ISO 8601).
743    pub end: String,
744    /// Open price.
745    pub open: f64,
746    /// High price.
747    pub high: f64,
748    /// Low price.
749    pub low: f64,
750    /// Closing price.
751    pub close: f64,
752    /// Volume traded during the bar.
753    pub volume: u64,
754}
755
756#[cfg(test)]
757mod tests {
758    use super::*;
759
760    #[test]
761    fn markets_response_deserializes_from_questrade_json() {
762        // Representative response from GET /v1/markets (real Questrade format).
763        let json = r#"{
764            "markets": [
765                {
766                    "name": "NYSE",
767                    "tradingVenues": ["NYSE"],
768                    "defaultTradingVenue": "NYSE",
769                    "primaryOrderRoutes": ["NYSE"],
770                    "secondaryOrderRoutes": [],
771                    "level1Feeds": ["NYSE"],
772                    "level2Feeds": [],
773                    "extendedStartTime": "2026-02-21T08:00:00.000000-05:00",
774                    "startTime": "2026-02-21T09:30:00.000000-05:00",
775                    "endTime": "2026-02-21T16:00:00.000000-05:00",
776                    "extendedEndTime": "2026-02-21T20:00:00.000000-05:00",
777                    "currency": "USD",
778                    "snapshot": { "isOpen": true, "delay": 0 }
779                },
780                {
781                    "name": "TSX",
782                    "tradingVenues": ["TSX"],
783                    "defaultTradingVenue": "TSX",
784                    "primaryOrderRoutes": ["TSX"],
785                    "secondaryOrderRoutes": [],
786                    "level1Feeds": ["TSX"],
787                    "level2Feeds": [],
788                    "extendedStartTime": "2026-02-21T08:00:00.000000-05:00",
789                    "startTime": "2026-02-21T09:30:00.000000-05:00",
790                    "endTime": "2026-02-21T16:00:00.000000-05:00",
791                    "extendedEndTime": "2026-02-21T17:00:00.000000-05:00",
792                    "currency": "CAD",
793                    "snapshot": null
794                }
795            ]
796        }"#;
797
798        let resp: MarketsResponse = serde_json::from_str(json).unwrap();
799        assert_eq!(resp.markets.len(), 2);
800
801        let nyse = &resp.markets[0];
802        assert_eq!(nyse.name, "NYSE");
803        assert_eq!(nyse.currency.as_deref(), Some("USD"));
804        assert_eq!(
805            nyse.start_time.as_deref(),
806            Some("2026-02-21T09:30:00.000000-05:00")
807        );
808        assert_eq!(
809            nyse.end_time.as_deref(),
810            Some("2026-02-21T16:00:00.000000-05:00")
811        );
812        let snap = nyse.snapshot.as_ref().unwrap();
813        assert!(snap.is_open);
814        assert_eq!(snap.delay, 0);
815
816        // TSX has snapshot: null — should deserialise to None.
817        let tsx = &resp.markets[1];
818        assert_eq!(tsx.name, "TSX");
819        assert!(tsx.snapshot.is_none());
820    }
821
822    #[test]
823    fn account_balances_deserializes_from_questrade_json() {
824        let json = r#"{
825            "perCurrencyBalances": [
826                {
827                    "currency": "CAD",
828                    "cash": 10000.0,
829                    "marketValue": 50000.0,
830                    "totalEquity": 60000.0,
831                    "buyingPower": 60000.0,
832                    "maintenanceExcess": 60000.0,
833                    "isRealTime": false
834                }
835            ],
836            "combinedBalances": [
837                {
838                    "currency": "CAD",
839                    "cash": 10000.0,
840                    "marketValue": 50000.0,
841                    "totalEquity": 60000.0,
842                    "buyingPower": 60000.0,
843                    "maintenanceExcess": 60000.0,
844                    "isRealTime": false
845                }
846            ],
847            "sodPerCurrencyBalances": [
848                {
849                    "currency": "CAD",
850                    "cash": 9000.0,
851                    "marketValue": 49000.0,
852                    "totalEquity": 58000.0,
853                    "buyingPower": 58000.0,
854                    "maintenanceExcess": 58000.0,
855                    "isRealTime": false
856                }
857            ],
858            "sodCombinedBalances": [
859                {
860                    "currency": "CAD",
861                    "cash": 9000.0,
862                    "marketValue": 49000.0,
863                    "totalEquity": 58000.0,
864                    "buyingPower": 58000.0,
865                    "maintenanceExcess": 58000.0,
866                    "isRealTime": false
867                }
868            ]
869        }"#;
870
871        let balances: AccountBalances = serde_json::from_str(json).unwrap();
872        assert_eq!(balances.per_currency_balances.len(), 1);
873        let cad = &balances.per_currency_balances[0];
874        assert_eq!(cad.currency, "CAD");
875        assert_eq!(cad.cash, 10000.0);
876        assert_eq!(cad.market_value, 50000.0);
877        assert_eq!(cad.total_equity, 60000.0);
878        assert!(!cad.is_real_time);
879        assert_eq!(balances.combined_balances.len(), 1);
880        assert_eq!(balances.sod_per_currency_balances.len(), 1);
881        assert_eq!(balances.sod_combined_balances.len(), 1);
882    }
883
884    #[test]
885    fn symbol_detail_deserializes_from_questrade_json() {
886        let json = r#"{
887            "symbols": [
888                {
889                    "symbol": "AAPL",
890                    "symbolId": 8049,
891                    "description": "Apple Inc.",
892                    "securityType": "Stock",
893                    "listingExchange": "NASDAQ",
894                    "currency": "USD",
895                    "isTradable": true,
896                    "isQuotable": true,
897                    "hasOptions": true,
898                    "prevDayClosePrice": 182.50,
899                    "highPrice52": 199.62,
900                    "lowPrice52": 124.17,
901                    "averageVol3Months": 52000000,
902                    "averageVol20Days": 50000000,
903                    "outstandingShares": 15700000000,
904                    "eps": 6.14,
905                    "pe": 29.74,
906                    "dividend": 0.96,
907                    "yield": 0.53,
908                    "exDate": "2023-11-10T00:00:00.000000-05:00",
909                    "dividendDate": "2023-11-16T00:00:00.000000-05:00",
910                    "marketCap": 2866625000000.0,
911                    "industrySector": "Technology",
912                    "industryGroup": "Technology Hardware, Storage & Peripherals",
913                    "industrySubGroup": "Other",
914                    "optionType": null,
915                    "optionExpiry": null,
916                    "optionStrikePrice": null,
917                    "optionExerciseType": null
918                }
919            ]
920        }"#;
921
922        let resp: SymbolDetailResponse = serde_json::from_str(json).unwrap();
923        assert_eq!(resp.symbols.len(), 1);
924        let s = &resp.symbols[0];
925        assert_eq!(s.symbol, "AAPL");
926        assert_eq!(s.symbol_id, 8049);
927        assert_eq!(s.description, "Apple Inc.");
928        assert_eq!(s.security_type, "Stock");
929        assert_eq!(s.listing_exchange, "NASDAQ");
930        assert_eq!(s.currency, "USD");
931        assert!(s.is_tradable);
932        assert!(s.is_quotable);
933        assert!(s.has_options);
934        assert_eq!(s.prev_day_close_price, Some(182.50));
935        assert_eq!(s.high_price52, Some(199.62));
936        assert_eq!(s.low_price52, Some(124.17));
937        assert_eq!(s.eps, Some(6.14));
938        assert_eq!(s.dividend_yield, Some(0.53));
939        assert_eq!(s.industry_sector.as_deref(), Some("Technology"));
940        assert!(s.option_type.is_none());
941        assert!(s.option_expiry.is_none());
942    }
943
944    #[test]
945    fn orders_response_deserializes_from_questrade_json() {
946        let json = r#"{
947            "orders": [
948                {
949                    "id": 173577870,
950                    "symbol": "AAPL",
951                    "symbolId": 8049,
952                    "totalQuantity": 100,
953                    "openQuantity": 0,
954                    "filledQuantity": 100,
955                    "canceledQuantity": 0,
956                    "side": "Buy",
957                    "orderType": "Limit",
958                    "limitPrice": 150.50,
959                    "stopPrice": null,
960                    "avgExecPrice": 150.25,
961                    "lastExecPrice": 150.25,
962                    "commissionCharged": 4.95,
963                    "state": "Executed",
964                    "timeInForce": "Day",
965                    "creationTime": "2026-02-20T10:30:00.000000-05:00",
966                    "updateTime": "2026-02-20T10:31:15.000000-05:00",
967                    "notes": null,
968                    "isAllOrNone": false,
969                    "isAnonymous": false,
970                    "orderGroupId": 0,
971                    "chainId": 173577870
972                },
973                {
974                    "id": 173600001,
975                    "symbol": "MSFT",
976                    "symbolId": 9291,
977                    "totalQuantity": 50,
978                    "openQuantity": 50,
979                    "filledQuantity": 0,
980                    "canceledQuantity": 0,
981                    "side": "Buy",
982                    "orderType": "Limit",
983                    "limitPrice": 400.00,
984                    "stopPrice": null,
985                    "avgExecPrice": null,
986                    "lastExecPrice": null,
987                    "commissionCharged": 0,
988                    "state": "Pending",
989                    "timeInForce": "GoodTillCanceled",
990                    "creationTime": "2026-02-21T09:45:00.000000-05:00",
991                    "updateTime": "2026-02-21T09:45:00.000000-05:00",
992                    "notes": "Staff note here",
993                    "isAllOrNone": true,
994                    "isAnonymous": false
995                }
996            ]
997        }"#;
998
999        let resp: OrdersResponse = serde_json::from_str(json).unwrap();
1000        assert_eq!(resp.orders.len(), 2);
1001
1002        // Fully filled order
1003        let o1 = &resp.orders[0];
1004        assert_eq!(o1.id, 173577870);
1005        assert_eq!(o1.symbol, "AAPL");
1006        assert_eq!(o1.symbol_id, 8049);
1007        assert_eq!(o1.total_quantity, 100.0);
1008        assert_eq!(o1.open_quantity, 0.0);
1009        assert_eq!(o1.filled_quantity, 100.0);
1010        assert_eq!(o1.canceled_quantity, 0.0);
1011        assert_eq!(o1.side, "Buy");
1012        assert_eq!(o1.order_type, "Limit");
1013        assert_eq!(o1.limit_price, Some(150.50));
1014        assert!(o1.stop_price.is_none());
1015        assert_eq!(o1.avg_exec_price, Some(150.25));
1016        assert_eq!(o1.last_exec_price, Some(150.25));
1017        assert_eq!(o1.commission_charged, 4.95);
1018        assert_eq!(o1.state, "Executed");
1019        assert_eq!(o1.time_in_force, "Day");
1020        assert!(o1.notes.is_none());
1021        assert!(!o1.is_all_or_none);
1022        assert_eq!(o1.chain_id, Some(173577870));
1023
1024        // Pending order with optional fields missing
1025        let o2 = &resp.orders[1];
1026        assert_eq!(o2.id, 173600001);
1027        assert_eq!(o2.symbol, "MSFT");
1028        assert_eq!(o2.state, "Pending");
1029        assert_eq!(o2.time_in_force, "GoodTillCanceled");
1030        assert!(o2.avg_exec_price.is_none());
1031        assert!(o2.last_exec_price.is_none());
1032        assert_eq!(o2.commission_charged, 0.0);
1033        assert_eq!(o2.notes.as_deref(), Some("Staff note here"));
1034        assert!(o2.is_all_or_none);
1035        // Missing orderGroupId/chainId should default to None
1036        assert!(o2.order_group_id.is_none());
1037        assert!(o2.chain_id.is_none());
1038    }
1039
1040    #[test]
1041    fn execution_deserializes_from_questrade_json() {
1042        let json = r#"{
1043            "executions": [
1044                {
1045                    "symbol": "AAPL",
1046                    "symbolId": 8049,
1047                    "quantity": 10,
1048                    "side": "Buy",
1049                    "price": 536.87,
1050                    "id": 53817310,
1051                    "orderId": 177106005,
1052                    "orderChainId": 17710600,
1053                    "exchangeExecId": "XS1771060050147",
1054                    "timestamp": "2014-03-31T13:38:29.000000-04:00",
1055                    "notes": "",
1056                    "venue": "LAMP",
1057                    "totalCost": 5368.7,
1058                    "orderPlacementCommission": 0,
1059                    "commission": 4.95,
1060                    "executionFee": 0,
1061                    "secFee": 0,
1062                    "canadianExecutionFee": 0,
1063                    "parentId": 0
1064                }
1065            ]
1066        }"#;
1067
1068        let resp: ExecutionsResponse = serde_json::from_str(json).unwrap();
1069        assert_eq!(resp.executions.len(), 1);
1070
1071        let e = &resp.executions[0];
1072        assert_eq!(e.symbol, "AAPL");
1073        assert_eq!(e.symbol_id, 8049);
1074        assert_eq!(e.quantity, 10.0);
1075        assert_eq!(e.side, "Buy");
1076        assert_eq!(e.price, 536.87);
1077        assert_eq!(e.id, 53817310);
1078        assert_eq!(e.order_id, 177106005);
1079        assert_eq!(e.order_chain_id, 17710600);
1080        assert_eq!(e.exchange_exec_id.as_deref(), Some("XS1771060050147"));
1081        assert_eq!(e.timestamp, "2014-03-31T13:38:29.000000-04:00");
1082        assert_eq!(e.venue.as_deref(), Some("LAMP"));
1083        assert_eq!(e.total_cost, 5368.7);
1084        assert_eq!(e.commission, 4.95);
1085        assert_eq!(e.execution_fee, 0.0);
1086        assert_eq!(e.sec_fee, 0.0);
1087        assert_eq!(e.parent_id, 0);
1088    }
1089
1090    #[test]
1091    fn strategy_quotes_response_deserializes_from_questrade_json() {
1092        let json = r#"{
1093            "strategyQuotes": [
1094                {
1095                    "variantId": 1,
1096                    "bidPrice": 27.2,
1097                    "askPrice": 27.23,
1098                    "underlying": "MSFT",
1099                    "underlyingId": 9291,
1100                    "openPrice": 27.0,
1101                    "volatility": 0.30,
1102                    "delta": 1.0,
1103                    "gamma": 0.0,
1104                    "theta": -0.05,
1105                    "vega": 0.01,
1106                    "rho": 0.002,
1107                    "isRealTime": true
1108                }
1109            ]
1110        }"#;
1111
1112        let resp: StrategyQuotesResponse = serde_json::from_str(json).unwrap();
1113        assert_eq!(resp.strategy_quotes.len(), 1);
1114
1115        let q = &resp.strategy_quotes[0];
1116        assert_eq!(q.variant_id, 1);
1117        assert_eq!(q.bid_price, Some(27.2));
1118        assert_eq!(q.ask_price, Some(27.23));
1119        assert_eq!(q.underlying, "MSFT");
1120        assert_eq!(q.underlying_id, 9291);
1121        assert_eq!(q.open_price, Some(27.0));
1122        assert_eq!(q.volatility, Some(0.30));
1123        assert_eq!(q.delta, Some(1.0));
1124        assert_eq!(q.gamma, Some(0.0));
1125        assert_eq!(q.theta, Some(-0.05));
1126        assert_eq!(q.vega, Some(0.01));
1127        assert_eq!(q.rho, Some(0.002));
1128        assert!(q.is_real_time);
1129    }
1130
1131    #[test]
1132    fn strategy_quote_request_serializes_to_questrade_json() {
1133        let req = StrategyQuoteRequest {
1134            variants: vec![StrategyVariantRequest {
1135                variant_id: 1,
1136                strategy: "Custom".to_string(),
1137                legs: vec![
1138                    StrategyLeg {
1139                        symbol_id: 27426,
1140                        action: "Buy".to_string(),
1141                        ratio: 1000,
1142                    },
1143                    StrategyLeg {
1144                        symbol_id: 10550014,
1145                        action: "Sell".to_string(),
1146                        ratio: 10,
1147                    },
1148                ],
1149            }],
1150        };
1151
1152        let json = serde_json::to_value(&req).unwrap();
1153        let variants = json["variants"].as_array().unwrap();
1154        assert_eq!(variants.len(), 1);
1155        assert_eq!(variants[0]["variantId"], 1);
1156        assert_eq!(variants[0]["strategy"], "Custom");
1157        let legs = variants[0]["legs"].as_array().unwrap();
1158        assert_eq!(legs.len(), 2);
1159        assert_eq!(legs[0]["symbolId"], 27426);
1160        assert_eq!(legs[0]["action"], "Buy");
1161        assert_eq!(legs[0]["ratio"], 1000);
1162        assert_eq!(legs[1]["symbolId"], 10550014);
1163        assert_eq!(legs[1]["action"], "Sell");
1164        assert_eq!(legs[1]["ratio"], 10);
1165    }
1166}