Skip to main content

questrade_client/
client.rs

1//! [`QuestradeClient`] — async HTTP client for the Questrade REST API.
2
3use std::collections::HashMap;
4use std::time::Duration;
5
6use rand::Rng;
7use time::OffsetDateTime;
8use time::format_description::well_known::Iso8601;
9use tracing::{debug, error, trace, warn};
10
11use crate::api_types::*;
12use crate::auth::TokenManager;
13use crate::error::{QuestradeError, Result};
14use crate::rate_limit::RateLimiter;
15
16/// Overall request timeout (connect + read combined).
17const REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
18/// TCP connection establishment timeout.
19const CONNECT_TIMEOUT: Duration = Duration::from_secs(10);
20/// Maximum number of retries on 429 rate-limit responses.
21const MAX_RETRIES: u32 = 3;
22/// Base delay in milliseconds for exponential backoff (doubles each attempt).
23const RETRY_BASE_DELAY_MS: u64 = 1000;
24
25/// Compute exponential backoff delay for a given attempt (0-indexed) with ±20% jitter.
26///
27/// - attempt 0 → base ~1 s
28/// - attempt 1 → base ~2 s
29/// - attempt 2 → base ~4 s
30fn backoff_delay(attempt: u32) -> Duration {
31    let base_ms = RETRY_BASE_DELAY_MS << attempt; // 1000, 2000, 4000 ms
32    let jitter_factor = rand::thread_rng().gen_range(0.8f64..=1.2f64);
33    let delay_ms = (base_ms as f64 * jitter_factor) as u64;
34    Duration::from_millis(delay_ms)
35}
36
37/// Determine how long to wait before retrying a 429 response.
38///
39/// If the response contains a `Retry-After` header with a valid integer number
40/// of seconds, that value is used (capped at 60 s to avoid indefinite waits).
41/// Otherwise, falls back to [`backoff_delay`] for the given attempt number.
42fn retry_after_or_backoff(response: &reqwest::Response, attempt: u32) -> Duration {
43    if let Some(val) = response.headers().get(reqwest::header::RETRY_AFTER)
44        && let Ok(s) = val.to_str()
45        && let Ok(secs) = s.trim().parse::<u64>()
46    {
47        let capped = secs.min(60);
48        return Duration::from_secs(capped);
49    }
50    backoff_delay(attempt)
51}
52
53/// Format datetimes for Questrade query parameters using second precision in UTC.
54///
55/// Some endpoints reject long fractional-second timestamps with:
56/// `{"code":1003,"message":"Argument length exceeds imposed limit"}`.
57fn format_query_datetime(dt: OffsetDateTime) -> Result<String> {
58    let utc = dt.to_offset(time::UtcOffset::UTC);
59    let fmt = time::format_description::parse("[year]-[month]-[day]T[hour]:[minute]:[second]Z")
60        .map_err(|e| QuestradeError::DateTime {
61            context: "Failed to build datetime format".to_string(),
62            source: Box::new(e),
63        })?;
64    utc.format(&fmt).map_err(|e| QuestradeError::DateTime {
65        context: "Failed to format datetime for query parameter".to_string(),
66        source: Box::new(e),
67    })
68}
69
70/// Async HTTP client for the Questrade REST API.
71///
72/// Wraps a [`TokenManager`] for transparent OAuth token refresh and provides
73/// methods for market data (quotes, option chains, candles) and account data
74/// (positions, balances, activities).
75///
76/// Construct via [`QuestradeClient::new`] for defaults, or use
77/// [`QuestradeClientBuilder`] to supply a custom [`reqwest::Client`]
78/// (e.g. for custom TLS roots or proxy configuration):
79///
80/// ```no_run
81/// # use questrade_client::{QuestradeClientBuilder, TokenManager};
82/// # async fn example(tm: TokenManager) -> Result<(), Box<dyn std::error::Error>> {
83/// let custom_http = reqwest::Client::builder()
84///     .danger_accept_invalid_certs(true)
85///     .build()?;
86///
87/// let client = QuestradeClientBuilder::new()
88///     .token_manager(tm)
89///     .http_client(custom_http)
90///     .build()?;
91/// # Ok(())
92/// # }
93/// ```
94pub struct QuestradeClient {
95    http: reqwest::Client,
96    token_manager: TokenManager,
97    log_raw_responses: bool,
98    rate_limiter: RateLimiter,
99}
100
101/// Builder for [`QuestradeClient`] that allows injecting a custom
102/// [`reqwest::Client`] for TLS, proxy, or timeout configuration.
103///
104/// # Required
105///
106/// - [`token_manager`](Self::token_manager) — must be set before calling [`build`](Self::build).
107///
108/// # Optional
109///
110/// - [`http_client`](Self::http_client) — if omitted, a default client with
111///   30 s request timeout and 10 s connect timeout is created.
112///
113/// # Example
114///
115/// ```no_run
116/// # use questrade_client::{QuestradeClientBuilder, TokenManager};
117/// # async fn example(tm: TokenManager) -> Result<(), Box<dyn std::error::Error>> {
118/// let client = QuestradeClientBuilder::new()
119///     .token_manager(tm)
120///     .build()?;
121/// # Ok(())
122/// # }
123/// ```
124pub struct QuestradeClientBuilder {
125    token_manager: Option<TokenManager>,
126    http_client: Option<reqwest::Client>,
127}
128
129impl QuestradeClientBuilder {
130    /// Create a new builder with all fields unset.
131    pub fn new() -> Self {
132        Self {
133            token_manager: None,
134            http_client: None,
135        }
136    }
137
138    /// Set the [`TokenManager`] used for OAuth token management (required).
139    pub fn token_manager(mut self, tm: TokenManager) -> Self {
140        self.token_manager = Some(tm);
141        self
142    }
143
144    /// Provide a pre-configured [`reqwest::Client`] for HTTP requests.
145    ///
146    /// Use this to customise TLS roots, proxy settings, timeouts, or any
147    /// other [`reqwest::ClientBuilder`] option. When omitted, a default
148    /// client is created with a 30 s overall timeout and a 10 s connect
149    /// timeout.
150    pub fn http_client(mut self, client: reqwest::Client) -> Self {
151        self.http_client = Some(client);
152        self
153    }
154
155    /// Consume the builder and create a [`QuestradeClient`].
156    ///
157    /// # Errors
158    ///
159    /// Returns an error if:
160    /// - [`token_manager`](Self::token_manager) was not set.
161    /// - No custom HTTP client was provided and building the default client
162    ///   fails (e.g. TLS initialisation error).
163    pub fn build(self) -> Result<QuestradeClient> {
164        let token_manager = self.token_manager.ok_or_else(|| {
165            QuestradeError::EmptyResponse(
166                "QuestradeClientBuilder: token_manager is required".to_string(),
167            )
168        })?;
169
170        let http = match self.http_client {
171            Some(client) => client,
172            None => reqwest::Client::builder()
173                .timeout(REQUEST_TIMEOUT)
174                .connect_timeout(CONNECT_TIMEOUT)
175                .build()?,
176        };
177
178        Ok(QuestradeClient {
179            http,
180            token_manager,
181            log_raw_responses: false,
182            rate_limiter: RateLimiter::new(),
183        })
184    }
185}
186
187impl Default for QuestradeClientBuilder {
188    fn default() -> Self {
189        Self::new()
190    }
191}
192
193impl QuestradeClient {
194    /// Create a new client backed by the given [`TokenManager`].
195    ///
196    /// This is a convenience shorthand equivalent to:
197    ///
198    /// ```no_run
199    /// # use questrade_client::{QuestradeClientBuilder, TokenManager};
200    /// # fn example(token_manager: TokenManager) -> Result<(), Box<dyn std::error::Error>> {
201    /// let client = QuestradeClientBuilder::new()
202    ///     .token_manager(token_manager)
203    ///     .build()?;
204    /// # Ok(())
205    /// # }
206    /// ```
207    ///
208    /// # Errors
209    ///
210    /// Returns an error if the underlying HTTP client cannot be built
211    /// (e.g. TLS initialisation fails).
212    pub fn new(token_manager: TokenManager) -> Result<Self> {
213        QuestradeClientBuilder::new()
214            .token_manager(token_manager)
215            .build()
216    }
217
218    /// Enable or disable raw response body logging at `trace!` level.
219    ///
220    /// When enabled, `get()` and `post()` read the response body as text,
221    /// log it at `trace!` level, then deserialize from the string. When
222    /// disabled (the default), responses are deserialized directly from the
223    /// stream for zero overhead.
224    pub fn with_raw_logging(mut self, enabled: bool) -> Self {
225        self.log_raw_responses = enabled;
226        self
227    }
228
229    /// GET request with auth header.
230    ///
231    /// Before sending, checks the proactive rate limiter and waits if the
232    /// category's budget is exhausted. After each response, updates the
233    /// rate-limit state from `X-RateLimit-*` headers. Retries once on 401
234    /// Unauthorized after forcing a token refresh. Retries up to `MAX_RETRIES`
235    /// times on 429 responses — the proactive wait handles the delay when
236    /// rate-limit headers are present, otherwise falls back to `Retry-After`
237    /// or exponential backoff.
238    async fn get<T: serde::de::DeserializeOwned>(&self, path: &str) -> Result<T> {
239        let category = RateLimiter::classify(path);
240        let mut auth_retried = false;
241        loop {
242            let (token, api_server) = self.token_manager.get_token().await?;
243            let url = format!("{}v1{}", api_server, path);
244            debug!(method = "GET", endpoint = %url, "HTTP request");
245
246            let resp = {
247                let mut attempt = 0u32;
248                loop {
249                    if let Some(wait) = self.rate_limiter.wait_duration(category) {
250                        debug!(category = %category, wait = ?wait, "sleeping until rate-limit window resets");
251                        tokio::time::sleep(wait).await;
252                    }
253
254                    let resp = match self.http.get(&url).bearer_auth(&token).send().await {
255                        Ok(r) => r,
256                        Err(e) => {
257                            error!(method = "GET", endpoint = %url, err = %e, "HTTP send failed");
258                            return Err(e.into());
259                        }
260                    };
261                    self.rate_limiter
262                        .update_from_headers(category, resp.headers());
263
264                    debug!(method = "GET", endpoint = %url, status = %resp.status(), "HTTP response");
265
266                    if resp.status() == reqwest::StatusCode::TOO_MANY_REQUESTS {
267                        if attempt < MAX_RETRIES {
268                            // If the rate limiter learned a wait duration from headers,
269                            // the pre-check at the top of this loop handles the delay.
270                            // Otherwise, fall back to Retry-After / exponential backoff.
271                            if self.rate_limiter.wait_duration(category).is_none() {
272                                let delay = retry_after_or_backoff(&resp, attempt);
273                                warn!(attempt = attempt + 1, delay = ?delay, reason = "429", "rate limited: 429 response, no rate-limit headers, backing off");
274                                tokio::time::sleep(delay).await;
275                            }
276                            attempt += 1;
277                            continue;
278                        }
279                        return Err(QuestradeError::RateLimited {
280                            retries: MAX_RETRIES,
281                        });
282                    }
283
284                    break resp;
285                }
286            };
287
288            if resp.status() == reqwest::StatusCode::UNAUTHORIZED && !auth_retried {
289                warn!("received 401 Unauthorized, forcing token refresh and retrying");
290                self.token_manager.force_refresh().await?;
291                auth_retried = true;
292                continue;
293            }
294
295            if !resp.status().is_success() {
296                let status = resp.status();
297                let body = resp.text().await.unwrap_or_default();
298                error!(method = "GET", endpoint = %url, status = %status, body = %body, "HTTP error response");
299                return Err(QuestradeError::Api { status, body });
300            }
301
302            if self.log_raw_responses {
303                let text = resp.text().await?;
304                trace!(method = "GET", endpoint = %url, body = %text, "raw response");
305                return Ok(serde_json::from_str(&text)?);
306            } else {
307                return Ok(resp.json().await?);
308            }
309        }
310    }
311
312    /// POST request with auth header and JSON body.
313    ///
314    /// Same rate-limit, auth-retry, and 429-retry behaviour as `get()`.
315    async fn post<T: serde::de::DeserializeOwned, B: serde::Serialize>(
316        &self,
317        path: &str,
318        body: &B,
319    ) -> Result<T> {
320        let category = RateLimiter::classify(path);
321        let mut auth_retried = false;
322        loop {
323            let (token, api_server) = self.token_manager.get_token().await?;
324            let url = format!("{}v1{}", api_server, path);
325            debug!(method = "POST", endpoint = %url, "HTTP request");
326
327            let resp = {
328                let mut attempt = 0u32;
329                loop {
330                    if let Some(wait) = self.rate_limiter.wait_duration(category) {
331                        debug!(category = %category, wait = ?wait, "sleeping until rate-limit window resets");
332                        tokio::time::sleep(wait).await;
333                    }
334
335                    let resp = match self
336                        .http
337                        .post(&url)
338                        .bearer_auth(&token)
339                        .json(body)
340                        .send()
341                        .await
342                    {
343                        Ok(r) => r,
344                        Err(e) => {
345                            error!(method = "POST", endpoint = %url, err = %e, "HTTP send failed");
346                            return Err(e.into());
347                        }
348                    };
349                    self.rate_limiter
350                        .update_from_headers(category, resp.headers());
351
352                    debug!(method = "POST", endpoint = %url, status = %resp.status(), "HTTP response");
353
354                    if resp.status() == reqwest::StatusCode::TOO_MANY_REQUESTS {
355                        if attempt < MAX_RETRIES {
356                            if self.rate_limiter.wait_duration(category).is_none() {
357                                let delay = retry_after_or_backoff(&resp, attempt);
358                                warn!(attempt = attempt + 1, delay = ?delay, reason = "429", "rate limited: 429 response (POST), no rate-limit headers, backing off");
359                                tokio::time::sleep(delay).await;
360                            }
361                            attempt += 1;
362                            continue;
363                        }
364                        return Err(QuestradeError::RateLimited {
365                            retries: MAX_RETRIES,
366                        });
367                    }
368
369                    break resp;
370                }
371            };
372
373            if resp.status() == reqwest::StatusCode::UNAUTHORIZED && !auth_retried {
374                warn!("received 401 Unauthorized, forcing token refresh and retrying");
375                self.token_manager.force_refresh().await?;
376                auth_retried = true;
377                continue;
378            }
379
380            if !resp.status().is_success() {
381                let status = resp.status();
382                let body_text = resp.text().await.unwrap_or_default();
383                error!(method = "POST", endpoint = %url, status = %status, body = %body_text, "HTTP error response");
384                return Err(QuestradeError::Api {
385                    status,
386                    body: body_text,
387                });
388            }
389
390            if self.log_raw_responses {
391                let text = resp.text().await?;
392                trace!(method = "POST", endpoint = %url, body = %text, "raw response");
393                return Ok(serde_json::from_str(&text)?);
394            } else {
395                return Ok(resp.json().await?);
396            }
397        }
398    }
399
400    /// GET request that returns the raw response body as a string.
401    ///
402    /// Same rate-limit, auth-retry, and 429-retry behaviour as `get()`
403    /// but returns the response body as-is without deserializing. Useful for
404    /// inspecting raw API responses during development.
405    pub async fn get_text(&self, path: &str) -> Result<String> {
406        let category = RateLimiter::classify(path);
407        let mut auth_retried = false;
408        loop {
409            let (token, api_server) = self.token_manager.get_token().await?;
410            let url = format!("{}v1{}", api_server, path);
411            debug!(method = "GET", endpoint = %url, "HTTP request (text)");
412
413            let resp = {
414                let mut attempt = 0u32;
415                loop {
416                    if let Some(wait) = self.rate_limiter.wait_duration(category) {
417                        debug!(category = %category, wait = ?wait, "sleeping until rate-limit window resets");
418                        tokio::time::sleep(wait).await;
419                    }
420
421                    let resp = match self.http.get(&url).bearer_auth(&token).send().await {
422                        Ok(r) => r,
423                        Err(e) => {
424                            error!(method = "GET", endpoint = %url, err = %e, "HTTP send failed (text)");
425                            return Err(e.into());
426                        }
427                    };
428                    self.rate_limiter
429                        .update_from_headers(category, resp.headers());
430
431                    debug!(method = "GET", endpoint = %url, status = %resp.status(), "HTTP response (text)");
432
433                    if resp.status() == reqwest::StatusCode::TOO_MANY_REQUESTS {
434                        if attempt < MAX_RETRIES {
435                            if self.rate_limiter.wait_duration(category).is_none() {
436                                let delay = retry_after_or_backoff(&resp, attempt);
437                                warn!(attempt = attempt + 1, delay = ?delay, reason = "429", "rate limited: 429 response, no rate-limit headers, backing off");
438                                tokio::time::sleep(delay).await;
439                            }
440                            attempt += 1;
441                            continue;
442                        }
443                        return Err(QuestradeError::RateLimited {
444                            retries: MAX_RETRIES,
445                        });
446                    }
447
448                    break resp;
449                }
450            };
451
452            if resp.status() == reqwest::StatusCode::UNAUTHORIZED && !auth_retried {
453                warn!("received 401 Unauthorized, forcing token refresh and retrying");
454                self.token_manager.force_refresh().await?;
455                auth_retried = true;
456                continue;
457            }
458
459            if !resp.status().is_success() {
460                let status = resp.status();
461                let body = resp.text().await.unwrap_or_default();
462                error!(method = "GET", endpoint = %url, status = %status, body = %body, "HTTP error response (text)");
463                return Err(QuestradeError::Api { status, body });
464            }
465
466            return Ok(resp.text().await?);
467        }
468    }
469
470    /// Parse a Questrade datetime string to `OffsetDateTime`.
471    ///
472    /// Questrade returns datetimes like `"2014-10-24T20:06:40.131000-04:00"`.
473    pub fn parse_datetime(s: &str) -> Result<OffsetDateTime> {
474        OffsetDateTime::parse(s, &Iso8601::DEFAULT).map_err(|e| QuestradeError::DateTime {
475            context: format!("Failed to parse datetime: {}", s),
476            source: Box::new(e),
477        })
478    }
479
480    /// Parse a Questrade datetime to just a `time::Date` (for option expiry).
481    pub fn parse_date(s: &str) -> Result<time::Date> {
482        let dt = Self::parse_datetime(s)?;
483        Ok(dt.date())
484    }
485
486    /// Resolve a ticker string to a Questrade symbol ID.
487    pub async fn resolve_symbol(&self, ticker: &str) -> Result<u64> {
488        let key = ticker.to_uppercase();
489        let resp: SymbolSearchResponse =
490            self.get(&format!("/symbols/search?prefix={}", key)).await?;
491        let symbol = resp
492            .symbols
493            .into_iter()
494            .find(|s| s.symbol.to_uppercase() == key)
495            .ok_or_else(|| QuestradeError::SymbolNotFound(ticker.to_string()))?;
496        Ok(symbol.symbol_id)
497    }
498
499    /// Fetch a raw equity quote by symbol ID.
500    pub async fn get_raw_quote(&self, symbol_id: u64) -> Result<Quote> {
501        let resp: QuoteResponse = self.get(&format!("/markets/quotes/{}", symbol_id)).await?;
502        resp.quotes
503            .into_iter()
504            .next()
505            .ok_or_else(|| QuestradeError::EmptyResponse("No quote returned".to_string()))
506    }
507
508    /// Fetch raw equity quotes for multiple symbol IDs in a single API call.
509    ///
510    /// Uses `GET /v1/markets/quotes?ids=...` with comma-separated IDs.
511    /// Returns quotes in arbitrary order; callers should match on `symbol_id`.
512    pub async fn get_raw_quotes(&self, symbol_ids: &[u64]) -> Result<Vec<Quote>> {
513        if symbol_ids.is_empty() {
514            return Ok(vec![]);
515        }
516        let ids = symbol_ids
517            .iter()
518            .map(|id| id.to_string())
519            .collect::<Vec<_>>()
520            .join(",");
521        let resp: QuoteResponse = self.get(&format!("/markets/quotes?ids={}", ids)).await?;
522        Ok(resp.quotes)
523    }
524
525    /// Fetch the option chain structure (expiries + strikes + symbol IDs) for a symbol.
526    pub async fn get_option_chain_structure(&self, symbol_id: u64) -> Result<OptionChainResponse> {
527        self.get(&format!("/symbols/{}/options", symbol_id)).await
528    }
529
530    /// Fetch current quotes for a set of option symbol IDs.
531    /// Returns a map of symbol_id -> (bid, ask).
532    pub async fn get_option_quotes_by_ids(
533        &self,
534        symbol_ids: &[u64],
535    ) -> Result<HashMap<u64, (f64, f64)>> {
536        let mut result = HashMap::new();
537        for chunk in symbol_ids.chunks(100) {
538            let req = OptionQuoteRequest {
539                option_ids: chunk.to_vec(),
540            };
541            let resp: OptionQuoteResponse = self.post("/markets/quotes/options", &req).await?;
542            for oq in resp.option_quotes {
543                result.insert(
544                    oq.symbol_id,
545                    (oq.bid_price.unwrap_or(0.0), oq.ask_price.unwrap_or(0.0)),
546                );
547            }
548        }
549        Ok(result)
550    }
551
552    /// Fetch full option quote objects for a set of option symbol IDs (in batches).
553    pub async fn get_option_quotes_raw(&self, ids: &[u64]) -> Result<Vec<OptionQuote>> {
554        let mut result = Vec::new();
555        for chunk in ids.chunks(100) {
556            let req = OptionQuoteRequest {
557                option_ids: chunk.to_vec(),
558            };
559            let resp: OptionQuoteResponse = self.post("/markets/quotes/options", &req).await?;
560            result.extend(resp.option_quotes);
561        }
562        Ok(result)
563    }
564
565    /// Fetch combined quotes for multi-leg option strategy variants.
566    ///
567    /// Posts the given variants to `POST /v1/markets/quotes/strategies` and
568    /// returns the strategy quotes. Each variant's `variant_id` is echoed in
569    /// the response for caller-side matching.
570    pub async fn get_strategy_quotes(
571        &self,
572        variants: &[StrategyVariantRequest],
573    ) -> Result<Vec<StrategyQuote>> {
574        let req = StrategyQuoteRequest {
575            variants: variants.to_vec(),
576        };
577        let resp: StrategyQuotesResponse = self.post("/markets/quotes/strategies", &req).await?;
578        Ok(resp.strategy_quotes)
579    }
580
581    /// Fetch historical candles for a symbol.
582    pub async fn get_candles(
583        &self,
584        symbol_id: u64,
585        start: OffsetDateTime,
586        end: OffsetDateTime,
587        interval: &str,
588    ) -> Result<Vec<Candle>> {
589        let start_str = format_query_datetime(start)?;
590        let end_str = format_query_datetime(end)?;
591        let resp: CandleResponse = self
592            .get(&format!(
593                "/markets/candles/{}?startTime={}&endTime={}&interval={}",
594                symbol_id, start_str, end_str, interval
595            ))
596            .await?;
597        Ok(resp.candles)
598    }
599
600    /// Fetch the current server time from Questrade.
601    ///
602    /// Uses `GET /v1/time`. Not cached — real-time by definition.
603    pub async fn get_server_time(&self) -> Result<OffsetDateTime> {
604        let resp: ServerTimeResponse = self.get("/time").await?;
605        Self::parse_datetime(&resp.time)
606    }
607
608    /// Fetch all accounts for the authenticated user.
609    pub async fn get_accounts(&self) -> Result<Vec<Account>> {
610        let resp: AccountsResponse = self.get("/accounts").await?;
611        Ok(resp.accounts)
612    }
613
614    /// Fetch positions for a specific account.
615    pub async fn get_positions(&self, account_id: &str) -> Result<Vec<PositionItem>> {
616        let resp: PositionsResponse = self
617            .get(&format!("/accounts/{}/positions", account_id))
618            .await?;
619        Ok(resp.positions)
620    }
621
622    /// Fetch current and start-of-day balances for a specific account.
623    pub async fn get_account_balances(&self, account_id: &str) -> Result<AccountBalances> {
624        self.get(&format!("/accounts/{}/balances", account_id))
625            .await
626    }
627
628    /// Fetch metadata for all markets (trading hours, open/closed status).
629    pub async fn get_markets(&self) -> Result<Vec<crate::api_types::MarketInfo>> {
630        let resp: crate::api_types::MarketsResponse = self.get("/markets").await?;
631        Ok(resp.markets)
632    }
633
634    /// Fetch full symbol details by numeric ID via `GET /v1/symbols/:id`.
635    pub async fn get_symbol(&self, symbol_id: u64) -> Result<SymbolDetail> {
636        let resp: SymbolDetailResponse = self.get(&format!("/symbols/{}", symbol_id)).await?;
637        resp.symbols.into_iter().next().ok_or_else(|| {
638            QuestradeError::EmptyResponse(format!("No symbol returned for id {}", symbol_id))
639        })
640    }
641
642    /// Fetch full symbol details for multiple IDs in a single API call.
643    ///
644    /// Uses `GET /v1/symbols?ids=...` with comma-separated IDs.
645    /// Returns details in arbitrary order; callers should match on `symbol_id`.
646    pub async fn get_symbols(&self, symbol_ids: &[u64]) -> Result<Vec<SymbolDetail>> {
647        if symbol_ids.is_empty() {
648            return Ok(vec![]);
649        }
650        let ids = symbol_ids
651            .iter()
652            .map(|id| id.to_string())
653            .collect::<Vec<_>>()
654            .join(",");
655        let resp: SymbolDetailResponse = self.get(&format!("/symbols?ids={}", ids)).await?;
656        Ok(resp.symbols)
657    }
658
659    /// Fetch account activities (executions, dividends, etc.) for a date range.
660    ///
661    /// Questrade limits queries to 31-day windows per request; we use 30-day
662    /// windows to stay safely within the boundary. This method transparently
663    /// splits any range longer than 30 days into compliant sub-windows and
664    /// combines the results, sorted by `trade_date` ascending.
665    pub async fn get_activities(
666        &self,
667        account_id: &str,
668        start: OffsetDateTime,
669        end: OffsetDateTime,
670    ) -> Result<Vec<ActivityItem>> {
671        let windows = activity_windows(start, end);
672        let mut all = Vec::new();
673        for (w_start, w_end) in windows {
674            let start_str = format_query_datetime(w_start)?;
675            let end_str = format_query_datetime(w_end)?;
676            let resp: ActivitiesResponse = self
677                .get(&format!(
678                    "/accounts/{}/activities?startTime={}&endTime={}",
679                    account_id, start_str, end_str,
680                ))
681                .await?;
682            all.extend(resp.activities);
683        }
684        all.sort_by(|a, b| a.trade_date.cmp(&b.trade_date));
685        Ok(all)
686    }
687
688    /// Fetch orders for a specific account within a date range.
689    ///
690    /// Use `state_filter` to limit results to open, closed, or all orders.
691    /// Unlike activities, there is no documented date-range window limit for
692    /// this endpoint.
693    pub async fn get_orders(
694        &self,
695        account_id: &str,
696        start: OffsetDateTime,
697        end: OffsetDateTime,
698        state_filter: OrderStateFilter,
699    ) -> Result<Vec<OrderItem>> {
700        let start_str = format_query_datetime(start)?;
701        let end_str = format_query_datetime(end)?;
702        let resp: OrdersResponse = self
703            .get(&format!(
704                "/accounts/{}/orders?startTime={}&endTime={}&stateFilter={}",
705                account_id, start_str, end_str, state_filter,
706            ))
707            .await?;
708        Ok(resp.orders)
709    }
710
711    /// Fetch trade executions (fill-level detail) for a date range.
712    ///
713    /// Uses 30-day windowing, same as [`get_activities`](Self::get_activities).
714    /// Results are sorted by `timestamp` ascending.
715    pub async fn get_executions(
716        &self,
717        account_id: &str,
718        start: OffsetDateTime,
719        end: OffsetDateTime,
720    ) -> Result<Vec<Execution>> {
721        let windows = activity_windows(start, end);
722        let mut all = Vec::new();
723        for (w_start, w_end) in windows {
724            let start_str = format_query_datetime(w_start)?;
725            let end_str = format_query_datetime(w_end)?;
726            let resp: ExecutionsResponse = self
727                .get(&format!(
728                    "/accounts/{}/executions?startTime={}&endTime={}",
729                    account_id, start_str, end_str,
730                ))
731                .await?;
732            all.extend(resp.executions);
733        }
734        all.sort_by(|a, b| a.timestamp.cmp(&b.timestamp));
735        Ok(all)
736    }
737}
738
739/// Split a date range into ≤30-day windows for Questrade's activities endpoint.
740///
741/// Questrade documents a "maximum 31 days" range, but live testing (Feb 2026)
742/// shows the actual limit is **31 calendar days in Eastern Time**, measured from
743/// midnight ET. For example, at 16:52 ET the API rejects a start time only
744/// 30 d 17 h earlier (past midnight ET 31 days ago) while accepting 30 d 16 h 30 m.
745///
746/// Using 30-day windows keeps us a full calendar day inside the limit regardless
747/// of the caller's timezone or time of day, with no observable cost (one extra
748/// API call per year of history).
749///
750/// Returns windows as `(start, end)` pairs in chronological order.
751/// Returns an empty `Vec` if `start >= end`.
752fn activity_windows(
753    start: OffsetDateTime,
754    end: OffsetDateTime,
755) -> Vec<(OffsetDateTime, OffsetDateTime)> {
756    const MAX_WINDOW: time::Duration = time::Duration::days(30);
757    let mut windows = Vec::new();
758    let mut cursor = start;
759    while cursor < end {
760        let window_end = (cursor + MAX_WINDOW).min(end);
761        windows.push((cursor, window_end));
762        cursor = window_end;
763    }
764    windows
765}
766
767#[cfg(test)]
768mod tests {
769    use super::*;
770    use crate::auth::{CachedToken, TokenManager};
771    use time::OffsetDateTime;
772    use wiremock::matchers::{header, method, path};
773    use wiremock::{Mock, MockServer, ResponseTemplate};
774
775    #[test]
776    fn server_time_response_deserializes() {
777        let json = r#"{"time":"2026-02-21T14:32:00.000000-05:00"}"#;
778        let resp: ServerTimeResponse = serde_json::from_str(json).unwrap();
779        assert_eq!(resp.time, "2026-02-21T14:32:00.000000-05:00");
780    }
781
782    #[test]
783    fn parse_server_time_returns_correct_fields() {
784        let json = r#"{"time":"2026-02-21T14:32:00.000000-05:00"}"#;
785        let resp: ServerTimeResponse = serde_json::from_str(json).unwrap();
786        let dt = QuestradeClient::parse_datetime(&resp.time).unwrap();
787        assert_eq!(dt.year(), 2026);
788        assert_eq!(dt.month(), time::Month::February);
789        assert_eq!(dt.day(), 21);
790        assert_eq!(dt.hour(), 14);
791        assert_eq!(dt.minute(), 32);
792        assert_eq!(dt.second(), 0);
793        assert_eq!(dt.offset().whole_hours(), -5);
794    }
795
796    #[test]
797    fn format_query_datetime_uses_utc_second_precision() {
798        let dt = OffsetDateTime::parse("2026-02-24T03:58:12.123456789-05:00", &Iso8601::DEFAULT)
799            .unwrap();
800        let s = format_query_datetime(dt).unwrap();
801        assert_eq!(s, "2026-02-24T08:58:12Z");
802        assert!(!s.contains('.'));
803    }
804
805    #[test]
806    fn backoff_delay_within_jitter_bounds() {
807        for attempt in 0..MAX_RETRIES {
808            for _ in 0..20 {
809                let delay = backoff_delay(attempt);
810                let base_ms = RETRY_BASE_DELAY_MS << attempt;
811                let min_ms = (base_ms as f64 * 0.8) as u64;
812                let max_ms = (base_ms as f64 * 1.2) as u64;
813                let actual_ms = delay.as_millis() as u64;
814                assert!(
815                    actual_ms >= min_ms && actual_ms <= max_ms,
816                    "attempt {attempt}: delay {actual_ms}ms not in [{min_ms}, {max_ms}]"
817                );
818            }
819        }
820    }
821
822    #[test]
823    fn backoff_delay_doubles_each_attempt() {
824        for attempt in 1..MAX_RETRIES {
825            let prev_base = RETRY_BASE_DELAY_MS << (attempt - 1);
826            let curr_base = RETRY_BASE_DELAY_MS << attempt;
827            assert_eq!(
828                curr_base,
829                prev_base * 2,
830                "base delay should double from attempt {} to {}",
831                attempt - 1,
832                attempt
833            );
834        }
835    }
836
837    #[test]
838    fn max_retries_constant() {
839        assert_eq!(MAX_RETRIES, 3, "expected 3 retries");
840    }
841
842    // --- activity_windows ---
843
844    fn dt(s: &str) -> OffsetDateTime {
845        OffsetDateTime::parse(s, &Iso8601::DEFAULT).unwrap()
846    }
847
848    #[test]
849    fn activity_windows_empty_range_returns_empty() {
850        let start = dt("2026-01-01T00:00:00Z");
851        assert!(activity_windows(start, start).is_empty());
852        // end before start also empty
853        assert!(activity_windows(start, start - time::Duration::days(1)).is_empty());
854    }
855
856    #[test]
857    fn activity_windows_single_window_when_range_within_31_days() {
858        let start = dt("2026-01-01T00:00:00Z");
859        let end = start + time::Duration::days(30);
860        let windows = activity_windows(start, end);
861        assert_eq!(windows.len(), 1);
862        assert_eq!(windows[0], (start, end));
863    }
864
865    #[test]
866    fn activity_windows_exactly_30_days_is_single_window() {
867        let start = dt("2026-01-01T00:00:00Z");
868        let end = start + time::Duration::days(30);
869        let windows = activity_windows(start, end);
870        assert_eq!(windows.len(), 1);
871        assert_eq!(windows[0], (start, end));
872    }
873
874    #[test]
875    fn activity_windows_31_days_splits_into_two() {
876        let start = dt("2026-01-01T00:00:00Z");
877        let end = start + time::Duration::days(31);
878        let windows = activity_windows(start, end);
879        assert_eq!(windows.len(), 2);
880        assert_eq!(windows[0], (start, start + time::Duration::days(30)));
881        assert_eq!(windows[1], (start + time::Duration::days(30), end));
882    }
883
884    #[test]
885    fn activity_windows_365_days_all_within_limit_and_contiguous() {
886        let start = dt("2026-01-01T00:00:00Z");
887        let end = start + time::Duration::days(365);
888        let windows = activity_windows(start, end);
889        // 365 / 30 = 12 full + 5 remaining = 13
890        assert_eq!(windows.len(), 13);
891        assert_eq!(windows[0].0, start);
892        assert_eq!(windows.last().unwrap().1, end);
893        for (ws, we) in &windows {
894            assert!(
895                (*we - *ws).whole_days() <= 30,
896                "window exceeds 30 days: {} days",
897                (*we - *ws).whole_days()
898            );
899        }
900        // Contiguous: each window starts where the previous ended
901        for i in 1..windows.len() {
902            assert_eq!(
903                windows[i].0,
904                windows[i - 1].1,
905                "gap between window {i} and {}",
906                i - 1
907            );
908        }
909    }
910
911    // --- 401 retry tests ---
912
913    #[tokio::test]
914    async fn get_retries_on_401_after_force_refresh() {
915        let server = MockServer::start().await;
916        let api_server = format!("{}/", server.uri());
917
918        // First API call with stale token → 401.
919        Mock::given(method("GET"))
920            .and(path("/v1/time"))
921            .and(header("Authorization", "Bearer stale_token"))
922            .respond_with(ResponseTemplate::new(401))
923            .expect(1)
924            .named("stale request")
925            .mount(&server)
926            .await;
927
928        // OAuth refresh → new token (api_server stays the same mock).
929        Mock::given(method("GET"))
930            .and(path("/oauth2/token"))
931            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
932                "access_token": "fresh_token",
933                "token_type": "Bearer",
934                "expires_in": 1800,
935                "refresh_token": "new_rt",
936                "api_server": api_server,
937            })))
938            .expect(1)
939            .named("oauth refresh")
940            .mount(&server)
941            .await;
942
943        // Retry with fresh token → success.
944        Mock::given(method("GET"))
945            .and(path("/v1/time"))
946            .and(header("Authorization", "Bearer fresh_token"))
947            .respond_with(
948                ResponseTemplate::new(200)
949                    .set_body_json(serde_json::json!({"time": "2026-03-02T12:00:00.000000-05:00"})),
950            )
951            .expect(1)
952            .named("fresh request")
953            .mount(&server)
954            .await;
955
956        // Build client with stale cached token.
957        let cached = CachedToken {
958            access_token: "stale_token".to_string(),
959            api_server: api_server.clone(),
960            expires_at: OffsetDateTime::now_utc() + time::Duration::minutes(25),
961        };
962        let tm = TokenManager::new_with_login_url(
963            "old_rt".to_string(),
964            None,
965            server.uri(),
966            Some(cached),
967        )
968        .await
969        .unwrap();
970
971        let client = QuestradeClient::new(tm).unwrap();
972        let time = client.get_server_time().await.unwrap();
973        assert_eq!(time.year(), 2026);
974    }
975
976    #[tokio::test]
977    async fn get_does_not_retry_401_more_than_once() {
978        let server = MockServer::start().await;
979        let api_server = format!("{}/", server.uri());
980
981        // API always returns 401.
982        Mock::given(method("GET"))
983            .and(path("/v1/time"))
984            .respond_with(ResponseTemplate::new(401).set_body_string("Unauthorized"))
985            .expect(2) // initial + one retry = 2
986            .mount(&server)
987            .await;
988
989        // OAuth refresh succeeds (but the new token is still rejected).
990        Mock::given(method("GET"))
991            .and(path("/oauth2/token"))
992            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
993                "access_token": "still_bad",
994                "token_type": "Bearer",
995                "expires_in": 1800,
996                "refresh_token": "new_rt",
997                "api_server": api_server,
998            })))
999            .expect(1)
1000            .mount(&server)
1001            .await;
1002
1003        let cached = CachedToken {
1004            access_token: "stale_token".to_string(),
1005            api_server,
1006            expires_at: OffsetDateTime::now_utc() + time::Duration::minutes(25),
1007        };
1008        let tm = TokenManager::new_with_login_url(
1009            "old_rt".to_string(),
1010            None,
1011            server.uri(),
1012            Some(cached),
1013        )
1014        .await
1015        .unwrap();
1016
1017        let client = QuestradeClient::new(tm).unwrap();
1018        let result = client.get_server_time().await;
1019        assert!(result.is_err());
1020        assert!(
1021            result.unwrap_err().to_string().contains("401"),
1022            "error should mention 401"
1023        );
1024    }
1025
1026    #[tokio::test]
1027    async fn post_retries_on_401_after_force_refresh() {
1028        let server = MockServer::start().await;
1029        let api_server = format!("{}/", server.uri());
1030
1031        // First POST with stale token → 401.
1032        Mock::given(method("POST"))
1033            .and(path("/v1/markets/quotes/options"))
1034            .and(header("Authorization", "Bearer stale_token"))
1035            .respond_with(ResponseTemplate::new(401))
1036            .expect(1)
1037            .named("stale post")
1038            .mount(&server)
1039            .await;
1040
1041        // OAuth refresh → new token.
1042        Mock::given(method("GET"))
1043            .and(path("/oauth2/token"))
1044            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
1045                "access_token": "fresh_token",
1046                "token_type": "Bearer",
1047                "expires_in": 1800,
1048                "refresh_token": "new_rt",
1049                "api_server": api_server,
1050            })))
1051            .expect(1)
1052            .named("oauth refresh")
1053            .mount(&server)
1054            .await;
1055
1056        // Retry POST with fresh token → success.
1057        Mock::given(method("POST"))
1058            .and(path("/v1/markets/quotes/options"))
1059            .and(header("Authorization", "Bearer fresh_token"))
1060            .respond_with(
1061                ResponseTemplate::new(200).set_body_json(serde_json::json!({"optionQuotes": []})),
1062            )
1063            .expect(1)
1064            .named("fresh post")
1065            .mount(&server)
1066            .await;
1067
1068        let cached = CachedToken {
1069            access_token: "stale_token".to_string(),
1070            api_server,
1071            expires_at: OffsetDateTime::now_utc() + time::Duration::minutes(25),
1072        };
1073        let tm = TokenManager::new_with_login_url(
1074            "old_rt".to_string(),
1075            None,
1076            server.uri(),
1077            Some(cached),
1078        )
1079        .await
1080        .unwrap();
1081
1082        let client = QuestradeClient::new(tm).unwrap();
1083        let quotes = client.get_option_quotes_raw(&[12345]).await.unwrap();
1084        assert!(quotes.is_empty());
1085    }
1086
1087    #[tokio::test]
1088    async fn get_with_raw_logging_deserializes_correctly() {
1089        let server = MockServer::start().await;
1090        let api_server = format!("{}/", server.uri());
1091
1092        Mock::given(method("GET"))
1093            .and(path("/v1/time"))
1094            .respond_with(
1095                ResponseTemplate::new(200)
1096                    .set_body_json(serde_json::json!({"time": "2026-03-02T12:00:00.000000-05:00"})),
1097            )
1098            .expect(1)
1099            .mount(&server)
1100            .await;
1101
1102        let cached = CachedToken {
1103            access_token: "token".to_string(),
1104            api_server,
1105            expires_at: OffsetDateTime::now_utc() + time::Duration::minutes(25),
1106        };
1107        let tm =
1108            TokenManager::new_with_login_url("rt".to_string(), None, server.uri(), Some(cached))
1109                .await
1110                .unwrap();
1111
1112        let client = QuestradeClient::new(tm).unwrap().with_raw_logging(true);
1113        let time = client.get_server_time().await.unwrap();
1114        assert_eq!(time.year(), 2026);
1115    }
1116
1117    #[tokio::test]
1118    async fn get_text_returns_raw_body() {
1119        let server = MockServer::start().await;
1120        let api_server = format!("{}/", server.uri());
1121
1122        let expected_json = r#"{"time":"2026-03-02T12:00:00.000000-05:00"}"#;
1123        Mock::given(method("GET"))
1124            .and(path("/v1/time"))
1125            .respond_with(ResponseTemplate::new(200).set_body_string(expected_json))
1126            .expect(1)
1127            .mount(&server)
1128            .await;
1129
1130        let cached = CachedToken {
1131            access_token: "token".to_string(),
1132            api_server,
1133            expires_at: OffsetDateTime::now_utc() + time::Duration::minutes(25),
1134        };
1135        let tm =
1136            TokenManager::new_with_login_url("rt".to_string(), None, server.uri(), Some(cached))
1137                .await
1138                .unwrap();
1139
1140        let client = QuestradeClient::new(tm).unwrap();
1141        let text = client.get_text("/time").await.unwrap();
1142        assert_eq!(text, expected_json);
1143    }
1144
1145    // --- 429 retry tests ---
1146
1147    #[tokio::test]
1148    async fn get_retries_on_429_then_succeeds() {
1149        let server = MockServer::start().await;
1150        let api_server = format!("{}/", server.uri());
1151
1152        Mock::given(method("GET"))
1153            .and(path("/v1/time"))
1154            .respond_with(ResponseTemplate::new(429))
1155            .expect(2)
1156            .up_to_n_times(2)
1157            .named("rate limited")
1158            .mount(&server)
1159            .await;
1160
1161        Mock::given(method("GET"))
1162            .and(path("/v1/time"))
1163            .respond_with(
1164                ResponseTemplate::new(200)
1165                    .set_body_json(serde_json::json!({"time": "2026-03-02T12:00:00.000000-05:00"})),
1166            )
1167            .expect(1)
1168            .named("success after rate limit")
1169            .mount(&server)
1170            .await;
1171
1172        let cached = CachedToken {
1173            access_token: "token".to_string(),
1174            api_server,
1175            expires_at: OffsetDateTime::now_utc() + time::Duration::minutes(25),
1176        };
1177        let tm =
1178            TokenManager::new_with_login_url("rt".to_string(), None, server.uri(), Some(cached))
1179                .await
1180                .unwrap();
1181
1182        let client = QuestradeClient::new(tm).unwrap();
1183        let time = client.get_server_time().await.unwrap();
1184        assert_eq!(time.year(), 2026);
1185    }
1186
1187    #[tokio::test]
1188    async fn post_retries_on_429_then_succeeds() {
1189        let server = MockServer::start().await;
1190        let api_server = format!("{}/", server.uri());
1191
1192        Mock::given(method("POST"))
1193            .and(path("/v1/markets/quotes/options"))
1194            .respond_with(ResponseTemplate::new(429))
1195            .expect(1)
1196            .up_to_n_times(1)
1197            .named("rate limited post")
1198            .mount(&server)
1199            .await;
1200
1201        Mock::given(method("POST"))
1202            .and(path("/v1/markets/quotes/options"))
1203            .respond_with(
1204                ResponseTemplate::new(200).set_body_json(serde_json::json!({"optionQuotes": []})),
1205            )
1206            .expect(1)
1207            .named("success post after rate limit")
1208            .mount(&server)
1209            .await;
1210
1211        let cached = CachedToken {
1212            access_token: "token".to_string(),
1213            api_server,
1214            expires_at: OffsetDateTime::now_utc() + time::Duration::minutes(25),
1215        };
1216        let tm =
1217            TokenManager::new_with_login_url("rt".to_string(), None, server.uri(), Some(cached))
1218                .await
1219                .unwrap();
1220
1221        let client = QuestradeClient::new(tm).unwrap();
1222        let quotes = client.get_option_quotes_raw(&[12345]).await.unwrap();
1223        assert!(quotes.is_empty());
1224    }
1225
1226    #[tokio::test]
1227    async fn get_fails_after_max_429_retries() {
1228        let server = MockServer::start().await;
1229        let api_server = format!("{}/", server.uri());
1230
1231        Mock::given(method("GET"))
1232            .and(path("/v1/time"))
1233            .respond_with(ResponseTemplate::new(429))
1234            .expect((MAX_RETRIES + 1) as u64)
1235            .mount(&server)
1236            .await;
1237
1238        let cached = CachedToken {
1239            access_token: "token".to_string(),
1240            api_server,
1241            expires_at: OffsetDateTime::now_utc() + time::Duration::minutes(25),
1242        };
1243        let tm =
1244            TokenManager::new_with_login_url("rt".to_string(), None, server.uri(), Some(cached))
1245                .await
1246                .unwrap();
1247
1248        let client = QuestradeClient::new(tm).unwrap();
1249        let result = client.get_server_time().await;
1250        assert!(result.is_err());
1251        assert!(
1252            result.unwrap_err().to_string().contains("rate limit"),
1253            "error should mention rate limit"
1254        );
1255    }
1256
1257    #[test]
1258    fn retry_after_header_is_respected() {
1259        let resp = http::Response::builder()
1260            .status(429)
1261            .header("Retry-After", "5")
1262            .body("")
1263            .unwrap();
1264        let resp = reqwest::Response::from(resp);
1265        let delay = retry_after_or_backoff(&resp, 0);
1266        assert_eq!(delay, Duration::from_secs(5));
1267    }
1268
1269    #[test]
1270    fn retry_after_header_capped_at_60s() {
1271        let resp = http::Response::builder()
1272            .status(429)
1273            .header("Retry-After", "300")
1274            .body("")
1275            .unwrap();
1276        let resp = reqwest::Response::from(resp);
1277        let delay = retry_after_or_backoff(&resp, 0);
1278        assert_eq!(delay, Duration::from_secs(60));
1279    }
1280
1281    #[test]
1282    fn retry_after_missing_falls_back_to_backoff() {
1283        let resp = http::Response::builder().status(429).body("").unwrap();
1284        let resp = reqwest::Response::from(resp);
1285        let delay = retry_after_or_backoff(&resp, 0);
1286        let ms = delay.as_millis() as u64;
1287        assert!(ms >= 800 && ms <= 1200, "expected ~1000ms, got {}ms", ms);
1288    }
1289}