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