Skip to main content

rustrade_execution/client/alpaca/
mod.rs

1// Alpaca ExecutionClient implementation
2//
3// Uses raw reqwest for REST and rustrade-integration tungstenite for WebSocket.
4// No official Alpaca Rust SDK — built directly on reqwest + rustrade-integration
5// to avoid supply chain risk in a trading system that handles real money.
6//
7// Architecture:
8// - REST (reqwest): account_snapshot, fetch_balances, fetch_open_orders,
9//   fetch_trades, open_order, cancel_order
10// - WebSocket (tungstenite): account_stream via Alpaca's trade_updates stream
11//   at wss://[paper-]api.alpaca.markets/stream
12//
13// Auth: header-based (APCA-API-KEY-ID + APCA-API-SECRET-KEY), no HMAC signing.
14// A reqwest::Client is built with these as default headers so every request
15// carries them automatically.
16//
17// Resilience features:
18// - Rate limit handling: reads X-Ratelimit-Remaining / X-Ratelimit-Reset headers;
19//   backs off on 429 with up to MAX_RATE_LIMIT_ATTEMPTS total attempts
20// - Reconnection: account_stream reconnects on WS close/error with exponential
21//   backoff (1 s → 30 s, max 10 attempts)
22// - Heartbeat monitoring: reconnects if no WS message for HEARTBEAT_TIMEOUT_SECS
23// - Fill recovery: after reconnect, fetches missed fills via GET /v2/account/activities
24//   since disconnect_time; sent through the dedup cache to filter duplicates
25// - Dedup cache: LRU keyed on "{order_id}:{cumulative_filled_qty}" prevents
26//   duplicate fills arising from the overlap between WS events before disconnect
27//   and the fill-recovery REST window
28//
29// Known limitations:
30// - Only FILL activities are recovered after reconnect; order lifecycle events
31//   (new, cancelled, expired) are not — callers must call fetch_open_orders after
32//   each reconnect to reconcile open-order state.
33
34use crate::{
35    AccountEventKind, AccountSnapshot, InstrumentAccountSnapshot, UnindexedAccountEvent,
36    UnindexedAccountSnapshot,
37    balance::{AssetBalance, Balance},
38    client::{BracketOrderClient, ExecutionClient},
39    emit_stream_terminated,
40    error::{
41        ApiError, ConnectivityError, OrderError, StreamTerminationReason, UnindexedClientError,
42        UnindexedOrderError,
43    },
44    order::{
45        Order, OrderKey, OrderKind, TimeInForce, TrailingOffsetType,
46        bracket::{
47            BracketOrderRequest as UnifiedBracketOrderRequest,
48            BracketOrderResult as UnifiedBracketOrderResult,
49        },
50        id::{ClientOrderId, OrderId, StrategyId},
51        request::{OrderRequestCancel, OrderRequestOpen, UnindexedOrderResponseCancel},
52        state::{Cancelled, Filled, Open, OrderState, UnindexedOrderState},
53    },
54    parse_env_bool,
55    trade::{AssetFees, Trade, TradeId},
56};
57use chrono::{DateTime, Utc};
58use fnv::FnvHashMap;
59use futures::{SinkExt as _, StreamExt as _, stream::BoxStream};
60use indexmap::IndexMap;
61use itertools::Itertools as _;
62use lru::LruCache;
63use rust_decimal::Decimal;
64use rustrade_instrument::{
65    Side, asset::name::AssetNameExchange, exchange::ExchangeId,
66    instrument::name::InstrumentNameExchange,
67};
68use rustrade_integration::protocol::websocket::{WebSocket, WsMessage};
69use serde::{Deserialize, Serialize};
70use smol_str::{SmolStr, format_smolstr};
71use std::{num::NonZeroUsize, pin::Pin, str::FromStr, sync::Arc, time::Duration};
72use tokio::sync::mpsc;
73use tracing::{debug, error, info, trace, warn};
74
75// ---------------------------------------------------------------------------
76// Constants
77// ---------------------------------------------------------------------------
78
79const INITIAL_BACKOFF_MS: u64 = 1_000;
80const MAX_BACKOFF_MS: u64 = 30_000;
81const MAX_RECONNECT_ATTEMPTS: u32 = 10;
82/// If no WS activity for this long, force reconnect.
83const HEARTBEAT_TIMEOUT_SECS: u64 = 35;
84/// Timeout for fill recovery REST queries after reconnect.
85const FILL_RECOVERY_TIMEOUT_SECS: u64 = 30;
86/// Extra lookback from disconnect timestamp to cover Tokio scheduling jitter and
87/// client/server clock drift on cloud VMs. The dedup cache absorbs resulting duplicates.
88const SIGNAL_RECOVERY_LOOKBACK_MS: i64 = 1_500;
89/// Alpaca's activity page size limit.
90const ALPACA_MAX_ACTIVITIES: usize = 100;
91/// Default cooldown when rate-limited (if X-Ratelimit-Reset header is absent).
92const DEFAULT_RATE_LIMIT_DELAY_SECS: u64 = 60;
93/// Total REST attempts (1 initial + retries) before giving up on rate-limit errors.
94/// The loop runs `0..MAX_RATE_LIMIT_ATTEMPTS`, retrying while `attempt + 1 < MAX`.
95const MAX_RATE_LIMIT_ATTEMPTS: u32 = 4;
96/// Dedup LRU cache size. Each entry is a ~50–70 byte String (UUID + decimal).
97/// 2_000 entries ≈ 120–140 KB — ample for options trading fill rates.
98const DEDUP_CACHE_SIZE: usize = 2_000;
99/// Timeout for the initial WS auth+subscribe handshake.
100const WS_HANDSHAKE_TIMEOUT_SECS: u64 = 15;
101/// Timeout for a graceful WS close. Prevents indefinite blocking when the
102/// server does not respond to the close frame before reconnect/shutdown.
103const WS_CLOSE_TIMEOUT_SECS: u64 = 5;
104
105// ---------------------------------------------------------------------------
106// GracefulShutdownStream
107// ---------------------------------------------------------------------------
108
109/// Wrapper stream that signals the `connection_manager` task to shut down gracefully
110/// when dropped, allowing it to send an orderly WebSocket close frame.
111///
112/// # Shutdown sequence
113/// Dropping this stream drops `inner` (the channel receiver), which makes `tx.closed()`
114/// resolve on the `connection_manager`'s next `select!` poll. The `tx.closed()` arm
115/// sends a WebSocket close frame (with `WS_CLOSE_TIMEOUT_SECS` timeout) and then
116/// returns, dropping the task cleanly.
117///
118/// `JoinHandle::drop` detaches the task — it is NOT aborted. The task exits within
119/// the current `select!` iteration (if the receiver is already dropped when polled)
120/// or after the current heartbeat window / backoff sleep at most.
121struct GracefulShutdownStream<S> {
122    inner: S,
123    /// Keeps the `JoinHandle` alive until this stream is dropped. Dropping
124    /// the `JoinHandle` detaches (not cancels) the task, allowing it to keep
125    /// running until `tx.closed()` resolves. Without this field the handle
126    /// would be detached immediately at `connection_manager` spawn time,
127    /// preventing any future `.await` or abort if the design changes.
128    _handle: tokio::task::JoinHandle<()>,
129}
130
131impl<S> GracefulShutdownStream<S> {
132    fn new(inner: S, handle: tokio::task::JoinHandle<()>) -> Self {
133        Self {
134            inner,
135            _handle: handle,
136        }
137    }
138}
139
140impl<S: futures::Stream + Unpin> futures::Stream for GracefulShutdownStream<S> {
141    type Item = S::Item;
142    fn poll_next(
143        mut self: Pin<&mut Self>,
144        cx: &mut std::task::Context<'_>,
145    ) -> std::task::Poll<Option<Self::Item>> {
146        Pin::new(&mut self.inner).poll_next(cx)
147    }
148    fn size_hint(&self) -> (usize, Option<usize>) {
149        self.inner.size_hint()
150    }
151}
152
153impl<S> Drop for GracefulShutdownStream<S> {
154    fn drop(&mut self) {
155        // Do not abort. Dropping `self.inner` (the channel receiver) makes `tx.closed()`
156        // resolve, which causes `connection_manager` to send a graceful WS close frame
157        // and return. Dropping the JoinHandle here detaches (not cancels) the task.
158    }
159}
160
161// ---------------------------------------------------------------------------
162// Rate limit tracker
163// ---------------------------------------------------------------------------
164
165/// Thread-safe rate-limit state shared across all clones of AlpacaClient.
166struct RateLimitTracker {
167    blocked_until: parking_lot::Mutex<Option<tokio::time::Instant>>,
168}
169
170impl RateLimitTracker {
171    fn new() -> Self {
172        Self {
173            blocked_until: parking_lot::Mutex::new(None),
174        }
175    }
176
177    /// Sleep until the current cooldown expires. Returns immediately if not blocked.
178    async fn wait_if_blocked(&self) {
179        loop {
180            // Capture the current time once per iteration — reused for both the
181            // expired-deadline check inside the lock and the sleep calculation below.
182            // Eliminates one vDSO call per REST request on the common non-blocked path.
183            let now = tokio::time::Instant::now();
184            // Read and conditionally clear the deadline in a single lock acquisition.
185            // The guard is dropped before the `.await` below — holding a sync Mutex
186            // across an await would deadlock. A TOCTOU window still exists between the
187            // guard drop and `sleep_until`: a concurrent `on_rate_limited` call could
188            // extend the deadline after we read it. The loop re-reads on wake and
189            // corrects any extended deadline, so the race is recovered from on the next
190            // iteration rather than being fully prevented.
191            let deadline = {
192                let mut guard = self.blocked_until.lock();
193                let d = *guard;
194                if matches!(d, Some(t) if t <= now) {
195                    // Clear expired deadline so on_rate_limited correctly
196                    // distinguishes "new rate-limit event" from "extended cooldown".
197                    *guard = None;
198                }
199                d
200            };
201            match deadline {
202                None => return,
203                Some(until) => {
204                    if until <= now {
205                        // Deadline was expired and cleared above; no sleep needed.
206                        return;
207                    }
208                    // as_millis() returns u128; truncation impossible (u64::MAX ms ≈ 584M years)
209                    #[allow(clippy::cast_possible_truncation)]
210                    let delay_ms = (until - now).as_millis() as u64;
211                    debug!(delay_ms, "Alpaca REST rate-limited, waiting before request");
212                    tokio::time::sleep_until(until).await;
213                }
214            }
215        }
216    }
217
218    /// Record a rate-limit event, extending any existing cooldown if longer.
219    fn on_rate_limited(&self, retry_after: Option<Duration>) {
220        let delay = retry_after.unwrap_or(Duration::from_secs(DEFAULT_RATE_LIMIT_DELAY_SECS));
221        let new_deadline = tokio::time::Instant::now() + delay;
222        let mut guard = self.blocked_until.lock();
223        let was_blocked = guard.is_some();
224        *guard = Some(guard.map_or(new_deadline, |existing| existing.max(new_deadline)));
225        if was_blocked {
226            debug!(
227                delay_secs = delay.as_secs(),
228                "Alpaca rate-limit cooldown extended"
229            );
230        } else {
231            warn!(
232                delay_secs = delay.as_secs(),
233                "Alpaca entering rate-limit degradation mode"
234            );
235        }
236    }
237}
238
239// ---------------------------------------------------------------------------
240// Exponential backoff
241// ---------------------------------------------------------------------------
242
243struct ExponentialBackoff {
244    attempt: u32,
245    max_attempts: u32,
246    initial_ms: u64,
247    max_ms: u64,
248}
249
250impl ExponentialBackoff {
251    fn new() -> Self {
252        Self {
253            attempt: 0,
254            max_attempts: MAX_RECONNECT_ATTEMPTS,
255            initial_ms: INITIAL_BACKOFF_MS,
256            max_ms: MAX_BACKOFF_MS,
257        }
258    }
259
260    fn reset(&mut self) {
261        self.attempt = 0;
262    }
263
264    /// Number of reconnect attempts consumed so far.
265    ///
266    /// After [`wait`](Self::wait) has returned `false` this equals `max_attempts` — the number of
267    /// attempts made before the budget was exhausted. Used to populate
268    /// [`StreamTerminationReason::ReconnectBudgetExhausted`].
269    fn attempts(&self) -> u32 {
270        self.attempt
271    }
272
273    /// Waits for the current backoff duration. Returns `false` if max attempts exhausted.
274    async fn wait(&mut self) -> bool {
275        if self.attempt >= self.max_attempts {
276            return false;
277        }
278        let delay_ms = self
279            .initial_ms
280            .saturating_mul(2u64.saturating_pow(self.attempt))
281            .min(self.max_ms);
282        self.attempt += 1;
283        debug!(
284            attempt = self.attempt,
285            max = self.max_attempts,
286            delay_ms,
287            "Alpaca reconnect backoff"
288        );
289        tokio::time::sleep(Duration::from_millis(delay_ms)).await;
290        true
291    }
292}
293
294// ---------------------------------------------------------------------------
295// Dedup cache
296// ---------------------------------------------------------------------------
297
298/// LRU cache keyed on `trade.id`, which both paths synthesise as
299/// `"{order_id}:{cumulative_filled_qty}"`.
300///
301/// WS fills: `convert_trade_update` sets `trade_id = order.id + ":" + order.filled_qty`
302/// (cumulative from the order update payload).
303///
304/// REST fills: `recover_fills` accumulates per-execution qty per order and overrides
305/// `trade.id` to the same format before inserting into the cache.
306///
307/// Using cumulative qty (not per-execution qty) means two equal-size partial fills
308/// on the same order produce distinct keys (`order:1` and `order:2`), preventing
309/// silent fill drops.
310///
311/// [`SmolStr`] keys avoid heap allocation for IDs ≤22 bytes. UUID-length keys
312/// (36 chars) always heap-allocate in `SmolStr`; `format_smolstr!` uses an
313/// internal `String` buffer for long keys, identical in allocation cost to
314/// `format!(…).into::<SmolStr>()`. The type is kept for API consistency with
315/// other key types in this codebase.
316type SharedDedupCache = Arc<parking_lot::Mutex<LruCache<SmolStr, ()>>>;
317
318fn new_dedup_cache() -> SharedDedupCache {
319    // allow(clippy::unwrap_used) — NonZeroUsize::new on a non-zero constant
320    // cannot fail at runtime.
321    #[allow(clippy::unwrap_used)]
322    Arc::new(parking_lot::Mutex::new(LruCache::new(
323        NonZeroUsize::new(DEDUP_CACHE_SIZE).unwrap(),
324    )))
325}
326
327/// Returns `true` if this key was already seen (duplicate). Inserts if new.
328fn is_duplicate(cache: &SharedDedupCache, key: &SmolStr) -> bool {
329    let mut guard = cache.lock();
330    // peek avoids promoting to MRU on the duplicate (discard) path
331    if guard.peek(key).is_some() {
332        return true;
333    }
334    // Clone on the insert (non-duplicate) path only. UUID-length SmolStr keys
335    // heap-allocate, but the WS path is single-threaded — there is no mutex
336    // contention to justify cloning before the lock on the duplicate fast-path.
337    guard.put(key.clone(), ());
338    false
339}
340
341// ---------------------------------------------------------------------------
342// Configuration
343// ---------------------------------------------------------------------------
344
345/// Configuration for the Alpaca execution client.
346// Serialize intentionally omitted — would expose secret_key in plaintext.
347#[derive(Clone, Deserialize)]
348#[serde(deny_unknown_fields)]
349pub struct AlpacaConfig {
350    // Private fields prevent accidental credential exposure via struct access.
351    api_key: String,
352    secret_key: String,
353
354    /// Use paper trading endpoints instead of production.
355    #[serde(default = "default_paper")]
356    pub paper: bool,
357    /// Test-only: override the REST base URL (e.g., to point at a wiremock server).
358    #[cfg(test)]
359    pub base_url_override: Option<String>,
360}
361
362/// Serde default for [`AlpacaConfig::paper`]: an absent `paper` field deserializes to the **safe**
363/// paper environment (`true`).
364///
365/// `#[serde(default = "…")]` requires a named function (it cannot take a literal), so this exists
366/// purely to supply that default to the derive.
367fn default_paper() -> bool {
368    true
369}
370
371// Custom Debug to avoid leaking credentials in logs.
372impl std::fmt::Debug for AlpacaConfig {
373    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
374        f.debug_struct("AlpacaConfig")
375            .field("api_key", &"***")
376            .field("secret_key", &"***")
377            .field("paper", &self.paper)
378            .finish()
379    }
380}
381
382impl AlpacaConfig {
383    /// Create a new Alpaca config using the **safe** paper-trading endpoints (alias for
384    /// [`paper`](Self::paper)).
385    pub fn new(api_key: String, secret_key: String) -> Self {
386        Self::paper(api_key, secret_key)
387    }
388
389    /// Create a config targeting Alpaca's **paper-trading** endpoints (simulated funds).
390    pub fn paper(api_key: String, secret_key: String) -> Self {
391        Self {
392            api_key,
393            secret_key,
394            paper: true,
395            #[cfg(test)]
396            base_url_override: None,
397        }
398    }
399
400    /// Create a config targeting Alpaca's **production** endpoints.
401    ///
402    /// ⚠️ Production trades execute against the live account with **real funds**. Prefer
403    /// [`paper`](Self::paper) unless you explicitly intend live execution.
404    pub fn production(api_key: String, secret_key: String) -> Self {
405        Self {
406            api_key,
407            secret_key,
408            paper: false,
409            #[cfg(test)]
410            base_url_override: None,
411        }
412    }
413
414    /// Build a config from environment variables.
415    ///
416    /// Reads:
417    /// - `ALPACA_API_KEY` (required) — API key id.
418    /// - `ALPACA_SECRET_KEY` (required) — API secret.
419    /// - `ALPACA_PAPER` (optional) — `"true"`/`"false"` (case-insensitive). **Absent ⇒ the safe
420    ///   paper environment.** Set `ALPACA_PAPER=false` to target production (real funds).
421    ///
422    /// # Errors
423    ///
424    /// Returns [`AlpacaConfigError`] (never panics):
425    /// - a required credential var is unset ([`MissingApiKey`](AlpacaConfigError::MissingApiKey) /
426    ///   [`MissingSecretKey`](AlpacaConfigError::MissingSecretKey)) or holds non-UTF-8
427    ///   ([`InvalidApiKey`](AlpacaConfigError::InvalidApiKey) /
428    ///   [`InvalidSecretKey`](AlpacaConfigError::InvalidSecretKey));
429    /// - `ALPACA_PAPER` is neither `true` nor `false`, or holds non-UTF-8
430    ///   ([`InvalidPaper`](AlpacaConfigError::InvalidPaper)).
431    pub fn from_env() -> Result<Self, AlpacaConfigError> {
432        let api_key = match std::env::var("ALPACA_API_KEY") {
433            Ok(value) => value,
434            Err(std::env::VarError::NotPresent) => return Err(AlpacaConfigError::MissingApiKey),
435            Err(std::env::VarError::NotUnicode(_)) => {
436                return Err(AlpacaConfigError::InvalidApiKey);
437            }
438        };
439        let secret_key = match std::env::var("ALPACA_SECRET_KEY") {
440            Ok(value) => value,
441            Err(std::env::VarError::NotPresent) => return Err(AlpacaConfigError::MissingSecretKey),
442            Err(std::env::VarError::NotUnicode(_)) => {
443                return Err(AlpacaConfigError::InvalidSecretKey);
444            }
445        };
446
447        let paper = match std::env::var("ALPACA_PAPER") {
448            Ok(value) => parse_env_bool(&value).ok_or(AlpacaConfigError::InvalidPaper(value))?,
449            Err(std::env::VarError::NotPresent) => true,
450            // The toggle value is not secret, so echo it (lossily) like the parse-failure arm above —
451            // an actionable "got X" beats a hardcoded sentinel.
452            Err(std::env::VarError::NotUnicode(value)) => {
453                return Err(AlpacaConfigError::InvalidPaper(
454                    value.to_string_lossy().into_owned(),
455                ));
456            }
457        };
458
459        if paper {
460            Ok(Self::paper(api_key, secret_key))
461        } else {
462            Ok(Self::production(api_key, secret_key))
463        }
464    }
465
466    /// Test-only: create config with a custom base URL for wiremock testing.
467    #[cfg(test)]
468    pub fn with_base_url(api_key: String, secret_key: String, base_url: String) -> Self {
469        Self {
470            api_key,
471            secret_key,
472            paper: true,
473            base_url_override: Some(base_url),
474        }
475    }
476
477    pub fn api_key(&self) -> &str {
478        &self.api_key
479    }
480
481    /// Base URL for REST API calls.
482    ///
483    /// In test builds, checks `base_url_override` first to allow wiremock testing.
484    pub fn rest_base_url(&self) -> &str {
485        #[cfg(test)]
486        if let Some(ref url) = self.base_url_override {
487            return url.as_str();
488        }
489        if self.paper {
490            "https://paper-api.alpaca.markets"
491        } else {
492            "https://api.alpaca.markets"
493        }
494    }
495
496    /// WebSocket URL for trade_updates stream.
497    pub fn ws_url(&self) -> &'static str {
498        if self.paper {
499            "wss://paper-api.alpaca.markets/stream"
500        } else {
501            "wss://api.alpaca.markets/stream"
502        }
503    }
504}
505
506#[derive(Debug, PartialEq, thiserror::Error)]
507pub enum AlpacaConfigError {
508    #[error("ALPACA_API_KEY environment variable not set")]
509    MissingApiKey,
510
511    // No payload: the raw value is secret-key material, so it must never be echoed into an error
512    // message or log. The variant name already identifies which credential var is non-UTF-8.
513    #[error("ALPACA_API_KEY environment variable is not valid UTF-8")]
514    InvalidApiKey,
515
516    #[error("ALPACA_SECRET_KEY environment variable not set")]
517    MissingSecretKey,
518
519    #[error("ALPACA_SECRET_KEY environment variable is not valid UTF-8")]
520    InvalidSecretKey,
521
522    #[error("ALPACA_PAPER must be true or false, got {0}")]
523    InvalidPaper(String),
524}
525
526// ---------------------------------------------------------------------------
527// REST response serde types
528// ---------------------------------------------------------------------------
529
530#[derive(Debug, Deserialize)]
531struct AlpacaAccount {
532    equity: String,
533    buying_power: String,
534    options_buying_power: Option<String>,
535    // crypto_buying_power is present in Alpaca's account response and represents
536    // available buying power specifically for crypto orders. Not currently used in
537    // balance logic (buying_power serves as the general free USD balance), but
538    // retained so serde doesn't error on accounts where the field is present.
539    #[allow(dead_code)]
540    // retained for serde completeness; may be used for per-asset-class reporting
541    crypto_buying_power: Option<String>,
542}
543
544/// A single position returned by GET /v2/positions.
545#[derive(Debug, Deserialize)]
546struct AlpacaPosition {
547    /// Exchange symbol (e.g., "BTC/USD" for crypto, "AAPL" for equity).
548    symbol: String,
549    /// Asset class: "us_equity", "crypto", "us_option".
550    asset_class: String,
551    /// Total quantity held (base currency for crypto).
552    qty: String,
553    /// Quantity available to trade (not locked in open orders).
554    qty_available: String,
555}
556
557#[derive(Debug, Deserialize)]
558struct AlpacaOrderResponse {
559    id: String,
560    client_order_id: Option<String>,
561    symbol: String,
562    qty: Option<String>,
563    filled_qty: String,
564    side: String,
565    #[serde(rename = "type")]
566    order_type: String,
567    time_in_force: String,
568    limit_price: Option<String>,
569    stop_price: Option<String>,
570    trail_percent: Option<String>,
571    trail_price: Option<String>,
572    created_at: String,
573}
574
575#[derive(Debug, Deserialize)]
576struct AlpacaActivity {
577    id: String,
578    order_id: String,
579    symbol: String,
580    side: String,
581    price: String,
582    qty: String,
583    transaction_time: String,
584}
585
586#[derive(Debug, Deserialize)]
587struct AlpacaApiError {
588    message: String,
589}
590
591// ---------------------------------------------------------------------------
592// AlpacaPositionIntent
593// ---------------------------------------------------------------------------
594
595/// Explicit position intent for Alpaca order placement.
596///
597/// Required for options orders; valid (but optional) for equities.
598/// Omit entirely for crypto orders (causes 422 Unprocessable Entity).
599///
600/// Use `AlpacaClient::open_order_with_intent` to supply a specific intent
601/// instead of the heuristic mapping used by the `ExecutionClient` trait impl.
602#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
603#[serde(rename_all = "snake_case")]
604pub enum AlpacaPositionIntent {
605    BuyToOpen,
606    BuyToClose,
607    SellToOpen,
608    SellToClose,
609}
610
611// ---------------------------------------------------------------------------
612// Bracket order types
613// ---------------------------------------------------------------------------
614
615/// Take-profit parameters for Alpaca bracket orders.
616///
617/// The take-profit leg is always a limit order at the specified price.
618#[derive(Debug, Serialize)]
619struct TakeProfitParams {
620    limit_price: String,
621}
622
623/// Stop-loss parameters for Alpaca bracket orders.
624///
625/// When `limit_price` is `None`, the stop-loss is a stop (market) order.
626/// When `limit_price` is `Some`, the stop-loss becomes a stop-limit order.
627#[derive(Debug, Serialize)]
628struct StopLossParams {
629    stop_price: String,
630    #[serde(skip_serializing_if = "Option::is_none")]
631    limit_price: Option<String>,
632}
633
634/// Request to place a bracket order (entry + take-profit + stop-loss).
635///
636/// A bracket order consists of three linked orders submitted in a single request:
637/// 1. **Entry**: Limit order to enter the position
638/// 2. **Take Profit**: Limit order to exit at profit target
639/// 3. **Stop Loss**: Stop or stop-limit order to exit at loss limit
640///
641/// When either the take-profit or stop-loss fills, Alpaca automatically cancels
642/// the other leg.
643///
644/// # Constraints
645///
646/// - `time_in_force` must be `Day` or `GoodUntilCancelled` (no extended hours)
647/// - Entry order type is always `Limit`
648/// - Take-profit is always a `Limit` order
649/// - Stop-loss is a `Stop` order (or `StopLimit` if `stop_loss_limit_price` is set)
650///
651/// # Example
652///
653/// ```ignore
654/// let request = AlpacaBracketOrderRequest::new(
655///     "AAPL".into(),
656///     StrategyId::new("momentum"),
657///     ClientOrderId::new("bracket-001"),
658///     Side::Buy,
659///     dec!(10),
660///     dec!(150.00),  // entry
661///     dec!(160.00),  // take profit
662///     dec!(145.00),  // stop loss
663///     TimeInForce::GoodUntilCancelled { post_only: false },
664/// );
665/// // For stop-limit SL: .with_stop_loss_limit_price(dec!(144.00))
666/// let result = client.open_bracket_order(request).await;
667/// ```
668#[non_exhaustive]
669#[derive(Debug, Clone)]
670pub struct AlpacaBracketOrderRequest {
671    /// Instrument to trade.
672    pub instrument: InstrumentNameExchange,
673    /// Strategy identifier for order correlation.
674    pub strategy: StrategyId,
675    /// Client order ID for the parent (entry) order.
676    pub cid: ClientOrderId,
677    /// Buy or Sell for the entry order (exits use opposite side).
678    pub side: Side,
679    /// Number of shares/contracts.
680    pub quantity: Decimal,
681    /// Entry limit price.
682    pub entry_price: Decimal,
683    /// Take-profit limit price.
684    pub take_profit_price: Decimal,
685    /// Stop-loss trigger price.
686    pub stop_loss_price: Decimal,
687    /// Optional stop-loss limit price. When set, makes the stop-loss a stop-limit order.
688    pub stop_loss_limit_price: Option<Decimal>,
689    /// Time-in-force for all legs. Must be `Day` or `GoodUntilCancelled`.
690    pub time_in_force: TimeInForce,
691}
692
693impl AlpacaBracketOrderRequest {
694    /// Create a new bracket order request.
695    ///
696    /// For a stop-limit stop-loss leg, chain `.with_stop_loss_limit_price()`.
697    #[allow(clippy::too_many_arguments)] // Bracket orders inherently need many params
698    pub fn new(
699        instrument: InstrumentNameExchange,
700        strategy: StrategyId,
701        cid: ClientOrderId,
702        side: Side,
703        quantity: Decimal,
704        entry_price: Decimal,
705        take_profit_price: Decimal,
706        stop_loss_price: Decimal,
707        time_in_force: TimeInForce,
708    ) -> Self {
709        Self {
710            instrument,
711            strategy,
712            cid,
713            side,
714            quantity,
715            entry_price,
716            take_profit_price,
717            stop_loss_price,
718            stop_loss_limit_price: None,
719            time_in_force,
720        }
721    }
722
723    /// Set the stop-loss limit price, converting the SL leg to a stop-limit order.
724    #[must_use]
725    pub fn with_stop_loss_limit_price(mut self, price: Decimal) -> Self {
726        self.stop_loss_limit_price = Some(price);
727        self
728    }
729}
730
731/// Result of placing an Alpaca bracket order.
732///
733/// Contains the parent order with its state. The take-profit and stop-loss legs
734/// are managed by Alpaca and their status can be queried via `fetch_open_orders`.
735///
736/// # Note
737///
738/// Unlike IBKR which returns three separate orders, Alpaca's bracket API returns
739/// a single parent order. The child legs (TP/SL) are implicitly created and linked
740/// by Alpaca. Use `fetch_open_orders` to retrieve all legs after placement.
741#[non_exhaustive]
742#[derive(Debug, Clone)]
743pub struct AlpacaBracketOrderResult {
744    /// Parent (entry) order with its current state.
745    pub parent: Order<ExchangeId, InstrumentNameExchange, UnindexedOrderState>,
746}
747
748// ---------------------------------------------------------------------------
749// REST order request body
750// ---------------------------------------------------------------------------
751
752#[derive(Debug, Serialize)]
753struct AlpacaOrderRequest<'a> {
754    symbol: &'a str,
755    qty: String,
756    side: &'static str,
757    #[serde(rename = "type")]
758    order_type: &'static str,
759    time_in_force: &'static str,
760    #[serde(skip_serializing_if = "Option::is_none")]
761    limit_price: Option<String>,
762    #[serde(skip_serializing_if = "Option::is_none")]
763    stop_price: Option<String>,
764    #[serde(skip_serializing_if = "Option::is_none")]
765    trail_percent: Option<String>,
766    #[serde(skip_serializing_if = "Option::is_none")]
767    trail_price: Option<String>,
768    #[serde(skip_serializing_if = "Option::is_none")]
769    client_order_id: Option<&'a str>,
770    // position_intent: heuristic mapping (buy→buy_to_open, sell→sell_to_close).
771    // Correct for directional long-only strategies. Omit for exchanges/asset
772    // classes that don't require it (stocks/crypto ignore this field).
773    #[serde(skip_serializing_if = "Option::is_none")]
774    position_intent: Option<AlpacaPositionIntent>,
775    // Bracket order fields (order_class, take_profit, stop_loss).
776    // Set order_class to "bracket" and populate TP/SL for bracket orders.
777    #[serde(skip_serializing_if = "Option::is_none")]
778    order_class: Option<&'static str>,
779    #[serde(skip_serializing_if = "Option::is_none")]
780    take_profit: Option<TakeProfitParams>,
781    #[serde(skip_serializing_if = "Option::is_none")]
782    stop_loss: Option<StopLossParams>,
783}
784
785// ---------------------------------------------------------------------------
786// WebSocket message types
787// ---------------------------------------------------------------------------
788
789/// Outer container for all Alpaca stream messages.
790///
791/// `data` is kept as a [`serde_json::value::RawValue`] to avoid allocating a full DOM tree
792/// for heartbeats and auth/listening acks that never reach `AlpacaTradeUpdate` parsing.
793#[derive(Debug, Deserialize)]
794struct AlpacaStreamMessage<'a> {
795    // Short well-known values ("trade_updates", "listening", "authorization")
796    // all fit inline in SmolStr — avoids one heap alloc per WS message.
797    stream: SmolStr,
798    #[serde(borrow)]
799    data: &'a serde_json::value::RawValue,
800}
801
802/// Parsed payload of a `trade_updates` event.
803///
804/// Numeric and timestamp fields borrow directly from the `RawValue` input buffer
805/// (`#[serde(borrow)]`), propagating the zero-copy design of `AlpacaStreamMessage`.
806/// This eliminates 4–6 heap allocations per fill event. The borrow is valid because
807/// Alpaca's numeric and timestamp strings contain no JSON escape sequences.
808#[derive(Debug, Deserialize)]
809struct AlpacaTradeUpdate<'a> {
810    // Short event tag ("fill", "partial_fill", "new", ...) — fits inline.
811    event: SmolStr,
812    #[serde(borrow)]
813    order: AlpacaOrderWs<'a>,
814    /// Fill price for this specific execution (None for non-fill events).
815    #[serde(borrow)]
816    price: Option<&'a str>,
817    /// Quantity for this specific execution (None for non-fill events).
818    #[serde(borrow)]
819    qty: Option<&'a str>,
820    #[serde(borrow)]
821    timestamp: Option<&'a str>,
822}
823
824/// Order state embedded in a `trade_updates` WebSocket event.
825#[derive(Debug, Deserialize)]
826struct AlpacaOrderWs<'a> {
827    // UUIDs (36 chars) heap-allocate in SmolStr, but using SmolStr directly avoids
828    // an intermediate String allocation when serde deserialises the field.
829    id: SmolStr,
830    client_order_id: Option<SmolStr>,
831    // Ticker symbols ("AAPL", "BTC/USD") fit inline in SmolStr (≤23 bytes),
832    // eliminating the heap allocation entirely for most symbols.
833    symbol: SmolStr,
834    #[serde(borrow)]
835    qty: Option<&'a str>,
836    // Alpaca guarantees `filled_qty` for fill/partial_fill and most lifecycle
837    // events, but some event types (e.g. `rejected`) may omit the field.
838    // Using Option avoids a deserialization failure that would silently drop
839    // the event. Call sites use `.unwrap_or("0")`.
840    #[serde(borrow)]
841    filled_qty: Option<&'a str>,
842    // Short enums ("buy"/"sell", "market"/"limit"/..., "day"/"gtc"/..., status)
843    // — all fit inline in SmolStr.
844    side: SmolStr,
845    #[serde(rename = "type")]
846    order_type: SmolStr,
847    time_in_force: SmolStr,
848    #[serde(borrow)]
849    limit_price: Option<&'a str>,
850    #[serde(borrow)]
851    stop_price: Option<&'a str>,
852    #[serde(borrow)]
853    trail_percent: Option<&'a str>,
854    #[serde(borrow)]
855    trail_price: Option<&'a str>,
856    status: SmolStr,
857}
858
859// ---------------------------------------------------------------------------
860// AlpacaClient
861// ---------------------------------------------------------------------------
862
863/// Alpaca execution client supporting options, equities, and crypto via the
864/// single unified Alpaca trading API.
865///
866/// All three asset classes share the same REST and WebSocket endpoints. The
867/// key behavioral differences handled transparently:
868/// - Options: `position_intent` field is required (detected by OCC symbol format)
869/// - Crypto: `position_intent` is omitted (not a valid field for crypto orders);
870///   fractional quantities are supported natively via `Decimal::to_string()`
871/// - Equities: `position_intent` is valid but optional for long-only strategies
872///
873/// Cloning is cheap: all inner state is behind `Arc`.
874#[derive(Clone)]
875pub struct AlpacaClient {
876    config: Arc<AlpacaConfig>,
877    /// reqwest client with APCA-API-KEY-ID and APCA-API-SECRET-KEY pre-set as
878    /// default headers — every request carries auth automatically.
879    http: reqwest::Client,
880    rate_limiter: Arc<RateLimitTracker>,
881    /// Pre-allocated `/v2/orders` endpoint URL to avoid allocation per request.
882    orders_url: String,
883}
884
885impl std::fmt::Debug for AlpacaClient {
886    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
887        f.debug_struct("AlpacaClient")
888            .field("paper", &self.config.paper)
889            .finish_non_exhaustive()
890    }
891}
892
893impl AlpacaClient {
894    /// Build a `reqwest::Client` with Alpaca auth headers pre-set.
895    ///
896    /// # Panics
897    ///
898    /// Panics if the API key or secret contains characters that are invalid
899    /// in an HTTP header value (non-ASCII or control characters).
900    #[allow(clippy::expect_used)] // Documented panic: invalid credentials detected at startup
901    fn build_http(config: &AlpacaConfig) -> reqwest::Client {
902        use reqwest::header::{HeaderMap, HeaderName, HeaderValue};
903        let mut headers = HeaderMap::new();
904        // HTTP header names are case-insensitive; use lowercase per HTTP/2 convention.
905        headers.insert(
906            HeaderName::from_static("apca-api-key-id"),
907            HeaderValue::from_str(&config.api_key)
908                .expect("Alpaca API key contains invalid header characters"),
909        );
910        headers.insert(
911            HeaderName::from_static("apca-api-secret-key"),
912            HeaderValue::from_str(&config.secret_key)
913                .expect("Alpaca secret key contains invalid header characters"),
914        );
915        reqwest::Client::builder()
916            .default_headers(headers)
917            .build()
918            .expect("failed to build reqwest client for Alpaca")
919    }
920
921    fn base_url(&self) -> &str {
922        self.config.rest_base_url()
923    }
924}
925
926// ---------------------------------------------------------------------------
927// REST helper: rate-limited request with retry
928// ---------------------------------------------------------------------------
929
930/// Parse the `X-Ratelimit-Reset` response header into a cooldown [`Duration`].
931///
932/// The header value is a Unix epoch timestamp (seconds). Returns the duration
933/// from now until that timestamp, clamped to a minimum of 1 second to avoid
934/// a zero-delay busy loop. Returns `None` if the header is absent or malformed;
935/// callers should fall back to [`DEFAULT_RATE_LIMIT_DELAY_SECS`].
936fn parse_rate_limit_delay(headers: &reqwest::header::HeaderMap) -> Option<Duration> {
937    headers
938        .get("x-ratelimit-reset")
939        .and_then(|v| v.to_str().ok())
940        .and_then(|s| s.parse::<u64>().ok())
941        .map(|reset_ts| {
942            let now_secs = std::time::SystemTime::now()
943                .duration_since(std::time::UNIX_EPOCH)
944                .unwrap_or_default()
945                .as_secs();
946            Duration::from_secs(reset_ts.saturating_sub(now_secs).max(1))
947        })
948}
949
950/// Execute a REST request with rate-limit awareness and retry.
951///
952/// The `build_request` closure is called on every attempt so the caller doesn't
953/// need a clone of `RequestBuilder` (which may not be cloneable with streaming bodies).
954/// For GET/POST/DELETE with fixed bodies, the closure is a cheap re-construction.
955///
956/// On HTTP 429, reads `X-Ratelimit-Reset` (Unix epoch) to determine the cooldown
957/// duration and retries up to `MAX_RATE_LIMIT_ATTEMPTS - 1` times.
958async fn rest_with_retry<T>(
959    rate_limiter: &RateLimitTracker,
960    mut build_request: impl FnMut() -> reqwest::RequestBuilder,
961) -> Result<T, UnindexedClientError>
962where
963    T: for<'de> Deserialize<'de>,
964{
965    for attempt in 0..MAX_RATE_LIMIT_ATTEMPTS {
966        rate_limiter.wait_if_blocked().await;
967        let response = build_request()
968            .send()
969            .await
970            .map_err(|e| connectivity_err(format!("Alpaca REST request failed: {e}")))?;
971
972        // Check X-Ratelimit-Remaining as an early warning; if exactly 0, the next
973        // request will 429. We don't proactively pause here — let the 429 handle it —
974        // but log at debug level so it's visible in traces.
975        if response
976            .headers()
977            .get("x-ratelimit-remaining")
978            .and_then(|v| v.to_str().ok())
979            .and_then(|s| s.parse::<u32>().ok())
980            == Some(0)
981        {
982            debug!("Alpaca REST rate-limit bucket exhausted (X-Ratelimit-Remaining: 0)");
983        }
984
985        let status = response.status();
986
987        if status == reqwest::StatusCode::TOO_MANY_REQUESTS {
988            let reset_delay = parse_rate_limit_delay(response.headers());
989
990            if attempt + 1 < MAX_RATE_LIMIT_ATTEMPTS {
991                warn!(
992                    attempt = attempt + 1,
993                    max_attempts = MAX_RATE_LIMIT_ATTEMPTS,
994                    "Alpaca REST rate-limited (429), retrying"
995                );
996                rate_limiter.on_rate_limited(reset_delay);
997                continue;
998            }
999
1000            // Final attempt still rate-limited — return typed error.
1001            warn!(
1002                max_attempts = MAX_RATE_LIMIT_ATTEMPTS,
1003                "Alpaca REST rate-limit retries exhausted"
1004            );
1005            return Err(UnindexedClientError::Api(ApiError::RateLimit));
1006        }
1007
1008        // 204 No Content is only valid for DELETE endpoints; use rest_delete_with_retry
1009        // for those. Reaching here for a 204 indicates API misuse — return a clear error
1010        // rather than a misleading "EOF while parsing" JSON failure.
1011        if status == reqwest::StatusCode::NO_CONTENT {
1012            return Err(connectivity_err(
1013                "Alpaca REST returned 204 No Content — use rest_delete_with_retry for DELETE endpoints"
1014                    .to_string(),
1015            ));
1016        }
1017
1018        let bytes = response
1019            .bytes()
1020            .await
1021            .map_err(|e| connectivity_err(format!("Alpaca REST read body failed: {e}")))?;
1022
1023        if status.is_success() {
1024            return serde_json::from_slice::<T>(&bytes).map_err(|e| {
1025                connectivity_err(format!(
1026                    "Alpaca REST JSON parse error ({status}): {e} | body: {}",
1027                    String::from_utf8_lossy(&bytes)
1028                        .chars()
1029                        .take(200)
1030                        .collect::<String>()
1031                ))
1032            });
1033        }
1034
1035        // Parse API error body for a better error message.
1036        let api_err = serde_json::from_slice::<AlpacaApiError>(&bytes)
1037            .map(|e| e.message)
1038            .unwrap_or_else(|_| String::from_utf8_lossy(&bytes).into_owned());
1039
1040        // 4xx = API-level rejection (wrong parameters, auth failure, insufficient funds).
1041        // 5xx / other = server-side failure treated as connectivity error.
1042        // Callers that pattern-match on UnindexedClientError (e.g. open_order_inner) rely
1043        // on Api(ApiError) to classify business rejections vs connectivity failures.
1044        //
1045        // Uses parse_api_error for consistent classification: 422 "insufficient funds"
1046        // maps to BalanceInsufficient (not generic OrderRejected), enabling callers to
1047        // trigger balance refresh on insufficient-funds rejections.
1048        if status.is_client_error() {
1049            return Err(UnindexedClientError::Api(parse_api_error(status, &api_err)));
1050        }
1051        return Err(connectivity_err(format!(
1052            "Alpaca REST error {status}: {api_err}"
1053        )));
1054    }
1055    unreachable!("Alpaca REST retry loop exited without returning")
1056}
1057
1058/// Execute a DELETE request, returning an order error on rejection.
1059///
1060/// Handles 204 No Content (success), 422 / 403 (API rejection), and 429 (rate limit).
1061async fn rest_delete_with_retry(
1062    rate_limiter: &RateLimitTracker,
1063    mut build_request: impl FnMut() -> reqwest::RequestBuilder,
1064) -> Result<(), UnindexedOrderError> {
1065    for attempt in 0..MAX_RATE_LIMIT_ATTEMPTS {
1066        rate_limiter.wait_if_blocked().await;
1067        let response = build_request().send().await.map_err(|e| {
1068            UnindexedOrderError::Connectivity(ConnectivityError::Socket(format!(
1069                "Alpaca cancel request failed: {e}"
1070            )))
1071        })?;
1072
1073        let status = response.status();
1074
1075        if status == reqwest::StatusCode::TOO_MANY_REQUESTS {
1076            let reset_delay = parse_rate_limit_delay(response.headers());
1077
1078            if attempt + 1 < MAX_RATE_LIMIT_ATTEMPTS {
1079                warn!(
1080                    attempt = attempt + 1,
1081                    max_attempts = MAX_RATE_LIMIT_ATTEMPTS,
1082                    "Alpaca cancel rate-limited (429), retrying"
1083                );
1084                rate_limiter.on_rate_limited(reset_delay);
1085                continue;
1086            }
1087
1088            // Final attempt still rate-limited — return typed error.
1089            warn!(
1090                max_attempts = MAX_RATE_LIMIT_ATTEMPTS,
1091                "Alpaca cancel rate-limit retries exhausted"
1092            );
1093            return Err(UnindexedOrderError::Rejected(ApiError::RateLimit));
1094        }
1095
1096        // 204 No Content: cancel succeeded.
1097        if status == reqwest::StatusCode::NO_CONTENT || status.is_success() {
1098            return Ok(());
1099        }
1100
1101        let bytes = response
1102            .bytes()
1103            .await
1104            .inspect_err(
1105                |e| warn!(%e, %status, "Alpaca cancel_order: failed to read error response body"),
1106            )
1107            .unwrap_or_default();
1108        let msg = serde_json::from_slice::<AlpacaApiError>(&bytes)
1109            .map(|e| e.message)
1110            .unwrap_or_else(|_| String::from_utf8_lossy(&bytes).into_owned());
1111
1112        return Err(parse_order_error(status, &msg));
1113    }
1114    unreachable!("Alpaca cancel retry loop exited without returning")
1115}
1116
1117// ---------------------------------------------------------------------------
1118// ExecutionClient implementation
1119// ---------------------------------------------------------------------------
1120
1121impl ExecutionClient for AlpacaClient {
1122    const EXCHANGE: ExchangeId = ExchangeId::AlpacaBroker;
1123    type Config = AlpacaConfig;
1124    type AccountStream = BoxStream<'static, UnindexedAccountEvent>;
1125
1126    /// # Panics
1127    ///
1128    /// Panics if the API key or secret key contains characters invalid in an
1129    /// HTTP header value.
1130    fn new(config: Self::Config) -> Self {
1131        let http = Self::build_http(&config);
1132        let orders_url = format!("{}/v2/orders", config.rest_base_url());
1133        Self {
1134            config: Arc::new(config),
1135            http,
1136            rate_limiter: Arc::new(RateLimitTracker::new()),
1137            orders_url,
1138        }
1139    }
1140
1141    /// # Rate limit note
1142    ///
1143    /// When both USD and non-USD assets are requested (the common startup case), this
1144    /// method fetches `/v2/account` and `/v2/positions` in parallel for ~100-300ms
1145    /// latency savings. Under rate pressure, both requests may hit 429 simultaneously
1146    /// and retry independently. Operators approaching Alpaca rate limits should call
1147    /// with explicit asset filters to serialize requests if needed.
1148    async fn account_snapshot(
1149        &self,
1150        assets: &[AssetNameExchange],
1151        instruments: &[InstrumentNameExchange],
1152    ) -> Result<UnindexedAccountSnapshot, UnindexedClientError> {
1153        let base = self.base_url();
1154        let http = self.http.clone();
1155        let rl = &self.rate_limiter;
1156
1157        let wants_usd = assets.is_empty()
1158            || assets
1159                .iter()
1160                .any(|a| a.name().as_str().eq_ignore_ascii_case("usd"));
1161        let wants_non_usd = assets.is_empty()
1162            || assets
1163                .iter()
1164                .any(|a| !a.name().as_str().eq_ignore_ascii_case("usd"));
1165
1166        // Fetch account + positions in parallel when both are needed (common startup case).
1167        // URLs are extracted before the closures to avoid re-allocating on each retry attempt.
1168        let account_url = format!("{base}/v2/account");
1169        let positions_url = format!("{base}/v2/positions");
1170        let balances = match (wants_usd, wants_non_usd) {
1171            (true, true) => {
1172                let (account, positions): (AlpacaAccount, Vec<AlpacaPosition>) = tokio::try_join!(
1173                    rest_with_retry(rl, || http.get(&account_url)),
1174                    rest_with_retry(rl, || http.get(&positions_url)),
1175                )?;
1176                let mut balances = convert_account_to_balances(&account, assets);
1177                balances.extend(convert_positions_to_balances(&positions, assets));
1178                balances
1179            }
1180            (true, false) => {
1181                let account: AlpacaAccount = rest_with_retry(rl, || http.get(&account_url)).await?;
1182                convert_account_to_balances(&account, assets)
1183            }
1184            (false, true) => {
1185                let positions: Vec<AlpacaPosition> =
1186                    rest_with_retry(rl, || http.get(&positions_url)).await?;
1187                convert_positions_to_balances(&positions, assets)
1188            }
1189            (false, false) => Vec::new(),
1190        };
1191
1192        let open_orders = fetch_raw_open_orders(&http, rl, base, instruments).await?;
1193
1194        // Group open orders by instrument symbol, building InstrumentAccountSnapshots.
1195        let instrument_snapshots = build_instrument_snapshots(open_orders, instruments);
1196
1197        Ok(AccountSnapshot::new(
1198            ExchangeId::AlpacaBroker,
1199            balances,
1200            instrument_snapshots,
1201        ))
1202    }
1203
1204    /// Returns a live stream of account events (fills, order updates).
1205    ///
1206    /// # Startup race window
1207    ///
1208    /// Fills arriving between `account_snapshot` and this method being called by the
1209    /// caller are not recovered automatically — the WebSocket connection does not exist
1210    /// yet during that window. Fills that arrive after the connection is established but
1211    /// before the first poll are buffered in the tungstenite internal buffer and delivered
1212    /// normally. Callers requiring fill completeness at startup **must** call
1213    /// [`ExecutionClient::fetch_trades`] with a ~1 s lookback after calling this method.
1214    ///
1215    /// This gap also applies on every **reconnect**: during the auth+subscribe handshake
1216    /// (`connect_and_subscribe`), any `trade_updates` messages that arrive are consumed
1217    /// by the handshake loop and not forwarded. Fill events in this window are recovered
1218    /// via the REST activities endpoint (anchored to `disconnect_time`). Lifecycle events
1219    /// (`new`, `canceled`, `rejected`) consumed during the handshake are **not** recovered
1220    /// — callers must call `fetch_open_orders` after each reconnect to reconcile order state.
1221    ///
1222    /// # Fill recovery ordering
1223    ///
1224    /// After a reconnect, missed fills are recovered from the REST activities endpoint
1225    /// using `direction=asc` to match the chronological order in which the WS stream
1226    /// advanced `filled_qty`. The dedup key `"{order_id}:{cum_qty}"` is synthesised
1227    /// by accumulating per-execution qty in that order. If Alpaca returns activities
1228    /// out of chronological order (e.g. at pagination boundaries), dedup keys will
1229    /// diverge and fills may be dropped or duplicated for that order.
1230    ///
1231    /// # Lifecycle event deduplication
1232    ///
1233    /// Order lifecycle events (`new`, `canceled`, `expired`) are **not** deduplicated
1234    /// across reconnects — only fill events carry a dedup key. After a reconnect, Alpaca
1235    /// re-delivers lifecycle events for orders that were active at disconnect time.
1236    /// Specifically, Alpaca re-delivers a `new` event for **every order open at disconnect
1237    /// time**, not only orders that changed during the gap.
1238    /// Callers must make [`AccountEventKind::OrderSnapshot`] and
1239    /// [`AccountEventKind::OrderCancelled`] processing idempotent, or call
1240    /// [`ExecutionClient::fetch_open_orders`] after each reconnect to reconcile state.
1241    ///
1242    /// # Rejected orders
1243    ///
1244    /// Alpaca `rejected` events are delivered as `AccountEventKind::OrderCancelled` with
1245    /// `state: Err(OrderRejected(...))`. Match on `response.state.is_err()` to distinguish
1246    /// rejections from true cancels — do not call `.unwrap()` on `OrderCancelled.state`.
1247    ///
1248    /// # Stream drop behaviour
1249    ///
1250    /// Dropping the returned `BoxStream` initiates a graceful shutdown of the
1251    /// background `connection_manager` task: the channel close causes the task
1252    /// to send a WebSocket close frame and exit within the current heartbeat
1253    /// window (≤60 s). Any `AccountEvent` items already queued but not yet
1254    /// polled are discarded. Callers who drop and re-subscribe must call
1255    /// [`ExecutionClient::fetch_trades`] with a short lookback to recover the gap.
1256    async fn account_stream(
1257        &self,
1258        _assets: &[AssetNameExchange], // ignored — Alpaca's trade_updates stream delivers all asset classes on one channel
1259        // instruments is used to filter fill recovery (REST) after a reconnect only.
1260        // Live WS events are NOT filtered by instrument: Alpaca's trade_updates stream
1261        // delivers all account events on one channel with no per-symbol subscription.
1262        instruments: &[InstrumentNameExchange],
1263    ) -> Result<Self::AccountStream, UnindexedClientError> {
1264        // Verify the initial connection before returning the stream; distinguishes
1265        // "can't connect at all" from "connected but later disconnected".
1266        let initial_ws = connect_and_subscribe(&self.config).await?;
1267
1268        // Unbounded channel — memory grows if the consumer is slow, but fills
1269        // are never silently dropped. Silent fill loss corrupts position state;
1270        // OOM is loudly observable. The WS delivery path uses non-blocking send()
1271        // which only fails if the receiver is dropped.
1272        let (tx, rx) = mpsc::unbounded_channel::<UnindexedAccountEvent>();
1273        let dedup = new_dedup_cache();
1274        let config = self.config.clone();
1275        let http = self.http.clone();
1276        let rate_limiter = self.rate_limiter.clone();
1277        let instruments = instruments.to_vec();
1278
1279        let cm_handle = tokio::spawn(connection_manager(
1280            tx,
1281            dedup,
1282            config,
1283            http,
1284            rate_limiter,
1285            instruments,
1286            Some(initial_ws),
1287        ));
1288
1289        let rx_stream = tokio_stream::wrappers::UnboundedReceiverStream::new(rx);
1290        let guarded = GracefulShutdownStream::new(rx_stream, cm_handle);
1291        Ok(futures::StreamExt::boxed(guarded))
1292    }
1293
1294    async fn cancel_order(
1295        &self,
1296        request: OrderRequestCancel<ExchangeId, &InstrumentNameExchange>,
1297    ) -> Option<UnindexedOrderResponseCancel> {
1298        let key = crate::order::OrderKey {
1299            exchange: request.key.exchange,
1300            instrument: request.key.instrument.clone(),
1301            strategy: request.key.strategy.clone(),
1302            cid: request.key.cid.clone(),
1303        };
1304
1305        // Require the exchange order ID — Alpaca's DELETE endpoint uses the UUID.
1306        // If only clientOrderId is available, the caller should first resolve it
1307        // via fetch_open_orders.
1308        let order_id: SmolStr = match &request.state.id {
1309            Some(id) => id.0.clone(),
1310            None => {
1311                warn!(
1312                    instrument = %key.instrument,
1313                    "Alpaca cancel_order: no exchange order ID available (clientOrderId-only cancel not supported)"
1314                );
1315                return Some(crate::order::request::OrderResponseCancel {
1316                    key,
1317                    state: Err(UnindexedOrderError::Rejected(ApiError::OrderRejected(
1318                        "exchange order ID required for cancel (fetch_open_orders to resolve)"
1319                            .into(),
1320                    ))),
1321                });
1322            }
1323        };
1324
1325        let base = self.base_url();
1326        let http = self.http.clone();
1327        let url = format!("{base}/v2/orders/{order_id}");
1328
1329        match rest_delete_with_retry(&self.rate_limiter, || http.delete(&url)).await {
1330            Ok(()) => {
1331                let exchange_order_id = OrderId(order_id);
1332                // REST DELETE returns no response body, so filled_qty unavailable.
1333                // Use ZERO; downstream can reconcile via WS events or fetch_open_orders.
1334                Some(crate::order::request::OrderResponseCancel {
1335                    key,
1336                    state: Ok(Cancelled::new(exchange_order_id, Utc::now(), Decimal::ZERO)),
1337                })
1338            }
1339            Err(e) => Some(crate::order::request::OrderResponseCancel { key, state: Err(e) }),
1340        }
1341    }
1342
1343    /// # Position intent derivation
1344    ///
1345    /// Alpaca options/equities require explicit `position_intent`. This impl derives
1346    /// intent from `RequestOpen::reduce_only` and `side`:
1347    ///
1348    /// | reduce_only | side | intent       | use case                          |
1349    /// |-------------|------|--------------|-----------------------------------|
1350    /// | false       | Buy  | BuyToOpen    | open long / add to long position  |
1351    /// | false       | Sell | SellToOpen   | open short / write option         |
1352    /// | true        | Buy  | BuyToClose   | close short position              |
1353    /// | true        | Sell | SellToClose  | close long position               |
1354    ///
1355    /// For explicit control, use [`AlpacaClient::open_order_with_intent`].
1356    ///
1357    /// # Market order price
1358    ///
1359    /// The returned `Order.price` echoes the request price. For market orders this is
1360    /// typically `Decimal::ZERO` (a placeholder). The **actual fill price** arrives via
1361    /// the WebSocket `trade_updates` stream as a `Trade` event. Do not rely on
1362    /// `Order.price` from this REST ack for market order fill prices.
1363    async fn open_order(
1364        &self,
1365        request: OrderRequestOpen<ExchangeId, &InstrumentNameExchange>,
1366    ) -> Option<Order<ExchangeId, InstrumentNameExchange, UnindexedOrderState>> {
1367        let side = request.state.side;
1368        let reduce_only = request.state.reduce_only;
1369        self.open_order_inner(request, map_position_intent(side, reduce_only))
1370            .await
1371    }
1372
1373    /// Fetches balances sequentially (unlike `account_snapshot` which parallelizes).
1374    ///
1375    /// Sequential fetch is intentional for live operation: under rate pressure, parallel
1376    /// requests may both hit 429 simultaneously and retry independently, doubling the
1377    /// backoff delay. The startup latency savings from `account_snapshot`'s parallel
1378    /// fetch are worth the tradeoff there; for periodic balance refreshes during live
1379    /// trading, sequential is safer.
1380    async fn fetch_balances(
1381        &self,
1382        assets: &[AssetNameExchange],
1383    ) -> Result<Vec<AssetBalance<AssetNameExchange>>, UnindexedClientError> {
1384        let base = self.base_url();
1385        let http = self.http.clone();
1386        let mut result = Vec::new();
1387
1388        // Only fetch the account (USD balance) when USD is among the requested assets.
1389        // Crypto-only requests skip this call to conserve rate-limit budget.
1390        let wants_usd = assets.is_empty()
1391            || assets
1392                .iter()
1393                .any(|a| a.name().as_str().eq_ignore_ascii_case("usd"));
1394        if wants_usd {
1395            // Pre-allocate URL to avoid re-allocation on each retry attempt.
1396            let account_url = format!("{base}/v2/account");
1397            let account: AlpacaAccount =
1398                rest_with_retry(&self.rate_limiter, || http.get(&account_url)).await?;
1399            result.extend(convert_account_to_balances(&account, assets));
1400        }
1401
1402        // Fetch positions for non-USD asset balances (e.g., BTC, ETH from crypto holdings).
1403        let wants_non_usd = assets.is_empty()
1404            || assets
1405                .iter()
1406                .any(|a| !a.name().as_str().eq_ignore_ascii_case("usd"));
1407        if wants_non_usd {
1408            // Pre-allocate URL to avoid re-allocation on each retry attempt.
1409            let positions_url = format!("{base}/v2/positions");
1410            let positions: Vec<AlpacaPosition> =
1411                rest_with_retry(&self.rate_limiter, || http.get(&positions_url)).await?;
1412            result.extend(convert_positions_to_balances(&positions, assets));
1413        }
1414
1415        Ok(result)
1416    }
1417
1418    async fn fetch_open_orders(
1419        &self,
1420        instruments: &[InstrumentNameExchange],
1421    ) -> Result<Vec<Order<ExchangeId, InstrumentNameExchange, Open>>, UnindexedClientError> {
1422        let base = self.base_url();
1423        let http = self.http.clone();
1424
1425        let open_orders =
1426            fetch_raw_open_orders(&http, &self.rate_limiter, base, instruments).await?;
1427
1428        let result = open_orders
1429            .into_iter()
1430            .filter_map(|o| convert_open_order(&o))
1431            .collect();
1432        Ok(result)
1433    }
1434
1435    async fn fetch_trades(
1436        &self,
1437        time_since: DateTime<Utc>,
1438        instruments: &[InstrumentNameExchange],
1439    ) -> Result<Vec<Trade<AssetNameExchange, InstrumentNameExchange>>, UnindexedClientError> {
1440        let after_str = time_since.to_rfc3339();
1441        let base = self.base_url();
1442        let http = self.http.clone();
1443
1444        let page = paginate_activities(&http, &self.rate_limiter, base, &after_str).await?;
1445
1446        // Propagate truncation as an error so callers can detect incomplete results.
1447        // The crypto repo can match on `Truncated` and alert operators.
1448        if page.truncated {
1449            return Err(UnindexedClientError::Truncated {
1450                limit: MAX_ACTIVITY_PAGES,
1451            });
1452        }
1453
1454        // Empty instruments slice means "all instruments" — same convention as
1455        // fetch_open_orders. Build a set only when filtering is needed.
1456        let trades = if instruments.is_empty() {
1457            page.activities
1458                .into_iter()
1459                .filter_map(|a| convert_activity_to_trade(&a))
1460                .collect()
1461        } else {
1462            let instrument_set: fnv::FnvHashSet<&str> =
1463                instruments.iter().map(|i| i.name().as_str()).collect();
1464            page.activities
1465                .into_iter()
1466                .filter(|a| instrument_set.contains(a.symbol.as_str()))
1467                .filter_map(|a| convert_activity_to_trade(&a))
1468                .collect()
1469        };
1470
1471        Ok(trades)
1472    }
1473}
1474
1475// ---------------------------------------------------------------------------
1476// AlpacaClient public extension methods (not on ExecutionClient trait)
1477// ---------------------------------------------------------------------------
1478
1479impl AlpacaClient {
1480    /// Place an order with an explicit `position_intent` override.
1481    ///
1482    /// Use this instead of `ExecutionClient::open_order` when you need to specify
1483    /// exact position intent (e.g. `SellToOpen` for writing a short option, or
1484    /// `BuyToClose` for closing a short position by buying).
1485    ///
1486    /// `intent` is only sent for non-crypto symbols. For crypto symbols (those
1487    /// containing `/`) the field is always omitted regardless of `intent`.
1488    ///
1489    /// # Caller obligations
1490    /// The caller is responsible for passing the semantically correct intent.
1491    /// No validation is performed against the order side or existing position.
1492    pub async fn open_order_with_intent(
1493        &self,
1494        request: OrderRequestOpen<ExchangeId, &InstrumentNameExchange>,
1495        intent: AlpacaPositionIntent,
1496    ) -> Option<Order<ExchangeId, InstrumentNameExchange, UnindexedOrderState>> {
1497        self.open_order_inner(request, intent).await
1498    }
1499
1500    /// Place a bracket order (entry + take-profit + stop-loss) in a single request.
1501    ///
1502    /// A bracket order consists of three linked orders:
1503    /// 1. **Entry**: Limit order to enter the position
1504    /// 2. **Take Profit**: Limit order to exit at profit target
1505    /// 3. **Stop Loss**: Stop (or stop-limit) order to exit at loss limit
1506    ///
1507    /// When the entry order fills, Alpaca activates both exit legs. When either
1508    /// exit leg fills, Alpaca automatically cancels the other.
1509    ///
1510    /// # Constraints
1511    ///
1512    /// - `time_in_force` must be `Day` or `GoodUntilCancelled` (Alpaca rejects others)
1513    /// - No extended hours trading with bracket orders
1514    /// - Entry is always a limit order
1515    ///
1516    /// # Example
1517    ///
1518    /// ```ignore
1519    /// let request = AlpacaBracketOrderRequest::new(
1520    ///     "AAPL".into(),
1521    ///     StrategyId::new("momentum"),
1522    ///     ClientOrderId::new("bracket-001"),
1523    ///     Side::Buy,
1524    ///     dec!(10),
1525    ///     dec!(150.00),  // entry
1526    ///     dec!(160.00),  // take profit
1527    ///     dec!(145.00),  // stop loss
1528    ///     TimeInForce::GoodUntilCancelled { post_only: false },
1529    /// );
1530    /// let result = client.open_bracket_order(request).await;
1531    /// ```
1532    pub async fn open_bracket_order(
1533        &self,
1534        request: AlpacaBracketOrderRequest,
1535    ) -> AlpacaBracketOrderResult {
1536        let order_key = crate::order::OrderKey::new(
1537            ExchangeId::AlpacaBroker,
1538            request.instrument.clone(),
1539            request.strategy.clone(),
1540            request.cid.clone(),
1541        );
1542
1543        // Validate time_in_force: bracket orders only support day or gtc
1544        let tif_str = match request.time_in_force {
1545            TimeInForce::GoodUntilEndOfDay => "day",
1546            TimeInForce::GoodUntilCancelled { post_only } => {
1547                if post_only {
1548                    return AlpacaBracketOrderResult {
1549                        parent: Order {
1550                            key: order_key,
1551                            side: request.side,
1552                            price: Some(request.entry_price),
1553                            quantity: request.quantity,
1554                            kind: OrderKind::Limit,
1555                            time_in_force: request.time_in_force,
1556                            state: OrderState::inactive(OrderError::Rejected(
1557                                ApiError::OrderRejected(
1558                                    "Alpaca does not support post_only for bracket orders"
1559                                        .to_string(),
1560                                ),
1561                            )),
1562                        },
1563                    };
1564                }
1565                "gtc"
1566            }
1567            other => {
1568                return AlpacaBracketOrderResult {
1569                    parent: Order {
1570                        key: order_key,
1571                        side: request.side,
1572                        price: Some(request.entry_price),
1573                        quantity: request.quantity,
1574                        kind: OrderKind::Limit,
1575                        time_in_force: request.time_in_force,
1576                        state: OrderState::inactive(OrderError::Rejected(ApiError::OrderRejected(
1577                            format!(
1578                                "Alpaca bracket orders only support Day or GTC time_in_force, got {:?}",
1579                                other
1580                            ),
1581                        ))),
1582                    },
1583                };
1584            }
1585        };
1586
1587        // Validate price ordering. Alpaca rejects mis-ordered brackets with a
1588        // generic 422; surface a structured local error before round-trip.
1589        let price_ordering_ok = match request.side {
1590            Side::Buy => {
1591                request.stop_loss_price < request.entry_price
1592                    && request.entry_price < request.take_profit_price
1593            }
1594            Side::Sell => {
1595                request.take_profit_price < request.entry_price
1596                    && request.entry_price < request.stop_loss_price
1597            }
1598        };
1599        if !price_ordering_ok {
1600            return AlpacaBracketOrderResult {
1601                parent: Order {
1602                    key: order_key,
1603                    side: request.side,
1604                    price: Some(request.entry_price),
1605                    quantity: request.quantity,
1606                    kind: OrderKind::Limit,
1607                    time_in_force: request.time_in_force,
1608                    state: OrderState::inactive(OrderError::Rejected(ApiError::OrderRejected(
1609                        format!(
1610                            "Invalid bracket price ordering for {:?} side: entry={}, take_profit={}, stop_loss={}",
1611                            request.side,
1612                            request.entry_price,
1613                            request.take_profit_price,
1614                            request.stop_loss_price,
1615                        ),
1616                    ))),
1617                },
1618            };
1619        }
1620
1621        // Validate stop-loss limit price if provided. For a Buy bracket, the SL
1622        // is a sell stop(-limit); the limit must be at or below the trigger so
1623        // the order can fill if price gaps down. Reverse for Sell brackets.
1624        if let Some(sl_limit) = request.stop_loss_limit_price {
1625            let sl_limit_ok = match request.side {
1626                Side::Buy => sl_limit <= request.stop_loss_price,
1627                Side::Sell => sl_limit >= request.stop_loss_price,
1628            };
1629            if !sl_limit_ok {
1630                return AlpacaBracketOrderResult {
1631                    parent: Order {
1632                        key: order_key,
1633                        side: request.side,
1634                        price: Some(request.entry_price),
1635                        quantity: request.quantity,
1636                        kind: OrderKind::Limit,
1637                        time_in_force: request.time_in_force,
1638                        state: OrderState::inactive(OrderError::Rejected(ApiError::OrderRejected(
1639                            format!(
1640                                "Invalid stop-loss limit price for {:?} bracket: \
1641                                 stop_loss_price={}, stop_loss_limit_price={}",
1642                                request.side, request.stop_loss_price, sl_limit,
1643                            ),
1644                        ))),
1645                    },
1646                };
1647            }
1648        }
1649
1650        // Build the bracket order request
1651        let body = AlpacaOrderRequest {
1652            symbol: request.instrument.name().as_str(),
1653            qty: request.quantity.to_string(),
1654            side: map_side(request.side),
1655            order_type: "limit",
1656            time_in_force: tif_str,
1657            limit_price: Some(request.entry_price.to_string()),
1658            stop_price: None,
1659            trail_percent: None,
1660            trail_price: None,
1661            client_order_id: Some(request.cid.0.as_str()),
1662            position_intent: if is_options_or_equity_symbol(request.instrument.name().as_str()) {
1663                Some(map_position_intent(request.side, false))
1664            } else {
1665                None
1666            },
1667            order_class: Some("bracket"),
1668            take_profit: Some(TakeProfitParams {
1669                limit_price: request.take_profit_price.to_string(),
1670            }),
1671            stop_loss: Some(StopLossParams {
1672                stop_price: request.stop_loss_price.to_string(),
1673                limit_price: request.stop_loss_limit_price.map(|p| p.to_string()),
1674            }),
1675        };
1676
1677        let http = self.http.clone();
1678        let rl = &self.rate_limiter;
1679
1680        let result: Result<AlpacaOrderResponse, UnindexedClientError> =
1681            rest_with_retry(rl, || http.post(&self.orders_url).json(&body)).await;
1682
1683        match result {
1684            Ok(resp) => {
1685                let exchange_order_id = OrderId(SmolStr::new(&resp.id));
1686                let time_exchange = parse_timestamp(&resp.created_at).unwrap_or_else(Utc::now);
1687                let filled_qty = Decimal::from_str(&resp.filled_qty).unwrap_or(Decimal::ZERO);
1688
1689                let state = if filled_qty >= request.quantity {
1690                    OrderState::fully_filled(Filled::new(
1691                        exchange_order_id,
1692                        time_exchange,
1693                        filled_qty,
1694                        None,
1695                    ))
1696                } else {
1697                    OrderState::active(Open::new(exchange_order_id, time_exchange, filled_qty))
1698                };
1699
1700                AlpacaBracketOrderResult {
1701                    parent: Order {
1702                        key: order_key,
1703                        side: request.side,
1704                        price: Some(request.entry_price),
1705                        quantity: request.quantity,
1706                        kind: OrderKind::Limit,
1707                        time_in_force: request.time_in_force,
1708                        state,
1709                    },
1710                }
1711            }
1712            Err(e) => {
1713                let order_err = match e {
1714                    UnindexedClientError::Connectivity(ce) => OrderError::Connectivity(ce),
1715                    UnindexedClientError::Api(ae) => OrderError::Rejected(ae),
1716                    UnindexedClientError::TaskFailed(_)
1717                    | UnindexedClientError::Internal(_)
1718                    | UnindexedClientError::Truncated { .. }
1719                    | UnindexedClientError::TruncatedSnapshot { .. } => {
1720                        unreachable!("rest_with_retry (order path) does not produce these variants")
1721                    }
1722                };
1723                AlpacaBracketOrderResult {
1724                    parent: Order {
1725                        key: order_key,
1726                        side: request.side,
1727                        price: Some(request.entry_price),
1728                        quantity: request.quantity,
1729                        kind: OrderKind::Limit,
1730                        time_in_force: request.time_in_force,
1731                        state: OrderState::inactive(order_err),
1732                    },
1733                }
1734            }
1735        }
1736    }
1737
1738    async fn open_order_inner(
1739        &self,
1740        request: OrderRequestOpen<ExchangeId, &InstrumentNameExchange>,
1741        intent: AlpacaPositionIntent,
1742    ) -> Option<Order<ExchangeId, InstrumentNameExchange, UnindexedOrderState>> {
1743        let instrument = request.key.instrument.clone();
1744        let side = request.state.side;
1745        let price = request.state.price;
1746        let quantity = request.state.quantity;
1747        let kind = request.state.kind;
1748        let time_in_force = request.state.time_in_force;
1749        let cid = request.key.cid.clone();
1750
1751        let order_key = crate::order::OrderKey::new(
1752            ExchangeId::AlpacaBroker,
1753            instrument.clone(),
1754            request.key.strategy.clone(),
1755            cid.clone(),
1756        );
1757
1758        // Validate time_in_force before building the request — reject post_only early.
1759        let tif_str = match map_time_in_force(time_in_force) {
1760            Ok(s) => s,
1761            Err(msg) => {
1762                return Some(Order {
1763                    key: order_key,
1764                    side,
1765                    price,
1766                    quantity,
1767                    kind,
1768                    time_in_force,
1769                    state: OrderState::inactive(OrderError::Rejected(ApiError::OrderRejected(
1770                        msg.to_string(),
1771                    ))),
1772                });
1773            }
1774        };
1775
1776        // Validate order kind — reject unsupported types early.
1777        let order_type_str = match map_order_kind(kind) {
1778            Some(s) => s,
1779            None => {
1780                return Some(Order {
1781                    key: order_key,
1782                    side,
1783                    price,
1784                    quantity,
1785                    kind,
1786                    time_in_force,
1787                    state: OrderState::inactive(OrderError::UnsupportedOrderType(format!(
1788                        "Alpaca connector does not yet support OrderKind::{kind:?}"
1789                    ))),
1790                });
1791            }
1792        };
1793
1794        // Validate trailing stop offset type — Alpaca only supports percent and absolute.
1795        if let OrderKind::TrailingStop {
1796            offset_type: TrailingOffsetType::BasisPoints,
1797            ..
1798        } = kind
1799        {
1800            return Some(Order {
1801                key: order_key,
1802                side,
1803                price,
1804                quantity,
1805                kind,
1806                time_in_force,
1807                state: OrderState::inactive(OrderError::UnsupportedOrderType(
1808                    "Alpaca does not support TrailingOffsetType::BasisPoints; \
1809                     use Percentage or Absolute"
1810                        .to_string(),
1811                )),
1812            });
1813        }
1814
1815        // StopLimit requires Order.price (the limit price applied once trigger fires).
1816        // Guard locally to avoid a wire round-trip producing a generic 422.
1817        if matches!(kind, OrderKind::StopLimit { .. }) && price.is_none() {
1818            return Some(Order {
1819                key: order_key,
1820                side,
1821                price,
1822                quantity,
1823                kind,
1824                time_in_force,
1825                state: OrderState::inactive(OrderError::Rejected(ApiError::OrderRejected(
1826                    "StopLimit order requires Order.price (the limit price) to be set".to_string(),
1827                ))),
1828            });
1829        }
1830
1831        // Extract stop/trailing parameters based on order kind.
1832        let (stop_price, trail_percent, trail_price) = match kind {
1833            OrderKind::Stop { trigger_price } | OrderKind::StopLimit { trigger_price } => {
1834                (Some(trigger_price.to_string()), None, None)
1835            }
1836            OrderKind::TrailingStop {
1837                offset,
1838                offset_type,
1839            } => match offset_type {
1840                TrailingOffsetType::Percentage => (None, Some(offset.to_string()), None),
1841                TrailingOffsetType::Absolute => (None, None, Some(offset.to_string())),
1842                TrailingOffsetType::BasisPoints => unreachable!("validated above"),
1843            },
1844            // TakeProfit/TakeProfitLimit rejected by map_order_kind → unreachable here
1845            OrderKind::Market
1846            | OrderKind::Limit
1847            | OrderKind::TakeProfit { .. }
1848            | OrderKind::TakeProfitLimit { .. }
1849            | OrderKind::TrailingStopLimit { .. } => (None, None, None),
1850        };
1851
1852        let body = AlpacaOrderRequest {
1853            symbol: instrument.name().as_str(),
1854            qty: quantity.to_string(),
1855            side: map_side(side),
1856            order_type: order_type_str,
1857            time_in_force: tif_str,
1858            limit_price: match kind {
1859                OrderKind::Limit | OrderKind::StopLimit { .. } => price.map(|p| p.to_string()),
1860                _ => None,
1861            },
1862            stop_price,
1863            trail_percent,
1864            trail_price,
1865            client_order_id: Some(cid.0.as_str()),
1866            // position_intent is required for options orders and valid (but optional)
1867            // for equities. It is NOT a valid field for crypto orders and must be
1868            // omitted, or Alpaca will return 422 Unprocessable Entity.
1869            // We detect options by OCC symbol format; equities also get the field
1870            // as it aids intent tracking on margin accounts.
1871            position_intent: if is_options_or_equity_symbol(instrument.name().as_str()) {
1872                Some(intent)
1873            } else {
1874                None
1875            },
1876            // Non-bracket order: no bracket fields
1877            order_class: None,
1878            take_profit: None,
1879            stop_loss: None,
1880        };
1881
1882        let http = self.http.clone();
1883        let rl = &self.rate_limiter;
1884
1885        let result: Result<AlpacaOrderResponse, UnindexedClientError> =
1886            rest_with_retry(rl, || http.post(&self.orders_url).json(&body)).await;
1887
1888        match result {
1889            Ok(resp) => {
1890                let exchange_order_id = OrderId(SmolStr::new(&resp.id));
1891                let time_exchange = parse_timestamp(&resp.created_at).unwrap_or_else(Utc::now);
1892                let filled_qty = Decimal::from_str(&resp.filled_qty).unwrap_or(Decimal::ZERO);
1893
1894                let state = if filled_qty >= quantity {
1895                    // Order was fully filled immediately (market order or aggressive limit)
1896                    OrderState::fully_filled(Filled::new(
1897                        exchange_order_id,
1898                        time_exchange,
1899                        filled_qty,
1900                        None, // Alpaca order response doesn't include avg_price
1901                    ))
1902                } else {
1903                    // Order is resting on the order book (partially filled or unfilled)
1904                    OrderState::active(Open::new(exchange_order_id, time_exchange, filled_qty))
1905                };
1906
1907                Some(Order {
1908                    key: order_key,
1909                    side,
1910                    price,
1911                    quantity,
1912                    kind,
1913                    time_in_force,
1914                    state,
1915                })
1916            }
1917            Err(e) => {
1918                let order_err = match e {
1919                    UnindexedClientError::Connectivity(ce) => OrderError::Connectivity(ce),
1920                    UnindexedClientError::Api(ae) => OrderError::Rejected(ae),
1921                    // TaskFailed, Internal, Truncated, and TruncatedSnapshot are not
1922                    // returned by rest_with_retry (REST-only path for orders), but matching
1923                    // explicitly ensures any new ClientError variant causes a compile error
1924                    // here rather than being silently misclassified. If a future refactor
1925                    // makes them reachable, the panic surfaces the bug loudly rather than
1926                    // producing a wrong error type.
1927                    UnindexedClientError::TaskFailed(_)
1928                    | UnindexedClientError::Internal(_)
1929                    | UnindexedClientError::Truncated { .. }
1930                    | UnindexedClientError::TruncatedSnapshot { .. } => {
1931                        unreachable!(
1932                            "rest_with_retry (order path) does not produce TaskFailed/Internal/Truncated/TruncatedSnapshot variants"
1933                        )
1934                    }
1935                };
1936                Some(Order {
1937                    key: order_key,
1938                    side,
1939                    price,
1940                    quantity,
1941                    kind,
1942                    time_in_force,
1943                    state: OrderState::inactive(order_err),
1944                })
1945            }
1946        }
1947    }
1948}
1949
1950// ---------------------------------------------------------------------------
1951// REST helpers
1952// ---------------------------------------------------------------------------
1953
1954/// Maximum number of open orders returned by a single `/v2/orders` request.
1955///
1956/// Alpaca's API caps at 500; accounts exceeding this have an incomplete snapshot.
1957const MAX_OPEN_ORDERS: usize = 500;
1958
1959/// Fetch all open orders from Alpaca, optionally filtered by symbol.
1960///
1961/// # Errors
1962///
1963/// Returns [`UnindexedClientError::TruncatedSnapshot`] when exactly 500 results
1964/// are returned, indicating the API limit was likely hit and data may be incomplete.
1965/// This is an Alpaca API limitation with no pagination support for open orders.
1966async fn fetch_raw_open_orders(
1967    http: &reqwest::Client,
1968    rate_limiter: &RateLimitTracker,
1969    base: &str,
1970    instruments: &[InstrumentNameExchange],
1971) -> Result<Vec<AlpacaOrderResponse>, UnindexedClientError> {
1972    let orders: Vec<AlpacaOrderResponse> = if instruments.is_empty() {
1973        rest_with_retry(rate_limiter, || {
1974            http.get(format!("{base}/v2/orders"))
1975                .query(&[("status", "open"), ("limit", "500")])
1976        })
1977        .await?
1978    } else {
1979        let symbols = instruments.iter().map(|i| i.name().as_str()).join(",");
1980        rest_with_retry(rate_limiter, || {
1981            http.get(format!("{base}/v2/orders")).query(&[
1982                ("status", "open"),
1983                ("limit", "500"),
1984                ("symbols", &symbols),
1985            ])
1986        })
1987        .await?
1988    };
1989    if orders.len() == MAX_OPEN_ORDERS {
1990        warn!(
1991            limit = MAX_OPEN_ORDERS,
1992            "Alpaca fetch_raw_open_orders: received exactly {MAX_OPEN_ORDERS} results — \
1993             response is likely truncated"
1994        );
1995        return Err(UnindexedClientError::TruncatedSnapshot {
1996            limit: MAX_OPEN_ORDERS,
1997        });
1998    }
1999    Ok(orders)
2000}
2001
2002// ---------------------------------------------------------------------------
2003// Activity pagination
2004// ---------------------------------------------------------------------------
2005
2006/// Maximum number of pages fetched by [`paginate_activities`].
2007///
2008/// 50 pages × 100 items = 5 000 fills. Exceeding this during recovery indicates
2009/// an unusually long outage; we warn and truncate rather than looping forever.
2010const MAX_ACTIVITY_PAGES: usize = 50;
2011
2012/// Result of [`paginate_activities`] including truncation status.
2013///
2014/// When `truncated` is true, the `activities` vector contains a partial result
2015/// capped at [`MAX_ACTIVITY_PAGES`] pages. Callers should handle this case
2016/// appropriately — typically by alerting operators about potential data loss.
2017struct ActivityPage {
2018    activities: Vec<AlpacaActivity>,
2019    truncated: bool,
2020}
2021
2022/// Fetch all FILL activities since `after` using token-based pagination.
2023///
2024/// Alpaca returns up to `ALPACA_MAX_ACTIVITIES` per page. If a full page is
2025/// returned, the next request uses the last item's `id` as the `page_token`.
2026/// Pagination terminates when a page has fewer items than `page_size`, or after
2027/// [`MAX_ACTIVITY_PAGES`] pages (whichever comes first).
2028///
2029/// Returns [`ActivityPage`] with `truncated = true` if the page limit was reached,
2030/// allowing callers to detect and handle partial results.
2031// Compile-time string form of ALPACA_MAX_ACTIVITIES (avoids runtime to_string() allocation).
2032const PAGE_SIZE_STR: &str = "100"; // must match ALPACA_MAX_ACTIVITIES
2033const _: () = assert!(
2034    ALPACA_MAX_ACTIVITIES == 100,
2035    "PAGE_SIZE_STR must be updated to match ALPACA_MAX_ACTIVITIES",
2036);
2037
2038async fn paginate_activities(
2039    http: &reqwest::Client,
2040    rate_limiter: &RateLimitTracker,
2041    base: &str,
2042    after: &str,
2043) -> Result<ActivityPage, UnindexedClientError> {
2044    let mut all = Vec::with_capacity(ALPACA_MAX_ACTIVITIES);
2045    let mut page_token: Option<String> = None;
2046    let mut pages = 0usize;
2047    let mut truncated = false;
2048
2049    loop {
2050        if pages >= MAX_ACTIVITY_PAGES {
2051            truncated = true;
2052            break;
2053        }
2054        pages += 1;
2055        // Borrow page_token as &str so the closure can capture by reference without cloning.
2056        let page_token_ref = page_token.as_deref();
2057        let activities: Vec<AlpacaActivity> = rest_with_retry(rate_limiter, || {
2058            let mut req = http.get(format!("{base}/v2/account/activities")).query(&[
2059                ("activity_type", "FILL"),
2060                ("after", after),
2061                ("page_size", PAGE_SIZE_STR),
2062                ("direction", "asc"),
2063            ]);
2064            if let Some(token) = page_token_ref {
2065                req = req.query(&[("page_token", token)]);
2066            }
2067            req
2068        })
2069        .await?;
2070
2071        let page_len = activities.len();
2072        // Capture the page token from the current page BEFORE extending `all`, so that
2073        // any future filtering between here and all.extend cannot shift the last element
2074        // and cause the same page to be re-fetched indefinitely.
2075        //
2076        // Alpaca activity IDs are ULID-format (monotonically increasing) and are accepted
2077        // as exclusive page_token cursors by GET /v2/account/activities: the next page
2078        // begins with the item AFTER the token, so the boundary item is not re-delivered.
2079        // The pagination contract relies on this property — verify against Alpaca API docs
2080        // if behaviour changes.
2081        let page_token_candidate = activities.last().map(|a| a.id.clone());
2082        all.extend(activities);
2083
2084        if page_len < ALPACA_MAX_ACTIVITIES {
2085            break;
2086        }
2087        match page_token_candidate {
2088            Some(token) if !token.is_empty() => {
2089                debug!("Alpaca paginate_activities: fetching next page ({page_len} results)");
2090                page_token = Some(token);
2091            }
2092            // Empty token or no last item — pagination complete.
2093            // Guard against empty string to prevent infinite loop if Alpaca ever
2094            // returns a full page with last.id = "" (would restart from beginning).
2095            _ => break,
2096        }
2097    }
2098
2099    Ok(ActivityPage {
2100        activities: all,
2101        truncated,
2102    })
2103}
2104
2105// ---------------------------------------------------------------------------
2106// WebSocket connection manager
2107// ---------------------------------------------------------------------------
2108
2109/// Long-running task managing the WebSocket lifecycle for account_stream.
2110///
2111/// Loop: connect → auth → subscribe → stream events → on disconnect → backoff
2112/// → fill recovery → reconnect. The `tx` channel persists across reconnections
2113/// so the consumer sees a seamless event stream.
2114///
2115/// Terminates when the consumer drops the stream or max reconnect attempts are
2116/// exhausted.
2117#[allow(clippy::cognitive_complexity)] // the inner select! loop owns `ws` and mutates `backoff` —
2118// extracting to a function requires threading 4 non-Clone values (ws, tx, dedup, backoff)
2119// through the call, which adds more complexity than it removes
2120async fn connection_manager(
2121    tx: mpsc::UnboundedSender<UnindexedAccountEvent>,
2122    dedup: SharedDedupCache,
2123    config: Arc<AlpacaConfig>,
2124    http: reqwest::Client,
2125    rate_limiter: Arc<RateLimitTracker>,
2126    instruments: Vec<InstrumentNameExchange>,
2127    initial_ws: Option<WebSocket>,
2128) {
2129    let mut backoff = ExponentialBackoff::new();
2130    let mut disconnect_time: Option<DateTime<Utc>> = None;
2131    let mut current_ws = initial_ws;
2132
2133    'outer: loop {
2134        // --- Connect (skip on first iteration if initial_ws was provided) ---
2135        let mut ws = match current_ws.take() {
2136            Some(ws) => ws,
2137            None => match connect_and_subscribe(&config).await {
2138                Ok(ws) => ws,
2139                Err(e) => {
2140                    error!(%e, "Alpaca WS connect/subscribe failed");
2141                    if !backoff.wait().await {
2142                        error!("Alpaca max reconnect attempts exhausted");
2143                        // Signal terminal stream death in-band before tx drops.
2144                        emit_stream_terminated(
2145                            &tx,
2146                            ExchangeId::AlpacaBroker,
2147                            StreamTerminationReason::ReconnectBudgetExhausted {
2148                                attempts: backoff.attempts(),
2149                                last_error: e.to_string(),
2150                            },
2151                        );
2152                        break;
2153                    }
2154                    continue;
2155                }
2156            },
2157        };
2158        info!("Alpaca account_stream connected and subscribed");
2159        // Session established — reset backoff regardless of whether any text events
2160        // arrive. Without this, a heartbeat-only session (Pings only, no trade_updates)
2161        // would never call process_ws_text and therefore never reset the counter,
2162        // causing the next disconnect to exhaust the reconnect budget faster than expected.
2163        //
2164        // Edge case: if the server accepts the WS handshake and immediately closes the
2165        // connection (fast-accept-close loop), backoff resets on every iteration, causing
2166        // all MAX_RECONNECT_ATTEMPTS attempts to run at INITIAL_BACKOFF_MS rather than
2167        // escalating. This is accepted behaviour for this pathological server scenario —
2168        // the 10-attempt budget still provides ~10 s of protection before giving up.
2169        backoff.reset();
2170
2171        // --- Fill recovery after reconnect ---
2172        // Runs before the event loop so live events arriving during recovery are
2173        // captured by the already-connected WS session. The dedup cache prevents
2174        // duplicates between recovered REST fills and live WS events.
2175        if let Some(dt) = disconnect_time.take() {
2176            let base = config.rest_base_url();
2177            let after_str = dt.to_rfc3339();
2178            match tokio::time::timeout(
2179                Duration::from_secs(FILL_RECOVERY_TIMEOUT_SECS),
2180                recover_fills(
2181                    &http,
2182                    &rate_limiter,
2183                    &instruments,
2184                    base,
2185                    &after_str,
2186                    &tx,
2187                    &dedup,
2188                ),
2189            )
2190            .await
2191            {
2192                Ok(()) => {}
2193                Err(_) => warn!(
2194                    timeout_secs = FILL_RECOVERY_TIMEOUT_SECS,
2195                    "Alpaca fill recovery timed out — some fills may be missing"
2196                ),
2197            }
2198        }
2199
2200        // --- Stream events ---
2201        // Each iteration of the inner loop polls ws.next(), a heartbeat timer,
2202        // and tx.closed() simultaneously via select!. The heartbeat deadline is
2203        // reset on every received message (rolling window).
2204        //
2205        // The timer is pinned once and reset in-place via Sleep::reset(), avoiding
2206        // a new Sleep allocation and Tokio timer-wheel registration per loop iteration.
2207        // Track the wall-clock time of the last received message. Used to anchor
2208        // fill recovery after a heartbeat timeout: Utc::now() at reconnect time
2209        // would be ~HEARTBEAT_TIMEOUT_SECS after the last real message, causing
2210        // the recovery window to miss fills in that silent period.
2211
2212        let mut last_message_time = Utc::now();
2213        let heartbeat = tokio::time::sleep(Duration::from_secs(HEARTBEAT_TIMEOUT_SECS));
2214        tokio::pin!(heartbeat);
2215
2216        // Resets the rolling heartbeat deadline and records the wall-clock receive time.
2217        // Pin<&mut Sleep> cannot be passed to a regular function, so a macro avoids
2218        // repeating the same two-line block across every message-bearing select! arm.
2219        // NOTE: must be defined AFTER the variables it captures for macro hygiene.
2220        macro_rules! reset_heartbeat {
2221            () => {
2222                heartbeat.as_mut().reset(
2223                    tokio::time::Instant::now() + Duration::from_secs(HEARTBEAT_TIMEOUT_SECS),
2224                );
2225                last_message_time = Utc::now();
2226            };
2227        }
2228        // Distinguishes why the inner stream loop exited so the terminal emit below can report a
2229        // meaningful `last_error`. The consumer-drop arm uses `break 'outer` and never yields one.
2230        enum DisconnectReason {
2231            ServerClose,
2232            Error(String),
2233            StreamEnded,
2234            HeartbeatTimeout,
2235        }
2236        let reason = loop {
2237            tokio::select! {
2238                msg = ws.next() => {
2239                    match msg {
2240                        Some(Ok(WsMessage::Ping(_))) => {
2241                            // tokio-tungstenite automatically queues a Pong when poll_next
2242                            // returns a Ping; sending a second Pong would be a duplicate.
2243                            reset_heartbeat!();
2244                        }
2245                        Some(Ok(WsMessage::Text(text))) => {
2246                            process_ws_text(text.as_str(), &tx, &dedup, &mut backoff);
2247                            reset_heartbeat!();
2248                        }
2249                        Some(Ok(WsMessage::Binary(bytes))) => {
2250                            // Alpaca paper trading sends binary-framed JSON.
2251                            match std::str::from_utf8(&bytes) {
2252                                Ok(text) => {
2253                                    process_ws_text(text, &tx, &dedup, &mut backoff);
2254                                    // Only reset heartbeat for valid UTF-8 frames that may carry
2255                                    // real events. A corrupt binary frame (e.g. from a proxy)
2256                                    // must not keep the watchdog from firing.
2257                                    reset_heartbeat!();
2258                                }
2259                                Err(e) => warn!(%e, "Alpaca WS binary frame: not valid UTF-8"),
2260                            }
2261                        }
2262                        Some(Ok(WsMessage::Close(frame))) => {
2263                            warn!(frame = ?frame, "Alpaca WS closed by server");
2264                            break DisconnectReason::ServerClose;
2265                        }
2266                        Some(Ok(_)) => {} // Pong, Frame — ignore
2267                        Some(Err(e)) => {
2268                            warn!(%e, "Alpaca WS error, reconnecting");
2269                            break DisconnectReason::Error(e.to_string());
2270                        }
2271                        None => {
2272                            warn!("Alpaca WS stream ended, reconnecting");
2273                            break DisconnectReason::StreamEnded;
2274                        }
2275                    }
2276                }
2277                _ = &mut heartbeat => {
2278                    warn!(
2279                        timeout_secs = HEARTBEAT_TIMEOUT_SECS,
2280                        "Alpaca heartbeat timeout, reconnecting"
2281                    );
2282                    // Heartbeat timeout is a failure — do NOT reset backoff here.
2283                    // Backoff resets on successful event receipt in process_ws_text.
2284                    break DisconnectReason::HeartbeatTimeout;
2285                }
2286                _ = tx.closed() => {
2287                    debug!("Alpaca account_stream consumer dropped, terminating");
2288                    let _ = tokio::time::timeout(
2289                        Duration::from_secs(WS_CLOSE_TIMEOUT_SECS),
2290                        ws.close(None),
2291                    ).await;
2292                    // Consumer dropped the receiver — no StreamTerminated emit (channel already
2293                    // closed, so it would be a no-op; see emit_stream_terminated docs).
2294                    break 'outer;
2295                }
2296            }
2297        };
2298
2299        // --- Record disconnect time for fill recovery ---
2300        // Anchor to last_message_time, not Utc::now(). For heartbeat-triggered
2301        // disconnects, Utc::now() would be ~HEARTBEAT_TIMEOUT_SECS after the last
2302        // real message, causing the recovery window to miss fills in that gap.
2303        disconnect_time =
2304            Some(last_message_time - chrono::Duration::milliseconds(SIGNAL_RECOVERY_LOOKBACK_MS));
2305
2306        // --- Close stale WS ---
2307        let _ =
2308            tokio::time::timeout(Duration::from_secs(WS_CLOSE_TIMEOUT_SECS), ws.close(None)).await;
2309
2310        if tx.is_closed() {
2311            // Consumer dropped the receiver — no StreamTerminated emit (channel already closed,
2312            // so it would be a no-op; see emit_stream_terminated docs).
2313            break;
2314        }
2315        if !backoff.wait().await {
2316            error!("Alpaca max reconnect attempts exhausted, stream terminating");
2317            // Surrender after repeated reconnects: report the failure that triggered this round
2318            // (consumer-drop already broke out via `break 'outer`).
2319            let last_error = match reason {
2320                DisconnectReason::HeartbeatTimeout => {
2321                    format!("heartbeat timeout ({HEARTBEAT_TIMEOUT_SECS}s)")
2322                }
2323                DisconnectReason::ServerClose => "WebSocket closed by server".to_string(),
2324                DisconnectReason::Error(e) => e,
2325                DisconnectReason::StreamEnded => "WebSocket stream ended".to_string(),
2326            };
2327            emit_stream_terminated(
2328                &tx,
2329                ExchangeId::AlpacaBroker,
2330                StreamTerminationReason::ReconnectBudgetExhausted {
2331                    attempts: backoff.attempts(),
2332                    last_error,
2333                },
2334            );
2335            break;
2336        }
2337    }
2338}
2339
2340/// Error during WebSocket handshake (auth + subscribe).
2341///
2342/// Separates transport errors (network issues, connection drops) from auth errors
2343/// (invalid credentials) so callers can apply appropriate retry logic.
2344#[derive(Debug)]
2345enum HandshakeError {
2346    /// Network-level failure (WS send failed, connection dropped).
2347    /// Transient — retry with backoff.
2348    Transport(String),
2349    /// Authentication rejected by Alpaca (invalid/expired credentials).
2350    /// Not transient — do not retry without credential changes.
2351    Auth(String),
2352}
2353
2354/// Connect to the Alpaca WebSocket, authenticate, and subscribe to trade_updates.
2355///
2356/// On auth or subscribe failure the connection is closed cleanly.
2357async fn connect_and_subscribe(config: &AlpacaConfig) -> Result<WebSocket, UnindexedClientError> {
2358    let url = config.ws_url();
2359    debug!(%url, "Alpaca: connecting to WebSocket");
2360
2361    let mut ws = rustrade_integration::protocol::websocket::connect(url)
2362        .await
2363        .map_err(|e| {
2364            UnindexedClientError::Connectivity(ConnectivityError::Socket(format!(
2365                "WS connect: {e}"
2366            )))
2367        })?;
2368
2369    // auth + subscribe with overall timeout
2370    let result = tokio::time::timeout(
2371        Duration::from_secs(WS_HANDSHAKE_TIMEOUT_SECS),
2372        ws_handshake(&mut ws, config),
2373    )
2374    .await;
2375
2376    match result {
2377        Ok(Ok(())) => Ok(ws),
2378        Ok(Err(HandshakeError::Transport(e))) => {
2379            let _ = ws.close(None).await;
2380            Err(UnindexedClientError::Connectivity(
2381                ConnectivityError::Socket(e),
2382            ))
2383        }
2384        Ok(Err(HandshakeError::Auth(e))) => {
2385            let _ = ws.close(None).await;
2386            Err(UnindexedClientError::Api(ApiError::Unauthenticated(e)))
2387        }
2388        Err(_) => {
2389            let _ = ws.close(None).await;
2390            Err(UnindexedClientError::Connectivity(
2391                ConnectivityError::Timeout,
2392            ))
2393        }
2394    }
2395}
2396
2397/// Perform the Alpaca WS auth + subscribe sequence on a connected WebSocket.
2398///
2399/// Protocol:
2400/// 1. Send `{"action":"auth","key":...,"secret":...}`
2401/// 2. Await `{"stream":"authorization","data":{"status":"authorized",...}}`
2402/// 3. Send `{"action":"listen","data":{"streams":["trade_updates"]}}`
2403/// 4. Await `{"stream":"listening","data":{"streams":["trade_updates"]}}`
2404async fn ws_handshake(ws: &mut WebSocket, config: &AlpacaConfig) -> Result<(), HandshakeError> {
2405    // Step 1: send auth
2406    let auth = serde_json::json!({
2407        "action": "auth",
2408        "key": config.api_key(),
2409        // direct field access within module — secret_key has no pub getter to
2410        // prevent external credential exposure
2411        "secret": config.secret_key,
2412    })
2413    .to_string();
2414    ws.send(WsMessage::Text(auth.into()))
2415        .await
2416        .map_err(|e| HandshakeError::Transport(format!("WS auth send: {e}")))?;
2417
2418    // Step 2: wait for authorization message
2419    loop {
2420        match ws.next().await {
2421            Some(Ok(WsMessage::Text(text))) => {
2422                if let Some(result) = check_auth_response(text.as_str()) {
2423                    result?;
2424                    break;
2425                }
2426            }
2427            Some(Ok(WsMessage::Binary(bytes))) => {
2428                if let Ok(text) = std::str::from_utf8(&bytes)
2429                    && let Some(result) = check_auth_response(text)
2430                {
2431                    result?;
2432                    break;
2433                }
2434            }
2435            Some(Err(e)) => {
2436                return Err(HandshakeError::Transport(format!(
2437                    "WS error during auth: {e}"
2438                )));
2439            }
2440            None => {
2441                return Err(HandshakeError::Transport(
2442                    "WS closed before auth response".into(),
2443                ));
2444            }
2445            _ => {} // ping/pong during auth — ignore
2446        }
2447    }
2448
2449    // Step 3: subscribe to trade_updates
2450    let sub = serde_json::json!({
2451        "action": "listen",
2452        "data": { "streams": ["trade_updates"] }
2453    })
2454    .to_string();
2455    ws.send(WsMessage::Text(sub.into()))
2456        .await
2457        .map_err(|e| HandshakeError::Transport(format!("WS subscribe send: {e}")))?;
2458
2459    // Step 4: wait for listening acknowledgment (optional but confirms subscription)
2460    loop {
2461        match ws.next().await {
2462            Some(Ok(WsMessage::Text(text))) => {
2463                if check_listen_ack(text.as_str()) {
2464                    break;
2465                }
2466                // Other messages in this window are NOT buffered and are permanently
2467                // dropped — callers must reconcile order state via fetch_open_orders
2468                // after each (re)connect, as documented in account_stream's doc comment.
2469                // Log at warn! for trade_updates (fill/lifecycle events) to ensure
2470                // production operators see dropped events; trace! for other streams.
2471                if let Ok(msg) = serde_json::from_str::<AlpacaStreamMessage<'_>>(text.as_str()) {
2472                    if msg.stream == "trade_updates" {
2473                        warn!(stream = %msg.stream, "WS trade_updates event dropped during listen-ack handshake — will be recovered via REST for fills, but lifecycle events (new/canceled) are lost");
2474                    } else {
2475                        trace!(stream = %msg.stream, "WS message dropped during listen-ack handshake");
2476                    }
2477                } else {
2478                    trace!(
2479                        bytes = text.len(),
2480                        "WS non-stream message dropped during listen-ack handshake"
2481                    );
2482                }
2483            }
2484            Some(Ok(WsMessage::Binary(bytes))) => {
2485                if let Ok(text) = std::str::from_utf8(&bytes)
2486                    && check_listen_ack(text)
2487                {
2488                    break;
2489                }
2490                trace!(
2491                    bytes = bytes.len(),
2492                    "WS binary message dropped during listen-ack handshake"
2493                );
2494            }
2495            Some(Err(e)) => {
2496                return Err(HandshakeError::Transport(format!(
2497                    "WS error during subscribe: {e}"
2498                )));
2499            }
2500            None => {
2501                return Err(HandshakeError::Transport(
2502                    "WS closed before subscribe ack".into(),
2503                ));
2504            }
2505            _ => {}
2506        }
2507    }
2508
2509    info!("Alpaca WS authenticated and subscribed to trade_updates");
2510    Ok(())
2511}
2512
2513/// Parse a WS message to check for authorization response.
2514/// Returns `None` if the message is not an authorization response.
2515/// Returns `Some(Ok(()))` on success, `Some(Err(HandshakeError::Auth(...)))` on auth failure.
2516///
2517/// Uses `AlpacaStreamMessage` for consistency with the rest of the WS parsing pipeline.
2518fn check_auth_response(text: &str) -> Option<Result<(), HandshakeError>> {
2519    let msg = serde_json::from_str::<AlpacaStreamMessage<'_>>(text).ok()?;
2520    if msg.stream != "authorization" {
2521        return None;
2522    }
2523    #[derive(Deserialize)]
2524    struct AuthData<'a> {
2525        status: &'a str,
2526    }
2527    let data = serde_json::from_str::<AuthData<'_>>(msg.data.get()).ok()?;
2528    if data.status == "authorized" {
2529        Some(Ok(()))
2530    } else {
2531        Some(Err(HandshakeError::Auth(format!(
2532            "Alpaca WS auth failed: status={}",
2533            data.status
2534        ))))
2535    }
2536}
2537
2538/// Returns `true` if the WS message is a listening acknowledgment for trade_updates.
2539///
2540/// Uses `AlpacaStreamMessage` for consistency with the rest of the WS parsing pipeline.
2541fn check_listen_ack(text: &str) -> bool {
2542    let Ok(msg) = serde_json::from_str::<AlpacaStreamMessage<'_>>(text) else {
2543        return false;
2544    };
2545    if msg.stream != "listening" {
2546        return false;
2547    }
2548    #[derive(Deserialize)]
2549    struct ListenData<'a> {
2550        #[serde(borrow)]
2551        streams: Vec<&'a str>,
2552    }
2553    let Ok(data) = serde_json::from_str::<ListenData<'_>>(msg.data.get()) else {
2554        return false;
2555    };
2556    data.streams.contains(&"trade_updates")
2557}
2558
2559// ---------------------------------------------------------------------------
2560// Process incoming WS messages
2561// ---------------------------------------------------------------------------
2562
2563/// Parse a raw WS text message and forward relevant account events to `tx`.
2564fn process_ws_text(
2565    text: &str,
2566    tx: &mpsc::UnboundedSender<UnindexedAccountEvent>,
2567    dedup: &SharedDedupCache,
2568    backoff: &mut ExponentialBackoff,
2569) {
2570    let msg: AlpacaStreamMessage<'_> = match serde_json::from_str(text) {
2571        Ok(m) => m,
2572        Err(e) => {
2573            trace!(
2574                %e,
2575                raw = ?&text[..text.len().min(200)],
2576                "Alpaca WS: skipped non-JSON message"
2577            );
2578            return;
2579        }
2580    };
2581
2582    // Any successfully-parsed frame proves the connection is alive — reset backoff here
2583    // so that unrecognised event types (pending_cancel, replaced, etc.) also reset it.
2584    backoff.reset();
2585
2586    match msg.stream.as_str() {
2587        "trade_updates" => {
2588            let update: AlpacaTradeUpdate<'_> = match serde_json::from_str(msg.data.get()) {
2589                Ok(u) => u,
2590                Err(e) => {
2591                    // Use warn (not trace): the outer frame was valid trade_updates JSON,
2592                    // so failure here signals an unexpected payload format. Could indicate
2593                    // an Alpaca API change or a missing required field dropping a real
2594                    // event (e.g. a rejected order). Visible at production log levels.
2595                    warn!(
2596                        %e,
2597                        raw = ?&msg.data.get()[..msg.data.get().len().min(200)],
2598                        "Alpaca WS trade_updates: failed to deserialize event — event dropped"
2599                    );
2600                    return;
2601                }
2602            };
2603
2604            // PERF: Check dedup BEFORE constructing the full event for fill events.
2605            // This avoids heap allocations (Trade, InstrumentNameExchange, TradeId) on
2606            // duplicate fills, which occur on every reconnect during recovery overlap.
2607            if is_fill_event(&update) {
2608                let key = early_dedup_key(&update);
2609                if is_duplicate(dedup, &key) {
2610                    trace!("Alpaca WS: skipping duplicate fill event (early check)");
2611                    return;
2612                }
2613            }
2614
2615            if let Some(event) = convert_trade_update(update) {
2616                // Consumer dropped errors are benign; connection_manager will detect
2617                // tx.closed() on the next select! poll and exit cleanly.
2618                let _ = tx.send(event);
2619            }
2620        }
2621        "authorization" | "listening" => {
2622            // Ack messages can appear during initial stream if the handshake was
2623            // not fully awaited. These are benign.
2624            trace!(stream = %msg.stream, "Alpaca WS: auth/listen ack received during stream");
2625        }
2626        other => {
2627            trace!(%other, "Alpaca WS: ignoring unknown stream type");
2628        }
2629    }
2630}
2631
2632/// Extract a dedup key from an account event, if applicable.
2633///
2634/// Uses `trade.id` as the key. Both the WS path (`convert_trade_update`) and the
2635/// REST recovery path (`recover_fills`) synthesise `TradeId` as
2636/// `"{order_id}:{cumulative_filled_qty}"`, ensuring the same fill produces the
2637/// same key regardless of which path delivered it.
2638fn fill_dedup_key_from_event(event: &UnindexedAccountEvent) -> Option<&SmolStr> {
2639    match &event.kind {
2640        // Return a reference to avoid cloning the SmolStr; is_duplicate takes &SmolStr.
2641        AccountEventKind::Trade(trade) => Some(&trade.id.0),
2642        _ => None,
2643    }
2644}
2645
2646/// Returns `true` if this event type produces a fill (Trade) event.
2647///
2648/// Used for early dedup check before allocating the full event.
2649#[inline]
2650fn is_fill_event(update: &AlpacaTradeUpdate<'_>) -> bool {
2651    matches!(update.event.as_str(), "fill" | "partial_fill")
2652}
2653
2654/// Construct the dedup key from raw WS update fields without allocating the full event.
2655///
2656/// Key format: `"{order_id}:{cumulative_filled_qty}"` — same as `convert_trade_update`
2657/// and `recover_fills` produce, ensuring cross-source dedup works correctly.
2658///
2659/// Note: The `format_smolstr!` call heap-allocates for UUID-length order IDs (36 chars
2660/// exceeds SmolStr's 22-byte inline limit). This is unavoidable given the key length.
2661/// Passing the Decimal directly to format_smolstr! (rather than via intermediate String)
2662/// lets Decimal's Display impl write directly into the buffer, eliminating one allocation.
2663fn early_dedup_key(update: &AlpacaTradeUpdate<'_>) -> SmolStr {
2664    let filled_qty = update.order.filled_qty.unwrap_or("0");
2665    // Parse and normalize to match convert_trade_update's key format exactly.
2666    // Decimal::from_str + normalize() are stack-only; no heap allocation until format_smolstr!.
2667    let qty = Decimal::from_str(filled_qty).unwrap_or(Decimal::ZERO);
2668    format_smolstr!("{}:{}", update.order.id, qty.normalize())
2669}
2670
2671// ---------------------------------------------------------------------------
2672// Fill recovery
2673// ---------------------------------------------------------------------------
2674
2675/// Fetch fills missed during a WS disconnect and forward through the dedup cache.
2676async fn recover_fills(
2677    http: &reqwest::Client,
2678    rate_limiter: &RateLimitTracker,
2679    instruments: &[InstrumentNameExchange],
2680    base: &str,
2681    after: &str,
2682    tx: &mpsc::UnboundedSender<UnindexedAccountEvent>,
2683    dedup: &SharedDedupCache,
2684) {
2685    // Empty `instruments` means "recover for all subscribed symbols" (same convention as
2686    // `fetch_trades` / `fetch_open_orders`). Skip the set allocation when not filtering.
2687    info!(%after, instruments = instruments.len(), "Alpaca recovering fills after reconnect");
2688    let instrument_set: fnv::FnvHashSet<&str> = if instruments.is_empty() {
2689        fnv::FnvHashSet::default()
2690    } else {
2691        instruments.iter().map(|i| i.name().as_str()).collect()
2692    };
2693
2694    let page = match paginate_activities(http, rate_limiter, base, after).await {
2695        Ok(p) => p,
2696        Err(e) => {
2697            error!(%e, "Alpaca fill recovery: REST request failed");
2698            return;
2699        }
2700    };
2701
2702    // Log truncation as error — the returned data is partial and some fills are
2703    // permanently lost. Callers cannot propagate this error (recover_fills returns ()),
2704    // but the log ensures operators are alerted.
2705    if page.truncated {
2706        error!(
2707            max_pages = MAX_ACTIVITY_PAGES,
2708            "Alpaca fill recovery: max page limit reached, truncating — \
2709             fills from this outage are permanently lost. Manual reconciliation required."
2710        );
2711    }
2712
2713    let activities = page.activities;
2714
2715    let mut recovered = 0u32;
2716    let mut duplicates = 0u32;
2717
2718    // Track cumulative filled qty per order so that the synthesised TradeId matches
2719    // the WS path: both produce "{order_id}:{cumulative_filled_qty}". This prevents
2720    // same-size partial fill collisions (e.g. two 1-lot fills on a 2-lot order)
2721    // and ensures cross-source dedup works correctly after reconnect.
2722    //
2723    // Activities are fetched with direction=asc so they arrive in chronological order.
2724    // Cumulative accumulation here must match the WS path's order.filled_qty progression.
2725    // If Alpaca violates this ordering guarantee, dedup keys will diverge and fills
2726    // may be dropped or duplicated.
2727    // Borrow `activities` so &str keys into order_id strings remain valid for the
2728    // entire loop. Consuming iteration would drop each activity at end of its iteration,
2729    // invalidating keys that still need to be looked up by later fills on the same order.
2730    let mut cumulative_qty: FnvHashMap<&str, Decimal> = FnvHashMap::default();
2731
2732    for activity in &activities {
2733        if !instrument_set.is_empty() && !instrument_set.contains(activity.symbol.as_str()) {
2734            // Safe to skip without advancing cumulative_qty: each Alpaca order is bound
2735            // to exactly one symbol, so no fill for a different symbol can ever share
2736            // the same order_id as a fill we're tracking.
2737            continue;
2738        }
2739
2740        // Advance cumulative_qty for every activity, regardless of whether parsing
2741        // succeeds. The WS path uses Alpaca's cumulative filled_qty, which counts ALL
2742        // fills — including those with malformed fields. Skipping the counter for a bad
2743        // fill causes subsequent fills to produce dedup keys that diverge from the WS
2744        // path (off by the bad fill's qty), so the next good fill appears as a duplicate
2745        // on one path and a new fill on the other, resulting in either a missed or a
2746        // double-delivered fill for that execution.
2747        //
2748        // activity.qty is always populated for FILL activities (Alpaca guarantees it).
2749        // unwrap_or(ZERO) guards against unexpected API changes; a zero qty skips
2750        // incrementing the cumulative counter for that activity. Note: a non-empty
2751        // but non-parseable qty string (e.g. an API regression sending "abc") also
2752        // produces exec_qty=ZERO — the counter stalls, causing subsequent fills on
2753        // the same order to produce dedup keys that diverge from the WS path.
2754        let exec_qty = Decimal::from_str(&activity.qty).unwrap_or(Decimal::ZERO);
2755        let cum = cumulative_qty
2756            .entry(activity.order_id.as_str())
2757            .or_default();
2758        *cum += exec_qty;
2759        let cumulative = *cum;
2760
2761        let mut trade = match convert_activity_to_trade(activity) {
2762            Some(t) => t,
2763            None => {
2764                warn!(id = %activity.id, symbol = %activity.symbol, "Alpaca: skipping activity with unparseable fields");
2765                continue; // Counter already advanced; dedup key sequence stays aligned with WS.
2766            }
2767        };
2768
2769        // Override trade.id to match the WS synthesised format so that
2770        // fill_dedup_key_from_event produces the same key for both sources.
2771        // Normalise the cumulative Decimal to strip trailing zeros so the string
2772        // representation matches the WS path (e.g. "1" == "1.00" after normalize).
2773        trade.id = TradeId(format_smolstr!(
2774            "{}:{}",
2775            activity.order_id,
2776            cumulative.normalize()
2777        ));
2778
2779        let event =
2780            UnindexedAccountEvent::new(ExchangeId::AlpacaBroker, AccountEventKind::Trade(trade));
2781
2782        // Re-use fill_dedup_key_from_event so the key logic is in one place.
2783        if fill_dedup_key_from_event(&event).is_some_and(|k| is_duplicate(dedup, k)) {
2784            duplicates += 1;
2785            continue;
2786        }
2787        if tx.send(event).is_err() {
2788            debug!("Alpaca fill recovery: consumer dropped during recovery");
2789            return;
2790        }
2791        recovered += 1;
2792    }
2793
2794    info!(recovered, duplicates, "Alpaca fill recovery complete");
2795}
2796
2797// ---------------------------------------------------------------------------
2798// Type conversion helpers
2799// ---------------------------------------------------------------------------
2800
2801/// Convert an Alpaca account response to rustrade balance entries.
2802///
2803/// Returns a single USD balance with:
2804/// - `total` = equity (total account value including open positions)
2805/// - `free` = options_buying_power (if available) or buying_power
2806///
2807/// If `assets` is non-empty, only returns the balance if "usd" (case-insensitive)
2808/// is in the requested set. An empty `assets` slice returns the USD balance unconditionally.
2809fn convert_account_to_balances(
2810    account: &AlpacaAccount,
2811    assets: &[AssetNameExchange],
2812) -> Vec<AssetBalance<AssetNameExchange>> {
2813    // Preserve the caller's casing for the USD asset name (e.g. "USD" vs "usd").
2814    // When no filter is given, fall back to lowercase "usd" as the canonical form.
2815    let usd_entry = assets
2816        .iter()
2817        .find(|a| a.name().as_str().eq_ignore_ascii_case("usd"));
2818
2819    // Filter check: if assets is specified, only return USD balance if requested.
2820    if !assets.is_empty() && usd_entry.is_none() {
2821        return Vec::new();
2822    }
2823
2824    let usd_name = usd_entry
2825        .cloned()
2826        .unwrap_or_else(|| AssetNameExchange::new("usd"));
2827
2828    let total = Decimal::from_str(&account.equity).unwrap_or(Decimal::ZERO);
2829    let free = account
2830        .options_buying_power
2831        .as_deref()
2832        .and_then(|s| Decimal::from_str(s).ok())
2833        // Filter out zero: Alpaca returns options_buying_power="0.00" for equity-only
2834        // accounts (options not enabled) rather than omitting the field. Without this
2835        // filter, unwrap_or_else never fires and the engine reports free=0, blocking
2836        // all orders on an account that may have substantial buying_power.
2837        .filter(|d| !d.is_zero())
2838        .unwrap_or_else(|| Decimal::from_str(&account.buying_power).unwrap_or(Decimal::ZERO));
2839
2840    vec![AssetBalance::new(
2841        usd_name,
2842        Balance::new(total, free),
2843        Utc::now(),
2844    )]
2845}
2846
2847/// Convert Alpaca positions to crypto asset balance entries.
2848///
2849/// Only positions with `asset_class == "crypto"` are included. The base asset
2850/// is extracted from the symbol (e.g., `"BTC/USD"` → `"btc"`).
2851///
2852/// - `total` = quantity of the holding in base currency units (e.g. 0.5 BTC)
2853/// - `free`  = qty_available (base currency units not locked in open orders)
2854///
2855/// If `assets` is non-empty, only positions whose base asset name matches an
2856/// entry in the slice (case-insensitive) are returned.
2857fn convert_positions_to_balances(
2858    positions: &[AlpacaPosition],
2859    assets: &[AssetNameExchange],
2860) -> Vec<AssetBalance<AssetNameExchange>> {
2861    let now = Utc::now();
2862    positions
2863        .iter()
2864        .filter(|p| p.asset_class.eq_ignore_ascii_case("crypto"))
2865        .filter_map(|p| {
2866            // Alpaca crypto symbols are "BASE/QUOTE" (e.g., "BTC/USD").
2867            // Extract the base currency as the asset name.
2868            let base = p
2869                .symbol
2870                .split('/')
2871                .next()
2872                .map(|s| s.to_ascii_lowercase())
2873                .unwrap_or_else(|| p.symbol.to_ascii_lowercase());
2874
2875            // Apply assets filter if specified.
2876            if !assets.is_empty()
2877                && !assets
2878                    .iter()
2879                    .any(|a| a.name().as_str().eq_ignore_ascii_case(&base))
2880            {
2881                return None;
2882            }
2883
2884            // total and free are in base currency units (e.g., BTC), not USD,
2885            // consistent with AssetBalance semantics for currency/crypto assets.
2886            let total = Decimal::from_str(&p.qty).unwrap_or(Decimal::ZERO);
2887            let free = Decimal::from_str(&p.qty_available).unwrap_or(Decimal::ZERO);
2888            let asset_name = AssetNameExchange::new(base);
2889            Some(AssetBalance::new(
2890                asset_name,
2891                Balance::new(total, free),
2892                now,
2893            ))
2894        })
2895        .collect()
2896}
2897
2898/// Group a list of open orders into per-instrument snapshots for account_snapshot.
2899///
2900/// When `instruments` is non-empty, a snapshot is returned for every requested instrument
2901/// (possibly with an empty orders list). When empty, only instruments with open orders
2902/// are returned.
2903fn build_instrument_snapshots(
2904    orders: Vec<AlpacaOrderResponse>,
2905    instruments: &[InstrumentNameExchange],
2906) -> Vec<InstrumentAccountSnapshot<ExchangeId, AssetNameExchange, InstrumentNameExchange>> {
2907    // Build ordered map from symbol → snapshot to preserve deterministic ordering.
2908    let mut by_symbol: IndexMap<SmolStr, Vec<_>> = IndexMap::new();
2909
2910    for order in orders {
2911        let sym = SmolStr::new(&order.symbol);
2912        if let Some(converted) = convert_open_order(&order) {
2913            let wrapped = crate::order::Order {
2914                key: converted.key,
2915                side: converted.side,
2916                price: converted.price,
2917                quantity: converted.quantity,
2918                kind: converted.kind,
2919                time_in_force: converted.time_in_force,
2920                state: OrderState::active(converted.state),
2921            };
2922            by_symbol.entry(sym).or_default().push(wrapped);
2923        }
2924    }
2925
2926    // If instruments is empty, return all; otherwise filter to requested set.
2927    if instruments.is_empty() {
2928        by_symbol
2929            .into_iter()
2930            .map(|(sym, orders)| {
2931                InstrumentAccountSnapshot::new(InstrumentNameExchange::new(sym), orders, None, None)
2932            })
2933            .collect()
2934    } else {
2935        instruments
2936            .iter()
2937            .map(|inst| {
2938                // swap_remove is O(1); output order is determined by the `instruments`
2939                // slice, not by the internal IndexMap order of `by_symbol`.
2940                let orders = by_symbol
2941                    .swap_remove(inst.name().as_str())
2942                    .unwrap_or_default();
2943                InstrumentAccountSnapshot::new(inst.clone(), orders, None, None)
2944            })
2945            .collect()
2946    }
2947}
2948
2949/// Convert an Alpaca REST order into rustrade's Open state order.
2950fn convert_open_order(
2951    o: &AlpacaOrderResponse,
2952) -> Option<Order<ExchangeId, InstrumentNameExchange, Open>> {
2953    let order_id = OrderId(SmolStr::new(&o.id));
2954    let cid = o
2955        .client_order_id
2956        .as_deref()
2957        .map(ClientOrderId::new)
2958        .unwrap_or_else(|| ClientOrderId::new(o.id.as_str()));
2959
2960    let instrument = InstrumentNameExchange::new(&o.symbol);
2961    let side = parse_side(&o.side)?;
2962    let quantity = Decimal::from_str(o.qty.as_deref().unwrap_or("0")).ok()?;
2963    // Notional orders (placed by dollar value, qty=null) have no representable quantity.
2964    // Skip them rather than recording a zero-quantity order that would corrupt reconciliation.
2965    if quantity.is_zero() {
2966        return None;
2967    }
2968    let price = o
2969        .limit_price
2970        .as_deref()
2971        .and_then(|s| Decimal::from_str(s).ok());
2972    let filled_qty = Decimal::from_str(&o.filled_qty).unwrap_or(Decimal::ZERO);
2973    let kind = parse_order_kind(
2974        &o.order_type,
2975        o.stop_price.as_deref(),
2976        o.trail_percent.as_deref(),
2977        o.trail_price.as_deref(),
2978    )?;
2979    let time_in_force = parse_time_in_force(&o.time_in_force);
2980    let time_exchange = parse_timestamp(&o.created_at).unwrap_or_else(Utc::now);
2981
2982    Some(Order {
2983        key: OrderKey::new(
2984            ExchangeId::AlpacaBroker,
2985            instrument,
2986            // Alpaca doesn't carry strategy IDs in any response field.
2987            StrategyId::unknown(),
2988            cid,
2989        ),
2990        side,
2991        price,
2992        quantity,
2993        kind,
2994        time_in_force,
2995        state: Open::new(order_id, time_exchange, filled_qty),
2996    })
2997}
2998
2999/// Convert an Alpaca FILL activity into a rustrade Trade.
3000fn convert_activity_to_trade(
3001    a: &AlpacaActivity,
3002) -> Option<Trade<AssetNameExchange, InstrumentNameExchange>> {
3003    let trade_id = TradeId::new(&a.id);
3004    let order_id = OrderId(SmolStr::new(&a.order_id));
3005    let instrument = InstrumentNameExchange::new(&a.symbol);
3006    let side = parse_side(&a.side)?;
3007    let price = Decimal::from_str(&a.price).ok()?;
3008    let quantity = Decimal::from_str(&a.qty).ok()?;
3009    let time_exchange = parse_timestamp(&a.transaction_time).unwrap_or_else(|| {
3010        warn!(id = %a.id, "Alpaca activity: unparseable transaction_time, using now");
3011        Utc::now()
3012    });
3013
3014    // Alpaca equities and options are commission-free. Crypto trades incur
3015    // maker/taker fees (currently 0.15–0.25%) charged in the credited asset,
3016    // but fee info is not available in trade responses — use Activities API
3017    // for end-of-day fee reconciliation.
3018    Some(Trade::new(
3019        trade_id,
3020        order_id,
3021        instrument,
3022        StrategyId::unknown(),
3023        time_exchange,
3024        side,
3025        price,
3026        quantity,
3027        AssetFees::new(
3028            AssetNameExchange::from("USD"),
3029            Decimal::ZERO,
3030            Some(Decimal::ZERO),
3031        ),
3032    ))
3033}
3034
3035/// Convert a WebSocket trade_update event into a rustrade AccountEvent.
3036fn convert_trade_update(update: AlpacaTradeUpdate<'_>) -> Option<UnindexedAccountEvent> {
3037    // Early exit for unrecognised event types before incurring allocations for
3038    // instrument/order_id/cid — those are wasted for unknown events.
3039    let event_str = update.event.as_str();
3040    if !matches!(
3041        event_str,
3042        "fill"
3043            | "partial_fill"
3044            | "new"
3045            | "accepted"
3046            | "pending_new"
3047            | "canceled"
3048            | "expired"
3049            | "replaced"
3050            | "done_for_day"
3051            | "rejected"
3052    ) {
3053        trace!(event = %event_str, "Alpaca WS: ignoring trade_updates event type");
3054        return None;
3055    }
3056
3057    let order = &update.order;
3058    let instrument = InstrumentNameExchange::new(&*order.symbol);
3059    let order_id = OrderId(order.id.clone());
3060    let cid = order
3061        .client_order_id
3062        .as_deref()
3063        .map(ClientOrderId::new)
3064        .unwrap_or_else(|| ClientOrderId::new(order.id.as_str()));
3065
3066    match event_str {
3067        "fill" | "partial_fill" => {
3068            // Use event-level price/qty for the per-execution trade.
3069            let price = update.price.and_then(|s| Decimal::from_str(s).ok())?;
3070            let quantity = update.qty.and_then(|s| Decimal::from_str(s).ok())?;
3071            let side = parse_side(&order.side)?;
3072            let time_exchange = update
3073                .timestamp
3074                .and_then(parse_timestamp)
3075                .unwrap_or_else(Utc::now);
3076
3077            // Trade ID: synthesise from order_id + cumulative filled qty since
3078            // Alpaca WS trade_updates don't include an activity ID.
3079            // Normalise via Decimal to strip trailing zeros so the key matches the
3080            // REST recovery path (e.g. "1.00" and "1" both normalise to "1").
3081            //
3082            // If filled_qty is unparseable (API regression), cum_qty falls back to
3083            // zero. Two consecutive bad fills on the same order would produce the same
3084            // dedup key ("order_id:0"), causing the second fill to be silently dropped.
3085            // Warn loudly so API regressions are surfaced immediately.
3086            let cum_qty = Decimal::from_str(order.filled_qty.unwrap_or("0"))
3087                .inspect_err(|e| {
3088                    warn!(
3089                        order_id = %order.id,
3090                        filled_qty = ?order.filled_qty,
3091                        %e,
3092                        "Alpaca WS: failed to parse filled_qty — dedup key will use 0, \
3093                         a second malformed fill on the same order would be deduplicated away"
3094                    );
3095                })
3096                .unwrap_or(Decimal::ZERO);
3097            let trade_id = TradeId(format_smolstr!("{}:{}", order.id, cum_qty.normalize()));
3098
3099            // Alpaca equities and options are commission-free. Crypto trades incur
3100            // maker/taker fees (currently 0.15–0.25%) in the credited asset, but
3101            // fee info is not available in WebSocket updates.
3102            let trade = Trade::new(
3103                trade_id,
3104                order_id,
3105                instrument,
3106                StrategyId::unknown(),
3107                time_exchange,
3108                side,
3109                price,
3110                quantity,
3111                AssetFees::new(
3112                    AssetNameExchange::from("USD"),
3113                    Decimal::ZERO,
3114                    Some(Decimal::ZERO),
3115                ),
3116            );
3117            Some(UnindexedAccountEvent::new(
3118                ExchangeId::AlpacaBroker,
3119                AccountEventKind::Trade(trade),
3120            ))
3121        }
3122
3123        "new" | "accepted" | "pending_new" => {
3124            // Order acknowledged by Alpaca — emit an OrderSnapshot.
3125            let side = parse_side(&order.side)?;
3126            let quantity = Decimal::from_str(order.qty.unwrap_or("0")).unwrap_or(Decimal::ZERO);
3127            // Notional orders (placed by dollar value) have qty=null, yielding quantity=0.
3128            // Emitting an OrderSnapshot with quantity=0 would corrupt OMS state — skip,
3129            // consistent with convert_open_order which also returns None for zero-qty orders.
3130            if quantity.is_zero() {
3131                trace!(order_id = %order.id, "Alpaca WS: skipping notional order snapshot (qty=None)");
3132                return None;
3133            }
3134            let price = order.limit_price.and_then(|s| Decimal::from_str(s).ok());
3135            let filled_qty =
3136                Decimal::from_str(order.filled_qty.unwrap_or("0")).unwrap_or(Decimal::ZERO);
3137            let kind = parse_order_kind(
3138                &order.order_type,
3139                order.stop_price,
3140                order.trail_percent,
3141                order.trail_price,
3142            )?;
3143            let time_in_force = parse_time_in_force(&order.time_in_force);
3144            let time_exchange = update
3145                .timestamp
3146                .and_then(parse_timestamp)
3147                .unwrap_or_else(Utc::now);
3148
3149            let open_state = Open::new(order_id, time_exchange, filled_qty);
3150            let order_snapshot = crate::order::Order {
3151                key: OrderKey::new(
3152                    ExchangeId::AlpacaBroker,
3153                    instrument,
3154                    StrategyId::unknown(),
3155                    cid,
3156                ),
3157                side,
3158                price,
3159                quantity,
3160                kind,
3161                time_in_force,
3162                state: OrderState::active(open_state),
3163            };
3164            Some(UnindexedAccountEvent::new(
3165                ExchangeId::AlpacaBroker,
3166                AccountEventKind::OrderSnapshot(
3167                    rustrade_integration::collection::snapshot::Snapshot(order_snapshot),
3168                ),
3169            ))
3170        }
3171
3172        "canceled" | "expired" | "replaced" | "done_for_day" => {
3173            // Order no longer active.
3174            //
3175            // NOTE on "replaced": Alpaca's replace operation cancels the original order
3176            // and creates a NEW order with a different order ID. This arm correctly marks
3177            // the original as cancelled, but callers must call `fetch_open_orders` to
3178            // discover the replacement order (which has a new ID and won't appear in OMS
3179            // state automatically).
3180            let time_exchange = update
3181                .timestamp
3182                .and_then(parse_timestamp)
3183                .unwrap_or_else(Utc::now);
3184            let filled_qty =
3185                Decimal::from_str(order.filled_qty.unwrap_or("0")).unwrap_or(Decimal::ZERO);
3186            let cancelled = Cancelled::new(order_id, time_exchange, filled_qty);
3187            let response = crate::order::request::OrderResponseCancel {
3188                key: OrderKey::new(
3189                    ExchangeId::AlpacaBroker,
3190                    instrument,
3191                    StrategyId::unknown(),
3192                    cid,
3193                ),
3194                state: Ok(cancelled),
3195            };
3196            Some(UnindexedAccountEvent::new(
3197                ExchangeId::AlpacaBroker,
3198                AccountEventKind::OrderCancelled(response),
3199            ))
3200        }
3201
3202        "rejected" => {
3203            let response = crate::order::request::OrderResponseCancel {
3204                key: OrderKey::new(
3205                    ExchangeId::AlpacaBroker,
3206                    instrument,
3207                    StrategyId::unknown(),
3208                    cid,
3209                ),
3210                state: Err(UnindexedOrderError::Rejected(ApiError::OrderRejected(
3211                    format!("order rejected: status={}", order.status),
3212                ))),
3213            };
3214            Some(UnindexedAccountEvent::new(
3215                ExchangeId::AlpacaBroker,
3216                AccountEventKind::OrderCancelled(response),
3217            ))
3218        }
3219
3220        // All recognised event types are handled above; the early-return guard at the
3221        // top of this function ensures we never reach here for unknown event types.
3222        _ => unreachable!("convert_trade_update: unrecognised event passed early-return guard"),
3223    }
3224}
3225
3226// ---------------------------------------------------------------------------
3227// Field parsers
3228// ---------------------------------------------------------------------------
3229
3230fn parse_side(s: &str) -> Option<Side> {
3231    match s {
3232        "buy" | "Buy" | "BUY" => Some(Side::Buy),
3233        "sell" | "Sell" | "SELL" => Some(Side::Sell),
3234        other => {
3235            trace!(%other, "Alpaca: unknown order side");
3236            None
3237        }
3238    }
3239}
3240
3241fn parse_order_kind(
3242    order_type: &str,
3243    stop_price: Option<&str>,
3244    trail_percent: Option<&str>,
3245    trail_price: Option<&str>,
3246) -> Option<OrderKind> {
3247    match order_type {
3248        "market" | "Market" => Some(OrderKind::Market),
3249        "limit" | "Limit" => Some(OrderKind::Limit),
3250        "stop" | "Stop" => {
3251            let trigger_price = stop_price.and_then(|s| Decimal::from_str(s).ok())?;
3252            Some(OrderKind::Stop { trigger_price })
3253        }
3254        "stop_limit" | "Stop_limit" => {
3255            let trigger_price = stop_price.and_then(|s| Decimal::from_str(s).ok())?;
3256            Some(OrderKind::StopLimit { trigger_price })
3257        }
3258        "trailing_stop" | "Trailing_stop" => {
3259            // Alpaca returns either trail_percent or trail_price, not both.
3260            if let Some(pct) = trail_percent.and_then(|s| Decimal::from_str(s).ok()) {
3261                Some(OrderKind::TrailingStop {
3262                    offset: pct,
3263                    offset_type: TrailingOffsetType::Percentage,
3264                })
3265            } else if let Some(price) = trail_price.and_then(|s| Decimal::from_str(s).ok()) {
3266                Some(OrderKind::TrailingStop {
3267                    offset: price,
3268                    offset_type: TrailingOffsetType::Absolute,
3269                })
3270            } else {
3271                trace!("Alpaca: trailing_stop missing trail_percent and trail_price");
3272                None
3273            }
3274        }
3275        other => {
3276            trace!(%other, "Alpaca: unsupported order type, skipping");
3277            None
3278        }
3279    }
3280}
3281
3282fn parse_time_in_force(s: &str) -> TimeInForce {
3283    match s {
3284        "gtc" | "GTC" => TimeInForce::GoodUntilCancelled { post_only: false },
3285        "day" | "DAY" => TimeInForce::GoodUntilEndOfDay,
3286        "fok" | "FOK" => TimeInForce::FillOrKill,
3287        "ioc" | "IOC" => TimeInForce::ImmediateOrCancel,
3288        other => {
3289            warn!(%other, "Alpaca: unknown time_in_force, defaulting to GoodUntilEndOfDay");
3290            TimeInForce::GoodUntilEndOfDay
3291        }
3292    }
3293}
3294
3295fn parse_timestamp(s: &str) -> Option<DateTime<Utc>> {
3296    DateTime::parse_from_rfc3339(s)
3297        .ok()
3298        .map(|dt| dt.with_timezone(&Utc))
3299}
3300
3301fn map_side(side: Side) -> &'static str {
3302    match side {
3303        Side::Buy => "buy",
3304        Side::Sell => "sell",
3305    }
3306}
3307
3308fn map_order_kind(kind: OrderKind) -> Option<&'static str> {
3309    match kind {
3310        OrderKind::Market => Some("market"),
3311        OrderKind::Limit => Some("limit"),
3312        OrderKind::Stop { .. } => Some("stop"),
3313        OrderKind::StopLimit { .. } => Some("stop_limit"),
3314        OrderKind::TrailingStop { .. } => Some("trailing_stop"),
3315        // Alpaca does not support take-profit or trailing stop-limit orders.
3316        OrderKind::TakeProfit { .. }
3317        | OrderKind::TakeProfitLimit { .. }
3318        | OrderKind::TrailingStopLimit { .. } => None,
3319    }
3320}
3321
3322/// Map rustrade's `TimeInForce` to Alpaca's string representation.
3323///
3324/// # Errors
3325///
3326/// Returns `Err` if `post_only: true` is requested. Alpaca does not support
3327/// post-only orders — silently placing a taker-eligible GTC order would be
3328/// the opposite of caller intent, risking unexpected taker fees. Callers
3329/// requiring maker-only execution must use a different venue or strategy.
3330fn map_time_in_force(tif: TimeInForce) -> Result<&'static str, &'static str> {
3331    match tif {
3332        TimeInForce::GoodUntilCancelled { post_only } => {
3333            if post_only {
3334                return Err("Alpaca does not support post_only orders");
3335            }
3336            Ok("gtc")
3337        }
3338        TimeInForce::GoodUntilEndOfDay => Ok("day"),
3339        TimeInForce::FillOrKill => Ok("fok"),
3340        TimeInForce::ImmediateOrCancel => Ok("ioc"),
3341        TimeInForce::AtOpen => Ok("opg"),
3342        TimeInForce::AtClose => Ok("cls"),
3343        // Alpaca's `gtd` requires an `expired_at` timestamp on the order request,
3344        // which this client does not currently surface. Reject to avoid silently
3345        // dropping the expiry semantics.
3346        TimeInForce::GoodTillDate { .. } => {
3347            Err("Alpaca GoodTillDate is not yet wired through this client")
3348        }
3349    }
3350}
3351
3352/// Returns `true` if the symbol is an equity or options symbol (i.e., NOT crypto).
3353///
3354/// Crypto symbols on Alpaca always contain a forward slash (e.g., `"BTC/USD"`).
3355/// Equities and OCC option symbols never contain a slash. We use this to decide
3356/// whether to include `position_intent` in the order request: the field is valid
3357/// for equities and required for options, but causes a 422 on crypto orders.
3358///
3359/// NOTE: relies on Alpaca's documented symbol format (as of 2025 API). If Alpaca
3360/// introduces a new asset class whose symbols contain `/`, `position_intent` would
3361/// be silently omitted for those orders, causing 422 errors.
3362fn is_options_or_equity_symbol(symbol: &str) -> bool {
3363    !symbol.contains('/')
3364}
3365
3366/// Derives Alpaca's `position_intent` from the generic `reduce_only` flag and `side`.
3367///
3368/// | reduce_only | side | intent       | use case                          |
3369/// |-------------|------|--------------|-----------------------------------|
3370/// | false       | Buy  | BuyToOpen    | open long / add to long position  |
3371/// | false       | Sell | SellToOpen   | open short / write option         |
3372/// | true        | Buy  | BuyToClose   | close short position              |
3373/// | true        | Sell | SellToClose  | close long position               |
3374fn map_position_intent(side: Side, reduce_only: bool) -> AlpacaPositionIntent {
3375    match (reduce_only, side) {
3376        (false, Side::Buy) => AlpacaPositionIntent::BuyToOpen,
3377        (false, Side::Sell) => AlpacaPositionIntent::SellToOpen,
3378        (true, Side::Buy) => AlpacaPositionIntent::BuyToClose,
3379        (true, Side::Sell) => AlpacaPositionIntent::SellToClose,
3380    }
3381}
3382
3383/// Classifies an HTTP error status + body into a typed [`ApiError`].
3384///
3385/// Used by both `rest_with_retry` (for general REST calls) and `rest_delete_with_retry`
3386/// (for cancel operations) to ensure consistent error classification across all REST paths.
3387fn parse_api_error(status: reqwest::StatusCode, message: &str) -> crate::error::UnindexedApiError {
3388    // Fast path: 429 doesn't need message parsing.
3389    if status == reqwest::StatusCode::TOO_MANY_REQUESTS {
3390        return ApiError::RateLimit;
3391    }
3392
3393    // Compute lowercase once for all match guards that inspect the message body.
3394    let lower = message.to_ascii_lowercase();
3395    match status.as_u16() {
3396        // Match "already" before "insufficient": if Alpaca ever sends a 422 body
3397        // containing both substrings, this arm wins and maps to OrderAlreadyCancelled,
3398        // which is more specific than BalanceInsufficient.
3399        422 if lower.contains("already") => ApiError::OrderAlreadyCancelled,
3400        // Alpaca returns 422 for business-rule rejections including insufficient
3401        // funds. 403 is *Forbidden* — auth/permission failure — and must NOT be
3402        // mapped to BalanceInsufficient even if the body happens to contain the
3403        // substring "insufficient".
3404        422 if lower.contains("insufficient") => {
3405            ApiError::BalanceInsufficient(AssetNameExchange::new("usd"), message.to_owned())
3406        }
3407        401 => ApiError::Unauthenticated(format!("unauthorized: {message}")),
3408        403 => ApiError::Unauthenticated(format!("forbidden: {message}")),
3409        404 => ApiError::OrderRejected(format!("order not found: {message}")),
3410        _ => ApiError::OrderRejected(message.to_owned()),
3411    }
3412}
3413
3414/// Wraps [`parse_api_error`] for order-specific error handling (e.g., cancel_order).
3415fn parse_order_error(status: reqwest::StatusCode, message: &str) -> UnindexedOrderError {
3416    UnindexedOrderError::Rejected(parse_api_error(status, message))
3417}
3418
3419fn connectivity_err(msg: impl Into<String>) -> UnindexedClientError {
3420    UnindexedClientError::Connectivity(ConnectivityError::Socket(msg.into()))
3421}
3422
3423// ---------------------------------------------------------------------------
3424// BracketOrderClient implementation
3425// ---------------------------------------------------------------------------
3426
3427impl BracketOrderClient for AlpacaClient {
3428    async fn open_bracket_order(
3429        &self,
3430        request: UnifiedBracketOrderRequest<ExchangeId, &InstrumentNameExchange>,
3431    ) -> UnifiedBracketOrderResult {
3432        let alpaca_request = AlpacaBracketOrderRequest {
3433            instrument: request.key.instrument.clone(),
3434            strategy: request.key.strategy.clone(),
3435            cid: request.key.cid.clone(),
3436            side: request.state.side,
3437            quantity: request.state.quantity,
3438            entry_price: request.state.entry_price,
3439            take_profit_price: request.state.take_profit_price,
3440            stop_loss_price: request.state.stop_loss_price,
3441            stop_loss_limit_price: request.state.stop_loss_limit_price,
3442            time_in_force: request.state.time_in_force,
3443        };
3444
3445        let result = self.open_bracket_order(alpaca_request).await;
3446
3447        UnifiedBracketOrderResult::parent_only(result.parent)
3448    }
3449}
3450
3451// ---------------------------------------------------------------------------
3452// Tests
3453// ---------------------------------------------------------------------------
3454
3455#[cfg(test)]
3456#[allow(clippy::unwrap_used, clippy::expect_used)] // Test code: panics on bad input are acceptable
3457mod tests {
3458    use super::*;
3459
3460    #[test]
3461    fn test_alpaca_config_new_uses_paper_trading_by_default() {
3462        let cfg = AlpacaConfig::new("my_key".into(), "my_secret".into());
3463        assert!(cfg.paper);
3464    }
3465
3466    #[test]
3467    fn test_alpaca_config_debug_redacts_credentials() {
3468        let cfg = AlpacaConfig::new("my_key".into(), "my_secret".into());
3469        let debug = format!("{cfg:?}");
3470        assert!(!debug.contains("my_key"), "api_key should be redacted");
3471        assert!(
3472            !debug.contains("my_secret"),
3473            "secret_key should be redacted"
3474        );
3475        assert!(debug.contains("paper: true"));
3476    }
3477
3478    #[test]
3479    fn test_alpaca_config_deserialize_omitted_paper_defaults_to_paper() {
3480        let cfg: AlpacaConfig = serde_json::from_str(
3481            r#"{
3482        "api_key": "my_key",
3483        "secret_key": "my_secret"
3484    }"#,
3485        )
3486        .unwrap();
3487
3488        assert!(cfg.paper);
3489        assert_eq!(cfg.rest_base_url(), "https://paper-api.alpaca.markets");
3490    }
3491
3492    #[test]
3493    fn test_alpaca_config_deserialize_paper_true() {
3494        let cfg: AlpacaConfig = serde_json::from_str(
3495            r#"{
3496        "api_key": "my_key",
3497        "secret_key": "my_secret",
3498        "paper": true
3499    }"#,
3500        )
3501        .unwrap();
3502
3503        assert!(cfg.paper);
3504        assert_eq!(cfg.rest_base_url(), "https://paper-api.alpaca.markets");
3505    }
3506
3507    #[test]
3508    fn test_alpaca_config_deserialize_paper_false() {
3509        let cfg: AlpacaConfig = serde_json::from_str(
3510            r#"{
3511        "api_key": "my_key",
3512        "secret_key": "my_secret",
3513        "paper": false
3514    }"#,
3515        )
3516        .unwrap();
3517
3518        assert!(!cfg.paper);
3519        assert_eq!(cfg.rest_base_url(), "https://api.alpaca.markets");
3520    }
3521
3522    #[test]
3523    fn test_alpaca_config_urls() {
3524        let paper = AlpacaConfig::paper("k".into(), "s".into());
3525        assert!(paper.rest_base_url().contains("paper-api"));
3526        assert!(paper.ws_url().contains("paper-api"));
3527
3528        let live = AlpacaConfig::production("k".into(), "s".into());
3529        assert!(!live.rest_base_url().contains("paper-api"));
3530        assert!(!live.ws_url().contains("paper-api"));
3531    }
3532
3533    #[test]
3534    #[serial_test::serial]
3535    fn test_alpaca_config_from_env_defaults_to_paper_trading() {
3536        temp_env::with_vars(
3537            [
3538                ("ALPACA_API_KEY", Some("my_key")),
3539                ("ALPACA_SECRET_KEY", Some("my_secret")),
3540                ("ALPACA_PAPER", None),
3541            ],
3542            || {
3543                let cfg = AlpacaConfig::from_env().unwrap();
3544                assert!(cfg.paper);
3545                assert_eq!(cfg.rest_base_url(), "https://paper-api.alpaca.markets");
3546            },
3547        );
3548    }
3549
3550    #[test]
3551    #[serial_test::serial]
3552    fn test_alpaca_config_from_env_accepts_explicit_production() {
3553        temp_env::with_vars(
3554            [
3555                ("ALPACA_API_KEY", Some("my_key")),
3556                ("ALPACA_SECRET_KEY", Some("my_secret")),
3557                ("ALPACA_PAPER", Some("false")),
3558            ],
3559            || {
3560                let cfg = AlpacaConfig::from_env().unwrap();
3561                assert!(!cfg.paper);
3562                assert_eq!(cfg.rest_base_url(), "https://api.alpaca.markets");
3563            },
3564        );
3565    }
3566
3567    #[test]
3568    #[serial_test::serial]
3569    fn test_alpaca_config_from_env_rejects_invalid_paper() {
3570        temp_env::with_vars(
3571            [
3572                ("ALPACA_API_KEY", Some("my_key")),
3573                ("ALPACA_SECRET_KEY", Some("my_secret")),
3574                ("ALPACA_PAPER", Some("maybe")),
3575            ],
3576            || {
3577                let err = AlpacaConfig::from_env().unwrap_err();
3578                assert!(matches!(err, AlpacaConfigError::InvalidPaper(value) if value == "maybe"));
3579            },
3580        );
3581    }
3582
3583    #[test]
3584    #[serial_test::serial]
3585    fn test_alpaca_config_from_env_requires_credentials() {
3586        temp_env::with_vars(
3587            [
3588                ("ALPACA_API_KEY", None),
3589                ("ALPACA_SECRET_KEY", Some("my_secret")),
3590                ("ALPACA_PAPER", None),
3591            ],
3592            || {
3593                let err = AlpacaConfig::from_env().unwrap_err();
3594                assert!(matches!(err, AlpacaConfigError::MissingApiKey));
3595            },
3596        );
3597    }
3598
3599    #[test]
3600    fn test_parse_side() {
3601        assert_eq!(parse_side("buy"), Some(Side::Buy));
3602        assert_eq!(parse_side("sell"), Some(Side::Sell));
3603        assert_eq!(parse_side("Buy"), Some(Side::Buy));
3604        assert_eq!(parse_side("BUY"), Some(Side::Buy));
3605        assert_eq!(parse_side("unknown"), None);
3606    }
3607
3608    #[test]
3609    fn test_parse_order_kind() {
3610        // Market and Limit don't need extra params
3611        assert_eq!(
3612            parse_order_kind("market", None, None, None),
3613            Some(OrderKind::Market)
3614        );
3615        assert_eq!(
3616            parse_order_kind("limit", None, None, None),
3617            Some(OrderKind::Limit)
3618        );
3619
3620        // Stop requires stop_price
3621        assert_eq!(
3622            parse_order_kind("stop", Some("150.00"), None, None),
3623            Some(OrderKind::Stop {
3624                trigger_price: Decimal::from_str("150.00").unwrap()
3625            })
3626        );
3627        assert_eq!(parse_order_kind("stop", None, None, None), None);
3628
3629        // StopLimit requires stop_price
3630        assert_eq!(
3631            parse_order_kind("stop_limit", Some("145.00"), None, None),
3632            Some(OrderKind::StopLimit {
3633                trigger_price: Decimal::from_str("145.00").unwrap()
3634            })
3635        );
3636
3637        // TrailingStop with percentage
3638        assert_eq!(
3639            parse_order_kind("trailing_stop", None, Some("5.0"), None),
3640            Some(OrderKind::TrailingStop {
3641                offset: Decimal::from_str("5.0").unwrap(),
3642                offset_type: TrailingOffsetType::Percentage,
3643            })
3644        );
3645
3646        // TrailingStop with absolute price
3647        assert_eq!(
3648            parse_order_kind("trailing_stop", None, None, Some("2.50")),
3649            Some(OrderKind::TrailingStop {
3650                offset: Decimal::from_str("2.50").unwrap(),
3651                offset_type: TrailingOffsetType::Absolute,
3652            })
3653        );
3654
3655        // TrailingStop without either offset returns None
3656        assert_eq!(parse_order_kind("trailing_stop", None, None, None), None);
3657
3658        // Unknown type returns None
3659        assert_eq!(parse_order_kind("unknown", None, None, None), None);
3660    }
3661
3662    #[test]
3663    fn test_map_order_kind() {
3664        assert_eq!(map_order_kind(OrderKind::Market), Some("market"));
3665        assert_eq!(map_order_kind(OrderKind::Limit), Some("limit"));
3666        assert_eq!(
3667            map_order_kind(OrderKind::Stop {
3668                trigger_price: Decimal::from_str("150.00").unwrap()
3669            }),
3670            Some("stop")
3671        );
3672        assert_eq!(
3673            map_order_kind(OrderKind::StopLimit {
3674                trigger_price: Decimal::from_str("145.00").unwrap()
3675            }),
3676            Some("stop_limit")
3677        );
3678        assert_eq!(
3679            map_order_kind(OrderKind::TrailingStop {
3680                offset: Decimal::from_str("5.0").unwrap(),
3681                offset_type: TrailingOffsetType::Percentage,
3682            }),
3683            Some("trailing_stop")
3684        );
3685        assert_eq!(
3686            map_order_kind(OrderKind::TrailingStop {
3687                offset: Decimal::from_str("2.50").unwrap(),
3688                offset_type: TrailingOffsetType::Absolute,
3689            }),
3690            Some("trailing_stop")
3691        );
3692        // TrailingStopLimit is not supported by Alpaca
3693        assert_eq!(
3694            map_order_kind(OrderKind::TrailingStopLimit {
3695                offset: Decimal::from_str("5.0").unwrap(),
3696                offset_type: TrailingOffsetType::Percentage,
3697                limit_offset: Decimal::from_str("1.0").unwrap(),
3698            }),
3699            None
3700        );
3701        // TakeProfit/TakeProfitLimit are not supported by Alpaca
3702        assert_eq!(
3703            map_order_kind(OrderKind::TakeProfit {
3704                trigger_price: Decimal::from_str("160.00").unwrap()
3705            }),
3706            None
3707        );
3708        assert_eq!(
3709            map_order_kind(OrderKind::TakeProfitLimit {
3710                trigger_price: Decimal::from_str("160.00").unwrap()
3711            }),
3712            None
3713        );
3714    }
3715
3716    #[test]
3717    fn test_map_time_in_force_roundtrip() {
3718        assert_eq!(
3719            map_time_in_force(TimeInForce::GoodUntilCancelled { post_only: false }),
3720            Ok("gtc")
3721        );
3722        assert_eq!(map_time_in_force(TimeInForce::GoodUntilEndOfDay), Ok("day"));
3723        assert_eq!(map_time_in_force(TimeInForce::FillOrKill), Ok("fok"));
3724        assert_eq!(map_time_in_force(TimeInForce::ImmediateOrCancel), Ok("ioc"));
3725    }
3726
3727    #[test]
3728    fn test_map_time_in_force_rejects_post_only() {
3729        let result = map_time_in_force(TimeInForce::GoodUntilCancelled { post_only: true });
3730        assert!(result.is_err(), "post_only must be rejected");
3731        assert!(result.unwrap_err().contains("post_only"));
3732    }
3733
3734    // =========================================================================
3735    // Bracket Order Serialization Tests
3736    // =========================================================================
3737
3738    #[test]
3739    fn test_bracket_order_serializes_with_stop_loss_stop_order() {
3740        // Bracket order with stop-loss as a simple stop order (no limit_price)
3741        let body = AlpacaOrderRequest {
3742            symbol: "AAPL",
3743            qty: "10".to_string(),
3744            side: "buy",
3745            order_type: "limit",
3746            time_in_force: "gtc",
3747            limit_price: Some("150.00".to_string()),
3748            stop_price: None,
3749            trail_percent: None,
3750            trail_price: None,
3751            client_order_id: Some("bracket-001"),
3752            position_intent: Some(AlpacaPositionIntent::BuyToOpen),
3753            order_class: Some("bracket"),
3754            take_profit: Some(TakeProfitParams {
3755                limit_price: "160.00".to_string(),
3756            }),
3757            stop_loss: Some(StopLossParams {
3758                stop_price: "145.00".to_string(),
3759                limit_price: None,
3760            }),
3761        };
3762
3763        let json = serde_json::to_value(&body).unwrap();
3764
3765        assert_eq!(json["symbol"], "AAPL");
3766        assert_eq!(json["qty"], "10");
3767        assert_eq!(json["side"], "buy");
3768        assert_eq!(json["type"], "limit");
3769        assert_eq!(json["time_in_force"], "gtc");
3770        assert_eq!(json["limit_price"], "150.00");
3771        assert_eq!(json["order_class"], "bracket");
3772
3773        // Take profit should have limit_price
3774        assert_eq!(json["take_profit"]["limit_price"], "160.00");
3775
3776        // Stop loss should have stop_price but NO limit_price
3777        assert_eq!(json["stop_loss"]["stop_price"], "145.00");
3778        assert!(
3779            json["stop_loss"].get("limit_price").is_none(),
3780            "stop_loss.limit_price should be omitted when None"
3781        );
3782    }
3783
3784    #[test]
3785    fn test_bracket_order_serializes_with_stop_loss_stop_limit_order() {
3786        // Bracket order with stop-loss as a stop-limit order (has limit_price)
3787        let body = AlpacaOrderRequest {
3788            symbol: "SPY",
3789            qty: "5".to_string(),
3790            side: "sell",
3791            order_type: "limit",
3792            time_in_force: "day",
3793            limit_price: Some("450.00".to_string()),
3794            stop_price: None,
3795            trail_percent: None,
3796            trail_price: None,
3797            client_order_id: Some("bracket-002"),
3798            position_intent: Some(AlpacaPositionIntent::SellToClose),
3799            order_class: Some("bracket"),
3800            take_profit: Some(TakeProfitParams {
3801                limit_price: "440.00".to_string(),
3802            }),
3803            stop_loss: Some(StopLossParams {
3804                stop_price: "455.00".to_string(),
3805                limit_price: Some("456.00".to_string()),
3806            }),
3807        };
3808
3809        let json = serde_json::to_value(&body).unwrap();
3810
3811        assert_eq!(json["symbol"], "SPY");
3812        assert_eq!(json["side"], "sell");
3813        assert_eq!(json["time_in_force"], "day");
3814        assert_eq!(json["order_class"], "bracket");
3815
3816        // Take profit
3817        assert_eq!(json["take_profit"]["limit_price"], "440.00");
3818
3819        // Stop loss with limit_price (stop-limit order)
3820        assert_eq!(json["stop_loss"]["stop_price"], "455.00");
3821        assert_eq!(
3822            json["stop_loss"]["limit_price"], "456.00",
3823            "stop_loss.limit_price should be present for stop-limit orders"
3824        );
3825    }
3826
3827    // =========================================================================
3828    // Bracket Order Validation Tests
3829    // =========================================================================
3830
3831    #[tokio::test]
3832    async fn test_open_bracket_order_rejects_invalid_tif() {
3833        use rust_decimal_macros::dec;
3834        use rustrade_instrument::instrument::name::InstrumentNameExchange;
3835
3836        // Create a minimal client with dummy credentials (no network call will be made)
3837        let config = AlpacaConfig::new("dummy_key".into(), "dummy_secret".into());
3838        let client = AlpacaClient::new(config);
3839
3840        let request = AlpacaBracketOrderRequest::new(
3841            InstrumentNameExchange::new("SPY"),
3842            crate::order::id::StrategyId::new("test"),
3843            crate::order::id::ClientOrderId::new("test-tif"),
3844            Side::Buy,
3845            dec!(1),
3846            dec!(100.00),
3847            dec!(120.00),
3848            dec!(90.00),
3849            TimeInForce::ImmediateOrCancel, // Invalid for brackets
3850        );
3851
3852        let result = client.open_bracket_order(request).await;
3853
3854        assert!(
3855            result.parent.state.is_failed(),
3856            "Bracket order with IOC TIF should be rejected locally"
3857        );
3858    }
3859
3860    #[tokio::test]
3861    async fn test_open_bracket_order_rejects_invalid_price_ordering() {
3862        use rust_decimal_macros::dec;
3863        use rustrade_instrument::instrument::name::InstrumentNameExchange;
3864
3865        let config = AlpacaConfig::new("dummy_key".into(), "dummy_secret".into());
3866        let client = AlpacaClient::new(config);
3867
3868        // Buy bracket with SL > entry (invalid)
3869        let request = AlpacaBracketOrderRequest::new(
3870            InstrumentNameExchange::new("SPY"),
3871            crate::order::id::StrategyId::new("test"),
3872            crate::order::id::ClientOrderId::new("test-price"),
3873            Side::Buy,
3874            dec!(1),
3875            dec!(100.00),
3876            dec!(120.00),
3877            dec!(105.00), // Invalid: SL > entry for buy
3878            TimeInForce::GoodUntilCancelled { post_only: false },
3879        );
3880
3881        let result = client.open_bracket_order(request).await;
3882
3883        assert!(
3884            result.parent.state.is_failed(),
3885            "Bracket order with invalid price ordering should be rejected locally"
3886        );
3887    }
3888
3889    #[tokio::test]
3890    async fn test_open_bracket_order_rejects_invalid_sl_limit_price() {
3891        use rust_decimal_macros::dec;
3892        use rustrade_instrument::instrument::name::InstrumentNameExchange;
3893
3894        let config = AlpacaConfig::new("dummy_key".into(), "dummy_secret".into());
3895        let client = AlpacaClient::new(config);
3896
3897        // Buy bracket with SL limit > SL trigger (invalid for sell stop-limit)
3898        let request = AlpacaBracketOrderRequest::new(
3899            InstrumentNameExchange::new("SPY"),
3900            crate::order::id::StrategyId::new("test"),
3901            crate::order::id::ClientOrderId::new("test-sl-limit"),
3902            Side::Buy,
3903            dec!(1),
3904            dec!(100.00),
3905            dec!(120.00),
3906            dec!(90.00),
3907            TimeInForce::GoodUntilCancelled { post_only: false },
3908        )
3909        .with_stop_loss_limit_price(dec!(95.00)); // Invalid: limit > trigger for sell SL
3910
3911        let result = client.open_bracket_order(request).await;
3912
3913        assert!(
3914            result.parent.state.is_failed(),
3915            "Bracket order with invalid SL limit price should be rejected locally"
3916        );
3917    }
3918
3919    #[test]
3920    fn test_non_bracket_order_omits_bracket_fields() {
3921        // Regular limit order should not have bracket fields
3922        let body = AlpacaOrderRequest {
3923            symbol: "AAPL",
3924            qty: "1".to_string(),
3925            side: "buy",
3926            order_type: "limit",
3927            time_in_force: "gtc",
3928            limit_price: Some("150.00".to_string()),
3929            stop_price: None,
3930            trail_percent: None,
3931            trail_price: None,
3932            client_order_id: Some("regular-001"),
3933            position_intent: Some(AlpacaPositionIntent::BuyToOpen),
3934            order_class: None,
3935            take_profit: None,
3936            stop_loss: None,
3937        };
3938
3939        let json = serde_json::to_value(&body).unwrap();
3940
3941        assert_eq!(json["symbol"], "AAPL");
3942        assert!(
3943            json.get("order_class").is_none(),
3944            "order_class should be omitted for non-bracket orders"
3945        );
3946        assert!(
3947            json.get("take_profit").is_none(),
3948            "take_profit should be omitted for non-bracket orders"
3949        );
3950        assert!(
3951            json.get("stop_loss").is_none(),
3952            "stop_loss should be omitted for non-bracket orders"
3953        );
3954    }
3955
3956    #[test]
3957    fn test_parse_timestamp_valid() {
3958        let ts = parse_timestamp("2025-04-18T14:30:00Z");
3959        assert!(ts.is_some());
3960        let ts2 = parse_timestamp("2025-04-18T14:30:00.123456Z");
3961        assert!(ts2.is_some());
3962        assert_eq!(parse_timestamp("not-a-timestamp"), None);
3963    }
3964
3965    #[test]
3966    fn test_check_auth_response_authorized() {
3967        let msg =
3968            r#"{"stream":"authorization","data":{"status":"authorized","action":"authenticate"}}"#;
3969        assert!(matches!(check_auth_response(msg), Some(Ok(()))));
3970    }
3971
3972    #[test]
3973    fn test_check_auth_response_unauthorized() {
3974        let msg = r#"{"stream":"authorization","data":{"status":"unauthorized"}}"#;
3975        assert!(matches!(
3976            check_auth_response(msg),
3977            Some(Err(HandshakeError::Auth(_)))
3978        ));
3979    }
3980
3981    #[test]
3982    fn test_check_auth_response_non_auth_message() {
3983        let msg = r#"{"stream":"trade_updates","data":{}}"#;
3984        assert!(check_auth_response(msg).is_none());
3985    }
3986
3987    #[test]
3988    fn test_check_listen_ack() {
3989        let ack = r#"{"stream":"listening","data":{"streams":["trade_updates"]}}"#;
3990        assert!(check_listen_ack(ack));
3991
3992        let other = r#"{"stream":"authorization","data":{}}"#;
3993        assert!(!check_listen_ack(other));
3994    }
3995
3996    #[test]
3997    fn test_dedup_cache() {
3998        let cache = new_dedup_cache();
3999        let key = SmolStr::new("order-1:1");
4000        assert!(
4001            !is_duplicate(&cache, &key),
4002            "first time should not be duplicate"
4003        );
4004        assert!(
4005            is_duplicate(&cache, &key),
4006            "second time should be duplicate"
4007        );
4008    }
4009
4010    #[tokio::test]
4011    async fn test_exponential_backoff_progression_and_exhaustion() {
4012        tokio::time::pause();
4013
4014        let mut b = ExponentialBackoff::new();
4015
4016        // First wait should succeed and increment attempt.
4017        assert!(b.wait().await, "first wait should return true");
4018        assert_eq!(b.attempt, 1);
4019
4020        // Drain remaining attempts.
4021        while b.wait().await {}
4022
4023        // Attempt counter saturates at max_attempts.
4024        assert_eq!(b.attempt, MAX_RECONNECT_ATTEMPTS);
4025
4026        // Once exhausted, wait returns false immediately without sleeping.
4027        assert!(!b.wait().await, "exhausted backoff should return false");
4028
4029        // reset() restores attempt to 0.
4030        b.reset();
4031        assert_eq!(b.attempt, 0);
4032
4033        // After reset, wait works again.
4034        assert!(b.wait().await, "wait should succeed after reset");
4035        assert_eq!(b.attempt, 1);
4036    }
4037
4038    #[test]
4039    fn test_convert_account_to_balances_empty_assets() {
4040        let account = AlpacaAccount {
4041            equity: "12000.00".into(),
4042            buying_power: "10000.00".into(),
4043            options_buying_power: Some("8000.00".into()),
4044            crypto_buying_power: None,
4045        };
4046        let balances = convert_account_to_balances(&account, &[]);
4047        assert_eq!(balances.len(), 1);
4048        assert_eq!(
4049            balances[0].balance.total,
4050            Decimal::from_str("12000.00").unwrap()
4051        );
4052        // options_buying_power is preferred for free
4053        assert_eq!(
4054            balances[0].balance.free,
4055            Decimal::from_str("8000.00").unwrap()
4056        );
4057    }
4058
4059    #[test]
4060    fn test_convert_account_to_balances_usd_filter() {
4061        let account = AlpacaAccount {
4062            equity: "12000.00".into(),
4063            buying_power: "10000.00".into(),
4064            options_buying_power: None,
4065            crypto_buying_power: None,
4066        };
4067        let usd = vec![AssetNameExchange::new("USD")];
4068        let balances = convert_account_to_balances(&account, &usd);
4069        assert_eq!(balances.len(), 1);
4070
4071        let non_usd = vec![AssetNameExchange::new("BTC")];
4072        let balances = convert_account_to_balances(&account, &non_usd);
4073        assert!(balances.is_empty());
4074    }
4075
4076    #[test]
4077    fn test_is_options_or_equity_symbol() {
4078        // Crypto symbols contain '/'
4079        assert!(!is_options_or_equity_symbol("BTC/USD"));
4080        assert!(!is_options_or_equity_symbol("ETH/USD"));
4081        assert!(!is_options_or_equity_symbol("SOL/USD"));
4082
4083        // Equity symbols — no slash
4084        assert!(is_options_or_equity_symbol("AAPL"));
4085        assert!(is_options_or_equity_symbol("SPY"));
4086        assert!(is_options_or_equity_symbol("MSFT"));
4087
4088        // OCC option symbols — no slash
4089        assert!(is_options_or_equity_symbol("SPY250418C00450000"));
4090        assert!(is_options_or_equity_symbol("AAPL250418P00145000"));
4091    }
4092
4093    #[test]
4094    fn test_parse_order_error_already_cancelled() {
4095        // Locks in match arm ordering: a 422 with "already" but NOT "insufficient"
4096        // must map to OrderAlreadyCancelled, not BalanceInsufficient.
4097        assert!(matches!(
4098            parse_order_error(
4099                reqwest::StatusCode::UNPROCESSABLE_ENTITY,
4100                "order is already cancelled"
4101            ),
4102            UnindexedOrderError::Rejected(ApiError::OrderAlreadyCancelled)
4103        ));
4104    }
4105
4106    #[test]
4107    fn test_parse_order_error_already_wins_over_insufficient_on_422() {
4108        // If Alpaca sends a body containing both "already" and "insufficient",
4109        // the "already" arm must win (it appears first in the match).
4110        assert!(matches!(
4111            parse_order_error(
4112                reqwest::StatusCode::UNPROCESSABLE_ENTITY,
4113                "order already cancelled due to insufficient margin"
4114            ),
4115            UnindexedOrderError::Rejected(ApiError::OrderAlreadyCancelled)
4116        ));
4117    }
4118
4119    fn make_order_ws<'a>(
4120        id: &str,
4121        symbol: &str,
4122        side: &str,
4123        filled_qty: &'a str,
4124    ) -> AlpacaOrderWs<'a> {
4125        AlpacaOrderWs {
4126            id: SmolStr::new(id),
4127            client_order_id: None,
4128            symbol: SmolStr::new(symbol),
4129            qty: Some("2"),
4130            filled_qty: Some(filled_qty),
4131            side: SmolStr::new(side),
4132            order_type: SmolStr::new("limit"),
4133            time_in_force: SmolStr::new("day"),
4134            limit_price: Some("100.00"),
4135            stop_price: None,
4136            trail_percent: None,
4137            trail_price: None,
4138            status: SmolStr::new("partially_filled"),
4139        }
4140    }
4141
4142    #[test]
4143    fn test_convert_trade_update_fill_produces_trade_with_dedup_key() {
4144        let update = AlpacaTradeUpdate {
4145            event: SmolStr::new("fill"),
4146            order: make_order_ws("ord-1", "SPY", "buy", "1"),
4147            price: Some("150.00"),
4148            qty: Some("1"),
4149            timestamp: Some("2025-04-18T14:30:00Z"),
4150        };
4151        let event = convert_trade_update(update).expect("fill should produce an event");
4152        let AccountEventKind::Trade(trade) = event.kind else {
4153            panic!("expected Trade, got {:?}", event.kind);
4154        };
4155        // Trade ID must be "{order_id}:{cumulative_filled_qty}" for dedup to match REST path.
4156        assert_eq!(trade.id.0.as_str(), "ord-1:1");
4157        assert_eq!(trade.price, Decimal::from_str("150.00").unwrap());
4158        assert_eq!(trade.quantity, Decimal::from_str("1").unwrap());
4159    }
4160
4161    #[test]
4162    fn test_convert_trade_update_partial_fill() {
4163        let update = AlpacaTradeUpdate {
4164            event: SmolStr::new("partial_fill"),
4165            order: make_order_ws("ord-2", "AAPL", "sell", "0.5"),
4166            price: Some("200.00"),
4167            qty: Some("0.5"),
4168            timestamp: None,
4169        };
4170        let event = convert_trade_update(update).expect("partial_fill should produce an event");
4171        assert!(matches!(event.kind, AccountEventKind::Trade(_)));
4172    }
4173
4174    #[test]
4175    fn test_convert_trade_update_new_order_produces_snapshot() {
4176        let update = AlpacaTradeUpdate {
4177            event: SmolStr::new("new"),
4178            order: AlpacaOrderWs {
4179                id: SmolStr::new("ord-new"),
4180                client_order_id: Some(SmolStr::new("cid-1")),
4181                symbol: SmolStr::new("AAPL"),
4182                qty: Some("10"),
4183                filled_qty: Some("0"),
4184                side: SmolStr::new("buy"),
4185                order_type: SmolStr::new("limit"),
4186                time_in_force: SmolStr::new("day"),
4187                limit_price: Some("150.00"),
4188                stop_price: None,
4189                trail_percent: None,
4190                trail_price: None,
4191                status: SmolStr::new("new"),
4192            },
4193            price: None,
4194            qty: None,
4195            timestamp: Some("2025-04-18T14:30:00Z"),
4196        };
4197        let event = convert_trade_update(update).expect("new event should produce an event");
4198        assert!(matches!(event.kind, AccountEventKind::OrderSnapshot(_)));
4199    }
4200
4201    #[test]
4202    fn test_convert_trade_update_canceled_produces_cancel() {
4203        let update = AlpacaTradeUpdate {
4204            event: SmolStr::new("canceled"),
4205            order: make_order_ws("ord-3", "AAPL", "sell", "0"),
4206            price: None,
4207            qty: None,
4208            timestamp: Some("2025-04-18T14:30:00Z"),
4209        };
4210        let event = convert_trade_update(update).expect("canceled should produce an event");
4211        let AccountEventKind::OrderCancelled(response) = event.kind else {
4212            panic!("expected OrderCancelled, got {:?}", event.kind);
4213        };
4214        assert!(response.state.is_ok());
4215    }
4216
4217    #[test]
4218    fn test_convert_trade_update_rejected_produces_error() {
4219        let update = AlpacaTradeUpdate {
4220            event: SmolStr::new("rejected"),
4221            order: make_order_ws("ord-4", "SPY", "buy", "0"),
4222            price: None,
4223            qty: None,
4224            timestamp: None,
4225        };
4226        let event = convert_trade_update(update).expect("rejected should produce an event");
4227        let AccountEventKind::OrderCancelled(response) = event.kind else {
4228            panic!("expected OrderCancelled, got {:?}", event.kind);
4229        };
4230        assert!(response.state.is_err());
4231    }
4232
4233    #[test]
4234    fn test_convert_open_order_notional_qty_none_is_skipped() {
4235        // qty=None means this is a notional order (placed by dollar value).
4236        // Recording it with quantity=0 would corrupt reconciliation, so it must be skipped.
4237        let order = AlpacaOrderResponse {
4238            id: "ord-notional".to_string(),
4239            client_order_id: None,
4240            symbol: "SPY".to_string(),
4241            qty: None,
4242            filled_qty: "0".to_string(),
4243            side: "buy".to_string(),
4244            order_type: "market".to_string(),
4245            time_in_force: "day".to_string(),
4246            limit_price: None,
4247            stop_price: None,
4248            trail_percent: None,
4249            trail_price: None,
4250            created_at: "2025-04-18T14:30:00Z".to_string(),
4251        };
4252        assert!(convert_open_order(&order).is_none());
4253    }
4254
4255    #[test]
4256    fn test_convert_activity_to_trade_bad_price_returns_none() {
4257        // When price is unparseable, convert_activity_to_trade must return None.
4258        // recover_fills advances cumulative_qty BEFORE this check so that the dedup
4259        // key sequence stays aligned with the WS path even when a fill is skipped.
4260        let activity = AlpacaActivity {
4261            id: "act-1".to_string(),
4262            order_id: "ord-1".to_string(),
4263            symbol: "SPY250418C00450000".to_string(),
4264            side: "buy".to_string(),
4265            price: "not-a-number".to_string(),
4266            qty: "1".to_string(),
4267            transaction_time: "2025-04-18T14:30:00Z".to_string(),
4268        };
4269        assert!(convert_activity_to_trade(&activity).is_none());
4270    }
4271
4272    #[test]
4273    fn test_convert_positions_to_balances_crypto() {
4274        let positions = vec![
4275            AlpacaPosition {
4276                symbol: "BTC/USD".into(),
4277                asset_class: "crypto".into(),
4278                qty: "0.5".into(),
4279                qty_available: "0.4".into(),
4280            },
4281            AlpacaPosition {
4282                symbol: "ETH/USD".into(),
4283                asset_class: "crypto".into(),
4284                qty: "2.0".into(),
4285                qty_available: "2.0".into(),
4286            },
4287            // Equity positions should be filtered out
4288            AlpacaPosition {
4289                symbol: "AAPL".into(),
4290                asset_class: "us_equity".into(),
4291                qty: "10".into(),
4292                qty_available: "10".into(),
4293            },
4294        ];
4295
4296        // All crypto assets
4297        let balances = convert_positions_to_balances(&positions, &[]);
4298        assert_eq!(balances.len(), 2, "only crypto positions returned");
4299        assert_eq!(balances[0].asset.name().as_str(), "btc");
4300        // total = qty (0.5 BTC), free = qty_available (0.4 BTC)
4301        assert_eq!(balances[0].balance.total, Decimal::from_str("0.5").unwrap());
4302        assert_eq!(balances[0].balance.free, Decimal::from_str("0.4").unwrap());
4303
4304        // Filter to BTC only
4305        let btc_only = vec![AssetNameExchange::new("BTC")];
4306        let balances = convert_positions_to_balances(&positions, &btc_only);
4307        assert_eq!(balances.len(), 1);
4308        assert_eq!(balances[0].asset.name().as_str(), "btc");
4309    }
4310
4311    fn make_order_response(id: &str, symbol: &str) -> AlpacaOrderResponse {
4312        AlpacaOrderResponse {
4313            id: id.to_string(),
4314            client_order_id: None,
4315            symbol: symbol.to_string(),
4316            qty: Some("1".to_string()),
4317            filled_qty: "0".to_string(),
4318            side: "buy".to_string(),
4319            order_type: "limit".to_string(),
4320            time_in_force: "day".to_string(),
4321            limit_price: Some("100.00".to_string()),
4322            stop_price: None,
4323            trail_percent: None,
4324            trail_price: None,
4325            created_at: "2025-04-18T14:30:00Z".to_string(),
4326        }
4327    }
4328
4329    #[test]
4330    fn test_build_instrument_snapshots_empty_instruments_returns_only_with_orders() {
4331        let orders = vec![
4332            make_order_response("o1", "AAPL"),
4333            make_order_response("o2", "SPY"),
4334        ];
4335        let snapshots = build_instrument_snapshots(orders, &[]);
4336        assert_eq!(snapshots.len(), 2);
4337        let symbols: Vec<&str> = snapshots
4338            .iter()
4339            .map(|s| s.instrument.name().as_str())
4340            .collect();
4341        assert!(symbols.contains(&"AAPL"));
4342        assert!(symbols.contains(&"SPY"));
4343    }
4344
4345    #[test]
4346    fn test_build_instrument_snapshots_requested_instrument_no_orders_gets_empty_snapshot() {
4347        // When instruments list is provided, every requested instrument must appear
4348        // even if it has no open orders — callers depend on this for reconciliation.
4349        let orders = vec![make_order_response("o1", "AAPL")];
4350        let instruments = vec![
4351            InstrumentNameExchange::new("AAPL"),
4352            InstrumentNameExchange::new("SPY"),
4353        ];
4354        let snapshots = build_instrument_snapshots(orders, &instruments);
4355        assert_eq!(snapshots.len(), 2);
4356        let spy = snapshots
4357            .iter()
4358            .find(|s| s.instrument.name().as_str() == "SPY")
4359            .expect("SPY snapshot must be present even with no orders");
4360        assert!(spy.orders.is_empty());
4361    }
4362
4363    #[test]
4364    fn test_build_instrument_snapshots_non_requested_instrument_excluded() {
4365        // An instrument with open orders that is NOT in the requested list must
4366        // not appear in the output when the instruments list is non-empty.
4367        let orders = vec![
4368            make_order_response("o1", "AAPL"),
4369            make_order_response("o2", "MSFT"), // not requested
4370        ];
4371        let instruments = vec![InstrumentNameExchange::new("AAPL")];
4372        let snapshots = build_instrument_snapshots(orders, &instruments);
4373        assert_eq!(snapshots.len(), 1);
4374        assert_eq!(snapshots[0].instrument.name().as_str(), "AAPL");
4375    }
4376
4377    /// Verifies that the dedup key synthesised by `recover_fills` (REST path) matches
4378    /// the key produced by `convert_trade_update` (WS path) for the same partial fills.
4379    ///
4380    /// This is the critical invariant for cross-source dedup after a reconnect:
4381    /// both paths must produce `"{order_id}:{cumulative_filled_qty}"` for the same fill.
4382    #[test]
4383    fn test_recover_fills_dedup_key_matches_ws_path() {
4384        let order_id = "ord-1";
4385
4386        // WS path: Alpaca sends cumulative filled_qty with each event.
4387        // Two partial fills of 1 lot each → filled_qty "1" then "2".
4388        let ws_keys: Vec<SmolStr> = ["1", "2"]
4389            .iter()
4390            .filter_map(|filled_qty| {
4391                let update = AlpacaTradeUpdate {
4392                    event: SmolStr::new("partial_fill"),
4393                    order: make_order_ws(order_id, "SPY", "buy", filled_qty),
4394                    price: Some("150.00"),
4395                    qty: Some("1"),
4396                    timestamp: None,
4397                };
4398                let event = convert_trade_update(update)?;
4399                fill_dedup_key_from_event(&event).cloned()
4400            })
4401            .collect();
4402
4403        // REST path: recover_fills accumulates cumulative qty from per-execution activities.
4404        // Two activities with exec qty "1" each → cumulative 1 then 2.
4405        let mut cumulative = Decimal::ZERO;
4406        let rest_keys: Vec<SmolStr> = ["1", "1"]
4407            .iter()
4408            .map(|exec_qty| {
4409                cumulative += Decimal::from_str(exec_qty).unwrap();
4410                format_smolstr!("{}:{}", order_id, cumulative.normalize())
4411            })
4412            .collect();
4413
4414        assert_eq!(
4415            ws_keys, rest_keys,
4416            "REST recovery dedup keys must match WS path keys for cross-source dedup to work"
4417        );
4418        assert_eq!(ws_keys[0].as_str(), "ord-1:1");
4419        assert_eq!(ws_keys[1].as_str(), "ord-1:2");
4420    }
4421
4422    /// Verifies that `early_dedup_key` produces the same key as the full event path.
4423    ///
4424    /// The early dedup check (M-1 optimization) extracts the key from raw WS fields
4425    /// before constructing the full event. This test ensures both paths produce
4426    /// identical keys, otherwise duplicate detection would fail.
4427    #[test]
4428    fn early_dedup_key_matches_full_event_path() {
4429        let update = AlpacaTradeUpdate {
4430            event: SmolStr::new("fill"),
4431            order: make_order_ws("ord-abc", "SPY", "buy", "5"),
4432            price: Some("150.00"),
4433            qty: Some("5"),
4434            timestamp: None,
4435        };
4436
4437        // Early path: extract key before full event construction
4438        let early_key = early_dedup_key(&update);
4439
4440        // Full path: construct event then extract key
4441        let event = convert_trade_update(update).expect("fill should produce an event");
4442        let full_key =
4443            fill_dedup_key_from_event(&event).expect("fill event should have a dedup key");
4444
4445        assert_eq!(
4446            early_key.as_str(),
4447            full_key.as_str(),
4448            "early_dedup_key must produce the same key as the full event path"
4449        );
4450        assert_eq!(early_key.as_str(), "ord-abc:5");
4451    }
4452
4453    /// Verifies that `early_dedup_key` correctly normalizes decimal strings.
4454    ///
4455    /// Alpaca may send "1.00" or "1" for the same fill. Both must produce the same
4456    /// dedup key to avoid false negatives in duplicate detection.
4457    #[test]
4458    fn early_dedup_key_normalizes_decimal_strings() {
4459        // Test with trailing zeros: "1.00" should normalize to "1"
4460        let update1 = AlpacaTradeUpdate {
4461            event: SmolStr::new("fill"),
4462            order: AlpacaOrderWs {
4463                id: SmolStr::new("ord-x"),
4464                client_order_id: Some(SmolStr::new("cid")),
4465                symbol: SmolStr::new("AAPL"),
4466                qty: Some("10"),
4467                filled_qty: Some("1.00"),
4468                side: SmolStr::new("buy"),
4469                order_type: SmolStr::new("market"),
4470                time_in_force: SmolStr::new("day"),
4471                limit_price: None,
4472                stop_price: None,
4473                trail_percent: None,
4474                trail_price: None,
4475                status: SmolStr::new("filled"),
4476            },
4477            price: Some("100.00"),
4478            qty: Some("10"),
4479            timestamp: None,
4480        };
4481        assert_eq!(early_dedup_key(&update1).as_str(), "ord-x:1");
4482
4483        // Test already normalized: "1" stays "1"
4484        let update2 = AlpacaTradeUpdate {
4485            event: SmolStr::new("fill"),
4486            order: AlpacaOrderWs {
4487                id: SmolStr::new("ord-x"),
4488                client_order_id: Some(SmolStr::new("cid")),
4489                symbol: SmolStr::new("AAPL"),
4490                qty: Some("10"),
4491                filled_qty: Some("1"),
4492                side: SmolStr::new("buy"),
4493                order_type: SmolStr::new("market"),
4494                time_in_force: SmolStr::new("day"),
4495                limit_price: None,
4496                stop_price: None,
4497                trail_percent: None,
4498                trail_price: None,
4499                status: SmolStr::new("filled"),
4500            },
4501            price: Some("100.00"),
4502            qty: Some("10"),
4503            timestamp: None,
4504        };
4505        assert_eq!(early_dedup_key(&update2).as_str(), "ord-x:1");
4506
4507        // Test single trailing zero: "1.0" normalizes to "1"
4508        let update3 = AlpacaTradeUpdate {
4509            event: SmolStr::new("fill"),
4510            order: AlpacaOrderWs {
4511                id: SmolStr::new("ord-x"),
4512                client_order_id: Some(SmolStr::new("cid")),
4513                symbol: SmolStr::new("AAPL"),
4514                qty: Some("10"),
4515                filled_qty: Some("1.0"),
4516                side: SmolStr::new("buy"),
4517                order_type: SmolStr::new("market"),
4518                time_in_force: SmolStr::new("day"),
4519                limit_price: None,
4520                stop_price: None,
4521                trail_percent: None,
4522                trail_price: None,
4523                status: SmolStr::new("filled"),
4524            },
4525            price: Some("100.00"),
4526            qty: Some("10"),
4527            timestamp: None,
4528        };
4529        assert_eq!(early_dedup_key(&update3).as_str(), "ord-x:1");
4530    }
4531
4532    /// Regression guard for HIGH-2: a `rejected` event without `filled_qty` in the JSON
4533    /// previously caused the entire AlpacaTradeUpdate to fail deserialization, silently
4534    /// dropping the event. After the fix, `filled_qty` is Option and defaults to None
4535    /// (unwrapped to "0" at use-sites), so the event reaches the `rejected` branch.
4536    #[test]
4537    fn process_ws_text_rejected_event_without_filled_qty_is_not_dropped() {
4538        let (tx, mut rx) = mpsc::unbounded_channel();
4539        let dedup = new_dedup_cache();
4540        let mut backoff = ExponentialBackoff::new();
4541
4542        // Minimal rejected-event JSON — no `filled_qty` field in the order object.
4543        let json = r#"{"stream":"trade_updates","data":{"event":"rejected","order":{"id":"test-rej-id","client_order_id":"test-cid","symbol":"AAPL","qty":"10","side":"buy","type":"limit","time_in_force":"day","limit_price":"100.00","status":"rejected"}}}"#;
4544
4545        process_ws_text(json, &tx, &dedup, &mut backoff);
4546
4547        // The event must NOT be silently dropped — an OrderCancelled must be emitted.
4548        let event = rx.try_recv()
4549            .expect("rejected event without filled_qty must produce an AccountEvent, not be silently dropped");
4550        assert!(
4551            matches!(event.kind, AccountEventKind::OrderCancelled(_)),
4552            "rejected event must map to OrderCancelled, got: {:?}",
4553            event.kind
4554        );
4555    }
4556
4557    /// Pins the string representation of `Decimal::ZERO.normalize()`, which is used
4558    /// as the dedup key fallback when `filled_qty` is unparseable: `"{order_id}:0"`.
4559    #[test]
4560    fn decimal_zero_normalize_is_zero_str() {
4561        assert_eq!(Decimal::ZERO.normalize().to_string(), "0");
4562    }
4563
4564    /// Verifies that trailing zeros are stripped by `normalize()`, ensuring dedup keys
4565    /// match regardless of whether Alpaca returns `"1.00"` or `"1"` for the same qty.
4566    /// This is the critical invariant for REST/WS dedup key equivalence.
4567    #[test]
4568    fn decimal_normalize_strips_trailing_zeros() {
4569        // Alpaca may return "1.00" in REST but WS cumulative may be "1" — must match.
4570        let from_rest = Decimal::from_str("1.00").unwrap().normalize();
4571        let from_ws = Decimal::from_str("1").unwrap().normalize();
4572        assert_eq!(from_rest.to_string(), from_ws.to_string());
4573        assert_eq!(from_rest.to_string(), "1");
4574
4575        // More edge cases: various trailing zero representations.
4576        assert_eq!(
4577            Decimal::from_str("100.000")
4578                .unwrap()
4579                .normalize()
4580                .to_string(),
4581            "100"
4582        );
4583        assert_eq!(
4584            Decimal::from_str("0.10").unwrap().normalize().to_string(),
4585            "0.1"
4586        );
4587        assert_eq!(
4588            Decimal::from_str("0.100").unwrap().normalize().to_string(),
4589            "0.1"
4590        );
4591    }
4592
4593    // H-2: zero options_buying_power falls back to buying_power (not free=0).
4594    // Alpaca returns options_buying_power="0.00" on equity-only accounts (options
4595    // not enabled) rather than omitting the field. Without the .filter(!is_zero())
4596    // guard the engine would see free=0 and block all orders.
4597    #[test]
4598    fn convert_account_to_balances_zero_options_buying_power_falls_back_to_buying_power() {
4599        let account = AlpacaAccount {
4600            equity: "12000.00".into(),
4601            buying_power: "10000.00".into(),
4602            options_buying_power: Some("0.00".into()), // equity-only: options not enabled
4603            crypto_buying_power: None,
4604        };
4605        let balances = convert_account_to_balances(&account, &[]);
4606        assert_eq!(balances.len(), 1);
4607        assert_eq!(
4608            balances[0].balance.free,
4609            Decimal::from_str("10000.00").unwrap(),
4610            "zero options_buying_power must fall back to buying_power, not report free=0"
4611        );
4612    }
4613
4614    // M-2: map_position_intent derives intent from (reduce_only, side).
4615    #[test]
4616    fn map_position_intent_open_buy_maps_to_buy_to_open() {
4617        assert_eq!(
4618            map_position_intent(Side::Buy, false),
4619            AlpacaPositionIntent::BuyToOpen
4620        );
4621    }
4622
4623    #[test]
4624    fn map_position_intent_open_sell_maps_to_sell_to_open() {
4625        assert_eq!(
4626            map_position_intent(Side::Sell, false),
4627            AlpacaPositionIntent::SellToOpen
4628        );
4629    }
4630
4631    #[test]
4632    fn map_position_intent_reduce_buy_maps_to_buy_to_close() {
4633        assert_eq!(
4634            map_position_intent(Side::Buy, true),
4635            AlpacaPositionIntent::BuyToClose
4636        );
4637    }
4638
4639    #[test]
4640    fn map_position_intent_reduce_sell_maps_to_sell_to_close() {
4641        assert_eq!(
4642            map_position_intent(Side::Sell, true),
4643            AlpacaPositionIntent::SellToClose
4644        );
4645    }
4646
4647    // M-1: parse_order_error — pin all status-code branches not covered by existing tests.
4648    #[test]
4649    fn parse_order_error_401_maps_to_unauthenticated() {
4650        // 401 Unauthorized: invalid/expired API credentials.
4651        assert!(matches!(
4652            parse_order_error(reqwest::StatusCode::UNAUTHORIZED, "bad credentials"),
4653            UnindexedOrderError::Rejected(ApiError::Unauthenticated(_))
4654        ));
4655    }
4656
4657    #[test]
4658    fn parse_order_error_403_maps_to_unauthenticated() {
4659        // 403 Forbidden indicates auth/permission failure — use Unauthenticated, not
4660        // OrderRejected or BalanceInsufficient (which could trigger incorrect retry logic).
4661        assert!(matches!(
4662            parse_order_error(reqwest::StatusCode::FORBIDDEN, "account suspended"),
4663            UnindexedOrderError::Rejected(ApiError::Unauthenticated(_))
4664        ));
4665    }
4666
4667    #[test]
4668    fn parse_order_error_404_maps_to_order_rejected_with_not_found_prefix() {
4669        let err = parse_order_error(reqwest::StatusCode::NOT_FOUND, "order not found");
4670        let UnindexedOrderError::Rejected(ApiError::OrderRejected(msg)) = err else {
4671            panic!("expected OrderRejected, got {err:?}");
4672        };
4673        assert!(
4674            msg.contains("order not found"),
4675            "message should contain 'order not found': {msg}"
4676        );
4677    }
4678
4679    #[test]
4680    fn parse_order_error_422_insufficient_only_maps_to_balance_insufficient() {
4681        // No "already" in body → must not match OrderAlreadyCancelled; must be BalanceInsufficient.
4682        assert!(matches!(
4683            parse_order_error(
4684                reqwest::StatusCode::UNPROCESSABLE_ENTITY,
4685                "insufficient funds for this order"
4686            ),
4687            UnindexedOrderError::Rejected(ApiError::BalanceInsufficient(_, _))
4688        ));
4689    }
4690
4691    #[test]
4692    fn parse_order_error_429_maps_to_rate_limit() {
4693        assert!(matches!(
4694            parse_order_error(reqwest::StatusCode::TOO_MANY_REQUESTS, "rate limited"),
4695            UnindexedOrderError::Rejected(ApiError::RateLimit)
4696        ));
4697    }
4698
4699    // M-4: parse_time_in_force — pin the unknown-value fallback to GoodUntilEndOfDay.
4700    // If Alpaca adds a new TIF (e.g. "opg" for at-the-open), orders continue to be
4701    // tracked with EOD expiry until this function is updated; the warn! makes it visible.
4702    #[test]
4703    fn parse_time_in_force_unknown_value_falls_back_to_good_until_end_of_day() {
4704        assert_eq!(
4705            parse_time_in_force("opg"),
4706            TimeInForce::GoodUntilEndOfDay,
4707            "unknown TIF must fall back to GoodUntilEndOfDay (with a warn! in production)"
4708        );
4709    }
4710
4711    // L-4: build_instrument_snapshots — output order must match the instruments slice,
4712    // not the internal IndexMap insertion order of the orders vec.
4713    #[test]
4714    fn build_instrument_snapshots_output_order_matches_instruments_slice() {
4715        // Orders arrive in symbol order: SPY, AAPL, MSFT.
4716        let orders = vec![
4717            make_order_response("o1", "SPY"),
4718            make_order_response("o2", "AAPL"),
4719            make_order_response("o3", "MSFT"),
4720        ];
4721        // Request a different order: MSFT first, then AAPL.
4722        let instruments = vec![
4723            InstrumentNameExchange::new("MSFT"),
4724            InstrumentNameExchange::new("AAPL"),
4725        ];
4726        let snapshots = build_instrument_snapshots(orders, &instruments);
4727        assert_eq!(snapshots.len(), 2);
4728        assert_eq!(
4729            snapshots[0].instrument.name().as_str(),
4730            "MSFT",
4731            "first snapshot must be MSFT (first in instruments slice)"
4732        );
4733        assert_eq!(
4734            snapshots[1].instrument.name().as_str(),
4735            "AAPL",
4736            "second snapshot must be AAPL (second in instruments slice)"
4737        );
4738    }
4739
4740    // ---------------------------------------------------------------------------
4741    // HTTP-mocked tests — paginate_activities (H-1) and fetch_raw_open_orders (H-3)
4742    // ---------------------------------------------------------------------------
4743    //
4744    // These tests use wiremock to stand up a local HTTP server, verifying the full
4745    // pagination loop and truncation logic without touching the real Alpaca API.
4746    mod http_tests {
4747        use super::super::*;
4748        use wiremock::matchers::{method, path};
4749        use wiremock::{Mock, MockServer, Request, Respond, ResponseTemplate};
4750
4751        /// Serves pre-configured JSON pages in registration order.
4752        ///
4753        /// Each call to `respond` advances an atomic counter and returns the next
4754        /// page body. Panics if called more times than pages were configured —
4755        /// surfaces unexpected extra requests as an explicit test failure rather
4756        /// than silently returning a stale response.
4757        struct Sequential {
4758            call: std::sync::atomic::AtomicU32,
4759            pages: Vec<serde_json::Value>,
4760        }
4761
4762        impl Sequential {
4763            fn new(pages: Vec<serde_json::Value>) -> Self {
4764                Self {
4765                    call: std::sync::atomic::AtomicU32::new(0),
4766                    pages,
4767                }
4768            }
4769        }
4770
4771        impl Respond for Sequential {
4772            fn respond(&self, _: &Request) -> ResponseTemplate {
4773                let i = self.call.fetch_add(1, std::sync::atomic::Ordering::Relaxed) as usize;
4774                let body = self.pages.get(i).unwrap_or_else(|| {
4775                    panic!(
4776                        "Sequential: request #{i} has no configured response \
4777                         (only {} page(s) supplied)",
4778                        self.pages.len()
4779                    )
4780                });
4781                ResponseTemplate::new(200).set_body_json(body)
4782            }
4783        }
4784
4785        /// Build a JSON array of N minimal AlpacaActivity objects with unique IDs.
4786        fn make_activities_json(count: usize, id_prefix: &str) -> serde_json::Value {
4787            serde_json::Value::Array(
4788                (0..count)
4789                    .map(|i| {
4790                        serde_json::json!({
4791                            "id": format!("{id_prefix}-{i:05}"),
4792                            "order_id": "ord-1",
4793                            "symbol": "SPY",
4794                            "side": "buy",
4795                            "price": "100.00",
4796                            "qty": "1",
4797                            "transaction_time": "2025-04-18T14:30:00Z"
4798                        })
4799                    })
4800                    .collect(),
4801            )
4802        }
4803
4804        /// Build a JSON array of N minimal AlpacaOrderResponse objects with unique IDs.
4805        fn make_orders_json(count: usize) -> serde_json::Value {
4806            serde_json::Value::Array(
4807                (0..count)
4808                    .map(|i| {
4809                        serde_json::json!({
4810                            "id": format!("order-{i:05}"),
4811                            "client_order_id": null,
4812                            "symbol": "SPY",
4813                            "qty": "1",
4814                            "filled_qty": "0",
4815                            "side": "buy",
4816                            "type": "limit",
4817                            "time_in_force": "day",
4818                            "limit_price": "100.00",
4819                            "created_at": "2025-04-18T14:30:00Z"
4820                        })
4821                    })
4822                    .collect(),
4823            )
4824        }
4825
4826        // --- H-1: paginate_activities ---
4827
4828        /// Single page with fewer items than the page limit — loop terminates immediately,
4829        /// no further request issued.
4830        #[tokio::test]
4831        async fn paginate_activities_single_page_below_max_returns_all_not_truncated() {
4832            let server = MockServer::start().await;
4833            Mock::given(method("GET"))
4834                .and(path("/v2/account/activities"))
4835                .respond_with(
4836                    ResponseTemplate::new(200).set_body_json(make_activities_json(5, "act")),
4837                )
4838                .mount(&server)
4839                .await;
4840
4841            let http = reqwest::Client::new();
4842            let rl = RateLimitTracker::new();
4843            let result = paginate_activities(&http, &rl, &server.uri(), "2025-01-01T00:00:00Z")
4844                .await
4845                .unwrap();
4846
4847            assert_eq!(result.activities.len(), 5);
4848            assert!(!result.truncated);
4849            assert_eq!(server.received_requests().await.unwrap().len(), 1);
4850        }
4851
4852        /// First page has exactly ALPACA_MAX_ACTIVITIES items, which triggers a second
4853        /// request. The second page is empty, so the loop terminates without truncation.
4854        #[tokio::test]
4855        async fn paginate_activities_exactly_page_size_items_fetches_second_page() {
4856            let server = MockServer::start().await;
4857            Mock::given(method("GET"))
4858                .and(path("/v2/account/activities"))
4859                .respond_with(Sequential::new(vec![
4860                    make_activities_json(ALPACA_MAX_ACTIVITIES, "act"),
4861                    serde_json::json!([]), // empty second page → loop stops
4862                ]))
4863                .mount(&server)
4864                .await;
4865
4866            let http = reqwest::Client::new();
4867            let rl = RateLimitTracker::new();
4868            let result = paginate_activities(&http, &rl, &server.uri(), "2025-01-01T00:00:00Z")
4869                .await
4870                .unwrap();
4871
4872            assert_eq!(result.activities.len(), ALPACA_MAX_ACTIVITIES);
4873            assert!(!result.truncated);
4874            assert_eq!(
4875                server.received_requests().await.unwrap().len(),
4876                2,
4877                "exactly 2 requests: first full page + second empty page"
4878            );
4879        }
4880
4881        /// Two-page case: 100 items on page 1, 37 on page 2 — all accumulated,
4882        /// loop terminates on the partial second page without truncation.
4883        #[tokio::test]
4884        async fn paginate_activities_two_pages_returns_combined_activities_not_truncated() {
4885            let server = MockServer::start().await;
4886            Mock::given(method("GET"))
4887                .and(path("/v2/account/activities"))
4888                .respond_with(Sequential::new(vec![
4889                    make_activities_json(ALPACA_MAX_ACTIVITIES, "p1"),
4890                    make_activities_json(37, "p2"),
4891                ]))
4892                .mount(&server)
4893                .await;
4894
4895            let http = reqwest::Client::new();
4896            let rl = RateLimitTracker::new();
4897            let result = paginate_activities(&http, &rl, &server.uri(), "2025-01-01T00:00:00Z")
4898                .await
4899                .unwrap();
4900
4901            assert_eq!(result.activities.len(), ALPACA_MAX_ACTIVITIES + 37);
4902            assert!(!result.truncated);
4903            assert_eq!(server.received_requests().await.unwrap().len(), 2);
4904        }
4905
4906        /// When every page is full the loop runs until MAX_ACTIVITY_PAGES pages have been
4907        /// fetched, then sets truncated=true and stops. Exactly MAX_ACTIVITY_PAGES HTTP
4908        /// requests are issued (the truncation guard fires before the (N+1)th call).
4909        #[tokio::test]
4910        async fn paginate_activities_at_max_pages_sets_truncated_true() {
4911            let server = MockServer::start().await;
4912
4913            // Always return a full page — the loop must enforce the cap itself.
4914            Mock::given(method("GET"))
4915                .and(path("/v2/account/activities"))
4916                .respond_with(
4917                    ResponseTemplate::new(200)
4918                        .set_body_json(make_activities_json(ALPACA_MAX_ACTIVITIES, "act")),
4919                )
4920                .mount(&server)
4921                .await;
4922
4923            let http = reqwest::Client::new();
4924            let rl = RateLimitTracker::new();
4925            let result = paginate_activities(&http, &rl, &server.uri(), "2025-01-01T00:00:00Z")
4926                .await
4927                .unwrap();
4928
4929            assert!(
4930                result.truncated,
4931                "must be truncated after MAX_ACTIVITY_PAGES pages"
4932            );
4933            assert_eq!(
4934                result.activities.len(),
4935                MAX_ACTIVITY_PAGES * ALPACA_MAX_ACTIVITIES,
4936                "must accumulate exactly MAX_ACTIVITY_PAGES * page_size activities"
4937            );
4938            // The truncation check fires at the top of the loop when pages == MAX_ACTIVITY_PAGES,
4939            // before the (MAX_ACTIVITY_PAGES+1)th request would be issued.
4940            assert_eq!(
4941                server.received_requests().await.unwrap().len(),
4942                MAX_ACTIVITY_PAGES,
4943                "loop must issue exactly MAX_ACTIVITY_PAGES requests then stop"
4944            );
4945        }
4946
4947        // --- H-3: fetch_raw_open_orders truncation boundary ---
4948
4949        /// 499 orders (one below MAX_OPEN_ORDERS) is not truncated — returns Ok.
4950        #[tokio::test]
4951        async fn fetch_raw_open_orders_499_results_returns_ok() {
4952            let server = MockServer::start().await;
4953            Mock::given(method("GET"))
4954                .and(path("/v2/orders"))
4955                .respond_with(
4956                    ResponseTemplate::new(200).set_body_json(make_orders_json(MAX_OPEN_ORDERS - 1)),
4957                )
4958                .mount(&server)
4959                .await;
4960
4961            let http = reqwest::Client::new();
4962            let rl = RateLimitTracker::new();
4963            let result = fetch_raw_open_orders(&http, &rl, &server.uri(), &[]).await;
4964
4965            assert!(
4966                result.is_ok(),
4967                "499 orders must not trigger truncation: {result:?}"
4968            );
4969            assert_eq!(result.unwrap().len(), MAX_OPEN_ORDERS - 1);
4970        }
4971
4972        /// Exactly MAX_OPEN_ORDERS results triggers TruncatedSnapshot because Alpaca's
4973        /// API cap means the response is likely incomplete. An off-by-one here would
4974        /// either silently corrupt OMS state or incorrectly reject a valid account.
4975        #[tokio::test]
4976        async fn fetch_raw_open_orders_500_results_returns_truncated_snapshot_error() {
4977            let server = MockServer::start().await;
4978            Mock::given(method("GET"))
4979                .and(path("/v2/orders"))
4980                .respond_with(
4981                    ResponseTemplate::new(200).set_body_json(make_orders_json(MAX_OPEN_ORDERS)),
4982                )
4983                .mount(&server)
4984                .await;
4985
4986            let http = reqwest::Client::new();
4987            let rl = RateLimitTracker::new();
4988            let result = fetch_raw_open_orders(&http, &rl, &server.uri(), &[]).await;
4989
4990            assert!(
4991                matches!(
4992                    result,
4993                    Err(UnindexedClientError::TruncatedSnapshot { limit }) if limit == MAX_OPEN_ORDERS
4994                ),
4995                "500 orders must return TruncatedSnapshot, got: {result:?}"
4996            );
4997        }
4998
4999        // --- L-2: open_order passes reduce_only through to position_intent ---
5000
5001        /// Verifies that `open_order` correctly derives `position_intent` from
5002        /// `reduce_only` and `side`. This test exercises the full path:
5003        /// `open_order` → `map_position_intent` → `open_order_inner` → HTTP request.
5004        ///
5005        /// Uses wiremock to capture the request body and verify position_intent.
5006        #[tokio::test]
5007        async fn open_order_reduce_only_sell_sends_sell_to_close_intent() {
5008            use crate::client::ExecutionClient;
5009            use crate::order::request::{OrderRequestOpen, RequestOpen};
5010            use crate::order::{
5011                OrderKey, OrderKind, TimeInForce,
5012                id::{ClientOrderId, StrategyId},
5013            };
5014            use rust_decimal::Decimal;
5015            use rustrade_instrument::Side;
5016            use rustrade_instrument::exchange::ExchangeId;
5017            use rustrade_instrument::instrument::name::InstrumentNameExchange;
5018            use wiremock::matchers::{method, path};
5019
5020            let server = MockServer::start().await;
5021
5022            // Mock POST /v2/orders to return a valid order response.
5023            // Use a custom responder to capture and verify the request body.
5024            let captured_body = std::sync::Arc::new(parking_lot::Mutex::new(None));
5025            let captured_clone = captured_body.clone();
5026
5027            Mock::given(method("POST"))
5028                .and(path("/v2/orders"))
5029                .respond_with(move |req: &Request| {
5030                    // Capture the request body for later assertion
5031                    let body: serde_json::Value = serde_json::from_slice(&req.body).unwrap();
5032                    *captured_clone.lock() = Some(body);
5033
5034                    ResponseTemplate::new(200).set_body_json(serde_json::json!({
5035                        "id": "test-order-id",
5036                        "client_order_id": "test-cid",
5037                        "symbol": "AAPL",
5038                        "qty": "10",
5039                        "filled_qty": "0",
5040                        "side": "sell",
5041                        "type": "market",
5042                        "time_in_force": "ioc",
5043                        "limit_price": null,
5044                        "created_at": "2025-04-18T14:30:00Z"
5045                    }))
5046                })
5047                .mount(&server)
5048                .await;
5049
5050            // Create client with base_url_override pointing to mock server
5051            let config =
5052                AlpacaConfig::with_base_url("test-key".into(), "test-secret".into(), server.uri());
5053            let client = AlpacaClient::new(config);
5054
5055            // Create a Sell order with reduce_only=true (should map to SellToClose)
5056            let request = OrderRequestOpen {
5057                key: OrderKey {
5058                    exchange: ExchangeId::AlpacaBroker,
5059                    instrument: InstrumentNameExchange::new("AAPL"),
5060                    strategy: StrategyId::new("test-strategy"),
5061                    cid: ClientOrderId::new("test-cid"),
5062                },
5063                state: RequestOpen {
5064                    side: Side::Sell,
5065                    price: None,
5066                    quantity: Decimal::new(10, 0),
5067                    kind: OrderKind::Market,
5068                    time_in_force: TimeInForce::ImmediateOrCancel,
5069                    position_id: None,
5070                    reduce_only: true, // This should map to SellToClose
5071                },
5072            };
5073
5074            // Call open_order (borrows instrument)
5075            let result = client
5076                .open_order(OrderRequestOpen {
5077                    key: OrderKey {
5078                        exchange: request.key.exchange,
5079                        instrument: &request.key.instrument,
5080                        strategy: request.key.strategy.clone(),
5081                        cid: request.key.cid.clone(),
5082                    },
5083                    state: request.state.clone(),
5084                })
5085                .await;
5086
5087            // Verify the order was accepted
5088            assert!(result.is_some(), "open_order should return a result");
5089            let order = result.unwrap();
5090            assert!(
5091                order.state.is_accepted(),
5092                "order should be accepted: {:?}",
5093                order.state
5094            );
5095
5096            // Verify the request body contained position_intent=sell_to_close
5097            let body = captured_body
5098                .lock()
5099                .take()
5100                .expect("request body should be captured");
5101            assert_eq!(
5102                body.get("position_intent").and_then(|v| v.as_str()),
5103                Some("sell_to_close"),
5104                "reduce_only=true + Side::Sell should produce position_intent=sell_to_close, got: {body}"
5105            );
5106        }
5107
5108        /// Verifies that reduce_only=false + Buy produces BuyToOpen intent.
5109        #[tokio::test]
5110        async fn open_order_not_reduce_only_buy_sends_buy_to_open_intent() {
5111            use crate::client::ExecutionClient;
5112            use crate::order::request::{OrderRequestOpen, RequestOpen};
5113            use crate::order::{
5114                OrderKey, OrderKind, TimeInForce,
5115                id::{ClientOrderId, StrategyId},
5116            };
5117            use rust_decimal::Decimal;
5118            use rustrade_instrument::Side;
5119            use rustrade_instrument::exchange::ExchangeId;
5120            use rustrade_instrument::instrument::name::InstrumentNameExchange;
5121
5122            let server = MockServer::start().await;
5123
5124            let captured_body = std::sync::Arc::new(parking_lot::Mutex::new(None));
5125            let captured_clone = captured_body.clone();
5126
5127            Mock::given(method("POST"))
5128                .and(path("/v2/orders"))
5129                .respond_with(move |req: &Request| {
5130                    let body: serde_json::Value = serde_json::from_slice(&req.body).unwrap();
5131                    *captured_clone.lock() = Some(body);
5132
5133                    ResponseTemplate::new(200).set_body_json(serde_json::json!({
5134                        "id": "test-order-id",
5135                        "client_order_id": "test-cid",
5136                        "symbol": "AAPL",
5137                        "qty": "10",
5138                        "filled_qty": "0",
5139                        "side": "buy",
5140                        "type": "market",
5141                        "time_in_force": "ioc",
5142                        "limit_price": null,
5143                        "created_at": "2025-04-18T14:30:00Z"
5144                    }))
5145                })
5146                .mount(&server)
5147                .await;
5148
5149            let config =
5150                AlpacaConfig::with_base_url("test-key".into(), "test-secret".into(), server.uri());
5151            let client = AlpacaClient::new(config);
5152
5153            let instrument = InstrumentNameExchange::new("AAPL");
5154            let request = OrderRequestOpen {
5155                key: OrderKey {
5156                    exchange: ExchangeId::AlpacaBroker,
5157                    instrument: &instrument,
5158                    strategy: StrategyId::new("test-strategy"),
5159                    cid: ClientOrderId::new("test-cid"),
5160                },
5161                state: RequestOpen {
5162                    side: Side::Buy,
5163                    price: None,
5164                    quantity: Decimal::new(10, 0),
5165                    kind: OrderKind::Market,
5166                    time_in_force: TimeInForce::ImmediateOrCancel,
5167                    position_id: None,
5168                    reduce_only: false, // This should map to BuyToOpen
5169                },
5170            };
5171
5172            let result = client.open_order(request).await;
5173
5174            assert!(result.is_some(), "open_order should return a result");
5175            let order = result.unwrap();
5176            assert!(
5177                order.state.is_accepted(),
5178                "order should be accepted: {:?}",
5179                order.state
5180            );
5181
5182            let body = captured_body
5183                .lock()
5184                .take()
5185                .expect("request body should be captured");
5186            assert_eq!(
5187                body.get("position_intent").and_then(|v| v.as_str()),
5188                Some("buy_to_open"),
5189                "reduce_only=false + Side::Buy should produce position_intent=buy_to_open, got: {body}"
5190            );
5191        }
5192    }
5193}