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 the last message, placed on Alpaca's clock as the times it stamped on the stream's
26//   events show it (see fill_recovery_start); sent through the dedup cache to filter duplicates. A read that fails,
27//   times out or truncates is reported as AccountEventKind::FillRecoveryGaveUp
28// - Dedup cache: LRU keyed on each fill's execution id (with "{order_id}:{cumulative_filled_qty}"
29//   for a fill without one) prevents duplicate fills arising from the overlap between WS events
30//   before disconnect and the fill-recovery REST window
31// - Ended-order recovery: after the fills, each order held as live that one listing of the open
32//   orders no longer shows is looked up by client order id (GET /v2/orders:by_client_order_id),
33//   and how it ended is reported; a failed check is retried on a backoff while connected
34//
35// Known limitations:
36// - Order lifecycle events missed while disconnected are recovered only for orders the client
37//   holds as live (placed through it, listed by it, or seen on its stream); an order placed
38//   elsewhere and ended during the outage is not reported.
39// - A fill frame whose order status is neither partially_filled nor filled emits the
40//   execution but no order snapshot, so a fill arriving after its order's terminal frame
41//   cannot resurrect a retired order as a resting one. That order's filled quantity is
42//   settled instead by the terminal frame's own filled_qty, or by fetch_open_orders.
43// - A trade_bust or trade_correct sent while disconnected is not recovered: fill recovery reads
44//   FILL activities only.
45
46use crate::{
47    AccountEventKind, AccountSnapshot, FillRecoveryFailure, FillRecoveryGap, FillRecoveryScope,
48    InstrumentAccountSnapshot, UnindexedAccountEvent, UnindexedAccountSnapshot,
49    balance::{AssetBalance, Balance},
50    cash_flow::{CashFlow, CashFlowId, CashFlowKind, CashFlowsRead},
51    client::{
52        BracketOrderClient, ExecutionClient, OrderStatusClient, ShortabilityClient,
53        order_recovery::{
54            KnownLiveOrders, NoPendingFills, OpenListing, OrderLookup, SharedKnownLiveOrders,
55            UncheckedOrders, fetch_ended_by_key, recover_ended_orders,
56        },
57        reconnect_budget::{BackoffLimits, ReconnectBudget},
58        venue_clock::{HostMoment, VenueClock},
59    },
60    emit_stream_terminated,
61    error::{
62        ApiError, BorrowReject, BorrowRejectReason, ConnectivityError, OrderError,
63        StreamTerminationReason, UnindexedApiError, UnindexedClientError, UnindexedOrderError,
64    },
65    order::{
66        Order, OrderKey, OrderKind, TimeInForce, TrailingOffsetType, UnindexedInactiveOrder,
67        UnindexedOrderKey, UnindexedOrderSnapshot,
68        bracket::{
69            BracketOrderRequest as UnifiedBracketOrderRequest,
70            BracketOrderResult as UnifiedBracketOrderResult,
71        },
72        id::{ClientOrderId, OrderId, StrategyId, VenueOrderId},
73        request::{OrderRequestCancel, OrderRequestOpen, ReduceOnly, UnindexedOrderResponseCancel},
74        state::{
75            ActiveOrderState, Cancelled, Expired, Filled, InactiveOrderState, Open, OrderState,
76            UnindexedOrderState,
77        },
78    },
79    parse_env_bool,
80    position::{Position, PositionReport},
81    shortability::Shortability,
82    trade::{AssetFees, TimeSource, Trade, TradeAmendmentKind, TradeId, TradeOrigin, TradesRead},
83};
84use chrono::{DateTime, NaiveDate, NaiveTime, SecondsFormat, TimeDelta, Utc};
85use fnv::{FnvHashMap, FnvHashSet};
86use futures::{SinkExt as _, StreamExt as _, stream::BoxStream};
87use indexmap::IndexMap;
88use itertools::Itertools as _;
89use lru::LruCache;
90use rust_decimal::Decimal;
91use rustrade_instrument::{
92    Side,
93    asset::name::AssetNameExchange,
94    exchange::ExchangeId,
95    instrument::{kind::InstrumentKindDiscriminant, name::InstrumentNameExchange},
96};
97use rustrade_integration::protocol::websocket::{WebSocket, WsMessage};
98use serde::{Deserialize, Serialize, de::DeserializeOwned};
99use smol_str::{SmolStr, format_smolstr};
100use std::{num::NonZeroUsize, pin::Pin, str::FromStr, sync::Arc, time::Duration};
101use tokio::sync::mpsc;
102use tracing::{debug, error, info, trace, warn};
103
104// ---------------------------------------------------------------------------
105// Constants
106// ---------------------------------------------------------------------------
107
108/// If no WS activity for this long, force reconnect.
109const HEARTBEAT_TIMEOUT_SECS: u64 = 35;
110/// Timeout for fill recovery REST queries after reconnect.
111const FILL_RECOVERY_TIMEOUT_SECS: u64 = 30;
112/// Extra lookback from the disconnect to cover the delay between a fill and its delivery, and
113/// Tokio scheduling jitter. The dedup cache absorbs resulting duplicates.
114const SIGNAL_RECOVERY_LOOKBACK_MS: i64 = 1_500;
115/// How much further back fill recovery starts before the stream has seen a time Alpaca stamped,
116/// when Alpaca's clock is unknown: the difference between this host's clock and Alpaca's that is
117/// allowed for then. Alpaca's requests are not signed with a timestamp, so nothing else bounds it.
118const UNSAMPLED_CLOCK_SKEW: TimeDelta = TimeDelta::seconds(30);
119/// Alpaca's activity page size limit.
120const ALPACA_MAX_ACTIVITIES: usize = 100;
121/// Cooldown when rate-limited without a readable wait, and the longest wait read from Alpaca's
122/// headers: the length of its rate-limit window.
123const DEFAULT_RATE_LIMIT_DELAY_SECS: u64 = 60;
124/// Total REST attempts (1 initial + retries) before giving up on rate-limit errors.
125/// The loop runs `0..MAX_RATE_LIMIT_ATTEMPTS`, retrying while `attempt + 1 < MAX`.
126const MAX_RATE_LIMIT_ATTEMPTS: u32 = 4;
127/// Dedup LRU cache size, in keys. Each fill records two (see [`is_duplicate_fill`]), so this holds
128/// the last 2,000 fills. Each key is a ~40–70 byte string (a UUID, or a UUID and a decimal):
129/// 4_000 keys ≈ 250 KB — ample for options trading fill rates.
130const DEDUP_CACHE_SIZE: usize = 4_000;
131
132/// The account currency, named as Alpaca spells it. Every USD balance and fee is reported under
133/// it.
134const USD: &str = "USD";
135/// Timeout for the initial WS auth+subscribe handshake.
136const WS_HANDSHAKE_TIMEOUT_SECS: u64 = 15;
137/// Timeout for a graceful WS close. Prevents indefinite blocking when the
138/// server does not respond to the close frame before reconnect/shutdown.
139const WS_CLOSE_TIMEOUT_SECS: u64 = 5;
140
141// ---------------------------------------------------------------------------
142// GracefulShutdownStream
143// ---------------------------------------------------------------------------
144
145/// Wrapper stream that signals the `connection_manager` task to shut down gracefully
146/// when dropped, allowing it to send an orderly WebSocket close frame.
147///
148/// # Shutdown sequence
149/// Dropping this stream drops `inner` (the channel receiver), which makes `tx.closed()`
150/// resolve on the `connection_manager`'s next `select!` poll. The `tx.closed()` arm
151/// sends a WebSocket close frame (with `WS_CLOSE_TIMEOUT_SECS` timeout) and then
152/// returns, dropping the task cleanly.
153///
154/// `JoinHandle::drop` detaches the task — it is NOT aborted. The task exits within
155/// the current `select!` iteration (if the receiver is already dropped when polled)
156/// or after the current heartbeat window / backoff sleep at most.
157struct GracefulShutdownStream<S> {
158    inner: S,
159    /// Keeps the `JoinHandle` alive until this stream is dropped. Dropping
160    /// the `JoinHandle` detaches (not cancels) the task, allowing it to keep
161    /// running until `tx.closed()` resolves. Without this field the handle
162    /// would be detached immediately at `connection_manager` spawn time,
163    /// preventing any future `.await` or abort if the design changes.
164    _handle: tokio::task::JoinHandle<()>,
165}
166
167impl<S> GracefulShutdownStream<S> {
168    fn new(inner: S, handle: tokio::task::JoinHandle<()>) -> Self {
169        Self {
170            inner,
171            _handle: handle,
172        }
173    }
174}
175
176impl<S: futures::Stream + Unpin> futures::Stream for GracefulShutdownStream<S> {
177    type Item = S::Item;
178    fn poll_next(
179        mut self: Pin<&mut Self>,
180        cx: &mut std::task::Context<'_>,
181    ) -> std::task::Poll<Option<Self::Item>> {
182        Pin::new(&mut self.inner).poll_next(cx)
183    }
184    fn size_hint(&self) -> (usize, Option<usize>) {
185        self.inner.size_hint()
186    }
187}
188
189impl<S> Drop for GracefulShutdownStream<S> {
190    fn drop(&mut self) {
191        // Do not abort. Dropping `self.inner` (the channel receiver) makes `tx.closed()`
192        // resolve, which causes `connection_manager` to send a graceful WS close frame
193        // and return. Dropping the JoinHandle here detaches (not cancels) the task.
194    }
195}
196
197// ---------------------------------------------------------------------------
198// Rate limit tracker
199// ---------------------------------------------------------------------------
200
201/// Thread-safe rate-limit state shared across all clones of AlpacaClient.
202struct RateLimitTracker {
203    blocked_until: parking_lot::Mutex<Option<tokio::time::Instant>>,
204}
205
206impl RateLimitTracker {
207    fn new() -> Self {
208        Self {
209            blocked_until: parking_lot::Mutex::new(None),
210        }
211    }
212
213    /// Sleep until the current cooldown expires. Returns immediately if not blocked.
214    async fn wait_if_blocked(&self) {
215        loop {
216            // Capture the current time once per iteration — reused for both the
217            // expired-deadline check inside the lock and the sleep calculation below.
218            // Eliminates one vDSO call per REST request on the common non-blocked path.
219            let now = tokio::time::Instant::now();
220            // Read and conditionally clear the deadline in a single lock acquisition.
221            // The guard is dropped before the `.await` below — holding a sync Mutex
222            // across an await would deadlock. A TOCTOU window still exists between the
223            // guard drop and `sleep_until`: a concurrent `on_rate_limited` call could
224            // extend the deadline after we read it. The loop re-reads on wake and
225            // corrects any extended deadline, so the race is recovered from on the next
226            // iteration rather than being fully prevented.
227            let deadline = {
228                let mut guard = self.blocked_until.lock();
229                let d = *guard;
230                if matches!(d, Some(t) if t <= now) {
231                    // Clear expired deadline so on_rate_limited correctly
232                    // distinguishes "new rate-limit event" from "extended cooldown".
233                    *guard = None;
234                }
235                d
236            };
237            match deadline {
238                None => return,
239                Some(until) => {
240                    if until <= now {
241                        // Deadline was expired and cleared above; no sleep needed.
242                        return;
243                    }
244                    // as_millis() returns u128; truncation impossible (u64::MAX ms ≈ 584M years)
245                    #[allow(clippy::cast_possible_truncation)]
246                    let delay_ms = (until - now).as_millis() as u64;
247                    debug!(delay_ms, "Alpaca REST rate-limited, waiting before request");
248                    tokio::time::sleep_until(until).await;
249                }
250            }
251        }
252    }
253
254    /// Record a rate-limit event, extending any existing cooldown if longer.
255    fn on_rate_limited(&self, retry_after: Option<Duration>) {
256        let delay = retry_after.unwrap_or(Duration::from_secs(DEFAULT_RATE_LIMIT_DELAY_SECS));
257        let was_blocked = self.extend(delay);
258        if was_blocked {
259            debug!(
260                delay_secs = delay.as_secs(),
261                "Alpaca rate-limit cooldown extended"
262            );
263        } else {
264            warn!(
265                delay_secs = delay.as_secs(),
266                "Alpaca entering rate-limit degradation mode"
267            );
268        }
269    }
270
271    /// Record a response reporting no requests left in the current window: pause every request
272    /// until the window resets, `reset` from now, so the next one is not refused with a 429.
273    fn on_bucket_exhausted(&self, reset: Duration) {
274        let was_blocked = self.extend(reset);
275        // info! not warn!: nothing was refused. A 429 logs at warn.
276        if !was_blocked {
277            info!(
278                delay_ms = u64::try_from(reset.as_millis()).unwrap_or(u64::MAX),
279                "Alpaca rate-limit window exhausted (X-Ratelimit-Remaining: 0), pausing requests until it resets"
280            );
281        }
282    }
283
284    /// Push the cooldown out to `delay` from now unless it already ends later, and return whether
285    /// one was active.
286    fn extend(&self, delay: Duration) -> bool {
287        let now = tokio::time::Instant::now();
288        let new_deadline = now + delay;
289        let mut guard = self.blocked_until.lock();
290        let was_blocked = guard.is_some_and(|until| until > now);
291        *guard = Some(guard.map_or(new_deadline, |existing| existing.max(new_deadline)));
292        was_blocked
293    }
294}
295
296// ---------------------------------------------------------------------------
297// Dedup cache
298// ---------------------------------------------------------------------------
299
300/// LRU cache of the fills the account stream has delivered, so that a fill both the WebSocket
301/// and fill recovery deliver, in the overlap around a reconnect, is sent once. See
302/// [`is_duplicate_fill`] for how a fill is recognised.
303type SharedDedupCache = Arc<parking_lot::Mutex<LruCache<FillKey, ()>>>;
304
305/// One key under which [`SharedDedupCache`] records a fill.
306///
307/// [`SmolStr`] keys avoid heap allocation for ids ≤22 bytes. UUID-length keys (36 chars) always
308/// heap-allocate.
309#[derive(Debug, Clone, PartialEq, Eq, Hash)]
310enum FillKey {
311    /// The fill's execution id: the WS frame's `execution_id`, or the UUID after `::` in its FILL
312    /// activity's id ([`activity_execution_id_part`]).
313    Execution(SmolStr),
314    /// The fill's [`fill_dedup_key`], recorded for every fill.
315    Cumulative(SmolStr),
316    /// The fill's [`fill_dedup_key`], recorded only for a fill without a usable execution id.
317    CumulativeWithoutId(SmolStr),
318}
319
320fn new_dedup_cache() -> SharedDedupCache {
321    // allow(clippy::unwrap_used) — NonZeroUsize::new on a non-zero constant
322    // cannot fail at runtime.
323    #[allow(clippy::unwrap_used)]
324    Arc::new(parking_lot::Mutex::new(LruCache::new(
325        NonZeroUsize::new(DEDUP_CACHE_SIZE).unwrap(),
326    )))
327}
328
329/// Returns `true` if this fill was already delivered (duplicate). Records it if not.
330///
331/// `execution_id` is the fill's execution id, `None` when it has no usable one; `cumulative` is
332/// its [`fill_dedup_key`].
333///
334/// A fill is identified by its execution id, which both paths carry and which Alpaca's activity
335/// docs name as the dedup key. The cumulative key alone would not do: a bust lowers an order's
336/// cumulative, after which a new fill can take it back to a cumulative an earlier fill reached,
337/// and that fill would be taken for the earlier one and dropped.
338///
339/// A fill without an execution id is recognised by its cumulative key instead. So that it still
340/// matches the same fill delivered *with* an id by the other path, every fill records its
341/// cumulative key, and a fill with an id is also checked against the cumulative keys of fills
342/// recorded without one.
343///
344/// So after a bust, a new fill can still be taken for an earlier one at the same cumulative when
345/// either lacks an execution id: a fill without one matches any earlier fill's cumulative key,
346/// and a fill with one matches an earlier id-less fill's. An id-less WS fill is logged at `warn!`
347/// by [`convert_trade_update_at`].
348fn is_duplicate_fill(
349    cache: &SharedDedupCache,
350    execution_id: Option<&str>,
351    cumulative: SmolStr,
352) -> bool {
353    // Built before locking, so the lock is not held across the allocation.
354    let execution = execution_id.map(|id| FillKey::Execution(SmolStr::new(id)));
355    let mut guard = cache.lock();
356    // peek avoids promoting to MRU on the duplicate (discard) path
357    match execution {
358        Some(execution) => {
359            if guard.peek(&execution).is_some()
360                || guard
361                    .peek(&FillKey::CumulativeWithoutId(cumulative.clone()))
362                    .is_some()
363            {
364                return true;
365            }
366            guard.put(execution, ());
367            guard.put(FillKey::Cumulative(cumulative), ());
368        }
369        None => {
370            if guard
371                .peek(&FillKey::Cumulative(cumulative.clone()))
372                .is_some()
373            {
374                return true;
375            }
376            guard.put(FillKey::Cumulative(cumulative.clone()), ());
377            guard.put(FillKey::CumulativeWithoutId(cumulative), ());
378        }
379    }
380    false
381}
382
383/// Record an execution delivered other than as a fill, so that fill recovery does not deliver it
384/// again as one.
385fn record_execution(cache: &SharedDedupCache, execution_id: &str) {
386    let execution = FillKey::Execution(SmolStr::new(execution_id));
387    cache.lock().put(execution, ());
388}
389
390// ---------------------------------------------------------------------------
391// Configuration
392// ---------------------------------------------------------------------------
393
394/// Configuration for the Alpaca execution client.
395// Serialize intentionally omitted — would expose secret_key in plaintext.
396#[derive(Clone, Deserialize)]
397#[serde(deny_unknown_fields)]
398pub struct AlpacaConfig {
399    // Private fields prevent accidental credential exposure via struct access.
400    api_key: String,
401    secret_key: String,
402
403    /// Use paper trading endpoints instead of production.
404    #[serde(default = "default_paper")]
405    pub paper: bool,
406    /// Test-only: override the REST base URL (e.g., to point at a wiremock server).
407    #[cfg(test)]
408    #[serde(skip)]
409    base_url_override: Option<String>,
410    /// Test-only: override the WebSocket URL (e.g., to point at a local server).
411    #[cfg(test)]
412    #[serde(skip)]
413    ws_url_override: Option<String>,
414}
415
416/// Serde default for [`AlpacaConfig::paper`]: an absent `paper` field deserializes to the **safe**
417/// paper environment (`true`).
418///
419/// `#[serde(default = "…")]` requires a named function (it cannot take a literal), so this exists
420/// purely to supply that default to the derive.
421fn default_paper() -> bool {
422    true
423}
424
425// Custom Debug to avoid leaking credentials in logs.
426impl std::fmt::Debug for AlpacaConfig {
427    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
428        f.debug_struct("AlpacaConfig")
429            .field("api_key", &"***")
430            .field("secret_key", &"***")
431            .field("paper", &self.paper)
432            .finish()
433    }
434}
435
436impl AlpacaConfig {
437    /// Create a new Alpaca config using the **safe** paper-trading endpoints (alias for
438    /// [`paper`](Self::paper)).
439    pub fn new(api_key: String, secret_key: String) -> Self {
440        Self::paper(api_key, secret_key)
441    }
442
443    /// Create a config targeting Alpaca's **paper-trading** endpoints (simulated funds).
444    pub fn paper(api_key: String, secret_key: String) -> Self {
445        Self {
446            api_key,
447            secret_key,
448            paper: true,
449            #[cfg(test)]
450            base_url_override: None,
451            #[cfg(test)]
452            ws_url_override: None,
453        }
454    }
455
456    /// Create a config targeting Alpaca's **production** endpoints.
457    ///
458    /// ⚠️ Production trades execute against the live account with **real funds**. Prefer
459    /// [`paper`](Self::paper) unless you explicitly intend live execution.
460    pub fn production(api_key: String, secret_key: String) -> Self {
461        Self {
462            api_key,
463            secret_key,
464            paper: false,
465            #[cfg(test)]
466            base_url_override: None,
467            #[cfg(test)]
468            ws_url_override: None,
469        }
470    }
471
472    /// Build a config from environment variables.
473    ///
474    /// Reads:
475    /// - `ALPACA_API_KEY` (required) — API key id.
476    /// - `ALPACA_SECRET_KEY` (required) — API secret.
477    /// - `ALPACA_PAPER` (optional) — `"true"`/`"false"` (case-insensitive). **Absent ⇒ the safe
478    ///   paper environment.** Set `ALPACA_PAPER=false` to target production (real funds).
479    ///
480    /// # Errors
481    ///
482    /// Returns [`AlpacaConfigError`] (never panics):
483    /// - a required credential var is unset ([`MissingApiKey`](AlpacaConfigError::MissingApiKey) /
484    ///   [`MissingSecretKey`](AlpacaConfigError::MissingSecretKey)) or holds non-UTF-8
485    ///   ([`InvalidApiKey`](AlpacaConfigError::InvalidApiKey) /
486    ///   [`InvalidSecretKey`](AlpacaConfigError::InvalidSecretKey));
487    /// - `ALPACA_PAPER` is neither `true` nor `false`, or holds non-UTF-8
488    ///   ([`InvalidPaper`](AlpacaConfigError::InvalidPaper)).
489    pub fn from_env() -> Result<Self, AlpacaConfigError> {
490        let api_key = match std::env::var("ALPACA_API_KEY") {
491            Ok(value) => value,
492            Err(std::env::VarError::NotPresent) => return Err(AlpacaConfigError::MissingApiKey),
493            Err(std::env::VarError::NotUnicode(_)) => {
494                return Err(AlpacaConfigError::InvalidApiKey);
495            }
496        };
497        let secret_key = match std::env::var("ALPACA_SECRET_KEY") {
498            Ok(value) => value,
499            Err(std::env::VarError::NotPresent) => return Err(AlpacaConfigError::MissingSecretKey),
500            Err(std::env::VarError::NotUnicode(_)) => {
501                return Err(AlpacaConfigError::InvalidSecretKey);
502            }
503        };
504
505        let paper = match std::env::var("ALPACA_PAPER") {
506            Ok(value) => parse_env_bool(&value).ok_or(AlpacaConfigError::InvalidPaper(value))?,
507            Err(std::env::VarError::NotPresent) => true,
508            // The toggle value is not secret, so echo it (lossily) like the parse-failure arm above —
509            // an actionable "got X" beats a hardcoded sentinel.
510            Err(std::env::VarError::NotUnicode(value)) => {
511                return Err(AlpacaConfigError::InvalidPaper(
512                    value.to_string_lossy().into_owned(),
513                ));
514            }
515        };
516
517        if paper {
518            Ok(Self::paper(api_key, secret_key))
519        } else {
520            Ok(Self::production(api_key, secret_key))
521        }
522    }
523
524    /// Test-only: create config with a custom base URL for wiremock testing.
525    #[cfg(test)]
526    pub fn with_base_url(api_key: String, secret_key: String, base_url: String) -> Self {
527        Self {
528            api_key,
529            secret_key,
530            paper: true,
531            base_url_override: Some(base_url),
532            ws_url_override: None,
533        }
534    }
535
536    pub fn api_key(&self) -> &str {
537        &self.api_key
538    }
539
540    /// Base URL for REST API calls.
541    ///
542    /// In test builds, checks `base_url_override` first to allow wiremock testing.
543    pub fn rest_base_url(&self) -> &str {
544        #[cfg(test)]
545        if let Some(ref url) = self.base_url_override {
546            return url.as_str();
547        }
548        if self.paper {
549            "https://paper-api.alpaca.markets"
550        } else {
551            "https://api.alpaca.markets"
552        }
553    }
554
555    /// WebSocket URL for trade_updates stream.
556    pub fn ws_url(&self) -> &'static str {
557        if self.paper {
558            "wss://paper-api.alpaca.markets/stream"
559        } else {
560            "wss://api.alpaca.markets/stream"
561        }
562    }
563
564    /// The URL the account stream connects to: [`ws_url`](Self::ws_url), or in test builds
565    /// `ws_url_override` when set.
566    fn stream_url(&self) -> &str {
567        #[cfg(test)]
568        if let Some(ref url) = self.ws_url_override {
569            return url.as_str();
570        }
571        self.ws_url()
572    }
573}
574
575#[derive(Debug, PartialEq, thiserror::Error)]
576pub enum AlpacaConfigError {
577    #[error("ALPACA_API_KEY environment variable not set")]
578    MissingApiKey,
579
580    // No payload: the raw value is secret-key material, so it must never be echoed into an error
581    // message or log. The variant name already identifies which credential var is non-UTF-8.
582    #[error("ALPACA_API_KEY environment variable is not valid UTF-8")]
583    InvalidApiKey,
584
585    #[error("ALPACA_SECRET_KEY environment variable not set")]
586    MissingSecretKey,
587
588    #[error("ALPACA_SECRET_KEY environment variable is not valid UTF-8")]
589    InvalidSecretKey,
590
591    #[error("ALPACA_PAPER must be true or false, got {0}")]
592    InvalidPaper(String),
593}
594
595// ---------------------------------------------------------------------------
596// REST response serde types
597// ---------------------------------------------------------------------------
598
599/// The fields of GET /v2/account the USD balance is built from.
600///
601/// Amounts are decoded as decimals, so a malformed one fails the request instead of reading as
602/// zero.
603#[derive(Debug, Deserialize)]
604struct AlpacaAccount {
605    /// Settled and unsettled cash. Negative when the account has borrowed on margin; a short
606    /// sale's proceeds are credited to it.
607    #[serde(with = "rust_decimal::serde::str")]
608    cash: Decimal,
609    /// Buying power for securities that cannot be bought on margin. On a margin account it is
610    /// equity less the initial margin requirement, so it counts the loan value of held
611    /// marginable stock and can exceed cash; a short sale's proceeds do not raise it.
612    #[serde(with = "rust_decimal::serde::str")]
613    non_marginable_buying_power: Decimal,
614}
615
616/// A single position returned by GET /v2/positions.
617///
618/// Amounts are decoded as decimals, so a malformed one fails the request instead of reading as
619/// zero or as a flat position.
620#[derive(Debug, Deserialize)]
621struct AlpacaPosition {
622    /// Exchange symbol (e.g., "BTCUSD" for crypto, "AAPL" for equity, an OCC symbol for an
623    /// option).
624    symbol: String,
625    /// Asset class: "us_equity", "crypto", "us_option".
626    asset_class: String,
627    /// Direction of the position. The size is taken from `qty`'s magnitude and the sign from
628    /// this, so a short reads as short whichever sign `qty` carries. Missing reads as
629    /// [`AlpacaPositionSide::Unknown`].
630    #[serde(default)]
631    side: AlpacaPositionSide,
632    /// Quantity held: shares, contracts, or base currency for crypto.
633    #[serde(with = "rust_decimal::serde::str")]
634    qty: Decimal,
635    /// Quantity available to trade (not locked in open orders).
636    #[serde(with = "rust_decimal::serde::str")]
637    qty_available: Decimal,
638    /// Average entry price per unit.
639    #[serde(default, with = "rust_decimal::serde::str_option")]
640    avg_entry_price: Option<Decimal>,
641    /// Unrealised profit or loss of the whole position, in USD.
642    #[serde(default, with = "rust_decimal::serde::str_option")]
643    unrealized_pl: Option<Decimal>,
644}
645
646/// Direction of an [`AlpacaPosition`].
647#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize)]
648#[serde(rename_all = "lowercase")]
649enum AlpacaPositionSide {
650    Long,
651    Short,
652    /// A missing side or a value Alpaca does not document. Decoded rather than rejected so that
653    /// one position cannot fail the whole response, which also carries the crypto balances; a
654    /// position that needs a direction fails in [`convert_positions`] instead.
655    #[default]
656    #[serde(other)]
657    Unknown,
658}
659
660#[derive(Debug, Deserialize)]
661struct AlpacaOrderResponse {
662    id: String,
663    client_order_id: Option<String>,
664    symbol: String,
665    qty: Option<String>,
666    filled_qty: String,
667    /// The average price of the order's fills, when any filled.
668    #[serde(default)]
669    filled_avg_price: Option<String>,
670    /// The order's status: `new`, `filled`, `canceled`, and so on. Read by the lookup of how an
671    /// order ended and from the response to placing one. Optional so that a response without it
672    /// still decodes.
673    #[serde(default)]
674    status: Option<String>,
675    side: String,
676    #[serde(rename = "type")]
677    order_type: String,
678    time_in_force: String,
679    limit_price: Option<String>,
680    stop_price: Option<String>,
681    trail_percent: Option<String>,
682    trail_price: Option<String>,
683    created_at: String,
684    /// When the order last changed state. Nullable in Alpaca's schema, so callers fall back to
685    /// `created_at` via [`order_state_time`].
686    updated_at: Option<String>,
687}
688
689#[derive(Debug, Deserialize)]
690struct AlpacaActivity {
691    id: String,
692    order_id: String,
693    symbol: String,
694    side: String,
695    price: String,
696    qty: String,
697    transaction_time: String,
698    /// The order's cumulative filled quantity as of this execution, as Alpaca reported it --
699    /// the same figure the WebSocket path reads from `order.filled_qty`.
700    ///
701    /// Alpaca's schema lists this on trade activities without qualifying by fill type -- unlike
702    /// `leaves_qty`, which it documents as partial-fill-specific. It is parsed as optional
703    /// regardless, so an omission degrades to the fallback below rather than discarding the
704    /// whole activity.
705    cum_qty: Option<String>,
706}
707
708/// A non-trade account activity, as Alpaca's activities endpoint serves it: a cash movement such as
709/// interest or a fee, read by [`AlpacaClient::fetch_cash_flows`], or an option exercise,
710/// assignment or expiry, read by [`AlpacaClient::fetch_option_events`]. A cash flow uses the
711/// amount, currency and symbol; an option event uses the quantity, price, symbol and date. Every
712/// field but the id and type is optional in Alpaca's schema, so a record missing one still decodes
713/// and the conversion decides what it can do without it.
714#[derive(Debug, Deserialize)]
715struct AlpacaNonTradeActivity {
716    id: String,
717    activity_type: String,
718    #[serde(default)]
719    activity_sub_type: Option<String>,
720    /// When Alpaca created the activity, which its `after` and `until` filter on. For a fee, this
721    /// is typically the day after the trade it was charged on.
722    #[serde(default)]
723    created_at: Option<String>,
724    /// The day the activity occurred or settled, as `YYYY-MM-DD`. A cash flow's time uses
725    /// `created_at`, which `after` and `until` filter on. An option event falls back to it when
726    /// `created_at` is missing, and pairs the legs of an exercise or assignment by it.
727    #[serde(default)]
728    date: Option<String>,
729    /// Signed: negative when the account paid.
730    #[serde(default)]
731    net_amount: Option<String>,
732    /// Signed: negative when the account sold. Contracts on an option event, or shares on the
733    /// share leg (`OPTRD`) of an exercise or assignment.
734    #[serde(default)]
735    qty: Option<String>,
736    /// On the share leg (`OPTRD`) of an exercise or assignment, the strike the shares traded at.
737    #[serde(default)]
738    price: Option<String>,
739    #[serde(default)]
740    currency: Option<String>,
741    #[serde(default)]
742    symbol: Option<String>,
743    /// `executed`, `correct` or `canceled`.
744    #[serde(default)]
745    status: Option<String>,
746}
747
748/// The body of an Alpaca error response: `{"code": 40310000, "message": "..."}`.
749///
750/// `code` is absent from some responses, notably the bare `{"message": "forbidden."}` that a
751/// wrong key, secret or host gets.
752#[derive(Debug, Deserialize)]
753struct AlpacaApiError {
754    code: Option<i64>,
755    message: String,
756}
757
758impl AlpacaApiError {
759    /// Decode an error body, keeping the raw text as the message when it is not Alpaca's JSON.
760    fn from_body(bytes: &[u8]) -> Self {
761        serde_json::from_slice(bytes).unwrap_or_else(|_| Self {
762            code: None,
763            message: String::from_utf8_lossy(bytes).into_owned(),
764        })
765    }
766}
767
768// ---------------------------------------------------------------------------
769// AlpacaPositionIntent
770// ---------------------------------------------------------------------------
771
772/// Explicit position intent for Alpaca order placement.
773///
774/// Required for options orders; valid (but optional) for equities.
775/// Omit entirely for crypto orders (causes 422 Unprocessable Entity).
776///
777/// Use `AlpacaClient::open_order_with_intent` to supply a specific intent
778/// instead of the heuristic mapping used by the `ExecutionClient` trait impl.
779#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
780#[serde(rename_all = "snake_case")]
781pub enum AlpacaPositionIntent {
782    BuyToOpen,
783    BuyToClose,
784    SellToOpen,
785    SellToClose,
786}
787
788// ---------------------------------------------------------------------------
789// Bracket order types
790// ---------------------------------------------------------------------------
791
792/// Take-profit parameters for Alpaca bracket orders.
793///
794/// The take-profit leg is always a limit order at the specified price.
795#[derive(Debug, Serialize)]
796struct TakeProfitParams {
797    limit_price: String,
798}
799
800/// Stop-loss parameters for Alpaca bracket orders.
801///
802/// When `limit_price` is `None`, the stop-loss is a stop (market) order.
803/// When `limit_price` is `Some`, the stop-loss becomes a stop-limit order.
804#[derive(Debug, Serialize)]
805struct StopLossParams {
806    stop_price: String,
807    #[serde(skip_serializing_if = "Option::is_none")]
808    limit_price: Option<String>,
809}
810
811/// Request to place a bracket order (entry + take-profit + stop-loss).
812///
813/// A bracket order consists of three linked orders submitted in a single request:
814/// 1. **Entry**: Limit order to enter the position
815/// 2. **Take Profit**: Limit order to exit at profit target
816/// 3. **Stop Loss**: Stop or stop-limit order to exit at loss limit
817///
818/// When either the take-profit or stop-loss fills, Alpaca automatically cancels
819/// the other leg.
820///
821/// # Constraints
822///
823/// - `time_in_force` must be `Day` or `GoodUntilCancelled` (no extended hours)
824/// - Entry order type is always `Limit`
825/// - Take-profit is always a `Limit` order
826/// - Stop-loss is a `Stop` order (or `StopLimit` if `stop_loss_limit_price` is set)
827///
828/// # Example
829///
830/// ```ignore
831/// let request = AlpacaBracketOrderRequest::new(
832///     "AAPL".into(),
833///     StrategyId::new("momentum"),
834///     ClientOrderId::new("bracket-001"),
835///     Side::Buy,
836///     dec!(10),
837///     dec!(150.00),  // entry
838///     dec!(160.00),  // take profit
839///     dec!(145.00),  // stop loss
840///     TimeInForce::GoodUntilCancelled { post_only: false },
841/// );
842/// // For stop-limit SL: .with_stop_loss_limit_price(dec!(144.00))
843/// let result = client.open_bracket_order(request).await;
844/// ```
845#[non_exhaustive]
846#[derive(Debug, Clone)]
847pub struct AlpacaBracketOrderRequest {
848    /// Instrument to trade.
849    pub instrument: InstrumentNameExchange,
850    /// Strategy identifier for order correlation.
851    pub strategy: StrategyId,
852    /// Client order ID for the parent (entry) order.
853    pub cid: ClientOrderId,
854    /// Buy or Sell for the entry order (exits use opposite side).
855    pub side: Side,
856    /// Number of shares/contracts.
857    pub quantity: Decimal,
858    /// Entry limit price.
859    pub entry_price: Decimal,
860    /// Take-profit limit price.
861    pub take_profit_price: Decimal,
862    /// Stop-loss trigger price.
863    pub stop_loss_price: Decimal,
864    /// Optional stop-loss limit price. When set, makes the stop-loss a stop-limit order.
865    pub stop_loss_limit_price: Option<Decimal>,
866    /// Time-in-force for all legs. Must be `Day` or `GoodUntilCancelled`.
867    pub time_in_force: TimeInForce,
868}
869
870impl AlpacaBracketOrderRequest {
871    /// Create a new bracket order request.
872    ///
873    /// For a stop-limit stop-loss leg, chain `.with_stop_loss_limit_price()`.
874    #[allow(clippy::too_many_arguments)] // Bracket orders inherently need many params
875    pub fn new(
876        instrument: InstrumentNameExchange,
877        strategy: StrategyId,
878        cid: ClientOrderId,
879        side: Side,
880        quantity: Decimal,
881        entry_price: Decimal,
882        take_profit_price: Decimal,
883        stop_loss_price: Decimal,
884        time_in_force: TimeInForce,
885    ) -> Self {
886        Self {
887            instrument,
888            strategy,
889            cid,
890            side,
891            quantity,
892            entry_price,
893            take_profit_price,
894            stop_loss_price,
895            stop_loss_limit_price: None,
896            time_in_force,
897        }
898    }
899
900    /// Set the stop-loss limit price, converting the SL leg to a stop-limit order.
901    #[must_use]
902    pub fn with_stop_loss_limit_price(mut self, price: Decimal) -> Self {
903        self.stop_loss_limit_price = Some(price);
904        self
905    }
906}
907
908/// Result of placing an Alpaca bracket order.
909///
910/// Contains the parent order with its state. The take-profit and stop-loss legs
911/// are managed by Alpaca and their status can be queried via `fetch_open_orders`.
912///
913/// # Note
914///
915/// Unlike IBKR which returns three separate orders, Alpaca's bracket API returns
916/// a single parent order. The child legs (TP/SL) are implicitly created and linked
917/// by Alpaca. Use `fetch_open_orders` to retrieve all legs after placement.
918#[non_exhaustive]
919#[derive(Debug, Clone)]
920pub struct AlpacaBracketOrderResult {
921    /// Parent (entry) order with its current state.
922    pub parent: Order<ExchangeId, InstrumentNameExchange, UnindexedOrderState>,
923}
924
925// ---------------------------------------------------------------------------
926// REST order request body
927// ---------------------------------------------------------------------------
928
929#[derive(Debug, Serialize)]
930struct AlpacaOrderRequest<'a> {
931    symbol: &'a str,
932    qty: String,
933    side: &'static str,
934    #[serde(rename = "type")]
935    order_type: &'static str,
936    time_in_force: &'static str,
937    #[serde(skip_serializing_if = "Option::is_none")]
938    limit_price: Option<String>,
939    #[serde(skip_serializing_if = "Option::is_none")]
940    stop_price: Option<String>,
941    #[serde(skip_serializing_if = "Option::is_none")]
942    trail_percent: Option<String>,
943    #[serde(skip_serializing_if = "Option::is_none")]
944    trail_price: Option<String>,
945    #[serde(skip_serializing_if = "Option::is_none")]
946    client_order_id: Option<&'a str>,
947    // position_intent: heuristic mapping (buy→buy_to_open, sell→sell_to_close).
948    // Correct for directional long-only strategies. Omit for exchanges/asset
949    // classes that don't require it (stocks/crypto ignore this field).
950    #[serde(skip_serializing_if = "Option::is_none")]
951    position_intent: Option<AlpacaPositionIntent>,
952    // Bracket order fields (order_class, take_profit, stop_loss).
953    // Set order_class to "bracket" and populate TP/SL for bracket orders.
954    #[serde(skip_serializing_if = "Option::is_none")]
955    order_class: Option<&'static str>,
956    #[serde(skip_serializing_if = "Option::is_none")]
957    take_profit: Option<TakeProfitParams>,
958    #[serde(skip_serializing_if = "Option::is_none")]
959    stop_loss: Option<StopLossParams>,
960}
961
962// ---------------------------------------------------------------------------
963// WebSocket message types
964// ---------------------------------------------------------------------------
965
966/// Outer container for all Alpaca stream messages.
967///
968/// `data` is kept as a [`serde_json::value::RawValue`] to avoid allocating a full DOM tree
969/// for heartbeats and auth/listening acks that never reach `AlpacaTradeUpdate` parsing.
970#[derive(Debug, Deserialize)]
971struct AlpacaStreamMessage<'a> {
972    // Short well-known values ("trade_updates", "listening", "authorization")
973    // all fit inline in SmolStr — avoids one heap alloc per WS message.
974    stream: SmolStr,
975    #[serde(borrow)]
976    data: &'a serde_json::value::RawValue,
977}
978
979/// Parsed payload of a `trade_updates` event.
980///
981/// Numeric and timestamp fields borrow directly from the `RawValue` input buffer
982/// (`#[serde(borrow)]`), propagating the zero-copy design of `AlpacaStreamMessage`.
983/// This eliminates 4–6 heap allocations per fill event. The borrow is valid because
984/// Alpaca's numeric and timestamp strings, and its execution ids (UUIDs), contain no JSON escape
985/// sequences.
986#[derive(Debug, Deserialize)]
987struct AlpacaTradeUpdate<'a> {
988    // Short event tag ("fill", "partial_fill", "new", ...) — fits inline.
989    event: SmolStr,
990    #[serde(borrow)]
991    order: AlpacaOrderWs<'a>,
992    /// Fill price for this specific execution (None for non-fill events).
993    #[serde(borrow)]
994    price: Option<&'a str>,
995    /// Quantity for this specific execution (None for non-fill events).
996    #[serde(borrow)]
997    qty: Option<&'a str>,
998    #[serde(borrow)]
999    timestamp: Option<&'a str>,
1000    /// Alpaca's id for this event. On a fill it is the execution's id, the same as the UUID after
1001    /// `::` in that fill's FILL activity id (see [`activity_execution_id`]), so it is the fill's
1002    /// [`TradeId`] on every path. On a `trade_correct` it is taken as the corrected execution's id.
1003    #[serde(borrow)]
1004    execution_id: Option<&'a str>,
1005    /// On `trade_bust` and `trade_correct` only: the `execution_id` of the fill busted or
1006    /// corrected, per Alpaca's Broker API schema (`TradeUpdateEventV2`). The Trading API's docs do
1007    /// not list either event, so this is unverified against a real frame.
1008    #[serde(borrow)]
1009    previous_execution_id: Option<&'a str>,
1010}
1011
1012/// Order state embedded in a `trade_updates` WebSocket event.
1013#[derive(Debug, Deserialize)]
1014struct AlpacaOrderWs<'a> {
1015    // UUIDs (36 chars) heap-allocate in SmolStr, but using SmolStr directly avoids
1016    // an intermediate String allocation when serde deserialises the field.
1017    id: SmolStr,
1018    client_order_id: Option<SmolStr>,
1019    // Ticker symbols ("AAPL", "BTC/USD") fit inline in SmolStr (≤23 bytes),
1020    // eliminating the heap allocation entirely for most symbols.
1021    symbol: SmolStr,
1022    #[serde(borrow)]
1023    qty: Option<&'a str>,
1024    // Alpaca guarantees `filled_qty` for fill/partial_fill and most lifecycle
1025    // events, but some event types (e.g. `rejected`) may omit the field.
1026    // Using Option avoids a deserialization failure that would silently drop
1027    // the event. A snapshot of a live order reads its absence as nothing filled
1028    // yet; a cancel reads it as unknown; the dedup key, and a fill's fallback trade id, read it
1029    // as "0".
1030    #[serde(borrow)]
1031    filled_qty: Option<&'a str>,
1032    // Short enums ("buy"/"sell", "market"/"limit"/..., "day"/"gtc"/..., status)
1033    // — all fit inline in SmolStr.
1034    side: SmolStr,
1035    #[serde(rename = "type")]
1036    order_type: SmolStr,
1037    time_in_force: SmolStr,
1038    #[serde(borrow)]
1039    limit_price: Option<&'a str>,
1040    #[serde(borrow)]
1041    stop_price: Option<&'a str>,
1042    #[serde(borrow)]
1043    trail_percent: Option<&'a str>,
1044    #[serde(borrow)]
1045    trail_price: Option<&'a str>,
1046    status: SmolStr,
1047    /// When the order last changed, as of this event. Read only for an event without a readable
1048    /// `timestamp`, as the nearest time the venue stamped.
1049    #[serde(borrow, default)]
1050    updated_at: Option<&'a str>,
1051}
1052
1053// ---------------------------------------------------------------------------
1054// AlpacaClient
1055// ---------------------------------------------------------------------------
1056
1057/// Alpaca execution client supporting options, equities, and crypto via the
1058/// single unified Alpaca trading API.
1059///
1060/// All three asset classes share the same REST and WebSocket endpoints. The
1061/// key behavioral differences handled transparently:
1062/// - Options: `position_intent` field is required (detected by OCC symbol format)
1063/// - Crypto: `position_intent` is omitted (not a valid field for crypto orders);
1064///   fractional quantities are supported natively via `Decimal::to_string()`
1065/// - Equities: `position_intent` is valid but optional for long-only strategies
1066///
1067/// # Rate limits
1068/// Once a response reports no requests left in the current window (`X-Ratelimit-Remaining: 0`),
1069/// every request, orders included, waits until the window resets (`X-Ratelimit-Reset`, at most a
1070/// minute away), so it is not refused. A 429 waits the same way, for the wait Alpaca gives or a
1071/// minute without one, and is retried. Once the retries are exhausted the request fails with
1072/// [`ApiError::RateLimit`], carrying that wait, and later requests still wait for it.
1073///
1074/// # Fees, interest and borrow fees
1075/// Every [`Trade`] this client reports carries zero fees. Equities and options trade without
1076/// commission. Alpaca's fill events carry no fee for crypto either: it is taken from the asset
1077/// the trade credits, so it shows only in that asset's balance.
1078///
1079/// Alpaca posts its other charges, such as margin interest and fees, to the account's cash as
1080/// account activities. A USD balance's `total` is that cash, so it includes a charge once Alpaca
1081/// has posted it. [`ExecutionClient::account_stream`] delivers none of them, nor any balance:
1082/// Alpaca streams neither. [`AlpacaClient::fetch_cash_flows`] reads them on request. PnL computed
1083/// from fills excludes them.
1084///
1085/// Cloning is cheap: all inner state is behind `Arc`.
1086#[derive(Clone)]
1087pub struct AlpacaClient {
1088    config: Arc<AlpacaConfig>,
1089    /// reqwest client with APCA-API-KEY-ID and APCA-API-SECRET-KEY pre-set as
1090    /// default headers — every request carries auth automatically.
1091    http: reqwest::Client,
1092    rate_limiter: Arc<RateLimitTracker>,
1093    /// Pre-allocated `/v2/orders` endpoint URL to avoid allocation per request.
1094    orders_url: String,
1095    /// The orders seen live and not yet seen end, which a reconnect asks about. Shared by every
1096    /// clone and every account stream, since an order placed through one ends on any of them.
1097    known_live: SharedKnownLiveOrders,
1098}
1099
1100impl std::fmt::Debug for AlpacaClient {
1101    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1102        f.debug_struct("AlpacaClient")
1103            .field("paper", &self.config.paper)
1104            .finish_non_exhaustive()
1105    }
1106}
1107
1108impl AlpacaClient {
1109    /// Build a `reqwest::Client` with Alpaca auth headers pre-set.
1110    ///
1111    /// # Panics
1112    ///
1113    /// Panics if the API key or secret contains characters that are invalid
1114    /// in an HTTP header value (non-ASCII or control characters).
1115    #[allow(clippy::expect_used)] // Documented panic: invalid credentials detected at startup
1116    fn build_http(config: &AlpacaConfig) -> reqwest::Client {
1117        use reqwest::header::{HeaderMap, HeaderName, HeaderValue};
1118        let mut headers = HeaderMap::new();
1119        // HTTP header names are case-insensitive; use lowercase per HTTP/2 convention.
1120        headers.insert(
1121            HeaderName::from_static("apca-api-key-id"),
1122            HeaderValue::from_str(&config.api_key)
1123                .expect("Alpaca API key contains invalid header characters"),
1124        );
1125        headers.insert(
1126            HeaderName::from_static("apca-api-secret-key"),
1127            HeaderValue::from_str(&config.secret_key)
1128                .expect("Alpaca secret key contains invalid header characters"),
1129        );
1130        reqwest::Client::builder()
1131            .default_headers(headers)
1132            .build()
1133            .expect("failed to build reqwest client for Alpaca")
1134    }
1135
1136    fn base_url(&self) -> &str {
1137        self.config.rest_base_url()
1138    }
1139
1140    /// Hold each of `orders` as live, for a reconnect to ask about if the stream misses its end.
1141    fn remember_live<'a>(
1142        &self,
1143        orders: impl IntoIterator<Item = &'a Order<ExchangeId, InstrumentNameExchange, Open>>,
1144    ) {
1145        let mut known = self.known_live.lock();
1146        for order in orders {
1147            known.live(&order.key, order.quantity, order.kind, &order.state);
1148        }
1149    }
1150}
1151
1152// ---------------------------------------------------------------------------
1153// REST helper: rate-limited request with retry
1154// ---------------------------------------------------------------------------
1155
1156/// How long Alpaca asks a rate-limited client to wait, from a 429's headers.
1157///
1158/// `Retry-After`, a delay in seconds, when present: taken as given, zero included, and not read in
1159/// its HTTP-date form. Otherwise `X-RateLimit-Reset`, which Alpaca documents: a Unix timestamp in
1160/// seconds, read as the time from now until then, at least 1 s so a reset already past never
1161/// busy-loops. `None` when neither header is readable; callers fall back to
1162/// [`DEFAULT_RATE_LIMIT_DELAY_SECS`].
1163///
1164/// Either is capped at [`DEFAULT_RATE_LIMIT_DELAY_SECS`], the length of Alpaca's window: a longer
1165/// wait is a bad header or clock skew, and must not hold orders back for longer, nor overflow the
1166/// cooldown's deadline.
1167///
1168/// The data crate's Alpaca REST client reads a 429 by the same rule.
1169fn parse_rate_limit_delay(headers: &reqwest::header::HeaderMap) -> Option<Duration> {
1170    let header = |name: &str| {
1171        headers
1172            .get(name)
1173            .and_then(|value| value.to_str().ok())
1174            .and_then(|value| value.trim().parse::<u64>().ok())
1175    };
1176
1177    let seconds = match header("retry-after") {
1178        Some(seconds) => seconds,
1179        None => {
1180            let reset = header("x-ratelimit-reset")?;
1181            let now = std::time::SystemTime::now()
1182                .duration_since(std::time::UNIX_EPOCH)
1183                .unwrap_or_default()
1184                .as_secs();
1185            reset.saturating_sub(now).max(1)
1186        }
1187    };
1188    Some(Duration::from_secs(
1189        seconds.min(DEFAULT_RATE_LIMIT_DELAY_SECS),
1190    ))
1191}
1192
1193/// Pause every request until the window resets when a response reports none remaining in it.
1194///
1195/// The pause is at most [`DEFAULT_RATE_LIMIT_DELAY_SECS`], the length of Alpaca's window, as
1196/// [`parse_rate_limit_delay`] caps it. Call on
1197/// any response except a 429, which sets its own cooldown. Without an
1198/// `X-Ratelimit-Reset` there is nothing to pause until, so the next request goes ahead and a 429,
1199/// if it comes, backs off.
1200fn observe_rate_limit_remaining(
1201    rate_limiter: &RateLimitTracker,
1202    headers: &reqwest::header::HeaderMap,
1203) {
1204    let exhausted = headers
1205        .get("x-ratelimit-remaining")
1206        .and_then(|v| v.to_str().ok())
1207        .and_then(|s| s.parse::<u32>().ok())
1208        == Some(0);
1209    if !exhausted {
1210        return;
1211    }
1212    match parse_rate_limit_delay(headers) {
1213        Some(reset) => rate_limiter.on_bucket_exhausted(reset),
1214        None => debug!(
1215            "Alpaca REST rate-limit window exhausted (X-Ratelimit-Remaining: 0) without X-Ratelimit-Reset"
1216        ),
1217    }
1218}
1219
1220/// Execute a REST request with rate-limit awareness and retry.
1221///
1222/// The `build_request` closure is called on every attempt so the caller doesn't
1223/// need a clone of `RequestBuilder` (which may not be cloneable with streaming bodies).
1224/// For GET/POST/DELETE with fixed bodies, the closure is a cheap re-construction.
1225///
1226/// On HTTP 429, reads the cooldown with [`parse_rate_limit_delay`] and retries up to
1227/// `MAX_RATE_LIMIT_ATTEMPTS - 1` times. Every 429, the last included, pauses later requests for
1228/// that cooldown, so a caller handed [`ApiError::RateLimit`] does not meet the same refusal on its
1229/// next call. Any other response reporting
1230/// `X-Ratelimit-Remaining: 0` pauses later requests until that reset; see
1231/// [`observe_rate_limit_remaining`].
1232///
1233/// # Errors
1234///
1235/// - [`UnindexedClientError::Connectivity`]: the request or its body read failed, or a 5xx.
1236/// - [`UnindexedClientError::Api`]: a 4xx, classified by [`parse_api_error`], or 429 retries
1237///   exhausted ([`ApiError::RateLimit`], with Alpaca's wait when its headers give one).
1238/// - [`UnindexedClientError::Internal`]: a 2xx body that does not decode into `T`, or a 204 on a
1239///   call that expects a body. Retrying returns the same answer.
1240async fn rest_with_retry<T>(
1241    rate_limiter: &RateLimitTracker,
1242    build_request: impl FnMut() -> reqwest::RequestBuilder,
1243) -> Result<T, UnindexedClientError>
1244where
1245    T: for<'de> Deserialize<'de>,
1246{
1247    match rest_request(rate_limiter, build_request).await? {
1248        Fetched::Found(value) => Ok(value),
1249        Fetched::NotFound(message) => Err(UnindexedClientError::Api(parse_api_error(
1250            reqwest::StatusCode::NOT_FOUND,
1251            None,
1252            &message,
1253        ))),
1254    }
1255}
1256
1257/// [`rest_with_retry`] for a lookup by id: a 404 is `Ok(None)`, Alpaca knowing nothing under the
1258/// id asked for, rather than an error.
1259async fn rest_lookup_with_retry<T>(
1260    rate_limiter: &RateLimitTracker,
1261    build_request: impl FnMut() -> reqwest::RequestBuilder,
1262) -> Result<Option<T>, UnindexedClientError>
1263where
1264    T: for<'de> Deserialize<'de>,
1265{
1266    match rest_request(rate_limiter, build_request).await? {
1267        Fetched::Found(value) => Ok(Some(value)),
1268        Fetched::NotFound(_) => Ok(None),
1269    }
1270}
1271
1272/// What [`rest_request`] got back from a request that did not fail.
1273#[derive(Debug)]
1274enum Fetched<T> {
1275    /// A 2xx body, decoded.
1276    Found(T),
1277    /// A 404, with Alpaca's message.
1278    NotFound(String),
1279}
1280
1281/// [`rest_with_retry`] and [`rest_lookup_with_retry`], which differ only in how they read a 404.
1282async fn rest_request<T>(
1283    rate_limiter: &RateLimitTracker,
1284    mut build_request: impl FnMut() -> reqwest::RequestBuilder,
1285) -> Result<Fetched<T>, UnindexedClientError>
1286where
1287    T: for<'de> Deserialize<'de>,
1288{
1289    for attempt in 0..MAX_RATE_LIMIT_ATTEMPTS {
1290        rate_limiter.wait_if_blocked().await;
1291        let response = build_request()
1292            .send()
1293            .await
1294            .map_err(|e| connectivity_err(format!("Alpaca REST request failed: {e}")))?;
1295
1296        let status = response.status();
1297
1298        if status == reqwest::StatusCode::TOO_MANY_REQUESTS {
1299            let reset_delay = parse_rate_limit_delay(response.headers());
1300            // Every refusal pauses the client, the last included, so the next request waits too.
1301            rate_limiter.on_rate_limited(reset_delay);
1302
1303            if attempt + 1 < MAX_RATE_LIMIT_ATTEMPTS {
1304                warn!(
1305                    attempt = attempt + 1,
1306                    max_attempts = MAX_RATE_LIMIT_ATTEMPTS,
1307                    "Alpaca REST rate-limited (429), retrying"
1308                );
1309                continue;
1310            }
1311
1312            // Final attempt still rate-limited — return typed error.
1313            warn!(
1314                max_attempts = MAX_RATE_LIMIT_ATTEMPTS,
1315                "Alpaca REST rate-limit retries exhausted"
1316            );
1317            return Err(UnindexedClientError::Api(ApiError::rate_limited(
1318                reset_delay,
1319            )));
1320        }
1321
1322        observe_rate_limit_remaining(rate_limiter, response.headers());
1323
1324        // 204 No Content is only valid for DELETE endpoints; use rest_delete_with_retry
1325        // for those. Reaching here for a 204 indicates API misuse — return a clear error
1326        // rather than a misleading "EOF while parsing" JSON failure. A retry cannot fix it.
1327        if status == reqwest::StatusCode::NO_CONTENT {
1328            return Err(UnindexedClientError::Internal(
1329                "Alpaca REST returned 204 No Content — use rest_delete_with_retry for DELETE endpoints"
1330                    .to_string(),
1331            ));
1332        }
1333
1334        let bytes = response
1335            .bytes()
1336            .await
1337            .map_err(|e| connectivity_err(format!("Alpaca REST read body failed: {e}")))?;
1338
1339        // A 2xx body that does not fit the model is not transient: retrying returns the same
1340        // body. An order path must still treat it as status unknown; see `order_post_error`.
1341        if status.is_success() {
1342            return serde_json::from_slice::<T>(&bytes)
1343                .map(Fetched::Found)
1344                .map_err(|e| {
1345                    UnindexedClientError::Internal(format!(
1346                        "Alpaca REST JSON parse error ({status}): {e} | body: {}",
1347                        String::from_utf8_lossy(&bytes)
1348                            .chars()
1349                            .take(200)
1350                            .collect::<String>()
1351                    ))
1352                });
1353        }
1354
1355        // Parse API error body for a better error message.
1356        let api_err = AlpacaApiError::from_body(&bytes);
1357
1358        if status == reqwest::StatusCode::NOT_FOUND {
1359            return Ok(Fetched::NotFound(api_err.message));
1360        }
1361
1362        // 4xx = API-level rejection (wrong parameters, auth failure, insufficient funds).
1363        // 5xx / other = server-side failure treated as connectivity error.
1364        // Callers that pattern-match on UnindexedClientError (e.g. open_order_inner) rely
1365        // on Api(ApiError) to classify business rejections vs connectivity failures.
1366        //
1367        // Uses parse_api_error for consistent classification: 422 "insufficient funds"
1368        // maps to BalanceInsufficient (not generic OrderRejected), enabling callers to
1369        // trigger balance refresh on insufficient-funds rejections.
1370        if status.is_client_error() {
1371            return Err(UnindexedClientError::Api(parse_api_error(
1372                status,
1373                api_err.code,
1374                &api_err.message,
1375            )));
1376        }
1377        return Err(connectivity_err(format!(
1378            "Alpaca REST error {status}: {}",
1379            api_err.message
1380        )));
1381    }
1382    unreachable!("Alpaca REST retry loop exited without returning")
1383}
1384
1385/// Execute a DELETE request, returning an order error on rejection.
1386///
1387/// Handles 204 No Content (success), other 4xx (API rejection, see [`parse_api_error`]), and 429
1388/// (rate limit, retried and pausing later requests as in [`rest_with_retry`]). Any other response
1389/// reporting `X-Ratelimit-Remaining: 0` pauses later requests,
1390/// as in [`rest_with_retry`].
1391async fn rest_delete_with_retry(
1392    rate_limiter: &RateLimitTracker,
1393    mut build_request: impl FnMut() -> reqwest::RequestBuilder,
1394) -> Result<(), UnindexedOrderError> {
1395    for attempt in 0..MAX_RATE_LIMIT_ATTEMPTS {
1396        rate_limiter.wait_if_blocked().await;
1397        let response = build_request().send().await.map_err(|e| {
1398            UnindexedOrderError::Connectivity(ConnectivityError::Socket(format!(
1399                "Alpaca cancel request failed: {e}"
1400            )))
1401        })?;
1402
1403        let status = response.status();
1404
1405        if status == reqwest::StatusCode::TOO_MANY_REQUESTS {
1406            let reset_delay = parse_rate_limit_delay(response.headers());
1407            // Every refusal pauses the client, the last included, so the next request waits too.
1408            rate_limiter.on_rate_limited(reset_delay);
1409
1410            if attempt + 1 < MAX_RATE_LIMIT_ATTEMPTS {
1411                warn!(
1412                    attempt = attempt + 1,
1413                    max_attempts = MAX_RATE_LIMIT_ATTEMPTS,
1414                    "Alpaca cancel rate-limited (429), retrying"
1415                );
1416                continue;
1417            }
1418
1419            // Final attempt still rate-limited — return typed error.
1420            warn!(
1421                max_attempts = MAX_RATE_LIMIT_ATTEMPTS,
1422                "Alpaca cancel rate-limit retries exhausted"
1423            );
1424            return Err(UnindexedOrderError::Rejected(ApiError::rate_limited(
1425                reset_delay,
1426            )));
1427        }
1428
1429        observe_rate_limit_remaining(rate_limiter, response.headers());
1430
1431        // 204 No Content: cancel succeeded.
1432        if status == reqwest::StatusCode::NO_CONTENT || status.is_success() {
1433            return Ok(());
1434        }
1435
1436        let bytes = response
1437            .bytes()
1438            .await
1439            .inspect_err(
1440                |e| warn!(%e, %status, "Alpaca cancel_order: failed to read error response body"),
1441            )
1442            .unwrap_or_default();
1443        let api_err = AlpacaApiError::from_body(&bytes);
1444
1445        return Err(parse_order_error(status, api_err.code, &api_err.message));
1446    }
1447    unreachable!("Alpaca cancel retry loop exited without returning")
1448}
1449
1450// ---------------------------------------------------------------------------
1451// ExecutionClient implementation
1452// ---------------------------------------------------------------------------
1453
1454impl ExecutionClient for AlpacaClient {
1455    const EXCHANGE: ExchangeId = ExchangeId::AlpacaBroker;
1456
1457    // Equities and crypto are both `Spot` here — Alpaca settles each as an outright holding, and
1458    // the client separates them only by symbol shape. `Option` is genuinely routed: the
1459    // `position_intent` Alpaca requires on options orders is derived from that same shape test,
1460    // which is `is_options_or_equity_symbol` — a not-a-crypto-pair check (`!symbol.contains('/')`),
1461    // not OCC format validation. Equities take the branch too, harmlessly: `position_intent` is
1462    // accepted on an equity order.
1463    const SUPPORTED_KINDS: &'static [InstrumentKindDiscriminant] = &[
1464        InstrumentKindDiscriminant::Spot,
1465        InstrumentKindDiscriminant::Option,
1466    ];
1467    type Config = AlpacaConfig;
1468    type AccountStream = BoxStream<'static, UnindexedAccountEvent>;
1469
1470    /// # Panics
1471    ///
1472    /// Panics if the API key or secret key contains characters invalid in an
1473    /// HTTP header value.
1474    fn new(config: Self::Config) -> Self {
1475        let http = Self::build_http(&config);
1476        let orders_url = format!("{}/v2/orders", config.rest_base_url());
1477        Self {
1478            config: Arc::new(config),
1479            http,
1480            rate_limiter: Arc::new(RateLimitTracker::new()),
1481            orders_url,
1482            known_live: KnownLiveOrders::shared(ExchangeId::AlpacaBroker),
1483        }
1484    }
1485
1486    /// # Balances and positions
1487    ///
1488    /// - **USD**: `total` is the account's `cash`, and `free` the lesser of cash and its
1489    ///   `non_marginable_buying_power`: cash that can be spent without borrowing. Alpaca's
1490    ///   buying power figures include the loan value of held stock, so none of them alone is
1491    ///   free cash. A short sale's proceeds are credited to cash but do not raise buying power,
1492    ///   so `free` stays below `total` while a short is open. Cash is negative while the account
1493    ///   borrows on margin, and then `free` is negative too. Account equity is not reported: it
1494    ///   is cash plus the value of the positions below, so it can be derived.
1495    /// - **Equities and options**: each holding becomes the
1496    ///   [`InstrumentAccountSnapshot::position`] of its instrument, [`PositionReport::Open`] with a
1497    ///   signed [`Position`] (negative for a short), the average entry price and the unrealised
1498    ///   PnL in USD. An equity or option without a holding is [`PositionReport::Flat`], since
1499    ///   Alpaca lists every open position. An option's
1500    ///   entry price is the premium per share of the underlying, as quoted and as orders are
1501    ///   priced, not per contract. Margin, liquidation price and leverage are `None`, and
1502    ///   `time_exchange` is the time of the call, since Alpaca does not timestamp positions.
1503    /// - **Crypto**: each holding is an asset balance of the asset it holds (e.g. `BTC` for the
1504    ///   position Alpaca lists as `BTCUSD`), in base units. Alpaca crypto is spot-only and cannot
1505    ///   be sold short, so there is no position to report: its instruments are
1506    ///   [`PositionReport::Unreported`].
1507    ///
1508    /// # Limitations
1509    ///
1510    /// A position is reported only under the instrument name Alpaca uses for it, ignoring case:
1511    /// the ticker for an equity and the OCC symbol for an option. A requested equity or option
1512    /// named otherwise is reported [`PositionReport::Flat`] even while Alpaca holds it. A crypto
1513    /// pair is recognised by the `/` in its name (e.g. `BTC/USD`, the form its orders use); one
1514    /// named without it is taken for an equity and reported flat too. With `instruments` empty,
1515    /// every instrument that has an open order or a position gets a snapshot; otherwise only the
1516    /// requested ones do.
1517    ///
1518    /// # Errors
1519    ///
1520    /// [`ClientError::Internal`](crate::error::ClientError::Internal) when a response does not
1521    /// decode, or when any non-zero equity or option position, requested or not, has a missing or
1522    /// unrecognised `side`: its direction cannot be known, and leaving it out would read as flat.
1523    /// Other request failures are `Connectivity` or `Api`, as for every Alpaca REST call.
1524    ///
1525    /// # Rate limit note
1526    ///
1527    /// `/v2/positions` is fetched on every call, since it holds both the crypto balances and the
1528    /// equity and option positions. When USD is requested too (the common startup case),
1529    /// `/v2/account` is fetched in parallel with it for ~100-300ms latency savings. Under rate
1530    /// pressure, both requests may hit 429 simultaneously and retry independently. Operators
1531    /// approaching Alpaca rate limits can request only non-USD assets to skip `/v2/account`.
1532    async fn account_snapshot(
1533        &self,
1534        assets: &[AssetNameExchange],
1535        instruments: &[InstrumentNameExchange],
1536    ) -> Result<UnindexedAccountSnapshot, UnindexedClientError> {
1537        let base = self.base_url();
1538        let http = self.http.clone();
1539        let rl = &self.rate_limiter;
1540
1541        let wants_usd = assets.is_empty() || assets.iter().any(is_usd);
1542        let wants_non_usd = assets.is_empty() || assets.iter().any(|a| !is_usd(a));
1543
1544        // URLs are extracted before the closures to avoid re-allocating on each retry attempt.
1545        let account_url = format!("{base}/v2/account");
1546        let positions_url = format!("{base}/v2/positions");
1547        // The balances' time: the reads describe the account no earlier than this.
1548        let sent = Utc::now();
1549        let (account, positions): (Option<AlpacaAccount>, Vec<AlpacaPosition>) = tokio::try_join!(
1550            async {
1551                if wants_usd {
1552                    rest_with_retry(rl, || http.get(&account_url))
1553                        .await
1554                        .map(Some)
1555                } else {
1556                    Ok(None)
1557                }
1558            },
1559            rest_with_retry(rl, || http.get(&positions_url)),
1560        )?;
1561
1562        let mut balances = account
1563            .map(|account| convert_account_to_balances(&account, assets, sent))
1564            .unwrap_or_default();
1565        if wants_non_usd {
1566            balances.extend(convert_positions_to_balances(&positions, assets, sent));
1567        }
1568
1569        // Converted before the open orders are fetched, so a position that cannot be converted
1570        // fails the call without a wasted request.
1571        let converted_positions = convert_positions(&positions, Utc::now())?;
1572        let open_orders = fetch_raw_open_orders(&http, rl, base, instruments).await?;
1573
1574        // Group open orders and positions by instrument symbol.
1575        let instrument_snapshots =
1576            build_instrument_snapshots(open_orders, converted_positions, instruments);
1577        {
1578            let mut known = self.known_live.lock();
1579            for order in instrument_snapshots
1580                .iter()
1581                .flat_map(|snapshot| &snapshot.orders)
1582            {
1583                if let OrderState::Active(ActiveOrderState::Open(open)) = &order.state {
1584                    known.live(&order.key, order.quantity, order.kind, open);
1585                }
1586            }
1587        }
1588
1589        Ok(AccountSnapshot::new(
1590            ExchangeId::AlpacaBroker,
1591            balances,
1592            instrument_snapshots,
1593        ))
1594    }
1595
1596    /// Returns a live stream of account events (fills, order updates).
1597    ///
1598    /// # Reconnection
1599    ///
1600    /// The stream reconnects by itself when the server closes the connection, the connection
1601    /// fails, or it carries nothing for a 35 s heartbeat window. Each reconnect recovers the fills
1602    /// and order endings the stream missed (see below).
1603    ///
1604    /// - **Attempts.** A dropped connection, and each reconnect that fails after it, spends one
1605    ///   attempt: a wait, then a reconnect. A reconnect fails when its connect, authentication or
1606    ///   `trade_updates` subscribe fails. The waits double from 1 s up to 30 s: 1, 2, 4, 8, 16,
1607    ///   then 30 s.
1608    /// - **Reset.** A connection still receiving after 35 s up clears the attempts spent before
1609    ///   it, so only failures in a row count. A connection the server accepts and closes sooner
1610    ///   counts as a failed reconnect.
1611    /// - **Giving up.** Once 10 reconnects in a row have failed, after about three minutes of
1612    ///   waiting in all, the stream ends with [`AccountEventKind::StreamTerminated`] carrying
1613    ///   [`ReconnectBudgetExhausted`](crate::error::StreamTerminationReason::ReconnectBudgetExhausted)
1614    ///   and the last failure. Credentials rejected on every reconnect end it this way too.
1615    /// - **Opening is not retried.** A failed `account_stream` call returns its error. Whether and
1616    ///   when to try again is the caller's decision, as it is after the stream ends.
1617    ///
1618    /// # Startup race window
1619    ///
1620    /// Fills arriving between `account_snapshot` and this method being called by the
1621    /// caller are not recovered automatically — the WebSocket connection does not exist
1622    /// yet during that window. Fills that arrive after the connection is established but
1623    /// before the first poll are buffered in the tungstenite internal buffer and delivered
1624    /// normally. Callers requiring fill completeness at startup **must** call
1625    /// [`ExecutionClient::fetch_trades`] with a ~1 s lookback after calling this method.
1626    ///
1627    /// This gap also applies on every **reconnect**: during the auth+subscribe handshake
1628    /// (`connect_and_subscribe`), any `trade_updates` messages that arrive are consumed
1629    /// by the handshake loop and not forwarded. Fill events in this window are recovered
1630    /// via the REST activities endpoint, from the last message before the disconnect. How an
1631    /// order held as live ended in this window is recovered too (see below); a lifecycle event of
1632    /// any other order, such as `new` for one placed elsewhere, is not.
1633    ///
1634    /// The read is placed on Alpaca's clock, as the times Alpaca stamped on the stream's
1635    /// `trade_updates` events show it, counted on from their arrival on a monotonic clock, so a
1636    /// host clock ahead of Alpaca's does not start it after fills Alpaca stamped during the
1637    /// outage. Before the stream's first such event, Alpaca's clock is unknown, and the read
1638    /// starts 30 s further back to allow for a difference between the clocks.
1639    ///
1640    /// # Fill recovery ordering
1641    ///
1642    /// After a reconnect, missed fills are recovered from the REST activities endpoint
1643    /// using `direction=asc` to match the chronological order in which the WS stream
1644    /// advanced `filled_qty`. A fill both paths deliver is sent once: it is recognised by its
1645    /// execution id, which the WS frame and the FILL activity both carry.
1646    ///
1647    /// A fill lacking that id on either path is recognised instead by `"{order_id}:{cum_qty}"`,
1648    /// taken from the activity's own `cum_qty`, so it matches the WS path's `filled_qty` by
1649    /// construction. Only where Alpaca omits `cum_qty` too does that key fall back to
1650    /// accumulating per-execution qty within the batch. That fallback counts from zero per order,
1651    /// so it is correct only for an order whose fills lie wholly inside the recovery window, and
1652    /// it is sensitive to activities arriving out of chronological order (e.g. at pagination
1653    /// boundaries).
1654    ///
1655    /// A recovered fill also carries the order's cumulative filled quantity, so it advances the
1656    /// order's `filled_quantity` without waiting for a `fetch_open_orders` reconciliation.
1657    ///
1658    /// Every fill's [`TradeId`] is Alpaca's execution id, whether the stream, recovery, or
1659    /// [`ExecutionClient::fetch_trades`] delivers it, so a fill read again by `fetch_trades`
1660    /// matches the one the stream delivered. A stream fill whose `execution_id` is missing, null
1661    /// or empty is logged and identified by `"{order_id}:{filled_qty}"` instead.
1662    ///
1663    /// The read is made once, with no retry. When it fails, times out, or stops at 5,000 fills,
1664    /// the stream sends one [`AccountEventKind::FillRecoveryGaveUp`] covering the stream's
1665    /// `instruments`, or every instrument when that list is empty. A truncated read delivers the
1666    /// fills it read first, and the event's span starts just before the last of them.
1667    ///
1668    /// # Busted and corrected fills
1669    ///
1670    /// A `trade_bust` or `trade_correct` is sent as [`AccountEventKind::TradeAmended`], naming
1671    /// the fill amended by its [`TradeId`]; the fill itself was delivered as first reported and
1672    /// is not withdrawn. The Trading API documents neither event, so their frames are read per
1673    /// Alpaca's Broker API schema, and each is also logged whole at `warn!`. One sent while the
1674    /// stream was disconnected is not recovered.
1675    ///
1676    /// # Lifecycle event deduplication
1677    ///
1678    /// Order lifecycle events (`new`, `canceled`, `expired`) are **not** deduplicated
1679    /// across reconnects — only fill events carry a dedup key. After a reconnect, Alpaca
1680    /// re-delivers lifecycle events for orders that were active at disconnect time.
1681    /// Specifically, Alpaca re-delivers a `new` event for **every order open at disconnect
1682    /// time**, not only orders that changed during the gap.
1683    /// Callers must make [`AccountEventKind::OrderSnapshot`] and
1684    /// [`AccountEventKind::OrderCancelled`] processing idempotent, or call
1685    /// [`ExecutionClient::fetch_open_orders`] after each reconnect to reconcile state.
1686    ///
1687    /// # Orders that ended while disconnected
1688    ///
1689    /// A reconnect also reports how each order the client holds as live ended, where it did, as an
1690    /// [`AccountEventKind::OrderSnapshot`] of its inactive state: filled (with the average fill
1691    /// price), cancelled or replaced (as cancelled, with what filled before), expired, or rejected.
1692    /// Like fill recovery, it covers only the instruments the stream was opened with, or every
1693    /// instrument when that list is empty. Alpaca's closed-order list filters on when an order was
1694    /// submitted, so such an order is found by asking about it, not by time.
1695    ///
1696    /// - **Which orders.** The client holds an order as live from the response to placing it,
1697    ///   from a listing of open orders ([`account_snapshot`](ExecutionClient::account_snapshot),
1698    ///   [`fetch_open_orders`](ExecutionClient::fetch_open_orders)), and from its live reports on
1699    ///   any of its account streams, until it sees the order end; a cancel Alpaca has only
1700    ///   accepted does not end it. A bracket's take-profit and stop-loss legs carry client order
1701    ///   ids Alpaca assigns, so they are held only once a listing or the stream reports them. It
1702    ///   holds up to 4,096 orders and forgets the oldest past that, logged at `warn`. An order
1703    ///   placed outside this client and never listed or reported to it is not covered.
1704    /// - **Cost.** One `GET /v2/orders?status=open` request listing every instrument with an
1705    ///   order held, then one `GET /v2/orders:by_client_order_id` for each held order the listing
1706    ///   no longer shows, 8 at a time. These share the account's rate limit with orders, so after
1707    ///   an outage in which many held orders ended, the check can hold orders back until the
1708    ///   window resets (see [Rate limits](AlpacaClient#rate-limits)).
1709    /// - **Fills first.** The check starts once fill recovery has finished or given up, so an
1710    ///   order's recovered fills arrive before how it ended. It runs alongside the stream, which
1711    ///   is read from the start. A fill that brings an order to its full quantity ends it, and
1712    ///   that order is not reported again.
1713    /// - **Keys.** Each snapshot carries [`StrategyId::unknown`], since Alpaca records no
1714    ///   strategy. The engine matches it to the order it tracks by client order id.
1715    /// - **Failures.** Each order's lookup is settled as it ends. An instrument whose listing or
1716    ///   lookup fails, or whose check is still running after 30 s, is retried while connected 1,
1717    ///   2, 4, 8 and 16 minutes later, asking only about the orders still held, then given up,
1718    ///   logged at `error`; its orders are asked about again at the next reconnect. A listing that
1719    ///   fails charges every instrument in it. Alpaca lists at most 500 open orders and has no
1720    ///   pagination for them, so a listing of 500 may be truncated and fails: an account with 500
1721    ///   or more open orders on the held instruments is not covered. An order Alpaca does not know
1722    ///   (404) stops being held, logged at `warn`; one still live, `done_for_day`, or in a state
1723    ///   this version cannot read stays held.
1724    ///
1725    /// The same lookup is public as [`OrderStatusClient::fetch_ended_orders`].
1726    ///
1727    /// # Rejected orders
1728    ///
1729    /// Alpaca `rejected` events are delivered as `AccountEventKind::OrderCancelled` with
1730    /// `state: Err(OrderRejected(...))`. Match on `response.state.is_err()` to distinguish
1731    /// rejections from true cancels — do not call `.unwrap()` on `OrderCancelled.state`.
1732    ///
1733    /// # Orders done for the day
1734    ///
1735    /// A `done_for_day` event is delivered as an [`AccountEventKind::OrderSnapshot`] of the order
1736    /// as open, with what it has filled: Alpaca stops working the order until the next trading
1737    /// day but has not ended it.
1738    ///
1739    /// # Stream drop behaviour
1740    ///
1741    /// Dropping the returned `BoxStream` initiates a graceful shutdown of the
1742    /// background `connection_manager` task: the channel close causes the task
1743    /// to send a WebSocket close frame and exit within the current heartbeat
1744    /// window (≤60 s). Any `AccountEvent` items already queued but not yet
1745    /// polled are discarded. Callers who drop and re-subscribe must call
1746    /// [`ExecutionClient::fetch_trades`] with a short lookback to recover the gap.
1747    async fn account_stream(
1748        &self,
1749        _assets: &[AssetNameExchange], // ignored — Alpaca's trade_updates stream delivers all asset classes on one channel
1750        // instruments is used to filter fill recovery (REST) after a reconnect only.
1751        // Live WS events are NOT filtered by instrument: Alpaca's trade_updates stream
1752        // delivers all account events on one channel with no per-symbol subscription.
1753        instruments: &[InstrumentNameExchange],
1754    ) -> Result<Self::AccountStream, UnindexedClientError> {
1755        // Verify the initial connection before returning the stream; distinguishes
1756        // "can't connect at all" from "connected but later disconnected".
1757        let initial_ws = connect_and_subscribe(&self.config).await?;
1758
1759        // Unbounded channel — memory grows if the consumer is slow, but fills
1760        // are never silently dropped. Silent fill loss corrupts position state;
1761        // OOM is loudly observable. The WS delivery path uses non-blocking send()
1762        // which only fails if the receiver is dropped.
1763        let (tx, rx) = mpsc::unbounded_channel::<UnindexedAccountEvent>();
1764        let dedup = new_dedup_cache();
1765        let config = self.config.clone();
1766        let http = self.http.clone();
1767        let rate_limiter = self.rate_limiter.clone();
1768        let known = self.known_live.clone();
1769        let instruments = instruments.to_vec();
1770
1771        let cm_handle = tokio::spawn(connection_manager(
1772            tx,
1773            dedup,
1774            config,
1775            http,
1776            rate_limiter,
1777            known,
1778            instruments,
1779            Some(initial_ws),
1780        ));
1781
1782        let rx_stream = tokio_stream::wrappers::UnboundedReceiverStream::new(rx);
1783        let guarded = GracefulShutdownStream::new(rx_stream, cm_handle);
1784        Ok(futures::StreamExt::boxed(guarded))
1785    }
1786
1787    /// Cancel an order.
1788    ///
1789    /// # Async cancel semantics
1790    ///
1791    /// Returns `Ok(Cancelled)` once Alpaca has **accepted** the cancel, not once the order is
1792    /// cancelled: `DELETE /v2/orders/{id}` answers 204 when the cancel request is accepted and the
1793    /// order will be cancelled. Until it is, the order can be `pending_cancel`, still live, and can
1794    /// still fill. The account stream reports how it ended, and any fill.
1795    ///
1796    /// The 204 has no body, so `filled_quantity` is zero whatever the order filled, and
1797    /// `time_exchange` is the local time the answer arrived.
1798    ///
1799    /// The client keeps holding the order as live until the stream reports how it ended, so if the
1800    /// stream is down when it does, a reconnect still finds out (see
1801    /// [`account_stream`](ExecutionClient::account_stream)).
1802    ///
1803    /// Needs the venue order id. A request without one is refused, since Alpaca cancels by that
1804    /// id only; resolve it through [`ExecutionClient::fetch_open_orders`].
1805    async fn cancel_order(
1806        &self,
1807        request: OrderRequestCancel<ExchangeId, &InstrumentNameExchange>,
1808    ) -> UnindexedOrderResponseCancel {
1809        let key = crate::order::OrderKey {
1810            exchange: request.key.exchange,
1811            instrument: request.key.instrument.clone(),
1812            strategy: request.key.strategy.clone(),
1813            cid: request.key.cid.clone(),
1814        };
1815
1816        // Require the exchange order ID — Alpaca's DELETE endpoint uses the UUID.
1817        // If only clientOrderId is available, the caller should first resolve it
1818        // via fetch_open_orders.
1819        let order_id: SmolStr = match request.state.id.as_ref().and_then(VenueOrderId::assigned) {
1820            Some(id) => id.0.clone(),
1821            None => {
1822                warn!(
1823                    instrument = %key.instrument,
1824                    "Alpaca cancel_order: no exchange order ID available (clientOrderId-only cancel not supported)"
1825                );
1826                return crate::order::request::OrderResponseCancel {
1827                    key,
1828                    state: Err(UnindexedOrderError::Rejected(ApiError::OrderRejected(
1829                        "exchange order ID required for cancel (fetch_open_orders to resolve)"
1830                            .into(),
1831                    ))),
1832                };
1833            }
1834        };
1835
1836        let base = self.base_url();
1837        let http = self.http.clone();
1838        let url = format!("{base}/v2/orders/{order_id}");
1839
1840        match rest_delete_with_retry(&self.rate_limiter, || http.delete(&url)).await {
1841            Ok(()) => {
1842                let exchange_order_id = OrderId(order_id);
1843                // REST DELETE returns no response body, so the filled quantity is unknown
1844                // here; the account stream's `canceled` update reports it.
1845                crate::order::request::OrderResponseCancel {
1846                    key,
1847                    state: Ok(Cancelled::new(exchange_order_id, Utc::now(), None)),
1848                }
1849            }
1850            Err(e) => crate::order::request::OrderResponseCancel { key, state: Err(e) },
1851        }
1852    }
1853
1854    /// # Position intent derivation
1855    ///
1856    /// Alpaca options/equities require explicit `position_intent`. This impl derives
1857    /// intent from `RequestOpen::reduce_only` and `side`:
1858    ///
1859    /// | reduce_only     | side | intent       | use case                          |
1860    /// |-----------------|------|--------------|-----------------------------------|
1861    /// | `No`            | Buy  | BuyToOpen    | open long / add to long position  |
1862    /// | `No`            | Sell | SellToOpen   | open short / write option         |
1863    /// | any other value | Buy  | BuyToClose   | close short position              |
1864    /// | any other value | Sell | SellToClose  | close long position               |
1865    ///
1866    /// For explicit control, use [`AlpacaClient::open_order_with_intent`].
1867    ///
1868    /// # Market order price
1869    ///
1870    /// The returned `Order.price` echoes the request price. For market orders this is
1871    /// typically `Decimal::ZERO` (a placeholder). The **actual fill price** arrives via
1872    /// the WebSocket `trade_updates` stream as a `Trade` event. Do not rely on
1873    /// `Order.price` from this REST ack for market order fill prices.
1874    async fn open_order(
1875        &self,
1876        request: OrderRequestOpen<ExchangeId, &InstrumentNameExchange>,
1877    ) -> Order<ExchangeId, InstrumentNameExchange, UnindexedOrderState> {
1878        let side = request.state.side;
1879        let reduce_only = request.state.reduce_only;
1880        self.open_order_inner(request, map_position_intent(side, reduce_only))
1881            .await
1882    }
1883
1884    /// Fetches balances sequentially (unlike `account_snapshot` which parallelizes).
1885    ///
1886    /// Sequential fetch is intentional for live operation: under rate pressure, parallel
1887    /// requests may both hit 429 simultaneously and retry independently, doubling the
1888    /// backoff delay. The startup latency savings from `account_snapshot`'s parallel
1889    /// fetch are worth the tradeoff there; for periodic balance refreshes during live
1890    /// trading, sequential is safer.
1891    async fn fetch_balances(
1892        &self,
1893        assets: &[AssetNameExchange],
1894    ) -> Result<Vec<AssetBalance<AssetNameExchange>>, UnindexedClientError> {
1895        let base = self.base_url();
1896        let http = self.http.clone();
1897        let mut result = Vec::new();
1898
1899        // Only fetch the account (USD balance) when USD is among the requested assets.
1900        // Crypto-only requests skip this call to conserve rate-limit budget.
1901        let wants_usd = assets.is_empty() || assets.iter().any(is_usd);
1902        if wants_usd {
1903            // Pre-allocate URL to avoid re-allocation on each retry attempt.
1904            let account_url = format!("{base}/v2/account");
1905            let sent = Utc::now();
1906            let account: AlpacaAccount =
1907                rest_with_retry(&self.rate_limiter, || http.get(&account_url)).await?;
1908            result.extend(convert_account_to_balances(&account, assets, sent));
1909        }
1910
1911        // Fetch positions for non-USD asset balances (e.g., BTC, ETH from crypto holdings).
1912        let wants_non_usd = assets.is_empty() || assets.iter().any(|a| !is_usd(a));
1913        if wants_non_usd {
1914            // Pre-allocate URL to avoid re-allocation on each retry attempt.
1915            let positions_url = format!("{base}/v2/positions");
1916            let sent = Utc::now();
1917            let positions: Vec<AlpacaPosition> =
1918                rest_with_retry(&self.rate_limiter, || http.get(&positions_url)).await?;
1919            result.extend(convert_positions_to_balances(&positions, assets, sent));
1920        }
1921
1922        Ok(result)
1923    }
1924
1925    async fn fetch_open_orders(
1926        &self,
1927        instruments: &[InstrumentNameExchange],
1928    ) -> Result<Vec<Order<ExchangeId, InstrumentNameExchange, Open>>, UnindexedClientError> {
1929        let base = self.base_url();
1930        let http = self.http.clone();
1931
1932        let open_orders =
1933            fetch_raw_open_orders(&http, &self.rate_limiter, base, instruments).await?;
1934
1935        let result: Vec<_> = open_orders
1936            .into_iter()
1937            .filter_map(|o| convert_open_order(&o))
1938            .collect();
1939        self.remember_live(&result);
1940        Ok(result)
1941    }
1942
1943    /// # Cost
1944    ///
1945    /// One call sends up to 50 requests, reading at most 5,000 FILL activities, and returns
1946    /// `resume` when it stops there. The limit counts every activity on the account, before the
1947    /// instrument filter, so a call for one instrument can spend all 50 requests and return no
1948    /// trades with `resume: Some`.
1949    ///
1950    /// # Times
1951    ///
1952    /// An activity whose `transaction_time` does not parse is still returned, since Alpaca placed
1953    /// it in the span. Its `time_exchange` is then the local clock at the read, which can lie
1954    /// outside the span, and a warning names it.
1955    async fn fetch_trades(
1956        &self,
1957        start: DateTime<Utc>,
1958        end: DateTime<Utc>,
1959        instruments: &[InstrumentNameExchange],
1960    ) -> Result<TradesRead<AssetNameExchange, InstrumentNameExchange>, UnindexedClientError> {
1961        // An empty span, or one ending before the epoch, holds nothing Alpaca has.
1962        if start > end || end < DateTime::UNIX_EPOCH {
1963            return Ok(TradesRead::complete(Vec::new()));
1964        }
1965        let page = paginate_activities::<AlpacaActivity>(
1966            &self.http,
1967            &self.rate_limiter,
1968            self.base_url(),
1969            FILL_ACTIVITIES,
1970            start,
1971            Some(end),
1972        )
1973        .await?;
1974
1975        let Ok(resume) = span_resume(page.resume, start, end) else {
1976            error!(
1977                %start,
1978                fills_read = page.activities.len(),
1979                "Alpaca fetch_trades: a full read did not advance past the span's first millisecond"
1980            );
1981            return Err(UnindexedClientError::Truncated {
1982                fills_read: page.activities.len(),
1983            });
1984        };
1985
1986        // Empty instruments slice means "all instruments" — same convention as
1987        // fetch_open_orders.
1988        let instrument_set: fnv::FnvHashSet<&str> =
1989            instruments.iter().map(|i| i.name().as_str()).collect();
1990        let trades = page
1991            .activities
1992            .iter()
1993            .filter(|a| instrument_set.is_empty() || instrument_set.contains(a.symbol.as_str()))
1994            // Alpaca's bounds are not exact (see `paginate_activities`), so the span is applied
1995            // here. An activity whose time does not parse is kept: Alpaca placed it in the span.
1996            .filter(|a| {
1997                parse_timestamp(&a.transaction_time)
1998                    .is_none_or(|time| (start..=end).contains(&time))
1999            })
2000            .filter_map(convert_activity_to_trade)
2001            .collect();
2002
2003        Ok(TradesRead::new(trades, resume))
2004    }
2005}
2006
2007// ---------------------------------------------------------------------------
2008// AlpacaClient public extension methods (not on ExecutionClient trait)
2009// ---------------------------------------------------------------------------
2010
2011impl AlpacaClient {
2012    /// Read the cash Alpaca moved into or out of the account other than by a trade, from `start`
2013    /// to `end`: interest, fees, and pass-through charges and rebates.
2014    ///
2015    /// Alpaca streams none of these: `trade_updates` carries order events only. Nothing is pushed:
2016    /// the caller decides when to read, and must drop the flows it has already applied, by
2017    /// [`id`](CashFlow::id), before applying a read's. Alpaca posts each to the account's cash,
2018    /// so the account's USD balance already includes a flow read here.
2019    ///
2020    /// # What each activity becomes
2021    ///
2022    /// | Activity | [`CashFlowKind`] |
2023    /// |---|---|
2024    /// | `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 |
2025    /// | `PTR`, a pass-through rebate | [`Rebate`](CashFlowKind::Rebate) |
2026    /// | Any other: `FEE`, `PTC`, credit interest | [`Other`](CashFlowKind::Other), naming the type and, when Alpaca gives one, the sub-type, as `FEE.TAF` |
2027    ///
2028    /// Alpaca documents no activity type for a stock borrow fee. Should it post one as one of
2029    /// these, it arrives as [`Other`](CashFlowKind::Other) with its own name.
2030    ///
2031    /// - [`amount`](CashFlow::amount) is Alpaca's `net_amount`, negative when the account paid.
2032    /// - [`asset`](CashFlow::asset) is its `currency`, or `USD` when it names none.
2033    /// - [`instrument`](CashFlow::instrument) is its `symbol`, when it names one.
2034    /// - [`time_exchange`](CashFlow::time_exchange) is when Alpaca created the activity
2035    ///   (`created_at`), which its `after` and `until` filter on. A fee is typically created the
2036    ///   day after the trade it was charged on.
2037    /// - [`id`](CashFlow::id) is Alpaca's activity id.
2038    ///
2039    /// # Status
2040    /// - An activity Alpaca marks `canceled` is left out, and logged at INFO. A flow read before
2041    ///   it was canceled is not reversed: the caller reconciles against the account's cash.
2042    /// - One marked `correct` is returned like any other. Alpaca does not document whether a
2043    ///   correction stands beside the activity it corrects or replaces it, so a caller applying
2044    ///   both by id may count the cash twice. Reconcile against the account's cash.
2045    ///
2046    /// An activity without an amount or a creation time that parses is left out, and logged at
2047    /// WARN.
2048    ///
2049    /// # Cost and resuming
2050    /// One call sends up to 50 requests, reading at most 5,000 activities, and returns `resume`
2051    /// when it stops there: the millisecond of the last activity's creation time. That resume is
2052    /// exact only if Alpaca serves these activities in order of creation, as it serves fills in
2053    /// order of time; its documentation orders them by "activity datetime" without saying which
2054    /// time, and this ordering has not been observed for them. Read spans short enough to fit one
2055    /// call, as the volume of these activities usually allows, and `resume` stays `None`.
2056    ///
2057    /// # Errors
2058    /// Fails if a request fails, or, with [`UnindexedClientError::Internal`], if more activities
2059    /// share the span's first millisecond than one call reads, so a read from `resume` could not
2060    /// advance.
2061    pub async fn fetch_cash_flows(
2062        &self,
2063        start: DateTime<Utc>,
2064        end: DateTime<Utc>,
2065    ) -> Result<CashFlowsRead<AssetNameExchange, InstrumentNameExchange>, UnindexedClientError>
2066    {
2067        // An empty span, or one ending before the epoch, holds nothing Alpaca has.
2068        if start > end || end < DateTime::UNIX_EPOCH {
2069            return Ok(CashFlowsRead::complete(Vec::new()));
2070        }
2071        let page = paginate_activities::<AlpacaNonTradeActivity>(
2072            &self.http,
2073            &self.rate_limiter,
2074            self.base_url(),
2075            CASH_FLOW_ACTIVITIES,
2076            start,
2077            Some(end),
2078        )
2079        .await?;
2080        let Ok(resume) = span_resume(page.resume, start, end) else {
2081            error!(
2082                %start,
2083                activities_read = page.activities.len(),
2084                "Alpaca fetch_cash_flows: a full read did not advance past the span's first \
2085                 millisecond"
2086            );
2087            return Err(UnindexedClientError::Internal(format!(
2088                "Alpaca cash-flow read of {} activities did not advance past {start}",
2089                page.activities.len()
2090            )));
2091        };
2092
2093        let flows = page
2094            .activities
2095            .iter()
2096            .filter_map(convert_cash_flow)
2097            // Alpaca's bounds are not exact (see `paginate_activities`), so the span is applied
2098            // here.
2099            .filter(|flow| (start..=end).contains(&flow.time_exchange))
2100            .collect();
2101        Ok(CashFlowsRead::new(flows, resume))
2102    }
2103
2104    /// Read the trades Alpaca made when an option contract was exercised, assigned or expired,
2105    /// from `start` to `end`.
2106    ///
2107    /// Alpaca reports these as account activities, not fills: `trade_updates` carries none of
2108    /// them, and [`fetch_trades`](crate::client::ExecutionClient::fetch_trades) reads fills only.
2109    /// Nothing is pushed: the caller decides when to read, and must drop the trades it has
2110    /// already applied, by [`id`](Trade::id), before applying a read's. To let these trades move
2111    /// the engine's positions without the engine also settling the contract at expiry, set the
2112    /// exchange's expiry handling to `Venue` (see `ExpiryHandling` in the `rustrade` crate).
2113    ///
2114    /// # What each activity becomes
2115    ///
2116    /// Each becomes one [`Trade`], whose [`origin`](Trade::origin) says why it happened:
2117    ///
2118    /// | Activity | Instrument | [`TradeOrigin`] | Price |
2119    /// |---|---|---|---|
2120    /// | `OPEXP`, expiry | the contract | [`Expiry`](TradeOrigin::Expiry) | 0 |
2121    /// | `OPEXC`, exercise | the contract | [`Exercise`](TradeOrigin::Exercise) | 0 |
2122    /// | `OPASN`, assignment | the contract | [`Assignment`](TradeOrigin::Assignment) | 0 |
2123    /// | `OPTRD`, the shares an exercise or assignment trades | the underlying | that of its exercise or assignment, else [`External`](TradeOrigin::External) | the strike |
2124    ///
2125    /// - [`side`](Trade::side) and [`quantity`](Trade::quantity) come from Alpaca's signed `qty`:
2126    ///   negative is a sell. An exercised long or an expiring long is sold; an assigned short is
2127    ///   bought. Quantities are in contracts on the contract, and in shares on `OPTRD`.
2128    /// - The contract leaves at a price of 0. What it was worth at exercise or assignment is in the
2129    ///   shares traded at the strike.
2130    /// - [`time_exchange`](Trade::time_exchange) is when Alpaca created the activity
2131    ///   (`created_at`), or the start of its `date`, in UTC, when it gives no creation time. Such
2132    ///   a dated activity is in the span when its `date` is one of the span's UTC days, so a read
2133    ///   up to now still returns one posted today, and successive reads within one day each
2134    ///   return it again.
2135    /// - [`id`](Trade::id) and [`order_id`](Trade::order_id) are Alpaca's activity id, since no
2136    ///   order produced the trade. [`strategy`](Trade::strategy) is unknown, and fees are zero.
2137    ///
2138    /// # Pairing the share leg
2139    ///
2140    /// `OPTRD` names the underlying, not the contract, so its origin is found by pairing it with
2141    /// an `OPEXC` or `OPASN` of the same `date` whose contract's root symbol is the `OPTRD`'s
2142    /// symbol, whose strike is its price, and whose kind implies its side: exercising a call or
2143    /// being assigned on a put buys the shares, and the other two sell them. Each is paired at
2144    /// most once, in the order Alpaca returns them, preferring one of 100 shares a contract, the
2145    /// standard size, so that two exercises at one strike pair with their own shares. An `OPTRD`
2146    /// with no such partner is returned as [`External`](TradeOrigin::External), and logged at
2147    /// WARN. The read covers the whole UTC days of `start` and `end`, so that both legs are read
2148    /// together, and returns only the trades in `start..=end`.
2149    ///
2150    /// # Status
2151    /// An activity Alpaca marks `canceled` is left out, and logged at INFO. An activity without a
2152    /// symbol, a non-zero quantity or a time, an `OPTRD` without a price, or one of another type
2153    /// is left out, and logged at WARN.
2154    ///
2155    /// # Cost and resuming
2156    /// As [`fetch_cash_flows`](Self::fetch_cash_flows): one call sends up to 50 requests, reading
2157    /// at most 5,000 activities, and returns `resume` when it stops there. A read from `resume`
2158    /// reads its whole UTC day again, so drop by id what was already applied. So more than 5,000
2159    /// option activities in one UTC day cannot be read through; see Errors.
2160    ///
2161    /// A read that stops at the cap can split an exercise or assignment from its share leg, and
2162    /// return the `OPTRD` as [`External`](TradeOrigin::External) while the next read, from
2163    /// `resume`, returns it again with its pair's origin. A caller dropping trades by id keeps
2164    /// the first. When a read returns `resume`, hold back its `External` `OPTRD`s until the next
2165    /// read.
2166    ///
2167    /// # Errors
2168    /// Fails if a request fails, or, with [`UnindexedClientError::Internal`], if a read from
2169    /// `resume` could not advance: one UTC day holds more option activities than one call reads.
2170    /// That includes a day whose activities before `start` alone fill the call.
2171    pub async fn fetch_option_events(
2172        &self,
2173        start: DateTime<Utc>,
2174        end: DateTime<Utc>,
2175    ) -> Result<TradesRead<AssetNameExchange, InstrumentNameExchange>, UnindexedClientError> {
2176        // An empty span, or one ending before the epoch, holds nothing Alpaca has.
2177        if start > end || end < DateTime::UNIX_EPOCH {
2178            return Ok(TradesRead::complete(Vec::new()));
2179        }
2180        // Whole UTC days, so the legs of one exercise or assignment are read together.
2181        let read_start = start.date_naive().and_time(NaiveTime::MIN).and_utc();
2182        let read_end = end.date_naive().and_time(LAST_MILLISECOND).and_utc();
2183        let page = paginate_activities::<AlpacaNonTradeActivity>(
2184            &self.http,
2185            &self.rate_limiter,
2186            self.base_url(),
2187            OPTION_EVENT_ACTIVITIES,
2188            read_start,
2189            Some(read_end),
2190        )
2191        .await?;
2192        let Ok(resume) = span_resume(page.resume, start, end) else {
2193            error!(
2194                %start,
2195                activities_read = page.activities.len(),
2196                "Alpaca fetch_option_events: a full read did not advance past the span's start"
2197            );
2198            return Err(UnindexedClientError::Internal(format!(
2199                "Alpaca option-event read of {} activities did not advance past {start}",
2200                page.activities.len()
2201            )));
2202        };
2203
2204        Ok(TradesRead::new(
2205            convert_option_events(&page.activities, start, end),
2206            resume,
2207        ))
2208    }
2209
2210    /// Place an order with an explicit `position_intent` override.
2211    ///
2212    /// Use this instead of `ExecutionClient::open_order` when you need to specify
2213    /// exact position intent (e.g. `SellToOpen` for writing a short option, or
2214    /// `BuyToClose` for closing a short position by buying).
2215    ///
2216    /// `intent` is only sent for non-crypto symbols. For crypto symbols (those
2217    /// containing `/`) the field is always omitted regardless of `intent`.
2218    ///
2219    /// # Caller obligations
2220    /// The caller is responsible for passing the semantically correct intent.
2221    /// No validation is performed against the order side or existing position.
2222    pub async fn open_order_with_intent(
2223        &self,
2224        request: OrderRequestOpen<ExchangeId, &InstrumentNameExchange>,
2225        intent: AlpacaPositionIntent,
2226    ) -> Order<ExchangeId, InstrumentNameExchange, UnindexedOrderState> {
2227        self.open_order_inner(request, intent).await
2228    }
2229
2230    /// Place a bracket order (entry + take-profit + stop-loss) in a single request.
2231    ///
2232    /// A bracket order consists of three linked orders:
2233    /// 1. **Entry**: Limit order to enter the position
2234    /// 2. **Take Profit**: Limit order to exit at profit target
2235    /// 3. **Stop Loss**: Stop (or stop-limit) order to exit at loss limit
2236    ///
2237    /// When the entry order fills, Alpaca activates both exit legs. When either
2238    /// exit leg fills, Alpaca automatically cancels the other.
2239    ///
2240    /// # Constraints
2241    ///
2242    /// - `time_in_force` must be `Day` or `GoodUntilCancelled` (Alpaca rejects others)
2243    /// - No extended hours trading with bracket orders
2244    /// - Entry is always a limit order
2245    ///
2246    /// # Example
2247    ///
2248    /// ```ignore
2249    /// let request = AlpacaBracketOrderRequest::new(
2250    ///     "AAPL".into(),
2251    ///     StrategyId::new("momentum"),
2252    ///     ClientOrderId::new("bracket-001"),
2253    ///     Side::Buy,
2254    ///     dec!(10),
2255    ///     dec!(150.00),  // entry
2256    ///     dec!(160.00),  // take profit
2257    ///     dec!(145.00),  // stop loss
2258    ///     TimeInForce::GoodUntilCancelled { post_only: false },
2259    /// );
2260    /// let result = client.open_bracket_order(request).await;
2261    /// ```
2262    pub async fn open_bracket_order(
2263        &self,
2264        request: AlpacaBracketOrderRequest,
2265    ) -> AlpacaBracketOrderResult {
2266        let order_key = crate::order::OrderKey::new(
2267            ExchangeId::AlpacaBroker,
2268            request.instrument.clone(),
2269            request.strategy.clone(),
2270            request.cid.clone(),
2271        );
2272
2273        // Validate time_in_force: bracket orders only support day or gtc
2274        let tif_str = match request.time_in_force {
2275            TimeInForce::GoodUntilEndOfDay => "day",
2276            TimeInForce::GoodUntilCancelled { post_only } => {
2277                if post_only {
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(
2287                                ApiError::OrderRejected(
2288                                    "Alpaca does not support post_only for bracket orders"
2289                                        .to_string(),
2290                                ),
2291                            )),
2292                        },
2293                    };
2294                }
2295                "gtc"
2296            }
2297            other => {
2298                return AlpacaBracketOrderResult {
2299                    parent: Order {
2300                        key: order_key,
2301                        side: request.side,
2302                        price: Some(request.entry_price),
2303                        quantity: request.quantity,
2304                        kind: OrderKind::Limit,
2305                        time_in_force: request.time_in_force,
2306                        state: OrderState::inactive(OrderError::Rejected(ApiError::OrderRejected(
2307                            format!(
2308                                "Alpaca bracket orders only support Day or GTC time_in_force, got {:?}",
2309                                other
2310                            ),
2311                        ))),
2312                    },
2313                };
2314            }
2315        };
2316
2317        // Validate price ordering. Alpaca rejects mis-ordered brackets with a
2318        // generic 422; surface a structured local error before round-trip.
2319        let price_ordering_ok = match request.side {
2320            Side::Buy => {
2321                request.stop_loss_price < request.entry_price
2322                    && request.entry_price < request.take_profit_price
2323            }
2324            Side::Sell => {
2325                request.take_profit_price < request.entry_price
2326                    && request.entry_price < request.stop_loss_price
2327            }
2328        };
2329        if !price_ordering_ok {
2330            return AlpacaBracketOrderResult {
2331                parent: Order {
2332                    key: order_key,
2333                    side: request.side,
2334                    price: Some(request.entry_price),
2335                    quantity: request.quantity,
2336                    kind: OrderKind::Limit,
2337                    time_in_force: request.time_in_force,
2338                    state: OrderState::inactive(OrderError::Rejected(ApiError::OrderRejected(
2339                        format!(
2340                            "Invalid bracket price ordering for {:?} side: entry={}, take_profit={}, stop_loss={}",
2341                            request.side,
2342                            request.entry_price,
2343                            request.take_profit_price,
2344                            request.stop_loss_price,
2345                        ),
2346                    ))),
2347                },
2348            };
2349        }
2350
2351        // Validate stop-loss limit price if provided. For a Buy bracket, the SL
2352        // is a sell stop(-limit); the limit must be at or below the trigger so
2353        // the order can fill if price gaps down. Reverse for Sell brackets.
2354        if let Some(sl_limit) = request.stop_loss_limit_price {
2355            let sl_limit_ok = match request.side {
2356                Side::Buy => sl_limit <= request.stop_loss_price,
2357                Side::Sell => sl_limit >= request.stop_loss_price,
2358            };
2359            if !sl_limit_ok {
2360                return AlpacaBracketOrderResult {
2361                    parent: Order {
2362                        key: order_key,
2363                        side: request.side,
2364                        price: Some(request.entry_price),
2365                        quantity: request.quantity,
2366                        kind: OrderKind::Limit,
2367                        time_in_force: request.time_in_force,
2368                        state: OrderState::inactive(OrderError::Rejected(ApiError::OrderRejected(
2369                            format!(
2370                                "Invalid stop-loss limit price for {:?} bracket: \
2371                                 stop_loss_price={}, stop_loss_limit_price={}",
2372                                request.side, request.stop_loss_price, sl_limit,
2373                            ),
2374                        ))),
2375                    },
2376                };
2377            }
2378        }
2379
2380        // Build the bracket order request
2381        let body = AlpacaOrderRequest {
2382            symbol: request.instrument.name().as_str(),
2383            qty: request.quantity.to_string(),
2384            side: map_side(request.side),
2385            order_type: "limit",
2386            time_in_force: tif_str,
2387            limit_price: Some(request.entry_price.to_string()),
2388            stop_price: None,
2389            trail_percent: None,
2390            trail_price: None,
2391            client_order_id: Some(request.cid.0.as_str()),
2392            position_intent: if is_options_or_equity_symbol(request.instrument.name().as_str()) {
2393                Some(map_position_intent(request.side, ReduceOnly::No))
2394            } else {
2395                None
2396            },
2397            order_class: Some("bracket"),
2398            take_profit: Some(TakeProfitParams {
2399                limit_price: request.take_profit_price.to_string(),
2400            }),
2401            stop_loss: Some(StopLossParams {
2402                stop_price: request.stop_loss_price.to_string(),
2403                limit_price: request.stop_loss_limit_price.map(|p| p.to_string()),
2404            }),
2405        };
2406
2407        let http = self.http.clone();
2408        let rl = &self.rate_limiter;
2409
2410        let result: Result<AlpacaOrderResponse, UnindexedClientError> =
2411            rest_with_retry(rl, || http.post(&self.orders_url).json(&body)).await;
2412
2413        match result {
2414            Ok(resp) => {
2415                let state = placed_order_state(&resp, &order_key.instrument, request.quantity);
2416                self.known_live.lock().placed(
2417                    &order_key,
2418                    request.quantity,
2419                    OrderKind::Limit,
2420                    &state,
2421                );
2422
2423                AlpacaBracketOrderResult {
2424                    parent: Order {
2425                        key: order_key,
2426                        side: request.side,
2427                        price: Some(request.entry_price),
2428                        quantity: request.quantity,
2429                        kind: OrderKind::Limit,
2430                        time_in_force: request.time_in_force,
2431                        state,
2432                    },
2433                }
2434            }
2435            Err(e) => {
2436                let order_err = order_post_error(e);
2437                AlpacaBracketOrderResult {
2438                    parent: Order {
2439                        key: order_key,
2440                        side: request.side,
2441                        price: Some(request.entry_price),
2442                        quantity: request.quantity,
2443                        kind: OrderKind::Limit,
2444                        time_in_force: request.time_in_force,
2445                        state: OrderState::inactive(order_err),
2446                    },
2447                }
2448            }
2449        }
2450    }
2451
2452    async fn open_order_inner(
2453        &self,
2454        request: OrderRequestOpen<ExchangeId, &InstrumentNameExchange>,
2455        intent: AlpacaPositionIntent,
2456    ) -> Order<ExchangeId, InstrumentNameExchange, UnindexedOrderState> {
2457        let instrument = request.key.instrument.clone();
2458        let side = request.state.side;
2459        let price = request.state.price;
2460        let quantity = request.state.quantity;
2461        let kind = request.state.kind;
2462        let time_in_force = request.state.time_in_force;
2463        let cid = request.key.cid.clone();
2464
2465        let order_key = crate::order::OrderKey::new(
2466            ExchangeId::AlpacaBroker,
2467            instrument.clone(),
2468            request.key.strategy.clone(),
2469            cid.clone(),
2470        );
2471
2472        // Margin is account-level at Alpaca: no order flag borrows or repays, so no effect is
2473        // honoured.
2474        if let Some(effect) = request.state.margin_effect {
2475            return Order {
2476                key: order_key,
2477                side,
2478                price,
2479                quantity,
2480                kind,
2481                time_in_force,
2482                state: OrderState::inactive(OrderError::UnsupportedMarginEffect(effect)),
2483            };
2484        }
2485
2486        // Validate time_in_force before building the request — reject post_only early.
2487        let tif_str = match map_time_in_force(time_in_force) {
2488            Ok(s) => s,
2489            Err(msg) => {
2490                return Order {
2491                    key: order_key,
2492                    side,
2493                    price,
2494                    quantity,
2495                    kind,
2496                    time_in_force,
2497                    state: OrderState::inactive(OrderError::Rejected(ApiError::OrderRejected(
2498                        msg.to_string(),
2499                    ))),
2500                };
2501            }
2502        };
2503
2504        // Validate order kind — reject unsupported types early.
2505        let order_type_str = match map_order_kind(kind) {
2506            Some(s) => s,
2507            None => {
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(format!(
2516                        "Alpaca connector does not yet support OrderKind::{kind:?}"
2517                    ))),
2518                };
2519            }
2520        };
2521
2522        // Validate trailing stop offset type — Alpaca only supports percent and absolute.
2523        if let OrderKind::TrailingStop {
2524            offset_type: TrailingOffsetType::BasisPoints,
2525            ..
2526        } = kind
2527        {
2528            return Order {
2529                key: order_key,
2530                side,
2531                price,
2532                quantity,
2533                kind,
2534                time_in_force,
2535                state: OrderState::inactive(OrderError::UnsupportedOrderType(
2536                    "Alpaca does not support TrailingOffsetType::BasisPoints; \
2537                     use Percentage or Absolute"
2538                        .to_string(),
2539                )),
2540            };
2541        }
2542
2543        // StopLimit requires Order.price (the limit price applied once trigger fires).
2544        // Guard locally to avoid a wire round-trip producing a generic 422.
2545        if matches!(kind, OrderKind::StopLimit { .. }) && price.is_none() {
2546            return Order {
2547                key: order_key,
2548                side,
2549                price,
2550                quantity,
2551                kind,
2552                time_in_force,
2553                state: OrderState::inactive(OrderError::Rejected(ApiError::OrderRejected(
2554                    "StopLimit order requires Order.price (the limit price) to be set".to_string(),
2555                ))),
2556            };
2557        }
2558
2559        // Extract stop/trailing parameters based on order kind.
2560        let (stop_price, trail_percent, trail_price) = match kind {
2561            OrderKind::Stop { trigger_price } | OrderKind::StopLimit { trigger_price } => {
2562                (Some(trigger_price.to_string()), None, None)
2563            }
2564            OrderKind::TrailingStop {
2565                offset,
2566                offset_type,
2567                ..
2568            } => match offset_type {
2569                TrailingOffsetType::Percentage => (None, Some(offset.to_string()), None),
2570                TrailingOffsetType::Absolute => (None, None, Some(offset.to_string())),
2571                TrailingOffsetType::BasisPoints => unreachable!("validated above"),
2572            },
2573            // TakeProfit/TakeProfitLimit rejected by map_order_kind → unreachable here
2574            OrderKind::Market
2575            | OrderKind::Limit
2576            | OrderKind::TakeProfit { .. }
2577            | OrderKind::TakeProfitLimit { .. }
2578            | OrderKind::TrailingStopLimit { .. }
2579            | OrderKind::TrailingStopFixedLimit { .. }
2580            | OrderKind::ConditionalUnresolved { .. } => (None, None, None),
2581        };
2582
2583        let body = AlpacaOrderRequest {
2584            symbol: instrument.name().as_str(),
2585            qty: quantity.to_string(),
2586            side: map_side(side),
2587            order_type: order_type_str,
2588            time_in_force: tif_str,
2589            limit_price: match kind {
2590                OrderKind::Limit | OrderKind::StopLimit { .. } => price.map(|p| p.to_string()),
2591                _ => None,
2592            },
2593            stop_price,
2594            trail_percent,
2595            trail_price,
2596            client_order_id: Some(cid.0.as_str()),
2597            // position_intent is required for options orders and valid (but optional)
2598            // for equities. It is NOT a valid field for crypto orders and must be
2599            // omitted, or Alpaca will return 422 Unprocessable Entity.
2600            // We detect options by OCC symbol format; equities also get the field
2601            // as it aids intent tracking on margin accounts.
2602            position_intent: if is_options_or_equity_symbol(instrument.name().as_str()) {
2603                Some(intent)
2604            } else {
2605                None
2606            },
2607            // Non-bracket order: no bracket fields
2608            order_class: None,
2609            take_profit: None,
2610            stop_loss: None,
2611        };
2612
2613        let http = self.http.clone();
2614        let rl = &self.rate_limiter;
2615
2616        let result: Result<AlpacaOrderResponse, UnindexedClientError> =
2617            rest_with_retry(rl, || http.post(&self.orders_url).json(&body)).await;
2618
2619        match result {
2620            Ok(resp) => {
2621                let state = placed_order_state(&resp, &order_key.instrument, quantity);
2622                self.known_live
2623                    .lock()
2624                    .placed(&order_key, quantity, kind, &state);
2625
2626                Order {
2627                    key: order_key,
2628                    side,
2629                    price,
2630                    quantity,
2631                    kind,
2632                    time_in_force,
2633                    state,
2634                }
2635            }
2636            Err(e) => {
2637                let order_err = order_post_error(e);
2638                Order {
2639                    key: order_key,
2640                    side,
2641                    price,
2642                    quantity,
2643                    kind,
2644                    time_in_force,
2645                    state: OrderState::inactive(order_err),
2646                }
2647            }
2648        }
2649    }
2650}
2651
2652// ---------------------------------------------------------------------------
2653// REST helpers
2654// ---------------------------------------------------------------------------
2655
2656/// Maximum number of open orders returned by a single `/v2/orders` request.
2657///
2658/// Alpaca's API caps at 500; accounts exceeding this have an incomplete snapshot.
2659const MAX_OPEN_ORDERS: usize = 500;
2660
2661/// Fetch all open orders from Alpaca, optionally filtered by symbol.
2662///
2663/// # Errors
2664///
2665/// Returns [`UnindexedClientError::TruncatedSnapshot`] when exactly 500 results
2666/// are returned, indicating the API limit was likely hit and data may be incomplete.
2667/// This is an Alpaca API limitation with no pagination support for open orders.
2668async fn fetch_raw_open_orders(
2669    http: &reqwest::Client,
2670    rate_limiter: &RateLimitTracker,
2671    base: &str,
2672    instruments: &[InstrumentNameExchange],
2673) -> Result<Vec<AlpacaOrderResponse>, UnindexedClientError> {
2674    let orders: Vec<AlpacaOrderResponse> = if instruments.is_empty() {
2675        rest_with_retry(rate_limiter, || {
2676            http.get(format!("{base}/v2/orders"))
2677                .query(&[("status", "open"), ("limit", "500")])
2678        })
2679        .await?
2680    } else {
2681        let symbols = instruments.iter().map(|i| i.name().as_str()).join(",");
2682        rest_with_retry(rate_limiter, || {
2683            http.get(format!("{base}/v2/orders")).query(&[
2684                ("status", "open"),
2685                ("limit", "500"),
2686                ("symbols", &symbols),
2687            ])
2688        })
2689        .await?
2690    };
2691    if orders.len() == MAX_OPEN_ORDERS {
2692        warn!(
2693            limit = MAX_OPEN_ORDERS,
2694            "Alpaca fetch_raw_open_orders: received exactly {MAX_OPEN_ORDERS} results — \
2695             response is likely truncated"
2696        );
2697        return Err(UnindexedClientError::TruncatedSnapshot {
2698            limit: MAX_OPEN_ORDERS,
2699        });
2700    }
2701    Ok(orders)
2702}
2703
2704// ---------------------------------------------------------------------------
2705// Activity pagination
2706// ---------------------------------------------------------------------------
2707
2708/// Maximum number of pages fetched by one [`paginate_activities`] call.
2709///
2710/// 50 pages × 100 items = 5 000 fills. It bounds how long one read, such as a reconnect's
2711/// recovery, can take; a read that reaches it says where to read on from.
2712const MAX_ACTIVITY_PAGES: usize = 50;
2713
2714/// An account activity that [`paginate_activities`] pages through.
2715trait PagedActivity: DeserializeOwned {
2716    /// The activity's id, which is also the token for the page after it.
2717    fn id(&self) -> &str;
2718    /// The time Alpaca orders activities by and applies `after` and `until` to, or `None` when it
2719    /// does not parse.
2720    fn time(&self) -> Option<DateTime<Utc>>;
2721}
2722
2723impl PagedActivity for AlpacaActivity {
2724    fn id(&self) -> &str {
2725        &self.id
2726    }
2727
2728    fn time(&self) -> Option<DateTime<Utc>> {
2729        parse_timestamp(&self.transaction_time)
2730    }
2731}
2732
2733impl PagedActivity for AlpacaNonTradeActivity {
2734    fn id(&self) -> &str {
2735        &self.id
2736    }
2737
2738    fn time(&self) -> Option<DateTime<Utc>> {
2739        self.created_at.as_deref().and_then(parse_timestamp)
2740    }
2741}
2742
2743/// The `activity_types` a read of fills asks for.
2744const FILL_ACTIVITIES: &str = "FILL";
2745
2746/// The `activity_types` [`AlpacaClient::fetch_cash_flows`] asks for.
2747const CASH_FLOW_ACTIVITIES: &str = "INT,FEE,PTC,PTR";
2748
2749/// The `activity_types` [`AlpacaClient::fetch_option_events`] asks for.
2750const OPTION_EVENT_ACTIVITIES: &str = "OPASN,OPEXC,OPEXP,OPTRD";
2751
2752/// The last millisecond of a day, where [`AlpacaClient::fetch_option_events`] ends its read.
2753const LAST_MILLISECOND: NaiveTime = match NaiveTime::from_hms_milli_opt(23, 59, 59, 999) {
2754    Some(time) => time,
2755    None => panic!("23:59:59.999 is a time of day"),
2756};
2757
2758/// Where a bounded read of `start..=end` that stopped at `resume` should read on from: `None` when
2759/// it read the whole span, or `Err(())` when a read from `resume` would stop where this one did.
2760///
2761/// Alpaca's `until` is not exact, and is not sent for an `end` not yet reached, so a read can run
2762/// past `end`. Activities are ascending by millisecond, so a resume past `end` means every
2763/// activity in the span was read.
2764fn span_resume(
2765    resume: Option<DateTime<Utc>>,
2766    start: DateTime<Utc>,
2767    end: DateTime<Utc>,
2768) -> Result<Option<DateTime<Utc>>, ()> {
2769    let resume = resume.filter(|resume| *resume <= end);
2770    if resume.is_some_and(|resume| resume <= floor_millis(start)) {
2771        return Err(());
2772    }
2773    Ok(resume)
2774}
2775
2776/// What one [`paginate_activities`] call read.
2777struct ActivityPage<A> {
2778    /// Every activity read, in Alpaca's ascending order.
2779    activities: Vec<A>,
2780    /// Set when the read stopped at [`MAX_ACTIVITY_PAGES`]: every activity before this time was
2781    /// read, and some from it on may not have been.
2782    resume: Option<DateTime<Utc>>,
2783}
2784
2785/// `time` rounded down to the millisecond.
2786fn floor_millis(time: DateTime<Utc>) -> DateTime<Utc> {
2787    DateTime::from_timestamp_millis(time.timestamp_millis()).unwrap_or(time)
2788}
2789
2790/// Fetch the activities of the `activity_types` named in `types` (comma-separated) from `start`,
2791/// and up to `end` if given, using token-based pagination.
2792///
2793/// Alpaca returns up to `ALPACA_MAX_ACTIVITIES` per page. If a full page is
2794/// returned, the next request uses the last item's `id` as the `page_token`.
2795/// Pagination terminates when a page has fewer items than `page_size`, or after
2796/// [`MAX_ACTIVITY_PAGES`] pages (whichever comes first).
2797///
2798/// # Bounds
2799///
2800/// Alpaca's `after` and `until` are not exact. Observed on paper (2026-10-05):
2801/// - `after` matches an activity whose time, rounded down to the millisecond, is at or after it.
2802///   So a microsecond `after` drops the rest of its own millisecond. It is sent rounded down to
2803///   the millisecond, which reads the whole of `start`'s.
2804/// - `until` matched no single rule: exact to the microsecond for one fill, yet including a fill
2805///   15 µs past it for two that shared a millisecond. It is sent as the millisecond after
2806///   `end`'s, or not at all for an `end` not yet reached, and callers apply `end` to what is
2807///   read.
2808///
2809/// So the activities read can begin before `start` and end after `end`, within their
2810/// milliseconds.
2811///
2812/// # Resuming
2813///
2814/// Within one millisecond Alpaca orders activities by id, not by time, so a read cut inside a
2815/// millisecond may not have reached an earlier fill in it. [`ActivityPage::resume`] is therefore
2816/// the millisecond of the last activity read, and a read from it reads that millisecond again.
2817// Compile-time string form of ALPACA_MAX_ACTIVITIES (avoids runtime to_string() allocation).
2818const PAGE_SIZE_STR: &str = "100"; // must match ALPACA_MAX_ACTIVITIES
2819const _: () = assert!(
2820    ALPACA_MAX_ACTIVITIES == 100,
2821    "PAGE_SIZE_STR must be updated to match ALPACA_MAX_ACTIVITIES",
2822);
2823
2824async fn paginate_activities<A: PagedActivity>(
2825    http: &reqwest::Client,
2826    rate_limiter: &RateLimitTracker,
2827    base: &str,
2828    types: &str,
2829    start: DateTime<Utc>,
2830    end: Option<DateTime<Utc>>,
2831) -> Result<ActivityPage<A>, UnindexedClientError> {
2832    // Clamped to the epoch so a far-past `start` still formats as RFC 3339; Alpaca holds nothing
2833    // older. An `end` not yet reached sends no `until`, so a far-future one cannot overflow.
2834    let after =
2835        floor_millis(start.max(DateTime::UNIX_EPOCH)).to_rfc3339_opts(SecondsFormat::Millis, true);
2836    let until = end.filter(|end| *end < Utc::now()).map(|end| {
2837        (floor_millis(end) + TimeDelta::milliseconds(1))
2838            .to_rfc3339_opts(SecondsFormat::Millis, true)
2839    });
2840    let mut all = Vec::with_capacity(ALPACA_MAX_ACTIVITIES);
2841    let mut page_token: Option<String> = None;
2842    let mut pages = 0usize;
2843    let mut truncated = false;
2844
2845    loop {
2846        if pages >= MAX_ACTIVITY_PAGES {
2847            truncated = true;
2848            break;
2849        }
2850        pages += 1;
2851        // Borrow page_token as &str so the closure can capture by reference without cloning.
2852        let page_token_ref = page_token.as_deref();
2853        let activities: Vec<A> = rest_with_retry(rate_limiter, || {
2854            let mut req = http.get(format!("{base}/v2/account/activities")).query(&[
2855                ("activity_types", types),
2856                ("after", after.as_str()),
2857                ("page_size", PAGE_SIZE_STR),
2858                ("direction", "asc"),
2859            ]);
2860            if let Some(until) = until.as_deref() {
2861                req = req.query(&[("until", until)]);
2862            }
2863            if let Some(token) = page_token_ref {
2864                req = req.query(&[("page_token", token)]);
2865            }
2866            req
2867        })
2868        .await?;
2869
2870        let page_len = activities.len();
2871        // Capture the page token from the current page BEFORE extending `all`, so that
2872        // any future filtering between here and all.extend cannot shift the last element
2873        // and cause the same page to be re-fetched indefinitely.
2874        //
2875        // Alpaca activity IDs are ULID-format (monotonically increasing) and are accepted
2876        // as exclusive page_token cursors by GET /v2/account/activities: the next page
2877        // begins with the item AFTER the token, so the boundary item is not re-delivered.
2878        // The pagination contract relies on this property — verify against Alpaca API docs
2879        // if behaviour changes.
2880        let page_token_candidate = activities.last().map(|a| a.id().to_owned());
2881        all.extend(activities);
2882
2883        if page_len < ALPACA_MAX_ACTIVITIES {
2884            break;
2885        }
2886        match page_token_candidate {
2887            Some(token) if !token.is_empty() => {
2888                debug!("Alpaca paginate_activities: fetching next page ({page_len} results)");
2889                page_token = Some(token);
2890            }
2891            // Empty token or no last item — pagination complete.
2892            // Guard against empty string to prevent infinite loop if Alpaca ever
2893            // returns a full page with last.id = "" (would restart from beginning).
2894            _ => break,
2895        }
2896    }
2897
2898    // Activities are ascending by millisecond, so the last one whose time parses bounds what is
2899    // left. None parsing leaves everything from `start`.
2900    let resume = truncated.then(|| {
2901        floor_millis(
2902            all.iter()
2903                .rev()
2904                .find_map(PagedActivity::time)
2905                .unwrap_or(start),
2906        )
2907    });
2908    Ok(ActivityPage {
2909        activities: all,
2910        resume,
2911    })
2912}
2913
2914// ---------------------------------------------------------------------------
2915// WebSocket connection manager
2916// ---------------------------------------------------------------------------
2917
2918/// Long-running task managing the WebSocket lifecycle for account_stream.
2919///
2920/// Loop: connect → auth → subscribe → fill recovery → ended-order check → stream events → on
2921/// disconnect → backoff → reconnect. The `tx` channel persists across reconnections so the
2922/// consumer sees a seamless event stream.
2923///
2924/// Terminates when the consumer drops the stream or max reconnect attempts are
2925/// exhausted.
2926#[allow(clippy::cognitive_complexity)] // the inner select! loop owns `ws` and mutates `budget` —
2927// extracting to a function requires threading 4 non-Clone values (ws, tx, dedup, budget)
2928// through the call, which adds more complexity than it removes
2929async fn connection_manager(
2930    tx: mpsc::UnboundedSender<UnindexedAccountEvent>,
2931    dedup: SharedDedupCache,
2932    config: Arc<AlpacaConfig>,
2933    http: reqwest::Client,
2934    rate_limiter: Arc<RateLimitTracker>,
2935    known: SharedKnownLiveOrders,
2936    instruments: Vec<InstrumentNameExchange>,
2937    initial_ws: Option<WebSocket>,
2938) {
2939    let mut budget = ReconnectBudget::new("Alpaca", BackoffLimits::ACCOUNT_STREAM);
2940    // The last message before the stream dropped, which fill recovery reads on from.
2941    let mut disconnected: Option<HostMoment> = None;
2942    // Alpaca's clock, from the times it stamped on the events the stream received.
2943    let mut clock = VenueClock::new();
2944    // Instruments whose known-live orders a reconnect has yet to check: checked after the fills,
2945    // and retried on a timer while connected, once due.
2946    let mut unchecked = UncheckedOrders::default();
2947    let mut current_ws = initial_ws;
2948
2949    'outer: loop {
2950        // --- Connect (skip on first iteration if initial_ws was provided) ---
2951        let mut ws = match current_ws.take() {
2952            Some(ws) => ws,
2953            None => match connect_and_subscribe(&config).await {
2954                Ok(ws) => ws,
2955                Err(e) => {
2956                    error!(%e, "Alpaca WS connect/subscribe failed");
2957                    if !budget.wait().await {
2958                        error!("Alpaca max reconnect attempts exhausted");
2959                        // Signal terminal stream death in-band before tx drops.
2960                        emit_stream_terminated(
2961                            &tx,
2962                            ExchangeId::AlpacaBroker,
2963                            StreamTerminationReason::ReconnectBudgetExhausted {
2964                                attempts: budget.attempts(),
2965                                last_error: e.to_string(),
2966                            },
2967                        );
2968                        break;
2969                    }
2970                    continue;
2971                }
2972            },
2973        };
2974        info!("Alpaca account_stream connected and subscribed");
2975        // the budget is NOT reset here: a server that accepts each handshake and closes the
2976        // connection at once would then be retried at the first backoff wait forever, never
2977        // exhausting the budget. The stream loop resets it on a frame received once the connection
2978        // has lasted a heartbeat window.
2979
2980        // --- Fill recovery after reconnect ---
2981        // Runs before the event loop so live events arriving during recovery are
2982        // captured by the already-connected WS session. The dedup cache prevents
2983        // duplicates between recovered REST fills and live WS events.
2984        if let Some(last) = disconnected.take() {
2985            // The live stream is already subscribed, so every fill Alpaca stamps after `end`
2986            // arrives live.
2987            let end = clock.span_end(last, HostMoment::now());
2988            recover_fills_or_report(
2989                &http,
2990                &rate_limiter,
2991                &instruments,
2992                config.rest_base_url(),
2993                fill_recovery_start(&clock, last),
2994                end,
2995                Duration::from_secs(FILL_RECOVERY_TIMEOUT_SECS),
2996                &tx,
2997                &dedup,
2998                &known,
2999            )
3000            .await;
3001            // Only the instruments this stream recovers fills for, every one when none is named.
3002            let held = {
3003                let known = known.lock();
3004                if instruments.is_empty() {
3005                    known.instruments()
3006                } else {
3007                    known.instruments_among(&instruments)
3008                }
3009            };
3010            unchecked.open(held);
3011        }
3012
3013        // --- Stream events ---
3014        // Each iteration of the inner loop polls ws.next(), a heartbeat timer,
3015        // and tx.closed() simultaneously via select!. The heartbeat deadline is
3016        // reset on every received message (rolling window).
3017        //
3018        // The timer is pinned once and reset in-place via Sleep::reset(), avoiding
3019        // a new Sleep allocation and Tokio timer-wheel registration per loop iteration.
3020        // Track the moment of the last received message. Used to anchor
3021        // fill recovery after a heartbeat timeout: the time at reconnect
3022        // would be ~HEARTBEAT_TIMEOUT_SECS after the last real message, causing
3023        // the recovery window to miss fills in that silent period.
3024
3025        let mut last_message = HostMoment::now();
3026        let heartbeat = tokio::time::sleep(Duration::from_secs(HEARTBEAT_TIMEOUT_SECS));
3027        tokio::pin!(heartbeat);
3028        // A connection still receiving a heartbeat window after the stream loop starts is proven
3029        // stable: the failures before it are cleared, so only consecutive ones exhaust the budget.
3030        let stable_from = tokio::time::Instant::now() + Duration::from_secs(HEARTBEAT_TIMEOUT_SECS);
3031
3032        // How the orders held as live ended: checked alongside the stream, so it is read, and a
3033        // disconnect seen, from the start. Fill recovery has finished or given up above, so the
3034        // fills come first. Dropping this when the stream loop ends loses nothing, since each
3035        // lookup and each check is settled in one step as soon as it ends.
3036        let order_checks =
3037            run_order_checks(&http, &rate_limiter, &config, &known, &mut unchecked, &tx);
3038        tokio::pin!(order_checks);
3039
3040        // Resets the rolling heartbeat deadline, records the receive moment, and clears
3041        // the backoff once the connection is proven stable.
3042        // Pin<&mut Sleep> cannot be passed to a regular function, so a macro avoids
3043        // repeating the same block across every message-bearing select! arm.
3044        // NOTE: must be defined AFTER the variables it captures for macro hygiene.
3045        macro_rules! reset_heartbeat {
3046            () => {
3047                let now = tokio::time::Instant::now();
3048                heartbeat
3049                    .as_mut()
3050                    .reset(now + Duration::from_secs(HEARTBEAT_TIMEOUT_SECS));
3051                last_message = HostMoment::new(Utc::now(), now);
3052                if now >= stable_from {
3053                    budget.reset();
3054                }
3055            };
3056        }
3057        // Distinguishes why the inner stream loop exited so the terminal emit below can report a
3058        // meaningful `last_error`. The consumer-drop arm uses `break 'outer` and never yields one.
3059        enum DisconnectReason {
3060            ServerClose,
3061            Error(String),
3062            StreamEnded,
3063            HeartbeatTimeout,
3064        }
3065        let reason = loop {
3066            tokio::select! {
3067                msg = ws.next() => {
3068                    match msg {
3069                        Some(Ok(WsMessage::Ping(_))) => {
3070                            // tokio-tungstenite automatically queues a Pong when poll_next
3071                            // returns a Ping; sending a second Pong would be a duplicate.
3072                            reset_heartbeat!();
3073                        }
3074                        Some(Ok(WsMessage::Text(text))) => {
3075                            process_ws_text(text.as_str(), &tx, &dedup, &known, &mut clock);
3076                            reset_heartbeat!();
3077                        }
3078                        Some(Ok(WsMessage::Binary(bytes))) => {
3079                            // Alpaca paper trading sends binary-framed JSON.
3080                            match std::str::from_utf8(&bytes) {
3081                                Ok(text) => {
3082                                    process_ws_text(text, &tx, &dedup, &known, &mut clock);
3083                                    // Only reset heartbeat for valid UTF-8 frames that may carry
3084                                    // real events. A corrupt binary frame (e.g. from a proxy)
3085                                    // must not keep the watchdog from firing.
3086                                    reset_heartbeat!();
3087                                }
3088                                Err(e) => warn!(%e, "Alpaca WS binary frame: not valid UTF-8"),
3089                            }
3090                        }
3091                        Some(Ok(WsMessage::Close(frame))) => {
3092                            warn!(frame = ?frame, "Alpaca WS closed by server");
3093                            break DisconnectReason::ServerClose;
3094                        }
3095                        Some(Ok(_)) => {} // Pong, Frame — ignore
3096                        Some(Err(e)) => {
3097                            warn!(%e, "Alpaca WS error, reconnecting");
3098                            break DisconnectReason::Error(e.to_string());
3099                        }
3100                        None => {
3101                            warn!("Alpaca WS stream ended, reconnecting");
3102                            break DisconnectReason::StreamEnded;
3103                        }
3104                    }
3105                }
3106                () = &mut order_checks => {}
3107                _ = &mut heartbeat => {
3108                    warn!(
3109                        timeout_secs = HEARTBEAT_TIMEOUT_SECS,
3110                        "Alpaca heartbeat timeout, reconnecting"
3111                    );
3112                    // Heartbeat timeout is a failure — do NOT reset backoff here.
3113                    break DisconnectReason::HeartbeatTimeout;
3114                }
3115                _ = tx.closed() => {
3116                    debug!("Alpaca account_stream consumer dropped, terminating");
3117                    let _ = tokio::time::timeout(
3118                        Duration::from_secs(WS_CLOSE_TIMEOUT_SECS),
3119                        ws.close(None),
3120                    ).await;
3121                    // Consumer dropped the receiver — no StreamTerminated emit (channel already
3122                    // closed, so it would be a no-op; see emit_stream_terminated docs).
3123                    break 'outer;
3124                }
3125            }
3126        };
3127
3128        // --- Record the disconnect for fill recovery ---
3129        // Anchor to the last message, not now. For heartbeat-triggered
3130        // disconnects, now would be ~HEARTBEAT_TIMEOUT_SECS after the last
3131        // real message, causing the recovery window to miss fills in that gap.
3132        disconnected = Some(last_message);
3133
3134        // --- Close stale WS ---
3135        let _ =
3136            tokio::time::timeout(Duration::from_secs(WS_CLOSE_TIMEOUT_SECS), ws.close(None)).await;
3137
3138        if tx.is_closed() {
3139            // Consumer dropped the receiver — no StreamTerminated emit (channel already closed,
3140            // so it would be a no-op; see emit_stream_terminated docs).
3141            break;
3142        }
3143        if !budget.wait().await {
3144            error!("Alpaca max reconnect attempts exhausted, stream terminating");
3145            // Surrender after repeated reconnects: report the failure that triggered this round
3146            // (consumer-drop already broke out via `break 'outer`).
3147            let last_error = match reason {
3148                DisconnectReason::HeartbeatTimeout => {
3149                    format!("heartbeat timeout ({HEARTBEAT_TIMEOUT_SECS}s)")
3150                }
3151                DisconnectReason::ServerClose => "WebSocket closed by server".to_string(),
3152                DisconnectReason::Error(e) => e,
3153                DisconnectReason::StreamEnded => "WebSocket stream ended".to_string(),
3154            };
3155            emit_stream_terminated(
3156                &tx,
3157                ExchangeId::AlpacaBroker,
3158                StreamTerminationReason::ReconnectBudgetExhausted {
3159                    attempts: budget.attempts(),
3160                    last_error,
3161                },
3162            );
3163            break;
3164        }
3165    }
3166}
3167
3168/// Error during WebSocket handshake (auth + subscribe).
3169///
3170/// Separates transport errors (network issues, connection drops) from auth errors
3171/// (invalid credentials) so callers can apply appropriate retry logic.
3172#[derive(Debug)]
3173enum HandshakeError {
3174    /// Network-level failure (WS send failed, connection dropped).
3175    /// Transient — retry with backoff.
3176    Transport(String),
3177    /// Authentication rejected by Alpaca (invalid/expired credentials).
3178    /// Not transient — do not retry without credential changes.
3179    Auth(String),
3180}
3181
3182/// Connect to the Alpaca WebSocket, authenticate, and subscribe to trade_updates.
3183///
3184/// On auth or subscribe failure the connection is closed cleanly.
3185async fn connect_and_subscribe(config: &AlpacaConfig) -> Result<WebSocket, UnindexedClientError> {
3186    let url = config.stream_url();
3187    debug!(%url, "Alpaca: connecting to WebSocket");
3188
3189    let mut ws = rustrade_integration::protocol::websocket::connect(url)
3190        .await
3191        .map_err(|e| {
3192            UnindexedClientError::Connectivity(ConnectivityError::Socket(format!(
3193                "WS connect: {e}"
3194            )))
3195        })?;
3196
3197    // auth + subscribe with overall timeout
3198    let result = tokio::time::timeout(
3199        Duration::from_secs(WS_HANDSHAKE_TIMEOUT_SECS),
3200        ws_handshake(&mut ws, config),
3201    )
3202    .await;
3203
3204    match result {
3205        Ok(Ok(())) => Ok(ws),
3206        Ok(Err(HandshakeError::Transport(e))) => {
3207            let _ = ws.close(None).await;
3208            Err(UnindexedClientError::Connectivity(
3209                ConnectivityError::Socket(e),
3210            ))
3211        }
3212        Ok(Err(HandshakeError::Auth(e))) => {
3213            let _ = ws.close(None).await;
3214            Err(UnindexedClientError::Api(ApiError::Unauthenticated(e)))
3215        }
3216        Err(_) => {
3217            let _ = ws.close(None).await;
3218            Err(UnindexedClientError::Connectivity(
3219                ConnectivityError::Timeout,
3220            ))
3221        }
3222    }
3223}
3224
3225/// Perform the Alpaca WS auth + subscribe sequence on a connected WebSocket.
3226///
3227/// Protocol:
3228/// 1. Send `{"action":"auth","key":...,"secret":...}`
3229/// 2. Await `{"stream":"authorization","data":{"status":"authorized",...}}`
3230/// 3. Send `{"action":"listen","data":{"streams":["trade_updates"]}}`
3231/// 4. Await `{"stream":"listening","data":{"streams":["trade_updates"]}}`
3232async fn ws_handshake(ws: &mut WebSocket, config: &AlpacaConfig) -> Result<(), HandshakeError> {
3233    // Step 1: send auth
3234    let auth = serde_json::json!({
3235        "action": "auth",
3236        "key": config.api_key(),
3237        // direct field access within module — secret_key has no pub getter to
3238        // prevent external credential exposure
3239        "secret": config.secret_key,
3240    })
3241    .to_string();
3242    ws.send(WsMessage::Text(auth.into()))
3243        .await
3244        .map_err(|e| HandshakeError::Transport(format!("WS auth send: {e}")))?;
3245
3246    // Step 2: wait for authorization message
3247    loop {
3248        match ws.next().await {
3249            Some(Ok(WsMessage::Text(text))) => {
3250                if let Some(result) = check_auth_response(text.as_str()) {
3251                    result?;
3252                    break;
3253                }
3254            }
3255            Some(Ok(WsMessage::Binary(bytes))) => {
3256                if let Ok(text) = std::str::from_utf8(&bytes)
3257                    && let Some(result) = check_auth_response(text)
3258                {
3259                    result?;
3260                    break;
3261                }
3262            }
3263            Some(Err(e)) => {
3264                return Err(HandshakeError::Transport(format!(
3265                    "WS error during auth: {e}"
3266                )));
3267            }
3268            None => {
3269                return Err(HandshakeError::Transport(
3270                    "WS closed before auth response".into(),
3271                ));
3272            }
3273            _ => {} // ping/pong during auth — ignore
3274        }
3275    }
3276
3277    // Step 3: subscribe to trade_updates
3278    let sub = serde_json::json!({
3279        "action": "listen",
3280        "data": { "streams": ["trade_updates"] }
3281    })
3282    .to_string();
3283    ws.send(WsMessage::Text(sub.into()))
3284        .await
3285        .map_err(|e| HandshakeError::Transport(format!("WS subscribe send: {e}")))?;
3286
3287    // Step 4: wait for listening acknowledgment (optional but confirms subscription)
3288    loop {
3289        match ws.next().await {
3290            Some(Ok(WsMessage::Text(text))) => {
3291                if check_listen_ack(text.as_str()) {
3292                    break;
3293                }
3294                // Other messages in this window are NOT buffered and are permanently
3295                // dropped. On a reconnect, REST recovers the fills and how each order held
3296                // as live ended; any other lifecycle event is lost, as documented in
3297                // account_stream's doc comment.
3298                // Log at warn! for trade_updates (fill/lifecycle events) to ensure
3299                // production operators see dropped events; trace! for other streams.
3300                if let Ok(msg) = serde_json::from_str::<AlpacaStreamMessage<'_>>(text.as_str()) {
3301                    if msg.stream == "trade_updates" {
3302                        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");
3303                    } else {
3304                        trace!(stream = %msg.stream, "WS message dropped during listen-ack handshake");
3305                    }
3306                } else {
3307                    trace!(
3308                        bytes = text.len(),
3309                        "WS non-stream message dropped during listen-ack handshake"
3310                    );
3311                }
3312            }
3313            Some(Ok(WsMessage::Binary(bytes))) => {
3314                if let Ok(text) = std::str::from_utf8(&bytes)
3315                    && check_listen_ack(text)
3316                {
3317                    break;
3318                }
3319                trace!(
3320                    bytes = bytes.len(),
3321                    "WS binary message dropped during listen-ack handshake"
3322                );
3323            }
3324            Some(Err(e)) => {
3325                return Err(HandshakeError::Transport(format!(
3326                    "WS error during subscribe: {e}"
3327                )));
3328            }
3329            None => {
3330                return Err(HandshakeError::Transport(
3331                    "WS closed before subscribe ack".into(),
3332                ));
3333            }
3334            _ => {}
3335        }
3336    }
3337
3338    info!("Alpaca WS authenticated and subscribed to trade_updates");
3339    Ok(())
3340}
3341
3342/// Parse a WS message to check for authorization response.
3343/// Returns `None` if the message is not an authorization response.
3344/// Returns `Some(Ok(()))` on success, `Some(Err(HandshakeError::Auth(...)))` on auth failure.
3345///
3346/// Uses `AlpacaStreamMessage` for consistency with the rest of the WS parsing pipeline.
3347fn check_auth_response(text: &str) -> Option<Result<(), HandshakeError>> {
3348    let msg = serde_json::from_str::<AlpacaStreamMessage<'_>>(text).ok()?;
3349    if msg.stream != "authorization" {
3350        return None;
3351    }
3352    #[derive(Deserialize)]
3353    struct AuthData<'a> {
3354        status: &'a str,
3355    }
3356    let data = serde_json::from_str::<AuthData<'_>>(msg.data.get()).ok()?;
3357    if data.status == "authorized" {
3358        Some(Ok(()))
3359    } else {
3360        Some(Err(HandshakeError::Auth(format!(
3361            "Alpaca WS auth failed: status={}",
3362            data.status
3363        ))))
3364    }
3365}
3366
3367/// Returns `true` if the WS message is a listening acknowledgment for trade_updates.
3368///
3369/// Uses `AlpacaStreamMessage` for consistency with the rest of the WS parsing pipeline.
3370fn check_listen_ack(text: &str) -> bool {
3371    let Ok(msg) = serde_json::from_str::<AlpacaStreamMessage<'_>>(text) else {
3372        return false;
3373    };
3374    if msg.stream != "listening" {
3375        return false;
3376    }
3377    #[derive(Deserialize)]
3378    struct ListenData<'a> {
3379        #[serde(borrow)]
3380        streams: Vec<&'a str>,
3381    }
3382    let Ok(data) = serde_json::from_str::<ListenData<'_>>(msg.data.get()) else {
3383        return false;
3384    };
3385    data.streams.contains(&"trade_updates")
3386}
3387
3388// ---------------------------------------------------------------------------
3389// Process incoming WS messages
3390// ---------------------------------------------------------------------------
3391
3392/// Parse a raw WS text message and forward relevant account events to `tx`.
3393///
3394/// Each event is applied to `known` before it is sent, under the lock, so an order this reports
3395/// ending and a reconnect's check of it reach the consumer in the order they were decided, and the
3396/// check reports only an order not already reported.
3397///
3398/// The time Alpaca stamped on a `trade_updates` event, of any kind, samples `clock` (see
3399/// [`trade_update_time`]), duplicates included: each was stamped by Alpaca's clock before it
3400/// arrived. A time this host filled in does not.
3401fn process_ws_text(
3402    text: &str,
3403    tx: &mpsc::UnboundedSender<UnindexedAccountEvent>,
3404    dedup: &SharedDedupCache,
3405    known: &SharedKnownLiveOrders,
3406    clock: &mut VenueClock,
3407) {
3408    let msg: AlpacaStreamMessage<'_> = match serde_json::from_str(text) {
3409        Ok(m) => m,
3410        Err(e) => {
3411            trace!(
3412                %e,
3413                raw = ?&text[..text.len().min(200)],
3414                "Alpaca WS: skipped non-JSON message"
3415            );
3416            return;
3417        }
3418    };
3419
3420    match msg.stream.as_str() {
3421        "trade_updates" => {
3422            let update: AlpacaTradeUpdate<'_> = match serde_json::from_str(msg.data.get()) {
3423                Ok(u) => u,
3424                Err(e) => {
3425                    // Use warn (not trace): the outer frame was valid trade_updates JSON,
3426                    // so failure here signals an unexpected payload format. Could indicate
3427                    // an Alpaca API change or a missing required field dropping a real
3428                    // event (e.g. a rejected order). Visible at production log levels.
3429                    warn!(
3430                        %e,
3431                        raw = ?&msg.data.get()[..msg.data.get().len().min(200)],
3432                        "Alpaca WS trade_updates: failed to deserialize event — event dropped"
3433                    );
3434                    return;
3435                }
3436            };
3437
3438            // Read once: it samples the clock here, and stamps the events built below. Read before
3439            // the duplicate gate, so a frame without Alpaca's time warns even when it is then
3440            // dropped as a duplicate or an event type this client ignores.
3441            let time = trade_update_time(&update);
3442            if time.1 == TimeSource::Venue {
3443                clock.observe(time.0, tokio::time::Instant::now());
3444            }
3445
3446            // PERF: Check dedup BEFORE constructing the full event for fill events.
3447            // This avoids heap allocations (Trade, InstrumentNameExchange, TradeId) on
3448            // duplicate fills, which occur on every reconnect during recovery overlap.
3449            if is_fill_event(&update)
3450                && is_duplicate_fill(dedup, ws_execution_id(&update), early_dedup_key(&update))
3451            {
3452                trace!("Alpaca WS: skipping duplicate fill event (early check)");
3453                return;
3454            }
3455
3456            // Neither amendment is in the Trading API's docs, and no real frame has been seen, so
3457            // log it whole: the first one shows what Alpaca sends.
3458            if is_trade_amendment(&update) {
3459                warn!(
3460                    event = %update.event,
3461                    raw = %msg.data.get(),
3462                    "Alpaca WS: a fill reported earlier was amended — reporting it as TradeAmended"
3463                );
3464            }
3465
3466            for event in convert_trade_update_at(update, time).into_iter().flatten() {
3467                // A resolved correction delivers its execution as the replacement trade. Should
3468                // Alpaca also list it as a FILL activity, recovery must not deliver it again. An
3469                // unresolved one delivered no trade, so recovery may.
3470                if let AccountEventKind::TradeAmended(amendment) = &event.kind
3471                    && let TradeAmendmentKind::Corrected { replacement } = &amendment.kind
3472                {
3473                    record_execution(dedup, &replacement.id.0);
3474                }
3475                let known = KnownLiveOrders::observes(&event.kind).then(|| {
3476                    let mut known = known.lock();
3477                    known.observe(&event);
3478                    known
3479                });
3480                // Consumer dropped errors are benign; connection_manager will detect
3481                // tx.closed() on the next select! poll and exit cleanly.
3482                let _ = tx.send(event);
3483                drop(known);
3484            }
3485        }
3486        "authorization" | "listening" => {
3487            // Ack messages can appear during initial stream if the handshake was
3488            // not fully awaited. These are benign.
3489            trace!(stream = %msg.stream, "Alpaca WS: auth/listen ack received during stream");
3490        }
3491        other => {
3492            trace!(%other, "Alpaca WS: ignoring unknown stream type");
3493        }
3494    }
3495}
3496
3497/// The cumulative dedup key for a fill that took order `order_id` to `cumulative` filled: what
3498/// [`is_duplicate_fill`] recognises a fill by when it has no execution id.
3499///
3500/// The account stream and fill recovery both build it, from the cumulative each reports for the
3501/// order (the WS frame's `order.filled_qty`, the FILL activity's `cum_qty`), so one fill
3502/// delivered by both is recognised. `normalize` strips trailing zeros, so `"1.00"` and `"1"` give
3503/// one key. A WS fill whose `filled_qty` does not parse is keyed at zero.
3504///
3505/// The `format_smolstr!` call heap-allocates for UUID-length order ids (36 chars exceeds
3506/// SmolStr's inline limit), which is unavoidable given the key length.
3507fn fill_dedup_key(order_id: &str, cumulative: Decimal) -> SmolStr {
3508    format_smolstr!("{}:{}", order_id, cumulative.normalize())
3509}
3510
3511/// Returns `true` if this event busts or corrects a fill reported earlier.
3512fn is_trade_amendment(update: &AlpacaTradeUpdate<'_>) -> bool {
3513    matches!(update.event.as_str(), "trade_bust" | "trade_correct")
3514}
3515
3516/// Returns `true` if this event type produces a fill (Trade) event.
3517///
3518/// Used for early dedup check before allocating the full event.
3519#[inline]
3520fn is_fill_event(update: &AlpacaTradeUpdate<'_>) -> bool {
3521    matches!(update.event.as_str(), "fill" | "partial_fill")
3522}
3523
3524/// A WS event's `execution_id`, or `None` when it is missing, null or empty.
3525fn ws_execution_id<'a>(update: &AlpacaTradeUpdate<'a>) -> Option<&'a str> {
3526    update.execution_id.filter(|id| !id.is_empty())
3527}
3528
3529/// The [`fill_dedup_key`] of a WS fill, from its raw fields, before the full event is built.
3530///
3531/// An unparseable `filled_qty` reads as zero, as `convert_trade_update_at` reports it.
3532fn early_dedup_key(update: &AlpacaTradeUpdate<'_>) -> SmolStr {
3533    let filled_qty = update.order.filled_qty.unwrap_or("0");
3534    let qty = Decimal::from_str(filled_qty).unwrap_or(Decimal::ZERO);
3535    fill_dedup_key(&update.order.id, qty)
3536}
3537
3538/// The execution id in a FILL activity's `id`, `"{time}::{execution id}"`: the same id the WS
3539/// fill carries as `execution_id`. The time prefix is US Eastern local time, so it is never read.
3540/// An id without `::`, or with nothing after it, is taken whole, so it stays unique.
3541fn activity_execution_id(activity_id: &str) -> &str {
3542    activity_execution_id_part(activity_id).unwrap_or(activity_id)
3543}
3544
3545/// The execution id after `::` in a FILL activity's `id`, or `None` when there is none: an id
3546/// taken whole by [`activity_execution_id`] is not one the WS fill carries, so dedup does not
3547/// key on it.
3548fn activity_execution_id_part(activity_id: &str) -> Option<&str> {
3549    activity_id
3550        .split_once("::")
3551        .map(|(_, execution_id)| execution_id)
3552        .filter(|execution_id| !execution_id.is_empty())
3553}
3554
3555// ---------------------------------------------------------------------------
3556// Fill recovery
3557// ---------------------------------------------------------------------------
3558
3559/// Fills a recovery read did not deliver: those from `start` until the recovery began.
3560#[derive(Debug, PartialEq)]
3561struct UnreadFills {
3562    start: DateTime<Utc>,
3563    reason: FillRecoveryFailure,
3564}
3565
3566/// Where the read of the fills missed after `last`, the last message the stream received, starts:
3567/// Alpaca's or this host's time then, whichever is earlier ([`VenueClock::span_start`]), less
3568/// [`SIGNAL_RECOVERY_LOOKBACK_MS`]. Before the stream has seen a time Alpaca stamped, from this
3569/// host's time less [`UNSAMPLED_CLOCK_SKEW`] too.
3570///
3571/// So a host clock ahead of Alpaca's does not start the read after fills Alpaca stamped during the
3572/// outage, once the stream has seen an event.
3573fn fill_recovery_start(clock: &VenueClock, last: HostMoment) -> DateTime<Utc> {
3574    let earlier = |time: DateTime<Utc>, by: TimeDelta| {
3575        time.checked_sub_signed(by)
3576            .unwrap_or(DateTime::<Utc>::MIN_UTC)
3577    };
3578    let at = clock
3579        .span_start(last)
3580        .unwrap_or_else(|| earlier(last.time, UNSAMPLED_CLOCK_SKEW));
3581    earlier(at, TimeDelta::milliseconds(SIGNAL_RECOVERY_LOOKBACK_MS))
3582}
3583
3584/// Recover the fills missed from `start` to `end` ([`recover_fills`]) within `timeout`, and send an
3585/// [`AccountEventKind::FillRecoveryGaveUp`] for those the read did not deliver, after the fills
3586/// it did.
3587///
3588/// The read runs from `start` to now. `end` is where the live stream takes over, by Alpaca's
3589/// clock as estimated (see [`VenueClock::span_end`]): it only ends a gap reported.
3590async fn recover_fills_or_report(
3591    http: &reqwest::Client,
3592    rate_limiter: &RateLimitTracker,
3593    instruments: &[InstrumentNameExchange],
3594    base: &str,
3595    start: DateTime<Utc>,
3596    end: DateTime<Utc>,
3597    timeout: Duration,
3598    tx: &mpsc::UnboundedSender<UnindexedAccountEvent>,
3599    dedup: &SharedDedupCache,
3600    known: &SharedKnownLiveOrders,
3601) {
3602    let outcome = match tokio::time::timeout(
3603        timeout,
3604        recover_fills(
3605            http,
3606            rate_limiter,
3607            instruments,
3608            base,
3609            start,
3610            tx,
3611            dedup,
3612            known,
3613        ),
3614    )
3615    .await
3616    {
3617        Ok(outcome) => outcome,
3618        // One account-wide activities query serves every instrument, so a timeout can have missed
3619        // fills in any of them: name the requested set. Nothing was forwarded, since recovery
3620        // sends its fills only after the read has finished.
3621        Err(_) => {
3622            warn!(
3623                timeout_secs = timeout.as_secs(),
3624                instruments = ?instruments
3625                    .iter()
3626                    .map(|instrument| instrument.name().as_str())
3627                    .collect::<Vec<_>>(),
3628                "Alpaca fill recovery timed out — fills since the disconnect may be missing for \
3629                 any of the instruments (every instrument when the list is empty)"
3630            );
3631            Err(UnreadFills {
3632                start,
3633                reason: FillRecoveryFailure::TimedOut {
3634                    timeout_secs: timeout.as_secs(),
3635                },
3636            })
3637        }
3638    };
3639    if let Err(unread) = outcome
3640        && tx
3641            .send(fill_recovery_gave_up(instruments, unread, end))
3642            .is_err()
3643    {
3644        debug!("Alpaca fill recovery: consumer dropped before the give-up was sent");
3645    }
3646}
3647
3648/// The [`AccountEventKind::FillRecoveryGaveUp`] for `unread`, a recovery whose span ended at
3649/// `end`, on a stream opened with `instruments`, every instrument when empty. Alpaca does not
3650/// retry the read, so it was read once.
3651///
3652/// A truncated read can return fills stamped after `end`, by Alpaca's clock, which may run ahead
3653/// of its estimate. The span then ends at its start rather than before it: the event is still
3654/// sent, since a report with nothing left to read costs the consumer one read, while a report
3655/// withheld on a clock comparison would be a silent loss.
3656fn fill_recovery_gave_up(
3657    instruments: &[InstrumentNameExchange],
3658    unread: UnreadFills,
3659    end: DateTime<Utc>,
3660) -> UnindexedAccountEvent {
3661    let UnreadFills { start, reason } = unread;
3662    // One account-wide read serves every instrument, so one event covers them all.
3663    let scope = if instruments.is_empty() {
3664        FillRecoveryScope::AllInstruments
3665    } else {
3666        FillRecoveryScope::Instruments(instruments.to_vec())
3667    };
3668    UnindexedAccountEvent::new(
3669        ExchangeId::AlpacaBroker,
3670        AccountEventKind::FillRecoveryGaveUp(FillRecoveryGap::new(
3671            scope,
3672            start,
3673            end.max(start),
3674            1,
3675            reason,
3676        )),
3677    )
3678}
3679
3680/// Fetch fills missed during a WS disconnect, since `after`, and forward through the dedup cache.
3681///
3682/// A fill that brings an order to its full quantity ends it in `known`, so a reconnect's check
3683/// does not report it again.
3684///
3685/// Returns the fills it did not deliver: all of them when the read fails, and those from the
3686/// millisecond of the last one read when the read stops at [`MAX_ACTIVITY_PAGES`]. The fills it read are sent
3687/// first. A consumer that drops the stream part-way gets `Ok`, as there is no one to report to.
3688async fn recover_fills(
3689    http: &reqwest::Client,
3690    rate_limiter: &RateLimitTracker,
3691    instruments: &[InstrumentNameExchange],
3692    base: &str,
3693    after: DateTime<Utc>,
3694    tx: &mpsc::UnboundedSender<UnindexedAccountEvent>,
3695    dedup: &SharedDedupCache,
3696    known: &SharedKnownLiveOrders,
3697) -> Result<(), UnreadFills> {
3698    // Empty `instruments` means "recover for all subscribed symbols" (same convention as
3699    // `fetch_trades` / `fetch_open_orders`). Skip the set allocation when not filtering.
3700    info!(%after, instruments = instruments.len(), "Alpaca recovering fills after reconnect");
3701    let instrument_set: fnv::FnvHashSet<&str> = if instruments.is_empty() {
3702        fnv::FnvHashSet::default()
3703    } else {
3704        instruments.iter().map(|i| i.name().as_str()).collect()
3705    };
3706
3707    let page = match paginate_activities::<AlpacaActivity>(
3708        http,
3709        rate_limiter,
3710        base,
3711        FILL_ACTIVITIES,
3712        after,
3713        None,
3714    )
3715    .await
3716    {
3717        Ok(p) => p,
3718        Err(e) => {
3719            error!(%e, "Alpaca fill recovery: REST request failed");
3720            return Err(UnreadFills {
3721                start: after,
3722                reason: FillRecoveryFailure::Request(e.to_string()),
3723            });
3724        }
3725    };
3726
3727    let activities = page.activities;
3728
3729    // The read is account-wide, so one that stopped at the page cap has every fill before its
3730    // resume point, whatever the instrument: the millisecond of its last fill, which a read from
3731    // there reads again. The dedup cache absorbs the fills read twice.
3732    let unread = page.resume.map(|start| {
3733        error!(
3734            max_pages = MAX_ACTIVITY_PAGES,
3735            fills_read = activities.len(),
3736            %start,
3737            "Alpaca fill recovery: max page limit reached, truncating — fills from this time on \
3738             are not delivered; read them with fetch_trades"
3739        );
3740        UnreadFills {
3741            start,
3742            reason: FillRecoveryFailure::Truncated {
3743                fills_read: activities.len(),
3744            },
3745        }
3746    });
3747
3748    let mut recovered = 0u32;
3749    let mut duplicates = 0u32;
3750
3751    // Fallback only. A fill is recognised by its execution id, and by its cumulative key
3752    // "{order_id}:{cumulative_filled_qty}" where either path lacks the id (see
3753    // `is_duplicate_fill`). The cumulative is taken from the activity's own `cum_qty` -- the same
3754    // figure the WS path reads from `order.filled_qty` -- so the two agree by construction rather
3755    // than by reconstruction.
3756    //
3757    // 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
3758    // entirely inside the recovery window: it counts from zero per order within the batch, so an
3759    // order that had already partly filled before the window yields keys offset by the amount it
3760    // filled earlier, and those keys match nothing the WS path ever emitted. Preferring `cum_qty`
3761    // is what removes that failure mode.
3762    //
3763    // Activities are fetched with direction=asc, so accumulation here follows execution order.
3764    // Borrow `activities` so &str keys into order_id strings remain valid for the entire loop:
3765    // consuming iteration would drop each activity at the end of its iteration, invalidating keys
3766    // that later fills on the same order still need to look up.
3767    let mut cumulative_qty: FnvHashMap<&str, Decimal> = FnvHashMap::default();
3768
3769    for activity in &activities {
3770        if !instrument_set.is_empty() && !instrument_set.contains(activity.symbol.as_str()) {
3771            // Safe to skip without advancing cumulative_qty: each Alpaca order is bound
3772            // to exactly one symbol, so no fill for a different symbol can ever share
3773            // the same order_id as a fill we're tracking.
3774            continue;
3775        }
3776
3777        // Advance cumulative_qty for every activity, regardless of whether parsing
3778        // succeeds. The WS path uses Alpaca's cumulative filled_qty, which counts ALL
3779        // fills — including those with malformed fields. Skipping the counter for a bad
3780        // fill causes subsequent fills to produce dedup keys that diverge from the WS
3781        // path (off by the bad fill's qty), so the next good fill appears as a duplicate
3782        // on one path and a new fill on the other, resulting in either a missed or a
3783        // double-delivered fill for that execution.
3784        //
3785        // activity.qty is always populated for FILL activities (Alpaca guarantees it).
3786        // unwrap_or(ZERO) guards against unexpected API changes; a zero qty skips
3787        // incrementing the cumulative counter for that activity. Note: a non-empty
3788        // but non-parseable qty string (e.g. an API regression sending "abc") also
3789        // produces exec_qty=ZERO — the counter stalls, causing subsequent fills on
3790        // the same order to produce dedup keys that diverge from the WS path.
3791        let exec_qty = Decimal::from_str(&activity.qty).unwrap_or(Decimal::ZERO);
3792        let cum = cumulative_qty
3793            .entry(activity.order_id.as_str())
3794            .or_default();
3795        *cum += exec_qty;
3796        let cumulative = *cum;
3797
3798        let trade = match convert_activity_to_trade(activity) {
3799            Some(t) => t,
3800            None => {
3801                warn!(id = %activity.id, symbol = %activity.symbol, "Alpaca: skipping activity with unparseable fields");
3802                continue; // Counter already advanced; dedup key sequence stays aligned with WS.
3803            }
3804        };
3805
3806        // The cumulative key, which matches the WS path's for the same fill.
3807        // `order_filled_quantity` holds the venue's own cumulative, parsed by
3808        // `convert_activity_to_trade`; `cumulative` is the intra-batch fallback described above.
3809        //
3810        // The counter is advanced for every activity, including those that carry `cum_qty` and so
3811        // never consult it. That is deliberate: it keeps the fallback usable for a later activity
3812        // on the same order that omits the field. A batch mixing both therefore interleaves two
3813        // key schemes, and the fallback keys in it remain offset by whatever the order filled
3814        // before the window -- the field's presence rescues an activity, not an order.
3815        let key = fill_dedup_key(
3816            &activity.order_id,
3817            trade.order_filled_quantity.unwrap_or(cumulative),
3818        );
3819        if is_duplicate_fill(dedup, activity_execution_id_part(&activity.id), key) {
3820            duplicates += 1;
3821            continue;
3822        }
3823
3824        let event =
3825            UnindexedAccountEvent::new(ExchangeId::AlpacaBroker, AccountEventKind::Trade(trade));
3826        let mut held = known.lock();
3827        held.observe(&event);
3828        let sent = tx.send(event);
3829        drop(held);
3830        if sent.is_err() {
3831            debug!("Alpaca fill recovery: consumer dropped during recovery");
3832            return Ok(());
3833        }
3834        recovered += 1;
3835    }
3836
3837    info!(recovered, duplicates, "Alpaca fill recovery complete");
3838    unread.map_or(Ok(()), Err)
3839}
3840
3841// ---------------------------------------------------------------------------
3842// Ended-order recovery
3843// ---------------------------------------------------------------------------
3844
3845/// Look the order under `key` up with `GET /v2/orders:by_client_order_id`.
3846///
3847/// A 404 is [`OrderLookup::Unknown`], and so is an order on another symbol than the key's (ignoring
3848/// case, as Alpaca's symbols are upper case).
3849async fn fetch_order_lookup(
3850    http: reqwest::Client,
3851    rate_limiter: Arc<RateLimitTracker>,
3852    config: Arc<AlpacaConfig>,
3853    key: UnindexedOrderKey,
3854) -> Result<OrderLookup, UnindexedClientError> {
3855    let url = format!("{}/v2/orders:by_client_order_id", config.rest_base_url());
3856    let cid = key.cid.0.as_str();
3857    let found: Option<AlpacaOrderResponse> = rest_lookup_with_retry(&rate_limiter, || {
3858        http.get(&url).query(&[("client_order_id", cid)])
3859    })
3860    .await?;
3861    let Some(order) = found else {
3862        debug!(instrument = %key.instrument, cid = %key.cid, "Alpaca does not know this order");
3863        return Ok(OrderLookup::Unknown);
3864    };
3865    if !order
3866        .symbol
3867        .eq_ignore_ascii_case(key.instrument.name().as_str())
3868    {
3869        debug!(
3870            instrument = %key.instrument,
3871            cid = %key.cid,
3872            symbol = %order.symbol,
3873            "Alpaca knows this client order id on another symbol"
3874        );
3875        return Ok(OrderLookup::Unknown);
3876    }
3877    Ok(
3878        convert_ended_order(&order, &key).map_or(OrderLookup::NotEnded, |order| {
3879            OrderLookup::Ended(Box::new(order))
3880        }),
3881    )
3882}
3883
3884/// The client order ids one `GET /v2/orders?status=open` lists on `instruments`, for a
3885/// reconnect's check of the orders held as live. A listing of 500 may be truncated, so it fails.
3886async fn listed_open_cids(
3887    http: reqwest::Client,
3888    rate_limiter: Arc<RateLimitTracker>,
3889    config: Arc<AlpacaConfig>,
3890    instruments: Vec<InstrumentNameExchange>,
3891) -> Result<FnvHashSet<ClientOrderId>, UnindexedClientError> {
3892    let orders =
3893        fetch_raw_open_orders(&http, &rate_limiter, config.rest_base_url(), &instruments).await?;
3894    Ok(orders
3895        .iter()
3896        .map(|order| alpaca_cid(order.client_order_id.as_deref(), &order.id))
3897        .collect())
3898}
3899
3900/// [`recover_ended_orders`] on Alpaca: one listing of every instrument due by
3901/// [`listed_open_cids`], and lookups by [`fetch_order_lookup`]. Fill recovery has finished or been
3902/// given up before it runs, so no fills are pending.
3903async fn recover_alpaca_ended_orders(
3904    http: &reqwest::Client,
3905    rate_limiter: &Arc<RateLimitTracker>,
3906    config: &Arc<AlpacaConfig>,
3907    known: &SharedKnownLiveOrders,
3908    unchecked: &mut UncheckedOrders,
3909    tx: &mpsc::UnboundedSender<UnindexedAccountEvent>,
3910) {
3911    recover_ended_orders(
3912        ExchangeId::AlpacaBroker,
3913        known,
3914        unchecked,
3915        &NoPendingFills,
3916        tx,
3917        OpenListing::Batched,
3918        |instruments| {
3919            listed_open_cids(
3920                http.clone(),
3921                rate_limiter.clone(),
3922                config.clone(),
3923                instruments,
3924            )
3925        },
3926        |key| fetch_order_lookup(http.clone(), rate_limiter.clone(), config.clone(), key),
3927    )
3928    .await;
3929}
3930
3931/// Run every order check as it falls due, the first at once and a failed one after its backoff,
3932/// for as long as it is polled. It never completes, and once the consumer has gone it only waits.
3933///
3934/// Fill recovery has finished or been given up before this starts, so no fills are pending.
3935async fn run_order_checks(
3936    http: &reqwest::Client,
3937    rate_limiter: &Arc<RateLimitTracker>,
3938    config: &Arc<AlpacaConfig>,
3939    known: &SharedKnownLiveOrders,
3940    unchecked: &mut UncheckedOrders,
3941    tx: &mpsc::UnboundedSender<UnindexedAccountEvent>,
3942) {
3943    loop {
3944        // A check stops at a gone consumer before settling, so its instruments stay due.
3945        if tx.is_closed() {
3946            return std::future::pending().await;
3947        }
3948        match unchecked.next_due(&NoPendingFills) {
3949            Some(due) => tokio::time::sleep_until(due).await,
3950            None => return std::future::pending().await,
3951        }
3952        recover_alpaca_ended_orders(http, rate_limiter, config, known, unchecked, tx).await;
3953    }
3954}
3955
3956/// How an order that has ended did end, from its REST order under `key`, the key it was asked for
3957/// (see [`ended_order_state`]).
3958///
3959/// Returns `None` for an order still live (see [`order_status_is_live`]), including
3960/// `done_for_day`, and, with a warning, for an order whose status is missing or unknown or that
3961/// cannot be converted. A caller reads `None` as "not ended", so such an order is asked about again
3962/// later rather than retired on a guess.
3963fn convert_ended_order(
3964    o: &AlpacaOrderResponse,
3965    key: &UnindexedOrderKey,
3966) -> Option<UnindexedInactiveOrder> {
3967    let instrument = &key.instrument;
3968    let Some(status) = o.status.as_deref() else {
3969        warn!(%instrument, cid = %key.cid, order_id = %o.id, "Alpaca order missing status");
3970        return None;
3971    };
3972    if order_status_is_live(status) {
3973        return None;
3974    }
3975    let Some(order) = convert_representable_open_order(o) else {
3976        warn!(%instrument, cid = %key.cid, order_id = %o.id, status, "Alpaca order cannot be represented, treating it as not ended");
3977        return None;
3978    };
3979    // Read again rather than from the open order, which reads an unknown fill as zero; that
3980    // conversion has already warned of it.
3981    let filled_qty = Decimal::from_str(&o.filled_qty).ok();
3982    let Some(state) = ended_order_state(
3983        status,
3984        OrderId(SmolStr::new(&o.id)),
3985        order.state.time_exchange,
3986        order.quantity,
3987        filled_qty,
3988        o.filled_avg_price.as_deref(),
3989    ) else {
3990        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");
3991        return None;
3992    };
3993    let mut order = order.map_state(|_| state);
3994    order.key = key.clone();
3995    Some(order)
3996}
3997
3998/// Whether an Alpaca order in `status` is still live, so may yet fill or be cancelled.
3999///
4000/// `done_for_day` is live: Alpaca works the order again the next trading day. The rest are
4001/// Alpaca's working and pending statuses. Every other status has ended the order (see
4002/// [`ended_order_state`]) or is unknown to this version.
4003fn order_status_is_live(status: &str) -> bool {
4004    matches!(
4005        status,
4006        "new"
4007            | "partially_filled"
4008            | "done_for_day"
4009            | "pending_cancel"
4010            | "pending_replace"
4011            | "accepted"
4012            | "pending_new"
4013            | "accepted_for_bidding"
4014            | "stopped"
4015            | "suspended"
4016            | "calculated"
4017            | "held"
4018    )
4019}
4020
4021/// How an order ended, from its Alpaca `status`, or `None` for a status that does not end it.
4022///
4023/// `filled` becomes [`InactiveOrderState::FullyFilled`] with the reported fill, or the whole
4024/// `quantity` when that is unknown, and `filled_avg_price`; `canceled` becomes
4025/// [`InactiveOrderState::Cancelled`], and so does `replaced`, since the order replacing it has an
4026/// id and client order id of its own; `expired` becomes [`InactiveOrderState::Expired`], each with
4027/// what filled before, `None` when that is unknown; and `rejected` becomes
4028/// [`InactiveOrderState::OpenFailed`]. Shared by the lookup of an order that ended
4029/// ([`convert_ended_order`]) and the response to placing one ([`placed_order_state`]).
4030fn ended_order_state<AssetKey, InstrumentKey>(
4031    status: &str,
4032    order_id: OrderId,
4033    time_exchange: DateTime<Utc>,
4034    quantity: Decimal,
4035    filled_qty: Option<Decimal>,
4036    filled_avg_price: Option<&str>,
4037) -> Option<InactiveOrderState<AssetKey, InstrumentKey>> {
4038    Some(match status {
4039        // A filled order filled its whole quantity, whether or not `filled_qty` said so.
4040        "filled" => InactiveOrderState::FullyFilled(Filled::new(
4041            order_id,
4042            time_exchange,
4043            filled_qty.unwrap_or(quantity),
4044            alpaca_avg_price(filled_avg_price),
4045        )),
4046        "canceled" | "replaced" => {
4047            InactiveOrderState::Cancelled(Cancelled::new(order_id, time_exchange, filled_qty))
4048        }
4049        "expired" => InactiveOrderState::Expired(Expired::new(order_id, time_exchange, filled_qty)),
4050        "rejected" => {
4051            InactiveOrderState::OpenFailed(OrderError::Rejected(ApiError::OrderRejected(format!(
4052                "Alpaca rejected order {order_id} after accepting it"
4053            ))))
4054        }
4055        _ => return None,
4056    })
4057}
4058
4059/// The state that `resp`, Alpaca's response to placing an order of `quantity`, reports.
4060///
4061/// An order that ended in the response itself is reported as it ended (see
4062/// [`ended_order_state`]): an IOC or FOK order that found no liquidity, or partly filled and had
4063/// the rest cancelled, is `Cancelled` or `Expired` with what filled, not `Open`, and one Alpaca
4064/// rejected after accepting it is `OpenFailed`. A live status (see [`order_status_is_live`]) reads
4065/// from what filled: `FullyFilled`, with `filled_avg_price`, once it covers `quantity`, otherwise
4066/// `Open`. So does a status that is missing or that this version does not know, with a warning,
4067/// since the account stream reports the order's next state either way.
4068fn placed_order_state(
4069    resp: &AlpacaOrderResponse,
4070    instrument: &InstrumentNameExchange,
4071    quantity: Decimal,
4072) -> UnindexedOrderState {
4073    let order_id = OrderId(SmolStr::new(&resp.id));
4074    let time_exchange = order_state_time(resp);
4075    let filled_qty = alpaca_filled_qty(&resp.id, Some(&resp.filled_qty));
4076    let filled_avg_price = resp.filled_avg_price.as_deref();
4077    match resp.status.as_deref() {
4078        Some(status) => {
4079            if let Some(ended) = ended_order_state(
4080                status,
4081                order_id.clone(),
4082                time_exchange,
4083                quantity,
4084                filled_qty,
4085                filled_avg_price,
4086            ) {
4087                return OrderState::Inactive(ended);
4088            }
4089            if !order_status_is_live(status) {
4090                warn!(%instrument, %order_id, status, "Alpaca placed an order with a status this version does not know, reading it from what filled");
4091            }
4092        }
4093        None => {
4094            warn!(%instrument, %order_id, "Alpaca placed an order without a status, reading it from what filled");
4095        }
4096    }
4097    match filled_qty {
4098        Some(filled_qty) if filled_qty >= quantity => OrderState::fully_filled(Filled::new(
4099            order_id,
4100            time_exchange,
4101            filled_qty,
4102            alpaca_avg_price(filled_avg_price),
4103        )),
4104        // A live order's fill only grows, so an unknown one reads as nothing filled until the
4105        // account stream reports more.
4106        _ => OrderState::active(Open::new(
4107            VenueOrderId::Assigned(order_id),
4108            time_exchange,
4109            filled_qty.unwrap_or(Decimal::ZERO),
4110        )),
4111    }
4112}
4113
4114/// The average price of an Alpaca order's fills, from its `filled_avg_price`: `None` when no
4115/// price is reported or it does not parse.
4116fn alpaca_avg_price(filled_avg_price: Option<&str>) -> Option<Decimal> {
4117    filled_avg_price.and_then(|price| Decimal::from_str(price).ok())
4118}
4119
4120// ---------------------------------------------------------------------------
4121// Type conversion helpers
4122// ---------------------------------------------------------------------------
4123
4124/// Whether a caller's `asset` names the account currency. A name a caller passes is matched
4125/// ignoring case, as the execution map matches the names this client reports.
4126fn is_usd(asset: &AssetNameExchange) -> bool {
4127    asset.name().as_str().eq_ignore_ascii_case(USD)
4128}
4129
4130/// Convert an Alpaca account response to rustrade balance entries.
4131///
4132/// Returns a single USD balance with:
4133/// - `total` = `cash`, which is negative while the account borrows on margin
4134/// - `free` = the lesser of `cash` and `non_marginable_buying_power`: cash that can be spent
4135///   without borrowing, within what Alpaca allows
4136///
4137/// `free` is capped at cash because `non_marginable_buying_power` counts the loan value of held
4138/// marginable stock, so it exceeds cash whenever such stock is held. The cap keeps
4139/// `free <= total`, so with negative cash `free` is negative too.
4140///
4141/// Account equity is not the total: equity and option holdings are reported as positions, and
4142/// counting them again here would double them.
4143///
4144/// The balance is named [`USD`], however the caller spelled it. If `assets` is non-empty, it is
4145/// returned only when USD is among them, matched ignoring case. An empty `assets` slice returns it
4146/// unconditionally. It is stamped `time`.
4147fn convert_account_to_balances(
4148    account: &AlpacaAccount,
4149    assets: &[AssetNameExchange],
4150    time: DateTime<Utc>,
4151) -> Vec<AssetBalance<AssetNameExchange>> {
4152    if !assets.is_empty() && !assets.iter().any(is_usd) {
4153        return Vec::new();
4154    }
4155
4156    vec![AssetBalance::new(
4157        AssetNameExchange::new(USD),
4158        Balance::new(
4159            account.cash,
4160            account.cash.min(account.non_marginable_buying_power),
4161        ),
4162        time,
4163    )]
4164}
4165
4166/// Convert Alpaca positions to crypto asset balance entries.
4167///
4168/// Only positions with `asset_class == "crypto"` are included; [`convert_positions`] reports the
4169/// rest. Each is named by the asset it holds, read from its symbol by
4170/// [`crypto_position_asset`] as Alpaca spells it (e.g., `"BTCUSD"` → `"BTC"`).
4171///
4172/// - `total` = quantity of the holding in base currency units (e.g. 0.5 BTC)
4173/// - `free`  = qty_available (base currency units not locked in open orders)
4174///
4175/// If `assets` is non-empty, only positions whose base asset name matches an
4176/// entry in the slice (case-insensitive) are returned. Each is stamped `time`.
4177fn convert_positions_to_balances(
4178    positions: &[AlpacaPosition],
4179    assets: &[AssetNameExchange],
4180    time: DateTime<Utc>,
4181) -> Vec<AssetBalance<AssetNameExchange>> {
4182    positions
4183        .iter()
4184        .filter(|p| is_crypto_position(p))
4185        .filter_map(|p| {
4186            let base = crypto_position_asset(&p.symbol);
4187
4188            // Apply assets filter if specified.
4189            if !assets.is_empty()
4190                && !assets
4191                    .iter()
4192                    .any(|a| a.name().as_str().eq_ignore_ascii_case(base))
4193            {
4194                return None;
4195            }
4196
4197            // total and free are in base currency units (e.g., BTC), not USD,
4198            // consistent with AssetBalance semantics for currency/crypto assets.
4199            let asset_name = AssetNameExchange::new(base);
4200            Some(AssetBalance::new(
4201                asset_name,
4202                Balance::new(p.qty, p.qty_available),
4203                time,
4204            ))
4205        })
4206        .collect()
4207}
4208
4209/// The asset a crypto position holds, read from its symbol.
4210///
4211/// Alpaca documents that its positions list names a crypto holding by its asset followed by `USD`
4212/// (`BTCUSD` holds BTC), while orders and fills name the pair (`BTC/USD`). Both forms are read,
4213/// the `USD` suffix ignoring case. A symbol in neither form, or with an empty asset, is returned
4214/// whole, with a warning, so its balance is still reported, under a name that fails to index
4215/// rather than not at all.
4216fn crypto_position_asset(symbol: &str) -> &str {
4217    let base = match symbol.split_once('/') {
4218        Some((base, _)) => Some(base),
4219        None => {
4220            let base_len = symbol.len().saturating_sub(USD.len());
4221            symbol
4222                .get(base_len..)
4223                .filter(|suffix| suffix.eq_ignore_ascii_case(USD))
4224                .and_then(|_| symbol.get(..base_len))
4225        }
4226    };
4227    match base {
4228        Some(base) if !base.is_empty() => base,
4229        _ => {
4230            warn!(
4231                %symbol,
4232                "Alpaca crypto position symbol is neither ASSETUSD nor BASE/QUOTE; using it whole"
4233            );
4234            symbol
4235        }
4236    }
4237}
4238
4239/// Whether an Alpaca position is a crypto holding, which is reported as an asset balance rather
4240/// than a [`Position`].
4241fn is_crypto_position(position: &AlpacaPosition) -> bool {
4242    position.asset_class.eq_ignore_ascii_case("crypto")
4243}
4244
4245/// Convert Alpaca's non-crypto positions (equities and options) to [`Position`]s, each with its
4246/// symbol, in Alpaca's order.
4247///
4248/// Crypto is left to [`convert_positions_to_balances`]: Alpaca crypto is spot-only and cannot be
4249/// sold short, so a holding is simply a balance of the base asset, as on every spot venue. Every
4250/// other asset class is a position, so a class Alpaca adds later is reported rather than dropped.
4251///
4252/// Each [`Position`] carries:
4253/// - `quantity`: the magnitude of `qty`, negative when `side` is `short`;
4254/// - `entry_price`: `avg_entry_price`, per share for an equity and per share of the underlying
4255///   for an option (the premium as quoted, not multiplied by the contract size);
4256/// - `unrealized_pnl`: `unrealized_pl`, in USD for the whole position;
4257/// - `time_exchange`: `now`, since Alpaca does not timestamp positions.
4258///
4259/// A position with zero quantity is left out.
4260///
4261/// # Errors
4262///
4263/// [`ClientError::Internal`](crate::error::ClientError::Internal) when a position's `side` is
4264/// missing or neither `long` nor `short`: reporting it either way could invert it, and leaving it
4265/// out would read as flat.
4266fn convert_positions(
4267    positions: &[AlpacaPosition],
4268    now: DateTime<Utc>,
4269) -> Result<Vec<(&str, Position)>, UnindexedClientError> {
4270    positions
4271        .iter()
4272        .filter(|p| !is_crypto_position(p) && !p.qty.is_zero())
4273        .map(|p| {
4274            let size = p.qty.abs();
4275            let quantity = match p.side {
4276                AlpacaPositionSide::Long => size,
4277                AlpacaPositionSide::Short => -size,
4278                AlpacaPositionSide::Unknown => {
4279                    return Err(UnindexedClientError::Internal(format!(
4280                        "Alpaca position {} has a missing or unrecognised side",
4281                        p.symbol
4282                    )));
4283                }
4284            };
4285            let position = Position::new(
4286                quantity,
4287                p.avg_entry_price,
4288                p.unrealized_pl,
4289                None,
4290                None,
4291                None,
4292                None,
4293                now,
4294            );
4295            Ok((p.symbol.as_str(), position))
4296        })
4297        .collect()
4298}
4299
4300/// Group open orders and positions into per-instrument snapshots for account_snapshot.
4301///
4302/// When `instruments` is non-empty, a snapshot is returned for every requested instrument
4303/// (possibly with no orders and no position). When empty, only instruments with an open order or
4304/// a position are returned.
4305///
4306/// `positions` come from [`convert_positions`], which holds every non-zero equity and option
4307/// position, so an equity or option without one is [`PositionReport::Flat`]. A crypto pair (a
4308/// symbol with a `/`) is [`PositionReport::Unreported`]: its holding is a balance.
4309///
4310/// Requested names match Alpaca's symbols ignoring case. When two requested names differ only in
4311/// case, the first gets the symbol's orders and position; the second is
4312/// [`PositionReport::Unreported`] with its orders not complete, since nothing is known about it.
4313///
4314/// Each snapshot declares its orders complete unless [`convert_open_order`] left one of its
4315/// orders out. That holds because `orders` is every open order, unpaged: a response at Alpaca's
4316/// cap fails the whole snapshot in [`fetch_raw_open_orders`] rather than arriving here short. And
4317/// every order is listed under the client order id the order stream reports it under
4318/// ([`alpaca_cid`]): the one it was placed with, or the one Alpaca assigns to a bracket's
4319/// take-profit and stop-loss legs.
4320fn build_instrument_snapshots(
4321    orders: Vec<AlpacaOrderResponse>,
4322    positions: Vec<(&str, Position)>,
4323    instruments: &[InstrumentNameExchange],
4324) -> Vec<InstrumentAccountSnapshot<ExchangeId, AssetNameExchange, InstrumentNameExchange>> {
4325    /// One instrument's converted orders, whether none was left out, and its position.
4326    struct Listing {
4327        orders: Vec<UnindexedOrderSnapshot>,
4328        complete: bool,
4329        position: Option<Position>,
4330    }
4331
4332    impl Listing {
4333        fn empty() -> Self {
4334            Self {
4335                orders: Vec::new(),
4336                complete: true,
4337                position: None,
4338            }
4339        }
4340    }
4341
4342    // Build ordered map from symbol → snapshot to preserve deterministic ordering.
4343    let mut by_symbol: IndexMap<SmolStr, Listing> = IndexMap::new();
4344
4345    // Keyed by the upper-case symbol, so a requested name in another case still finds its
4346    // listing. Alpaca's own symbols are upper case, so an unfiltered read lists them unchanged.
4347    for order in orders {
4348        let listing = by_symbol
4349            .entry(order.symbol.to_ascii_uppercase().into())
4350            .or_insert_with(Listing::empty);
4351        match convert_open_order(&order) {
4352            Some(converted) => listing.orders.push(converted.into()),
4353            None => listing.complete = false,
4354        }
4355    }
4356
4357    for (symbol, position) in positions {
4358        by_symbol
4359            .entry(symbol.to_ascii_uppercase().into())
4360            .or_insert_with(Listing::empty)
4361            .position = Some(position);
4362    }
4363
4364    let snapshot = |instrument: InstrumentNameExchange, listing: Listing| {
4365        // `positions` holds every non-zero equity and option position, so one missing here is
4366        // flat. Crypto is reported as balances, never as a position.
4367        let position = if is_options_or_equity_symbol(instrument.name().as_str()) {
4368            listing
4369                .position
4370                .map_or(PositionReport::Flat, PositionReport::from_position)
4371        } else {
4372            PositionReport::Unreported
4373        };
4374        InstrumentAccountSnapshot::new(instrument, listing.orders, listing.complete, position, None)
4375    };
4376
4377    // If instruments is empty, return all; otherwise filter to requested set.
4378    if instruments.is_empty() {
4379        by_symbol
4380            .into_iter()
4381            .map(|(sym, listing)| snapshot(InstrumentNameExchange::new(sym), listing))
4382            .collect()
4383    } else {
4384        // Upper-case symbols already given to an earlier requested name.
4385        let mut claimed = std::collections::HashSet::new();
4386        instruments
4387            .iter()
4388            .map(|inst| {
4389                let symbol = inst.name().as_str().to_ascii_uppercase();
4390                // A second name for the same symbol, differing only in case: its listing went to
4391                // the first, so nothing is known about it rather than it being flat and orderless.
4392                if !claimed.insert(symbol.clone()) {
4393                    return InstrumentAccountSnapshot::new(
4394                        inst.clone(),
4395                        Vec::new(),
4396                        false,
4397                        PositionReport::Unreported,
4398                        None,
4399                    );
4400                }
4401                // swap_remove is O(1); output order is determined by the `instruments`
4402                // slice, not by the internal IndexMap order of `by_symbol`.
4403                let listing = by_symbol
4404                    .swap_remove(symbol.as_str())
4405                    .unwrap_or_else(Listing::empty);
4406                snapshot(inst.clone(), listing)
4407            })
4408            .collect()
4409    }
4410}
4411
4412/// Convert an Alpaca REST open order into rustrade's Open state order.
4413///
4414/// `None`, with a `warn!`, for an order it cannot represent: a notional order (placed by dollar
4415/// value, so its `qty` is null), which this client never places but the Alpaca dashboard can, or
4416/// one whose side, quantity or kind does not parse. A list missing that order is not every open
4417/// order, so [`build_instrument_snapshots`] must not declare it complete.
4418///
4419/// It warns on every call, so an order like that which stays open is logged again each time the
4420/// open orders are fetched.
4421fn convert_open_order(
4422    o: &AlpacaOrderResponse,
4423) -> Option<Order<ExchangeId, InstrumentNameExchange, Open>> {
4424    let converted = convert_representable_open_order(o);
4425    if converted.is_none() {
4426        warn!(
4427            order_id = %o.id,
4428            symbol = %o.symbol,
4429            qty = ?o.qty,
4430            side = %o.side,
4431            order_type = %o.order_type,
4432            "Alpaca open order cannot be represented - leaving it out"
4433        );
4434    }
4435    converted
4436}
4437
4438/// The client order id an Alpaca order is reported under, on the REST and stream paths alike: its
4439/// `client_order_id`, or its venue id when it has none.
4440///
4441/// Both paths must agree, or a complete snapshot would miss an order the stream is tracking under
4442/// another id.
4443fn alpaca_cid(client_order_id: Option<&str>, order_id: &str) -> ClientOrderId {
4444    ClientOrderId::new(client_order_id.unwrap_or(order_id))
4445}
4446
4447/// [`convert_open_order`] without the `warn!`.
4448fn convert_representable_open_order(
4449    o: &AlpacaOrderResponse,
4450) -> Option<Order<ExchangeId, InstrumentNameExchange, Open>> {
4451    let order_id = OrderId(SmolStr::new(&o.id));
4452    let cid = alpaca_cid(o.client_order_id.as_deref(), &o.id);
4453
4454    let instrument = InstrumentNameExchange::new(&o.symbol);
4455    let side = parse_side(&o.side)?;
4456    let quantity = Decimal::from_str(o.qty.as_deref().unwrap_or("0")).ok()?;
4457    // Notional orders (placed by dollar value, qty=null) have no representable quantity.
4458    // Leave them out rather than recording a zero-quantity order that would corrupt
4459    // reconciliation.
4460    if quantity.is_zero() {
4461        return None;
4462    }
4463    let price = o
4464        .limit_price
4465        .as_deref()
4466        .and_then(|s| Decimal::from_str(s).ok());
4467    let filled_qty = alpaca_filled_qty(&o.id, Some(&o.filled_qty)).unwrap_or(Decimal::ZERO);
4468    let kind = parse_order_kind(
4469        &o.order_type,
4470        o.stop_price.as_deref(),
4471        o.trail_percent.as_deref(),
4472        o.trail_price.as_deref(),
4473    )?;
4474    let time_in_force = parse_time_in_force(&o.time_in_force);
4475    let time_exchange = order_state_time(o);
4476
4477    Some(Order {
4478        key: OrderKey::new(
4479            ExchangeId::AlpacaBroker,
4480            instrument,
4481            // Alpaca doesn't carry strategy IDs in any response field.
4482            StrategyId::unknown(),
4483            cid,
4484        ),
4485        side,
4486        price,
4487        quantity,
4488        kind,
4489        time_in_force,
4490        state: Open::new(VenueOrderId::Assigned(order_id), time_exchange, filled_qty),
4491    })
4492}
4493
4494/// Convert an Alpaca FILL activity into a rustrade Trade.
4495fn convert_activity_to_trade(
4496    a: &AlpacaActivity,
4497) -> Option<Trade<AssetNameExchange, InstrumentNameExchange>> {
4498    let trade_id = TradeId::new(activity_execution_id(&a.id));
4499    let order_id = OrderId(SmolStr::new(&a.order_id));
4500    let instrument = InstrumentNameExchange::new(&a.symbol);
4501    let side = parse_side(&a.side)?;
4502    let price = Decimal::from_str(&a.price).ok()?;
4503    let quantity = Decimal::from_str(&a.qty).ok()?;
4504    // `None` when Alpaca omitted the field or sent something unparseable, which is the honest
4505    // report: the venue did not tell us where the order stands, rather than "nothing is filled".
4506    let order_filled_quantity = a.cum_qty.as_deref().and_then(|s| Decimal::from_str(s).ok());
4507    // The activity carries no other time, so without `transaction_time` the fill is kept, stamped
4508    // with this host's clock and marked so.
4509    let (time_exchange, time_source) = match parse_timestamp(&a.transaction_time) {
4510        Some(time) => (time, TimeSource::Venue),
4511        None => {
4512            warn!(id = %a.id, "Alpaca activity: unparseable transaction_time, using now");
4513            (Utc::now(), TimeSource::Host)
4514        }
4515    };
4516
4517    // Alpaca equities and options are commission-free. Crypto trades incur
4518    // maker/taker fees (currently 0.15–0.25%) charged in the credited asset,
4519    // but fee info is not available in trade responses — use Activities API
4520    // for end-of-day fee reconciliation.
4521    Some(
4522        Trade::new(
4523            trade_id,
4524            order_id,
4525            instrument,
4526            StrategyId::unknown(),
4527            time_exchange,
4528            side,
4529            price,
4530            quantity,
4531            order_filled_quantity,
4532            AssetFees::new(
4533                AssetNameExchange::from(USD),
4534                Decimal::ZERO,
4535                Some(Decimal::ZERO),
4536            ),
4537        )
4538        .with_time_source(time_source),
4539    )
4540}
4541
4542/// Build an `OrderSnapshot` event from the order payload embedded in a `trade_updates` frame.
4543///
4544/// Shared by the acknowledgement arm and the fill arms of [`convert_trade_update_at`], so that a
4545/// fill reports the order's new cumulative `filled_quantity` the same way an acknowledgement
4546/// reports its initial one.
4547///
4548/// Returns `None` for a notional order (placed by dollar value, `qty` is null). Emitting a
4549/// snapshot with `quantity == 0` would read as an order with nothing left to fill and retire it,
4550/// so such orders are left untracked -- consistent with `convert_open_order` on the REST path.
4551fn ws_order_snapshot(
4552    order: &AlpacaOrderWs<'_>,
4553    instrument: InstrumentNameExchange,
4554    cid: ClientOrderId,
4555    order_id: OrderId,
4556    time_exchange: DateTime<Utc>,
4557) -> Option<UnindexedAccountEvent> {
4558    let side = parse_side(&order.side)?;
4559    let quantity = Decimal::from_str(order.qty.unwrap_or("0")).unwrap_or(Decimal::ZERO);
4560    if quantity.is_zero() {
4561        trace!(order_id = %order.id, "Alpaca WS: skipping notional order snapshot (qty=None)");
4562        return None;
4563    }
4564    let price = order.limit_price.and_then(|s| Decimal::from_str(s).ok());
4565    // A live order's fill only grows, so a frame that omits it (some lifecycle events may) reads
4566    // as nothing filled yet, without a warning; one that garbles it is still reported.
4567    let filled_qty = order
4568        .filled_qty
4569        .and_then(|_| alpaca_filled_qty(&order.id, order.filled_qty))
4570        .unwrap_or(Decimal::ZERO);
4571    let kind = parse_order_kind(
4572        &order.order_type,
4573        order.stop_price,
4574        order.trail_percent,
4575        order.trail_price,
4576    )?;
4577    let time_in_force = parse_time_in_force(&order.time_in_force);
4578
4579    let order_snapshot = crate::order::Order {
4580        key: OrderKey::new(
4581            ExchangeId::AlpacaBroker,
4582            instrument,
4583            StrategyId::unknown(),
4584            cid,
4585        ),
4586        side,
4587        price,
4588        quantity,
4589        kind,
4590        time_in_force,
4591        state: OrderState::active(Open::new(
4592            VenueOrderId::Assigned(order_id),
4593            time_exchange,
4594            filled_qty,
4595        )),
4596    };
4597    Some(UnindexedAccountEvent::new(
4598        ExchangeId::AlpacaBroker,
4599        AccountEventKind::OrderSnapshot(rustrade_integration::collection::snapshot::Snapshot(
4600            order_snapshot,
4601        )),
4602    ))
4603}
4604
4605/// Whether a fill frame's order payload describes an order that is still live at the exchange,
4606/// and may therefore be written into engine state as an `Open` snapshot.
4607///
4608/// A fill frame carries the order's status alongside the execution. Only `partially_filled` and
4609/// `filled` say the order reached this execution while working; every other status means some
4610/// other frame owns the order's current state. Writing an `Open` snapshot from one of those would
4611/// resurrect an order the engine has already retired, leaving a resting order that does not exist
4612/// at the exchange -- a fill that arrives after its order's terminal frame is exactly the ordering
4613/// this guards against.
4614fn ws_fill_order_is_live(status: &str) -> bool {
4615    matches!(status, "partially_filled" | "filled")
4616}
4617
4618/// The [`AccountEventKind::TradeAmended`] for a `trade_bust` or `trade_correct` frame.
4619///
4620/// Neither event is in the Trading API's docs. The fields read are those of the Broker API's
4621/// `TradeUpdateEventV2`: `previous_execution_id` names the fill amended, `execution_id` is taken
4622/// as the corrected execution's id, `qty` is the busted or corrected quantity (negative on some
4623/// busts), and `price` the corrected price, which that schema does not promise on a correction. A
4624/// correction lacking anything a replacement trade needs is reported as
4625/// [`TradeAmendmentKind::CorrectedUnresolved`] with what it did carry, never dropped.
4626///
4627/// No order snapshot is emitted: a bust's lower cumulative would be refused, as
4628/// [`TradeAmendment`](crate::trade::TradeAmendment) documents.
4629fn trade_amendment_event(
4630    update: &AlpacaTradeUpdate<'_>,
4631    instrument: InstrumentNameExchange,
4632    order_id: OrderId,
4633    (time_exchange, time_source): (DateTime<Utc>, TimeSource),
4634) -> UnindexedAccountEvent {
4635    let order = &update.order;
4636    let original = update
4637        .previous_execution_id
4638        .filter(|id| !id.is_empty())
4639        .map(TradeId::new);
4640    let quantity = update.qty.and_then(|s| Decimal::from_str(s).ok());
4641
4642    let kind = if update.event == "trade_bust" {
4643        TradeAmendmentKind::Busted {
4644            quantity: quantity.map(|quantity| quantity.abs()),
4645        }
4646    } else {
4647        let id = ws_execution_id(update).map(TradeId::new);
4648        let price = update.price.and_then(|s| Decimal::from_str(s).ok());
4649        match (id, price, quantity, parse_side(&order.side)) {
4650            (Some(id), Some(price), Some(quantity), Some(side)) if quantity > Decimal::ZERO => {
4651                TradeAmendmentKind::Corrected {
4652                    replacement: with_reported_cid(
4653                        Trade::new(
4654                            id,
4655                            order_id.clone(),
4656                            instrument.clone(),
4657                            StrategyId::unknown(),
4658                            time_exchange,
4659                            side,
4660                            price,
4661                            quantity,
4662                            // What `order.filled_qty` means on a correction is undocumented, so the
4663                            // replacement does not claim a cumulative.
4664                            None,
4665                            // As on a fill: no fee info in WebSocket updates.
4666                            AssetFees::new(
4667                                AssetNameExchange::from(USD),
4668                                Decimal::ZERO,
4669                                Some(Decimal::ZERO),
4670                            ),
4671                        )
4672                        .with_time_source(time_source),
4673                        order.client_order_id.as_ref(),
4674                    ),
4675                }
4676            }
4677            (id, price, quantity, _) => TradeAmendmentKind::CorrectedUnresolved {
4678                id,
4679                price,
4680                quantity,
4681            },
4682        }
4683    };
4684
4685    UnindexedAccountEvent::new(
4686        ExchangeId::AlpacaBroker,
4687        AccountEventKind::TradeAmended(crate::trade::TradeAmendment::new(
4688            instrument,
4689            order_id,
4690            time_exchange,
4691            original,
4692            kind,
4693        )),
4694    )
4695}
4696
4697/// When a `trade_updates` event happened, and whose clock says so: its `timestamp`, else its
4698/// order's `updated_at`, else this host's clock, as [`TimeSource::Host`].
4699///
4700/// Alpaca stamps every event, so either falling back is logged with a `warn!`, once per frame,
4701/// whether or not the frame then delivers an event. `updated_at` is
4702/// when the order last changed as of the event, which for a fill is the fill. Either is a time on
4703/// Alpaca's clock no later than when it sent the event, as [`VenueClock::observe`] needs.
4704fn trade_update_time(update: &AlpacaTradeUpdate<'_>) -> (DateTime<Utc>, TimeSource) {
4705    if let Some(time) = update.timestamp.and_then(parse_timestamp) {
4706        return (time, TimeSource::Venue);
4707    }
4708    let order = &update.order;
4709    if let Some(time) = order.updated_at.and_then(parse_timestamp) {
4710        warn!(order_id = %order.id, event = %update.event, timestamp = ?update.timestamp, "Alpaca WS: event missing/unparseable timestamp, using its order's updated_at");
4711        return (time, TimeSource::Venue);
4712    }
4713    warn!(order_id = %order.id, event = %update.event, timestamp = ?update.timestamp, updated_at = ?order.updated_at, "Alpaca WS: event missing/unparseable timestamp and order updated_at, using now");
4714    (Utc::now(), TimeSource::Host)
4715}
4716
4717/// [`convert_trade_update_at`], reading the event's time itself.
4718#[cfg(test)]
4719fn convert_trade_update(update: AlpacaTradeUpdate<'_>) -> [Option<UnindexedAccountEvent>; 2] {
4720    let time = trade_update_time(&update);
4721    convert_trade_update_at(update, time)
4722}
4723
4724/// Convert a WebSocket trade_update event into rustrade AccountEvents.
4725///
4726/// Returns up to two events, in the order they must be applied. A fill frame genuinely carries
4727/// two facts -- the execution print and the order's new cumulative filled quantity -- so it maps
4728/// to both a `Trade` and an `OrderSnapshot`.
4729///
4730/// The `Trade` is always first. A fully-filled snapshot retires its order, and routing a fill
4731/// against an order that has already been retired is a strictly harder problem than routing it
4732/// against a live one; emitting the execution first keeps the easy ordering.
4733///
4734/// The events are stamped with the event's time and its source, read by [`trade_update_time`].
4735fn convert_trade_update_at(
4736    update: AlpacaTradeUpdate<'_>,
4737    (time_exchange, time_source): (DateTime<Utc>, TimeSource),
4738) -> [Option<UnindexedAccountEvent>; 2] {
4739    // Early exit for unrecognised event types before incurring allocations for
4740    // instrument/order_id/cid — those are wasted for unknown events.
4741    let event_str = update.event.as_str();
4742    if !matches!(
4743        event_str,
4744        "fill"
4745            | "partial_fill"
4746            | "new"
4747            | "accepted"
4748            | "pending_new"
4749            | "canceled"
4750            | "expired"
4751            | "replaced"
4752            | "done_for_day"
4753            | "rejected"
4754            | "trade_bust"
4755            | "trade_correct"
4756    ) {
4757        trace!(event = %event_str, "Alpaca WS: ignoring trade_updates event type");
4758        return [None, None];
4759    }
4760
4761    let order = &update.order;
4762    let instrument = InstrumentNameExchange::new(&*order.symbol);
4763    let order_id = OrderId(order.id.clone());
4764    let cid = alpaca_cid(order.client_order_id.as_deref(), &order.id);
4765
4766    match event_str {
4767        "fill" | "partial_fill" => {
4768            // Use event-level price/qty for the per-execution trade.
4769            let (Some(price), Some(quantity), Some(side)) = (
4770                update.price.and_then(|s| Decimal::from_str(s).ok()),
4771                update.qty.and_then(|s| Decimal::from_str(s).ok()),
4772                parse_side(&order.side),
4773            ) else {
4774                return [None, None];
4775            };
4776
4777            // If filled_qty is unparseable (API regression), cum_qty falls back to zero: the
4778            // trade reports a cumulative of 0, and a fill without an execution id is deduplicated
4779            // on "order_id:0", so a second such fill on the same order would be silently dropped.
4780            // Warn loudly so API regressions are surfaced immediately.
4781            let cum_qty = Decimal::from_str(order.filled_qty.unwrap_or("0"))
4782                .inspect_err(|e| {
4783                    warn!(
4784                        order_id = %order.id,
4785                        filled_qty = ?order.filled_qty,
4786                        %e,
4787                        "Alpaca WS: failed to parse filled_qty — the fill reports a cumulative \
4788                         of 0, and without an execution id a second such fill on the same order \
4789                         would be deduplicated away"
4790                    );
4791                })
4792                .unwrap_or(Decimal::ZERO);
4793            // The execution id, which the FILL activity for this fill carries too, so the fill has
4794            // one TradeId however it is delivered. Without it, fall back to the dedup key, which
4795            // is unique per fill but matches nothing a REST read returns.
4796            let trade_id = match ws_execution_id(&update) {
4797                Some(execution_id) => TradeId::new(execution_id),
4798                None => {
4799                    warn!(
4800                        order_id = %order.id,
4801                        "Alpaca WS: fill without a usable execution_id — its TradeId is \
4802                         \"{{order_id}}:{{filled_qty}}\", which fetch_trades will not match"
4803                    );
4804                    TradeId(fill_dedup_key(&order.id, cum_qty))
4805                }
4806            };
4807
4808            // Alpaca equities and options are commission-free. Crypto trades incur
4809            // maker/taker fees (currently 0.15–0.25%) in the credited asset, but
4810            // fee info is not available in WebSocket updates.
4811            let trade = Trade::new(
4812                trade_id,
4813                order_id.clone(),
4814                instrument.clone(),
4815                StrategyId::unknown(),
4816                time_exchange,
4817                side,
4818                price,
4819                quantity,
4820                // The venue's own cumulative for this order, as of this execution.
4821                Some(cum_qty),
4822                AssetFees::new(
4823                    AssetNameExchange::from(USD),
4824                    Decimal::ZERO,
4825                    Some(Decimal::ZERO),
4826                ),
4827            )
4828            .with_time_source(time_source);
4829            let trade = with_reported_cid(trade, order.client_order_id.as_ref());
4830            let trade_event = UnindexedAccountEvent::new(
4831                ExchangeId::AlpacaBroker,
4832                AccountEventKind::Trade(trade),
4833            );
4834
4835            // The execution alone does not move the order: `filled_quantity` is only ever carried
4836            // into engine state by an order snapshot, so without this second event a partially
4837            // filled order reads as having nothing filled until REST reconciliation refreshes it.
4838            let snapshot_event = if ws_fill_order_is_live(&order.status) {
4839                ws_order_snapshot(order, instrument, cid, order_id, time_exchange)
4840            } else {
4841                trace!(
4842                    order_id = %order.id,
4843                    status = %order.status,
4844                    "Alpaca WS: fill for an order the exchange no longer reports as working — \
4845                     emitting the execution without an order snapshot"
4846                );
4847                None
4848            };
4849
4850            [Some(trade_event), snapshot_event]
4851        }
4852
4853        "new" | "accepted" | "pending_new" | "done_for_day" => {
4854            // Order acknowledged by Alpaca, or done for the day: Alpaca stops working a
4855            // `done_for_day` order until the next trading day but has not ended it, so it is
4856            // reported open with what it has filled, never retired.
4857
4858            [
4859                ws_order_snapshot(order, instrument, cid, order_id, time_exchange),
4860                None,
4861            ]
4862        }
4863
4864        "canceled" | "expired" | "replaced" => {
4865            // Order no longer active.
4866            //
4867            // NOTE on "replaced": Alpaca's replace operation cancels the original order
4868            // and creates a NEW order with a different order ID. This arm correctly marks
4869            // the original as cancelled, but callers must call `fetch_open_orders` to
4870            // discover the replacement order (which has a new ID and won't appear in OMS
4871            // state automatically).
4872            let filled_qty = alpaca_filled_qty(&order.id, order.filled_qty);
4873            let cancelled = Cancelled::new(order_id, time_exchange, filled_qty);
4874            let response = crate::order::request::OrderResponseCancel {
4875                key: OrderKey::new(
4876                    ExchangeId::AlpacaBroker,
4877                    instrument,
4878                    StrategyId::unknown(),
4879                    cid,
4880                ),
4881                state: Ok(cancelled),
4882            };
4883            [
4884                Some(UnindexedAccountEvent::new(
4885                    ExchangeId::AlpacaBroker,
4886                    AccountEventKind::OrderCancelled(response),
4887                )),
4888                None,
4889            ]
4890        }
4891
4892        "trade_bust" | "trade_correct" => [
4893            Some(trade_amendment_event(
4894                &update,
4895                instrument,
4896                order_id,
4897                (time_exchange, time_source),
4898            )),
4899            None,
4900        ],
4901
4902        "rejected" => {
4903            let response = crate::order::request::OrderResponseCancel {
4904                key: OrderKey::new(
4905                    ExchangeId::AlpacaBroker,
4906                    instrument,
4907                    StrategyId::unknown(),
4908                    cid,
4909                ),
4910                state: Err(UnindexedOrderError::Rejected(ApiError::OrderRejected(
4911                    format!("order rejected: status={}", order.status),
4912                ))),
4913            };
4914            [
4915                Some(UnindexedAccountEvent::new(
4916                    ExchangeId::AlpacaBroker,
4917                    AccountEventKind::OrderCancelled(response),
4918                )),
4919                None,
4920            ]
4921        }
4922
4923        // All recognised event types are handled above; the early-return guard at the
4924        // top of this function ensures we never reach here for unknown event types.
4925        _ => unreachable!("convert_trade_update: unrecognised event passed early-return guard"),
4926    }
4927}
4928
4929// ---------------------------------------------------------------------------
4930// Field parsers
4931// ---------------------------------------------------------------------------
4932
4933fn parse_side(s: &str) -> Option<Side> {
4934    match s {
4935        "buy" | "Buy" | "BUY" => Some(Side::Buy),
4936        "sell" | "Sell" | "SELL" => Some(Side::Sell),
4937        other => {
4938            trace!(%other, "Alpaca: unknown order side");
4939            None
4940        }
4941    }
4942}
4943
4944fn parse_order_kind(
4945    order_type: &str,
4946    stop_price: Option<&str>,
4947    trail_percent: Option<&str>,
4948    trail_price: Option<&str>,
4949) -> Option<OrderKind> {
4950    match order_type {
4951        "market" | "Market" => Some(OrderKind::Market),
4952        "limit" | "Limit" => Some(OrderKind::Limit),
4953        "stop" | "Stop" => {
4954            let trigger_price = stop_price.and_then(|s| Decimal::from_str(s).ok())?;
4955            Some(OrderKind::Stop { trigger_price })
4956        }
4957        "stop_limit" | "Stop_limit" => {
4958            let trigger_price = stop_price.and_then(|s| Decimal::from_str(s).ok())?;
4959            Some(OrderKind::StopLimit { trigger_price })
4960        }
4961        "trailing_stop" | "Trailing_stop" => {
4962            // Alpaca returns either trail_percent or trail_price, not both.
4963            if let Some(pct) = trail_percent.and_then(|s| Decimal::from_str(s).ok()) {
4964                Some(OrderKind::TrailingStop {
4965                    offset: pct,
4966                    offset_type: TrailingOffsetType::Percentage,
4967                    activation_price: None,
4968                })
4969            } else if let Some(price) = trail_price.and_then(|s| Decimal::from_str(s).ok()) {
4970                Some(OrderKind::TrailingStop {
4971                    offset: price,
4972                    offset_type: TrailingOffsetType::Absolute,
4973                    activation_price: None,
4974                })
4975            } else {
4976                trace!("Alpaca: trailing_stop missing trail_percent and trail_price");
4977                None
4978            }
4979        }
4980        other => {
4981            trace!(%other, "Alpaca: unsupported order type, skipping");
4982            None
4983        }
4984    }
4985}
4986
4987fn parse_time_in_force(s: &str) -> TimeInForce {
4988    match s {
4989        "gtc" | "GTC" => TimeInForce::GoodUntilCancelled { post_only: false },
4990        "day" | "DAY" => TimeInForce::GoodUntilEndOfDay,
4991        "fok" | "FOK" => TimeInForce::FillOrKill,
4992        "ioc" | "IOC" => TimeInForce::ImmediateOrCancel,
4993        other => {
4994            warn!(%other, "Alpaca: unknown time_in_force, defaulting to GoodUntilEndOfDay");
4995            TimeInForce::GoodUntilEndOfDay
4996        }
4997    }
4998}
4999
5000/// When the exchange last reported this order's state.
5001///
5002/// [`Open::time_exchange`] orders an order's states, and the engine discards a snapshot older
5003/// than the state it already tracks. `created_at` does not order them: it is the same value for
5004/// every snapshot of one order, so a snapshot stamped with it is indistinguishable from the
5005/// acknowledgement and is discarded the moment anything has advanced the tracked order past it.
5006/// `updated_at` moves with each state change, which is what that comparison needs.
5007///
5008/// Falls back to `created_at` when the venue omits `updated_at`, and to now when neither parses.
5009fn order_state_time(order: &AlpacaOrderResponse) -> DateTime<Utc> {
5010    order
5011        .updated_at
5012        .as_deref()
5013        .and_then(parse_timestamp)
5014        .or_else(|| parse_timestamp(&order.created_at))
5015        .unwrap_or_else(Utc::now)
5016}
5017
5018/// The quantity Alpaca order `order_id` has filled, from its `filled_qty`: `None`, with a
5019/// warning, when it is missing or does not parse, so that an unknown fill is not read as zero.
5020fn alpaca_filled_qty(order_id: &str, filled_qty: Option<&str>) -> Option<Decimal> {
5021    let Some(raw) = filled_qty else {
5022        warn!(%order_id, "Alpaca did not report how much the order filled");
5023        return None;
5024    };
5025    match Decimal::from_str(raw) {
5026        Ok(filled_qty) => Some(filled_qty),
5027        Err(_) => {
5028            warn!(%order_id, filled_qty = raw, "Alpaca reported an unparseable filled_qty, treating it as unknown");
5029            None
5030        }
5031    }
5032}
5033
5034/// A non-trade activity as a [`CashFlow`], or `None` if Alpaca canceled it or it lacks an amount
5035/// or a time. See [`AlpacaClient::fetch_cash_flows`].
5036fn convert_cash_flow(
5037    activity: &AlpacaNonTradeActivity,
5038) -> Option<CashFlow<AssetNameExchange, InstrumentNameExchange>> {
5039    let id = activity.id.as_str();
5040    if activity.status.as_deref() == Some("canceled") {
5041        info!(id, activity_type = %activity.activity_type, "Alpaca activity canceled — left out");
5042        return None;
5043    }
5044    let Some(amount) = activity
5045        .net_amount
5046        .as_deref()
5047        .and_then(|amount| Decimal::from_str(amount).ok())
5048    else {
5049        warn!(id, net_amount = ?activity.net_amount, "Alpaca activity missing/unparseable net_amount — left out");
5050        return None;
5051    };
5052    let Some(time_exchange) = activity.time() else {
5053        warn!(
5054            id,
5055            created_at = ?activity.created_at,
5056            date = ?activity.date,
5057            "Alpaca activity missing/unparseable time — left out"
5058        );
5059        return None;
5060    };
5061    let sub_type = activity
5062        .activity_sub_type
5063        .as_deref()
5064        .filter(|sub_type| !sub_type.is_empty());
5065    let kind = match (activity.activity_type.as_str(), sub_type) {
5066        ("INT", Some("MGN")) => CashFlowKind::MarginInterest {
5067            rate: None,
5068            principal: None,
5069            timing: None,
5070        },
5071        // Credit interest is received; margin interest is paid.
5072        ("INT", None) if amount.is_sign_negative() => CashFlowKind::MarginInterest {
5073            rate: None,
5074            principal: None,
5075            timing: None,
5076        },
5077        ("PTR", _) => CashFlowKind::Rebate,
5078        (activity_type, Some(sub_type)) => {
5079            CashFlowKind::Other(format!("{activity_type}.{sub_type}"))
5080        }
5081        (activity_type, None) => CashFlowKind::Other(activity_type.to_owned()),
5082    };
5083    Some(CashFlow::new(
5084        kind,
5085        AssetNameExchange::new(
5086            activity
5087                .currency
5088                .as_deref()
5089                .filter(|currency| !currency.is_empty())
5090                .unwrap_or("USD"),
5091        ),
5092        amount,
5093        activity
5094            .symbol
5095            .as_deref()
5096            .filter(|symbol| !symbol.is_empty())
5097            .map(InstrumentNameExchange::new),
5098        time_exchange,
5099        Some(CashFlowId::new(id)),
5100    ))
5101}
5102
5103/// One option activity, read for [`convert_option_events`].
5104struct OptionLeg<'a> {
5105    id: &'a str,
5106    symbol: &'a str,
5107    /// Signed: negative is a sell.
5108    quantity: Decimal,
5109    /// The strike, on the share leg only.
5110    price: Option<Decimal>,
5111    date: Option<NaiveDate>,
5112    time: DateTime<Utc>,
5113    /// Whether `time` is Alpaca's `created_at`, not the start of `date`.
5114    created: bool,
5115    /// `None` for the share leg (`OPTRD`), whose origin is its pair's.
5116    origin: Option<TradeOrigin>,
5117    /// On an exercise or assignment, its contract as [`occ_contract`] reads it.
5118    contract: Option<(&'a str, bool, Decimal)>,
5119}
5120
5121impl OptionLeg<'_> {
5122    /// Whether the leg is in `start..=end`: by its creation time, or, when Alpaca gave none, by
5123    /// its `date`, so that a read up to now still returns a leg dated today.
5124    fn is_within(&self, start: DateTime<Utc>, end: DateTime<Utc>) -> bool {
5125        if self.created {
5126            (start..=end).contains(&self.time)
5127        } else {
5128            (start.date_naive()..=end.date_naive()).contains(&self.time.date_naive())
5129        }
5130    }
5131}
5132
5133/// An option activity as an [`OptionLeg`], or `None`, logged, if it is canceled, of another type,
5134/// or lacks what its trade needs. See [`AlpacaClient::fetch_option_events`].
5135fn option_leg(activity: &AlpacaNonTradeActivity) -> Option<OptionLeg<'_>> {
5136    let id = activity.id.as_str();
5137    let activity_type = activity.activity_type.as_str();
5138    if activity.status.as_deref() == Some("canceled") {
5139        info!(
5140            id,
5141            activity_type, "Alpaca option activity canceled — left out"
5142        );
5143        return None;
5144    }
5145    let origin = match activity_type {
5146        "OPEXP" => Some(TradeOrigin::Expiry),
5147        "OPEXC" => Some(TradeOrigin::Exercise),
5148        "OPASN" => Some(TradeOrigin::Assignment),
5149        "OPTRD" => None,
5150        _ => {
5151            warn!(
5152                id,
5153                activity_type, "Alpaca activity is not an option event — left out"
5154            );
5155            return None;
5156        }
5157    };
5158    let date = activity
5159        .date
5160        .as_deref()
5161        .and_then(|date| NaiveDate::parse_from_str(date, "%Y-%m-%d").ok());
5162    let created_at = activity.created_at.as_deref().and_then(parse_timestamp);
5163    let time = created_at.or_else(|| date.map(|date| date.and_time(NaiveTime::MIN).and_utc()));
5164    let symbol = activity
5165        .symbol
5166        .as_deref()
5167        .filter(|symbol| !symbol.is_empty());
5168    let quantity = activity
5169        .qty
5170        .as_deref()
5171        .and_then(|qty| Decimal::from_str(qty).ok())
5172        .filter(|qty| !qty.is_zero());
5173    let price = activity
5174        .price
5175        .as_deref()
5176        .and_then(|price| Decimal::from_str(price).ok());
5177    let (Some(symbol), Some(quantity), Some(time)) = (symbol, quantity, time) else {
5178        warn!(
5179            id,
5180            activity_type,
5181            symbol = ?activity.symbol,
5182            qty = ?activity.qty,
5183            created_at = ?activity.created_at,
5184            date = ?activity.date,
5185            "Alpaca option activity missing/unparseable symbol, quantity or time — left out"
5186        );
5187        return None;
5188    };
5189    if origin.is_none() && price.is_none() {
5190        warn!(id, price = ?activity.price, "Alpaca OPTRD missing/unparseable price — left out");
5191        return None;
5192    }
5193    let contract = matches!(
5194        origin,
5195        Some(TradeOrigin::Exercise | TradeOrigin::Assignment)
5196    )
5197    .then(|| occ_contract(symbol))
5198    .flatten();
5199    Some(OptionLeg {
5200        id,
5201        symbol,
5202        quantity,
5203        price,
5204        date,
5205        time,
5206        created: created_at.is_some(),
5207        origin,
5208        contract,
5209    })
5210}
5211
5212/// The root symbol, whether it is a call, and the strike of a contract, from its OCC symbol: the
5213/// root, then the expiry as `YYMMDD`, `C` or `P`, and the strike in thousandths over eight digits.
5214fn occ_contract(symbol: &str) -> Option<(&str, bool, Decimal)> {
5215    let split = symbol.len().checked_sub(15)?;
5216    let (root, tail) = (
5217        symbol.get(..split)?.trim_end(),
5218        symbol.get(split..)?.as_bytes(),
5219    );
5220    let is_call = match tail[6] {
5221        b'C' => true,
5222        b'P' => false,
5223        _ => return None,
5224    };
5225    if root.is_empty() || !tail[..6].iter().chain(&tail[7..]).all(u8::is_ascii_digit) {
5226        return None;
5227    }
5228    let strike = std::str::from_utf8(&tail[7..]).ok()?.parse::<i64>().ok()?;
5229    Some((root, is_call, Decimal::new(strike, 3)))
5230}
5231
5232/// Whether `contract`, an exercise or assignment, is the one `shares`, an `OPTRD`, is the share
5233/// leg of. See [`AlpacaClient::fetch_option_events`].
5234fn is_share_leg_of(shares: &OptionLeg<'_>, contract: &OptionLeg<'_>) -> bool {
5235    let Some((root, is_call, strike)) = contract.contract else {
5236        return false;
5237    };
5238    // Exercising a call, or being assigned on a put, buys the shares.
5239    let buys = match contract.origin {
5240        Some(TradeOrigin::Exercise) => is_call,
5241        Some(TradeOrigin::Assignment) => !is_call,
5242        _ => return false,
5243    };
5244    shares.date.is_some()
5245        && shares.date == contract.date
5246        && shares.symbol == root
5247        && shares.price == Some(strike)
5248        && shares.quantity.is_sign_positive() == buys
5249}
5250
5251/// The option activities in `start..=end` as [`Trade`]s, each share leg given the origin of the
5252/// exercise or assignment it pairs with, which may lie outside the span. See
5253/// [`AlpacaClient::fetch_option_events`].
5254fn convert_option_events(
5255    activities: &[AlpacaNonTradeActivity],
5256    start: DateTime<Utc>,
5257    end: DateTime<Utc>,
5258) -> Vec<Trade<AssetNameExchange, InstrumentNameExchange>> {
5259    let legs: Vec<_> = activities.iter().filter_map(option_leg).collect();
5260    let mut paired = vec![false; legs.len()];
5261    legs.iter()
5262        .filter_map(|leg| {
5263            // Every share leg is paired, in or out of the span, so that one outside it does not
5264            // take the partner of one inside.
5265            let origin = leg.origin.unwrap_or_else(|| {
5266                let pairs = |index: &usize| !paired[*index] && is_share_leg_of(leg, &legs[*index]);
5267                // The standard contract delivers 100 shares. A match of that size goes first, so
5268                // that two exercises at one strike each pair with their own shares; an adjusted
5269                // contract, delivering another amount, still pairs with the first match.
5270                let delivers_its_shares = |index: &usize| {
5271                    legs[*index].quantity.abs() * Decimal::ONE_HUNDRED == leg.quantity.abs()
5272                };
5273                let partner = (0..legs.len())
5274                    .filter(pairs)
5275                    .find(delivers_its_shares)
5276                    .or_else(|| (0..legs.len()).find(pairs));
5277                match partner {
5278                    Some(index) => {
5279                        paired[index] = true;
5280                        legs[index].origin.unwrap_or(TradeOrigin::External)
5281                    }
5282                    None => {
5283                        warn!(
5284                            id = leg.id,
5285                            symbol = leg.symbol,
5286                            date = ?leg.date,
5287                            "Alpaca OPTRD pairs with no exercise or assignment read — reported as \
5288                             External"
5289                        );
5290                        TradeOrigin::External
5291                    }
5292                }
5293            });
5294            if !leg.is_within(start, end) {
5295                return None;
5296            }
5297            let side = if leg.quantity.is_sign_negative() {
5298                Side::Sell
5299            } else {
5300                Side::Buy
5301            };
5302            let trade = Trade::new(
5303                TradeId::new(leg.id),
5304                OrderId::new(leg.id),
5305                InstrumentNameExchange::new(leg.symbol),
5306                StrategyId::unknown(),
5307                leg.time,
5308                side,
5309                leg.price.unwrap_or(Decimal::ZERO),
5310                leg.quantity.abs(),
5311                None,
5312                AssetFees::new(
5313                    AssetNameExchange::from(USD),
5314                    Decimal::ZERO,
5315                    Some(Decimal::ZERO),
5316                ),
5317            )
5318            .with_origin(origin);
5319            Some(trade)
5320        })
5321        .collect()
5322}
5323
5324/// `trade` with the client order id its WebSocket order update reported, if any.
5325fn with_reported_cid<AssetKey, InstrumentKey>(
5326    trade: Trade<AssetKey, InstrumentKey>,
5327    client_order_id: Option<&SmolStr>,
5328) -> Trade<AssetKey, InstrumentKey> {
5329    match client_order_id {
5330        // A `SmolStr` clone shares a long id rather than copying it.
5331        Some(cid) if !cid.is_empty() => trade.with_client_order_id(ClientOrderId(cid.clone())),
5332        _ => trade,
5333    }
5334}
5335
5336fn parse_timestamp(s: &str) -> Option<DateTime<Utc>> {
5337    DateTime::parse_from_rfc3339(s)
5338        .ok()
5339        .map(|dt| dt.with_timezone(&Utc))
5340}
5341
5342fn map_side(side: Side) -> &'static str {
5343    match side {
5344        Side::Buy => "buy",
5345        Side::Sell => "sell",
5346    }
5347}
5348
5349fn map_order_kind(kind: OrderKind) -> Option<&'static str> {
5350    match kind {
5351        OrderKind::Market => Some("market"),
5352        OrderKind::Limit => Some("limit"),
5353        OrderKind::Stop { .. } => Some("stop"),
5354        OrderKind::StopLimit { .. } => Some("stop_limit"),
5355        OrderKind::TrailingStop {
5356            activation_price: None,
5357            ..
5358        } => Some("trailing_stop"),
5359        // Alpaca has no take-profit, trailing stop-limit or fixed-limit trailing order, and no
5360        // activation price on a trailing stop. An unresolved kind is never placed.
5361        OrderKind::TakeProfit { .. }
5362        | OrderKind::TakeProfitLimit { .. }
5363        | OrderKind::TrailingStop {
5364            activation_price: Some(_),
5365            ..
5366        }
5367        | OrderKind::TrailingStopLimit { .. }
5368        | OrderKind::TrailingStopFixedLimit { .. }
5369        | OrderKind::ConditionalUnresolved { .. } => None,
5370    }
5371}
5372
5373/// Map rustrade's `TimeInForce` to Alpaca's string representation.
5374///
5375/// # Errors
5376///
5377/// Returns `Err` if `post_only: true` is requested. Alpaca does not support
5378/// post-only orders — silently placing a taker-eligible GTC order would be
5379/// the opposite of caller intent, risking unexpected taker fees. Callers
5380/// requiring maker-only execution must use a different venue or strategy.
5381fn map_time_in_force(tif: TimeInForce) -> Result<&'static str, &'static str> {
5382    match tif {
5383        TimeInForce::GoodUntilCancelled { post_only } => {
5384            if post_only {
5385                return Err("Alpaca does not support post_only orders");
5386            }
5387            Ok("gtc")
5388        }
5389        TimeInForce::GoodUntilEndOfDay => Ok("day"),
5390        TimeInForce::FillOrKill => Ok("fok"),
5391        TimeInForce::ImmediateOrCancel => Ok("ioc"),
5392        TimeInForce::AtOpen => Ok("opg"),
5393        TimeInForce::AtClose => Ok("cls"),
5394        // Alpaca's `gtd` requires an `expired_at` timestamp on the order request,
5395        // which this client does not currently surface. Reject to avoid silently
5396        // dropping the expiry semantics.
5397        TimeInForce::GoodTillDate { .. } => {
5398            Err("Alpaca GoodTillDate is not yet wired through this client")
5399        }
5400    }
5401}
5402
5403/// Returns `true` if the symbol is an equity or options symbol (i.e., NOT crypto).
5404///
5405/// Crypto symbols on Alpaca always contain a forward slash (e.g., `"BTC/USD"`).
5406/// Equities and OCC option symbols never contain a slash. We use this to decide
5407/// whether to include `position_intent` in the order request: the field is valid
5408/// for equities and required for options, but causes a 422 on crypto orders.
5409///
5410/// NOTE: relies on Alpaca's documented symbol format (as of 2025 API). If Alpaca
5411/// introduces a new asset class whose symbols contain `/`, `position_intent` would
5412/// be silently omitted for those orders, causing 422 errors.
5413fn is_options_or_equity_symbol(symbol: &str) -> bool {
5414    !symbol.contains('/')
5415}
5416
5417/// Derives Alpaca's `position_intent` from the generic [`ReduceOnly`] and `side`.
5418///
5419/// | reduce_only     | side | intent       | use case                          |
5420/// |-----------------|------|--------------|-----------------------------------|
5421/// | `No`            | Buy  | BuyToOpen    | open long / add to long position  |
5422/// | `No`            | Sell | SellToOpen   | open short / write option         |
5423/// | any other value | Buy  | BuyToClose   | close short position              |
5424/// | any other value | Sell | SellToClose  | close long position               |
5425fn map_position_intent(side: Side, reduce_only: ReduceOnly) -> AlpacaPositionIntent {
5426    match (reduce_only.is_reduce_only(), side) {
5427        (false, Side::Buy) => AlpacaPositionIntent::BuyToOpen,
5428        (false, Side::Sell) => AlpacaPositionIntent::SellToOpen,
5429        (true, Side::Buy) => AlpacaPositionIntent::BuyToClose,
5430        (true, Side::Sell) => AlpacaPositionIntent::SellToClose,
5431    }
5432}
5433
5434/// Classifies an HTTP error status + body into a typed [`ApiError`].
5435///
5436/// Used by both `rest_with_retry` (for general REST calls) and `rest_delete_with_retry`
5437/// (for cancel operations) to ensure consistent error classification across all REST paths.
5438///
5439/// `code` is the body's Alpaca error code, where it has one. It is prefixed to the message of a
5440/// refusal, so a caller can see it, except on a 404, whose message says the order was not found.
5441///
5442/// The mapping knows nothing of the request, so a query (account, positions, activities) refused
5443/// on a business rule gets an order's variant too, as any other 4xx on a query already does.
5444fn parse_api_error(
5445    status: reqwest::StatusCode,
5446    code: Option<i64>,
5447    message: &str,
5448) -> crate::error::UnindexedApiError {
5449    // Fast path: 429 doesn't need message parsing. The retry loops read a 429's headers before
5450    // this is reached; this mapping has none to read.
5451    if status == reqwest::StatusCode::TOO_MANY_REQUESTS {
5452        return ApiError::rate_limited(None);
5453    }
5454
5455    // Compute lowercase once for all match guards that inspect the message body.
5456    let lower = message.to_ascii_lowercase();
5457    match status.as_u16() {
5458        // Match "already" before "insufficient": if Alpaca ever sends a 422 body
5459        // containing both substrings, this arm wins and maps to OrderAlreadyCancelled,
5460        // which is more specific than BalanceInsufficient.
5461        422 if lower.contains("already") => ApiError::OrderAlreadyCancelled,
5462        422 => parse_business_refusal(code, message, &lower),
5463        401 => ApiError::Unauthenticated(format!("unauthorized: {message}")),
5464        // A 403 means one of two things, told apart by the code. Without one, it is Alpaca's
5465        // `{"message": "forbidden."}` to a wrong key, secret or host. With one, the account is
5466        // fine and Alpaca refused the request on a business rule: `40310000` is generic
5467        // ("insufficient buying power", "insufficient qty available for order", "account is
5468        // not allowed to short", "account is restricted to liquidation only", ...), and
5469        // `40310100` is pattern-day-trader protection.
5470        403 => match code {
5471            None => ApiError::Unauthenticated(format!("forbidden: {message}")),
5472            Some(_) => parse_business_refusal(code, message, &lower),
5473        },
5474        404 => ApiError::OrderRejected(format!("order not found: {message}")),
5475        _ => ApiError::OrderRejected(with_alpaca_code(code, message)),
5476    }
5477}
5478
5479/// Classifies a refusal on a business rule (a 422, or a 403 with a code) by its text, since
5480/// Alpaca's codes are generic. `lower` is `message` in lowercase. The first match below wins, so
5481/// a refused short is reported as such even if its text also says "insufficient".
5482///
5483/// - "account is not allowed to short" → [`ApiError::BorrowRejected`] with
5484///   [`BorrowRejectReason::BorrowDisabled`].
5485/// - "cannot be sold short" → [`BorrowRejectReason::NotShortable`], except Alpaca's "fractional
5486///   orders cannot be sold short": the asset may be shortable in whole shares, so that is the
5487///   order's shape, an [`ApiError::OrderRejected`].
5488/// - "insufficient" → [`ApiError::BalanceInsufficient`]. The body says what ran short
5489///   ("insufficient buying power", or "insufficient qty available" on a sell) but names no
5490///   asset, so none is guessed.
5491/// - anything else → [`ApiError::OrderRejected`].
5492fn parse_business_refusal(code: Option<i64>, message: &str, lower: &str) -> UnindexedApiError {
5493    let borrow = if lower.contains("not allowed to short") {
5494        Some(BorrowRejectReason::BorrowDisabled)
5495    } else if lower.contains("cannot be sold short") && !lower.contains("fractional") {
5496        Some(BorrowRejectReason::NotShortable)
5497    } else {
5498        None
5499    };
5500    if let Some(reason) = borrow {
5501        ApiError::BorrowRejected(BorrowReject::new(
5502            reason,
5503            code.map(|code| code.to_string()),
5504            message.to_owned(),
5505        ))
5506    } else if lower.contains("insufficient") {
5507        ApiError::BalanceInsufficient(None, with_alpaca_code(code, message))
5508    } else {
5509        ApiError::OrderRejected(with_alpaca_code(code, message))
5510    }
5511}
5512
5513/// `message` prefixed with Alpaca's code where it has one, so a caller can see it.
5514fn with_alpaca_code(code: Option<i64>, message: &str) -> String {
5515    match code {
5516        Some(code) => format!("{code} {message}"),
5517        None => message.to_owned(),
5518    }
5519}
5520
5521/// Wraps [`parse_api_error`] for order-specific error handling (e.g., cancel_order).
5522fn parse_order_error(
5523    status: reqwest::StatusCode,
5524    code: Option<i64>,
5525    message: &str,
5526) -> UnindexedOrderError {
5527    UnindexedOrderError::Rejected(parse_api_error(status, code, message))
5528}
5529
5530fn connectivity_err(msg: impl Into<String>) -> UnindexedClientError {
5531    UnindexedClientError::Connectivity(ConnectivityError::Socket(msg.into()))
5532}
5533
5534/// Map a failed order `POST` through [`rest_with_retry`] to the order's error.
5535///
5536/// [`UnindexedClientError::Internal`] there means Alpaca answered 2xx, so it accepted the order,
5537/// but the response could not be read. The order may be live, so it is reported as
5538/// [`OrderError::Connectivity`], the status-unknown error, never as a rejection. A caller must
5539/// reconcile it (open orders, fills) before resubmitting, or it may place the order twice.
5540fn order_post_error(error: UnindexedClientError) -> UnindexedOrderError {
5541    match error {
5542        UnindexedClientError::Connectivity(ce) => OrderError::Connectivity(ce),
5543        UnindexedClientError::Api(ae) => OrderError::Rejected(ae),
5544        UnindexedClientError::Internal(msg) => OrderError::Connectivity(ConnectivityError::Socket(
5545            format!("Alpaca order status unknown, its 2xx response could not be read: {msg}"),
5546        )),
5547        // `rest_with_retry` returns none of these today. Matched explicitly so a new
5548        // `ClientError` variant is a compile error here, and reported as status unknown rather
5549        // than panicking an order path if that ever changes.
5550        other @ (UnindexedClientError::TaskFailed(_)
5551        | UnindexedClientError::Truncated { .. }
5552        | UnindexedClientError::TruncatedSnapshot { .. }) => OrderError::Connectivity(
5553            ConnectivityError::Socket(format!("Alpaca order status unknown: {other}")),
5554        ),
5555    }
5556}
5557
5558// ---------------------------------------------------------------------------
5559// OrderStatusClient implementation
5560// ---------------------------------------------------------------------------
5561
5562impl OrderStatusClient for AlpacaClient {
5563    /// Looks each order up with `GET /v2/orders:by_client_order_id`, up to 8 at a time.
5564    ///
5565    /// `filled` is reported as fully filled with `filled_avg_price`, `canceled` and `replaced` as
5566    /// cancelled (the order replacing one has its own client order id, which the caller must
5567    /// learn from [`ExecutionClient::fetch_open_orders`]), `expired` as expired and `rejected` as
5568    /// open failed. `done_for_day` is not ended: Alpaca works the order again the next trading day.
5569    /// A 404, or an order on another symbol than the key's (ignoring case), is unknown.
5570    async fn fetch_ended_orders(
5571        &self,
5572        orders: &[UnindexedOrderKey],
5573    ) -> Result<Vec<UnindexedInactiveOrder>, UnindexedClientError> {
5574        fetch_ended_by_key(orders, |key| {
5575            fetch_order_lookup(
5576                self.http.clone(),
5577                self.rate_limiter.clone(),
5578                self.config.clone(),
5579                key,
5580            )
5581        })
5582        .await
5583    }
5584}
5585
5586// ---------------------------------------------------------------------------
5587// ShortabilityClient implementation
5588// ---------------------------------------------------------------------------
5589
5590impl ShortabilityClient for AlpacaClient {
5591    /// Reads the asset with `GET /v2/assets/{symbol}`, one request.
5592    ///
5593    /// - `shortable` is the asset's `shortable`.
5594    /// - `easy_to_borrow` is its `borrow_status`, which Alpaca reports for US equities only, or
5595    ///   failing that its `easy_to_borrow` flag; neither present leaves it unknown.
5596    /// - `fee_rate` is zero for an easy-to-borrow stock, as Alpaca charges no borrow fee on one.
5597    ///   That is Alpaca's charge, not the market's cost of borrowing. Alpaca publishes no rate for
5598    ///   a hard-to-borrow stock, so its fee is unknown.
5599    /// - `available` is unknown. Alpaca's locate quotes report what it can lend of a hard-to-borrow
5600    ///   stock, but they are not on paper accounts and are not read here.
5601    /// - `delisting_at` is unknown: Alpaca's asset carries no delisting date.
5602    ///
5603    /// Alpaca updates its borrow flags from a daily list, so they may be up to a day old.
5604    ///
5605    /// # Errors
5606    ///
5607    /// [`ApiError::InstrumentInvalid`] for a symbol Alpaca knows no asset under, and for an
5608    /// option, as writing one borrows nothing.
5609    async fn fetch_shortability(
5610        &self,
5611        instrument: &InstrumentNameExchange,
5612    ) -> Result<Shortability, UnindexedClientError> {
5613        let url = asset_url(self.base_url(), instrument)?;
5614        let found: Option<AlpacaAsset> =
5615            rest_lookup_with_retry(&self.rate_limiter, || self.http.get(url.clone())).await?;
5616        let Some(asset) = found else {
5617            return Err(instrument_invalid(
5618                instrument,
5619                "Alpaca knows no asset under this symbol",
5620            ));
5621        };
5622        shortability_from_asset(instrument, &asset)
5623    }
5624}
5625
5626/// `GET /v2/assets/{symbol}`, with the symbol as one path segment, so a crypto pair's `/` is
5627/// escaped rather than read as a path separator.
5628fn asset_url(
5629    base_url: &str,
5630    instrument: &InstrumentNameExchange,
5631) -> Result<reqwest::Url, UnindexedClientError> {
5632    let mut url = reqwest::Url::parse(base_url).map_err(|error| {
5633        UnindexedClientError::Internal(format!("Alpaca base URL {base_url:?}: {error}"))
5634    })?;
5635    url.path_segments_mut()
5636        .map_err(|()| {
5637            UnindexedClientError::Internal(format!("Alpaca base URL {base_url:?} has no path"))
5638        })?
5639        .pop_if_empty()
5640        .extend(["v2", "assets", instrument.name().as_str()]);
5641    Ok(url)
5642}
5643
5644fn instrument_invalid(instrument: &InstrumentNameExchange, reason: &str) -> UnindexedClientError {
5645    UnindexedClientError::Api(ApiError::InstrumentInvalid(
5646        instrument.clone(),
5647        reason.to_owned(),
5648    ))
5649}
5650
5651/// What [`AlpacaClient::fetch_shortability`] reports of `asset`.
5652fn shortability_from_asset(
5653    instrument: &InstrumentNameExchange,
5654    asset: &AlpacaAsset,
5655) -> Result<Shortability, UnindexedClientError> {
5656    if asset.class == AlpacaAssetClass::UsOption {
5657        return Err(instrument_invalid(
5658            instrument,
5659            "shortability does not apply to an option: writing one borrows nothing",
5660        ));
5661    }
5662    let easy_to_borrow = match asset.borrow_status {
5663        Some(AlpacaBorrowStatus::EasyToBorrow) => Some(true),
5664        Some(AlpacaBorrowStatus::HardToBorrow) => Some(false),
5665        Some(AlpacaBorrowStatus::Other) | None => asset.easy_to_borrow,
5666    };
5667    let mut shortability = Shortability::new();
5668    if let Some(shortable) = asset.shortable {
5669        shortability = shortability.with_shortable(shortable);
5670    }
5671    if let Some(easy_to_borrow) = easy_to_borrow {
5672        shortability = shortability.with_easy_to_borrow(easy_to_borrow);
5673        if easy_to_borrow {
5674            shortability = shortability.with_fee_rate(Decimal::ZERO);
5675        }
5676    }
5677    Ok(shortability)
5678}
5679
5680/// The fields of `GET /v2/assets/{symbol}` a [`Shortability`] is built from. Each is optional, so
5681/// one Alpaca leaves out reads as unknown rather than failing the request.
5682#[derive(Debug, Deserialize)]
5683struct AlpacaAsset {
5684    #[serde(default)]
5685    class: AlpacaAssetClass,
5686    #[serde(default)]
5687    shortable: Option<bool>,
5688    /// US equities only.
5689    #[serde(default)]
5690    borrow_status: Option<AlpacaBorrowStatus>,
5691    /// In Alpaca's examples but not its schema, so read only when `borrow_status` is absent.
5692    #[serde(default)]
5693    easy_to_borrow: Option<bool>,
5694}
5695
5696#[derive(Debug, Default, PartialEq, Eq, Deserialize)]
5697#[serde(rename_all = "snake_case")]
5698enum AlpacaAssetClass {
5699    UsOption,
5700    #[default]
5701    #[serde(other)]
5702    Other,
5703}
5704
5705#[derive(Debug, PartialEq, Eq, Deserialize)]
5706#[serde(rename_all = "snake_case")]
5707enum AlpacaBorrowStatus {
5708    EasyToBorrow,
5709    HardToBorrow,
5710    #[serde(other)]
5711    Other,
5712}
5713
5714// ---------------------------------------------------------------------------
5715// BracketOrderClient implementation
5716// ---------------------------------------------------------------------------
5717
5718impl BracketOrderClient for AlpacaClient {
5719    async fn open_bracket_order(
5720        &self,
5721        request: UnifiedBracketOrderRequest<ExchangeId, &InstrumentNameExchange>,
5722    ) -> UnifiedBracketOrderResult {
5723        let alpaca_request = AlpacaBracketOrderRequest {
5724            instrument: request.key.instrument.clone(),
5725            strategy: request.key.strategy.clone(),
5726            cid: request.key.cid.clone(),
5727            side: request.state.side,
5728            quantity: request.state.quantity,
5729            entry_price: request.state.entry_price,
5730            take_profit_price: request.state.take_profit_price,
5731            stop_loss_price: request.state.stop_loss_price,
5732            stop_loss_limit_price: request.state.stop_loss_limit_price,
5733            time_in_force: request.state.time_in_force,
5734        };
5735
5736        let result = self.open_bracket_order(alpaca_request).await;
5737
5738        UnifiedBracketOrderResult::parent_only(result.parent)
5739    }
5740}
5741
5742// ---------------------------------------------------------------------------
5743// Tests
5744// ---------------------------------------------------------------------------
5745
5746#[cfg(test)]
5747#[allow(clippy::unwrap_used, clippy::expect_used)] // Test code: panics on bad input are acceptable
5748mod tests {
5749    use super::*;
5750    use rust_decimal_macros::dec;
5751
5752    #[test]
5753    fn test_alpaca_config_new_uses_paper_trading_by_default() {
5754        let cfg = AlpacaConfig::new("my_key".into(), "my_secret".into());
5755        assert!(cfg.paper);
5756    }
5757
5758    #[test]
5759    fn test_alpaca_config_debug_redacts_credentials() {
5760        let cfg = AlpacaConfig::new("my_key".into(), "my_secret".into());
5761        let debug = format!("{cfg:?}");
5762        assert!(!debug.contains("my_key"), "api_key should be redacted");
5763        assert!(
5764            !debug.contains("my_secret"),
5765            "secret_key should be redacted"
5766        );
5767        assert!(debug.contains("paper: true"));
5768    }
5769
5770    #[test]
5771    fn test_alpaca_config_deserialize_omitted_paper_defaults_to_paper() {
5772        let cfg: AlpacaConfig = serde_json::from_str(
5773            r#"{
5774        "api_key": "my_key",
5775        "secret_key": "my_secret"
5776    }"#,
5777        )
5778        .unwrap();
5779
5780        assert!(cfg.paper);
5781        assert_eq!(cfg.rest_base_url(), "https://paper-api.alpaca.markets");
5782    }
5783
5784    #[test]
5785    fn test_alpaca_config_deserialize_paper_true() {
5786        let cfg: AlpacaConfig = serde_json::from_str(
5787            r#"{
5788        "api_key": "my_key",
5789        "secret_key": "my_secret",
5790        "paper": true
5791    }"#,
5792        )
5793        .unwrap();
5794
5795        assert!(cfg.paper);
5796        assert_eq!(cfg.rest_base_url(), "https://paper-api.alpaca.markets");
5797    }
5798
5799    #[test]
5800    fn test_alpaca_config_deserialize_paper_false() {
5801        let cfg: AlpacaConfig = serde_json::from_str(
5802            r#"{
5803        "api_key": "my_key",
5804        "secret_key": "my_secret",
5805        "paper": false
5806    }"#,
5807        )
5808        .unwrap();
5809
5810        assert!(!cfg.paper);
5811        assert_eq!(cfg.rest_base_url(), "https://api.alpaca.markets");
5812    }
5813
5814    #[test]
5815    fn test_alpaca_config_deserialize_rejects_test_url_overrides() {
5816        for key in ["base_url_override", "ws_url_override"] {
5817            let json = format!(
5818                r#"{{"api_key": "my_key", "secret_key": "my_secret", "{key}": "http://127.0.0.1:1"}}"#
5819            );
5820            let Err(err) = serde_json::from_str::<AlpacaConfig>(&json) else {
5821                panic!("a config with `{key}` must not deserialize");
5822            };
5823            assert!(
5824                err.to_string().contains(&format!("unknown field `{key}`")),
5825                "unexpected error for `{key}`: {err}"
5826            );
5827        }
5828    }
5829
5830    #[test]
5831    fn test_alpaca_config_urls() {
5832        let paper = AlpacaConfig::paper("k".into(), "s".into());
5833        assert!(paper.rest_base_url().contains("paper-api"));
5834        assert!(paper.ws_url().contains("paper-api"));
5835
5836        let live = AlpacaConfig::production("k".into(), "s".into());
5837        assert!(!live.rest_base_url().contains("paper-api"));
5838        assert!(!live.ws_url().contains("paper-api"));
5839    }
5840
5841    #[test]
5842    #[serial_test::serial]
5843    fn test_alpaca_config_from_env_defaults_to_paper_trading() {
5844        temp_env::with_vars(
5845            [
5846                ("ALPACA_API_KEY", Some("my_key")),
5847                ("ALPACA_SECRET_KEY", Some("my_secret")),
5848                ("ALPACA_PAPER", None),
5849            ],
5850            || {
5851                let cfg = AlpacaConfig::from_env().unwrap();
5852                assert!(cfg.paper);
5853                assert_eq!(cfg.rest_base_url(), "https://paper-api.alpaca.markets");
5854            },
5855        );
5856    }
5857
5858    #[test]
5859    #[serial_test::serial]
5860    fn test_alpaca_config_from_env_accepts_explicit_production() {
5861        temp_env::with_vars(
5862            [
5863                ("ALPACA_API_KEY", Some("my_key")),
5864                ("ALPACA_SECRET_KEY", Some("my_secret")),
5865                ("ALPACA_PAPER", Some("false")),
5866            ],
5867            || {
5868                let cfg = AlpacaConfig::from_env().unwrap();
5869                assert!(!cfg.paper);
5870                assert_eq!(cfg.rest_base_url(), "https://api.alpaca.markets");
5871            },
5872        );
5873    }
5874
5875    #[test]
5876    #[serial_test::serial]
5877    fn test_alpaca_config_from_env_rejects_invalid_paper() {
5878        temp_env::with_vars(
5879            [
5880                ("ALPACA_API_KEY", Some("my_key")),
5881                ("ALPACA_SECRET_KEY", Some("my_secret")),
5882                ("ALPACA_PAPER", Some("maybe")),
5883            ],
5884            || {
5885                let err = AlpacaConfig::from_env().unwrap_err();
5886                assert!(matches!(err, AlpacaConfigError::InvalidPaper(value) if value == "maybe"));
5887            },
5888        );
5889    }
5890
5891    #[test]
5892    #[serial_test::serial]
5893    fn test_alpaca_config_from_env_requires_credentials() {
5894        temp_env::with_vars(
5895            [
5896                ("ALPACA_API_KEY", None),
5897                ("ALPACA_SECRET_KEY", Some("my_secret")),
5898                ("ALPACA_PAPER", None),
5899            ],
5900            || {
5901                let err = AlpacaConfig::from_env().unwrap_err();
5902                assert!(matches!(err, AlpacaConfigError::MissingApiKey));
5903            },
5904        );
5905    }
5906
5907    #[test]
5908    fn test_parse_side() {
5909        assert_eq!(parse_side("buy"), Some(Side::Buy));
5910        assert_eq!(parse_side("sell"), Some(Side::Sell));
5911        assert_eq!(parse_side("Buy"), Some(Side::Buy));
5912        assert_eq!(parse_side("BUY"), Some(Side::Buy));
5913        assert_eq!(parse_side("unknown"), None);
5914    }
5915
5916    #[test]
5917    fn test_parse_order_kind() {
5918        // Market and Limit don't need extra params
5919        assert_eq!(
5920            parse_order_kind("market", None, None, None),
5921            Some(OrderKind::Market)
5922        );
5923        assert_eq!(
5924            parse_order_kind("limit", None, None, None),
5925            Some(OrderKind::Limit)
5926        );
5927
5928        // Stop requires stop_price
5929        assert_eq!(
5930            parse_order_kind("stop", Some("150.00"), None, None),
5931            Some(OrderKind::Stop {
5932                trigger_price: Decimal::from_str("150.00").unwrap()
5933            })
5934        );
5935        assert_eq!(parse_order_kind("stop", None, None, None), None);
5936
5937        // StopLimit requires stop_price
5938        assert_eq!(
5939            parse_order_kind("stop_limit", Some("145.00"), None, None),
5940            Some(OrderKind::StopLimit {
5941                trigger_price: Decimal::from_str("145.00").unwrap()
5942            })
5943        );
5944
5945        // TrailingStop with percentage
5946        assert_eq!(
5947            parse_order_kind("trailing_stop", None, Some("5.0"), None),
5948            Some(OrderKind::TrailingStop {
5949                offset: Decimal::from_str("5.0").unwrap(),
5950                offset_type: TrailingOffsetType::Percentage,
5951                activation_price: None,
5952            })
5953        );
5954
5955        // TrailingStop with absolute price
5956        assert_eq!(
5957            parse_order_kind("trailing_stop", None, None, Some("2.50")),
5958            Some(OrderKind::TrailingStop {
5959                offset: Decimal::from_str("2.50").unwrap(),
5960                offset_type: TrailingOffsetType::Absolute,
5961                activation_price: None,
5962            })
5963        );
5964
5965        // TrailingStop without either offset returns None
5966        assert_eq!(parse_order_kind("trailing_stop", None, None, None), None);
5967
5968        // Unknown type returns None
5969        assert_eq!(parse_order_kind("unknown", None, None, None), None);
5970    }
5971
5972    #[test]
5973    fn test_map_order_kind() {
5974        assert_eq!(map_order_kind(OrderKind::Market), Some("market"));
5975        assert_eq!(map_order_kind(OrderKind::Limit), Some("limit"));
5976        assert_eq!(
5977            map_order_kind(OrderKind::Stop {
5978                trigger_price: Decimal::from_str("150.00").unwrap()
5979            }),
5980            Some("stop")
5981        );
5982        assert_eq!(
5983            map_order_kind(OrderKind::StopLimit {
5984                trigger_price: Decimal::from_str("145.00").unwrap()
5985            }),
5986            Some("stop_limit")
5987        );
5988        assert_eq!(
5989            map_order_kind(OrderKind::TrailingStop {
5990                offset: Decimal::from_str("5.0").unwrap(),
5991                offset_type: TrailingOffsetType::Percentage,
5992                activation_price: None,
5993            }),
5994            Some("trailing_stop")
5995        );
5996        assert_eq!(
5997            map_order_kind(OrderKind::TrailingStop {
5998                offset: Decimal::from_str("2.50").unwrap(),
5999                offset_type: TrailingOffsetType::Absolute,
6000                activation_price: None,
6001            }),
6002            Some("trailing_stop")
6003        );
6004        // Alpaca has no activation price, and no fixed-limit trailing stop
6005        assert_eq!(
6006            map_order_kind(OrderKind::TrailingStop {
6007                offset: Decimal::from_str("5.0").unwrap(),
6008                offset_type: TrailingOffsetType::Percentage,
6009                activation_price: Some(Decimal::from(100)),
6010            }),
6011            None
6012        );
6013        assert_eq!(
6014            map_order_kind(OrderKind::TrailingStopFixedLimit {
6015                offset: Decimal::from_str("5.0").unwrap(),
6016                offset_type: TrailingOffsetType::Percentage,
6017                activation_price: None,
6018            }),
6019            None
6020        );
6021        // TrailingStopLimit is not supported by Alpaca
6022        assert_eq!(
6023            map_order_kind(OrderKind::TrailingStopLimit {
6024                offset: Decimal::from_str("5.0").unwrap(),
6025                offset_type: TrailingOffsetType::Percentage,
6026                stop_price: Decimal::from_str("95.0").unwrap(),
6027                limit_offset: Decimal::from_str("1.0").unwrap(),
6028            }),
6029            None
6030        );
6031        // An unresolved kind is only read back from a venue, never placed.
6032        assert_eq!(
6033            map_order_kind(OrderKind::ConditionalUnresolved {
6034                family: crate::order::ConditionalFamily::Stop,
6035                trigger_price: None,
6036            }),
6037            None
6038        );
6039        // TakeProfit/TakeProfitLimit are not supported by Alpaca
6040        assert_eq!(
6041            map_order_kind(OrderKind::TakeProfit {
6042                trigger_price: Decimal::from_str("160.00").unwrap()
6043            }),
6044            None
6045        );
6046        assert_eq!(
6047            map_order_kind(OrderKind::TakeProfitLimit {
6048                trigger_price: Decimal::from_str("160.00").unwrap()
6049            }),
6050            None
6051        );
6052    }
6053
6054    #[test]
6055    fn test_map_time_in_force_roundtrip() {
6056        assert_eq!(
6057            map_time_in_force(TimeInForce::GoodUntilCancelled { post_only: false }),
6058            Ok("gtc")
6059        );
6060        assert_eq!(map_time_in_force(TimeInForce::GoodUntilEndOfDay), Ok("day"));
6061        assert_eq!(map_time_in_force(TimeInForce::FillOrKill), Ok("fok"));
6062        assert_eq!(map_time_in_force(TimeInForce::ImmediateOrCancel), Ok("ioc"));
6063    }
6064
6065    #[test]
6066    fn test_map_time_in_force_rejects_post_only() {
6067        let result = map_time_in_force(TimeInForce::GoodUntilCancelled { post_only: true });
6068        assert!(result.is_err(), "post_only must be rejected");
6069        assert!(result.unwrap_err().contains("post_only"));
6070    }
6071
6072    // =========================================================================
6073    // Bracket Order Serialization Tests
6074    // =========================================================================
6075
6076    #[test]
6077    fn test_bracket_order_serializes_with_stop_loss_stop_order() {
6078        // Bracket order with stop-loss as a simple stop order (no limit_price)
6079        let body = AlpacaOrderRequest {
6080            symbol: "AAPL",
6081            qty: "10".to_string(),
6082            side: "buy",
6083            order_type: "limit",
6084            time_in_force: "gtc",
6085            limit_price: Some("150.00".to_string()),
6086            stop_price: None,
6087            trail_percent: None,
6088            trail_price: None,
6089            client_order_id: Some("bracket-001"),
6090            position_intent: Some(AlpacaPositionIntent::BuyToOpen),
6091            order_class: Some("bracket"),
6092            take_profit: Some(TakeProfitParams {
6093                limit_price: "160.00".to_string(),
6094            }),
6095            stop_loss: Some(StopLossParams {
6096                stop_price: "145.00".to_string(),
6097                limit_price: None,
6098            }),
6099        };
6100
6101        let json = serde_json::to_value(&body).unwrap();
6102
6103        assert_eq!(json["symbol"], "AAPL");
6104        assert_eq!(json["qty"], "10");
6105        assert_eq!(json["side"], "buy");
6106        assert_eq!(json["type"], "limit");
6107        assert_eq!(json["time_in_force"], "gtc");
6108        assert_eq!(json["limit_price"], "150.00");
6109        assert_eq!(json["order_class"], "bracket");
6110
6111        // Take profit should have limit_price
6112        assert_eq!(json["take_profit"]["limit_price"], "160.00");
6113
6114        // Stop loss should have stop_price but NO limit_price
6115        assert_eq!(json["stop_loss"]["stop_price"], "145.00");
6116        assert!(
6117            json["stop_loss"].get("limit_price").is_none(),
6118            "stop_loss.limit_price should be omitted when None"
6119        );
6120    }
6121
6122    #[test]
6123    fn test_bracket_order_serializes_with_stop_loss_stop_limit_order() {
6124        // Bracket order with stop-loss as a stop-limit order (has limit_price)
6125        let body = AlpacaOrderRequest {
6126            symbol: "SPY",
6127            qty: "5".to_string(),
6128            side: "sell",
6129            order_type: "limit",
6130            time_in_force: "day",
6131            limit_price: Some("450.00".to_string()),
6132            stop_price: None,
6133            trail_percent: None,
6134            trail_price: None,
6135            client_order_id: Some("bracket-002"),
6136            position_intent: Some(AlpacaPositionIntent::SellToClose),
6137            order_class: Some("bracket"),
6138            take_profit: Some(TakeProfitParams {
6139                limit_price: "440.00".to_string(),
6140            }),
6141            stop_loss: Some(StopLossParams {
6142                stop_price: "455.00".to_string(),
6143                limit_price: Some("456.00".to_string()),
6144            }),
6145        };
6146
6147        let json = serde_json::to_value(&body).unwrap();
6148
6149        assert_eq!(json["symbol"], "SPY");
6150        assert_eq!(json["side"], "sell");
6151        assert_eq!(json["time_in_force"], "day");
6152        assert_eq!(json["order_class"], "bracket");
6153
6154        // Take profit
6155        assert_eq!(json["take_profit"]["limit_price"], "440.00");
6156
6157        // Stop loss with limit_price (stop-limit order)
6158        assert_eq!(json["stop_loss"]["stop_price"], "455.00");
6159        assert_eq!(
6160            json["stop_loss"]["limit_price"], "456.00",
6161            "stop_loss.limit_price should be present for stop-limit orders"
6162        );
6163    }
6164
6165    // =========================================================================
6166    // Bracket Order Validation Tests
6167    // =========================================================================
6168
6169    #[tokio::test]
6170    async fn test_open_bracket_order_rejects_invalid_tif() {
6171        use rust_decimal_macros::dec;
6172        use rustrade_instrument::instrument::name::InstrumentNameExchange;
6173
6174        // Create a minimal client with dummy credentials (no network call will be made)
6175        let config = AlpacaConfig::new("dummy_key".into(), "dummy_secret".into());
6176        let client = AlpacaClient::new(config);
6177
6178        let request = AlpacaBracketOrderRequest::new(
6179            InstrumentNameExchange::new("SPY"),
6180            crate::order::id::StrategyId::new("test"),
6181            crate::order::id::ClientOrderId::new("test-tif"),
6182            Side::Buy,
6183            dec!(1),
6184            dec!(100.00),
6185            dec!(120.00),
6186            dec!(90.00),
6187            TimeInForce::ImmediateOrCancel, // Invalid for brackets
6188        );
6189
6190        let result = client.open_bracket_order(request).await;
6191
6192        assert!(
6193            result.parent.state.is_failed(),
6194            "Bracket order with IOC TIF should be rejected locally"
6195        );
6196    }
6197
6198    #[tokio::test]
6199    async fn test_open_bracket_order_rejects_invalid_price_ordering() {
6200        use rust_decimal_macros::dec;
6201        use rustrade_instrument::instrument::name::InstrumentNameExchange;
6202
6203        let config = AlpacaConfig::new("dummy_key".into(), "dummy_secret".into());
6204        let client = AlpacaClient::new(config);
6205
6206        // Buy bracket with SL > entry (invalid)
6207        let request = AlpacaBracketOrderRequest::new(
6208            InstrumentNameExchange::new("SPY"),
6209            crate::order::id::StrategyId::new("test"),
6210            crate::order::id::ClientOrderId::new("test-price"),
6211            Side::Buy,
6212            dec!(1),
6213            dec!(100.00),
6214            dec!(120.00),
6215            dec!(105.00), // Invalid: SL > entry for buy
6216            TimeInForce::GoodUntilCancelled { post_only: false },
6217        );
6218
6219        let result = client.open_bracket_order(request).await;
6220
6221        assert!(
6222            result.parent.state.is_failed(),
6223            "Bracket order with invalid price ordering should be rejected locally"
6224        );
6225    }
6226
6227    #[tokio::test]
6228    async fn test_open_bracket_order_rejects_invalid_sl_limit_price() {
6229        use rust_decimal_macros::dec;
6230        use rustrade_instrument::instrument::name::InstrumentNameExchange;
6231
6232        let config = AlpacaConfig::new("dummy_key".into(), "dummy_secret".into());
6233        let client = AlpacaClient::new(config);
6234
6235        // Buy bracket with SL limit > SL trigger (invalid for sell stop-limit)
6236        let request = AlpacaBracketOrderRequest::new(
6237            InstrumentNameExchange::new("SPY"),
6238            crate::order::id::StrategyId::new("test"),
6239            crate::order::id::ClientOrderId::new("test-sl-limit"),
6240            Side::Buy,
6241            dec!(1),
6242            dec!(100.00),
6243            dec!(120.00),
6244            dec!(90.00),
6245            TimeInForce::GoodUntilCancelled { post_only: false },
6246        )
6247        .with_stop_loss_limit_price(dec!(95.00)); // Invalid: limit > trigger for sell SL
6248
6249        let result = client.open_bracket_order(request).await;
6250
6251        assert!(
6252            result.parent.state.is_failed(),
6253            "Bracket order with invalid SL limit price should be rejected locally"
6254        );
6255    }
6256
6257    #[test]
6258    fn test_non_bracket_order_omits_bracket_fields() {
6259        // Regular limit order should not have bracket fields
6260        let body = AlpacaOrderRequest {
6261            symbol: "AAPL",
6262            qty: "1".to_string(),
6263            side: "buy",
6264            order_type: "limit",
6265            time_in_force: "gtc",
6266            limit_price: Some("150.00".to_string()),
6267            stop_price: None,
6268            trail_percent: None,
6269            trail_price: None,
6270            client_order_id: Some("regular-001"),
6271            position_intent: Some(AlpacaPositionIntent::BuyToOpen),
6272            order_class: None,
6273            take_profit: None,
6274            stop_loss: None,
6275        };
6276
6277        let json = serde_json::to_value(&body).unwrap();
6278
6279        assert_eq!(json["symbol"], "AAPL");
6280        assert!(
6281            json.get("order_class").is_none(),
6282            "order_class should be omitted for non-bracket orders"
6283        );
6284        assert!(
6285            json.get("take_profit").is_none(),
6286            "take_profit should be omitted for non-bracket orders"
6287        );
6288        assert!(
6289            json.get("stop_loss").is_none(),
6290            "stop_loss should be omitted for non-bracket orders"
6291        );
6292    }
6293
6294    #[test]
6295    fn test_parse_timestamp_valid() {
6296        let ts = parse_timestamp("2025-04-18T14:30:00Z");
6297        assert!(ts.is_some());
6298        let ts2 = parse_timestamp("2025-04-18T14:30:00.123456Z");
6299        assert!(ts2.is_some());
6300        assert_eq!(parse_timestamp("not-a-timestamp"), None);
6301    }
6302
6303    #[test]
6304    fn test_check_auth_response_authorized() {
6305        let msg =
6306            r#"{"stream":"authorization","data":{"status":"authorized","action":"authenticate"}}"#;
6307        assert!(matches!(check_auth_response(msg), Some(Ok(()))));
6308    }
6309
6310    #[test]
6311    fn test_check_auth_response_unauthorized() {
6312        let msg = r#"{"stream":"authorization","data":{"status":"unauthorized"}}"#;
6313        assert!(matches!(
6314            check_auth_response(msg),
6315            Some(Err(HandshakeError::Auth(_)))
6316        ));
6317    }
6318
6319    #[test]
6320    fn test_check_auth_response_non_auth_message() {
6321        let msg = r#"{"stream":"trade_updates","data":{}}"#;
6322        assert!(check_auth_response(msg).is_none());
6323    }
6324
6325    #[test]
6326    fn test_check_listen_ack() {
6327        let ack = r#"{"stream":"listening","data":{"streams":["trade_updates"]}}"#;
6328        assert!(check_listen_ack(ack));
6329
6330        let other = r#"{"stream":"authorization","data":{}}"#;
6331        assert!(!check_listen_ack(other));
6332    }
6333
6334    #[test]
6335    fn test_dedup_cache() {
6336        let cache = new_dedup_cache();
6337        let key = || SmolStr::new("order-1:1");
6338        assert!(
6339            !is_duplicate_fill(&cache, Some("exec-1"), key()),
6340            "first time should not be duplicate"
6341        );
6342        assert!(
6343            is_duplicate_fill(&cache, Some("exec-1"), key()),
6344            "second time should be duplicate"
6345        );
6346    }
6347
6348    /// A fill is told apart by its execution id, so after a bust lowers an order's cumulative, a
6349    /// new fill that takes it back to a cumulative an earlier fill reached is not dropped.
6350    #[test]
6351    fn dedup_tells_fills_apart_by_execution_id_not_cumulative() {
6352        let cache = new_dedup_cache();
6353        assert!(!is_duplicate_fill(
6354            &cache,
6355            Some("exec-a"),
6356            SmolStr::new("ord-1:5")
6357        ));
6358        // A bust took the order back to 3, and a new fill takes it to 5 again.
6359        assert!(
6360            !is_duplicate_fill(&cache, Some("exec-b"), SmolStr::new("ord-1:5")),
6361            "a different execution at a cumulative seen before is a new fill"
6362        );
6363    }
6364
6365    /// A fill one path delivers without an execution id still matches the same fill the other
6366    /// path delivers with one, whichever comes first.
6367    #[test]
6368    fn dedup_matches_a_fill_with_an_execution_id_to_the_same_fill_without_one() {
6369        let cache = new_dedup_cache();
6370
6371        assert!(!is_duplicate_fill(&cache, None, SmolStr::new("ord-1:2")));
6372        assert!(
6373            is_duplicate_fill(&cache, Some("exec-a"), SmolStr::new("ord-1:2")),
6374            "without an id first, then with one"
6375        );
6376
6377        assert!(!is_duplicate_fill(
6378            &cache,
6379            Some("exec-b"),
6380            SmolStr::new("ord-1:4")
6381        ));
6382        assert!(
6383            is_duplicate_fill(&cache, None, SmolStr::new("ord-1:4")),
6384            "with an id first, then without one"
6385        );
6386    }
6387
6388    /// `account_stream`'s rustdoc states the heartbeat window as a number. Change it only together
6389    /// with those docs. The reconnect budget's own numbers are pinned in `client::reconnect_budget`.
6390    #[test]
6391    fn the_heartbeat_window_is_as_documented() {
6392        assert_eq!(HEARTBEAT_TIMEOUT_SECS, 35);
6393    }
6394
6395    #[test]
6396    fn test_convert_account_to_balances_empty_assets() {
6397        // equity and buying_power are larger than cash on a margin account holding positions;
6398        // neither may leak into the balance.
6399        let account: AlpacaAccount = serde_json::from_value(serde_json::json!({
6400            "cash": "9000.50",
6401            "equity": "12000.00",
6402            "buying_power": "36000.00",
6403            "regt_buying_power": "18000.00",
6404            "options_buying_power": "8000.00",
6405            "non_marginable_buying_power": "8500.25",
6406        }))
6407        .unwrap();
6408        let balances = convert_account_to_balances(&account, &[], Utc::now());
6409        assert_eq!(balances.len(), 1);
6410        assert_eq!(balances[0].asset, AssetNameExchange::new("USD"));
6411        assert_eq!(balances[0].balance.total, dec!(9000.50), "total is cash");
6412        assert_eq!(
6413            balances[0].balance.free,
6414            dec!(8500.25),
6415            "free is non-marginable buying power when below cash"
6416        );
6417        assert_eq!(balances[0].balance.margin, None);
6418    }
6419
6420    /// Long marginable stock: non-marginable buying power is equity less initial margin, which
6421    /// exceeds cash, so `free` is capped at cash.
6422    #[test]
6423    fn test_convert_account_to_balances_free_capped_at_cash_with_long_stock() {
6424        let account: AlpacaAccount = serde_json::from_value(serde_json::json!({
6425            "cash": "50000",
6426            "equity": "100000",
6427            "initial_margin": "25000",
6428            "non_marginable_buying_power": "75000",
6429        }))
6430        .unwrap();
6431        let balances = convert_account_to_balances(&account, &[], Utc::now());
6432        assert_eq!(balances[0].balance.total, dec!(50000));
6433        assert_eq!(balances[0].balance.free, dec!(50000));
6434    }
6435
6436    /// A short sale credits its proceeds to cash but lowers non-marginable buying power, so
6437    /// `free` is the buying power, below `total`.
6438    #[test]
6439    fn test_convert_account_to_balances_open_short_keeps_free_below_cash() {
6440        let account: AlpacaAccount = serde_json::from_value(serde_json::json!({
6441            "cash": "10200",
6442            "equity": "10000",
6443            "initial_margin": "100",
6444            "non_marginable_buying_power": "9900",
6445        }))
6446        .unwrap();
6447        let balances = convert_account_to_balances(&account, &[], Utc::now());
6448        assert_eq!(balances[0].balance.total, dec!(10200));
6449        assert_eq!(balances[0].balance.free, dec!(9900));
6450    }
6451
6452    #[test]
6453    fn test_convert_account_to_balances_negative_cash_on_margin() {
6454        let account: AlpacaAccount = serde_json::from_value(serde_json::json!({
6455            "cash": "-2500.75",
6456            "equity": "7500.00",
6457            "buying_power": "5000.00",
6458            "non_marginable_buying_power": "1250.00",
6459        }))
6460        .unwrap();
6461        let balances = convert_account_to_balances(&account, &[], Utc::now());
6462        assert_eq!(balances.len(), 1);
6463        assert_eq!(balances[0].balance.total, dec!(-2500.75));
6464        assert_eq!(
6465            balances[0].balance.free,
6466            dec!(-2500.75),
6467            "no free cash while borrowing; free <= total still holds"
6468        );
6469    }
6470
6471    #[test]
6472    fn test_alpaca_account_malformed_cash_fails_to_decode() {
6473        let result = serde_json::from_value::<AlpacaAccount>(serde_json::json!({
6474            "cash": "not-a-number",
6475            "non_marginable_buying_power": "0",
6476        }));
6477        assert!(result.is_err(), "a malformed cash must not read as zero");
6478    }
6479
6480    #[test]
6481    fn test_convert_account_to_balances_usd_filter() {
6482        let account = AlpacaAccount {
6483            cash: dec!(12000.00),
6484            non_marginable_buying_power: dec!(10000.00),
6485        };
6486        let usd = vec![AssetNameExchange::new("USD")];
6487        let balances = convert_account_to_balances(&account, &usd, Utc::now());
6488        assert_eq!(balances.len(), 1);
6489        assert_eq!(balances[0].asset, AssetNameExchange::new("USD"));
6490
6491        let non_usd = vec![AssetNameExchange::new("BTC")];
6492        let balances = convert_account_to_balances(&account, &non_usd, Utc::now());
6493        assert!(balances.is_empty());
6494    }
6495
6496    /// A caller's filter is matched ignoring case, but the balance keeps Alpaca's spelling, the
6497    /// one every USD fee is reported under too.
6498    #[test]
6499    fn a_usd_balance_is_named_usd_however_the_filter_spells_it() {
6500        let account = AlpacaAccount {
6501            cash: dec!(12000.00),
6502            non_marginable_buying_power: dec!(10000.00),
6503        };
6504        let balances =
6505            convert_account_to_balances(&account, &[AssetNameExchange::new("usd")], Utc::now());
6506        assert_eq!(balances.len(), 1);
6507        assert_eq!(balances[0].asset, AssetNameExchange::new("USD"));
6508    }
6509
6510    #[test]
6511    fn test_is_options_or_equity_symbol() {
6512        // Crypto symbols contain '/'
6513        assert!(!is_options_or_equity_symbol("BTC/USD"));
6514        assert!(!is_options_or_equity_symbol("ETH/USD"));
6515        assert!(!is_options_or_equity_symbol("SOL/USD"));
6516
6517        // Equity symbols — no slash
6518        assert!(is_options_or_equity_symbol("AAPL"));
6519        assert!(is_options_or_equity_symbol("SPY"));
6520        assert!(is_options_or_equity_symbol("MSFT"));
6521
6522        // OCC option symbols — no slash
6523        assert!(is_options_or_equity_symbol("SPY250418C00450000"));
6524        assert!(is_options_or_equity_symbol("AAPL250418P00145000"));
6525    }
6526
6527    #[test]
6528    fn test_parse_order_error_already_cancelled() {
6529        // Locks in match arm ordering: a 422 with "already" but NOT "insufficient"
6530        // must map to OrderAlreadyCancelled, not BalanceInsufficient.
6531        assert!(matches!(
6532            parse_order_error(
6533                reqwest::StatusCode::UNPROCESSABLE_ENTITY,
6534                None,
6535                "order is already cancelled"
6536            ),
6537            UnindexedOrderError::Rejected(ApiError::OrderAlreadyCancelled)
6538        ));
6539    }
6540
6541    #[test]
6542    fn test_parse_order_error_already_wins_over_insufficient_on_422() {
6543        // If Alpaca sends a body containing both "already" and "insufficient",
6544        // the "already" arm must win (it appears first in the match).
6545        assert!(matches!(
6546            parse_order_error(
6547                reqwest::StatusCode::UNPROCESSABLE_ENTITY,
6548                None,
6549                "order already cancelled due to insufficient margin"
6550            ),
6551            UnindexedOrderError::Rejected(ApiError::OrderAlreadyCancelled)
6552        ));
6553    }
6554
6555    fn make_order_ws<'a>(
6556        id: &str,
6557        symbol: &str,
6558        side: &str,
6559        filled_qty: &'a str,
6560    ) -> AlpacaOrderWs<'a> {
6561        AlpacaOrderWs {
6562            id: SmolStr::new(id),
6563            client_order_id: None,
6564            symbol: SmolStr::new(symbol),
6565            qty: Some("2"),
6566            filled_qty: Some(filled_qty),
6567            side: SmolStr::new(side),
6568            order_type: SmolStr::new("limit"),
6569            time_in_force: SmolStr::new("day"),
6570            limit_price: Some("100.00"),
6571            stop_price: None,
6572            trail_percent: None,
6573            trail_price: None,
6574            status: SmolStr::new("partially_filled"),
6575            updated_at: None,
6576        }
6577    }
6578
6579    /// The one event produced by a frame that maps to a single `AccountEvent`.
6580    ///
6581    /// Asserts the second slot is empty, so a test written for a single-event frame fails
6582    /// loudly if that frame ever starts producing two.
6583    fn sole_event(events: [Option<UnindexedAccountEvent>; 2]) -> Option<UnindexedAccountEvent> {
6584        let [first, second] = events;
6585        assert!(
6586            second.is_none(),
6587            "expected a single event, got a second: {second:?}"
6588        );
6589        first
6590    }
6591
6592    /// The `(Trade, OrderSnapshot)` pair a fill frame must produce, in that order.
6593    fn fill_events(
6594        events: [Option<UnindexedAccountEvent>; 2],
6595    ) -> (
6596        Trade<AssetNameExchange, InstrumentNameExchange>,
6597        crate::order::Order<
6598            ExchangeId,
6599            InstrumentNameExchange,
6600            OrderState<AssetNameExchange, InstrumentNameExchange>,
6601        >,
6602    ) {
6603        let [first, second] = events;
6604        let first = first.expect("a fill frame must produce an execution");
6605        let second = second.expect("a fill frame must produce an order snapshot");
6606        let AccountEventKind::Trade(trade) = first.kind else {
6607            panic!("first event must be the Trade, got {:?}", first.kind);
6608        };
6609        let AccountEventKind::OrderSnapshot(rustrade_integration::collection::snapshot::Snapshot(
6610            order,
6611        )) = second.kind
6612        else {
6613            panic!(
6614                "second event must be the OrderSnapshot, got {:?}",
6615                second.kind
6616            );
6617        };
6618        (trade, order)
6619    }
6620
6621    /// A fill carries the client order id its order was placed with, so a consumer can attribute
6622    /// it before any response names the order.
6623    #[test]
6624    fn test_convert_trade_update_fill_carries_the_client_order_id() {
6625        let mut order = make_order_ws("ord-1", "SPY", "buy", "1");
6626        order.client_order_id = Some(SmolStr::new("outside-1"));
6627        let update = AlpacaTradeUpdate {
6628            event: SmolStr::new("fill"),
6629            execution_id: Some("exec-1"),
6630            previous_execution_id: None,
6631            order,
6632            price: Some("150.00"),
6633            qty: Some("1"),
6634            timestamp: Some("2025-04-18T14:30:00Z"),
6635        };
6636        let (trade, _order) = fill_events(convert_trade_update(update));
6637        assert_eq!(trade.client_order_id, Some(ClientOrderId::new("outside-1")));
6638    }
6639
6640    /// A fill is at its frame's `timestamp`, else at its order's `updated_at`, both the venue's;
6641    /// with neither it is at this host's time, and says so. An order snapshot takes the same time.
6642    #[test]
6643    fn a_fills_time_is_its_timestamp_then_its_orders_update_time_then_this_hosts() {
6644        let fill = |timestamp, updated_at| {
6645            let mut order = make_order_ws("ord-1", "SPY", "buy", "1");
6646            order.updated_at = updated_at;
6647            let update = AlpacaTradeUpdate {
6648                event: SmolStr::new("fill"),
6649                execution_id: Some("exec-1"),
6650                previous_execution_id: None,
6651                order,
6652                price: Some("150.00"),
6653                qty: Some("1"),
6654                timestamp,
6655            };
6656            let (trade, order) = fill_events(convert_trade_update(update));
6657            let OrderState::Active(crate::order::state::ActiveOrderState::Open(open)) = order.state
6658            else {
6659                panic!("expected an Open snapshot, got {:?}", order.state);
6660            };
6661            assert_eq!(open.time_exchange, trade.time_exchange);
6662            (trade.time_exchange, trade.time_source)
6663        };
6664        let at = |time| parse_timestamp(time).unwrap();
6665
6666        assert_eq!(
6667            fill(Some("2025-04-18T14:30:00Z"), Some("2025-04-18T14:30:01Z")),
6668            (at("2025-04-18T14:30:00Z"), TimeSource::Venue)
6669        );
6670        for timestamp in [None, Some("not a time")] {
6671            assert_eq!(
6672                fill(timestamp, Some("2025-04-18T14:30:01Z")),
6673                (at("2025-04-18T14:30:01Z"), TimeSource::Venue),
6674                "{timestamp:?}"
6675            );
6676        }
6677        let before = Utc::now();
6678        let (time, source) = fill(None, None);
6679        assert_eq!(source, TimeSource::Host);
6680        assert!((before..=Utc::now()).contains(&time));
6681    }
6682
6683    #[test]
6684    fn test_convert_trade_update_fill_produces_trade_with_execution_id() {
6685        let update = AlpacaTradeUpdate {
6686            event: SmolStr::new("fill"),
6687            execution_id: Some("exec-1"),
6688            previous_execution_id: None,
6689            order: make_order_ws("ord-1", "SPY", "buy", "1"),
6690            price: Some("150.00"),
6691            qty: Some("1"),
6692            timestamp: Some("2025-04-18T14:30:00Z"),
6693        };
6694        let (trade, order) = fill_events(convert_trade_update(update));
6695        // The venue's execution id, which the fill's FILL activity carries too.
6696        assert_eq!(trade.id.0.as_str(), "exec-1");
6697        assert_eq!(
6698            trade.client_order_id, None,
6699            "the update names no client order id"
6700        );
6701        assert_eq!(trade.price, Decimal::from_str("150.00").unwrap());
6702        assert_eq!(trade.quantity, Decimal::from_str("1").unwrap());
6703
6704        // The execution print alone leaves the order reading as untouched. The snapshot carries
6705        // the cumulative filled quantity, which is the only way it reaches engine state.
6706        let OrderState::Active(crate::order::state::ActiveOrderState::Open(open)) = order.state
6707        else {
6708            panic!("expected an Open snapshot, got {:?}", order.state);
6709        };
6710        assert_eq!(open.filled_quantity, Decimal::from_str("1").unwrap());
6711        assert_eq!(open.id.assigned().map(|id| id.0.as_str()), Some("ord-1"));
6712    }
6713
6714    #[test]
6715    fn test_convert_trade_update_partial_fill() {
6716        let update = AlpacaTradeUpdate {
6717            event: SmolStr::new("partial_fill"),
6718            execution_id: None,
6719            previous_execution_id: None,
6720            order: make_order_ws("ord-2", "AAPL", "sell", "0.5"),
6721            price: Some("200.00"),
6722            qty: Some("0.5"),
6723            timestamp: None,
6724        };
6725        let (trade, order) = fill_events(convert_trade_update(update));
6726        assert_eq!(trade.quantity, Decimal::from_str("0.5").unwrap());
6727
6728        // make_order_ws reports qty=2, so 0.5 cumulative leaves 1.5 working: the order stays
6729        // Open, and a consumer reading quantity_remaining now sees 1.5 rather than 2.
6730        let OrderState::Active(crate::order::state::ActiveOrderState::Open(open)) = order.state
6731        else {
6732            panic!("expected an Open snapshot, got {:?}", order.state);
6733        };
6734        assert_eq!(open.filled_quantity, Decimal::from_str("0.5").unwrap());
6735        assert_eq!(
6736            open.quantity_remaining(order.quantity),
6737            Decimal::from_str("1.5").unwrap()
6738        );
6739    }
6740
6741    /// A `fill` that completes the order reports nothing left to fill, which is how the engine
6742    /// learns the order is done. Without the snapshot the order would sit in engine state as a
6743    /// resting order with `filled_quantity` 0 until REST reconciliation refreshed it.
6744    #[test]
6745    fn a_full_fill_snapshot_reports_nothing_left_to_fill() {
6746        let update = AlpacaTradeUpdate {
6747            event: SmolStr::new("fill"),
6748            execution_id: None,
6749            previous_execution_id: None,
6750            // make_order_ws reports qty=2; a cumulative filled of 2 completes it.
6751            order: AlpacaOrderWs {
6752                status: SmolStr::new("filled"),
6753                updated_at: None,
6754                ..make_order_ws("ord-full", "SPY", "buy", "2")
6755            },
6756            price: Some("150.00"),
6757            qty: Some("1"),
6758            timestamp: Some("2025-04-18T14:30:00Z"),
6759        };
6760        let (_trade, order) = fill_events(convert_trade_update(update));
6761        let OrderState::Active(crate::order::state::ActiveOrderState::Open(open)) = order.state
6762        else {
6763            panic!("expected an Open snapshot, got {:?}", order.state);
6764        };
6765        assert_eq!(open.filled_quantity, Decimal::from_str("2").unwrap());
6766        assert!(
6767            open.quantity_remaining(order.quantity).is_zero(),
6768            "a completed order must report nothing left to fill so the engine retires it"
6769        );
6770    }
6771
6772    /// A fill whose order the exchange no longer reports as working must not be written back into
6773    /// engine state as an `Open` order. The execution still counts -- it moved the position --
6774    /// but resurrecting the order would leave a resting order that does not exist at the venue.
6775    #[test]
6776    fn a_fill_for_an_order_no_longer_working_emits_the_execution_without_a_snapshot() {
6777        for status in [
6778            "canceled",
6779            "expired",
6780            "rejected",
6781            "done_for_day",
6782            "pending_cancel",
6783        ] {
6784            let update = AlpacaTradeUpdate {
6785                event: SmolStr::new("partial_fill"),
6786                execution_id: None,
6787                previous_execution_id: None,
6788                order: AlpacaOrderWs {
6789                    status: SmolStr::new(status),
6790                    updated_at: None,
6791                    ..make_order_ws("ord-late", "SPY", "buy", "1")
6792                },
6793                price: Some("150.00"),
6794                qty: Some("1"),
6795                timestamp: None,
6796            };
6797            let [trade, snapshot] = convert_trade_update(update);
6798            assert!(
6799                matches!(trade.map(|e| e.kind), Some(AccountEventKind::Trade(_))),
6800                "the execution must still be reported for status {status}"
6801            );
6802            assert!(
6803                snapshot.is_none(),
6804                "status {status} must not produce an order snapshot"
6805            );
6806        }
6807    }
6808
6809    /// A notional order (placed by dollar value) carries no `qty`, so there is no quantity to
6810    /// report a remaining amount against. The execution is still reported; the order is not.
6811    #[test]
6812    fn a_notional_order_fill_emits_the_execution_without_a_snapshot() {
6813        let update = AlpacaTradeUpdate {
6814            event: SmolStr::new("partial_fill"),
6815            execution_id: None,
6816            previous_execution_id: None,
6817            order: AlpacaOrderWs {
6818                qty: None,
6819                ..make_order_ws("ord-notional-ws", "SPY", "buy", "1")
6820            },
6821            price: Some("150.00"),
6822            qty: Some("1"),
6823            timestamp: None,
6824        };
6825        let [trade, snapshot] = convert_trade_update(update);
6826        assert!(matches!(
6827            trade.map(|e| e.kind),
6828            Some(AccountEventKind::Trade(_))
6829        ));
6830        assert!(
6831            snapshot.is_none(),
6832            "a notional order has no quantity to snapshot against"
6833        );
6834    }
6835
6836    #[test]
6837    fn test_convert_trade_update_new_order_produces_snapshot() {
6838        let update = AlpacaTradeUpdate {
6839            event: SmolStr::new("new"),
6840            execution_id: None,
6841            previous_execution_id: None,
6842            order: AlpacaOrderWs {
6843                id: SmolStr::new("ord-new"),
6844                client_order_id: Some(SmolStr::new("cid-1")),
6845                symbol: SmolStr::new("AAPL"),
6846                qty: Some("10"),
6847                filled_qty: Some("0"),
6848                side: SmolStr::new("buy"),
6849                order_type: SmolStr::new("limit"),
6850                time_in_force: SmolStr::new("day"),
6851                limit_price: Some("150.00"),
6852                stop_price: None,
6853                trail_percent: None,
6854                trail_price: None,
6855                status: SmolStr::new("new"),
6856                updated_at: None,
6857            },
6858            price: None,
6859            qty: None,
6860            timestamp: Some("2025-04-18T14:30:00Z"),
6861        };
6862        let event =
6863            sole_event(convert_trade_update(update)).expect("new event should produce an event");
6864        assert!(matches!(event.kind, AccountEventKind::OrderSnapshot(_)));
6865    }
6866
6867    #[test]
6868    fn test_convert_trade_update_canceled_produces_cancel() {
6869        let update = AlpacaTradeUpdate {
6870            event: SmolStr::new("canceled"),
6871            execution_id: None,
6872            previous_execution_id: None,
6873            order: make_order_ws("ord-3", "AAPL", "sell", "0"),
6874            price: None,
6875            qty: None,
6876            timestamp: Some("2025-04-18T14:30:00Z"),
6877        };
6878        let event =
6879            sole_event(convert_trade_update(update)).expect("canceled should produce an event");
6880        let AccountEventKind::OrderCancelled(response) = event.kind else {
6881            panic!("expected OrderCancelled, got {:?}", event.kind);
6882        };
6883        assert!(response.state.is_ok());
6884    }
6885
6886    /// A `canceled` update carries what filled, and an unknown fill stays unknown, not zero.
6887    #[test]
6888    fn a_canceled_update_carries_its_fill_or_none() {
6889        let cancelled = |filled_qty: Option<&'static str>| {
6890            let mut order = make_order_ws("ord-3", "AAPL", "sell", "0");
6891            order.filled_qty = filled_qty;
6892            let update = AlpacaTradeUpdate {
6893                event: SmolStr::new("canceled"),
6894                execution_id: None,
6895                previous_execution_id: None,
6896                order,
6897                price: None,
6898                qty: None,
6899                timestamp: Some("2025-04-18T14:30:00Z"),
6900            };
6901            let event =
6902                sole_event(convert_trade_update(update)).expect("canceled should produce an event");
6903            let AccountEventKind::OrderCancelled(response) = event.kind else {
6904                panic!("expected OrderCancelled, got {:?}", event.kind);
6905            };
6906            let Ok(cancelled) = response.state else {
6907                panic!("expected Ok, got {:?}", response.state);
6908            };
6909            cancelled.filled_quantity
6910        };
6911
6912        assert_eq!(cancelled(Some("1")), Some(Decimal::ONE));
6913        assert_eq!(cancelled(Some("0")), Some(Decimal::ZERO));
6914        assert_eq!(cancelled(None), None);
6915        assert_eq!(cancelled(Some("not a number")), None);
6916    }
6917
6918    /// An ended order whose `filled_qty` does not parse reports its fill unknown, not zero; a
6919    /// filled one filled its whole quantity regardless.
6920    #[test]
6921    fn an_ended_order_with_an_unparseable_fill_reports_it_unknown() {
6922        let key = OrderKey::new(
6923            ExchangeId::AlpacaBroker,
6924            InstrumentNameExchange::new("AAPL"),
6925            StrategyId::new("strategy"),
6926            ClientOrderId::new("c"),
6927        );
6928        let ended = |status: &str| {
6929            let mut order = make_order_response("o1", "AAPL");
6930            order.status = Some(status.to_string());
6931            order.filled_qty = "not a number".to_string();
6932            convert_ended_order(&order, &key).map(|order| order.state)
6933        };
6934
6935        assert!(matches!(
6936            ended("canceled"),
6937            Some(InactiveOrderState::Cancelled(Cancelled {
6938                filled_quantity: None,
6939                ..
6940            }))
6941        ));
6942        assert!(matches!(
6943            ended("expired"),
6944            Some(InactiveOrderState::Expired(Expired {
6945                filled_quantity: None,
6946                ..
6947            }))
6948        ));
6949        assert!(matches!(
6950            ended("filled"),
6951            Some(InactiveOrderState::FullyFilled(Filled { filled_quantity, .. }))
6952                if filled_quantity == Decimal::ONE
6953        ));
6954    }
6955
6956    #[test]
6957    fn test_convert_trade_update_rejected_produces_error() {
6958        let update = AlpacaTradeUpdate {
6959            event: SmolStr::new("rejected"),
6960            execution_id: None,
6961            previous_execution_id: None,
6962            order: make_order_ws("ord-4", "SPY", "buy", "0"),
6963            price: None,
6964            qty: None,
6965            timestamp: None,
6966        };
6967        let event =
6968            sole_event(convert_trade_update(update)).expect("rejected should produce an event");
6969        let AccountEventKind::OrderCancelled(response) = event.kind else {
6970            panic!("expected OrderCancelled, got {:?}", event.kind);
6971        };
6972        assert!(response.state.is_err());
6973    }
6974
6975    #[test]
6976    fn test_convert_open_order_notional_qty_none_is_skipped() {
6977        // qty=None means this is a notional order (placed by dollar value).
6978        // Recording it with quantity=0 would corrupt reconciliation, so it must be skipped.
6979        let order = AlpacaOrderResponse {
6980            id: "ord-notional".to_string(),
6981            client_order_id: None,
6982            symbol: "SPY".to_string(),
6983            qty: None,
6984            filled_qty: "0".to_string(),
6985            filled_avg_price: None,
6986            status: None,
6987            side: "buy".to_string(),
6988            order_type: "market".to_string(),
6989            time_in_force: "day".to_string(),
6990            limit_price: None,
6991            stop_price: None,
6992            trail_percent: None,
6993            trail_price: None,
6994            created_at: "2025-04-18T14:30:00Z".to_string(),
6995            updated_at: None,
6996        };
6997        assert!(convert_open_order(&order).is_none());
6998    }
6999
7000    /// A FILL activity is at its `transaction_time`, the venue's; without one it is kept, at this
7001    /// host's time, and says so.
7002    #[test]
7003    fn an_activity_without_a_transaction_time_is_at_this_hosts_time() {
7004        let activity = |transaction_time: &str| AlpacaActivity {
7005            id: "act-1".to_string(),
7006            order_id: "ord-1".to_string(),
7007            symbol: "SPY".to_string(),
7008            side: "buy".to_string(),
7009            price: "150.00".to_string(),
7010            qty: "1".to_string(),
7011            transaction_time: transaction_time.to_string(),
7012            cum_qty: Some("1".to_string()),
7013        };
7014
7015        let trade = convert_activity_to_trade(&activity("2025-04-18T14:30:00Z")).unwrap();
7016        assert_eq!(
7017            (trade.time_exchange, trade.time_source),
7018            (
7019                parse_timestamp("2025-04-18T14:30:00Z").unwrap(),
7020                TimeSource::Venue
7021            )
7022        );
7023        let before = Utc::now();
7024        let trade = convert_activity_to_trade(&activity("not a time")).unwrap();
7025        assert_eq!(trade.time_source, TimeSource::Host);
7026        assert!((before..=Utc::now()).contains(&trade.time_exchange));
7027    }
7028
7029    #[test]
7030    fn test_convert_activity_to_trade_bad_price_returns_none() {
7031        // When price is unparseable, convert_activity_to_trade must return None.
7032        // recover_fills advances cumulative_qty BEFORE this check so that the dedup
7033        // key sequence stays aligned with the WS path even when a fill is skipped.
7034        let activity = AlpacaActivity {
7035            id: "act-1".to_string(),
7036            order_id: "ord-1".to_string(),
7037            symbol: "SPY250418C00450000".to_string(),
7038            side: "buy".to_string(),
7039            price: "not-a-number".to_string(),
7040            qty: "1".to_string(),
7041            transaction_time: "2025-04-18T14:30:00Z".to_string(),
7042            cum_qty: Some("1".to_string()),
7043        };
7044        assert!(convert_activity_to_trade(&activity).is_none());
7045    }
7046
7047    #[test]
7048    fn test_convert_positions_to_balances_crypto() {
7049        let positions = vec![
7050            // The positions list's form: the asset followed by USD.
7051            AlpacaPosition {
7052                qty_available: dec!(0.4),
7053                ..alpaca_position("BTCUSD", "crypto", AlpacaPositionSide::Long, dec!(0.5))
7054            },
7055            // The pair form orders use, read too.
7056            alpaca_position("ETH/USD", "crypto", AlpacaPositionSide::Long, dec!(2.0)),
7057            // Equity positions should be filtered out
7058            alpaca_position("AAPL", "us_equity", AlpacaPositionSide::Long, dec!(10)),
7059        ];
7060
7061        // All crypto assets
7062        let balances = convert_positions_to_balances(&positions, &[], Utc::now());
7063        assert_eq!(balances.len(), 2, "only crypto positions returned");
7064        assert_eq!(balances[0].asset.name().as_str(), "BTC");
7065        assert_eq!(balances[1].asset.name().as_str(), "ETH");
7066        // total = qty (0.5 BTC), free = qty_available (0.4 BTC)
7067        assert_eq!(balances[0].balance.total, dec!(0.5));
7068        assert_eq!(balances[0].balance.free, dec!(0.4));
7069
7070        // Filter to BTC only: matches the `BTCUSD` position
7071        let btc_only = vec![AssetNameExchange::new("btc")];
7072        let balances = convert_positions_to_balances(&positions, &btc_only, Utc::now());
7073        assert_eq!(balances.len(), 1);
7074        assert_eq!(balances[0].asset.name().as_str(), "BTC");
7075    }
7076
7077    #[test]
7078    fn test_crypto_position_asset() {
7079        assert_eq!(crypto_position_asset("BTCUSD"), "BTC");
7080        assert_eq!(
7081            crypto_position_asset("btcusd"),
7082            "btc",
7083            "suffix matched ignoring case"
7084        );
7085        assert_eq!(
7086            crypto_position_asset("USDTUSD"),
7087            "USDT",
7088            "only one USD is stripped"
7089        );
7090        assert_eq!(crypto_position_asset("BTC/USD"), "BTC");
7091        assert_eq!(crypto_position_asset("BTC/USDT"), "BTC");
7092        assert_eq!(crypto_position_asset("€USD"), "€");
7093        // Neither form, or an empty asset: used whole rather than dropped.
7094        assert_eq!(crypto_position_asset("USD"), "USD");
7095        assert_eq!(crypto_position_asset("/USD"), "/USD");
7096        assert_eq!(crypto_position_asset("BTCUSDT"), "BTCUSDT");
7097        assert_eq!(
7098            crypto_position_asset("BTCێ"),
7099            "BTCێ",
7100            "no char boundary at the suffix"
7101        );
7102        assert_eq!(crypto_position_asset(""), "");
7103    }
7104
7105    /// A position as `/v2/positions` reports it, with `qty_available` equal to `qty`.
7106    fn alpaca_position(
7107        symbol: &str,
7108        asset_class: &str,
7109        side: AlpacaPositionSide,
7110        qty: Decimal,
7111    ) -> AlpacaPosition {
7112        AlpacaPosition {
7113            symbol: symbol.into(),
7114            asset_class: asset_class.into(),
7115            side,
7116            qty,
7117            qty_available: qty,
7118            avg_entry_price: Some(dec!(100)),
7119            unrealized_pl: Some(dec!(0)),
7120        }
7121    }
7122
7123    #[test]
7124    fn test_convert_positions_sign_comes_from_side_not_qty() {
7125        let now = Utc::now();
7126        let positions = vec![
7127            alpaca_position("AAPL", "us_equity", AlpacaPositionSide::Long, dec!(10)),
7128            alpaca_position("TSLA", "us_equity", AlpacaPositionSide::Short, dec!(-3)),
7129            // Short with an unsigned qty still reads as short.
7130            alpaca_position("MSFT", "us_equity", AlpacaPositionSide::Short, dec!(4)),
7131        ];
7132
7133        let converted = convert_positions(&positions, now).unwrap();
7134        let quantities: Vec<_> = converted.iter().map(|(s, p)| (*s, p.quantity)).collect();
7135        assert_eq!(
7136            quantities,
7137            vec![("AAPL", dec!(10)), ("TSLA", dec!(-3)), ("MSFT", dec!(-4))]
7138        );
7139        assert!(converted.iter().all(|(_, p)| p.time_exchange == now));
7140    }
7141
7142    #[test]
7143    fn test_convert_positions_leaves_out_crypto_and_flat() {
7144        let positions = vec![
7145            alpaca_position("BTC/USD", "crypto", AlpacaPositionSide::Long, dec!(0.5)),
7146            alpaca_position("AAPL", "us_equity", AlpacaPositionSide::Long, dec!(0)),
7147            alpaca_position("SPY", "us_equity", AlpacaPositionSide::Long, dec!(1)),
7148        ];
7149
7150        let converted = convert_positions(&positions, Utc::now()).unwrap();
7151        let symbols: Vec<_> = converted.iter().map(|(s, _)| *s).collect();
7152        assert_eq!(symbols, vec!["SPY"]);
7153    }
7154
7155    #[test]
7156    fn test_alpaca_position_decodes_equity_option_and_fractional() {
7157        let positions: Vec<AlpacaPosition> = serde_json::from_value(serde_json::json!([
7158            {
7159                "asset_id": "00000000-0000-0000-0000-000000000001",
7160                "symbol": "XYZ",
7161                "exchange": "NASDAQ",
7162                "asset_class": "us_equity",
7163                "qty": "-7",
7164                "qty_available": "-7",
7165                "side": "short",
7166                "avg_entry_price": "50.25",
7167                "market_value": "-351.75",
7168                "unrealized_pl": "0",
7169            },
7170            {
7171                "symbol": "XYZ271217C00055000",
7172                "asset_class": "us_option",
7173                "qty": "2",
7174                "qty_available": "2",
7175                "side": "long",
7176                "avg_entry_price": "1.35",
7177                "unrealized_pl": "-20",
7178            },
7179            {
7180                "symbol": "ABC",
7181                "asset_class": "us_equity",
7182                "qty": "0.125",
7183                "qty_available": "0.125",
7184                "side": "long",
7185                "avg_entry_price": "80",
7186                "unrealized_pl": null,
7187            },
7188        ]))
7189        .unwrap();
7190
7191        let converted = convert_positions(&positions, Utc::now()).unwrap();
7192        let [
7193            (short_sym, short),
7194            (option_sym, option),
7195            (fraction_sym, fraction),
7196        ] = converted.as_slice()
7197        else {
7198            panic!("expected three positions, got {converted:?}");
7199        };
7200
7201        assert_eq!(*short_sym, "XYZ");
7202        assert_eq!(short.quantity, dec!(-7));
7203        assert_eq!(short.entry_price, Some(dec!(50.25)));
7204        assert!(short.is_short());
7205
7206        assert_eq!(*option_sym, "XYZ271217C00055000");
7207        assert_eq!(option.quantity, dec!(2));
7208        assert_eq!(option.entry_price, Some(dec!(1.35)), "per-share premium");
7209        assert_eq!(option.unrealized_pnl, Some(dec!(-20)));
7210
7211        assert_eq!(*fraction_sym, "ABC");
7212        assert_eq!(fraction.quantity, dec!(0.125));
7213        assert_eq!(fraction.unrealized_pnl, None);
7214
7215        for (_, p) in &converted {
7216            assert_eq!(p.margin_used, None);
7217            assert_eq!(p.liquidation_price, None);
7218            assert_eq!(p.leverage, None);
7219        }
7220    }
7221
7222    /// An unknown or missing side decodes, so the crypto balances in the same response survive,
7223    /// but a position that needs a direction fails rather than guessing one or reading as flat.
7224    #[test]
7225    fn test_alpaca_position_unknown_side_fails_only_where_a_direction_is_needed() {
7226        let positions: Vec<AlpacaPosition> = serde_json::from_value(serde_json::json!([
7227            {
7228                "symbol": "BTC/USD",
7229                "asset_class": "crypto",
7230                "qty": "0.5",
7231                "qty_available": "0.5",
7232            },
7233            {
7234                "symbol": "XYZ",
7235                "asset_class": "us_equity",
7236                "qty": "1",
7237                "qty_available": "1",
7238                "side": "sideways",
7239            },
7240        ]))
7241        .unwrap();
7242        assert_eq!(positions[0].side, AlpacaPositionSide::Unknown);
7243        assert_eq!(positions[1].side, AlpacaPositionSide::Unknown);
7244
7245        let balances = convert_positions_to_balances(&positions, &[], Utc::now());
7246        assert_eq!(balances.len(), 1);
7247        assert_eq!(balances[0].balance.total, dec!(0.5));
7248
7249        let result = convert_positions(&positions, Utc::now());
7250        assert!(
7251            matches!(&result, Err(UnindexedClientError::Internal(msg)) if msg.contains("XYZ")),
7252            "an unknown side must not guess a direction: {result:?}"
7253        );
7254    }
7255
7256    /// `updated_at` orders an order's states; `created_at` is constant across all of them.
7257    #[test]
7258    fn order_state_time_prefers_updated_at() {
7259        let mut resp = make_order_response("ord-1", "SPY");
7260        resp.created_at = "2025-04-18T14:30:00Z".to_string();
7261        resp.updated_at = Some("2025-04-18T15:45:00Z".to_string());
7262        assert_eq!(
7263            order_state_time(&resp),
7264            parse_timestamp("2025-04-18T15:45:00Z").unwrap(),
7265            "updated_at must win over created_at"
7266        );
7267
7268        // Alpaca's schema makes updated_at nullable, so creation time is the fallback.
7269        resp.updated_at = None;
7270        assert_eq!(
7271            order_state_time(&resp),
7272            parse_timestamp("2025-04-18T14:30:00Z").unwrap(),
7273            "absent updated_at falls back to created_at"
7274        );
7275
7276        // An unparseable updated_at must not silently become `now`, which would order this state
7277        // ahead of every later one.
7278        resp.updated_at = Some("not-a-timestamp".to_string());
7279        assert_eq!(
7280            order_state_time(&resp),
7281            parse_timestamp("2025-04-18T14:30:00Z").unwrap(),
7282            "unparseable updated_at falls back to created_at"
7283        );
7284    }
7285
7286    /// A placement response without a status reads from what filled, as one with a live status
7287    /// does.
7288    #[test]
7289    fn a_placement_response_without_a_status_reads_from_what_filled() {
7290        let instrument = InstrumentNameExchange::new("SPY");
7291        let mut resp = make_order_response("ord-1", "SPY");
7292        resp.filled_qty = "0.4".to_string();
7293        let OrderState::Active(ActiveOrderState::Open(open)) =
7294            placed_order_state(&resp, &instrument, Decimal::ONE)
7295        else {
7296            panic!("expected Open");
7297        };
7298        assert_eq!(open.filled_quantity, Decimal::new(4, 1));
7299
7300        resp.filled_qty = "1".to_string();
7301        resp.filled_avg_price = Some("100.5".to_string());
7302        let state = placed_order_state(&resp, &instrument, Decimal::ONE);
7303        let OrderState::Inactive(InactiveOrderState::FullyFilled(filled)) = state else {
7304            panic!("expected FullyFilled, got {state:?}");
7305        };
7306        assert_eq!(filled.filled_quantity, Decimal::ONE);
7307        assert_eq!(filled.avg_price, Some(Decimal::new(1005, 1)));
7308    }
7309
7310    fn make_order_response(id: &str, symbol: &str) -> AlpacaOrderResponse {
7311        AlpacaOrderResponse {
7312            id: id.to_string(),
7313            client_order_id: None,
7314            symbol: symbol.to_string(),
7315            qty: Some("1".to_string()),
7316            filled_qty: "0".to_string(),
7317            filled_avg_price: None,
7318            status: None,
7319            side: "buy".to_string(),
7320            order_type: "limit".to_string(),
7321            time_in_force: "day".to_string(),
7322            limit_price: Some("100.00".to_string()),
7323            stop_price: None,
7324            trail_percent: None,
7325            trail_price: None,
7326            created_at: "2025-04-18T14:30:00Z".to_string(),
7327            updated_at: None,
7328        }
7329    }
7330
7331    #[test]
7332    fn test_build_instrument_snapshots_empty_instruments_returns_only_with_orders() {
7333        let orders = vec![
7334            make_order_response("o1", "AAPL"),
7335            make_order_response("o2", "SPY"),
7336        ];
7337        let snapshots = build_instrument_snapshots(orders, Vec::new(), &[]);
7338        assert_eq!(snapshots.len(), 2);
7339        let symbols: Vec<&str> = snapshots
7340            .iter()
7341            .map(|s| s.instrument.name().as_str())
7342            .collect();
7343        assert!(symbols.contains(&"AAPL"));
7344        assert!(symbols.contains(&"SPY"));
7345    }
7346
7347    #[test]
7348    fn test_build_instrument_snapshots_requested_instrument_no_orders_gets_empty_snapshot() {
7349        // When instruments list is provided, every requested instrument must appear
7350        // even if it has no open orders — callers depend on this for reconciliation.
7351        let orders = vec![make_order_response("o1", "AAPL")];
7352        let instruments = vec![
7353            InstrumentNameExchange::new("AAPL"),
7354            InstrumentNameExchange::new("SPY"),
7355        ];
7356        let snapshots = build_instrument_snapshots(orders, Vec::new(), &instruments);
7357        assert_eq!(snapshots.len(), 2);
7358        let spy = snapshots
7359            .iter()
7360            .find(|s| s.instrument.name().as_str() == "SPY")
7361            .expect("SPY snapshot must be present even with no orders");
7362        assert!(spy.orders.is_empty());
7363    }
7364
7365    /// A notional order: placed by dollar value, so Alpaca reports no `qty`.
7366    fn make_notional_order_response(id: &str, symbol: &str) -> AlpacaOrderResponse {
7367        AlpacaOrderResponse {
7368            qty: None,
7369            ..make_order_response(id, symbol)
7370        }
7371    }
7372
7373    fn snapshot_for<'a>(
7374        snapshots: &'a [InstrumentAccountSnapshot<
7375            ExchangeId,
7376            AssetNameExchange,
7377            InstrumentNameExchange,
7378        >],
7379        symbol: &str,
7380    ) -> &'a InstrumentAccountSnapshot<ExchangeId, AssetNameExchange, InstrumentNameExchange> {
7381        snapshots
7382            .iter()
7383            .find(|snapshot| snapshot.instrument.name().as_str() == symbol)
7384            .unwrap_or_else(|| panic!("no snapshot for {symbol}"))
7385    }
7386
7387    /// Every open order converted, so each list is every order open on the venue, including a
7388    /// requested instrument with none: that entry is what lets the engine see an order that ended
7389    /// while the stream was down is gone.
7390    #[test]
7391    fn test_build_instrument_snapshots_declares_complete_lists_complete() {
7392        let instruments = [
7393            InstrumentNameExchange::new("AAPL"),
7394            InstrumentNameExchange::new("SPY"),
7395        ];
7396        for requested in [&instruments[..], &[]] {
7397            let orders = vec![make_order_response("o1", "AAPL")];
7398            let snapshots = build_instrument_snapshots(orders, Vec::new(), requested);
7399            assert!(!snapshots.is_empty());
7400            assert!(snapshots.iter().all(|snapshot| snapshot.orders_complete));
7401        }
7402    }
7403
7404    /// A notional order cannot be represented, so its instrument's list is not every open order
7405    /// and must not say so: the engine would retire that order as absent. Other instruments are
7406    /// unaffected.
7407    #[test]
7408    fn test_build_instrument_snapshots_notional_order_leaves_only_its_instrument_incomplete() {
7409        let orders = vec![
7410            make_order_response("o1", "AAPL"),
7411            make_order_response("o2", "SPY"),
7412            make_notional_order_response("o3", "SPY"),
7413        ];
7414        let instruments = [
7415            InstrumentNameExchange::new("AAPL"),
7416            InstrumentNameExchange::new("SPY"),
7417        ];
7418        let snapshots = build_instrument_snapshots(orders, Vec::new(), &instruments);
7419
7420        assert!(snapshot_for(&snapshots, "AAPL").orders_complete);
7421        let spy = snapshot_for(&snapshots, "SPY");
7422        assert!(!spy.orders_complete);
7423        assert_eq!(spy.orders.len(), 1);
7424    }
7425
7426    /// Unfiltered, an instrument whose only open order cannot be represented still gets an entry,
7427    /// one that says its list is not complete.
7428    #[test]
7429    fn test_build_instrument_snapshots_unfiltered_lists_an_instrument_with_only_a_notional_order() {
7430        let orders = vec![make_notional_order_response("o1", "SPY")];
7431        let snapshots = build_instrument_snapshots(orders, Vec::new(), &[]);
7432
7433        let spy = snapshot_for(&snapshots, "SPY");
7434        assert!(!spy.orders_complete);
7435        assert!(spy.orders.is_empty());
7436    }
7437
7438    #[test]
7439    fn test_build_instrument_snapshots_non_requested_instrument_excluded() {
7440        // An instrument with open orders that is NOT in the requested list must
7441        // not appear in the output when the instruments list is non-empty.
7442        let orders = vec![
7443            make_order_response("o1", "AAPL"),
7444            make_order_response("o2", "MSFT"), // not requested
7445        ];
7446        let instruments = vec![InstrumentNameExchange::new("AAPL")];
7447        let snapshots = build_instrument_snapshots(orders, Vec::new(), &instruments);
7448        assert_eq!(snapshots.len(), 1);
7449        assert_eq!(snapshots[0].instrument.name().as_str(), "AAPL");
7450    }
7451
7452    fn position(quantity: Decimal) -> Position {
7453        Position::new(
7454            quantity,
7455            Some(dec!(100)),
7456            None,
7457            None,
7458            None,
7459            None,
7460            None,
7461            Utc::now(),
7462        )
7463    }
7464
7465    #[test]
7466    fn test_build_instrument_snapshots_unfiltered_lists_positions_without_orders() {
7467        let orders = vec![
7468            make_order_response("o1", "AAPL"),
7469            make_order_response("o2", "MSFT"),
7470            make_order_response("o3", "BTC/USD"),
7471        ];
7472        let positions = vec![("AAPL", position(dec!(5))), ("TSLA", position(dec!(-2)))];
7473
7474        let snapshots = build_instrument_snapshots(orders, positions, &[]);
7475        let listed: Vec<_> = snapshots
7476            .iter()
7477            .map(|s| {
7478                (
7479                    s.instrument.name().as_str(),
7480                    s.orders.len(),
7481                    s.position.quantity(),
7482                )
7483            })
7484            .collect();
7485        // An equity with an order but no position is flat; crypto is never a position.
7486        assert_eq!(
7487            listed,
7488            vec![
7489                ("AAPL", 1, Some(dec!(5))),
7490                ("MSFT", 1, Some(Decimal::ZERO)),
7491                ("BTC/USD", 1, None),
7492                ("TSLA", 0, Some(dec!(-2)))
7493            ]
7494        );
7495        assert!(snapshots.iter().all(|s| s.orders_complete));
7496    }
7497
7498    #[test]
7499    fn test_build_instrument_snapshots_filtered_attaches_only_requested_positions() {
7500        let positions = vec![("AAPL", position(dec!(5))), ("TSLA", position(dec!(-2)))];
7501        let instruments = vec![
7502            InstrumentNameExchange::new("tsla"),
7503            InstrumentNameExchange::new("MSFT"),
7504            InstrumentNameExchange::new("BTC/USD"),
7505            InstrumentNameExchange::new("TSLA"),
7506        ];
7507
7508        let snapshots = build_instrument_snapshots(Vec::new(), positions, &instruments);
7509        let listed: Vec<_> = snapshots
7510            .iter()
7511            .map(|s| (s.instrument.name().as_str(), s.position.quantity()))
7512            .collect();
7513        // A requested equity with no position is reported flat, and one requested in another case
7514        // still finds its position; crypto is never a position. A second name for a symbol
7515        // already claimed is unknown, not flat.
7516        assert_eq!(
7517            listed,
7518            vec![
7519                ("tsla", Some(dec!(-2))),
7520                ("MSFT", Some(Decimal::ZERO)),
7521                ("BTC/USD", None),
7522                ("TSLA", None)
7523            ]
7524        );
7525        assert!(!snapshots[3].orders_complete);
7526    }
7527
7528    fn epoch(secs: i64) -> DateTime<Utc> {
7529        DateTime::<Utc>::from_timestamp(1_800_000_000 + secs, 0).unwrap()
7530    }
7531
7532    /// Fill recovery is placed on Alpaca's clock once the stream has seen a time it stamped, so a
7533    /// host clock ahead of Alpaca's beyond the lookback still reads the fills Alpaca stamped
7534    /// during the outage.
7535    #[test]
7536    fn fill_recovery_from_a_host_clock_ahead_starts_at_alpacas_time() {
7537        let base = tokio::time::Instant::now();
7538        let mut clock = VenueClock::new();
7539        clock.observe(epoch(0), base);
7540        // The last message arrived as Alpaca's clock read 0 s; the host reads 60 s ahead.
7541        let last = HostMoment::new(epoch(60), base);
7542
7543        assert_eq!(
7544            fill_recovery_start(&clock, last),
7545            epoch(0) - TimeDelta::milliseconds(SIGNAL_RECOVERY_LOOKBACK_MS)
7546        );
7547    }
7548
7549    /// Before the stream has seen a time Alpaca stamped, the read starts further back by the skew
7550    /// allowed for.
7551    #[test]
7552    fn fill_recovery_with_no_sample_allows_for_the_unsampled_skew() {
7553        let last = HostMoment::new(epoch(60), tokio::time::Instant::now());
7554
7555        assert_eq!(
7556            fill_recovery_start(&VenueClock::new(), last),
7557            epoch(30) - TimeDelta::milliseconds(SIGNAL_RECOVERY_LOOKBACK_MS)
7558        );
7559        assert_eq!(UNSAMPLED_CLOCK_SKEW, TimeDelta::seconds(30));
7560    }
7561
7562    /// Any `trade_updates` event samples the stream's clock with the time Alpaca stamped on it:
7563    /// its `timestamp`, else its order's `updated_at`. An order's event counts as a fill's does.
7564    #[test]
7565    fn a_trade_update_samples_the_streams_clock() {
7566        let (tx, _rx) = mpsc::unbounded_channel();
7567        let known = KnownLiveOrders::shared(ExchangeId::AlpacaBroker);
7568        let frame = |stamps: &str| {
7569            format!(
7570                r#"{{"stream":"trade_updates","data":{{"event":"new",{stamps}"order":{{"id":"id-a","client_order_id":"a","symbol":"SPY","qty":"2","filled_qty":"0","side":"buy","type":"limit","time_in_force":"gtc","limit_price":"100","status":"new"{order_stamp}}}}}}}"#,
7571                order_stamp = if stamps.is_empty() {
7572                    r#","updated_at":"2027-01-15T08:00:05Z""#
7573                } else {
7574                    ""
7575                },
7576            )
7577        };
7578        let stamped = |frame: &str| {
7579            let mut clock = VenueClock::new();
7580            let before = tokio::time::Instant::now();
7581            process_ws_text(frame, &tx, &new_dedup_cache(), &known, &mut clock);
7582            clock.earliest_at(before)
7583        };
7584        let utc = |s: &str| DateTime::parse_from_rfc3339(s).unwrap().with_timezone(&Utc);
7585
7586        let by_timestamp = stamped(&frame(r#""timestamp":"2027-01-15T08:00:00Z","#)).unwrap();
7587        assert!(
7588            by_timestamp <= utc("2027-01-15T08:00:00Z"),
7589            "{by_timestamp}"
7590        );
7591        assert!(by_timestamp > utc("2027-01-15T07:59:59Z"), "{by_timestamp}");
7592        let by_updated_at = stamped(&frame("")).unwrap();
7593        assert!(
7594            by_updated_at <= utc("2027-01-15T08:00:05Z"),
7595            "{by_updated_at}"
7596        );
7597        assert!(
7598            by_updated_at > utc("2027-01-15T08:00:04Z"),
7599            "{by_updated_at}"
7600        );
7601    }
7602
7603    /// Verifies that the dedup key synthesised by `recover_fills` (REST path) matches
7604    /// the key produced by `convert_trade_update` (WS path) for the same partial fills.
7605    ///
7606    /// This is the critical invariant for cross-source dedup after a reconnect:
7607    /// both paths must produce `"{order_id}:{cumulative_filled_qty}"` for the same fill.
7608    #[test]
7609    fn test_recover_fills_dedup_key_matches_ws_path() {
7610        let order_id = "ord-1";
7611
7612        // WS path: Alpaca sends cumulative filled_qty with each event.
7613        // Two partial fills of 1 lot each → filled_qty "1" then "2".
7614        let ws_keys: Vec<SmolStr> = ["1", "2"]
7615            .iter()
7616            .map(|filled_qty| {
7617                early_dedup_key(&AlpacaTradeUpdate {
7618                    event: SmolStr::new("partial_fill"),
7619                    execution_id: None,
7620                    previous_execution_id: None,
7621                    order: make_order_ws(order_id, "SPY", "buy", filled_qty),
7622                    price: Some("150.00"),
7623                    qty: Some("1"),
7624                    timestamp: None,
7625                })
7626            })
7627            .collect();
7628
7629        // REST path: recover_fills accumulates cumulative qty from per-execution activities.
7630        // Two activities with exec qty "1" each → cumulative 1 then 2.
7631        let mut cumulative = Decimal::ZERO;
7632        let rest_keys: Vec<SmolStr> = ["1", "1"]
7633            .iter()
7634            .map(|exec_qty| {
7635                cumulative += Decimal::from_str(exec_qty).unwrap();
7636                fill_dedup_key(order_id, cumulative)
7637            })
7638            .collect();
7639
7640        assert_eq!(
7641            ws_keys, rest_keys,
7642            "REST recovery dedup keys must match WS path keys for cross-source dedup to work"
7643        );
7644        assert_eq!(ws_keys[0].as_str(), "ord-1:1");
7645        assert_eq!(ws_keys[1].as_str(), "ord-1:2");
7646    }
7647
7648    /// Verifies that `early_dedup_key` produces the same key as the full event path.
7649    ///
7650    /// A fill without an `execution_id` falls back to its dedup key for its `TradeId`, the key the
7651    /// early dedup check reads from the raw WS fields before the event is built.
7652    #[test]
7653    fn a_ws_fill_without_an_execution_id_is_identified_by_its_dedup_key() {
7654        let update = AlpacaTradeUpdate {
7655            event: SmolStr::new("fill"),
7656            execution_id: None,
7657            previous_execution_id: None,
7658            order: make_order_ws("ord-abc", "SPY", "buy", "5"),
7659            price: Some("150.00"),
7660            qty: Some("5"),
7661            timestamp: None,
7662        };
7663
7664        // Early path: extract key before full event construction
7665        let early_key = early_dedup_key(&update);
7666
7667        // A fill frame also carries the order snapshot; the execution is always the first slot.
7668        let [event, _snapshot] = convert_trade_update(update);
7669        let Some(UnindexedAccountEvent {
7670            kind: AccountEventKind::Trade(trade),
7671            ..
7672        }) = event
7673        else {
7674            panic!("fill should produce an execution");
7675        };
7676
7677        assert_eq!(
7678            trade.id.0.as_str(),
7679            early_key.as_str(),
7680            "without an execution_id the TradeId is the dedup key"
7681        );
7682        assert_eq!(early_key.as_str(), "ord-abc:5");
7683    }
7684
7685    /// Verifies that `early_dedup_key` correctly normalizes decimal strings.
7686    ///
7687    /// Alpaca may send "1.00" or "1" for the same fill. Both must produce the same
7688    /// dedup key to avoid false negatives in duplicate detection.
7689    #[test]
7690    fn early_dedup_key_normalizes_decimal_strings() {
7691        // Test with trailing zeros: "1.00" should normalize to "1"
7692        let update1 = AlpacaTradeUpdate {
7693            event: SmolStr::new("fill"),
7694            execution_id: None,
7695            previous_execution_id: None,
7696            order: AlpacaOrderWs {
7697                id: SmolStr::new("ord-x"),
7698                client_order_id: Some(SmolStr::new("cid")),
7699                symbol: SmolStr::new("AAPL"),
7700                qty: Some("10"),
7701                filled_qty: Some("1.00"),
7702                side: SmolStr::new("buy"),
7703                order_type: SmolStr::new("market"),
7704                time_in_force: SmolStr::new("day"),
7705                limit_price: None,
7706                stop_price: None,
7707                trail_percent: None,
7708                trail_price: None,
7709                status: SmolStr::new("filled"),
7710                updated_at: None,
7711            },
7712            price: Some("100.00"),
7713            qty: Some("10"),
7714            timestamp: None,
7715        };
7716        assert_eq!(early_dedup_key(&update1).as_str(), "ord-x:1");
7717
7718        // Test already normalized: "1" stays "1"
7719        let update2 = AlpacaTradeUpdate {
7720            event: SmolStr::new("fill"),
7721            execution_id: None,
7722            previous_execution_id: None,
7723            order: AlpacaOrderWs {
7724                id: SmolStr::new("ord-x"),
7725                client_order_id: Some(SmolStr::new("cid")),
7726                symbol: SmolStr::new("AAPL"),
7727                qty: Some("10"),
7728                filled_qty: Some("1"),
7729                side: SmolStr::new("buy"),
7730                order_type: SmolStr::new("market"),
7731                time_in_force: SmolStr::new("day"),
7732                limit_price: None,
7733                stop_price: None,
7734                trail_percent: None,
7735                trail_price: None,
7736                status: SmolStr::new("filled"),
7737                updated_at: None,
7738            },
7739            price: Some("100.00"),
7740            qty: Some("10"),
7741            timestamp: None,
7742        };
7743        assert_eq!(early_dedup_key(&update2).as_str(), "ord-x:1");
7744
7745        // Test single trailing zero: "1.0" normalizes to "1"
7746        let update3 = AlpacaTradeUpdate {
7747            event: SmolStr::new("fill"),
7748            execution_id: None,
7749            previous_execution_id: None,
7750            order: AlpacaOrderWs {
7751                id: SmolStr::new("ord-x"),
7752                client_order_id: Some(SmolStr::new("cid")),
7753                symbol: SmolStr::new("AAPL"),
7754                qty: Some("10"),
7755                filled_qty: Some("1.0"),
7756                side: SmolStr::new("buy"),
7757                order_type: SmolStr::new("market"),
7758                time_in_force: SmolStr::new("day"),
7759                limit_price: None,
7760                stop_price: None,
7761                trail_percent: None,
7762                trail_price: None,
7763                status: SmolStr::new("filled"),
7764                updated_at: None,
7765            },
7766            price: Some("100.00"),
7767            qty: Some("10"),
7768            timestamp: None,
7769        };
7770        assert_eq!(early_dedup_key(&update3).as_str(), "ord-x:1");
7771    }
7772
7773    /// Regression guard for HIGH-2: a `rejected` event without `filled_qty` in the JSON
7774    /// previously caused the entire AlpacaTradeUpdate to fail deserialization, silently
7775    /// dropping the event. After the fix, `filled_qty` is Option and defaults to None
7776    /// (unwrapped to "0" at use-sites), so the event reaches the `rejected` branch.
7777    #[test]
7778    fn process_ws_text_rejected_event_without_filled_qty_is_not_dropped() {
7779        let (tx, mut rx) = mpsc::unbounded_channel();
7780        let dedup = new_dedup_cache();
7781
7782        // Minimal rejected-event JSON — no `filled_qty` field in the order object.
7783        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"}}}"#;
7784
7785        let known = KnownLiveOrders::shared(ExchangeId::AlpacaBroker);
7786        process_ws_text(json, &tx, &dedup, &known, &mut VenueClock::new());
7787
7788        // The event must NOT be silently dropped — an OrderCancelled must be emitted.
7789        let event = rx.try_recv()
7790            .expect("rejected event without filled_qty must produce an AccountEvent, not be silently dropped");
7791        assert!(
7792            matches!(event.kind, AccountEventKind::OrderCancelled(_)),
7793            "rejected event must map to OrderCancelled, got: {:?}",
7794            event.kind
7795        );
7796    }
7797
7798    /// A `trade_updates` fill frame as Alpaca sends it: its `execution_id` is the trade's id. One
7799    /// that is null, empty or absent falls back to the dedup key.
7800    #[test]
7801    fn process_ws_text_identifies_a_fill_by_its_execution_id() {
7802        let frame = |execution_id: &str| {
7803            format!(
7804                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"}}}}"#
7805            )
7806        };
7807        let cases = [
7808            (
7809                r#""execution_id":"524b1902-817e-446c-825b-a9fcfebbc17e","#,
7810                "524b1902-817e-446c-825b-a9fcfebbc17e",
7811            ),
7812            (r#""execution_id":null,"#, "ord-1:2"),
7813            (r#""execution_id":"","#, "ord-1:2"),
7814            ("", "ord-1:2"),
7815        ];
7816        for (field, expected) in cases {
7817            let (tx, mut rx) = mpsc::unbounded_channel();
7818            let known = KnownLiveOrders::shared(ExchangeId::AlpacaBroker);
7819            process_ws_text(
7820                &frame(field),
7821                &tx,
7822                &new_dedup_cache(),
7823                &known,
7824                &mut VenueClock::new(),
7825            );
7826            let Ok(UnindexedAccountEvent {
7827                kind: AccountEventKind::Trade(trade),
7828                ..
7829            }) = rx.try_recv()
7830            else {
7831                panic!("the fill frame {field:?} produces a Trade");
7832            };
7833            assert_eq!(trade.id.0.as_str(), expected, "frame {field:?}");
7834        }
7835    }
7836
7837    /// A `trade_bust` or `trade_correct` frame for `ord-1` (a buy of SPY), with `fields` spliced
7838    /// in. Its shape follows the Broker API's `TradeUpdateEventV2`; no real frame has been seen.
7839    fn amendment_frame(event: &str, fields: &str) -> String {
7840        format!(
7841            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"}}}}"#
7842        )
7843    }
7844
7845    /// The events the stream sends for `frames`, in order.
7846    fn stream_events(frames: &[String]) -> Vec<UnindexedAccountEvent> {
7847        let (tx, mut rx) = mpsc::unbounded_channel();
7848        let dedup = new_dedup_cache();
7849        let known = KnownLiveOrders::shared(ExchangeId::AlpacaBroker);
7850        for frame in frames {
7851            process_ws_text(frame, &tx, &dedup, &known, &mut VenueClock::new());
7852        }
7853        std::iter::from_fn(|| rx.try_recv().ok()).collect()
7854    }
7855
7856    /// The one event the stream sends for an amendment frame, as a `TradeAmendment`.
7857    fn amendment_of(
7858        frame: String,
7859    ) -> crate::trade::TradeAmendment<AssetNameExchange, InstrumentNameExchange> {
7860        let events = stream_events(&[frame]);
7861        let [
7862            UnindexedAccountEvent {
7863                kind: AccountEventKind::TradeAmended(amendment),
7864                ..
7865            },
7866        ] = <[_; 1]>::try_from(events).unwrap_or_else(|events| {
7867            panic!("one TradeAmended, got {events:?}");
7868        })
7869        else {
7870            panic!("expected TradeAmended");
7871        };
7872        amendment
7873    }
7874
7875    /// A bust names the fill it cancels, and its quantity, which Alpaca may send as a negative
7876    /// reversal, is read as a magnitude.
7877    #[test]
7878    fn process_ws_text_reports_a_trade_bust() {
7879        let amendment = amendment_of(amendment_frame(
7880            "trade_bust",
7881            r#""execution_id":"exec-x","previous_execution_id":"exec-a","qty":"-2","#,
7882        ));
7883        assert_eq!(amendment.original, Some(TradeId::new("exec-a")));
7884        assert_eq!(amendment.order_id, OrderId::new("ord-1"));
7885        assert_eq!(amendment.instrument, InstrumentNameExchange::new("SPY"));
7886        assert_eq!(
7887            Some(amendment.time_exchange),
7888            parse_timestamp("2025-04-18T14:31:00Z")
7889        );
7890        assert_eq!(
7891            amendment.kind,
7892            TradeAmendmentKind::Busted {
7893                quantity: Some(Decimal::TWO)
7894            }
7895        );
7896    }
7897
7898    /// A bust is reported even when it names neither the fill nor a quantity.
7899    #[test]
7900    fn process_ws_text_reports_a_bust_that_names_no_fill() {
7901        let amendment = amendment_of(amendment_frame("trade_bust", ""));
7902        assert_eq!(amendment.original, None);
7903        assert_eq!(
7904            amendment.kind,
7905            TradeAmendmentKind::Busted { quantity: None }
7906        );
7907    }
7908
7909    /// A correction carrying a price and a quantity replaces the fill with a trade under its own
7910    /// execution id, which claims no cumulative.
7911    #[test]
7912    fn process_ws_text_reports_a_trade_correct_with_its_replacement() {
7913        let amendment = amendment_of(amendment_frame(
7914            "trade_correct",
7915            r#""execution_id":"exec-c","previous_execution_id":"exec-a","price":"101.50","qty":"2","#,
7916        ));
7917        assert_eq!(amendment.original, Some(TradeId::new("exec-a")));
7918        let TradeAmendmentKind::Corrected { replacement } = amendment.kind else {
7919            panic!("a full replacement: {:?}", amendment.kind);
7920        };
7921        assert_eq!(replacement.id, TradeId::new("exec-c"));
7922        assert_eq!(replacement.order_id, OrderId::new("ord-1"));
7923        assert_eq!(replacement.side, Side::Buy);
7924        assert_eq!(replacement.price, Decimal::new(10150, 2));
7925        assert_eq!(replacement.quantity, Decimal::TWO);
7926        assert_eq!(replacement.order_filled_quantity, None);
7927        assert_eq!(replacement.time_exchange, amendment.time_exchange);
7928    }
7929
7930    /// A correction lacking anything a replacement trade needs is still reported, with what it
7931    /// carried.
7932    #[test]
7933    fn process_ws_text_reports_a_correction_it_cannot_resolve() {
7934        let price = Some(Decimal::new(10150, 2));
7935        let cases = [
7936            (
7937                r#""execution_id":"exec-c","qty":"2","#,
7938                (Some("exec-c"), None, Some(Decimal::TWO)),
7939            ),
7940            (
7941                r#""price":"101.50","qty":"2","#,
7942                (None, price, Some(Decimal::TWO)),
7943            ),
7944            (
7945                r#""execution_id":"exec-c","price":"101.50","qty":"0","#,
7946                (Some("exec-c"), price, Some(Decimal::ZERO)),
7947            ),
7948            (
7949                r#""execution_id":"exec-c","price":"101.50","#,
7950                (Some("exec-c"), price, None),
7951            ),
7952        ];
7953        for (fields, (id, price, quantity)) in cases {
7954            let amendment = amendment_of(amendment_frame("trade_correct", fields));
7955            assert_eq!(
7956                amendment.kind,
7957                TradeAmendmentKind::CorrectedUnresolved {
7958                    id: id.map(TradeId::new),
7959                    price,
7960                    quantity,
7961                },
7962                "{fields}"
7963            );
7964        }
7965    }
7966
7967    /// After a bust takes an order back, a new fill that returns it to a cumulative an earlier
7968    /// fill reached is delivered, not taken for that fill.
7969    #[test]
7970    fn a_fill_after_a_bust_is_delivered_at_a_cumulative_seen_before() {
7971        let fill = |execution_id: &str| {
7972            format!(
7973                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"}}}}"#
7974            )
7975        };
7976        let events = stream_events(&[
7977            fill("exec-a"),
7978            amendment_frame(
7979                "trade_bust",
7980                r#""execution_id":"exec-x","previous_execution_id":"exec-a","qty":"-2","#,
7981            ),
7982            fill("exec-b"),
7983        ]);
7984
7985        let kinds: Vec<_> = events
7986            .iter()
7987            .filter_map(|event| match &event.kind {
7988                AccountEventKind::Trade(trade) => Some(format!("trade {}", trade.id)),
7989                AccountEventKind::TradeAmended(amendment) => {
7990                    Some(format!("amended {:?}", amendment.original))
7991                }
7992                _ => None,
7993            })
7994            .collect();
7995        assert_eq!(
7996            kinds,
7997            vec![
7998                "trade exec-a",
7999                "amended Some(TradeId(\"exec-a\"))",
8000                "trade exec-b"
8001            ]
8002        );
8003    }
8004
8005    /// Pins the string representation of `Decimal::ZERO.normalize()`, which is used
8006    /// as the dedup key fallback when `filled_qty` is unparseable: `"{order_id}:0"`.
8007    #[test]
8008    fn decimal_zero_normalize_is_zero_str() {
8009        assert_eq!(Decimal::ZERO.normalize().to_string(), "0");
8010    }
8011
8012    /// Verifies that trailing zeros are stripped by `normalize()`, ensuring dedup keys
8013    /// match regardless of whether Alpaca returns `"1.00"` or `"1"` for the same qty.
8014    /// This is the critical invariant for REST/WS dedup key equivalence.
8015    #[test]
8016    fn decimal_normalize_strips_trailing_zeros() {
8017        // Alpaca may return "1.00" in REST but WS cumulative may be "1" — must match.
8018        let from_rest = Decimal::from_str("1.00").unwrap().normalize();
8019        let from_ws = Decimal::from_str("1").unwrap().normalize();
8020        assert_eq!(from_rest.to_string(), from_ws.to_string());
8021        assert_eq!(from_rest.to_string(), "1");
8022
8023        // More edge cases: various trailing zero representations.
8024        assert_eq!(
8025            Decimal::from_str("100.000")
8026                .unwrap()
8027                .normalize()
8028                .to_string(),
8029            "100"
8030        );
8031        assert_eq!(
8032            Decimal::from_str("0.10").unwrap().normalize().to_string(),
8033            "0.1"
8034        );
8035        assert_eq!(
8036            Decimal::from_str("0.100").unwrap().normalize().to_string(),
8037            "0.1"
8038        );
8039    }
8040
8041    // M-2: map_position_intent derives intent from (reduce_only, side).
8042    #[test]
8043    fn map_position_intent_open_buy_maps_to_buy_to_open() {
8044        assert_eq!(
8045            map_position_intent(Side::Buy, ReduceOnly::No),
8046            AlpacaPositionIntent::BuyToOpen
8047        );
8048    }
8049
8050    #[test]
8051    fn map_position_intent_open_sell_maps_to_sell_to_open() {
8052        assert_eq!(
8053            map_position_intent(Side::Sell, ReduceOnly::No),
8054            AlpacaPositionIntent::SellToOpen
8055        );
8056    }
8057
8058    #[test]
8059    fn map_position_intent_reduce_buy_maps_to_buy_to_close() {
8060        assert_eq!(
8061            map_position_intent(Side::Buy, ReduceOnly::Position),
8062            AlpacaPositionIntent::BuyToClose
8063        );
8064    }
8065
8066    #[test]
8067    fn map_position_intent_reduce_sell_maps_to_sell_to_close() {
8068        assert_eq!(
8069            map_position_intent(Side::Sell, ReduceOnly::Position),
8070            AlpacaPositionIntent::SellToClose
8071        );
8072    }
8073
8074    #[test]
8075    fn map_position_intent_maps_a_free_base_close_as_any_other_close() {
8076        assert_eq!(
8077            map_position_intent(Side::Sell, ReduceOnly::PositionAndFreeBase),
8078            AlpacaPositionIntent::SellToClose
8079        );
8080        assert_eq!(
8081            map_position_intent(Side::Buy, ReduceOnly::PositionAndFreeBase),
8082            AlpacaPositionIntent::BuyToClose
8083        );
8084    }
8085
8086    // M-1: parse_order_error — pin all status-code branches not covered by existing tests.
8087    #[test]
8088    fn parse_order_error_401_maps_to_unauthenticated() {
8089        // 401 Unauthorized: invalid/expired API credentials.
8090        assert!(matches!(
8091            parse_order_error(reqwest::StatusCode::UNAUTHORIZED, None, "bad credentials"),
8092            UnindexedOrderError::Rejected(ApiError::Unauthenticated(_))
8093        ));
8094    }
8095
8096    #[test]
8097    fn parse_order_error_403_without_code_maps_to_unauthenticated() {
8098        // Alpaca's answer to a wrong key, secret or host: a bare `{"message": "forbidden."}`.
8099        assert!(matches!(
8100            parse_order_error(reqwest::StatusCode::FORBIDDEN, None, "forbidden."),
8101            UnindexedOrderError::Rejected(ApiError::Unauthenticated(_))
8102        ));
8103    }
8104
8105    #[test]
8106    fn parse_order_error_403_with_code_and_insufficient_maps_to_balance_insufficient() {
8107        for message in [
8108            "insufficient buying power",
8109            "insufficient day trading buying power",
8110            "insufficient qty available for order (requested: 10, available: 0)",
8111        ] {
8112            let err = parse_order_error(reqwest::StatusCode::FORBIDDEN, Some(40310000), message);
8113            let UnindexedOrderError::Rejected(ApiError::BalanceInsufficient(None, text)) = err
8114            else {
8115                panic!("expected BalanceInsufficient for {message:?}, got {err:?}");
8116            };
8117            assert_eq!(text, format!("40310000 {message}"));
8118        }
8119    }
8120
8121    #[test]
8122    fn parse_order_error_reads_a_refused_short_by_its_text() {
8123        for (status, code, message, reason) in [
8124            (
8125                reqwest::StatusCode::FORBIDDEN,
8126                Some(40310000),
8127                "account is not allowed to short",
8128                BorrowRejectReason::BorrowDisabled,
8129            ),
8130            // Which status Alpaca gives a non-shortable asset is unconfirmed, so both are read.
8131            (
8132                reqwest::StatusCode::FORBIDDEN,
8133                Some(40310000),
8134                "asset XYZ cannot be sold short",
8135                BorrowRejectReason::NotShortable,
8136            ),
8137            (
8138                reqwest::StatusCode::UNPROCESSABLE_ENTITY,
8139                Some(42210000),
8140                "asset XYZ cannot be sold short",
8141                BorrowRejectReason::NotShortable,
8142            ),
8143            (
8144                reqwest::StatusCode::UNPROCESSABLE_ENTITY,
8145                None,
8146                "account is not allowed to short",
8147                BorrowRejectReason::BorrowDisabled,
8148            ),
8149        ] {
8150            assert_eq!(
8151                parse_order_error(status, code, message),
8152                UnindexedOrderError::Rejected(ApiError::BorrowRejected(BorrowReject::new(
8153                    reason,
8154                    code.map(|code| code.to_string()),
8155                    message.to_owned(),
8156                ))),
8157                "{status} {message:?}"
8158            );
8159        }
8160
8161        // The asset may be shortable in whole shares: the order's shape was refused, not a
8162        // borrow.
8163        for (status, code) in [
8164            (reqwest::StatusCode::UNPROCESSABLE_ENTITY, 42210000),
8165            (reqwest::StatusCode::FORBIDDEN, 40310000),
8166        ] {
8167            assert_eq!(
8168                parse_order_error(status, Some(code), "fractional orders cannot be sold short"),
8169                UnindexedOrderError::Rejected(ApiError::OrderRejected(format!(
8170                    "{code} fractional orders cannot be sold short"
8171                ))),
8172                "{status}"
8173            );
8174        }
8175
8176        // A refused short wins over "insufficient" in the same text.
8177        assert!(matches!(
8178            parse_order_error(
8179                reqwest::StatusCode::FORBIDDEN,
8180                Some(40310000),
8181                "insufficient qty: account is not allowed to short",
8182            ),
8183            UnindexedOrderError::Rejected(ApiError::BorrowRejected(_))
8184        ));
8185
8186        // Without a code a 403 is a wrong key, whatever its text.
8187        assert!(matches!(
8188            parse_order_error(
8189                reqwest::StatusCode::FORBIDDEN,
8190                None,
8191                "account is not allowed to short"
8192            ),
8193            UnindexedOrderError::Rejected(ApiError::Unauthenticated(_))
8194        ));
8195    }
8196
8197    #[test]
8198    fn parse_order_error_403_with_code_maps_to_order_rejected() {
8199        for (code, message) in [
8200            (40310000, "account is restricted to liquidation only"),
8201            (
8202                40310100,
8203                "trade denied due to pattern day trading protection",
8204            ),
8205        ] {
8206            let err = parse_order_error(reqwest::StatusCode::FORBIDDEN, Some(code), message);
8207            let UnindexedOrderError::Rejected(ApiError::OrderRejected(text)) = err else {
8208                panic!("expected OrderRejected for {message:?}, got {err:?}");
8209            };
8210            assert_eq!(text, format!("{code} {message}"));
8211        }
8212    }
8213
8214    #[test]
8215    fn alpaca_api_error_reads_code_and_falls_back_to_raw_text() {
8216        let coded = AlpacaApiError::from_body(
8217            br#"{"code": 40310000, "message": "insufficient buying power"}"#,
8218        );
8219        assert_eq!(coded.code, Some(40310000));
8220        assert_eq!(coded.message, "insufficient buying power");
8221
8222        let bare = AlpacaApiError::from_body(br#"{"message": "forbidden."}"#);
8223        assert_eq!(bare.code, None);
8224        assert_eq!(bare.message, "forbidden.");
8225
8226        let raw = AlpacaApiError::from_body(b"upstream connect error");
8227        assert_eq!(raw.code, None);
8228        assert_eq!(raw.message, "upstream connect error");
8229    }
8230
8231    #[test]
8232    fn parse_order_error_404_maps_to_order_rejected_with_not_found_prefix() {
8233        let err = parse_order_error(reqwest::StatusCode::NOT_FOUND, None, "order not found");
8234        let UnindexedOrderError::Rejected(ApiError::OrderRejected(msg)) = err else {
8235            panic!("expected OrderRejected, got {err:?}");
8236        };
8237        assert!(
8238            msg.contains("order not found"),
8239            "message should contain 'order not found': {msg}"
8240        );
8241    }
8242
8243    #[test]
8244    fn parse_order_error_422_insufficient_only_maps_to_balance_insufficient() {
8245        // No "already" in body → must not match OrderAlreadyCancelled; must be BalanceInsufficient.
8246        assert!(matches!(
8247            parse_order_error(
8248                reqwest::StatusCode::UNPROCESSABLE_ENTITY,
8249                None,
8250                "insufficient funds for this order"
8251            ),
8252            UnindexedOrderError::Rejected(ApiError::BalanceInsufficient(None, _))
8253        ));
8254    }
8255
8256    #[test]
8257    fn parse_order_error_429_maps_to_rate_limit() {
8258        assert!(matches!(
8259            parse_order_error(reqwest::StatusCode::TOO_MANY_REQUESTS, None, "rate limited"),
8260            UnindexedOrderError::Rejected(ApiError::RateLimit {
8261                retry_after: None,
8262                ..
8263            })
8264        ));
8265    }
8266
8267    #[test]
8268    fn a_rate_limit_delay_is_capped_at_alpacas_window() {
8269        let window = Some(Duration::from_secs(DEFAULT_RATE_LIMIT_DELAY_SECS));
8270        for (name, value) in [("retry-after", u64::MAX), ("x-ratelimit-reset", u64::MAX)] {
8271            let mut headers = reqwest::header::HeaderMap::new();
8272            headers.insert(name, value.to_string().parse().unwrap());
8273            assert_eq!(parse_rate_limit_delay(&headers), window, "{name}");
8274            // Nor does it overflow the cooldown's deadline.
8275            RateLimitTracker::new().on_rate_limited(parse_rate_limit_delay(&headers));
8276        }
8277
8278        let mut headers = reqwest::header::HeaderMap::new();
8279        headers.insert("retry-after", "0".parse().unwrap());
8280        assert_eq!(
8281            parse_rate_limit_delay(&headers),
8282            Some(Duration::ZERO),
8283            "taken as given"
8284        );
8285    }
8286
8287    // M-4: parse_time_in_force — pin the unknown-value fallback to GoodUntilEndOfDay.
8288    // If Alpaca adds a new TIF (e.g. "opg" for at-the-open), orders continue to be
8289    // tracked with EOD expiry until this function is updated; the warn! makes it visible.
8290    #[test]
8291    fn parse_time_in_force_unknown_value_falls_back_to_good_until_end_of_day() {
8292        assert_eq!(
8293            parse_time_in_force("opg"),
8294            TimeInForce::GoodUntilEndOfDay,
8295            "unknown TIF must fall back to GoodUntilEndOfDay (with a warn! in production)"
8296        );
8297    }
8298
8299    // L-4: build_instrument_snapshots — output order must match the instruments slice,
8300    // not the internal IndexMap insertion order of the orders vec.
8301    #[test]
8302    fn build_instrument_snapshots_output_order_matches_instruments_slice() {
8303        // Orders arrive in symbol order: SPY, AAPL, MSFT.
8304        let orders = vec![
8305            make_order_response("o1", "SPY"),
8306            make_order_response("o2", "AAPL"),
8307            make_order_response("o3", "MSFT"),
8308        ];
8309        // Request a different order: MSFT first, then AAPL.
8310        let instruments = vec![
8311            InstrumentNameExchange::new("MSFT"),
8312            InstrumentNameExchange::new("AAPL"),
8313        ];
8314        let snapshots = build_instrument_snapshots(orders, Vec::new(), &instruments);
8315        assert_eq!(snapshots.len(), 2);
8316        assert_eq!(
8317            snapshots[0].instrument.name().as_str(),
8318            "MSFT",
8319            "first snapshot must be MSFT (first in instruments slice)"
8320        );
8321        assert_eq!(
8322            snapshots[1].instrument.name().as_str(),
8323            "AAPL",
8324            "second snapshot must be AAPL (second in instruments slice)"
8325        );
8326    }
8327
8328    // ---------------------------------------------------------------------------
8329    // HTTP-mocked tests — paginate_activities (H-1) and fetch_raw_open_orders (H-3)
8330    // ---------------------------------------------------------------------------
8331    //
8332    // These tests use wiremock to stand up a local HTTP server, verifying the full
8333    // pagination loop and truncation logic without touching the real Alpaca API.
8334    mod http_tests {
8335        use super::super::*;
8336        use wiremock::matchers::{method, path};
8337        use wiremock::{Mock, MockServer, Request, Respond, ResponseTemplate};
8338
8339        /// Serves pre-configured JSON pages in registration order.
8340        ///
8341        /// Each call to `respond` advances an atomic counter and returns the next
8342        /// page body. Panics if called more times than pages were configured —
8343        /// surfaces unexpected extra requests as an explicit test failure rather
8344        /// than silently returning a stale response.
8345        struct Sequential {
8346            call: std::sync::atomic::AtomicU32,
8347            pages: Vec<serde_json::Value>,
8348        }
8349
8350        impl Sequential {
8351            fn new(pages: Vec<serde_json::Value>) -> Self {
8352                Self {
8353                    call: std::sync::atomic::AtomicU32::new(0),
8354                    pages,
8355                }
8356            }
8357        }
8358
8359        impl Respond for Sequential {
8360            fn respond(&self, _: &Request) -> ResponseTemplate {
8361                let i = self.call.fetch_add(1, std::sync::atomic::Ordering::Relaxed) as usize;
8362                let body = self.pages.get(i).unwrap_or_else(|| {
8363                    panic!(
8364                        "Sequential: request #{i} has no configured response \
8365                         (only {} page(s) supplied)",
8366                        self.pages.len()
8367                    )
8368                });
8369                ResponseTemplate::new(200).set_body_json(body)
8370            }
8371        }
8372
8373        /// Build a JSON array of N minimal AlpacaActivity objects with unique IDs.
8374        fn make_activities_json(count: usize, id_prefix: &str) -> serde_json::Value {
8375            serde_json::Value::Array(
8376                (0..count)
8377                    .map(|i| {
8378                        serde_json::json!({
8379                            "id": format!("{id_prefix}-{i:05}"),
8380                            "order_id": "ord-1",
8381                            "symbol": "SPY",
8382                            "side": "buy",
8383                            "price": "100.00",
8384                            "qty": "1",
8385                            "transaction_time": "2025-04-18T14:30:00Z"
8386                        })
8387                    })
8388                    .collect(),
8389            )
8390        }
8391
8392        /// Build a JSON array of N minimal AlpacaOrderResponse objects with unique IDs.
8393        fn make_orders_json(count: usize) -> serde_json::Value {
8394            serde_json::Value::Array(
8395                (0..count)
8396                    .map(|i| {
8397                        serde_json::json!({
8398                            "id": format!("order-{i:05}"),
8399                            "client_order_id": null,
8400                            "symbol": "SPY",
8401                            "qty": "1",
8402                            "filled_qty": "0",
8403                            "side": "buy",
8404                            "type": "limit",
8405                            "time_in_force": "day",
8406                            "limit_price": "100.00",
8407                            "created_at": "2025-04-18T14:30:00Z"
8408                        })
8409                    })
8410                    .collect(),
8411            )
8412        }
8413
8414        // --- H-1: paginate_activities ---
8415
8416        /// Single page with fewer items than the page limit — loop terminates immediately,
8417        /// no further request issued.
8418        #[tokio::test]
8419        async fn paginate_activities_single_page_below_max_returns_all_not_truncated() {
8420            let server = MockServer::start().await;
8421            Mock::given(method("GET"))
8422                .and(path("/v2/account/activities"))
8423                .respond_with(
8424                    ResponseTemplate::new(200).set_body_json(make_activities_json(5, "act")),
8425                )
8426                .mount(&server)
8427                .await;
8428
8429            let http = reqwest::Client::new();
8430            let rl = RateLimitTracker::new();
8431            let result = paginate_activities::<AlpacaActivity>(
8432                &http,
8433                &rl,
8434                &server.uri(),
8435                FILL_ACTIVITIES,
8436                recovery_after(),
8437                None,
8438            )
8439            .await
8440            .unwrap();
8441
8442            assert_eq!(result.activities.len(), 5);
8443            assert_eq!(result.resume, None);
8444            assert_eq!(server.received_requests().await.unwrap().len(), 1);
8445        }
8446
8447        /// First page has exactly ALPACA_MAX_ACTIVITIES items, which triggers a second
8448        /// request. The second page is empty, so the loop terminates without truncation.
8449        #[tokio::test]
8450        async fn paginate_activities_exactly_page_size_items_fetches_second_page() {
8451            let server = MockServer::start().await;
8452            Mock::given(method("GET"))
8453                .and(path("/v2/account/activities"))
8454                .respond_with(Sequential::new(vec![
8455                    make_activities_json(ALPACA_MAX_ACTIVITIES, "act"),
8456                    serde_json::json!([]), // empty second page → loop stops
8457                ]))
8458                .mount(&server)
8459                .await;
8460
8461            let http = reqwest::Client::new();
8462            let rl = RateLimitTracker::new();
8463            let result = paginate_activities::<AlpacaActivity>(
8464                &http,
8465                &rl,
8466                &server.uri(),
8467                FILL_ACTIVITIES,
8468                recovery_after(),
8469                None,
8470            )
8471            .await
8472            .unwrap();
8473
8474            assert_eq!(result.activities.len(), ALPACA_MAX_ACTIVITIES);
8475            assert_eq!(result.resume, None);
8476            assert_eq!(
8477                server.received_requests().await.unwrap().len(),
8478                2,
8479                "exactly 2 requests: first full page + second empty page"
8480            );
8481        }
8482
8483        /// Two-page case: 100 items on page 1, 37 on page 2 — all accumulated,
8484        /// loop terminates on the partial second page without truncation.
8485        #[tokio::test]
8486        async fn paginate_activities_two_pages_returns_combined_activities_not_truncated() {
8487            let server = MockServer::start().await;
8488            Mock::given(method("GET"))
8489                .and(path("/v2/account/activities"))
8490                .respond_with(Sequential::new(vec![
8491                    make_activities_json(ALPACA_MAX_ACTIVITIES, "p1"),
8492                    make_activities_json(37, "p2"),
8493                ]))
8494                .mount(&server)
8495                .await;
8496
8497            let http = reqwest::Client::new();
8498            let rl = RateLimitTracker::new();
8499            let result = paginate_activities::<AlpacaActivity>(
8500                &http,
8501                &rl,
8502                &server.uri(),
8503                FILL_ACTIVITIES,
8504                recovery_after(),
8505                None,
8506            )
8507            .await
8508            .unwrap();
8509
8510            assert_eq!(result.activities.len(), ALPACA_MAX_ACTIVITIES + 37);
8511            assert_eq!(result.resume, None);
8512            assert_eq!(server.received_requests().await.unwrap().len(), 2);
8513        }
8514
8515        /// When every page is full the loop runs until MAX_ACTIVITY_PAGES pages have been
8516        /// fetched, then sets truncated=true and stops. Exactly MAX_ACTIVITY_PAGES HTTP
8517        /// requests are issued (the truncation guard fires before the (N+1)th call).
8518        #[tokio::test]
8519        async fn paginate_activities_at_max_pages_sets_truncated_true() {
8520            let server = MockServer::start().await;
8521
8522            // Always return a full page — the loop must enforce the cap itself.
8523            Mock::given(method("GET"))
8524                .and(path("/v2/account/activities"))
8525                .respond_with(
8526                    ResponseTemplate::new(200)
8527                        .set_body_json(make_activities_json(ALPACA_MAX_ACTIVITIES, "act")),
8528                )
8529                .mount(&server)
8530                .await;
8531
8532            let http = reqwest::Client::new();
8533            let rl = RateLimitTracker::new();
8534            let result = paginate_activities::<AlpacaActivity>(
8535                &http,
8536                &rl,
8537                &server.uri(),
8538                FILL_ACTIVITIES,
8539                recovery_after(),
8540                None,
8541            )
8542            .await
8543            .unwrap();
8544
8545            assert!(
8546                result.resume.is_some(),
8547                "must say where to read on after MAX_ACTIVITY_PAGES pages"
8548            );
8549            assert_eq!(
8550                result.activities.len(),
8551                MAX_ACTIVITY_PAGES * ALPACA_MAX_ACTIVITIES,
8552                "must accumulate exactly MAX_ACTIVITY_PAGES * page_size activities"
8553            );
8554            // The truncation check fires at the top of the loop when pages == MAX_ACTIVITY_PAGES,
8555            // before the (MAX_ACTIVITY_PAGES+1)th request would be issued.
8556            assert_eq!(
8557                server.received_requests().await.unwrap().len(),
8558                MAX_ACTIVITY_PAGES,
8559                "loop must issue exactly MAX_ACTIVITY_PAGES requests then stop"
8560            );
8561        }
8562
8563        // --- H-3: fetch_raw_open_orders truncation boundary ---
8564
8565        /// 499 orders (one below MAX_OPEN_ORDERS) is not truncated — returns Ok.
8566        #[tokio::test]
8567        async fn fetch_raw_open_orders_499_results_returns_ok() {
8568            let server = MockServer::start().await;
8569            Mock::given(method("GET"))
8570                .and(path("/v2/orders"))
8571                .respond_with(
8572                    ResponseTemplate::new(200).set_body_json(make_orders_json(MAX_OPEN_ORDERS - 1)),
8573                )
8574                .mount(&server)
8575                .await;
8576
8577            let http = reqwest::Client::new();
8578            let rl = RateLimitTracker::new();
8579            let result = fetch_raw_open_orders(&http, &rl, &server.uri(), &[]).await;
8580
8581            assert!(
8582                result.is_ok(),
8583                "499 orders must not trigger truncation: {result:?}"
8584            );
8585            assert_eq!(result.unwrap().len(), MAX_OPEN_ORDERS - 1);
8586        }
8587
8588        /// Exactly MAX_OPEN_ORDERS results triggers TruncatedSnapshot because Alpaca's
8589        /// API cap means the response is likely incomplete. An off-by-one here would
8590        /// either silently corrupt OMS state or incorrectly reject a valid account.
8591        #[tokio::test]
8592        async fn fetch_raw_open_orders_500_results_returns_truncated_snapshot_error() {
8593            let server = MockServer::start().await;
8594            Mock::given(method("GET"))
8595                .and(path("/v2/orders"))
8596                .respond_with(
8597                    ResponseTemplate::new(200).set_body_json(make_orders_json(MAX_OPEN_ORDERS)),
8598                )
8599                .mount(&server)
8600                .await;
8601
8602            let http = reqwest::Client::new();
8603            let rl = RateLimitTracker::new();
8604            let result = fetch_raw_open_orders(&http, &rl, &server.uri(), &[]).await;
8605
8606            assert!(
8607                matches!(
8608                    result,
8609                    Err(UnindexedClientError::TruncatedSnapshot { limit }) if limit == MAX_OPEN_ORDERS
8610                ),
8611                "500 orders must return TruncatedSnapshot, got: {result:?}"
8612            );
8613        }
8614
8615        // --- account_snapshot: cash balance, equity/option positions, crypto balances ---
8616
8617        /// Mounts `/v2/account`, `/v2/positions` and an empty `/v2/orders` on `server`.
8618        /// Synthetic values only; no provider data.
8619        async fn mount_account_and_positions(server: &MockServer) {
8620            mount_account_and_positions_answering_after(server, Duration::ZERO).await;
8621        }
8622
8623        /// [`mount_account_and_positions`], answering the account and positions reads `delay`
8624        /// after each arrives.
8625        async fn mount_account_and_positions_answering_after(server: &MockServer, delay: Duration) {
8626            Mock::given(method("GET"))
8627                .and(path("/v2/account"))
8628                .respond_with(
8629                    ResponseTemplate::new(200)
8630                        .set_body_json(serde_json::json!({
8631                            "status": "ACTIVE",
8632                            "currency": "USD",
8633                            "cash": "-1500.50",
8634                            "equity": "4000.00",
8635                            "buying_power": "5000.00",
8636                            "options_buying_power": "2000.00",
8637                            "non_marginable_buying_power": "1000.00",
8638                        }))
8639                        .set_delay(delay),
8640                )
8641                .mount(server)
8642                .await;
8643            Mock::given(method("GET"))
8644                .and(path("/v2/positions"))
8645                .respond_with(
8646                    ResponseTemplate::new(200)
8647                        .set_body_json(serde_json::json!([
8648                            {
8649                                "symbol": "XYZ",
8650                                "asset_class": "us_equity",
8651                                "side": "short",
8652                                "qty": "-10",
8653                                "qty_available": "-10",
8654                                "avg_entry_price": "20.00",
8655                                "unrealized_pl": "15.50",
8656                            },
8657                            {
8658                                "symbol": "XYZ271217C00025000",
8659                                "asset_class": "us_option",
8660                                "side": "long",
8661                                "qty": "3",
8662                                "qty_available": "3",
8663                                "avg_entry_price": "0.85",
8664                                "unrealized_pl": "-45",
8665                            },
8666                            {
8667                                "symbol": "ABC",
8668                                "asset_class": "us_equity",
8669                                "side": "long",
8670                                "qty": "2.5",
8671                                "qty_available": "2.5",
8672                                "avg_entry_price": "40.00",
8673                                "unrealized_pl": "1.25",
8674                            },
8675                            {
8676                                "symbol": "BTCUSD",
8677                                "asset_class": "crypto",
8678                                "side": "long",
8679                                "qty": "0.75",
8680                                "qty_available": "0.5",
8681                                "avg_entry_price": "50000",
8682                                "unrealized_pl": "0",
8683                            },
8684                        ]))
8685                        .set_delay(delay),
8686                )
8687                .mount(server)
8688                .await;
8689            Mock::given(method("GET"))
8690                .and(path("/v2/orders"))
8691                .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([])))
8692                .mount(server)
8693                .await;
8694        }
8695
8696        fn client_for(server: &MockServer) -> AlpacaClient {
8697            AlpacaClient::new(AlpacaConfig::with_base_url(
8698                "test-key".into(),
8699                "test-secret".into(),
8700                server.uri(),
8701            ))
8702        }
8703
8704        #[tokio::test]
8705        async fn account_snapshot_reports_cash_positions_and_crypto_balances() {
8706            use crate::client::ExecutionClient;
8707            use rust_decimal_macros::dec;
8708
8709            let server = MockServer::start().await;
8710            mount_account_and_positions(&server).await;
8711
8712            let snapshot = client_for(&server)
8713                .account_snapshot(&[], &[])
8714                .await
8715                .unwrap();
8716
8717            // USD: cash (negative on margin), and free capped at it, not equity or buying power.
8718            let balances: Vec<_> = snapshot
8719                .balances
8720                .iter()
8721                .map(|b| (b.asset.name().as_str(), b.balance.total, b.balance.free))
8722                .collect();
8723            assert_eq!(
8724                balances,
8725                vec![
8726                    ("USD", dec!(-1500.50), dec!(-1500.50)),
8727                    ("BTC", dec!(0.75), dec!(0.5)),
8728                ],
8729                "crypto stays a balance; equities and options do not appear as balances"
8730            );
8731
8732            let positions: Vec<_> = snapshot
8733                .instruments
8734                .iter()
8735                .map(|i| {
8736                    let p = i.position.open().unwrap();
8737                    (
8738                        i.instrument.name().as_str(),
8739                        p.quantity,
8740                        p.entry_price,
8741                        p.unrealized_pnl,
8742                    )
8743                })
8744                .collect();
8745            assert_eq!(
8746                positions,
8747                vec![
8748                    ("XYZ", dec!(-10), Some(dec!(20.00)), Some(dec!(15.50))),
8749                    (
8750                        "XYZ271217C00025000",
8751                        dec!(3),
8752                        Some(dec!(0.85)),
8753                        Some(dec!(-45))
8754                    ),
8755                    ("ABC", dec!(2.5), Some(dec!(40.00)), Some(dec!(1.25))),
8756                ],
8757                "short equity, option and fractional positions; no crypto position"
8758            );
8759        }
8760
8761        /// Balances are stamped no later than the requests that read them were sent, never when
8762        /// the answers arrive: the engine orders balance state by that time, so a later one would
8763        /// shut out events from between the two.
8764        #[tokio::test]
8765        async fn balances_are_stamped_before_their_reads_were_sent() {
8766            use crate::client::ExecutionClient;
8767
8768            const ANSWER_DELAY: Duration = Duration::from_millis(200);
8769            let server = MockServer::start().await;
8770            mount_account_and_positions_answering_after(&server, ANSWER_DELAY).await;
8771            let client = client_for(&server);
8772
8773            let before = Utc::now();
8774            let fetched = client.fetch_balances(&[]).await.unwrap();
8775            let between = Utc::now();
8776            let snapshot = client.account_snapshot(&[], &[]).await.unwrap();
8777            let after = Utc::now();
8778
8779            assert_eq!((fetched.len(), snapshot.balances.len()), (2, 2));
8780            // `fetch_balances` reads the account, then the positions: the account's balance was
8781            // sent for before both answers.
8782            assert_eq!(fetched[0].asset.name().as_str(), USD);
8783            assert!(fetched[0].time_exchange + 2 * ANSWER_DELAY <= between);
8784            // Each read's balances against the span its requests were sent in.
8785            for (balances, from, to) in [
8786                (&fetched, before, between),
8787                (&snapshot.balances, between, after),
8788            ] {
8789                for balance in balances {
8790                    assert!(
8791                        from <= balance.time_exchange && balance.time_exchange + ANSWER_DELAY <= to,
8792                        "{balance:?} not within {from} ..= {to} - {ANSWER_DELAY:?}"
8793                    );
8794                }
8795            }
8796        }
8797
8798        /// A USD-only request still fetches positions, since they back the instrument
8799        /// snapshots, and reports no crypto balance.
8800        #[tokio::test]
8801        async fn account_snapshot_usd_only_still_reports_positions() {
8802            use crate::client::ExecutionClient;
8803            use rust_decimal_macros::dec;
8804
8805            let server = MockServer::start().await;
8806            mount_account_and_positions(&server).await;
8807
8808            let usd = [AssetNameExchange::new("USD")];
8809            let instruments = [InstrumentNameExchange::new("XYZ")];
8810            let snapshot = client_for(&server)
8811                .account_snapshot(&usd, &instruments)
8812                .await
8813                .unwrap();
8814
8815            assert_eq!(snapshot.balances.len(), 1);
8816            assert_eq!(snapshot.balances[0].asset, AssetNameExchange::new("USD"));
8817            assert_eq!(snapshot.balances[0].balance.total, dec!(-1500.50));
8818
8819            assert_eq!(snapshot.instruments.len(), 1);
8820            assert_eq!(snapshot.instruments[0].position.quantity(), Some(dec!(-10)));
8821        }
8822
8823        /// A non-USD request skips `/v2/account` entirely.
8824        #[tokio::test]
8825        async fn account_snapshot_non_usd_only_skips_account_request() {
8826            use crate::client::ExecutionClient;
8827
8828            let server = MockServer::start().await;
8829            mount_account_and_positions(&server).await;
8830
8831            let btc = [AssetNameExchange::new("btc")];
8832            let snapshot = client_for(&server)
8833                .account_snapshot(&btc, &[])
8834                .await
8835                .unwrap();
8836
8837            assert_eq!(snapshot.balances.len(), 1);
8838            assert_eq!(snapshot.balances[0].asset, AssetNameExchange::new("BTC"));
8839            let requests = server.received_requests().await.unwrap();
8840            assert!(
8841                requests.iter().all(|r| r.url.path() != "/v2/account"),
8842                "no USD requested, so /v2/account must not be fetched"
8843            );
8844        }
8845
8846        // --- L-2: open_order passes reduce_only through to position_intent ---
8847
8848        /// Margin is account-level at Alpaca, so a request naming a margin effect is refused
8849        /// unsent, `NoSideEffect` included: nothing here can promise it.
8850        #[tokio::test]
8851        async fn open_order_refuses_every_margin_effect_without_sending() {
8852            use crate::client::ExecutionClient;
8853            use crate::error::OrderError;
8854            use crate::margin::MarginEffect;
8855            use crate::order::request::{OrderRequestOpen, RequestOpen};
8856            use crate::order::state::InactiveOrderState;
8857            use crate::order::{
8858                OrderKey, OrderKind, TimeInForce,
8859                id::{ClientOrderId, StrategyId},
8860            };
8861            use rust_decimal::Decimal;
8862            use rustrade_instrument::Side;
8863            use rustrade_instrument::exchange::ExchangeId;
8864            use rustrade_instrument::instrument::name::InstrumentNameExchange;
8865
8866            let server = MockServer::start().await;
8867            let client = client_for(&server);
8868            let instrument = InstrumentNameExchange::new("AAPL");
8869
8870            for effect in [
8871                MarginEffect::NoSideEffect,
8872                MarginEffect::Borrow,
8873                MarginEffect::Repay,
8874            ] {
8875                let order = client
8876                    .open_order(OrderRequestOpen {
8877                        key: OrderKey {
8878                            exchange: ExchangeId::AlpacaBroker,
8879                            instrument: &instrument,
8880                            strategy: StrategyId::new("test-strategy"),
8881                            cid: ClientOrderId::new("test-cid"),
8882                        },
8883                        state: RequestOpen::new(
8884                            Side::Buy,
8885                            None,
8886                            Decimal::new(10, 0),
8887                            OrderKind::Market,
8888                            TimeInForce::ImmediateOrCancel,
8889                        )
8890                        .with_margin_effect(effect),
8891                    })
8892                    .await;
8893
8894                assert!(
8895                    matches!(
8896                        order.state,
8897                        OrderState::Inactive(InactiveOrderState::OpenFailed(
8898                            OrderError::UnsupportedMarginEffect(refused)
8899                        )) if refused == effect
8900                    ),
8901                    "{order:?}"
8902                );
8903            }
8904            assert!(
8905                server.received_requests().await.unwrap().is_empty(),
8906                "a refused order is never sent"
8907            );
8908        }
8909
8910        /// Verifies that `open_order` correctly derives `position_intent` from
8911        /// `reduce_only` and `side`. This test exercises the full path:
8912        /// `open_order` → `map_position_intent` → `open_order_inner` → HTTP request.
8913        ///
8914        /// Uses wiremock to capture the request body and verify position_intent.
8915        #[tokio::test]
8916        async fn open_order_reduce_only_sell_sends_sell_to_close_intent() {
8917            use crate::client::ExecutionClient;
8918            use crate::order::request::{OrderRequestOpen, RequestOpen};
8919            use crate::order::{
8920                OrderKey, OrderKind, TimeInForce,
8921                id::{ClientOrderId, StrategyId},
8922            };
8923            use rust_decimal::Decimal;
8924            use rustrade_instrument::Side;
8925            use rustrade_instrument::exchange::ExchangeId;
8926            use rustrade_instrument::instrument::name::InstrumentNameExchange;
8927            use wiremock::matchers::{method, path};
8928
8929            let server = MockServer::start().await;
8930
8931            // Mock POST /v2/orders to return a valid order response.
8932            // Use a custom responder to capture and verify the request body.
8933            let captured_body = std::sync::Arc::new(parking_lot::Mutex::new(None));
8934            let captured_clone = captured_body.clone();
8935
8936            Mock::given(method("POST"))
8937                .and(path("/v2/orders"))
8938                .respond_with(move |req: &Request| {
8939                    // Capture the request body for later assertion
8940                    let body: serde_json::Value = serde_json::from_slice(&req.body).unwrap();
8941                    *captured_clone.lock() = Some(body);
8942
8943                    ResponseTemplate::new(200).set_body_json(serde_json::json!({
8944                        "id": "test-order-id",
8945                        "client_order_id": "test-cid",
8946                        "symbol": "AAPL",
8947                        "qty": "10",
8948                        "filled_qty": "0",
8949                        "side": "sell",
8950                        "type": "market",
8951                        "time_in_force": "ioc",
8952                        "limit_price": null,
8953                        "created_at": "2025-04-18T14:30:00Z"
8954                    }))
8955                })
8956                .mount(&server)
8957                .await;
8958
8959            // Create client with base_url_override pointing to mock server
8960            let config =
8961                AlpacaConfig::with_base_url("test-key".into(), "test-secret".into(), server.uri());
8962            let client = AlpacaClient::new(config);
8963
8964            // Create a Sell order with reduce_only Position (should map to SellToClose)
8965            let request = OrderRequestOpen {
8966                key: OrderKey {
8967                    exchange: ExchangeId::AlpacaBroker,
8968                    instrument: InstrumentNameExchange::new("AAPL"),
8969                    strategy: StrategyId::new("test-strategy"),
8970                    cid: ClientOrderId::new("test-cid"),
8971                },
8972                state: RequestOpen::new(
8973                    Side::Sell,
8974                    None,
8975                    Decimal::new(10, 0),
8976                    OrderKind::Market,
8977                    TimeInForce::ImmediateOrCancel,
8978                )
8979                .with_reduce_only(ReduceOnly::Position), // This should map to SellToClose
8980            };
8981
8982            // Call open_order (borrows instrument)
8983            let order = client
8984                .open_order(OrderRequestOpen {
8985                    key: OrderKey {
8986                        exchange: request.key.exchange,
8987                        instrument: &request.key.instrument,
8988                        strategy: request.key.strategy.clone(),
8989                        cid: request.key.cid.clone(),
8990                    },
8991                    state: request.state.clone(),
8992                })
8993                .await;
8994
8995            // Verify the order was accepted
8996            assert!(
8997                order.state.is_accepted(),
8998                "order should be accepted: {:?}",
8999                order.state
9000            );
9001
9002            // Verify the request body contained position_intent=sell_to_close
9003            let body = captured_body
9004                .lock()
9005                .take()
9006                .expect("request body should be captured");
9007            assert_eq!(
9008                body.get("position_intent").and_then(|v| v.as_str()),
9009                Some("sell_to_close"),
9010                "reduce_only Position + Side::Sell should produce position_intent=sell_to_close, got: {body}"
9011            );
9012        }
9013
9014        /// Verifies that reduce_only No + Buy produces BuyToOpen intent.
9015        #[tokio::test]
9016        async fn open_order_not_reduce_only_buy_sends_buy_to_open_intent() {
9017            use crate::client::ExecutionClient;
9018            use crate::order::request::{OrderRequestOpen, RequestOpen};
9019            use crate::order::{
9020                OrderKey, OrderKind, TimeInForce,
9021                id::{ClientOrderId, StrategyId},
9022            };
9023            use rust_decimal::Decimal;
9024            use rustrade_instrument::Side;
9025            use rustrade_instrument::exchange::ExchangeId;
9026            use rustrade_instrument::instrument::name::InstrumentNameExchange;
9027
9028            let server = MockServer::start().await;
9029
9030            let captured_body = std::sync::Arc::new(parking_lot::Mutex::new(None));
9031            let captured_clone = captured_body.clone();
9032
9033            Mock::given(method("POST"))
9034                .and(path("/v2/orders"))
9035                .respond_with(move |req: &Request| {
9036                    let body: serde_json::Value = serde_json::from_slice(&req.body).unwrap();
9037                    *captured_clone.lock() = Some(body);
9038
9039                    ResponseTemplate::new(200).set_body_json(serde_json::json!({
9040                        "id": "test-order-id",
9041                        "client_order_id": "test-cid",
9042                        "symbol": "AAPL",
9043                        "qty": "10",
9044                        "filled_qty": "0",
9045                        "side": "buy",
9046                        "type": "market",
9047                        "time_in_force": "ioc",
9048                        "limit_price": null,
9049                        "created_at": "2025-04-18T14:30:00Z"
9050                    }))
9051                })
9052                .mount(&server)
9053                .await;
9054
9055            let config =
9056                AlpacaConfig::with_base_url("test-key".into(), "test-secret".into(), server.uri());
9057            let client = AlpacaClient::new(config);
9058
9059            let instrument = InstrumentNameExchange::new("AAPL");
9060            let request = OrderRequestOpen {
9061                key: OrderKey {
9062                    exchange: ExchangeId::AlpacaBroker,
9063                    instrument: &instrument,
9064                    strategy: StrategyId::new("test-strategy"),
9065                    cid: ClientOrderId::new("test-cid"),
9066                },
9067                // Not reduce_only: this should map to BuyToOpen
9068                state: RequestOpen::new(
9069                    Side::Buy,
9070                    None,
9071                    Decimal::new(10, 0),
9072                    OrderKind::Market,
9073                    TimeInForce::ImmediateOrCancel,
9074                ),
9075            };
9076
9077            let order = client.open_order(request).await;
9078
9079            assert!(
9080                order.state.is_accepted(),
9081                "order should be accepted: {:?}",
9082                order.state
9083            );
9084
9085            let body = captured_body
9086                .lock()
9087                .take()
9088                .expect("request body should be captured");
9089            assert_eq!(
9090                body.get("position_intent").and_then(|v| v.as_str()),
9091                Some("buy_to_open"),
9092                "reduce_only No + Side::Buy should produce position_intent=buy_to_open, got: {body}"
9093            );
9094        }
9095
9096        /// A 2xx body that does not decode is not transient, so a query reports it as `Internal`
9097        /// rather than as connectivity a caller would retry.
9098        #[tokio::test]
9099        async fn rest_with_retry_reports_an_undecodable_2xx_body_as_internal() {
9100            let server = MockServer::start().await;
9101            Mock::given(method("GET"))
9102                .and(path("/v2/account"))
9103                .respond_with(ResponseTemplate::new(200).set_body_string("not json"))
9104                .mount(&server)
9105                .await;
9106
9107            let http = reqwest::Client::new();
9108            let rl = RateLimitTracker::new();
9109            let url = format!("{}/v2/account", server.uri());
9110            let result: Result<serde_json::Value, _> =
9111                rest_with_retry(&rl, || http.get(&url)).await;
9112            assert!(
9113                matches!(result, Err(UnindexedClientError::Internal(_))),
9114                "{result:?}"
9115            );
9116        }
9117
9118        /// Whether `rl` still holds requests back after 50 ms.
9119        async fn holds_back(rl: &RateLimitTracker) -> bool {
9120            tokio::time::timeout(Duration::from_millis(50), rl.wait_if_blocked())
9121                .await
9122                .is_err()
9123        }
9124
9125        /// The rate-limit headers of a response: `remaining`, and a reset 30 s ahead if `reset`.
9126        fn with_rate_limit(
9127            template: ResponseTemplate,
9128            remaining: u32,
9129            reset: bool,
9130        ) -> ResponseTemplate {
9131            let template = template.insert_header("x-ratelimit-remaining", remaining.to_string());
9132            if !reset {
9133                return template;
9134            }
9135            let now_secs = std::time::SystemTime::now()
9136                .duration_since(std::time::UNIX_EPOCH)
9137                .unwrap()
9138                .as_secs();
9139            template.insert_header("x-ratelimit-reset", (now_secs + 30).to_string())
9140        }
9141
9142        /// A response reporting no requests left holds every later request back until the reset;
9143        /// one with requests left, or with no reset to wait for, does not.
9144        #[tokio::test]
9145        async fn rest_with_retry_pauses_once_no_requests_remain() {
9146            for (remaining, reset, pauses) in [(0, true, true), (1, true, false), (0, false, false)]
9147            {
9148                let server = MockServer::start().await;
9149                Mock::given(method("GET"))
9150                    .and(path("/v2/account"))
9151                    .respond_with(with_rate_limit(
9152                        ResponseTemplate::new(200).set_body_json(serde_json::json!({})),
9153                        remaining,
9154                        reset,
9155                    ))
9156                    .mount(&server)
9157                    .await;
9158
9159                let http = reqwest::Client::new();
9160                let rl = RateLimitTracker::new();
9161                let url = format!("{}/v2/account", server.uri());
9162                let _: serde_json::Value = rest_with_retry(&rl, || http.get(&url)).await.unwrap();
9163                assert_eq!(
9164                    holds_back(&rl).await,
9165                    pauses,
9166                    "remaining {remaining}, reset {reset}"
9167                );
9168            }
9169        }
9170
9171        /// A reset further out than Alpaca's one-minute window holds requests back for a minute
9172        /// at most.
9173        #[tokio::test]
9174        async fn a_pause_lasts_a_minute_at_most() {
9175            tokio::time::pause();
9176            let now_secs = std::time::SystemTime::now()
9177                .duration_since(std::time::UNIX_EPOCH)
9178                .unwrap()
9179                .as_secs();
9180            let mut headers = reqwest::header::HeaderMap::new();
9181            headers.insert("x-ratelimit-remaining", "0".parse().unwrap());
9182            headers.insert(
9183                "x-ratelimit-reset",
9184                (now_secs + 3_600).to_string().parse().unwrap(),
9185            );
9186            let rl = RateLimitTracker::new();
9187
9188            observe_rate_limit_remaining(&rl, &headers);
9189            assert!(holds_back(&rl).await);
9190            tokio::time::advance(Duration::from_secs(DEFAULT_RATE_LIMIT_DELAY_SECS)).await;
9191            assert!(!holds_back(&rl).await);
9192        }
9193
9194        /// A cancel's response reporting no requests left holds later requests back too.
9195        #[tokio::test]
9196        async fn rest_delete_with_retry_pauses_once_no_requests_remain() {
9197            let server = MockServer::start().await;
9198            Mock::given(method("DELETE"))
9199                .and(path("/v2/orders/abc"))
9200                .respond_with(with_rate_limit(ResponseTemplate::new(204), 0, true))
9201                .mount(&server)
9202                .await;
9203
9204            let http = reqwest::Client::new();
9205            let rl = RateLimitTracker::new();
9206            let url = format!("{}/v2/orders/abc", server.uri());
9207            rest_delete_with_retry(&rl, || http.delete(&url))
9208                .await
9209                .unwrap();
9210            assert!(holds_back(&rl).await);
9211        }
9212
9213        /// An order Alpaca accepted (2xx) but whose response does not decode may be live, so it
9214        /// fails as status unknown (`Connectivity`), never as a rejection, and does not panic.
9215        #[tokio::test]
9216        async fn open_order_with_an_undecodable_2xx_response_is_status_unknown() {
9217            use crate::client::ExecutionClient;
9218            use crate::error::OrderError;
9219            use crate::order::request::{OrderRequestOpen, RequestOpen};
9220            use crate::order::state::{InactiveOrderState, OrderState};
9221            use crate::order::{
9222                OrderKey, OrderKind, TimeInForce,
9223                id::{ClientOrderId, StrategyId},
9224            };
9225            use rustrade_instrument::Side;
9226            use rustrade_instrument::exchange::ExchangeId;
9227            use rustrade_instrument::instrument::name::InstrumentNameExchange;
9228
9229            let server = MockServer::start().await;
9230            Mock::given(method("POST"))
9231                .and(path("/v2/orders"))
9232                .respond_with(
9233                    ResponseTemplate::new(200).set_body_json(serde_json::json!({ "id": 1 })),
9234                )
9235                .mount(&server)
9236                .await;
9237
9238            let instrument = InstrumentNameExchange::new("AAPL");
9239            let order = client_for(&server)
9240                .open_order(OrderRequestOpen {
9241                    key: OrderKey {
9242                        exchange: ExchangeId::AlpacaBroker,
9243                        instrument: &instrument,
9244                        strategy: StrategyId::new("test-strategy"),
9245                        cid: ClientOrderId::new("test-cid"),
9246                    },
9247                    state: RequestOpen::new(
9248                        Side::Buy,
9249                        None,
9250                        Decimal::new(10, 0),
9251                        OrderKind::Market,
9252                        TimeInForce::ImmediateOrCancel,
9253                    ),
9254                })
9255                .await;
9256
9257            assert!(
9258                matches!(
9259                    order.state,
9260                    OrderState::Inactive(InactiveOrderState::OpenFailed(OrderError::Connectivity(
9261                        _
9262                    )))
9263                ),
9264                "{:?}",
9265                order.state
9266            );
9267        }
9268
9269        /// A 403 refusal, or a 422 refused short, reaches the order's state as the refusal it is,
9270        /// with Alpaca's code, and a 403 without a code (a wrong key) as an authentication failure.
9271        #[tokio::test]
9272        async fn open_order_reports_a_403_by_its_code() {
9273            use crate::client::ExecutionClient;
9274            use crate::error::OrderError;
9275            use crate::order::request::{OrderRequestOpen, RequestOpen};
9276            use crate::order::state::{InactiveOrderState, OrderState};
9277            use crate::order::{
9278                OrderKey, OrderKind, TimeInForce,
9279                id::{ClientOrderId, StrategyId},
9280            };
9281            use rustrade_instrument::Side;
9282            use rustrade_instrument::exchange::ExchangeId;
9283            use rustrade_instrument::instrument::name::InstrumentNameExchange;
9284
9285            async fn open_against(
9286                status: u16,
9287                body: serde_json::Value,
9288            ) -> OrderState<AssetNameExchange, InstrumentNameExchange> {
9289                let server = MockServer::start().await;
9290                Mock::given(method("POST"))
9291                    .and(path("/v2/orders"))
9292                    .respond_with(ResponseTemplate::new(status).set_body_json(body))
9293                    .mount(&server)
9294                    .await;
9295                let instrument = InstrumentNameExchange::new("AAPL");
9296                client_for(&server)
9297                    .open_order(OrderRequestOpen {
9298                        key: OrderKey {
9299                            exchange: ExchangeId::AlpacaBroker,
9300                            instrument: &instrument,
9301                            strategy: StrategyId::new("test-strategy"),
9302                            cid: ClientOrderId::new("test-cid"),
9303                        },
9304                        state: RequestOpen::new(
9305                            Side::Sell,
9306                            None,
9307                            Decimal::new(10, 0),
9308                            OrderKind::Market,
9309                            TimeInForce::ImmediateOrCancel,
9310                        ),
9311                    })
9312                    .await
9313                    .state
9314            }
9315
9316            let rejected =
9317                |state: &OrderState<AssetNameExchange, InstrumentNameExchange>| match state {
9318                    OrderState::Inactive(InactiveOrderState::OpenFailed(OrderError::Rejected(
9319                        e,
9320                    ))) => e.clone(),
9321                    other => panic!("expected a rejection, got {other:?}"),
9322                };
9323
9324            let state = open_against(
9325                403,
9326                serde_json::json!({
9327                    "code": 40310000,
9328                    "message": "insufficient buying power"
9329                }),
9330            )
9331            .await;
9332            assert_eq!(
9333                rejected(&state),
9334                ApiError::BalanceInsufficient(None, "40310000 insufficient buying power".into())
9335            );
9336
9337            let state = open_against(
9338                403,
9339                serde_json::json!({
9340                    "code": 40310000,
9341                    "message": "account is not allowed to short"
9342                }),
9343            )
9344            .await;
9345            assert_eq!(
9346                rejected(&state),
9347                ApiError::BorrowRejected(BorrowReject::new(
9348                    BorrowRejectReason::BorrowDisabled,
9349                    Some("40310000".into()),
9350                    "account is not allowed to short".into(),
9351                ))
9352            );
9353
9354            // Which status Alpaca gives a non-shortable asset is unconfirmed; a 422 is read too.
9355            let state = open_against(
9356                422,
9357                serde_json::json!({
9358                    "code": 42210000,
9359                    "message": "asset XYZ cannot be sold short"
9360                }),
9361            )
9362            .await;
9363            assert_eq!(
9364                rejected(&state),
9365                ApiError::BorrowRejected(BorrowReject::new(
9366                    BorrowRejectReason::NotShortable,
9367                    Some("42210000".into()),
9368                    "asset XYZ cannot be sold short".into(),
9369                ))
9370            );
9371
9372            let state = open_against(403, serde_json::json!({ "message": "forbidden." })).await;
9373            assert!(
9374                matches!(rejected(&state), ApiError::Unauthenticated(_)),
9375                "{state:?}"
9376            );
9377        }
9378
9379        /// The bracket path maps an accepted (2xx) but unreadable response the same way: the
9380        /// parent fails as status unknown (`Connectivity`), never as a rejection.
9381        #[tokio::test]
9382        async fn open_bracket_order_with_an_undecodable_2xx_response_is_status_unknown() {
9383            use crate::error::OrderError;
9384            use crate::order::state::{InactiveOrderState, OrderState};
9385            use rustrade_instrument::instrument::name::InstrumentNameExchange;
9386
9387            let server = MockServer::start().await;
9388            Mock::given(method("POST"))
9389                .and(path("/v2/orders"))
9390                .respond_with(
9391                    ResponseTemplate::new(200).set_body_json(serde_json::json!({ "id": 1 })),
9392                )
9393                .mount(&server)
9394                .await;
9395
9396            let result = client_for(&server)
9397                .open_bracket_order(AlpacaBracketOrderRequest::new(
9398                    InstrumentNameExchange::new("SPY"),
9399                    crate::order::id::StrategyId::new("test"),
9400                    crate::order::id::ClientOrderId::new("test-bracket"),
9401                    Side::Buy,
9402                    Decimal::ONE,
9403                    Decimal::new(100, 0),
9404                    Decimal::new(120, 0),
9405                    Decimal::new(90, 0),
9406                    TimeInForce::GoodUntilCancelled { post_only: false },
9407                ))
9408                .await;
9409
9410            assert!(
9411                matches!(
9412                    result.parent.state,
9413                    OrderState::Inactive(InactiveOrderState::OpenFailed(OrderError::Connectivity(
9414                        _
9415                    )))
9416                ),
9417                "{:?}",
9418                result.parent.state
9419            );
9420        }
9421
9422        // -----------------------------------------------------------------------
9423        // recover_fills — what a recovered fill reports, and how it is keyed
9424        // -----------------------------------------------------------------------
9425        //
9426        // These drive `recover_fills` itself rather than re-deriving its arithmetic in the test.
9427        // A key that is correct in isolation is worth nothing if the function does not produce it.
9428
9429        /// The disconnect anchor the recovery tests read from.
9430        fn recovery_after() -> DateTime<Utc> {
9431            "2025-01-01T00:00:00Z".parse().expect("valid time")
9432        }
9433
9434        /// One FILL activity as Alpaca serves it. `cum_qty` is omitted entirely when `None`, which
9435        /// is how the pre-existing fallback path is reached.
9436        fn activity_json(
9437            id: &str,
9438            order_id: &str,
9439            qty: &str,
9440            cum_qty: Option<&str>,
9441        ) -> serde_json::Value {
9442            let mut v = serde_json::json!({
9443                "id": id,
9444                "order_id": order_id,
9445                "symbol": "SPY",
9446                "side": "buy",
9447                "price": "100.00",
9448                "qty": qty,
9449                "transaction_time": "2025-04-18T14:30:00Z"
9450            });
9451            if let Some(c) = cum_qty {
9452                v["cum_qty"] = serde_json::Value::String(c.to_string());
9453            }
9454            v
9455        }
9456
9457        /// The manager clears its reconnect failures only once a connection outlives a heartbeat
9458        /// window. A connection the server accepts and closes at once is a failure like any
9459        /// other, so the waits between such reconnects grow, and a server doing so to every
9460        /// connection exhausts the budget.
9461        #[tokio::test(start_paused = true)]
9462        async fn the_reconnect_budget_resets_only_once_a_connection_proves_stable() {
9463            use futures::{SinkExt as _, StreamExt as _};
9464            use tokio_tungstenite::tungstenite::Message;
9465
9466            // The third connection receives pings past the heartbeat window; the fourth stays open.
9467            const STABLE: Duration = Duration::from_secs(HEARTBEAT_TIMEOUT_SECS + 3);
9468            const DROPPED: Duration = Duration::from_millis(100);
9469            let lifetime = |n: usize| match n {
9470                0 | 1 => Some(DROPPED),
9471                2 => Some(STABLE),
9472                _ => None,
9473            };
9474
9475            // A WS server that authorises and acknowledges the listen, pings every second so the
9476            // heartbeat holds, and closes the `n`th connection `lifetime(n)` after its listen.
9477            let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
9478            let ws_url = format!("ws://{}", listener.local_addr().unwrap());
9479            let listens = Arc::new(parking_lot::Mutex::new(Vec::new()));
9480            let seen = Arc::clone(&listens);
9481            tokio::spawn(async move {
9482                let mut connections = 0..;
9483                while let Ok((stream, _)) = listener.accept().await {
9484                    // Without it, a frame can wait in the kernel for the client's delayed ACK,
9485                    // which is real time the paused clock does not wait for.
9486                    stream.set_nodelay(true).unwrap();
9487                    let seen = Arc::clone(&seen);
9488                    let lifetime = connections.next().and_then(lifetime);
9489                    tokio::spawn(async move {
9490                        let Ok(mut ws) = tokio_tungstenite::accept_async(stream).await else {
9491                            return;
9492                        };
9493                        for answer in [
9494                            r#"{"stream":"authorization","data":{"status":"authorized","action":"authenticate"}}"#,
9495                            r#"{"stream":"listening","data":{"streams":["trade_updates"]}}"#,
9496                        ] {
9497                            let Some(Ok(_)) = ws.next().await else {
9498                                return;
9499                            };
9500                            if ws.send(Message::text(answer)).await.is_err() {
9501                                return;
9502                            }
9503                        }
9504                        seen.lock().push(tokio::time::Instant::now());
9505                        let closes = async {
9506                            match lifetime {
9507                                Some(after) => tokio::time::sleep(after).await,
9508                                None => std::future::pending().await,
9509                            }
9510                        };
9511                        tokio::pin!(closes);
9512                        let mut pings = tokio::time::interval(Duration::from_secs(1));
9513                        loop {
9514                            tokio::select! {
9515                                () = &mut closes => {
9516                                    let _ = ws.close(None).await;
9517                                    return;
9518                                }
9519                                _ = pings.tick() => {
9520                                    if ws.send(Message::Ping(Default::default())).await.is_err() {
9521                                        return;
9522                                    }
9523                                }
9524                                message = ws.next() => {
9525                                    if !matches!(message, Some(Ok(_))) {
9526                                        return;
9527                                    }
9528                                }
9529                            }
9530                        }
9531                    });
9532                }
9533            });
9534            // REST answers nothing, so each reconnect's recovery fails at once. It is served on
9535            // this runtime, so the paused clock measures the waits exactly.
9536            let rest_url = crate::client::test_http::serve(|_, _| (404, String::new())).await;
9537            let mut config = AlpacaConfig::with_base_url("key".into(), "secret".into(), rest_url);
9538            config.ws_url_override = Some(ws_url);
9539            let (tx, _rx) = mpsc::unbounded_channel();
9540            tokio::spawn(connection_manager(
9541                tx,
9542                new_dedup_cache(),
9543                Arc::new(config),
9544                reqwest::Client::new(),
9545                Arc::new(RateLimitTracker::new()),
9546                KnownLiveOrders::shared(ExchangeId::AlpacaBroker),
9547                Vec::new(),
9548                None,
9549            ));
9550
9551            let deadline = tokio::time::Instant::now() + STABLE + Duration::from_secs(15);
9552            while listens.lock().len() < 4 {
9553                assert!(
9554                    tokio::time::Instant::now() < deadline,
9555                    "{} connections",
9556                    listens.lock().len()
9557                );
9558                tokio::time::sleep(Duration::from_millis(50)).await;
9559            }
9560
9561            // On the paused clock each wait is exact. Not reset by the second connection's
9562            // handshake: its close waits the second backoff. Reset by the third connection's
9563            // stability timer: its close waits the first backoff again, not the third.
9564            let initial = BackoffLimits::ACCOUNT_STREAM.initial;
9565            let waits: Vec<_> = listens.lock().windows(2).map(|w| w[1] - w[0]).collect();
9566            assert_eq!(
9567                waits,
9568                [DROPPED + initial, DROPPED + 2 * initial, STABLE + initial],
9569                "the waits between listens"
9570            );
9571        }
9572
9573        /// A reconnect whose connects all fail waits out every backoff and only then ends the
9574        /// stream, reporting the whole budget as spent.
9575        #[tokio::test(start_paused = true)]
9576        async fn failed_connects_end_the_stream_once_the_budget_is_spent() {
9577            // A port nothing listens on, so every connect fails.
9578            let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
9579            let ws_url = format!("ws://{}", listener.local_addr().unwrap());
9580            drop(listener);
9581            let mut config = AlpacaConfig::paper("key".into(), "secret".into());
9582            config.ws_url_override = Some(ws_url);
9583            let (tx, mut rx) = mpsc::unbounded_channel();
9584            let started = tokio::time::Instant::now();
9585
9586            connection_manager(
9587                tx,
9588                new_dedup_cache(),
9589                Arc::new(config),
9590                reqwest::Client::new(),
9591                Arc::new(RateLimitTracker::new()),
9592                KnownLiveOrders::shared(ExchangeId::AlpacaBroker),
9593                Vec::new(),
9594                None,
9595            )
9596            .await;
9597
9598            let Ok(event) = rx.try_recv() else {
9599                panic!("the stream ended without reporting why");
9600            };
9601            let AccountEventKind::StreamTerminated(
9602                StreamTerminationReason::ReconnectBudgetExhausted {
9603                    attempts,
9604                    last_error,
9605                },
9606            ) = event.kind
9607            else {
9608                panic!("unexpected event: {event:?}");
9609            };
9610            assert_eq!(attempts, BackoffLimits::ACCOUNT_STREAM.max_attempts);
9611            assert!(
9612                !last_error.is_empty(),
9613                "the last connect failure is reported"
9614            );
9615            // 1 + 2 + 4 + 8 + 16 s, then 30 s for each of the five attempts left.
9616            assert!(
9617                started.elapsed() >= Duration::from_secs(181),
9618                "{:?}",
9619                started.elapsed()
9620            );
9621            assert!(rx.try_recv().is_err(), "nothing follows the termination");
9622        }
9623
9624        /// A reconnect reads the missed fills from Alpaca's time at the last message, as the
9625        /// stream's events showed it: with this host's clock two minutes ahead of Alpaca's, the
9626        /// read starts at Alpaca's time, not this host's, so the fills Alpaca stamped during the
9627        /// outage are read.
9628        #[tokio::test]
9629        async fn a_reconnect_reads_missed_fills_from_alpacas_time_when_the_host_is_ahead() {
9630            use futures::{SinkExt as _, StreamExt as _};
9631            use tokio_tungstenite::tungstenite::Message;
9632
9633            // Alpaca's clock reads two minutes behind this host's.
9634            let stamped = Utc::now() - TimeDelta::seconds(120);
9635            let update = format!(
9636                r#"{{"stream":"trade_updates","data":{{"event":"new","timestamp":"{}","order":{{"id":"id-a","client_order_id":"a","symbol":"SPY","qty":"2","filled_qty":"0","side":"buy","type":"limit","time_in_force":"gtc","limit_price":"100","status":"new"}}}}}}"#,
9637                stamped.to_rfc3339_opts(SecondsFormat::Millis, true)
9638            );
9639
9640            // A WS server that authorises and acknowledges the listen, sends the first
9641            // connection the update and closes it, and keeps the second open.
9642            let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
9643            let ws_url = format!("ws://{}", listener.local_addr().unwrap());
9644            tokio::spawn(async move {
9645                let mut connections = 0..;
9646                while let Ok((stream, _)) = listener.accept().await {
9647                    let first = connections.next() == Some(0);
9648                    let update = update.clone();
9649                    tokio::spawn(async move {
9650                        let Ok(mut ws) = tokio_tungstenite::accept_async(stream).await else {
9651                            return;
9652                        };
9653                        for answer in [
9654                            r#"{"stream":"authorization","data":{"status":"authorized","action":"authenticate"}}"#,
9655                            r#"{"stream":"listening","data":{"streams":["trade_updates"]}}"#,
9656                        ] {
9657                            let Some(Ok(_)) = ws.next().await else {
9658                                return;
9659                            };
9660                            if ws.send(Message::text(answer)).await.is_err() {
9661                                return;
9662                            }
9663                        }
9664                        if first {
9665                            let _ = ws.send(Message::text(update)).await;
9666                            let _ = ws.close(None).await;
9667                        }
9668                        while let Some(Ok(_)) = ws.next().await {}
9669                    });
9670                }
9671            });
9672            let rest = MockServer::start().await;
9673            Mock::given(method("GET"))
9674                .and(path("/v2/account/activities"))
9675                .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([])))
9676                .mount(&rest)
9677                .await;
9678            let mut config = AlpacaConfig::with_base_url("key".into(), "secret".into(), rest.uri());
9679            config.ws_url_override = Some(ws_url);
9680            let (tx, _rx) = mpsc::unbounded_channel();
9681            let task = tokio::spawn(connection_manager(
9682                tx,
9683                new_dedup_cache(),
9684                Arc::new(config),
9685                reqwest::Client::new(),
9686                Arc::new(RateLimitTracker::new()),
9687                KnownLiveOrders::shared(ExchangeId::AlpacaBroker),
9688                Vec::new(),
9689                None,
9690            ));
9691
9692            // Activities are read only on a reconnect, so the first read is the reconnect's.
9693            let after = tokio::time::timeout(Duration::from_secs(10), async {
9694                loop {
9695                    let requests = rest.received_requests().await.unwrap_or_default();
9696                    if let Some(after) = requests
9697                        .iter()
9698                        .filter(|request| request.url.path() == "/v2/account/activities")
9699                        .find_map(|request| {
9700                            request
9701                                .url
9702                                .query_pairs()
9703                                .find(|(key, _)| key == "after")
9704                                .map(|(_, after)| after.into_owned())
9705                        })
9706                    {
9707                        break after;
9708                    }
9709                    tokio::time::sleep(Duration::from_millis(20)).await;
9710                }
9711            })
9712            .await
9713            .expect("the reconnect reads the missed fills");
9714            let after = DateTime::parse_from_rfc3339(&after)
9715                .unwrap()
9716                .with_timezone(&Utc);
9717
9718            // Alpaca's time at the last message, less the lookback, and not this host's.
9719            let lookback = TimeDelta::milliseconds(SIGNAL_RECOVERY_LOOKBACK_MS);
9720            assert!(
9721                after <= stamped + TimeDelta::seconds(1) - lookback,
9722                "{after}"
9723            );
9724            assert!(
9725                after >= stamped - TimeDelta::seconds(1) - lookback,
9726                "{after}"
9727            );
9728
9729            task.abort();
9730        }
9731
9732        /// Serve `activities` from a mock and run `recover_fills` against it, returning every
9733        /// event it forwarded.
9734        async fn drive_recover_fills_with(
9735            activities: Vec<serde_json::Value>,
9736            dedup: SharedDedupCache,
9737            known: &SharedKnownLiveOrders,
9738        ) -> Vec<UnindexedAccountEvent> {
9739            let server = MockServer::start().await;
9740            Mock::given(method("GET"))
9741                .and(path("/v2/account/activities"))
9742                .respond_with(
9743                    ResponseTemplate::new(200).set_body_json(serde_json::Value::Array(activities)),
9744                )
9745                .mount(&server)
9746                .await;
9747
9748            let http = reqwest::Client::new();
9749            let rl = RateLimitTracker::new();
9750            let (tx, mut rx) = mpsc::unbounded_channel();
9751            let outcome = recover_fills(
9752                &http,
9753                &rl,
9754                &[],
9755                &server.uri(),
9756                recovery_after(),
9757                &tx,
9758                &dedup,
9759                known,
9760            )
9761            .await;
9762            assert_eq!(outcome, Ok(()), "a full read leaves nothing unrecovered");
9763            drop(tx);
9764
9765            let mut out = Vec::new();
9766            while let Ok(event) = rx.try_recv() {
9767                out.push(event);
9768            }
9769            out
9770        }
9771
9772        async fn drive_recover_fills(
9773            activities: Vec<serde_json::Value>,
9774        ) -> Vec<UnindexedAccountEvent> {
9775            drive_recover_fills_with(
9776                activities,
9777                new_dedup_cache(),
9778                &KnownLiveOrders::shared(ExchangeId::AlpacaBroker),
9779            )
9780            .await
9781        }
9782
9783        /// Run `recover_fills` for every instrument against `server`, returning what it left
9784        /// unread and every event it forwarded.
9785        async fn recover_fills_from(
9786            server: &MockServer,
9787        ) -> (Result<(), UnreadFills>, Vec<UnindexedAccountEvent>) {
9788            let (tx, mut rx) = mpsc::unbounded_channel();
9789            let outcome = recover_fills(
9790                &reqwest::Client::new(),
9791                &RateLimitTracker::new(),
9792                &[],
9793                &server.uri(),
9794                recovery_after(),
9795                &tx,
9796                &new_dedup_cache(),
9797                &KnownLiveOrders::shared(ExchangeId::AlpacaBroker),
9798            )
9799            .await;
9800            drop(tx);
9801            (outcome, std::iter::from_fn(|| rx.try_recv().ok()).collect())
9802        }
9803
9804        /// A recovery read that fails delivers nothing and leaves every fill since the disconnect
9805        /// unread, with the request's error.
9806        #[tokio::test]
9807        async fn a_failed_recovery_read_leaves_every_fill_since_the_disconnect_unread() {
9808            let server = MockServer::start().await;
9809            Mock::given(method("GET"))
9810                .and(path("/v2/account/activities"))
9811                .respond_with(ResponseTemplate::new(500).set_body_string("unavailable"))
9812                .mount(&server)
9813                .await;
9814
9815            let (outcome, events) = recover_fills_from(&server).await;
9816
9817            let Err(UnreadFills {
9818                start,
9819                reason: FillRecoveryFailure::Request(_),
9820            }) = outcome
9821            else {
9822                panic!("expected the request's failure, got {outcome:?}");
9823            };
9824            assert_eq!(start, recovery_after(), "from the disconnect");
9825            assert!(events.is_empty(), "nothing forwarded: {events:?}");
9826        }
9827
9828        /// A recovery read that stops at the page cap delivers the fills it read, then leaves
9829        /// those from the millisecond of the last one on unread: Alpaca orders a millisecond's
9830        /// fills by id, so the cut may have passed over an earlier one in it. A last time that
9831        /// does not parse falls back to the latest that does, and none parsing to the disconnect.
9832        #[tokio::test]
9833        async fn a_truncated_recovery_read_delivers_what_it_read_and_leaves_the_rest_unread() {
9834            let time = |s: &str| s.parse::<DateTime<Utc>>().expect("valid time");
9835            // The page's other activities are at 14:30:00.
9836            let cases = [
9837                (
9838                    Some("2025-04-18T14:30:01.234567Z"),
9839                    time("2025-04-18T14:30:01.234Z"),
9840                ),
9841                (Some("not a time"), time("2025-04-18T14:30:00Z")),
9842                (None, recovery_after()),
9843            ];
9844            for (last_time, expected_start) in cases {
9845                let mut page = make_activities_json(ALPACA_MAX_ACTIVITIES, "act");
9846                let Some(activities) = page.as_array_mut() else {
9847                    panic!("an array of activities");
9848                };
9849                match last_time {
9850                    Some(last_time) => {
9851                        if let Some(last) = activities.last_mut() {
9852                            last["transaction_time"] =
9853                                serde_json::Value::String(last_time.to_string());
9854                        }
9855                    }
9856                    None => {
9857                        for activity in activities.iter_mut() {
9858                            activity["transaction_time"] =
9859                                serde_json::Value::String("not a time".to_string());
9860                        }
9861                    }
9862                }
9863                let server = MockServer::start().await;
9864                Mock::given(method("GET"))
9865                    .and(path("/v2/account/activities"))
9866                    .respond_with(ResponseTemplate::new(200).set_body_json(page))
9867                    .mount(&server)
9868                    .await;
9869
9870                let (outcome, events) = recover_fills_from(&server).await;
9871
9872                let fills_read = MAX_ACTIVITY_PAGES * ALPACA_MAX_ACTIVITIES;
9873                assert_eq!(
9874                    outcome,
9875                    Err(UnreadFills {
9876                        start: expected_start,
9877                        reason: FillRecoveryFailure::Truncated { fills_read },
9878                    }),
9879                    "last time {last_time:?}"
9880                );
9881                assert_eq!(events.len(), fills_read, "every fill read is forwarded");
9882            }
9883        }
9884
9885        /// One account-wide read serves every instrument the stream covers, so one give-up names
9886        /// them all: the stream's list, or every instrument when it has none. It was read once.
9887        #[test]
9888        fn a_fill_recovery_give_up_covers_every_instrument_the_stream_does() {
9889            let start = recovery_after();
9890            let end = start + chrono::Duration::minutes(5);
9891            let spy = InstrumentNameExchange::new("SPY");
9892            let qqq = InstrumentNameExchange::new("QQQ");
9893            let cases = [
9894                (vec![], FillRecoveryScope::AllInstruments),
9895                (
9896                    vec![spy.clone(), qqq.clone()],
9897                    FillRecoveryScope::Instruments(vec![spy, qqq]),
9898                ),
9899            ];
9900            for (instruments, scope) in cases {
9901                let reason = FillRecoveryFailure::TimedOut { timeout_secs: 30 };
9902                let unread = UnreadFills {
9903                    start,
9904                    reason: reason.clone(),
9905                };
9906                assert_eq!(
9907                    fill_recovery_gave_up(&instruments, unread, end),
9908                    UnindexedAccountEvent::new(
9909                        ExchangeId::AlpacaBroker,
9910                        AccountEventKind::FillRecoveryGaveUp(FillRecoveryGap::new(
9911                            scope, start, end, 1, reason,
9912                        )),
9913                    )
9914                );
9915            }
9916        }
9917
9918        /// A truncated read can return fills stamped after the moment recovery began, by a venue
9919        /// clock ahead of this host's. The give-up is still sent, its span ending at its start
9920        /// rather than before it.
9921        #[test]
9922        fn a_read_that_reached_past_the_recovery_start_still_reports() {
9923            let end = recovery_after();
9924            let start = end + chrono::Duration::seconds(1);
9925            let unread = UnreadFills {
9926                start,
9927                reason: FillRecoveryFailure::Truncated { fills_read: 5_000 },
9928            };
9929            let event = fill_recovery_gave_up(&[], unread, end);
9930            let AccountEventKind::FillRecoveryGaveUp(gave_up) = event.kind else {
9931                panic!("expected FillRecoveryGaveUp, got {event:?}");
9932            };
9933            assert_eq!((gave_up.start, gave_up.end), (start, start));
9934        }
9935
9936        /// Run `recover_fills_or_report` for `instruments` against `server` within `timeout`,
9937        /// returning every event it sent.
9938        async fn recover_fills_or_report_from(
9939            server: &MockServer,
9940            instruments: &[InstrumentNameExchange],
9941            timeout: Duration,
9942        ) -> Vec<UnindexedAccountEvent> {
9943            let (tx, mut rx) = mpsc::unbounded_channel();
9944            recover_fills_or_report(
9945                &reqwest::Client::new(),
9946                &RateLimitTracker::new(),
9947                instruments,
9948                &server.uri(),
9949                recovery_after(),
9950                Utc::now(),
9951                timeout,
9952                &tx,
9953                &new_dedup_cache(),
9954                &KnownLiveOrders::shared(ExchangeId::AlpacaBroker),
9955            )
9956            .await;
9957            drop(tx);
9958            std::iter::from_fn(|| rx.try_recv().ok()).collect()
9959        }
9960
9961        /// A recovery that times out forwards nothing and reports every fill since the disconnect,
9962        /// for the stream's instruments.
9963        #[tokio::test]
9964        async fn a_timed_out_recovery_reports_every_fill_since_the_disconnect() {
9965            let server = MockServer::start().await;
9966            Mock::given(method("GET"))
9967                .and(path("/v2/account/activities"))
9968                .respond_with(
9969                    ResponseTemplate::new(200)
9970                        .set_body_json(serde_json::json!([]))
9971                        .set_delay(Duration::from_secs(3_600)),
9972                )
9973                .mount(&server)
9974                .await;
9975            let spy = InstrumentNameExchange::new("SPY");
9976
9977            let events = recover_fills_or_report_from(
9978                &server,
9979                std::slice::from_ref(&spy),
9980                Duration::from_millis(50),
9981            )
9982            .await;
9983
9984            let [event] = events.as_slice() else {
9985                panic!("only the give-up is sent: {events:?}");
9986            };
9987            let AccountEventKind::FillRecoveryGaveUp(gave_up) = &event.kind else {
9988                panic!("expected FillRecoveryGaveUp, got {event:?}");
9989            };
9990            assert_eq!(gave_up.scope, FillRecoveryScope::Instruments(vec![spy]));
9991            assert_eq!(gave_up.start, recovery_after(), "from the disconnect");
9992            assert!(gave_up.end >= gave_up.start);
9993            assert_eq!(gave_up.attempts, 1);
9994            assert!(
9995                matches!(gave_up.reason, FillRecoveryFailure::TimedOut { .. }),
9996                "{:?}",
9997                gave_up.reason
9998            );
9999        }
10000
10001        /// A truncated recovery sends every fill it read before the give-up, so a consumer sees the
10002        /// report after what was delivered.
10003        #[tokio::test]
10004        async fn a_truncated_recovery_reports_after_the_fills_it_read() {
10005            let server = MockServer::start().await;
10006            Mock::given(method("GET"))
10007                .and(path("/v2/account/activities"))
10008                .respond_with(
10009                    ResponseTemplate::new(200)
10010                        .set_body_json(make_activities_json(ALPACA_MAX_ACTIVITIES, "act")),
10011                )
10012                .mount(&server)
10013                .await;
10014
10015            let events = recover_fills_or_report_from(&server, &[], Duration::from_secs(30)).await;
10016
10017            let Some((last, fills)) = events.split_last() else {
10018                panic!("events are sent");
10019            };
10020            assert_eq!(fills.len(), MAX_ACTIVITY_PAGES * ALPACA_MAX_ACTIVITIES);
10021            assert!(
10022                fills
10023                    .iter()
10024                    .all(|event| matches!(event.kind, AccountEventKind::Trade(_))),
10025                "every fill comes first"
10026            );
10027            let AccountEventKind::FillRecoveryGaveUp(gave_up) = &last.kind else {
10028                panic!("the give-up comes last, got {last:?}");
10029            };
10030            assert_eq!(gave_up.scope, FillRecoveryScope::AllInstruments);
10031            assert!(matches!(
10032                gave_up.reason,
10033                FillRecoveryFailure::Truncated { .. }
10034            ));
10035        }
10036
10037        /// A FILL activity's execution id is the part of its `id` after `::`; an id without one is
10038        /// taken whole.
10039        #[test]
10040        fn an_activity_id_names_its_execution_after_the_separator() {
10041            assert_eq!(
10042                activity_execution_id("20261005041849348::524b1902-817e-446c-825b-a9fcfebbc17e"),
10043                "524b1902-817e-446c-825b-a9fcfebbc17e"
10044            );
10045            assert_eq!(activity_execution_id("act-1"), "act-1");
10046            assert_eq!(
10047                activity_execution_id("20261005041849348::"),
10048                "20261005041849348::"
10049            );
10050        }
10051
10052        /// One fill carries one `TradeId` however it is delivered: over the account stream, by
10053        /// reconnect recovery, and by `fetch_trades`. So a consumer reconciling with `fetch_trades`
10054        /// matches what the stream already delivered.
10055        #[tokio::test]
10056        async fn a_fill_has_one_trade_id_on_the_stream_in_recovery_and_from_fetch_trades() {
10057            use crate::client::ExecutionClient;
10058
10059            let execution_id = "524b1902-817e-446c-825b-a9fcfebbc17e";
10060            let activity = activity_json(
10061                &format!("20250418103000000::{execution_id}"),
10062                "ord-1",
10063                "2",
10064                Some("2"),
10065            );
10066
10067            let [streamed, _snapshot] = convert_trade_update(AlpacaTradeUpdate {
10068                event: SmolStr::new("fill"),
10069                execution_id: Some(execution_id),
10070                previous_execution_id: None,
10071                order: super::make_order_ws("ord-1", "SPY", "buy", "2"),
10072                price: Some("100.00"),
10073                qty: Some("2"),
10074                timestamp: Some("2025-04-18T14:30:00Z"),
10075            });
10076            let Some(streamed) = streamed else {
10077                panic!("a fill converts to a Trade");
10078            };
10079
10080            let recovered = drive_recover_fills(vec![activity.clone()]).await;
10081            let [recovered] = recovered.as_slice() else {
10082                panic!("one fill recovered: {recovered:?}");
10083            };
10084
10085            let server = MockServer::start().await;
10086            Mock::given(method("GET"))
10087                .and(path("/v2/account/activities"))
10088                .respond_with(
10089                    ResponseTemplate::new(200)
10090                        .set_body_json(serde_json::Value::Array(vec![activity])),
10091                )
10092                .mount(&server)
10093                .await;
10094            let Ok(fetched) = client_for(&server)
10095                .fetch_trades(recovery_after(), Utc::now(), &[])
10096                .await
10097            else {
10098                panic!("fetch_trades reads the activity");
10099            };
10100            let [fetched] = fetched.trades.as_slice() else {
10101                panic!("one fill fetched: {fetched:?}");
10102            };
10103
10104            assert_eq!(trade_of(&streamed).id.0.as_str(), execution_id);
10105            assert_eq!(trade_of(recovered).id.0.as_str(), execution_id);
10106            assert_eq!(fetched.id.0.as_str(), execution_id);
10107        }
10108
10109        // -----------------------------------------------------------------------
10110        // fetch_trades — bounded reads of a span, resumed by the caller
10111        // -----------------------------------------------------------------------
10112
10113        fn time(s: &str) -> DateTime<Utc> {
10114            s.parse().expect("valid time")
10115        }
10116
10117        /// Alpaca's activities endpoint over `activities`, which are sorted by id, as observed on
10118        /// paper: `after` matches an activity whose time, rounded down to the millisecond, is at
10119        /// or after it, `until` one whose rounded time is before it, and `page_token` resumes
10120        /// after the activity it names.
10121        struct ActivitiesVenue {
10122            activities: Vec<serde_json::Value>,
10123        }
10124
10125        impl Respond for ActivitiesVenue {
10126            fn respond(&self, request: &Request) -> ResponseTemplate {
10127                let query: std::collections::HashMap<String, String> =
10128                    request.url.query_pairs().into_owned().collect();
10129                let bound = |name: &str| query.get(name).map(|s| time(s));
10130                let (after, until) = (bound("after"), bound("until"));
10131                let size: usize = query
10132                    .get("page_size")
10133                    .and_then(|s| s.parse().ok())
10134                    .unwrap_or(ALPACA_MAX_ACTIVITIES);
10135                let in_bounds = self.activities.iter().filter(|activity| {
10136                    let at =
10137                        floor_millis(time(activity["transaction_time"].as_str().expect("a time")));
10138                    after.is_none_or(|after| at >= after) && until.is_none_or(|until| at < until)
10139                });
10140                let page: Vec<_> = match query.get("page_token") {
10141                    Some(token) => in_bounds
10142                        .skip_while(|activity| activity["id"] != token.as_str())
10143                        .skip(1)
10144                        .take(size)
10145                        .cloned()
10146                        .collect(),
10147                    None => in_bounds.take(size).cloned().collect(),
10148                };
10149                ResponseTemplate::new(200).set_body_json(page)
10150            }
10151        }
10152
10153        /// A FILL activity on `symbol` with id `id` at `transaction_time`.
10154        fn fill_at(id: &str, symbol: &str, transaction_time: DateTime<Utc>) -> serde_json::Value {
10155            serde_json::json!({
10156                "id": id,
10157                "order_id": format!("ord-{id}"),
10158                "symbol": symbol,
10159                "side": "buy",
10160                "price": "100.00",
10161                "qty": "1",
10162                "cum_qty": "1",
10163                "transaction_time": transaction_time.to_rfc3339_opts(SecondsFormat::Micros, true),
10164            })
10165        }
10166
10167        async fn serve_activities(activities: Vec<serde_json::Value>) -> MockServer {
10168            let server = MockServer::start().await;
10169            Mock::given(method("GET"))
10170                .and(path("/v2/account/activities"))
10171                .respond_with(ActivitiesVenue { activities })
10172                .mount(&server)
10173                .await;
10174            server
10175        }
10176
10177        /// A non-trade activity of `activity_type` and `sub_type`, for `net_amount`, created at
10178        /// `created_at`.
10179        fn non_trade(
10180            id: &str,
10181            activity_type: &str,
10182            sub_type: Option<&str>,
10183            net_amount: &str,
10184            created_at: &str,
10185        ) -> serde_json::Value {
10186            let mut activity = serde_json::json!({
10187                "id": id, "activity_type": activity_type, "net_amount": net_amount,
10188                "created_at": created_at, "date": &created_at[..10], "currency": "USD",
10189                "status": "executed",
10190            });
10191            if let Some(sub_type) = sub_type {
10192                activity["activity_sub_type"] = serde_json::json!(sub_type);
10193            }
10194            activity
10195        }
10196
10197        /// The query of every request `server` received.
10198        async fn query_pairs(server: &MockServer) -> Vec<Vec<(String, String)>> {
10199            let Some(requests) = server.received_requests().await else {
10200                panic!("requests are recorded");
10201            };
10202            requests
10203                .iter()
10204                .map(|request| request.url.query_pairs().into_owned().collect())
10205                .collect()
10206        }
10207
10208        #[tokio::test]
10209        async fn fetch_cash_flows_maps_each_activity_type_and_leaves_out_what_it_cannot_use() {
10210            let at = "2025-04-18T05:00:00Z";
10211            let mut fee = non_trade("fee", "FEE", Some("TAF"), "-0.02", at);
10212            fee["symbol"] = serde_json::json!("SPY");
10213            let mut canceled = non_trade("canceled", "FEE", Some("REG"), "-0.01", at);
10214            canceled["status"] = serde_json::json!("canceled");
10215            let mut no_amount = non_trade("no-amount", "FEE", None, "0", at);
10216            no_amount.as_object_mut().unwrap().remove("net_amount");
10217            let mut no_created_at = non_trade("no-time", "PTC", None, "-0.50", at);
10218            no_created_at.as_object_mut().unwrap().remove("created_at");
10219            let mut bad_time = non_trade("bad-time", "PTC", None, "-0.50", at);
10220            bad_time["created_at"] = serde_json::json!("not-a-time");
10221            let mut corrected = non_trade("correct", "PTC", None, "-0.40", at);
10222            corrected["status"] = serde_json::json!("correct");
10223            let mut euro = non_trade("euro", "PTR", None, "0.30", at);
10224            euro["currency"] = serde_json::json!("EUR");
10225            let server = MockServer::start().await;
10226            Mock::given(method("GET"))
10227                .and(path("/v2/account/activities"))
10228                .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([
10229                    non_trade("mgn", "INT", Some("MGN"), "-12.50", at),
10230                    non_trade("int-paid", "INT", None, "-3.00", at),
10231                    non_trade("int-credit", "INT", Some("CDT"), "1.00", at),
10232                    fee,
10233                    canceled,
10234                    no_amount,
10235                    no_created_at,
10236                    bad_time,
10237                    corrected,
10238                    euro,
10239                    non_trade("late", "FEE", None, "-0.01", "2025-04-20T05:00:00Z"),
10240                ])))
10241                .mount(&server)
10242                .await;
10243            let client = client_for(&server);
10244
10245            let read = client
10246                .fetch_cash_flows(time("2025-04-18T00:00:00Z"), time("2025-04-19T00:00:00Z"))
10247                .await
10248                .unwrap();
10249
10250            assert_eq!(read.resume, None);
10251            let kinds: Vec<_> = read
10252                .flows
10253                .iter()
10254                .map(|flow| (flow.id.clone().unwrap().0.to_string(), flow.kind.clone()))
10255                .collect();
10256            let interest = CashFlowKind::MarginInterest {
10257                rate: None,
10258                principal: None,
10259                timing: None,
10260            };
10261            assert_eq!(
10262                kinds,
10263                [
10264                    ("mgn", interest.clone()),
10265                    ("int-paid", interest),
10266                    ("int-credit", CashFlowKind::Other("INT.CDT".into())),
10267                    ("fee", CashFlowKind::Other("FEE.TAF".into())),
10268                    ("correct", CashFlowKind::Other("PTC".into())),
10269                    ("euro", CashFlowKind::Rebate),
10270                ]
10271                .map(|(id, kind)| (id.to_owned(), kind))
10272            );
10273            let by_id = |id: &str| {
10274                read.flows
10275                    .iter()
10276                    .find(|flow| flow.id == Some(CashFlowId::new(id)))
10277                    .unwrap()
10278            };
10279            assert_eq!(by_id("mgn").amount, Decimal::new(-1250, 2));
10280            assert_eq!(by_id("mgn").asset, AssetNameExchange::new("USD"));
10281            assert_eq!(by_id("mgn").instrument, None);
10282            assert_eq!(by_id("mgn").time_exchange, time(at));
10283            assert_eq!(
10284                by_id("fee").instrument,
10285                Some(InstrumentNameExchange::new("SPY"))
10286            );
10287            assert_eq!(
10288                by_id("correct").amount,
10289                Decimal::new(-40, 2),
10290                "a correction is returned as it is"
10291            );
10292            assert_eq!(by_id("euro").asset, AssetNameExchange::new("EUR"));
10293
10294            let queries = query_pairs(&server).await;
10295            let [query] = queries.as_slice() else {
10296                panic!("one request: {queries:?}");
10297            };
10298            assert!(
10299                query.contains(&("activity_types".into(), "INT,FEE,PTC,PTR".into())),
10300                "{query:?}"
10301            );
10302        }
10303
10304        #[tokio::test]
10305        async fn fetch_trades_asks_for_fills_by_the_documented_parameter() {
10306            use crate::client::ExecutionClient;
10307
10308            let server = serve_activities(Vec::new()).await;
10309            let client = client_for(&server);
10310
10311            let read = client
10312                .fetch_trades(
10313                    time("2025-04-18T00:00:00Z"),
10314                    time("2025-04-19T00:00:00Z"),
10315                    &[],
10316                )
10317                .await
10318                .unwrap();
10319
10320            assert!(read.trades.is_empty());
10321            let queries = query_pairs(&server).await;
10322            let [query] = queries.as_slice() else {
10323                panic!("one request: {queries:?}");
10324            };
10325            assert!(
10326                query.contains(&("activity_types".into(), "FILL".into())),
10327                "{query:?}"
10328            );
10329            assert!(
10330                !query.iter().any(|(name, _)| name == "activity_type"),
10331                "{query:?}"
10332            );
10333        }
10334
10335        #[tokio::test]
10336        async fn fetch_cash_flows_reads_nothing_for_an_empty_span() {
10337            let server = MockServer::start().await;
10338            let client = client_for(&server);
10339
10340            let read = client
10341                .fetch_cash_flows(time("2025-04-19T00:00:00Z"), time("2025-04-18T00:00:00Z"))
10342                .await
10343                .unwrap();
10344
10345            assert!(read.flows.is_empty() && read.resume.is_none());
10346            assert!(query_pairs(&server).await.is_empty());
10347        }
10348
10349        /// A span holding more activities than one call reads returns those it read and says
10350        /// where to read on from: the millisecond of the last one's creation.
10351        #[tokio::test]
10352        async fn fetch_cash_flows_that_stops_at_its_bound_says_where_to_read_on() {
10353            let base = time("2025-04-18T05:00:00Z");
10354            let activities: Vec<_> = (0..6_000_i64)
10355                .map(|n| {
10356                    let created =
10357                        (base + TimeDelta::seconds(n)).to_rfc3339_opts(SecondsFormat::Micros, true);
10358                    non_trade(&format!("{n:017}::fee"), "FEE", None, "-0.01", &created)
10359                })
10360                .collect();
10361            // Every page answers in order after the token, as Alpaca pages by id.
10362            struct Paged(Vec<serde_json::Value>);
10363            impl Respond for Paged {
10364                fn respond(&self, request: &Request) -> ResponseTemplate {
10365                    let token = request
10366                        .url
10367                        .query_pairs()
10368                        .find(|(name, _)| name == "page_token")
10369                        .map(|(_, value)| value.into_owned());
10370                    let page: Vec<_> = self
10371                        .0
10372                        .iter()
10373                        .skip_while(|activity| {
10374                            token
10375                                .as_deref()
10376                                .is_some_and(|token| activity["id"] != token)
10377                        })
10378                        .skip(usize::from(token.is_some()))
10379                        .take(ALPACA_MAX_ACTIVITIES)
10380                        .cloned()
10381                        .collect();
10382                    ResponseTemplate::new(200).set_body_json(page)
10383                }
10384            }
10385            let server = MockServer::start().await;
10386            Mock::given(method("GET"))
10387                .and(path("/v2/account/activities"))
10388                .respond_with(Paged(activities))
10389                .mount(&server)
10390                .await;
10391            let client = client_for(&server);
10392
10393            let read = client
10394                .fetch_cash_flows(base, base + TimeDelta::days(1))
10395                .await
10396                .unwrap();
10397
10398            assert_eq!(read.flows.len(), 5_000);
10399            assert_eq!(read.resume, Some(base + TimeDelta::seconds(4_999)));
10400        }
10401
10402        /// A read whose every page holds activities of the span's first millisecond cannot
10403        /// advance, so it fails rather than return a `resume` that would loop.
10404        #[tokio::test]
10405        async fn fetch_cash_flows_that_cannot_advance_fails() {
10406            let at = "2025-04-18T05:00:00Z";
10407            let full: Vec<_> = (0..ALPACA_MAX_ACTIVITIES)
10408                .map(|n| non_trade(&format!("fee-{n}"), "FEE", None, "-0.01", at))
10409                .collect();
10410            let server = MockServer::start().await;
10411            Mock::given(method("GET"))
10412                .and(path("/v2/account/activities"))
10413                .respond_with(ResponseTemplate::new(200).set_body_json(full))
10414                .mount(&server)
10415                .await;
10416            let client = client_for(&server);
10417
10418            let result = client
10419                .fetch_cash_flows(time(at), time("2025-04-19T00:00:00Z"))
10420                .await;
10421
10422            assert!(
10423                matches!(result, Err(UnindexedClientError::Internal(_))),
10424                "{result:?}"
10425            );
10426        }
10427
10428        /// An option activity of `activity_type` on `symbol`, for the signed `qty`, dated `date`,
10429        /// with no creation time.
10430        fn option_activity(
10431            id: &str,
10432            activity_type: &str,
10433            symbol: &str,
10434            qty: &str,
10435            date: &str,
10436        ) -> serde_json::Value {
10437            serde_json::json!({
10438                "id": id, "activity_type": activity_type, "symbol": symbol, "qty": qty,
10439                "date": date, "net_amount": "0", "status": "executed",
10440            })
10441        }
10442
10443        /// The share leg (`OPTRD`) of an exercise or assignment, at `price`.
10444        fn share_leg(
10445            id: &str,
10446            symbol: &str,
10447            qty: &str,
10448            price: &str,
10449            date: &str,
10450        ) -> serde_json::Value {
10451            let mut activity = option_activity(id, "OPTRD", symbol, qty, date);
10452            activity["price"] = serde_json::json!(price);
10453            activity
10454        }
10455
10456        async fn serve_json(activities: serde_json::Value) -> MockServer {
10457            let server = MockServer::start().await;
10458            Mock::given(method("GET"))
10459                .and(path("/v2/account/activities"))
10460                .respond_with(ResponseTemplate::new(200).set_body_json(activities))
10461                .mount(&server)
10462                .await;
10463            server
10464        }
10465
10466        #[test]
10467        fn occ_contract_reads_the_root_kind_and_strike() {
10468            assert_eq!(
10469                occ_contract("QS240301C00006500"),
10470                Some(("QS", true, Decimal::new(65, 1)))
10471            );
10472            assert_eq!(
10473                occ_contract("AAPL240119P00150000"),
10474                Some(("AAPL", false, Decimal::from(150)))
10475            );
10476            for not_occ in [
10477                "QS",
10478                "240301C00006500",
10479                "QS240301X00006500",
10480                "QS2403O1C00006500",
10481            ] {
10482                assert_eq!(occ_contract(not_occ), None, "{not_occ}");
10483            }
10484        }
10485
10486        #[tokio::test]
10487        async fn fetch_option_events_maps_each_activity_and_pairs_the_share_legs() {
10488            let day = "2024-03-01";
10489            let mut expiry = option_activity("exp", "OPEXP", "QS240301C00010500", "-1", day);
10490            expiry["created_at"] = serde_json::json!("2024-03-01T21:30:00Z");
10491            let mut canceled = option_activity("canceled", "OPEXP", "QS240301P00000500", "-1", day);
10492            canceled["status"] = serde_json::json!("canceled");
10493            let server = serve_json(serde_json::json!([
10494                expiry,
10495                option_activity("exc", "OPEXC", "QS240301C00006500", "-1", day),
10496                share_leg("exc-shares", "QS", "100", "6.5", day),
10497                option_activity("asn", "OPASN", "QS240301P00009000", "1", day),
10498                share_leg("asn-shares", "QS", "100", "9", day),
10499                share_leg("orphan", "QS", "-100", "7", day),
10500                canceled,
10501                option_activity("no-price", "OPTRD", "QS", "100", day),
10502                option_activity("no-qty", "OPEXP", "QS240301C00011000", "0", day),
10503                option_activity("later", "OPEXP", "QS240308C00010500", "-1", "2024-03-02"),
10504            ]))
10505            .await;
10506            let client = client_for(&server);
10507
10508            let read = client
10509                .fetch_option_events(time("2024-03-01T00:00:00Z"), time("2024-03-01T23:00:00Z"))
10510                .await
10511                .unwrap();
10512
10513            assert_eq!(read.resume, None);
10514            let summary: Vec<_> = read
10515                .trades
10516                .iter()
10517                .map(|trade| {
10518                    (
10519                        trade.id.0.to_string(),
10520                        trade.instrument.name().to_string(),
10521                        trade.origin,
10522                        trade.side,
10523                        trade.price,
10524                        trade.quantity,
10525                    )
10526                })
10527                .collect();
10528            let row = |id: &str, symbol: &str, origin, side, price: Decimal, quantity: i64| {
10529                (
10530                    id.to_owned(),
10531                    symbol.to_owned(),
10532                    origin,
10533                    side,
10534                    price,
10535                    Decimal::from(quantity),
10536                )
10537            };
10538            assert_eq!(
10539                summary,
10540                [
10541                    row(
10542                        "exp",
10543                        "QS240301C00010500",
10544                        TradeOrigin::Expiry,
10545                        Side::Sell,
10546                        Decimal::ZERO,
10547                        1
10548                    ),
10549                    row(
10550                        "exc",
10551                        "QS240301C00006500",
10552                        TradeOrigin::Exercise,
10553                        Side::Sell,
10554                        Decimal::ZERO,
10555                        1
10556                    ),
10557                    row(
10558                        "exc-shares",
10559                        "QS",
10560                        TradeOrigin::Exercise,
10561                        Side::Buy,
10562                        Decimal::new(65, 1),
10563                        100
10564                    ),
10565                    row(
10566                        "asn",
10567                        "QS240301P00009000",
10568                        TradeOrigin::Assignment,
10569                        Side::Buy,
10570                        Decimal::ZERO,
10571                        1
10572                    ),
10573                    row(
10574                        "asn-shares",
10575                        "QS",
10576                        TradeOrigin::Assignment,
10577                        Side::Buy,
10578                        Decimal::from(9),
10579                        100
10580                    ),
10581                    row(
10582                        "orphan",
10583                        "QS",
10584                        TradeOrigin::External,
10585                        Side::Sell,
10586                        Decimal::from(7),
10587                        100
10588                    ),
10589                ]
10590            );
10591            let exp = &read.trades[0];
10592            assert_eq!(
10593                exp.time_exchange,
10594                time("2024-03-01T21:30:00Z"),
10595                "created_at"
10596            );
10597            assert_eq!(exp.order_id, OrderId::new("exp"));
10598            assert_eq!(exp.fees.fees, Decimal::ZERO);
10599            assert_eq!(
10600                read.trades[1].time_exchange,
10601                time("2024-03-01T00:00:00Z"),
10602                "no created_at: the start of its date"
10603            );
10604
10605            let queries = query_pairs(&server).await;
10606            let [query] = queries.as_slice() else {
10607                panic!("one request: {queries:?}");
10608            };
10609            assert!(
10610                query.contains(&("activity_types".into(), "OPASN,OPEXC,OPEXP,OPTRD".into())),
10611                "{query:?}"
10612            );
10613        }
10614
10615        /// A share leg pairs with the exercise or assignment whose kind implies its side, not with
10616        /// another at the same strike, and each pairs once.
10617        #[tokio::test]
10618        async fn fetch_option_events_pairs_a_share_leg_by_its_side() {
10619            let day = "2024-03-01";
10620            let server = serve_json(serde_json::json!([
10621                option_activity("exc-call", "OPEXC", "QS240301C00006500", "-1", day),
10622                option_activity("asn-call", "OPASN", "QS240301C00006500", "1", day),
10623                share_leg("sold", "QS", "-100", "6.5", day),
10624                share_leg("bought", "QS", "100", "6.5", day),
10625                share_leg("bought-again", "QS", "100", "6.5", day),
10626                share_leg("other-day", "QS", "100", "6.5", "2024-02-29"),
10627            ]))
10628            .await;
10629            let client = client_for(&server);
10630
10631            let read = client
10632                .fetch_option_events(time("2024-02-29T00:00:00Z"), time("2024-03-01T23:00:00Z"))
10633                .await
10634                .unwrap();
10635
10636            let origin = |id: &str| {
10637                read.trades
10638                    .iter()
10639                    .find(|trade| trade.id == TradeId::new(id))
10640                    .map(|trade| trade.origin)
10641            };
10642            assert_eq!(origin("sold"), Some(TradeOrigin::Assignment));
10643            assert_eq!(origin("bought"), Some(TradeOrigin::Exercise));
10644            assert_eq!(origin("bought-again"), Some(TradeOrigin::External));
10645            assert_eq!(origin("other-day"), Some(TradeOrigin::External));
10646        }
10647
10648        /// A share leg pairs with the exercise or assignment whose contracts deliver its shares,
10649        /// 100 a contract, though another that would buy them comes first. One of another size
10650        /// still pairs, with the first match left.
10651        #[tokio::test]
10652        async fn fetch_option_events_pairs_a_share_leg_by_its_size() {
10653            let day = "2024-03-01";
10654            let server = serve_json(serde_json::json!([
10655                // Being assigned on a put and exercising a call both buy at the strike.
10656                option_activity("asn-two", "OPASN", "QS240301P00006500", "2", day),
10657                option_activity("exc-one", "OPEXC", "QS240301C00006500", "-1", day),
10658                option_activity("exc-adjusted", "OPEXC", "QS240301C00006500", "-1", day),
10659                share_leg("hundred", "QS", "100", "6.5", day),
10660                share_leg("two-hundred", "QS", "200", "6.5", day),
10661                share_leg("hundred-fifty", "QS", "150", "6.5", day),
10662            ]))
10663            .await;
10664            let client = client_for(&server);
10665
10666            let read = client
10667                .fetch_option_events(time("2024-03-01T00:00:00Z"), time("2024-03-01T23:00:00Z"))
10668                .await
10669                .unwrap();
10670
10671            let origin = |id: &str| {
10672                read.trades
10673                    .iter()
10674                    .find(|trade| trade.id == TradeId::new(id))
10675                    .map(|trade| trade.origin)
10676            };
10677            assert_eq!(origin("hundred"), Some(TradeOrigin::Exercise));
10678            assert_eq!(origin("two-hundred"), Some(TradeOrigin::Assignment));
10679            assert_eq!(origin("hundred-fifty"), Some(TradeOrigin::Exercise));
10680        }
10681
10682        /// The read covers the whole UTC days of the span, so both legs of an event are read
10683        /// together, and returns only what is in the span: by creation time, or, without one, by
10684        /// date.
10685        #[tokio::test]
10686        async fn fetch_option_events_reads_whole_days_and_returns_the_span() {
10687            let mut early =
10688                option_activity("early", "OPEXP", "QS240301C00010500", "-1", "2024-03-01");
10689            early["created_at"] = serde_json::json!("2024-03-01T10:00:00Z");
10690            let mut inside =
10691                option_activity("inside", "OPEXP", "QS240301C00011500", "-1", "2024-03-01");
10692            inside["created_at"] = serde_json::json!("2024-03-01T16:00:00Z");
10693            let server = serve_json(serde_json::json!([
10694                option_activity(
10695                    "dated-before",
10696                    "OPEXP",
10697                    "QS240229C00010500",
10698                    "-1",
10699                    "2024-02-29"
10700                ),
10701                early,
10702                option_activity("dated", "OPEXP", "QS240301C00012500", "-1", "2024-03-01"),
10703                inside,
10704            ]))
10705            .await;
10706            let client = client_for(&server);
10707
10708            let read = client
10709                .fetch_option_events(time("2024-03-01T15:00:00Z"), time("2024-03-01T17:00:00Z"))
10710                .await
10711                .unwrap();
10712
10713            let ids: Vec<_> = read
10714                .trades
10715                .iter()
10716                .map(|trade| trade.id.0.to_string())
10717                .collect();
10718            assert_eq!(ids, ["dated", "inside"]);
10719            let queries = query_pairs(&server).await;
10720            let [query] = queries.as_slice() else {
10721                panic!("one request: {queries:?}");
10722            };
10723            assert!(
10724                query.contains(&("after".into(), "2024-03-01T00:00:00.000Z".into())),
10725                "{query:?}"
10726            );
10727            assert!(
10728                query.contains(&("until".into(), "2024-03-02T00:00:00.000Z".into())),
10729                "{query:?}"
10730            );
10731        }
10732
10733        #[tokio::test]
10734        async fn fetch_option_events_reads_nothing_for_an_empty_span() {
10735            let server = MockServer::start().await;
10736            let client = client_for(&server);
10737
10738            let read = client
10739                .fetch_option_events(time("2024-03-02T00:00:00Z"), time("2024-03-01T00:00:00Z"))
10740                .await
10741                .unwrap();
10742
10743            assert!(read.trades.is_empty() && read.resume.is_none());
10744            assert!(query_pairs(&server).await.is_empty());
10745        }
10746
10747        /// Read `start..=end` to its end, from each call's `resume`, returning the trade ids
10748        /// read and how many calls it took.
10749        async fn read_span(
10750            client: &AlpacaClient,
10751            start: DateTime<Utc>,
10752            end: DateTime<Utc>,
10753            instruments: &[InstrumentNameExchange],
10754        ) -> (std::collections::HashSet<String>, usize) {
10755            use crate::client::ExecutionClient;
10756
10757            let mut ids = std::collections::HashSet::new();
10758            let mut from = start;
10759            for calls in 1..=10 {
10760                let read = match client.fetch_trades(from, end, instruments).await {
10761                    Ok(read) => read,
10762                    Err(error) => panic!("call {calls} failed: {error:?}"),
10763                };
10764                ids.extend(read.trades.iter().map(|trade| trade.id.0.to_string()));
10765                match read.resume {
10766                    Some(resume) => from = resume,
10767                    None => return (ids, calls),
10768                }
10769            }
10770            panic!("the reads did not reach the span's end");
10771        }
10772
10773        /// A span holding more fills than one call reads is read completely across calls, each
10774        /// resumed from the last. Three fills share each millisecond, ordered by id against
10775        /// their times, so a call's cut falls inside one: its last fill read is later than an
10776        /// unread one in the same millisecond, which a resume from the last fill's exact time
10777        /// would lose.
10778        #[tokio::test]
10779        async fn fetch_trades_reads_a_busy_span_completely_across_calls() {
10780            let base = time("2025-04-18T14:30:00Z");
10781            let mut activities = Vec::new();
10782            for ms in 0..4_000_i64 {
10783                let symbol = if ms % 2 == 0 { "SPY" } else { "QQQ" };
10784                for (k, micros) in [("a", 900), ("b", 500), ("c", 100)] {
10785                    let at = base + TimeDelta::milliseconds(ms) + TimeDelta::microseconds(micros);
10786                    activities.push(fill_at(&format!("{ms:017}::{ms}-{k}"), symbol, at));
10787                }
10788            }
10789            let server = serve_activities(activities).await;
10790            let client = client_for(&server);
10791            let end = time("2025-04-18T15:00:00Z");
10792
10793            let (ids, calls) = read_span(&client, recovery_after(), end, &[]).await;
10794            assert_eq!(ids.len(), 12_000, "every fill is read once or more");
10795            assert_eq!(calls, 3, "5,000 activities per call");
10796
10797            let spy = [InstrumentNameExchange::new("SPY")];
10798            let (ids, _) = read_span(&client, recovery_after(), end, &spy).await;
10799            assert_eq!(ids.len(), 6_000, "every SPY fill, and only those");
10800
10801            let Some(requests) = server.received_requests().await else {
10802                panic!("requests are recorded");
10803            };
10804            assert!(
10805                requests
10806                    .iter()
10807                    .all(|request| request.url.query_pairs().any(
10808                        |(name, value)| name == "until" && value == "2025-04-18T15:00:00.001Z"
10809                    )),
10810                "every request is bounded by the span's end"
10811            );
10812        }
10813
10814        /// A call can spend its whole bound on other instruments' fills: it returns none, and
10815        /// says where to read on from.
10816        #[tokio::test]
10817        async fn fetch_trades_says_where_to_read_on_after_a_call_without_matches() {
10818            use crate::client::ExecutionClient;
10819
10820            let server = MockServer::start().await;
10821            Mock::given(method("GET"))
10822                .and(path("/v2/account/activities"))
10823                .respond_with(
10824                    ResponseTemplate::new(200)
10825                        .set_body_json(make_activities_json(ALPACA_MAX_ACTIVITIES, "act")),
10826                )
10827                .mount(&server)
10828                .await;
10829
10830            let qqq = [InstrumentNameExchange::new("QQQ")];
10831            let Ok(read) = client_for(&server)
10832                .fetch_trades(recovery_after(), Utc::now(), &qqq)
10833                .await
10834            else {
10835                panic!("the read succeeds");
10836            };
10837            assert!(read.trades.is_empty(), "every fill read is SPY's");
10838            assert_eq!(read.resume, Some(time("2025-04-18T14:30:00Z")));
10839        }
10840
10841        /// A full read that does not get past the span's first millisecond cannot advance, so it
10842        /// is an error rather than a resume that would loop.
10843        #[tokio::test]
10844        async fn fetch_trades_that_cannot_advance_is_truncated() {
10845            use crate::client::ExecutionClient;
10846
10847            let server = MockServer::start().await;
10848            Mock::given(method("GET"))
10849                .and(path("/v2/account/activities"))
10850                .respond_with(
10851                    ResponseTemplate::new(200)
10852                        .set_body_json(make_activities_json(ALPACA_MAX_ACTIVITIES, "act")),
10853                )
10854                .mount(&server)
10855                .await;
10856
10857            let result = client_for(&server)
10858                .fetch_trades(time("2025-04-18T14:30:00.000500Z"), Utc::now(), &[])
10859                .await;
10860            assert!(
10861                matches!(
10862                    result,
10863                    Err(UnindexedClientError::Truncated { fills_read: 5_000 })
10864                ),
10865                "got {result:?}"
10866            );
10867        }
10868
10869        /// The span is applied exactly, although Alpaca's bounds reach the whole of the
10870        /// milliseconds holding `start` and `end`.
10871        #[tokio::test]
10872        async fn fetch_trades_returns_only_the_span() {
10873            let (start, end) = (
10874                time("2025-04-18T14:30:00.123456Z"),
10875                time("2025-04-18T14:30:05.678901Z"),
10876            );
10877            let micro = TimeDelta::microseconds(1);
10878            let server = serve_activities(vec![
10879                fill_at("1::before", "SPY", start - micro),
10880                fill_at("2::start", "SPY", start),
10881                fill_at("3::end", "SPY", end),
10882                fill_at("4::after", "SPY", end + micro),
10883            ])
10884            .await;
10885
10886            let (ids, calls) = read_span(&client_for(&server), start, end, &[]).await;
10887            assert_eq!(calls, 1);
10888            let mut ids: Vec<_> = ids.into_iter().collect();
10889            ids.sort();
10890            assert_eq!(ids, ["end", "start"]);
10891        }
10892
10893        /// Alpaca's `until` is not exact, so a capped read can return activities past `end`. Every
10894        /// activity before them was read, so the span is complete.
10895        #[tokio::test]
10896        async fn fetch_trades_that_read_past_the_end_is_complete() {
10897            use crate::client::ExecutionClient;
10898
10899            let server = MockServer::start().await;
10900            // Full pages at 14:30:00, whatever `until` asks for.
10901            Mock::given(method("GET"))
10902                .and(path("/v2/account/activities"))
10903                .respond_with(
10904                    ResponseTemplate::new(200)
10905                        .set_body_json(make_activities_json(ALPACA_MAX_ACTIVITIES, "act")),
10906                )
10907                .mount(&server)
10908                .await;
10909
10910            let Ok(read) = client_for(&server)
10911                .fetch_trades(recovery_after(), time("2025-04-18T14:29:00Z"), &[])
10912                .await
10913            else {
10914                panic!("the read succeeds");
10915            };
10916            assert_eq!(read, TradesRead::complete(Vec::new()));
10917        }
10918
10919        /// The extreme times are valid bounds: a start before the epoch asks from the epoch, an
10920        /// end not yet reached sends no `until` rather than overflowing, and a span ending before
10921        /// the epoch reads nothing.
10922        #[tokio::test]
10923        async fn fetch_trades_accepts_the_extreme_times() {
10924            use crate::client::ExecutionClient;
10925
10926            let server = serve_activities(vec![fill_at(
10927                "1::fill",
10928                "SPY",
10929                time("2025-04-18T14:30:00Z"),
10930            )])
10931            .await;
10932            let Ok(read) = client_for(&server)
10933                .fetch_trades(DateTime::<Utc>::MIN_UTC, DateTime::<Utc>::MAX_UTC, &[])
10934                .await
10935            else {
10936                panic!("the read succeeds");
10937            };
10938            assert_eq!(read.trades.len(), 1);
10939            assert_eq!(read.resume, None);
10940
10941            let Some(requests) = server.received_requests().await else {
10942                panic!("requests are recorded");
10943            };
10944            let [request] = requests.as_slice() else {
10945                panic!("one request: {requests:?}");
10946            };
10947            let query: std::collections::HashMap<_, _> =
10948                request.url.query_pairs().into_owned().collect();
10949            assert_eq!(
10950                query.get("after").map(String::as_str),
10951                Some("1970-01-01T00:00:00.000Z")
10952            );
10953            assert!(!query.contains_key("until"), "{query:?}");
10954
10955            // A span ending before the epoch holds nothing, and is not requested.
10956            let before_epoch = DateTime::UNIX_EPOCH - TimeDelta::days(1);
10957            let Ok(read) = client_for(&server)
10958                .fetch_trades(DateTime::<Utc>::MIN_UTC, before_epoch, &[])
10959                .await
10960            else {
10961                panic!("the read succeeds");
10962            };
10963            assert_eq!(read, TradesRead::complete(Vec::new()));
10964            assert_eq!(server.received_requests().await.map(|r| r.len()), Some(1));
10965        }
10966
10967        /// A span whose start is after its end is empty: nothing is requested.
10968        #[tokio::test]
10969        async fn fetch_trades_reads_nothing_for_an_empty_span() {
10970            use crate::client::ExecutionClient;
10971
10972            let server = MockServer::start().await;
10973            let start = recovery_after();
10974            let Ok(read) = client_for(&server)
10975                .fetch_trades(start, start - TimeDelta::seconds(1), &[])
10976                .await
10977            else {
10978                panic!("an empty span reads");
10979            };
10980            assert_eq!(read, TradesRead::complete(Vec::new()));
10981            assert_eq!(server.received_requests().await.map(|r| r.len()), Some(0));
10982        }
10983
10984        /// Alpaca's `after` compares at millisecond precision, so a recovery from mid-millisecond
10985        /// asks from that millisecond's start, or it would miss a fill later in it.
10986        #[tokio::test]
10987        async fn recovery_reads_from_the_start_of_the_disconnects_millisecond() {
10988            let server = MockServer::start().await;
10989            Mock::given(method("GET"))
10990                .and(path("/v2/account/activities"))
10991                .and(wiremock::matchers::query_param(
10992                    "after",
10993                    "2025-01-01T00:00:00.123Z",
10994                ))
10995                .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([])))
10996                .expect(1)
10997                .mount(&server)
10998                .await;
10999
11000            let (tx, _rx) = mpsc::unbounded_channel();
11001            let outcome = recover_fills(
11002                &reqwest::Client::new(),
11003                &RateLimitTracker::new(),
11004                &[],
11005                &server.uri(),
11006                time("2025-01-01T00:00:00.123456Z"),
11007                &tx,
11008                &new_dedup_cache(),
11009                &KnownLiveOrders::shared(ExchangeId::AlpacaBroker),
11010            )
11011            .await;
11012            assert_eq!(outcome, Ok(()));
11013            server.verify().await;
11014        }
11015
11016        /// The cumulative dedup keys `dedup` holds, oldest first.
11017        fn dedup_keys(dedup: &SharedDedupCache) -> Vec<String> {
11018            let mut keys: Vec<_> = dedup
11019                .lock()
11020                .iter()
11021                .filter_map(|(key, _)| match key {
11022                    FillKey::Cumulative(key) => Some(key.to_string()),
11023                    FillKey::Execution(_) | FillKey::CumulativeWithoutId(_) => None,
11024                })
11025                .collect();
11026            // The LRU iterates most recent first.
11027            keys.reverse();
11028            keys
11029        }
11030
11031        fn trade_of(
11032            event: &UnindexedAccountEvent,
11033        ) -> &Trade<AssetNameExchange, InstrumentNameExchange> {
11034            match &event.kind {
11035                AccountEventKind::Trade(t) => t,
11036                other => panic!("expected a Trade event, got {other:?}"),
11037            }
11038        }
11039
11040        /// A recovered fill reports where it left the order, so it advances `filled_quantity`
11041        /// without waiting for a `fetch_open_orders` reconciliation that the caller may never make.
11042        #[tokio::test]
11043        async fn a_recovered_fill_reports_the_cumulative_alpaca_sent() {
11044            let events = drive_recover_fills(vec![
11045                activity_json("act-1", "ord-1", "2", Some("2")),
11046                activity_json("act-2", "ord-1", "3", Some("5")),
11047            ])
11048            .await;
11049
11050            let reported: Vec<_> = events
11051                .iter()
11052                .map(|e| trade_of(e).order_filled_quantity)
11053                .collect();
11054            assert_eq!(
11055                reported,
11056                vec![
11057                    Some(Decimal::from_str("2").unwrap()),
11058                    Some(Decimal::from_str("5").unwrap())
11059                ]
11060            );
11061        }
11062
11063        /// The dedup key comes from the venue's own cumulative, not from counting executions
11064        /// within the recovery batch.
11065        ///
11066        /// An order that had already partly filled *before* the recovery window is the case that
11067        /// separates the two, and it is the case the previous implementation got wrong: counting
11068        /// from zero inside the batch yields a key the WebSocket path never emitted for that fill.
11069        #[tokio::test]
11070        async fn the_dedup_key_uses_the_venue_cumulative_not_an_intra_batch_count() {
11071            // Three lots filled before the window; the recovered 2-lot fill takes the order to 5.
11072            let dedup = new_dedup_cache();
11073            drive_recover_fills_with(
11074                vec![activity_json("act-1", "ord-1", "2", Some("5"))],
11075                dedup.clone(),
11076                &KnownLiveOrders::shared(ExchangeId::AlpacaBroker),
11077            )
11078            .await;
11079
11080            assert_eq!(
11081                dedup_keys(&dedup),
11082                vec!["ord-1:5"],
11083                "counting executions within the batch would key this as ord-1:2"
11084            );
11085        }
11086
11087        /// Delivers a WS fill of 2 lots on `ord-1`, leaving it at 5, with `execution_id`, then
11088        /// recovers one activity `activity_id` of the same size and cumulative through the same
11089        /// dedup cache.
11090        async fn recover_after_ws_fill(
11091            execution_id: Option<&str>,
11092            activity_id: &str,
11093        ) -> Vec<UnindexedAccountEvent> {
11094            let dedup = new_dedup_cache();
11095            let update = AlpacaTradeUpdate {
11096                event: SmolStr::new("partial_fill"),
11097                execution_id,
11098                previous_execution_id: None,
11099                order: super::make_order_ws("ord-1", "SPY", "buy", "5"),
11100                price: Some("100.00"),
11101                qty: Some("2"),
11102                timestamp: None,
11103            };
11104            assert!(
11105                !is_duplicate_fill(&dedup, ws_execution_id(&update), early_dedup_key(&update)),
11106                "precondition: first sighting"
11107            );
11108
11109            drive_recover_fills_with(
11110                vec![activity_json(activity_id, "ord-1", "2", Some("5"))],
11111                dedup,
11112                &KnownLiveOrders::shared(ExchangeId::AlpacaBroker),
11113            )
11114            .await
11115        }
11116
11117        /// The property the cache exists for: one fill delivered by both paths is one fill. It is
11118        /// recognised by its execution id, or by its cumulative where either path lacks the id.
11119        #[tokio::test]
11120        async fn a_fill_already_delivered_over_websocket_is_not_recovered_twice() {
11121            for (ws_execution_id, activity_id) in [
11122                (Some("exec-a"), "20250418103000000::exec-a"),
11123                (None, "20250418103000000::exec-a"),
11124                (Some("exec-a"), "act-1"),
11125            ] {
11126                let events = recover_after_ws_fill(ws_execution_id, activity_id).await;
11127                assert!(
11128                    events.is_empty(),
11129                    "a fill already delivered over WebSocket ({ws_execution_id:?}) must not be \
11130                     re-delivered by recovery ({activity_id}), got {events:?}"
11131                );
11132            }
11133        }
11134
11135        /// Streams a `trade_correct` with `fields` (its execution id `exec-c`), then recovers the
11136        /// FILL activity of `exec-c` through the same dedup cache.
11137        async fn recover_after_correction(fields: &str) -> Vec<UnindexedAccountEvent> {
11138            let (tx, _rx) = mpsc::unbounded_channel();
11139            let dedup = new_dedup_cache();
11140            let known = KnownLiveOrders::shared(ExchangeId::AlpacaBroker);
11141            process_ws_text(
11142                &super::amendment_frame("trade_correct", fields),
11143                &tx,
11144                &dedup,
11145                &known,
11146                &mut VenueClock::new(),
11147            );
11148
11149            drive_recover_fills_with(
11150                vec![activity_json(
11151                    "20250418103100000::exec-c",
11152                    "ord-1",
11153                    "2",
11154                    Some("2"),
11155                )],
11156                dedup,
11157                &known,
11158            )
11159            .await
11160        }
11161
11162        /// A correction's execution was delivered as the replacement trade, so recovery does not
11163        /// deliver it again should Alpaca list it as a FILL activity.
11164        #[tokio::test]
11165        async fn a_corrected_execution_is_not_recovered_again() {
11166            let events = recover_after_correction(
11167                r#""execution_id":"exec-c","previous_execution_id":"exec-a","price":"101.50","qty":"2","#,
11168            )
11169            .await;
11170            assert!(
11171                events.is_empty(),
11172                "the corrected execution was delivered as the replacement, got {events:?}"
11173            );
11174        }
11175
11176        /// An unresolved correction delivered no trade, so recovery still delivers its execution.
11177        #[tokio::test]
11178        async fn an_unresolved_correction_does_not_keep_its_execution_from_recovery() {
11179            let events = recover_after_correction(
11180                r#""execution_id":"exec-c","previous_execution_id":"exec-a","qty":"2","#,
11181            )
11182            .await;
11183            let ids: Vec<_> = events.iter().map(|e| trade_of(e).id.0.as_str()).collect();
11184            assert_eq!(ids, vec!["exec-c"]);
11185        }
11186
11187        /// A different execution that takes the order to a cumulative already seen, as after a
11188        /// bust, is a new fill and is recovered.
11189        #[tokio::test]
11190        async fn a_different_execution_at_a_cumulative_already_seen_is_recovered() {
11191            let events = recover_after_ws_fill(Some("exec-a"), "20250418103000000::exec-b").await;
11192            let ids: Vec<_> = events.iter().map(|e| trade_of(e).id.0.as_str()).collect();
11193            assert_eq!(ids, vec!["exec-b"]);
11194        }
11195
11196        /// With `cum_qty` absent the key falls back to counting within the batch -- exactly what
11197        /// shipped before -- and the fill reports no cumulative rather than a fabricated one.
11198        #[tokio::test]
11199        async fn without_a_reported_cumulative_the_previous_behaviour_is_preserved() {
11200            let dedup = new_dedup_cache();
11201            let events = drive_recover_fills_with(
11202                vec![
11203                    activity_json("act-1", "ord-1", "2", None),
11204                    activity_json("act-2", "ord-1", "3", None),
11205                ],
11206                dedup.clone(),
11207                &KnownLiveOrders::shared(ExchangeId::AlpacaBroker),
11208            )
11209            .await;
11210
11211            assert_eq!(
11212                dedup_keys(&dedup),
11213                vec!["ord-1:2", "ord-1:5"],
11214                "intra-batch accumulation"
11215            );
11216            let ids: Vec<_> = events.iter().map(|e| trade_of(e).id.0.as_str()).collect();
11217            assert_eq!(ids, vec!["act-1", "act-2"], "each keeps its activity's id");
11218
11219            assert!(
11220                events
11221                    .iter()
11222                    .all(|e| trade_of(e).order_filled_quantity.is_none()),
11223                "a venue that reported no cumulative must not have one invented for it"
11224            );
11225        }
11226
11227        // --- How orders ended: fetch_ended_orders, the reconnect check, and what is held ---
11228
11229        /// An order as `GET /v2/orders` and `GET /v2/orders:by_client_order_id` serve it, of
11230        /// quantity 2.
11231        fn order_json(cid: &str, symbol: &str, status: &str, filled: &str) -> serde_json::Value {
11232            serde_json::json!({
11233                "id": format!("id-{cid}"),
11234                "client_order_id": cid,
11235                "symbol": symbol,
11236                "qty": "2",
11237                "filled_qty": filled,
11238                "filled_avg_price": if filled == "0" { None } else { Some("101.5") },
11239                "status": status,
11240                "side": "buy",
11241                "type": "limit",
11242                "time_in_force": "gtc",
11243                "limit_price": "100",
11244                "created_at": "2026-10-01T14:30:00Z",
11245                "updated_at": "2026-10-01T15:00:00Z"
11246            })
11247        }
11248
11249        /// Serve `order` as the lookup of client order id `cid`, expecting `calls` lookups.
11250        async fn mount_lookup(server: &MockServer, cid: &str, order: ResponseTemplate, calls: u64) {
11251            use wiremock::matchers::query_param;
11252            Mock::given(method("GET"))
11253                .and(path("/v2/orders:by_client_order_id"))
11254                .and(query_param("client_order_id", cid))
11255                .respond_with(order)
11256                .expect(calls)
11257                .mount(server)
11258                .await;
11259        }
11260
11261        fn ok(body: serde_json::Value) -> ResponseTemplate {
11262            ResponseTemplate::new(200).set_body_json(body)
11263        }
11264
11265        fn alpaca_key(instrument: &str, cid: &str) -> UnindexedOrderKey {
11266            OrderKey::new(
11267                ExchangeId::AlpacaBroker,
11268                InstrumentNameExchange::new(instrument),
11269                StrategyId::new("strategy"),
11270                ClientOrderId::new(cid),
11271            )
11272        }
11273
11274        fn hold(known: &SharedKnownLiveOrders, key: &UnindexedOrderKey) {
11275            let open = Open::new(
11276                VenueOrderId::Assigned(OrderId::new(format!("id-{}", key.cid))),
11277                Utc::now(),
11278                Decimal::ZERO,
11279            );
11280            known
11281                .lock()
11282                .live(key, Decimal::TWO, OrderKind::Limit, &open);
11283        }
11284
11285        #[tokio::test]
11286        async fn fetch_ended_orders_reads_each_end_alpaca_reports() {
11287            use rust_decimal_macros::dec;
11288
11289            let server = MockServer::start().await;
11290            for (cid, status, filled) in [
11291                ("filled", "filled", "2"),
11292                ("canceled", "canceled", "1"),
11293                ("replaced", "replaced", "0"),
11294                ("expired", "expired", "1"),
11295                ("rejected", "rejected", "0"),
11296                ("done", "done_for_day", "1"),
11297                ("live", "new", "0"),
11298                ("unknown-status", "frozen", "0"),
11299            ] {
11300                mount_lookup(&server, cid, ok(order_json(cid, "SPY", status, filled)), 1).await;
11301            }
11302            mount_lookup(
11303                &server,
11304                "elsewhere",
11305                ok(order_json("elsewhere", "QQQ", "canceled", "0")),
11306                1,
11307            )
11308            .await;
11309            mount_lookup(
11310                &server,
11311                "gone",
11312                ResponseTemplate::new(404).set_body_json(serde_json::json!({
11313                    "code": 40410000,
11314                    "message": "order not found for gone"
11315                })),
11316                1,
11317            )
11318            .await;
11319
11320            let keys: Vec<_> = [
11321                "filled",
11322                "canceled",
11323                "replaced",
11324                "expired",
11325                "rejected",
11326                "done",
11327                "live",
11328                "unknown-status",
11329                "elsewhere",
11330                "gone",
11331            ]
11332            .into_iter()
11333            .map(|cid| alpaca_key("spy", cid))
11334            .collect();
11335            let ended = client_for(&server).fetch_ended_orders(&keys).await.unwrap();
11336
11337            let mut found: Vec<_> = ended
11338                .iter()
11339                .map(|order| (order.key.cid.0.as_str(), &order.state))
11340                .collect();
11341            found.sort_by_key(|(cid, _)| *cid);
11342            let [
11343                (canceled, InactiveOrderState::Cancelled(cancelled)),
11344                (expired, InactiveOrderState::Expired(expiry)),
11345                (filled, InactiveOrderState::FullyFilled(fill)),
11346                (rejected, InactiveOrderState::OpenFailed(failed)),
11347                (replaced, InactiveOrderState::Cancelled(replacement)),
11348            ] = found.as_slice()
11349            else {
11350                panic!("filled, canceled, replaced, expired and rejected: {found:?}");
11351            };
11352            assert_eq!(
11353                [*canceled, *expired, *filled, *rejected, *replaced],
11354                ["canceled", "expired", "filled", "rejected", "replaced"]
11355            );
11356            assert_eq!(
11357                (fill.filled_quantity, fill.avg_price),
11358                (dec!(2), Some(dec!(101.5)))
11359            );
11360            assert_eq!(
11361                cancelled.filled_quantity,
11362                Some(dec!(1)),
11363                "what filled before"
11364            );
11365            assert_eq!(expiry.filled_quantity, Some(dec!(1)));
11366            assert_eq!(replacement.filled_quantity, Some(Decimal::ZERO));
11367            assert_eq!(
11368                cancelled.time_exchange,
11369                parse_timestamp("2026-10-01T15:00:00Z").unwrap(),
11370                "stamped when the order last changed"
11371            );
11372            assert!(matches!(
11373                failed,
11374                OrderError::Rejected(ApiError::OrderRejected(message))
11375                    if message.contains("id-rejected")
11376            ));
11377            assert!(
11378                ended
11379                    .iter()
11380                    .all(|order| order.key.strategy == StrategyId::new("strategy")
11381                        && order.key.instrument == InstrumentNameExchange::new("spy")),
11382                "each carries the key it was asked for"
11383            );
11384        }
11385
11386        #[tokio::test]
11387        async fn fetch_ended_orders_fails_when_a_lookup_fails() {
11388            let server = MockServer::start().await;
11389            mount_lookup(&server, "a", ok(order_json("a", "SPY", "canceled", "0")), 1).await;
11390            mount_lookup(&server, "b", ResponseTemplate::new(500), 1).await;
11391
11392            let result = client_for(&server)
11393                .fetch_ended_orders(&[alpaca_key("SPY", "a"), alpaca_key("SPY", "b")])
11394                .await;
11395
11396            assert!(
11397                matches!(result, Err(UnindexedClientError::Connectivity(_))),
11398                "{result:?}"
11399            );
11400        }
11401
11402        /// One listing covers every instrument due; only the held orders it no longer shows are
11403        /// looked up, and only those that ended are reported and stop being held.
11404        #[tokio::test]
11405        async fn a_reconnect_check_lists_once_and_reports_the_orders_that_ended() {
11406            use wiremock::matchers::query_param;
11407
11408            let server = MockServer::start().await;
11409            Mock::given(method("GET"))
11410                .and(path("/v2/orders"))
11411                .and(query_param("status", "open"))
11412                .and(query_param("symbols", "QQQ,SPY"))
11413                .respond_with(ok(serde_json::json!([order_json(
11414                    "spy-listed",
11415                    "SPY",
11416                    "new",
11417                    "0"
11418                )])))
11419                .expect(1)
11420                .mount(&server)
11421                .await;
11422            mount_lookup(&server, "spy-listed", ResponseTemplate::new(500), 0).await;
11423            mount_lookup(
11424                &server,
11425                "spy-cancelled",
11426                ok(order_json("spy-cancelled", "SPY", "canceled", "1")),
11427                1,
11428            )
11429            .await;
11430            mount_lookup(
11431                &server,
11432                "spy-done",
11433                ok(order_json("spy-done", "SPY", "done_for_day", "1")),
11434                1,
11435            )
11436            .await;
11437            mount_lookup(
11438                &server,
11439                "qqq-filled",
11440                ok(order_json("qqq-filled", "QQQ", "filled", "2")),
11441                1,
11442            )
11443            .await;
11444
11445            let known = KnownLiveOrders::shared(ExchangeId::AlpacaBroker);
11446            let [listed, cancelled, done, filled] = [
11447                ("SPY", "spy-listed"),
11448                ("SPY", "spy-cancelled"),
11449                ("SPY", "spy-done"),
11450                ("QQQ", "qqq-filled"),
11451            ]
11452            .map(|(instrument, cid)| alpaca_key(instrument, cid));
11453            for key in [&listed, &cancelled, &done, &filled] {
11454                hold(&known, key);
11455            }
11456            let mut unchecked = UncheckedOrders::default();
11457            unchecked.open(known.lock().instruments());
11458            let config = Arc::new(AlpacaConfig::with_base_url(
11459                "test-key".into(),
11460                "test-secret".into(),
11461                server.uri(),
11462            ));
11463            let (tx, mut rx) = mpsc::unbounded_channel();
11464
11465            recover_alpaca_ended_orders(
11466                &reqwest::Client::new(),
11467                &Arc::new(RateLimitTracker::new()),
11468                &config,
11469                &known,
11470                &mut unchecked,
11471                &tx,
11472            )
11473            .await;
11474
11475            let mut reported: Vec<_> = std::iter::from_fn(|| rx.try_recv().ok())
11476                .map(|event| {
11477                    let AccountEventKind::OrderSnapshot(
11478                        rustrade_integration::collection::snapshot::Snapshot(order),
11479                    ) = event.kind
11480                    else {
11481                        panic!("an order snapshot: {event:?}");
11482                    };
11483                    assert!(matches!(order.state, OrderState::Inactive(_)), "{order:?}");
11484                    order.key.cid
11485                })
11486                .collect();
11487            reported.sort();
11488            assert_eq!(reported, [filled.cid.clone(), cancelled.cid.clone()]);
11489            let known = known.lock();
11490            assert!(known.contains(&listed.cid) && known.contains(&done.cid));
11491            assert!(!known.contains(&cancelled.cid) && !known.contains(&filled.cid));
11492            assert!(unchecked.is_empty(), "both instruments checked");
11493        }
11494
11495        /// A listing that fails charges every instrument in it, which then waits for a retry.
11496        #[tokio::test]
11497        async fn a_failed_listing_is_retried_for_every_instrument_in_it() {
11498            let server = MockServer::start().await;
11499            Mock::given(method("GET"))
11500                .and(path("/v2/orders"))
11501                .respond_with(ResponseTemplate::new(503))
11502                .expect(1)
11503                .mount(&server)
11504                .await;
11505
11506            let known = KnownLiveOrders::shared(ExchangeId::AlpacaBroker);
11507            hold(&known, &alpaca_key("SPY", "a"));
11508            hold(&known, &alpaca_key("QQQ", "b"));
11509            let mut unchecked = UncheckedOrders::default();
11510            unchecked.open(known.lock().instruments());
11511            let config = Arc::new(AlpacaConfig::with_base_url(
11512                "test-key".into(),
11513                "test-secret".into(),
11514                server.uri(),
11515            ));
11516            let (tx, mut rx) = mpsc::unbounded_channel();
11517
11518            recover_alpaca_ended_orders(
11519                &reqwest::Client::new(),
11520                &Arc::new(RateLimitTracker::new()),
11521                &config,
11522                &known,
11523                &mut unchecked,
11524                &tx,
11525            )
11526            .await;
11527
11528            assert!(rx.try_recv().is_err(), "nothing reported");
11529            let now = tokio::time::Instant::now();
11530            assert!(
11531                unchecked.ready(&NoPendingFills, now).is_empty(),
11532                "both wait"
11533            );
11534            for instrument in ["QQQ", "SPY"] {
11535                assert_eq!(
11536                    unchecked.failed(
11537                        &InstrumentNameExchange::new(instrument),
11538                        now,
11539                        Duration::ZERO
11540                    ),
11541                    Some(crate::client::order_recovery::GapFailure::Retry(
11542                        Duration::from_secs(crate::client::order_recovery::GAP_RETRY_BASE_SECS * 2)
11543                    )),
11544                    "{instrument} has failed once already"
11545                );
11546            }
11547            assert_eq!(known.lock().instruments().len(), 2, "both still held");
11548        }
11549
11550        #[tokio::test]
11551        async fn placing_listing_and_cancelling_keep_the_held_orders_in_step() {
11552            use crate::order::request::{OrderRequestCancel, RequestCancel, RequestOpen};
11553
11554            let server = MockServer::start().await;
11555            Mock::given(method("POST"))
11556                .and(path("/v2/orders"))
11557                .respond_with(ok(order_json("resting", "SPY", "new", "0")))
11558                .up_to_n_times(1)
11559                .mount(&server)
11560                .await;
11561            Mock::given(method("POST"))
11562                .and(path("/v2/orders"))
11563                .respond_with(ok(order_json("filled", "SPY", "filled", "2")))
11564                .mount(&server)
11565                .await;
11566            Mock::given(method("GET"))
11567                .and(path("/v2/orders"))
11568                .respond_with(ok(serde_json::json!([order_json(
11569                    "listed", "QQQ", "new", "0"
11570                )])))
11571                .mount(&server)
11572                .await;
11573            Mock::given(method("DELETE"))
11574                .and(path("/v2/orders/id-resting"))
11575                .respond_with(ResponseTemplate::new(204))
11576                .mount(&server)
11577                .await;
11578            let client = client_for(&server);
11579            let spy = InstrumentNameExchange::new("SPY");
11580
11581            for cid in ["resting", "filled"] {
11582                let request = OrderRequestOpen {
11583                    key: OrderKey::new(
11584                        ExchangeId::AlpacaBroker,
11585                        &spy,
11586                        StrategyId::new("strategy"),
11587                        ClientOrderId::new(cid),
11588                    ),
11589                    state: RequestOpen::new(
11590                        Side::Buy,
11591                        Some(Decimal::ONE_HUNDRED),
11592                        Decimal::TWO,
11593                        OrderKind::Limit,
11594                        TimeInForce::GoodUntilCancelled { post_only: false },
11595                    ),
11596                };
11597                client.open_order(request).await;
11598            }
11599            client.fetch_open_orders(&[]).await.unwrap();
11600            {
11601                let known = client.known_live.lock();
11602                assert!(known.contains(&ClientOrderId::new("resting")));
11603                assert!(
11604                    !known.contains(&ClientOrderId::new("filled")),
11605                    "filled in its placement response"
11606                );
11607                assert!(known.contains(&ClientOrderId::new("listed")));
11608            }
11609
11610            let cancel = client
11611                .cancel_order(OrderRequestCancel {
11612                    key: OrderKey::new(
11613                        ExchangeId::AlpacaBroker,
11614                        &spy,
11615                        StrategyId::new("strategy"),
11616                        ClientOrderId::new("resting"),
11617                    ),
11618                    state: RequestCancel {
11619                        id: Some(VenueOrderId::Assigned(OrderId::new("id-resting"))),
11620                    },
11621                })
11622                .await;
11623            let Ok(cancelled) = &cancel.state else {
11624                panic!("expected Ok, got {cancel:?}");
11625            };
11626            assert_eq!(
11627                cancelled.filled_quantity, None,
11628                "the DELETE response has no body, so what filled is unknown"
11629            );
11630            assert!(
11631                client
11632                    .known_live
11633                    .lock()
11634                    .contains(&ClientOrderId::new("resting")),
11635                "a cancel Alpaca has only accepted has not ended the order"
11636            );
11637        }
11638
11639        /// An order that ended in its placement response is reported as it ended, on both
11640        /// placement paths, and is not held as live: an IOC that found no liquidity or partly
11641        /// filled and expired the rest, one cancelled or replaced, one rejected after Alpaca
11642        /// accepted it, and one filled. A live status is reported open, with what filled (zero
11643        /// when that is unknown), and held, as is one this version does not know; one whose fill
11644        /// covers the quantity is reported filled.
11645        #[tokio::test]
11646        async fn an_order_that_ended_in_its_placement_response_is_reported_as_it_ended() {
11647            use crate::order::request::RequestOpen;
11648
11649            let Some(time) = parse_timestamp("2026-10-01T15:00:00Z") else {
11650                panic!("order_json's updated_at parses");
11651            };
11652            let id = |cid: &str| OrderId::new(format!("id-{cid}"));
11653            let open = |cid: &str, filled| {
11654                OrderState::active(Open::new(VenueOrderId::Assigned(id(cid)), time, filled))
11655            };
11656            let cases: [(&str, &str, &str, UnindexedOrderState, bool); 13] = [
11657                (
11658                    "ioc-unfilled",
11659                    "expired",
11660                    "0",
11661                    OrderState::expired(Expired::new(
11662                        id("ioc-unfilled"),
11663                        time,
11664                        Some(Decimal::ZERO),
11665                    )),
11666                    false,
11667                ),
11668                (
11669                    "ioc-partial",
11670                    "expired",
11671                    "1",
11672                    OrderState::expired(Expired::new(id("ioc-partial"), time, Some(Decimal::ONE))),
11673                    false,
11674                ),
11675                (
11676                    "fill-unknown",
11677                    "expired",
11678                    "not-a-number",
11679                    OrderState::expired(Expired::new(id("fill-unknown"), time, None)),
11680                    false,
11681                ),
11682                (
11683                    "cancelled",
11684                    "canceled",
11685                    "1",
11686                    OrderState::inactive(Cancelled::new(id("cancelled"), time, Some(Decimal::ONE))),
11687                    false,
11688                ),
11689                (
11690                    "rejected",
11691                    "rejected",
11692                    "0",
11693                    OrderState::inactive(OrderError::Rejected(ApiError::OrderRejected(
11694                        "Alpaca rejected order id-rejected after accepting it".to_string(),
11695                    ))),
11696                    false,
11697                ),
11698                (
11699                    "filled",
11700                    "filled",
11701                    "2",
11702                    OrderState::fully_filled(Filled::new(
11703                        id("filled"),
11704                        time,
11705                        Decimal::TWO,
11706                        Some(Decimal::new(1015, 1)),
11707                    )),
11708                    false,
11709                ),
11710                (
11711                    "replaced",
11712                    "replaced",
11713                    "0",
11714                    OrderState::inactive(Cancelled::new(id("replaced"), time, Some(Decimal::ZERO))),
11715                    false,
11716                ),
11717                (
11718                    "live-covered",
11719                    "partially_filled",
11720                    "2",
11721                    OrderState::fully_filled(Filled::new(
11722                        id("live-covered"),
11723                        time,
11724                        Decimal::TWO,
11725                        Some(Decimal::new(1015, 1)),
11726                    )),
11727                    false,
11728                ),
11729                ("new", "new", "0", open("new", Decimal::ZERO), true),
11730                (
11731                    "live-fill-unknown",
11732                    "new",
11733                    "not-a-number",
11734                    open("live-fill-unknown", Decimal::ZERO),
11735                    true,
11736                ),
11737                (
11738                    "partial",
11739                    "partially_filled",
11740                    "1",
11741                    open("partial", Decimal::ONE),
11742                    true,
11743                ),
11744                (
11745                    "for-the-day",
11746                    "done_for_day",
11747                    "1",
11748                    open("for-the-day", Decimal::ONE),
11749                    true,
11750                ),
11751                (
11752                    "unknown",
11753                    "a_status_not_yet_documented",
11754                    "0",
11755                    open("unknown", Decimal::ZERO),
11756                    true,
11757                ),
11758            ];
11759
11760            for (cid, status, filled, expected, held) in cases {
11761                for bracket in [false, true] {
11762                    let server = MockServer::start().await;
11763                    Mock::given(method("POST"))
11764                        .and(path("/v2/orders"))
11765                        .respond_with(ok(order_json(cid, "SPY", status, filled)))
11766                        .expect(1)
11767                        .mount(&server)
11768                        .await;
11769                    let client = client_for(&server);
11770                    let spy = InstrumentNameExchange::new("SPY");
11771                    let time_in_force = TimeInForce::GoodUntilCancelled { post_only: false };
11772
11773                    let state = if bracket {
11774                        client
11775                            .open_bracket_order(AlpacaBracketOrderRequest::new(
11776                                spy,
11777                                StrategyId::new("strategy"),
11778                                ClientOrderId::new(cid),
11779                                Side::Buy,
11780                                Decimal::TWO,
11781                                Decimal::ONE_HUNDRED,
11782                                Decimal::new(120, 0),
11783                                Decimal::new(90, 0),
11784                                time_in_force,
11785                            ))
11786                            .await
11787                            .parent
11788                            .state
11789                    } else {
11790                        let request = OrderRequestOpen {
11791                            key: OrderKey::new(
11792                                ExchangeId::AlpacaBroker,
11793                                &spy,
11794                                StrategyId::new("strategy"),
11795                                ClientOrderId::new(cid),
11796                            ),
11797                            state: RequestOpen::new(
11798                                Side::Buy,
11799                                Some(Decimal::ONE_HUNDRED),
11800                                Decimal::TWO,
11801                                OrderKind::Limit,
11802                                time_in_force,
11803                            ),
11804                        };
11805                        client.open_order(request).await.state
11806                    };
11807
11808                    assert_eq!(state, expected, "{status} {filled}, bracket: {bracket}");
11809                    assert_eq!(
11810                        client.known_live.lock().contains(&ClientOrderId::new(cid)),
11811                        held,
11812                        "{status} {filled}, bracket: {bracket}"
11813                    );
11814                }
11815            }
11816        }
11817
11818        /// The stream holds an acknowledged order, keeps holding one done for the day (reported
11819        /// open, not retired), and drops one cancelled.
11820        #[test]
11821        fn the_stream_keeps_the_held_orders_in_step() {
11822            let (tx, mut rx) = mpsc::unbounded_channel();
11823            let dedup = new_dedup_cache();
11824            let known = KnownLiveOrders::shared(ExchangeId::AlpacaBroker);
11825            let frame = |event: &str, filled: &str| {
11826                format!(
11827                    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}"}}}}}}"#
11828                )
11829            };
11830            let cid = ClientOrderId::new("a");
11831
11832            process_ws_text(
11833                &frame("new", "0"),
11834                &tx,
11835                &dedup,
11836                &known,
11837                &mut VenueClock::new(),
11838            );
11839            assert!(known.lock().contains(&cid), "acknowledged");
11840
11841            process_ws_text(
11842                &frame("done_for_day", "1"),
11843                &tx,
11844                &dedup,
11845                &known,
11846                &mut VenueClock::new(),
11847            );
11848            assert!(known.lock().contains(&cid), "done for the day is not ended");
11849
11850            process_ws_text(
11851                &frame("canceled", "1"),
11852                &tx,
11853                &dedup,
11854                &known,
11855                &mut VenueClock::new(),
11856            );
11857            assert!(!known.lock().contains(&cid), "cancelled");
11858
11859            let events: Vec<_> = std::iter::from_fn(|| rx.try_recv().ok()).collect();
11860            let [_, done, cancelled] = events.as_slice() else {
11861                panic!("three events: {events:?}");
11862            };
11863            let AccountEventKind::OrderSnapshot(
11864                rustrade_integration::collection::snapshot::Snapshot(order),
11865            ) = &done.kind
11866            else {
11867                panic!("done_for_day reports an order snapshot: {done:?}");
11868            };
11869            let OrderState::Active(ActiveOrderState::Open(open)) = &order.state else {
11870                panic!("open: {order:?}");
11871            };
11872            assert_eq!(open.filled_quantity, Decimal::ONE, "with what it filled");
11873            assert!(matches!(
11874                cancelled.kind,
11875                AccountEventKind::OrderCancelled(_)
11876            ));
11877        }
11878
11879        /// A recovered fill that brings an order to its full quantity ends it, so a reconnect's
11880        /// check does not ask about it.
11881        #[tokio::test]
11882        async fn a_recovered_fill_that_completes_an_order_stops_it_being_held() {
11883            let known = KnownLiveOrders::shared(ExchangeId::AlpacaBroker);
11884            let key = alpaca_key("SPY", "a");
11885            known.lock().live(
11886                &key,
11887                Decimal::TWO,
11888                OrderKind::Limit,
11889                &Open::new(
11890                    VenueOrderId::Assigned(OrderId::new("ord-1")),
11891                    Utc::now(),
11892                    Decimal::ZERO,
11893                ),
11894            );
11895
11896            drive_recover_fills_with(
11897                vec![activity_json("act-1", "ord-1", "1", Some("1"))],
11898                new_dedup_cache(),
11899                &known,
11900            )
11901            .await;
11902            assert!(known.lock().contains(&key.cid), "half filled");
11903
11904            drive_recover_fills_with(
11905                vec![activity_json("act-2", "ord-1", "1", Some("2"))],
11906                new_dedup_cache(),
11907                &known,
11908            )
11909            .await;
11910            assert!(!known.lock().contains(&key.cid), "filled");
11911        }
11912
11913        /// The first check runs as soon as the loop is polled, and a failed one waits for its
11914        /// backoff rather than running again at once.
11915        #[tokio::test]
11916        async fn the_check_loop_runs_at_once_then_waits_out_a_failure() {
11917            let server = MockServer::start().await;
11918            Mock::given(method("GET"))
11919                .and(path("/v2/orders"))
11920                .respond_with(ResponseTemplate::new(503))
11921                .expect(1)
11922                .mount(&server)
11923                .await;
11924            let known = KnownLiveOrders::shared(ExchangeId::AlpacaBroker);
11925            hold(&known, &alpaca_key("SPY", "a"));
11926            let mut unchecked = UncheckedOrders::default();
11927            unchecked.open(known.lock().instruments());
11928            let config = Arc::new(AlpacaConfig::with_base_url(
11929                "test-key".into(),
11930                "test-secret".into(),
11931                server.uri(),
11932            ));
11933            let (tx, _rx) = mpsc::unbounded_channel();
11934
11935            let ran = tokio::time::timeout(
11936                Duration::from_secs(1),
11937                run_order_checks(
11938                    &reqwest::Client::new(),
11939                    &Arc::new(RateLimitTracker::new()),
11940                    &config,
11941                    &known,
11942                    &mut unchecked,
11943                    &tx,
11944                ),
11945            )
11946            .await;
11947
11948            assert!(ran.is_err(), "the loop never completes");
11949            assert!(
11950                unchecked.contains(&InstrumentNameExchange::new("SPY")),
11951                "SPY waits for its retry"
11952            );
11953            assert_eq!(
11954                server.received_requests().await.unwrap().len(),
11955                1,
11956                "the failed check is not run again at once"
11957            );
11958        }
11959
11960        /// Once the consumer has gone, the loop asks nothing.
11961        #[tokio::test]
11962        async fn the_check_loop_waits_once_the_consumer_has_gone() {
11963            let server = MockServer::start().await;
11964            Mock::given(method("GET"))
11965                .respond_with(ResponseTemplate::new(503))
11966                .expect(0)
11967                .mount(&server)
11968                .await;
11969            let known = KnownLiveOrders::shared(ExchangeId::AlpacaBroker);
11970            hold(&known, &alpaca_key("SPY", "a"));
11971            let mut unchecked = UncheckedOrders::default();
11972            unchecked.open(known.lock().instruments());
11973            let config = Arc::new(AlpacaConfig::with_base_url(
11974                "test-key".into(),
11975                "test-secret".into(),
11976                server.uri(),
11977            ));
11978            let (tx, rx) = mpsc::unbounded_channel();
11979            drop(rx);
11980
11981            let ran = tokio::time::timeout(
11982                Duration::from_millis(200),
11983                run_order_checks(
11984                    &reqwest::Client::new(),
11985                    &Arc::new(RateLimitTracker::new()),
11986                    &config,
11987                    &known,
11988                    &mut unchecked,
11989                    &tx,
11990                ),
11991            )
11992            .await;
11993
11994            assert!(ran.is_err(), "the loop never completes");
11995            assert!(
11996                server.received_requests().await.unwrap().is_empty(),
11997                "nothing asked once the consumer has gone"
11998            );
11999        }
12000
12001        async fn shortability_with_asset(
12002            symbol: &str,
12003            asset: serde_json::Value,
12004        ) -> (Result<Shortability, UnindexedClientError>, Vec<String>) {
12005            use crate::client::ShortabilityClient;
12006
12007            let server = MockServer::start().await;
12008            Mock::given(method("GET"))
12009                .respond_with(ResponseTemplate::new(200).set_body_json(asset))
12010                .mount(&server)
12011                .await;
12012            let result = client_for(&server)
12013                .fetch_shortability(&InstrumentNameExchange::new(symbol))
12014                .await;
12015            let paths = server
12016                .received_requests()
12017                .await
12018                .unwrap()
12019                .iter()
12020                .map(|request| request.url.path().to_owned())
12021                .collect();
12022            (result, paths)
12023        }
12024
12025        #[tokio::test]
12026        async fn an_easy_to_borrow_stock_is_shortable_at_no_fee() {
12027            let (result, paths) = shortability_with_asset(
12028                "AAPL",
12029                serde_json::json!({
12030                    "class": "us_equity",
12031                    "symbol": "AAPL",
12032                    "shortable": true,
12033                    "easy_to_borrow": true,
12034                    "borrow_status": "easy_to_borrow",
12035                }),
12036            )
12037            .await;
12038
12039            assert_eq!(
12040                result.unwrap(),
12041                Shortability::new()
12042                    .with_shortable(true)
12043                    .with_easy_to_borrow(true)
12044                    .with_fee_rate(Decimal::ZERO)
12045            );
12046            assert_eq!(paths, vec!["/v2/assets/AAPL"]);
12047        }
12048
12049        #[tokio::test]
12050        async fn a_hard_to_borrow_stock_has_no_known_fee() {
12051            let (result, _) = shortability_with_asset(
12052                "GME",
12053                serde_json::json!({
12054                    "class": "us_equity",
12055                    "shortable": true,
12056                    "easy_to_borrow": true,
12057                    "borrow_status": "hard_to_borrow",
12058                }),
12059            )
12060            .await;
12061
12062            assert_eq!(
12063                result.unwrap(),
12064                Shortability::new()
12065                    .with_shortable(true)
12066                    .with_easy_to_borrow(false),
12067                "borrow_status wins over the easy_to_borrow flag, and a hard-to-borrow fee is unknown"
12068            );
12069        }
12070
12071        #[tokio::test]
12072        async fn without_a_borrow_status_the_easy_to_borrow_flag_is_read() {
12073            let (result, _) = shortability_with_asset(
12074                "AAPL",
12075                serde_json::json!({ "class": "us_equity", "shortable": false, "easy_to_borrow": false }),
12076            )
12077            .await;
12078
12079            assert_eq!(
12080                result.unwrap(),
12081                Shortability::new()
12082                    .with_shortable(false)
12083                    .with_easy_to_borrow(false)
12084            );
12085        }
12086
12087        #[tokio::test]
12088        async fn an_asset_without_borrow_fields_is_unknown_but_for_shortable() {
12089            let (result, paths) = shortability_with_asset(
12090                "BTC/USD",
12091                serde_json::json!({ "class": "crypto", "symbol": "BTC/USD", "shortable": false }),
12092            )
12093            .await;
12094
12095            assert_eq!(result.unwrap(), Shortability::new().with_shortable(false));
12096            assert_eq!(
12097                paths,
12098                vec!["/v2/assets/BTC%2FUSD"],
12099                "a crypto pair's slash is escaped, not read as a path separator"
12100            );
12101        }
12102
12103        #[tokio::test]
12104        async fn an_option_has_no_shortability() {
12105            let (result, _) = shortability_with_asset(
12106                "AAPL250117C00150000",
12107                serde_json::json!({ "class": "us_option", "shortable": true }),
12108            )
12109            .await;
12110
12111            assert!(
12112                matches!(
12113                    result,
12114                    Err(UnindexedClientError::Api(ApiError::InstrumentInvalid(ref instrument, ref reason)))
12115                        if instrument.name().as_str() == "AAPL250117C00150000"
12116                            && reason.contains("option")
12117                ),
12118                "{result:?}"
12119            );
12120        }
12121
12122        #[tokio::test]
12123        async fn an_unknown_symbol_is_an_invalid_instrument() {
12124            use crate::client::ShortabilityClient;
12125
12126            let server = MockServer::start().await;
12127            Mock::given(method("GET"))
12128                .and(path("/v2/assets/NOPE"))
12129                .respond_with(
12130                    ResponseTemplate::new(404).set_body_json(
12131                        serde_json::json!({ "message": "asset not found for NOPE" }),
12132                    ),
12133                )
12134                .mount(&server)
12135                .await;
12136
12137            let result = client_for(&server)
12138                .fetch_shortability(&InstrumentNameExchange::new("NOPE"))
12139                .await;
12140
12141            assert!(
12142                matches!(
12143                    result,
12144                    Err(UnindexedClientError::Api(ApiError::InstrumentInvalid(ref instrument, _)))
12145                        if instrument.name().as_str() == "NOPE"
12146                ),
12147                "{result:?}"
12148            );
12149        }
12150
12151        /// Answers every request with a 429. Every answer but the last says `Retry-After: 0`, so
12152        /// the client's retries do not wait; the last carries `last`'s header, if any.
12153        struct RateLimited {
12154            call: std::sync::atomic::AtomicU32,
12155            last: Option<(&'static str, String)>,
12156        }
12157
12158        impl RateLimited {
12159            fn mount(last: Option<(&'static str, String)>) -> Self {
12160                Self {
12161                    call: std::sync::atomic::AtomicU32::new(0),
12162                    last,
12163                }
12164            }
12165        }
12166
12167        impl Respond for RateLimited {
12168            fn respond(&self, _: &Request) -> ResponseTemplate {
12169                let call = self.call.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1;
12170                let response = ResponseTemplate::new(429)
12171                    .set_body_json(serde_json::json!({"message": "too many requests"}));
12172                if call < MAX_RATE_LIMIT_ATTEMPTS {
12173                    return response.insert_header("retry-after", "0");
12174                }
12175                match &self.last {
12176                    Some((name, value)) => response.insert_header(*name, value.as_str()),
12177                    None => response,
12178                }
12179            }
12180        }
12181
12182        fn unix_now() -> u64 {
12183            std::time::SystemTime::now()
12184                .duration_since(std::time::UNIX_EPOCH)
12185                .unwrap()
12186                .as_secs()
12187        }
12188
12189        #[tokio::test]
12190        async fn exhausted_429_retries_report_alpacas_wait() {
12191            let server = MockServer::start().await;
12192            Mock::given(method("GET"))
12193                .and(path("/v2/account"))
12194                .respond_with(RateLimited::mount(Some(("retry-after", "7".to_string()))))
12195                .mount(&server)
12196                .await;
12197
12198            let http = reqwest::Client::new();
12199            let rl = RateLimitTracker::new();
12200            let url = format!("{}/v2/account", server.uri());
12201            let error = rest_with_retry::<serde_json::Value>(&rl, || http.get(&url))
12202                .await
12203                .unwrap_err();
12204
12205            let UnindexedClientError::Api(error) = error else {
12206                panic!("expected Api, got {error:?}");
12207            };
12208            assert_eq!(error.retry_after(), Some(Duration::from_secs(7)));
12209            let blocked_until = rl
12210                .blocked_until
12211                .lock()
12212                .expect("the last refusal pauses the client");
12213            assert!(
12214                blocked_until >= tokio::time::Instant::now() + Duration::from_secs(6),
12215                "the client's next request waits for Alpaca's wait"
12216            );
12217            assert_eq!(
12218                server.received_requests().await.unwrap().len(),
12219                MAX_RATE_LIMIT_ATTEMPTS as usize
12220            );
12221        }
12222
12223        #[tokio::test]
12224        async fn exhausted_429_retries_read_the_reset_time_without_retry_after() {
12225            let server = MockServer::start().await;
12226            Mock::given(method("GET"))
12227                .and(path("/v2/account"))
12228                .respond_with(RateLimited::mount(Some((
12229                    "x-ratelimit-reset",
12230                    (unix_now() + 30).to_string(),
12231                ))))
12232                .mount(&server)
12233                .await;
12234
12235            let http = reqwest::Client::new();
12236            let rl = RateLimitTracker::new();
12237            let url = format!("{}/v2/account", server.uri());
12238            let error = rest_with_retry::<serde_json::Value>(&rl, || http.get(&url))
12239                .await
12240                .unwrap_err();
12241
12242            let UnindexedClientError::Api(ApiError::RateLimit {
12243                retry_after: Some(wait),
12244                ..
12245            }) = error
12246            else {
12247                panic!("expected a RateLimit with a wait, got {error:?}");
12248            };
12249            assert!(
12250                (Duration::from_secs(29)..=Duration::from_secs(30)).contains(&wait),
12251                "{wait:?}"
12252            );
12253        }
12254
12255        #[tokio::test]
12256        async fn exhausted_429_retries_without_a_hint_report_none() {
12257            let server = MockServer::start().await;
12258            Mock::given(method("GET"))
12259                .and(path("/v2/account"))
12260                .respond_with(RateLimited::mount(None))
12261                .mount(&server)
12262                .await;
12263
12264            let http = reqwest::Client::new();
12265            let rl = RateLimitTracker::new();
12266            let url = format!("{}/v2/account", server.uri());
12267            let error = rest_with_retry::<serde_json::Value>(&rl, || http.get(&url))
12268                .await
12269                .unwrap_err();
12270
12271            assert!(
12272                matches!(
12273                    error,
12274                    UnindexedClientError::Api(ApiError::RateLimit {
12275                        retry_after: None,
12276                        ..
12277                    })
12278                ),
12279                "{error:?}"
12280            );
12281        }
12282
12283        #[tokio::test]
12284        async fn an_exhausted_cancel_reports_alpacas_wait() {
12285            let server = MockServer::start().await;
12286            Mock::given(method("DELETE"))
12287                .and(path("/v2/orders/abc"))
12288                .respond_with(RateLimited::mount(Some(("retry-after", "5".to_string()))))
12289                .mount(&server)
12290                .await;
12291
12292            let http = reqwest::Client::new();
12293            let rl = RateLimitTracker::new();
12294            let url = format!("{}/v2/orders/abc", server.uri());
12295            let error = rest_delete_with_retry(&rl, || http.delete(&url))
12296                .await
12297                .unwrap_err();
12298
12299            let UnindexedOrderError::Rejected(error) = error else {
12300                panic!("expected Rejected, got {error:?}");
12301            };
12302            assert_eq!(error.retry_after(), Some(Duration::from_secs(5)));
12303            assert!(
12304                rl.blocked_until.lock().is_some(),
12305                "the last refusal pauses the client"
12306            );
12307        }
12308    }
12309}