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