Skip to main content

rustrade_execution/client/alpaca/
mod.rs

1// Alpaca ExecutionClient implementation
2//
3// Uses raw reqwest for REST and rustrade-integration tungstenite for WebSocket.
4// No official Alpaca Rust SDK — built directly on reqwest + rustrade-integration
5// to avoid supply chain risk in a trading system that handles real money.
6//
7// Architecture:
8// - REST (reqwest): account_snapshot, fetch_balances, fetch_open_orders,
9//   fetch_trades, open_order, cancel_order
10// - WebSocket (tungstenite): account_stream via Alpaca's trade_updates stream
11//   at wss://[paper-]api.alpaca.markets/stream
12//
13// Auth: header-based (APCA-API-KEY-ID + APCA-API-SECRET-KEY), no HMAC signing.
14// A reqwest::Client is built with these as default headers so every request
15// carries them automatically.
16//
17// Resilience features:
18// - Rate limit handling: reads X-Ratelimit-Remaining / X-Ratelimit-Reset headers;
19//   backs off on 429 with up to MAX_RATE_LIMIT_ATTEMPTS total attempts
20// - Reconnection: account_stream reconnects on WS close/error with exponential
21//   backoff (1 s → 30 s, max 10 attempts)
22// - Heartbeat monitoring: reconnects if no WS message for HEARTBEAT_TIMEOUT_SECS
23// - Fill recovery: after reconnect, fetches missed fills via GET /v2/account/activities
24//   since disconnect_time; sent through the dedup cache to filter duplicates
25// - Dedup cache: LRU keyed on "{order_id}:{cumulative_filled_qty}" prevents
26//   duplicate fills arising from the overlap between WS events before disconnect
27//   and the fill-recovery REST window
28//
29// Known limitations:
30// - Only FILL activities are recovered after reconnect; order lifecycle events
31//   (new, cancelled, expired) are not — callers must call fetch_open_orders after
32//   each reconnect to reconcile open-order state.
33
34use crate::{
35    AccountEventKind, AccountSnapshot, InstrumentAccountSnapshot, UnindexedAccountEvent,
36    UnindexedAccountSnapshot,
37    balance::{AssetBalance, Balance},
38    client::{BracketOrderClient, ExecutionClient},
39    error::{ApiError, ConnectivityError, OrderError, UnindexedClientError, UnindexedOrderError},
40    order::{
41        Order, OrderKey, OrderKind, TimeInForce, TrailingOffsetType,
42        bracket::{
43            BracketOrderRequest as UnifiedBracketOrderRequest,
44            BracketOrderResult as UnifiedBracketOrderResult,
45        },
46        id::{ClientOrderId, OrderId, StrategyId},
47        request::{OrderRequestCancel, OrderRequestOpen, UnindexedOrderResponseCancel},
48        state::{Cancelled, Filled, Open, OrderState, UnindexedOrderState},
49    },
50    trade::{AssetFees, Trade, TradeId},
51};
52use chrono::{DateTime, Utc};
53use fnv::FnvHashMap;
54use futures::{SinkExt as _, StreamExt as _, stream::BoxStream};
55use indexmap::IndexMap;
56use itertools::Itertools as _;
57use lru::LruCache;
58use rust_decimal::Decimal;
59use rustrade_instrument::{
60    Side, asset::name::AssetNameExchange, exchange::ExchangeId,
61    instrument::name::InstrumentNameExchange,
62};
63use rustrade_integration::protocol::websocket::{WebSocket, WsMessage};
64use serde::{Deserialize, Serialize};
65use smol_str::{SmolStr, format_smolstr};
66use std::{num::NonZeroUsize, pin::Pin, str::FromStr, sync::Arc, time::Duration};
67use tokio::sync::mpsc;
68use tracing::{debug, error, info, trace, warn};
69
70// ---------------------------------------------------------------------------
71// Constants
72// ---------------------------------------------------------------------------
73
74const INITIAL_BACKOFF_MS: u64 = 1_000;
75const MAX_BACKOFF_MS: u64 = 30_000;
76const MAX_RECONNECT_ATTEMPTS: u32 = 10;
77/// If no WS activity for this long, force reconnect.
78const HEARTBEAT_TIMEOUT_SECS: u64 = 35;
79/// Timeout for fill recovery REST queries after reconnect.
80const FILL_RECOVERY_TIMEOUT_SECS: u64 = 30;
81/// Extra lookback from disconnect timestamp to cover Tokio scheduling jitter and
82/// client/server clock drift on cloud VMs. The dedup cache absorbs resulting duplicates.
83const SIGNAL_RECOVERY_LOOKBACK_MS: i64 = 1_500;
84/// Alpaca's activity page size limit.
85const ALPACA_MAX_ACTIVITIES: usize = 100;
86/// Default cooldown when rate-limited (if X-Ratelimit-Reset header is absent).
87const DEFAULT_RATE_LIMIT_DELAY_SECS: u64 = 60;
88/// Total REST attempts (1 initial + retries) before giving up on rate-limit errors.
89/// The loop runs `0..MAX_RATE_LIMIT_ATTEMPTS`, retrying while `attempt + 1 < MAX`.
90const MAX_RATE_LIMIT_ATTEMPTS: u32 = 4;
91/// Dedup LRU cache size. Each entry is a ~50–70 byte String (UUID + decimal).
92/// 2_000 entries ≈ 120–140 KB — ample for options trading fill rates.
93const DEDUP_CACHE_SIZE: usize = 2_000;
94/// Timeout for the initial WS auth+subscribe handshake.
95const WS_HANDSHAKE_TIMEOUT_SECS: u64 = 15;
96/// Timeout for a graceful WS close. Prevents indefinite blocking when the
97/// server does not respond to the close frame before reconnect/shutdown.
98const WS_CLOSE_TIMEOUT_SECS: u64 = 5;
99
100// ---------------------------------------------------------------------------
101// GracefulShutdownStream
102// ---------------------------------------------------------------------------
103
104/// Wrapper stream that signals the `connection_manager` task to shut down gracefully
105/// when dropped, allowing it to send an orderly WebSocket close frame.
106///
107/// # Shutdown sequence
108/// Dropping this stream drops `inner` (the channel receiver), which makes `tx.closed()`
109/// resolve on the `connection_manager`'s next `select!` poll. The `tx.closed()` arm
110/// sends a WebSocket close frame (with `WS_CLOSE_TIMEOUT_SECS` timeout) and then
111/// returns, dropping the task cleanly.
112///
113/// `JoinHandle::drop` detaches the task — it is NOT aborted. The task exits within
114/// the current `select!` iteration (if the receiver is already dropped when polled)
115/// or after the current heartbeat window / backoff sleep at most.
116struct GracefulShutdownStream<S> {
117    inner: S,
118    /// Keeps the `JoinHandle` alive until this stream is dropped. Dropping
119    /// the `JoinHandle` detaches (not cancels) the task, allowing it to keep
120    /// running until `tx.closed()` resolves. Without this field the handle
121    /// would be detached immediately at `connection_manager` spawn time,
122    /// preventing any future `.await` or abort if the design changes.
123    _handle: tokio::task::JoinHandle<()>,
124}
125
126impl<S> GracefulShutdownStream<S> {
127    fn new(inner: S, handle: tokio::task::JoinHandle<()>) -> Self {
128        Self {
129            inner,
130            _handle: handle,
131        }
132    }
133}
134
135impl<S: futures::Stream + Unpin> futures::Stream for GracefulShutdownStream<S> {
136    type Item = S::Item;
137    fn poll_next(
138        mut self: Pin<&mut Self>,
139        cx: &mut std::task::Context<'_>,
140    ) -> std::task::Poll<Option<Self::Item>> {
141        Pin::new(&mut self.inner).poll_next(cx)
142    }
143    fn size_hint(&self) -> (usize, Option<usize>) {
144        self.inner.size_hint()
145    }
146}
147
148impl<S> Drop for GracefulShutdownStream<S> {
149    fn drop(&mut self) {
150        // Do not abort. Dropping `self.inner` (the channel receiver) makes `tx.closed()`
151        // resolve, which causes `connection_manager` to send a graceful WS close frame
152        // and return. Dropping the JoinHandle here detaches (not cancels) the task.
153    }
154}
155
156// ---------------------------------------------------------------------------
157// Rate limit tracker
158// ---------------------------------------------------------------------------
159
160/// Thread-safe rate-limit state shared across all clones of AlpacaClient.
161struct RateLimitTracker {
162    blocked_until: parking_lot::Mutex<Option<tokio::time::Instant>>,
163}
164
165impl RateLimitTracker {
166    fn new() -> Self {
167        Self {
168            blocked_until: parking_lot::Mutex::new(None),
169        }
170    }
171
172    /// Sleep until the current cooldown expires. Returns immediately if not blocked.
173    async fn wait_if_blocked(&self) {
174        loop {
175            // Capture the current time once per iteration — reused for both the
176            // expired-deadline check inside the lock and the sleep calculation below.
177            // Eliminates one vDSO call per REST request on the common non-blocked path.
178            let now = tokio::time::Instant::now();
179            // Read and conditionally clear the deadline in a single lock acquisition.
180            // The guard is dropped before the `.await` below — holding a sync Mutex
181            // across an await would deadlock. A TOCTOU window still exists between the
182            // guard drop and `sleep_until`: a concurrent `on_rate_limited` call could
183            // extend the deadline after we read it. The loop re-reads on wake and
184            // corrects any extended deadline, so the race is recovered from on the next
185            // iteration rather than being fully prevented.
186            let deadline = {
187                let mut guard = self.blocked_until.lock();
188                let d = *guard;
189                if matches!(d, Some(t) if t <= now) {
190                    // Clear expired deadline so on_rate_limited correctly
191                    // distinguishes "new rate-limit event" from "extended cooldown".
192                    *guard = None;
193                }
194                d
195            };
196            match deadline {
197                None => return,
198                Some(until) => {
199                    if until <= now {
200                        // Deadline was expired and cleared above; no sleep needed.
201                        return;
202                    }
203                    // as_millis() returns u128; truncation impossible (u64::MAX ms ≈ 584M years)
204                    #[allow(clippy::cast_possible_truncation)]
205                    let delay_ms = (until - now).as_millis() as u64;
206                    debug!(delay_ms, "Alpaca REST rate-limited, waiting before request");
207                    tokio::time::sleep_until(until).await;
208                }
209            }
210        }
211    }
212
213    /// Record a rate-limit event, extending any existing cooldown if longer.
214    fn on_rate_limited(&self, retry_after: Option<Duration>) {
215        let delay = retry_after.unwrap_or(Duration::from_secs(DEFAULT_RATE_LIMIT_DELAY_SECS));
216        let new_deadline = tokio::time::Instant::now() + delay;
217        let mut guard = self.blocked_until.lock();
218        let was_blocked = guard.is_some();
219        *guard = Some(guard.map_or(new_deadline, |existing| existing.max(new_deadline)));
220        if was_blocked {
221            debug!(
222                delay_secs = delay.as_secs(),
223                "Alpaca rate-limit cooldown extended"
224            );
225        } else {
226            warn!(
227                delay_secs = delay.as_secs(),
228                "Alpaca entering rate-limit degradation mode"
229            );
230        }
231    }
232}
233
234// ---------------------------------------------------------------------------
235// Exponential backoff
236// ---------------------------------------------------------------------------
237
238struct ExponentialBackoff {
239    attempt: u32,
240    max_attempts: u32,
241    initial_ms: u64,
242    max_ms: u64,
243}
244
245impl ExponentialBackoff {
246    fn new() -> Self {
247        Self {
248            attempt: 0,
249            max_attempts: MAX_RECONNECT_ATTEMPTS,
250            initial_ms: INITIAL_BACKOFF_MS,
251            max_ms: MAX_BACKOFF_MS,
252        }
253    }
254
255    fn reset(&mut self) {
256        self.attempt = 0;
257    }
258
259    /// Waits for the current backoff duration. Returns `false` if max attempts exhausted.
260    async fn wait(&mut self) -> bool {
261        if self.attempt >= self.max_attempts {
262            return false;
263        }
264        let delay_ms = self
265            .initial_ms
266            .saturating_mul(2u64.saturating_pow(self.attempt))
267            .min(self.max_ms);
268        self.attempt += 1;
269        debug!(
270            attempt = self.attempt,
271            max = self.max_attempts,
272            delay_ms,
273            "Alpaca reconnect backoff"
274        );
275        tokio::time::sleep(Duration::from_millis(delay_ms)).await;
276        true
277    }
278}
279
280// ---------------------------------------------------------------------------
281// Dedup cache
282// ---------------------------------------------------------------------------
283
284/// LRU cache keyed on `trade.id`, which both paths synthesise as
285/// `"{order_id}:{cumulative_filled_qty}"`.
286///
287/// WS fills: `convert_trade_update` sets `trade_id = order.id + ":" + order.filled_qty`
288/// (cumulative from the order update payload).
289///
290/// REST fills: `recover_fills` accumulates per-execution qty per order and overrides
291/// `trade.id` to the same format before inserting into the cache.
292///
293/// Using cumulative qty (not per-execution qty) means two equal-size partial fills
294/// on the same order produce distinct keys (`order:1` and `order:2`), preventing
295/// silent fill drops.
296///
297/// [`SmolStr`] keys avoid heap allocation for IDs ≤22 bytes. UUID-length keys
298/// (36 chars) always heap-allocate in `SmolStr`; `format_smolstr!` uses an
299/// internal `String` buffer for long keys, identical in allocation cost to
300/// `format!(…).into::<SmolStr>()`. The type is kept for API consistency with
301/// other key types in this codebase.
302type SharedDedupCache = Arc<parking_lot::Mutex<LruCache<SmolStr, ()>>>;
303
304fn new_dedup_cache() -> SharedDedupCache {
305    // allow(clippy::unwrap_used) — NonZeroUsize::new on a non-zero constant
306    // cannot fail at runtime.
307    #[allow(clippy::unwrap_used)]
308    Arc::new(parking_lot::Mutex::new(LruCache::new(
309        NonZeroUsize::new(DEDUP_CACHE_SIZE).unwrap(),
310    )))
311}
312
313/// Returns `true` if this key was already seen (duplicate). Inserts if new.
314fn is_duplicate(cache: &SharedDedupCache, key: &SmolStr) -> bool {
315    let mut guard = cache.lock();
316    // peek avoids promoting to MRU on the duplicate (discard) path
317    if guard.peek(key).is_some() {
318        return true;
319    }
320    // Clone on the insert (non-duplicate) path only. UUID-length SmolStr keys
321    // heap-allocate, but the WS path is single-threaded — there is no mutex
322    // contention to justify cloning before the lock on the duplicate fast-path.
323    guard.put(key.clone(), ());
324    false
325}
326
327// ---------------------------------------------------------------------------
328// Configuration
329// ---------------------------------------------------------------------------
330
331/// Configuration for the Alpaca execution client.
332// Serialize intentionally omitted — would expose secret_key in plaintext.
333#[derive(Clone, Deserialize)]
334#[serde(deny_unknown_fields)]
335pub struct AlpacaConfig {
336    // Private fields prevent accidental credential exposure via struct access.
337    api_key: String,
338    secret_key: String,
339    /// Use paper trading endpoints instead of production.
340    pub paper: bool,
341    /// Test-only: override the REST base URL (e.g., to point at a wiremock server).
342    #[cfg(test)]
343    pub base_url_override: Option<String>,
344}
345
346// Custom Debug to avoid leaking credentials in logs.
347impl std::fmt::Debug for AlpacaConfig {
348    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
349        f.debug_struct("AlpacaConfig")
350            .field("api_key", &"***")
351            .field("secret_key", &"***")
352            .field("paper", &self.paper)
353            .finish()
354    }
355}
356
357impl AlpacaConfig {
358    pub fn new(api_key: String, secret_key: String, paper: bool) -> Self {
359        Self {
360            api_key,
361            secret_key,
362            paper,
363            #[cfg(test)]
364            base_url_override: None,
365        }
366    }
367
368    /// Test-only: create config with a custom base URL for wiremock testing.
369    #[cfg(test)]
370    pub fn with_base_url(api_key: String, secret_key: String, base_url: String) -> Self {
371        Self {
372            api_key,
373            secret_key,
374            paper: true,
375            base_url_override: Some(base_url),
376        }
377    }
378
379    pub fn api_key(&self) -> &str {
380        &self.api_key
381    }
382
383    /// Base URL for REST API calls.
384    ///
385    /// In test builds, checks `base_url_override` first to allow wiremock testing.
386    pub fn rest_base_url(&self) -> &str {
387        #[cfg(test)]
388        if let Some(ref url) = self.base_url_override {
389            return url.as_str();
390        }
391        if self.paper {
392            "https://paper-api.alpaca.markets"
393        } else {
394            "https://api.alpaca.markets"
395        }
396    }
397
398    /// WebSocket URL for trade_updates stream.
399    pub fn ws_url(&self) -> &'static str {
400        if self.paper {
401            "wss://paper-api.alpaca.markets/stream"
402        } else {
403            "wss://api.alpaca.markets/stream"
404        }
405    }
406}
407
408// ---------------------------------------------------------------------------
409// REST response serde types
410// ---------------------------------------------------------------------------
411
412#[derive(Debug, Deserialize)]
413struct AlpacaAccount {
414    equity: String,
415    buying_power: String,
416    options_buying_power: Option<String>,
417    // crypto_buying_power is present in Alpaca's account response and represents
418    // available buying power specifically for crypto orders. Not currently used in
419    // balance logic (buying_power serves as the general free USD balance), but
420    // retained so serde doesn't error on accounts where the field is present.
421    #[allow(dead_code)]
422    // retained for serde completeness; may be used for per-asset-class reporting
423    crypto_buying_power: Option<String>,
424}
425
426/// A single position returned by GET /v2/positions.
427#[derive(Debug, Deserialize)]
428struct AlpacaPosition {
429    /// Exchange symbol (e.g., "BTC/USD" for crypto, "AAPL" for equity).
430    symbol: String,
431    /// Asset class: "us_equity", "crypto", "us_option".
432    asset_class: String,
433    /// Total quantity held (base currency for crypto).
434    qty: String,
435    /// Quantity available to trade (not locked in open orders).
436    qty_available: String,
437}
438
439#[derive(Debug, Deserialize)]
440struct AlpacaOrderResponse {
441    id: String,
442    client_order_id: Option<String>,
443    symbol: String,
444    qty: Option<String>,
445    filled_qty: String,
446    side: String,
447    #[serde(rename = "type")]
448    order_type: String,
449    time_in_force: String,
450    limit_price: Option<String>,
451    stop_price: Option<String>,
452    trail_percent: Option<String>,
453    trail_price: Option<String>,
454    created_at: String,
455}
456
457#[derive(Debug, Deserialize)]
458struct AlpacaActivity {
459    id: String,
460    order_id: String,
461    symbol: String,
462    side: String,
463    price: String,
464    qty: String,
465    transaction_time: String,
466}
467
468#[derive(Debug, Deserialize)]
469struct AlpacaApiError {
470    message: String,
471}
472
473// ---------------------------------------------------------------------------
474// AlpacaPositionIntent
475// ---------------------------------------------------------------------------
476
477/// Explicit position intent for Alpaca order placement.
478///
479/// Required for options orders; valid (but optional) for equities.
480/// Omit entirely for crypto orders (causes 422 Unprocessable Entity).
481///
482/// Use `AlpacaClient::open_order_with_intent` to supply a specific intent
483/// instead of the heuristic mapping used by the `ExecutionClient` trait impl.
484#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
485#[serde(rename_all = "snake_case")]
486pub enum AlpacaPositionIntent {
487    BuyToOpen,
488    BuyToClose,
489    SellToOpen,
490    SellToClose,
491}
492
493// ---------------------------------------------------------------------------
494// Bracket order types
495// ---------------------------------------------------------------------------
496
497/// Take-profit parameters for Alpaca bracket orders.
498///
499/// The take-profit leg is always a limit order at the specified price.
500#[derive(Debug, Serialize)]
501struct TakeProfitParams {
502    limit_price: String,
503}
504
505/// Stop-loss parameters for Alpaca bracket orders.
506///
507/// When `limit_price` is `None`, the stop-loss is a stop (market) order.
508/// When `limit_price` is `Some`, the stop-loss becomes a stop-limit order.
509#[derive(Debug, Serialize)]
510struct StopLossParams {
511    stop_price: String,
512    #[serde(skip_serializing_if = "Option::is_none")]
513    limit_price: Option<String>,
514}
515
516/// Request to place a bracket order (entry + take-profit + stop-loss).
517///
518/// A bracket order consists of three linked orders submitted in a single request:
519/// 1. **Entry**: Limit order to enter the position
520/// 2. **Take Profit**: Limit order to exit at profit target
521/// 3. **Stop Loss**: Stop or stop-limit order to exit at loss limit
522///
523/// When either the take-profit or stop-loss fills, Alpaca automatically cancels
524/// the other leg.
525///
526/// # Constraints
527///
528/// - `time_in_force` must be `Day` or `GoodUntilCancelled` (no extended hours)
529/// - Entry order type is always `Limit`
530/// - Take-profit is always a `Limit` order
531/// - Stop-loss is a `Stop` order (or `StopLimit` if `stop_loss_limit_price` is set)
532///
533/// # Example
534///
535/// ```ignore
536/// let request = AlpacaBracketOrderRequest::new(
537///     "AAPL".into(),
538///     StrategyId::new("momentum"),
539///     ClientOrderId::new("bracket-001"),
540///     Side::Buy,
541///     dec!(10),
542///     dec!(150.00),  // entry
543///     dec!(160.00),  // take profit
544///     dec!(145.00),  // stop loss
545///     TimeInForce::GoodUntilCancelled { post_only: false },
546/// );
547/// // For stop-limit SL: .with_stop_loss_limit_price(dec!(144.00))
548/// let result = client.open_bracket_order(request).await;
549/// ```
550#[non_exhaustive]
551#[derive(Debug, Clone)]
552pub struct AlpacaBracketOrderRequest {
553    /// Instrument to trade.
554    pub instrument: InstrumentNameExchange,
555    /// Strategy identifier for order correlation.
556    pub strategy: StrategyId,
557    /// Client order ID for the parent (entry) order.
558    pub cid: ClientOrderId,
559    /// Buy or Sell for the entry order (exits use opposite side).
560    pub side: Side,
561    /// Number of shares/contracts.
562    pub quantity: Decimal,
563    /// Entry limit price.
564    pub entry_price: Decimal,
565    /// Take-profit limit price.
566    pub take_profit_price: Decimal,
567    /// Stop-loss trigger price.
568    pub stop_loss_price: Decimal,
569    /// Optional stop-loss limit price. When set, makes the stop-loss a stop-limit order.
570    pub stop_loss_limit_price: Option<Decimal>,
571    /// Time-in-force for all legs. Must be `Day` or `GoodUntilCancelled`.
572    pub time_in_force: TimeInForce,
573}
574
575impl AlpacaBracketOrderRequest {
576    /// Create a new bracket order request.
577    ///
578    /// For a stop-limit stop-loss leg, chain `.with_stop_loss_limit_price()`.
579    #[allow(clippy::too_many_arguments)] // Bracket orders inherently need many params
580    pub fn new(
581        instrument: InstrumentNameExchange,
582        strategy: StrategyId,
583        cid: ClientOrderId,
584        side: Side,
585        quantity: Decimal,
586        entry_price: Decimal,
587        take_profit_price: Decimal,
588        stop_loss_price: Decimal,
589        time_in_force: TimeInForce,
590    ) -> Self {
591        Self {
592            instrument,
593            strategy,
594            cid,
595            side,
596            quantity,
597            entry_price,
598            take_profit_price,
599            stop_loss_price,
600            stop_loss_limit_price: None,
601            time_in_force,
602        }
603    }
604
605    /// Set the stop-loss limit price, converting the SL leg to a stop-limit order.
606    #[must_use]
607    pub fn with_stop_loss_limit_price(mut self, price: Decimal) -> Self {
608        self.stop_loss_limit_price = Some(price);
609        self
610    }
611}
612
613/// Result of placing an Alpaca bracket order.
614///
615/// Contains the parent order with its state. The take-profit and stop-loss legs
616/// are managed by Alpaca and their status can be queried via `fetch_open_orders`.
617///
618/// # Note
619///
620/// Unlike IBKR which returns three separate orders, Alpaca's bracket API returns
621/// a single parent order. The child legs (TP/SL) are implicitly created and linked
622/// by Alpaca. Use `fetch_open_orders` to retrieve all legs after placement.
623#[non_exhaustive]
624#[derive(Debug, Clone)]
625pub struct AlpacaBracketOrderResult {
626    /// Parent (entry) order with its current state.
627    pub parent: Order<ExchangeId, InstrumentNameExchange, UnindexedOrderState>,
628}
629
630// ---------------------------------------------------------------------------
631// REST order request body
632// ---------------------------------------------------------------------------
633
634#[derive(Debug, Serialize)]
635struct AlpacaOrderRequest<'a> {
636    symbol: &'a str,
637    qty: String,
638    side: &'static str,
639    #[serde(rename = "type")]
640    order_type: &'static str,
641    time_in_force: &'static str,
642    #[serde(skip_serializing_if = "Option::is_none")]
643    limit_price: Option<String>,
644    #[serde(skip_serializing_if = "Option::is_none")]
645    stop_price: Option<String>,
646    #[serde(skip_serializing_if = "Option::is_none")]
647    trail_percent: Option<String>,
648    #[serde(skip_serializing_if = "Option::is_none")]
649    trail_price: Option<String>,
650    #[serde(skip_serializing_if = "Option::is_none")]
651    client_order_id: Option<&'a str>,
652    // position_intent: heuristic mapping (buy→buy_to_open, sell→sell_to_close).
653    // Correct for directional long-only strategies. Omit for exchanges/asset
654    // classes that don't require it (stocks/crypto ignore this field).
655    #[serde(skip_serializing_if = "Option::is_none")]
656    position_intent: Option<AlpacaPositionIntent>,
657    // Bracket order fields (order_class, take_profit, stop_loss).
658    // Set order_class to "bracket" and populate TP/SL for bracket orders.
659    #[serde(skip_serializing_if = "Option::is_none")]
660    order_class: Option<&'static str>,
661    #[serde(skip_serializing_if = "Option::is_none")]
662    take_profit: Option<TakeProfitParams>,
663    #[serde(skip_serializing_if = "Option::is_none")]
664    stop_loss: Option<StopLossParams>,
665}
666
667// ---------------------------------------------------------------------------
668// WebSocket message types
669// ---------------------------------------------------------------------------
670
671/// Outer container for all Alpaca stream messages.
672///
673/// `data` is kept as a [`serde_json::value::RawValue`] to avoid allocating a full DOM tree
674/// for heartbeats and auth/listening acks that never reach `AlpacaTradeUpdate` parsing.
675#[derive(Debug, Deserialize)]
676struct AlpacaStreamMessage<'a> {
677    // Short well-known values ("trade_updates", "listening", "authorization")
678    // all fit inline in SmolStr — avoids one heap alloc per WS message.
679    stream: SmolStr,
680    #[serde(borrow)]
681    data: &'a serde_json::value::RawValue,
682}
683
684/// Parsed payload of a `trade_updates` event.
685///
686/// Numeric and timestamp fields borrow directly from the `RawValue` input buffer
687/// (`#[serde(borrow)]`), propagating the zero-copy design of `AlpacaStreamMessage`.
688/// This eliminates 4–6 heap allocations per fill event. The borrow is valid because
689/// Alpaca's numeric and timestamp strings contain no JSON escape sequences.
690#[derive(Debug, Deserialize)]
691struct AlpacaTradeUpdate<'a> {
692    // Short event tag ("fill", "partial_fill", "new", ...) — fits inline.
693    event: SmolStr,
694    #[serde(borrow)]
695    order: AlpacaOrderWs<'a>,
696    /// Fill price for this specific execution (None for non-fill events).
697    #[serde(borrow)]
698    price: Option<&'a str>,
699    /// Quantity for this specific execution (None for non-fill events).
700    #[serde(borrow)]
701    qty: Option<&'a str>,
702    #[serde(borrow)]
703    timestamp: Option<&'a str>,
704}
705
706/// Order state embedded in a `trade_updates` WebSocket event.
707#[derive(Debug, Deserialize)]
708struct AlpacaOrderWs<'a> {
709    // UUIDs (36 chars) heap-allocate in SmolStr, but using SmolStr directly avoids
710    // an intermediate String allocation when serde deserialises the field.
711    id: SmolStr,
712    client_order_id: Option<SmolStr>,
713    // Ticker symbols ("AAPL", "BTC/USD") fit inline in SmolStr (≤23 bytes),
714    // eliminating the heap allocation entirely for most symbols.
715    symbol: SmolStr,
716    #[serde(borrow)]
717    qty: Option<&'a str>,
718    // Alpaca guarantees `filled_qty` for fill/partial_fill and most lifecycle
719    // events, but some event types (e.g. `rejected`) may omit the field.
720    // Using Option avoids a deserialization failure that would silently drop
721    // the event. Call sites use `.unwrap_or("0")`.
722    #[serde(borrow)]
723    filled_qty: Option<&'a str>,
724    // Short enums ("buy"/"sell", "market"/"limit"/..., "day"/"gtc"/..., status)
725    // — all fit inline in SmolStr.
726    side: SmolStr,
727    #[serde(rename = "type")]
728    order_type: SmolStr,
729    time_in_force: SmolStr,
730    #[serde(borrow)]
731    limit_price: Option<&'a str>,
732    #[serde(borrow)]
733    stop_price: Option<&'a str>,
734    #[serde(borrow)]
735    trail_percent: Option<&'a str>,
736    #[serde(borrow)]
737    trail_price: Option<&'a str>,
738    status: SmolStr,
739}
740
741// ---------------------------------------------------------------------------
742// AlpacaClient
743// ---------------------------------------------------------------------------
744
745/// Alpaca execution client supporting options, equities, and crypto via the
746/// single unified Alpaca trading API.
747///
748/// All three asset classes share the same REST and WebSocket endpoints. The
749/// key behavioral differences handled transparently:
750/// - Options: `position_intent` field is required (detected by OCC symbol format)
751/// - Crypto: `position_intent` is omitted (not a valid field for crypto orders);
752///   fractional quantities are supported natively via `Decimal::to_string()`
753/// - Equities: `position_intent` is valid but optional for long-only strategies
754///
755/// Cloning is cheap: all inner state is behind `Arc`.
756#[derive(Clone)]
757pub struct AlpacaClient {
758    config: Arc<AlpacaConfig>,
759    /// reqwest client with APCA-API-KEY-ID and APCA-API-SECRET-KEY pre-set as
760    /// default headers — every request carries auth automatically.
761    http: reqwest::Client,
762    rate_limiter: Arc<RateLimitTracker>,
763    /// Pre-allocated `/v2/orders` endpoint URL to avoid allocation per request.
764    orders_url: String,
765}
766
767impl std::fmt::Debug for AlpacaClient {
768    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
769        f.debug_struct("AlpacaClient")
770            .field("paper", &self.config.paper)
771            .finish_non_exhaustive()
772    }
773}
774
775impl AlpacaClient {
776    /// Build a `reqwest::Client` with Alpaca auth headers pre-set.
777    ///
778    /// # Panics
779    ///
780    /// Panics if the API key or secret contains characters that are invalid
781    /// in an HTTP header value (non-ASCII or control characters).
782    #[allow(clippy::expect_used)] // Documented panic: invalid credentials detected at startup
783    fn build_http(config: &AlpacaConfig) -> reqwest::Client {
784        use reqwest::header::{HeaderMap, HeaderName, HeaderValue};
785        let mut headers = HeaderMap::new();
786        // HTTP header names are case-insensitive; use lowercase per HTTP/2 convention.
787        headers.insert(
788            HeaderName::from_static("apca-api-key-id"),
789            HeaderValue::from_str(&config.api_key)
790                .expect("Alpaca API key contains invalid header characters"),
791        );
792        headers.insert(
793            HeaderName::from_static("apca-api-secret-key"),
794            HeaderValue::from_str(&config.secret_key)
795                .expect("Alpaca secret key contains invalid header characters"),
796        );
797        reqwest::Client::builder()
798            .default_headers(headers)
799            .build()
800            .expect("failed to build reqwest client for Alpaca")
801    }
802
803    fn base_url(&self) -> &str {
804        self.config.rest_base_url()
805    }
806}
807
808// ---------------------------------------------------------------------------
809// REST helper: rate-limited request with retry
810// ---------------------------------------------------------------------------
811
812/// Parse the `X-Ratelimit-Reset` response header into a cooldown [`Duration`].
813///
814/// The header value is a Unix epoch timestamp (seconds). Returns the duration
815/// from now until that timestamp, clamped to a minimum of 1 second to avoid
816/// a zero-delay busy loop. Returns `None` if the header is absent or malformed;
817/// callers should fall back to [`DEFAULT_RATE_LIMIT_DELAY_SECS`].
818fn parse_rate_limit_delay(headers: &reqwest::header::HeaderMap) -> Option<Duration> {
819    headers
820        .get("x-ratelimit-reset")
821        .and_then(|v| v.to_str().ok())
822        .and_then(|s| s.parse::<u64>().ok())
823        .map(|reset_ts| {
824            let now_secs = std::time::SystemTime::now()
825                .duration_since(std::time::UNIX_EPOCH)
826                .unwrap_or_default()
827                .as_secs();
828            Duration::from_secs(reset_ts.saturating_sub(now_secs).max(1))
829        })
830}
831
832/// Execute a REST request with rate-limit awareness and retry.
833///
834/// The `build_request` closure is called on every attempt so the caller doesn't
835/// need a clone of `RequestBuilder` (which may not be cloneable with streaming bodies).
836/// For GET/POST/DELETE with fixed bodies, the closure is a cheap re-construction.
837///
838/// On HTTP 429, reads `X-Ratelimit-Reset` (Unix epoch) to determine the cooldown
839/// duration and retries up to `MAX_RATE_LIMIT_ATTEMPTS - 1` times.
840async fn rest_with_retry<T>(
841    rate_limiter: &RateLimitTracker,
842    mut build_request: impl FnMut() -> reqwest::RequestBuilder,
843) -> Result<T, UnindexedClientError>
844where
845    T: for<'de> Deserialize<'de>,
846{
847    for attempt in 0..MAX_RATE_LIMIT_ATTEMPTS {
848        rate_limiter.wait_if_blocked().await;
849        let response = build_request()
850            .send()
851            .await
852            .map_err(|e| connectivity_err(format!("Alpaca REST request failed: {e}")))?;
853
854        // Check X-Ratelimit-Remaining as an early warning; if exactly 0, the next
855        // request will 429. We don't proactively pause here — let the 429 handle it —
856        // but log at debug level so it's visible in traces.
857        if response
858            .headers()
859            .get("x-ratelimit-remaining")
860            .and_then(|v| v.to_str().ok())
861            .and_then(|s| s.parse::<u32>().ok())
862            == Some(0)
863        {
864            debug!("Alpaca REST rate-limit bucket exhausted (X-Ratelimit-Remaining: 0)");
865        }
866
867        let status = response.status();
868
869        if status == reqwest::StatusCode::TOO_MANY_REQUESTS {
870            let reset_delay = parse_rate_limit_delay(response.headers());
871
872            if attempt + 1 < MAX_RATE_LIMIT_ATTEMPTS {
873                warn!(
874                    attempt = attempt + 1,
875                    max_attempts = MAX_RATE_LIMIT_ATTEMPTS,
876                    "Alpaca REST rate-limited (429), retrying"
877                );
878                rate_limiter.on_rate_limited(reset_delay);
879                continue;
880            }
881
882            // Final attempt still rate-limited — return typed error.
883            warn!(
884                max_attempts = MAX_RATE_LIMIT_ATTEMPTS,
885                "Alpaca REST rate-limit retries exhausted"
886            );
887            return Err(UnindexedClientError::Api(ApiError::RateLimit));
888        }
889
890        // 204 No Content is only valid for DELETE endpoints; use rest_delete_with_retry
891        // for those. Reaching here for a 204 indicates API misuse — return a clear error
892        // rather than a misleading "EOF while parsing" JSON failure.
893        if status == reqwest::StatusCode::NO_CONTENT {
894            return Err(connectivity_err(
895                "Alpaca REST returned 204 No Content — use rest_delete_with_retry for DELETE endpoints"
896                    .to_string(),
897            ));
898        }
899
900        let bytes = response
901            .bytes()
902            .await
903            .map_err(|e| connectivity_err(format!("Alpaca REST read body failed: {e}")))?;
904
905        if status.is_success() {
906            return serde_json::from_slice::<T>(&bytes).map_err(|e| {
907                connectivity_err(format!(
908                    "Alpaca REST JSON parse error ({status}): {e} | body: {}",
909                    String::from_utf8_lossy(&bytes)
910                        .chars()
911                        .take(200)
912                        .collect::<String>()
913                ))
914            });
915        }
916
917        // Parse API error body for a better error message.
918        let api_err = serde_json::from_slice::<AlpacaApiError>(&bytes)
919            .map(|e| e.message)
920            .unwrap_or_else(|_| String::from_utf8_lossy(&bytes).into_owned());
921
922        // 4xx = API-level rejection (wrong parameters, auth failure, insufficient funds).
923        // 5xx / other = server-side failure treated as connectivity error.
924        // Callers that pattern-match on UnindexedClientError (e.g. open_order_inner) rely
925        // on Api(ApiError) to classify business rejections vs connectivity failures.
926        //
927        // Uses parse_api_error for consistent classification: 422 "insufficient funds"
928        // maps to BalanceInsufficient (not generic OrderRejected), enabling callers to
929        // trigger balance refresh on insufficient-funds rejections.
930        if status.is_client_error() {
931            return Err(UnindexedClientError::Api(parse_api_error(status, &api_err)));
932        }
933        return Err(connectivity_err(format!(
934            "Alpaca REST error {status}: {api_err}"
935        )));
936    }
937    unreachable!("Alpaca REST retry loop exited without returning")
938}
939
940/// Execute a DELETE request, returning an order error on rejection.
941///
942/// Handles 204 No Content (success), 422 / 403 (API rejection), and 429 (rate limit).
943async fn rest_delete_with_retry(
944    rate_limiter: &RateLimitTracker,
945    mut build_request: impl FnMut() -> reqwest::RequestBuilder,
946) -> Result<(), UnindexedOrderError> {
947    for attempt in 0..MAX_RATE_LIMIT_ATTEMPTS {
948        rate_limiter.wait_if_blocked().await;
949        let response = build_request().send().await.map_err(|e| {
950            UnindexedOrderError::Connectivity(ConnectivityError::Socket(format!(
951                "Alpaca cancel request failed: {e}"
952            )))
953        })?;
954
955        let status = response.status();
956
957        if status == reqwest::StatusCode::TOO_MANY_REQUESTS {
958            let reset_delay = parse_rate_limit_delay(response.headers());
959
960            if attempt + 1 < MAX_RATE_LIMIT_ATTEMPTS {
961                warn!(
962                    attempt = attempt + 1,
963                    max_attempts = MAX_RATE_LIMIT_ATTEMPTS,
964                    "Alpaca cancel rate-limited (429), retrying"
965                );
966                rate_limiter.on_rate_limited(reset_delay);
967                continue;
968            }
969
970            // Final attempt still rate-limited — return typed error.
971            warn!(
972                max_attempts = MAX_RATE_LIMIT_ATTEMPTS,
973                "Alpaca cancel rate-limit retries exhausted"
974            );
975            return Err(UnindexedOrderError::Rejected(ApiError::RateLimit));
976        }
977
978        // 204 No Content: cancel succeeded.
979        if status == reqwest::StatusCode::NO_CONTENT || status.is_success() {
980            return Ok(());
981        }
982
983        let bytes = response
984            .bytes()
985            .await
986            .inspect_err(
987                |e| warn!(%e, %status, "Alpaca cancel_order: failed to read error response body"),
988            )
989            .unwrap_or_default();
990        let msg = serde_json::from_slice::<AlpacaApiError>(&bytes)
991            .map(|e| e.message)
992            .unwrap_or_else(|_| String::from_utf8_lossy(&bytes).into_owned());
993
994        return Err(parse_order_error(status, &msg));
995    }
996    unreachable!("Alpaca cancel retry loop exited without returning")
997}
998
999// ---------------------------------------------------------------------------
1000// ExecutionClient implementation
1001// ---------------------------------------------------------------------------
1002
1003impl ExecutionClient for AlpacaClient {
1004    const EXCHANGE: ExchangeId = ExchangeId::AlpacaBroker;
1005    type Config = AlpacaConfig;
1006    type AccountStream = BoxStream<'static, UnindexedAccountEvent>;
1007
1008    /// # Panics
1009    ///
1010    /// Panics if the API key or secret key contains characters invalid in an
1011    /// HTTP header value.
1012    fn new(config: Self::Config) -> Self {
1013        let http = Self::build_http(&config);
1014        let orders_url = format!("{}/v2/orders", config.rest_base_url());
1015        Self {
1016            config: Arc::new(config),
1017            http,
1018            rate_limiter: Arc::new(RateLimitTracker::new()),
1019            orders_url,
1020        }
1021    }
1022
1023    /// # Rate limit note
1024    ///
1025    /// When both USD and non-USD assets are requested (the common startup case), this
1026    /// method fetches `/v2/account` and `/v2/positions` in parallel for ~100-300ms
1027    /// latency savings. Under rate pressure, both requests may hit 429 simultaneously
1028    /// and retry independently. Operators approaching Alpaca rate limits should call
1029    /// with explicit asset filters to serialize requests if needed.
1030    async fn account_snapshot(
1031        &self,
1032        assets: &[AssetNameExchange],
1033        instruments: &[InstrumentNameExchange],
1034    ) -> Result<UnindexedAccountSnapshot, UnindexedClientError> {
1035        let base = self.base_url();
1036        let http = self.http.clone();
1037        let rl = &self.rate_limiter;
1038
1039        let wants_usd = assets.is_empty()
1040            || assets
1041                .iter()
1042                .any(|a| a.name().as_str().eq_ignore_ascii_case("usd"));
1043        let wants_non_usd = assets.is_empty()
1044            || assets
1045                .iter()
1046                .any(|a| !a.name().as_str().eq_ignore_ascii_case("usd"));
1047
1048        // Fetch account + positions in parallel when both are needed (common startup case).
1049        // URLs are extracted before the closures to avoid re-allocating on each retry attempt.
1050        let account_url = format!("{base}/v2/account");
1051        let positions_url = format!("{base}/v2/positions");
1052        let balances = match (wants_usd, wants_non_usd) {
1053            (true, true) => {
1054                let (account, positions): (AlpacaAccount, Vec<AlpacaPosition>) = tokio::try_join!(
1055                    rest_with_retry(rl, || http.get(&account_url)),
1056                    rest_with_retry(rl, || http.get(&positions_url)),
1057                )?;
1058                let mut balances = convert_account_to_balances(&account, assets);
1059                balances.extend(convert_positions_to_balances(&positions, assets));
1060                balances
1061            }
1062            (true, false) => {
1063                let account: AlpacaAccount = rest_with_retry(rl, || http.get(&account_url)).await?;
1064                convert_account_to_balances(&account, assets)
1065            }
1066            (false, true) => {
1067                let positions: Vec<AlpacaPosition> =
1068                    rest_with_retry(rl, || http.get(&positions_url)).await?;
1069                convert_positions_to_balances(&positions, assets)
1070            }
1071            (false, false) => Vec::new(),
1072        };
1073
1074        let open_orders = fetch_raw_open_orders(&http, rl, base, instruments).await?;
1075
1076        // Group open orders by instrument symbol, building InstrumentAccountSnapshots.
1077        let instrument_snapshots = build_instrument_snapshots(open_orders, instruments);
1078
1079        Ok(AccountSnapshot::new(
1080            ExchangeId::AlpacaBroker,
1081            balances,
1082            instrument_snapshots,
1083        ))
1084    }
1085
1086    /// Returns a live stream of account events (fills, order updates).
1087    ///
1088    /// # Startup race window
1089    ///
1090    /// Fills arriving between `account_snapshot` and this method being called by the
1091    /// caller are not recovered automatically — the WebSocket connection does not exist
1092    /// yet during that window. Fills that arrive after the connection is established but
1093    /// before the first poll are buffered in the tungstenite internal buffer and delivered
1094    /// normally. Callers requiring fill completeness at startup **must** call
1095    /// [`ExecutionClient::fetch_trades`] with a ~1 s lookback after calling this method.
1096    ///
1097    /// This gap also applies on every **reconnect**: during the auth+subscribe handshake
1098    /// (`connect_and_subscribe`), any `trade_updates` messages that arrive are consumed
1099    /// by the handshake loop and not forwarded. Fill events in this window are recovered
1100    /// via the REST activities endpoint (anchored to `disconnect_time`). Lifecycle events
1101    /// (`new`, `canceled`, `rejected`) consumed during the handshake are **not** recovered
1102    /// — callers must call `fetch_open_orders` after each reconnect to reconcile order state.
1103    ///
1104    /// # Fill recovery ordering
1105    ///
1106    /// After a reconnect, missed fills are recovered from the REST activities endpoint
1107    /// using `direction=asc` to match the chronological order in which the WS stream
1108    /// advanced `filled_qty`. The dedup key `"{order_id}:{cum_qty}"` is synthesised
1109    /// by accumulating per-execution qty in that order. If Alpaca returns activities
1110    /// out of chronological order (e.g. at pagination boundaries), dedup keys will
1111    /// diverge and fills may be dropped or duplicated for that order.
1112    ///
1113    /// # Lifecycle event deduplication
1114    ///
1115    /// Order lifecycle events (`new`, `canceled`, `expired`) are **not** deduplicated
1116    /// across reconnects — only fill events carry a dedup key. After a reconnect, Alpaca
1117    /// re-delivers lifecycle events for orders that were active at disconnect time.
1118    /// Specifically, Alpaca re-delivers a `new` event for **every order open at disconnect
1119    /// time**, not only orders that changed during the gap.
1120    /// Callers must make [`AccountEventKind::OrderSnapshot`] and
1121    /// [`AccountEventKind::OrderCancelled`] processing idempotent, or call
1122    /// [`ExecutionClient::fetch_open_orders`] after each reconnect to reconcile state.
1123    ///
1124    /// # Rejected orders
1125    ///
1126    /// Alpaca `rejected` events are delivered as `AccountEventKind::OrderCancelled` with
1127    /// `state: Err(OrderRejected(...))`. Match on `response.state.is_err()` to distinguish
1128    /// rejections from true cancels — do not call `.unwrap()` on `OrderCancelled.state`.
1129    ///
1130    /// # Stream drop behaviour
1131    ///
1132    /// Dropping the returned `BoxStream` initiates a graceful shutdown of the
1133    /// background `connection_manager` task: the channel close causes the task
1134    /// to send a WebSocket close frame and exit within the current heartbeat
1135    /// window (≤60 s). Any `AccountEvent` items already queued but not yet
1136    /// polled are discarded. Callers who drop and re-subscribe must call
1137    /// [`ExecutionClient::fetch_trades`] with a short lookback to recover the gap.
1138    async fn account_stream(
1139        &self,
1140        _assets: &[AssetNameExchange], // ignored — Alpaca's trade_updates stream delivers all asset classes on one channel
1141        // instruments is used to filter fill recovery (REST) after a reconnect only.
1142        // Live WS events are NOT filtered by instrument: Alpaca's trade_updates stream
1143        // delivers all account events on one channel with no per-symbol subscription.
1144        instruments: &[InstrumentNameExchange],
1145    ) -> Result<Self::AccountStream, UnindexedClientError> {
1146        // Verify the initial connection before returning the stream; distinguishes
1147        // "can't connect at all" from "connected but later disconnected".
1148        let initial_ws = connect_and_subscribe(&self.config).await?;
1149
1150        // Unbounded channel — memory grows if the consumer is slow, but fills
1151        // are never silently dropped. Silent fill loss corrupts position state;
1152        // OOM is loudly observable. The WS delivery path uses non-blocking send()
1153        // which only fails if the receiver is dropped.
1154        let (tx, rx) = mpsc::unbounded_channel::<UnindexedAccountEvent>();
1155        let dedup = new_dedup_cache();
1156        let config = self.config.clone();
1157        let http = self.http.clone();
1158        let rate_limiter = self.rate_limiter.clone();
1159        let instruments = instruments.to_vec();
1160
1161        let cm_handle = tokio::spawn(connection_manager(
1162            tx,
1163            dedup,
1164            config,
1165            http,
1166            rate_limiter,
1167            instruments,
1168            Some(initial_ws),
1169        ));
1170
1171        let rx_stream = tokio_stream::wrappers::UnboundedReceiverStream::new(rx);
1172        let guarded = GracefulShutdownStream::new(rx_stream, cm_handle);
1173        Ok(futures::StreamExt::boxed(guarded))
1174    }
1175
1176    async fn cancel_order(
1177        &self,
1178        request: OrderRequestCancel<ExchangeId, &InstrumentNameExchange>,
1179    ) -> Option<UnindexedOrderResponseCancel> {
1180        let key = crate::order::OrderKey {
1181            exchange: request.key.exchange,
1182            instrument: request.key.instrument.clone(),
1183            strategy: request.key.strategy.clone(),
1184            cid: request.key.cid.clone(),
1185        };
1186
1187        // Require the exchange order ID — Alpaca's DELETE endpoint uses the UUID.
1188        // If only clientOrderId is available, the caller should first resolve it
1189        // via fetch_open_orders.
1190        let order_id: SmolStr = match &request.state.id {
1191            Some(id) => id.0.clone(),
1192            None => {
1193                warn!(
1194                    instrument = %key.instrument,
1195                    "Alpaca cancel_order: no exchange order ID available (clientOrderId-only cancel not supported)"
1196                );
1197                return Some(crate::order::request::OrderResponseCancel {
1198                    key,
1199                    state: Err(UnindexedOrderError::Rejected(ApiError::OrderRejected(
1200                        "exchange order ID required for cancel (fetch_open_orders to resolve)"
1201                            .into(),
1202                    ))),
1203                });
1204            }
1205        };
1206
1207        let base = self.base_url();
1208        let http = self.http.clone();
1209        let url = format!("{base}/v2/orders/{order_id}");
1210
1211        match rest_delete_with_retry(&self.rate_limiter, || http.delete(&url)).await {
1212            Ok(()) => {
1213                let exchange_order_id = OrderId(order_id);
1214                // REST DELETE returns no response body, so filled_qty unavailable.
1215                // Use ZERO; downstream can reconcile via WS events or fetch_open_orders.
1216                Some(crate::order::request::OrderResponseCancel {
1217                    key,
1218                    state: Ok(Cancelled::new(exchange_order_id, Utc::now(), Decimal::ZERO)),
1219                })
1220            }
1221            Err(e) => Some(crate::order::request::OrderResponseCancel { key, state: Err(e) }),
1222        }
1223    }
1224
1225    /// # Position intent derivation
1226    ///
1227    /// Alpaca options/equities require explicit `position_intent`. This impl derives
1228    /// intent from `RequestOpen::reduce_only` and `side`:
1229    ///
1230    /// | reduce_only | side | intent       | use case                          |
1231    /// |-------------|------|--------------|-----------------------------------|
1232    /// | false       | Buy  | BuyToOpen    | open long / add to long position  |
1233    /// | false       | Sell | SellToOpen   | open short / write option         |
1234    /// | true        | Buy  | BuyToClose   | close short position              |
1235    /// | true        | Sell | SellToClose  | close long position               |
1236    ///
1237    /// For explicit control, use [`AlpacaClient::open_order_with_intent`].
1238    ///
1239    /// # Market order price
1240    ///
1241    /// The returned `Order.price` echoes the request price. For market orders this is
1242    /// typically `Decimal::ZERO` (a placeholder). The **actual fill price** arrives via
1243    /// the WebSocket `trade_updates` stream as a `Trade` event. Do not rely on
1244    /// `Order.price` from this REST ack for market order fill prices.
1245    async fn open_order(
1246        &self,
1247        request: OrderRequestOpen<ExchangeId, &InstrumentNameExchange>,
1248    ) -> Option<Order<ExchangeId, InstrumentNameExchange, UnindexedOrderState>> {
1249        let side = request.state.side;
1250        let reduce_only = request.state.reduce_only;
1251        self.open_order_inner(request, map_position_intent(side, reduce_only))
1252            .await
1253    }
1254
1255    /// Fetches balances sequentially (unlike `account_snapshot` which parallelizes).
1256    ///
1257    /// Sequential fetch is intentional for live operation: under rate pressure, parallel
1258    /// requests may both hit 429 simultaneously and retry independently, doubling the
1259    /// backoff delay. The startup latency savings from `account_snapshot`'s parallel
1260    /// fetch are worth the tradeoff there; for periodic balance refreshes during live
1261    /// trading, sequential is safer.
1262    async fn fetch_balances(
1263        &self,
1264        assets: &[AssetNameExchange],
1265    ) -> Result<Vec<AssetBalance<AssetNameExchange>>, UnindexedClientError> {
1266        let base = self.base_url();
1267        let http = self.http.clone();
1268        let mut result = Vec::new();
1269
1270        // Only fetch the account (USD balance) when USD is among the requested assets.
1271        // Crypto-only requests skip this call to conserve rate-limit budget.
1272        let wants_usd = assets.is_empty()
1273            || assets
1274                .iter()
1275                .any(|a| a.name().as_str().eq_ignore_ascii_case("usd"));
1276        if wants_usd {
1277            // Pre-allocate URL to avoid re-allocation on each retry attempt.
1278            let account_url = format!("{base}/v2/account");
1279            let account: AlpacaAccount =
1280                rest_with_retry(&self.rate_limiter, || http.get(&account_url)).await?;
1281            result.extend(convert_account_to_balances(&account, assets));
1282        }
1283
1284        // Fetch positions for non-USD asset balances (e.g., BTC, ETH from crypto holdings).
1285        let wants_non_usd = assets.is_empty()
1286            || assets
1287                .iter()
1288                .any(|a| !a.name().as_str().eq_ignore_ascii_case("usd"));
1289        if wants_non_usd {
1290            // Pre-allocate URL to avoid re-allocation on each retry attempt.
1291            let positions_url = format!("{base}/v2/positions");
1292            let positions: Vec<AlpacaPosition> =
1293                rest_with_retry(&self.rate_limiter, || http.get(&positions_url)).await?;
1294            result.extend(convert_positions_to_balances(&positions, assets));
1295        }
1296
1297        Ok(result)
1298    }
1299
1300    async fn fetch_open_orders(
1301        &self,
1302        instruments: &[InstrumentNameExchange],
1303    ) -> Result<Vec<Order<ExchangeId, InstrumentNameExchange, Open>>, UnindexedClientError> {
1304        let base = self.base_url();
1305        let http = self.http.clone();
1306
1307        let open_orders =
1308            fetch_raw_open_orders(&http, &self.rate_limiter, base, instruments).await?;
1309
1310        let result = open_orders
1311            .into_iter()
1312            .filter_map(|o| convert_open_order(&o))
1313            .collect();
1314        Ok(result)
1315    }
1316
1317    async fn fetch_trades(
1318        &self,
1319        time_since: DateTime<Utc>,
1320        instruments: &[InstrumentNameExchange],
1321    ) -> Result<Vec<Trade<AssetNameExchange, InstrumentNameExchange>>, UnindexedClientError> {
1322        let after_str = time_since.to_rfc3339();
1323        let base = self.base_url();
1324        let http = self.http.clone();
1325
1326        let page = paginate_activities(&http, &self.rate_limiter, base, &after_str).await?;
1327
1328        // Propagate truncation as an error so callers can detect incomplete results.
1329        // The crypto repo can match on `Truncated` and alert operators.
1330        if page.truncated {
1331            return Err(UnindexedClientError::Truncated {
1332                limit: MAX_ACTIVITY_PAGES,
1333            });
1334        }
1335
1336        // Empty instruments slice means "all instruments" — same convention as
1337        // fetch_open_orders. Build a set only when filtering is needed.
1338        let trades = if instruments.is_empty() {
1339            page.activities
1340                .into_iter()
1341                .filter_map(|a| convert_activity_to_trade(&a))
1342                .collect()
1343        } else {
1344            let instrument_set: fnv::FnvHashSet<&str> =
1345                instruments.iter().map(|i| i.name().as_str()).collect();
1346            page.activities
1347                .into_iter()
1348                .filter(|a| instrument_set.contains(a.symbol.as_str()))
1349                .filter_map(|a| convert_activity_to_trade(&a))
1350                .collect()
1351        };
1352
1353        Ok(trades)
1354    }
1355}
1356
1357// ---------------------------------------------------------------------------
1358// AlpacaClient public extension methods (not on ExecutionClient trait)
1359// ---------------------------------------------------------------------------
1360
1361impl AlpacaClient {
1362    /// Place an order with an explicit `position_intent` override.
1363    ///
1364    /// Use this instead of `ExecutionClient::open_order` when you need to specify
1365    /// exact position intent (e.g. `SellToOpen` for writing a short option, or
1366    /// `BuyToClose` for closing a short position by buying).
1367    ///
1368    /// `intent` is only sent for non-crypto symbols. For crypto symbols (those
1369    /// containing `/`) the field is always omitted regardless of `intent`.
1370    ///
1371    /// # Caller obligations
1372    /// The caller is responsible for passing the semantically correct intent.
1373    /// No validation is performed against the order side or existing position.
1374    pub async fn open_order_with_intent(
1375        &self,
1376        request: OrderRequestOpen<ExchangeId, &InstrumentNameExchange>,
1377        intent: AlpacaPositionIntent,
1378    ) -> Option<Order<ExchangeId, InstrumentNameExchange, UnindexedOrderState>> {
1379        self.open_order_inner(request, intent).await
1380    }
1381
1382    /// Place a bracket order (entry + take-profit + stop-loss) in a single request.
1383    ///
1384    /// A bracket order consists of three linked orders:
1385    /// 1. **Entry**: Limit order to enter the position
1386    /// 2. **Take Profit**: Limit order to exit at profit target
1387    /// 3. **Stop Loss**: Stop (or stop-limit) order to exit at loss limit
1388    ///
1389    /// When the entry order fills, Alpaca activates both exit legs. When either
1390    /// exit leg fills, Alpaca automatically cancels the other.
1391    ///
1392    /// # Constraints
1393    ///
1394    /// - `time_in_force` must be `Day` or `GoodUntilCancelled` (Alpaca rejects others)
1395    /// - No extended hours trading with bracket orders
1396    /// - Entry is always a limit order
1397    ///
1398    /// # Example
1399    ///
1400    /// ```ignore
1401    /// let request = AlpacaBracketOrderRequest::new(
1402    ///     "AAPL".into(),
1403    ///     StrategyId::new("momentum"),
1404    ///     ClientOrderId::new("bracket-001"),
1405    ///     Side::Buy,
1406    ///     dec!(10),
1407    ///     dec!(150.00),  // entry
1408    ///     dec!(160.00),  // take profit
1409    ///     dec!(145.00),  // stop loss
1410    ///     TimeInForce::GoodUntilCancelled { post_only: false },
1411    /// );
1412    /// let result = client.open_bracket_order(request).await;
1413    /// ```
1414    pub async fn open_bracket_order(
1415        &self,
1416        request: AlpacaBracketOrderRequest,
1417    ) -> AlpacaBracketOrderResult {
1418        let order_key = crate::order::OrderKey::new(
1419            ExchangeId::AlpacaBroker,
1420            request.instrument.clone(),
1421            request.strategy.clone(),
1422            request.cid.clone(),
1423        );
1424
1425        // Validate time_in_force: bracket orders only support day or gtc
1426        let tif_str = match request.time_in_force {
1427            TimeInForce::GoodUntilEndOfDay => "day",
1428            TimeInForce::GoodUntilCancelled { post_only } => {
1429                if post_only {
1430                    return AlpacaBracketOrderResult {
1431                        parent: Order {
1432                            key: order_key,
1433                            side: request.side,
1434                            price: Some(request.entry_price),
1435                            quantity: request.quantity,
1436                            kind: OrderKind::Limit,
1437                            time_in_force: request.time_in_force,
1438                            state: OrderState::inactive(OrderError::Rejected(
1439                                ApiError::OrderRejected(
1440                                    "Alpaca does not support post_only for bracket orders"
1441                                        .to_string(),
1442                                ),
1443                            )),
1444                        },
1445                    };
1446                }
1447                "gtc"
1448            }
1449            other => {
1450                return AlpacaBracketOrderResult {
1451                    parent: Order {
1452                        key: order_key,
1453                        side: request.side,
1454                        price: Some(request.entry_price),
1455                        quantity: request.quantity,
1456                        kind: OrderKind::Limit,
1457                        time_in_force: request.time_in_force,
1458                        state: OrderState::inactive(OrderError::Rejected(ApiError::OrderRejected(
1459                            format!(
1460                                "Alpaca bracket orders only support Day or GTC time_in_force, got {:?}",
1461                                other
1462                            ),
1463                        ))),
1464                    },
1465                };
1466            }
1467        };
1468
1469        // Validate price ordering. Alpaca rejects mis-ordered brackets with a
1470        // generic 422; surface a structured local error before round-trip.
1471        let price_ordering_ok = match request.side {
1472            Side::Buy => {
1473                request.stop_loss_price < request.entry_price
1474                    && request.entry_price < request.take_profit_price
1475            }
1476            Side::Sell => {
1477                request.take_profit_price < request.entry_price
1478                    && request.entry_price < request.stop_loss_price
1479            }
1480        };
1481        if !price_ordering_ok {
1482            return AlpacaBracketOrderResult {
1483                parent: Order {
1484                    key: order_key,
1485                    side: request.side,
1486                    price: Some(request.entry_price),
1487                    quantity: request.quantity,
1488                    kind: OrderKind::Limit,
1489                    time_in_force: request.time_in_force,
1490                    state: OrderState::inactive(OrderError::Rejected(ApiError::OrderRejected(
1491                        format!(
1492                            "Invalid bracket price ordering for {:?} side: entry={}, take_profit={}, stop_loss={}",
1493                            request.side,
1494                            request.entry_price,
1495                            request.take_profit_price,
1496                            request.stop_loss_price,
1497                        ),
1498                    ))),
1499                },
1500            };
1501        }
1502
1503        // Validate stop-loss limit price if provided. For a Buy bracket, the SL
1504        // is a sell stop(-limit); the limit must be at or below the trigger so
1505        // the order can fill if price gaps down. Reverse for Sell brackets.
1506        if let Some(sl_limit) = request.stop_loss_limit_price {
1507            let sl_limit_ok = match request.side {
1508                Side::Buy => sl_limit <= request.stop_loss_price,
1509                Side::Sell => sl_limit >= request.stop_loss_price,
1510            };
1511            if !sl_limit_ok {
1512                return AlpacaBracketOrderResult {
1513                    parent: Order {
1514                        key: order_key,
1515                        side: request.side,
1516                        price: Some(request.entry_price),
1517                        quantity: request.quantity,
1518                        kind: OrderKind::Limit,
1519                        time_in_force: request.time_in_force,
1520                        state: OrderState::inactive(OrderError::Rejected(ApiError::OrderRejected(
1521                            format!(
1522                                "Invalid stop-loss limit price for {:?} bracket: \
1523                                 stop_loss_price={}, stop_loss_limit_price={}",
1524                                request.side, request.stop_loss_price, sl_limit,
1525                            ),
1526                        ))),
1527                    },
1528                };
1529            }
1530        }
1531
1532        // Build the bracket order request
1533        let body = AlpacaOrderRequest {
1534            symbol: request.instrument.name().as_str(),
1535            qty: request.quantity.to_string(),
1536            side: map_side(request.side),
1537            order_type: "limit",
1538            time_in_force: tif_str,
1539            limit_price: Some(request.entry_price.to_string()),
1540            stop_price: None,
1541            trail_percent: None,
1542            trail_price: None,
1543            client_order_id: Some(request.cid.0.as_str()),
1544            position_intent: if is_options_or_equity_symbol(request.instrument.name().as_str()) {
1545                Some(map_position_intent(request.side, false))
1546            } else {
1547                None
1548            },
1549            order_class: Some("bracket"),
1550            take_profit: Some(TakeProfitParams {
1551                limit_price: request.take_profit_price.to_string(),
1552            }),
1553            stop_loss: Some(StopLossParams {
1554                stop_price: request.stop_loss_price.to_string(),
1555                limit_price: request.stop_loss_limit_price.map(|p| p.to_string()),
1556            }),
1557        };
1558
1559        let http = self.http.clone();
1560        let rl = &self.rate_limiter;
1561
1562        let result: Result<AlpacaOrderResponse, UnindexedClientError> =
1563            rest_with_retry(rl, || http.post(&self.orders_url).json(&body)).await;
1564
1565        match result {
1566            Ok(resp) => {
1567                let exchange_order_id = OrderId(SmolStr::new(&resp.id));
1568                let time_exchange = parse_timestamp(&resp.created_at).unwrap_or_else(Utc::now);
1569                let filled_qty = Decimal::from_str(&resp.filled_qty).unwrap_or(Decimal::ZERO);
1570
1571                let state = if filled_qty >= request.quantity {
1572                    OrderState::fully_filled(Filled::new(
1573                        exchange_order_id,
1574                        time_exchange,
1575                        filled_qty,
1576                        None,
1577                    ))
1578                } else {
1579                    OrderState::active(Open::new(exchange_order_id, time_exchange, filled_qty))
1580                };
1581
1582                AlpacaBracketOrderResult {
1583                    parent: Order {
1584                        key: order_key,
1585                        side: request.side,
1586                        price: Some(request.entry_price),
1587                        quantity: request.quantity,
1588                        kind: OrderKind::Limit,
1589                        time_in_force: request.time_in_force,
1590                        state,
1591                    },
1592                }
1593            }
1594            Err(e) => {
1595                let order_err = match e {
1596                    UnindexedClientError::Connectivity(ce) => OrderError::Connectivity(ce),
1597                    UnindexedClientError::Api(ae) => OrderError::Rejected(ae),
1598                    UnindexedClientError::TaskFailed(_)
1599                    | UnindexedClientError::Internal(_)
1600                    | UnindexedClientError::Truncated { .. }
1601                    | UnindexedClientError::TruncatedSnapshot { .. } => {
1602                        unreachable!("rest_with_retry (order path) does not produce these variants")
1603                    }
1604                };
1605                AlpacaBracketOrderResult {
1606                    parent: Order {
1607                        key: order_key,
1608                        side: request.side,
1609                        price: Some(request.entry_price),
1610                        quantity: request.quantity,
1611                        kind: OrderKind::Limit,
1612                        time_in_force: request.time_in_force,
1613                        state: OrderState::inactive(order_err),
1614                    },
1615                }
1616            }
1617        }
1618    }
1619
1620    async fn open_order_inner(
1621        &self,
1622        request: OrderRequestOpen<ExchangeId, &InstrumentNameExchange>,
1623        intent: AlpacaPositionIntent,
1624    ) -> Option<Order<ExchangeId, InstrumentNameExchange, UnindexedOrderState>> {
1625        let instrument = request.key.instrument.clone();
1626        let side = request.state.side;
1627        let price = request.state.price;
1628        let quantity = request.state.quantity;
1629        let kind = request.state.kind;
1630        let time_in_force = request.state.time_in_force;
1631        let cid = request.key.cid.clone();
1632
1633        let order_key = crate::order::OrderKey::new(
1634            ExchangeId::AlpacaBroker,
1635            instrument.clone(),
1636            request.key.strategy.clone(),
1637            cid.clone(),
1638        );
1639
1640        // Validate time_in_force before building the request — reject post_only early.
1641        let tif_str = match map_time_in_force(time_in_force) {
1642            Ok(s) => s,
1643            Err(msg) => {
1644                return Some(Order {
1645                    key: order_key,
1646                    side,
1647                    price,
1648                    quantity,
1649                    kind,
1650                    time_in_force,
1651                    state: OrderState::inactive(OrderError::Rejected(ApiError::OrderRejected(
1652                        msg.to_string(),
1653                    ))),
1654                });
1655            }
1656        };
1657
1658        // Validate order kind — reject unsupported types early.
1659        let order_type_str = match map_order_kind(kind) {
1660            Some(s) => s,
1661            None => {
1662                return Some(Order {
1663                    key: order_key,
1664                    side,
1665                    price,
1666                    quantity,
1667                    kind,
1668                    time_in_force,
1669                    state: OrderState::inactive(OrderError::UnsupportedOrderType(format!(
1670                        "Alpaca connector does not yet support OrderKind::{kind:?}"
1671                    ))),
1672                });
1673            }
1674        };
1675
1676        // Validate trailing stop offset type — Alpaca only supports percent and absolute.
1677        if let OrderKind::TrailingStop {
1678            offset_type: TrailingOffsetType::BasisPoints,
1679            ..
1680        } = kind
1681        {
1682            return Some(Order {
1683                key: order_key,
1684                side,
1685                price,
1686                quantity,
1687                kind,
1688                time_in_force,
1689                state: OrderState::inactive(OrderError::UnsupportedOrderType(
1690                    "Alpaca does not support TrailingOffsetType::BasisPoints; \
1691                     use Percentage or Absolute"
1692                        .to_string(),
1693                )),
1694            });
1695        }
1696
1697        // StopLimit requires Order.price (the limit price applied once trigger fires).
1698        // Guard locally to avoid a wire round-trip producing a generic 422.
1699        if matches!(kind, OrderKind::StopLimit { .. }) && price.is_none() {
1700            return Some(Order {
1701                key: order_key,
1702                side,
1703                price,
1704                quantity,
1705                kind,
1706                time_in_force,
1707                state: OrderState::inactive(OrderError::Rejected(ApiError::OrderRejected(
1708                    "StopLimit order requires Order.price (the limit price) to be set".to_string(),
1709                ))),
1710            });
1711        }
1712
1713        // Extract stop/trailing parameters based on order kind.
1714        let (stop_price, trail_percent, trail_price) = match kind {
1715            OrderKind::Stop { trigger_price } | OrderKind::StopLimit { trigger_price } => {
1716                (Some(trigger_price.to_string()), None, None)
1717            }
1718            OrderKind::TrailingStop {
1719                offset,
1720                offset_type,
1721            } => match offset_type {
1722                TrailingOffsetType::Percentage => (None, Some(offset.to_string()), None),
1723                TrailingOffsetType::Absolute => (None, None, Some(offset.to_string())),
1724                TrailingOffsetType::BasisPoints => unreachable!("validated above"),
1725            },
1726            // TakeProfit/TakeProfitLimit rejected by map_order_kind → unreachable here
1727            OrderKind::Market
1728            | OrderKind::Limit
1729            | OrderKind::TakeProfit { .. }
1730            | OrderKind::TakeProfitLimit { .. }
1731            | OrderKind::TrailingStopLimit { .. } => (None, None, None),
1732        };
1733
1734        let body = AlpacaOrderRequest {
1735            symbol: instrument.name().as_str(),
1736            qty: quantity.to_string(),
1737            side: map_side(side),
1738            order_type: order_type_str,
1739            time_in_force: tif_str,
1740            limit_price: match kind {
1741                OrderKind::Limit | OrderKind::StopLimit { .. } => price.map(|p| p.to_string()),
1742                _ => None,
1743            },
1744            stop_price,
1745            trail_percent,
1746            trail_price,
1747            client_order_id: Some(cid.0.as_str()),
1748            // position_intent is required for options orders and valid (but optional)
1749            // for equities. It is NOT a valid field for crypto orders and must be
1750            // omitted, or Alpaca will return 422 Unprocessable Entity.
1751            // We detect options by OCC symbol format; equities also get the field
1752            // as it aids intent tracking on margin accounts.
1753            position_intent: if is_options_or_equity_symbol(instrument.name().as_str()) {
1754                Some(intent)
1755            } else {
1756                None
1757            },
1758            // Non-bracket order: no bracket fields
1759            order_class: None,
1760            take_profit: None,
1761            stop_loss: None,
1762        };
1763
1764        let http = self.http.clone();
1765        let rl = &self.rate_limiter;
1766
1767        let result: Result<AlpacaOrderResponse, UnindexedClientError> =
1768            rest_with_retry(rl, || http.post(&self.orders_url).json(&body)).await;
1769
1770        match result {
1771            Ok(resp) => {
1772                let exchange_order_id = OrderId(SmolStr::new(&resp.id));
1773                let time_exchange = parse_timestamp(&resp.created_at).unwrap_or_else(Utc::now);
1774                let filled_qty = Decimal::from_str(&resp.filled_qty).unwrap_or(Decimal::ZERO);
1775
1776                let state = if filled_qty >= quantity {
1777                    // Order was fully filled immediately (market order or aggressive limit)
1778                    OrderState::fully_filled(Filled::new(
1779                        exchange_order_id,
1780                        time_exchange,
1781                        filled_qty,
1782                        None, // Alpaca order response doesn't include avg_price
1783                    ))
1784                } else {
1785                    // Order is resting on the order book (partially filled or unfilled)
1786                    OrderState::active(Open::new(exchange_order_id, time_exchange, filled_qty))
1787                };
1788
1789                Some(Order {
1790                    key: order_key,
1791                    side,
1792                    price,
1793                    quantity,
1794                    kind,
1795                    time_in_force,
1796                    state,
1797                })
1798            }
1799            Err(e) => {
1800                let order_err = match e {
1801                    UnindexedClientError::Connectivity(ce) => OrderError::Connectivity(ce),
1802                    UnindexedClientError::Api(ae) => OrderError::Rejected(ae),
1803                    // TaskFailed, Internal, Truncated, and TruncatedSnapshot are not
1804                    // returned by rest_with_retry (REST-only path for orders), but matching
1805                    // explicitly ensures any new ClientError variant causes a compile error
1806                    // here rather than being silently misclassified. If a future refactor
1807                    // makes them reachable, the panic surfaces the bug loudly rather than
1808                    // producing a wrong error type.
1809                    UnindexedClientError::TaskFailed(_)
1810                    | UnindexedClientError::Internal(_)
1811                    | UnindexedClientError::Truncated { .. }
1812                    | UnindexedClientError::TruncatedSnapshot { .. } => {
1813                        unreachable!(
1814                            "rest_with_retry (order path) does not produce TaskFailed/Internal/Truncated/TruncatedSnapshot variants"
1815                        )
1816                    }
1817                };
1818                Some(Order {
1819                    key: order_key,
1820                    side,
1821                    price,
1822                    quantity,
1823                    kind,
1824                    time_in_force,
1825                    state: OrderState::inactive(order_err),
1826                })
1827            }
1828        }
1829    }
1830}
1831
1832// ---------------------------------------------------------------------------
1833// REST helpers
1834// ---------------------------------------------------------------------------
1835
1836/// Maximum number of open orders returned by a single `/v2/orders` request.
1837///
1838/// Alpaca's API caps at 500; accounts exceeding this have an incomplete snapshot.
1839const MAX_OPEN_ORDERS: usize = 500;
1840
1841/// Fetch all open orders from Alpaca, optionally filtered by symbol.
1842///
1843/// # Errors
1844///
1845/// Returns [`UnindexedClientError::TruncatedSnapshot`] when exactly 500 results
1846/// are returned, indicating the API limit was likely hit and data may be incomplete.
1847/// This is an Alpaca API limitation with no pagination support for open orders.
1848async fn fetch_raw_open_orders(
1849    http: &reqwest::Client,
1850    rate_limiter: &RateLimitTracker,
1851    base: &str,
1852    instruments: &[InstrumentNameExchange],
1853) -> Result<Vec<AlpacaOrderResponse>, UnindexedClientError> {
1854    let orders: Vec<AlpacaOrderResponse> = if instruments.is_empty() {
1855        rest_with_retry(rate_limiter, || {
1856            http.get(format!("{base}/v2/orders"))
1857                .query(&[("status", "open"), ("limit", "500")])
1858        })
1859        .await?
1860    } else {
1861        let symbols = instruments.iter().map(|i| i.name().as_str()).join(",");
1862        rest_with_retry(rate_limiter, || {
1863            http.get(format!("{base}/v2/orders")).query(&[
1864                ("status", "open"),
1865                ("limit", "500"),
1866                ("symbols", &symbols),
1867            ])
1868        })
1869        .await?
1870    };
1871    if orders.len() == MAX_OPEN_ORDERS {
1872        warn!(
1873            limit = MAX_OPEN_ORDERS,
1874            "Alpaca fetch_raw_open_orders: received exactly {MAX_OPEN_ORDERS} results — \
1875             response is likely truncated"
1876        );
1877        return Err(UnindexedClientError::TruncatedSnapshot {
1878            limit: MAX_OPEN_ORDERS,
1879        });
1880    }
1881    Ok(orders)
1882}
1883
1884// ---------------------------------------------------------------------------
1885// Activity pagination
1886// ---------------------------------------------------------------------------
1887
1888/// Maximum number of pages fetched by [`paginate_activities`].
1889///
1890/// 50 pages × 100 items = 5 000 fills. Exceeding this during recovery indicates
1891/// an unusually long outage; we warn and truncate rather than looping forever.
1892const MAX_ACTIVITY_PAGES: usize = 50;
1893
1894/// Result of [`paginate_activities`] including truncation status.
1895///
1896/// When `truncated` is true, the `activities` vector contains a partial result
1897/// capped at [`MAX_ACTIVITY_PAGES`] pages. Callers should handle this case
1898/// appropriately — typically by alerting operators about potential data loss.
1899struct ActivityPage {
1900    activities: Vec<AlpacaActivity>,
1901    truncated: bool,
1902}
1903
1904/// Fetch all FILL activities since `after` using token-based pagination.
1905///
1906/// Alpaca returns up to `ALPACA_MAX_ACTIVITIES` per page. If a full page is
1907/// returned, the next request uses the last item's `id` as the `page_token`.
1908/// Pagination terminates when a page has fewer items than `page_size`, or after
1909/// [`MAX_ACTIVITY_PAGES`] pages (whichever comes first).
1910///
1911/// Returns [`ActivityPage`] with `truncated = true` if the page limit was reached,
1912/// allowing callers to detect and handle partial results.
1913// Compile-time string form of ALPACA_MAX_ACTIVITIES (avoids runtime to_string() allocation).
1914const PAGE_SIZE_STR: &str = "100"; // must match ALPACA_MAX_ACTIVITIES
1915const _: () = assert!(
1916    ALPACA_MAX_ACTIVITIES == 100,
1917    "PAGE_SIZE_STR must be updated to match ALPACA_MAX_ACTIVITIES",
1918);
1919
1920async fn paginate_activities(
1921    http: &reqwest::Client,
1922    rate_limiter: &RateLimitTracker,
1923    base: &str,
1924    after: &str,
1925) -> Result<ActivityPage, UnindexedClientError> {
1926    let mut all = Vec::with_capacity(ALPACA_MAX_ACTIVITIES);
1927    let mut page_token: Option<String> = None;
1928    let mut pages = 0usize;
1929    let mut truncated = false;
1930
1931    loop {
1932        if pages >= MAX_ACTIVITY_PAGES {
1933            truncated = true;
1934            break;
1935        }
1936        pages += 1;
1937        // Borrow page_token as &str so the closure can capture by reference without cloning.
1938        let page_token_ref = page_token.as_deref();
1939        let activities: Vec<AlpacaActivity> = rest_with_retry(rate_limiter, || {
1940            let mut req = http.get(format!("{base}/v2/account/activities")).query(&[
1941                ("activity_type", "FILL"),
1942                ("after", after),
1943                ("page_size", PAGE_SIZE_STR),
1944                ("direction", "asc"),
1945            ]);
1946            if let Some(token) = page_token_ref {
1947                req = req.query(&[("page_token", token)]);
1948            }
1949            req
1950        })
1951        .await?;
1952
1953        let page_len = activities.len();
1954        // Capture the page token from the current page BEFORE extending `all`, so that
1955        // any future filtering between here and all.extend cannot shift the last element
1956        // and cause the same page to be re-fetched indefinitely.
1957        //
1958        // Alpaca activity IDs are ULID-format (monotonically increasing) and are accepted
1959        // as exclusive page_token cursors by GET /v2/account/activities: the next page
1960        // begins with the item AFTER the token, so the boundary item is not re-delivered.
1961        // The pagination contract relies on this property — verify against Alpaca API docs
1962        // if behaviour changes.
1963        let page_token_candidate = activities.last().map(|a| a.id.clone());
1964        all.extend(activities);
1965
1966        if page_len < ALPACA_MAX_ACTIVITIES {
1967            break;
1968        }
1969        match page_token_candidate {
1970            Some(token) if !token.is_empty() => {
1971                debug!("Alpaca paginate_activities: fetching next page ({page_len} results)");
1972                page_token = Some(token);
1973            }
1974            // Empty token or no last item — pagination complete.
1975            // Guard against empty string to prevent infinite loop if Alpaca ever
1976            // returns a full page with last.id = "" (would restart from beginning).
1977            _ => break,
1978        }
1979    }
1980
1981    Ok(ActivityPage {
1982        activities: all,
1983        truncated,
1984    })
1985}
1986
1987// ---------------------------------------------------------------------------
1988// WebSocket connection manager
1989// ---------------------------------------------------------------------------
1990
1991/// Long-running task managing the WebSocket lifecycle for account_stream.
1992///
1993/// Loop: connect → auth → subscribe → stream events → on disconnect → backoff
1994/// → fill recovery → reconnect. The `tx` channel persists across reconnections
1995/// so the consumer sees a seamless event stream.
1996///
1997/// Terminates when the consumer drops the stream or max reconnect attempts are
1998/// exhausted.
1999#[allow(clippy::cognitive_complexity)] // the inner select! loop owns `ws` and mutates `backoff` —
2000// extracting to a function requires threading 4 non-Clone values (ws, tx, dedup, backoff)
2001// through the call, which adds more complexity than it removes
2002async fn connection_manager(
2003    tx: mpsc::UnboundedSender<UnindexedAccountEvent>,
2004    dedup: SharedDedupCache,
2005    config: Arc<AlpacaConfig>,
2006    http: reqwest::Client,
2007    rate_limiter: Arc<RateLimitTracker>,
2008    instruments: Vec<InstrumentNameExchange>,
2009    initial_ws: Option<WebSocket>,
2010) {
2011    let mut backoff = ExponentialBackoff::new();
2012    let mut disconnect_time: Option<DateTime<Utc>> = None;
2013    let mut current_ws = initial_ws;
2014
2015    'outer: loop {
2016        // --- Connect (skip on first iteration if initial_ws was provided) ---
2017        let mut ws = match current_ws.take() {
2018            Some(ws) => ws,
2019            None => match connect_and_subscribe(&config).await {
2020                Ok(ws) => ws,
2021                Err(e) => {
2022                    error!(%e, "Alpaca WS connect/subscribe failed");
2023                    if !backoff.wait().await {
2024                        error!("Alpaca max reconnect attempts exhausted");
2025                        break;
2026                    }
2027                    continue;
2028                }
2029            },
2030        };
2031        info!("Alpaca account_stream connected and subscribed");
2032        // Session established — reset backoff regardless of whether any text events
2033        // arrive. Without this, a heartbeat-only session (Pings only, no trade_updates)
2034        // would never call process_ws_text and therefore never reset the counter,
2035        // causing the next disconnect to exhaust the reconnect budget faster than expected.
2036        //
2037        // Edge case: if the server accepts the WS handshake and immediately closes the
2038        // connection (fast-accept-close loop), backoff resets on every iteration, causing
2039        // all MAX_RECONNECT_ATTEMPTS attempts to run at INITIAL_BACKOFF_MS rather than
2040        // escalating. This is accepted behaviour for this pathological server scenario —
2041        // the 10-attempt budget still provides ~10 s of protection before giving up.
2042        backoff.reset();
2043
2044        // --- Fill recovery after reconnect ---
2045        // Runs before the event loop so live events arriving during recovery are
2046        // captured by the already-connected WS session. The dedup cache prevents
2047        // duplicates between recovered REST fills and live WS events.
2048        if let Some(dt) = disconnect_time.take() {
2049            let base = config.rest_base_url();
2050            let after_str = dt.to_rfc3339();
2051            match tokio::time::timeout(
2052                Duration::from_secs(FILL_RECOVERY_TIMEOUT_SECS),
2053                recover_fills(
2054                    &http,
2055                    &rate_limiter,
2056                    &instruments,
2057                    base,
2058                    &after_str,
2059                    &tx,
2060                    &dedup,
2061                ),
2062            )
2063            .await
2064            {
2065                Ok(()) => {}
2066                Err(_) => warn!(
2067                    timeout_secs = FILL_RECOVERY_TIMEOUT_SECS,
2068                    "Alpaca fill recovery timed out — some fills may be missing"
2069                ),
2070            }
2071        }
2072
2073        // --- Stream events ---
2074        // Each iteration of the inner loop polls ws.next(), a heartbeat timer,
2075        // and tx.closed() simultaneously via select!. The heartbeat deadline is
2076        // reset on every received message (rolling window).
2077        //
2078        // The timer is pinned once and reset in-place via Sleep::reset(), avoiding
2079        // a new Sleep allocation and Tokio timer-wheel registration per loop iteration.
2080        // Track the wall-clock time of the last received message. Used to anchor
2081        // fill recovery after a heartbeat timeout: Utc::now() at reconnect time
2082        // would be ~HEARTBEAT_TIMEOUT_SECS after the last real message, causing
2083        // the recovery window to miss fills in that silent period.
2084
2085        let mut last_message_time = Utc::now();
2086        let heartbeat = tokio::time::sleep(Duration::from_secs(HEARTBEAT_TIMEOUT_SECS));
2087        tokio::pin!(heartbeat);
2088
2089        // Resets the rolling heartbeat deadline and records the wall-clock receive time.
2090        // Pin<&mut Sleep> cannot be passed to a regular function, so a macro avoids
2091        // repeating the same two-line block across every message-bearing select! arm.
2092        // NOTE: must be defined AFTER the variables it captures for macro hygiene.
2093        macro_rules! reset_heartbeat {
2094            () => {
2095                heartbeat.as_mut().reset(
2096                    tokio::time::Instant::now() + Duration::from_secs(HEARTBEAT_TIMEOUT_SECS),
2097                );
2098                last_message_time = Utc::now();
2099            };
2100        }
2101        loop {
2102            tokio::select! {
2103                msg = ws.next() => {
2104                    match msg {
2105                        Some(Ok(WsMessage::Ping(_))) => {
2106                            // tokio-tungstenite automatically queues a Pong when poll_next
2107                            // returns a Ping; sending a second Pong would be a duplicate.
2108                            reset_heartbeat!();
2109                        }
2110                        Some(Ok(WsMessage::Text(text))) => {
2111                            process_ws_text(text.as_str(), &tx, &dedup, &mut backoff);
2112                            reset_heartbeat!();
2113                        }
2114                        Some(Ok(WsMessage::Binary(bytes))) => {
2115                            // Alpaca paper trading sends binary-framed JSON.
2116                            match std::str::from_utf8(&bytes) {
2117                                Ok(text) => {
2118                                    process_ws_text(text, &tx, &dedup, &mut backoff);
2119                                    // Only reset heartbeat for valid UTF-8 frames that may carry
2120                                    // real events. A corrupt binary frame (e.g. from a proxy)
2121                                    // must not keep the watchdog from firing.
2122                                    reset_heartbeat!();
2123                                }
2124                                Err(e) => warn!(%e, "Alpaca WS binary frame: not valid UTF-8"),
2125                            }
2126                        }
2127                        Some(Ok(WsMessage::Close(frame))) => {
2128                            warn!(frame = ?frame, "Alpaca WS closed by server");
2129                            break;
2130                        }
2131                        Some(Ok(_)) => {} // Pong, Frame — ignore
2132                        Some(Err(e)) => {
2133                            warn!(%e, "Alpaca WS error, reconnecting");
2134                            break;
2135                        }
2136                        None => {
2137                            warn!("Alpaca WS stream ended, reconnecting");
2138                            break;
2139                        }
2140                    }
2141                }
2142                _ = &mut heartbeat => {
2143                    warn!(
2144                        timeout_secs = HEARTBEAT_TIMEOUT_SECS,
2145                        "Alpaca heartbeat timeout, reconnecting"
2146                    );
2147                    // Heartbeat timeout is a failure — do NOT reset backoff here.
2148                    // Backoff resets on successful event receipt in process_ws_text.
2149                    break;
2150                }
2151                _ = tx.closed() => {
2152                    debug!("Alpaca account_stream consumer dropped, terminating");
2153                    let _ = tokio::time::timeout(
2154                        Duration::from_secs(WS_CLOSE_TIMEOUT_SECS),
2155                        ws.close(None),
2156                    ).await;
2157                    break 'outer;
2158                }
2159            }
2160        }
2161
2162        // --- Record disconnect time for fill recovery ---
2163        // Anchor to last_message_time, not Utc::now(). For heartbeat-triggered
2164        // disconnects, Utc::now() would be ~HEARTBEAT_TIMEOUT_SECS after the last
2165        // real message, causing the recovery window to miss fills in that gap.
2166        disconnect_time =
2167            Some(last_message_time - chrono::Duration::milliseconds(SIGNAL_RECOVERY_LOOKBACK_MS));
2168
2169        // --- Close stale WS ---
2170        let _ =
2171            tokio::time::timeout(Duration::from_secs(WS_CLOSE_TIMEOUT_SECS), ws.close(None)).await;
2172
2173        if tx.is_closed() {
2174            break;
2175        }
2176        if !backoff.wait().await {
2177            error!("Alpaca max reconnect attempts exhausted, stream terminating");
2178            break;
2179        }
2180    }
2181}
2182
2183/// Error during WebSocket handshake (auth + subscribe).
2184///
2185/// Separates transport errors (network issues, connection drops) from auth errors
2186/// (invalid credentials) so callers can apply appropriate retry logic.
2187#[derive(Debug)]
2188enum HandshakeError {
2189    /// Network-level failure (WS send failed, connection dropped).
2190    /// Transient — retry with backoff.
2191    Transport(String),
2192    /// Authentication rejected by Alpaca (invalid/expired credentials).
2193    /// Not transient — do not retry without credential changes.
2194    Auth(String),
2195}
2196
2197/// Connect to the Alpaca WebSocket, authenticate, and subscribe to trade_updates.
2198///
2199/// On auth or subscribe failure the connection is closed cleanly.
2200async fn connect_and_subscribe(config: &AlpacaConfig) -> Result<WebSocket, UnindexedClientError> {
2201    let url = config.ws_url();
2202    debug!(%url, "Alpaca: connecting to WebSocket");
2203
2204    let mut ws = rustrade_integration::protocol::websocket::connect(url)
2205        .await
2206        .map_err(|e| {
2207            UnindexedClientError::Connectivity(ConnectivityError::Socket(format!(
2208                "WS connect: {e}"
2209            )))
2210        })?;
2211
2212    // auth + subscribe with overall timeout
2213    let result = tokio::time::timeout(
2214        Duration::from_secs(WS_HANDSHAKE_TIMEOUT_SECS),
2215        ws_handshake(&mut ws, config),
2216    )
2217    .await;
2218
2219    match result {
2220        Ok(Ok(())) => Ok(ws),
2221        Ok(Err(HandshakeError::Transport(e))) => {
2222            let _ = ws.close(None).await;
2223            Err(UnindexedClientError::Connectivity(
2224                ConnectivityError::Socket(e),
2225            ))
2226        }
2227        Ok(Err(HandshakeError::Auth(e))) => {
2228            let _ = ws.close(None).await;
2229            Err(UnindexedClientError::Api(ApiError::Unauthenticated(e)))
2230        }
2231        Err(_) => {
2232            let _ = ws.close(None).await;
2233            Err(UnindexedClientError::Connectivity(
2234                ConnectivityError::Timeout,
2235            ))
2236        }
2237    }
2238}
2239
2240/// Perform the Alpaca WS auth + subscribe sequence on a connected WebSocket.
2241///
2242/// Protocol:
2243/// 1. Send `{"action":"auth","key":...,"secret":...}`
2244/// 2. Await `{"stream":"authorization","data":{"status":"authorized",...}}`
2245/// 3. Send `{"action":"listen","data":{"streams":["trade_updates"]}}`
2246/// 4. Await `{"stream":"listening","data":{"streams":["trade_updates"]}}`
2247async fn ws_handshake(ws: &mut WebSocket, config: &AlpacaConfig) -> Result<(), HandshakeError> {
2248    // Step 1: send auth
2249    let auth = serde_json::json!({
2250        "action": "auth",
2251        "key": config.api_key(),
2252        // direct field access within module — secret_key has no pub getter to
2253        // prevent external credential exposure
2254        "secret": config.secret_key,
2255    })
2256    .to_string();
2257    ws.send(WsMessage::Text(auth.into()))
2258        .await
2259        .map_err(|e| HandshakeError::Transport(format!("WS auth send: {e}")))?;
2260
2261    // Step 2: wait for authorization message
2262    loop {
2263        match ws.next().await {
2264            Some(Ok(WsMessage::Text(text))) => {
2265                if let Some(result) = check_auth_response(text.as_str()) {
2266                    result?;
2267                    break;
2268                }
2269            }
2270            Some(Ok(WsMessage::Binary(bytes))) => {
2271                if let Ok(text) = std::str::from_utf8(&bytes)
2272                    && let Some(result) = check_auth_response(text)
2273                {
2274                    result?;
2275                    break;
2276                }
2277            }
2278            Some(Err(e)) => {
2279                return Err(HandshakeError::Transport(format!(
2280                    "WS error during auth: {e}"
2281                )));
2282            }
2283            None => {
2284                return Err(HandshakeError::Transport(
2285                    "WS closed before auth response".into(),
2286                ));
2287            }
2288            _ => {} // ping/pong during auth — ignore
2289        }
2290    }
2291
2292    // Step 3: subscribe to trade_updates
2293    let sub = serde_json::json!({
2294        "action": "listen",
2295        "data": { "streams": ["trade_updates"] }
2296    })
2297    .to_string();
2298    ws.send(WsMessage::Text(sub.into()))
2299        .await
2300        .map_err(|e| HandshakeError::Transport(format!("WS subscribe send: {e}")))?;
2301
2302    // Step 4: wait for listening acknowledgment (optional but confirms subscription)
2303    loop {
2304        match ws.next().await {
2305            Some(Ok(WsMessage::Text(text))) => {
2306                if check_listen_ack(text.as_str()) {
2307                    break;
2308                }
2309                // Other messages in this window are NOT buffered and are permanently
2310                // dropped — callers must reconcile order state via fetch_open_orders
2311                // after each (re)connect, as documented in account_stream's doc comment.
2312                // Log at warn! for trade_updates (fill/lifecycle events) to ensure
2313                // production operators see dropped events; trace! for other streams.
2314                if let Ok(msg) = serde_json::from_str::<AlpacaStreamMessage<'_>>(text.as_str()) {
2315                    if msg.stream == "trade_updates" {
2316                        warn!(stream = %msg.stream, "WS trade_updates event dropped during listen-ack handshake — will be recovered via REST for fills, but lifecycle events (new/canceled) are lost");
2317                    } else {
2318                        trace!(stream = %msg.stream, "WS message dropped during listen-ack handshake");
2319                    }
2320                } else {
2321                    trace!(
2322                        bytes = text.len(),
2323                        "WS non-stream message dropped during listen-ack handshake"
2324                    );
2325                }
2326            }
2327            Some(Ok(WsMessage::Binary(bytes))) => {
2328                if let Ok(text) = std::str::from_utf8(&bytes)
2329                    && check_listen_ack(text)
2330                {
2331                    break;
2332                }
2333                trace!(
2334                    bytes = bytes.len(),
2335                    "WS binary message dropped during listen-ack handshake"
2336                );
2337            }
2338            Some(Err(e)) => {
2339                return Err(HandshakeError::Transport(format!(
2340                    "WS error during subscribe: {e}"
2341                )));
2342            }
2343            None => {
2344                return Err(HandshakeError::Transport(
2345                    "WS closed before subscribe ack".into(),
2346                ));
2347            }
2348            _ => {}
2349        }
2350    }
2351
2352    info!("Alpaca WS authenticated and subscribed to trade_updates");
2353    Ok(())
2354}
2355
2356/// Parse a WS message to check for authorization response.
2357/// Returns `None` if the message is not an authorization response.
2358/// Returns `Some(Ok(()))` on success, `Some(Err(HandshakeError::Auth(...)))` on auth failure.
2359///
2360/// Uses `AlpacaStreamMessage` for consistency with the rest of the WS parsing pipeline.
2361fn check_auth_response(text: &str) -> Option<Result<(), HandshakeError>> {
2362    let msg = serde_json::from_str::<AlpacaStreamMessage<'_>>(text).ok()?;
2363    if msg.stream != "authorization" {
2364        return None;
2365    }
2366    #[derive(Deserialize)]
2367    struct AuthData<'a> {
2368        status: &'a str,
2369    }
2370    let data = serde_json::from_str::<AuthData<'_>>(msg.data.get()).ok()?;
2371    if data.status == "authorized" {
2372        Some(Ok(()))
2373    } else {
2374        Some(Err(HandshakeError::Auth(format!(
2375            "Alpaca WS auth failed: status={}",
2376            data.status
2377        ))))
2378    }
2379}
2380
2381/// Returns `true` if the WS message is a listening acknowledgment for trade_updates.
2382///
2383/// Uses `AlpacaStreamMessage` for consistency with the rest of the WS parsing pipeline.
2384fn check_listen_ack(text: &str) -> bool {
2385    let Ok(msg) = serde_json::from_str::<AlpacaStreamMessage<'_>>(text) else {
2386        return false;
2387    };
2388    if msg.stream != "listening" {
2389        return false;
2390    }
2391    #[derive(Deserialize)]
2392    struct ListenData<'a> {
2393        #[serde(borrow)]
2394        streams: Vec<&'a str>,
2395    }
2396    let Ok(data) = serde_json::from_str::<ListenData<'_>>(msg.data.get()) else {
2397        return false;
2398    };
2399    data.streams.contains(&"trade_updates")
2400}
2401
2402// ---------------------------------------------------------------------------
2403// Process incoming WS messages
2404// ---------------------------------------------------------------------------
2405
2406/// Parse a raw WS text message and forward relevant account events to `tx`.
2407fn process_ws_text(
2408    text: &str,
2409    tx: &mpsc::UnboundedSender<UnindexedAccountEvent>,
2410    dedup: &SharedDedupCache,
2411    backoff: &mut ExponentialBackoff,
2412) {
2413    let msg: AlpacaStreamMessage<'_> = match serde_json::from_str(text) {
2414        Ok(m) => m,
2415        Err(e) => {
2416            trace!(
2417                %e,
2418                raw = ?&text[..text.len().min(200)],
2419                "Alpaca WS: skipped non-JSON message"
2420            );
2421            return;
2422        }
2423    };
2424
2425    // Any successfully-parsed frame proves the connection is alive — reset backoff here
2426    // so that unrecognised event types (pending_cancel, replaced, etc.) also reset it.
2427    backoff.reset();
2428
2429    match msg.stream.as_str() {
2430        "trade_updates" => {
2431            let update: AlpacaTradeUpdate<'_> = match serde_json::from_str(msg.data.get()) {
2432                Ok(u) => u,
2433                Err(e) => {
2434                    // Use warn (not trace): the outer frame was valid trade_updates JSON,
2435                    // so failure here signals an unexpected payload format. Could indicate
2436                    // an Alpaca API change or a missing required field dropping a real
2437                    // event (e.g. a rejected order). Visible at production log levels.
2438                    warn!(
2439                        %e,
2440                        raw = ?&msg.data.get()[..msg.data.get().len().min(200)],
2441                        "Alpaca WS trade_updates: failed to deserialize event — event dropped"
2442                    );
2443                    return;
2444                }
2445            };
2446
2447            // PERF: Check dedup BEFORE constructing the full event for fill events.
2448            // This avoids heap allocations (Trade, InstrumentNameExchange, TradeId) on
2449            // duplicate fills, which occur on every reconnect during recovery overlap.
2450            if is_fill_event(&update) {
2451                let key = early_dedup_key(&update);
2452                if is_duplicate(dedup, &key) {
2453                    trace!("Alpaca WS: skipping duplicate fill event (early check)");
2454                    return;
2455                }
2456            }
2457
2458            if let Some(event) = convert_trade_update(update) {
2459                // Consumer dropped errors are benign; connection_manager will detect
2460                // tx.closed() on the next select! poll and exit cleanly.
2461                let _ = tx.send(event);
2462            }
2463        }
2464        "authorization" | "listening" => {
2465            // Ack messages can appear during initial stream if the handshake was
2466            // not fully awaited. These are benign.
2467            trace!(stream = %msg.stream, "Alpaca WS: auth/listen ack received during stream");
2468        }
2469        other => {
2470            trace!(%other, "Alpaca WS: ignoring unknown stream type");
2471        }
2472    }
2473}
2474
2475/// Extract a dedup key from an account event, if applicable.
2476///
2477/// Uses `trade.id` as the key. Both the WS path (`convert_trade_update`) and the
2478/// REST recovery path (`recover_fills`) synthesise `TradeId` as
2479/// `"{order_id}:{cumulative_filled_qty}"`, ensuring the same fill produces the
2480/// same key regardless of which path delivered it.
2481fn fill_dedup_key_from_event(event: &UnindexedAccountEvent) -> Option<&SmolStr> {
2482    match &event.kind {
2483        // Return a reference to avoid cloning the SmolStr; is_duplicate takes &SmolStr.
2484        AccountEventKind::Trade(trade) => Some(&trade.id.0),
2485        _ => None,
2486    }
2487}
2488
2489/// Returns `true` if this event type produces a fill (Trade) event.
2490///
2491/// Used for early dedup check before allocating the full event.
2492#[inline]
2493fn is_fill_event(update: &AlpacaTradeUpdate<'_>) -> bool {
2494    matches!(update.event.as_str(), "fill" | "partial_fill")
2495}
2496
2497/// Construct the dedup key from raw WS update fields without allocating the full event.
2498///
2499/// Key format: `"{order_id}:{cumulative_filled_qty}"` — same as `convert_trade_update`
2500/// and `recover_fills` produce, ensuring cross-source dedup works correctly.
2501///
2502/// Note: The `format_smolstr!` call heap-allocates for UUID-length order IDs (36 chars
2503/// exceeds SmolStr's 22-byte inline limit). This is unavoidable given the key length.
2504/// Passing the Decimal directly to format_smolstr! (rather than via intermediate String)
2505/// lets Decimal's Display impl write directly into the buffer, eliminating one allocation.
2506fn early_dedup_key(update: &AlpacaTradeUpdate<'_>) -> SmolStr {
2507    let filled_qty = update.order.filled_qty.unwrap_or("0");
2508    // Parse and normalize to match convert_trade_update's key format exactly.
2509    // Decimal::from_str + normalize() are stack-only; no heap allocation until format_smolstr!.
2510    let qty = Decimal::from_str(filled_qty).unwrap_or(Decimal::ZERO);
2511    format_smolstr!("{}:{}", update.order.id, qty.normalize())
2512}
2513
2514// ---------------------------------------------------------------------------
2515// Fill recovery
2516// ---------------------------------------------------------------------------
2517
2518/// Fetch fills missed during a WS disconnect and forward through the dedup cache.
2519async fn recover_fills(
2520    http: &reqwest::Client,
2521    rate_limiter: &RateLimitTracker,
2522    instruments: &[InstrumentNameExchange],
2523    base: &str,
2524    after: &str,
2525    tx: &mpsc::UnboundedSender<UnindexedAccountEvent>,
2526    dedup: &SharedDedupCache,
2527) {
2528    // Empty `instruments` means "recover for all subscribed symbols" (same convention as
2529    // `fetch_trades` / `fetch_open_orders`). Skip the set allocation when not filtering.
2530    info!(%after, instruments = instruments.len(), "Alpaca recovering fills after reconnect");
2531    let instrument_set: fnv::FnvHashSet<&str> = if instruments.is_empty() {
2532        fnv::FnvHashSet::default()
2533    } else {
2534        instruments.iter().map(|i| i.name().as_str()).collect()
2535    };
2536
2537    let page = match paginate_activities(http, rate_limiter, base, after).await {
2538        Ok(p) => p,
2539        Err(e) => {
2540            error!(%e, "Alpaca fill recovery: REST request failed");
2541            return;
2542        }
2543    };
2544
2545    // Log truncation as error — the returned data is partial and some fills are
2546    // permanently lost. Callers cannot propagate this error (recover_fills returns ()),
2547    // but the log ensures operators are alerted.
2548    if page.truncated {
2549        error!(
2550            max_pages = MAX_ACTIVITY_PAGES,
2551            "Alpaca fill recovery: max page limit reached, truncating — \
2552             fills from this outage are permanently lost. Manual reconciliation required."
2553        );
2554    }
2555
2556    let activities = page.activities;
2557
2558    let mut recovered = 0u32;
2559    let mut duplicates = 0u32;
2560
2561    // Track cumulative filled qty per order so that the synthesised TradeId matches
2562    // the WS path: both produce "{order_id}:{cumulative_filled_qty}". This prevents
2563    // same-size partial fill collisions (e.g. two 1-lot fills on a 2-lot order)
2564    // and ensures cross-source dedup works correctly after reconnect.
2565    //
2566    // Activities are fetched with direction=asc so they arrive in chronological order.
2567    // Cumulative accumulation here must match the WS path's order.filled_qty progression.
2568    // If Alpaca violates this ordering guarantee, dedup keys will diverge and fills
2569    // may be dropped or duplicated.
2570    // Borrow `activities` so &str keys into order_id strings remain valid for the
2571    // entire loop. Consuming iteration would drop each activity at end of its iteration,
2572    // invalidating keys that still need to be looked up by later fills on the same order.
2573    let mut cumulative_qty: FnvHashMap<&str, Decimal> = FnvHashMap::default();
2574
2575    for activity in &activities {
2576        if !instrument_set.is_empty() && !instrument_set.contains(activity.symbol.as_str()) {
2577            // Safe to skip without advancing cumulative_qty: each Alpaca order is bound
2578            // to exactly one symbol, so no fill for a different symbol can ever share
2579            // the same order_id as a fill we're tracking.
2580            continue;
2581        }
2582
2583        // Advance cumulative_qty for every activity, regardless of whether parsing
2584        // succeeds. The WS path uses Alpaca's cumulative filled_qty, which counts ALL
2585        // fills — including those with malformed fields. Skipping the counter for a bad
2586        // fill causes subsequent fills to produce dedup keys that diverge from the WS
2587        // path (off by the bad fill's qty), so the next good fill appears as a duplicate
2588        // on one path and a new fill on the other, resulting in either a missed or a
2589        // double-delivered fill for that execution.
2590        //
2591        // activity.qty is always populated for FILL activities (Alpaca guarantees it).
2592        // unwrap_or(ZERO) guards against unexpected API changes; a zero qty skips
2593        // incrementing the cumulative counter for that activity. Note: a non-empty
2594        // but non-parseable qty string (e.g. an API regression sending "abc") also
2595        // produces exec_qty=ZERO — the counter stalls, causing subsequent fills on
2596        // the same order to produce dedup keys that diverge from the WS path.
2597        let exec_qty = Decimal::from_str(&activity.qty).unwrap_or(Decimal::ZERO);
2598        let cum = cumulative_qty
2599            .entry(activity.order_id.as_str())
2600            .or_default();
2601        *cum += exec_qty;
2602        let cumulative = *cum;
2603
2604        let mut trade = match convert_activity_to_trade(activity) {
2605            Some(t) => t,
2606            None => {
2607                warn!(id = %activity.id, symbol = %activity.symbol, "Alpaca: skipping activity with unparseable fields");
2608                continue; // Counter already advanced; dedup key sequence stays aligned with WS.
2609            }
2610        };
2611
2612        // Override trade.id to match the WS synthesised format so that
2613        // fill_dedup_key_from_event produces the same key for both sources.
2614        // Normalise the cumulative Decimal to strip trailing zeros so the string
2615        // representation matches the WS path (e.g. "1" == "1.00" after normalize).
2616        trade.id = TradeId(format_smolstr!(
2617            "{}:{}",
2618            activity.order_id,
2619            cumulative.normalize()
2620        ));
2621
2622        let event =
2623            UnindexedAccountEvent::new(ExchangeId::AlpacaBroker, AccountEventKind::Trade(trade));
2624
2625        // Re-use fill_dedup_key_from_event so the key logic is in one place.
2626        if fill_dedup_key_from_event(&event).is_some_and(|k| is_duplicate(dedup, k)) {
2627            duplicates += 1;
2628            continue;
2629        }
2630        if tx.send(event).is_err() {
2631            debug!("Alpaca fill recovery: consumer dropped during recovery");
2632            return;
2633        }
2634        recovered += 1;
2635    }
2636
2637    info!(recovered, duplicates, "Alpaca fill recovery complete");
2638}
2639
2640// ---------------------------------------------------------------------------
2641// Type conversion helpers
2642// ---------------------------------------------------------------------------
2643
2644/// Convert an Alpaca account response to rustrade balance entries.
2645///
2646/// Returns a single USD balance with:
2647/// - `total` = equity (total account value including open positions)
2648/// - `free` = options_buying_power (if available) or buying_power
2649///
2650/// If `assets` is non-empty, only returns the balance if "usd" (case-insensitive)
2651/// is in the requested set. An empty `assets` slice returns the USD balance unconditionally.
2652fn convert_account_to_balances(
2653    account: &AlpacaAccount,
2654    assets: &[AssetNameExchange],
2655) -> Vec<AssetBalance<AssetNameExchange>> {
2656    // Preserve the caller's casing for the USD asset name (e.g. "USD" vs "usd").
2657    // When no filter is given, fall back to lowercase "usd" as the canonical form.
2658    let usd_entry = assets
2659        .iter()
2660        .find(|a| a.name().as_str().eq_ignore_ascii_case("usd"));
2661
2662    // Filter check: if assets is specified, only return USD balance if requested.
2663    if !assets.is_empty() && usd_entry.is_none() {
2664        return Vec::new();
2665    }
2666
2667    let usd_name = usd_entry
2668        .cloned()
2669        .unwrap_or_else(|| AssetNameExchange::new("usd"));
2670
2671    let total = Decimal::from_str(&account.equity).unwrap_or(Decimal::ZERO);
2672    let free = account
2673        .options_buying_power
2674        .as_deref()
2675        .and_then(|s| Decimal::from_str(s).ok())
2676        // Filter out zero: Alpaca returns options_buying_power="0.00" for equity-only
2677        // accounts (options not enabled) rather than omitting the field. Without this
2678        // filter, unwrap_or_else never fires and the engine reports free=0, blocking
2679        // all orders on an account that may have substantial buying_power.
2680        .filter(|d| !d.is_zero())
2681        .unwrap_or_else(|| Decimal::from_str(&account.buying_power).unwrap_or(Decimal::ZERO));
2682
2683    vec![AssetBalance::new(
2684        usd_name,
2685        Balance::new(total, free),
2686        Utc::now(),
2687    )]
2688}
2689
2690/// Convert Alpaca positions to crypto asset balance entries.
2691///
2692/// Only positions with `asset_class == "crypto"` are included. The base asset
2693/// is extracted from the symbol (e.g., `"BTC/USD"` → `"btc"`).
2694///
2695/// - `total` = quantity of the holding in base currency units (e.g. 0.5 BTC)
2696/// - `free`  = qty_available (base currency units not locked in open orders)
2697///
2698/// If `assets` is non-empty, only positions whose base asset name matches an
2699/// entry in the slice (case-insensitive) are returned.
2700fn convert_positions_to_balances(
2701    positions: &[AlpacaPosition],
2702    assets: &[AssetNameExchange],
2703) -> Vec<AssetBalance<AssetNameExchange>> {
2704    let now = Utc::now();
2705    positions
2706        .iter()
2707        .filter(|p| p.asset_class.eq_ignore_ascii_case("crypto"))
2708        .filter_map(|p| {
2709            // Alpaca crypto symbols are "BASE/QUOTE" (e.g., "BTC/USD").
2710            // Extract the base currency as the asset name.
2711            let base = p
2712                .symbol
2713                .split('/')
2714                .next()
2715                .map(|s| s.to_ascii_lowercase())
2716                .unwrap_or_else(|| p.symbol.to_ascii_lowercase());
2717
2718            // Apply assets filter if specified.
2719            if !assets.is_empty()
2720                && !assets
2721                    .iter()
2722                    .any(|a| a.name().as_str().eq_ignore_ascii_case(&base))
2723            {
2724                return None;
2725            }
2726
2727            // total and free are in base currency units (e.g., BTC), not USD,
2728            // consistent with AssetBalance semantics for currency/crypto assets.
2729            let total = Decimal::from_str(&p.qty).unwrap_or(Decimal::ZERO);
2730            let free = Decimal::from_str(&p.qty_available).unwrap_or(Decimal::ZERO);
2731            let asset_name = AssetNameExchange::new(base);
2732            Some(AssetBalance::new(
2733                asset_name,
2734                Balance::new(total, free),
2735                now,
2736            ))
2737        })
2738        .collect()
2739}
2740
2741/// Group a list of open orders into per-instrument snapshots for account_snapshot.
2742///
2743/// When `instruments` is non-empty, a snapshot is returned for every requested instrument
2744/// (possibly with an empty orders list). When empty, only instruments with open orders
2745/// are returned.
2746fn build_instrument_snapshots(
2747    orders: Vec<AlpacaOrderResponse>,
2748    instruments: &[InstrumentNameExchange],
2749) -> Vec<InstrumentAccountSnapshot<ExchangeId, AssetNameExchange, InstrumentNameExchange>> {
2750    // Build ordered map from symbol → snapshot to preserve deterministic ordering.
2751    let mut by_symbol: IndexMap<SmolStr, Vec<_>> = IndexMap::new();
2752
2753    for order in orders {
2754        let sym = SmolStr::new(&order.symbol);
2755        if let Some(converted) = convert_open_order(&order) {
2756            let wrapped = crate::order::Order {
2757                key: converted.key,
2758                side: converted.side,
2759                price: converted.price,
2760                quantity: converted.quantity,
2761                kind: converted.kind,
2762                time_in_force: converted.time_in_force,
2763                state: OrderState::active(converted.state),
2764            };
2765            by_symbol.entry(sym).or_default().push(wrapped);
2766        }
2767    }
2768
2769    // If instruments is empty, return all; otherwise filter to requested set.
2770    if instruments.is_empty() {
2771        by_symbol
2772            .into_iter()
2773            .map(|(sym, orders)| {
2774                InstrumentAccountSnapshot::new(InstrumentNameExchange::new(sym), orders, None, None)
2775            })
2776            .collect()
2777    } else {
2778        instruments
2779            .iter()
2780            .map(|inst| {
2781                // swap_remove is O(1); output order is determined by the `instruments`
2782                // slice, not by the internal IndexMap order of `by_symbol`.
2783                let orders = by_symbol
2784                    .swap_remove(inst.name().as_str())
2785                    .unwrap_or_default();
2786                InstrumentAccountSnapshot::new(inst.clone(), orders, None, None)
2787            })
2788            .collect()
2789    }
2790}
2791
2792/// Convert an Alpaca REST order into rustrade's Open state order.
2793fn convert_open_order(
2794    o: &AlpacaOrderResponse,
2795) -> Option<Order<ExchangeId, InstrumentNameExchange, Open>> {
2796    let order_id = OrderId(SmolStr::new(&o.id));
2797    let cid = o
2798        .client_order_id
2799        .as_deref()
2800        .map(ClientOrderId::new)
2801        .unwrap_or_else(|| ClientOrderId::new(o.id.as_str()));
2802
2803    let instrument = InstrumentNameExchange::new(&o.symbol);
2804    let side = parse_side(&o.side)?;
2805    let quantity = Decimal::from_str(o.qty.as_deref().unwrap_or("0")).ok()?;
2806    // Notional orders (placed by dollar value, qty=null) have no representable quantity.
2807    // Skip them rather than recording a zero-quantity order that would corrupt reconciliation.
2808    if quantity.is_zero() {
2809        return None;
2810    }
2811    let price = o
2812        .limit_price
2813        .as_deref()
2814        .and_then(|s| Decimal::from_str(s).ok());
2815    let filled_qty = Decimal::from_str(&o.filled_qty).unwrap_or(Decimal::ZERO);
2816    let kind = parse_order_kind(
2817        &o.order_type,
2818        o.stop_price.as_deref(),
2819        o.trail_percent.as_deref(),
2820        o.trail_price.as_deref(),
2821    )?;
2822    let time_in_force = parse_time_in_force(&o.time_in_force);
2823    let time_exchange = parse_timestamp(&o.created_at).unwrap_or_else(Utc::now);
2824
2825    Some(Order {
2826        key: OrderKey::new(
2827            ExchangeId::AlpacaBroker,
2828            instrument,
2829            // Alpaca doesn't carry strategy IDs in any response field.
2830            StrategyId::unknown(),
2831            cid,
2832        ),
2833        side,
2834        price,
2835        quantity,
2836        kind,
2837        time_in_force,
2838        state: Open::new(order_id, time_exchange, filled_qty),
2839    })
2840}
2841
2842/// Convert an Alpaca FILL activity into a rustrade Trade.
2843fn convert_activity_to_trade(
2844    a: &AlpacaActivity,
2845) -> Option<Trade<AssetNameExchange, InstrumentNameExchange>> {
2846    let trade_id = TradeId::new(&a.id);
2847    let order_id = OrderId(SmolStr::new(&a.order_id));
2848    let instrument = InstrumentNameExchange::new(&a.symbol);
2849    let side = parse_side(&a.side)?;
2850    let price = Decimal::from_str(&a.price).ok()?;
2851    let quantity = Decimal::from_str(&a.qty).ok()?;
2852    let time_exchange = parse_timestamp(&a.transaction_time).unwrap_or_else(|| {
2853        warn!(id = %a.id, "Alpaca activity: unparseable transaction_time, using now");
2854        Utc::now()
2855    });
2856
2857    // Alpaca equities and options are commission-free. Crypto trades incur
2858    // maker/taker fees (currently 0.15–0.25%) charged in the credited asset,
2859    // but fee info is not available in trade responses — use Activities API
2860    // for end-of-day fee reconciliation.
2861    Some(Trade::new(
2862        trade_id,
2863        order_id,
2864        instrument,
2865        StrategyId::unknown(),
2866        time_exchange,
2867        side,
2868        price,
2869        quantity,
2870        AssetFees::new(
2871            AssetNameExchange::from("USD"),
2872            Decimal::ZERO,
2873            Some(Decimal::ZERO),
2874        ),
2875    ))
2876}
2877
2878/// Convert a WebSocket trade_update event into a rustrade AccountEvent.
2879fn convert_trade_update(update: AlpacaTradeUpdate<'_>) -> Option<UnindexedAccountEvent> {
2880    // Early exit for unrecognised event types before incurring allocations for
2881    // instrument/order_id/cid — those are wasted for unknown events.
2882    let event_str = update.event.as_str();
2883    if !matches!(
2884        event_str,
2885        "fill"
2886            | "partial_fill"
2887            | "new"
2888            | "accepted"
2889            | "pending_new"
2890            | "canceled"
2891            | "expired"
2892            | "replaced"
2893            | "done_for_day"
2894            | "rejected"
2895    ) {
2896        trace!(event = %event_str, "Alpaca WS: ignoring trade_updates event type");
2897        return None;
2898    }
2899
2900    let order = &update.order;
2901    let instrument = InstrumentNameExchange::new(&*order.symbol);
2902    let order_id = OrderId(order.id.clone());
2903    let cid = order
2904        .client_order_id
2905        .as_deref()
2906        .map(ClientOrderId::new)
2907        .unwrap_or_else(|| ClientOrderId::new(order.id.as_str()));
2908
2909    match event_str {
2910        "fill" | "partial_fill" => {
2911            // Use event-level price/qty for the per-execution trade.
2912            let price = update.price.and_then(|s| Decimal::from_str(s).ok())?;
2913            let quantity = update.qty.and_then(|s| Decimal::from_str(s).ok())?;
2914            let side = parse_side(&order.side)?;
2915            let time_exchange = update
2916                .timestamp
2917                .and_then(parse_timestamp)
2918                .unwrap_or_else(Utc::now);
2919
2920            // Trade ID: synthesise from order_id + cumulative filled qty since
2921            // Alpaca WS trade_updates don't include an activity ID.
2922            // Normalise via Decimal to strip trailing zeros so the key matches the
2923            // REST recovery path (e.g. "1.00" and "1" both normalise to "1").
2924            //
2925            // If filled_qty is unparseable (API regression), cum_qty falls back to
2926            // zero. Two consecutive bad fills on the same order would produce the same
2927            // dedup key ("order_id:0"), causing the second fill to be silently dropped.
2928            // Warn loudly so API regressions are surfaced immediately.
2929            let cum_qty = Decimal::from_str(order.filled_qty.unwrap_or("0"))
2930                .inspect_err(|e| {
2931                    warn!(
2932                        order_id = %order.id,
2933                        filled_qty = ?order.filled_qty,
2934                        %e,
2935                        "Alpaca WS: failed to parse filled_qty — dedup key will use 0, \
2936                         a second malformed fill on the same order would be deduplicated away"
2937                    );
2938                })
2939                .unwrap_or(Decimal::ZERO);
2940            let trade_id = TradeId(format_smolstr!("{}:{}", order.id, cum_qty.normalize()));
2941
2942            // Alpaca equities and options are commission-free. Crypto trades incur
2943            // maker/taker fees (currently 0.15–0.25%) in the credited asset, but
2944            // fee info is not available in WebSocket updates.
2945            let trade = Trade::new(
2946                trade_id,
2947                order_id,
2948                instrument,
2949                StrategyId::unknown(),
2950                time_exchange,
2951                side,
2952                price,
2953                quantity,
2954                AssetFees::new(
2955                    AssetNameExchange::from("USD"),
2956                    Decimal::ZERO,
2957                    Some(Decimal::ZERO),
2958                ),
2959            );
2960            Some(UnindexedAccountEvent::new(
2961                ExchangeId::AlpacaBroker,
2962                AccountEventKind::Trade(trade),
2963            ))
2964        }
2965
2966        "new" | "accepted" | "pending_new" => {
2967            // Order acknowledged by Alpaca — emit an OrderSnapshot.
2968            let side = parse_side(&order.side)?;
2969            let quantity = Decimal::from_str(order.qty.unwrap_or("0")).unwrap_or(Decimal::ZERO);
2970            // Notional orders (placed by dollar value) have qty=null, yielding quantity=0.
2971            // Emitting an OrderSnapshot with quantity=0 would corrupt OMS state — skip,
2972            // consistent with convert_open_order which also returns None for zero-qty orders.
2973            if quantity.is_zero() {
2974                trace!(order_id = %order.id, "Alpaca WS: skipping notional order snapshot (qty=None)");
2975                return None;
2976            }
2977            let price = order.limit_price.and_then(|s| Decimal::from_str(s).ok());
2978            let filled_qty =
2979                Decimal::from_str(order.filled_qty.unwrap_or("0")).unwrap_or(Decimal::ZERO);
2980            let kind = parse_order_kind(
2981                &order.order_type,
2982                order.stop_price,
2983                order.trail_percent,
2984                order.trail_price,
2985            )?;
2986            let time_in_force = parse_time_in_force(&order.time_in_force);
2987            let time_exchange = update
2988                .timestamp
2989                .and_then(parse_timestamp)
2990                .unwrap_or_else(Utc::now);
2991
2992            let open_state = Open::new(order_id, time_exchange, filled_qty);
2993            let order_snapshot = crate::order::Order {
2994                key: OrderKey::new(
2995                    ExchangeId::AlpacaBroker,
2996                    instrument,
2997                    StrategyId::unknown(),
2998                    cid,
2999                ),
3000                side,
3001                price,
3002                quantity,
3003                kind,
3004                time_in_force,
3005                state: OrderState::active(open_state),
3006            };
3007            Some(UnindexedAccountEvent::new(
3008                ExchangeId::AlpacaBroker,
3009                AccountEventKind::OrderSnapshot(
3010                    rustrade_integration::collection::snapshot::Snapshot(order_snapshot),
3011                ),
3012            ))
3013        }
3014
3015        "canceled" | "expired" | "replaced" | "done_for_day" => {
3016            // Order no longer active.
3017            //
3018            // NOTE on "replaced": Alpaca's replace operation cancels the original order
3019            // and creates a NEW order with a different order ID. This arm correctly marks
3020            // the original as cancelled, but callers must call `fetch_open_orders` to
3021            // discover the replacement order (which has a new ID and won't appear in OMS
3022            // state automatically).
3023            let time_exchange = update
3024                .timestamp
3025                .and_then(parse_timestamp)
3026                .unwrap_or_else(Utc::now);
3027            let filled_qty =
3028                Decimal::from_str(order.filled_qty.unwrap_or("0")).unwrap_or(Decimal::ZERO);
3029            let cancelled = Cancelled::new(order_id, time_exchange, filled_qty);
3030            let response = crate::order::request::OrderResponseCancel {
3031                key: OrderKey::new(
3032                    ExchangeId::AlpacaBroker,
3033                    instrument,
3034                    StrategyId::unknown(),
3035                    cid,
3036                ),
3037                state: Ok(cancelled),
3038            };
3039            Some(UnindexedAccountEvent::new(
3040                ExchangeId::AlpacaBroker,
3041                AccountEventKind::OrderCancelled(response),
3042            ))
3043        }
3044
3045        "rejected" => {
3046            let response = crate::order::request::OrderResponseCancel {
3047                key: OrderKey::new(
3048                    ExchangeId::AlpacaBroker,
3049                    instrument,
3050                    StrategyId::unknown(),
3051                    cid,
3052                ),
3053                state: Err(UnindexedOrderError::Rejected(ApiError::OrderRejected(
3054                    format!("order rejected: status={}", order.status),
3055                ))),
3056            };
3057            Some(UnindexedAccountEvent::new(
3058                ExchangeId::AlpacaBroker,
3059                AccountEventKind::OrderCancelled(response),
3060            ))
3061        }
3062
3063        // All recognised event types are handled above; the early-return guard at the
3064        // top of this function ensures we never reach here for unknown event types.
3065        _ => unreachable!("convert_trade_update: unrecognised event passed early-return guard"),
3066    }
3067}
3068
3069// ---------------------------------------------------------------------------
3070// Field parsers
3071// ---------------------------------------------------------------------------
3072
3073fn parse_side(s: &str) -> Option<Side> {
3074    match s {
3075        "buy" | "Buy" | "BUY" => Some(Side::Buy),
3076        "sell" | "Sell" | "SELL" => Some(Side::Sell),
3077        other => {
3078            trace!(%other, "Alpaca: unknown order side");
3079            None
3080        }
3081    }
3082}
3083
3084fn parse_order_kind(
3085    order_type: &str,
3086    stop_price: Option<&str>,
3087    trail_percent: Option<&str>,
3088    trail_price: Option<&str>,
3089) -> Option<OrderKind> {
3090    match order_type {
3091        "market" | "Market" => Some(OrderKind::Market),
3092        "limit" | "Limit" => Some(OrderKind::Limit),
3093        "stop" | "Stop" => {
3094            let trigger_price = stop_price.and_then(|s| Decimal::from_str(s).ok())?;
3095            Some(OrderKind::Stop { trigger_price })
3096        }
3097        "stop_limit" | "Stop_limit" => {
3098            let trigger_price = stop_price.and_then(|s| Decimal::from_str(s).ok())?;
3099            Some(OrderKind::StopLimit { trigger_price })
3100        }
3101        "trailing_stop" | "Trailing_stop" => {
3102            // Alpaca returns either trail_percent or trail_price, not both.
3103            if let Some(pct) = trail_percent.and_then(|s| Decimal::from_str(s).ok()) {
3104                Some(OrderKind::TrailingStop {
3105                    offset: pct,
3106                    offset_type: TrailingOffsetType::Percentage,
3107                })
3108            } else if let Some(price) = trail_price.and_then(|s| Decimal::from_str(s).ok()) {
3109                Some(OrderKind::TrailingStop {
3110                    offset: price,
3111                    offset_type: TrailingOffsetType::Absolute,
3112                })
3113            } else {
3114                trace!("Alpaca: trailing_stop missing trail_percent and trail_price");
3115                None
3116            }
3117        }
3118        other => {
3119            trace!(%other, "Alpaca: unsupported order type, skipping");
3120            None
3121        }
3122    }
3123}
3124
3125fn parse_time_in_force(s: &str) -> TimeInForce {
3126    match s {
3127        "gtc" | "GTC" => TimeInForce::GoodUntilCancelled { post_only: false },
3128        "day" | "DAY" => TimeInForce::GoodUntilEndOfDay,
3129        "fok" | "FOK" => TimeInForce::FillOrKill,
3130        "ioc" | "IOC" => TimeInForce::ImmediateOrCancel,
3131        other => {
3132            warn!(%other, "Alpaca: unknown time_in_force, defaulting to GoodUntilEndOfDay");
3133            TimeInForce::GoodUntilEndOfDay
3134        }
3135    }
3136}
3137
3138fn parse_timestamp(s: &str) -> Option<DateTime<Utc>> {
3139    DateTime::parse_from_rfc3339(s)
3140        .ok()
3141        .map(|dt| dt.with_timezone(&Utc))
3142}
3143
3144fn map_side(side: Side) -> &'static str {
3145    match side {
3146        Side::Buy => "buy",
3147        Side::Sell => "sell",
3148    }
3149}
3150
3151fn map_order_kind(kind: OrderKind) -> Option<&'static str> {
3152    match kind {
3153        OrderKind::Market => Some("market"),
3154        OrderKind::Limit => Some("limit"),
3155        OrderKind::Stop { .. } => Some("stop"),
3156        OrderKind::StopLimit { .. } => Some("stop_limit"),
3157        OrderKind::TrailingStop { .. } => Some("trailing_stop"),
3158        // Alpaca does not support take-profit or trailing stop-limit orders.
3159        OrderKind::TakeProfit { .. }
3160        | OrderKind::TakeProfitLimit { .. }
3161        | OrderKind::TrailingStopLimit { .. } => None,
3162    }
3163}
3164
3165/// Map rustrade's `TimeInForce` to Alpaca's string representation.
3166///
3167/// # Errors
3168///
3169/// Returns `Err` if `post_only: true` is requested. Alpaca does not support
3170/// post-only orders — silently placing a taker-eligible GTC order would be
3171/// the opposite of caller intent, risking unexpected taker fees. Callers
3172/// requiring maker-only execution must use a different venue or strategy.
3173fn map_time_in_force(tif: TimeInForce) -> Result<&'static str, &'static str> {
3174    match tif {
3175        TimeInForce::GoodUntilCancelled { post_only } => {
3176            if post_only {
3177                return Err("Alpaca does not support post_only orders");
3178            }
3179            Ok("gtc")
3180        }
3181        TimeInForce::GoodUntilEndOfDay => Ok("day"),
3182        TimeInForce::FillOrKill => Ok("fok"),
3183        TimeInForce::ImmediateOrCancel => Ok("ioc"),
3184        TimeInForce::AtOpen => Ok("opg"),
3185        TimeInForce::AtClose => Ok("cls"),
3186        // Alpaca's `gtd` requires an `expired_at` timestamp on the order request,
3187        // which this client does not currently surface. Reject to avoid silently
3188        // dropping the expiry semantics.
3189        TimeInForce::GoodTillDate { .. } => {
3190            Err("Alpaca GoodTillDate is not yet wired through this client")
3191        }
3192    }
3193}
3194
3195/// Returns `true` if the symbol is an equity or options symbol (i.e., NOT crypto).
3196///
3197/// Crypto symbols on Alpaca always contain a forward slash (e.g., `"BTC/USD"`).
3198/// Equities and OCC option symbols never contain a slash. We use this to decide
3199/// whether to include `position_intent` in the order request: the field is valid
3200/// for equities and required for options, but causes a 422 on crypto orders.
3201///
3202/// NOTE: relies on Alpaca's documented symbol format (as of 2025 API). If Alpaca
3203/// introduces a new asset class whose symbols contain `/`, `position_intent` would
3204/// be silently omitted for those orders, causing 422 errors.
3205fn is_options_or_equity_symbol(symbol: &str) -> bool {
3206    !symbol.contains('/')
3207}
3208
3209/// Derives Alpaca's `position_intent` from the generic `reduce_only` flag and `side`.
3210///
3211/// | reduce_only | side | intent       | use case                          |
3212/// |-------------|------|--------------|-----------------------------------|
3213/// | false       | Buy  | BuyToOpen    | open long / add to long position  |
3214/// | false       | Sell | SellToOpen   | open short / write option         |
3215/// | true        | Buy  | BuyToClose   | close short position              |
3216/// | true        | Sell | SellToClose  | close long position               |
3217fn map_position_intent(side: Side, reduce_only: bool) -> AlpacaPositionIntent {
3218    match (reduce_only, side) {
3219        (false, Side::Buy) => AlpacaPositionIntent::BuyToOpen,
3220        (false, Side::Sell) => AlpacaPositionIntent::SellToOpen,
3221        (true, Side::Buy) => AlpacaPositionIntent::BuyToClose,
3222        (true, Side::Sell) => AlpacaPositionIntent::SellToClose,
3223    }
3224}
3225
3226/// Classifies an HTTP error status + body into a typed [`ApiError`].
3227///
3228/// Used by both `rest_with_retry` (for general REST calls) and `rest_delete_with_retry`
3229/// (for cancel operations) to ensure consistent error classification across all REST paths.
3230fn parse_api_error(status: reqwest::StatusCode, message: &str) -> crate::error::UnindexedApiError {
3231    // Fast path: 429 doesn't need message parsing.
3232    if status == reqwest::StatusCode::TOO_MANY_REQUESTS {
3233        return ApiError::RateLimit;
3234    }
3235
3236    // Compute lowercase once for all match guards that inspect the message body.
3237    let lower = message.to_ascii_lowercase();
3238    match status.as_u16() {
3239        // Match "already" before "insufficient": if Alpaca ever sends a 422 body
3240        // containing both substrings, this arm wins and maps to OrderAlreadyCancelled,
3241        // which is more specific than BalanceInsufficient.
3242        422 if lower.contains("already") => ApiError::OrderAlreadyCancelled,
3243        // Alpaca returns 422 for business-rule rejections including insufficient
3244        // funds. 403 is *Forbidden* — auth/permission failure — and must NOT be
3245        // mapped to BalanceInsufficient even if the body happens to contain the
3246        // substring "insufficient".
3247        422 if lower.contains("insufficient") => {
3248            ApiError::BalanceInsufficient(AssetNameExchange::new("usd"), message.to_owned())
3249        }
3250        401 => ApiError::Unauthenticated(format!("unauthorized: {message}")),
3251        403 => ApiError::Unauthenticated(format!("forbidden: {message}")),
3252        404 => ApiError::OrderRejected(format!("order not found: {message}")),
3253        _ => ApiError::OrderRejected(message.to_owned()),
3254    }
3255}
3256
3257/// Wraps [`parse_api_error`] for order-specific error handling (e.g., cancel_order).
3258fn parse_order_error(status: reqwest::StatusCode, message: &str) -> UnindexedOrderError {
3259    UnindexedOrderError::Rejected(parse_api_error(status, message))
3260}
3261
3262fn connectivity_err(msg: impl Into<String>) -> UnindexedClientError {
3263    UnindexedClientError::Connectivity(ConnectivityError::Socket(msg.into()))
3264}
3265
3266// ---------------------------------------------------------------------------
3267// BracketOrderClient implementation
3268// ---------------------------------------------------------------------------
3269
3270impl BracketOrderClient for AlpacaClient {
3271    async fn open_bracket_order(
3272        &self,
3273        request: UnifiedBracketOrderRequest<ExchangeId, &InstrumentNameExchange>,
3274    ) -> UnifiedBracketOrderResult {
3275        let alpaca_request = AlpacaBracketOrderRequest {
3276            instrument: request.key.instrument.clone(),
3277            strategy: request.key.strategy.clone(),
3278            cid: request.key.cid.clone(),
3279            side: request.state.side,
3280            quantity: request.state.quantity,
3281            entry_price: request.state.entry_price,
3282            take_profit_price: request.state.take_profit_price,
3283            stop_loss_price: request.state.stop_loss_price,
3284            stop_loss_limit_price: request.state.stop_loss_limit_price,
3285            time_in_force: request.state.time_in_force,
3286        };
3287
3288        let result = self.open_bracket_order(alpaca_request).await;
3289
3290        UnifiedBracketOrderResult::parent_only(result.parent)
3291    }
3292}
3293
3294// ---------------------------------------------------------------------------
3295// Tests
3296// ---------------------------------------------------------------------------
3297
3298#[cfg(test)]
3299#[allow(clippy::unwrap_used, clippy::expect_used)] // Test code: panics on bad input are acceptable
3300mod tests {
3301    use super::*;
3302
3303    #[test]
3304    fn test_alpaca_config_debug_redacts_credentials() {
3305        let cfg = AlpacaConfig::new("my_key".into(), "my_secret".into(), true);
3306        let debug = format!("{cfg:?}");
3307        assert!(!debug.contains("my_key"), "api_key should be redacted");
3308        assert!(
3309            !debug.contains("my_secret"),
3310            "secret_key should be redacted"
3311        );
3312        assert!(debug.contains("paper: true"));
3313    }
3314
3315    #[test]
3316    fn test_alpaca_config_urls() {
3317        let paper = AlpacaConfig::new("k".into(), "s".into(), true);
3318        assert!(paper.rest_base_url().contains("paper-api"));
3319        assert!(paper.ws_url().contains("paper-api"));
3320
3321        let live = AlpacaConfig::new("k".into(), "s".into(), false);
3322        assert!(!live.rest_base_url().contains("paper-api"));
3323        assert!(!live.ws_url().contains("paper-api"));
3324    }
3325
3326    #[test]
3327    fn test_parse_side() {
3328        assert_eq!(parse_side("buy"), Some(Side::Buy));
3329        assert_eq!(parse_side("sell"), Some(Side::Sell));
3330        assert_eq!(parse_side("Buy"), Some(Side::Buy));
3331        assert_eq!(parse_side("BUY"), Some(Side::Buy));
3332        assert_eq!(parse_side("unknown"), None);
3333    }
3334
3335    #[test]
3336    fn test_parse_order_kind() {
3337        // Market and Limit don't need extra params
3338        assert_eq!(
3339            parse_order_kind("market", None, None, None),
3340            Some(OrderKind::Market)
3341        );
3342        assert_eq!(
3343            parse_order_kind("limit", None, None, None),
3344            Some(OrderKind::Limit)
3345        );
3346
3347        // Stop requires stop_price
3348        assert_eq!(
3349            parse_order_kind("stop", Some("150.00"), None, None),
3350            Some(OrderKind::Stop {
3351                trigger_price: Decimal::from_str("150.00").unwrap()
3352            })
3353        );
3354        assert_eq!(parse_order_kind("stop", None, None, None), None);
3355
3356        // StopLimit requires stop_price
3357        assert_eq!(
3358            parse_order_kind("stop_limit", Some("145.00"), None, None),
3359            Some(OrderKind::StopLimit {
3360                trigger_price: Decimal::from_str("145.00").unwrap()
3361            })
3362        );
3363
3364        // TrailingStop with percentage
3365        assert_eq!(
3366            parse_order_kind("trailing_stop", None, Some("5.0"), None),
3367            Some(OrderKind::TrailingStop {
3368                offset: Decimal::from_str("5.0").unwrap(),
3369                offset_type: TrailingOffsetType::Percentage,
3370            })
3371        );
3372
3373        // TrailingStop with absolute price
3374        assert_eq!(
3375            parse_order_kind("trailing_stop", None, None, Some("2.50")),
3376            Some(OrderKind::TrailingStop {
3377                offset: Decimal::from_str("2.50").unwrap(),
3378                offset_type: TrailingOffsetType::Absolute,
3379            })
3380        );
3381
3382        // TrailingStop without either offset returns None
3383        assert_eq!(parse_order_kind("trailing_stop", None, None, None), None);
3384
3385        // Unknown type returns None
3386        assert_eq!(parse_order_kind("unknown", None, None, None), None);
3387    }
3388
3389    #[test]
3390    fn test_map_order_kind() {
3391        assert_eq!(map_order_kind(OrderKind::Market), Some("market"));
3392        assert_eq!(map_order_kind(OrderKind::Limit), Some("limit"));
3393        assert_eq!(
3394            map_order_kind(OrderKind::Stop {
3395                trigger_price: Decimal::from_str("150.00").unwrap()
3396            }),
3397            Some("stop")
3398        );
3399        assert_eq!(
3400            map_order_kind(OrderKind::StopLimit {
3401                trigger_price: Decimal::from_str("145.00").unwrap()
3402            }),
3403            Some("stop_limit")
3404        );
3405        assert_eq!(
3406            map_order_kind(OrderKind::TrailingStop {
3407                offset: Decimal::from_str("5.0").unwrap(),
3408                offset_type: TrailingOffsetType::Percentage,
3409            }),
3410            Some("trailing_stop")
3411        );
3412        assert_eq!(
3413            map_order_kind(OrderKind::TrailingStop {
3414                offset: Decimal::from_str("2.50").unwrap(),
3415                offset_type: TrailingOffsetType::Absolute,
3416            }),
3417            Some("trailing_stop")
3418        );
3419        // TrailingStopLimit is not supported by Alpaca
3420        assert_eq!(
3421            map_order_kind(OrderKind::TrailingStopLimit {
3422                offset: Decimal::from_str("5.0").unwrap(),
3423                offset_type: TrailingOffsetType::Percentage,
3424                limit_offset: Decimal::from_str("1.0").unwrap(),
3425            }),
3426            None
3427        );
3428        // TakeProfit/TakeProfitLimit are not supported by Alpaca
3429        assert_eq!(
3430            map_order_kind(OrderKind::TakeProfit {
3431                trigger_price: Decimal::from_str("160.00").unwrap()
3432            }),
3433            None
3434        );
3435        assert_eq!(
3436            map_order_kind(OrderKind::TakeProfitLimit {
3437                trigger_price: Decimal::from_str("160.00").unwrap()
3438            }),
3439            None
3440        );
3441    }
3442
3443    #[test]
3444    fn test_map_time_in_force_roundtrip() {
3445        assert_eq!(
3446            map_time_in_force(TimeInForce::GoodUntilCancelled { post_only: false }),
3447            Ok("gtc")
3448        );
3449        assert_eq!(map_time_in_force(TimeInForce::GoodUntilEndOfDay), Ok("day"));
3450        assert_eq!(map_time_in_force(TimeInForce::FillOrKill), Ok("fok"));
3451        assert_eq!(map_time_in_force(TimeInForce::ImmediateOrCancel), Ok("ioc"));
3452    }
3453
3454    #[test]
3455    fn test_map_time_in_force_rejects_post_only() {
3456        let result = map_time_in_force(TimeInForce::GoodUntilCancelled { post_only: true });
3457        assert!(result.is_err(), "post_only must be rejected");
3458        assert!(result.unwrap_err().contains("post_only"));
3459    }
3460
3461    // =========================================================================
3462    // Bracket Order Serialization Tests
3463    // =========================================================================
3464
3465    #[test]
3466    fn test_bracket_order_serializes_with_stop_loss_stop_order() {
3467        // Bracket order with stop-loss as a simple stop order (no limit_price)
3468        let body = AlpacaOrderRequest {
3469            symbol: "AAPL",
3470            qty: "10".to_string(),
3471            side: "buy",
3472            order_type: "limit",
3473            time_in_force: "gtc",
3474            limit_price: Some("150.00".to_string()),
3475            stop_price: None,
3476            trail_percent: None,
3477            trail_price: None,
3478            client_order_id: Some("bracket-001"),
3479            position_intent: Some(AlpacaPositionIntent::BuyToOpen),
3480            order_class: Some("bracket"),
3481            take_profit: Some(TakeProfitParams {
3482                limit_price: "160.00".to_string(),
3483            }),
3484            stop_loss: Some(StopLossParams {
3485                stop_price: "145.00".to_string(),
3486                limit_price: None,
3487            }),
3488        };
3489
3490        let json = serde_json::to_value(&body).unwrap();
3491
3492        assert_eq!(json["symbol"], "AAPL");
3493        assert_eq!(json["qty"], "10");
3494        assert_eq!(json["side"], "buy");
3495        assert_eq!(json["type"], "limit");
3496        assert_eq!(json["time_in_force"], "gtc");
3497        assert_eq!(json["limit_price"], "150.00");
3498        assert_eq!(json["order_class"], "bracket");
3499
3500        // Take profit should have limit_price
3501        assert_eq!(json["take_profit"]["limit_price"], "160.00");
3502
3503        // Stop loss should have stop_price but NO limit_price
3504        assert_eq!(json["stop_loss"]["stop_price"], "145.00");
3505        assert!(
3506            json["stop_loss"].get("limit_price").is_none(),
3507            "stop_loss.limit_price should be omitted when None"
3508        );
3509    }
3510
3511    #[test]
3512    fn test_bracket_order_serializes_with_stop_loss_stop_limit_order() {
3513        // Bracket order with stop-loss as a stop-limit order (has limit_price)
3514        let body = AlpacaOrderRequest {
3515            symbol: "SPY",
3516            qty: "5".to_string(),
3517            side: "sell",
3518            order_type: "limit",
3519            time_in_force: "day",
3520            limit_price: Some("450.00".to_string()),
3521            stop_price: None,
3522            trail_percent: None,
3523            trail_price: None,
3524            client_order_id: Some("bracket-002"),
3525            position_intent: Some(AlpacaPositionIntent::SellToClose),
3526            order_class: Some("bracket"),
3527            take_profit: Some(TakeProfitParams {
3528                limit_price: "440.00".to_string(),
3529            }),
3530            stop_loss: Some(StopLossParams {
3531                stop_price: "455.00".to_string(),
3532                limit_price: Some("456.00".to_string()),
3533            }),
3534        };
3535
3536        let json = serde_json::to_value(&body).unwrap();
3537
3538        assert_eq!(json["symbol"], "SPY");
3539        assert_eq!(json["side"], "sell");
3540        assert_eq!(json["time_in_force"], "day");
3541        assert_eq!(json["order_class"], "bracket");
3542
3543        // Take profit
3544        assert_eq!(json["take_profit"]["limit_price"], "440.00");
3545
3546        // Stop loss with limit_price (stop-limit order)
3547        assert_eq!(json["stop_loss"]["stop_price"], "455.00");
3548        assert_eq!(
3549            json["stop_loss"]["limit_price"], "456.00",
3550            "stop_loss.limit_price should be present for stop-limit orders"
3551        );
3552    }
3553
3554    // =========================================================================
3555    // Bracket Order Validation Tests
3556    // =========================================================================
3557
3558    #[tokio::test]
3559    async fn test_open_bracket_order_rejects_invalid_tif() {
3560        use rust_decimal_macros::dec;
3561        use rustrade_instrument::instrument::name::InstrumentNameExchange;
3562
3563        // Create a minimal client with dummy credentials (no network call will be made)
3564        let config = AlpacaConfig::new("dummy_key".into(), "dummy_secret".into(), true);
3565        let client = AlpacaClient::new(config);
3566
3567        let request = AlpacaBracketOrderRequest::new(
3568            InstrumentNameExchange::new("SPY"),
3569            crate::order::id::StrategyId::new("test"),
3570            crate::order::id::ClientOrderId::new("test-tif"),
3571            Side::Buy,
3572            dec!(1),
3573            dec!(100.00),
3574            dec!(120.00),
3575            dec!(90.00),
3576            TimeInForce::ImmediateOrCancel, // Invalid for brackets
3577        );
3578
3579        let result = client.open_bracket_order(request).await;
3580
3581        assert!(
3582            result.parent.state.is_failed(),
3583            "Bracket order with IOC TIF should be rejected locally"
3584        );
3585    }
3586
3587    #[tokio::test]
3588    async fn test_open_bracket_order_rejects_invalid_price_ordering() {
3589        use rust_decimal_macros::dec;
3590        use rustrade_instrument::instrument::name::InstrumentNameExchange;
3591
3592        let config = AlpacaConfig::new("dummy_key".into(), "dummy_secret".into(), true);
3593        let client = AlpacaClient::new(config);
3594
3595        // Buy bracket with SL > entry (invalid)
3596        let request = AlpacaBracketOrderRequest::new(
3597            InstrumentNameExchange::new("SPY"),
3598            crate::order::id::StrategyId::new("test"),
3599            crate::order::id::ClientOrderId::new("test-price"),
3600            Side::Buy,
3601            dec!(1),
3602            dec!(100.00),
3603            dec!(120.00),
3604            dec!(105.00), // Invalid: SL > entry for buy
3605            TimeInForce::GoodUntilCancelled { post_only: false },
3606        );
3607
3608        let result = client.open_bracket_order(request).await;
3609
3610        assert!(
3611            result.parent.state.is_failed(),
3612            "Bracket order with invalid price ordering should be rejected locally"
3613        );
3614    }
3615
3616    #[tokio::test]
3617    async fn test_open_bracket_order_rejects_invalid_sl_limit_price() {
3618        use rust_decimal_macros::dec;
3619        use rustrade_instrument::instrument::name::InstrumentNameExchange;
3620
3621        let config = AlpacaConfig::new("dummy_key".into(), "dummy_secret".into(), true);
3622        let client = AlpacaClient::new(config);
3623
3624        // Buy bracket with SL limit > SL trigger (invalid for sell stop-limit)
3625        let request = AlpacaBracketOrderRequest::new(
3626            InstrumentNameExchange::new("SPY"),
3627            crate::order::id::StrategyId::new("test"),
3628            crate::order::id::ClientOrderId::new("test-sl-limit"),
3629            Side::Buy,
3630            dec!(1),
3631            dec!(100.00),
3632            dec!(120.00),
3633            dec!(90.00),
3634            TimeInForce::GoodUntilCancelled { post_only: false },
3635        )
3636        .with_stop_loss_limit_price(dec!(95.00)); // Invalid: limit > trigger for sell SL
3637
3638        let result = client.open_bracket_order(request).await;
3639
3640        assert!(
3641            result.parent.state.is_failed(),
3642            "Bracket order with invalid SL limit price should be rejected locally"
3643        );
3644    }
3645
3646    #[test]
3647    fn test_non_bracket_order_omits_bracket_fields() {
3648        // Regular limit order should not have bracket fields
3649        let body = AlpacaOrderRequest {
3650            symbol: "AAPL",
3651            qty: "1".to_string(),
3652            side: "buy",
3653            order_type: "limit",
3654            time_in_force: "gtc",
3655            limit_price: Some("150.00".to_string()),
3656            stop_price: None,
3657            trail_percent: None,
3658            trail_price: None,
3659            client_order_id: Some("regular-001"),
3660            position_intent: Some(AlpacaPositionIntent::BuyToOpen),
3661            order_class: None,
3662            take_profit: None,
3663            stop_loss: None,
3664        };
3665
3666        let json = serde_json::to_value(&body).unwrap();
3667
3668        assert_eq!(json["symbol"], "AAPL");
3669        assert!(
3670            json.get("order_class").is_none(),
3671            "order_class should be omitted for non-bracket orders"
3672        );
3673        assert!(
3674            json.get("take_profit").is_none(),
3675            "take_profit should be omitted for non-bracket orders"
3676        );
3677        assert!(
3678            json.get("stop_loss").is_none(),
3679            "stop_loss should be omitted for non-bracket orders"
3680        );
3681    }
3682
3683    #[test]
3684    fn test_parse_timestamp_valid() {
3685        let ts = parse_timestamp("2025-04-18T14:30:00Z");
3686        assert!(ts.is_some());
3687        let ts2 = parse_timestamp("2025-04-18T14:30:00.123456Z");
3688        assert!(ts2.is_some());
3689        assert_eq!(parse_timestamp("not-a-timestamp"), None);
3690    }
3691
3692    #[test]
3693    fn test_check_auth_response_authorized() {
3694        let msg =
3695            r#"{"stream":"authorization","data":{"status":"authorized","action":"authenticate"}}"#;
3696        assert!(matches!(check_auth_response(msg), Some(Ok(()))));
3697    }
3698
3699    #[test]
3700    fn test_check_auth_response_unauthorized() {
3701        let msg = r#"{"stream":"authorization","data":{"status":"unauthorized"}}"#;
3702        assert!(matches!(
3703            check_auth_response(msg),
3704            Some(Err(HandshakeError::Auth(_)))
3705        ));
3706    }
3707
3708    #[test]
3709    fn test_check_auth_response_non_auth_message() {
3710        let msg = r#"{"stream":"trade_updates","data":{}}"#;
3711        assert!(check_auth_response(msg).is_none());
3712    }
3713
3714    #[test]
3715    fn test_check_listen_ack() {
3716        let ack = r#"{"stream":"listening","data":{"streams":["trade_updates"]}}"#;
3717        assert!(check_listen_ack(ack));
3718
3719        let other = r#"{"stream":"authorization","data":{}}"#;
3720        assert!(!check_listen_ack(other));
3721    }
3722
3723    #[test]
3724    fn test_dedup_cache() {
3725        let cache = new_dedup_cache();
3726        let key = SmolStr::new("order-1:1");
3727        assert!(
3728            !is_duplicate(&cache, &key),
3729            "first time should not be duplicate"
3730        );
3731        assert!(
3732            is_duplicate(&cache, &key),
3733            "second time should be duplicate"
3734        );
3735    }
3736
3737    #[tokio::test]
3738    async fn test_exponential_backoff_progression_and_exhaustion() {
3739        tokio::time::pause();
3740
3741        let mut b = ExponentialBackoff::new();
3742
3743        // First wait should succeed and increment attempt.
3744        assert!(b.wait().await, "first wait should return true");
3745        assert_eq!(b.attempt, 1);
3746
3747        // Drain remaining attempts.
3748        while b.wait().await {}
3749
3750        // Attempt counter saturates at max_attempts.
3751        assert_eq!(b.attempt, MAX_RECONNECT_ATTEMPTS);
3752
3753        // Once exhausted, wait returns false immediately without sleeping.
3754        assert!(!b.wait().await, "exhausted backoff should return false");
3755
3756        // reset() restores attempt to 0.
3757        b.reset();
3758        assert_eq!(b.attempt, 0);
3759
3760        // After reset, wait works again.
3761        assert!(b.wait().await, "wait should succeed after reset");
3762        assert_eq!(b.attempt, 1);
3763    }
3764
3765    #[test]
3766    fn test_convert_account_to_balances_empty_assets() {
3767        let account = AlpacaAccount {
3768            equity: "12000.00".into(),
3769            buying_power: "10000.00".into(),
3770            options_buying_power: Some("8000.00".into()),
3771            crypto_buying_power: None,
3772        };
3773        let balances = convert_account_to_balances(&account, &[]);
3774        assert_eq!(balances.len(), 1);
3775        assert_eq!(
3776            balances[0].balance.total,
3777            Decimal::from_str("12000.00").unwrap()
3778        );
3779        // options_buying_power is preferred for free
3780        assert_eq!(
3781            balances[0].balance.free,
3782            Decimal::from_str("8000.00").unwrap()
3783        );
3784    }
3785
3786    #[test]
3787    fn test_convert_account_to_balances_usd_filter() {
3788        let account = AlpacaAccount {
3789            equity: "12000.00".into(),
3790            buying_power: "10000.00".into(),
3791            options_buying_power: None,
3792            crypto_buying_power: None,
3793        };
3794        let usd = vec![AssetNameExchange::new("USD")];
3795        let balances = convert_account_to_balances(&account, &usd);
3796        assert_eq!(balances.len(), 1);
3797
3798        let non_usd = vec![AssetNameExchange::new("BTC")];
3799        let balances = convert_account_to_balances(&account, &non_usd);
3800        assert!(balances.is_empty());
3801    }
3802
3803    #[test]
3804    fn test_is_options_or_equity_symbol() {
3805        // Crypto symbols contain '/'
3806        assert!(!is_options_or_equity_symbol("BTC/USD"));
3807        assert!(!is_options_or_equity_symbol("ETH/USD"));
3808        assert!(!is_options_or_equity_symbol("SOL/USD"));
3809
3810        // Equity symbols — no slash
3811        assert!(is_options_or_equity_symbol("AAPL"));
3812        assert!(is_options_or_equity_symbol("SPY"));
3813        assert!(is_options_or_equity_symbol("MSFT"));
3814
3815        // OCC option symbols — no slash
3816        assert!(is_options_or_equity_symbol("SPY250418C00450000"));
3817        assert!(is_options_or_equity_symbol("AAPL250418P00145000"));
3818    }
3819
3820    #[test]
3821    fn test_parse_order_error_already_cancelled() {
3822        // Locks in match arm ordering: a 422 with "already" but NOT "insufficient"
3823        // must map to OrderAlreadyCancelled, not BalanceInsufficient.
3824        assert!(matches!(
3825            parse_order_error(
3826                reqwest::StatusCode::UNPROCESSABLE_ENTITY,
3827                "order is already cancelled"
3828            ),
3829            UnindexedOrderError::Rejected(ApiError::OrderAlreadyCancelled)
3830        ));
3831    }
3832
3833    #[test]
3834    fn test_parse_order_error_already_wins_over_insufficient_on_422() {
3835        // If Alpaca sends a body containing both "already" and "insufficient",
3836        // the "already" arm must win (it appears first in the match).
3837        assert!(matches!(
3838            parse_order_error(
3839                reqwest::StatusCode::UNPROCESSABLE_ENTITY,
3840                "order already cancelled due to insufficient margin"
3841            ),
3842            UnindexedOrderError::Rejected(ApiError::OrderAlreadyCancelled)
3843        ));
3844    }
3845
3846    fn make_order_ws<'a>(
3847        id: &str,
3848        symbol: &str,
3849        side: &str,
3850        filled_qty: &'a str,
3851    ) -> AlpacaOrderWs<'a> {
3852        AlpacaOrderWs {
3853            id: SmolStr::new(id),
3854            client_order_id: None,
3855            symbol: SmolStr::new(symbol),
3856            qty: Some("2"),
3857            filled_qty: Some(filled_qty),
3858            side: SmolStr::new(side),
3859            order_type: SmolStr::new("limit"),
3860            time_in_force: SmolStr::new("day"),
3861            limit_price: Some("100.00"),
3862            stop_price: None,
3863            trail_percent: None,
3864            trail_price: None,
3865            status: SmolStr::new("partially_filled"),
3866        }
3867    }
3868
3869    #[test]
3870    fn test_convert_trade_update_fill_produces_trade_with_dedup_key() {
3871        let update = AlpacaTradeUpdate {
3872            event: SmolStr::new("fill"),
3873            order: make_order_ws("ord-1", "SPY", "buy", "1"),
3874            price: Some("150.00"),
3875            qty: Some("1"),
3876            timestamp: Some("2025-04-18T14:30:00Z"),
3877        };
3878        let event = convert_trade_update(update).expect("fill should produce an event");
3879        let AccountEventKind::Trade(trade) = event.kind else {
3880            panic!("expected Trade, got {:?}", event.kind);
3881        };
3882        // Trade ID must be "{order_id}:{cumulative_filled_qty}" for dedup to match REST path.
3883        assert_eq!(trade.id.0.as_str(), "ord-1:1");
3884        assert_eq!(trade.price, Decimal::from_str("150.00").unwrap());
3885        assert_eq!(trade.quantity, Decimal::from_str("1").unwrap());
3886    }
3887
3888    #[test]
3889    fn test_convert_trade_update_partial_fill() {
3890        let update = AlpacaTradeUpdate {
3891            event: SmolStr::new("partial_fill"),
3892            order: make_order_ws("ord-2", "AAPL", "sell", "0.5"),
3893            price: Some("200.00"),
3894            qty: Some("0.5"),
3895            timestamp: None,
3896        };
3897        let event = convert_trade_update(update).expect("partial_fill should produce an event");
3898        assert!(matches!(event.kind, AccountEventKind::Trade(_)));
3899    }
3900
3901    #[test]
3902    fn test_convert_trade_update_new_order_produces_snapshot() {
3903        let update = AlpacaTradeUpdate {
3904            event: SmolStr::new("new"),
3905            order: AlpacaOrderWs {
3906                id: SmolStr::new("ord-new"),
3907                client_order_id: Some(SmolStr::new("cid-1")),
3908                symbol: SmolStr::new("AAPL"),
3909                qty: Some("10"),
3910                filled_qty: Some("0"),
3911                side: SmolStr::new("buy"),
3912                order_type: SmolStr::new("limit"),
3913                time_in_force: SmolStr::new("day"),
3914                limit_price: Some("150.00"),
3915                stop_price: None,
3916                trail_percent: None,
3917                trail_price: None,
3918                status: SmolStr::new("new"),
3919            },
3920            price: None,
3921            qty: None,
3922            timestamp: Some("2025-04-18T14:30:00Z"),
3923        };
3924        let event = convert_trade_update(update).expect("new event should produce an event");
3925        assert!(matches!(event.kind, AccountEventKind::OrderSnapshot(_)));
3926    }
3927
3928    #[test]
3929    fn test_convert_trade_update_canceled_produces_cancel() {
3930        let update = AlpacaTradeUpdate {
3931            event: SmolStr::new("canceled"),
3932            order: make_order_ws("ord-3", "AAPL", "sell", "0"),
3933            price: None,
3934            qty: None,
3935            timestamp: Some("2025-04-18T14:30:00Z"),
3936        };
3937        let event = convert_trade_update(update).expect("canceled should produce an event");
3938        let AccountEventKind::OrderCancelled(response) = event.kind else {
3939            panic!("expected OrderCancelled, got {:?}", event.kind);
3940        };
3941        assert!(response.state.is_ok());
3942    }
3943
3944    #[test]
3945    fn test_convert_trade_update_rejected_produces_error() {
3946        let update = AlpacaTradeUpdate {
3947            event: SmolStr::new("rejected"),
3948            order: make_order_ws("ord-4", "SPY", "buy", "0"),
3949            price: None,
3950            qty: None,
3951            timestamp: None,
3952        };
3953        let event = convert_trade_update(update).expect("rejected should produce an event");
3954        let AccountEventKind::OrderCancelled(response) = event.kind else {
3955            panic!("expected OrderCancelled, got {:?}", event.kind);
3956        };
3957        assert!(response.state.is_err());
3958    }
3959
3960    #[test]
3961    fn test_convert_open_order_notional_qty_none_is_skipped() {
3962        // qty=None means this is a notional order (placed by dollar value).
3963        // Recording it with quantity=0 would corrupt reconciliation, so it must be skipped.
3964        let order = AlpacaOrderResponse {
3965            id: "ord-notional".to_string(),
3966            client_order_id: None,
3967            symbol: "SPY".to_string(),
3968            qty: None,
3969            filled_qty: "0".to_string(),
3970            side: "buy".to_string(),
3971            order_type: "market".to_string(),
3972            time_in_force: "day".to_string(),
3973            limit_price: None,
3974            stop_price: None,
3975            trail_percent: None,
3976            trail_price: None,
3977            created_at: "2025-04-18T14:30:00Z".to_string(),
3978        };
3979        assert!(convert_open_order(&order).is_none());
3980    }
3981
3982    #[test]
3983    fn test_convert_activity_to_trade_bad_price_returns_none() {
3984        // When price is unparseable, convert_activity_to_trade must return None.
3985        // recover_fills advances cumulative_qty BEFORE this check so that the dedup
3986        // key sequence stays aligned with the WS path even when a fill is skipped.
3987        let activity = AlpacaActivity {
3988            id: "act-1".to_string(),
3989            order_id: "ord-1".to_string(),
3990            symbol: "SPY250418C00450000".to_string(),
3991            side: "buy".to_string(),
3992            price: "not-a-number".to_string(),
3993            qty: "1".to_string(),
3994            transaction_time: "2025-04-18T14:30:00Z".to_string(),
3995        };
3996        assert!(convert_activity_to_trade(&activity).is_none());
3997    }
3998
3999    #[test]
4000    fn test_convert_positions_to_balances_crypto() {
4001        let positions = vec![
4002            AlpacaPosition {
4003                symbol: "BTC/USD".into(),
4004                asset_class: "crypto".into(),
4005                qty: "0.5".into(),
4006                qty_available: "0.4".into(),
4007            },
4008            AlpacaPosition {
4009                symbol: "ETH/USD".into(),
4010                asset_class: "crypto".into(),
4011                qty: "2.0".into(),
4012                qty_available: "2.0".into(),
4013            },
4014            // Equity positions should be filtered out
4015            AlpacaPosition {
4016                symbol: "AAPL".into(),
4017                asset_class: "us_equity".into(),
4018                qty: "10".into(),
4019                qty_available: "10".into(),
4020            },
4021        ];
4022
4023        // All crypto assets
4024        let balances = convert_positions_to_balances(&positions, &[]);
4025        assert_eq!(balances.len(), 2, "only crypto positions returned");
4026        assert_eq!(balances[0].asset.name().as_str(), "btc");
4027        // total = qty (0.5 BTC), free = qty_available (0.4 BTC)
4028        assert_eq!(balances[0].balance.total, Decimal::from_str("0.5").unwrap());
4029        assert_eq!(balances[0].balance.free, Decimal::from_str("0.4").unwrap());
4030
4031        // Filter to BTC only
4032        let btc_only = vec![AssetNameExchange::new("BTC")];
4033        let balances = convert_positions_to_balances(&positions, &btc_only);
4034        assert_eq!(balances.len(), 1);
4035        assert_eq!(balances[0].asset.name().as_str(), "btc");
4036    }
4037
4038    fn make_order_response(id: &str, symbol: &str) -> AlpacaOrderResponse {
4039        AlpacaOrderResponse {
4040            id: id.to_string(),
4041            client_order_id: None,
4042            symbol: symbol.to_string(),
4043            qty: Some("1".to_string()),
4044            filled_qty: "0".to_string(),
4045            side: "buy".to_string(),
4046            order_type: "limit".to_string(),
4047            time_in_force: "day".to_string(),
4048            limit_price: Some("100.00".to_string()),
4049            stop_price: None,
4050            trail_percent: None,
4051            trail_price: None,
4052            created_at: "2025-04-18T14:30:00Z".to_string(),
4053        }
4054    }
4055
4056    #[test]
4057    fn test_build_instrument_snapshots_empty_instruments_returns_only_with_orders() {
4058        let orders = vec![
4059            make_order_response("o1", "AAPL"),
4060            make_order_response("o2", "SPY"),
4061        ];
4062        let snapshots = build_instrument_snapshots(orders, &[]);
4063        assert_eq!(snapshots.len(), 2);
4064        let symbols: Vec<&str> = snapshots
4065            .iter()
4066            .map(|s| s.instrument.name().as_str())
4067            .collect();
4068        assert!(symbols.contains(&"AAPL"));
4069        assert!(symbols.contains(&"SPY"));
4070    }
4071
4072    #[test]
4073    fn test_build_instrument_snapshots_requested_instrument_no_orders_gets_empty_snapshot() {
4074        // When instruments list is provided, every requested instrument must appear
4075        // even if it has no open orders — callers depend on this for reconciliation.
4076        let orders = vec![make_order_response("o1", "AAPL")];
4077        let instruments = vec![
4078            InstrumentNameExchange::new("AAPL"),
4079            InstrumentNameExchange::new("SPY"),
4080        ];
4081        let snapshots = build_instrument_snapshots(orders, &instruments);
4082        assert_eq!(snapshots.len(), 2);
4083        let spy = snapshots
4084            .iter()
4085            .find(|s| s.instrument.name().as_str() == "SPY")
4086            .expect("SPY snapshot must be present even with no orders");
4087        assert!(spy.orders.is_empty());
4088    }
4089
4090    #[test]
4091    fn test_build_instrument_snapshots_non_requested_instrument_excluded() {
4092        // An instrument with open orders that is NOT in the requested list must
4093        // not appear in the output when the instruments list is non-empty.
4094        let orders = vec![
4095            make_order_response("o1", "AAPL"),
4096            make_order_response("o2", "MSFT"), // not requested
4097        ];
4098        let instruments = vec![InstrumentNameExchange::new("AAPL")];
4099        let snapshots = build_instrument_snapshots(orders, &instruments);
4100        assert_eq!(snapshots.len(), 1);
4101        assert_eq!(snapshots[0].instrument.name().as_str(), "AAPL");
4102    }
4103
4104    /// Verifies that the dedup key synthesised by `recover_fills` (REST path) matches
4105    /// the key produced by `convert_trade_update` (WS path) for the same partial fills.
4106    ///
4107    /// This is the critical invariant for cross-source dedup after a reconnect:
4108    /// both paths must produce `"{order_id}:{cumulative_filled_qty}"` for the same fill.
4109    #[test]
4110    fn test_recover_fills_dedup_key_matches_ws_path() {
4111        let order_id = "ord-1";
4112
4113        // WS path: Alpaca sends cumulative filled_qty with each event.
4114        // Two partial fills of 1 lot each → filled_qty "1" then "2".
4115        let ws_keys: Vec<SmolStr> = ["1", "2"]
4116            .iter()
4117            .filter_map(|filled_qty| {
4118                let update = AlpacaTradeUpdate {
4119                    event: SmolStr::new("partial_fill"),
4120                    order: make_order_ws(order_id, "SPY", "buy", filled_qty),
4121                    price: Some("150.00"),
4122                    qty: Some("1"),
4123                    timestamp: None,
4124                };
4125                let event = convert_trade_update(update)?;
4126                fill_dedup_key_from_event(&event).cloned()
4127            })
4128            .collect();
4129
4130        // REST path: recover_fills accumulates cumulative qty from per-execution activities.
4131        // Two activities with exec qty "1" each → cumulative 1 then 2.
4132        let mut cumulative = Decimal::ZERO;
4133        let rest_keys: Vec<SmolStr> = ["1", "1"]
4134            .iter()
4135            .map(|exec_qty| {
4136                cumulative += Decimal::from_str(exec_qty).unwrap();
4137                format_smolstr!("{}:{}", order_id, cumulative.normalize())
4138            })
4139            .collect();
4140
4141        assert_eq!(
4142            ws_keys, rest_keys,
4143            "REST recovery dedup keys must match WS path keys for cross-source dedup to work"
4144        );
4145        assert_eq!(ws_keys[0].as_str(), "ord-1:1");
4146        assert_eq!(ws_keys[1].as_str(), "ord-1:2");
4147    }
4148
4149    /// Verifies that `early_dedup_key` produces the same key as the full event path.
4150    ///
4151    /// The early dedup check (M-1 optimization) extracts the key from raw WS fields
4152    /// before constructing the full event. This test ensures both paths produce
4153    /// identical keys, otherwise duplicate detection would fail.
4154    #[test]
4155    fn early_dedup_key_matches_full_event_path() {
4156        let update = AlpacaTradeUpdate {
4157            event: SmolStr::new("fill"),
4158            order: make_order_ws("ord-abc", "SPY", "buy", "5"),
4159            price: Some("150.00"),
4160            qty: Some("5"),
4161            timestamp: None,
4162        };
4163
4164        // Early path: extract key before full event construction
4165        let early_key = early_dedup_key(&update);
4166
4167        // Full path: construct event then extract key
4168        let event = convert_trade_update(update).expect("fill should produce an event");
4169        let full_key =
4170            fill_dedup_key_from_event(&event).expect("fill event should have a dedup key");
4171
4172        assert_eq!(
4173            early_key.as_str(),
4174            full_key.as_str(),
4175            "early_dedup_key must produce the same key as the full event path"
4176        );
4177        assert_eq!(early_key.as_str(), "ord-abc:5");
4178    }
4179
4180    /// Verifies that `early_dedup_key` correctly normalizes decimal strings.
4181    ///
4182    /// Alpaca may send "1.00" or "1" for the same fill. Both must produce the same
4183    /// dedup key to avoid false negatives in duplicate detection.
4184    #[test]
4185    fn early_dedup_key_normalizes_decimal_strings() {
4186        // Test with trailing zeros: "1.00" should normalize to "1"
4187        let update1 = AlpacaTradeUpdate {
4188            event: SmolStr::new("fill"),
4189            order: AlpacaOrderWs {
4190                id: SmolStr::new("ord-x"),
4191                client_order_id: Some(SmolStr::new("cid")),
4192                symbol: SmolStr::new("AAPL"),
4193                qty: Some("10"),
4194                filled_qty: Some("1.00"),
4195                side: SmolStr::new("buy"),
4196                order_type: SmolStr::new("market"),
4197                time_in_force: SmolStr::new("day"),
4198                limit_price: None,
4199                stop_price: None,
4200                trail_percent: None,
4201                trail_price: None,
4202                status: SmolStr::new("filled"),
4203            },
4204            price: Some("100.00"),
4205            qty: Some("10"),
4206            timestamp: None,
4207        };
4208        assert_eq!(early_dedup_key(&update1).as_str(), "ord-x:1");
4209
4210        // Test already normalized: "1" stays "1"
4211        let update2 = AlpacaTradeUpdate {
4212            event: SmolStr::new("fill"),
4213            order: AlpacaOrderWs {
4214                id: SmolStr::new("ord-x"),
4215                client_order_id: Some(SmolStr::new("cid")),
4216                symbol: SmolStr::new("AAPL"),
4217                qty: Some("10"),
4218                filled_qty: Some("1"),
4219                side: SmolStr::new("buy"),
4220                order_type: SmolStr::new("market"),
4221                time_in_force: SmolStr::new("day"),
4222                limit_price: None,
4223                stop_price: None,
4224                trail_percent: None,
4225                trail_price: None,
4226                status: SmolStr::new("filled"),
4227            },
4228            price: Some("100.00"),
4229            qty: Some("10"),
4230            timestamp: None,
4231        };
4232        assert_eq!(early_dedup_key(&update2).as_str(), "ord-x:1");
4233
4234        // Test single trailing zero: "1.0" normalizes to "1"
4235        let update3 = AlpacaTradeUpdate {
4236            event: SmolStr::new("fill"),
4237            order: AlpacaOrderWs {
4238                id: SmolStr::new("ord-x"),
4239                client_order_id: Some(SmolStr::new("cid")),
4240                symbol: SmolStr::new("AAPL"),
4241                qty: Some("10"),
4242                filled_qty: Some("1.0"),
4243                side: SmolStr::new("buy"),
4244                order_type: SmolStr::new("market"),
4245                time_in_force: SmolStr::new("day"),
4246                limit_price: None,
4247                stop_price: None,
4248                trail_percent: None,
4249                trail_price: None,
4250                status: SmolStr::new("filled"),
4251            },
4252            price: Some("100.00"),
4253            qty: Some("10"),
4254            timestamp: None,
4255        };
4256        assert_eq!(early_dedup_key(&update3).as_str(), "ord-x:1");
4257    }
4258
4259    /// Regression guard for HIGH-2: a `rejected` event without `filled_qty` in the JSON
4260    /// previously caused the entire AlpacaTradeUpdate to fail deserialization, silently
4261    /// dropping the event. After the fix, `filled_qty` is Option and defaults to None
4262    /// (unwrapped to "0" at use-sites), so the event reaches the `rejected` branch.
4263    #[test]
4264    fn process_ws_text_rejected_event_without_filled_qty_is_not_dropped() {
4265        let (tx, mut rx) = mpsc::unbounded_channel();
4266        let dedup = new_dedup_cache();
4267        let mut backoff = ExponentialBackoff::new();
4268
4269        // Minimal rejected-event JSON — no `filled_qty` field in the order object.
4270        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"}}}"#;
4271
4272        process_ws_text(json, &tx, &dedup, &mut backoff);
4273
4274        // The event must NOT be silently dropped — an OrderCancelled must be emitted.
4275        let event = rx.try_recv()
4276            .expect("rejected event without filled_qty must produce an AccountEvent, not be silently dropped");
4277        assert!(
4278            matches!(event.kind, AccountEventKind::OrderCancelled(_)),
4279            "rejected event must map to OrderCancelled, got: {:?}",
4280            event.kind
4281        );
4282    }
4283
4284    /// Pins the string representation of `Decimal::ZERO.normalize()`, which is used
4285    /// as the dedup key fallback when `filled_qty` is unparseable: `"{order_id}:0"`.
4286    #[test]
4287    fn decimal_zero_normalize_is_zero_str() {
4288        assert_eq!(Decimal::ZERO.normalize().to_string(), "0");
4289    }
4290
4291    /// Verifies that trailing zeros are stripped by `normalize()`, ensuring dedup keys
4292    /// match regardless of whether Alpaca returns `"1.00"` or `"1"` for the same qty.
4293    /// This is the critical invariant for REST/WS dedup key equivalence.
4294    #[test]
4295    fn decimal_normalize_strips_trailing_zeros() {
4296        // Alpaca may return "1.00" in REST but WS cumulative may be "1" — must match.
4297        let from_rest = Decimal::from_str("1.00").unwrap().normalize();
4298        let from_ws = Decimal::from_str("1").unwrap().normalize();
4299        assert_eq!(from_rest.to_string(), from_ws.to_string());
4300        assert_eq!(from_rest.to_string(), "1");
4301
4302        // More edge cases: various trailing zero representations.
4303        assert_eq!(
4304            Decimal::from_str("100.000")
4305                .unwrap()
4306                .normalize()
4307                .to_string(),
4308            "100"
4309        );
4310        assert_eq!(
4311            Decimal::from_str("0.10").unwrap().normalize().to_string(),
4312            "0.1"
4313        );
4314        assert_eq!(
4315            Decimal::from_str("0.100").unwrap().normalize().to_string(),
4316            "0.1"
4317        );
4318    }
4319
4320    // H-2: zero options_buying_power falls back to buying_power (not free=0).
4321    // Alpaca returns options_buying_power="0.00" on equity-only accounts (options
4322    // not enabled) rather than omitting the field. Without the .filter(!is_zero())
4323    // guard the engine would see free=0 and block all orders.
4324    #[test]
4325    fn convert_account_to_balances_zero_options_buying_power_falls_back_to_buying_power() {
4326        let account = AlpacaAccount {
4327            equity: "12000.00".into(),
4328            buying_power: "10000.00".into(),
4329            options_buying_power: Some("0.00".into()), // equity-only: options not enabled
4330            crypto_buying_power: None,
4331        };
4332        let balances = convert_account_to_balances(&account, &[]);
4333        assert_eq!(balances.len(), 1);
4334        assert_eq!(
4335            balances[0].balance.free,
4336            Decimal::from_str("10000.00").unwrap(),
4337            "zero options_buying_power must fall back to buying_power, not report free=0"
4338        );
4339    }
4340
4341    // M-2: map_position_intent derives intent from (reduce_only, side).
4342    #[test]
4343    fn map_position_intent_open_buy_maps_to_buy_to_open() {
4344        assert_eq!(
4345            map_position_intent(Side::Buy, false),
4346            AlpacaPositionIntent::BuyToOpen
4347        );
4348    }
4349
4350    #[test]
4351    fn map_position_intent_open_sell_maps_to_sell_to_open() {
4352        assert_eq!(
4353            map_position_intent(Side::Sell, false),
4354            AlpacaPositionIntent::SellToOpen
4355        );
4356    }
4357
4358    #[test]
4359    fn map_position_intent_reduce_buy_maps_to_buy_to_close() {
4360        assert_eq!(
4361            map_position_intent(Side::Buy, true),
4362            AlpacaPositionIntent::BuyToClose
4363        );
4364    }
4365
4366    #[test]
4367    fn map_position_intent_reduce_sell_maps_to_sell_to_close() {
4368        assert_eq!(
4369            map_position_intent(Side::Sell, true),
4370            AlpacaPositionIntent::SellToClose
4371        );
4372    }
4373
4374    // M-1: parse_order_error — pin all status-code branches not covered by existing tests.
4375    #[test]
4376    fn parse_order_error_401_maps_to_unauthenticated() {
4377        // 401 Unauthorized: invalid/expired API credentials.
4378        assert!(matches!(
4379            parse_order_error(reqwest::StatusCode::UNAUTHORIZED, "bad credentials"),
4380            UnindexedOrderError::Rejected(ApiError::Unauthenticated(_))
4381        ));
4382    }
4383
4384    #[test]
4385    fn parse_order_error_403_maps_to_unauthenticated() {
4386        // 403 Forbidden indicates auth/permission failure — use Unauthenticated, not
4387        // OrderRejected or BalanceInsufficient (which could trigger incorrect retry logic).
4388        assert!(matches!(
4389            parse_order_error(reqwest::StatusCode::FORBIDDEN, "account suspended"),
4390            UnindexedOrderError::Rejected(ApiError::Unauthenticated(_))
4391        ));
4392    }
4393
4394    #[test]
4395    fn parse_order_error_404_maps_to_order_rejected_with_not_found_prefix() {
4396        let err = parse_order_error(reqwest::StatusCode::NOT_FOUND, "order not found");
4397        let UnindexedOrderError::Rejected(ApiError::OrderRejected(msg)) = err else {
4398            panic!("expected OrderRejected, got {err:?}");
4399        };
4400        assert!(
4401            msg.contains("order not found"),
4402            "message should contain 'order not found': {msg}"
4403        );
4404    }
4405
4406    #[test]
4407    fn parse_order_error_422_insufficient_only_maps_to_balance_insufficient() {
4408        // No "already" in body → must not match OrderAlreadyCancelled; must be BalanceInsufficient.
4409        assert!(matches!(
4410            parse_order_error(
4411                reqwest::StatusCode::UNPROCESSABLE_ENTITY,
4412                "insufficient funds for this order"
4413            ),
4414            UnindexedOrderError::Rejected(ApiError::BalanceInsufficient(_, _))
4415        ));
4416    }
4417
4418    #[test]
4419    fn parse_order_error_429_maps_to_rate_limit() {
4420        assert!(matches!(
4421            parse_order_error(reqwest::StatusCode::TOO_MANY_REQUESTS, "rate limited"),
4422            UnindexedOrderError::Rejected(ApiError::RateLimit)
4423        ));
4424    }
4425
4426    // M-4: parse_time_in_force — pin the unknown-value fallback to GoodUntilEndOfDay.
4427    // If Alpaca adds a new TIF (e.g. "opg" for at-the-open), orders continue to be
4428    // tracked with EOD expiry until this function is updated; the warn! makes it visible.
4429    #[test]
4430    fn parse_time_in_force_unknown_value_falls_back_to_good_until_end_of_day() {
4431        assert_eq!(
4432            parse_time_in_force("opg"),
4433            TimeInForce::GoodUntilEndOfDay,
4434            "unknown TIF must fall back to GoodUntilEndOfDay (with a warn! in production)"
4435        );
4436    }
4437
4438    // L-4: build_instrument_snapshots — output order must match the instruments slice,
4439    // not the internal IndexMap insertion order of the orders vec.
4440    #[test]
4441    fn build_instrument_snapshots_output_order_matches_instruments_slice() {
4442        // Orders arrive in symbol order: SPY, AAPL, MSFT.
4443        let orders = vec![
4444            make_order_response("o1", "SPY"),
4445            make_order_response("o2", "AAPL"),
4446            make_order_response("o3", "MSFT"),
4447        ];
4448        // Request a different order: MSFT first, then AAPL.
4449        let instruments = vec![
4450            InstrumentNameExchange::new("MSFT"),
4451            InstrumentNameExchange::new("AAPL"),
4452        ];
4453        let snapshots = build_instrument_snapshots(orders, &instruments);
4454        assert_eq!(snapshots.len(), 2);
4455        assert_eq!(
4456            snapshots[0].instrument.name().as_str(),
4457            "MSFT",
4458            "first snapshot must be MSFT (first in instruments slice)"
4459        );
4460        assert_eq!(
4461            snapshots[1].instrument.name().as_str(),
4462            "AAPL",
4463            "second snapshot must be AAPL (second in instruments slice)"
4464        );
4465    }
4466
4467    // ---------------------------------------------------------------------------
4468    // HTTP-mocked tests — paginate_activities (H-1) and fetch_raw_open_orders (H-3)
4469    // ---------------------------------------------------------------------------
4470    //
4471    // These tests use wiremock to stand up a local HTTP server, verifying the full
4472    // pagination loop and truncation logic without touching the real Alpaca API.
4473    mod http_tests {
4474        use super::super::*;
4475        use wiremock::matchers::{method, path};
4476        use wiremock::{Mock, MockServer, Request, Respond, ResponseTemplate};
4477
4478        /// Serves pre-configured JSON pages in registration order.
4479        ///
4480        /// Each call to `respond` advances an atomic counter and returns the next
4481        /// page body. Panics if called more times than pages were configured —
4482        /// surfaces unexpected extra requests as an explicit test failure rather
4483        /// than silently returning a stale response.
4484        struct Sequential {
4485            call: std::sync::atomic::AtomicU32,
4486            pages: Vec<serde_json::Value>,
4487        }
4488
4489        impl Sequential {
4490            fn new(pages: Vec<serde_json::Value>) -> Self {
4491                Self {
4492                    call: std::sync::atomic::AtomicU32::new(0),
4493                    pages,
4494                }
4495            }
4496        }
4497
4498        impl Respond for Sequential {
4499            fn respond(&self, _: &Request) -> ResponseTemplate {
4500                let i = self.call.fetch_add(1, std::sync::atomic::Ordering::Relaxed) as usize;
4501                let body = self.pages.get(i).unwrap_or_else(|| {
4502                    panic!(
4503                        "Sequential: request #{i} has no configured response \
4504                         (only {} page(s) supplied)",
4505                        self.pages.len()
4506                    )
4507                });
4508                ResponseTemplate::new(200).set_body_json(body)
4509            }
4510        }
4511
4512        /// Build a JSON array of N minimal AlpacaActivity objects with unique IDs.
4513        fn make_activities_json(count: usize, id_prefix: &str) -> serde_json::Value {
4514            serde_json::Value::Array(
4515                (0..count)
4516                    .map(|i| {
4517                        serde_json::json!({
4518                            "id": format!("{id_prefix}-{i:05}"),
4519                            "order_id": "ord-1",
4520                            "symbol": "SPY",
4521                            "side": "buy",
4522                            "price": "100.00",
4523                            "qty": "1",
4524                            "transaction_time": "2025-04-18T14:30:00Z"
4525                        })
4526                    })
4527                    .collect(),
4528            )
4529        }
4530
4531        /// Build a JSON array of N minimal AlpacaOrderResponse objects with unique IDs.
4532        fn make_orders_json(count: usize) -> serde_json::Value {
4533            serde_json::Value::Array(
4534                (0..count)
4535                    .map(|i| {
4536                        serde_json::json!({
4537                            "id": format!("order-{i:05}"),
4538                            "client_order_id": null,
4539                            "symbol": "SPY",
4540                            "qty": "1",
4541                            "filled_qty": "0",
4542                            "side": "buy",
4543                            "type": "limit",
4544                            "time_in_force": "day",
4545                            "limit_price": "100.00",
4546                            "created_at": "2025-04-18T14:30:00Z"
4547                        })
4548                    })
4549                    .collect(),
4550            )
4551        }
4552
4553        // --- H-1: paginate_activities ---
4554
4555        /// Single page with fewer items than the page limit — loop terminates immediately,
4556        /// no further request issued.
4557        #[tokio::test]
4558        async fn paginate_activities_single_page_below_max_returns_all_not_truncated() {
4559            let server = MockServer::start().await;
4560            Mock::given(method("GET"))
4561                .and(path("/v2/account/activities"))
4562                .respond_with(
4563                    ResponseTemplate::new(200).set_body_json(make_activities_json(5, "act")),
4564                )
4565                .mount(&server)
4566                .await;
4567
4568            let http = reqwest::Client::new();
4569            let rl = RateLimitTracker::new();
4570            let result = paginate_activities(&http, &rl, &server.uri(), "2025-01-01T00:00:00Z")
4571                .await
4572                .unwrap();
4573
4574            assert_eq!(result.activities.len(), 5);
4575            assert!(!result.truncated);
4576            assert_eq!(server.received_requests().await.unwrap().len(), 1);
4577        }
4578
4579        /// First page has exactly ALPACA_MAX_ACTIVITIES items, which triggers a second
4580        /// request. The second page is empty, so the loop terminates without truncation.
4581        #[tokio::test]
4582        async fn paginate_activities_exactly_page_size_items_fetches_second_page() {
4583            let server = MockServer::start().await;
4584            Mock::given(method("GET"))
4585                .and(path("/v2/account/activities"))
4586                .respond_with(Sequential::new(vec![
4587                    make_activities_json(ALPACA_MAX_ACTIVITIES, "act"),
4588                    serde_json::json!([]), // empty second page → loop stops
4589                ]))
4590                .mount(&server)
4591                .await;
4592
4593            let http = reqwest::Client::new();
4594            let rl = RateLimitTracker::new();
4595            let result = paginate_activities(&http, &rl, &server.uri(), "2025-01-01T00:00:00Z")
4596                .await
4597                .unwrap();
4598
4599            assert_eq!(result.activities.len(), ALPACA_MAX_ACTIVITIES);
4600            assert!(!result.truncated);
4601            assert_eq!(
4602                server.received_requests().await.unwrap().len(),
4603                2,
4604                "exactly 2 requests: first full page + second empty page"
4605            );
4606        }
4607
4608        /// Two-page case: 100 items on page 1, 37 on page 2 — all accumulated,
4609        /// loop terminates on the partial second page without truncation.
4610        #[tokio::test]
4611        async fn paginate_activities_two_pages_returns_combined_activities_not_truncated() {
4612            let server = MockServer::start().await;
4613            Mock::given(method("GET"))
4614                .and(path("/v2/account/activities"))
4615                .respond_with(Sequential::new(vec![
4616                    make_activities_json(ALPACA_MAX_ACTIVITIES, "p1"),
4617                    make_activities_json(37, "p2"),
4618                ]))
4619                .mount(&server)
4620                .await;
4621
4622            let http = reqwest::Client::new();
4623            let rl = RateLimitTracker::new();
4624            let result = paginate_activities(&http, &rl, &server.uri(), "2025-01-01T00:00:00Z")
4625                .await
4626                .unwrap();
4627
4628            assert_eq!(result.activities.len(), ALPACA_MAX_ACTIVITIES + 37);
4629            assert!(!result.truncated);
4630            assert_eq!(server.received_requests().await.unwrap().len(), 2);
4631        }
4632
4633        /// When every page is full the loop runs until MAX_ACTIVITY_PAGES pages have been
4634        /// fetched, then sets truncated=true and stops. Exactly MAX_ACTIVITY_PAGES HTTP
4635        /// requests are issued (the truncation guard fires before the (N+1)th call).
4636        #[tokio::test]
4637        async fn paginate_activities_at_max_pages_sets_truncated_true() {
4638            let server = MockServer::start().await;
4639
4640            // Always return a full page — the loop must enforce the cap itself.
4641            Mock::given(method("GET"))
4642                .and(path("/v2/account/activities"))
4643                .respond_with(
4644                    ResponseTemplate::new(200)
4645                        .set_body_json(make_activities_json(ALPACA_MAX_ACTIVITIES, "act")),
4646                )
4647                .mount(&server)
4648                .await;
4649
4650            let http = reqwest::Client::new();
4651            let rl = RateLimitTracker::new();
4652            let result = paginate_activities(&http, &rl, &server.uri(), "2025-01-01T00:00:00Z")
4653                .await
4654                .unwrap();
4655
4656            assert!(
4657                result.truncated,
4658                "must be truncated after MAX_ACTIVITY_PAGES pages"
4659            );
4660            assert_eq!(
4661                result.activities.len(),
4662                MAX_ACTIVITY_PAGES * ALPACA_MAX_ACTIVITIES,
4663                "must accumulate exactly MAX_ACTIVITY_PAGES * page_size activities"
4664            );
4665            // The truncation check fires at the top of the loop when pages == MAX_ACTIVITY_PAGES,
4666            // before the (MAX_ACTIVITY_PAGES+1)th request would be issued.
4667            assert_eq!(
4668                server.received_requests().await.unwrap().len(),
4669                MAX_ACTIVITY_PAGES,
4670                "loop must issue exactly MAX_ACTIVITY_PAGES requests then stop"
4671            );
4672        }
4673
4674        // --- H-3: fetch_raw_open_orders truncation boundary ---
4675
4676        /// 499 orders (one below MAX_OPEN_ORDERS) is not truncated — returns Ok.
4677        #[tokio::test]
4678        async fn fetch_raw_open_orders_499_results_returns_ok() {
4679            let server = MockServer::start().await;
4680            Mock::given(method("GET"))
4681                .and(path("/v2/orders"))
4682                .respond_with(
4683                    ResponseTemplate::new(200).set_body_json(make_orders_json(MAX_OPEN_ORDERS - 1)),
4684                )
4685                .mount(&server)
4686                .await;
4687
4688            let http = reqwest::Client::new();
4689            let rl = RateLimitTracker::new();
4690            let result = fetch_raw_open_orders(&http, &rl, &server.uri(), &[]).await;
4691
4692            assert!(
4693                result.is_ok(),
4694                "499 orders must not trigger truncation: {result:?}"
4695            );
4696            assert_eq!(result.unwrap().len(), MAX_OPEN_ORDERS - 1);
4697        }
4698
4699        /// Exactly MAX_OPEN_ORDERS results triggers TruncatedSnapshot because Alpaca's
4700        /// API cap means the response is likely incomplete. An off-by-one here would
4701        /// either silently corrupt OMS state or incorrectly reject a valid account.
4702        #[tokio::test]
4703        async fn fetch_raw_open_orders_500_results_returns_truncated_snapshot_error() {
4704            let server = MockServer::start().await;
4705            Mock::given(method("GET"))
4706                .and(path("/v2/orders"))
4707                .respond_with(
4708                    ResponseTemplate::new(200).set_body_json(make_orders_json(MAX_OPEN_ORDERS)),
4709                )
4710                .mount(&server)
4711                .await;
4712
4713            let http = reqwest::Client::new();
4714            let rl = RateLimitTracker::new();
4715            let result = fetch_raw_open_orders(&http, &rl, &server.uri(), &[]).await;
4716
4717            assert!(
4718                matches!(
4719                    result,
4720                    Err(UnindexedClientError::TruncatedSnapshot { limit }) if limit == MAX_OPEN_ORDERS
4721                ),
4722                "500 orders must return TruncatedSnapshot, got: {result:?}"
4723            );
4724        }
4725
4726        // --- L-2: open_order passes reduce_only through to position_intent ---
4727
4728        /// Verifies that `open_order` correctly derives `position_intent` from
4729        /// `reduce_only` and `side`. This test exercises the full path:
4730        /// `open_order` → `map_position_intent` → `open_order_inner` → HTTP request.
4731        ///
4732        /// Uses wiremock to capture the request body and verify position_intent.
4733        #[tokio::test]
4734        async fn open_order_reduce_only_sell_sends_sell_to_close_intent() {
4735            use crate::client::ExecutionClient;
4736            use crate::order::request::{OrderRequestOpen, RequestOpen};
4737            use crate::order::{
4738                OrderKey, OrderKind, TimeInForce,
4739                id::{ClientOrderId, StrategyId},
4740            };
4741            use rust_decimal::Decimal;
4742            use rustrade_instrument::Side;
4743            use rustrade_instrument::exchange::ExchangeId;
4744            use rustrade_instrument::instrument::name::InstrumentNameExchange;
4745            use wiremock::matchers::{method, path};
4746
4747            let server = MockServer::start().await;
4748
4749            // Mock POST /v2/orders to return a valid order response.
4750            // Use a custom responder to capture and verify the request body.
4751            let captured_body = std::sync::Arc::new(parking_lot::Mutex::new(None));
4752            let captured_clone = captured_body.clone();
4753
4754            Mock::given(method("POST"))
4755                .and(path("/v2/orders"))
4756                .respond_with(move |req: &Request| {
4757                    // Capture the request body for later assertion
4758                    let body: serde_json::Value = serde_json::from_slice(&req.body).unwrap();
4759                    *captured_clone.lock() = Some(body);
4760
4761                    ResponseTemplate::new(200).set_body_json(serde_json::json!({
4762                        "id": "test-order-id",
4763                        "client_order_id": "test-cid",
4764                        "symbol": "AAPL",
4765                        "qty": "10",
4766                        "filled_qty": "0",
4767                        "side": "sell",
4768                        "type": "market",
4769                        "time_in_force": "ioc",
4770                        "limit_price": null,
4771                        "created_at": "2025-04-18T14:30:00Z"
4772                    }))
4773                })
4774                .mount(&server)
4775                .await;
4776
4777            // Create client with base_url_override pointing to mock server
4778            let config =
4779                AlpacaConfig::with_base_url("test-key".into(), "test-secret".into(), server.uri());
4780            let client = AlpacaClient::new(config);
4781
4782            // Create a Sell order with reduce_only=true (should map to SellToClose)
4783            let request = OrderRequestOpen {
4784                key: OrderKey {
4785                    exchange: ExchangeId::AlpacaBroker,
4786                    instrument: InstrumentNameExchange::new("AAPL"),
4787                    strategy: StrategyId::new("test-strategy"),
4788                    cid: ClientOrderId::new("test-cid"),
4789                },
4790                state: RequestOpen {
4791                    side: Side::Sell,
4792                    price: None,
4793                    quantity: Decimal::new(10, 0),
4794                    kind: OrderKind::Market,
4795                    time_in_force: TimeInForce::ImmediateOrCancel,
4796                    position_id: None,
4797                    reduce_only: true, // This should map to SellToClose
4798                },
4799            };
4800
4801            // Call open_order (borrows instrument)
4802            let result = client
4803                .open_order(OrderRequestOpen {
4804                    key: OrderKey {
4805                        exchange: request.key.exchange,
4806                        instrument: &request.key.instrument,
4807                        strategy: request.key.strategy.clone(),
4808                        cid: request.key.cid.clone(),
4809                    },
4810                    state: request.state.clone(),
4811                })
4812                .await;
4813
4814            // Verify the order was accepted
4815            assert!(result.is_some(), "open_order should return a result");
4816            let order = result.unwrap();
4817            assert!(
4818                order.state.is_accepted(),
4819                "order should be accepted: {:?}",
4820                order.state
4821            );
4822
4823            // Verify the request body contained position_intent=sell_to_close
4824            let body = captured_body
4825                .lock()
4826                .take()
4827                .expect("request body should be captured");
4828            assert_eq!(
4829                body.get("position_intent").and_then(|v| v.as_str()),
4830                Some("sell_to_close"),
4831                "reduce_only=true + Side::Sell should produce position_intent=sell_to_close, got: {body}"
4832            );
4833        }
4834
4835        /// Verifies that reduce_only=false + Buy produces BuyToOpen intent.
4836        #[tokio::test]
4837        async fn open_order_not_reduce_only_buy_sends_buy_to_open_intent() {
4838            use crate::client::ExecutionClient;
4839            use crate::order::request::{OrderRequestOpen, RequestOpen};
4840            use crate::order::{
4841                OrderKey, OrderKind, TimeInForce,
4842                id::{ClientOrderId, StrategyId},
4843            };
4844            use rust_decimal::Decimal;
4845            use rustrade_instrument::Side;
4846            use rustrade_instrument::exchange::ExchangeId;
4847            use rustrade_instrument::instrument::name::InstrumentNameExchange;
4848
4849            let server = MockServer::start().await;
4850
4851            let captured_body = std::sync::Arc::new(parking_lot::Mutex::new(None));
4852            let captured_clone = captured_body.clone();
4853
4854            Mock::given(method("POST"))
4855                .and(path("/v2/orders"))
4856                .respond_with(move |req: &Request| {
4857                    let body: serde_json::Value = serde_json::from_slice(&req.body).unwrap();
4858                    *captured_clone.lock() = Some(body);
4859
4860                    ResponseTemplate::new(200).set_body_json(serde_json::json!({
4861                        "id": "test-order-id",
4862                        "client_order_id": "test-cid",
4863                        "symbol": "AAPL",
4864                        "qty": "10",
4865                        "filled_qty": "0",
4866                        "side": "buy",
4867                        "type": "market",
4868                        "time_in_force": "ioc",
4869                        "limit_price": null,
4870                        "created_at": "2025-04-18T14:30:00Z"
4871                    }))
4872                })
4873                .mount(&server)
4874                .await;
4875
4876            let config =
4877                AlpacaConfig::with_base_url("test-key".into(), "test-secret".into(), server.uri());
4878            let client = AlpacaClient::new(config);
4879
4880            let instrument = InstrumentNameExchange::new("AAPL");
4881            let request = OrderRequestOpen {
4882                key: OrderKey {
4883                    exchange: ExchangeId::AlpacaBroker,
4884                    instrument: &instrument,
4885                    strategy: StrategyId::new("test-strategy"),
4886                    cid: ClientOrderId::new("test-cid"),
4887                },
4888                state: RequestOpen {
4889                    side: Side::Buy,
4890                    price: None,
4891                    quantity: Decimal::new(10, 0),
4892                    kind: OrderKind::Market,
4893                    time_in_force: TimeInForce::ImmediateOrCancel,
4894                    position_id: None,
4895                    reduce_only: false, // This should map to BuyToOpen
4896                },
4897            };
4898
4899            let result = client.open_order(request).await;
4900
4901            assert!(result.is_some(), "open_order should return a result");
4902            let order = result.unwrap();
4903            assert!(
4904                order.state.is_accepted(),
4905                "order should be accepted: {:?}",
4906                order.state
4907            );
4908
4909            let body = captured_body
4910                .lock()
4911                .take()
4912                .expect("request body should be captured");
4913            assert_eq!(
4914                body.get("position_intent").and_then(|v| v.as_str()),
4915                Some("buy_to_open"),
4916                "reduce_only=false + Side::Buy should produce position_intent=buy_to_open, got: {body}"
4917            );
4918        }
4919    }
4920}