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        reconnect_budget::{BackoffLimits, ReconnectBudget},
57    },
58    emit_stream_terminated,
59    error::{
60        ApiError, BorrowReject, BorrowRejectReason, ConnectivityError, OrderError,
61        StreamTerminationReason, UnindexedApiError, UnindexedClientError, UnindexedOrderError,
62    },
63    order::{
64        Order, OrderKey, OrderKind, TimeInForce, TrailingOffsetType, UnindexedInactiveOrder,
65        UnindexedOrderKey, UnindexedOrderSnapshot,
66        bracket::{
67            BracketOrderRequest as UnifiedBracketOrderRequest,
68            BracketOrderResult as UnifiedBracketOrderResult,
69        },
70        id::{ClientOrderId, OrderId, StrategyId, VenueOrderId},
71        request::{OrderRequestCancel, OrderRequestOpen, ReduceOnly, UnindexedOrderResponseCancel},
72        state::{
73            ActiveOrderState, Cancelled, Expired, Filled, InactiveOrderState, Open, OrderState,
74            UnindexedOrderState,
75        },
76    },
77    parse_env_bool,
78    position::{Position, PositionReport},
79    shortability::Shortability,
80    trade::{AssetFees, Trade, TradeAmendmentKind, TradeId, TradeOrigin, TradesRead},
81};
82use chrono::{DateTime, NaiveDate, NaiveTime, SecondsFormat, TimeDelta, Utc};
83use fnv::{FnvHashMap, FnvHashSet};
84use futures::{SinkExt as _, StreamExt as _, stream::BoxStream};
85use indexmap::IndexMap;
86use itertools::Itertools as _;
87use lru::LruCache;
88use rust_decimal::Decimal;
89use rustrade_instrument::{
90    Side,
91    asset::name::AssetNameExchange,
92    exchange::ExchangeId,
93    instrument::{kind::InstrumentKindDiscriminant, name::InstrumentNameExchange},
94};
95use rustrade_integration::protocol::websocket::{WebSocket, WsMessage};
96use serde::{Deserialize, Serialize, de::DeserializeOwned};
97use smol_str::{SmolStr, format_smolstr};
98use std::{num::NonZeroUsize, pin::Pin, str::FromStr, sync::Arc, time::Duration};
99use tokio::sync::mpsc;
100use tracing::{debug, error, info, trace, warn};
101
102// ---------------------------------------------------------------------------
103// Constants
104// ---------------------------------------------------------------------------
105
106/// If no WS activity for this long, force reconnect.
107const HEARTBEAT_TIMEOUT_SECS: u64 = 35;
108/// Timeout for fill recovery REST queries after reconnect.
109const FILL_RECOVERY_TIMEOUT_SECS: u64 = 30;
110/// Extra lookback from disconnect timestamp to cover Tokio scheduling jitter and
111/// client/server clock drift on cloud VMs. The dedup cache absorbs resulting duplicates.
112const SIGNAL_RECOVERY_LOOKBACK_MS: i64 = 1_500;
113/// Alpaca's activity page size limit.
114const ALPACA_MAX_ACTIVITIES: usize = 100;
115/// Cooldown when rate-limited without a readable wait, and the longest wait read from Alpaca's
116/// headers: the length of its rate-limit window.
117const DEFAULT_RATE_LIMIT_DELAY_SECS: u64 = 60;
118/// Total REST attempts (1 initial + retries) before giving up on rate-limit errors.
119/// The loop runs `0..MAX_RATE_LIMIT_ATTEMPTS`, retrying while `attempt + 1 < MAX`.
120const MAX_RATE_LIMIT_ATTEMPTS: u32 = 4;
121/// Dedup LRU cache size, in keys. Each fill records two (see [`is_duplicate_fill`]), so this holds
122/// the last 2,000 fills. Each key is a ~40–70 byte string (a UUID, or a UUID and a decimal):
123/// 4_000 keys ≈ 250 KB — ample for options trading fill rates.
124const DEDUP_CACHE_SIZE: usize = 4_000;
125
126/// The account currency, named as Alpaca spells it. Every USD balance and fee is reported under
127/// it.
128const USD: &str = "USD";
129/// Timeout for the initial WS auth+subscribe handshake.
130const WS_HANDSHAKE_TIMEOUT_SECS: u64 = 15;
131/// Timeout for a graceful WS close. Prevents indefinite blocking when the
132/// server does not respond to the close frame before reconnect/shutdown.
133const WS_CLOSE_TIMEOUT_SECS: u64 = 5;
134
135// ---------------------------------------------------------------------------
136// GracefulShutdownStream
137// ---------------------------------------------------------------------------
138
139/// Wrapper stream that signals the `connection_manager` task to shut down gracefully
140/// when dropped, allowing it to send an orderly WebSocket close frame.
141///
142/// # Shutdown sequence
143/// Dropping this stream drops `inner` (the channel receiver), which makes `tx.closed()`
144/// resolve on the `connection_manager`'s next `select!` poll. The `tx.closed()` arm
145/// sends a WebSocket close frame (with `WS_CLOSE_TIMEOUT_SECS` timeout) and then
146/// returns, dropping the task cleanly.
147///
148/// `JoinHandle::drop` detaches the task — it is NOT aborted. The task exits within
149/// the current `select!` iteration (if the receiver is already dropped when polled)
150/// or after the current heartbeat window / backoff sleep at most.
151struct GracefulShutdownStream<S> {
152    inner: S,
153    /// Keeps the `JoinHandle` alive until this stream is dropped. Dropping
154    /// the `JoinHandle` detaches (not cancels) the task, allowing it to keep
155    /// running until `tx.closed()` resolves. Without this field the handle
156    /// would be detached immediately at `connection_manager` spawn time,
157    /// preventing any future `.await` or abort if the design changes.
158    _handle: tokio::task::JoinHandle<()>,
159}
160
161impl<S> GracefulShutdownStream<S> {
162    fn new(inner: S, handle: tokio::task::JoinHandle<()>) -> Self {
163        Self {
164            inner,
165            _handle: handle,
166        }
167    }
168}
169
170impl<S: futures::Stream + Unpin> futures::Stream for GracefulShutdownStream<S> {
171    type Item = S::Item;
172    fn poll_next(
173        mut self: Pin<&mut Self>,
174        cx: &mut std::task::Context<'_>,
175    ) -> std::task::Poll<Option<Self::Item>> {
176        Pin::new(&mut self.inner).poll_next(cx)
177    }
178    fn size_hint(&self) -> (usize, Option<usize>) {
179        self.inner.size_hint()
180    }
181}
182
183impl<S> Drop for GracefulShutdownStream<S> {
184    fn drop(&mut self) {
185        // Do not abort. Dropping `self.inner` (the channel receiver) makes `tx.closed()`
186        // resolve, which causes `connection_manager` to send a graceful WS close frame
187        // and return. Dropping the JoinHandle here detaches (not cancels) the task.
188    }
189}
190
191// ---------------------------------------------------------------------------
192// Rate limit tracker
193// ---------------------------------------------------------------------------
194
195/// Thread-safe rate-limit state shared across all clones of AlpacaClient.
196struct RateLimitTracker {
197    blocked_until: parking_lot::Mutex<Option<tokio::time::Instant>>,
198}
199
200impl RateLimitTracker {
201    fn new() -> Self {
202        Self {
203            blocked_until: parking_lot::Mutex::new(None),
204        }
205    }
206
207    /// Sleep until the current cooldown expires. Returns immediately if not blocked.
208    async fn wait_if_blocked(&self) {
209        loop {
210            // Capture the current time once per iteration — reused for both the
211            // expired-deadline check inside the lock and the sleep calculation below.
212            // Eliminates one vDSO call per REST request on the common non-blocked path.
213            let now = tokio::time::Instant::now();
214            // Read and conditionally clear the deadline in a single lock acquisition.
215            // The guard is dropped before the `.await` below — holding a sync Mutex
216            // across an await would deadlock. A TOCTOU window still exists between the
217            // guard drop and `sleep_until`: a concurrent `on_rate_limited` call could
218            // extend the deadline after we read it. The loop re-reads on wake and
219            // corrects any extended deadline, so the race is recovered from on the next
220            // iteration rather than being fully prevented.
221            let deadline = {
222                let mut guard = self.blocked_until.lock();
223                let d = *guard;
224                if matches!(d, Some(t) if t <= now) {
225                    // Clear expired deadline so on_rate_limited correctly
226                    // distinguishes "new rate-limit event" from "extended cooldown".
227                    *guard = None;
228                }
229                d
230            };
231            match deadline {
232                None => return,
233                Some(until) => {
234                    if until <= now {
235                        // Deadline was expired and cleared above; no sleep needed.
236                        return;
237                    }
238                    // as_millis() returns u128; truncation impossible (u64::MAX ms ≈ 584M years)
239                    #[allow(clippy::cast_possible_truncation)]
240                    let delay_ms = (until - now).as_millis() as u64;
241                    debug!(delay_ms, "Alpaca REST rate-limited, waiting before request");
242                    tokio::time::sleep_until(until).await;
243                }
244            }
245        }
246    }
247
248    /// Record a rate-limit event, extending any existing cooldown if longer.
249    fn on_rate_limited(&self, retry_after: Option<Duration>) {
250        let delay = retry_after.unwrap_or(Duration::from_secs(DEFAULT_RATE_LIMIT_DELAY_SECS));
251        let was_blocked = self.extend(delay);
252        if was_blocked {
253            debug!(
254                delay_secs = delay.as_secs(),
255                "Alpaca rate-limit cooldown extended"
256            );
257        } else {
258            warn!(
259                delay_secs = delay.as_secs(),
260                "Alpaca entering rate-limit degradation mode"
261            );
262        }
263    }
264
265    /// Record a response reporting no requests left in the current window: pause every request
266    /// until the window resets, `reset` from now, so the next one is not refused with a 429.
267    fn on_bucket_exhausted(&self, reset: Duration) {
268        let was_blocked = self.extend(reset);
269        // info! not warn!: nothing was refused. A 429 logs at warn.
270        if !was_blocked {
271            info!(
272                delay_ms = u64::try_from(reset.as_millis()).unwrap_or(u64::MAX),
273                "Alpaca rate-limit window exhausted (X-Ratelimit-Remaining: 0), pausing requests until it resets"
274            );
275        }
276    }
277
278    /// Push the cooldown out to `delay` from now unless it already ends later, and return whether
279    /// one was active.
280    fn extend(&self, delay: Duration) -> bool {
281        let now = tokio::time::Instant::now();
282        let new_deadline = now + delay;
283        let mut guard = self.blocked_until.lock();
284        let was_blocked = guard.is_some_and(|until| until > now);
285        *guard = Some(guard.map_or(new_deadline, |existing| existing.max(new_deadline)));
286        was_blocked
287    }
288}
289
290// ---------------------------------------------------------------------------
291// Dedup cache
292// ---------------------------------------------------------------------------
293
294/// LRU cache of the fills the account stream has delivered, so that a fill both the WebSocket
295/// and fill recovery deliver, in the overlap around a reconnect, is sent once. See
296/// [`is_duplicate_fill`] for how a fill is recognised.
297type SharedDedupCache = Arc<parking_lot::Mutex<LruCache<FillKey, ()>>>;
298
299/// One key under which [`SharedDedupCache`] records a fill.
300///
301/// [`SmolStr`] keys avoid heap allocation for ids ≤22 bytes. UUID-length keys (36 chars) always
302/// heap-allocate.
303#[derive(Debug, Clone, PartialEq, Eq, Hash)]
304enum FillKey {
305    /// The fill's execution id: the WS frame's `execution_id`, or the UUID after `::` in its FILL
306    /// activity's id ([`activity_execution_id_part`]).
307    Execution(SmolStr),
308    /// The fill's [`fill_dedup_key`], recorded for every fill.
309    Cumulative(SmolStr),
310    /// The fill's [`fill_dedup_key`], recorded only for a fill without a usable execution id.
311    CumulativeWithoutId(SmolStr),
312}
313
314fn new_dedup_cache() -> SharedDedupCache {
315    // allow(clippy::unwrap_used) — NonZeroUsize::new on a non-zero constant
316    // cannot fail at runtime.
317    #[allow(clippy::unwrap_used)]
318    Arc::new(parking_lot::Mutex::new(LruCache::new(
319        NonZeroUsize::new(DEDUP_CACHE_SIZE).unwrap(),
320    )))
321}
322
323/// Returns `true` if this fill was already delivered (duplicate). Records it if not.
324///
325/// `execution_id` is the fill's execution id, `None` when it has no usable one; `cumulative` is
326/// its [`fill_dedup_key`].
327///
328/// A fill is identified by its execution id, which both paths carry and which Alpaca's activity
329/// docs name as the dedup key. The cumulative key alone would not do: a bust lowers an order's
330/// cumulative, after which a new fill can take it back to a cumulative an earlier fill reached,
331/// and that fill would be taken for the earlier one and dropped.
332///
333/// A fill without an execution id is recognised by its cumulative key instead. So that it still
334/// matches the same fill delivered *with* an id by the other path, every fill records its
335/// cumulative key, and a fill with an id is also checked against the cumulative keys of fills
336/// recorded without one.
337///
338/// So after a bust, a new fill can still be taken for an earlier one at the same cumulative when
339/// either lacks an execution id: a fill without one matches any earlier fill's cumulative key,
340/// and a fill with one matches an earlier id-less fill's. An id-less WS fill is logged at `warn!`
341/// by [`convert_trade_update`].
342fn is_duplicate_fill(
343    cache: &SharedDedupCache,
344    execution_id: Option<&str>,
345    cumulative: SmolStr,
346) -> bool {
347    // Built before locking, so the lock is not held across the allocation.
348    let execution = execution_id.map(|id| FillKey::Execution(SmolStr::new(id)));
349    let mut guard = cache.lock();
350    // peek avoids promoting to MRU on the duplicate (discard) path
351    match execution {
352        Some(execution) => {
353            if guard.peek(&execution).is_some()
354                || guard
355                    .peek(&FillKey::CumulativeWithoutId(cumulative.clone()))
356                    .is_some()
357            {
358                return true;
359            }
360            guard.put(execution, ());
361            guard.put(FillKey::Cumulative(cumulative), ());
362        }
363        None => {
364            if guard
365                .peek(&FillKey::Cumulative(cumulative.clone()))
366                .is_some()
367            {
368                return true;
369            }
370            guard.put(FillKey::Cumulative(cumulative.clone()), ());
371            guard.put(FillKey::CumulativeWithoutId(cumulative), ());
372        }
373    }
374    false
375}
376
377/// Record an execution delivered other than as a fill, so that fill recovery does not deliver it
378/// again as one.
379fn record_execution(cache: &SharedDedupCache, execution_id: &str) {
380    let execution = FillKey::Execution(SmolStr::new(execution_id));
381    cache.lock().put(execution, ());
382}
383
384// ---------------------------------------------------------------------------
385// Configuration
386// ---------------------------------------------------------------------------
387
388/// Configuration for the Alpaca execution client.
389// Serialize intentionally omitted — would expose secret_key in plaintext.
390#[derive(Clone, Deserialize)]
391#[serde(deny_unknown_fields)]
392pub struct AlpacaConfig {
393    // Private fields prevent accidental credential exposure via struct access.
394    api_key: String,
395    secret_key: String,
396
397    /// Use paper trading endpoints instead of production.
398    #[serde(default = "default_paper")]
399    pub paper: bool,
400    /// Test-only: override the REST base URL (e.g., to point at a wiremock server).
401    #[cfg(test)]
402    #[serde(skip)]
403    base_url_override: Option<String>,
404    /// Test-only: override the WebSocket URL (e.g., to point at a local server).
405    #[cfg(test)]
406    #[serde(skip)]
407    ws_url_override: Option<String>,
408}
409
410/// Serde default for [`AlpacaConfig::paper`]: an absent `paper` field deserializes to the **safe**
411/// paper environment (`true`).
412///
413/// `#[serde(default = "…")]` requires a named function (it cannot take a literal), so this exists
414/// purely to supply that default to the derive.
415fn default_paper() -> bool {
416    true
417}
418
419// Custom Debug to avoid leaking credentials in logs.
420impl std::fmt::Debug for AlpacaConfig {
421    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
422        f.debug_struct("AlpacaConfig")
423            .field("api_key", &"***")
424            .field("secret_key", &"***")
425            .field("paper", &self.paper)
426            .finish()
427    }
428}
429
430impl AlpacaConfig {
431    /// Create a new Alpaca config using the **safe** paper-trading endpoints (alias for
432    /// [`paper`](Self::paper)).
433    pub fn new(api_key: String, secret_key: String) -> Self {
434        Self::paper(api_key, secret_key)
435    }
436
437    /// Create a config targeting Alpaca's **paper-trading** endpoints (simulated funds).
438    pub fn paper(api_key: String, secret_key: String) -> Self {
439        Self {
440            api_key,
441            secret_key,
442            paper: true,
443            #[cfg(test)]
444            base_url_override: None,
445            #[cfg(test)]
446            ws_url_override: None,
447        }
448    }
449
450    /// Create a config targeting Alpaca's **production** endpoints.
451    ///
452    /// ⚠️ Production trades execute against the live account with **real funds**. Prefer
453    /// [`paper`](Self::paper) unless you explicitly intend live execution.
454    pub fn production(api_key: String, secret_key: String) -> Self {
455        Self {
456            api_key,
457            secret_key,
458            paper: false,
459            #[cfg(test)]
460            base_url_override: None,
461            #[cfg(test)]
462            ws_url_override: None,
463        }
464    }
465
466    /// Build a config from environment variables.
467    ///
468    /// Reads:
469    /// - `ALPACA_API_KEY` (required) — API key id.
470    /// - `ALPACA_SECRET_KEY` (required) — API secret.
471    /// - `ALPACA_PAPER` (optional) — `"true"`/`"false"` (case-insensitive). **Absent ⇒ the safe
472    ///   paper environment.** Set `ALPACA_PAPER=false` to target production (real funds).
473    ///
474    /// # Errors
475    ///
476    /// Returns [`AlpacaConfigError`] (never panics):
477    /// - a required credential var is unset ([`MissingApiKey`](AlpacaConfigError::MissingApiKey) /
478    ///   [`MissingSecretKey`](AlpacaConfigError::MissingSecretKey)) or holds non-UTF-8
479    ///   ([`InvalidApiKey`](AlpacaConfigError::InvalidApiKey) /
480    ///   [`InvalidSecretKey`](AlpacaConfigError::InvalidSecretKey));
481    /// - `ALPACA_PAPER` is neither `true` nor `false`, or holds non-UTF-8
482    ///   ([`InvalidPaper`](AlpacaConfigError::InvalidPaper)).
483    pub fn from_env() -> Result<Self, AlpacaConfigError> {
484        let api_key = match std::env::var("ALPACA_API_KEY") {
485            Ok(value) => value,
486            Err(std::env::VarError::NotPresent) => return Err(AlpacaConfigError::MissingApiKey),
487            Err(std::env::VarError::NotUnicode(_)) => {
488                return Err(AlpacaConfigError::InvalidApiKey);
489            }
490        };
491        let secret_key = match std::env::var("ALPACA_SECRET_KEY") {
492            Ok(value) => value,
493            Err(std::env::VarError::NotPresent) => return Err(AlpacaConfigError::MissingSecretKey),
494            Err(std::env::VarError::NotUnicode(_)) => {
495                return Err(AlpacaConfigError::InvalidSecretKey);
496            }
497        };
498
499        let paper = match std::env::var("ALPACA_PAPER") {
500            Ok(value) => parse_env_bool(&value).ok_or(AlpacaConfigError::InvalidPaper(value))?,
501            Err(std::env::VarError::NotPresent) => true,
502            // The toggle value is not secret, so echo it (lossily) like the parse-failure arm above —
503            // an actionable "got X" beats a hardcoded sentinel.
504            Err(std::env::VarError::NotUnicode(value)) => {
505                return Err(AlpacaConfigError::InvalidPaper(
506                    value.to_string_lossy().into_owned(),
507                ));
508            }
509        };
510
511        if paper {
512            Ok(Self::paper(api_key, secret_key))
513        } else {
514            Ok(Self::production(api_key, secret_key))
515        }
516    }
517
518    /// Test-only: create config with a custom base URL for wiremock testing.
519    #[cfg(test)]
520    pub fn with_base_url(api_key: String, secret_key: String, base_url: String) -> Self {
521        Self {
522            api_key,
523            secret_key,
524            paper: true,
525            base_url_override: Some(base_url),
526            ws_url_override: None,
527        }
528    }
529
530    pub fn api_key(&self) -> &str {
531        &self.api_key
532    }
533
534    /// Base URL for REST API calls.
535    ///
536    /// In test builds, checks `base_url_override` first to allow wiremock testing.
537    pub fn rest_base_url(&self) -> &str {
538        #[cfg(test)]
539        if let Some(ref url) = self.base_url_override {
540            return url.as_str();
541        }
542        if self.paper {
543            "https://paper-api.alpaca.markets"
544        } else {
545            "https://api.alpaca.markets"
546        }
547    }
548
549    /// WebSocket URL for trade_updates stream.
550    pub fn ws_url(&self) -> &'static str {
551        if self.paper {
552            "wss://paper-api.alpaca.markets/stream"
553        } else {
554            "wss://api.alpaca.markets/stream"
555        }
556    }
557
558    /// The URL the account stream connects to: [`ws_url`](Self::ws_url), or in test builds
559    /// `ws_url_override` when set.
560    fn stream_url(&self) -> &str {
561        #[cfg(test)]
562        if let Some(ref url) = self.ws_url_override {
563            return url.as_str();
564        }
565        self.ws_url()
566    }
567}
568
569#[derive(Debug, PartialEq, thiserror::Error)]
570pub enum AlpacaConfigError {
571    #[error("ALPACA_API_KEY environment variable not set")]
572    MissingApiKey,
573
574    // No payload: the raw value is secret-key material, so it must never be echoed into an error
575    // message or log. The variant name already identifies which credential var is non-UTF-8.
576    #[error("ALPACA_API_KEY environment variable is not valid UTF-8")]
577    InvalidApiKey,
578
579    #[error("ALPACA_SECRET_KEY environment variable not set")]
580    MissingSecretKey,
581
582    #[error("ALPACA_SECRET_KEY environment variable is not valid UTF-8")]
583    InvalidSecretKey,
584
585    #[error("ALPACA_PAPER must be true or false, got {0}")]
586    InvalidPaper(String),
587}
588
589// ---------------------------------------------------------------------------
590// REST response serde types
591// ---------------------------------------------------------------------------
592
593/// The fields of GET /v2/account the USD balance is built from.
594///
595/// Amounts are decoded as decimals, so a malformed one fails the request instead of reading as
596/// zero.
597#[derive(Debug, Deserialize)]
598struct AlpacaAccount {
599    /// Settled and unsettled cash. Negative when the account has borrowed on margin; a short
600    /// sale's proceeds are credited to it.
601    #[serde(with = "rust_decimal::serde::str")]
602    cash: Decimal,
603    /// Buying power for securities that cannot be bought on margin. On a margin account it is
604    /// equity less the initial margin requirement, so it counts the loan value of held
605    /// marginable stock and can exceed cash; a short sale's proceeds do not raise it.
606    #[serde(with = "rust_decimal::serde::str")]
607    non_marginable_buying_power: Decimal,
608}
609
610/// A single position returned by GET /v2/positions.
611///
612/// Amounts are decoded as decimals, so a malformed one fails the request instead of reading as
613/// zero or as a flat position.
614#[derive(Debug, Deserialize)]
615struct AlpacaPosition {
616    /// Exchange symbol (e.g., "BTCUSD" for crypto, "AAPL" for equity, an OCC symbol for an
617    /// option).
618    symbol: String,
619    /// Asset class: "us_equity", "crypto", "us_option".
620    asset_class: String,
621    /// Direction of the position. The size is taken from `qty`'s magnitude and the sign from
622    /// this, so a short reads as short whichever sign `qty` carries. Missing reads as
623    /// [`AlpacaPositionSide::Unknown`].
624    #[serde(default)]
625    side: AlpacaPositionSide,
626    /// Quantity held: shares, contracts, or base currency for crypto.
627    #[serde(with = "rust_decimal::serde::str")]
628    qty: Decimal,
629    /// Quantity available to trade (not locked in open orders).
630    #[serde(with = "rust_decimal::serde::str")]
631    qty_available: Decimal,
632    /// Average entry price per unit.
633    #[serde(default, with = "rust_decimal::serde::str_option")]
634    avg_entry_price: Option<Decimal>,
635    /// Unrealised profit or loss of the whole position, in USD.
636    #[serde(default, with = "rust_decimal::serde::str_option")]
637    unrealized_pl: Option<Decimal>,
638}
639
640/// Direction of an [`AlpacaPosition`].
641#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize)]
642#[serde(rename_all = "lowercase")]
643enum AlpacaPositionSide {
644    Long,
645    Short,
646    /// A missing side or a value Alpaca does not document. Decoded rather than rejected so that
647    /// one position cannot fail the whole response, which also carries the crypto balances; a
648    /// position that needs a direction fails in [`convert_positions`] instead.
649    #[default]
650    #[serde(other)]
651    Unknown,
652}
653
654#[derive(Debug, Deserialize)]
655struct AlpacaOrderResponse {
656    id: String,
657    client_order_id: Option<String>,
658    symbol: String,
659    qty: Option<String>,
660    filled_qty: String,
661    /// The average price of the order's fills, when any filled.
662    #[serde(default)]
663    filled_avg_price: Option<String>,
664    /// The order's status: `new`, `filled`, `canceled`, and so on. Read by the lookup of how an
665    /// order ended and from the response to placing one. Optional so that a response without it
666    /// still decodes.
667    #[serde(default)]
668    status: Option<String>,
669    side: String,
670    #[serde(rename = "type")]
671    order_type: String,
672    time_in_force: String,
673    limit_price: Option<String>,
674    stop_price: Option<String>,
675    trail_percent: Option<String>,
676    trail_price: Option<String>,
677    created_at: String,
678    /// When the order last changed state. Nullable in Alpaca's schema, so callers fall back to
679    /// `created_at` via [`order_state_time`].
680    updated_at: Option<String>,
681}
682
683#[derive(Debug, Deserialize)]
684struct AlpacaActivity {
685    id: String,
686    order_id: String,
687    symbol: String,
688    side: String,
689    price: String,
690    qty: String,
691    transaction_time: String,
692    /// The order's cumulative filled quantity as of this execution, as Alpaca reported it --
693    /// the same figure the WebSocket path reads from `order.filled_qty`.
694    ///
695    /// Alpaca's schema lists this on trade activities without qualifying by fill type -- unlike
696    /// `leaves_qty`, which it documents as partial-fill-specific. It is parsed as optional
697    /// regardless, so an omission degrades to the fallback below rather than discarding the
698    /// whole activity.
699    cum_qty: Option<String>,
700}
701
702/// A non-trade account activity, as Alpaca's activities endpoint serves it: a cash movement such as
703/// interest or a fee, read by [`AlpacaClient::fetch_cash_flows`], or an option exercise,
704/// assignment or expiry, read by [`AlpacaClient::fetch_option_events`]. A cash flow uses the
705/// amount, currency and symbol; an option event uses the quantity, price, symbol and date. Every
706/// field but the id and type is optional in Alpaca's schema, so a record missing one still decodes
707/// and the conversion decides what it can do without it.
708#[derive(Debug, Deserialize)]
709struct AlpacaNonTradeActivity {
710    id: String,
711    activity_type: String,
712    #[serde(default)]
713    activity_sub_type: Option<String>,
714    /// When Alpaca created the activity, which its `after` and `until` filter on. For a fee, this
715    /// is typically the day after the trade it was charged on.
716    #[serde(default)]
717    created_at: Option<String>,
718    /// The day the activity occurred or settled, as `YYYY-MM-DD`. A cash flow's time uses
719    /// `created_at`, which `after` and `until` filter on. An option event falls back to it when
720    /// `created_at` is missing, and pairs the legs of an exercise or assignment by it.
721    #[serde(default)]
722    date: Option<String>,
723    /// Signed: negative when the account paid.
724    #[serde(default)]
725    net_amount: Option<String>,
726    /// Signed: negative when the account sold. Contracts on an option event, or shares on the
727    /// share leg (`OPTRD`) of an exercise or assignment.
728    #[serde(default)]
729    qty: Option<String>,
730    /// On the share leg (`OPTRD`) of an exercise or assignment, the strike the shares traded at.
731    #[serde(default)]
732    price: Option<String>,
733    #[serde(default)]
734    currency: Option<String>,
735    #[serde(default)]
736    symbol: Option<String>,
737    /// `executed`, `correct` or `canceled`.
738    #[serde(default)]
739    status: Option<String>,
740}
741
742/// The body of an Alpaca error response: `{"code": 40310000, "message": "..."}`.
743///
744/// `code` is absent from some responses, notably the bare `{"message": "forbidden."}` that a
745/// wrong key, secret or host gets.
746#[derive(Debug, Deserialize)]
747struct AlpacaApiError {
748    code: Option<i64>,
749    message: String,
750}
751
752impl AlpacaApiError {
753    /// Decode an error body, keeping the raw text as the message when it is not Alpaca's JSON.
754    fn from_body(bytes: &[u8]) -> Self {
755        serde_json::from_slice(bytes).unwrap_or_else(|_| Self {
756            code: None,
757            message: String::from_utf8_lossy(bytes).into_owned(),
758        })
759    }
760}
761
762// ---------------------------------------------------------------------------
763// AlpacaPositionIntent
764// ---------------------------------------------------------------------------
765
766/// Explicit position intent for Alpaca order placement.
767///
768/// Required for options orders; valid (but optional) for equities.
769/// Omit entirely for crypto orders (causes 422 Unprocessable Entity).
770///
771/// Use `AlpacaClient::open_order_with_intent` to supply a specific intent
772/// instead of the heuristic mapping used by the `ExecutionClient` trait impl.
773#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
774#[serde(rename_all = "snake_case")]
775pub enum AlpacaPositionIntent {
776    BuyToOpen,
777    BuyToClose,
778    SellToOpen,
779    SellToClose,
780}
781
782// ---------------------------------------------------------------------------
783// Bracket order types
784// ---------------------------------------------------------------------------
785
786/// Take-profit parameters for Alpaca bracket orders.
787///
788/// The take-profit leg is always a limit order at the specified price.
789#[derive(Debug, Serialize)]
790struct TakeProfitParams {
791    limit_price: String,
792}
793
794/// Stop-loss parameters for Alpaca bracket orders.
795///
796/// When `limit_price` is `None`, the stop-loss is a stop (market) order.
797/// When `limit_price` is `Some`, the stop-loss becomes a stop-limit order.
798#[derive(Debug, Serialize)]
799struct StopLossParams {
800    stop_price: String,
801    #[serde(skip_serializing_if = "Option::is_none")]
802    limit_price: Option<String>,
803}
804
805/// Request to place a bracket order (entry + take-profit + stop-loss).
806///
807/// A bracket order consists of three linked orders submitted in a single request:
808/// 1. **Entry**: Limit order to enter the position
809/// 2. **Take Profit**: Limit order to exit at profit target
810/// 3. **Stop Loss**: Stop or stop-limit order to exit at loss limit
811///
812/// When either the take-profit or stop-loss fills, Alpaca automatically cancels
813/// the other leg.
814///
815/// # Constraints
816///
817/// - `time_in_force` must be `Day` or `GoodUntilCancelled` (no extended hours)
818/// - Entry order type is always `Limit`
819/// - Take-profit is always a `Limit` order
820/// - Stop-loss is a `Stop` order (or `StopLimit` if `stop_loss_limit_price` is set)
821///
822/// # Example
823///
824/// ```ignore
825/// let request = AlpacaBracketOrderRequest::new(
826///     "AAPL".into(),
827///     StrategyId::new("momentum"),
828///     ClientOrderId::new("bracket-001"),
829///     Side::Buy,
830///     dec!(10),
831///     dec!(150.00),  // entry
832///     dec!(160.00),  // take profit
833///     dec!(145.00),  // stop loss
834///     TimeInForce::GoodUntilCancelled { post_only: false },
835/// );
836/// // For stop-limit SL: .with_stop_loss_limit_price(dec!(144.00))
837/// let result = client.open_bracket_order(request).await;
838/// ```
839#[non_exhaustive]
840#[derive(Debug, Clone)]
841pub struct AlpacaBracketOrderRequest {
842    /// Instrument to trade.
843    pub instrument: InstrumentNameExchange,
844    /// Strategy identifier for order correlation.
845    pub strategy: StrategyId,
846    /// Client order ID for the parent (entry) order.
847    pub cid: ClientOrderId,
848    /// Buy or Sell for the entry order (exits use opposite side).
849    pub side: Side,
850    /// Number of shares/contracts.
851    pub quantity: Decimal,
852    /// Entry limit price.
853    pub entry_price: Decimal,
854    /// Take-profit limit price.
855    pub take_profit_price: Decimal,
856    /// Stop-loss trigger price.
857    pub stop_loss_price: Decimal,
858    /// Optional stop-loss limit price. When set, makes the stop-loss a stop-limit order.
859    pub stop_loss_limit_price: Option<Decimal>,
860    /// Time-in-force for all legs. Must be `Day` or `GoodUntilCancelled`.
861    pub time_in_force: TimeInForce,
862}
863
864impl AlpacaBracketOrderRequest {
865    /// Create a new bracket order request.
866    ///
867    /// For a stop-limit stop-loss leg, chain `.with_stop_loss_limit_price()`.
868    #[allow(clippy::too_many_arguments)] // Bracket orders inherently need many params
869    pub fn new(
870        instrument: InstrumentNameExchange,
871        strategy: StrategyId,
872        cid: ClientOrderId,
873        side: Side,
874        quantity: Decimal,
875        entry_price: Decimal,
876        take_profit_price: Decimal,
877        stop_loss_price: Decimal,
878        time_in_force: TimeInForce,
879    ) -> Self {
880        Self {
881            instrument,
882            strategy,
883            cid,
884            side,
885            quantity,
886            entry_price,
887            take_profit_price,
888            stop_loss_price,
889            stop_loss_limit_price: None,
890            time_in_force,
891        }
892    }
893
894    /// Set the stop-loss limit price, converting the SL leg to a stop-limit order.
895    #[must_use]
896    pub fn with_stop_loss_limit_price(mut self, price: Decimal) -> Self {
897        self.stop_loss_limit_price = Some(price);
898        self
899    }
900}
901
902/// Result of placing an Alpaca bracket order.
903///
904/// Contains the parent order with its state. The take-profit and stop-loss legs
905/// are managed by Alpaca and their status can be queried via `fetch_open_orders`.
906///
907/// # Note
908///
909/// Unlike IBKR which returns three separate orders, Alpaca's bracket API returns
910/// a single parent order. The child legs (TP/SL) are implicitly created and linked
911/// by Alpaca. Use `fetch_open_orders` to retrieve all legs after placement.
912#[non_exhaustive]
913#[derive(Debug, Clone)]
914pub struct AlpacaBracketOrderResult {
915    /// Parent (entry) order with its current state.
916    pub parent: Order<ExchangeId, InstrumentNameExchange, UnindexedOrderState>,
917}
918
919// ---------------------------------------------------------------------------
920// REST order request body
921// ---------------------------------------------------------------------------
922
923#[derive(Debug, Serialize)]
924struct AlpacaOrderRequest<'a> {
925    symbol: &'a str,
926    qty: String,
927    side: &'static str,
928    #[serde(rename = "type")]
929    order_type: &'static str,
930    time_in_force: &'static str,
931    #[serde(skip_serializing_if = "Option::is_none")]
932    limit_price: Option<String>,
933    #[serde(skip_serializing_if = "Option::is_none")]
934    stop_price: Option<String>,
935    #[serde(skip_serializing_if = "Option::is_none")]
936    trail_percent: Option<String>,
937    #[serde(skip_serializing_if = "Option::is_none")]
938    trail_price: Option<String>,
939    #[serde(skip_serializing_if = "Option::is_none")]
940    client_order_id: Option<&'a str>,
941    // position_intent: heuristic mapping (buy→buy_to_open, sell→sell_to_close).
942    // Correct for directional long-only strategies. Omit for exchanges/asset
943    // classes that don't require it (stocks/crypto ignore this field).
944    #[serde(skip_serializing_if = "Option::is_none")]
945    position_intent: Option<AlpacaPositionIntent>,
946    // Bracket order fields (order_class, take_profit, stop_loss).
947    // Set order_class to "bracket" and populate TP/SL for bracket orders.
948    #[serde(skip_serializing_if = "Option::is_none")]
949    order_class: Option<&'static str>,
950    #[serde(skip_serializing_if = "Option::is_none")]
951    take_profit: Option<TakeProfitParams>,
952    #[serde(skip_serializing_if = "Option::is_none")]
953    stop_loss: Option<StopLossParams>,
954}
955
956// ---------------------------------------------------------------------------
957// WebSocket message types
958// ---------------------------------------------------------------------------
959
960/// Outer container for all Alpaca stream messages.
961///
962/// `data` is kept as a [`serde_json::value::RawValue`] to avoid allocating a full DOM tree
963/// for heartbeats and auth/listening acks that never reach `AlpacaTradeUpdate` parsing.
964#[derive(Debug, Deserialize)]
965struct AlpacaStreamMessage<'a> {
966    // Short well-known values ("trade_updates", "listening", "authorization")
967    // all fit inline in SmolStr — avoids one heap alloc per WS message.
968    stream: SmolStr,
969    #[serde(borrow)]
970    data: &'a serde_json::value::RawValue,
971}
972
973/// Parsed payload of a `trade_updates` event.
974///
975/// Numeric and timestamp fields borrow directly from the `RawValue` input buffer
976/// (`#[serde(borrow)]`), propagating the zero-copy design of `AlpacaStreamMessage`.
977/// This eliminates 4–6 heap allocations per fill event. The borrow is valid because
978/// Alpaca's numeric and timestamp strings, and its execution ids (UUIDs), contain no JSON escape
979/// sequences.
980#[derive(Debug, Deserialize)]
981struct AlpacaTradeUpdate<'a> {
982    // Short event tag ("fill", "partial_fill", "new", ...) — fits inline.
983    event: SmolStr,
984    #[serde(borrow)]
985    order: AlpacaOrderWs<'a>,
986    /// Fill price for this specific execution (None for non-fill events).
987    #[serde(borrow)]
988    price: Option<&'a str>,
989    /// Quantity for this specific execution (None for non-fill events).
990    #[serde(borrow)]
991    qty: Option<&'a str>,
992    #[serde(borrow)]
993    timestamp: Option<&'a str>,
994    /// Alpaca's id for this event. On a fill it is the execution's id, the same as the UUID after
995    /// `::` in that fill's FILL activity id (see [`activity_execution_id`]), so it is the fill's
996    /// [`TradeId`] on every path. On a `trade_correct` it is taken as the corrected execution's id.
997    #[serde(borrow)]
998    execution_id: Option<&'a str>,
999    /// On `trade_bust` and `trade_correct` only: the `execution_id` of the fill busted or
1000    /// corrected, per Alpaca's Broker API schema (`TradeUpdateEventV2`). The Trading API's docs do
1001    /// not list either event, so this is unverified against a real frame.
1002    #[serde(borrow)]
1003    previous_execution_id: Option<&'a str>,
1004}
1005
1006/// Order state embedded in a `trade_updates` WebSocket event.
1007#[derive(Debug, Deserialize)]
1008struct AlpacaOrderWs<'a> {
1009    // UUIDs (36 chars) heap-allocate in SmolStr, but using SmolStr directly avoids
1010    // an intermediate String allocation when serde deserialises the field.
1011    id: SmolStr,
1012    client_order_id: Option<SmolStr>,
1013    // Ticker symbols ("AAPL", "BTC/USD") fit inline in SmolStr (≤23 bytes),
1014    // eliminating the heap allocation entirely for most symbols.
1015    symbol: SmolStr,
1016    #[serde(borrow)]
1017    qty: Option<&'a str>,
1018    // Alpaca guarantees `filled_qty` for fill/partial_fill and most lifecycle
1019    // events, but some event types (e.g. `rejected`) may omit the field.
1020    // Using Option avoids a deserialization failure that would silently drop
1021    // the event. A snapshot of a live order reads its absence as nothing filled
1022    // yet; a cancel reads it as unknown; the dedup key, and a fill's fallback trade id, read it
1023    // as "0".
1024    #[serde(borrow)]
1025    filled_qty: Option<&'a str>,
1026    // Short enums ("buy"/"sell", "market"/"limit"/..., "day"/"gtc"/..., status)
1027    // — all fit inline in SmolStr.
1028    side: SmolStr,
1029    #[serde(rename = "type")]
1030    order_type: SmolStr,
1031    time_in_force: SmolStr,
1032    #[serde(borrow)]
1033    limit_price: Option<&'a str>,
1034    #[serde(borrow)]
1035    stop_price: Option<&'a str>,
1036    #[serde(borrow)]
1037    trail_percent: Option<&'a str>,
1038    #[serde(borrow)]
1039    trail_price: Option<&'a str>,
1040    status: SmolStr,
1041}
1042
1043// ---------------------------------------------------------------------------
1044// AlpacaClient
1045// ---------------------------------------------------------------------------
1046
1047/// Alpaca execution client supporting options, equities, and crypto via the
1048/// single unified Alpaca trading API.
1049///
1050/// All three asset classes share the same REST and WebSocket endpoints. The
1051/// key behavioral differences handled transparently:
1052/// - Options: `position_intent` field is required (detected by OCC symbol format)
1053/// - Crypto: `position_intent` is omitted (not a valid field for crypto orders);
1054///   fractional quantities are supported natively via `Decimal::to_string()`
1055/// - Equities: `position_intent` is valid but optional for long-only strategies
1056///
1057/// # Rate limits
1058/// Once a response reports no requests left in the current window (`X-Ratelimit-Remaining: 0`),
1059/// every request, orders included, waits until the window resets (`X-Ratelimit-Reset`, at most a
1060/// minute away), so it is not refused. A 429 waits the same way, for the wait Alpaca gives or a
1061/// minute without one, and is retried. Once the retries are exhausted the request fails with
1062/// [`ApiError::RateLimit`], carrying that wait, and later requests still wait for it.
1063///
1064/// # Fees, interest and borrow fees
1065/// Every [`Trade`] this client reports carries zero fees. Equities and options trade without
1066/// commission. Alpaca's fill events carry no fee for crypto either: it is taken from the asset
1067/// the trade credits, so it shows only in that asset's balance.
1068///
1069/// Alpaca posts its other charges, such as margin interest and fees, to the account's cash as
1070/// account activities. A USD balance's `total` is that cash, so it includes a charge once Alpaca
1071/// has posted it. [`ExecutionClient::account_stream`] delivers none of them, nor any balance:
1072/// Alpaca streams neither. [`AlpacaClient::fetch_cash_flows`] reads them on request. PnL computed
1073/// from fills excludes them.
1074///
1075/// Cloning is cheap: all inner state is behind `Arc`.
1076#[derive(Clone)]
1077pub struct AlpacaClient {
1078    config: Arc<AlpacaConfig>,
1079    /// reqwest client with APCA-API-KEY-ID and APCA-API-SECRET-KEY pre-set as
1080    /// default headers — every request carries auth automatically.
1081    http: reqwest::Client,
1082    rate_limiter: Arc<RateLimitTracker>,
1083    /// Pre-allocated `/v2/orders` endpoint URL to avoid allocation per request.
1084    orders_url: String,
1085    /// The orders seen live and not yet seen end, which a reconnect asks about. Shared by every
1086    /// clone and every account stream, since an order placed through one ends on any of them.
1087    known_live: SharedKnownLiveOrders,
1088}
1089
1090impl std::fmt::Debug for AlpacaClient {
1091    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1092        f.debug_struct("AlpacaClient")
1093            .field("paper", &self.config.paper)
1094            .finish_non_exhaustive()
1095    }
1096}
1097
1098impl AlpacaClient {
1099    /// Build a `reqwest::Client` with Alpaca auth headers pre-set.
1100    ///
1101    /// # Panics
1102    ///
1103    /// Panics if the API key or secret contains characters that are invalid
1104    /// in an HTTP header value (non-ASCII or control characters).
1105    #[allow(clippy::expect_used)] // Documented panic: invalid credentials detected at startup
1106    fn build_http(config: &AlpacaConfig) -> reqwest::Client {
1107        use reqwest::header::{HeaderMap, HeaderName, HeaderValue};
1108        let mut headers = HeaderMap::new();
1109        // HTTP header names are case-insensitive; use lowercase per HTTP/2 convention.
1110        headers.insert(
1111            HeaderName::from_static("apca-api-key-id"),
1112            HeaderValue::from_str(&config.api_key)
1113                .expect("Alpaca API key contains invalid header characters"),
1114        );
1115        headers.insert(
1116            HeaderName::from_static("apca-api-secret-key"),
1117            HeaderValue::from_str(&config.secret_key)
1118                .expect("Alpaca secret key contains invalid header characters"),
1119        );
1120        reqwest::Client::builder()
1121            .default_headers(headers)
1122            .build()
1123            .expect("failed to build reqwest client for Alpaca")
1124    }
1125
1126    fn base_url(&self) -> &str {
1127        self.config.rest_base_url()
1128    }
1129
1130    /// Hold each of `orders` as live, for a reconnect to ask about if the stream misses its end.
1131    fn remember_live<'a>(
1132        &self,
1133        orders: impl IntoIterator<Item = &'a Order<ExchangeId, InstrumentNameExchange, Open>>,
1134    ) {
1135        let mut known = self.known_live.lock();
1136        for order in orders {
1137            known.live(&order.key, order.quantity, order.kind, &order.state);
1138        }
1139    }
1140}
1141
1142// ---------------------------------------------------------------------------
1143// REST helper: rate-limited request with retry
1144// ---------------------------------------------------------------------------
1145
1146/// How long Alpaca asks a rate-limited client to wait, from a 429's headers.
1147///
1148/// `Retry-After`, a delay in seconds, when present: taken as given, zero included, and not read in
1149/// its HTTP-date form. Otherwise `X-RateLimit-Reset`, which Alpaca documents: a Unix timestamp in
1150/// seconds, read as the time from now until then, at least 1 s so a reset already past never
1151/// busy-loops. `None` when neither header is readable; callers fall back to
1152/// [`DEFAULT_RATE_LIMIT_DELAY_SECS`].
1153///
1154/// Either is capped at [`DEFAULT_RATE_LIMIT_DELAY_SECS`], the length of Alpaca's window: a longer
1155/// wait is a bad header or clock skew, and must not hold orders back for longer, nor overflow the
1156/// cooldown's deadline.
1157///
1158/// The data crate's Alpaca REST client reads a 429 by the same rule.
1159fn parse_rate_limit_delay(headers: &reqwest::header::HeaderMap) -> Option<Duration> {
1160    let header = |name: &str| {
1161        headers
1162            .get(name)
1163            .and_then(|value| value.to_str().ok())
1164            .and_then(|value| value.trim().parse::<u64>().ok())
1165    };
1166
1167    let seconds = match header("retry-after") {
1168        Some(seconds) => seconds,
1169        None => {
1170            let reset = header("x-ratelimit-reset")?;
1171            let now = std::time::SystemTime::now()
1172                .duration_since(std::time::UNIX_EPOCH)
1173                .unwrap_or_default()
1174                .as_secs();
1175            reset.saturating_sub(now).max(1)
1176        }
1177    };
1178    Some(Duration::from_secs(
1179        seconds.min(DEFAULT_RATE_LIMIT_DELAY_SECS),
1180    ))
1181}
1182
1183/// Pause every request until the window resets when a response reports none remaining in it.
1184///
1185/// The pause is at most [`DEFAULT_RATE_LIMIT_DELAY_SECS`], the length of Alpaca's window, as
1186/// [`parse_rate_limit_delay`] caps it. Call on
1187/// any response except a 429, which sets its own cooldown. Without an
1188/// `X-Ratelimit-Reset` there is nothing to pause until, so the next request goes ahead and a 429,
1189/// if it comes, backs off.
1190fn observe_rate_limit_remaining(
1191    rate_limiter: &RateLimitTracker,
1192    headers: &reqwest::header::HeaderMap,
1193) {
1194    let exhausted = headers
1195        .get("x-ratelimit-remaining")
1196        .and_then(|v| v.to_str().ok())
1197        .and_then(|s| s.parse::<u32>().ok())
1198        == Some(0);
1199    if !exhausted {
1200        return;
1201    }
1202    match parse_rate_limit_delay(headers) {
1203        Some(reset) => rate_limiter.on_bucket_exhausted(reset),
1204        None => debug!(
1205            "Alpaca REST rate-limit window exhausted (X-Ratelimit-Remaining: 0) without X-Ratelimit-Reset"
1206        ),
1207    }
1208}
1209
1210/// Execute a REST request with rate-limit awareness and retry.
1211///
1212/// The `build_request` closure is called on every attempt so the caller doesn't
1213/// need a clone of `RequestBuilder` (which may not be cloneable with streaming bodies).
1214/// For GET/POST/DELETE with fixed bodies, the closure is a cheap re-construction.
1215///
1216/// On HTTP 429, reads the cooldown with [`parse_rate_limit_delay`] and retries up to
1217/// `MAX_RATE_LIMIT_ATTEMPTS - 1` times. Every 429, the last included, pauses later requests for
1218/// that cooldown, so a caller handed [`ApiError::RateLimit`] does not meet the same refusal on its
1219/// next call. Any other response reporting
1220/// `X-Ratelimit-Remaining: 0` pauses later requests until that reset; see
1221/// [`observe_rate_limit_remaining`].
1222///
1223/// # Errors
1224///
1225/// - [`UnindexedClientError::Connectivity`]: the request or its body read failed, or a 5xx.
1226/// - [`UnindexedClientError::Api`]: a 4xx, classified by [`parse_api_error`], or 429 retries
1227///   exhausted ([`ApiError::RateLimit`], with Alpaca's wait when its headers give one).
1228/// - [`UnindexedClientError::Internal`]: a 2xx body that does not decode into `T`, or a 204 on a
1229///   call that expects a body. Retrying returns the same answer.
1230async fn rest_with_retry<T>(
1231    rate_limiter: &RateLimitTracker,
1232    build_request: impl FnMut() -> reqwest::RequestBuilder,
1233) -> Result<T, UnindexedClientError>
1234where
1235    T: for<'de> Deserialize<'de>,
1236{
1237    match rest_request(rate_limiter, build_request).await? {
1238        Fetched::Found(value) => Ok(value),
1239        Fetched::NotFound(message) => Err(UnindexedClientError::Api(parse_api_error(
1240            reqwest::StatusCode::NOT_FOUND,
1241            None,
1242            &message,
1243        ))),
1244    }
1245}
1246
1247/// [`rest_with_retry`] for a lookup by id: a 404 is `Ok(None)`, Alpaca knowing nothing under the
1248/// id asked for, rather than an error.
1249async fn rest_lookup_with_retry<T>(
1250    rate_limiter: &RateLimitTracker,
1251    build_request: impl FnMut() -> reqwest::RequestBuilder,
1252) -> Result<Option<T>, UnindexedClientError>
1253where
1254    T: for<'de> Deserialize<'de>,
1255{
1256    match rest_request(rate_limiter, build_request).await? {
1257        Fetched::Found(value) => Ok(Some(value)),
1258        Fetched::NotFound(_) => Ok(None),
1259    }
1260}
1261
1262/// What [`rest_request`] got back from a request that did not fail.
1263#[derive(Debug)]
1264enum Fetched<T> {
1265    /// A 2xx body, decoded.
1266    Found(T),
1267    /// A 404, with Alpaca's message.
1268    NotFound(String),
1269}
1270
1271/// [`rest_with_retry`] and [`rest_lookup_with_retry`], which differ only in how they read a 404.
1272async fn rest_request<T>(
1273    rate_limiter: &RateLimitTracker,
1274    mut build_request: impl FnMut() -> reqwest::RequestBuilder,
1275) -> Result<Fetched<T>, UnindexedClientError>
1276where
1277    T: for<'de> Deserialize<'de>,
1278{
1279    for attempt in 0..MAX_RATE_LIMIT_ATTEMPTS {
1280        rate_limiter.wait_if_blocked().await;
1281        let response = build_request()
1282            .send()
1283            .await
1284            .map_err(|e| connectivity_err(format!("Alpaca REST request failed: {e}")))?;
1285
1286        let status = response.status();
1287
1288        if status == reqwest::StatusCode::TOO_MANY_REQUESTS {
1289            let reset_delay = parse_rate_limit_delay(response.headers());
1290            // Every refusal pauses the client, the last included, so the next request waits too.
1291            rate_limiter.on_rate_limited(reset_delay);
1292
1293            if attempt + 1 < MAX_RATE_LIMIT_ATTEMPTS {
1294                warn!(
1295                    attempt = attempt + 1,
1296                    max_attempts = MAX_RATE_LIMIT_ATTEMPTS,
1297                    "Alpaca REST rate-limited (429), retrying"
1298                );
1299                continue;
1300            }
1301
1302            // Final attempt still rate-limited — return typed error.
1303            warn!(
1304                max_attempts = MAX_RATE_LIMIT_ATTEMPTS,
1305                "Alpaca REST rate-limit retries exhausted"
1306            );
1307            return Err(UnindexedClientError::Api(ApiError::rate_limited(
1308                reset_delay,
1309            )));
1310        }
1311
1312        observe_rate_limit_remaining(rate_limiter, response.headers());
1313
1314        // 204 No Content is only valid for DELETE endpoints; use rest_delete_with_retry
1315        // for those. Reaching here for a 204 indicates API misuse — return a clear error
1316        // rather than a misleading "EOF while parsing" JSON failure. A retry cannot fix it.
1317        if status == reqwest::StatusCode::NO_CONTENT {
1318            return Err(UnindexedClientError::Internal(
1319                "Alpaca REST returned 204 No Content — use rest_delete_with_retry for DELETE endpoints"
1320                    .to_string(),
1321            ));
1322        }
1323
1324        let bytes = response
1325            .bytes()
1326            .await
1327            .map_err(|e| connectivity_err(format!("Alpaca REST read body failed: {e}")))?;
1328
1329        // A 2xx body that does not fit the model is not transient: retrying returns the same
1330        // body. An order path must still treat it as status unknown; see `order_post_error`.
1331        if status.is_success() {
1332            return serde_json::from_slice::<T>(&bytes)
1333                .map(Fetched::Found)
1334                .map_err(|e| {
1335                    UnindexedClientError::Internal(format!(
1336                        "Alpaca REST JSON parse error ({status}): {e} | body: {}",
1337                        String::from_utf8_lossy(&bytes)
1338                            .chars()
1339                            .take(200)
1340                            .collect::<String>()
1341                    ))
1342                });
1343        }
1344
1345        // Parse API error body for a better error message.
1346        let api_err = AlpacaApiError::from_body(&bytes);
1347
1348        if status == reqwest::StatusCode::NOT_FOUND {
1349            return Ok(Fetched::NotFound(api_err.message));
1350        }
1351
1352        // 4xx = API-level rejection (wrong parameters, auth failure, insufficient funds).
1353        // 5xx / other = server-side failure treated as connectivity error.
1354        // Callers that pattern-match on UnindexedClientError (e.g. open_order_inner) rely
1355        // on Api(ApiError) to classify business rejections vs connectivity failures.
1356        //
1357        // Uses parse_api_error for consistent classification: 422 "insufficient funds"
1358        // maps to BalanceInsufficient (not generic OrderRejected), enabling callers to
1359        // trigger balance refresh on insufficient-funds rejections.
1360        if status.is_client_error() {
1361            return Err(UnindexedClientError::Api(parse_api_error(
1362                status,
1363                api_err.code,
1364                &api_err.message,
1365            )));
1366        }
1367        return Err(connectivity_err(format!(
1368            "Alpaca REST error {status}: {}",
1369            api_err.message
1370        )));
1371    }
1372    unreachable!("Alpaca REST retry loop exited without returning")
1373}
1374
1375/// Execute a DELETE request, returning an order error on rejection.
1376///
1377/// Handles 204 No Content (success), other 4xx (API rejection, see [`parse_api_error`]), and 429
1378/// (rate limit, retried and pausing later requests as in [`rest_with_retry`]). Any other response
1379/// reporting `X-Ratelimit-Remaining: 0` pauses later requests,
1380/// as in [`rest_with_retry`].
1381async fn rest_delete_with_retry(
1382    rate_limiter: &RateLimitTracker,
1383    mut build_request: impl FnMut() -> reqwest::RequestBuilder,
1384) -> Result<(), UnindexedOrderError> {
1385    for attempt in 0..MAX_RATE_LIMIT_ATTEMPTS {
1386        rate_limiter.wait_if_blocked().await;
1387        let response = build_request().send().await.map_err(|e| {
1388            UnindexedOrderError::Connectivity(ConnectivityError::Socket(format!(
1389                "Alpaca cancel request failed: {e}"
1390            )))
1391        })?;
1392
1393        let status = response.status();
1394
1395        if status == reqwest::StatusCode::TOO_MANY_REQUESTS {
1396            let reset_delay = parse_rate_limit_delay(response.headers());
1397            // Every refusal pauses the client, the last included, so the next request waits too.
1398            rate_limiter.on_rate_limited(reset_delay);
1399
1400            if attempt + 1 < MAX_RATE_LIMIT_ATTEMPTS {
1401                warn!(
1402                    attempt = attempt + 1,
1403                    max_attempts = MAX_RATE_LIMIT_ATTEMPTS,
1404                    "Alpaca cancel rate-limited (429), retrying"
1405                );
1406                continue;
1407            }
1408
1409            // Final attempt still rate-limited — return typed error.
1410            warn!(
1411                max_attempts = MAX_RATE_LIMIT_ATTEMPTS,
1412                "Alpaca cancel rate-limit retries exhausted"
1413            );
1414            return Err(UnindexedOrderError::Rejected(ApiError::rate_limited(
1415                reset_delay,
1416            )));
1417        }
1418
1419        observe_rate_limit_remaining(rate_limiter, response.headers());
1420
1421        // 204 No Content: cancel succeeded.
1422        if status == reqwest::StatusCode::NO_CONTENT || status.is_success() {
1423            return Ok(());
1424        }
1425
1426        let bytes = response
1427            .bytes()
1428            .await
1429            .inspect_err(
1430                |e| warn!(%e, %status, "Alpaca cancel_order: failed to read error response body"),
1431            )
1432            .unwrap_or_default();
1433        let api_err = AlpacaApiError::from_body(&bytes);
1434
1435        return Err(parse_order_error(status, api_err.code, &api_err.message));
1436    }
1437    unreachable!("Alpaca cancel retry loop exited without returning")
1438}
1439
1440// ---------------------------------------------------------------------------
1441// ExecutionClient implementation
1442// ---------------------------------------------------------------------------
1443
1444impl ExecutionClient for AlpacaClient {
1445    const EXCHANGE: ExchangeId = ExchangeId::AlpacaBroker;
1446
1447    // Equities and crypto are both `Spot` here — Alpaca settles each as an outright holding, and
1448    // the client separates them only by symbol shape. `Option` is genuinely routed: the
1449    // `position_intent` Alpaca requires on options orders is derived from that same shape test,
1450    // which is `is_options_or_equity_symbol` — a not-a-crypto-pair check (`!symbol.contains('/')`),
1451    // not OCC format validation. Equities take the branch too, harmlessly: `position_intent` is
1452    // accepted on an equity order.
1453    const SUPPORTED_KINDS: &'static [InstrumentKindDiscriminant] = &[
1454        InstrumentKindDiscriminant::Spot,
1455        InstrumentKindDiscriminant::Option,
1456    ];
1457    type Config = AlpacaConfig;
1458    type AccountStream = BoxStream<'static, UnindexedAccountEvent>;
1459
1460    /// # Panics
1461    ///
1462    /// Panics if the API key or secret key contains characters invalid in an
1463    /// HTTP header value.
1464    fn new(config: Self::Config) -> Self {
1465        let http = Self::build_http(&config);
1466        let orders_url = format!("{}/v2/orders", config.rest_base_url());
1467        Self {
1468            config: Arc::new(config),
1469            http,
1470            rate_limiter: Arc::new(RateLimitTracker::new()),
1471            orders_url,
1472            known_live: KnownLiveOrders::shared(ExchangeId::AlpacaBroker),
1473        }
1474    }
1475
1476    /// # Balances and positions
1477    ///
1478    /// - **USD**: `total` is the account's `cash`, and `free` the lesser of cash and its
1479    ///   `non_marginable_buying_power`: cash that can be spent without borrowing. Alpaca's
1480    ///   buying power figures include the loan value of held stock, so none of them alone is
1481    ///   free cash. A short sale's proceeds are credited to cash but do not raise buying power,
1482    ///   so `free` stays below `total` while a short is open. Cash is negative while the account
1483    ///   borrows on margin, and then `free` is negative too. Account equity is not reported: it
1484    ///   is cash plus the value of the positions below, so it can be derived.
1485    /// - **Equities and options**: each holding becomes the
1486    ///   [`InstrumentAccountSnapshot::position`] of its instrument, [`PositionReport::Open`] with a
1487    ///   signed [`Position`] (negative for a short), the average entry price and the unrealised
1488    ///   PnL in USD. An equity or option without a holding is [`PositionReport::Flat`], since
1489    ///   Alpaca lists every open position. An option's
1490    ///   entry price is the premium per share of the underlying, as quoted and as orders are
1491    ///   priced, not per contract. Margin, liquidation price and leverage are `None`, and
1492    ///   `time_exchange` is the time of the call, since Alpaca does not timestamp positions.
1493    /// - **Crypto**: each holding is an asset balance of the asset it holds (e.g. `BTC` for the
1494    ///   position Alpaca lists as `BTCUSD`), in base units. Alpaca crypto is spot-only and cannot
1495    ///   be sold short, so there is no position to report: its instruments are
1496    ///   [`PositionReport::Unreported`].
1497    ///
1498    /// # Limitations
1499    ///
1500    /// A position is reported only under the instrument name Alpaca uses for it, ignoring case:
1501    /// the ticker for an equity and the OCC symbol for an option. A requested equity or option
1502    /// named otherwise is reported [`PositionReport::Flat`] even while Alpaca holds it. A crypto
1503    /// pair is recognised by the `/` in its name (e.g. `BTC/USD`, the form its orders use); one
1504    /// named without it is taken for an equity and reported flat too. With `instruments` empty,
1505    /// every instrument that has an open order or a position gets a snapshot; otherwise only the
1506    /// requested ones do.
1507    ///
1508    /// # Errors
1509    ///
1510    /// [`ClientError::Internal`](crate::error::ClientError::Internal) when a response does not
1511    /// decode, or when any non-zero equity or option position, requested or not, has a missing or
1512    /// unrecognised `side`: its direction cannot be known, and leaving it out would read as flat.
1513    /// Other request failures are `Connectivity` or `Api`, as for every Alpaca REST call.
1514    ///
1515    /// # Rate limit note
1516    ///
1517    /// `/v2/positions` is fetched on every call, since it holds both the crypto balances and the
1518    /// equity and option positions. When USD is requested too (the common startup case),
1519    /// `/v2/account` is fetched in parallel with it for ~100-300ms latency savings. Under rate
1520    /// pressure, both requests may hit 429 simultaneously and retry independently. Operators
1521    /// approaching Alpaca rate limits can request only non-USD assets to skip `/v2/account`.
1522    async fn account_snapshot(
1523        &self,
1524        assets: &[AssetNameExchange],
1525        instruments: &[InstrumentNameExchange],
1526    ) -> Result<UnindexedAccountSnapshot, UnindexedClientError> {
1527        let base = self.base_url();
1528        let http = self.http.clone();
1529        let rl = &self.rate_limiter;
1530
1531        let wants_usd = assets.is_empty() || assets.iter().any(is_usd);
1532        let wants_non_usd = assets.is_empty() || assets.iter().any(|a| !is_usd(a));
1533
1534        // URLs are extracted before the closures to avoid re-allocating on each retry attempt.
1535        let account_url = format!("{base}/v2/account");
1536        let positions_url = format!("{base}/v2/positions");
1537        let (account, positions): (Option<AlpacaAccount>, Vec<AlpacaPosition>) = tokio::try_join!(
1538            async {
1539                if wants_usd {
1540                    rest_with_retry(rl, || http.get(&account_url))
1541                        .await
1542                        .map(Some)
1543                } else {
1544                    Ok(None)
1545                }
1546            },
1547            rest_with_retry(rl, || http.get(&positions_url)),
1548        )?;
1549
1550        let mut balances = account
1551            .map(|account| convert_account_to_balances(&account, assets))
1552            .unwrap_or_default();
1553        if wants_non_usd {
1554            balances.extend(convert_positions_to_balances(&positions, assets));
1555        }
1556
1557        // Converted before the open orders are fetched, so a position that cannot be converted
1558        // fails the call without a wasted request.
1559        let converted_positions = convert_positions(&positions, Utc::now())?;
1560        let open_orders = fetch_raw_open_orders(&http, rl, base, instruments).await?;
1561
1562        // Group open orders and positions by instrument symbol.
1563        let instrument_snapshots =
1564            build_instrument_snapshots(open_orders, converted_positions, instruments);
1565        {
1566            let mut known = self.known_live.lock();
1567            for order in instrument_snapshots
1568                .iter()
1569                .flat_map(|snapshot| &snapshot.orders)
1570            {
1571                if let OrderState::Active(ActiveOrderState::Open(open)) = &order.state {
1572                    known.live(&order.key, order.quantity, order.kind, open);
1573                }
1574            }
1575        }
1576
1577        Ok(AccountSnapshot::new(
1578            ExchangeId::AlpacaBroker,
1579            balances,
1580            instrument_snapshots,
1581        ))
1582    }
1583
1584    /// Returns a live stream of account events (fills, order updates).
1585    ///
1586    /// # Reconnection
1587    ///
1588    /// The stream reconnects by itself when the server closes the connection, the connection
1589    /// fails, or it carries nothing for a 35 s heartbeat window. Each reconnect recovers the fills
1590    /// and order endings the stream missed (see below).
1591    ///
1592    /// - **Attempts.** A dropped connection, and each reconnect that fails after it, spends one
1593    ///   attempt: a wait, then a reconnect. A reconnect fails when its connect, authentication or
1594    ///   `trade_updates` subscribe fails. The waits double from 1 s up to 30 s: 1, 2, 4, 8, 16,
1595    ///   then 30 s.
1596    /// - **Reset.** A connection still receiving after 35 s up clears the attempts spent before
1597    ///   it, so only failures in a row count. A connection the server accepts and closes sooner
1598    ///   counts as a failed reconnect.
1599    /// - **Giving up.** Once 10 reconnects in a row have failed, after about three minutes of
1600    ///   waiting in all, the stream ends with [`AccountEventKind::StreamTerminated`] carrying
1601    ///   [`ReconnectBudgetExhausted`](crate::error::StreamTerminationReason::ReconnectBudgetExhausted)
1602    ///   and the last failure. Credentials rejected on every reconnect end it this way too.
1603    /// - **Opening is not retried.** A failed `account_stream` call returns its error. Whether and
1604    ///   when to try again is the caller's decision, as it is after the stream ends.
1605    ///
1606    /// # Startup race window
1607    ///
1608    /// Fills arriving between `account_snapshot` and this method being called by the
1609    /// caller are not recovered automatically — the WebSocket connection does not exist
1610    /// yet during that window. Fills that arrive after the connection is established but
1611    /// before the first poll are buffered in the tungstenite internal buffer and delivered
1612    /// normally. Callers requiring fill completeness at startup **must** call
1613    /// [`ExecutionClient::fetch_trades`] with a ~1 s lookback after calling this method.
1614    ///
1615    /// This gap also applies on every **reconnect**: during the auth+subscribe handshake
1616    /// (`connect_and_subscribe`), any `trade_updates` messages that arrive are consumed
1617    /// by the handshake loop and not forwarded. Fill events in this window are recovered
1618    /// via the REST activities endpoint (anchored to `disconnect_time`). How an order held as live
1619    /// ended in this window is recovered too (see below); a lifecycle event of any other order,
1620    /// such as `new` for one placed elsewhere, is not.
1621    ///
1622    /// # Fill recovery ordering
1623    ///
1624    /// After a reconnect, missed fills are recovered from the REST activities endpoint
1625    /// using `direction=asc` to match the chronological order in which the WS stream
1626    /// advanced `filled_qty`. A fill both paths deliver is sent once: it is recognised by its
1627    /// execution id, which the WS frame and the FILL activity both carry.
1628    ///
1629    /// A fill lacking that id on either path is recognised instead by `"{order_id}:{cum_qty}"`,
1630    /// taken from the activity's own `cum_qty`, so it matches the WS path's `filled_qty` by
1631    /// construction. Only where Alpaca omits `cum_qty` too does that key fall back to
1632    /// accumulating per-execution qty within the batch. That fallback counts from zero per order,
1633    /// so it is correct only for an order whose fills lie wholly inside the recovery window, and
1634    /// it is sensitive to activities arriving out of chronological order (e.g. at pagination
1635    /// boundaries).
1636    ///
1637    /// A recovered fill also carries the order's cumulative filled quantity, so it advances the
1638    /// order's `filled_quantity` without waiting for a `fetch_open_orders` reconciliation.
1639    ///
1640    /// Every fill's [`TradeId`] is Alpaca's execution id, whether the stream, recovery, or
1641    /// [`ExecutionClient::fetch_trades`] delivers it, so a fill read again by `fetch_trades`
1642    /// matches the one the stream delivered. A stream fill whose `execution_id` is missing, null
1643    /// or empty is logged and identified by `"{order_id}:{filled_qty}"` instead.
1644    ///
1645    /// The read is made once, with no retry. When it fails, times out, or stops at 5,000 fills,
1646    /// the stream sends one [`AccountEventKind::FillRecoveryGaveUp`] covering the stream's
1647    /// `instruments`, or every instrument when that list is empty. A truncated read delivers the
1648    /// fills it read first, and the event's span starts just before the last of them.
1649    ///
1650    /// # Busted and corrected fills
1651    ///
1652    /// A `trade_bust` or `trade_correct` is sent as [`AccountEventKind::TradeAmended`], naming
1653    /// the fill amended by its [`TradeId`]; the fill itself was delivered as first reported and
1654    /// is not withdrawn. The Trading API documents neither event, so their frames are read per
1655    /// Alpaca's Broker API schema, and each is also logged whole at `warn!`. One sent while the
1656    /// stream was disconnected is not recovered.
1657    ///
1658    /// # Lifecycle event deduplication
1659    ///
1660    /// Order lifecycle events (`new`, `canceled`, `expired`) are **not** deduplicated
1661    /// across reconnects — only fill events carry a dedup key. After a reconnect, Alpaca
1662    /// re-delivers lifecycle events for orders that were active at disconnect time.
1663    /// Specifically, Alpaca re-delivers a `new` event for **every order open at disconnect
1664    /// time**, not only orders that changed during the gap.
1665    /// Callers must make [`AccountEventKind::OrderSnapshot`] and
1666    /// [`AccountEventKind::OrderCancelled`] processing idempotent, or call
1667    /// [`ExecutionClient::fetch_open_orders`] after each reconnect to reconcile state.
1668    ///
1669    /// # Orders that ended while disconnected
1670    ///
1671    /// A reconnect also reports how each order the client holds as live ended, where it did, as an
1672    /// [`AccountEventKind::OrderSnapshot`] of its inactive state: filled (with the average fill
1673    /// price), cancelled or replaced (as cancelled, with what filled before), expired, or rejected.
1674    /// Like fill recovery, it covers only the instruments the stream was opened with, or every
1675    /// instrument when that list is empty. Alpaca's closed-order list filters on when an order was
1676    /// submitted, so such an order is found by asking about it, not by time.
1677    ///
1678    /// - **Which orders.** The client holds an order as live from the response to placing it,
1679    ///   from a listing of open orders ([`account_snapshot`](ExecutionClient::account_snapshot),
1680    ///   [`fetch_open_orders`](ExecutionClient::fetch_open_orders)), and from its live reports on
1681    ///   any of its account streams, until it sees the order end; a cancel Alpaca has only
1682    ///   accepted does not end it. A bracket's take-profit and stop-loss legs carry client order
1683    ///   ids Alpaca assigns, so they are held only once a listing or the stream reports them. It
1684    ///   holds up to 4,096 orders and forgets the oldest past that, logged at `warn`. An order
1685    ///   placed outside this client and never listed or reported to it is not covered.
1686    /// - **Cost.** One `GET /v2/orders?status=open` request listing every instrument with an
1687    ///   order held, then one `GET /v2/orders:by_client_order_id` for each held order the listing
1688    ///   no longer shows, 8 at a time. These share the account's rate limit with orders, so after
1689    ///   an outage in which many held orders ended, the check can hold orders back until the
1690    ///   window resets (see [Rate limits](AlpacaClient#rate-limits)).
1691    /// - **Fills first.** The check starts once fill recovery has finished or given up, so an
1692    ///   order's recovered fills arrive before how it ended. It runs alongside the stream, which
1693    ///   is read from the start. A fill that brings an order to its full quantity ends it, and
1694    ///   that order is not reported again.
1695    /// - **Keys.** Each snapshot carries [`StrategyId::unknown`], since Alpaca records no
1696    ///   strategy. The engine matches it to the order it tracks by client order id.
1697    /// - **Failures.** Each order's lookup is settled as it ends. An instrument whose listing or
1698    ///   lookup fails, or whose check is still running after 30 s, is retried while connected 1,
1699    ///   2, 4, 8 and 16 minutes later, asking only about the orders still held, then given up,
1700    ///   logged at `error`; its orders are asked about again at the next reconnect. A listing that
1701    ///   fails charges every instrument in it. Alpaca lists at most 500 open orders and has no
1702    ///   pagination for them, so a listing of 500 may be truncated and fails: an account with 500
1703    ///   or more open orders on the held instruments is not covered. An order Alpaca does not know
1704    ///   (404) stops being held, logged at `warn`; one still live, `done_for_day`, or in a state
1705    ///   this version cannot read stays held.
1706    ///
1707    /// The same lookup is public as [`OrderStatusClient::fetch_ended_orders`].
1708    ///
1709    /// # Rejected orders
1710    ///
1711    /// Alpaca `rejected` events are delivered as `AccountEventKind::OrderCancelled` with
1712    /// `state: Err(OrderRejected(...))`. Match on `response.state.is_err()` to distinguish
1713    /// rejections from true cancels — do not call `.unwrap()` on `OrderCancelled.state`.
1714    ///
1715    /// # Orders done for the day
1716    ///
1717    /// A `done_for_day` event is delivered as an [`AccountEventKind::OrderSnapshot`] of the order
1718    /// as open, with what it has filled: Alpaca stops working the order until the next trading
1719    /// day but has not ended it.
1720    ///
1721    /// # Stream drop behaviour
1722    ///
1723    /// Dropping the returned `BoxStream` initiates a graceful shutdown of the
1724    /// background `connection_manager` task: the channel close causes the task
1725    /// to send a WebSocket close frame and exit within the current heartbeat
1726    /// window (≤60 s). Any `AccountEvent` items already queued but not yet
1727    /// polled are discarded. Callers who drop and re-subscribe must call
1728    /// [`ExecutionClient::fetch_trades`] with a short lookback to recover the gap.
1729    async fn account_stream(
1730        &self,
1731        _assets: &[AssetNameExchange], // ignored — Alpaca's trade_updates stream delivers all asset classes on one channel
1732        // instruments is used to filter fill recovery (REST) after a reconnect only.
1733        // Live WS events are NOT filtered by instrument: Alpaca's trade_updates stream
1734        // delivers all account events on one channel with no per-symbol subscription.
1735        instruments: &[InstrumentNameExchange],
1736    ) -> Result<Self::AccountStream, UnindexedClientError> {
1737        // Verify the initial connection before returning the stream; distinguishes
1738        // "can't connect at all" from "connected but later disconnected".
1739        let initial_ws = connect_and_subscribe(&self.config).await?;
1740
1741        // Unbounded channel — memory grows if the consumer is slow, but fills
1742        // are never silently dropped. Silent fill loss corrupts position state;
1743        // OOM is loudly observable. The WS delivery path uses non-blocking send()
1744        // which only fails if the receiver is dropped.
1745        let (tx, rx) = mpsc::unbounded_channel::<UnindexedAccountEvent>();
1746        let dedup = new_dedup_cache();
1747        let config = self.config.clone();
1748        let http = self.http.clone();
1749        let rate_limiter = self.rate_limiter.clone();
1750        let known = self.known_live.clone();
1751        let instruments = instruments.to_vec();
1752
1753        let cm_handle = tokio::spawn(connection_manager(
1754            tx,
1755            dedup,
1756            config,
1757            http,
1758            rate_limiter,
1759            known,
1760            instruments,
1761            Some(initial_ws),
1762        ));
1763
1764        let rx_stream = tokio_stream::wrappers::UnboundedReceiverStream::new(rx);
1765        let guarded = GracefulShutdownStream::new(rx_stream, cm_handle);
1766        Ok(futures::StreamExt::boxed(guarded))
1767    }
1768
1769    /// Cancel an order.
1770    ///
1771    /// # Async cancel semantics
1772    ///
1773    /// Returns `Ok(Cancelled)` once Alpaca has **accepted** the cancel, not once the order is
1774    /// cancelled: `DELETE /v2/orders/{id}` answers 204 when the cancel request is accepted and the
1775    /// order will be cancelled. Until it is, the order can be `pending_cancel`, still live, and can
1776    /// still fill. The account stream reports how it ended, and any fill.
1777    ///
1778    /// The 204 has no body, so `filled_quantity` is zero whatever the order filled, and
1779    /// `time_exchange` is the local time the answer arrived.
1780    ///
1781    /// The client keeps holding the order as live until the stream reports how it ended, so if the
1782    /// stream is down when it does, a reconnect still finds out (see
1783    /// [`account_stream`](ExecutionClient::account_stream)).
1784    ///
1785    /// Needs the venue order id. A request without one is refused, since Alpaca cancels by that
1786    /// id only; resolve it through [`ExecutionClient::fetch_open_orders`].
1787    async fn cancel_order(
1788        &self,
1789        request: OrderRequestCancel<ExchangeId, &InstrumentNameExchange>,
1790    ) -> UnindexedOrderResponseCancel {
1791        let key = crate::order::OrderKey {
1792            exchange: request.key.exchange,
1793            instrument: request.key.instrument.clone(),
1794            strategy: request.key.strategy.clone(),
1795            cid: request.key.cid.clone(),
1796        };
1797
1798        // Require the exchange order ID — Alpaca's DELETE endpoint uses the UUID.
1799        // If only clientOrderId is available, the caller should first resolve it
1800        // via fetch_open_orders.
1801        let order_id: SmolStr = match request.state.id.as_ref().and_then(VenueOrderId::assigned) {
1802            Some(id) => id.0.clone(),
1803            None => {
1804                warn!(
1805                    instrument = %key.instrument,
1806                    "Alpaca cancel_order: no exchange order ID available (clientOrderId-only cancel not supported)"
1807                );
1808                return crate::order::request::OrderResponseCancel {
1809                    key,
1810                    state: Err(UnindexedOrderError::Rejected(ApiError::OrderRejected(
1811                        "exchange order ID required for cancel (fetch_open_orders to resolve)"
1812                            .into(),
1813                    ))),
1814                };
1815            }
1816        };
1817
1818        let base = self.base_url();
1819        let http = self.http.clone();
1820        let url = format!("{base}/v2/orders/{order_id}");
1821
1822        match rest_delete_with_retry(&self.rate_limiter, || http.delete(&url)).await {
1823            Ok(()) => {
1824                let exchange_order_id = OrderId(order_id);
1825                // REST DELETE returns no response body, so the filled quantity is unknown
1826                // here; the account stream's `canceled` update reports it.
1827                crate::order::request::OrderResponseCancel {
1828                    key,
1829                    state: Ok(Cancelled::new(exchange_order_id, Utc::now(), None)),
1830                }
1831            }
1832            Err(e) => crate::order::request::OrderResponseCancel { key, state: Err(e) },
1833        }
1834    }
1835
1836    /// # Position intent derivation
1837    ///
1838    /// Alpaca options/equities require explicit `position_intent`. This impl derives
1839    /// intent from `RequestOpen::reduce_only` and `side`:
1840    ///
1841    /// | reduce_only     | side | intent       | use case                          |
1842    /// |-----------------|------|--------------|-----------------------------------|
1843    /// | `No`            | Buy  | BuyToOpen    | open long / add to long position  |
1844    /// | `No`            | Sell | SellToOpen   | open short / write option         |
1845    /// | any other value | Buy  | BuyToClose   | close short position              |
1846    /// | any other value | Sell | SellToClose  | close long position               |
1847    ///
1848    /// For explicit control, use [`AlpacaClient::open_order_with_intent`].
1849    ///
1850    /// # Market order price
1851    ///
1852    /// The returned `Order.price` echoes the request price. For market orders this is
1853    /// typically `Decimal::ZERO` (a placeholder). The **actual fill price** arrives via
1854    /// the WebSocket `trade_updates` stream as a `Trade` event. Do not rely on
1855    /// `Order.price` from this REST ack for market order fill prices.
1856    async fn open_order(
1857        &self,
1858        request: OrderRequestOpen<ExchangeId, &InstrumentNameExchange>,
1859    ) -> Order<ExchangeId, InstrumentNameExchange, UnindexedOrderState> {
1860        let side = request.state.side;
1861        let reduce_only = request.state.reduce_only;
1862        self.open_order_inner(request, map_position_intent(side, reduce_only))
1863            .await
1864    }
1865
1866    /// Fetches balances sequentially (unlike `account_snapshot` which parallelizes).
1867    ///
1868    /// Sequential fetch is intentional for live operation: under rate pressure, parallel
1869    /// requests may both hit 429 simultaneously and retry independently, doubling the
1870    /// backoff delay. The startup latency savings from `account_snapshot`'s parallel
1871    /// fetch are worth the tradeoff there; for periodic balance refreshes during live
1872    /// trading, sequential is safer.
1873    async fn fetch_balances(
1874        &self,
1875        assets: &[AssetNameExchange],
1876    ) -> Result<Vec<AssetBalance<AssetNameExchange>>, UnindexedClientError> {
1877        let base = self.base_url();
1878        let http = self.http.clone();
1879        let mut result = Vec::new();
1880
1881        // Only fetch the account (USD balance) when USD is among the requested assets.
1882        // Crypto-only requests skip this call to conserve rate-limit budget.
1883        let wants_usd = assets.is_empty() || assets.iter().any(is_usd);
1884        if wants_usd {
1885            // Pre-allocate URL to avoid re-allocation on each retry attempt.
1886            let account_url = format!("{base}/v2/account");
1887            let account: AlpacaAccount =
1888                rest_with_retry(&self.rate_limiter, || http.get(&account_url)).await?;
1889            result.extend(convert_account_to_balances(&account, assets));
1890        }
1891
1892        // Fetch positions for non-USD asset balances (e.g., BTC, ETH from crypto holdings).
1893        let wants_non_usd = assets.is_empty() || assets.iter().any(|a| !is_usd(a));
1894        if wants_non_usd {
1895            // Pre-allocate URL to avoid re-allocation on each retry attempt.
1896            let positions_url = format!("{base}/v2/positions");
1897            let positions: Vec<AlpacaPosition> =
1898                rest_with_retry(&self.rate_limiter, || http.get(&positions_url)).await?;
1899            result.extend(convert_positions_to_balances(&positions, assets));
1900        }
1901
1902        Ok(result)
1903    }
1904
1905    async fn fetch_open_orders(
1906        &self,
1907        instruments: &[InstrumentNameExchange],
1908    ) -> Result<Vec<Order<ExchangeId, InstrumentNameExchange, Open>>, UnindexedClientError> {
1909        let base = self.base_url();
1910        let http = self.http.clone();
1911
1912        let open_orders =
1913            fetch_raw_open_orders(&http, &self.rate_limiter, base, instruments).await?;
1914
1915        let result: Vec<_> = open_orders
1916            .into_iter()
1917            .filter_map(|o| convert_open_order(&o))
1918            .collect();
1919        self.remember_live(&result);
1920        Ok(result)
1921    }
1922
1923    /// # Cost
1924    ///
1925    /// One call sends up to 50 requests, reading at most 5,000 FILL activities, and returns
1926    /// `resume` when it stops there. The limit counts every activity on the account, before the
1927    /// instrument filter, so a call for one instrument can spend all 50 requests and return no
1928    /// trades with `resume: Some`.
1929    ///
1930    /// # Times
1931    ///
1932    /// An activity whose `transaction_time` does not parse is still returned, since Alpaca placed
1933    /// it in the span. Its `time_exchange` is then the local clock at the read, which can lie
1934    /// outside the span, and a warning names it.
1935    async fn fetch_trades(
1936        &self,
1937        start: DateTime<Utc>,
1938        end: DateTime<Utc>,
1939        instruments: &[InstrumentNameExchange],
1940    ) -> Result<TradesRead<AssetNameExchange, InstrumentNameExchange>, UnindexedClientError> {
1941        // An empty span, or one ending before the epoch, holds nothing Alpaca has.
1942        if start > end || end < DateTime::UNIX_EPOCH {
1943            return Ok(TradesRead::complete(Vec::new()));
1944        }
1945        let page = paginate_activities::<AlpacaActivity>(
1946            &self.http,
1947            &self.rate_limiter,
1948            self.base_url(),
1949            FILL_ACTIVITIES,
1950            start,
1951            Some(end),
1952        )
1953        .await?;
1954
1955        let Ok(resume) = span_resume(page.resume, start, end) else {
1956            error!(
1957                %start,
1958                fills_read = page.activities.len(),
1959                "Alpaca fetch_trades: a full read did not advance past the span's first millisecond"
1960            );
1961            return Err(UnindexedClientError::Truncated {
1962                fills_read: page.activities.len(),
1963            });
1964        };
1965
1966        // Empty instruments slice means "all instruments" — same convention as
1967        // fetch_open_orders.
1968        let instrument_set: fnv::FnvHashSet<&str> =
1969            instruments.iter().map(|i| i.name().as_str()).collect();
1970        let trades = page
1971            .activities
1972            .iter()
1973            .filter(|a| instrument_set.is_empty() || instrument_set.contains(a.symbol.as_str()))
1974            // Alpaca's bounds are not exact (see `paginate_activities`), so the span is applied
1975            // here. An activity whose time does not parse is kept: Alpaca placed it in the span.
1976            .filter(|a| {
1977                parse_timestamp(&a.transaction_time)
1978                    .is_none_or(|time| (start..=end).contains(&time))
1979            })
1980            .filter_map(convert_activity_to_trade)
1981            .collect();
1982
1983        Ok(TradesRead::new(trades, resume))
1984    }
1985}
1986
1987// ---------------------------------------------------------------------------
1988// AlpacaClient public extension methods (not on ExecutionClient trait)
1989// ---------------------------------------------------------------------------
1990
1991impl AlpacaClient {
1992    /// Read the cash Alpaca moved into or out of the account other than by a trade, from `start`
1993    /// to `end`: interest, fees, and pass-through charges and rebates.
1994    ///
1995    /// Alpaca streams none of these: `trade_updates` carries order events only. Nothing is pushed:
1996    /// the caller decides when to read, and must drop the flows it has already applied, by
1997    /// [`id`](CashFlow::id), before applying a read's. Alpaca posts each to the account's cash,
1998    /// so the account's USD balance already includes a flow read here.
1999    ///
2000    /// # What each activity becomes
2001    ///
2002    /// | Activity | [`CashFlowKind`] |
2003    /// |---|---|
2004    /// | `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 |
2005    /// | `PTR`, a pass-through rebate | [`Rebate`](CashFlowKind::Rebate) |
2006    /// | Any other: `FEE`, `PTC`, credit interest | [`Other`](CashFlowKind::Other), naming the type and, when Alpaca gives one, the sub-type, as `FEE.TAF` |
2007    ///
2008    /// Alpaca documents no activity type for a stock borrow fee. Should it post one as one of
2009    /// these, it arrives as [`Other`](CashFlowKind::Other) with its own name.
2010    ///
2011    /// - [`amount`](CashFlow::amount) is Alpaca's `net_amount`, negative when the account paid.
2012    /// - [`asset`](CashFlow::asset) is its `currency`, or `USD` when it names none.
2013    /// - [`instrument`](CashFlow::instrument) is its `symbol`, when it names one.
2014    /// - [`time_exchange`](CashFlow::time_exchange) is when Alpaca created the activity
2015    ///   (`created_at`), which its `after` and `until` filter on. A fee is typically created the
2016    ///   day after the trade it was charged on.
2017    /// - [`id`](CashFlow::id) is Alpaca's activity id.
2018    ///
2019    /// # Status
2020    /// - An activity Alpaca marks `canceled` is left out, and logged at INFO. A flow read before
2021    ///   it was canceled is not reversed: the caller reconciles against the account's cash.
2022    /// - One marked `correct` is returned like any other. Alpaca does not document whether a
2023    ///   correction stands beside the activity it corrects or replaces it, so a caller applying
2024    ///   both by id may count the cash twice. Reconcile against the account's cash.
2025    ///
2026    /// An activity without an amount or a creation time that parses is left out, and logged at
2027    /// WARN.
2028    ///
2029    /// # Cost and resuming
2030    /// One call sends up to 50 requests, reading at most 5,000 activities, and returns `resume`
2031    /// when it stops there: the millisecond of the last activity's creation time. That resume is
2032    /// exact only if Alpaca serves these activities in order of creation, as it serves fills in
2033    /// order of time; its documentation orders them by "activity datetime" without saying which
2034    /// time, and this ordering has not been observed for them. Read spans short enough to fit one
2035    /// call, as the volume of these activities usually allows, and `resume` stays `None`.
2036    ///
2037    /// # Errors
2038    /// Fails if a request fails, or, with [`UnindexedClientError::Internal`], if more activities
2039    /// share the span's first millisecond than one call reads, so a read from `resume` could not
2040    /// advance.
2041    pub async fn fetch_cash_flows(
2042        &self,
2043        start: DateTime<Utc>,
2044        end: DateTime<Utc>,
2045    ) -> Result<CashFlowsRead<AssetNameExchange, InstrumentNameExchange>, UnindexedClientError>
2046    {
2047        // An empty span, or one ending before the epoch, holds nothing Alpaca has.
2048        if start > end || end < DateTime::UNIX_EPOCH {
2049            return Ok(CashFlowsRead::complete(Vec::new()));
2050        }
2051        let page = paginate_activities::<AlpacaNonTradeActivity>(
2052            &self.http,
2053            &self.rate_limiter,
2054            self.base_url(),
2055            CASH_FLOW_ACTIVITIES,
2056            start,
2057            Some(end),
2058        )
2059        .await?;
2060        let Ok(resume) = span_resume(page.resume, start, end) else {
2061            error!(
2062                %start,
2063                activities_read = page.activities.len(),
2064                "Alpaca fetch_cash_flows: a full read did not advance past the span's first \
2065                 millisecond"
2066            );
2067            return Err(UnindexedClientError::Internal(format!(
2068                "Alpaca cash-flow read of {} activities did not advance past {start}",
2069                page.activities.len()
2070            )));
2071        };
2072
2073        let flows = page
2074            .activities
2075            .iter()
2076            .filter_map(convert_cash_flow)
2077            // Alpaca's bounds are not exact (see `paginate_activities`), so the span is applied
2078            // here.
2079            .filter(|flow| (start..=end).contains(&flow.time_exchange))
2080            .collect();
2081        Ok(CashFlowsRead::new(flows, resume))
2082    }
2083
2084    /// Read the trades Alpaca made when an option contract was exercised, assigned or expired,
2085    /// from `start` to `end`.
2086    ///
2087    /// Alpaca reports these as account activities, not fills: `trade_updates` carries none of
2088    /// them, and [`fetch_trades`](crate::client::ExecutionClient::fetch_trades) reads fills only.
2089    /// Nothing is pushed: the caller decides when to read, and must drop the trades it has
2090    /// already applied, by [`id`](Trade::id), before applying a read's. To let these trades move
2091    /// the engine's positions without the engine also settling the contract at expiry, set the
2092    /// exchange's expiry handling to `Venue` (see `ExpiryHandling` in the `rustrade` crate).
2093    ///
2094    /// # What each activity becomes
2095    ///
2096    /// Each becomes one [`Trade`], whose [`origin`](Trade::origin) says why it happened:
2097    ///
2098    /// | Activity | Instrument | [`TradeOrigin`] | Price |
2099    /// |---|---|---|---|
2100    /// | `OPEXP`, expiry | the contract | [`Expiry`](TradeOrigin::Expiry) | 0 |
2101    /// | `OPEXC`, exercise | the contract | [`Exercise`](TradeOrigin::Exercise) | 0 |
2102    /// | `OPASN`, assignment | the contract | [`Assignment`](TradeOrigin::Assignment) | 0 |
2103    /// | `OPTRD`, the shares an exercise or assignment trades | the underlying | that of its exercise or assignment, else [`External`](TradeOrigin::External) | the strike |
2104    ///
2105    /// - [`side`](Trade::side) and [`quantity`](Trade::quantity) come from Alpaca's signed `qty`:
2106    ///   negative is a sell. An exercised long or an expiring long is sold; an assigned short is
2107    ///   bought. Quantities are in contracts on the contract, and in shares on `OPTRD`.
2108    /// - The contract leaves at a price of 0. What it was worth at exercise or assignment is in the
2109    ///   shares traded at the strike.
2110    /// - [`time_exchange`](Trade::time_exchange) is when Alpaca created the activity
2111    ///   (`created_at`), or the start of its `date`, in UTC, when it gives no creation time. Such
2112    ///   a dated activity is in the span when its `date` is one of the span's UTC days, so a read
2113    ///   up to now still returns one posted today, and successive reads within one day each
2114    ///   return it again.
2115    /// - [`id`](Trade::id) and [`order_id`](Trade::order_id) are Alpaca's activity id, since no
2116    ///   order produced the trade. [`strategy`](Trade::strategy) is unknown, and fees are zero.
2117    ///
2118    /// # Pairing the share leg
2119    ///
2120    /// `OPTRD` names the underlying, not the contract, so its origin is found by pairing it with
2121    /// an `OPEXC` or `OPASN` of the same `date` whose contract's root symbol is the `OPTRD`'s
2122    /// symbol, whose strike is its price, and whose kind implies its side: exercising a call or
2123    /// being assigned on a put buys the shares, and the other two sell them. Each is paired at
2124    /// most once, in the order Alpaca returns them, preferring one of 100 shares a contract, the
2125    /// standard size, so that two exercises at one strike pair with their own shares. An `OPTRD`
2126    /// with no such partner is returned as [`External`](TradeOrigin::External), and logged at
2127    /// WARN. The read covers the whole UTC days of `start` and `end`, so that both legs are read
2128    /// together, and returns only the trades in `start..=end`.
2129    ///
2130    /// # Status
2131    /// An activity Alpaca marks `canceled` is left out, and logged at INFO. An activity without a
2132    /// symbol, a non-zero quantity or a time, an `OPTRD` without a price, or one of another type
2133    /// is left out, and logged at WARN.
2134    ///
2135    /// # Cost and resuming
2136    /// As [`fetch_cash_flows`](Self::fetch_cash_flows): one call sends up to 50 requests, reading
2137    /// at most 5,000 activities, and returns `resume` when it stops there. A read from `resume`
2138    /// reads its whole UTC day again, so drop by id what was already applied. So more than 5,000
2139    /// option activities in one UTC day cannot be read through; see Errors.
2140    ///
2141    /// A read that stops at the cap can split an exercise or assignment from its share leg, and
2142    /// return the `OPTRD` as [`External`](TradeOrigin::External) while the next read, from
2143    /// `resume`, returns it again with its pair's origin. A caller dropping trades by id keeps
2144    /// the first. When a read returns `resume`, hold back its `External` `OPTRD`s until the next
2145    /// read.
2146    ///
2147    /// # Errors
2148    /// Fails if a request fails, or, with [`UnindexedClientError::Internal`], if a read from
2149    /// `resume` could not advance: one UTC day holds more option activities than one call reads.
2150    /// That includes a day whose activities before `start` alone fill the call.
2151    pub async fn fetch_option_events(
2152        &self,
2153        start: DateTime<Utc>,
2154        end: DateTime<Utc>,
2155    ) -> Result<TradesRead<AssetNameExchange, InstrumentNameExchange>, UnindexedClientError> {
2156        // An empty span, or one ending before the epoch, holds nothing Alpaca has.
2157        if start > end || end < DateTime::UNIX_EPOCH {
2158            return Ok(TradesRead::complete(Vec::new()));
2159        }
2160        // Whole UTC days, so the legs of one exercise or assignment are read together.
2161        let read_start = start.date_naive().and_time(NaiveTime::MIN).and_utc();
2162        let read_end = end.date_naive().and_time(LAST_MILLISECOND).and_utc();
2163        let page = paginate_activities::<AlpacaNonTradeActivity>(
2164            &self.http,
2165            &self.rate_limiter,
2166            self.base_url(),
2167            OPTION_EVENT_ACTIVITIES,
2168            read_start,
2169            Some(read_end),
2170        )
2171        .await?;
2172        let Ok(resume) = span_resume(page.resume, start, end) else {
2173            error!(
2174                %start,
2175                activities_read = page.activities.len(),
2176                "Alpaca fetch_option_events: a full read did not advance past the span's start"
2177            );
2178            return Err(UnindexedClientError::Internal(format!(
2179                "Alpaca option-event read of {} activities did not advance past {start}",
2180                page.activities.len()
2181            )));
2182        };
2183
2184        Ok(TradesRead::new(
2185            convert_option_events(&page.activities, start, end),
2186            resume,
2187        ))
2188    }
2189
2190    /// Place an order with an explicit `position_intent` override.
2191    ///
2192    /// Use this instead of `ExecutionClient::open_order` when you need to specify
2193    /// exact position intent (e.g. `SellToOpen` for writing a short option, or
2194    /// `BuyToClose` for closing a short position by buying).
2195    ///
2196    /// `intent` is only sent for non-crypto symbols. For crypto symbols (those
2197    /// containing `/`) the field is always omitted regardless of `intent`.
2198    ///
2199    /// # Caller obligations
2200    /// The caller is responsible for passing the semantically correct intent.
2201    /// No validation is performed against the order side or existing position.
2202    pub async fn open_order_with_intent(
2203        &self,
2204        request: OrderRequestOpen<ExchangeId, &InstrumentNameExchange>,
2205        intent: AlpacaPositionIntent,
2206    ) -> Order<ExchangeId, InstrumentNameExchange, UnindexedOrderState> {
2207        self.open_order_inner(request, intent).await
2208    }
2209
2210    /// Place a bracket order (entry + take-profit + stop-loss) in a single request.
2211    ///
2212    /// A bracket order consists of three linked orders:
2213    /// 1. **Entry**: Limit order to enter the position
2214    /// 2. **Take Profit**: Limit order to exit at profit target
2215    /// 3. **Stop Loss**: Stop (or stop-limit) order to exit at loss limit
2216    ///
2217    /// When the entry order fills, Alpaca activates both exit legs. When either
2218    /// exit leg fills, Alpaca automatically cancels the other.
2219    ///
2220    /// # Constraints
2221    ///
2222    /// - `time_in_force` must be `Day` or `GoodUntilCancelled` (Alpaca rejects others)
2223    /// - No extended hours trading with bracket orders
2224    /// - Entry is always a limit order
2225    ///
2226    /// # Example
2227    ///
2228    /// ```ignore
2229    /// let request = AlpacaBracketOrderRequest::new(
2230    ///     "AAPL".into(),
2231    ///     StrategyId::new("momentum"),
2232    ///     ClientOrderId::new("bracket-001"),
2233    ///     Side::Buy,
2234    ///     dec!(10),
2235    ///     dec!(150.00),  // entry
2236    ///     dec!(160.00),  // take profit
2237    ///     dec!(145.00),  // stop loss
2238    ///     TimeInForce::GoodUntilCancelled { post_only: false },
2239    /// );
2240    /// let result = client.open_bracket_order(request).await;
2241    /// ```
2242    pub async fn open_bracket_order(
2243        &self,
2244        request: AlpacaBracketOrderRequest,
2245    ) -> AlpacaBracketOrderResult {
2246        let order_key = crate::order::OrderKey::new(
2247            ExchangeId::AlpacaBroker,
2248            request.instrument.clone(),
2249            request.strategy.clone(),
2250            request.cid.clone(),
2251        );
2252
2253        // Validate time_in_force: bracket orders only support day or gtc
2254        let tif_str = match request.time_in_force {
2255            TimeInForce::GoodUntilEndOfDay => "day",
2256            TimeInForce::GoodUntilCancelled { post_only } => {
2257                if post_only {
2258                    return AlpacaBracketOrderResult {
2259                        parent: Order {
2260                            key: order_key,
2261                            side: request.side,
2262                            price: Some(request.entry_price),
2263                            quantity: request.quantity,
2264                            kind: OrderKind::Limit,
2265                            time_in_force: request.time_in_force,
2266                            state: OrderState::inactive(OrderError::Rejected(
2267                                ApiError::OrderRejected(
2268                                    "Alpaca does not support post_only for bracket orders"
2269                                        .to_string(),
2270                                ),
2271                            )),
2272                        },
2273                    };
2274                }
2275                "gtc"
2276            }
2277            other => {
2278                return AlpacaBracketOrderResult {
2279                    parent: Order {
2280                        key: order_key,
2281                        side: request.side,
2282                        price: Some(request.entry_price),
2283                        quantity: request.quantity,
2284                        kind: OrderKind::Limit,
2285                        time_in_force: request.time_in_force,
2286                        state: OrderState::inactive(OrderError::Rejected(ApiError::OrderRejected(
2287                            format!(
2288                                "Alpaca bracket orders only support Day or GTC time_in_force, got {:?}",
2289                                other
2290                            ),
2291                        ))),
2292                    },
2293                };
2294            }
2295        };
2296
2297        // Validate price ordering. Alpaca rejects mis-ordered brackets with a
2298        // generic 422; surface a structured local error before round-trip.
2299        let price_ordering_ok = match request.side {
2300            Side::Buy => {
2301                request.stop_loss_price < request.entry_price
2302                    && request.entry_price < request.take_profit_price
2303            }
2304            Side::Sell => {
2305                request.take_profit_price < request.entry_price
2306                    && request.entry_price < request.stop_loss_price
2307            }
2308        };
2309        if !price_ordering_ok {
2310            return AlpacaBracketOrderResult {
2311                parent: Order {
2312                    key: order_key,
2313                    side: request.side,
2314                    price: Some(request.entry_price),
2315                    quantity: request.quantity,
2316                    kind: OrderKind::Limit,
2317                    time_in_force: request.time_in_force,
2318                    state: OrderState::inactive(OrderError::Rejected(ApiError::OrderRejected(
2319                        format!(
2320                            "Invalid bracket price ordering for {:?} side: entry={}, take_profit={}, stop_loss={}",
2321                            request.side,
2322                            request.entry_price,
2323                            request.take_profit_price,
2324                            request.stop_loss_price,
2325                        ),
2326                    ))),
2327                },
2328            };
2329        }
2330
2331        // Validate stop-loss limit price if provided. For a Buy bracket, the SL
2332        // is a sell stop(-limit); the limit must be at or below the trigger so
2333        // the order can fill if price gaps down. Reverse for Sell brackets.
2334        if let Some(sl_limit) = request.stop_loss_limit_price {
2335            let sl_limit_ok = match request.side {
2336                Side::Buy => sl_limit <= request.stop_loss_price,
2337                Side::Sell => sl_limit >= request.stop_loss_price,
2338            };
2339            if !sl_limit_ok {
2340                return AlpacaBracketOrderResult {
2341                    parent: Order {
2342                        key: order_key,
2343                        side: request.side,
2344                        price: Some(request.entry_price),
2345                        quantity: request.quantity,
2346                        kind: OrderKind::Limit,
2347                        time_in_force: request.time_in_force,
2348                        state: OrderState::inactive(OrderError::Rejected(ApiError::OrderRejected(
2349                            format!(
2350                                "Invalid stop-loss limit price for {:?} bracket: \
2351                                 stop_loss_price={}, stop_loss_limit_price={}",
2352                                request.side, request.stop_loss_price, sl_limit,
2353                            ),
2354                        ))),
2355                    },
2356                };
2357            }
2358        }
2359
2360        // Build the bracket order request
2361        let body = AlpacaOrderRequest {
2362            symbol: request.instrument.name().as_str(),
2363            qty: request.quantity.to_string(),
2364            side: map_side(request.side),
2365            order_type: "limit",
2366            time_in_force: tif_str,
2367            limit_price: Some(request.entry_price.to_string()),
2368            stop_price: None,
2369            trail_percent: None,
2370            trail_price: None,
2371            client_order_id: Some(request.cid.0.as_str()),
2372            position_intent: if is_options_or_equity_symbol(request.instrument.name().as_str()) {
2373                Some(map_position_intent(request.side, ReduceOnly::No))
2374            } else {
2375                None
2376            },
2377            order_class: Some("bracket"),
2378            take_profit: Some(TakeProfitParams {
2379                limit_price: request.take_profit_price.to_string(),
2380            }),
2381            stop_loss: Some(StopLossParams {
2382                stop_price: request.stop_loss_price.to_string(),
2383                limit_price: request.stop_loss_limit_price.map(|p| p.to_string()),
2384            }),
2385        };
2386
2387        let http = self.http.clone();
2388        let rl = &self.rate_limiter;
2389
2390        let result: Result<AlpacaOrderResponse, UnindexedClientError> =
2391            rest_with_retry(rl, || http.post(&self.orders_url).json(&body)).await;
2392
2393        match result {
2394            Ok(resp) => {
2395                let state = placed_order_state(&resp, &order_key.instrument, request.quantity);
2396                self.known_live.lock().placed(
2397                    &order_key,
2398                    request.quantity,
2399                    OrderKind::Limit,
2400                    &state,
2401                );
2402
2403                AlpacaBracketOrderResult {
2404                    parent: Order {
2405                        key: order_key,
2406                        side: request.side,
2407                        price: Some(request.entry_price),
2408                        quantity: request.quantity,
2409                        kind: OrderKind::Limit,
2410                        time_in_force: request.time_in_force,
2411                        state,
2412                    },
2413                }
2414            }
2415            Err(e) => {
2416                let order_err = order_post_error(e);
2417                AlpacaBracketOrderResult {
2418                    parent: Order {
2419                        key: order_key,
2420                        side: request.side,
2421                        price: Some(request.entry_price),
2422                        quantity: request.quantity,
2423                        kind: OrderKind::Limit,
2424                        time_in_force: request.time_in_force,
2425                        state: OrderState::inactive(order_err),
2426                    },
2427                }
2428            }
2429        }
2430    }
2431
2432    async fn open_order_inner(
2433        &self,
2434        request: OrderRequestOpen<ExchangeId, &InstrumentNameExchange>,
2435        intent: AlpacaPositionIntent,
2436    ) -> Order<ExchangeId, InstrumentNameExchange, UnindexedOrderState> {
2437        let instrument = request.key.instrument.clone();
2438        let side = request.state.side;
2439        let price = request.state.price;
2440        let quantity = request.state.quantity;
2441        let kind = request.state.kind;
2442        let time_in_force = request.state.time_in_force;
2443        let cid = request.key.cid.clone();
2444
2445        let order_key = crate::order::OrderKey::new(
2446            ExchangeId::AlpacaBroker,
2447            instrument.clone(),
2448            request.key.strategy.clone(),
2449            cid.clone(),
2450        );
2451
2452        // Margin is account-level at Alpaca: no order flag borrows or repays, so no effect is
2453        // honoured.
2454        if let Some(effect) = request.state.margin_effect {
2455            return Order {
2456                key: order_key,
2457                side,
2458                price,
2459                quantity,
2460                kind,
2461                time_in_force,
2462                state: OrderState::inactive(OrderError::UnsupportedMarginEffect(effect)),
2463            };
2464        }
2465
2466        // Validate time_in_force before building the request — reject post_only early.
2467        let tif_str = match map_time_in_force(time_in_force) {
2468            Ok(s) => s,
2469            Err(msg) => {
2470                return Order {
2471                    key: order_key,
2472                    side,
2473                    price,
2474                    quantity,
2475                    kind,
2476                    time_in_force,
2477                    state: OrderState::inactive(OrderError::Rejected(ApiError::OrderRejected(
2478                        msg.to_string(),
2479                    ))),
2480                };
2481            }
2482        };
2483
2484        // Validate order kind — reject unsupported types early.
2485        let order_type_str = match map_order_kind(kind) {
2486            Some(s) => s,
2487            None => {
2488                return Order {
2489                    key: order_key,
2490                    side,
2491                    price,
2492                    quantity,
2493                    kind,
2494                    time_in_force,
2495                    state: OrderState::inactive(OrderError::UnsupportedOrderType(format!(
2496                        "Alpaca connector does not yet support OrderKind::{kind:?}"
2497                    ))),
2498                };
2499            }
2500        };
2501
2502        // Validate trailing stop offset type — Alpaca only supports percent and absolute.
2503        if let OrderKind::TrailingStop {
2504            offset_type: TrailingOffsetType::BasisPoints,
2505            ..
2506        } = kind
2507        {
2508            return Order {
2509                key: order_key,
2510                side,
2511                price,
2512                quantity,
2513                kind,
2514                time_in_force,
2515                state: OrderState::inactive(OrderError::UnsupportedOrderType(
2516                    "Alpaca does not support TrailingOffsetType::BasisPoints; \
2517                     use Percentage or Absolute"
2518                        .to_string(),
2519                )),
2520            };
2521        }
2522
2523        // StopLimit requires Order.price (the limit price applied once trigger fires).
2524        // Guard locally to avoid a wire round-trip producing a generic 422.
2525        if matches!(kind, OrderKind::StopLimit { .. }) && price.is_none() {
2526            return Order {
2527                key: order_key,
2528                side,
2529                price,
2530                quantity,
2531                kind,
2532                time_in_force,
2533                state: OrderState::inactive(OrderError::Rejected(ApiError::OrderRejected(
2534                    "StopLimit order requires Order.price (the limit price) to be set".to_string(),
2535                ))),
2536            };
2537        }
2538
2539        // Extract stop/trailing parameters based on order kind.
2540        let (stop_price, trail_percent, trail_price) = match kind {
2541            OrderKind::Stop { trigger_price } | OrderKind::StopLimit { trigger_price } => {
2542                (Some(trigger_price.to_string()), None, None)
2543            }
2544            OrderKind::TrailingStop {
2545                offset,
2546                offset_type,
2547                ..
2548            } => match offset_type {
2549                TrailingOffsetType::Percentage => (None, Some(offset.to_string()), None),
2550                TrailingOffsetType::Absolute => (None, None, Some(offset.to_string())),
2551                TrailingOffsetType::BasisPoints => unreachable!("validated above"),
2552            },
2553            // TakeProfit/TakeProfitLimit rejected by map_order_kind → unreachable here
2554            OrderKind::Market
2555            | OrderKind::Limit
2556            | OrderKind::TakeProfit { .. }
2557            | OrderKind::TakeProfitLimit { .. }
2558            | OrderKind::TrailingStopLimit { .. }
2559            | OrderKind::TrailingStopFixedLimit { .. }
2560            | OrderKind::ConditionalUnresolved { .. } => (None, None, None),
2561        };
2562
2563        let body = AlpacaOrderRequest {
2564            symbol: instrument.name().as_str(),
2565            qty: quantity.to_string(),
2566            side: map_side(side),
2567            order_type: order_type_str,
2568            time_in_force: tif_str,
2569            limit_price: match kind {
2570                OrderKind::Limit | OrderKind::StopLimit { .. } => price.map(|p| p.to_string()),
2571                _ => None,
2572            },
2573            stop_price,
2574            trail_percent,
2575            trail_price,
2576            client_order_id: Some(cid.0.as_str()),
2577            // position_intent is required for options orders and valid (but optional)
2578            // for equities. It is NOT a valid field for crypto orders and must be
2579            // omitted, or Alpaca will return 422 Unprocessable Entity.
2580            // We detect options by OCC symbol format; equities also get the field
2581            // as it aids intent tracking on margin accounts.
2582            position_intent: if is_options_or_equity_symbol(instrument.name().as_str()) {
2583                Some(intent)
2584            } else {
2585                None
2586            },
2587            // Non-bracket order: no bracket fields
2588            order_class: None,
2589            take_profit: None,
2590            stop_loss: None,
2591        };
2592
2593        let http = self.http.clone();
2594        let rl = &self.rate_limiter;
2595
2596        let result: Result<AlpacaOrderResponse, UnindexedClientError> =
2597            rest_with_retry(rl, || http.post(&self.orders_url).json(&body)).await;
2598
2599        match result {
2600            Ok(resp) => {
2601                let state = placed_order_state(&resp, &order_key.instrument, quantity);
2602                self.known_live
2603                    .lock()
2604                    .placed(&order_key, quantity, kind, &state);
2605
2606                Order {
2607                    key: order_key,
2608                    side,
2609                    price,
2610                    quantity,
2611                    kind,
2612                    time_in_force,
2613                    state,
2614                }
2615            }
2616            Err(e) => {
2617                let order_err = order_post_error(e);
2618                Order {
2619                    key: order_key,
2620                    side,
2621                    price,
2622                    quantity,
2623                    kind,
2624                    time_in_force,
2625                    state: OrderState::inactive(order_err),
2626                }
2627            }
2628        }
2629    }
2630}
2631
2632// ---------------------------------------------------------------------------
2633// REST helpers
2634// ---------------------------------------------------------------------------
2635
2636/// Maximum number of open orders returned by a single `/v2/orders` request.
2637///
2638/// Alpaca's API caps at 500; accounts exceeding this have an incomplete snapshot.
2639const MAX_OPEN_ORDERS: usize = 500;
2640
2641/// Fetch all open orders from Alpaca, optionally filtered by symbol.
2642///
2643/// # Errors
2644///
2645/// Returns [`UnindexedClientError::TruncatedSnapshot`] when exactly 500 results
2646/// are returned, indicating the API limit was likely hit and data may be incomplete.
2647/// This is an Alpaca API limitation with no pagination support for open orders.
2648async fn fetch_raw_open_orders(
2649    http: &reqwest::Client,
2650    rate_limiter: &RateLimitTracker,
2651    base: &str,
2652    instruments: &[InstrumentNameExchange],
2653) -> Result<Vec<AlpacaOrderResponse>, UnindexedClientError> {
2654    let orders: Vec<AlpacaOrderResponse> = if instruments.is_empty() {
2655        rest_with_retry(rate_limiter, || {
2656            http.get(format!("{base}/v2/orders"))
2657                .query(&[("status", "open"), ("limit", "500")])
2658        })
2659        .await?
2660    } else {
2661        let symbols = instruments.iter().map(|i| i.name().as_str()).join(",");
2662        rest_with_retry(rate_limiter, || {
2663            http.get(format!("{base}/v2/orders")).query(&[
2664                ("status", "open"),
2665                ("limit", "500"),
2666                ("symbols", &symbols),
2667            ])
2668        })
2669        .await?
2670    };
2671    if orders.len() == MAX_OPEN_ORDERS {
2672        warn!(
2673            limit = MAX_OPEN_ORDERS,
2674            "Alpaca fetch_raw_open_orders: received exactly {MAX_OPEN_ORDERS} results — \
2675             response is likely truncated"
2676        );
2677        return Err(UnindexedClientError::TruncatedSnapshot {
2678            limit: MAX_OPEN_ORDERS,
2679        });
2680    }
2681    Ok(orders)
2682}
2683
2684// ---------------------------------------------------------------------------
2685// Activity pagination
2686// ---------------------------------------------------------------------------
2687
2688/// Maximum number of pages fetched by one [`paginate_activities`] call.
2689///
2690/// 50 pages × 100 items = 5 000 fills. It bounds how long one read, such as a reconnect's
2691/// recovery, can take; a read that reaches it says where to read on from.
2692const MAX_ACTIVITY_PAGES: usize = 50;
2693
2694/// An account activity that [`paginate_activities`] pages through.
2695trait PagedActivity: DeserializeOwned {
2696    /// The activity's id, which is also the token for the page after it.
2697    fn id(&self) -> &str;
2698    /// The time Alpaca orders activities by and applies `after` and `until` to, or `None` when it
2699    /// does not parse.
2700    fn time(&self) -> Option<DateTime<Utc>>;
2701}
2702
2703impl PagedActivity for AlpacaActivity {
2704    fn id(&self) -> &str {
2705        &self.id
2706    }
2707
2708    fn time(&self) -> Option<DateTime<Utc>> {
2709        parse_timestamp(&self.transaction_time)
2710    }
2711}
2712
2713impl PagedActivity for AlpacaNonTradeActivity {
2714    fn id(&self) -> &str {
2715        &self.id
2716    }
2717
2718    fn time(&self) -> Option<DateTime<Utc>> {
2719        self.created_at.as_deref().and_then(parse_timestamp)
2720    }
2721}
2722
2723/// The `activity_types` a read of fills asks for.
2724const FILL_ACTIVITIES: &str = "FILL";
2725
2726/// The `activity_types` [`AlpacaClient::fetch_cash_flows`] asks for.
2727const CASH_FLOW_ACTIVITIES: &str = "INT,FEE,PTC,PTR";
2728
2729/// The `activity_types` [`AlpacaClient::fetch_option_events`] asks for.
2730const OPTION_EVENT_ACTIVITIES: &str = "OPASN,OPEXC,OPEXP,OPTRD";
2731
2732/// The last millisecond of a day, where [`AlpacaClient::fetch_option_events`] ends its read.
2733const LAST_MILLISECOND: NaiveTime = match NaiveTime::from_hms_milli_opt(23, 59, 59, 999) {
2734    Some(time) => time,
2735    None => panic!("23:59:59.999 is a time of day"),
2736};
2737
2738/// Where a bounded read of `start..=end` that stopped at `resume` should read on from: `None` when
2739/// it read the whole span, or `Err(())` when a read from `resume` would stop where this one did.
2740///
2741/// Alpaca's `until` is not exact, and is not sent for an `end` not yet reached, so a read can run
2742/// past `end`. Activities are ascending by millisecond, so a resume past `end` means every
2743/// activity in the span was read.
2744fn span_resume(
2745    resume: Option<DateTime<Utc>>,
2746    start: DateTime<Utc>,
2747    end: DateTime<Utc>,
2748) -> Result<Option<DateTime<Utc>>, ()> {
2749    let resume = resume.filter(|resume| *resume <= end);
2750    if resume.is_some_and(|resume| resume <= floor_millis(start)) {
2751        return Err(());
2752    }
2753    Ok(resume)
2754}
2755
2756/// What one [`paginate_activities`] call read.
2757struct ActivityPage<A> {
2758    /// Every activity read, in Alpaca's ascending order.
2759    activities: Vec<A>,
2760    /// Set when the read stopped at [`MAX_ACTIVITY_PAGES`]: every activity before this time was
2761    /// read, and some from it on may not have been.
2762    resume: Option<DateTime<Utc>>,
2763}
2764
2765/// `time` rounded down to the millisecond.
2766fn floor_millis(time: DateTime<Utc>) -> DateTime<Utc> {
2767    DateTime::from_timestamp_millis(time.timestamp_millis()).unwrap_or(time)
2768}
2769
2770/// Fetch the activities of the `activity_types` named in `types` (comma-separated) from `start`,
2771/// and up to `end` if given, using token-based pagination.
2772///
2773/// Alpaca returns up to `ALPACA_MAX_ACTIVITIES` per page. If a full page is
2774/// returned, the next request uses the last item's `id` as the `page_token`.
2775/// Pagination terminates when a page has fewer items than `page_size`, or after
2776/// [`MAX_ACTIVITY_PAGES`] pages (whichever comes first).
2777///
2778/// # Bounds
2779///
2780/// Alpaca's `after` and `until` are not exact. Observed on paper (2026-10-05):
2781/// - `after` matches an activity whose time, rounded down to the millisecond, is at or after it.
2782///   So a microsecond `after` drops the rest of its own millisecond. It is sent rounded down to
2783///   the millisecond, which reads the whole of `start`'s.
2784/// - `until` matched no single rule: exact to the microsecond for one fill, yet including a fill
2785///   15 µs past it for two that shared a millisecond. It is sent as the millisecond after
2786///   `end`'s, or not at all for an `end` not yet reached, and callers apply `end` to what is
2787///   read.
2788///
2789/// So the activities read can begin before `start` and end after `end`, within their
2790/// milliseconds.
2791///
2792/// # Resuming
2793///
2794/// Within one millisecond Alpaca orders activities by id, not by time, so a read cut inside a
2795/// millisecond may not have reached an earlier fill in it. [`ActivityPage::resume`] is therefore
2796/// the millisecond of the last activity read, and a read from it reads that millisecond again.
2797// Compile-time string form of ALPACA_MAX_ACTIVITIES (avoids runtime to_string() allocation).
2798const PAGE_SIZE_STR: &str = "100"; // must match ALPACA_MAX_ACTIVITIES
2799const _: () = assert!(
2800    ALPACA_MAX_ACTIVITIES == 100,
2801    "PAGE_SIZE_STR must be updated to match ALPACA_MAX_ACTIVITIES",
2802);
2803
2804async fn paginate_activities<A: PagedActivity>(
2805    http: &reqwest::Client,
2806    rate_limiter: &RateLimitTracker,
2807    base: &str,
2808    types: &str,
2809    start: DateTime<Utc>,
2810    end: Option<DateTime<Utc>>,
2811) -> Result<ActivityPage<A>, UnindexedClientError> {
2812    // Clamped to the epoch so a far-past `start` still formats as RFC 3339; Alpaca holds nothing
2813    // older. An `end` not yet reached sends no `until`, so a far-future one cannot overflow.
2814    let after =
2815        floor_millis(start.max(DateTime::UNIX_EPOCH)).to_rfc3339_opts(SecondsFormat::Millis, true);
2816    let until = end.filter(|end| *end < Utc::now()).map(|end| {
2817        (floor_millis(end) + TimeDelta::milliseconds(1))
2818            .to_rfc3339_opts(SecondsFormat::Millis, true)
2819    });
2820    let mut all = Vec::with_capacity(ALPACA_MAX_ACTIVITIES);
2821    let mut page_token: Option<String> = None;
2822    let mut pages = 0usize;
2823    let mut truncated = false;
2824
2825    loop {
2826        if pages >= MAX_ACTIVITY_PAGES {
2827            truncated = true;
2828            break;
2829        }
2830        pages += 1;
2831        // Borrow page_token as &str so the closure can capture by reference without cloning.
2832        let page_token_ref = page_token.as_deref();
2833        let activities: Vec<A> = rest_with_retry(rate_limiter, || {
2834            let mut req = http.get(format!("{base}/v2/account/activities")).query(&[
2835                ("activity_types", types),
2836                ("after", after.as_str()),
2837                ("page_size", PAGE_SIZE_STR),
2838                ("direction", "asc"),
2839            ]);
2840            if let Some(until) = until.as_deref() {
2841                req = req.query(&[("until", until)]);
2842            }
2843            if let Some(token) = page_token_ref {
2844                req = req.query(&[("page_token", token)]);
2845            }
2846            req
2847        })
2848        .await?;
2849
2850        let page_len = activities.len();
2851        // Capture the page token from the current page BEFORE extending `all`, so that
2852        // any future filtering between here and all.extend cannot shift the last element
2853        // and cause the same page to be re-fetched indefinitely.
2854        //
2855        // Alpaca activity IDs are ULID-format (monotonically increasing) and are accepted
2856        // as exclusive page_token cursors by GET /v2/account/activities: the next page
2857        // begins with the item AFTER the token, so the boundary item is not re-delivered.
2858        // The pagination contract relies on this property — verify against Alpaca API docs
2859        // if behaviour changes.
2860        let page_token_candidate = activities.last().map(|a| a.id().to_owned());
2861        all.extend(activities);
2862
2863        if page_len < ALPACA_MAX_ACTIVITIES {
2864            break;
2865        }
2866        match page_token_candidate {
2867            Some(token) if !token.is_empty() => {
2868                debug!("Alpaca paginate_activities: fetching next page ({page_len} results)");
2869                page_token = Some(token);
2870            }
2871            // Empty token or no last item — pagination complete.
2872            // Guard against empty string to prevent infinite loop if Alpaca ever
2873            // returns a full page with last.id = "" (would restart from beginning).
2874            _ => break,
2875        }
2876    }
2877
2878    // Activities are ascending by millisecond, so the last one whose time parses bounds what is
2879    // left. None parsing leaves everything from `start`.
2880    let resume = truncated.then(|| {
2881        floor_millis(
2882            all.iter()
2883                .rev()
2884                .find_map(PagedActivity::time)
2885                .unwrap_or(start),
2886        )
2887    });
2888    Ok(ActivityPage {
2889        activities: all,
2890        resume,
2891    })
2892}
2893
2894// ---------------------------------------------------------------------------
2895// WebSocket connection manager
2896// ---------------------------------------------------------------------------
2897
2898/// Long-running task managing the WebSocket lifecycle for account_stream.
2899///
2900/// Loop: connect → auth → subscribe → fill recovery → ended-order check → stream events → on
2901/// disconnect → backoff → reconnect. The `tx` channel persists across reconnections so the
2902/// consumer sees a seamless event stream.
2903///
2904/// Terminates when the consumer drops the stream or max reconnect attempts are
2905/// exhausted.
2906#[allow(clippy::cognitive_complexity)] // the inner select! loop owns `ws` and mutates `budget` —
2907// extracting to a function requires threading 4 non-Clone values (ws, tx, dedup, budget)
2908// through the call, which adds more complexity than it removes
2909async fn connection_manager(
2910    tx: mpsc::UnboundedSender<UnindexedAccountEvent>,
2911    dedup: SharedDedupCache,
2912    config: Arc<AlpacaConfig>,
2913    http: reqwest::Client,
2914    rate_limiter: Arc<RateLimitTracker>,
2915    known: SharedKnownLiveOrders,
2916    instruments: Vec<InstrumentNameExchange>,
2917    initial_ws: Option<WebSocket>,
2918) {
2919    let mut budget = ReconnectBudget::new("Alpaca", BackoffLimits::ACCOUNT_STREAM);
2920    let mut disconnect_time: Option<DateTime<Utc>> = None;
2921    // Instruments whose known-live orders a reconnect has yet to check: checked after the fills,
2922    // and retried on a timer while connected, once due.
2923    let mut unchecked = UncheckedOrders::default();
2924    let mut current_ws = initial_ws;
2925
2926    'outer: loop {
2927        // --- Connect (skip on first iteration if initial_ws was provided) ---
2928        let mut ws = match current_ws.take() {
2929            Some(ws) => ws,
2930            None => match connect_and_subscribe(&config).await {
2931                Ok(ws) => ws,
2932                Err(e) => {
2933                    error!(%e, "Alpaca WS connect/subscribe failed");
2934                    if !budget.wait().await {
2935                        error!("Alpaca max reconnect attempts exhausted");
2936                        // Signal terminal stream death in-band before tx drops.
2937                        emit_stream_terminated(
2938                            &tx,
2939                            ExchangeId::AlpacaBroker,
2940                            StreamTerminationReason::ReconnectBudgetExhausted {
2941                                attempts: budget.attempts(),
2942                                last_error: e.to_string(),
2943                            },
2944                        );
2945                        break;
2946                    }
2947                    continue;
2948                }
2949            },
2950        };
2951        info!("Alpaca account_stream connected and subscribed");
2952        // the budget is NOT reset here: a server that accepts each handshake and closes the
2953        // connection at once would then be retried at the first backoff wait forever, never
2954        // exhausting the budget. The stream loop resets it on a frame received once the connection
2955        // has lasted a heartbeat window.
2956
2957        // --- Fill recovery after reconnect ---
2958        // Runs before the event loop so live events arriving during recovery are
2959        // captured by the already-connected WS session. The dedup cache prevents
2960        // duplicates between recovered REST fills and live WS events.
2961        if let Some(dt) = disconnect_time.take() {
2962            recover_fills_or_report(
2963                &http,
2964                &rate_limiter,
2965                &instruments,
2966                config.rest_base_url(),
2967                dt,
2968                Duration::from_secs(FILL_RECOVERY_TIMEOUT_SECS),
2969                &tx,
2970                &dedup,
2971                &known,
2972            )
2973            .await;
2974            // Only the instruments this stream recovers fills for, every one when none is named.
2975            let held = {
2976                let known = known.lock();
2977                if instruments.is_empty() {
2978                    known.instruments()
2979                } else {
2980                    known.instruments_among(&instruments)
2981                }
2982            };
2983            unchecked.open(held);
2984        }
2985
2986        // --- Stream events ---
2987        // Each iteration of the inner loop polls ws.next(), a heartbeat timer,
2988        // and tx.closed() simultaneously via select!. The heartbeat deadline is
2989        // reset on every received message (rolling window).
2990        //
2991        // The timer is pinned once and reset in-place via Sleep::reset(), avoiding
2992        // a new Sleep allocation and Tokio timer-wheel registration per loop iteration.
2993        // Track the wall-clock time of the last received message. Used to anchor
2994        // fill recovery after a heartbeat timeout: Utc::now() at reconnect time
2995        // would be ~HEARTBEAT_TIMEOUT_SECS after the last real message, causing
2996        // the recovery window to miss fills in that silent period.
2997
2998        let mut last_message_time = Utc::now();
2999        let heartbeat = tokio::time::sleep(Duration::from_secs(HEARTBEAT_TIMEOUT_SECS));
3000        tokio::pin!(heartbeat);
3001        // A connection still receiving a heartbeat window after the stream loop starts is proven
3002        // stable: the failures before it are cleared, so only consecutive ones exhaust the budget.
3003        let stable_from = tokio::time::Instant::now() + Duration::from_secs(HEARTBEAT_TIMEOUT_SECS);
3004
3005        // How the orders held as live ended: checked alongside the stream, so it is read, and a
3006        // disconnect seen, from the start. Fill recovery has finished or given up above, so the
3007        // fills come first. Dropping this when the stream loop ends loses nothing, since each
3008        // lookup and each check is settled in one step as soon as it ends.
3009        let order_checks =
3010            run_order_checks(&http, &rate_limiter, &config, &known, &mut unchecked, &tx);
3011        tokio::pin!(order_checks);
3012
3013        // Resets the rolling heartbeat deadline, records the wall-clock receive time, and clears
3014        // the backoff once the connection is proven stable.
3015        // Pin<&mut Sleep> cannot be passed to a regular function, so a macro avoids
3016        // repeating the same block across every message-bearing select! arm.
3017        // NOTE: must be defined AFTER the variables it captures for macro hygiene.
3018        macro_rules! reset_heartbeat {
3019            () => {
3020                let now = tokio::time::Instant::now();
3021                heartbeat
3022                    .as_mut()
3023                    .reset(now + Duration::from_secs(HEARTBEAT_TIMEOUT_SECS));
3024                last_message_time = Utc::now();
3025                if now >= stable_from {
3026                    budget.reset();
3027                }
3028            };
3029        }
3030        // Distinguishes why the inner stream loop exited so the terminal emit below can report a
3031        // meaningful `last_error`. The consumer-drop arm uses `break 'outer` and never yields one.
3032        enum DisconnectReason {
3033            ServerClose,
3034            Error(String),
3035            StreamEnded,
3036            HeartbeatTimeout,
3037        }
3038        let reason = loop {
3039            tokio::select! {
3040                msg = ws.next() => {
3041                    match msg {
3042                        Some(Ok(WsMessage::Ping(_))) => {
3043                            // tokio-tungstenite automatically queues a Pong when poll_next
3044                            // returns a Ping; sending a second Pong would be a duplicate.
3045                            reset_heartbeat!();
3046                        }
3047                        Some(Ok(WsMessage::Text(text))) => {
3048                            process_ws_text(text.as_str(), &tx, &dedup, &known);
3049                            reset_heartbeat!();
3050                        }
3051                        Some(Ok(WsMessage::Binary(bytes))) => {
3052                            // Alpaca paper trading sends binary-framed JSON.
3053                            match std::str::from_utf8(&bytes) {
3054                                Ok(text) => {
3055                                    process_ws_text(text, &tx, &dedup, &known);
3056                                    // Only reset heartbeat for valid UTF-8 frames that may carry
3057                                    // real events. A corrupt binary frame (e.g. from a proxy)
3058                                    // must not keep the watchdog from firing.
3059                                    reset_heartbeat!();
3060                                }
3061                                Err(e) => warn!(%e, "Alpaca WS binary frame: not valid UTF-8"),
3062                            }
3063                        }
3064                        Some(Ok(WsMessage::Close(frame))) => {
3065                            warn!(frame = ?frame, "Alpaca WS closed by server");
3066                            break DisconnectReason::ServerClose;
3067                        }
3068                        Some(Ok(_)) => {} // Pong, Frame — ignore
3069                        Some(Err(e)) => {
3070                            warn!(%e, "Alpaca WS error, reconnecting");
3071                            break DisconnectReason::Error(e.to_string());
3072                        }
3073                        None => {
3074                            warn!("Alpaca WS stream ended, reconnecting");
3075                            break DisconnectReason::StreamEnded;
3076                        }
3077                    }
3078                }
3079                () = &mut order_checks => {}
3080                _ = &mut heartbeat => {
3081                    warn!(
3082                        timeout_secs = HEARTBEAT_TIMEOUT_SECS,
3083                        "Alpaca heartbeat timeout, reconnecting"
3084                    );
3085                    // Heartbeat timeout is a failure — do NOT reset backoff here.
3086                    break DisconnectReason::HeartbeatTimeout;
3087                }
3088                _ = tx.closed() => {
3089                    debug!("Alpaca account_stream consumer dropped, terminating");
3090                    let _ = tokio::time::timeout(
3091                        Duration::from_secs(WS_CLOSE_TIMEOUT_SECS),
3092                        ws.close(None),
3093                    ).await;
3094                    // Consumer dropped the receiver — no StreamTerminated emit (channel already
3095                    // closed, so it would be a no-op; see emit_stream_terminated docs).
3096                    break 'outer;
3097                }
3098            }
3099        };
3100
3101        // --- Record disconnect time for fill recovery ---
3102        // Anchor to last_message_time, not Utc::now(). For heartbeat-triggered
3103        // disconnects, Utc::now() would be ~HEARTBEAT_TIMEOUT_SECS after the last
3104        // real message, causing the recovery window to miss fills in that gap.
3105        disconnect_time =
3106            Some(last_message_time - chrono::Duration::milliseconds(SIGNAL_RECOVERY_LOOKBACK_MS));
3107
3108        // --- Close stale WS ---
3109        let _ =
3110            tokio::time::timeout(Duration::from_secs(WS_CLOSE_TIMEOUT_SECS), ws.close(None)).await;
3111
3112        if tx.is_closed() {
3113            // Consumer dropped the receiver — no StreamTerminated emit (channel already closed,
3114            // so it would be a no-op; see emit_stream_terminated docs).
3115            break;
3116        }
3117        if !budget.wait().await {
3118            error!("Alpaca max reconnect attempts exhausted, stream terminating");
3119            // Surrender after repeated reconnects: report the failure that triggered this round
3120            // (consumer-drop already broke out via `break 'outer`).
3121            let last_error = match reason {
3122                DisconnectReason::HeartbeatTimeout => {
3123                    format!("heartbeat timeout ({HEARTBEAT_TIMEOUT_SECS}s)")
3124                }
3125                DisconnectReason::ServerClose => "WebSocket closed by server".to_string(),
3126                DisconnectReason::Error(e) => e,
3127                DisconnectReason::StreamEnded => "WebSocket stream ended".to_string(),
3128            };
3129            emit_stream_terminated(
3130                &tx,
3131                ExchangeId::AlpacaBroker,
3132                StreamTerminationReason::ReconnectBudgetExhausted {
3133                    attempts: budget.attempts(),
3134                    last_error,
3135                },
3136            );
3137            break;
3138        }
3139    }
3140}
3141
3142/// Error during WebSocket handshake (auth + subscribe).
3143///
3144/// Separates transport errors (network issues, connection drops) from auth errors
3145/// (invalid credentials) so callers can apply appropriate retry logic.
3146#[derive(Debug)]
3147enum HandshakeError {
3148    /// Network-level failure (WS send failed, connection dropped).
3149    /// Transient — retry with backoff.
3150    Transport(String),
3151    /// Authentication rejected by Alpaca (invalid/expired credentials).
3152    /// Not transient — do not retry without credential changes.
3153    Auth(String),
3154}
3155
3156/// Connect to the Alpaca WebSocket, authenticate, and subscribe to trade_updates.
3157///
3158/// On auth or subscribe failure the connection is closed cleanly.
3159async fn connect_and_subscribe(config: &AlpacaConfig) -> Result<WebSocket, UnindexedClientError> {
3160    let url = config.stream_url();
3161    debug!(%url, "Alpaca: connecting to WebSocket");
3162
3163    let mut ws = rustrade_integration::protocol::websocket::connect(url)
3164        .await
3165        .map_err(|e| {
3166            UnindexedClientError::Connectivity(ConnectivityError::Socket(format!(
3167                "WS connect: {e}"
3168            )))
3169        })?;
3170
3171    // auth + subscribe with overall timeout
3172    let result = tokio::time::timeout(
3173        Duration::from_secs(WS_HANDSHAKE_TIMEOUT_SECS),
3174        ws_handshake(&mut ws, config),
3175    )
3176    .await;
3177
3178    match result {
3179        Ok(Ok(())) => Ok(ws),
3180        Ok(Err(HandshakeError::Transport(e))) => {
3181            let _ = ws.close(None).await;
3182            Err(UnindexedClientError::Connectivity(
3183                ConnectivityError::Socket(e),
3184            ))
3185        }
3186        Ok(Err(HandshakeError::Auth(e))) => {
3187            let _ = ws.close(None).await;
3188            Err(UnindexedClientError::Api(ApiError::Unauthenticated(e)))
3189        }
3190        Err(_) => {
3191            let _ = ws.close(None).await;
3192            Err(UnindexedClientError::Connectivity(
3193                ConnectivityError::Timeout,
3194            ))
3195        }
3196    }
3197}
3198
3199/// Perform the Alpaca WS auth + subscribe sequence on a connected WebSocket.
3200///
3201/// Protocol:
3202/// 1. Send `{"action":"auth","key":...,"secret":...}`
3203/// 2. Await `{"stream":"authorization","data":{"status":"authorized",...}}`
3204/// 3. Send `{"action":"listen","data":{"streams":["trade_updates"]}}`
3205/// 4. Await `{"stream":"listening","data":{"streams":["trade_updates"]}}`
3206async fn ws_handshake(ws: &mut WebSocket, config: &AlpacaConfig) -> Result<(), HandshakeError> {
3207    // Step 1: send auth
3208    let auth = serde_json::json!({
3209        "action": "auth",
3210        "key": config.api_key(),
3211        // direct field access within module — secret_key has no pub getter to
3212        // prevent external credential exposure
3213        "secret": config.secret_key,
3214    })
3215    .to_string();
3216    ws.send(WsMessage::Text(auth.into()))
3217        .await
3218        .map_err(|e| HandshakeError::Transport(format!("WS auth send: {e}")))?;
3219
3220    // Step 2: wait for authorization message
3221    loop {
3222        match ws.next().await {
3223            Some(Ok(WsMessage::Text(text))) => {
3224                if let Some(result) = check_auth_response(text.as_str()) {
3225                    result?;
3226                    break;
3227                }
3228            }
3229            Some(Ok(WsMessage::Binary(bytes))) => {
3230                if let Ok(text) = std::str::from_utf8(&bytes)
3231                    && let Some(result) = check_auth_response(text)
3232                {
3233                    result?;
3234                    break;
3235                }
3236            }
3237            Some(Err(e)) => {
3238                return Err(HandshakeError::Transport(format!(
3239                    "WS error during auth: {e}"
3240                )));
3241            }
3242            None => {
3243                return Err(HandshakeError::Transport(
3244                    "WS closed before auth response".into(),
3245                ));
3246            }
3247            _ => {} // ping/pong during auth — ignore
3248        }
3249    }
3250
3251    // Step 3: subscribe to trade_updates
3252    let sub = serde_json::json!({
3253        "action": "listen",
3254        "data": { "streams": ["trade_updates"] }
3255    })
3256    .to_string();
3257    ws.send(WsMessage::Text(sub.into()))
3258        .await
3259        .map_err(|e| HandshakeError::Transport(format!("WS subscribe send: {e}")))?;
3260
3261    // Step 4: wait for listening acknowledgment (optional but confirms subscription)
3262    loop {
3263        match ws.next().await {
3264            Some(Ok(WsMessage::Text(text))) => {
3265                if check_listen_ack(text.as_str()) {
3266                    break;
3267                }
3268                // Other messages in this window are NOT buffered and are permanently
3269                // dropped. On a reconnect, REST recovers the fills and how each order held
3270                // as live ended; any other lifecycle event is lost, as documented in
3271                // account_stream's doc comment.
3272                // Log at warn! for trade_updates (fill/lifecycle events) to ensure
3273                // production operators see dropped events; trace! for other streams.
3274                if let Ok(msg) = serde_json::from_str::<AlpacaStreamMessage<'_>>(text.as_str()) {
3275                    if msg.stream == "trade_updates" {
3276                        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");
3277                    } else {
3278                        trace!(stream = %msg.stream, "WS message dropped during listen-ack handshake");
3279                    }
3280                } else {
3281                    trace!(
3282                        bytes = text.len(),
3283                        "WS non-stream message dropped during listen-ack handshake"
3284                    );
3285                }
3286            }
3287            Some(Ok(WsMessage::Binary(bytes))) => {
3288                if let Ok(text) = std::str::from_utf8(&bytes)
3289                    && check_listen_ack(text)
3290                {
3291                    break;
3292                }
3293                trace!(
3294                    bytes = bytes.len(),
3295                    "WS binary message dropped during listen-ack handshake"
3296                );
3297            }
3298            Some(Err(e)) => {
3299                return Err(HandshakeError::Transport(format!(
3300                    "WS error during subscribe: {e}"
3301                )));
3302            }
3303            None => {
3304                return Err(HandshakeError::Transport(
3305                    "WS closed before subscribe ack".into(),
3306                ));
3307            }
3308            _ => {}
3309        }
3310    }
3311
3312    info!("Alpaca WS authenticated and subscribed to trade_updates");
3313    Ok(())
3314}
3315
3316/// Parse a WS message to check for authorization response.
3317/// Returns `None` if the message is not an authorization response.
3318/// Returns `Some(Ok(()))` on success, `Some(Err(HandshakeError::Auth(...)))` on auth failure.
3319///
3320/// Uses `AlpacaStreamMessage` for consistency with the rest of the WS parsing pipeline.
3321fn check_auth_response(text: &str) -> Option<Result<(), HandshakeError>> {
3322    let msg = serde_json::from_str::<AlpacaStreamMessage<'_>>(text).ok()?;
3323    if msg.stream != "authorization" {
3324        return None;
3325    }
3326    #[derive(Deserialize)]
3327    struct AuthData<'a> {
3328        status: &'a str,
3329    }
3330    let data = serde_json::from_str::<AuthData<'_>>(msg.data.get()).ok()?;
3331    if data.status == "authorized" {
3332        Some(Ok(()))
3333    } else {
3334        Some(Err(HandshakeError::Auth(format!(
3335            "Alpaca WS auth failed: status={}",
3336            data.status
3337        ))))
3338    }
3339}
3340
3341/// Returns `true` if the WS message is a listening acknowledgment for trade_updates.
3342///
3343/// Uses `AlpacaStreamMessage` for consistency with the rest of the WS parsing pipeline.
3344fn check_listen_ack(text: &str) -> bool {
3345    let Ok(msg) = serde_json::from_str::<AlpacaStreamMessage<'_>>(text) else {
3346        return false;
3347    };
3348    if msg.stream != "listening" {
3349        return false;
3350    }
3351    #[derive(Deserialize)]
3352    struct ListenData<'a> {
3353        #[serde(borrow)]
3354        streams: Vec<&'a str>,
3355    }
3356    let Ok(data) = serde_json::from_str::<ListenData<'_>>(msg.data.get()) else {
3357        return false;
3358    };
3359    data.streams.contains(&"trade_updates")
3360}
3361
3362// ---------------------------------------------------------------------------
3363// Process incoming WS messages
3364// ---------------------------------------------------------------------------
3365
3366/// Parse a raw WS text message and forward relevant account events to `tx`.
3367///
3368/// Each event is applied to `known` before it is sent, under the lock, so an order this reports
3369/// ending and a reconnect's check of it reach the consumer in the order they were decided, and the
3370/// check reports only an order not already reported.
3371fn process_ws_text(
3372    text: &str,
3373    tx: &mpsc::UnboundedSender<UnindexedAccountEvent>,
3374    dedup: &SharedDedupCache,
3375    known: &SharedKnownLiveOrders,
3376) {
3377    let msg: AlpacaStreamMessage<'_> = match serde_json::from_str(text) {
3378        Ok(m) => m,
3379        Err(e) => {
3380            trace!(
3381                %e,
3382                raw = ?&text[..text.len().min(200)],
3383                "Alpaca WS: skipped non-JSON message"
3384            );
3385            return;
3386        }
3387    };
3388
3389    match msg.stream.as_str() {
3390        "trade_updates" => {
3391            let update: AlpacaTradeUpdate<'_> = match serde_json::from_str(msg.data.get()) {
3392                Ok(u) => u,
3393                Err(e) => {
3394                    // Use warn (not trace): the outer frame was valid trade_updates JSON,
3395                    // so failure here signals an unexpected payload format. Could indicate
3396                    // an Alpaca API change or a missing required field dropping a real
3397                    // event (e.g. a rejected order). Visible at production log levels.
3398                    warn!(
3399                        %e,
3400                        raw = ?&msg.data.get()[..msg.data.get().len().min(200)],
3401                        "Alpaca WS trade_updates: failed to deserialize event — event dropped"
3402                    );
3403                    return;
3404                }
3405            };
3406
3407            // PERF: Check dedup BEFORE constructing the full event for fill events.
3408            // This avoids heap allocations (Trade, InstrumentNameExchange, TradeId) on
3409            // duplicate fills, which occur on every reconnect during recovery overlap.
3410            if is_fill_event(&update)
3411                && is_duplicate_fill(dedup, ws_execution_id(&update), early_dedup_key(&update))
3412            {
3413                trace!("Alpaca WS: skipping duplicate fill event (early check)");
3414                return;
3415            }
3416
3417            // Neither amendment is in the Trading API's docs, and no real frame has been seen, so
3418            // log it whole: the first one shows what Alpaca sends.
3419            if is_trade_amendment(&update) {
3420                warn!(
3421                    event = %update.event,
3422                    raw = %msg.data.get(),
3423                    "Alpaca WS: a fill reported earlier was amended — reporting it as TradeAmended"
3424                );
3425            }
3426
3427            for event in convert_trade_update(update).into_iter().flatten() {
3428                // A resolved correction delivers its execution as the replacement trade. Should
3429                // Alpaca also list it as a FILL activity, recovery must not deliver it again. An
3430                // unresolved one delivered no trade, so recovery may.
3431                if let AccountEventKind::TradeAmended(amendment) = &event.kind
3432                    && let TradeAmendmentKind::Corrected { replacement } = &amendment.kind
3433                {
3434                    record_execution(dedup, &replacement.id.0);
3435                }
3436                let known = KnownLiveOrders::observes(&event.kind).then(|| {
3437                    let mut known = known.lock();
3438                    known.observe(&event);
3439                    known
3440                });
3441                // Consumer dropped errors are benign; connection_manager will detect
3442                // tx.closed() on the next select! poll and exit cleanly.
3443                let _ = tx.send(event);
3444                drop(known);
3445            }
3446        }
3447        "authorization" | "listening" => {
3448            // Ack messages can appear during initial stream if the handshake was
3449            // not fully awaited. These are benign.
3450            trace!(stream = %msg.stream, "Alpaca WS: auth/listen ack received during stream");
3451        }
3452        other => {
3453            trace!(%other, "Alpaca WS: ignoring unknown stream type");
3454        }
3455    }
3456}
3457
3458/// The cumulative dedup key for a fill that took order `order_id` to `cumulative` filled: what
3459/// [`is_duplicate_fill`] recognises a fill by when it has no execution id.
3460///
3461/// The account stream and fill recovery both build it, from the cumulative each reports for the
3462/// order (the WS frame's `order.filled_qty`, the FILL activity's `cum_qty`), so one fill
3463/// delivered by both is recognised. `normalize` strips trailing zeros, so `"1.00"` and `"1"` give
3464/// one key. A WS fill whose `filled_qty` does not parse is keyed at zero.
3465///
3466/// The `format_smolstr!` call heap-allocates for UUID-length order ids (36 chars exceeds
3467/// SmolStr's inline limit), which is unavoidable given the key length.
3468fn fill_dedup_key(order_id: &str, cumulative: Decimal) -> SmolStr {
3469    format_smolstr!("{}:{}", order_id, cumulative.normalize())
3470}
3471
3472/// Returns `true` if this event busts or corrects a fill reported earlier.
3473fn is_trade_amendment(update: &AlpacaTradeUpdate<'_>) -> bool {
3474    matches!(update.event.as_str(), "trade_bust" | "trade_correct")
3475}
3476
3477/// Returns `true` if this event type produces a fill (Trade) event.
3478///
3479/// Used for early dedup check before allocating the full event.
3480#[inline]
3481fn is_fill_event(update: &AlpacaTradeUpdate<'_>) -> bool {
3482    matches!(update.event.as_str(), "fill" | "partial_fill")
3483}
3484
3485/// A WS event's `execution_id`, or `None` when it is missing, null or empty.
3486fn ws_execution_id<'a>(update: &AlpacaTradeUpdate<'a>) -> Option<&'a str> {
3487    update.execution_id.filter(|id| !id.is_empty())
3488}
3489
3490/// The [`fill_dedup_key`] of a WS fill, from its raw fields, before the full event is built.
3491///
3492/// An unparseable `filled_qty` reads as zero, as `convert_trade_update` reports it.
3493fn early_dedup_key(update: &AlpacaTradeUpdate<'_>) -> SmolStr {
3494    let filled_qty = update.order.filled_qty.unwrap_or("0");
3495    let qty = Decimal::from_str(filled_qty).unwrap_or(Decimal::ZERO);
3496    fill_dedup_key(&update.order.id, qty)
3497}
3498
3499/// The execution id in a FILL activity's `id`, `"{time}::{execution id}"`: the same id the WS
3500/// fill carries as `execution_id`. The time prefix is US Eastern local time, so it is never read.
3501/// An id without `::`, or with nothing after it, is taken whole, so it stays unique.
3502fn activity_execution_id(activity_id: &str) -> &str {
3503    activity_execution_id_part(activity_id).unwrap_or(activity_id)
3504}
3505
3506/// The execution id after `::` in a FILL activity's `id`, or `None` when there is none: an id
3507/// taken whole by [`activity_execution_id`] is not one the WS fill carries, so dedup does not
3508/// key on it.
3509fn activity_execution_id_part(activity_id: &str) -> Option<&str> {
3510    activity_id
3511        .split_once("::")
3512        .map(|(_, execution_id)| execution_id)
3513        .filter(|execution_id| !execution_id.is_empty())
3514}
3515
3516// ---------------------------------------------------------------------------
3517// Fill recovery
3518// ---------------------------------------------------------------------------
3519
3520/// Fills a recovery read did not deliver: those from `start` until the recovery began.
3521#[derive(Debug, PartialEq)]
3522struct UnreadFills {
3523    start: DateTime<Utc>,
3524    reason: FillRecoveryFailure,
3525}
3526
3527/// Recover the fills missed since `disconnect` ([`recover_fills`]) within `timeout`, and send an
3528/// [`AccountEventKind::FillRecoveryGaveUp`] for those the read did not deliver, after the fills
3529/// it did.
3530async fn recover_fills_or_report(
3531    http: &reqwest::Client,
3532    rate_limiter: &RateLimitTracker,
3533    instruments: &[InstrumentNameExchange],
3534    base: &str,
3535    disconnect: DateTime<Utc>,
3536    timeout: Duration,
3537    tx: &mpsc::UnboundedSender<UnindexedAccountEvent>,
3538    dedup: &SharedDedupCache,
3539    known: &SharedKnownLiveOrders,
3540) {
3541    // The live stream is already subscribed, so every fill after this arrives live.
3542    let recovery_start = Utc::now();
3543    let outcome = match tokio::time::timeout(
3544        timeout,
3545        recover_fills(
3546            http,
3547            rate_limiter,
3548            instruments,
3549            base,
3550            disconnect,
3551            tx,
3552            dedup,
3553            known,
3554        ),
3555    )
3556    .await
3557    {
3558        Ok(outcome) => outcome,
3559        // One account-wide activities query serves every instrument, so a timeout can have missed
3560        // fills in any of them: name the requested set. Nothing was forwarded, since recovery
3561        // sends its fills only after the read has finished.
3562        Err(_) => {
3563            warn!(
3564                timeout_secs = timeout.as_secs(),
3565                instruments = ?instruments
3566                    .iter()
3567                    .map(|instrument| instrument.name().as_str())
3568                    .collect::<Vec<_>>(),
3569                "Alpaca fill recovery timed out — fills since the disconnect may be missing for \
3570                 any of the instruments (every instrument when the list is empty)"
3571            );
3572            Err(UnreadFills {
3573                start: disconnect,
3574                reason: FillRecoveryFailure::TimedOut {
3575                    timeout_secs: timeout.as_secs(),
3576                },
3577            })
3578        }
3579    };
3580    if let Err(unread) = outcome
3581        && tx
3582            .send(fill_recovery_gave_up(instruments, unread, recovery_start))
3583            .is_err()
3584    {
3585        debug!("Alpaca fill recovery: consumer dropped before the give-up was sent");
3586    }
3587}
3588
3589/// The [`AccountEventKind::FillRecoveryGaveUp`] for `unread`, whose recovery began at `end`,
3590/// on a stream opened with `instruments`, every instrument when empty. Alpaca does not retry the
3591/// read, so it was read once.
3592///
3593/// A truncated read can return fills stamped after `end`, by Alpaca's clock, which may run ahead
3594/// of this host's. The span then ends at its start rather than before it: the event is still
3595/// sent, since a report with nothing left to read costs the consumer one read, while a report
3596/// withheld on a clock comparison would be a silent loss.
3597fn fill_recovery_gave_up(
3598    instruments: &[InstrumentNameExchange],
3599    unread: UnreadFills,
3600    end: DateTime<Utc>,
3601) -> UnindexedAccountEvent {
3602    let UnreadFills { start, reason } = unread;
3603    // One account-wide read serves every instrument, so one event covers them all.
3604    let scope = if instruments.is_empty() {
3605        FillRecoveryScope::AllInstruments
3606    } else {
3607        FillRecoveryScope::Instruments(instruments.to_vec())
3608    };
3609    UnindexedAccountEvent::new(
3610        ExchangeId::AlpacaBroker,
3611        AccountEventKind::FillRecoveryGaveUp(FillRecoveryGap::new(
3612            scope,
3613            start,
3614            end.max(start),
3615            1,
3616            reason,
3617        )),
3618    )
3619}
3620
3621/// Fetch fills missed during a WS disconnect, since `after`, and forward through the dedup cache.
3622///
3623/// A fill that brings an order to its full quantity ends it in `known`, so a reconnect's check
3624/// does not report it again.
3625///
3626/// Returns the fills it did not deliver: all of them when the read fails, and those from the
3627/// millisecond of the last one read when the read stops at [`MAX_ACTIVITY_PAGES`]. The fills it read are sent
3628/// first. A consumer that drops the stream part-way gets `Ok`, as there is no one to report to.
3629async fn recover_fills(
3630    http: &reqwest::Client,
3631    rate_limiter: &RateLimitTracker,
3632    instruments: &[InstrumentNameExchange],
3633    base: &str,
3634    after: DateTime<Utc>,
3635    tx: &mpsc::UnboundedSender<UnindexedAccountEvent>,
3636    dedup: &SharedDedupCache,
3637    known: &SharedKnownLiveOrders,
3638) -> Result<(), UnreadFills> {
3639    // Empty `instruments` means "recover for all subscribed symbols" (same convention as
3640    // `fetch_trades` / `fetch_open_orders`). Skip the set allocation when not filtering.
3641    info!(%after, instruments = instruments.len(), "Alpaca recovering fills after reconnect");
3642    let instrument_set: fnv::FnvHashSet<&str> = if instruments.is_empty() {
3643        fnv::FnvHashSet::default()
3644    } else {
3645        instruments.iter().map(|i| i.name().as_str()).collect()
3646    };
3647
3648    let page = match paginate_activities::<AlpacaActivity>(
3649        http,
3650        rate_limiter,
3651        base,
3652        FILL_ACTIVITIES,
3653        after,
3654        None,
3655    )
3656    .await
3657    {
3658        Ok(p) => p,
3659        Err(e) => {
3660            error!(%e, "Alpaca fill recovery: REST request failed");
3661            return Err(UnreadFills {
3662                start: after,
3663                reason: FillRecoveryFailure::Request(e.to_string()),
3664            });
3665        }
3666    };
3667
3668    let activities = page.activities;
3669
3670    // The read is account-wide, so one that stopped at the page cap has every fill before its
3671    // resume point, whatever the instrument: the millisecond of its last fill, which a read from
3672    // there reads again. The dedup cache absorbs the fills read twice.
3673    let unread = page.resume.map(|start| {
3674        error!(
3675            max_pages = MAX_ACTIVITY_PAGES,
3676            fills_read = activities.len(),
3677            %start,
3678            "Alpaca fill recovery: max page limit reached, truncating — fills from this time on \
3679             are not delivered; read them with fetch_trades"
3680        );
3681        UnreadFills {
3682            start,
3683            reason: FillRecoveryFailure::Truncated {
3684                fills_read: activities.len(),
3685            },
3686        }
3687    });
3688
3689    let mut recovered = 0u32;
3690    let mut duplicates = 0u32;
3691
3692    // Fallback only. A fill is recognised by its execution id, and by its cumulative key
3693    // "{order_id}:{cumulative_filled_qty}" where either path lacks the id (see
3694    // `is_duplicate_fill`). The cumulative is taken from the activity's own `cum_qty` -- the same
3695    // figure the WS path reads from `order.filled_qty` -- so the two agree by construction rather
3696    // than by reconstruction.
3697    //
3698    // 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
3699    // entirely inside the recovery window: it counts from zero per order within the batch, so an
3700    // order that had already partly filled before the window yields keys offset by the amount it
3701    // filled earlier, and those keys match nothing the WS path ever emitted. Preferring `cum_qty`
3702    // is what removes that failure mode.
3703    //
3704    // Activities are fetched with direction=asc, so accumulation here follows execution order.
3705    // Borrow `activities` so &str keys into order_id strings remain valid for the entire loop:
3706    // consuming iteration would drop each activity at the end of its iteration, invalidating keys
3707    // that later fills on the same order still need to look up.
3708    let mut cumulative_qty: FnvHashMap<&str, Decimal> = FnvHashMap::default();
3709
3710    for activity in &activities {
3711        if !instrument_set.is_empty() && !instrument_set.contains(activity.symbol.as_str()) {
3712            // Safe to skip without advancing cumulative_qty: each Alpaca order is bound
3713            // to exactly one symbol, so no fill for a different symbol can ever share
3714            // the same order_id as a fill we're tracking.
3715            continue;
3716        }
3717
3718        // Advance cumulative_qty for every activity, regardless of whether parsing
3719        // succeeds. The WS path uses Alpaca's cumulative filled_qty, which counts ALL
3720        // fills — including those with malformed fields. Skipping the counter for a bad
3721        // fill causes subsequent fills to produce dedup keys that diverge from the WS
3722        // path (off by the bad fill's qty), so the next good fill appears as a duplicate
3723        // on one path and a new fill on the other, resulting in either a missed or a
3724        // double-delivered fill for that execution.
3725        //
3726        // activity.qty is always populated for FILL activities (Alpaca guarantees it).
3727        // unwrap_or(ZERO) guards against unexpected API changes; a zero qty skips
3728        // incrementing the cumulative counter for that activity. Note: a non-empty
3729        // but non-parseable qty string (e.g. an API regression sending "abc") also
3730        // produces exec_qty=ZERO — the counter stalls, causing subsequent fills on
3731        // the same order to produce dedup keys that diverge from the WS path.
3732        let exec_qty = Decimal::from_str(&activity.qty).unwrap_or(Decimal::ZERO);
3733        let cum = cumulative_qty
3734            .entry(activity.order_id.as_str())
3735            .or_default();
3736        *cum += exec_qty;
3737        let cumulative = *cum;
3738
3739        let trade = match convert_activity_to_trade(activity) {
3740            Some(t) => t,
3741            None => {
3742                warn!(id = %activity.id, symbol = %activity.symbol, "Alpaca: skipping activity with unparseable fields");
3743                continue; // Counter already advanced; dedup key sequence stays aligned with WS.
3744            }
3745        };
3746
3747        // The cumulative key, which matches the WS path's for the same fill.
3748        // `order_filled_quantity` holds the venue's own cumulative, parsed by
3749        // `convert_activity_to_trade`; `cumulative` is the intra-batch fallback described above.
3750        //
3751        // The counter is advanced for every activity, including those that carry `cum_qty` and so
3752        // never consult it. That is deliberate: it keeps the fallback usable for a later activity
3753        // on the same order that omits the field. A batch mixing both therefore interleaves two
3754        // key schemes, and the fallback keys in it remain offset by whatever the order filled
3755        // before the window -- the field's presence rescues an activity, not an order.
3756        let key = fill_dedup_key(
3757            &activity.order_id,
3758            trade.order_filled_quantity.unwrap_or(cumulative),
3759        );
3760        if is_duplicate_fill(dedup, activity_execution_id_part(&activity.id), key) {
3761            duplicates += 1;
3762            continue;
3763        }
3764
3765        let event =
3766            UnindexedAccountEvent::new(ExchangeId::AlpacaBroker, AccountEventKind::Trade(trade));
3767        let mut held = known.lock();
3768        held.observe(&event);
3769        let sent = tx.send(event);
3770        drop(held);
3771        if sent.is_err() {
3772            debug!("Alpaca fill recovery: consumer dropped during recovery");
3773            return Ok(());
3774        }
3775        recovered += 1;
3776    }
3777
3778    info!(recovered, duplicates, "Alpaca fill recovery complete");
3779    unread.map_or(Ok(()), Err)
3780}
3781
3782// ---------------------------------------------------------------------------
3783// Ended-order recovery
3784// ---------------------------------------------------------------------------
3785
3786/// Look the order under `key` up with `GET /v2/orders:by_client_order_id`.
3787///
3788/// A 404 is [`OrderLookup::Unknown`], and so is an order on another symbol than the key's (ignoring
3789/// case, as Alpaca's symbols are upper case).
3790async fn fetch_order_lookup(
3791    http: reqwest::Client,
3792    rate_limiter: Arc<RateLimitTracker>,
3793    config: Arc<AlpacaConfig>,
3794    key: UnindexedOrderKey,
3795) -> Result<OrderLookup, UnindexedClientError> {
3796    let url = format!("{}/v2/orders:by_client_order_id", config.rest_base_url());
3797    let cid = key.cid.0.as_str();
3798    let found: Option<AlpacaOrderResponse> = rest_lookup_with_retry(&rate_limiter, || {
3799        http.get(&url).query(&[("client_order_id", cid)])
3800    })
3801    .await?;
3802    let Some(order) = found else {
3803        debug!(instrument = %key.instrument, cid = %key.cid, "Alpaca does not know this order");
3804        return Ok(OrderLookup::Unknown);
3805    };
3806    if !order
3807        .symbol
3808        .eq_ignore_ascii_case(key.instrument.name().as_str())
3809    {
3810        debug!(
3811            instrument = %key.instrument,
3812            cid = %key.cid,
3813            symbol = %order.symbol,
3814            "Alpaca knows this client order id on another symbol"
3815        );
3816        return Ok(OrderLookup::Unknown);
3817    }
3818    Ok(
3819        convert_ended_order(&order, &key).map_or(OrderLookup::NotEnded, |order| {
3820            OrderLookup::Ended(Box::new(order))
3821        }),
3822    )
3823}
3824
3825/// The client order ids one `GET /v2/orders?status=open` lists on `instruments`, for a
3826/// reconnect's check of the orders held as live. A listing of 500 may be truncated, so it fails.
3827async fn listed_open_cids(
3828    http: reqwest::Client,
3829    rate_limiter: Arc<RateLimitTracker>,
3830    config: Arc<AlpacaConfig>,
3831    instruments: Vec<InstrumentNameExchange>,
3832) -> Result<FnvHashSet<ClientOrderId>, UnindexedClientError> {
3833    let orders =
3834        fetch_raw_open_orders(&http, &rate_limiter, config.rest_base_url(), &instruments).await?;
3835    Ok(orders
3836        .iter()
3837        .map(|order| alpaca_cid(order.client_order_id.as_deref(), &order.id))
3838        .collect())
3839}
3840
3841/// [`recover_ended_orders`] on Alpaca: one listing of every instrument due by
3842/// [`listed_open_cids`], and lookups by [`fetch_order_lookup`]. Fill recovery has finished or been
3843/// given up before it runs, so no fills are pending.
3844async fn recover_alpaca_ended_orders(
3845    http: &reqwest::Client,
3846    rate_limiter: &Arc<RateLimitTracker>,
3847    config: &Arc<AlpacaConfig>,
3848    known: &SharedKnownLiveOrders,
3849    unchecked: &mut UncheckedOrders,
3850    tx: &mpsc::UnboundedSender<UnindexedAccountEvent>,
3851) {
3852    recover_ended_orders(
3853        ExchangeId::AlpacaBroker,
3854        known,
3855        unchecked,
3856        &NoPendingFills,
3857        tx,
3858        OpenListing::Batched,
3859        |instruments| {
3860            listed_open_cids(
3861                http.clone(),
3862                rate_limiter.clone(),
3863                config.clone(),
3864                instruments,
3865            )
3866        },
3867        |key| fetch_order_lookup(http.clone(), rate_limiter.clone(), config.clone(), key),
3868    )
3869    .await;
3870}
3871
3872/// Run every order check as it falls due, the first at once and a failed one after its backoff,
3873/// for as long as it is polled. It never completes, and once the consumer has gone it only waits.
3874///
3875/// Fill recovery has finished or been given up before this starts, so no fills are pending.
3876async fn run_order_checks(
3877    http: &reqwest::Client,
3878    rate_limiter: &Arc<RateLimitTracker>,
3879    config: &Arc<AlpacaConfig>,
3880    known: &SharedKnownLiveOrders,
3881    unchecked: &mut UncheckedOrders,
3882    tx: &mpsc::UnboundedSender<UnindexedAccountEvent>,
3883) {
3884    loop {
3885        // A check stops at a gone consumer before settling, so its instruments stay due.
3886        if tx.is_closed() {
3887            return std::future::pending().await;
3888        }
3889        match unchecked.next_due(&NoPendingFills) {
3890            Some(due) => tokio::time::sleep_until(due).await,
3891            None => return std::future::pending().await,
3892        }
3893        recover_alpaca_ended_orders(http, rate_limiter, config, known, unchecked, tx).await;
3894    }
3895}
3896
3897/// How an order that has ended did end, from its REST order under `key`, the key it was asked for
3898/// (see [`ended_order_state`]).
3899///
3900/// Returns `None` for an order still live (see [`order_status_is_live`]), including
3901/// `done_for_day`, and, with a warning, for an order whose status is missing or unknown or that
3902/// cannot be converted. A caller reads `None` as "not ended", so such an order is asked about again
3903/// later rather than retired on a guess.
3904fn convert_ended_order(
3905    o: &AlpacaOrderResponse,
3906    key: &UnindexedOrderKey,
3907) -> Option<UnindexedInactiveOrder> {
3908    let instrument = &key.instrument;
3909    let Some(status) = o.status.as_deref() else {
3910        warn!(%instrument, cid = %key.cid, order_id = %o.id, "Alpaca order missing status");
3911        return None;
3912    };
3913    if order_status_is_live(status) {
3914        return None;
3915    }
3916    let Some(order) = convert_representable_open_order(o) else {
3917        warn!(%instrument, cid = %key.cid, order_id = %o.id, status, "Alpaca order cannot be represented, treating it as not ended");
3918        return None;
3919    };
3920    // Read again rather than from the open order, which reads an unknown fill as zero; that
3921    // conversion has already warned of it.
3922    let filled_qty = Decimal::from_str(&o.filled_qty).ok();
3923    let Some(state) = ended_order_state(
3924        status,
3925        OrderId(SmolStr::new(&o.id)),
3926        order.state.time_exchange,
3927        order.quantity,
3928        filled_qty,
3929        o.filled_avg_price.as_deref(),
3930    ) else {
3931        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");
3932        return None;
3933    };
3934    let mut order = order.map_state(|_| state);
3935    order.key = key.clone();
3936    Some(order)
3937}
3938
3939/// Whether an Alpaca order in `status` is still live, so may yet fill or be cancelled.
3940///
3941/// `done_for_day` is live: Alpaca works the order again the next trading day. The rest are
3942/// Alpaca's working and pending statuses. Every other status has ended the order (see
3943/// [`ended_order_state`]) or is unknown to this version.
3944fn order_status_is_live(status: &str) -> bool {
3945    matches!(
3946        status,
3947        "new"
3948            | "partially_filled"
3949            | "done_for_day"
3950            | "pending_cancel"
3951            | "pending_replace"
3952            | "accepted"
3953            | "pending_new"
3954            | "accepted_for_bidding"
3955            | "stopped"
3956            | "suspended"
3957            | "calculated"
3958            | "held"
3959    )
3960}
3961
3962/// How an order ended, from its Alpaca `status`, or `None` for a status that does not end it.
3963///
3964/// `filled` becomes [`InactiveOrderState::FullyFilled`] with the reported fill, or the whole
3965/// `quantity` when that is unknown, and `filled_avg_price`; `canceled` becomes
3966/// [`InactiveOrderState::Cancelled`], and so does `replaced`, since the order replacing it has an
3967/// id and client order id of its own; `expired` becomes [`InactiveOrderState::Expired`], each with
3968/// what filled before, `None` when that is unknown; and `rejected` becomes
3969/// [`InactiveOrderState::OpenFailed`]. Shared by the lookup of an order that ended
3970/// ([`convert_ended_order`]) and the response to placing one ([`placed_order_state`]).
3971fn ended_order_state<AssetKey, InstrumentKey>(
3972    status: &str,
3973    order_id: OrderId,
3974    time_exchange: DateTime<Utc>,
3975    quantity: Decimal,
3976    filled_qty: Option<Decimal>,
3977    filled_avg_price: Option<&str>,
3978) -> Option<InactiveOrderState<AssetKey, InstrumentKey>> {
3979    Some(match status {
3980        // A filled order filled its whole quantity, whether or not `filled_qty` said so.
3981        "filled" => InactiveOrderState::FullyFilled(Filled::new(
3982            order_id,
3983            time_exchange,
3984            filled_qty.unwrap_or(quantity),
3985            alpaca_avg_price(filled_avg_price),
3986        )),
3987        "canceled" | "replaced" => {
3988            InactiveOrderState::Cancelled(Cancelled::new(order_id, time_exchange, filled_qty))
3989        }
3990        "expired" => InactiveOrderState::Expired(Expired::new(order_id, time_exchange, filled_qty)),
3991        "rejected" => {
3992            InactiveOrderState::OpenFailed(OrderError::Rejected(ApiError::OrderRejected(format!(
3993                "Alpaca rejected order {order_id} after accepting it"
3994            ))))
3995        }
3996        _ => return None,
3997    })
3998}
3999
4000/// The state that `resp`, Alpaca's response to placing an order of `quantity`, reports.
4001///
4002/// An order that ended in the response itself is reported as it ended (see
4003/// [`ended_order_state`]): an IOC or FOK order that found no liquidity, or partly filled and had
4004/// the rest cancelled, is `Cancelled` or `Expired` with what filled, not `Open`, and one Alpaca
4005/// rejected after accepting it is `OpenFailed`. A live status (see [`order_status_is_live`]) reads
4006/// from what filled: `FullyFilled`, with `filled_avg_price`, once it covers `quantity`, otherwise
4007/// `Open`. So does a status that is missing or that this version does not know, with a warning,
4008/// since the account stream reports the order's next state either way.
4009fn placed_order_state(
4010    resp: &AlpacaOrderResponse,
4011    instrument: &InstrumentNameExchange,
4012    quantity: Decimal,
4013) -> UnindexedOrderState {
4014    let order_id = OrderId(SmolStr::new(&resp.id));
4015    let time_exchange = order_state_time(resp);
4016    let filled_qty = alpaca_filled_qty(&resp.id, Some(&resp.filled_qty));
4017    let filled_avg_price = resp.filled_avg_price.as_deref();
4018    match resp.status.as_deref() {
4019        Some(status) => {
4020            if let Some(ended) = ended_order_state(
4021                status,
4022                order_id.clone(),
4023                time_exchange,
4024                quantity,
4025                filled_qty,
4026                filled_avg_price,
4027            ) {
4028                return OrderState::Inactive(ended);
4029            }
4030            if !order_status_is_live(status) {
4031                warn!(%instrument, %order_id, status, "Alpaca placed an order with a status this version does not know, reading it from what filled");
4032            }
4033        }
4034        None => {
4035            warn!(%instrument, %order_id, "Alpaca placed an order without a status, reading it from what filled");
4036        }
4037    }
4038    match filled_qty {
4039        Some(filled_qty) if filled_qty >= quantity => OrderState::fully_filled(Filled::new(
4040            order_id,
4041            time_exchange,
4042            filled_qty,
4043            alpaca_avg_price(filled_avg_price),
4044        )),
4045        // A live order's fill only grows, so an unknown one reads as nothing filled until the
4046        // account stream reports more.
4047        _ => OrderState::active(Open::new(
4048            VenueOrderId::Assigned(order_id),
4049            time_exchange,
4050            filled_qty.unwrap_or(Decimal::ZERO),
4051        )),
4052    }
4053}
4054
4055/// The average price of an Alpaca order's fills, from its `filled_avg_price`: `None` when no
4056/// price is reported or it does not parse.
4057fn alpaca_avg_price(filled_avg_price: Option<&str>) -> Option<Decimal> {
4058    filled_avg_price.and_then(|price| Decimal::from_str(price).ok())
4059}
4060
4061// ---------------------------------------------------------------------------
4062// Type conversion helpers
4063// ---------------------------------------------------------------------------
4064
4065/// Whether a caller's `asset` names the account currency. A name a caller passes is matched
4066/// ignoring case, as the execution map matches the names this client reports.
4067fn is_usd(asset: &AssetNameExchange) -> bool {
4068    asset.name().as_str().eq_ignore_ascii_case(USD)
4069}
4070
4071/// Convert an Alpaca account response to rustrade balance entries.
4072///
4073/// Returns a single USD balance with:
4074/// - `total` = `cash`, which is negative while the account borrows on margin
4075/// - `free` = the lesser of `cash` and `non_marginable_buying_power`: cash that can be spent
4076///   without borrowing, within what Alpaca allows
4077///
4078/// `free` is capped at cash because `non_marginable_buying_power` counts the loan value of held
4079/// marginable stock, so it exceeds cash whenever such stock is held. The cap keeps
4080/// `free <= total`, so with negative cash `free` is negative too.
4081///
4082/// Account equity is not the total: equity and option holdings are reported as positions, and
4083/// counting them again here would double them.
4084///
4085/// The balance is named [`USD`], however the caller spelled it. If `assets` is non-empty, it is
4086/// returned only when USD is among them, matched ignoring case. An empty `assets` slice returns it
4087/// unconditionally.
4088fn convert_account_to_balances(
4089    account: &AlpacaAccount,
4090    assets: &[AssetNameExchange],
4091) -> Vec<AssetBalance<AssetNameExchange>> {
4092    if !assets.is_empty() && !assets.iter().any(is_usd) {
4093        return Vec::new();
4094    }
4095
4096    vec![AssetBalance::new(
4097        AssetNameExchange::new(USD),
4098        Balance::new(
4099            account.cash,
4100            account.cash.min(account.non_marginable_buying_power),
4101        ),
4102        Utc::now(),
4103    )]
4104}
4105
4106/// Convert Alpaca positions to crypto asset balance entries.
4107///
4108/// Only positions with `asset_class == "crypto"` are included; [`convert_positions`] reports the
4109/// rest. Each is named by the asset it holds, read from its symbol by
4110/// [`crypto_position_asset`] as Alpaca spells it (e.g., `"BTCUSD"` → `"BTC"`).
4111///
4112/// - `total` = quantity of the holding in base currency units (e.g. 0.5 BTC)
4113/// - `free`  = qty_available (base currency units not locked in open orders)
4114///
4115/// If `assets` is non-empty, only positions whose base asset name matches an
4116/// entry in the slice (case-insensitive) are returned.
4117fn convert_positions_to_balances(
4118    positions: &[AlpacaPosition],
4119    assets: &[AssetNameExchange],
4120) -> Vec<AssetBalance<AssetNameExchange>> {
4121    let now = Utc::now();
4122    positions
4123        .iter()
4124        .filter(|p| is_crypto_position(p))
4125        .filter_map(|p| {
4126            let base = crypto_position_asset(&p.symbol);
4127
4128            // Apply assets filter if specified.
4129            if !assets.is_empty()
4130                && !assets
4131                    .iter()
4132                    .any(|a| a.name().as_str().eq_ignore_ascii_case(base))
4133            {
4134                return None;
4135            }
4136
4137            // total and free are in base currency units (e.g., BTC), not USD,
4138            // consistent with AssetBalance semantics for currency/crypto assets.
4139            let asset_name = AssetNameExchange::new(base);
4140            Some(AssetBalance::new(
4141                asset_name,
4142                Balance::new(p.qty, p.qty_available),
4143                now,
4144            ))
4145        })
4146        .collect()
4147}
4148
4149/// The asset a crypto position holds, read from its symbol.
4150///
4151/// Alpaca documents that its positions list names a crypto holding by its asset followed by `USD`
4152/// (`BTCUSD` holds BTC), while orders and fills name the pair (`BTC/USD`). Both forms are read,
4153/// the `USD` suffix ignoring case. A symbol in neither form, or with an empty asset, is returned
4154/// whole, with a warning, so its balance is still reported, under a name that fails to index
4155/// rather than not at all.
4156fn crypto_position_asset(symbol: &str) -> &str {
4157    let base = match symbol.split_once('/') {
4158        Some((base, _)) => Some(base),
4159        None => {
4160            let base_len = symbol.len().saturating_sub(USD.len());
4161            symbol
4162                .get(base_len..)
4163                .filter(|suffix| suffix.eq_ignore_ascii_case(USD))
4164                .and_then(|_| symbol.get(..base_len))
4165        }
4166    };
4167    match base {
4168        Some(base) if !base.is_empty() => base,
4169        _ => {
4170            warn!(
4171                %symbol,
4172                "Alpaca crypto position symbol is neither ASSETUSD nor BASE/QUOTE; using it whole"
4173            );
4174            symbol
4175        }
4176    }
4177}
4178
4179/// Whether an Alpaca position is a crypto holding, which is reported as an asset balance rather
4180/// than a [`Position`].
4181fn is_crypto_position(position: &AlpacaPosition) -> bool {
4182    position.asset_class.eq_ignore_ascii_case("crypto")
4183}
4184
4185/// Convert Alpaca's non-crypto positions (equities and options) to [`Position`]s, each with its
4186/// symbol, in Alpaca's order.
4187///
4188/// Crypto is left to [`convert_positions_to_balances`]: Alpaca crypto is spot-only and cannot be
4189/// sold short, so a holding is simply a balance of the base asset, as on every spot venue. Every
4190/// other asset class is a position, so a class Alpaca adds later is reported rather than dropped.
4191///
4192/// Each [`Position`] carries:
4193/// - `quantity`: the magnitude of `qty`, negative when `side` is `short`;
4194/// - `entry_price`: `avg_entry_price`, per share for an equity and per share of the underlying
4195///   for an option (the premium as quoted, not multiplied by the contract size);
4196/// - `unrealized_pnl`: `unrealized_pl`, in USD for the whole position;
4197/// - `time_exchange`: `now`, since Alpaca does not timestamp positions.
4198///
4199/// A position with zero quantity is left out.
4200///
4201/// # Errors
4202///
4203/// [`ClientError::Internal`](crate::error::ClientError::Internal) when a position's `side` is
4204/// missing or neither `long` nor `short`: reporting it either way could invert it, and leaving it
4205/// out would read as flat.
4206fn convert_positions(
4207    positions: &[AlpacaPosition],
4208    now: DateTime<Utc>,
4209) -> Result<Vec<(&str, Position)>, UnindexedClientError> {
4210    positions
4211        .iter()
4212        .filter(|p| !is_crypto_position(p) && !p.qty.is_zero())
4213        .map(|p| {
4214            let size = p.qty.abs();
4215            let quantity = match p.side {
4216                AlpacaPositionSide::Long => size,
4217                AlpacaPositionSide::Short => -size,
4218                AlpacaPositionSide::Unknown => {
4219                    return Err(UnindexedClientError::Internal(format!(
4220                        "Alpaca position {} has a missing or unrecognised side",
4221                        p.symbol
4222                    )));
4223                }
4224            };
4225            let position = Position::new(
4226                quantity,
4227                p.avg_entry_price,
4228                p.unrealized_pl,
4229                None,
4230                None,
4231                None,
4232                None,
4233                now,
4234            );
4235            Ok((p.symbol.as_str(), position))
4236        })
4237        .collect()
4238}
4239
4240/// Group open orders and positions into per-instrument snapshots for account_snapshot.
4241///
4242/// When `instruments` is non-empty, a snapshot is returned for every requested instrument
4243/// (possibly with no orders and no position). When empty, only instruments with an open order or
4244/// a position are returned.
4245///
4246/// `positions` come from [`convert_positions`], which holds every non-zero equity and option
4247/// position, so an equity or option without one is [`PositionReport::Flat`]. A crypto pair (a
4248/// symbol with a `/`) is [`PositionReport::Unreported`]: its holding is a balance.
4249///
4250/// Requested names match Alpaca's symbols ignoring case. When two requested names differ only in
4251/// case, the first gets the symbol's orders and position; the second is
4252/// [`PositionReport::Unreported`] with its orders not complete, since nothing is known about it.
4253///
4254/// Each snapshot declares its orders complete unless [`convert_open_order`] left one of its
4255/// orders out. That holds because `orders` is every open order, unpaged: a response at Alpaca's
4256/// cap fails the whole snapshot in [`fetch_raw_open_orders`] rather than arriving here short. And
4257/// every order is listed under the client order id the order stream reports it under
4258/// ([`alpaca_cid`]): the one it was placed with, or the one Alpaca assigns to a bracket's
4259/// take-profit and stop-loss legs.
4260fn build_instrument_snapshots(
4261    orders: Vec<AlpacaOrderResponse>,
4262    positions: Vec<(&str, Position)>,
4263    instruments: &[InstrumentNameExchange],
4264) -> Vec<InstrumentAccountSnapshot<ExchangeId, AssetNameExchange, InstrumentNameExchange>> {
4265    /// One instrument's converted orders, whether none was left out, and its position.
4266    struct Listing {
4267        orders: Vec<UnindexedOrderSnapshot>,
4268        complete: bool,
4269        position: Option<Position>,
4270    }
4271
4272    impl Listing {
4273        fn empty() -> Self {
4274            Self {
4275                orders: Vec::new(),
4276                complete: true,
4277                position: None,
4278            }
4279        }
4280    }
4281
4282    // Build ordered map from symbol → snapshot to preserve deterministic ordering.
4283    let mut by_symbol: IndexMap<SmolStr, Listing> = IndexMap::new();
4284
4285    // Keyed by the upper-case symbol, so a requested name in another case still finds its
4286    // listing. Alpaca's own symbols are upper case, so an unfiltered read lists them unchanged.
4287    for order in orders {
4288        let listing = by_symbol
4289            .entry(order.symbol.to_ascii_uppercase().into())
4290            .or_insert_with(Listing::empty);
4291        match convert_open_order(&order) {
4292            Some(converted) => listing.orders.push(converted.into()),
4293            None => listing.complete = false,
4294        }
4295    }
4296
4297    for (symbol, position) in positions {
4298        by_symbol
4299            .entry(symbol.to_ascii_uppercase().into())
4300            .or_insert_with(Listing::empty)
4301            .position = Some(position);
4302    }
4303
4304    let snapshot = |instrument: InstrumentNameExchange, listing: Listing| {
4305        // `positions` holds every non-zero equity and option position, so one missing here is
4306        // flat. Crypto is reported as balances, never as a position.
4307        let position = if is_options_or_equity_symbol(instrument.name().as_str()) {
4308            listing
4309                .position
4310                .map_or(PositionReport::Flat, PositionReport::from_position)
4311        } else {
4312            PositionReport::Unreported
4313        };
4314        InstrumentAccountSnapshot::new(instrument, listing.orders, listing.complete, position, None)
4315    };
4316
4317    // If instruments is empty, return all; otherwise filter to requested set.
4318    if instruments.is_empty() {
4319        by_symbol
4320            .into_iter()
4321            .map(|(sym, listing)| snapshot(InstrumentNameExchange::new(sym), listing))
4322            .collect()
4323    } else {
4324        // Upper-case symbols already given to an earlier requested name.
4325        let mut claimed = std::collections::HashSet::new();
4326        instruments
4327            .iter()
4328            .map(|inst| {
4329                let symbol = inst.name().as_str().to_ascii_uppercase();
4330                // A second name for the same symbol, differing only in case: its listing went to
4331                // the first, so nothing is known about it rather than it being flat and orderless.
4332                if !claimed.insert(symbol.clone()) {
4333                    return InstrumentAccountSnapshot::new(
4334                        inst.clone(),
4335                        Vec::new(),
4336                        false,
4337                        PositionReport::Unreported,
4338                        None,
4339                    );
4340                }
4341                // swap_remove is O(1); output order is determined by the `instruments`
4342                // slice, not by the internal IndexMap order of `by_symbol`.
4343                let listing = by_symbol
4344                    .swap_remove(symbol.as_str())
4345                    .unwrap_or_else(Listing::empty);
4346                snapshot(inst.clone(), listing)
4347            })
4348            .collect()
4349    }
4350}
4351
4352/// Convert an Alpaca REST open order into rustrade's Open state order.
4353///
4354/// `None`, with a `warn!`, for an order it cannot represent: a notional order (placed by dollar
4355/// value, so its `qty` is null), which this client never places but the Alpaca dashboard can, or
4356/// one whose side, quantity or kind does not parse. A list missing that order is not every open
4357/// order, so [`build_instrument_snapshots`] must not declare it complete.
4358///
4359/// It warns on every call, so an order like that which stays open is logged again each time the
4360/// open orders are fetched.
4361fn convert_open_order(
4362    o: &AlpacaOrderResponse,
4363) -> Option<Order<ExchangeId, InstrumentNameExchange, Open>> {
4364    let converted = convert_representable_open_order(o);
4365    if converted.is_none() {
4366        warn!(
4367            order_id = %o.id,
4368            symbol = %o.symbol,
4369            qty = ?o.qty,
4370            side = %o.side,
4371            order_type = %o.order_type,
4372            "Alpaca open order cannot be represented - leaving it out"
4373        );
4374    }
4375    converted
4376}
4377
4378/// The client order id an Alpaca order is reported under, on the REST and stream paths alike: its
4379/// `client_order_id`, or its venue id when it has none.
4380///
4381/// Both paths must agree, or a complete snapshot would miss an order the stream is tracking under
4382/// another id.
4383fn alpaca_cid(client_order_id: Option<&str>, order_id: &str) -> ClientOrderId {
4384    ClientOrderId::new(client_order_id.unwrap_or(order_id))
4385}
4386
4387/// [`convert_open_order`] without the `warn!`.
4388fn convert_representable_open_order(
4389    o: &AlpacaOrderResponse,
4390) -> Option<Order<ExchangeId, InstrumentNameExchange, Open>> {
4391    let order_id = OrderId(SmolStr::new(&o.id));
4392    let cid = alpaca_cid(o.client_order_id.as_deref(), &o.id);
4393
4394    let instrument = InstrumentNameExchange::new(&o.symbol);
4395    let side = parse_side(&o.side)?;
4396    let quantity = Decimal::from_str(o.qty.as_deref().unwrap_or("0")).ok()?;
4397    // Notional orders (placed by dollar value, qty=null) have no representable quantity.
4398    // Leave them out rather than recording a zero-quantity order that would corrupt
4399    // reconciliation.
4400    if quantity.is_zero() {
4401        return None;
4402    }
4403    let price = o
4404        .limit_price
4405        .as_deref()
4406        .and_then(|s| Decimal::from_str(s).ok());
4407    let filled_qty = alpaca_filled_qty(&o.id, Some(&o.filled_qty)).unwrap_or(Decimal::ZERO);
4408    let kind = parse_order_kind(
4409        &o.order_type,
4410        o.stop_price.as_deref(),
4411        o.trail_percent.as_deref(),
4412        o.trail_price.as_deref(),
4413    )?;
4414    let time_in_force = parse_time_in_force(&o.time_in_force);
4415    let time_exchange = order_state_time(o);
4416
4417    Some(Order {
4418        key: OrderKey::new(
4419            ExchangeId::AlpacaBroker,
4420            instrument,
4421            // Alpaca doesn't carry strategy IDs in any response field.
4422            StrategyId::unknown(),
4423            cid,
4424        ),
4425        side,
4426        price,
4427        quantity,
4428        kind,
4429        time_in_force,
4430        state: Open::new(VenueOrderId::Assigned(order_id), time_exchange, filled_qty),
4431    })
4432}
4433
4434/// Convert an Alpaca FILL activity into a rustrade Trade.
4435fn convert_activity_to_trade(
4436    a: &AlpacaActivity,
4437) -> Option<Trade<AssetNameExchange, InstrumentNameExchange>> {
4438    let trade_id = TradeId::new(activity_execution_id(&a.id));
4439    let order_id = OrderId(SmolStr::new(&a.order_id));
4440    let instrument = InstrumentNameExchange::new(&a.symbol);
4441    let side = parse_side(&a.side)?;
4442    let price = Decimal::from_str(&a.price).ok()?;
4443    let quantity = Decimal::from_str(&a.qty).ok()?;
4444    // `None` when Alpaca omitted the field or sent something unparseable, which is the honest
4445    // report: the venue did not tell us where the order stands, rather than "nothing is filled".
4446    let order_filled_quantity = a.cum_qty.as_deref().and_then(|s| Decimal::from_str(s).ok());
4447    let time_exchange = parse_timestamp(&a.transaction_time).unwrap_or_else(|| {
4448        warn!(id = %a.id, "Alpaca activity: unparseable transaction_time, using now");
4449        Utc::now()
4450    });
4451
4452    // Alpaca equities and options are commission-free. Crypto trades incur
4453    // maker/taker fees (currently 0.15–0.25%) charged in the credited asset,
4454    // but fee info is not available in trade responses — use Activities API
4455    // for end-of-day fee reconciliation.
4456    Some(Trade::new(
4457        trade_id,
4458        order_id,
4459        instrument,
4460        StrategyId::unknown(),
4461        time_exchange,
4462        side,
4463        price,
4464        quantity,
4465        order_filled_quantity,
4466        AssetFees::new(
4467            AssetNameExchange::from(USD),
4468            Decimal::ZERO,
4469            Some(Decimal::ZERO),
4470        ),
4471    ))
4472}
4473
4474/// Build an `OrderSnapshot` event from the order payload embedded in a `trade_updates` frame.
4475///
4476/// Shared by the acknowledgement arm and the fill arms of [`convert_trade_update`], so that a
4477/// fill reports the order's new cumulative `filled_quantity` the same way an acknowledgement
4478/// reports its initial one.
4479///
4480/// Returns `None` for a notional order (placed by dollar value, `qty` is null). Emitting a
4481/// snapshot with `quantity == 0` would read as an order with nothing left to fill and retire it,
4482/// so such orders are left untracked -- consistent with `convert_open_order` on the REST path.
4483fn ws_order_snapshot(
4484    order: &AlpacaOrderWs<'_>,
4485    instrument: InstrumentNameExchange,
4486    cid: ClientOrderId,
4487    order_id: OrderId,
4488    time_exchange: DateTime<Utc>,
4489) -> Option<UnindexedAccountEvent> {
4490    let side = parse_side(&order.side)?;
4491    let quantity = Decimal::from_str(order.qty.unwrap_or("0")).unwrap_or(Decimal::ZERO);
4492    if quantity.is_zero() {
4493        trace!(order_id = %order.id, "Alpaca WS: skipping notional order snapshot (qty=None)");
4494        return None;
4495    }
4496    let price = order.limit_price.and_then(|s| Decimal::from_str(s).ok());
4497    // A live order's fill only grows, so a frame that omits it (some lifecycle events may) reads
4498    // as nothing filled yet, without a warning; one that garbles it is still reported.
4499    let filled_qty = order
4500        .filled_qty
4501        .and_then(|_| alpaca_filled_qty(&order.id, order.filled_qty))
4502        .unwrap_or(Decimal::ZERO);
4503    let kind = parse_order_kind(
4504        &order.order_type,
4505        order.stop_price,
4506        order.trail_percent,
4507        order.trail_price,
4508    )?;
4509    let time_in_force = parse_time_in_force(&order.time_in_force);
4510
4511    let order_snapshot = crate::order::Order {
4512        key: OrderKey::new(
4513            ExchangeId::AlpacaBroker,
4514            instrument,
4515            StrategyId::unknown(),
4516            cid,
4517        ),
4518        side,
4519        price,
4520        quantity,
4521        kind,
4522        time_in_force,
4523        state: OrderState::active(Open::new(
4524            VenueOrderId::Assigned(order_id),
4525            time_exchange,
4526            filled_qty,
4527        )),
4528    };
4529    Some(UnindexedAccountEvent::new(
4530        ExchangeId::AlpacaBroker,
4531        AccountEventKind::OrderSnapshot(rustrade_integration::collection::snapshot::Snapshot(
4532            order_snapshot,
4533        )),
4534    ))
4535}
4536
4537/// Whether a fill frame's order payload describes an order that is still live at the exchange,
4538/// and may therefore be written into engine state as an `Open` snapshot.
4539///
4540/// A fill frame carries the order's status alongside the execution. Only `partially_filled` and
4541/// `filled` say the order reached this execution while working; every other status means some
4542/// other frame owns the order's current state. Writing an `Open` snapshot from one of those would
4543/// resurrect an order the engine has already retired, leaving a resting order that does not exist
4544/// at the exchange -- a fill that arrives after its order's terminal frame is exactly the ordering
4545/// this guards against.
4546fn ws_fill_order_is_live(status: &str) -> bool {
4547    matches!(status, "partially_filled" | "filled")
4548}
4549
4550/// The [`AccountEventKind::TradeAmended`] for a `trade_bust` or `trade_correct` frame.
4551///
4552/// Neither event is in the Trading API's docs. The fields read are those of the Broker API's
4553/// `TradeUpdateEventV2`: `previous_execution_id` names the fill amended, `execution_id` is taken
4554/// as the corrected execution's id, `qty` is the busted or corrected quantity (negative on some
4555/// busts), and `price` the corrected price, which that schema does not promise on a correction. A
4556/// correction lacking anything a replacement trade needs is reported as
4557/// [`TradeAmendmentKind::CorrectedUnresolved`] with what it did carry, never dropped.
4558///
4559/// No order snapshot is emitted: a bust's lower cumulative would be refused, as
4560/// [`TradeAmendment`](crate::trade::TradeAmendment) documents.
4561fn trade_amendment_event(
4562    update: &AlpacaTradeUpdate<'_>,
4563    instrument: InstrumentNameExchange,
4564    order_id: OrderId,
4565) -> UnindexedAccountEvent {
4566    let order = &update.order;
4567    let time_exchange = update
4568        .timestamp
4569        .and_then(parse_timestamp)
4570        .unwrap_or_else(Utc::now);
4571    let original = update
4572        .previous_execution_id
4573        .filter(|id| !id.is_empty())
4574        .map(TradeId::new);
4575    let quantity = update.qty.and_then(|s| Decimal::from_str(s).ok());
4576
4577    let kind = if update.event == "trade_bust" {
4578        TradeAmendmentKind::Busted {
4579            quantity: quantity.map(|quantity| quantity.abs()),
4580        }
4581    } else {
4582        let id = ws_execution_id(update).map(TradeId::new);
4583        let price = update.price.and_then(|s| Decimal::from_str(s).ok());
4584        match (id, price, quantity, parse_side(&order.side)) {
4585            (Some(id), Some(price), Some(quantity), Some(side)) if quantity > Decimal::ZERO => {
4586                TradeAmendmentKind::Corrected {
4587                    replacement: with_reported_cid(
4588                        Trade::new(
4589                            id,
4590                            order_id.clone(),
4591                            instrument.clone(),
4592                            StrategyId::unknown(),
4593                            time_exchange,
4594                            side,
4595                            price,
4596                            quantity,
4597                            // What `order.filled_qty` means on a correction is undocumented, so the
4598                            // replacement does not claim a cumulative.
4599                            None,
4600                            // As on a fill: no fee info in WebSocket updates.
4601                            AssetFees::new(
4602                                AssetNameExchange::from(USD),
4603                                Decimal::ZERO,
4604                                Some(Decimal::ZERO),
4605                            ),
4606                        ),
4607                        order.client_order_id.as_ref(),
4608                    ),
4609                }
4610            }
4611            (id, price, quantity, _) => TradeAmendmentKind::CorrectedUnresolved {
4612                id,
4613                price,
4614                quantity,
4615            },
4616        }
4617    };
4618
4619    UnindexedAccountEvent::new(
4620        ExchangeId::AlpacaBroker,
4621        AccountEventKind::TradeAmended(crate::trade::TradeAmendment::new(
4622            instrument,
4623            order_id,
4624            time_exchange,
4625            original,
4626            kind,
4627        )),
4628    )
4629}
4630
4631/// Convert a WebSocket trade_update event into rustrade AccountEvents.
4632///
4633/// Returns up to two events, in the order they must be applied. A fill frame genuinely carries
4634/// two facts -- the execution print and the order's new cumulative filled quantity -- so it maps
4635/// to both a `Trade` and an `OrderSnapshot`.
4636///
4637/// The `Trade` is always first. A fully-filled snapshot retires its order, and routing a fill
4638/// against an order that has already been retired is a strictly harder problem than routing it
4639/// against a live one; emitting the execution first keeps the easy ordering.
4640fn convert_trade_update(update: AlpacaTradeUpdate<'_>) -> [Option<UnindexedAccountEvent>; 2] {
4641    // Early exit for unrecognised event types before incurring allocations for
4642    // instrument/order_id/cid — those are wasted for unknown events.
4643    let event_str = update.event.as_str();
4644    if !matches!(
4645        event_str,
4646        "fill"
4647            | "partial_fill"
4648            | "new"
4649            | "accepted"
4650            | "pending_new"
4651            | "canceled"
4652            | "expired"
4653            | "replaced"
4654            | "done_for_day"
4655            | "rejected"
4656            | "trade_bust"
4657            | "trade_correct"
4658    ) {
4659        trace!(event = %event_str, "Alpaca WS: ignoring trade_updates event type");
4660        return [None, None];
4661    }
4662
4663    let order = &update.order;
4664    let instrument = InstrumentNameExchange::new(&*order.symbol);
4665    let order_id = OrderId(order.id.clone());
4666    let cid = alpaca_cid(order.client_order_id.as_deref(), &order.id);
4667
4668    match event_str {
4669        "fill" | "partial_fill" => {
4670            // Use event-level price/qty for the per-execution trade.
4671            let (Some(price), Some(quantity), Some(side)) = (
4672                update.price.and_then(|s| Decimal::from_str(s).ok()),
4673                update.qty.and_then(|s| Decimal::from_str(s).ok()),
4674                parse_side(&order.side),
4675            ) else {
4676                return [None, None];
4677            };
4678            let time_exchange = update
4679                .timestamp
4680                .and_then(parse_timestamp)
4681                .unwrap_or_else(Utc::now);
4682
4683            // If filled_qty is unparseable (API regression), cum_qty falls back to zero: the
4684            // trade reports a cumulative of 0, and a fill without an execution id is deduplicated
4685            // on "order_id:0", so a second such fill on the same order would be silently dropped.
4686            // Warn loudly so API regressions are surfaced immediately.
4687            let cum_qty = Decimal::from_str(order.filled_qty.unwrap_or("0"))
4688                .inspect_err(|e| {
4689                    warn!(
4690                        order_id = %order.id,
4691                        filled_qty = ?order.filled_qty,
4692                        %e,
4693                        "Alpaca WS: failed to parse filled_qty — the fill reports a cumulative \
4694                         of 0, and without an execution id a second such fill on the same order \
4695                         would be deduplicated away"
4696                    );
4697                })
4698                .unwrap_or(Decimal::ZERO);
4699            // The execution id, which the FILL activity for this fill carries too, so the fill has
4700            // one TradeId however it is delivered. Without it, fall back to the dedup key, which
4701            // is unique per fill but matches nothing a REST read returns.
4702            let trade_id = match ws_execution_id(&update) {
4703                Some(execution_id) => TradeId::new(execution_id),
4704                None => {
4705                    warn!(
4706                        order_id = %order.id,
4707                        "Alpaca WS: fill without a usable execution_id — its TradeId is \
4708                         \"{{order_id}}:{{filled_qty}}\", which fetch_trades will not match"
4709                    );
4710                    TradeId(fill_dedup_key(&order.id, cum_qty))
4711                }
4712            };
4713
4714            // Alpaca equities and options are commission-free. Crypto trades incur
4715            // maker/taker fees (currently 0.15–0.25%) in the credited asset, but
4716            // fee info is not available in WebSocket updates.
4717            let trade = Trade::new(
4718                trade_id,
4719                order_id.clone(),
4720                instrument.clone(),
4721                StrategyId::unknown(),
4722                time_exchange,
4723                side,
4724                price,
4725                quantity,
4726                // The venue's own cumulative for this order, as of this execution.
4727                Some(cum_qty),
4728                AssetFees::new(
4729                    AssetNameExchange::from(USD),
4730                    Decimal::ZERO,
4731                    Some(Decimal::ZERO),
4732                ),
4733            );
4734            let trade = with_reported_cid(trade, order.client_order_id.as_ref());
4735            let trade_event = UnindexedAccountEvent::new(
4736                ExchangeId::AlpacaBroker,
4737                AccountEventKind::Trade(trade),
4738            );
4739
4740            // The execution alone does not move the order: `filled_quantity` is only ever carried
4741            // into engine state by an order snapshot, so without this second event a partially
4742            // filled order reads as having nothing filled until REST reconciliation refreshes it.
4743            let snapshot_event = if ws_fill_order_is_live(&order.status) {
4744                ws_order_snapshot(order, instrument, cid, order_id, time_exchange)
4745            } else {
4746                trace!(
4747                    order_id = %order.id,
4748                    status = %order.status,
4749                    "Alpaca WS: fill for an order the exchange no longer reports as working — \
4750                     emitting the execution without an order snapshot"
4751                );
4752                None
4753            };
4754
4755            [Some(trade_event), snapshot_event]
4756        }
4757
4758        "new" | "accepted" | "pending_new" | "done_for_day" => {
4759            // Order acknowledged by Alpaca, or done for the day: Alpaca stops working a
4760            // `done_for_day` order until the next trading day but has not ended it, so it is
4761            // reported open with what it has filled, never retired.
4762            let time_exchange = update
4763                .timestamp
4764                .and_then(parse_timestamp)
4765                .unwrap_or_else(Utc::now);
4766
4767            [
4768                ws_order_snapshot(order, instrument, cid, order_id, time_exchange),
4769                None,
4770            ]
4771        }
4772
4773        "canceled" | "expired" | "replaced" => {
4774            // Order no longer active.
4775            //
4776            // NOTE on "replaced": Alpaca's replace operation cancels the original order
4777            // and creates a NEW order with a different order ID. This arm correctly marks
4778            // the original as cancelled, but callers must call `fetch_open_orders` to
4779            // discover the replacement order (which has a new ID and won't appear in OMS
4780            // state automatically).
4781            let time_exchange = update
4782                .timestamp
4783                .and_then(parse_timestamp)
4784                .unwrap_or_else(Utc::now);
4785            let filled_qty = alpaca_filled_qty(&order.id, order.filled_qty);
4786            let cancelled = Cancelled::new(order_id, time_exchange, filled_qty);
4787            let response = crate::order::request::OrderResponseCancel {
4788                key: OrderKey::new(
4789                    ExchangeId::AlpacaBroker,
4790                    instrument,
4791                    StrategyId::unknown(),
4792                    cid,
4793                ),
4794                state: Ok(cancelled),
4795            };
4796            [
4797                Some(UnindexedAccountEvent::new(
4798                    ExchangeId::AlpacaBroker,
4799                    AccountEventKind::OrderCancelled(response),
4800                )),
4801                None,
4802            ]
4803        }
4804
4805        "trade_bust" | "trade_correct" => [
4806            Some(trade_amendment_event(&update, instrument, order_id)),
4807            None,
4808        ],
4809
4810        "rejected" => {
4811            let response = crate::order::request::OrderResponseCancel {
4812                key: OrderKey::new(
4813                    ExchangeId::AlpacaBroker,
4814                    instrument,
4815                    StrategyId::unknown(),
4816                    cid,
4817                ),
4818                state: Err(UnindexedOrderError::Rejected(ApiError::OrderRejected(
4819                    format!("order rejected: status={}", order.status),
4820                ))),
4821            };
4822            [
4823                Some(UnindexedAccountEvent::new(
4824                    ExchangeId::AlpacaBroker,
4825                    AccountEventKind::OrderCancelled(response),
4826                )),
4827                None,
4828            ]
4829        }
4830
4831        // All recognised event types are handled above; the early-return guard at the
4832        // top of this function ensures we never reach here for unknown event types.
4833        _ => unreachable!("convert_trade_update: unrecognised event passed early-return guard"),
4834    }
4835}
4836
4837// ---------------------------------------------------------------------------
4838// Field parsers
4839// ---------------------------------------------------------------------------
4840
4841fn parse_side(s: &str) -> Option<Side> {
4842    match s {
4843        "buy" | "Buy" | "BUY" => Some(Side::Buy),
4844        "sell" | "Sell" | "SELL" => Some(Side::Sell),
4845        other => {
4846            trace!(%other, "Alpaca: unknown order side");
4847            None
4848        }
4849    }
4850}
4851
4852fn parse_order_kind(
4853    order_type: &str,
4854    stop_price: Option<&str>,
4855    trail_percent: Option<&str>,
4856    trail_price: Option<&str>,
4857) -> Option<OrderKind> {
4858    match order_type {
4859        "market" | "Market" => Some(OrderKind::Market),
4860        "limit" | "Limit" => Some(OrderKind::Limit),
4861        "stop" | "Stop" => {
4862            let trigger_price = stop_price.and_then(|s| Decimal::from_str(s).ok())?;
4863            Some(OrderKind::Stop { trigger_price })
4864        }
4865        "stop_limit" | "Stop_limit" => {
4866            let trigger_price = stop_price.and_then(|s| Decimal::from_str(s).ok())?;
4867            Some(OrderKind::StopLimit { trigger_price })
4868        }
4869        "trailing_stop" | "Trailing_stop" => {
4870            // Alpaca returns either trail_percent or trail_price, not both.
4871            if let Some(pct) = trail_percent.and_then(|s| Decimal::from_str(s).ok()) {
4872                Some(OrderKind::TrailingStop {
4873                    offset: pct,
4874                    offset_type: TrailingOffsetType::Percentage,
4875                    activation_price: None,
4876                })
4877            } else if let Some(price) = trail_price.and_then(|s| Decimal::from_str(s).ok()) {
4878                Some(OrderKind::TrailingStop {
4879                    offset: price,
4880                    offset_type: TrailingOffsetType::Absolute,
4881                    activation_price: None,
4882                })
4883            } else {
4884                trace!("Alpaca: trailing_stop missing trail_percent and trail_price");
4885                None
4886            }
4887        }
4888        other => {
4889            trace!(%other, "Alpaca: unsupported order type, skipping");
4890            None
4891        }
4892    }
4893}
4894
4895fn parse_time_in_force(s: &str) -> TimeInForce {
4896    match s {
4897        "gtc" | "GTC" => TimeInForce::GoodUntilCancelled { post_only: false },
4898        "day" | "DAY" => TimeInForce::GoodUntilEndOfDay,
4899        "fok" | "FOK" => TimeInForce::FillOrKill,
4900        "ioc" | "IOC" => TimeInForce::ImmediateOrCancel,
4901        other => {
4902            warn!(%other, "Alpaca: unknown time_in_force, defaulting to GoodUntilEndOfDay");
4903            TimeInForce::GoodUntilEndOfDay
4904        }
4905    }
4906}
4907
4908/// When the exchange last reported this order's state.
4909///
4910/// [`Open::time_exchange`] orders an order's states, and the engine discards a snapshot older
4911/// than the state it already tracks. `created_at` does not order them: it is the same value for
4912/// every snapshot of one order, so a snapshot stamped with it is indistinguishable from the
4913/// acknowledgement and is discarded the moment anything has advanced the tracked order past it.
4914/// `updated_at` moves with each state change, which is what that comparison needs.
4915///
4916/// Falls back to `created_at` when the venue omits `updated_at`, and to now when neither parses.
4917fn order_state_time(order: &AlpacaOrderResponse) -> DateTime<Utc> {
4918    order
4919        .updated_at
4920        .as_deref()
4921        .and_then(parse_timestamp)
4922        .or_else(|| parse_timestamp(&order.created_at))
4923        .unwrap_or_else(Utc::now)
4924}
4925
4926/// The quantity Alpaca order `order_id` has filled, from its `filled_qty`: `None`, with a
4927/// warning, when it is missing or does not parse, so that an unknown fill is not read as zero.
4928fn alpaca_filled_qty(order_id: &str, filled_qty: Option<&str>) -> Option<Decimal> {
4929    let Some(raw) = filled_qty else {
4930        warn!(%order_id, "Alpaca did not report how much the order filled");
4931        return None;
4932    };
4933    match Decimal::from_str(raw) {
4934        Ok(filled_qty) => Some(filled_qty),
4935        Err(_) => {
4936            warn!(%order_id, filled_qty = raw, "Alpaca reported an unparseable filled_qty, treating it as unknown");
4937            None
4938        }
4939    }
4940}
4941
4942/// A non-trade activity as a [`CashFlow`], or `None` if Alpaca canceled it or it lacks an amount
4943/// or a time. See [`AlpacaClient::fetch_cash_flows`].
4944fn convert_cash_flow(
4945    activity: &AlpacaNonTradeActivity,
4946) -> Option<CashFlow<AssetNameExchange, InstrumentNameExchange>> {
4947    let id = activity.id.as_str();
4948    if activity.status.as_deref() == Some("canceled") {
4949        info!(id, activity_type = %activity.activity_type, "Alpaca activity canceled — left out");
4950        return None;
4951    }
4952    let Some(amount) = activity
4953        .net_amount
4954        .as_deref()
4955        .and_then(|amount| Decimal::from_str(amount).ok())
4956    else {
4957        warn!(id, net_amount = ?activity.net_amount, "Alpaca activity missing/unparseable net_amount — left out");
4958        return None;
4959    };
4960    let Some(time_exchange) = activity.time() else {
4961        warn!(
4962            id,
4963            created_at = ?activity.created_at,
4964            date = ?activity.date,
4965            "Alpaca activity missing/unparseable time — left out"
4966        );
4967        return None;
4968    };
4969    let sub_type = activity
4970        .activity_sub_type
4971        .as_deref()
4972        .filter(|sub_type| !sub_type.is_empty());
4973    let kind = match (activity.activity_type.as_str(), sub_type) {
4974        ("INT", Some("MGN")) => CashFlowKind::MarginInterest {
4975            rate: None,
4976            principal: None,
4977            timing: None,
4978        },
4979        // Credit interest is received; margin interest is paid.
4980        ("INT", None) if amount.is_sign_negative() => CashFlowKind::MarginInterest {
4981            rate: None,
4982            principal: None,
4983            timing: None,
4984        },
4985        ("PTR", _) => CashFlowKind::Rebate,
4986        (activity_type, Some(sub_type)) => {
4987            CashFlowKind::Other(format!("{activity_type}.{sub_type}"))
4988        }
4989        (activity_type, None) => CashFlowKind::Other(activity_type.to_owned()),
4990    };
4991    Some(CashFlow::new(
4992        kind,
4993        AssetNameExchange::new(
4994            activity
4995                .currency
4996                .as_deref()
4997                .filter(|currency| !currency.is_empty())
4998                .unwrap_or("USD"),
4999        ),
5000        amount,
5001        activity
5002            .symbol
5003            .as_deref()
5004            .filter(|symbol| !symbol.is_empty())
5005            .map(InstrumentNameExchange::new),
5006        time_exchange,
5007        Some(CashFlowId::new(id)),
5008    ))
5009}
5010
5011/// One option activity, read for [`convert_option_events`].
5012struct OptionLeg<'a> {
5013    id: &'a str,
5014    symbol: &'a str,
5015    /// Signed: negative is a sell.
5016    quantity: Decimal,
5017    /// The strike, on the share leg only.
5018    price: Option<Decimal>,
5019    date: Option<NaiveDate>,
5020    time: DateTime<Utc>,
5021    /// Whether `time` is Alpaca's `created_at`, not the start of `date`.
5022    created: bool,
5023    /// `None` for the share leg (`OPTRD`), whose origin is its pair's.
5024    origin: Option<TradeOrigin>,
5025    /// On an exercise or assignment, its contract as [`occ_contract`] reads it.
5026    contract: Option<(&'a str, bool, Decimal)>,
5027}
5028
5029impl OptionLeg<'_> {
5030    /// Whether the leg is in `start..=end`: by its creation time, or, when Alpaca gave none, by
5031    /// its `date`, so that a read up to now still returns a leg dated today.
5032    fn is_within(&self, start: DateTime<Utc>, end: DateTime<Utc>) -> bool {
5033        if self.created {
5034            (start..=end).contains(&self.time)
5035        } else {
5036            (start.date_naive()..=end.date_naive()).contains(&self.time.date_naive())
5037        }
5038    }
5039}
5040
5041/// An option activity as an [`OptionLeg`], or `None`, logged, if it is canceled, of another type,
5042/// or lacks what its trade needs. See [`AlpacaClient::fetch_option_events`].
5043fn option_leg(activity: &AlpacaNonTradeActivity) -> Option<OptionLeg<'_>> {
5044    let id = activity.id.as_str();
5045    let activity_type = activity.activity_type.as_str();
5046    if activity.status.as_deref() == Some("canceled") {
5047        info!(
5048            id,
5049            activity_type, "Alpaca option activity canceled — left out"
5050        );
5051        return None;
5052    }
5053    let origin = match activity_type {
5054        "OPEXP" => Some(TradeOrigin::Expiry),
5055        "OPEXC" => Some(TradeOrigin::Exercise),
5056        "OPASN" => Some(TradeOrigin::Assignment),
5057        "OPTRD" => None,
5058        _ => {
5059            warn!(
5060                id,
5061                activity_type, "Alpaca activity is not an option event — left out"
5062            );
5063            return None;
5064        }
5065    };
5066    let date = activity
5067        .date
5068        .as_deref()
5069        .and_then(|date| NaiveDate::parse_from_str(date, "%Y-%m-%d").ok());
5070    let created_at = activity.created_at.as_deref().and_then(parse_timestamp);
5071    let time = created_at.or_else(|| date.map(|date| date.and_time(NaiveTime::MIN).and_utc()));
5072    let symbol = activity
5073        .symbol
5074        .as_deref()
5075        .filter(|symbol| !symbol.is_empty());
5076    let quantity = activity
5077        .qty
5078        .as_deref()
5079        .and_then(|qty| Decimal::from_str(qty).ok())
5080        .filter(|qty| !qty.is_zero());
5081    let price = activity
5082        .price
5083        .as_deref()
5084        .and_then(|price| Decimal::from_str(price).ok());
5085    let (Some(symbol), Some(quantity), Some(time)) = (symbol, quantity, time) else {
5086        warn!(
5087            id,
5088            activity_type,
5089            symbol = ?activity.symbol,
5090            qty = ?activity.qty,
5091            created_at = ?activity.created_at,
5092            date = ?activity.date,
5093            "Alpaca option activity missing/unparseable symbol, quantity or time — left out"
5094        );
5095        return None;
5096    };
5097    if origin.is_none() && price.is_none() {
5098        warn!(id, price = ?activity.price, "Alpaca OPTRD missing/unparseable price — left out");
5099        return None;
5100    }
5101    let contract = matches!(
5102        origin,
5103        Some(TradeOrigin::Exercise | TradeOrigin::Assignment)
5104    )
5105    .then(|| occ_contract(symbol))
5106    .flatten();
5107    Some(OptionLeg {
5108        id,
5109        symbol,
5110        quantity,
5111        price,
5112        date,
5113        time,
5114        created: created_at.is_some(),
5115        origin,
5116        contract,
5117    })
5118}
5119
5120/// The root symbol, whether it is a call, and the strike of a contract, from its OCC symbol: the
5121/// root, then the expiry as `YYMMDD`, `C` or `P`, and the strike in thousandths over eight digits.
5122fn occ_contract(symbol: &str) -> Option<(&str, bool, Decimal)> {
5123    let split = symbol.len().checked_sub(15)?;
5124    let (root, tail) = (
5125        symbol.get(..split)?.trim_end(),
5126        symbol.get(split..)?.as_bytes(),
5127    );
5128    let is_call = match tail[6] {
5129        b'C' => true,
5130        b'P' => false,
5131        _ => return None,
5132    };
5133    if root.is_empty() || !tail[..6].iter().chain(&tail[7..]).all(u8::is_ascii_digit) {
5134        return None;
5135    }
5136    let strike = std::str::from_utf8(&tail[7..]).ok()?.parse::<i64>().ok()?;
5137    Some((root, is_call, Decimal::new(strike, 3)))
5138}
5139
5140/// Whether `contract`, an exercise or assignment, is the one `shares`, an `OPTRD`, is the share
5141/// leg of. See [`AlpacaClient::fetch_option_events`].
5142fn is_share_leg_of(shares: &OptionLeg<'_>, contract: &OptionLeg<'_>) -> bool {
5143    let Some((root, is_call, strike)) = contract.contract else {
5144        return false;
5145    };
5146    // Exercising a call, or being assigned on a put, buys the shares.
5147    let buys = match contract.origin {
5148        Some(TradeOrigin::Exercise) => is_call,
5149        Some(TradeOrigin::Assignment) => !is_call,
5150        _ => return false,
5151    };
5152    shares.date.is_some()
5153        && shares.date == contract.date
5154        && shares.symbol == root
5155        && shares.price == Some(strike)
5156        && shares.quantity.is_sign_positive() == buys
5157}
5158
5159/// The option activities in `start..=end` as [`Trade`]s, each share leg given the origin of the
5160/// exercise or assignment it pairs with, which may lie outside the span. See
5161/// [`AlpacaClient::fetch_option_events`].
5162fn convert_option_events(
5163    activities: &[AlpacaNonTradeActivity],
5164    start: DateTime<Utc>,
5165    end: DateTime<Utc>,
5166) -> Vec<Trade<AssetNameExchange, InstrumentNameExchange>> {
5167    let legs: Vec<_> = activities.iter().filter_map(option_leg).collect();
5168    let mut paired = vec![false; legs.len()];
5169    legs.iter()
5170        .filter_map(|leg| {
5171            // Every share leg is paired, in or out of the span, so that one outside it does not
5172            // take the partner of one inside.
5173            let origin = leg.origin.unwrap_or_else(|| {
5174                let pairs = |index: &usize| !paired[*index] && is_share_leg_of(leg, &legs[*index]);
5175                // The standard contract delivers 100 shares. A match of that size goes first, so
5176                // that two exercises at one strike each pair with their own shares; an adjusted
5177                // contract, delivering another amount, still pairs with the first match.
5178                let delivers_its_shares = |index: &usize| {
5179                    legs[*index].quantity.abs() * Decimal::ONE_HUNDRED == leg.quantity.abs()
5180                };
5181                let partner = (0..legs.len())
5182                    .filter(pairs)
5183                    .find(delivers_its_shares)
5184                    .or_else(|| (0..legs.len()).find(pairs));
5185                match partner {
5186                    Some(index) => {
5187                        paired[index] = true;
5188                        legs[index].origin.unwrap_or(TradeOrigin::External)
5189                    }
5190                    None => {
5191                        warn!(
5192                            id = leg.id,
5193                            symbol = leg.symbol,
5194                            date = ?leg.date,
5195                            "Alpaca OPTRD pairs with no exercise or assignment read — reported as \
5196                             External"
5197                        );
5198                        TradeOrigin::External
5199                    }
5200                }
5201            });
5202            if !leg.is_within(start, end) {
5203                return None;
5204            }
5205            let side = if leg.quantity.is_sign_negative() {
5206                Side::Sell
5207            } else {
5208                Side::Buy
5209            };
5210            let trade = Trade::new(
5211                TradeId::new(leg.id),
5212                OrderId::new(leg.id),
5213                InstrumentNameExchange::new(leg.symbol),
5214                StrategyId::unknown(),
5215                leg.time,
5216                side,
5217                leg.price.unwrap_or(Decimal::ZERO),
5218                leg.quantity.abs(),
5219                None,
5220                AssetFees::new(
5221                    AssetNameExchange::from(USD),
5222                    Decimal::ZERO,
5223                    Some(Decimal::ZERO),
5224                ),
5225            )
5226            .with_origin(origin);
5227            Some(trade)
5228        })
5229        .collect()
5230}
5231
5232/// `trade` with the client order id its WebSocket order update reported, if any.
5233fn with_reported_cid<AssetKey, InstrumentKey>(
5234    trade: Trade<AssetKey, InstrumentKey>,
5235    client_order_id: Option<&SmolStr>,
5236) -> Trade<AssetKey, InstrumentKey> {
5237    match client_order_id {
5238        // A `SmolStr` clone shares a long id rather than copying it.
5239        Some(cid) if !cid.is_empty() => trade.with_client_order_id(ClientOrderId(cid.clone())),
5240        _ => trade,
5241    }
5242}
5243
5244fn parse_timestamp(s: &str) -> Option<DateTime<Utc>> {
5245    DateTime::parse_from_rfc3339(s)
5246        .ok()
5247        .map(|dt| dt.with_timezone(&Utc))
5248}
5249
5250fn map_side(side: Side) -> &'static str {
5251    match side {
5252        Side::Buy => "buy",
5253        Side::Sell => "sell",
5254    }
5255}
5256
5257fn map_order_kind(kind: OrderKind) -> Option<&'static str> {
5258    match kind {
5259        OrderKind::Market => Some("market"),
5260        OrderKind::Limit => Some("limit"),
5261        OrderKind::Stop { .. } => Some("stop"),
5262        OrderKind::StopLimit { .. } => Some("stop_limit"),
5263        OrderKind::TrailingStop {
5264            activation_price: None,
5265            ..
5266        } => Some("trailing_stop"),
5267        // Alpaca has no take-profit, trailing stop-limit or fixed-limit trailing order, and no
5268        // activation price on a trailing stop. An unresolved kind is never placed.
5269        OrderKind::TakeProfit { .. }
5270        | OrderKind::TakeProfitLimit { .. }
5271        | OrderKind::TrailingStop {
5272            activation_price: Some(_),
5273            ..
5274        }
5275        | OrderKind::TrailingStopLimit { .. }
5276        | OrderKind::TrailingStopFixedLimit { .. }
5277        | OrderKind::ConditionalUnresolved { .. } => None,
5278    }
5279}
5280
5281/// Map rustrade's `TimeInForce` to Alpaca's string representation.
5282///
5283/// # Errors
5284///
5285/// Returns `Err` if `post_only: true` is requested. Alpaca does not support
5286/// post-only orders — silently placing a taker-eligible GTC order would be
5287/// the opposite of caller intent, risking unexpected taker fees. Callers
5288/// requiring maker-only execution must use a different venue or strategy.
5289fn map_time_in_force(tif: TimeInForce) -> Result<&'static str, &'static str> {
5290    match tif {
5291        TimeInForce::GoodUntilCancelled { post_only } => {
5292            if post_only {
5293                return Err("Alpaca does not support post_only orders");
5294            }
5295            Ok("gtc")
5296        }
5297        TimeInForce::GoodUntilEndOfDay => Ok("day"),
5298        TimeInForce::FillOrKill => Ok("fok"),
5299        TimeInForce::ImmediateOrCancel => Ok("ioc"),
5300        TimeInForce::AtOpen => Ok("opg"),
5301        TimeInForce::AtClose => Ok("cls"),
5302        // Alpaca's `gtd` requires an `expired_at` timestamp on the order request,
5303        // which this client does not currently surface. Reject to avoid silently
5304        // dropping the expiry semantics.
5305        TimeInForce::GoodTillDate { .. } => {
5306            Err("Alpaca GoodTillDate is not yet wired through this client")
5307        }
5308    }
5309}
5310
5311/// Returns `true` if the symbol is an equity or options symbol (i.e., NOT crypto).
5312///
5313/// Crypto symbols on Alpaca always contain a forward slash (e.g., `"BTC/USD"`).
5314/// Equities and OCC option symbols never contain a slash. We use this to decide
5315/// whether to include `position_intent` in the order request: the field is valid
5316/// for equities and required for options, but causes a 422 on crypto orders.
5317///
5318/// NOTE: relies on Alpaca's documented symbol format (as of 2025 API). If Alpaca
5319/// introduces a new asset class whose symbols contain `/`, `position_intent` would
5320/// be silently omitted for those orders, causing 422 errors.
5321fn is_options_or_equity_symbol(symbol: &str) -> bool {
5322    !symbol.contains('/')
5323}
5324
5325/// Derives Alpaca's `position_intent` from the generic [`ReduceOnly`] and `side`.
5326///
5327/// | reduce_only     | side | intent       | use case                          |
5328/// |-----------------|------|--------------|-----------------------------------|
5329/// | `No`            | Buy  | BuyToOpen    | open long / add to long position  |
5330/// | `No`            | Sell | SellToOpen   | open short / write option         |
5331/// | any other value | Buy  | BuyToClose   | close short position              |
5332/// | any other value | Sell | SellToClose  | close long position               |
5333fn map_position_intent(side: Side, reduce_only: ReduceOnly) -> AlpacaPositionIntent {
5334    match (reduce_only.is_reduce_only(), side) {
5335        (false, Side::Buy) => AlpacaPositionIntent::BuyToOpen,
5336        (false, Side::Sell) => AlpacaPositionIntent::SellToOpen,
5337        (true, Side::Buy) => AlpacaPositionIntent::BuyToClose,
5338        (true, Side::Sell) => AlpacaPositionIntent::SellToClose,
5339    }
5340}
5341
5342/// Classifies an HTTP error status + body into a typed [`ApiError`].
5343///
5344/// Used by both `rest_with_retry` (for general REST calls) and `rest_delete_with_retry`
5345/// (for cancel operations) to ensure consistent error classification across all REST paths.
5346///
5347/// `code` is the body's Alpaca error code, where it has one. It is prefixed to the message of a
5348/// refusal, so a caller can see it, except on a 404, whose message says the order was not found.
5349///
5350/// The mapping knows nothing of the request, so a query (account, positions, activities) refused
5351/// on a business rule gets an order's variant too, as any other 4xx on a query already does.
5352fn parse_api_error(
5353    status: reqwest::StatusCode,
5354    code: Option<i64>,
5355    message: &str,
5356) -> crate::error::UnindexedApiError {
5357    // Fast path: 429 doesn't need message parsing. The retry loops read a 429's headers before
5358    // this is reached; this mapping has none to read.
5359    if status == reqwest::StatusCode::TOO_MANY_REQUESTS {
5360        return ApiError::rate_limited(None);
5361    }
5362
5363    // Compute lowercase once for all match guards that inspect the message body.
5364    let lower = message.to_ascii_lowercase();
5365    match status.as_u16() {
5366        // Match "already" before "insufficient": if Alpaca ever sends a 422 body
5367        // containing both substrings, this arm wins and maps to OrderAlreadyCancelled,
5368        // which is more specific than BalanceInsufficient.
5369        422 if lower.contains("already") => ApiError::OrderAlreadyCancelled,
5370        422 => parse_business_refusal(code, message, &lower),
5371        401 => ApiError::Unauthenticated(format!("unauthorized: {message}")),
5372        // A 403 means one of two things, told apart by the code. Without one, it is Alpaca's
5373        // `{"message": "forbidden."}` to a wrong key, secret or host. With one, the account is
5374        // fine and Alpaca refused the request on a business rule: `40310000` is generic
5375        // ("insufficient buying power", "insufficient qty available for order", "account is
5376        // not allowed to short", "account is restricted to liquidation only", ...), and
5377        // `40310100` is pattern-day-trader protection.
5378        403 => match code {
5379            None => ApiError::Unauthenticated(format!("forbidden: {message}")),
5380            Some(_) => parse_business_refusal(code, message, &lower),
5381        },
5382        404 => ApiError::OrderRejected(format!("order not found: {message}")),
5383        _ => ApiError::OrderRejected(with_alpaca_code(code, message)),
5384    }
5385}
5386
5387/// Classifies a refusal on a business rule (a 422, or a 403 with a code) by its text, since
5388/// Alpaca's codes are generic. `lower` is `message` in lowercase. The first match below wins, so
5389/// a refused short is reported as such even if its text also says "insufficient".
5390///
5391/// - "account is not allowed to short" → [`ApiError::BorrowRejected`] with
5392///   [`BorrowRejectReason::BorrowDisabled`].
5393/// - "cannot be sold short" → [`BorrowRejectReason::NotShortable`], except Alpaca's "fractional
5394///   orders cannot be sold short": the asset may be shortable in whole shares, so that is the
5395///   order's shape, an [`ApiError::OrderRejected`].
5396/// - "insufficient" → [`ApiError::BalanceInsufficient`]. The body says what ran short
5397///   ("insufficient buying power", or "insufficient qty available" on a sell) but names no
5398///   asset, so none is guessed.
5399/// - anything else → [`ApiError::OrderRejected`].
5400fn parse_business_refusal(code: Option<i64>, message: &str, lower: &str) -> UnindexedApiError {
5401    let borrow = if lower.contains("not allowed to short") {
5402        Some(BorrowRejectReason::BorrowDisabled)
5403    } else if lower.contains("cannot be sold short") && !lower.contains("fractional") {
5404        Some(BorrowRejectReason::NotShortable)
5405    } else {
5406        None
5407    };
5408    if let Some(reason) = borrow {
5409        ApiError::BorrowRejected(BorrowReject::new(
5410            reason,
5411            code.map(|code| code.to_string()),
5412            message.to_owned(),
5413        ))
5414    } else if lower.contains("insufficient") {
5415        ApiError::BalanceInsufficient(None, with_alpaca_code(code, message))
5416    } else {
5417        ApiError::OrderRejected(with_alpaca_code(code, message))
5418    }
5419}
5420
5421/// `message` prefixed with Alpaca's code where it has one, so a caller can see it.
5422fn with_alpaca_code(code: Option<i64>, message: &str) -> String {
5423    match code {
5424        Some(code) => format!("{code} {message}"),
5425        None => message.to_owned(),
5426    }
5427}
5428
5429/// Wraps [`parse_api_error`] for order-specific error handling (e.g., cancel_order).
5430fn parse_order_error(
5431    status: reqwest::StatusCode,
5432    code: Option<i64>,
5433    message: &str,
5434) -> UnindexedOrderError {
5435    UnindexedOrderError::Rejected(parse_api_error(status, code, message))
5436}
5437
5438fn connectivity_err(msg: impl Into<String>) -> UnindexedClientError {
5439    UnindexedClientError::Connectivity(ConnectivityError::Socket(msg.into()))
5440}
5441
5442/// Map a failed order `POST` through [`rest_with_retry`] to the order's error.
5443///
5444/// [`UnindexedClientError::Internal`] there means Alpaca answered 2xx, so it accepted the order,
5445/// but the response could not be read. The order may be live, so it is reported as
5446/// [`OrderError::Connectivity`], the status-unknown error, never as a rejection. A caller must
5447/// reconcile it (open orders, fills) before resubmitting, or it may place the order twice.
5448fn order_post_error(error: UnindexedClientError) -> UnindexedOrderError {
5449    match error {
5450        UnindexedClientError::Connectivity(ce) => OrderError::Connectivity(ce),
5451        UnindexedClientError::Api(ae) => OrderError::Rejected(ae),
5452        UnindexedClientError::Internal(msg) => OrderError::Connectivity(ConnectivityError::Socket(
5453            format!("Alpaca order status unknown, its 2xx response could not be read: {msg}"),
5454        )),
5455        // `rest_with_retry` returns none of these today. Matched explicitly so a new
5456        // `ClientError` variant is a compile error here, and reported as status unknown rather
5457        // than panicking an order path if that ever changes.
5458        other @ (UnindexedClientError::TaskFailed(_)
5459        | UnindexedClientError::Truncated { .. }
5460        | UnindexedClientError::TruncatedSnapshot { .. }) => OrderError::Connectivity(
5461            ConnectivityError::Socket(format!("Alpaca order status unknown: {other}")),
5462        ),
5463    }
5464}
5465
5466// ---------------------------------------------------------------------------
5467// OrderStatusClient implementation
5468// ---------------------------------------------------------------------------
5469
5470impl OrderStatusClient for AlpacaClient {
5471    /// Looks each order up with `GET /v2/orders:by_client_order_id`, up to 8 at a time.
5472    ///
5473    /// `filled` is reported as fully filled with `filled_avg_price`, `canceled` and `replaced` as
5474    /// cancelled (the order replacing one has its own client order id, which the caller must
5475    /// learn from [`ExecutionClient::fetch_open_orders`]), `expired` as expired and `rejected` as
5476    /// open failed. `done_for_day` is not ended: Alpaca works the order again the next trading day.
5477    /// A 404, or an order on another symbol than the key's (ignoring case), is unknown.
5478    async fn fetch_ended_orders(
5479        &self,
5480        orders: &[UnindexedOrderKey],
5481    ) -> Result<Vec<UnindexedInactiveOrder>, UnindexedClientError> {
5482        fetch_ended_by_key(orders, |key| {
5483            fetch_order_lookup(
5484                self.http.clone(),
5485                self.rate_limiter.clone(),
5486                self.config.clone(),
5487                key,
5488            )
5489        })
5490        .await
5491    }
5492}
5493
5494// ---------------------------------------------------------------------------
5495// ShortabilityClient implementation
5496// ---------------------------------------------------------------------------
5497
5498impl ShortabilityClient for AlpacaClient {
5499    /// Reads the asset with `GET /v2/assets/{symbol}`, one request.
5500    ///
5501    /// - `shortable` is the asset's `shortable`.
5502    /// - `easy_to_borrow` is its `borrow_status`, which Alpaca reports for US equities only, or
5503    ///   failing that its `easy_to_borrow` flag; neither present leaves it unknown.
5504    /// - `fee_rate` is zero for an easy-to-borrow stock, as Alpaca charges no borrow fee on one.
5505    ///   That is Alpaca's charge, not the market's cost of borrowing. Alpaca publishes no rate for
5506    ///   a hard-to-borrow stock, so its fee is unknown.
5507    /// - `available` is unknown. Alpaca's locate quotes report what it can lend of a hard-to-borrow
5508    ///   stock, but they are not on paper accounts and are not read here.
5509    /// - `delisting_at` is unknown: Alpaca's asset carries no delisting date.
5510    ///
5511    /// Alpaca updates its borrow flags from a daily list, so they may be up to a day old.
5512    ///
5513    /// # Errors
5514    ///
5515    /// [`ApiError::InstrumentInvalid`] for a symbol Alpaca knows no asset under, and for an
5516    /// option, as writing one borrows nothing.
5517    async fn fetch_shortability(
5518        &self,
5519        instrument: &InstrumentNameExchange,
5520    ) -> Result<Shortability, UnindexedClientError> {
5521        let url = asset_url(self.base_url(), instrument)?;
5522        let found: Option<AlpacaAsset> =
5523            rest_lookup_with_retry(&self.rate_limiter, || self.http.get(url.clone())).await?;
5524        let Some(asset) = found else {
5525            return Err(instrument_invalid(
5526                instrument,
5527                "Alpaca knows no asset under this symbol",
5528            ));
5529        };
5530        shortability_from_asset(instrument, &asset)
5531    }
5532}
5533
5534/// `GET /v2/assets/{symbol}`, with the symbol as one path segment, so a crypto pair's `/` is
5535/// escaped rather than read as a path separator.
5536fn asset_url(
5537    base_url: &str,
5538    instrument: &InstrumentNameExchange,
5539) -> Result<reqwest::Url, UnindexedClientError> {
5540    let mut url = reqwest::Url::parse(base_url).map_err(|error| {
5541        UnindexedClientError::Internal(format!("Alpaca base URL {base_url:?}: {error}"))
5542    })?;
5543    url.path_segments_mut()
5544        .map_err(|()| {
5545            UnindexedClientError::Internal(format!("Alpaca base URL {base_url:?} has no path"))
5546        })?
5547        .pop_if_empty()
5548        .extend(["v2", "assets", instrument.name().as_str()]);
5549    Ok(url)
5550}
5551
5552fn instrument_invalid(instrument: &InstrumentNameExchange, reason: &str) -> UnindexedClientError {
5553    UnindexedClientError::Api(ApiError::InstrumentInvalid(
5554        instrument.clone(),
5555        reason.to_owned(),
5556    ))
5557}
5558
5559/// What [`AlpacaClient::fetch_shortability`] reports of `asset`.
5560fn shortability_from_asset(
5561    instrument: &InstrumentNameExchange,
5562    asset: &AlpacaAsset,
5563) -> Result<Shortability, UnindexedClientError> {
5564    if asset.class == AlpacaAssetClass::UsOption {
5565        return Err(instrument_invalid(
5566            instrument,
5567            "shortability does not apply to an option: writing one borrows nothing",
5568        ));
5569    }
5570    let easy_to_borrow = match asset.borrow_status {
5571        Some(AlpacaBorrowStatus::EasyToBorrow) => Some(true),
5572        Some(AlpacaBorrowStatus::HardToBorrow) => Some(false),
5573        Some(AlpacaBorrowStatus::Other) | None => asset.easy_to_borrow,
5574    };
5575    let mut shortability = Shortability::new();
5576    if let Some(shortable) = asset.shortable {
5577        shortability = shortability.with_shortable(shortable);
5578    }
5579    if let Some(easy_to_borrow) = easy_to_borrow {
5580        shortability = shortability.with_easy_to_borrow(easy_to_borrow);
5581        if easy_to_borrow {
5582            shortability = shortability.with_fee_rate(Decimal::ZERO);
5583        }
5584    }
5585    Ok(shortability)
5586}
5587
5588/// The fields of `GET /v2/assets/{symbol}` a [`Shortability`] is built from. Each is optional, so
5589/// one Alpaca leaves out reads as unknown rather than failing the request.
5590#[derive(Debug, Deserialize)]
5591struct AlpacaAsset {
5592    #[serde(default)]
5593    class: AlpacaAssetClass,
5594    #[serde(default)]
5595    shortable: Option<bool>,
5596    /// US equities only.
5597    #[serde(default)]
5598    borrow_status: Option<AlpacaBorrowStatus>,
5599    /// In Alpaca's examples but not its schema, so read only when `borrow_status` is absent.
5600    #[serde(default)]
5601    easy_to_borrow: Option<bool>,
5602}
5603
5604#[derive(Debug, Default, PartialEq, Eq, Deserialize)]
5605#[serde(rename_all = "snake_case")]
5606enum AlpacaAssetClass {
5607    UsOption,
5608    #[default]
5609    #[serde(other)]
5610    Other,
5611}
5612
5613#[derive(Debug, PartialEq, Eq, Deserialize)]
5614#[serde(rename_all = "snake_case")]
5615enum AlpacaBorrowStatus {
5616    EasyToBorrow,
5617    HardToBorrow,
5618    #[serde(other)]
5619    Other,
5620}
5621
5622// ---------------------------------------------------------------------------
5623// BracketOrderClient implementation
5624// ---------------------------------------------------------------------------
5625
5626impl BracketOrderClient for AlpacaClient {
5627    async fn open_bracket_order(
5628        &self,
5629        request: UnifiedBracketOrderRequest<ExchangeId, &InstrumentNameExchange>,
5630    ) -> UnifiedBracketOrderResult {
5631        let alpaca_request = AlpacaBracketOrderRequest {
5632            instrument: request.key.instrument.clone(),
5633            strategy: request.key.strategy.clone(),
5634            cid: request.key.cid.clone(),
5635            side: request.state.side,
5636            quantity: request.state.quantity,
5637            entry_price: request.state.entry_price,
5638            take_profit_price: request.state.take_profit_price,
5639            stop_loss_price: request.state.stop_loss_price,
5640            stop_loss_limit_price: request.state.stop_loss_limit_price,
5641            time_in_force: request.state.time_in_force,
5642        };
5643
5644        let result = self.open_bracket_order(alpaca_request).await;
5645
5646        UnifiedBracketOrderResult::parent_only(result.parent)
5647    }
5648}
5649
5650// ---------------------------------------------------------------------------
5651// Tests
5652// ---------------------------------------------------------------------------
5653
5654#[cfg(test)]
5655#[allow(clippy::unwrap_used, clippy::expect_used)] // Test code: panics on bad input are acceptable
5656mod tests {
5657    use super::*;
5658    use rust_decimal_macros::dec;
5659
5660    #[test]
5661    fn test_alpaca_config_new_uses_paper_trading_by_default() {
5662        let cfg = AlpacaConfig::new("my_key".into(), "my_secret".into());
5663        assert!(cfg.paper);
5664    }
5665
5666    #[test]
5667    fn test_alpaca_config_debug_redacts_credentials() {
5668        let cfg = AlpacaConfig::new("my_key".into(), "my_secret".into());
5669        let debug = format!("{cfg:?}");
5670        assert!(!debug.contains("my_key"), "api_key should be redacted");
5671        assert!(
5672            !debug.contains("my_secret"),
5673            "secret_key should be redacted"
5674        );
5675        assert!(debug.contains("paper: true"));
5676    }
5677
5678    #[test]
5679    fn test_alpaca_config_deserialize_omitted_paper_defaults_to_paper() {
5680        let cfg: AlpacaConfig = serde_json::from_str(
5681            r#"{
5682        "api_key": "my_key",
5683        "secret_key": "my_secret"
5684    }"#,
5685        )
5686        .unwrap();
5687
5688        assert!(cfg.paper);
5689        assert_eq!(cfg.rest_base_url(), "https://paper-api.alpaca.markets");
5690    }
5691
5692    #[test]
5693    fn test_alpaca_config_deserialize_paper_true() {
5694        let cfg: AlpacaConfig = serde_json::from_str(
5695            r#"{
5696        "api_key": "my_key",
5697        "secret_key": "my_secret",
5698        "paper": true
5699    }"#,
5700        )
5701        .unwrap();
5702
5703        assert!(cfg.paper);
5704        assert_eq!(cfg.rest_base_url(), "https://paper-api.alpaca.markets");
5705    }
5706
5707    #[test]
5708    fn test_alpaca_config_deserialize_paper_false() {
5709        let cfg: AlpacaConfig = serde_json::from_str(
5710            r#"{
5711        "api_key": "my_key",
5712        "secret_key": "my_secret",
5713        "paper": false
5714    }"#,
5715        )
5716        .unwrap();
5717
5718        assert!(!cfg.paper);
5719        assert_eq!(cfg.rest_base_url(), "https://api.alpaca.markets");
5720    }
5721
5722    #[test]
5723    fn test_alpaca_config_deserialize_rejects_test_url_overrides() {
5724        for key in ["base_url_override", "ws_url_override"] {
5725            let json = format!(
5726                r#"{{"api_key": "my_key", "secret_key": "my_secret", "{key}": "http://127.0.0.1:1"}}"#
5727            );
5728            let Err(err) = serde_json::from_str::<AlpacaConfig>(&json) else {
5729                panic!("a config with `{key}` must not deserialize");
5730            };
5731            assert!(
5732                err.to_string().contains(&format!("unknown field `{key}`")),
5733                "unexpected error for `{key}`: {err}"
5734            );
5735        }
5736    }
5737
5738    #[test]
5739    fn test_alpaca_config_urls() {
5740        let paper = AlpacaConfig::paper("k".into(), "s".into());
5741        assert!(paper.rest_base_url().contains("paper-api"));
5742        assert!(paper.ws_url().contains("paper-api"));
5743
5744        let live = AlpacaConfig::production("k".into(), "s".into());
5745        assert!(!live.rest_base_url().contains("paper-api"));
5746        assert!(!live.ws_url().contains("paper-api"));
5747    }
5748
5749    #[test]
5750    #[serial_test::serial]
5751    fn test_alpaca_config_from_env_defaults_to_paper_trading() {
5752        temp_env::with_vars(
5753            [
5754                ("ALPACA_API_KEY", Some("my_key")),
5755                ("ALPACA_SECRET_KEY", Some("my_secret")),
5756                ("ALPACA_PAPER", None),
5757            ],
5758            || {
5759                let cfg = AlpacaConfig::from_env().unwrap();
5760                assert!(cfg.paper);
5761                assert_eq!(cfg.rest_base_url(), "https://paper-api.alpaca.markets");
5762            },
5763        );
5764    }
5765
5766    #[test]
5767    #[serial_test::serial]
5768    fn test_alpaca_config_from_env_accepts_explicit_production() {
5769        temp_env::with_vars(
5770            [
5771                ("ALPACA_API_KEY", Some("my_key")),
5772                ("ALPACA_SECRET_KEY", Some("my_secret")),
5773                ("ALPACA_PAPER", Some("false")),
5774            ],
5775            || {
5776                let cfg = AlpacaConfig::from_env().unwrap();
5777                assert!(!cfg.paper);
5778                assert_eq!(cfg.rest_base_url(), "https://api.alpaca.markets");
5779            },
5780        );
5781    }
5782
5783    #[test]
5784    #[serial_test::serial]
5785    fn test_alpaca_config_from_env_rejects_invalid_paper() {
5786        temp_env::with_vars(
5787            [
5788                ("ALPACA_API_KEY", Some("my_key")),
5789                ("ALPACA_SECRET_KEY", Some("my_secret")),
5790                ("ALPACA_PAPER", Some("maybe")),
5791            ],
5792            || {
5793                let err = AlpacaConfig::from_env().unwrap_err();
5794                assert!(matches!(err, AlpacaConfigError::InvalidPaper(value) if value == "maybe"));
5795            },
5796        );
5797    }
5798
5799    #[test]
5800    #[serial_test::serial]
5801    fn test_alpaca_config_from_env_requires_credentials() {
5802        temp_env::with_vars(
5803            [
5804                ("ALPACA_API_KEY", None),
5805                ("ALPACA_SECRET_KEY", Some("my_secret")),
5806                ("ALPACA_PAPER", None),
5807            ],
5808            || {
5809                let err = AlpacaConfig::from_env().unwrap_err();
5810                assert!(matches!(err, AlpacaConfigError::MissingApiKey));
5811            },
5812        );
5813    }
5814
5815    #[test]
5816    fn test_parse_side() {
5817        assert_eq!(parse_side("buy"), Some(Side::Buy));
5818        assert_eq!(parse_side("sell"), Some(Side::Sell));
5819        assert_eq!(parse_side("Buy"), Some(Side::Buy));
5820        assert_eq!(parse_side("BUY"), Some(Side::Buy));
5821        assert_eq!(parse_side("unknown"), None);
5822    }
5823
5824    #[test]
5825    fn test_parse_order_kind() {
5826        // Market and Limit don't need extra params
5827        assert_eq!(
5828            parse_order_kind("market", None, None, None),
5829            Some(OrderKind::Market)
5830        );
5831        assert_eq!(
5832            parse_order_kind("limit", None, None, None),
5833            Some(OrderKind::Limit)
5834        );
5835
5836        // Stop requires stop_price
5837        assert_eq!(
5838            parse_order_kind("stop", Some("150.00"), None, None),
5839            Some(OrderKind::Stop {
5840                trigger_price: Decimal::from_str("150.00").unwrap()
5841            })
5842        );
5843        assert_eq!(parse_order_kind("stop", None, None, None), None);
5844
5845        // StopLimit requires stop_price
5846        assert_eq!(
5847            parse_order_kind("stop_limit", Some("145.00"), None, None),
5848            Some(OrderKind::StopLimit {
5849                trigger_price: Decimal::from_str("145.00").unwrap()
5850            })
5851        );
5852
5853        // TrailingStop with percentage
5854        assert_eq!(
5855            parse_order_kind("trailing_stop", None, Some("5.0"), None),
5856            Some(OrderKind::TrailingStop {
5857                offset: Decimal::from_str("5.0").unwrap(),
5858                offset_type: TrailingOffsetType::Percentage,
5859                activation_price: None,
5860            })
5861        );
5862
5863        // TrailingStop with absolute price
5864        assert_eq!(
5865            parse_order_kind("trailing_stop", None, None, Some("2.50")),
5866            Some(OrderKind::TrailingStop {
5867                offset: Decimal::from_str("2.50").unwrap(),
5868                offset_type: TrailingOffsetType::Absolute,
5869                activation_price: None,
5870            })
5871        );
5872
5873        // TrailingStop without either offset returns None
5874        assert_eq!(parse_order_kind("trailing_stop", None, None, None), None);
5875
5876        // Unknown type returns None
5877        assert_eq!(parse_order_kind("unknown", None, None, None), None);
5878    }
5879
5880    #[test]
5881    fn test_map_order_kind() {
5882        assert_eq!(map_order_kind(OrderKind::Market), Some("market"));
5883        assert_eq!(map_order_kind(OrderKind::Limit), Some("limit"));
5884        assert_eq!(
5885            map_order_kind(OrderKind::Stop {
5886                trigger_price: Decimal::from_str("150.00").unwrap()
5887            }),
5888            Some("stop")
5889        );
5890        assert_eq!(
5891            map_order_kind(OrderKind::StopLimit {
5892                trigger_price: Decimal::from_str("145.00").unwrap()
5893            }),
5894            Some("stop_limit")
5895        );
5896        assert_eq!(
5897            map_order_kind(OrderKind::TrailingStop {
5898                offset: Decimal::from_str("5.0").unwrap(),
5899                offset_type: TrailingOffsetType::Percentage,
5900                activation_price: None,
5901            }),
5902            Some("trailing_stop")
5903        );
5904        assert_eq!(
5905            map_order_kind(OrderKind::TrailingStop {
5906                offset: Decimal::from_str("2.50").unwrap(),
5907                offset_type: TrailingOffsetType::Absolute,
5908                activation_price: None,
5909            }),
5910            Some("trailing_stop")
5911        );
5912        // Alpaca has no activation price, and no fixed-limit trailing stop
5913        assert_eq!(
5914            map_order_kind(OrderKind::TrailingStop {
5915                offset: Decimal::from_str("5.0").unwrap(),
5916                offset_type: TrailingOffsetType::Percentage,
5917                activation_price: Some(Decimal::from(100)),
5918            }),
5919            None
5920        );
5921        assert_eq!(
5922            map_order_kind(OrderKind::TrailingStopFixedLimit {
5923                offset: Decimal::from_str("5.0").unwrap(),
5924                offset_type: TrailingOffsetType::Percentage,
5925                activation_price: None,
5926            }),
5927            None
5928        );
5929        // TrailingStopLimit is not supported by Alpaca
5930        assert_eq!(
5931            map_order_kind(OrderKind::TrailingStopLimit {
5932                offset: Decimal::from_str("5.0").unwrap(),
5933                offset_type: TrailingOffsetType::Percentage,
5934                stop_price: Decimal::from_str("95.0").unwrap(),
5935                limit_offset: Decimal::from_str("1.0").unwrap(),
5936            }),
5937            None
5938        );
5939        // An unresolved kind is only read back from a venue, never placed.
5940        assert_eq!(
5941            map_order_kind(OrderKind::ConditionalUnresolved {
5942                family: crate::order::ConditionalFamily::Stop,
5943                trigger_price: None,
5944            }),
5945            None
5946        );
5947        // TakeProfit/TakeProfitLimit are not supported by Alpaca
5948        assert_eq!(
5949            map_order_kind(OrderKind::TakeProfit {
5950                trigger_price: Decimal::from_str("160.00").unwrap()
5951            }),
5952            None
5953        );
5954        assert_eq!(
5955            map_order_kind(OrderKind::TakeProfitLimit {
5956                trigger_price: Decimal::from_str("160.00").unwrap()
5957            }),
5958            None
5959        );
5960    }
5961
5962    #[test]
5963    fn test_map_time_in_force_roundtrip() {
5964        assert_eq!(
5965            map_time_in_force(TimeInForce::GoodUntilCancelled { post_only: false }),
5966            Ok("gtc")
5967        );
5968        assert_eq!(map_time_in_force(TimeInForce::GoodUntilEndOfDay), Ok("day"));
5969        assert_eq!(map_time_in_force(TimeInForce::FillOrKill), Ok("fok"));
5970        assert_eq!(map_time_in_force(TimeInForce::ImmediateOrCancel), Ok("ioc"));
5971    }
5972
5973    #[test]
5974    fn test_map_time_in_force_rejects_post_only() {
5975        let result = map_time_in_force(TimeInForce::GoodUntilCancelled { post_only: true });
5976        assert!(result.is_err(), "post_only must be rejected");
5977        assert!(result.unwrap_err().contains("post_only"));
5978    }
5979
5980    // =========================================================================
5981    // Bracket Order Serialization Tests
5982    // =========================================================================
5983
5984    #[test]
5985    fn test_bracket_order_serializes_with_stop_loss_stop_order() {
5986        // Bracket order with stop-loss as a simple stop order (no limit_price)
5987        let body = AlpacaOrderRequest {
5988            symbol: "AAPL",
5989            qty: "10".to_string(),
5990            side: "buy",
5991            order_type: "limit",
5992            time_in_force: "gtc",
5993            limit_price: Some("150.00".to_string()),
5994            stop_price: None,
5995            trail_percent: None,
5996            trail_price: None,
5997            client_order_id: Some("bracket-001"),
5998            position_intent: Some(AlpacaPositionIntent::BuyToOpen),
5999            order_class: Some("bracket"),
6000            take_profit: Some(TakeProfitParams {
6001                limit_price: "160.00".to_string(),
6002            }),
6003            stop_loss: Some(StopLossParams {
6004                stop_price: "145.00".to_string(),
6005                limit_price: None,
6006            }),
6007        };
6008
6009        let json = serde_json::to_value(&body).unwrap();
6010
6011        assert_eq!(json["symbol"], "AAPL");
6012        assert_eq!(json["qty"], "10");
6013        assert_eq!(json["side"], "buy");
6014        assert_eq!(json["type"], "limit");
6015        assert_eq!(json["time_in_force"], "gtc");
6016        assert_eq!(json["limit_price"], "150.00");
6017        assert_eq!(json["order_class"], "bracket");
6018
6019        // Take profit should have limit_price
6020        assert_eq!(json["take_profit"]["limit_price"], "160.00");
6021
6022        // Stop loss should have stop_price but NO limit_price
6023        assert_eq!(json["stop_loss"]["stop_price"], "145.00");
6024        assert!(
6025            json["stop_loss"].get("limit_price").is_none(),
6026            "stop_loss.limit_price should be omitted when None"
6027        );
6028    }
6029
6030    #[test]
6031    fn test_bracket_order_serializes_with_stop_loss_stop_limit_order() {
6032        // Bracket order with stop-loss as a stop-limit order (has limit_price)
6033        let body = AlpacaOrderRequest {
6034            symbol: "SPY",
6035            qty: "5".to_string(),
6036            side: "sell",
6037            order_type: "limit",
6038            time_in_force: "day",
6039            limit_price: Some("450.00".to_string()),
6040            stop_price: None,
6041            trail_percent: None,
6042            trail_price: None,
6043            client_order_id: Some("bracket-002"),
6044            position_intent: Some(AlpacaPositionIntent::SellToClose),
6045            order_class: Some("bracket"),
6046            take_profit: Some(TakeProfitParams {
6047                limit_price: "440.00".to_string(),
6048            }),
6049            stop_loss: Some(StopLossParams {
6050                stop_price: "455.00".to_string(),
6051                limit_price: Some("456.00".to_string()),
6052            }),
6053        };
6054
6055        let json = serde_json::to_value(&body).unwrap();
6056
6057        assert_eq!(json["symbol"], "SPY");
6058        assert_eq!(json["side"], "sell");
6059        assert_eq!(json["time_in_force"], "day");
6060        assert_eq!(json["order_class"], "bracket");
6061
6062        // Take profit
6063        assert_eq!(json["take_profit"]["limit_price"], "440.00");
6064
6065        // Stop loss with limit_price (stop-limit order)
6066        assert_eq!(json["stop_loss"]["stop_price"], "455.00");
6067        assert_eq!(
6068            json["stop_loss"]["limit_price"], "456.00",
6069            "stop_loss.limit_price should be present for stop-limit orders"
6070        );
6071    }
6072
6073    // =========================================================================
6074    // Bracket Order Validation Tests
6075    // =========================================================================
6076
6077    #[tokio::test]
6078    async fn test_open_bracket_order_rejects_invalid_tif() {
6079        use rust_decimal_macros::dec;
6080        use rustrade_instrument::instrument::name::InstrumentNameExchange;
6081
6082        // Create a minimal client with dummy credentials (no network call will be made)
6083        let config = AlpacaConfig::new("dummy_key".into(), "dummy_secret".into());
6084        let client = AlpacaClient::new(config);
6085
6086        let request = AlpacaBracketOrderRequest::new(
6087            InstrumentNameExchange::new("SPY"),
6088            crate::order::id::StrategyId::new("test"),
6089            crate::order::id::ClientOrderId::new("test-tif"),
6090            Side::Buy,
6091            dec!(1),
6092            dec!(100.00),
6093            dec!(120.00),
6094            dec!(90.00),
6095            TimeInForce::ImmediateOrCancel, // Invalid for brackets
6096        );
6097
6098        let result = client.open_bracket_order(request).await;
6099
6100        assert!(
6101            result.parent.state.is_failed(),
6102            "Bracket order with IOC TIF should be rejected locally"
6103        );
6104    }
6105
6106    #[tokio::test]
6107    async fn test_open_bracket_order_rejects_invalid_price_ordering() {
6108        use rust_decimal_macros::dec;
6109        use rustrade_instrument::instrument::name::InstrumentNameExchange;
6110
6111        let config = AlpacaConfig::new("dummy_key".into(), "dummy_secret".into());
6112        let client = AlpacaClient::new(config);
6113
6114        // Buy bracket with SL > entry (invalid)
6115        let request = AlpacaBracketOrderRequest::new(
6116            InstrumentNameExchange::new("SPY"),
6117            crate::order::id::StrategyId::new("test"),
6118            crate::order::id::ClientOrderId::new("test-price"),
6119            Side::Buy,
6120            dec!(1),
6121            dec!(100.00),
6122            dec!(120.00),
6123            dec!(105.00), // Invalid: SL > entry for buy
6124            TimeInForce::GoodUntilCancelled { post_only: false },
6125        );
6126
6127        let result = client.open_bracket_order(request).await;
6128
6129        assert!(
6130            result.parent.state.is_failed(),
6131            "Bracket order with invalid price ordering should be rejected locally"
6132        );
6133    }
6134
6135    #[tokio::test]
6136    async fn test_open_bracket_order_rejects_invalid_sl_limit_price() {
6137        use rust_decimal_macros::dec;
6138        use rustrade_instrument::instrument::name::InstrumentNameExchange;
6139
6140        let config = AlpacaConfig::new("dummy_key".into(), "dummy_secret".into());
6141        let client = AlpacaClient::new(config);
6142
6143        // Buy bracket with SL limit > SL trigger (invalid for sell stop-limit)
6144        let request = AlpacaBracketOrderRequest::new(
6145            InstrumentNameExchange::new("SPY"),
6146            crate::order::id::StrategyId::new("test"),
6147            crate::order::id::ClientOrderId::new("test-sl-limit"),
6148            Side::Buy,
6149            dec!(1),
6150            dec!(100.00),
6151            dec!(120.00),
6152            dec!(90.00),
6153            TimeInForce::GoodUntilCancelled { post_only: false },
6154        )
6155        .with_stop_loss_limit_price(dec!(95.00)); // Invalid: limit > trigger for sell SL
6156
6157        let result = client.open_bracket_order(request).await;
6158
6159        assert!(
6160            result.parent.state.is_failed(),
6161            "Bracket order with invalid SL limit price should be rejected locally"
6162        );
6163    }
6164
6165    #[test]
6166    fn test_non_bracket_order_omits_bracket_fields() {
6167        // Regular limit order should not have bracket fields
6168        let body = AlpacaOrderRequest {
6169            symbol: "AAPL",
6170            qty: "1".to_string(),
6171            side: "buy",
6172            order_type: "limit",
6173            time_in_force: "gtc",
6174            limit_price: Some("150.00".to_string()),
6175            stop_price: None,
6176            trail_percent: None,
6177            trail_price: None,
6178            client_order_id: Some("regular-001"),
6179            position_intent: Some(AlpacaPositionIntent::BuyToOpen),
6180            order_class: None,
6181            take_profit: None,
6182            stop_loss: None,
6183        };
6184
6185        let json = serde_json::to_value(&body).unwrap();
6186
6187        assert_eq!(json["symbol"], "AAPL");
6188        assert!(
6189            json.get("order_class").is_none(),
6190            "order_class should be omitted for non-bracket orders"
6191        );
6192        assert!(
6193            json.get("take_profit").is_none(),
6194            "take_profit should be omitted for non-bracket orders"
6195        );
6196        assert!(
6197            json.get("stop_loss").is_none(),
6198            "stop_loss should be omitted for non-bracket orders"
6199        );
6200    }
6201
6202    #[test]
6203    fn test_parse_timestamp_valid() {
6204        let ts = parse_timestamp("2025-04-18T14:30:00Z");
6205        assert!(ts.is_some());
6206        let ts2 = parse_timestamp("2025-04-18T14:30:00.123456Z");
6207        assert!(ts2.is_some());
6208        assert_eq!(parse_timestamp("not-a-timestamp"), None);
6209    }
6210
6211    #[test]
6212    fn test_check_auth_response_authorized() {
6213        let msg =
6214            r#"{"stream":"authorization","data":{"status":"authorized","action":"authenticate"}}"#;
6215        assert!(matches!(check_auth_response(msg), Some(Ok(()))));
6216    }
6217
6218    #[test]
6219    fn test_check_auth_response_unauthorized() {
6220        let msg = r#"{"stream":"authorization","data":{"status":"unauthorized"}}"#;
6221        assert!(matches!(
6222            check_auth_response(msg),
6223            Some(Err(HandshakeError::Auth(_)))
6224        ));
6225    }
6226
6227    #[test]
6228    fn test_check_auth_response_non_auth_message() {
6229        let msg = r#"{"stream":"trade_updates","data":{}}"#;
6230        assert!(check_auth_response(msg).is_none());
6231    }
6232
6233    #[test]
6234    fn test_check_listen_ack() {
6235        let ack = r#"{"stream":"listening","data":{"streams":["trade_updates"]}}"#;
6236        assert!(check_listen_ack(ack));
6237
6238        let other = r#"{"stream":"authorization","data":{}}"#;
6239        assert!(!check_listen_ack(other));
6240    }
6241
6242    #[test]
6243    fn test_dedup_cache() {
6244        let cache = new_dedup_cache();
6245        let key = || SmolStr::new("order-1:1");
6246        assert!(
6247            !is_duplicate_fill(&cache, Some("exec-1"), key()),
6248            "first time should not be duplicate"
6249        );
6250        assert!(
6251            is_duplicate_fill(&cache, Some("exec-1"), key()),
6252            "second time should be duplicate"
6253        );
6254    }
6255
6256    /// A fill is told apart by its execution id, so after a bust lowers an order's cumulative, a
6257    /// new fill that takes it back to a cumulative an earlier fill reached is not dropped.
6258    #[test]
6259    fn dedup_tells_fills_apart_by_execution_id_not_cumulative() {
6260        let cache = new_dedup_cache();
6261        assert!(!is_duplicate_fill(
6262            &cache,
6263            Some("exec-a"),
6264            SmolStr::new("ord-1:5")
6265        ));
6266        // A bust took the order back to 3, and a new fill takes it to 5 again.
6267        assert!(
6268            !is_duplicate_fill(&cache, Some("exec-b"), SmolStr::new("ord-1:5")),
6269            "a different execution at a cumulative seen before is a new fill"
6270        );
6271    }
6272
6273    /// A fill one path delivers without an execution id still matches the same fill the other
6274    /// path delivers with one, whichever comes first.
6275    #[test]
6276    fn dedup_matches_a_fill_with_an_execution_id_to_the_same_fill_without_one() {
6277        let cache = new_dedup_cache();
6278
6279        assert!(!is_duplicate_fill(&cache, None, SmolStr::new("ord-1:2")));
6280        assert!(
6281            is_duplicate_fill(&cache, Some("exec-a"), SmolStr::new("ord-1:2")),
6282            "without an id first, then with one"
6283        );
6284
6285        assert!(!is_duplicate_fill(
6286            &cache,
6287            Some("exec-b"),
6288            SmolStr::new("ord-1:4")
6289        ));
6290        assert!(
6291            is_duplicate_fill(&cache, None, SmolStr::new("ord-1:4")),
6292            "with an id first, then without one"
6293        );
6294    }
6295
6296    /// `account_stream`'s rustdoc states the heartbeat window as a number. Change it only together
6297    /// with those docs. The reconnect budget's own numbers are pinned in `client::reconnect_budget`.
6298    #[test]
6299    fn the_heartbeat_window_is_as_documented() {
6300        assert_eq!(HEARTBEAT_TIMEOUT_SECS, 35);
6301    }
6302
6303    #[test]
6304    fn test_convert_account_to_balances_empty_assets() {
6305        // equity and buying_power are larger than cash on a margin account holding positions;
6306        // neither may leak into the balance.
6307        let account: AlpacaAccount = serde_json::from_value(serde_json::json!({
6308            "cash": "9000.50",
6309            "equity": "12000.00",
6310            "buying_power": "36000.00",
6311            "regt_buying_power": "18000.00",
6312            "options_buying_power": "8000.00",
6313            "non_marginable_buying_power": "8500.25",
6314        }))
6315        .unwrap();
6316        let balances = convert_account_to_balances(&account, &[]);
6317        assert_eq!(balances.len(), 1);
6318        assert_eq!(balances[0].asset, AssetNameExchange::new("USD"));
6319        assert_eq!(balances[0].balance.total, dec!(9000.50), "total is cash");
6320        assert_eq!(
6321            balances[0].balance.free,
6322            dec!(8500.25),
6323            "free is non-marginable buying power when below cash"
6324        );
6325        assert_eq!(balances[0].balance.margin, None);
6326    }
6327
6328    /// Long marginable stock: non-marginable buying power is equity less initial margin, which
6329    /// exceeds cash, so `free` is capped at cash.
6330    #[test]
6331    fn test_convert_account_to_balances_free_capped_at_cash_with_long_stock() {
6332        let account: AlpacaAccount = serde_json::from_value(serde_json::json!({
6333            "cash": "50000",
6334            "equity": "100000",
6335            "initial_margin": "25000",
6336            "non_marginable_buying_power": "75000",
6337        }))
6338        .unwrap();
6339        let balances = convert_account_to_balances(&account, &[]);
6340        assert_eq!(balances[0].balance.total, dec!(50000));
6341        assert_eq!(balances[0].balance.free, dec!(50000));
6342    }
6343
6344    /// A short sale credits its proceeds to cash but lowers non-marginable buying power, so
6345    /// `free` is the buying power, below `total`.
6346    #[test]
6347    fn test_convert_account_to_balances_open_short_keeps_free_below_cash() {
6348        let account: AlpacaAccount = serde_json::from_value(serde_json::json!({
6349            "cash": "10200",
6350            "equity": "10000",
6351            "initial_margin": "100",
6352            "non_marginable_buying_power": "9900",
6353        }))
6354        .unwrap();
6355        let balances = convert_account_to_balances(&account, &[]);
6356        assert_eq!(balances[0].balance.total, dec!(10200));
6357        assert_eq!(balances[0].balance.free, dec!(9900));
6358    }
6359
6360    #[test]
6361    fn test_convert_account_to_balances_negative_cash_on_margin() {
6362        let account: AlpacaAccount = serde_json::from_value(serde_json::json!({
6363            "cash": "-2500.75",
6364            "equity": "7500.00",
6365            "buying_power": "5000.00",
6366            "non_marginable_buying_power": "1250.00",
6367        }))
6368        .unwrap();
6369        let balances = convert_account_to_balances(&account, &[]);
6370        assert_eq!(balances.len(), 1);
6371        assert_eq!(balances[0].balance.total, dec!(-2500.75));
6372        assert_eq!(
6373            balances[0].balance.free,
6374            dec!(-2500.75),
6375            "no free cash while borrowing; free <= total still holds"
6376        );
6377    }
6378
6379    #[test]
6380    fn test_alpaca_account_malformed_cash_fails_to_decode() {
6381        let result = serde_json::from_value::<AlpacaAccount>(serde_json::json!({
6382            "cash": "not-a-number",
6383            "non_marginable_buying_power": "0",
6384        }));
6385        assert!(result.is_err(), "a malformed cash must not read as zero");
6386    }
6387
6388    #[test]
6389    fn test_convert_account_to_balances_usd_filter() {
6390        let account = AlpacaAccount {
6391            cash: dec!(12000.00),
6392            non_marginable_buying_power: dec!(10000.00),
6393        };
6394        let usd = vec![AssetNameExchange::new("USD")];
6395        let balances = convert_account_to_balances(&account, &usd);
6396        assert_eq!(balances.len(), 1);
6397        assert_eq!(balances[0].asset, AssetNameExchange::new("USD"));
6398
6399        let non_usd = vec![AssetNameExchange::new("BTC")];
6400        let balances = convert_account_to_balances(&account, &non_usd);
6401        assert!(balances.is_empty());
6402    }
6403
6404    /// A caller's filter is matched ignoring case, but the balance keeps Alpaca's spelling, the
6405    /// one every USD fee is reported under too.
6406    #[test]
6407    fn a_usd_balance_is_named_usd_however_the_filter_spells_it() {
6408        let account = AlpacaAccount {
6409            cash: dec!(12000.00),
6410            non_marginable_buying_power: dec!(10000.00),
6411        };
6412        let balances = convert_account_to_balances(&account, &[AssetNameExchange::new("usd")]);
6413        assert_eq!(balances.len(), 1);
6414        assert_eq!(balances[0].asset, AssetNameExchange::new("USD"));
6415    }
6416
6417    #[test]
6418    fn test_is_options_or_equity_symbol() {
6419        // Crypto symbols contain '/'
6420        assert!(!is_options_or_equity_symbol("BTC/USD"));
6421        assert!(!is_options_or_equity_symbol("ETH/USD"));
6422        assert!(!is_options_or_equity_symbol("SOL/USD"));
6423
6424        // Equity symbols — no slash
6425        assert!(is_options_or_equity_symbol("AAPL"));
6426        assert!(is_options_or_equity_symbol("SPY"));
6427        assert!(is_options_or_equity_symbol("MSFT"));
6428
6429        // OCC option symbols — no slash
6430        assert!(is_options_or_equity_symbol("SPY250418C00450000"));
6431        assert!(is_options_or_equity_symbol("AAPL250418P00145000"));
6432    }
6433
6434    #[test]
6435    fn test_parse_order_error_already_cancelled() {
6436        // Locks in match arm ordering: a 422 with "already" but NOT "insufficient"
6437        // must map to OrderAlreadyCancelled, not BalanceInsufficient.
6438        assert!(matches!(
6439            parse_order_error(
6440                reqwest::StatusCode::UNPROCESSABLE_ENTITY,
6441                None,
6442                "order is already cancelled"
6443            ),
6444            UnindexedOrderError::Rejected(ApiError::OrderAlreadyCancelled)
6445        ));
6446    }
6447
6448    #[test]
6449    fn test_parse_order_error_already_wins_over_insufficient_on_422() {
6450        // If Alpaca sends a body containing both "already" and "insufficient",
6451        // the "already" arm must win (it appears first in the match).
6452        assert!(matches!(
6453            parse_order_error(
6454                reqwest::StatusCode::UNPROCESSABLE_ENTITY,
6455                None,
6456                "order already cancelled due to insufficient margin"
6457            ),
6458            UnindexedOrderError::Rejected(ApiError::OrderAlreadyCancelled)
6459        ));
6460    }
6461
6462    fn make_order_ws<'a>(
6463        id: &str,
6464        symbol: &str,
6465        side: &str,
6466        filled_qty: &'a str,
6467    ) -> AlpacaOrderWs<'a> {
6468        AlpacaOrderWs {
6469            id: SmolStr::new(id),
6470            client_order_id: None,
6471            symbol: SmolStr::new(symbol),
6472            qty: Some("2"),
6473            filled_qty: Some(filled_qty),
6474            side: SmolStr::new(side),
6475            order_type: SmolStr::new("limit"),
6476            time_in_force: SmolStr::new("day"),
6477            limit_price: Some("100.00"),
6478            stop_price: None,
6479            trail_percent: None,
6480            trail_price: None,
6481            status: SmolStr::new("partially_filled"),
6482        }
6483    }
6484
6485    /// The one event produced by a frame that maps to a single `AccountEvent`.
6486    ///
6487    /// Asserts the second slot is empty, so a test written for a single-event frame fails
6488    /// loudly if that frame ever starts producing two.
6489    fn sole_event(events: [Option<UnindexedAccountEvent>; 2]) -> Option<UnindexedAccountEvent> {
6490        let [first, second] = events;
6491        assert!(
6492            second.is_none(),
6493            "expected a single event, got a second: {second:?}"
6494        );
6495        first
6496    }
6497
6498    /// The `(Trade, OrderSnapshot)` pair a fill frame must produce, in that order.
6499    fn fill_events(
6500        events: [Option<UnindexedAccountEvent>; 2],
6501    ) -> (
6502        Trade<AssetNameExchange, InstrumentNameExchange>,
6503        crate::order::Order<
6504            ExchangeId,
6505            InstrumentNameExchange,
6506            OrderState<AssetNameExchange, InstrumentNameExchange>,
6507        >,
6508    ) {
6509        let [first, second] = events;
6510        let first = first.expect("a fill frame must produce an execution");
6511        let second = second.expect("a fill frame must produce an order snapshot");
6512        let AccountEventKind::Trade(trade) = first.kind else {
6513            panic!("first event must be the Trade, got {:?}", first.kind);
6514        };
6515        let AccountEventKind::OrderSnapshot(rustrade_integration::collection::snapshot::Snapshot(
6516            order,
6517        )) = second.kind
6518        else {
6519            panic!(
6520                "second event must be the OrderSnapshot, got {:?}",
6521                second.kind
6522            );
6523        };
6524        (trade, order)
6525    }
6526
6527    /// A fill carries the client order id its order was placed with, so a consumer can attribute
6528    /// it before any response names the order.
6529    #[test]
6530    fn test_convert_trade_update_fill_carries_the_client_order_id() {
6531        let mut order = make_order_ws("ord-1", "SPY", "buy", "1");
6532        order.client_order_id = Some(SmolStr::new("outside-1"));
6533        let update = AlpacaTradeUpdate {
6534            event: SmolStr::new("fill"),
6535            execution_id: Some("exec-1"),
6536            previous_execution_id: None,
6537            order,
6538            price: Some("150.00"),
6539            qty: Some("1"),
6540            timestamp: Some("2025-04-18T14:30:00Z"),
6541        };
6542        let (trade, _order) = fill_events(convert_trade_update(update));
6543        assert_eq!(trade.client_order_id, Some(ClientOrderId::new("outside-1")));
6544    }
6545
6546    #[test]
6547    fn test_convert_trade_update_fill_produces_trade_with_execution_id() {
6548        let update = AlpacaTradeUpdate {
6549            event: SmolStr::new("fill"),
6550            execution_id: Some("exec-1"),
6551            previous_execution_id: None,
6552            order: make_order_ws("ord-1", "SPY", "buy", "1"),
6553            price: Some("150.00"),
6554            qty: Some("1"),
6555            timestamp: Some("2025-04-18T14:30:00Z"),
6556        };
6557        let (trade, order) = fill_events(convert_trade_update(update));
6558        // The venue's execution id, which the fill's FILL activity carries too.
6559        assert_eq!(trade.id.0.as_str(), "exec-1");
6560        assert_eq!(
6561            trade.client_order_id, None,
6562            "the update names no client order id"
6563        );
6564        assert_eq!(trade.price, Decimal::from_str("150.00").unwrap());
6565        assert_eq!(trade.quantity, Decimal::from_str("1").unwrap());
6566
6567        // The execution print alone leaves the order reading as untouched. The snapshot carries
6568        // the cumulative filled quantity, which is the only way it reaches engine state.
6569        let OrderState::Active(crate::order::state::ActiveOrderState::Open(open)) = order.state
6570        else {
6571            panic!("expected an Open snapshot, got {:?}", order.state);
6572        };
6573        assert_eq!(open.filled_quantity, Decimal::from_str("1").unwrap());
6574        assert_eq!(open.id.assigned().map(|id| id.0.as_str()), Some("ord-1"));
6575    }
6576
6577    #[test]
6578    fn test_convert_trade_update_partial_fill() {
6579        let update = AlpacaTradeUpdate {
6580            event: SmolStr::new("partial_fill"),
6581            execution_id: None,
6582            previous_execution_id: None,
6583            order: make_order_ws("ord-2", "AAPL", "sell", "0.5"),
6584            price: Some("200.00"),
6585            qty: Some("0.5"),
6586            timestamp: None,
6587        };
6588        let (trade, order) = fill_events(convert_trade_update(update));
6589        assert_eq!(trade.quantity, Decimal::from_str("0.5").unwrap());
6590
6591        // make_order_ws reports qty=2, so 0.5 cumulative leaves 1.5 working: the order stays
6592        // Open, and a consumer reading quantity_remaining now sees 1.5 rather than 2.
6593        let OrderState::Active(crate::order::state::ActiveOrderState::Open(open)) = order.state
6594        else {
6595            panic!("expected an Open snapshot, got {:?}", order.state);
6596        };
6597        assert_eq!(open.filled_quantity, Decimal::from_str("0.5").unwrap());
6598        assert_eq!(
6599            open.quantity_remaining(order.quantity),
6600            Decimal::from_str("1.5").unwrap()
6601        );
6602    }
6603
6604    /// A `fill` that completes the order reports nothing left to fill, which is how the engine
6605    /// learns the order is done. Without the snapshot the order would sit in engine state as a
6606    /// resting order with `filled_quantity` 0 until REST reconciliation refreshed it.
6607    #[test]
6608    fn a_full_fill_snapshot_reports_nothing_left_to_fill() {
6609        let update = AlpacaTradeUpdate {
6610            event: SmolStr::new("fill"),
6611            execution_id: None,
6612            previous_execution_id: None,
6613            // make_order_ws reports qty=2; a cumulative filled of 2 completes it.
6614            order: AlpacaOrderWs {
6615                status: SmolStr::new("filled"),
6616                ..make_order_ws("ord-full", "SPY", "buy", "2")
6617            },
6618            price: Some("150.00"),
6619            qty: Some("1"),
6620            timestamp: Some("2025-04-18T14:30:00Z"),
6621        };
6622        let (_trade, order) = fill_events(convert_trade_update(update));
6623        let OrderState::Active(crate::order::state::ActiveOrderState::Open(open)) = order.state
6624        else {
6625            panic!("expected an Open snapshot, got {:?}", order.state);
6626        };
6627        assert_eq!(open.filled_quantity, Decimal::from_str("2").unwrap());
6628        assert!(
6629            open.quantity_remaining(order.quantity).is_zero(),
6630            "a completed order must report nothing left to fill so the engine retires it"
6631        );
6632    }
6633
6634    /// A fill whose order the exchange no longer reports as working must not be written back into
6635    /// engine state as an `Open` order. The execution still counts -- it moved the position --
6636    /// but resurrecting the order would leave a resting order that does not exist at the venue.
6637    #[test]
6638    fn a_fill_for_an_order_no_longer_working_emits_the_execution_without_a_snapshot() {
6639        for status in [
6640            "canceled",
6641            "expired",
6642            "rejected",
6643            "done_for_day",
6644            "pending_cancel",
6645        ] {
6646            let update = AlpacaTradeUpdate {
6647                event: SmolStr::new("partial_fill"),
6648                execution_id: None,
6649                previous_execution_id: None,
6650                order: AlpacaOrderWs {
6651                    status: SmolStr::new(status),
6652                    ..make_order_ws("ord-late", "SPY", "buy", "1")
6653                },
6654                price: Some("150.00"),
6655                qty: Some("1"),
6656                timestamp: None,
6657            };
6658            let [trade, snapshot] = convert_trade_update(update);
6659            assert!(
6660                matches!(trade.map(|e| e.kind), Some(AccountEventKind::Trade(_))),
6661                "the execution must still be reported for status {status}"
6662            );
6663            assert!(
6664                snapshot.is_none(),
6665                "status {status} must not produce an order snapshot"
6666            );
6667        }
6668    }
6669
6670    /// A notional order (placed by dollar value) carries no `qty`, so there is no quantity to
6671    /// report a remaining amount against. The execution is still reported; the order is not.
6672    #[test]
6673    fn a_notional_order_fill_emits_the_execution_without_a_snapshot() {
6674        let update = AlpacaTradeUpdate {
6675            event: SmolStr::new("partial_fill"),
6676            execution_id: None,
6677            previous_execution_id: None,
6678            order: AlpacaOrderWs {
6679                qty: None,
6680                ..make_order_ws("ord-notional-ws", "SPY", "buy", "1")
6681            },
6682            price: Some("150.00"),
6683            qty: Some("1"),
6684            timestamp: None,
6685        };
6686        let [trade, snapshot] = convert_trade_update(update);
6687        assert!(matches!(
6688            trade.map(|e| e.kind),
6689            Some(AccountEventKind::Trade(_))
6690        ));
6691        assert!(
6692            snapshot.is_none(),
6693            "a notional order has no quantity to snapshot against"
6694        );
6695    }
6696
6697    #[test]
6698    fn test_convert_trade_update_new_order_produces_snapshot() {
6699        let update = AlpacaTradeUpdate {
6700            event: SmolStr::new("new"),
6701            execution_id: None,
6702            previous_execution_id: None,
6703            order: AlpacaOrderWs {
6704                id: SmolStr::new("ord-new"),
6705                client_order_id: Some(SmolStr::new("cid-1")),
6706                symbol: SmolStr::new("AAPL"),
6707                qty: Some("10"),
6708                filled_qty: Some("0"),
6709                side: SmolStr::new("buy"),
6710                order_type: SmolStr::new("limit"),
6711                time_in_force: SmolStr::new("day"),
6712                limit_price: Some("150.00"),
6713                stop_price: None,
6714                trail_percent: None,
6715                trail_price: None,
6716                status: SmolStr::new("new"),
6717            },
6718            price: None,
6719            qty: None,
6720            timestamp: Some("2025-04-18T14:30:00Z"),
6721        };
6722        let event =
6723            sole_event(convert_trade_update(update)).expect("new event should produce an event");
6724        assert!(matches!(event.kind, AccountEventKind::OrderSnapshot(_)));
6725    }
6726
6727    #[test]
6728    fn test_convert_trade_update_canceled_produces_cancel() {
6729        let update = AlpacaTradeUpdate {
6730            event: SmolStr::new("canceled"),
6731            execution_id: None,
6732            previous_execution_id: None,
6733            order: make_order_ws("ord-3", "AAPL", "sell", "0"),
6734            price: None,
6735            qty: None,
6736            timestamp: Some("2025-04-18T14:30:00Z"),
6737        };
6738        let event =
6739            sole_event(convert_trade_update(update)).expect("canceled should produce an event");
6740        let AccountEventKind::OrderCancelled(response) = event.kind else {
6741            panic!("expected OrderCancelled, got {:?}", event.kind);
6742        };
6743        assert!(response.state.is_ok());
6744    }
6745
6746    /// A `canceled` update carries what filled, and an unknown fill stays unknown, not zero.
6747    #[test]
6748    fn a_canceled_update_carries_its_fill_or_none() {
6749        let cancelled = |filled_qty: Option<&'static str>| {
6750            let mut order = make_order_ws("ord-3", "AAPL", "sell", "0");
6751            order.filled_qty = filled_qty;
6752            let update = AlpacaTradeUpdate {
6753                event: SmolStr::new("canceled"),
6754                execution_id: None,
6755                previous_execution_id: None,
6756                order,
6757                price: None,
6758                qty: None,
6759                timestamp: Some("2025-04-18T14:30:00Z"),
6760            };
6761            let event =
6762                sole_event(convert_trade_update(update)).expect("canceled should produce an event");
6763            let AccountEventKind::OrderCancelled(response) = event.kind else {
6764                panic!("expected OrderCancelled, got {:?}", event.kind);
6765            };
6766            let Ok(cancelled) = response.state else {
6767                panic!("expected Ok, got {:?}", response.state);
6768            };
6769            cancelled.filled_quantity
6770        };
6771
6772        assert_eq!(cancelled(Some("1")), Some(Decimal::ONE));
6773        assert_eq!(cancelled(Some("0")), Some(Decimal::ZERO));
6774        assert_eq!(cancelled(None), None);
6775        assert_eq!(cancelled(Some("not a number")), None);
6776    }
6777
6778    /// An ended order whose `filled_qty` does not parse reports its fill unknown, not zero; a
6779    /// filled one filled its whole quantity regardless.
6780    #[test]
6781    fn an_ended_order_with_an_unparseable_fill_reports_it_unknown() {
6782        let key = OrderKey::new(
6783            ExchangeId::AlpacaBroker,
6784            InstrumentNameExchange::new("AAPL"),
6785            StrategyId::new("strategy"),
6786            ClientOrderId::new("c"),
6787        );
6788        let ended = |status: &str| {
6789            let mut order = make_order_response("o1", "AAPL");
6790            order.status = Some(status.to_string());
6791            order.filled_qty = "not a number".to_string();
6792            convert_ended_order(&order, &key).map(|order| order.state)
6793        };
6794
6795        assert!(matches!(
6796            ended("canceled"),
6797            Some(InactiveOrderState::Cancelled(Cancelled {
6798                filled_quantity: None,
6799                ..
6800            }))
6801        ));
6802        assert!(matches!(
6803            ended("expired"),
6804            Some(InactiveOrderState::Expired(Expired {
6805                filled_quantity: None,
6806                ..
6807            }))
6808        ));
6809        assert!(matches!(
6810            ended("filled"),
6811            Some(InactiveOrderState::FullyFilled(Filled { filled_quantity, .. }))
6812                if filled_quantity == Decimal::ONE
6813        ));
6814    }
6815
6816    #[test]
6817    fn test_convert_trade_update_rejected_produces_error() {
6818        let update = AlpacaTradeUpdate {
6819            event: SmolStr::new("rejected"),
6820            execution_id: None,
6821            previous_execution_id: None,
6822            order: make_order_ws("ord-4", "SPY", "buy", "0"),
6823            price: None,
6824            qty: None,
6825            timestamp: None,
6826        };
6827        let event =
6828            sole_event(convert_trade_update(update)).expect("rejected should produce an event");
6829        let AccountEventKind::OrderCancelled(response) = event.kind else {
6830            panic!("expected OrderCancelled, got {:?}", event.kind);
6831        };
6832        assert!(response.state.is_err());
6833    }
6834
6835    #[test]
6836    fn test_convert_open_order_notional_qty_none_is_skipped() {
6837        // qty=None means this is a notional order (placed by dollar value).
6838        // Recording it with quantity=0 would corrupt reconciliation, so it must be skipped.
6839        let order = AlpacaOrderResponse {
6840            id: "ord-notional".to_string(),
6841            client_order_id: None,
6842            symbol: "SPY".to_string(),
6843            qty: None,
6844            filled_qty: "0".to_string(),
6845            filled_avg_price: None,
6846            status: None,
6847            side: "buy".to_string(),
6848            order_type: "market".to_string(),
6849            time_in_force: "day".to_string(),
6850            limit_price: None,
6851            stop_price: None,
6852            trail_percent: None,
6853            trail_price: None,
6854            created_at: "2025-04-18T14:30:00Z".to_string(),
6855            updated_at: None,
6856        };
6857        assert!(convert_open_order(&order).is_none());
6858    }
6859
6860    #[test]
6861    fn test_convert_activity_to_trade_bad_price_returns_none() {
6862        // When price is unparseable, convert_activity_to_trade must return None.
6863        // recover_fills advances cumulative_qty BEFORE this check so that the dedup
6864        // key sequence stays aligned with the WS path even when a fill is skipped.
6865        let activity = AlpacaActivity {
6866            id: "act-1".to_string(),
6867            order_id: "ord-1".to_string(),
6868            symbol: "SPY250418C00450000".to_string(),
6869            side: "buy".to_string(),
6870            price: "not-a-number".to_string(),
6871            qty: "1".to_string(),
6872            transaction_time: "2025-04-18T14:30:00Z".to_string(),
6873            cum_qty: Some("1".to_string()),
6874        };
6875        assert!(convert_activity_to_trade(&activity).is_none());
6876    }
6877
6878    #[test]
6879    fn test_convert_positions_to_balances_crypto() {
6880        let positions = vec![
6881            // The positions list's form: the asset followed by USD.
6882            AlpacaPosition {
6883                qty_available: dec!(0.4),
6884                ..alpaca_position("BTCUSD", "crypto", AlpacaPositionSide::Long, dec!(0.5))
6885            },
6886            // The pair form orders use, read too.
6887            alpaca_position("ETH/USD", "crypto", AlpacaPositionSide::Long, dec!(2.0)),
6888            // Equity positions should be filtered out
6889            alpaca_position("AAPL", "us_equity", AlpacaPositionSide::Long, dec!(10)),
6890        ];
6891
6892        // All crypto assets
6893        let balances = convert_positions_to_balances(&positions, &[]);
6894        assert_eq!(balances.len(), 2, "only crypto positions returned");
6895        assert_eq!(balances[0].asset.name().as_str(), "BTC");
6896        assert_eq!(balances[1].asset.name().as_str(), "ETH");
6897        // total = qty (0.5 BTC), free = qty_available (0.4 BTC)
6898        assert_eq!(balances[0].balance.total, dec!(0.5));
6899        assert_eq!(balances[0].balance.free, dec!(0.4));
6900
6901        // Filter to BTC only: matches the `BTCUSD` position
6902        let btc_only = vec![AssetNameExchange::new("btc")];
6903        let balances = convert_positions_to_balances(&positions, &btc_only);
6904        assert_eq!(balances.len(), 1);
6905        assert_eq!(balances[0].asset.name().as_str(), "BTC");
6906    }
6907
6908    #[test]
6909    fn test_crypto_position_asset() {
6910        assert_eq!(crypto_position_asset("BTCUSD"), "BTC");
6911        assert_eq!(
6912            crypto_position_asset("btcusd"),
6913            "btc",
6914            "suffix matched ignoring case"
6915        );
6916        assert_eq!(
6917            crypto_position_asset("USDTUSD"),
6918            "USDT",
6919            "only one USD is stripped"
6920        );
6921        assert_eq!(crypto_position_asset("BTC/USD"), "BTC");
6922        assert_eq!(crypto_position_asset("BTC/USDT"), "BTC");
6923        assert_eq!(crypto_position_asset("€USD"), "€");
6924        // Neither form, or an empty asset: used whole rather than dropped.
6925        assert_eq!(crypto_position_asset("USD"), "USD");
6926        assert_eq!(crypto_position_asset("/USD"), "/USD");
6927        assert_eq!(crypto_position_asset("BTCUSDT"), "BTCUSDT");
6928        assert_eq!(
6929            crypto_position_asset("BTCێ"),
6930            "BTCێ",
6931            "no char boundary at the suffix"
6932        );
6933        assert_eq!(crypto_position_asset(""), "");
6934    }
6935
6936    /// A position as `/v2/positions` reports it, with `qty_available` equal to `qty`.
6937    fn alpaca_position(
6938        symbol: &str,
6939        asset_class: &str,
6940        side: AlpacaPositionSide,
6941        qty: Decimal,
6942    ) -> AlpacaPosition {
6943        AlpacaPosition {
6944            symbol: symbol.into(),
6945            asset_class: asset_class.into(),
6946            side,
6947            qty,
6948            qty_available: qty,
6949            avg_entry_price: Some(dec!(100)),
6950            unrealized_pl: Some(dec!(0)),
6951        }
6952    }
6953
6954    #[test]
6955    fn test_convert_positions_sign_comes_from_side_not_qty() {
6956        let now = Utc::now();
6957        let positions = vec![
6958            alpaca_position("AAPL", "us_equity", AlpacaPositionSide::Long, dec!(10)),
6959            alpaca_position("TSLA", "us_equity", AlpacaPositionSide::Short, dec!(-3)),
6960            // Short with an unsigned qty still reads as short.
6961            alpaca_position("MSFT", "us_equity", AlpacaPositionSide::Short, dec!(4)),
6962        ];
6963
6964        let converted = convert_positions(&positions, now).unwrap();
6965        let quantities: Vec<_> = converted.iter().map(|(s, p)| (*s, p.quantity)).collect();
6966        assert_eq!(
6967            quantities,
6968            vec![("AAPL", dec!(10)), ("TSLA", dec!(-3)), ("MSFT", dec!(-4))]
6969        );
6970        assert!(converted.iter().all(|(_, p)| p.time_exchange == now));
6971    }
6972
6973    #[test]
6974    fn test_convert_positions_leaves_out_crypto_and_flat() {
6975        let positions = vec![
6976            alpaca_position("BTC/USD", "crypto", AlpacaPositionSide::Long, dec!(0.5)),
6977            alpaca_position("AAPL", "us_equity", AlpacaPositionSide::Long, dec!(0)),
6978            alpaca_position("SPY", "us_equity", AlpacaPositionSide::Long, dec!(1)),
6979        ];
6980
6981        let converted = convert_positions(&positions, Utc::now()).unwrap();
6982        let symbols: Vec<_> = converted.iter().map(|(s, _)| *s).collect();
6983        assert_eq!(symbols, vec!["SPY"]);
6984    }
6985
6986    #[test]
6987    fn test_alpaca_position_decodes_equity_option_and_fractional() {
6988        let positions: Vec<AlpacaPosition> = serde_json::from_value(serde_json::json!([
6989            {
6990                "asset_id": "00000000-0000-0000-0000-000000000001",
6991                "symbol": "XYZ",
6992                "exchange": "NASDAQ",
6993                "asset_class": "us_equity",
6994                "qty": "-7",
6995                "qty_available": "-7",
6996                "side": "short",
6997                "avg_entry_price": "50.25",
6998                "market_value": "-351.75",
6999                "unrealized_pl": "0",
7000            },
7001            {
7002                "symbol": "XYZ271217C00055000",
7003                "asset_class": "us_option",
7004                "qty": "2",
7005                "qty_available": "2",
7006                "side": "long",
7007                "avg_entry_price": "1.35",
7008                "unrealized_pl": "-20",
7009            },
7010            {
7011                "symbol": "ABC",
7012                "asset_class": "us_equity",
7013                "qty": "0.125",
7014                "qty_available": "0.125",
7015                "side": "long",
7016                "avg_entry_price": "80",
7017                "unrealized_pl": null,
7018            },
7019        ]))
7020        .unwrap();
7021
7022        let converted = convert_positions(&positions, Utc::now()).unwrap();
7023        let [
7024            (short_sym, short),
7025            (option_sym, option),
7026            (fraction_sym, fraction),
7027        ] = converted.as_slice()
7028        else {
7029            panic!("expected three positions, got {converted:?}");
7030        };
7031
7032        assert_eq!(*short_sym, "XYZ");
7033        assert_eq!(short.quantity, dec!(-7));
7034        assert_eq!(short.entry_price, Some(dec!(50.25)));
7035        assert!(short.is_short());
7036
7037        assert_eq!(*option_sym, "XYZ271217C00055000");
7038        assert_eq!(option.quantity, dec!(2));
7039        assert_eq!(option.entry_price, Some(dec!(1.35)), "per-share premium");
7040        assert_eq!(option.unrealized_pnl, Some(dec!(-20)));
7041
7042        assert_eq!(*fraction_sym, "ABC");
7043        assert_eq!(fraction.quantity, dec!(0.125));
7044        assert_eq!(fraction.unrealized_pnl, None);
7045
7046        for (_, p) in &converted {
7047            assert_eq!(p.margin_used, None);
7048            assert_eq!(p.liquidation_price, None);
7049            assert_eq!(p.leverage, None);
7050        }
7051    }
7052
7053    /// An unknown or missing side decodes, so the crypto balances in the same response survive,
7054    /// but a position that needs a direction fails rather than guessing one or reading as flat.
7055    #[test]
7056    fn test_alpaca_position_unknown_side_fails_only_where_a_direction_is_needed() {
7057        let positions: Vec<AlpacaPosition> = serde_json::from_value(serde_json::json!([
7058            {
7059                "symbol": "BTC/USD",
7060                "asset_class": "crypto",
7061                "qty": "0.5",
7062                "qty_available": "0.5",
7063            },
7064            {
7065                "symbol": "XYZ",
7066                "asset_class": "us_equity",
7067                "qty": "1",
7068                "qty_available": "1",
7069                "side": "sideways",
7070            },
7071        ]))
7072        .unwrap();
7073        assert_eq!(positions[0].side, AlpacaPositionSide::Unknown);
7074        assert_eq!(positions[1].side, AlpacaPositionSide::Unknown);
7075
7076        let balances = convert_positions_to_balances(&positions, &[]);
7077        assert_eq!(balances.len(), 1);
7078        assert_eq!(balances[0].balance.total, dec!(0.5));
7079
7080        let result = convert_positions(&positions, Utc::now());
7081        assert!(
7082            matches!(&result, Err(UnindexedClientError::Internal(msg)) if msg.contains("XYZ")),
7083            "an unknown side must not guess a direction: {result:?}"
7084        );
7085    }
7086
7087    /// `updated_at` orders an order's states; `created_at` is constant across all of them.
7088    #[test]
7089    fn order_state_time_prefers_updated_at() {
7090        let mut resp = make_order_response("ord-1", "SPY");
7091        resp.created_at = "2025-04-18T14:30:00Z".to_string();
7092        resp.updated_at = Some("2025-04-18T15:45:00Z".to_string());
7093        assert_eq!(
7094            order_state_time(&resp),
7095            parse_timestamp("2025-04-18T15:45:00Z").unwrap(),
7096            "updated_at must win over created_at"
7097        );
7098
7099        // Alpaca's schema makes updated_at nullable, so creation time is the fallback.
7100        resp.updated_at = None;
7101        assert_eq!(
7102            order_state_time(&resp),
7103            parse_timestamp("2025-04-18T14:30:00Z").unwrap(),
7104            "absent updated_at falls back to created_at"
7105        );
7106
7107        // An unparseable updated_at must not silently become `now`, which would order this state
7108        // ahead of every later one.
7109        resp.updated_at = Some("not-a-timestamp".to_string());
7110        assert_eq!(
7111            order_state_time(&resp),
7112            parse_timestamp("2025-04-18T14:30:00Z").unwrap(),
7113            "unparseable updated_at falls back to created_at"
7114        );
7115    }
7116
7117    /// A placement response without a status reads from what filled, as one with a live status
7118    /// does.
7119    #[test]
7120    fn a_placement_response_without_a_status_reads_from_what_filled() {
7121        let instrument = InstrumentNameExchange::new("SPY");
7122        let mut resp = make_order_response("ord-1", "SPY");
7123        resp.filled_qty = "0.4".to_string();
7124        let OrderState::Active(ActiveOrderState::Open(open)) =
7125            placed_order_state(&resp, &instrument, Decimal::ONE)
7126        else {
7127            panic!("expected Open");
7128        };
7129        assert_eq!(open.filled_quantity, Decimal::new(4, 1));
7130
7131        resp.filled_qty = "1".to_string();
7132        resp.filled_avg_price = Some("100.5".to_string());
7133        let state = placed_order_state(&resp, &instrument, Decimal::ONE);
7134        let OrderState::Inactive(InactiveOrderState::FullyFilled(filled)) = state else {
7135            panic!("expected FullyFilled, got {state:?}");
7136        };
7137        assert_eq!(filled.filled_quantity, Decimal::ONE);
7138        assert_eq!(filled.avg_price, Some(Decimal::new(1005, 1)));
7139    }
7140
7141    fn make_order_response(id: &str, symbol: &str) -> AlpacaOrderResponse {
7142        AlpacaOrderResponse {
7143            id: id.to_string(),
7144            client_order_id: None,
7145            symbol: symbol.to_string(),
7146            qty: Some("1".to_string()),
7147            filled_qty: "0".to_string(),
7148            filled_avg_price: None,
7149            status: None,
7150            side: "buy".to_string(),
7151            order_type: "limit".to_string(),
7152            time_in_force: "day".to_string(),
7153            limit_price: Some("100.00".to_string()),
7154            stop_price: None,
7155            trail_percent: None,
7156            trail_price: None,
7157            created_at: "2025-04-18T14:30:00Z".to_string(),
7158            updated_at: None,
7159        }
7160    }
7161
7162    #[test]
7163    fn test_build_instrument_snapshots_empty_instruments_returns_only_with_orders() {
7164        let orders = vec![
7165            make_order_response("o1", "AAPL"),
7166            make_order_response("o2", "SPY"),
7167        ];
7168        let snapshots = build_instrument_snapshots(orders, Vec::new(), &[]);
7169        assert_eq!(snapshots.len(), 2);
7170        let symbols: Vec<&str> = snapshots
7171            .iter()
7172            .map(|s| s.instrument.name().as_str())
7173            .collect();
7174        assert!(symbols.contains(&"AAPL"));
7175        assert!(symbols.contains(&"SPY"));
7176    }
7177
7178    #[test]
7179    fn test_build_instrument_snapshots_requested_instrument_no_orders_gets_empty_snapshot() {
7180        // When instruments list is provided, every requested instrument must appear
7181        // even if it has no open orders — callers depend on this for reconciliation.
7182        let orders = vec![make_order_response("o1", "AAPL")];
7183        let instruments = vec![
7184            InstrumentNameExchange::new("AAPL"),
7185            InstrumentNameExchange::new("SPY"),
7186        ];
7187        let snapshots = build_instrument_snapshots(orders, Vec::new(), &instruments);
7188        assert_eq!(snapshots.len(), 2);
7189        let spy = snapshots
7190            .iter()
7191            .find(|s| s.instrument.name().as_str() == "SPY")
7192            .expect("SPY snapshot must be present even with no orders");
7193        assert!(spy.orders.is_empty());
7194    }
7195
7196    /// A notional order: placed by dollar value, so Alpaca reports no `qty`.
7197    fn make_notional_order_response(id: &str, symbol: &str) -> AlpacaOrderResponse {
7198        AlpacaOrderResponse {
7199            qty: None,
7200            ..make_order_response(id, symbol)
7201        }
7202    }
7203
7204    fn snapshot_for<'a>(
7205        snapshots: &'a [InstrumentAccountSnapshot<
7206            ExchangeId,
7207            AssetNameExchange,
7208            InstrumentNameExchange,
7209        >],
7210        symbol: &str,
7211    ) -> &'a InstrumentAccountSnapshot<ExchangeId, AssetNameExchange, InstrumentNameExchange> {
7212        snapshots
7213            .iter()
7214            .find(|snapshot| snapshot.instrument.name().as_str() == symbol)
7215            .unwrap_or_else(|| panic!("no snapshot for {symbol}"))
7216    }
7217
7218    /// Every open order converted, so each list is every order open on the venue, including a
7219    /// requested instrument with none: that entry is what lets the engine see an order that ended
7220    /// while the stream was down is gone.
7221    #[test]
7222    fn test_build_instrument_snapshots_declares_complete_lists_complete() {
7223        let instruments = [
7224            InstrumentNameExchange::new("AAPL"),
7225            InstrumentNameExchange::new("SPY"),
7226        ];
7227        for requested in [&instruments[..], &[]] {
7228            let orders = vec![make_order_response("o1", "AAPL")];
7229            let snapshots = build_instrument_snapshots(orders, Vec::new(), requested);
7230            assert!(!snapshots.is_empty());
7231            assert!(snapshots.iter().all(|snapshot| snapshot.orders_complete));
7232        }
7233    }
7234
7235    /// A notional order cannot be represented, so its instrument's list is not every open order
7236    /// and must not say so: the engine would retire that order as absent. Other instruments are
7237    /// unaffected.
7238    #[test]
7239    fn test_build_instrument_snapshots_notional_order_leaves_only_its_instrument_incomplete() {
7240        let orders = vec![
7241            make_order_response("o1", "AAPL"),
7242            make_order_response("o2", "SPY"),
7243            make_notional_order_response("o3", "SPY"),
7244        ];
7245        let instruments = [
7246            InstrumentNameExchange::new("AAPL"),
7247            InstrumentNameExchange::new("SPY"),
7248        ];
7249        let snapshots = build_instrument_snapshots(orders, Vec::new(), &instruments);
7250
7251        assert!(snapshot_for(&snapshots, "AAPL").orders_complete);
7252        let spy = snapshot_for(&snapshots, "SPY");
7253        assert!(!spy.orders_complete);
7254        assert_eq!(spy.orders.len(), 1);
7255    }
7256
7257    /// Unfiltered, an instrument whose only open order cannot be represented still gets an entry,
7258    /// one that says its list is not complete.
7259    #[test]
7260    fn test_build_instrument_snapshots_unfiltered_lists_an_instrument_with_only_a_notional_order() {
7261        let orders = vec![make_notional_order_response("o1", "SPY")];
7262        let snapshots = build_instrument_snapshots(orders, Vec::new(), &[]);
7263
7264        let spy = snapshot_for(&snapshots, "SPY");
7265        assert!(!spy.orders_complete);
7266        assert!(spy.orders.is_empty());
7267    }
7268
7269    #[test]
7270    fn test_build_instrument_snapshots_non_requested_instrument_excluded() {
7271        // An instrument with open orders that is NOT in the requested list must
7272        // not appear in the output when the instruments list is non-empty.
7273        let orders = vec![
7274            make_order_response("o1", "AAPL"),
7275            make_order_response("o2", "MSFT"), // not requested
7276        ];
7277        let instruments = vec![InstrumentNameExchange::new("AAPL")];
7278        let snapshots = build_instrument_snapshots(orders, Vec::new(), &instruments);
7279        assert_eq!(snapshots.len(), 1);
7280        assert_eq!(snapshots[0].instrument.name().as_str(), "AAPL");
7281    }
7282
7283    fn position(quantity: Decimal) -> Position {
7284        Position::new(
7285            quantity,
7286            Some(dec!(100)),
7287            None,
7288            None,
7289            None,
7290            None,
7291            None,
7292            Utc::now(),
7293        )
7294    }
7295
7296    #[test]
7297    fn test_build_instrument_snapshots_unfiltered_lists_positions_without_orders() {
7298        let orders = vec![
7299            make_order_response("o1", "AAPL"),
7300            make_order_response("o2", "MSFT"),
7301            make_order_response("o3", "BTC/USD"),
7302        ];
7303        let positions = vec![("AAPL", position(dec!(5))), ("TSLA", position(dec!(-2)))];
7304
7305        let snapshots = build_instrument_snapshots(orders, positions, &[]);
7306        let listed: Vec<_> = snapshots
7307            .iter()
7308            .map(|s| {
7309                (
7310                    s.instrument.name().as_str(),
7311                    s.orders.len(),
7312                    s.position.quantity(),
7313                )
7314            })
7315            .collect();
7316        // An equity with an order but no position is flat; crypto is never a position.
7317        assert_eq!(
7318            listed,
7319            vec![
7320                ("AAPL", 1, Some(dec!(5))),
7321                ("MSFT", 1, Some(Decimal::ZERO)),
7322                ("BTC/USD", 1, None),
7323                ("TSLA", 0, Some(dec!(-2)))
7324            ]
7325        );
7326        assert!(snapshots.iter().all(|s| s.orders_complete));
7327    }
7328
7329    #[test]
7330    fn test_build_instrument_snapshots_filtered_attaches_only_requested_positions() {
7331        let positions = vec![("AAPL", position(dec!(5))), ("TSLA", position(dec!(-2)))];
7332        let instruments = vec![
7333            InstrumentNameExchange::new("tsla"),
7334            InstrumentNameExchange::new("MSFT"),
7335            InstrumentNameExchange::new("BTC/USD"),
7336            InstrumentNameExchange::new("TSLA"),
7337        ];
7338
7339        let snapshots = build_instrument_snapshots(Vec::new(), positions, &instruments);
7340        let listed: Vec<_> = snapshots
7341            .iter()
7342            .map(|s| (s.instrument.name().as_str(), s.position.quantity()))
7343            .collect();
7344        // A requested equity with no position is reported flat, and one requested in another case
7345        // still finds its position; crypto is never a position. A second name for a symbol
7346        // already claimed is unknown, not flat.
7347        assert_eq!(
7348            listed,
7349            vec![
7350                ("tsla", Some(dec!(-2))),
7351                ("MSFT", Some(Decimal::ZERO)),
7352                ("BTC/USD", None),
7353                ("TSLA", None)
7354            ]
7355        );
7356        assert!(!snapshots[3].orders_complete);
7357    }
7358
7359    /// Verifies that the dedup key synthesised by `recover_fills` (REST path) matches
7360    /// the key produced by `convert_trade_update` (WS path) for the same partial fills.
7361    ///
7362    /// This is the critical invariant for cross-source dedup after a reconnect:
7363    /// both paths must produce `"{order_id}:{cumulative_filled_qty}"` for the same fill.
7364    #[test]
7365    fn test_recover_fills_dedup_key_matches_ws_path() {
7366        let order_id = "ord-1";
7367
7368        // WS path: Alpaca sends cumulative filled_qty with each event.
7369        // Two partial fills of 1 lot each → filled_qty "1" then "2".
7370        let ws_keys: Vec<SmolStr> = ["1", "2"]
7371            .iter()
7372            .map(|filled_qty| {
7373                early_dedup_key(&AlpacaTradeUpdate {
7374                    event: SmolStr::new("partial_fill"),
7375                    execution_id: None,
7376                    previous_execution_id: None,
7377                    order: make_order_ws(order_id, "SPY", "buy", filled_qty),
7378                    price: Some("150.00"),
7379                    qty: Some("1"),
7380                    timestamp: None,
7381                })
7382            })
7383            .collect();
7384
7385        // REST path: recover_fills accumulates cumulative qty from per-execution activities.
7386        // Two activities with exec qty "1" each → cumulative 1 then 2.
7387        let mut cumulative = Decimal::ZERO;
7388        let rest_keys: Vec<SmolStr> = ["1", "1"]
7389            .iter()
7390            .map(|exec_qty| {
7391                cumulative += Decimal::from_str(exec_qty).unwrap();
7392                fill_dedup_key(order_id, cumulative)
7393            })
7394            .collect();
7395
7396        assert_eq!(
7397            ws_keys, rest_keys,
7398            "REST recovery dedup keys must match WS path keys for cross-source dedup to work"
7399        );
7400        assert_eq!(ws_keys[0].as_str(), "ord-1:1");
7401        assert_eq!(ws_keys[1].as_str(), "ord-1:2");
7402    }
7403
7404    /// Verifies that `early_dedup_key` produces the same key as the full event path.
7405    ///
7406    /// A fill without an `execution_id` falls back to its dedup key for its `TradeId`, the key the
7407    /// early dedup check reads from the raw WS fields before the event is built.
7408    #[test]
7409    fn a_ws_fill_without_an_execution_id_is_identified_by_its_dedup_key() {
7410        let update = AlpacaTradeUpdate {
7411            event: SmolStr::new("fill"),
7412            execution_id: None,
7413            previous_execution_id: None,
7414            order: make_order_ws("ord-abc", "SPY", "buy", "5"),
7415            price: Some("150.00"),
7416            qty: Some("5"),
7417            timestamp: None,
7418        };
7419
7420        // Early path: extract key before full event construction
7421        let early_key = early_dedup_key(&update);
7422
7423        // A fill frame also carries the order snapshot; the execution is always the first slot.
7424        let [event, _snapshot] = convert_trade_update(update);
7425        let Some(UnindexedAccountEvent {
7426            kind: AccountEventKind::Trade(trade),
7427            ..
7428        }) = event
7429        else {
7430            panic!("fill should produce an execution");
7431        };
7432
7433        assert_eq!(
7434            trade.id.0.as_str(),
7435            early_key.as_str(),
7436            "without an execution_id the TradeId is the dedup key"
7437        );
7438        assert_eq!(early_key.as_str(), "ord-abc:5");
7439    }
7440
7441    /// Verifies that `early_dedup_key` correctly normalizes decimal strings.
7442    ///
7443    /// Alpaca may send "1.00" or "1" for the same fill. Both must produce the same
7444    /// dedup key to avoid false negatives in duplicate detection.
7445    #[test]
7446    fn early_dedup_key_normalizes_decimal_strings() {
7447        // Test with trailing zeros: "1.00" should normalize to "1"
7448        let update1 = AlpacaTradeUpdate {
7449            event: SmolStr::new("fill"),
7450            execution_id: None,
7451            previous_execution_id: None,
7452            order: AlpacaOrderWs {
7453                id: SmolStr::new("ord-x"),
7454                client_order_id: Some(SmolStr::new("cid")),
7455                symbol: SmolStr::new("AAPL"),
7456                qty: Some("10"),
7457                filled_qty: Some("1.00"),
7458                side: SmolStr::new("buy"),
7459                order_type: SmolStr::new("market"),
7460                time_in_force: SmolStr::new("day"),
7461                limit_price: None,
7462                stop_price: None,
7463                trail_percent: None,
7464                trail_price: None,
7465                status: SmolStr::new("filled"),
7466            },
7467            price: Some("100.00"),
7468            qty: Some("10"),
7469            timestamp: None,
7470        };
7471        assert_eq!(early_dedup_key(&update1).as_str(), "ord-x:1");
7472
7473        // Test already normalized: "1" stays "1"
7474        let update2 = AlpacaTradeUpdate {
7475            event: SmolStr::new("fill"),
7476            execution_id: None,
7477            previous_execution_id: None,
7478            order: AlpacaOrderWs {
7479                id: SmolStr::new("ord-x"),
7480                client_order_id: Some(SmolStr::new("cid")),
7481                symbol: SmolStr::new("AAPL"),
7482                qty: Some("10"),
7483                filled_qty: Some("1"),
7484                side: SmolStr::new("buy"),
7485                order_type: SmolStr::new("market"),
7486                time_in_force: SmolStr::new("day"),
7487                limit_price: None,
7488                stop_price: None,
7489                trail_percent: None,
7490                trail_price: None,
7491                status: SmolStr::new("filled"),
7492            },
7493            price: Some("100.00"),
7494            qty: Some("10"),
7495            timestamp: None,
7496        };
7497        assert_eq!(early_dedup_key(&update2).as_str(), "ord-x:1");
7498
7499        // Test single trailing zero: "1.0" normalizes to "1"
7500        let update3 = AlpacaTradeUpdate {
7501            event: SmolStr::new("fill"),
7502            execution_id: None,
7503            previous_execution_id: None,
7504            order: AlpacaOrderWs {
7505                id: SmolStr::new("ord-x"),
7506                client_order_id: Some(SmolStr::new("cid")),
7507                symbol: SmolStr::new("AAPL"),
7508                qty: Some("10"),
7509                filled_qty: Some("1.0"),
7510                side: SmolStr::new("buy"),
7511                order_type: SmolStr::new("market"),
7512                time_in_force: SmolStr::new("day"),
7513                limit_price: None,
7514                stop_price: None,
7515                trail_percent: None,
7516                trail_price: None,
7517                status: SmolStr::new("filled"),
7518            },
7519            price: Some("100.00"),
7520            qty: Some("10"),
7521            timestamp: None,
7522        };
7523        assert_eq!(early_dedup_key(&update3).as_str(), "ord-x:1");
7524    }
7525
7526    /// Regression guard for HIGH-2: a `rejected` event without `filled_qty` in the JSON
7527    /// previously caused the entire AlpacaTradeUpdate to fail deserialization, silently
7528    /// dropping the event. After the fix, `filled_qty` is Option and defaults to None
7529    /// (unwrapped to "0" at use-sites), so the event reaches the `rejected` branch.
7530    #[test]
7531    fn process_ws_text_rejected_event_without_filled_qty_is_not_dropped() {
7532        let (tx, mut rx) = mpsc::unbounded_channel();
7533        let dedup = new_dedup_cache();
7534
7535        // Minimal rejected-event JSON — no `filled_qty` field in the order object.
7536        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"}}}"#;
7537
7538        let known = KnownLiveOrders::shared(ExchangeId::AlpacaBroker);
7539        process_ws_text(json, &tx, &dedup, &known);
7540
7541        // The event must NOT be silently dropped — an OrderCancelled must be emitted.
7542        let event = rx.try_recv()
7543            .expect("rejected event without filled_qty must produce an AccountEvent, not be silently dropped");
7544        assert!(
7545            matches!(event.kind, AccountEventKind::OrderCancelled(_)),
7546            "rejected event must map to OrderCancelled, got: {:?}",
7547            event.kind
7548        );
7549    }
7550
7551    /// A `trade_updates` fill frame as Alpaca sends it: its `execution_id` is the trade's id. One
7552    /// that is null, empty or absent falls back to the dedup key.
7553    #[test]
7554    fn process_ws_text_identifies_a_fill_by_its_execution_id() {
7555        let frame = |execution_id: &str| {
7556            format!(
7557                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"}}}}"#
7558            )
7559        };
7560        let cases = [
7561            (
7562                r#""execution_id":"524b1902-817e-446c-825b-a9fcfebbc17e","#,
7563                "524b1902-817e-446c-825b-a9fcfebbc17e",
7564            ),
7565            (r#""execution_id":null,"#, "ord-1:2"),
7566            (r#""execution_id":"","#, "ord-1:2"),
7567            ("", "ord-1:2"),
7568        ];
7569        for (field, expected) in cases {
7570            let (tx, mut rx) = mpsc::unbounded_channel();
7571            let known = KnownLiveOrders::shared(ExchangeId::AlpacaBroker);
7572            process_ws_text(&frame(field), &tx, &new_dedup_cache(), &known);
7573            let Ok(UnindexedAccountEvent {
7574                kind: AccountEventKind::Trade(trade),
7575                ..
7576            }) = rx.try_recv()
7577            else {
7578                panic!("the fill frame {field:?} produces a Trade");
7579            };
7580            assert_eq!(trade.id.0.as_str(), expected, "frame {field:?}");
7581        }
7582    }
7583
7584    /// A `trade_bust` or `trade_correct` frame for `ord-1` (a buy of SPY), with `fields` spliced
7585    /// in. Its shape follows the Broker API's `TradeUpdateEventV2`; no real frame has been seen.
7586    fn amendment_frame(event: &str, fields: &str) -> String {
7587        format!(
7588            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"}}}}"#
7589        )
7590    }
7591
7592    /// The events the stream sends for `frames`, in order.
7593    fn stream_events(frames: &[String]) -> Vec<UnindexedAccountEvent> {
7594        let (tx, mut rx) = mpsc::unbounded_channel();
7595        let dedup = new_dedup_cache();
7596        let known = KnownLiveOrders::shared(ExchangeId::AlpacaBroker);
7597        for frame in frames {
7598            process_ws_text(frame, &tx, &dedup, &known);
7599        }
7600        std::iter::from_fn(|| rx.try_recv().ok()).collect()
7601    }
7602
7603    /// The one event the stream sends for an amendment frame, as a `TradeAmendment`.
7604    fn amendment_of(
7605        frame: String,
7606    ) -> crate::trade::TradeAmendment<AssetNameExchange, InstrumentNameExchange> {
7607        let events = stream_events(&[frame]);
7608        let [
7609            UnindexedAccountEvent {
7610                kind: AccountEventKind::TradeAmended(amendment),
7611                ..
7612            },
7613        ] = <[_; 1]>::try_from(events).unwrap_or_else(|events| {
7614            panic!("one TradeAmended, got {events:?}");
7615        })
7616        else {
7617            panic!("expected TradeAmended");
7618        };
7619        amendment
7620    }
7621
7622    /// A bust names the fill it cancels, and its quantity, which Alpaca may send as a negative
7623    /// reversal, is read as a magnitude.
7624    #[test]
7625    fn process_ws_text_reports_a_trade_bust() {
7626        let amendment = amendment_of(amendment_frame(
7627            "trade_bust",
7628            r#""execution_id":"exec-x","previous_execution_id":"exec-a","qty":"-2","#,
7629        ));
7630        assert_eq!(amendment.original, Some(TradeId::new("exec-a")));
7631        assert_eq!(amendment.order_id, OrderId::new("ord-1"));
7632        assert_eq!(amendment.instrument, InstrumentNameExchange::new("SPY"));
7633        assert_eq!(
7634            Some(amendment.time_exchange),
7635            parse_timestamp("2025-04-18T14:31:00Z")
7636        );
7637        assert_eq!(
7638            amendment.kind,
7639            TradeAmendmentKind::Busted {
7640                quantity: Some(Decimal::TWO)
7641            }
7642        );
7643    }
7644
7645    /// A bust is reported even when it names neither the fill nor a quantity.
7646    #[test]
7647    fn process_ws_text_reports_a_bust_that_names_no_fill() {
7648        let amendment = amendment_of(amendment_frame("trade_bust", ""));
7649        assert_eq!(amendment.original, None);
7650        assert_eq!(
7651            amendment.kind,
7652            TradeAmendmentKind::Busted { quantity: None }
7653        );
7654    }
7655
7656    /// A correction carrying a price and a quantity replaces the fill with a trade under its own
7657    /// execution id, which claims no cumulative.
7658    #[test]
7659    fn process_ws_text_reports_a_trade_correct_with_its_replacement() {
7660        let amendment = amendment_of(amendment_frame(
7661            "trade_correct",
7662            r#""execution_id":"exec-c","previous_execution_id":"exec-a","price":"101.50","qty":"2","#,
7663        ));
7664        assert_eq!(amendment.original, Some(TradeId::new("exec-a")));
7665        let TradeAmendmentKind::Corrected { replacement } = amendment.kind else {
7666            panic!("a full replacement: {:?}", amendment.kind);
7667        };
7668        assert_eq!(replacement.id, TradeId::new("exec-c"));
7669        assert_eq!(replacement.order_id, OrderId::new("ord-1"));
7670        assert_eq!(replacement.side, Side::Buy);
7671        assert_eq!(replacement.price, Decimal::new(10150, 2));
7672        assert_eq!(replacement.quantity, Decimal::TWO);
7673        assert_eq!(replacement.order_filled_quantity, None);
7674        assert_eq!(replacement.time_exchange, amendment.time_exchange);
7675    }
7676
7677    /// A correction lacking anything a replacement trade needs is still reported, with what it
7678    /// carried.
7679    #[test]
7680    fn process_ws_text_reports_a_correction_it_cannot_resolve() {
7681        let price = Some(Decimal::new(10150, 2));
7682        let cases = [
7683            (
7684                r#""execution_id":"exec-c","qty":"2","#,
7685                (Some("exec-c"), None, Some(Decimal::TWO)),
7686            ),
7687            (
7688                r#""price":"101.50","qty":"2","#,
7689                (None, price, Some(Decimal::TWO)),
7690            ),
7691            (
7692                r#""execution_id":"exec-c","price":"101.50","qty":"0","#,
7693                (Some("exec-c"), price, Some(Decimal::ZERO)),
7694            ),
7695            (
7696                r#""execution_id":"exec-c","price":"101.50","#,
7697                (Some("exec-c"), price, None),
7698            ),
7699        ];
7700        for (fields, (id, price, quantity)) in cases {
7701            let amendment = amendment_of(amendment_frame("trade_correct", fields));
7702            assert_eq!(
7703                amendment.kind,
7704                TradeAmendmentKind::CorrectedUnresolved {
7705                    id: id.map(TradeId::new),
7706                    price,
7707                    quantity,
7708                },
7709                "{fields}"
7710            );
7711        }
7712    }
7713
7714    /// After a bust takes an order back, a new fill that returns it to a cumulative an earlier
7715    /// fill reached is delivered, not taken for that fill.
7716    #[test]
7717    fn a_fill_after_a_bust_is_delivered_at_a_cumulative_seen_before() {
7718        let fill = |execution_id: &str| {
7719            format!(
7720                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"}}}}"#
7721            )
7722        };
7723        let events = stream_events(&[
7724            fill("exec-a"),
7725            amendment_frame(
7726                "trade_bust",
7727                r#""execution_id":"exec-x","previous_execution_id":"exec-a","qty":"-2","#,
7728            ),
7729            fill("exec-b"),
7730        ]);
7731
7732        let kinds: Vec<_> = events
7733            .iter()
7734            .filter_map(|event| match &event.kind {
7735                AccountEventKind::Trade(trade) => Some(format!("trade {}", trade.id)),
7736                AccountEventKind::TradeAmended(amendment) => {
7737                    Some(format!("amended {:?}", amendment.original))
7738                }
7739                _ => None,
7740            })
7741            .collect();
7742        assert_eq!(
7743            kinds,
7744            vec![
7745                "trade exec-a",
7746                "amended Some(TradeId(\"exec-a\"))",
7747                "trade exec-b"
7748            ]
7749        );
7750    }
7751
7752    /// Pins the string representation of `Decimal::ZERO.normalize()`, which is used
7753    /// as the dedup key fallback when `filled_qty` is unparseable: `"{order_id}:0"`.
7754    #[test]
7755    fn decimal_zero_normalize_is_zero_str() {
7756        assert_eq!(Decimal::ZERO.normalize().to_string(), "0");
7757    }
7758
7759    /// Verifies that trailing zeros are stripped by `normalize()`, ensuring dedup keys
7760    /// match regardless of whether Alpaca returns `"1.00"` or `"1"` for the same qty.
7761    /// This is the critical invariant for REST/WS dedup key equivalence.
7762    #[test]
7763    fn decimal_normalize_strips_trailing_zeros() {
7764        // Alpaca may return "1.00" in REST but WS cumulative may be "1" — must match.
7765        let from_rest = Decimal::from_str("1.00").unwrap().normalize();
7766        let from_ws = Decimal::from_str("1").unwrap().normalize();
7767        assert_eq!(from_rest.to_string(), from_ws.to_string());
7768        assert_eq!(from_rest.to_string(), "1");
7769
7770        // More edge cases: various trailing zero representations.
7771        assert_eq!(
7772            Decimal::from_str("100.000")
7773                .unwrap()
7774                .normalize()
7775                .to_string(),
7776            "100"
7777        );
7778        assert_eq!(
7779            Decimal::from_str("0.10").unwrap().normalize().to_string(),
7780            "0.1"
7781        );
7782        assert_eq!(
7783            Decimal::from_str("0.100").unwrap().normalize().to_string(),
7784            "0.1"
7785        );
7786    }
7787
7788    // M-2: map_position_intent derives intent from (reduce_only, side).
7789    #[test]
7790    fn map_position_intent_open_buy_maps_to_buy_to_open() {
7791        assert_eq!(
7792            map_position_intent(Side::Buy, ReduceOnly::No),
7793            AlpacaPositionIntent::BuyToOpen
7794        );
7795    }
7796
7797    #[test]
7798    fn map_position_intent_open_sell_maps_to_sell_to_open() {
7799        assert_eq!(
7800            map_position_intent(Side::Sell, ReduceOnly::No),
7801            AlpacaPositionIntent::SellToOpen
7802        );
7803    }
7804
7805    #[test]
7806    fn map_position_intent_reduce_buy_maps_to_buy_to_close() {
7807        assert_eq!(
7808            map_position_intent(Side::Buy, ReduceOnly::Position),
7809            AlpacaPositionIntent::BuyToClose
7810        );
7811    }
7812
7813    #[test]
7814    fn map_position_intent_reduce_sell_maps_to_sell_to_close() {
7815        assert_eq!(
7816            map_position_intent(Side::Sell, ReduceOnly::Position),
7817            AlpacaPositionIntent::SellToClose
7818        );
7819    }
7820
7821    #[test]
7822    fn map_position_intent_maps_a_free_base_close_as_any_other_close() {
7823        assert_eq!(
7824            map_position_intent(Side::Sell, ReduceOnly::PositionAndFreeBase),
7825            AlpacaPositionIntent::SellToClose
7826        );
7827        assert_eq!(
7828            map_position_intent(Side::Buy, ReduceOnly::PositionAndFreeBase),
7829            AlpacaPositionIntent::BuyToClose
7830        );
7831    }
7832
7833    // M-1: parse_order_error — pin all status-code branches not covered by existing tests.
7834    #[test]
7835    fn parse_order_error_401_maps_to_unauthenticated() {
7836        // 401 Unauthorized: invalid/expired API credentials.
7837        assert!(matches!(
7838            parse_order_error(reqwest::StatusCode::UNAUTHORIZED, None, "bad credentials"),
7839            UnindexedOrderError::Rejected(ApiError::Unauthenticated(_))
7840        ));
7841    }
7842
7843    #[test]
7844    fn parse_order_error_403_without_code_maps_to_unauthenticated() {
7845        // Alpaca's answer to a wrong key, secret or host: a bare `{"message": "forbidden."}`.
7846        assert!(matches!(
7847            parse_order_error(reqwest::StatusCode::FORBIDDEN, None, "forbidden."),
7848            UnindexedOrderError::Rejected(ApiError::Unauthenticated(_))
7849        ));
7850    }
7851
7852    #[test]
7853    fn parse_order_error_403_with_code_and_insufficient_maps_to_balance_insufficient() {
7854        for message in [
7855            "insufficient buying power",
7856            "insufficient day trading buying power",
7857            "insufficient qty available for order (requested: 10, available: 0)",
7858        ] {
7859            let err = parse_order_error(reqwest::StatusCode::FORBIDDEN, Some(40310000), message);
7860            let UnindexedOrderError::Rejected(ApiError::BalanceInsufficient(None, text)) = err
7861            else {
7862                panic!("expected BalanceInsufficient for {message:?}, got {err:?}");
7863            };
7864            assert_eq!(text, format!("40310000 {message}"));
7865        }
7866    }
7867
7868    #[test]
7869    fn parse_order_error_reads_a_refused_short_by_its_text() {
7870        for (status, code, message, reason) in [
7871            (
7872                reqwest::StatusCode::FORBIDDEN,
7873                Some(40310000),
7874                "account is not allowed to short",
7875                BorrowRejectReason::BorrowDisabled,
7876            ),
7877            // Which status Alpaca gives a non-shortable asset is unconfirmed, so both are read.
7878            (
7879                reqwest::StatusCode::FORBIDDEN,
7880                Some(40310000),
7881                "asset XYZ cannot be sold short",
7882                BorrowRejectReason::NotShortable,
7883            ),
7884            (
7885                reqwest::StatusCode::UNPROCESSABLE_ENTITY,
7886                Some(42210000),
7887                "asset XYZ cannot be sold short",
7888                BorrowRejectReason::NotShortable,
7889            ),
7890            (
7891                reqwest::StatusCode::UNPROCESSABLE_ENTITY,
7892                None,
7893                "account is not allowed to short",
7894                BorrowRejectReason::BorrowDisabled,
7895            ),
7896        ] {
7897            assert_eq!(
7898                parse_order_error(status, code, message),
7899                UnindexedOrderError::Rejected(ApiError::BorrowRejected(BorrowReject::new(
7900                    reason,
7901                    code.map(|code| code.to_string()),
7902                    message.to_owned(),
7903                ))),
7904                "{status} {message:?}"
7905            );
7906        }
7907
7908        // The asset may be shortable in whole shares: the order's shape was refused, not a
7909        // borrow.
7910        for (status, code) in [
7911            (reqwest::StatusCode::UNPROCESSABLE_ENTITY, 42210000),
7912            (reqwest::StatusCode::FORBIDDEN, 40310000),
7913        ] {
7914            assert_eq!(
7915                parse_order_error(status, Some(code), "fractional orders cannot be sold short"),
7916                UnindexedOrderError::Rejected(ApiError::OrderRejected(format!(
7917                    "{code} fractional orders cannot be sold short"
7918                ))),
7919                "{status}"
7920            );
7921        }
7922
7923        // A refused short wins over "insufficient" in the same text.
7924        assert!(matches!(
7925            parse_order_error(
7926                reqwest::StatusCode::FORBIDDEN,
7927                Some(40310000),
7928                "insufficient qty: account is not allowed to short",
7929            ),
7930            UnindexedOrderError::Rejected(ApiError::BorrowRejected(_))
7931        ));
7932
7933        // Without a code a 403 is a wrong key, whatever its text.
7934        assert!(matches!(
7935            parse_order_error(
7936                reqwest::StatusCode::FORBIDDEN,
7937                None,
7938                "account is not allowed to short"
7939            ),
7940            UnindexedOrderError::Rejected(ApiError::Unauthenticated(_))
7941        ));
7942    }
7943
7944    #[test]
7945    fn parse_order_error_403_with_code_maps_to_order_rejected() {
7946        for (code, message) in [
7947            (40310000, "account is restricted to liquidation only"),
7948            (
7949                40310100,
7950                "trade denied due to pattern day trading protection",
7951            ),
7952        ] {
7953            let err = parse_order_error(reqwest::StatusCode::FORBIDDEN, Some(code), message);
7954            let UnindexedOrderError::Rejected(ApiError::OrderRejected(text)) = err else {
7955                panic!("expected OrderRejected for {message:?}, got {err:?}");
7956            };
7957            assert_eq!(text, format!("{code} {message}"));
7958        }
7959    }
7960
7961    #[test]
7962    fn alpaca_api_error_reads_code_and_falls_back_to_raw_text() {
7963        let coded = AlpacaApiError::from_body(
7964            br#"{"code": 40310000, "message": "insufficient buying power"}"#,
7965        );
7966        assert_eq!(coded.code, Some(40310000));
7967        assert_eq!(coded.message, "insufficient buying power");
7968
7969        let bare = AlpacaApiError::from_body(br#"{"message": "forbidden."}"#);
7970        assert_eq!(bare.code, None);
7971        assert_eq!(bare.message, "forbidden.");
7972
7973        let raw = AlpacaApiError::from_body(b"upstream connect error");
7974        assert_eq!(raw.code, None);
7975        assert_eq!(raw.message, "upstream connect error");
7976    }
7977
7978    #[test]
7979    fn parse_order_error_404_maps_to_order_rejected_with_not_found_prefix() {
7980        let err = parse_order_error(reqwest::StatusCode::NOT_FOUND, None, "order not found");
7981        let UnindexedOrderError::Rejected(ApiError::OrderRejected(msg)) = err else {
7982            panic!("expected OrderRejected, got {err:?}");
7983        };
7984        assert!(
7985            msg.contains("order not found"),
7986            "message should contain 'order not found': {msg}"
7987        );
7988    }
7989
7990    #[test]
7991    fn parse_order_error_422_insufficient_only_maps_to_balance_insufficient() {
7992        // No "already" in body → must not match OrderAlreadyCancelled; must be BalanceInsufficient.
7993        assert!(matches!(
7994            parse_order_error(
7995                reqwest::StatusCode::UNPROCESSABLE_ENTITY,
7996                None,
7997                "insufficient funds for this order"
7998            ),
7999            UnindexedOrderError::Rejected(ApiError::BalanceInsufficient(None, _))
8000        ));
8001    }
8002
8003    #[test]
8004    fn parse_order_error_429_maps_to_rate_limit() {
8005        assert!(matches!(
8006            parse_order_error(reqwest::StatusCode::TOO_MANY_REQUESTS, None, "rate limited"),
8007            UnindexedOrderError::Rejected(ApiError::RateLimit {
8008                retry_after: None,
8009                ..
8010            })
8011        ));
8012    }
8013
8014    #[test]
8015    fn a_rate_limit_delay_is_capped_at_alpacas_window() {
8016        let window = Some(Duration::from_secs(DEFAULT_RATE_LIMIT_DELAY_SECS));
8017        for (name, value) in [("retry-after", u64::MAX), ("x-ratelimit-reset", u64::MAX)] {
8018            let mut headers = reqwest::header::HeaderMap::new();
8019            headers.insert(name, value.to_string().parse().unwrap());
8020            assert_eq!(parse_rate_limit_delay(&headers), window, "{name}");
8021            // Nor does it overflow the cooldown's deadline.
8022            RateLimitTracker::new().on_rate_limited(parse_rate_limit_delay(&headers));
8023        }
8024
8025        let mut headers = reqwest::header::HeaderMap::new();
8026        headers.insert("retry-after", "0".parse().unwrap());
8027        assert_eq!(
8028            parse_rate_limit_delay(&headers),
8029            Some(Duration::ZERO),
8030            "taken as given"
8031        );
8032    }
8033
8034    // M-4: parse_time_in_force — pin the unknown-value fallback to GoodUntilEndOfDay.
8035    // If Alpaca adds a new TIF (e.g. "opg" for at-the-open), orders continue to be
8036    // tracked with EOD expiry until this function is updated; the warn! makes it visible.
8037    #[test]
8038    fn parse_time_in_force_unknown_value_falls_back_to_good_until_end_of_day() {
8039        assert_eq!(
8040            parse_time_in_force("opg"),
8041            TimeInForce::GoodUntilEndOfDay,
8042            "unknown TIF must fall back to GoodUntilEndOfDay (with a warn! in production)"
8043        );
8044    }
8045
8046    // L-4: build_instrument_snapshots — output order must match the instruments slice,
8047    // not the internal IndexMap insertion order of the orders vec.
8048    #[test]
8049    fn build_instrument_snapshots_output_order_matches_instruments_slice() {
8050        // Orders arrive in symbol order: SPY, AAPL, MSFT.
8051        let orders = vec![
8052            make_order_response("o1", "SPY"),
8053            make_order_response("o2", "AAPL"),
8054            make_order_response("o3", "MSFT"),
8055        ];
8056        // Request a different order: MSFT first, then AAPL.
8057        let instruments = vec![
8058            InstrumentNameExchange::new("MSFT"),
8059            InstrumentNameExchange::new("AAPL"),
8060        ];
8061        let snapshots = build_instrument_snapshots(orders, Vec::new(), &instruments);
8062        assert_eq!(snapshots.len(), 2);
8063        assert_eq!(
8064            snapshots[0].instrument.name().as_str(),
8065            "MSFT",
8066            "first snapshot must be MSFT (first in instruments slice)"
8067        );
8068        assert_eq!(
8069            snapshots[1].instrument.name().as_str(),
8070            "AAPL",
8071            "second snapshot must be AAPL (second in instruments slice)"
8072        );
8073    }
8074
8075    // ---------------------------------------------------------------------------
8076    // HTTP-mocked tests — paginate_activities (H-1) and fetch_raw_open_orders (H-3)
8077    // ---------------------------------------------------------------------------
8078    //
8079    // These tests use wiremock to stand up a local HTTP server, verifying the full
8080    // pagination loop and truncation logic without touching the real Alpaca API.
8081    mod http_tests {
8082        use super::super::*;
8083        use wiremock::matchers::{method, path};
8084        use wiremock::{Mock, MockServer, Request, Respond, ResponseTemplate};
8085
8086        /// Serves pre-configured JSON pages in registration order.
8087        ///
8088        /// Each call to `respond` advances an atomic counter and returns the next
8089        /// page body. Panics if called more times than pages were configured —
8090        /// surfaces unexpected extra requests as an explicit test failure rather
8091        /// than silently returning a stale response.
8092        struct Sequential {
8093            call: std::sync::atomic::AtomicU32,
8094            pages: Vec<serde_json::Value>,
8095        }
8096
8097        impl Sequential {
8098            fn new(pages: Vec<serde_json::Value>) -> Self {
8099                Self {
8100                    call: std::sync::atomic::AtomicU32::new(0),
8101                    pages,
8102                }
8103            }
8104        }
8105
8106        impl Respond for Sequential {
8107            fn respond(&self, _: &Request) -> ResponseTemplate {
8108                let i = self.call.fetch_add(1, std::sync::atomic::Ordering::Relaxed) as usize;
8109                let body = self.pages.get(i).unwrap_or_else(|| {
8110                    panic!(
8111                        "Sequential: request #{i} has no configured response \
8112                         (only {} page(s) supplied)",
8113                        self.pages.len()
8114                    )
8115                });
8116                ResponseTemplate::new(200).set_body_json(body)
8117            }
8118        }
8119
8120        /// Build a JSON array of N minimal AlpacaActivity objects with unique IDs.
8121        fn make_activities_json(count: usize, id_prefix: &str) -> serde_json::Value {
8122            serde_json::Value::Array(
8123                (0..count)
8124                    .map(|i| {
8125                        serde_json::json!({
8126                            "id": format!("{id_prefix}-{i:05}"),
8127                            "order_id": "ord-1",
8128                            "symbol": "SPY",
8129                            "side": "buy",
8130                            "price": "100.00",
8131                            "qty": "1",
8132                            "transaction_time": "2025-04-18T14:30:00Z"
8133                        })
8134                    })
8135                    .collect(),
8136            )
8137        }
8138
8139        /// Build a JSON array of N minimal AlpacaOrderResponse objects with unique IDs.
8140        fn make_orders_json(count: usize) -> serde_json::Value {
8141            serde_json::Value::Array(
8142                (0..count)
8143                    .map(|i| {
8144                        serde_json::json!({
8145                            "id": format!("order-{i:05}"),
8146                            "client_order_id": null,
8147                            "symbol": "SPY",
8148                            "qty": "1",
8149                            "filled_qty": "0",
8150                            "side": "buy",
8151                            "type": "limit",
8152                            "time_in_force": "day",
8153                            "limit_price": "100.00",
8154                            "created_at": "2025-04-18T14:30:00Z"
8155                        })
8156                    })
8157                    .collect(),
8158            )
8159        }
8160
8161        // --- H-1: paginate_activities ---
8162
8163        /// Single page with fewer items than the page limit — loop terminates immediately,
8164        /// no further request issued.
8165        #[tokio::test]
8166        async fn paginate_activities_single_page_below_max_returns_all_not_truncated() {
8167            let server = MockServer::start().await;
8168            Mock::given(method("GET"))
8169                .and(path("/v2/account/activities"))
8170                .respond_with(
8171                    ResponseTemplate::new(200).set_body_json(make_activities_json(5, "act")),
8172                )
8173                .mount(&server)
8174                .await;
8175
8176            let http = reqwest::Client::new();
8177            let rl = RateLimitTracker::new();
8178            let result = paginate_activities::<AlpacaActivity>(
8179                &http,
8180                &rl,
8181                &server.uri(),
8182                FILL_ACTIVITIES,
8183                recovery_after(),
8184                None,
8185            )
8186            .await
8187            .unwrap();
8188
8189            assert_eq!(result.activities.len(), 5);
8190            assert_eq!(result.resume, None);
8191            assert_eq!(server.received_requests().await.unwrap().len(), 1);
8192        }
8193
8194        /// First page has exactly ALPACA_MAX_ACTIVITIES items, which triggers a second
8195        /// request. The second page is empty, so the loop terminates without truncation.
8196        #[tokio::test]
8197        async fn paginate_activities_exactly_page_size_items_fetches_second_page() {
8198            let server = MockServer::start().await;
8199            Mock::given(method("GET"))
8200                .and(path("/v2/account/activities"))
8201                .respond_with(Sequential::new(vec![
8202                    make_activities_json(ALPACA_MAX_ACTIVITIES, "act"),
8203                    serde_json::json!([]), // empty second page → loop stops
8204                ]))
8205                .mount(&server)
8206                .await;
8207
8208            let http = reqwest::Client::new();
8209            let rl = RateLimitTracker::new();
8210            let result = paginate_activities::<AlpacaActivity>(
8211                &http,
8212                &rl,
8213                &server.uri(),
8214                FILL_ACTIVITIES,
8215                recovery_after(),
8216                None,
8217            )
8218            .await
8219            .unwrap();
8220
8221            assert_eq!(result.activities.len(), ALPACA_MAX_ACTIVITIES);
8222            assert_eq!(result.resume, None);
8223            assert_eq!(
8224                server.received_requests().await.unwrap().len(),
8225                2,
8226                "exactly 2 requests: first full page + second empty page"
8227            );
8228        }
8229
8230        /// Two-page case: 100 items on page 1, 37 on page 2 — all accumulated,
8231        /// loop terminates on the partial second page without truncation.
8232        #[tokio::test]
8233        async fn paginate_activities_two_pages_returns_combined_activities_not_truncated() {
8234            let server = MockServer::start().await;
8235            Mock::given(method("GET"))
8236                .and(path("/v2/account/activities"))
8237                .respond_with(Sequential::new(vec![
8238                    make_activities_json(ALPACA_MAX_ACTIVITIES, "p1"),
8239                    make_activities_json(37, "p2"),
8240                ]))
8241                .mount(&server)
8242                .await;
8243
8244            let http = reqwest::Client::new();
8245            let rl = RateLimitTracker::new();
8246            let result = paginate_activities::<AlpacaActivity>(
8247                &http,
8248                &rl,
8249                &server.uri(),
8250                FILL_ACTIVITIES,
8251                recovery_after(),
8252                None,
8253            )
8254            .await
8255            .unwrap();
8256
8257            assert_eq!(result.activities.len(), ALPACA_MAX_ACTIVITIES + 37);
8258            assert_eq!(result.resume, None);
8259            assert_eq!(server.received_requests().await.unwrap().len(), 2);
8260        }
8261
8262        /// When every page is full the loop runs until MAX_ACTIVITY_PAGES pages have been
8263        /// fetched, then sets truncated=true and stops. Exactly MAX_ACTIVITY_PAGES HTTP
8264        /// requests are issued (the truncation guard fires before the (N+1)th call).
8265        #[tokio::test]
8266        async fn paginate_activities_at_max_pages_sets_truncated_true() {
8267            let server = MockServer::start().await;
8268
8269            // Always return a full page — the loop must enforce the cap itself.
8270            Mock::given(method("GET"))
8271                .and(path("/v2/account/activities"))
8272                .respond_with(
8273                    ResponseTemplate::new(200)
8274                        .set_body_json(make_activities_json(ALPACA_MAX_ACTIVITIES, "act")),
8275                )
8276                .mount(&server)
8277                .await;
8278
8279            let http = reqwest::Client::new();
8280            let rl = RateLimitTracker::new();
8281            let result = paginate_activities::<AlpacaActivity>(
8282                &http,
8283                &rl,
8284                &server.uri(),
8285                FILL_ACTIVITIES,
8286                recovery_after(),
8287                None,
8288            )
8289            .await
8290            .unwrap();
8291
8292            assert!(
8293                result.resume.is_some(),
8294                "must say where to read on after MAX_ACTIVITY_PAGES pages"
8295            );
8296            assert_eq!(
8297                result.activities.len(),
8298                MAX_ACTIVITY_PAGES * ALPACA_MAX_ACTIVITIES,
8299                "must accumulate exactly MAX_ACTIVITY_PAGES * page_size activities"
8300            );
8301            // The truncation check fires at the top of the loop when pages == MAX_ACTIVITY_PAGES,
8302            // before the (MAX_ACTIVITY_PAGES+1)th request would be issued.
8303            assert_eq!(
8304                server.received_requests().await.unwrap().len(),
8305                MAX_ACTIVITY_PAGES,
8306                "loop must issue exactly MAX_ACTIVITY_PAGES requests then stop"
8307            );
8308        }
8309
8310        // --- H-3: fetch_raw_open_orders truncation boundary ---
8311
8312        /// 499 orders (one below MAX_OPEN_ORDERS) is not truncated — returns Ok.
8313        #[tokio::test]
8314        async fn fetch_raw_open_orders_499_results_returns_ok() {
8315            let server = MockServer::start().await;
8316            Mock::given(method("GET"))
8317                .and(path("/v2/orders"))
8318                .respond_with(
8319                    ResponseTemplate::new(200).set_body_json(make_orders_json(MAX_OPEN_ORDERS - 1)),
8320                )
8321                .mount(&server)
8322                .await;
8323
8324            let http = reqwest::Client::new();
8325            let rl = RateLimitTracker::new();
8326            let result = fetch_raw_open_orders(&http, &rl, &server.uri(), &[]).await;
8327
8328            assert!(
8329                result.is_ok(),
8330                "499 orders must not trigger truncation: {result:?}"
8331            );
8332            assert_eq!(result.unwrap().len(), MAX_OPEN_ORDERS - 1);
8333        }
8334
8335        /// Exactly MAX_OPEN_ORDERS results triggers TruncatedSnapshot because Alpaca's
8336        /// API cap means the response is likely incomplete. An off-by-one here would
8337        /// either silently corrupt OMS state or incorrectly reject a valid account.
8338        #[tokio::test]
8339        async fn fetch_raw_open_orders_500_results_returns_truncated_snapshot_error() {
8340            let server = MockServer::start().await;
8341            Mock::given(method("GET"))
8342                .and(path("/v2/orders"))
8343                .respond_with(
8344                    ResponseTemplate::new(200).set_body_json(make_orders_json(MAX_OPEN_ORDERS)),
8345                )
8346                .mount(&server)
8347                .await;
8348
8349            let http = reqwest::Client::new();
8350            let rl = RateLimitTracker::new();
8351            let result = fetch_raw_open_orders(&http, &rl, &server.uri(), &[]).await;
8352
8353            assert!(
8354                matches!(
8355                    result,
8356                    Err(UnindexedClientError::TruncatedSnapshot { limit }) if limit == MAX_OPEN_ORDERS
8357                ),
8358                "500 orders must return TruncatedSnapshot, got: {result:?}"
8359            );
8360        }
8361
8362        // --- account_snapshot: cash balance, equity/option positions, crypto balances ---
8363
8364        /// Mounts `/v2/account`, `/v2/positions` and an empty `/v2/orders` on `server`.
8365        /// Synthetic values only; no provider data.
8366        async fn mount_account_and_positions(server: &MockServer) {
8367            Mock::given(method("GET"))
8368                .and(path("/v2/account"))
8369                .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
8370                    "status": "ACTIVE",
8371                    "currency": "USD",
8372                    "cash": "-1500.50",
8373                    "equity": "4000.00",
8374                    "buying_power": "5000.00",
8375                    "options_buying_power": "2000.00",
8376                    "non_marginable_buying_power": "1000.00",
8377                })))
8378                .mount(server)
8379                .await;
8380            Mock::given(method("GET"))
8381                .and(path("/v2/positions"))
8382                .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([
8383                    {
8384                        "symbol": "XYZ",
8385                        "asset_class": "us_equity",
8386                        "side": "short",
8387                        "qty": "-10",
8388                        "qty_available": "-10",
8389                        "avg_entry_price": "20.00",
8390                        "unrealized_pl": "15.50",
8391                    },
8392                    {
8393                        "symbol": "XYZ271217C00025000",
8394                        "asset_class": "us_option",
8395                        "side": "long",
8396                        "qty": "3",
8397                        "qty_available": "3",
8398                        "avg_entry_price": "0.85",
8399                        "unrealized_pl": "-45",
8400                    },
8401                    {
8402                        "symbol": "ABC",
8403                        "asset_class": "us_equity",
8404                        "side": "long",
8405                        "qty": "2.5",
8406                        "qty_available": "2.5",
8407                        "avg_entry_price": "40.00",
8408                        "unrealized_pl": "1.25",
8409                    },
8410                    {
8411                        "symbol": "BTCUSD",
8412                        "asset_class": "crypto",
8413                        "side": "long",
8414                        "qty": "0.75",
8415                        "qty_available": "0.5",
8416                        "avg_entry_price": "50000",
8417                        "unrealized_pl": "0",
8418                    },
8419                ])))
8420                .mount(server)
8421                .await;
8422            Mock::given(method("GET"))
8423                .and(path("/v2/orders"))
8424                .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([])))
8425                .mount(server)
8426                .await;
8427        }
8428
8429        fn client_for(server: &MockServer) -> AlpacaClient {
8430            AlpacaClient::new(AlpacaConfig::with_base_url(
8431                "test-key".into(),
8432                "test-secret".into(),
8433                server.uri(),
8434            ))
8435        }
8436
8437        #[tokio::test]
8438        async fn account_snapshot_reports_cash_positions_and_crypto_balances() {
8439            use crate::client::ExecutionClient;
8440            use rust_decimal_macros::dec;
8441
8442            let server = MockServer::start().await;
8443            mount_account_and_positions(&server).await;
8444
8445            let snapshot = client_for(&server)
8446                .account_snapshot(&[], &[])
8447                .await
8448                .unwrap();
8449
8450            // USD: cash (negative on margin), and free capped at it, not equity or buying power.
8451            let balances: Vec<_> = snapshot
8452                .balances
8453                .iter()
8454                .map(|b| (b.asset.name().as_str(), b.balance.total, b.balance.free))
8455                .collect();
8456            assert_eq!(
8457                balances,
8458                vec![
8459                    ("USD", dec!(-1500.50), dec!(-1500.50)),
8460                    ("BTC", dec!(0.75), dec!(0.5)),
8461                ],
8462                "crypto stays a balance; equities and options do not appear as balances"
8463            );
8464
8465            let positions: Vec<_> = snapshot
8466                .instruments
8467                .iter()
8468                .map(|i| {
8469                    let p = i.position.open().unwrap();
8470                    (
8471                        i.instrument.name().as_str(),
8472                        p.quantity,
8473                        p.entry_price,
8474                        p.unrealized_pnl,
8475                    )
8476                })
8477                .collect();
8478            assert_eq!(
8479                positions,
8480                vec![
8481                    ("XYZ", dec!(-10), Some(dec!(20.00)), Some(dec!(15.50))),
8482                    (
8483                        "XYZ271217C00025000",
8484                        dec!(3),
8485                        Some(dec!(0.85)),
8486                        Some(dec!(-45))
8487                    ),
8488                    ("ABC", dec!(2.5), Some(dec!(40.00)), Some(dec!(1.25))),
8489                ],
8490                "short equity, option and fractional positions; no crypto position"
8491            );
8492        }
8493
8494        /// A USD-only request still fetches positions, since they back the instrument
8495        /// snapshots, and reports no crypto balance.
8496        #[tokio::test]
8497        async fn account_snapshot_usd_only_still_reports_positions() {
8498            use crate::client::ExecutionClient;
8499            use rust_decimal_macros::dec;
8500
8501            let server = MockServer::start().await;
8502            mount_account_and_positions(&server).await;
8503
8504            let usd = [AssetNameExchange::new("USD")];
8505            let instruments = [InstrumentNameExchange::new("XYZ")];
8506            let snapshot = client_for(&server)
8507                .account_snapshot(&usd, &instruments)
8508                .await
8509                .unwrap();
8510
8511            assert_eq!(snapshot.balances.len(), 1);
8512            assert_eq!(snapshot.balances[0].asset, AssetNameExchange::new("USD"));
8513            assert_eq!(snapshot.balances[0].balance.total, dec!(-1500.50));
8514
8515            assert_eq!(snapshot.instruments.len(), 1);
8516            assert_eq!(snapshot.instruments[0].position.quantity(), Some(dec!(-10)));
8517        }
8518
8519        /// A non-USD request skips `/v2/account` entirely.
8520        #[tokio::test]
8521        async fn account_snapshot_non_usd_only_skips_account_request() {
8522            use crate::client::ExecutionClient;
8523
8524            let server = MockServer::start().await;
8525            mount_account_and_positions(&server).await;
8526
8527            let btc = [AssetNameExchange::new("btc")];
8528            let snapshot = client_for(&server)
8529                .account_snapshot(&btc, &[])
8530                .await
8531                .unwrap();
8532
8533            assert_eq!(snapshot.balances.len(), 1);
8534            assert_eq!(snapshot.balances[0].asset, AssetNameExchange::new("BTC"));
8535            let requests = server.received_requests().await.unwrap();
8536            assert!(
8537                requests.iter().all(|r| r.url.path() != "/v2/account"),
8538                "no USD requested, so /v2/account must not be fetched"
8539            );
8540        }
8541
8542        // --- L-2: open_order passes reduce_only through to position_intent ---
8543
8544        /// Margin is account-level at Alpaca, so a request naming a margin effect is refused
8545        /// unsent, `NoSideEffect` included: nothing here can promise it.
8546        #[tokio::test]
8547        async fn open_order_refuses_every_margin_effect_without_sending() {
8548            use crate::client::ExecutionClient;
8549            use crate::error::OrderError;
8550            use crate::margin::MarginEffect;
8551            use crate::order::request::{OrderRequestOpen, RequestOpen};
8552            use crate::order::state::InactiveOrderState;
8553            use crate::order::{
8554                OrderKey, OrderKind, TimeInForce,
8555                id::{ClientOrderId, StrategyId},
8556            };
8557            use rust_decimal::Decimal;
8558            use rustrade_instrument::Side;
8559            use rustrade_instrument::exchange::ExchangeId;
8560            use rustrade_instrument::instrument::name::InstrumentNameExchange;
8561
8562            let server = MockServer::start().await;
8563            let client = client_for(&server);
8564            let instrument = InstrumentNameExchange::new("AAPL");
8565
8566            for effect in [
8567                MarginEffect::NoSideEffect,
8568                MarginEffect::Borrow,
8569                MarginEffect::Repay,
8570            ] {
8571                let order = client
8572                    .open_order(OrderRequestOpen {
8573                        key: OrderKey {
8574                            exchange: ExchangeId::AlpacaBroker,
8575                            instrument: &instrument,
8576                            strategy: StrategyId::new("test-strategy"),
8577                            cid: ClientOrderId::new("test-cid"),
8578                        },
8579                        state: RequestOpen::new(
8580                            Side::Buy,
8581                            None,
8582                            Decimal::new(10, 0),
8583                            OrderKind::Market,
8584                            TimeInForce::ImmediateOrCancel,
8585                        )
8586                        .with_margin_effect(effect),
8587                    })
8588                    .await;
8589
8590                assert!(
8591                    matches!(
8592                        order.state,
8593                        OrderState::Inactive(InactiveOrderState::OpenFailed(
8594                            OrderError::UnsupportedMarginEffect(refused)
8595                        )) if refused == effect
8596                    ),
8597                    "{order:?}"
8598                );
8599            }
8600            assert!(
8601                server.received_requests().await.unwrap().is_empty(),
8602                "a refused order is never sent"
8603            );
8604        }
8605
8606        /// Verifies that `open_order` correctly derives `position_intent` from
8607        /// `reduce_only` and `side`. This test exercises the full path:
8608        /// `open_order` → `map_position_intent` → `open_order_inner` → HTTP request.
8609        ///
8610        /// Uses wiremock to capture the request body and verify position_intent.
8611        #[tokio::test]
8612        async fn open_order_reduce_only_sell_sends_sell_to_close_intent() {
8613            use crate::client::ExecutionClient;
8614            use crate::order::request::{OrderRequestOpen, RequestOpen};
8615            use crate::order::{
8616                OrderKey, OrderKind, TimeInForce,
8617                id::{ClientOrderId, StrategyId},
8618            };
8619            use rust_decimal::Decimal;
8620            use rustrade_instrument::Side;
8621            use rustrade_instrument::exchange::ExchangeId;
8622            use rustrade_instrument::instrument::name::InstrumentNameExchange;
8623            use wiremock::matchers::{method, path};
8624
8625            let server = MockServer::start().await;
8626
8627            // Mock POST /v2/orders to return a valid order response.
8628            // Use a custom responder to capture and verify the request body.
8629            let captured_body = std::sync::Arc::new(parking_lot::Mutex::new(None));
8630            let captured_clone = captured_body.clone();
8631
8632            Mock::given(method("POST"))
8633                .and(path("/v2/orders"))
8634                .respond_with(move |req: &Request| {
8635                    // Capture the request body for later assertion
8636                    let body: serde_json::Value = serde_json::from_slice(&req.body).unwrap();
8637                    *captured_clone.lock() = Some(body);
8638
8639                    ResponseTemplate::new(200).set_body_json(serde_json::json!({
8640                        "id": "test-order-id",
8641                        "client_order_id": "test-cid",
8642                        "symbol": "AAPL",
8643                        "qty": "10",
8644                        "filled_qty": "0",
8645                        "side": "sell",
8646                        "type": "market",
8647                        "time_in_force": "ioc",
8648                        "limit_price": null,
8649                        "created_at": "2025-04-18T14:30:00Z"
8650                    }))
8651                })
8652                .mount(&server)
8653                .await;
8654
8655            // Create client with base_url_override pointing to mock server
8656            let config =
8657                AlpacaConfig::with_base_url("test-key".into(), "test-secret".into(), server.uri());
8658            let client = AlpacaClient::new(config);
8659
8660            // Create a Sell order with reduce_only Position (should map to SellToClose)
8661            let request = OrderRequestOpen {
8662                key: OrderKey {
8663                    exchange: ExchangeId::AlpacaBroker,
8664                    instrument: InstrumentNameExchange::new("AAPL"),
8665                    strategy: StrategyId::new("test-strategy"),
8666                    cid: ClientOrderId::new("test-cid"),
8667                },
8668                state: RequestOpen::new(
8669                    Side::Sell,
8670                    None,
8671                    Decimal::new(10, 0),
8672                    OrderKind::Market,
8673                    TimeInForce::ImmediateOrCancel,
8674                )
8675                .with_reduce_only(ReduceOnly::Position), // This should map to SellToClose
8676            };
8677
8678            // Call open_order (borrows instrument)
8679            let order = client
8680                .open_order(OrderRequestOpen {
8681                    key: OrderKey {
8682                        exchange: request.key.exchange,
8683                        instrument: &request.key.instrument,
8684                        strategy: request.key.strategy.clone(),
8685                        cid: request.key.cid.clone(),
8686                    },
8687                    state: request.state.clone(),
8688                })
8689                .await;
8690
8691            // Verify the order was accepted
8692            assert!(
8693                order.state.is_accepted(),
8694                "order should be accepted: {:?}",
8695                order.state
8696            );
8697
8698            // Verify the request body contained position_intent=sell_to_close
8699            let body = captured_body
8700                .lock()
8701                .take()
8702                .expect("request body should be captured");
8703            assert_eq!(
8704                body.get("position_intent").and_then(|v| v.as_str()),
8705                Some("sell_to_close"),
8706                "reduce_only Position + Side::Sell should produce position_intent=sell_to_close, got: {body}"
8707            );
8708        }
8709
8710        /// Verifies that reduce_only No + Buy produces BuyToOpen intent.
8711        #[tokio::test]
8712        async fn open_order_not_reduce_only_buy_sends_buy_to_open_intent() {
8713            use crate::client::ExecutionClient;
8714            use crate::order::request::{OrderRequestOpen, RequestOpen};
8715            use crate::order::{
8716                OrderKey, OrderKind, TimeInForce,
8717                id::{ClientOrderId, StrategyId},
8718            };
8719            use rust_decimal::Decimal;
8720            use rustrade_instrument::Side;
8721            use rustrade_instrument::exchange::ExchangeId;
8722            use rustrade_instrument::instrument::name::InstrumentNameExchange;
8723
8724            let server = MockServer::start().await;
8725
8726            let captured_body = std::sync::Arc::new(parking_lot::Mutex::new(None));
8727            let captured_clone = captured_body.clone();
8728
8729            Mock::given(method("POST"))
8730                .and(path("/v2/orders"))
8731                .respond_with(move |req: &Request| {
8732                    let body: serde_json::Value = serde_json::from_slice(&req.body).unwrap();
8733                    *captured_clone.lock() = Some(body);
8734
8735                    ResponseTemplate::new(200).set_body_json(serde_json::json!({
8736                        "id": "test-order-id",
8737                        "client_order_id": "test-cid",
8738                        "symbol": "AAPL",
8739                        "qty": "10",
8740                        "filled_qty": "0",
8741                        "side": "buy",
8742                        "type": "market",
8743                        "time_in_force": "ioc",
8744                        "limit_price": null,
8745                        "created_at": "2025-04-18T14:30:00Z"
8746                    }))
8747                })
8748                .mount(&server)
8749                .await;
8750
8751            let config =
8752                AlpacaConfig::with_base_url("test-key".into(), "test-secret".into(), server.uri());
8753            let client = AlpacaClient::new(config);
8754
8755            let instrument = InstrumentNameExchange::new("AAPL");
8756            let request = OrderRequestOpen {
8757                key: OrderKey {
8758                    exchange: ExchangeId::AlpacaBroker,
8759                    instrument: &instrument,
8760                    strategy: StrategyId::new("test-strategy"),
8761                    cid: ClientOrderId::new("test-cid"),
8762                },
8763                // Not reduce_only: this should map to BuyToOpen
8764                state: RequestOpen::new(
8765                    Side::Buy,
8766                    None,
8767                    Decimal::new(10, 0),
8768                    OrderKind::Market,
8769                    TimeInForce::ImmediateOrCancel,
8770                ),
8771            };
8772
8773            let order = client.open_order(request).await;
8774
8775            assert!(
8776                order.state.is_accepted(),
8777                "order should be accepted: {:?}",
8778                order.state
8779            );
8780
8781            let body = captured_body
8782                .lock()
8783                .take()
8784                .expect("request body should be captured");
8785            assert_eq!(
8786                body.get("position_intent").and_then(|v| v.as_str()),
8787                Some("buy_to_open"),
8788                "reduce_only No + Side::Buy should produce position_intent=buy_to_open, got: {body}"
8789            );
8790        }
8791
8792        /// A 2xx body that does not decode is not transient, so a query reports it as `Internal`
8793        /// rather than as connectivity a caller would retry.
8794        #[tokio::test]
8795        async fn rest_with_retry_reports_an_undecodable_2xx_body_as_internal() {
8796            let server = MockServer::start().await;
8797            Mock::given(method("GET"))
8798                .and(path("/v2/account"))
8799                .respond_with(ResponseTemplate::new(200).set_body_string("not json"))
8800                .mount(&server)
8801                .await;
8802
8803            let http = reqwest::Client::new();
8804            let rl = RateLimitTracker::new();
8805            let url = format!("{}/v2/account", server.uri());
8806            let result: Result<serde_json::Value, _> =
8807                rest_with_retry(&rl, || http.get(&url)).await;
8808            assert!(
8809                matches!(result, Err(UnindexedClientError::Internal(_))),
8810                "{result:?}"
8811            );
8812        }
8813
8814        /// Whether `rl` still holds requests back after 50 ms.
8815        async fn holds_back(rl: &RateLimitTracker) -> bool {
8816            tokio::time::timeout(Duration::from_millis(50), rl.wait_if_blocked())
8817                .await
8818                .is_err()
8819        }
8820
8821        /// The rate-limit headers of a response: `remaining`, and a reset 30 s ahead if `reset`.
8822        fn with_rate_limit(
8823            template: ResponseTemplate,
8824            remaining: u32,
8825            reset: bool,
8826        ) -> ResponseTemplate {
8827            let template = template.insert_header("x-ratelimit-remaining", remaining.to_string());
8828            if !reset {
8829                return template;
8830            }
8831            let now_secs = std::time::SystemTime::now()
8832                .duration_since(std::time::UNIX_EPOCH)
8833                .unwrap()
8834                .as_secs();
8835            template.insert_header("x-ratelimit-reset", (now_secs + 30).to_string())
8836        }
8837
8838        /// A response reporting no requests left holds every later request back until the reset;
8839        /// one with requests left, or with no reset to wait for, does not.
8840        #[tokio::test]
8841        async fn rest_with_retry_pauses_once_no_requests_remain() {
8842            for (remaining, reset, pauses) in [(0, true, true), (1, true, false), (0, false, false)]
8843            {
8844                let server = MockServer::start().await;
8845                Mock::given(method("GET"))
8846                    .and(path("/v2/account"))
8847                    .respond_with(with_rate_limit(
8848                        ResponseTemplate::new(200).set_body_json(serde_json::json!({})),
8849                        remaining,
8850                        reset,
8851                    ))
8852                    .mount(&server)
8853                    .await;
8854
8855                let http = reqwest::Client::new();
8856                let rl = RateLimitTracker::new();
8857                let url = format!("{}/v2/account", server.uri());
8858                let _: serde_json::Value = rest_with_retry(&rl, || http.get(&url)).await.unwrap();
8859                assert_eq!(
8860                    holds_back(&rl).await,
8861                    pauses,
8862                    "remaining {remaining}, reset {reset}"
8863                );
8864            }
8865        }
8866
8867        /// A reset further out than Alpaca's one-minute window holds requests back for a minute
8868        /// at most.
8869        #[tokio::test]
8870        async fn a_pause_lasts_a_minute_at_most() {
8871            tokio::time::pause();
8872            let now_secs = std::time::SystemTime::now()
8873                .duration_since(std::time::UNIX_EPOCH)
8874                .unwrap()
8875                .as_secs();
8876            let mut headers = reqwest::header::HeaderMap::new();
8877            headers.insert("x-ratelimit-remaining", "0".parse().unwrap());
8878            headers.insert(
8879                "x-ratelimit-reset",
8880                (now_secs + 3_600).to_string().parse().unwrap(),
8881            );
8882            let rl = RateLimitTracker::new();
8883
8884            observe_rate_limit_remaining(&rl, &headers);
8885            assert!(holds_back(&rl).await);
8886            tokio::time::advance(Duration::from_secs(DEFAULT_RATE_LIMIT_DELAY_SECS)).await;
8887            assert!(!holds_back(&rl).await);
8888        }
8889
8890        /// A cancel's response reporting no requests left holds later requests back too.
8891        #[tokio::test]
8892        async fn rest_delete_with_retry_pauses_once_no_requests_remain() {
8893            let server = MockServer::start().await;
8894            Mock::given(method("DELETE"))
8895                .and(path("/v2/orders/abc"))
8896                .respond_with(with_rate_limit(ResponseTemplate::new(204), 0, true))
8897                .mount(&server)
8898                .await;
8899
8900            let http = reqwest::Client::new();
8901            let rl = RateLimitTracker::new();
8902            let url = format!("{}/v2/orders/abc", server.uri());
8903            rest_delete_with_retry(&rl, || http.delete(&url))
8904                .await
8905                .unwrap();
8906            assert!(holds_back(&rl).await);
8907        }
8908
8909        /// An order Alpaca accepted (2xx) but whose response does not decode may be live, so it
8910        /// fails as status unknown (`Connectivity`), never as a rejection, and does not panic.
8911        #[tokio::test]
8912        async fn open_order_with_an_undecodable_2xx_response_is_status_unknown() {
8913            use crate::client::ExecutionClient;
8914            use crate::error::OrderError;
8915            use crate::order::request::{OrderRequestOpen, RequestOpen};
8916            use crate::order::state::{InactiveOrderState, OrderState};
8917            use crate::order::{
8918                OrderKey, OrderKind, TimeInForce,
8919                id::{ClientOrderId, StrategyId},
8920            };
8921            use rustrade_instrument::Side;
8922            use rustrade_instrument::exchange::ExchangeId;
8923            use rustrade_instrument::instrument::name::InstrumentNameExchange;
8924
8925            let server = MockServer::start().await;
8926            Mock::given(method("POST"))
8927                .and(path("/v2/orders"))
8928                .respond_with(
8929                    ResponseTemplate::new(200).set_body_json(serde_json::json!({ "id": 1 })),
8930                )
8931                .mount(&server)
8932                .await;
8933
8934            let instrument = InstrumentNameExchange::new("AAPL");
8935            let order = client_for(&server)
8936                .open_order(OrderRequestOpen {
8937                    key: OrderKey {
8938                        exchange: ExchangeId::AlpacaBroker,
8939                        instrument: &instrument,
8940                        strategy: StrategyId::new("test-strategy"),
8941                        cid: ClientOrderId::new("test-cid"),
8942                    },
8943                    state: RequestOpen::new(
8944                        Side::Buy,
8945                        None,
8946                        Decimal::new(10, 0),
8947                        OrderKind::Market,
8948                        TimeInForce::ImmediateOrCancel,
8949                    ),
8950                })
8951                .await;
8952
8953            assert!(
8954                matches!(
8955                    order.state,
8956                    OrderState::Inactive(InactiveOrderState::OpenFailed(OrderError::Connectivity(
8957                        _
8958                    )))
8959                ),
8960                "{:?}",
8961                order.state
8962            );
8963        }
8964
8965        /// A 403 refusal, or a 422 refused short, reaches the order's state as the refusal it is,
8966        /// with Alpaca's code, and a 403 without a code (a wrong key) as an authentication failure.
8967        #[tokio::test]
8968        async fn open_order_reports_a_403_by_its_code() {
8969            use crate::client::ExecutionClient;
8970            use crate::error::OrderError;
8971            use crate::order::request::{OrderRequestOpen, RequestOpen};
8972            use crate::order::state::{InactiveOrderState, OrderState};
8973            use crate::order::{
8974                OrderKey, OrderKind, TimeInForce,
8975                id::{ClientOrderId, StrategyId},
8976            };
8977            use rustrade_instrument::Side;
8978            use rustrade_instrument::exchange::ExchangeId;
8979            use rustrade_instrument::instrument::name::InstrumentNameExchange;
8980
8981            async fn open_against(
8982                status: u16,
8983                body: serde_json::Value,
8984            ) -> OrderState<AssetNameExchange, InstrumentNameExchange> {
8985                let server = MockServer::start().await;
8986                Mock::given(method("POST"))
8987                    .and(path("/v2/orders"))
8988                    .respond_with(ResponseTemplate::new(status).set_body_json(body))
8989                    .mount(&server)
8990                    .await;
8991                let instrument = InstrumentNameExchange::new("AAPL");
8992                client_for(&server)
8993                    .open_order(OrderRequestOpen {
8994                        key: OrderKey {
8995                            exchange: ExchangeId::AlpacaBroker,
8996                            instrument: &instrument,
8997                            strategy: StrategyId::new("test-strategy"),
8998                            cid: ClientOrderId::new("test-cid"),
8999                        },
9000                        state: RequestOpen::new(
9001                            Side::Sell,
9002                            None,
9003                            Decimal::new(10, 0),
9004                            OrderKind::Market,
9005                            TimeInForce::ImmediateOrCancel,
9006                        ),
9007                    })
9008                    .await
9009                    .state
9010            }
9011
9012            let rejected =
9013                |state: &OrderState<AssetNameExchange, InstrumentNameExchange>| match state {
9014                    OrderState::Inactive(InactiveOrderState::OpenFailed(OrderError::Rejected(
9015                        e,
9016                    ))) => e.clone(),
9017                    other => panic!("expected a rejection, got {other:?}"),
9018                };
9019
9020            let state = open_against(
9021                403,
9022                serde_json::json!({
9023                    "code": 40310000,
9024                    "message": "insufficient buying power"
9025                }),
9026            )
9027            .await;
9028            assert_eq!(
9029                rejected(&state),
9030                ApiError::BalanceInsufficient(None, "40310000 insufficient buying power".into())
9031            );
9032
9033            let state = open_against(
9034                403,
9035                serde_json::json!({
9036                    "code": 40310000,
9037                    "message": "account is not allowed to short"
9038                }),
9039            )
9040            .await;
9041            assert_eq!(
9042                rejected(&state),
9043                ApiError::BorrowRejected(BorrowReject::new(
9044                    BorrowRejectReason::BorrowDisabled,
9045                    Some("40310000".into()),
9046                    "account is not allowed to short".into(),
9047                ))
9048            );
9049
9050            // Which status Alpaca gives a non-shortable asset is unconfirmed; a 422 is read too.
9051            let state = open_against(
9052                422,
9053                serde_json::json!({
9054                    "code": 42210000,
9055                    "message": "asset XYZ cannot be sold short"
9056                }),
9057            )
9058            .await;
9059            assert_eq!(
9060                rejected(&state),
9061                ApiError::BorrowRejected(BorrowReject::new(
9062                    BorrowRejectReason::NotShortable,
9063                    Some("42210000".into()),
9064                    "asset XYZ cannot be sold short".into(),
9065                ))
9066            );
9067
9068            let state = open_against(403, serde_json::json!({ "message": "forbidden." })).await;
9069            assert!(
9070                matches!(rejected(&state), ApiError::Unauthenticated(_)),
9071                "{state:?}"
9072            );
9073        }
9074
9075        /// The bracket path maps an accepted (2xx) but unreadable response the same way: the
9076        /// parent fails as status unknown (`Connectivity`), never as a rejection.
9077        #[tokio::test]
9078        async fn open_bracket_order_with_an_undecodable_2xx_response_is_status_unknown() {
9079            use crate::error::OrderError;
9080            use crate::order::state::{InactiveOrderState, OrderState};
9081            use rustrade_instrument::instrument::name::InstrumentNameExchange;
9082
9083            let server = MockServer::start().await;
9084            Mock::given(method("POST"))
9085                .and(path("/v2/orders"))
9086                .respond_with(
9087                    ResponseTemplate::new(200).set_body_json(serde_json::json!({ "id": 1 })),
9088                )
9089                .mount(&server)
9090                .await;
9091
9092            let result = client_for(&server)
9093                .open_bracket_order(AlpacaBracketOrderRequest::new(
9094                    InstrumentNameExchange::new("SPY"),
9095                    crate::order::id::StrategyId::new("test"),
9096                    crate::order::id::ClientOrderId::new("test-bracket"),
9097                    Side::Buy,
9098                    Decimal::ONE,
9099                    Decimal::new(100, 0),
9100                    Decimal::new(120, 0),
9101                    Decimal::new(90, 0),
9102                    TimeInForce::GoodUntilCancelled { post_only: false },
9103                ))
9104                .await;
9105
9106            assert!(
9107                matches!(
9108                    result.parent.state,
9109                    OrderState::Inactive(InactiveOrderState::OpenFailed(OrderError::Connectivity(
9110                        _
9111                    )))
9112                ),
9113                "{:?}",
9114                result.parent.state
9115            );
9116        }
9117
9118        // -----------------------------------------------------------------------
9119        // recover_fills — what a recovered fill reports, and how it is keyed
9120        // -----------------------------------------------------------------------
9121        //
9122        // These drive `recover_fills` itself rather than re-deriving its arithmetic in the test.
9123        // A key that is correct in isolation is worth nothing if the function does not produce it.
9124
9125        /// The disconnect anchor the recovery tests read from.
9126        fn recovery_after() -> DateTime<Utc> {
9127            "2025-01-01T00:00:00Z".parse().expect("valid time")
9128        }
9129
9130        /// One FILL activity as Alpaca serves it. `cum_qty` is omitted entirely when `None`, which
9131        /// is how the pre-existing fallback path is reached.
9132        fn activity_json(
9133            id: &str,
9134            order_id: &str,
9135            qty: &str,
9136            cum_qty: Option<&str>,
9137        ) -> serde_json::Value {
9138            let mut v = serde_json::json!({
9139                "id": id,
9140                "order_id": order_id,
9141                "symbol": "SPY",
9142                "side": "buy",
9143                "price": "100.00",
9144                "qty": qty,
9145                "transaction_time": "2025-04-18T14:30:00Z"
9146            });
9147            if let Some(c) = cum_qty {
9148                v["cum_qty"] = serde_json::Value::String(c.to_string());
9149            }
9150            v
9151        }
9152
9153        /// The manager clears its reconnect failures only once a connection outlives a heartbeat
9154        /// window. A connection the server accepts and closes at once is a failure like any
9155        /// other, so the waits between such reconnects grow, and a server doing so to every
9156        /// connection exhausts the budget.
9157        #[tokio::test(start_paused = true)]
9158        async fn the_reconnect_budget_resets_only_once_a_connection_proves_stable() {
9159            use futures::{SinkExt as _, StreamExt as _};
9160            use tokio_tungstenite::tungstenite::Message;
9161
9162            // The third connection receives pings past the heartbeat window; the fourth stays open.
9163            const STABLE: Duration = Duration::from_secs(HEARTBEAT_TIMEOUT_SECS + 3);
9164            const DROPPED: Duration = Duration::from_millis(100);
9165            let lifetime = |n: usize| match n {
9166                0 | 1 => Some(DROPPED),
9167                2 => Some(STABLE),
9168                _ => None,
9169            };
9170
9171            // A WS server that authorises and acknowledges the listen, pings every second so the
9172            // heartbeat holds, and closes the `n`th connection `lifetime(n)` after its listen.
9173            let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
9174            let ws_url = format!("ws://{}", listener.local_addr().unwrap());
9175            let listens = Arc::new(parking_lot::Mutex::new(Vec::new()));
9176            let seen = Arc::clone(&listens);
9177            tokio::spawn(async move {
9178                let mut connections = 0..;
9179                while let Ok((stream, _)) = listener.accept().await {
9180                    // Without it, a frame can wait in the kernel for the client's delayed ACK,
9181                    // which is real time the paused clock does not wait for.
9182                    stream.set_nodelay(true).unwrap();
9183                    let seen = Arc::clone(&seen);
9184                    let lifetime = connections.next().and_then(lifetime);
9185                    tokio::spawn(async move {
9186                        let Ok(mut ws) = tokio_tungstenite::accept_async(stream).await else {
9187                            return;
9188                        };
9189                        for answer in [
9190                            r#"{"stream":"authorization","data":{"status":"authorized","action":"authenticate"}}"#,
9191                            r#"{"stream":"listening","data":{"streams":["trade_updates"]}}"#,
9192                        ] {
9193                            let Some(Ok(_)) = ws.next().await else {
9194                                return;
9195                            };
9196                            if ws.send(Message::text(answer)).await.is_err() {
9197                                return;
9198                            }
9199                        }
9200                        seen.lock().push(tokio::time::Instant::now());
9201                        let closes = async {
9202                            match lifetime {
9203                                Some(after) => tokio::time::sleep(after).await,
9204                                None => std::future::pending().await,
9205                            }
9206                        };
9207                        tokio::pin!(closes);
9208                        let mut pings = tokio::time::interval(Duration::from_secs(1));
9209                        loop {
9210                            tokio::select! {
9211                                () = &mut closes => {
9212                                    let _ = ws.close(None).await;
9213                                    return;
9214                                }
9215                                _ = pings.tick() => {
9216                                    if ws.send(Message::Ping(Default::default())).await.is_err() {
9217                                        return;
9218                                    }
9219                                }
9220                                message = ws.next() => {
9221                                    if !matches!(message, Some(Ok(_))) {
9222                                        return;
9223                                    }
9224                                }
9225                            }
9226                        }
9227                    });
9228                }
9229            });
9230            // REST answers nothing, so each reconnect's recovery fails at once. It is served on
9231            // this runtime, so the paused clock measures the waits exactly.
9232            let rest_url = crate::client::test_http::serve(|_, _| (404, String::new())).await;
9233            let mut config = AlpacaConfig::with_base_url("key".into(), "secret".into(), rest_url);
9234            config.ws_url_override = Some(ws_url);
9235            let (tx, _rx) = mpsc::unbounded_channel();
9236            tokio::spawn(connection_manager(
9237                tx,
9238                new_dedup_cache(),
9239                Arc::new(config),
9240                reqwest::Client::new(),
9241                Arc::new(RateLimitTracker::new()),
9242                KnownLiveOrders::shared(ExchangeId::AlpacaBroker),
9243                Vec::new(),
9244                None,
9245            ));
9246
9247            let deadline = tokio::time::Instant::now() + STABLE + Duration::from_secs(15);
9248            while listens.lock().len() < 4 {
9249                assert!(
9250                    tokio::time::Instant::now() < deadline,
9251                    "{} connections",
9252                    listens.lock().len()
9253                );
9254                tokio::time::sleep(Duration::from_millis(50)).await;
9255            }
9256
9257            // On the paused clock each wait is exact. Not reset by the second connection's
9258            // handshake: its close waits the second backoff. Reset by the third connection's
9259            // stability timer: its close waits the first backoff again, not the third.
9260            let initial = BackoffLimits::ACCOUNT_STREAM.initial;
9261            let waits: Vec<_> = listens.lock().windows(2).map(|w| w[1] - w[0]).collect();
9262            assert_eq!(
9263                waits,
9264                [DROPPED + initial, DROPPED + 2 * initial, STABLE + initial],
9265                "the waits between listens"
9266            );
9267        }
9268
9269        /// A reconnect whose connects all fail waits out every backoff and only then ends the
9270        /// stream, reporting the whole budget as spent.
9271        #[tokio::test(start_paused = true)]
9272        async fn failed_connects_end_the_stream_once_the_budget_is_spent() {
9273            // A port nothing listens on, so every connect fails.
9274            let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
9275            let ws_url = format!("ws://{}", listener.local_addr().unwrap());
9276            drop(listener);
9277            let mut config = AlpacaConfig::paper("key".into(), "secret".into());
9278            config.ws_url_override = Some(ws_url);
9279            let (tx, mut rx) = mpsc::unbounded_channel();
9280            let started = tokio::time::Instant::now();
9281
9282            connection_manager(
9283                tx,
9284                new_dedup_cache(),
9285                Arc::new(config),
9286                reqwest::Client::new(),
9287                Arc::new(RateLimitTracker::new()),
9288                KnownLiveOrders::shared(ExchangeId::AlpacaBroker),
9289                Vec::new(),
9290                None,
9291            )
9292            .await;
9293
9294            let Ok(event) = rx.try_recv() else {
9295                panic!("the stream ended without reporting why");
9296            };
9297            let AccountEventKind::StreamTerminated(
9298                StreamTerminationReason::ReconnectBudgetExhausted {
9299                    attempts,
9300                    last_error,
9301                },
9302            ) = event.kind
9303            else {
9304                panic!("unexpected event: {event:?}");
9305            };
9306            assert_eq!(attempts, BackoffLimits::ACCOUNT_STREAM.max_attempts);
9307            assert!(
9308                !last_error.is_empty(),
9309                "the last connect failure is reported"
9310            );
9311            // 1 + 2 + 4 + 8 + 16 s, then 30 s for each of the five attempts left.
9312            assert!(
9313                started.elapsed() >= Duration::from_secs(181),
9314                "{:?}",
9315                started.elapsed()
9316            );
9317            assert!(rx.try_recv().is_err(), "nothing follows the termination");
9318        }
9319
9320        /// Serve `activities` from a mock and run `recover_fills` against it, returning every
9321        /// event it forwarded.
9322        async fn drive_recover_fills_with(
9323            activities: Vec<serde_json::Value>,
9324            dedup: SharedDedupCache,
9325            known: &SharedKnownLiveOrders,
9326        ) -> Vec<UnindexedAccountEvent> {
9327            let server = MockServer::start().await;
9328            Mock::given(method("GET"))
9329                .and(path("/v2/account/activities"))
9330                .respond_with(
9331                    ResponseTemplate::new(200).set_body_json(serde_json::Value::Array(activities)),
9332                )
9333                .mount(&server)
9334                .await;
9335
9336            let http = reqwest::Client::new();
9337            let rl = RateLimitTracker::new();
9338            let (tx, mut rx) = mpsc::unbounded_channel();
9339            let outcome = recover_fills(
9340                &http,
9341                &rl,
9342                &[],
9343                &server.uri(),
9344                recovery_after(),
9345                &tx,
9346                &dedup,
9347                known,
9348            )
9349            .await;
9350            assert_eq!(outcome, Ok(()), "a full read leaves nothing unrecovered");
9351            drop(tx);
9352
9353            let mut out = Vec::new();
9354            while let Ok(event) = rx.try_recv() {
9355                out.push(event);
9356            }
9357            out
9358        }
9359
9360        async fn drive_recover_fills(
9361            activities: Vec<serde_json::Value>,
9362        ) -> Vec<UnindexedAccountEvent> {
9363            drive_recover_fills_with(
9364                activities,
9365                new_dedup_cache(),
9366                &KnownLiveOrders::shared(ExchangeId::AlpacaBroker),
9367            )
9368            .await
9369        }
9370
9371        /// Run `recover_fills` for every instrument against `server`, returning what it left
9372        /// unread and every event it forwarded.
9373        async fn recover_fills_from(
9374            server: &MockServer,
9375        ) -> (Result<(), UnreadFills>, Vec<UnindexedAccountEvent>) {
9376            let (tx, mut rx) = mpsc::unbounded_channel();
9377            let outcome = recover_fills(
9378                &reqwest::Client::new(),
9379                &RateLimitTracker::new(),
9380                &[],
9381                &server.uri(),
9382                recovery_after(),
9383                &tx,
9384                &new_dedup_cache(),
9385                &KnownLiveOrders::shared(ExchangeId::AlpacaBroker),
9386            )
9387            .await;
9388            drop(tx);
9389            (outcome, std::iter::from_fn(|| rx.try_recv().ok()).collect())
9390        }
9391
9392        /// A recovery read that fails delivers nothing and leaves every fill since the disconnect
9393        /// unread, with the request's error.
9394        #[tokio::test]
9395        async fn a_failed_recovery_read_leaves_every_fill_since_the_disconnect_unread() {
9396            let server = MockServer::start().await;
9397            Mock::given(method("GET"))
9398                .and(path("/v2/account/activities"))
9399                .respond_with(ResponseTemplate::new(500).set_body_string("unavailable"))
9400                .mount(&server)
9401                .await;
9402
9403            let (outcome, events) = recover_fills_from(&server).await;
9404
9405            let Err(UnreadFills {
9406                start,
9407                reason: FillRecoveryFailure::Request(_),
9408            }) = outcome
9409            else {
9410                panic!("expected the request's failure, got {outcome:?}");
9411            };
9412            assert_eq!(start, recovery_after(), "from the disconnect");
9413            assert!(events.is_empty(), "nothing forwarded: {events:?}");
9414        }
9415
9416        /// A recovery read that stops at the page cap delivers the fills it read, then leaves
9417        /// those from the millisecond of the last one on unread: Alpaca orders a millisecond's
9418        /// fills by id, so the cut may have passed over an earlier one in it. A last time that
9419        /// does not parse falls back to the latest that does, and none parsing to the disconnect.
9420        #[tokio::test]
9421        async fn a_truncated_recovery_read_delivers_what_it_read_and_leaves_the_rest_unread() {
9422            let time = |s: &str| s.parse::<DateTime<Utc>>().expect("valid time");
9423            // The page's other activities are at 14:30:00.
9424            let cases = [
9425                (
9426                    Some("2025-04-18T14:30:01.234567Z"),
9427                    time("2025-04-18T14:30:01.234Z"),
9428                ),
9429                (Some("not a time"), time("2025-04-18T14:30:00Z")),
9430                (None, recovery_after()),
9431            ];
9432            for (last_time, expected_start) in cases {
9433                let mut page = make_activities_json(ALPACA_MAX_ACTIVITIES, "act");
9434                let Some(activities) = page.as_array_mut() else {
9435                    panic!("an array of activities");
9436                };
9437                match last_time {
9438                    Some(last_time) => {
9439                        if let Some(last) = activities.last_mut() {
9440                            last["transaction_time"] =
9441                                serde_json::Value::String(last_time.to_string());
9442                        }
9443                    }
9444                    None => {
9445                        for activity in activities.iter_mut() {
9446                            activity["transaction_time"] =
9447                                serde_json::Value::String("not a time".to_string());
9448                        }
9449                    }
9450                }
9451                let server = MockServer::start().await;
9452                Mock::given(method("GET"))
9453                    .and(path("/v2/account/activities"))
9454                    .respond_with(ResponseTemplate::new(200).set_body_json(page))
9455                    .mount(&server)
9456                    .await;
9457
9458                let (outcome, events) = recover_fills_from(&server).await;
9459
9460                let fills_read = MAX_ACTIVITY_PAGES * ALPACA_MAX_ACTIVITIES;
9461                assert_eq!(
9462                    outcome,
9463                    Err(UnreadFills {
9464                        start: expected_start,
9465                        reason: FillRecoveryFailure::Truncated { fills_read },
9466                    }),
9467                    "last time {last_time:?}"
9468                );
9469                assert_eq!(events.len(), fills_read, "every fill read is forwarded");
9470            }
9471        }
9472
9473        /// One account-wide read serves every instrument the stream covers, so one give-up names
9474        /// them all: the stream's list, or every instrument when it has none. It was read once.
9475        #[test]
9476        fn a_fill_recovery_give_up_covers_every_instrument_the_stream_does() {
9477            let start = recovery_after();
9478            let end = start + chrono::Duration::minutes(5);
9479            let spy = InstrumentNameExchange::new("SPY");
9480            let qqq = InstrumentNameExchange::new("QQQ");
9481            let cases = [
9482                (vec![], FillRecoveryScope::AllInstruments),
9483                (
9484                    vec![spy.clone(), qqq.clone()],
9485                    FillRecoveryScope::Instruments(vec![spy, qqq]),
9486                ),
9487            ];
9488            for (instruments, scope) in cases {
9489                let reason = FillRecoveryFailure::TimedOut { timeout_secs: 30 };
9490                let unread = UnreadFills {
9491                    start,
9492                    reason: reason.clone(),
9493                };
9494                assert_eq!(
9495                    fill_recovery_gave_up(&instruments, unread, end),
9496                    UnindexedAccountEvent::new(
9497                        ExchangeId::AlpacaBroker,
9498                        AccountEventKind::FillRecoveryGaveUp(FillRecoveryGap::new(
9499                            scope, start, end, 1, reason,
9500                        )),
9501                    )
9502                );
9503            }
9504        }
9505
9506        /// A truncated read can return fills stamped after the moment recovery began, by a venue
9507        /// clock ahead of this host's. The give-up is still sent, its span ending at its start
9508        /// rather than before it.
9509        #[test]
9510        fn a_read_that_reached_past_the_recovery_start_still_reports() {
9511            let end = recovery_after();
9512            let start = end + chrono::Duration::seconds(1);
9513            let unread = UnreadFills {
9514                start,
9515                reason: FillRecoveryFailure::Truncated { fills_read: 5_000 },
9516            };
9517            let event = fill_recovery_gave_up(&[], unread, end);
9518            let AccountEventKind::FillRecoveryGaveUp(gave_up) = event.kind else {
9519                panic!("expected FillRecoveryGaveUp, got {event:?}");
9520            };
9521            assert_eq!((gave_up.start, gave_up.end), (start, start));
9522        }
9523
9524        /// Run `recover_fills_or_report` for `instruments` against `server` within `timeout`,
9525        /// returning every event it sent.
9526        async fn recover_fills_or_report_from(
9527            server: &MockServer,
9528            instruments: &[InstrumentNameExchange],
9529            timeout: Duration,
9530        ) -> Vec<UnindexedAccountEvent> {
9531            let (tx, mut rx) = mpsc::unbounded_channel();
9532            recover_fills_or_report(
9533                &reqwest::Client::new(),
9534                &RateLimitTracker::new(),
9535                instruments,
9536                &server.uri(),
9537                recovery_after(),
9538                timeout,
9539                &tx,
9540                &new_dedup_cache(),
9541                &KnownLiveOrders::shared(ExchangeId::AlpacaBroker),
9542            )
9543            .await;
9544            drop(tx);
9545            std::iter::from_fn(|| rx.try_recv().ok()).collect()
9546        }
9547
9548        /// A recovery that times out forwards nothing and reports every fill since the disconnect,
9549        /// for the stream's instruments.
9550        #[tokio::test]
9551        async fn a_timed_out_recovery_reports_every_fill_since_the_disconnect() {
9552            let server = MockServer::start().await;
9553            Mock::given(method("GET"))
9554                .and(path("/v2/account/activities"))
9555                .respond_with(
9556                    ResponseTemplate::new(200)
9557                        .set_body_json(serde_json::json!([]))
9558                        .set_delay(Duration::from_secs(3_600)),
9559                )
9560                .mount(&server)
9561                .await;
9562            let spy = InstrumentNameExchange::new("SPY");
9563
9564            let events = recover_fills_or_report_from(
9565                &server,
9566                std::slice::from_ref(&spy),
9567                Duration::from_millis(50),
9568            )
9569            .await;
9570
9571            let [event] = events.as_slice() else {
9572                panic!("only the give-up is sent: {events:?}");
9573            };
9574            let AccountEventKind::FillRecoveryGaveUp(gave_up) = &event.kind else {
9575                panic!("expected FillRecoveryGaveUp, got {event:?}");
9576            };
9577            assert_eq!(gave_up.scope, FillRecoveryScope::Instruments(vec![spy]));
9578            assert_eq!(gave_up.start, recovery_after(), "from the disconnect");
9579            assert!(gave_up.end >= gave_up.start);
9580            assert_eq!(gave_up.attempts, 1);
9581            assert!(
9582                matches!(gave_up.reason, FillRecoveryFailure::TimedOut { .. }),
9583                "{:?}",
9584                gave_up.reason
9585            );
9586        }
9587
9588        /// A truncated recovery sends every fill it read before the give-up, so a consumer sees the
9589        /// report after what was delivered.
9590        #[tokio::test]
9591        async fn a_truncated_recovery_reports_after_the_fills_it_read() {
9592            let server = MockServer::start().await;
9593            Mock::given(method("GET"))
9594                .and(path("/v2/account/activities"))
9595                .respond_with(
9596                    ResponseTemplate::new(200)
9597                        .set_body_json(make_activities_json(ALPACA_MAX_ACTIVITIES, "act")),
9598                )
9599                .mount(&server)
9600                .await;
9601
9602            let events = recover_fills_or_report_from(&server, &[], Duration::from_secs(30)).await;
9603
9604            let Some((last, fills)) = events.split_last() else {
9605                panic!("events are sent");
9606            };
9607            assert_eq!(fills.len(), MAX_ACTIVITY_PAGES * ALPACA_MAX_ACTIVITIES);
9608            assert!(
9609                fills
9610                    .iter()
9611                    .all(|event| matches!(event.kind, AccountEventKind::Trade(_))),
9612                "every fill comes first"
9613            );
9614            let AccountEventKind::FillRecoveryGaveUp(gave_up) = &last.kind else {
9615                panic!("the give-up comes last, got {last:?}");
9616            };
9617            assert_eq!(gave_up.scope, FillRecoveryScope::AllInstruments);
9618            assert!(matches!(
9619                gave_up.reason,
9620                FillRecoveryFailure::Truncated { .. }
9621            ));
9622        }
9623
9624        /// A FILL activity's execution id is the part of its `id` after `::`; an id without one is
9625        /// taken whole.
9626        #[test]
9627        fn an_activity_id_names_its_execution_after_the_separator() {
9628            assert_eq!(
9629                activity_execution_id("20261005041849348::524b1902-817e-446c-825b-a9fcfebbc17e"),
9630                "524b1902-817e-446c-825b-a9fcfebbc17e"
9631            );
9632            assert_eq!(activity_execution_id("act-1"), "act-1");
9633            assert_eq!(
9634                activity_execution_id("20261005041849348::"),
9635                "20261005041849348::"
9636            );
9637        }
9638
9639        /// One fill carries one `TradeId` however it is delivered: over the account stream, by
9640        /// reconnect recovery, and by `fetch_trades`. So a consumer reconciling with `fetch_trades`
9641        /// matches what the stream already delivered.
9642        #[tokio::test]
9643        async fn a_fill_has_one_trade_id_on_the_stream_in_recovery_and_from_fetch_trades() {
9644            use crate::client::ExecutionClient;
9645
9646            let execution_id = "524b1902-817e-446c-825b-a9fcfebbc17e";
9647            let activity = activity_json(
9648                &format!("20250418103000000::{execution_id}"),
9649                "ord-1",
9650                "2",
9651                Some("2"),
9652            );
9653
9654            let [streamed, _snapshot] = convert_trade_update(AlpacaTradeUpdate {
9655                event: SmolStr::new("fill"),
9656                execution_id: Some(execution_id),
9657                previous_execution_id: None,
9658                order: super::make_order_ws("ord-1", "SPY", "buy", "2"),
9659                price: Some("100.00"),
9660                qty: Some("2"),
9661                timestamp: Some("2025-04-18T14:30:00Z"),
9662            });
9663            let Some(streamed) = streamed else {
9664                panic!("a fill converts to a Trade");
9665            };
9666
9667            let recovered = drive_recover_fills(vec![activity.clone()]).await;
9668            let [recovered] = recovered.as_slice() else {
9669                panic!("one fill recovered: {recovered:?}");
9670            };
9671
9672            let server = MockServer::start().await;
9673            Mock::given(method("GET"))
9674                .and(path("/v2/account/activities"))
9675                .respond_with(
9676                    ResponseTemplate::new(200)
9677                        .set_body_json(serde_json::Value::Array(vec![activity])),
9678                )
9679                .mount(&server)
9680                .await;
9681            let Ok(fetched) = client_for(&server)
9682                .fetch_trades(recovery_after(), Utc::now(), &[])
9683                .await
9684            else {
9685                panic!("fetch_trades reads the activity");
9686            };
9687            let [fetched] = fetched.trades.as_slice() else {
9688                panic!("one fill fetched: {fetched:?}");
9689            };
9690
9691            assert_eq!(trade_of(&streamed).id.0.as_str(), execution_id);
9692            assert_eq!(trade_of(recovered).id.0.as_str(), execution_id);
9693            assert_eq!(fetched.id.0.as_str(), execution_id);
9694        }
9695
9696        // -----------------------------------------------------------------------
9697        // fetch_trades — bounded reads of a span, resumed by the caller
9698        // -----------------------------------------------------------------------
9699
9700        fn time(s: &str) -> DateTime<Utc> {
9701            s.parse().expect("valid time")
9702        }
9703
9704        /// Alpaca's activities endpoint over `activities`, which are sorted by id, as observed on
9705        /// paper: `after` matches an activity whose time, rounded down to the millisecond, is at
9706        /// or after it, `until` one whose rounded time is before it, and `page_token` resumes
9707        /// after the activity it names.
9708        struct ActivitiesVenue {
9709            activities: Vec<serde_json::Value>,
9710        }
9711
9712        impl Respond for ActivitiesVenue {
9713            fn respond(&self, request: &Request) -> ResponseTemplate {
9714                let query: std::collections::HashMap<String, String> =
9715                    request.url.query_pairs().into_owned().collect();
9716                let bound = |name: &str| query.get(name).map(|s| time(s));
9717                let (after, until) = (bound("after"), bound("until"));
9718                let size: usize = query
9719                    .get("page_size")
9720                    .and_then(|s| s.parse().ok())
9721                    .unwrap_or(ALPACA_MAX_ACTIVITIES);
9722                let in_bounds = self.activities.iter().filter(|activity| {
9723                    let at =
9724                        floor_millis(time(activity["transaction_time"].as_str().expect("a time")));
9725                    after.is_none_or(|after| at >= after) && until.is_none_or(|until| at < until)
9726                });
9727                let page: Vec<_> = match query.get("page_token") {
9728                    Some(token) => in_bounds
9729                        .skip_while(|activity| activity["id"] != token.as_str())
9730                        .skip(1)
9731                        .take(size)
9732                        .cloned()
9733                        .collect(),
9734                    None => in_bounds.take(size).cloned().collect(),
9735                };
9736                ResponseTemplate::new(200).set_body_json(page)
9737            }
9738        }
9739
9740        /// A FILL activity on `symbol` with id `id` at `transaction_time`.
9741        fn fill_at(id: &str, symbol: &str, transaction_time: DateTime<Utc>) -> serde_json::Value {
9742            serde_json::json!({
9743                "id": id,
9744                "order_id": format!("ord-{id}"),
9745                "symbol": symbol,
9746                "side": "buy",
9747                "price": "100.00",
9748                "qty": "1",
9749                "cum_qty": "1",
9750                "transaction_time": transaction_time.to_rfc3339_opts(SecondsFormat::Micros, true),
9751            })
9752        }
9753
9754        async fn serve_activities(activities: Vec<serde_json::Value>) -> MockServer {
9755            let server = MockServer::start().await;
9756            Mock::given(method("GET"))
9757                .and(path("/v2/account/activities"))
9758                .respond_with(ActivitiesVenue { activities })
9759                .mount(&server)
9760                .await;
9761            server
9762        }
9763
9764        /// A non-trade activity of `activity_type` and `sub_type`, for `net_amount`, created at
9765        /// `created_at`.
9766        fn non_trade(
9767            id: &str,
9768            activity_type: &str,
9769            sub_type: Option<&str>,
9770            net_amount: &str,
9771            created_at: &str,
9772        ) -> serde_json::Value {
9773            let mut activity = serde_json::json!({
9774                "id": id, "activity_type": activity_type, "net_amount": net_amount,
9775                "created_at": created_at, "date": &created_at[..10], "currency": "USD",
9776                "status": "executed",
9777            });
9778            if let Some(sub_type) = sub_type {
9779                activity["activity_sub_type"] = serde_json::json!(sub_type);
9780            }
9781            activity
9782        }
9783
9784        /// The query of every request `server` received.
9785        async fn query_pairs(server: &MockServer) -> Vec<Vec<(String, String)>> {
9786            let Some(requests) = server.received_requests().await else {
9787                panic!("requests are recorded");
9788            };
9789            requests
9790                .iter()
9791                .map(|request| request.url.query_pairs().into_owned().collect())
9792                .collect()
9793        }
9794
9795        #[tokio::test]
9796        async fn fetch_cash_flows_maps_each_activity_type_and_leaves_out_what_it_cannot_use() {
9797            let at = "2025-04-18T05:00:00Z";
9798            let mut fee = non_trade("fee", "FEE", Some("TAF"), "-0.02", at);
9799            fee["symbol"] = serde_json::json!("SPY");
9800            let mut canceled = non_trade("canceled", "FEE", Some("REG"), "-0.01", at);
9801            canceled["status"] = serde_json::json!("canceled");
9802            let mut no_amount = non_trade("no-amount", "FEE", None, "0", at);
9803            no_amount.as_object_mut().unwrap().remove("net_amount");
9804            let mut no_created_at = non_trade("no-time", "PTC", None, "-0.50", at);
9805            no_created_at.as_object_mut().unwrap().remove("created_at");
9806            let mut bad_time = non_trade("bad-time", "PTC", None, "-0.50", at);
9807            bad_time["created_at"] = serde_json::json!("not-a-time");
9808            let mut corrected = non_trade("correct", "PTC", None, "-0.40", at);
9809            corrected["status"] = serde_json::json!("correct");
9810            let mut euro = non_trade("euro", "PTR", None, "0.30", at);
9811            euro["currency"] = serde_json::json!("EUR");
9812            let server = MockServer::start().await;
9813            Mock::given(method("GET"))
9814                .and(path("/v2/account/activities"))
9815                .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([
9816                    non_trade("mgn", "INT", Some("MGN"), "-12.50", at),
9817                    non_trade("int-paid", "INT", None, "-3.00", at),
9818                    non_trade("int-credit", "INT", Some("CDT"), "1.00", at),
9819                    fee,
9820                    canceled,
9821                    no_amount,
9822                    no_created_at,
9823                    bad_time,
9824                    corrected,
9825                    euro,
9826                    non_trade("late", "FEE", None, "-0.01", "2025-04-20T05:00:00Z"),
9827                ])))
9828                .mount(&server)
9829                .await;
9830            let client = client_for(&server);
9831
9832            let read = client
9833                .fetch_cash_flows(time("2025-04-18T00:00:00Z"), time("2025-04-19T00:00:00Z"))
9834                .await
9835                .unwrap();
9836
9837            assert_eq!(read.resume, None);
9838            let kinds: Vec<_> = read
9839                .flows
9840                .iter()
9841                .map(|flow| (flow.id.clone().unwrap().0.to_string(), flow.kind.clone()))
9842                .collect();
9843            let interest = CashFlowKind::MarginInterest {
9844                rate: None,
9845                principal: None,
9846                timing: None,
9847            };
9848            assert_eq!(
9849                kinds,
9850                [
9851                    ("mgn", interest.clone()),
9852                    ("int-paid", interest),
9853                    ("int-credit", CashFlowKind::Other("INT.CDT".into())),
9854                    ("fee", CashFlowKind::Other("FEE.TAF".into())),
9855                    ("correct", CashFlowKind::Other("PTC".into())),
9856                    ("euro", CashFlowKind::Rebate),
9857                ]
9858                .map(|(id, kind)| (id.to_owned(), kind))
9859            );
9860            let by_id = |id: &str| {
9861                read.flows
9862                    .iter()
9863                    .find(|flow| flow.id == Some(CashFlowId::new(id)))
9864                    .unwrap()
9865            };
9866            assert_eq!(by_id("mgn").amount, Decimal::new(-1250, 2));
9867            assert_eq!(by_id("mgn").asset, AssetNameExchange::new("USD"));
9868            assert_eq!(by_id("mgn").instrument, None);
9869            assert_eq!(by_id("mgn").time_exchange, time(at));
9870            assert_eq!(
9871                by_id("fee").instrument,
9872                Some(InstrumentNameExchange::new("SPY"))
9873            );
9874            assert_eq!(
9875                by_id("correct").amount,
9876                Decimal::new(-40, 2),
9877                "a correction is returned as it is"
9878            );
9879            assert_eq!(by_id("euro").asset, AssetNameExchange::new("EUR"));
9880
9881            let queries = query_pairs(&server).await;
9882            let [query] = queries.as_slice() else {
9883                panic!("one request: {queries:?}");
9884            };
9885            assert!(
9886                query.contains(&("activity_types".into(), "INT,FEE,PTC,PTR".into())),
9887                "{query:?}"
9888            );
9889        }
9890
9891        #[tokio::test]
9892        async fn fetch_trades_asks_for_fills_by_the_documented_parameter() {
9893            use crate::client::ExecutionClient;
9894
9895            let server = serve_activities(Vec::new()).await;
9896            let client = client_for(&server);
9897
9898            let read = client
9899                .fetch_trades(
9900                    time("2025-04-18T00:00:00Z"),
9901                    time("2025-04-19T00:00:00Z"),
9902                    &[],
9903                )
9904                .await
9905                .unwrap();
9906
9907            assert!(read.trades.is_empty());
9908            let queries = query_pairs(&server).await;
9909            let [query] = queries.as_slice() else {
9910                panic!("one request: {queries:?}");
9911            };
9912            assert!(
9913                query.contains(&("activity_types".into(), "FILL".into())),
9914                "{query:?}"
9915            );
9916            assert!(
9917                !query.iter().any(|(name, _)| name == "activity_type"),
9918                "{query:?}"
9919            );
9920        }
9921
9922        #[tokio::test]
9923        async fn fetch_cash_flows_reads_nothing_for_an_empty_span() {
9924            let server = MockServer::start().await;
9925            let client = client_for(&server);
9926
9927            let read = client
9928                .fetch_cash_flows(time("2025-04-19T00:00:00Z"), time("2025-04-18T00:00:00Z"))
9929                .await
9930                .unwrap();
9931
9932            assert!(read.flows.is_empty() && read.resume.is_none());
9933            assert!(query_pairs(&server).await.is_empty());
9934        }
9935
9936        /// A span holding more activities than one call reads returns those it read and says
9937        /// where to read on from: the millisecond of the last one's creation.
9938        #[tokio::test]
9939        async fn fetch_cash_flows_that_stops_at_its_bound_says_where_to_read_on() {
9940            let base = time("2025-04-18T05:00:00Z");
9941            let activities: Vec<_> = (0..6_000_i64)
9942                .map(|n| {
9943                    let created =
9944                        (base + TimeDelta::seconds(n)).to_rfc3339_opts(SecondsFormat::Micros, true);
9945                    non_trade(&format!("{n:017}::fee"), "FEE", None, "-0.01", &created)
9946                })
9947                .collect();
9948            // Every page answers in order after the token, as Alpaca pages by id.
9949            struct Paged(Vec<serde_json::Value>);
9950            impl Respond for Paged {
9951                fn respond(&self, request: &Request) -> ResponseTemplate {
9952                    let token = request
9953                        .url
9954                        .query_pairs()
9955                        .find(|(name, _)| name == "page_token")
9956                        .map(|(_, value)| value.into_owned());
9957                    let page: Vec<_> = self
9958                        .0
9959                        .iter()
9960                        .skip_while(|activity| {
9961                            token
9962                                .as_deref()
9963                                .is_some_and(|token| activity["id"] != token)
9964                        })
9965                        .skip(usize::from(token.is_some()))
9966                        .take(ALPACA_MAX_ACTIVITIES)
9967                        .cloned()
9968                        .collect();
9969                    ResponseTemplate::new(200).set_body_json(page)
9970                }
9971            }
9972            let server = MockServer::start().await;
9973            Mock::given(method("GET"))
9974                .and(path("/v2/account/activities"))
9975                .respond_with(Paged(activities))
9976                .mount(&server)
9977                .await;
9978            let client = client_for(&server);
9979
9980            let read = client
9981                .fetch_cash_flows(base, base + TimeDelta::days(1))
9982                .await
9983                .unwrap();
9984
9985            assert_eq!(read.flows.len(), 5_000);
9986            assert_eq!(read.resume, Some(base + TimeDelta::seconds(4_999)));
9987        }
9988
9989        /// A read whose every page holds activities of the span's first millisecond cannot
9990        /// advance, so it fails rather than return a `resume` that would loop.
9991        #[tokio::test]
9992        async fn fetch_cash_flows_that_cannot_advance_fails() {
9993            let at = "2025-04-18T05:00:00Z";
9994            let full: Vec<_> = (0..ALPACA_MAX_ACTIVITIES)
9995                .map(|n| non_trade(&format!("fee-{n}"), "FEE", None, "-0.01", at))
9996                .collect();
9997            let server = MockServer::start().await;
9998            Mock::given(method("GET"))
9999                .and(path("/v2/account/activities"))
10000                .respond_with(ResponseTemplate::new(200).set_body_json(full))
10001                .mount(&server)
10002                .await;
10003            let client = client_for(&server);
10004
10005            let result = client
10006                .fetch_cash_flows(time(at), time("2025-04-19T00:00:00Z"))
10007                .await;
10008
10009            assert!(
10010                matches!(result, Err(UnindexedClientError::Internal(_))),
10011                "{result:?}"
10012            );
10013        }
10014
10015        /// An option activity of `activity_type` on `symbol`, for the signed `qty`, dated `date`,
10016        /// with no creation time.
10017        fn option_activity(
10018            id: &str,
10019            activity_type: &str,
10020            symbol: &str,
10021            qty: &str,
10022            date: &str,
10023        ) -> serde_json::Value {
10024            serde_json::json!({
10025                "id": id, "activity_type": activity_type, "symbol": symbol, "qty": qty,
10026                "date": date, "net_amount": "0", "status": "executed",
10027            })
10028        }
10029
10030        /// The share leg (`OPTRD`) of an exercise or assignment, at `price`.
10031        fn share_leg(
10032            id: &str,
10033            symbol: &str,
10034            qty: &str,
10035            price: &str,
10036            date: &str,
10037        ) -> serde_json::Value {
10038            let mut activity = option_activity(id, "OPTRD", symbol, qty, date);
10039            activity["price"] = serde_json::json!(price);
10040            activity
10041        }
10042
10043        async fn serve_json(activities: serde_json::Value) -> MockServer {
10044            let server = MockServer::start().await;
10045            Mock::given(method("GET"))
10046                .and(path("/v2/account/activities"))
10047                .respond_with(ResponseTemplate::new(200).set_body_json(activities))
10048                .mount(&server)
10049                .await;
10050            server
10051        }
10052
10053        #[test]
10054        fn occ_contract_reads_the_root_kind_and_strike() {
10055            assert_eq!(
10056                occ_contract("QS240301C00006500"),
10057                Some(("QS", true, Decimal::new(65, 1)))
10058            );
10059            assert_eq!(
10060                occ_contract("AAPL240119P00150000"),
10061                Some(("AAPL", false, Decimal::from(150)))
10062            );
10063            for not_occ in [
10064                "QS",
10065                "240301C00006500",
10066                "QS240301X00006500",
10067                "QS2403O1C00006500",
10068            ] {
10069                assert_eq!(occ_contract(not_occ), None, "{not_occ}");
10070            }
10071        }
10072
10073        #[tokio::test]
10074        async fn fetch_option_events_maps_each_activity_and_pairs_the_share_legs() {
10075            let day = "2024-03-01";
10076            let mut expiry = option_activity("exp", "OPEXP", "QS240301C00010500", "-1", day);
10077            expiry["created_at"] = serde_json::json!("2024-03-01T21:30:00Z");
10078            let mut canceled = option_activity("canceled", "OPEXP", "QS240301P00000500", "-1", day);
10079            canceled["status"] = serde_json::json!("canceled");
10080            let server = serve_json(serde_json::json!([
10081                expiry,
10082                option_activity("exc", "OPEXC", "QS240301C00006500", "-1", day),
10083                share_leg("exc-shares", "QS", "100", "6.5", day),
10084                option_activity("asn", "OPASN", "QS240301P00009000", "1", day),
10085                share_leg("asn-shares", "QS", "100", "9", day),
10086                share_leg("orphan", "QS", "-100", "7", day),
10087                canceled,
10088                option_activity("no-price", "OPTRD", "QS", "100", day),
10089                option_activity("no-qty", "OPEXP", "QS240301C00011000", "0", day),
10090                option_activity("later", "OPEXP", "QS240308C00010500", "-1", "2024-03-02"),
10091            ]))
10092            .await;
10093            let client = client_for(&server);
10094
10095            let read = client
10096                .fetch_option_events(time("2024-03-01T00:00:00Z"), time("2024-03-01T23:00:00Z"))
10097                .await
10098                .unwrap();
10099
10100            assert_eq!(read.resume, None);
10101            let summary: Vec<_> = read
10102                .trades
10103                .iter()
10104                .map(|trade| {
10105                    (
10106                        trade.id.0.to_string(),
10107                        trade.instrument.name().to_string(),
10108                        trade.origin,
10109                        trade.side,
10110                        trade.price,
10111                        trade.quantity,
10112                    )
10113                })
10114                .collect();
10115            let row = |id: &str, symbol: &str, origin, side, price: Decimal, quantity: i64| {
10116                (
10117                    id.to_owned(),
10118                    symbol.to_owned(),
10119                    origin,
10120                    side,
10121                    price,
10122                    Decimal::from(quantity),
10123                )
10124            };
10125            assert_eq!(
10126                summary,
10127                [
10128                    row(
10129                        "exp",
10130                        "QS240301C00010500",
10131                        TradeOrigin::Expiry,
10132                        Side::Sell,
10133                        Decimal::ZERO,
10134                        1
10135                    ),
10136                    row(
10137                        "exc",
10138                        "QS240301C00006500",
10139                        TradeOrigin::Exercise,
10140                        Side::Sell,
10141                        Decimal::ZERO,
10142                        1
10143                    ),
10144                    row(
10145                        "exc-shares",
10146                        "QS",
10147                        TradeOrigin::Exercise,
10148                        Side::Buy,
10149                        Decimal::new(65, 1),
10150                        100
10151                    ),
10152                    row(
10153                        "asn",
10154                        "QS240301P00009000",
10155                        TradeOrigin::Assignment,
10156                        Side::Buy,
10157                        Decimal::ZERO,
10158                        1
10159                    ),
10160                    row(
10161                        "asn-shares",
10162                        "QS",
10163                        TradeOrigin::Assignment,
10164                        Side::Buy,
10165                        Decimal::from(9),
10166                        100
10167                    ),
10168                    row(
10169                        "orphan",
10170                        "QS",
10171                        TradeOrigin::External,
10172                        Side::Sell,
10173                        Decimal::from(7),
10174                        100
10175                    ),
10176                ]
10177            );
10178            let exp = &read.trades[0];
10179            assert_eq!(
10180                exp.time_exchange,
10181                time("2024-03-01T21:30:00Z"),
10182                "created_at"
10183            );
10184            assert_eq!(exp.order_id, OrderId::new("exp"));
10185            assert_eq!(exp.fees.fees, Decimal::ZERO);
10186            assert_eq!(
10187                read.trades[1].time_exchange,
10188                time("2024-03-01T00:00:00Z"),
10189                "no created_at: the start of its date"
10190            );
10191
10192            let queries = query_pairs(&server).await;
10193            let [query] = queries.as_slice() else {
10194                panic!("one request: {queries:?}");
10195            };
10196            assert!(
10197                query.contains(&("activity_types".into(), "OPASN,OPEXC,OPEXP,OPTRD".into())),
10198                "{query:?}"
10199            );
10200        }
10201
10202        /// A share leg pairs with the exercise or assignment whose kind implies its side, not with
10203        /// another at the same strike, and each pairs once.
10204        #[tokio::test]
10205        async fn fetch_option_events_pairs_a_share_leg_by_its_side() {
10206            let day = "2024-03-01";
10207            let server = serve_json(serde_json::json!([
10208                option_activity("exc-call", "OPEXC", "QS240301C00006500", "-1", day),
10209                option_activity("asn-call", "OPASN", "QS240301C00006500", "1", day),
10210                share_leg("sold", "QS", "-100", "6.5", day),
10211                share_leg("bought", "QS", "100", "6.5", day),
10212                share_leg("bought-again", "QS", "100", "6.5", day),
10213                share_leg("other-day", "QS", "100", "6.5", "2024-02-29"),
10214            ]))
10215            .await;
10216            let client = client_for(&server);
10217
10218            let read = client
10219                .fetch_option_events(time("2024-02-29T00:00:00Z"), time("2024-03-01T23:00:00Z"))
10220                .await
10221                .unwrap();
10222
10223            let origin = |id: &str| {
10224                read.trades
10225                    .iter()
10226                    .find(|trade| trade.id == TradeId::new(id))
10227                    .map(|trade| trade.origin)
10228            };
10229            assert_eq!(origin("sold"), Some(TradeOrigin::Assignment));
10230            assert_eq!(origin("bought"), Some(TradeOrigin::Exercise));
10231            assert_eq!(origin("bought-again"), Some(TradeOrigin::External));
10232            assert_eq!(origin("other-day"), Some(TradeOrigin::External));
10233        }
10234
10235        /// A share leg pairs with the exercise or assignment whose contracts deliver its shares,
10236        /// 100 a contract, though another that would buy them comes first. One of another size
10237        /// still pairs, with the first match left.
10238        #[tokio::test]
10239        async fn fetch_option_events_pairs_a_share_leg_by_its_size() {
10240            let day = "2024-03-01";
10241            let server = serve_json(serde_json::json!([
10242                // Being assigned on a put and exercising a call both buy at the strike.
10243                option_activity("asn-two", "OPASN", "QS240301P00006500", "2", day),
10244                option_activity("exc-one", "OPEXC", "QS240301C00006500", "-1", day),
10245                option_activity("exc-adjusted", "OPEXC", "QS240301C00006500", "-1", day),
10246                share_leg("hundred", "QS", "100", "6.5", day),
10247                share_leg("two-hundred", "QS", "200", "6.5", day),
10248                share_leg("hundred-fifty", "QS", "150", "6.5", day),
10249            ]))
10250            .await;
10251            let client = client_for(&server);
10252
10253            let read = client
10254                .fetch_option_events(time("2024-03-01T00:00:00Z"), time("2024-03-01T23:00:00Z"))
10255                .await
10256                .unwrap();
10257
10258            let origin = |id: &str| {
10259                read.trades
10260                    .iter()
10261                    .find(|trade| trade.id == TradeId::new(id))
10262                    .map(|trade| trade.origin)
10263            };
10264            assert_eq!(origin("hundred"), Some(TradeOrigin::Exercise));
10265            assert_eq!(origin("two-hundred"), Some(TradeOrigin::Assignment));
10266            assert_eq!(origin("hundred-fifty"), Some(TradeOrigin::Exercise));
10267        }
10268
10269        /// The read covers the whole UTC days of the span, so both legs of an event are read
10270        /// together, and returns only what is in the span: by creation time, or, without one, by
10271        /// date.
10272        #[tokio::test]
10273        async fn fetch_option_events_reads_whole_days_and_returns_the_span() {
10274            let mut early =
10275                option_activity("early", "OPEXP", "QS240301C00010500", "-1", "2024-03-01");
10276            early["created_at"] = serde_json::json!("2024-03-01T10:00:00Z");
10277            let mut inside =
10278                option_activity("inside", "OPEXP", "QS240301C00011500", "-1", "2024-03-01");
10279            inside["created_at"] = serde_json::json!("2024-03-01T16:00:00Z");
10280            let server = serve_json(serde_json::json!([
10281                option_activity(
10282                    "dated-before",
10283                    "OPEXP",
10284                    "QS240229C00010500",
10285                    "-1",
10286                    "2024-02-29"
10287                ),
10288                early,
10289                option_activity("dated", "OPEXP", "QS240301C00012500", "-1", "2024-03-01"),
10290                inside,
10291            ]))
10292            .await;
10293            let client = client_for(&server);
10294
10295            let read = client
10296                .fetch_option_events(time("2024-03-01T15:00:00Z"), time("2024-03-01T17:00:00Z"))
10297                .await
10298                .unwrap();
10299
10300            let ids: Vec<_> = read
10301                .trades
10302                .iter()
10303                .map(|trade| trade.id.0.to_string())
10304                .collect();
10305            assert_eq!(ids, ["dated", "inside"]);
10306            let queries = query_pairs(&server).await;
10307            let [query] = queries.as_slice() else {
10308                panic!("one request: {queries:?}");
10309            };
10310            assert!(
10311                query.contains(&("after".into(), "2024-03-01T00:00:00.000Z".into())),
10312                "{query:?}"
10313            );
10314            assert!(
10315                query.contains(&("until".into(), "2024-03-02T00:00:00.000Z".into())),
10316                "{query:?}"
10317            );
10318        }
10319
10320        #[tokio::test]
10321        async fn fetch_option_events_reads_nothing_for_an_empty_span() {
10322            let server = MockServer::start().await;
10323            let client = client_for(&server);
10324
10325            let read = client
10326                .fetch_option_events(time("2024-03-02T00:00:00Z"), time("2024-03-01T00:00:00Z"))
10327                .await
10328                .unwrap();
10329
10330            assert!(read.trades.is_empty() && read.resume.is_none());
10331            assert!(query_pairs(&server).await.is_empty());
10332        }
10333
10334        /// Read `start..=end` to its end, from each call's `resume`, returning the trade ids
10335        /// read and how many calls it took.
10336        async fn read_span(
10337            client: &AlpacaClient,
10338            start: DateTime<Utc>,
10339            end: DateTime<Utc>,
10340            instruments: &[InstrumentNameExchange],
10341        ) -> (std::collections::HashSet<String>, usize) {
10342            use crate::client::ExecutionClient;
10343
10344            let mut ids = std::collections::HashSet::new();
10345            let mut from = start;
10346            for calls in 1..=10 {
10347                let read = match client.fetch_trades(from, end, instruments).await {
10348                    Ok(read) => read,
10349                    Err(error) => panic!("call {calls} failed: {error:?}"),
10350                };
10351                ids.extend(read.trades.iter().map(|trade| trade.id.0.to_string()));
10352                match read.resume {
10353                    Some(resume) => from = resume,
10354                    None => return (ids, calls),
10355                }
10356            }
10357            panic!("the reads did not reach the span's end");
10358        }
10359
10360        /// A span holding more fills than one call reads is read completely across calls, each
10361        /// resumed from the last. Three fills share each millisecond, ordered by id against
10362        /// their times, so a call's cut falls inside one: its last fill read is later than an
10363        /// unread one in the same millisecond, which a resume from the last fill's exact time
10364        /// would lose.
10365        #[tokio::test]
10366        async fn fetch_trades_reads_a_busy_span_completely_across_calls() {
10367            let base = time("2025-04-18T14:30:00Z");
10368            let mut activities = Vec::new();
10369            for ms in 0..4_000_i64 {
10370                let symbol = if ms % 2 == 0 { "SPY" } else { "QQQ" };
10371                for (k, micros) in [("a", 900), ("b", 500), ("c", 100)] {
10372                    let at = base + TimeDelta::milliseconds(ms) + TimeDelta::microseconds(micros);
10373                    activities.push(fill_at(&format!("{ms:017}::{ms}-{k}"), symbol, at));
10374                }
10375            }
10376            let server = serve_activities(activities).await;
10377            let client = client_for(&server);
10378            let end = time("2025-04-18T15:00:00Z");
10379
10380            let (ids, calls) = read_span(&client, recovery_after(), end, &[]).await;
10381            assert_eq!(ids.len(), 12_000, "every fill is read once or more");
10382            assert_eq!(calls, 3, "5,000 activities per call");
10383
10384            let spy = [InstrumentNameExchange::new("SPY")];
10385            let (ids, _) = read_span(&client, recovery_after(), end, &spy).await;
10386            assert_eq!(ids.len(), 6_000, "every SPY fill, and only those");
10387
10388            let Some(requests) = server.received_requests().await else {
10389                panic!("requests are recorded");
10390            };
10391            assert!(
10392                requests
10393                    .iter()
10394                    .all(|request| request.url.query_pairs().any(
10395                        |(name, value)| name == "until" && value == "2025-04-18T15:00:00.001Z"
10396                    )),
10397                "every request is bounded by the span's end"
10398            );
10399        }
10400
10401        /// A call can spend its whole bound on other instruments' fills: it returns none, and
10402        /// says where to read on from.
10403        #[tokio::test]
10404        async fn fetch_trades_says_where_to_read_on_after_a_call_without_matches() {
10405            use crate::client::ExecutionClient;
10406
10407            let server = MockServer::start().await;
10408            Mock::given(method("GET"))
10409                .and(path("/v2/account/activities"))
10410                .respond_with(
10411                    ResponseTemplate::new(200)
10412                        .set_body_json(make_activities_json(ALPACA_MAX_ACTIVITIES, "act")),
10413                )
10414                .mount(&server)
10415                .await;
10416
10417            let qqq = [InstrumentNameExchange::new("QQQ")];
10418            let Ok(read) = client_for(&server)
10419                .fetch_trades(recovery_after(), Utc::now(), &qqq)
10420                .await
10421            else {
10422                panic!("the read succeeds");
10423            };
10424            assert!(read.trades.is_empty(), "every fill read is SPY's");
10425            assert_eq!(read.resume, Some(time("2025-04-18T14:30:00Z")));
10426        }
10427
10428        /// A full read that does not get past the span's first millisecond cannot advance, so it
10429        /// is an error rather than a resume that would loop.
10430        #[tokio::test]
10431        async fn fetch_trades_that_cannot_advance_is_truncated() {
10432            use crate::client::ExecutionClient;
10433
10434            let server = MockServer::start().await;
10435            Mock::given(method("GET"))
10436                .and(path("/v2/account/activities"))
10437                .respond_with(
10438                    ResponseTemplate::new(200)
10439                        .set_body_json(make_activities_json(ALPACA_MAX_ACTIVITIES, "act")),
10440                )
10441                .mount(&server)
10442                .await;
10443
10444            let result = client_for(&server)
10445                .fetch_trades(time("2025-04-18T14:30:00.000500Z"), Utc::now(), &[])
10446                .await;
10447            assert!(
10448                matches!(
10449                    result,
10450                    Err(UnindexedClientError::Truncated { fills_read: 5_000 })
10451                ),
10452                "got {result:?}"
10453            );
10454        }
10455
10456        /// The span is applied exactly, although Alpaca's bounds reach the whole of the
10457        /// milliseconds holding `start` and `end`.
10458        #[tokio::test]
10459        async fn fetch_trades_returns_only_the_span() {
10460            let (start, end) = (
10461                time("2025-04-18T14:30:00.123456Z"),
10462                time("2025-04-18T14:30:05.678901Z"),
10463            );
10464            let micro = TimeDelta::microseconds(1);
10465            let server = serve_activities(vec![
10466                fill_at("1::before", "SPY", start - micro),
10467                fill_at("2::start", "SPY", start),
10468                fill_at("3::end", "SPY", end),
10469                fill_at("4::after", "SPY", end + micro),
10470            ])
10471            .await;
10472
10473            let (ids, calls) = read_span(&client_for(&server), start, end, &[]).await;
10474            assert_eq!(calls, 1);
10475            let mut ids: Vec<_> = ids.into_iter().collect();
10476            ids.sort();
10477            assert_eq!(ids, ["end", "start"]);
10478        }
10479
10480        /// Alpaca's `until` is not exact, so a capped read can return activities past `end`. Every
10481        /// activity before them was read, so the span is complete.
10482        #[tokio::test]
10483        async fn fetch_trades_that_read_past_the_end_is_complete() {
10484            use crate::client::ExecutionClient;
10485
10486            let server = MockServer::start().await;
10487            // Full pages at 14:30:00, whatever `until` asks for.
10488            Mock::given(method("GET"))
10489                .and(path("/v2/account/activities"))
10490                .respond_with(
10491                    ResponseTemplate::new(200)
10492                        .set_body_json(make_activities_json(ALPACA_MAX_ACTIVITIES, "act")),
10493                )
10494                .mount(&server)
10495                .await;
10496
10497            let Ok(read) = client_for(&server)
10498                .fetch_trades(recovery_after(), time("2025-04-18T14:29:00Z"), &[])
10499                .await
10500            else {
10501                panic!("the read succeeds");
10502            };
10503            assert_eq!(read, TradesRead::complete(Vec::new()));
10504        }
10505
10506        /// The extreme times are valid bounds: a start before the epoch asks from the epoch, an
10507        /// end not yet reached sends no `until` rather than overflowing, and a span ending before
10508        /// the epoch reads nothing.
10509        #[tokio::test]
10510        async fn fetch_trades_accepts_the_extreme_times() {
10511            use crate::client::ExecutionClient;
10512
10513            let server = serve_activities(vec![fill_at(
10514                "1::fill",
10515                "SPY",
10516                time("2025-04-18T14:30:00Z"),
10517            )])
10518            .await;
10519            let Ok(read) = client_for(&server)
10520                .fetch_trades(DateTime::<Utc>::MIN_UTC, DateTime::<Utc>::MAX_UTC, &[])
10521                .await
10522            else {
10523                panic!("the read succeeds");
10524            };
10525            assert_eq!(read.trades.len(), 1);
10526            assert_eq!(read.resume, None);
10527
10528            let Some(requests) = server.received_requests().await else {
10529                panic!("requests are recorded");
10530            };
10531            let [request] = requests.as_slice() else {
10532                panic!("one request: {requests:?}");
10533            };
10534            let query: std::collections::HashMap<_, _> =
10535                request.url.query_pairs().into_owned().collect();
10536            assert_eq!(
10537                query.get("after").map(String::as_str),
10538                Some("1970-01-01T00:00:00.000Z")
10539            );
10540            assert!(!query.contains_key("until"), "{query:?}");
10541
10542            // A span ending before the epoch holds nothing, and is not requested.
10543            let before_epoch = DateTime::UNIX_EPOCH - TimeDelta::days(1);
10544            let Ok(read) = client_for(&server)
10545                .fetch_trades(DateTime::<Utc>::MIN_UTC, before_epoch, &[])
10546                .await
10547            else {
10548                panic!("the read succeeds");
10549            };
10550            assert_eq!(read, TradesRead::complete(Vec::new()));
10551            assert_eq!(server.received_requests().await.map(|r| r.len()), Some(1));
10552        }
10553
10554        /// A span whose start is after its end is empty: nothing is requested.
10555        #[tokio::test]
10556        async fn fetch_trades_reads_nothing_for_an_empty_span() {
10557            use crate::client::ExecutionClient;
10558
10559            let server = MockServer::start().await;
10560            let start = recovery_after();
10561            let Ok(read) = client_for(&server)
10562                .fetch_trades(start, start - TimeDelta::seconds(1), &[])
10563                .await
10564            else {
10565                panic!("an empty span reads");
10566            };
10567            assert_eq!(read, TradesRead::complete(Vec::new()));
10568            assert_eq!(server.received_requests().await.map(|r| r.len()), Some(0));
10569        }
10570
10571        /// Alpaca's `after` compares at millisecond precision, so a recovery from mid-millisecond
10572        /// asks from that millisecond's start, or it would miss a fill later in it.
10573        #[tokio::test]
10574        async fn recovery_reads_from_the_start_of_the_disconnects_millisecond() {
10575            let server = MockServer::start().await;
10576            Mock::given(method("GET"))
10577                .and(path("/v2/account/activities"))
10578                .and(wiremock::matchers::query_param(
10579                    "after",
10580                    "2025-01-01T00:00:00.123Z",
10581                ))
10582                .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([])))
10583                .expect(1)
10584                .mount(&server)
10585                .await;
10586
10587            let (tx, _rx) = mpsc::unbounded_channel();
10588            let outcome = recover_fills(
10589                &reqwest::Client::new(),
10590                &RateLimitTracker::new(),
10591                &[],
10592                &server.uri(),
10593                time("2025-01-01T00:00:00.123456Z"),
10594                &tx,
10595                &new_dedup_cache(),
10596                &KnownLiveOrders::shared(ExchangeId::AlpacaBroker),
10597            )
10598            .await;
10599            assert_eq!(outcome, Ok(()));
10600            server.verify().await;
10601        }
10602
10603        /// The cumulative dedup keys `dedup` holds, oldest first.
10604        fn dedup_keys(dedup: &SharedDedupCache) -> Vec<String> {
10605            let mut keys: Vec<_> = dedup
10606                .lock()
10607                .iter()
10608                .filter_map(|(key, _)| match key {
10609                    FillKey::Cumulative(key) => Some(key.to_string()),
10610                    FillKey::Execution(_) | FillKey::CumulativeWithoutId(_) => None,
10611                })
10612                .collect();
10613            // The LRU iterates most recent first.
10614            keys.reverse();
10615            keys
10616        }
10617
10618        fn trade_of(
10619            event: &UnindexedAccountEvent,
10620        ) -> &Trade<AssetNameExchange, InstrumentNameExchange> {
10621            match &event.kind {
10622                AccountEventKind::Trade(t) => t,
10623                other => panic!("expected a Trade event, got {other:?}"),
10624            }
10625        }
10626
10627        /// A recovered fill reports where it left the order, so it advances `filled_quantity`
10628        /// without waiting for a `fetch_open_orders` reconciliation that the caller may never make.
10629        #[tokio::test]
10630        async fn a_recovered_fill_reports_the_cumulative_alpaca_sent() {
10631            let events = drive_recover_fills(vec![
10632                activity_json("act-1", "ord-1", "2", Some("2")),
10633                activity_json("act-2", "ord-1", "3", Some("5")),
10634            ])
10635            .await;
10636
10637            let reported: Vec<_> = events
10638                .iter()
10639                .map(|e| trade_of(e).order_filled_quantity)
10640                .collect();
10641            assert_eq!(
10642                reported,
10643                vec![
10644                    Some(Decimal::from_str("2").unwrap()),
10645                    Some(Decimal::from_str("5").unwrap())
10646                ]
10647            );
10648        }
10649
10650        /// The dedup key comes from the venue's own cumulative, not from counting executions
10651        /// within the recovery batch.
10652        ///
10653        /// An order that had already partly filled *before* the recovery window is the case that
10654        /// separates the two, and it is the case the previous implementation got wrong: counting
10655        /// from zero inside the batch yields a key the WebSocket path never emitted for that fill.
10656        #[tokio::test]
10657        async fn the_dedup_key_uses_the_venue_cumulative_not_an_intra_batch_count() {
10658            // Three lots filled before the window; the recovered 2-lot fill takes the order to 5.
10659            let dedup = new_dedup_cache();
10660            drive_recover_fills_with(
10661                vec![activity_json("act-1", "ord-1", "2", Some("5"))],
10662                dedup.clone(),
10663                &KnownLiveOrders::shared(ExchangeId::AlpacaBroker),
10664            )
10665            .await;
10666
10667            assert_eq!(
10668                dedup_keys(&dedup),
10669                vec!["ord-1:5"],
10670                "counting executions within the batch would key this as ord-1:2"
10671            );
10672        }
10673
10674        /// Delivers a WS fill of 2 lots on `ord-1`, leaving it at 5, with `execution_id`, then
10675        /// recovers one activity `activity_id` of the same size and cumulative through the same
10676        /// dedup cache.
10677        async fn recover_after_ws_fill(
10678            execution_id: Option<&str>,
10679            activity_id: &str,
10680        ) -> Vec<UnindexedAccountEvent> {
10681            let dedup = new_dedup_cache();
10682            let update = AlpacaTradeUpdate {
10683                event: SmolStr::new("partial_fill"),
10684                execution_id,
10685                previous_execution_id: None,
10686                order: super::make_order_ws("ord-1", "SPY", "buy", "5"),
10687                price: Some("100.00"),
10688                qty: Some("2"),
10689                timestamp: None,
10690            };
10691            assert!(
10692                !is_duplicate_fill(&dedup, ws_execution_id(&update), early_dedup_key(&update)),
10693                "precondition: first sighting"
10694            );
10695
10696            drive_recover_fills_with(
10697                vec![activity_json(activity_id, "ord-1", "2", Some("5"))],
10698                dedup,
10699                &KnownLiveOrders::shared(ExchangeId::AlpacaBroker),
10700            )
10701            .await
10702        }
10703
10704        /// The property the cache exists for: one fill delivered by both paths is one fill. It is
10705        /// recognised by its execution id, or by its cumulative where either path lacks the id.
10706        #[tokio::test]
10707        async fn a_fill_already_delivered_over_websocket_is_not_recovered_twice() {
10708            for (ws_execution_id, activity_id) in [
10709                (Some("exec-a"), "20250418103000000::exec-a"),
10710                (None, "20250418103000000::exec-a"),
10711                (Some("exec-a"), "act-1"),
10712            ] {
10713                let events = recover_after_ws_fill(ws_execution_id, activity_id).await;
10714                assert!(
10715                    events.is_empty(),
10716                    "a fill already delivered over WebSocket ({ws_execution_id:?}) must not be \
10717                     re-delivered by recovery ({activity_id}), got {events:?}"
10718                );
10719            }
10720        }
10721
10722        /// Streams a `trade_correct` with `fields` (its execution id `exec-c`), then recovers the
10723        /// FILL activity of `exec-c` through the same dedup cache.
10724        async fn recover_after_correction(fields: &str) -> Vec<UnindexedAccountEvent> {
10725            let (tx, _rx) = mpsc::unbounded_channel();
10726            let dedup = new_dedup_cache();
10727            let known = KnownLiveOrders::shared(ExchangeId::AlpacaBroker);
10728            process_ws_text(
10729                &super::amendment_frame("trade_correct", fields),
10730                &tx,
10731                &dedup,
10732                &known,
10733            );
10734
10735            drive_recover_fills_with(
10736                vec![activity_json(
10737                    "20250418103100000::exec-c",
10738                    "ord-1",
10739                    "2",
10740                    Some("2"),
10741                )],
10742                dedup,
10743                &known,
10744            )
10745            .await
10746        }
10747
10748        /// A correction's execution was delivered as the replacement trade, so recovery does not
10749        /// deliver it again should Alpaca list it as a FILL activity.
10750        #[tokio::test]
10751        async fn a_corrected_execution_is_not_recovered_again() {
10752            let events = recover_after_correction(
10753                r#""execution_id":"exec-c","previous_execution_id":"exec-a","price":"101.50","qty":"2","#,
10754            )
10755            .await;
10756            assert!(
10757                events.is_empty(),
10758                "the corrected execution was delivered as the replacement, got {events:?}"
10759            );
10760        }
10761
10762        /// An unresolved correction delivered no trade, so recovery still delivers its execution.
10763        #[tokio::test]
10764        async fn an_unresolved_correction_does_not_keep_its_execution_from_recovery() {
10765            let events = recover_after_correction(
10766                r#""execution_id":"exec-c","previous_execution_id":"exec-a","qty":"2","#,
10767            )
10768            .await;
10769            let ids: Vec<_> = events.iter().map(|e| trade_of(e).id.0.as_str()).collect();
10770            assert_eq!(ids, vec!["exec-c"]);
10771        }
10772
10773        /// A different execution that takes the order to a cumulative already seen, as after a
10774        /// bust, is a new fill and is recovered.
10775        #[tokio::test]
10776        async fn a_different_execution_at_a_cumulative_already_seen_is_recovered() {
10777            let events = recover_after_ws_fill(Some("exec-a"), "20250418103000000::exec-b").await;
10778            let ids: Vec<_> = events.iter().map(|e| trade_of(e).id.0.as_str()).collect();
10779            assert_eq!(ids, vec!["exec-b"]);
10780        }
10781
10782        /// With `cum_qty` absent the key falls back to counting within the batch -- exactly what
10783        /// shipped before -- and the fill reports no cumulative rather than a fabricated one.
10784        #[tokio::test]
10785        async fn without_a_reported_cumulative_the_previous_behaviour_is_preserved() {
10786            let dedup = new_dedup_cache();
10787            let events = drive_recover_fills_with(
10788                vec![
10789                    activity_json("act-1", "ord-1", "2", None),
10790                    activity_json("act-2", "ord-1", "3", None),
10791                ],
10792                dedup.clone(),
10793                &KnownLiveOrders::shared(ExchangeId::AlpacaBroker),
10794            )
10795            .await;
10796
10797            assert_eq!(
10798                dedup_keys(&dedup),
10799                vec!["ord-1:2", "ord-1:5"],
10800                "intra-batch accumulation"
10801            );
10802            let ids: Vec<_> = events.iter().map(|e| trade_of(e).id.0.as_str()).collect();
10803            assert_eq!(ids, vec!["act-1", "act-2"], "each keeps its activity's id");
10804
10805            assert!(
10806                events
10807                    .iter()
10808                    .all(|e| trade_of(e).order_filled_quantity.is_none()),
10809                "a venue that reported no cumulative must not have one invented for it"
10810            );
10811        }
10812
10813        // --- How orders ended: fetch_ended_orders, the reconnect check, and what is held ---
10814
10815        /// An order as `GET /v2/orders` and `GET /v2/orders:by_client_order_id` serve it, of
10816        /// quantity 2.
10817        fn order_json(cid: &str, symbol: &str, status: &str, filled: &str) -> serde_json::Value {
10818            serde_json::json!({
10819                "id": format!("id-{cid}"),
10820                "client_order_id": cid,
10821                "symbol": symbol,
10822                "qty": "2",
10823                "filled_qty": filled,
10824                "filled_avg_price": if filled == "0" { None } else { Some("101.5") },
10825                "status": status,
10826                "side": "buy",
10827                "type": "limit",
10828                "time_in_force": "gtc",
10829                "limit_price": "100",
10830                "created_at": "2026-10-01T14:30:00Z",
10831                "updated_at": "2026-10-01T15:00:00Z"
10832            })
10833        }
10834
10835        /// Serve `order` as the lookup of client order id `cid`, expecting `calls` lookups.
10836        async fn mount_lookup(server: &MockServer, cid: &str, order: ResponseTemplate, calls: u64) {
10837            use wiremock::matchers::query_param;
10838            Mock::given(method("GET"))
10839                .and(path("/v2/orders:by_client_order_id"))
10840                .and(query_param("client_order_id", cid))
10841                .respond_with(order)
10842                .expect(calls)
10843                .mount(server)
10844                .await;
10845        }
10846
10847        fn ok(body: serde_json::Value) -> ResponseTemplate {
10848            ResponseTemplate::new(200).set_body_json(body)
10849        }
10850
10851        fn alpaca_key(instrument: &str, cid: &str) -> UnindexedOrderKey {
10852            OrderKey::new(
10853                ExchangeId::AlpacaBroker,
10854                InstrumentNameExchange::new(instrument),
10855                StrategyId::new("strategy"),
10856                ClientOrderId::new(cid),
10857            )
10858        }
10859
10860        fn hold(known: &SharedKnownLiveOrders, key: &UnindexedOrderKey) {
10861            let open = Open::new(
10862                VenueOrderId::Assigned(OrderId::new(format!("id-{}", key.cid))),
10863                Utc::now(),
10864                Decimal::ZERO,
10865            );
10866            known
10867                .lock()
10868                .live(key, Decimal::TWO, OrderKind::Limit, &open);
10869        }
10870
10871        #[tokio::test]
10872        async fn fetch_ended_orders_reads_each_end_alpaca_reports() {
10873            use rust_decimal_macros::dec;
10874
10875            let server = MockServer::start().await;
10876            for (cid, status, filled) in [
10877                ("filled", "filled", "2"),
10878                ("canceled", "canceled", "1"),
10879                ("replaced", "replaced", "0"),
10880                ("expired", "expired", "1"),
10881                ("rejected", "rejected", "0"),
10882                ("done", "done_for_day", "1"),
10883                ("live", "new", "0"),
10884                ("unknown-status", "frozen", "0"),
10885            ] {
10886                mount_lookup(&server, cid, ok(order_json(cid, "SPY", status, filled)), 1).await;
10887            }
10888            mount_lookup(
10889                &server,
10890                "elsewhere",
10891                ok(order_json("elsewhere", "QQQ", "canceled", "0")),
10892                1,
10893            )
10894            .await;
10895            mount_lookup(
10896                &server,
10897                "gone",
10898                ResponseTemplate::new(404).set_body_json(serde_json::json!({
10899                    "code": 40410000,
10900                    "message": "order not found for gone"
10901                })),
10902                1,
10903            )
10904            .await;
10905
10906            let keys: Vec<_> = [
10907                "filled",
10908                "canceled",
10909                "replaced",
10910                "expired",
10911                "rejected",
10912                "done",
10913                "live",
10914                "unknown-status",
10915                "elsewhere",
10916                "gone",
10917            ]
10918            .into_iter()
10919            .map(|cid| alpaca_key("spy", cid))
10920            .collect();
10921            let ended = client_for(&server).fetch_ended_orders(&keys).await.unwrap();
10922
10923            let mut found: Vec<_> = ended
10924                .iter()
10925                .map(|order| (order.key.cid.0.as_str(), &order.state))
10926                .collect();
10927            found.sort_by_key(|(cid, _)| *cid);
10928            let [
10929                (canceled, InactiveOrderState::Cancelled(cancelled)),
10930                (expired, InactiveOrderState::Expired(expiry)),
10931                (filled, InactiveOrderState::FullyFilled(fill)),
10932                (rejected, InactiveOrderState::OpenFailed(failed)),
10933                (replaced, InactiveOrderState::Cancelled(replacement)),
10934            ] = found.as_slice()
10935            else {
10936                panic!("filled, canceled, replaced, expired and rejected: {found:?}");
10937            };
10938            assert_eq!(
10939                [*canceled, *expired, *filled, *rejected, *replaced],
10940                ["canceled", "expired", "filled", "rejected", "replaced"]
10941            );
10942            assert_eq!(
10943                (fill.filled_quantity, fill.avg_price),
10944                (dec!(2), Some(dec!(101.5)))
10945            );
10946            assert_eq!(
10947                cancelled.filled_quantity,
10948                Some(dec!(1)),
10949                "what filled before"
10950            );
10951            assert_eq!(expiry.filled_quantity, Some(dec!(1)));
10952            assert_eq!(replacement.filled_quantity, Some(Decimal::ZERO));
10953            assert_eq!(
10954                cancelled.time_exchange,
10955                parse_timestamp("2026-10-01T15:00:00Z").unwrap(),
10956                "stamped when the order last changed"
10957            );
10958            assert!(matches!(
10959                failed,
10960                OrderError::Rejected(ApiError::OrderRejected(message))
10961                    if message.contains("id-rejected")
10962            ));
10963            assert!(
10964                ended
10965                    .iter()
10966                    .all(|order| order.key.strategy == StrategyId::new("strategy")
10967                        && order.key.instrument == InstrumentNameExchange::new("spy")),
10968                "each carries the key it was asked for"
10969            );
10970        }
10971
10972        #[tokio::test]
10973        async fn fetch_ended_orders_fails_when_a_lookup_fails() {
10974            let server = MockServer::start().await;
10975            mount_lookup(&server, "a", ok(order_json("a", "SPY", "canceled", "0")), 1).await;
10976            mount_lookup(&server, "b", ResponseTemplate::new(500), 1).await;
10977
10978            let result = client_for(&server)
10979                .fetch_ended_orders(&[alpaca_key("SPY", "a"), alpaca_key("SPY", "b")])
10980                .await;
10981
10982            assert!(
10983                matches!(result, Err(UnindexedClientError::Connectivity(_))),
10984                "{result:?}"
10985            );
10986        }
10987
10988        /// One listing covers every instrument due; only the held orders it no longer shows are
10989        /// looked up, and only those that ended are reported and stop being held.
10990        #[tokio::test]
10991        async fn a_reconnect_check_lists_once_and_reports_the_orders_that_ended() {
10992            use wiremock::matchers::query_param;
10993
10994            let server = MockServer::start().await;
10995            Mock::given(method("GET"))
10996                .and(path("/v2/orders"))
10997                .and(query_param("status", "open"))
10998                .and(query_param("symbols", "QQQ,SPY"))
10999                .respond_with(ok(serde_json::json!([order_json(
11000                    "spy-listed",
11001                    "SPY",
11002                    "new",
11003                    "0"
11004                )])))
11005                .expect(1)
11006                .mount(&server)
11007                .await;
11008            mount_lookup(&server, "spy-listed", ResponseTemplate::new(500), 0).await;
11009            mount_lookup(
11010                &server,
11011                "spy-cancelled",
11012                ok(order_json("spy-cancelled", "SPY", "canceled", "1")),
11013                1,
11014            )
11015            .await;
11016            mount_lookup(
11017                &server,
11018                "spy-done",
11019                ok(order_json("spy-done", "SPY", "done_for_day", "1")),
11020                1,
11021            )
11022            .await;
11023            mount_lookup(
11024                &server,
11025                "qqq-filled",
11026                ok(order_json("qqq-filled", "QQQ", "filled", "2")),
11027                1,
11028            )
11029            .await;
11030
11031            let known = KnownLiveOrders::shared(ExchangeId::AlpacaBroker);
11032            let [listed, cancelled, done, filled] = [
11033                ("SPY", "spy-listed"),
11034                ("SPY", "spy-cancelled"),
11035                ("SPY", "spy-done"),
11036                ("QQQ", "qqq-filled"),
11037            ]
11038            .map(|(instrument, cid)| alpaca_key(instrument, cid));
11039            for key in [&listed, &cancelled, &done, &filled] {
11040                hold(&known, key);
11041            }
11042            let mut unchecked = UncheckedOrders::default();
11043            unchecked.open(known.lock().instruments());
11044            let config = Arc::new(AlpacaConfig::with_base_url(
11045                "test-key".into(),
11046                "test-secret".into(),
11047                server.uri(),
11048            ));
11049            let (tx, mut rx) = mpsc::unbounded_channel();
11050
11051            recover_alpaca_ended_orders(
11052                &reqwest::Client::new(),
11053                &Arc::new(RateLimitTracker::new()),
11054                &config,
11055                &known,
11056                &mut unchecked,
11057                &tx,
11058            )
11059            .await;
11060
11061            let mut reported: Vec<_> = std::iter::from_fn(|| rx.try_recv().ok())
11062                .map(|event| {
11063                    let AccountEventKind::OrderSnapshot(
11064                        rustrade_integration::collection::snapshot::Snapshot(order),
11065                    ) = event.kind
11066                    else {
11067                        panic!("an order snapshot: {event:?}");
11068                    };
11069                    assert!(matches!(order.state, OrderState::Inactive(_)), "{order:?}");
11070                    order.key.cid
11071                })
11072                .collect();
11073            reported.sort();
11074            assert_eq!(reported, [filled.cid.clone(), cancelled.cid.clone()]);
11075            let known = known.lock();
11076            assert!(known.contains(&listed.cid) && known.contains(&done.cid));
11077            assert!(!known.contains(&cancelled.cid) && !known.contains(&filled.cid));
11078            assert!(unchecked.is_empty(), "both instruments checked");
11079        }
11080
11081        /// A listing that fails charges every instrument in it, which then waits for a retry.
11082        #[tokio::test]
11083        async fn a_failed_listing_is_retried_for_every_instrument_in_it() {
11084            let server = MockServer::start().await;
11085            Mock::given(method("GET"))
11086                .and(path("/v2/orders"))
11087                .respond_with(ResponseTemplate::new(503))
11088                .expect(1)
11089                .mount(&server)
11090                .await;
11091
11092            let known = KnownLiveOrders::shared(ExchangeId::AlpacaBroker);
11093            hold(&known, &alpaca_key("SPY", "a"));
11094            hold(&known, &alpaca_key("QQQ", "b"));
11095            let mut unchecked = UncheckedOrders::default();
11096            unchecked.open(known.lock().instruments());
11097            let config = Arc::new(AlpacaConfig::with_base_url(
11098                "test-key".into(),
11099                "test-secret".into(),
11100                server.uri(),
11101            ));
11102            let (tx, mut rx) = mpsc::unbounded_channel();
11103
11104            recover_alpaca_ended_orders(
11105                &reqwest::Client::new(),
11106                &Arc::new(RateLimitTracker::new()),
11107                &config,
11108                &known,
11109                &mut unchecked,
11110                &tx,
11111            )
11112            .await;
11113
11114            assert!(rx.try_recv().is_err(), "nothing reported");
11115            let now = tokio::time::Instant::now();
11116            assert!(
11117                unchecked.ready(&NoPendingFills, now).is_empty(),
11118                "both wait"
11119            );
11120            for instrument in ["QQQ", "SPY"] {
11121                assert_eq!(
11122                    unchecked.failed(
11123                        &InstrumentNameExchange::new(instrument),
11124                        now,
11125                        Duration::ZERO
11126                    ),
11127                    Some(crate::client::order_recovery::GapFailure::Retry(
11128                        Duration::from_secs(crate::client::order_recovery::GAP_RETRY_BASE_SECS * 2)
11129                    )),
11130                    "{instrument} has failed once already"
11131                );
11132            }
11133            assert_eq!(known.lock().instruments().len(), 2, "both still held");
11134        }
11135
11136        #[tokio::test]
11137        async fn placing_listing_and_cancelling_keep_the_held_orders_in_step() {
11138            use crate::order::request::{OrderRequestCancel, RequestCancel, RequestOpen};
11139
11140            let server = MockServer::start().await;
11141            Mock::given(method("POST"))
11142                .and(path("/v2/orders"))
11143                .respond_with(ok(order_json("resting", "SPY", "new", "0")))
11144                .up_to_n_times(1)
11145                .mount(&server)
11146                .await;
11147            Mock::given(method("POST"))
11148                .and(path("/v2/orders"))
11149                .respond_with(ok(order_json("filled", "SPY", "filled", "2")))
11150                .mount(&server)
11151                .await;
11152            Mock::given(method("GET"))
11153                .and(path("/v2/orders"))
11154                .respond_with(ok(serde_json::json!([order_json(
11155                    "listed", "QQQ", "new", "0"
11156                )])))
11157                .mount(&server)
11158                .await;
11159            Mock::given(method("DELETE"))
11160                .and(path("/v2/orders/id-resting"))
11161                .respond_with(ResponseTemplate::new(204))
11162                .mount(&server)
11163                .await;
11164            let client = client_for(&server);
11165            let spy = InstrumentNameExchange::new("SPY");
11166
11167            for cid in ["resting", "filled"] {
11168                let request = OrderRequestOpen {
11169                    key: OrderKey::new(
11170                        ExchangeId::AlpacaBroker,
11171                        &spy,
11172                        StrategyId::new("strategy"),
11173                        ClientOrderId::new(cid),
11174                    ),
11175                    state: RequestOpen::new(
11176                        Side::Buy,
11177                        Some(Decimal::ONE_HUNDRED),
11178                        Decimal::TWO,
11179                        OrderKind::Limit,
11180                        TimeInForce::GoodUntilCancelled { post_only: false },
11181                    ),
11182                };
11183                client.open_order(request).await;
11184            }
11185            client.fetch_open_orders(&[]).await.unwrap();
11186            {
11187                let known = client.known_live.lock();
11188                assert!(known.contains(&ClientOrderId::new("resting")));
11189                assert!(
11190                    !known.contains(&ClientOrderId::new("filled")),
11191                    "filled in its placement response"
11192                );
11193                assert!(known.contains(&ClientOrderId::new("listed")));
11194            }
11195
11196            let cancel = client
11197                .cancel_order(OrderRequestCancel {
11198                    key: OrderKey::new(
11199                        ExchangeId::AlpacaBroker,
11200                        &spy,
11201                        StrategyId::new("strategy"),
11202                        ClientOrderId::new("resting"),
11203                    ),
11204                    state: RequestCancel {
11205                        id: Some(VenueOrderId::Assigned(OrderId::new("id-resting"))),
11206                    },
11207                })
11208                .await;
11209            let Ok(cancelled) = &cancel.state else {
11210                panic!("expected Ok, got {cancel:?}");
11211            };
11212            assert_eq!(
11213                cancelled.filled_quantity, None,
11214                "the DELETE response has no body, so what filled is unknown"
11215            );
11216            assert!(
11217                client
11218                    .known_live
11219                    .lock()
11220                    .contains(&ClientOrderId::new("resting")),
11221                "a cancel Alpaca has only accepted has not ended the order"
11222            );
11223        }
11224
11225        /// An order that ended in its placement response is reported as it ended, on both
11226        /// placement paths, and is not held as live: an IOC that found no liquidity or partly
11227        /// filled and expired the rest, one cancelled or replaced, one rejected after Alpaca
11228        /// accepted it, and one filled. A live status is reported open, with what filled (zero
11229        /// when that is unknown), and held, as is one this version does not know; one whose fill
11230        /// covers the quantity is reported filled.
11231        #[tokio::test]
11232        async fn an_order_that_ended_in_its_placement_response_is_reported_as_it_ended() {
11233            use crate::order::request::RequestOpen;
11234
11235            let Some(time) = parse_timestamp("2026-10-01T15:00:00Z") else {
11236                panic!("order_json's updated_at parses");
11237            };
11238            let id = |cid: &str| OrderId::new(format!("id-{cid}"));
11239            let open = |cid: &str, filled| {
11240                OrderState::active(Open::new(VenueOrderId::Assigned(id(cid)), time, filled))
11241            };
11242            let cases: [(&str, &str, &str, UnindexedOrderState, bool); 13] = [
11243                (
11244                    "ioc-unfilled",
11245                    "expired",
11246                    "0",
11247                    OrderState::expired(Expired::new(
11248                        id("ioc-unfilled"),
11249                        time,
11250                        Some(Decimal::ZERO),
11251                    )),
11252                    false,
11253                ),
11254                (
11255                    "ioc-partial",
11256                    "expired",
11257                    "1",
11258                    OrderState::expired(Expired::new(id("ioc-partial"), time, Some(Decimal::ONE))),
11259                    false,
11260                ),
11261                (
11262                    "fill-unknown",
11263                    "expired",
11264                    "not-a-number",
11265                    OrderState::expired(Expired::new(id("fill-unknown"), time, None)),
11266                    false,
11267                ),
11268                (
11269                    "cancelled",
11270                    "canceled",
11271                    "1",
11272                    OrderState::inactive(Cancelled::new(id("cancelled"), time, Some(Decimal::ONE))),
11273                    false,
11274                ),
11275                (
11276                    "rejected",
11277                    "rejected",
11278                    "0",
11279                    OrderState::inactive(OrderError::Rejected(ApiError::OrderRejected(
11280                        "Alpaca rejected order id-rejected after accepting it".to_string(),
11281                    ))),
11282                    false,
11283                ),
11284                (
11285                    "filled",
11286                    "filled",
11287                    "2",
11288                    OrderState::fully_filled(Filled::new(
11289                        id("filled"),
11290                        time,
11291                        Decimal::TWO,
11292                        Some(Decimal::new(1015, 1)),
11293                    )),
11294                    false,
11295                ),
11296                (
11297                    "replaced",
11298                    "replaced",
11299                    "0",
11300                    OrderState::inactive(Cancelled::new(id("replaced"), time, Some(Decimal::ZERO))),
11301                    false,
11302                ),
11303                (
11304                    "live-covered",
11305                    "partially_filled",
11306                    "2",
11307                    OrderState::fully_filled(Filled::new(
11308                        id("live-covered"),
11309                        time,
11310                        Decimal::TWO,
11311                        Some(Decimal::new(1015, 1)),
11312                    )),
11313                    false,
11314                ),
11315                ("new", "new", "0", open("new", Decimal::ZERO), true),
11316                (
11317                    "live-fill-unknown",
11318                    "new",
11319                    "not-a-number",
11320                    open("live-fill-unknown", Decimal::ZERO),
11321                    true,
11322                ),
11323                (
11324                    "partial",
11325                    "partially_filled",
11326                    "1",
11327                    open("partial", Decimal::ONE),
11328                    true,
11329                ),
11330                (
11331                    "for-the-day",
11332                    "done_for_day",
11333                    "1",
11334                    open("for-the-day", Decimal::ONE),
11335                    true,
11336                ),
11337                (
11338                    "unknown",
11339                    "a_status_not_yet_documented",
11340                    "0",
11341                    open("unknown", Decimal::ZERO),
11342                    true,
11343                ),
11344            ];
11345
11346            for (cid, status, filled, expected, held) in cases {
11347                for bracket in [false, true] {
11348                    let server = MockServer::start().await;
11349                    Mock::given(method("POST"))
11350                        .and(path("/v2/orders"))
11351                        .respond_with(ok(order_json(cid, "SPY", status, filled)))
11352                        .expect(1)
11353                        .mount(&server)
11354                        .await;
11355                    let client = client_for(&server);
11356                    let spy = InstrumentNameExchange::new("SPY");
11357                    let time_in_force = TimeInForce::GoodUntilCancelled { post_only: false };
11358
11359                    let state = if bracket {
11360                        client
11361                            .open_bracket_order(AlpacaBracketOrderRequest::new(
11362                                spy,
11363                                StrategyId::new("strategy"),
11364                                ClientOrderId::new(cid),
11365                                Side::Buy,
11366                                Decimal::TWO,
11367                                Decimal::ONE_HUNDRED,
11368                                Decimal::new(120, 0),
11369                                Decimal::new(90, 0),
11370                                time_in_force,
11371                            ))
11372                            .await
11373                            .parent
11374                            .state
11375                    } else {
11376                        let request = OrderRequestOpen {
11377                            key: OrderKey::new(
11378                                ExchangeId::AlpacaBroker,
11379                                &spy,
11380                                StrategyId::new("strategy"),
11381                                ClientOrderId::new(cid),
11382                            ),
11383                            state: RequestOpen::new(
11384                                Side::Buy,
11385                                Some(Decimal::ONE_HUNDRED),
11386                                Decimal::TWO,
11387                                OrderKind::Limit,
11388                                time_in_force,
11389                            ),
11390                        };
11391                        client.open_order(request).await.state
11392                    };
11393
11394                    assert_eq!(state, expected, "{status} {filled}, bracket: {bracket}");
11395                    assert_eq!(
11396                        client.known_live.lock().contains(&ClientOrderId::new(cid)),
11397                        held,
11398                        "{status} {filled}, bracket: {bracket}"
11399                    );
11400                }
11401            }
11402        }
11403
11404        /// The stream holds an acknowledged order, keeps holding one done for the day (reported
11405        /// open, not retired), and drops one cancelled.
11406        #[test]
11407        fn the_stream_keeps_the_held_orders_in_step() {
11408            let (tx, mut rx) = mpsc::unbounded_channel();
11409            let dedup = new_dedup_cache();
11410            let known = KnownLiveOrders::shared(ExchangeId::AlpacaBroker);
11411            let frame = |event: &str, filled: &str| {
11412                format!(
11413                    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}"}}}}}}"#
11414                )
11415            };
11416            let cid = ClientOrderId::new("a");
11417
11418            process_ws_text(&frame("new", "0"), &tx, &dedup, &known);
11419            assert!(known.lock().contains(&cid), "acknowledged");
11420
11421            process_ws_text(&frame("done_for_day", "1"), &tx, &dedup, &known);
11422            assert!(known.lock().contains(&cid), "done for the day is not ended");
11423
11424            process_ws_text(&frame("canceled", "1"), &tx, &dedup, &known);
11425            assert!(!known.lock().contains(&cid), "cancelled");
11426
11427            let events: Vec<_> = std::iter::from_fn(|| rx.try_recv().ok()).collect();
11428            let [_, done, cancelled] = events.as_slice() else {
11429                panic!("three events: {events:?}");
11430            };
11431            let AccountEventKind::OrderSnapshot(
11432                rustrade_integration::collection::snapshot::Snapshot(order),
11433            ) = &done.kind
11434            else {
11435                panic!("done_for_day reports an order snapshot: {done:?}");
11436            };
11437            let OrderState::Active(ActiveOrderState::Open(open)) = &order.state else {
11438                panic!("open: {order:?}");
11439            };
11440            assert_eq!(open.filled_quantity, Decimal::ONE, "with what it filled");
11441            assert!(matches!(
11442                cancelled.kind,
11443                AccountEventKind::OrderCancelled(_)
11444            ));
11445        }
11446
11447        /// A recovered fill that brings an order to its full quantity ends it, so a reconnect's
11448        /// check does not ask about it.
11449        #[tokio::test]
11450        async fn a_recovered_fill_that_completes_an_order_stops_it_being_held() {
11451            let known = KnownLiveOrders::shared(ExchangeId::AlpacaBroker);
11452            let key = alpaca_key("SPY", "a");
11453            known.lock().live(
11454                &key,
11455                Decimal::TWO,
11456                OrderKind::Limit,
11457                &Open::new(
11458                    VenueOrderId::Assigned(OrderId::new("ord-1")),
11459                    Utc::now(),
11460                    Decimal::ZERO,
11461                ),
11462            );
11463
11464            drive_recover_fills_with(
11465                vec![activity_json("act-1", "ord-1", "1", Some("1"))],
11466                new_dedup_cache(),
11467                &known,
11468            )
11469            .await;
11470            assert!(known.lock().contains(&key.cid), "half filled");
11471
11472            drive_recover_fills_with(
11473                vec![activity_json("act-2", "ord-1", "1", Some("2"))],
11474                new_dedup_cache(),
11475                &known,
11476            )
11477            .await;
11478            assert!(!known.lock().contains(&key.cid), "filled");
11479        }
11480
11481        /// The first check runs as soon as the loop is polled, and a failed one waits for its
11482        /// backoff rather than running again at once.
11483        #[tokio::test]
11484        async fn the_check_loop_runs_at_once_then_waits_out_a_failure() {
11485            let server = MockServer::start().await;
11486            Mock::given(method("GET"))
11487                .and(path("/v2/orders"))
11488                .respond_with(ResponseTemplate::new(503))
11489                .expect(1)
11490                .mount(&server)
11491                .await;
11492            let known = KnownLiveOrders::shared(ExchangeId::AlpacaBroker);
11493            hold(&known, &alpaca_key("SPY", "a"));
11494            let mut unchecked = UncheckedOrders::default();
11495            unchecked.open(known.lock().instruments());
11496            let config = Arc::new(AlpacaConfig::with_base_url(
11497                "test-key".into(),
11498                "test-secret".into(),
11499                server.uri(),
11500            ));
11501            let (tx, _rx) = mpsc::unbounded_channel();
11502
11503            let ran = tokio::time::timeout(
11504                Duration::from_secs(1),
11505                run_order_checks(
11506                    &reqwest::Client::new(),
11507                    &Arc::new(RateLimitTracker::new()),
11508                    &config,
11509                    &known,
11510                    &mut unchecked,
11511                    &tx,
11512                ),
11513            )
11514            .await;
11515
11516            assert!(ran.is_err(), "the loop never completes");
11517            assert!(
11518                unchecked.contains(&InstrumentNameExchange::new("SPY")),
11519                "SPY waits for its retry"
11520            );
11521            assert_eq!(
11522                server.received_requests().await.unwrap().len(),
11523                1,
11524                "the failed check is not run again at once"
11525            );
11526        }
11527
11528        /// Once the consumer has gone, the loop asks nothing.
11529        #[tokio::test]
11530        async fn the_check_loop_waits_once_the_consumer_has_gone() {
11531            let server = MockServer::start().await;
11532            Mock::given(method("GET"))
11533                .respond_with(ResponseTemplate::new(503))
11534                .expect(0)
11535                .mount(&server)
11536                .await;
11537            let known = KnownLiveOrders::shared(ExchangeId::AlpacaBroker);
11538            hold(&known, &alpaca_key("SPY", "a"));
11539            let mut unchecked = UncheckedOrders::default();
11540            unchecked.open(known.lock().instruments());
11541            let config = Arc::new(AlpacaConfig::with_base_url(
11542                "test-key".into(),
11543                "test-secret".into(),
11544                server.uri(),
11545            ));
11546            let (tx, rx) = mpsc::unbounded_channel();
11547            drop(rx);
11548
11549            let ran = tokio::time::timeout(
11550                Duration::from_millis(200),
11551                run_order_checks(
11552                    &reqwest::Client::new(),
11553                    &Arc::new(RateLimitTracker::new()),
11554                    &config,
11555                    &known,
11556                    &mut unchecked,
11557                    &tx,
11558                ),
11559            )
11560            .await;
11561
11562            assert!(ran.is_err(), "the loop never completes");
11563            assert!(
11564                server.received_requests().await.unwrap().is_empty(),
11565                "nothing asked once the consumer has gone"
11566            );
11567        }
11568
11569        async fn shortability_with_asset(
11570            symbol: &str,
11571            asset: serde_json::Value,
11572        ) -> (Result<Shortability, UnindexedClientError>, Vec<String>) {
11573            use crate::client::ShortabilityClient;
11574
11575            let server = MockServer::start().await;
11576            Mock::given(method("GET"))
11577                .respond_with(ResponseTemplate::new(200).set_body_json(asset))
11578                .mount(&server)
11579                .await;
11580            let result = client_for(&server)
11581                .fetch_shortability(&InstrumentNameExchange::new(symbol))
11582                .await;
11583            let paths = server
11584                .received_requests()
11585                .await
11586                .unwrap()
11587                .iter()
11588                .map(|request| request.url.path().to_owned())
11589                .collect();
11590            (result, paths)
11591        }
11592
11593        #[tokio::test]
11594        async fn an_easy_to_borrow_stock_is_shortable_at_no_fee() {
11595            let (result, paths) = shortability_with_asset(
11596                "AAPL",
11597                serde_json::json!({
11598                    "class": "us_equity",
11599                    "symbol": "AAPL",
11600                    "shortable": true,
11601                    "easy_to_borrow": true,
11602                    "borrow_status": "easy_to_borrow",
11603                }),
11604            )
11605            .await;
11606
11607            assert_eq!(
11608                result.unwrap(),
11609                Shortability::new()
11610                    .with_shortable(true)
11611                    .with_easy_to_borrow(true)
11612                    .with_fee_rate(Decimal::ZERO)
11613            );
11614            assert_eq!(paths, vec!["/v2/assets/AAPL"]);
11615        }
11616
11617        #[tokio::test]
11618        async fn a_hard_to_borrow_stock_has_no_known_fee() {
11619            let (result, _) = shortability_with_asset(
11620                "GME",
11621                serde_json::json!({
11622                    "class": "us_equity",
11623                    "shortable": true,
11624                    "easy_to_borrow": true,
11625                    "borrow_status": "hard_to_borrow",
11626                }),
11627            )
11628            .await;
11629
11630            assert_eq!(
11631                result.unwrap(),
11632                Shortability::new()
11633                    .with_shortable(true)
11634                    .with_easy_to_borrow(false),
11635                "borrow_status wins over the easy_to_borrow flag, and a hard-to-borrow fee is unknown"
11636            );
11637        }
11638
11639        #[tokio::test]
11640        async fn without_a_borrow_status_the_easy_to_borrow_flag_is_read() {
11641            let (result, _) = shortability_with_asset(
11642                "AAPL",
11643                serde_json::json!({ "class": "us_equity", "shortable": false, "easy_to_borrow": false }),
11644            )
11645            .await;
11646
11647            assert_eq!(
11648                result.unwrap(),
11649                Shortability::new()
11650                    .with_shortable(false)
11651                    .with_easy_to_borrow(false)
11652            );
11653        }
11654
11655        #[tokio::test]
11656        async fn an_asset_without_borrow_fields_is_unknown_but_for_shortable() {
11657            let (result, paths) = shortability_with_asset(
11658                "BTC/USD",
11659                serde_json::json!({ "class": "crypto", "symbol": "BTC/USD", "shortable": false }),
11660            )
11661            .await;
11662
11663            assert_eq!(result.unwrap(), Shortability::new().with_shortable(false));
11664            assert_eq!(
11665                paths,
11666                vec!["/v2/assets/BTC%2FUSD"],
11667                "a crypto pair's slash is escaped, not read as a path separator"
11668            );
11669        }
11670
11671        #[tokio::test]
11672        async fn an_option_has_no_shortability() {
11673            let (result, _) = shortability_with_asset(
11674                "AAPL250117C00150000",
11675                serde_json::json!({ "class": "us_option", "shortable": true }),
11676            )
11677            .await;
11678
11679            assert!(
11680                matches!(
11681                    result,
11682                    Err(UnindexedClientError::Api(ApiError::InstrumentInvalid(ref instrument, ref reason)))
11683                        if instrument.name().as_str() == "AAPL250117C00150000"
11684                            && reason.contains("option")
11685                ),
11686                "{result:?}"
11687            );
11688        }
11689
11690        #[tokio::test]
11691        async fn an_unknown_symbol_is_an_invalid_instrument() {
11692            use crate::client::ShortabilityClient;
11693
11694            let server = MockServer::start().await;
11695            Mock::given(method("GET"))
11696                .and(path("/v2/assets/NOPE"))
11697                .respond_with(
11698                    ResponseTemplate::new(404).set_body_json(
11699                        serde_json::json!({ "message": "asset not found for NOPE" }),
11700                    ),
11701                )
11702                .mount(&server)
11703                .await;
11704
11705            let result = client_for(&server)
11706                .fetch_shortability(&InstrumentNameExchange::new("NOPE"))
11707                .await;
11708
11709            assert!(
11710                matches!(
11711                    result,
11712                    Err(UnindexedClientError::Api(ApiError::InstrumentInvalid(ref instrument, _)))
11713                        if instrument.name().as_str() == "NOPE"
11714                ),
11715                "{result:?}"
11716            );
11717        }
11718
11719        /// Answers every request with a 429. Every answer but the last says `Retry-After: 0`, so
11720        /// the client's retries do not wait; the last carries `last`'s header, if any.
11721        struct RateLimited {
11722            call: std::sync::atomic::AtomicU32,
11723            last: Option<(&'static str, String)>,
11724        }
11725
11726        impl RateLimited {
11727            fn mount(last: Option<(&'static str, String)>) -> Self {
11728                Self {
11729                    call: std::sync::atomic::AtomicU32::new(0),
11730                    last,
11731                }
11732            }
11733        }
11734
11735        impl Respond for RateLimited {
11736            fn respond(&self, _: &Request) -> ResponseTemplate {
11737                let call = self.call.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1;
11738                let response = ResponseTemplate::new(429)
11739                    .set_body_json(serde_json::json!({"message": "too many requests"}));
11740                if call < MAX_RATE_LIMIT_ATTEMPTS {
11741                    return response.insert_header("retry-after", "0");
11742                }
11743                match &self.last {
11744                    Some((name, value)) => response.insert_header(*name, value.as_str()),
11745                    None => response,
11746                }
11747            }
11748        }
11749
11750        fn unix_now() -> u64 {
11751            std::time::SystemTime::now()
11752                .duration_since(std::time::UNIX_EPOCH)
11753                .unwrap()
11754                .as_secs()
11755        }
11756
11757        #[tokio::test]
11758        async fn exhausted_429_retries_report_alpacas_wait() {
11759            let server = MockServer::start().await;
11760            Mock::given(method("GET"))
11761                .and(path("/v2/account"))
11762                .respond_with(RateLimited::mount(Some(("retry-after", "7".to_string()))))
11763                .mount(&server)
11764                .await;
11765
11766            let http = reqwest::Client::new();
11767            let rl = RateLimitTracker::new();
11768            let url = format!("{}/v2/account", server.uri());
11769            let error = rest_with_retry::<serde_json::Value>(&rl, || http.get(&url))
11770                .await
11771                .unwrap_err();
11772
11773            let UnindexedClientError::Api(error) = error else {
11774                panic!("expected Api, got {error:?}");
11775            };
11776            assert_eq!(error.retry_after(), Some(Duration::from_secs(7)));
11777            let blocked_until = rl
11778                .blocked_until
11779                .lock()
11780                .expect("the last refusal pauses the client");
11781            assert!(
11782                blocked_until >= tokio::time::Instant::now() + Duration::from_secs(6),
11783                "the client's next request waits for Alpaca's wait"
11784            );
11785            assert_eq!(
11786                server.received_requests().await.unwrap().len(),
11787                MAX_RATE_LIMIT_ATTEMPTS as usize
11788            );
11789        }
11790
11791        #[tokio::test]
11792        async fn exhausted_429_retries_read_the_reset_time_without_retry_after() {
11793            let server = MockServer::start().await;
11794            Mock::given(method("GET"))
11795                .and(path("/v2/account"))
11796                .respond_with(RateLimited::mount(Some((
11797                    "x-ratelimit-reset",
11798                    (unix_now() + 30).to_string(),
11799                ))))
11800                .mount(&server)
11801                .await;
11802
11803            let http = reqwest::Client::new();
11804            let rl = RateLimitTracker::new();
11805            let url = format!("{}/v2/account", server.uri());
11806            let error = rest_with_retry::<serde_json::Value>(&rl, || http.get(&url))
11807                .await
11808                .unwrap_err();
11809
11810            let UnindexedClientError::Api(ApiError::RateLimit {
11811                retry_after: Some(wait),
11812                ..
11813            }) = error
11814            else {
11815                panic!("expected a RateLimit with a wait, got {error:?}");
11816            };
11817            assert!(
11818                (Duration::from_secs(29)..=Duration::from_secs(30)).contains(&wait),
11819                "{wait:?}"
11820            );
11821        }
11822
11823        #[tokio::test]
11824        async fn exhausted_429_retries_without_a_hint_report_none() {
11825            let server = MockServer::start().await;
11826            Mock::given(method("GET"))
11827                .and(path("/v2/account"))
11828                .respond_with(RateLimited::mount(None))
11829                .mount(&server)
11830                .await;
11831
11832            let http = reqwest::Client::new();
11833            let rl = RateLimitTracker::new();
11834            let url = format!("{}/v2/account", server.uri());
11835            let error = rest_with_retry::<serde_json::Value>(&rl, || http.get(&url))
11836                .await
11837                .unwrap_err();
11838
11839            assert!(
11840                matches!(
11841                    error,
11842                    UnindexedClientError::Api(ApiError::RateLimit {
11843                        retry_after: None,
11844                        ..
11845                    })
11846                ),
11847                "{error:?}"
11848            );
11849        }
11850
11851        #[tokio::test]
11852        async fn an_exhausted_cancel_reports_alpacas_wait() {
11853            let server = MockServer::start().await;
11854            Mock::given(method("DELETE"))
11855                .and(path("/v2/orders/abc"))
11856                .respond_with(RateLimited::mount(Some(("retry-after", "5".to_string()))))
11857                .mount(&server)
11858                .await;
11859
11860            let http = reqwest::Client::new();
11861            let rl = RateLimitTracker::new();
11862            let url = format!("{}/v2/orders/abc", server.uri());
11863            let error = rest_delete_with_retry(&rl, || http.delete(&url))
11864                .await
11865                .unwrap_err();
11866
11867            let UnindexedOrderError::Rejected(error) = error else {
11868                panic!("expected Rejected, got {error:?}");
11869            };
11870            assert_eq!(error.retry_after(), Some(Duration::from_secs(5)));
11871            assert!(
11872                rl.blocked_until.lock().is_some(),
11873                "the last refusal pauses the client"
11874            );
11875        }
11876    }
11877}