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,
50        bracket::{
51            BracketOrderRequest as UnifiedBracketOrderRequest,
52            BracketOrderResult as UnifiedBracketOrderResult,
53        },
54        id::{ClientOrderId, OrderId, StrategyId},
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    /// # Rate limit note
1172    ///
1173    /// When both USD and non-USD assets are requested (the common startup case), this
1174    /// method fetches `/v2/account` and `/v2/positions` in parallel for ~100-300ms
1175    /// latency savings. Under rate pressure, both requests may hit 429 simultaneously
1176    /// and retry independently. Operators approaching Alpaca rate limits should call
1177    /// with explicit asset filters to serialize requests if needed.
1178    async fn account_snapshot(
1179        &self,
1180        assets: &[AssetNameExchange],
1181        instruments: &[InstrumentNameExchange],
1182    ) -> Result<UnindexedAccountSnapshot, UnindexedClientError> {
1183        let base = self.base_url();
1184        let http = self.http.clone();
1185        let rl = &self.rate_limiter;
1186
1187        let wants_usd = assets.is_empty()
1188            || assets
1189                .iter()
1190                .any(|a| a.name().as_str().eq_ignore_ascii_case("usd"));
1191        let wants_non_usd = assets.is_empty()
1192            || assets
1193                .iter()
1194                .any(|a| !a.name().as_str().eq_ignore_ascii_case("usd"));
1195
1196        // Fetch account + positions in parallel when both are needed (common startup case).
1197        // URLs are extracted before the closures to avoid re-allocating on each retry attempt.
1198        let account_url = format!("{base}/v2/account");
1199        let positions_url = format!("{base}/v2/positions");
1200        let balances = match (wants_usd, wants_non_usd) {
1201            (true, true) => {
1202                let (account, positions): (AlpacaAccount, Vec<AlpacaPosition>) = tokio::try_join!(
1203                    rest_with_retry(rl, || http.get(&account_url)),
1204                    rest_with_retry(rl, || http.get(&positions_url)),
1205                )?;
1206                let mut balances = convert_account_to_balances(&account, assets);
1207                balances.extend(convert_positions_to_balances(&positions, assets));
1208                balances
1209            }
1210            (true, false) => {
1211                let account: AlpacaAccount = rest_with_retry(rl, || http.get(&account_url)).await?;
1212                convert_account_to_balances(&account, assets)
1213            }
1214            (false, true) => {
1215                let positions: Vec<AlpacaPosition> =
1216                    rest_with_retry(rl, || http.get(&positions_url)).await?;
1217                convert_positions_to_balances(&positions, assets)
1218            }
1219            (false, false) => Vec::new(),
1220        };
1221
1222        let open_orders = fetch_raw_open_orders(&http, rl, base, instruments).await?;
1223
1224        // Group open orders by instrument symbol, building InstrumentAccountSnapshots.
1225        let instrument_snapshots = build_instrument_snapshots(open_orders, instruments);
1226
1227        Ok(AccountSnapshot::new(
1228            ExchangeId::AlpacaBroker,
1229            balances,
1230            instrument_snapshots,
1231        ))
1232    }
1233
1234    /// Returns a live stream of account events (fills, order updates).
1235    ///
1236    /// # Startup race window
1237    ///
1238    /// Fills arriving between `account_snapshot` and this method being called by the
1239    /// caller are not recovered automatically — the WebSocket connection does not exist
1240    /// yet during that window. Fills that arrive after the connection is established but
1241    /// before the first poll are buffered in the tungstenite internal buffer and delivered
1242    /// normally. Callers requiring fill completeness at startup **must** call
1243    /// [`ExecutionClient::fetch_trades`] with a ~1 s lookback after calling this method.
1244    ///
1245    /// This gap also applies on every **reconnect**: during the auth+subscribe handshake
1246    /// (`connect_and_subscribe`), any `trade_updates` messages that arrive are consumed
1247    /// by the handshake loop and not forwarded. Fill events in this window are recovered
1248    /// via the REST activities endpoint (anchored to `disconnect_time`). Lifecycle events
1249    /// (`new`, `canceled`, `rejected`) consumed during the handshake are **not** recovered
1250    /// — callers must call `fetch_open_orders` after each reconnect to reconcile order state.
1251    ///
1252    /// # Fill recovery ordering
1253    ///
1254    /// After a reconnect, missed fills are recovered from the REST activities endpoint
1255    /// using `direction=asc` to match the chronological order in which the WS stream
1256    /// advanced `filled_qty`. The dedup key `"{order_id}:{cum_qty}"` is taken from the
1257    /// activity's own `cum_qty`, so it matches the WS path by construction and does not
1258    /// depend on the order in which activities arrive.
1259    ///
1260    /// Only where Alpaca omits `cum_qty` does the key fall back to accumulating per-execution
1261    /// qty within the batch. That fallback counts from zero per order, so it is correct only for
1262    /// an order whose fills lie wholly inside the recovery window, and it is sensitive to
1263    /// activities arriving out of chronological order (e.g. at pagination boundaries).
1264    ///
1265    /// A recovered fill also carries the order's cumulative filled quantity, so it advances the
1266    /// order's `filled_quantity` without waiting for a `fetch_open_orders` reconciliation.
1267    ///
1268    /// # Lifecycle event deduplication
1269    ///
1270    /// Order lifecycle events (`new`, `canceled`, `expired`) are **not** deduplicated
1271    /// across reconnects — only fill events carry a dedup key. After a reconnect, Alpaca
1272    /// re-delivers lifecycle events for orders that were active at disconnect time.
1273    /// Specifically, Alpaca re-delivers a `new` event for **every order open at disconnect
1274    /// time**, not only orders that changed during the gap.
1275    /// Callers must make [`AccountEventKind::OrderSnapshot`] and
1276    /// [`AccountEventKind::OrderCancelled`] processing idempotent, or call
1277    /// [`ExecutionClient::fetch_open_orders`] after each reconnect to reconcile state.
1278    ///
1279    /// # Rejected orders
1280    ///
1281    /// Alpaca `rejected` events are delivered as `AccountEventKind::OrderCancelled` with
1282    /// `state: Err(OrderRejected(...))`. Match on `response.state.is_err()` to distinguish
1283    /// rejections from true cancels — do not call `.unwrap()` on `OrderCancelled.state`.
1284    ///
1285    /// # Stream drop behaviour
1286    ///
1287    /// Dropping the returned `BoxStream` initiates a graceful shutdown of the
1288    /// background `connection_manager` task: the channel close causes the task
1289    /// to send a WebSocket close frame and exit within the current heartbeat
1290    /// window (≤60 s). Any `AccountEvent` items already queued but not yet
1291    /// polled are discarded. Callers who drop and re-subscribe must call
1292    /// [`ExecutionClient::fetch_trades`] with a short lookback to recover the gap.
1293    async fn account_stream(
1294        &self,
1295        _assets: &[AssetNameExchange], // ignored — Alpaca's trade_updates stream delivers all asset classes on one channel
1296        // instruments is used to filter fill recovery (REST) after a reconnect only.
1297        // Live WS events are NOT filtered by instrument: Alpaca's trade_updates stream
1298        // delivers all account events on one channel with no per-symbol subscription.
1299        instruments: &[InstrumentNameExchange],
1300    ) -> Result<Self::AccountStream, UnindexedClientError> {
1301        // Verify the initial connection before returning the stream; distinguishes
1302        // "can't connect at all" from "connected but later disconnected".
1303        let initial_ws = connect_and_subscribe(&self.config).await?;
1304
1305        // Unbounded channel — memory grows if the consumer is slow, but fills
1306        // are never silently dropped. Silent fill loss corrupts position state;
1307        // OOM is loudly observable. The WS delivery path uses non-blocking send()
1308        // which only fails if the receiver is dropped.
1309        let (tx, rx) = mpsc::unbounded_channel::<UnindexedAccountEvent>();
1310        let dedup = new_dedup_cache();
1311        let config = self.config.clone();
1312        let http = self.http.clone();
1313        let rate_limiter = self.rate_limiter.clone();
1314        let instruments = instruments.to_vec();
1315
1316        let cm_handle = tokio::spawn(connection_manager(
1317            tx,
1318            dedup,
1319            config,
1320            http,
1321            rate_limiter,
1322            instruments,
1323            Some(initial_ws),
1324        ));
1325
1326        let rx_stream = tokio_stream::wrappers::UnboundedReceiverStream::new(rx);
1327        let guarded = GracefulShutdownStream::new(rx_stream, cm_handle);
1328        Ok(futures::StreamExt::boxed(guarded))
1329    }
1330
1331    async fn cancel_order(
1332        &self,
1333        request: OrderRequestCancel<ExchangeId, &InstrumentNameExchange>,
1334    ) -> Option<UnindexedOrderResponseCancel> {
1335        let key = crate::order::OrderKey {
1336            exchange: request.key.exchange,
1337            instrument: request.key.instrument.clone(),
1338            strategy: request.key.strategy.clone(),
1339            cid: request.key.cid.clone(),
1340        };
1341
1342        // Require the exchange order ID — Alpaca's DELETE endpoint uses the UUID.
1343        // If only clientOrderId is available, the caller should first resolve it
1344        // via fetch_open_orders.
1345        let order_id: SmolStr = match &request.state.id {
1346            Some(id) => id.0.clone(),
1347            None => {
1348                warn!(
1349                    instrument = %key.instrument,
1350                    "Alpaca cancel_order: no exchange order ID available (clientOrderId-only cancel not supported)"
1351                );
1352                return Some(crate::order::request::OrderResponseCancel {
1353                    key,
1354                    state: Err(UnindexedOrderError::Rejected(ApiError::OrderRejected(
1355                        "exchange order ID required for cancel (fetch_open_orders to resolve)"
1356                            .into(),
1357                    ))),
1358                });
1359            }
1360        };
1361
1362        let base = self.base_url();
1363        let http = self.http.clone();
1364        let url = format!("{base}/v2/orders/{order_id}");
1365
1366        match rest_delete_with_retry(&self.rate_limiter, || http.delete(&url)).await {
1367            Ok(()) => {
1368                let exchange_order_id = OrderId(order_id);
1369                // REST DELETE returns no response body, so filled_qty unavailable.
1370                // Use ZERO; downstream can reconcile via WS events or fetch_open_orders.
1371                Some(crate::order::request::OrderResponseCancel {
1372                    key,
1373                    state: Ok(Cancelled::new(exchange_order_id, Utc::now(), Decimal::ZERO)),
1374                })
1375            }
1376            Err(e) => Some(crate::order::request::OrderResponseCancel { key, state: Err(e) }),
1377        }
1378    }
1379
1380    /// # Position intent derivation
1381    ///
1382    /// Alpaca options/equities require explicit `position_intent`. This impl derives
1383    /// intent from `RequestOpen::reduce_only` and `side`:
1384    ///
1385    /// | reduce_only | side | intent       | use case                          |
1386    /// |-------------|------|--------------|-----------------------------------|
1387    /// | false       | Buy  | BuyToOpen    | open long / add to long position  |
1388    /// | false       | Sell | SellToOpen   | open short / write option         |
1389    /// | true        | Buy  | BuyToClose   | close short position              |
1390    /// | true        | Sell | SellToClose  | close long position               |
1391    ///
1392    /// For explicit control, use [`AlpacaClient::open_order_with_intent`].
1393    ///
1394    /// # Market order price
1395    ///
1396    /// The returned `Order.price` echoes the request price. For market orders this is
1397    /// typically `Decimal::ZERO` (a placeholder). The **actual fill price** arrives via
1398    /// the WebSocket `trade_updates` stream as a `Trade` event. Do not rely on
1399    /// `Order.price` from this REST ack for market order fill prices.
1400    async fn open_order(
1401        &self,
1402        request: OrderRequestOpen<ExchangeId, &InstrumentNameExchange>,
1403    ) -> Option<Order<ExchangeId, InstrumentNameExchange, UnindexedOrderState>> {
1404        let side = request.state.side;
1405        let reduce_only = request.state.reduce_only;
1406        self.open_order_inner(request, map_position_intent(side, reduce_only))
1407            .await
1408    }
1409
1410    /// Fetches balances sequentially (unlike `account_snapshot` which parallelizes).
1411    ///
1412    /// Sequential fetch is intentional for live operation: under rate pressure, parallel
1413    /// requests may both hit 429 simultaneously and retry independently, doubling the
1414    /// backoff delay. The startup latency savings from `account_snapshot`'s parallel
1415    /// fetch are worth the tradeoff there; for periodic balance refreshes during live
1416    /// trading, sequential is safer.
1417    async fn fetch_balances(
1418        &self,
1419        assets: &[AssetNameExchange],
1420    ) -> Result<Vec<AssetBalance<AssetNameExchange>>, UnindexedClientError> {
1421        let base = self.base_url();
1422        let http = self.http.clone();
1423        let mut result = Vec::new();
1424
1425        // Only fetch the account (USD balance) when USD is among the requested assets.
1426        // Crypto-only requests skip this call to conserve rate-limit budget.
1427        let wants_usd = assets.is_empty()
1428            || assets
1429                .iter()
1430                .any(|a| a.name().as_str().eq_ignore_ascii_case("usd"));
1431        if wants_usd {
1432            // Pre-allocate URL to avoid re-allocation on each retry attempt.
1433            let account_url = format!("{base}/v2/account");
1434            let account: AlpacaAccount =
1435                rest_with_retry(&self.rate_limiter, || http.get(&account_url)).await?;
1436            result.extend(convert_account_to_balances(&account, assets));
1437        }
1438
1439        // Fetch positions for non-USD asset balances (e.g., BTC, ETH from crypto holdings).
1440        let wants_non_usd = assets.is_empty()
1441            || assets
1442                .iter()
1443                .any(|a| !a.name().as_str().eq_ignore_ascii_case("usd"));
1444        if wants_non_usd {
1445            // Pre-allocate URL to avoid re-allocation on each retry attempt.
1446            let positions_url = format!("{base}/v2/positions");
1447            let positions: Vec<AlpacaPosition> =
1448                rest_with_retry(&self.rate_limiter, || http.get(&positions_url)).await?;
1449            result.extend(convert_positions_to_balances(&positions, assets));
1450        }
1451
1452        Ok(result)
1453    }
1454
1455    async fn fetch_open_orders(
1456        &self,
1457        instruments: &[InstrumentNameExchange],
1458    ) -> Result<Vec<Order<ExchangeId, InstrumentNameExchange, Open>>, UnindexedClientError> {
1459        let base = self.base_url();
1460        let http = self.http.clone();
1461
1462        let open_orders =
1463            fetch_raw_open_orders(&http, &self.rate_limiter, base, instruments).await?;
1464
1465        let result = open_orders
1466            .into_iter()
1467            .filter_map(|o| convert_open_order(&o))
1468            .collect();
1469        Ok(result)
1470    }
1471
1472    async fn fetch_trades(
1473        &self,
1474        time_since: DateTime<Utc>,
1475        instruments: &[InstrumentNameExchange],
1476    ) -> Result<Vec<Trade<AssetNameExchange, InstrumentNameExchange>>, UnindexedClientError> {
1477        let after_str = time_since.to_rfc3339();
1478        let base = self.base_url();
1479        let http = self.http.clone();
1480
1481        let page = paginate_activities(&http, &self.rate_limiter, base, &after_str).await?;
1482
1483        // Propagate truncation as an error so callers can detect incomplete results.
1484        // A caller can match on `Truncated` and alert rather than act on a partial page.
1485        if page.truncated {
1486            return Err(UnindexedClientError::Truncated {
1487                limit: MAX_ACTIVITY_PAGES,
1488            });
1489        }
1490
1491        // Empty instruments slice means "all instruments" — same convention as
1492        // fetch_open_orders. Build a set only when filtering is needed.
1493        let trades = if instruments.is_empty() {
1494            page.activities
1495                .into_iter()
1496                .filter_map(|a| convert_activity_to_trade(&a))
1497                .collect()
1498        } else {
1499            let instrument_set: fnv::FnvHashSet<&str> =
1500                instruments.iter().map(|i| i.name().as_str()).collect();
1501            page.activities
1502                .into_iter()
1503                .filter(|a| instrument_set.contains(a.symbol.as_str()))
1504                .filter_map(|a| convert_activity_to_trade(&a))
1505                .collect()
1506        };
1507
1508        Ok(trades)
1509    }
1510}
1511
1512// ---------------------------------------------------------------------------
1513// AlpacaClient public extension methods (not on ExecutionClient trait)
1514// ---------------------------------------------------------------------------
1515
1516impl AlpacaClient {
1517    /// Place an order with an explicit `position_intent` override.
1518    ///
1519    /// Use this instead of `ExecutionClient::open_order` when you need to specify
1520    /// exact position intent (e.g. `SellToOpen` for writing a short option, or
1521    /// `BuyToClose` for closing a short position by buying).
1522    ///
1523    /// `intent` is only sent for non-crypto symbols. For crypto symbols (those
1524    /// containing `/`) the field is always omitted regardless of `intent`.
1525    ///
1526    /// # Caller obligations
1527    /// The caller is responsible for passing the semantically correct intent.
1528    /// No validation is performed against the order side or existing position.
1529    pub async fn open_order_with_intent(
1530        &self,
1531        request: OrderRequestOpen<ExchangeId, &InstrumentNameExchange>,
1532        intent: AlpacaPositionIntent,
1533    ) -> Option<Order<ExchangeId, InstrumentNameExchange, UnindexedOrderState>> {
1534        self.open_order_inner(request, intent).await
1535    }
1536
1537    /// Place a bracket order (entry + take-profit + stop-loss) in a single request.
1538    ///
1539    /// A bracket order consists of three linked orders:
1540    /// 1. **Entry**: Limit order to enter the position
1541    /// 2. **Take Profit**: Limit order to exit at profit target
1542    /// 3. **Stop Loss**: Stop (or stop-limit) order to exit at loss limit
1543    ///
1544    /// When the entry order fills, Alpaca activates both exit legs. When either
1545    /// exit leg fills, Alpaca automatically cancels the other.
1546    ///
1547    /// # Constraints
1548    ///
1549    /// - `time_in_force` must be `Day` or `GoodUntilCancelled` (Alpaca rejects others)
1550    /// - No extended hours trading with bracket orders
1551    /// - Entry is always a limit order
1552    ///
1553    /// # Example
1554    ///
1555    /// ```ignore
1556    /// let request = AlpacaBracketOrderRequest::new(
1557    ///     "AAPL".into(),
1558    ///     StrategyId::new("momentum"),
1559    ///     ClientOrderId::new("bracket-001"),
1560    ///     Side::Buy,
1561    ///     dec!(10),
1562    ///     dec!(150.00),  // entry
1563    ///     dec!(160.00),  // take profit
1564    ///     dec!(145.00),  // stop loss
1565    ///     TimeInForce::GoodUntilCancelled { post_only: false },
1566    /// );
1567    /// let result = client.open_bracket_order(request).await;
1568    /// ```
1569    pub async fn open_bracket_order(
1570        &self,
1571        request: AlpacaBracketOrderRequest,
1572    ) -> AlpacaBracketOrderResult {
1573        let order_key = crate::order::OrderKey::new(
1574            ExchangeId::AlpacaBroker,
1575            request.instrument.clone(),
1576            request.strategy.clone(),
1577            request.cid.clone(),
1578        );
1579
1580        // Validate time_in_force: bracket orders only support day or gtc
1581        let tif_str = match request.time_in_force {
1582            TimeInForce::GoodUntilEndOfDay => "day",
1583            TimeInForce::GoodUntilCancelled { post_only } => {
1584                if post_only {
1585                    return AlpacaBracketOrderResult {
1586                        parent: Order {
1587                            key: order_key,
1588                            side: request.side,
1589                            price: Some(request.entry_price),
1590                            quantity: request.quantity,
1591                            kind: OrderKind::Limit,
1592                            time_in_force: request.time_in_force,
1593                            state: OrderState::inactive(OrderError::Rejected(
1594                                ApiError::OrderRejected(
1595                                    "Alpaca does not support post_only for bracket orders"
1596                                        .to_string(),
1597                                ),
1598                            )),
1599                        },
1600                    };
1601                }
1602                "gtc"
1603            }
1604            other => {
1605                return AlpacaBracketOrderResult {
1606                    parent: Order {
1607                        key: order_key,
1608                        side: request.side,
1609                        price: Some(request.entry_price),
1610                        quantity: request.quantity,
1611                        kind: OrderKind::Limit,
1612                        time_in_force: request.time_in_force,
1613                        state: OrderState::inactive(OrderError::Rejected(ApiError::OrderRejected(
1614                            format!(
1615                                "Alpaca bracket orders only support Day or GTC time_in_force, got {:?}",
1616                                other
1617                            ),
1618                        ))),
1619                    },
1620                };
1621            }
1622        };
1623
1624        // Validate price ordering. Alpaca rejects mis-ordered brackets with a
1625        // generic 422; surface a structured local error before round-trip.
1626        let price_ordering_ok = match request.side {
1627            Side::Buy => {
1628                request.stop_loss_price < request.entry_price
1629                    && request.entry_price < request.take_profit_price
1630            }
1631            Side::Sell => {
1632                request.take_profit_price < request.entry_price
1633                    && request.entry_price < request.stop_loss_price
1634            }
1635        };
1636        if !price_ordering_ok {
1637            return AlpacaBracketOrderResult {
1638                parent: Order {
1639                    key: order_key,
1640                    side: request.side,
1641                    price: Some(request.entry_price),
1642                    quantity: request.quantity,
1643                    kind: OrderKind::Limit,
1644                    time_in_force: request.time_in_force,
1645                    state: OrderState::inactive(OrderError::Rejected(ApiError::OrderRejected(
1646                        format!(
1647                            "Invalid bracket price ordering for {:?} side: entry={}, take_profit={}, stop_loss={}",
1648                            request.side,
1649                            request.entry_price,
1650                            request.take_profit_price,
1651                            request.stop_loss_price,
1652                        ),
1653                    ))),
1654                },
1655            };
1656        }
1657
1658        // Validate stop-loss limit price if provided. For a Buy bracket, the SL
1659        // is a sell stop(-limit); the limit must be at or below the trigger so
1660        // the order can fill if price gaps down. Reverse for Sell brackets.
1661        if let Some(sl_limit) = request.stop_loss_limit_price {
1662            let sl_limit_ok = match request.side {
1663                Side::Buy => sl_limit <= request.stop_loss_price,
1664                Side::Sell => sl_limit >= request.stop_loss_price,
1665            };
1666            if !sl_limit_ok {
1667                return AlpacaBracketOrderResult {
1668                    parent: Order {
1669                        key: order_key,
1670                        side: request.side,
1671                        price: Some(request.entry_price),
1672                        quantity: request.quantity,
1673                        kind: OrderKind::Limit,
1674                        time_in_force: request.time_in_force,
1675                        state: OrderState::inactive(OrderError::Rejected(ApiError::OrderRejected(
1676                            format!(
1677                                "Invalid stop-loss limit price for {:?} bracket: \
1678                                 stop_loss_price={}, stop_loss_limit_price={}",
1679                                request.side, request.stop_loss_price, sl_limit,
1680                            ),
1681                        ))),
1682                    },
1683                };
1684            }
1685        }
1686
1687        // Build the bracket order request
1688        let body = AlpacaOrderRequest {
1689            symbol: request.instrument.name().as_str(),
1690            qty: request.quantity.to_string(),
1691            side: map_side(request.side),
1692            order_type: "limit",
1693            time_in_force: tif_str,
1694            limit_price: Some(request.entry_price.to_string()),
1695            stop_price: None,
1696            trail_percent: None,
1697            trail_price: None,
1698            client_order_id: Some(request.cid.0.as_str()),
1699            position_intent: if is_options_or_equity_symbol(request.instrument.name().as_str()) {
1700                Some(map_position_intent(request.side, false))
1701            } else {
1702                None
1703            },
1704            order_class: Some("bracket"),
1705            take_profit: Some(TakeProfitParams {
1706                limit_price: request.take_profit_price.to_string(),
1707            }),
1708            stop_loss: Some(StopLossParams {
1709                stop_price: request.stop_loss_price.to_string(),
1710                limit_price: request.stop_loss_limit_price.map(|p| p.to_string()),
1711            }),
1712        };
1713
1714        let http = self.http.clone();
1715        let rl = &self.rate_limiter;
1716
1717        let result: Result<AlpacaOrderResponse, UnindexedClientError> =
1718            rest_with_retry(rl, || http.post(&self.orders_url).json(&body)).await;
1719
1720        match result {
1721            Ok(resp) => {
1722                let exchange_order_id = OrderId(SmolStr::new(&resp.id));
1723                let time_exchange = order_state_time(&resp);
1724                let filled_qty = Decimal::from_str(&resp.filled_qty).unwrap_or(Decimal::ZERO);
1725
1726                let state = if filled_qty >= request.quantity {
1727                    OrderState::fully_filled(Filled::new(
1728                        exchange_order_id,
1729                        time_exchange,
1730                        filled_qty,
1731                        None,
1732                    ))
1733                } else {
1734                    OrderState::active(Open::new(exchange_order_id, time_exchange, filled_qty))
1735                };
1736
1737                AlpacaBracketOrderResult {
1738                    parent: Order {
1739                        key: order_key,
1740                        side: request.side,
1741                        price: Some(request.entry_price),
1742                        quantity: request.quantity,
1743                        kind: OrderKind::Limit,
1744                        time_in_force: request.time_in_force,
1745                        state,
1746                    },
1747                }
1748            }
1749            Err(e) => {
1750                let order_err = match e {
1751                    UnindexedClientError::Connectivity(ce) => OrderError::Connectivity(ce),
1752                    UnindexedClientError::Api(ae) => OrderError::Rejected(ae),
1753                    UnindexedClientError::TaskFailed(_)
1754                    | UnindexedClientError::Internal(_)
1755                    | UnindexedClientError::Truncated { .. }
1756                    | UnindexedClientError::TruncatedSnapshot { .. } => {
1757                        unreachable!("rest_with_retry (order path) does not produce these variants")
1758                    }
1759                };
1760                AlpacaBracketOrderResult {
1761                    parent: Order {
1762                        key: order_key,
1763                        side: request.side,
1764                        price: Some(request.entry_price),
1765                        quantity: request.quantity,
1766                        kind: OrderKind::Limit,
1767                        time_in_force: request.time_in_force,
1768                        state: OrderState::inactive(order_err),
1769                    },
1770                }
1771            }
1772        }
1773    }
1774
1775    async fn open_order_inner(
1776        &self,
1777        request: OrderRequestOpen<ExchangeId, &InstrumentNameExchange>,
1778        intent: AlpacaPositionIntent,
1779    ) -> Option<Order<ExchangeId, InstrumentNameExchange, UnindexedOrderState>> {
1780        let instrument = request.key.instrument.clone();
1781        let side = request.state.side;
1782        let price = request.state.price;
1783        let quantity = request.state.quantity;
1784        let kind = request.state.kind;
1785        let time_in_force = request.state.time_in_force;
1786        let cid = request.key.cid.clone();
1787
1788        let order_key = crate::order::OrderKey::new(
1789            ExchangeId::AlpacaBroker,
1790            instrument.clone(),
1791            request.key.strategy.clone(),
1792            cid.clone(),
1793        );
1794
1795        // Validate time_in_force before building the request — reject post_only early.
1796        let tif_str = match map_time_in_force(time_in_force) {
1797            Ok(s) => s,
1798            Err(msg) => {
1799                return Some(Order {
1800                    key: order_key,
1801                    side,
1802                    price,
1803                    quantity,
1804                    kind,
1805                    time_in_force,
1806                    state: OrderState::inactive(OrderError::Rejected(ApiError::OrderRejected(
1807                        msg.to_string(),
1808                    ))),
1809                });
1810            }
1811        };
1812
1813        // Validate order kind — reject unsupported types early.
1814        let order_type_str = match map_order_kind(kind) {
1815            Some(s) => s,
1816            None => {
1817                return Some(Order {
1818                    key: order_key,
1819                    side,
1820                    price,
1821                    quantity,
1822                    kind,
1823                    time_in_force,
1824                    state: OrderState::inactive(OrderError::UnsupportedOrderType(format!(
1825                        "Alpaca connector does not yet support OrderKind::{kind:?}"
1826                    ))),
1827                });
1828            }
1829        };
1830
1831        // Validate trailing stop offset type — Alpaca only supports percent and absolute.
1832        if let OrderKind::TrailingStop {
1833            offset_type: TrailingOffsetType::BasisPoints,
1834            ..
1835        } = kind
1836        {
1837            return Some(Order {
1838                key: order_key,
1839                side,
1840                price,
1841                quantity,
1842                kind,
1843                time_in_force,
1844                state: OrderState::inactive(OrderError::UnsupportedOrderType(
1845                    "Alpaca does not support TrailingOffsetType::BasisPoints; \
1846                     use Percentage or Absolute"
1847                        .to_string(),
1848                )),
1849            });
1850        }
1851
1852        // StopLimit requires Order.price (the limit price applied once trigger fires).
1853        // Guard locally to avoid a wire round-trip producing a generic 422.
1854        if matches!(kind, OrderKind::StopLimit { .. }) && price.is_none() {
1855            return Some(Order {
1856                key: order_key,
1857                side,
1858                price,
1859                quantity,
1860                kind,
1861                time_in_force,
1862                state: OrderState::inactive(OrderError::Rejected(ApiError::OrderRejected(
1863                    "StopLimit order requires Order.price (the limit price) to be set".to_string(),
1864                ))),
1865            });
1866        }
1867
1868        // Extract stop/trailing parameters based on order kind.
1869        let (stop_price, trail_percent, trail_price) = match kind {
1870            OrderKind::Stop { trigger_price } | OrderKind::StopLimit { trigger_price } => {
1871                (Some(trigger_price.to_string()), None, None)
1872            }
1873            OrderKind::TrailingStop {
1874                offset,
1875                offset_type,
1876            } => match offset_type {
1877                TrailingOffsetType::Percentage => (None, Some(offset.to_string()), None),
1878                TrailingOffsetType::Absolute => (None, None, Some(offset.to_string())),
1879                TrailingOffsetType::BasisPoints => unreachable!("validated above"),
1880            },
1881            // TakeProfit/TakeProfitLimit rejected by map_order_kind → unreachable here
1882            OrderKind::Market
1883            | OrderKind::Limit
1884            | OrderKind::TakeProfit { .. }
1885            | OrderKind::TakeProfitLimit { .. }
1886            | OrderKind::TrailingStopLimit { .. } => (None, None, None),
1887        };
1888
1889        let body = AlpacaOrderRequest {
1890            symbol: instrument.name().as_str(),
1891            qty: quantity.to_string(),
1892            side: map_side(side),
1893            order_type: order_type_str,
1894            time_in_force: tif_str,
1895            limit_price: match kind {
1896                OrderKind::Limit | OrderKind::StopLimit { .. } => price.map(|p| p.to_string()),
1897                _ => None,
1898            },
1899            stop_price,
1900            trail_percent,
1901            trail_price,
1902            client_order_id: Some(cid.0.as_str()),
1903            // position_intent is required for options orders and valid (but optional)
1904            // for equities. It is NOT a valid field for crypto orders and must be
1905            // omitted, or Alpaca will return 422 Unprocessable Entity.
1906            // We detect options by OCC symbol format; equities also get the field
1907            // as it aids intent tracking on margin accounts.
1908            position_intent: if is_options_or_equity_symbol(instrument.name().as_str()) {
1909                Some(intent)
1910            } else {
1911                None
1912            },
1913            // Non-bracket order: no bracket fields
1914            order_class: None,
1915            take_profit: None,
1916            stop_loss: None,
1917        };
1918
1919        let http = self.http.clone();
1920        let rl = &self.rate_limiter;
1921
1922        let result: Result<AlpacaOrderResponse, UnindexedClientError> =
1923            rest_with_retry(rl, || http.post(&self.orders_url).json(&body)).await;
1924
1925        match result {
1926            Ok(resp) => {
1927                let exchange_order_id = OrderId(SmolStr::new(&resp.id));
1928                let time_exchange = order_state_time(&resp);
1929                let filled_qty = Decimal::from_str(&resp.filled_qty).unwrap_or(Decimal::ZERO);
1930
1931                let state = if filled_qty >= quantity {
1932                    // Order was fully filled immediately (market order or aggressive limit)
1933                    OrderState::fully_filled(Filled::new(
1934                        exchange_order_id,
1935                        time_exchange,
1936                        filled_qty,
1937                        None, // Alpaca order response doesn't include avg_price
1938                    ))
1939                } else {
1940                    // Order is resting on the order book (partially filled or unfilled)
1941                    OrderState::active(Open::new(exchange_order_id, time_exchange, filled_qty))
1942                };
1943
1944                Some(Order {
1945                    key: order_key,
1946                    side,
1947                    price,
1948                    quantity,
1949                    kind,
1950                    time_in_force,
1951                    state,
1952                })
1953            }
1954            Err(e) => {
1955                let order_err = match e {
1956                    UnindexedClientError::Connectivity(ce) => OrderError::Connectivity(ce),
1957                    UnindexedClientError::Api(ae) => OrderError::Rejected(ae),
1958                    // TaskFailed, Internal, Truncated, and TruncatedSnapshot are not
1959                    // returned by rest_with_retry (REST-only path for orders), but matching
1960                    // explicitly ensures any new ClientError variant causes a compile error
1961                    // here rather than being silently misclassified. If a future refactor
1962                    // makes them reachable, the panic surfaces the bug loudly rather than
1963                    // producing a wrong error type.
1964                    UnindexedClientError::TaskFailed(_)
1965                    | UnindexedClientError::Internal(_)
1966                    | UnindexedClientError::Truncated { .. }
1967                    | UnindexedClientError::TruncatedSnapshot { .. } => {
1968                        unreachable!(
1969                            "rest_with_retry (order path) does not produce TaskFailed/Internal/Truncated/TruncatedSnapshot variants"
1970                        )
1971                    }
1972                };
1973                Some(Order {
1974                    key: order_key,
1975                    side,
1976                    price,
1977                    quantity,
1978                    kind,
1979                    time_in_force,
1980                    state: OrderState::inactive(order_err),
1981                })
1982            }
1983        }
1984    }
1985}
1986
1987// ---------------------------------------------------------------------------
1988// REST helpers
1989// ---------------------------------------------------------------------------
1990
1991/// Maximum number of open orders returned by a single `/v2/orders` request.
1992///
1993/// Alpaca's API caps at 500; accounts exceeding this have an incomplete snapshot.
1994const MAX_OPEN_ORDERS: usize = 500;
1995
1996/// Fetch all open orders from Alpaca, optionally filtered by symbol.
1997///
1998/// # Errors
1999///
2000/// Returns [`UnindexedClientError::TruncatedSnapshot`] when exactly 500 results
2001/// are returned, indicating the API limit was likely hit and data may be incomplete.
2002/// This is an Alpaca API limitation with no pagination support for open orders.
2003async fn fetch_raw_open_orders(
2004    http: &reqwest::Client,
2005    rate_limiter: &RateLimitTracker,
2006    base: &str,
2007    instruments: &[InstrumentNameExchange],
2008) -> Result<Vec<AlpacaOrderResponse>, UnindexedClientError> {
2009    let orders: Vec<AlpacaOrderResponse> = if instruments.is_empty() {
2010        rest_with_retry(rate_limiter, || {
2011            http.get(format!("{base}/v2/orders"))
2012                .query(&[("status", "open"), ("limit", "500")])
2013        })
2014        .await?
2015    } else {
2016        let symbols = instruments.iter().map(|i| i.name().as_str()).join(",");
2017        rest_with_retry(rate_limiter, || {
2018            http.get(format!("{base}/v2/orders")).query(&[
2019                ("status", "open"),
2020                ("limit", "500"),
2021                ("symbols", &symbols),
2022            ])
2023        })
2024        .await?
2025    };
2026    if orders.len() == MAX_OPEN_ORDERS {
2027        warn!(
2028            limit = MAX_OPEN_ORDERS,
2029            "Alpaca fetch_raw_open_orders: received exactly {MAX_OPEN_ORDERS} results — \
2030             response is likely truncated"
2031        );
2032        return Err(UnindexedClientError::TruncatedSnapshot {
2033            limit: MAX_OPEN_ORDERS,
2034        });
2035    }
2036    Ok(orders)
2037}
2038
2039// ---------------------------------------------------------------------------
2040// Activity pagination
2041// ---------------------------------------------------------------------------
2042
2043/// Maximum number of pages fetched by [`paginate_activities`].
2044///
2045/// 50 pages × 100 items = 5 000 fills. Exceeding this during recovery indicates
2046/// an unusually long outage; we warn and truncate rather than looping forever.
2047const MAX_ACTIVITY_PAGES: usize = 50;
2048
2049/// Result of [`paginate_activities`] including truncation status.
2050///
2051/// When `truncated` is true, the `activities` vector contains a partial result
2052/// capped at [`MAX_ACTIVITY_PAGES`] pages. Callers should handle this case
2053/// appropriately — typically by alerting operators about potential data loss.
2054struct ActivityPage {
2055    activities: Vec<AlpacaActivity>,
2056    truncated: bool,
2057}
2058
2059/// Fetch all FILL activities since `after` using token-based pagination.
2060///
2061/// Alpaca returns up to `ALPACA_MAX_ACTIVITIES` per page. If a full page is
2062/// returned, the next request uses the last item's `id` as the `page_token`.
2063/// Pagination terminates when a page has fewer items than `page_size`, or after
2064/// [`MAX_ACTIVITY_PAGES`] pages (whichever comes first).
2065///
2066/// Returns [`ActivityPage`] with `truncated = true` if the page limit was reached,
2067/// allowing callers to detect and handle partial results.
2068// Compile-time string form of ALPACA_MAX_ACTIVITIES (avoids runtime to_string() allocation).
2069const PAGE_SIZE_STR: &str = "100"; // must match ALPACA_MAX_ACTIVITIES
2070const _: () = assert!(
2071    ALPACA_MAX_ACTIVITIES == 100,
2072    "PAGE_SIZE_STR must be updated to match ALPACA_MAX_ACTIVITIES",
2073);
2074
2075async fn paginate_activities(
2076    http: &reqwest::Client,
2077    rate_limiter: &RateLimitTracker,
2078    base: &str,
2079    after: &str,
2080) -> Result<ActivityPage, UnindexedClientError> {
2081    let mut all = Vec::with_capacity(ALPACA_MAX_ACTIVITIES);
2082    let mut page_token: Option<String> = None;
2083    let mut pages = 0usize;
2084    let mut truncated = false;
2085
2086    loop {
2087        if pages >= MAX_ACTIVITY_PAGES {
2088            truncated = true;
2089            break;
2090        }
2091        pages += 1;
2092        // Borrow page_token as &str so the closure can capture by reference without cloning.
2093        let page_token_ref = page_token.as_deref();
2094        let activities: Vec<AlpacaActivity> = rest_with_retry(rate_limiter, || {
2095            let mut req = http.get(format!("{base}/v2/account/activities")).query(&[
2096                ("activity_type", "FILL"),
2097                ("after", after),
2098                ("page_size", PAGE_SIZE_STR),
2099                ("direction", "asc"),
2100            ]);
2101            if let Some(token) = page_token_ref {
2102                req = req.query(&[("page_token", token)]);
2103            }
2104            req
2105        })
2106        .await?;
2107
2108        let page_len = activities.len();
2109        // Capture the page token from the current page BEFORE extending `all`, so that
2110        // any future filtering between here and all.extend cannot shift the last element
2111        // and cause the same page to be re-fetched indefinitely.
2112        //
2113        // Alpaca activity IDs are ULID-format (monotonically increasing) and are accepted
2114        // as exclusive page_token cursors by GET /v2/account/activities: the next page
2115        // begins with the item AFTER the token, so the boundary item is not re-delivered.
2116        // The pagination contract relies on this property — verify against Alpaca API docs
2117        // if behaviour changes.
2118        let page_token_candidate = activities.last().map(|a| a.id.clone());
2119        all.extend(activities);
2120
2121        if page_len < ALPACA_MAX_ACTIVITIES {
2122            break;
2123        }
2124        match page_token_candidate {
2125            Some(token) if !token.is_empty() => {
2126                debug!("Alpaca paginate_activities: fetching next page ({page_len} results)");
2127                page_token = Some(token);
2128            }
2129            // Empty token or no last item — pagination complete.
2130            // Guard against empty string to prevent infinite loop if Alpaca ever
2131            // returns a full page with last.id = "" (would restart from beginning).
2132            _ => break,
2133        }
2134    }
2135
2136    Ok(ActivityPage {
2137        activities: all,
2138        truncated,
2139    })
2140}
2141
2142// ---------------------------------------------------------------------------
2143// WebSocket connection manager
2144// ---------------------------------------------------------------------------
2145
2146/// Long-running task managing the WebSocket lifecycle for account_stream.
2147///
2148/// Loop: connect → auth → subscribe → stream events → on disconnect → backoff
2149/// → fill recovery → reconnect. The `tx` channel persists across reconnections
2150/// so the consumer sees a seamless event stream.
2151///
2152/// Terminates when the consumer drops the stream or max reconnect attempts are
2153/// exhausted.
2154#[allow(clippy::cognitive_complexity)] // the inner select! loop owns `ws` and mutates `backoff` —
2155// extracting to a function requires threading 4 non-Clone values (ws, tx, dedup, backoff)
2156// through the call, which adds more complexity than it removes
2157async fn connection_manager(
2158    tx: mpsc::UnboundedSender<UnindexedAccountEvent>,
2159    dedup: SharedDedupCache,
2160    config: Arc<AlpacaConfig>,
2161    http: reqwest::Client,
2162    rate_limiter: Arc<RateLimitTracker>,
2163    instruments: Vec<InstrumentNameExchange>,
2164    initial_ws: Option<WebSocket>,
2165) {
2166    let mut backoff = ExponentialBackoff::new();
2167    let mut disconnect_time: Option<DateTime<Utc>> = None;
2168    let mut current_ws = initial_ws;
2169
2170    'outer: loop {
2171        // --- Connect (skip on first iteration if initial_ws was provided) ---
2172        let mut ws = match current_ws.take() {
2173            Some(ws) => ws,
2174            None => match connect_and_subscribe(&config).await {
2175                Ok(ws) => ws,
2176                Err(e) => {
2177                    error!(%e, "Alpaca WS connect/subscribe failed");
2178                    if !backoff.wait().await {
2179                        error!("Alpaca max reconnect attempts exhausted");
2180                        // Signal terminal stream death in-band before tx drops.
2181                        emit_stream_terminated(
2182                            &tx,
2183                            ExchangeId::AlpacaBroker,
2184                            StreamTerminationReason::ReconnectBudgetExhausted {
2185                                attempts: backoff.attempts(),
2186                                last_error: e.to_string(),
2187                            },
2188                        );
2189                        break;
2190                    }
2191                    continue;
2192                }
2193            },
2194        };
2195        info!("Alpaca account_stream connected and subscribed");
2196        // Session established — reset backoff regardless of whether any text events
2197        // arrive. Without this, a heartbeat-only session (Pings only, no trade_updates)
2198        // would never call process_ws_text and therefore never reset the counter,
2199        // causing the next disconnect to exhaust the reconnect budget faster than expected.
2200        //
2201        // Edge case: if the server accepts the WS handshake and immediately closes the
2202        // connection (fast-accept-close loop), backoff resets on every iteration, causing
2203        // all MAX_RECONNECT_ATTEMPTS attempts to run at INITIAL_BACKOFF_MS rather than
2204        // escalating. This is accepted behaviour for this pathological server scenario —
2205        // the 10-attempt budget still provides ~10 s of protection before giving up.
2206        backoff.reset();
2207
2208        // --- Fill recovery after reconnect ---
2209        // Runs before the event loop so live events arriving during recovery are
2210        // captured by the already-connected WS session. The dedup cache prevents
2211        // duplicates between recovered REST fills and live WS events.
2212        if let Some(dt) = disconnect_time.take() {
2213            let base = config.rest_base_url();
2214            let after_str = dt.to_rfc3339();
2215            match tokio::time::timeout(
2216                Duration::from_secs(FILL_RECOVERY_TIMEOUT_SECS),
2217                recover_fills(
2218                    &http,
2219                    &rate_limiter,
2220                    &instruments,
2221                    base,
2222                    &after_str,
2223                    &tx,
2224                    &dedup,
2225                ),
2226            )
2227            .await
2228            {
2229                Ok(()) => {}
2230                Err(_) => warn!(
2231                    timeout_secs = FILL_RECOVERY_TIMEOUT_SECS,
2232                    "Alpaca fill recovery timed out — some fills may be missing"
2233                ),
2234            }
2235        }
2236
2237        // --- Stream events ---
2238        // Each iteration of the inner loop polls ws.next(), a heartbeat timer,
2239        // and tx.closed() simultaneously via select!. The heartbeat deadline is
2240        // reset on every received message (rolling window).
2241        //
2242        // The timer is pinned once and reset in-place via Sleep::reset(), avoiding
2243        // a new Sleep allocation and Tokio timer-wheel registration per loop iteration.
2244        // Track the wall-clock time of the last received message. Used to anchor
2245        // fill recovery after a heartbeat timeout: Utc::now() at reconnect time
2246        // would be ~HEARTBEAT_TIMEOUT_SECS after the last real message, causing
2247        // the recovery window to miss fills in that silent period.
2248
2249        let mut last_message_time = Utc::now();
2250        let heartbeat = tokio::time::sleep(Duration::from_secs(HEARTBEAT_TIMEOUT_SECS));
2251        tokio::pin!(heartbeat);
2252
2253        // Resets the rolling heartbeat deadline and records the wall-clock receive time.
2254        // Pin<&mut Sleep> cannot be passed to a regular function, so a macro avoids
2255        // repeating the same two-line block across every message-bearing select! arm.
2256        // NOTE: must be defined AFTER the variables it captures for macro hygiene.
2257        macro_rules! reset_heartbeat {
2258            () => {
2259                heartbeat.as_mut().reset(
2260                    tokio::time::Instant::now() + Duration::from_secs(HEARTBEAT_TIMEOUT_SECS),
2261                );
2262                last_message_time = Utc::now();
2263            };
2264        }
2265        // Distinguishes why the inner stream loop exited so the terminal emit below can report a
2266        // meaningful `last_error`. The consumer-drop arm uses `break 'outer` and never yields one.
2267        enum DisconnectReason {
2268            ServerClose,
2269            Error(String),
2270            StreamEnded,
2271            HeartbeatTimeout,
2272        }
2273        let reason = loop {
2274            tokio::select! {
2275                msg = ws.next() => {
2276                    match msg {
2277                        Some(Ok(WsMessage::Ping(_))) => {
2278                            // tokio-tungstenite automatically queues a Pong when poll_next
2279                            // returns a Ping; sending a second Pong would be a duplicate.
2280                            reset_heartbeat!();
2281                        }
2282                        Some(Ok(WsMessage::Text(text))) => {
2283                            process_ws_text(text.as_str(), &tx, &dedup, &mut backoff);
2284                            reset_heartbeat!();
2285                        }
2286                        Some(Ok(WsMessage::Binary(bytes))) => {
2287                            // Alpaca paper trading sends binary-framed JSON.
2288                            match std::str::from_utf8(&bytes) {
2289                                Ok(text) => {
2290                                    process_ws_text(text, &tx, &dedup, &mut backoff);
2291                                    // Only reset heartbeat for valid UTF-8 frames that may carry
2292                                    // real events. A corrupt binary frame (e.g. from a proxy)
2293                                    // must not keep the watchdog from firing.
2294                                    reset_heartbeat!();
2295                                }
2296                                Err(e) => warn!(%e, "Alpaca WS binary frame: not valid UTF-8"),
2297                            }
2298                        }
2299                        Some(Ok(WsMessage::Close(frame))) => {
2300                            warn!(frame = ?frame, "Alpaca WS closed by server");
2301                            break DisconnectReason::ServerClose;
2302                        }
2303                        Some(Ok(_)) => {} // Pong, Frame — ignore
2304                        Some(Err(e)) => {
2305                            warn!(%e, "Alpaca WS error, reconnecting");
2306                            break DisconnectReason::Error(e.to_string());
2307                        }
2308                        None => {
2309                            warn!("Alpaca WS stream ended, reconnecting");
2310                            break DisconnectReason::StreamEnded;
2311                        }
2312                    }
2313                }
2314                _ = &mut heartbeat => {
2315                    warn!(
2316                        timeout_secs = HEARTBEAT_TIMEOUT_SECS,
2317                        "Alpaca heartbeat timeout, reconnecting"
2318                    );
2319                    // Heartbeat timeout is a failure — do NOT reset backoff here.
2320                    // Backoff resets on successful event receipt in process_ws_text.
2321                    break DisconnectReason::HeartbeatTimeout;
2322                }
2323                _ = tx.closed() => {
2324                    debug!("Alpaca account_stream consumer dropped, terminating");
2325                    let _ = tokio::time::timeout(
2326                        Duration::from_secs(WS_CLOSE_TIMEOUT_SECS),
2327                        ws.close(None),
2328                    ).await;
2329                    // Consumer dropped the receiver — no StreamTerminated emit (channel already
2330                    // closed, so it would be a no-op; see emit_stream_terminated docs).
2331                    break 'outer;
2332                }
2333            }
2334        };
2335
2336        // --- Record disconnect time for fill recovery ---
2337        // Anchor to last_message_time, not Utc::now(). For heartbeat-triggered
2338        // disconnects, Utc::now() would be ~HEARTBEAT_TIMEOUT_SECS after the last
2339        // real message, causing the recovery window to miss fills in that gap.
2340        disconnect_time =
2341            Some(last_message_time - chrono::Duration::milliseconds(SIGNAL_RECOVERY_LOOKBACK_MS));
2342
2343        // --- Close stale WS ---
2344        let _ =
2345            tokio::time::timeout(Duration::from_secs(WS_CLOSE_TIMEOUT_SECS), ws.close(None)).await;
2346
2347        if tx.is_closed() {
2348            // Consumer dropped the receiver — no StreamTerminated emit (channel already closed,
2349            // so it would be a no-op; see emit_stream_terminated docs).
2350            break;
2351        }
2352        if !backoff.wait().await {
2353            error!("Alpaca max reconnect attempts exhausted, stream terminating");
2354            // Surrender after repeated reconnects: report the failure that triggered this round
2355            // (consumer-drop already broke out via `break 'outer`).
2356            let last_error = match reason {
2357                DisconnectReason::HeartbeatTimeout => {
2358                    format!("heartbeat timeout ({HEARTBEAT_TIMEOUT_SECS}s)")
2359                }
2360                DisconnectReason::ServerClose => "WebSocket closed by server".to_string(),
2361                DisconnectReason::Error(e) => e,
2362                DisconnectReason::StreamEnded => "WebSocket stream ended".to_string(),
2363            };
2364            emit_stream_terminated(
2365                &tx,
2366                ExchangeId::AlpacaBroker,
2367                StreamTerminationReason::ReconnectBudgetExhausted {
2368                    attempts: backoff.attempts(),
2369                    last_error,
2370                },
2371            );
2372            break;
2373        }
2374    }
2375}
2376
2377/// Error during WebSocket handshake (auth + subscribe).
2378///
2379/// Separates transport errors (network issues, connection drops) from auth errors
2380/// (invalid credentials) so callers can apply appropriate retry logic.
2381#[derive(Debug)]
2382enum HandshakeError {
2383    /// Network-level failure (WS send failed, connection dropped).
2384    /// Transient — retry with backoff.
2385    Transport(String),
2386    /// Authentication rejected by Alpaca (invalid/expired credentials).
2387    /// Not transient — do not retry without credential changes.
2388    Auth(String),
2389}
2390
2391/// Connect to the Alpaca WebSocket, authenticate, and subscribe to trade_updates.
2392///
2393/// On auth or subscribe failure the connection is closed cleanly.
2394async fn connect_and_subscribe(config: &AlpacaConfig) -> Result<WebSocket, UnindexedClientError> {
2395    let url = config.ws_url();
2396    debug!(%url, "Alpaca: connecting to WebSocket");
2397
2398    let mut ws = rustrade_integration::protocol::websocket::connect(url)
2399        .await
2400        .map_err(|e| {
2401            UnindexedClientError::Connectivity(ConnectivityError::Socket(format!(
2402                "WS connect: {e}"
2403            )))
2404        })?;
2405
2406    // auth + subscribe with overall timeout
2407    let result = tokio::time::timeout(
2408        Duration::from_secs(WS_HANDSHAKE_TIMEOUT_SECS),
2409        ws_handshake(&mut ws, config),
2410    )
2411    .await;
2412
2413    match result {
2414        Ok(Ok(())) => Ok(ws),
2415        Ok(Err(HandshakeError::Transport(e))) => {
2416            let _ = ws.close(None).await;
2417            Err(UnindexedClientError::Connectivity(
2418                ConnectivityError::Socket(e),
2419            ))
2420        }
2421        Ok(Err(HandshakeError::Auth(e))) => {
2422            let _ = ws.close(None).await;
2423            Err(UnindexedClientError::Api(ApiError::Unauthenticated(e)))
2424        }
2425        Err(_) => {
2426            let _ = ws.close(None).await;
2427            Err(UnindexedClientError::Connectivity(
2428                ConnectivityError::Timeout,
2429            ))
2430        }
2431    }
2432}
2433
2434/// Perform the Alpaca WS auth + subscribe sequence on a connected WebSocket.
2435///
2436/// Protocol:
2437/// 1. Send `{"action":"auth","key":...,"secret":...}`
2438/// 2. Await `{"stream":"authorization","data":{"status":"authorized",...}}`
2439/// 3. Send `{"action":"listen","data":{"streams":["trade_updates"]}}`
2440/// 4. Await `{"stream":"listening","data":{"streams":["trade_updates"]}}`
2441async fn ws_handshake(ws: &mut WebSocket, config: &AlpacaConfig) -> Result<(), HandshakeError> {
2442    // Step 1: send auth
2443    let auth = serde_json::json!({
2444        "action": "auth",
2445        "key": config.api_key(),
2446        // direct field access within module — secret_key has no pub getter to
2447        // prevent external credential exposure
2448        "secret": config.secret_key,
2449    })
2450    .to_string();
2451    ws.send(WsMessage::Text(auth.into()))
2452        .await
2453        .map_err(|e| HandshakeError::Transport(format!("WS auth send: {e}")))?;
2454
2455    // Step 2: wait for authorization message
2456    loop {
2457        match ws.next().await {
2458            Some(Ok(WsMessage::Text(text))) => {
2459                if let Some(result) = check_auth_response(text.as_str()) {
2460                    result?;
2461                    break;
2462                }
2463            }
2464            Some(Ok(WsMessage::Binary(bytes))) => {
2465                if let Ok(text) = std::str::from_utf8(&bytes)
2466                    && let Some(result) = check_auth_response(text)
2467                {
2468                    result?;
2469                    break;
2470                }
2471            }
2472            Some(Err(e)) => {
2473                return Err(HandshakeError::Transport(format!(
2474                    "WS error during auth: {e}"
2475                )));
2476            }
2477            None => {
2478                return Err(HandshakeError::Transport(
2479                    "WS closed before auth response".into(),
2480                ));
2481            }
2482            _ => {} // ping/pong during auth — ignore
2483        }
2484    }
2485
2486    // Step 3: subscribe to trade_updates
2487    let sub = serde_json::json!({
2488        "action": "listen",
2489        "data": { "streams": ["trade_updates"] }
2490    })
2491    .to_string();
2492    ws.send(WsMessage::Text(sub.into()))
2493        .await
2494        .map_err(|e| HandshakeError::Transport(format!("WS subscribe send: {e}")))?;
2495
2496    // Step 4: wait for listening acknowledgment (optional but confirms subscription)
2497    loop {
2498        match ws.next().await {
2499            Some(Ok(WsMessage::Text(text))) => {
2500                if check_listen_ack(text.as_str()) {
2501                    break;
2502                }
2503                // Other messages in this window are NOT buffered and are permanently
2504                // dropped — callers must reconcile order state via fetch_open_orders
2505                // after each (re)connect, as documented in account_stream's doc comment.
2506                // Log at warn! for trade_updates (fill/lifecycle events) to ensure
2507                // production operators see dropped events; trace! for other streams.
2508                if let Ok(msg) = serde_json::from_str::<AlpacaStreamMessage<'_>>(text.as_str()) {
2509                    if msg.stream == "trade_updates" {
2510                        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");
2511                    } else {
2512                        trace!(stream = %msg.stream, "WS message dropped during listen-ack handshake");
2513                    }
2514                } else {
2515                    trace!(
2516                        bytes = text.len(),
2517                        "WS non-stream message dropped during listen-ack handshake"
2518                    );
2519                }
2520            }
2521            Some(Ok(WsMessage::Binary(bytes))) => {
2522                if let Ok(text) = std::str::from_utf8(&bytes)
2523                    && check_listen_ack(text)
2524                {
2525                    break;
2526                }
2527                trace!(
2528                    bytes = bytes.len(),
2529                    "WS binary message dropped during listen-ack handshake"
2530                );
2531            }
2532            Some(Err(e)) => {
2533                return Err(HandshakeError::Transport(format!(
2534                    "WS error during subscribe: {e}"
2535                )));
2536            }
2537            None => {
2538                return Err(HandshakeError::Transport(
2539                    "WS closed before subscribe ack".into(),
2540                ));
2541            }
2542            _ => {}
2543        }
2544    }
2545
2546    info!("Alpaca WS authenticated and subscribed to trade_updates");
2547    Ok(())
2548}
2549
2550/// Parse a WS message to check for authorization response.
2551/// Returns `None` if the message is not an authorization response.
2552/// Returns `Some(Ok(()))` on success, `Some(Err(HandshakeError::Auth(...)))` on auth failure.
2553///
2554/// Uses `AlpacaStreamMessage` for consistency with the rest of the WS parsing pipeline.
2555fn check_auth_response(text: &str) -> Option<Result<(), HandshakeError>> {
2556    let msg = serde_json::from_str::<AlpacaStreamMessage<'_>>(text).ok()?;
2557    if msg.stream != "authorization" {
2558        return None;
2559    }
2560    #[derive(Deserialize)]
2561    struct AuthData<'a> {
2562        status: &'a str,
2563    }
2564    let data = serde_json::from_str::<AuthData<'_>>(msg.data.get()).ok()?;
2565    if data.status == "authorized" {
2566        Some(Ok(()))
2567    } else {
2568        Some(Err(HandshakeError::Auth(format!(
2569            "Alpaca WS auth failed: status={}",
2570            data.status
2571        ))))
2572    }
2573}
2574
2575/// Returns `true` if the WS message is a listening acknowledgment for trade_updates.
2576///
2577/// Uses `AlpacaStreamMessage` for consistency with the rest of the WS parsing pipeline.
2578fn check_listen_ack(text: &str) -> bool {
2579    let Ok(msg) = serde_json::from_str::<AlpacaStreamMessage<'_>>(text) else {
2580        return false;
2581    };
2582    if msg.stream != "listening" {
2583        return false;
2584    }
2585    #[derive(Deserialize)]
2586    struct ListenData<'a> {
2587        #[serde(borrow)]
2588        streams: Vec<&'a str>,
2589    }
2590    let Ok(data) = serde_json::from_str::<ListenData<'_>>(msg.data.get()) else {
2591        return false;
2592    };
2593    data.streams.contains(&"trade_updates")
2594}
2595
2596// ---------------------------------------------------------------------------
2597// Process incoming WS messages
2598// ---------------------------------------------------------------------------
2599
2600/// Parse a raw WS text message and forward relevant account events to `tx`.
2601fn process_ws_text(
2602    text: &str,
2603    tx: &mpsc::UnboundedSender<UnindexedAccountEvent>,
2604    dedup: &SharedDedupCache,
2605    backoff: &mut ExponentialBackoff,
2606) {
2607    let msg: AlpacaStreamMessage<'_> = match serde_json::from_str(text) {
2608        Ok(m) => m,
2609        Err(e) => {
2610            trace!(
2611                %e,
2612                raw = ?&text[..text.len().min(200)],
2613                "Alpaca WS: skipped non-JSON message"
2614            );
2615            return;
2616        }
2617    };
2618
2619    // Any successfully-parsed frame proves the connection is alive — reset backoff here
2620    // so that unrecognised event types (pending_cancel, replaced, etc.) also reset it.
2621    backoff.reset();
2622
2623    match msg.stream.as_str() {
2624        "trade_updates" => {
2625            let update: AlpacaTradeUpdate<'_> = match serde_json::from_str(msg.data.get()) {
2626                Ok(u) => u,
2627                Err(e) => {
2628                    // Use warn (not trace): the outer frame was valid trade_updates JSON,
2629                    // so failure here signals an unexpected payload format. Could indicate
2630                    // an Alpaca API change or a missing required field dropping a real
2631                    // event (e.g. a rejected order). Visible at production log levels.
2632                    warn!(
2633                        %e,
2634                        raw = ?&msg.data.get()[..msg.data.get().len().min(200)],
2635                        "Alpaca WS trade_updates: failed to deserialize event — event dropped"
2636                    );
2637                    return;
2638                }
2639            };
2640
2641            // PERF: Check dedup BEFORE constructing the full event for fill events.
2642            // This avoids heap allocations (Trade, InstrumentNameExchange, TradeId) on
2643            // duplicate fills, which occur on every reconnect during recovery overlap.
2644            if is_fill_event(&update) {
2645                let key = early_dedup_key(&update);
2646                if is_duplicate(dedup, &key) {
2647                    trace!("Alpaca WS: skipping duplicate fill event (early check)");
2648                    return;
2649                }
2650            }
2651
2652            for event in convert_trade_update(update).into_iter().flatten() {
2653                // Consumer dropped errors are benign; connection_manager will detect
2654                // tx.closed() on the next select! poll and exit cleanly.
2655                let _ = tx.send(event);
2656            }
2657        }
2658        "authorization" | "listening" => {
2659            // Ack messages can appear during initial stream if the handshake was
2660            // not fully awaited. These are benign.
2661            trace!(stream = %msg.stream, "Alpaca WS: auth/listen ack received during stream");
2662        }
2663        other => {
2664            trace!(%other, "Alpaca WS: ignoring unknown stream type");
2665        }
2666    }
2667}
2668
2669/// Extract a dedup key from an account event, if applicable.
2670///
2671/// Uses `trade.id` as the key. Both the WS path (`convert_trade_update`) and the
2672/// REST recovery path (`recover_fills`) synthesise `TradeId` as
2673/// `"{order_id}:{cumulative_filled_qty}"`, ensuring the same fill produces the
2674/// same key regardless of which path delivered it.
2675fn fill_dedup_key_from_event(event: &UnindexedAccountEvent) -> Option<&SmolStr> {
2676    match &event.kind {
2677        // Return a reference to avoid cloning the SmolStr; is_duplicate takes &SmolStr.
2678        AccountEventKind::Trade(trade) => Some(&trade.id.0),
2679        _ => None,
2680    }
2681}
2682
2683/// Returns `true` if this event type produces a fill (Trade) event.
2684///
2685/// Used for early dedup check before allocating the full event.
2686#[inline]
2687fn is_fill_event(update: &AlpacaTradeUpdate<'_>) -> bool {
2688    matches!(update.event.as_str(), "fill" | "partial_fill")
2689}
2690
2691/// Construct the dedup key from raw WS update fields without allocating the full event.
2692///
2693/// Key format: `"{order_id}:{cumulative_filled_qty}"` — same as `convert_trade_update`
2694/// and `recover_fills` produce, ensuring cross-source dedup works correctly.
2695///
2696/// Note: The `format_smolstr!` call heap-allocates for UUID-length order IDs (36 chars
2697/// exceeds SmolStr's 22-byte inline limit). This is unavoidable given the key length.
2698/// Passing the Decimal directly to format_smolstr! (rather than via intermediate String)
2699/// lets Decimal's Display impl write directly into the buffer, eliminating one allocation.
2700fn early_dedup_key(update: &AlpacaTradeUpdate<'_>) -> SmolStr {
2701    let filled_qty = update.order.filled_qty.unwrap_or("0");
2702    // Parse and normalize to match convert_trade_update's key format exactly.
2703    // Decimal::from_str + normalize() are stack-only; no heap allocation until format_smolstr!.
2704    let qty = Decimal::from_str(filled_qty).unwrap_or(Decimal::ZERO);
2705    format_smolstr!("{}:{}", update.order.id, qty.normalize())
2706}
2707
2708// ---------------------------------------------------------------------------
2709// Fill recovery
2710// ---------------------------------------------------------------------------
2711
2712/// Fetch fills missed during a WS disconnect and forward through the dedup cache.
2713async fn recover_fills(
2714    http: &reqwest::Client,
2715    rate_limiter: &RateLimitTracker,
2716    instruments: &[InstrumentNameExchange],
2717    base: &str,
2718    after: &str,
2719    tx: &mpsc::UnboundedSender<UnindexedAccountEvent>,
2720    dedup: &SharedDedupCache,
2721) {
2722    // Empty `instruments` means "recover for all subscribed symbols" (same convention as
2723    // `fetch_trades` / `fetch_open_orders`). Skip the set allocation when not filtering.
2724    info!(%after, instruments = instruments.len(), "Alpaca recovering fills after reconnect");
2725    let instrument_set: fnv::FnvHashSet<&str> = if instruments.is_empty() {
2726        fnv::FnvHashSet::default()
2727    } else {
2728        instruments.iter().map(|i| i.name().as_str()).collect()
2729    };
2730
2731    let page = match paginate_activities(http, rate_limiter, base, after).await {
2732        Ok(p) => p,
2733        Err(e) => {
2734            error!(%e, "Alpaca fill recovery: REST request failed");
2735            return;
2736        }
2737    };
2738
2739    // Log truncation as error — the returned data is partial and some fills are
2740    // permanently lost. Callers cannot propagate this error (recover_fills returns ()),
2741    // but the log ensures operators are alerted.
2742    if page.truncated {
2743        error!(
2744            max_pages = MAX_ACTIVITY_PAGES,
2745            "Alpaca fill recovery: max page limit reached, truncating — \
2746             fills from this outage are permanently lost. Manual reconciliation required."
2747        );
2748    }
2749
2750    let activities = page.activities;
2751
2752    let mut recovered = 0u32;
2753    let mut duplicates = 0u32;
2754
2755    // Fallback only. The dedup key is "{order_id}:{cumulative_filled_qty}" on both paths, and the
2756    // cumulative is now taken from the activity's own `cum_qty` -- the same figure the WS path
2757    // reads from `order.filled_qty` -- so the two agree by construction rather than by
2758    // reconstruction.
2759    //
2760    // This counter is what the key falls back to when `cum_qty` is absent, which reproduces the
2761    // previous behaviour exactly. That behaviour is correct only for an order whose fills lie
2762    // entirely inside the recovery window: it counts from zero per order within the batch, so an
2763    // order that had already partly filled before the window yields keys offset by the amount it
2764    // filled earlier, and those keys match nothing the WS path ever emitted. Preferring `cum_qty`
2765    // is what removes that failure mode.
2766    //
2767    // Activities are fetched with direction=asc, so accumulation here follows execution order.
2768    // Borrow `activities` so &str keys into order_id strings remain valid for the entire loop:
2769    // consuming iteration would drop each activity at the end of its iteration, invalidating keys
2770    // that later fills on the same order still need to look up.
2771    let mut cumulative_qty: FnvHashMap<&str, Decimal> = FnvHashMap::default();
2772
2773    for activity in &activities {
2774        if !instrument_set.is_empty() && !instrument_set.contains(activity.symbol.as_str()) {
2775            // Safe to skip without advancing cumulative_qty: each Alpaca order is bound
2776            // to exactly one symbol, so no fill for a different symbol can ever share
2777            // the same order_id as a fill we're tracking.
2778            continue;
2779        }
2780
2781        // Advance cumulative_qty for every activity, regardless of whether parsing
2782        // succeeds. The WS path uses Alpaca's cumulative filled_qty, which counts ALL
2783        // fills — including those with malformed fields. Skipping the counter for a bad
2784        // fill causes subsequent fills to produce dedup keys that diverge from the WS
2785        // path (off by the bad fill's qty), so the next good fill appears as a duplicate
2786        // on one path and a new fill on the other, resulting in either a missed or a
2787        // double-delivered fill for that execution.
2788        //
2789        // activity.qty is always populated for FILL activities (Alpaca guarantees it).
2790        // unwrap_or(ZERO) guards against unexpected API changes; a zero qty skips
2791        // incrementing the cumulative counter for that activity. Note: a non-empty
2792        // but non-parseable qty string (e.g. an API regression sending "abc") also
2793        // produces exec_qty=ZERO — the counter stalls, causing subsequent fills on
2794        // the same order to produce dedup keys that diverge from the WS path.
2795        let exec_qty = Decimal::from_str(&activity.qty).unwrap_or(Decimal::ZERO);
2796        let cum = cumulative_qty
2797            .entry(activity.order_id.as_str())
2798            .or_default();
2799        *cum += exec_qty;
2800        let cumulative = *cum;
2801
2802        let mut trade = match convert_activity_to_trade(activity) {
2803            Some(t) => t,
2804            None => {
2805                warn!(id = %activity.id, symbol = %activity.symbol, "Alpaca: skipping activity with unparseable fields");
2806                continue; // Counter already advanced; dedup key sequence stays aligned with WS.
2807            }
2808        };
2809
2810        // Override trade.id to match the WS synthesised format so that
2811        // fill_dedup_key_from_event produces the same key for both sources.
2812        // Normalise the cumulative Decimal to strip trailing zeros so the string
2813        // representation matches the WS path (e.g. "1" == "1.00" after normalize).
2814        //
2815        // `order_filled_quantity` holds the venue's own cumulative, parsed by
2816        // `convert_activity_to_trade`; `cumulative` is the intra-batch fallback described above.
2817        //
2818        // The counter is advanced for every activity, including those that carry `cum_qty` and so
2819        // never consult it. That is deliberate: it keeps the fallback usable for a later activity
2820        // on the same order that omits the field. A batch mixing both therefore interleaves two
2821        // key schemes, and the fallback keys in it remain offset by whatever the order filled
2822        // before the window -- the field's presence rescues an activity, not an order.
2823        let key_cumulative = trade.order_filled_quantity.unwrap_or(cumulative);
2824        trade.id = TradeId(format_smolstr!(
2825            "{}:{}",
2826            activity.order_id,
2827            key_cumulative.normalize()
2828        ));
2829
2830        let event =
2831            UnindexedAccountEvent::new(ExchangeId::AlpacaBroker, AccountEventKind::Trade(trade));
2832
2833        // Re-use fill_dedup_key_from_event so the key logic is in one place.
2834        if fill_dedup_key_from_event(&event).is_some_and(|k| is_duplicate(dedup, k)) {
2835            duplicates += 1;
2836            continue;
2837        }
2838        if tx.send(event).is_err() {
2839            debug!("Alpaca fill recovery: consumer dropped during recovery");
2840            return;
2841        }
2842        recovered += 1;
2843    }
2844
2845    info!(recovered, duplicates, "Alpaca fill recovery complete");
2846}
2847
2848// ---------------------------------------------------------------------------
2849// Type conversion helpers
2850// ---------------------------------------------------------------------------
2851
2852/// Convert an Alpaca account response to rustrade balance entries.
2853///
2854/// Returns a single USD balance with:
2855/// - `total` = equity (total account value including open positions)
2856/// - `free` = options_buying_power (if available) or buying_power
2857///
2858/// If `assets` is non-empty, only returns the balance if "usd" (case-insensitive)
2859/// is in the requested set. An empty `assets` slice returns the USD balance unconditionally.
2860fn convert_account_to_balances(
2861    account: &AlpacaAccount,
2862    assets: &[AssetNameExchange],
2863) -> Vec<AssetBalance<AssetNameExchange>> {
2864    // Preserve the caller's casing for the USD asset name (e.g. "USD" vs "usd").
2865    // When no filter is given, fall back to lowercase "usd" as the canonical form.
2866    let usd_entry = assets
2867        .iter()
2868        .find(|a| a.name().as_str().eq_ignore_ascii_case("usd"));
2869
2870    // Filter check: if assets is specified, only return USD balance if requested.
2871    if !assets.is_empty() && usd_entry.is_none() {
2872        return Vec::new();
2873    }
2874
2875    let usd_name = usd_entry
2876        .cloned()
2877        .unwrap_or_else(|| AssetNameExchange::new("usd"));
2878
2879    let total = Decimal::from_str(&account.equity).unwrap_or(Decimal::ZERO);
2880    let free = account
2881        .options_buying_power
2882        .as_deref()
2883        .and_then(|s| Decimal::from_str(s).ok())
2884        // Filter out zero: Alpaca returns options_buying_power="0.00" for equity-only
2885        // accounts (options not enabled) rather than omitting the field. Without this
2886        // filter, unwrap_or_else never fires and the engine reports free=0, blocking
2887        // all orders on an account that may have substantial buying_power.
2888        .filter(|d| !d.is_zero())
2889        .unwrap_or_else(|| Decimal::from_str(&account.buying_power).unwrap_or(Decimal::ZERO));
2890
2891    vec![AssetBalance::new(
2892        usd_name,
2893        Balance::new(total, free),
2894        Utc::now(),
2895    )]
2896}
2897
2898/// Convert Alpaca positions to crypto asset balance entries.
2899///
2900/// Only positions with `asset_class == "crypto"` are included. The base asset
2901/// is extracted from the symbol (e.g., `"BTC/USD"` → `"btc"`).
2902///
2903/// - `total` = quantity of the holding in base currency units (e.g. 0.5 BTC)
2904/// - `free`  = qty_available (base currency units not locked in open orders)
2905///
2906/// If `assets` is non-empty, only positions whose base asset name matches an
2907/// entry in the slice (case-insensitive) are returned.
2908fn convert_positions_to_balances(
2909    positions: &[AlpacaPosition],
2910    assets: &[AssetNameExchange],
2911) -> Vec<AssetBalance<AssetNameExchange>> {
2912    let now = Utc::now();
2913    positions
2914        .iter()
2915        .filter(|p| p.asset_class.eq_ignore_ascii_case("crypto"))
2916        .filter_map(|p| {
2917            // Alpaca crypto symbols are "BASE/QUOTE" (e.g., "BTC/USD").
2918            // Extract the base currency as the asset name.
2919            let base = p
2920                .symbol
2921                .split('/')
2922                .next()
2923                .map(|s| s.to_ascii_lowercase())
2924                .unwrap_or_else(|| p.symbol.to_ascii_lowercase());
2925
2926            // Apply assets filter if specified.
2927            if !assets.is_empty()
2928                && !assets
2929                    .iter()
2930                    .any(|a| a.name().as_str().eq_ignore_ascii_case(&base))
2931            {
2932                return None;
2933            }
2934
2935            // total and free are in base currency units (e.g., BTC), not USD,
2936            // consistent with AssetBalance semantics for currency/crypto assets.
2937            let total = Decimal::from_str(&p.qty).unwrap_or(Decimal::ZERO);
2938            let free = Decimal::from_str(&p.qty_available).unwrap_or(Decimal::ZERO);
2939            let asset_name = AssetNameExchange::new(base);
2940            Some(AssetBalance::new(
2941                asset_name,
2942                Balance::new(total, free),
2943                now,
2944            ))
2945        })
2946        .collect()
2947}
2948
2949/// Group a list of open orders into per-instrument snapshots for account_snapshot.
2950///
2951/// When `instruments` is non-empty, a snapshot is returned for every requested instrument
2952/// (possibly with an empty orders list). When empty, only instruments with open orders
2953/// are returned.
2954fn build_instrument_snapshots(
2955    orders: Vec<AlpacaOrderResponse>,
2956    instruments: &[InstrumentNameExchange],
2957) -> Vec<InstrumentAccountSnapshot<ExchangeId, AssetNameExchange, InstrumentNameExchange>> {
2958    // Build ordered map from symbol → snapshot to preserve deterministic ordering.
2959    let mut by_symbol: IndexMap<SmolStr, Vec<_>> = IndexMap::new();
2960
2961    for order in orders {
2962        let sym = SmolStr::new(&order.symbol);
2963        if let Some(converted) = convert_open_order(&order) {
2964            let wrapped = crate::order::Order {
2965                key: converted.key,
2966                side: converted.side,
2967                price: converted.price,
2968                quantity: converted.quantity,
2969                kind: converted.kind,
2970                time_in_force: converted.time_in_force,
2971                state: OrderState::active(converted.state),
2972            };
2973            by_symbol.entry(sym).or_default().push(wrapped);
2974        }
2975    }
2976
2977    // If instruments is empty, return all; otherwise filter to requested set.
2978    if instruments.is_empty() {
2979        by_symbol
2980            .into_iter()
2981            .map(|(sym, orders)| {
2982                InstrumentAccountSnapshot::new(InstrumentNameExchange::new(sym), orders, None, None)
2983            })
2984            .collect()
2985    } else {
2986        instruments
2987            .iter()
2988            .map(|inst| {
2989                // swap_remove is O(1); output order is determined by the `instruments`
2990                // slice, not by the internal IndexMap order of `by_symbol`.
2991                let orders = by_symbol
2992                    .swap_remove(inst.name().as_str())
2993                    .unwrap_or_default();
2994                InstrumentAccountSnapshot::new(inst.clone(), orders, None, None)
2995            })
2996            .collect()
2997    }
2998}
2999
3000/// Convert an Alpaca REST order into rustrade's Open state order.
3001fn convert_open_order(
3002    o: &AlpacaOrderResponse,
3003) -> Option<Order<ExchangeId, InstrumentNameExchange, Open>> {
3004    let order_id = OrderId(SmolStr::new(&o.id));
3005    let cid = o
3006        .client_order_id
3007        .as_deref()
3008        .map(ClientOrderId::new)
3009        .unwrap_or_else(|| ClientOrderId::new(o.id.as_str()));
3010
3011    let instrument = InstrumentNameExchange::new(&o.symbol);
3012    let side = parse_side(&o.side)?;
3013    let quantity = Decimal::from_str(o.qty.as_deref().unwrap_or("0")).ok()?;
3014    // Notional orders (placed by dollar value, qty=null) have no representable quantity.
3015    // Skip them rather than recording a zero-quantity order that would corrupt reconciliation.
3016    if quantity.is_zero() {
3017        return None;
3018    }
3019    let price = o
3020        .limit_price
3021        .as_deref()
3022        .and_then(|s| Decimal::from_str(s).ok());
3023    let filled_qty = Decimal::from_str(&o.filled_qty).unwrap_or(Decimal::ZERO);
3024    let kind = parse_order_kind(
3025        &o.order_type,
3026        o.stop_price.as_deref(),
3027        o.trail_percent.as_deref(),
3028        o.trail_price.as_deref(),
3029    )?;
3030    let time_in_force = parse_time_in_force(&o.time_in_force);
3031    let time_exchange = order_state_time(o);
3032
3033    Some(Order {
3034        key: OrderKey::new(
3035            ExchangeId::AlpacaBroker,
3036            instrument,
3037            // Alpaca doesn't carry strategy IDs in any response field.
3038            StrategyId::unknown(),
3039            cid,
3040        ),
3041        side,
3042        price,
3043        quantity,
3044        kind,
3045        time_in_force,
3046        state: Open::new(order_id, time_exchange, filled_qty),
3047    })
3048}
3049
3050/// Convert an Alpaca FILL activity into a rustrade Trade.
3051fn convert_activity_to_trade(
3052    a: &AlpacaActivity,
3053) -> Option<Trade<AssetNameExchange, InstrumentNameExchange>> {
3054    let trade_id = TradeId::new(&a.id);
3055    let order_id = OrderId(SmolStr::new(&a.order_id));
3056    let instrument = InstrumentNameExchange::new(&a.symbol);
3057    let side = parse_side(&a.side)?;
3058    let price = Decimal::from_str(&a.price).ok()?;
3059    let quantity = Decimal::from_str(&a.qty).ok()?;
3060    // `None` when Alpaca omitted the field or sent something unparseable, which is the honest
3061    // report: the venue did not tell us where the order stands, rather than "nothing is filled".
3062    let order_filled_quantity = a.cum_qty.as_deref().and_then(|s| Decimal::from_str(s).ok());
3063    let time_exchange = parse_timestamp(&a.transaction_time).unwrap_or_else(|| {
3064        warn!(id = %a.id, "Alpaca activity: unparseable transaction_time, using now");
3065        Utc::now()
3066    });
3067
3068    // Alpaca equities and options are commission-free. Crypto trades incur
3069    // maker/taker fees (currently 0.15–0.25%) charged in the credited asset,
3070    // but fee info is not available in trade responses — use Activities API
3071    // for end-of-day fee reconciliation.
3072    Some(Trade::new(
3073        trade_id,
3074        order_id,
3075        instrument,
3076        StrategyId::unknown(),
3077        time_exchange,
3078        side,
3079        price,
3080        quantity,
3081        order_filled_quantity,
3082        AssetFees::new(
3083            AssetNameExchange::from("USD"),
3084            Decimal::ZERO,
3085            Some(Decimal::ZERO),
3086        ),
3087    ))
3088}
3089
3090/// Build an `OrderSnapshot` event from the order payload embedded in a `trade_updates` frame.
3091///
3092/// Shared by the acknowledgement arm and the fill arms of [`convert_trade_update`], so that a
3093/// fill reports the order's new cumulative `filled_quantity` the same way an acknowledgement
3094/// reports its initial one.
3095///
3096/// Returns `None` for a notional order (placed by dollar value, `qty` is null). Emitting a
3097/// snapshot with `quantity == 0` would read as an order with nothing left to fill and retire it,
3098/// so such orders are left untracked -- consistent with `convert_open_order` on the REST path.
3099fn ws_order_snapshot(
3100    order: &AlpacaOrderWs<'_>,
3101    instrument: InstrumentNameExchange,
3102    cid: ClientOrderId,
3103    order_id: OrderId,
3104    time_exchange: DateTime<Utc>,
3105) -> Option<UnindexedAccountEvent> {
3106    let side = parse_side(&order.side)?;
3107    let quantity = Decimal::from_str(order.qty.unwrap_or("0")).unwrap_or(Decimal::ZERO);
3108    if quantity.is_zero() {
3109        trace!(order_id = %order.id, "Alpaca WS: skipping notional order snapshot (qty=None)");
3110        return None;
3111    }
3112    let price = order.limit_price.and_then(|s| Decimal::from_str(s).ok());
3113    let filled_qty = Decimal::from_str(order.filled_qty.unwrap_or("0")).unwrap_or(Decimal::ZERO);
3114    let kind = parse_order_kind(
3115        &order.order_type,
3116        order.stop_price,
3117        order.trail_percent,
3118        order.trail_price,
3119    )?;
3120    let time_in_force = parse_time_in_force(&order.time_in_force);
3121
3122    let order_snapshot = crate::order::Order {
3123        key: OrderKey::new(
3124            ExchangeId::AlpacaBroker,
3125            instrument,
3126            StrategyId::unknown(),
3127            cid,
3128        ),
3129        side,
3130        price,
3131        quantity,
3132        kind,
3133        time_in_force,
3134        state: OrderState::active(Open::new(order_id, time_exchange, filled_qty)),
3135    };
3136    Some(UnindexedAccountEvent::new(
3137        ExchangeId::AlpacaBroker,
3138        AccountEventKind::OrderSnapshot(rustrade_integration::collection::snapshot::Snapshot(
3139            order_snapshot,
3140        )),
3141    ))
3142}
3143
3144/// Whether a fill frame's order payload describes an order that is still live at the exchange,
3145/// and may therefore be written into engine state as an `Open` snapshot.
3146///
3147/// A fill frame carries the order's status alongside the execution. Only `partially_filled` and
3148/// `filled` say the order reached this execution while working; every other status means some
3149/// other frame owns the order's current state. Writing an `Open` snapshot from one of those would
3150/// resurrect an order the engine has already retired, leaving a resting order that does not exist
3151/// at the exchange -- a fill that arrives after its order's terminal frame is exactly the ordering
3152/// this guards against.
3153fn ws_fill_order_is_live(status: &str) -> bool {
3154    matches!(status, "partially_filled" | "filled")
3155}
3156
3157/// Convert a WebSocket trade_update event into rustrade AccountEvents.
3158///
3159/// Returns up to two events, in the order they must be applied. A fill frame genuinely carries
3160/// two facts -- the execution print and the order's new cumulative filled quantity -- so it maps
3161/// to both a `Trade` and an `OrderSnapshot`.
3162///
3163/// The `Trade` is always first. A fully-filled snapshot retires its order, and routing a fill
3164/// against an order that has already been retired is a strictly harder problem than routing it
3165/// against a live one; emitting the execution first keeps the easy ordering.
3166fn convert_trade_update(update: AlpacaTradeUpdate<'_>) -> [Option<UnindexedAccountEvent>; 2] {
3167    // Early exit for unrecognised event types before incurring allocations for
3168    // instrument/order_id/cid — those are wasted for unknown events.
3169    let event_str = update.event.as_str();
3170    if !matches!(
3171        event_str,
3172        "fill"
3173            | "partial_fill"
3174            | "new"
3175            | "accepted"
3176            | "pending_new"
3177            | "canceled"
3178            | "expired"
3179            | "replaced"
3180            | "done_for_day"
3181            | "rejected"
3182    ) {
3183        trace!(event = %event_str, "Alpaca WS: ignoring trade_updates event type");
3184        return [None, None];
3185    }
3186
3187    let order = &update.order;
3188    let instrument = InstrumentNameExchange::new(&*order.symbol);
3189    let order_id = OrderId(order.id.clone());
3190    let cid = order
3191        .client_order_id
3192        .as_deref()
3193        .map(ClientOrderId::new)
3194        .unwrap_or_else(|| ClientOrderId::new(order.id.as_str()));
3195
3196    match event_str {
3197        "fill" | "partial_fill" => {
3198            // Use event-level price/qty for the per-execution trade.
3199            let (Some(price), Some(quantity), Some(side)) = (
3200                update.price.and_then(|s| Decimal::from_str(s).ok()),
3201                update.qty.and_then(|s| Decimal::from_str(s).ok()),
3202                parse_side(&order.side),
3203            ) else {
3204                return [None, None];
3205            };
3206            let time_exchange = update
3207                .timestamp
3208                .and_then(parse_timestamp)
3209                .unwrap_or_else(Utc::now);
3210
3211            // Trade ID: synthesise from order_id + cumulative filled qty since
3212            // Alpaca WS trade_updates don't include an activity ID.
3213            // Normalise via Decimal to strip trailing zeros so the key matches the
3214            // REST recovery path (e.g. "1.00" and "1" both normalise to "1").
3215            //
3216            // If filled_qty is unparseable (API regression), cum_qty falls back to
3217            // zero. Two consecutive bad fills on the same order would produce the same
3218            // dedup key ("order_id:0"), causing the second fill to be silently dropped.
3219            // Warn loudly so API regressions are surfaced immediately.
3220            let cum_qty = Decimal::from_str(order.filled_qty.unwrap_or("0"))
3221                .inspect_err(|e| {
3222                    warn!(
3223                        order_id = %order.id,
3224                        filled_qty = ?order.filled_qty,
3225                        %e,
3226                        "Alpaca WS: failed to parse filled_qty — dedup key will use 0, \
3227                         a second malformed fill on the same order would be deduplicated away"
3228                    );
3229                })
3230                .unwrap_or(Decimal::ZERO);
3231            let trade_id = TradeId(format_smolstr!("{}:{}", order.id, cum_qty.normalize()));
3232
3233            // Alpaca equities and options are commission-free. Crypto trades incur
3234            // maker/taker fees (currently 0.15–0.25%) in the credited asset, but
3235            // fee info is not available in WebSocket updates.
3236            let trade = Trade::new(
3237                trade_id,
3238                order_id.clone(),
3239                instrument.clone(),
3240                StrategyId::unknown(),
3241                time_exchange,
3242                side,
3243                price,
3244                quantity,
3245                // The venue's own cumulative for this order, as of this execution.
3246                Some(cum_qty),
3247                AssetFees::new(
3248                    AssetNameExchange::from("USD"),
3249                    Decimal::ZERO,
3250                    Some(Decimal::ZERO),
3251                ),
3252            );
3253            let trade_event = UnindexedAccountEvent::new(
3254                ExchangeId::AlpacaBroker,
3255                AccountEventKind::Trade(trade),
3256            );
3257
3258            // The execution alone does not move the order: `filled_quantity` is only ever carried
3259            // into engine state by an order snapshot, so without this second event a partially
3260            // filled order reads as having nothing filled until REST reconciliation refreshes it.
3261            let snapshot_event = if ws_fill_order_is_live(&order.status) {
3262                ws_order_snapshot(order, instrument, cid, order_id, time_exchange)
3263            } else {
3264                trace!(
3265                    order_id = %order.id,
3266                    status = %order.status,
3267                    "Alpaca WS: fill for an order the exchange no longer reports as working — \
3268                     emitting the execution without an order snapshot"
3269                );
3270                None
3271            };
3272
3273            [Some(trade_event), snapshot_event]
3274        }
3275
3276        "new" | "accepted" | "pending_new" => {
3277            // Order acknowledged by Alpaca — emit an OrderSnapshot.
3278            let time_exchange = update
3279                .timestamp
3280                .and_then(parse_timestamp)
3281                .unwrap_or_else(Utc::now);
3282
3283            [
3284                ws_order_snapshot(order, instrument, cid, order_id, time_exchange),
3285                None,
3286            ]
3287        }
3288
3289        "canceled" | "expired" | "replaced" | "done_for_day" => {
3290            // Order no longer active.
3291            //
3292            // NOTE on "replaced": Alpaca's replace operation cancels the original order
3293            // and creates a NEW order with a different order ID. This arm correctly marks
3294            // the original as cancelled, but callers must call `fetch_open_orders` to
3295            // discover the replacement order (which has a new ID and won't appear in OMS
3296            // state automatically).
3297            let time_exchange = update
3298                .timestamp
3299                .and_then(parse_timestamp)
3300                .unwrap_or_else(Utc::now);
3301            let filled_qty =
3302                Decimal::from_str(order.filled_qty.unwrap_or("0")).unwrap_or(Decimal::ZERO);
3303            let cancelled = Cancelled::new(order_id, time_exchange, filled_qty);
3304            let response = crate::order::request::OrderResponseCancel {
3305                key: OrderKey::new(
3306                    ExchangeId::AlpacaBroker,
3307                    instrument,
3308                    StrategyId::unknown(),
3309                    cid,
3310                ),
3311                state: Ok(cancelled),
3312            };
3313            [
3314                Some(UnindexedAccountEvent::new(
3315                    ExchangeId::AlpacaBroker,
3316                    AccountEventKind::OrderCancelled(response),
3317                )),
3318                None,
3319            ]
3320        }
3321
3322        "rejected" => {
3323            let response = crate::order::request::OrderResponseCancel {
3324                key: OrderKey::new(
3325                    ExchangeId::AlpacaBroker,
3326                    instrument,
3327                    StrategyId::unknown(),
3328                    cid,
3329                ),
3330                state: Err(UnindexedOrderError::Rejected(ApiError::OrderRejected(
3331                    format!("order rejected: status={}", order.status),
3332                ))),
3333            };
3334            [
3335                Some(UnindexedAccountEvent::new(
3336                    ExchangeId::AlpacaBroker,
3337                    AccountEventKind::OrderCancelled(response),
3338                )),
3339                None,
3340            ]
3341        }
3342
3343        // All recognised event types are handled above; the early-return guard at the
3344        // top of this function ensures we never reach here for unknown event types.
3345        _ => unreachable!("convert_trade_update: unrecognised event passed early-return guard"),
3346    }
3347}
3348
3349// ---------------------------------------------------------------------------
3350// Field parsers
3351// ---------------------------------------------------------------------------
3352
3353fn parse_side(s: &str) -> Option<Side> {
3354    match s {
3355        "buy" | "Buy" | "BUY" => Some(Side::Buy),
3356        "sell" | "Sell" | "SELL" => Some(Side::Sell),
3357        other => {
3358            trace!(%other, "Alpaca: unknown order side");
3359            None
3360        }
3361    }
3362}
3363
3364fn parse_order_kind(
3365    order_type: &str,
3366    stop_price: Option<&str>,
3367    trail_percent: Option<&str>,
3368    trail_price: Option<&str>,
3369) -> Option<OrderKind> {
3370    match order_type {
3371        "market" | "Market" => Some(OrderKind::Market),
3372        "limit" | "Limit" => Some(OrderKind::Limit),
3373        "stop" | "Stop" => {
3374            let trigger_price = stop_price.and_then(|s| Decimal::from_str(s).ok())?;
3375            Some(OrderKind::Stop { trigger_price })
3376        }
3377        "stop_limit" | "Stop_limit" => {
3378            let trigger_price = stop_price.and_then(|s| Decimal::from_str(s).ok())?;
3379            Some(OrderKind::StopLimit { trigger_price })
3380        }
3381        "trailing_stop" | "Trailing_stop" => {
3382            // Alpaca returns either trail_percent or trail_price, not both.
3383            if let Some(pct) = trail_percent.and_then(|s| Decimal::from_str(s).ok()) {
3384                Some(OrderKind::TrailingStop {
3385                    offset: pct,
3386                    offset_type: TrailingOffsetType::Percentage,
3387                })
3388            } else if let Some(price) = trail_price.and_then(|s| Decimal::from_str(s).ok()) {
3389                Some(OrderKind::TrailingStop {
3390                    offset: price,
3391                    offset_type: TrailingOffsetType::Absolute,
3392                })
3393            } else {
3394                trace!("Alpaca: trailing_stop missing trail_percent and trail_price");
3395                None
3396            }
3397        }
3398        other => {
3399            trace!(%other, "Alpaca: unsupported order type, skipping");
3400            None
3401        }
3402    }
3403}
3404
3405fn parse_time_in_force(s: &str) -> TimeInForce {
3406    match s {
3407        "gtc" | "GTC" => TimeInForce::GoodUntilCancelled { post_only: false },
3408        "day" | "DAY" => TimeInForce::GoodUntilEndOfDay,
3409        "fok" | "FOK" => TimeInForce::FillOrKill,
3410        "ioc" | "IOC" => TimeInForce::ImmediateOrCancel,
3411        other => {
3412            warn!(%other, "Alpaca: unknown time_in_force, defaulting to GoodUntilEndOfDay");
3413            TimeInForce::GoodUntilEndOfDay
3414        }
3415    }
3416}
3417
3418/// When the exchange last reported this order's state.
3419///
3420/// [`Open::time_exchange`] orders an order's states, and the engine discards a snapshot older
3421/// than the state it already tracks. `created_at` does not order them: it is the same value for
3422/// every snapshot of one order, so a snapshot stamped with it is indistinguishable from the
3423/// acknowledgement and is discarded the moment anything has advanced the tracked order past it.
3424/// `updated_at` moves with each state change, which is what that comparison needs.
3425///
3426/// Falls back to `created_at` when the venue omits `updated_at`, and to now when neither parses.
3427fn order_state_time(order: &AlpacaOrderResponse) -> DateTime<Utc> {
3428    order
3429        .updated_at
3430        .as_deref()
3431        .and_then(parse_timestamp)
3432        .or_else(|| parse_timestamp(&order.created_at))
3433        .unwrap_or_else(Utc::now)
3434}
3435
3436fn parse_timestamp(s: &str) -> Option<DateTime<Utc>> {
3437    DateTime::parse_from_rfc3339(s)
3438        .ok()
3439        .map(|dt| dt.with_timezone(&Utc))
3440}
3441
3442fn map_side(side: Side) -> &'static str {
3443    match side {
3444        Side::Buy => "buy",
3445        Side::Sell => "sell",
3446    }
3447}
3448
3449fn map_order_kind(kind: OrderKind) -> Option<&'static str> {
3450    match kind {
3451        OrderKind::Market => Some("market"),
3452        OrderKind::Limit => Some("limit"),
3453        OrderKind::Stop { .. } => Some("stop"),
3454        OrderKind::StopLimit { .. } => Some("stop_limit"),
3455        OrderKind::TrailingStop { .. } => Some("trailing_stop"),
3456        // Alpaca does not support take-profit or trailing stop-limit orders.
3457        OrderKind::TakeProfit { .. }
3458        | OrderKind::TakeProfitLimit { .. }
3459        | OrderKind::TrailingStopLimit { .. } => None,
3460    }
3461}
3462
3463/// Map rustrade's `TimeInForce` to Alpaca's string representation.
3464///
3465/// # Errors
3466///
3467/// Returns `Err` if `post_only: true` is requested. Alpaca does not support
3468/// post-only orders — silently placing a taker-eligible GTC order would be
3469/// the opposite of caller intent, risking unexpected taker fees. Callers
3470/// requiring maker-only execution must use a different venue or strategy.
3471fn map_time_in_force(tif: TimeInForce) -> Result<&'static str, &'static str> {
3472    match tif {
3473        TimeInForce::GoodUntilCancelled { post_only } => {
3474            if post_only {
3475                return Err("Alpaca does not support post_only orders");
3476            }
3477            Ok("gtc")
3478        }
3479        TimeInForce::GoodUntilEndOfDay => Ok("day"),
3480        TimeInForce::FillOrKill => Ok("fok"),
3481        TimeInForce::ImmediateOrCancel => Ok("ioc"),
3482        TimeInForce::AtOpen => Ok("opg"),
3483        TimeInForce::AtClose => Ok("cls"),
3484        // Alpaca's `gtd` requires an `expired_at` timestamp on the order request,
3485        // which this client does not currently surface. Reject to avoid silently
3486        // dropping the expiry semantics.
3487        TimeInForce::GoodTillDate { .. } => {
3488            Err("Alpaca GoodTillDate is not yet wired through this client")
3489        }
3490    }
3491}
3492
3493/// Returns `true` if the symbol is an equity or options symbol (i.e., NOT crypto).
3494///
3495/// Crypto symbols on Alpaca always contain a forward slash (e.g., `"BTC/USD"`).
3496/// Equities and OCC option symbols never contain a slash. We use this to decide
3497/// whether to include `position_intent` in the order request: the field is valid
3498/// for equities and required for options, but causes a 422 on crypto orders.
3499///
3500/// NOTE: relies on Alpaca's documented symbol format (as of 2025 API). If Alpaca
3501/// introduces a new asset class whose symbols contain `/`, `position_intent` would
3502/// be silently omitted for those orders, causing 422 errors.
3503fn is_options_or_equity_symbol(symbol: &str) -> bool {
3504    !symbol.contains('/')
3505}
3506
3507/// Derives Alpaca's `position_intent` from the generic `reduce_only` flag and `side`.
3508///
3509/// | reduce_only | side | intent       | use case                          |
3510/// |-------------|------|--------------|-----------------------------------|
3511/// | false       | Buy  | BuyToOpen    | open long / add to long position  |
3512/// | false       | Sell | SellToOpen   | open short / write option         |
3513/// | true        | Buy  | BuyToClose   | close short position              |
3514/// | true        | Sell | SellToClose  | close long position               |
3515fn map_position_intent(side: Side, reduce_only: bool) -> AlpacaPositionIntent {
3516    match (reduce_only, side) {
3517        (false, Side::Buy) => AlpacaPositionIntent::BuyToOpen,
3518        (false, Side::Sell) => AlpacaPositionIntent::SellToOpen,
3519        (true, Side::Buy) => AlpacaPositionIntent::BuyToClose,
3520        (true, Side::Sell) => AlpacaPositionIntent::SellToClose,
3521    }
3522}
3523
3524/// Classifies an HTTP error status + body into a typed [`ApiError`].
3525///
3526/// Used by both `rest_with_retry` (for general REST calls) and `rest_delete_with_retry`
3527/// (for cancel operations) to ensure consistent error classification across all REST paths.
3528fn parse_api_error(status: reqwest::StatusCode, message: &str) -> crate::error::UnindexedApiError {
3529    // Fast path: 429 doesn't need message parsing.
3530    if status == reqwest::StatusCode::TOO_MANY_REQUESTS {
3531        return ApiError::RateLimit;
3532    }
3533
3534    // Compute lowercase once for all match guards that inspect the message body.
3535    let lower = message.to_ascii_lowercase();
3536    match status.as_u16() {
3537        // Match "already" before "insufficient": if Alpaca ever sends a 422 body
3538        // containing both substrings, this arm wins and maps to OrderAlreadyCancelled,
3539        // which is more specific than BalanceInsufficient.
3540        422 if lower.contains("already") => ApiError::OrderAlreadyCancelled,
3541        // Alpaca returns 422 for business-rule rejections including insufficient
3542        // funds. 403 is *Forbidden* — auth/permission failure — and must NOT be
3543        // mapped to BalanceInsufficient even if the body happens to contain the
3544        // substring "insufficient".
3545        422 if lower.contains("insufficient") => {
3546            ApiError::BalanceInsufficient(AssetNameExchange::new("usd"), message.to_owned())
3547        }
3548        401 => ApiError::Unauthenticated(format!("unauthorized: {message}")),
3549        403 => ApiError::Unauthenticated(format!("forbidden: {message}")),
3550        404 => ApiError::OrderRejected(format!("order not found: {message}")),
3551        _ => ApiError::OrderRejected(message.to_owned()),
3552    }
3553}
3554
3555/// Wraps [`parse_api_error`] for order-specific error handling (e.g., cancel_order).
3556fn parse_order_error(status: reqwest::StatusCode, message: &str) -> UnindexedOrderError {
3557    UnindexedOrderError::Rejected(parse_api_error(status, message))
3558}
3559
3560fn connectivity_err(msg: impl Into<String>) -> UnindexedClientError {
3561    UnindexedClientError::Connectivity(ConnectivityError::Socket(msg.into()))
3562}
3563
3564// ---------------------------------------------------------------------------
3565// BracketOrderClient implementation
3566// ---------------------------------------------------------------------------
3567
3568impl BracketOrderClient for AlpacaClient {
3569    async fn open_bracket_order(
3570        &self,
3571        request: UnifiedBracketOrderRequest<ExchangeId, &InstrumentNameExchange>,
3572    ) -> UnifiedBracketOrderResult {
3573        let alpaca_request = AlpacaBracketOrderRequest {
3574            instrument: request.key.instrument.clone(),
3575            strategy: request.key.strategy.clone(),
3576            cid: request.key.cid.clone(),
3577            side: request.state.side,
3578            quantity: request.state.quantity,
3579            entry_price: request.state.entry_price,
3580            take_profit_price: request.state.take_profit_price,
3581            stop_loss_price: request.state.stop_loss_price,
3582            stop_loss_limit_price: request.state.stop_loss_limit_price,
3583            time_in_force: request.state.time_in_force,
3584        };
3585
3586        let result = self.open_bracket_order(alpaca_request).await;
3587
3588        UnifiedBracketOrderResult::parent_only(result.parent)
3589    }
3590}
3591
3592// ---------------------------------------------------------------------------
3593// Tests
3594// ---------------------------------------------------------------------------
3595
3596#[cfg(test)]
3597#[allow(clippy::unwrap_used, clippy::expect_used)] // Test code: panics on bad input are acceptable
3598mod tests {
3599    use super::*;
3600
3601    #[test]
3602    fn test_alpaca_config_new_uses_paper_trading_by_default() {
3603        let cfg = AlpacaConfig::new("my_key".into(), "my_secret".into());
3604        assert!(cfg.paper);
3605    }
3606
3607    #[test]
3608    fn test_alpaca_config_debug_redacts_credentials() {
3609        let cfg = AlpacaConfig::new("my_key".into(), "my_secret".into());
3610        let debug = format!("{cfg:?}");
3611        assert!(!debug.contains("my_key"), "api_key should be redacted");
3612        assert!(
3613            !debug.contains("my_secret"),
3614            "secret_key should be redacted"
3615        );
3616        assert!(debug.contains("paper: true"));
3617    }
3618
3619    #[test]
3620    fn test_alpaca_config_deserialize_omitted_paper_defaults_to_paper() {
3621        let cfg: AlpacaConfig = serde_json::from_str(
3622            r#"{
3623        "api_key": "my_key",
3624        "secret_key": "my_secret"
3625    }"#,
3626        )
3627        .unwrap();
3628
3629        assert!(cfg.paper);
3630        assert_eq!(cfg.rest_base_url(), "https://paper-api.alpaca.markets");
3631    }
3632
3633    #[test]
3634    fn test_alpaca_config_deserialize_paper_true() {
3635        let cfg: AlpacaConfig = serde_json::from_str(
3636            r#"{
3637        "api_key": "my_key",
3638        "secret_key": "my_secret",
3639        "paper": true
3640    }"#,
3641        )
3642        .unwrap();
3643
3644        assert!(cfg.paper);
3645        assert_eq!(cfg.rest_base_url(), "https://paper-api.alpaca.markets");
3646    }
3647
3648    #[test]
3649    fn test_alpaca_config_deserialize_paper_false() {
3650        let cfg: AlpacaConfig = serde_json::from_str(
3651            r#"{
3652        "api_key": "my_key",
3653        "secret_key": "my_secret",
3654        "paper": false
3655    }"#,
3656        )
3657        .unwrap();
3658
3659        assert!(!cfg.paper);
3660        assert_eq!(cfg.rest_base_url(), "https://api.alpaca.markets");
3661    }
3662
3663    #[test]
3664    fn test_alpaca_config_urls() {
3665        let paper = AlpacaConfig::paper("k".into(), "s".into());
3666        assert!(paper.rest_base_url().contains("paper-api"));
3667        assert!(paper.ws_url().contains("paper-api"));
3668
3669        let live = AlpacaConfig::production("k".into(), "s".into());
3670        assert!(!live.rest_base_url().contains("paper-api"));
3671        assert!(!live.ws_url().contains("paper-api"));
3672    }
3673
3674    #[test]
3675    #[serial_test::serial]
3676    fn test_alpaca_config_from_env_defaults_to_paper_trading() {
3677        temp_env::with_vars(
3678            [
3679                ("ALPACA_API_KEY", Some("my_key")),
3680                ("ALPACA_SECRET_KEY", Some("my_secret")),
3681                ("ALPACA_PAPER", None),
3682            ],
3683            || {
3684                let cfg = AlpacaConfig::from_env().unwrap();
3685                assert!(cfg.paper);
3686                assert_eq!(cfg.rest_base_url(), "https://paper-api.alpaca.markets");
3687            },
3688        );
3689    }
3690
3691    #[test]
3692    #[serial_test::serial]
3693    fn test_alpaca_config_from_env_accepts_explicit_production() {
3694        temp_env::with_vars(
3695            [
3696                ("ALPACA_API_KEY", Some("my_key")),
3697                ("ALPACA_SECRET_KEY", Some("my_secret")),
3698                ("ALPACA_PAPER", Some("false")),
3699            ],
3700            || {
3701                let cfg = AlpacaConfig::from_env().unwrap();
3702                assert!(!cfg.paper);
3703                assert_eq!(cfg.rest_base_url(), "https://api.alpaca.markets");
3704            },
3705        );
3706    }
3707
3708    #[test]
3709    #[serial_test::serial]
3710    fn test_alpaca_config_from_env_rejects_invalid_paper() {
3711        temp_env::with_vars(
3712            [
3713                ("ALPACA_API_KEY", Some("my_key")),
3714                ("ALPACA_SECRET_KEY", Some("my_secret")),
3715                ("ALPACA_PAPER", Some("maybe")),
3716            ],
3717            || {
3718                let err = AlpacaConfig::from_env().unwrap_err();
3719                assert!(matches!(err, AlpacaConfigError::InvalidPaper(value) if value == "maybe"));
3720            },
3721        );
3722    }
3723
3724    #[test]
3725    #[serial_test::serial]
3726    fn test_alpaca_config_from_env_requires_credentials() {
3727        temp_env::with_vars(
3728            [
3729                ("ALPACA_API_KEY", None),
3730                ("ALPACA_SECRET_KEY", Some("my_secret")),
3731                ("ALPACA_PAPER", None),
3732            ],
3733            || {
3734                let err = AlpacaConfig::from_env().unwrap_err();
3735                assert!(matches!(err, AlpacaConfigError::MissingApiKey));
3736            },
3737        );
3738    }
3739
3740    #[test]
3741    fn test_parse_side() {
3742        assert_eq!(parse_side("buy"), Some(Side::Buy));
3743        assert_eq!(parse_side("sell"), Some(Side::Sell));
3744        assert_eq!(parse_side("Buy"), Some(Side::Buy));
3745        assert_eq!(parse_side("BUY"), Some(Side::Buy));
3746        assert_eq!(parse_side("unknown"), None);
3747    }
3748
3749    #[test]
3750    fn test_parse_order_kind() {
3751        // Market and Limit don't need extra params
3752        assert_eq!(
3753            parse_order_kind("market", None, None, None),
3754            Some(OrderKind::Market)
3755        );
3756        assert_eq!(
3757            parse_order_kind("limit", None, None, None),
3758            Some(OrderKind::Limit)
3759        );
3760
3761        // Stop requires stop_price
3762        assert_eq!(
3763            parse_order_kind("stop", Some("150.00"), None, None),
3764            Some(OrderKind::Stop {
3765                trigger_price: Decimal::from_str("150.00").unwrap()
3766            })
3767        );
3768        assert_eq!(parse_order_kind("stop", None, None, None), None);
3769
3770        // StopLimit requires stop_price
3771        assert_eq!(
3772            parse_order_kind("stop_limit", Some("145.00"), None, None),
3773            Some(OrderKind::StopLimit {
3774                trigger_price: Decimal::from_str("145.00").unwrap()
3775            })
3776        );
3777
3778        // TrailingStop with percentage
3779        assert_eq!(
3780            parse_order_kind("trailing_stop", None, Some("5.0"), None),
3781            Some(OrderKind::TrailingStop {
3782                offset: Decimal::from_str("5.0").unwrap(),
3783                offset_type: TrailingOffsetType::Percentage,
3784            })
3785        );
3786
3787        // TrailingStop with absolute price
3788        assert_eq!(
3789            parse_order_kind("trailing_stop", None, None, Some("2.50")),
3790            Some(OrderKind::TrailingStop {
3791                offset: Decimal::from_str("2.50").unwrap(),
3792                offset_type: TrailingOffsetType::Absolute,
3793            })
3794        );
3795
3796        // TrailingStop without either offset returns None
3797        assert_eq!(parse_order_kind("trailing_stop", None, None, None), None);
3798
3799        // Unknown type returns None
3800        assert_eq!(parse_order_kind("unknown", None, None, None), None);
3801    }
3802
3803    #[test]
3804    fn test_map_order_kind() {
3805        assert_eq!(map_order_kind(OrderKind::Market), Some("market"));
3806        assert_eq!(map_order_kind(OrderKind::Limit), Some("limit"));
3807        assert_eq!(
3808            map_order_kind(OrderKind::Stop {
3809                trigger_price: Decimal::from_str("150.00").unwrap()
3810            }),
3811            Some("stop")
3812        );
3813        assert_eq!(
3814            map_order_kind(OrderKind::StopLimit {
3815                trigger_price: Decimal::from_str("145.00").unwrap()
3816            }),
3817            Some("stop_limit")
3818        );
3819        assert_eq!(
3820            map_order_kind(OrderKind::TrailingStop {
3821                offset: Decimal::from_str("5.0").unwrap(),
3822                offset_type: TrailingOffsetType::Percentage,
3823            }),
3824            Some("trailing_stop")
3825        );
3826        assert_eq!(
3827            map_order_kind(OrderKind::TrailingStop {
3828                offset: Decimal::from_str("2.50").unwrap(),
3829                offset_type: TrailingOffsetType::Absolute,
3830            }),
3831            Some("trailing_stop")
3832        );
3833        // TrailingStopLimit is not supported by Alpaca
3834        assert_eq!(
3835            map_order_kind(OrderKind::TrailingStopLimit {
3836                offset: Decimal::from_str("5.0").unwrap(),
3837                offset_type: TrailingOffsetType::Percentage,
3838                limit_offset: Decimal::from_str("1.0").unwrap(),
3839            }),
3840            None
3841        );
3842        // TakeProfit/TakeProfitLimit are not supported by Alpaca
3843        assert_eq!(
3844            map_order_kind(OrderKind::TakeProfit {
3845                trigger_price: Decimal::from_str("160.00").unwrap()
3846            }),
3847            None
3848        );
3849        assert_eq!(
3850            map_order_kind(OrderKind::TakeProfitLimit {
3851                trigger_price: Decimal::from_str("160.00").unwrap()
3852            }),
3853            None
3854        );
3855    }
3856
3857    #[test]
3858    fn test_map_time_in_force_roundtrip() {
3859        assert_eq!(
3860            map_time_in_force(TimeInForce::GoodUntilCancelled { post_only: false }),
3861            Ok("gtc")
3862        );
3863        assert_eq!(map_time_in_force(TimeInForce::GoodUntilEndOfDay), Ok("day"));
3864        assert_eq!(map_time_in_force(TimeInForce::FillOrKill), Ok("fok"));
3865        assert_eq!(map_time_in_force(TimeInForce::ImmediateOrCancel), Ok("ioc"));
3866    }
3867
3868    #[test]
3869    fn test_map_time_in_force_rejects_post_only() {
3870        let result = map_time_in_force(TimeInForce::GoodUntilCancelled { post_only: true });
3871        assert!(result.is_err(), "post_only must be rejected");
3872        assert!(result.unwrap_err().contains("post_only"));
3873    }
3874
3875    // =========================================================================
3876    // Bracket Order Serialization Tests
3877    // =========================================================================
3878
3879    #[test]
3880    fn test_bracket_order_serializes_with_stop_loss_stop_order() {
3881        // Bracket order with stop-loss as a simple stop order (no limit_price)
3882        let body = AlpacaOrderRequest {
3883            symbol: "AAPL",
3884            qty: "10".to_string(),
3885            side: "buy",
3886            order_type: "limit",
3887            time_in_force: "gtc",
3888            limit_price: Some("150.00".to_string()),
3889            stop_price: None,
3890            trail_percent: None,
3891            trail_price: None,
3892            client_order_id: Some("bracket-001"),
3893            position_intent: Some(AlpacaPositionIntent::BuyToOpen),
3894            order_class: Some("bracket"),
3895            take_profit: Some(TakeProfitParams {
3896                limit_price: "160.00".to_string(),
3897            }),
3898            stop_loss: Some(StopLossParams {
3899                stop_price: "145.00".to_string(),
3900                limit_price: None,
3901            }),
3902        };
3903
3904        let json = serde_json::to_value(&body).unwrap();
3905
3906        assert_eq!(json["symbol"], "AAPL");
3907        assert_eq!(json["qty"], "10");
3908        assert_eq!(json["side"], "buy");
3909        assert_eq!(json["type"], "limit");
3910        assert_eq!(json["time_in_force"], "gtc");
3911        assert_eq!(json["limit_price"], "150.00");
3912        assert_eq!(json["order_class"], "bracket");
3913
3914        // Take profit should have limit_price
3915        assert_eq!(json["take_profit"]["limit_price"], "160.00");
3916
3917        // Stop loss should have stop_price but NO limit_price
3918        assert_eq!(json["stop_loss"]["stop_price"], "145.00");
3919        assert!(
3920            json["stop_loss"].get("limit_price").is_none(),
3921            "stop_loss.limit_price should be omitted when None"
3922        );
3923    }
3924
3925    #[test]
3926    fn test_bracket_order_serializes_with_stop_loss_stop_limit_order() {
3927        // Bracket order with stop-loss as a stop-limit order (has limit_price)
3928        let body = AlpacaOrderRequest {
3929            symbol: "SPY",
3930            qty: "5".to_string(),
3931            side: "sell",
3932            order_type: "limit",
3933            time_in_force: "day",
3934            limit_price: Some("450.00".to_string()),
3935            stop_price: None,
3936            trail_percent: None,
3937            trail_price: None,
3938            client_order_id: Some("bracket-002"),
3939            position_intent: Some(AlpacaPositionIntent::SellToClose),
3940            order_class: Some("bracket"),
3941            take_profit: Some(TakeProfitParams {
3942                limit_price: "440.00".to_string(),
3943            }),
3944            stop_loss: Some(StopLossParams {
3945                stop_price: "455.00".to_string(),
3946                limit_price: Some("456.00".to_string()),
3947            }),
3948        };
3949
3950        let json = serde_json::to_value(&body).unwrap();
3951
3952        assert_eq!(json["symbol"], "SPY");
3953        assert_eq!(json["side"], "sell");
3954        assert_eq!(json["time_in_force"], "day");
3955        assert_eq!(json["order_class"], "bracket");
3956
3957        // Take profit
3958        assert_eq!(json["take_profit"]["limit_price"], "440.00");
3959
3960        // Stop loss with limit_price (stop-limit order)
3961        assert_eq!(json["stop_loss"]["stop_price"], "455.00");
3962        assert_eq!(
3963            json["stop_loss"]["limit_price"], "456.00",
3964            "stop_loss.limit_price should be present for stop-limit orders"
3965        );
3966    }
3967
3968    // =========================================================================
3969    // Bracket Order Validation Tests
3970    // =========================================================================
3971
3972    #[tokio::test]
3973    async fn test_open_bracket_order_rejects_invalid_tif() {
3974        use rust_decimal_macros::dec;
3975        use rustrade_instrument::instrument::name::InstrumentNameExchange;
3976
3977        // Create a minimal client with dummy credentials (no network call will be made)
3978        let config = AlpacaConfig::new("dummy_key".into(), "dummy_secret".into());
3979        let client = AlpacaClient::new(config);
3980
3981        let request = AlpacaBracketOrderRequest::new(
3982            InstrumentNameExchange::new("SPY"),
3983            crate::order::id::StrategyId::new("test"),
3984            crate::order::id::ClientOrderId::new("test-tif"),
3985            Side::Buy,
3986            dec!(1),
3987            dec!(100.00),
3988            dec!(120.00),
3989            dec!(90.00),
3990            TimeInForce::ImmediateOrCancel, // Invalid for brackets
3991        );
3992
3993        let result = client.open_bracket_order(request).await;
3994
3995        assert!(
3996            result.parent.state.is_failed(),
3997            "Bracket order with IOC TIF should be rejected locally"
3998        );
3999    }
4000
4001    #[tokio::test]
4002    async fn test_open_bracket_order_rejects_invalid_price_ordering() {
4003        use rust_decimal_macros::dec;
4004        use rustrade_instrument::instrument::name::InstrumentNameExchange;
4005
4006        let config = AlpacaConfig::new("dummy_key".into(), "dummy_secret".into());
4007        let client = AlpacaClient::new(config);
4008
4009        // Buy bracket with SL > entry (invalid)
4010        let request = AlpacaBracketOrderRequest::new(
4011            InstrumentNameExchange::new("SPY"),
4012            crate::order::id::StrategyId::new("test"),
4013            crate::order::id::ClientOrderId::new("test-price"),
4014            Side::Buy,
4015            dec!(1),
4016            dec!(100.00),
4017            dec!(120.00),
4018            dec!(105.00), // Invalid: SL > entry for buy
4019            TimeInForce::GoodUntilCancelled { post_only: false },
4020        );
4021
4022        let result = client.open_bracket_order(request).await;
4023
4024        assert!(
4025            result.parent.state.is_failed(),
4026            "Bracket order with invalid price ordering should be rejected locally"
4027        );
4028    }
4029
4030    #[tokio::test]
4031    async fn test_open_bracket_order_rejects_invalid_sl_limit_price() {
4032        use rust_decimal_macros::dec;
4033        use rustrade_instrument::instrument::name::InstrumentNameExchange;
4034
4035        let config = AlpacaConfig::new("dummy_key".into(), "dummy_secret".into());
4036        let client = AlpacaClient::new(config);
4037
4038        // Buy bracket with SL limit > SL trigger (invalid for sell stop-limit)
4039        let request = AlpacaBracketOrderRequest::new(
4040            InstrumentNameExchange::new("SPY"),
4041            crate::order::id::StrategyId::new("test"),
4042            crate::order::id::ClientOrderId::new("test-sl-limit"),
4043            Side::Buy,
4044            dec!(1),
4045            dec!(100.00),
4046            dec!(120.00),
4047            dec!(90.00),
4048            TimeInForce::GoodUntilCancelled { post_only: false },
4049        )
4050        .with_stop_loss_limit_price(dec!(95.00)); // Invalid: limit > trigger for sell SL
4051
4052        let result = client.open_bracket_order(request).await;
4053
4054        assert!(
4055            result.parent.state.is_failed(),
4056            "Bracket order with invalid SL limit price should be rejected locally"
4057        );
4058    }
4059
4060    #[test]
4061    fn test_non_bracket_order_omits_bracket_fields() {
4062        // Regular limit order should not have bracket fields
4063        let body = AlpacaOrderRequest {
4064            symbol: "AAPL",
4065            qty: "1".to_string(),
4066            side: "buy",
4067            order_type: "limit",
4068            time_in_force: "gtc",
4069            limit_price: Some("150.00".to_string()),
4070            stop_price: None,
4071            trail_percent: None,
4072            trail_price: None,
4073            client_order_id: Some("regular-001"),
4074            position_intent: Some(AlpacaPositionIntent::BuyToOpen),
4075            order_class: None,
4076            take_profit: None,
4077            stop_loss: None,
4078        };
4079
4080        let json = serde_json::to_value(&body).unwrap();
4081
4082        assert_eq!(json["symbol"], "AAPL");
4083        assert!(
4084            json.get("order_class").is_none(),
4085            "order_class should be omitted for non-bracket orders"
4086        );
4087        assert!(
4088            json.get("take_profit").is_none(),
4089            "take_profit should be omitted for non-bracket orders"
4090        );
4091        assert!(
4092            json.get("stop_loss").is_none(),
4093            "stop_loss should be omitted for non-bracket orders"
4094        );
4095    }
4096
4097    #[test]
4098    fn test_parse_timestamp_valid() {
4099        let ts = parse_timestamp("2025-04-18T14:30:00Z");
4100        assert!(ts.is_some());
4101        let ts2 = parse_timestamp("2025-04-18T14:30:00.123456Z");
4102        assert!(ts2.is_some());
4103        assert_eq!(parse_timestamp("not-a-timestamp"), None);
4104    }
4105
4106    #[test]
4107    fn test_check_auth_response_authorized() {
4108        let msg =
4109            r#"{"stream":"authorization","data":{"status":"authorized","action":"authenticate"}}"#;
4110        assert!(matches!(check_auth_response(msg), Some(Ok(()))));
4111    }
4112
4113    #[test]
4114    fn test_check_auth_response_unauthorized() {
4115        let msg = r#"{"stream":"authorization","data":{"status":"unauthorized"}}"#;
4116        assert!(matches!(
4117            check_auth_response(msg),
4118            Some(Err(HandshakeError::Auth(_)))
4119        ));
4120    }
4121
4122    #[test]
4123    fn test_check_auth_response_non_auth_message() {
4124        let msg = r#"{"stream":"trade_updates","data":{}}"#;
4125        assert!(check_auth_response(msg).is_none());
4126    }
4127
4128    #[test]
4129    fn test_check_listen_ack() {
4130        let ack = r#"{"stream":"listening","data":{"streams":["trade_updates"]}}"#;
4131        assert!(check_listen_ack(ack));
4132
4133        let other = r#"{"stream":"authorization","data":{}}"#;
4134        assert!(!check_listen_ack(other));
4135    }
4136
4137    #[test]
4138    fn test_dedup_cache() {
4139        let cache = new_dedup_cache();
4140        let key = SmolStr::new("order-1:1");
4141        assert!(
4142            !is_duplicate(&cache, &key),
4143            "first time should not be duplicate"
4144        );
4145        assert!(
4146            is_duplicate(&cache, &key),
4147            "second time should be duplicate"
4148        );
4149    }
4150
4151    #[tokio::test]
4152    async fn test_exponential_backoff_progression_and_exhaustion() {
4153        tokio::time::pause();
4154
4155        let mut b = ExponentialBackoff::new();
4156
4157        // First wait should succeed and increment attempt.
4158        assert!(b.wait().await, "first wait should return true");
4159        assert_eq!(b.attempt, 1);
4160
4161        // Drain remaining attempts.
4162        while b.wait().await {}
4163
4164        // Attempt counter saturates at max_attempts.
4165        assert_eq!(b.attempt, MAX_RECONNECT_ATTEMPTS);
4166
4167        // Once exhausted, wait returns false immediately without sleeping.
4168        assert!(!b.wait().await, "exhausted backoff should return false");
4169
4170        // reset() restores attempt to 0.
4171        b.reset();
4172        assert_eq!(b.attempt, 0);
4173
4174        // After reset, wait works again.
4175        assert!(b.wait().await, "wait should succeed after reset");
4176        assert_eq!(b.attempt, 1);
4177    }
4178
4179    #[test]
4180    fn test_convert_account_to_balances_empty_assets() {
4181        let account = AlpacaAccount {
4182            equity: "12000.00".into(),
4183            buying_power: "10000.00".into(),
4184            options_buying_power: Some("8000.00".into()),
4185            crypto_buying_power: None,
4186        };
4187        let balances = convert_account_to_balances(&account, &[]);
4188        assert_eq!(balances.len(), 1);
4189        assert_eq!(
4190            balances[0].balance.total,
4191            Decimal::from_str("12000.00").unwrap()
4192        );
4193        // options_buying_power is preferred for free
4194        assert_eq!(
4195            balances[0].balance.free,
4196            Decimal::from_str("8000.00").unwrap()
4197        );
4198    }
4199
4200    #[test]
4201    fn test_convert_account_to_balances_usd_filter() {
4202        let account = AlpacaAccount {
4203            equity: "12000.00".into(),
4204            buying_power: "10000.00".into(),
4205            options_buying_power: None,
4206            crypto_buying_power: None,
4207        };
4208        let usd = vec![AssetNameExchange::new("USD")];
4209        let balances = convert_account_to_balances(&account, &usd);
4210        assert_eq!(balances.len(), 1);
4211
4212        let non_usd = vec![AssetNameExchange::new("BTC")];
4213        let balances = convert_account_to_balances(&account, &non_usd);
4214        assert!(balances.is_empty());
4215    }
4216
4217    #[test]
4218    fn test_is_options_or_equity_symbol() {
4219        // Crypto symbols contain '/'
4220        assert!(!is_options_or_equity_symbol("BTC/USD"));
4221        assert!(!is_options_or_equity_symbol("ETH/USD"));
4222        assert!(!is_options_or_equity_symbol("SOL/USD"));
4223
4224        // Equity symbols — no slash
4225        assert!(is_options_or_equity_symbol("AAPL"));
4226        assert!(is_options_or_equity_symbol("SPY"));
4227        assert!(is_options_or_equity_symbol("MSFT"));
4228
4229        // OCC option symbols — no slash
4230        assert!(is_options_or_equity_symbol("SPY250418C00450000"));
4231        assert!(is_options_or_equity_symbol("AAPL250418P00145000"));
4232    }
4233
4234    #[test]
4235    fn test_parse_order_error_already_cancelled() {
4236        // Locks in match arm ordering: a 422 with "already" but NOT "insufficient"
4237        // must map to OrderAlreadyCancelled, not BalanceInsufficient.
4238        assert!(matches!(
4239            parse_order_error(
4240                reqwest::StatusCode::UNPROCESSABLE_ENTITY,
4241                "order is already cancelled"
4242            ),
4243            UnindexedOrderError::Rejected(ApiError::OrderAlreadyCancelled)
4244        ));
4245    }
4246
4247    #[test]
4248    fn test_parse_order_error_already_wins_over_insufficient_on_422() {
4249        // If Alpaca sends a body containing both "already" and "insufficient",
4250        // the "already" arm must win (it appears first in the match).
4251        assert!(matches!(
4252            parse_order_error(
4253                reqwest::StatusCode::UNPROCESSABLE_ENTITY,
4254                "order already cancelled due to insufficient margin"
4255            ),
4256            UnindexedOrderError::Rejected(ApiError::OrderAlreadyCancelled)
4257        ));
4258    }
4259
4260    fn make_order_ws<'a>(
4261        id: &str,
4262        symbol: &str,
4263        side: &str,
4264        filled_qty: &'a str,
4265    ) -> AlpacaOrderWs<'a> {
4266        AlpacaOrderWs {
4267            id: SmolStr::new(id),
4268            client_order_id: None,
4269            symbol: SmolStr::new(symbol),
4270            qty: Some("2"),
4271            filled_qty: Some(filled_qty),
4272            side: SmolStr::new(side),
4273            order_type: SmolStr::new("limit"),
4274            time_in_force: SmolStr::new("day"),
4275            limit_price: Some("100.00"),
4276            stop_price: None,
4277            trail_percent: None,
4278            trail_price: None,
4279            status: SmolStr::new("partially_filled"),
4280        }
4281    }
4282
4283    /// The one event produced by a frame that maps to a single `AccountEvent`.
4284    ///
4285    /// Asserts the second slot is empty, so a test written for a single-event frame fails
4286    /// loudly if that frame ever starts producing two.
4287    fn sole_event(events: [Option<UnindexedAccountEvent>; 2]) -> Option<UnindexedAccountEvent> {
4288        let [first, second] = events;
4289        assert!(
4290            second.is_none(),
4291            "expected a single event, got a second: {second:?}"
4292        );
4293        first
4294    }
4295
4296    /// The `(Trade, OrderSnapshot)` pair a fill frame must produce, in that order.
4297    fn fill_events(
4298        events: [Option<UnindexedAccountEvent>; 2],
4299    ) -> (
4300        Trade<AssetNameExchange, InstrumentNameExchange>,
4301        crate::order::Order<
4302            ExchangeId,
4303            InstrumentNameExchange,
4304            OrderState<AssetNameExchange, InstrumentNameExchange>,
4305        >,
4306    ) {
4307        let [first, second] = events;
4308        let first = first.expect("a fill frame must produce an execution");
4309        let second = second.expect("a fill frame must produce an order snapshot");
4310        let AccountEventKind::Trade(trade) = first.kind else {
4311            panic!("first event must be the Trade, got {:?}", first.kind);
4312        };
4313        let AccountEventKind::OrderSnapshot(rustrade_integration::collection::snapshot::Snapshot(
4314            order,
4315        )) = second.kind
4316        else {
4317            panic!(
4318                "second event must be the OrderSnapshot, got {:?}",
4319                second.kind
4320            );
4321        };
4322        (trade, order)
4323    }
4324
4325    #[test]
4326    fn test_convert_trade_update_fill_produces_trade_with_dedup_key() {
4327        let update = AlpacaTradeUpdate {
4328            event: SmolStr::new("fill"),
4329            order: make_order_ws("ord-1", "SPY", "buy", "1"),
4330            price: Some("150.00"),
4331            qty: Some("1"),
4332            timestamp: Some("2025-04-18T14:30:00Z"),
4333        };
4334        let (trade, order) = fill_events(convert_trade_update(update));
4335        // Trade ID must be "{order_id}:{cumulative_filled_qty}" for dedup to match REST path.
4336        assert_eq!(trade.id.0.as_str(), "ord-1:1");
4337        assert_eq!(trade.price, Decimal::from_str("150.00").unwrap());
4338        assert_eq!(trade.quantity, Decimal::from_str("1").unwrap());
4339
4340        // The execution print alone leaves the order reading as untouched. The snapshot carries
4341        // the cumulative filled quantity, which is the only way it reaches engine state.
4342        let OrderState::Active(crate::order::state::ActiveOrderState::Open(open)) = order.state
4343        else {
4344            panic!("expected an Open snapshot, got {:?}", order.state);
4345        };
4346        assert_eq!(open.filled_quantity, Decimal::from_str("1").unwrap());
4347        assert_eq!(open.id.0.as_str(), "ord-1");
4348    }
4349
4350    #[test]
4351    fn test_convert_trade_update_partial_fill() {
4352        let update = AlpacaTradeUpdate {
4353            event: SmolStr::new("partial_fill"),
4354            order: make_order_ws("ord-2", "AAPL", "sell", "0.5"),
4355            price: Some("200.00"),
4356            qty: Some("0.5"),
4357            timestamp: None,
4358        };
4359        let (trade, order) = fill_events(convert_trade_update(update));
4360        assert_eq!(trade.quantity, Decimal::from_str("0.5").unwrap());
4361
4362        // make_order_ws reports qty=2, so 0.5 cumulative leaves 1.5 working: the order stays
4363        // Open, and a consumer reading quantity_remaining now sees 1.5 rather than 2.
4364        let OrderState::Active(crate::order::state::ActiveOrderState::Open(open)) = order.state
4365        else {
4366            panic!("expected an Open snapshot, got {:?}", order.state);
4367        };
4368        assert_eq!(open.filled_quantity, Decimal::from_str("0.5").unwrap());
4369        assert_eq!(
4370            open.quantity_remaining(order.quantity),
4371            Decimal::from_str("1.5").unwrap()
4372        );
4373    }
4374
4375    /// A `fill` that completes the order reports nothing left to fill, which is how the engine
4376    /// learns the order is done. Without the snapshot the order would sit in engine state as a
4377    /// resting order with `filled_quantity` 0 until REST reconciliation refreshed it.
4378    #[test]
4379    fn a_full_fill_snapshot_reports_nothing_left_to_fill() {
4380        let update = AlpacaTradeUpdate {
4381            event: SmolStr::new("fill"),
4382            // make_order_ws reports qty=2; a cumulative filled of 2 completes it.
4383            order: AlpacaOrderWs {
4384                status: SmolStr::new("filled"),
4385                ..make_order_ws("ord-full", "SPY", "buy", "2")
4386            },
4387            price: Some("150.00"),
4388            qty: Some("1"),
4389            timestamp: Some("2025-04-18T14:30:00Z"),
4390        };
4391        let (_trade, order) = fill_events(convert_trade_update(update));
4392        let OrderState::Active(crate::order::state::ActiveOrderState::Open(open)) = order.state
4393        else {
4394            panic!("expected an Open snapshot, got {:?}", order.state);
4395        };
4396        assert_eq!(open.filled_quantity, Decimal::from_str("2").unwrap());
4397        assert!(
4398            open.quantity_remaining(order.quantity).is_zero(),
4399            "a completed order must report nothing left to fill so the engine retires it"
4400        );
4401    }
4402
4403    /// A fill whose order the exchange no longer reports as working must not be written back into
4404    /// engine state as an `Open` order. The execution still counts -- it moved the position --
4405    /// but resurrecting the order would leave a resting order that does not exist at the venue.
4406    #[test]
4407    fn a_fill_for_an_order_no_longer_working_emits_the_execution_without_a_snapshot() {
4408        for status in [
4409            "canceled",
4410            "expired",
4411            "rejected",
4412            "done_for_day",
4413            "pending_cancel",
4414        ] {
4415            let update = AlpacaTradeUpdate {
4416                event: SmolStr::new("partial_fill"),
4417                order: AlpacaOrderWs {
4418                    status: SmolStr::new(status),
4419                    ..make_order_ws("ord-late", "SPY", "buy", "1")
4420                },
4421                price: Some("150.00"),
4422                qty: Some("1"),
4423                timestamp: None,
4424            };
4425            let [trade, snapshot] = convert_trade_update(update);
4426            assert!(
4427                matches!(trade.map(|e| e.kind), Some(AccountEventKind::Trade(_))),
4428                "the execution must still be reported for status {status}"
4429            );
4430            assert!(
4431                snapshot.is_none(),
4432                "status {status} must not produce an order snapshot"
4433            );
4434        }
4435    }
4436
4437    /// A notional order (placed by dollar value) carries no `qty`, so there is no quantity to
4438    /// report a remaining amount against. The execution is still reported; the order is not.
4439    #[test]
4440    fn a_notional_order_fill_emits_the_execution_without_a_snapshot() {
4441        let update = AlpacaTradeUpdate {
4442            event: SmolStr::new("partial_fill"),
4443            order: AlpacaOrderWs {
4444                qty: None,
4445                ..make_order_ws("ord-notional-ws", "SPY", "buy", "1")
4446            },
4447            price: Some("150.00"),
4448            qty: Some("1"),
4449            timestamp: None,
4450        };
4451        let [trade, snapshot] = convert_trade_update(update);
4452        assert!(matches!(
4453            trade.map(|e| e.kind),
4454            Some(AccountEventKind::Trade(_))
4455        ));
4456        assert!(
4457            snapshot.is_none(),
4458            "a notional order has no quantity to snapshot against"
4459        );
4460    }
4461
4462    #[test]
4463    fn test_convert_trade_update_new_order_produces_snapshot() {
4464        let update = AlpacaTradeUpdate {
4465            event: SmolStr::new("new"),
4466            order: AlpacaOrderWs {
4467                id: SmolStr::new("ord-new"),
4468                client_order_id: Some(SmolStr::new("cid-1")),
4469                symbol: SmolStr::new("AAPL"),
4470                qty: Some("10"),
4471                filled_qty: Some("0"),
4472                side: SmolStr::new("buy"),
4473                order_type: SmolStr::new("limit"),
4474                time_in_force: SmolStr::new("day"),
4475                limit_price: Some("150.00"),
4476                stop_price: None,
4477                trail_percent: None,
4478                trail_price: None,
4479                status: SmolStr::new("new"),
4480            },
4481            price: None,
4482            qty: None,
4483            timestamp: Some("2025-04-18T14:30:00Z"),
4484        };
4485        let event =
4486            sole_event(convert_trade_update(update)).expect("new event should produce an event");
4487        assert!(matches!(event.kind, AccountEventKind::OrderSnapshot(_)));
4488    }
4489
4490    #[test]
4491    fn test_convert_trade_update_canceled_produces_cancel() {
4492        let update = AlpacaTradeUpdate {
4493            event: SmolStr::new("canceled"),
4494            order: make_order_ws("ord-3", "AAPL", "sell", "0"),
4495            price: None,
4496            qty: None,
4497            timestamp: Some("2025-04-18T14:30:00Z"),
4498        };
4499        let event =
4500            sole_event(convert_trade_update(update)).expect("canceled should produce an event");
4501        let AccountEventKind::OrderCancelled(response) = event.kind else {
4502            panic!("expected OrderCancelled, got {:?}", event.kind);
4503        };
4504        assert!(response.state.is_ok());
4505    }
4506
4507    #[test]
4508    fn test_convert_trade_update_rejected_produces_error() {
4509        let update = AlpacaTradeUpdate {
4510            event: SmolStr::new("rejected"),
4511            order: make_order_ws("ord-4", "SPY", "buy", "0"),
4512            price: None,
4513            qty: None,
4514            timestamp: None,
4515        };
4516        let event =
4517            sole_event(convert_trade_update(update)).expect("rejected should produce an event");
4518        let AccountEventKind::OrderCancelled(response) = event.kind else {
4519            panic!("expected OrderCancelled, got {:?}", event.kind);
4520        };
4521        assert!(response.state.is_err());
4522    }
4523
4524    #[test]
4525    fn test_convert_open_order_notional_qty_none_is_skipped() {
4526        // qty=None means this is a notional order (placed by dollar value).
4527        // Recording it with quantity=0 would corrupt reconciliation, so it must be skipped.
4528        let order = AlpacaOrderResponse {
4529            id: "ord-notional".to_string(),
4530            client_order_id: None,
4531            symbol: "SPY".to_string(),
4532            qty: None,
4533            filled_qty: "0".to_string(),
4534            side: "buy".to_string(),
4535            order_type: "market".to_string(),
4536            time_in_force: "day".to_string(),
4537            limit_price: None,
4538            stop_price: None,
4539            trail_percent: None,
4540            trail_price: None,
4541            created_at: "2025-04-18T14:30:00Z".to_string(),
4542            updated_at: None,
4543        };
4544        assert!(convert_open_order(&order).is_none());
4545    }
4546
4547    #[test]
4548    fn test_convert_activity_to_trade_bad_price_returns_none() {
4549        // When price is unparseable, convert_activity_to_trade must return None.
4550        // recover_fills advances cumulative_qty BEFORE this check so that the dedup
4551        // key sequence stays aligned with the WS path even when a fill is skipped.
4552        let activity = AlpacaActivity {
4553            id: "act-1".to_string(),
4554            order_id: "ord-1".to_string(),
4555            symbol: "SPY250418C00450000".to_string(),
4556            side: "buy".to_string(),
4557            price: "not-a-number".to_string(),
4558            qty: "1".to_string(),
4559            transaction_time: "2025-04-18T14:30:00Z".to_string(),
4560            cum_qty: Some("1".to_string()),
4561        };
4562        assert!(convert_activity_to_trade(&activity).is_none());
4563    }
4564
4565    #[test]
4566    fn test_convert_positions_to_balances_crypto() {
4567        let positions = vec![
4568            AlpacaPosition {
4569                symbol: "BTC/USD".into(),
4570                asset_class: "crypto".into(),
4571                qty: "0.5".into(),
4572                qty_available: "0.4".into(),
4573            },
4574            AlpacaPosition {
4575                symbol: "ETH/USD".into(),
4576                asset_class: "crypto".into(),
4577                qty: "2.0".into(),
4578                qty_available: "2.0".into(),
4579            },
4580            // Equity positions should be filtered out
4581            AlpacaPosition {
4582                symbol: "AAPL".into(),
4583                asset_class: "us_equity".into(),
4584                qty: "10".into(),
4585                qty_available: "10".into(),
4586            },
4587        ];
4588
4589        // All crypto assets
4590        let balances = convert_positions_to_balances(&positions, &[]);
4591        assert_eq!(balances.len(), 2, "only crypto positions returned");
4592        assert_eq!(balances[0].asset.name().as_str(), "btc");
4593        // total = qty (0.5 BTC), free = qty_available (0.4 BTC)
4594        assert_eq!(balances[0].balance.total, Decimal::from_str("0.5").unwrap());
4595        assert_eq!(balances[0].balance.free, Decimal::from_str("0.4").unwrap());
4596
4597        // Filter to BTC only
4598        let btc_only = vec![AssetNameExchange::new("BTC")];
4599        let balances = convert_positions_to_balances(&positions, &btc_only);
4600        assert_eq!(balances.len(), 1);
4601        assert_eq!(balances[0].asset.name().as_str(), "btc");
4602    }
4603
4604    /// `updated_at` orders an order's states; `created_at` is constant across all of them.
4605    #[test]
4606    fn order_state_time_prefers_updated_at() {
4607        let mut resp = make_order_response("ord-1", "SPY");
4608        resp.created_at = "2025-04-18T14:30:00Z".to_string();
4609        resp.updated_at = Some("2025-04-18T15:45:00Z".to_string());
4610        assert_eq!(
4611            order_state_time(&resp),
4612            parse_timestamp("2025-04-18T15:45:00Z").unwrap(),
4613            "updated_at must win over created_at"
4614        );
4615
4616        // Alpaca's schema makes updated_at nullable, so creation time is the fallback.
4617        resp.updated_at = None;
4618        assert_eq!(
4619            order_state_time(&resp),
4620            parse_timestamp("2025-04-18T14:30:00Z").unwrap(),
4621            "absent updated_at falls back to created_at"
4622        );
4623
4624        // An unparseable updated_at must not silently become `now`, which would order this state
4625        // ahead of every later one.
4626        resp.updated_at = Some("not-a-timestamp".to_string());
4627        assert_eq!(
4628            order_state_time(&resp),
4629            parse_timestamp("2025-04-18T14:30:00Z").unwrap(),
4630            "unparseable updated_at falls back to created_at"
4631        );
4632    }
4633
4634    fn make_order_response(id: &str, symbol: &str) -> AlpacaOrderResponse {
4635        AlpacaOrderResponse {
4636            id: id.to_string(),
4637            client_order_id: None,
4638            symbol: symbol.to_string(),
4639            qty: Some("1".to_string()),
4640            filled_qty: "0".to_string(),
4641            side: "buy".to_string(),
4642            order_type: "limit".to_string(),
4643            time_in_force: "day".to_string(),
4644            limit_price: Some("100.00".to_string()),
4645            stop_price: None,
4646            trail_percent: None,
4647            trail_price: None,
4648            created_at: "2025-04-18T14:30:00Z".to_string(),
4649            updated_at: None,
4650        }
4651    }
4652
4653    #[test]
4654    fn test_build_instrument_snapshots_empty_instruments_returns_only_with_orders() {
4655        let orders = vec![
4656            make_order_response("o1", "AAPL"),
4657            make_order_response("o2", "SPY"),
4658        ];
4659        let snapshots = build_instrument_snapshots(orders, &[]);
4660        assert_eq!(snapshots.len(), 2);
4661        let symbols: Vec<&str> = snapshots
4662            .iter()
4663            .map(|s| s.instrument.name().as_str())
4664            .collect();
4665        assert!(symbols.contains(&"AAPL"));
4666        assert!(symbols.contains(&"SPY"));
4667    }
4668
4669    #[test]
4670    fn test_build_instrument_snapshots_requested_instrument_no_orders_gets_empty_snapshot() {
4671        // When instruments list is provided, every requested instrument must appear
4672        // even if it has no open orders — callers depend on this for reconciliation.
4673        let orders = vec![make_order_response("o1", "AAPL")];
4674        let instruments = vec![
4675            InstrumentNameExchange::new("AAPL"),
4676            InstrumentNameExchange::new("SPY"),
4677        ];
4678        let snapshots = build_instrument_snapshots(orders, &instruments);
4679        assert_eq!(snapshots.len(), 2);
4680        let spy = snapshots
4681            .iter()
4682            .find(|s| s.instrument.name().as_str() == "SPY")
4683            .expect("SPY snapshot must be present even with no orders");
4684        assert!(spy.orders.is_empty());
4685    }
4686
4687    #[test]
4688    fn test_build_instrument_snapshots_non_requested_instrument_excluded() {
4689        // An instrument with open orders that is NOT in the requested list must
4690        // not appear in the output when the instruments list is non-empty.
4691        let orders = vec![
4692            make_order_response("o1", "AAPL"),
4693            make_order_response("o2", "MSFT"), // not requested
4694        ];
4695        let instruments = vec![InstrumentNameExchange::new("AAPL")];
4696        let snapshots = build_instrument_snapshots(orders, &instruments);
4697        assert_eq!(snapshots.len(), 1);
4698        assert_eq!(snapshots[0].instrument.name().as_str(), "AAPL");
4699    }
4700
4701    /// Verifies that the dedup key synthesised by `recover_fills` (REST path) matches
4702    /// the key produced by `convert_trade_update` (WS path) for the same partial fills.
4703    ///
4704    /// This is the critical invariant for cross-source dedup after a reconnect:
4705    /// both paths must produce `"{order_id}:{cumulative_filled_qty}"` for the same fill.
4706    #[test]
4707    fn test_recover_fills_dedup_key_matches_ws_path() {
4708        let order_id = "ord-1";
4709
4710        // WS path: Alpaca sends cumulative filled_qty with each event.
4711        // Two partial fills of 1 lot each → filled_qty "1" then "2".
4712        let ws_keys: Vec<SmolStr> = ["1", "2"]
4713            .iter()
4714            .filter_map(|filled_qty| {
4715                let update = AlpacaTradeUpdate {
4716                    event: SmolStr::new("partial_fill"),
4717                    order: make_order_ws(order_id, "SPY", "buy", filled_qty),
4718                    price: Some("150.00"),
4719                    qty: Some("1"),
4720                    timestamp: None,
4721                };
4722                let [event, _snapshot] = convert_trade_update(update);
4723                let event = event?;
4724                fill_dedup_key_from_event(&event).cloned()
4725            })
4726            .collect();
4727
4728        // REST path: recover_fills accumulates cumulative qty from per-execution activities.
4729        // Two activities with exec qty "1" each → cumulative 1 then 2.
4730        let mut cumulative = Decimal::ZERO;
4731        let rest_keys: Vec<SmolStr> = ["1", "1"]
4732            .iter()
4733            .map(|exec_qty| {
4734                cumulative += Decimal::from_str(exec_qty).unwrap();
4735                format_smolstr!("{}:{}", order_id, cumulative.normalize())
4736            })
4737            .collect();
4738
4739        assert_eq!(
4740            ws_keys, rest_keys,
4741            "REST recovery dedup keys must match WS path keys for cross-source dedup to work"
4742        );
4743        assert_eq!(ws_keys[0].as_str(), "ord-1:1");
4744        assert_eq!(ws_keys[1].as_str(), "ord-1:2");
4745    }
4746
4747    /// Verifies that `early_dedup_key` produces the same key as the full event path.
4748    ///
4749    /// The early dedup check (M-1 optimization) extracts the key from raw WS fields
4750    /// before constructing the full event. This test ensures both paths produce
4751    /// identical keys, otherwise duplicate detection would fail.
4752    #[test]
4753    fn early_dedup_key_matches_full_event_path() {
4754        let update = AlpacaTradeUpdate {
4755            event: SmolStr::new("fill"),
4756            order: make_order_ws("ord-abc", "SPY", "buy", "5"),
4757            price: Some("150.00"),
4758            qty: Some("5"),
4759            timestamp: None,
4760        };
4761
4762        // Early path: extract key before full event construction
4763        let early_key = early_dedup_key(&update);
4764
4765        // Full path: construct event then extract key. A fill frame also carries the order
4766        // snapshot; the dedup key lives on the execution, which is always the first slot.
4767        let [event, _snapshot] = convert_trade_update(update);
4768        let event = event.expect("fill should produce an execution");
4769        let full_key =
4770            fill_dedup_key_from_event(&event).expect("fill event should have a dedup key");
4771
4772        assert_eq!(
4773            early_key.as_str(),
4774            full_key.as_str(),
4775            "early_dedup_key must produce the same key as the full event path"
4776        );
4777        assert_eq!(early_key.as_str(), "ord-abc:5");
4778    }
4779
4780    /// Verifies that `early_dedup_key` correctly normalizes decimal strings.
4781    ///
4782    /// Alpaca may send "1.00" or "1" for the same fill. Both must produce the same
4783    /// dedup key to avoid false negatives in duplicate detection.
4784    #[test]
4785    fn early_dedup_key_normalizes_decimal_strings() {
4786        // Test with trailing zeros: "1.00" should normalize to "1"
4787        let update1 = AlpacaTradeUpdate {
4788            event: SmolStr::new("fill"),
4789            order: AlpacaOrderWs {
4790                id: SmolStr::new("ord-x"),
4791                client_order_id: Some(SmolStr::new("cid")),
4792                symbol: SmolStr::new("AAPL"),
4793                qty: Some("10"),
4794                filled_qty: Some("1.00"),
4795                side: SmolStr::new("buy"),
4796                order_type: SmolStr::new("market"),
4797                time_in_force: SmolStr::new("day"),
4798                limit_price: None,
4799                stop_price: None,
4800                trail_percent: None,
4801                trail_price: None,
4802                status: SmolStr::new("filled"),
4803            },
4804            price: Some("100.00"),
4805            qty: Some("10"),
4806            timestamp: None,
4807        };
4808        assert_eq!(early_dedup_key(&update1).as_str(), "ord-x:1");
4809
4810        // Test already normalized: "1" stays "1"
4811        let update2 = AlpacaTradeUpdate {
4812            event: SmolStr::new("fill"),
4813            order: AlpacaOrderWs {
4814                id: SmolStr::new("ord-x"),
4815                client_order_id: Some(SmolStr::new("cid")),
4816                symbol: SmolStr::new("AAPL"),
4817                qty: Some("10"),
4818                filled_qty: Some("1"),
4819                side: SmolStr::new("buy"),
4820                order_type: SmolStr::new("market"),
4821                time_in_force: SmolStr::new("day"),
4822                limit_price: None,
4823                stop_price: None,
4824                trail_percent: None,
4825                trail_price: None,
4826                status: SmolStr::new("filled"),
4827            },
4828            price: Some("100.00"),
4829            qty: Some("10"),
4830            timestamp: None,
4831        };
4832        assert_eq!(early_dedup_key(&update2).as_str(), "ord-x:1");
4833
4834        // Test single trailing zero: "1.0" normalizes to "1"
4835        let update3 = AlpacaTradeUpdate {
4836            event: SmolStr::new("fill"),
4837            order: AlpacaOrderWs {
4838                id: SmolStr::new("ord-x"),
4839                client_order_id: Some(SmolStr::new("cid")),
4840                symbol: SmolStr::new("AAPL"),
4841                qty: Some("10"),
4842                filled_qty: Some("1.0"),
4843                side: SmolStr::new("buy"),
4844                order_type: SmolStr::new("market"),
4845                time_in_force: SmolStr::new("day"),
4846                limit_price: None,
4847                stop_price: None,
4848                trail_percent: None,
4849                trail_price: None,
4850                status: SmolStr::new("filled"),
4851            },
4852            price: Some("100.00"),
4853            qty: Some("10"),
4854            timestamp: None,
4855        };
4856        assert_eq!(early_dedup_key(&update3).as_str(), "ord-x:1");
4857    }
4858
4859    /// Regression guard for HIGH-2: a `rejected` event without `filled_qty` in the JSON
4860    /// previously caused the entire AlpacaTradeUpdate to fail deserialization, silently
4861    /// dropping the event. After the fix, `filled_qty` is Option and defaults to None
4862    /// (unwrapped to "0" at use-sites), so the event reaches the `rejected` branch.
4863    #[test]
4864    fn process_ws_text_rejected_event_without_filled_qty_is_not_dropped() {
4865        let (tx, mut rx) = mpsc::unbounded_channel();
4866        let dedup = new_dedup_cache();
4867        let mut backoff = ExponentialBackoff::new();
4868
4869        // Minimal rejected-event JSON — no `filled_qty` field in the order object.
4870        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"}}}"#;
4871
4872        process_ws_text(json, &tx, &dedup, &mut backoff);
4873
4874        // The event must NOT be silently dropped — an OrderCancelled must be emitted.
4875        let event = rx.try_recv()
4876            .expect("rejected event without filled_qty must produce an AccountEvent, not be silently dropped");
4877        assert!(
4878            matches!(event.kind, AccountEventKind::OrderCancelled(_)),
4879            "rejected event must map to OrderCancelled, got: {:?}",
4880            event.kind
4881        );
4882    }
4883
4884    /// Pins the string representation of `Decimal::ZERO.normalize()`, which is used
4885    /// as the dedup key fallback when `filled_qty` is unparseable: `"{order_id}:0"`.
4886    #[test]
4887    fn decimal_zero_normalize_is_zero_str() {
4888        assert_eq!(Decimal::ZERO.normalize().to_string(), "0");
4889    }
4890
4891    /// Verifies that trailing zeros are stripped by `normalize()`, ensuring dedup keys
4892    /// match regardless of whether Alpaca returns `"1.00"` or `"1"` for the same qty.
4893    /// This is the critical invariant for REST/WS dedup key equivalence.
4894    #[test]
4895    fn decimal_normalize_strips_trailing_zeros() {
4896        // Alpaca may return "1.00" in REST but WS cumulative may be "1" — must match.
4897        let from_rest = Decimal::from_str("1.00").unwrap().normalize();
4898        let from_ws = Decimal::from_str("1").unwrap().normalize();
4899        assert_eq!(from_rest.to_string(), from_ws.to_string());
4900        assert_eq!(from_rest.to_string(), "1");
4901
4902        // More edge cases: various trailing zero representations.
4903        assert_eq!(
4904            Decimal::from_str("100.000")
4905                .unwrap()
4906                .normalize()
4907                .to_string(),
4908            "100"
4909        );
4910        assert_eq!(
4911            Decimal::from_str("0.10").unwrap().normalize().to_string(),
4912            "0.1"
4913        );
4914        assert_eq!(
4915            Decimal::from_str("0.100").unwrap().normalize().to_string(),
4916            "0.1"
4917        );
4918    }
4919
4920    // H-2: zero options_buying_power falls back to buying_power (not free=0).
4921    // Alpaca returns options_buying_power="0.00" on equity-only accounts (options
4922    // not enabled) rather than omitting the field. Without the .filter(!is_zero())
4923    // guard the engine would see free=0 and block all orders.
4924    #[test]
4925    fn convert_account_to_balances_zero_options_buying_power_falls_back_to_buying_power() {
4926        let account = AlpacaAccount {
4927            equity: "12000.00".into(),
4928            buying_power: "10000.00".into(),
4929            options_buying_power: Some("0.00".into()), // equity-only: options not enabled
4930            crypto_buying_power: None,
4931        };
4932        let balances = convert_account_to_balances(&account, &[]);
4933        assert_eq!(balances.len(), 1);
4934        assert_eq!(
4935            balances[0].balance.free,
4936            Decimal::from_str("10000.00").unwrap(),
4937            "zero options_buying_power must fall back to buying_power, not report free=0"
4938        );
4939    }
4940
4941    // M-2: map_position_intent derives intent from (reduce_only, side).
4942    #[test]
4943    fn map_position_intent_open_buy_maps_to_buy_to_open() {
4944        assert_eq!(
4945            map_position_intent(Side::Buy, false),
4946            AlpacaPositionIntent::BuyToOpen
4947        );
4948    }
4949
4950    #[test]
4951    fn map_position_intent_open_sell_maps_to_sell_to_open() {
4952        assert_eq!(
4953            map_position_intent(Side::Sell, false),
4954            AlpacaPositionIntent::SellToOpen
4955        );
4956    }
4957
4958    #[test]
4959    fn map_position_intent_reduce_buy_maps_to_buy_to_close() {
4960        assert_eq!(
4961            map_position_intent(Side::Buy, true),
4962            AlpacaPositionIntent::BuyToClose
4963        );
4964    }
4965
4966    #[test]
4967    fn map_position_intent_reduce_sell_maps_to_sell_to_close() {
4968        assert_eq!(
4969            map_position_intent(Side::Sell, true),
4970            AlpacaPositionIntent::SellToClose
4971        );
4972    }
4973
4974    // M-1: parse_order_error — pin all status-code branches not covered by existing tests.
4975    #[test]
4976    fn parse_order_error_401_maps_to_unauthenticated() {
4977        // 401 Unauthorized: invalid/expired API credentials.
4978        assert!(matches!(
4979            parse_order_error(reqwest::StatusCode::UNAUTHORIZED, "bad credentials"),
4980            UnindexedOrderError::Rejected(ApiError::Unauthenticated(_))
4981        ));
4982    }
4983
4984    #[test]
4985    fn parse_order_error_403_maps_to_unauthenticated() {
4986        // 403 Forbidden indicates auth/permission failure — use Unauthenticated, not
4987        // OrderRejected or BalanceInsufficient (which could trigger incorrect retry logic).
4988        assert!(matches!(
4989            parse_order_error(reqwest::StatusCode::FORBIDDEN, "account suspended"),
4990            UnindexedOrderError::Rejected(ApiError::Unauthenticated(_))
4991        ));
4992    }
4993
4994    #[test]
4995    fn parse_order_error_404_maps_to_order_rejected_with_not_found_prefix() {
4996        let err = parse_order_error(reqwest::StatusCode::NOT_FOUND, "order not found");
4997        let UnindexedOrderError::Rejected(ApiError::OrderRejected(msg)) = err else {
4998            panic!("expected OrderRejected, got {err:?}");
4999        };
5000        assert!(
5001            msg.contains("order not found"),
5002            "message should contain 'order not found': {msg}"
5003        );
5004    }
5005
5006    #[test]
5007    fn parse_order_error_422_insufficient_only_maps_to_balance_insufficient() {
5008        // No "already" in body → must not match OrderAlreadyCancelled; must be BalanceInsufficient.
5009        assert!(matches!(
5010            parse_order_error(
5011                reqwest::StatusCode::UNPROCESSABLE_ENTITY,
5012                "insufficient funds for this order"
5013            ),
5014            UnindexedOrderError::Rejected(ApiError::BalanceInsufficient(_, _))
5015        ));
5016    }
5017
5018    #[test]
5019    fn parse_order_error_429_maps_to_rate_limit() {
5020        assert!(matches!(
5021            parse_order_error(reqwest::StatusCode::TOO_MANY_REQUESTS, "rate limited"),
5022            UnindexedOrderError::Rejected(ApiError::RateLimit)
5023        ));
5024    }
5025
5026    // M-4: parse_time_in_force — pin the unknown-value fallback to GoodUntilEndOfDay.
5027    // If Alpaca adds a new TIF (e.g. "opg" for at-the-open), orders continue to be
5028    // tracked with EOD expiry until this function is updated; the warn! makes it visible.
5029    #[test]
5030    fn parse_time_in_force_unknown_value_falls_back_to_good_until_end_of_day() {
5031        assert_eq!(
5032            parse_time_in_force("opg"),
5033            TimeInForce::GoodUntilEndOfDay,
5034            "unknown TIF must fall back to GoodUntilEndOfDay (with a warn! in production)"
5035        );
5036    }
5037
5038    // L-4: build_instrument_snapshots — output order must match the instruments slice,
5039    // not the internal IndexMap insertion order of the orders vec.
5040    #[test]
5041    fn build_instrument_snapshots_output_order_matches_instruments_slice() {
5042        // Orders arrive in symbol order: SPY, AAPL, MSFT.
5043        let orders = vec![
5044            make_order_response("o1", "SPY"),
5045            make_order_response("o2", "AAPL"),
5046            make_order_response("o3", "MSFT"),
5047        ];
5048        // Request a different order: MSFT first, then AAPL.
5049        let instruments = vec![
5050            InstrumentNameExchange::new("MSFT"),
5051            InstrumentNameExchange::new("AAPL"),
5052        ];
5053        let snapshots = build_instrument_snapshots(orders, &instruments);
5054        assert_eq!(snapshots.len(), 2);
5055        assert_eq!(
5056            snapshots[0].instrument.name().as_str(),
5057            "MSFT",
5058            "first snapshot must be MSFT (first in instruments slice)"
5059        );
5060        assert_eq!(
5061            snapshots[1].instrument.name().as_str(),
5062            "AAPL",
5063            "second snapshot must be AAPL (second in instruments slice)"
5064        );
5065    }
5066
5067    // ---------------------------------------------------------------------------
5068    // HTTP-mocked tests — paginate_activities (H-1) and fetch_raw_open_orders (H-3)
5069    // ---------------------------------------------------------------------------
5070    //
5071    // These tests use wiremock to stand up a local HTTP server, verifying the full
5072    // pagination loop and truncation logic without touching the real Alpaca API.
5073    mod http_tests {
5074        use super::super::*;
5075        use wiremock::matchers::{method, path};
5076        use wiremock::{Mock, MockServer, Request, Respond, ResponseTemplate};
5077
5078        /// Serves pre-configured JSON pages in registration order.
5079        ///
5080        /// Each call to `respond` advances an atomic counter and returns the next
5081        /// page body. Panics if called more times than pages were configured —
5082        /// surfaces unexpected extra requests as an explicit test failure rather
5083        /// than silently returning a stale response.
5084        struct Sequential {
5085            call: std::sync::atomic::AtomicU32,
5086            pages: Vec<serde_json::Value>,
5087        }
5088
5089        impl Sequential {
5090            fn new(pages: Vec<serde_json::Value>) -> Self {
5091                Self {
5092                    call: std::sync::atomic::AtomicU32::new(0),
5093                    pages,
5094                }
5095            }
5096        }
5097
5098        impl Respond for Sequential {
5099            fn respond(&self, _: &Request) -> ResponseTemplate {
5100                let i = self.call.fetch_add(1, std::sync::atomic::Ordering::Relaxed) as usize;
5101                let body = self.pages.get(i).unwrap_or_else(|| {
5102                    panic!(
5103                        "Sequential: request #{i} has no configured response \
5104                         (only {} page(s) supplied)",
5105                        self.pages.len()
5106                    )
5107                });
5108                ResponseTemplate::new(200).set_body_json(body)
5109            }
5110        }
5111
5112        /// Build a JSON array of N minimal AlpacaActivity objects with unique IDs.
5113        fn make_activities_json(count: usize, id_prefix: &str) -> serde_json::Value {
5114            serde_json::Value::Array(
5115                (0..count)
5116                    .map(|i| {
5117                        serde_json::json!({
5118                            "id": format!("{id_prefix}-{i:05}"),
5119                            "order_id": "ord-1",
5120                            "symbol": "SPY",
5121                            "side": "buy",
5122                            "price": "100.00",
5123                            "qty": "1",
5124                            "transaction_time": "2025-04-18T14:30:00Z"
5125                        })
5126                    })
5127                    .collect(),
5128            )
5129        }
5130
5131        /// Build a JSON array of N minimal AlpacaOrderResponse objects with unique IDs.
5132        fn make_orders_json(count: usize) -> serde_json::Value {
5133            serde_json::Value::Array(
5134                (0..count)
5135                    .map(|i| {
5136                        serde_json::json!({
5137                            "id": format!("order-{i:05}"),
5138                            "client_order_id": null,
5139                            "symbol": "SPY",
5140                            "qty": "1",
5141                            "filled_qty": "0",
5142                            "side": "buy",
5143                            "type": "limit",
5144                            "time_in_force": "day",
5145                            "limit_price": "100.00",
5146                            "created_at": "2025-04-18T14:30:00Z"
5147                        })
5148                    })
5149                    .collect(),
5150            )
5151        }
5152
5153        // --- H-1: paginate_activities ---
5154
5155        /// Single page with fewer items than the page limit — loop terminates immediately,
5156        /// no further request issued.
5157        #[tokio::test]
5158        async fn paginate_activities_single_page_below_max_returns_all_not_truncated() {
5159            let server = MockServer::start().await;
5160            Mock::given(method("GET"))
5161                .and(path("/v2/account/activities"))
5162                .respond_with(
5163                    ResponseTemplate::new(200).set_body_json(make_activities_json(5, "act")),
5164                )
5165                .mount(&server)
5166                .await;
5167
5168            let http = reqwest::Client::new();
5169            let rl = RateLimitTracker::new();
5170            let result = paginate_activities(&http, &rl, &server.uri(), "2025-01-01T00:00:00Z")
5171                .await
5172                .unwrap();
5173
5174            assert_eq!(result.activities.len(), 5);
5175            assert!(!result.truncated);
5176            assert_eq!(server.received_requests().await.unwrap().len(), 1);
5177        }
5178
5179        /// First page has exactly ALPACA_MAX_ACTIVITIES items, which triggers a second
5180        /// request. The second page is empty, so the loop terminates without truncation.
5181        #[tokio::test]
5182        async fn paginate_activities_exactly_page_size_items_fetches_second_page() {
5183            let server = MockServer::start().await;
5184            Mock::given(method("GET"))
5185                .and(path("/v2/account/activities"))
5186                .respond_with(Sequential::new(vec![
5187                    make_activities_json(ALPACA_MAX_ACTIVITIES, "act"),
5188                    serde_json::json!([]), // empty second page → loop stops
5189                ]))
5190                .mount(&server)
5191                .await;
5192
5193            let http = reqwest::Client::new();
5194            let rl = RateLimitTracker::new();
5195            let result = paginate_activities(&http, &rl, &server.uri(), "2025-01-01T00:00:00Z")
5196                .await
5197                .unwrap();
5198
5199            assert_eq!(result.activities.len(), ALPACA_MAX_ACTIVITIES);
5200            assert!(!result.truncated);
5201            assert_eq!(
5202                server.received_requests().await.unwrap().len(),
5203                2,
5204                "exactly 2 requests: first full page + second empty page"
5205            );
5206        }
5207
5208        /// Two-page case: 100 items on page 1, 37 on page 2 — all accumulated,
5209        /// loop terminates on the partial second page without truncation.
5210        #[tokio::test]
5211        async fn paginate_activities_two_pages_returns_combined_activities_not_truncated() {
5212            let server = MockServer::start().await;
5213            Mock::given(method("GET"))
5214                .and(path("/v2/account/activities"))
5215                .respond_with(Sequential::new(vec![
5216                    make_activities_json(ALPACA_MAX_ACTIVITIES, "p1"),
5217                    make_activities_json(37, "p2"),
5218                ]))
5219                .mount(&server)
5220                .await;
5221
5222            let http = reqwest::Client::new();
5223            let rl = RateLimitTracker::new();
5224            let result = paginate_activities(&http, &rl, &server.uri(), "2025-01-01T00:00:00Z")
5225                .await
5226                .unwrap();
5227
5228            assert_eq!(result.activities.len(), ALPACA_MAX_ACTIVITIES + 37);
5229            assert!(!result.truncated);
5230            assert_eq!(server.received_requests().await.unwrap().len(), 2);
5231        }
5232
5233        /// When every page is full the loop runs until MAX_ACTIVITY_PAGES pages have been
5234        /// fetched, then sets truncated=true and stops. Exactly MAX_ACTIVITY_PAGES HTTP
5235        /// requests are issued (the truncation guard fires before the (N+1)th call).
5236        #[tokio::test]
5237        async fn paginate_activities_at_max_pages_sets_truncated_true() {
5238            let server = MockServer::start().await;
5239
5240            // Always return a full page — the loop must enforce the cap itself.
5241            Mock::given(method("GET"))
5242                .and(path("/v2/account/activities"))
5243                .respond_with(
5244                    ResponseTemplate::new(200)
5245                        .set_body_json(make_activities_json(ALPACA_MAX_ACTIVITIES, "act")),
5246                )
5247                .mount(&server)
5248                .await;
5249
5250            let http = reqwest::Client::new();
5251            let rl = RateLimitTracker::new();
5252            let result = paginate_activities(&http, &rl, &server.uri(), "2025-01-01T00:00:00Z")
5253                .await
5254                .unwrap();
5255
5256            assert!(
5257                result.truncated,
5258                "must be truncated after MAX_ACTIVITY_PAGES pages"
5259            );
5260            assert_eq!(
5261                result.activities.len(),
5262                MAX_ACTIVITY_PAGES * ALPACA_MAX_ACTIVITIES,
5263                "must accumulate exactly MAX_ACTIVITY_PAGES * page_size activities"
5264            );
5265            // The truncation check fires at the top of the loop when pages == MAX_ACTIVITY_PAGES,
5266            // before the (MAX_ACTIVITY_PAGES+1)th request would be issued.
5267            assert_eq!(
5268                server.received_requests().await.unwrap().len(),
5269                MAX_ACTIVITY_PAGES,
5270                "loop must issue exactly MAX_ACTIVITY_PAGES requests then stop"
5271            );
5272        }
5273
5274        // --- H-3: fetch_raw_open_orders truncation boundary ---
5275
5276        /// 499 orders (one below MAX_OPEN_ORDERS) is not truncated — returns Ok.
5277        #[tokio::test]
5278        async fn fetch_raw_open_orders_499_results_returns_ok() {
5279            let server = MockServer::start().await;
5280            Mock::given(method("GET"))
5281                .and(path("/v2/orders"))
5282                .respond_with(
5283                    ResponseTemplate::new(200).set_body_json(make_orders_json(MAX_OPEN_ORDERS - 1)),
5284                )
5285                .mount(&server)
5286                .await;
5287
5288            let http = reqwest::Client::new();
5289            let rl = RateLimitTracker::new();
5290            let result = fetch_raw_open_orders(&http, &rl, &server.uri(), &[]).await;
5291
5292            assert!(
5293                result.is_ok(),
5294                "499 orders must not trigger truncation: {result:?}"
5295            );
5296            assert_eq!(result.unwrap().len(), MAX_OPEN_ORDERS - 1);
5297        }
5298
5299        /// Exactly MAX_OPEN_ORDERS results triggers TruncatedSnapshot because Alpaca's
5300        /// API cap means the response is likely incomplete. An off-by-one here would
5301        /// either silently corrupt OMS state or incorrectly reject a valid account.
5302        #[tokio::test]
5303        async fn fetch_raw_open_orders_500_results_returns_truncated_snapshot_error() {
5304            let server = MockServer::start().await;
5305            Mock::given(method("GET"))
5306                .and(path("/v2/orders"))
5307                .respond_with(
5308                    ResponseTemplate::new(200).set_body_json(make_orders_json(MAX_OPEN_ORDERS)),
5309                )
5310                .mount(&server)
5311                .await;
5312
5313            let http = reqwest::Client::new();
5314            let rl = RateLimitTracker::new();
5315            let result = fetch_raw_open_orders(&http, &rl, &server.uri(), &[]).await;
5316
5317            assert!(
5318                matches!(
5319                    result,
5320                    Err(UnindexedClientError::TruncatedSnapshot { limit }) if limit == MAX_OPEN_ORDERS
5321                ),
5322                "500 orders must return TruncatedSnapshot, got: {result:?}"
5323            );
5324        }
5325
5326        // --- L-2: open_order passes reduce_only through to position_intent ---
5327
5328        /// Verifies that `open_order` correctly derives `position_intent` from
5329        /// `reduce_only` and `side`. This test exercises the full path:
5330        /// `open_order` → `map_position_intent` → `open_order_inner` → HTTP request.
5331        ///
5332        /// Uses wiremock to capture the request body and verify position_intent.
5333        #[tokio::test]
5334        async fn open_order_reduce_only_sell_sends_sell_to_close_intent() {
5335            use crate::client::ExecutionClient;
5336            use crate::order::request::{OrderRequestOpen, RequestOpen};
5337            use crate::order::{
5338                OrderKey, OrderKind, TimeInForce,
5339                id::{ClientOrderId, StrategyId},
5340            };
5341            use rust_decimal::Decimal;
5342            use rustrade_instrument::Side;
5343            use rustrade_instrument::exchange::ExchangeId;
5344            use rustrade_instrument::instrument::name::InstrumentNameExchange;
5345            use wiremock::matchers::{method, path};
5346
5347            let server = MockServer::start().await;
5348
5349            // Mock POST /v2/orders to return a valid order response.
5350            // Use a custom responder to capture and verify the request body.
5351            let captured_body = std::sync::Arc::new(parking_lot::Mutex::new(None));
5352            let captured_clone = captured_body.clone();
5353
5354            Mock::given(method("POST"))
5355                .and(path("/v2/orders"))
5356                .respond_with(move |req: &Request| {
5357                    // Capture the request body for later assertion
5358                    let body: serde_json::Value = serde_json::from_slice(&req.body).unwrap();
5359                    *captured_clone.lock() = Some(body);
5360
5361                    ResponseTemplate::new(200).set_body_json(serde_json::json!({
5362                        "id": "test-order-id",
5363                        "client_order_id": "test-cid",
5364                        "symbol": "AAPL",
5365                        "qty": "10",
5366                        "filled_qty": "0",
5367                        "side": "sell",
5368                        "type": "market",
5369                        "time_in_force": "ioc",
5370                        "limit_price": null,
5371                        "created_at": "2025-04-18T14:30:00Z"
5372                    }))
5373                })
5374                .mount(&server)
5375                .await;
5376
5377            // Create client with base_url_override pointing to mock server
5378            let config =
5379                AlpacaConfig::with_base_url("test-key".into(), "test-secret".into(), server.uri());
5380            let client = AlpacaClient::new(config);
5381
5382            // Create a Sell order with reduce_only=true (should map to SellToClose)
5383            let request = OrderRequestOpen {
5384                key: OrderKey {
5385                    exchange: ExchangeId::AlpacaBroker,
5386                    instrument: InstrumentNameExchange::new("AAPL"),
5387                    strategy: StrategyId::new("test-strategy"),
5388                    cid: ClientOrderId::new("test-cid"),
5389                },
5390                state: RequestOpen {
5391                    side: Side::Sell,
5392                    price: None,
5393                    quantity: Decimal::new(10, 0),
5394                    kind: OrderKind::Market,
5395                    time_in_force: TimeInForce::ImmediateOrCancel,
5396                    position_id: None,
5397                    reduce_only: true, // This should map to SellToClose
5398                    market: None,
5399                },
5400            };
5401
5402            // Call open_order (borrows instrument)
5403            let result = client
5404                .open_order(OrderRequestOpen {
5405                    key: OrderKey {
5406                        exchange: request.key.exchange,
5407                        instrument: &request.key.instrument,
5408                        strategy: request.key.strategy.clone(),
5409                        cid: request.key.cid.clone(),
5410                    },
5411                    state: request.state.clone(),
5412                })
5413                .await;
5414
5415            // Verify the order was accepted
5416            assert!(result.is_some(), "open_order should return a result");
5417            let order = result.unwrap();
5418            assert!(
5419                order.state.is_accepted(),
5420                "order should be accepted: {:?}",
5421                order.state
5422            );
5423
5424            // Verify the request body contained position_intent=sell_to_close
5425            let body = captured_body
5426                .lock()
5427                .take()
5428                .expect("request body should be captured");
5429            assert_eq!(
5430                body.get("position_intent").and_then(|v| v.as_str()),
5431                Some("sell_to_close"),
5432                "reduce_only=true + Side::Sell should produce position_intent=sell_to_close, got: {body}"
5433            );
5434        }
5435
5436        /// Verifies that reduce_only=false + Buy produces BuyToOpen intent.
5437        #[tokio::test]
5438        async fn open_order_not_reduce_only_buy_sends_buy_to_open_intent() {
5439            use crate::client::ExecutionClient;
5440            use crate::order::request::{OrderRequestOpen, RequestOpen};
5441            use crate::order::{
5442                OrderKey, OrderKind, TimeInForce,
5443                id::{ClientOrderId, StrategyId},
5444            };
5445            use rust_decimal::Decimal;
5446            use rustrade_instrument::Side;
5447            use rustrade_instrument::exchange::ExchangeId;
5448            use rustrade_instrument::instrument::name::InstrumentNameExchange;
5449
5450            let server = MockServer::start().await;
5451
5452            let captured_body = std::sync::Arc::new(parking_lot::Mutex::new(None));
5453            let captured_clone = captured_body.clone();
5454
5455            Mock::given(method("POST"))
5456                .and(path("/v2/orders"))
5457                .respond_with(move |req: &Request| {
5458                    let body: serde_json::Value = serde_json::from_slice(&req.body).unwrap();
5459                    *captured_clone.lock() = Some(body);
5460
5461                    ResponseTemplate::new(200).set_body_json(serde_json::json!({
5462                        "id": "test-order-id",
5463                        "client_order_id": "test-cid",
5464                        "symbol": "AAPL",
5465                        "qty": "10",
5466                        "filled_qty": "0",
5467                        "side": "buy",
5468                        "type": "market",
5469                        "time_in_force": "ioc",
5470                        "limit_price": null,
5471                        "created_at": "2025-04-18T14:30:00Z"
5472                    }))
5473                })
5474                .mount(&server)
5475                .await;
5476
5477            let config =
5478                AlpacaConfig::with_base_url("test-key".into(), "test-secret".into(), server.uri());
5479            let client = AlpacaClient::new(config);
5480
5481            let instrument = InstrumentNameExchange::new("AAPL");
5482            let request = OrderRequestOpen {
5483                key: OrderKey {
5484                    exchange: ExchangeId::AlpacaBroker,
5485                    instrument: &instrument,
5486                    strategy: StrategyId::new("test-strategy"),
5487                    cid: ClientOrderId::new("test-cid"),
5488                },
5489                state: RequestOpen {
5490                    side: Side::Buy,
5491                    price: None,
5492                    quantity: Decimal::new(10, 0),
5493                    kind: OrderKind::Market,
5494                    time_in_force: TimeInForce::ImmediateOrCancel,
5495                    position_id: None,
5496                    reduce_only: false, // This should map to BuyToOpen
5497                    market: None,
5498                },
5499            };
5500
5501            let result = client.open_order(request).await;
5502
5503            assert!(result.is_some(), "open_order should return a result");
5504            let order = result.unwrap();
5505            assert!(
5506                order.state.is_accepted(),
5507                "order should be accepted: {:?}",
5508                order.state
5509            );
5510
5511            let body = captured_body
5512                .lock()
5513                .take()
5514                .expect("request body should be captured");
5515            assert_eq!(
5516                body.get("position_intent").and_then(|v| v.as_str()),
5517                Some("buy_to_open"),
5518                "reduce_only=false + Side::Buy should produce position_intent=buy_to_open, got: {body}"
5519            );
5520        }
5521
5522        // -----------------------------------------------------------------------
5523        // recover_fills — what a recovered fill reports, and how it is keyed
5524        // -----------------------------------------------------------------------
5525        //
5526        // These drive `recover_fills` itself rather than re-deriving its arithmetic in the test.
5527        // A key that is correct in isolation is worth nothing if the function does not produce it.
5528
5529        /// One FILL activity as Alpaca serves it. `cum_qty` is omitted entirely when `None`, which
5530        /// is how the pre-existing fallback path is reached.
5531        fn activity_json(
5532            id: &str,
5533            order_id: &str,
5534            qty: &str,
5535            cum_qty: Option<&str>,
5536        ) -> serde_json::Value {
5537            let mut v = serde_json::json!({
5538                "id": id,
5539                "order_id": order_id,
5540                "symbol": "SPY",
5541                "side": "buy",
5542                "price": "100.00",
5543                "qty": qty,
5544                "transaction_time": "2025-04-18T14:30:00Z"
5545            });
5546            if let Some(c) = cum_qty {
5547                v["cum_qty"] = serde_json::Value::String(c.to_string());
5548            }
5549            v
5550        }
5551
5552        /// Serve `activities` from a mock and run `recover_fills` against it, returning every
5553        /// event it forwarded.
5554        async fn drive_recover_fills_with(
5555            activities: Vec<serde_json::Value>,
5556            dedup: SharedDedupCache,
5557        ) -> Vec<UnindexedAccountEvent> {
5558            let server = MockServer::start().await;
5559            Mock::given(method("GET"))
5560                .and(path("/v2/account/activities"))
5561                .respond_with(
5562                    ResponseTemplate::new(200).set_body_json(serde_json::Value::Array(activities)),
5563                )
5564                .mount(&server)
5565                .await;
5566
5567            let http = reqwest::Client::new();
5568            let rl = RateLimitTracker::new();
5569            let (tx, mut rx) = mpsc::unbounded_channel();
5570            recover_fills(
5571                &http,
5572                &rl,
5573                &[],
5574                &server.uri(),
5575                "2025-01-01T00:00:00Z",
5576                &tx,
5577                &dedup,
5578            )
5579            .await;
5580            drop(tx);
5581
5582            let mut out = Vec::new();
5583            while let Ok(event) = rx.try_recv() {
5584                out.push(event);
5585            }
5586            out
5587        }
5588
5589        async fn drive_recover_fills(
5590            activities: Vec<serde_json::Value>,
5591        ) -> Vec<UnindexedAccountEvent> {
5592            drive_recover_fills_with(activities, new_dedup_cache()).await
5593        }
5594
5595        fn trade_of(
5596            event: &UnindexedAccountEvent,
5597        ) -> &Trade<AssetNameExchange, InstrumentNameExchange> {
5598            match &event.kind {
5599                AccountEventKind::Trade(t) => t,
5600                other => panic!("expected a Trade event, got {other:?}"),
5601            }
5602        }
5603
5604        /// A recovered fill reports where it left the order, so it advances `filled_quantity`
5605        /// without waiting for a `fetch_open_orders` reconciliation that the caller may never make.
5606        #[tokio::test]
5607        async fn a_recovered_fill_reports_the_cumulative_alpaca_sent() {
5608            let events = drive_recover_fills(vec![
5609                activity_json("act-1", "ord-1", "2", Some("2")),
5610                activity_json("act-2", "ord-1", "3", Some("5")),
5611            ])
5612            .await;
5613
5614            let reported: Vec<_> = events
5615                .iter()
5616                .map(|e| trade_of(e).order_filled_quantity)
5617                .collect();
5618            assert_eq!(
5619                reported,
5620                vec![
5621                    Some(Decimal::from_str("2").unwrap()),
5622                    Some(Decimal::from_str("5").unwrap())
5623                ]
5624            );
5625        }
5626
5627        /// The dedup key comes from the venue's own cumulative, not from counting executions
5628        /// within the recovery batch.
5629        ///
5630        /// An order that had already partly filled *before* the recovery window is the case that
5631        /// separates the two, and it is the case the previous implementation got wrong: counting
5632        /// from zero inside the batch yields a key the WebSocket path never emitted for that fill.
5633        #[tokio::test]
5634        async fn the_dedup_key_uses_the_venue_cumulative_not_an_intra_batch_count() {
5635            // Three lots filled before the window; the recovered 2-lot fill takes the order to 5.
5636            let events =
5637                drive_recover_fills(vec![activity_json("act-1", "ord-1", "2", Some("5"))]).await;
5638
5639            assert_eq!(
5640                trade_of(&events[0]).id.0.as_str(),
5641                "ord-1:5",
5642                "counting executions within the batch would key this as ord-1:2"
5643            );
5644        }
5645
5646        /// The property the key exists for: one fill delivered twice, by two different paths, is
5647        /// one fill. Before the cumulative was carried this held only for an order whose fills lay
5648        /// wholly inside the recovery window -- precisely the orders least in need of recovery.
5649        #[tokio::test]
5650        async fn a_fill_already_delivered_over_websocket_is_not_recovered_twice() {
5651            let dedup = new_dedup_cache();
5652
5653            // The same execution as it arrived over WebSocket: 2 lots, leaving the order at 5.
5654            let update = AlpacaTradeUpdate {
5655                event: SmolStr::new("partial_fill"),
5656                order: super::make_order_ws("ord-1", "SPY", "buy", "5"),
5657                price: Some("100.00"),
5658                qty: Some("2"),
5659                timestamp: None,
5660            };
5661            let [ws_event, _snapshot] = convert_trade_update(update);
5662            let ws_event = ws_event.expect("a partial_fill converts to a Trade");
5663            let ws_key = fill_dedup_key_from_event(&ws_event)
5664                .expect("a Trade carries a dedup key")
5665                .clone();
5666            assert!(
5667                !is_duplicate(&dedup, &ws_key),
5668                "precondition: first sighting"
5669            );
5670
5671            let events = drive_recover_fills_with(
5672                vec![activity_json("act-1", "ord-1", "2", Some("5"))],
5673                dedup,
5674            )
5675            .await;
5676
5677            assert!(
5678                events.is_empty(),
5679                "a fill already delivered over WebSocket must not be re-delivered by recovery, \
5680                 got {events:?}"
5681            );
5682        }
5683
5684        /// With `cum_qty` absent the key falls back to counting within the batch -- exactly what
5685        /// shipped before -- and the fill reports no cumulative rather than a fabricated one.
5686        #[tokio::test]
5687        async fn without_a_reported_cumulative_the_previous_behaviour_is_preserved() {
5688            let events = drive_recover_fills(vec![
5689                activity_json("act-1", "ord-1", "2", None),
5690                activity_json("act-2", "ord-1", "3", None),
5691            ])
5692            .await;
5693
5694            let keys: Vec<_> = events
5695                .iter()
5696                .map(|e| trade_of(e).id.0.as_str().to_owned())
5697                .collect();
5698            assert_eq!(keys, vec!["ord-1:2", "ord-1:5"], "intra-batch accumulation");
5699
5700            assert!(
5701                events
5702                    .iter()
5703                    .all(|e| trade_of(e).order_filled_quantity.is_none()),
5704                "a venue that reported no cumulative must not have one invented for it"
5705            );
5706        }
5707    }
5708}