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}
461
462/// Serde default for [`AlpacaConfig::paper`]: an absent `paper` field deserializes to the **safe**
463/// paper environment (`true`).
464///
465/// `#[serde(default = "…")]` requires a named function (it cannot take a literal), so this exists
466/// purely to supply that default to the derive.
467fn default_paper() -> bool {
468    true
469}
470
471// Custom Debug to avoid leaking credentials in logs.
472impl std::fmt::Debug for AlpacaConfig {
473    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
474        f.debug_struct("AlpacaConfig")
475            .field("api_key", &"***")
476            .field("secret_key", &"***")
477            .field("paper", &self.paper)
478            .finish()
479    }
480}
481
482impl AlpacaConfig {
483    /// Create a new Alpaca config using the **safe** paper-trading endpoints (alias for
484    /// [`paper`](Self::paper)).
485    pub fn new(api_key: String, secret_key: String) -> Self {
486        Self::paper(api_key, secret_key)
487    }
488
489    /// Create a config targeting Alpaca's **paper-trading** endpoints (simulated funds).
490    pub fn paper(api_key: String, secret_key: String) -> Self {
491        Self {
492            api_key,
493            secret_key,
494            paper: true,
495            #[cfg(test)]
496            base_url_override: None,
497        }
498    }
499
500    /// Create a config targeting Alpaca's **production** endpoints.
501    ///
502    /// ⚠️ Production trades execute against the live account with **real funds**. Prefer
503    /// [`paper`](Self::paper) unless you explicitly intend live execution.
504    pub fn production(api_key: String, secret_key: String) -> Self {
505        Self {
506            api_key,
507            secret_key,
508            paper: false,
509            #[cfg(test)]
510            base_url_override: None,
511        }
512    }
513
514    /// Build a config from environment variables.
515    ///
516    /// Reads:
517    /// - `ALPACA_API_KEY` (required) — API key id.
518    /// - `ALPACA_SECRET_KEY` (required) — API secret.
519    /// - `ALPACA_PAPER` (optional) — `"true"`/`"false"` (case-insensitive). **Absent ⇒ the safe
520    ///   paper environment.** Set `ALPACA_PAPER=false` to target production (real funds).
521    ///
522    /// # Errors
523    ///
524    /// Returns [`AlpacaConfigError`] (never panics):
525    /// - a required credential var is unset ([`MissingApiKey`](AlpacaConfigError::MissingApiKey) /
526    ///   [`MissingSecretKey`](AlpacaConfigError::MissingSecretKey)) or holds non-UTF-8
527    ///   ([`InvalidApiKey`](AlpacaConfigError::InvalidApiKey) /
528    ///   [`InvalidSecretKey`](AlpacaConfigError::InvalidSecretKey));
529    /// - `ALPACA_PAPER` is neither `true` nor `false`, or holds non-UTF-8
530    ///   ([`InvalidPaper`](AlpacaConfigError::InvalidPaper)).
531    pub fn from_env() -> Result<Self, AlpacaConfigError> {
532        let api_key = match std::env::var("ALPACA_API_KEY") {
533            Ok(value) => value,
534            Err(std::env::VarError::NotPresent) => return Err(AlpacaConfigError::MissingApiKey),
535            Err(std::env::VarError::NotUnicode(_)) => {
536                return Err(AlpacaConfigError::InvalidApiKey);
537            }
538        };
539        let secret_key = match std::env::var("ALPACA_SECRET_KEY") {
540            Ok(value) => value,
541            Err(std::env::VarError::NotPresent) => return Err(AlpacaConfigError::MissingSecretKey),
542            Err(std::env::VarError::NotUnicode(_)) => {
543                return Err(AlpacaConfigError::InvalidSecretKey);
544            }
545        };
546
547        let paper = match std::env::var("ALPACA_PAPER") {
548            Ok(value) => parse_env_bool(&value).ok_or(AlpacaConfigError::InvalidPaper(value))?,
549            Err(std::env::VarError::NotPresent) => true,
550            // The toggle value is not secret, so echo it (lossily) like the parse-failure arm above —
551            // an actionable "got X" beats a hardcoded sentinel.
552            Err(std::env::VarError::NotUnicode(value)) => {
553                return Err(AlpacaConfigError::InvalidPaper(
554                    value.to_string_lossy().into_owned(),
555                ));
556            }
557        };
558
559        if paper {
560            Ok(Self::paper(api_key, secret_key))
561        } else {
562            Ok(Self::production(api_key, secret_key))
563        }
564    }
565
566    /// Test-only: create config with a custom base URL for wiremock testing.
567    #[cfg(test)]
568    pub fn with_base_url(api_key: String, secret_key: String, base_url: String) -> Self {
569        Self {
570            api_key,
571            secret_key,
572            paper: true,
573            base_url_override: Some(base_url),
574        }
575    }
576
577    pub fn api_key(&self) -> &str {
578        &self.api_key
579    }
580
581    /// Base URL for REST API calls.
582    ///
583    /// In test builds, checks `base_url_override` first to allow wiremock testing.
584    pub fn rest_base_url(&self) -> &str {
585        #[cfg(test)]
586        if let Some(ref url) = self.base_url_override {
587            return url.as_str();
588        }
589        if self.paper {
590            "https://paper-api.alpaca.markets"
591        } else {
592            "https://api.alpaca.markets"
593        }
594    }
595
596    /// WebSocket URL for trade_updates stream.
597    pub fn ws_url(&self) -> &'static str {
598        if self.paper {
599            "wss://paper-api.alpaca.markets/stream"
600        } else {
601            "wss://api.alpaca.markets/stream"
602        }
603    }
604}
605
606#[derive(Debug, PartialEq, thiserror::Error)]
607pub enum AlpacaConfigError {
608    #[error("ALPACA_API_KEY environment variable not set")]
609    MissingApiKey,
610
611    // No payload: the raw value is secret-key material, so it must never be echoed into an error
612    // message or log. The variant name already identifies which credential var is non-UTF-8.
613    #[error("ALPACA_API_KEY environment variable is not valid UTF-8")]
614    InvalidApiKey,
615
616    #[error("ALPACA_SECRET_KEY environment variable not set")]
617    MissingSecretKey,
618
619    #[error("ALPACA_SECRET_KEY environment variable is not valid UTF-8")]
620    InvalidSecretKey,
621
622    #[error("ALPACA_PAPER must be true or false, got {0}")]
623    InvalidPaper(String),
624}
625
626// ---------------------------------------------------------------------------
627// REST response serde types
628// ---------------------------------------------------------------------------
629
630/// The fields of GET /v2/account the USD balance is built from.
631///
632/// Amounts are decoded as decimals, so a malformed one fails the request instead of reading as
633/// zero.
634#[derive(Debug, Deserialize)]
635struct AlpacaAccount {
636    /// Settled and unsettled cash. Negative when the account has borrowed on margin; a short
637    /// sale's proceeds are credited to it.
638    #[serde(with = "rust_decimal::serde::str")]
639    cash: Decimal,
640    /// Buying power for securities that cannot be bought on margin. On a margin account it is
641    /// equity less the initial margin requirement, so it counts the loan value of held
642    /// marginable stock and can exceed cash; a short sale's proceeds do not raise it.
643    #[serde(with = "rust_decimal::serde::str")]
644    non_marginable_buying_power: Decimal,
645}
646
647/// A single position returned by GET /v2/positions.
648///
649/// Amounts are decoded as decimals, so a malformed one fails the request instead of reading as
650/// zero or as a flat position.
651#[derive(Debug, Deserialize)]
652struct AlpacaPosition {
653    /// Exchange symbol (e.g., "BTCUSD" for crypto, "AAPL" for equity, an OCC symbol for an
654    /// option).
655    symbol: String,
656    /// Asset class: "us_equity", "crypto", "us_option".
657    asset_class: String,
658    /// Direction of the position. The size is taken from `qty`'s magnitude and the sign from
659    /// this, so a short reads as short whichever sign `qty` carries. Missing reads as
660    /// [`AlpacaPositionSide::Unknown`].
661    #[serde(default)]
662    side: AlpacaPositionSide,
663    /// Quantity held: shares, contracts, or base currency for crypto.
664    #[serde(with = "rust_decimal::serde::str")]
665    qty: Decimal,
666    /// Quantity available to trade (not locked in open orders).
667    #[serde(with = "rust_decimal::serde::str")]
668    qty_available: Decimal,
669    /// Average entry price per unit.
670    #[serde(default, with = "rust_decimal::serde::str_option")]
671    avg_entry_price: Option<Decimal>,
672    /// Unrealised profit or loss of the whole position, in USD.
673    #[serde(default, with = "rust_decimal::serde::str_option")]
674    unrealized_pl: Option<Decimal>,
675}
676
677/// Direction of an [`AlpacaPosition`].
678#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize)]
679#[serde(rename_all = "lowercase")]
680enum AlpacaPositionSide {
681    Long,
682    Short,
683    /// A missing side or a value Alpaca does not document. Decoded rather than rejected so that
684    /// one position cannot fail the whole response, which also carries the crypto balances; a
685    /// position that needs a direction fails in [`convert_positions`] instead.
686    #[default]
687    #[serde(other)]
688    Unknown,
689}
690
691#[derive(Debug, Deserialize)]
692struct AlpacaOrderResponse {
693    id: String,
694    client_order_id: Option<String>,
695    symbol: String,
696    qty: Option<String>,
697    filled_qty: String,
698    /// The average price of the order's fills, when any filled.
699    #[serde(default)]
700    filled_avg_price: Option<String>,
701    /// The order's status: `new`, `filled`, `canceled`, and so on. Read by the lookup of how an
702    /// order ended and from the response to placing one. Optional so that a response without it
703    /// still decodes.
704    #[serde(default)]
705    status: Option<String>,
706    side: String,
707    #[serde(rename = "type")]
708    order_type: String,
709    time_in_force: String,
710    limit_price: Option<String>,
711    stop_price: Option<String>,
712    trail_percent: Option<String>,
713    trail_price: Option<String>,
714    created_at: String,
715    /// When the order last changed state. Nullable in Alpaca's schema, so callers fall back to
716    /// `created_at` via [`order_state_time`].
717    updated_at: Option<String>,
718}
719
720#[derive(Debug, Deserialize)]
721struct AlpacaActivity {
722    id: String,
723    order_id: String,
724    symbol: String,
725    side: String,
726    price: String,
727    qty: String,
728    transaction_time: String,
729    /// The order's cumulative filled quantity as of this execution, as Alpaca reported it --
730    /// the same figure the WebSocket path reads from `order.filled_qty`.
731    ///
732    /// Alpaca's schema lists this on trade activities without qualifying by fill type -- unlike
733    /// `leaves_qty`, which it documents as partial-fill-specific. It is parsed as optional
734    /// regardless, so an omission degrades to the fallback below rather than discarding the
735    /// whole activity.
736    cum_qty: Option<String>,
737}
738
739/// A non-trade account activity, as Alpaca's activities endpoint serves it: a cash movement such as
740/// interest or a fee, read by [`AlpacaClient::fetch_cash_flows`], or an option exercise,
741/// assignment or expiry, read by [`AlpacaClient::fetch_option_events`]. A cash flow uses the
742/// amount, currency and symbol; an option event uses the quantity, price, symbol and date. Every
743/// field but the id and type is optional in Alpaca's schema, so a record missing one still decodes
744/// and the conversion decides what it can do without it.
745#[derive(Debug, Deserialize)]
746struct AlpacaNonTradeActivity {
747    id: String,
748    activity_type: String,
749    #[serde(default)]
750    activity_sub_type: Option<String>,
751    /// When Alpaca created the activity, which its `after` and `until` filter on. For a fee, this
752    /// is typically the day after the trade it was charged on.
753    #[serde(default)]
754    created_at: Option<String>,
755    /// The day the activity occurred or settled, as `YYYY-MM-DD`. A cash flow's time uses
756    /// `created_at`, which `after` and `until` filter on. An option event falls back to it when
757    /// `created_at` is missing, and pairs the legs of an exercise or assignment by it.
758    #[serde(default)]
759    date: Option<String>,
760    /// Signed: negative when the account paid.
761    #[serde(default)]
762    net_amount: Option<String>,
763    /// Signed: negative when the account sold. Contracts on an option event, or shares on the
764    /// share leg (`OPTRD`) of an exercise or assignment.
765    #[serde(default)]
766    qty: Option<String>,
767    /// On the share leg (`OPTRD`) of an exercise or assignment, the strike the shares traded at.
768    #[serde(default)]
769    price: Option<String>,
770    #[serde(default)]
771    currency: Option<String>,
772    #[serde(default)]
773    symbol: Option<String>,
774    /// `executed`, `correct` or `canceled`.
775    #[serde(default)]
776    status: Option<String>,
777}
778
779/// The body of an Alpaca error response: `{"code": 40310000, "message": "..."}`.
780///
781/// `code` is absent from some responses, notably the bare `{"message": "forbidden."}` that a
782/// wrong key, secret or host gets.
783#[derive(Debug, Deserialize)]
784struct AlpacaApiError {
785    code: Option<i64>,
786    message: String,
787}
788
789impl AlpacaApiError {
790    /// Decode an error body, keeping the raw text as the message when it is not Alpaca's JSON.
791    fn from_body(bytes: &[u8]) -> Self {
792        serde_json::from_slice(bytes).unwrap_or_else(|_| Self {
793            code: None,
794            message: String::from_utf8_lossy(bytes).into_owned(),
795        })
796    }
797}
798
799// ---------------------------------------------------------------------------
800// AlpacaPositionIntent
801// ---------------------------------------------------------------------------
802
803/// Explicit position intent for Alpaca order placement.
804///
805/// Required for options orders; valid (but optional) for equities.
806/// Omit entirely for crypto orders (causes 422 Unprocessable Entity).
807///
808/// Use `AlpacaClient::open_order_with_intent` to supply a specific intent
809/// instead of the heuristic mapping used by the `ExecutionClient` trait impl.
810#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
811#[serde(rename_all = "snake_case")]
812pub enum AlpacaPositionIntent {
813    BuyToOpen,
814    BuyToClose,
815    SellToOpen,
816    SellToClose,
817}
818
819// ---------------------------------------------------------------------------
820// Bracket order types
821// ---------------------------------------------------------------------------
822
823/// Take-profit parameters for Alpaca bracket orders.
824///
825/// The take-profit leg is always a limit order at the specified price.
826#[derive(Debug, Serialize)]
827struct TakeProfitParams {
828    limit_price: String,
829}
830
831/// Stop-loss parameters for Alpaca bracket orders.
832///
833/// When `limit_price` is `None`, the stop-loss is a stop (market) order.
834/// When `limit_price` is `Some`, the stop-loss becomes a stop-limit order.
835#[derive(Debug, Serialize)]
836struct StopLossParams {
837    stop_price: String,
838    #[serde(skip_serializing_if = "Option::is_none")]
839    limit_price: Option<String>,
840}
841
842/// Request to place a bracket order (entry + take-profit + stop-loss).
843///
844/// A bracket order consists of three linked orders submitted in a single request:
845/// 1. **Entry**: Limit order to enter the position
846/// 2. **Take Profit**: Limit order to exit at profit target
847/// 3. **Stop Loss**: Stop or stop-limit order to exit at loss limit
848///
849/// When either the take-profit or stop-loss fills, Alpaca automatically cancels
850/// the other leg.
851///
852/// # Constraints
853///
854/// - `time_in_force` must be `Day` or `GoodUntilCancelled` (no extended hours)
855/// - Entry order type is always `Limit`
856/// - Take-profit is always a `Limit` order
857/// - Stop-loss is a `Stop` order (or `StopLimit` if `stop_loss_limit_price` is set)
858///
859/// # Example
860///
861/// ```ignore
862/// let request = AlpacaBracketOrderRequest::new(
863///     "AAPL".into(),
864///     StrategyId::new("momentum"),
865///     ClientOrderId::new("bracket-001"),
866///     Side::Buy,
867///     dec!(10),
868///     dec!(150.00),  // entry
869///     dec!(160.00),  // take profit
870///     dec!(145.00),  // stop loss
871///     TimeInForce::GoodUntilCancelled { post_only: false },
872/// );
873/// // For stop-limit SL: .with_stop_loss_limit_price(dec!(144.00))
874/// let result = client.open_bracket_order(request).await;
875/// ```
876#[non_exhaustive]
877#[derive(Debug, Clone)]
878pub struct AlpacaBracketOrderRequest {
879    /// Instrument to trade.
880    pub instrument: InstrumentNameExchange,
881    /// Strategy identifier for order correlation.
882    pub strategy: StrategyId,
883    /// Client order ID for the parent (entry) order.
884    pub cid: ClientOrderId,
885    /// Buy or Sell for the entry order (exits use opposite side).
886    pub side: Side,
887    /// Number of shares/contracts.
888    pub quantity: Decimal,
889    /// Entry limit price.
890    pub entry_price: Decimal,
891    /// Take-profit limit price.
892    pub take_profit_price: Decimal,
893    /// Stop-loss trigger price.
894    pub stop_loss_price: Decimal,
895    /// Optional stop-loss limit price. When set, makes the stop-loss a stop-limit order.
896    pub stop_loss_limit_price: Option<Decimal>,
897    /// Time-in-force for all legs. Must be `Day` or `GoodUntilCancelled`.
898    pub time_in_force: TimeInForce,
899}
900
901impl AlpacaBracketOrderRequest {
902    /// Create a new bracket order request.
903    ///
904    /// For a stop-limit stop-loss leg, chain `.with_stop_loss_limit_price()`.
905    #[allow(clippy::too_many_arguments)] // Bracket orders inherently need many params
906    pub fn new(
907        instrument: InstrumentNameExchange,
908        strategy: StrategyId,
909        cid: ClientOrderId,
910        side: Side,
911        quantity: Decimal,
912        entry_price: Decimal,
913        take_profit_price: Decimal,
914        stop_loss_price: Decimal,
915        time_in_force: TimeInForce,
916    ) -> Self {
917        Self {
918            instrument,
919            strategy,
920            cid,
921            side,
922            quantity,
923            entry_price,
924            take_profit_price,
925            stop_loss_price,
926            stop_loss_limit_price: None,
927            time_in_force,
928        }
929    }
930
931    /// Set the stop-loss limit price, converting the SL leg to a stop-limit order.
932    #[must_use]
933    pub fn with_stop_loss_limit_price(mut self, price: Decimal) -> Self {
934        self.stop_loss_limit_price = Some(price);
935        self
936    }
937}
938
939/// Result of placing an Alpaca bracket order.
940///
941/// Contains the parent order with its state. The take-profit and stop-loss legs
942/// are managed by Alpaca and their status can be queried via `fetch_open_orders`.
943///
944/// # Note
945///
946/// Unlike IBKR which returns three separate orders, Alpaca's bracket API returns
947/// a single parent order. The child legs (TP/SL) are implicitly created and linked
948/// by Alpaca. Use `fetch_open_orders` to retrieve all legs after placement.
949#[non_exhaustive]
950#[derive(Debug, Clone)]
951pub struct AlpacaBracketOrderResult {
952    /// Parent (entry) order with its current state.
953    pub parent: Order<ExchangeId, InstrumentNameExchange, UnindexedOrderState>,
954}
955
956// ---------------------------------------------------------------------------
957// REST order request body
958// ---------------------------------------------------------------------------
959
960#[derive(Debug, Serialize)]
961struct AlpacaOrderRequest<'a> {
962    symbol: &'a str,
963    qty: String,
964    side: &'static str,
965    #[serde(rename = "type")]
966    order_type: &'static str,
967    time_in_force: &'static str,
968    #[serde(skip_serializing_if = "Option::is_none")]
969    limit_price: Option<String>,
970    #[serde(skip_serializing_if = "Option::is_none")]
971    stop_price: Option<String>,
972    #[serde(skip_serializing_if = "Option::is_none")]
973    trail_percent: Option<String>,
974    #[serde(skip_serializing_if = "Option::is_none")]
975    trail_price: Option<String>,
976    #[serde(skip_serializing_if = "Option::is_none")]
977    client_order_id: Option<&'a str>,
978    // position_intent: heuristic mapping (buy→buy_to_open, sell→sell_to_close).
979    // Correct for directional long-only strategies. Omit for exchanges/asset
980    // classes that don't require it (stocks/crypto ignore this field).
981    #[serde(skip_serializing_if = "Option::is_none")]
982    position_intent: Option<AlpacaPositionIntent>,
983    // Bracket order fields (order_class, take_profit, stop_loss).
984    // Set order_class to "bracket" and populate TP/SL for bracket orders.
985    #[serde(skip_serializing_if = "Option::is_none")]
986    order_class: Option<&'static str>,
987    #[serde(skip_serializing_if = "Option::is_none")]
988    take_profit: Option<TakeProfitParams>,
989    #[serde(skip_serializing_if = "Option::is_none")]
990    stop_loss: Option<StopLossParams>,
991}
992
993// ---------------------------------------------------------------------------
994// WebSocket message types
995// ---------------------------------------------------------------------------
996
997/// Outer container for all Alpaca stream messages.
998///
999/// `data` is kept as a [`serde_json::value::RawValue`] to avoid allocating a full DOM tree
1000/// for heartbeats and auth/listening acks that never reach `AlpacaTradeUpdate` parsing.
1001#[derive(Debug, Deserialize)]
1002struct AlpacaStreamMessage<'a> {
1003    // Short well-known values ("trade_updates", "listening", "authorization")
1004    // all fit inline in SmolStr — avoids one heap alloc per WS message.
1005    stream: SmolStr,
1006    #[serde(borrow)]
1007    data: &'a serde_json::value::RawValue,
1008}
1009
1010/// Parsed payload of a `trade_updates` event.
1011///
1012/// Numeric and timestamp fields borrow directly from the `RawValue` input buffer
1013/// (`#[serde(borrow)]`), propagating the zero-copy design of `AlpacaStreamMessage`.
1014/// This eliminates 4–6 heap allocations per fill event. The borrow is valid because
1015/// Alpaca's numeric and timestamp strings, and its execution ids (UUIDs), contain no JSON escape
1016/// sequences.
1017#[derive(Debug, Deserialize)]
1018struct AlpacaTradeUpdate<'a> {
1019    // Short event tag ("fill", "partial_fill", "new", ...) — fits inline.
1020    event: SmolStr,
1021    #[serde(borrow)]
1022    order: AlpacaOrderWs<'a>,
1023    /// Fill price for this specific execution (None for non-fill events).
1024    #[serde(borrow)]
1025    price: Option<&'a str>,
1026    /// Quantity for this specific execution (None for non-fill events).
1027    #[serde(borrow)]
1028    qty: Option<&'a str>,
1029    #[serde(borrow)]
1030    timestamp: Option<&'a str>,
1031    /// Alpaca's id for this event. On a fill it is the execution's id, the same as the UUID after
1032    /// `::` in that fill's FILL activity id (see [`activity_execution_id`]), so it is the fill's
1033    /// [`TradeId`] on every path. On a `trade_correct` it is taken as the corrected execution's id.
1034    #[serde(borrow)]
1035    execution_id: Option<&'a str>,
1036    /// On `trade_bust` and `trade_correct` only: the `execution_id` of the fill busted or
1037    /// corrected, per Alpaca's Broker API schema (`TradeUpdateEventV2`). The Trading API's docs do
1038    /// not list either event, so this is unverified against a real frame.
1039    #[serde(borrow)]
1040    previous_execution_id: Option<&'a str>,
1041}
1042
1043/// Order state embedded in a `trade_updates` WebSocket event.
1044#[derive(Debug, Deserialize)]
1045struct AlpacaOrderWs<'a> {
1046    // UUIDs (36 chars) heap-allocate in SmolStr, but using SmolStr directly avoids
1047    // an intermediate String allocation when serde deserialises the field.
1048    id: SmolStr,
1049    client_order_id: Option<SmolStr>,
1050    // Ticker symbols ("AAPL", "BTC/USD") fit inline in SmolStr (≤23 bytes),
1051    // eliminating the heap allocation entirely for most symbols.
1052    symbol: SmolStr,
1053    #[serde(borrow)]
1054    qty: Option<&'a str>,
1055    // Alpaca guarantees `filled_qty` for fill/partial_fill and most lifecycle
1056    // events, but some event types (e.g. `rejected`) may omit the field.
1057    // Using Option avoids a deserialization failure that would silently drop
1058    // the event. A snapshot of a live order reads its absence as nothing filled
1059    // yet; a cancel reads it as unknown; the dedup key, and a fill's fallback trade id, read it
1060    // as "0".
1061    #[serde(borrow)]
1062    filled_qty: Option<&'a str>,
1063    // Short enums ("buy"/"sell", "market"/"limit"/..., "day"/"gtc"/..., status)
1064    // — all fit inline in SmolStr.
1065    side: SmolStr,
1066    #[serde(rename = "type")]
1067    order_type: SmolStr,
1068    time_in_force: SmolStr,
1069    #[serde(borrow)]
1070    limit_price: Option<&'a str>,
1071    #[serde(borrow)]
1072    stop_price: Option<&'a str>,
1073    #[serde(borrow)]
1074    trail_percent: Option<&'a str>,
1075    #[serde(borrow)]
1076    trail_price: Option<&'a str>,
1077    status: SmolStr,
1078}
1079
1080// ---------------------------------------------------------------------------
1081// AlpacaClient
1082// ---------------------------------------------------------------------------
1083
1084/// Alpaca execution client supporting options, equities, and crypto via the
1085/// single unified Alpaca trading API.
1086///
1087/// All three asset classes share the same REST and WebSocket endpoints. The
1088/// key behavioral differences handled transparently:
1089/// - Options: `position_intent` field is required (detected by OCC symbol format)
1090/// - Crypto: `position_intent` is omitted (not a valid field for crypto orders);
1091///   fractional quantities are supported natively via `Decimal::to_string()`
1092/// - Equities: `position_intent` is valid but optional for long-only strategies
1093///
1094/// # Rate limits
1095/// Once a response reports no requests left in the current window (`X-Ratelimit-Remaining: 0`),
1096/// every request, orders included, waits until the window resets (`X-Ratelimit-Reset`, at most a
1097/// minute away), so it is not refused. A 429 waits the same way, for the wait Alpaca gives or a
1098/// minute without one, and is retried. Once the retries are exhausted the request fails with
1099/// [`ApiError::RateLimit`], carrying that wait, and later requests still wait for it.
1100///
1101/// # Fees, interest and borrow fees
1102/// Every [`Trade`] this client reports carries zero fees. Equities and options trade without
1103/// commission. Alpaca's fill events carry no fee for crypto either: it is taken from the asset
1104/// the trade credits, so it shows only in that asset's balance.
1105///
1106/// Alpaca posts its other charges, such as margin interest and fees, to the account's cash as
1107/// account activities. A USD balance's `total` is that cash, so it includes a charge once Alpaca
1108/// has posted it. [`ExecutionClient::account_stream`] delivers none of them, nor any balance:
1109/// Alpaca streams neither. [`AlpacaClient::fetch_cash_flows`] reads them on request. PnL computed
1110/// from fills excludes them.
1111///
1112/// Cloning is cheap: all inner state is behind `Arc`.
1113#[derive(Clone)]
1114pub struct AlpacaClient {
1115    config: Arc<AlpacaConfig>,
1116    /// reqwest client with APCA-API-KEY-ID and APCA-API-SECRET-KEY pre-set as
1117    /// default headers — every request carries auth automatically.
1118    http: reqwest::Client,
1119    rate_limiter: Arc<RateLimitTracker>,
1120    /// Pre-allocated `/v2/orders` endpoint URL to avoid allocation per request.
1121    orders_url: String,
1122    /// The orders seen live and not yet seen end, which a reconnect asks about. Shared by every
1123    /// clone and every account stream, since an order placed through one ends on any of them.
1124    known_live: SharedKnownLiveOrders,
1125}
1126
1127impl std::fmt::Debug for AlpacaClient {
1128    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1129        f.debug_struct("AlpacaClient")
1130            .field("paper", &self.config.paper)
1131            .finish_non_exhaustive()
1132    }
1133}
1134
1135impl AlpacaClient {
1136    /// Build a `reqwest::Client` with Alpaca auth headers pre-set.
1137    ///
1138    /// # Panics
1139    ///
1140    /// Panics if the API key or secret contains characters that are invalid
1141    /// in an HTTP header value (non-ASCII or control characters).
1142    #[allow(clippy::expect_used)] // Documented panic: invalid credentials detected at startup
1143    fn build_http(config: &AlpacaConfig) -> reqwest::Client {
1144        use reqwest::header::{HeaderMap, HeaderName, HeaderValue};
1145        let mut headers = HeaderMap::new();
1146        // HTTP header names are case-insensitive; use lowercase per HTTP/2 convention.
1147        headers.insert(
1148            HeaderName::from_static("apca-api-key-id"),
1149            HeaderValue::from_str(&config.api_key)
1150                .expect("Alpaca API key contains invalid header characters"),
1151        );
1152        headers.insert(
1153            HeaderName::from_static("apca-api-secret-key"),
1154            HeaderValue::from_str(&config.secret_key)
1155                .expect("Alpaca secret key contains invalid header characters"),
1156        );
1157        reqwest::Client::builder()
1158            .default_headers(headers)
1159            .build()
1160            .expect("failed to build reqwest client for Alpaca")
1161    }
1162
1163    fn base_url(&self) -> &str {
1164        self.config.rest_base_url()
1165    }
1166
1167    /// Hold each of `orders` as live, for a reconnect to ask about if the stream misses its end.
1168    fn remember_live<'a>(
1169        &self,
1170        orders: impl IntoIterator<Item = &'a Order<ExchangeId, InstrumentNameExchange, Open>>,
1171    ) {
1172        let mut known = self.known_live.lock();
1173        for order in orders {
1174            known.live(&order.key, order.quantity, order.kind, &order.state);
1175        }
1176    }
1177}
1178
1179// ---------------------------------------------------------------------------
1180// REST helper: rate-limited request with retry
1181// ---------------------------------------------------------------------------
1182
1183/// How long Alpaca asks a rate-limited client to wait, from a 429's headers.
1184///
1185/// `Retry-After`, a delay in seconds, when present: taken as given, zero included, and not read in
1186/// its HTTP-date form. Otherwise `X-RateLimit-Reset`, which Alpaca documents: a Unix timestamp in
1187/// seconds, read as the time from now until then, at least 1 s so a reset already past never
1188/// busy-loops. `None` when neither header is readable; callers fall back to
1189/// [`DEFAULT_RATE_LIMIT_DELAY_SECS`].
1190///
1191/// Either is capped at [`DEFAULT_RATE_LIMIT_DELAY_SECS`], the length of Alpaca's window: a longer
1192/// wait is a bad header or clock skew, and must not hold orders back for longer, nor overflow the
1193/// cooldown's deadline.
1194///
1195/// The data crate's Alpaca REST client reads a 429 by the same rule.
1196fn parse_rate_limit_delay(headers: &reqwest::header::HeaderMap) -> Option<Duration> {
1197    let header = |name: &str| {
1198        headers
1199            .get(name)
1200            .and_then(|value| value.to_str().ok())
1201            .and_then(|value| value.trim().parse::<u64>().ok())
1202    };
1203
1204    let seconds = match header("retry-after") {
1205        Some(seconds) => seconds,
1206        None => {
1207            let reset = header("x-ratelimit-reset")?;
1208            let now = std::time::SystemTime::now()
1209                .duration_since(std::time::UNIX_EPOCH)
1210                .unwrap_or_default()
1211                .as_secs();
1212            reset.saturating_sub(now).max(1)
1213        }
1214    };
1215    Some(Duration::from_secs(
1216        seconds.min(DEFAULT_RATE_LIMIT_DELAY_SECS),
1217    ))
1218}
1219
1220/// Pause every request until the window resets when a response reports none remaining in it.
1221///
1222/// The pause is at most [`DEFAULT_RATE_LIMIT_DELAY_SECS`], the length of Alpaca's window, as
1223/// [`parse_rate_limit_delay`] caps it. Call on
1224/// any response except a 429, which sets its own cooldown. Without an
1225/// `X-Ratelimit-Reset` there is nothing to pause until, so the next request goes ahead and a 429,
1226/// if it comes, backs off.
1227fn observe_rate_limit_remaining(
1228    rate_limiter: &RateLimitTracker,
1229    headers: &reqwest::header::HeaderMap,
1230) {
1231    let exhausted = headers
1232        .get("x-ratelimit-remaining")
1233        .and_then(|v| v.to_str().ok())
1234        .and_then(|s| s.parse::<u32>().ok())
1235        == Some(0);
1236    if !exhausted {
1237        return;
1238    }
1239    match parse_rate_limit_delay(headers) {
1240        Some(reset) => rate_limiter.on_bucket_exhausted(reset),
1241        None => debug!(
1242            "Alpaca REST rate-limit window exhausted (X-Ratelimit-Remaining: 0) without X-Ratelimit-Reset"
1243        ),
1244    }
1245}
1246
1247/// Execute a REST request with rate-limit awareness and retry.
1248///
1249/// The `build_request` closure is called on every attempt so the caller doesn't
1250/// need a clone of `RequestBuilder` (which may not be cloneable with streaming bodies).
1251/// For GET/POST/DELETE with fixed bodies, the closure is a cheap re-construction.
1252///
1253/// On HTTP 429, reads the cooldown with [`parse_rate_limit_delay`] and retries up to
1254/// `MAX_RATE_LIMIT_ATTEMPTS - 1` times. Every 429, the last included, pauses later requests for
1255/// that cooldown, so a caller handed [`ApiError::RateLimit`] does not meet the same refusal on its
1256/// next call. Any other response reporting
1257/// `X-Ratelimit-Remaining: 0` pauses later requests until that reset; see
1258/// [`observe_rate_limit_remaining`].
1259///
1260/// # Errors
1261///
1262/// - [`UnindexedClientError::Connectivity`]: the request or its body read failed, or a 5xx.
1263/// - [`UnindexedClientError::Api`]: a 4xx, classified by [`parse_api_error`], or 429 retries
1264///   exhausted ([`ApiError::RateLimit`], with Alpaca's wait when its headers give one).
1265/// - [`UnindexedClientError::Internal`]: a 2xx body that does not decode into `T`, or a 204 on a
1266///   call that expects a body. Retrying returns the same answer.
1267async fn rest_with_retry<T>(
1268    rate_limiter: &RateLimitTracker,
1269    build_request: impl FnMut() -> reqwest::RequestBuilder,
1270) -> Result<T, UnindexedClientError>
1271where
1272    T: for<'de> Deserialize<'de>,
1273{
1274    match rest_request(rate_limiter, build_request).await? {
1275        Fetched::Found(value) => Ok(value),
1276        Fetched::NotFound(message) => Err(UnindexedClientError::Api(parse_api_error(
1277            reqwest::StatusCode::NOT_FOUND,
1278            None,
1279            &message,
1280        ))),
1281    }
1282}
1283
1284/// [`rest_with_retry`] for a lookup by id: a 404 is `Ok(None)`, Alpaca knowing nothing under the
1285/// id asked for, rather than an error.
1286async fn rest_lookup_with_retry<T>(
1287    rate_limiter: &RateLimitTracker,
1288    build_request: impl FnMut() -> reqwest::RequestBuilder,
1289) -> Result<Option<T>, UnindexedClientError>
1290where
1291    T: for<'de> Deserialize<'de>,
1292{
1293    match rest_request(rate_limiter, build_request).await? {
1294        Fetched::Found(value) => Ok(Some(value)),
1295        Fetched::NotFound(_) => Ok(None),
1296    }
1297}
1298
1299/// What [`rest_request`] got back from a request that did not fail.
1300#[derive(Debug)]
1301enum Fetched<T> {
1302    /// A 2xx body, decoded.
1303    Found(T),
1304    /// A 404, with Alpaca's message.
1305    NotFound(String),
1306}
1307
1308/// [`rest_with_retry`] and [`rest_lookup_with_retry`], which differ only in how they read a 404.
1309async fn rest_request<T>(
1310    rate_limiter: &RateLimitTracker,
1311    mut build_request: impl FnMut() -> reqwest::RequestBuilder,
1312) -> Result<Fetched<T>, UnindexedClientError>
1313where
1314    T: for<'de> Deserialize<'de>,
1315{
1316    for attempt in 0..MAX_RATE_LIMIT_ATTEMPTS {
1317        rate_limiter.wait_if_blocked().await;
1318        let response = build_request()
1319            .send()
1320            .await
1321            .map_err(|e| connectivity_err(format!("Alpaca REST request failed: {e}")))?;
1322
1323        let status = response.status();
1324
1325        if status == reqwest::StatusCode::TOO_MANY_REQUESTS {
1326            let reset_delay = parse_rate_limit_delay(response.headers());
1327            // Every refusal pauses the client, the last included, so the next request waits too.
1328            rate_limiter.on_rate_limited(reset_delay);
1329
1330            if attempt + 1 < MAX_RATE_LIMIT_ATTEMPTS {
1331                warn!(
1332                    attempt = attempt + 1,
1333                    max_attempts = MAX_RATE_LIMIT_ATTEMPTS,
1334                    "Alpaca REST rate-limited (429), retrying"
1335                );
1336                continue;
1337            }
1338
1339            // Final attempt still rate-limited — return typed error.
1340            warn!(
1341                max_attempts = MAX_RATE_LIMIT_ATTEMPTS,
1342                "Alpaca REST rate-limit retries exhausted"
1343            );
1344            return Err(UnindexedClientError::Api(ApiError::rate_limited(
1345                reset_delay,
1346            )));
1347        }
1348
1349        observe_rate_limit_remaining(rate_limiter, response.headers());
1350
1351        // 204 No Content is only valid for DELETE endpoints; use rest_delete_with_retry
1352        // for those. Reaching here for a 204 indicates API misuse — return a clear error
1353        // rather than a misleading "EOF while parsing" JSON failure. A retry cannot fix it.
1354        if status == reqwest::StatusCode::NO_CONTENT {
1355            return Err(UnindexedClientError::Internal(
1356                "Alpaca REST returned 204 No Content — use rest_delete_with_retry for DELETE endpoints"
1357                    .to_string(),
1358            ));
1359        }
1360
1361        let bytes = response
1362            .bytes()
1363            .await
1364            .map_err(|e| connectivity_err(format!("Alpaca REST read body failed: {e}")))?;
1365
1366        // A 2xx body that does not fit the model is not transient: retrying returns the same
1367        // body. An order path must still treat it as status unknown; see `order_post_error`.
1368        if status.is_success() {
1369            return serde_json::from_slice::<T>(&bytes)
1370                .map(Fetched::Found)
1371                .map_err(|e| {
1372                    UnindexedClientError::Internal(format!(
1373                        "Alpaca REST JSON parse error ({status}): {e} | body: {}",
1374                        String::from_utf8_lossy(&bytes)
1375                            .chars()
1376                            .take(200)
1377                            .collect::<String>()
1378                    ))
1379                });
1380        }
1381
1382        // Parse API error body for a better error message.
1383        let api_err = AlpacaApiError::from_body(&bytes);
1384
1385        if status == reqwest::StatusCode::NOT_FOUND {
1386            return Ok(Fetched::NotFound(api_err.message));
1387        }
1388
1389        // 4xx = API-level rejection (wrong parameters, auth failure, insufficient funds).
1390        // 5xx / other = server-side failure treated as connectivity error.
1391        // Callers that pattern-match on UnindexedClientError (e.g. open_order_inner) rely
1392        // on Api(ApiError) to classify business rejections vs connectivity failures.
1393        //
1394        // Uses parse_api_error for consistent classification: 422 "insufficient funds"
1395        // maps to BalanceInsufficient (not generic OrderRejected), enabling callers to
1396        // trigger balance refresh on insufficient-funds rejections.
1397        if status.is_client_error() {
1398            return Err(UnindexedClientError::Api(parse_api_error(
1399                status,
1400                api_err.code,
1401                &api_err.message,
1402            )));
1403        }
1404        return Err(connectivity_err(format!(
1405            "Alpaca REST error {status}: {}",
1406            api_err.message
1407        )));
1408    }
1409    unreachable!("Alpaca REST retry loop exited without returning")
1410}
1411
1412/// Execute a DELETE request, returning an order error on rejection.
1413///
1414/// Handles 204 No Content (success), other 4xx (API rejection, see [`parse_api_error`]), and 429
1415/// (rate limit, retried and pausing later requests as in [`rest_with_retry`]). Any other response
1416/// reporting `X-Ratelimit-Remaining: 0` pauses later requests,
1417/// as in [`rest_with_retry`].
1418async fn rest_delete_with_retry(
1419    rate_limiter: &RateLimitTracker,
1420    mut build_request: impl FnMut() -> reqwest::RequestBuilder,
1421) -> Result<(), UnindexedOrderError> {
1422    for attempt in 0..MAX_RATE_LIMIT_ATTEMPTS {
1423        rate_limiter.wait_if_blocked().await;
1424        let response = build_request().send().await.map_err(|e| {
1425            UnindexedOrderError::Connectivity(ConnectivityError::Socket(format!(
1426                "Alpaca cancel request failed: {e}"
1427            )))
1428        })?;
1429
1430        let status = response.status();
1431
1432        if status == reqwest::StatusCode::TOO_MANY_REQUESTS {
1433            let reset_delay = parse_rate_limit_delay(response.headers());
1434            // Every refusal pauses the client, the last included, so the next request waits too.
1435            rate_limiter.on_rate_limited(reset_delay);
1436
1437            if attempt + 1 < MAX_RATE_LIMIT_ATTEMPTS {
1438                warn!(
1439                    attempt = attempt + 1,
1440                    max_attempts = MAX_RATE_LIMIT_ATTEMPTS,
1441                    "Alpaca cancel rate-limited (429), retrying"
1442                );
1443                continue;
1444            }
1445
1446            // Final attempt still rate-limited — return typed error.
1447            warn!(
1448                max_attempts = MAX_RATE_LIMIT_ATTEMPTS,
1449                "Alpaca cancel rate-limit retries exhausted"
1450            );
1451            return Err(UnindexedOrderError::Rejected(ApiError::rate_limited(
1452                reset_delay,
1453            )));
1454        }
1455
1456        observe_rate_limit_remaining(rate_limiter, response.headers());
1457
1458        // 204 No Content: cancel succeeded.
1459        if status == reqwest::StatusCode::NO_CONTENT || status.is_success() {
1460            return Ok(());
1461        }
1462
1463        let bytes = response
1464            .bytes()
1465            .await
1466            .inspect_err(
1467                |e| warn!(%e, %status, "Alpaca cancel_order: failed to read error response body"),
1468            )
1469            .unwrap_or_default();
1470        let api_err = AlpacaApiError::from_body(&bytes);
1471
1472        return Err(parse_order_error(status, api_err.code, &api_err.message));
1473    }
1474    unreachable!("Alpaca cancel retry loop exited without returning")
1475}
1476
1477// ---------------------------------------------------------------------------
1478// ExecutionClient implementation
1479// ---------------------------------------------------------------------------
1480
1481impl ExecutionClient for AlpacaClient {
1482    const EXCHANGE: ExchangeId = ExchangeId::AlpacaBroker;
1483
1484    // Equities and crypto are both `Spot` here — Alpaca settles each as an outright holding, and
1485    // the client separates them only by symbol shape. `Option` is genuinely routed: the
1486    // `position_intent` Alpaca requires on options orders is derived from that same shape test,
1487    // which is `is_options_or_equity_symbol` — a not-a-crypto-pair check (`!symbol.contains('/')`),
1488    // not OCC format validation. Equities take the branch too, harmlessly: `position_intent` is
1489    // accepted on an equity order.
1490    const SUPPORTED_KINDS: &'static [InstrumentKindDiscriminant] = &[
1491        InstrumentKindDiscriminant::Spot,
1492        InstrumentKindDiscriminant::Option,
1493    ];
1494    type Config = AlpacaConfig;
1495    type AccountStream = BoxStream<'static, UnindexedAccountEvent>;
1496
1497    /// # Panics
1498    ///
1499    /// Panics if the API key or secret key contains characters invalid in an
1500    /// HTTP header value.
1501    fn new(config: Self::Config) -> Self {
1502        let http = Self::build_http(&config);
1503        let orders_url = format!("{}/v2/orders", config.rest_base_url());
1504        Self {
1505            config: Arc::new(config),
1506            http,
1507            rate_limiter: Arc::new(RateLimitTracker::new()),
1508            orders_url,
1509            known_live: KnownLiveOrders::shared(ExchangeId::AlpacaBroker),
1510        }
1511    }
1512
1513    /// # Balances and positions
1514    ///
1515    /// - **USD**: `total` is the account's `cash`, and `free` the lesser of cash and its
1516    ///   `non_marginable_buying_power`: cash that can be spent without borrowing. Alpaca's
1517    ///   buying power figures include the loan value of held stock, so none of them alone is
1518    ///   free cash. A short sale's proceeds are credited to cash but do not raise buying power,
1519    ///   so `free` stays below `total` while a short is open. Cash is negative while the account
1520    ///   borrows on margin, and then `free` is negative too. Account equity is not reported: it
1521    ///   is cash plus the value of the positions below, so it can be derived.
1522    /// - **Equities and options**: each holding becomes the
1523    ///   [`InstrumentAccountSnapshot::position`] of its instrument, [`PositionReport::Open`] with a
1524    ///   signed [`Position`] (negative for a short), the average entry price and the unrealised
1525    ///   PnL in USD. An equity or option without a holding is [`PositionReport::Flat`], since
1526    ///   Alpaca lists every open position. An option's
1527    ///   entry price is the premium per share of the underlying, as quoted and as orders are
1528    ///   priced, not per contract. Margin, liquidation price and leverage are `None`, and
1529    ///   `time_exchange` is the time of the call, since Alpaca does not timestamp positions.
1530    /// - **Crypto**: each holding is an asset balance of the asset it holds (e.g. `BTC` for the
1531    ///   position Alpaca lists as `BTCUSD`), in base units. Alpaca crypto is spot-only and cannot
1532    ///   be sold short, so there is no position to report: its instruments are
1533    ///   [`PositionReport::Unreported`].
1534    ///
1535    /// # Limitations
1536    ///
1537    /// A position is reported only under the instrument name Alpaca uses for it, ignoring case:
1538    /// the ticker for an equity and the OCC symbol for an option. A requested equity or option
1539    /// named otherwise is reported [`PositionReport::Flat`] even while Alpaca holds it. A crypto
1540    /// pair is recognised by the `/` in its name (e.g. `BTC/USD`, the form its orders use); one
1541    /// named without it is taken for an equity and reported flat too. With `instruments` empty,
1542    /// every instrument that has an open order or a position gets a snapshot; otherwise only the
1543    /// requested ones do.
1544    ///
1545    /// # Errors
1546    ///
1547    /// [`ClientError::Internal`](crate::error::ClientError::Internal) when a response does not
1548    /// decode, or when any non-zero equity or option position, requested or not, has a missing or
1549    /// unrecognised `side`: its direction cannot be known, and leaving it out would read as flat.
1550    /// Other request failures are `Connectivity` or `Api`, as for every Alpaca REST call.
1551    ///
1552    /// # Rate limit note
1553    ///
1554    /// `/v2/positions` is fetched on every call, since it holds both the crypto balances and the
1555    /// equity and option positions. When USD is requested too (the common startup case),
1556    /// `/v2/account` is fetched in parallel with it for ~100-300ms latency savings. Under rate
1557    /// pressure, both requests may hit 429 simultaneously and retry independently. Operators
1558    /// approaching Alpaca rate limits can request only non-USD assets to skip `/v2/account`.
1559    async fn account_snapshot(
1560        &self,
1561        assets: &[AssetNameExchange],
1562        instruments: &[InstrumentNameExchange],
1563    ) -> Result<UnindexedAccountSnapshot, UnindexedClientError> {
1564        let base = self.base_url();
1565        let http = self.http.clone();
1566        let rl = &self.rate_limiter;
1567
1568        let wants_usd = assets.is_empty() || assets.iter().any(is_usd);
1569        let wants_non_usd = assets.is_empty() || assets.iter().any(|a| !is_usd(a));
1570
1571        // URLs are extracted before the closures to avoid re-allocating on each retry attempt.
1572        let account_url = format!("{base}/v2/account");
1573        let positions_url = format!("{base}/v2/positions");
1574        let (account, positions): (Option<AlpacaAccount>, Vec<AlpacaPosition>) = tokio::try_join!(
1575            async {
1576                if wants_usd {
1577                    rest_with_retry(rl, || http.get(&account_url))
1578                        .await
1579                        .map(Some)
1580                } else {
1581                    Ok(None)
1582                }
1583            },
1584            rest_with_retry(rl, || http.get(&positions_url)),
1585        )?;
1586
1587        let mut balances = account
1588            .map(|account| convert_account_to_balances(&account, assets))
1589            .unwrap_or_default();
1590        if wants_non_usd {
1591            balances.extend(convert_positions_to_balances(&positions, assets));
1592        }
1593
1594        // Converted before the open orders are fetched, so a position that cannot be converted
1595        // fails the call without a wasted request.
1596        let converted_positions = convert_positions(&positions, Utc::now())?;
1597        let open_orders = fetch_raw_open_orders(&http, rl, base, instruments).await?;
1598
1599        // Group open orders and positions by instrument symbol.
1600        let instrument_snapshots =
1601            build_instrument_snapshots(open_orders, converted_positions, instruments);
1602        {
1603            let mut known = self.known_live.lock();
1604            for order in instrument_snapshots
1605                .iter()
1606                .flat_map(|snapshot| &snapshot.orders)
1607            {
1608                if let OrderState::Active(ActiveOrderState::Open(open)) = &order.state {
1609                    known.live(&order.key, order.quantity, order.kind, open);
1610                }
1611            }
1612        }
1613
1614        Ok(AccountSnapshot::new(
1615            ExchangeId::AlpacaBroker,
1616            balances,
1617            instrument_snapshots,
1618        ))
1619    }
1620
1621    /// Returns a live stream of account events (fills, order updates).
1622    ///
1623    /// # Startup race window
1624    ///
1625    /// Fills arriving between `account_snapshot` and this method being called by the
1626    /// caller are not recovered automatically — the WebSocket connection does not exist
1627    /// yet during that window. Fills that arrive after the connection is established but
1628    /// before the first poll are buffered in the tungstenite internal buffer and delivered
1629    /// normally. Callers requiring fill completeness at startup **must** call
1630    /// [`ExecutionClient::fetch_trades`] with a ~1 s lookback after calling this method.
1631    ///
1632    /// This gap also applies on every **reconnect**: during the auth+subscribe handshake
1633    /// (`connect_and_subscribe`), any `trade_updates` messages that arrive are consumed
1634    /// by the handshake loop and not forwarded. Fill events in this window are recovered
1635    /// via the REST activities endpoint (anchored to `disconnect_time`). How an order held as live
1636    /// ended in this window is recovered too (see below); a lifecycle event of any other order,
1637    /// such as `new` for one placed elsewhere, is not.
1638    ///
1639    /// # Fill recovery ordering
1640    ///
1641    /// After a reconnect, missed fills are recovered from the REST activities endpoint
1642    /// using `direction=asc` to match the chronological order in which the WS stream
1643    /// advanced `filled_qty`. A fill both paths deliver is sent once: it is recognised by its
1644    /// execution id, which the WS frame and the FILL activity both carry.
1645    ///
1646    /// A fill lacking that id on either path is recognised instead by `"{order_id}:{cum_qty}"`,
1647    /// taken from the activity's own `cum_qty`, so it matches the WS path's `filled_qty` by
1648    /// construction. Only where Alpaca omits `cum_qty` too does that key fall back to
1649    /// accumulating per-execution qty within the batch. That fallback counts from zero per order,
1650    /// so it is correct only for an order whose fills lie wholly inside the recovery window, and
1651    /// it is sensitive to activities arriving out of chronological order (e.g. at pagination
1652    /// boundaries).
1653    ///
1654    /// A recovered fill also carries the order's cumulative filled quantity, so it advances the
1655    /// order's `filled_quantity` without waiting for a `fetch_open_orders` reconciliation.
1656    ///
1657    /// Every fill's [`TradeId`] is Alpaca's execution id, whether the stream, recovery, or
1658    /// [`ExecutionClient::fetch_trades`] delivers it, so a fill read again by `fetch_trades`
1659    /// matches the one the stream delivered. A stream fill whose `execution_id` is missing, null
1660    /// or empty is logged and identified by `"{order_id}:{filled_qty}"` instead.
1661    ///
1662    /// The read is made once, with no retry. When it fails, times out, or stops at 5,000 fills,
1663    /// the stream sends one [`AccountEventKind::FillRecoveryGaveUp`] covering the stream's
1664    /// `instruments`, or every instrument when that list is empty. A truncated read delivers the
1665    /// fills it read first, and the event's span starts just before the last of them.
1666    ///
1667    /// # Busted and corrected fills
1668    ///
1669    /// A `trade_bust` or `trade_correct` is sent as [`AccountEventKind::TradeAmended`], naming
1670    /// the fill amended by its [`TradeId`]; the fill itself was delivered as first reported and
1671    /// is not withdrawn. The Trading API documents neither event, so their frames are read per
1672    /// Alpaca's Broker API schema, and each is also logged whole at `warn!`. One sent while the
1673    /// stream was disconnected is not recovered.
1674    ///
1675    /// # Lifecycle event deduplication
1676    ///
1677    /// Order lifecycle events (`new`, `canceled`, `expired`) are **not** deduplicated
1678    /// across reconnects — only fill events carry a dedup key. After a reconnect, Alpaca
1679    /// re-delivers lifecycle events for orders that were active at disconnect time.
1680    /// Specifically, Alpaca re-delivers a `new` event for **every order open at disconnect
1681    /// time**, not only orders that changed during the gap.
1682    /// Callers must make [`AccountEventKind::OrderSnapshot`] and
1683    /// [`AccountEventKind::OrderCancelled`] processing idempotent, or call
1684    /// [`ExecutionClient::fetch_open_orders`] after each reconnect to reconcile state.
1685    ///
1686    /// # Orders that ended while disconnected
1687    ///
1688    /// A reconnect also reports how each order the client holds as live ended, where it did, as an
1689    /// [`AccountEventKind::OrderSnapshot`] of its inactive state: filled (with the average fill
1690    /// price), cancelled or replaced (as cancelled, with what filled before), expired, or rejected.
1691    /// Like fill recovery, it covers only the instruments the stream was opened with, or every
1692    /// instrument when that list is empty. Alpaca's closed-order list filters on when an order was
1693    /// submitted, so such an order is found by asking about it, not by time.
1694    ///
1695    /// - **Which orders.** The client holds an order as live from the response to placing it,
1696    ///   from a listing of open orders ([`account_snapshot`](ExecutionClient::account_snapshot),
1697    ///   [`fetch_open_orders`](ExecutionClient::fetch_open_orders)), and from its live reports on
1698    ///   any of its account streams, until it sees the order end; a cancel Alpaca has only
1699    ///   accepted does not end it. A bracket's take-profit and stop-loss legs carry client order
1700    ///   ids Alpaca assigns, so they are held only once a listing or the stream reports them. It
1701    ///   holds up to 4,096 orders and forgets the oldest past that, logged at `warn`. An order
1702    ///   placed outside this client and never listed or reported to it is not covered.
1703    /// - **Cost.** One `GET /v2/orders?status=open` request listing every instrument with an
1704    ///   order held, then one `GET /v2/orders:by_client_order_id` for each held order the listing
1705    ///   no longer shows, 8 at a time. These share the account's rate limit with orders, so after
1706    ///   an outage in which many held orders ended, the check can hold orders back until the
1707    ///   window resets (see [Rate limits](AlpacaClient#rate-limits)).
1708    /// - **Fills first.** The check starts once fill recovery has finished or given up, so an
1709    ///   order's recovered fills arrive before how it ended. It runs alongside the stream, which
1710    ///   is read from the start. A fill that brings an order to its full quantity ends it, and
1711    ///   that order is not reported again.
1712    /// - **Keys.** Each snapshot carries [`StrategyId::unknown`], since Alpaca records no
1713    ///   strategy. The engine matches it to the order it tracks by client order id.
1714    /// - **Failures.** Each order's lookup is settled as it ends. An instrument whose listing or
1715    ///   lookup fails, or whose check is still running after 30 s, is retried while connected 1,
1716    ///   2, 4, 8 and 16 minutes later, asking only about the orders still held, then given up,
1717    ///   logged at `error`; its orders are asked about again at the next reconnect. A listing that
1718    ///   fails charges every instrument in it. Alpaca lists at most 500 open orders and has no
1719    ///   pagination for them, so a listing of 500 may be truncated and fails: an account with 500
1720    ///   or more open orders on the held instruments is not covered. An order Alpaca does not know
1721    ///   (404) stops being held, logged at `warn`; one still live, `done_for_day`, or in a state
1722    ///   this version cannot read stays held.
1723    ///
1724    /// The same lookup is public as [`OrderStatusClient::fetch_ended_orders`].
1725    ///
1726    /// # Rejected orders
1727    ///
1728    /// Alpaca `rejected` events are delivered as `AccountEventKind::OrderCancelled` with
1729    /// `state: Err(OrderRejected(...))`. Match on `response.state.is_err()` to distinguish
1730    /// rejections from true cancels — do not call `.unwrap()` on `OrderCancelled.state`.
1731    ///
1732    /// # Orders done for the day
1733    ///
1734    /// A `done_for_day` event is delivered as an [`AccountEventKind::OrderSnapshot`] of the order
1735    /// as open, with what it has filled: Alpaca stops working the order until the next trading
1736    /// day but has not ended it.
1737    ///
1738    /// # Stream drop behaviour
1739    ///
1740    /// Dropping the returned `BoxStream` initiates a graceful shutdown of the
1741    /// background `connection_manager` task: the channel close causes the task
1742    /// to send a WebSocket close frame and exit within the current heartbeat
1743    /// window (≤60 s). Any `AccountEvent` items already queued but not yet
1744    /// polled are discarded. Callers who drop and re-subscribe must call
1745    /// [`ExecutionClient::fetch_trades`] with a short lookback to recover the gap.
1746    async fn account_stream(
1747        &self,
1748        _assets: &[AssetNameExchange], // ignored — Alpaca's trade_updates stream delivers all asset classes on one channel
1749        // instruments is used to filter fill recovery (REST) after a reconnect only.
1750        // Live WS events are NOT filtered by instrument: Alpaca's trade_updates stream
1751        // delivers all account events on one channel with no per-symbol subscription.
1752        instruments: &[InstrumentNameExchange],
1753    ) -> Result<Self::AccountStream, UnindexedClientError> {
1754        // Verify the initial connection before returning the stream; distinguishes
1755        // "can't connect at all" from "connected but later disconnected".
1756        let initial_ws = connect_and_subscribe(&self.config).await?;
1757
1758        // Unbounded channel — memory grows if the consumer is slow, but fills
1759        // are never silently dropped. Silent fill loss corrupts position state;
1760        // OOM is loudly observable. The WS delivery path uses non-blocking send()
1761        // which only fails if the receiver is dropped.
1762        let (tx, rx) = mpsc::unbounded_channel::<UnindexedAccountEvent>();
1763        let dedup = new_dedup_cache();
1764        let config = self.config.clone();
1765        let http = self.http.clone();
1766        let rate_limiter = self.rate_limiter.clone();
1767        let known = self.known_live.clone();
1768        let instruments = instruments.to_vec();
1769
1770        let cm_handle = tokio::spawn(connection_manager(
1771            tx,
1772            dedup,
1773            config,
1774            http,
1775            rate_limiter,
1776            known,
1777            instruments,
1778            Some(initial_ws),
1779        ));
1780
1781        let rx_stream = tokio_stream::wrappers::UnboundedReceiverStream::new(rx);
1782        let guarded = GracefulShutdownStream::new(rx_stream, cm_handle);
1783        Ok(futures::StreamExt::boxed(guarded))
1784    }
1785
1786    /// Cancel an order.
1787    ///
1788    /// # Async cancel semantics
1789    ///
1790    /// Returns `Ok(Cancelled)` once Alpaca has **accepted** the cancel, not once the order is
1791    /// cancelled: `DELETE /v2/orders/{id}` answers 204 when the cancel request is accepted and the
1792    /// order will be cancelled. Until it is, the order can be `pending_cancel`, still live, and can
1793    /// still fill. The account stream reports how it ended, and any fill.
1794    ///
1795    /// The 204 has no body, so `filled_quantity` is zero whatever the order filled, and
1796    /// `time_exchange` is the local time the answer arrived.
1797    ///
1798    /// The client keeps holding the order as live until the stream reports how it ended, so if the
1799    /// stream is down when it does, a reconnect still finds out (see
1800    /// [`account_stream`](ExecutionClient::account_stream)).
1801    ///
1802    /// Needs the venue order id. A request without one is refused, since Alpaca cancels by that
1803    /// id only; resolve it through [`ExecutionClient::fetch_open_orders`].
1804    async fn cancel_order(
1805        &self,
1806        request: OrderRequestCancel<ExchangeId, &InstrumentNameExchange>,
1807    ) -> UnindexedOrderResponseCancel {
1808        let key = crate::order::OrderKey {
1809            exchange: request.key.exchange,
1810            instrument: request.key.instrument.clone(),
1811            strategy: request.key.strategy.clone(),
1812            cid: request.key.cid.clone(),
1813        };
1814
1815        // Require the exchange order ID — Alpaca's DELETE endpoint uses the UUID.
1816        // If only clientOrderId is available, the caller should first resolve it
1817        // via fetch_open_orders.
1818        let order_id: SmolStr = match request.state.id.as_ref().and_then(VenueOrderId::assigned) {
1819            Some(id) => id.0.clone(),
1820            None => {
1821                warn!(
1822                    instrument = %key.instrument,
1823                    "Alpaca cancel_order: no exchange order ID available (clientOrderId-only cancel not supported)"
1824                );
1825                return crate::order::request::OrderResponseCancel {
1826                    key,
1827                    state: Err(UnindexedOrderError::Rejected(ApiError::OrderRejected(
1828                        "exchange order ID required for cancel (fetch_open_orders to resolve)"
1829                            .into(),
1830                    ))),
1831                };
1832            }
1833        };
1834
1835        let base = self.base_url();
1836        let http = self.http.clone();
1837        let url = format!("{base}/v2/orders/{order_id}");
1838
1839        match rest_delete_with_retry(&self.rate_limiter, || http.delete(&url)).await {
1840            Ok(()) => {
1841                let exchange_order_id = OrderId(order_id);
1842                // REST DELETE returns no response body, so the filled quantity is unknown
1843                // here; the account stream's `canceled` update reports it.
1844                crate::order::request::OrderResponseCancel {
1845                    key,
1846                    state: Ok(Cancelled::new(exchange_order_id, Utc::now(), None)),
1847                }
1848            }
1849            Err(e) => crate::order::request::OrderResponseCancel { key, state: Err(e) },
1850        }
1851    }
1852
1853    /// # Position intent derivation
1854    ///
1855    /// Alpaca options/equities require explicit `position_intent`. This impl derives
1856    /// intent from `RequestOpen::reduce_only` and `side`:
1857    ///
1858    /// | reduce_only | side | intent       | use case                          |
1859    /// |-------------|------|--------------|-----------------------------------|
1860    /// | false       | Buy  | BuyToOpen    | open long / add to long position  |
1861    /// | false       | Sell | SellToOpen   | open short / write option         |
1862    /// | true        | Buy  | BuyToClose   | close short position              |
1863    /// | true        | Sell | SellToClose  | close long position               |
1864    ///
1865    /// For explicit control, use [`AlpacaClient::open_order_with_intent`].
1866    ///
1867    /// # Market order price
1868    ///
1869    /// The returned `Order.price` echoes the request price. For market orders this is
1870    /// typically `Decimal::ZERO` (a placeholder). The **actual fill price** arrives via
1871    /// the WebSocket `trade_updates` stream as a `Trade` event. Do not rely on
1872    /// `Order.price` from this REST ack for market order fill prices.
1873    async fn open_order(
1874        &self,
1875        request: OrderRequestOpen<ExchangeId, &InstrumentNameExchange>,
1876    ) -> Order<ExchangeId, InstrumentNameExchange, UnindexedOrderState> {
1877        let side = request.state.side;
1878        let reduce_only = request.state.reduce_only;
1879        self.open_order_inner(request, map_position_intent(side, reduce_only))
1880            .await
1881    }
1882
1883    /// Fetches balances sequentially (unlike `account_snapshot` which parallelizes).
1884    ///
1885    /// Sequential fetch is intentional for live operation: under rate pressure, parallel
1886    /// requests may both hit 429 simultaneously and retry independently, doubling the
1887    /// backoff delay. The startup latency savings from `account_snapshot`'s parallel
1888    /// fetch are worth the tradeoff there; for periodic balance refreshes during live
1889    /// trading, sequential is safer.
1890    async fn fetch_balances(
1891        &self,
1892        assets: &[AssetNameExchange],
1893    ) -> Result<Vec<AssetBalance<AssetNameExchange>>, UnindexedClientError> {
1894        let base = self.base_url();
1895        let http = self.http.clone();
1896        let mut result = Vec::new();
1897
1898        // Only fetch the account (USD balance) when USD is among the requested assets.
1899        // Crypto-only requests skip this call to conserve rate-limit budget.
1900        let wants_usd = assets.is_empty() || assets.iter().any(is_usd);
1901        if wants_usd {
1902            // Pre-allocate URL to avoid re-allocation on each retry attempt.
1903            let account_url = format!("{base}/v2/account");
1904            let account: AlpacaAccount =
1905                rest_with_retry(&self.rate_limiter, || http.get(&account_url)).await?;
1906            result.extend(convert_account_to_balances(&account, assets));
1907        }
1908
1909        // Fetch positions for non-USD asset balances (e.g., BTC, ETH from crypto holdings).
1910        let wants_non_usd = assets.is_empty() || assets.iter().any(|a| !is_usd(a));
1911        if wants_non_usd {
1912            // Pre-allocate URL to avoid re-allocation on each retry attempt.
1913            let positions_url = format!("{base}/v2/positions");
1914            let positions: Vec<AlpacaPosition> =
1915                rest_with_retry(&self.rate_limiter, || http.get(&positions_url)).await?;
1916            result.extend(convert_positions_to_balances(&positions, assets));
1917        }
1918
1919        Ok(result)
1920    }
1921
1922    async fn fetch_open_orders(
1923        &self,
1924        instruments: &[InstrumentNameExchange],
1925    ) -> Result<Vec<Order<ExchangeId, InstrumentNameExchange, Open>>, UnindexedClientError> {
1926        let base = self.base_url();
1927        let http = self.http.clone();
1928
1929        let open_orders =
1930            fetch_raw_open_orders(&http, &self.rate_limiter, base, instruments).await?;
1931
1932        let result: Vec<_> = open_orders
1933            .into_iter()
1934            .filter_map(|o| convert_open_order(&o))
1935            .collect();
1936        self.remember_live(&result);
1937        Ok(result)
1938    }
1939
1940    /// # Cost
1941    ///
1942    /// One call sends up to 50 requests, reading at most 5,000 FILL activities, and returns
1943    /// `resume` when it stops there. The limit counts every activity on the account, before the
1944    /// instrument filter, so a call for one instrument can spend all 50 requests and return no
1945    /// trades with `resume: Some`.
1946    ///
1947    /// # Times
1948    ///
1949    /// An activity whose `transaction_time` does not parse is still returned, since Alpaca placed
1950    /// it in the span. Its `time_exchange` is then the local clock at the read, which can lie
1951    /// outside the span, and a warning names it.
1952    async fn fetch_trades(
1953        &self,
1954        start: DateTime<Utc>,
1955        end: DateTime<Utc>,
1956        instruments: &[InstrumentNameExchange],
1957    ) -> Result<TradesRead<AssetNameExchange, InstrumentNameExchange>, UnindexedClientError> {
1958        // An empty span, or one ending before the epoch, holds nothing Alpaca has.
1959        if start > end || end < DateTime::UNIX_EPOCH {
1960            return Ok(TradesRead::complete(Vec::new()));
1961        }
1962        let page = paginate_activities::<AlpacaActivity>(
1963            &self.http,
1964            &self.rate_limiter,
1965            self.base_url(),
1966            FILL_ACTIVITIES,
1967            start,
1968            Some(end),
1969        )
1970        .await?;
1971
1972        let Ok(resume) = span_resume(page.resume, start, end) else {
1973            error!(
1974                %start,
1975                fills_read = page.activities.len(),
1976                "Alpaca fetch_trades: a full read did not advance past the span's first millisecond"
1977            );
1978            return Err(UnindexedClientError::Truncated {
1979                fills_read: page.activities.len(),
1980            });
1981        };
1982
1983        // Empty instruments slice means "all instruments" — same convention as
1984        // fetch_open_orders.
1985        let instrument_set: fnv::FnvHashSet<&str> =
1986            instruments.iter().map(|i| i.name().as_str()).collect();
1987        let trades = page
1988            .activities
1989            .iter()
1990            .filter(|a| instrument_set.is_empty() || instrument_set.contains(a.symbol.as_str()))
1991            // Alpaca's bounds are not exact (see `paginate_activities`), so the span is applied
1992            // here. An activity whose time does not parse is kept: Alpaca placed it in the span.
1993            .filter(|a| {
1994                parse_timestamp(&a.transaction_time)
1995                    .is_none_or(|time| (start..=end).contains(&time))
1996            })
1997            .filter_map(convert_activity_to_trade)
1998            .collect();
1999
2000        Ok(TradesRead::new(trades, resume))
2001    }
2002}
2003
2004// ---------------------------------------------------------------------------
2005// AlpacaClient public extension methods (not on ExecutionClient trait)
2006// ---------------------------------------------------------------------------
2007
2008impl AlpacaClient {
2009    /// Read the cash Alpaca moved into or out of the account other than by a trade, from `start`
2010    /// to `end`: interest, fees, and pass-through charges and rebates.
2011    ///
2012    /// Alpaca streams none of these: `trade_updates` carries order events only. Nothing is pushed:
2013    /// the caller decides when to read, and must drop the flows it has already applied, by
2014    /// [`id`](CashFlow::id), before applying a read's. Alpaca posts each to the account's cash,
2015    /// so the account's USD balance already includes a flow read here.
2016    ///
2017    /// # What each activity becomes
2018    ///
2019    /// | Activity | [`CashFlowKind`] |
2020    /// |---|---|
2021    /// | `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 |
2022    /// | `PTR`, a pass-through rebate | [`Rebate`](CashFlowKind::Rebate) |
2023    /// | Any other: `FEE`, `PTC`, credit interest | [`Other`](CashFlowKind::Other), naming the type and, when Alpaca gives one, the sub-type, as `FEE.TAF` |
2024    ///
2025    /// Alpaca documents no activity type for a stock borrow fee. Should it post one as one of
2026    /// these, it arrives as [`Other`](CashFlowKind::Other) with its own name.
2027    ///
2028    /// - [`amount`](CashFlow::amount) is Alpaca's `net_amount`, negative when the account paid.
2029    /// - [`asset`](CashFlow::asset) is its `currency`, or `USD` when it names none.
2030    /// - [`instrument`](CashFlow::instrument) is its `symbol`, when it names one.
2031    /// - [`time_exchange`](CashFlow::time_exchange) is when Alpaca created the activity
2032    ///   (`created_at`), which its `after` and `until` filter on. A fee is typically created the
2033    ///   day after the trade it was charged on.
2034    /// - [`id`](CashFlow::id) is Alpaca's activity id.
2035    ///
2036    /// # Status
2037    /// - An activity Alpaca marks `canceled` is left out, and logged at INFO. A flow read before
2038    ///   it was canceled is not reversed: the caller reconciles against the account's cash.
2039    /// - One marked `correct` is returned like any other. Alpaca does not document whether a
2040    ///   correction stands beside the activity it corrects or replaces it, so a caller applying
2041    ///   both by id may count the cash twice. Reconcile against the account's cash.
2042    ///
2043    /// An activity without an amount or a creation time that parses is left out, and logged at
2044    /// WARN.
2045    ///
2046    /// # Cost and resuming
2047    /// One call sends up to 50 requests, reading at most 5,000 activities, and returns `resume`
2048    /// when it stops there: the millisecond of the last activity's creation time. That resume is
2049    /// exact only if Alpaca serves these activities in order of creation, as it serves fills in
2050    /// order of time; its documentation orders them by "activity datetime" without saying which
2051    /// time, and this ordering has not been observed for them. Read spans short enough to fit one
2052    /// call, as the volume of these activities usually allows, and `resume` stays `None`.
2053    ///
2054    /// # Errors
2055    /// Fails if a request fails, or, with [`UnindexedClientError::Internal`], if more activities
2056    /// share the span's first millisecond than one call reads, so a read from `resume` could not
2057    /// advance.
2058    pub async fn fetch_cash_flows(
2059        &self,
2060        start: DateTime<Utc>,
2061        end: DateTime<Utc>,
2062    ) -> Result<CashFlowsRead<AssetNameExchange, InstrumentNameExchange>, UnindexedClientError>
2063    {
2064        // An empty span, or one ending before the epoch, holds nothing Alpaca has.
2065        if start > end || end < DateTime::UNIX_EPOCH {
2066            return Ok(CashFlowsRead::complete(Vec::new()));
2067        }
2068        let page = paginate_activities::<AlpacaNonTradeActivity>(
2069            &self.http,
2070            &self.rate_limiter,
2071            self.base_url(),
2072            CASH_FLOW_ACTIVITIES,
2073            start,
2074            Some(end),
2075        )
2076        .await?;
2077        let Ok(resume) = span_resume(page.resume, start, end) else {
2078            error!(
2079                %start,
2080                activities_read = page.activities.len(),
2081                "Alpaca fetch_cash_flows: a full read did not advance past the span's first \
2082                 millisecond"
2083            );
2084            return Err(UnindexedClientError::Internal(format!(
2085                "Alpaca cash-flow read of {} activities did not advance past {start}",
2086                page.activities.len()
2087            )));
2088        };
2089
2090        let flows = page
2091            .activities
2092            .iter()
2093            .filter_map(convert_cash_flow)
2094            // Alpaca's bounds are not exact (see `paginate_activities`), so the span is applied
2095            // here.
2096            .filter(|flow| (start..=end).contains(&flow.time_exchange))
2097            .collect();
2098        Ok(CashFlowsRead::new(flows, resume))
2099    }
2100
2101    /// Read the trades Alpaca made when an option contract was exercised, assigned or expired,
2102    /// from `start` to `end`.
2103    ///
2104    /// Alpaca reports these as account activities, not fills: `trade_updates` carries none of
2105    /// them, and [`fetch_trades`](crate::client::ExecutionClient::fetch_trades) reads fills only.
2106    /// Nothing is pushed: the caller decides when to read, and must drop the trades it has
2107    /// already applied, by [`id`](Trade::id), before applying a read's. To let these trades move
2108    /// the engine's positions without the engine also settling the contract at expiry, set the
2109    /// exchange's expiry handling to `Venue` (see `ExpiryHandling` in the `rustrade` crate).
2110    ///
2111    /// # What each activity becomes
2112    ///
2113    /// Each becomes one [`Trade`], whose [`origin`](Trade::origin) says why it happened:
2114    ///
2115    /// | Activity | Instrument | [`TradeOrigin`] | Price |
2116    /// |---|---|---|---|
2117    /// | `OPEXP`, expiry | the contract | [`Expiry`](TradeOrigin::Expiry) | 0 |
2118    /// | `OPEXC`, exercise | the contract | [`Exercise`](TradeOrigin::Exercise) | 0 |
2119    /// | `OPASN`, assignment | the contract | [`Assignment`](TradeOrigin::Assignment) | 0 |
2120    /// | `OPTRD`, the shares an exercise or assignment trades | the underlying | that of its exercise or assignment, else [`External`](TradeOrigin::External) | the strike |
2121    ///
2122    /// - [`side`](Trade::side) and [`quantity`](Trade::quantity) come from Alpaca's signed `qty`:
2123    ///   negative is a sell. An exercised long or an expiring long is sold; an assigned short is
2124    ///   bought. Quantities are in contracts on the contract, and in shares on `OPTRD`.
2125    /// - The contract leaves at a price of 0. What it was worth at exercise or assignment is in the
2126    ///   shares traded at the strike.
2127    /// - [`time_exchange`](Trade::time_exchange) is when Alpaca created the activity
2128    ///   (`created_at`), or the start of its `date`, in UTC, when it gives no creation time. Such
2129    ///   a dated activity is in the span when its `date` is one of the span's UTC days, so a read
2130    ///   up to now still returns one posted today, and successive reads within one day each
2131    ///   return it again.
2132    /// - [`id`](Trade::id) and [`order_id`](Trade::order_id) are Alpaca's activity id, since no
2133    ///   order produced the trade. [`strategy`](Trade::strategy) is unknown, and fees are zero.
2134    ///
2135    /// # Pairing the share leg
2136    ///
2137    /// `OPTRD` names the underlying, not the contract, so its origin is found by pairing it with
2138    /// an `OPEXC` or `OPASN` of the same `date` whose contract's root symbol is the `OPTRD`'s
2139    /// symbol, whose strike is its price, and whose kind implies its side: exercising a call or
2140    /// being assigned on a put buys the shares, and the other two sell them. Each is paired at
2141    /// most once, in the order Alpaca returns them, preferring one of 100 shares a contract, the
2142    /// standard size, so that two exercises at one strike pair with their own shares. An `OPTRD`
2143    /// with no such partner is returned as [`External`](TradeOrigin::External), and logged at
2144    /// WARN. The read covers the whole UTC days of `start` and `end`, so that both legs are read
2145    /// together, and returns only the trades in `start..=end`.
2146    ///
2147    /// # Status
2148    /// An activity Alpaca marks `canceled` is left out, and logged at INFO. An activity without a
2149    /// symbol, a non-zero quantity or a time, an `OPTRD` without a price, or one of another type
2150    /// is left out, and logged at WARN.
2151    ///
2152    /// # Cost and resuming
2153    /// As [`fetch_cash_flows`](Self::fetch_cash_flows): one call sends up to 50 requests, reading
2154    /// at most 5,000 activities, and returns `resume` when it stops there. A read from `resume`
2155    /// reads its whole UTC day again, so drop by id what was already applied. So more than 5,000
2156    /// option activities in one UTC day cannot be read through; see Errors.
2157    ///
2158    /// A read that stops at the cap can split an exercise or assignment from its share leg, and
2159    /// return the `OPTRD` as [`External`](TradeOrigin::External) while the next read, from
2160    /// `resume`, returns it again with its pair's origin. A caller dropping trades by id keeps
2161    /// the first. When a read returns `resume`, hold back its `External` `OPTRD`s until the next
2162    /// read.
2163    ///
2164    /// # Errors
2165    /// Fails if a request fails, or, with [`UnindexedClientError::Internal`], if a read from
2166    /// `resume` could not advance: one UTC day holds more option activities than one call reads.
2167    /// That includes a day whose activities before `start` alone fill the call.
2168    pub async fn fetch_option_events(
2169        &self,
2170        start: DateTime<Utc>,
2171        end: DateTime<Utc>,
2172    ) -> Result<TradesRead<AssetNameExchange, InstrumentNameExchange>, UnindexedClientError> {
2173        // An empty span, or one ending before the epoch, holds nothing Alpaca has.
2174        if start > end || end < DateTime::UNIX_EPOCH {
2175            return Ok(TradesRead::complete(Vec::new()));
2176        }
2177        // Whole UTC days, so the legs of one exercise or assignment are read together.
2178        let read_start = start.date_naive().and_time(NaiveTime::MIN).and_utc();
2179        let read_end = end.date_naive().and_time(LAST_MILLISECOND).and_utc();
2180        let page = paginate_activities::<AlpacaNonTradeActivity>(
2181            &self.http,
2182            &self.rate_limiter,
2183            self.base_url(),
2184            OPTION_EVENT_ACTIVITIES,
2185            read_start,
2186            Some(read_end),
2187        )
2188        .await?;
2189        let Ok(resume) = span_resume(page.resume, start, end) else {
2190            error!(
2191                %start,
2192                activities_read = page.activities.len(),
2193                "Alpaca fetch_option_events: a full read did not advance past the span's start"
2194            );
2195            return Err(UnindexedClientError::Internal(format!(
2196                "Alpaca option-event read of {} activities did not advance past {start}",
2197                page.activities.len()
2198            )));
2199        };
2200
2201        Ok(TradesRead::new(
2202            convert_option_events(&page.activities, start, end),
2203            resume,
2204        ))
2205    }
2206
2207    /// Place an order with an explicit `position_intent` override.
2208    ///
2209    /// Use this instead of `ExecutionClient::open_order` when you need to specify
2210    /// exact position intent (e.g. `SellToOpen` for writing a short option, or
2211    /// `BuyToClose` for closing a short position by buying).
2212    ///
2213    /// `intent` is only sent for non-crypto symbols. For crypto symbols (those
2214    /// containing `/`) the field is always omitted regardless of `intent`.
2215    ///
2216    /// # Caller obligations
2217    /// The caller is responsible for passing the semantically correct intent.
2218    /// No validation is performed against the order side or existing position.
2219    pub async fn open_order_with_intent(
2220        &self,
2221        request: OrderRequestOpen<ExchangeId, &InstrumentNameExchange>,
2222        intent: AlpacaPositionIntent,
2223    ) -> Order<ExchangeId, InstrumentNameExchange, UnindexedOrderState> {
2224        self.open_order_inner(request, intent).await
2225    }
2226
2227    /// Place a bracket order (entry + take-profit + stop-loss) in a single request.
2228    ///
2229    /// A bracket order consists of three linked orders:
2230    /// 1. **Entry**: Limit order to enter the position
2231    /// 2. **Take Profit**: Limit order to exit at profit target
2232    /// 3. **Stop Loss**: Stop (or stop-limit) order to exit at loss limit
2233    ///
2234    /// When the entry order fills, Alpaca activates both exit legs. When either
2235    /// exit leg fills, Alpaca automatically cancels the other.
2236    ///
2237    /// # Constraints
2238    ///
2239    /// - `time_in_force` must be `Day` or `GoodUntilCancelled` (Alpaca rejects others)
2240    /// - No extended hours trading with bracket orders
2241    /// - Entry is always a limit order
2242    ///
2243    /// # Example
2244    ///
2245    /// ```ignore
2246    /// let request = AlpacaBracketOrderRequest::new(
2247    ///     "AAPL".into(),
2248    ///     StrategyId::new("momentum"),
2249    ///     ClientOrderId::new("bracket-001"),
2250    ///     Side::Buy,
2251    ///     dec!(10),
2252    ///     dec!(150.00),  // entry
2253    ///     dec!(160.00),  // take profit
2254    ///     dec!(145.00),  // stop loss
2255    ///     TimeInForce::GoodUntilCancelled { post_only: false },
2256    /// );
2257    /// let result = client.open_bracket_order(request).await;
2258    /// ```
2259    pub async fn open_bracket_order(
2260        &self,
2261        request: AlpacaBracketOrderRequest,
2262    ) -> AlpacaBracketOrderResult {
2263        let order_key = crate::order::OrderKey::new(
2264            ExchangeId::AlpacaBroker,
2265            request.instrument.clone(),
2266            request.strategy.clone(),
2267            request.cid.clone(),
2268        );
2269
2270        // Validate time_in_force: bracket orders only support day or gtc
2271        let tif_str = match request.time_in_force {
2272            TimeInForce::GoodUntilEndOfDay => "day",
2273            TimeInForce::GoodUntilCancelled { post_only } => {
2274                if post_only {
2275                    return AlpacaBracketOrderResult {
2276                        parent: Order {
2277                            key: order_key,
2278                            side: request.side,
2279                            price: Some(request.entry_price),
2280                            quantity: request.quantity,
2281                            kind: OrderKind::Limit,
2282                            time_in_force: request.time_in_force,
2283                            state: OrderState::inactive(OrderError::Rejected(
2284                                ApiError::OrderRejected(
2285                                    "Alpaca does not support post_only for bracket orders"
2286                                        .to_string(),
2287                                ),
2288                            )),
2289                        },
2290                    };
2291                }
2292                "gtc"
2293            }
2294            other => {
2295                return AlpacaBracketOrderResult {
2296                    parent: Order {
2297                        key: order_key,
2298                        side: request.side,
2299                        price: Some(request.entry_price),
2300                        quantity: request.quantity,
2301                        kind: OrderKind::Limit,
2302                        time_in_force: request.time_in_force,
2303                        state: OrderState::inactive(OrderError::Rejected(ApiError::OrderRejected(
2304                            format!(
2305                                "Alpaca bracket orders only support Day or GTC time_in_force, got {:?}",
2306                                other
2307                            ),
2308                        ))),
2309                    },
2310                };
2311            }
2312        };
2313
2314        // Validate price ordering. Alpaca rejects mis-ordered brackets with a
2315        // generic 422; surface a structured local error before round-trip.
2316        let price_ordering_ok = match request.side {
2317            Side::Buy => {
2318                request.stop_loss_price < request.entry_price
2319                    && request.entry_price < request.take_profit_price
2320            }
2321            Side::Sell => {
2322                request.take_profit_price < request.entry_price
2323                    && request.entry_price < request.stop_loss_price
2324            }
2325        };
2326        if !price_ordering_ok {
2327            return AlpacaBracketOrderResult {
2328                parent: Order {
2329                    key: order_key,
2330                    side: request.side,
2331                    price: Some(request.entry_price),
2332                    quantity: request.quantity,
2333                    kind: OrderKind::Limit,
2334                    time_in_force: request.time_in_force,
2335                    state: OrderState::inactive(OrderError::Rejected(ApiError::OrderRejected(
2336                        format!(
2337                            "Invalid bracket price ordering for {:?} side: entry={}, take_profit={}, stop_loss={}",
2338                            request.side,
2339                            request.entry_price,
2340                            request.take_profit_price,
2341                            request.stop_loss_price,
2342                        ),
2343                    ))),
2344                },
2345            };
2346        }
2347
2348        // Validate stop-loss limit price if provided. For a Buy bracket, the SL
2349        // is a sell stop(-limit); the limit must be at or below the trigger so
2350        // the order can fill if price gaps down. Reverse for Sell brackets.
2351        if let Some(sl_limit) = request.stop_loss_limit_price {
2352            let sl_limit_ok = match request.side {
2353                Side::Buy => sl_limit <= request.stop_loss_price,
2354                Side::Sell => sl_limit >= request.stop_loss_price,
2355            };
2356            if !sl_limit_ok {
2357                return AlpacaBracketOrderResult {
2358                    parent: Order {
2359                        key: order_key,
2360                        side: request.side,
2361                        price: Some(request.entry_price),
2362                        quantity: request.quantity,
2363                        kind: OrderKind::Limit,
2364                        time_in_force: request.time_in_force,
2365                        state: OrderState::inactive(OrderError::Rejected(ApiError::OrderRejected(
2366                            format!(
2367                                "Invalid stop-loss limit price for {:?} bracket: \
2368                                 stop_loss_price={}, stop_loss_limit_price={}",
2369                                request.side, request.stop_loss_price, sl_limit,
2370                            ),
2371                        ))),
2372                    },
2373                };
2374            }
2375        }
2376
2377        // Build the bracket order request
2378        let body = AlpacaOrderRequest {
2379            symbol: request.instrument.name().as_str(),
2380            qty: request.quantity.to_string(),
2381            side: map_side(request.side),
2382            order_type: "limit",
2383            time_in_force: tif_str,
2384            limit_price: Some(request.entry_price.to_string()),
2385            stop_price: None,
2386            trail_percent: None,
2387            trail_price: None,
2388            client_order_id: Some(request.cid.0.as_str()),
2389            position_intent: if is_options_or_equity_symbol(request.instrument.name().as_str()) {
2390                Some(map_position_intent(request.side, false))
2391            } else {
2392                None
2393            },
2394            order_class: Some("bracket"),
2395            take_profit: Some(TakeProfitParams {
2396                limit_price: request.take_profit_price.to_string(),
2397            }),
2398            stop_loss: Some(StopLossParams {
2399                stop_price: request.stop_loss_price.to_string(),
2400                limit_price: request.stop_loss_limit_price.map(|p| p.to_string()),
2401            }),
2402        };
2403
2404        let http = self.http.clone();
2405        let rl = &self.rate_limiter;
2406
2407        let result: Result<AlpacaOrderResponse, UnindexedClientError> =
2408            rest_with_retry(rl, || http.post(&self.orders_url).json(&body)).await;
2409
2410        match result {
2411            Ok(resp) => {
2412                let state = placed_order_state(&resp, &order_key.instrument, request.quantity);
2413                self.known_live.lock().placed(
2414                    &order_key,
2415                    request.quantity,
2416                    OrderKind::Limit,
2417                    &state,
2418                );
2419
2420                AlpacaBracketOrderResult {
2421                    parent: Order {
2422                        key: order_key,
2423                        side: request.side,
2424                        price: Some(request.entry_price),
2425                        quantity: request.quantity,
2426                        kind: OrderKind::Limit,
2427                        time_in_force: request.time_in_force,
2428                        state,
2429                    },
2430                }
2431            }
2432            Err(e) => {
2433                let order_err = order_post_error(e);
2434                AlpacaBracketOrderResult {
2435                    parent: Order {
2436                        key: order_key,
2437                        side: request.side,
2438                        price: Some(request.entry_price),
2439                        quantity: request.quantity,
2440                        kind: OrderKind::Limit,
2441                        time_in_force: request.time_in_force,
2442                        state: OrderState::inactive(order_err),
2443                    },
2444                }
2445            }
2446        }
2447    }
2448
2449    async fn open_order_inner(
2450        &self,
2451        request: OrderRequestOpen<ExchangeId, &InstrumentNameExchange>,
2452        intent: AlpacaPositionIntent,
2453    ) -> Order<ExchangeId, InstrumentNameExchange, UnindexedOrderState> {
2454        let instrument = request.key.instrument.clone();
2455        let side = request.state.side;
2456        let price = request.state.price;
2457        let quantity = request.state.quantity;
2458        let kind = request.state.kind;
2459        let time_in_force = request.state.time_in_force;
2460        let cid = request.key.cid.clone();
2461
2462        let order_key = crate::order::OrderKey::new(
2463            ExchangeId::AlpacaBroker,
2464            instrument.clone(),
2465            request.key.strategy.clone(),
2466            cid.clone(),
2467        );
2468
2469        // Validate time_in_force before building the request — reject post_only early.
2470        let tif_str = match map_time_in_force(time_in_force) {
2471            Ok(s) => s,
2472            Err(msg) => {
2473                return Order {
2474                    key: order_key,
2475                    side,
2476                    price,
2477                    quantity,
2478                    kind,
2479                    time_in_force,
2480                    state: OrderState::inactive(OrderError::Rejected(ApiError::OrderRejected(
2481                        msg.to_string(),
2482                    ))),
2483                };
2484            }
2485        };
2486
2487        // Validate order kind — reject unsupported types early.
2488        let order_type_str = match map_order_kind(kind) {
2489            Some(s) => s,
2490            None => {
2491                return Order {
2492                    key: order_key,
2493                    side,
2494                    price,
2495                    quantity,
2496                    kind,
2497                    time_in_force,
2498                    state: OrderState::inactive(OrderError::UnsupportedOrderType(format!(
2499                        "Alpaca connector does not yet support OrderKind::{kind:?}"
2500                    ))),
2501                };
2502            }
2503        };
2504
2505        // Validate trailing stop offset type — Alpaca only supports percent and absolute.
2506        if let OrderKind::TrailingStop {
2507            offset_type: TrailingOffsetType::BasisPoints,
2508            ..
2509        } = kind
2510        {
2511            return Order {
2512                key: order_key,
2513                side,
2514                price,
2515                quantity,
2516                kind,
2517                time_in_force,
2518                state: OrderState::inactive(OrderError::UnsupportedOrderType(
2519                    "Alpaca does not support TrailingOffsetType::BasisPoints; \
2520                     use Percentage or Absolute"
2521                        .to_string(),
2522                )),
2523            };
2524        }
2525
2526        // StopLimit requires Order.price (the limit price applied once trigger fires).
2527        // Guard locally to avoid a wire round-trip producing a generic 422.
2528        if matches!(kind, OrderKind::StopLimit { .. }) && price.is_none() {
2529            return Order {
2530                key: order_key,
2531                side,
2532                price,
2533                quantity,
2534                kind,
2535                time_in_force,
2536                state: OrderState::inactive(OrderError::Rejected(ApiError::OrderRejected(
2537                    "StopLimit order requires Order.price (the limit price) to be set".to_string(),
2538                ))),
2539            };
2540        }
2541
2542        // Extract stop/trailing parameters based on order kind.
2543        let (stop_price, trail_percent, trail_price) = match kind {
2544            OrderKind::Stop { trigger_price } | OrderKind::StopLimit { trigger_price } => {
2545                (Some(trigger_price.to_string()), None, None)
2546            }
2547            OrderKind::TrailingStop {
2548                offset,
2549                offset_type,
2550                ..
2551            } => match offset_type {
2552                TrailingOffsetType::Percentage => (None, Some(offset.to_string()), None),
2553                TrailingOffsetType::Absolute => (None, None, Some(offset.to_string())),
2554                TrailingOffsetType::BasisPoints => unreachable!("validated above"),
2555            },
2556            // TakeProfit/TakeProfitLimit rejected by map_order_kind → unreachable here
2557            OrderKind::Market
2558            | OrderKind::Limit
2559            | OrderKind::TakeProfit { .. }
2560            | OrderKind::TakeProfitLimit { .. }
2561            | OrderKind::TrailingStopLimit { .. }
2562            | OrderKind::TrailingStopFixedLimit { .. } => (None, None, None),
2563        };
2564
2565        let body = AlpacaOrderRequest {
2566            symbol: instrument.name().as_str(),
2567            qty: quantity.to_string(),
2568            side: map_side(side),
2569            order_type: order_type_str,
2570            time_in_force: tif_str,
2571            limit_price: match kind {
2572                OrderKind::Limit | OrderKind::StopLimit { .. } => price.map(|p| p.to_string()),
2573                _ => None,
2574            },
2575            stop_price,
2576            trail_percent,
2577            trail_price,
2578            client_order_id: Some(cid.0.as_str()),
2579            // position_intent is required for options orders and valid (but optional)
2580            // for equities. It is NOT a valid field for crypto orders and must be
2581            // omitted, or Alpaca will return 422 Unprocessable Entity.
2582            // We detect options by OCC symbol format; equities also get the field
2583            // as it aids intent tracking on margin accounts.
2584            position_intent: if is_options_or_equity_symbol(instrument.name().as_str()) {
2585                Some(intent)
2586            } else {
2587                None
2588            },
2589            // Non-bracket order: no bracket fields
2590            order_class: None,
2591            take_profit: None,
2592            stop_loss: None,
2593        };
2594
2595        let http = self.http.clone();
2596        let rl = &self.rate_limiter;
2597
2598        let result: Result<AlpacaOrderResponse, UnindexedClientError> =
2599            rest_with_retry(rl, || http.post(&self.orders_url).json(&body)).await;
2600
2601        match result {
2602            Ok(resp) => {
2603                let state = placed_order_state(&resp, &order_key.instrument, quantity);
2604                self.known_live
2605                    .lock()
2606                    .placed(&order_key, quantity, kind, &state);
2607
2608                Order {
2609                    key: order_key,
2610                    side,
2611                    price,
2612                    quantity,
2613                    kind,
2614                    time_in_force,
2615                    state,
2616                }
2617            }
2618            Err(e) => {
2619                let order_err = order_post_error(e);
2620                Order {
2621                    key: order_key,
2622                    side,
2623                    price,
2624                    quantity,
2625                    kind,
2626                    time_in_force,
2627                    state: OrderState::inactive(order_err),
2628                }
2629            }
2630        }
2631    }
2632}
2633
2634// ---------------------------------------------------------------------------
2635// REST helpers
2636// ---------------------------------------------------------------------------
2637
2638/// Maximum number of open orders returned by a single `/v2/orders` request.
2639///
2640/// Alpaca's API caps at 500; accounts exceeding this have an incomplete snapshot.
2641const MAX_OPEN_ORDERS: usize = 500;
2642
2643/// Fetch all open orders from Alpaca, optionally filtered by symbol.
2644///
2645/// # Errors
2646///
2647/// Returns [`UnindexedClientError::TruncatedSnapshot`] when exactly 500 results
2648/// are returned, indicating the API limit was likely hit and data may be incomplete.
2649/// This is an Alpaca API limitation with no pagination support for open orders.
2650async fn fetch_raw_open_orders(
2651    http: &reqwest::Client,
2652    rate_limiter: &RateLimitTracker,
2653    base: &str,
2654    instruments: &[InstrumentNameExchange],
2655) -> Result<Vec<AlpacaOrderResponse>, UnindexedClientError> {
2656    let orders: Vec<AlpacaOrderResponse> = if instruments.is_empty() {
2657        rest_with_retry(rate_limiter, || {
2658            http.get(format!("{base}/v2/orders"))
2659                .query(&[("status", "open"), ("limit", "500")])
2660        })
2661        .await?
2662    } else {
2663        let symbols = instruments.iter().map(|i| i.name().as_str()).join(",");
2664        rest_with_retry(rate_limiter, || {
2665            http.get(format!("{base}/v2/orders")).query(&[
2666                ("status", "open"),
2667                ("limit", "500"),
2668                ("symbols", &symbols),
2669            ])
2670        })
2671        .await?
2672    };
2673    if orders.len() == MAX_OPEN_ORDERS {
2674        warn!(
2675            limit = MAX_OPEN_ORDERS,
2676            "Alpaca fetch_raw_open_orders: received exactly {MAX_OPEN_ORDERS} results — \
2677             response is likely truncated"
2678        );
2679        return Err(UnindexedClientError::TruncatedSnapshot {
2680            limit: MAX_OPEN_ORDERS,
2681        });
2682    }
2683    Ok(orders)
2684}
2685
2686// ---------------------------------------------------------------------------
2687// Activity pagination
2688// ---------------------------------------------------------------------------
2689
2690/// Maximum number of pages fetched by one [`paginate_activities`] call.
2691///
2692/// 50 pages × 100 items = 5 000 fills. It bounds how long one read, such as a reconnect's
2693/// recovery, can take; a read that reaches it says where to read on from.
2694const MAX_ACTIVITY_PAGES: usize = 50;
2695
2696/// An account activity that [`paginate_activities`] pages through.
2697trait PagedActivity: DeserializeOwned {
2698    /// The activity's id, which is also the token for the page after it.
2699    fn id(&self) -> &str;
2700    /// The time Alpaca orders activities by and applies `after` and `until` to, or `None` when it
2701    /// does not parse.
2702    fn time(&self) -> Option<DateTime<Utc>>;
2703}
2704
2705impl PagedActivity for AlpacaActivity {
2706    fn id(&self) -> &str {
2707        &self.id
2708    }
2709
2710    fn time(&self) -> Option<DateTime<Utc>> {
2711        parse_timestamp(&self.transaction_time)
2712    }
2713}
2714
2715impl PagedActivity for AlpacaNonTradeActivity {
2716    fn id(&self) -> &str {
2717        &self.id
2718    }
2719
2720    fn time(&self) -> Option<DateTime<Utc>> {
2721        self.created_at.as_deref().and_then(parse_timestamp)
2722    }
2723}
2724
2725/// The `activity_types` a read of fills asks for.
2726const FILL_ACTIVITIES: &str = "FILL";
2727
2728/// The `activity_types` [`AlpacaClient::fetch_cash_flows`] asks for.
2729const CASH_FLOW_ACTIVITIES: &str = "INT,FEE,PTC,PTR";
2730
2731/// The `activity_types` [`AlpacaClient::fetch_option_events`] asks for.
2732const OPTION_EVENT_ACTIVITIES: &str = "OPASN,OPEXC,OPEXP,OPTRD";
2733
2734/// The last millisecond of a day, where [`AlpacaClient::fetch_option_events`] ends its read.
2735const LAST_MILLISECOND: NaiveTime = match NaiveTime::from_hms_milli_opt(23, 59, 59, 999) {
2736    Some(time) => time,
2737    None => panic!("23:59:59.999 is a time of day"),
2738};
2739
2740/// Where a bounded read of `start..=end` that stopped at `resume` should read on from: `None` when
2741/// it read the whole span, or `Err(())` when a read from `resume` would stop where this one did.
2742///
2743/// Alpaca's `until` is not exact, and is not sent for an `end` not yet reached, so a read can run
2744/// past `end`. Activities are ascending by millisecond, so a resume past `end` means every
2745/// activity in the span was read.
2746fn span_resume(
2747    resume: Option<DateTime<Utc>>,
2748    start: DateTime<Utc>,
2749    end: DateTime<Utc>,
2750) -> Result<Option<DateTime<Utc>>, ()> {
2751    let resume = resume.filter(|resume| *resume <= end);
2752    if resume.is_some_and(|resume| resume <= floor_millis(start)) {
2753        return Err(());
2754    }
2755    Ok(resume)
2756}
2757
2758/// What one [`paginate_activities`] call read.
2759struct ActivityPage<A> {
2760    /// Every activity read, in Alpaca's ascending order.
2761    activities: Vec<A>,
2762    /// Set when the read stopped at [`MAX_ACTIVITY_PAGES`]: every activity before this time was
2763    /// read, and some from it on may not have been.
2764    resume: Option<DateTime<Utc>>,
2765}
2766
2767/// `time` rounded down to the millisecond.
2768fn floor_millis(time: DateTime<Utc>) -> DateTime<Utc> {
2769    DateTime::from_timestamp_millis(time.timestamp_millis()).unwrap_or(time)
2770}
2771
2772/// Fetch the activities of the `activity_types` named in `types` (comma-separated) from `start`,
2773/// and up to `end` if given, using token-based pagination.
2774///
2775/// Alpaca returns up to `ALPACA_MAX_ACTIVITIES` per page. If a full page is
2776/// returned, the next request uses the last item's `id` as the `page_token`.
2777/// Pagination terminates when a page has fewer items than `page_size`, or after
2778/// [`MAX_ACTIVITY_PAGES`] pages (whichever comes first).
2779///
2780/// # Bounds
2781///
2782/// Alpaca's `after` and `until` are not exact. Observed on paper (2026-10-05):
2783/// - `after` matches an activity whose time, rounded down to the millisecond, is at or after it.
2784///   So a microsecond `after` drops the rest of its own millisecond. It is sent rounded down to
2785///   the millisecond, which reads the whole of `start`'s.
2786/// - `until` matched no single rule: exact to the microsecond for one fill, yet including a fill
2787///   15 µs past it for two that shared a millisecond. It is sent as the millisecond after
2788///   `end`'s, or not at all for an `end` not yet reached, and callers apply `end` to what is
2789///   read.
2790///
2791/// So the activities read can begin before `start` and end after `end`, within their
2792/// milliseconds.
2793///
2794/// # Resuming
2795///
2796/// Within one millisecond Alpaca orders activities by id, not by time, so a read cut inside a
2797/// millisecond may not have reached an earlier fill in it. [`ActivityPage::resume`] is therefore
2798/// the millisecond of the last activity read, and a read from it reads that millisecond again.
2799// Compile-time string form of ALPACA_MAX_ACTIVITIES (avoids runtime to_string() allocation).
2800const PAGE_SIZE_STR: &str = "100"; // must match ALPACA_MAX_ACTIVITIES
2801const _: () = assert!(
2802    ALPACA_MAX_ACTIVITIES == 100,
2803    "PAGE_SIZE_STR must be updated to match ALPACA_MAX_ACTIVITIES",
2804);
2805
2806async fn paginate_activities<A: PagedActivity>(
2807    http: &reqwest::Client,
2808    rate_limiter: &RateLimitTracker,
2809    base: &str,
2810    types: &str,
2811    start: DateTime<Utc>,
2812    end: Option<DateTime<Utc>>,
2813) -> Result<ActivityPage<A>, UnindexedClientError> {
2814    // Clamped to the epoch so a far-past `start` still formats as RFC 3339; Alpaca holds nothing
2815    // older. An `end` not yet reached sends no `until`, so a far-future one cannot overflow.
2816    let after =
2817        floor_millis(start.max(DateTime::UNIX_EPOCH)).to_rfc3339_opts(SecondsFormat::Millis, true);
2818    let until = end.filter(|end| *end < Utc::now()).map(|end| {
2819        (floor_millis(end) + TimeDelta::milliseconds(1))
2820            .to_rfc3339_opts(SecondsFormat::Millis, true)
2821    });
2822    let mut all = Vec::with_capacity(ALPACA_MAX_ACTIVITIES);
2823    let mut page_token: Option<String> = None;
2824    let mut pages = 0usize;
2825    let mut truncated = false;
2826
2827    loop {
2828        if pages >= MAX_ACTIVITY_PAGES {
2829            truncated = true;
2830            break;
2831        }
2832        pages += 1;
2833        // Borrow page_token as &str so the closure can capture by reference without cloning.
2834        let page_token_ref = page_token.as_deref();
2835        let activities: Vec<A> = rest_with_retry(rate_limiter, || {
2836            let mut req = http.get(format!("{base}/v2/account/activities")).query(&[
2837                ("activity_types", types),
2838                ("after", after.as_str()),
2839                ("page_size", PAGE_SIZE_STR),
2840                ("direction", "asc"),
2841            ]);
2842            if let Some(until) = until.as_deref() {
2843                req = req.query(&[("until", until)]);
2844            }
2845            if let Some(token) = page_token_ref {
2846                req = req.query(&[("page_token", token)]);
2847            }
2848            req
2849        })
2850        .await?;
2851
2852        let page_len = activities.len();
2853        // Capture the page token from the current page BEFORE extending `all`, so that
2854        // any future filtering between here and all.extend cannot shift the last element
2855        // and cause the same page to be re-fetched indefinitely.
2856        //
2857        // Alpaca activity IDs are ULID-format (monotonically increasing) and are accepted
2858        // as exclusive page_token cursors by GET /v2/account/activities: the next page
2859        // begins with the item AFTER the token, so the boundary item is not re-delivered.
2860        // The pagination contract relies on this property — verify against Alpaca API docs
2861        // if behaviour changes.
2862        let page_token_candidate = activities.last().map(|a| a.id().to_owned());
2863        all.extend(activities);
2864
2865        if page_len < ALPACA_MAX_ACTIVITIES {
2866            break;
2867        }
2868        match page_token_candidate {
2869            Some(token) if !token.is_empty() => {
2870                debug!("Alpaca paginate_activities: fetching next page ({page_len} results)");
2871                page_token = Some(token);
2872            }
2873            // Empty token or no last item — pagination complete.
2874            // Guard against empty string to prevent infinite loop if Alpaca ever
2875            // returns a full page with last.id = "" (would restart from beginning).
2876            _ => break,
2877        }
2878    }
2879
2880    // Activities are ascending by millisecond, so the last one whose time parses bounds what is
2881    // left. None parsing leaves everything from `start`.
2882    let resume = truncated.then(|| {
2883        floor_millis(
2884            all.iter()
2885                .rev()
2886                .find_map(PagedActivity::time)
2887                .unwrap_or(start),
2888        )
2889    });
2890    Ok(ActivityPage {
2891        activities: all,
2892        resume,
2893    })
2894}
2895
2896// ---------------------------------------------------------------------------
2897// WebSocket connection manager
2898// ---------------------------------------------------------------------------
2899
2900/// Long-running task managing the WebSocket lifecycle for account_stream.
2901///
2902/// Loop: connect → auth → subscribe → fill recovery → ended-order check → stream events → on
2903/// disconnect → backoff → reconnect. The `tx` channel persists across reconnections so the
2904/// consumer sees a seamless event stream.
2905///
2906/// Terminates when the consumer drops the stream or max reconnect attempts are
2907/// exhausted.
2908#[allow(clippy::cognitive_complexity)] // the inner select! loop owns `ws` and mutates `backoff` —
2909// extracting to a function requires threading 4 non-Clone values (ws, tx, dedup, backoff)
2910// through the call, which adds more complexity than it removes
2911async fn connection_manager(
2912    tx: mpsc::UnboundedSender<UnindexedAccountEvent>,
2913    dedup: SharedDedupCache,
2914    config: Arc<AlpacaConfig>,
2915    http: reqwest::Client,
2916    rate_limiter: Arc<RateLimitTracker>,
2917    known: SharedKnownLiveOrders,
2918    instruments: Vec<InstrumentNameExchange>,
2919    initial_ws: Option<WebSocket>,
2920) {
2921    let mut backoff = ExponentialBackoff::new();
2922    let mut disconnect_time: Option<DateTime<Utc>> = None;
2923    // Instruments whose known-live orders a reconnect has yet to check: checked after the fills,
2924    // and retried on a timer while connected, once due.
2925    let mut unchecked = UncheckedOrders::default();
2926    let mut current_ws = initial_ws;
2927
2928    'outer: loop {
2929        // --- Connect (skip on first iteration if initial_ws was provided) ---
2930        let mut ws = match current_ws.take() {
2931            Some(ws) => ws,
2932            None => match connect_and_subscribe(&config).await {
2933                Ok(ws) => ws,
2934                Err(e) => {
2935                    error!(%e, "Alpaca WS connect/subscribe failed");
2936                    if !backoff.wait().await {
2937                        error!("Alpaca max reconnect attempts exhausted");
2938                        // Signal terminal stream death in-band before tx drops.
2939                        emit_stream_terminated(
2940                            &tx,
2941                            ExchangeId::AlpacaBroker,
2942                            StreamTerminationReason::ReconnectBudgetExhausted {
2943                                attempts: backoff.attempts(),
2944                                last_error: e.to_string(),
2945                            },
2946                        );
2947                        break;
2948                    }
2949                    continue;
2950                }
2951            },
2952        };
2953        info!("Alpaca account_stream connected and subscribed");
2954        // Session established — reset backoff regardless of whether any text events
2955        // arrive. Without this, a heartbeat-only session (Pings only, no trade_updates)
2956        // would never call process_ws_text and therefore never reset the counter,
2957        // causing the next disconnect to exhaust the reconnect budget faster than expected.
2958        //
2959        // Edge case: if the server accepts the WS handshake and immediately closes the
2960        // connection (fast-accept-close loop), backoff resets on every iteration, causing
2961        // all MAX_RECONNECT_ATTEMPTS attempts to run at INITIAL_BACKOFF_MS rather than
2962        // escalating. This is accepted behaviour for this pathological server scenario —
2963        // the 10-attempt budget still provides ~10 s of protection before giving up.
2964        backoff.reset();
2965
2966        // --- Fill recovery after reconnect ---
2967        // Runs before the event loop so live events arriving during recovery are
2968        // captured by the already-connected WS session. The dedup cache prevents
2969        // duplicates between recovered REST fills and live WS events.
2970        if let Some(dt) = disconnect_time.take() {
2971            recover_fills_or_report(
2972                &http,
2973                &rate_limiter,
2974                &instruments,
2975                config.rest_base_url(),
2976                dt,
2977                Duration::from_secs(FILL_RECOVERY_TIMEOUT_SECS),
2978                &tx,
2979                &dedup,
2980                &known,
2981            )
2982            .await;
2983            // Only the instruments this stream recovers fills for, every one when none is named.
2984            let held = {
2985                let known = known.lock();
2986                if instruments.is_empty() {
2987                    known.instruments()
2988                } else {
2989                    known.instruments_among(&instruments)
2990                }
2991            };
2992            unchecked.open(held);
2993        }
2994
2995        // --- Stream events ---
2996        // Each iteration of the inner loop polls ws.next(), a heartbeat timer,
2997        // and tx.closed() simultaneously via select!. The heartbeat deadline is
2998        // reset on every received message (rolling window).
2999        //
3000        // The timer is pinned once and reset in-place via Sleep::reset(), avoiding
3001        // a new Sleep allocation and Tokio timer-wheel registration per loop iteration.
3002        // Track the wall-clock time of the last received message. Used to anchor
3003        // fill recovery after a heartbeat timeout: Utc::now() at reconnect time
3004        // would be ~HEARTBEAT_TIMEOUT_SECS after the last real message, causing
3005        // the recovery window to miss fills in that silent period.
3006
3007        let mut last_message_time = Utc::now();
3008        let heartbeat = tokio::time::sleep(Duration::from_secs(HEARTBEAT_TIMEOUT_SECS));
3009        tokio::pin!(heartbeat);
3010
3011        // How the orders held as live ended: checked alongside the stream, so it is read, and a
3012        // disconnect seen, from the start. Fill recovery has finished or given up above, so the
3013        // fills come first. Dropping this when the stream loop ends loses nothing, since each
3014        // lookup and each check is settled in one step as soon as it ends.
3015        let order_checks =
3016            run_order_checks(&http, &rate_limiter, &config, &known, &mut unchecked, &tx);
3017        tokio::pin!(order_checks);
3018
3019        // Resets the rolling heartbeat deadline and records the wall-clock receive time.
3020        // Pin<&mut Sleep> cannot be passed to a regular function, so a macro avoids
3021        // repeating the same two-line block across every message-bearing select! arm.
3022        // NOTE: must be defined AFTER the variables it captures for macro hygiene.
3023        macro_rules! reset_heartbeat {
3024            () => {
3025                heartbeat.as_mut().reset(
3026                    tokio::time::Instant::now() + Duration::from_secs(HEARTBEAT_TIMEOUT_SECS),
3027                );
3028                last_message_time = Utc::now();
3029            };
3030        }
3031        // Distinguishes why the inner stream loop exited so the terminal emit below can report a
3032        // meaningful `last_error`. The consumer-drop arm uses `break 'outer` and never yields one.
3033        enum DisconnectReason {
3034            ServerClose,
3035            Error(String),
3036            StreamEnded,
3037            HeartbeatTimeout,
3038        }
3039        let reason = loop {
3040            tokio::select! {
3041                msg = ws.next() => {
3042                    match msg {
3043                        Some(Ok(WsMessage::Ping(_))) => {
3044                            // tokio-tungstenite automatically queues a Pong when poll_next
3045                            // returns a Ping; sending a second Pong would be a duplicate.
3046                            reset_heartbeat!();
3047                        }
3048                        Some(Ok(WsMessage::Text(text))) => {
3049                            process_ws_text(text.as_str(), &tx, &dedup, &known, &mut backoff);
3050                            reset_heartbeat!();
3051                        }
3052                        Some(Ok(WsMessage::Binary(bytes))) => {
3053                            // Alpaca paper trading sends binary-framed JSON.
3054                            match std::str::from_utf8(&bytes) {
3055                                Ok(text) => {
3056                                    process_ws_text(text, &tx, &dedup, &known, &mut backoff);
3057                                    // Only reset heartbeat for valid UTF-8 frames that may carry
3058                                    // real events. A corrupt binary frame (e.g. from a proxy)
3059                                    // must not keep the watchdog from firing.
3060                                    reset_heartbeat!();
3061                                }
3062                                Err(e) => warn!(%e, "Alpaca WS binary frame: not valid UTF-8"),
3063                            }
3064                        }
3065                        Some(Ok(WsMessage::Close(frame))) => {
3066                            warn!(frame = ?frame, "Alpaca WS closed by server");
3067                            break DisconnectReason::ServerClose;
3068                        }
3069                        Some(Ok(_)) => {} // Pong, Frame — ignore
3070                        Some(Err(e)) => {
3071                            warn!(%e, "Alpaca WS error, reconnecting");
3072                            break DisconnectReason::Error(e.to_string());
3073                        }
3074                        None => {
3075                            warn!("Alpaca WS stream ended, reconnecting");
3076                            break DisconnectReason::StreamEnded;
3077                        }
3078                    }
3079                }
3080                () = &mut order_checks => {}
3081                _ = &mut heartbeat => {
3082                    warn!(
3083                        timeout_secs = HEARTBEAT_TIMEOUT_SECS,
3084                        "Alpaca heartbeat timeout, reconnecting"
3085                    );
3086                    // Heartbeat timeout is a failure — do NOT reset backoff here.
3087                    // Backoff resets on successful event receipt in process_ws_text.
3088                    break DisconnectReason::HeartbeatTimeout;
3089                }
3090                _ = tx.closed() => {
3091                    debug!("Alpaca account_stream consumer dropped, terminating");
3092                    let _ = tokio::time::timeout(
3093                        Duration::from_secs(WS_CLOSE_TIMEOUT_SECS),
3094                        ws.close(None),
3095                    ).await;
3096                    // Consumer dropped the receiver — no StreamTerminated emit (channel already
3097                    // closed, so it would be a no-op; see emit_stream_terminated docs).
3098                    break 'outer;
3099                }
3100            }
3101        };
3102
3103        // --- Record disconnect time for fill recovery ---
3104        // Anchor to last_message_time, not Utc::now(). For heartbeat-triggered
3105        // disconnects, Utc::now() would be ~HEARTBEAT_TIMEOUT_SECS after the last
3106        // real message, causing the recovery window to miss fills in that gap.
3107        disconnect_time =
3108            Some(last_message_time - chrono::Duration::milliseconds(SIGNAL_RECOVERY_LOOKBACK_MS));
3109
3110        // --- Close stale WS ---
3111        let _ =
3112            tokio::time::timeout(Duration::from_secs(WS_CLOSE_TIMEOUT_SECS), ws.close(None)).await;
3113
3114        if tx.is_closed() {
3115            // Consumer dropped the receiver — no StreamTerminated emit (channel already closed,
3116            // so it would be a no-op; see emit_stream_terminated docs).
3117            break;
3118        }
3119        if !backoff.wait().await {
3120            error!("Alpaca max reconnect attempts exhausted, stream terminating");
3121            // Surrender after repeated reconnects: report the failure that triggered this round
3122            // (consumer-drop already broke out via `break 'outer`).
3123            let last_error = match reason {
3124                DisconnectReason::HeartbeatTimeout => {
3125                    format!("heartbeat timeout ({HEARTBEAT_TIMEOUT_SECS}s)")
3126                }
3127                DisconnectReason::ServerClose => "WebSocket closed by server".to_string(),
3128                DisconnectReason::Error(e) => e,
3129                DisconnectReason::StreamEnded => "WebSocket stream ended".to_string(),
3130            };
3131            emit_stream_terminated(
3132                &tx,
3133                ExchangeId::AlpacaBroker,
3134                StreamTerminationReason::ReconnectBudgetExhausted {
3135                    attempts: backoff.attempts(),
3136                    last_error,
3137                },
3138            );
3139            break;
3140        }
3141    }
3142}
3143
3144/// Error during WebSocket handshake (auth + subscribe).
3145///
3146/// Separates transport errors (network issues, connection drops) from auth errors
3147/// (invalid credentials) so callers can apply appropriate retry logic.
3148#[derive(Debug)]
3149enum HandshakeError {
3150    /// Network-level failure (WS send failed, connection dropped).
3151    /// Transient — retry with backoff.
3152    Transport(String),
3153    /// Authentication rejected by Alpaca (invalid/expired credentials).
3154    /// Not transient — do not retry without credential changes.
3155    Auth(String),
3156}
3157
3158/// Connect to the Alpaca WebSocket, authenticate, and subscribe to trade_updates.
3159///
3160/// On auth or subscribe failure the connection is closed cleanly.
3161async fn connect_and_subscribe(config: &AlpacaConfig) -> Result<WebSocket, UnindexedClientError> {
3162    let url = config.ws_url();
3163    debug!(%url, "Alpaca: connecting to WebSocket");
3164
3165    let mut ws = rustrade_integration::protocol::websocket::connect(url)
3166        .await
3167        .map_err(|e| {
3168            UnindexedClientError::Connectivity(ConnectivityError::Socket(format!(
3169                "WS connect: {e}"
3170            )))
3171        })?;
3172
3173    // auth + subscribe with overall timeout
3174    let result = tokio::time::timeout(
3175        Duration::from_secs(WS_HANDSHAKE_TIMEOUT_SECS),
3176        ws_handshake(&mut ws, config),
3177    )
3178    .await;
3179
3180    match result {
3181        Ok(Ok(())) => Ok(ws),
3182        Ok(Err(HandshakeError::Transport(e))) => {
3183            let _ = ws.close(None).await;
3184            Err(UnindexedClientError::Connectivity(
3185                ConnectivityError::Socket(e),
3186            ))
3187        }
3188        Ok(Err(HandshakeError::Auth(e))) => {
3189            let _ = ws.close(None).await;
3190            Err(UnindexedClientError::Api(ApiError::Unauthenticated(e)))
3191        }
3192        Err(_) => {
3193            let _ = ws.close(None).await;
3194            Err(UnindexedClientError::Connectivity(
3195                ConnectivityError::Timeout,
3196            ))
3197        }
3198    }
3199}
3200
3201/// Perform the Alpaca WS auth + subscribe sequence on a connected WebSocket.
3202///
3203/// Protocol:
3204/// 1. Send `{"action":"auth","key":...,"secret":...}`
3205/// 2. Await `{"stream":"authorization","data":{"status":"authorized",...}}`
3206/// 3. Send `{"action":"listen","data":{"streams":["trade_updates"]}}`
3207/// 4. Await `{"stream":"listening","data":{"streams":["trade_updates"]}}`
3208async fn ws_handshake(ws: &mut WebSocket, config: &AlpacaConfig) -> Result<(), HandshakeError> {
3209    // Step 1: send auth
3210    let auth = serde_json::json!({
3211        "action": "auth",
3212        "key": config.api_key(),
3213        // direct field access within module — secret_key has no pub getter to
3214        // prevent external credential exposure
3215        "secret": config.secret_key,
3216    })
3217    .to_string();
3218    ws.send(WsMessage::Text(auth.into()))
3219        .await
3220        .map_err(|e| HandshakeError::Transport(format!("WS auth send: {e}")))?;
3221
3222    // Step 2: wait for authorization message
3223    loop {
3224        match ws.next().await {
3225            Some(Ok(WsMessage::Text(text))) => {
3226                if let Some(result) = check_auth_response(text.as_str()) {
3227                    result?;
3228                    break;
3229                }
3230            }
3231            Some(Ok(WsMessage::Binary(bytes))) => {
3232                if let Ok(text) = std::str::from_utf8(&bytes)
3233                    && let Some(result) = check_auth_response(text)
3234                {
3235                    result?;
3236                    break;
3237                }
3238            }
3239            Some(Err(e)) => {
3240                return Err(HandshakeError::Transport(format!(
3241                    "WS error during auth: {e}"
3242                )));
3243            }
3244            None => {
3245                return Err(HandshakeError::Transport(
3246                    "WS closed before auth response".into(),
3247                ));
3248            }
3249            _ => {} // ping/pong during auth — ignore
3250        }
3251    }
3252
3253    // Step 3: subscribe to trade_updates
3254    let sub = serde_json::json!({
3255        "action": "listen",
3256        "data": { "streams": ["trade_updates"] }
3257    })
3258    .to_string();
3259    ws.send(WsMessage::Text(sub.into()))
3260        .await
3261        .map_err(|e| HandshakeError::Transport(format!("WS subscribe send: {e}")))?;
3262
3263    // Step 4: wait for listening acknowledgment (optional but confirms subscription)
3264    loop {
3265        match ws.next().await {
3266            Some(Ok(WsMessage::Text(text))) => {
3267                if check_listen_ack(text.as_str()) {
3268                    break;
3269                }
3270                // Other messages in this window are NOT buffered and are permanently
3271                // dropped. On a reconnect, REST recovers the fills and how each order held
3272                // as live ended; any other lifecycle event is lost, as documented in
3273                // account_stream's doc comment.
3274                // Log at warn! for trade_updates (fill/lifecycle events) to ensure
3275                // production operators see dropped events; trace! for other streams.
3276                if let Ok(msg) = serde_json::from_str::<AlpacaStreamMessage<'_>>(text.as_str()) {
3277                    if msg.stream == "trade_updates" {
3278                        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");
3279                    } else {
3280                        trace!(stream = %msg.stream, "WS message dropped during listen-ack handshake");
3281                    }
3282                } else {
3283                    trace!(
3284                        bytes = text.len(),
3285                        "WS non-stream message dropped during listen-ack handshake"
3286                    );
3287                }
3288            }
3289            Some(Ok(WsMessage::Binary(bytes))) => {
3290                if let Ok(text) = std::str::from_utf8(&bytes)
3291                    && check_listen_ack(text)
3292                {
3293                    break;
3294                }
3295                trace!(
3296                    bytes = bytes.len(),
3297                    "WS binary message dropped during listen-ack handshake"
3298                );
3299            }
3300            Some(Err(e)) => {
3301                return Err(HandshakeError::Transport(format!(
3302                    "WS error during subscribe: {e}"
3303                )));
3304            }
3305            None => {
3306                return Err(HandshakeError::Transport(
3307                    "WS closed before subscribe ack".into(),
3308                ));
3309            }
3310            _ => {}
3311        }
3312    }
3313
3314    info!("Alpaca WS authenticated and subscribed to trade_updates");
3315    Ok(())
3316}
3317
3318/// Parse a WS message to check for authorization response.
3319/// Returns `None` if the message is not an authorization response.
3320/// Returns `Some(Ok(()))` on success, `Some(Err(HandshakeError::Auth(...)))` on auth failure.
3321///
3322/// Uses `AlpacaStreamMessage` for consistency with the rest of the WS parsing pipeline.
3323fn check_auth_response(text: &str) -> Option<Result<(), HandshakeError>> {
3324    let msg = serde_json::from_str::<AlpacaStreamMessage<'_>>(text).ok()?;
3325    if msg.stream != "authorization" {
3326        return None;
3327    }
3328    #[derive(Deserialize)]
3329    struct AuthData<'a> {
3330        status: &'a str,
3331    }
3332    let data = serde_json::from_str::<AuthData<'_>>(msg.data.get()).ok()?;
3333    if data.status == "authorized" {
3334        Some(Ok(()))
3335    } else {
3336        Some(Err(HandshakeError::Auth(format!(
3337            "Alpaca WS auth failed: status={}",
3338            data.status
3339        ))))
3340    }
3341}
3342
3343/// Returns `true` if the WS message is a listening acknowledgment for trade_updates.
3344///
3345/// Uses `AlpacaStreamMessage` for consistency with the rest of the WS parsing pipeline.
3346fn check_listen_ack(text: &str) -> bool {
3347    let Ok(msg) = serde_json::from_str::<AlpacaStreamMessage<'_>>(text) else {
3348        return false;
3349    };
3350    if msg.stream != "listening" {
3351        return false;
3352    }
3353    #[derive(Deserialize)]
3354    struct ListenData<'a> {
3355        #[serde(borrow)]
3356        streams: Vec<&'a str>,
3357    }
3358    let Ok(data) = serde_json::from_str::<ListenData<'_>>(msg.data.get()) else {
3359        return false;
3360    };
3361    data.streams.contains(&"trade_updates")
3362}
3363
3364// ---------------------------------------------------------------------------
3365// Process incoming WS messages
3366// ---------------------------------------------------------------------------
3367
3368/// Parse a raw WS text message and forward relevant account events to `tx`.
3369///
3370/// Each event is applied to `known` before it is sent, under the lock, so an order this reports
3371/// ending and a reconnect's check of it reach the consumer in the order they were decided, and the
3372/// check reports only an order not already reported.
3373fn process_ws_text(
3374    text: &str,
3375    tx: &mpsc::UnboundedSender<UnindexedAccountEvent>,
3376    dedup: &SharedDedupCache,
3377    known: &SharedKnownLiveOrders,
3378    backoff: &mut ExponentialBackoff,
3379) {
3380    let msg: AlpacaStreamMessage<'_> = match serde_json::from_str(text) {
3381        Ok(m) => m,
3382        Err(e) => {
3383            trace!(
3384                %e,
3385                raw = ?&text[..text.len().min(200)],
3386                "Alpaca WS: skipped non-JSON message"
3387            );
3388            return;
3389        }
3390    };
3391
3392    // Any successfully-parsed frame proves the connection is alive — reset backoff here
3393    // so that unrecognised event types (pending_cancel, replaced, etc.) also reset it.
3394    backoff.reset();
3395
3396    match msg.stream.as_str() {
3397        "trade_updates" => {
3398            let update: AlpacaTradeUpdate<'_> = match serde_json::from_str(msg.data.get()) {
3399                Ok(u) => u,
3400                Err(e) => {
3401                    // Use warn (not trace): the outer frame was valid trade_updates JSON,
3402                    // so failure here signals an unexpected payload format. Could indicate
3403                    // an Alpaca API change or a missing required field dropping a real
3404                    // event (e.g. a rejected order). Visible at production log levels.
3405                    warn!(
3406                        %e,
3407                        raw = ?&msg.data.get()[..msg.data.get().len().min(200)],
3408                        "Alpaca WS trade_updates: failed to deserialize event — event dropped"
3409                    );
3410                    return;
3411                }
3412            };
3413
3414            // PERF: Check dedup BEFORE constructing the full event for fill events.
3415            // This avoids heap allocations (Trade, InstrumentNameExchange, TradeId) on
3416            // duplicate fills, which occur on every reconnect during recovery overlap.
3417            if is_fill_event(&update)
3418                && is_duplicate_fill(dedup, ws_execution_id(&update), early_dedup_key(&update))
3419            {
3420                trace!("Alpaca WS: skipping duplicate fill event (early check)");
3421                return;
3422            }
3423
3424            // Neither amendment is in the Trading API's docs, and no real frame has been seen, so
3425            // log it whole: the first one shows what Alpaca sends.
3426            if is_trade_amendment(&update) {
3427                warn!(
3428                    event = %update.event,
3429                    raw = %msg.data.get(),
3430                    "Alpaca WS: a fill reported earlier was amended — reporting it as TradeAmended"
3431                );
3432            }
3433
3434            for event in convert_trade_update(update).into_iter().flatten() {
3435                // A resolved correction delivers its execution as the replacement trade. Should
3436                // Alpaca also list it as a FILL activity, recovery must not deliver it again. An
3437                // unresolved one delivered no trade, so recovery may.
3438                if let AccountEventKind::TradeAmended(amendment) = &event.kind
3439                    && let TradeAmendmentKind::Corrected { replacement } = &amendment.kind
3440                {
3441                    record_execution(dedup, &replacement.id.0);
3442                }
3443                let known = KnownLiveOrders::observes(&event.kind).then(|| {
3444                    let mut known = known.lock();
3445                    known.observe(&event);
3446                    known
3447                });
3448                // Consumer dropped errors are benign; connection_manager will detect
3449                // tx.closed() on the next select! poll and exit cleanly.
3450                let _ = tx.send(event);
3451                drop(known);
3452            }
3453        }
3454        "authorization" | "listening" => {
3455            // Ack messages can appear during initial stream if the handshake was
3456            // not fully awaited. These are benign.
3457            trace!(stream = %msg.stream, "Alpaca WS: auth/listen ack received during stream");
3458        }
3459        other => {
3460            trace!(%other, "Alpaca WS: ignoring unknown stream type");
3461        }
3462    }
3463}
3464
3465/// The cumulative dedup key for a fill that took order `order_id` to `cumulative` filled: what
3466/// [`is_duplicate_fill`] recognises a fill by when it has no execution id.
3467///
3468/// The account stream and fill recovery both build it, from the cumulative each reports for the
3469/// order (the WS frame's `order.filled_qty`, the FILL activity's `cum_qty`), so one fill
3470/// delivered by both is recognised. `normalize` strips trailing zeros, so `"1.00"` and `"1"` give
3471/// one key. A WS fill whose `filled_qty` does not parse is keyed at zero.
3472///
3473/// The `format_smolstr!` call heap-allocates for UUID-length order ids (36 chars exceeds
3474/// SmolStr's inline limit), which is unavoidable given the key length.
3475fn fill_dedup_key(order_id: &str, cumulative: Decimal) -> SmolStr {
3476    format_smolstr!("{}:{}", order_id, cumulative.normalize())
3477}
3478
3479/// Returns `true` if this event busts or corrects a fill reported earlier.
3480fn is_trade_amendment(update: &AlpacaTradeUpdate<'_>) -> bool {
3481    matches!(update.event.as_str(), "trade_bust" | "trade_correct")
3482}
3483
3484/// Returns `true` if this event type produces a fill (Trade) event.
3485///
3486/// Used for early dedup check before allocating the full event.
3487#[inline]
3488fn is_fill_event(update: &AlpacaTradeUpdate<'_>) -> bool {
3489    matches!(update.event.as_str(), "fill" | "partial_fill")
3490}
3491
3492/// A WS event's `execution_id`, or `None` when it is missing, null or empty.
3493fn ws_execution_id<'a>(update: &AlpacaTradeUpdate<'a>) -> Option<&'a str> {
3494    update.execution_id.filter(|id| !id.is_empty())
3495}
3496
3497/// The [`fill_dedup_key`] of a WS fill, from its raw fields, before the full event is built.
3498///
3499/// An unparseable `filled_qty` reads as zero, as `convert_trade_update` reports it.
3500fn early_dedup_key(update: &AlpacaTradeUpdate<'_>) -> SmolStr {
3501    let filled_qty = update.order.filled_qty.unwrap_or("0");
3502    let qty = Decimal::from_str(filled_qty).unwrap_or(Decimal::ZERO);
3503    fill_dedup_key(&update.order.id, qty)
3504}
3505
3506/// The execution id in a FILL activity's `id`, `"{time}::{execution id}"`: the same id the WS
3507/// fill carries as `execution_id`. The time prefix is US Eastern local time, so it is never read.
3508/// An id without `::`, or with nothing after it, is taken whole, so it stays unique.
3509fn activity_execution_id(activity_id: &str) -> &str {
3510    activity_execution_id_part(activity_id).unwrap_or(activity_id)
3511}
3512
3513/// The execution id after `::` in a FILL activity's `id`, or `None` when there is none: an id
3514/// taken whole by [`activity_execution_id`] is not one the WS fill carries, so dedup does not
3515/// key on it.
3516fn activity_execution_id_part(activity_id: &str) -> Option<&str> {
3517    activity_id
3518        .split_once("::")
3519        .map(|(_, execution_id)| execution_id)
3520        .filter(|execution_id| !execution_id.is_empty())
3521}
3522
3523// ---------------------------------------------------------------------------
3524// Fill recovery
3525// ---------------------------------------------------------------------------
3526
3527/// Fills a recovery read did not deliver: those from `start` until the recovery began.
3528#[derive(Debug, PartialEq)]
3529struct UnreadFills {
3530    start: DateTime<Utc>,
3531    reason: FillRecoveryFailure,
3532}
3533
3534/// Recover the fills missed since `disconnect` ([`recover_fills`]) within `timeout`, and send an
3535/// [`AccountEventKind::FillRecoveryGaveUp`] for those the read did not deliver, after the fills
3536/// it did.
3537async fn recover_fills_or_report(
3538    http: &reqwest::Client,
3539    rate_limiter: &RateLimitTracker,
3540    instruments: &[InstrumentNameExchange],
3541    base: &str,
3542    disconnect: DateTime<Utc>,
3543    timeout: Duration,
3544    tx: &mpsc::UnboundedSender<UnindexedAccountEvent>,
3545    dedup: &SharedDedupCache,
3546    known: &SharedKnownLiveOrders,
3547) {
3548    // The live stream is already subscribed, so every fill after this arrives live.
3549    let recovery_start = Utc::now();
3550    let outcome = match tokio::time::timeout(
3551        timeout,
3552        recover_fills(
3553            http,
3554            rate_limiter,
3555            instruments,
3556            base,
3557            disconnect,
3558            tx,
3559            dedup,
3560            known,
3561        ),
3562    )
3563    .await
3564    {
3565        Ok(outcome) => outcome,
3566        // One account-wide activities query serves every instrument, so a timeout can have missed
3567        // fills in any of them: name the requested set. Nothing was forwarded, since recovery
3568        // sends its fills only after the read has finished.
3569        Err(_) => {
3570            warn!(
3571                timeout_secs = timeout.as_secs(),
3572                instruments = ?instruments
3573                    .iter()
3574                    .map(|instrument| instrument.name().as_str())
3575                    .collect::<Vec<_>>(),
3576                "Alpaca fill recovery timed out — fills since the disconnect may be missing for \
3577                 any of the instruments (every instrument when the list is empty)"
3578            );
3579            Err(UnreadFills {
3580                start: disconnect,
3581                reason: FillRecoveryFailure::TimedOut {
3582                    timeout_secs: timeout.as_secs(),
3583                },
3584            })
3585        }
3586    };
3587    if let Err(unread) = outcome
3588        && tx
3589            .send(fill_recovery_gave_up(instruments, unread, recovery_start))
3590            .is_err()
3591    {
3592        debug!("Alpaca fill recovery: consumer dropped before the give-up was sent");
3593    }
3594}
3595
3596/// The [`AccountEventKind::FillRecoveryGaveUp`] for `unread`, whose recovery began at `end`,
3597/// on a stream opened with `instruments`, every instrument when empty. Alpaca does not retry the
3598/// read, so it was read once.
3599///
3600/// A truncated read can return fills stamped after `end`, by Alpaca's clock, which may run ahead
3601/// of this host's. The span then ends at its start rather than before it: the event is still
3602/// sent, since a report with nothing left to read costs the consumer one read, while a report
3603/// withheld on a clock comparison would be a silent loss.
3604fn fill_recovery_gave_up(
3605    instruments: &[InstrumentNameExchange],
3606    unread: UnreadFills,
3607    end: DateTime<Utc>,
3608) -> UnindexedAccountEvent {
3609    let UnreadFills { start, reason } = unread;
3610    // One account-wide read serves every instrument, so one event covers them all.
3611    let scope = if instruments.is_empty() {
3612        FillRecoveryScope::AllInstruments
3613    } else {
3614        FillRecoveryScope::Instruments(instruments.to_vec())
3615    };
3616    UnindexedAccountEvent::new(
3617        ExchangeId::AlpacaBroker,
3618        AccountEventKind::FillRecoveryGaveUp(FillRecoveryGap::new(
3619            scope,
3620            start,
3621            end.max(start),
3622            1,
3623            reason,
3624        )),
3625    )
3626}
3627
3628/// Fetch fills missed during a WS disconnect, since `after`, and forward through the dedup cache.
3629///
3630/// A fill that brings an order to its full quantity ends it in `known`, so a reconnect's check
3631/// does not report it again.
3632///
3633/// Returns the fills it did not deliver: all of them when the read fails, and those from the
3634/// millisecond of the last one read when the read stops at [`MAX_ACTIVITY_PAGES`]. The fills it read are sent
3635/// first. A consumer that drops the stream part-way gets `Ok`, as there is no one to report to.
3636async fn recover_fills(
3637    http: &reqwest::Client,
3638    rate_limiter: &RateLimitTracker,
3639    instruments: &[InstrumentNameExchange],
3640    base: &str,
3641    after: DateTime<Utc>,
3642    tx: &mpsc::UnboundedSender<UnindexedAccountEvent>,
3643    dedup: &SharedDedupCache,
3644    known: &SharedKnownLiveOrders,
3645) -> Result<(), UnreadFills> {
3646    // Empty `instruments` means "recover for all subscribed symbols" (same convention as
3647    // `fetch_trades` / `fetch_open_orders`). Skip the set allocation when not filtering.
3648    info!(%after, instruments = instruments.len(), "Alpaca recovering fills after reconnect");
3649    let instrument_set: fnv::FnvHashSet<&str> = if instruments.is_empty() {
3650        fnv::FnvHashSet::default()
3651    } else {
3652        instruments.iter().map(|i| i.name().as_str()).collect()
3653    };
3654
3655    let page = match paginate_activities::<AlpacaActivity>(
3656        http,
3657        rate_limiter,
3658        base,
3659        FILL_ACTIVITIES,
3660        after,
3661        None,
3662    )
3663    .await
3664    {
3665        Ok(p) => p,
3666        Err(e) => {
3667            error!(%e, "Alpaca fill recovery: REST request failed");
3668            return Err(UnreadFills {
3669                start: after,
3670                reason: FillRecoveryFailure::Request(e.to_string()),
3671            });
3672        }
3673    };
3674
3675    let activities = page.activities;
3676
3677    // The read is account-wide, so one that stopped at the page cap has every fill before its
3678    // resume point, whatever the instrument: the millisecond of its last fill, which a read from
3679    // there reads again. The dedup cache absorbs the fills read twice.
3680    let unread = page.resume.map(|start| {
3681        error!(
3682            max_pages = MAX_ACTIVITY_PAGES,
3683            fills_read = activities.len(),
3684            %start,
3685            "Alpaca fill recovery: max page limit reached, truncating — fills from this time on \
3686             are not delivered; read them with fetch_trades"
3687        );
3688        UnreadFills {
3689            start,
3690            reason: FillRecoveryFailure::Truncated {
3691                fills_read: activities.len(),
3692            },
3693        }
3694    });
3695
3696    let mut recovered = 0u32;
3697    let mut duplicates = 0u32;
3698
3699    // Fallback only. A fill is recognised by its execution id, and by its cumulative key
3700    // "{order_id}:{cumulative_filled_qty}" where either path lacks the id (see
3701    // `is_duplicate_fill`). The cumulative is taken from the activity's own `cum_qty` -- the same
3702    // figure the WS path reads from `order.filled_qty` -- so the two agree by construction rather
3703    // than by reconstruction.
3704    //
3705    // 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
3706    // entirely inside the recovery window: it counts from zero per order within the batch, so an
3707    // order that had already partly filled before the window yields keys offset by the amount it
3708    // filled earlier, and those keys match nothing the WS path ever emitted. Preferring `cum_qty`
3709    // is what removes that failure mode.
3710    //
3711    // Activities are fetched with direction=asc, so accumulation here follows execution order.
3712    // Borrow `activities` so &str keys into order_id strings remain valid for the entire loop:
3713    // consuming iteration would drop each activity at the end of its iteration, invalidating keys
3714    // that later fills on the same order still need to look up.
3715    let mut cumulative_qty: FnvHashMap<&str, Decimal> = FnvHashMap::default();
3716
3717    for activity in &activities {
3718        if !instrument_set.is_empty() && !instrument_set.contains(activity.symbol.as_str()) {
3719            // Safe to skip without advancing cumulative_qty: each Alpaca order is bound
3720            // to exactly one symbol, so no fill for a different symbol can ever share
3721            // the same order_id as a fill we're tracking.
3722            continue;
3723        }
3724
3725        // Advance cumulative_qty for every activity, regardless of whether parsing
3726        // succeeds. The WS path uses Alpaca's cumulative filled_qty, which counts ALL
3727        // fills — including those with malformed fields. Skipping the counter for a bad
3728        // fill causes subsequent fills to produce dedup keys that diverge from the WS
3729        // path (off by the bad fill's qty), so the next good fill appears as a duplicate
3730        // on one path and a new fill on the other, resulting in either a missed or a
3731        // double-delivered fill for that execution.
3732        //
3733        // activity.qty is always populated for FILL activities (Alpaca guarantees it).
3734        // unwrap_or(ZERO) guards against unexpected API changes; a zero qty skips
3735        // incrementing the cumulative counter for that activity. Note: a non-empty
3736        // but non-parseable qty string (e.g. an API regression sending "abc") also
3737        // produces exec_qty=ZERO — the counter stalls, causing subsequent fills on
3738        // the same order to produce dedup keys that diverge from the WS path.
3739        let exec_qty = Decimal::from_str(&activity.qty).unwrap_or(Decimal::ZERO);
3740        let cum = cumulative_qty
3741            .entry(activity.order_id.as_str())
3742            .or_default();
3743        *cum += exec_qty;
3744        let cumulative = *cum;
3745
3746        let trade = match convert_activity_to_trade(activity) {
3747            Some(t) => t,
3748            None => {
3749                warn!(id = %activity.id, symbol = %activity.symbol, "Alpaca: skipping activity with unparseable fields");
3750                continue; // Counter already advanced; dedup key sequence stays aligned with WS.
3751            }
3752        };
3753
3754        // The cumulative key, which matches the WS path's for the same fill.
3755        // `order_filled_quantity` holds the venue's own cumulative, parsed by
3756        // `convert_activity_to_trade`; `cumulative` is the intra-batch fallback described above.
3757        //
3758        // The counter is advanced for every activity, including those that carry `cum_qty` and so
3759        // never consult it. That is deliberate: it keeps the fallback usable for a later activity
3760        // on the same order that omits the field. A batch mixing both therefore interleaves two
3761        // key schemes, and the fallback keys in it remain offset by whatever the order filled
3762        // before the window -- the field's presence rescues an activity, not an order.
3763        let key = fill_dedup_key(
3764            &activity.order_id,
3765            trade.order_filled_quantity.unwrap_or(cumulative),
3766        );
3767        if is_duplicate_fill(dedup, activity_execution_id_part(&activity.id), key) {
3768            duplicates += 1;
3769            continue;
3770        }
3771
3772        let event =
3773            UnindexedAccountEvent::new(ExchangeId::AlpacaBroker, AccountEventKind::Trade(trade));
3774        let mut held = known.lock();
3775        held.observe(&event);
3776        let sent = tx.send(event);
3777        drop(held);
3778        if sent.is_err() {
3779            debug!("Alpaca fill recovery: consumer dropped during recovery");
3780            return Ok(());
3781        }
3782        recovered += 1;
3783    }
3784
3785    info!(recovered, duplicates, "Alpaca fill recovery complete");
3786    unread.map_or(Ok(()), Err)
3787}
3788
3789// ---------------------------------------------------------------------------
3790// Ended-order recovery
3791// ---------------------------------------------------------------------------
3792
3793/// Look the order under `key` up with `GET /v2/orders:by_client_order_id`.
3794///
3795/// A 404 is [`OrderLookup::Unknown`], and so is an order on another symbol than the key's (ignoring
3796/// case, as Alpaca's symbols are upper case).
3797async fn fetch_order_lookup(
3798    http: reqwest::Client,
3799    rate_limiter: Arc<RateLimitTracker>,
3800    config: Arc<AlpacaConfig>,
3801    key: UnindexedOrderKey,
3802) -> Result<OrderLookup, UnindexedClientError> {
3803    let url = format!("{}/v2/orders:by_client_order_id", config.rest_base_url());
3804    let cid = key.cid.0.as_str();
3805    let found: Option<AlpacaOrderResponse> = rest_lookup_with_retry(&rate_limiter, || {
3806        http.get(&url).query(&[("client_order_id", cid)])
3807    })
3808    .await?;
3809    let Some(order) = found else {
3810        debug!(instrument = %key.instrument, cid = %key.cid, "Alpaca does not know this order");
3811        return Ok(OrderLookup::Unknown);
3812    };
3813    if !order
3814        .symbol
3815        .eq_ignore_ascii_case(key.instrument.name().as_str())
3816    {
3817        debug!(
3818            instrument = %key.instrument,
3819            cid = %key.cid,
3820            symbol = %order.symbol,
3821            "Alpaca knows this client order id on another symbol"
3822        );
3823        return Ok(OrderLookup::Unknown);
3824    }
3825    Ok(
3826        convert_ended_order(&order, &key).map_or(OrderLookup::NotEnded, |order| {
3827            OrderLookup::Ended(Box::new(order))
3828        }),
3829    )
3830}
3831
3832/// The client order ids one `GET /v2/orders?status=open` lists on `instruments`, for a
3833/// reconnect's check of the orders held as live. A listing of 500 may be truncated, so it fails.
3834async fn listed_open_cids(
3835    http: reqwest::Client,
3836    rate_limiter: Arc<RateLimitTracker>,
3837    config: Arc<AlpacaConfig>,
3838    instruments: Vec<InstrumentNameExchange>,
3839) -> Result<FnvHashSet<ClientOrderId>, UnindexedClientError> {
3840    let orders =
3841        fetch_raw_open_orders(&http, &rate_limiter, config.rest_base_url(), &instruments).await?;
3842    Ok(orders
3843        .iter()
3844        .map(|order| alpaca_cid(order.client_order_id.as_deref(), &order.id))
3845        .collect())
3846}
3847
3848/// [`recover_ended_orders`] on Alpaca: one listing of every instrument due by
3849/// [`listed_open_cids`], and lookups by [`fetch_order_lookup`]. Fill recovery has finished or been
3850/// given up before it runs, so no fills are pending.
3851async fn recover_alpaca_ended_orders(
3852    http: &reqwest::Client,
3853    rate_limiter: &Arc<RateLimitTracker>,
3854    config: &Arc<AlpacaConfig>,
3855    known: &SharedKnownLiveOrders,
3856    unchecked: &mut UncheckedOrders,
3857    tx: &mpsc::UnboundedSender<UnindexedAccountEvent>,
3858) {
3859    recover_ended_orders(
3860        ExchangeId::AlpacaBroker,
3861        known,
3862        unchecked,
3863        &NoPendingFills,
3864        tx,
3865        OpenListing::Batched,
3866        |instruments| {
3867            listed_open_cids(
3868                http.clone(),
3869                rate_limiter.clone(),
3870                config.clone(),
3871                instruments,
3872            )
3873        },
3874        |key| fetch_order_lookup(http.clone(), rate_limiter.clone(), config.clone(), key),
3875    )
3876    .await;
3877}
3878
3879/// Run every order check as it falls due, the first at once and a failed one after its backoff,
3880/// for as long as it is polled. It never completes, and once the consumer has gone it only waits.
3881///
3882/// Fill recovery has finished or been given up before this starts, so no fills are pending.
3883async fn run_order_checks(
3884    http: &reqwest::Client,
3885    rate_limiter: &Arc<RateLimitTracker>,
3886    config: &Arc<AlpacaConfig>,
3887    known: &SharedKnownLiveOrders,
3888    unchecked: &mut UncheckedOrders,
3889    tx: &mpsc::UnboundedSender<UnindexedAccountEvent>,
3890) {
3891    loop {
3892        // A check stops at a gone consumer before settling, so its instruments stay due.
3893        if tx.is_closed() {
3894            return std::future::pending().await;
3895        }
3896        match unchecked.next_due(&NoPendingFills) {
3897            Some(due) => tokio::time::sleep_until(due).await,
3898            None => return std::future::pending().await,
3899        }
3900        recover_alpaca_ended_orders(http, rate_limiter, config, known, unchecked, tx).await;
3901    }
3902}
3903
3904/// How an order that has ended did end, from its REST order under `key`, the key it was asked for
3905/// (see [`ended_order_state`]).
3906///
3907/// Returns `None` for an order still live (see [`order_status_is_live`]), including
3908/// `done_for_day`, and, with a warning, for an order whose status is missing or unknown or that
3909/// cannot be converted. A caller reads `None` as "not ended", so such an order is asked about again
3910/// later rather than retired on a guess.
3911fn convert_ended_order(
3912    o: &AlpacaOrderResponse,
3913    key: &UnindexedOrderKey,
3914) -> Option<UnindexedInactiveOrder> {
3915    let instrument = &key.instrument;
3916    let Some(status) = o.status.as_deref() else {
3917        warn!(%instrument, cid = %key.cid, order_id = %o.id, "Alpaca order missing status");
3918        return None;
3919    };
3920    if order_status_is_live(status) {
3921        return None;
3922    }
3923    let Some(order) = convert_representable_open_order(o) else {
3924        warn!(%instrument, cid = %key.cid, order_id = %o.id, status, "Alpaca order cannot be represented, treating it as not ended");
3925        return None;
3926    };
3927    // Read again rather than from the open order, which reads an unknown fill as zero; that
3928    // conversion has already warned of it.
3929    let filled_qty = Decimal::from_str(&o.filled_qty).ok();
3930    let Some(state) = ended_order_state(
3931        status,
3932        OrderId(SmolStr::new(&o.id)),
3933        order.state.time_exchange,
3934        order.quantity,
3935        filled_qty,
3936        o.filled_avg_price.as_deref(),
3937    ) else {
3938        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");
3939        return None;
3940    };
3941    let mut order = order.map_state(|_| state);
3942    order.key = key.clone();
3943    Some(order)
3944}
3945
3946/// Whether an Alpaca order in `status` is still live, so may yet fill or be cancelled.
3947///
3948/// `done_for_day` is live: Alpaca works the order again the next trading day. The rest are
3949/// Alpaca's working and pending statuses. Every other status has ended the order (see
3950/// [`ended_order_state`]) or is unknown to this version.
3951fn order_status_is_live(status: &str) -> bool {
3952    matches!(
3953        status,
3954        "new"
3955            | "partially_filled"
3956            | "done_for_day"
3957            | "pending_cancel"
3958            | "pending_replace"
3959            | "accepted"
3960            | "pending_new"
3961            | "accepted_for_bidding"
3962            | "stopped"
3963            | "suspended"
3964            | "calculated"
3965            | "held"
3966    )
3967}
3968
3969/// How an order ended, from its Alpaca `status`, or `None` for a status that does not end it.
3970///
3971/// `filled` becomes [`InactiveOrderState::FullyFilled`] with the reported fill, or the whole
3972/// `quantity` when that is unknown, and `filled_avg_price`; `canceled` becomes
3973/// [`InactiveOrderState::Cancelled`], and so does `replaced`, since the order replacing it has an
3974/// id and client order id of its own; `expired` becomes [`InactiveOrderState::Expired`], each with
3975/// what filled before, `None` when that is unknown; and `rejected` becomes
3976/// [`InactiveOrderState::OpenFailed`]. Shared by the lookup of an order that ended
3977/// ([`convert_ended_order`]) and the response to placing one ([`placed_order_state`]).
3978fn ended_order_state<AssetKey, InstrumentKey>(
3979    status: &str,
3980    order_id: OrderId,
3981    time_exchange: DateTime<Utc>,
3982    quantity: Decimal,
3983    filled_qty: Option<Decimal>,
3984    filled_avg_price: Option<&str>,
3985) -> Option<InactiveOrderState<AssetKey, InstrumentKey>> {
3986    Some(match status {
3987        // A filled order filled its whole quantity, whether or not `filled_qty` said so.
3988        "filled" => InactiveOrderState::FullyFilled(Filled::new(
3989            order_id,
3990            time_exchange,
3991            filled_qty.unwrap_or(quantity),
3992            alpaca_avg_price(filled_avg_price),
3993        )),
3994        "canceled" | "replaced" => {
3995            InactiveOrderState::Cancelled(Cancelled::new(order_id, time_exchange, filled_qty))
3996        }
3997        "expired" => InactiveOrderState::Expired(Expired::new(order_id, time_exchange, filled_qty)),
3998        "rejected" => {
3999            InactiveOrderState::OpenFailed(OrderError::Rejected(ApiError::OrderRejected(format!(
4000                "Alpaca rejected order {order_id} after accepting it"
4001            ))))
4002        }
4003        _ => return None,
4004    })
4005}
4006
4007/// The state that `resp`, Alpaca's response to placing an order of `quantity`, reports.
4008///
4009/// An order that ended in the response itself is reported as it ended (see
4010/// [`ended_order_state`]): an IOC or FOK order that found no liquidity, or partly filled and had
4011/// the rest cancelled, is `Cancelled` or `Expired` with what filled, not `Open`, and one Alpaca
4012/// rejected after accepting it is `OpenFailed`. A live status (see [`order_status_is_live`]) reads
4013/// from what filled: `FullyFilled`, with `filled_avg_price`, once it covers `quantity`, otherwise
4014/// `Open`. So does a status that is missing or that this version does not know, with a warning,
4015/// since the account stream reports the order's next state either way.
4016fn placed_order_state(
4017    resp: &AlpacaOrderResponse,
4018    instrument: &InstrumentNameExchange,
4019    quantity: Decimal,
4020) -> UnindexedOrderState {
4021    let order_id = OrderId(SmolStr::new(&resp.id));
4022    let time_exchange = order_state_time(resp);
4023    let filled_qty = alpaca_filled_qty(&resp.id, Some(&resp.filled_qty));
4024    let filled_avg_price = resp.filled_avg_price.as_deref();
4025    match resp.status.as_deref() {
4026        Some(status) => {
4027            if let Some(ended) = ended_order_state(
4028                status,
4029                order_id.clone(),
4030                time_exchange,
4031                quantity,
4032                filled_qty,
4033                filled_avg_price,
4034            ) {
4035                return OrderState::Inactive(ended);
4036            }
4037            if !order_status_is_live(status) {
4038                warn!(%instrument, %order_id, status, "Alpaca placed an order with a status this version does not know, reading it from what filled");
4039            }
4040        }
4041        None => {
4042            warn!(%instrument, %order_id, "Alpaca placed an order without a status, reading it from what filled");
4043        }
4044    }
4045    match filled_qty {
4046        Some(filled_qty) if filled_qty >= quantity => OrderState::fully_filled(Filled::new(
4047            order_id,
4048            time_exchange,
4049            filled_qty,
4050            alpaca_avg_price(filled_avg_price),
4051        )),
4052        // A live order's fill only grows, so an unknown one reads as nothing filled until the
4053        // account stream reports more.
4054        _ => OrderState::active(Open::new(
4055            VenueOrderId::Assigned(order_id),
4056            time_exchange,
4057            filled_qty.unwrap_or(Decimal::ZERO),
4058        )),
4059    }
4060}
4061
4062/// The average price of an Alpaca order's fills, from its `filled_avg_price`: `None` when no
4063/// price is reported or it does not parse.
4064fn alpaca_avg_price(filled_avg_price: Option<&str>) -> Option<Decimal> {
4065    filled_avg_price.and_then(|price| Decimal::from_str(price).ok())
4066}
4067
4068// ---------------------------------------------------------------------------
4069// Type conversion helpers
4070// ---------------------------------------------------------------------------
4071
4072/// Whether a caller's `asset` names the account currency. A name a caller passes is matched
4073/// ignoring case, as the execution map matches the names this client reports.
4074fn is_usd(asset: &AssetNameExchange) -> bool {
4075    asset.name().as_str().eq_ignore_ascii_case(USD)
4076}
4077
4078/// Convert an Alpaca account response to rustrade balance entries.
4079///
4080/// Returns a single USD balance with:
4081/// - `total` = `cash`, which is negative while the account borrows on margin
4082/// - `free` = the lesser of `cash` and `non_marginable_buying_power`: cash that can be spent
4083///   without borrowing, within what Alpaca allows
4084///
4085/// `free` is capped at cash because `non_marginable_buying_power` counts the loan value of held
4086/// marginable stock, so it exceeds cash whenever such stock is held. The cap keeps
4087/// `free <= total`, so with negative cash `free` is negative too.
4088///
4089/// Account equity is not the total: equity and option holdings are reported as positions, and
4090/// counting them again here would double them.
4091///
4092/// The balance is named [`USD`], however the caller spelled it. If `assets` is non-empty, it is
4093/// returned only when USD is among them, matched ignoring case. An empty `assets` slice returns it
4094/// unconditionally.
4095fn convert_account_to_balances(
4096    account: &AlpacaAccount,
4097    assets: &[AssetNameExchange],
4098) -> Vec<AssetBalance<AssetNameExchange>> {
4099    if !assets.is_empty() && !assets.iter().any(is_usd) {
4100        return Vec::new();
4101    }
4102
4103    vec![AssetBalance::new(
4104        AssetNameExchange::new(USD),
4105        Balance::new(
4106            account.cash,
4107            account.cash.min(account.non_marginable_buying_power),
4108        ),
4109        Utc::now(),
4110    )]
4111}
4112
4113/// Convert Alpaca positions to crypto asset balance entries.
4114///
4115/// Only positions with `asset_class == "crypto"` are included; [`convert_positions`] reports the
4116/// rest. Each is named by the asset it holds, read from its symbol by
4117/// [`crypto_position_asset`] as Alpaca spells it (e.g., `"BTCUSD"` → `"BTC"`).
4118///
4119/// - `total` = quantity of the holding in base currency units (e.g. 0.5 BTC)
4120/// - `free`  = qty_available (base currency units not locked in open orders)
4121///
4122/// If `assets` is non-empty, only positions whose base asset name matches an
4123/// entry in the slice (case-insensitive) are returned.
4124fn convert_positions_to_balances(
4125    positions: &[AlpacaPosition],
4126    assets: &[AssetNameExchange],
4127) -> Vec<AssetBalance<AssetNameExchange>> {
4128    let now = Utc::now();
4129    positions
4130        .iter()
4131        .filter(|p| is_crypto_position(p))
4132        .filter_map(|p| {
4133            let base = crypto_position_asset(&p.symbol);
4134
4135            // Apply assets filter if specified.
4136            if !assets.is_empty()
4137                && !assets
4138                    .iter()
4139                    .any(|a| a.name().as_str().eq_ignore_ascii_case(base))
4140            {
4141                return None;
4142            }
4143
4144            // total and free are in base currency units (e.g., BTC), not USD,
4145            // consistent with AssetBalance semantics for currency/crypto assets.
4146            let asset_name = AssetNameExchange::new(base);
4147            Some(AssetBalance::new(
4148                asset_name,
4149                Balance::new(p.qty, p.qty_available),
4150                now,
4151            ))
4152        })
4153        .collect()
4154}
4155
4156/// The asset a crypto position holds, read from its symbol.
4157///
4158/// Alpaca documents that its positions list names a crypto holding by its asset followed by `USD`
4159/// (`BTCUSD` holds BTC), while orders and fills name the pair (`BTC/USD`). Both forms are read,
4160/// the `USD` suffix ignoring case. A symbol in neither form, or with an empty asset, is returned
4161/// whole, with a warning, so its balance is still reported, under a name that fails to index
4162/// rather than not at all.
4163fn crypto_position_asset(symbol: &str) -> &str {
4164    let base = match symbol.split_once('/') {
4165        Some((base, _)) => Some(base),
4166        None => {
4167            let base_len = symbol.len().saturating_sub(USD.len());
4168            symbol
4169                .get(base_len..)
4170                .filter(|suffix| suffix.eq_ignore_ascii_case(USD))
4171                .and_then(|_| symbol.get(..base_len))
4172        }
4173    };
4174    match base {
4175        Some(base) if !base.is_empty() => base,
4176        _ => {
4177            warn!(
4178                %symbol,
4179                "Alpaca crypto position symbol is neither ASSETUSD nor BASE/QUOTE; using it whole"
4180            );
4181            symbol
4182        }
4183    }
4184}
4185
4186/// Whether an Alpaca position is a crypto holding, which is reported as an asset balance rather
4187/// than a [`Position`].
4188fn is_crypto_position(position: &AlpacaPosition) -> bool {
4189    position.asset_class.eq_ignore_ascii_case("crypto")
4190}
4191
4192/// Convert Alpaca's non-crypto positions (equities and options) to [`Position`]s, each with its
4193/// symbol, in Alpaca's order.
4194///
4195/// Crypto is left to [`convert_positions_to_balances`]: Alpaca crypto is spot-only and cannot be
4196/// sold short, so a holding is simply a balance of the base asset, as on every spot venue. Every
4197/// other asset class is a position, so a class Alpaca adds later is reported rather than dropped.
4198///
4199/// Each [`Position`] carries:
4200/// - `quantity`: the magnitude of `qty`, negative when `side` is `short`;
4201/// - `entry_price`: `avg_entry_price`, per share for an equity and per share of the underlying
4202///   for an option (the premium as quoted, not multiplied by the contract size);
4203/// - `unrealized_pnl`: `unrealized_pl`, in USD for the whole position;
4204/// - `time_exchange`: `now`, since Alpaca does not timestamp positions.
4205///
4206/// A position with zero quantity is left out.
4207///
4208/// # Errors
4209///
4210/// [`ClientError::Internal`](crate::error::ClientError::Internal) when a position's `side` is
4211/// missing or neither `long` nor `short`: reporting it either way could invert it, and leaving it
4212/// out would read as flat.
4213fn convert_positions(
4214    positions: &[AlpacaPosition],
4215    now: DateTime<Utc>,
4216) -> Result<Vec<(&str, Position)>, UnindexedClientError> {
4217    positions
4218        .iter()
4219        .filter(|p| !is_crypto_position(p) && !p.qty.is_zero())
4220        .map(|p| {
4221            let size = p.qty.abs();
4222            let quantity = match p.side {
4223                AlpacaPositionSide::Long => size,
4224                AlpacaPositionSide::Short => -size,
4225                AlpacaPositionSide::Unknown => {
4226                    return Err(UnindexedClientError::Internal(format!(
4227                        "Alpaca position {} has a missing or unrecognised side",
4228                        p.symbol
4229                    )));
4230                }
4231            };
4232            let position = Position::new(
4233                quantity,
4234                p.avg_entry_price,
4235                p.unrealized_pl,
4236                None,
4237                None,
4238                None,
4239                None,
4240                now,
4241            );
4242            Ok((p.symbol.as_str(), position))
4243        })
4244        .collect()
4245}
4246
4247/// Group open orders and positions into per-instrument snapshots for account_snapshot.
4248///
4249/// When `instruments` is non-empty, a snapshot is returned for every requested instrument
4250/// (possibly with no orders and no position). When empty, only instruments with an open order or
4251/// a position are returned.
4252///
4253/// `positions` come from [`convert_positions`], which holds every non-zero equity and option
4254/// position, so an equity or option without one is [`PositionReport::Flat`]. A crypto pair (a
4255/// symbol with a `/`) is [`PositionReport::Unreported`]: its holding is a balance.
4256///
4257/// Requested names match Alpaca's symbols ignoring case. When two requested names differ only in
4258/// case, the first gets the symbol's orders and position; the second is
4259/// [`PositionReport::Unreported`] with its orders not complete, since nothing is known about it.
4260///
4261/// Each snapshot declares its orders complete unless [`convert_open_order`] left one of its
4262/// orders out. That holds because `orders` is every open order, unpaged: a response at Alpaca's
4263/// cap fails the whole snapshot in [`fetch_raw_open_orders`] rather than arriving here short. And
4264/// every order is listed under the client order id the order stream reports it under
4265/// ([`alpaca_cid`]): the one it was placed with, or the one Alpaca assigns to a bracket's
4266/// take-profit and stop-loss legs.
4267fn build_instrument_snapshots(
4268    orders: Vec<AlpacaOrderResponse>,
4269    positions: Vec<(&str, Position)>,
4270    instruments: &[InstrumentNameExchange],
4271) -> Vec<InstrumentAccountSnapshot<ExchangeId, AssetNameExchange, InstrumentNameExchange>> {
4272    /// One instrument's converted orders, whether none was left out, and its position.
4273    struct Listing {
4274        orders: Vec<UnindexedOrderSnapshot>,
4275        complete: bool,
4276        position: Option<Position>,
4277    }
4278
4279    impl Listing {
4280        fn empty() -> Self {
4281            Self {
4282                orders: Vec::new(),
4283                complete: true,
4284                position: None,
4285            }
4286        }
4287    }
4288
4289    // Build ordered map from symbol → snapshot to preserve deterministic ordering.
4290    let mut by_symbol: IndexMap<SmolStr, Listing> = IndexMap::new();
4291
4292    // Keyed by the upper-case symbol, so a requested name in another case still finds its
4293    // listing. Alpaca's own symbols are upper case, so an unfiltered read lists them unchanged.
4294    for order in orders {
4295        let listing = by_symbol
4296            .entry(order.symbol.to_ascii_uppercase().into())
4297            .or_insert_with(Listing::empty);
4298        match convert_open_order(&order) {
4299            Some(converted) => listing.orders.push(converted.into()),
4300            None => listing.complete = false,
4301        }
4302    }
4303
4304    for (symbol, position) in positions {
4305        by_symbol
4306            .entry(symbol.to_ascii_uppercase().into())
4307            .or_insert_with(Listing::empty)
4308            .position = Some(position);
4309    }
4310
4311    let snapshot = |instrument: InstrumentNameExchange, listing: Listing| {
4312        // `positions` holds every non-zero equity and option position, so one missing here is
4313        // flat. Crypto is reported as balances, never as a position.
4314        let position = if is_options_or_equity_symbol(instrument.name().as_str()) {
4315            listing
4316                .position
4317                .map_or(PositionReport::Flat, PositionReport::from_position)
4318        } else {
4319            PositionReport::Unreported
4320        };
4321        InstrumentAccountSnapshot::new(instrument, listing.orders, listing.complete, position, None)
4322    };
4323
4324    // If instruments is empty, return all; otherwise filter to requested set.
4325    if instruments.is_empty() {
4326        by_symbol
4327            .into_iter()
4328            .map(|(sym, listing)| snapshot(InstrumentNameExchange::new(sym), listing))
4329            .collect()
4330    } else {
4331        // Upper-case symbols already given to an earlier requested name.
4332        let mut claimed = std::collections::HashSet::new();
4333        instruments
4334            .iter()
4335            .map(|inst| {
4336                let symbol = inst.name().as_str().to_ascii_uppercase();
4337                // A second name for the same symbol, differing only in case: its listing went to
4338                // the first, so nothing is known about it rather than it being flat and orderless.
4339                if !claimed.insert(symbol.clone()) {
4340                    return InstrumentAccountSnapshot::new(
4341                        inst.clone(),
4342                        Vec::new(),
4343                        false,
4344                        PositionReport::Unreported,
4345                        None,
4346                    );
4347                }
4348                // swap_remove is O(1); output order is determined by the `instruments`
4349                // slice, not by the internal IndexMap order of `by_symbol`.
4350                let listing = by_symbol
4351                    .swap_remove(symbol.as_str())
4352                    .unwrap_or_else(Listing::empty);
4353                snapshot(inst.clone(), listing)
4354            })
4355            .collect()
4356    }
4357}
4358
4359/// Convert an Alpaca REST open order into rustrade's Open state order.
4360///
4361/// `None`, with a `warn!`, for an order it cannot represent: a notional order (placed by dollar
4362/// value, so its `qty` is null), which this client never places but the Alpaca dashboard can, or
4363/// one whose side, quantity or kind does not parse. A list missing that order is not every open
4364/// order, so [`build_instrument_snapshots`] must not declare it complete.
4365///
4366/// It warns on every call, so an order like that which stays open is logged again each time the
4367/// open orders are fetched.
4368fn convert_open_order(
4369    o: &AlpacaOrderResponse,
4370) -> Option<Order<ExchangeId, InstrumentNameExchange, Open>> {
4371    let converted = convert_representable_open_order(o);
4372    if converted.is_none() {
4373        warn!(
4374            order_id = %o.id,
4375            symbol = %o.symbol,
4376            qty = ?o.qty,
4377            side = %o.side,
4378            order_type = %o.order_type,
4379            "Alpaca open order cannot be represented - leaving it out"
4380        );
4381    }
4382    converted
4383}
4384
4385/// The client order id an Alpaca order is reported under, on the REST and stream paths alike: its
4386/// `client_order_id`, or its venue id when it has none.
4387///
4388/// Both paths must agree, or a complete snapshot would miss an order the stream is tracking under
4389/// another id.
4390fn alpaca_cid(client_order_id: Option<&str>, order_id: &str) -> ClientOrderId {
4391    ClientOrderId::new(client_order_id.unwrap_or(order_id))
4392}
4393
4394/// [`convert_open_order`] without the `warn!`.
4395fn convert_representable_open_order(
4396    o: &AlpacaOrderResponse,
4397) -> Option<Order<ExchangeId, InstrumentNameExchange, Open>> {
4398    let order_id = OrderId(SmolStr::new(&o.id));
4399    let cid = alpaca_cid(o.client_order_id.as_deref(), &o.id);
4400
4401    let instrument = InstrumentNameExchange::new(&o.symbol);
4402    let side = parse_side(&o.side)?;
4403    let quantity = Decimal::from_str(o.qty.as_deref().unwrap_or("0")).ok()?;
4404    // Notional orders (placed by dollar value, qty=null) have no representable quantity.
4405    // Leave them out rather than recording a zero-quantity order that would corrupt
4406    // reconciliation.
4407    if quantity.is_zero() {
4408        return None;
4409    }
4410    let price = o
4411        .limit_price
4412        .as_deref()
4413        .and_then(|s| Decimal::from_str(s).ok());
4414    let filled_qty = alpaca_filled_qty(&o.id, Some(&o.filled_qty)).unwrap_or(Decimal::ZERO);
4415    let kind = parse_order_kind(
4416        &o.order_type,
4417        o.stop_price.as_deref(),
4418        o.trail_percent.as_deref(),
4419        o.trail_price.as_deref(),
4420    )?;
4421    let time_in_force = parse_time_in_force(&o.time_in_force);
4422    let time_exchange = order_state_time(o);
4423
4424    Some(Order {
4425        key: OrderKey::new(
4426            ExchangeId::AlpacaBroker,
4427            instrument,
4428            // Alpaca doesn't carry strategy IDs in any response field.
4429            StrategyId::unknown(),
4430            cid,
4431        ),
4432        side,
4433        price,
4434        quantity,
4435        kind,
4436        time_in_force,
4437        state: Open::new(VenueOrderId::Assigned(order_id), time_exchange, filled_qty),
4438    })
4439}
4440
4441/// Convert an Alpaca FILL activity into a rustrade Trade.
4442fn convert_activity_to_trade(
4443    a: &AlpacaActivity,
4444) -> Option<Trade<AssetNameExchange, InstrumentNameExchange>> {
4445    let trade_id = TradeId::new(activity_execution_id(&a.id));
4446    let order_id = OrderId(SmolStr::new(&a.order_id));
4447    let instrument = InstrumentNameExchange::new(&a.symbol);
4448    let side = parse_side(&a.side)?;
4449    let price = Decimal::from_str(&a.price).ok()?;
4450    let quantity = Decimal::from_str(&a.qty).ok()?;
4451    // `None` when Alpaca omitted the field or sent something unparseable, which is the honest
4452    // report: the venue did not tell us where the order stands, rather than "nothing is filled".
4453    let order_filled_quantity = a.cum_qty.as_deref().and_then(|s| Decimal::from_str(s).ok());
4454    let time_exchange = parse_timestamp(&a.transaction_time).unwrap_or_else(|| {
4455        warn!(id = %a.id, "Alpaca activity: unparseable transaction_time, using now");
4456        Utc::now()
4457    });
4458
4459    // Alpaca equities and options are commission-free. Crypto trades incur
4460    // maker/taker fees (currently 0.15–0.25%) charged in the credited asset,
4461    // but fee info is not available in trade responses — use Activities API
4462    // for end-of-day fee reconciliation.
4463    Some(Trade::new(
4464        trade_id,
4465        order_id,
4466        instrument,
4467        StrategyId::unknown(),
4468        time_exchange,
4469        side,
4470        price,
4471        quantity,
4472        order_filled_quantity,
4473        AssetFees::new(
4474            AssetNameExchange::from(USD),
4475            Decimal::ZERO,
4476            Some(Decimal::ZERO),
4477        ),
4478    ))
4479}
4480
4481/// Build an `OrderSnapshot` event from the order payload embedded in a `trade_updates` frame.
4482///
4483/// Shared by the acknowledgement arm and the fill arms of [`convert_trade_update`], so that a
4484/// fill reports the order's new cumulative `filled_quantity` the same way an acknowledgement
4485/// reports its initial one.
4486///
4487/// Returns `None` for a notional order (placed by dollar value, `qty` is null). Emitting a
4488/// snapshot with `quantity == 0` would read as an order with nothing left to fill and retire it,
4489/// so such orders are left untracked -- consistent with `convert_open_order` on the REST path.
4490fn ws_order_snapshot(
4491    order: &AlpacaOrderWs<'_>,
4492    instrument: InstrumentNameExchange,
4493    cid: ClientOrderId,
4494    order_id: OrderId,
4495    time_exchange: DateTime<Utc>,
4496) -> Option<UnindexedAccountEvent> {
4497    let side = parse_side(&order.side)?;
4498    let quantity = Decimal::from_str(order.qty.unwrap_or("0")).unwrap_or(Decimal::ZERO);
4499    if quantity.is_zero() {
4500        trace!(order_id = %order.id, "Alpaca WS: skipping notional order snapshot (qty=None)");
4501        return None;
4502    }
4503    let price = order.limit_price.and_then(|s| Decimal::from_str(s).ok());
4504    // A live order's fill only grows, so a frame that omits it (some lifecycle events may) reads
4505    // as nothing filled yet, without a warning; one that garbles it is still reported.
4506    let filled_qty = order
4507        .filled_qty
4508        .and_then(|_| alpaca_filled_qty(&order.id, order.filled_qty))
4509        .unwrap_or(Decimal::ZERO);
4510    let kind = parse_order_kind(
4511        &order.order_type,
4512        order.stop_price,
4513        order.trail_percent,
4514        order.trail_price,
4515    )?;
4516    let time_in_force = parse_time_in_force(&order.time_in_force);
4517
4518    let order_snapshot = crate::order::Order {
4519        key: OrderKey::new(
4520            ExchangeId::AlpacaBroker,
4521            instrument,
4522            StrategyId::unknown(),
4523            cid,
4524        ),
4525        side,
4526        price,
4527        quantity,
4528        kind,
4529        time_in_force,
4530        state: OrderState::active(Open::new(
4531            VenueOrderId::Assigned(order_id),
4532            time_exchange,
4533            filled_qty,
4534        )),
4535    };
4536    Some(UnindexedAccountEvent::new(
4537        ExchangeId::AlpacaBroker,
4538        AccountEventKind::OrderSnapshot(rustrade_integration::collection::snapshot::Snapshot(
4539            order_snapshot,
4540        )),
4541    ))
4542}
4543
4544/// Whether a fill frame's order payload describes an order that is still live at the exchange,
4545/// and may therefore be written into engine state as an `Open` snapshot.
4546///
4547/// A fill frame carries the order's status alongside the execution. Only `partially_filled` and
4548/// `filled` say the order reached this execution while working; every other status means some
4549/// other frame owns the order's current state. Writing an `Open` snapshot from one of those would
4550/// resurrect an order the engine has already retired, leaving a resting order that does not exist
4551/// at the exchange -- a fill that arrives after its order's terminal frame is exactly the ordering
4552/// this guards against.
4553fn ws_fill_order_is_live(status: &str) -> bool {
4554    matches!(status, "partially_filled" | "filled")
4555}
4556
4557/// The [`AccountEventKind::TradeAmended`] for a `trade_bust` or `trade_correct` frame.
4558///
4559/// Neither event is in the Trading API's docs. The fields read are those of the Broker API's
4560/// `TradeUpdateEventV2`: `previous_execution_id` names the fill amended, `execution_id` is taken
4561/// as the corrected execution's id, `qty` is the busted or corrected quantity (negative on some
4562/// busts), and `price` the corrected price, which that schema does not promise on a correction. A
4563/// correction lacking anything a replacement trade needs is reported as
4564/// [`TradeAmendmentKind::CorrectedUnresolved`] with what it did carry, never dropped.
4565///
4566/// No order snapshot is emitted: a bust's lower cumulative would be refused, as
4567/// [`TradeAmendment`](crate::trade::TradeAmendment) documents.
4568fn trade_amendment_event(
4569    update: &AlpacaTradeUpdate<'_>,
4570    instrument: InstrumentNameExchange,
4571    order_id: OrderId,
4572) -> UnindexedAccountEvent {
4573    let order = &update.order;
4574    let time_exchange = update
4575        .timestamp
4576        .and_then(parse_timestamp)
4577        .unwrap_or_else(Utc::now);
4578    let original = update
4579        .previous_execution_id
4580        .filter(|id| !id.is_empty())
4581        .map(TradeId::new);
4582    let quantity = update.qty.and_then(|s| Decimal::from_str(s).ok());
4583
4584    let kind = if update.event == "trade_bust" {
4585        TradeAmendmentKind::Busted {
4586            quantity: quantity.map(|quantity| quantity.abs()),
4587        }
4588    } else {
4589        let id = ws_execution_id(update).map(TradeId::new);
4590        let price = update.price.and_then(|s| Decimal::from_str(s).ok());
4591        match (id, price, quantity, parse_side(&order.side)) {
4592            (Some(id), Some(price), Some(quantity), Some(side)) if quantity > Decimal::ZERO => {
4593                TradeAmendmentKind::Corrected {
4594                    replacement: with_reported_cid(
4595                        Trade::new(
4596                            id,
4597                            order_id.clone(),
4598                            instrument.clone(),
4599                            StrategyId::unknown(),
4600                            time_exchange,
4601                            side,
4602                            price,
4603                            quantity,
4604                            // What `order.filled_qty` means on a correction is undocumented, so the
4605                            // replacement does not claim a cumulative.
4606                            None,
4607                            // As on a fill: no fee info in WebSocket updates.
4608                            AssetFees::new(
4609                                AssetNameExchange::from(USD),
4610                                Decimal::ZERO,
4611                                Some(Decimal::ZERO),
4612                            ),
4613                        ),
4614                        order.client_order_id.as_ref(),
4615                    ),
4616                }
4617            }
4618            (id, price, quantity, _) => TradeAmendmentKind::CorrectedUnresolved {
4619                id,
4620                price,
4621                quantity,
4622            },
4623        }
4624    };
4625
4626    UnindexedAccountEvent::new(
4627        ExchangeId::AlpacaBroker,
4628        AccountEventKind::TradeAmended(crate::trade::TradeAmendment::new(
4629            instrument,
4630            order_id,
4631            time_exchange,
4632            original,
4633            kind,
4634        )),
4635    )
4636}
4637
4638/// Convert a WebSocket trade_update event into rustrade AccountEvents.
4639///
4640/// Returns up to two events, in the order they must be applied. A fill frame genuinely carries
4641/// two facts -- the execution print and the order's new cumulative filled quantity -- so it maps
4642/// to both a `Trade` and an `OrderSnapshot`.
4643///
4644/// The `Trade` is always first. A fully-filled snapshot retires its order, and routing a fill
4645/// against an order that has already been retired is a strictly harder problem than routing it
4646/// against a live one; emitting the execution first keeps the easy ordering.
4647fn convert_trade_update(update: AlpacaTradeUpdate<'_>) -> [Option<UnindexedAccountEvent>; 2] {
4648    // Early exit for unrecognised event types before incurring allocations for
4649    // instrument/order_id/cid — those are wasted for unknown events.
4650    let event_str = update.event.as_str();
4651    if !matches!(
4652        event_str,
4653        "fill"
4654            | "partial_fill"
4655            | "new"
4656            | "accepted"
4657            | "pending_new"
4658            | "canceled"
4659            | "expired"
4660            | "replaced"
4661            | "done_for_day"
4662            | "rejected"
4663            | "trade_bust"
4664            | "trade_correct"
4665    ) {
4666        trace!(event = %event_str, "Alpaca WS: ignoring trade_updates event type");
4667        return [None, None];
4668    }
4669
4670    let order = &update.order;
4671    let instrument = InstrumentNameExchange::new(&*order.symbol);
4672    let order_id = OrderId(order.id.clone());
4673    let cid = alpaca_cid(order.client_order_id.as_deref(), &order.id);
4674
4675    match event_str {
4676        "fill" | "partial_fill" => {
4677            // Use event-level price/qty for the per-execution trade.
4678            let (Some(price), Some(quantity), Some(side)) = (
4679                update.price.and_then(|s| Decimal::from_str(s).ok()),
4680                update.qty.and_then(|s| Decimal::from_str(s).ok()),
4681                parse_side(&order.side),
4682            ) else {
4683                return [None, None];
4684            };
4685            let time_exchange = update
4686                .timestamp
4687                .and_then(parse_timestamp)
4688                .unwrap_or_else(Utc::now);
4689
4690            // If filled_qty is unparseable (API regression), cum_qty falls back to zero: the
4691            // trade reports a cumulative of 0, and a fill without an execution id is deduplicated
4692            // on "order_id:0", so a second such fill on the same order would be silently dropped.
4693            // Warn loudly so API regressions are surfaced immediately.
4694            let cum_qty = Decimal::from_str(order.filled_qty.unwrap_or("0"))
4695                .inspect_err(|e| {
4696                    warn!(
4697                        order_id = %order.id,
4698                        filled_qty = ?order.filled_qty,
4699                        %e,
4700                        "Alpaca WS: failed to parse filled_qty — the fill reports a cumulative \
4701                         of 0, and without an execution id a second such fill on the same order \
4702                         would be deduplicated away"
4703                    );
4704                })
4705                .unwrap_or(Decimal::ZERO);
4706            // The execution id, which the FILL activity for this fill carries too, so the fill has
4707            // one TradeId however it is delivered. Without it, fall back to the dedup key, which
4708            // is unique per fill but matches nothing a REST read returns.
4709            let trade_id = match ws_execution_id(&update) {
4710                Some(execution_id) => TradeId::new(execution_id),
4711                None => {
4712                    warn!(
4713                        order_id = %order.id,
4714                        "Alpaca WS: fill without a usable execution_id — its TradeId is \
4715                         \"{{order_id}}:{{filled_qty}}\", which fetch_trades will not match"
4716                    );
4717                    TradeId(fill_dedup_key(&order.id, cum_qty))
4718                }
4719            };
4720
4721            // Alpaca equities and options are commission-free. Crypto trades incur
4722            // maker/taker fees (currently 0.15–0.25%) in the credited asset, but
4723            // fee info is not available in WebSocket updates.
4724            let trade = Trade::new(
4725                trade_id,
4726                order_id.clone(),
4727                instrument.clone(),
4728                StrategyId::unknown(),
4729                time_exchange,
4730                side,
4731                price,
4732                quantity,
4733                // The venue's own cumulative for this order, as of this execution.
4734                Some(cum_qty),
4735                AssetFees::new(
4736                    AssetNameExchange::from(USD),
4737                    Decimal::ZERO,
4738                    Some(Decimal::ZERO),
4739                ),
4740            );
4741            let trade = with_reported_cid(trade, order.client_order_id.as_ref());
4742            let trade_event = UnindexedAccountEvent::new(
4743                ExchangeId::AlpacaBroker,
4744                AccountEventKind::Trade(trade),
4745            );
4746
4747            // The execution alone does not move the order: `filled_quantity` is only ever carried
4748            // into engine state by an order snapshot, so without this second event a partially
4749            // filled order reads as having nothing filled until REST reconciliation refreshes it.
4750            let snapshot_event = if ws_fill_order_is_live(&order.status) {
4751                ws_order_snapshot(order, instrument, cid, order_id, time_exchange)
4752            } else {
4753                trace!(
4754                    order_id = %order.id,
4755                    status = %order.status,
4756                    "Alpaca WS: fill for an order the exchange no longer reports as working — \
4757                     emitting the execution without an order snapshot"
4758                );
4759                None
4760            };
4761
4762            [Some(trade_event), snapshot_event]
4763        }
4764
4765        "new" | "accepted" | "pending_new" | "done_for_day" => {
4766            // Order acknowledged by Alpaca, or done for the day: Alpaca stops working a
4767            // `done_for_day` order until the next trading day but has not ended it, so it is
4768            // reported open with what it has filled, never retired.
4769            let time_exchange = update
4770                .timestamp
4771                .and_then(parse_timestamp)
4772                .unwrap_or_else(Utc::now);
4773
4774            [
4775                ws_order_snapshot(order, instrument, cid, order_id, time_exchange),
4776                None,
4777            ]
4778        }
4779
4780        "canceled" | "expired" | "replaced" => {
4781            // Order no longer active.
4782            //
4783            // NOTE on "replaced": Alpaca's replace operation cancels the original order
4784            // and creates a NEW order with a different order ID. This arm correctly marks
4785            // the original as cancelled, but callers must call `fetch_open_orders` to
4786            // discover the replacement order (which has a new ID and won't appear in OMS
4787            // state automatically).
4788            let time_exchange = update
4789                .timestamp
4790                .and_then(parse_timestamp)
4791                .unwrap_or_else(Utc::now);
4792            let filled_qty = alpaca_filled_qty(&order.id, order.filled_qty);
4793            let cancelled = Cancelled::new(order_id, time_exchange, filled_qty);
4794            let response = crate::order::request::OrderResponseCancel {
4795                key: OrderKey::new(
4796                    ExchangeId::AlpacaBroker,
4797                    instrument,
4798                    StrategyId::unknown(),
4799                    cid,
4800                ),
4801                state: Ok(cancelled),
4802            };
4803            [
4804                Some(UnindexedAccountEvent::new(
4805                    ExchangeId::AlpacaBroker,
4806                    AccountEventKind::OrderCancelled(response),
4807                )),
4808                None,
4809            ]
4810        }
4811
4812        "trade_bust" | "trade_correct" => [
4813            Some(trade_amendment_event(&update, instrument, order_id)),
4814            None,
4815        ],
4816
4817        "rejected" => {
4818            let response = crate::order::request::OrderResponseCancel {
4819                key: OrderKey::new(
4820                    ExchangeId::AlpacaBroker,
4821                    instrument,
4822                    StrategyId::unknown(),
4823                    cid,
4824                ),
4825                state: Err(UnindexedOrderError::Rejected(ApiError::OrderRejected(
4826                    format!("order rejected: status={}", order.status),
4827                ))),
4828            };
4829            [
4830                Some(UnindexedAccountEvent::new(
4831                    ExchangeId::AlpacaBroker,
4832                    AccountEventKind::OrderCancelled(response),
4833                )),
4834                None,
4835            ]
4836        }
4837
4838        // All recognised event types are handled above; the early-return guard at the
4839        // top of this function ensures we never reach here for unknown event types.
4840        _ => unreachable!("convert_trade_update: unrecognised event passed early-return guard"),
4841    }
4842}
4843
4844// ---------------------------------------------------------------------------
4845// Field parsers
4846// ---------------------------------------------------------------------------
4847
4848fn parse_side(s: &str) -> Option<Side> {
4849    match s {
4850        "buy" | "Buy" | "BUY" => Some(Side::Buy),
4851        "sell" | "Sell" | "SELL" => Some(Side::Sell),
4852        other => {
4853            trace!(%other, "Alpaca: unknown order side");
4854            None
4855        }
4856    }
4857}
4858
4859fn parse_order_kind(
4860    order_type: &str,
4861    stop_price: Option<&str>,
4862    trail_percent: Option<&str>,
4863    trail_price: Option<&str>,
4864) -> Option<OrderKind> {
4865    match order_type {
4866        "market" | "Market" => Some(OrderKind::Market),
4867        "limit" | "Limit" => Some(OrderKind::Limit),
4868        "stop" | "Stop" => {
4869            let trigger_price = stop_price.and_then(|s| Decimal::from_str(s).ok())?;
4870            Some(OrderKind::Stop { trigger_price })
4871        }
4872        "stop_limit" | "Stop_limit" => {
4873            let trigger_price = stop_price.and_then(|s| Decimal::from_str(s).ok())?;
4874            Some(OrderKind::StopLimit { trigger_price })
4875        }
4876        "trailing_stop" | "Trailing_stop" => {
4877            // Alpaca returns either trail_percent or trail_price, not both.
4878            if let Some(pct) = trail_percent.and_then(|s| Decimal::from_str(s).ok()) {
4879                Some(OrderKind::TrailingStop {
4880                    offset: pct,
4881                    offset_type: TrailingOffsetType::Percentage,
4882                    activation_price: None,
4883                })
4884            } else if let Some(price) = trail_price.and_then(|s| Decimal::from_str(s).ok()) {
4885                Some(OrderKind::TrailingStop {
4886                    offset: price,
4887                    offset_type: TrailingOffsetType::Absolute,
4888                    activation_price: None,
4889                })
4890            } else {
4891                trace!("Alpaca: trailing_stop missing trail_percent and trail_price");
4892                None
4893            }
4894        }
4895        other => {
4896            trace!(%other, "Alpaca: unsupported order type, skipping");
4897            None
4898        }
4899    }
4900}
4901
4902fn parse_time_in_force(s: &str) -> TimeInForce {
4903    match s {
4904        "gtc" | "GTC" => TimeInForce::GoodUntilCancelled { post_only: false },
4905        "day" | "DAY" => TimeInForce::GoodUntilEndOfDay,
4906        "fok" | "FOK" => TimeInForce::FillOrKill,
4907        "ioc" | "IOC" => TimeInForce::ImmediateOrCancel,
4908        other => {
4909            warn!(%other, "Alpaca: unknown time_in_force, defaulting to GoodUntilEndOfDay");
4910            TimeInForce::GoodUntilEndOfDay
4911        }
4912    }
4913}
4914
4915/// When the exchange last reported this order's state.
4916///
4917/// [`Open::time_exchange`] orders an order's states, and the engine discards a snapshot older
4918/// than the state it already tracks. `created_at` does not order them: it is the same value for
4919/// every snapshot of one order, so a snapshot stamped with it is indistinguishable from the
4920/// acknowledgement and is discarded the moment anything has advanced the tracked order past it.
4921/// `updated_at` moves with each state change, which is what that comparison needs.
4922///
4923/// Falls back to `created_at` when the venue omits `updated_at`, and to now when neither parses.
4924fn order_state_time(order: &AlpacaOrderResponse) -> DateTime<Utc> {
4925    order
4926        .updated_at
4927        .as_deref()
4928        .and_then(parse_timestamp)
4929        .or_else(|| parse_timestamp(&order.created_at))
4930        .unwrap_or_else(Utc::now)
4931}
4932
4933/// The quantity Alpaca order `order_id` has filled, from its `filled_qty`: `None`, with a
4934/// warning, when it is missing or does not parse, so that an unknown fill is not read as zero.
4935fn alpaca_filled_qty(order_id: &str, filled_qty: Option<&str>) -> Option<Decimal> {
4936    let Some(raw) = filled_qty else {
4937        warn!(%order_id, "Alpaca did not report how much the order filled");
4938        return None;
4939    };
4940    match Decimal::from_str(raw) {
4941        Ok(filled_qty) => Some(filled_qty),
4942        Err(_) => {
4943            warn!(%order_id, filled_qty = raw, "Alpaca reported an unparseable filled_qty, treating it as unknown");
4944            None
4945        }
4946    }
4947}
4948
4949/// A non-trade activity as a [`CashFlow`], or `None` if Alpaca canceled it or it lacks an amount
4950/// or a time. See [`AlpacaClient::fetch_cash_flows`].
4951fn convert_cash_flow(
4952    activity: &AlpacaNonTradeActivity,
4953) -> Option<CashFlow<AssetNameExchange, InstrumentNameExchange>> {
4954    let id = activity.id.as_str();
4955    if activity.status.as_deref() == Some("canceled") {
4956        info!(id, activity_type = %activity.activity_type, "Alpaca activity canceled — left out");
4957        return None;
4958    }
4959    let Some(amount) = activity
4960        .net_amount
4961        .as_deref()
4962        .and_then(|amount| Decimal::from_str(amount).ok())
4963    else {
4964        warn!(id, net_amount = ?activity.net_amount, "Alpaca activity missing/unparseable net_amount — left out");
4965        return None;
4966    };
4967    let Some(time_exchange) = activity.time() else {
4968        warn!(
4969            id,
4970            created_at = ?activity.created_at,
4971            date = ?activity.date,
4972            "Alpaca activity missing/unparseable time — left out"
4973        );
4974        return None;
4975    };
4976    let sub_type = activity
4977        .activity_sub_type
4978        .as_deref()
4979        .filter(|sub_type| !sub_type.is_empty());
4980    let kind = match (activity.activity_type.as_str(), sub_type) {
4981        ("INT", Some("MGN")) => CashFlowKind::MarginInterest {
4982            rate: None,
4983            principal: None,
4984        },
4985        // Credit interest is received; margin interest is paid.
4986        ("INT", None) if amount.is_sign_negative() => CashFlowKind::MarginInterest {
4987            rate: None,
4988            principal: None,
4989        },
4990        ("PTR", _) => CashFlowKind::Rebate,
4991        (activity_type, Some(sub_type)) => {
4992            CashFlowKind::Other(format!("{activity_type}.{sub_type}"))
4993        }
4994        (activity_type, None) => CashFlowKind::Other(activity_type.to_owned()),
4995    };
4996    Some(CashFlow::new(
4997        kind,
4998        AssetNameExchange::new(
4999            activity
5000                .currency
5001                .as_deref()
5002                .filter(|currency| !currency.is_empty())
5003                .unwrap_or("USD"),
5004        ),
5005        amount,
5006        activity
5007            .symbol
5008            .as_deref()
5009            .filter(|symbol| !symbol.is_empty())
5010            .map(InstrumentNameExchange::new),
5011        time_exchange,
5012        Some(CashFlowId::new(id)),
5013    ))
5014}
5015
5016/// One option activity, read for [`convert_option_events`].
5017struct OptionLeg<'a> {
5018    id: &'a str,
5019    symbol: &'a str,
5020    /// Signed: negative is a sell.
5021    quantity: Decimal,
5022    /// The strike, on the share leg only.
5023    price: Option<Decimal>,
5024    date: Option<NaiveDate>,
5025    time: DateTime<Utc>,
5026    /// Whether `time` is Alpaca's `created_at`, not the start of `date`.
5027    created: bool,
5028    /// `None` for the share leg (`OPTRD`), whose origin is its pair's.
5029    origin: Option<TradeOrigin>,
5030    /// On an exercise or assignment, its contract as [`occ_contract`] reads it.
5031    contract: Option<(&'a str, bool, Decimal)>,
5032}
5033
5034impl OptionLeg<'_> {
5035    /// Whether the leg is in `start..=end`: by its creation time, or, when Alpaca gave none, by
5036    /// its `date`, so that a read up to now still returns a leg dated today.
5037    fn is_within(&self, start: DateTime<Utc>, end: DateTime<Utc>) -> bool {
5038        if self.created {
5039            (start..=end).contains(&self.time)
5040        } else {
5041            (start.date_naive()..=end.date_naive()).contains(&self.time.date_naive())
5042        }
5043    }
5044}
5045
5046/// An option activity as an [`OptionLeg`], or `None`, logged, if it is canceled, of another type,
5047/// or lacks what its trade needs. See [`AlpacaClient::fetch_option_events`].
5048fn option_leg(activity: &AlpacaNonTradeActivity) -> Option<OptionLeg<'_>> {
5049    let id = activity.id.as_str();
5050    let activity_type = activity.activity_type.as_str();
5051    if activity.status.as_deref() == Some("canceled") {
5052        info!(
5053            id,
5054            activity_type, "Alpaca option activity canceled — left out"
5055        );
5056        return None;
5057    }
5058    let origin = match activity_type {
5059        "OPEXP" => Some(TradeOrigin::Expiry),
5060        "OPEXC" => Some(TradeOrigin::Exercise),
5061        "OPASN" => Some(TradeOrigin::Assignment),
5062        "OPTRD" => None,
5063        _ => {
5064            warn!(
5065                id,
5066                activity_type, "Alpaca activity is not an option event — left out"
5067            );
5068            return None;
5069        }
5070    };
5071    let date = activity
5072        .date
5073        .as_deref()
5074        .and_then(|date| NaiveDate::parse_from_str(date, "%Y-%m-%d").ok());
5075    let created_at = activity.created_at.as_deref().and_then(parse_timestamp);
5076    let time = created_at.or_else(|| date.map(|date| date.and_time(NaiveTime::MIN).and_utc()));
5077    let symbol = activity
5078        .symbol
5079        .as_deref()
5080        .filter(|symbol| !symbol.is_empty());
5081    let quantity = activity
5082        .qty
5083        .as_deref()
5084        .and_then(|qty| Decimal::from_str(qty).ok())
5085        .filter(|qty| !qty.is_zero());
5086    let price = activity
5087        .price
5088        .as_deref()
5089        .and_then(|price| Decimal::from_str(price).ok());
5090    let (Some(symbol), Some(quantity), Some(time)) = (symbol, quantity, time) else {
5091        warn!(
5092            id,
5093            activity_type,
5094            symbol = ?activity.symbol,
5095            qty = ?activity.qty,
5096            created_at = ?activity.created_at,
5097            date = ?activity.date,
5098            "Alpaca option activity missing/unparseable symbol, quantity or time — left out"
5099        );
5100        return None;
5101    };
5102    if origin.is_none() && price.is_none() {
5103        warn!(id, price = ?activity.price, "Alpaca OPTRD missing/unparseable price — left out");
5104        return None;
5105    }
5106    let contract = matches!(
5107        origin,
5108        Some(TradeOrigin::Exercise | TradeOrigin::Assignment)
5109    )
5110    .then(|| occ_contract(symbol))
5111    .flatten();
5112    Some(OptionLeg {
5113        id,
5114        symbol,
5115        quantity,
5116        price,
5117        date,
5118        time,
5119        created: created_at.is_some(),
5120        origin,
5121        contract,
5122    })
5123}
5124
5125/// The root symbol, whether it is a call, and the strike of a contract, from its OCC symbol: the
5126/// root, then the expiry as `YYMMDD`, `C` or `P`, and the strike in thousandths over eight digits.
5127fn occ_contract(symbol: &str) -> Option<(&str, bool, Decimal)> {
5128    let split = symbol.len().checked_sub(15)?;
5129    let (root, tail) = (
5130        symbol.get(..split)?.trim_end(),
5131        symbol.get(split..)?.as_bytes(),
5132    );
5133    let is_call = match tail[6] {
5134        b'C' => true,
5135        b'P' => false,
5136        _ => return None,
5137    };
5138    if root.is_empty() || !tail[..6].iter().chain(&tail[7..]).all(u8::is_ascii_digit) {
5139        return None;
5140    }
5141    let strike = std::str::from_utf8(&tail[7..]).ok()?.parse::<i64>().ok()?;
5142    Some((root, is_call, Decimal::new(strike, 3)))
5143}
5144
5145/// Whether `contract`, an exercise or assignment, is the one `shares`, an `OPTRD`, is the share
5146/// leg of. See [`AlpacaClient::fetch_option_events`].
5147fn is_share_leg_of(shares: &OptionLeg<'_>, contract: &OptionLeg<'_>) -> bool {
5148    let Some((root, is_call, strike)) = contract.contract else {
5149        return false;
5150    };
5151    // Exercising a call, or being assigned on a put, buys the shares.
5152    let buys = match contract.origin {
5153        Some(TradeOrigin::Exercise) => is_call,
5154        Some(TradeOrigin::Assignment) => !is_call,
5155        _ => return false,
5156    };
5157    shares.date.is_some()
5158        && shares.date == contract.date
5159        && shares.symbol == root
5160        && shares.price == Some(strike)
5161        && shares.quantity.is_sign_positive() == buys
5162}
5163
5164/// The option activities in `start..=end` as [`Trade`]s, each share leg given the origin of the
5165/// exercise or assignment it pairs with, which may lie outside the span. See
5166/// [`AlpacaClient::fetch_option_events`].
5167fn convert_option_events(
5168    activities: &[AlpacaNonTradeActivity],
5169    start: DateTime<Utc>,
5170    end: DateTime<Utc>,
5171) -> Vec<Trade<AssetNameExchange, InstrumentNameExchange>> {
5172    let legs: Vec<_> = activities.iter().filter_map(option_leg).collect();
5173    let mut paired = vec![false; legs.len()];
5174    legs.iter()
5175        .filter_map(|leg| {
5176            // Every share leg is paired, in or out of the span, so that one outside it does not
5177            // take the partner of one inside.
5178            let origin = leg.origin.unwrap_or_else(|| {
5179                let pairs = |index: &usize| !paired[*index] && is_share_leg_of(leg, &legs[*index]);
5180                // The standard contract delivers 100 shares. A match of that size goes first, so
5181                // that two exercises at one strike each pair with their own shares; an adjusted
5182                // contract, delivering another amount, still pairs with the first match.
5183                let delivers_its_shares = |index: &usize| {
5184                    legs[*index].quantity.abs() * Decimal::ONE_HUNDRED == leg.quantity.abs()
5185                };
5186                let partner = (0..legs.len())
5187                    .filter(pairs)
5188                    .find(delivers_its_shares)
5189                    .or_else(|| (0..legs.len()).find(pairs));
5190                match partner {
5191                    Some(index) => {
5192                        paired[index] = true;
5193                        legs[index].origin.unwrap_or(TradeOrigin::External)
5194                    }
5195                    None => {
5196                        warn!(
5197                            id = leg.id,
5198                            symbol = leg.symbol,
5199                            date = ?leg.date,
5200                            "Alpaca OPTRD pairs with no exercise or assignment read — reported as \
5201                             External"
5202                        );
5203                        TradeOrigin::External
5204                    }
5205                }
5206            });
5207            if !leg.is_within(start, end) {
5208                return None;
5209            }
5210            let side = if leg.quantity.is_sign_negative() {
5211                Side::Sell
5212            } else {
5213                Side::Buy
5214            };
5215            let trade = Trade::new(
5216                TradeId::new(leg.id),
5217                OrderId::new(leg.id),
5218                InstrumentNameExchange::new(leg.symbol),
5219                StrategyId::unknown(),
5220                leg.time,
5221                side,
5222                leg.price.unwrap_or(Decimal::ZERO),
5223                leg.quantity.abs(),
5224                None,
5225                AssetFees::new(
5226                    AssetNameExchange::from(USD),
5227                    Decimal::ZERO,
5228                    Some(Decimal::ZERO),
5229                ),
5230            )
5231            .with_origin(origin);
5232            Some(trade)
5233        })
5234        .collect()
5235}
5236
5237/// `trade` with the client order id its WebSocket order update reported, if any.
5238fn with_reported_cid<AssetKey, InstrumentKey>(
5239    trade: Trade<AssetKey, InstrumentKey>,
5240    client_order_id: Option<&SmolStr>,
5241) -> Trade<AssetKey, InstrumentKey> {
5242    match client_order_id {
5243        // A `SmolStr` clone shares a long id rather than copying it.
5244        Some(cid) if !cid.is_empty() => trade.with_client_order_id(ClientOrderId(cid.clone())),
5245        _ => trade,
5246    }
5247}
5248
5249fn parse_timestamp(s: &str) -> Option<DateTime<Utc>> {
5250    DateTime::parse_from_rfc3339(s)
5251        .ok()
5252        .map(|dt| dt.with_timezone(&Utc))
5253}
5254
5255fn map_side(side: Side) -> &'static str {
5256    match side {
5257        Side::Buy => "buy",
5258        Side::Sell => "sell",
5259    }
5260}
5261
5262fn map_order_kind(kind: OrderKind) -> Option<&'static str> {
5263    match kind {
5264        OrderKind::Market => Some("market"),
5265        OrderKind::Limit => Some("limit"),
5266        OrderKind::Stop { .. } => Some("stop"),
5267        OrderKind::StopLimit { .. } => Some("stop_limit"),
5268        OrderKind::TrailingStop {
5269            activation_price: None,
5270            ..
5271        } => Some("trailing_stop"),
5272        // Alpaca has no take-profit, trailing stop-limit or fixed-limit trailing order, and no
5273        // activation price on a trailing stop.
5274        OrderKind::TakeProfit { .. }
5275        | OrderKind::TakeProfitLimit { .. }
5276        | OrderKind::TrailingStop {
5277            activation_price: Some(_),
5278            ..
5279        }
5280        | OrderKind::TrailingStopLimit { .. }
5281        | OrderKind::TrailingStopFixedLimit { .. } => None,
5282    }
5283}
5284
5285/// Map rustrade's `TimeInForce` to Alpaca's string representation.
5286///
5287/// # Errors
5288///
5289/// Returns `Err` if `post_only: true` is requested. Alpaca does not support
5290/// post-only orders — silently placing a taker-eligible GTC order would be
5291/// the opposite of caller intent, risking unexpected taker fees. Callers
5292/// requiring maker-only execution must use a different venue or strategy.
5293fn map_time_in_force(tif: TimeInForce) -> Result<&'static str, &'static str> {
5294    match tif {
5295        TimeInForce::GoodUntilCancelled { post_only } => {
5296            if post_only {
5297                return Err("Alpaca does not support post_only orders");
5298            }
5299            Ok("gtc")
5300        }
5301        TimeInForce::GoodUntilEndOfDay => Ok("day"),
5302        TimeInForce::FillOrKill => Ok("fok"),
5303        TimeInForce::ImmediateOrCancel => Ok("ioc"),
5304        TimeInForce::AtOpen => Ok("opg"),
5305        TimeInForce::AtClose => Ok("cls"),
5306        // Alpaca's `gtd` requires an `expired_at` timestamp on the order request,
5307        // which this client does not currently surface. Reject to avoid silently
5308        // dropping the expiry semantics.
5309        TimeInForce::GoodTillDate { .. } => {
5310            Err("Alpaca GoodTillDate is not yet wired through this client")
5311        }
5312    }
5313}
5314
5315/// Returns `true` if the symbol is an equity or options symbol (i.e., NOT crypto).
5316///
5317/// Crypto symbols on Alpaca always contain a forward slash (e.g., `"BTC/USD"`).
5318/// Equities and OCC option symbols never contain a slash. We use this to decide
5319/// whether to include `position_intent` in the order request: the field is valid
5320/// for equities and required for options, but causes a 422 on crypto orders.
5321///
5322/// NOTE: relies on Alpaca's documented symbol format (as of 2025 API). If Alpaca
5323/// introduces a new asset class whose symbols contain `/`, `position_intent` would
5324/// be silently omitted for those orders, causing 422 errors.
5325fn is_options_or_equity_symbol(symbol: &str) -> bool {
5326    !symbol.contains('/')
5327}
5328
5329/// Derives Alpaca's `position_intent` from the generic `reduce_only` flag and `side`.
5330///
5331/// | reduce_only | side | intent       | use case                          |
5332/// |-------------|------|--------------|-----------------------------------|
5333/// | false       | Buy  | BuyToOpen    | open long / add to long position  |
5334/// | false       | Sell | SellToOpen   | open short / write option         |
5335/// | true        | Buy  | BuyToClose   | close short position              |
5336/// | true        | Sell | SellToClose  | close long position               |
5337fn map_position_intent(side: Side, reduce_only: bool) -> AlpacaPositionIntent {
5338    match (reduce_only, side) {
5339        (false, Side::Buy) => AlpacaPositionIntent::BuyToOpen,
5340        (false, Side::Sell) => AlpacaPositionIntent::SellToOpen,
5341        (true, Side::Buy) => AlpacaPositionIntent::BuyToClose,
5342        (true, Side::Sell) => AlpacaPositionIntent::SellToClose,
5343    }
5344}
5345
5346/// Classifies an HTTP error status + body into a typed [`ApiError`].
5347///
5348/// Used by both `rest_with_retry` (for general REST calls) and `rest_delete_with_retry`
5349/// (for cancel operations) to ensure consistent error classification across all REST paths.
5350///
5351/// `code` is the body's Alpaca error code, where it has one. It is prefixed to the message of a
5352/// refusal, so a caller can see it, except on a 404, whose message says the order was not found.
5353///
5354/// The mapping knows nothing of the request, so a query (account, positions, activities) refused
5355/// on a business rule gets an order's variant too, as any other 4xx on a query already does.
5356fn parse_api_error(
5357    status: reqwest::StatusCode,
5358    code: Option<i64>,
5359    message: &str,
5360) -> crate::error::UnindexedApiError {
5361    // Fast path: 429 doesn't need message parsing. The retry loops read a 429's headers before
5362    // this is reached; this mapping has none to read.
5363    if status == reqwest::StatusCode::TOO_MANY_REQUESTS {
5364        return ApiError::rate_limited(None);
5365    }
5366
5367    // Compute lowercase once for all match guards that inspect the message body.
5368    let lower = message.to_ascii_lowercase();
5369    match status.as_u16() {
5370        // Match "already" before "insufficient": if Alpaca ever sends a 422 body
5371        // containing both substrings, this arm wins and maps to OrderAlreadyCancelled,
5372        // which is more specific than BalanceInsufficient.
5373        422 if lower.contains("already") => ApiError::OrderAlreadyCancelled,
5374        422 => parse_business_refusal(code, message, &lower),
5375        401 => ApiError::Unauthenticated(format!("unauthorized: {message}")),
5376        // A 403 means one of two things, told apart by the code. Without one, it is Alpaca's
5377        // `{"message": "forbidden."}` to a wrong key, secret or host. With one, the account is
5378        // fine and Alpaca refused the request on a business rule: `40310000` is generic
5379        // ("insufficient buying power", "insufficient qty available for order", "account is
5380        // not allowed to short", "account is restricted to liquidation only", ...), and
5381        // `40310100` is pattern-day-trader protection.
5382        403 => match code {
5383            None => ApiError::Unauthenticated(format!("forbidden: {message}")),
5384            Some(_) => parse_business_refusal(code, message, &lower),
5385        },
5386        404 => ApiError::OrderRejected(format!("order not found: {message}")),
5387        _ => ApiError::OrderRejected(with_alpaca_code(code, message)),
5388    }
5389}
5390
5391/// Classifies a refusal on a business rule (a 422, or a 403 with a code) by its text, since
5392/// Alpaca's codes are generic. `lower` is `message` in lowercase. The first match below wins, so
5393/// a refused short is reported as such even if its text also says "insufficient".
5394///
5395/// - "account is not allowed to short" → [`ApiError::BorrowRejected`] with
5396///   [`BorrowRejectReason::BorrowDisabled`].
5397/// - "cannot be sold short" → [`BorrowRejectReason::NotShortable`], except Alpaca's "fractional
5398///   orders cannot be sold short": the asset may be shortable in whole shares, so that is the
5399///   order's shape, an [`ApiError::OrderRejected`].
5400/// - "insufficient" → [`ApiError::BalanceInsufficient`]. The body says what ran short
5401///   ("insufficient buying power", or "insufficient qty available" on a sell) but names no
5402///   asset, so none is guessed.
5403/// - anything else → [`ApiError::OrderRejected`].
5404fn parse_business_refusal(code: Option<i64>, message: &str, lower: &str) -> UnindexedApiError {
5405    let borrow = if lower.contains("not allowed to short") {
5406        Some(BorrowRejectReason::BorrowDisabled)
5407    } else if lower.contains("cannot be sold short") && !lower.contains("fractional") {
5408        Some(BorrowRejectReason::NotShortable)
5409    } else {
5410        None
5411    };
5412    if let Some(reason) = borrow {
5413        ApiError::BorrowRejected(BorrowReject::new(
5414            reason,
5415            code.map(|code| code.to_string()),
5416            message.to_owned(),
5417        ))
5418    } else if lower.contains("insufficient") {
5419        ApiError::BalanceInsufficient(None, with_alpaca_code(code, message))
5420    } else {
5421        ApiError::OrderRejected(with_alpaca_code(code, message))
5422    }
5423}
5424
5425/// `message` prefixed with Alpaca's code where it has one, so a caller can see it.
5426fn with_alpaca_code(code: Option<i64>, message: &str) -> String {
5427    match code {
5428        Some(code) => format!("{code} {message}"),
5429        None => message.to_owned(),
5430    }
5431}
5432
5433/// Wraps [`parse_api_error`] for order-specific error handling (e.g., cancel_order).
5434fn parse_order_error(
5435    status: reqwest::StatusCode,
5436    code: Option<i64>,
5437    message: &str,
5438) -> UnindexedOrderError {
5439    UnindexedOrderError::Rejected(parse_api_error(status, code, message))
5440}
5441
5442fn connectivity_err(msg: impl Into<String>) -> UnindexedClientError {
5443    UnindexedClientError::Connectivity(ConnectivityError::Socket(msg.into()))
5444}
5445
5446/// Map a failed order `POST` through [`rest_with_retry`] to the order's error.
5447///
5448/// [`UnindexedClientError::Internal`] there means Alpaca answered 2xx, so it accepted the order,
5449/// but the response could not be read. The order may be live, so it is reported as
5450/// [`OrderError::Connectivity`], the status-unknown error, never as a rejection. A caller must
5451/// reconcile it (open orders, fills) before resubmitting, or it may place the order twice.
5452fn order_post_error(error: UnindexedClientError) -> UnindexedOrderError {
5453    match error {
5454        UnindexedClientError::Connectivity(ce) => OrderError::Connectivity(ce),
5455        UnindexedClientError::Api(ae) => OrderError::Rejected(ae),
5456        UnindexedClientError::Internal(msg) => OrderError::Connectivity(ConnectivityError::Socket(
5457            format!("Alpaca order status unknown, its 2xx response could not be read: {msg}"),
5458        )),
5459        // `rest_with_retry` returns none of these today. Matched explicitly so a new
5460        // `ClientError` variant is a compile error here, and reported as status unknown rather
5461        // than panicking an order path if that ever changes.
5462        other @ (UnindexedClientError::TaskFailed(_)
5463        | UnindexedClientError::Truncated { .. }
5464        | UnindexedClientError::TruncatedSnapshot { .. }) => OrderError::Connectivity(
5465            ConnectivityError::Socket(format!("Alpaca order status unknown: {other}")),
5466        ),
5467    }
5468}
5469
5470// ---------------------------------------------------------------------------
5471// OrderStatusClient implementation
5472// ---------------------------------------------------------------------------
5473
5474impl OrderStatusClient for AlpacaClient {
5475    /// Looks each order up with `GET /v2/orders:by_client_order_id`, up to 8 at a time.
5476    ///
5477    /// `filled` is reported as fully filled with `filled_avg_price`, `canceled` and `replaced` as
5478    /// cancelled (the order replacing one has its own client order id, which the caller must
5479    /// learn from [`ExecutionClient::fetch_open_orders`]), `expired` as expired and `rejected` as
5480    /// open failed. `done_for_day` is not ended: Alpaca works the order again the next trading day.
5481    /// A 404, or an order on another symbol than the key's (ignoring case), is unknown.
5482    async fn fetch_ended_orders(
5483        &self,
5484        orders: &[UnindexedOrderKey],
5485    ) -> Result<Vec<UnindexedInactiveOrder>, UnindexedClientError> {
5486        fetch_ended_by_key(orders, |key| {
5487            fetch_order_lookup(
5488                self.http.clone(),
5489                self.rate_limiter.clone(),
5490                self.config.clone(),
5491                key,
5492            )
5493        })
5494        .await
5495    }
5496}
5497
5498// ---------------------------------------------------------------------------
5499// ShortabilityClient implementation
5500// ---------------------------------------------------------------------------
5501
5502impl ShortabilityClient for AlpacaClient {
5503    /// Reads the asset with `GET /v2/assets/{symbol}`, one request.
5504    ///
5505    /// - `shortable` is the asset's `shortable`.
5506    /// - `easy_to_borrow` is its `borrow_status`, which Alpaca reports for US equities only, or
5507    ///   failing that its `easy_to_borrow` flag; neither present leaves it unknown.
5508    /// - `fee_rate` is zero for an easy-to-borrow stock, as Alpaca charges no borrow fee on one.
5509    ///   That is Alpaca's charge, not the market's cost of borrowing. Alpaca publishes no rate for
5510    ///   a hard-to-borrow stock, so its fee is unknown.
5511    /// - `available` is unknown. Alpaca's locate quotes report what it can lend of a hard-to-borrow
5512    ///   stock, but they are not on paper accounts and are not read here.
5513    /// - `delisting_at` is unknown: Alpaca's asset carries no delisting date.
5514    ///
5515    /// Alpaca updates its borrow flags from a daily list, so they may be up to a day old.
5516    ///
5517    /// # Errors
5518    ///
5519    /// [`ApiError::InstrumentInvalid`] for a symbol Alpaca knows no asset under, and for an
5520    /// option, as writing one borrows nothing.
5521    async fn fetch_shortability(
5522        &self,
5523        instrument: &InstrumentNameExchange,
5524    ) -> Result<Shortability, UnindexedClientError> {
5525        let url = asset_url(self.base_url(), instrument)?;
5526        let found: Option<AlpacaAsset> =
5527            rest_lookup_with_retry(&self.rate_limiter, || self.http.get(url.clone())).await?;
5528        let Some(asset) = found else {
5529            return Err(instrument_invalid(
5530                instrument,
5531                "Alpaca knows no asset under this symbol",
5532            ));
5533        };
5534        shortability_from_asset(instrument, &asset)
5535    }
5536}
5537
5538/// `GET /v2/assets/{symbol}`, with the symbol as one path segment, so a crypto pair's `/` is
5539/// escaped rather than read as a path separator.
5540fn asset_url(
5541    base_url: &str,
5542    instrument: &InstrumentNameExchange,
5543) -> Result<reqwest::Url, UnindexedClientError> {
5544    let mut url = reqwest::Url::parse(base_url).map_err(|error| {
5545        UnindexedClientError::Internal(format!("Alpaca base URL {base_url:?}: {error}"))
5546    })?;
5547    url.path_segments_mut()
5548        .map_err(|()| {
5549            UnindexedClientError::Internal(format!("Alpaca base URL {base_url:?} has no path"))
5550        })?
5551        .pop_if_empty()
5552        .extend(["v2", "assets", instrument.name().as_str()]);
5553    Ok(url)
5554}
5555
5556fn instrument_invalid(instrument: &InstrumentNameExchange, reason: &str) -> UnindexedClientError {
5557    UnindexedClientError::Api(ApiError::InstrumentInvalid(
5558        instrument.clone(),
5559        reason.to_owned(),
5560    ))
5561}
5562
5563/// What [`AlpacaClient::fetch_shortability`] reports of `asset`.
5564fn shortability_from_asset(
5565    instrument: &InstrumentNameExchange,
5566    asset: &AlpacaAsset,
5567) -> Result<Shortability, UnindexedClientError> {
5568    if asset.class == AlpacaAssetClass::UsOption {
5569        return Err(instrument_invalid(
5570            instrument,
5571            "shortability does not apply to an option: writing one borrows nothing",
5572        ));
5573    }
5574    let easy_to_borrow = match asset.borrow_status {
5575        Some(AlpacaBorrowStatus::EasyToBorrow) => Some(true),
5576        Some(AlpacaBorrowStatus::HardToBorrow) => Some(false),
5577        Some(AlpacaBorrowStatus::Other) | None => asset.easy_to_borrow,
5578    };
5579    let mut shortability = Shortability::new();
5580    if let Some(shortable) = asset.shortable {
5581        shortability = shortability.with_shortable(shortable);
5582    }
5583    if let Some(easy_to_borrow) = easy_to_borrow {
5584        shortability = shortability.with_easy_to_borrow(easy_to_borrow);
5585        if easy_to_borrow {
5586            shortability = shortability.with_fee_rate(Decimal::ZERO);
5587        }
5588    }
5589    Ok(shortability)
5590}
5591
5592/// The fields of `GET /v2/assets/{symbol}` a [`Shortability`] is built from. Each is optional, so
5593/// one Alpaca leaves out reads as unknown rather than failing the request.
5594#[derive(Debug, Deserialize)]
5595struct AlpacaAsset {
5596    #[serde(default)]
5597    class: AlpacaAssetClass,
5598    #[serde(default)]
5599    shortable: Option<bool>,
5600    /// US equities only.
5601    #[serde(default)]
5602    borrow_status: Option<AlpacaBorrowStatus>,
5603    /// In Alpaca's examples but not its schema, so read only when `borrow_status` is absent.
5604    #[serde(default)]
5605    easy_to_borrow: Option<bool>,
5606}
5607
5608#[derive(Debug, Default, PartialEq, Eq, Deserialize)]
5609#[serde(rename_all = "snake_case")]
5610enum AlpacaAssetClass {
5611    UsOption,
5612    #[default]
5613    #[serde(other)]
5614    Other,
5615}
5616
5617#[derive(Debug, PartialEq, Eq, Deserialize)]
5618#[serde(rename_all = "snake_case")]
5619enum AlpacaBorrowStatus {
5620    EasyToBorrow,
5621    HardToBorrow,
5622    #[serde(other)]
5623    Other,
5624}
5625
5626// ---------------------------------------------------------------------------
5627// BracketOrderClient implementation
5628// ---------------------------------------------------------------------------
5629
5630impl BracketOrderClient for AlpacaClient {
5631    async fn open_bracket_order(
5632        &self,
5633        request: UnifiedBracketOrderRequest<ExchangeId, &InstrumentNameExchange>,
5634    ) -> UnifiedBracketOrderResult {
5635        let alpaca_request = AlpacaBracketOrderRequest {
5636            instrument: request.key.instrument.clone(),
5637            strategy: request.key.strategy.clone(),
5638            cid: request.key.cid.clone(),
5639            side: request.state.side,
5640            quantity: request.state.quantity,
5641            entry_price: request.state.entry_price,
5642            take_profit_price: request.state.take_profit_price,
5643            stop_loss_price: request.state.stop_loss_price,
5644            stop_loss_limit_price: request.state.stop_loss_limit_price,
5645            time_in_force: request.state.time_in_force,
5646        };
5647
5648        let result = self.open_bracket_order(alpaca_request).await;
5649
5650        UnifiedBracketOrderResult::parent_only(result.parent)
5651    }
5652}
5653
5654// ---------------------------------------------------------------------------
5655// Tests
5656// ---------------------------------------------------------------------------
5657
5658#[cfg(test)]
5659#[allow(clippy::unwrap_used, clippy::expect_used)] // Test code: panics on bad input are acceptable
5660mod tests {
5661    use super::*;
5662    use rust_decimal_macros::dec;
5663
5664    #[test]
5665    fn test_alpaca_config_new_uses_paper_trading_by_default() {
5666        let cfg = AlpacaConfig::new("my_key".into(), "my_secret".into());
5667        assert!(cfg.paper);
5668    }
5669
5670    #[test]
5671    fn test_alpaca_config_debug_redacts_credentials() {
5672        let cfg = AlpacaConfig::new("my_key".into(), "my_secret".into());
5673        let debug = format!("{cfg:?}");
5674        assert!(!debug.contains("my_key"), "api_key should be redacted");
5675        assert!(
5676            !debug.contains("my_secret"),
5677            "secret_key should be redacted"
5678        );
5679        assert!(debug.contains("paper: true"));
5680    }
5681
5682    #[test]
5683    fn test_alpaca_config_deserialize_omitted_paper_defaults_to_paper() {
5684        let cfg: AlpacaConfig = serde_json::from_str(
5685            r#"{
5686        "api_key": "my_key",
5687        "secret_key": "my_secret"
5688    }"#,
5689        )
5690        .unwrap();
5691
5692        assert!(cfg.paper);
5693        assert_eq!(cfg.rest_base_url(), "https://paper-api.alpaca.markets");
5694    }
5695
5696    #[test]
5697    fn test_alpaca_config_deserialize_paper_true() {
5698        let cfg: AlpacaConfig = serde_json::from_str(
5699            r#"{
5700        "api_key": "my_key",
5701        "secret_key": "my_secret",
5702        "paper": true
5703    }"#,
5704        )
5705        .unwrap();
5706
5707        assert!(cfg.paper);
5708        assert_eq!(cfg.rest_base_url(), "https://paper-api.alpaca.markets");
5709    }
5710
5711    #[test]
5712    fn test_alpaca_config_deserialize_paper_false() {
5713        let cfg: AlpacaConfig = serde_json::from_str(
5714            r#"{
5715        "api_key": "my_key",
5716        "secret_key": "my_secret",
5717        "paper": false
5718    }"#,
5719        )
5720        .unwrap();
5721
5722        assert!(!cfg.paper);
5723        assert_eq!(cfg.rest_base_url(), "https://api.alpaca.markets");
5724    }
5725
5726    #[test]
5727    fn test_alpaca_config_urls() {
5728        let paper = AlpacaConfig::paper("k".into(), "s".into());
5729        assert!(paper.rest_base_url().contains("paper-api"));
5730        assert!(paper.ws_url().contains("paper-api"));
5731
5732        let live = AlpacaConfig::production("k".into(), "s".into());
5733        assert!(!live.rest_base_url().contains("paper-api"));
5734        assert!(!live.ws_url().contains("paper-api"));
5735    }
5736
5737    #[test]
5738    #[serial_test::serial]
5739    fn test_alpaca_config_from_env_defaults_to_paper_trading() {
5740        temp_env::with_vars(
5741            [
5742                ("ALPACA_API_KEY", Some("my_key")),
5743                ("ALPACA_SECRET_KEY", Some("my_secret")),
5744                ("ALPACA_PAPER", None),
5745            ],
5746            || {
5747                let cfg = AlpacaConfig::from_env().unwrap();
5748                assert!(cfg.paper);
5749                assert_eq!(cfg.rest_base_url(), "https://paper-api.alpaca.markets");
5750            },
5751        );
5752    }
5753
5754    #[test]
5755    #[serial_test::serial]
5756    fn test_alpaca_config_from_env_accepts_explicit_production() {
5757        temp_env::with_vars(
5758            [
5759                ("ALPACA_API_KEY", Some("my_key")),
5760                ("ALPACA_SECRET_KEY", Some("my_secret")),
5761                ("ALPACA_PAPER", Some("false")),
5762            ],
5763            || {
5764                let cfg = AlpacaConfig::from_env().unwrap();
5765                assert!(!cfg.paper);
5766                assert_eq!(cfg.rest_base_url(), "https://api.alpaca.markets");
5767            },
5768        );
5769    }
5770
5771    #[test]
5772    #[serial_test::serial]
5773    fn test_alpaca_config_from_env_rejects_invalid_paper() {
5774        temp_env::with_vars(
5775            [
5776                ("ALPACA_API_KEY", Some("my_key")),
5777                ("ALPACA_SECRET_KEY", Some("my_secret")),
5778                ("ALPACA_PAPER", Some("maybe")),
5779            ],
5780            || {
5781                let err = AlpacaConfig::from_env().unwrap_err();
5782                assert!(matches!(err, AlpacaConfigError::InvalidPaper(value) if value == "maybe"));
5783            },
5784        );
5785    }
5786
5787    #[test]
5788    #[serial_test::serial]
5789    fn test_alpaca_config_from_env_requires_credentials() {
5790        temp_env::with_vars(
5791            [
5792                ("ALPACA_API_KEY", None),
5793                ("ALPACA_SECRET_KEY", Some("my_secret")),
5794                ("ALPACA_PAPER", None),
5795            ],
5796            || {
5797                let err = AlpacaConfig::from_env().unwrap_err();
5798                assert!(matches!(err, AlpacaConfigError::MissingApiKey));
5799            },
5800        );
5801    }
5802
5803    #[test]
5804    fn test_parse_side() {
5805        assert_eq!(parse_side("buy"), Some(Side::Buy));
5806        assert_eq!(parse_side("sell"), Some(Side::Sell));
5807        assert_eq!(parse_side("Buy"), Some(Side::Buy));
5808        assert_eq!(parse_side("BUY"), Some(Side::Buy));
5809        assert_eq!(parse_side("unknown"), None);
5810    }
5811
5812    #[test]
5813    fn test_parse_order_kind() {
5814        // Market and Limit don't need extra params
5815        assert_eq!(
5816            parse_order_kind("market", None, None, None),
5817            Some(OrderKind::Market)
5818        );
5819        assert_eq!(
5820            parse_order_kind("limit", None, None, None),
5821            Some(OrderKind::Limit)
5822        );
5823
5824        // Stop requires stop_price
5825        assert_eq!(
5826            parse_order_kind("stop", Some("150.00"), None, None),
5827            Some(OrderKind::Stop {
5828                trigger_price: Decimal::from_str("150.00").unwrap()
5829            })
5830        );
5831        assert_eq!(parse_order_kind("stop", None, None, None), None);
5832
5833        // StopLimit requires stop_price
5834        assert_eq!(
5835            parse_order_kind("stop_limit", Some("145.00"), None, None),
5836            Some(OrderKind::StopLimit {
5837                trigger_price: Decimal::from_str("145.00").unwrap()
5838            })
5839        );
5840
5841        // TrailingStop with percentage
5842        assert_eq!(
5843            parse_order_kind("trailing_stop", None, Some("5.0"), None),
5844            Some(OrderKind::TrailingStop {
5845                offset: Decimal::from_str("5.0").unwrap(),
5846                offset_type: TrailingOffsetType::Percentage,
5847                activation_price: None,
5848            })
5849        );
5850
5851        // TrailingStop with absolute price
5852        assert_eq!(
5853            parse_order_kind("trailing_stop", None, None, Some("2.50")),
5854            Some(OrderKind::TrailingStop {
5855                offset: Decimal::from_str("2.50").unwrap(),
5856                offset_type: TrailingOffsetType::Absolute,
5857                activation_price: None,
5858            })
5859        );
5860
5861        // TrailingStop without either offset returns None
5862        assert_eq!(parse_order_kind("trailing_stop", None, None, None), None);
5863
5864        // Unknown type returns None
5865        assert_eq!(parse_order_kind("unknown", None, None, None), None);
5866    }
5867
5868    #[test]
5869    fn test_map_order_kind() {
5870        assert_eq!(map_order_kind(OrderKind::Market), Some("market"));
5871        assert_eq!(map_order_kind(OrderKind::Limit), Some("limit"));
5872        assert_eq!(
5873            map_order_kind(OrderKind::Stop {
5874                trigger_price: Decimal::from_str("150.00").unwrap()
5875            }),
5876            Some("stop")
5877        );
5878        assert_eq!(
5879            map_order_kind(OrderKind::StopLimit {
5880                trigger_price: Decimal::from_str("145.00").unwrap()
5881            }),
5882            Some("stop_limit")
5883        );
5884        assert_eq!(
5885            map_order_kind(OrderKind::TrailingStop {
5886                offset: Decimal::from_str("5.0").unwrap(),
5887                offset_type: TrailingOffsetType::Percentage,
5888                activation_price: None,
5889            }),
5890            Some("trailing_stop")
5891        );
5892        assert_eq!(
5893            map_order_kind(OrderKind::TrailingStop {
5894                offset: Decimal::from_str("2.50").unwrap(),
5895                offset_type: TrailingOffsetType::Absolute,
5896                activation_price: None,
5897            }),
5898            Some("trailing_stop")
5899        );
5900        // Alpaca has no activation price, and no fixed-limit trailing stop
5901        assert_eq!(
5902            map_order_kind(OrderKind::TrailingStop {
5903                offset: Decimal::from_str("5.0").unwrap(),
5904                offset_type: TrailingOffsetType::Percentage,
5905                activation_price: Some(Decimal::from(100)),
5906            }),
5907            None
5908        );
5909        assert_eq!(
5910            map_order_kind(OrderKind::TrailingStopFixedLimit {
5911                offset: Decimal::from_str("5.0").unwrap(),
5912                offset_type: TrailingOffsetType::Percentage,
5913                activation_price: None,
5914            }),
5915            None
5916        );
5917        // TrailingStopLimit is not supported by Alpaca
5918        assert_eq!(
5919            map_order_kind(OrderKind::TrailingStopLimit {
5920                offset: Decimal::from_str("5.0").unwrap(),
5921                offset_type: TrailingOffsetType::Percentage,
5922                stop_price: Decimal::from_str("95.0").unwrap(),
5923                limit_offset: Decimal::from_str("1.0").unwrap(),
5924            }),
5925            None
5926        );
5927        // TakeProfit/TakeProfitLimit are not supported by Alpaca
5928        assert_eq!(
5929            map_order_kind(OrderKind::TakeProfit {
5930                trigger_price: Decimal::from_str("160.00").unwrap()
5931            }),
5932            None
5933        );
5934        assert_eq!(
5935            map_order_kind(OrderKind::TakeProfitLimit {
5936                trigger_price: Decimal::from_str("160.00").unwrap()
5937            }),
5938            None
5939        );
5940    }
5941
5942    #[test]
5943    fn test_map_time_in_force_roundtrip() {
5944        assert_eq!(
5945            map_time_in_force(TimeInForce::GoodUntilCancelled { post_only: false }),
5946            Ok("gtc")
5947        );
5948        assert_eq!(map_time_in_force(TimeInForce::GoodUntilEndOfDay), Ok("day"));
5949        assert_eq!(map_time_in_force(TimeInForce::FillOrKill), Ok("fok"));
5950        assert_eq!(map_time_in_force(TimeInForce::ImmediateOrCancel), Ok("ioc"));
5951    }
5952
5953    #[test]
5954    fn test_map_time_in_force_rejects_post_only() {
5955        let result = map_time_in_force(TimeInForce::GoodUntilCancelled { post_only: true });
5956        assert!(result.is_err(), "post_only must be rejected");
5957        assert!(result.unwrap_err().contains("post_only"));
5958    }
5959
5960    // =========================================================================
5961    // Bracket Order Serialization Tests
5962    // =========================================================================
5963
5964    #[test]
5965    fn test_bracket_order_serializes_with_stop_loss_stop_order() {
5966        // Bracket order with stop-loss as a simple stop order (no limit_price)
5967        let body = AlpacaOrderRequest {
5968            symbol: "AAPL",
5969            qty: "10".to_string(),
5970            side: "buy",
5971            order_type: "limit",
5972            time_in_force: "gtc",
5973            limit_price: Some("150.00".to_string()),
5974            stop_price: None,
5975            trail_percent: None,
5976            trail_price: None,
5977            client_order_id: Some("bracket-001"),
5978            position_intent: Some(AlpacaPositionIntent::BuyToOpen),
5979            order_class: Some("bracket"),
5980            take_profit: Some(TakeProfitParams {
5981                limit_price: "160.00".to_string(),
5982            }),
5983            stop_loss: Some(StopLossParams {
5984                stop_price: "145.00".to_string(),
5985                limit_price: None,
5986            }),
5987        };
5988
5989        let json = serde_json::to_value(&body).unwrap();
5990
5991        assert_eq!(json["symbol"], "AAPL");
5992        assert_eq!(json["qty"], "10");
5993        assert_eq!(json["side"], "buy");
5994        assert_eq!(json["type"], "limit");
5995        assert_eq!(json["time_in_force"], "gtc");
5996        assert_eq!(json["limit_price"], "150.00");
5997        assert_eq!(json["order_class"], "bracket");
5998
5999        // Take profit should have limit_price
6000        assert_eq!(json["take_profit"]["limit_price"], "160.00");
6001
6002        // Stop loss should have stop_price but NO limit_price
6003        assert_eq!(json["stop_loss"]["stop_price"], "145.00");
6004        assert!(
6005            json["stop_loss"].get("limit_price").is_none(),
6006            "stop_loss.limit_price should be omitted when None"
6007        );
6008    }
6009
6010    #[test]
6011    fn test_bracket_order_serializes_with_stop_loss_stop_limit_order() {
6012        // Bracket order with stop-loss as a stop-limit order (has limit_price)
6013        let body = AlpacaOrderRequest {
6014            symbol: "SPY",
6015            qty: "5".to_string(),
6016            side: "sell",
6017            order_type: "limit",
6018            time_in_force: "day",
6019            limit_price: Some("450.00".to_string()),
6020            stop_price: None,
6021            trail_percent: None,
6022            trail_price: None,
6023            client_order_id: Some("bracket-002"),
6024            position_intent: Some(AlpacaPositionIntent::SellToClose),
6025            order_class: Some("bracket"),
6026            take_profit: Some(TakeProfitParams {
6027                limit_price: "440.00".to_string(),
6028            }),
6029            stop_loss: Some(StopLossParams {
6030                stop_price: "455.00".to_string(),
6031                limit_price: Some("456.00".to_string()),
6032            }),
6033        };
6034
6035        let json = serde_json::to_value(&body).unwrap();
6036
6037        assert_eq!(json["symbol"], "SPY");
6038        assert_eq!(json["side"], "sell");
6039        assert_eq!(json["time_in_force"], "day");
6040        assert_eq!(json["order_class"], "bracket");
6041
6042        // Take profit
6043        assert_eq!(json["take_profit"]["limit_price"], "440.00");
6044
6045        // Stop loss with limit_price (stop-limit order)
6046        assert_eq!(json["stop_loss"]["stop_price"], "455.00");
6047        assert_eq!(
6048            json["stop_loss"]["limit_price"], "456.00",
6049            "stop_loss.limit_price should be present for stop-limit orders"
6050        );
6051    }
6052
6053    // =========================================================================
6054    // Bracket Order Validation Tests
6055    // =========================================================================
6056
6057    #[tokio::test]
6058    async fn test_open_bracket_order_rejects_invalid_tif() {
6059        use rust_decimal_macros::dec;
6060        use rustrade_instrument::instrument::name::InstrumentNameExchange;
6061
6062        // Create a minimal client with dummy credentials (no network call will be made)
6063        let config = AlpacaConfig::new("dummy_key".into(), "dummy_secret".into());
6064        let client = AlpacaClient::new(config);
6065
6066        let request = AlpacaBracketOrderRequest::new(
6067            InstrumentNameExchange::new("SPY"),
6068            crate::order::id::StrategyId::new("test"),
6069            crate::order::id::ClientOrderId::new("test-tif"),
6070            Side::Buy,
6071            dec!(1),
6072            dec!(100.00),
6073            dec!(120.00),
6074            dec!(90.00),
6075            TimeInForce::ImmediateOrCancel, // Invalid for brackets
6076        );
6077
6078        let result = client.open_bracket_order(request).await;
6079
6080        assert!(
6081            result.parent.state.is_failed(),
6082            "Bracket order with IOC TIF should be rejected locally"
6083        );
6084    }
6085
6086    #[tokio::test]
6087    async fn test_open_bracket_order_rejects_invalid_price_ordering() {
6088        use rust_decimal_macros::dec;
6089        use rustrade_instrument::instrument::name::InstrumentNameExchange;
6090
6091        let config = AlpacaConfig::new("dummy_key".into(), "dummy_secret".into());
6092        let client = AlpacaClient::new(config);
6093
6094        // Buy bracket with SL > entry (invalid)
6095        let request = AlpacaBracketOrderRequest::new(
6096            InstrumentNameExchange::new("SPY"),
6097            crate::order::id::StrategyId::new("test"),
6098            crate::order::id::ClientOrderId::new("test-price"),
6099            Side::Buy,
6100            dec!(1),
6101            dec!(100.00),
6102            dec!(120.00),
6103            dec!(105.00), // Invalid: SL > entry for buy
6104            TimeInForce::GoodUntilCancelled { post_only: false },
6105        );
6106
6107        let result = client.open_bracket_order(request).await;
6108
6109        assert!(
6110            result.parent.state.is_failed(),
6111            "Bracket order with invalid price ordering should be rejected locally"
6112        );
6113    }
6114
6115    #[tokio::test]
6116    async fn test_open_bracket_order_rejects_invalid_sl_limit_price() {
6117        use rust_decimal_macros::dec;
6118        use rustrade_instrument::instrument::name::InstrumentNameExchange;
6119
6120        let config = AlpacaConfig::new("dummy_key".into(), "dummy_secret".into());
6121        let client = AlpacaClient::new(config);
6122
6123        // Buy bracket with SL limit > SL trigger (invalid for sell stop-limit)
6124        let request = AlpacaBracketOrderRequest::new(
6125            InstrumentNameExchange::new("SPY"),
6126            crate::order::id::StrategyId::new("test"),
6127            crate::order::id::ClientOrderId::new("test-sl-limit"),
6128            Side::Buy,
6129            dec!(1),
6130            dec!(100.00),
6131            dec!(120.00),
6132            dec!(90.00),
6133            TimeInForce::GoodUntilCancelled { post_only: false },
6134        )
6135        .with_stop_loss_limit_price(dec!(95.00)); // Invalid: limit > trigger for sell SL
6136
6137        let result = client.open_bracket_order(request).await;
6138
6139        assert!(
6140            result.parent.state.is_failed(),
6141            "Bracket order with invalid SL limit price should be rejected locally"
6142        );
6143    }
6144
6145    #[test]
6146    fn test_non_bracket_order_omits_bracket_fields() {
6147        // Regular limit order should not have bracket fields
6148        let body = AlpacaOrderRequest {
6149            symbol: "AAPL",
6150            qty: "1".to_string(),
6151            side: "buy",
6152            order_type: "limit",
6153            time_in_force: "gtc",
6154            limit_price: Some("150.00".to_string()),
6155            stop_price: None,
6156            trail_percent: None,
6157            trail_price: None,
6158            client_order_id: Some("regular-001"),
6159            position_intent: Some(AlpacaPositionIntent::BuyToOpen),
6160            order_class: None,
6161            take_profit: None,
6162            stop_loss: None,
6163        };
6164
6165        let json = serde_json::to_value(&body).unwrap();
6166
6167        assert_eq!(json["symbol"], "AAPL");
6168        assert!(
6169            json.get("order_class").is_none(),
6170            "order_class should be omitted for non-bracket orders"
6171        );
6172        assert!(
6173            json.get("take_profit").is_none(),
6174            "take_profit should be omitted for non-bracket orders"
6175        );
6176        assert!(
6177            json.get("stop_loss").is_none(),
6178            "stop_loss should be omitted for non-bracket orders"
6179        );
6180    }
6181
6182    #[test]
6183    fn test_parse_timestamp_valid() {
6184        let ts = parse_timestamp("2025-04-18T14:30:00Z");
6185        assert!(ts.is_some());
6186        let ts2 = parse_timestamp("2025-04-18T14:30:00.123456Z");
6187        assert!(ts2.is_some());
6188        assert_eq!(parse_timestamp("not-a-timestamp"), None);
6189    }
6190
6191    #[test]
6192    fn test_check_auth_response_authorized() {
6193        let msg =
6194            r#"{"stream":"authorization","data":{"status":"authorized","action":"authenticate"}}"#;
6195        assert!(matches!(check_auth_response(msg), Some(Ok(()))));
6196    }
6197
6198    #[test]
6199    fn test_check_auth_response_unauthorized() {
6200        let msg = r#"{"stream":"authorization","data":{"status":"unauthorized"}}"#;
6201        assert!(matches!(
6202            check_auth_response(msg),
6203            Some(Err(HandshakeError::Auth(_)))
6204        ));
6205    }
6206
6207    #[test]
6208    fn test_check_auth_response_non_auth_message() {
6209        let msg = r#"{"stream":"trade_updates","data":{}}"#;
6210        assert!(check_auth_response(msg).is_none());
6211    }
6212
6213    #[test]
6214    fn test_check_listen_ack() {
6215        let ack = r#"{"stream":"listening","data":{"streams":["trade_updates"]}}"#;
6216        assert!(check_listen_ack(ack));
6217
6218        let other = r#"{"stream":"authorization","data":{}}"#;
6219        assert!(!check_listen_ack(other));
6220    }
6221
6222    #[test]
6223    fn test_dedup_cache() {
6224        let cache = new_dedup_cache();
6225        let key = || SmolStr::new("order-1:1");
6226        assert!(
6227            !is_duplicate_fill(&cache, Some("exec-1"), key()),
6228            "first time should not be duplicate"
6229        );
6230        assert!(
6231            is_duplicate_fill(&cache, Some("exec-1"), key()),
6232            "second time should be duplicate"
6233        );
6234    }
6235
6236    /// A fill is told apart by its execution id, so after a bust lowers an order's cumulative, a
6237    /// new fill that takes it back to a cumulative an earlier fill reached is not dropped.
6238    #[test]
6239    fn dedup_tells_fills_apart_by_execution_id_not_cumulative() {
6240        let cache = new_dedup_cache();
6241        assert!(!is_duplicate_fill(
6242            &cache,
6243            Some("exec-a"),
6244            SmolStr::new("ord-1:5")
6245        ));
6246        // A bust took the order back to 3, and a new fill takes it to 5 again.
6247        assert!(
6248            !is_duplicate_fill(&cache, Some("exec-b"), SmolStr::new("ord-1:5")),
6249            "a different execution at a cumulative seen before is a new fill"
6250        );
6251    }
6252
6253    /// A fill one path delivers without an execution id still matches the same fill the other
6254    /// path delivers with one, whichever comes first.
6255    #[test]
6256    fn dedup_matches_a_fill_with_an_execution_id_to_the_same_fill_without_one() {
6257        let cache = new_dedup_cache();
6258
6259        assert!(!is_duplicate_fill(&cache, None, SmolStr::new("ord-1:2")));
6260        assert!(
6261            is_duplicate_fill(&cache, Some("exec-a"), SmolStr::new("ord-1:2")),
6262            "without an id first, then with one"
6263        );
6264
6265        assert!(!is_duplicate_fill(
6266            &cache,
6267            Some("exec-b"),
6268            SmolStr::new("ord-1:4")
6269        ));
6270        assert!(
6271            is_duplicate_fill(&cache, None, SmolStr::new("ord-1:4")),
6272            "with an id first, then without one"
6273        );
6274    }
6275
6276    #[tokio::test]
6277    async fn test_exponential_backoff_progression_and_exhaustion() {
6278        tokio::time::pause();
6279
6280        let mut b = ExponentialBackoff::new();
6281
6282        // First wait should succeed and increment attempt.
6283        assert!(b.wait().await, "first wait should return true");
6284        assert_eq!(b.attempt, 1);
6285
6286        // Drain remaining attempts.
6287        while b.wait().await {}
6288
6289        // Attempt counter saturates at max_attempts.
6290        assert_eq!(b.attempt, MAX_RECONNECT_ATTEMPTS);
6291
6292        // Once exhausted, wait returns false immediately without sleeping.
6293        assert!(!b.wait().await, "exhausted backoff should return false");
6294
6295        // reset() restores attempt to 0.
6296        b.reset();
6297        assert_eq!(b.attempt, 0);
6298
6299        // After reset, wait works again.
6300        assert!(b.wait().await, "wait should succeed after reset");
6301        assert_eq!(b.attempt, 1);
6302    }
6303
6304    #[test]
6305    fn test_convert_account_to_balances_empty_assets() {
6306        // equity and buying_power are larger than cash on a margin account holding positions;
6307        // neither may leak into the balance.
6308        let account: AlpacaAccount = serde_json::from_value(serde_json::json!({
6309            "cash": "9000.50",
6310            "equity": "12000.00",
6311            "buying_power": "36000.00",
6312            "regt_buying_power": "18000.00",
6313            "options_buying_power": "8000.00",
6314            "non_marginable_buying_power": "8500.25",
6315        }))
6316        .unwrap();
6317        let balances = convert_account_to_balances(&account, &[]);
6318        assert_eq!(balances.len(), 1);
6319        assert_eq!(balances[0].asset, AssetNameExchange::new("USD"));
6320        assert_eq!(balances[0].balance.total, dec!(9000.50), "total is cash");
6321        assert_eq!(
6322            balances[0].balance.free,
6323            dec!(8500.25),
6324            "free is non-marginable buying power when below cash"
6325        );
6326        assert_eq!(balances[0].balance.margin, None);
6327    }
6328
6329    /// Long marginable stock: non-marginable buying power is equity less initial margin, which
6330    /// exceeds cash, so `free` is capped at cash.
6331    #[test]
6332    fn test_convert_account_to_balances_free_capped_at_cash_with_long_stock() {
6333        let account: AlpacaAccount = serde_json::from_value(serde_json::json!({
6334            "cash": "50000",
6335            "equity": "100000",
6336            "initial_margin": "25000",
6337            "non_marginable_buying_power": "75000",
6338        }))
6339        .unwrap();
6340        let balances = convert_account_to_balances(&account, &[]);
6341        assert_eq!(balances[0].balance.total, dec!(50000));
6342        assert_eq!(balances[0].balance.free, dec!(50000));
6343    }
6344
6345    /// A short sale credits its proceeds to cash but lowers non-marginable buying power, so
6346    /// `free` is the buying power, below `total`.
6347    #[test]
6348    fn test_convert_account_to_balances_open_short_keeps_free_below_cash() {
6349        let account: AlpacaAccount = serde_json::from_value(serde_json::json!({
6350            "cash": "10200",
6351            "equity": "10000",
6352            "initial_margin": "100",
6353            "non_marginable_buying_power": "9900",
6354        }))
6355        .unwrap();
6356        let balances = convert_account_to_balances(&account, &[]);
6357        assert_eq!(balances[0].balance.total, dec!(10200));
6358        assert_eq!(balances[0].balance.free, dec!(9900));
6359    }
6360
6361    #[test]
6362    fn test_convert_account_to_balances_negative_cash_on_margin() {
6363        let account: AlpacaAccount = serde_json::from_value(serde_json::json!({
6364            "cash": "-2500.75",
6365            "equity": "7500.00",
6366            "buying_power": "5000.00",
6367            "non_marginable_buying_power": "1250.00",
6368        }))
6369        .unwrap();
6370        let balances = convert_account_to_balances(&account, &[]);
6371        assert_eq!(balances.len(), 1);
6372        assert_eq!(balances[0].balance.total, dec!(-2500.75));
6373        assert_eq!(
6374            balances[0].balance.free,
6375            dec!(-2500.75),
6376            "no free cash while borrowing; free <= total still holds"
6377        );
6378    }
6379
6380    #[test]
6381    fn test_alpaca_account_malformed_cash_fails_to_decode() {
6382        let result = serde_json::from_value::<AlpacaAccount>(serde_json::json!({
6383            "cash": "not-a-number",
6384            "non_marginable_buying_power": "0",
6385        }));
6386        assert!(result.is_err(), "a malformed cash must not read as zero");
6387    }
6388
6389    #[test]
6390    fn test_convert_account_to_balances_usd_filter() {
6391        let account = AlpacaAccount {
6392            cash: dec!(12000.00),
6393            non_marginable_buying_power: dec!(10000.00),
6394        };
6395        let usd = vec![AssetNameExchange::new("USD")];
6396        let balances = convert_account_to_balances(&account, &usd);
6397        assert_eq!(balances.len(), 1);
6398        assert_eq!(balances[0].asset, AssetNameExchange::new("USD"));
6399
6400        let non_usd = vec![AssetNameExchange::new("BTC")];
6401        let balances = convert_account_to_balances(&account, &non_usd);
6402        assert!(balances.is_empty());
6403    }
6404
6405    /// A caller's filter is matched ignoring case, but the balance keeps Alpaca's spelling, the
6406    /// one every USD fee is reported under too.
6407    #[test]
6408    fn a_usd_balance_is_named_usd_however_the_filter_spells_it() {
6409        let account = AlpacaAccount {
6410            cash: dec!(12000.00),
6411            non_marginable_buying_power: dec!(10000.00),
6412        };
6413        let balances = convert_account_to_balances(&account, &[AssetNameExchange::new("usd")]);
6414        assert_eq!(balances.len(), 1);
6415        assert_eq!(balances[0].asset, AssetNameExchange::new("USD"));
6416    }
6417
6418    #[test]
6419    fn test_is_options_or_equity_symbol() {
6420        // Crypto symbols contain '/'
6421        assert!(!is_options_or_equity_symbol("BTC/USD"));
6422        assert!(!is_options_or_equity_symbol("ETH/USD"));
6423        assert!(!is_options_or_equity_symbol("SOL/USD"));
6424
6425        // Equity symbols — no slash
6426        assert!(is_options_or_equity_symbol("AAPL"));
6427        assert!(is_options_or_equity_symbol("SPY"));
6428        assert!(is_options_or_equity_symbol("MSFT"));
6429
6430        // OCC option symbols — no slash
6431        assert!(is_options_or_equity_symbol("SPY250418C00450000"));
6432        assert!(is_options_or_equity_symbol("AAPL250418P00145000"));
6433    }
6434
6435    #[test]
6436    fn test_parse_order_error_already_cancelled() {
6437        // Locks in match arm ordering: a 422 with "already" but NOT "insufficient"
6438        // must map to OrderAlreadyCancelled, not BalanceInsufficient.
6439        assert!(matches!(
6440            parse_order_error(
6441                reqwest::StatusCode::UNPROCESSABLE_ENTITY,
6442                None,
6443                "order is already cancelled"
6444            ),
6445            UnindexedOrderError::Rejected(ApiError::OrderAlreadyCancelled)
6446        ));
6447    }
6448
6449    #[test]
6450    fn test_parse_order_error_already_wins_over_insufficient_on_422() {
6451        // If Alpaca sends a body containing both "already" and "insufficient",
6452        // the "already" arm must win (it appears first in the match).
6453        assert!(matches!(
6454            parse_order_error(
6455                reqwest::StatusCode::UNPROCESSABLE_ENTITY,
6456                None,
6457                "order already cancelled due to insufficient margin"
6458            ),
6459            UnindexedOrderError::Rejected(ApiError::OrderAlreadyCancelled)
6460        ));
6461    }
6462
6463    fn make_order_ws<'a>(
6464        id: &str,
6465        symbol: &str,
6466        side: &str,
6467        filled_qty: &'a str,
6468    ) -> AlpacaOrderWs<'a> {
6469        AlpacaOrderWs {
6470            id: SmolStr::new(id),
6471            client_order_id: None,
6472            symbol: SmolStr::new(symbol),
6473            qty: Some("2"),
6474            filled_qty: Some(filled_qty),
6475            side: SmolStr::new(side),
6476            order_type: SmolStr::new("limit"),
6477            time_in_force: SmolStr::new("day"),
6478            limit_price: Some("100.00"),
6479            stop_price: None,
6480            trail_percent: None,
6481            trail_price: None,
6482            status: SmolStr::new("partially_filled"),
6483        }
6484    }
6485
6486    /// The one event produced by a frame that maps to a single `AccountEvent`.
6487    ///
6488    /// Asserts the second slot is empty, so a test written for a single-event frame fails
6489    /// loudly if that frame ever starts producing two.
6490    fn sole_event(events: [Option<UnindexedAccountEvent>; 2]) -> Option<UnindexedAccountEvent> {
6491        let [first, second] = events;
6492        assert!(
6493            second.is_none(),
6494            "expected a single event, got a second: {second:?}"
6495        );
6496        first
6497    }
6498
6499    /// The `(Trade, OrderSnapshot)` pair a fill frame must produce, in that order.
6500    fn fill_events(
6501        events: [Option<UnindexedAccountEvent>; 2],
6502    ) -> (
6503        Trade<AssetNameExchange, InstrumentNameExchange>,
6504        crate::order::Order<
6505            ExchangeId,
6506            InstrumentNameExchange,
6507            OrderState<AssetNameExchange, InstrumentNameExchange>,
6508        >,
6509    ) {
6510        let [first, second] = events;
6511        let first = first.expect("a fill frame must produce an execution");
6512        let second = second.expect("a fill frame must produce an order snapshot");
6513        let AccountEventKind::Trade(trade) = first.kind else {
6514            panic!("first event must be the Trade, got {:?}", first.kind);
6515        };
6516        let AccountEventKind::OrderSnapshot(rustrade_integration::collection::snapshot::Snapshot(
6517            order,
6518        )) = second.kind
6519        else {
6520            panic!(
6521                "second event must be the OrderSnapshot, got {:?}",
6522                second.kind
6523            );
6524        };
6525        (trade, order)
6526    }
6527
6528    /// A fill carries the client order id its order was placed with, so a consumer can attribute
6529    /// it before any response names the order.
6530    #[test]
6531    fn test_convert_trade_update_fill_carries_the_client_order_id() {
6532        let mut order = make_order_ws("ord-1", "SPY", "buy", "1");
6533        order.client_order_id = Some(SmolStr::new("outside-1"));
6534        let update = AlpacaTradeUpdate {
6535            event: SmolStr::new("fill"),
6536            execution_id: Some("exec-1"),
6537            previous_execution_id: None,
6538            order,
6539            price: Some("150.00"),
6540            qty: Some("1"),
6541            timestamp: Some("2025-04-18T14:30:00Z"),
6542        };
6543        let (trade, _order) = fill_events(convert_trade_update(update));
6544        assert_eq!(trade.client_order_id, Some(ClientOrderId::new("outside-1")));
6545    }
6546
6547    #[test]
6548    fn test_convert_trade_update_fill_produces_trade_with_execution_id() {
6549        let update = AlpacaTradeUpdate {
6550            event: SmolStr::new("fill"),
6551            execution_id: Some("exec-1"),
6552            previous_execution_id: None,
6553            order: make_order_ws("ord-1", "SPY", "buy", "1"),
6554            price: Some("150.00"),
6555            qty: Some("1"),
6556            timestamp: Some("2025-04-18T14:30:00Z"),
6557        };
6558        let (trade, order) = fill_events(convert_trade_update(update));
6559        // The venue's execution id, which the fill's FILL activity carries too.
6560        assert_eq!(trade.id.0.as_str(), "exec-1");
6561        assert_eq!(
6562            trade.client_order_id, None,
6563            "the update names no client order id"
6564        );
6565        assert_eq!(trade.price, Decimal::from_str("150.00").unwrap());
6566        assert_eq!(trade.quantity, Decimal::from_str("1").unwrap());
6567
6568        // The execution print alone leaves the order reading as untouched. The snapshot carries
6569        // the cumulative filled quantity, which is the only way it reaches engine state.
6570        let OrderState::Active(crate::order::state::ActiveOrderState::Open(open)) = order.state
6571        else {
6572            panic!("expected an Open snapshot, got {:?}", order.state);
6573        };
6574        assert_eq!(open.filled_quantity, Decimal::from_str("1").unwrap());
6575        assert_eq!(open.id.assigned().map(|id| id.0.as_str()), Some("ord-1"));
6576    }
6577
6578    #[test]
6579    fn test_convert_trade_update_partial_fill() {
6580        let update = AlpacaTradeUpdate {
6581            event: SmolStr::new("partial_fill"),
6582            execution_id: None,
6583            previous_execution_id: None,
6584            order: make_order_ws("ord-2", "AAPL", "sell", "0.5"),
6585            price: Some("200.00"),
6586            qty: Some("0.5"),
6587            timestamp: None,
6588        };
6589        let (trade, order) = fill_events(convert_trade_update(update));
6590        assert_eq!(trade.quantity, Decimal::from_str("0.5").unwrap());
6591
6592        // make_order_ws reports qty=2, so 0.5 cumulative leaves 1.5 working: the order stays
6593        // Open, and a consumer reading quantity_remaining now sees 1.5 rather than 2.
6594        let OrderState::Active(crate::order::state::ActiveOrderState::Open(open)) = order.state
6595        else {
6596            panic!("expected an Open snapshot, got {:?}", order.state);
6597        };
6598        assert_eq!(open.filled_quantity, Decimal::from_str("0.5").unwrap());
6599        assert_eq!(
6600            open.quantity_remaining(order.quantity),
6601            Decimal::from_str("1.5").unwrap()
6602        );
6603    }
6604
6605    /// A `fill` that completes the order reports nothing left to fill, which is how the engine
6606    /// learns the order is done. Without the snapshot the order would sit in engine state as a
6607    /// resting order with `filled_quantity` 0 until REST reconciliation refreshed it.
6608    #[test]
6609    fn a_full_fill_snapshot_reports_nothing_left_to_fill() {
6610        let update = AlpacaTradeUpdate {
6611            event: SmolStr::new("fill"),
6612            execution_id: None,
6613            previous_execution_id: None,
6614            // make_order_ws reports qty=2; a cumulative filled of 2 completes it.
6615            order: AlpacaOrderWs {
6616                status: SmolStr::new("filled"),
6617                ..make_order_ws("ord-full", "SPY", "buy", "2")
6618            },
6619            price: Some("150.00"),
6620            qty: Some("1"),
6621            timestamp: Some("2025-04-18T14:30:00Z"),
6622        };
6623        let (_trade, order) = fill_events(convert_trade_update(update));
6624        let OrderState::Active(crate::order::state::ActiveOrderState::Open(open)) = order.state
6625        else {
6626            panic!("expected an Open snapshot, got {:?}", order.state);
6627        };
6628        assert_eq!(open.filled_quantity, Decimal::from_str("2").unwrap());
6629        assert!(
6630            open.quantity_remaining(order.quantity).is_zero(),
6631            "a completed order must report nothing left to fill so the engine retires it"
6632        );
6633    }
6634
6635    /// A fill whose order the exchange no longer reports as working must not be written back into
6636    /// engine state as an `Open` order. The execution still counts -- it moved the position --
6637    /// but resurrecting the order would leave a resting order that does not exist at the venue.
6638    #[test]
6639    fn a_fill_for_an_order_no_longer_working_emits_the_execution_without_a_snapshot() {
6640        for status in [
6641            "canceled",
6642            "expired",
6643            "rejected",
6644            "done_for_day",
6645            "pending_cancel",
6646        ] {
6647            let update = AlpacaTradeUpdate {
6648                event: SmolStr::new("partial_fill"),
6649                execution_id: None,
6650                previous_execution_id: None,
6651                order: AlpacaOrderWs {
6652                    status: SmolStr::new(status),
6653                    ..make_order_ws("ord-late", "SPY", "buy", "1")
6654                },
6655                price: Some("150.00"),
6656                qty: Some("1"),
6657                timestamp: None,
6658            };
6659            let [trade, snapshot] = convert_trade_update(update);
6660            assert!(
6661                matches!(trade.map(|e| e.kind), Some(AccountEventKind::Trade(_))),
6662                "the execution must still be reported for status {status}"
6663            );
6664            assert!(
6665                snapshot.is_none(),
6666                "status {status} must not produce an order snapshot"
6667            );
6668        }
6669    }
6670
6671    /// A notional order (placed by dollar value) carries no `qty`, so there is no quantity to
6672    /// report a remaining amount against. The execution is still reported; the order is not.
6673    #[test]
6674    fn a_notional_order_fill_emits_the_execution_without_a_snapshot() {
6675        let update = AlpacaTradeUpdate {
6676            event: SmolStr::new("partial_fill"),
6677            execution_id: None,
6678            previous_execution_id: None,
6679            order: AlpacaOrderWs {
6680                qty: None,
6681                ..make_order_ws("ord-notional-ws", "SPY", "buy", "1")
6682            },
6683            price: Some("150.00"),
6684            qty: Some("1"),
6685            timestamp: None,
6686        };
6687        let [trade, snapshot] = convert_trade_update(update);
6688        assert!(matches!(
6689            trade.map(|e| e.kind),
6690            Some(AccountEventKind::Trade(_))
6691        ));
6692        assert!(
6693            snapshot.is_none(),
6694            "a notional order has no quantity to snapshot against"
6695        );
6696    }
6697
6698    #[test]
6699    fn test_convert_trade_update_new_order_produces_snapshot() {
6700        let update = AlpacaTradeUpdate {
6701            event: SmolStr::new("new"),
6702            execution_id: None,
6703            previous_execution_id: None,
6704            order: AlpacaOrderWs {
6705                id: SmolStr::new("ord-new"),
6706                client_order_id: Some(SmolStr::new("cid-1")),
6707                symbol: SmolStr::new("AAPL"),
6708                qty: Some("10"),
6709                filled_qty: Some("0"),
6710                side: SmolStr::new("buy"),
6711                order_type: SmolStr::new("limit"),
6712                time_in_force: SmolStr::new("day"),
6713                limit_price: Some("150.00"),
6714                stop_price: None,
6715                trail_percent: None,
6716                trail_price: None,
6717                status: SmolStr::new("new"),
6718            },
6719            price: None,
6720            qty: None,
6721            timestamp: Some("2025-04-18T14:30:00Z"),
6722        };
6723        let event =
6724            sole_event(convert_trade_update(update)).expect("new event should produce an event");
6725        assert!(matches!(event.kind, AccountEventKind::OrderSnapshot(_)));
6726    }
6727
6728    #[test]
6729    fn test_convert_trade_update_canceled_produces_cancel() {
6730        let update = AlpacaTradeUpdate {
6731            event: SmolStr::new("canceled"),
6732            execution_id: None,
6733            previous_execution_id: None,
6734            order: make_order_ws("ord-3", "AAPL", "sell", "0"),
6735            price: None,
6736            qty: None,
6737            timestamp: Some("2025-04-18T14:30:00Z"),
6738        };
6739        let event =
6740            sole_event(convert_trade_update(update)).expect("canceled should produce an event");
6741        let AccountEventKind::OrderCancelled(response) = event.kind else {
6742            panic!("expected OrderCancelled, got {:?}", event.kind);
6743        };
6744        assert!(response.state.is_ok());
6745    }
6746
6747    /// A `canceled` update carries what filled, and an unknown fill stays unknown, not zero.
6748    #[test]
6749    fn a_canceled_update_carries_its_fill_or_none() {
6750        let cancelled = |filled_qty: Option<&'static str>| {
6751            let mut order = make_order_ws("ord-3", "AAPL", "sell", "0");
6752            order.filled_qty = filled_qty;
6753            let update = AlpacaTradeUpdate {
6754                event: SmolStr::new("canceled"),
6755                execution_id: None,
6756                previous_execution_id: None,
6757                order,
6758                price: None,
6759                qty: None,
6760                timestamp: Some("2025-04-18T14:30:00Z"),
6761            };
6762            let event =
6763                sole_event(convert_trade_update(update)).expect("canceled should produce an event");
6764            let AccountEventKind::OrderCancelled(response) = event.kind else {
6765                panic!("expected OrderCancelled, got {:?}", event.kind);
6766            };
6767            let Ok(cancelled) = response.state else {
6768                panic!("expected Ok, got {:?}", response.state);
6769            };
6770            cancelled.filled_quantity
6771        };
6772
6773        assert_eq!(cancelled(Some("1")), Some(Decimal::ONE));
6774        assert_eq!(cancelled(Some("0")), Some(Decimal::ZERO));
6775        assert_eq!(cancelled(None), None);
6776        assert_eq!(cancelled(Some("not a number")), None);
6777    }
6778
6779    /// An ended order whose `filled_qty` does not parse reports its fill unknown, not zero; a
6780    /// filled one filled its whole quantity regardless.
6781    #[test]
6782    fn an_ended_order_with_an_unparseable_fill_reports_it_unknown() {
6783        let key = OrderKey::new(
6784            ExchangeId::AlpacaBroker,
6785            InstrumentNameExchange::new("AAPL"),
6786            StrategyId::new("strategy"),
6787            ClientOrderId::new("c"),
6788        );
6789        let ended = |status: &str| {
6790            let mut order = make_order_response("o1", "AAPL");
6791            order.status = Some(status.to_string());
6792            order.filled_qty = "not a number".to_string();
6793            convert_ended_order(&order, &key).map(|order| order.state)
6794        };
6795
6796        assert!(matches!(
6797            ended("canceled"),
6798            Some(InactiveOrderState::Cancelled(Cancelled {
6799                filled_quantity: None,
6800                ..
6801            }))
6802        ));
6803        assert!(matches!(
6804            ended("expired"),
6805            Some(InactiveOrderState::Expired(Expired {
6806                filled_quantity: None,
6807                ..
6808            }))
6809        ));
6810        assert!(matches!(
6811            ended("filled"),
6812            Some(InactiveOrderState::FullyFilled(Filled { filled_quantity, .. }))
6813                if filled_quantity == Decimal::ONE
6814        ));
6815    }
6816
6817    #[test]
6818    fn test_convert_trade_update_rejected_produces_error() {
6819        let update = AlpacaTradeUpdate {
6820            event: SmolStr::new("rejected"),
6821            execution_id: None,
6822            previous_execution_id: None,
6823            order: make_order_ws("ord-4", "SPY", "buy", "0"),
6824            price: None,
6825            qty: None,
6826            timestamp: None,
6827        };
6828        let event =
6829            sole_event(convert_trade_update(update)).expect("rejected should produce an event");
6830        let AccountEventKind::OrderCancelled(response) = event.kind else {
6831            panic!("expected OrderCancelled, got {:?}", event.kind);
6832        };
6833        assert!(response.state.is_err());
6834    }
6835
6836    #[test]
6837    fn test_convert_open_order_notional_qty_none_is_skipped() {
6838        // qty=None means this is a notional order (placed by dollar value).
6839        // Recording it with quantity=0 would corrupt reconciliation, so it must be skipped.
6840        let order = AlpacaOrderResponse {
6841            id: "ord-notional".to_string(),
6842            client_order_id: None,
6843            symbol: "SPY".to_string(),
6844            qty: None,
6845            filled_qty: "0".to_string(),
6846            filled_avg_price: None,
6847            status: None,
6848            side: "buy".to_string(),
6849            order_type: "market".to_string(),
6850            time_in_force: "day".to_string(),
6851            limit_price: None,
6852            stop_price: None,
6853            trail_percent: None,
6854            trail_price: None,
6855            created_at: "2025-04-18T14:30:00Z".to_string(),
6856            updated_at: None,
6857        };
6858        assert!(convert_open_order(&order).is_none());
6859    }
6860
6861    #[test]
6862    fn test_convert_activity_to_trade_bad_price_returns_none() {
6863        // When price is unparseable, convert_activity_to_trade must return None.
6864        // recover_fills advances cumulative_qty BEFORE this check so that the dedup
6865        // key sequence stays aligned with the WS path even when a fill is skipped.
6866        let activity = AlpacaActivity {
6867            id: "act-1".to_string(),
6868            order_id: "ord-1".to_string(),
6869            symbol: "SPY250418C00450000".to_string(),
6870            side: "buy".to_string(),
6871            price: "not-a-number".to_string(),
6872            qty: "1".to_string(),
6873            transaction_time: "2025-04-18T14:30:00Z".to_string(),
6874            cum_qty: Some("1".to_string()),
6875        };
6876        assert!(convert_activity_to_trade(&activity).is_none());
6877    }
6878
6879    #[test]
6880    fn test_convert_positions_to_balances_crypto() {
6881        let positions = vec![
6882            // The positions list's form: the asset followed by USD.
6883            AlpacaPosition {
6884                qty_available: dec!(0.4),
6885                ..alpaca_position("BTCUSD", "crypto", AlpacaPositionSide::Long, dec!(0.5))
6886            },
6887            // The pair form orders use, read too.
6888            alpaca_position("ETH/USD", "crypto", AlpacaPositionSide::Long, dec!(2.0)),
6889            // Equity positions should be filtered out
6890            alpaca_position("AAPL", "us_equity", AlpacaPositionSide::Long, dec!(10)),
6891        ];
6892
6893        // All crypto assets
6894        let balances = convert_positions_to_balances(&positions, &[]);
6895        assert_eq!(balances.len(), 2, "only crypto positions returned");
6896        assert_eq!(balances[0].asset.name().as_str(), "BTC");
6897        assert_eq!(balances[1].asset.name().as_str(), "ETH");
6898        // total = qty (0.5 BTC), free = qty_available (0.4 BTC)
6899        assert_eq!(balances[0].balance.total, dec!(0.5));
6900        assert_eq!(balances[0].balance.free, dec!(0.4));
6901
6902        // Filter to BTC only: matches the `BTCUSD` position
6903        let btc_only = vec![AssetNameExchange::new("btc")];
6904        let balances = convert_positions_to_balances(&positions, &btc_only);
6905        assert_eq!(balances.len(), 1);
6906        assert_eq!(balances[0].asset.name().as_str(), "BTC");
6907    }
6908
6909    #[test]
6910    fn test_crypto_position_asset() {
6911        assert_eq!(crypto_position_asset("BTCUSD"), "BTC");
6912        assert_eq!(
6913            crypto_position_asset("btcusd"),
6914            "btc",
6915            "suffix matched ignoring case"
6916        );
6917        assert_eq!(
6918            crypto_position_asset("USDTUSD"),
6919            "USDT",
6920            "only one USD is stripped"
6921        );
6922        assert_eq!(crypto_position_asset("BTC/USD"), "BTC");
6923        assert_eq!(crypto_position_asset("BTC/USDT"), "BTC");
6924        assert_eq!(crypto_position_asset("€USD"), "€");
6925        // Neither form, or an empty asset: used whole rather than dropped.
6926        assert_eq!(crypto_position_asset("USD"), "USD");
6927        assert_eq!(crypto_position_asset("/USD"), "/USD");
6928        assert_eq!(crypto_position_asset("BTCUSDT"), "BTCUSDT");
6929        assert_eq!(
6930            crypto_position_asset("BTCێ"),
6931            "BTCێ",
6932            "no char boundary at the suffix"
6933        );
6934        assert_eq!(crypto_position_asset(""), "");
6935    }
6936
6937    /// A position as `/v2/positions` reports it, with `qty_available` equal to `qty`.
6938    fn alpaca_position(
6939        symbol: &str,
6940        asset_class: &str,
6941        side: AlpacaPositionSide,
6942        qty: Decimal,
6943    ) -> AlpacaPosition {
6944        AlpacaPosition {
6945            symbol: symbol.into(),
6946            asset_class: asset_class.into(),
6947            side,
6948            qty,
6949            qty_available: qty,
6950            avg_entry_price: Some(dec!(100)),
6951            unrealized_pl: Some(dec!(0)),
6952        }
6953    }
6954
6955    #[test]
6956    fn test_convert_positions_sign_comes_from_side_not_qty() {
6957        let now = Utc::now();
6958        let positions = vec![
6959            alpaca_position("AAPL", "us_equity", AlpacaPositionSide::Long, dec!(10)),
6960            alpaca_position("TSLA", "us_equity", AlpacaPositionSide::Short, dec!(-3)),
6961            // Short with an unsigned qty still reads as short.
6962            alpaca_position("MSFT", "us_equity", AlpacaPositionSide::Short, dec!(4)),
6963        ];
6964
6965        let converted = convert_positions(&positions, now).unwrap();
6966        let quantities: Vec<_> = converted.iter().map(|(s, p)| (*s, p.quantity)).collect();
6967        assert_eq!(
6968            quantities,
6969            vec![("AAPL", dec!(10)), ("TSLA", dec!(-3)), ("MSFT", dec!(-4))]
6970        );
6971        assert!(converted.iter().all(|(_, p)| p.time_exchange == now));
6972    }
6973
6974    #[test]
6975    fn test_convert_positions_leaves_out_crypto_and_flat() {
6976        let positions = vec![
6977            alpaca_position("BTC/USD", "crypto", AlpacaPositionSide::Long, dec!(0.5)),
6978            alpaca_position("AAPL", "us_equity", AlpacaPositionSide::Long, dec!(0)),
6979            alpaca_position("SPY", "us_equity", AlpacaPositionSide::Long, dec!(1)),
6980        ];
6981
6982        let converted = convert_positions(&positions, Utc::now()).unwrap();
6983        let symbols: Vec<_> = converted.iter().map(|(s, _)| *s).collect();
6984        assert_eq!(symbols, vec!["SPY"]);
6985    }
6986
6987    #[test]
6988    fn test_alpaca_position_decodes_equity_option_and_fractional() {
6989        let positions: Vec<AlpacaPosition> = serde_json::from_value(serde_json::json!([
6990            {
6991                "asset_id": "00000000-0000-0000-0000-000000000001",
6992                "symbol": "XYZ",
6993                "exchange": "NASDAQ",
6994                "asset_class": "us_equity",
6995                "qty": "-7",
6996                "qty_available": "-7",
6997                "side": "short",
6998                "avg_entry_price": "50.25",
6999                "market_value": "-351.75",
7000                "unrealized_pl": "0",
7001            },
7002            {
7003                "symbol": "XYZ271217C00055000",
7004                "asset_class": "us_option",
7005                "qty": "2",
7006                "qty_available": "2",
7007                "side": "long",
7008                "avg_entry_price": "1.35",
7009                "unrealized_pl": "-20",
7010            },
7011            {
7012                "symbol": "ABC",
7013                "asset_class": "us_equity",
7014                "qty": "0.125",
7015                "qty_available": "0.125",
7016                "side": "long",
7017                "avg_entry_price": "80",
7018                "unrealized_pl": null,
7019            },
7020        ]))
7021        .unwrap();
7022
7023        let converted = convert_positions(&positions, Utc::now()).unwrap();
7024        let [
7025            (short_sym, short),
7026            (option_sym, option),
7027            (fraction_sym, fraction),
7028        ] = converted.as_slice()
7029        else {
7030            panic!("expected three positions, got {converted:?}");
7031        };
7032
7033        assert_eq!(*short_sym, "XYZ");
7034        assert_eq!(short.quantity, dec!(-7));
7035        assert_eq!(short.entry_price, Some(dec!(50.25)));
7036        assert!(short.is_short());
7037
7038        assert_eq!(*option_sym, "XYZ271217C00055000");
7039        assert_eq!(option.quantity, dec!(2));
7040        assert_eq!(option.entry_price, Some(dec!(1.35)), "per-share premium");
7041        assert_eq!(option.unrealized_pnl, Some(dec!(-20)));
7042
7043        assert_eq!(*fraction_sym, "ABC");
7044        assert_eq!(fraction.quantity, dec!(0.125));
7045        assert_eq!(fraction.unrealized_pnl, None);
7046
7047        for (_, p) in &converted {
7048            assert_eq!(p.margin_used, None);
7049            assert_eq!(p.liquidation_price, None);
7050            assert_eq!(p.leverage, None);
7051        }
7052    }
7053
7054    /// An unknown or missing side decodes, so the crypto balances in the same response survive,
7055    /// but a position that needs a direction fails rather than guessing one or reading as flat.
7056    #[test]
7057    fn test_alpaca_position_unknown_side_fails_only_where_a_direction_is_needed() {
7058        let positions: Vec<AlpacaPosition> = serde_json::from_value(serde_json::json!([
7059            {
7060                "symbol": "BTC/USD",
7061                "asset_class": "crypto",
7062                "qty": "0.5",
7063                "qty_available": "0.5",
7064            },
7065            {
7066                "symbol": "XYZ",
7067                "asset_class": "us_equity",
7068                "qty": "1",
7069                "qty_available": "1",
7070                "side": "sideways",
7071            },
7072        ]))
7073        .unwrap();
7074        assert_eq!(positions[0].side, AlpacaPositionSide::Unknown);
7075        assert_eq!(positions[1].side, AlpacaPositionSide::Unknown);
7076
7077        let balances = convert_positions_to_balances(&positions, &[]);
7078        assert_eq!(balances.len(), 1);
7079        assert_eq!(balances[0].balance.total, dec!(0.5));
7080
7081        let result = convert_positions(&positions, Utc::now());
7082        assert!(
7083            matches!(&result, Err(UnindexedClientError::Internal(msg)) if msg.contains("XYZ")),
7084            "an unknown side must not guess a direction: {result:?}"
7085        );
7086    }
7087
7088    /// `updated_at` orders an order's states; `created_at` is constant across all of them.
7089    #[test]
7090    fn order_state_time_prefers_updated_at() {
7091        let mut resp = make_order_response("ord-1", "SPY");
7092        resp.created_at = "2025-04-18T14:30:00Z".to_string();
7093        resp.updated_at = Some("2025-04-18T15:45:00Z".to_string());
7094        assert_eq!(
7095            order_state_time(&resp),
7096            parse_timestamp("2025-04-18T15:45:00Z").unwrap(),
7097            "updated_at must win over created_at"
7098        );
7099
7100        // Alpaca's schema makes updated_at nullable, so creation time is the fallback.
7101        resp.updated_at = None;
7102        assert_eq!(
7103            order_state_time(&resp),
7104            parse_timestamp("2025-04-18T14:30:00Z").unwrap(),
7105            "absent updated_at falls back to created_at"
7106        );
7107
7108        // An unparseable updated_at must not silently become `now`, which would order this state
7109        // ahead of every later one.
7110        resp.updated_at = Some("not-a-timestamp".to_string());
7111        assert_eq!(
7112            order_state_time(&resp),
7113            parse_timestamp("2025-04-18T14:30:00Z").unwrap(),
7114            "unparseable updated_at falls back to created_at"
7115        );
7116    }
7117
7118    /// A placement response without a status reads from what filled, as one with a live status
7119    /// does.
7120    #[test]
7121    fn a_placement_response_without_a_status_reads_from_what_filled() {
7122        let instrument = InstrumentNameExchange::new("SPY");
7123        let mut resp = make_order_response("ord-1", "SPY");
7124        resp.filled_qty = "0.4".to_string();
7125        let OrderState::Active(ActiveOrderState::Open(open)) =
7126            placed_order_state(&resp, &instrument, Decimal::ONE)
7127        else {
7128            panic!("expected Open");
7129        };
7130        assert_eq!(open.filled_quantity, Decimal::new(4, 1));
7131
7132        resp.filled_qty = "1".to_string();
7133        resp.filled_avg_price = Some("100.5".to_string());
7134        let state = placed_order_state(&resp, &instrument, Decimal::ONE);
7135        let OrderState::Inactive(InactiveOrderState::FullyFilled(filled)) = state else {
7136            panic!("expected FullyFilled, got {state:?}");
7137        };
7138        assert_eq!(filled.filled_quantity, Decimal::ONE);
7139        assert_eq!(filled.avg_price, Some(Decimal::new(1005, 1)));
7140    }
7141
7142    fn make_order_response(id: &str, symbol: &str) -> AlpacaOrderResponse {
7143        AlpacaOrderResponse {
7144            id: id.to_string(),
7145            client_order_id: None,
7146            symbol: symbol.to_string(),
7147            qty: Some("1".to_string()),
7148            filled_qty: "0".to_string(),
7149            filled_avg_price: None,
7150            status: None,
7151            side: "buy".to_string(),
7152            order_type: "limit".to_string(),
7153            time_in_force: "day".to_string(),
7154            limit_price: Some("100.00".to_string()),
7155            stop_price: None,
7156            trail_percent: None,
7157            trail_price: None,
7158            created_at: "2025-04-18T14:30:00Z".to_string(),
7159            updated_at: None,
7160        }
7161    }
7162
7163    #[test]
7164    fn test_build_instrument_snapshots_empty_instruments_returns_only_with_orders() {
7165        let orders = vec![
7166            make_order_response("o1", "AAPL"),
7167            make_order_response("o2", "SPY"),
7168        ];
7169        let snapshots = build_instrument_snapshots(orders, Vec::new(), &[]);
7170        assert_eq!(snapshots.len(), 2);
7171        let symbols: Vec<&str> = snapshots
7172            .iter()
7173            .map(|s| s.instrument.name().as_str())
7174            .collect();
7175        assert!(symbols.contains(&"AAPL"));
7176        assert!(symbols.contains(&"SPY"));
7177    }
7178
7179    #[test]
7180    fn test_build_instrument_snapshots_requested_instrument_no_orders_gets_empty_snapshot() {
7181        // When instruments list is provided, every requested instrument must appear
7182        // even if it has no open orders — callers depend on this for reconciliation.
7183        let orders = vec![make_order_response("o1", "AAPL")];
7184        let instruments = vec![
7185            InstrumentNameExchange::new("AAPL"),
7186            InstrumentNameExchange::new("SPY"),
7187        ];
7188        let snapshots = build_instrument_snapshots(orders, Vec::new(), &instruments);
7189        assert_eq!(snapshots.len(), 2);
7190        let spy = snapshots
7191            .iter()
7192            .find(|s| s.instrument.name().as_str() == "SPY")
7193            .expect("SPY snapshot must be present even with no orders");
7194        assert!(spy.orders.is_empty());
7195    }
7196
7197    /// A notional order: placed by dollar value, so Alpaca reports no `qty`.
7198    fn make_notional_order_response(id: &str, symbol: &str) -> AlpacaOrderResponse {
7199        AlpacaOrderResponse {
7200            qty: None,
7201            ..make_order_response(id, symbol)
7202        }
7203    }
7204
7205    fn snapshot_for<'a>(
7206        snapshots: &'a [InstrumentAccountSnapshot<
7207            ExchangeId,
7208            AssetNameExchange,
7209            InstrumentNameExchange,
7210        >],
7211        symbol: &str,
7212    ) -> &'a InstrumentAccountSnapshot<ExchangeId, AssetNameExchange, InstrumentNameExchange> {
7213        snapshots
7214            .iter()
7215            .find(|snapshot| snapshot.instrument.name().as_str() == symbol)
7216            .unwrap_or_else(|| panic!("no snapshot for {symbol}"))
7217    }
7218
7219    /// Every open order converted, so each list is every order open on the venue, including a
7220    /// requested instrument with none: that entry is what lets the engine see an order that ended
7221    /// while the stream was down is gone.
7222    #[test]
7223    fn test_build_instrument_snapshots_declares_complete_lists_complete() {
7224        let instruments = [
7225            InstrumentNameExchange::new("AAPL"),
7226            InstrumentNameExchange::new("SPY"),
7227        ];
7228        for requested in [&instruments[..], &[]] {
7229            let orders = vec![make_order_response("o1", "AAPL")];
7230            let snapshots = build_instrument_snapshots(orders, Vec::new(), requested);
7231            assert!(!snapshots.is_empty());
7232            assert!(snapshots.iter().all(|snapshot| snapshot.orders_complete));
7233        }
7234    }
7235
7236    /// A notional order cannot be represented, so its instrument's list is not every open order
7237    /// and must not say so: the engine would retire that order as absent. Other instruments are
7238    /// unaffected.
7239    #[test]
7240    fn test_build_instrument_snapshots_notional_order_leaves_only_its_instrument_incomplete() {
7241        let orders = vec![
7242            make_order_response("o1", "AAPL"),
7243            make_order_response("o2", "SPY"),
7244            make_notional_order_response("o3", "SPY"),
7245        ];
7246        let instruments = [
7247            InstrumentNameExchange::new("AAPL"),
7248            InstrumentNameExchange::new("SPY"),
7249        ];
7250        let snapshots = build_instrument_snapshots(orders, Vec::new(), &instruments);
7251
7252        assert!(snapshot_for(&snapshots, "AAPL").orders_complete);
7253        let spy = snapshot_for(&snapshots, "SPY");
7254        assert!(!spy.orders_complete);
7255        assert_eq!(spy.orders.len(), 1);
7256    }
7257
7258    /// Unfiltered, an instrument whose only open order cannot be represented still gets an entry,
7259    /// one that says its list is not complete.
7260    #[test]
7261    fn test_build_instrument_snapshots_unfiltered_lists_an_instrument_with_only_a_notional_order() {
7262        let orders = vec![make_notional_order_response("o1", "SPY")];
7263        let snapshots = build_instrument_snapshots(orders, Vec::new(), &[]);
7264
7265        let spy = snapshot_for(&snapshots, "SPY");
7266        assert!(!spy.orders_complete);
7267        assert!(spy.orders.is_empty());
7268    }
7269
7270    #[test]
7271    fn test_build_instrument_snapshots_non_requested_instrument_excluded() {
7272        // An instrument with open orders that is NOT in the requested list must
7273        // not appear in the output when the instruments list is non-empty.
7274        let orders = vec![
7275            make_order_response("o1", "AAPL"),
7276            make_order_response("o2", "MSFT"), // not requested
7277        ];
7278        let instruments = vec![InstrumentNameExchange::new("AAPL")];
7279        let snapshots = build_instrument_snapshots(orders, Vec::new(), &instruments);
7280        assert_eq!(snapshots.len(), 1);
7281        assert_eq!(snapshots[0].instrument.name().as_str(), "AAPL");
7282    }
7283
7284    fn position(quantity: Decimal) -> Position {
7285        Position::new(
7286            quantity,
7287            Some(dec!(100)),
7288            None,
7289            None,
7290            None,
7291            None,
7292            None,
7293            Utc::now(),
7294        )
7295    }
7296
7297    #[test]
7298    fn test_build_instrument_snapshots_unfiltered_lists_positions_without_orders() {
7299        let orders = vec![
7300            make_order_response("o1", "AAPL"),
7301            make_order_response("o2", "MSFT"),
7302            make_order_response("o3", "BTC/USD"),
7303        ];
7304        let positions = vec![("AAPL", position(dec!(5))), ("TSLA", position(dec!(-2)))];
7305
7306        let snapshots = build_instrument_snapshots(orders, positions, &[]);
7307        let listed: Vec<_> = snapshots
7308            .iter()
7309            .map(|s| {
7310                (
7311                    s.instrument.name().as_str(),
7312                    s.orders.len(),
7313                    s.position.quantity(),
7314                )
7315            })
7316            .collect();
7317        // An equity with an order but no position is flat; crypto is never a position.
7318        assert_eq!(
7319            listed,
7320            vec![
7321                ("AAPL", 1, Some(dec!(5))),
7322                ("MSFT", 1, Some(Decimal::ZERO)),
7323                ("BTC/USD", 1, None),
7324                ("TSLA", 0, Some(dec!(-2)))
7325            ]
7326        );
7327        assert!(snapshots.iter().all(|s| s.orders_complete));
7328    }
7329
7330    #[test]
7331    fn test_build_instrument_snapshots_filtered_attaches_only_requested_positions() {
7332        let positions = vec![("AAPL", position(dec!(5))), ("TSLA", position(dec!(-2)))];
7333        let instruments = vec![
7334            InstrumentNameExchange::new("tsla"),
7335            InstrumentNameExchange::new("MSFT"),
7336            InstrumentNameExchange::new("BTC/USD"),
7337            InstrumentNameExchange::new("TSLA"),
7338        ];
7339
7340        let snapshots = build_instrument_snapshots(Vec::new(), positions, &instruments);
7341        let listed: Vec<_> = snapshots
7342            .iter()
7343            .map(|s| (s.instrument.name().as_str(), s.position.quantity()))
7344            .collect();
7345        // A requested equity with no position is reported flat, and one requested in another case
7346        // still finds its position; crypto is never a position. A second name for a symbol
7347        // already claimed is unknown, not flat.
7348        assert_eq!(
7349            listed,
7350            vec![
7351                ("tsla", Some(dec!(-2))),
7352                ("MSFT", Some(Decimal::ZERO)),
7353                ("BTC/USD", None),
7354                ("TSLA", None)
7355            ]
7356        );
7357        assert!(!snapshots[3].orders_complete);
7358    }
7359
7360    /// Verifies that the dedup key synthesised by `recover_fills` (REST path) matches
7361    /// the key produced by `convert_trade_update` (WS path) for the same partial fills.
7362    ///
7363    /// This is the critical invariant for cross-source dedup after a reconnect:
7364    /// both paths must produce `"{order_id}:{cumulative_filled_qty}"` for the same fill.
7365    #[test]
7366    fn test_recover_fills_dedup_key_matches_ws_path() {
7367        let order_id = "ord-1";
7368
7369        // WS path: Alpaca sends cumulative filled_qty with each event.
7370        // Two partial fills of 1 lot each → filled_qty "1" then "2".
7371        let ws_keys: Vec<SmolStr> = ["1", "2"]
7372            .iter()
7373            .map(|filled_qty| {
7374                early_dedup_key(&AlpacaTradeUpdate {
7375                    event: SmolStr::new("partial_fill"),
7376                    execution_id: None,
7377                    previous_execution_id: None,
7378                    order: make_order_ws(order_id, "SPY", "buy", filled_qty),
7379                    price: Some("150.00"),
7380                    qty: Some("1"),
7381                    timestamp: None,
7382                })
7383            })
7384            .collect();
7385
7386        // REST path: recover_fills accumulates cumulative qty from per-execution activities.
7387        // Two activities with exec qty "1" each → cumulative 1 then 2.
7388        let mut cumulative = Decimal::ZERO;
7389        let rest_keys: Vec<SmolStr> = ["1", "1"]
7390            .iter()
7391            .map(|exec_qty| {
7392                cumulative += Decimal::from_str(exec_qty).unwrap();
7393                fill_dedup_key(order_id, cumulative)
7394            })
7395            .collect();
7396
7397        assert_eq!(
7398            ws_keys, rest_keys,
7399            "REST recovery dedup keys must match WS path keys for cross-source dedup to work"
7400        );
7401        assert_eq!(ws_keys[0].as_str(), "ord-1:1");
7402        assert_eq!(ws_keys[1].as_str(), "ord-1:2");
7403    }
7404
7405    /// Verifies that `early_dedup_key` produces the same key as the full event path.
7406    ///
7407    /// A fill without an `execution_id` falls back to its dedup key for its `TradeId`, the key the
7408    /// early dedup check reads from the raw WS fields before the event is built.
7409    #[test]
7410    fn a_ws_fill_without_an_execution_id_is_identified_by_its_dedup_key() {
7411        let update = AlpacaTradeUpdate {
7412            event: SmolStr::new("fill"),
7413            execution_id: None,
7414            previous_execution_id: None,
7415            order: make_order_ws("ord-abc", "SPY", "buy", "5"),
7416            price: Some("150.00"),
7417            qty: Some("5"),
7418            timestamp: None,
7419        };
7420
7421        // Early path: extract key before full event construction
7422        let early_key = early_dedup_key(&update);
7423
7424        // A fill frame also carries the order snapshot; the execution is always the first slot.
7425        let [event, _snapshot] = convert_trade_update(update);
7426        let Some(UnindexedAccountEvent {
7427            kind: AccountEventKind::Trade(trade),
7428            ..
7429        }) = event
7430        else {
7431            panic!("fill should produce an execution");
7432        };
7433
7434        assert_eq!(
7435            trade.id.0.as_str(),
7436            early_key.as_str(),
7437            "without an execution_id the TradeId is the dedup key"
7438        );
7439        assert_eq!(early_key.as_str(), "ord-abc:5");
7440    }
7441
7442    /// Verifies that `early_dedup_key` correctly normalizes decimal strings.
7443    ///
7444    /// Alpaca may send "1.00" or "1" for the same fill. Both must produce the same
7445    /// dedup key to avoid false negatives in duplicate detection.
7446    #[test]
7447    fn early_dedup_key_normalizes_decimal_strings() {
7448        // Test with trailing zeros: "1.00" should normalize to "1"
7449        let update1 = AlpacaTradeUpdate {
7450            event: SmolStr::new("fill"),
7451            execution_id: None,
7452            previous_execution_id: None,
7453            order: AlpacaOrderWs {
7454                id: SmolStr::new("ord-x"),
7455                client_order_id: Some(SmolStr::new("cid")),
7456                symbol: SmolStr::new("AAPL"),
7457                qty: Some("10"),
7458                filled_qty: Some("1.00"),
7459                side: SmolStr::new("buy"),
7460                order_type: SmolStr::new("market"),
7461                time_in_force: SmolStr::new("day"),
7462                limit_price: None,
7463                stop_price: None,
7464                trail_percent: None,
7465                trail_price: None,
7466                status: SmolStr::new("filled"),
7467            },
7468            price: Some("100.00"),
7469            qty: Some("10"),
7470            timestamp: None,
7471        };
7472        assert_eq!(early_dedup_key(&update1).as_str(), "ord-x:1");
7473
7474        // Test already normalized: "1" stays "1"
7475        let update2 = AlpacaTradeUpdate {
7476            event: SmolStr::new("fill"),
7477            execution_id: None,
7478            previous_execution_id: None,
7479            order: AlpacaOrderWs {
7480                id: SmolStr::new("ord-x"),
7481                client_order_id: Some(SmolStr::new("cid")),
7482                symbol: SmolStr::new("AAPL"),
7483                qty: Some("10"),
7484                filled_qty: Some("1"),
7485                side: SmolStr::new("buy"),
7486                order_type: SmolStr::new("market"),
7487                time_in_force: SmolStr::new("day"),
7488                limit_price: None,
7489                stop_price: None,
7490                trail_percent: None,
7491                trail_price: None,
7492                status: SmolStr::new("filled"),
7493            },
7494            price: Some("100.00"),
7495            qty: Some("10"),
7496            timestamp: None,
7497        };
7498        assert_eq!(early_dedup_key(&update2).as_str(), "ord-x:1");
7499
7500        // Test single trailing zero: "1.0" normalizes to "1"
7501        let update3 = AlpacaTradeUpdate {
7502            event: SmolStr::new("fill"),
7503            execution_id: None,
7504            previous_execution_id: None,
7505            order: AlpacaOrderWs {
7506                id: SmolStr::new("ord-x"),
7507                client_order_id: Some(SmolStr::new("cid")),
7508                symbol: SmolStr::new("AAPL"),
7509                qty: Some("10"),
7510                filled_qty: Some("1.0"),
7511                side: SmolStr::new("buy"),
7512                order_type: SmolStr::new("market"),
7513                time_in_force: SmolStr::new("day"),
7514                limit_price: None,
7515                stop_price: None,
7516                trail_percent: None,
7517                trail_price: None,
7518                status: SmolStr::new("filled"),
7519            },
7520            price: Some("100.00"),
7521            qty: Some("10"),
7522            timestamp: None,
7523        };
7524        assert_eq!(early_dedup_key(&update3).as_str(), "ord-x:1");
7525    }
7526
7527    /// Regression guard for HIGH-2: a `rejected` event without `filled_qty` in the JSON
7528    /// previously caused the entire AlpacaTradeUpdate to fail deserialization, silently
7529    /// dropping the event. After the fix, `filled_qty` is Option and defaults to None
7530    /// (unwrapped to "0" at use-sites), so the event reaches the `rejected` branch.
7531    #[test]
7532    fn process_ws_text_rejected_event_without_filled_qty_is_not_dropped() {
7533        let (tx, mut rx) = mpsc::unbounded_channel();
7534        let dedup = new_dedup_cache();
7535        let mut backoff = ExponentialBackoff::new();
7536
7537        // Minimal rejected-event JSON — no `filled_qty` field in the order object.
7538        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"}}}"#;
7539
7540        let known = KnownLiveOrders::shared(ExchangeId::AlpacaBroker);
7541        process_ws_text(json, &tx, &dedup, &known, &mut backoff);
7542
7543        // The event must NOT be silently dropped — an OrderCancelled must be emitted.
7544        let event = rx.try_recv()
7545            .expect("rejected event without filled_qty must produce an AccountEvent, not be silently dropped");
7546        assert!(
7547            matches!(event.kind, AccountEventKind::OrderCancelled(_)),
7548            "rejected event must map to OrderCancelled, got: {:?}",
7549            event.kind
7550        );
7551    }
7552
7553    /// A `trade_updates` fill frame as Alpaca sends it: its `execution_id` is the trade's id. One
7554    /// that is null, empty or absent falls back to the dedup key.
7555    #[test]
7556    fn process_ws_text_identifies_a_fill_by_its_execution_id() {
7557        let frame = |execution_id: &str| {
7558            format!(
7559                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"}}}}"#
7560            )
7561        };
7562        let cases = [
7563            (
7564                r#""execution_id":"524b1902-817e-446c-825b-a9fcfebbc17e","#,
7565                "524b1902-817e-446c-825b-a9fcfebbc17e",
7566            ),
7567            (r#""execution_id":null,"#, "ord-1:2"),
7568            (r#""execution_id":"","#, "ord-1:2"),
7569            ("", "ord-1:2"),
7570        ];
7571        for (field, expected) in cases {
7572            let (tx, mut rx) = mpsc::unbounded_channel();
7573            let known = KnownLiveOrders::shared(ExchangeId::AlpacaBroker);
7574            process_ws_text(
7575                &frame(field),
7576                &tx,
7577                &new_dedup_cache(),
7578                &known,
7579                &mut ExponentialBackoff::new(),
7580            );
7581            let Ok(UnindexedAccountEvent {
7582                kind: AccountEventKind::Trade(trade),
7583                ..
7584            }) = rx.try_recv()
7585            else {
7586                panic!("the fill frame {field:?} produces a Trade");
7587            };
7588            assert_eq!(trade.id.0.as_str(), expected, "frame {field:?}");
7589        }
7590    }
7591
7592    /// A `trade_bust` or `trade_correct` frame for `ord-1` (a buy of SPY), with `fields` spliced
7593    /// in. Its shape follows the Broker API's `TradeUpdateEventV2`; no real frame has been seen.
7594    fn amendment_frame(event: &str, fields: &str) -> String {
7595        format!(
7596            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"}}}}"#
7597        )
7598    }
7599
7600    /// The events the stream sends for `frames`, in order.
7601    fn stream_events(frames: &[String]) -> Vec<UnindexedAccountEvent> {
7602        let (tx, mut rx) = mpsc::unbounded_channel();
7603        let dedup = new_dedup_cache();
7604        let known = KnownLiveOrders::shared(ExchangeId::AlpacaBroker);
7605        let mut backoff = ExponentialBackoff::new();
7606        for frame in frames {
7607            process_ws_text(frame, &tx, &dedup, &known, &mut backoff);
7608        }
7609        std::iter::from_fn(|| rx.try_recv().ok()).collect()
7610    }
7611
7612    /// The one event the stream sends for an amendment frame, as a `TradeAmendment`.
7613    fn amendment_of(
7614        frame: String,
7615    ) -> crate::trade::TradeAmendment<AssetNameExchange, InstrumentNameExchange> {
7616        let events = stream_events(&[frame]);
7617        let [
7618            UnindexedAccountEvent {
7619                kind: AccountEventKind::TradeAmended(amendment),
7620                ..
7621            },
7622        ] = <[_; 1]>::try_from(events).unwrap_or_else(|events| {
7623            panic!("one TradeAmended, got {events:?}");
7624        })
7625        else {
7626            panic!("expected TradeAmended");
7627        };
7628        amendment
7629    }
7630
7631    /// A bust names the fill it cancels, and its quantity, which Alpaca may send as a negative
7632    /// reversal, is read as a magnitude.
7633    #[test]
7634    fn process_ws_text_reports_a_trade_bust() {
7635        let amendment = amendment_of(amendment_frame(
7636            "trade_bust",
7637            r#""execution_id":"exec-x","previous_execution_id":"exec-a","qty":"-2","#,
7638        ));
7639        assert_eq!(amendment.original, Some(TradeId::new("exec-a")));
7640        assert_eq!(amendment.order_id, OrderId::new("ord-1"));
7641        assert_eq!(amendment.instrument, InstrumentNameExchange::new("SPY"));
7642        assert_eq!(
7643            Some(amendment.time_exchange),
7644            parse_timestamp("2025-04-18T14:31:00Z")
7645        );
7646        assert_eq!(
7647            amendment.kind,
7648            TradeAmendmentKind::Busted {
7649                quantity: Some(Decimal::TWO)
7650            }
7651        );
7652    }
7653
7654    /// A bust is reported even when it names neither the fill nor a quantity.
7655    #[test]
7656    fn process_ws_text_reports_a_bust_that_names_no_fill() {
7657        let amendment = amendment_of(amendment_frame("trade_bust", ""));
7658        assert_eq!(amendment.original, None);
7659        assert_eq!(
7660            amendment.kind,
7661            TradeAmendmentKind::Busted { quantity: None }
7662        );
7663    }
7664
7665    /// A correction carrying a price and a quantity replaces the fill with a trade under its own
7666    /// execution id, which claims no cumulative.
7667    #[test]
7668    fn process_ws_text_reports_a_trade_correct_with_its_replacement() {
7669        let amendment = amendment_of(amendment_frame(
7670            "trade_correct",
7671            r#""execution_id":"exec-c","previous_execution_id":"exec-a","price":"101.50","qty":"2","#,
7672        ));
7673        assert_eq!(amendment.original, Some(TradeId::new("exec-a")));
7674        let TradeAmendmentKind::Corrected { replacement } = amendment.kind else {
7675            panic!("a full replacement: {:?}", amendment.kind);
7676        };
7677        assert_eq!(replacement.id, TradeId::new("exec-c"));
7678        assert_eq!(replacement.order_id, OrderId::new("ord-1"));
7679        assert_eq!(replacement.side, Side::Buy);
7680        assert_eq!(replacement.price, Decimal::new(10150, 2));
7681        assert_eq!(replacement.quantity, Decimal::TWO);
7682        assert_eq!(replacement.order_filled_quantity, None);
7683        assert_eq!(replacement.time_exchange, amendment.time_exchange);
7684    }
7685
7686    /// A correction lacking anything a replacement trade needs is still reported, with what it
7687    /// carried.
7688    #[test]
7689    fn process_ws_text_reports_a_correction_it_cannot_resolve() {
7690        let price = Some(Decimal::new(10150, 2));
7691        let cases = [
7692            (
7693                r#""execution_id":"exec-c","qty":"2","#,
7694                (Some("exec-c"), None, Some(Decimal::TWO)),
7695            ),
7696            (
7697                r#""price":"101.50","qty":"2","#,
7698                (None, price, Some(Decimal::TWO)),
7699            ),
7700            (
7701                r#""execution_id":"exec-c","price":"101.50","qty":"0","#,
7702                (Some("exec-c"), price, Some(Decimal::ZERO)),
7703            ),
7704            (
7705                r#""execution_id":"exec-c","price":"101.50","#,
7706                (Some("exec-c"), price, None),
7707            ),
7708        ];
7709        for (fields, (id, price, quantity)) in cases {
7710            let amendment = amendment_of(amendment_frame("trade_correct", fields));
7711            assert_eq!(
7712                amendment.kind,
7713                TradeAmendmentKind::CorrectedUnresolved {
7714                    id: id.map(TradeId::new),
7715                    price,
7716                    quantity,
7717                },
7718                "{fields}"
7719            );
7720        }
7721    }
7722
7723    /// After a bust takes an order back, a new fill that returns it to a cumulative an earlier
7724    /// fill reached is delivered, not taken for that fill.
7725    #[test]
7726    fn a_fill_after_a_bust_is_delivered_at_a_cumulative_seen_before() {
7727        let fill = |execution_id: &str| {
7728            format!(
7729                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"}}}}"#
7730            )
7731        };
7732        let events = stream_events(&[
7733            fill("exec-a"),
7734            amendment_frame(
7735                "trade_bust",
7736                r#""execution_id":"exec-x","previous_execution_id":"exec-a","qty":"-2","#,
7737            ),
7738            fill("exec-b"),
7739        ]);
7740
7741        let kinds: Vec<_> = events
7742            .iter()
7743            .filter_map(|event| match &event.kind {
7744                AccountEventKind::Trade(trade) => Some(format!("trade {}", trade.id)),
7745                AccountEventKind::TradeAmended(amendment) => {
7746                    Some(format!("amended {:?}", amendment.original))
7747                }
7748                _ => None,
7749            })
7750            .collect();
7751        assert_eq!(
7752            kinds,
7753            vec![
7754                "trade exec-a",
7755                "amended Some(TradeId(\"exec-a\"))",
7756                "trade exec-b"
7757            ]
7758        );
7759    }
7760
7761    /// Pins the string representation of `Decimal::ZERO.normalize()`, which is used
7762    /// as the dedup key fallback when `filled_qty` is unparseable: `"{order_id}:0"`.
7763    #[test]
7764    fn decimal_zero_normalize_is_zero_str() {
7765        assert_eq!(Decimal::ZERO.normalize().to_string(), "0");
7766    }
7767
7768    /// Verifies that trailing zeros are stripped by `normalize()`, ensuring dedup keys
7769    /// match regardless of whether Alpaca returns `"1.00"` or `"1"` for the same qty.
7770    /// This is the critical invariant for REST/WS dedup key equivalence.
7771    #[test]
7772    fn decimal_normalize_strips_trailing_zeros() {
7773        // Alpaca may return "1.00" in REST but WS cumulative may be "1" — must match.
7774        let from_rest = Decimal::from_str("1.00").unwrap().normalize();
7775        let from_ws = Decimal::from_str("1").unwrap().normalize();
7776        assert_eq!(from_rest.to_string(), from_ws.to_string());
7777        assert_eq!(from_rest.to_string(), "1");
7778
7779        // More edge cases: various trailing zero representations.
7780        assert_eq!(
7781            Decimal::from_str("100.000")
7782                .unwrap()
7783                .normalize()
7784                .to_string(),
7785            "100"
7786        );
7787        assert_eq!(
7788            Decimal::from_str("0.10").unwrap().normalize().to_string(),
7789            "0.1"
7790        );
7791        assert_eq!(
7792            Decimal::from_str("0.100").unwrap().normalize().to_string(),
7793            "0.1"
7794        );
7795    }
7796
7797    // M-2: map_position_intent derives intent from (reduce_only, side).
7798    #[test]
7799    fn map_position_intent_open_buy_maps_to_buy_to_open() {
7800        assert_eq!(
7801            map_position_intent(Side::Buy, false),
7802            AlpacaPositionIntent::BuyToOpen
7803        );
7804    }
7805
7806    #[test]
7807    fn map_position_intent_open_sell_maps_to_sell_to_open() {
7808        assert_eq!(
7809            map_position_intent(Side::Sell, false),
7810            AlpacaPositionIntent::SellToOpen
7811        );
7812    }
7813
7814    #[test]
7815    fn map_position_intent_reduce_buy_maps_to_buy_to_close() {
7816        assert_eq!(
7817            map_position_intent(Side::Buy, true),
7818            AlpacaPositionIntent::BuyToClose
7819        );
7820    }
7821
7822    #[test]
7823    fn map_position_intent_reduce_sell_maps_to_sell_to_close() {
7824        assert_eq!(
7825            map_position_intent(Side::Sell, true),
7826            AlpacaPositionIntent::SellToClose
7827        );
7828    }
7829
7830    // M-1: parse_order_error — pin all status-code branches not covered by existing tests.
7831    #[test]
7832    fn parse_order_error_401_maps_to_unauthenticated() {
7833        // 401 Unauthorized: invalid/expired API credentials.
7834        assert!(matches!(
7835            parse_order_error(reqwest::StatusCode::UNAUTHORIZED, None, "bad credentials"),
7836            UnindexedOrderError::Rejected(ApiError::Unauthenticated(_))
7837        ));
7838    }
7839
7840    #[test]
7841    fn parse_order_error_403_without_code_maps_to_unauthenticated() {
7842        // Alpaca's answer to a wrong key, secret or host: a bare `{"message": "forbidden."}`.
7843        assert!(matches!(
7844            parse_order_error(reqwest::StatusCode::FORBIDDEN, None, "forbidden."),
7845            UnindexedOrderError::Rejected(ApiError::Unauthenticated(_))
7846        ));
7847    }
7848
7849    #[test]
7850    fn parse_order_error_403_with_code_and_insufficient_maps_to_balance_insufficient() {
7851        for message in [
7852            "insufficient buying power",
7853            "insufficient day trading buying power",
7854            "insufficient qty available for order (requested: 10, available: 0)",
7855        ] {
7856            let err = parse_order_error(reqwest::StatusCode::FORBIDDEN, Some(40310000), message);
7857            let UnindexedOrderError::Rejected(ApiError::BalanceInsufficient(None, text)) = err
7858            else {
7859                panic!("expected BalanceInsufficient for {message:?}, got {err:?}");
7860            };
7861            assert_eq!(text, format!("40310000 {message}"));
7862        }
7863    }
7864
7865    #[test]
7866    fn parse_order_error_reads_a_refused_short_by_its_text() {
7867        for (status, code, message, reason) in [
7868            (
7869                reqwest::StatusCode::FORBIDDEN,
7870                Some(40310000),
7871                "account is not allowed to short",
7872                BorrowRejectReason::BorrowDisabled,
7873            ),
7874            // Which status Alpaca gives a non-shortable asset is unconfirmed, so both are read.
7875            (
7876                reqwest::StatusCode::FORBIDDEN,
7877                Some(40310000),
7878                "asset XYZ cannot be sold short",
7879                BorrowRejectReason::NotShortable,
7880            ),
7881            (
7882                reqwest::StatusCode::UNPROCESSABLE_ENTITY,
7883                Some(42210000),
7884                "asset XYZ cannot be sold short",
7885                BorrowRejectReason::NotShortable,
7886            ),
7887            (
7888                reqwest::StatusCode::UNPROCESSABLE_ENTITY,
7889                None,
7890                "account is not allowed to short",
7891                BorrowRejectReason::BorrowDisabled,
7892            ),
7893        ] {
7894            assert_eq!(
7895                parse_order_error(status, code, message),
7896                UnindexedOrderError::Rejected(ApiError::BorrowRejected(BorrowReject::new(
7897                    reason,
7898                    code.map(|code| code.to_string()),
7899                    message.to_owned(),
7900                ))),
7901                "{status} {message:?}"
7902            );
7903        }
7904
7905        // The asset may be shortable in whole shares: the order's shape was refused, not a
7906        // borrow.
7907        for (status, code) in [
7908            (reqwest::StatusCode::UNPROCESSABLE_ENTITY, 42210000),
7909            (reqwest::StatusCode::FORBIDDEN, 40310000),
7910        ] {
7911            assert_eq!(
7912                parse_order_error(status, Some(code), "fractional orders cannot be sold short"),
7913                UnindexedOrderError::Rejected(ApiError::OrderRejected(format!(
7914                    "{code} fractional orders cannot be sold short"
7915                ))),
7916                "{status}"
7917            );
7918        }
7919
7920        // A refused short wins over "insufficient" in the same text.
7921        assert!(matches!(
7922            parse_order_error(
7923                reqwest::StatusCode::FORBIDDEN,
7924                Some(40310000),
7925                "insufficient qty: account is not allowed to short",
7926            ),
7927            UnindexedOrderError::Rejected(ApiError::BorrowRejected(_))
7928        ));
7929
7930        // Without a code a 403 is a wrong key, whatever its text.
7931        assert!(matches!(
7932            parse_order_error(
7933                reqwest::StatusCode::FORBIDDEN,
7934                None,
7935                "account is not allowed to short"
7936            ),
7937            UnindexedOrderError::Rejected(ApiError::Unauthenticated(_))
7938        ));
7939    }
7940
7941    #[test]
7942    fn parse_order_error_403_with_code_maps_to_order_rejected() {
7943        for (code, message) in [
7944            (40310000, "account is restricted to liquidation only"),
7945            (
7946                40310100,
7947                "trade denied due to pattern day trading protection",
7948            ),
7949        ] {
7950            let err = parse_order_error(reqwest::StatusCode::FORBIDDEN, Some(code), message);
7951            let UnindexedOrderError::Rejected(ApiError::OrderRejected(text)) = err else {
7952                panic!("expected OrderRejected for {message:?}, got {err:?}");
7953            };
7954            assert_eq!(text, format!("{code} {message}"));
7955        }
7956    }
7957
7958    #[test]
7959    fn alpaca_api_error_reads_code_and_falls_back_to_raw_text() {
7960        let coded = AlpacaApiError::from_body(
7961            br#"{"code": 40310000, "message": "insufficient buying power"}"#,
7962        );
7963        assert_eq!(coded.code, Some(40310000));
7964        assert_eq!(coded.message, "insufficient buying power");
7965
7966        let bare = AlpacaApiError::from_body(br#"{"message": "forbidden."}"#);
7967        assert_eq!(bare.code, None);
7968        assert_eq!(bare.message, "forbidden.");
7969
7970        let raw = AlpacaApiError::from_body(b"upstream connect error");
7971        assert_eq!(raw.code, None);
7972        assert_eq!(raw.message, "upstream connect error");
7973    }
7974
7975    #[test]
7976    fn parse_order_error_404_maps_to_order_rejected_with_not_found_prefix() {
7977        let err = parse_order_error(reqwest::StatusCode::NOT_FOUND, None, "order not found");
7978        let UnindexedOrderError::Rejected(ApiError::OrderRejected(msg)) = err else {
7979            panic!("expected OrderRejected, got {err:?}");
7980        };
7981        assert!(
7982            msg.contains("order not found"),
7983            "message should contain 'order not found': {msg}"
7984        );
7985    }
7986
7987    #[test]
7988    fn parse_order_error_422_insufficient_only_maps_to_balance_insufficient() {
7989        // No "already" in body → must not match OrderAlreadyCancelled; must be BalanceInsufficient.
7990        assert!(matches!(
7991            parse_order_error(
7992                reqwest::StatusCode::UNPROCESSABLE_ENTITY,
7993                None,
7994                "insufficient funds for this order"
7995            ),
7996            UnindexedOrderError::Rejected(ApiError::BalanceInsufficient(None, _))
7997        ));
7998    }
7999
8000    #[test]
8001    fn parse_order_error_429_maps_to_rate_limit() {
8002        assert!(matches!(
8003            parse_order_error(reqwest::StatusCode::TOO_MANY_REQUESTS, None, "rate limited"),
8004            UnindexedOrderError::Rejected(ApiError::RateLimit {
8005                retry_after: None,
8006                ..
8007            })
8008        ));
8009    }
8010
8011    #[test]
8012    fn a_rate_limit_delay_is_capped_at_alpacas_window() {
8013        let window = Some(Duration::from_secs(DEFAULT_RATE_LIMIT_DELAY_SECS));
8014        for (name, value) in [("retry-after", u64::MAX), ("x-ratelimit-reset", u64::MAX)] {
8015            let mut headers = reqwest::header::HeaderMap::new();
8016            headers.insert(name, value.to_string().parse().unwrap());
8017            assert_eq!(parse_rate_limit_delay(&headers), window, "{name}");
8018            // Nor does it overflow the cooldown's deadline.
8019            RateLimitTracker::new().on_rate_limited(parse_rate_limit_delay(&headers));
8020        }
8021
8022        let mut headers = reqwest::header::HeaderMap::new();
8023        headers.insert("retry-after", "0".parse().unwrap());
8024        assert_eq!(
8025            parse_rate_limit_delay(&headers),
8026            Some(Duration::ZERO),
8027            "taken as given"
8028        );
8029    }
8030
8031    // M-4: parse_time_in_force — pin the unknown-value fallback to GoodUntilEndOfDay.
8032    // If Alpaca adds a new TIF (e.g. "opg" for at-the-open), orders continue to be
8033    // tracked with EOD expiry until this function is updated; the warn! makes it visible.
8034    #[test]
8035    fn parse_time_in_force_unknown_value_falls_back_to_good_until_end_of_day() {
8036        assert_eq!(
8037            parse_time_in_force("opg"),
8038            TimeInForce::GoodUntilEndOfDay,
8039            "unknown TIF must fall back to GoodUntilEndOfDay (with a warn! in production)"
8040        );
8041    }
8042
8043    // L-4: build_instrument_snapshots — output order must match the instruments slice,
8044    // not the internal IndexMap insertion order of the orders vec.
8045    #[test]
8046    fn build_instrument_snapshots_output_order_matches_instruments_slice() {
8047        // Orders arrive in symbol order: SPY, AAPL, MSFT.
8048        let orders = vec![
8049            make_order_response("o1", "SPY"),
8050            make_order_response("o2", "AAPL"),
8051            make_order_response("o3", "MSFT"),
8052        ];
8053        // Request a different order: MSFT first, then AAPL.
8054        let instruments = vec![
8055            InstrumentNameExchange::new("MSFT"),
8056            InstrumentNameExchange::new("AAPL"),
8057        ];
8058        let snapshots = build_instrument_snapshots(orders, Vec::new(), &instruments);
8059        assert_eq!(snapshots.len(), 2);
8060        assert_eq!(
8061            snapshots[0].instrument.name().as_str(),
8062            "MSFT",
8063            "first snapshot must be MSFT (first in instruments slice)"
8064        );
8065        assert_eq!(
8066            snapshots[1].instrument.name().as_str(),
8067            "AAPL",
8068            "second snapshot must be AAPL (second in instruments slice)"
8069        );
8070    }
8071
8072    // ---------------------------------------------------------------------------
8073    // HTTP-mocked tests — paginate_activities (H-1) and fetch_raw_open_orders (H-3)
8074    // ---------------------------------------------------------------------------
8075    //
8076    // These tests use wiremock to stand up a local HTTP server, verifying the full
8077    // pagination loop and truncation logic without touching the real Alpaca API.
8078    mod http_tests {
8079        use super::super::*;
8080        use wiremock::matchers::{method, path};
8081        use wiremock::{Mock, MockServer, Request, Respond, ResponseTemplate};
8082
8083        /// Serves pre-configured JSON pages in registration order.
8084        ///
8085        /// Each call to `respond` advances an atomic counter and returns the next
8086        /// page body. Panics if called more times than pages were configured —
8087        /// surfaces unexpected extra requests as an explicit test failure rather
8088        /// than silently returning a stale response.
8089        struct Sequential {
8090            call: std::sync::atomic::AtomicU32,
8091            pages: Vec<serde_json::Value>,
8092        }
8093
8094        impl Sequential {
8095            fn new(pages: Vec<serde_json::Value>) -> Self {
8096                Self {
8097                    call: std::sync::atomic::AtomicU32::new(0),
8098                    pages,
8099                }
8100            }
8101        }
8102
8103        impl Respond for Sequential {
8104            fn respond(&self, _: &Request) -> ResponseTemplate {
8105                let i = self.call.fetch_add(1, std::sync::atomic::Ordering::Relaxed) as usize;
8106                let body = self.pages.get(i).unwrap_or_else(|| {
8107                    panic!(
8108                        "Sequential: request #{i} has no configured response \
8109                         (only {} page(s) supplied)",
8110                        self.pages.len()
8111                    )
8112                });
8113                ResponseTemplate::new(200).set_body_json(body)
8114            }
8115        }
8116
8117        /// Build a JSON array of N minimal AlpacaActivity objects with unique IDs.
8118        fn make_activities_json(count: usize, id_prefix: &str) -> serde_json::Value {
8119            serde_json::Value::Array(
8120                (0..count)
8121                    .map(|i| {
8122                        serde_json::json!({
8123                            "id": format!("{id_prefix}-{i:05}"),
8124                            "order_id": "ord-1",
8125                            "symbol": "SPY",
8126                            "side": "buy",
8127                            "price": "100.00",
8128                            "qty": "1",
8129                            "transaction_time": "2025-04-18T14:30:00Z"
8130                        })
8131                    })
8132                    .collect(),
8133            )
8134        }
8135
8136        /// Build a JSON array of N minimal AlpacaOrderResponse objects with unique IDs.
8137        fn make_orders_json(count: usize) -> serde_json::Value {
8138            serde_json::Value::Array(
8139                (0..count)
8140                    .map(|i| {
8141                        serde_json::json!({
8142                            "id": format!("order-{i:05}"),
8143                            "client_order_id": null,
8144                            "symbol": "SPY",
8145                            "qty": "1",
8146                            "filled_qty": "0",
8147                            "side": "buy",
8148                            "type": "limit",
8149                            "time_in_force": "day",
8150                            "limit_price": "100.00",
8151                            "created_at": "2025-04-18T14:30:00Z"
8152                        })
8153                    })
8154                    .collect(),
8155            )
8156        }
8157
8158        // --- H-1: paginate_activities ---
8159
8160        /// Single page with fewer items than the page limit — loop terminates immediately,
8161        /// no further request issued.
8162        #[tokio::test]
8163        async fn paginate_activities_single_page_below_max_returns_all_not_truncated() {
8164            let server = MockServer::start().await;
8165            Mock::given(method("GET"))
8166                .and(path("/v2/account/activities"))
8167                .respond_with(
8168                    ResponseTemplate::new(200).set_body_json(make_activities_json(5, "act")),
8169                )
8170                .mount(&server)
8171                .await;
8172
8173            let http = reqwest::Client::new();
8174            let rl = RateLimitTracker::new();
8175            let result = paginate_activities::<AlpacaActivity>(
8176                &http,
8177                &rl,
8178                &server.uri(),
8179                FILL_ACTIVITIES,
8180                recovery_after(),
8181                None,
8182            )
8183            .await
8184            .unwrap();
8185
8186            assert_eq!(result.activities.len(), 5);
8187            assert_eq!(result.resume, None);
8188            assert_eq!(server.received_requests().await.unwrap().len(), 1);
8189        }
8190
8191        /// First page has exactly ALPACA_MAX_ACTIVITIES items, which triggers a second
8192        /// request. The second page is empty, so the loop terminates without truncation.
8193        #[tokio::test]
8194        async fn paginate_activities_exactly_page_size_items_fetches_second_page() {
8195            let server = MockServer::start().await;
8196            Mock::given(method("GET"))
8197                .and(path("/v2/account/activities"))
8198                .respond_with(Sequential::new(vec![
8199                    make_activities_json(ALPACA_MAX_ACTIVITIES, "act"),
8200                    serde_json::json!([]), // empty second page → loop stops
8201                ]))
8202                .mount(&server)
8203                .await;
8204
8205            let http = reqwest::Client::new();
8206            let rl = RateLimitTracker::new();
8207            let result = paginate_activities::<AlpacaActivity>(
8208                &http,
8209                &rl,
8210                &server.uri(),
8211                FILL_ACTIVITIES,
8212                recovery_after(),
8213                None,
8214            )
8215            .await
8216            .unwrap();
8217
8218            assert_eq!(result.activities.len(), ALPACA_MAX_ACTIVITIES);
8219            assert_eq!(result.resume, None);
8220            assert_eq!(
8221                server.received_requests().await.unwrap().len(),
8222                2,
8223                "exactly 2 requests: first full page + second empty page"
8224            );
8225        }
8226
8227        /// Two-page case: 100 items on page 1, 37 on page 2 — all accumulated,
8228        /// loop terminates on the partial second page without truncation.
8229        #[tokio::test]
8230        async fn paginate_activities_two_pages_returns_combined_activities_not_truncated() {
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, "p1"),
8236                    make_activities_json(37, "p2"),
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 + 37);
8255            assert_eq!(result.resume, None);
8256            assert_eq!(server.received_requests().await.unwrap().len(), 2);
8257        }
8258
8259        /// When every page is full the loop runs until MAX_ACTIVITY_PAGES pages have been
8260        /// fetched, then sets truncated=true and stops. Exactly MAX_ACTIVITY_PAGES HTTP
8261        /// requests are issued (the truncation guard fires before the (N+1)th call).
8262        #[tokio::test]
8263        async fn paginate_activities_at_max_pages_sets_truncated_true() {
8264            let server = MockServer::start().await;
8265
8266            // Always return a full page — the loop must enforce the cap itself.
8267            Mock::given(method("GET"))
8268                .and(path("/v2/account/activities"))
8269                .respond_with(
8270                    ResponseTemplate::new(200)
8271                        .set_body_json(make_activities_json(ALPACA_MAX_ACTIVITIES, "act")),
8272                )
8273                .mount(&server)
8274                .await;
8275
8276            let http = reqwest::Client::new();
8277            let rl = RateLimitTracker::new();
8278            let result = paginate_activities::<AlpacaActivity>(
8279                &http,
8280                &rl,
8281                &server.uri(),
8282                FILL_ACTIVITIES,
8283                recovery_after(),
8284                None,
8285            )
8286            .await
8287            .unwrap();
8288
8289            assert!(
8290                result.resume.is_some(),
8291                "must say where to read on after MAX_ACTIVITY_PAGES pages"
8292            );
8293            assert_eq!(
8294                result.activities.len(),
8295                MAX_ACTIVITY_PAGES * ALPACA_MAX_ACTIVITIES,
8296                "must accumulate exactly MAX_ACTIVITY_PAGES * page_size activities"
8297            );
8298            // The truncation check fires at the top of the loop when pages == MAX_ACTIVITY_PAGES,
8299            // before the (MAX_ACTIVITY_PAGES+1)th request would be issued.
8300            assert_eq!(
8301                server.received_requests().await.unwrap().len(),
8302                MAX_ACTIVITY_PAGES,
8303                "loop must issue exactly MAX_ACTIVITY_PAGES requests then stop"
8304            );
8305        }
8306
8307        // --- H-3: fetch_raw_open_orders truncation boundary ---
8308
8309        /// 499 orders (one below MAX_OPEN_ORDERS) is not truncated — returns Ok.
8310        #[tokio::test]
8311        async fn fetch_raw_open_orders_499_results_returns_ok() {
8312            let server = MockServer::start().await;
8313            Mock::given(method("GET"))
8314                .and(path("/v2/orders"))
8315                .respond_with(
8316                    ResponseTemplate::new(200).set_body_json(make_orders_json(MAX_OPEN_ORDERS - 1)),
8317                )
8318                .mount(&server)
8319                .await;
8320
8321            let http = reqwest::Client::new();
8322            let rl = RateLimitTracker::new();
8323            let result = fetch_raw_open_orders(&http, &rl, &server.uri(), &[]).await;
8324
8325            assert!(
8326                result.is_ok(),
8327                "499 orders must not trigger truncation: {result:?}"
8328            );
8329            assert_eq!(result.unwrap().len(), MAX_OPEN_ORDERS - 1);
8330        }
8331
8332        /// Exactly MAX_OPEN_ORDERS results triggers TruncatedSnapshot because Alpaca's
8333        /// API cap means the response is likely incomplete. An off-by-one here would
8334        /// either silently corrupt OMS state or incorrectly reject a valid account.
8335        #[tokio::test]
8336        async fn fetch_raw_open_orders_500_results_returns_truncated_snapshot_error() {
8337            let server = MockServer::start().await;
8338            Mock::given(method("GET"))
8339                .and(path("/v2/orders"))
8340                .respond_with(
8341                    ResponseTemplate::new(200).set_body_json(make_orders_json(MAX_OPEN_ORDERS)),
8342                )
8343                .mount(&server)
8344                .await;
8345
8346            let http = reqwest::Client::new();
8347            let rl = RateLimitTracker::new();
8348            let result = fetch_raw_open_orders(&http, &rl, &server.uri(), &[]).await;
8349
8350            assert!(
8351                matches!(
8352                    result,
8353                    Err(UnindexedClientError::TruncatedSnapshot { limit }) if limit == MAX_OPEN_ORDERS
8354                ),
8355                "500 orders must return TruncatedSnapshot, got: {result:?}"
8356            );
8357        }
8358
8359        // --- account_snapshot: cash balance, equity/option positions, crypto balances ---
8360
8361        /// Mounts `/v2/account`, `/v2/positions` and an empty `/v2/orders` on `server`.
8362        /// Synthetic values only; no provider data.
8363        async fn mount_account_and_positions(server: &MockServer) {
8364            Mock::given(method("GET"))
8365                .and(path("/v2/account"))
8366                .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
8367                    "status": "ACTIVE",
8368                    "currency": "USD",
8369                    "cash": "-1500.50",
8370                    "equity": "4000.00",
8371                    "buying_power": "5000.00",
8372                    "options_buying_power": "2000.00",
8373                    "non_marginable_buying_power": "1000.00",
8374                })))
8375                .mount(server)
8376                .await;
8377            Mock::given(method("GET"))
8378                .and(path("/v2/positions"))
8379                .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([
8380                    {
8381                        "symbol": "XYZ",
8382                        "asset_class": "us_equity",
8383                        "side": "short",
8384                        "qty": "-10",
8385                        "qty_available": "-10",
8386                        "avg_entry_price": "20.00",
8387                        "unrealized_pl": "15.50",
8388                    },
8389                    {
8390                        "symbol": "XYZ271217C00025000",
8391                        "asset_class": "us_option",
8392                        "side": "long",
8393                        "qty": "3",
8394                        "qty_available": "3",
8395                        "avg_entry_price": "0.85",
8396                        "unrealized_pl": "-45",
8397                    },
8398                    {
8399                        "symbol": "ABC",
8400                        "asset_class": "us_equity",
8401                        "side": "long",
8402                        "qty": "2.5",
8403                        "qty_available": "2.5",
8404                        "avg_entry_price": "40.00",
8405                        "unrealized_pl": "1.25",
8406                    },
8407                    {
8408                        "symbol": "BTCUSD",
8409                        "asset_class": "crypto",
8410                        "side": "long",
8411                        "qty": "0.75",
8412                        "qty_available": "0.5",
8413                        "avg_entry_price": "50000",
8414                        "unrealized_pl": "0",
8415                    },
8416                ])))
8417                .mount(server)
8418                .await;
8419            Mock::given(method("GET"))
8420                .and(path("/v2/orders"))
8421                .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([])))
8422                .mount(server)
8423                .await;
8424        }
8425
8426        fn client_for(server: &MockServer) -> AlpacaClient {
8427            AlpacaClient::new(AlpacaConfig::with_base_url(
8428                "test-key".into(),
8429                "test-secret".into(),
8430                server.uri(),
8431            ))
8432        }
8433
8434        #[tokio::test]
8435        async fn account_snapshot_reports_cash_positions_and_crypto_balances() {
8436            use crate::client::ExecutionClient;
8437            use rust_decimal_macros::dec;
8438
8439            let server = MockServer::start().await;
8440            mount_account_and_positions(&server).await;
8441
8442            let snapshot = client_for(&server)
8443                .account_snapshot(&[], &[])
8444                .await
8445                .unwrap();
8446
8447            // USD: cash (negative on margin), and free capped at it, not equity or buying power.
8448            let balances: Vec<_> = snapshot
8449                .balances
8450                .iter()
8451                .map(|b| (b.asset.name().as_str(), b.balance.total, b.balance.free))
8452                .collect();
8453            assert_eq!(
8454                balances,
8455                vec![
8456                    ("USD", dec!(-1500.50), dec!(-1500.50)),
8457                    ("BTC", dec!(0.75), dec!(0.5)),
8458                ],
8459                "crypto stays a balance; equities and options do not appear as balances"
8460            );
8461
8462            let positions: Vec<_> = snapshot
8463                .instruments
8464                .iter()
8465                .map(|i| {
8466                    let p = i.position.open().unwrap();
8467                    (
8468                        i.instrument.name().as_str(),
8469                        p.quantity,
8470                        p.entry_price,
8471                        p.unrealized_pnl,
8472                    )
8473                })
8474                .collect();
8475            assert_eq!(
8476                positions,
8477                vec![
8478                    ("XYZ", dec!(-10), Some(dec!(20.00)), Some(dec!(15.50))),
8479                    (
8480                        "XYZ271217C00025000",
8481                        dec!(3),
8482                        Some(dec!(0.85)),
8483                        Some(dec!(-45))
8484                    ),
8485                    ("ABC", dec!(2.5), Some(dec!(40.00)), Some(dec!(1.25))),
8486                ],
8487                "short equity, option and fractional positions; no crypto position"
8488            );
8489        }
8490
8491        /// A USD-only request still fetches positions, since they back the instrument
8492        /// snapshots, and reports no crypto balance.
8493        #[tokio::test]
8494        async fn account_snapshot_usd_only_still_reports_positions() {
8495            use crate::client::ExecutionClient;
8496            use rust_decimal_macros::dec;
8497
8498            let server = MockServer::start().await;
8499            mount_account_and_positions(&server).await;
8500
8501            let usd = [AssetNameExchange::new("USD")];
8502            let instruments = [InstrumentNameExchange::new("XYZ")];
8503            let snapshot = client_for(&server)
8504                .account_snapshot(&usd, &instruments)
8505                .await
8506                .unwrap();
8507
8508            assert_eq!(snapshot.balances.len(), 1);
8509            assert_eq!(snapshot.balances[0].asset, AssetNameExchange::new("USD"));
8510            assert_eq!(snapshot.balances[0].balance.total, dec!(-1500.50));
8511
8512            assert_eq!(snapshot.instruments.len(), 1);
8513            assert_eq!(snapshot.instruments[0].position.quantity(), Some(dec!(-10)));
8514        }
8515
8516        /// A non-USD request skips `/v2/account` entirely.
8517        #[tokio::test]
8518        async fn account_snapshot_non_usd_only_skips_account_request() {
8519            use crate::client::ExecutionClient;
8520
8521            let server = MockServer::start().await;
8522            mount_account_and_positions(&server).await;
8523
8524            let btc = [AssetNameExchange::new("btc")];
8525            let snapshot = client_for(&server)
8526                .account_snapshot(&btc, &[])
8527                .await
8528                .unwrap();
8529
8530            assert_eq!(snapshot.balances.len(), 1);
8531            assert_eq!(snapshot.balances[0].asset, AssetNameExchange::new("BTC"));
8532            let requests = server.received_requests().await.unwrap();
8533            assert!(
8534                requests.iter().all(|r| r.url.path() != "/v2/account"),
8535                "no USD requested, so /v2/account must not be fetched"
8536            );
8537        }
8538
8539        // --- L-2: open_order passes reduce_only through to position_intent ---
8540
8541        /// Verifies that `open_order` correctly derives `position_intent` from
8542        /// `reduce_only` and `side`. This test exercises the full path:
8543        /// `open_order` → `map_position_intent` → `open_order_inner` → HTTP request.
8544        ///
8545        /// Uses wiremock to capture the request body and verify position_intent.
8546        #[tokio::test]
8547        async fn open_order_reduce_only_sell_sends_sell_to_close_intent() {
8548            use crate::client::ExecutionClient;
8549            use crate::order::request::{OrderRequestOpen, RequestOpen};
8550            use crate::order::{
8551                OrderKey, OrderKind, TimeInForce,
8552                id::{ClientOrderId, StrategyId},
8553            };
8554            use rust_decimal::Decimal;
8555            use rustrade_instrument::Side;
8556            use rustrade_instrument::exchange::ExchangeId;
8557            use rustrade_instrument::instrument::name::InstrumentNameExchange;
8558            use wiremock::matchers::{method, path};
8559
8560            let server = MockServer::start().await;
8561
8562            // Mock POST /v2/orders to return a valid order response.
8563            // Use a custom responder to capture and verify the request body.
8564            let captured_body = std::sync::Arc::new(parking_lot::Mutex::new(None));
8565            let captured_clone = captured_body.clone();
8566
8567            Mock::given(method("POST"))
8568                .and(path("/v2/orders"))
8569                .respond_with(move |req: &Request| {
8570                    // Capture the request body for later assertion
8571                    let body: serde_json::Value = serde_json::from_slice(&req.body).unwrap();
8572                    *captured_clone.lock() = Some(body);
8573
8574                    ResponseTemplate::new(200).set_body_json(serde_json::json!({
8575                        "id": "test-order-id",
8576                        "client_order_id": "test-cid",
8577                        "symbol": "AAPL",
8578                        "qty": "10",
8579                        "filled_qty": "0",
8580                        "side": "sell",
8581                        "type": "market",
8582                        "time_in_force": "ioc",
8583                        "limit_price": null,
8584                        "created_at": "2025-04-18T14:30:00Z"
8585                    }))
8586                })
8587                .mount(&server)
8588                .await;
8589
8590            // Create client with base_url_override pointing to mock server
8591            let config =
8592                AlpacaConfig::with_base_url("test-key".into(), "test-secret".into(), server.uri());
8593            let client = AlpacaClient::new(config);
8594
8595            // Create a Sell order with reduce_only=true (should map to SellToClose)
8596            let request = OrderRequestOpen {
8597                key: OrderKey {
8598                    exchange: ExchangeId::AlpacaBroker,
8599                    instrument: InstrumentNameExchange::new("AAPL"),
8600                    strategy: StrategyId::new("test-strategy"),
8601                    cid: ClientOrderId::new("test-cid"),
8602                },
8603                state: RequestOpen {
8604                    side: Side::Sell,
8605                    price: None,
8606                    quantity: Decimal::new(10, 0),
8607                    kind: OrderKind::Market,
8608                    time_in_force: TimeInForce::ImmediateOrCancel,
8609                    position_id: None,
8610                    reduce_only: true, // This should map to SellToClose
8611                    market: None,
8612                },
8613            };
8614
8615            // Call open_order (borrows instrument)
8616            let order = client
8617                .open_order(OrderRequestOpen {
8618                    key: OrderKey {
8619                        exchange: request.key.exchange,
8620                        instrument: &request.key.instrument,
8621                        strategy: request.key.strategy.clone(),
8622                        cid: request.key.cid.clone(),
8623                    },
8624                    state: request.state.clone(),
8625                })
8626                .await;
8627
8628            // Verify the order was accepted
8629            assert!(
8630                order.state.is_accepted(),
8631                "order should be accepted: {:?}",
8632                order.state
8633            );
8634
8635            // Verify the request body contained position_intent=sell_to_close
8636            let body = captured_body
8637                .lock()
8638                .take()
8639                .expect("request body should be captured");
8640            assert_eq!(
8641                body.get("position_intent").and_then(|v| v.as_str()),
8642                Some("sell_to_close"),
8643                "reduce_only=true + Side::Sell should produce position_intent=sell_to_close, got: {body}"
8644            );
8645        }
8646
8647        /// Verifies that reduce_only=false + Buy produces BuyToOpen intent.
8648        #[tokio::test]
8649        async fn open_order_not_reduce_only_buy_sends_buy_to_open_intent() {
8650            use crate::client::ExecutionClient;
8651            use crate::order::request::{OrderRequestOpen, RequestOpen};
8652            use crate::order::{
8653                OrderKey, OrderKind, TimeInForce,
8654                id::{ClientOrderId, StrategyId},
8655            };
8656            use rust_decimal::Decimal;
8657            use rustrade_instrument::Side;
8658            use rustrade_instrument::exchange::ExchangeId;
8659            use rustrade_instrument::instrument::name::InstrumentNameExchange;
8660
8661            let server = MockServer::start().await;
8662
8663            let captured_body = std::sync::Arc::new(parking_lot::Mutex::new(None));
8664            let captured_clone = captured_body.clone();
8665
8666            Mock::given(method("POST"))
8667                .and(path("/v2/orders"))
8668                .respond_with(move |req: &Request| {
8669                    let body: serde_json::Value = serde_json::from_slice(&req.body).unwrap();
8670                    *captured_clone.lock() = Some(body);
8671
8672                    ResponseTemplate::new(200).set_body_json(serde_json::json!({
8673                        "id": "test-order-id",
8674                        "client_order_id": "test-cid",
8675                        "symbol": "AAPL",
8676                        "qty": "10",
8677                        "filled_qty": "0",
8678                        "side": "buy",
8679                        "type": "market",
8680                        "time_in_force": "ioc",
8681                        "limit_price": null,
8682                        "created_at": "2025-04-18T14:30:00Z"
8683                    }))
8684                })
8685                .mount(&server)
8686                .await;
8687
8688            let config =
8689                AlpacaConfig::with_base_url("test-key".into(), "test-secret".into(), server.uri());
8690            let client = AlpacaClient::new(config);
8691
8692            let instrument = InstrumentNameExchange::new("AAPL");
8693            let request = OrderRequestOpen {
8694                key: OrderKey {
8695                    exchange: ExchangeId::AlpacaBroker,
8696                    instrument: &instrument,
8697                    strategy: StrategyId::new("test-strategy"),
8698                    cid: ClientOrderId::new("test-cid"),
8699                },
8700                state: RequestOpen {
8701                    side: Side::Buy,
8702                    price: None,
8703                    quantity: Decimal::new(10, 0),
8704                    kind: OrderKind::Market,
8705                    time_in_force: TimeInForce::ImmediateOrCancel,
8706                    position_id: None,
8707                    reduce_only: false, // This should map to BuyToOpen
8708                    market: None,
8709                },
8710            };
8711
8712            let order = client.open_order(request).await;
8713
8714            assert!(
8715                order.state.is_accepted(),
8716                "order should be accepted: {:?}",
8717                order.state
8718            );
8719
8720            let body = captured_body
8721                .lock()
8722                .take()
8723                .expect("request body should be captured");
8724            assert_eq!(
8725                body.get("position_intent").and_then(|v| v.as_str()),
8726                Some("buy_to_open"),
8727                "reduce_only=false + Side::Buy should produce position_intent=buy_to_open, got: {body}"
8728            );
8729        }
8730
8731        /// A 2xx body that does not decode is not transient, so a query reports it as `Internal`
8732        /// rather than as connectivity a caller would retry.
8733        #[tokio::test]
8734        async fn rest_with_retry_reports_an_undecodable_2xx_body_as_internal() {
8735            let server = MockServer::start().await;
8736            Mock::given(method("GET"))
8737                .and(path("/v2/account"))
8738                .respond_with(ResponseTemplate::new(200).set_body_string("not json"))
8739                .mount(&server)
8740                .await;
8741
8742            let http = reqwest::Client::new();
8743            let rl = RateLimitTracker::new();
8744            let url = format!("{}/v2/account", server.uri());
8745            let result: Result<serde_json::Value, _> =
8746                rest_with_retry(&rl, || http.get(&url)).await;
8747            assert!(
8748                matches!(result, Err(UnindexedClientError::Internal(_))),
8749                "{result:?}"
8750            );
8751        }
8752
8753        /// Whether `rl` still holds requests back after 50 ms.
8754        async fn holds_back(rl: &RateLimitTracker) -> bool {
8755            tokio::time::timeout(Duration::from_millis(50), rl.wait_if_blocked())
8756                .await
8757                .is_err()
8758        }
8759
8760        /// The rate-limit headers of a response: `remaining`, and a reset 30 s ahead if `reset`.
8761        fn with_rate_limit(
8762            template: ResponseTemplate,
8763            remaining: u32,
8764            reset: bool,
8765        ) -> ResponseTemplate {
8766            let template = template.insert_header("x-ratelimit-remaining", remaining.to_string());
8767            if !reset {
8768                return template;
8769            }
8770            let now_secs = std::time::SystemTime::now()
8771                .duration_since(std::time::UNIX_EPOCH)
8772                .unwrap()
8773                .as_secs();
8774            template.insert_header("x-ratelimit-reset", (now_secs + 30).to_string())
8775        }
8776
8777        /// A response reporting no requests left holds every later request back until the reset;
8778        /// one with requests left, or with no reset to wait for, does not.
8779        #[tokio::test]
8780        async fn rest_with_retry_pauses_once_no_requests_remain() {
8781            for (remaining, reset, pauses) in [(0, true, true), (1, true, false), (0, false, false)]
8782            {
8783                let server = MockServer::start().await;
8784                Mock::given(method("GET"))
8785                    .and(path("/v2/account"))
8786                    .respond_with(with_rate_limit(
8787                        ResponseTemplate::new(200).set_body_json(serde_json::json!({})),
8788                        remaining,
8789                        reset,
8790                    ))
8791                    .mount(&server)
8792                    .await;
8793
8794                let http = reqwest::Client::new();
8795                let rl = RateLimitTracker::new();
8796                let url = format!("{}/v2/account", server.uri());
8797                let _: serde_json::Value = rest_with_retry(&rl, || http.get(&url)).await.unwrap();
8798                assert_eq!(
8799                    holds_back(&rl).await,
8800                    pauses,
8801                    "remaining {remaining}, reset {reset}"
8802                );
8803            }
8804        }
8805
8806        /// A reset further out than Alpaca's one-minute window holds requests back for a minute
8807        /// at most.
8808        #[tokio::test]
8809        async fn a_pause_lasts_a_minute_at_most() {
8810            tokio::time::pause();
8811            let now_secs = std::time::SystemTime::now()
8812                .duration_since(std::time::UNIX_EPOCH)
8813                .unwrap()
8814                .as_secs();
8815            let mut headers = reqwest::header::HeaderMap::new();
8816            headers.insert("x-ratelimit-remaining", "0".parse().unwrap());
8817            headers.insert(
8818                "x-ratelimit-reset",
8819                (now_secs + 3_600).to_string().parse().unwrap(),
8820            );
8821            let rl = RateLimitTracker::new();
8822
8823            observe_rate_limit_remaining(&rl, &headers);
8824            assert!(holds_back(&rl).await);
8825            tokio::time::advance(Duration::from_secs(DEFAULT_RATE_LIMIT_DELAY_SECS)).await;
8826            assert!(!holds_back(&rl).await);
8827        }
8828
8829        /// A cancel's response reporting no requests left holds later requests back too.
8830        #[tokio::test]
8831        async fn rest_delete_with_retry_pauses_once_no_requests_remain() {
8832            let server = MockServer::start().await;
8833            Mock::given(method("DELETE"))
8834                .and(path("/v2/orders/abc"))
8835                .respond_with(with_rate_limit(ResponseTemplate::new(204), 0, true))
8836                .mount(&server)
8837                .await;
8838
8839            let http = reqwest::Client::new();
8840            let rl = RateLimitTracker::new();
8841            let url = format!("{}/v2/orders/abc", server.uri());
8842            rest_delete_with_retry(&rl, || http.delete(&url))
8843                .await
8844                .unwrap();
8845            assert!(holds_back(&rl).await);
8846        }
8847
8848        /// An order Alpaca accepted (2xx) but whose response does not decode may be live, so it
8849        /// fails as status unknown (`Connectivity`), never as a rejection, and does not panic.
8850        #[tokio::test]
8851        async fn open_order_with_an_undecodable_2xx_response_is_status_unknown() {
8852            use crate::client::ExecutionClient;
8853            use crate::error::OrderError;
8854            use crate::order::request::{OrderRequestOpen, RequestOpen};
8855            use crate::order::state::{InactiveOrderState, OrderState};
8856            use crate::order::{
8857                OrderKey, OrderKind, TimeInForce,
8858                id::{ClientOrderId, StrategyId},
8859            };
8860            use rustrade_instrument::Side;
8861            use rustrade_instrument::exchange::ExchangeId;
8862            use rustrade_instrument::instrument::name::InstrumentNameExchange;
8863
8864            let server = MockServer::start().await;
8865            Mock::given(method("POST"))
8866                .and(path("/v2/orders"))
8867                .respond_with(
8868                    ResponseTemplate::new(200).set_body_json(serde_json::json!({ "id": 1 })),
8869                )
8870                .mount(&server)
8871                .await;
8872
8873            let instrument = InstrumentNameExchange::new("AAPL");
8874            let order = client_for(&server)
8875                .open_order(OrderRequestOpen {
8876                    key: OrderKey {
8877                        exchange: ExchangeId::AlpacaBroker,
8878                        instrument: &instrument,
8879                        strategy: StrategyId::new("test-strategy"),
8880                        cid: ClientOrderId::new("test-cid"),
8881                    },
8882                    state: RequestOpen {
8883                        side: Side::Buy,
8884                        price: None,
8885                        quantity: Decimal::new(10, 0),
8886                        kind: OrderKind::Market,
8887                        time_in_force: TimeInForce::ImmediateOrCancel,
8888                        position_id: None,
8889                        reduce_only: false,
8890                        market: None,
8891                    },
8892                })
8893                .await;
8894
8895            assert!(
8896                matches!(
8897                    order.state,
8898                    OrderState::Inactive(InactiveOrderState::OpenFailed(OrderError::Connectivity(
8899                        _
8900                    )))
8901                ),
8902                "{:?}",
8903                order.state
8904            );
8905        }
8906
8907        /// A 403 refusal, or a 422 refused short, reaches the order's state as the refusal it is,
8908        /// with Alpaca's code, and a 403 without a code (a wrong key) as an authentication failure.
8909        #[tokio::test]
8910        async fn open_order_reports_a_403_by_its_code() {
8911            use crate::client::ExecutionClient;
8912            use crate::error::OrderError;
8913            use crate::order::request::{OrderRequestOpen, RequestOpen};
8914            use crate::order::state::{InactiveOrderState, OrderState};
8915            use crate::order::{
8916                OrderKey, OrderKind, TimeInForce,
8917                id::{ClientOrderId, StrategyId},
8918            };
8919            use rustrade_instrument::Side;
8920            use rustrade_instrument::exchange::ExchangeId;
8921            use rustrade_instrument::instrument::name::InstrumentNameExchange;
8922
8923            async fn open_against(
8924                status: u16,
8925                body: serde_json::Value,
8926            ) -> OrderState<AssetNameExchange, InstrumentNameExchange> {
8927                let server = MockServer::start().await;
8928                Mock::given(method("POST"))
8929                    .and(path("/v2/orders"))
8930                    .respond_with(ResponseTemplate::new(status).set_body_json(body))
8931                    .mount(&server)
8932                    .await;
8933                let instrument = InstrumentNameExchange::new("AAPL");
8934                client_for(&server)
8935                    .open_order(OrderRequestOpen {
8936                        key: OrderKey {
8937                            exchange: ExchangeId::AlpacaBroker,
8938                            instrument: &instrument,
8939                            strategy: StrategyId::new("test-strategy"),
8940                            cid: ClientOrderId::new("test-cid"),
8941                        },
8942                        state: RequestOpen {
8943                            side: Side::Sell,
8944                            price: None,
8945                            quantity: Decimal::new(10, 0),
8946                            kind: OrderKind::Market,
8947                            time_in_force: TimeInForce::ImmediateOrCancel,
8948                            position_id: None,
8949                            reduce_only: false,
8950                            market: None,
8951                        },
8952                    })
8953                    .await
8954                    .state
8955            }
8956
8957            let rejected =
8958                |state: &OrderState<AssetNameExchange, InstrumentNameExchange>| match state {
8959                    OrderState::Inactive(InactiveOrderState::OpenFailed(OrderError::Rejected(
8960                        e,
8961                    ))) => e.clone(),
8962                    other => panic!("expected a rejection, got {other:?}"),
8963                };
8964
8965            let state = open_against(
8966                403,
8967                serde_json::json!({
8968                    "code": 40310000,
8969                    "message": "insufficient buying power"
8970                }),
8971            )
8972            .await;
8973            assert_eq!(
8974                rejected(&state),
8975                ApiError::BalanceInsufficient(None, "40310000 insufficient buying power".into())
8976            );
8977
8978            let state = open_against(
8979                403,
8980                serde_json::json!({
8981                    "code": 40310000,
8982                    "message": "account is not allowed to short"
8983                }),
8984            )
8985            .await;
8986            assert_eq!(
8987                rejected(&state),
8988                ApiError::BorrowRejected(BorrowReject::new(
8989                    BorrowRejectReason::BorrowDisabled,
8990                    Some("40310000".into()),
8991                    "account is not allowed to short".into(),
8992                ))
8993            );
8994
8995            // Which status Alpaca gives a non-shortable asset is unconfirmed; a 422 is read too.
8996            let state = open_against(
8997                422,
8998                serde_json::json!({
8999                    "code": 42210000,
9000                    "message": "asset XYZ cannot be sold short"
9001                }),
9002            )
9003            .await;
9004            assert_eq!(
9005                rejected(&state),
9006                ApiError::BorrowRejected(BorrowReject::new(
9007                    BorrowRejectReason::NotShortable,
9008                    Some("42210000".into()),
9009                    "asset XYZ cannot be sold short".into(),
9010                ))
9011            );
9012
9013            let state = open_against(403, serde_json::json!({ "message": "forbidden." })).await;
9014            assert!(
9015                matches!(rejected(&state), ApiError::Unauthenticated(_)),
9016                "{state:?}"
9017            );
9018        }
9019
9020        /// The bracket path maps an accepted (2xx) but unreadable response the same way: the
9021        /// parent fails as status unknown (`Connectivity`), never as a rejection.
9022        #[tokio::test]
9023        async fn open_bracket_order_with_an_undecodable_2xx_response_is_status_unknown() {
9024            use crate::error::OrderError;
9025            use crate::order::state::{InactiveOrderState, OrderState};
9026            use rustrade_instrument::instrument::name::InstrumentNameExchange;
9027
9028            let server = MockServer::start().await;
9029            Mock::given(method("POST"))
9030                .and(path("/v2/orders"))
9031                .respond_with(
9032                    ResponseTemplate::new(200).set_body_json(serde_json::json!({ "id": 1 })),
9033                )
9034                .mount(&server)
9035                .await;
9036
9037            let result = client_for(&server)
9038                .open_bracket_order(AlpacaBracketOrderRequest::new(
9039                    InstrumentNameExchange::new("SPY"),
9040                    crate::order::id::StrategyId::new("test"),
9041                    crate::order::id::ClientOrderId::new("test-bracket"),
9042                    Side::Buy,
9043                    Decimal::ONE,
9044                    Decimal::new(100, 0),
9045                    Decimal::new(120, 0),
9046                    Decimal::new(90, 0),
9047                    TimeInForce::GoodUntilCancelled { post_only: false },
9048                ))
9049                .await;
9050
9051            assert!(
9052                matches!(
9053                    result.parent.state,
9054                    OrderState::Inactive(InactiveOrderState::OpenFailed(OrderError::Connectivity(
9055                        _
9056                    )))
9057                ),
9058                "{:?}",
9059                result.parent.state
9060            );
9061        }
9062
9063        // -----------------------------------------------------------------------
9064        // recover_fills — what a recovered fill reports, and how it is keyed
9065        // -----------------------------------------------------------------------
9066        //
9067        // These drive `recover_fills` itself rather than re-deriving its arithmetic in the test.
9068        // A key that is correct in isolation is worth nothing if the function does not produce it.
9069
9070        /// The disconnect anchor the recovery tests read from.
9071        fn recovery_after() -> DateTime<Utc> {
9072            "2025-01-01T00:00:00Z".parse().expect("valid time")
9073        }
9074
9075        /// One FILL activity as Alpaca serves it. `cum_qty` is omitted entirely when `None`, which
9076        /// is how the pre-existing fallback path is reached.
9077        fn activity_json(
9078            id: &str,
9079            order_id: &str,
9080            qty: &str,
9081            cum_qty: Option<&str>,
9082        ) -> serde_json::Value {
9083            let mut v = serde_json::json!({
9084                "id": id,
9085                "order_id": order_id,
9086                "symbol": "SPY",
9087                "side": "buy",
9088                "price": "100.00",
9089                "qty": qty,
9090                "transaction_time": "2025-04-18T14:30:00Z"
9091            });
9092            if let Some(c) = cum_qty {
9093                v["cum_qty"] = serde_json::Value::String(c.to_string());
9094            }
9095            v
9096        }
9097
9098        /// Serve `activities` from a mock and run `recover_fills` against it, returning every
9099        /// event it forwarded.
9100        async fn drive_recover_fills_with(
9101            activities: Vec<serde_json::Value>,
9102            dedup: SharedDedupCache,
9103            known: &SharedKnownLiveOrders,
9104        ) -> Vec<UnindexedAccountEvent> {
9105            let server = MockServer::start().await;
9106            Mock::given(method("GET"))
9107                .and(path("/v2/account/activities"))
9108                .respond_with(
9109                    ResponseTemplate::new(200).set_body_json(serde_json::Value::Array(activities)),
9110                )
9111                .mount(&server)
9112                .await;
9113
9114            let http = reqwest::Client::new();
9115            let rl = RateLimitTracker::new();
9116            let (tx, mut rx) = mpsc::unbounded_channel();
9117            let outcome = recover_fills(
9118                &http,
9119                &rl,
9120                &[],
9121                &server.uri(),
9122                recovery_after(),
9123                &tx,
9124                &dedup,
9125                known,
9126            )
9127            .await;
9128            assert_eq!(outcome, Ok(()), "a full read leaves nothing unrecovered");
9129            drop(tx);
9130
9131            let mut out = Vec::new();
9132            while let Ok(event) = rx.try_recv() {
9133                out.push(event);
9134            }
9135            out
9136        }
9137
9138        async fn drive_recover_fills(
9139            activities: Vec<serde_json::Value>,
9140        ) -> Vec<UnindexedAccountEvent> {
9141            drive_recover_fills_with(
9142                activities,
9143                new_dedup_cache(),
9144                &KnownLiveOrders::shared(ExchangeId::AlpacaBroker),
9145            )
9146            .await
9147        }
9148
9149        /// Run `recover_fills` for every instrument against `server`, returning what it left
9150        /// unread and every event it forwarded.
9151        async fn recover_fills_from(
9152            server: &MockServer,
9153        ) -> (Result<(), UnreadFills>, Vec<UnindexedAccountEvent>) {
9154            let (tx, mut rx) = mpsc::unbounded_channel();
9155            let outcome = recover_fills(
9156                &reqwest::Client::new(),
9157                &RateLimitTracker::new(),
9158                &[],
9159                &server.uri(),
9160                recovery_after(),
9161                &tx,
9162                &new_dedup_cache(),
9163                &KnownLiveOrders::shared(ExchangeId::AlpacaBroker),
9164            )
9165            .await;
9166            drop(tx);
9167            (outcome, std::iter::from_fn(|| rx.try_recv().ok()).collect())
9168        }
9169
9170        /// A recovery read that fails delivers nothing and leaves every fill since the disconnect
9171        /// unread, with the request's error.
9172        #[tokio::test]
9173        async fn a_failed_recovery_read_leaves_every_fill_since_the_disconnect_unread() {
9174            let server = MockServer::start().await;
9175            Mock::given(method("GET"))
9176                .and(path("/v2/account/activities"))
9177                .respond_with(ResponseTemplate::new(500).set_body_string("unavailable"))
9178                .mount(&server)
9179                .await;
9180
9181            let (outcome, events) = recover_fills_from(&server).await;
9182
9183            let Err(UnreadFills {
9184                start,
9185                reason: FillRecoveryFailure::Request(_),
9186            }) = outcome
9187            else {
9188                panic!("expected the request's failure, got {outcome:?}");
9189            };
9190            assert_eq!(start, recovery_after(), "from the disconnect");
9191            assert!(events.is_empty(), "nothing forwarded: {events:?}");
9192        }
9193
9194        /// A recovery read that stops at the page cap delivers the fills it read, then leaves
9195        /// those from the millisecond of the last one on unread: Alpaca orders a millisecond's
9196        /// fills by id, so the cut may have passed over an earlier one in it. A last time that
9197        /// does not parse falls back to the latest that does, and none parsing to the disconnect.
9198        #[tokio::test]
9199        async fn a_truncated_recovery_read_delivers_what_it_read_and_leaves_the_rest_unread() {
9200            let time = |s: &str| s.parse::<DateTime<Utc>>().expect("valid time");
9201            // The page's other activities are at 14:30:00.
9202            let cases = [
9203                (
9204                    Some("2025-04-18T14:30:01.234567Z"),
9205                    time("2025-04-18T14:30:01.234Z"),
9206                ),
9207                (Some("not a time"), time("2025-04-18T14:30:00Z")),
9208                (None, recovery_after()),
9209            ];
9210            for (last_time, expected_start) in cases {
9211                let mut page = make_activities_json(ALPACA_MAX_ACTIVITIES, "act");
9212                let Some(activities) = page.as_array_mut() else {
9213                    panic!("an array of activities");
9214                };
9215                match last_time {
9216                    Some(last_time) => {
9217                        if let Some(last) = activities.last_mut() {
9218                            last["transaction_time"] =
9219                                serde_json::Value::String(last_time.to_string());
9220                        }
9221                    }
9222                    None => {
9223                        for activity in activities.iter_mut() {
9224                            activity["transaction_time"] =
9225                                serde_json::Value::String("not a time".to_string());
9226                        }
9227                    }
9228                }
9229                let server = MockServer::start().await;
9230                Mock::given(method("GET"))
9231                    .and(path("/v2/account/activities"))
9232                    .respond_with(ResponseTemplate::new(200).set_body_json(page))
9233                    .mount(&server)
9234                    .await;
9235
9236                let (outcome, events) = recover_fills_from(&server).await;
9237
9238                let fills_read = MAX_ACTIVITY_PAGES * ALPACA_MAX_ACTIVITIES;
9239                assert_eq!(
9240                    outcome,
9241                    Err(UnreadFills {
9242                        start: expected_start,
9243                        reason: FillRecoveryFailure::Truncated { fills_read },
9244                    }),
9245                    "last time {last_time:?}"
9246                );
9247                assert_eq!(events.len(), fills_read, "every fill read is forwarded");
9248            }
9249        }
9250
9251        /// One account-wide read serves every instrument the stream covers, so one give-up names
9252        /// them all: the stream's list, or every instrument when it has none. It was read once.
9253        #[test]
9254        fn a_fill_recovery_give_up_covers_every_instrument_the_stream_does() {
9255            let start = recovery_after();
9256            let end = start + chrono::Duration::minutes(5);
9257            let spy = InstrumentNameExchange::new("SPY");
9258            let qqq = InstrumentNameExchange::new("QQQ");
9259            let cases = [
9260                (vec![], FillRecoveryScope::AllInstruments),
9261                (
9262                    vec![spy.clone(), qqq.clone()],
9263                    FillRecoveryScope::Instruments(vec![spy, qqq]),
9264                ),
9265            ];
9266            for (instruments, scope) in cases {
9267                let reason = FillRecoveryFailure::TimedOut { timeout_secs: 30 };
9268                let unread = UnreadFills {
9269                    start,
9270                    reason: reason.clone(),
9271                };
9272                assert_eq!(
9273                    fill_recovery_gave_up(&instruments, unread, end),
9274                    UnindexedAccountEvent::new(
9275                        ExchangeId::AlpacaBroker,
9276                        AccountEventKind::FillRecoveryGaveUp(FillRecoveryGap::new(
9277                            scope, start, end, 1, reason,
9278                        )),
9279                    )
9280                );
9281            }
9282        }
9283
9284        /// A truncated read can return fills stamped after the moment recovery began, by a venue
9285        /// clock ahead of this host's. The give-up is still sent, its span ending at its start
9286        /// rather than before it.
9287        #[test]
9288        fn a_read_that_reached_past_the_recovery_start_still_reports() {
9289            let end = recovery_after();
9290            let start = end + chrono::Duration::seconds(1);
9291            let unread = UnreadFills {
9292                start,
9293                reason: FillRecoveryFailure::Truncated { fills_read: 5_000 },
9294            };
9295            let event = fill_recovery_gave_up(&[], unread, end);
9296            let AccountEventKind::FillRecoveryGaveUp(gave_up) = event.kind else {
9297                panic!("expected FillRecoveryGaveUp, got {event:?}");
9298            };
9299            assert_eq!((gave_up.start, gave_up.end), (start, start));
9300        }
9301
9302        /// Run `recover_fills_or_report` for `instruments` against `server` within `timeout`,
9303        /// returning every event it sent.
9304        async fn recover_fills_or_report_from(
9305            server: &MockServer,
9306            instruments: &[InstrumentNameExchange],
9307            timeout: Duration,
9308        ) -> Vec<UnindexedAccountEvent> {
9309            let (tx, mut rx) = mpsc::unbounded_channel();
9310            recover_fills_or_report(
9311                &reqwest::Client::new(),
9312                &RateLimitTracker::new(),
9313                instruments,
9314                &server.uri(),
9315                recovery_after(),
9316                timeout,
9317                &tx,
9318                &new_dedup_cache(),
9319                &KnownLiveOrders::shared(ExchangeId::AlpacaBroker),
9320            )
9321            .await;
9322            drop(tx);
9323            std::iter::from_fn(|| rx.try_recv().ok()).collect()
9324        }
9325
9326        /// A recovery that times out forwards nothing and reports every fill since the disconnect,
9327        /// for the stream's instruments.
9328        #[tokio::test]
9329        async fn a_timed_out_recovery_reports_every_fill_since_the_disconnect() {
9330            let server = MockServer::start().await;
9331            Mock::given(method("GET"))
9332                .and(path("/v2/account/activities"))
9333                .respond_with(
9334                    ResponseTemplate::new(200)
9335                        .set_body_json(serde_json::json!([]))
9336                        .set_delay(Duration::from_secs(3_600)),
9337                )
9338                .mount(&server)
9339                .await;
9340            let spy = InstrumentNameExchange::new("SPY");
9341
9342            let events = recover_fills_or_report_from(
9343                &server,
9344                std::slice::from_ref(&spy),
9345                Duration::from_millis(50),
9346            )
9347            .await;
9348
9349            let [event] = events.as_slice() else {
9350                panic!("only the give-up is sent: {events:?}");
9351            };
9352            let AccountEventKind::FillRecoveryGaveUp(gave_up) = &event.kind else {
9353                panic!("expected FillRecoveryGaveUp, got {event:?}");
9354            };
9355            assert_eq!(gave_up.scope, FillRecoveryScope::Instruments(vec![spy]));
9356            assert_eq!(gave_up.start, recovery_after(), "from the disconnect");
9357            assert!(gave_up.end >= gave_up.start);
9358            assert_eq!(gave_up.attempts, 1);
9359            assert!(
9360                matches!(gave_up.reason, FillRecoveryFailure::TimedOut { .. }),
9361                "{:?}",
9362                gave_up.reason
9363            );
9364        }
9365
9366        /// A truncated recovery sends every fill it read before the give-up, so a consumer sees the
9367        /// report after what was delivered.
9368        #[tokio::test]
9369        async fn a_truncated_recovery_reports_after_the_fills_it_read() {
9370            let server = MockServer::start().await;
9371            Mock::given(method("GET"))
9372                .and(path("/v2/account/activities"))
9373                .respond_with(
9374                    ResponseTemplate::new(200)
9375                        .set_body_json(make_activities_json(ALPACA_MAX_ACTIVITIES, "act")),
9376                )
9377                .mount(&server)
9378                .await;
9379
9380            let events = recover_fills_or_report_from(&server, &[], Duration::from_secs(30)).await;
9381
9382            let Some((last, fills)) = events.split_last() else {
9383                panic!("events are sent");
9384            };
9385            assert_eq!(fills.len(), MAX_ACTIVITY_PAGES * ALPACA_MAX_ACTIVITIES);
9386            assert!(
9387                fills
9388                    .iter()
9389                    .all(|event| matches!(event.kind, AccountEventKind::Trade(_))),
9390                "every fill comes first"
9391            );
9392            let AccountEventKind::FillRecoveryGaveUp(gave_up) = &last.kind else {
9393                panic!("the give-up comes last, got {last:?}");
9394            };
9395            assert_eq!(gave_up.scope, FillRecoveryScope::AllInstruments);
9396            assert!(matches!(
9397                gave_up.reason,
9398                FillRecoveryFailure::Truncated { .. }
9399            ));
9400        }
9401
9402        /// A FILL activity's execution id is the part of its `id` after `::`; an id without one is
9403        /// taken whole.
9404        #[test]
9405        fn an_activity_id_names_its_execution_after_the_separator() {
9406            assert_eq!(
9407                activity_execution_id("20261005041849348::524b1902-817e-446c-825b-a9fcfebbc17e"),
9408                "524b1902-817e-446c-825b-a9fcfebbc17e"
9409            );
9410            assert_eq!(activity_execution_id("act-1"), "act-1");
9411            assert_eq!(
9412                activity_execution_id("20261005041849348::"),
9413                "20261005041849348::"
9414            );
9415        }
9416
9417        /// One fill carries one `TradeId` however it is delivered: over the account stream, by
9418        /// reconnect recovery, and by `fetch_trades`. So a consumer reconciling with `fetch_trades`
9419        /// matches what the stream already delivered.
9420        #[tokio::test]
9421        async fn a_fill_has_one_trade_id_on_the_stream_in_recovery_and_from_fetch_trades() {
9422            use crate::client::ExecutionClient;
9423
9424            let execution_id = "524b1902-817e-446c-825b-a9fcfebbc17e";
9425            let activity = activity_json(
9426                &format!("20250418103000000::{execution_id}"),
9427                "ord-1",
9428                "2",
9429                Some("2"),
9430            );
9431
9432            let [streamed, _snapshot] = convert_trade_update(AlpacaTradeUpdate {
9433                event: SmolStr::new("fill"),
9434                execution_id: Some(execution_id),
9435                previous_execution_id: None,
9436                order: super::make_order_ws("ord-1", "SPY", "buy", "2"),
9437                price: Some("100.00"),
9438                qty: Some("2"),
9439                timestamp: Some("2025-04-18T14:30:00Z"),
9440            });
9441            let Some(streamed) = streamed else {
9442                panic!("a fill converts to a Trade");
9443            };
9444
9445            let recovered = drive_recover_fills(vec![activity.clone()]).await;
9446            let [recovered] = recovered.as_slice() else {
9447                panic!("one fill recovered: {recovered:?}");
9448            };
9449
9450            let server = MockServer::start().await;
9451            Mock::given(method("GET"))
9452                .and(path("/v2/account/activities"))
9453                .respond_with(
9454                    ResponseTemplate::new(200)
9455                        .set_body_json(serde_json::Value::Array(vec![activity])),
9456                )
9457                .mount(&server)
9458                .await;
9459            let Ok(fetched) = client_for(&server)
9460                .fetch_trades(recovery_after(), Utc::now(), &[])
9461                .await
9462            else {
9463                panic!("fetch_trades reads the activity");
9464            };
9465            let [fetched] = fetched.trades.as_slice() else {
9466                panic!("one fill fetched: {fetched:?}");
9467            };
9468
9469            assert_eq!(trade_of(&streamed).id.0.as_str(), execution_id);
9470            assert_eq!(trade_of(recovered).id.0.as_str(), execution_id);
9471            assert_eq!(fetched.id.0.as_str(), execution_id);
9472        }
9473
9474        // -----------------------------------------------------------------------
9475        // fetch_trades — bounded reads of a span, resumed by the caller
9476        // -----------------------------------------------------------------------
9477
9478        fn time(s: &str) -> DateTime<Utc> {
9479            s.parse().expect("valid time")
9480        }
9481
9482        /// Alpaca's activities endpoint over `activities`, which are sorted by id, as observed on
9483        /// paper: `after` matches an activity whose time, rounded down to the millisecond, is at
9484        /// or after it, `until` one whose rounded time is before it, and `page_token` resumes
9485        /// after the activity it names.
9486        struct ActivitiesVenue {
9487            activities: Vec<serde_json::Value>,
9488        }
9489
9490        impl Respond for ActivitiesVenue {
9491            fn respond(&self, request: &Request) -> ResponseTemplate {
9492                let query: std::collections::HashMap<String, String> =
9493                    request.url.query_pairs().into_owned().collect();
9494                let bound = |name: &str| query.get(name).map(|s| time(s));
9495                let (after, until) = (bound("after"), bound("until"));
9496                let size: usize = query
9497                    .get("page_size")
9498                    .and_then(|s| s.parse().ok())
9499                    .unwrap_or(ALPACA_MAX_ACTIVITIES);
9500                let in_bounds = self.activities.iter().filter(|activity| {
9501                    let at =
9502                        floor_millis(time(activity["transaction_time"].as_str().expect("a time")));
9503                    after.is_none_or(|after| at >= after) && until.is_none_or(|until| at < until)
9504                });
9505                let page: Vec<_> = match query.get("page_token") {
9506                    Some(token) => in_bounds
9507                        .skip_while(|activity| activity["id"] != token.as_str())
9508                        .skip(1)
9509                        .take(size)
9510                        .cloned()
9511                        .collect(),
9512                    None => in_bounds.take(size).cloned().collect(),
9513                };
9514                ResponseTemplate::new(200).set_body_json(page)
9515            }
9516        }
9517
9518        /// A FILL activity on `symbol` with id `id` at `transaction_time`.
9519        fn fill_at(id: &str, symbol: &str, transaction_time: DateTime<Utc>) -> serde_json::Value {
9520            serde_json::json!({
9521                "id": id,
9522                "order_id": format!("ord-{id}"),
9523                "symbol": symbol,
9524                "side": "buy",
9525                "price": "100.00",
9526                "qty": "1",
9527                "cum_qty": "1",
9528                "transaction_time": transaction_time.to_rfc3339_opts(SecondsFormat::Micros, true),
9529            })
9530        }
9531
9532        async fn serve_activities(activities: Vec<serde_json::Value>) -> MockServer {
9533            let server = MockServer::start().await;
9534            Mock::given(method("GET"))
9535                .and(path("/v2/account/activities"))
9536                .respond_with(ActivitiesVenue { activities })
9537                .mount(&server)
9538                .await;
9539            server
9540        }
9541
9542        /// A non-trade activity of `activity_type` and `sub_type`, for `net_amount`, created at
9543        /// `created_at`.
9544        fn non_trade(
9545            id: &str,
9546            activity_type: &str,
9547            sub_type: Option<&str>,
9548            net_amount: &str,
9549            created_at: &str,
9550        ) -> serde_json::Value {
9551            let mut activity = serde_json::json!({
9552                "id": id, "activity_type": activity_type, "net_amount": net_amount,
9553                "created_at": created_at, "date": &created_at[..10], "currency": "USD",
9554                "status": "executed",
9555            });
9556            if let Some(sub_type) = sub_type {
9557                activity["activity_sub_type"] = serde_json::json!(sub_type);
9558            }
9559            activity
9560        }
9561
9562        /// The query of every request `server` received.
9563        async fn query_pairs(server: &MockServer) -> Vec<Vec<(String, String)>> {
9564            let Some(requests) = server.received_requests().await else {
9565                panic!("requests are recorded");
9566            };
9567            requests
9568                .iter()
9569                .map(|request| request.url.query_pairs().into_owned().collect())
9570                .collect()
9571        }
9572
9573        #[tokio::test]
9574        async fn fetch_cash_flows_maps_each_activity_type_and_leaves_out_what_it_cannot_use() {
9575            let at = "2025-04-18T05:00:00Z";
9576            let mut fee = non_trade("fee", "FEE", Some("TAF"), "-0.02", at);
9577            fee["symbol"] = serde_json::json!("SPY");
9578            let mut canceled = non_trade("canceled", "FEE", Some("REG"), "-0.01", at);
9579            canceled["status"] = serde_json::json!("canceled");
9580            let mut no_amount = non_trade("no-amount", "FEE", None, "0", at);
9581            no_amount.as_object_mut().unwrap().remove("net_amount");
9582            let mut no_created_at = non_trade("no-time", "PTC", None, "-0.50", at);
9583            no_created_at.as_object_mut().unwrap().remove("created_at");
9584            let mut bad_time = non_trade("bad-time", "PTC", None, "-0.50", at);
9585            bad_time["created_at"] = serde_json::json!("not-a-time");
9586            let mut corrected = non_trade("correct", "PTC", None, "-0.40", at);
9587            corrected["status"] = serde_json::json!("correct");
9588            let mut euro = non_trade("euro", "PTR", None, "0.30", at);
9589            euro["currency"] = serde_json::json!("EUR");
9590            let server = MockServer::start().await;
9591            Mock::given(method("GET"))
9592                .and(path("/v2/account/activities"))
9593                .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([
9594                    non_trade("mgn", "INT", Some("MGN"), "-12.50", at),
9595                    non_trade("int-paid", "INT", None, "-3.00", at),
9596                    non_trade("int-credit", "INT", Some("CDT"), "1.00", at),
9597                    fee,
9598                    canceled,
9599                    no_amount,
9600                    no_created_at,
9601                    bad_time,
9602                    corrected,
9603                    euro,
9604                    non_trade("late", "FEE", None, "-0.01", "2025-04-20T05:00:00Z"),
9605                ])))
9606                .mount(&server)
9607                .await;
9608            let client = client_for(&server);
9609
9610            let read = client
9611                .fetch_cash_flows(time("2025-04-18T00:00:00Z"), time("2025-04-19T00:00:00Z"))
9612                .await
9613                .unwrap();
9614
9615            assert_eq!(read.resume, None);
9616            let kinds: Vec<_> = read
9617                .flows
9618                .iter()
9619                .map(|flow| (flow.id.clone().unwrap().0.to_string(), flow.kind.clone()))
9620                .collect();
9621            let interest = CashFlowKind::MarginInterest {
9622                rate: None,
9623                principal: None,
9624            };
9625            assert_eq!(
9626                kinds,
9627                [
9628                    ("mgn", interest.clone()),
9629                    ("int-paid", interest),
9630                    ("int-credit", CashFlowKind::Other("INT.CDT".into())),
9631                    ("fee", CashFlowKind::Other("FEE.TAF".into())),
9632                    ("correct", CashFlowKind::Other("PTC".into())),
9633                    ("euro", CashFlowKind::Rebate),
9634                ]
9635                .map(|(id, kind)| (id.to_owned(), kind))
9636            );
9637            let by_id = |id: &str| {
9638                read.flows
9639                    .iter()
9640                    .find(|flow| flow.id == Some(CashFlowId::new(id)))
9641                    .unwrap()
9642            };
9643            assert_eq!(by_id("mgn").amount, Decimal::new(-1250, 2));
9644            assert_eq!(by_id("mgn").asset, AssetNameExchange::new("USD"));
9645            assert_eq!(by_id("mgn").instrument, None);
9646            assert_eq!(by_id("mgn").time_exchange, time(at));
9647            assert_eq!(
9648                by_id("fee").instrument,
9649                Some(InstrumentNameExchange::new("SPY"))
9650            );
9651            assert_eq!(
9652                by_id("correct").amount,
9653                Decimal::new(-40, 2),
9654                "a correction is returned as it is"
9655            );
9656            assert_eq!(by_id("euro").asset, AssetNameExchange::new("EUR"));
9657
9658            let queries = query_pairs(&server).await;
9659            let [query] = queries.as_slice() else {
9660                panic!("one request: {queries:?}");
9661            };
9662            assert!(
9663                query.contains(&("activity_types".into(), "INT,FEE,PTC,PTR".into())),
9664                "{query:?}"
9665            );
9666        }
9667
9668        #[tokio::test]
9669        async fn fetch_trades_asks_for_fills_by_the_documented_parameter() {
9670            use crate::client::ExecutionClient;
9671
9672            let server = serve_activities(Vec::new()).await;
9673            let client = client_for(&server);
9674
9675            let read = client
9676                .fetch_trades(
9677                    time("2025-04-18T00:00:00Z"),
9678                    time("2025-04-19T00:00:00Z"),
9679                    &[],
9680                )
9681                .await
9682                .unwrap();
9683
9684            assert!(read.trades.is_empty());
9685            let queries = query_pairs(&server).await;
9686            let [query] = queries.as_slice() else {
9687                panic!("one request: {queries:?}");
9688            };
9689            assert!(
9690                query.contains(&("activity_types".into(), "FILL".into())),
9691                "{query:?}"
9692            );
9693            assert!(
9694                !query.iter().any(|(name, _)| name == "activity_type"),
9695                "{query:?}"
9696            );
9697        }
9698
9699        #[tokio::test]
9700        async fn fetch_cash_flows_reads_nothing_for_an_empty_span() {
9701            let server = MockServer::start().await;
9702            let client = client_for(&server);
9703
9704            let read = client
9705                .fetch_cash_flows(time("2025-04-19T00:00:00Z"), time("2025-04-18T00:00:00Z"))
9706                .await
9707                .unwrap();
9708
9709            assert!(read.flows.is_empty() && read.resume.is_none());
9710            assert!(query_pairs(&server).await.is_empty());
9711        }
9712
9713        /// A span holding more activities than one call reads returns those it read and says
9714        /// where to read on from: the millisecond of the last one's creation.
9715        #[tokio::test]
9716        async fn fetch_cash_flows_that_stops_at_its_bound_says_where_to_read_on() {
9717            let base = time("2025-04-18T05:00:00Z");
9718            let activities: Vec<_> = (0..6_000_i64)
9719                .map(|n| {
9720                    let created =
9721                        (base + TimeDelta::seconds(n)).to_rfc3339_opts(SecondsFormat::Micros, true);
9722                    non_trade(&format!("{n:017}::fee"), "FEE", None, "-0.01", &created)
9723                })
9724                .collect();
9725            // Every page answers in order after the token, as Alpaca pages by id.
9726            struct Paged(Vec<serde_json::Value>);
9727            impl Respond for Paged {
9728                fn respond(&self, request: &Request) -> ResponseTemplate {
9729                    let token = request
9730                        .url
9731                        .query_pairs()
9732                        .find(|(name, _)| name == "page_token")
9733                        .map(|(_, value)| value.into_owned());
9734                    let page: Vec<_> = self
9735                        .0
9736                        .iter()
9737                        .skip_while(|activity| {
9738                            token
9739                                .as_deref()
9740                                .is_some_and(|token| activity["id"] != token)
9741                        })
9742                        .skip(usize::from(token.is_some()))
9743                        .take(ALPACA_MAX_ACTIVITIES)
9744                        .cloned()
9745                        .collect();
9746                    ResponseTemplate::new(200).set_body_json(page)
9747                }
9748            }
9749            let server = MockServer::start().await;
9750            Mock::given(method("GET"))
9751                .and(path("/v2/account/activities"))
9752                .respond_with(Paged(activities))
9753                .mount(&server)
9754                .await;
9755            let client = client_for(&server);
9756
9757            let read = client
9758                .fetch_cash_flows(base, base + TimeDelta::days(1))
9759                .await
9760                .unwrap();
9761
9762            assert_eq!(read.flows.len(), 5_000);
9763            assert_eq!(read.resume, Some(base + TimeDelta::seconds(4_999)));
9764        }
9765
9766        /// A read whose every page holds activities of the span's first millisecond cannot
9767        /// advance, so it fails rather than return a `resume` that would loop.
9768        #[tokio::test]
9769        async fn fetch_cash_flows_that_cannot_advance_fails() {
9770            let at = "2025-04-18T05:00:00Z";
9771            let full: Vec<_> = (0..ALPACA_MAX_ACTIVITIES)
9772                .map(|n| non_trade(&format!("fee-{n}"), "FEE", None, "-0.01", at))
9773                .collect();
9774            let server = MockServer::start().await;
9775            Mock::given(method("GET"))
9776                .and(path("/v2/account/activities"))
9777                .respond_with(ResponseTemplate::new(200).set_body_json(full))
9778                .mount(&server)
9779                .await;
9780            let client = client_for(&server);
9781
9782            let result = client
9783                .fetch_cash_flows(time(at), time("2025-04-19T00:00:00Z"))
9784                .await;
9785
9786            assert!(
9787                matches!(result, Err(UnindexedClientError::Internal(_))),
9788                "{result:?}"
9789            );
9790        }
9791
9792        /// An option activity of `activity_type` on `symbol`, for the signed `qty`, dated `date`,
9793        /// with no creation time.
9794        fn option_activity(
9795            id: &str,
9796            activity_type: &str,
9797            symbol: &str,
9798            qty: &str,
9799            date: &str,
9800        ) -> serde_json::Value {
9801            serde_json::json!({
9802                "id": id, "activity_type": activity_type, "symbol": symbol, "qty": qty,
9803                "date": date, "net_amount": "0", "status": "executed",
9804            })
9805        }
9806
9807        /// The share leg (`OPTRD`) of an exercise or assignment, at `price`.
9808        fn share_leg(
9809            id: &str,
9810            symbol: &str,
9811            qty: &str,
9812            price: &str,
9813            date: &str,
9814        ) -> serde_json::Value {
9815            let mut activity = option_activity(id, "OPTRD", symbol, qty, date);
9816            activity["price"] = serde_json::json!(price);
9817            activity
9818        }
9819
9820        async fn serve_json(activities: serde_json::Value) -> MockServer {
9821            let server = MockServer::start().await;
9822            Mock::given(method("GET"))
9823                .and(path("/v2/account/activities"))
9824                .respond_with(ResponseTemplate::new(200).set_body_json(activities))
9825                .mount(&server)
9826                .await;
9827            server
9828        }
9829
9830        #[test]
9831        fn occ_contract_reads_the_root_kind_and_strike() {
9832            assert_eq!(
9833                occ_contract("QS240301C00006500"),
9834                Some(("QS", true, Decimal::new(65, 1)))
9835            );
9836            assert_eq!(
9837                occ_contract("AAPL240119P00150000"),
9838                Some(("AAPL", false, Decimal::from(150)))
9839            );
9840            for not_occ in [
9841                "QS",
9842                "240301C00006500",
9843                "QS240301X00006500",
9844                "QS2403O1C00006500",
9845            ] {
9846                assert_eq!(occ_contract(not_occ), None, "{not_occ}");
9847            }
9848        }
9849
9850        #[tokio::test]
9851        async fn fetch_option_events_maps_each_activity_and_pairs_the_share_legs() {
9852            let day = "2024-03-01";
9853            let mut expiry = option_activity("exp", "OPEXP", "QS240301C00010500", "-1", day);
9854            expiry["created_at"] = serde_json::json!("2024-03-01T21:30:00Z");
9855            let mut canceled = option_activity("canceled", "OPEXP", "QS240301P00000500", "-1", day);
9856            canceled["status"] = serde_json::json!("canceled");
9857            let server = serve_json(serde_json::json!([
9858                expiry,
9859                option_activity("exc", "OPEXC", "QS240301C00006500", "-1", day),
9860                share_leg("exc-shares", "QS", "100", "6.5", day),
9861                option_activity("asn", "OPASN", "QS240301P00009000", "1", day),
9862                share_leg("asn-shares", "QS", "100", "9", day),
9863                share_leg("orphan", "QS", "-100", "7", day),
9864                canceled,
9865                option_activity("no-price", "OPTRD", "QS", "100", day),
9866                option_activity("no-qty", "OPEXP", "QS240301C00011000", "0", day),
9867                option_activity("later", "OPEXP", "QS240308C00010500", "-1", "2024-03-02"),
9868            ]))
9869            .await;
9870            let client = client_for(&server);
9871
9872            let read = client
9873                .fetch_option_events(time("2024-03-01T00:00:00Z"), time("2024-03-01T23:00:00Z"))
9874                .await
9875                .unwrap();
9876
9877            assert_eq!(read.resume, None);
9878            let summary: Vec<_> = read
9879                .trades
9880                .iter()
9881                .map(|trade| {
9882                    (
9883                        trade.id.0.to_string(),
9884                        trade.instrument.name().to_string(),
9885                        trade.origin,
9886                        trade.side,
9887                        trade.price,
9888                        trade.quantity,
9889                    )
9890                })
9891                .collect();
9892            let row = |id: &str, symbol: &str, origin, side, price: Decimal, quantity: i64| {
9893                (
9894                    id.to_owned(),
9895                    symbol.to_owned(),
9896                    origin,
9897                    side,
9898                    price,
9899                    Decimal::from(quantity),
9900                )
9901            };
9902            assert_eq!(
9903                summary,
9904                [
9905                    row(
9906                        "exp",
9907                        "QS240301C00010500",
9908                        TradeOrigin::Expiry,
9909                        Side::Sell,
9910                        Decimal::ZERO,
9911                        1
9912                    ),
9913                    row(
9914                        "exc",
9915                        "QS240301C00006500",
9916                        TradeOrigin::Exercise,
9917                        Side::Sell,
9918                        Decimal::ZERO,
9919                        1
9920                    ),
9921                    row(
9922                        "exc-shares",
9923                        "QS",
9924                        TradeOrigin::Exercise,
9925                        Side::Buy,
9926                        Decimal::new(65, 1),
9927                        100
9928                    ),
9929                    row(
9930                        "asn",
9931                        "QS240301P00009000",
9932                        TradeOrigin::Assignment,
9933                        Side::Buy,
9934                        Decimal::ZERO,
9935                        1
9936                    ),
9937                    row(
9938                        "asn-shares",
9939                        "QS",
9940                        TradeOrigin::Assignment,
9941                        Side::Buy,
9942                        Decimal::from(9),
9943                        100
9944                    ),
9945                    row(
9946                        "orphan",
9947                        "QS",
9948                        TradeOrigin::External,
9949                        Side::Sell,
9950                        Decimal::from(7),
9951                        100
9952                    ),
9953                ]
9954            );
9955            let exp = &read.trades[0];
9956            assert_eq!(
9957                exp.time_exchange,
9958                time("2024-03-01T21:30:00Z"),
9959                "created_at"
9960            );
9961            assert_eq!(exp.order_id, OrderId::new("exp"));
9962            assert_eq!(exp.fees.fees, Decimal::ZERO);
9963            assert_eq!(
9964                read.trades[1].time_exchange,
9965                time("2024-03-01T00:00:00Z"),
9966                "no created_at: the start of its date"
9967            );
9968
9969            let queries = query_pairs(&server).await;
9970            let [query] = queries.as_slice() else {
9971                panic!("one request: {queries:?}");
9972            };
9973            assert!(
9974                query.contains(&("activity_types".into(), "OPASN,OPEXC,OPEXP,OPTRD".into())),
9975                "{query:?}"
9976            );
9977        }
9978
9979        /// A share leg pairs with the exercise or assignment whose kind implies its side, not with
9980        /// another at the same strike, and each pairs once.
9981        #[tokio::test]
9982        async fn fetch_option_events_pairs_a_share_leg_by_its_side() {
9983            let day = "2024-03-01";
9984            let server = serve_json(serde_json::json!([
9985                option_activity("exc-call", "OPEXC", "QS240301C00006500", "-1", day),
9986                option_activity("asn-call", "OPASN", "QS240301C00006500", "1", day),
9987                share_leg("sold", "QS", "-100", "6.5", day),
9988                share_leg("bought", "QS", "100", "6.5", day),
9989                share_leg("bought-again", "QS", "100", "6.5", day),
9990                share_leg("other-day", "QS", "100", "6.5", "2024-02-29"),
9991            ]))
9992            .await;
9993            let client = client_for(&server);
9994
9995            let read = client
9996                .fetch_option_events(time("2024-02-29T00:00:00Z"), time("2024-03-01T23:00:00Z"))
9997                .await
9998                .unwrap();
9999
10000            let origin = |id: &str| {
10001                read.trades
10002                    .iter()
10003                    .find(|trade| trade.id == TradeId::new(id))
10004                    .map(|trade| trade.origin)
10005            };
10006            assert_eq!(origin("sold"), Some(TradeOrigin::Assignment));
10007            assert_eq!(origin("bought"), Some(TradeOrigin::Exercise));
10008            assert_eq!(origin("bought-again"), Some(TradeOrigin::External));
10009            assert_eq!(origin("other-day"), Some(TradeOrigin::External));
10010        }
10011
10012        /// A share leg pairs with the exercise or assignment whose contracts deliver its shares,
10013        /// 100 a contract, though another that would buy them comes first. One of another size
10014        /// still pairs, with the first match left.
10015        #[tokio::test]
10016        async fn fetch_option_events_pairs_a_share_leg_by_its_size() {
10017            let day = "2024-03-01";
10018            let server = serve_json(serde_json::json!([
10019                // Being assigned on a put and exercising a call both buy at the strike.
10020                option_activity("asn-two", "OPASN", "QS240301P00006500", "2", day),
10021                option_activity("exc-one", "OPEXC", "QS240301C00006500", "-1", day),
10022                option_activity("exc-adjusted", "OPEXC", "QS240301C00006500", "-1", day),
10023                share_leg("hundred", "QS", "100", "6.5", day),
10024                share_leg("two-hundred", "QS", "200", "6.5", day),
10025                share_leg("hundred-fifty", "QS", "150", "6.5", day),
10026            ]))
10027            .await;
10028            let client = client_for(&server);
10029
10030            let read = client
10031                .fetch_option_events(time("2024-03-01T00:00:00Z"), time("2024-03-01T23:00:00Z"))
10032                .await
10033                .unwrap();
10034
10035            let origin = |id: &str| {
10036                read.trades
10037                    .iter()
10038                    .find(|trade| trade.id == TradeId::new(id))
10039                    .map(|trade| trade.origin)
10040            };
10041            assert_eq!(origin("hundred"), Some(TradeOrigin::Exercise));
10042            assert_eq!(origin("two-hundred"), Some(TradeOrigin::Assignment));
10043            assert_eq!(origin("hundred-fifty"), Some(TradeOrigin::Exercise));
10044        }
10045
10046        /// The read covers the whole UTC days of the span, so both legs of an event are read
10047        /// together, and returns only what is in the span: by creation time, or, without one, by
10048        /// date.
10049        #[tokio::test]
10050        async fn fetch_option_events_reads_whole_days_and_returns_the_span() {
10051            let mut early =
10052                option_activity("early", "OPEXP", "QS240301C00010500", "-1", "2024-03-01");
10053            early["created_at"] = serde_json::json!("2024-03-01T10:00:00Z");
10054            let mut inside =
10055                option_activity("inside", "OPEXP", "QS240301C00011500", "-1", "2024-03-01");
10056            inside["created_at"] = serde_json::json!("2024-03-01T16:00:00Z");
10057            let server = serve_json(serde_json::json!([
10058                option_activity(
10059                    "dated-before",
10060                    "OPEXP",
10061                    "QS240229C00010500",
10062                    "-1",
10063                    "2024-02-29"
10064                ),
10065                early,
10066                option_activity("dated", "OPEXP", "QS240301C00012500", "-1", "2024-03-01"),
10067                inside,
10068            ]))
10069            .await;
10070            let client = client_for(&server);
10071
10072            let read = client
10073                .fetch_option_events(time("2024-03-01T15:00:00Z"), time("2024-03-01T17:00:00Z"))
10074                .await
10075                .unwrap();
10076
10077            let ids: Vec<_> = read
10078                .trades
10079                .iter()
10080                .map(|trade| trade.id.0.to_string())
10081                .collect();
10082            assert_eq!(ids, ["dated", "inside"]);
10083            let queries = query_pairs(&server).await;
10084            let [query] = queries.as_slice() else {
10085                panic!("one request: {queries:?}");
10086            };
10087            assert!(
10088                query.contains(&("after".into(), "2024-03-01T00:00:00.000Z".into())),
10089                "{query:?}"
10090            );
10091            assert!(
10092                query.contains(&("until".into(), "2024-03-02T00:00:00.000Z".into())),
10093                "{query:?}"
10094            );
10095        }
10096
10097        #[tokio::test]
10098        async fn fetch_option_events_reads_nothing_for_an_empty_span() {
10099            let server = MockServer::start().await;
10100            let client = client_for(&server);
10101
10102            let read = client
10103                .fetch_option_events(time("2024-03-02T00:00:00Z"), time("2024-03-01T00:00:00Z"))
10104                .await
10105                .unwrap();
10106
10107            assert!(read.trades.is_empty() && read.resume.is_none());
10108            assert!(query_pairs(&server).await.is_empty());
10109        }
10110
10111        /// Read `start..=end` to its end, from each call's `resume`, returning the trade ids
10112        /// read and how many calls it took.
10113        async fn read_span(
10114            client: &AlpacaClient,
10115            start: DateTime<Utc>,
10116            end: DateTime<Utc>,
10117            instruments: &[InstrumentNameExchange],
10118        ) -> (std::collections::HashSet<String>, usize) {
10119            use crate::client::ExecutionClient;
10120
10121            let mut ids = std::collections::HashSet::new();
10122            let mut from = start;
10123            for calls in 1..=10 {
10124                let read = match client.fetch_trades(from, end, instruments).await {
10125                    Ok(read) => read,
10126                    Err(error) => panic!("call {calls} failed: {error:?}"),
10127                };
10128                ids.extend(read.trades.iter().map(|trade| trade.id.0.to_string()));
10129                match read.resume {
10130                    Some(resume) => from = resume,
10131                    None => return (ids, calls),
10132                }
10133            }
10134            panic!("the reads did not reach the span's end");
10135        }
10136
10137        /// A span holding more fills than one call reads is read completely across calls, each
10138        /// resumed from the last. Three fills share each millisecond, ordered by id against
10139        /// their times, so a call's cut falls inside one: its last fill read is later than an
10140        /// unread one in the same millisecond, which a resume from the last fill's exact time
10141        /// would lose.
10142        #[tokio::test]
10143        async fn fetch_trades_reads_a_busy_span_completely_across_calls() {
10144            let base = time("2025-04-18T14:30:00Z");
10145            let mut activities = Vec::new();
10146            for ms in 0..4_000_i64 {
10147                let symbol = if ms % 2 == 0 { "SPY" } else { "QQQ" };
10148                for (k, micros) in [("a", 900), ("b", 500), ("c", 100)] {
10149                    let at = base + TimeDelta::milliseconds(ms) + TimeDelta::microseconds(micros);
10150                    activities.push(fill_at(&format!("{ms:017}::{ms}-{k}"), symbol, at));
10151                }
10152            }
10153            let server = serve_activities(activities).await;
10154            let client = client_for(&server);
10155            let end = time("2025-04-18T15:00:00Z");
10156
10157            let (ids, calls) = read_span(&client, recovery_after(), end, &[]).await;
10158            assert_eq!(ids.len(), 12_000, "every fill is read once or more");
10159            assert_eq!(calls, 3, "5,000 activities per call");
10160
10161            let spy = [InstrumentNameExchange::new("SPY")];
10162            let (ids, _) = read_span(&client, recovery_after(), end, &spy).await;
10163            assert_eq!(ids.len(), 6_000, "every SPY fill, and only those");
10164
10165            let Some(requests) = server.received_requests().await else {
10166                panic!("requests are recorded");
10167            };
10168            assert!(
10169                requests
10170                    .iter()
10171                    .all(|request| request.url.query_pairs().any(
10172                        |(name, value)| name == "until" && value == "2025-04-18T15:00:00.001Z"
10173                    )),
10174                "every request is bounded by the span's end"
10175            );
10176        }
10177
10178        /// A call can spend its whole bound on other instruments' fills: it returns none, and
10179        /// says where to read on from.
10180        #[tokio::test]
10181        async fn fetch_trades_says_where_to_read_on_after_a_call_without_matches() {
10182            use crate::client::ExecutionClient;
10183
10184            let server = MockServer::start().await;
10185            Mock::given(method("GET"))
10186                .and(path("/v2/account/activities"))
10187                .respond_with(
10188                    ResponseTemplate::new(200)
10189                        .set_body_json(make_activities_json(ALPACA_MAX_ACTIVITIES, "act")),
10190                )
10191                .mount(&server)
10192                .await;
10193
10194            let qqq = [InstrumentNameExchange::new("QQQ")];
10195            let Ok(read) = client_for(&server)
10196                .fetch_trades(recovery_after(), Utc::now(), &qqq)
10197                .await
10198            else {
10199                panic!("the read succeeds");
10200            };
10201            assert!(read.trades.is_empty(), "every fill read is SPY's");
10202            assert_eq!(read.resume, Some(time("2025-04-18T14:30:00Z")));
10203        }
10204
10205        /// A full read that does not get past the span's first millisecond cannot advance, so it
10206        /// is an error rather than a resume that would loop.
10207        #[tokio::test]
10208        async fn fetch_trades_that_cannot_advance_is_truncated() {
10209            use crate::client::ExecutionClient;
10210
10211            let server = MockServer::start().await;
10212            Mock::given(method("GET"))
10213                .and(path("/v2/account/activities"))
10214                .respond_with(
10215                    ResponseTemplate::new(200)
10216                        .set_body_json(make_activities_json(ALPACA_MAX_ACTIVITIES, "act")),
10217                )
10218                .mount(&server)
10219                .await;
10220
10221            let result = client_for(&server)
10222                .fetch_trades(time("2025-04-18T14:30:00.000500Z"), Utc::now(), &[])
10223                .await;
10224            assert!(
10225                matches!(
10226                    result,
10227                    Err(UnindexedClientError::Truncated { fills_read: 5_000 })
10228                ),
10229                "got {result:?}"
10230            );
10231        }
10232
10233        /// The span is applied exactly, although Alpaca's bounds reach the whole of the
10234        /// milliseconds holding `start` and `end`.
10235        #[tokio::test]
10236        async fn fetch_trades_returns_only_the_span() {
10237            let (start, end) = (
10238                time("2025-04-18T14:30:00.123456Z"),
10239                time("2025-04-18T14:30:05.678901Z"),
10240            );
10241            let micro = TimeDelta::microseconds(1);
10242            let server = serve_activities(vec![
10243                fill_at("1::before", "SPY", start - micro),
10244                fill_at("2::start", "SPY", start),
10245                fill_at("3::end", "SPY", end),
10246                fill_at("4::after", "SPY", end + micro),
10247            ])
10248            .await;
10249
10250            let (ids, calls) = read_span(&client_for(&server), start, end, &[]).await;
10251            assert_eq!(calls, 1);
10252            let mut ids: Vec<_> = ids.into_iter().collect();
10253            ids.sort();
10254            assert_eq!(ids, ["end", "start"]);
10255        }
10256
10257        /// Alpaca's `until` is not exact, so a capped read can return activities past `end`. Every
10258        /// activity before them was read, so the span is complete.
10259        #[tokio::test]
10260        async fn fetch_trades_that_read_past_the_end_is_complete() {
10261            use crate::client::ExecutionClient;
10262
10263            let server = MockServer::start().await;
10264            // Full pages at 14:30:00, whatever `until` asks for.
10265            Mock::given(method("GET"))
10266                .and(path("/v2/account/activities"))
10267                .respond_with(
10268                    ResponseTemplate::new(200)
10269                        .set_body_json(make_activities_json(ALPACA_MAX_ACTIVITIES, "act")),
10270                )
10271                .mount(&server)
10272                .await;
10273
10274            let Ok(read) = client_for(&server)
10275                .fetch_trades(recovery_after(), time("2025-04-18T14:29:00Z"), &[])
10276                .await
10277            else {
10278                panic!("the read succeeds");
10279            };
10280            assert_eq!(read, TradesRead::complete(Vec::new()));
10281        }
10282
10283        /// The extreme times are valid bounds: a start before the epoch asks from the epoch, an
10284        /// end not yet reached sends no `until` rather than overflowing, and a span ending before
10285        /// the epoch reads nothing.
10286        #[tokio::test]
10287        async fn fetch_trades_accepts_the_extreme_times() {
10288            use crate::client::ExecutionClient;
10289
10290            let server = serve_activities(vec![fill_at(
10291                "1::fill",
10292                "SPY",
10293                time("2025-04-18T14:30:00Z"),
10294            )])
10295            .await;
10296            let Ok(read) = client_for(&server)
10297                .fetch_trades(DateTime::<Utc>::MIN_UTC, DateTime::<Utc>::MAX_UTC, &[])
10298                .await
10299            else {
10300                panic!("the read succeeds");
10301            };
10302            assert_eq!(read.trades.len(), 1);
10303            assert_eq!(read.resume, None);
10304
10305            let Some(requests) = server.received_requests().await else {
10306                panic!("requests are recorded");
10307            };
10308            let [request] = requests.as_slice() else {
10309                panic!("one request: {requests:?}");
10310            };
10311            let query: std::collections::HashMap<_, _> =
10312                request.url.query_pairs().into_owned().collect();
10313            assert_eq!(
10314                query.get("after").map(String::as_str),
10315                Some("1970-01-01T00:00:00.000Z")
10316            );
10317            assert!(!query.contains_key("until"), "{query:?}");
10318
10319            // A span ending before the epoch holds nothing, and is not requested.
10320            let before_epoch = DateTime::UNIX_EPOCH - TimeDelta::days(1);
10321            let Ok(read) = client_for(&server)
10322                .fetch_trades(DateTime::<Utc>::MIN_UTC, before_epoch, &[])
10323                .await
10324            else {
10325                panic!("the read succeeds");
10326            };
10327            assert_eq!(read, TradesRead::complete(Vec::new()));
10328            assert_eq!(server.received_requests().await.map(|r| r.len()), Some(1));
10329        }
10330
10331        /// A span whose start is after its end is empty: nothing is requested.
10332        #[tokio::test]
10333        async fn fetch_trades_reads_nothing_for_an_empty_span() {
10334            use crate::client::ExecutionClient;
10335
10336            let server = MockServer::start().await;
10337            let start = recovery_after();
10338            let Ok(read) = client_for(&server)
10339                .fetch_trades(start, start - TimeDelta::seconds(1), &[])
10340                .await
10341            else {
10342                panic!("an empty span reads");
10343            };
10344            assert_eq!(read, TradesRead::complete(Vec::new()));
10345            assert_eq!(server.received_requests().await.map(|r| r.len()), Some(0));
10346        }
10347
10348        /// Alpaca's `after` compares at millisecond precision, so a recovery from mid-millisecond
10349        /// asks from that millisecond's start, or it would miss a fill later in it.
10350        #[tokio::test]
10351        async fn recovery_reads_from_the_start_of_the_disconnects_millisecond() {
10352            let server = MockServer::start().await;
10353            Mock::given(method("GET"))
10354                .and(path("/v2/account/activities"))
10355                .and(wiremock::matchers::query_param(
10356                    "after",
10357                    "2025-01-01T00:00:00.123Z",
10358                ))
10359                .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([])))
10360                .expect(1)
10361                .mount(&server)
10362                .await;
10363
10364            let (tx, _rx) = mpsc::unbounded_channel();
10365            let outcome = recover_fills(
10366                &reqwest::Client::new(),
10367                &RateLimitTracker::new(),
10368                &[],
10369                &server.uri(),
10370                time("2025-01-01T00:00:00.123456Z"),
10371                &tx,
10372                &new_dedup_cache(),
10373                &KnownLiveOrders::shared(ExchangeId::AlpacaBroker),
10374            )
10375            .await;
10376            assert_eq!(outcome, Ok(()));
10377            server.verify().await;
10378        }
10379
10380        /// The cumulative dedup keys `dedup` holds, oldest first.
10381        fn dedup_keys(dedup: &SharedDedupCache) -> Vec<String> {
10382            let mut keys: Vec<_> = dedup
10383                .lock()
10384                .iter()
10385                .filter_map(|(key, _)| match key {
10386                    FillKey::Cumulative(key) => Some(key.to_string()),
10387                    FillKey::Execution(_) | FillKey::CumulativeWithoutId(_) => None,
10388                })
10389                .collect();
10390            // The LRU iterates most recent first.
10391            keys.reverse();
10392            keys
10393        }
10394
10395        fn trade_of(
10396            event: &UnindexedAccountEvent,
10397        ) -> &Trade<AssetNameExchange, InstrumentNameExchange> {
10398            match &event.kind {
10399                AccountEventKind::Trade(t) => t,
10400                other => panic!("expected a Trade event, got {other:?}"),
10401            }
10402        }
10403
10404        /// A recovered fill reports where it left the order, so it advances `filled_quantity`
10405        /// without waiting for a `fetch_open_orders` reconciliation that the caller may never make.
10406        #[tokio::test]
10407        async fn a_recovered_fill_reports_the_cumulative_alpaca_sent() {
10408            let events = drive_recover_fills(vec![
10409                activity_json("act-1", "ord-1", "2", Some("2")),
10410                activity_json("act-2", "ord-1", "3", Some("5")),
10411            ])
10412            .await;
10413
10414            let reported: Vec<_> = events
10415                .iter()
10416                .map(|e| trade_of(e).order_filled_quantity)
10417                .collect();
10418            assert_eq!(
10419                reported,
10420                vec![
10421                    Some(Decimal::from_str("2").unwrap()),
10422                    Some(Decimal::from_str("5").unwrap())
10423                ]
10424            );
10425        }
10426
10427        /// The dedup key comes from the venue's own cumulative, not from counting executions
10428        /// within the recovery batch.
10429        ///
10430        /// An order that had already partly filled *before* the recovery window is the case that
10431        /// separates the two, and it is the case the previous implementation got wrong: counting
10432        /// from zero inside the batch yields a key the WebSocket path never emitted for that fill.
10433        #[tokio::test]
10434        async fn the_dedup_key_uses_the_venue_cumulative_not_an_intra_batch_count() {
10435            // Three lots filled before the window; the recovered 2-lot fill takes the order to 5.
10436            let dedup = new_dedup_cache();
10437            drive_recover_fills_with(
10438                vec![activity_json("act-1", "ord-1", "2", Some("5"))],
10439                dedup.clone(),
10440                &KnownLiveOrders::shared(ExchangeId::AlpacaBroker),
10441            )
10442            .await;
10443
10444            assert_eq!(
10445                dedup_keys(&dedup),
10446                vec!["ord-1:5"],
10447                "counting executions within the batch would key this as ord-1:2"
10448            );
10449        }
10450
10451        /// Delivers a WS fill of 2 lots on `ord-1`, leaving it at 5, with `execution_id`, then
10452        /// recovers one activity `activity_id` of the same size and cumulative through the same
10453        /// dedup cache.
10454        async fn recover_after_ws_fill(
10455            execution_id: Option<&str>,
10456            activity_id: &str,
10457        ) -> Vec<UnindexedAccountEvent> {
10458            let dedup = new_dedup_cache();
10459            let update = AlpacaTradeUpdate {
10460                event: SmolStr::new("partial_fill"),
10461                execution_id,
10462                previous_execution_id: None,
10463                order: super::make_order_ws("ord-1", "SPY", "buy", "5"),
10464                price: Some("100.00"),
10465                qty: Some("2"),
10466                timestamp: None,
10467            };
10468            assert!(
10469                !is_duplicate_fill(&dedup, ws_execution_id(&update), early_dedup_key(&update)),
10470                "precondition: first sighting"
10471            );
10472
10473            drive_recover_fills_with(
10474                vec![activity_json(activity_id, "ord-1", "2", Some("5"))],
10475                dedup,
10476                &KnownLiveOrders::shared(ExchangeId::AlpacaBroker),
10477            )
10478            .await
10479        }
10480
10481        /// The property the cache exists for: one fill delivered by both paths is one fill. It is
10482        /// recognised by its execution id, or by its cumulative where either path lacks the id.
10483        #[tokio::test]
10484        async fn a_fill_already_delivered_over_websocket_is_not_recovered_twice() {
10485            for (ws_execution_id, activity_id) in [
10486                (Some("exec-a"), "20250418103000000::exec-a"),
10487                (None, "20250418103000000::exec-a"),
10488                (Some("exec-a"), "act-1"),
10489            ] {
10490                let events = recover_after_ws_fill(ws_execution_id, activity_id).await;
10491                assert!(
10492                    events.is_empty(),
10493                    "a fill already delivered over WebSocket ({ws_execution_id:?}) must not be \
10494                     re-delivered by recovery ({activity_id}), got {events:?}"
10495                );
10496            }
10497        }
10498
10499        /// Streams a `trade_correct` with `fields` (its execution id `exec-c`), then recovers the
10500        /// FILL activity of `exec-c` through the same dedup cache.
10501        async fn recover_after_correction(fields: &str) -> Vec<UnindexedAccountEvent> {
10502            let (tx, _rx) = mpsc::unbounded_channel();
10503            let dedup = new_dedup_cache();
10504            let known = KnownLiveOrders::shared(ExchangeId::AlpacaBroker);
10505            process_ws_text(
10506                &super::amendment_frame("trade_correct", fields),
10507                &tx,
10508                &dedup,
10509                &known,
10510                &mut ExponentialBackoff::new(),
10511            );
10512
10513            drive_recover_fills_with(
10514                vec![activity_json(
10515                    "20250418103100000::exec-c",
10516                    "ord-1",
10517                    "2",
10518                    Some("2"),
10519                )],
10520                dedup,
10521                &known,
10522            )
10523            .await
10524        }
10525
10526        /// A correction's execution was delivered as the replacement trade, so recovery does not
10527        /// deliver it again should Alpaca list it as a FILL activity.
10528        #[tokio::test]
10529        async fn a_corrected_execution_is_not_recovered_again() {
10530            let events = recover_after_correction(
10531                r#""execution_id":"exec-c","previous_execution_id":"exec-a","price":"101.50","qty":"2","#,
10532            )
10533            .await;
10534            assert!(
10535                events.is_empty(),
10536                "the corrected execution was delivered as the replacement, got {events:?}"
10537            );
10538        }
10539
10540        /// An unresolved correction delivered no trade, so recovery still delivers its execution.
10541        #[tokio::test]
10542        async fn an_unresolved_correction_does_not_keep_its_execution_from_recovery() {
10543            let events = recover_after_correction(
10544                r#""execution_id":"exec-c","previous_execution_id":"exec-a","qty":"2","#,
10545            )
10546            .await;
10547            let ids: Vec<_> = events.iter().map(|e| trade_of(e).id.0.as_str()).collect();
10548            assert_eq!(ids, vec!["exec-c"]);
10549        }
10550
10551        /// A different execution that takes the order to a cumulative already seen, as after a
10552        /// bust, is a new fill and is recovered.
10553        #[tokio::test]
10554        async fn a_different_execution_at_a_cumulative_already_seen_is_recovered() {
10555            let events = recover_after_ws_fill(Some("exec-a"), "20250418103000000::exec-b").await;
10556            let ids: Vec<_> = events.iter().map(|e| trade_of(e).id.0.as_str()).collect();
10557            assert_eq!(ids, vec!["exec-b"]);
10558        }
10559
10560        /// With `cum_qty` absent the key falls back to counting within the batch -- exactly what
10561        /// shipped before -- and the fill reports no cumulative rather than a fabricated one.
10562        #[tokio::test]
10563        async fn without_a_reported_cumulative_the_previous_behaviour_is_preserved() {
10564            let dedup = new_dedup_cache();
10565            let events = drive_recover_fills_with(
10566                vec![
10567                    activity_json("act-1", "ord-1", "2", None),
10568                    activity_json("act-2", "ord-1", "3", None),
10569                ],
10570                dedup.clone(),
10571                &KnownLiveOrders::shared(ExchangeId::AlpacaBroker),
10572            )
10573            .await;
10574
10575            assert_eq!(
10576                dedup_keys(&dedup),
10577                vec!["ord-1:2", "ord-1:5"],
10578                "intra-batch accumulation"
10579            );
10580            let ids: Vec<_> = events.iter().map(|e| trade_of(e).id.0.as_str()).collect();
10581            assert_eq!(ids, vec!["act-1", "act-2"], "each keeps its activity's id");
10582
10583            assert!(
10584                events
10585                    .iter()
10586                    .all(|e| trade_of(e).order_filled_quantity.is_none()),
10587                "a venue that reported no cumulative must not have one invented for it"
10588            );
10589        }
10590
10591        // --- How orders ended: fetch_ended_orders, the reconnect check, and what is held ---
10592
10593        /// An order as `GET /v2/orders` and `GET /v2/orders:by_client_order_id` serve it, of
10594        /// quantity 2.
10595        fn order_json(cid: &str, symbol: &str, status: &str, filled: &str) -> serde_json::Value {
10596            serde_json::json!({
10597                "id": format!("id-{cid}"),
10598                "client_order_id": cid,
10599                "symbol": symbol,
10600                "qty": "2",
10601                "filled_qty": filled,
10602                "filled_avg_price": if filled == "0" { None } else { Some("101.5") },
10603                "status": status,
10604                "side": "buy",
10605                "type": "limit",
10606                "time_in_force": "gtc",
10607                "limit_price": "100",
10608                "created_at": "2026-10-01T14:30:00Z",
10609                "updated_at": "2026-10-01T15:00:00Z"
10610            })
10611        }
10612
10613        /// Serve `order` as the lookup of client order id `cid`, expecting `calls` lookups.
10614        async fn mount_lookup(server: &MockServer, cid: &str, order: ResponseTemplate, calls: u64) {
10615            use wiremock::matchers::query_param;
10616            Mock::given(method("GET"))
10617                .and(path("/v2/orders:by_client_order_id"))
10618                .and(query_param("client_order_id", cid))
10619                .respond_with(order)
10620                .expect(calls)
10621                .mount(server)
10622                .await;
10623        }
10624
10625        fn ok(body: serde_json::Value) -> ResponseTemplate {
10626            ResponseTemplate::new(200).set_body_json(body)
10627        }
10628
10629        fn alpaca_key(instrument: &str, cid: &str) -> UnindexedOrderKey {
10630            OrderKey::new(
10631                ExchangeId::AlpacaBroker,
10632                InstrumentNameExchange::new(instrument),
10633                StrategyId::new("strategy"),
10634                ClientOrderId::new(cid),
10635            )
10636        }
10637
10638        fn hold(known: &SharedKnownLiveOrders, key: &UnindexedOrderKey) {
10639            let open = Open::new(
10640                VenueOrderId::Assigned(OrderId::new(format!("id-{}", key.cid))),
10641                Utc::now(),
10642                Decimal::ZERO,
10643            );
10644            known
10645                .lock()
10646                .live(key, Decimal::TWO, OrderKind::Limit, &open);
10647        }
10648
10649        #[tokio::test]
10650        async fn fetch_ended_orders_reads_each_end_alpaca_reports() {
10651            use rust_decimal_macros::dec;
10652
10653            let server = MockServer::start().await;
10654            for (cid, status, filled) in [
10655                ("filled", "filled", "2"),
10656                ("canceled", "canceled", "1"),
10657                ("replaced", "replaced", "0"),
10658                ("expired", "expired", "1"),
10659                ("rejected", "rejected", "0"),
10660                ("done", "done_for_day", "1"),
10661                ("live", "new", "0"),
10662                ("unknown-status", "frozen", "0"),
10663            ] {
10664                mount_lookup(&server, cid, ok(order_json(cid, "SPY", status, filled)), 1).await;
10665            }
10666            mount_lookup(
10667                &server,
10668                "elsewhere",
10669                ok(order_json("elsewhere", "QQQ", "canceled", "0")),
10670                1,
10671            )
10672            .await;
10673            mount_lookup(
10674                &server,
10675                "gone",
10676                ResponseTemplate::new(404).set_body_json(serde_json::json!({
10677                    "code": 40410000,
10678                    "message": "order not found for gone"
10679                })),
10680                1,
10681            )
10682            .await;
10683
10684            let keys: Vec<_> = [
10685                "filled",
10686                "canceled",
10687                "replaced",
10688                "expired",
10689                "rejected",
10690                "done",
10691                "live",
10692                "unknown-status",
10693                "elsewhere",
10694                "gone",
10695            ]
10696            .into_iter()
10697            .map(|cid| alpaca_key("spy", cid))
10698            .collect();
10699            let ended = client_for(&server).fetch_ended_orders(&keys).await.unwrap();
10700
10701            let mut found: Vec<_> = ended
10702                .iter()
10703                .map(|order| (order.key.cid.0.as_str(), &order.state))
10704                .collect();
10705            found.sort_by_key(|(cid, _)| *cid);
10706            let [
10707                (canceled, InactiveOrderState::Cancelled(cancelled)),
10708                (expired, InactiveOrderState::Expired(expiry)),
10709                (filled, InactiveOrderState::FullyFilled(fill)),
10710                (rejected, InactiveOrderState::OpenFailed(failed)),
10711                (replaced, InactiveOrderState::Cancelled(replacement)),
10712            ] = found.as_slice()
10713            else {
10714                panic!("filled, canceled, replaced, expired and rejected: {found:?}");
10715            };
10716            assert_eq!(
10717                [*canceled, *expired, *filled, *rejected, *replaced],
10718                ["canceled", "expired", "filled", "rejected", "replaced"]
10719            );
10720            assert_eq!(
10721                (fill.filled_quantity, fill.avg_price),
10722                (dec!(2), Some(dec!(101.5)))
10723            );
10724            assert_eq!(
10725                cancelled.filled_quantity,
10726                Some(dec!(1)),
10727                "what filled before"
10728            );
10729            assert_eq!(expiry.filled_quantity, Some(dec!(1)));
10730            assert_eq!(replacement.filled_quantity, Some(Decimal::ZERO));
10731            assert_eq!(
10732                cancelled.time_exchange,
10733                parse_timestamp("2026-10-01T15:00:00Z").unwrap(),
10734                "stamped when the order last changed"
10735            );
10736            assert!(matches!(
10737                failed,
10738                OrderError::Rejected(ApiError::OrderRejected(message))
10739                    if message.contains("id-rejected")
10740            ));
10741            assert!(
10742                ended
10743                    .iter()
10744                    .all(|order| order.key.strategy == StrategyId::new("strategy")
10745                        && order.key.instrument == InstrumentNameExchange::new("spy")),
10746                "each carries the key it was asked for"
10747            );
10748        }
10749
10750        #[tokio::test]
10751        async fn fetch_ended_orders_fails_when_a_lookup_fails() {
10752            let server = MockServer::start().await;
10753            mount_lookup(&server, "a", ok(order_json("a", "SPY", "canceled", "0")), 1).await;
10754            mount_lookup(&server, "b", ResponseTemplate::new(500), 1).await;
10755
10756            let result = client_for(&server)
10757                .fetch_ended_orders(&[alpaca_key("SPY", "a"), alpaca_key("SPY", "b")])
10758                .await;
10759
10760            assert!(
10761                matches!(result, Err(UnindexedClientError::Connectivity(_))),
10762                "{result:?}"
10763            );
10764        }
10765
10766        /// One listing covers every instrument due; only the held orders it no longer shows are
10767        /// looked up, and only those that ended are reported and stop being held.
10768        #[tokio::test]
10769        async fn a_reconnect_check_lists_once_and_reports_the_orders_that_ended() {
10770            use wiremock::matchers::query_param;
10771
10772            let server = MockServer::start().await;
10773            Mock::given(method("GET"))
10774                .and(path("/v2/orders"))
10775                .and(query_param("status", "open"))
10776                .and(query_param("symbols", "QQQ,SPY"))
10777                .respond_with(ok(serde_json::json!([order_json(
10778                    "spy-listed",
10779                    "SPY",
10780                    "new",
10781                    "0"
10782                )])))
10783                .expect(1)
10784                .mount(&server)
10785                .await;
10786            mount_lookup(&server, "spy-listed", ResponseTemplate::new(500), 0).await;
10787            mount_lookup(
10788                &server,
10789                "spy-cancelled",
10790                ok(order_json("spy-cancelled", "SPY", "canceled", "1")),
10791                1,
10792            )
10793            .await;
10794            mount_lookup(
10795                &server,
10796                "spy-done",
10797                ok(order_json("spy-done", "SPY", "done_for_day", "1")),
10798                1,
10799            )
10800            .await;
10801            mount_lookup(
10802                &server,
10803                "qqq-filled",
10804                ok(order_json("qqq-filled", "QQQ", "filled", "2")),
10805                1,
10806            )
10807            .await;
10808
10809            let known = KnownLiveOrders::shared(ExchangeId::AlpacaBroker);
10810            let [listed, cancelled, done, filled] = [
10811                ("SPY", "spy-listed"),
10812                ("SPY", "spy-cancelled"),
10813                ("SPY", "spy-done"),
10814                ("QQQ", "qqq-filled"),
10815            ]
10816            .map(|(instrument, cid)| alpaca_key(instrument, cid));
10817            for key in [&listed, &cancelled, &done, &filled] {
10818                hold(&known, key);
10819            }
10820            let mut unchecked = UncheckedOrders::default();
10821            unchecked.open(known.lock().instruments());
10822            let config = Arc::new(AlpacaConfig::with_base_url(
10823                "test-key".into(),
10824                "test-secret".into(),
10825                server.uri(),
10826            ));
10827            let (tx, mut rx) = mpsc::unbounded_channel();
10828
10829            recover_alpaca_ended_orders(
10830                &reqwest::Client::new(),
10831                &Arc::new(RateLimitTracker::new()),
10832                &config,
10833                &known,
10834                &mut unchecked,
10835                &tx,
10836            )
10837            .await;
10838
10839            let mut reported: Vec<_> = std::iter::from_fn(|| rx.try_recv().ok())
10840                .map(|event| {
10841                    let AccountEventKind::OrderSnapshot(
10842                        rustrade_integration::collection::snapshot::Snapshot(order),
10843                    ) = event.kind
10844                    else {
10845                        panic!("an order snapshot: {event:?}");
10846                    };
10847                    assert!(matches!(order.state, OrderState::Inactive(_)), "{order:?}");
10848                    order.key.cid
10849                })
10850                .collect();
10851            reported.sort();
10852            assert_eq!(reported, [filled.cid.clone(), cancelled.cid.clone()]);
10853            let known = known.lock();
10854            assert!(known.contains(&listed.cid) && known.contains(&done.cid));
10855            assert!(!known.contains(&cancelled.cid) && !known.contains(&filled.cid));
10856            assert!(unchecked.is_empty(), "both instruments checked");
10857        }
10858
10859        /// A listing that fails charges every instrument in it, which then waits for a retry.
10860        #[tokio::test]
10861        async fn a_failed_listing_is_retried_for_every_instrument_in_it() {
10862            let server = MockServer::start().await;
10863            Mock::given(method("GET"))
10864                .and(path("/v2/orders"))
10865                .respond_with(ResponseTemplate::new(503))
10866                .expect(1)
10867                .mount(&server)
10868                .await;
10869
10870            let known = KnownLiveOrders::shared(ExchangeId::AlpacaBroker);
10871            hold(&known, &alpaca_key("SPY", "a"));
10872            hold(&known, &alpaca_key("QQQ", "b"));
10873            let mut unchecked = UncheckedOrders::default();
10874            unchecked.open(known.lock().instruments());
10875            let config = Arc::new(AlpacaConfig::with_base_url(
10876                "test-key".into(),
10877                "test-secret".into(),
10878                server.uri(),
10879            ));
10880            let (tx, mut rx) = mpsc::unbounded_channel();
10881
10882            recover_alpaca_ended_orders(
10883                &reqwest::Client::new(),
10884                &Arc::new(RateLimitTracker::new()),
10885                &config,
10886                &known,
10887                &mut unchecked,
10888                &tx,
10889            )
10890            .await;
10891
10892            assert!(rx.try_recv().is_err(), "nothing reported");
10893            let now = tokio::time::Instant::now();
10894            assert!(
10895                unchecked.ready(&NoPendingFills, now).is_empty(),
10896                "both wait"
10897            );
10898            for instrument in ["QQQ", "SPY"] {
10899                assert_eq!(
10900                    unchecked.failed(
10901                        &InstrumentNameExchange::new(instrument),
10902                        now,
10903                        Duration::ZERO
10904                    ),
10905                    Some(crate::client::order_recovery::GapFailure::Retry(
10906                        Duration::from_secs(crate::client::order_recovery::GAP_RETRY_BASE_SECS * 2)
10907                    )),
10908                    "{instrument} has failed once already"
10909                );
10910            }
10911            assert_eq!(known.lock().instruments().len(), 2, "both still held");
10912        }
10913
10914        #[tokio::test]
10915        async fn placing_listing_and_cancelling_keep_the_held_orders_in_step() {
10916            use crate::order::request::{OrderRequestCancel, RequestCancel, RequestOpen};
10917
10918            let server = MockServer::start().await;
10919            Mock::given(method("POST"))
10920                .and(path("/v2/orders"))
10921                .respond_with(ok(order_json("resting", "SPY", "new", "0")))
10922                .up_to_n_times(1)
10923                .mount(&server)
10924                .await;
10925            Mock::given(method("POST"))
10926                .and(path("/v2/orders"))
10927                .respond_with(ok(order_json("filled", "SPY", "filled", "2")))
10928                .mount(&server)
10929                .await;
10930            Mock::given(method("GET"))
10931                .and(path("/v2/orders"))
10932                .respond_with(ok(serde_json::json!([order_json(
10933                    "listed", "QQQ", "new", "0"
10934                )])))
10935                .mount(&server)
10936                .await;
10937            Mock::given(method("DELETE"))
10938                .and(path("/v2/orders/id-resting"))
10939                .respond_with(ResponseTemplate::new(204))
10940                .mount(&server)
10941                .await;
10942            let client = client_for(&server);
10943            let spy = InstrumentNameExchange::new("SPY");
10944
10945            for cid in ["resting", "filled"] {
10946                let request = OrderRequestOpen {
10947                    key: OrderKey::new(
10948                        ExchangeId::AlpacaBroker,
10949                        &spy,
10950                        StrategyId::new("strategy"),
10951                        ClientOrderId::new(cid),
10952                    ),
10953                    state: RequestOpen {
10954                        side: Side::Buy,
10955                        price: Some(Decimal::ONE_HUNDRED),
10956                        quantity: Decimal::TWO,
10957                        kind: OrderKind::Limit,
10958                        time_in_force: TimeInForce::GoodUntilCancelled { post_only: false },
10959                        position_id: None,
10960                        reduce_only: false,
10961                        market: None,
10962                    },
10963                };
10964                client.open_order(request).await;
10965            }
10966            client.fetch_open_orders(&[]).await.unwrap();
10967            {
10968                let known = client.known_live.lock();
10969                assert!(known.contains(&ClientOrderId::new("resting")));
10970                assert!(
10971                    !known.contains(&ClientOrderId::new("filled")),
10972                    "filled in its placement response"
10973                );
10974                assert!(known.contains(&ClientOrderId::new("listed")));
10975            }
10976
10977            let cancel = client
10978                .cancel_order(OrderRequestCancel {
10979                    key: OrderKey::new(
10980                        ExchangeId::AlpacaBroker,
10981                        &spy,
10982                        StrategyId::new("strategy"),
10983                        ClientOrderId::new("resting"),
10984                    ),
10985                    state: RequestCancel {
10986                        id: Some(VenueOrderId::Assigned(OrderId::new("id-resting"))),
10987                    },
10988                })
10989                .await;
10990            let Ok(cancelled) = &cancel.state else {
10991                panic!("expected Ok, got {cancel:?}");
10992            };
10993            assert_eq!(
10994                cancelled.filled_quantity, None,
10995                "the DELETE response has no body, so what filled is unknown"
10996            );
10997            assert!(
10998                client
10999                    .known_live
11000                    .lock()
11001                    .contains(&ClientOrderId::new("resting")),
11002                "a cancel Alpaca has only accepted has not ended the order"
11003            );
11004        }
11005
11006        /// An order that ended in its placement response is reported as it ended, on both
11007        /// placement paths, and is not held as live: an IOC that found no liquidity or partly
11008        /// filled and expired the rest, one cancelled or replaced, one rejected after Alpaca
11009        /// accepted it, and one filled. A live status is reported open, with what filled (zero
11010        /// when that is unknown), and held, as is one this version does not know; one whose fill
11011        /// covers the quantity is reported filled.
11012        #[tokio::test]
11013        async fn an_order_that_ended_in_its_placement_response_is_reported_as_it_ended() {
11014            use crate::order::request::RequestOpen;
11015
11016            let Some(time) = parse_timestamp("2026-10-01T15:00:00Z") else {
11017                panic!("order_json's updated_at parses");
11018            };
11019            let id = |cid: &str| OrderId::new(format!("id-{cid}"));
11020            let open = |cid: &str, filled| {
11021                OrderState::active(Open::new(VenueOrderId::Assigned(id(cid)), time, filled))
11022            };
11023            let cases: [(&str, &str, &str, UnindexedOrderState, bool); 13] = [
11024                (
11025                    "ioc-unfilled",
11026                    "expired",
11027                    "0",
11028                    OrderState::expired(Expired::new(
11029                        id("ioc-unfilled"),
11030                        time,
11031                        Some(Decimal::ZERO),
11032                    )),
11033                    false,
11034                ),
11035                (
11036                    "ioc-partial",
11037                    "expired",
11038                    "1",
11039                    OrderState::expired(Expired::new(id("ioc-partial"), time, Some(Decimal::ONE))),
11040                    false,
11041                ),
11042                (
11043                    "fill-unknown",
11044                    "expired",
11045                    "not-a-number",
11046                    OrderState::expired(Expired::new(id("fill-unknown"), time, None)),
11047                    false,
11048                ),
11049                (
11050                    "cancelled",
11051                    "canceled",
11052                    "1",
11053                    OrderState::inactive(Cancelled::new(id("cancelled"), time, Some(Decimal::ONE))),
11054                    false,
11055                ),
11056                (
11057                    "rejected",
11058                    "rejected",
11059                    "0",
11060                    OrderState::inactive(OrderError::Rejected(ApiError::OrderRejected(
11061                        "Alpaca rejected order id-rejected after accepting it".to_string(),
11062                    ))),
11063                    false,
11064                ),
11065                (
11066                    "filled",
11067                    "filled",
11068                    "2",
11069                    OrderState::fully_filled(Filled::new(
11070                        id("filled"),
11071                        time,
11072                        Decimal::TWO,
11073                        Some(Decimal::new(1015, 1)),
11074                    )),
11075                    false,
11076                ),
11077                (
11078                    "replaced",
11079                    "replaced",
11080                    "0",
11081                    OrderState::inactive(Cancelled::new(id("replaced"), time, Some(Decimal::ZERO))),
11082                    false,
11083                ),
11084                (
11085                    "live-covered",
11086                    "partially_filled",
11087                    "2",
11088                    OrderState::fully_filled(Filled::new(
11089                        id("live-covered"),
11090                        time,
11091                        Decimal::TWO,
11092                        Some(Decimal::new(1015, 1)),
11093                    )),
11094                    false,
11095                ),
11096                ("new", "new", "0", open("new", Decimal::ZERO), true),
11097                (
11098                    "live-fill-unknown",
11099                    "new",
11100                    "not-a-number",
11101                    open("live-fill-unknown", Decimal::ZERO),
11102                    true,
11103                ),
11104                (
11105                    "partial",
11106                    "partially_filled",
11107                    "1",
11108                    open("partial", Decimal::ONE),
11109                    true,
11110                ),
11111                (
11112                    "for-the-day",
11113                    "done_for_day",
11114                    "1",
11115                    open("for-the-day", Decimal::ONE),
11116                    true,
11117                ),
11118                (
11119                    "unknown",
11120                    "a_status_not_yet_documented",
11121                    "0",
11122                    open("unknown", Decimal::ZERO),
11123                    true,
11124                ),
11125            ];
11126
11127            for (cid, status, filled, expected, held) in cases {
11128                for bracket in [false, true] {
11129                    let server = MockServer::start().await;
11130                    Mock::given(method("POST"))
11131                        .and(path("/v2/orders"))
11132                        .respond_with(ok(order_json(cid, "SPY", status, filled)))
11133                        .expect(1)
11134                        .mount(&server)
11135                        .await;
11136                    let client = client_for(&server);
11137                    let spy = InstrumentNameExchange::new("SPY");
11138                    let time_in_force = TimeInForce::GoodUntilCancelled { post_only: false };
11139
11140                    let state = if bracket {
11141                        client
11142                            .open_bracket_order(AlpacaBracketOrderRequest::new(
11143                                spy,
11144                                StrategyId::new("strategy"),
11145                                ClientOrderId::new(cid),
11146                                Side::Buy,
11147                                Decimal::TWO,
11148                                Decimal::ONE_HUNDRED,
11149                                Decimal::new(120, 0),
11150                                Decimal::new(90, 0),
11151                                time_in_force,
11152                            ))
11153                            .await
11154                            .parent
11155                            .state
11156                    } else {
11157                        let request = OrderRequestOpen {
11158                            key: OrderKey::new(
11159                                ExchangeId::AlpacaBroker,
11160                                &spy,
11161                                StrategyId::new("strategy"),
11162                                ClientOrderId::new(cid),
11163                            ),
11164                            state: RequestOpen {
11165                                side: Side::Buy,
11166                                price: Some(Decimal::ONE_HUNDRED),
11167                                quantity: Decimal::TWO,
11168                                kind: OrderKind::Limit,
11169                                time_in_force,
11170                                position_id: None,
11171                                reduce_only: false,
11172                                market: None,
11173                            },
11174                        };
11175                        client.open_order(request).await.state
11176                    };
11177
11178                    assert_eq!(state, expected, "{status} {filled}, bracket: {bracket}");
11179                    assert_eq!(
11180                        client.known_live.lock().contains(&ClientOrderId::new(cid)),
11181                        held,
11182                        "{status} {filled}, bracket: {bracket}"
11183                    );
11184                }
11185            }
11186        }
11187
11188        /// The stream holds an acknowledged order, keeps holding one done for the day (reported
11189        /// open, not retired), and drops one cancelled.
11190        #[test]
11191        fn the_stream_keeps_the_held_orders_in_step() {
11192            let (tx, mut rx) = mpsc::unbounded_channel();
11193            let dedup = new_dedup_cache();
11194            let known = KnownLiveOrders::shared(ExchangeId::AlpacaBroker);
11195            let mut backoff = ExponentialBackoff::new();
11196            let frame = |event: &str, filled: &str| {
11197                format!(
11198                    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}"}}}}}}"#
11199                )
11200            };
11201            let cid = ClientOrderId::new("a");
11202
11203            process_ws_text(&frame("new", "0"), &tx, &dedup, &known, &mut backoff);
11204            assert!(known.lock().contains(&cid), "acknowledged");
11205
11206            process_ws_text(
11207                &frame("done_for_day", "1"),
11208                &tx,
11209                &dedup,
11210                &known,
11211                &mut backoff,
11212            );
11213            assert!(known.lock().contains(&cid), "done for the day is not ended");
11214
11215            process_ws_text(&frame("canceled", "1"), &tx, &dedup, &known, &mut backoff);
11216            assert!(!known.lock().contains(&cid), "cancelled");
11217
11218            let events: Vec<_> = std::iter::from_fn(|| rx.try_recv().ok()).collect();
11219            let [_, done, cancelled] = events.as_slice() else {
11220                panic!("three events: {events:?}");
11221            };
11222            let AccountEventKind::OrderSnapshot(
11223                rustrade_integration::collection::snapshot::Snapshot(order),
11224            ) = &done.kind
11225            else {
11226                panic!("done_for_day reports an order snapshot: {done:?}");
11227            };
11228            let OrderState::Active(ActiveOrderState::Open(open)) = &order.state else {
11229                panic!("open: {order:?}");
11230            };
11231            assert_eq!(open.filled_quantity, Decimal::ONE, "with what it filled");
11232            assert!(matches!(
11233                cancelled.kind,
11234                AccountEventKind::OrderCancelled(_)
11235            ));
11236        }
11237
11238        /// A recovered fill that brings an order to its full quantity ends it, so a reconnect's
11239        /// check does not ask about it.
11240        #[tokio::test]
11241        async fn a_recovered_fill_that_completes_an_order_stops_it_being_held() {
11242            let known = KnownLiveOrders::shared(ExchangeId::AlpacaBroker);
11243            let key = alpaca_key("SPY", "a");
11244            known.lock().live(
11245                &key,
11246                Decimal::TWO,
11247                OrderKind::Limit,
11248                &Open::new(
11249                    VenueOrderId::Assigned(OrderId::new("ord-1")),
11250                    Utc::now(),
11251                    Decimal::ZERO,
11252                ),
11253            );
11254
11255            drive_recover_fills_with(
11256                vec![activity_json("act-1", "ord-1", "1", Some("1"))],
11257                new_dedup_cache(),
11258                &known,
11259            )
11260            .await;
11261            assert!(known.lock().contains(&key.cid), "half filled");
11262
11263            drive_recover_fills_with(
11264                vec![activity_json("act-2", "ord-1", "1", Some("2"))],
11265                new_dedup_cache(),
11266                &known,
11267            )
11268            .await;
11269            assert!(!known.lock().contains(&key.cid), "filled");
11270        }
11271
11272        /// The first check runs as soon as the loop is polled, and a failed one waits for its
11273        /// backoff rather than running again at once.
11274        #[tokio::test]
11275        async fn the_check_loop_runs_at_once_then_waits_out_a_failure() {
11276            let server = MockServer::start().await;
11277            Mock::given(method("GET"))
11278                .and(path("/v2/orders"))
11279                .respond_with(ResponseTemplate::new(503))
11280                .expect(1)
11281                .mount(&server)
11282                .await;
11283            let known = KnownLiveOrders::shared(ExchangeId::AlpacaBroker);
11284            hold(&known, &alpaca_key("SPY", "a"));
11285            let mut unchecked = UncheckedOrders::default();
11286            unchecked.open(known.lock().instruments());
11287            let config = Arc::new(AlpacaConfig::with_base_url(
11288                "test-key".into(),
11289                "test-secret".into(),
11290                server.uri(),
11291            ));
11292            let (tx, _rx) = mpsc::unbounded_channel();
11293
11294            let ran = tokio::time::timeout(
11295                Duration::from_secs(1),
11296                run_order_checks(
11297                    &reqwest::Client::new(),
11298                    &Arc::new(RateLimitTracker::new()),
11299                    &config,
11300                    &known,
11301                    &mut unchecked,
11302                    &tx,
11303                ),
11304            )
11305            .await;
11306
11307            assert!(ran.is_err(), "the loop never completes");
11308            assert!(
11309                unchecked.contains(&InstrumentNameExchange::new("SPY")),
11310                "SPY waits for its retry"
11311            );
11312            assert_eq!(
11313                server.received_requests().await.unwrap().len(),
11314                1,
11315                "the failed check is not run again at once"
11316            );
11317        }
11318
11319        /// Once the consumer has gone, the loop asks nothing.
11320        #[tokio::test]
11321        async fn the_check_loop_waits_once_the_consumer_has_gone() {
11322            let server = MockServer::start().await;
11323            Mock::given(method("GET"))
11324                .respond_with(ResponseTemplate::new(503))
11325                .expect(0)
11326                .mount(&server)
11327                .await;
11328            let known = KnownLiveOrders::shared(ExchangeId::AlpacaBroker);
11329            hold(&known, &alpaca_key("SPY", "a"));
11330            let mut unchecked = UncheckedOrders::default();
11331            unchecked.open(known.lock().instruments());
11332            let config = Arc::new(AlpacaConfig::with_base_url(
11333                "test-key".into(),
11334                "test-secret".into(),
11335                server.uri(),
11336            ));
11337            let (tx, rx) = mpsc::unbounded_channel();
11338            drop(rx);
11339
11340            let ran = tokio::time::timeout(
11341                Duration::from_millis(200),
11342                run_order_checks(
11343                    &reqwest::Client::new(),
11344                    &Arc::new(RateLimitTracker::new()),
11345                    &config,
11346                    &known,
11347                    &mut unchecked,
11348                    &tx,
11349                ),
11350            )
11351            .await;
11352
11353            assert!(ran.is_err(), "the loop never completes");
11354            assert!(
11355                server.received_requests().await.unwrap().is_empty(),
11356                "nothing asked once the consumer has gone"
11357            );
11358        }
11359
11360        async fn shortability_with_asset(
11361            symbol: &str,
11362            asset: serde_json::Value,
11363        ) -> (Result<Shortability, UnindexedClientError>, Vec<String>) {
11364            use crate::client::ShortabilityClient;
11365
11366            let server = MockServer::start().await;
11367            Mock::given(method("GET"))
11368                .respond_with(ResponseTemplate::new(200).set_body_json(asset))
11369                .mount(&server)
11370                .await;
11371            let result = client_for(&server)
11372                .fetch_shortability(&InstrumentNameExchange::new(symbol))
11373                .await;
11374            let paths = server
11375                .received_requests()
11376                .await
11377                .unwrap()
11378                .iter()
11379                .map(|request| request.url.path().to_owned())
11380                .collect();
11381            (result, paths)
11382        }
11383
11384        #[tokio::test]
11385        async fn an_easy_to_borrow_stock_is_shortable_at_no_fee() {
11386            let (result, paths) = shortability_with_asset(
11387                "AAPL",
11388                serde_json::json!({
11389                    "class": "us_equity",
11390                    "symbol": "AAPL",
11391                    "shortable": true,
11392                    "easy_to_borrow": true,
11393                    "borrow_status": "easy_to_borrow",
11394                }),
11395            )
11396            .await;
11397
11398            assert_eq!(
11399                result.unwrap(),
11400                Shortability::new()
11401                    .with_shortable(true)
11402                    .with_easy_to_borrow(true)
11403                    .with_fee_rate(Decimal::ZERO)
11404            );
11405            assert_eq!(paths, vec!["/v2/assets/AAPL"]);
11406        }
11407
11408        #[tokio::test]
11409        async fn a_hard_to_borrow_stock_has_no_known_fee() {
11410            let (result, _) = shortability_with_asset(
11411                "GME",
11412                serde_json::json!({
11413                    "class": "us_equity",
11414                    "shortable": true,
11415                    "easy_to_borrow": true,
11416                    "borrow_status": "hard_to_borrow",
11417                }),
11418            )
11419            .await;
11420
11421            assert_eq!(
11422                result.unwrap(),
11423                Shortability::new()
11424                    .with_shortable(true)
11425                    .with_easy_to_borrow(false),
11426                "borrow_status wins over the easy_to_borrow flag, and a hard-to-borrow fee is unknown"
11427            );
11428        }
11429
11430        #[tokio::test]
11431        async fn without_a_borrow_status_the_easy_to_borrow_flag_is_read() {
11432            let (result, _) = shortability_with_asset(
11433                "AAPL",
11434                serde_json::json!({ "class": "us_equity", "shortable": false, "easy_to_borrow": false }),
11435            )
11436            .await;
11437
11438            assert_eq!(
11439                result.unwrap(),
11440                Shortability::new()
11441                    .with_shortable(false)
11442                    .with_easy_to_borrow(false)
11443            );
11444        }
11445
11446        #[tokio::test]
11447        async fn an_asset_without_borrow_fields_is_unknown_but_for_shortable() {
11448            let (result, paths) = shortability_with_asset(
11449                "BTC/USD",
11450                serde_json::json!({ "class": "crypto", "symbol": "BTC/USD", "shortable": false }),
11451            )
11452            .await;
11453
11454            assert_eq!(result.unwrap(), Shortability::new().with_shortable(false));
11455            assert_eq!(
11456                paths,
11457                vec!["/v2/assets/BTC%2FUSD"],
11458                "a crypto pair's slash is escaped, not read as a path separator"
11459            );
11460        }
11461
11462        #[tokio::test]
11463        async fn an_option_has_no_shortability() {
11464            let (result, _) = shortability_with_asset(
11465                "AAPL250117C00150000",
11466                serde_json::json!({ "class": "us_option", "shortable": true }),
11467            )
11468            .await;
11469
11470            assert!(
11471                matches!(
11472                    result,
11473                    Err(UnindexedClientError::Api(ApiError::InstrumentInvalid(ref instrument, ref reason)))
11474                        if instrument.name().as_str() == "AAPL250117C00150000"
11475                            && reason.contains("option")
11476                ),
11477                "{result:?}"
11478            );
11479        }
11480
11481        #[tokio::test]
11482        async fn an_unknown_symbol_is_an_invalid_instrument() {
11483            use crate::client::ShortabilityClient;
11484
11485            let server = MockServer::start().await;
11486            Mock::given(method("GET"))
11487                .and(path("/v2/assets/NOPE"))
11488                .respond_with(
11489                    ResponseTemplate::new(404).set_body_json(
11490                        serde_json::json!({ "message": "asset not found for NOPE" }),
11491                    ),
11492                )
11493                .mount(&server)
11494                .await;
11495
11496            let result = client_for(&server)
11497                .fetch_shortability(&InstrumentNameExchange::new("NOPE"))
11498                .await;
11499
11500            assert!(
11501                matches!(
11502                    result,
11503                    Err(UnindexedClientError::Api(ApiError::InstrumentInvalid(ref instrument, _)))
11504                        if instrument.name().as_str() == "NOPE"
11505                ),
11506                "{result:?}"
11507            );
11508        }
11509
11510        /// Answers every request with a 429. Every answer but the last says `Retry-After: 0`, so
11511        /// the client's retries do not wait; the last carries `last`'s header, if any.
11512        struct RateLimited {
11513            call: std::sync::atomic::AtomicU32,
11514            last: Option<(&'static str, String)>,
11515        }
11516
11517        impl RateLimited {
11518            fn mount(last: Option<(&'static str, String)>) -> Self {
11519                Self {
11520                    call: std::sync::atomic::AtomicU32::new(0),
11521                    last,
11522                }
11523            }
11524        }
11525
11526        impl Respond for RateLimited {
11527            fn respond(&self, _: &Request) -> ResponseTemplate {
11528                let call = self.call.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1;
11529                let response = ResponseTemplate::new(429)
11530                    .set_body_json(serde_json::json!({"message": "too many requests"}));
11531                if call < MAX_RATE_LIMIT_ATTEMPTS {
11532                    return response.insert_header("retry-after", "0");
11533                }
11534                match &self.last {
11535                    Some((name, value)) => response.insert_header(*name, value.as_str()),
11536                    None => response,
11537                }
11538            }
11539        }
11540
11541        fn unix_now() -> u64 {
11542            std::time::SystemTime::now()
11543                .duration_since(std::time::UNIX_EPOCH)
11544                .unwrap()
11545                .as_secs()
11546        }
11547
11548        #[tokio::test]
11549        async fn exhausted_429_retries_report_alpacas_wait() {
11550            let server = MockServer::start().await;
11551            Mock::given(method("GET"))
11552                .and(path("/v2/account"))
11553                .respond_with(RateLimited::mount(Some(("retry-after", "7".to_string()))))
11554                .mount(&server)
11555                .await;
11556
11557            let http = reqwest::Client::new();
11558            let rl = RateLimitTracker::new();
11559            let url = format!("{}/v2/account", server.uri());
11560            let error = rest_with_retry::<serde_json::Value>(&rl, || http.get(&url))
11561                .await
11562                .unwrap_err();
11563
11564            let UnindexedClientError::Api(error) = error else {
11565                panic!("expected Api, got {error:?}");
11566            };
11567            assert_eq!(error.retry_after(), Some(Duration::from_secs(7)));
11568            let blocked_until = rl
11569                .blocked_until
11570                .lock()
11571                .expect("the last refusal pauses the client");
11572            assert!(
11573                blocked_until >= tokio::time::Instant::now() + Duration::from_secs(6),
11574                "the client's next request waits for Alpaca's wait"
11575            );
11576            assert_eq!(
11577                server.received_requests().await.unwrap().len(),
11578                MAX_RATE_LIMIT_ATTEMPTS as usize
11579            );
11580        }
11581
11582        #[tokio::test]
11583        async fn exhausted_429_retries_read_the_reset_time_without_retry_after() {
11584            let server = MockServer::start().await;
11585            Mock::given(method("GET"))
11586                .and(path("/v2/account"))
11587                .respond_with(RateLimited::mount(Some((
11588                    "x-ratelimit-reset",
11589                    (unix_now() + 30).to_string(),
11590                ))))
11591                .mount(&server)
11592                .await;
11593
11594            let http = reqwest::Client::new();
11595            let rl = RateLimitTracker::new();
11596            let url = format!("{}/v2/account", server.uri());
11597            let error = rest_with_retry::<serde_json::Value>(&rl, || http.get(&url))
11598                .await
11599                .unwrap_err();
11600
11601            let UnindexedClientError::Api(ApiError::RateLimit {
11602                retry_after: Some(wait),
11603                ..
11604            }) = error
11605            else {
11606                panic!("expected a RateLimit with a wait, got {error:?}");
11607            };
11608            assert!(
11609                (Duration::from_secs(29)..=Duration::from_secs(30)).contains(&wait),
11610                "{wait:?}"
11611            );
11612        }
11613
11614        #[tokio::test]
11615        async fn exhausted_429_retries_without_a_hint_report_none() {
11616            let server = MockServer::start().await;
11617            Mock::given(method("GET"))
11618                .and(path("/v2/account"))
11619                .respond_with(RateLimited::mount(None))
11620                .mount(&server)
11621                .await;
11622
11623            let http = reqwest::Client::new();
11624            let rl = RateLimitTracker::new();
11625            let url = format!("{}/v2/account", server.uri());
11626            let error = rest_with_retry::<serde_json::Value>(&rl, || http.get(&url))
11627                .await
11628                .unwrap_err();
11629
11630            assert!(
11631                matches!(
11632                    error,
11633                    UnindexedClientError::Api(ApiError::RateLimit {
11634                        retry_after: None,
11635                        ..
11636                    })
11637                ),
11638                "{error:?}"
11639            );
11640        }
11641
11642        #[tokio::test]
11643        async fn an_exhausted_cancel_reports_alpacas_wait() {
11644            let server = MockServer::start().await;
11645            Mock::given(method("DELETE"))
11646                .and(path("/v2/orders/abc"))
11647                .respond_with(RateLimited::mount(Some(("retry-after", "5".to_string()))))
11648                .mount(&server)
11649                .await;
11650
11651            let http = reqwest::Client::new();
11652            let rl = RateLimitTracker::new();
11653            let url = format!("{}/v2/orders/abc", server.uri());
11654            let error = rest_delete_with_retry(&rl, || http.delete(&url))
11655                .await
11656                .unwrap_err();
11657
11658            let UnindexedOrderError::Rejected(error) = error else {
11659                panic!("expected Rejected, got {error:?}");
11660            };
11661            assert_eq!(error.retry_after(), Some(Duration::from_secs(5)));
11662            assert!(
11663                rl.blocked_until.lock().is_some(),
11664                "the last refusal pauses the client"
11665            );
11666        }
11667    }
11668}