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 historical candles for a symbol.
481    pub async fn get_candles(
482        &self,
483        symbol_id: u64,
484        start: OffsetDateTime,
485        end: OffsetDateTime,
486        interval: &str,
487    ) -> Result<Vec<Candle>> {
488        let start_str = start
489            .format(&Iso8601::DEFAULT)
490            .map_err(|e| QuestradeError::DateTime {
491                context: "Failed to format start time".to_string(),
492                source: Box::new(e),
493            })?;
494        let end_str = end
495            .format(&Iso8601::DEFAULT)
496            .map_err(|e| QuestradeError::DateTime {
497                context: "Failed to format end time".to_string(),
498                source: Box::new(e),
499            })?;
500        let resp: CandleResponse = self
501            .get(&format!(
502                "/markets/candles/{}?startTime={}&endTime={}&interval={}",
503                symbol_id, start_str, end_str, interval
504            ))
505            .await?;
506        Ok(resp.candles)
507    }
508
509    /// Fetch the current server time from Questrade.
510    ///
511    /// Uses `GET /v1/time`. Not cached — real-time by definition.
512    pub async fn get_server_time(&self) -> Result<OffsetDateTime> {
513        let resp: ServerTimeResponse = self.get("/time").await?;
514        Self::parse_datetime(&resp.time)
515    }
516
517    /// Fetch all accounts for the authenticated user.
518    pub async fn get_accounts(&self) -> Result<Vec<Account>> {
519        let resp: AccountsResponse = self.get("/accounts").await?;
520        Ok(resp.accounts)
521    }
522
523    /// Fetch positions for a specific account.
524    pub async fn get_positions(&self, account_id: &str) -> Result<Vec<PositionItem>> {
525        let resp: PositionsResponse = self
526            .get(&format!("/accounts/{}/positions", account_id))
527            .await?;
528        Ok(resp.positions)
529    }
530
531    /// Fetch current and start-of-day balances for a specific account.
532    pub async fn get_account_balances(&self, account_id: &str) -> Result<AccountBalances> {
533        self.get(&format!("/accounts/{}/balances", account_id))
534            .await
535    }
536
537    /// Fetch metadata for all markets (trading hours, open/closed status).
538    pub async fn get_markets(&self) -> Result<Vec<crate::api_types::MarketInfo>> {
539        let resp: crate::api_types::MarketsResponse = self.get("/markets").await?;
540        Ok(resp.markets)
541    }
542
543    /// Fetch full symbol details by numeric ID via `GET /v1/symbols/:id`.
544    pub async fn get_symbol(&self, symbol_id: u64) -> Result<SymbolDetail> {
545        let resp: SymbolDetailResponse = self.get(&format!("/symbols/{}", symbol_id)).await?;
546        resp.symbols.into_iter().next().ok_or_else(|| {
547            QuestradeError::EmptyResponse(format!("No symbol returned for id {}", symbol_id))
548        })
549    }
550
551    /// Fetch account activities (executions, dividends, etc.) for a date range.
552    ///
553    /// Questrade limits queries to 31-day windows per request; we use 30-day
554    /// windows to stay safely within the boundary. This method transparently
555    /// splits any range longer than 30 days into compliant sub-windows and
556    /// combines the results, sorted by `trade_date` ascending.
557    pub async fn get_activities(
558        &self,
559        account_id: &str,
560        start: OffsetDateTime,
561        end: OffsetDateTime,
562    ) -> Result<Vec<ActivityItem>> {
563        let windows = activity_windows(start, end);
564        let mut all = Vec::new();
565        for (w_start, w_end) in windows {
566            let start_str = format_query_datetime(w_start)?;
567            let end_str = format_query_datetime(w_end)?;
568            let resp: ActivitiesResponse = self
569                .get(&format!(
570                    "/accounts/{}/activities?startTime={}&endTime={}",
571                    account_id, start_str, end_str,
572                ))
573                .await?;
574            all.extend(resp.activities);
575        }
576        all.sort_by(|a, b| a.trade_date.cmp(&b.trade_date));
577        Ok(all)
578    }
579
580    /// Fetch orders for a specific account within a date range.
581    ///
582    /// Use `state_filter` to limit results to open, closed, or all orders.
583    /// Unlike activities, there is no documented date-range window limit for
584    /// this endpoint.
585    pub async fn get_orders(
586        &self,
587        account_id: &str,
588        start: OffsetDateTime,
589        end: OffsetDateTime,
590        state_filter: OrderStateFilter,
591    ) -> Result<Vec<OrderItem>> {
592        let start_str = format_query_datetime(start)?;
593        let end_str = format_query_datetime(end)?;
594        let resp: OrdersResponse = self
595            .get(&format!(
596                "/accounts/{}/orders?startTime={}&endTime={}&stateFilter={}",
597                account_id, start_str, end_str, state_filter,
598            ))
599            .await?;
600        Ok(resp.orders)
601    }
602
603    /// Fetch trade executions (fill-level detail) for a date range.
604    ///
605    /// Uses 30-day windowing, same as [`get_activities`](Self::get_activities).
606    /// Results are sorted by `timestamp` ascending.
607    pub async fn get_executions(
608        &self,
609        account_id: &str,
610        start: OffsetDateTime,
611        end: OffsetDateTime,
612    ) -> Result<Vec<Execution>> {
613        let windows = activity_windows(start, end);
614        let mut all = Vec::new();
615        for (w_start, w_end) in windows {
616            let start_str = format_query_datetime(w_start)?;
617            let end_str = format_query_datetime(w_end)?;
618            let resp: ExecutionsResponse = self
619                .get(&format!(
620                    "/accounts/{}/executions?startTime={}&endTime={}",
621                    account_id, start_str, end_str,
622                ))
623                .await?;
624            all.extend(resp.executions);
625        }
626        all.sort_by(|a, b| a.timestamp.cmp(&b.timestamp));
627        Ok(all)
628    }
629}
630
631/// Split a date range into ≤30-day windows for Questrade's activities endpoint.
632///
633/// Questrade documents a "maximum 31 days" range, but live testing (Feb 2026)
634/// shows the actual limit is **31 calendar days in Eastern Time**, measured from
635/// midnight ET. For example, at 16:52 ET the API rejects a start time only
636/// 30 d 17 h earlier (past midnight ET 31 days ago) while accepting 30 d 16 h 30 m.
637///
638/// Using 30-day windows keeps us a full calendar day inside the limit regardless
639/// of the caller's timezone or time of day, with no observable cost (one extra
640/// API call per year of history).
641///
642/// Returns windows as `(start, end)` pairs in chronological order.
643/// Returns an empty `Vec` if `start >= end`.
644fn activity_windows(
645    start: OffsetDateTime,
646    end: OffsetDateTime,
647) -> Vec<(OffsetDateTime, OffsetDateTime)> {
648    const MAX_WINDOW: time::Duration = time::Duration::days(30);
649    let mut windows = Vec::new();
650    let mut cursor = start;
651    while cursor < end {
652        let window_end = (cursor + MAX_WINDOW).min(end);
653        windows.push((cursor, window_end));
654        cursor = window_end;
655    }
656    windows
657}
658
659#[cfg(test)]
660mod tests {
661    use super::*;
662    use crate::auth::{CachedToken, TokenManager};
663    use time::OffsetDateTime;
664    use wiremock::matchers::{header, method, path};
665    use wiremock::{Mock, MockServer, ResponseTemplate};
666
667    #[test]
668    fn server_time_response_deserializes() {
669        let json = r#"{"time":"2026-02-21T14:32:00.000000-05:00"}"#;
670        let resp: ServerTimeResponse = serde_json::from_str(json).unwrap();
671        assert_eq!(resp.time, "2026-02-21T14:32:00.000000-05:00");
672    }
673
674    #[test]
675    fn parse_server_time_returns_correct_fields() {
676        let json = r#"{"time":"2026-02-21T14:32:00.000000-05:00"}"#;
677        let resp: ServerTimeResponse = serde_json::from_str(json).unwrap();
678        let dt = QuestradeClient::parse_datetime(&resp.time).unwrap();
679        assert_eq!(dt.year(), 2026);
680        assert_eq!(dt.month(), time::Month::February);
681        assert_eq!(dt.day(), 21);
682        assert_eq!(dt.hour(), 14);
683        assert_eq!(dt.minute(), 32);
684        assert_eq!(dt.second(), 0);
685        assert_eq!(dt.offset().whole_hours(), -5);
686    }
687
688    #[test]
689    fn format_query_datetime_uses_utc_second_precision() {
690        let dt = OffsetDateTime::parse("2026-02-24T03:58:12.123456789-05:00", &Iso8601::DEFAULT)
691            .unwrap();
692        let s = format_query_datetime(dt).unwrap();
693        assert_eq!(s, "2026-02-24T08:58:12Z");
694        assert!(!s.contains('.'));
695    }
696
697    #[test]
698    fn backoff_delay_within_jitter_bounds() {
699        for attempt in 0..MAX_RETRIES {
700            for _ in 0..20 {
701                let delay = backoff_delay(attempt);
702                let base_ms = RETRY_BASE_DELAY_MS << attempt;
703                let min_ms = (base_ms as f64 * 0.8) as u64;
704                let max_ms = (base_ms as f64 * 1.2) as u64;
705                let actual_ms = delay.as_millis() as u64;
706                assert!(
707                    actual_ms >= min_ms && actual_ms <= max_ms,
708                    "attempt {attempt}: delay {actual_ms}ms not in [{min_ms}, {max_ms}]"
709                );
710            }
711        }
712    }
713
714    #[test]
715    fn backoff_delay_doubles_each_attempt() {
716        for attempt in 1..MAX_RETRIES {
717            let prev_base = RETRY_BASE_DELAY_MS << (attempt - 1);
718            let curr_base = RETRY_BASE_DELAY_MS << attempt;
719            assert_eq!(
720                curr_base,
721                prev_base * 2,
722                "base delay should double from attempt {} to {}",
723                attempt - 1,
724                attempt
725            );
726        }
727    }
728
729    #[test]
730    fn max_retries_constant() {
731        assert_eq!(MAX_RETRIES, 3, "expected 3 retries");
732    }
733
734    // --- activity_windows ---
735
736    fn dt(s: &str) -> OffsetDateTime {
737        OffsetDateTime::parse(s, &Iso8601::DEFAULT).unwrap()
738    }
739
740    #[test]
741    fn activity_windows_empty_range_returns_empty() {
742        let start = dt("2026-01-01T00:00:00Z");
743        assert!(activity_windows(start, start).is_empty());
744        // end before start also empty
745        assert!(activity_windows(start, start - time::Duration::days(1)).is_empty());
746    }
747
748    #[test]
749    fn activity_windows_single_window_when_range_within_31_days() {
750        let start = dt("2026-01-01T00:00:00Z");
751        let end = start + time::Duration::days(30);
752        let windows = activity_windows(start, end);
753        assert_eq!(windows.len(), 1);
754        assert_eq!(windows[0], (start, end));
755    }
756
757    #[test]
758    fn activity_windows_exactly_30_days_is_single_window() {
759        let start = dt("2026-01-01T00:00:00Z");
760        let end = start + time::Duration::days(30);
761        let windows = activity_windows(start, end);
762        assert_eq!(windows.len(), 1);
763        assert_eq!(windows[0], (start, end));
764    }
765
766    #[test]
767    fn activity_windows_31_days_splits_into_two() {
768        let start = dt("2026-01-01T00:00:00Z");
769        let end = start + time::Duration::days(31);
770        let windows = activity_windows(start, end);
771        assert_eq!(windows.len(), 2);
772        assert_eq!(windows[0], (start, start + time::Duration::days(30)));
773        assert_eq!(windows[1], (start + time::Duration::days(30), end));
774    }
775
776    #[test]
777    fn activity_windows_365_days_all_within_limit_and_contiguous() {
778        let start = dt("2026-01-01T00:00:00Z");
779        let end = start + time::Duration::days(365);
780        let windows = activity_windows(start, end);
781        // 365 / 30 = 12 full + 5 remaining = 13
782        assert_eq!(windows.len(), 13);
783        assert_eq!(windows[0].0, start);
784        assert_eq!(windows.last().unwrap().1, end);
785        for (ws, we) in &windows {
786            assert!(
787                (*we - *ws).whole_days() <= 30,
788                "window exceeds 30 days: {} days",
789                (*we - *ws).whole_days()
790            );
791        }
792        // Contiguous: each window starts where the previous ended
793        for i in 1..windows.len() {
794            assert_eq!(
795                windows[i].0,
796                windows[i - 1].1,
797                "gap between window {i} and {}",
798                i - 1
799            );
800        }
801    }
802
803    // --- 401 retry tests ---
804
805    #[tokio::test]
806    async fn get_retries_on_401_after_force_refresh() {
807        let server = MockServer::start().await;
808        let api_server = format!("{}/", server.uri());
809
810        // First API call with stale token → 401.
811        Mock::given(method("GET"))
812            .and(path("/v1/time"))
813            .and(header("Authorization", "Bearer stale_token"))
814            .respond_with(ResponseTemplate::new(401))
815            .expect(1)
816            .named("stale request")
817            .mount(&server)
818            .await;
819
820        // OAuth refresh → new token (api_server stays the same mock).
821        Mock::given(method("GET"))
822            .and(path("/oauth2/token"))
823            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
824                "access_token": "fresh_token",
825                "token_type": "Bearer",
826                "expires_in": 1800,
827                "refresh_token": "new_rt",
828                "api_server": api_server,
829            })))
830            .expect(1)
831            .named("oauth refresh")
832            .mount(&server)
833            .await;
834
835        // Retry with fresh token → success.
836        Mock::given(method("GET"))
837            .and(path("/v1/time"))
838            .and(header("Authorization", "Bearer fresh_token"))
839            .respond_with(
840                ResponseTemplate::new(200)
841                    .set_body_json(serde_json::json!({"time": "2026-03-02T12:00:00.000000-05:00"})),
842            )
843            .expect(1)
844            .named("fresh request")
845            .mount(&server)
846            .await;
847
848        // Build client with stale cached token.
849        let cached = CachedToken {
850            access_token: "stale_token".to_string(),
851            api_server: api_server.clone(),
852            expires_at: OffsetDateTime::now_utc() + time::Duration::minutes(25),
853        };
854        let tm = TokenManager::new_with_login_url(
855            "old_rt".to_string(),
856            None,
857            server.uri(),
858            Some(cached),
859        )
860        .await
861        .unwrap();
862
863        let client = QuestradeClient::new(tm).unwrap();
864        let time = client.get_server_time().await.unwrap();
865        assert_eq!(time.year(), 2026);
866    }
867
868    #[tokio::test]
869    async fn get_does_not_retry_401_more_than_once() {
870        let server = MockServer::start().await;
871        let api_server = format!("{}/", server.uri());
872
873        // API always returns 401.
874        Mock::given(method("GET"))
875            .and(path("/v1/time"))
876            .respond_with(ResponseTemplate::new(401).set_body_string("Unauthorized"))
877            .expect(2) // initial + one retry = 2
878            .mount(&server)
879            .await;
880
881        // OAuth refresh succeeds (but the new token is still rejected).
882        Mock::given(method("GET"))
883            .and(path("/oauth2/token"))
884            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
885                "access_token": "still_bad",
886                "token_type": "Bearer",
887                "expires_in": 1800,
888                "refresh_token": "new_rt",
889                "api_server": api_server,
890            })))
891            .expect(1)
892            .mount(&server)
893            .await;
894
895        let cached = CachedToken {
896            access_token: "stale_token".to_string(),
897            api_server,
898            expires_at: OffsetDateTime::now_utc() + time::Duration::minutes(25),
899        };
900        let tm = TokenManager::new_with_login_url(
901            "old_rt".to_string(),
902            None,
903            server.uri(),
904            Some(cached),
905        )
906        .await
907        .unwrap();
908
909        let client = QuestradeClient::new(tm).unwrap();
910        let result = client.get_server_time().await;
911        assert!(result.is_err());
912        assert!(
913            result.unwrap_err().to_string().contains("401"),
914            "error should mention 401"
915        );
916    }
917
918    #[tokio::test]
919    async fn post_retries_on_401_after_force_refresh() {
920        let server = MockServer::start().await;
921        let api_server = format!("{}/", server.uri());
922
923        // First POST with stale token → 401.
924        Mock::given(method("POST"))
925            .and(path("/v1/markets/quotes/options"))
926            .and(header("Authorization", "Bearer stale_token"))
927            .respond_with(ResponseTemplate::new(401))
928            .expect(1)
929            .named("stale post")
930            .mount(&server)
931            .await;
932
933        // OAuth refresh → new token.
934        Mock::given(method("GET"))
935            .and(path("/oauth2/token"))
936            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
937                "access_token": "fresh_token",
938                "token_type": "Bearer",
939                "expires_in": 1800,
940                "refresh_token": "new_rt",
941                "api_server": api_server,
942            })))
943            .expect(1)
944            .named("oauth refresh")
945            .mount(&server)
946            .await;
947
948        // Retry POST with fresh token → success.
949        Mock::given(method("POST"))
950            .and(path("/v1/markets/quotes/options"))
951            .and(header("Authorization", "Bearer fresh_token"))
952            .respond_with(
953                ResponseTemplate::new(200).set_body_json(serde_json::json!({"optionQuotes": []})),
954            )
955            .expect(1)
956            .named("fresh post")
957            .mount(&server)
958            .await;
959
960        let cached = CachedToken {
961            access_token: "stale_token".to_string(),
962            api_server,
963            expires_at: OffsetDateTime::now_utc() + time::Duration::minutes(25),
964        };
965        let tm = TokenManager::new_with_login_url(
966            "old_rt".to_string(),
967            None,
968            server.uri(),
969            Some(cached),
970        )
971        .await
972        .unwrap();
973
974        let client = QuestradeClient::new(tm).unwrap();
975        let quotes = client.get_option_quotes_raw(&[12345]).await.unwrap();
976        assert!(quotes.is_empty());
977    }
978
979    #[tokio::test]
980    async fn get_with_raw_logging_deserializes_correctly() {
981        let server = MockServer::start().await;
982        let api_server = format!("{}/", server.uri());
983
984        Mock::given(method("GET"))
985            .and(path("/v1/time"))
986            .respond_with(
987                ResponseTemplate::new(200)
988                    .set_body_json(serde_json::json!({"time": "2026-03-02T12:00:00.000000-05:00"})),
989            )
990            .expect(1)
991            .mount(&server)
992            .await;
993
994        let cached = CachedToken {
995            access_token: "token".to_string(),
996            api_server,
997            expires_at: OffsetDateTime::now_utc() + time::Duration::minutes(25),
998        };
999        let tm =
1000            TokenManager::new_with_login_url("rt".to_string(), None, server.uri(), Some(cached))
1001                .await
1002                .unwrap();
1003
1004        let client = QuestradeClient::new(tm).unwrap().with_raw_logging(true);
1005        let time = client.get_server_time().await.unwrap();
1006        assert_eq!(time.year(), 2026);
1007    }
1008
1009    #[tokio::test]
1010    async fn get_text_returns_raw_body() {
1011        let server = MockServer::start().await;
1012        let api_server = format!("{}/", server.uri());
1013
1014        let expected_json = r#"{"time":"2026-03-02T12:00:00.000000-05:00"}"#;
1015        Mock::given(method("GET"))
1016            .and(path("/v1/time"))
1017            .respond_with(ResponseTemplate::new(200).set_body_string(expected_json))
1018            .expect(1)
1019            .mount(&server)
1020            .await;
1021
1022        let cached = CachedToken {
1023            access_token: "token".to_string(),
1024            api_server,
1025            expires_at: OffsetDateTime::now_utc() + time::Duration::minutes(25),
1026        };
1027        let tm =
1028            TokenManager::new_with_login_url("rt".to_string(), None, server.uri(), Some(cached))
1029                .await
1030                .unwrap();
1031
1032        let client = QuestradeClient::new(tm).unwrap();
1033        let text = client.get_text("/time").await.unwrap();
1034        assert_eq!(text, expected_json);
1035    }
1036
1037    // --- 429 retry tests ---
1038
1039    #[tokio::test]
1040    async fn get_retries_on_429_then_succeeds() {
1041        let server = MockServer::start().await;
1042        let api_server = format!("{}/", server.uri());
1043
1044        Mock::given(method("GET"))
1045            .and(path("/v1/time"))
1046            .respond_with(ResponseTemplate::new(429))
1047            .expect(2)
1048            .up_to_n_times(2)
1049            .named("rate limited")
1050            .mount(&server)
1051            .await;
1052
1053        Mock::given(method("GET"))
1054            .and(path("/v1/time"))
1055            .respond_with(
1056                ResponseTemplate::new(200)
1057                    .set_body_json(serde_json::json!({"time": "2026-03-02T12:00:00.000000-05:00"})),
1058            )
1059            .expect(1)
1060            .named("success after rate limit")
1061            .mount(&server)
1062            .await;
1063
1064        let cached = CachedToken {
1065            access_token: "token".to_string(),
1066            api_server,
1067            expires_at: OffsetDateTime::now_utc() + time::Duration::minutes(25),
1068        };
1069        let tm =
1070            TokenManager::new_with_login_url("rt".to_string(), None, server.uri(), Some(cached))
1071                .await
1072                .unwrap();
1073
1074        let client = QuestradeClient::new(tm).unwrap();
1075        let time = client.get_server_time().await.unwrap();
1076        assert_eq!(time.year(), 2026);
1077    }
1078
1079    #[tokio::test]
1080    async fn post_retries_on_429_then_succeeds() {
1081        let server = MockServer::start().await;
1082        let api_server = format!("{}/", server.uri());
1083
1084        Mock::given(method("POST"))
1085            .and(path("/v1/markets/quotes/options"))
1086            .respond_with(ResponseTemplate::new(429))
1087            .expect(1)
1088            .up_to_n_times(1)
1089            .named("rate limited post")
1090            .mount(&server)
1091            .await;
1092
1093        Mock::given(method("POST"))
1094            .and(path("/v1/markets/quotes/options"))
1095            .respond_with(
1096                ResponseTemplate::new(200).set_body_json(serde_json::json!({"optionQuotes": []})),
1097            )
1098            .expect(1)
1099            .named("success post after rate limit")
1100            .mount(&server)
1101            .await;
1102
1103        let cached = CachedToken {
1104            access_token: "token".to_string(),
1105            api_server,
1106            expires_at: OffsetDateTime::now_utc() + time::Duration::minutes(25),
1107        };
1108        let tm =
1109            TokenManager::new_with_login_url("rt".to_string(), None, server.uri(), Some(cached))
1110                .await
1111                .unwrap();
1112
1113        let client = QuestradeClient::new(tm).unwrap();
1114        let quotes = client.get_option_quotes_raw(&[12345]).await.unwrap();
1115        assert!(quotes.is_empty());
1116    }
1117
1118    #[tokio::test]
1119    async fn get_fails_after_max_429_retries() {
1120        let server = MockServer::start().await;
1121        let api_server = format!("{}/", server.uri());
1122
1123        Mock::given(method("GET"))
1124            .and(path("/v1/time"))
1125            .respond_with(ResponseTemplate::new(429))
1126            .expect((MAX_RETRIES + 1) as u64)
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 result = client.get_server_time().await;
1142        assert!(result.is_err());
1143        assert!(
1144            result.unwrap_err().to_string().contains("rate limit"),
1145            "error should mention rate limit"
1146        );
1147    }
1148
1149    #[test]
1150    fn retry_after_header_is_respected() {
1151        let resp = http::Response::builder()
1152            .status(429)
1153            .header("Retry-After", "5")
1154            .body("")
1155            .unwrap();
1156        let resp = reqwest::Response::from(resp);
1157        let delay = retry_after_or_backoff(&resp, 0);
1158        assert_eq!(delay, Duration::from_secs(5));
1159    }
1160
1161    #[test]
1162    fn retry_after_header_capped_at_60s() {
1163        let resp = http::Response::builder()
1164            .status(429)
1165            .header("Retry-After", "300")
1166            .body("")
1167            .unwrap();
1168        let resp = reqwest::Response::from(resp);
1169        let delay = retry_after_or_backoff(&resp, 0);
1170        assert_eq!(delay, Duration::from_secs(60));
1171    }
1172
1173    #[test]
1174    fn retry_after_missing_falls_back_to_backoff() {
1175        let resp = http::Response::builder().status(429).body("").unwrap();
1176        let resp = reqwest::Response::from(resp);
1177        let delay = retry_after_or_backoff(&resp, 0);
1178        let ms = delay.as_millis() as u64;
1179        assert!(ms >= 800 && ms <= 1200, "expected ~1000ms, got {}ms", ms);
1180    }
1181}