1use 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
70const INITIAL_BACKOFF_MS: u64 = 1_000;
75const MAX_BACKOFF_MS: u64 = 30_000;
76const MAX_RECONNECT_ATTEMPTS: u32 = 10;
77const HEARTBEAT_TIMEOUT_SECS: u64 = 35;
79const FILL_RECOVERY_TIMEOUT_SECS: u64 = 30;
81const SIGNAL_RECOVERY_LOOKBACK_MS: i64 = 1_500;
84const ALPACA_MAX_ACTIVITIES: usize = 100;
86const DEFAULT_RATE_LIMIT_DELAY_SECS: u64 = 60;
88const MAX_RATE_LIMIT_ATTEMPTS: u32 = 4;
91const DEDUP_CACHE_SIZE: usize = 2_000;
94const WS_HANDSHAKE_TIMEOUT_SECS: u64 = 15;
96const WS_CLOSE_TIMEOUT_SECS: u64 = 5;
99
100struct GracefulShutdownStream<S> {
117 inner: S,
118 _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 }
154}
155
156struct 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 async fn wait_if_blocked(&self) {
174 loop {
175 let now = tokio::time::Instant::now();
179 let deadline = {
187 let mut guard = self.blocked_until.lock();
188 let d = *guard;
189 if matches!(d, Some(t) if t <= now) {
190 *guard = None;
193 }
194 d
195 };
196 match deadline {
197 None => return,
198 Some(until) => {
199 if until <= now {
200 return;
202 }
203 #[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 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
234struct 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 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
280type SharedDedupCache = Arc<parking_lot::Mutex<LruCache<SmolStr, ()>>>;
303
304fn new_dedup_cache() -> SharedDedupCache {
305 #[allow(clippy::unwrap_used)]
308 Arc::new(parking_lot::Mutex::new(LruCache::new(
309 NonZeroUsize::new(DEDUP_CACHE_SIZE).unwrap(),
310 )))
311}
312
313fn is_duplicate(cache: &SharedDedupCache, key: &SmolStr) -> bool {
315 let mut guard = cache.lock();
316 if guard.peek(key).is_some() {
318 return true;
319 }
320 guard.put(key.clone(), ());
324 false
325}
326
327#[derive(Clone, Deserialize)]
334#[serde(deny_unknown_fields)]
335pub struct AlpacaConfig {
336 api_key: String,
338 secret_key: String,
339 pub paper: bool,
341 #[cfg(test)]
343 pub base_url_override: Option<String>,
344}
345
346impl 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 #[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 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 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#[derive(Debug, Deserialize)]
413struct AlpacaAccount {
414 equity: String,
415 buying_power: String,
416 options_buying_power: Option<String>,
417 #[allow(dead_code)]
422 crypto_buying_power: Option<String>,
424}
425
426#[derive(Debug, Deserialize)]
428struct AlpacaPosition {
429 symbol: String,
431 asset_class: String,
433 qty: String,
435 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#[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#[derive(Debug, Serialize)]
501struct TakeProfitParams {
502 limit_price: String,
503}
504
505#[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#[non_exhaustive]
551#[derive(Debug, Clone)]
552pub struct AlpacaBracketOrderRequest {
553 pub instrument: InstrumentNameExchange,
555 pub strategy: StrategyId,
557 pub cid: ClientOrderId,
559 pub side: Side,
561 pub quantity: Decimal,
563 pub entry_price: Decimal,
565 pub take_profit_price: Decimal,
567 pub stop_loss_price: Decimal,
569 pub stop_loss_limit_price: Option<Decimal>,
571 pub time_in_force: TimeInForce,
573}
574
575impl AlpacaBracketOrderRequest {
576 #[allow(clippy::too_many_arguments)] 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 #[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#[non_exhaustive]
624#[derive(Debug, Clone)]
625pub struct AlpacaBracketOrderResult {
626 pub parent: Order<ExchangeId, InstrumentNameExchange, UnindexedOrderState>,
628}
629
630#[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 #[serde(skip_serializing_if = "Option::is_none")]
656 position_intent: Option<AlpacaPositionIntent>,
657 #[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#[derive(Debug, Deserialize)]
676struct AlpacaStreamMessage<'a> {
677 stream: SmolStr,
680 #[serde(borrow)]
681 data: &'a serde_json::value::RawValue,
682}
683
684#[derive(Debug, Deserialize)]
691struct AlpacaTradeUpdate<'a> {
692 event: SmolStr,
694 #[serde(borrow)]
695 order: AlpacaOrderWs<'a>,
696 #[serde(borrow)]
698 price: Option<&'a str>,
699 #[serde(borrow)]
701 qty: Option<&'a str>,
702 #[serde(borrow)]
703 timestamp: Option<&'a str>,
704}
705
706#[derive(Debug, Deserialize)]
708struct AlpacaOrderWs<'a> {
709 id: SmolStr,
712 client_order_id: Option<SmolStr>,
713 symbol: SmolStr,
716 #[serde(borrow)]
717 qty: Option<&'a str>,
718 #[serde(borrow)]
723 filled_qty: Option<&'a str>,
724 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#[derive(Clone)]
757pub struct AlpacaClient {
758 config: Arc<AlpacaConfig>,
759 http: reqwest::Client,
762 rate_limiter: Arc<RateLimitTracker>,
763 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 #[allow(clippy::expect_used)] fn build_http(config: &AlpacaConfig) -> reqwest::Client {
784 use reqwest::header::{HeaderMap, HeaderName, HeaderValue};
785 let mut headers = HeaderMap::new();
786 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
808fn 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
832async 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 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 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 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 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 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
940async 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 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 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
999impl ExecutionClient for AlpacaClient {
1004 const EXCHANGE: ExchangeId = ExchangeId::AlpacaBroker;
1005 type Config = AlpacaConfig;
1006 type AccountStream = BoxStream<'static, UnindexedAccountEvent>;
1007
1008 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 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 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 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 async fn account_stream(
1139 &self,
1140 _assets: &[AssetNameExchange], instruments: &[InstrumentNameExchange],
1145 ) -> Result<Self::AccountStream, UnindexedClientError> {
1146 let initial_ws = connect_and_subscribe(&self.config).await?;
1149
1150 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 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 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 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 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 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 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 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 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 if page.truncated {
1331 return Err(UnindexedClientError::Truncated {
1332 limit: MAX_ACTIVITY_PAGES,
1333 });
1334 }
1335
1336 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
1357impl AlpacaClient {
1362 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 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 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 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 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 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 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 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 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 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 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 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: if is_options_or_equity_symbol(instrument.name().as_str()) {
1754 Some(intent)
1755 } else {
1756 None
1757 },
1758 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 OrderState::fully_filled(Filled::new(
1779 exchange_order_id,
1780 time_exchange,
1781 filled_qty,
1782 None, ))
1784 } else {
1785 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 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
1832const MAX_OPEN_ORDERS: usize = 500;
1840
1841async 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
1884const MAX_ACTIVITY_PAGES: usize = 50;
1893
1894struct ActivityPage {
1900 activities: Vec<AlpacaActivity>,
1901 truncated: bool,
1902}
1903
1904const PAGE_SIZE_STR: &str = "100"; const _: () = 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 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 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 _ => break,
1978 }
1979 }
1980
1981 Ok(ActivityPage {
1982 activities: all,
1983 truncated,
1984 })
1985}
1986
1987#[allow(clippy::cognitive_complexity)] async 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 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 backoff.reset();
2043
2044 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 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 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 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 match std::str::from_utf8(&bytes) {
2117 Ok(text) => {
2118 process_ws_text(text, &tx, &dedup, &mut backoff);
2119 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(_)) => {} 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 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 disconnect_time =
2167 Some(last_message_time - chrono::Duration::milliseconds(SIGNAL_RECOVERY_LOOKBACK_MS));
2168
2169 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#[derive(Debug)]
2188enum HandshakeError {
2189 Transport(String),
2192 Auth(String),
2195}
2196
2197async 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 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
2240async fn ws_handshake(ws: &mut WebSocket, config: &AlpacaConfig) -> Result<(), HandshakeError> {
2248 let auth = serde_json::json!({
2250 "action": "auth",
2251 "key": config.api_key(),
2252 "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 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 _ => {} }
2290 }
2291
2292 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 loop {
2304 match ws.next().await {
2305 Some(Ok(WsMessage::Text(text))) => {
2306 if check_listen_ack(text.as_str()) {
2307 break;
2308 }
2309 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
2356fn 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
2381fn 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
2402fn 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 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 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 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 let _ = tx.send(event);
2462 }
2463 }
2464 "authorization" | "listening" => {
2465 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
2475fn fill_dedup_key_from_event(event: &UnindexedAccountEvent) -> Option<&SmolStr> {
2482 match &event.kind {
2483 AccountEventKind::Trade(trade) => Some(&trade.id.0),
2485 _ => None,
2486 }
2487}
2488
2489#[inline]
2493fn is_fill_event(update: &AlpacaTradeUpdate<'_>) -> bool {
2494 matches!(update.event.as_str(), "fill" | "partial_fill")
2495}
2496
2497fn early_dedup_key(update: &AlpacaTradeUpdate<'_>) -> SmolStr {
2507 let filled_qty = update.order.filled_qty.unwrap_or("0");
2508 let qty = Decimal::from_str(filled_qty).unwrap_or(Decimal::ZERO);
2511 format_smolstr!("{}:{}", update.order.id, qty.normalize())
2512}
2513
2514async 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 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 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 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 continue;
2581 }
2582
2583 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; }
2610 };
2611
2612 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 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
2640fn convert_account_to_balances(
2653 account: &AlpacaAccount,
2654 assets: &[AssetNameExchange],
2655) -> Vec<AssetBalance<AssetNameExchange>> {
2656 let usd_entry = assets
2659 .iter()
2660 .find(|a| a.name().as_str().eq_ignore_ascii_case("usd"));
2661
2662 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(|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
2690fn 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 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 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 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
2741fn build_instrument_snapshots(
2747 orders: Vec<AlpacaOrderResponse>,
2748 instruments: &[InstrumentNameExchange],
2749) -> Vec<InstrumentAccountSnapshot<ExchangeId, AssetNameExchange, InstrumentNameExchange>> {
2750 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() {
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 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
2792fn 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 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 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
2842fn 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 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
2878fn convert_trade_update(update: AlpacaTradeUpdate<'_>) -> Option<UnindexedAccountEvent> {
2880 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 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 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 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 let side = parse_side(&order.side)?;
2969 let quantity = Decimal::from_str(order.qty.unwrap_or("0")).unwrap_or(Decimal::ZERO);
2970 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 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 _ => unreachable!("convert_trade_update: unrecognised event passed early-return guard"),
3066 }
3067}
3068
3069fn 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 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 OrderKind::TakeProfit { .. }
3160 | OrderKind::TakeProfitLimit { .. }
3161 | OrderKind::TrailingStopLimit { .. } => None,
3162 }
3163}
3164
3165fn 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 TimeInForce::GoodTillDate { .. } => {
3190 Err("Alpaca GoodTillDate is not yet wired through this client")
3191 }
3192 }
3193}
3194
3195fn is_options_or_equity_symbol(symbol: &str) -> bool {
3206 !symbol.contains('/')
3207}
3208
3209fn 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
3226fn parse_api_error(status: reqwest::StatusCode, message: &str) -> crate::error::UnindexedApiError {
3231 if status == reqwest::StatusCode::TOO_MANY_REQUESTS {
3233 return ApiError::RateLimit;
3234 }
3235
3236 let lower = message.to_ascii_lowercase();
3238 match status.as_u16() {
3239 422 if lower.contains("already") => ApiError::OrderAlreadyCancelled,
3243 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
3257fn 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
3266impl 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#[cfg(test)]
3299#[allow(clippy::unwrap_used, clippy::expect_used)] mod 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 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 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 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 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 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 assert_eq!(parse_order_kind("trailing_stop", None, None, None), None);
3384
3385 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 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 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 #[test]
3466 fn test_bracket_order_serializes_with_stop_loss_stop_order() {
3467 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 assert_eq!(json["take_profit"]["limit_price"], "160.00");
3502
3503 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 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 assert_eq!(json["take_profit"]["limit_price"], "440.00");
3545
3546 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 #[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 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, );
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 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), 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 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)); 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 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 assert!(b.wait().await, "first wait should return true");
3745 assert_eq!(b.attempt, 1);
3746
3747 while b.wait().await {}
3749
3750 assert_eq!(b.attempt, MAX_RECONNECT_ATTEMPTS);
3752
3753 assert!(!b.wait().await, "exhausted backoff should return false");
3755
3756 b.reset();
3758 assert_eq!(b.attempt, 0);
3759
3760 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 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 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 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 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 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 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 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 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 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 AlpacaPosition {
4016 symbol: "AAPL".into(),
4017 asset_class: "us_equity".into(),
4018 qty: "10".into(),
4019 qty_available: "10".into(),
4020 },
4021 ];
4022
4023 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 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 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 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 let orders = vec![
4095 make_order_response("o1", "AAPL"),
4096 make_order_response("o2", "MSFT"), ];
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 #[test]
4110 fn test_recover_fills_dedup_key_matches_ws_path() {
4111 let order_id = "ord-1";
4112
4113 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 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 #[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 let early_key = early_dedup_key(&update);
4166
4167 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 #[test]
4185 fn early_dedup_key_normalizes_decimal_strings() {
4186 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 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 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 #[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 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 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 #[test]
4287 fn decimal_zero_normalize_is_zero_str() {
4288 assert_eq!(Decimal::ZERO.normalize().to_string(), "0");
4289 }
4290
4291 #[test]
4295 fn decimal_normalize_strips_trailing_zeros() {
4296 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 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 #[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()), 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 #[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 #[test]
4376 fn parse_order_error_401_maps_to_unauthenticated() {
4377 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 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 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 #[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 #[test]
4441 fn build_instrument_snapshots_output_order_matches_instruments_slice() {
4442 let orders = vec![
4444 make_order_response("o1", "SPY"),
4445 make_order_response("o2", "AAPL"),
4446 make_order_response("o3", "MSFT"),
4447 ];
4448 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 mod http_tests {
4474 use super::super::*;
4475 use wiremock::matchers::{method, path};
4476 use wiremock::{Mock, MockServer, Request, Respond, ResponseTemplate};
4477
4478 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 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 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 #[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 #[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!([]), ]))
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 #[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 #[tokio::test]
4637 async fn paginate_activities_at_max_pages_sets_truncated_true() {
4638 let server = MockServer::start().await;
4639
4640 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 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 #[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 #[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 #[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 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 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 let config =
4779 AlpacaConfig::with_base_url("test-key".into(), "test-secret".into(), server.uri());
4780 let client = AlpacaClient::new(config);
4781
4782 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, },
4799 };
4800
4801 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 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 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 #[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, },
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}