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