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, fetch_shortability (GET /v2/assets/{symbol})
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. A read that fails,
26//   times out or truncates is reported as AccountEventKind::FillRecoveryGaveUp
27// - Dedup cache: LRU keyed on each fill's execution id (with "{order_id}:{cumulative_filled_qty}"
28//   for a fill without one) prevents duplicate fills arising from the overlap between WS events
29//   before disconnect and the fill-recovery REST window
30// - Ended-order recovery: after the fills, each order held as live that one listing of the open
31//   orders no longer shows is looked up by client order id (GET /v2/orders:by_client_order_id),
32//   and how it ended is reported; a failed check is retried on a backoff while connected
33//
34// Known limitations:
35// - Order lifecycle events missed while disconnected are recovered only for orders the client
36//   holds as live (placed through it, listed by it, or seen on its stream); an order placed
37//   elsewhere and ended during the outage is not reported.
38// - A fill frame whose order status is neither partially_filled nor filled emits the
39//   execution but no order snapshot, so a fill arriving after its order's terminal frame
40//   cannot resurrect a retired order as a resting one. That order's filled quantity is
41//   settled instead by the terminal frame's own filled_qty, or by fetch_open_orders.
42// - A trade_bust or trade_correct sent while disconnected is not recovered: fill recovery reads
43//   FILL activities only.
44
45use crate::{
46    AccountEventKind, AccountSnapshot, FillRecoveryFailure, FillRecoveryGap, FillRecoveryScope,
47    InstrumentAccountSnapshot, UnindexedAccountEvent, UnindexedAccountSnapshot,
48    balance::{AssetBalance, Balance},
49    cash_flow::{CashFlow, CashFlowId, CashFlowKind, CashFlowsRead},
50    client::{
51        BracketOrderClient, ExecutionClient, OrderStatusClient, ShortabilityClient,
52        order_recovery::{
53            KnownLiveOrders, NoPendingFills, OpenListing, OrderLookup, SharedKnownLiveOrders,
54            UncheckedOrders, fetch_ended_by_key, recover_ended_orders,
55        },
56    },
57    emit_stream_terminated,
58    error::{
59        ApiError, BorrowReject, BorrowRejectReason, ConnectivityError, OrderError,
60        StreamTerminationReason, UnindexedApiError, UnindexedClientError, UnindexedOrderError,
61    },
62    order::{
63        Order, OrderKey, OrderKind, TimeInForce, TrailingOffsetType, UnindexedInactiveOrder,
64        UnindexedOrderKey, UnindexedOrderSnapshot,
65        bracket::{
66            BracketOrderRequest as UnifiedBracketOrderRequest,
67            BracketOrderResult as UnifiedBracketOrderResult,
68        },
69        id::{ClientOrderId, OrderId, StrategyId, VenueOrderId},
70        request::{OrderRequestCancel, OrderRequestOpen, UnindexedOrderResponseCancel},
71        state::{
72            ActiveOrderState, Cancelled, Expired, Filled, InactiveOrderState, Open, OrderState,
73            UnindexedOrderState,
74        },
75    },
76    parse_env_bool,
77    position::{Position, PositionReport},
78    shortability::Shortability,
79    trade::{AssetFees, Trade, TradeAmendmentKind, TradeId, TradeOrigin, TradesRead},
80};
81use chrono::{DateTime, NaiveDate, NaiveTime, SecondsFormat, TimeDelta, Utc};
82use fnv::{FnvHashMap, FnvHashSet};
83use futures::{SinkExt as _, StreamExt as _, stream::BoxStream};
84use indexmap::IndexMap;
85use itertools::Itertools as _;
86use lru::LruCache;
87use rust_decimal::Decimal;
88use rustrade_instrument::{
89    Side,
90    asset::name::AssetNameExchange,
91    exchange::ExchangeId,
92    instrument::{kind::InstrumentKindDiscriminant, name::InstrumentNameExchange},
93};
94use rustrade_integration::protocol::websocket::{WebSocket, WsMessage};
95use serde::{Deserialize, Serialize, de::DeserializeOwned};
96use smol_str::{SmolStr, format_smolstr};
97use std::{num::NonZeroUsize, pin::Pin, str::FromStr, sync::Arc, time::Duration};
98use tokio::sync::mpsc;
99use tracing::{debug, error, info, trace, warn};
100
101// ---------------------------------------------------------------------------
102// Constants
103// ---------------------------------------------------------------------------
104
105const INITIAL_BACKOFF_MS: u64 = 1_000;
106const MAX_BACKOFF_MS: u64 = 30_000;
107const MAX_RECONNECT_ATTEMPTS: u32 = 10;
108/// If no WS activity for this long, force reconnect.
109const HEARTBEAT_TIMEOUT_SECS: u64 = 35;
110/// Timeout for fill recovery REST queries after reconnect.
111const FILL_RECOVERY_TIMEOUT_SECS: u64 = 30;
112/// Extra lookback from disconnect timestamp to cover Tokio scheduling jitter and
113/// client/server clock drift on cloud VMs. The dedup cache absorbs resulting duplicates.
114const SIGNAL_RECOVERY_LOOKBACK_MS: i64 = 1_500;
115/// Alpaca's activity page size limit.
116const ALPACA_MAX_ACTIVITIES: usize = 100;
117/// Cooldown when rate-limited without a readable wait, and the longest wait read from Alpaca's
118/// headers: the length of its rate-limit window.
119const DEFAULT_RATE_LIMIT_DELAY_SECS: u64 = 60;
120/// Total REST attempts (1 initial + retries) before giving up on rate-limit errors.
121/// The loop runs `0..MAX_RATE_LIMIT_ATTEMPTS`, retrying while `attempt + 1 < MAX`.
122const MAX_RATE_LIMIT_ATTEMPTS: u32 = 4;
123/// Dedup LRU cache size, in keys. Each fill records two (see [`is_duplicate_fill`]), so this holds
124/// the last 2,000 fills. Each key is a ~40–70 byte string (a UUID, or a UUID and a decimal):
125/// 4_000 keys ≈ 250 KB — ample for options trading fill rates.
126const DEDUP_CACHE_SIZE: usize = 4_000;
127
128/// The account currency, named as Alpaca spells it. Every USD balance and fee is reported under
129/// it.
130const USD: &str = "USD";
131/// Timeout for the initial WS auth+subscribe handshake.
132const WS_HANDSHAKE_TIMEOUT_SECS: u64 = 15;
133/// Timeout for a graceful WS close. Prevents indefinite blocking when the
134/// server does not respond to the close frame before reconnect/shutdown.
135const WS_CLOSE_TIMEOUT_SECS: u64 = 5;
136
137// ---------------------------------------------------------------------------
138// GracefulShutdownStream
139// ---------------------------------------------------------------------------
140
141/// Wrapper stream that signals the `connection_manager` task to shut down gracefully
142/// when dropped, allowing it to send an orderly WebSocket close frame.
143///
144/// # Shutdown sequence
145/// Dropping this stream drops `inner` (the channel receiver), which makes `tx.closed()`
146/// resolve on the `connection_manager`'s next `select!` poll. The `tx.closed()` arm
147/// sends a WebSocket close frame (with `WS_CLOSE_TIMEOUT_SECS` timeout) and then
148/// returns, dropping the task cleanly.
149///
150/// `JoinHandle::drop` detaches the task — it is NOT aborted. The task exits within
151/// the current `select!` iteration (if the receiver is already dropped when polled)
152/// or after the current heartbeat window / backoff sleep at most.
153struct GracefulShutdownStream<S> {
154    inner: S,
155    /// Keeps the `JoinHandle` alive until this stream is dropped. Dropping
156    /// the `JoinHandle` detaches (not cancels) the task, allowing it to keep
157    /// running until `tx.closed()` resolves. Without this field the handle
158    /// would be detached immediately at `connection_manager` spawn time,
159    /// preventing any future `.await` or abort if the design changes.
160    _handle: tokio::task::JoinHandle<()>,
161}
162
163impl<S> GracefulShutdownStream<S> {
164    fn new(inner: S, handle: tokio::task::JoinHandle<()>) -> Self {
165        Self {
166            inner,
167            _handle: handle,
168        }
169    }
170}
171
172impl<S: futures::Stream + Unpin> futures::Stream for GracefulShutdownStream<S> {
173    type Item = S::Item;
174    fn poll_next(
175        mut self: Pin<&mut Self>,
176        cx: &mut std::task::Context<'_>,
177    ) -> std::task::Poll<Option<Self::Item>> {
178        Pin::new(&mut self.inner).poll_next(cx)
179    }
180    fn size_hint(&self) -> (usize, Option<usize>) {
181        self.inner.size_hint()
182    }
183}
184
185impl<S> Drop for GracefulShutdownStream<S> {
186    fn drop(&mut self) {
187        // Do not abort. Dropping `self.inner` (the channel receiver) makes `tx.closed()`
188        // resolve, which causes `connection_manager` to send a graceful WS close frame
189        // and return. Dropping the JoinHandle here detaches (not cancels) the task.
190    }
191}
192
193// ---------------------------------------------------------------------------
194// Rate limit tracker
195// ---------------------------------------------------------------------------
196
197/// Thread-safe rate-limit state shared across all clones of AlpacaClient.
198struct RateLimitTracker {
199    blocked_until: parking_lot::Mutex<Option<tokio::time::Instant>>,
200}
201
202impl RateLimitTracker {
203    fn new() -> Self {
204        Self {
205            blocked_until: parking_lot::Mutex::new(None),
206        }
207    }
208
209    /// Sleep until the current cooldown expires. Returns immediately if not blocked.
210    async fn wait_if_blocked(&self) {
211        loop {
212            // Capture the current time once per iteration — reused for both the
213            // expired-deadline check inside the lock and the sleep calculation below.
214            // Eliminates one vDSO call per REST request on the common non-blocked path.
215            let now = tokio::time::Instant::now();
216            // Read and conditionally clear the deadline in a single lock acquisition.
217            // The guard is dropped before the `.await` below — holding a sync Mutex
218            // across an await would deadlock. A TOCTOU window still exists between the
219            // guard drop and `sleep_until`: a concurrent `on_rate_limited` call could
220            // extend the deadline after we read it. The loop re-reads on wake and
221            // corrects any extended deadline, so the race is recovered from on the next
222            // iteration rather than being fully prevented.
223            let deadline = {
224                let mut guard = self.blocked_until.lock();
225                let d = *guard;
226                if matches!(d, Some(t) if t <= now) {
227                    // Clear expired deadline so on_rate_limited correctly
228                    // distinguishes "new rate-limit event" from "extended cooldown".
229                    *guard = None;
230                }
231                d
232            };
233            match deadline {
234                None => return,
235                Some(until) => {
236                    if until <= now {
237                        // Deadline was expired and cleared above; no sleep needed.
238                        return;
239                    }
240                    // as_millis() returns u128; truncation impossible (u64::MAX ms ≈ 584M years)
241                    #[allow(clippy::cast_possible_truncation)]
242                    let delay_ms = (until - now).as_millis() as u64;
243                    debug!(delay_ms, "Alpaca REST rate-limited, waiting before request");
244                    tokio::time::sleep_until(until).await;
245                }
246            }
247        }
248    }
249
250    /// Record a rate-limit event, extending any existing cooldown if longer.
251    fn on_rate_limited(&self, retry_after: Option<Duration>) {
252        let delay = retry_after.unwrap_or(Duration::from_secs(DEFAULT_RATE_LIMIT_DELAY_SECS));
253        let was_blocked = self.extend(delay);
254        if was_blocked {
255            debug!(
256                delay_secs = delay.as_secs(),
257                "Alpaca rate-limit cooldown extended"
258            );
259        } else {
260            warn!(
261                delay_secs = delay.as_secs(),
262                "Alpaca entering rate-limit degradation mode"
263            );
264        }
265    }
266
267    /// Record a response reporting no requests left in the current window: pause every request
268    /// until the window resets, `reset` from now, so the next one is not refused with a 429.
269    fn on_bucket_exhausted(&self, reset: Duration) {
270        let was_blocked = self.extend(reset);
271        // info! not warn!: nothing was refused. A 429 logs at warn.
272        if !was_blocked {
273            info!(
274                delay_ms = u64::try_from(reset.as_millis()).unwrap_or(u64::MAX),
275                "Alpaca rate-limit window exhausted (X-Ratelimit-Remaining: 0), pausing requests until it resets"
276            );
277        }
278    }
279
280    /// Push the cooldown out to `delay` from now unless it already ends later, and return whether
281    /// one was active.
282    fn extend(&self, delay: Duration) -> bool {
283        let now = tokio::time::Instant::now();
284        let new_deadline = now + delay;
285        let mut guard = self.blocked_until.lock();
286        let was_blocked = guard.is_some_and(|until| until > now);
287        *guard = Some(guard.map_or(new_deadline, |existing| existing.max(new_deadline)));
288        was_blocked
289    }
290}
291
292// ---------------------------------------------------------------------------
293// Exponential backoff
294// ---------------------------------------------------------------------------
295
296struct ExponentialBackoff {
297    attempt: u32,
298    max_attempts: u32,
299    initial_ms: u64,
300    max_ms: u64,
301}
302
303impl ExponentialBackoff {
304    fn new() -> Self {
305        Self {
306            attempt: 0,
307            max_attempts: MAX_RECONNECT_ATTEMPTS,
308            initial_ms: INITIAL_BACKOFF_MS,
309            max_ms: MAX_BACKOFF_MS,
310        }
311    }
312
313    fn reset(&mut self) {
314        self.attempt = 0;
315    }
316
317    /// Number of reconnect attempts consumed so far.
318    ///
319    /// After [`wait`](Self::wait) has returned `false` this equals `max_attempts` — the number of
320    /// attempts made before the budget was exhausted. Used to populate
321    /// [`StreamTerminationReason::ReconnectBudgetExhausted`].
322    fn attempts(&self) -> u32 {
323        self.attempt
324    }
325
326    /// Waits for the current backoff duration. Returns `false` if max attempts exhausted.
327    async fn wait(&mut self) -> bool {
328        if self.attempt >= self.max_attempts {
329            return false;
330        }
331        let delay_ms = self
332            .initial_ms
333            .saturating_mul(2u64.saturating_pow(self.attempt))
334            .min(self.max_ms);
335        self.attempt += 1;
336        debug!(
337            attempt = self.attempt,
338            max = self.max_attempts,
339            delay_ms,
340            "Alpaca reconnect backoff"
341        );
342        tokio::time::sleep(Duration::from_millis(delay_ms)).await;
343        true
344    }
345}
346
347// ---------------------------------------------------------------------------
348// Dedup cache
349// ---------------------------------------------------------------------------
350
351/// LRU cache of the fills the account stream has delivered, so that a fill both the WebSocket
352/// and fill recovery deliver, in the overlap around a reconnect, is sent once. See
353/// [`is_duplicate_fill`] for how a fill is recognised.
354type SharedDedupCache = Arc<parking_lot::Mutex<LruCache<FillKey, ()>>>;
355
356/// One key under which [`SharedDedupCache`] records a fill.
357///
358/// [`SmolStr`] keys avoid heap allocation for ids ≤22 bytes. UUID-length keys (36 chars) always
359/// heap-allocate.
360#[derive(Debug, Clone, PartialEq, Eq, Hash)]
361enum FillKey {
362    /// The fill's execution id: the WS frame's `execution_id`, or the UUID after `::` in its FILL
363    /// activity's id ([`activity_execution_id_part`]).
364    Execution(SmolStr),
365    /// The fill's [`fill_dedup_key`], recorded for every fill.
366    Cumulative(SmolStr),
367    /// The fill's [`fill_dedup_key`], recorded only for a fill without a usable execution id.
368    CumulativeWithoutId(SmolStr),
369}
370
371fn new_dedup_cache() -> SharedDedupCache {
372    // allow(clippy::unwrap_used) — NonZeroUsize::new on a non-zero constant
373    // cannot fail at runtime.
374    #[allow(clippy::unwrap_used)]
375    Arc::new(parking_lot::Mutex::new(LruCache::new(
376        NonZeroUsize::new(DEDUP_CACHE_SIZE).unwrap(),
377    )))
378}
379
380/// Returns `true` if this fill was already delivered (duplicate). Records it if not.
381///
382/// `execution_id` is the fill's execution id, `None` when it has no usable one; `cumulative` is
383/// its [`fill_dedup_key`].
384///
385/// A fill is identified by its execution id, which both paths carry and which Alpaca's activity
386/// docs name as the dedup key. The cumulative key alone would not do: a bust lowers an order's
387/// cumulative, after which a new fill can take it back to a cumulative an earlier fill reached,
388/// and that fill would be taken for the earlier one and dropped.
389///
390/// A fill without an execution id is recognised by its cumulative key instead. So that it still
391/// matches the same fill delivered *with* an id by the other path, every fill records its
392/// cumulative key, and a fill with an id is also checked against the cumulative keys of fills
393/// recorded without one.
394///
395/// So after a bust, a new fill can still be taken for an earlier one at the same cumulative when
396/// either lacks an execution id: a fill without one matches any earlier fill's cumulative key,
397/// and a fill with one matches an earlier id-less fill's. An id-less WS fill is logged at `warn!`
398/// by [`convert_trade_update`].
399fn is_duplicate_fill(
400    cache: &SharedDedupCache,
401    execution_id: Option<&str>,
402    cumulative: SmolStr,
403) -> bool {
404    // Built before locking, so the lock is not held across the allocation.
405    let execution = execution_id.map(|id| FillKey::Execution(SmolStr::new(id)));
406    let mut guard = cache.lock();
407    // peek avoids promoting to MRU on the duplicate (discard) path
408    match execution {
409        Some(execution) => {
410            if guard.peek(&execution).is_some()
411                || guard
412                    .peek(&FillKey::CumulativeWithoutId(cumulative.clone()))
413                    .is_some()
414            {
415                return true;
416            }
417            guard.put(execution, ());
418            guard.put(FillKey::Cumulative(cumulative), ());
419        }
420        None => {
421            if guard
422                .peek(&FillKey::Cumulative(cumulative.clone()))
423                .is_some()
424            {
425                return true;
426            }
427            guard.put(FillKey::Cumulative(cumulative.clone()), ());
428            guard.put(FillKey::CumulativeWithoutId(cumulative), ());
429        }
430    }
431    false
432}
433
434/// Record an execution delivered other than as a fill, so that fill recovery does not deliver it
435/// again as one.
436fn record_execution(cache: &SharedDedupCache, execution_id: &str) {
437    let execution = FillKey::Execution(SmolStr::new(execution_id));
438    cache.lock().put(execution, ());
439}
440
441// ---------------------------------------------------------------------------
442// Configuration
443// ---------------------------------------------------------------------------
444
445/// Configuration for the Alpaca execution client.
446// Serialize intentionally omitted — would expose secret_key in plaintext.
447#[derive(Clone, Deserialize)]
448#[serde(deny_unknown_fields)]
449pub struct AlpacaConfig {
450    // Private fields prevent accidental credential exposure via struct access.
451    api_key: String,
452    secret_key: String,
453
454    /// Use paper trading endpoints instead of production.
455    #[serde(default = "default_paper")]
456    pub paper: bool,
457    /// Test-only: override the REST base URL (e.g., to point at a wiremock server).
458    #[cfg(test)]
459    pub base_url_override: Option<String>,
460    /// Test-only: override the WebSocket URL (e.g., to point at a local server).
461    #[cfg(test)]
462    #[serde(skip)]
463    ws_url_override: Option<String>,
464}
465
466/// Serde default for [`AlpacaConfig::paper`]: an absent `paper` field deserializes to the **safe**
467/// paper environment (`true`).
468///
469/// `#[serde(default = "…")]` requires a named function (it cannot take a literal), so this exists
470/// purely to supply that default to the derive.
471fn default_paper() -> bool {
472    true
473}
474
475// Custom Debug to avoid leaking credentials in logs.
476impl std::fmt::Debug for AlpacaConfig {
477    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
478        f.debug_struct("AlpacaConfig")
479            .field("api_key", &"***")
480            .field("secret_key", &"***")
481            .field("paper", &self.paper)
482            .finish()
483    }
484}
485
486impl AlpacaConfig {
487    /// Create a new Alpaca config using the **safe** paper-trading endpoints (alias for
488    /// [`paper`](Self::paper)).
489    pub fn new(api_key: String, secret_key: String) -> Self {
490        Self::paper(api_key, secret_key)
491    }
492
493    /// Create a config targeting Alpaca's **paper-trading** endpoints (simulated funds).
494    pub fn paper(api_key: String, secret_key: String) -> Self {
495        Self {
496            api_key,
497            secret_key,
498            paper: true,
499            #[cfg(test)]
500            base_url_override: None,
501            #[cfg(test)]
502            ws_url_override: None,
503        }
504    }
505
506    /// Create a config targeting Alpaca's **production** endpoints.
507    ///
508    /// ⚠️ Production trades execute against the live account with **real funds**. Prefer
509    /// [`paper`](Self::paper) unless you explicitly intend live execution.
510    pub fn production(api_key: String, secret_key: String) -> Self {
511        Self {
512            api_key,
513            secret_key,
514            paper: false,
515            #[cfg(test)]
516            base_url_override: None,
517            #[cfg(test)]
518            ws_url_override: None,
519        }
520    }
521
522    /// Build a config from environment variables.
523    ///
524    /// Reads:
525    /// - `ALPACA_API_KEY` (required) — API key id.
526    /// - `ALPACA_SECRET_KEY` (required) — API secret.
527    /// - `ALPACA_PAPER` (optional) — `"true"`/`"false"` (case-insensitive). **Absent ⇒ the safe
528    ///   paper environment.** Set `ALPACA_PAPER=false` to target production (real funds).
529    ///
530    /// # Errors
531    ///
532    /// Returns [`AlpacaConfigError`] (never panics):
533    /// - a required credential var is unset ([`MissingApiKey`](AlpacaConfigError::MissingApiKey) /
534    ///   [`MissingSecretKey`](AlpacaConfigError::MissingSecretKey)) or holds non-UTF-8
535    ///   ([`InvalidApiKey`](AlpacaConfigError::InvalidApiKey) /
536    ///   [`InvalidSecretKey`](AlpacaConfigError::InvalidSecretKey));
537    /// - `ALPACA_PAPER` is neither `true` nor `false`, or holds non-UTF-8
538    ///   ([`InvalidPaper`](AlpacaConfigError::InvalidPaper)).
539    pub fn from_env() -> Result<Self, AlpacaConfigError> {
540        let api_key = match std::env::var("ALPACA_API_KEY") {
541            Ok(value) => value,
542            Err(std::env::VarError::NotPresent) => return Err(AlpacaConfigError::MissingApiKey),
543            Err(std::env::VarError::NotUnicode(_)) => {
544                return Err(AlpacaConfigError::InvalidApiKey);
545            }
546        };
547        let secret_key = match std::env::var("ALPACA_SECRET_KEY") {
548            Ok(value) => value,
549            Err(std::env::VarError::NotPresent) => return Err(AlpacaConfigError::MissingSecretKey),
550            Err(std::env::VarError::NotUnicode(_)) => {
551                return Err(AlpacaConfigError::InvalidSecretKey);
552            }
553        };
554
555        let paper = match std::env::var("ALPACA_PAPER") {
556            Ok(value) => parse_env_bool(&value).ok_or(AlpacaConfigError::InvalidPaper(value))?,
557            Err(std::env::VarError::NotPresent) => true,
558            // The toggle value is not secret, so echo it (lossily) like the parse-failure arm above —
559            // an actionable "got X" beats a hardcoded sentinel.
560            Err(std::env::VarError::NotUnicode(value)) => {
561                return Err(AlpacaConfigError::InvalidPaper(
562                    value.to_string_lossy().into_owned(),
563                ));
564            }
565        };
566
567        if paper {
568            Ok(Self::paper(api_key, secret_key))
569        } else {
570            Ok(Self::production(api_key, secret_key))
571        }
572    }
573
574    /// Test-only: create config with a custom base URL for wiremock testing.
575    #[cfg(test)]
576    pub fn with_base_url(api_key: String, secret_key: String, base_url: String) -> Self {
577        Self {
578            api_key,
579            secret_key,
580            paper: true,
581            base_url_override: Some(base_url),
582            ws_url_override: None,
583        }
584    }
585
586    pub fn api_key(&self) -> &str {
587        &self.api_key
588    }
589
590    /// Base URL for REST API calls.
591    ///
592    /// In test builds, checks `base_url_override` first to allow wiremock testing.
593    pub fn rest_base_url(&self) -> &str {
594        #[cfg(test)]
595        if let Some(ref url) = self.base_url_override {
596            return url.as_str();
597        }
598        if self.paper {
599            "https://paper-api.alpaca.markets"
600        } else {
601            "https://api.alpaca.markets"
602        }
603    }
604
605    /// WebSocket URL for trade_updates stream.
606    pub fn ws_url(&self) -> &'static str {
607        if self.paper {
608            "wss://paper-api.alpaca.markets/stream"
609        } else {
610            "wss://api.alpaca.markets/stream"
611        }
612    }
613
614    /// The URL the account stream connects to: [`ws_url`](Self::ws_url), or in test builds
615    /// `ws_url_override` when set.
616    fn stream_url(&self) -> &str {
617        #[cfg(test)]
618        if let Some(ref url) = self.ws_url_override {
619            return url.as_str();
620        }
621        self.ws_url()
622    }
623}
624
625#[derive(Debug, PartialEq, thiserror::Error)]
626pub enum AlpacaConfigError {
627    #[error("ALPACA_API_KEY environment variable not set")]
628    MissingApiKey,
629
630    // No payload: the raw value is secret-key material, so it must never be echoed into an error
631    // message or log. The variant name already identifies which credential var is non-UTF-8.
632    #[error("ALPACA_API_KEY environment variable is not valid UTF-8")]
633    InvalidApiKey,
634
635    #[error("ALPACA_SECRET_KEY environment variable not set")]
636    MissingSecretKey,
637
638    #[error("ALPACA_SECRET_KEY environment variable is not valid UTF-8")]
639    InvalidSecretKey,
640
641    #[error("ALPACA_PAPER must be true or false, got {0}")]
642    InvalidPaper(String),
643}
644
645// ---------------------------------------------------------------------------
646// REST response serde types
647// ---------------------------------------------------------------------------
648
649/// The fields of GET /v2/account the USD balance is built from.
650///
651/// Amounts are decoded as decimals, so a malformed one fails the request instead of reading as
652/// zero.
653#[derive(Debug, Deserialize)]
654struct AlpacaAccount {
655    /// Settled and unsettled cash. Negative when the account has borrowed on margin; a short
656    /// sale's proceeds are credited to it.
657    #[serde(with = "rust_decimal::serde::str")]
658    cash: Decimal,
659    /// Buying power for securities that cannot be bought on margin. On a margin account it is
660    /// equity less the initial margin requirement, so it counts the loan value of held
661    /// marginable stock and can exceed cash; a short sale's proceeds do not raise it.
662    #[serde(with = "rust_decimal::serde::str")]
663    non_marginable_buying_power: Decimal,
664}
665
666/// A single position returned by GET /v2/positions.
667///
668/// Amounts are decoded as decimals, so a malformed one fails the request instead of reading as
669/// zero or as a flat position.
670#[derive(Debug, Deserialize)]
671struct AlpacaPosition {
672    /// Exchange symbol (e.g., "BTCUSD" for crypto, "AAPL" for equity, an OCC symbol for an
673    /// option).
674    symbol: String,
675    /// Asset class: "us_equity", "crypto", "us_option".
676    asset_class: String,
677    /// Direction of the position. The size is taken from `qty`'s magnitude and the sign from
678    /// this, so a short reads as short whichever sign `qty` carries. Missing reads as
679    /// [`AlpacaPositionSide::Unknown`].
680    #[serde(default)]
681    side: AlpacaPositionSide,
682    /// Quantity held: shares, contracts, or base currency for crypto.
683    #[serde(with = "rust_decimal::serde::str")]
684    qty: Decimal,
685    /// Quantity available to trade (not locked in open orders).
686    #[serde(with = "rust_decimal::serde::str")]
687    qty_available: Decimal,
688    /// Average entry price per unit.
689    #[serde(default, with = "rust_decimal::serde::str_option")]
690    avg_entry_price: Option<Decimal>,
691    /// Unrealised profit or loss of the whole position, in USD.
692    #[serde(default, with = "rust_decimal::serde::str_option")]
693    unrealized_pl: Option<Decimal>,
694}
695
696/// Direction of an [`AlpacaPosition`].
697#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize)]
698#[serde(rename_all = "lowercase")]
699enum AlpacaPositionSide {
700    Long,
701    Short,
702    /// A missing side or a value Alpaca does not document. Decoded rather than rejected so that
703    /// one position cannot fail the whole response, which also carries the crypto balances; a
704    /// position that needs a direction fails in [`convert_positions`] instead.
705    #[default]
706    #[serde(other)]
707    Unknown,
708}
709
710#[derive(Debug, Deserialize)]
711struct AlpacaOrderResponse {
712    id: String,
713    client_order_id: Option<String>,
714    symbol: String,
715    qty: Option<String>,
716    filled_qty: String,
717    /// The average price of the order's fills, when any filled.
718    #[serde(default)]
719    filled_avg_price: Option<String>,
720    /// The order's status: `new`, `filled`, `canceled`, and so on. Read by the lookup of how an
721    /// order ended and from the response to placing one. Optional so that a response without it
722    /// still decodes.
723    #[serde(default)]
724    status: Option<String>,
725    side: String,
726    #[serde(rename = "type")]
727    order_type: String,
728    time_in_force: String,
729    limit_price: Option<String>,
730    stop_price: Option<String>,
731    trail_percent: Option<String>,
732    trail_price: Option<String>,
733    created_at: String,
734    /// When the order last changed state. Nullable in Alpaca's schema, so callers fall back to
735    /// `created_at` via [`order_state_time`].
736    updated_at: Option<String>,
737}
738
739#[derive(Debug, Deserialize)]
740struct AlpacaActivity {
741    id: String,
742    order_id: String,
743    symbol: String,
744    side: String,
745    price: String,
746    qty: String,
747    transaction_time: String,
748    /// The order's cumulative filled quantity as of this execution, as Alpaca reported it --
749    /// the same figure the WebSocket path reads from `order.filled_qty`.
750    ///
751    /// Alpaca's schema lists this on trade activities without qualifying by fill type -- unlike
752    /// `leaves_qty`, which it documents as partial-fill-specific. It is parsed as optional
753    /// regardless, so an omission degrades to the fallback below rather than discarding the
754    /// whole activity.
755    cum_qty: Option<String>,
756}
757
758/// A non-trade account activity, as Alpaca's activities endpoint serves it: a cash movement such as
759/// interest or a fee, read by [`AlpacaClient::fetch_cash_flows`], or an option exercise,
760/// assignment or expiry, read by [`AlpacaClient::fetch_option_events`]. A cash flow uses the
761/// amount, currency and symbol; an option event uses the quantity, price, symbol and date. Every
762/// field but the id and type is optional in Alpaca's schema, so a record missing one still decodes
763/// and the conversion decides what it can do without it.
764#[derive(Debug, Deserialize)]
765struct AlpacaNonTradeActivity {
766    id: String,
767    activity_type: String,
768    #[serde(default)]
769    activity_sub_type: Option<String>,
770    /// When Alpaca created the activity, which its `after` and `until` filter on. For a fee, this
771    /// is typically the day after the trade it was charged on.
772    #[serde(default)]
773    created_at: Option<String>,
774    /// The day the activity occurred or settled, as `YYYY-MM-DD`. A cash flow's time uses
775    /// `created_at`, which `after` and `until` filter on. An option event falls back to it when
776    /// `created_at` is missing, and pairs the legs of an exercise or assignment by it.
777    #[serde(default)]
778    date: Option<String>,
779    /// Signed: negative when the account paid.
780    #[serde(default)]
781    net_amount: Option<String>,
782    /// Signed: negative when the account sold. Contracts on an option event, or shares on the
783    /// share leg (`OPTRD`) of an exercise or assignment.
784    #[serde(default)]
785    qty: Option<String>,
786    /// On the share leg (`OPTRD`) of an exercise or assignment, the strike the shares traded at.
787    #[serde(default)]
788    price: Option<String>,
789    #[serde(default)]
790    currency: Option<String>,
791    #[serde(default)]
792    symbol: Option<String>,
793    /// `executed`, `correct` or `canceled`.
794    #[serde(default)]
795    status: Option<String>,
796}
797
798/// The body of an Alpaca error response: `{"code": 40310000, "message": "..."}`.
799///
800/// `code` is absent from some responses, notably the bare `{"message": "forbidden."}` that a
801/// wrong key, secret or host gets.
802#[derive(Debug, Deserialize)]
803struct AlpacaApiError {
804    code: Option<i64>,
805    message: String,
806}
807
808impl AlpacaApiError {
809    /// Decode an error body, keeping the raw text as the message when it is not Alpaca's JSON.
810    fn from_body(bytes: &[u8]) -> Self {
811        serde_json::from_slice(bytes).unwrap_or_else(|_| Self {
812            code: None,
813            message: String::from_utf8_lossy(bytes).into_owned(),
814        })
815    }
816}
817
818// ---------------------------------------------------------------------------
819// AlpacaPositionIntent
820// ---------------------------------------------------------------------------
821
822/// Explicit position intent for Alpaca order placement.
823///
824/// Required for options orders; valid (but optional) for equities.
825/// Omit entirely for crypto orders (causes 422 Unprocessable Entity).
826///
827/// Use `AlpacaClient::open_order_with_intent` to supply a specific intent
828/// instead of the heuristic mapping used by the `ExecutionClient` trait impl.
829#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
830#[serde(rename_all = "snake_case")]
831pub enum AlpacaPositionIntent {
832    BuyToOpen,
833    BuyToClose,
834    SellToOpen,
835    SellToClose,
836}
837
838// ---------------------------------------------------------------------------
839// Bracket order types
840// ---------------------------------------------------------------------------
841
842/// Take-profit parameters for Alpaca bracket orders.
843///
844/// The take-profit leg is always a limit order at the specified price.
845#[derive(Debug, Serialize)]
846struct TakeProfitParams {
847    limit_price: String,
848}
849
850/// Stop-loss parameters for Alpaca bracket orders.
851///
852/// When `limit_price` is `None`, the stop-loss is a stop (market) order.
853/// When `limit_price` is `Some`, the stop-loss becomes a stop-limit order.
854#[derive(Debug, Serialize)]
855struct StopLossParams {
856    stop_price: String,
857    #[serde(skip_serializing_if = "Option::is_none")]
858    limit_price: Option<String>,
859}
860
861/// Request to place a bracket order (entry + take-profit + stop-loss).
862///
863/// A bracket order consists of three linked orders submitted in a single request:
864/// 1. **Entry**: Limit order to enter the position
865/// 2. **Take Profit**: Limit order to exit at profit target
866/// 3. **Stop Loss**: Stop or stop-limit order to exit at loss limit
867///
868/// When either the take-profit or stop-loss fills, Alpaca automatically cancels
869/// the other leg.
870///
871/// # Constraints
872///
873/// - `time_in_force` must be `Day` or `GoodUntilCancelled` (no extended hours)
874/// - Entry order type is always `Limit`
875/// - Take-profit is always a `Limit` order
876/// - Stop-loss is a `Stop` order (or `StopLimit` if `stop_loss_limit_price` is set)
877///
878/// # Example
879///
880/// ```ignore
881/// let request = AlpacaBracketOrderRequest::new(
882///     "AAPL".into(),
883///     StrategyId::new("momentum"),
884///     ClientOrderId::new("bracket-001"),
885///     Side::Buy,
886///     dec!(10),
887///     dec!(150.00),  // entry
888///     dec!(160.00),  // take profit
889///     dec!(145.00),  // stop loss
890///     TimeInForce::GoodUntilCancelled { post_only: false },
891/// );
892/// // For stop-limit SL: .with_stop_loss_limit_price(dec!(144.00))
893/// let result = client.open_bracket_order(request).await;
894/// ```
895#[non_exhaustive]
896#[derive(Debug, Clone)]
897pub struct AlpacaBracketOrderRequest {
898    /// Instrument to trade.
899    pub instrument: InstrumentNameExchange,
900    /// Strategy identifier for order correlation.
901    pub strategy: StrategyId,
902    /// Client order ID for the parent (entry) order.
903    pub cid: ClientOrderId,
904    /// Buy or Sell for the entry order (exits use opposite side).
905    pub side: Side,
906    /// Number of shares/contracts.
907    pub quantity: Decimal,
908    /// Entry limit price.
909    pub entry_price: Decimal,
910    /// Take-profit limit price.
911    pub take_profit_price: Decimal,
912    /// Stop-loss trigger price.
913    pub stop_loss_price: Decimal,
914    /// Optional stop-loss limit price. When set, makes the stop-loss a stop-limit order.
915    pub stop_loss_limit_price: Option<Decimal>,
916    /// Time-in-force for all legs. Must be `Day` or `GoodUntilCancelled`.
917    pub time_in_force: TimeInForce,
918}
919
920impl AlpacaBracketOrderRequest {
921    /// Create a new bracket order request.
922    ///
923    /// For a stop-limit stop-loss leg, chain `.with_stop_loss_limit_price()`.
924    #[allow(clippy::too_many_arguments)] // Bracket orders inherently need many params
925    pub fn new(
926        instrument: InstrumentNameExchange,
927        strategy: StrategyId,
928        cid: ClientOrderId,
929        side: Side,
930        quantity: Decimal,
931        entry_price: Decimal,
932        take_profit_price: Decimal,
933        stop_loss_price: Decimal,
934        time_in_force: TimeInForce,
935    ) -> Self {
936        Self {
937            instrument,
938            strategy,
939            cid,
940            side,
941            quantity,
942            entry_price,
943            take_profit_price,
944            stop_loss_price,
945            stop_loss_limit_price: None,
946            time_in_force,
947        }
948    }
949
950    /// Set the stop-loss limit price, converting the SL leg to a stop-limit order.
951    #[must_use]
952    pub fn with_stop_loss_limit_price(mut self, price: Decimal) -> Self {
953        self.stop_loss_limit_price = Some(price);
954        self
955    }
956}
957
958/// Result of placing an Alpaca bracket order.
959///
960/// Contains the parent order with its state. The take-profit and stop-loss legs
961/// are managed by Alpaca and their status can be queried via `fetch_open_orders`.
962///
963/// # Note
964///
965/// Unlike IBKR which returns three separate orders, Alpaca's bracket API returns
966/// a single parent order. The child legs (TP/SL) are implicitly created and linked
967/// by Alpaca. Use `fetch_open_orders` to retrieve all legs after placement.
968#[non_exhaustive]
969#[derive(Debug, Clone)]
970pub struct AlpacaBracketOrderResult {
971    /// Parent (entry) order with its current state.
972    pub parent: Order<ExchangeId, InstrumentNameExchange, UnindexedOrderState>,
973}
974
975// ---------------------------------------------------------------------------
976// REST order request body
977// ---------------------------------------------------------------------------
978
979#[derive(Debug, Serialize)]
980struct AlpacaOrderRequest<'a> {
981    symbol: &'a str,
982    qty: String,
983    side: &'static str,
984    #[serde(rename = "type")]
985    order_type: &'static str,
986    time_in_force: &'static str,
987    #[serde(skip_serializing_if = "Option::is_none")]
988    limit_price: Option<String>,
989    #[serde(skip_serializing_if = "Option::is_none")]
990    stop_price: Option<String>,
991    #[serde(skip_serializing_if = "Option::is_none")]
992    trail_percent: Option<String>,
993    #[serde(skip_serializing_if = "Option::is_none")]
994    trail_price: Option<String>,
995    #[serde(skip_serializing_if = "Option::is_none")]
996    client_order_id: Option<&'a str>,
997    // position_intent: heuristic mapping (buy→buy_to_open, sell→sell_to_close).
998    // Correct for directional long-only strategies. Omit for exchanges/asset
999    // classes that don't require it (stocks/crypto ignore this field).
1000    #[serde(skip_serializing_if = "Option::is_none")]
1001    position_intent: Option<AlpacaPositionIntent>,
1002    // Bracket order fields (order_class, take_profit, stop_loss).
1003    // Set order_class to "bracket" and populate TP/SL for bracket orders.
1004    #[serde(skip_serializing_if = "Option::is_none")]
1005    order_class: Option<&'static str>,
1006    #[serde(skip_serializing_if = "Option::is_none")]
1007    take_profit: Option<TakeProfitParams>,
1008    #[serde(skip_serializing_if = "Option::is_none")]
1009    stop_loss: Option<StopLossParams>,
1010}
1011
1012// ---------------------------------------------------------------------------
1013// WebSocket message types
1014// ---------------------------------------------------------------------------
1015
1016/// Outer container for all Alpaca stream messages.
1017///
1018/// `data` is kept as a [`serde_json::value::RawValue`] to avoid allocating a full DOM tree
1019/// for heartbeats and auth/listening acks that never reach `AlpacaTradeUpdate` parsing.
1020#[derive(Debug, Deserialize)]
1021struct AlpacaStreamMessage<'a> {
1022    // Short well-known values ("trade_updates", "listening", "authorization")
1023    // all fit inline in SmolStr — avoids one heap alloc per WS message.
1024    stream: SmolStr,
1025    #[serde(borrow)]
1026    data: &'a serde_json::value::RawValue,
1027}
1028
1029/// Parsed payload of a `trade_updates` event.
1030///
1031/// Numeric and timestamp fields borrow directly from the `RawValue` input buffer
1032/// (`#[serde(borrow)]`), propagating the zero-copy design of `AlpacaStreamMessage`.
1033/// This eliminates 4–6 heap allocations per fill event. The borrow is valid because
1034/// Alpaca's numeric and timestamp strings, and its execution ids (UUIDs), contain no JSON escape
1035/// sequences.
1036#[derive(Debug, Deserialize)]
1037struct AlpacaTradeUpdate<'a> {
1038    // Short event tag ("fill", "partial_fill", "new", ...) — fits inline.
1039    event: SmolStr,
1040    #[serde(borrow)]
1041    order: AlpacaOrderWs<'a>,
1042    /// Fill price for this specific execution (None for non-fill events).
1043    #[serde(borrow)]
1044    price: Option<&'a str>,
1045    /// Quantity for this specific execution (None for non-fill events).
1046    #[serde(borrow)]
1047    qty: Option<&'a str>,
1048    #[serde(borrow)]
1049    timestamp: Option<&'a str>,
1050    /// Alpaca's id for this event. On a fill it is the execution's id, the same as the UUID after
1051    /// `::` in that fill's FILL activity id (see [`activity_execution_id`]), so it is the fill's
1052    /// [`TradeId`] on every path. On a `trade_correct` it is taken as the corrected execution's id.
1053    #[serde(borrow)]
1054    execution_id: Option<&'a str>,
1055    /// On `trade_bust` and `trade_correct` only: the `execution_id` of the fill busted or
1056    /// corrected, per Alpaca's Broker API schema (`TradeUpdateEventV2`). The Trading API's docs do
1057    /// not list either event, so this is unverified against a real frame.
1058    #[serde(borrow)]
1059    previous_execution_id: Option<&'a str>,
1060}
1061
1062/// Order state embedded in a `trade_updates` WebSocket event.
1063#[derive(Debug, Deserialize)]
1064struct AlpacaOrderWs<'a> {
1065    // UUIDs (36 chars) heap-allocate in SmolStr, but using SmolStr directly avoids
1066    // an intermediate String allocation when serde deserialises the field.
1067    id: SmolStr,
1068    client_order_id: Option<SmolStr>,
1069    // Ticker symbols ("AAPL", "BTC/USD") fit inline in SmolStr (≤23 bytes),
1070    // eliminating the heap allocation entirely for most symbols.
1071    symbol: SmolStr,
1072    #[serde(borrow)]
1073    qty: Option<&'a str>,
1074    // Alpaca guarantees `filled_qty` for fill/partial_fill and most lifecycle
1075    // events, but some event types (e.g. `rejected`) may omit the field.
1076    // Using Option avoids a deserialization failure that would silently drop
1077    // the event. A snapshot of a live order reads its absence as nothing filled
1078    // yet; a cancel reads it as unknown; the dedup key, and a fill's fallback trade id, read it
1079    // as "0".
1080    #[serde(borrow)]
1081    filled_qty: Option<&'a str>,
1082    // Short enums ("buy"/"sell", "market"/"limit"/..., "day"/"gtc"/..., status)
1083    // — all fit inline in SmolStr.
1084    side: SmolStr,
1085    #[serde(rename = "type")]
1086    order_type: SmolStr,
1087    time_in_force: SmolStr,
1088    #[serde(borrow)]
1089    limit_price: Option<&'a str>,
1090    #[serde(borrow)]
1091    stop_price: Option<&'a str>,
1092    #[serde(borrow)]
1093    trail_percent: Option<&'a str>,
1094    #[serde(borrow)]
1095    trail_price: Option<&'a str>,
1096    status: SmolStr,
1097}
1098
1099// ---------------------------------------------------------------------------
1100// AlpacaClient
1101// ---------------------------------------------------------------------------
1102
1103/// Alpaca execution client supporting options, equities, and crypto via the
1104/// single unified Alpaca trading API.
1105///
1106/// All three asset classes share the same REST and WebSocket endpoints. The
1107/// key behavioral differences handled transparently:
1108/// - Options: `position_intent` field is required (detected by OCC symbol format)
1109/// - Crypto: `position_intent` is omitted (not a valid field for crypto orders);
1110///   fractional quantities are supported natively via `Decimal::to_string()`
1111/// - Equities: `position_intent` is valid but optional for long-only strategies
1112///
1113/// # Rate limits
1114/// Once a response reports no requests left in the current window (`X-Ratelimit-Remaining: 0`),
1115/// every request, orders included, waits until the window resets (`X-Ratelimit-Reset`, at most a
1116/// minute away), so it is not refused. A 429 waits the same way, for the wait Alpaca gives or a
1117/// minute without one, and is retried. Once the retries are exhausted the request fails with
1118/// [`ApiError::RateLimit`], carrying that wait, and later requests still wait for it.
1119///
1120/// # Fees, interest and borrow fees
1121/// Every [`Trade`] this client reports carries zero fees. Equities and options trade without
1122/// commission. Alpaca's fill events carry no fee for crypto either: it is taken from the asset
1123/// the trade credits, so it shows only in that asset's balance.
1124///
1125/// Alpaca posts its other charges, such as margin interest and fees, to the account's cash as
1126/// account activities. A USD balance's `total` is that cash, so it includes a charge once Alpaca
1127/// has posted it. [`ExecutionClient::account_stream`] delivers none of them, nor any balance:
1128/// Alpaca streams neither. [`AlpacaClient::fetch_cash_flows`] reads them on request. PnL computed
1129/// from fills excludes them.
1130///
1131/// Cloning is cheap: all inner state is behind `Arc`.
1132#[derive(Clone)]
1133pub struct AlpacaClient {
1134    config: Arc<AlpacaConfig>,
1135    /// reqwest client with APCA-API-KEY-ID and APCA-API-SECRET-KEY pre-set as
1136    /// default headers — every request carries auth automatically.
1137    http: reqwest::Client,
1138    rate_limiter: Arc<RateLimitTracker>,
1139    /// Pre-allocated `/v2/orders` endpoint URL to avoid allocation per request.
1140    orders_url: String,
1141    /// The orders seen live and not yet seen end, which a reconnect asks about. Shared by every
1142    /// clone and every account stream, since an order placed through one ends on any of them.
1143    known_live: SharedKnownLiveOrders,
1144}
1145
1146impl std::fmt::Debug for AlpacaClient {
1147    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1148        f.debug_struct("AlpacaClient")
1149            .field("paper", &self.config.paper)
1150            .finish_non_exhaustive()
1151    }
1152}
1153
1154impl AlpacaClient {
1155    /// Build a `reqwest::Client` with Alpaca auth headers pre-set.
1156    ///
1157    /// # Panics
1158    ///
1159    /// Panics if the API key or secret contains characters that are invalid
1160    /// in an HTTP header value (non-ASCII or control characters).
1161    #[allow(clippy::expect_used)] // Documented panic: invalid credentials detected at startup
1162    fn build_http(config: &AlpacaConfig) -> reqwest::Client {
1163        use reqwest::header::{HeaderMap, HeaderName, HeaderValue};
1164        let mut headers = HeaderMap::new();
1165        // HTTP header names are case-insensitive; use lowercase per HTTP/2 convention.
1166        headers.insert(
1167            HeaderName::from_static("apca-api-key-id"),
1168            HeaderValue::from_str(&config.api_key)
1169                .expect("Alpaca API key contains invalid header characters"),
1170        );
1171        headers.insert(
1172            HeaderName::from_static("apca-api-secret-key"),
1173            HeaderValue::from_str(&config.secret_key)
1174                .expect("Alpaca secret key contains invalid header characters"),
1175        );
1176        reqwest::Client::builder()
1177            .default_headers(headers)
1178            .build()
1179            .expect("failed to build reqwest client for Alpaca")
1180    }
1181
1182    fn base_url(&self) -> &str {
1183        self.config.rest_base_url()
1184    }
1185
1186    /// Hold each of `orders` as live, for a reconnect to ask about if the stream misses its end.
1187    fn remember_live<'a>(
1188        &self,
1189        orders: impl IntoIterator<Item = &'a Order<ExchangeId, InstrumentNameExchange, Open>>,
1190    ) {
1191        let mut known = self.known_live.lock();
1192        for order in orders {
1193            known.live(&order.key, order.quantity, order.kind, &order.state);
1194        }
1195    }
1196}
1197
1198// ---------------------------------------------------------------------------
1199// REST helper: rate-limited request with retry
1200// ---------------------------------------------------------------------------
1201
1202/// How long Alpaca asks a rate-limited client to wait, from a 429's headers.
1203///
1204/// `Retry-After`, a delay in seconds, when present: taken as given, zero included, and not read in
1205/// its HTTP-date form. Otherwise `X-RateLimit-Reset`, which Alpaca documents: a Unix timestamp in
1206/// seconds, read as the time from now until then, at least 1 s so a reset already past never
1207/// busy-loops. `None` when neither header is readable; callers fall back to
1208/// [`DEFAULT_RATE_LIMIT_DELAY_SECS`].
1209///
1210/// Either is capped at [`DEFAULT_RATE_LIMIT_DELAY_SECS`], the length of Alpaca's window: a longer
1211/// wait is a bad header or clock skew, and must not hold orders back for longer, nor overflow the
1212/// cooldown's deadline.
1213///
1214/// The data crate's Alpaca REST client reads a 429 by the same rule.
1215fn parse_rate_limit_delay(headers: &reqwest::header::HeaderMap) -> Option<Duration> {
1216    let header = |name: &str| {
1217        headers
1218            .get(name)
1219            .and_then(|value| value.to_str().ok())
1220            .and_then(|value| value.trim().parse::<u64>().ok())
1221    };
1222
1223    let seconds = match header("retry-after") {
1224        Some(seconds) => seconds,
1225        None => {
1226            let reset = header("x-ratelimit-reset")?;
1227            let now = std::time::SystemTime::now()
1228                .duration_since(std::time::UNIX_EPOCH)
1229                .unwrap_or_default()
1230                .as_secs();
1231            reset.saturating_sub(now).max(1)
1232        }
1233    };
1234    Some(Duration::from_secs(
1235        seconds.min(DEFAULT_RATE_LIMIT_DELAY_SECS),
1236    ))
1237}
1238
1239/// Pause every request until the window resets when a response reports none remaining in it.
1240///
1241/// The pause is at most [`DEFAULT_RATE_LIMIT_DELAY_SECS`], the length of Alpaca's window, as
1242/// [`parse_rate_limit_delay`] caps it. Call on
1243/// any response except a 429, which sets its own cooldown. Without an
1244/// `X-Ratelimit-Reset` there is nothing to pause until, so the next request goes ahead and a 429,
1245/// if it comes, backs off.
1246fn observe_rate_limit_remaining(
1247    rate_limiter: &RateLimitTracker,
1248    headers: &reqwest::header::HeaderMap,
1249) {
1250    let exhausted = headers
1251        .get("x-ratelimit-remaining")
1252        .and_then(|v| v.to_str().ok())
1253        .and_then(|s| s.parse::<u32>().ok())
1254        == Some(0);
1255    if !exhausted {
1256        return;
1257    }
1258    match parse_rate_limit_delay(headers) {
1259        Some(reset) => rate_limiter.on_bucket_exhausted(reset),
1260        None => debug!(
1261            "Alpaca REST rate-limit window exhausted (X-Ratelimit-Remaining: 0) without X-Ratelimit-Reset"
1262        ),
1263    }
1264}
1265
1266/// Execute a REST request with rate-limit awareness and retry.
1267///
1268/// The `build_request` closure is called on every attempt so the caller doesn't
1269/// need a clone of `RequestBuilder` (which may not be cloneable with streaming bodies).
1270/// For GET/POST/DELETE with fixed bodies, the closure is a cheap re-construction.
1271///
1272/// On HTTP 429, reads the cooldown with [`parse_rate_limit_delay`] and retries up to
1273/// `MAX_RATE_LIMIT_ATTEMPTS - 1` times. Every 429, the last included, pauses later requests for
1274/// that cooldown, so a caller handed [`ApiError::RateLimit`] does not meet the same refusal on its
1275/// next call. Any other response reporting
1276/// `X-Ratelimit-Remaining: 0` pauses later requests until that reset; see
1277/// [`observe_rate_limit_remaining`].
1278///
1279/// # Errors
1280///
1281/// - [`UnindexedClientError::Connectivity`]: the request or its body read failed, or a 5xx.
1282/// - [`UnindexedClientError::Api`]: a 4xx, classified by [`parse_api_error`], or 429 retries
1283///   exhausted ([`ApiError::RateLimit`], with Alpaca's wait when its headers give one).
1284/// - [`UnindexedClientError::Internal`]: a 2xx body that does not decode into `T`, or a 204 on a
1285///   call that expects a body. Retrying returns the same answer.
1286async fn rest_with_retry<T>(
1287    rate_limiter: &RateLimitTracker,
1288    build_request: impl FnMut() -> reqwest::RequestBuilder,
1289) -> Result<T, UnindexedClientError>
1290where
1291    T: for<'de> Deserialize<'de>,
1292{
1293    match rest_request(rate_limiter, build_request).await? {
1294        Fetched::Found(value) => Ok(value),
1295        Fetched::NotFound(message) => Err(UnindexedClientError::Api(parse_api_error(
1296            reqwest::StatusCode::NOT_FOUND,
1297            None,
1298            &message,
1299        ))),
1300    }
1301}
1302
1303/// [`rest_with_retry`] for a lookup by id: a 404 is `Ok(None)`, Alpaca knowing nothing under the
1304/// id asked for, rather than an error.
1305async fn rest_lookup_with_retry<T>(
1306    rate_limiter: &RateLimitTracker,
1307    build_request: impl FnMut() -> reqwest::RequestBuilder,
1308) -> Result<Option<T>, UnindexedClientError>
1309where
1310    T: for<'de> Deserialize<'de>,
1311{
1312    match rest_request(rate_limiter, build_request).await? {
1313        Fetched::Found(value) => Ok(Some(value)),
1314        Fetched::NotFound(_) => Ok(None),
1315    }
1316}
1317
1318/// What [`rest_request`] got back from a request that did not fail.
1319#[derive(Debug)]
1320enum Fetched<T> {
1321    /// A 2xx body, decoded.
1322    Found(T),
1323    /// A 404, with Alpaca's message.
1324    NotFound(String),
1325}
1326
1327/// [`rest_with_retry`] and [`rest_lookup_with_retry`], which differ only in how they read a 404.
1328async fn rest_request<T>(
1329    rate_limiter: &RateLimitTracker,
1330    mut build_request: impl FnMut() -> reqwest::RequestBuilder,
1331) -> Result<Fetched<T>, UnindexedClientError>
1332where
1333    T: for<'de> Deserialize<'de>,
1334{
1335    for attempt in 0..MAX_RATE_LIMIT_ATTEMPTS {
1336        rate_limiter.wait_if_blocked().await;
1337        let response = build_request()
1338            .send()
1339            .await
1340            .map_err(|e| connectivity_err(format!("Alpaca REST request failed: {e}")))?;
1341
1342        let status = response.status();
1343
1344        if status == reqwest::StatusCode::TOO_MANY_REQUESTS {
1345            let reset_delay = parse_rate_limit_delay(response.headers());
1346            // Every refusal pauses the client, the last included, so the next request waits too.
1347            rate_limiter.on_rate_limited(reset_delay);
1348
1349            if attempt + 1 < MAX_RATE_LIMIT_ATTEMPTS {
1350                warn!(
1351                    attempt = attempt + 1,
1352                    max_attempts = MAX_RATE_LIMIT_ATTEMPTS,
1353                    "Alpaca REST rate-limited (429), retrying"
1354                );
1355                continue;
1356            }
1357
1358            // Final attempt still rate-limited — return typed error.
1359            warn!(
1360                max_attempts = MAX_RATE_LIMIT_ATTEMPTS,
1361                "Alpaca REST rate-limit retries exhausted"
1362            );
1363            return Err(UnindexedClientError::Api(ApiError::rate_limited(
1364                reset_delay,
1365            )));
1366        }
1367
1368        observe_rate_limit_remaining(rate_limiter, response.headers());
1369
1370        // 204 No Content is only valid for DELETE endpoints; use rest_delete_with_retry
1371        // for those. Reaching here for a 204 indicates API misuse — return a clear error
1372        // rather than a misleading "EOF while parsing" JSON failure. A retry cannot fix it.
1373        if status == reqwest::StatusCode::NO_CONTENT {
1374            return Err(UnindexedClientError::Internal(
1375                "Alpaca REST returned 204 No Content — use rest_delete_with_retry for DELETE endpoints"
1376                    .to_string(),
1377            ));
1378        }
1379
1380        let bytes = response
1381            .bytes()
1382            .await
1383            .map_err(|e| connectivity_err(format!("Alpaca REST read body failed: {e}")))?;
1384
1385        // A 2xx body that does not fit the model is not transient: retrying returns the same
1386        // body. An order path must still treat it as status unknown; see `order_post_error`.
1387        if status.is_success() {
1388            return serde_json::from_slice::<T>(&bytes)
1389                .map(Fetched::Found)
1390                .map_err(|e| {
1391                    UnindexedClientError::Internal(format!(
1392                        "Alpaca REST JSON parse error ({status}): {e} | body: {}",
1393                        String::from_utf8_lossy(&bytes)
1394                            .chars()
1395                            .take(200)
1396                            .collect::<String>()
1397                    ))
1398                });
1399        }
1400
1401        // Parse API error body for a better error message.
1402        let api_err = AlpacaApiError::from_body(&bytes);
1403
1404        if status == reqwest::StatusCode::NOT_FOUND {
1405            return Ok(Fetched::NotFound(api_err.message));
1406        }
1407
1408        // 4xx = API-level rejection (wrong parameters, auth failure, insufficient funds).
1409        // 5xx / other = server-side failure treated as connectivity error.
1410        // Callers that pattern-match on UnindexedClientError (e.g. open_order_inner) rely
1411        // on Api(ApiError) to classify business rejections vs connectivity failures.
1412        //
1413        // Uses parse_api_error for consistent classification: 422 "insufficient funds"
1414        // maps to BalanceInsufficient (not generic OrderRejected), enabling callers to
1415        // trigger balance refresh on insufficient-funds rejections.
1416        if status.is_client_error() {
1417            return Err(UnindexedClientError::Api(parse_api_error(
1418                status,
1419                api_err.code,
1420                &api_err.message,
1421            )));
1422        }
1423        return Err(connectivity_err(format!(
1424            "Alpaca REST error {status}: {}",
1425            api_err.message
1426        )));
1427    }
1428    unreachable!("Alpaca REST retry loop exited without returning")
1429}
1430
1431/// Execute a DELETE request, returning an order error on rejection.
1432///
1433/// Handles 204 No Content (success), other 4xx (API rejection, see [`parse_api_error`]), and 429
1434/// (rate limit, retried and pausing later requests as in [`rest_with_retry`]). Any other response
1435/// reporting `X-Ratelimit-Remaining: 0` pauses later requests,
1436/// as in [`rest_with_retry`].
1437async fn rest_delete_with_retry(
1438    rate_limiter: &RateLimitTracker,
1439    mut build_request: impl FnMut() -> reqwest::RequestBuilder,
1440) -> Result<(), UnindexedOrderError> {
1441    for attempt in 0..MAX_RATE_LIMIT_ATTEMPTS {
1442        rate_limiter.wait_if_blocked().await;
1443        let response = build_request().send().await.map_err(|e| {
1444            UnindexedOrderError::Connectivity(ConnectivityError::Socket(format!(
1445                "Alpaca cancel request failed: {e}"
1446            )))
1447        })?;
1448
1449        let status = response.status();
1450
1451        if status == reqwest::StatusCode::TOO_MANY_REQUESTS {
1452            let reset_delay = parse_rate_limit_delay(response.headers());
1453            // Every refusal pauses the client, the last included, so the next request waits too.
1454            rate_limiter.on_rate_limited(reset_delay);
1455
1456            if attempt + 1 < MAX_RATE_LIMIT_ATTEMPTS {
1457                warn!(
1458                    attempt = attempt + 1,
1459                    max_attempts = MAX_RATE_LIMIT_ATTEMPTS,
1460                    "Alpaca cancel rate-limited (429), retrying"
1461                );
1462                continue;
1463            }
1464
1465            // Final attempt still rate-limited — return typed error.
1466            warn!(
1467                max_attempts = MAX_RATE_LIMIT_ATTEMPTS,
1468                "Alpaca cancel rate-limit retries exhausted"
1469            );
1470            return Err(UnindexedOrderError::Rejected(ApiError::rate_limited(
1471                reset_delay,
1472            )));
1473        }
1474
1475        observe_rate_limit_remaining(rate_limiter, response.headers());
1476
1477        // 204 No Content: cancel succeeded.
1478        if status == reqwest::StatusCode::NO_CONTENT || status.is_success() {
1479            return Ok(());
1480        }
1481
1482        let bytes = response
1483            .bytes()
1484            .await
1485            .inspect_err(
1486                |e| warn!(%e, %status, "Alpaca cancel_order: failed to read error response body"),
1487            )
1488            .unwrap_or_default();
1489        let api_err = AlpacaApiError::from_body(&bytes);
1490
1491        return Err(parse_order_error(status, api_err.code, &api_err.message));
1492    }
1493    unreachable!("Alpaca cancel retry loop exited without returning")
1494}
1495
1496// ---------------------------------------------------------------------------
1497// ExecutionClient implementation
1498// ---------------------------------------------------------------------------
1499
1500impl ExecutionClient for AlpacaClient {
1501    const EXCHANGE: ExchangeId = ExchangeId::AlpacaBroker;
1502
1503    // Equities and crypto are both `Spot` here — Alpaca settles each as an outright holding, and
1504    // the client separates them only by symbol shape. `Option` is genuinely routed: the
1505    // `position_intent` Alpaca requires on options orders is derived from that same shape test,
1506    // which is `is_options_or_equity_symbol` — a not-a-crypto-pair check (`!symbol.contains('/')`),
1507    // not OCC format validation. Equities take the branch too, harmlessly: `position_intent` is
1508    // accepted on an equity order.
1509    const SUPPORTED_KINDS: &'static [InstrumentKindDiscriminant] = &[
1510        InstrumentKindDiscriminant::Spot,
1511        InstrumentKindDiscriminant::Option,
1512    ];
1513    type Config = AlpacaConfig;
1514    type AccountStream = BoxStream<'static, UnindexedAccountEvent>;
1515
1516    /// # Panics
1517    ///
1518    /// Panics if the API key or secret key contains characters invalid in an
1519    /// HTTP header value.
1520    fn new(config: Self::Config) -> Self {
1521        let http = Self::build_http(&config);
1522        let orders_url = format!("{}/v2/orders", config.rest_base_url());
1523        Self {
1524            config: Arc::new(config),
1525            http,
1526            rate_limiter: Arc::new(RateLimitTracker::new()),
1527            orders_url,
1528            known_live: KnownLiveOrders::shared(ExchangeId::AlpacaBroker),
1529        }
1530    }
1531
1532    /// # Balances and positions
1533    ///
1534    /// - **USD**: `total` is the account's `cash`, and `free` the lesser of cash and its
1535    ///   `non_marginable_buying_power`: cash that can be spent without borrowing. Alpaca's
1536    ///   buying power figures include the loan value of held stock, so none of them alone is
1537    ///   free cash. A short sale's proceeds are credited to cash but do not raise buying power,
1538    ///   so `free` stays below `total` while a short is open. Cash is negative while the account
1539    ///   borrows on margin, and then `free` is negative too. Account equity is not reported: it
1540    ///   is cash plus the value of the positions below, so it can be derived.
1541    /// - **Equities and options**: each holding becomes the
1542    ///   [`InstrumentAccountSnapshot::position`] of its instrument, [`PositionReport::Open`] with a
1543    ///   signed [`Position`] (negative for a short), the average entry price and the unrealised
1544    ///   PnL in USD. An equity or option without a holding is [`PositionReport::Flat`], since
1545    ///   Alpaca lists every open position. An option's
1546    ///   entry price is the premium per share of the underlying, as quoted and as orders are
1547    ///   priced, not per contract. Margin, liquidation price and leverage are `None`, and
1548    ///   `time_exchange` is the time of the call, since Alpaca does not timestamp positions.
1549    /// - **Crypto**: each holding is an asset balance of the asset it holds (e.g. `BTC` for the
1550    ///   position Alpaca lists as `BTCUSD`), in base units. Alpaca crypto is spot-only and cannot
1551    ///   be sold short, so there is no position to report: its instruments are
1552    ///   [`PositionReport::Unreported`].
1553    ///
1554    /// # Limitations
1555    ///
1556    /// A position is reported only under the instrument name Alpaca uses for it, ignoring case:
1557    /// the ticker for an equity and the OCC symbol for an option. A requested equity or option
1558    /// named otherwise is reported [`PositionReport::Flat`] even while Alpaca holds it. A crypto
1559    /// pair is recognised by the `/` in its name (e.g. `BTC/USD`, the form its orders use); one
1560    /// named without it is taken for an equity and reported flat too. With `instruments` empty,
1561    /// every instrument that has an open order or a position gets a snapshot; otherwise only the
1562    /// requested ones do.
1563    ///
1564    /// # Errors
1565    ///
1566    /// [`ClientError::Internal`](crate::error::ClientError::Internal) when a response does not
1567    /// decode, or when any non-zero equity or option position, requested or not, has a missing or
1568    /// unrecognised `side`: its direction cannot be known, and leaving it out would read as flat.
1569    /// Other request failures are `Connectivity` or `Api`, as for every Alpaca REST call.
1570    ///
1571    /// # Rate limit note
1572    ///
1573    /// `/v2/positions` is fetched on every call, since it holds both the crypto balances and the
1574    /// equity and option positions. When USD is requested too (the common startup case),
1575    /// `/v2/account` is fetched in parallel with it for ~100-300ms latency savings. Under rate
1576    /// pressure, both requests may hit 429 simultaneously and retry independently. Operators
1577    /// approaching Alpaca rate limits can request only non-USD assets to skip `/v2/account`.
1578    async fn account_snapshot(
1579        &self,
1580        assets: &[AssetNameExchange],
1581        instruments: &[InstrumentNameExchange],
1582    ) -> Result<UnindexedAccountSnapshot, UnindexedClientError> {
1583        let base = self.base_url();
1584        let http = self.http.clone();
1585        let rl = &self.rate_limiter;
1586
1587        let wants_usd = assets.is_empty() || assets.iter().any(is_usd);
1588        let wants_non_usd = assets.is_empty() || assets.iter().any(|a| !is_usd(a));
1589
1590        // URLs are extracted before the closures to avoid re-allocating on each retry attempt.
1591        let account_url = format!("{base}/v2/account");
1592        let positions_url = format!("{base}/v2/positions");
1593        let (account, positions): (Option<AlpacaAccount>, Vec<AlpacaPosition>) = tokio::try_join!(
1594            async {
1595                if wants_usd {
1596                    rest_with_retry(rl, || http.get(&account_url))
1597                        .await
1598                        .map(Some)
1599                } else {
1600                    Ok(None)
1601                }
1602            },
1603            rest_with_retry(rl, || http.get(&positions_url)),
1604        )?;
1605
1606        let mut balances = account
1607            .map(|account| convert_account_to_balances(&account, assets))
1608            .unwrap_or_default();
1609        if wants_non_usd {
1610            balances.extend(convert_positions_to_balances(&positions, assets));
1611        }
1612
1613        // Converted before the open orders are fetched, so a position that cannot be converted
1614        // fails the call without a wasted request.
1615        let converted_positions = convert_positions(&positions, Utc::now())?;
1616        let open_orders = fetch_raw_open_orders(&http, rl, base, instruments).await?;
1617
1618        // Group open orders and positions by instrument symbol.
1619        let instrument_snapshots =
1620            build_instrument_snapshots(open_orders, converted_positions, instruments);
1621        {
1622            let mut known = self.known_live.lock();
1623            for order in instrument_snapshots
1624                .iter()
1625                .flat_map(|snapshot| &snapshot.orders)
1626            {
1627                if let OrderState::Active(ActiveOrderState::Open(open)) = &order.state {
1628                    known.live(&order.key, order.quantity, order.kind, open);
1629                }
1630            }
1631        }
1632
1633        Ok(AccountSnapshot::new(
1634            ExchangeId::AlpacaBroker,
1635            balances,
1636            instrument_snapshots,
1637        ))
1638    }
1639
1640    /// Returns a live stream of account events (fills, order updates).
1641    ///
1642    /// # Reconnection
1643    ///
1644    /// The stream reconnects by itself when the server closes the connection, the connection
1645    /// fails, or it carries nothing for a 35 s heartbeat window. Each reconnect recovers the fills
1646    /// and order endings the stream missed (see below).
1647    ///
1648    /// - **Attempts.** A dropped connection, and each reconnect that fails after it, spends one
1649    ///   attempt: a wait, then a reconnect. A reconnect fails when its connect, authentication or
1650    ///   `trade_updates` subscribe fails. The waits double from 1 s up to 30 s: 1, 2, 4, 8, 16,
1651    ///   then 30 s.
1652    /// - **Reset.** A connection still receiving after 35 s up clears the attempts spent before
1653    ///   it, so only failures in a row count. A connection the server accepts and closes sooner
1654    ///   counts as a failed reconnect.
1655    /// - **Giving up.** Once 10 reconnects in a row have failed, after about three minutes of
1656    ///   waiting in all, the stream ends with [`AccountEventKind::StreamTerminated`] carrying
1657    ///   [`ReconnectBudgetExhausted`](crate::error::StreamTerminationReason::ReconnectBudgetExhausted)
1658    ///   and the last failure. Credentials rejected on every reconnect end it this way too.
1659    /// - **Opening is not retried.** A failed `account_stream` call returns its error. Whether and
1660    ///   when to try again is the caller's decision, as it is after the stream ends.
1661    ///
1662    /// # Startup race window
1663    ///
1664    /// Fills arriving between `account_snapshot` and this method being called by the
1665    /// caller are not recovered automatically — the WebSocket connection does not exist
1666    /// yet during that window. Fills that arrive after the connection is established but
1667    /// before the first poll are buffered in the tungstenite internal buffer and delivered
1668    /// normally. Callers requiring fill completeness at startup **must** call
1669    /// [`ExecutionClient::fetch_trades`] with a ~1 s lookback after calling this method.
1670    ///
1671    /// This gap also applies on every **reconnect**: during the auth+subscribe handshake
1672    /// (`connect_and_subscribe`), any `trade_updates` messages that arrive are consumed
1673    /// by the handshake loop and not forwarded. Fill events in this window are recovered
1674    /// via the REST activities endpoint (anchored to `disconnect_time`). How an order held as live
1675    /// ended in this window is recovered too (see below); a lifecycle event of any other order,
1676    /// such as `new` for one placed elsewhere, is not.
1677    ///
1678    /// # Fill recovery ordering
1679    ///
1680    /// After a reconnect, missed fills are recovered from the REST activities endpoint
1681    /// using `direction=asc` to match the chronological order in which the WS stream
1682    /// advanced `filled_qty`. A fill both paths deliver is sent once: it is recognised by its
1683    /// execution id, which the WS frame and the FILL activity both carry.
1684    ///
1685    /// A fill lacking that id on either path is recognised instead by `"{order_id}:{cum_qty}"`,
1686    /// taken from the activity's own `cum_qty`, so it matches the WS path's `filled_qty` by
1687    /// construction. Only where Alpaca omits `cum_qty` too does that key fall back to
1688    /// accumulating per-execution qty within the batch. That fallback counts from zero per order,
1689    /// so it is correct only for an order whose fills lie wholly inside the recovery window, and
1690    /// it is sensitive to activities arriving out of chronological order (e.g. at pagination
1691    /// boundaries).
1692    ///
1693    /// A recovered fill also carries the order's cumulative filled quantity, so it advances the
1694    /// order's `filled_quantity` without waiting for a `fetch_open_orders` reconciliation.
1695    ///
1696    /// Every fill's [`TradeId`] is Alpaca's execution id, whether the stream, recovery, or
1697    /// [`ExecutionClient::fetch_trades`] delivers it, so a fill read again by `fetch_trades`
1698    /// matches the one the stream delivered. A stream fill whose `execution_id` is missing, null
1699    /// or empty is logged and identified by `"{order_id}:{filled_qty}"` instead.
1700    ///
1701    /// The read is made once, with no retry. When it fails, times out, or stops at 5,000 fills,
1702    /// the stream sends one [`AccountEventKind::FillRecoveryGaveUp`] covering the stream's
1703    /// `instruments`, or every instrument when that list is empty. A truncated read delivers the
1704    /// fills it read first, and the event's span starts just before the last of them.
1705    ///
1706    /// # Busted and corrected fills
1707    ///
1708    /// A `trade_bust` or `trade_correct` is sent as [`AccountEventKind::TradeAmended`], naming
1709    /// the fill amended by its [`TradeId`]; the fill itself was delivered as first reported and
1710    /// is not withdrawn. The Trading API documents neither event, so their frames are read per
1711    /// Alpaca's Broker API schema, and each is also logged whole at `warn!`. One sent while the
1712    /// stream was disconnected is not recovered.
1713    ///
1714    /// # Lifecycle event deduplication
1715    ///
1716    /// Order lifecycle events (`new`, `canceled`, `expired`) are **not** deduplicated
1717    /// across reconnects — only fill events carry a dedup key. After a reconnect, Alpaca
1718    /// re-delivers lifecycle events for orders that were active at disconnect time.
1719    /// Specifically, Alpaca re-delivers a `new` event for **every order open at disconnect
1720    /// time**, not only orders that changed during the gap.
1721    /// Callers must make [`AccountEventKind::OrderSnapshot`] and
1722    /// [`AccountEventKind::OrderCancelled`] processing idempotent, or call
1723    /// [`ExecutionClient::fetch_open_orders`] after each reconnect to reconcile state.
1724    ///
1725    /// # Orders that ended while disconnected
1726    ///
1727    /// A reconnect also reports how each order the client holds as live ended, where it did, as an
1728    /// [`AccountEventKind::OrderSnapshot`] of its inactive state: filled (with the average fill
1729    /// price), cancelled or replaced (as cancelled, with what filled before), expired, or rejected.
1730    /// Like fill recovery, it covers only the instruments the stream was opened with, or every
1731    /// instrument when that list is empty. Alpaca's closed-order list filters on when an order was
1732    /// submitted, so such an order is found by asking about it, not by time.
1733    ///
1734    /// - **Which orders.** The client holds an order as live from the response to placing it,
1735    ///   from a listing of open orders ([`account_snapshot`](ExecutionClient::account_snapshot),
1736    ///   [`fetch_open_orders`](ExecutionClient::fetch_open_orders)), and from its live reports on
1737    ///   any of its account streams, until it sees the order end; a cancel Alpaca has only
1738    ///   accepted does not end it. A bracket's take-profit and stop-loss legs carry client order
1739    ///   ids Alpaca assigns, so they are held only once a listing or the stream reports them. It
1740    ///   holds up to 4,096 orders and forgets the oldest past that, logged at `warn`. An order
1741    ///   placed outside this client and never listed or reported to it is not covered.
1742    /// - **Cost.** One `GET /v2/orders?status=open` request listing every instrument with an
1743    ///   order held, then one `GET /v2/orders:by_client_order_id` for each held order the listing
1744    ///   no longer shows, 8 at a time. These share the account's rate limit with orders, so after
1745    ///   an outage in which many held orders ended, the check can hold orders back until the
1746    ///   window resets (see [Rate limits](AlpacaClient#rate-limits)).
1747    /// - **Fills first.** The check starts once fill recovery has finished or given up, so an
1748    ///   order's recovered fills arrive before how it ended. It runs alongside the stream, which
1749    ///   is read from the start. A fill that brings an order to its full quantity ends it, and
1750    ///   that order is not reported again.
1751    /// - **Keys.** Each snapshot carries [`StrategyId::unknown`], since Alpaca records no
1752    ///   strategy. The engine matches it to the order it tracks by client order id.
1753    /// - **Failures.** Each order's lookup is settled as it ends. An instrument whose listing or
1754    ///   lookup fails, or whose check is still running after 30 s, is retried while connected 1,
1755    ///   2, 4, 8 and 16 minutes later, asking only about the orders still held, then given up,
1756    ///   logged at `error`; its orders are asked about again at the next reconnect. A listing that
1757    ///   fails charges every instrument in it. Alpaca lists at most 500 open orders and has no
1758    ///   pagination for them, so a listing of 500 may be truncated and fails: an account with 500
1759    ///   or more open orders on the held instruments is not covered. An order Alpaca does not know
1760    ///   (404) stops being held, logged at `warn`; one still live, `done_for_day`, or in a state
1761    ///   this version cannot read stays held.
1762    ///
1763    /// The same lookup is public as [`OrderStatusClient::fetch_ended_orders`].
1764    ///
1765    /// # Rejected orders
1766    ///
1767    /// Alpaca `rejected` events are delivered as `AccountEventKind::OrderCancelled` with
1768    /// `state: Err(OrderRejected(...))`. Match on `response.state.is_err()` to distinguish
1769    /// rejections from true cancels — do not call `.unwrap()` on `OrderCancelled.state`.
1770    ///
1771    /// # Orders done for the day
1772    ///
1773    /// A `done_for_day` event is delivered as an [`AccountEventKind::OrderSnapshot`] of the order
1774    /// as open, with what it has filled: Alpaca stops working the order until the next trading
1775    /// day but has not ended it.
1776    ///
1777    /// # Stream drop behaviour
1778    ///
1779    /// Dropping the returned `BoxStream` initiates a graceful shutdown of the
1780    /// background `connection_manager` task: the channel close causes the task
1781    /// to send a WebSocket close frame and exit within the current heartbeat
1782    /// window (≤60 s). Any `AccountEvent` items already queued but not yet
1783    /// polled are discarded. Callers who drop and re-subscribe must call
1784    /// [`ExecutionClient::fetch_trades`] with a short lookback to recover the gap.
1785    async fn account_stream(
1786        &self,
1787        _assets: &[AssetNameExchange], // ignored — Alpaca's trade_updates stream delivers all asset classes on one channel
1788        // instruments is used to filter fill recovery (REST) after a reconnect only.
1789        // Live WS events are NOT filtered by instrument: Alpaca's trade_updates stream
1790        // delivers all account events on one channel with no per-symbol subscription.
1791        instruments: &[InstrumentNameExchange],
1792    ) -> Result<Self::AccountStream, UnindexedClientError> {
1793        // Verify the initial connection before returning the stream; distinguishes
1794        // "can't connect at all" from "connected but later disconnected".
1795        let initial_ws = connect_and_subscribe(&self.config).await?;
1796
1797        // Unbounded channel — memory grows if the consumer is slow, but fills
1798        // are never silently dropped. Silent fill loss corrupts position state;
1799        // OOM is loudly observable. The WS delivery path uses non-blocking send()
1800        // which only fails if the receiver is dropped.
1801        let (tx, rx) = mpsc::unbounded_channel::<UnindexedAccountEvent>();
1802        let dedup = new_dedup_cache();
1803        let config = self.config.clone();
1804        let http = self.http.clone();
1805        let rate_limiter = self.rate_limiter.clone();
1806        let known = self.known_live.clone();
1807        let instruments = instruments.to_vec();
1808
1809        let cm_handle = tokio::spawn(connection_manager(
1810            tx,
1811            dedup,
1812            config,
1813            http,
1814            rate_limiter,
1815            known,
1816            instruments,
1817            Some(initial_ws),
1818        ));
1819
1820        let rx_stream = tokio_stream::wrappers::UnboundedReceiverStream::new(rx);
1821        let guarded = GracefulShutdownStream::new(rx_stream, cm_handle);
1822        Ok(futures::StreamExt::boxed(guarded))
1823    }
1824
1825    /// Cancel an order.
1826    ///
1827    /// # Async cancel semantics
1828    ///
1829    /// Returns `Ok(Cancelled)` once Alpaca has **accepted** the cancel, not once the order is
1830    /// cancelled: `DELETE /v2/orders/{id}` answers 204 when the cancel request is accepted and the
1831    /// order will be cancelled. Until it is, the order can be `pending_cancel`, still live, and can
1832    /// still fill. The account stream reports how it ended, and any fill.
1833    ///
1834    /// The 204 has no body, so `filled_quantity` is zero whatever the order filled, and
1835    /// `time_exchange` is the local time the answer arrived.
1836    ///
1837    /// The client keeps holding the order as live until the stream reports how it ended, so if the
1838    /// stream is down when it does, a reconnect still finds out (see
1839    /// [`account_stream`](ExecutionClient::account_stream)).
1840    ///
1841    /// Needs the venue order id. A request without one is refused, since Alpaca cancels by that
1842    /// id only; resolve it through [`ExecutionClient::fetch_open_orders`].
1843    async fn cancel_order(
1844        &self,
1845        request: OrderRequestCancel<ExchangeId, &InstrumentNameExchange>,
1846    ) -> UnindexedOrderResponseCancel {
1847        let key = crate::order::OrderKey {
1848            exchange: request.key.exchange,
1849            instrument: request.key.instrument.clone(),
1850            strategy: request.key.strategy.clone(),
1851            cid: request.key.cid.clone(),
1852        };
1853
1854        // Require the exchange order ID — Alpaca's DELETE endpoint uses the UUID.
1855        // If only clientOrderId is available, the caller should first resolve it
1856        // via fetch_open_orders.
1857        let order_id: SmolStr = match request.state.id.as_ref().and_then(VenueOrderId::assigned) {
1858            Some(id) => id.0.clone(),
1859            None => {
1860                warn!(
1861                    instrument = %key.instrument,
1862                    "Alpaca cancel_order: no exchange order ID available (clientOrderId-only cancel not supported)"
1863                );
1864                return crate::order::request::OrderResponseCancel {
1865                    key,
1866                    state: Err(UnindexedOrderError::Rejected(ApiError::OrderRejected(
1867                        "exchange order ID required for cancel (fetch_open_orders to resolve)"
1868                            .into(),
1869                    ))),
1870                };
1871            }
1872        };
1873
1874        let base = self.base_url();
1875        let http = self.http.clone();
1876        let url = format!("{base}/v2/orders/{order_id}");
1877
1878        match rest_delete_with_retry(&self.rate_limiter, || http.delete(&url)).await {
1879            Ok(()) => {
1880                let exchange_order_id = OrderId(order_id);
1881                // REST DELETE returns no response body, so the filled quantity is unknown
1882                // here; the account stream's `canceled` update reports it.
1883                crate::order::request::OrderResponseCancel {
1884                    key,
1885                    state: Ok(Cancelled::new(exchange_order_id, Utc::now(), None)),
1886                }
1887            }
1888            Err(e) => crate::order::request::OrderResponseCancel { key, state: Err(e) },
1889        }
1890    }
1891
1892    /// # Position intent derivation
1893    ///
1894    /// Alpaca options/equities require explicit `position_intent`. This impl derives
1895    /// intent from `RequestOpen::reduce_only` and `side`:
1896    ///
1897    /// | reduce_only | side | intent       | use case                          |
1898    /// |-------------|------|--------------|-----------------------------------|
1899    /// | false       | Buy  | BuyToOpen    | open long / add to long position  |
1900    /// | false       | Sell | SellToOpen   | open short / write option         |
1901    /// | true        | Buy  | BuyToClose   | close short position              |
1902    /// | true        | Sell | SellToClose  | close long position               |
1903    ///
1904    /// For explicit control, use [`AlpacaClient::open_order_with_intent`].
1905    ///
1906    /// # Market order price
1907    ///
1908    /// The returned `Order.price` echoes the request price. For market orders this is
1909    /// typically `Decimal::ZERO` (a placeholder). The **actual fill price** arrives via
1910    /// the WebSocket `trade_updates` stream as a `Trade` event. Do not rely on
1911    /// `Order.price` from this REST ack for market order fill prices.
1912    async fn open_order(
1913        &self,
1914        request: OrderRequestOpen<ExchangeId, &InstrumentNameExchange>,
1915    ) -> Order<ExchangeId, InstrumentNameExchange, UnindexedOrderState> {
1916        let side = request.state.side;
1917        let reduce_only = request.state.reduce_only;
1918        self.open_order_inner(request, map_position_intent(side, reduce_only))
1919            .await
1920    }
1921
1922    /// Fetches balances sequentially (unlike `account_snapshot` which parallelizes).
1923    ///
1924    /// Sequential fetch is intentional for live operation: under rate pressure, parallel
1925    /// requests may both hit 429 simultaneously and retry independently, doubling the
1926    /// backoff delay. The startup latency savings from `account_snapshot`'s parallel
1927    /// fetch are worth the tradeoff there; for periodic balance refreshes during live
1928    /// trading, sequential is safer.
1929    async fn fetch_balances(
1930        &self,
1931        assets: &[AssetNameExchange],
1932    ) -> Result<Vec<AssetBalance<AssetNameExchange>>, UnindexedClientError> {
1933        let base = self.base_url();
1934        let http = self.http.clone();
1935        let mut result = Vec::new();
1936
1937        // Only fetch the account (USD balance) when USD is among the requested assets.
1938        // Crypto-only requests skip this call to conserve rate-limit budget.
1939        let wants_usd = assets.is_empty() || assets.iter().any(is_usd);
1940        if wants_usd {
1941            // Pre-allocate URL to avoid re-allocation on each retry attempt.
1942            let account_url = format!("{base}/v2/account");
1943            let account: AlpacaAccount =
1944                rest_with_retry(&self.rate_limiter, || http.get(&account_url)).await?;
1945            result.extend(convert_account_to_balances(&account, assets));
1946        }
1947
1948        // Fetch positions for non-USD asset balances (e.g., BTC, ETH from crypto holdings).
1949        let wants_non_usd = assets.is_empty() || assets.iter().any(|a| !is_usd(a));
1950        if wants_non_usd {
1951            // Pre-allocate URL to avoid re-allocation on each retry attempt.
1952            let positions_url = format!("{base}/v2/positions");
1953            let positions: Vec<AlpacaPosition> =
1954                rest_with_retry(&self.rate_limiter, || http.get(&positions_url)).await?;
1955            result.extend(convert_positions_to_balances(&positions, assets));
1956        }
1957
1958        Ok(result)
1959    }
1960
1961    async fn fetch_open_orders(
1962        &self,
1963        instruments: &[InstrumentNameExchange],
1964    ) -> Result<Vec<Order<ExchangeId, InstrumentNameExchange, Open>>, UnindexedClientError> {
1965        let base = self.base_url();
1966        let http = self.http.clone();
1967
1968        let open_orders =
1969            fetch_raw_open_orders(&http, &self.rate_limiter, base, instruments).await?;
1970
1971        let result: Vec<_> = open_orders
1972            .into_iter()
1973            .filter_map(|o| convert_open_order(&o))
1974            .collect();
1975        self.remember_live(&result);
1976        Ok(result)
1977    }
1978
1979    /// # Cost
1980    ///
1981    /// One call sends up to 50 requests, reading at most 5,000 FILL activities, and returns
1982    /// `resume` when it stops there. The limit counts every activity on the account, before the
1983    /// instrument filter, so a call for one instrument can spend all 50 requests and return no
1984    /// trades with `resume: Some`.
1985    ///
1986    /// # Times
1987    ///
1988    /// An activity whose `transaction_time` does not parse is still returned, since Alpaca placed
1989    /// it in the span. Its `time_exchange` is then the local clock at the read, which can lie
1990    /// outside the span, and a warning names it.
1991    async fn fetch_trades(
1992        &self,
1993        start: DateTime<Utc>,
1994        end: DateTime<Utc>,
1995        instruments: &[InstrumentNameExchange],
1996    ) -> Result<TradesRead<AssetNameExchange, InstrumentNameExchange>, UnindexedClientError> {
1997        // An empty span, or one ending before the epoch, holds nothing Alpaca has.
1998        if start > end || end < DateTime::UNIX_EPOCH {
1999            return Ok(TradesRead::complete(Vec::new()));
2000        }
2001        let page = paginate_activities::<AlpacaActivity>(
2002            &self.http,
2003            &self.rate_limiter,
2004            self.base_url(),
2005            FILL_ACTIVITIES,
2006            start,
2007            Some(end),
2008        )
2009        .await?;
2010
2011        let Ok(resume) = span_resume(page.resume, start, end) else {
2012            error!(
2013                %start,
2014                fills_read = page.activities.len(),
2015                "Alpaca fetch_trades: a full read did not advance past the span's first millisecond"
2016            );
2017            return Err(UnindexedClientError::Truncated {
2018                fills_read: page.activities.len(),
2019            });
2020        };
2021
2022        // Empty instruments slice means "all instruments" — same convention as
2023        // fetch_open_orders.
2024        let instrument_set: fnv::FnvHashSet<&str> =
2025            instruments.iter().map(|i| i.name().as_str()).collect();
2026        let trades = page
2027            .activities
2028            .iter()
2029            .filter(|a| instrument_set.is_empty() || instrument_set.contains(a.symbol.as_str()))
2030            // Alpaca's bounds are not exact (see `paginate_activities`), so the span is applied
2031            // here. An activity whose time does not parse is kept: Alpaca placed it in the span.
2032            .filter(|a| {
2033                parse_timestamp(&a.transaction_time)
2034                    .is_none_or(|time| (start..=end).contains(&time))
2035            })
2036            .filter_map(convert_activity_to_trade)
2037            .collect();
2038
2039        Ok(TradesRead::new(trades, resume))
2040    }
2041}
2042
2043// ---------------------------------------------------------------------------
2044// AlpacaClient public extension methods (not on ExecutionClient trait)
2045// ---------------------------------------------------------------------------
2046
2047impl AlpacaClient {
2048    /// Read the cash Alpaca moved into or out of the account other than by a trade, from `start`
2049    /// to `end`: interest, fees, and pass-through charges and rebates.
2050    ///
2051    /// Alpaca streams none of these: `trade_updates` carries order events only. Nothing is pushed:
2052    /// the caller decides when to read, and must drop the flows it has already applied, by
2053    /// [`id`](CashFlow::id), before applying a read's. Alpaca posts each to the account's cash,
2054    /// so the account's USD balance already includes a flow read here.
2055    ///
2056    /// # What each activity becomes
2057    ///
2058    /// | Activity | [`CashFlowKind`] |
2059    /// |---|---|
2060    /// | `INT` of sub-type `MGN`, or with no sub-type and a negative amount | [`MarginInterest`](CashFlowKind::MarginInterest), with no rate or principal: Alpaca reports neither |
2061    /// | `PTR`, a pass-through rebate | [`Rebate`](CashFlowKind::Rebate) |
2062    /// | Any other: `FEE`, `PTC`, credit interest | [`Other`](CashFlowKind::Other), naming the type and, when Alpaca gives one, the sub-type, as `FEE.TAF` |
2063    ///
2064    /// Alpaca documents no activity type for a stock borrow fee. Should it post one as one of
2065    /// these, it arrives as [`Other`](CashFlowKind::Other) with its own name.
2066    ///
2067    /// - [`amount`](CashFlow::amount) is Alpaca's `net_amount`, negative when the account paid.
2068    /// - [`asset`](CashFlow::asset) is its `currency`, or `USD` when it names none.
2069    /// - [`instrument`](CashFlow::instrument) is its `symbol`, when it names one.
2070    /// - [`time_exchange`](CashFlow::time_exchange) is when Alpaca created the activity
2071    ///   (`created_at`), which its `after` and `until` filter on. A fee is typically created the
2072    ///   day after the trade it was charged on.
2073    /// - [`id`](CashFlow::id) is Alpaca's activity id.
2074    ///
2075    /// # Status
2076    /// - An activity Alpaca marks `canceled` is left out, and logged at INFO. A flow read before
2077    ///   it was canceled is not reversed: the caller reconciles against the account's cash.
2078    /// - One marked `correct` is returned like any other. Alpaca does not document whether a
2079    ///   correction stands beside the activity it corrects or replaces it, so a caller applying
2080    ///   both by id may count the cash twice. Reconcile against the account's cash.
2081    ///
2082    /// An activity without an amount or a creation time that parses is left out, and logged at
2083    /// WARN.
2084    ///
2085    /// # Cost and resuming
2086    /// One call sends up to 50 requests, reading at most 5,000 activities, and returns `resume`
2087    /// when it stops there: the millisecond of the last activity's creation time. That resume is
2088    /// exact only if Alpaca serves these activities in order of creation, as it serves fills in
2089    /// order of time; its documentation orders them by "activity datetime" without saying which
2090    /// time, and this ordering has not been observed for them. Read spans short enough to fit one
2091    /// call, as the volume of these activities usually allows, and `resume` stays `None`.
2092    ///
2093    /// # Errors
2094    /// Fails if a request fails, or, with [`UnindexedClientError::Internal`], if more activities
2095    /// share the span's first millisecond than one call reads, so a read from `resume` could not
2096    /// advance.
2097    pub async fn fetch_cash_flows(
2098        &self,
2099        start: DateTime<Utc>,
2100        end: DateTime<Utc>,
2101    ) -> Result<CashFlowsRead<AssetNameExchange, InstrumentNameExchange>, UnindexedClientError>
2102    {
2103        // An empty span, or one ending before the epoch, holds nothing Alpaca has.
2104        if start > end || end < DateTime::UNIX_EPOCH {
2105            return Ok(CashFlowsRead::complete(Vec::new()));
2106        }
2107        let page = paginate_activities::<AlpacaNonTradeActivity>(
2108            &self.http,
2109            &self.rate_limiter,
2110            self.base_url(),
2111            CASH_FLOW_ACTIVITIES,
2112            start,
2113            Some(end),
2114        )
2115        .await?;
2116        let Ok(resume) = span_resume(page.resume, start, end) else {
2117            error!(
2118                %start,
2119                activities_read = page.activities.len(),
2120                "Alpaca fetch_cash_flows: a full read did not advance past the span's first \
2121                 millisecond"
2122            );
2123            return Err(UnindexedClientError::Internal(format!(
2124                "Alpaca cash-flow read of {} activities did not advance past {start}",
2125                page.activities.len()
2126            )));
2127        };
2128
2129        let flows = page
2130            .activities
2131            .iter()
2132            .filter_map(convert_cash_flow)
2133            // Alpaca's bounds are not exact (see `paginate_activities`), so the span is applied
2134            // here.
2135            .filter(|flow| (start..=end).contains(&flow.time_exchange))
2136            .collect();
2137        Ok(CashFlowsRead::new(flows, resume))
2138    }
2139
2140    /// Read the trades Alpaca made when an option contract was exercised, assigned or expired,
2141    /// from `start` to `end`.
2142    ///
2143    /// Alpaca reports these as account activities, not fills: `trade_updates` carries none of
2144    /// them, and [`fetch_trades`](crate::client::ExecutionClient::fetch_trades) reads fills only.
2145    /// Nothing is pushed: the caller decides when to read, and must drop the trades it has
2146    /// already applied, by [`id`](Trade::id), before applying a read's. To let these trades move
2147    /// the engine's positions without the engine also settling the contract at expiry, set the
2148    /// exchange's expiry handling to `Venue` (see `ExpiryHandling` in the `rustrade` crate).
2149    ///
2150    /// # What each activity becomes
2151    ///
2152    /// Each becomes one [`Trade`], whose [`origin`](Trade::origin) says why it happened:
2153    ///
2154    /// | Activity | Instrument | [`TradeOrigin`] | Price |
2155    /// |---|---|---|---|
2156    /// | `OPEXP`, expiry | the contract | [`Expiry`](TradeOrigin::Expiry) | 0 |
2157    /// | `OPEXC`, exercise | the contract | [`Exercise`](TradeOrigin::Exercise) | 0 |
2158    /// | `OPASN`, assignment | the contract | [`Assignment`](TradeOrigin::Assignment) | 0 |
2159    /// | `OPTRD`, the shares an exercise or assignment trades | the underlying | that of its exercise or assignment, else [`External`](TradeOrigin::External) | the strike |
2160    ///
2161    /// - [`side`](Trade::side) and [`quantity`](Trade::quantity) come from Alpaca's signed `qty`:
2162    ///   negative is a sell. An exercised long or an expiring long is sold; an assigned short is
2163    ///   bought. Quantities are in contracts on the contract, and in shares on `OPTRD`.
2164    /// - The contract leaves at a price of 0. What it was worth at exercise or assignment is in the
2165    ///   shares traded at the strike.
2166    /// - [`time_exchange`](Trade::time_exchange) is when Alpaca created the activity
2167    ///   (`created_at`), or the start of its `date`, in UTC, when it gives no creation time. Such
2168    ///   a dated activity is in the span when its `date` is one of the span's UTC days, so a read
2169    ///   up to now still returns one posted today, and successive reads within one day each
2170    ///   return it again.
2171    /// - [`id`](Trade::id) and [`order_id`](Trade::order_id) are Alpaca's activity id, since no
2172    ///   order produced the trade. [`strategy`](Trade::strategy) is unknown, and fees are zero.
2173    ///
2174    /// # Pairing the share leg
2175    ///
2176    /// `OPTRD` names the underlying, not the contract, so its origin is found by pairing it with
2177    /// an `OPEXC` or `OPASN` of the same `date` whose contract's root symbol is the `OPTRD`'s
2178    /// symbol, whose strike is its price, and whose kind implies its side: exercising a call or
2179    /// being assigned on a put buys the shares, and the other two sell them. Each is paired at
2180    /// most once, in the order Alpaca returns them, preferring one of 100 shares a contract, the
2181    /// standard size, so that two exercises at one strike pair with their own shares. An `OPTRD`
2182    /// with no such partner is returned as [`External`](TradeOrigin::External), and logged at
2183    /// WARN. The read covers the whole UTC days of `start` and `end`, so that both legs are read
2184    /// together, and returns only the trades in `start..=end`.
2185    ///
2186    /// # Status
2187    /// An activity Alpaca marks `canceled` is left out, and logged at INFO. An activity without a
2188    /// symbol, a non-zero quantity or a time, an `OPTRD` without a price, or one of another type
2189    /// is left out, and logged at WARN.
2190    ///
2191    /// # Cost and resuming
2192    /// As [`fetch_cash_flows`](Self::fetch_cash_flows): one call sends up to 50 requests, reading
2193    /// at most 5,000 activities, and returns `resume` when it stops there. A read from `resume`
2194    /// reads its whole UTC day again, so drop by id what was already applied. So more than 5,000
2195    /// option activities in one UTC day cannot be read through; see Errors.
2196    ///
2197    /// A read that stops at the cap can split an exercise or assignment from its share leg, and
2198    /// return the `OPTRD` as [`External`](TradeOrigin::External) while the next read, from
2199    /// `resume`, returns it again with its pair's origin. A caller dropping trades by id keeps
2200    /// the first. When a read returns `resume`, hold back its `External` `OPTRD`s until the next
2201    /// read.
2202    ///
2203    /// # Errors
2204    /// Fails if a request fails, or, with [`UnindexedClientError::Internal`], if a read from
2205    /// `resume` could not advance: one UTC day holds more option activities than one call reads.
2206    /// That includes a day whose activities before `start` alone fill the call.
2207    pub async fn fetch_option_events(
2208        &self,
2209        start: DateTime<Utc>,
2210        end: DateTime<Utc>,
2211    ) -> Result<TradesRead<AssetNameExchange, InstrumentNameExchange>, UnindexedClientError> {
2212        // An empty span, or one ending before the epoch, holds nothing Alpaca has.
2213        if start > end || end < DateTime::UNIX_EPOCH {
2214            return Ok(TradesRead::complete(Vec::new()));
2215        }
2216        // Whole UTC days, so the legs of one exercise or assignment are read together.
2217        let read_start = start.date_naive().and_time(NaiveTime::MIN).and_utc();
2218        let read_end = end.date_naive().and_time(LAST_MILLISECOND).and_utc();
2219        let page = paginate_activities::<AlpacaNonTradeActivity>(
2220            &self.http,
2221            &self.rate_limiter,
2222            self.base_url(),
2223            OPTION_EVENT_ACTIVITIES,
2224            read_start,
2225            Some(read_end),
2226        )
2227        .await?;
2228        let Ok(resume) = span_resume(page.resume, start, end) else {
2229            error!(
2230                %start,
2231                activities_read = page.activities.len(),
2232                "Alpaca fetch_option_events: a full read did not advance past the span's start"
2233            );
2234            return Err(UnindexedClientError::Internal(format!(
2235                "Alpaca option-event read of {} activities did not advance past {start}",
2236                page.activities.len()
2237            )));
2238        };
2239
2240        Ok(TradesRead::new(
2241            convert_option_events(&page.activities, start, end),
2242            resume,
2243        ))
2244    }
2245
2246    /// Place an order with an explicit `position_intent` override.
2247    ///
2248    /// Use this instead of `ExecutionClient::open_order` when you need to specify
2249    /// exact position intent (e.g. `SellToOpen` for writing a short option, or
2250    /// `BuyToClose` for closing a short position by buying).
2251    ///
2252    /// `intent` is only sent for non-crypto symbols. For crypto symbols (those
2253    /// containing `/`) the field is always omitted regardless of `intent`.
2254    ///
2255    /// # Caller obligations
2256    /// The caller is responsible for passing the semantically correct intent.
2257    /// No validation is performed against the order side or existing position.
2258    pub async fn open_order_with_intent(
2259        &self,
2260        request: OrderRequestOpen<ExchangeId, &InstrumentNameExchange>,
2261        intent: AlpacaPositionIntent,
2262    ) -> Order<ExchangeId, InstrumentNameExchange, UnindexedOrderState> {
2263        self.open_order_inner(request, intent).await
2264    }
2265
2266    /// Place a bracket order (entry + take-profit + stop-loss) in a single request.
2267    ///
2268    /// A bracket order consists of three linked orders:
2269    /// 1. **Entry**: Limit order to enter the position
2270    /// 2. **Take Profit**: Limit order to exit at profit target
2271    /// 3. **Stop Loss**: Stop (or stop-limit) order to exit at loss limit
2272    ///
2273    /// When the entry order fills, Alpaca activates both exit legs. When either
2274    /// exit leg fills, Alpaca automatically cancels the other.
2275    ///
2276    /// # Constraints
2277    ///
2278    /// - `time_in_force` must be `Day` or `GoodUntilCancelled` (Alpaca rejects others)
2279    /// - No extended hours trading with bracket orders
2280    /// - Entry is always a limit order
2281    ///
2282    /// # Example
2283    ///
2284    /// ```ignore
2285    /// let request = AlpacaBracketOrderRequest::new(
2286    ///     "AAPL".into(),
2287    ///     StrategyId::new("momentum"),
2288    ///     ClientOrderId::new("bracket-001"),
2289    ///     Side::Buy,
2290    ///     dec!(10),
2291    ///     dec!(150.00),  // entry
2292    ///     dec!(160.00),  // take profit
2293    ///     dec!(145.00),  // stop loss
2294    ///     TimeInForce::GoodUntilCancelled { post_only: false },
2295    /// );
2296    /// let result = client.open_bracket_order(request).await;
2297    /// ```
2298    pub async fn open_bracket_order(
2299        &self,
2300        request: AlpacaBracketOrderRequest,
2301    ) -> AlpacaBracketOrderResult {
2302        let order_key = crate::order::OrderKey::new(
2303            ExchangeId::AlpacaBroker,
2304            request.instrument.clone(),
2305            request.strategy.clone(),
2306            request.cid.clone(),
2307        );
2308
2309        // Validate time_in_force: bracket orders only support day or gtc
2310        let tif_str = match request.time_in_force {
2311            TimeInForce::GoodUntilEndOfDay => "day",
2312            TimeInForce::GoodUntilCancelled { post_only } => {
2313                if post_only {
2314                    return AlpacaBracketOrderResult {
2315                        parent: Order {
2316                            key: order_key,
2317                            side: request.side,
2318                            price: Some(request.entry_price),
2319                            quantity: request.quantity,
2320                            kind: OrderKind::Limit,
2321                            time_in_force: request.time_in_force,
2322                            state: OrderState::inactive(OrderError::Rejected(
2323                                ApiError::OrderRejected(
2324                                    "Alpaca does not support post_only for bracket orders"
2325                                        .to_string(),
2326                                ),
2327                            )),
2328                        },
2329                    };
2330                }
2331                "gtc"
2332            }
2333            other => {
2334                return AlpacaBracketOrderResult {
2335                    parent: Order {
2336                        key: order_key,
2337                        side: request.side,
2338                        price: Some(request.entry_price),
2339                        quantity: request.quantity,
2340                        kind: OrderKind::Limit,
2341                        time_in_force: request.time_in_force,
2342                        state: OrderState::inactive(OrderError::Rejected(ApiError::OrderRejected(
2343                            format!(
2344                                "Alpaca bracket orders only support Day or GTC time_in_force, got {:?}",
2345                                other
2346                            ),
2347                        ))),
2348                    },
2349                };
2350            }
2351        };
2352
2353        // Validate price ordering. Alpaca rejects mis-ordered brackets with a
2354        // generic 422; surface a structured local error before round-trip.
2355        let price_ordering_ok = match request.side {
2356            Side::Buy => {
2357                request.stop_loss_price < request.entry_price
2358                    && request.entry_price < request.take_profit_price
2359            }
2360            Side::Sell => {
2361                request.take_profit_price < request.entry_price
2362                    && request.entry_price < request.stop_loss_price
2363            }
2364        };
2365        if !price_ordering_ok {
2366            return AlpacaBracketOrderResult {
2367                parent: Order {
2368                    key: order_key,
2369                    side: request.side,
2370                    price: Some(request.entry_price),
2371                    quantity: request.quantity,
2372                    kind: OrderKind::Limit,
2373                    time_in_force: request.time_in_force,
2374                    state: OrderState::inactive(OrderError::Rejected(ApiError::OrderRejected(
2375                        format!(
2376                            "Invalid bracket price ordering for {:?} side: entry={}, take_profit={}, stop_loss={}",
2377                            request.side,
2378                            request.entry_price,
2379                            request.take_profit_price,
2380                            request.stop_loss_price,
2381                        ),
2382                    ))),
2383                },
2384            };
2385        }
2386
2387        // Validate stop-loss limit price if provided. For a Buy bracket, the SL
2388        // is a sell stop(-limit); the limit must be at or below the trigger so
2389        // the order can fill if price gaps down. Reverse for Sell brackets.
2390        if let Some(sl_limit) = request.stop_loss_limit_price {
2391            let sl_limit_ok = match request.side {
2392                Side::Buy => sl_limit <= request.stop_loss_price,
2393                Side::Sell => sl_limit >= request.stop_loss_price,
2394            };
2395            if !sl_limit_ok {
2396                return AlpacaBracketOrderResult {
2397                    parent: Order {
2398                        key: order_key,
2399                        side: request.side,
2400                        price: Some(request.entry_price),
2401                        quantity: request.quantity,
2402                        kind: OrderKind::Limit,
2403                        time_in_force: request.time_in_force,
2404                        state: OrderState::inactive(OrderError::Rejected(ApiError::OrderRejected(
2405                            format!(
2406                                "Invalid stop-loss limit price for {:?} bracket: \
2407                                 stop_loss_price={}, stop_loss_limit_price={}",
2408                                request.side, request.stop_loss_price, sl_limit,
2409                            ),
2410                        ))),
2411                    },
2412                };
2413            }
2414        }
2415
2416        // Build the bracket order request
2417        let body = AlpacaOrderRequest {
2418            symbol: request.instrument.name().as_str(),
2419            qty: request.quantity.to_string(),
2420            side: map_side(request.side),
2421            order_type: "limit",
2422            time_in_force: tif_str,
2423            limit_price: Some(request.entry_price.to_string()),
2424            stop_price: None,
2425            trail_percent: None,
2426            trail_price: None,
2427            client_order_id: Some(request.cid.0.as_str()),
2428            position_intent: if is_options_or_equity_symbol(request.instrument.name().as_str()) {
2429                Some(map_position_intent(request.side, false))
2430            } else {
2431                None
2432            },
2433            order_class: Some("bracket"),
2434            take_profit: Some(TakeProfitParams {
2435                limit_price: request.take_profit_price.to_string(),
2436            }),
2437            stop_loss: Some(StopLossParams {
2438                stop_price: request.stop_loss_price.to_string(),
2439                limit_price: request.stop_loss_limit_price.map(|p| p.to_string()),
2440            }),
2441        };
2442
2443        let http = self.http.clone();
2444        let rl = &self.rate_limiter;
2445
2446        let result: Result<AlpacaOrderResponse, UnindexedClientError> =
2447            rest_with_retry(rl, || http.post(&self.orders_url).json(&body)).await;
2448
2449        match result {
2450            Ok(resp) => {
2451                let state = placed_order_state(&resp, &order_key.instrument, request.quantity);
2452                self.known_live.lock().placed(
2453                    &order_key,
2454                    request.quantity,
2455                    OrderKind::Limit,
2456                    &state,
2457                );
2458
2459                AlpacaBracketOrderResult {
2460                    parent: Order {
2461                        key: order_key,
2462                        side: request.side,
2463                        price: Some(request.entry_price),
2464                        quantity: request.quantity,
2465                        kind: OrderKind::Limit,
2466                        time_in_force: request.time_in_force,
2467                        state,
2468                    },
2469                }
2470            }
2471            Err(e) => {
2472                let order_err = order_post_error(e);
2473                AlpacaBracketOrderResult {
2474                    parent: Order {
2475                        key: order_key,
2476                        side: request.side,
2477                        price: Some(request.entry_price),
2478                        quantity: request.quantity,
2479                        kind: OrderKind::Limit,
2480                        time_in_force: request.time_in_force,
2481                        state: OrderState::inactive(order_err),
2482                    },
2483                }
2484            }
2485        }
2486    }
2487
2488    async fn open_order_inner(
2489        &self,
2490        request: OrderRequestOpen<ExchangeId, &InstrumentNameExchange>,
2491        intent: AlpacaPositionIntent,
2492    ) -> Order<ExchangeId, InstrumentNameExchange, UnindexedOrderState> {
2493        let instrument = request.key.instrument.clone();
2494        let side = request.state.side;
2495        let price = request.state.price;
2496        let quantity = request.state.quantity;
2497        let kind = request.state.kind;
2498        let time_in_force = request.state.time_in_force;
2499        let cid = request.key.cid.clone();
2500
2501        let order_key = crate::order::OrderKey::new(
2502            ExchangeId::AlpacaBroker,
2503            instrument.clone(),
2504            request.key.strategy.clone(),
2505            cid.clone(),
2506        );
2507
2508        // Validate time_in_force before building the request — reject post_only early.
2509        let tif_str = match map_time_in_force(time_in_force) {
2510            Ok(s) => s,
2511            Err(msg) => {
2512                return Order {
2513                    key: order_key,
2514                    side,
2515                    price,
2516                    quantity,
2517                    kind,
2518                    time_in_force,
2519                    state: OrderState::inactive(OrderError::Rejected(ApiError::OrderRejected(
2520                        msg.to_string(),
2521                    ))),
2522                };
2523            }
2524        };
2525
2526        // Validate order kind — reject unsupported types early.
2527        let order_type_str = match map_order_kind(kind) {
2528            Some(s) => s,
2529            None => {
2530                return Order {
2531                    key: order_key,
2532                    side,
2533                    price,
2534                    quantity,
2535                    kind,
2536                    time_in_force,
2537                    state: OrderState::inactive(OrderError::UnsupportedOrderType(format!(
2538                        "Alpaca connector does not yet support OrderKind::{kind:?}"
2539                    ))),
2540                };
2541            }
2542        };
2543
2544        // Validate trailing stop offset type — Alpaca only supports percent and absolute.
2545        if let OrderKind::TrailingStop {
2546            offset_type: TrailingOffsetType::BasisPoints,
2547            ..
2548        } = kind
2549        {
2550            return Order {
2551                key: order_key,
2552                side,
2553                price,
2554                quantity,
2555                kind,
2556                time_in_force,
2557                state: OrderState::inactive(OrderError::UnsupportedOrderType(
2558                    "Alpaca does not support TrailingOffsetType::BasisPoints; \
2559                     use Percentage or Absolute"
2560                        .to_string(),
2561                )),
2562            };
2563        }
2564
2565        // StopLimit requires Order.price (the limit price applied once trigger fires).
2566        // Guard locally to avoid a wire round-trip producing a generic 422.
2567        if matches!(kind, OrderKind::StopLimit { .. }) && price.is_none() {
2568            return Order {
2569                key: order_key,
2570                side,
2571                price,
2572                quantity,
2573                kind,
2574                time_in_force,
2575                state: OrderState::inactive(OrderError::Rejected(ApiError::OrderRejected(
2576                    "StopLimit order requires Order.price (the limit price) to be set".to_string(),
2577                ))),
2578            };
2579        }
2580
2581        // Extract stop/trailing parameters based on order kind.
2582        let (stop_price, trail_percent, trail_price) = match kind {
2583            OrderKind::Stop { trigger_price } | OrderKind::StopLimit { trigger_price } => {
2584                (Some(trigger_price.to_string()), None, None)
2585            }
2586            OrderKind::TrailingStop {
2587                offset,
2588                offset_type,
2589                ..
2590            } => match offset_type {
2591                TrailingOffsetType::Percentage => (None, Some(offset.to_string()), None),
2592                TrailingOffsetType::Absolute => (None, None, Some(offset.to_string())),
2593                TrailingOffsetType::BasisPoints => unreachable!("validated above"),
2594            },
2595            // TakeProfit/TakeProfitLimit rejected by map_order_kind → unreachable here
2596            OrderKind::Market
2597            | OrderKind::Limit
2598            | OrderKind::TakeProfit { .. }
2599            | OrderKind::TakeProfitLimit { .. }
2600            | OrderKind::TrailingStopLimit { .. }
2601            | OrderKind::TrailingStopFixedLimit { .. } => (None, None, None),
2602        };
2603
2604        let body = AlpacaOrderRequest {
2605            symbol: instrument.name().as_str(),
2606            qty: quantity.to_string(),
2607            side: map_side(side),
2608            order_type: order_type_str,
2609            time_in_force: tif_str,
2610            limit_price: match kind {
2611                OrderKind::Limit | OrderKind::StopLimit { .. } => price.map(|p| p.to_string()),
2612                _ => None,
2613            },
2614            stop_price,
2615            trail_percent,
2616            trail_price,
2617            client_order_id: Some(cid.0.as_str()),
2618            // position_intent is required for options orders and valid (but optional)
2619            // for equities. It is NOT a valid field for crypto orders and must be
2620            // omitted, or Alpaca will return 422 Unprocessable Entity.
2621            // We detect options by OCC symbol format; equities also get the field
2622            // as it aids intent tracking on margin accounts.
2623            position_intent: if is_options_or_equity_symbol(instrument.name().as_str()) {
2624                Some(intent)
2625            } else {
2626                None
2627            },
2628            // Non-bracket order: no bracket fields
2629            order_class: None,
2630            take_profit: None,
2631            stop_loss: None,
2632        };
2633
2634        let http = self.http.clone();
2635        let rl = &self.rate_limiter;
2636
2637        let result: Result<AlpacaOrderResponse, UnindexedClientError> =
2638            rest_with_retry(rl, || http.post(&self.orders_url).json(&body)).await;
2639
2640        match result {
2641            Ok(resp) => {
2642                let state = placed_order_state(&resp, &order_key.instrument, quantity);
2643                self.known_live
2644                    .lock()
2645                    .placed(&order_key, quantity, kind, &state);
2646
2647                Order {
2648                    key: order_key,
2649                    side,
2650                    price,
2651                    quantity,
2652                    kind,
2653                    time_in_force,
2654                    state,
2655                }
2656            }
2657            Err(e) => {
2658                let order_err = order_post_error(e);
2659                Order {
2660                    key: order_key,
2661                    side,
2662                    price,
2663                    quantity,
2664                    kind,
2665                    time_in_force,
2666                    state: OrderState::inactive(order_err),
2667                }
2668            }
2669        }
2670    }
2671}
2672
2673// ---------------------------------------------------------------------------
2674// REST helpers
2675// ---------------------------------------------------------------------------
2676
2677/// Maximum number of open orders returned by a single `/v2/orders` request.
2678///
2679/// Alpaca's API caps at 500; accounts exceeding this have an incomplete snapshot.
2680const MAX_OPEN_ORDERS: usize = 500;
2681
2682/// Fetch all open orders from Alpaca, optionally filtered by symbol.
2683///
2684/// # Errors
2685///
2686/// Returns [`UnindexedClientError::TruncatedSnapshot`] when exactly 500 results
2687/// are returned, indicating the API limit was likely hit and data may be incomplete.
2688/// This is an Alpaca API limitation with no pagination support for open orders.
2689async fn fetch_raw_open_orders(
2690    http: &reqwest::Client,
2691    rate_limiter: &RateLimitTracker,
2692    base: &str,
2693    instruments: &[InstrumentNameExchange],
2694) -> Result<Vec<AlpacaOrderResponse>, UnindexedClientError> {
2695    let orders: Vec<AlpacaOrderResponse> = if instruments.is_empty() {
2696        rest_with_retry(rate_limiter, || {
2697            http.get(format!("{base}/v2/orders"))
2698                .query(&[("status", "open"), ("limit", "500")])
2699        })
2700        .await?
2701    } else {
2702        let symbols = instruments.iter().map(|i| i.name().as_str()).join(",");
2703        rest_with_retry(rate_limiter, || {
2704            http.get(format!("{base}/v2/orders")).query(&[
2705                ("status", "open"),
2706                ("limit", "500"),
2707                ("symbols", &symbols),
2708            ])
2709        })
2710        .await?
2711    };
2712    if orders.len() == MAX_OPEN_ORDERS {
2713        warn!(
2714            limit = MAX_OPEN_ORDERS,
2715            "Alpaca fetch_raw_open_orders: received exactly {MAX_OPEN_ORDERS} results — \
2716             response is likely truncated"
2717        );
2718        return Err(UnindexedClientError::TruncatedSnapshot {
2719            limit: MAX_OPEN_ORDERS,
2720        });
2721    }
2722    Ok(orders)
2723}
2724
2725// ---------------------------------------------------------------------------
2726// Activity pagination
2727// ---------------------------------------------------------------------------
2728
2729/// Maximum number of pages fetched by one [`paginate_activities`] call.
2730///
2731/// 50 pages × 100 items = 5 000 fills. It bounds how long one read, such as a reconnect's
2732/// recovery, can take; a read that reaches it says where to read on from.
2733const MAX_ACTIVITY_PAGES: usize = 50;
2734
2735/// An account activity that [`paginate_activities`] pages through.
2736trait PagedActivity: DeserializeOwned {
2737    /// The activity's id, which is also the token for the page after it.
2738    fn id(&self) -> &str;
2739    /// The time Alpaca orders activities by and applies `after` and `until` to, or `None` when it
2740    /// does not parse.
2741    fn time(&self) -> Option<DateTime<Utc>>;
2742}
2743
2744impl PagedActivity for AlpacaActivity {
2745    fn id(&self) -> &str {
2746        &self.id
2747    }
2748
2749    fn time(&self) -> Option<DateTime<Utc>> {
2750        parse_timestamp(&self.transaction_time)
2751    }
2752}
2753
2754impl PagedActivity for AlpacaNonTradeActivity {
2755    fn id(&self) -> &str {
2756        &self.id
2757    }
2758
2759    fn time(&self) -> Option<DateTime<Utc>> {
2760        self.created_at.as_deref().and_then(parse_timestamp)
2761    }
2762}
2763
2764/// The `activity_types` a read of fills asks for.
2765const FILL_ACTIVITIES: &str = "FILL";
2766
2767/// The `activity_types` [`AlpacaClient::fetch_cash_flows`] asks for.
2768const CASH_FLOW_ACTIVITIES: &str = "INT,FEE,PTC,PTR";
2769
2770/// The `activity_types` [`AlpacaClient::fetch_option_events`] asks for.
2771const OPTION_EVENT_ACTIVITIES: &str = "OPASN,OPEXC,OPEXP,OPTRD";
2772
2773/// The last millisecond of a day, where [`AlpacaClient::fetch_option_events`] ends its read.
2774const LAST_MILLISECOND: NaiveTime = match NaiveTime::from_hms_milli_opt(23, 59, 59, 999) {
2775    Some(time) => time,
2776    None => panic!("23:59:59.999 is a time of day"),
2777};
2778
2779/// Where a bounded read of `start..=end` that stopped at `resume` should read on from: `None` when
2780/// it read the whole span, or `Err(())` when a read from `resume` would stop where this one did.
2781///
2782/// Alpaca's `until` is not exact, and is not sent for an `end` not yet reached, so a read can run
2783/// past `end`. Activities are ascending by millisecond, so a resume past `end` means every
2784/// activity in the span was read.
2785fn span_resume(
2786    resume: Option<DateTime<Utc>>,
2787    start: DateTime<Utc>,
2788    end: DateTime<Utc>,
2789) -> Result<Option<DateTime<Utc>>, ()> {
2790    let resume = resume.filter(|resume| *resume <= end);
2791    if resume.is_some_and(|resume| resume <= floor_millis(start)) {
2792        return Err(());
2793    }
2794    Ok(resume)
2795}
2796
2797/// What one [`paginate_activities`] call read.
2798struct ActivityPage<A> {
2799    /// Every activity read, in Alpaca's ascending order.
2800    activities: Vec<A>,
2801    /// Set when the read stopped at [`MAX_ACTIVITY_PAGES`]: every activity before this time was
2802    /// read, and some from it on may not have been.
2803    resume: Option<DateTime<Utc>>,
2804}
2805
2806/// `time` rounded down to the millisecond.
2807fn floor_millis(time: DateTime<Utc>) -> DateTime<Utc> {
2808    DateTime::from_timestamp_millis(time.timestamp_millis()).unwrap_or(time)
2809}
2810
2811/// Fetch the activities of the `activity_types` named in `types` (comma-separated) from `start`,
2812/// and up to `end` if given, using token-based pagination.
2813///
2814/// Alpaca returns up to `ALPACA_MAX_ACTIVITIES` per page. If a full page is
2815/// returned, the next request uses the last item's `id` as the `page_token`.
2816/// Pagination terminates when a page has fewer items than `page_size`, or after
2817/// [`MAX_ACTIVITY_PAGES`] pages (whichever comes first).
2818///
2819/// # Bounds
2820///
2821/// Alpaca's `after` and `until` are not exact. Observed on paper (2026-10-05):
2822/// - `after` matches an activity whose time, rounded down to the millisecond, is at or after it.
2823///   So a microsecond `after` drops the rest of its own millisecond. It is sent rounded down to
2824///   the millisecond, which reads the whole of `start`'s.
2825/// - `until` matched no single rule: exact to the microsecond for one fill, yet including a fill
2826///   15 µs past it for two that shared a millisecond. It is sent as the millisecond after
2827///   `end`'s, or not at all for an `end` not yet reached, and callers apply `end` to what is
2828///   read.
2829///
2830/// So the activities read can begin before `start` and end after `end`, within their
2831/// milliseconds.
2832///
2833/// # Resuming
2834///
2835/// Within one millisecond Alpaca orders activities by id, not by time, so a read cut inside a
2836/// millisecond may not have reached an earlier fill in it. [`ActivityPage::resume`] is therefore
2837/// the millisecond of the last activity read, and a read from it reads that millisecond again.
2838// Compile-time string form of ALPACA_MAX_ACTIVITIES (avoids runtime to_string() allocation).
2839const PAGE_SIZE_STR: &str = "100"; // must match ALPACA_MAX_ACTIVITIES
2840const _: () = assert!(
2841    ALPACA_MAX_ACTIVITIES == 100,
2842    "PAGE_SIZE_STR must be updated to match ALPACA_MAX_ACTIVITIES",
2843);
2844
2845async fn paginate_activities<A: PagedActivity>(
2846    http: &reqwest::Client,
2847    rate_limiter: &RateLimitTracker,
2848    base: &str,
2849    types: &str,
2850    start: DateTime<Utc>,
2851    end: Option<DateTime<Utc>>,
2852) -> Result<ActivityPage<A>, UnindexedClientError> {
2853    // Clamped to the epoch so a far-past `start` still formats as RFC 3339; Alpaca holds nothing
2854    // older. An `end` not yet reached sends no `until`, so a far-future one cannot overflow.
2855    let after =
2856        floor_millis(start.max(DateTime::UNIX_EPOCH)).to_rfc3339_opts(SecondsFormat::Millis, true);
2857    let until = end.filter(|end| *end < Utc::now()).map(|end| {
2858        (floor_millis(end) + TimeDelta::milliseconds(1))
2859            .to_rfc3339_opts(SecondsFormat::Millis, true)
2860    });
2861    let mut all = Vec::with_capacity(ALPACA_MAX_ACTIVITIES);
2862    let mut page_token: Option<String> = None;
2863    let mut pages = 0usize;
2864    let mut truncated = false;
2865
2866    loop {
2867        if pages >= MAX_ACTIVITY_PAGES {
2868            truncated = true;
2869            break;
2870        }
2871        pages += 1;
2872        // Borrow page_token as &str so the closure can capture by reference without cloning.
2873        let page_token_ref = page_token.as_deref();
2874        let activities: Vec<A> = rest_with_retry(rate_limiter, || {
2875            let mut req = http.get(format!("{base}/v2/account/activities")).query(&[
2876                ("activity_types", types),
2877                ("after", after.as_str()),
2878                ("page_size", PAGE_SIZE_STR),
2879                ("direction", "asc"),
2880            ]);
2881            if let Some(until) = until.as_deref() {
2882                req = req.query(&[("until", until)]);
2883            }
2884            if let Some(token) = page_token_ref {
2885                req = req.query(&[("page_token", token)]);
2886            }
2887            req
2888        })
2889        .await?;
2890
2891        let page_len = activities.len();
2892        // Capture the page token from the current page BEFORE extending `all`, so that
2893        // any future filtering between here and all.extend cannot shift the last element
2894        // and cause the same page to be re-fetched indefinitely.
2895        //
2896        // Alpaca activity IDs are ULID-format (monotonically increasing) and are accepted
2897        // as exclusive page_token cursors by GET /v2/account/activities: the next page
2898        // begins with the item AFTER the token, so the boundary item is not re-delivered.
2899        // The pagination contract relies on this property — verify against Alpaca API docs
2900        // if behaviour changes.
2901        let page_token_candidate = activities.last().map(|a| a.id().to_owned());
2902        all.extend(activities);
2903
2904        if page_len < ALPACA_MAX_ACTIVITIES {
2905            break;
2906        }
2907        match page_token_candidate {
2908            Some(token) if !token.is_empty() => {
2909                debug!("Alpaca paginate_activities: fetching next page ({page_len} results)");
2910                page_token = Some(token);
2911            }
2912            // Empty token or no last item — pagination complete.
2913            // Guard against empty string to prevent infinite loop if Alpaca ever
2914            // returns a full page with last.id = "" (would restart from beginning).
2915            _ => break,
2916        }
2917    }
2918
2919    // Activities are ascending by millisecond, so the last one whose time parses bounds what is
2920    // left. None parsing leaves everything from `start`.
2921    let resume = truncated.then(|| {
2922        floor_millis(
2923            all.iter()
2924                .rev()
2925                .find_map(PagedActivity::time)
2926                .unwrap_or(start),
2927        )
2928    });
2929    Ok(ActivityPage {
2930        activities: all,
2931        resume,
2932    })
2933}
2934
2935// ---------------------------------------------------------------------------
2936// WebSocket connection manager
2937// ---------------------------------------------------------------------------
2938
2939/// Long-running task managing the WebSocket lifecycle for account_stream.
2940///
2941/// Loop: connect → auth → subscribe → fill recovery → ended-order check → stream events → on
2942/// disconnect → backoff → reconnect. The `tx` channel persists across reconnections so the
2943/// consumer sees a seamless event stream.
2944///
2945/// Terminates when the consumer drops the stream or max reconnect attempts are
2946/// exhausted.
2947#[allow(clippy::cognitive_complexity)] // the inner select! loop owns `ws` and mutates `backoff` —
2948// extracting to a function requires threading 4 non-Clone values (ws, tx, dedup, backoff)
2949// through the call, which adds more complexity than it removes
2950async fn connection_manager(
2951    tx: mpsc::UnboundedSender<UnindexedAccountEvent>,
2952    dedup: SharedDedupCache,
2953    config: Arc<AlpacaConfig>,
2954    http: reqwest::Client,
2955    rate_limiter: Arc<RateLimitTracker>,
2956    known: SharedKnownLiveOrders,
2957    instruments: Vec<InstrumentNameExchange>,
2958    initial_ws: Option<WebSocket>,
2959) {
2960    let mut backoff = ExponentialBackoff::new();
2961    let mut disconnect_time: Option<DateTime<Utc>> = None;
2962    // Instruments whose known-live orders a reconnect has yet to check: checked after the fills,
2963    // and retried on a timer while connected, once due.
2964    let mut unchecked = UncheckedOrders::default();
2965    let mut current_ws = initial_ws;
2966
2967    'outer: loop {
2968        // --- Connect (skip on first iteration if initial_ws was provided) ---
2969        let mut ws = match current_ws.take() {
2970            Some(ws) => ws,
2971            None => match connect_and_subscribe(&config).await {
2972                Ok(ws) => ws,
2973                Err(e) => {
2974                    error!(%e, "Alpaca WS connect/subscribe failed");
2975                    if !backoff.wait().await {
2976                        error!("Alpaca max reconnect attempts exhausted");
2977                        // Signal terminal stream death in-band before tx drops.
2978                        emit_stream_terminated(
2979                            &tx,
2980                            ExchangeId::AlpacaBroker,
2981                            StreamTerminationReason::ReconnectBudgetExhausted {
2982                                attempts: backoff.attempts(),
2983                                last_error: e.to_string(),
2984                            },
2985                        );
2986                        break;
2987                    }
2988                    continue;
2989                }
2990            },
2991        };
2992        info!("Alpaca account_stream connected and subscribed");
2993        // backoff is NOT reset here: a server that accepts each handshake and closes the
2994        // connection at once would then be retried at INITIAL_BACKOFF_MS forever, never
2995        // exhausting the budget. The stream loop resets it on a frame received once the connection
2996        // has lasted a heartbeat window.
2997
2998        // --- Fill recovery after reconnect ---
2999        // Runs before the event loop so live events arriving during recovery are
3000        // captured by the already-connected WS session. The dedup cache prevents
3001        // duplicates between recovered REST fills and live WS events.
3002        if let Some(dt) = disconnect_time.take() {
3003            recover_fills_or_report(
3004                &http,
3005                &rate_limiter,
3006                &instruments,
3007                config.rest_base_url(),
3008                dt,
3009                Duration::from_secs(FILL_RECOVERY_TIMEOUT_SECS),
3010                &tx,
3011                &dedup,
3012                &known,
3013            )
3014            .await;
3015            // Only the instruments this stream recovers fills for, every one when none is named.
3016            let held = {
3017                let known = known.lock();
3018                if instruments.is_empty() {
3019                    known.instruments()
3020                } else {
3021                    known.instruments_among(&instruments)
3022                }
3023            };
3024            unchecked.open(held);
3025        }
3026
3027        // --- Stream events ---
3028        // Each iteration of the inner loop polls ws.next(), a heartbeat timer,
3029        // and tx.closed() simultaneously via select!. The heartbeat deadline is
3030        // reset on every received message (rolling window).
3031        //
3032        // The timer is pinned once and reset in-place via Sleep::reset(), avoiding
3033        // a new Sleep allocation and Tokio timer-wheel registration per loop iteration.
3034        // Track the wall-clock time of the last received message. Used to anchor
3035        // fill recovery after a heartbeat timeout: Utc::now() at reconnect time
3036        // would be ~HEARTBEAT_TIMEOUT_SECS after the last real message, causing
3037        // the recovery window to miss fills in that silent period.
3038
3039        let mut last_message_time = Utc::now();
3040        let heartbeat = tokio::time::sleep(Duration::from_secs(HEARTBEAT_TIMEOUT_SECS));
3041        tokio::pin!(heartbeat);
3042        // A connection still receiving a heartbeat window after the stream loop starts is proven
3043        // stable: the failures before it are cleared, so only consecutive ones exhaust the budget.
3044        let stable_from = tokio::time::Instant::now() + Duration::from_secs(HEARTBEAT_TIMEOUT_SECS);
3045
3046        // How the orders held as live ended: checked alongside the stream, so it is read, and a
3047        // disconnect seen, from the start. Fill recovery has finished or given up above, so the
3048        // fills come first. Dropping this when the stream loop ends loses nothing, since each
3049        // lookup and each check is settled in one step as soon as it ends.
3050        let order_checks =
3051            run_order_checks(&http, &rate_limiter, &config, &known, &mut unchecked, &tx);
3052        tokio::pin!(order_checks);
3053
3054        // Resets the rolling heartbeat deadline, records the wall-clock receive time, and clears
3055        // the backoff once the connection is proven stable.
3056        // Pin<&mut Sleep> cannot be passed to a regular function, so a macro avoids
3057        // repeating the same block across every message-bearing select! arm.
3058        // NOTE: must be defined AFTER the variables it captures for macro hygiene.
3059        macro_rules! reset_heartbeat {
3060            () => {
3061                let now = tokio::time::Instant::now();
3062                heartbeat
3063                    .as_mut()
3064                    .reset(now + Duration::from_secs(HEARTBEAT_TIMEOUT_SECS));
3065                last_message_time = Utc::now();
3066                if now >= stable_from {
3067                    backoff.reset();
3068                }
3069            };
3070        }
3071        // Distinguishes why the inner stream loop exited so the terminal emit below can report a
3072        // meaningful `last_error`. The consumer-drop arm uses `break 'outer` and never yields one.
3073        enum DisconnectReason {
3074            ServerClose,
3075            Error(String),
3076            StreamEnded,
3077            HeartbeatTimeout,
3078        }
3079        let reason = loop {
3080            tokio::select! {
3081                msg = ws.next() => {
3082                    match msg {
3083                        Some(Ok(WsMessage::Ping(_))) => {
3084                            // tokio-tungstenite automatically queues a Pong when poll_next
3085                            // returns a Ping; sending a second Pong would be a duplicate.
3086                            reset_heartbeat!();
3087                        }
3088                        Some(Ok(WsMessage::Text(text))) => {
3089                            process_ws_text(text.as_str(), &tx, &dedup, &known);
3090                            reset_heartbeat!();
3091                        }
3092                        Some(Ok(WsMessage::Binary(bytes))) => {
3093                            // Alpaca paper trading sends binary-framed JSON.
3094                            match std::str::from_utf8(&bytes) {
3095                                Ok(text) => {
3096                                    process_ws_text(text, &tx, &dedup, &known);
3097                                    // Only reset heartbeat for valid UTF-8 frames that may carry
3098                                    // real events. A corrupt binary frame (e.g. from a proxy)
3099                                    // must not keep the watchdog from firing.
3100                                    reset_heartbeat!();
3101                                }
3102                                Err(e) => warn!(%e, "Alpaca WS binary frame: not valid UTF-8"),
3103                            }
3104                        }
3105                        Some(Ok(WsMessage::Close(frame))) => {
3106                            warn!(frame = ?frame, "Alpaca WS closed by server");
3107                            break DisconnectReason::ServerClose;
3108                        }
3109                        Some(Ok(_)) => {} // Pong, Frame — ignore
3110                        Some(Err(e)) => {
3111                            warn!(%e, "Alpaca WS error, reconnecting");
3112                            break DisconnectReason::Error(e.to_string());
3113                        }
3114                        None => {
3115                            warn!("Alpaca WS stream ended, reconnecting");
3116                            break DisconnectReason::StreamEnded;
3117                        }
3118                    }
3119                }
3120                () = &mut order_checks => {}
3121                _ = &mut heartbeat => {
3122                    warn!(
3123                        timeout_secs = HEARTBEAT_TIMEOUT_SECS,
3124                        "Alpaca heartbeat timeout, reconnecting"
3125                    );
3126                    // Heartbeat timeout is a failure — do NOT reset backoff here.
3127                    break DisconnectReason::HeartbeatTimeout;
3128                }
3129                _ = tx.closed() => {
3130                    debug!("Alpaca account_stream consumer dropped, terminating");
3131                    let _ = tokio::time::timeout(
3132                        Duration::from_secs(WS_CLOSE_TIMEOUT_SECS),
3133                        ws.close(None),
3134                    ).await;
3135                    // Consumer dropped the receiver — no StreamTerminated emit (channel already
3136                    // closed, so it would be a no-op; see emit_stream_terminated docs).
3137                    break 'outer;
3138                }
3139            }
3140        };
3141
3142        // --- Record disconnect time for fill recovery ---
3143        // Anchor to last_message_time, not Utc::now(). For heartbeat-triggered
3144        // disconnects, Utc::now() would be ~HEARTBEAT_TIMEOUT_SECS after the last
3145        // real message, causing the recovery window to miss fills in that gap.
3146        disconnect_time =
3147            Some(last_message_time - chrono::Duration::milliseconds(SIGNAL_RECOVERY_LOOKBACK_MS));
3148
3149        // --- Close stale WS ---
3150        let _ =
3151            tokio::time::timeout(Duration::from_secs(WS_CLOSE_TIMEOUT_SECS), ws.close(None)).await;
3152
3153        if tx.is_closed() {
3154            // Consumer dropped the receiver — no StreamTerminated emit (channel already closed,
3155            // so it would be a no-op; see emit_stream_terminated docs).
3156            break;
3157        }
3158        if !backoff.wait().await {
3159            error!("Alpaca max reconnect attempts exhausted, stream terminating");
3160            // Surrender after repeated reconnects: report the failure that triggered this round
3161            // (consumer-drop already broke out via `break 'outer`).
3162            let last_error = match reason {
3163                DisconnectReason::HeartbeatTimeout => {
3164                    format!("heartbeat timeout ({HEARTBEAT_TIMEOUT_SECS}s)")
3165                }
3166                DisconnectReason::ServerClose => "WebSocket closed by server".to_string(),
3167                DisconnectReason::Error(e) => e,
3168                DisconnectReason::StreamEnded => "WebSocket stream ended".to_string(),
3169            };
3170            emit_stream_terminated(
3171                &tx,
3172                ExchangeId::AlpacaBroker,
3173                StreamTerminationReason::ReconnectBudgetExhausted {
3174                    attempts: backoff.attempts(),
3175                    last_error,
3176                },
3177            );
3178            break;
3179        }
3180    }
3181}
3182
3183/// Error during WebSocket handshake (auth + subscribe).
3184///
3185/// Separates transport errors (network issues, connection drops) from auth errors
3186/// (invalid credentials) so callers can apply appropriate retry logic.
3187#[derive(Debug)]
3188enum HandshakeError {
3189    /// Network-level failure (WS send failed, connection dropped).
3190    /// Transient — retry with backoff.
3191    Transport(String),
3192    /// Authentication rejected by Alpaca (invalid/expired credentials).
3193    /// Not transient — do not retry without credential changes.
3194    Auth(String),
3195}
3196
3197/// Connect to the Alpaca WebSocket, authenticate, and subscribe to trade_updates.
3198///
3199/// On auth or subscribe failure the connection is closed cleanly.
3200async fn connect_and_subscribe(config: &AlpacaConfig) -> Result<WebSocket, UnindexedClientError> {
3201    let url = config.stream_url();
3202    debug!(%url, "Alpaca: connecting to WebSocket");
3203
3204    let mut ws = rustrade_integration::protocol::websocket::connect(url)
3205        .await
3206        .map_err(|e| {
3207            UnindexedClientError::Connectivity(ConnectivityError::Socket(format!(
3208                "WS connect: {e}"
3209            )))
3210        })?;
3211
3212    // auth + subscribe with overall timeout
3213    let result = tokio::time::timeout(
3214        Duration::from_secs(WS_HANDSHAKE_TIMEOUT_SECS),
3215        ws_handshake(&mut ws, config),
3216    )
3217    .await;
3218
3219    match result {
3220        Ok(Ok(())) => Ok(ws),
3221        Ok(Err(HandshakeError::Transport(e))) => {
3222            let _ = ws.close(None).await;
3223            Err(UnindexedClientError::Connectivity(
3224                ConnectivityError::Socket(e),
3225            ))
3226        }
3227        Ok(Err(HandshakeError::Auth(e))) => {
3228            let _ = ws.close(None).await;
3229            Err(UnindexedClientError::Api(ApiError::Unauthenticated(e)))
3230        }
3231        Err(_) => {
3232            let _ = ws.close(None).await;
3233            Err(UnindexedClientError::Connectivity(
3234                ConnectivityError::Timeout,
3235            ))
3236        }
3237    }
3238}
3239
3240/// Perform the Alpaca WS auth + subscribe sequence on a connected WebSocket.
3241///
3242/// Protocol:
3243/// 1. Send `{"action":"auth","key":...,"secret":...}`
3244/// 2. Await `{"stream":"authorization","data":{"status":"authorized",...}}`
3245/// 3. Send `{"action":"listen","data":{"streams":["trade_updates"]}}`
3246/// 4. Await `{"stream":"listening","data":{"streams":["trade_updates"]}}`
3247async fn ws_handshake(ws: &mut WebSocket, config: &AlpacaConfig) -> Result<(), HandshakeError> {
3248    // Step 1: send auth
3249    let auth = serde_json::json!({
3250        "action": "auth",
3251        "key": config.api_key(),
3252        // direct field access within module — secret_key has no pub getter to
3253        // prevent external credential exposure
3254        "secret": config.secret_key,
3255    })
3256    .to_string();
3257    ws.send(WsMessage::Text(auth.into()))
3258        .await
3259        .map_err(|e| HandshakeError::Transport(format!("WS auth send: {e}")))?;
3260
3261    // Step 2: wait for authorization message
3262    loop {
3263        match ws.next().await {
3264            Some(Ok(WsMessage::Text(text))) => {
3265                if let Some(result) = check_auth_response(text.as_str()) {
3266                    result?;
3267                    break;
3268                }
3269            }
3270            Some(Ok(WsMessage::Binary(bytes))) => {
3271                if let Ok(text) = std::str::from_utf8(&bytes)
3272                    && let Some(result) = check_auth_response(text)
3273                {
3274                    result?;
3275                    break;
3276                }
3277            }
3278            Some(Err(e)) => {
3279                return Err(HandshakeError::Transport(format!(
3280                    "WS error during auth: {e}"
3281                )));
3282            }
3283            None => {
3284                return Err(HandshakeError::Transport(
3285                    "WS closed before auth response".into(),
3286                ));
3287            }
3288            _ => {} // ping/pong during auth — ignore
3289        }
3290    }
3291
3292    // Step 3: subscribe to trade_updates
3293    let sub = serde_json::json!({
3294        "action": "listen",
3295        "data": { "streams": ["trade_updates"] }
3296    })
3297    .to_string();
3298    ws.send(WsMessage::Text(sub.into()))
3299        .await
3300        .map_err(|e| HandshakeError::Transport(format!("WS subscribe send: {e}")))?;
3301
3302    // Step 4: wait for listening acknowledgment (optional but confirms subscription)
3303    loop {
3304        match ws.next().await {
3305            Some(Ok(WsMessage::Text(text))) => {
3306                if check_listen_ack(text.as_str()) {
3307                    break;
3308                }
3309                // Other messages in this window are NOT buffered and are permanently
3310                // dropped. On a reconnect, REST recovers the fills and how each order held
3311                // as live ended; any other lifecycle event is lost, as documented in
3312                // account_stream's doc comment.
3313                // Log at warn! for trade_updates (fill/lifecycle events) to ensure
3314                // production operators see dropped events; trace! for other streams.
3315                if let Ok(msg) = serde_json::from_str::<AlpacaStreamMessage<'_>>(text.as_str()) {
3316                    if msg.stream == "trade_updates" {
3317                        warn!(stream = %msg.stream, "WS trade_updates event dropped during listen-ack handshake — on a reconnect, fills and how orders held as live ended are recovered via REST; other lifecycle events are lost");
3318                    } else {
3319                        trace!(stream = %msg.stream, "WS message dropped during listen-ack handshake");
3320                    }
3321                } else {
3322                    trace!(
3323                        bytes = text.len(),
3324                        "WS non-stream message dropped during listen-ack handshake"
3325                    );
3326                }
3327            }
3328            Some(Ok(WsMessage::Binary(bytes))) => {
3329                if let Ok(text) = std::str::from_utf8(&bytes)
3330                    && check_listen_ack(text)
3331                {
3332                    break;
3333                }
3334                trace!(
3335                    bytes = bytes.len(),
3336                    "WS binary message dropped during listen-ack handshake"
3337                );
3338            }
3339            Some(Err(e)) => {
3340                return Err(HandshakeError::Transport(format!(
3341                    "WS error during subscribe: {e}"
3342                )));
3343            }
3344            None => {
3345                return Err(HandshakeError::Transport(
3346                    "WS closed before subscribe ack".into(),
3347                ));
3348            }
3349            _ => {}
3350        }
3351    }
3352
3353    info!("Alpaca WS authenticated and subscribed to trade_updates");
3354    Ok(())
3355}
3356
3357/// Parse a WS message to check for authorization response.
3358/// Returns `None` if the message is not an authorization response.
3359/// Returns `Some(Ok(()))` on success, `Some(Err(HandshakeError::Auth(...)))` on auth failure.
3360///
3361/// Uses `AlpacaStreamMessage` for consistency with the rest of the WS parsing pipeline.
3362fn check_auth_response(text: &str) -> Option<Result<(), HandshakeError>> {
3363    let msg = serde_json::from_str::<AlpacaStreamMessage<'_>>(text).ok()?;
3364    if msg.stream != "authorization" {
3365        return None;
3366    }
3367    #[derive(Deserialize)]
3368    struct AuthData<'a> {
3369        status: &'a str,
3370    }
3371    let data = serde_json::from_str::<AuthData<'_>>(msg.data.get()).ok()?;
3372    if data.status == "authorized" {
3373        Some(Ok(()))
3374    } else {
3375        Some(Err(HandshakeError::Auth(format!(
3376            "Alpaca WS auth failed: status={}",
3377            data.status
3378        ))))
3379    }
3380}
3381
3382/// Returns `true` if the WS message is a listening acknowledgment for trade_updates.
3383///
3384/// Uses `AlpacaStreamMessage` for consistency with the rest of the WS parsing pipeline.
3385fn check_listen_ack(text: &str) -> bool {
3386    let Ok(msg) = serde_json::from_str::<AlpacaStreamMessage<'_>>(text) else {
3387        return false;
3388    };
3389    if msg.stream != "listening" {
3390        return false;
3391    }
3392    #[derive(Deserialize)]
3393    struct ListenData<'a> {
3394        #[serde(borrow)]
3395        streams: Vec<&'a str>,
3396    }
3397    let Ok(data) = serde_json::from_str::<ListenData<'_>>(msg.data.get()) else {
3398        return false;
3399    };
3400    data.streams.contains(&"trade_updates")
3401}
3402
3403// ---------------------------------------------------------------------------
3404// Process incoming WS messages
3405// ---------------------------------------------------------------------------
3406
3407/// Parse a raw WS text message and forward relevant account events to `tx`.
3408///
3409/// Each event is applied to `known` before it is sent, under the lock, so an order this reports
3410/// ending and a reconnect's check of it reach the consumer in the order they were decided, and the
3411/// check reports only an order not already reported.
3412fn process_ws_text(
3413    text: &str,
3414    tx: &mpsc::UnboundedSender<UnindexedAccountEvent>,
3415    dedup: &SharedDedupCache,
3416    known: &SharedKnownLiveOrders,
3417) {
3418    let msg: AlpacaStreamMessage<'_> = match serde_json::from_str(text) {
3419        Ok(m) => m,
3420        Err(e) => {
3421            trace!(
3422                %e,
3423                raw = ?&text[..text.len().min(200)],
3424                "Alpaca WS: skipped non-JSON message"
3425            );
3426            return;
3427        }
3428    };
3429
3430    match msg.stream.as_str() {
3431        "trade_updates" => {
3432            let update: AlpacaTradeUpdate<'_> = match serde_json::from_str(msg.data.get()) {
3433                Ok(u) => u,
3434                Err(e) => {
3435                    // Use warn (not trace): the outer frame was valid trade_updates JSON,
3436                    // so failure here signals an unexpected payload format. Could indicate
3437                    // an Alpaca API change or a missing required field dropping a real
3438                    // event (e.g. a rejected order). Visible at production log levels.
3439                    warn!(
3440                        %e,
3441                        raw = ?&msg.data.get()[..msg.data.get().len().min(200)],
3442                        "Alpaca WS trade_updates: failed to deserialize event — event dropped"
3443                    );
3444                    return;
3445                }
3446            };
3447
3448            // PERF: Check dedup BEFORE constructing the full event for fill events.
3449            // This avoids heap allocations (Trade, InstrumentNameExchange, TradeId) on
3450            // duplicate fills, which occur on every reconnect during recovery overlap.
3451            if is_fill_event(&update)
3452                && is_duplicate_fill(dedup, ws_execution_id(&update), early_dedup_key(&update))
3453            {
3454                trace!("Alpaca WS: skipping duplicate fill event (early check)");
3455                return;
3456            }
3457
3458            // Neither amendment is in the Trading API's docs, and no real frame has been seen, so
3459            // log it whole: the first one shows what Alpaca sends.
3460            if is_trade_amendment(&update) {
3461                warn!(
3462                    event = %update.event,
3463                    raw = %msg.data.get(),
3464                    "Alpaca WS: a fill reported earlier was amended — reporting it as TradeAmended"
3465                );
3466            }
3467
3468            for event in convert_trade_update(update).into_iter().flatten() {
3469                // A resolved correction delivers its execution as the replacement trade. Should
3470                // Alpaca also list it as a FILL activity, recovery must not deliver it again. An
3471                // unresolved one delivered no trade, so recovery may.
3472                if let AccountEventKind::TradeAmended(amendment) = &event.kind
3473                    && let TradeAmendmentKind::Corrected { replacement } = &amendment.kind
3474                {
3475                    record_execution(dedup, &replacement.id.0);
3476                }
3477                let known = KnownLiveOrders::observes(&event.kind).then(|| {
3478                    let mut known = known.lock();
3479                    known.observe(&event);
3480                    known
3481                });
3482                // Consumer dropped errors are benign; connection_manager will detect
3483                // tx.closed() on the next select! poll and exit cleanly.
3484                let _ = tx.send(event);
3485                drop(known);
3486            }
3487        }
3488        "authorization" | "listening" => {
3489            // Ack messages can appear during initial stream if the handshake was
3490            // not fully awaited. These are benign.
3491            trace!(stream = %msg.stream, "Alpaca WS: auth/listen ack received during stream");
3492        }
3493        other => {
3494            trace!(%other, "Alpaca WS: ignoring unknown stream type");
3495        }
3496    }
3497}
3498
3499/// The cumulative dedup key for a fill that took order `order_id` to `cumulative` filled: what
3500/// [`is_duplicate_fill`] recognises a fill by when it has no execution id.
3501///
3502/// The account stream and fill recovery both build it, from the cumulative each reports for the
3503/// order (the WS frame's `order.filled_qty`, the FILL activity's `cum_qty`), so one fill
3504/// delivered by both is recognised. `normalize` strips trailing zeros, so `"1.00"` and `"1"` give
3505/// one key. A WS fill whose `filled_qty` does not parse is keyed at zero.
3506///
3507/// The `format_smolstr!` call heap-allocates for UUID-length order ids (36 chars exceeds
3508/// SmolStr's inline limit), which is unavoidable given the key length.
3509fn fill_dedup_key(order_id: &str, cumulative: Decimal) -> SmolStr {
3510    format_smolstr!("{}:{}", order_id, cumulative.normalize())
3511}
3512
3513/// Returns `true` if this event busts or corrects a fill reported earlier.
3514fn is_trade_amendment(update: &AlpacaTradeUpdate<'_>) -> bool {
3515    matches!(update.event.as_str(), "trade_bust" | "trade_correct")
3516}
3517
3518/// Returns `true` if this event type produces a fill (Trade) event.
3519///
3520/// Used for early dedup check before allocating the full event.
3521#[inline]
3522fn is_fill_event(update: &AlpacaTradeUpdate<'_>) -> bool {
3523    matches!(update.event.as_str(), "fill" | "partial_fill")
3524}
3525
3526/// A WS event's `execution_id`, or `None` when it is missing, null or empty.
3527fn ws_execution_id<'a>(update: &AlpacaTradeUpdate<'a>) -> Option<&'a str> {
3528    update.execution_id.filter(|id| !id.is_empty())
3529}
3530
3531/// The [`fill_dedup_key`] of a WS fill, from its raw fields, before the full event is built.
3532///
3533/// An unparseable `filled_qty` reads as zero, as `convert_trade_update` reports it.
3534fn early_dedup_key(update: &AlpacaTradeUpdate<'_>) -> SmolStr {
3535    let filled_qty = update.order.filled_qty.unwrap_or("0");
3536    let qty = Decimal::from_str(filled_qty).unwrap_or(Decimal::ZERO);
3537    fill_dedup_key(&update.order.id, qty)
3538}
3539
3540/// The execution id in a FILL activity's `id`, `"{time}::{execution id}"`: the same id the WS
3541/// fill carries as `execution_id`. The time prefix is US Eastern local time, so it is never read.
3542/// An id without `::`, or with nothing after it, is taken whole, so it stays unique.
3543fn activity_execution_id(activity_id: &str) -> &str {
3544    activity_execution_id_part(activity_id).unwrap_or(activity_id)
3545}
3546
3547/// The execution id after `::` in a FILL activity's `id`, or `None` when there is none: an id
3548/// taken whole by [`activity_execution_id`] is not one the WS fill carries, so dedup does not
3549/// key on it.
3550fn activity_execution_id_part(activity_id: &str) -> Option<&str> {
3551    activity_id
3552        .split_once("::")
3553        .map(|(_, execution_id)| execution_id)
3554        .filter(|execution_id| !execution_id.is_empty())
3555}
3556
3557// ---------------------------------------------------------------------------
3558// Fill recovery
3559// ---------------------------------------------------------------------------
3560
3561/// Fills a recovery read did not deliver: those from `start` until the recovery began.
3562#[derive(Debug, PartialEq)]
3563struct UnreadFills {
3564    start: DateTime<Utc>,
3565    reason: FillRecoveryFailure,
3566}
3567
3568/// Recover the fills missed since `disconnect` ([`recover_fills`]) within `timeout`, and send an
3569/// [`AccountEventKind::FillRecoveryGaveUp`] for those the read did not deliver, after the fills
3570/// it did.
3571async fn recover_fills_or_report(
3572    http: &reqwest::Client,
3573    rate_limiter: &RateLimitTracker,
3574    instruments: &[InstrumentNameExchange],
3575    base: &str,
3576    disconnect: DateTime<Utc>,
3577    timeout: Duration,
3578    tx: &mpsc::UnboundedSender<UnindexedAccountEvent>,
3579    dedup: &SharedDedupCache,
3580    known: &SharedKnownLiveOrders,
3581) {
3582    // The live stream is already subscribed, so every fill after this arrives live.
3583    let recovery_start = Utc::now();
3584    let outcome = match tokio::time::timeout(
3585        timeout,
3586        recover_fills(
3587            http,
3588            rate_limiter,
3589            instruments,
3590            base,
3591            disconnect,
3592            tx,
3593            dedup,
3594            known,
3595        ),
3596    )
3597    .await
3598    {
3599        Ok(outcome) => outcome,
3600        // One account-wide activities query serves every instrument, so a timeout can have missed
3601        // fills in any of them: name the requested set. Nothing was forwarded, since recovery
3602        // sends its fills only after the read has finished.
3603        Err(_) => {
3604            warn!(
3605                timeout_secs = timeout.as_secs(),
3606                instruments = ?instruments
3607                    .iter()
3608                    .map(|instrument| instrument.name().as_str())
3609                    .collect::<Vec<_>>(),
3610                "Alpaca fill recovery timed out — fills since the disconnect may be missing for \
3611                 any of the instruments (every instrument when the list is empty)"
3612            );
3613            Err(UnreadFills {
3614                start: disconnect,
3615                reason: FillRecoveryFailure::TimedOut {
3616                    timeout_secs: timeout.as_secs(),
3617                },
3618            })
3619        }
3620    };
3621    if let Err(unread) = outcome
3622        && tx
3623            .send(fill_recovery_gave_up(instruments, unread, recovery_start))
3624            .is_err()
3625    {
3626        debug!("Alpaca fill recovery: consumer dropped before the give-up was sent");
3627    }
3628}
3629
3630/// The [`AccountEventKind::FillRecoveryGaveUp`] for `unread`, whose recovery began at `end`,
3631/// on a stream opened with `instruments`, every instrument when empty. Alpaca does not retry the
3632/// read, so it was read once.
3633///
3634/// A truncated read can return fills stamped after `end`, by Alpaca's clock, which may run ahead
3635/// of this host's. The span then ends at its start rather than before it: the event is still
3636/// sent, since a report with nothing left to read costs the consumer one read, while a report
3637/// withheld on a clock comparison would be a silent loss.
3638fn fill_recovery_gave_up(
3639    instruments: &[InstrumentNameExchange],
3640    unread: UnreadFills,
3641    end: DateTime<Utc>,
3642) -> UnindexedAccountEvent {
3643    let UnreadFills { start, reason } = unread;
3644    // One account-wide read serves every instrument, so one event covers them all.
3645    let scope = if instruments.is_empty() {
3646        FillRecoveryScope::AllInstruments
3647    } else {
3648        FillRecoveryScope::Instruments(instruments.to_vec())
3649    };
3650    UnindexedAccountEvent::new(
3651        ExchangeId::AlpacaBroker,
3652        AccountEventKind::FillRecoveryGaveUp(FillRecoveryGap::new(
3653            scope,
3654            start,
3655            end.max(start),
3656            1,
3657            reason,
3658        )),
3659    )
3660}
3661
3662/// Fetch fills missed during a WS disconnect, since `after`, and forward through the dedup cache.
3663///
3664/// A fill that brings an order to its full quantity ends it in `known`, so a reconnect's check
3665/// does not report it again.
3666///
3667/// Returns the fills it did not deliver: all of them when the read fails, and those from the
3668/// millisecond of the last one read when the read stops at [`MAX_ACTIVITY_PAGES`]. The fills it read are sent
3669/// first. A consumer that drops the stream part-way gets `Ok`, as there is no one to report to.
3670async fn recover_fills(
3671    http: &reqwest::Client,
3672    rate_limiter: &RateLimitTracker,
3673    instruments: &[InstrumentNameExchange],
3674    base: &str,
3675    after: DateTime<Utc>,
3676    tx: &mpsc::UnboundedSender<UnindexedAccountEvent>,
3677    dedup: &SharedDedupCache,
3678    known: &SharedKnownLiveOrders,
3679) -> Result<(), UnreadFills> {
3680    // Empty `instruments` means "recover for all subscribed symbols" (same convention as
3681    // `fetch_trades` / `fetch_open_orders`). Skip the set allocation when not filtering.
3682    info!(%after, instruments = instruments.len(), "Alpaca recovering fills after reconnect");
3683    let instrument_set: fnv::FnvHashSet<&str> = if instruments.is_empty() {
3684        fnv::FnvHashSet::default()
3685    } else {
3686        instruments.iter().map(|i| i.name().as_str()).collect()
3687    };
3688
3689    let page = match paginate_activities::<AlpacaActivity>(
3690        http,
3691        rate_limiter,
3692        base,
3693        FILL_ACTIVITIES,
3694        after,
3695        None,
3696    )
3697    .await
3698    {
3699        Ok(p) => p,
3700        Err(e) => {
3701            error!(%e, "Alpaca fill recovery: REST request failed");
3702            return Err(UnreadFills {
3703                start: after,
3704                reason: FillRecoveryFailure::Request(e.to_string()),
3705            });
3706        }
3707    };
3708
3709    let activities = page.activities;
3710
3711    // The read is account-wide, so one that stopped at the page cap has every fill before its
3712    // resume point, whatever the instrument: the millisecond of its last fill, which a read from
3713    // there reads again. The dedup cache absorbs the fills read twice.
3714    let unread = page.resume.map(|start| {
3715        error!(
3716            max_pages = MAX_ACTIVITY_PAGES,
3717            fills_read = activities.len(),
3718            %start,
3719            "Alpaca fill recovery: max page limit reached, truncating — fills from this time on \
3720             are not delivered; read them with fetch_trades"
3721        );
3722        UnreadFills {
3723            start,
3724            reason: FillRecoveryFailure::Truncated {
3725                fills_read: activities.len(),
3726            },
3727        }
3728    });
3729
3730    let mut recovered = 0u32;
3731    let mut duplicates = 0u32;
3732
3733    // Fallback only. A fill is recognised by its execution id, and by its cumulative key
3734    // "{order_id}:{cumulative_filled_qty}" where either path lacks the id (see
3735    // `is_duplicate_fill`). The cumulative is taken from the activity's own `cum_qty` -- the same
3736    // figure the WS path reads from `order.filled_qty` -- so the two agree by construction rather
3737    // than by reconstruction.
3738    //
3739    // This counter is what the cumulative key falls back to when `cum_qty` is absent. That behaviour is correct only for an order whose fills lie
3740    // entirely inside the recovery window: it counts from zero per order within the batch, so an
3741    // order that had already partly filled before the window yields keys offset by the amount it
3742    // filled earlier, and those keys match nothing the WS path ever emitted. Preferring `cum_qty`
3743    // is what removes that failure mode.
3744    //
3745    // Activities are fetched with direction=asc, so accumulation here follows execution order.
3746    // Borrow `activities` so &str keys into order_id strings remain valid for the entire loop:
3747    // consuming iteration would drop each activity at the end of its iteration, invalidating keys
3748    // that later fills on the same order still need to look up.
3749    let mut cumulative_qty: FnvHashMap<&str, Decimal> = FnvHashMap::default();
3750
3751    for activity in &activities {
3752        if !instrument_set.is_empty() && !instrument_set.contains(activity.symbol.as_str()) {
3753            // Safe to skip without advancing cumulative_qty: each Alpaca order is bound
3754            // to exactly one symbol, so no fill for a different symbol can ever share
3755            // the same order_id as a fill we're tracking.
3756            continue;
3757        }
3758
3759        // Advance cumulative_qty for every activity, regardless of whether parsing
3760        // succeeds. The WS path uses Alpaca's cumulative filled_qty, which counts ALL
3761        // fills — including those with malformed fields. Skipping the counter for a bad
3762        // fill causes subsequent fills to produce dedup keys that diverge from the WS
3763        // path (off by the bad fill's qty), so the next good fill appears as a duplicate
3764        // on one path and a new fill on the other, resulting in either a missed or a
3765        // double-delivered fill for that execution.
3766        //
3767        // activity.qty is always populated for FILL activities (Alpaca guarantees it).
3768        // unwrap_or(ZERO) guards against unexpected API changes; a zero qty skips
3769        // incrementing the cumulative counter for that activity. Note: a non-empty
3770        // but non-parseable qty string (e.g. an API regression sending "abc") also
3771        // produces exec_qty=ZERO — the counter stalls, causing subsequent fills on
3772        // the same order to produce dedup keys that diverge from the WS path.
3773        let exec_qty = Decimal::from_str(&activity.qty).unwrap_or(Decimal::ZERO);
3774        let cum = cumulative_qty
3775            .entry(activity.order_id.as_str())
3776            .or_default();
3777        *cum += exec_qty;
3778        let cumulative = *cum;
3779
3780        let trade = match convert_activity_to_trade(activity) {
3781            Some(t) => t,
3782            None => {
3783                warn!(id = %activity.id, symbol = %activity.symbol, "Alpaca: skipping activity with unparseable fields");
3784                continue; // Counter already advanced; dedup key sequence stays aligned with WS.
3785            }
3786        };
3787
3788        // The cumulative key, which matches the WS path's for the same fill.
3789        // `order_filled_quantity` holds the venue's own cumulative, parsed by
3790        // `convert_activity_to_trade`; `cumulative` is the intra-batch fallback described above.
3791        //
3792        // The counter is advanced for every activity, including those that carry `cum_qty` and so
3793        // never consult it. That is deliberate: it keeps the fallback usable for a later activity
3794        // on the same order that omits the field. A batch mixing both therefore interleaves two
3795        // key schemes, and the fallback keys in it remain offset by whatever the order filled
3796        // before the window -- the field's presence rescues an activity, not an order.
3797        let key = fill_dedup_key(
3798            &activity.order_id,
3799            trade.order_filled_quantity.unwrap_or(cumulative),
3800        );
3801        if is_duplicate_fill(dedup, activity_execution_id_part(&activity.id), key) {
3802            duplicates += 1;
3803            continue;
3804        }
3805
3806        let event =
3807            UnindexedAccountEvent::new(ExchangeId::AlpacaBroker, AccountEventKind::Trade(trade));
3808        let mut held = known.lock();
3809        held.observe(&event);
3810        let sent = tx.send(event);
3811        drop(held);
3812        if sent.is_err() {
3813            debug!("Alpaca fill recovery: consumer dropped during recovery");
3814            return Ok(());
3815        }
3816        recovered += 1;
3817    }
3818
3819    info!(recovered, duplicates, "Alpaca fill recovery complete");
3820    unread.map_or(Ok(()), Err)
3821}
3822
3823// ---------------------------------------------------------------------------
3824// Ended-order recovery
3825// ---------------------------------------------------------------------------
3826
3827/// Look the order under `key` up with `GET /v2/orders:by_client_order_id`.
3828///
3829/// A 404 is [`OrderLookup::Unknown`], and so is an order on another symbol than the key's (ignoring
3830/// case, as Alpaca's symbols are upper case).
3831async fn fetch_order_lookup(
3832    http: reqwest::Client,
3833    rate_limiter: Arc<RateLimitTracker>,
3834    config: Arc<AlpacaConfig>,
3835    key: UnindexedOrderKey,
3836) -> Result<OrderLookup, UnindexedClientError> {
3837    let url = format!("{}/v2/orders:by_client_order_id", config.rest_base_url());
3838    let cid = key.cid.0.as_str();
3839    let found: Option<AlpacaOrderResponse> = rest_lookup_with_retry(&rate_limiter, || {
3840        http.get(&url).query(&[("client_order_id", cid)])
3841    })
3842    .await?;
3843    let Some(order) = found else {
3844        debug!(instrument = %key.instrument, cid = %key.cid, "Alpaca does not know this order");
3845        return Ok(OrderLookup::Unknown);
3846    };
3847    if !order
3848        .symbol
3849        .eq_ignore_ascii_case(key.instrument.name().as_str())
3850    {
3851        debug!(
3852            instrument = %key.instrument,
3853            cid = %key.cid,
3854            symbol = %order.symbol,
3855            "Alpaca knows this client order id on another symbol"
3856        );
3857        return Ok(OrderLookup::Unknown);
3858    }
3859    Ok(
3860        convert_ended_order(&order, &key).map_or(OrderLookup::NotEnded, |order| {
3861            OrderLookup::Ended(Box::new(order))
3862        }),
3863    )
3864}
3865
3866/// The client order ids one `GET /v2/orders?status=open` lists on `instruments`, for a
3867/// reconnect's check of the orders held as live. A listing of 500 may be truncated, so it fails.
3868async fn listed_open_cids(
3869    http: reqwest::Client,
3870    rate_limiter: Arc<RateLimitTracker>,
3871    config: Arc<AlpacaConfig>,
3872    instruments: Vec<InstrumentNameExchange>,
3873) -> Result<FnvHashSet<ClientOrderId>, UnindexedClientError> {
3874    let orders =
3875        fetch_raw_open_orders(&http, &rate_limiter, config.rest_base_url(), &instruments).await?;
3876    Ok(orders
3877        .iter()
3878        .map(|order| alpaca_cid(order.client_order_id.as_deref(), &order.id))
3879        .collect())
3880}
3881
3882/// [`recover_ended_orders`] on Alpaca: one listing of every instrument due by
3883/// [`listed_open_cids`], and lookups by [`fetch_order_lookup`]. Fill recovery has finished or been
3884/// given up before it runs, so no fills are pending.
3885async fn recover_alpaca_ended_orders(
3886    http: &reqwest::Client,
3887    rate_limiter: &Arc<RateLimitTracker>,
3888    config: &Arc<AlpacaConfig>,
3889    known: &SharedKnownLiveOrders,
3890    unchecked: &mut UncheckedOrders,
3891    tx: &mpsc::UnboundedSender<UnindexedAccountEvent>,
3892) {
3893    recover_ended_orders(
3894        ExchangeId::AlpacaBroker,
3895        known,
3896        unchecked,
3897        &NoPendingFills,
3898        tx,
3899        OpenListing::Batched,
3900        |instruments| {
3901            listed_open_cids(
3902                http.clone(),
3903                rate_limiter.clone(),
3904                config.clone(),
3905                instruments,
3906            )
3907        },
3908        |key| fetch_order_lookup(http.clone(), rate_limiter.clone(), config.clone(), key),
3909    )
3910    .await;
3911}
3912
3913/// Run every order check as it falls due, the first at once and a failed one after its backoff,
3914/// for as long as it is polled. It never completes, and once the consumer has gone it only waits.
3915///
3916/// Fill recovery has finished or been given up before this starts, so no fills are pending.
3917async fn run_order_checks(
3918    http: &reqwest::Client,
3919    rate_limiter: &Arc<RateLimitTracker>,
3920    config: &Arc<AlpacaConfig>,
3921    known: &SharedKnownLiveOrders,
3922    unchecked: &mut UncheckedOrders,
3923    tx: &mpsc::UnboundedSender<UnindexedAccountEvent>,
3924) {
3925    loop {
3926        // A check stops at a gone consumer before settling, so its instruments stay due.
3927        if tx.is_closed() {
3928            return std::future::pending().await;
3929        }
3930        match unchecked.next_due(&NoPendingFills) {
3931            Some(due) => tokio::time::sleep_until(due).await,
3932            None => return std::future::pending().await,
3933        }
3934        recover_alpaca_ended_orders(http, rate_limiter, config, known, unchecked, tx).await;
3935    }
3936}
3937
3938/// How an order that has ended did end, from its REST order under `key`, the key it was asked for
3939/// (see [`ended_order_state`]).
3940///
3941/// Returns `None` for an order still live (see [`order_status_is_live`]), including
3942/// `done_for_day`, and, with a warning, for an order whose status is missing or unknown or that
3943/// cannot be converted. A caller reads `None` as "not ended", so such an order is asked about again
3944/// later rather than retired on a guess.
3945fn convert_ended_order(
3946    o: &AlpacaOrderResponse,
3947    key: &UnindexedOrderKey,
3948) -> Option<UnindexedInactiveOrder> {
3949    let instrument = &key.instrument;
3950    let Some(status) = o.status.as_deref() else {
3951        warn!(%instrument, cid = %key.cid, order_id = %o.id, "Alpaca order missing status");
3952        return None;
3953    };
3954    if order_status_is_live(status) {
3955        return None;
3956    }
3957    let Some(order) = convert_representable_open_order(o) else {
3958        warn!(%instrument, cid = %key.cid, order_id = %o.id, status, "Alpaca order cannot be represented, treating it as not ended");
3959        return None;
3960    };
3961    // Read again rather than from the open order, which reads an unknown fill as zero; that
3962    // conversion has already warned of it.
3963    let filled_qty = Decimal::from_str(&o.filled_qty).ok();
3964    let Some(state) = ended_order_state(
3965        status,
3966        OrderId(SmolStr::new(&o.id)),
3967        order.state.time_exchange,
3968        order.quantity,
3969        filled_qty,
3970        o.filled_avg_price.as_deref(),
3971    ) else {
3972        warn!(%instrument, cid = %key.cid, order_id = %o.id, status, "Alpaca order has a status this version does not know, treating it as not ended");
3973        return None;
3974    };
3975    let mut order = order.map_state(|_| state);
3976    order.key = key.clone();
3977    Some(order)
3978}
3979
3980/// Whether an Alpaca order in `status` is still live, so may yet fill or be cancelled.
3981///
3982/// `done_for_day` is live: Alpaca works the order again the next trading day. The rest are
3983/// Alpaca's working and pending statuses. Every other status has ended the order (see
3984/// [`ended_order_state`]) or is unknown to this version.
3985fn order_status_is_live(status: &str) -> bool {
3986    matches!(
3987        status,
3988        "new"
3989            | "partially_filled"
3990            | "done_for_day"
3991            | "pending_cancel"
3992            | "pending_replace"
3993            | "accepted"
3994            | "pending_new"
3995            | "accepted_for_bidding"
3996            | "stopped"
3997            | "suspended"
3998            | "calculated"
3999            | "held"
4000    )
4001}
4002
4003/// How an order ended, from its Alpaca `status`, or `None` for a status that does not end it.
4004///
4005/// `filled` becomes [`InactiveOrderState::FullyFilled`] with the reported fill, or the whole
4006/// `quantity` when that is unknown, and `filled_avg_price`; `canceled` becomes
4007/// [`InactiveOrderState::Cancelled`], and so does `replaced`, since the order replacing it has an
4008/// id and client order id of its own; `expired` becomes [`InactiveOrderState::Expired`], each with
4009/// what filled before, `None` when that is unknown; and `rejected` becomes
4010/// [`InactiveOrderState::OpenFailed`]. Shared by the lookup of an order that ended
4011/// ([`convert_ended_order`]) and the response to placing one ([`placed_order_state`]).
4012fn ended_order_state<AssetKey, InstrumentKey>(
4013    status: &str,
4014    order_id: OrderId,
4015    time_exchange: DateTime<Utc>,
4016    quantity: Decimal,
4017    filled_qty: Option<Decimal>,
4018    filled_avg_price: Option<&str>,
4019) -> Option<InactiveOrderState<AssetKey, InstrumentKey>> {
4020    Some(match status {
4021        // A filled order filled its whole quantity, whether or not `filled_qty` said so.
4022        "filled" => InactiveOrderState::FullyFilled(Filled::new(
4023            order_id,
4024            time_exchange,
4025            filled_qty.unwrap_or(quantity),
4026            alpaca_avg_price(filled_avg_price),
4027        )),
4028        "canceled" | "replaced" => {
4029            InactiveOrderState::Cancelled(Cancelled::new(order_id, time_exchange, filled_qty))
4030        }
4031        "expired" => InactiveOrderState::Expired(Expired::new(order_id, time_exchange, filled_qty)),
4032        "rejected" => {
4033            InactiveOrderState::OpenFailed(OrderError::Rejected(ApiError::OrderRejected(format!(
4034                "Alpaca rejected order {order_id} after accepting it"
4035            ))))
4036        }
4037        _ => return None,
4038    })
4039}
4040
4041/// The state that `resp`, Alpaca's response to placing an order of `quantity`, reports.
4042///
4043/// An order that ended in the response itself is reported as it ended (see
4044/// [`ended_order_state`]): an IOC or FOK order that found no liquidity, or partly filled and had
4045/// the rest cancelled, is `Cancelled` or `Expired` with what filled, not `Open`, and one Alpaca
4046/// rejected after accepting it is `OpenFailed`. A live status (see [`order_status_is_live`]) reads
4047/// from what filled: `FullyFilled`, with `filled_avg_price`, once it covers `quantity`, otherwise
4048/// `Open`. So does a status that is missing or that this version does not know, with a warning,
4049/// since the account stream reports the order's next state either way.
4050fn placed_order_state(
4051    resp: &AlpacaOrderResponse,
4052    instrument: &InstrumentNameExchange,
4053    quantity: Decimal,
4054) -> UnindexedOrderState {
4055    let order_id = OrderId(SmolStr::new(&resp.id));
4056    let time_exchange = order_state_time(resp);
4057    let filled_qty = alpaca_filled_qty(&resp.id, Some(&resp.filled_qty));
4058    let filled_avg_price = resp.filled_avg_price.as_deref();
4059    match resp.status.as_deref() {
4060        Some(status) => {
4061            if let Some(ended) = ended_order_state(
4062                status,
4063                order_id.clone(),
4064                time_exchange,
4065                quantity,
4066                filled_qty,
4067                filled_avg_price,
4068            ) {
4069                return OrderState::Inactive(ended);
4070            }
4071            if !order_status_is_live(status) {
4072                warn!(%instrument, %order_id, status, "Alpaca placed an order with a status this version does not know, reading it from what filled");
4073            }
4074        }
4075        None => {
4076            warn!(%instrument, %order_id, "Alpaca placed an order without a status, reading it from what filled");
4077        }
4078    }
4079    match filled_qty {
4080        Some(filled_qty) if filled_qty >= quantity => OrderState::fully_filled(Filled::new(
4081            order_id,
4082            time_exchange,
4083            filled_qty,
4084            alpaca_avg_price(filled_avg_price),
4085        )),
4086        // A live order's fill only grows, so an unknown one reads as nothing filled until the
4087        // account stream reports more.
4088        _ => OrderState::active(Open::new(
4089            VenueOrderId::Assigned(order_id),
4090            time_exchange,
4091            filled_qty.unwrap_or(Decimal::ZERO),
4092        )),
4093    }
4094}
4095
4096/// The average price of an Alpaca order's fills, from its `filled_avg_price`: `None` when no
4097/// price is reported or it does not parse.
4098fn alpaca_avg_price(filled_avg_price: Option<&str>) -> Option<Decimal> {
4099    filled_avg_price.and_then(|price| Decimal::from_str(price).ok())
4100}
4101
4102// ---------------------------------------------------------------------------
4103// Type conversion helpers
4104// ---------------------------------------------------------------------------
4105
4106/// Whether a caller's `asset` names the account currency. A name a caller passes is matched
4107/// ignoring case, as the execution map matches the names this client reports.
4108fn is_usd(asset: &AssetNameExchange) -> bool {
4109    asset.name().as_str().eq_ignore_ascii_case(USD)
4110}
4111
4112/// Convert an Alpaca account response to rustrade balance entries.
4113///
4114/// Returns a single USD balance with:
4115/// - `total` = `cash`, which is negative while the account borrows on margin
4116/// - `free` = the lesser of `cash` and `non_marginable_buying_power`: cash that can be spent
4117///   without borrowing, within what Alpaca allows
4118///
4119/// `free` is capped at cash because `non_marginable_buying_power` counts the loan value of held
4120/// marginable stock, so it exceeds cash whenever such stock is held. The cap keeps
4121/// `free <= total`, so with negative cash `free` is negative too.
4122///
4123/// Account equity is not the total: equity and option holdings are reported as positions, and
4124/// counting them again here would double them.
4125///
4126/// The balance is named [`USD`], however the caller spelled it. If `assets` is non-empty, it is
4127/// returned only when USD is among them, matched ignoring case. An empty `assets` slice returns it
4128/// unconditionally.
4129fn convert_account_to_balances(
4130    account: &AlpacaAccount,
4131    assets: &[AssetNameExchange],
4132) -> Vec<AssetBalance<AssetNameExchange>> {
4133    if !assets.is_empty() && !assets.iter().any(is_usd) {
4134        return Vec::new();
4135    }
4136
4137    vec![AssetBalance::new(
4138        AssetNameExchange::new(USD),
4139        Balance::new(
4140            account.cash,
4141            account.cash.min(account.non_marginable_buying_power),
4142        ),
4143        Utc::now(),
4144    )]
4145}
4146
4147/// Convert Alpaca positions to crypto asset balance entries.
4148///
4149/// Only positions with `asset_class == "crypto"` are included; [`convert_positions`] reports the
4150/// rest. Each is named by the asset it holds, read from its symbol by
4151/// [`crypto_position_asset`] as Alpaca spells it (e.g., `"BTCUSD"` → `"BTC"`).
4152///
4153/// - `total` = quantity of the holding in base currency units (e.g. 0.5 BTC)
4154/// - `free`  = qty_available (base currency units not locked in open orders)
4155///
4156/// If `assets` is non-empty, only positions whose base asset name matches an
4157/// entry in the slice (case-insensitive) are returned.
4158fn convert_positions_to_balances(
4159    positions: &[AlpacaPosition],
4160    assets: &[AssetNameExchange],
4161) -> Vec<AssetBalance<AssetNameExchange>> {
4162    let now = Utc::now();
4163    positions
4164        .iter()
4165        .filter(|p| is_crypto_position(p))
4166        .filter_map(|p| {
4167            let base = crypto_position_asset(&p.symbol);
4168
4169            // Apply assets filter if specified.
4170            if !assets.is_empty()
4171                && !assets
4172                    .iter()
4173                    .any(|a| a.name().as_str().eq_ignore_ascii_case(base))
4174            {
4175                return None;
4176            }
4177
4178            // total and free are in base currency units (e.g., BTC), not USD,
4179            // consistent with AssetBalance semantics for currency/crypto assets.
4180            let asset_name = AssetNameExchange::new(base);
4181            Some(AssetBalance::new(
4182                asset_name,
4183                Balance::new(p.qty, p.qty_available),
4184                now,
4185            ))
4186        })
4187        .collect()
4188}
4189
4190/// The asset a crypto position holds, read from its symbol.
4191///
4192/// Alpaca documents that its positions list names a crypto holding by its asset followed by `USD`
4193/// (`BTCUSD` holds BTC), while orders and fills name the pair (`BTC/USD`). Both forms are read,
4194/// the `USD` suffix ignoring case. A symbol in neither form, or with an empty asset, is returned
4195/// whole, with a warning, so its balance is still reported, under a name that fails to index
4196/// rather than not at all.
4197fn crypto_position_asset(symbol: &str) -> &str {
4198    let base = match symbol.split_once('/') {
4199        Some((base, _)) => Some(base),
4200        None => {
4201            let base_len = symbol.len().saturating_sub(USD.len());
4202            symbol
4203                .get(base_len..)
4204                .filter(|suffix| suffix.eq_ignore_ascii_case(USD))
4205                .and_then(|_| symbol.get(..base_len))
4206        }
4207    };
4208    match base {
4209        Some(base) if !base.is_empty() => base,
4210        _ => {
4211            warn!(
4212                %symbol,
4213                "Alpaca crypto position symbol is neither ASSETUSD nor BASE/QUOTE; using it whole"
4214            );
4215            symbol
4216        }
4217    }
4218}
4219
4220/// Whether an Alpaca position is a crypto holding, which is reported as an asset balance rather
4221/// than a [`Position`].
4222fn is_crypto_position(position: &AlpacaPosition) -> bool {
4223    position.asset_class.eq_ignore_ascii_case("crypto")
4224}
4225
4226/// Convert Alpaca's non-crypto positions (equities and options) to [`Position`]s, each with its
4227/// symbol, in Alpaca's order.
4228///
4229/// Crypto is left to [`convert_positions_to_balances`]: Alpaca crypto is spot-only and cannot be
4230/// sold short, so a holding is simply a balance of the base asset, as on every spot venue. Every
4231/// other asset class is a position, so a class Alpaca adds later is reported rather than dropped.
4232///
4233/// Each [`Position`] carries:
4234/// - `quantity`: the magnitude of `qty`, negative when `side` is `short`;
4235/// - `entry_price`: `avg_entry_price`, per share for an equity and per share of the underlying
4236///   for an option (the premium as quoted, not multiplied by the contract size);
4237/// - `unrealized_pnl`: `unrealized_pl`, in USD for the whole position;
4238/// - `time_exchange`: `now`, since Alpaca does not timestamp positions.
4239///
4240/// A position with zero quantity is left out.
4241///
4242/// # Errors
4243///
4244/// [`ClientError::Internal`](crate::error::ClientError::Internal) when a position's `side` is
4245/// missing or neither `long` nor `short`: reporting it either way could invert it, and leaving it
4246/// out would read as flat.
4247fn convert_positions(
4248    positions: &[AlpacaPosition],
4249    now: DateTime<Utc>,
4250) -> Result<Vec<(&str, Position)>, UnindexedClientError> {
4251    positions
4252        .iter()
4253        .filter(|p| !is_crypto_position(p) && !p.qty.is_zero())
4254        .map(|p| {
4255            let size = p.qty.abs();
4256            let quantity = match p.side {
4257                AlpacaPositionSide::Long => size,
4258                AlpacaPositionSide::Short => -size,
4259                AlpacaPositionSide::Unknown => {
4260                    return Err(UnindexedClientError::Internal(format!(
4261                        "Alpaca position {} has a missing or unrecognised side",
4262                        p.symbol
4263                    )));
4264                }
4265            };
4266            let position = Position::new(
4267                quantity,
4268                p.avg_entry_price,
4269                p.unrealized_pl,
4270                None,
4271                None,
4272                None,
4273                None,
4274                now,
4275            );
4276            Ok((p.symbol.as_str(), position))
4277        })
4278        .collect()
4279}
4280
4281/// Group open orders and positions into per-instrument snapshots for account_snapshot.
4282///
4283/// When `instruments` is non-empty, a snapshot is returned for every requested instrument
4284/// (possibly with no orders and no position). When empty, only instruments with an open order or
4285/// a position are returned.
4286///
4287/// `positions` come from [`convert_positions`], which holds every non-zero equity and option
4288/// position, so an equity or option without one is [`PositionReport::Flat`]. A crypto pair (a
4289/// symbol with a `/`) is [`PositionReport::Unreported`]: its holding is a balance.
4290///
4291/// Requested names match Alpaca's symbols ignoring case. When two requested names differ only in
4292/// case, the first gets the symbol's orders and position; the second is
4293/// [`PositionReport::Unreported`] with its orders not complete, since nothing is known about it.
4294///
4295/// Each snapshot declares its orders complete unless [`convert_open_order`] left one of its
4296/// orders out. That holds because `orders` is every open order, unpaged: a response at Alpaca's
4297/// cap fails the whole snapshot in [`fetch_raw_open_orders`] rather than arriving here short. And
4298/// every order is listed under the client order id the order stream reports it under
4299/// ([`alpaca_cid`]): the one it was placed with, or the one Alpaca assigns to a bracket's
4300/// take-profit and stop-loss legs.
4301fn build_instrument_snapshots(
4302    orders: Vec<AlpacaOrderResponse>,
4303    positions: Vec<(&str, Position)>,
4304    instruments: &[InstrumentNameExchange],
4305) -> Vec<InstrumentAccountSnapshot<ExchangeId, AssetNameExchange, InstrumentNameExchange>> {
4306    /// One instrument's converted orders, whether none was left out, and its position.
4307    struct Listing {
4308        orders: Vec<UnindexedOrderSnapshot>,
4309        complete: bool,
4310        position: Option<Position>,
4311    }
4312
4313    impl Listing {
4314        fn empty() -> Self {
4315            Self {
4316                orders: Vec::new(),
4317                complete: true,
4318                position: None,
4319            }
4320        }
4321    }
4322
4323    // Build ordered map from symbol → snapshot to preserve deterministic ordering.
4324    let mut by_symbol: IndexMap<SmolStr, Listing> = IndexMap::new();
4325
4326    // Keyed by the upper-case symbol, so a requested name in another case still finds its
4327    // listing. Alpaca's own symbols are upper case, so an unfiltered read lists them unchanged.
4328    for order in orders {
4329        let listing = by_symbol
4330            .entry(order.symbol.to_ascii_uppercase().into())
4331            .or_insert_with(Listing::empty);
4332        match convert_open_order(&order) {
4333            Some(converted) => listing.orders.push(converted.into()),
4334            None => listing.complete = false,
4335        }
4336    }
4337
4338    for (symbol, position) in positions {
4339        by_symbol
4340            .entry(symbol.to_ascii_uppercase().into())
4341            .or_insert_with(Listing::empty)
4342            .position = Some(position);
4343    }
4344
4345    let snapshot = |instrument: InstrumentNameExchange, listing: Listing| {
4346        // `positions` holds every non-zero equity and option position, so one missing here is
4347        // flat. Crypto is reported as balances, never as a position.
4348        let position = if is_options_or_equity_symbol(instrument.name().as_str()) {
4349            listing
4350                .position
4351                .map_or(PositionReport::Flat, PositionReport::from_position)
4352        } else {
4353            PositionReport::Unreported
4354        };
4355        InstrumentAccountSnapshot::new(instrument, listing.orders, listing.complete, position, None)
4356    };
4357
4358    // If instruments is empty, return all; otherwise filter to requested set.
4359    if instruments.is_empty() {
4360        by_symbol
4361            .into_iter()
4362            .map(|(sym, listing)| snapshot(InstrumentNameExchange::new(sym), listing))
4363            .collect()
4364    } else {
4365        // Upper-case symbols already given to an earlier requested name.
4366        let mut claimed = std::collections::HashSet::new();
4367        instruments
4368            .iter()
4369            .map(|inst| {
4370                let symbol = inst.name().as_str().to_ascii_uppercase();
4371                // A second name for the same symbol, differing only in case: its listing went to
4372                // the first, so nothing is known about it rather than it being flat and orderless.
4373                if !claimed.insert(symbol.clone()) {
4374                    return InstrumentAccountSnapshot::new(
4375                        inst.clone(),
4376                        Vec::new(),
4377                        false,
4378                        PositionReport::Unreported,
4379                        None,
4380                    );
4381                }
4382                // swap_remove is O(1); output order is determined by the `instruments`
4383                // slice, not by the internal IndexMap order of `by_symbol`.
4384                let listing = by_symbol
4385                    .swap_remove(symbol.as_str())
4386                    .unwrap_or_else(Listing::empty);
4387                snapshot(inst.clone(), listing)
4388            })
4389            .collect()
4390    }
4391}
4392
4393/// Convert an Alpaca REST open order into rustrade's Open state order.
4394///
4395/// `None`, with a `warn!`, for an order it cannot represent: a notional order (placed by dollar
4396/// value, so its `qty` is null), which this client never places but the Alpaca dashboard can, or
4397/// one whose side, quantity or kind does not parse. A list missing that order is not every open
4398/// order, so [`build_instrument_snapshots`] must not declare it complete.
4399///
4400/// It warns on every call, so an order like that which stays open is logged again each time the
4401/// open orders are fetched.
4402fn convert_open_order(
4403    o: &AlpacaOrderResponse,
4404) -> Option<Order<ExchangeId, InstrumentNameExchange, Open>> {
4405    let converted = convert_representable_open_order(o);
4406    if converted.is_none() {
4407        warn!(
4408            order_id = %o.id,
4409            symbol = %o.symbol,
4410            qty = ?o.qty,
4411            side = %o.side,
4412            order_type = %o.order_type,
4413            "Alpaca open order cannot be represented - leaving it out"
4414        );
4415    }
4416    converted
4417}
4418
4419/// The client order id an Alpaca order is reported under, on the REST and stream paths alike: its
4420/// `client_order_id`, or its venue id when it has none.
4421///
4422/// Both paths must agree, or a complete snapshot would miss an order the stream is tracking under
4423/// another id.
4424fn alpaca_cid(client_order_id: Option<&str>, order_id: &str) -> ClientOrderId {
4425    ClientOrderId::new(client_order_id.unwrap_or(order_id))
4426}
4427
4428/// [`convert_open_order`] without the `warn!`.
4429fn convert_representable_open_order(
4430    o: &AlpacaOrderResponse,
4431) -> Option<Order<ExchangeId, InstrumentNameExchange, Open>> {
4432    let order_id = OrderId(SmolStr::new(&o.id));
4433    let cid = alpaca_cid(o.client_order_id.as_deref(), &o.id);
4434
4435    let instrument = InstrumentNameExchange::new(&o.symbol);
4436    let side = parse_side(&o.side)?;
4437    let quantity = Decimal::from_str(o.qty.as_deref().unwrap_or("0")).ok()?;
4438    // Notional orders (placed by dollar value, qty=null) have no representable quantity.
4439    // Leave them out rather than recording a zero-quantity order that would corrupt
4440    // reconciliation.
4441    if quantity.is_zero() {
4442        return None;
4443    }
4444    let price = o
4445        .limit_price
4446        .as_deref()
4447        .and_then(|s| Decimal::from_str(s).ok());
4448    let filled_qty = alpaca_filled_qty(&o.id, Some(&o.filled_qty)).unwrap_or(Decimal::ZERO);
4449    let kind = parse_order_kind(
4450        &o.order_type,
4451        o.stop_price.as_deref(),
4452        o.trail_percent.as_deref(),
4453        o.trail_price.as_deref(),
4454    )?;
4455    let time_in_force = parse_time_in_force(&o.time_in_force);
4456    let time_exchange = order_state_time(o);
4457
4458    Some(Order {
4459        key: OrderKey::new(
4460            ExchangeId::AlpacaBroker,
4461            instrument,
4462            // Alpaca doesn't carry strategy IDs in any response field.
4463            StrategyId::unknown(),
4464            cid,
4465        ),
4466        side,
4467        price,
4468        quantity,
4469        kind,
4470        time_in_force,
4471        state: Open::new(VenueOrderId::Assigned(order_id), time_exchange, filled_qty),
4472    })
4473}
4474
4475/// Convert an Alpaca FILL activity into a rustrade Trade.
4476fn convert_activity_to_trade(
4477    a: &AlpacaActivity,
4478) -> Option<Trade<AssetNameExchange, InstrumentNameExchange>> {
4479    let trade_id = TradeId::new(activity_execution_id(&a.id));
4480    let order_id = OrderId(SmolStr::new(&a.order_id));
4481    let instrument = InstrumentNameExchange::new(&a.symbol);
4482    let side = parse_side(&a.side)?;
4483    let price = Decimal::from_str(&a.price).ok()?;
4484    let quantity = Decimal::from_str(&a.qty).ok()?;
4485    // `None` when Alpaca omitted the field or sent something unparseable, which is the honest
4486    // report: the venue did not tell us where the order stands, rather than "nothing is filled".
4487    let order_filled_quantity = a.cum_qty.as_deref().and_then(|s| Decimal::from_str(s).ok());
4488    let time_exchange = parse_timestamp(&a.transaction_time).unwrap_or_else(|| {
4489        warn!(id = %a.id, "Alpaca activity: unparseable transaction_time, using now");
4490        Utc::now()
4491    });
4492
4493    // Alpaca equities and options are commission-free. Crypto trades incur
4494    // maker/taker fees (currently 0.15–0.25%) charged in the credited asset,
4495    // but fee info is not available in trade responses — use Activities API
4496    // for end-of-day fee reconciliation.
4497    Some(Trade::new(
4498        trade_id,
4499        order_id,
4500        instrument,
4501        StrategyId::unknown(),
4502        time_exchange,
4503        side,
4504        price,
4505        quantity,
4506        order_filled_quantity,
4507        AssetFees::new(
4508            AssetNameExchange::from(USD),
4509            Decimal::ZERO,
4510            Some(Decimal::ZERO),
4511        ),
4512    ))
4513}
4514
4515/// Build an `OrderSnapshot` event from the order payload embedded in a `trade_updates` frame.
4516///
4517/// Shared by the acknowledgement arm and the fill arms of [`convert_trade_update`], so that a
4518/// fill reports the order's new cumulative `filled_quantity` the same way an acknowledgement
4519/// reports its initial one.
4520///
4521/// Returns `None` for a notional order (placed by dollar value, `qty` is null). Emitting a
4522/// snapshot with `quantity == 0` would read as an order with nothing left to fill and retire it,
4523/// so such orders are left untracked -- consistent with `convert_open_order` on the REST path.
4524fn ws_order_snapshot(
4525    order: &AlpacaOrderWs<'_>,
4526    instrument: InstrumentNameExchange,
4527    cid: ClientOrderId,
4528    order_id: OrderId,
4529    time_exchange: DateTime<Utc>,
4530) -> Option<UnindexedAccountEvent> {
4531    let side = parse_side(&order.side)?;
4532    let quantity = Decimal::from_str(order.qty.unwrap_or("0")).unwrap_or(Decimal::ZERO);
4533    if quantity.is_zero() {
4534        trace!(order_id = %order.id, "Alpaca WS: skipping notional order snapshot (qty=None)");
4535        return None;
4536    }
4537    let price = order.limit_price.and_then(|s| Decimal::from_str(s).ok());
4538    // A live order's fill only grows, so a frame that omits it (some lifecycle events may) reads
4539    // as nothing filled yet, without a warning; one that garbles it is still reported.
4540    let filled_qty = order
4541        .filled_qty
4542        .and_then(|_| alpaca_filled_qty(&order.id, order.filled_qty))
4543        .unwrap_or(Decimal::ZERO);
4544    let kind = parse_order_kind(
4545        &order.order_type,
4546        order.stop_price,
4547        order.trail_percent,
4548        order.trail_price,
4549    )?;
4550    let time_in_force = parse_time_in_force(&order.time_in_force);
4551
4552    let order_snapshot = crate::order::Order {
4553        key: OrderKey::new(
4554            ExchangeId::AlpacaBroker,
4555            instrument,
4556            StrategyId::unknown(),
4557            cid,
4558        ),
4559        side,
4560        price,
4561        quantity,
4562        kind,
4563        time_in_force,
4564        state: OrderState::active(Open::new(
4565            VenueOrderId::Assigned(order_id),
4566            time_exchange,
4567            filled_qty,
4568        )),
4569    };
4570    Some(UnindexedAccountEvent::new(
4571        ExchangeId::AlpacaBroker,
4572        AccountEventKind::OrderSnapshot(rustrade_integration::collection::snapshot::Snapshot(
4573            order_snapshot,
4574        )),
4575    ))
4576}
4577
4578/// Whether a fill frame's order payload describes an order that is still live at the exchange,
4579/// and may therefore be written into engine state as an `Open` snapshot.
4580///
4581/// A fill frame carries the order's status alongside the execution. Only `partially_filled` and
4582/// `filled` say the order reached this execution while working; every other status means some
4583/// other frame owns the order's current state. Writing an `Open` snapshot from one of those would
4584/// resurrect an order the engine has already retired, leaving a resting order that does not exist
4585/// at the exchange -- a fill that arrives after its order's terminal frame is exactly the ordering
4586/// this guards against.
4587fn ws_fill_order_is_live(status: &str) -> bool {
4588    matches!(status, "partially_filled" | "filled")
4589}
4590
4591/// The [`AccountEventKind::TradeAmended`] for a `trade_bust` or `trade_correct` frame.
4592///
4593/// Neither event is in the Trading API's docs. The fields read are those of the Broker API's
4594/// `TradeUpdateEventV2`: `previous_execution_id` names the fill amended, `execution_id` is taken
4595/// as the corrected execution's id, `qty` is the busted or corrected quantity (negative on some
4596/// busts), and `price` the corrected price, which that schema does not promise on a correction. A
4597/// correction lacking anything a replacement trade needs is reported as
4598/// [`TradeAmendmentKind::CorrectedUnresolved`] with what it did carry, never dropped.
4599///
4600/// No order snapshot is emitted: a bust's lower cumulative would be refused, as
4601/// [`TradeAmendment`](crate::trade::TradeAmendment) documents.
4602fn trade_amendment_event(
4603    update: &AlpacaTradeUpdate<'_>,
4604    instrument: InstrumentNameExchange,
4605    order_id: OrderId,
4606) -> UnindexedAccountEvent {
4607    let order = &update.order;
4608    let time_exchange = update
4609        .timestamp
4610        .and_then(parse_timestamp)
4611        .unwrap_or_else(Utc::now);
4612    let original = update
4613        .previous_execution_id
4614        .filter(|id| !id.is_empty())
4615        .map(TradeId::new);
4616    let quantity = update.qty.and_then(|s| Decimal::from_str(s).ok());
4617
4618    let kind = if update.event == "trade_bust" {
4619        TradeAmendmentKind::Busted {
4620            quantity: quantity.map(|quantity| quantity.abs()),
4621        }
4622    } else {
4623        let id = ws_execution_id(update).map(TradeId::new);
4624        let price = update.price.and_then(|s| Decimal::from_str(s).ok());
4625        match (id, price, quantity, parse_side(&order.side)) {
4626            (Some(id), Some(price), Some(quantity), Some(side)) if quantity > Decimal::ZERO => {
4627                TradeAmendmentKind::Corrected {
4628                    replacement: with_reported_cid(
4629                        Trade::new(
4630                            id,
4631                            order_id.clone(),
4632                            instrument.clone(),
4633                            StrategyId::unknown(),
4634                            time_exchange,
4635                            side,
4636                            price,
4637                            quantity,
4638                            // What `order.filled_qty` means on a correction is undocumented, so the
4639                            // replacement does not claim a cumulative.
4640                            None,
4641                            // As on a fill: no fee info in WebSocket updates.
4642                            AssetFees::new(
4643                                AssetNameExchange::from(USD),
4644                                Decimal::ZERO,
4645                                Some(Decimal::ZERO),
4646                            ),
4647                        ),
4648                        order.client_order_id.as_ref(),
4649                    ),
4650                }
4651            }
4652            (id, price, quantity, _) => TradeAmendmentKind::CorrectedUnresolved {
4653                id,
4654                price,
4655                quantity,
4656            },
4657        }
4658    };
4659
4660    UnindexedAccountEvent::new(
4661        ExchangeId::AlpacaBroker,
4662        AccountEventKind::TradeAmended(crate::trade::TradeAmendment::new(
4663            instrument,
4664            order_id,
4665            time_exchange,
4666            original,
4667            kind,
4668        )),
4669    )
4670}
4671
4672/// Convert a WebSocket trade_update event into rustrade AccountEvents.
4673///
4674/// Returns up to two events, in the order they must be applied. A fill frame genuinely carries
4675/// two facts -- the execution print and the order's new cumulative filled quantity -- so it maps
4676/// to both a `Trade` and an `OrderSnapshot`.
4677///
4678/// The `Trade` is always first. A fully-filled snapshot retires its order, and routing a fill
4679/// against an order that has already been retired is a strictly harder problem than routing it
4680/// against a live one; emitting the execution first keeps the easy ordering.
4681fn convert_trade_update(update: AlpacaTradeUpdate<'_>) -> [Option<UnindexedAccountEvent>; 2] {
4682    // Early exit for unrecognised event types before incurring allocations for
4683    // instrument/order_id/cid — those are wasted for unknown events.
4684    let event_str = update.event.as_str();
4685    if !matches!(
4686        event_str,
4687        "fill"
4688            | "partial_fill"
4689            | "new"
4690            | "accepted"
4691            | "pending_new"
4692            | "canceled"
4693            | "expired"
4694            | "replaced"
4695            | "done_for_day"
4696            | "rejected"
4697            | "trade_bust"
4698            | "trade_correct"
4699    ) {
4700        trace!(event = %event_str, "Alpaca WS: ignoring trade_updates event type");
4701        return [None, None];
4702    }
4703
4704    let order = &update.order;
4705    let instrument = InstrumentNameExchange::new(&*order.symbol);
4706    let order_id = OrderId(order.id.clone());
4707    let cid = alpaca_cid(order.client_order_id.as_deref(), &order.id);
4708
4709    match event_str {
4710        "fill" | "partial_fill" => {
4711            // Use event-level price/qty for the per-execution trade.
4712            let (Some(price), Some(quantity), Some(side)) = (
4713                update.price.and_then(|s| Decimal::from_str(s).ok()),
4714                update.qty.and_then(|s| Decimal::from_str(s).ok()),
4715                parse_side(&order.side),
4716            ) else {
4717                return [None, None];
4718            };
4719            let time_exchange = update
4720                .timestamp
4721                .and_then(parse_timestamp)
4722                .unwrap_or_else(Utc::now);
4723
4724            // If filled_qty is unparseable (API regression), cum_qty falls back to zero: the
4725            // trade reports a cumulative of 0, and a fill without an execution id is deduplicated
4726            // on "order_id:0", so a second such fill on the same order would be silently dropped.
4727            // Warn loudly so API regressions are surfaced immediately.
4728            let cum_qty = Decimal::from_str(order.filled_qty.unwrap_or("0"))
4729                .inspect_err(|e| {
4730                    warn!(
4731                        order_id = %order.id,
4732                        filled_qty = ?order.filled_qty,
4733                        %e,
4734                        "Alpaca WS: failed to parse filled_qty — the fill reports a cumulative \
4735                         of 0, and without an execution id a second such fill on the same order \
4736                         would be deduplicated away"
4737                    );
4738                })
4739                .unwrap_or(Decimal::ZERO);
4740            // The execution id, which the FILL activity for this fill carries too, so the fill has
4741            // one TradeId however it is delivered. Without it, fall back to the dedup key, which
4742            // is unique per fill but matches nothing a REST read returns.
4743            let trade_id = match ws_execution_id(&update) {
4744                Some(execution_id) => TradeId::new(execution_id),
4745                None => {
4746                    warn!(
4747                        order_id = %order.id,
4748                        "Alpaca WS: fill without a usable execution_id — its TradeId is \
4749                         \"{{order_id}}:{{filled_qty}}\", which fetch_trades will not match"
4750                    );
4751                    TradeId(fill_dedup_key(&order.id, cum_qty))
4752                }
4753            };
4754
4755            // Alpaca equities and options are commission-free. Crypto trades incur
4756            // maker/taker fees (currently 0.15–0.25%) in the credited asset, but
4757            // fee info is not available in WebSocket updates.
4758            let trade = Trade::new(
4759                trade_id,
4760                order_id.clone(),
4761                instrument.clone(),
4762                StrategyId::unknown(),
4763                time_exchange,
4764                side,
4765                price,
4766                quantity,
4767                // The venue's own cumulative for this order, as of this execution.
4768                Some(cum_qty),
4769                AssetFees::new(
4770                    AssetNameExchange::from(USD),
4771                    Decimal::ZERO,
4772                    Some(Decimal::ZERO),
4773                ),
4774            );
4775            let trade = with_reported_cid(trade, order.client_order_id.as_ref());
4776            let trade_event = UnindexedAccountEvent::new(
4777                ExchangeId::AlpacaBroker,
4778                AccountEventKind::Trade(trade),
4779            );
4780
4781            // The execution alone does not move the order: `filled_quantity` is only ever carried
4782            // into engine state by an order snapshot, so without this second event a partially
4783            // filled order reads as having nothing filled until REST reconciliation refreshes it.
4784            let snapshot_event = if ws_fill_order_is_live(&order.status) {
4785                ws_order_snapshot(order, instrument, cid, order_id, time_exchange)
4786            } else {
4787                trace!(
4788                    order_id = %order.id,
4789                    status = %order.status,
4790                    "Alpaca WS: fill for an order the exchange no longer reports as working — \
4791                     emitting the execution without an order snapshot"
4792                );
4793                None
4794            };
4795
4796            [Some(trade_event), snapshot_event]
4797        }
4798
4799        "new" | "accepted" | "pending_new" | "done_for_day" => {
4800            // Order acknowledged by Alpaca, or done for the day: Alpaca stops working a
4801            // `done_for_day` order until the next trading day but has not ended it, so it is
4802            // reported open with what it has filled, never retired.
4803            let time_exchange = update
4804                .timestamp
4805                .and_then(parse_timestamp)
4806                .unwrap_or_else(Utc::now);
4807
4808            [
4809                ws_order_snapshot(order, instrument, cid, order_id, time_exchange),
4810                None,
4811            ]
4812        }
4813
4814        "canceled" | "expired" | "replaced" => {
4815            // Order no longer active.
4816            //
4817            // NOTE on "replaced": Alpaca's replace operation cancels the original order
4818            // and creates a NEW order with a different order ID. This arm correctly marks
4819            // the original as cancelled, but callers must call `fetch_open_orders` to
4820            // discover the replacement order (which has a new ID and won't appear in OMS
4821            // state automatically).
4822            let time_exchange = update
4823                .timestamp
4824                .and_then(parse_timestamp)
4825                .unwrap_or_else(Utc::now);
4826            let filled_qty = alpaca_filled_qty(&order.id, order.filled_qty);
4827            let cancelled = Cancelled::new(order_id, time_exchange, filled_qty);
4828            let response = crate::order::request::OrderResponseCancel {
4829                key: OrderKey::new(
4830                    ExchangeId::AlpacaBroker,
4831                    instrument,
4832                    StrategyId::unknown(),
4833                    cid,
4834                ),
4835                state: Ok(cancelled),
4836            };
4837            [
4838                Some(UnindexedAccountEvent::new(
4839                    ExchangeId::AlpacaBroker,
4840                    AccountEventKind::OrderCancelled(response),
4841                )),
4842                None,
4843            ]
4844        }
4845
4846        "trade_bust" | "trade_correct" => [
4847            Some(trade_amendment_event(&update, instrument, order_id)),
4848            None,
4849        ],
4850
4851        "rejected" => {
4852            let response = crate::order::request::OrderResponseCancel {
4853                key: OrderKey::new(
4854                    ExchangeId::AlpacaBroker,
4855                    instrument,
4856                    StrategyId::unknown(),
4857                    cid,
4858                ),
4859                state: Err(UnindexedOrderError::Rejected(ApiError::OrderRejected(
4860                    format!("order rejected: status={}", order.status),
4861                ))),
4862            };
4863            [
4864                Some(UnindexedAccountEvent::new(
4865                    ExchangeId::AlpacaBroker,
4866                    AccountEventKind::OrderCancelled(response),
4867                )),
4868                None,
4869            ]
4870        }
4871
4872        // All recognised event types are handled above; the early-return guard at the
4873        // top of this function ensures we never reach here for unknown event types.
4874        _ => unreachable!("convert_trade_update: unrecognised event passed early-return guard"),
4875    }
4876}
4877
4878// ---------------------------------------------------------------------------
4879// Field parsers
4880// ---------------------------------------------------------------------------
4881
4882fn parse_side(s: &str) -> Option<Side> {
4883    match s {
4884        "buy" | "Buy" | "BUY" => Some(Side::Buy),
4885        "sell" | "Sell" | "SELL" => Some(Side::Sell),
4886        other => {
4887            trace!(%other, "Alpaca: unknown order side");
4888            None
4889        }
4890    }
4891}
4892
4893fn parse_order_kind(
4894    order_type: &str,
4895    stop_price: Option<&str>,
4896    trail_percent: Option<&str>,
4897    trail_price: Option<&str>,
4898) -> Option<OrderKind> {
4899    match order_type {
4900        "market" | "Market" => Some(OrderKind::Market),
4901        "limit" | "Limit" => Some(OrderKind::Limit),
4902        "stop" | "Stop" => {
4903            let trigger_price = stop_price.and_then(|s| Decimal::from_str(s).ok())?;
4904            Some(OrderKind::Stop { trigger_price })
4905        }
4906        "stop_limit" | "Stop_limit" => {
4907            let trigger_price = stop_price.and_then(|s| Decimal::from_str(s).ok())?;
4908            Some(OrderKind::StopLimit { trigger_price })
4909        }
4910        "trailing_stop" | "Trailing_stop" => {
4911            // Alpaca returns either trail_percent or trail_price, not both.
4912            if let Some(pct) = trail_percent.and_then(|s| Decimal::from_str(s).ok()) {
4913                Some(OrderKind::TrailingStop {
4914                    offset: pct,
4915                    offset_type: TrailingOffsetType::Percentage,
4916                    activation_price: None,
4917                })
4918            } else if let Some(price) = trail_price.and_then(|s| Decimal::from_str(s).ok()) {
4919                Some(OrderKind::TrailingStop {
4920                    offset: price,
4921                    offset_type: TrailingOffsetType::Absolute,
4922                    activation_price: None,
4923                })
4924            } else {
4925                trace!("Alpaca: trailing_stop missing trail_percent and trail_price");
4926                None
4927            }
4928        }
4929        other => {
4930            trace!(%other, "Alpaca: unsupported order type, skipping");
4931            None
4932        }
4933    }
4934}
4935
4936fn parse_time_in_force(s: &str) -> TimeInForce {
4937    match s {
4938        "gtc" | "GTC" => TimeInForce::GoodUntilCancelled { post_only: false },
4939        "day" | "DAY" => TimeInForce::GoodUntilEndOfDay,
4940        "fok" | "FOK" => TimeInForce::FillOrKill,
4941        "ioc" | "IOC" => TimeInForce::ImmediateOrCancel,
4942        other => {
4943            warn!(%other, "Alpaca: unknown time_in_force, defaulting to GoodUntilEndOfDay");
4944            TimeInForce::GoodUntilEndOfDay
4945        }
4946    }
4947}
4948
4949/// When the exchange last reported this order's state.
4950///
4951/// [`Open::time_exchange`] orders an order's states, and the engine discards a snapshot older
4952/// than the state it already tracks. `created_at` does not order them: it is the same value for
4953/// every snapshot of one order, so a snapshot stamped with it is indistinguishable from the
4954/// acknowledgement and is discarded the moment anything has advanced the tracked order past it.
4955/// `updated_at` moves with each state change, which is what that comparison needs.
4956///
4957/// Falls back to `created_at` when the venue omits `updated_at`, and to now when neither parses.
4958fn order_state_time(order: &AlpacaOrderResponse) -> DateTime<Utc> {
4959    order
4960        .updated_at
4961        .as_deref()
4962        .and_then(parse_timestamp)
4963        .or_else(|| parse_timestamp(&order.created_at))
4964        .unwrap_or_else(Utc::now)
4965}
4966
4967/// The quantity Alpaca order `order_id` has filled, from its `filled_qty`: `None`, with a
4968/// warning, when it is missing or does not parse, so that an unknown fill is not read as zero.
4969fn alpaca_filled_qty(order_id: &str, filled_qty: Option<&str>) -> Option<Decimal> {
4970    let Some(raw) = filled_qty else {
4971        warn!(%order_id, "Alpaca did not report how much the order filled");
4972        return None;
4973    };
4974    match Decimal::from_str(raw) {
4975        Ok(filled_qty) => Some(filled_qty),
4976        Err(_) => {
4977            warn!(%order_id, filled_qty = raw, "Alpaca reported an unparseable filled_qty, treating it as unknown");
4978            None
4979        }
4980    }
4981}
4982
4983/// A non-trade activity as a [`CashFlow`], or `None` if Alpaca canceled it or it lacks an amount
4984/// or a time. See [`AlpacaClient::fetch_cash_flows`].
4985fn convert_cash_flow(
4986    activity: &AlpacaNonTradeActivity,
4987) -> Option<CashFlow<AssetNameExchange, InstrumentNameExchange>> {
4988    let id = activity.id.as_str();
4989    if activity.status.as_deref() == Some("canceled") {
4990        info!(id, activity_type = %activity.activity_type, "Alpaca activity canceled — left out");
4991        return None;
4992    }
4993    let Some(amount) = activity
4994        .net_amount
4995        .as_deref()
4996        .and_then(|amount| Decimal::from_str(amount).ok())
4997    else {
4998        warn!(id, net_amount = ?activity.net_amount, "Alpaca activity missing/unparseable net_amount — left out");
4999        return None;
5000    };
5001    let Some(time_exchange) = activity.time() else {
5002        warn!(
5003            id,
5004            created_at = ?activity.created_at,
5005            date = ?activity.date,
5006            "Alpaca activity missing/unparseable time — left out"
5007        );
5008        return None;
5009    };
5010    let sub_type = activity
5011        .activity_sub_type
5012        .as_deref()
5013        .filter(|sub_type| !sub_type.is_empty());
5014    let kind = match (activity.activity_type.as_str(), sub_type) {
5015        ("INT", Some("MGN")) => CashFlowKind::MarginInterest {
5016            rate: None,
5017            principal: None,
5018        },
5019        // Credit interest is received; margin interest is paid.
5020        ("INT", None) if amount.is_sign_negative() => CashFlowKind::MarginInterest {
5021            rate: None,
5022            principal: None,
5023        },
5024        ("PTR", _) => CashFlowKind::Rebate,
5025        (activity_type, Some(sub_type)) => {
5026            CashFlowKind::Other(format!("{activity_type}.{sub_type}"))
5027        }
5028        (activity_type, None) => CashFlowKind::Other(activity_type.to_owned()),
5029    };
5030    Some(CashFlow::new(
5031        kind,
5032        AssetNameExchange::new(
5033            activity
5034                .currency
5035                .as_deref()
5036                .filter(|currency| !currency.is_empty())
5037                .unwrap_or("USD"),
5038        ),
5039        amount,
5040        activity
5041            .symbol
5042            .as_deref()
5043            .filter(|symbol| !symbol.is_empty())
5044            .map(InstrumentNameExchange::new),
5045        time_exchange,
5046        Some(CashFlowId::new(id)),
5047    ))
5048}
5049
5050/// One option activity, read for [`convert_option_events`].
5051struct OptionLeg<'a> {
5052    id: &'a str,
5053    symbol: &'a str,
5054    /// Signed: negative is a sell.
5055    quantity: Decimal,
5056    /// The strike, on the share leg only.
5057    price: Option<Decimal>,
5058    date: Option<NaiveDate>,
5059    time: DateTime<Utc>,
5060    /// Whether `time` is Alpaca's `created_at`, not the start of `date`.
5061    created: bool,
5062    /// `None` for the share leg (`OPTRD`), whose origin is its pair's.
5063    origin: Option<TradeOrigin>,
5064    /// On an exercise or assignment, its contract as [`occ_contract`] reads it.
5065    contract: Option<(&'a str, bool, Decimal)>,
5066}
5067
5068impl OptionLeg<'_> {
5069    /// Whether the leg is in `start..=end`: by its creation time, or, when Alpaca gave none, by
5070    /// its `date`, so that a read up to now still returns a leg dated today.
5071    fn is_within(&self, start: DateTime<Utc>, end: DateTime<Utc>) -> bool {
5072        if self.created {
5073            (start..=end).contains(&self.time)
5074        } else {
5075            (start.date_naive()..=end.date_naive()).contains(&self.time.date_naive())
5076        }
5077    }
5078}
5079
5080/// An option activity as an [`OptionLeg`], or `None`, logged, if it is canceled, of another type,
5081/// or lacks what its trade needs. See [`AlpacaClient::fetch_option_events`].
5082fn option_leg(activity: &AlpacaNonTradeActivity) -> Option<OptionLeg<'_>> {
5083    let id = activity.id.as_str();
5084    let activity_type = activity.activity_type.as_str();
5085    if activity.status.as_deref() == Some("canceled") {
5086        info!(
5087            id,
5088            activity_type, "Alpaca option activity canceled — left out"
5089        );
5090        return None;
5091    }
5092    let origin = match activity_type {
5093        "OPEXP" => Some(TradeOrigin::Expiry),
5094        "OPEXC" => Some(TradeOrigin::Exercise),
5095        "OPASN" => Some(TradeOrigin::Assignment),
5096        "OPTRD" => None,
5097        _ => {
5098            warn!(
5099                id,
5100                activity_type, "Alpaca activity is not an option event — left out"
5101            );
5102            return None;
5103        }
5104    };
5105    let date = activity
5106        .date
5107        .as_deref()
5108        .and_then(|date| NaiveDate::parse_from_str(date, "%Y-%m-%d").ok());
5109    let created_at = activity.created_at.as_deref().and_then(parse_timestamp);
5110    let time = created_at.or_else(|| date.map(|date| date.and_time(NaiveTime::MIN).and_utc()));
5111    let symbol = activity
5112        .symbol
5113        .as_deref()
5114        .filter(|symbol| !symbol.is_empty());
5115    let quantity = activity
5116        .qty
5117        .as_deref()
5118        .and_then(|qty| Decimal::from_str(qty).ok())
5119        .filter(|qty| !qty.is_zero());
5120    let price = activity
5121        .price
5122        .as_deref()
5123        .and_then(|price| Decimal::from_str(price).ok());
5124    let (Some(symbol), Some(quantity), Some(time)) = (symbol, quantity, time) else {
5125        warn!(
5126            id,
5127            activity_type,
5128            symbol = ?activity.symbol,
5129            qty = ?activity.qty,
5130            created_at = ?activity.created_at,
5131            date = ?activity.date,
5132            "Alpaca option activity missing/unparseable symbol, quantity or time — left out"
5133        );
5134        return None;
5135    };
5136    if origin.is_none() && price.is_none() {
5137        warn!(id, price = ?activity.price, "Alpaca OPTRD missing/unparseable price — left out");
5138        return None;
5139    }
5140    let contract = matches!(
5141        origin,
5142        Some(TradeOrigin::Exercise | TradeOrigin::Assignment)
5143    )
5144    .then(|| occ_contract(symbol))
5145    .flatten();
5146    Some(OptionLeg {
5147        id,
5148        symbol,
5149        quantity,
5150        price,
5151        date,
5152        time,
5153        created: created_at.is_some(),
5154        origin,
5155        contract,
5156    })
5157}
5158
5159/// The root symbol, whether it is a call, and the strike of a contract, from its OCC symbol: the
5160/// root, then the expiry as `YYMMDD`, `C` or `P`, and the strike in thousandths over eight digits.
5161fn occ_contract(symbol: &str) -> Option<(&str, bool, Decimal)> {
5162    let split = symbol.len().checked_sub(15)?;
5163    let (root, tail) = (
5164        symbol.get(..split)?.trim_end(),
5165        symbol.get(split..)?.as_bytes(),
5166    );
5167    let is_call = match tail[6] {
5168        b'C' => true,
5169        b'P' => false,
5170        _ => return None,
5171    };
5172    if root.is_empty() || !tail[..6].iter().chain(&tail[7..]).all(u8::is_ascii_digit) {
5173        return None;
5174    }
5175    let strike = std::str::from_utf8(&tail[7..]).ok()?.parse::<i64>().ok()?;
5176    Some((root, is_call, Decimal::new(strike, 3)))
5177}
5178
5179/// Whether `contract`, an exercise or assignment, is the one `shares`, an `OPTRD`, is the share
5180/// leg of. See [`AlpacaClient::fetch_option_events`].
5181fn is_share_leg_of(shares: &OptionLeg<'_>, contract: &OptionLeg<'_>) -> bool {
5182    let Some((root, is_call, strike)) = contract.contract else {
5183        return false;
5184    };
5185    // Exercising a call, or being assigned on a put, buys the shares.
5186    let buys = match contract.origin {
5187        Some(TradeOrigin::Exercise) => is_call,
5188        Some(TradeOrigin::Assignment) => !is_call,
5189        _ => return false,
5190    };
5191    shares.date.is_some()
5192        && shares.date == contract.date
5193        && shares.symbol == root
5194        && shares.price == Some(strike)
5195        && shares.quantity.is_sign_positive() == buys
5196}
5197
5198/// The option activities in `start..=end` as [`Trade`]s, each share leg given the origin of the
5199/// exercise or assignment it pairs with, which may lie outside the span. See
5200/// [`AlpacaClient::fetch_option_events`].
5201fn convert_option_events(
5202    activities: &[AlpacaNonTradeActivity],
5203    start: DateTime<Utc>,
5204    end: DateTime<Utc>,
5205) -> Vec<Trade<AssetNameExchange, InstrumentNameExchange>> {
5206    let legs: Vec<_> = activities.iter().filter_map(option_leg).collect();
5207    let mut paired = vec![false; legs.len()];
5208    legs.iter()
5209        .filter_map(|leg| {
5210            // Every share leg is paired, in or out of the span, so that one outside it does not
5211            // take the partner of one inside.
5212            let origin = leg.origin.unwrap_or_else(|| {
5213                let pairs = |index: &usize| !paired[*index] && is_share_leg_of(leg, &legs[*index]);
5214                // The standard contract delivers 100 shares. A match of that size goes first, so
5215                // that two exercises at one strike each pair with their own shares; an adjusted
5216                // contract, delivering another amount, still pairs with the first match.
5217                let delivers_its_shares = |index: &usize| {
5218                    legs[*index].quantity.abs() * Decimal::ONE_HUNDRED == leg.quantity.abs()
5219                };
5220                let partner = (0..legs.len())
5221                    .filter(pairs)
5222                    .find(delivers_its_shares)
5223                    .or_else(|| (0..legs.len()).find(pairs));
5224                match partner {
5225                    Some(index) => {
5226                        paired[index] = true;
5227                        legs[index].origin.unwrap_or(TradeOrigin::External)
5228                    }
5229                    None => {
5230                        warn!(
5231                            id = leg.id,
5232                            symbol = leg.symbol,
5233                            date = ?leg.date,
5234                            "Alpaca OPTRD pairs with no exercise or assignment read — reported as \
5235                             External"
5236                        );
5237                        TradeOrigin::External
5238                    }
5239                }
5240            });
5241            if !leg.is_within(start, end) {
5242                return None;
5243            }
5244            let side = if leg.quantity.is_sign_negative() {
5245                Side::Sell
5246            } else {
5247                Side::Buy
5248            };
5249            let trade = Trade::new(
5250                TradeId::new(leg.id),
5251                OrderId::new(leg.id),
5252                InstrumentNameExchange::new(leg.symbol),
5253                StrategyId::unknown(),
5254                leg.time,
5255                side,
5256                leg.price.unwrap_or(Decimal::ZERO),
5257                leg.quantity.abs(),
5258                None,
5259                AssetFees::new(
5260                    AssetNameExchange::from(USD),
5261                    Decimal::ZERO,
5262                    Some(Decimal::ZERO),
5263                ),
5264            )
5265            .with_origin(origin);
5266            Some(trade)
5267        })
5268        .collect()
5269}
5270
5271/// `trade` with the client order id its WebSocket order update reported, if any.
5272fn with_reported_cid<AssetKey, InstrumentKey>(
5273    trade: Trade<AssetKey, InstrumentKey>,
5274    client_order_id: Option<&SmolStr>,
5275) -> Trade<AssetKey, InstrumentKey> {
5276    match client_order_id {
5277        // A `SmolStr` clone shares a long id rather than copying it.
5278        Some(cid) if !cid.is_empty() => trade.with_client_order_id(ClientOrderId(cid.clone())),
5279        _ => trade,
5280    }
5281}
5282
5283fn parse_timestamp(s: &str) -> Option<DateTime<Utc>> {
5284    DateTime::parse_from_rfc3339(s)
5285        .ok()
5286        .map(|dt| dt.with_timezone(&Utc))
5287}
5288
5289fn map_side(side: Side) -> &'static str {
5290    match side {
5291        Side::Buy => "buy",
5292        Side::Sell => "sell",
5293    }
5294}
5295
5296fn map_order_kind(kind: OrderKind) -> Option<&'static str> {
5297    match kind {
5298        OrderKind::Market => Some("market"),
5299        OrderKind::Limit => Some("limit"),
5300        OrderKind::Stop { .. } => Some("stop"),
5301        OrderKind::StopLimit { .. } => Some("stop_limit"),
5302        OrderKind::TrailingStop {
5303            activation_price: None,
5304            ..
5305        } => Some("trailing_stop"),
5306        // Alpaca has no take-profit, trailing stop-limit or fixed-limit trailing order, and no
5307        // activation price on a trailing stop.
5308        OrderKind::TakeProfit { .. }
5309        | OrderKind::TakeProfitLimit { .. }
5310        | OrderKind::TrailingStop {
5311            activation_price: Some(_),
5312            ..
5313        }
5314        | OrderKind::TrailingStopLimit { .. }
5315        | OrderKind::TrailingStopFixedLimit { .. } => None,
5316    }
5317}
5318
5319/// Map rustrade's `TimeInForce` to Alpaca's string representation.
5320///
5321/// # Errors
5322///
5323/// Returns `Err` if `post_only: true` is requested. Alpaca does not support
5324/// post-only orders — silently placing a taker-eligible GTC order would be
5325/// the opposite of caller intent, risking unexpected taker fees. Callers
5326/// requiring maker-only execution must use a different venue or strategy.
5327fn map_time_in_force(tif: TimeInForce) -> Result<&'static str, &'static str> {
5328    match tif {
5329        TimeInForce::GoodUntilCancelled { post_only } => {
5330            if post_only {
5331                return Err("Alpaca does not support post_only orders");
5332            }
5333            Ok("gtc")
5334        }
5335        TimeInForce::GoodUntilEndOfDay => Ok("day"),
5336        TimeInForce::FillOrKill => Ok("fok"),
5337        TimeInForce::ImmediateOrCancel => Ok("ioc"),
5338        TimeInForce::AtOpen => Ok("opg"),
5339        TimeInForce::AtClose => Ok("cls"),
5340        // Alpaca's `gtd` requires an `expired_at` timestamp on the order request,
5341        // which this client does not currently surface. Reject to avoid silently
5342        // dropping the expiry semantics.
5343        TimeInForce::GoodTillDate { .. } => {
5344            Err("Alpaca GoodTillDate is not yet wired through this client")
5345        }
5346    }
5347}
5348
5349/// Returns `true` if the symbol is an equity or options symbol (i.e., NOT crypto).
5350///
5351/// Crypto symbols on Alpaca always contain a forward slash (e.g., `"BTC/USD"`).
5352/// Equities and OCC option symbols never contain a slash. We use this to decide
5353/// whether to include `position_intent` in the order request: the field is valid
5354/// for equities and required for options, but causes a 422 on crypto orders.
5355///
5356/// NOTE: relies on Alpaca's documented symbol format (as of 2025 API). If Alpaca
5357/// introduces a new asset class whose symbols contain `/`, `position_intent` would
5358/// be silently omitted for those orders, causing 422 errors.
5359fn is_options_or_equity_symbol(symbol: &str) -> bool {
5360    !symbol.contains('/')
5361}
5362
5363/// Derives Alpaca's `position_intent` from the generic `reduce_only` flag and `side`.
5364///
5365/// | reduce_only | side | intent       | use case                          |
5366/// |-------------|------|--------------|-----------------------------------|
5367/// | false       | Buy  | BuyToOpen    | open long / add to long position  |
5368/// | false       | Sell | SellToOpen   | open short / write option         |
5369/// | true        | Buy  | BuyToClose   | close short position              |
5370/// | true        | Sell | SellToClose  | close long position               |
5371fn map_position_intent(side: Side, reduce_only: bool) -> AlpacaPositionIntent {
5372    match (reduce_only, side) {
5373        (false, Side::Buy) => AlpacaPositionIntent::BuyToOpen,
5374        (false, Side::Sell) => AlpacaPositionIntent::SellToOpen,
5375        (true, Side::Buy) => AlpacaPositionIntent::BuyToClose,
5376        (true, Side::Sell) => AlpacaPositionIntent::SellToClose,
5377    }
5378}
5379
5380/// Classifies an HTTP error status + body into a typed [`ApiError`].
5381///
5382/// Used by both `rest_with_retry` (for general REST calls) and `rest_delete_with_retry`
5383/// (for cancel operations) to ensure consistent error classification across all REST paths.
5384///
5385/// `code` is the body's Alpaca error code, where it has one. It is prefixed to the message of a
5386/// refusal, so a caller can see it, except on a 404, whose message says the order was not found.
5387///
5388/// The mapping knows nothing of the request, so a query (account, positions, activities) refused
5389/// on a business rule gets an order's variant too, as any other 4xx on a query already does.
5390fn parse_api_error(
5391    status: reqwest::StatusCode,
5392    code: Option<i64>,
5393    message: &str,
5394) -> crate::error::UnindexedApiError {
5395    // Fast path: 429 doesn't need message parsing. The retry loops read a 429's headers before
5396    // this is reached; this mapping has none to read.
5397    if status == reqwest::StatusCode::TOO_MANY_REQUESTS {
5398        return ApiError::rate_limited(None);
5399    }
5400
5401    // Compute lowercase once for all match guards that inspect the message body.
5402    let lower = message.to_ascii_lowercase();
5403    match status.as_u16() {
5404        // Match "already" before "insufficient": if Alpaca ever sends a 422 body
5405        // containing both substrings, this arm wins and maps to OrderAlreadyCancelled,
5406        // which is more specific than BalanceInsufficient.
5407        422 if lower.contains("already") => ApiError::OrderAlreadyCancelled,
5408        422 => parse_business_refusal(code, message, &lower),
5409        401 => ApiError::Unauthenticated(format!("unauthorized: {message}")),
5410        // A 403 means one of two things, told apart by the code. Without one, it is Alpaca's
5411        // `{"message": "forbidden."}` to a wrong key, secret or host. With one, the account is
5412        // fine and Alpaca refused the request on a business rule: `40310000` is generic
5413        // ("insufficient buying power", "insufficient qty available for order", "account is
5414        // not allowed to short", "account is restricted to liquidation only", ...), and
5415        // `40310100` is pattern-day-trader protection.
5416        403 => match code {
5417            None => ApiError::Unauthenticated(format!("forbidden: {message}")),
5418            Some(_) => parse_business_refusal(code, message, &lower),
5419        },
5420        404 => ApiError::OrderRejected(format!("order not found: {message}")),
5421        _ => ApiError::OrderRejected(with_alpaca_code(code, message)),
5422    }
5423}
5424
5425/// Classifies a refusal on a business rule (a 422, or a 403 with a code) by its text, since
5426/// Alpaca's codes are generic. `lower` is `message` in lowercase. The first match below wins, so
5427/// a refused short is reported as such even if its text also says "insufficient".
5428///
5429/// - "account is not allowed to short" → [`ApiError::BorrowRejected`] with
5430///   [`BorrowRejectReason::BorrowDisabled`].
5431/// - "cannot be sold short" → [`BorrowRejectReason::NotShortable`], except Alpaca's "fractional
5432///   orders cannot be sold short": the asset may be shortable in whole shares, so that is the
5433///   order's shape, an [`ApiError::OrderRejected`].
5434/// - "insufficient" → [`ApiError::BalanceInsufficient`]. The body says what ran short
5435///   ("insufficient buying power", or "insufficient qty available" on a sell) but names no
5436///   asset, so none is guessed.
5437/// - anything else → [`ApiError::OrderRejected`].
5438fn parse_business_refusal(code: Option<i64>, message: &str, lower: &str) -> UnindexedApiError {
5439    let borrow = if lower.contains("not allowed to short") {
5440        Some(BorrowRejectReason::BorrowDisabled)
5441    } else if lower.contains("cannot be sold short") && !lower.contains("fractional") {
5442        Some(BorrowRejectReason::NotShortable)
5443    } else {
5444        None
5445    };
5446    if let Some(reason) = borrow {
5447        ApiError::BorrowRejected(BorrowReject::new(
5448            reason,
5449            code.map(|code| code.to_string()),
5450            message.to_owned(),
5451        ))
5452    } else if lower.contains("insufficient") {
5453        ApiError::BalanceInsufficient(None, with_alpaca_code(code, message))
5454    } else {
5455        ApiError::OrderRejected(with_alpaca_code(code, message))
5456    }
5457}
5458
5459/// `message` prefixed with Alpaca's code where it has one, so a caller can see it.
5460fn with_alpaca_code(code: Option<i64>, message: &str) -> String {
5461    match code {
5462        Some(code) => format!("{code} {message}"),
5463        None => message.to_owned(),
5464    }
5465}
5466
5467/// Wraps [`parse_api_error`] for order-specific error handling (e.g., cancel_order).
5468fn parse_order_error(
5469    status: reqwest::StatusCode,
5470    code: Option<i64>,
5471    message: &str,
5472) -> UnindexedOrderError {
5473    UnindexedOrderError::Rejected(parse_api_error(status, code, message))
5474}
5475
5476fn connectivity_err(msg: impl Into<String>) -> UnindexedClientError {
5477    UnindexedClientError::Connectivity(ConnectivityError::Socket(msg.into()))
5478}
5479
5480/// Map a failed order `POST` through [`rest_with_retry`] to the order's error.
5481///
5482/// [`UnindexedClientError::Internal`] there means Alpaca answered 2xx, so it accepted the order,
5483/// but the response could not be read. The order may be live, so it is reported as
5484/// [`OrderError::Connectivity`], the status-unknown error, never as a rejection. A caller must
5485/// reconcile it (open orders, fills) before resubmitting, or it may place the order twice.
5486fn order_post_error(error: UnindexedClientError) -> UnindexedOrderError {
5487    match error {
5488        UnindexedClientError::Connectivity(ce) => OrderError::Connectivity(ce),
5489        UnindexedClientError::Api(ae) => OrderError::Rejected(ae),
5490        UnindexedClientError::Internal(msg) => OrderError::Connectivity(ConnectivityError::Socket(
5491            format!("Alpaca order status unknown, its 2xx response could not be read: {msg}"),
5492        )),
5493        // `rest_with_retry` returns none of these today. Matched explicitly so a new
5494        // `ClientError` variant is a compile error here, and reported as status unknown rather
5495        // than panicking an order path if that ever changes.
5496        other @ (UnindexedClientError::TaskFailed(_)
5497        | UnindexedClientError::Truncated { .. }
5498        | UnindexedClientError::TruncatedSnapshot { .. }) => OrderError::Connectivity(
5499            ConnectivityError::Socket(format!("Alpaca order status unknown: {other}")),
5500        ),
5501    }
5502}
5503
5504// ---------------------------------------------------------------------------
5505// OrderStatusClient implementation
5506// ---------------------------------------------------------------------------
5507
5508impl OrderStatusClient for AlpacaClient {
5509    /// Looks each order up with `GET /v2/orders:by_client_order_id`, up to 8 at a time.
5510    ///
5511    /// `filled` is reported as fully filled with `filled_avg_price`, `canceled` and `replaced` as
5512    /// cancelled (the order replacing one has its own client order id, which the caller must
5513    /// learn from [`ExecutionClient::fetch_open_orders`]), `expired` as expired and `rejected` as
5514    /// open failed. `done_for_day` is not ended: Alpaca works the order again the next trading day.
5515    /// A 404, or an order on another symbol than the key's (ignoring case), is unknown.
5516    async fn fetch_ended_orders(
5517        &self,
5518        orders: &[UnindexedOrderKey],
5519    ) -> Result<Vec<UnindexedInactiveOrder>, UnindexedClientError> {
5520        fetch_ended_by_key(orders, |key| {
5521            fetch_order_lookup(
5522                self.http.clone(),
5523                self.rate_limiter.clone(),
5524                self.config.clone(),
5525                key,
5526            )
5527        })
5528        .await
5529    }
5530}
5531
5532// ---------------------------------------------------------------------------
5533// ShortabilityClient implementation
5534// ---------------------------------------------------------------------------
5535
5536impl ShortabilityClient for AlpacaClient {
5537    /// Reads the asset with `GET /v2/assets/{symbol}`, one request.
5538    ///
5539    /// - `shortable` is the asset's `shortable`.
5540    /// - `easy_to_borrow` is its `borrow_status`, which Alpaca reports for US equities only, or
5541    ///   failing that its `easy_to_borrow` flag; neither present leaves it unknown.
5542    /// - `fee_rate` is zero for an easy-to-borrow stock, as Alpaca charges no borrow fee on one.
5543    ///   That is Alpaca's charge, not the market's cost of borrowing. Alpaca publishes no rate for
5544    ///   a hard-to-borrow stock, so its fee is unknown.
5545    /// - `available` is unknown. Alpaca's locate quotes report what it can lend of a hard-to-borrow
5546    ///   stock, but they are not on paper accounts and are not read here.
5547    /// - `delisting_at` is unknown: Alpaca's asset carries no delisting date.
5548    ///
5549    /// Alpaca updates its borrow flags from a daily list, so they may be up to a day old.
5550    ///
5551    /// # Errors
5552    ///
5553    /// [`ApiError::InstrumentInvalid`] for a symbol Alpaca knows no asset under, and for an
5554    /// option, as writing one borrows nothing.
5555    async fn fetch_shortability(
5556        &self,
5557        instrument: &InstrumentNameExchange,
5558    ) -> Result<Shortability, UnindexedClientError> {
5559        let url = asset_url(self.base_url(), instrument)?;
5560        let found: Option<AlpacaAsset> =
5561            rest_lookup_with_retry(&self.rate_limiter, || self.http.get(url.clone())).await?;
5562        let Some(asset) = found else {
5563            return Err(instrument_invalid(
5564                instrument,
5565                "Alpaca knows no asset under this symbol",
5566            ));
5567        };
5568        shortability_from_asset(instrument, &asset)
5569    }
5570}
5571
5572/// `GET /v2/assets/{symbol}`, with the symbol as one path segment, so a crypto pair's `/` is
5573/// escaped rather than read as a path separator.
5574fn asset_url(
5575    base_url: &str,
5576    instrument: &InstrumentNameExchange,
5577) -> Result<reqwest::Url, UnindexedClientError> {
5578    let mut url = reqwest::Url::parse(base_url).map_err(|error| {
5579        UnindexedClientError::Internal(format!("Alpaca base URL {base_url:?}: {error}"))
5580    })?;
5581    url.path_segments_mut()
5582        .map_err(|()| {
5583            UnindexedClientError::Internal(format!("Alpaca base URL {base_url:?} has no path"))
5584        })?
5585        .pop_if_empty()
5586        .extend(["v2", "assets", instrument.name().as_str()]);
5587    Ok(url)
5588}
5589
5590fn instrument_invalid(instrument: &InstrumentNameExchange, reason: &str) -> UnindexedClientError {
5591    UnindexedClientError::Api(ApiError::InstrumentInvalid(
5592        instrument.clone(),
5593        reason.to_owned(),
5594    ))
5595}
5596
5597/// What [`AlpacaClient::fetch_shortability`] reports of `asset`.
5598fn shortability_from_asset(
5599    instrument: &InstrumentNameExchange,
5600    asset: &AlpacaAsset,
5601) -> Result<Shortability, UnindexedClientError> {
5602    if asset.class == AlpacaAssetClass::UsOption {
5603        return Err(instrument_invalid(
5604            instrument,
5605            "shortability does not apply to an option: writing one borrows nothing",
5606        ));
5607    }
5608    let easy_to_borrow = match asset.borrow_status {
5609        Some(AlpacaBorrowStatus::EasyToBorrow) => Some(true),
5610        Some(AlpacaBorrowStatus::HardToBorrow) => Some(false),
5611        Some(AlpacaBorrowStatus::Other) | None => asset.easy_to_borrow,
5612    };
5613    let mut shortability = Shortability::new();
5614    if let Some(shortable) = asset.shortable {
5615        shortability = shortability.with_shortable(shortable);
5616    }
5617    if let Some(easy_to_borrow) = easy_to_borrow {
5618        shortability = shortability.with_easy_to_borrow(easy_to_borrow);
5619        if easy_to_borrow {
5620            shortability = shortability.with_fee_rate(Decimal::ZERO);
5621        }
5622    }
5623    Ok(shortability)
5624}
5625
5626/// The fields of `GET /v2/assets/{symbol}` a [`Shortability`] is built from. Each is optional, so
5627/// one Alpaca leaves out reads as unknown rather than failing the request.
5628#[derive(Debug, Deserialize)]
5629struct AlpacaAsset {
5630    #[serde(default)]
5631    class: AlpacaAssetClass,
5632    #[serde(default)]
5633    shortable: Option<bool>,
5634    /// US equities only.
5635    #[serde(default)]
5636    borrow_status: Option<AlpacaBorrowStatus>,
5637    /// In Alpaca's examples but not its schema, so read only when `borrow_status` is absent.
5638    #[serde(default)]
5639    easy_to_borrow: Option<bool>,
5640}
5641
5642#[derive(Debug, Default, PartialEq, Eq, Deserialize)]
5643#[serde(rename_all = "snake_case")]
5644enum AlpacaAssetClass {
5645    UsOption,
5646    #[default]
5647    #[serde(other)]
5648    Other,
5649}
5650
5651#[derive(Debug, PartialEq, Eq, Deserialize)]
5652#[serde(rename_all = "snake_case")]
5653enum AlpacaBorrowStatus {
5654    EasyToBorrow,
5655    HardToBorrow,
5656    #[serde(other)]
5657    Other,
5658}
5659
5660// ---------------------------------------------------------------------------
5661// BracketOrderClient implementation
5662// ---------------------------------------------------------------------------
5663
5664impl BracketOrderClient for AlpacaClient {
5665    async fn open_bracket_order(
5666        &self,
5667        request: UnifiedBracketOrderRequest<ExchangeId, &InstrumentNameExchange>,
5668    ) -> UnifiedBracketOrderResult {
5669        let alpaca_request = AlpacaBracketOrderRequest {
5670            instrument: request.key.instrument.clone(),
5671            strategy: request.key.strategy.clone(),
5672            cid: request.key.cid.clone(),
5673            side: request.state.side,
5674            quantity: request.state.quantity,
5675            entry_price: request.state.entry_price,
5676            take_profit_price: request.state.take_profit_price,
5677            stop_loss_price: request.state.stop_loss_price,
5678            stop_loss_limit_price: request.state.stop_loss_limit_price,
5679            time_in_force: request.state.time_in_force,
5680        };
5681
5682        let result = self.open_bracket_order(alpaca_request).await;
5683
5684        UnifiedBracketOrderResult::parent_only(result.parent)
5685    }
5686}
5687
5688// ---------------------------------------------------------------------------
5689// Tests
5690// ---------------------------------------------------------------------------
5691
5692#[cfg(test)]
5693#[allow(clippy::unwrap_used, clippy::expect_used)] // Test code: panics on bad input are acceptable
5694mod tests {
5695    use super::*;
5696    use rust_decimal_macros::dec;
5697
5698    #[test]
5699    fn test_alpaca_config_new_uses_paper_trading_by_default() {
5700        let cfg = AlpacaConfig::new("my_key".into(), "my_secret".into());
5701        assert!(cfg.paper);
5702    }
5703
5704    #[test]
5705    fn test_alpaca_config_debug_redacts_credentials() {
5706        let cfg = AlpacaConfig::new("my_key".into(), "my_secret".into());
5707        let debug = format!("{cfg:?}");
5708        assert!(!debug.contains("my_key"), "api_key should be redacted");
5709        assert!(
5710            !debug.contains("my_secret"),
5711            "secret_key should be redacted"
5712        );
5713        assert!(debug.contains("paper: true"));
5714    }
5715
5716    #[test]
5717    fn test_alpaca_config_deserialize_omitted_paper_defaults_to_paper() {
5718        let cfg: AlpacaConfig = serde_json::from_str(
5719            r#"{
5720        "api_key": "my_key",
5721        "secret_key": "my_secret"
5722    }"#,
5723        )
5724        .unwrap();
5725
5726        assert!(cfg.paper);
5727        assert_eq!(cfg.rest_base_url(), "https://paper-api.alpaca.markets");
5728    }
5729
5730    #[test]
5731    fn test_alpaca_config_deserialize_paper_true() {
5732        let cfg: AlpacaConfig = serde_json::from_str(
5733            r#"{
5734        "api_key": "my_key",
5735        "secret_key": "my_secret",
5736        "paper": true
5737    }"#,
5738        )
5739        .unwrap();
5740
5741        assert!(cfg.paper);
5742        assert_eq!(cfg.rest_base_url(), "https://paper-api.alpaca.markets");
5743    }
5744
5745    #[test]
5746    fn test_alpaca_config_deserialize_paper_false() {
5747        let cfg: AlpacaConfig = serde_json::from_str(
5748            r#"{
5749        "api_key": "my_key",
5750        "secret_key": "my_secret",
5751        "paper": false
5752    }"#,
5753        )
5754        .unwrap();
5755
5756        assert!(!cfg.paper);
5757        assert_eq!(cfg.rest_base_url(), "https://api.alpaca.markets");
5758    }
5759
5760    #[test]
5761    fn test_alpaca_config_urls() {
5762        let paper = AlpacaConfig::paper("k".into(), "s".into());
5763        assert!(paper.rest_base_url().contains("paper-api"));
5764        assert!(paper.ws_url().contains("paper-api"));
5765
5766        let live = AlpacaConfig::production("k".into(), "s".into());
5767        assert!(!live.rest_base_url().contains("paper-api"));
5768        assert!(!live.ws_url().contains("paper-api"));
5769    }
5770
5771    #[test]
5772    #[serial_test::serial]
5773    fn test_alpaca_config_from_env_defaults_to_paper_trading() {
5774        temp_env::with_vars(
5775            [
5776                ("ALPACA_API_KEY", Some("my_key")),
5777                ("ALPACA_SECRET_KEY", Some("my_secret")),
5778                ("ALPACA_PAPER", None),
5779            ],
5780            || {
5781                let cfg = AlpacaConfig::from_env().unwrap();
5782                assert!(cfg.paper);
5783                assert_eq!(cfg.rest_base_url(), "https://paper-api.alpaca.markets");
5784            },
5785        );
5786    }
5787
5788    #[test]
5789    #[serial_test::serial]
5790    fn test_alpaca_config_from_env_accepts_explicit_production() {
5791        temp_env::with_vars(
5792            [
5793                ("ALPACA_API_KEY", Some("my_key")),
5794                ("ALPACA_SECRET_KEY", Some("my_secret")),
5795                ("ALPACA_PAPER", Some("false")),
5796            ],
5797            || {
5798                let cfg = AlpacaConfig::from_env().unwrap();
5799                assert!(!cfg.paper);
5800                assert_eq!(cfg.rest_base_url(), "https://api.alpaca.markets");
5801            },
5802        );
5803    }
5804
5805    #[test]
5806    #[serial_test::serial]
5807    fn test_alpaca_config_from_env_rejects_invalid_paper() {
5808        temp_env::with_vars(
5809            [
5810                ("ALPACA_API_KEY", Some("my_key")),
5811                ("ALPACA_SECRET_KEY", Some("my_secret")),
5812                ("ALPACA_PAPER", Some("maybe")),
5813            ],
5814            || {
5815                let err = AlpacaConfig::from_env().unwrap_err();
5816                assert!(matches!(err, AlpacaConfigError::InvalidPaper(value) if value == "maybe"));
5817            },
5818        );
5819    }
5820
5821    #[test]
5822    #[serial_test::serial]
5823    fn test_alpaca_config_from_env_requires_credentials() {
5824        temp_env::with_vars(
5825            [
5826                ("ALPACA_API_KEY", None),
5827                ("ALPACA_SECRET_KEY", Some("my_secret")),
5828                ("ALPACA_PAPER", None),
5829            ],
5830            || {
5831                let err = AlpacaConfig::from_env().unwrap_err();
5832                assert!(matches!(err, AlpacaConfigError::MissingApiKey));
5833            },
5834        );
5835    }
5836
5837    #[test]
5838    fn test_parse_side() {
5839        assert_eq!(parse_side("buy"), Some(Side::Buy));
5840        assert_eq!(parse_side("sell"), Some(Side::Sell));
5841        assert_eq!(parse_side("Buy"), Some(Side::Buy));
5842        assert_eq!(parse_side("BUY"), Some(Side::Buy));
5843        assert_eq!(parse_side("unknown"), None);
5844    }
5845
5846    #[test]
5847    fn test_parse_order_kind() {
5848        // Market and Limit don't need extra params
5849        assert_eq!(
5850            parse_order_kind("market", None, None, None),
5851            Some(OrderKind::Market)
5852        );
5853        assert_eq!(
5854            parse_order_kind("limit", None, None, None),
5855            Some(OrderKind::Limit)
5856        );
5857
5858        // Stop requires stop_price
5859        assert_eq!(
5860            parse_order_kind("stop", Some("150.00"), None, None),
5861            Some(OrderKind::Stop {
5862                trigger_price: Decimal::from_str("150.00").unwrap()
5863            })
5864        );
5865        assert_eq!(parse_order_kind("stop", None, None, None), None);
5866
5867        // StopLimit requires stop_price
5868        assert_eq!(
5869            parse_order_kind("stop_limit", Some("145.00"), None, None),
5870            Some(OrderKind::StopLimit {
5871                trigger_price: Decimal::from_str("145.00").unwrap()
5872            })
5873        );
5874
5875        // TrailingStop with percentage
5876        assert_eq!(
5877            parse_order_kind("trailing_stop", None, Some("5.0"), None),
5878            Some(OrderKind::TrailingStop {
5879                offset: Decimal::from_str("5.0").unwrap(),
5880                offset_type: TrailingOffsetType::Percentage,
5881                activation_price: None,
5882            })
5883        );
5884
5885        // TrailingStop with absolute price
5886        assert_eq!(
5887            parse_order_kind("trailing_stop", None, None, Some("2.50")),
5888            Some(OrderKind::TrailingStop {
5889                offset: Decimal::from_str("2.50").unwrap(),
5890                offset_type: TrailingOffsetType::Absolute,
5891                activation_price: None,
5892            })
5893        );
5894
5895        // TrailingStop without either offset returns None
5896        assert_eq!(parse_order_kind("trailing_stop", None, None, None), None);
5897
5898        // Unknown type returns None
5899        assert_eq!(parse_order_kind("unknown", None, None, None), None);
5900    }
5901
5902    #[test]
5903    fn test_map_order_kind() {
5904        assert_eq!(map_order_kind(OrderKind::Market), Some("market"));
5905        assert_eq!(map_order_kind(OrderKind::Limit), Some("limit"));
5906        assert_eq!(
5907            map_order_kind(OrderKind::Stop {
5908                trigger_price: Decimal::from_str("150.00").unwrap()
5909            }),
5910            Some("stop")
5911        );
5912        assert_eq!(
5913            map_order_kind(OrderKind::StopLimit {
5914                trigger_price: Decimal::from_str("145.00").unwrap()
5915            }),
5916            Some("stop_limit")
5917        );
5918        assert_eq!(
5919            map_order_kind(OrderKind::TrailingStop {
5920                offset: Decimal::from_str("5.0").unwrap(),
5921                offset_type: TrailingOffsetType::Percentage,
5922                activation_price: None,
5923            }),
5924            Some("trailing_stop")
5925        );
5926        assert_eq!(
5927            map_order_kind(OrderKind::TrailingStop {
5928                offset: Decimal::from_str("2.50").unwrap(),
5929                offset_type: TrailingOffsetType::Absolute,
5930                activation_price: None,
5931            }),
5932            Some("trailing_stop")
5933        );
5934        // Alpaca has no activation price, and no fixed-limit trailing stop
5935        assert_eq!(
5936            map_order_kind(OrderKind::TrailingStop {
5937                offset: Decimal::from_str("5.0").unwrap(),
5938                offset_type: TrailingOffsetType::Percentage,
5939                activation_price: Some(Decimal::from(100)),
5940            }),
5941            None
5942        );
5943        assert_eq!(
5944            map_order_kind(OrderKind::TrailingStopFixedLimit {
5945                offset: Decimal::from_str("5.0").unwrap(),
5946                offset_type: TrailingOffsetType::Percentage,
5947                activation_price: None,
5948            }),
5949            None
5950        );
5951        // TrailingStopLimit is not supported by Alpaca
5952        assert_eq!(
5953            map_order_kind(OrderKind::TrailingStopLimit {
5954                offset: Decimal::from_str("5.0").unwrap(),
5955                offset_type: TrailingOffsetType::Percentage,
5956                stop_price: Decimal::from_str("95.0").unwrap(),
5957                limit_offset: Decimal::from_str("1.0").unwrap(),
5958            }),
5959            None
5960        );
5961        // TakeProfit/TakeProfitLimit are not supported by Alpaca
5962        assert_eq!(
5963            map_order_kind(OrderKind::TakeProfit {
5964                trigger_price: Decimal::from_str("160.00").unwrap()
5965            }),
5966            None
5967        );
5968        assert_eq!(
5969            map_order_kind(OrderKind::TakeProfitLimit {
5970                trigger_price: Decimal::from_str("160.00").unwrap()
5971            }),
5972            None
5973        );
5974    }
5975
5976    #[test]
5977    fn test_map_time_in_force_roundtrip() {
5978        assert_eq!(
5979            map_time_in_force(TimeInForce::GoodUntilCancelled { post_only: false }),
5980            Ok("gtc")
5981        );
5982        assert_eq!(map_time_in_force(TimeInForce::GoodUntilEndOfDay), Ok("day"));
5983        assert_eq!(map_time_in_force(TimeInForce::FillOrKill), Ok("fok"));
5984        assert_eq!(map_time_in_force(TimeInForce::ImmediateOrCancel), Ok("ioc"));
5985    }
5986
5987    #[test]
5988    fn test_map_time_in_force_rejects_post_only() {
5989        let result = map_time_in_force(TimeInForce::GoodUntilCancelled { post_only: true });
5990        assert!(result.is_err(), "post_only must be rejected");
5991        assert!(result.unwrap_err().contains("post_only"));
5992    }
5993
5994    // =========================================================================
5995    // Bracket Order Serialization Tests
5996    // =========================================================================
5997
5998    #[test]
5999    fn test_bracket_order_serializes_with_stop_loss_stop_order() {
6000        // Bracket order with stop-loss as a simple stop order (no limit_price)
6001        let body = AlpacaOrderRequest {
6002            symbol: "AAPL",
6003            qty: "10".to_string(),
6004            side: "buy",
6005            order_type: "limit",
6006            time_in_force: "gtc",
6007            limit_price: Some("150.00".to_string()),
6008            stop_price: None,
6009            trail_percent: None,
6010            trail_price: None,
6011            client_order_id: Some("bracket-001"),
6012            position_intent: Some(AlpacaPositionIntent::BuyToOpen),
6013            order_class: Some("bracket"),
6014            take_profit: Some(TakeProfitParams {
6015                limit_price: "160.00".to_string(),
6016            }),
6017            stop_loss: Some(StopLossParams {
6018                stop_price: "145.00".to_string(),
6019                limit_price: None,
6020            }),
6021        };
6022
6023        let json = serde_json::to_value(&body).unwrap();
6024
6025        assert_eq!(json["symbol"], "AAPL");
6026        assert_eq!(json["qty"], "10");
6027        assert_eq!(json["side"], "buy");
6028        assert_eq!(json["type"], "limit");
6029        assert_eq!(json["time_in_force"], "gtc");
6030        assert_eq!(json["limit_price"], "150.00");
6031        assert_eq!(json["order_class"], "bracket");
6032
6033        // Take profit should have limit_price
6034        assert_eq!(json["take_profit"]["limit_price"], "160.00");
6035
6036        // Stop loss should have stop_price but NO limit_price
6037        assert_eq!(json["stop_loss"]["stop_price"], "145.00");
6038        assert!(
6039            json["stop_loss"].get("limit_price").is_none(),
6040            "stop_loss.limit_price should be omitted when None"
6041        );
6042    }
6043
6044    #[test]
6045    fn test_bracket_order_serializes_with_stop_loss_stop_limit_order() {
6046        // Bracket order with stop-loss as a stop-limit order (has limit_price)
6047        let body = AlpacaOrderRequest {
6048            symbol: "SPY",
6049            qty: "5".to_string(),
6050            side: "sell",
6051            order_type: "limit",
6052            time_in_force: "day",
6053            limit_price: Some("450.00".to_string()),
6054            stop_price: None,
6055            trail_percent: None,
6056            trail_price: None,
6057            client_order_id: Some("bracket-002"),
6058            position_intent: Some(AlpacaPositionIntent::SellToClose),
6059            order_class: Some("bracket"),
6060            take_profit: Some(TakeProfitParams {
6061                limit_price: "440.00".to_string(),
6062            }),
6063            stop_loss: Some(StopLossParams {
6064                stop_price: "455.00".to_string(),
6065                limit_price: Some("456.00".to_string()),
6066            }),
6067        };
6068
6069        let json = serde_json::to_value(&body).unwrap();
6070
6071        assert_eq!(json["symbol"], "SPY");
6072        assert_eq!(json["side"], "sell");
6073        assert_eq!(json["time_in_force"], "day");
6074        assert_eq!(json["order_class"], "bracket");
6075
6076        // Take profit
6077        assert_eq!(json["take_profit"]["limit_price"], "440.00");
6078
6079        // Stop loss with limit_price (stop-limit order)
6080        assert_eq!(json["stop_loss"]["stop_price"], "455.00");
6081        assert_eq!(
6082            json["stop_loss"]["limit_price"], "456.00",
6083            "stop_loss.limit_price should be present for stop-limit orders"
6084        );
6085    }
6086
6087    // =========================================================================
6088    // Bracket Order Validation Tests
6089    // =========================================================================
6090
6091    #[tokio::test]
6092    async fn test_open_bracket_order_rejects_invalid_tif() {
6093        use rust_decimal_macros::dec;
6094        use rustrade_instrument::instrument::name::InstrumentNameExchange;
6095
6096        // Create a minimal client with dummy credentials (no network call will be made)
6097        let config = AlpacaConfig::new("dummy_key".into(), "dummy_secret".into());
6098        let client = AlpacaClient::new(config);
6099
6100        let request = AlpacaBracketOrderRequest::new(
6101            InstrumentNameExchange::new("SPY"),
6102            crate::order::id::StrategyId::new("test"),
6103            crate::order::id::ClientOrderId::new("test-tif"),
6104            Side::Buy,
6105            dec!(1),
6106            dec!(100.00),
6107            dec!(120.00),
6108            dec!(90.00),
6109            TimeInForce::ImmediateOrCancel, // Invalid for brackets
6110        );
6111
6112        let result = client.open_bracket_order(request).await;
6113
6114        assert!(
6115            result.parent.state.is_failed(),
6116            "Bracket order with IOC TIF should be rejected locally"
6117        );
6118    }
6119
6120    #[tokio::test]
6121    async fn test_open_bracket_order_rejects_invalid_price_ordering() {
6122        use rust_decimal_macros::dec;
6123        use rustrade_instrument::instrument::name::InstrumentNameExchange;
6124
6125        let config = AlpacaConfig::new("dummy_key".into(), "dummy_secret".into());
6126        let client = AlpacaClient::new(config);
6127
6128        // Buy bracket with SL > entry (invalid)
6129        let request = AlpacaBracketOrderRequest::new(
6130            InstrumentNameExchange::new("SPY"),
6131            crate::order::id::StrategyId::new("test"),
6132            crate::order::id::ClientOrderId::new("test-price"),
6133            Side::Buy,
6134            dec!(1),
6135            dec!(100.00),
6136            dec!(120.00),
6137            dec!(105.00), // Invalid: SL > entry for buy
6138            TimeInForce::GoodUntilCancelled { post_only: false },
6139        );
6140
6141        let result = client.open_bracket_order(request).await;
6142
6143        assert!(
6144            result.parent.state.is_failed(),
6145            "Bracket order with invalid price ordering should be rejected locally"
6146        );
6147    }
6148
6149    #[tokio::test]
6150    async fn test_open_bracket_order_rejects_invalid_sl_limit_price() {
6151        use rust_decimal_macros::dec;
6152        use rustrade_instrument::instrument::name::InstrumentNameExchange;
6153
6154        let config = AlpacaConfig::new("dummy_key".into(), "dummy_secret".into());
6155        let client = AlpacaClient::new(config);
6156
6157        // Buy bracket with SL limit > SL trigger (invalid for sell stop-limit)
6158        let request = AlpacaBracketOrderRequest::new(
6159            InstrumentNameExchange::new("SPY"),
6160            crate::order::id::StrategyId::new("test"),
6161            crate::order::id::ClientOrderId::new("test-sl-limit"),
6162            Side::Buy,
6163            dec!(1),
6164            dec!(100.00),
6165            dec!(120.00),
6166            dec!(90.00),
6167            TimeInForce::GoodUntilCancelled { post_only: false },
6168        )
6169        .with_stop_loss_limit_price(dec!(95.00)); // Invalid: limit > trigger for sell SL
6170
6171        let result = client.open_bracket_order(request).await;
6172
6173        assert!(
6174            result.parent.state.is_failed(),
6175            "Bracket order with invalid SL limit price should be rejected locally"
6176        );
6177    }
6178
6179    #[test]
6180    fn test_non_bracket_order_omits_bracket_fields() {
6181        // Regular limit order should not have bracket fields
6182        let body = AlpacaOrderRequest {
6183            symbol: "AAPL",
6184            qty: "1".to_string(),
6185            side: "buy",
6186            order_type: "limit",
6187            time_in_force: "gtc",
6188            limit_price: Some("150.00".to_string()),
6189            stop_price: None,
6190            trail_percent: None,
6191            trail_price: None,
6192            client_order_id: Some("regular-001"),
6193            position_intent: Some(AlpacaPositionIntent::BuyToOpen),
6194            order_class: None,
6195            take_profit: None,
6196            stop_loss: None,
6197        };
6198
6199        let json = serde_json::to_value(&body).unwrap();
6200
6201        assert_eq!(json["symbol"], "AAPL");
6202        assert!(
6203            json.get("order_class").is_none(),
6204            "order_class should be omitted for non-bracket orders"
6205        );
6206        assert!(
6207            json.get("take_profit").is_none(),
6208            "take_profit should be omitted for non-bracket orders"
6209        );
6210        assert!(
6211            json.get("stop_loss").is_none(),
6212            "stop_loss should be omitted for non-bracket orders"
6213        );
6214    }
6215
6216    #[test]
6217    fn test_parse_timestamp_valid() {
6218        let ts = parse_timestamp("2025-04-18T14:30:00Z");
6219        assert!(ts.is_some());
6220        let ts2 = parse_timestamp("2025-04-18T14:30:00.123456Z");
6221        assert!(ts2.is_some());
6222        assert_eq!(parse_timestamp("not-a-timestamp"), None);
6223    }
6224
6225    #[test]
6226    fn test_check_auth_response_authorized() {
6227        let msg =
6228            r#"{"stream":"authorization","data":{"status":"authorized","action":"authenticate"}}"#;
6229        assert!(matches!(check_auth_response(msg), Some(Ok(()))));
6230    }
6231
6232    #[test]
6233    fn test_check_auth_response_unauthorized() {
6234        let msg = r#"{"stream":"authorization","data":{"status":"unauthorized"}}"#;
6235        assert!(matches!(
6236            check_auth_response(msg),
6237            Some(Err(HandshakeError::Auth(_)))
6238        ));
6239    }
6240
6241    #[test]
6242    fn test_check_auth_response_non_auth_message() {
6243        let msg = r#"{"stream":"trade_updates","data":{}}"#;
6244        assert!(check_auth_response(msg).is_none());
6245    }
6246
6247    #[test]
6248    fn test_check_listen_ack() {
6249        let ack = r#"{"stream":"listening","data":{"streams":["trade_updates"]}}"#;
6250        assert!(check_listen_ack(ack));
6251
6252        let other = r#"{"stream":"authorization","data":{}}"#;
6253        assert!(!check_listen_ack(other));
6254    }
6255
6256    #[test]
6257    fn test_dedup_cache() {
6258        let cache = new_dedup_cache();
6259        let key = || SmolStr::new("order-1:1");
6260        assert!(
6261            !is_duplicate_fill(&cache, Some("exec-1"), key()),
6262            "first time should not be duplicate"
6263        );
6264        assert!(
6265            is_duplicate_fill(&cache, Some("exec-1"), key()),
6266            "second time should be duplicate"
6267        );
6268    }
6269
6270    /// A fill is told apart by its execution id, so after a bust lowers an order's cumulative, a
6271    /// new fill that takes it back to a cumulative an earlier fill reached is not dropped.
6272    #[test]
6273    fn dedup_tells_fills_apart_by_execution_id_not_cumulative() {
6274        let cache = new_dedup_cache();
6275        assert!(!is_duplicate_fill(
6276            &cache,
6277            Some("exec-a"),
6278            SmolStr::new("ord-1:5")
6279        ));
6280        // A bust took the order back to 3, and a new fill takes it to 5 again.
6281        assert!(
6282            !is_duplicate_fill(&cache, Some("exec-b"), SmolStr::new("ord-1:5")),
6283            "a different execution at a cumulative seen before is a new fill"
6284        );
6285    }
6286
6287    /// A fill one path delivers without an execution id still matches the same fill the other
6288    /// path delivers with one, whichever comes first.
6289    #[test]
6290    fn dedup_matches_a_fill_with_an_execution_id_to_the_same_fill_without_one() {
6291        let cache = new_dedup_cache();
6292
6293        assert!(!is_duplicate_fill(&cache, None, SmolStr::new("ord-1:2")));
6294        assert!(
6295            is_duplicate_fill(&cache, Some("exec-a"), SmolStr::new("ord-1:2")),
6296            "without an id first, then with one"
6297        );
6298
6299        assert!(!is_duplicate_fill(
6300            &cache,
6301            Some("exec-b"),
6302            SmolStr::new("ord-1:4")
6303        ));
6304        assert!(
6305            is_duplicate_fill(&cache, None, SmolStr::new("ord-1:4")),
6306            "with an id first, then without one"
6307        );
6308    }
6309
6310    /// `account_stream`'s rustdoc states the reconnect budget, its backoff and the heartbeat
6311    /// window as numbers. Change these only together with those docs.
6312    #[test]
6313    fn the_reconnect_budget_is_as_documented() {
6314        assert_eq!(MAX_RECONNECT_ATTEMPTS, 10);
6315        assert_eq!(INITIAL_BACKOFF_MS, 1_000);
6316        assert_eq!(MAX_BACKOFF_MS, 30_000);
6317        assert_eq!(HEARTBEAT_TIMEOUT_SECS, 35);
6318    }
6319
6320    #[tokio::test]
6321    async fn test_exponential_backoff_progression_and_exhaustion() {
6322        tokio::time::pause();
6323
6324        let mut b = ExponentialBackoff::new();
6325
6326        // First wait should succeed and increment attempt.
6327        assert!(b.wait().await, "first wait should return true");
6328        assert_eq!(b.attempt, 1);
6329
6330        // Drain remaining attempts.
6331        while b.wait().await {}
6332
6333        // Attempt counter saturates at max_attempts.
6334        assert_eq!(b.attempt, MAX_RECONNECT_ATTEMPTS);
6335
6336        // Once exhausted, wait returns false immediately without sleeping.
6337        assert!(!b.wait().await, "exhausted backoff should return false");
6338
6339        // reset() restores attempt to 0.
6340        b.reset();
6341        assert_eq!(b.attempt, 0);
6342
6343        // After reset, wait works again.
6344        assert!(b.wait().await, "wait should succeed after reset");
6345        assert_eq!(b.attempt, 1);
6346    }
6347
6348    #[test]
6349    fn test_convert_account_to_balances_empty_assets() {
6350        // equity and buying_power are larger than cash on a margin account holding positions;
6351        // neither may leak into the balance.
6352        let account: AlpacaAccount = serde_json::from_value(serde_json::json!({
6353            "cash": "9000.50",
6354            "equity": "12000.00",
6355            "buying_power": "36000.00",
6356            "regt_buying_power": "18000.00",
6357            "options_buying_power": "8000.00",
6358            "non_marginable_buying_power": "8500.25",
6359        }))
6360        .unwrap();
6361        let balances = convert_account_to_balances(&account, &[]);
6362        assert_eq!(balances.len(), 1);
6363        assert_eq!(balances[0].asset, AssetNameExchange::new("USD"));
6364        assert_eq!(balances[0].balance.total, dec!(9000.50), "total is cash");
6365        assert_eq!(
6366            balances[0].balance.free,
6367            dec!(8500.25),
6368            "free is non-marginable buying power when below cash"
6369        );
6370        assert_eq!(balances[0].balance.margin, None);
6371    }
6372
6373    /// Long marginable stock: non-marginable buying power is equity less initial margin, which
6374    /// exceeds cash, so `free` is capped at cash.
6375    #[test]
6376    fn test_convert_account_to_balances_free_capped_at_cash_with_long_stock() {
6377        let account: AlpacaAccount = serde_json::from_value(serde_json::json!({
6378            "cash": "50000",
6379            "equity": "100000",
6380            "initial_margin": "25000",
6381            "non_marginable_buying_power": "75000",
6382        }))
6383        .unwrap();
6384        let balances = convert_account_to_balances(&account, &[]);
6385        assert_eq!(balances[0].balance.total, dec!(50000));
6386        assert_eq!(balances[0].balance.free, dec!(50000));
6387    }
6388
6389    /// A short sale credits its proceeds to cash but lowers non-marginable buying power, so
6390    /// `free` is the buying power, below `total`.
6391    #[test]
6392    fn test_convert_account_to_balances_open_short_keeps_free_below_cash() {
6393        let account: AlpacaAccount = serde_json::from_value(serde_json::json!({
6394            "cash": "10200",
6395            "equity": "10000",
6396            "initial_margin": "100",
6397            "non_marginable_buying_power": "9900",
6398        }))
6399        .unwrap();
6400        let balances = convert_account_to_balances(&account, &[]);
6401        assert_eq!(balances[0].balance.total, dec!(10200));
6402        assert_eq!(balances[0].balance.free, dec!(9900));
6403    }
6404
6405    #[test]
6406    fn test_convert_account_to_balances_negative_cash_on_margin() {
6407        let account: AlpacaAccount = serde_json::from_value(serde_json::json!({
6408            "cash": "-2500.75",
6409            "equity": "7500.00",
6410            "buying_power": "5000.00",
6411            "non_marginable_buying_power": "1250.00",
6412        }))
6413        .unwrap();
6414        let balances = convert_account_to_balances(&account, &[]);
6415        assert_eq!(balances.len(), 1);
6416        assert_eq!(balances[0].balance.total, dec!(-2500.75));
6417        assert_eq!(
6418            balances[0].balance.free,
6419            dec!(-2500.75),
6420            "no free cash while borrowing; free <= total still holds"
6421        );
6422    }
6423
6424    #[test]
6425    fn test_alpaca_account_malformed_cash_fails_to_decode() {
6426        let result = serde_json::from_value::<AlpacaAccount>(serde_json::json!({
6427            "cash": "not-a-number",
6428            "non_marginable_buying_power": "0",
6429        }));
6430        assert!(result.is_err(), "a malformed cash must not read as zero");
6431    }
6432
6433    #[test]
6434    fn test_convert_account_to_balances_usd_filter() {
6435        let account = AlpacaAccount {
6436            cash: dec!(12000.00),
6437            non_marginable_buying_power: dec!(10000.00),
6438        };
6439        let usd = vec![AssetNameExchange::new("USD")];
6440        let balances = convert_account_to_balances(&account, &usd);
6441        assert_eq!(balances.len(), 1);
6442        assert_eq!(balances[0].asset, AssetNameExchange::new("USD"));
6443
6444        let non_usd = vec![AssetNameExchange::new("BTC")];
6445        let balances = convert_account_to_balances(&account, &non_usd);
6446        assert!(balances.is_empty());
6447    }
6448
6449    /// A caller's filter is matched ignoring case, but the balance keeps Alpaca's spelling, the
6450    /// one every USD fee is reported under too.
6451    #[test]
6452    fn a_usd_balance_is_named_usd_however_the_filter_spells_it() {
6453        let account = AlpacaAccount {
6454            cash: dec!(12000.00),
6455            non_marginable_buying_power: dec!(10000.00),
6456        };
6457        let balances = convert_account_to_balances(&account, &[AssetNameExchange::new("usd")]);
6458        assert_eq!(balances.len(), 1);
6459        assert_eq!(balances[0].asset, AssetNameExchange::new("USD"));
6460    }
6461
6462    #[test]
6463    fn test_is_options_or_equity_symbol() {
6464        // Crypto symbols contain '/'
6465        assert!(!is_options_or_equity_symbol("BTC/USD"));
6466        assert!(!is_options_or_equity_symbol("ETH/USD"));
6467        assert!(!is_options_or_equity_symbol("SOL/USD"));
6468
6469        // Equity symbols — no slash
6470        assert!(is_options_or_equity_symbol("AAPL"));
6471        assert!(is_options_or_equity_symbol("SPY"));
6472        assert!(is_options_or_equity_symbol("MSFT"));
6473
6474        // OCC option symbols — no slash
6475        assert!(is_options_or_equity_symbol("SPY250418C00450000"));
6476        assert!(is_options_or_equity_symbol("AAPL250418P00145000"));
6477    }
6478
6479    #[test]
6480    fn test_parse_order_error_already_cancelled() {
6481        // Locks in match arm ordering: a 422 with "already" but NOT "insufficient"
6482        // must map to OrderAlreadyCancelled, not BalanceInsufficient.
6483        assert!(matches!(
6484            parse_order_error(
6485                reqwest::StatusCode::UNPROCESSABLE_ENTITY,
6486                None,
6487                "order is already cancelled"
6488            ),
6489            UnindexedOrderError::Rejected(ApiError::OrderAlreadyCancelled)
6490        ));
6491    }
6492
6493    #[test]
6494    fn test_parse_order_error_already_wins_over_insufficient_on_422() {
6495        // If Alpaca sends a body containing both "already" and "insufficient",
6496        // the "already" arm must win (it appears first in the match).
6497        assert!(matches!(
6498            parse_order_error(
6499                reqwest::StatusCode::UNPROCESSABLE_ENTITY,
6500                None,
6501                "order already cancelled due to insufficient margin"
6502            ),
6503            UnindexedOrderError::Rejected(ApiError::OrderAlreadyCancelled)
6504        ));
6505    }
6506
6507    fn make_order_ws<'a>(
6508        id: &str,
6509        symbol: &str,
6510        side: &str,
6511        filled_qty: &'a str,
6512    ) -> AlpacaOrderWs<'a> {
6513        AlpacaOrderWs {
6514            id: SmolStr::new(id),
6515            client_order_id: None,
6516            symbol: SmolStr::new(symbol),
6517            qty: Some("2"),
6518            filled_qty: Some(filled_qty),
6519            side: SmolStr::new(side),
6520            order_type: SmolStr::new("limit"),
6521            time_in_force: SmolStr::new("day"),
6522            limit_price: Some("100.00"),
6523            stop_price: None,
6524            trail_percent: None,
6525            trail_price: None,
6526            status: SmolStr::new("partially_filled"),
6527        }
6528    }
6529
6530    /// The one event produced by a frame that maps to a single `AccountEvent`.
6531    ///
6532    /// Asserts the second slot is empty, so a test written for a single-event frame fails
6533    /// loudly if that frame ever starts producing two.
6534    fn sole_event(events: [Option<UnindexedAccountEvent>; 2]) -> Option<UnindexedAccountEvent> {
6535        let [first, second] = events;
6536        assert!(
6537            second.is_none(),
6538            "expected a single event, got a second: {second:?}"
6539        );
6540        first
6541    }
6542
6543    /// The `(Trade, OrderSnapshot)` pair a fill frame must produce, in that order.
6544    fn fill_events(
6545        events: [Option<UnindexedAccountEvent>; 2],
6546    ) -> (
6547        Trade<AssetNameExchange, InstrumentNameExchange>,
6548        crate::order::Order<
6549            ExchangeId,
6550            InstrumentNameExchange,
6551            OrderState<AssetNameExchange, InstrumentNameExchange>,
6552        >,
6553    ) {
6554        let [first, second] = events;
6555        let first = first.expect("a fill frame must produce an execution");
6556        let second = second.expect("a fill frame must produce an order snapshot");
6557        let AccountEventKind::Trade(trade) = first.kind else {
6558            panic!("first event must be the Trade, got {:?}", first.kind);
6559        };
6560        let AccountEventKind::OrderSnapshot(rustrade_integration::collection::snapshot::Snapshot(
6561            order,
6562        )) = second.kind
6563        else {
6564            panic!(
6565                "second event must be the OrderSnapshot, got {:?}",
6566                second.kind
6567            );
6568        };
6569        (trade, order)
6570    }
6571
6572    /// A fill carries the client order id its order was placed with, so a consumer can attribute
6573    /// it before any response names the order.
6574    #[test]
6575    fn test_convert_trade_update_fill_carries_the_client_order_id() {
6576        let mut order = make_order_ws("ord-1", "SPY", "buy", "1");
6577        order.client_order_id = Some(SmolStr::new("outside-1"));
6578        let update = AlpacaTradeUpdate {
6579            event: SmolStr::new("fill"),
6580            execution_id: Some("exec-1"),
6581            previous_execution_id: None,
6582            order,
6583            price: Some("150.00"),
6584            qty: Some("1"),
6585            timestamp: Some("2025-04-18T14:30:00Z"),
6586        };
6587        let (trade, _order) = fill_events(convert_trade_update(update));
6588        assert_eq!(trade.client_order_id, Some(ClientOrderId::new("outside-1")));
6589    }
6590
6591    #[test]
6592    fn test_convert_trade_update_fill_produces_trade_with_execution_id() {
6593        let update = AlpacaTradeUpdate {
6594            event: SmolStr::new("fill"),
6595            execution_id: Some("exec-1"),
6596            previous_execution_id: None,
6597            order: make_order_ws("ord-1", "SPY", "buy", "1"),
6598            price: Some("150.00"),
6599            qty: Some("1"),
6600            timestamp: Some("2025-04-18T14:30:00Z"),
6601        };
6602        let (trade, order) = fill_events(convert_trade_update(update));
6603        // The venue's execution id, which the fill's FILL activity carries too.
6604        assert_eq!(trade.id.0.as_str(), "exec-1");
6605        assert_eq!(
6606            trade.client_order_id, None,
6607            "the update names no client order id"
6608        );
6609        assert_eq!(trade.price, Decimal::from_str("150.00").unwrap());
6610        assert_eq!(trade.quantity, Decimal::from_str("1").unwrap());
6611
6612        // The execution print alone leaves the order reading as untouched. The snapshot carries
6613        // the cumulative filled quantity, which is the only way it reaches engine state.
6614        let OrderState::Active(crate::order::state::ActiveOrderState::Open(open)) = order.state
6615        else {
6616            panic!("expected an Open snapshot, got {:?}", order.state);
6617        };
6618        assert_eq!(open.filled_quantity, Decimal::from_str("1").unwrap());
6619        assert_eq!(open.id.assigned().map(|id| id.0.as_str()), Some("ord-1"));
6620    }
6621
6622    #[test]
6623    fn test_convert_trade_update_partial_fill() {
6624        let update = AlpacaTradeUpdate {
6625            event: SmolStr::new("partial_fill"),
6626            execution_id: None,
6627            previous_execution_id: None,
6628            order: make_order_ws("ord-2", "AAPL", "sell", "0.5"),
6629            price: Some("200.00"),
6630            qty: Some("0.5"),
6631            timestamp: None,
6632        };
6633        let (trade, order) = fill_events(convert_trade_update(update));
6634        assert_eq!(trade.quantity, Decimal::from_str("0.5").unwrap());
6635
6636        // make_order_ws reports qty=2, so 0.5 cumulative leaves 1.5 working: the order stays
6637        // Open, and a consumer reading quantity_remaining now sees 1.5 rather than 2.
6638        let OrderState::Active(crate::order::state::ActiveOrderState::Open(open)) = order.state
6639        else {
6640            panic!("expected an Open snapshot, got {:?}", order.state);
6641        };
6642        assert_eq!(open.filled_quantity, Decimal::from_str("0.5").unwrap());
6643        assert_eq!(
6644            open.quantity_remaining(order.quantity),
6645            Decimal::from_str("1.5").unwrap()
6646        );
6647    }
6648
6649    /// A `fill` that completes the order reports nothing left to fill, which is how the engine
6650    /// learns the order is done. Without the snapshot the order would sit in engine state as a
6651    /// resting order with `filled_quantity` 0 until REST reconciliation refreshed it.
6652    #[test]
6653    fn a_full_fill_snapshot_reports_nothing_left_to_fill() {
6654        let update = AlpacaTradeUpdate {
6655            event: SmolStr::new("fill"),
6656            execution_id: None,
6657            previous_execution_id: None,
6658            // make_order_ws reports qty=2; a cumulative filled of 2 completes it.
6659            order: AlpacaOrderWs {
6660                status: SmolStr::new("filled"),
6661                ..make_order_ws("ord-full", "SPY", "buy", "2")
6662            },
6663            price: Some("150.00"),
6664            qty: Some("1"),
6665            timestamp: Some("2025-04-18T14:30:00Z"),
6666        };
6667        let (_trade, order) = fill_events(convert_trade_update(update));
6668        let OrderState::Active(crate::order::state::ActiveOrderState::Open(open)) = order.state
6669        else {
6670            panic!("expected an Open snapshot, got {:?}", order.state);
6671        };
6672        assert_eq!(open.filled_quantity, Decimal::from_str("2").unwrap());
6673        assert!(
6674            open.quantity_remaining(order.quantity).is_zero(),
6675            "a completed order must report nothing left to fill so the engine retires it"
6676        );
6677    }
6678
6679    /// A fill whose order the exchange no longer reports as working must not be written back into
6680    /// engine state as an `Open` order. The execution still counts -- it moved the position --
6681    /// but resurrecting the order would leave a resting order that does not exist at the venue.
6682    #[test]
6683    fn a_fill_for_an_order_no_longer_working_emits_the_execution_without_a_snapshot() {
6684        for status in [
6685            "canceled",
6686            "expired",
6687            "rejected",
6688            "done_for_day",
6689            "pending_cancel",
6690        ] {
6691            let update = AlpacaTradeUpdate {
6692                event: SmolStr::new("partial_fill"),
6693                execution_id: None,
6694                previous_execution_id: None,
6695                order: AlpacaOrderWs {
6696                    status: SmolStr::new(status),
6697                    ..make_order_ws("ord-late", "SPY", "buy", "1")
6698                },
6699                price: Some("150.00"),
6700                qty: Some("1"),
6701                timestamp: None,
6702            };
6703            let [trade, snapshot] = convert_trade_update(update);
6704            assert!(
6705                matches!(trade.map(|e| e.kind), Some(AccountEventKind::Trade(_))),
6706                "the execution must still be reported for status {status}"
6707            );
6708            assert!(
6709                snapshot.is_none(),
6710                "status {status} must not produce an order snapshot"
6711            );
6712        }
6713    }
6714
6715    /// A notional order (placed by dollar value) carries no `qty`, so there is no quantity to
6716    /// report a remaining amount against. The execution is still reported; the order is not.
6717    #[test]
6718    fn a_notional_order_fill_emits_the_execution_without_a_snapshot() {
6719        let update = AlpacaTradeUpdate {
6720            event: SmolStr::new("partial_fill"),
6721            execution_id: None,
6722            previous_execution_id: None,
6723            order: AlpacaOrderWs {
6724                qty: None,
6725                ..make_order_ws("ord-notional-ws", "SPY", "buy", "1")
6726            },
6727            price: Some("150.00"),
6728            qty: Some("1"),
6729            timestamp: None,
6730        };
6731        let [trade, snapshot] = convert_trade_update(update);
6732        assert!(matches!(
6733            trade.map(|e| e.kind),
6734            Some(AccountEventKind::Trade(_))
6735        ));
6736        assert!(
6737            snapshot.is_none(),
6738            "a notional order has no quantity to snapshot against"
6739        );
6740    }
6741
6742    #[test]
6743    fn test_convert_trade_update_new_order_produces_snapshot() {
6744        let update = AlpacaTradeUpdate {
6745            event: SmolStr::new("new"),
6746            execution_id: None,
6747            previous_execution_id: None,
6748            order: AlpacaOrderWs {
6749                id: SmolStr::new("ord-new"),
6750                client_order_id: Some(SmolStr::new("cid-1")),
6751                symbol: SmolStr::new("AAPL"),
6752                qty: Some("10"),
6753                filled_qty: Some("0"),
6754                side: SmolStr::new("buy"),
6755                order_type: SmolStr::new("limit"),
6756                time_in_force: SmolStr::new("day"),
6757                limit_price: Some("150.00"),
6758                stop_price: None,
6759                trail_percent: None,
6760                trail_price: None,
6761                status: SmolStr::new("new"),
6762            },
6763            price: None,
6764            qty: None,
6765            timestamp: Some("2025-04-18T14:30:00Z"),
6766        };
6767        let event =
6768            sole_event(convert_trade_update(update)).expect("new event should produce an event");
6769        assert!(matches!(event.kind, AccountEventKind::OrderSnapshot(_)));
6770    }
6771
6772    #[test]
6773    fn test_convert_trade_update_canceled_produces_cancel() {
6774        let update = AlpacaTradeUpdate {
6775            event: SmolStr::new("canceled"),
6776            execution_id: None,
6777            previous_execution_id: None,
6778            order: make_order_ws("ord-3", "AAPL", "sell", "0"),
6779            price: None,
6780            qty: None,
6781            timestamp: Some("2025-04-18T14:30:00Z"),
6782        };
6783        let event =
6784            sole_event(convert_trade_update(update)).expect("canceled should produce an event");
6785        let AccountEventKind::OrderCancelled(response) = event.kind else {
6786            panic!("expected OrderCancelled, got {:?}", event.kind);
6787        };
6788        assert!(response.state.is_ok());
6789    }
6790
6791    /// A `canceled` update carries what filled, and an unknown fill stays unknown, not zero.
6792    #[test]
6793    fn a_canceled_update_carries_its_fill_or_none() {
6794        let cancelled = |filled_qty: Option<&'static str>| {
6795            let mut order = make_order_ws("ord-3", "AAPL", "sell", "0");
6796            order.filled_qty = filled_qty;
6797            let update = AlpacaTradeUpdate {
6798                event: SmolStr::new("canceled"),
6799                execution_id: None,
6800                previous_execution_id: None,
6801                order,
6802                price: None,
6803                qty: None,
6804                timestamp: Some("2025-04-18T14:30:00Z"),
6805            };
6806            let event =
6807                sole_event(convert_trade_update(update)).expect("canceled should produce an event");
6808            let AccountEventKind::OrderCancelled(response) = event.kind else {
6809                panic!("expected OrderCancelled, got {:?}", event.kind);
6810            };
6811            let Ok(cancelled) = response.state else {
6812                panic!("expected Ok, got {:?}", response.state);
6813            };
6814            cancelled.filled_quantity
6815        };
6816
6817        assert_eq!(cancelled(Some("1")), Some(Decimal::ONE));
6818        assert_eq!(cancelled(Some("0")), Some(Decimal::ZERO));
6819        assert_eq!(cancelled(None), None);
6820        assert_eq!(cancelled(Some("not a number")), None);
6821    }
6822
6823    /// An ended order whose `filled_qty` does not parse reports its fill unknown, not zero; a
6824    /// filled one filled its whole quantity regardless.
6825    #[test]
6826    fn an_ended_order_with_an_unparseable_fill_reports_it_unknown() {
6827        let key = OrderKey::new(
6828            ExchangeId::AlpacaBroker,
6829            InstrumentNameExchange::new("AAPL"),
6830            StrategyId::new("strategy"),
6831            ClientOrderId::new("c"),
6832        );
6833        let ended = |status: &str| {
6834            let mut order = make_order_response("o1", "AAPL");
6835            order.status = Some(status.to_string());
6836            order.filled_qty = "not a number".to_string();
6837            convert_ended_order(&order, &key).map(|order| order.state)
6838        };
6839
6840        assert!(matches!(
6841            ended("canceled"),
6842            Some(InactiveOrderState::Cancelled(Cancelled {
6843                filled_quantity: None,
6844                ..
6845            }))
6846        ));
6847        assert!(matches!(
6848            ended("expired"),
6849            Some(InactiveOrderState::Expired(Expired {
6850                filled_quantity: None,
6851                ..
6852            }))
6853        ));
6854        assert!(matches!(
6855            ended("filled"),
6856            Some(InactiveOrderState::FullyFilled(Filled { filled_quantity, .. }))
6857                if filled_quantity == Decimal::ONE
6858        ));
6859    }
6860
6861    #[test]
6862    fn test_convert_trade_update_rejected_produces_error() {
6863        let update = AlpacaTradeUpdate {
6864            event: SmolStr::new("rejected"),
6865            execution_id: None,
6866            previous_execution_id: None,
6867            order: make_order_ws("ord-4", "SPY", "buy", "0"),
6868            price: None,
6869            qty: None,
6870            timestamp: None,
6871        };
6872        let event =
6873            sole_event(convert_trade_update(update)).expect("rejected should produce an event");
6874        let AccountEventKind::OrderCancelled(response) = event.kind else {
6875            panic!("expected OrderCancelled, got {:?}", event.kind);
6876        };
6877        assert!(response.state.is_err());
6878    }
6879
6880    #[test]
6881    fn test_convert_open_order_notional_qty_none_is_skipped() {
6882        // qty=None means this is a notional order (placed by dollar value).
6883        // Recording it with quantity=0 would corrupt reconciliation, so it must be skipped.
6884        let order = AlpacaOrderResponse {
6885            id: "ord-notional".to_string(),
6886            client_order_id: None,
6887            symbol: "SPY".to_string(),
6888            qty: None,
6889            filled_qty: "0".to_string(),
6890            filled_avg_price: None,
6891            status: None,
6892            side: "buy".to_string(),
6893            order_type: "market".to_string(),
6894            time_in_force: "day".to_string(),
6895            limit_price: None,
6896            stop_price: None,
6897            trail_percent: None,
6898            trail_price: None,
6899            created_at: "2025-04-18T14:30:00Z".to_string(),
6900            updated_at: None,
6901        };
6902        assert!(convert_open_order(&order).is_none());
6903    }
6904
6905    #[test]
6906    fn test_convert_activity_to_trade_bad_price_returns_none() {
6907        // When price is unparseable, convert_activity_to_trade must return None.
6908        // recover_fills advances cumulative_qty BEFORE this check so that the dedup
6909        // key sequence stays aligned with the WS path even when a fill is skipped.
6910        let activity = AlpacaActivity {
6911            id: "act-1".to_string(),
6912            order_id: "ord-1".to_string(),
6913            symbol: "SPY250418C00450000".to_string(),
6914            side: "buy".to_string(),
6915            price: "not-a-number".to_string(),
6916            qty: "1".to_string(),
6917            transaction_time: "2025-04-18T14:30:00Z".to_string(),
6918            cum_qty: Some("1".to_string()),
6919        };
6920        assert!(convert_activity_to_trade(&activity).is_none());
6921    }
6922
6923    #[test]
6924    fn test_convert_positions_to_balances_crypto() {
6925        let positions = vec![
6926            // The positions list's form: the asset followed by USD.
6927            AlpacaPosition {
6928                qty_available: dec!(0.4),
6929                ..alpaca_position("BTCUSD", "crypto", AlpacaPositionSide::Long, dec!(0.5))
6930            },
6931            // The pair form orders use, read too.
6932            alpaca_position("ETH/USD", "crypto", AlpacaPositionSide::Long, dec!(2.0)),
6933            // Equity positions should be filtered out
6934            alpaca_position("AAPL", "us_equity", AlpacaPositionSide::Long, dec!(10)),
6935        ];
6936
6937        // All crypto assets
6938        let balances = convert_positions_to_balances(&positions, &[]);
6939        assert_eq!(balances.len(), 2, "only crypto positions returned");
6940        assert_eq!(balances[0].asset.name().as_str(), "BTC");
6941        assert_eq!(balances[1].asset.name().as_str(), "ETH");
6942        // total = qty (0.5 BTC), free = qty_available (0.4 BTC)
6943        assert_eq!(balances[0].balance.total, dec!(0.5));
6944        assert_eq!(balances[0].balance.free, dec!(0.4));
6945
6946        // Filter to BTC only: matches the `BTCUSD` position
6947        let btc_only = vec![AssetNameExchange::new("btc")];
6948        let balances = convert_positions_to_balances(&positions, &btc_only);
6949        assert_eq!(balances.len(), 1);
6950        assert_eq!(balances[0].asset.name().as_str(), "BTC");
6951    }
6952
6953    #[test]
6954    fn test_crypto_position_asset() {
6955        assert_eq!(crypto_position_asset("BTCUSD"), "BTC");
6956        assert_eq!(
6957            crypto_position_asset("btcusd"),
6958            "btc",
6959            "suffix matched ignoring case"
6960        );
6961        assert_eq!(
6962            crypto_position_asset("USDTUSD"),
6963            "USDT",
6964            "only one USD is stripped"
6965        );
6966        assert_eq!(crypto_position_asset("BTC/USD"), "BTC");
6967        assert_eq!(crypto_position_asset("BTC/USDT"), "BTC");
6968        assert_eq!(crypto_position_asset("€USD"), "€");
6969        // Neither form, or an empty asset: used whole rather than dropped.
6970        assert_eq!(crypto_position_asset("USD"), "USD");
6971        assert_eq!(crypto_position_asset("/USD"), "/USD");
6972        assert_eq!(crypto_position_asset("BTCUSDT"), "BTCUSDT");
6973        assert_eq!(
6974            crypto_position_asset("BTCێ"),
6975            "BTCێ",
6976            "no char boundary at the suffix"
6977        );
6978        assert_eq!(crypto_position_asset(""), "");
6979    }
6980
6981    /// A position as `/v2/positions` reports it, with `qty_available` equal to `qty`.
6982    fn alpaca_position(
6983        symbol: &str,
6984        asset_class: &str,
6985        side: AlpacaPositionSide,
6986        qty: Decimal,
6987    ) -> AlpacaPosition {
6988        AlpacaPosition {
6989            symbol: symbol.into(),
6990            asset_class: asset_class.into(),
6991            side,
6992            qty,
6993            qty_available: qty,
6994            avg_entry_price: Some(dec!(100)),
6995            unrealized_pl: Some(dec!(0)),
6996        }
6997    }
6998
6999    #[test]
7000    fn test_convert_positions_sign_comes_from_side_not_qty() {
7001        let now = Utc::now();
7002        let positions = vec![
7003            alpaca_position("AAPL", "us_equity", AlpacaPositionSide::Long, dec!(10)),
7004            alpaca_position("TSLA", "us_equity", AlpacaPositionSide::Short, dec!(-3)),
7005            // Short with an unsigned qty still reads as short.
7006            alpaca_position("MSFT", "us_equity", AlpacaPositionSide::Short, dec!(4)),
7007        ];
7008
7009        let converted = convert_positions(&positions, now).unwrap();
7010        let quantities: Vec<_> = converted.iter().map(|(s, p)| (*s, p.quantity)).collect();
7011        assert_eq!(
7012            quantities,
7013            vec![("AAPL", dec!(10)), ("TSLA", dec!(-3)), ("MSFT", dec!(-4))]
7014        );
7015        assert!(converted.iter().all(|(_, p)| p.time_exchange == now));
7016    }
7017
7018    #[test]
7019    fn test_convert_positions_leaves_out_crypto_and_flat() {
7020        let positions = vec![
7021            alpaca_position("BTC/USD", "crypto", AlpacaPositionSide::Long, dec!(0.5)),
7022            alpaca_position("AAPL", "us_equity", AlpacaPositionSide::Long, dec!(0)),
7023            alpaca_position("SPY", "us_equity", AlpacaPositionSide::Long, dec!(1)),
7024        ];
7025
7026        let converted = convert_positions(&positions, Utc::now()).unwrap();
7027        let symbols: Vec<_> = converted.iter().map(|(s, _)| *s).collect();
7028        assert_eq!(symbols, vec!["SPY"]);
7029    }
7030
7031    #[test]
7032    fn test_alpaca_position_decodes_equity_option_and_fractional() {
7033        let positions: Vec<AlpacaPosition> = serde_json::from_value(serde_json::json!([
7034            {
7035                "asset_id": "00000000-0000-0000-0000-000000000001",
7036                "symbol": "XYZ",
7037                "exchange": "NASDAQ",
7038                "asset_class": "us_equity",
7039                "qty": "-7",
7040                "qty_available": "-7",
7041                "side": "short",
7042                "avg_entry_price": "50.25",
7043                "market_value": "-351.75",
7044                "unrealized_pl": "0",
7045            },
7046            {
7047                "symbol": "XYZ271217C00055000",
7048                "asset_class": "us_option",
7049                "qty": "2",
7050                "qty_available": "2",
7051                "side": "long",
7052                "avg_entry_price": "1.35",
7053                "unrealized_pl": "-20",
7054            },
7055            {
7056                "symbol": "ABC",
7057                "asset_class": "us_equity",
7058                "qty": "0.125",
7059                "qty_available": "0.125",
7060                "side": "long",
7061                "avg_entry_price": "80",
7062                "unrealized_pl": null,
7063            },
7064        ]))
7065        .unwrap();
7066
7067        let converted = convert_positions(&positions, Utc::now()).unwrap();
7068        let [
7069            (short_sym, short),
7070            (option_sym, option),
7071            (fraction_sym, fraction),
7072        ] = converted.as_slice()
7073        else {
7074            panic!("expected three positions, got {converted:?}");
7075        };
7076
7077        assert_eq!(*short_sym, "XYZ");
7078        assert_eq!(short.quantity, dec!(-7));
7079        assert_eq!(short.entry_price, Some(dec!(50.25)));
7080        assert!(short.is_short());
7081
7082        assert_eq!(*option_sym, "XYZ271217C00055000");
7083        assert_eq!(option.quantity, dec!(2));
7084        assert_eq!(option.entry_price, Some(dec!(1.35)), "per-share premium");
7085        assert_eq!(option.unrealized_pnl, Some(dec!(-20)));
7086
7087        assert_eq!(*fraction_sym, "ABC");
7088        assert_eq!(fraction.quantity, dec!(0.125));
7089        assert_eq!(fraction.unrealized_pnl, None);
7090
7091        for (_, p) in &converted {
7092            assert_eq!(p.margin_used, None);
7093            assert_eq!(p.liquidation_price, None);
7094            assert_eq!(p.leverage, None);
7095        }
7096    }
7097
7098    /// An unknown or missing side decodes, so the crypto balances in the same response survive,
7099    /// but a position that needs a direction fails rather than guessing one or reading as flat.
7100    #[test]
7101    fn test_alpaca_position_unknown_side_fails_only_where_a_direction_is_needed() {
7102        let positions: Vec<AlpacaPosition> = serde_json::from_value(serde_json::json!([
7103            {
7104                "symbol": "BTC/USD",
7105                "asset_class": "crypto",
7106                "qty": "0.5",
7107                "qty_available": "0.5",
7108            },
7109            {
7110                "symbol": "XYZ",
7111                "asset_class": "us_equity",
7112                "qty": "1",
7113                "qty_available": "1",
7114                "side": "sideways",
7115            },
7116        ]))
7117        .unwrap();
7118        assert_eq!(positions[0].side, AlpacaPositionSide::Unknown);
7119        assert_eq!(positions[1].side, AlpacaPositionSide::Unknown);
7120
7121        let balances = convert_positions_to_balances(&positions, &[]);
7122        assert_eq!(balances.len(), 1);
7123        assert_eq!(balances[0].balance.total, dec!(0.5));
7124
7125        let result = convert_positions(&positions, Utc::now());
7126        assert!(
7127            matches!(&result, Err(UnindexedClientError::Internal(msg)) if msg.contains("XYZ")),
7128            "an unknown side must not guess a direction: {result:?}"
7129        );
7130    }
7131
7132    /// `updated_at` orders an order's states; `created_at` is constant across all of them.
7133    #[test]
7134    fn order_state_time_prefers_updated_at() {
7135        let mut resp = make_order_response("ord-1", "SPY");
7136        resp.created_at = "2025-04-18T14:30:00Z".to_string();
7137        resp.updated_at = Some("2025-04-18T15:45:00Z".to_string());
7138        assert_eq!(
7139            order_state_time(&resp),
7140            parse_timestamp("2025-04-18T15:45:00Z").unwrap(),
7141            "updated_at must win over created_at"
7142        );
7143
7144        // Alpaca's schema makes updated_at nullable, so creation time is the fallback.
7145        resp.updated_at = None;
7146        assert_eq!(
7147            order_state_time(&resp),
7148            parse_timestamp("2025-04-18T14:30:00Z").unwrap(),
7149            "absent updated_at falls back to created_at"
7150        );
7151
7152        // An unparseable updated_at must not silently become `now`, which would order this state
7153        // ahead of every later one.
7154        resp.updated_at = Some("not-a-timestamp".to_string());
7155        assert_eq!(
7156            order_state_time(&resp),
7157            parse_timestamp("2025-04-18T14:30:00Z").unwrap(),
7158            "unparseable updated_at falls back to created_at"
7159        );
7160    }
7161
7162    /// A placement response without a status reads from what filled, as one with a live status
7163    /// does.
7164    #[test]
7165    fn a_placement_response_without_a_status_reads_from_what_filled() {
7166        let instrument = InstrumentNameExchange::new("SPY");
7167        let mut resp = make_order_response("ord-1", "SPY");
7168        resp.filled_qty = "0.4".to_string();
7169        let OrderState::Active(ActiveOrderState::Open(open)) =
7170            placed_order_state(&resp, &instrument, Decimal::ONE)
7171        else {
7172            panic!("expected Open");
7173        };
7174        assert_eq!(open.filled_quantity, Decimal::new(4, 1));
7175
7176        resp.filled_qty = "1".to_string();
7177        resp.filled_avg_price = Some("100.5".to_string());
7178        let state = placed_order_state(&resp, &instrument, Decimal::ONE);
7179        let OrderState::Inactive(InactiveOrderState::FullyFilled(filled)) = state else {
7180            panic!("expected FullyFilled, got {state:?}");
7181        };
7182        assert_eq!(filled.filled_quantity, Decimal::ONE);
7183        assert_eq!(filled.avg_price, Some(Decimal::new(1005, 1)));
7184    }
7185
7186    fn make_order_response(id: &str, symbol: &str) -> AlpacaOrderResponse {
7187        AlpacaOrderResponse {
7188            id: id.to_string(),
7189            client_order_id: None,
7190            symbol: symbol.to_string(),
7191            qty: Some("1".to_string()),
7192            filled_qty: "0".to_string(),
7193            filled_avg_price: None,
7194            status: None,
7195            side: "buy".to_string(),
7196            order_type: "limit".to_string(),
7197            time_in_force: "day".to_string(),
7198            limit_price: Some("100.00".to_string()),
7199            stop_price: None,
7200            trail_percent: None,
7201            trail_price: None,
7202            created_at: "2025-04-18T14:30:00Z".to_string(),
7203            updated_at: None,
7204        }
7205    }
7206
7207    #[test]
7208    fn test_build_instrument_snapshots_empty_instruments_returns_only_with_orders() {
7209        let orders = vec![
7210            make_order_response("o1", "AAPL"),
7211            make_order_response("o2", "SPY"),
7212        ];
7213        let snapshots = build_instrument_snapshots(orders, Vec::new(), &[]);
7214        assert_eq!(snapshots.len(), 2);
7215        let symbols: Vec<&str> = snapshots
7216            .iter()
7217            .map(|s| s.instrument.name().as_str())
7218            .collect();
7219        assert!(symbols.contains(&"AAPL"));
7220        assert!(symbols.contains(&"SPY"));
7221    }
7222
7223    #[test]
7224    fn test_build_instrument_snapshots_requested_instrument_no_orders_gets_empty_snapshot() {
7225        // When instruments list is provided, every requested instrument must appear
7226        // even if it has no open orders — callers depend on this for reconciliation.
7227        let orders = vec![make_order_response("o1", "AAPL")];
7228        let instruments = vec![
7229            InstrumentNameExchange::new("AAPL"),
7230            InstrumentNameExchange::new("SPY"),
7231        ];
7232        let snapshots = build_instrument_snapshots(orders, Vec::new(), &instruments);
7233        assert_eq!(snapshots.len(), 2);
7234        let spy = snapshots
7235            .iter()
7236            .find(|s| s.instrument.name().as_str() == "SPY")
7237            .expect("SPY snapshot must be present even with no orders");
7238        assert!(spy.orders.is_empty());
7239    }
7240
7241    /// A notional order: placed by dollar value, so Alpaca reports no `qty`.
7242    fn make_notional_order_response(id: &str, symbol: &str) -> AlpacaOrderResponse {
7243        AlpacaOrderResponse {
7244            qty: None,
7245            ..make_order_response(id, symbol)
7246        }
7247    }
7248
7249    fn snapshot_for<'a>(
7250        snapshots: &'a [InstrumentAccountSnapshot<
7251            ExchangeId,
7252            AssetNameExchange,
7253            InstrumentNameExchange,
7254        >],
7255        symbol: &str,
7256    ) -> &'a InstrumentAccountSnapshot<ExchangeId, AssetNameExchange, InstrumentNameExchange> {
7257        snapshots
7258            .iter()
7259            .find(|snapshot| snapshot.instrument.name().as_str() == symbol)
7260            .unwrap_or_else(|| panic!("no snapshot for {symbol}"))
7261    }
7262
7263    /// Every open order converted, so each list is every order open on the venue, including a
7264    /// requested instrument with none: that entry is what lets the engine see an order that ended
7265    /// while the stream was down is gone.
7266    #[test]
7267    fn test_build_instrument_snapshots_declares_complete_lists_complete() {
7268        let instruments = [
7269            InstrumentNameExchange::new("AAPL"),
7270            InstrumentNameExchange::new("SPY"),
7271        ];
7272        for requested in [&instruments[..], &[]] {
7273            let orders = vec![make_order_response("o1", "AAPL")];
7274            let snapshots = build_instrument_snapshots(orders, Vec::new(), requested);
7275            assert!(!snapshots.is_empty());
7276            assert!(snapshots.iter().all(|snapshot| snapshot.orders_complete));
7277        }
7278    }
7279
7280    /// A notional order cannot be represented, so its instrument's list is not every open order
7281    /// and must not say so: the engine would retire that order as absent. Other instruments are
7282    /// unaffected.
7283    #[test]
7284    fn test_build_instrument_snapshots_notional_order_leaves_only_its_instrument_incomplete() {
7285        let orders = vec![
7286            make_order_response("o1", "AAPL"),
7287            make_order_response("o2", "SPY"),
7288            make_notional_order_response("o3", "SPY"),
7289        ];
7290        let instruments = [
7291            InstrumentNameExchange::new("AAPL"),
7292            InstrumentNameExchange::new("SPY"),
7293        ];
7294        let snapshots = build_instrument_snapshots(orders, Vec::new(), &instruments);
7295
7296        assert!(snapshot_for(&snapshots, "AAPL").orders_complete);
7297        let spy = snapshot_for(&snapshots, "SPY");
7298        assert!(!spy.orders_complete);
7299        assert_eq!(spy.orders.len(), 1);
7300    }
7301
7302    /// Unfiltered, an instrument whose only open order cannot be represented still gets an entry,
7303    /// one that says its list is not complete.
7304    #[test]
7305    fn test_build_instrument_snapshots_unfiltered_lists_an_instrument_with_only_a_notional_order() {
7306        let orders = vec![make_notional_order_response("o1", "SPY")];
7307        let snapshots = build_instrument_snapshots(orders, Vec::new(), &[]);
7308
7309        let spy = snapshot_for(&snapshots, "SPY");
7310        assert!(!spy.orders_complete);
7311        assert!(spy.orders.is_empty());
7312    }
7313
7314    #[test]
7315    fn test_build_instrument_snapshots_non_requested_instrument_excluded() {
7316        // An instrument with open orders that is NOT in the requested list must
7317        // not appear in the output when the instruments list is non-empty.
7318        let orders = vec![
7319            make_order_response("o1", "AAPL"),
7320            make_order_response("o2", "MSFT"), // not requested
7321        ];
7322        let instruments = vec![InstrumentNameExchange::new("AAPL")];
7323        let snapshots = build_instrument_snapshots(orders, Vec::new(), &instruments);
7324        assert_eq!(snapshots.len(), 1);
7325        assert_eq!(snapshots[0].instrument.name().as_str(), "AAPL");
7326    }
7327
7328    fn position(quantity: Decimal) -> Position {
7329        Position::new(
7330            quantity,
7331            Some(dec!(100)),
7332            None,
7333            None,
7334            None,
7335            None,
7336            None,
7337            Utc::now(),
7338        )
7339    }
7340
7341    #[test]
7342    fn test_build_instrument_snapshots_unfiltered_lists_positions_without_orders() {
7343        let orders = vec![
7344            make_order_response("o1", "AAPL"),
7345            make_order_response("o2", "MSFT"),
7346            make_order_response("o3", "BTC/USD"),
7347        ];
7348        let positions = vec![("AAPL", position(dec!(5))), ("TSLA", position(dec!(-2)))];
7349
7350        let snapshots = build_instrument_snapshots(orders, positions, &[]);
7351        let listed: Vec<_> = snapshots
7352            .iter()
7353            .map(|s| {
7354                (
7355                    s.instrument.name().as_str(),
7356                    s.orders.len(),
7357                    s.position.quantity(),
7358                )
7359            })
7360            .collect();
7361        // An equity with an order but no position is flat; crypto is never a position.
7362        assert_eq!(
7363            listed,
7364            vec![
7365                ("AAPL", 1, Some(dec!(5))),
7366                ("MSFT", 1, Some(Decimal::ZERO)),
7367                ("BTC/USD", 1, None),
7368                ("TSLA", 0, Some(dec!(-2)))
7369            ]
7370        );
7371        assert!(snapshots.iter().all(|s| s.orders_complete));
7372    }
7373
7374    #[test]
7375    fn test_build_instrument_snapshots_filtered_attaches_only_requested_positions() {
7376        let positions = vec![("AAPL", position(dec!(5))), ("TSLA", position(dec!(-2)))];
7377        let instruments = vec![
7378            InstrumentNameExchange::new("tsla"),
7379            InstrumentNameExchange::new("MSFT"),
7380            InstrumentNameExchange::new("BTC/USD"),
7381            InstrumentNameExchange::new("TSLA"),
7382        ];
7383
7384        let snapshots = build_instrument_snapshots(Vec::new(), positions, &instruments);
7385        let listed: Vec<_> = snapshots
7386            .iter()
7387            .map(|s| (s.instrument.name().as_str(), s.position.quantity()))
7388            .collect();
7389        // A requested equity with no position is reported flat, and one requested in another case
7390        // still finds its position; crypto is never a position. A second name for a symbol
7391        // already claimed is unknown, not flat.
7392        assert_eq!(
7393            listed,
7394            vec![
7395                ("tsla", Some(dec!(-2))),
7396                ("MSFT", Some(Decimal::ZERO)),
7397                ("BTC/USD", None),
7398                ("TSLA", None)
7399            ]
7400        );
7401        assert!(!snapshots[3].orders_complete);
7402    }
7403
7404    /// Verifies that the dedup key synthesised by `recover_fills` (REST path) matches
7405    /// the key produced by `convert_trade_update` (WS path) for the same partial fills.
7406    ///
7407    /// This is the critical invariant for cross-source dedup after a reconnect:
7408    /// both paths must produce `"{order_id}:{cumulative_filled_qty}"` for the same fill.
7409    #[test]
7410    fn test_recover_fills_dedup_key_matches_ws_path() {
7411        let order_id = "ord-1";
7412
7413        // WS path: Alpaca sends cumulative filled_qty with each event.
7414        // Two partial fills of 1 lot each → filled_qty "1" then "2".
7415        let ws_keys: Vec<SmolStr> = ["1", "2"]
7416            .iter()
7417            .map(|filled_qty| {
7418                early_dedup_key(&AlpacaTradeUpdate {
7419                    event: SmolStr::new("partial_fill"),
7420                    execution_id: None,
7421                    previous_execution_id: None,
7422                    order: make_order_ws(order_id, "SPY", "buy", filled_qty),
7423                    price: Some("150.00"),
7424                    qty: Some("1"),
7425                    timestamp: None,
7426                })
7427            })
7428            .collect();
7429
7430        // REST path: recover_fills accumulates cumulative qty from per-execution activities.
7431        // Two activities with exec qty "1" each → cumulative 1 then 2.
7432        let mut cumulative = Decimal::ZERO;
7433        let rest_keys: Vec<SmolStr> = ["1", "1"]
7434            .iter()
7435            .map(|exec_qty| {
7436                cumulative += Decimal::from_str(exec_qty).unwrap();
7437                fill_dedup_key(order_id, cumulative)
7438            })
7439            .collect();
7440
7441        assert_eq!(
7442            ws_keys, rest_keys,
7443            "REST recovery dedup keys must match WS path keys for cross-source dedup to work"
7444        );
7445        assert_eq!(ws_keys[0].as_str(), "ord-1:1");
7446        assert_eq!(ws_keys[1].as_str(), "ord-1:2");
7447    }
7448
7449    /// Verifies that `early_dedup_key` produces the same key as the full event path.
7450    ///
7451    /// A fill without an `execution_id` falls back to its dedup key for its `TradeId`, the key the
7452    /// early dedup check reads from the raw WS fields before the event is built.
7453    #[test]
7454    fn a_ws_fill_without_an_execution_id_is_identified_by_its_dedup_key() {
7455        let update = AlpacaTradeUpdate {
7456            event: SmolStr::new("fill"),
7457            execution_id: None,
7458            previous_execution_id: None,
7459            order: make_order_ws("ord-abc", "SPY", "buy", "5"),
7460            price: Some("150.00"),
7461            qty: Some("5"),
7462            timestamp: None,
7463        };
7464
7465        // Early path: extract key before full event construction
7466        let early_key = early_dedup_key(&update);
7467
7468        // A fill frame also carries the order snapshot; the execution is always the first slot.
7469        let [event, _snapshot] = convert_trade_update(update);
7470        let Some(UnindexedAccountEvent {
7471            kind: AccountEventKind::Trade(trade),
7472            ..
7473        }) = event
7474        else {
7475            panic!("fill should produce an execution");
7476        };
7477
7478        assert_eq!(
7479            trade.id.0.as_str(),
7480            early_key.as_str(),
7481            "without an execution_id the TradeId is the dedup key"
7482        );
7483        assert_eq!(early_key.as_str(), "ord-abc:5");
7484    }
7485
7486    /// Verifies that `early_dedup_key` correctly normalizes decimal strings.
7487    ///
7488    /// Alpaca may send "1.00" or "1" for the same fill. Both must produce the same
7489    /// dedup key to avoid false negatives in duplicate detection.
7490    #[test]
7491    fn early_dedup_key_normalizes_decimal_strings() {
7492        // Test with trailing zeros: "1.00" should normalize to "1"
7493        let update1 = AlpacaTradeUpdate {
7494            event: SmolStr::new("fill"),
7495            execution_id: None,
7496            previous_execution_id: None,
7497            order: AlpacaOrderWs {
7498                id: SmolStr::new("ord-x"),
7499                client_order_id: Some(SmolStr::new("cid")),
7500                symbol: SmolStr::new("AAPL"),
7501                qty: Some("10"),
7502                filled_qty: Some("1.00"),
7503                side: SmolStr::new("buy"),
7504                order_type: SmolStr::new("market"),
7505                time_in_force: SmolStr::new("day"),
7506                limit_price: None,
7507                stop_price: None,
7508                trail_percent: None,
7509                trail_price: None,
7510                status: SmolStr::new("filled"),
7511            },
7512            price: Some("100.00"),
7513            qty: Some("10"),
7514            timestamp: None,
7515        };
7516        assert_eq!(early_dedup_key(&update1).as_str(), "ord-x:1");
7517
7518        // Test already normalized: "1" stays "1"
7519        let update2 = AlpacaTradeUpdate {
7520            event: SmolStr::new("fill"),
7521            execution_id: None,
7522            previous_execution_id: None,
7523            order: AlpacaOrderWs {
7524                id: SmolStr::new("ord-x"),
7525                client_order_id: Some(SmolStr::new("cid")),
7526                symbol: SmolStr::new("AAPL"),
7527                qty: Some("10"),
7528                filled_qty: Some("1"),
7529                side: SmolStr::new("buy"),
7530                order_type: SmolStr::new("market"),
7531                time_in_force: SmolStr::new("day"),
7532                limit_price: None,
7533                stop_price: None,
7534                trail_percent: None,
7535                trail_price: None,
7536                status: SmolStr::new("filled"),
7537            },
7538            price: Some("100.00"),
7539            qty: Some("10"),
7540            timestamp: None,
7541        };
7542        assert_eq!(early_dedup_key(&update2).as_str(), "ord-x:1");
7543
7544        // Test single trailing zero: "1.0" normalizes to "1"
7545        let update3 = AlpacaTradeUpdate {
7546            event: SmolStr::new("fill"),
7547            execution_id: None,
7548            previous_execution_id: None,
7549            order: AlpacaOrderWs {
7550                id: SmolStr::new("ord-x"),
7551                client_order_id: Some(SmolStr::new("cid")),
7552                symbol: SmolStr::new("AAPL"),
7553                qty: Some("10"),
7554                filled_qty: Some("1.0"),
7555                side: SmolStr::new("buy"),
7556                order_type: SmolStr::new("market"),
7557                time_in_force: SmolStr::new("day"),
7558                limit_price: None,
7559                stop_price: None,
7560                trail_percent: None,
7561                trail_price: None,
7562                status: SmolStr::new("filled"),
7563            },
7564            price: Some("100.00"),
7565            qty: Some("10"),
7566            timestamp: None,
7567        };
7568        assert_eq!(early_dedup_key(&update3).as_str(), "ord-x:1");
7569    }
7570
7571    /// Regression guard for HIGH-2: a `rejected` event without `filled_qty` in the JSON
7572    /// previously caused the entire AlpacaTradeUpdate to fail deserialization, silently
7573    /// dropping the event. After the fix, `filled_qty` is Option and defaults to None
7574    /// (unwrapped to "0" at use-sites), so the event reaches the `rejected` branch.
7575    #[test]
7576    fn process_ws_text_rejected_event_without_filled_qty_is_not_dropped() {
7577        let (tx, mut rx) = mpsc::unbounded_channel();
7578        let dedup = new_dedup_cache();
7579
7580        // Minimal rejected-event JSON — no `filled_qty` field in the order object.
7581        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"}}}"#;
7582
7583        let known = KnownLiveOrders::shared(ExchangeId::AlpacaBroker);
7584        process_ws_text(json, &tx, &dedup, &known);
7585
7586        // The event must NOT be silently dropped — an OrderCancelled must be emitted.
7587        let event = rx.try_recv()
7588            .expect("rejected event without filled_qty must produce an AccountEvent, not be silently dropped");
7589        assert!(
7590            matches!(event.kind, AccountEventKind::OrderCancelled(_)),
7591            "rejected event must map to OrderCancelled, got: {:?}",
7592            event.kind
7593        );
7594    }
7595
7596    /// A `trade_updates` fill frame as Alpaca sends it: its `execution_id` is the trade's id. One
7597    /// that is null, empty or absent falls back to the dedup key.
7598    #[test]
7599    fn process_ws_text_identifies_a_fill_by_its_execution_id() {
7600        let frame = |execution_id: &str| {
7601            format!(
7602                r#"{{"stream":"trade_updates","data":{{"event":"fill",{execution_id}"order":{{"id":"ord-1","client_order_id":"cid-1","symbol":"SPY","qty":"2","filled_qty":"2","side":"buy","type":"market","time_in_force":"day","status":"filled"}},"price":"100.00","qty":"2","timestamp":"2025-04-18T14:30:00Z"}}}}"#
7603            )
7604        };
7605        let cases = [
7606            (
7607                r#""execution_id":"524b1902-817e-446c-825b-a9fcfebbc17e","#,
7608                "524b1902-817e-446c-825b-a9fcfebbc17e",
7609            ),
7610            (r#""execution_id":null,"#, "ord-1:2"),
7611            (r#""execution_id":"","#, "ord-1:2"),
7612            ("", "ord-1:2"),
7613        ];
7614        for (field, expected) in cases {
7615            let (tx, mut rx) = mpsc::unbounded_channel();
7616            let known = KnownLiveOrders::shared(ExchangeId::AlpacaBroker);
7617            process_ws_text(&frame(field), &tx, &new_dedup_cache(), &known);
7618            let Ok(UnindexedAccountEvent {
7619                kind: AccountEventKind::Trade(trade),
7620                ..
7621            }) = rx.try_recv()
7622            else {
7623                panic!("the fill frame {field:?} produces a Trade");
7624            };
7625            assert_eq!(trade.id.0.as_str(), expected, "frame {field:?}");
7626        }
7627    }
7628
7629    /// A `trade_bust` or `trade_correct` frame for `ord-1` (a buy of SPY), with `fields` spliced
7630    /// in. Its shape follows the Broker API's `TradeUpdateEventV2`; no real frame has been seen.
7631    fn amendment_frame(event: &str, fields: &str) -> String {
7632        format!(
7633            r#"{{"stream":"trade_updates","data":{{"event":"{event}",{fields}"order":{{"id":"ord-1","client_order_id":"cid-1","symbol":"SPY","qty":"2","filled_qty":"0","side":"buy","type":"market","time_in_force":"day","status":"filled"}},"timestamp":"2025-04-18T14:31:00Z"}}}}"#
7634        )
7635    }
7636
7637    /// The events the stream sends for `frames`, in order.
7638    fn stream_events(frames: &[String]) -> Vec<UnindexedAccountEvent> {
7639        let (tx, mut rx) = mpsc::unbounded_channel();
7640        let dedup = new_dedup_cache();
7641        let known = KnownLiveOrders::shared(ExchangeId::AlpacaBroker);
7642        for frame in frames {
7643            process_ws_text(frame, &tx, &dedup, &known);
7644        }
7645        std::iter::from_fn(|| rx.try_recv().ok()).collect()
7646    }
7647
7648    /// The one event the stream sends for an amendment frame, as a `TradeAmendment`.
7649    fn amendment_of(
7650        frame: String,
7651    ) -> crate::trade::TradeAmendment<AssetNameExchange, InstrumentNameExchange> {
7652        let events = stream_events(&[frame]);
7653        let [
7654            UnindexedAccountEvent {
7655                kind: AccountEventKind::TradeAmended(amendment),
7656                ..
7657            },
7658        ] = <[_; 1]>::try_from(events).unwrap_or_else(|events| {
7659            panic!("one TradeAmended, got {events:?}");
7660        })
7661        else {
7662            panic!("expected TradeAmended");
7663        };
7664        amendment
7665    }
7666
7667    /// A bust names the fill it cancels, and its quantity, which Alpaca may send as a negative
7668    /// reversal, is read as a magnitude.
7669    #[test]
7670    fn process_ws_text_reports_a_trade_bust() {
7671        let amendment = amendment_of(amendment_frame(
7672            "trade_bust",
7673            r#""execution_id":"exec-x","previous_execution_id":"exec-a","qty":"-2","#,
7674        ));
7675        assert_eq!(amendment.original, Some(TradeId::new("exec-a")));
7676        assert_eq!(amendment.order_id, OrderId::new("ord-1"));
7677        assert_eq!(amendment.instrument, InstrumentNameExchange::new("SPY"));
7678        assert_eq!(
7679            Some(amendment.time_exchange),
7680            parse_timestamp("2025-04-18T14:31:00Z")
7681        );
7682        assert_eq!(
7683            amendment.kind,
7684            TradeAmendmentKind::Busted {
7685                quantity: Some(Decimal::TWO)
7686            }
7687        );
7688    }
7689
7690    /// A bust is reported even when it names neither the fill nor a quantity.
7691    #[test]
7692    fn process_ws_text_reports_a_bust_that_names_no_fill() {
7693        let amendment = amendment_of(amendment_frame("trade_bust", ""));
7694        assert_eq!(amendment.original, None);
7695        assert_eq!(
7696            amendment.kind,
7697            TradeAmendmentKind::Busted { quantity: None }
7698        );
7699    }
7700
7701    /// A correction carrying a price and a quantity replaces the fill with a trade under its own
7702    /// execution id, which claims no cumulative.
7703    #[test]
7704    fn process_ws_text_reports_a_trade_correct_with_its_replacement() {
7705        let amendment = amendment_of(amendment_frame(
7706            "trade_correct",
7707            r#""execution_id":"exec-c","previous_execution_id":"exec-a","price":"101.50","qty":"2","#,
7708        ));
7709        assert_eq!(amendment.original, Some(TradeId::new("exec-a")));
7710        let TradeAmendmentKind::Corrected { replacement } = amendment.kind else {
7711            panic!("a full replacement: {:?}", amendment.kind);
7712        };
7713        assert_eq!(replacement.id, TradeId::new("exec-c"));
7714        assert_eq!(replacement.order_id, OrderId::new("ord-1"));
7715        assert_eq!(replacement.side, Side::Buy);
7716        assert_eq!(replacement.price, Decimal::new(10150, 2));
7717        assert_eq!(replacement.quantity, Decimal::TWO);
7718        assert_eq!(replacement.order_filled_quantity, None);
7719        assert_eq!(replacement.time_exchange, amendment.time_exchange);
7720    }
7721
7722    /// A correction lacking anything a replacement trade needs is still reported, with what it
7723    /// carried.
7724    #[test]
7725    fn process_ws_text_reports_a_correction_it_cannot_resolve() {
7726        let price = Some(Decimal::new(10150, 2));
7727        let cases = [
7728            (
7729                r#""execution_id":"exec-c","qty":"2","#,
7730                (Some("exec-c"), None, Some(Decimal::TWO)),
7731            ),
7732            (
7733                r#""price":"101.50","qty":"2","#,
7734                (None, price, Some(Decimal::TWO)),
7735            ),
7736            (
7737                r#""execution_id":"exec-c","price":"101.50","qty":"0","#,
7738                (Some("exec-c"), price, Some(Decimal::ZERO)),
7739            ),
7740            (
7741                r#""execution_id":"exec-c","price":"101.50","#,
7742                (Some("exec-c"), price, None),
7743            ),
7744        ];
7745        for (fields, (id, price, quantity)) in cases {
7746            let amendment = amendment_of(amendment_frame("trade_correct", fields));
7747            assert_eq!(
7748                amendment.kind,
7749                TradeAmendmentKind::CorrectedUnresolved {
7750                    id: id.map(TradeId::new),
7751                    price,
7752                    quantity,
7753                },
7754                "{fields}"
7755            );
7756        }
7757    }
7758
7759    /// After a bust takes an order back, a new fill that returns it to a cumulative an earlier
7760    /// fill reached is delivered, not taken for that fill.
7761    #[test]
7762    fn a_fill_after_a_bust_is_delivered_at_a_cumulative_seen_before() {
7763        let fill = |execution_id: &str| {
7764            format!(
7765                r#"{{"stream":"trade_updates","data":{{"event":"partial_fill","execution_id":"{execution_id}","order":{{"id":"ord-1","client_order_id":"cid-1","symbol":"SPY","qty":"4","filled_qty":"2","side":"buy","type":"market","time_in_force":"day","status":"partially_filled"}},"price":"100.00","qty":"2","timestamp":"2025-04-18T14:30:00Z"}}}}"#
7766            )
7767        };
7768        let events = stream_events(&[
7769            fill("exec-a"),
7770            amendment_frame(
7771                "trade_bust",
7772                r#""execution_id":"exec-x","previous_execution_id":"exec-a","qty":"-2","#,
7773            ),
7774            fill("exec-b"),
7775        ]);
7776
7777        let kinds: Vec<_> = events
7778            .iter()
7779            .filter_map(|event| match &event.kind {
7780                AccountEventKind::Trade(trade) => Some(format!("trade {}", trade.id)),
7781                AccountEventKind::TradeAmended(amendment) => {
7782                    Some(format!("amended {:?}", amendment.original))
7783                }
7784                _ => None,
7785            })
7786            .collect();
7787        assert_eq!(
7788            kinds,
7789            vec![
7790                "trade exec-a",
7791                "amended Some(TradeId(\"exec-a\"))",
7792                "trade exec-b"
7793            ]
7794        );
7795    }
7796
7797    /// Pins the string representation of `Decimal::ZERO.normalize()`, which is used
7798    /// as the dedup key fallback when `filled_qty` is unparseable: `"{order_id}:0"`.
7799    #[test]
7800    fn decimal_zero_normalize_is_zero_str() {
7801        assert_eq!(Decimal::ZERO.normalize().to_string(), "0");
7802    }
7803
7804    /// Verifies that trailing zeros are stripped by `normalize()`, ensuring dedup keys
7805    /// match regardless of whether Alpaca returns `"1.00"` or `"1"` for the same qty.
7806    /// This is the critical invariant for REST/WS dedup key equivalence.
7807    #[test]
7808    fn decimal_normalize_strips_trailing_zeros() {
7809        // Alpaca may return "1.00" in REST but WS cumulative may be "1" — must match.
7810        let from_rest = Decimal::from_str("1.00").unwrap().normalize();
7811        let from_ws = Decimal::from_str("1").unwrap().normalize();
7812        assert_eq!(from_rest.to_string(), from_ws.to_string());
7813        assert_eq!(from_rest.to_string(), "1");
7814
7815        // More edge cases: various trailing zero representations.
7816        assert_eq!(
7817            Decimal::from_str("100.000")
7818                .unwrap()
7819                .normalize()
7820                .to_string(),
7821            "100"
7822        );
7823        assert_eq!(
7824            Decimal::from_str("0.10").unwrap().normalize().to_string(),
7825            "0.1"
7826        );
7827        assert_eq!(
7828            Decimal::from_str("0.100").unwrap().normalize().to_string(),
7829            "0.1"
7830        );
7831    }
7832
7833    // M-2: map_position_intent derives intent from (reduce_only, side).
7834    #[test]
7835    fn map_position_intent_open_buy_maps_to_buy_to_open() {
7836        assert_eq!(
7837            map_position_intent(Side::Buy, false),
7838            AlpacaPositionIntent::BuyToOpen
7839        );
7840    }
7841
7842    #[test]
7843    fn map_position_intent_open_sell_maps_to_sell_to_open() {
7844        assert_eq!(
7845            map_position_intent(Side::Sell, false),
7846            AlpacaPositionIntent::SellToOpen
7847        );
7848    }
7849
7850    #[test]
7851    fn map_position_intent_reduce_buy_maps_to_buy_to_close() {
7852        assert_eq!(
7853            map_position_intent(Side::Buy, true),
7854            AlpacaPositionIntent::BuyToClose
7855        );
7856    }
7857
7858    #[test]
7859    fn map_position_intent_reduce_sell_maps_to_sell_to_close() {
7860        assert_eq!(
7861            map_position_intent(Side::Sell, true),
7862            AlpacaPositionIntent::SellToClose
7863        );
7864    }
7865
7866    // M-1: parse_order_error — pin all status-code branches not covered by existing tests.
7867    #[test]
7868    fn parse_order_error_401_maps_to_unauthenticated() {
7869        // 401 Unauthorized: invalid/expired API credentials.
7870        assert!(matches!(
7871            parse_order_error(reqwest::StatusCode::UNAUTHORIZED, None, "bad credentials"),
7872            UnindexedOrderError::Rejected(ApiError::Unauthenticated(_))
7873        ));
7874    }
7875
7876    #[test]
7877    fn parse_order_error_403_without_code_maps_to_unauthenticated() {
7878        // Alpaca's answer to a wrong key, secret or host: a bare `{"message": "forbidden."}`.
7879        assert!(matches!(
7880            parse_order_error(reqwest::StatusCode::FORBIDDEN, None, "forbidden."),
7881            UnindexedOrderError::Rejected(ApiError::Unauthenticated(_))
7882        ));
7883    }
7884
7885    #[test]
7886    fn parse_order_error_403_with_code_and_insufficient_maps_to_balance_insufficient() {
7887        for message in [
7888            "insufficient buying power",
7889            "insufficient day trading buying power",
7890            "insufficient qty available for order (requested: 10, available: 0)",
7891        ] {
7892            let err = parse_order_error(reqwest::StatusCode::FORBIDDEN, Some(40310000), message);
7893            let UnindexedOrderError::Rejected(ApiError::BalanceInsufficient(None, text)) = err
7894            else {
7895                panic!("expected BalanceInsufficient for {message:?}, got {err:?}");
7896            };
7897            assert_eq!(text, format!("40310000 {message}"));
7898        }
7899    }
7900
7901    #[test]
7902    fn parse_order_error_reads_a_refused_short_by_its_text() {
7903        for (status, code, message, reason) in [
7904            (
7905                reqwest::StatusCode::FORBIDDEN,
7906                Some(40310000),
7907                "account is not allowed to short",
7908                BorrowRejectReason::BorrowDisabled,
7909            ),
7910            // Which status Alpaca gives a non-shortable asset is unconfirmed, so both are read.
7911            (
7912                reqwest::StatusCode::FORBIDDEN,
7913                Some(40310000),
7914                "asset XYZ cannot be sold short",
7915                BorrowRejectReason::NotShortable,
7916            ),
7917            (
7918                reqwest::StatusCode::UNPROCESSABLE_ENTITY,
7919                Some(42210000),
7920                "asset XYZ cannot be sold short",
7921                BorrowRejectReason::NotShortable,
7922            ),
7923            (
7924                reqwest::StatusCode::UNPROCESSABLE_ENTITY,
7925                None,
7926                "account is not allowed to short",
7927                BorrowRejectReason::BorrowDisabled,
7928            ),
7929        ] {
7930            assert_eq!(
7931                parse_order_error(status, code, message),
7932                UnindexedOrderError::Rejected(ApiError::BorrowRejected(BorrowReject::new(
7933                    reason,
7934                    code.map(|code| code.to_string()),
7935                    message.to_owned(),
7936                ))),
7937                "{status} {message:?}"
7938            );
7939        }
7940
7941        // The asset may be shortable in whole shares: the order's shape was refused, not a
7942        // borrow.
7943        for (status, code) in [
7944            (reqwest::StatusCode::UNPROCESSABLE_ENTITY, 42210000),
7945            (reqwest::StatusCode::FORBIDDEN, 40310000),
7946        ] {
7947            assert_eq!(
7948                parse_order_error(status, Some(code), "fractional orders cannot be sold short"),
7949                UnindexedOrderError::Rejected(ApiError::OrderRejected(format!(
7950                    "{code} fractional orders cannot be sold short"
7951                ))),
7952                "{status}"
7953            );
7954        }
7955
7956        // A refused short wins over "insufficient" in the same text.
7957        assert!(matches!(
7958            parse_order_error(
7959                reqwest::StatusCode::FORBIDDEN,
7960                Some(40310000),
7961                "insufficient qty: account is not allowed to short",
7962            ),
7963            UnindexedOrderError::Rejected(ApiError::BorrowRejected(_))
7964        ));
7965
7966        // Without a code a 403 is a wrong key, whatever its text.
7967        assert!(matches!(
7968            parse_order_error(
7969                reqwest::StatusCode::FORBIDDEN,
7970                None,
7971                "account is not allowed to short"
7972            ),
7973            UnindexedOrderError::Rejected(ApiError::Unauthenticated(_))
7974        ));
7975    }
7976
7977    #[test]
7978    fn parse_order_error_403_with_code_maps_to_order_rejected() {
7979        for (code, message) in [
7980            (40310000, "account is restricted to liquidation only"),
7981            (
7982                40310100,
7983                "trade denied due to pattern day trading protection",
7984            ),
7985        ] {
7986            let err = parse_order_error(reqwest::StatusCode::FORBIDDEN, Some(code), message);
7987            let UnindexedOrderError::Rejected(ApiError::OrderRejected(text)) = err else {
7988                panic!("expected OrderRejected for {message:?}, got {err:?}");
7989            };
7990            assert_eq!(text, format!("{code} {message}"));
7991        }
7992    }
7993
7994    #[test]
7995    fn alpaca_api_error_reads_code_and_falls_back_to_raw_text() {
7996        let coded = AlpacaApiError::from_body(
7997            br#"{"code": 40310000, "message": "insufficient buying power"}"#,
7998        );
7999        assert_eq!(coded.code, Some(40310000));
8000        assert_eq!(coded.message, "insufficient buying power");
8001
8002        let bare = AlpacaApiError::from_body(br#"{"message": "forbidden."}"#);
8003        assert_eq!(bare.code, None);
8004        assert_eq!(bare.message, "forbidden.");
8005
8006        let raw = AlpacaApiError::from_body(b"upstream connect error");
8007        assert_eq!(raw.code, None);
8008        assert_eq!(raw.message, "upstream connect error");
8009    }
8010
8011    #[test]
8012    fn parse_order_error_404_maps_to_order_rejected_with_not_found_prefix() {
8013        let err = parse_order_error(reqwest::StatusCode::NOT_FOUND, None, "order not found");
8014        let UnindexedOrderError::Rejected(ApiError::OrderRejected(msg)) = err else {
8015            panic!("expected OrderRejected, got {err:?}");
8016        };
8017        assert!(
8018            msg.contains("order not found"),
8019            "message should contain 'order not found': {msg}"
8020        );
8021    }
8022
8023    #[test]
8024    fn parse_order_error_422_insufficient_only_maps_to_balance_insufficient() {
8025        // No "already" in body → must not match OrderAlreadyCancelled; must be BalanceInsufficient.
8026        assert!(matches!(
8027            parse_order_error(
8028                reqwest::StatusCode::UNPROCESSABLE_ENTITY,
8029                None,
8030                "insufficient funds for this order"
8031            ),
8032            UnindexedOrderError::Rejected(ApiError::BalanceInsufficient(None, _))
8033        ));
8034    }
8035
8036    #[test]
8037    fn parse_order_error_429_maps_to_rate_limit() {
8038        assert!(matches!(
8039            parse_order_error(reqwest::StatusCode::TOO_MANY_REQUESTS, None, "rate limited"),
8040            UnindexedOrderError::Rejected(ApiError::RateLimit {
8041                retry_after: None,
8042                ..
8043            })
8044        ));
8045    }
8046
8047    #[test]
8048    fn a_rate_limit_delay_is_capped_at_alpacas_window() {
8049        let window = Some(Duration::from_secs(DEFAULT_RATE_LIMIT_DELAY_SECS));
8050        for (name, value) in [("retry-after", u64::MAX), ("x-ratelimit-reset", u64::MAX)] {
8051            let mut headers = reqwest::header::HeaderMap::new();
8052            headers.insert(name, value.to_string().parse().unwrap());
8053            assert_eq!(parse_rate_limit_delay(&headers), window, "{name}");
8054            // Nor does it overflow the cooldown's deadline.
8055            RateLimitTracker::new().on_rate_limited(parse_rate_limit_delay(&headers));
8056        }
8057
8058        let mut headers = reqwest::header::HeaderMap::new();
8059        headers.insert("retry-after", "0".parse().unwrap());
8060        assert_eq!(
8061            parse_rate_limit_delay(&headers),
8062            Some(Duration::ZERO),
8063            "taken as given"
8064        );
8065    }
8066
8067    // M-4: parse_time_in_force — pin the unknown-value fallback to GoodUntilEndOfDay.
8068    // If Alpaca adds a new TIF (e.g. "opg" for at-the-open), orders continue to be
8069    // tracked with EOD expiry until this function is updated; the warn! makes it visible.
8070    #[test]
8071    fn parse_time_in_force_unknown_value_falls_back_to_good_until_end_of_day() {
8072        assert_eq!(
8073            parse_time_in_force("opg"),
8074            TimeInForce::GoodUntilEndOfDay,
8075            "unknown TIF must fall back to GoodUntilEndOfDay (with a warn! in production)"
8076        );
8077    }
8078
8079    // L-4: build_instrument_snapshots — output order must match the instruments slice,
8080    // not the internal IndexMap insertion order of the orders vec.
8081    #[test]
8082    fn build_instrument_snapshots_output_order_matches_instruments_slice() {
8083        // Orders arrive in symbol order: SPY, AAPL, MSFT.
8084        let orders = vec![
8085            make_order_response("o1", "SPY"),
8086            make_order_response("o2", "AAPL"),
8087            make_order_response("o3", "MSFT"),
8088        ];
8089        // Request a different order: MSFT first, then AAPL.
8090        let instruments = vec![
8091            InstrumentNameExchange::new("MSFT"),
8092            InstrumentNameExchange::new("AAPL"),
8093        ];
8094        let snapshots = build_instrument_snapshots(orders, Vec::new(), &instruments);
8095        assert_eq!(snapshots.len(), 2);
8096        assert_eq!(
8097            snapshots[0].instrument.name().as_str(),
8098            "MSFT",
8099            "first snapshot must be MSFT (first in instruments slice)"
8100        );
8101        assert_eq!(
8102            snapshots[1].instrument.name().as_str(),
8103            "AAPL",
8104            "second snapshot must be AAPL (second in instruments slice)"
8105        );
8106    }
8107
8108    // ---------------------------------------------------------------------------
8109    // HTTP-mocked tests — paginate_activities (H-1) and fetch_raw_open_orders (H-3)
8110    // ---------------------------------------------------------------------------
8111    //
8112    // These tests use wiremock to stand up a local HTTP server, verifying the full
8113    // pagination loop and truncation logic without touching the real Alpaca API.
8114    mod http_tests {
8115        use super::super::*;
8116        use wiremock::matchers::{method, path};
8117        use wiremock::{Mock, MockServer, Request, Respond, ResponseTemplate};
8118
8119        /// Serves pre-configured JSON pages in registration order.
8120        ///
8121        /// Each call to `respond` advances an atomic counter and returns the next
8122        /// page body. Panics if called more times than pages were configured —
8123        /// surfaces unexpected extra requests as an explicit test failure rather
8124        /// than silently returning a stale response.
8125        struct Sequential {
8126            call: std::sync::atomic::AtomicU32,
8127            pages: Vec<serde_json::Value>,
8128        }
8129
8130        impl Sequential {
8131            fn new(pages: Vec<serde_json::Value>) -> Self {
8132                Self {
8133                    call: std::sync::atomic::AtomicU32::new(0),
8134                    pages,
8135                }
8136            }
8137        }
8138
8139        impl Respond for Sequential {
8140            fn respond(&self, _: &Request) -> ResponseTemplate {
8141                let i = self.call.fetch_add(1, std::sync::atomic::Ordering::Relaxed) as usize;
8142                let body = self.pages.get(i).unwrap_or_else(|| {
8143                    panic!(
8144                        "Sequential: request #{i} has no configured response \
8145                         (only {} page(s) supplied)",
8146                        self.pages.len()
8147                    )
8148                });
8149                ResponseTemplate::new(200).set_body_json(body)
8150            }
8151        }
8152
8153        /// Build a JSON array of N minimal AlpacaActivity objects with unique IDs.
8154        fn make_activities_json(count: usize, id_prefix: &str) -> serde_json::Value {
8155            serde_json::Value::Array(
8156                (0..count)
8157                    .map(|i| {
8158                        serde_json::json!({
8159                            "id": format!("{id_prefix}-{i:05}"),
8160                            "order_id": "ord-1",
8161                            "symbol": "SPY",
8162                            "side": "buy",
8163                            "price": "100.00",
8164                            "qty": "1",
8165                            "transaction_time": "2025-04-18T14:30:00Z"
8166                        })
8167                    })
8168                    .collect(),
8169            )
8170        }
8171
8172        /// Build a JSON array of N minimal AlpacaOrderResponse objects with unique IDs.
8173        fn make_orders_json(count: usize) -> serde_json::Value {
8174            serde_json::Value::Array(
8175                (0..count)
8176                    .map(|i| {
8177                        serde_json::json!({
8178                            "id": format!("order-{i:05}"),
8179                            "client_order_id": null,
8180                            "symbol": "SPY",
8181                            "qty": "1",
8182                            "filled_qty": "0",
8183                            "side": "buy",
8184                            "type": "limit",
8185                            "time_in_force": "day",
8186                            "limit_price": "100.00",
8187                            "created_at": "2025-04-18T14:30:00Z"
8188                        })
8189                    })
8190                    .collect(),
8191            )
8192        }
8193
8194        // --- H-1: paginate_activities ---
8195
8196        /// Single page with fewer items than the page limit — loop terminates immediately,
8197        /// no further request issued.
8198        #[tokio::test]
8199        async fn paginate_activities_single_page_below_max_returns_all_not_truncated() {
8200            let server = MockServer::start().await;
8201            Mock::given(method("GET"))
8202                .and(path("/v2/account/activities"))
8203                .respond_with(
8204                    ResponseTemplate::new(200).set_body_json(make_activities_json(5, "act")),
8205                )
8206                .mount(&server)
8207                .await;
8208
8209            let http = reqwest::Client::new();
8210            let rl = RateLimitTracker::new();
8211            let result = paginate_activities::<AlpacaActivity>(
8212                &http,
8213                &rl,
8214                &server.uri(),
8215                FILL_ACTIVITIES,
8216                recovery_after(),
8217                None,
8218            )
8219            .await
8220            .unwrap();
8221
8222            assert_eq!(result.activities.len(), 5);
8223            assert_eq!(result.resume, None);
8224            assert_eq!(server.received_requests().await.unwrap().len(), 1);
8225        }
8226
8227        /// First page has exactly ALPACA_MAX_ACTIVITIES items, which triggers a second
8228        /// request. The second page is empty, so the loop terminates without truncation.
8229        #[tokio::test]
8230        async fn paginate_activities_exactly_page_size_items_fetches_second_page() {
8231            let server = MockServer::start().await;
8232            Mock::given(method("GET"))
8233                .and(path("/v2/account/activities"))
8234                .respond_with(Sequential::new(vec![
8235                    make_activities_json(ALPACA_MAX_ACTIVITIES, "act"),
8236                    serde_json::json!([]), // empty second page → loop stops
8237                ]))
8238                .mount(&server)
8239                .await;
8240
8241            let http = reqwest::Client::new();
8242            let rl = RateLimitTracker::new();
8243            let result = paginate_activities::<AlpacaActivity>(
8244                &http,
8245                &rl,
8246                &server.uri(),
8247                FILL_ACTIVITIES,
8248                recovery_after(),
8249                None,
8250            )
8251            .await
8252            .unwrap();
8253
8254            assert_eq!(result.activities.len(), ALPACA_MAX_ACTIVITIES);
8255            assert_eq!(result.resume, None);
8256            assert_eq!(
8257                server.received_requests().await.unwrap().len(),
8258                2,
8259                "exactly 2 requests: first full page + second empty page"
8260            );
8261        }
8262
8263        /// Two-page case: 100 items on page 1, 37 on page 2 — all accumulated,
8264        /// loop terminates on the partial second page without truncation.
8265        #[tokio::test]
8266        async fn paginate_activities_two_pages_returns_combined_activities_not_truncated() {
8267            let server = MockServer::start().await;
8268            Mock::given(method("GET"))
8269                .and(path("/v2/account/activities"))
8270                .respond_with(Sequential::new(vec![
8271                    make_activities_json(ALPACA_MAX_ACTIVITIES, "p1"),
8272                    make_activities_json(37, "p2"),
8273                ]))
8274                .mount(&server)
8275                .await;
8276
8277            let http = reqwest::Client::new();
8278            let rl = RateLimitTracker::new();
8279            let result = paginate_activities::<AlpacaActivity>(
8280                &http,
8281                &rl,
8282                &server.uri(),
8283                FILL_ACTIVITIES,
8284                recovery_after(),
8285                None,
8286            )
8287            .await
8288            .unwrap();
8289
8290            assert_eq!(result.activities.len(), ALPACA_MAX_ACTIVITIES + 37);
8291            assert_eq!(result.resume, None);
8292            assert_eq!(server.received_requests().await.unwrap().len(), 2);
8293        }
8294
8295        /// When every page is full the loop runs until MAX_ACTIVITY_PAGES pages have been
8296        /// fetched, then sets truncated=true and stops. Exactly MAX_ACTIVITY_PAGES HTTP
8297        /// requests are issued (the truncation guard fires before the (N+1)th call).
8298        #[tokio::test]
8299        async fn paginate_activities_at_max_pages_sets_truncated_true() {
8300            let server = MockServer::start().await;
8301
8302            // Always return a full page — the loop must enforce the cap itself.
8303            Mock::given(method("GET"))
8304                .and(path("/v2/account/activities"))
8305                .respond_with(
8306                    ResponseTemplate::new(200)
8307                        .set_body_json(make_activities_json(ALPACA_MAX_ACTIVITIES, "act")),
8308                )
8309                .mount(&server)
8310                .await;
8311
8312            let http = reqwest::Client::new();
8313            let rl = RateLimitTracker::new();
8314            let result = paginate_activities::<AlpacaActivity>(
8315                &http,
8316                &rl,
8317                &server.uri(),
8318                FILL_ACTIVITIES,
8319                recovery_after(),
8320                None,
8321            )
8322            .await
8323            .unwrap();
8324
8325            assert!(
8326                result.resume.is_some(),
8327                "must say where to read on after MAX_ACTIVITY_PAGES pages"
8328            );
8329            assert_eq!(
8330                result.activities.len(),
8331                MAX_ACTIVITY_PAGES * ALPACA_MAX_ACTIVITIES,
8332                "must accumulate exactly MAX_ACTIVITY_PAGES * page_size activities"
8333            );
8334            // The truncation check fires at the top of the loop when pages == MAX_ACTIVITY_PAGES,
8335            // before the (MAX_ACTIVITY_PAGES+1)th request would be issued.
8336            assert_eq!(
8337                server.received_requests().await.unwrap().len(),
8338                MAX_ACTIVITY_PAGES,
8339                "loop must issue exactly MAX_ACTIVITY_PAGES requests then stop"
8340            );
8341        }
8342
8343        // --- H-3: fetch_raw_open_orders truncation boundary ---
8344
8345        /// 499 orders (one below MAX_OPEN_ORDERS) is not truncated — returns Ok.
8346        #[tokio::test]
8347        async fn fetch_raw_open_orders_499_results_returns_ok() {
8348            let server = MockServer::start().await;
8349            Mock::given(method("GET"))
8350                .and(path("/v2/orders"))
8351                .respond_with(
8352                    ResponseTemplate::new(200).set_body_json(make_orders_json(MAX_OPEN_ORDERS - 1)),
8353                )
8354                .mount(&server)
8355                .await;
8356
8357            let http = reqwest::Client::new();
8358            let rl = RateLimitTracker::new();
8359            let result = fetch_raw_open_orders(&http, &rl, &server.uri(), &[]).await;
8360
8361            assert!(
8362                result.is_ok(),
8363                "499 orders must not trigger truncation: {result:?}"
8364            );
8365            assert_eq!(result.unwrap().len(), MAX_OPEN_ORDERS - 1);
8366        }
8367
8368        /// Exactly MAX_OPEN_ORDERS results triggers TruncatedSnapshot because Alpaca's
8369        /// API cap means the response is likely incomplete. An off-by-one here would
8370        /// either silently corrupt OMS state or incorrectly reject a valid account.
8371        #[tokio::test]
8372        async fn fetch_raw_open_orders_500_results_returns_truncated_snapshot_error() {
8373            let server = MockServer::start().await;
8374            Mock::given(method("GET"))
8375                .and(path("/v2/orders"))
8376                .respond_with(
8377                    ResponseTemplate::new(200).set_body_json(make_orders_json(MAX_OPEN_ORDERS)),
8378                )
8379                .mount(&server)
8380                .await;
8381
8382            let http = reqwest::Client::new();
8383            let rl = RateLimitTracker::new();
8384            let result = fetch_raw_open_orders(&http, &rl, &server.uri(), &[]).await;
8385
8386            assert!(
8387                matches!(
8388                    result,
8389                    Err(UnindexedClientError::TruncatedSnapshot { limit }) if limit == MAX_OPEN_ORDERS
8390                ),
8391                "500 orders must return TruncatedSnapshot, got: {result:?}"
8392            );
8393        }
8394
8395        // --- account_snapshot: cash balance, equity/option positions, crypto balances ---
8396
8397        /// Mounts `/v2/account`, `/v2/positions` and an empty `/v2/orders` on `server`.
8398        /// Synthetic values only; no provider data.
8399        async fn mount_account_and_positions(server: &MockServer) {
8400            Mock::given(method("GET"))
8401                .and(path("/v2/account"))
8402                .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
8403                    "status": "ACTIVE",
8404                    "currency": "USD",
8405                    "cash": "-1500.50",
8406                    "equity": "4000.00",
8407                    "buying_power": "5000.00",
8408                    "options_buying_power": "2000.00",
8409                    "non_marginable_buying_power": "1000.00",
8410                })))
8411                .mount(server)
8412                .await;
8413            Mock::given(method("GET"))
8414                .and(path("/v2/positions"))
8415                .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([
8416                    {
8417                        "symbol": "XYZ",
8418                        "asset_class": "us_equity",
8419                        "side": "short",
8420                        "qty": "-10",
8421                        "qty_available": "-10",
8422                        "avg_entry_price": "20.00",
8423                        "unrealized_pl": "15.50",
8424                    },
8425                    {
8426                        "symbol": "XYZ271217C00025000",
8427                        "asset_class": "us_option",
8428                        "side": "long",
8429                        "qty": "3",
8430                        "qty_available": "3",
8431                        "avg_entry_price": "0.85",
8432                        "unrealized_pl": "-45",
8433                    },
8434                    {
8435                        "symbol": "ABC",
8436                        "asset_class": "us_equity",
8437                        "side": "long",
8438                        "qty": "2.5",
8439                        "qty_available": "2.5",
8440                        "avg_entry_price": "40.00",
8441                        "unrealized_pl": "1.25",
8442                    },
8443                    {
8444                        "symbol": "BTCUSD",
8445                        "asset_class": "crypto",
8446                        "side": "long",
8447                        "qty": "0.75",
8448                        "qty_available": "0.5",
8449                        "avg_entry_price": "50000",
8450                        "unrealized_pl": "0",
8451                    },
8452                ])))
8453                .mount(server)
8454                .await;
8455            Mock::given(method("GET"))
8456                .and(path("/v2/orders"))
8457                .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([])))
8458                .mount(server)
8459                .await;
8460        }
8461
8462        fn client_for(server: &MockServer) -> AlpacaClient {
8463            AlpacaClient::new(AlpacaConfig::with_base_url(
8464                "test-key".into(),
8465                "test-secret".into(),
8466                server.uri(),
8467            ))
8468        }
8469
8470        #[tokio::test]
8471        async fn account_snapshot_reports_cash_positions_and_crypto_balances() {
8472            use crate::client::ExecutionClient;
8473            use rust_decimal_macros::dec;
8474
8475            let server = MockServer::start().await;
8476            mount_account_and_positions(&server).await;
8477
8478            let snapshot = client_for(&server)
8479                .account_snapshot(&[], &[])
8480                .await
8481                .unwrap();
8482
8483            // USD: cash (negative on margin), and free capped at it, not equity or buying power.
8484            let balances: Vec<_> = snapshot
8485                .balances
8486                .iter()
8487                .map(|b| (b.asset.name().as_str(), b.balance.total, b.balance.free))
8488                .collect();
8489            assert_eq!(
8490                balances,
8491                vec![
8492                    ("USD", dec!(-1500.50), dec!(-1500.50)),
8493                    ("BTC", dec!(0.75), dec!(0.5)),
8494                ],
8495                "crypto stays a balance; equities and options do not appear as balances"
8496            );
8497
8498            let positions: Vec<_> = snapshot
8499                .instruments
8500                .iter()
8501                .map(|i| {
8502                    let p = i.position.open().unwrap();
8503                    (
8504                        i.instrument.name().as_str(),
8505                        p.quantity,
8506                        p.entry_price,
8507                        p.unrealized_pnl,
8508                    )
8509                })
8510                .collect();
8511            assert_eq!(
8512                positions,
8513                vec![
8514                    ("XYZ", dec!(-10), Some(dec!(20.00)), Some(dec!(15.50))),
8515                    (
8516                        "XYZ271217C00025000",
8517                        dec!(3),
8518                        Some(dec!(0.85)),
8519                        Some(dec!(-45))
8520                    ),
8521                    ("ABC", dec!(2.5), Some(dec!(40.00)), Some(dec!(1.25))),
8522                ],
8523                "short equity, option and fractional positions; no crypto position"
8524            );
8525        }
8526
8527        /// A USD-only request still fetches positions, since they back the instrument
8528        /// snapshots, and reports no crypto balance.
8529        #[tokio::test]
8530        async fn account_snapshot_usd_only_still_reports_positions() {
8531            use crate::client::ExecutionClient;
8532            use rust_decimal_macros::dec;
8533
8534            let server = MockServer::start().await;
8535            mount_account_and_positions(&server).await;
8536
8537            let usd = [AssetNameExchange::new("USD")];
8538            let instruments = [InstrumentNameExchange::new("XYZ")];
8539            let snapshot = client_for(&server)
8540                .account_snapshot(&usd, &instruments)
8541                .await
8542                .unwrap();
8543
8544            assert_eq!(snapshot.balances.len(), 1);
8545            assert_eq!(snapshot.balances[0].asset, AssetNameExchange::new("USD"));
8546            assert_eq!(snapshot.balances[0].balance.total, dec!(-1500.50));
8547
8548            assert_eq!(snapshot.instruments.len(), 1);
8549            assert_eq!(snapshot.instruments[0].position.quantity(), Some(dec!(-10)));
8550        }
8551
8552        /// A non-USD request skips `/v2/account` entirely.
8553        #[tokio::test]
8554        async fn account_snapshot_non_usd_only_skips_account_request() {
8555            use crate::client::ExecutionClient;
8556
8557            let server = MockServer::start().await;
8558            mount_account_and_positions(&server).await;
8559
8560            let btc = [AssetNameExchange::new("btc")];
8561            let snapshot = client_for(&server)
8562                .account_snapshot(&btc, &[])
8563                .await
8564                .unwrap();
8565
8566            assert_eq!(snapshot.balances.len(), 1);
8567            assert_eq!(snapshot.balances[0].asset, AssetNameExchange::new("BTC"));
8568            let requests = server.received_requests().await.unwrap();
8569            assert!(
8570                requests.iter().all(|r| r.url.path() != "/v2/account"),
8571                "no USD requested, so /v2/account must not be fetched"
8572            );
8573        }
8574
8575        // --- L-2: open_order passes reduce_only through to position_intent ---
8576
8577        /// Verifies that `open_order` correctly derives `position_intent` from
8578        /// `reduce_only` and `side`. This test exercises the full path:
8579        /// `open_order` → `map_position_intent` → `open_order_inner` → HTTP request.
8580        ///
8581        /// Uses wiremock to capture the request body and verify position_intent.
8582        #[tokio::test]
8583        async fn open_order_reduce_only_sell_sends_sell_to_close_intent() {
8584            use crate::client::ExecutionClient;
8585            use crate::order::request::{OrderRequestOpen, RequestOpen};
8586            use crate::order::{
8587                OrderKey, OrderKind, TimeInForce,
8588                id::{ClientOrderId, StrategyId},
8589            };
8590            use rust_decimal::Decimal;
8591            use rustrade_instrument::Side;
8592            use rustrade_instrument::exchange::ExchangeId;
8593            use rustrade_instrument::instrument::name::InstrumentNameExchange;
8594            use wiremock::matchers::{method, path};
8595
8596            let server = MockServer::start().await;
8597
8598            // Mock POST /v2/orders to return a valid order response.
8599            // Use a custom responder to capture and verify the request body.
8600            let captured_body = std::sync::Arc::new(parking_lot::Mutex::new(None));
8601            let captured_clone = captured_body.clone();
8602
8603            Mock::given(method("POST"))
8604                .and(path("/v2/orders"))
8605                .respond_with(move |req: &Request| {
8606                    // Capture the request body for later assertion
8607                    let body: serde_json::Value = serde_json::from_slice(&req.body).unwrap();
8608                    *captured_clone.lock() = Some(body);
8609
8610                    ResponseTemplate::new(200).set_body_json(serde_json::json!({
8611                        "id": "test-order-id",
8612                        "client_order_id": "test-cid",
8613                        "symbol": "AAPL",
8614                        "qty": "10",
8615                        "filled_qty": "0",
8616                        "side": "sell",
8617                        "type": "market",
8618                        "time_in_force": "ioc",
8619                        "limit_price": null,
8620                        "created_at": "2025-04-18T14:30:00Z"
8621                    }))
8622                })
8623                .mount(&server)
8624                .await;
8625
8626            // Create client with base_url_override pointing to mock server
8627            let config =
8628                AlpacaConfig::with_base_url("test-key".into(), "test-secret".into(), server.uri());
8629            let client = AlpacaClient::new(config);
8630
8631            // Create a Sell order with reduce_only=true (should map to SellToClose)
8632            let request = OrderRequestOpen {
8633                key: OrderKey {
8634                    exchange: ExchangeId::AlpacaBroker,
8635                    instrument: InstrumentNameExchange::new("AAPL"),
8636                    strategy: StrategyId::new("test-strategy"),
8637                    cid: ClientOrderId::new("test-cid"),
8638                },
8639                state: RequestOpen {
8640                    side: Side::Sell,
8641                    price: None,
8642                    quantity: Decimal::new(10, 0),
8643                    kind: OrderKind::Market,
8644                    time_in_force: TimeInForce::ImmediateOrCancel,
8645                    position_id: None,
8646                    reduce_only: true, // This should map to SellToClose
8647                    market: None,
8648                },
8649            };
8650
8651            // Call open_order (borrows instrument)
8652            let order = client
8653                .open_order(OrderRequestOpen {
8654                    key: OrderKey {
8655                        exchange: request.key.exchange,
8656                        instrument: &request.key.instrument,
8657                        strategy: request.key.strategy.clone(),
8658                        cid: request.key.cid.clone(),
8659                    },
8660                    state: request.state.clone(),
8661                })
8662                .await;
8663
8664            // Verify the order was accepted
8665            assert!(
8666                order.state.is_accepted(),
8667                "order should be accepted: {:?}",
8668                order.state
8669            );
8670
8671            // Verify the request body contained position_intent=sell_to_close
8672            let body = captured_body
8673                .lock()
8674                .take()
8675                .expect("request body should be captured");
8676            assert_eq!(
8677                body.get("position_intent").and_then(|v| v.as_str()),
8678                Some("sell_to_close"),
8679                "reduce_only=true + Side::Sell should produce position_intent=sell_to_close, got: {body}"
8680            );
8681        }
8682
8683        /// Verifies that reduce_only=false + Buy produces BuyToOpen intent.
8684        #[tokio::test]
8685        async fn open_order_not_reduce_only_buy_sends_buy_to_open_intent() {
8686            use crate::client::ExecutionClient;
8687            use crate::order::request::{OrderRequestOpen, RequestOpen};
8688            use crate::order::{
8689                OrderKey, OrderKind, TimeInForce,
8690                id::{ClientOrderId, StrategyId},
8691            };
8692            use rust_decimal::Decimal;
8693            use rustrade_instrument::Side;
8694            use rustrade_instrument::exchange::ExchangeId;
8695            use rustrade_instrument::instrument::name::InstrumentNameExchange;
8696
8697            let server = MockServer::start().await;
8698
8699            let captured_body = std::sync::Arc::new(parking_lot::Mutex::new(None));
8700            let captured_clone = captured_body.clone();
8701
8702            Mock::given(method("POST"))
8703                .and(path("/v2/orders"))
8704                .respond_with(move |req: &Request| {
8705                    let body: serde_json::Value = serde_json::from_slice(&req.body).unwrap();
8706                    *captured_clone.lock() = Some(body);
8707
8708                    ResponseTemplate::new(200).set_body_json(serde_json::json!({
8709                        "id": "test-order-id",
8710                        "client_order_id": "test-cid",
8711                        "symbol": "AAPL",
8712                        "qty": "10",
8713                        "filled_qty": "0",
8714                        "side": "buy",
8715                        "type": "market",
8716                        "time_in_force": "ioc",
8717                        "limit_price": null,
8718                        "created_at": "2025-04-18T14:30:00Z"
8719                    }))
8720                })
8721                .mount(&server)
8722                .await;
8723
8724            let config =
8725                AlpacaConfig::with_base_url("test-key".into(), "test-secret".into(), server.uri());
8726            let client = AlpacaClient::new(config);
8727
8728            let instrument = InstrumentNameExchange::new("AAPL");
8729            let request = OrderRequestOpen {
8730                key: OrderKey {
8731                    exchange: ExchangeId::AlpacaBroker,
8732                    instrument: &instrument,
8733                    strategy: StrategyId::new("test-strategy"),
8734                    cid: ClientOrderId::new("test-cid"),
8735                },
8736                state: RequestOpen {
8737                    side: Side::Buy,
8738                    price: None,
8739                    quantity: Decimal::new(10, 0),
8740                    kind: OrderKind::Market,
8741                    time_in_force: TimeInForce::ImmediateOrCancel,
8742                    position_id: None,
8743                    reduce_only: false, // This should map to BuyToOpen
8744                    market: None,
8745                },
8746            };
8747
8748            let order = client.open_order(request).await;
8749
8750            assert!(
8751                order.state.is_accepted(),
8752                "order should be accepted: {:?}",
8753                order.state
8754            );
8755
8756            let body = captured_body
8757                .lock()
8758                .take()
8759                .expect("request body should be captured");
8760            assert_eq!(
8761                body.get("position_intent").and_then(|v| v.as_str()),
8762                Some("buy_to_open"),
8763                "reduce_only=false + Side::Buy should produce position_intent=buy_to_open, got: {body}"
8764            );
8765        }
8766
8767        /// A 2xx body that does not decode is not transient, so a query reports it as `Internal`
8768        /// rather than as connectivity a caller would retry.
8769        #[tokio::test]
8770        async fn rest_with_retry_reports_an_undecodable_2xx_body_as_internal() {
8771            let server = MockServer::start().await;
8772            Mock::given(method("GET"))
8773                .and(path("/v2/account"))
8774                .respond_with(ResponseTemplate::new(200).set_body_string("not json"))
8775                .mount(&server)
8776                .await;
8777
8778            let http = reqwest::Client::new();
8779            let rl = RateLimitTracker::new();
8780            let url = format!("{}/v2/account", server.uri());
8781            let result: Result<serde_json::Value, _> =
8782                rest_with_retry(&rl, || http.get(&url)).await;
8783            assert!(
8784                matches!(result, Err(UnindexedClientError::Internal(_))),
8785                "{result:?}"
8786            );
8787        }
8788
8789        /// Whether `rl` still holds requests back after 50 ms.
8790        async fn holds_back(rl: &RateLimitTracker) -> bool {
8791            tokio::time::timeout(Duration::from_millis(50), rl.wait_if_blocked())
8792                .await
8793                .is_err()
8794        }
8795
8796        /// The rate-limit headers of a response: `remaining`, and a reset 30 s ahead if `reset`.
8797        fn with_rate_limit(
8798            template: ResponseTemplate,
8799            remaining: u32,
8800            reset: bool,
8801        ) -> ResponseTemplate {
8802            let template = template.insert_header("x-ratelimit-remaining", remaining.to_string());
8803            if !reset {
8804                return template;
8805            }
8806            let now_secs = std::time::SystemTime::now()
8807                .duration_since(std::time::UNIX_EPOCH)
8808                .unwrap()
8809                .as_secs();
8810            template.insert_header("x-ratelimit-reset", (now_secs + 30).to_string())
8811        }
8812
8813        /// A response reporting no requests left holds every later request back until the reset;
8814        /// one with requests left, or with no reset to wait for, does not.
8815        #[tokio::test]
8816        async fn rest_with_retry_pauses_once_no_requests_remain() {
8817            for (remaining, reset, pauses) in [(0, true, true), (1, true, false), (0, false, false)]
8818            {
8819                let server = MockServer::start().await;
8820                Mock::given(method("GET"))
8821                    .and(path("/v2/account"))
8822                    .respond_with(with_rate_limit(
8823                        ResponseTemplate::new(200).set_body_json(serde_json::json!({})),
8824                        remaining,
8825                        reset,
8826                    ))
8827                    .mount(&server)
8828                    .await;
8829
8830                let http = reqwest::Client::new();
8831                let rl = RateLimitTracker::new();
8832                let url = format!("{}/v2/account", server.uri());
8833                let _: serde_json::Value = rest_with_retry(&rl, || http.get(&url)).await.unwrap();
8834                assert_eq!(
8835                    holds_back(&rl).await,
8836                    pauses,
8837                    "remaining {remaining}, reset {reset}"
8838                );
8839            }
8840        }
8841
8842        /// A reset further out than Alpaca's one-minute window holds requests back for a minute
8843        /// at most.
8844        #[tokio::test]
8845        async fn a_pause_lasts_a_minute_at_most() {
8846            tokio::time::pause();
8847            let now_secs = std::time::SystemTime::now()
8848                .duration_since(std::time::UNIX_EPOCH)
8849                .unwrap()
8850                .as_secs();
8851            let mut headers = reqwest::header::HeaderMap::new();
8852            headers.insert("x-ratelimit-remaining", "0".parse().unwrap());
8853            headers.insert(
8854                "x-ratelimit-reset",
8855                (now_secs + 3_600).to_string().parse().unwrap(),
8856            );
8857            let rl = RateLimitTracker::new();
8858
8859            observe_rate_limit_remaining(&rl, &headers);
8860            assert!(holds_back(&rl).await);
8861            tokio::time::advance(Duration::from_secs(DEFAULT_RATE_LIMIT_DELAY_SECS)).await;
8862            assert!(!holds_back(&rl).await);
8863        }
8864
8865        /// A cancel's response reporting no requests left holds later requests back too.
8866        #[tokio::test]
8867        async fn rest_delete_with_retry_pauses_once_no_requests_remain() {
8868            let server = MockServer::start().await;
8869            Mock::given(method("DELETE"))
8870                .and(path("/v2/orders/abc"))
8871                .respond_with(with_rate_limit(ResponseTemplate::new(204), 0, true))
8872                .mount(&server)
8873                .await;
8874
8875            let http = reqwest::Client::new();
8876            let rl = RateLimitTracker::new();
8877            let url = format!("{}/v2/orders/abc", server.uri());
8878            rest_delete_with_retry(&rl, || http.delete(&url))
8879                .await
8880                .unwrap();
8881            assert!(holds_back(&rl).await);
8882        }
8883
8884        /// An order Alpaca accepted (2xx) but whose response does not decode may be live, so it
8885        /// fails as status unknown (`Connectivity`), never as a rejection, and does not panic.
8886        #[tokio::test]
8887        async fn open_order_with_an_undecodable_2xx_response_is_status_unknown() {
8888            use crate::client::ExecutionClient;
8889            use crate::error::OrderError;
8890            use crate::order::request::{OrderRequestOpen, RequestOpen};
8891            use crate::order::state::{InactiveOrderState, OrderState};
8892            use crate::order::{
8893                OrderKey, OrderKind, TimeInForce,
8894                id::{ClientOrderId, StrategyId},
8895            };
8896            use rustrade_instrument::Side;
8897            use rustrade_instrument::exchange::ExchangeId;
8898            use rustrade_instrument::instrument::name::InstrumentNameExchange;
8899
8900            let server = MockServer::start().await;
8901            Mock::given(method("POST"))
8902                .and(path("/v2/orders"))
8903                .respond_with(
8904                    ResponseTemplate::new(200).set_body_json(serde_json::json!({ "id": 1 })),
8905                )
8906                .mount(&server)
8907                .await;
8908
8909            let instrument = InstrumentNameExchange::new("AAPL");
8910            let order = client_for(&server)
8911                .open_order(OrderRequestOpen {
8912                    key: OrderKey {
8913                        exchange: ExchangeId::AlpacaBroker,
8914                        instrument: &instrument,
8915                        strategy: StrategyId::new("test-strategy"),
8916                        cid: ClientOrderId::new("test-cid"),
8917                    },
8918                    state: RequestOpen {
8919                        side: Side::Buy,
8920                        price: None,
8921                        quantity: Decimal::new(10, 0),
8922                        kind: OrderKind::Market,
8923                        time_in_force: TimeInForce::ImmediateOrCancel,
8924                        position_id: None,
8925                        reduce_only: false,
8926                        market: None,
8927                    },
8928                })
8929                .await;
8930
8931            assert!(
8932                matches!(
8933                    order.state,
8934                    OrderState::Inactive(InactiveOrderState::OpenFailed(OrderError::Connectivity(
8935                        _
8936                    )))
8937                ),
8938                "{:?}",
8939                order.state
8940            );
8941        }
8942
8943        /// A 403 refusal, or a 422 refused short, reaches the order's state as the refusal it is,
8944        /// with Alpaca's code, and a 403 without a code (a wrong key) as an authentication failure.
8945        #[tokio::test]
8946        async fn open_order_reports_a_403_by_its_code() {
8947            use crate::client::ExecutionClient;
8948            use crate::error::OrderError;
8949            use crate::order::request::{OrderRequestOpen, RequestOpen};
8950            use crate::order::state::{InactiveOrderState, OrderState};
8951            use crate::order::{
8952                OrderKey, OrderKind, TimeInForce,
8953                id::{ClientOrderId, StrategyId},
8954            };
8955            use rustrade_instrument::Side;
8956            use rustrade_instrument::exchange::ExchangeId;
8957            use rustrade_instrument::instrument::name::InstrumentNameExchange;
8958
8959            async fn open_against(
8960                status: u16,
8961                body: serde_json::Value,
8962            ) -> OrderState<AssetNameExchange, InstrumentNameExchange> {
8963                let server = MockServer::start().await;
8964                Mock::given(method("POST"))
8965                    .and(path("/v2/orders"))
8966                    .respond_with(ResponseTemplate::new(status).set_body_json(body))
8967                    .mount(&server)
8968                    .await;
8969                let instrument = InstrumentNameExchange::new("AAPL");
8970                client_for(&server)
8971                    .open_order(OrderRequestOpen {
8972                        key: OrderKey {
8973                            exchange: ExchangeId::AlpacaBroker,
8974                            instrument: &instrument,
8975                            strategy: StrategyId::new("test-strategy"),
8976                            cid: ClientOrderId::new("test-cid"),
8977                        },
8978                        state: RequestOpen {
8979                            side: Side::Sell,
8980                            price: None,
8981                            quantity: Decimal::new(10, 0),
8982                            kind: OrderKind::Market,
8983                            time_in_force: TimeInForce::ImmediateOrCancel,
8984                            position_id: None,
8985                            reduce_only: false,
8986                            market: None,
8987                        },
8988                    })
8989                    .await
8990                    .state
8991            }
8992
8993            let rejected =
8994                |state: &OrderState<AssetNameExchange, InstrumentNameExchange>| match state {
8995                    OrderState::Inactive(InactiveOrderState::OpenFailed(OrderError::Rejected(
8996                        e,
8997                    ))) => e.clone(),
8998                    other => panic!("expected a rejection, got {other:?}"),
8999                };
9000
9001            let state = open_against(
9002                403,
9003                serde_json::json!({
9004                    "code": 40310000,
9005                    "message": "insufficient buying power"
9006                }),
9007            )
9008            .await;
9009            assert_eq!(
9010                rejected(&state),
9011                ApiError::BalanceInsufficient(None, "40310000 insufficient buying power".into())
9012            );
9013
9014            let state = open_against(
9015                403,
9016                serde_json::json!({
9017                    "code": 40310000,
9018                    "message": "account is not allowed to short"
9019                }),
9020            )
9021            .await;
9022            assert_eq!(
9023                rejected(&state),
9024                ApiError::BorrowRejected(BorrowReject::new(
9025                    BorrowRejectReason::BorrowDisabled,
9026                    Some("40310000".into()),
9027                    "account is not allowed to short".into(),
9028                ))
9029            );
9030
9031            // Which status Alpaca gives a non-shortable asset is unconfirmed; a 422 is read too.
9032            let state = open_against(
9033                422,
9034                serde_json::json!({
9035                    "code": 42210000,
9036                    "message": "asset XYZ cannot be sold short"
9037                }),
9038            )
9039            .await;
9040            assert_eq!(
9041                rejected(&state),
9042                ApiError::BorrowRejected(BorrowReject::new(
9043                    BorrowRejectReason::NotShortable,
9044                    Some("42210000".into()),
9045                    "asset XYZ cannot be sold short".into(),
9046                ))
9047            );
9048
9049            let state = open_against(403, serde_json::json!({ "message": "forbidden." })).await;
9050            assert!(
9051                matches!(rejected(&state), ApiError::Unauthenticated(_)),
9052                "{state:?}"
9053            );
9054        }
9055
9056        /// The bracket path maps an accepted (2xx) but unreadable response the same way: the
9057        /// parent fails as status unknown (`Connectivity`), never as a rejection.
9058        #[tokio::test]
9059        async fn open_bracket_order_with_an_undecodable_2xx_response_is_status_unknown() {
9060            use crate::error::OrderError;
9061            use crate::order::state::{InactiveOrderState, OrderState};
9062            use rustrade_instrument::instrument::name::InstrumentNameExchange;
9063
9064            let server = MockServer::start().await;
9065            Mock::given(method("POST"))
9066                .and(path("/v2/orders"))
9067                .respond_with(
9068                    ResponseTemplate::new(200).set_body_json(serde_json::json!({ "id": 1 })),
9069                )
9070                .mount(&server)
9071                .await;
9072
9073            let result = client_for(&server)
9074                .open_bracket_order(AlpacaBracketOrderRequest::new(
9075                    InstrumentNameExchange::new("SPY"),
9076                    crate::order::id::StrategyId::new("test"),
9077                    crate::order::id::ClientOrderId::new("test-bracket"),
9078                    Side::Buy,
9079                    Decimal::ONE,
9080                    Decimal::new(100, 0),
9081                    Decimal::new(120, 0),
9082                    Decimal::new(90, 0),
9083                    TimeInForce::GoodUntilCancelled { post_only: false },
9084                ))
9085                .await;
9086
9087            assert!(
9088                matches!(
9089                    result.parent.state,
9090                    OrderState::Inactive(InactiveOrderState::OpenFailed(OrderError::Connectivity(
9091                        _
9092                    )))
9093                ),
9094                "{:?}",
9095                result.parent.state
9096            );
9097        }
9098
9099        // -----------------------------------------------------------------------
9100        // recover_fills — what a recovered fill reports, and how it is keyed
9101        // -----------------------------------------------------------------------
9102        //
9103        // These drive `recover_fills` itself rather than re-deriving its arithmetic in the test.
9104        // A key that is correct in isolation is worth nothing if the function does not produce it.
9105
9106        /// The disconnect anchor the recovery tests read from.
9107        fn recovery_after() -> DateTime<Utc> {
9108            "2025-01-01T00:00:00Z".parse().expect("valid time")
9109        }
9110
9111        /// One FILL activity as Alpaca serves it. `cum_qty` is omitted entirely when `None`, which
9112        /// is how the pre-existing fallback path is reached.
9113        fn activity_json(
9114            id: &str,
9115            order_id: &str,
9116            qty: &str,
9117            cum_qty: Option<&str>,
9118        ) -> serde_json::Value {
9119            let mut v = serde_json::json!({
9120                "id": id,
9121                "order_id": order_id,
9122                "symbol": "SPY",
9123                "side": "buy",
9124                "price": "100.00",
9125                "qty": qty,
9126                "transaction_time": "2025-04-18T14:30:00Z"
9127            });
9128            if let Some(c) = cum_qty {
9129                v["cum_qty"] = serde_json::Value::String(c.to_string());
9130            }
9131            v
9132        }
9133
9134        /// The manager clears its reconnect failures only once a connection outlives a heartbeat
9135        /// window. A connection the server accepts and closes at once is a failure like any
9136        /// other, so the waits between such reconnects grow, and a server doing so to every
9137        /// connection exhausts the budget.
9138        #[tokio::test]
9139        async fn the_reconnect_budget_resets_only_once_a_connection_proves_stable() {
9140            use futures::{SinkExt as _, StreamExt as _};
9141            use tokio_tungstenite::tungstenite::Message;
9142
9143            // The third connection receives pings past the heartbeat window; the fourth stays open.
9144            const STABLE: Duration = Duration::from_secs(HEARTBEAT_TIMEOUT_SECS + 3);
9145            let lifetime = |n: usize| match n {
9146                0 | 1 => Some(Duration::from_millis(100)),
9147                2 => Some(STABLE),
9148                _ => None,
9149            };
9150
9151            // A WS server that authorises and acknowledges the listen, pings every second so the
9152            // heartbeat holds, and closes the `n`th connection `lifetime(n)` after its listen.
9153            let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
9154            let ws_url = format!("ws://{}", listener.local_addr().unwrap());
9155            let listens = Arc::new(parking_lot::Mutex::new(Vec::new()));
9156            let seen = Arc::clone(&listens);
9157            tokio::spawn(async move {
9158                let mut connections = 0..;
9159                while let Ok((stream, _)) = listener.accept().await {
9160                    let seen = Arc::clone(&seen);
9161                    let lifetime = connections.next().and_then(lifetime);
9162                    tokio::spawn(async move {
9163                        let Ok(mut ws) = tokio_tungstenite::accept_async(stream).await else {
9164                            return;
9165                        };
9166                        for answer in [
9167                            r#"{"stream":"authorization","data":{"status":"authorized","action":"authenticate"}}"#,
9168                            r#"{"stream":"listening","data":{"streams":["trade_updates"]}}"#,
9169                        ] {
9170                            let Some(Ok(_)) = ws.next().await else {
9171                                return;
9172                            };
9173                            if ws.send(Message::text(answer)).await.is_err() {
9174                                return;
9175                            }
9176                        }
9177                        seen.lock().push(tokio::time::Instant::now());
9178                        let closes = async {
9179                            match lifetime {
9180                                Some(after) => tokio::time::sleep(after).await,
9181                                None => std::future::pending().await,
9182                            }
9183                        };
9184                        tokio::pin!(closes);
9185                        let mut pings = tokio::time::interval(Duration::from_secs(1));
9186                        loop {
9187                            tokio::select! {
9188                                () = &mut closes => {
9189                                    let _ = ws.close(None).await;
9190                                    return;
9191                                }
9192                                _ = pings.tick() => {
9193                                    if ws.send(Message::Ping(Default::default())).await.is_err() {
9194                                        return;
9195                                    }
9196                                }
9197                                message = ws.next() => {
9198                                    if !matches!(message, Some(Ok(_))) {
9199                                        return;
9200                                    }
9201                                }
9202                            }
9203                        }
9204                    });
9205                }
9206            });
9207            // REST answers nothing, so each reconnect's recovery fails at once.
9208            let server = MockServer::start().await;
9209            let mut config =
9210                AlpacaConfig::with_base_url("key".into(), "secret".into(), server.uri());
9211            config.ws_url_override = Some(ws_url);
9212            let (tx, _rx) = mpsc::unbounded_channel();
9213            tokio::spawn(connection_manager(
9214                tx,
9215                new_dedup_cache(),
9216                Arc::new(config),
9217                reqwest::Client::new(),
9218                Arc::new(RateLimitTracker::new()),
9219                KnownLiveOrders::shared(ExchangeId::AlpacaBroker),
9220                Vec::new(),
9221                None,
9222            ));
9223
9224            let deadline = tokio::time::Instant::now() + STABLE + Duration::from_secs(15);
9225            while listens.lock().len() < 4 {
9226                assert!(
9227                    tokio::time::Instant::now() < deadline,
9228                    "{} connections",
9229                    listens.lock().len()
9230                );
9231                tokio::time::sleep(Duration::from_millis(50)).await;
9232            }
9233
9234            let listens = listens.lock();
9235            // Not reset by the second connection's handshake: its close waits the second backoff.
9236            let escalated = listens[2] - listens[1];
9237            assert!(
9238                escalated >= Duration::from_millis(2 * INITIAL_BACKOFF_MS),
9239                "reconnected {escalated:?} after a closed connection"
9240            );
9241            // Reset by the third connection's stability timer: its close waits the first backoff
9242            // again, not the third (4 s).
9243            let after_stable = listens[3] - listens[2] - STABLE;
9244            assert!(
9245                after_stable < Duration::from_millis(3 * INITIAL_BACKOFF_MS),
9246                "reconnected {after_stable:?} after a stable connection"
9247            );
9248        }
9249
9250        /// Serve `activities` from a mock and run `recover_fills` against it, returning every
9251        /// event it forwarded.
9252        async fn drive_recover_fills_with(
9253            activities: Vec<serde_json::Value>,
9254            dedup: SharedDedupCache,
9255            known: &SharedKnownLiveOrders,
9256        ) -> Vec<UnindexedAccountEvent> {
9257            let server = MockServer::start().await;
9258            Mock::given(method("GET"))
9259                .and(path("/v2/account/activities"))
9260                .respond_with(
9261                    ResponseTemplate::new(200).set_body_json(serde_json::Value::Array(activities)),
9262                )
9263                .mount(&server)
9264                .await;
9265
9266            let http = reqwest::Client::new();
9267            let rl = RateLimitTracker::new();
9268            let (tx, mut rx) = mpsc::unbounded_channel();
9269            let outcome = recover_fills(
9270                &http,
9271                &rl,
9272                &[],
9273                &server.uri(),
9274                recovery_after(),
9275                &tx,
9276                &dedup,
9277                known,
9278            )
9279            .await;
9280            assert_eq!(outcome, Ok(()), "a full read leaves nothing unrecovered");
9281            drop(tx);
9282
9283            let mut out = Vec::new();
9284            while let Ok(event) = rx.try_recv() {
9285                out.push(event);
9286            }
9287            out
9288        }
9289
9290        async fn drive_recover_fills(
9291            activities: Vec<serde_json::Value>,
9292        ) -> Vec<UnindexedAccountEvent> {
9293            drive_recover_fills_with(
9294                activities,
9295                new_dedup_cache(),
9296                &KnownLiveOrders::shared(ExchangeId::AlpacaBroker),
9297            )
9298            .await
9299        }
9300
9301        /// Run `recover_fills` for every instrument against `server`, returning what it left
9302        /// unread and every event it forwarded.
9303        async fn recover_fills_from(
9304            server: &MockServer,
9305        ) -> (Result<(), UnreadFills>, Vec<UnindexedAccountEvent>) {
9306            let (tx, mut rx) = mpsc::unbounded_channel();
9307            let outcome = recover_fills(
9308                &reqwest::Client::new(),
9309                &RateLimitTracker::new(),
9310                &[],
9311                &server.uri(),
9312                recovery_after(),
9313                &tx,
9314                &new_dedup_cache(),
9315                &KnownLiveOrders::shared(ExchangeId::AlpacaBroker),
9316            )
9317            .await;
9318            drop(tx);
9319            (outcome, std::iter::from_fn(|| rx.try_recv().ok()).collect())
9320        }
9321
9322        /// A recovery read that fails delivers nothing and leaves every fill since the disconnect
9323        /// unread, with the request's error.
9324        #[tokio::test]
9325        async fn a_failed_recovery_read_leaves_every_fill_since_the_disconnect_unread() {
9326            let server = MockServer::start().await;
9327            Mock::given(method("GET"))
9328                .and(path("/v2/account/activities"))
9329                .respond_with(ResponseTemplate::new(500).set_body_string("unavailable"))
9330                .mount(&server)
9331                .await;
9332
9333            let (outcome, events) = recover_fills_from(&server).await;
9334
9335            let Err(UnreadFills {
9336                start,
9337                reason: FillRecoveryFailure::Request(_),
9338            }) = outcome
9339            else {
9340                panic!("expected the request's failure, got {outcome:?}");
9341            };
9342            assert_eq!(start, recovery_after(), "from the disconnect");
9343            assert!(events.is_empty(), "nothing forwarded: {events:?}");
9344        }
9345
9346        /// A recovery read that stops at the page cap delivers the fills it read, then leaves
9347        /// those from the millisecond of the last one on unread: Alpaca orders a millisecond's
9348        /// fills by id, so the cut may have passed over an earlier one in it. A last time that
9349        /// does not parse falls back to the latest that does, and none parsing to the disconnect.
9350        #[tokio::test]
9351        async fn a_truncated_recovery_read_delivers_what_it_read_and_leaves_the_rest_unread() {
9352            let time = |s: &str| s.parse::<DateTime<Utc>>().expect("valid time");
9353            // The page's other activities are at 14:30:00.
9354            let cases = [
9355                (
9356                    Some("2025-04-18T14:30:01.234567Z"),
9357                    time("2025-04-18T14:30:01.234Z"),
9358                ),
9359                (Some("not a time"), time("2025-04-18T14:30:00Z")),
9360                (None, recovery_after()),
9361            ];
9362            for (last_time, expected_start) in cases {
9363                let mut page = make_activities_json(ALPACA_MAX_ACTIVITIES, "act");
9364                let Some(activities) = page.as_array_mut() else {
9365                    panic!("an array of activities");
9366                };
9367                match last_time {
9368                    Some(last_time) => {
9369                        if let Some(last) = activities.last_mut() {
9370                            last["transaction_time"] =
9371                                serde_json::Value::String(last_time.to_string());
9372                        }
9373                    }
9374                    None => {
9375                        for activity in activities.iter_mut() {
9376                            activity["transaction_time"] =
9377                                serde_json::Value::String("not a time".to_string());
9378                        }
9379                    }
9380                }
9381                let server = MockServer::start().await;
9382                Mock::given(method("GET"))
9383                    .and(path("/v2/account/activities"))
9384                    .respond_with(ResponseTemplate::new(200).set_body_json(page))
9385                    .mount(&server)
9386                    .await;
9387
9388                let (outcome, events) = recover_fills_from(&server).await;
9389
9390                let fills_read = MAX_ACTIVITY_PAGES * ALPACA_MAX_ACTIVITIES;
9391                assert_eq!(
9392                    outcome,
9393                    Err(UnreadFills {
9394                        start: expected_start,
9395                        reason: FillRecoveryFailure::Truncated { fills_read },
9396                    }),
9397                    "last time {last_time:?}"
9398                );
9399                assert_eq!(events.len(), fills_read, "every fill read is forwarded");
9400            }
9401        }
9402
9403        /// One account-wide read serves every instrument the stream covers, so one give-up names
9404        /// them all: the stream's list, or every instrument when it has none. It was read once.
9405        #[test]
9406        fn a_fill_recovery_give_up_covers_every_instrument_the_stream_does() {
9407            let start = recovery_after();
9408            let end = start + chrono::Duration::minutes(5);
9409            let spy = InstrumentNameExchange::new("SPY");
9410            let qqq = InstrumentNameExchange::new("QQQ");
9411            let cases = [
9412                (vec![], FillRecoveryScope::AllInstruments),
9413                (
9414                    vec![spy.clone(), qqq.clone()],
9415                    FillRecoveryScope::Instruments(vec![spy, qqq]),
9416                ),
9417            ];
9418            for (instruments, scope) in cases {
9419                let reason = FillRecoveryFailure::TimedOut { timeout_secs: 30 };
9420                let unread = UnreadFills {
9421                    start,
9422                    reason: reason.clone(),
9423                };
9424                assert_eq!(
9425                    fill_recovery_gave_up(&instruments, unread, end),
9426                    UnindexedAccountEvent::new(
9427                        ExchangeId::AlpacaBroker,
9428                        AccountEventKind::FillRecoveryGaveUp(FillRecoveryGap::new(
9429                            scope, start, end, 1, reason,
9430                        )),
9431                    )
9432                );
9433            }
9434        }
9435
9436        /// A truncated read can return fills stamped after the moment recovery began, by a venue
9437        /// clock ahead of this host's. The give-up is still sent, its span ending at its start
9438        /// rather than before it.
9439        #[test]
9440        fn a_read_that_reached_past_the_recovery_start_still_reports() {
9441            let end = recovery_after();
9442            let start = end + chrono::Duration::seconds(1);
9443            let unread = UnreadFills {
9444                start,
9445                reason: FillRecoveryFailure::Truncated { fills_read: 5_000 },
9446            };
9447            let event = fill_recovery_gave_up(&[], unread, end);
9448            let AccountEventKind::FillRecoveryGaveUp(gave_up) = event.kind else {
9449                panic!("expected FillRecoveryGaveUp, got {event:?}");
9450            };
9451            assert_eq!((gave_up.start, gave_up.end), (start, start));
9452        }
9453
9454        /// Run `recover_fills_or_report` for `instruments` against `server` within `timeout`,
9455        /// returning every event it sent.
9456        async fn recover_fills_or_report_from(
9457            server: &MockServer,
9458            instruments: &[InstrumentNameExchange],
9459            timeout: Duration,
9460        ) -> Vec<UnindexedAccountEvent> {
9461            let (tx, mut rx) = mpsc::unbounded_channel();
9462            recover_fills_or_report(
9463                &reqwest::Client::new(),
9464                &RateLimitTracker::new(),
9465                instruments,
9466                &server.uri(),
9467                recovery_after(),
9468                timeout,
9469                &tx,
9470                &new_dedup_cache(),
9471                &KnownLiveOrders::shared(ExchangeId::AlpacaBroker),
9472            )
9473            .await;
9474            drop(tx);
9475            std::iter::from_fn(|| rx.try_recv().ok()).collect()
9476        }
9477
9478        /// A recovery that times out forwards nothing and reports every fill since the disconnect,
9479        /// for the stream's instruments.
9480        #[tokio::test]
9481        async fn a_timed_out_recovery_reports_every_fill_since_the_disconnect() {
9482            let server = MockServer::start().await;
9483            Mock::given(method("GET"))
9484                .and(path("/v2/account/activities"))
9485                .respond_with(
9486                    ResponseTemplate::new(200)
9487                        .set_body_json(serde_json::json!([]))
9488                        .set_delay(Duration::from_secs(3_600)),
9489                )
9490                .mount(&server)
9491                .await;
9492            let spy = InstrumentNameExchange::new("SPY");
9493
9494            let events = recover_fills_or_report_from(
9495                &server,
9496                std::slice::from_ref(&spy),
9497                Duration::from_millis(50),
9498            )
9499            .await;
9500
9501            let [event] = events.as_slice() else {
9502                panic!("only the give-up is sent: {events:?}");
9503            };
9504            let AccountEventKind::FillRecoveryGaveUp(gave_up) = &event.kind else {
9505                panic!("expected FillRecoveryGaveUp, got {event:?}");
9506            };
9507            assert_eq!(gave_up.scope, FillRecoveryScope::Instruments(vec![spy]));
9508            assert_eq!(gave_up.start, recovery_after(), "from the disconnect");
9509            assert!(gave_up.end >= gave_up.start);
9510            assert_eq!(gave_up.attempts, 1);
9511            assert!(
9512                matches!(gave_up.reason, FillRecoveryFailure::TimedOut { .. }),
9513                "{:?}",
9514                gave_up.reason
9515            );
9516        }
9517
9518        /// A truncated recovery sends every fill it read before the give-up, so a consumer sees the
9519        /// report after what was delivered.
9520        #[tokio::test]
9521        async fn a_truncated_recovery_reports_after_the_fills_it_read() {
9522            let server = MockServer::start().await;
9523            Mock::given(method("GET"))
9524                .and(path("/v2/account/activities"))
9525                .respond_with(
9526                    ResponseTemplate::new(200)
9527                        .set_body_json(make_activities_json(ALPACA_MAX_ACTIVITIES, "act")),
9528                )
9529                .mount(&server)
9530                .await;
9531
9532            let events = recover_fills_or_report_from(&server, &[], Duration::from_secs(30)).await;
9533
9534            let Some((last, fills)) = events.split_last() else {
9535                panic!("events are sent");
9536            };
9537            assert_eq!(fills.len(), MAX_ACTIVITY_PAGES * ALPACA_MAX_ACTIVITIES);
9538            assert!(
9539                fills
9540                    .iter()
9541                    .all(|event| matches!(event.kind, AccountEventKind::Trade(_))),
9542                "every fill comes first"
9543            );
9544            let AccountEventKind::FillRecoveryGaveUp(gave_up) = &last.kind else {
9545                panic!("the give-up comes last, got {last:?}");
9546            };
9547            assert_eq!(gave_up.scope, FillRecoveryScope::AllInstruments);
9548            assert!(matches!(
9549                gave_up.reason,
9550                FillRecoveryFailure::Truncated { .. }
9551            ));
9552        }
9553
9554        /// A FILL activity's execution id is the part of its `id` after `::`; an id without one is
9555        /// taken whole.
9556        #[test]
9557        fn an_activity_id_names_its_execution_after_the_separator() {
9558            assert_eq!(
9559                activity_execution_id("20261005041849348::524b1902-817e-446c-825b-a9fcfebbc17e"),
9560                "524b1902-817e-446c-825b-a9fcfebbc17e"
9561            );
9562            assert_eq!(activity_execution_id("act-1"), "act-1");
9563            assert_eq!(
9564                activity_execution_id("20261005041849348::"),
9565                "20261005041849348::"
9566            );
9567        }
9568
9569        /// One fill carries one `TradeId` however it is delivered: over the account stream, by
9570        /// reconnect recovery, and by `fetch_trades`. So a consumer reconciling with `fetch_trades`
9571        /// matches what the stream already delivered.
9572        #[tokio::test]
9573        async fn a_fill_has_one_trade_id_on_the_stream_in_recovery_and_from_fetch_trades() {
9574            use crate::client::ExecutionClient;
9575
9576            let execution_id = "524b1902-817e-446c-825b-a9fcfebbc17e";
9577            let activity = activity_json(
9578                &format!("20250418103000000::{execution_id}"),
9579                "ord-1",
9580                "2",
9581                Some("2"),
9582            );
9583
9584            let [streamed, _snapshot] = convert_trade_update(AlpacaTradeUpdate {
9585                event: SmolStr::new("fill"),
9586                execution_id: Some(execution_id),
9587                previous_execution_id: None,
9588                order: super::make_order_ws("ord-1", "SPY", "buy", "2"),
9589                price: Some("100.00"),
9590                qty: Some("2"),
9591                timestamp: Some("2025-04-18T14:30:00Z"),
9592            });
9593            let Some(streamed) = streamed else {
9594                panic!("a fill converts to a Trade");
9595            };
9596
9597            let recovered = drive_recover_fills(vec![activity.clone()]).await;
9598            let [recovered] = recovered.as_slice() else {
9599                panic!("one fill recovered: {recovered:?}");
9600            };
9601
9602            let server = MockServer::start().await;
9603            Mock::given(method("GET"))
9604                .and(path("/v2/account/activities"))
9605                .respond_with(
9606                    ResponseTemplate::new(200)
9607                        .set_body_json(serde_json::Value::Array(vec![activity])),
9608                )
9609                .mount(&server)
9610                .await;
9611            let Ok(fetched) = client_for(&server)
9612                .fetch_trades(recovery_after(), Utc::now(), &[])
9613                .await
9614            else {
9615                panic!("fetch_trades reads the activity");
9616            };
9617            let [fetched] = fetched.trades.as_slice() else {
9618                panic!("one fill fetched: {fetched:?}");
9619            };
9620
9621            assert_eq!(trade_of(&streamed).id.0.as_str(), execution_id);
9622            assert_eq!(trade_of(recovered).id.0.as_str(), execution_id);
9623            assert_eq!(fetched.id.0.as_str(), execution_id);
9624        }
9625
9626        // -----------------------------------------------------------------------
9627        // fetch_trades — bounded reads of a span, resumed by the caller
9628        // -----------------------------------------------------------------------
9629
9630        fn time(s: &str) -> DateTime<Utc> {
9631            s.parse().expect("valid time")
9632        }
9633
9634        /// Alpaca's activities endpoint over `activities`, which are sorted by id, as observed on
9635        /// paper: `after` matches an activity whose time, rounded down to the millisecond, is at
9636        /// or after it, `until` one whose rounded time is before it, and `page_token` resumes
9637        /// after the activity it names.
9638        struct ActivitiesVenue {
9639            activities: Vec<serde_json::Value>,
9640        }
9641
9642        impl Respond for ActivitiesVenue {
9643            fn respond(&self, request: &Request) -> ResponseTemplate {
9644                let query: std::collections::HashMap<String, String> =
9645                    request.url.query_pairs().into_owned().collect();
9646                let bound = |name: &str| query.get(name).map(|s| time(s));
9647                let (after, until) = (bound("after"), bound("until"));
9648                let size: usize = query
9649                    .get("page_size")
9650                    .and_then(|s| s.parse().ok())
9651                    .unwrap_or(ALPACA_MAX_ACTIVITIES);
9652                let in_bounds = self.activities.iter().filter(|activity| {
9653                    let at =
9654                        floor_millis(time(activity["transaction_time"].as_str().expect("a time")));
9655                    after.is_none_or(|after| at >= after) && until.is_none_or(|until| at < until)
9656                });
9657                let page: Vec<_> = match query.get("page_token") {
9658                    Some(token) => in_bounds
9659                        .skip_while(|activity| activity["id"] != token.as_str())
9660                        .skip(1)
9661                        .take(size)
9662                        .cloned()
9663                        .collect(),
9664                    None => in_bounds.take(size).cloned().collect(),
9665                };
9666                ResponseTemplate::new(200).set_body_json(page)
9667            }
9668        }
9669
9670        /// A FILL activity on `symbol` with id `id` at `transaction_time`.
9671        fn fill_at(id: &str, symbol: &str, transaction_time: DateTime<Utc>) -> serde_json::Value {
9672            serde_json::json!({
9673                "id": id,
9674                "order_id": format!("ord-{id}"),
9675                "symbol": symbol,
9676                "side": "buy",
9677                "price": "100.00",
9678                "qty": "1",
9679                "cum_qty": "1",
9680                "transaction_time": transaction_time.to_rfc3339_opts(SecondsFormat::Micros, true),
9681            })
9682        }
9683
9684        async fn serve_activities(activities: Vec<serde_json::Value>) -> MockServer {
9685            let server = MockServer::start().await;
9686            Mock::given(method("GET"))
9687                .and(path("/v2/account/activities"))
9688                .respond_with(ActivitiesVenue { activities })
9689                .mount(&server)
9690                .await;
9691            server
9692        }
9693
9694        /// A non-trade activity of `activity_type` and `sub_type`, for `net_amount`, created at
9695        /// `created_at`.
9696        fn non_trade(
9697            id: &str,
9698            activity_type: &str,
9699            sub_type: Option<&str>,
9700            net_amount: &str,
9701            created_at: &str,
9702        ) -> serde_json::Value {
9703            let mut activity = serde_json::json!({
9704                "id": id, "activity_type": activity_type, "net_amount": net_amount,
9705                "created_at": created_at, "date": &created_at[..10], "currency": "USD",
9706                "status": "executed",
9707            });
9708            if let Some(sub_type) = sub_type {
9709                activity["activity_sub_type"] = serde_json::json!(sub_type);
9710            }
9711            activity
9712        }
9713
9714        /// The query of every request `server` received.
9715        async fn query_pairs(server: &MockServer) -> Vec<Vec<(String, String)>> {
9716            let Some(requests) = server.received_requests().await else {
9717                panic!("requests are recorded");
9718            };
9719            requests
9720                .iter()
9721                .map(|request| request.url.query_pairs().into_owned().collect())
9722                .collect()
9723        }
9724
9725        #[tokio::test]
9726        async fn fetch_cash_flows_maps_each_activity_type_and_leaves_out_what_it_cannot_use() {
9727            let at = "2025-04-18T05:00:00Z";
9728            let mut fee = non_trade("fee", "FEE", Some("TAF"), "-0.02", at);
9729            fee["symbol"] = serde_json::json!("SPY");
9730            let mut canceled = non_trade("canceled", "FEE", Some("REG"), "-0.01", at);
9731            canceled["status"] = serde_json::json!("canceled");
9732            let mut no_amount = non_trade("no-amount", "FEE", None, "0", at);
9733            no_amount.as_object_mut().unwrap().remove("net_amount");
9734            let mut no_created_at = non_trade("no-time", "PTC", None, "-0.50", at);
9735            no_created_at.as_object_mut().unwrap().remove("created_at");
9736            let mut bad_time = non_trade("bad-time", "PTC", None, "-0.50", at);
9737            bad_time["created_at"] = serde_json::json!("not-a-time");
9738            let mut corrected = non_trade("correct", "PTC", None, "-0.40", at);
9739            corrected["status"] = serde_json::json!("correct");
9740            let mut euro = non_trade("euro", "PTR", None, "0.30", at);
9741            euro["currency"] = serde_json::json!("EUR");
9742            let server = MockServer::start().await;
9743            Mock::given(method("GET"))
9744                .and(path("/v2/account/activities"))
9745                .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([
9746                    non_trade("mgn", "INT", Some("MGN"), "-12.50", at),
9747                    non_trade("int-paid", "INT", None, "-3.00", at),
9748                    non_trade("int-credit", "INT", Some("CDT"), "1.00", at),
9749                    fee,
9750                    canceled,
9751                    no_amount,
9752                    no_created_at,
9753                    bad_time,
9754                    corrected,
9755                    euro,
9756                    non_trade("late", "FEE", None, "-0.01", "2025-04-20T05:00:00Z"),
9757                ])))
9758                .mount(&server)
9759                .await;
9760            let client = client_for(&server);
9761
9762            let read = client
9763                .fetch_cash_flows(time("2025-04-18T00:00:00Z"), time("2025-04-19T00:00:00Z"))
9764                .await
9765                .unwrap();
9766
9767            assert_eq!(read.resume, None);
9768            let kinds: Vec<_> = read
9769                .flows
9770                .iter()
9771                .map(|flow| (flow.id.clone().unwrap().0.to_string(), flow.kind.clone()))
9772                .collect();
9773            let interest = CashFlowKind::MarginInterest {
9774                rate: None,
9775                principal: None,
9776            };
9777            assert_eq!(
9778                kinds,
9779                [
9780                    ("mgn", interest.clone()),
9781                    ("int-paid", interest),
9782                    ("int-credit", CashFlowKind::Other("INT.CDT".into())),
9783                    ("fee", CashFlowKind::Other("FEE.TAF".into())),
9784                    ("correct", CashFlowKind::Other("PTC".into())),
9785                    ("euro", CashFlowKind::Rebate),
9786                ]
9787                .map(|(id, kind)| (id.to_owned(), kind))
9788            );
9789            let by_id = |id: &str| {
9790                read.flows
9791                    .iter()
9792                    .find(|flow| flow.id == Some(CashFlowId::new(id)))
9793                    .unwrap()
9794            };
9795            assert_eq!(by_id("mgn").amount, Decimal::new(-1250, 2));
9796            assert_eq!(by_id("mgn").asset, AssetNameExchange::new("USD"));
9797            assert_eq!(by_id("mgn").instrument, None);
9798            assert_eq!(by_id("mgn").time_exchange, time(at));
9799            assert_eq!(
9800                by_id("fee").instrument,
9801                Some(InstrumentNameExchange::new("SPY"))
9802            );
9803            assert_eq!(
9804                by_id("correct").amount,
9805                Decimal::new(-40, 2),
9806                "a correction is returned as it is"
9807            );
9808            assert_eq!(by_id("euro").asset, AssetNameExchange::new("EUR"));
9809
9810            let queries = query_pairs(&server).await;
9811            let [query] = queries.as_slice() else {
9812                panic!("one request: {queries:?}");
9813            };
9814            assert!(
9815                query.contains(&("activity_types".into(), "INT,FEE,PTC,PTR".into())),
9816                "{query:?}"
9817            );
9818        }
9819
9820        #[tokio::test]
9821        async fn fetch_trades_asks_for_fills_by_the_documented_parameter() {
9822            use crate::client::ExecutionClient;
9823
9824            let server = serve_activities(Vec::new()).await;
9825            let client = client_for(&server);
9826
9827            let read = client
9828                .fetch_trades(
9829                    time("2025-04-18T00:00:00Z"),
9830                    time("2025-04-19T00:00:00Z"),
9831                    &[],
9832                )
9833                .await
9834                .unwrap();
9835
9836            assert!(read.trades.is_empty());
9837            let queries = query_pairs(&server).await;
9838            let [query] = queries.as_slice() else {
9839                panic!("one request: {queries:?}");
9840            };
9841            assert!(
9842                query.contains(&("activity_types".into(), "FILL".into())),
9843                "{query:?}"
9844            );
9845            assert!(
9846                !query.iter().any(|(name, _)| name == "activity_type"),
9847                "{query:?}"
9848            );
9849        }
9850
9851        #[tokio::test]
9852        async fn fetch_cash_flows_reads_nothing_for_an_empty_span() {
9853            let server = MockServer::start().await;
9854            let client = client_for(&server);
9855
9856            let read = client
9857                .fetch_cash_flows(time("2025-04-19T00:00:00Z"), time("2025-04-18T00:00:00Z"))
9858                .await
9859                .unwrap();
9860
9861            assert!(read.flows.is_empty() && read.resume.is_none());
9862            assert!(query_pairs(&server).await.is_empty());
9863        }
9864
9865        /// A span holding more activities than one call reads returns those it read and says
9866        /// where to read on from: the millisecond of the last one's creation.
9867        #[tokio::test]
9868        async fn fetch_cash_flows_that_stops_at_its_bound_says_where_to_read_on() {
9869            let base = time("2025-04-18T05:00:00Z");
9870            let activities: Vec<_> = (0..6_000_i64)
9871                .map(|n| {
9872                    let created =
9873                        (base + TimeDelta::seconds(n)).to_rfc3339_opts(SecondsFormat::Micros, true);
9874                    non_trade(&format!("{n:017}::fee"), "FEE", None, "-0.01", &created)
9875                })
9876                .collect();
9877            // Every page answers in order after the token, as Alpaca pages by id.
9878            struct Paged(Vec<serde_json::Value>);
9879            impl Respond for Paged {
9880                fn respond(&self, request: &Request) -> ResponseTemplate {
9881                    let token = request
9882                        .url
9883                        .query_pairs()
9884                        .find(|(name, _)| name == "page_token")
9885                        .map(|(_, value)| value.into_owned());
9886                    let page: Vec<_> = self
9887                        .0
9888                        .iter()
9889                        .skip_while(|activity| {
9890                            token
9891                                .as_deref()
9892                                .is_some_and(|token| activity["id"] != token)
9893                        })
9894                        .skip(usize::from(token.is_some()))
9895                        .take(ALPACA_MAX_ACTIVITIES)
9896                        .cloned()
9897                        .collect();
9898                    ResponseTemplate::new(200).set_body_json(page)
9899                }
9900            }
9901            let server = MockServer::start().await;
9902            Mock::given(method("GET"))
9903                .and(path("/v2/account/activities"))
9904                .respond_with(Paged(activities))
9905                .mount(&server)
9906                .await;
9907            let client = client_for(&server);
9908
9909            let read = client
9910                .fetch_cash_flows(base, base + TimeDelta::days(1))
9911                .await
9912                .unwrap();
9913
9914            assert_eq!(read.flows.len(), 5_000);
9915            assert_eq!(read.resume, Some(base + TimeDelta::seconds(4_999)));
9916        }
9917
9918        /// A read whose every page holds activities of the span's first millisecond cannot
9919        /// advance, so it fails rather than return a `resume` that would loop.
9920        #[tokio::test]
9921        async fn fetch_cash_flows_that_cannot_advance_fails() {
9922            let at = "2025-04-18T05:00:00Z";
9923            let full: Vec<_> = (0..ALPACA_MAX_ACTIVITIES)
9924                .map(|n| non_trade(&format!("fee-{n}"), "FEE", None, "-0.01", at))
9925                .collect();
9926            let server = MockServer::start().await;
9927            Mock::given(method("GET"))
9928                .and(path("/v2/account/activities"))
9929                .respond_with(ResponseTemplate::new(200).set_body_json(full))
9930                .mount(&server)
9931                .await;
9932            let client = client_for(&server);
9933
9934            let result = client
9935                .fetch_cash_flows(time(at), time("2025-04-19T00:00:00Z"))
9936                .await;
9937
9938            assert!(
9939                matches!(result, Err(UnindexedClientError::Internal(_))),
9940                "{result:?}"
9941            );
9942        }
9943
9944        /// An option activity of `activity_type` on `symbol`, for the signed `qty`, dated `date`,
9945        /// with no creation time.
9946        fn option_activity(
9947            id: &str,
9948            activity_type: &str,
9949            symbol: &str,
9950            qty: &str,
9951            date: &str,
9952        ) -> serde_json::Value {
9953            serde_json::json!({
9954                "id": id, "activity_type": activity_type, "symbol": symbol, "qty": qty,
9955                "date": date, "net_amount": "0", "status": "executed",
9956            })
9957        }
9958
9959        /// The share leg (`OPTRD`) of an exercise or assignment, at `price`.
9960        fn share_leg(
9961            id: &str,
9962            symbol: &str,
9963            qty: &str,
9964            price: &str,
9965            date: &str,
9966        ) -> serde_json::Value {
9967            let mut activity = option_activity(id, "OPTRD", symbol, qty, date);
9968            activity["price"] = serde_json::json!(price);
9969            activity
9970        }
9971
9972        async fn serve_json(activities: serde_json::Value) -> MockServer {
9973            let server = MockServer::start().await;
9974            Mock::given(method("GET"))
9975                .and(path("/v2/account/activities"))
9976                .respond_with(ResponseTemplate::new(200).set_body_json(activities))
9977                .mount(&server)
9978                .await;
9979            server
9980        }
9981
9982        #[test]
9983        fn occ_contract_reads_the_root_kind_and_strike() {
9984            assert_eq!(
9985                occ_contract("QS240301C00006500"),
9986                Some(("QS", true, Decimal::new(65, 1)))
9987            );
9988            assert_eq!(
9989                occ_contract("AAPL240119P00150000"),
9990                Some(("AAPL", false, Decimal::from(150)))
9991            );
9992            for not_occ in [
9993                "QS",
9994                "240301C00006500",
9995                "QS240301X00006500",
9996                "QS2403O1C00006500",
9997            ] {
9998                assert_eq!(occ_contract(not_occ), None, "{not_occ}");
9999            }
10000        }
10001
10002        #[tokio::test]
10003        async fn fetch_option_events_maps_each_activity_and_pairs_the_share_legs() {
10004            let day = "2024-03-01";
10005            let mut expiry = option_activity("exp", "OPEXP", "QS240301C00010500", "-1", day);
10006            expiry["created_at"] = serde_json::json!("2024-03-01T21:30:00Z");
10007            let mut canceled = option_activity("canceled", "OPEXP", "QS240301P00000500", "-1", day);
10008            canceled["status"] = serde_json::json!("canceled");
10009            let server = serve_json(serde_json::json!([
10010                expiry,
10011                option_activity("exc", "OPEXC", "QS240301C00006500", "-1", day),
10012                share_leg("exc-shares", "QS", "100", "6.5", day),
10013                option_activity("asn", "OPASN", "QS240301P00009000", "1", day),
10014                share_leg("asn-shares", "QS", "100", "9", day),
10015                share_leg("orphan", "QS", "-100", "7", day),
10016                canceled,
10017                option_activity("no-price", "OPTRD", "QS", "100", day),
10018                option_activity("no-qty", "OPEXP", "QS240301C00011000", "0", day),
10019                option_activity("later", "OPEXP", "QS240308C00010500", "-1", "2024-03-02"),
10020            ]))
10021            .await;
10022            let client = client_for(&server);
10023
10024            let read = client
10025                .fetch_option_events(time("2024-03-01T00:00:00Z"), time("2024-03-01T23:00:00Z"))
10026                .await
10027                .unwrap();
10028
10029            assert_eq!(read.resume, None);
10030            let summary: Vec<_> = read
10031                .trades
10032                .iter()
10033                .map(|trade| {
10034                    (
10035                        trade.id.0.to_string(),
10036                        trade.instrument.name().to_string(),
10037                        trade.origin,
10038                        trade.side,
10039                        trade.price,
10040                        trade.quantity,
10041                    )
10042                })
10043                .collect();
10044            let row = |id: &str, symbol: &str, origin, side, price: Decimal, quantity: i64| {
10045                (
10046                    id.to_owned(),
10047                    symbol.to_owned(),
10048                    origin,
10049                    side,
10050                    price,
10051                    Decimal::from(quantity),
10052                )
10053            };
10054            assert_eq!(
10055                summary,
10056                [
10057                    row(
10058                        "exp",
10059                        "QS240301C00010500",
10060                        TradeOrigin::Expiry,
10061                        Side::Sell,
10062                        Decimal::ZERO,
10063                        1
10064                    ),
10065                    row(
10066                        "exc",
10067                        "QS240301C00006500",
10068                        TradeOrigin::Exercise,
10069                        Side::Sell,
10070                        Decimal::ZERO,
10071                        1
10072                    ),
10073                    row(
10074                        "exc-shares",
10075                        "QS",
10076                        TradeOrigin::Exercise,
10077                        Side::Buy,
10078                        Decimal::new(65, 1),
10079                        100
10080                    ),
10081                    row(
10082                        "asn",
10083                        "QS240301P00009000",
10084                        TradeOrigin::Assignment,
10085                        Side::Buy,
10086                        Decimal::ZERO,
10087                        1
10088                    ),
10089                    row(
10090                        "asn-shares",
10091                        "QS",
10092                        TradeOrigin::Assignment,
10093                        Side::Buy,
10094                        Decimal::from(9),
10095                        100
10096                    ),
10097                    row(
10098                        "orphan",
10099                        "QS",
10100                        TradeOrigin::External,
10101                        Side::Sell,
10102                        Decimal::from(7),
10103                        100
10104                    ),
10105                ]
10106            );
10107            let exp = &read.trades[0];
10108            assert_eq!(
10109                exp.time_exchange,
10110                time("2024-03-01T21:30:00Z"),
10111                "created_at"
10112            );
10113            assert_eq!(exp.order_id, OrderId::new("exp"));
10114            assert_eq!(exp.fees.fees, Decimal::ZERO);
10115            assert_eq!(
10116                read.trades[1].time_exchange,
10117                time("2024-03-01T00:00:00Z"),
10118                "no created_at: the start of its date"
10119            );
10120
10121            let queries = query_pairs(&server).await;
10122            let [query] = queries.as_slice() else {
10123                panic!("one request: {queries:?}");
10124            };
10125            assert!(
10126                query.contains(&("activity_types".into(), "OPASN,OPEXC,OPEXP,OPTRD".into())),
10127                "{query:?}"
10128            );
10129        }
10130
10131        /// A share leg pairs with the exercise or assignment whose kind implies its side, not with
10132        /// another at the same strike, and each pairs once.
10133        #[tokio::test]
10134        async fn fetch_option_events_pairs_a_share_leg_by_its_side() {
10135            let day = "2024-03-01";
10136            let server = serve_json(serde_json::json!([
10137                option_activity("exc-call", "OPEXC", "QS240301C00006500", "-1", day),
10138                option_activity("asn-call", "OPASN", "QS240301C00006500", "1", day),
10139                share_leg("sold", "QS", "-100", "6.5", day),
10140                share_leg("bought", "QS", "100", "6.5", day),
10141                share_leg("bought-again", "QS", "100", "6.5", day),
10142                share_leg("other-day", "QS", "100", "6.5", "2024-02-29"),
10143            ]))
10144            .await;
10145            let client = client_for(&server);
10146
10147            let read = client
10148                .fetch_option_events(time("2024-02-29T00:00:00Z"), time("2024-03-01T23:00:00Z"))
10149                .await
10150                .unwrap();
10151
10152            let origin = |id: &str| {
10153                read.trades
10154                    .iter()
10155                    .find(|trade| trade.id == TradeId::new(id))
10156                    .map(|trade| trade.origin)
10157            };
10158            assert_eq!(origin("sold"), Some(TradeOrigin::Assignment));
10159            assert_eq!(origin("bought"), Some(TradeOrigin::Exercise));
10160            assert_eq!(origin("bought-again"), Some(TradeOrigin::External));
10161            assert_eq!(origin("other-day"), Some(TradeOrigin::External));
10162        }
10163
10164        /// A share leg pairs with the exercise or assignment whose contracts deliver its shares,
10165        /// 100 a contract, though another that would buy them comes first. One of another size
10166        /// still pairs, with the first match left.
10167        #[tokio::test]
10168        async fn fetch_option_events_pairs_a_share_leg_by_its_size() {
10169            let day = "2024-03-01";
10170            let server = serve_json(serde_json::json!([
10171                // Being assigned on a put and exercising a call both buy at the strike.
10172                option_activity("asn-two", "OPASN", "QS240301P00006500", "2", day),
10173                option_activity("exc-one", "OPEXC", "QS240301C00006500", "-1", day),
10174                option_activity("exc-adjusted", "OPEXC", "QS240301C00006500", "-1", day),
10175                share_leg("hundred", "QS", "100", "6.5", day),
10176                share_leg("two-hundred", "QS", "200", "6.5", day),
10177                share_leg("hundred-fifty", "QS", "150", "6.5", day),
10178            ]))
10179            .await;
10180            let client = client_for(&server);
10181
10182            let read = client
10183                .fetch_option_events(time("2024-03-01T00:00:00Z"), time("2024-03-01T23:00:00Z"))
10184                .await
10185                .unwrap();
10186
10187            let origin = |id: &str| {
10188                read.trades
10189                    .iter()
10190                    .find(|trade| trade.id == TradeId::new(id))
10191                    .map(|trade| trade.origin)
10192            };
10193            assert_eq!(origin("hundred"), Some(TradeOrigin::Exercise));
10194            assert_eq!(origin("two-hundred"), Some(TradeOrigin::Assignment));
10195            assert_eq!(origin("hundred-fifty"), Some(TradeOrigin::Exercise));
10196        }
10197
10198        /// The read covers the whole UTC days of the span, so both legs of an event are read
10199        /// together, and returns only what is in the span: by creation time, or, without one, by
10200        /// date.
10201        #[tokio::test]
10202        async fn fetch_option_events_reads_whole_days_and_returns_the_span() {
10203            let mut early =
10204                option_activity("early", "OPEXP", "QS240301C00010500", "-1", "2024-03-01");
10205            early["created_at"] = serde_json::json!("2024-03-01T10:00:00Z");
10206            let mut inside =
10207                option_activity("inside", "OPEXP", "QS240301C00011500", "-1", "2024-03-01");
10208            inside["created_at"] = serde_json::json!("2024-03-01T16:00:00Z");
10209            let server = serve_json(serde_json::json!([
10210                option_activity(
10211                    "dated-before",
10212                    "OPEXP",
10213                    "QS240229C00010500",
10214                    "-1",
10215                    "2024-02-29"
10216                ),
10217                early,
10218                option_activity("dated", "OPEXP", "QS240301C00012500", "-1", "2024-03-01"),
10219                inside,
10220            ]))
10221            .await;
10222            let client = client_for(&server);
10223
10224            let read = client
10225                .fetch_option_events(time("2024-03-01T15:00:00Z"), time("2024-03-01T17:00:00Z"))
10226                .await
10227                .unwrap();
10228
10229            let ids: Vec<_> = read
10230                .trades
10231                .iter()
10232                .map(|trade| trade.id.0.to_string())
10233                .collect();
10234            assert_eq!(ids, ["dated", "inside"]);
10235            let queries = query_pairs(&server).await;
10236            let [query] = queries.as_slice() else {
10237                panic!("one request: {queries:?}");
10238            };
10239            assert!(
10240                query.contains(&("after".into(), "2024-03-01T00:00:00.000Z".into())),
10241                "{query:?}"
10242            );
10243            assert!(
10244                query.contains(&("until".into(), "2024-03-02T00:00:00.000Z".into())),
10245                "{query:?}"
10246            );
10247        }
10248
10249        #[tokio::test]
10250        async fn fetch_option_events_reads_nothing_for_an_empty_span() {
10251            let server = MockServer::start().await;
10252            let client = client_for(&server);
10253
10254            let read = client
10255                .fetch_option_events(time("2024-03-02T00:00:00Z"), time("2024-03-01T00:00:00Z"))
10256                .await
10257                .unwrap();
10258
10259            assert!(read.trades.is_empty() && read.resume.is_none());
10260            assert!(query_pairs(&server).await.is_empty());
10261        }
10262
10263        /// Read `start..=end` to its end, from each call's `resume`, returning the trade ids
10264        /// read and how many calls it took.
10265        async fn read_span(
10266            client: &AlpacaClient,
10267            start: DateTime<Utc>,
10268            end: DateTime<Utc>,
10269            instruments: &[InstrumentNameExchange],
10270        ) -> (std::collections::HashSet<String>, usize) {
10271            use crate::client::ExecutionClient;
10272
10273            let mut ids = std::collections::HashSet::new();
10274            let mut from = start;
10275            for calls in 1..=10 {
10276                let read = match client.fetch_trades(from, end, instruments).await {
10277                    Ok(read) => read,
10278                    Err(error) => panic!("call {calls} failed: {error:?}"),
10279                };
10280                ids.extend(read.trades.iter().map(|trade| trade.id.0.to_string()));
10281                match read.resume {
10282                    Some(resume) => from = resume,
10283                    None => return (ids, calls),
10284                }
10285            }
10286            panic!("the reads did not reach the span's end");
10287        }
10288
10289        /// A span holding more fills than one call reads is read completely across calls, each
10290        /// resumed from the last. Three fills share each millisecond, ordered by id against
10291        /// their times, so a call's cut falls inside one: its last fill read is later than an
10292        /// unread one in the same millisecond, which a resume from the last fill's exact time
10293        /// would lose.
10294        #[tokio::test]
10295        async fn fetch_trades_reads_a_busy_span_completely_across_calls() {
10296            let base = time("2025-04-18T14:30:00Z");
10297            let mut activities = Vec::new();
10298            for ms in 0..4_000_i64 {
10299                let symbol = if ms % 2 == 0 { "SPY" } else { "QQQ" };
10300                for (k, micros) in [("a", 900), ("b", 500), ("c", 100)] {
10301                    let at = base + TimeDelta::milliseconds(ms) + TimeDelta::microseconds(micros);
10302                    activities.push(fill_at(&format!("{ms:017}::{ms}-{k}"), symbol, at));
10303                }
10304            }
10305            let server = serve_activities(activities).await;
10306            let client = client_for(&server);
10307            let end = time("2025-04-18T15:00:00Z");
10308
10309            let (ids, calls) = read_span(&client, recovery_after(), end, &[]).await;
10310            assert_eq!(ids.len(), 12_000, "every fill is read once or more");
10311            assert_eq!(calls, 3, "5,000 activities per call");
10312
10313            let spy = [InstrumentNameExchange::new("SPY")];
10314            let (ids, _) = read_span(&client, recovery_after(), end, &spy).await;
10315            assert_eq!(ids.len(), 6_000, "every SPY fill, and only those");
10316
10317            let Some(requests) = server.received_requests().await else {
10318                panic!("requests are recorded");
10319            };
10320            assert!(
10321                requests
10322                    .iter()
10323                    .all(|request| request.url.query_pairs().any(
10324                        |(name, value)| name == "until" && value == "2025-04-18T15:00:00.001Z"
10325                    )),
10326                "every request is bounded by the span's end"
10327            );
10328        }
10329
10330        /// A call can spend its whole bound on other instruments' fills: it returns none, and
10331        /// says where to read on from.
10332        #[tokio::test]
10333        async fn fetch_trades_says_where_to_read_on_after_a_call_without_matches() {
10334            use crate::client::ExecutionClient;
10335
10336            let server = MockServer::start().await;
10337            Mock::given(method("GET"))
10338                .and(path("/v2/account/activities"))
10339                .respond_with(
10340                    ResponseTemplate::new(200)
10341                        .set_body_json(make_activities_json(ALPACA_MAX_ACTIVITIES, "act")),
10342                )
10343                .mount(&server)
10344                .await;
10345
10346            let qqq = [InstrumentNameExchange::new("QQQ")];
10347            let Ok(read) = client_for(&server)
10348                .fetch_trades(recovery_after(), Utc::now(), &qqq)
10349                .await
10350            else {
10351                panic!("the read succeeds");
10352            };
10353            assert!(read.trades.is_empty(), "every fill read is SPY's");
10354            assert_eq!(read.resume, Some(time("2025-04-18T14:30:00Z")));
10355        }
10356
10357        /// A full read that does not get past the span's first millisecond cannot advance, so it
10358        /// is an error rather than a resume that would loop.
10359        #[tokio::test]
10360        async fn fetch_trades_that_cannot_advance_is_truncated() {
10361            use crate::client::ExecutionClient;
10362
10363            let server = MockServer::start().await;
10364            Mock::given(method("GET"))
10365                .and(path("/v2/account/activities"))
10366                .respond_with(
10367                    ResponseTemplate::new(200)
10368                        .set_body_json(make_activities_json(ALPACA_MAX_ACTIVITIES, "act")),
10369                )
10370                .mount(&server)
10371                .await;
10372
10373            let result = client_for(&server)
10374                .fetch_trades(time("2025-04-18T14:30:00.000500Z"), Utc::now(), &[])
10375                .await;
10376            assert!(
10377                matches!(
10378                    result,
10379                    Err(UnindexedClientError::Truncated { fills_read: 5_000 })
10380                ),
10381                "got {result:?}"
10382            );
10383        }
10384
10385        /// The span is applied exactly, although Alpaca's bounds reach the whole of the
10386        /// milliseconds holding `start` and `end`.
10387        #[tokio::test]
10388        async fn fetch_trades_returns_only_the_span() {
10389            let (start, end) = (
10390                time("2025-04-18T14:30:00.123456Z"),
10391                time("2025-04-18T14:30:05.678901Z"),
10392            );
10393            let micro = TimeDelta::microseconds(1);
10394            let server = serve_activities(vec![
10395                fill_at("1::before", "SPY", start - micro),
10396                fill_at("2::start", "SPY", start),
10397                fill_at("3::end", "SPY", end),
10398                fill_at("4::after", "SPY", end + micro),
10399            ])
10400            .await;
10401
10402            let (ids, calls) = read_span(&client_for(&server), start, end, &[]).await;
10403            assert_eq!(calls, 1);
10404            let mut ids: Vec<_> = ids.into_iter().collect();
10405            ids.sort();
10406            assert_eq!(ids, ["end", "start"]);
10407        }
10408
10409        /// Alpaca's `until` is not exact, so a capped read can return activities past `end`. Every
10410        /// activity before them was read, so the span is complete.
10411        #[tokio::test]
10412        async fn fetch_trades_that_read_past_the_end_is_complete() {
10413            use crate::client::ExecutionClient;
10414
10415            let server = MockServer::start().await;
10416            // Full pages at 14:30:00, whatever `until` asks for.
10417            Mock::given(method("GET"))
10418                .and(path("/v2/account/activities"))
10419                .respond_with(
10420                    ResponseTemplate::new(200)
10421                        .set_body_json(make_activities_json(ALPACA_MAX_ACTIVITIES, "act")),
10422                )
10423                .mount(&server)
10424                .await;
10425
10426            let Ok(read) = client_for(&server)
10427                .fetch_trades(recovery_after(), time("2025-04-18T14:29:00Z"), &[])
10428                .await
10429            else {
10430                panic!("the read succeeds");
10431            };
10432            assert_eq!(read, TradesRead::complete(Vec::new()));
10433        }
10434
10435        /// The extreme times are valid bounds: a start before the epoch asks from the epoch, an
10436        /// end not yet reached sends no `until` rather than overflowing, and a span ending before
10437        /// the epoch reads nothing.
10438        #[tokio::test]
10439        async fn fetch_trades_accepts_the_extreme_times() {
10440            use crate::client::ExecutionClient;
10441
10442            let server = serve_activities(vec![fill_at(
10443                "1::fill",
10444                "SPY",
10445                time("2025-04-18T14:30:00Z"),
10446            )])
10447            .await;
10448            let Ok(read) = client_for(&server)
10449                .fetch_trades(DateTime::<Utc>::MIN_UTC, DateTime::<Utc>::MAX_UTC, &[])
10450                .await
10451            else {
10452                panic!("the read succeeds");
10453            };
10454            assert_eq!(read.trades.len(), 1);
10455            assert_eq!(read.resume, None);
10456
10457            let Some(requests) = server.received_requests().await else {
10458                panic!("requests are recorded");
10459            };
10460            let [request] = requests.as_slice() else {
10461                panic!("one request: {requests:?}");
10462            };
10463            let query: std::collections::HashMap<_, _> =
10464                request.url.query_pairs().into_owned().collect();
10465            assert_eq!(
10466                query.get("after").map(String::as_str),
10467                Some("1970-01-01T00:00:00.000Z")
10468            );
10469            assert!(!query.contains_key("until"), "{query:?}");
10470
10471            // A span ending before the epoch holds nothing, and is not requested.
10472            let before_epoch = DateTime::UNIX_EPOCH - TimeDelta::days(1);
10473            let Ok(read) = client_for(&server)
10474                .fetch_trades(DateTime::<Utc>::MIN_UTC, before_epoch, &[])
10475                .await
10476            else {
10477                panic!("the read succeeds");
10478            };
10479            assert_eq!(read, TradesRead::complete(Vec::new()));
10480            assert_eq!(server.received_requests().await.map(|r| r.len()), Some(1));
10481        }
10482
10483        /// A span whose start is after its end is empty: nothing is requested.
10484        #[tokio::test]
10485        async fn fetch_trades_reads_nothing_for_an_empty_span() {
10486            use crate::client::ExecutionClient;
10487
10488            let server = MockServer::start().await;
10489            let start = recovery_after();
10490            let Ok(read) = client_for(&server)
10491                .fetch_trades(start, start - TimeDelta::seconds(1), &[])
10492                .await
10493            else {
10494                panic!("an empty span reads");
10495            };
10496            assert_eq!(read, TradesRead::complete(Vec::new()));
10497            assert_eq!(server.received_requests().await.map(|r| r.len()), Some(0));
10498        }
10499
10500        /// Alpaca's `after` compares at millisecond precision, so a recovery from mid-millisecond
10501        /// asks from that millisecond's start, or it would miss a fill later in it.
10502        #[tokio::test]
10503        async fn recovery_reads_from_the_start_of_the_disconnects_millisecond() {
10504            let server = MockServer::start().await;
10505            Mock::given(method("GET"))
10506                .and(path("/v2/account/activities"))
10507                .and(wiremock::matchers::query_param(
10508                    "after",
10509                    "2025-01-01T00:00:00.123Z",
10510                ))
10511                .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([])))
10512                .expect(1)
10513                .mount(&server)
10514                .await;
10515
10516            let (tx, _rx) = mpsc::unbounded_channel();
10517            let outcome = recover_fills(
10518                &reqwest::Client::new(),
10519                &RateLimitTracker::new(),
10520                &[],
10521                &server.uri(),
10522                time("2025-01-01T00:00:00.123456Z"),
10523                &tx,
10524                &new_dedup_cache(),
10525                &KnownLiveOrders::shared(ExchangeId::AlpacaBroker),
10526            )
10527            .await;
10528            assert_eq!(outcome, Ok(()));
10529            server.verify().await;
10530        }
10531
10532        /// The cumulative dedup keys `dedup` holds, oldest first.
10533        fn dedup_keys(dedup: &SharedDedupCache) -> Vec<String> {
10534            let mut keys: Vec<_> = dedup
10535                .lock()
10536                .iter()
10537                .filter_map(|(key, _)| match key {
10538                    FillKey::Cumulative(key) => Some(key.to_string()),
10539                    FillKey::Execution(_) | FillKey::CumulativeWithoutId(_) => None,
10540                })
10541                .collect();
10542            // The LRU iterates most recent first.
10543            keys.reverse();
10544            keys
10545        }
10546
10547        fn trade_of(
10548            event: &UnindexedAccountEvent,
10549        ) -> &Trade<AssetNameExchange, InstrumentNameExchange> {
10550            match &event.kind {
10551                AccountEventKind::Trade(t) => t,
10552                other => panic!("expected a Trade event, got {other:?}"),
10553            }
10554        }
10555
10556        /// A recovered fill reports where it left the order, so it advances `filled_quantity`
10557        /// without waiting for a `fetch_open_orders` reconciliation that the caller may never make.
10558        #[tokio::test]
10559        async fn a_recovered_fill_reports_the_cumulative_alpaca_sent() {
10560            let events = drive_recover_fills(vec![
10561                activity_json("act-1", "ord-1", "2", Some("2")),
10562                activity_json("act-2", "ord-1", "3", Some("5")),
10563            ])
10564            .await;
10565
10566            let reported: Vec<_> = events
10567                .iter()
10568                .map(|e| trade_of(e).order_filled_quantity)
10569                .collect();
10570            assert_eq!(
10571                reported,
10572                vec![
10573                    Some(Decimal::from_str("2").unwrap()),
10574                    Some(Decimal::from_str("5").unwrap())
10575                ]
10576            );
10577        }
10578
10579        /// The dedup key comes from the venue's own cumulative, not from counting executions
10580        /// within the recovery batch.
10581        ///
10582        /// An order that had already partly filled *before* the recovery window is the case that
10583        /// separates the two, and it is the case the previous implementation got wrong: counting
10584        /// from zero inside the batch yields a key the WebSocket path never emitted for that fill.
10585        #[tokio::test]
10586        async fn the_dedup_key_uses_the_venue_cumulative_not_an_intra_batch_count() {
10587            // Three lots filled before the window; the recovered 2-lot fill takes the order to 5.
10588            let dedup = new_dedup_cache();
10589            drive_recover_fills_with(
10590                vec![activity_json("act-1", "ord-1", "2", Some("5"))],
10591                dedup.clone(),
10592                &KnownLiveOrders::shared(ExchangeId::AlpacaBroker),
10593            )
10594            .await;
10595
10596            assert_eq!(
10597                dedup_keys(&dedup),
10598                vec!["ord-1:5"],
10599                "counting executions within the batch would key this as ord-1:2"
10600            );
10601        }
10602
10603        /// Delivers a WS fill of 2 lots on `ord-1`, leaving it at 5, with `execution_id`, then
10604        /// recovers one activity `activity_id` of the same size and cumulative through the same
10605        /// dedup cache.
10606        async fn recover_after_ws_fill(
10607            execution_id: Option<&str>,
10608            activity_id: &str,
10609        ) -> Vec<UnindexedAccountEvent> {
10610            let dedup = new_dedup_cache();
10611            let update = AlpacaTradeUpdate {
10612                event: SmolStr::new("partial_fill"),
10613                execution_id,
10614                previous_execution_id: None,
10615                order: super::make_order_ws("ord-1", "SPY", "buy", "5"),
10616                price: Some("100.00"),
10617                qty: Some("2"),
10618                timestamp: None,
10619            };
10620            assert!(
10621                !is_duplicate_fill(&dedup, ws_execution_id(&update), early_dedup_key(&update)),
10622                "precondition: first sighting"
10623            );
10624
10625            drive_recover_fills_with(
10626                vec![activity_json(activity_id, "ord-1", "2", Some("5"))],
10627                dedup,
10628                &KnownLiveOrders::shared(ExchangeId::AlpacaBroker),
10629            )
10630            .await
10631        }
10632
10633        /// The property the cache exists for: one fill delivered by both paths is one fill. It is
10634        /// recognised by its execution id, or by its cumulative where either path lacks the id.
10635        #[tokio::test]
10636        async fn a_fill_already_delivered_over_websocket_is_not_recovered_twice() {
10637            for (ws_execution_id, activity_id) in [
10638                (Some("exec-a"), "20250418103000000::exec-a"),
10639                (None, "20250418103000000::exec-a"),
10640                (Some("exec-a"), "act-1"),
10641            ] {
10642                let events = recover_after_ws_fill(ws_execution_id, activity_id).await;
10643                assert!(
10644                    events.is_empty(),
10645                    "a fill already delivered over WebSocket ({ws_execution_id:?}) must not be \
10646                     re-delivered by recovery ({activity_id}), got {events:?}"
10647                );
10648            }
10649        }
10650
10651        /// Streams a `trade_correct` with `fields` (its execution id `exec-c`), then recovers the
10652        /// FILL activity of `exec-c` through the same dedup cache.
10653        async fn recover_after_correction(fields: &str) -> Vec<UnindexedAccountEvent> {
10654            let (tx, _rx) = mpsc::unbounded_channel();
10655            let dedup = new_dedup_cache();
10656            let known = KnownLiveOrders::shared(ExchangeId::AlpacaBroker);
10657            process_ws_text(
10658                &super::amendment_frame("trade_correct", fields),
10659                &tx,
10660                &dedup,
10661                &known,
10662            );
10663
10664            drive_recover_fills_with(
10665                vec![activity_json(
10666                    "20250418103100000::exec-c",
10667                    "ord-1",
10668                    "2",
10669                    Some("2"),
10670                )],
10671                dedup,
10672                &known,
10673            )
10674            .await
10675        }
10676
10677        /// A correction's execution was delivered as the replacement trade, so recovery does not
10678        /// deliver it again should Alpaca list it as a FILL activity.
10679        #[tokio::test]
10680        async fn a_corrected_execution_is_not_recovered_again() {
10681            let events = recover_after_correction(
10682                r#""execution_id":"exec-c","previous_execution_id":"exec-a","price":"101.50","qty":"2","#,
10683            )
10684            .await;
10685            assert!(
10686                events.is_empty(),
10687                "the corrected execution was delivered as the replacement, got {events:?}"
10688            );
10689        }
10690
10691        /// An unresolved correction delivered no trade, so recovery still delivers its execution.
10692        #[tokio::test]
10693        async fn an_unresolved_correction_does_not_keep_its_execution_from_recovery() {
10694            let events = recover_after_correction(
10695                r#""execution_id":"exec-c","previous_execution_id":"exec-a","qty":"2","#,
10696            )
10697            .await;
10698            let ids: Vec<_> = events.iter().map(|e| trade_of(e).id.0.as_str()).collect();
10699            assert_eq!(ids, vec!["exec-c"]);
10700        }
10701
10702        /// A different execution that takes the order to a cumulative already seen, as after a
10703        /// bust, is a new fill and is recovered.
10704        #[tokio::test]
10705        async fn a_different_execution_at_a_cumulative_already_seen_is_recovered() {
10706            let events = recover_after_ws_fill(Some("exec-a"), "20250418103000000::exec-b").await;
10707            let ids: Vec<_> = events.iter().map(|e| trade_of(e).id.0.as_str()).collect();
10708            assert_eq!(ids, vec!["exec-b"]);
10709        }
10710
10711        /// With `cum_qty` absent the key falls back to counting within the batch -- exactly what
10712        /// shipped before -- and the fill reports no cumulative rather than a fabricated one.
10713        #[tokio::test]
10714        async fn without_a_reported_cumulative_the_previous_behaviour_is_preserved() {
10715            let dedup = new_dedup_cache();
10716            let events = drive_recover_fills_with(
10717                vec![
10718                    activity_json("act-1", "ord-1", "2", None),
10719                    activity_json("act-2", "ord-1", "3", None),
10720                ],
10721                dedup.clone(),
10722                &KnownLiveOrders::shared(ExchangeId::AlpacaBroker),
10723            )
10724            .await;
10725
10726            assert_eq!(
10727                dedup_keys(&dedup),
10728                vec!["ord-1:2", "ord-1:5"],
10729                "intra-batch accumulation"
10730            );
10731            let ids: Vec<_> = events.iter().map(|e| trade_of(e).id.0.as_str()).collect();
10732            assert_eq!(ids, vec!["act-1", "act-2"], "each keeps its activity's id");
10733
10734            assert!(
10735                events
10736                    .iter()
10737                    .all(|e| trade_of(e).order_filled_quantity.is_none()),
10738                "a venue that reported no cumulative must not have one invented for it"
10739            );
10740        }
10741
10742        // --- How orders ended: fetch_ended_orders, the reconnect check, and what is held ---
10743
10744        /// An order as `GET /v2/orders` and `GET /v2/orders:by_client_order_id` serve it, of
10745        /// quantity 2.
10746        fn order_json(cid: &str, symbol: &str, status: &str, filled: &str) -> serde_json::Value {
10747            serde_json::json!({
10748                "id": format!("id-{cid}"),
10749                "client_order_id": cid,
10750                "symbol": symbol,
10751                "qty": "2",
10752                "filled_qty": filled,
10753                "filled_avg_price": if filled == "0" { None } else { Some("101.5") },
10754                "status": status,
10755                "side": "buy",
10756                "type": "limit",
10757                "time_in_force": "gtc",
10758                "limit_price": "100",
10759                "created_at": "2026-10-01T14:30:00Z",
10760                "updated_at": "2026-10-01T15:00:00Z"
10761            })
10762        }
10763
10764        /// Serve `order` as the lookup of client order id `cid`, expecting `calls` lookups.
10765        async fn mount_lookup(server: &MockServer, cid: &str, order: ResponseTemplate, calls: u64) {
10766            use wiremock::matchers::query_param;
10767            Mock::given(method("GET"))
10768                .and(path("/v2/orders:by_client_order_id"))
10769                .and(query_param("client_order_id", cid))
10770                .respond_with(order)
10771                .expect(calls)
10772                .mount(server)
10773                .await;
10774        }
10775
10776        fn ok(body: serde_json::Value) -> ResponseTemplate {
10777            ResponseTemplate::new(200).set_body_json(body)
10778        }
10779
10780        fn alpaca_key(instrument: &str, cid: &str) -> UnindexedOrderKey {
10781            OrderKey::new(
10782                ExchangeId::AlpacaBroker,
10783                InstrumentNameExchange::new(instrument),
10784                StrategyId::new("strategy"),
10785                ClientOrderId::new(cid),
10786            )
10787        }
10788
10789        fn hold(known: &SharedKnownLiveOrders, key: &UnindexedOrderKey) {
10790            let open = Open::new(
10791                VenueOrderId::Assigned(OrderId::new(format!("id-{}", key.cid))),
10792                Utc::now(),
10793                Decimal::ZERO,
10794            );
10795            known
10796                .lock()
10797                .live(key, Decimal::TWO, OrderKind::Limit, &open);
10798        }
10799
10800        #[tokio::test]
10801        async fn fetch_ended_orders_reads_each_end_alpaca_reports() {
10802            use rust_decimal_macros::dec;
10803
10804            let server = MockServer::start().await;
10805            for (cid, status, filled) in [
10806                ("filled", "filled", "2"),
10807                ("canceled", "canceled", "1"),
10808                ("replaced", "replaced", "0"),
10809                ("expired", "expired", "1"),
10810                ("rejected", "rejected", "0"),
10811                ("done", "done_for_day", "1"),
10812                ("live", "new", "0"),
10813                ("unknown-status", "frozen", "0"),
10814            ] {
10815                mount_lookup(&server, cid, ok(order_json(cid, "SPY", status, filled)), 1).await;
10816            }
10817            mount_lookup(
10818                &server,
10819                "elsewhere",
10820                ok(order_json("elsewhere", "QQQ", "canceled", "0")),
10821                1,
10822            )
10823            .await;
10824            mount_lookup(
10825                &server,
10826                "gone",
10827                ResponseTemplate::new(404).set_body_json(serde_json::json!({
10828                    "code": 40410000,
10829                    "message": "order not found for gone"
10830                })),
10831                1,
10832            )
10833            .await;
10834
10835            let keys: Vec<_> = [
10836                "filled",
10837                "canceled",
10838                "replaced",
10839                "expired",
10840                "rejected",
10841                "done",
10842                "live",
10843                "unknown-status",
10844                "elsewhere",
10845                "gone",
10846            ]
10847            .into_iter()
10848            .map(|cid| alpaca_key("spy", cid))
10849            .collect();
10850            let ended = client_for(&server).fetch_ended_orders(&keys).await.unwrap();
10851
10852            let mut found: Vec<_> = ended
10853                .iter()
10854                .map(|order| (order.key.cid.0.as_str(), &order.state))
10855                .collect();
10856            found.sort_by_key(|(cid, _)| *cid);
10857            let [
10858                (canceled, InactiveOrderState::Cancelled(cancelled)),
10859                (expired, InactiveOrderState::Expired(expiry)),
10860                (filled, InactiveOrderState::FullyFilled(fill)),
10861                (rejected, InactiveOrderState::OpenFailed(failed)),
10862                (replaced, InactiveOrderState::Cancelled(replacement)),
10863            ] = found.as_slice()
10864            else {
10865                panic!("filled, canceled, replaced, expired and rejected: {found:?}");
10866            };
10867            assert_eq!(
10868                [*canceled, *expired, *filled, *rejected, *replaced],
10869                ["canceled", "expired", "filled", "rejected", "replaced"]
10870            );
10871            assert_eq!(
10872                (fill.filled_quantity, fill.avg_price),
10873                (dec!(2), Some(dec!(101.5)))
10874            );
10875            assert_eq!(
10876                cancelled.filled_quantity,
10877                Some(dec!(1)),
10878                "what filled before"
10879            );
10880            assert_eq!(expiry.filled_quantity, Some(dec!(1)));
10881            assert_eq!(replacement.filled_quantity, Some(Decimal::ZERO));
10882            assert_eq!(
10883                cancelled.time_exchange,
10884                parse_timestamp("2026-10-01T15:00:00Z").unwrap(),
10885                "stamped when the order last changed"
10886            );
10887            assert!(matches!(
10888                failed,
10889                OrderError::Rejected(ApiError::OrderRejected(message))
10890                    if message.contains("id-rejected")
10891            ));
10892            assert!(
10893                ended
10894                    .iter()
10895                    .all(|order| order.key.strategy == StrategyId::new("strategy")
10896                        && order.key.instrument == InstrumentNameExchange::new("spy")),
10897                "each carries the key it was asked for"
10898            );
10899        }
10900
10901        #[tokio::test]
10902        async fn fetch_ended_orders_fails_when_a_lookup_fails() {
10903            let server = MockServer::start().await;
10904            mount_lookup(&server, "a", ok(order_json("a", "SPY", "canceled", "0")), 1).await;
10905            mount_lookup(&server, "b", ResponseTemplate::new(500), 1).await;
10906
10907            let result = client_for(&server)
10908                .fetch_ended_orders(&[alpaca_key("SPY", "a"), alpaca_key("SPY", "b")])
10909                .await;
10910
10911            assert!(
10912                matches!(result, Err(UnindexedClientError::Connectivity(_))),
10913                "{result:?}"
10914            );
10915        }
10916
10917        /// One listing covers every instrument due; only the held orders it no longer shows are
10918        /// looked up, and only those that ended are reported and stop being held.
10919        #[tokio::test]
10920        async fn a_reconnect_check_lists_once_and_reports_the_orders_that_ended() {
10921            use wiremock::matchers::query_param;
10922
10923            let server = MockServer::start().await;
10924            Mock::given(method("GET"))
10925                .and(path("/v2/orders"))
10926                .and(query_param("status", "open"))
10927                .and(query_param("symbols", "QQQ,SPY"))
10928                .respond_with(ok(serde_json::json!([order_json(
10929                    "spy-listed",
10930                    "SPY",
10931                    "new",
10932                    "0"
10933                )])))
10934                .expect(1)
10935                .mount(&server)
10936                .await;
10937            mount_lookup(&server, "spy-listed", ResponseTemplate::new(500), 0).await;
10938            mount_lookup(
10939                &server,
10940                "spy-cancelled",
10941                ok(order_json("spy-cancelled", "SPY", "canceled", "1")),
10942                1,
10943            )
10944            .await;
10945            mount_lookup(
10946                &server,
10947                "spy-done",
10948                ok(order_json("spy-done", "SPY", "done_for_day", "1")),
10949                1,
10950            )
10951            .await;
10952            mount_lookup(
10953                &server,
10954                "qqq-filled",
10955                ok(order_json("qqq-filled", "QQQ", "filled", "2")),
10956                1,
10957            )
10958            .await;
10959
10960            let known = KnownLiveOrders::shared(ExchangeId::AlpacaBroker);
10961            let [listed, cancelled, done, filled] = [
10962                ("SPY", "spy-listed"),
10963                ("SPY", "spy-cancelled"),
10964                ("SPY", "spy-done"),
10965                ("QQQ", "qqq-filled"),
10966            ]
10967            .map(|(instrument, cid)| alpaca_key(instrument, cid));
10968            for key in [&listed, &cancelled, &done, &filled] {
10969                hold(&known, key);
10970            }
10971            let mut unchecked = UncheckedOrders::default();
10972            unchecked.open(known.lock().instruments());
10973            let config = Arc::new(AlpacaConfig::with_base_url(
10974                "test-key".into(),
10975                "test-secret".into(),
10976                server.uri(),
10977            ));
10978            let (tx, mut rx) = mpsc::unbounded_channel();
10979
10980            recover_alpaca_ended_orders(
10981                &reqwest::Client::new(),
10982                &Arc::new(RateLimitTracker::new()),
10983                &config,
10984                &known,
10985                &mut unchecked,
10986                &tx,
10987            )
10988            .await;
10989
10990            let mut reported: Vec<_> = std::iter::from_fn(|| rx.try_recv().ok())
10991                .map(|event| {
10992                    let AccountEventKind::OrderSnapshot(
10993                        rustrade_integration::collection::snapshot::Snapshot(order),
10994                    ) = event.kind
10995                    else {
10996                        panic!("an order snapshot: {event:?}");
10997                    };
10998                    assert!(matches!(order.state, OrderState::Inactive(_)), "{order:?}");
10999                    order.key.cid
11000                })
11001                .collect();
11002            reported.sort();
11003            assert_eq!(reported, [filled.cid.clone(), cancelled.cid.clone()]);
11004            let known = known.lock();
11005            assert!(known.contains(&listed.cid) && known.contains(&done.cid));
11006            assert!(!known.contains(&cancelled.cid) && !known.contains(&filled.cid));
11007            assert!(unchecked.is_empty(), "both instruments checked");
11008        }
11009
11010        /// A listing that fails charges every instrument in it, which then waits for a retry.
11011        #[tokio::test]
11012        async fn a_failed_listing_is_retried_for_every_instrument_in_it() {
11013            let server = MockServer::start().await;
11014            Mock::given(method("GET"))
11015                .and(path("/v2/orders"))
11016                .respond_with(ResponseTemplate::new(503))
11017                .expect(1)
11018                .mount(&server)
11019                .await;
11020
11021            let known = KnownLiveOrders::shared(ExchangeId::AlpacaBroker);
11022            hold(&known, &alpaca_key("SPY", "a"));
11023            hold(&known, &alpaca_key("QQQ", "b"));
11024            let mut unchecked = UncheckedOrders::default();
11025            unchecked.open(known.lock().instruments());
11026            let config = Arc::new(AlpacaConfig::with_base_url(
11027                "test-key".into(),
11028                "test-secret".into(),
11029                server.uri(),
11030            ));
11031            let (tx, mut rx) = mpsc::unbounded_channel();
11032
11033            recover_alpaca_ended_orders(
11034                &reqwest::Client::new(),
11035                &Arc::new(RateLimitTracker::new()),
11036                &config,
11037                &known,
11038                &mut unchecked,
11039                &tx,
11040            )
11041            .await;
11042
11043            assert!(rx.try_recv().is_err(), "nothing reported");
11044            let now = tokio::time::Instant::now();
11045            assert!(
11046                unchecked.ready(&NoPendingFills, now).is_empty(),
11047                "both wait"
11048            );
11049            for instrument in ["QQQ", "SPY"] {
11050                assert_eq!(
11051                    unchecked.failed(
11052                        &InstrumentNameExchange::new(instrument),
11053                        now,
11054                        Duration::ZERO
11055                    ),
11056                    Some(crate::client::order_recovery::GapFailure::Retry(
11057                        Duration::from_secs(crate::client::order_recovery::GAP_RETRY_BASE_SECS * 2)
11058                    )),
11059                    "{instrument} has failed once already"
11060                );
11061            }
11062            assert_eq!(known.lock().instruments().len(), 2, "both still held");
11063        }
11064
11065        #[tokio::test]
11066        async fn placing_listing_and_cancelling_keep_the_held_orders_in_step() {
11067            use crate::order::request::{OrderRequestCancel, RequestCancel, RequestOpen};
11068
11069            let server = MockServer::start().await;
11070            Mock::given(method("POST"))
11071                .and(path("/v2/orders"))
11072                .respond_with(ok(order_json("resting", "SPY", "new", "0")))
11073                .up_to_n_times(1)
11074                .mount(&server)
11075                .await;
11076            Mock::given(method("POST"))
11077                .and(path("/v2/orders"))
11078                .respond_with(ok(order_json("filled", "SPY", "filled", "2")))
11079                .mount(&server)
11080                .await;
11081            Mock::given(method("GET"))
11082                .and(path("/v2/orders"))
11083                .respond_with(ok(serde_json::json!([order_json(
11084                    "listed", "QQQ", "new", "0"
11085                )])))
11086                .mount(&server)
11087                .await;
11088            Mock::given(method("DELETE"))
11089                .and(path("/v2/orders/id-resting"))
11090                .respond_with(ResponseTemplate::new(204))
11091                .mount(&server)
11092                .await;
11093            let client = client_for(&server);
11094            let spy = InstrumentNameExchange::new("SPY");
11095
11096            for cid in ["resting", "filled"] {
11097                let request = OrderRequestOpen {
11098                    key: OrderKey::new(
11099                        ExchangeId::AlpacaBroker,
11100                        &spy,
11101                        StrategyId::new("strategy"),
11102                        ClientOrderId::new(cid),
11103                    ),
11104                    state: RequestOpen {
11105                        side: Side::Buy,
11106                        price: Some(Decimal::ONE_HUNDRED),
11107                        quantity: Decimal::TWO,
11108                        kind: OrderKind::Limit,
11109                        time_in_force: TimeInForce::GoodUntilCancelled { post_only: false },
11110                        position_id: None,
11111                        reduce_only: false,
11112                        market: None,
11113                    },
11114                };
11115                client.open_order(request).await;
11116            }
11117            client.fetch_open_orders(&[]).await.unwrap();
11118            {
11119                let known = client.known_live.lock();
11120                assert!(known.contains(&ClientOrderId::new("resting")));
11121                assert!(
11122                    !known.contains(&ClientOrderId::new("filled")),
11123                    "filled in its placement response"
11124                );
11125                assert!(known.contains(&ClientOrderId::new("listed")));
11126            }
11127
11128            let cancel = client
11129                .cancel_order(OrderRequestCancel {
11130                    key: OrderKey::new(
11131                        ExchangeId::AlpacaBroker,
11132                        &spy,
11133                        StrategyId::new("strategy"),
11134                        ClientOrderId::new("resting"),
11135                    ),
11136                    state: RequestCancel {
11137                        id: Some(VenueOrderId::Assigned(OrderId::new("id-resting"))),
11138                    },
11139                })
11140                .await;
11141            let Ok(cancelled) = &cancel.state else {
11142                panic!("expected Ok, got {cancel:?}");
11143            };
11144            assert_eq!(
11145                cancelled.filled_quantity, None,
11146                "the DELETE response has no body, so what filled is unknown"
11147            );
11148            assert!(
11149                client
11150                    .known_live
11151                    .lock()
11152                    .contains(&ClientOrderId::new("resting")),
11153                "a cancel Alpaca has only accepted has not ended the order"
11154            );
11155        }
11156
11157        /// An order that ended in its placement response is reported as it ended, on both
11158        /// placement paths, and is not held as live: an IOC that found no liquidity or partly
11159        /// filled and expired the rest, one cancelled or replaced, one rejected after Alpaca
11160        /// accepted it, and one filled. A live status is reported open, with what filled (zero
11161        /// when that is unknown), and held, as is one this version does not know; one whose fill
11162        /// covers the quantity is reported filled.
11163        #[tokio::test]
11164        async fn an_order_that_ended_in_its_placement_response_is_reported_as_it_ended() {
11165            use crate::order::request::RequestOpen;
11166
11167            let Some(time) = parse_timestamp("2026-10-01T15:00:00Z") else {
11168                panic!("order_json's updated_at parses");
11169            };
11170            let id = |cid: &str| OrderId::new(format!("id-{cid}"));
11171            let open = |cid: &str, filled| {
11172                OrderState::active(Open::new(VenueOrderId::Assigned(id(cid)), time, filled))
11173            };
11174            let cases: [(&str, &str, &str, UnindexedOrderState, bool); 13] = [
11175                (
11176                    "ioc-unfilled",
11177                    "expired",
11178                    "0",
11179                    OrderState::expired(Expired::new(
11180                        id("ioc-unfilled"),
11181                        time,
11182                        Some(Decimal::ZERO),
11183                    )),
11184                    false,
11185                ),
11186                (
11187                    "ioc-partial",
11188                    "expired",
11189                    "1",
11190                    OrderState::expired(Expired::new(id("ioc-partial"), time, Some(Decimal::ONE))),
11191                    false,
11192                ),
11193                (
11194                    "fill-unknown",
11195                    "expired",
11196                    "not-a-number",
11197                    OrderState::expired(Expired::new(id("fill-unknown"), time, None)),
11198                    false,
11199                ),
11200                (
11201                    "cancelled",
11202                    "canceled",
11203                    "1",
11204                    OrderState::inactive(Cancelled::new(id("cancelled"), time, Some(Decimal::ONE))),
11205                    false,
11206                ),
11207                (
11208                    "rejected",
11209                    "rejected",
11210                    "0",
11211                    OrderState::inactive(OrderError::Rejected(ApiError::OrderRejected(
11212                        "Alpaca rejected order id-rejected after accepting it".to_string(),
11213                    ))),
11214                    false,
11215                ),
11216                (
11217                    "filled",
11218                    "filled",
11219                    "2",
11220                    OrderState::fully_filled(Filled::new(
11221                        id("filled"),
11222                        time,
11223                        Decimal::TWO,
11224                        Some(Decimal::new(1015, 1)),
11225                    )),
11226                    false,
11227                ),
11228                (
11229                    "replaced",
11230                    "replaced",
11231                    "0",
11232                    OrderState::inactive(Cancelled::new(id("replaced"), time, Some(Decimal::ZERO))),
11233                    false,
11234                ),
11235                (
11236                    "live-covered",
11237                    "partially_filled",
11238                    "2",
11239                    OrderState::fully_filled(Filled::new(
11240                        id("live-covered"),
11241                        time,
11242                        Decimal::TWO,
11243                        Some(Decimal::new(1015, 1)),
11244                    )),
11245                    false,
11246                ),
11247                ("new", "new", "0", open("new", Decimal::ZERO), true),
11248                (
11249                    "live-fill-unknown",
11250                    "new",
11251                    "not-a-number",
11252                    open("live-fill-unknown", Decimal::ZERO),
11253                    true,
11254                ),
11255                (
11256                    "partial",
11257                    "partially_filled",
11258                    "1",
11259                    open("partial", Decimal::ONE),
11260                    true,
11261                ),
11262                (
11263                    "for-the-day",
11264                    "done_for_day",
11265                    "1",
11266                    open("for-the-day", Decimal::ONE),
11267                    true,
11268                ),
11269                (
11270                    "unknown",
11271                    "a_status_not_yet_documented",
11272                    "0",
11273                    open("unknown", Decimal::ZERO),
11274                    true,
11275                ),
11276            ];
11277
11278            for (cid, status, filled, expected, held) in cases {
11279                for bracket in [false, true] {
11280                    let server = MockServer::start().await;
11281                    Mock::given(method("POST"))
11282                        .and(path("/v2/orders"))
11283                        .respond_with(ok(order_json(cid, "SPY", status, filled)))
11284                        .expect(1)
11285                        .mount(&server)
11286                        .await;
11287                    let client = client_for(&server);
11288                    let spy = InstrumentNameExchange::new("SPY");
11289                    let time_in_force = TimeInForce::GoodUntilCancelled { post_only: false };
11290
11291                    let state = if bracket {
11292                        client
11293                            .open_bracket_order(AlpacaBracketOrderRequest::new(
11294                                spy,
11295                                StrategyId::new("strategy"),
11296                                ClientOrderId::new(cid),
11297                                Side::Buy,
11298                                Decimal::TWO,
11299                                Decimal::ONE_HUNDRED,
11300                                Decimal::new(120, 0),
11301                                Decimal::new(90, 0),
11302                                time_in_force,
11303                            ))
11304                            .await
11305                            .parent
11306                            .state
11307                    } else {
11308                        let request = OrderRequestOpen {
11309                            key: OrderKey::new(
11310                                ExchangeId::AlpacaBroker,
11311                                &spy,
11312                                StrategyId::new("strategy"),
11313                                ClientOrderId::new(cid),
11314                            ),
11315                            state: RequestOpen {
11316                                side: Side::Buy,
11317                                price: Some(Decimal::ONE_HUNDRED),
11318                                quantity: Decimal::TWO,
11319                                kind: OrderKind::Limit,
11320                                time_in_force,
11321                                position_id: None,
11322                                reduce_only: false,
11323                                market: None,
11324                            },
11325                        };
11326                        client.open_order(request).await.state
11327                    };
11328
11329                    assert_eq!(state, expected, "{status} {filled}, bracket: {bracket}");
11330                    assert_eq!(
11331                        client.known_live.lock().contains(&ClientOrderId::new(cid)),
11332                        held,
11333                        "{status} {filled}, bracket: {bracket}"
11334                    );
11335                }
11336            }
11337        }
11338
11339        /// The stream holds an acknowledged order, keeps holding one done for the day (reported
11340        /// open, not retired), and drops one cancelled.
11341        #[test]
11342        fn the_stream_keeps_the_held_orders_in_step() {
11343            let (tx, mut rx) = mpsc::unbounded_channel();
11344            let dedup = new_dedup_cache();
11345            let known = KnownLiveOrders::shared(ExchangeId::AlpacaBroker);
11346            let frame = |event: &str, filled: &str| {
11347                format!(
11348                    r#"{{"stream":"trade_updates","data":{{"event":"{event}","order":{{"id":"id-a","client_order_id":"a","symbol":"SPY","qty":"2","filled_qty":"{filled}","side":"buy","type":"limit","time_in_force":"gtc","limit_price":"100","status":"{event}"}}}}}}"#
11349                )
11350            };
11351            let cid = ClientOrderId::new("a");
11352
11353            process_ws_text(&frame("new", "0"), &tx, &dedup, &known);
11354            assert!(known.lock().contains(&cid), "acknowledged");
11355
11356            process_ws_text(&frame("done_for_day", "1"), &tx, &dedup, &known);
11357            assert!(known.lock().contains(&cid), "done for the day is not ended");
11358
11359            process_ws_text(&frame("canceled", "1"), &tx, &dedup, &known);
11360            assert!(!known.lock().contains(&cid), "cancelled");
11361
11362            let events: Vec<_> = std::iter::from_fn(|| rx.try_recv().ok()).collect();
11363            let [_, done, cancelled] = events.as_slice() else {
11364                panic!("three events: {events:?}");
11365            };
11366            let AccountEventKind::OrderSnapshot(
11367                rustrade_integration::collection::snapshot::Snapshot(order),
11368            ) = &done.kind
11369            else {
11370                panic!("done_for_day reports an order snapshot: {done:?}");
11371            };
11372            let OrderState::Active(ActiveOrderState::Open(open)) = &order.state else {
11373                panic!("open: {order:?}");
11374            };
11375            assert_eq!(open.filled_quantity, Decimal::ONE, "with what it filled");
11376            assert!(matches!(
11377                cancelled.kind,
11378                AccountEventKind::OrderCancelled(_)
11379            ));
11380        }
11381
11382        /// A recovered fill that brings an order to its full quantity ends it, so a reconnect's
11383        /// check does not ask about it.
11384        #[tokio::test]
11385        async fn a_recovered_fill_that_completes_an_order_stops_it_being_held() {
11386            let known = KnownLiveOrders::shared(ExchangeId::AlpacaBroker);
11387            let key = alpaca_key("SPY", "a");
11388            known.lock().live(
11389                &key,
11390                Decimal::TWO,
11391                OrderKind::Limit,
11392                &Open::new(
11393                    VenueOrderId::Assigned(OrderId::new("ord-1")),
11394                    Utc::now(),
11395                    Decimal::ZERO,
11396                ),
11397            );
11398
11399            drive_recover_fills_with(
11400                vec![activity_json("act-1", "ord-1", "1", Some("1"))],
11401                new_dedup_cache(),
11402                &known,
11403            )
11404            .await;
11405            assert!(known.lock().contains(&key.cid), "half filled");
11406
11407            drive_recover_fills_with(
11408                vec![activity_json("act-2", "ord-1", "1", Some("2"))],
11409                new_dedup_cache(),
11410                &known,
11411            )
11412            .await;
11413            assert!(!known.lock().contains(&key.cid), "filled");
11414        }
11415
11416        /// The first check runs as soon as the loop is polled, and a failed one waits for its
11417        /// backoff rather than running again at once.
11418        #[tokio::test]
11419        async fn the_check_loop_runs_at_once_then_waits_out_a_failure() {
11420            let server = MockServer::start().await;
11421            Mock::given(method("GET"))
11422                .and(path("/v2/orders"))
11423                .respond_with(ResponseTemplate::new(503))
11424                .expect(1)
11425                .mount(&server)
11426                .await;
11427            let known = KnownLiveOrders::shared(ExchangeId::AlpacaBroker);
11428            hold(&known, &alpaca_key("SPY", "a"));
11429            let mut unchecked = UncheckedOrders::default();
11430            unchecked.open(known.lock().instruments());
11431            let config = Arc::new(AlpacaConfig::with_base_url(
11432                "test-key".into(),
11433                "test-secret".into(),
11434                server.uri(),
11435            ));
11436            let (tx, _rx) = mpsc::unbounded_channel();
11437
11438            let ran = tokio::time::timeout(
11439                Duration::from_secs(1),
11440                run_order_checks(
11441                    &reqwest::Client::new(),
11442                    &Arc::new(RateLimitTracker::new()),
11443                    &config,
11444                    &known,
11445                    &mut unchecked,
11446                    &tx,
11447                ),
11448            )
11449            .await;
11450
11451            assert!(ran.is_err(), "the loop never completes");
11452            assert!(
11453                unchecked.contains(&InstrumentNameExchange::new("SPY")),
11454                "SPY waits for its retry"
11455            );
11456            assert_eq!(
11457                server.received_requests().await.unwrap().len(),
11458                1,
11459                "the failed check is not run again at once"
11460            );
11461        }
11462
11463        /// Once the consumer has gone, the loop asks nothing.
11464        #[tokio::test]
11465        async fn the_check_loop_waits_once_the_consumer_has_gone() {
11466            let server = MockServer::start().await;
11467            Mock::given(method("GET"))
11468                .respond_with(ResponseTemplate::new(503))
11469                .expect(0)
11470                .mount(&server)
11471                .await;
11472            let known = KnownLiveOrders::shared(ExchangeId::AlpacaBroker);
11473            hold(&known, &alpaca_key("SPY", "a"));
11474            let mut unchecked = UncheckedOrders::default();
11475            unchecked.open(known.lock().instruments());
11476            let config = Arc::new(AlpacaConfig::with_base_url(
11477                "test-key".into(),
11478                "test-secret".into(),
11479                server.uri(),
11480            ));
11481            let (tx, rx) = mpsc::unbounded_channel();
11482            drop(rx);
11483
11484            let ran = tokio::time::timeout(
11485                Duration::from_millis(200),
11486                run_order_checks(
11487                    &reqwest::Client::new(),
11488                    &Arc::new(RateLimitTracker::new()),
11489                    &config,
11490                    &known,
11491                    &mut unchecked,
11492                    &tx,
11493                ),
11494            )
11495            .await;
11496
11497            assert!(ran.is_err(), "the loop never completes");
11498            assert!(
11499                server.received_requests().await.unwrap().is_empty(),
11500                "nothing asked once the consumer has gone"
11501            );
11502        }
11503
11504        async fn shortability_with_asset(
11505            symbol: &str,
11506            asset: serde_json::Value,
11507        ) -> (Result<Shortability, UnindexedClientError>, Vec<String>) {
11508            use crate::client::ShortabilityClient;
11509
11510            let server = MockServer::start().await;
11511            Mock::given(method("GET"))
11512                .respond_with(ResponseTemplate::new(200).set_body_json(asset))
11513                .mount(&server)
11514                .await;
11515            let result = client_for(&server)
11516                .fetch_shortability(&InstrumentNameExchange::new(symbol))
11517                .await;
11518            let paths = server
11519                .received_requests()
11520                .await
11521                .unwrap()
11522                .iter()
11523                .map(|request| request.url.path().to_owned())
11524                .collect();
11525            (result, paths)
11526        }
11527
11528        #[tokio::test]
11529        async fn an_easy_to_borrow_stock_is_shortable_at_no_fee() {
11530            let (result, paths) = shortability_with_asset(
11531                "AAPL",
11532                serde_json::json!({
11533                    "class": "us_equity",
11534                    "symbol": "AAPL",
11535                    "shortable": true,
11536                    "easy_to_borrow": true,
11537                    "borrow_status": "easy_to_borrow",
11538                }),
11539            )
11540            .await;
11541
11542            assert_eq!(
11543                result.unwrap(),
11544                Shortability::new()
11545                    .with_shortable(true)
11546                    .with_easy_to_borrow(true)
11547                    .with_fee_rate(Decimal::ZERO)
11548            );
11549            assert_eq!(paths, vec!["/v2/assets/AAPL"]);
11550        }
11551
11552        #[tokio::test]
11553        async fn a_hard_to_borrow_stock_has_no_known_fee() {
11554            let (result, _) = shortability_with_asset(
11555                "GME",
11556                serde_json::json!({
11557                    "class": "us_equity",
11558                    "shortable": true,
11559                    "easy_to_borrow": true,
11560                    "borrow_status": "hard_to_borrow",
11561                }),
11562            )
11563            .await;
11564
11565            assert_eq!(
11566                result.unwrap(),
11567                Shortability::new()
11568                    .with_shortable(true)
11569                    .with_easy_to_borrow(false),
11570                "borrow_status wins over the easy_to_borrow flag, and a hard-to-borrow fee is unknown"
11571            );
11572        }
11573
11574        #[tokio::test]
11575        async fn without_a_borrow_status_the_easy_to_borrow_flag_is_read() {
11576            let (result, _) = shortability_with_asset(
11577                "AAPL",
11578                serde_json::json!({ "class": "us_equity", "shortable": false, "easy_to_borrow": false }),
11579            )
11580            .await;
11581
11582            assert_eq!(
11583                result.unwrap(),
11584                Shortability::new()
11585                    .with_shortable(false)
11586                    .with_easy_to_borrow(false)
11587            );
11588        }
11589
11590        #[tokio::test]
11591        async fn an_asset_without_borrow_fields_is_unknown_but_for_shortable() {
11592            let (result, paths) = shortability_with_asset(
11593                "BTC/USD",
11594                serde_json::json!({ "class": "crypto", "symbol": "BTC/USD", "shortable": false }),
11595            )
11596            .await;
11597
11598            assert_eq!(result.unwrap(), Shortability::new().with_shortable(false));
11599            assert_eq!(
11600                paths,
11601                vec!["/v2/assets/BTC%2FUSD"],
11602                "a crypto pair's slash is escaped, not read as a path separator"
11603            );
11604        }
11605
11606        #[tokio::test]
11607        async fn an_option_has_no_shortability() {
11608            let (result, _) = shortability_with_asset(
11609                "AAPL250117C00150000",
11610                serde_json::json!({ "class": "us_option", "shortable": true }),
11611            )
11612            .await;
11613
11614            assert!(
11615                matches!(
11616                    result,
11617                    Err(UnindexedClientError::Api(ApiError::InstrumentInvalid(ref instrument, ref reason)))
11618                        if instrument.name().as_str() == "AAPL250117C00150000"
11619                            && reason.contains("option")
11620                ),
11621                "{result:?}"
11622            );
11623        }
11624
11625        #[tokio::test]
11626        async fn an_unknown_symbol_is_an_invalid_instrument() {
11627            use crate::client::ShortabilityClient;
11628
11629            let server = MockServer::start().await;
11630            Mock::given(method("GET"))
11631                .and(path("/v2/assets/NOPE"))
11632                .respond_with(
11633                    ResponseTemplate::new(404).set_body_json(
11634                        serde_json::json!({ "message": "asset not found for NOPE" }),
11635                    ),
11636                )
11637                .mount(&server)
11638                .await;
11639
11640            let result = client_for(&server)
11641                .fetch_shortability(&InstrumentNameExchange::new("NOPE"))
11642                .await;
11643
11644            assert!(
11645                matches!(
11646                    result,
11647                    Err(UnindexedClientError::Api(ApiError::InstrumentInvalid(ref instrument, _)))
11648                        if instrument.name().as_str() == "NOPE"
11649                ),
11650                "{result:?}"
11651            );
11652        }
11653
11654        /// Answers every request with a 429. Every answer but the last says `Retry-After: 0`, so
11655        /// the client's retries do not wait; the last carries `last`'s header, if any.
11656        struct RateLimited {
11657            call: std::sync::atomic::AtomicU32,
11658            last: Option<(&'static str, String)>,
11659        }
11660
11661        impl RateLimited {
11662            fn mount(last: Option<(&'static str, String)>) -> Self {
11663                Self {
11664                    call: std::sync::atomic::AtomicU32::new(0),
11665                    last,
11666                }
11667            }
11668        }
11669
11670        impl Respond for RateLimited {
11671            fn respond(&self, _: &Request) -> ResponseTemplate {
11672                let call = self.call.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1;
11673                let response = ResponseTemplate::new(429)
11674                    .set_body_json(serde_json::json!({"message": "too many requests"}));
11675                if call < MAX_RATE_LIMIT_ATTEMPTS {
11676                    return response.insert_header("retry-after", "0");
11677                }
11678                match &self.last {
11679                    Some((name, value)) => response.insert_header(*name, value.as_str()),
11680                    None => response,
11681                }
11682            }
11683        }
11684
11685        fn unix_now() -> u64 {
11686            std::time::SystemTime::now()
11687                .duration_since(std::time::UNIX_EPOCH)
11688                .unwrap()
11689                .as_secs()
11690        }
11691
11692        #[tokio::test]
11693        async fn exhausted_429_retries_report_alpacas_wait() {
11694            let server = MockServer::start().await;
11695            Mock::given(method("GET"))
11696                .and(path("/v2/account"))
11697                .respond_with(RateLimited::mount(Some(("retry-after", "7".to_string()))))
11698                .mount(&server)
11699                .await;
11700
11701            let http = reqwest::Client::new();
11702            let rl = RateLimitTracker::new();
11703            let url = format!("{}/v2/account", server.uri());
11704            let error = rest_with_retry::<serde_json::Value>(&rl, || http.get(&url))
11705                .await
11706                .unwrap_err();
11707
11708            let UnindexedClientError::Api(error) = error else {
11709                panic!("expected Api, got {error:?}");
11710            };
11711            assert_eq!(error.retry_after(), Some(Duration::from_secs(7)));
11712            let blocked_until = rl
11713                .blocked_until
11714                .lock()
11715                .expect("the last refusal pauses the client");
11716            assert!(
11717                blocked_until >= tokio::time::Instant::now() + Duration::from_secs(6),
11718                "the client's next request waits for Alpaca's wait"
11719            );
11720            assert_eq!(
11721                server.received_requests().await.unwrap().len(),
11722                MAX_RATE_LIMIT_ATTEMPTS as usize
11723            );
11724        }
11725
11726        #[tokio::test]
11727        async fn exhausted_429_retries_read_the_reset_time_without_retry_after() {
11728            let server = MockServer::start().await;
11729            Mock::given(method("GET"))
11730                .and(path("/v2/account"))
11731                .respond_with(RateLimited::mount(Some((
11732                    "x-ratelimit-reset",
11733                    (unix_now() + 30).to_string(),
11734                ))))
11735                .mount(&server)
11736                .await;
11737
11738            let http = reqwest::Client::new();
11739            let rl = RateLimitTracker::new();
11740            let url = format!("{}/v2/account", server.uri());
11741            let error = rest_with_retry::<serde_json::Value>(&rl, || http.get(&url))
11742                .await
11743                .unwrap_err();
11744
11745            let UnindexedClientError::Api(ApiError::RateLimit {
11746                retry_after: Some(wait),
11747                ..
11748            }) = error
11749            else {
11750                panic!("expected a RateLimit with a wait, got {error:?}");
11751            };
11752            assert!(
11753                (Duration::from_secs(29)..=Duration::from_secs(30)).contains(&wait),
11754                "{wait:?}"
11755            );
11756        }
11757
11758        #[tokio::test]
11759        async fn exhausted_429_retries_without_a_hint_report_none() {
11760            let server = MockServer::start().await;
11761            Mock::given(method("GET"))
11762                .and(path("/v2/account"))
11763                .respond_with(RateLimited::mount(None))
11764                .mount(&server)
11765                .await;
11766
11767            let http = reqwest::Client::new();
11768            let rl = RateLimitTracker::new();
11769            let url = format!("{}/v2/account", server.uri());
11770            let error = rest_with_retry::<serde_json::Value>(&rl, || http.get(&url))
11771                .await
11772                .unwrap_err();
11773
11774            assert!(
11775                matches!(
11776                    error,
11777                    UnindexedClientError::Api(ApiError::RateLimit {
11778                        retry_after: None,
11779                        ..
11780                    })
11781                ),
11782                "{error:?}"
11783            );
11784        }
11785
11786        #[tokio::test]
11787        async fn an_exhausted_cancel_reports_alpacas_wait() {
11788            let server = MockServer::start().await;
11789            Mock::given(method("DELETE"))
11790                .and(path("/v2/orders/abc"))
11791                .respond_with(RateLimited::mount(Some(("retry-after", "5".to_string()))))
11792                .mount(&server)
11793                .await;
11794
11795            let http = reqwest::Client::new();
11796            let rl = RateLimitTracker::new();
11797            let url = format!("{}/v2/orders/abc", server.uri());
11798            let error = rest_delete_with_retry(&rl, || http.delete(&url))
11799                .await
11800                .unwrap_err();
11801
11802            let UnindexedOrderError::Rejected(error) = error else {
11803                panic!("expected Rejected, got {error:?}");
11804            };
11805            assert_eq!(error.retry_after(), Some(Duration::from_secs(5)));
11806            assert!(
11807                rl.blocked_until.lock().is_some(),
11808                "the last refusal pauses the client"
11809            );
11810        }
11811    }
11812}