1use crate::{
35 AccountEventKind, AccountSnapshot, InstrumentAccountSnapshot, UnindexedAccountEvent,
36 UnindexedAccountSnapshot,
37 balance::{AssetBalance, Balance},
38 client::{BracketOrderClient, ExecutionClient},
39 emit_stream_terminated,
40 error::{
41 ApiError, ConnectivityError, OrderError, StreamTerminationReason, UnindexedClientError,
42 UnindexedOrderError,
43 },
44 order::{
45 Order, OrderKey, OrderKind, TimeInForce, TrailingOffsetType,
46 bracket::{
47 BracketOrderRequest as UnifiedBracketOrderRequest,
48 BracketOrderResult as UnifiedBracketOrderResult,
49 },
50 id::{ClientOrderId, OrderId, StrategyId},
51 request::{OrderRequestCancel, OrderRequestOpen, UnindexedOrderResponseCancel},
52 state::{Cancelled, Filled, Open, OrderState, UnindexedOrderState},
53 },
54 parse_env_bool,
55 trade::{AssetFees, Trade, TradeId},
56};
57use chrono::{DateTime, Utc};
58use fnv::FnvHashMap;
59use futures::{SinkExt as _, StreamExt as _, stream::BoxStream};
60use indexmap::IndexMap;
61use itertools::Itertools as _;
62use lru::LruCache;
63use rust_decimal::Decimal;
64use rustrade_instrument::{
65 Side, asset::name::AssetNameExchange, exchange::ExchangeId,
66 instrument::name::InstrumentNameExchange,
67};
68use rustrade_integration::protocol::websocket::{WebSocket, WsMessage};
69use serde::{Deserialize, Serialize};
70use smol_str::{SmolStr, format_smolstr};
71use std::{num::NonZeroUsize, pin::Pin, str::FromStr, sync::Arc, time::Duration};
72use tokio::sync::mpsc;
73use tracing::{debug, error, info, trace, warn};
74
75const INITIAL_BACKOFF_MS: u64 = 1_000;
80const MAX_BACKOFF_MS: u64 = 30_000;
81const MAX_RECONNECT_ATTEMPTS: u32 = 10;
82const HEARTBEAT_TIMEOUT_SECS: u64 = 35;
84const FILL_RECOVERY_TIMEOUT_SECS: u64 = 30;
86const SIGNAL_RECOVERY_LOOKBACK_MS: i64 = 1_500;
89const ALPACA_MAX_ACTIVITIES: usize = 100;
91const DEFAULT_RATE_LIMIT_DELAY_SECS: u64 = 60;
93const MAX_RATE_LIMIT_ATTEMPTS: u32 = 4;
96const DEDUP_CACHE_SIZE: usize = 2_000;
99const WS_HANDSHAKE_TIMEOUT_SECS: u64 = 15;
101const WS_CLOSE_TIMEOUT_SECS: u64 = 5;
104
105struct GracefulShutdownStream<S> {
122 inner: S,
123 _handle: tokio::task::JoinHandle<()>,
129}
130
131impl<S> GracefulShutdownStream<S> {
132 fn new(inner: S, handle: tokio::task::JoinHandle<()>) -> Self {
133 Self {
134 inner,
135 _handle: handle,
136 }
137 }
138}
139
140impl<S: futures::Stream + Unpin> futures::Stream for GracefulShutdownStream<S> {
141 type Item = S::Item;
142 fn poll_next(
143 mut self: Pin<&mut Self>,
144 cx: &mut std::task::Context<'_>,
145 ) -> std::task::Poll<Option<Self::Item>> {
146 Pin::new(&mut self.inner).poll_next(cx)
147 }
148 fn size_hint(&self) -> (usize, Option<usize>) {
149 self.inner.size_hint()
150 }
151}
152
153impl<S> Drop for GracefulShutdownStream<S> {
154 fn drop(&mut self) {
155 }
159}
160
161struct RateLimitTracker {
167 blocked_until: parking_lot::Mutex<Option<tokio::time::Instant>>,
168}
169
170impl RateLimitTracker {
171 fn new() -> Self {
172 Self {
173 blocked_until: parking_lot::Mutex::new(None),
174 }
175 }
176
177 async fn wait_if_blocked(&self) {
179 loop {
180 let now = tokio::time::Instant::now();
184 let deadline = {
192 let mut guard = self.blocked_until.lock();
193 let d = *guard;
194 if matches!(d, Some(t) if t <= now) {
195 *guard = None;
198 }
199 d
200 };
201 match deadline {
202 None => return,
203 Some(until) => {
204 if until <= now {
205 return;
207 }
208 #[allow(clippy::cast_possible_truncation)]
210 let delay_ms = (until - now).as_millis() as u64;
211 debug!(delay_ms, "Alpaca REST rate-limited, waiting before request");
212 tokio::time::sleep_until(until).await;
213 }
214 }
215 }
216 }
217
218 fn on_rate_limited(&self, retry_after: Option<Duration>) {
220 let delay = retry_after.unwrap_or(Duration::from_secs(DEFAULT_RATE_LIMIT_DELAY_SECS));
221 let new_deadline = tokio::time::Instant::now() + delay;
222 let mut guard = self.blocked_until.lock();
223 let was_blocked = guard.is_some();
224 *guard = Some(guard.map_or(new_deadline, |existing| existing.max(new_deadline)));
225 if was_blocked {
226 debug!(
227 delay_secs = delay.as_secs(),
228 "Alpaca rate-limit cooldown extended"
229 );
230 } else {
231 warn!(
232 delay_secs = delay.as_secs(),
233 "Alpaca entering rate-limit degradation mode"
234 );
235 }
236 }
237}
238
239struct ExponentialBackoff {
244 attempt: u32,
245 max_attempts: u32,
246 initial_ms: u64,
247 max_ms: u64,
248}
249
250impl ExponentialBackoff {
251 fn new() -> Self {
252 Self {
253 attempt: 0,
254 max_attempts: MAX_RECONNECT_ATTEMPTS,
255 initial_ms: INITIAL_BACKOFF_MS,
256 max_ms: MAX_BACKOFF_MS,
257 }
258 }
259
260 fn reset(&mut self) {
261 self.attempt = 0;
262 }
263
264 fn attempts(&self) -> u32 {
270 self.attempt
271 }
272
273 async fn wait(&mut self) -> bool {
275 if self.attempt >= self.max_attempts {
276 return false;
277 }
278 let delay_ms = self
279 .initial_ms
280 .saturating_mul(2u64.saturating_pow(self.attempt))
281 .min(self.max_ms);
282 self.attempt += 1;
283 debug!(
284 attempt = self.attempt,
285 max = self.max_attempts,
286 delay_ms,
287 "Alpaca reconnect backoff"
288 );
289 tokio::time::sleep(Duration::from_millis(delay_ms)).await;
290 true
291 }
292}
293
294type SharedDedupCache = Arc<parking_lot::Mutex<LruCache<SmolStr, ()>>>;
317
318fn new_dedup_cache() -> SharedDedupCache {
319 #[allow(clippy::unwrap_used)]
322 Arc::new(parking_lot::Mutex::new(LruCache::new(
323 NonZeroUsize::new(DEDUP_CACHE_SIZE).unwrap(),
324 )))
325}
326
327fn is_duplicate(cache: &SharedDedupCache, key: &SmolStr) -> bool {
329 let mut guard = cache.lock();
330 if guard.peek(key).is_some() {
332 return true;
333 }
334 guard.put(key.clone(), ());
338 false
339}
340
341#[derive(Clone, Deserialize)]
348#[serde(deny_unknown_fields)]
349pub struct AlpacaConfig {
350 api_key: String,
352 secret_key: String,
353
354 #[serde(default = "default_paper")]
356 pub paper: bool,
357 #[cfg(test)]
359 pub base_url_override: Option<String>,
360}
361
362fn default_paper() -> bool {
368 true
369}
370
371impl std::fmt::Debug for AlpacaConfig {
373 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
374 f.debug_struct("AlpacaConfig")
375 .field("api_key", &"***")
376 .field("secret_key", &"***")
377 .field("paper", &self.paper)
378 .finish()
379 }
380}
381
382impl AlpacaConfig {
383 pub fn new(api_key: String, secret_key: String) -> Self {
386 Self::paper(api_key, secret_key)
387 }
388
389 pub fn paper(api_key: String, secret_key: String) -> Self {
391 Self {
392 api_key,
393 secret_key,
394 paper: true,
395 #[cfg(test)]
396 base_url_override: None,
397 }
398 }
399
400 pub fn production(api_key: String, secret_key: String) -> Self {
405 Self {
406 api_key,
407 secret_key,
408 paper: false,
409 #[cfg(test)]
410 base_url_override: None,
411 }
412 }
413
414 pub fn from_env() -> Result<Self, AlpacaConfigError> {
432 let api_key = match std::env::var("ALPACA_API_KEY") {
433 Ok(value) => value,
434 Err(std::env::VarError::NotPresent) => return Err(AlpacaConfigError::MissingApiKey),
435 Err(std::env::VarError::NotUnicode(_)) => {
436 return Err(AlpacaConfigError::InvalidApiKey);
437 }
438 };
439 let secret_key = match std::env::var("ALPACA_SECRET_KEY") {
440 Ok(value) => value,
441 Err(std::env::VarError::NotPresent) => return Err(AlpacaConfigError::MissingSecretKey),
442 Err(std::env::VarError::NotUnicode(_)) => {
443 return Err(AlpacaConfigError::InvalidSecretKey);
444 }
445 };
446
447 let paper = match std::env::var("ALPACA_PAPER") {
448 Ok(value) => parse_env_bool(&value).ok_or(AlpacaConfigError::InvalidPaper(value))?,
449 Err(std::env::VarError::NotPresent) => true,
450 Err(std::env::VarError::NotUnicode(value)) => {
453 return Err(AlpacaConfigError::InvalidPaper(
454 value.to_string_lossy().into_owned(),
455 ));
456 }
457 };
458
459 if paper {
460 Ok(Self::paper(api_key, secret_key))
461 } else {
462 Ok(Self::production(api_key, secret_key))
463 }
464 }
465
466 #[cfg(test)]
468 pub fn with_base_url(api_key: String, secret_key: String, base_url: String) -> Self {
469 Self {
470 api_key,
471 secret_key,
472 paper: true,
473 base_url_override: Some(base_url),
474 }
475 }
476
477 pub fn api_key(&self) -> &str {
478 &self.api_key
479 }
480
481 pub fn rest_base_url(&self) -> &str {
485 #[cfg(test)]
486 if let Some(ref url) = self.base_url_override {
487 return url.as_str();
488 }
489 if self.paper {
490 "https://paper-api.alpaca.markets"
491 } else {
492 "https://api.alpaca.markets"
493 }
494 }
495
496 pub fn ws_url(&self) -> &'static str {
498 if self.paper {
499 "wss://paper-api.alpaca.markets/stream"
500 } else {
501 "wss://api.alpaca.markets/stream"
502 }
503 }
504}
505
506#[derive(Debug, PartialEq, thiserror::Error)]
507pub enum AlpacaConfigError {
508 #[error("ALPACA_API_KEY environment variable not set")]
509 MissingApiKey,
510
511 #[error("ALPACA_API_KEY environment variable is not valid UTF-8")]
514 InvalidApiKey,
515
516 #[error("ALPACA_SECRET_KEY environment variable not set")]
517 MissingSecretKey,
518
519 #[error("ALPACA_SECRET_KEY environment variable is not valid UTF-8")]
520 InvalidSecretKey,
521
522 #[error("ALPACA_PAPER must be true or false, got {0}")]
523 InvalidPaper(String),
524}
525
526#[derive(Debug, Deserialize)]
531struct AlpacaAccount {
532 equity: String,
533 buying_power: String,
534 options_buying_power: Option<String>,
535 #[allow(dead_code)]
540 crypto_buying_power: Option<String>,
542}
543
544#[derive(Debug, Deserialize)]
546struct AlpacaPosition {
547 symbol: String,
549 asset_class: String,
551 qty: String,
553 qty_available: String,
555}
556
557#[derive(Debug, Deserialize)]
558struct AlpacaOrderResponse {
559 id: String,
560 client_order_id: Option<String>,
561 symbol: String,
562 qty: Option<String>,
563 filled_qty: String,
564 side: String,
565 #[serde(rename = "type")]
566 order_type: String,
567 time_in_force: String,
568 limit_price: Option<String>,
569 stop_price: Option<String>,
570 trail_percent: Option<String>,
571 trail_price: Option<String>,
572 created_at: String,
573}
574
575#[derive(Debug, Deserialize)]
576struct AlpacaActivity {
577 id: String,
578 order_id: String,
579 symbol: String,
580 side: String,
581 price: String,
582 qty: String,
583 transaction_time: String,
584}
585
586#[derive(Debug, Deserialize)]
587struct AlpacaApiError {
588 message: String,
589}
590
591#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
603#[serde(rename_all = "snake_case")]
604pub enum AlpacaPositionIntent {
605 BuyToOpen,
606 BuyToClose,
607 SellToOpen,
608 SellToClose,
609}
610
611#[derive(Debug, Serialize)]
619struct TakeProfitParams {
620 limit_price: String,
621}
622
623#[derive(Debug, Serialize)]
628struct StopLossParams {
629 stop_price: String,
630 #[serde(skip_serializing_if = "Option::is_none")]
631 limit_price: Option<String>,
632}
633
634#[non_exhaustive]
669#[derive(Debug, Clone)]
670pub struct AlpacaBracketOrderRequest {
671 pub instrument: InstrumentNameExchange,
673 pub strategy: StrategyId,
675 pub cid: ClientOrderId,
677 pub side: Side,
679 pub quantity: Decimal,
681 pub entry_price: Decimal,
683 pub take_profit_price: Decimal,
685 pub stop_loss_price: Decimal,
687 pub stop_loss_limit_price: Option<Decimal>,
689 pub time_in_force: TimeInForce,
691}
692
693impl AlpacaBracketOrderRequest {
694 #[allow(clippy::too_many_arguments)] pub fn new(
699 instrument: InstrumentNameExchange,
700 strategy: StrategyId,
701 cid: ClientOrderId,
702 side: Side,
703 quantity: Decimal,
704 entry_price: Decimal,
705 take_profit_price: Decimal,
706 stop_loss_price: Decimal,
707 time_in_force: TimeInForce,
708 ) -> Self {
709 Self {
710 instrument,
711 strategy,
712 cid,
713 side,
714 quantity,
715 entry_price,
716 take_profit_price,
717 stop_loss_price,
718 stop_loss_limit_price: None,
719 time_in_force,
720 }
721 }
722
723 #[must_use]
725 pub fn with_stop_loss_limit_price(mut self, price: Decimal) -> Self {
726 self.stop_loss_limit_price = Some(price);
727 self
728 }
729}
730
731#[non_exhaustive]
742#[derive(Debug, Clone)]
743pub struct AlpacaBracketOrderResult {
744 pub parent: Order<ExchangeId, InstrumentNameExchange, UnindexedOrderState>,
746}
747
748#[derive(Debug, Serialize)]
753struct AlpacaOrderRequest<'a> {
754 symbol: &'a str,
755 qty: String,
756 side: &'static str,
757 #[serde(rename = "type")]
758 order_type: &'static str,
759 time_in_force: &'static str,
760 #[serde(skip_serializing_if = "Option::is_none")]
761 limit_price: Option<String>,
762 #[serde(skip_serializing_if = "Option::is_none")]
763 stop_price: Option<String>,
764 #[serde(skip_serializing_if = "Option::is_none")]
765 trail_percent: Option<String>,
766 #[serde(skip_serializing_if = "Option::is_none")]
767 trail_price: Option<String>,
768 #[serde(skip_serializing_if = "Option::is_none")]
769 client_order_id: Option<&'a str>,
770 #[serde(skip_serializing_if = "Option::is_none")]
774 position_intent: Option<AlpacaPositionIntent>,
775 #[serde(skip_serializing_if = "Option::is_none")]
778 order_class: Option<&'static str>,
779 #[serde(skip_serializing_if = "Option::is_none")]
780 take_profit: Option<TakeProfitParams>,
781 #[serde(skip_serializing_if = "Option::is_none")]
782 stop_loss: Option<StopLossParams>,
783}
784
785#[derive(Debug, Deserialize)]
794struct AlpacaStreamMessage<'a> {
795 stream: SmolStr,
798 #[serde(borrow)]
799 data: &'a serde_json::value::RawValue,
800}
801
802#[derive(Debug, Deserialize)]
809struct AlpacaTradeUpdate<'a> {
810 event: SmolStr,
812 #[serde(borrow)]
813 order: AlpacaOrderWs<'a>,
814 #[serde(borrow)]
816 price: Option<&'a str>,
817 #[serde(borrow)]
819 qty: Option<&'a str>,
820 #[serde(borrow)]
821 timestamp: Option<&'a str>,
822}
823
824#[derive(Debug, Deserialize)]
826struct AlpacaOrderWs<'a> {
827 id: SmolStr,
830 client_order_id: Option<SmolStr>,
831 symbol: SmolStr,
834 #[serde(borrow)]
835 qty: Option<&'a str>,
836 #[serde(borrow)]
841 filled_qty: Option<&'a str>,
842 side: SmolStr,
845 #[serde(rename = "type")]
846 order_type: SmolStr,
847 time_in_force: SmolStr,
848 #[serde(borrow)]
849 limit_price: Option<&'a str>,
850 #[serde(borrow)]
851 stop_price: Option<&'a str>,
852 #[serde(borrow)]
853 trail_percent: Option<&'a str>,
854 #[serde(borrow)]
855 trail_price: Option<&'a str>,
856 status: SmolStr,
857}
858
859#[derive(Clone)]
875pub struct AlpacaClient {
876 config: Arc<AlpacaConfig>,
877 http: reqwest::Client,
880 rate_limiter: Arc<RateLimitTracker>,
881 orders_url: String,
883}
884
885impl std::fmt::Debug for AlpacaClient {
886 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
887 f.debug_struct("AlpacaClient")
888 .field("paper", &self.config.paper)
889 .finish_non_exhaustive()
890 }
891}
892
893impl AlpacaClient {
894 #[allow(clippy::expect_used)] fn build_http(config: &AlpacaConfig) -> reqwest::Client {
902 use reqwest::header::{HeaderMap, HeaderName, HeaderValue};
903 let mut headers = HeaderMap::new();
904 headers.insert(
906 HeaderName::from_static("apca-api-key-id"),
907 HeaderValue::from_str(&config.api_key)
908 .expect("Alpaca API key contains invalid header characters"),
909 );
910 headers.insert(
911 HeaderName::from_static("apca-api-secret-key"),
912 HeaderValue::from_str(&config.secret_key)
913 .expect("Alpaca secret key contains invalid header characters"),
914 );
915 reqwest::Client::builder()
916 .default_headers(headers)
917 .build()
918 .expect("failed to build reqwest client for Alpaca")
919 }
920
921 fn base_url(&self) -> &str {
922 self.config.rest_base_url()
923 }
924}
925
926fn parse_rate_limit_delay(headers: &reqwest::header::HeaderMap) -> Option<Duration> {
937 headers
938 .get("x-ratelimit-reset")
939 .and_then(|v| v.to_str().ok())
940 .and_then(|s| s.parse::<u64>().ok())
941 .map(|reset_ts| {
942 let now_secs = std::time::SystemTime::now()
943 .duration_since(std::time::UNIX_EPOCH)
944 .unwrap_or_default()
945 .as_secs();
946 Duration::from_secs(reset_ts.saturating_sub(now_secs).max(1))
947 })
948}
949
950async fn rest_with_retry<T>(
959 rate_limiter: &RateLimitTracker,
960 mut build_request: impl FnMut() -> reqwest::RequestBuilder,
961) -> Result<T, UnindexedClientError>
962where
963 T: for<'de> Deserialize<'de>,
964{
965 for attempt in 0..MAX_RATE_LIMIT_ATTEMPTS {
966 rate_limiter.wait_if_blocked().await;
967 let response = build_request()
968 .send()
969 .await
970 .map_err(|e| connectivity_err(format!("Alpaca REST request failed: {e}")))?;
971
972 if response
976 .headers()
977 .get("x-ratelimit-remaining")
978 .and_then(|v| v.to_str().ok())
979 .and_then(|s| s.parse::<u32>().ok())
980 == Some(0)
981 {
982 debug!("Alpaca REST rate-limit bucket exhausted (X-Ratelimit-Remaining: 0)");
983 }
984
985 let status = response.status();
986
987 if status == reqwest::StatusCode::TOO_MANY_REQUESTS {
988 let reset_delay = parse_rate_limit_delay(response.headers());
989
990 if attempt + 1 < MAX_RATE_LIMIT_ATTEMPTS {
991 warn!(
992 attempt = attempt + 1,
993 max_attempts = MAX_RATE_LIMIT_ATTEMPTS,
994 "Alpaca REST rate-limited (429), retrying"
995 );
996 rate_limiter.on_rate_limited(reset_delay);
997 continue;
998 }
999
1000 warn!(
1002 max_attempts = MAX_RATE_LIMIT_ATTEMPTS,
1003 "Alpaca REST rate-limit retries exhausted"
1004 );
1005 return Err(UnindexedClientError::Api(ApiError::RateLimit));
1006 }
1007
1008 if status == reqwest::StatusCode::NO_CONTENT {
1012 return Err(connectivity_err(
1013 "Alpaca REST returned 204 No Content — use rest_delete_with_retry for DELETE endpoints"
1014 .to_string(),
1015 ));
1016 }
1017
1018 let bytes = response
1019 .bytes()
1020 .await
1021 .map_err(|e| connectivity_err(format!("Alpaca REST read body failed: {e}")))?;
1022
1023 if status.is_success() {
1024 return serde_json::from_slice::<T>(&bytes).map_err(|e| {
1025 connectivity_err(format!(
1026 "Alpaca REST JSON parse error ({status}): {e} | body: {}",
1027 String::from_utf8_lossy(&bytes)
1028 .chars()
1029 .take(200)
1030 .collect::<String>()
1031 ))
1032 });
1033 }
1034
1035 let api_err = serde_json::from_slice::<AlpacaApiError>(&bytes)
1037 .map(|e| e.message)
1038 .unwrap_or_else(|_| String::from_utf8_lossy(&bytes).into_owned());
1039
1040 if status.is_client_error() {
1049 return Err(UnindexedClientError::Api(parse_api_error(status, &api_err)));
1050 }
1051 return Err(connectivity_err(format!(
1052 "Alpaca REST error {status}: {api_err}"
1053 )));
1054 }
1055 unreachable!("Alpaca REST retry loop exited without returning")
1056}
1057
1058async fn rest_delete_with_retry(
1062 rate_limiter: &RateLimitTracker,
1063 mut build_request: impl FnMut() -> reqwest::RequestBuilder,
1064) -> Result<(), UnindexedOrderError> {
1065 for attempt in 0..MAX_RATE_LIMIT_ATTEMPTS {
1066 rate_limiter.wait_if_blocked().await;
1067 let response = build_request().send().await.map_err(|e| {
1068 UnindexedOrderError::Connectivity(ConnectivityError::Socket(format!(
1069 "Alpaca cancel request failed: {e}"
1070 )))
1071 })?;
1072
1073 let status = response.status();
1074
1075 if status == reqwest::StatusCode::TOO_MANY_REQUESTS {
1076 let reset_delay = parse_rate_limit_delay(response.headers());
1077
1078 if attempt + 1 < MAX_RATE_LIMIT_ATTEMPTS {
1079 warn!(
1080 attempt = attempt + 1,
1081 max_attempts = MAX_RATE_LIMIT_ATTEMPTS,
1082 "Alpaca cancel rate-limited (429), retrying"
1083 );
1084 rate_limiter.on_rate_limited(reset_delay);
1085 continue;
1086 }
1087
1088 warn!(
1090 max_attempts = MAX_RATE_LIMIT_ATTEMPTS,
1091 "Alpaca cancel rate-limit retries exhausted"
1092 );
1093 return Err(UnindexedOrderError::Rejected(ApiError::RateLimit));
1094 }
1095
1096 if status == reqwest::StatusCode::NO_CONTENT || status.is_success() {
1098 return Ok(());
1099 }
1100
1101 let bytes = response
1102 .bytes()
1103 .await
1104 .inspect_err(
1105 |e| warn!(%e, %status, "Alpaca cancel_order: failed to read error response body"),
1106 )
1107 .unwrap_or_default();
1108 let msg = serde_json::from_slice::<AlpacaApiError>(&bytes)
1109 .map(|e| e.message)
1110 .unwrap_or_else(|_| String::from_utf8_lossy(&bytes).into_owned());
1111
1112 return Err(parse_order_error(status, &msg));
1113 }
1114 unreachable!("Alpaca cancel retry loop exited without returning")
1115}
1116
1117impl ExecutionClient for AlpacaClient {
1122 const EXCHANGE: ExchangeId = ExchangeId::AlpacaBroker;
1123 type Config = AlpacaConfig;
1124 type AccountStream = BoxStream<'static, UnindexedAccountEvent>;
1125
1126 fn new(config: Self::Config) -> Self {
1131 let http = Self::build_http(&config);
1132 let orders_url = format!("{}/v2/orders", config.rest_base_url());
1133 Self {
1134 config: Arc::new(config),
1135 http,
1136 rate_limiter: Arc::new(RateLimitTracker::new()),
1137 orders_url,
1138 }
1139 }
1140
1141 async fn account_snapshot(
1149 &self,
1150 assets: &[AssetNameExchange],
1151 instruments: &[InstrumentNameExchange],
1152 ) -> Result<UnindexedAccountSnapshot, UnindexedClientError> {
1153 let base = self.base_url();
1154 let http = self.http.clone();
1155 let rl = &self.rate_limiter;
1156
1157 let wants_usd = assets.is_empty()
1158 || assets
1159 .iter()
1160 .any(|a| a.name().as_str().eq_ignore_ascii_case("usd"));
1161 let wants_non_usd = assets.is_empty()
1162 || assets
1163 .iter()
1164 .any(|a| !a.name().as_str().eq_ignore_ascii_case("usd"));
1165
1166 let account_url = format!("{base}/v2/account");
1169 let positions_url = format!("{base}/v2/positions");
1170 let balances = match (wants_usd, wants_non_usd) {
1171 (true, true) => {
1172 let (account, positions): (AlpacaAccount, Vec<AlpacaPosition>) = tokio::try_join!(
1173 rest_with_retry(rl, || http.get(&account_url)),
1174 rest_with_retry(rl, || http.get(&positions_url)),
1175 )?;
1176 let mut balances = convert_account_to_balances(&account, assets);
1177 balances.extend(convert_positions_to_balances(&positions, assets));
1178 balances
1179 }
1180 (true, false) => {
1181 let account: AlpacaAccount = rest_with_retry(rl, || http.get(&account_url)).await?;
1182 convert_account_to_balances(&account, assets)
1183 }
1184 (false, true) => {
1185 let positions: Vec<AlpacaPosition> =
1186 rest_with_retry(rl, || http.get(&positions_url)).await?;
1187 convert_positions_to_balances(&positions, assets)
1188 }
1189 (false, false) => Vec::new(),
1190 };
1191
1192 let open_orders = fetch_raw_open_orders(&http, rl, base, instruments).await?;
1193
1194 let instrument_snapshots = build_instrument_snapshots(open_orders, instruments);
1196
1197 Ok(AccountSnapshot::new(
1198 ExchangeId::AlpacaBroker,
1199 balances,
1200 instrument_snapshots,
1201 ))
1202 }
1203
1204 async fn account_stream(
1257 &self,
1258 _assets: &[AssetNameExchange], instruments: &[InstrumentNameExchange],
1263 ) -> Result<Self::AccountStream, UnindexedClientError> {
1264 let initial_ws = connect_and_subscribe(&self.config).await?;
1267
1268 let (tx, rx) = mpsc::unbounded_channel::<UnindexedAccountEvent>();
1273 let dedup = new_dedup_cache();
1274 let config = self.config.clone();
1275 let http = self.http.clone();
1276 let rate_limiter = self.rate_limiter.clone();
1277 let instruments = instruments.to_vec();
1278
1279 let cm_handle = tokio::spawn(connection_manager(
1280 tx,
1281 dedup,
1282 config,
1283 http,
1284 rate_limiter,
1285 instruments,
1286 Some(initial_ws),
1287 ));
1288
1289 let rx_stream = tokio_stream::wrappers::UnboundedReceiverStream::new(rx);
1290 let guarded = GracefulShutdownStream::new(rx_stream, cm_handle);
1291 Ok(futures::StreamExt::boxed(guarded))
1292 }
1293
1294 async fn cancel_order(
1295 &self,
1296 request: OrderRequestCancel<ExchangeId, &InstrumentNameExchange>,
1297 ) -> Option<UnindexedOrderResponseCancel> {
1298 let key = crate::order::OrderKey {
1299 exchange: request.key.exchange,
1300 instrument: request.key.instrument.clone(),
1301 strategy: request.key.strategy.clone(),
1302 cid: request.key.cid.clone(),
1303 };
1304
1305 let order_id: SmolStr = match &request.state.id {
1309 Some(id) => id.0.clone(),
1310 None => {
1311 warn!(
1312 instrument = %key.instrument,
1313 "Alpaca cancel_order: no exchange order ID available (clientOrderId-only cancel not supported)"
1314 );
1315 return Some(crate::order::request::OrderResponseCancel {
1316 key,
1317 state: Err(UnindexedOrderError::Rejected(ApiError::OrderRejected(
1318 "exchange order ID required for cancel (fetch_open_orders to resolve)"
1319 .into(),
1320 ))),
1321 });
1322 }
1323 };
1324
1325 let base = self.base_url();
1326 let http = self.http.clone();
1327 let url = format!("{base}/v2/orders/{order_id}");
1328
1329 match rest_delete_with_retry(&self.rate_limiter, || http.delete(&url)).await {
1330 Ok(()) => {
1331 let exchange_order_id = OrderId(order_id);
1332 Some(crate::order::request::OrderResponseCancel {
1335 key,
1336 state: Ok(Cancelled::new(exchange_order_id, Utc::now(), Decimal::ZERO)),
1337 })
1338 }
1339 Err(e) => Some(crate::order::request::OrderResponseCancel { key, state: Err(e) }),
1340 }
1341 }
1342
1343 async fn open_order(
1364 &self,
1365 request: OrderRequestOpen<ExchangeId, &InstrumentNameExchange>,
1366 ) -> Option<Order<ExchangeId, InstrumentNameExchange, UnindexedOrderState>> {
1367 let side = request.state.side;
1368 let reduce_only = request.state.reduce_only;
1369 self.open_order_inner(request, map_position_intent(side, reduce_only))
1370 .await
1371 }
1372
1373 async fn fetch_balances(
1381 &self,
1382 assets: &[AssetNameExchange],
1383 ) -> Result<Vec<AssetBalance<AssetNameExchange>>, UnindexedClientError> {
1384 let base = self.base_url();
1385 let http = self.http.clone();
1386 let mut result = Vec::new();
1387
1388 let wants_usd = assets.is_empty()
1391 || assets
1392 .iter()
1393 .any(|a| a.name().as_str().eq_ignore_ascii_case("usd"));
1394 if wants_usd {
1395 let account_url = format!("{base}/v2/account");
1397 let account: AlpacaAccount =
1398 rest_with_retry(&self.rate_limiter, || http.get(&account_url)).await?;
1399 result.extend(convert_account_to_balances(&account, assets));
1400 }
1401
1402 let wants_non_usd = assets.is_empty()
1404 || assets
1405 .iter()
1406 .any(|a| !a.name().as_str().eq_ignore_ascii_case("usd"));
1407 if wants_non_usd {
1408 let positions_url = format!("{base}/v2/positions");
1410 let positions: Vec<AlpacaPosition> =
1411 rest_with_retry(&self.rate_limiter, || http.get(&positions_url)).await?;
1412 result.extend(convert_positions_to_balances(&positions, assets));
1413 }
1414
1415 Ok(result)
1416 }
1417
1418 async fn fetch_open_orders(
1419 &self,
1420 instruments: &[InstrumentNameExchange],
1421 ) -> Result<Vec<Order<ExchangeId, InstrumentNameExchange, Open>>, UnindexedClientError> {
1422 let base = self.base_url();
1423 let http = self.http.clone();
1424
1425 let open_orders =
1426 fetch_raw_open_orders(&http, &self.rate_limiter, base, instruments).await?;
1427
1428 let result = open_orders
1429 .into_iter()
1430 .filter_map(|o| convert_open_order(&o))
1431 .collect();
1432 Ok(result)
1433 }
1434
1435 async fn fetch_trades(
1436 &self,
1437 time_since: DateTime<Utc>,
1438 instruments: &[InstrumentNameExchange],
1439 ) -> Result<Vec<Trade<AssetNameExchange, InstrumentNameExchange>>, UnindexedClientError> {
1440 let after_str = time_since.to_rfc3339();
1441 let base = self.base_url();
1442 let http = self.http.clone();
1443
1444 let page = paginate_activities(&http, &self.rate_limiter, base, &after_str).await?;
1445
1446 if page.truncated {
1449 return Err(UnindexedClientError::Truncated {
1450 limit: MAX_ACTIVITY_PAGES,
1451 });
1452 }
1453
1454 let trades = if instruments.is_empty() {
1457 page.activities
1458 .into_iter()
1459 .filter_map(|a| convert_activity_to_trade(&a))
1460 .collect()
1461 } else {
1462 let instrument_set: fnv::FnvHashSet<&str> =
1463 instruments.iter().map(|i| i.name().as_str()).collect();
1464 page.activities
1465 .into_iter()
1466 .filter(|a| instrument_set.contains(a.symbol.as_str()))
1467 .filter_map(|a| convert_activity_to_trade(&a))
1468 .collect()
1469 };
1470
1471 Ok(trades)
1472 }
1473}
1474
1475impl AlpacaClient {
1480 pub async fn open_order_with_intent(
1493 &self,
1494 request: OrderRequestOpen<ExchangeId, &InstrumentNameExchange>,
1495 intent: AlpacaPositionIntent,
1496 ) -> Option<Order<ExchangeId, InstrumentNameExchange, UnindexedOrderState>> {
1497 self.open_order_inner(request, intent).await
1498 }
1499
1500 pub async fn open_bracket_order(
1533 &self,
1534 request: AlpacaBracketOrderRequest,
1535 ) -> AlpacaBracketOrderResult {
1536 let order_key = crate::order::OrderKey::new(
1537 ExchangeId::AlpacaBroker,
1538 request.instrument.clone(),
1539 request.strategy.clone(),
1540 request.cid.clone(),
1541 );
1542
1543 let tif_str = match request.time_in_force {
1545 TimeInForce::GoodUntilEndOfDay => "day",
1546 TimeInForce::GoodUntilCancelled { post_only } => {
1547 if post_only {
1548 return AlpacaBracketOrderResult {
1549 parent: Order {
1550 key: order_key,
1551 side: request.side,
1552 price: Some(request.entry_price),
1553 quantity: request.quantity,
1554 kind: OrderKind::Limit,
1555 time_in_force: request.time_in_force,
1556 state: OrderState::inactive(OrderError::Rejected(
1557 ApiError::OrderRejected(
1558 "Alpaca does not support post_only for bracket orders"
1559 .to_string(),
1560 ),
1561 )),
1562 },
1563 };
1564 }
1565 "gtc"
1566 }
1567 other => {
1568 return AlpacaBracketOrderResult {
1569 parent: Order {
1570 key: order_key,
1571 side: request.side,
1572 price: Some(request.entry_price),
1573 quantity: request.quantity,
1574 kind: OrderKind::Limit,
1575 time_in_force: request.time_in_force,
1576 state: OrderState::inactive(OrderError::Rejected(ApiError::OrderRejected(
1577 format!(
1578 "Alpaca bracket orders only support Day or GTC time_in_force, got {:?}",
1579 other
1580 ),
1581 ))),
1582 },
1583 };
1584 }
1585 };
1586
1587 let price_ordering_ok = match request.side {
1590 Side::Buy => {
1591 request.stop_loss_price < request.entry_price
1592 && request.entry_price < request.take_profit_price
1593 }
1594 Side::Sell => {
1595 request.take_profit_price < request.entry_price
1596 && request.entry_price < request.stop_loss_price
1597 }
1598 };
1599 if !price_ordering_ok {
1600 return AlpacaBracketOrderResult {
1601 parent: Order {
1602 key: order_key,
1603 side: request.side,
1604 price: Some(request.entry_price),
1605 quantity: request.quantity,
1606 kind: OrderKind::Limit,
1607 time_in_force: request.time_in_force,
1608 state: OrderState::inactive(OrderError::Rejected(ApiError::OrderRejected(
1609 format!(
1610 "Invalid bracket price ordering for {:?} side: entry={}, take_profit={}, stop_loss={}",
1611 request.side,
1612 request.entry_price,
1613 request.take_profit_price,
1614 request.stop_loss_price,
1615 ),
1616 ))),
1617 },
1618 };
1619 }
1620
1621 if let Some(sl_limit) = request.stop_loss_limit_price {
1625 let sl_limit_ok = match request.side {
1626 Side::Buy => sl_limit <= request.stop_loss_price,
1627 Side::Sell => sl_limit >= request.stop_loss_price,
1628 };
1629 if !sl_limit_ok {
1630 return AlpacaBracketOrderResult {
1631 parent: Order {
1632 key: order_key,
1633 side: request.side,
1634 price: Some(request.entry_price),
1635 quantity: request.quantity,
1636 kind: OrderKind::Limit,
1637 time_in_force: request.time_in_force,
1638 state: OrderState::inactive(OrderError::Rejected(ApiError::OrderRejected(
1639 format!(
1640 "Invalid stop-loss limit price for {:?} bracket: \
1641 stop_loss_price={}, stop_loss_limit_price={}",
1642 request.side, request.stop_loss_price, sl_limit,
1643 ),
1644 ))),
1645 },
1646 };
1647 }
1648 }
1649
1650 let body = AlpacaOrderRequest {
1652 symbol: request.instrument.name().as_str(),
1653 qty: request.quantity.to_string(),
1654 side: map_side(request.side),
1655 order_type: "limit",
1656 time_in_force: tif_str,
1657 limit_price: Some(request.entry_price.to_string()),
1658 stop_price: None,
1659 trail_percent: None,
1660 trail_price: None,
1661 client_order_id: Some(request.cid.0.as_str()),
1662 position_intent: if is_options_or_equity_symbol(request.instrument.name().as_str()) {
1663 Some(map_position_intent(request.side, false))
1664 } else {
1665 None
1666 },
1667 order_class: Some("bracket"),
1668 take_profit: Some(TakeProfitParams {
1669 limit_price: request.take_profit_price.to_string(),
1670 }),
1671 stop_loss: Some(StopLossParams {
1672 stop_price: request.stop_loss_price.to_string(),
1673 limit_price: request.stop_loss_limit_price.map(|p| p.to_string()),
1674 }),
1675 };
1676
1677 let http = self.http.clone();
1678 let rl = &self.rate_limiter;
1679
1680 let result: Result<AlpacaOrderResponse, UnindexedClientError> =
1681 rest_with_retry(rl, || http.post(&self.orders_url).json(&body)).await;
1682
1683 match result {
1684 Ok(resp) => {
1685 let exchange_order_id = OrderId(SmolStr::new(&resp.id));
1686 let time_exchange = parse_timestamp(&resp.created_at).unwrap_or_else(Utc::now);
1687 let filled_qty = Decimal::from_str(&resp.filled_qty).unwrap_or(Decimal::ZERO);
1688
1689 let state = if filled_qty >= request.quantity {
1690 OrderState::fully_filled(Filled::new(
1691 exchange_order_id,
1692 time_exchange,
1693 filled_qty,
1694 None,
1695 ))
1696 } else {
1697 OrderState::active(Open::new(exchange_order_id, time_exchange, filled_qty))
1698 };
1699
1700 AlpacaBracketOrderResult {
1701 parent: Order {
1702 key: order_key,
1703 side: request.side,
1704 price: Some(request.entry_price),
1705 quantity: request.quantity,
1706 kind: OrderKind::Limit,
1707 time_in_force: request.time_in_force,
1708 state,
1709 },
1710 }
1711 }
1712 Err(e) => {
1713 let order_err = match e {
1714 UnindexedClientError::Connectivity(ce) => OrderError::Connectivity(ce),
1715 UnindexedClientError::Api(ae) => OrderError::Rejected(ae),
1716 UnindexedClientError::TaskFailed(_)
1717 | UnindexedClientError::Internal(_)
1718 | UnindexedClientError::Truncated { .. }
1719 | UnindexedClientError::TruncatedSnapshot { .. } => {
1720 unreachable!("rest_with_retry (order path) does not produce these variants")
1721 }
1722 };
1723 AlpacaBracketOrderResult {
1724 parent: Order {
1725 key: order_key,
1726 side: request.side,
1727 price: Some(request.entry_price),
1728 quantity: request.quantity,
1729 kind: OrderKind::Limit,
1730 time_in_force: request.time_in_force,
1731 state: OrderState::inactive(order_err),
1732 },
1733 }
1734 }
1735 }
1736 }
1737
1738 async fn open_order_inner(
1739 &self,
1740 request: OrderRequestOpen<ExchangeId, &InstrumentNameExchange>,
1741 intent: AlpacaPositionIntent,
1742 ) -> Option<Order<ExchangeId, InstrumentNameExchange, UnindexedOrderState>> {
1743 let instrument = request.key.instrument.clone();
1744 let side = request.state.side;
1745 let price = request.state.price;
1746 let quantity = request.state.quantity;
1747 let kind = request.state.kind;
1748 let time_in_force = request.state.time_in_force;
1749 let cid = request.key.cid.clone();
1750
1751 let order_key = crate::order::OrderKey::new(
1752 ExchangeId::AlpacaBroker,
1753 instrument.clone(),
1754 request.key.strategy.clone(),
1755 cid.clone(),
1756 );
1757
1758 let tif_str = match map_time_in_force(time_in_force) {
1760 Ok(s) => s,
1761 Err(msg) => {
1762 return Some(Order {
1763 key: order_key,
1764 side,
1765 price,
1766 quantity,
1767 kind,
1768 time_in_force,
1769 state: OrderState::inactive(OrderError::Rejected(ApiError::OrderRejected(
1770 msg.to_string(),
1771 ))),
1772 });
1773 }
1774 };
1775
1776 let order_type_str = match map_order_kind(kind) {
1778 Some(s) => s,
1779 None => {
1780 return Some(Order {
1781 key: order_key,
1782 side,
1783 price,
1784 quantity,
1785 kind,
1786 time_in_force,
1787 state: OrderState::inactive(OrderError::UnsupportedOrderType(format!(
1788 "Alpaca connector does not yet support OrderKind::{kind:?}"
1789 ))),
1790 });
1791 }
1792 };
1793
1794 if let OrderKind::TrailingStop {
1796 offset_type: TrailingOffsetType::BasisPoints,
1797 ..
1798 } = kind
1799 {
1800 return Some(Order {
1801 key: order_key,
1802 side,
1803 price,
1804 quantity,
1805 kind,
1806 time_in_force,
1807 state: OrderState::inactive(OrderError::UnsupportedOrderType(
1808 "Alpaca does not support TrailingOffsetType::BasisPoints; \
1809 use Percentage or Absolute"
1810 .to_string(),
1811 )),
1812 });
1813 }
1814
1815 if matches!(kind, OrderKind::StopLimit { .. }) && price.is_none() {
1818 return Some(Order {
1819 key: order_key,
1820 side,
1821 price,
1822 quantity,
1823 kind,
1824 time_in_force,
1825 state: OrderState::inactive(OrderError::Rejected(ApiError::OrderRejected(
1826 "StopLimit order requires Order.price (the limit price) to be set".to_string(),
1827 ))),
1828 });
1829 }
1830
1831 let (stop_price, trail_percent, trail_price) = match kind {
1833 OrderKind::Stop { trigger_price } | OrderKind::StopLimit { trigger_price } => {
1834 (Some(trigger_price.to_string()), None, None)
1835 }
1836 OrderKind::TrailingStop {
1837 offset,
1838 offset_type,
1839 } => match offset_type {
1840 TrailingOffsetType::Percentage => (None, Some(offset.to_string()), None),
1841 TrailingOffsetType::Absolute => (None, None, Some(offset.to_string())),
1842 TrailingOffsetType::BasisPoints => unreachable!("validated above"),
1843 },
1844 OrderKind::Market
1846 | OrderKind::Limit
1847 | OrderKind::TakeProfit { .. }
1848 | OrderKind::TakeProfitLimit { .. }
1849 | OrderKind::TrailingStopLimit { .. } => (None, None, None),
1850 };
1851
1852 let body = AlpacaOrderRequest {
1853 symbol: instrument.name().as_str(),
1854 qty: quantity.to_string(),
1855 side: map_side(side),
1856 order_type: order_type_str,
1857 time_in_force: tif_str,
1858 limit_price: match kind {
1859 OrderKind::Limit | OrderKind::StopLimit { .. } => price.map(|p| p.to_string()),
1860 _ => None,
1861 },
1862 stop_price,
1863 trail_percent,
1864 trail_price,
1865 client_order_id: Some(cid.0.as_str()),
1866 position_intent: if is_options_or_equity_symbol(instrument.name().as_str()) {
1872 Some(intent)
1873 } else {
1874 None
1875 },
1876 order_class: None,
1878 take_profit: None,
1879 stop_loss: None,
1880 };
1881
1882 let http = self.http.clone();
1883 let rl = &self.rate_limiter;
1884
1885 let result: Result<AlpacaOrderResponse, UnindexedClientError> =
1886 rest_with_retry(rl, || http.post(&self.orders_url).json(&body)).await;
1887
1888 match result {
1889 Ok(resp) => {
1890 let exchange_order_id = OrderId(SmolStr::new(&resp.id));
1891 let time_exchange = parse_timestamp(&resp.created_at).unwrap_or_else(Utc::now);
1892 let filled_qty = Decimal::from_str(&resp.filled_qty).unwrap_or(Decimal::ZERO);
1893
1894 let state = if filled_qty >= quantity {
1895 OrderState::fully_filled(Filled::new(
1897 exchange_order_id,
1898 time_exchange,
1899 filled_qty,
1900 None, ))
1902 } else {
1903 OrderState::active(Open::new(exchange_order_id, time_exchange, filled_qty))
1905 };
1906
1907 Some(Order {
1908 key: order_key,
1909 side,
1910 price,
1911 quantity,
1912 kind,
1913 time_in_force,
1914 state,
1915 })
1916 }
1917 Err(e) => {
1918 let order_err = match e {
1919 UnindexedClientError::Connectivity(ce) => OrderError::Connectivity(ce),
1920 UnindexedClientError::Api(ae) => OrderError::Rejected(ae),
1921 UnindexedClientError::TaskFailed(_)
1928 | UnindexedClientError::Internal(_)
1929 | UnindexedClientError::Truncated { .. }
1930 | UnindexedClientError::TruncatedSnapshot { .. } => {
1931 unreachable!(
1932 "rest_with_retry (order path) does not produce TaskFailed/Internal/Truncated/TruncatedSnapshot variants"
1933 )
1934 }
1935 };
1936 Some(Order {
1937 key: order_key,
1938 side,
1939 price,
1940 quantity,
1941 kind,
1942 time_in_force,
1943 state: OrderState::inactive(order_err),
1944 })
1945 }
1946 }
1947 }
1948}
1949
1950const MAX_OPEN_ORDERS: usize = 500;
1958
1959async fn fetch_raw_open_orders(
1967 http: &reqwest::Client,
1968 rate_limiter: &RateLimitTracker,
1969 base: &str,
1970 instruments: &[InstrumentNameExchange],
1971) -> Result<Vec<AlpacaOrderResponse>, UnindexedClientError> {
1972 let orders: Vec<AlpacaOrderResponse> = if instruments.is_empty() {
1973 rest_with_retry(rate_limiter, || {
1974 http.get(format!("{base}/v2/orders"))
1975 .query(&[("status", "open"), ("limit", "500")])
1976 })
1977 .await?
1978 } else {
1979 let symbols = instruments.iter().map(|i| i.name().as_str()).join(",");
1980 rest_with_retry(rate_limiter, || {
1981 http.get(format!("{base}/v2/orders")).query(&[
1982 ("status", "open"),
1983 ("limit", "500"),
1984 ("symbols", &symbols),
1985 ])
1986 })
1987 .await?
1988 };
1989 if orders.len() == MAX_OPEN_ORDERS {
1990 warn!(
1991 limit = MAX_OPEN_ORDERS,
1992 "Alpaca fetch_raw_open_orders: received exactly {MAX_OPEN_ORDERS} results — \
1993 response is likely truncated"
1994 );
1995 return Err(UnindexedClientError::TruncatedSnapshot {
1996 limit: MAX_OPEN_ORDERS,
1997 });
1998 }
1999 Ok(orders)
2000}
2001
2002const MAX_ACTIVITY_PAGES: usize = 50;
2011
2012struct ActivityPage {
2018 activities: Vec<AlpacaActivity>,
2019 truncated: bool,
2020}
2021
2022const PAGE_SIZE_STR: &str = "100"; const _: () = assert!(
2034 ALPACA_MAX_ACTIVITIES == 100,
2035 "PAGE_SIZE_STR must be updated to match ALPACA_MAX_ACTIVITIES",
2036);
2037
2038async fn paginate_activities(
2039 http: &reqwest::Client,
2040 rate_limiter: &RateLimitTracker,
2041 base: &str,
2042 after: &str,
2043) -> Result<ActivityPage, UnindexedClientError> {
2044 let mut all = Vec::with_capacity(ALPACA_MAX_ACTIVITIES);
2045 let mut page_token: Option<String> = None;
2046 let mut pages = 0usize;
2047 let mut truncated = false;
2048
2049 loop {
2050 if pages >= MAX_ACTIVITY_PAGES {
2051 truncated = true;
2052 break;
2053 }
2054 pages += 1;
2055 let page_token_ref = page_token.as_deref();
2057 let activities: Vec<AlpacaActivity> = rest_with_retry(rate_limiter, || {
2058 let mut req = http.get(format!("{base}/v2/account/activities")).query(&[
2059 ("activity_type", "FILL"),
2060 ("after", after),
2061 ("page_size", PAGE_SIZE_STR),
2062 ("direction", "asc"),
2063 ]);
2064 if let Some(token) = page_token_ref {
2065 req = req.query(&[("page_token", token)]);
2066 }
2067 req
2068 })
2069 .await?;
2070
2071 let page_len = activities.len();
2072 let page_token_candidate = activities.last().map(|a| a.id.clone());
2082 all.extend(activities);
2083
2084 if page_len < ALPACA_MAX_ACTIVITIES {
2085 break;
2086 }
2087 match page_token_candidate {
2088 Some(token) if !token.is_empty() => {
2089 debug!("Alpaca paginate_activities: fetching next page ({page_len} results)");
2090 page_token = Some(token);
2091 }
2092 _ => break,
2096 }
2097 }
2098
2099 Ok(ActivityPage {
2100 activities: all,
2101 truncated,
2102 })
2103}
2104
2105#[allow(clippy::cognitive_complexity)] async fn connection_manager(
2121 tx: mpsc::UnboundedSender<UnindexedAccountEvent>,
2122 dedup: SharedDedupCache,
2123 config: Arc<AlpacaConfig>,
2124 http: reqwest::Client,
2125 rate_limiter: Arc<RateLimitTracker>,
2126 instruments: Vec<InstrumentNameExchange>,
2127 initial_ws: Option<WebSocket>,
2128) {
2129 let mut backoff = ExponentialBackoff::new();
2130 let mut disconnect_time: Option<DateTime<Utc>> = None;
2131 let mut current_ws = initial_ws;
2132
2133 'outer: loop {
2134 let mut ws = match current_ws.take() {
2136 Some(ws) => ws,
2137 None => match connect_and_subscribe(&config).await {
2138 Ok(ws) => ws,
2139 Err(e) => {
2140 error!(%e, "Alpaca WS connect/subscribe failed");
2141 if !backoff.wait().await {
2142 error!("Alpaca max reconnect attempts exhausted");
2143 emit_stream_terminated(
2145 &tx,
2146 ExchangeId::AlpacaBroker,
2147 StreamTerminationReason::ReconnectBudgetExhausted {
2148 attempts: backoff.attempts(),
2149 last_error: e.to_string(),
2150 },
2151 );
2152 break;
2153 }
2154 continue;
2155 }
2156 },
2157 };
2158 info!("Alpaca account_stream connected and subscribed");
2159 backoff.reset();
2170
2171 if let Some(dt) = disconnect_time.take() {
2176 let base = config.rest_base_url();
2177 let after_str = dt.to_rfc3339();
2178 match tokio::time::timeout(
2179 Duration::from_secs(FILL_RECOVERY_TIMEOUT_SECS),
2180 recover_fills(
2181 &http,
2182 &rate_limiter,
2183 &instruments,
2184 base,
2185 &after_str,
2186 &tx,
2187 &dedup,
2188 ),
2189 )
2190 .await
2191 {
2192 Ok(()) => {}
2193 Err(_) => warn!(
2194 timeout_secs = FILL_RECOVERY_TIMEOUT_SECS,
2195 "Alpaca fill recovery timed out — some fills may be missing"
2196 ),
2197 }
2198 }
2199
2200 let mut last_message_time = Utc::now();
2213 let heartbeat = tokio::time::sleep(Duration::from_secs(HEARTBEAT_TIMEOUT_SECS));
2214 tokio::pin!(heartbeat);
2215
2216 macro_rules! reset_heartbeat {
2221 () => {
2222 heartbeat.as_mut().reset(
2223 tokio::time::Instant::now() + Duration::from_secs(HEARTBEAT_TIMEOUT_SECS),
2224 );
2225 last_message_time = Utc::now();
2226 };
2227 }
2228 enum DisconnectReason {
2231 ServerClose,
2232 Error(String),
2233 StreamEnded,
2234 HeartbeatTimeout,
2235 }
2236 let reason = loop {
2237 tokio::select! {
2238 msg = ws.next() => {
2239 match msg {
2240 Some(Ok(WsMessage::Ping(_))) => {
2241 reset_heartbeat!();
2244 }
2245 Some(Ok(WsMessage::Text(text))) => {
2246 process_ws_text(text.as_str(), &tx, &dedup, &mut backoff);
2247 reset_heartbeat!();
2248 }
2249 Some(Ok(WsMessage::Binary(bytes))) => {
2250 match std::str::from_utf8(&bytes) {
2252 Ok(text) => {
2253 process_ws_text(text, &tx, &dedup, &mut backoff);
2254 reset_heartbeat!();
2258 }
2259 Err(e) => warn!(%e, "Alpaca WS binary frame: not valid UTF-8"),
2260 }
2261 }
2262 Some(Ok(WsMessage::Close(frame))) => {
2263 warn!(frame = ?frame, "Alpaca WS closed by server");
2264 break DisconnectReason::ServerClose;
2265 }
2266 Some(Ok(_)) => {} Some(Err(e)) => {
2268 warn!(%e, "Alpaca WS error, reconnecting");
2269 break DisconnectReason::Error(e.to_string());
2270 }
2271 None => {
2272 warn!("Alpaca WS stream ended, reconnecting");
2273 break DisconnectReason::StreamEnded;
2274 }
2275 }
2276 }
2277 _ = &mut heartbeat => {
2278 warn!(
2279 timeout_secs = HEARTBEAT_TIMEOUT_SECS,
2280 "Alpaca heartbeat timeout, reconnecting"
2281 );
2282 break DisconnectReason::HeartbeatTimeout;
2285 }
2286 _ = tx.closed() => {
2287 debug!("Alpaca account_stream consumer dropped, terminating");
2288 let _ = tokio::time::timeout(
2289 Duration::from_secs(WS_CLOSE_TIMEOUT_SECS),
2290 ws.close(None),
2291 ).await;
2292 break 'outer;
2295 }
2296 }
2297 };
2298
2299 disconnect_time =
2304 Some(last_message_time - chrono::Duration::milliseconds(SIGNAL_RECOVERY_LOOKBACK_MS));
2305
2306 let _ =
2308 tokio::time::timeout(Duration::from_secs(WS_CLOSE_TIMEOUT_SECS), ws.close(None)).await;
2309
2310 if tx.is_closed() {
2311 break;
2314 }
2315 if !backoff.wait().await {
2316 error!("Alpaca max reconnect attempts exhausted, stream terminating");
2317 let last_error = match reason {
2320 DisconnectReason::HeartbeatTimeout => {
2321 format!("heartbeat timeout ({HEARTBEAT_TIMEOUT_SECS}s)")
2322 }
2323 DisconnectReason::ServerClose => "WebSocket closed by server".to_string(),
2324 DisconnectReason::Error(e) => e,
2325 DisconnectReason::StreamEnded => "WebSocket stream ended".to_string(),
2326 };
2327 emit_stream_terminated(
2328 &tx,
2329 ExchangeId::AlpacaBroker,
2330 StreamTerminationReason::ReconnectBudgetExhausted {
2331 attempts: backoff.attempts(),
2332 last_error,
2333 },
2334 );
2335 break;
2336 }
2337 }
2338}
2339
2340#[derive(Debug)]
2345enum HandshakeError {
2346 Transport(String),
2349 Auth(String),
2352}
2353
2354async fn connect_and_subscribe(config: &AlpacaConfig) -> Result<WebSocket, UnindexedClientError> {
2358 let url = config.ws_url();
2359 debug!(%url, "Alpaca: connecting to WebSocket");
2360
2361 let mut ws = rustrade_integration::protocol::websocket::connect(url)
2362 .await
2363 .map_err(|e| {
2364 UnindexedClientError::Connectivity(ConnectivityError::Socket(format!(
2365 "WS connect: {e}"
2366 )))
2367 })?;
2368
2369 let result = tokio::time::timeout(
2371 Duration::from_secs(WS_HANDSHAKE_TIMEOUT_SECS),
2372 ws_handshake(&mut ws, config),
2373 )
2374 .await;
2375
2376 match result {
2377 Ok(Ok(())) => Ok(ws),
2378 Ok(Err(HandshakeError::Transport(e))) => {
2379 let _ = ws.close(None).await;
2380 Err(UnindexedClientError::Connectivity(
2381 ConnectivityError::Socket(e),
2382 ))
2383 }
2384 Ok(Err(HandshakeError::Auth(e))) => {
2385 let _ = ws.close(None).await;
2386 Err(UnindexedClientError::Api(ApiError::Unauthenticated(e)))
2387 }
2388 Err(_) => {
2389 let _ = ws.close(None).await;
2390 Err(UnindexedClientError::Connectivity(
2391 ConnectivityError::Timeout,
2392 ))
2393 }
2394 }
2395}
2396
2397async fn ws_handshake(ws: &mut WebSocket, config: &AlpacaConfig) -> Result<(), HandshakeError> {
2405 let auth = serde_json::json!({
2407 "action": "auth",
2408 "key": config.api_key(),
2409 "secret": config.secret_key,
2412 })
2413 .to_string();
2414 ws.send(WsMessage::Text(auth.into()))
2415 .await
2416 .map_err(|e| HandshakeError::Transport(format!("WS auth send: {e}")))?;
2417
2418 loop {
2420 match ws.next().await {
2421 Some(Ok(WsMessage::Text(text))) => {
2422 if let Some(result) = check_auth_response(text.as_str()) {
2423 result?;
2424 break;
2425 }
2426 }
2427 Some(Ok(WsMessage::Binary(bytes))) => {
2428 if let Ok(text) = std::str::from_utf8(&bytes)
2429 && let Some(result) = check_auth_response(text)
2430 {
2431 result?;
2432 break;
2433 }
2434 }
2435 Some(Err(e)) => {
2436 return Err(HandshakeError::Transport(format!(
2437 "WS error during auth: {e}"
2438 )));
2439 }
2440 None => {
2441 return Err(HandshakeError::Transport(
2442 "WS closed before auth response".into(),
2443 ));
2444 }
2445 _ => {} }
2447 }
2448
2449 let sub = serde_json::json!({
2451 "action": "listen",
2452 "data": { "streams": ["trade_updates"] }
2453 })
2454 .to_string();
2455 ws.send(WsMessage::Text(sub.into()))
2456 .await
2457 .map_err(|e| HandshakeError::Transport(format!("WS subscribe send: {e}")))?;
2458
2459 loop {
2461 match ws.next().await {
2462 Some(Ok(WsMessage::Text(text))) => {
2463 if check_listen_ack(text.as_str()) {
2464 break;
2465 }
2466 if let Ok(msg) = serde_json::from_str::<AlpacaStreamMessage<'_>>(text.as_str()) {
2472 if msg.stream == "trade_updates" {
2473 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");
2474 } else {
2475 trace!(stream = %msg.stream, "WS message dropped during listen-ack handshake");
2476 }
2477 } else {
2478 trace!(
2479 bytes = text.len(),
2480 "WS non-stream message dropped during listen-ack handshake"
2481 );
2482 }
2483 }
2484 Some(Ok(WsMessage::Binary(bytes))) => {
2485 if let Ok(text) = std::str::from_utf8(&bytes)
2486 && check_listen_ack(text)
2487 {
2488 break;
2489 }
2490 trace!(
2491 bytes = bytes.len(),
2492 "WS binary message dropped during listen-ack handshake"
2493 );
2494 }
2495 Some(Err(e)) => {
2496 return Err(HandshakeError::Transport(format!(
2497 "WS error during subscribe: {e}"
2498 )));
2499 }
2500 None => {
2501 return Err(HandshakeError::Transport(
2502 "WS closed before subscribe ack".into(),
2503 ));
2504 }
2505 _ => {}
2506 }
2507 }
2508
2509 info!("Alpaca WS authenticated and subscribed to trade_updates");
2510 Ok(())
2511}
2512
2513fn check_auth_response(text: &str) -> Option<Result<(), HandshakeError>> {
2519 let msg = serde_json::from_str::<AlpacaStreamMessage<'_>>(text).ok()?;
2520 if msg.stream != "authorization" {
2521 return None;
2522 }
2523 #[derive(Deserialize)]
2524 struct AuthData<'a> {
2525 status: &'a str,
2526 }
2527 let data = serde_json::from_str::<AuthData<'_>>(msg.data.get()).ok()?;
2528 if data.status == "authorized" {
2529 Some(Ok(()))
2530 } else {
2531 Some(Err(HandshakeError::Auth(format!(
2532 "Alpaca WS auth failed: status={}",
2533 data.status
2534 ))))
2535 }
2536}
2537
2538fn check_listen_ack(text: &str) -> bool {
2542 let Ok(msg) = serde_json::from_str::<AlpacaStreamMessage<'_>>(text) else {
2543 return false;
2544 };
2545 if msg.stream != "listening" {
2546 return false;
2547 }
2548 #[derive(Deserialize)]
2549 struct ListenData<'a> {
2550 #[serde(borrow)]
2551 streams: Vec<&'a str>,
2552 }
2553 let Ok(data) = serde_json::from_str::<ListenData<'_>>(msg.data.get()) else {
2554 return false;
2555 };
2556 data.streams.contains(&"trade_updates")
2557}
2558
2559fn process_ws_text(
2565 text: &str,
2566 tx: &mpsc::UnboundedSender<UnindexedAccountEvent>,
2567 dedup: &SharedDedupCache,
2568 backoff: &mut ExponentialBackoff,
2569) {
2570 let msg: AlpacaStreamMessage<'_> = match serde_json::from_str(text) {
2571 Ok(m) => m,
2572 Err(e) => {
2573 trace!(
2574 %e,
2575 raw = ?&text[..text.len().min(200)],
2576 "Alpaca WS: skipped non-JSON message"
2577 );
2578 return;
2579 }
2580 };
2581
2582 backoff.reset();
2585
2586 match msg.stream.as_str() {
2587 "trade_updates" => {
2588 let update: AlpacaTradeUpdate<'_> = match serde_json::from_str(msg.data.get()) {
2589 Ok(u) => u,
2590 Err(e) => {
2591 warn!(
2596 %e,
2597 raw = ?&msg.data.get()[..msg.data.get().len().min(200)],
2598 "Alpaca WS trade_updates: failed to deserialize event — event dropped"
2599 );
2600 return;
2601 }
2602 };
2603
2604 if is_fill_event(&update) {
2608 let key = early_dedup_key(&update);
2609 if is_duplicate(dedup, &key) {
2610 trace!("Alpaca WS: skipping duplicate fill event (early check)");
2611 return;
2612 }
2613 }
2614
2615 if let Some(event) = convert_trade_update(update) {
2616 let _ = tx.send(event);
2619 }
2620 }
2621 "authorization" | "listening" => {
2622 trace!(stream = %msg.stream, "Alpaca WS: auth/listen ack received during stream");
2625 }
2626 other => {
2627 trace!(%other, "Alpaca WS: ignoring unknown stream type");
2628 }
2629 }
2630}
2631
2632fn fill_dedup_key_from_event(event: &UnindexedAccountEvent) -> Option<&SmolStr> {
2639 match &event.kind {
2640 AccountEventKind::Trade(trade) => Some(&trade.id.0),
2642 _ => None,
2643 }
2644}
2645
2646#[inline]
2650fn is_fill_event(update: &AlpacaTradeUpdate<'_>) -> bool {
2651 matches!(update.event.as_str(), "fill" | "partial_fill")
2652}
2653
2654fn early_dedup_key(update: &AlpacaTradeUpdate<'_>) -> SmolStr {
2664 let filled_qty = update.order.filled_qty.unwrap_or("0");
2665 let qty = Decimal::from_str(filled_qty).unwrap_or(Decimal::ZERO);
2668 format_smolstr!("{}:{}", update.order.id, qty.normalize())
2669}
2670
2671async fn recover_fills(
2677 http: &reqwest::Client,
2678 rate_limiter: &RateLimitTracker,
2679 instruments: &[InstrumentNameExchange],
2680 base: &str,
2681 after: &str,
2682 tx: &mpsc::UnboundedSender<UnindexedAccountEvent>,
2683 dedup: &SharedDedupCache,
2684) {
2685 info!(%after, instruments = instruments.len(), "Alpaca recovering fills after reconnect");
2688 let instrument_set: fnv::FnvHashSet<&str> = if instruments.is_empty() {
2689 fnv::FnvHashSet::default()
2690 } else {
2691 instruments.iter().map(|i| i.name().as_str()).collect()
2692 };
2693
2694 let page = match paginate_activities(http, rate_limiter, base, after).await {
2695 Ok(p) => p,
2696 Err(e) => {
2697 error!(%e, "Alpaca fill recovery: REST request failed");
2698 return;
2699 }
2700 };
2701
2702 if page.truncated {
2706 error!(
2707 max_pages = MAX_ACTIVITY_PAGES,
2708 "Alpaca fill recovery: max page limit reached, truncating — \
2709 fills from this outage are permanently lost. Manual reconciliation required."
2710 );
2711 }
2712
2713 let activities = page.activities;
2714
2715 let mut recovered = 0u32;
2716 let mut duplicates = 0u32;
2717
2718 let mut cumulative_qty: FnvHashMap<&str, Decimal> = FnvHashMap::default();
2731
2732 for activity in &activities {
2733 if !instrument_set.is_empty() && !instrument_set.contains(activity.symbol.as_str()) {
2734 continue;
2738 }
2739
2740 let exec_qty = Decimal::from_str(&activity.qty).unwrap_or(Decimal::ZERO);
2755 let cum = cumulative_qty
2756 .entry(activity.order_id.as_str())
2757 .or_default();
2758 *cum += exec_qty;
2759 let cumulative = *cum;
2760
2761 let mut trade = match convert_activity_to_trade(activity) {
2762 Some(t) => t,
2763 None => {
2764 warn!(id = %activity.id, symbol = %activity.symbol, "Alpaca: skipping activity with unparseable fields");
2765 continue; }
2767 };
2768
2769 trade.id = TradeId(format_smolstr!(
2774 "{}:{}",
2775 activity.order_id,
2776 cumulative.normalize()
2777 ));
2778
2779 let event =
2780 UnindexedAccountEvent::new(ExchangeId::AlpacaBroker, AccountEventKind::Trade(trade));
2781
2782 if fill_dedup_key_from_event(&event).is_some_and(|k| is_duplicate(dedup, k)) {
2784 duplicates += 1;
2785 continue;
2786 }
2787 if tx.send(event).is_err() {
2788 debug!("Alpaca fill recovery: consumer dropped during recovery");
2789 return;
2790 }
2791 recovered += 1;
2792 }
2793
2794 info!(recovered, duplicates, "Alpaca fill recovery complete");
2795}
2796
2797fn convert_account_to_balances(
2810 account: &AlpacaAccount,
2811 assets: &[AssetNameExchange],
2812) -> Vec<AssetBalance<AssetNameExchange>> {
2813 let usd_entry = assets
2816 .iter()
2817 .find(|a| a.name().as_str().eq_ignore_ascii_case("usd"));
2818
2819 if !assets.is_empty() && usd_entry.is_none() {
2821 return Vec::new();
2822 }
2823
2824 let usd_name = usd_entry
2825 .cloned()
2826 .unwrap_or_else(|| AssetNameExchange::new("usd"));
2827
2828 let total = Decimal::from_str(&account.equity).unwrap_or(Decimal::ZERO);
2829 let free = account
2830 .options_buying_power
2831 .as_deref()
2832 .and_then(|s| Decimal::from_str(s).ok())
2833 .filter(|d| !d.is_zero())
2838 .unwrap_or_else(|| Decimal::from_str(&account.buying_power).unwrap_or(Decimal::ZERO));
2839
2840 vec![AssetBalance::new(
2841 usd_name,
2842 Balance::new(total, free),
2843 Utc::now(),
2844 )]
2845}
2846
2847fn convert_positions_to_balances(
2858 positions: &[AlpacaPosition],
2859 assets: &[AssetNameExchange],
2860) -> Vec<AssetBalance<AssetNameExchange>> {
2861 let now = Utc::now();
2862 positions
2863 .iter()
2864 .filter(|p| p.asset_class.eq_ignore_ascii_case("crypto"))
2865 .filter_map(|p| {
2866 let base = p
2869 .symbol
2870 .split('/')
2871 .next()
2872 .map(|s| s.to_ascii_lowercase())
2873 .unwrap_or_else(|| p.symbol.to_ascii_lowercase());
2874
2875 if !assets.is_empty()
2877 && !assets
2878 .iter()
2879 .any(|a| a.name().as_str().eq_ignore_ascii_case(&base))
2880 {
2881 return None;
2882 }
2883
2884 let total = Decimal::from_str(&p.qty).unwrap_or(Decimal::ZERO);
2887 let free = Decimal::from_str(&p.qty_available).unwrap_or(Decimal::ZERO);
2888 let asset_name = AssetNameExchange::new(base);
2889 Some(AssetBalance::new(
2890 asset_name,
2891 Balance::new(total, free),
2892 now,
2893 ))
2894 })
2895 .collect()
2896}
2897
2898fn build_instrument_snapshots(
2904 orders: Vec<AlpacaOrderResponse>,
2905 instruments: &[InstrumentNameExchange],
2906) -> Vec<InstrumentAccountSnapshot<ExchangeId, AssetNameExchange, InstrumentNameExchange>> {
2907 let mut by_symbol: IndexMap<SmolStr, Vec<_>> = IndexMap::new();
2909
2910 for order in orders {
2911 let sym = SmolStr::new(&order.symbol);
2912 if let Some(converted) = convert_open_order(&order) {
2913 let wrapped = crate::order::Order {
2914 key: converted.key,
2915 side: converted.side,
2916 price: converted.price,
2917 quantity: converted.quantity,
2918 kind: converted.kind,
2919 time_in_force: converted.time_in_force,
2920 state: OrderState::active(converted.state),
2921 };
2922 by_symbol.entry(sym).or_default().push(wrapped);
2923 }
2924 }
2925
2926 if instruments.is_empty() {
2928 by_symbol
2929 .into_iter()
2930 .map(|(sym, orders)| {
2931 InstrumentAccountSnapshot::new(InstrumentNameExchange::new(sym), orders, None, None)
2932 })
2933 .collect()
2934 } else {
2935 instruments
2936 .iter()
2937 .map(|inst| {
2938 let orders = by_symbol
2941 .swap_remove(inst.name().as_str())
2942 .unwrap_or_default();
2943 InstrumentAccountSnapshot::new(inst.clone(), orders, None, None)
2944 })
2945 .collect()
2946 }
2947}
2948
2949fn convert_open_order(
2951 o: &AlpacaOrderResponse,
2952) -> Option<Order<ExchangeId, InstrumentNameExchange, Open>> {
2953 let order_id = OrderId(SmolStr::new(&o.id));
2954 let cid = o
2955 .client_order_id
2956 .as_deref()
2957 .map(ClientOrderId::new)
2958 .unwrap_or_else(|| ClientOrderId::new(o.id.as_str()));
2959
2960 let instrument = InstrumentNameExchange::new(&o.symbol);
2961 let side = parse_side(&o.side)?;
2962 let quantity = Decimal::from_str(o.qty.as_deref().unwrap_or("0")).ok()?;
2963 if quantity.is_zero() {
2966 return None;
2967 }
2968 let price = o
2969 .limit_price
2970 .as_deref()
2971 .and_then(|s| Decimal::from_str(s).ok());
2972 let filled_qty = Decimal::from_str(&o.filled_qty).unwrap_or(Decimal::ZERO);
2973 let kind = parse_order_kind(
2974 &o.order_type,
2975 o.stop_price.as_deref(),
2976 o.trail_percent.as_deref(),
2977 o.trail_price.as_deref(),
2978 )?;
2979 let time_in_force = parse_time_in_force(&o.time_in_force);
2980 let time_exchange = parse_timestamp(&o.created_at).unwrap_or_else(Utc::now);
2981
2982 Some(Order {
2983 key: OrderKey::new(
2984 ExchangeId::AlpacaBroker,
2985 instrument,
2986 StrategyId::unknown(),
2988 cid,
2989 ),
2990 side,
2991 price,
2992 quantity,
2993 kind,
2994 time_in_force,
2995 state: Open::new(order_id, time_exchange, filled_qty),
2996 })
2997}
2998
2999fn convert_activity_to_trade(
3001 a: &AlpacaActivity,
3002) -> Option<Trade<AssetNameExchange, InstrumentNameExchange>> {
3003 let trade_id = TradeId::new(&a.id);
3004 let order_id = OrderId(SmolStr::new(&a.order_id));
3005 let instrument = InstrumentNameExchange::new(&a.symbol);
3006 let side = parse_side(&a.side)?;
3007 let price = Decimal::from_str(&a.price).ok()?;
3008 let quantity = Decimal::from_str(&a.qty).ok()?;
3009 let time_exchange = parse_timestamp(&a.transaction_time).unwrap_or_else(|| {
3010 warn!(id = %a.id, "Alpaca activity: unparseable transaction_time, using now");
3011 Utc::now()
3012 });
3013
3014 Some(Trade::new(
3019 trade_id,
3020 order_id,
3021 instrument,
3022 StrategyId::unknown(),
3023 time_exchange,
3024 side,
3025 price,
3026 quantity,
3027 AssetFees::new(
3028 AssetNameExchange::from("USD"),
3029 Decimal::ZERO,
3030 Some(Decimal::ZERO),
3031 ),
3032 ))
3033}
3034
3035fn convert_trade_update(update: AlpacaTradeUpdate<'_>) -> Option<UnindexedAccountEvent> {
3037 let event_str = update.event.as_str();
3040 if !matches!(
3041 event_str,
3042 "fill"
3043 | "partial_fill"
3044 | "new"
3045 | "accepted"
3046 | "pending_new"
3047 | "canceled"
3048 | "expired"
3049 | "replaced"
3050 | "done_for_day"
3051 | "rejected"
3052 ) {
3053 trace!(event = %event_str, "Alpaca WS: ignoring trade_updates event type");
3054 return None;
3055 }
3056
3057 let order = &update.order;
3058 let instrument = InstrumentNameExchange::new(&*order.symbol);
3059 let order_id = OrderId(order.id.clone());
3060 let cid = order
3061 .client_order_id
3062 .as_deref()
3063 .map(ClientOrderId::new)
3064 .unwrap_or_else(|| ClientOrderId::new(order.id.as_str()));
3065
3066 match event_str {
3067 "fill" | "partial_fill" => {
3068 let price = update.price.and_then(|s| Decimal::from_str(s).ok())?;
3070 let quantity = update.qty.and_then(|s| Decimal::from_str(s).ok())?;
3071 let side = parse_side(&order.side)?;
3072 let time_exchange = update
3073 .timestamp
3074 .and_then(parse_timestamp)
3075 .unwrap_or_else(Utc::now);
3076
3077 let cum_qty = Decimal::from_str(order.filled_qty.unwrap_or("0"))
3087 .inspect_err(|e| {
3088 warn!(
3089 order_id = %order.id,
3090 filled_qty = ?order.filled_qty,
3091 %e,
3092 "Alpaca WS: failed to parse filled_qty — dedup key will use 0, \
3093 a second malformed fill on the same order would be deduplicated away"
3094 );
3095 })
3096 .unwrap_or(Decimal::ZERO);
3097 let trade_id = TradeId(format_smolstr!("{}:{}", order.id, cum_qty.normalize()));
3098
3099 let trade = Trade::new(
3103 trade_id,
3104 order_id,
3105 instrument,
3106 StrategyId::unknown(),
3107 time_exchange,
3108 side,
3109 price,
3110 quantity,
3111 AssetFees::new(
3112 AssetNameExchange::from("USD"),
3113 Decimal::ZERO,
3114 Some(Decimal::ZERO),
3115 ),
3116 );
3117 Some(UnindexedAccountEvent::new(
3118 ExchangeId::AlpacaBroker,
3119 AccountEventKind::Trade(trade),
3120 ))
3121 }
3122
3123 "new" | "accepted" | "pending_new" => {
3124 let side = parse_side(&order.side)?;
3126 let quantity = Decimal::from_str(order.qty.unwrap_or("0")).unwrap_or(Decimal::ZERO);
3127 if quantity.is_zero() {
3131 trace!(order_id = %order.id, "Alpaca WS: skipping notional order snapshot (qty=None)");
3132 return None;
3133 }
3134 let price = order.limit_price.and_then(|s| Decimal::from_str(s).ok());
3135 let filled_qty =
3136 Decimal::from_str(order.filled_qty.unwrap_or("0")).unwrap_or(Decimal::ZERO);
3137 let kind = parse_order_kind(
3138 &order.order_type,
3139 order.stop_price,
3140 order.trail_percent,
3141 order.trail_price,
3142 )?;
3143 let time_in_force = parse_time_in_force(&order.time_in_force);
3144 let time_exchange = update
3145 .timestamp
3146 .and_then(parse_timestamp)
3147 .unwrap_or_else(Utc::now);
3148
3149 let open_state = Open::new(order_id, time_exchange, filled_qty);
3150 let order_snapshot = crate::order::Order {
3151 key: OrderKey::new(
3152 ExchangeId::AlpacaBroker,
3153 instrument,
3154 StrategyId::unknown(),
3155 cid,
3156 ),
3157 side,
3158 price,
3159 quantity,
3160 kind,
3161 time_in_force,
3162 state: OrderState::active(open_state),
3163 };
3164 Some(UnindexedAccountEvent::new(
3165 ExchangeId::AlpacaBroker,
3166 AccountEventKind::OrderSnapshot(
3167 rustrade_integration::collection::snapshot::Snapshot(order_snapshot),
3168 ),
3169 ))
3170 }
3171
3172 "canceled" | "expired" | "replaced" | "done_for_day" => {
3173 let time_exchange = update
3181 .timestamp
3182 .and_then(parse_timestamp)
3183 .unwrap_or_else(Utc::now);
3184 let filled_qty =
3185 Decimal::from_str(order.filled_qty.unwrap_or("0")).unwrap_or(Decimal::ZERO);
3186 let cancelled = Cancelled::new(order_id, time_exchange, filled_qty);
3187 let response = crate::order::request::OrderResponseCancel {
3188 key: OrderKey::new(
3189 ExchangeId::AlpacaBroker,
3190 instrument,
3191 StrategyId::unknown(),
3192 cid,
3193 ),
3194 state: Ok(cancelled),
3195 };
3196 Some(UnindexedAccountEvent::new(
3197 ExchangeId::AlpacaBroker,
3198 AccountEventKind::OrderCancelled(response),
3199 ))
3200 }
3201
3202 "rejected" => {
3203 let response = crate::order::request::OrderResponseCancel {
3204 key: OrderKey::new(
3205 ExchangeId::AlpacaBroker,
3206 instrument,
3207 StrategyId::unknown(),
3208 cid,
3209 ),
3210 state: Err(UnindexedOrderError::Rejected(ApiError::OrderRejected(
3211 format!("order rejected: status={}", order.status),
3212 ))),
3213 };
3214 Some(UnindexedAccountEvent::new(
3215 ExchangeId::AlpacaBroker,
3216 AccountEventKind::OrderCancelled(response),
3217 ))
3218 }
3219
3220 _ => unreachable!("convert_trade_update: unrecognised event passed early-return guard"),
3223 }
3224}
3225
3226fn parse_side(s: &str) -> Option<Side> {
3231 match s {
3232 "buy" | "Buy" | "BUY" => Some(Side::Buy),
3233 "sell" | "Sell" | "SELL" => Some(Side::Sell),
3234 other => {
3235 trace!(%other, "Alpaca: unknown order side");
3236 None
3237 }
3238 }
3239}
3240
3241fn parse_order_kind(
3242 order_type: &str,
3243 stop_price: Option<&str>,
3244 trail_percent: Option<&str>,
3245 trail_price: Option<&str>,
3246) -> Option<OrderKind> {
3247 match order_type {
3248 "market" | "Market" => Some(OrderKind::Market),
3249 "limit" | "Limit" => Some(OrderKind::Limit),
3250 "stop" | "Stop" => {
3251 let trigger_price = stop_price.and_then(|s| Decimal::from_str(s).ok())?;
3252 Some(OrderKind::Stop { trigger_price })
3253 }
3254 "stop_limit" | "Stop_limit" => {
3255 let trigger_price = stop_price.and_then(|s| Decimal::from_str(s).ok())?;
3256 Some(OrderKind::StopLimit { trigger_price })
3257 }
3258 "trailing_stop" | "Trailing_stop" => {
3259 if let Some(pct) = trail_percent.and_then(|s| Decimal::from_str(s).ok()) {
3261 Some(OrderKind::TrailingStop {
3262 offset: pct,
3263 offset_type: TrailingOffsetType::Percentage,
3264 })
3265 } else if let Some(price) = trail_price.and_then(|s| Decimal::from_str(s).ok()) {
3266 Some(OrderKind::TrailingStop {
3267 offset: price,
3268 offset_type: TrailingOffsetType::Absolute,
3269 })
3270 } else {
3271 trace!("Alpaca: trailing_stop missing trail_percent and trail_price");
3272 None
3273 }
3274 }
3275 other => {
3276 trace!(%other, "Alpaca: unsupported order type, skipping");
3277 None
3278 }
3279 }
3280}
3281
3282fn parse_time_in_force(s: &str) -> TimeInForce {
3283 match s {
3284 "gtc" | "GTC" => TimeInForce::GoodUntilCancelled { post_only: false },
3285 "day" | "DAY" => TimeInForce::GoodUntilEndOfDay,
3286 "fok" | "FOK" => TimeInForce::FillOrKill,
3287 "ioc" | "IOC" => TimeInForce::ImmediateOrCancel,
3288 other => {
3289 warn!(%other, "Alpaca: unknown time_in_force, defaulting to GoodUntilEndOfDay");
3290 TimeInForce::GoodUntilEndOfDay
3291 }
3292 }
3293}
3294
3295fn parse_timestamp(s: &str) -> Option<DateTime<Utc>> {
3296 DateTime::parse_from_rfc3339(s)
3297 .ok()
3298 .map(|dt| dt.with_timezone(&Utc))
3299}
3300
3301fn map_side(side: Side) -> &'static str {
3302 match side {
3303 Side::Buy => "buy",
3304 Side::Sell => "sell",
3305 }
3306}
3307
3308fn map_order_kind(kind: OrderKind) -> Option<&'static str> {
3309 match kind {
3310 OrderKind::Market => Some("market"),
3311 OrderKind::Limit => Some("limit"),
3312 OrderKind::Stop { .. } => Some("stop"),
3313 OrderKind::StopLimit { .. } => Some("stop_limit"),
3314 OrderKind::TrailingStop { .. } => Some("trailing_stop"),
3315 OrderKind::TakeProfit { .. }
3317 | OrderKind::TakeProfitLimit { .. }
3318 | OrderKind::TrailingStopLimit { .. } => None,
3319 }
3320}
3321
3322fn map_time_in_force(tif: TimeInForce) -> Result<&'static str, &'static str> {
3331 match tif {
3332 TimeInForce::GoodUntilCancelled { post_only } => {
3333 if post_only {
3334 return Err("Alpaca does not support post_only orders");
3335 }
3336 Ok("gtc")
3337 }
3338 TimeInForce::GoodUntilEndOfDay => Ok("day"),
3339 TimeInForce::FillOrKill => Ok("fok"),
3340 TimeInForce::ImmediateOrCancel => Ok("ioc"),
3341 TimeInForce::AtOpen => Ok("opg"),
3342 TimeInForce::AtClose => Ok("cls"),
3343 TimeInForce::GoodTillDate { .. } => {
3347 Err("Alpaca GoodTillDate is not yet wired through this client")
3348 }
3349 }
3350}
3351
3352fn is_options_or_equity_symbol(symbol: &str) -> bool {
3363 !symbol.contains('/')
3364}
3365
3366fn map_position_intent(side: Side, reduce_only: bool) -> AlpacaPositionIntent {
3375 match (reduce_only, side) {
3376 (false, Side::Buy) => AlpacaPositionIntent::BuyToOpen,
3377 (false, Side::Sell) => AlpacaPositionIntent::SellToOpen,
3378 (true, Side::Buy) => AlpacaPositionIntent::BuyToClose,
3379 (true, Side::Sell) => AlpacaPositionIntent::SellToClose,
3380 }
3381}
3382
3383fn parse_api_error(status: reqwest::StatusCode, message: &str) -> crate::error::UnindexedApiError {
3388 if status == reqwest::StatusCode::TOO_MANY_REQUESTS {
3390 return ApiError::RateLimit;
3391 }
3392
3393 let lower = message.to_ascii_lowercase();
3395 match status.as_u16() {
3396 422 if lower.contains("already") => ApiError::OrderAlreadyCancelled,
3400 422 if lower.contains("insufficient") => {
3405 ApiError::BalanceInsufficient(AssetNameExchange::new("usd"), message.to_owned())
3406 }
3407 401 => ApiError::Unauthenticated(format!("unauthorized: {message}")),
3408 403 => ApiError::Unauthenticated(format!("forbidden: {message}")),
3409 404 => ApiError::OrderRejected(format!("order not found: {message}")),
3410 _ => ApiError::OrderRejected(message.to_owned()),
3411 }
3412}
3413
3414fn parse_order_error(status: reqwest::StatusCode, message: &str) -> UnindexedOrderError {
3416 UnindexedOrderError::Rejected(parse_api_error(status, message))
3417}
3418
3419fn connectivity_err(msg: impl Into<String>) -> UnindexedClientError {
3420 UnindexedClientError::Connectivity(ConnectivityError::Socket(msg.into()))
3421}
3422
3423impl BracketOrderClient for AlpacaClient {
3428 async fn open_bracket_order(
3429 &self,
3430 request: UnifiedBracketOrderRequest<ExchangeId, &InstrumentNameExchange>,
3431 ) -> UnifiedBracketOrderResult {
3432 let alpaca_request = AlpacaBracketOrderRequest {
3433 instrument: request.key.instrument.clone(),
3434 strategy: request.key.strategy.clone(),
3435 cid: request.key.cid.clone(),
3436 side: request.state.side,
3437 quantity: request.state.quantity,
3438 entry_price: request.state.entry_price,
3439 take_profit_price: request.state.take_profit_price,
3440 stop_loss_price: request.state.stop_loss_price,
3441 stop_loss_limit_price: request.state.stop_loss_limit_price,
3442 time_in_force: request.state.time_in_force,
3443 };
3444
3445 let result = self.open_bracket_order(alpaca_request).await;
3446
3447 UnifiedBracketOrderResult::parent_only(result.parent)
3448 }
3449}
3450
3451#[cfg(test)]
3456#[allow(clippy::unwrap_used, clippy::expect_used)] mod tests {
3458 use super::*;
3459
3460 #[test]
3461 fn test_alpaca_config_new_uses_paper_trading_by_default() {
3462 let cfg = AlpacaConfig::new("my_key".into(), "my_secret".into());
3463 assert!(cfg.paper);
3464 }
3465
3466 #[test]
3467 fn test_alpaca_config_debug_redacts_credentials() {
3468 let cfg = AlpacaConfig::new("my_key".into(), "my_secret".into());
3469 let debug = format!("{cfg:?}");
3470 assert!(!debug.contains("my_key"), "api_key should be redacted");
3471 assert!(
3472 !debug.contains("my_secret"),
3473 "secret_key should be redacted"
3474 );
3475 assert!(debug.contains("paper: true"));
3476 }
3477
3478 #[test]
3479 fn test_alpaca_config_deserialize_omitted_paper_defaults_to_paper() {
3480 let cfg: AlpacaConfig = serde_json::from_str(
3481 r#"{
3482 "api_key": "my_key",
3483 "secret_key": "my_secret"
3484 }"#,
3485 )
3486 .unwrap();
3487
3488 assert!(cfg.paper);
3489 assert_eq!(cfg.rest_base_url(), "https://paper-api.alpaca.markets");
3490 }
3491
3492 #[test]
3493 fn test_alpaca_config_deserialize_paper_true() {
3494 let cfg: AlpacaConfig = serde_json::from_str(
3495 r#"{
3496 "api_key": "my_key",
3497 "secret_key": "my_secret",
3498 "paper": true
3499 }"#,
3500 )
3501 .unwrap();
3502
3503 assert!(cfg.paper);
3504 assert_eq!(cfg.rest_base_url(), "https://paper-api.alpaca.markets");
3505 }
3506
3507 #[test]
3508 fn test_alpaca_config_deserialize_paper_false() {
3509 let cfg: AlpacaConfig = serde_json::from_str(
3510 r#"{
3511 "api_key": "my_key",
3512 "secret_key": "my_secret",
3513 "paper": false
3514 }"#,
3515 )
3516 .unwrap();
3517
3518 assert!(!cfg.paper);
3519 assert_eq!(cfg.rest_base_url(), "https://api.alpaca.markets");
3520 }
3521
3522 #[test]
3523 fn test_alpaca_config_urls() {
3524 let paper = AlpacaConfig::paper("k".into(), "s".into());
3525 assert!(paper.rest_base_url().contains("paper-api"));
3526 assert!(paper.ws_url().contains("paper-api"));
3527
3528 let live = AlpacaConfig::production("k".into(), "s".into());
3529 assert!(!live.rest_base_url().contains("paper-api"));
3530 assert!(!live.ws_url().contains("paper-api"));
3531 }
3532
3533 #[test]
3534 #[serial_test::serial]
3535 fn test_alpaca_config_from_env_defaults_to_paper_trading() {
3536 temp_env::with_vars(
3537 [
3538 ("ALPACA_API_KEY", Some("my_key")),
3539 ("ALPACA_SECRET_KEY", Some("my_secret")),
3540 ("ALPACA_PAPER", None),
3541 ],
3542 || {
3543 let cfg = AlpacaConfig::from_env().unwrap();
3544 assert!(cfg.paper);
3545 assert_eq!(cfg.rest_base_url(), "https://paper-api.alpaca.markets");
3546 },
3547 );
3548 }
3549
3550 #[test]
3551 #[serial_test::serial]
3552 fn test_alpaca_config_from_env_accepts_explicit_production() {
3553 temp_env::with_vars(
3554 [
3555 ("ALPACA_API_KEY", Some("my_key")),
3556 ("ALPACA_SECRET_KEY", Some("my_secret")),
3557 ("ALPACA_PAPER", Some("false")),
3558 ],
3559 || {
3560 let cfg = AlpacaConfig::from_env().unwrap();
3561 assert!(!cfg.paper);
3562 assert_eq!(cfg.rest_base_url(), "https://api.alpaca.markets");
3563 },
3564 );
3565 }
3566
3567 #[test]
3568 #[serial_test::serial]
3569 fn test_alpaca_config_from_env_rejects_invalid_paper() {
3570 temp_env::with_vars(
3571 [
3572 ("ALPACA_API_KEY", Some("my_key")),
3573 ("ALPACA_SECRET_KEY", Some("my_secret")),
3574 ("ALPACA_PAPER", Some("maybe")),
3575 ],
3576 || {
3577 let err = AlpacaConfig::from_env().unwrap_err();
3578 assert!(matches!(err, AlpacaConfigError::InvalidPaper(value) if value == "maybe"));
3579 },
3580 );
3581 }
3582
3583 #[test]
3584 #[serial_test::serial]
3585 fn test_alpaca_config_from_env_requires_credentials() {
3586 temp_env::with_vars(
3587 [
3588 ("ALPACA_API_KEY", None),
3589 ("ALPACA_SECRET_KEY", Some("my_secret")),
3590 ("ALPACA_PAPER", None),
3591 ],
3592 || {
3593 let err = AlpacaConfig::from_env().unwrap_err();
3594 assert!(matches!(err, AlpacaConfigError::MissingApiKey));
3595 },
3596 );
3597 }
3598
3599 #[test]
3600 fn test_parse_side() {
3601 assert_eq!(parse_side("buy"), Some(Side::Buy));
3602 assert_eq!(parse_side("sell"), Some(Side::Sell));
3603 assert_eq!(parse_side("Buy"), Some(Side::Buy));
3604 assert_eq!(parse_side("BUY"), Some(Side::Buy));
3605 assert_eq!(parse_side("unknown"), None);
3606 }
3607
3608 #[test]
3609 fn test_parse_order_kind() {
3610 assert_eq!(
3612 parse_order_kind("market", None, None, None),
3613 Some(OrderKind::Market)
3614 );
3615 assert_eq!(
3616 parse_order_kind("limit", None, None, None),
3617 Some(OrderKind::Limit)
3618 );
3619
3620 assert_eq!(
3622 parse_order_kind("stop", Some("150.00"), None, None),
3623 Some(OrderKind::Stop {
3624 trigger_price: Decimal::from_str("150.00").unwrap()
3625 })
3626 );
3627 assert_eq!(parse_order_kind("stop", None, None, None), None);
3628
3629 assert_eq!(
3631 parse_order_kind("stop_limit", Some("145.00"), None, None),
3632 Some(OrderKind::StopLimit {
3633 trigger_price: Decimal::from_str("145.00").unwrap()
3634 })
3635 );
3636
3637 assert_eq!(
3639 parse_order_kind("trailing_stop", None, Some("5.0"), None),
3640 Some(OrderKind::TrailingStop {
3641 offset: Decimal::from_str("5.0").unwrap(),
3642 offset_type: TrailingOffsetType::Percentage,
3643 })
3644 );
3645
3646 assert_eq!(
3648 parse_order_kind("trailing_stop", None, None, Some("2.50")),
3649 Some(OrderKind::TrailingStop {
3650 offset: Decimal::from_str("2.50").unwrap(),
3651 offset_type: TrailingOffsetType::Absolute,
3652 })
3653 );
3654
3655 assert_eq!(parse_order_kind("trailing_stop", None, None, None), None);
3657
3658 assert_eq!(parse_order_kind("unknown", None, None, None), None);
3660 }
3661
3662 #[test]
3663 fn test_map_order_kind() {
3664 assert_eq!(map_order_kind(OrderKind::Market), Some("market"));
3665 assert_eq!(map_order_kind(OrderKind::Limit), Some("limit"));
3666 assert_eq!(
3667 map_order_kind(OrderKind::Stop {
3668 trigger_price: Decimal::from_str("150.00").unwrap()
3669 }),
3670 Some("stop")
3671 );
3672 assert_eq!(
3673 map_order_kind(OrderKind::StopLimit {
3674 trigger_price: Decimal::from_str("145.00").unwrap()
3675 }),
3676 Some("stop_limit")
3677 );
3678 assert_eq!(
3679 map_order_kind(OrderKind::TrailingStop {
3680 offset: Decimal::from_str("5.0").unwrap(),
3681 offset_type: TrailingOffsetType::Percentage,
3682 }),
3683 Some("trailing_stop")
3684 );
3685 assert_eq!(
3686 map_order_kind(OrderKind::TrailingStop {
3687 offset: Decimal::from_str("2.50").unwrap(),
3688 offset_type: TrailingOffsetType::Absolute,
3689 }),
3690 Some("trailing_stop")
3691 );
3692 assert_eq!(
3694 map_order_kind(OrderKind::TrailingStopLimit {
3695 offset: Decimal::from_str("5.0").unwrap(),
3696 offset_type: TrailingOffsetType::Percentage,
3697 limit_offset: Decimal::from_str("1.0").unwrap(),
3698 }),
3699 None
3700 );
3701 assert_eq!(
3703 map_order_kind(OrderKind::TakeProfit {
3704 trigger_price: Decimal::from_str("160.00").unwrap()
3705 }),
3706 None
3707 );
3708 assert_eq!(
3709 map_order_kind(OrderKind::TakeProfitLimit {
3710 trigger_price: Decimal::from_str("160.00").unwrap()
3711 }),
3712 None
3713 );
3714 }
3715
3716 #[test]
3717 fn test_map_time_in_force_roundtrip() {
3718 assert_eq!(
3719 map_time_in_force(TimeInForce::GoodUntilCancelled { post_only: false }),
3720 Ok("gtc")
3721 );
3722 assert_eq!(map_time_in_force(TimeInForce::GoodUntilEndOfDay), Ok("day"));
3723 assert_eq!(map_time_in_force(TimeInForce::FillOrKill), Ok("fok"));
3724 assert_eq!(map_time_in_force(TimeInForce::ImmediateOrCancel), Ok("ioc"));
3725 }
3726
3727 #[test]
3728 fn test_map_time_in_force_rejects_post_only() {
3729 let result = map_time_in_force(TimeInForce::GoodUntilCancelled { post_only: true });
3730 assert!(result.is_err(), "post_only must be rejected");
3731 assert!(result.unwrap_err().contains("post_only"));
3732 }
3733
3734 #[test]
3739 fn test_bracket_order_serializes_with_stop_loss_stop_order() {
3740 let body = AlpacaOrderRequest {
3742 symbol: "AAPL",
3743 qty: "10".to_string(),
3744 side: "buy",
3745 order_type: "limit",
3746 time_in_force: "gtc",
3747 limit_price: Some("150.00".to_string()),
3748 stop_price: None,
3749 trail_percent: None,
3750 trail_price: None,
3751 client_order_id: Some("bracket-001"),
3752 position_intent: Some(AlpacaPositionIntent::BuyToOpen),
3753 order_class: Some("bracket"),
3754 take_profit: Some(TakeProfitParams {
3755 limit_price: "160.00".to_string(),
3756 }),
3757 stop_loss: Some(StopLossParams {
3758 stop_price: "145.00".to_string(),
3759 limit_price: None,
3760 }),
3761 };
3762
3763 let json = serde_json::to_value(&body).unwrap();
3764
3765 assert_eq!(json["symbol"], "AAPL");
3766 assert_eq!(json["qty"], "10");
3767 assert_eq!(json["side"], "buy");
3768 assert_eq!(json["type"], "limit");
3769 assert_eq!(json["time_in_force"], "gtc");
3770 assert_eq!(json["limit_price"], "150.00");
3771 assert_eq!(json["order_class"], "bracket");
3772
3773 assert_eq!(json["take_profit"]["limit_price"], "160.00");
3775
3776 assert_eq!(json["stop_loss"]["stop_price"], "145.00");
3778 assert!(
3779 json["stop_loss"].get("limit_price").is_none(),
3780 "stop_loss.limit_price should be omitted when None"
3781 );
3782 }
3783
3784 #[test]
3785 fn test_bracket_order_serializes_with_stop_loss_stop_limit_order() {
3786 let body = AlpacaOrderRequest {
3788 symbol: "SPY",
3789 qty: "5".to_string(),
3790 side: "sell",
3791 order_type: "limit",
3792 time_in_force: "day",
3793 limit_price: Some("450.00".to_string()),
3794 stop_price: None,
3795 trail_percent: None,
3796 trail_price: None,
3797 client_order_id: Some("bracket-002"),
3798 position_intent: Some(AlpacaPositionIntent::SellToClose),
3799 order_class: Some("bracket"),
3800 take_profit: Some(TakeProfitParams {
3801 limit_price: "440.00".to_string(),
3802 }),
3803 stop_loss: Some(StopLossParams {
3804 stop_price: "455.00".to_string(),
3805 limit_price: Some("456.00".to_string()),
3806 }),
3807 };
3808
3809 let json = serde_json::to_value(&body).unwrap();
3810
3811 assert_eq!(json["symbol"], "SPY");
3812 assert_eq!(json["side"], "sell");
3813 assert_eq!(json["time_in_force"], "day");
3814 assert_eq!(json["order_class"], "bracket");
3815
3816 assert_eq!(json["take_profit"]["limit_price"], "440.00");
3818
3819 assert_eq!(json["stop_loss"]["stop_price"], "455.00");
3821 assert_eq!(
3822 json["stop_loss"]["limit_price"], "456.00",
3823 "stop_loss.limit_price should be present for stop-limit orders"
3824 );
3825 }
3826
3827 #[tokio::test]
3832 async fn test_open_bracket_order_rejects_invalid_tif() {
3833 use rust_decimal_macros::dec;
3834 use rustrade_instrument::instrument::name::InstrumentNameExchange;
3835
3836 let config = AlpacaConfig::new("dummy_key".into(), "dummy_secret".into());
3838 let client = AlpacaClient::new(config);
3839
3840 let request = AlpacaBracketOrderRequest::new(
3841 InstrumentNameExchange::new("SPY"),
3842 crate::order::id::StrategyId::new("test"),
3843 crate::order::id::ClientOrderId::new("test-tif"),
3844 Side::Buy,
3845 dec!(1),
3846 dec!(100.00),
3847 dec!(120.00),
3848 dec!(90.00),
3849 TimeInForce::ImmediateOrCancel, );
3851
3852 let result = client.open_bracket_order(request).await;
3853
3854 assert!(
3855 result.parent.state.is_failed(),
3856 "Bracket order with IOC TIF should be rejected locally"
3857 );
3858 }
3859
3860 #[tokio::test]
3861 async fn test_open_bracket_order_rejects_invalid_price_ordering() {
3862 use rust_decimal_macros::dec;
3863 use rustrade_instrument::instrument::name::InstrumentNameExchange;
3864
3865 let config = AlpacaConfig::new("dummy_key".into(), "dummy_secret".into());
3866 let client = AlpacaClient::new(config);
3867
3868 let request = AlpacaBracketOrderRequest::new(
3870 InstrumentNameExchange::new("SPY"),
3871 crate::order::id::StrategyId::new("test"),
3872 crate::order::id::ClientOrderId::new("test-price"),
3873 Side::Buy,
3874 dec!(1),
3875 dec!(100.00),
3876 dec!(120.00),
3877 dec!(105.00), TimeInForce::GoodUntilCancelled { post_only: false },
3879 );
3880
3881 let result = client.open_bracket_order(request).await;
3882
3883 assert!(
3884 result.parent.state.is_failed(),
3885 "Bracket order with invalid price ordering should be rejected locally"
3886 );
3887 }
3888
3889 #[tokio::test]
3890 async fn test_open_bracket_order_rejects_invalid_sl_limit_price() {
3891 use rust_decimal_macros::dec;
3892 use rustrade_instrument::instrument::name::InstrumentNameExchange;
3893
3894 let config = AlpacaConfig::new("dummy_key".into(), "dummy_secret".into());
3895 let client = AlpacaClient::new(config);
3896
3897 let request = AlpacaBracketOrderRequest::new(
3899 InstrumentNameExchange::new("SPY"),
3900 crate::order::id::StrategyId::new("test"),
3901 crate::order::id::ClientOrderId::new("test-sl-limit"),
3902 Side::Buy,
3903 dec!(1),
3904 dec!(100.00),
3905 dec!(120.00),
3906 dec!(90.00),
3907 TimeInForce::GoodUntilCancelled { post_only: false },
3908 )
3909 .with_stop_loss_limit_price(dec!(95.00)); let result = client.open_bracket_order(request).await;
3912
3913 assert!(
3914 result.parent.state.is_failed(),
3915 "Bracket order with invalid SL limit price should be rejected locally"
3916 );
3917 }
3918
3919 #[test]
3920 fn test_non_bracket_order_omits_bracket_fields() {
3921 let body = AlpacaOrderRequest {
3923 symbol: "AAPL",
3924 qty: "1".to_string(),
3925 side: "buy",
3926 order_type: "limit",
3927 time_in_force: "gtc",
3928 limit_price: Some("150.00".to_string()),
3929 stop_price: None,
3930 trail_percent: None,
3931 trail_price: None,
3932 client_order_id: Some("regular-001"),
3933 position_intent: Some(AlpacaPositionIntent::BuyToOpen),
3934 order_class: None,
3935 take_profit: None,
3936 stop_loss: None,
3937 };
3938
3939 let json = serde_json::to_value(&body).unwrap();
3940
3941 assert_eq!(json["symbol"], "AAPL");
3942 assert!(
3943 json.get("order_class").is_none(),
3944 "order_class should be omitted for non-bracket orders"
3945 );
3946 assert!(
3947 json.get("take_profit").is_none(),
3948 "take_profit should be omitted for non-bracket orders"
3949 );
3950 assert!(
3951 json.get("stop_loss").is_none(),
3952 "stop_loss should be omitted for non-bracket orders"
3953 );
3954 }
3955
3956 #[test]
3957 fn test_parse_timestamp_valid() {
3958 let ts = parse_timestamp("2025-04-18T14:30:00Z");
3959 assert!(ts.is_some());
3960 let ts2 = parse_timestamp("2025-04-18T14:30:00.123456Z");
3961 assert!(ts2.is_some());
3962 assert_eq!(parse_timestamp("not-a-timestamp"), None);
3963 }
3964
3965 #[test]
3966 fn test_check_auth_response_authorized() {
3967 let msg =
3968 r#"{"stream":"authorization","data":{"status":"authorized","action":"authenticate"}}"#;
3969 assert!(matches!(check_auth_response(msg), Some(Ok(()))));
3970 }
3971
3972 #[test]
3973 fn test_check_auth_response_unauthorized() {
3974 let msg = r#"{"stream":"authorization","data":{"status":"unauthorized"}}"#;
3975 assert!(matches!(
3976 check_auth_response(msg),
3977 Some(Err(HandshakeError::Auth(_)))
3978 ));
3979 }
3980
3981 #[test]
3982 fn test_check_auth_response_non_auth_message() {
3983 let msg = r#"{"stream":"trade_updates","data":{}}"#;
3984 assert!(check_auth_response(msg).is_none());
3985 }
3986
3987 #[test]
3988 fn test_check_listen_ack() {
3989 let ack = r#"{"stream":"listening","data":{"streams":["trade_updates"]}}"#;
3990 assert!(check_listen_ack(ack));
3991
3992 let other = r#"{"stream":"authorization","data":{}}"#;
3993 assert!(!check_listen_ack(other));
3994 }
3995
3996 #[test]
3997 fn test_dedup_cache() {
3998 let cache = new_dedup_cache();
3999 let key = SmolStr::new("order-1:1");
4000 assert!(
4001 !is_duplicate(&cache, &key),
4002 "first time should not be duplicate"
4003 );
4004 assert!(
4005 is_duplicate(&cache, &key),
4006 "second time should be duplicate"
4007 );
4008 }
4009
4010 #[tokio::test]
4011 async fn test_exponential_backoff_progression_and_exhaustion() {
4012 tokio::time::pause();
4013
4014 let mut b = ExponentialBackoff::new();
4015
4016 assert!(b.wait().await, "first wait should return true");
4018 assert_eq!(b.attempt, 1);
4019
4020 while b.wait().await {}
4022
4023 assert_eq!(b.attempt, MAX_RECONNECT_ATTEMPTS);
4025
4026 assert!(!b.wait().await, "exhausted backoff should return false");
4028
4029 b.reset();
4031 assert_eq!(b.attempt, 0);
4032
4033 assert!(b.wait().await, "wait should succeed after reset");
4035 assert_eq!(b.attempt, 1);
4036 }
4037
4038 #[test]
4039 fn test_convert_account_to_balances_empty_assets() {
4040 let account = AlpacaAccount {
4041 equity: "12000.00".into(),
4042 buying_power: "10000.00".into(),
4043 options_buying_power: Some("8000.00".into()),
4044 crypto_buying_power: None,
4045 };
4046 let balances = convert_account_to_balances(&account, &[]);
4047 assert_eq!(balances.len(), 1);
4048 assert_eq!(
4049 balances[0].balance.total,
4050 Decimal::from_str("12000.00").unwrap()
4051 );
4052 assert_eq!(
4054 balances[0].balance.free,
4055 Decimal::from_str("8000.00").unwrap()
4056 );
4057 }
4058
4059 #[test]
4060 fn test_convert_account_to_balances_usd_filter() {
4061 let account = AlpacaAccount {
4062 equity: "12000.00".into(),
4063 buying_power: "10000.00".into(),
4064 options_buying_power: None,
4065 crypto_buying_power: None,
4066 };
4067 let usd = vec![AssetNameExchange::new("USD")];
4068 let balances = convert_account_to_balances(&account, &usd);
4069 assert_eq!(balances.len(), 1);
4070
4071 let non_usd = vec![AssetNameExchange::new("BTC")];
4072 let balances = convert_account_to_balances(&account, &non_usd);
4073 assert!(balances.is_empty());
4074 }
4075
4076 #[test]
4077 fn test_is_options_or_equity_symbol() {
4078 assert!(!is_options_or_equity_symbol("BTC/USD"));
4080 assert!(!is_options_or_equity_symbol("ETH/USD"));
4081 assert!(!is_options_or_equity_symbol("SOL/USD"));
4082
4083 assert!(is_options_or_equity_symbol("AAPL"));
4085 assert!(is_options_or_equity_symbol("SPY"));
4086 assert!(is_options_or_equity_symbol("MSFT"));
4087
4088 assert!(is_options_or_equity_symbol("SPY250418C00450000"));
4090 assert!(is_options_or_equity_symbol("AAPL250418P00145000"));
4091 }
4092
4093 #[test]
4094 fn test_parse_order_error_already_cancelled() {
4095 assert!(matches!(
4098 parse_order_error(
4099 reqwest::StatusCode::UNPROCESSABLE_ENTITY,
4100 "order is already cancelled"
4101 ),
4102 UnindexedOrderError::Rejected(ApiError::OrderAlreadyCancelled)
4103 ));
4104 }
4105
4106 #[test]
4107 fn test_parse_order_error_already_wins_over_insufficient_on_422() {
4108 assert!(matches!(
4111 parse_order_error(
4112 reqwest::StatusCode::UNPROCESSABLE_ENTITY,
4113 "order already cancelled due to insufficient margin"
4114 ),
4115 UnindexedOrderError::Rejected(ApiError::OrderAlreadyCancelled)
4116 ));
4117 }
4118
4119 fn make_order_ws<'a>(
4120 id: &str,
4121 symbol: &str,
4122 side: &str,
4123 filled_qty: &'a str,
4124 ) -> AlpacaOrderWs<'a> {
4125 AlpacaOrderWs {
4126 id: SmolStr::new(id),
4127 client_order_id: None,
4128 symbol: SmolStr::new(symbol),
4129 qty: Some("2"),
4130 filled_qty: Some(filled_qty),
4131 side: SmolStr::new(side),
4132 order_type: SmolStr::new("limit"),
4133 time_in_force: SmolStr::new("day"),
4134 limit_price: Some("100.00"),
4135 stop_price: None,
4136 trail_percent: None,
4137 trail_price: None,
4138 status: SmolStr::new("partially_filled"),
4139 }
4140 }
4141
4142 #[test]
4143 fn test_convert_trade_update_fill_produces_trade_with_dedup_key() {
4144 let update = AlpacaTradeUpdate {
4145 event: SmolStr::new("fill"),
4146 order: make_order_ws("ord-1", "SPY", "buy", "1"),
4147 price: Some("150.00"),
4148 qty: Some("1"),
4149 timestamp: Some("2025-04-18T14:30:00Z"),
4150 };
4151 let event = convert_trade_update(update).expect("fill should produce an event");
4152 let AccountEventKind::Trade(trade) = event.kind else {
4153 panic!("expected Trade, got {:?}", event.kind);
4154 };
4155 assert_eq!(trade.id.0.as_str(), "ord-1:1");
4157 assert_eq!(trade.price, Decimal::from_str("150.00").unwrap());
4158 assert_eq!(trade.quantity, Decimal::from_str("1").unwrap());
4159 }
4160
4161 #[test]
4162 fn test_convert_trade_update_partial_fill() {
4163 let update = AlpacaTradeUpdate {
4164 event: SmolStr::new("partial_fill"),
4165 order: make_order_ws("ord-2", "AAPL", "sell", "0.5"),
4166 price: Some("200.00"),
4167 qty: Some("0.5"),
4168 timestamp: None,
4169 };
4170 let event = convert_trade_update(update).expect("partial_fill should produce an event");
4171 assert!(matches!(event.kind, AccountEventKind::Trade(_)));
4172 }
4173
4174 #[test]
4175 fn test_convert_trade_update_new_order_produces_snapshot() {
4176 let update = AlpacaTradeUpdate {
4177 event: SmolStr::new("new"),
4178 order: AlpacaOrderWs {
4179 id: SmolStr::new("ord-new"),
4180 client_order_id: Some(SmolStr::new("cid-1")),
4181 symbol: SmolStr::new("AAPL"),
4182 qty: Some("10"),
4183 filled_qty: Some("0"),
4184 side: SmolStr::new("buy"),
4185 order_type: SmolStr::new("limit"),
4186 time_in_force: SmolStr::new("day"),
4187 limit_price: Some("150.00"),
4188 stop_price: None,
4189 trail_percent: None,
4190 trail_price: None,
4191 status: SmolStr::new("new"),
4192 },
4193 price: None,
4194 qty: None,
4195 timestamp: Some("2025-04-18T14:30:00Z"),
4196 };
4197 let event = convert_trade_update(update).expect("new event should produce an event");
4198 assert!(matches!(event.kind, AccountEventKind::OrderSnapshot(_)));
4199 }
4200
4201 #[test]
4202 fn test_convert_trade_update_canceled_produces_cancel() {
4203 let update = AlpacaTradeUpdate {
4204 event: SmolStr::new("canceled"),
4205 order: make_order_ws("ord-3", "AAPL", "sell", "0"),
4206 price: None,
4207 qty: None,
4208 timestamp: Some("2025-04-18T14:30:00Z"),
4209 };
4210 let event = convert_trade_update(update).expect("canceled should produce an event");
4211 let AccountEventKind::OrderCancelled(response) = event.kind else {
4212 panic!("expected OrderCancelled, got {:?}", event.kind);
4213 };
4214 assert!(response.state.is_ok());
4215 }
4216
4217 #[test]
4218 fn test_convert_trade_update_rejected_produces_error() {
4219 let update = AlpacaTradeUpdate {
4220 event: SmolStr::new("rejected"),
4221 order: make_order_ws("ord-4", "SPY", "buy", "0"),
4222 price: None,
4223 qty: None,
4224 timestamp: None,
4225 };
4226 let event = convert_trade_update(update).expect("rejected should produce an event");
4227 let AccountEventKind::OrderCancelled(response) = event.kind else {
4228 panic!("expected OrderCancelled, got {:?}", event.kind);
4229 };
4230 assert!(response.state.is_err());
4231 }
4232
4233 #[test]
4234 fn test_convert_open_order_notional_qty_none_is_skipped() {
4235 let order = AlpacaOrderResponse {
4238 id: "ord-notional".to_string(),
4239 client_order_id: None,
4240 symbol: "SPY".to_string(),
4241 qty: None,
4242 filled_qty: "0".to_string(),
4243 side: "buy".to_string(),
4244 order_type: "market".to_string(),
4245 time_in_force: "day".to_string(),
4246 limit_price: None,
4247 stop_price: None,
4248 trail_percent: None,
4249 trail_price: None,
4250 created_at: "2025-04-18T14:30:00Z".to_string(),
4251 };
4252 assert!(convert_open_order(&order).is_none());
4253 }
4254
4255 #[test]
4256 fn test_convert_activity_to_trade_bad_price_returns_none() {
4257 let activity = AlpacaActivity {
4261 id: "act-1".to_string(),
4262 order_id: "ord-1".to_string(),
4263 symbol: "SPY250418C00450000".to_string(),
4264 side: "buy".to_string(),
4265 price: "not-a-number".to_string(),
4266 qty: "1".to_string(),
4267 transaction_time: "2025-04-18T14:30:00Z".to_string(),
4268 };
4269 assert!(convert_activity_to_trade(&activity).is_none());
4270 }
4271
4272 #[test]
4273 fn test_convert_positions_to_balances_crypto() {
4274 let positions = vec![
4275 AlpacaPosition {
4276 symbol: "BTC/USD".into(),
4277 asset_class: "crypto".into(),
4278 qty: "0.5".into(),
4279 qty_available: "0.4".into(),
4280 },
4281 AlpacaPosition {
4282 symbol: "ETH/USD".into(),
4283 asset_class: "crypto".into(),
4284 qty: "2.0".into(),
4285 qty_available: "2.0".into(),
4286 },
4287 AlpacaPosition {
4289 symbol: "AAPL".into(),
4290 asset_class: "us_equity".into(),
4291 qty: "10".into(),
4292 qty_available: "10".into(),
4293 },
4294 ];
4295
4296 let balances = convert_positions_to_balances(&positions, &[]);
4298 assert_eq!(balances.len(), 2, "only crypto positions returned");
4299 assert_eq!(balances[0].asset.name().as_str(), "btc");
4300 assert_eq!(balances[0].balance.total, Decimal::from_str("0.5").unwrap());
4302 assert_eq!(balances[0].balance.free, Decimal::from_str("0.4").unwrap());
4303
4304 let btc_only = vec![AssetNameExchange::new("BTC")];
4306 let balances = convert_positions_to_balances(&positions, &btc_only);
4307 assert_eq!(balances.len(), 1);
4308 assert_eq!(balances[0].asset.name().as_str(), "btc");
4309 }
4310
4311 fn make_order_response(id: &str, symbol: &str) -> AlpacaOrderResponse {
4312 AlpacaOrderResponse {
4313 id: id.to_string(),
4314 client_order_id: None,
4315 symbol: symbol.to_string(),
4316 qty: Some("1".to_string()),
4317 filled_qty: "0".to_string(),
4318 side: "buy".to_string(),
4319 order_type: "limit".to_string(),
4320 time_in_force: "day".to_string(),
4321 limit_price: Some("100.00".to_string()),
4322 stop_price: None,
4323 trail_percent: None,
4324 trail_price: None,
4325 created_at: "2025-04-18T14:30:00Z".to_string(),
4326 }
4327 }
4328
4329 #[test]
4330 fn test_build_instrument_snapshots_empty_instruments_returns_only_with_orders() {
4331 let orders = vec![
4332 make_order_response("o1", "AAPL"),
4333 make_order_response("o2", "SPY"),
4334 ];
4335 let snapshots = build_instrument_snapshots(orders, &[]);
4336 assert_eq!(snapshots.len(), 2);
4337 let symbols: Vec<&str> = snapshots
4338 .iter()
4339 .map(|s| s.instrument.name().as_str())
4340 .collect();
4341 assert!(symbols.contains(&"AAPL"));
4342 assert!(symbols.contains(&"SPY"));
4343 }
4344
4345 #[test]
4346 fn test_build_instrument_snapshots_requested_instrument_no_orders_gets_empty_snapshot() {
4347 let orders = vec![make_order_response("o1", "AAPL")];
4350 let instruments = vec![
4351 InstrumentNameExchange::new("AAPL"),
4352 InstrumentNameExchange::new("SPY"),
4353 ];
4354 let snapshots = build_instrument_snapshots(orders, &instruments);
4355 assert_eq!(snapshots.len(), 2);
4356 let spy = snapshots
4357 .iter()
4358 .find(|s| s.instrument.name().as_str() == "SPY")
4359 .expect("SPY snapshot must be present even with no orders");
4360 assert!(spy.orders.is_empty());
4361 }
4362
4363 #[test]
4364 fn test_build_instrument_snapshots_non_requested_instrument_excluded() {
4365 let orders = vec![
4368 make_order_response("o1", "AAPL"),
4369 make_order_response("o2", "MSFT"), ];
4371 let instruments = vec![InstrumentNameExchange::new("AAPL")];
4372 let snapshots = build_instrument_snapshots(orders, &instruments);
4373 assert_eq!(snapshots.len(), 1);
4374 assert_eq!(snapshots[0].instrument.name().as_str(), "AAPL");
4375 }
4376
4377 #[test]
4383 fn test_recover_fills_dedup_key_matches_ws_path() {
4384 let order_id = "ord-1";
4385
4386 let ws_keys: Vec<SmolStr> = ["1", "2"]
4389 .iter()
4390 .filter_map(|filled_qty| {
4391 let update = AlpacaTradeUpdate {
4392 event: SmolStr::new("partial_fill"),
4393 order: make_order_ws(order_id, "SPY", "buy", filled_qty),
4394 price: Some("150.00"),
4395 qty: Some("1"),
4396 timestamp: None,
4397 };
4398 let event = convert_trade_update(update)?;
4399 fill_dedup_key_from_event(&event).cloned()
4400 })
4401 .collect();
4402
4403 let mut cumulative = Decimal::ZERO;
4406 let rest_keys: Vec<SmolStr> = ["1", "1"]
4407 .iter()
4408 .map(|exec_qty| {
4409 cumulative += Decimal::from_str(exec_qty).unwrap();
4410 format_smolstr!("{}:{}", order_id, cumulative.normalize())
4411 })
4412 .collect();
4413
4414 assert_eq!(
4415 ws_keys, rest_keys,
4416 "REST recovery dedup keys must match WS path keys for cross-source dedup to work"
4417 );
4418 assert_eq!(ws_keys[0].as_str(), "ord-1:1");
4419 assert_eq!(ws_keys[1].as_str(), "ord-1:2");
4420 }
4421
4422 #[test]
4428 fn early_dedup_key_matches_full_event_path() {
4429 let update = AlpacaTradeUpdate {
4430 event: SmolStr::new("fill"),
4431 order: make_order_ws("ord-abc", "SPY", "buy", "5"),
4432 price: Some("150.00"),
4433 qty: Some("5"),
4434 timestamp: None,
4435 };
4436
4437 let early_key = early_dedup_key(&update);
4439
4440 let event = convert_trade_update(update).expect("fill should produce an event");
4442 let full_key =
4443 fill_dedup_key_from_event(&event).expect("fill event should have a dedup key");
4444
4445 assert_eq!(
4446 early_key.as_str(),
4447 full_key.as_str(),
4448 "early_dedup_key must produce the same key as the full event path"
4449 );
4450 assert_eq!(early_key.as_str(), "ord-abc:5");
4451 }
4452
4453 #[test]
4458 fn early_dedup_key_normalizes_decimal_strings() {
4459 let update1 = AlpacaTradeUpdate {
4461 event: SmolStr::new("fill"),
4462 order: AlpacaOrderWs {
4463 id: SmolStr::new("ord-x"),
4464 client_order_id: Some(SmolStr::new("cid")),
4465 symbol: SmolStr::new("AAPL"),
4466 qty: Some("10"),
4467 filled_qty: Some("1.00"),
4468 side: SmolStr::new("buy"),
4469 order_type: SmolStr::new("market"),
4470 time_in_force: SmolStr::new("day"),
4471 limit_price: None,
4472 stop_price: None,
4473 trail_percent: None,
4474 trail_price: None,
4475 status: SmolStr::new("filled"),
4476 },
4477 price: Some("100.00"),
4478 qty: Some("10"),
4479 timestamp: None,
4480 };
4481 assert_eq!(early_dedup_key(&update1).as_str(), "ord-x:1");
4482
4483 let update2 = AlpacaTradeUpdate {
4485 event: SmolStr::new("fill"),
4486 order: AlpacaOrderWs {
4487 id: SmolStr::new("ord-x"),
4488 client_order_id: Some(SmolStr::new("cid")),
4489 symbol: SmolStr::new("AAPL"),
4490 qty: Some("10"),
4491 filled_qty: Some("1"),
4492 side: SmolStr::new("buy"),
4493 order_type: SmolStr::new("market"),
4494 time_in_force: SmolStr::new("day"),
4495 limit_price: None,
4496 stop_price: None,
4497 trail_percent: None,
4498 trail_price: None,
4499 status: SmolStr::new("filled"),
4500 },
4501 price: Some("100.00"),
4502 qty: Some("10"),
4503 timestamp: None,
4504 };
4505 assert_eq!(early_dedup_key(&update2).as_str(), "ord-x:1");
4506
4507 let update3 = AlpacaTradeUpdate {
4509 event: SmolStr::new("fill"),
4510 order: AlpacaOrderWs {
4511 id: SmolStr::new("ord-x"),
4512 client_order_id: Some(SmolStr::new("cid")),
4513 symbol: SmolStr::new("AAPL"),
4514 qty: Some("10"),
4515 filled_qty: Some("1.0"),
4516 side: SmolStr::new("buy"),
4517 order_type: SmolStr::new("market"),
4518 time_in_force: SmolStr::new("day"),
4519 limit_price: None,
4520 stop_price: None,
4521 trail_percent: None,
4522 trail_price: None,
4523 status: SmolStr::new("filled"),
4524 },
4525 price: Some("100.00"),
4526 qty: Some("10"),
4527 timestamp: None,
4528 };
4529 assert_eq!(early_dedup_key(&update3).as_str(), "ord-x:1");
4530 }
4531
4532 #[test]
4537 fn process_ws_text_rejected_event_without_filled_qty_is_not_dropped() {
4538 let (tx, mut rx) = mpsc::unbounded_channel();
4539 let dedup = new_dedup_cache();
4540 let mut backoff = ExponentialBackoff::new();
4541
4542 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"}}}"#;
4544
4545 process_ws_text(json, &tx, &dedup, &mut backoff);
4546
4547 let event = rx.try_recv()
4549 .expect("rejected event without filled_qty must produce an AccountEvent, not be silently dropped");
4550 assert!(
4551 matches!(event.kind, AccountEventKind::OrderCancelled(_)),
4552 "rejected event must map to OrderCancelled, got: {:?}",
4553 event.kind
4554 );
4555 }
4556
4557 #[test]
4560 fn decimal_zero_normalize_is_zero_str() {
4561 assert_eq!(Decimal::ZERO.normalize().to_string(), "0");
4562 }
4563
4564 #[test]
4568 fn decimal_normalize_strips_trailing_zeros() {
4569 let from_rest = Decimal::from_str("1.00").unwrap().normalize();
4571 let from_ws = Decimal::from_str("1").unwrap().normalize();
4572 assert_eq!(from_rest.to_string(), from_ws.to_string());
4573 assert_eq!(from_rest.to_string(), "1");
4574
4575 assert_eq!(
4577 Decimal::from_str("100.000")
4578 .unwrap()
4579 .normalize()
4580 .to_string(),
4581 "100"
4582 );
4583 assert_eq!(
4584 Decimal::from_str("0.10").unwrap().normalize().to_string(),
4585 "0.1"
4586 );
4587 assert_eq!(
4588 Decimal::from_str("0.100").unwrap().normalize().to_string(),
4589 "0.1"
4590 );
4591 }
4592
4593 #[test]
4598 fn convert_account_to_balances_zero_options_buying_power_falls_back_to_buying_power() {
4599 let account = AlpacaAccount {
4600 equity: "12000.00".into(),
4601 buying_power: "10000.00".into(),
4602 options_buying_power: Some("0.00".into()), crypto_buying_power: None,
4604 };
4605 let balances = convert_account_to_balances(&account, &[]);
4606 assert_eq!(balances.len(), 1);
4607 assert_eq!(
4608 balances[0].balance.free,
4609 Decimal::from_str("10000.00").unwrap(),
4610 "zero options_buying_power must fall back to buying_power, not report free=0"
4611 );
4612 }
4613
4614 #[test]
4616 fn map_position_intent_open_buy_maps_to_buy_to_open() {
4617 assert_eq!(
4618 map_position_intent(Side::Buy, false),
4619 AlpacaPositionIntent::BuyToOpen
4620 );
4621 }
4622
4623 #[test]
4624 fn map_position_intent_open_sell_maps_to_sell_to_open() {
4625 assert_eq!(
4626 map_position_intent(Side::Sell, false),
4627 AlpacaPositionIntent::SellToOpen
4628 );
4629 }
4630
4631 #[test]
4632 fn map_position_intent_reduce_buy_maps_to_buy_to_close() {
4633 assert_eq!(
4634 map_position_intent(Side::Buy, true),
4635 AlpacaPositionIntent::BuyToClose
4636 );
4637 }
4638
4639 #[test]
4640 fn map_position_intent_reduce_sell_maps_to_sell_to_close() {
4641 assert_eq!(
4642 map_position_intent(Side::Sell, true),
4643 AlpacaPositionIntent::SellToClose
4644 );
4645 }
4646
4647 #[test]
4649 fn parse_order_error_401_maps_to_unauthenticated() {
4650 assert!(matches!(
4652 parse_order_error(reqwest::StatusCode::UNAUTHORIZED, "bad credentials"),
4653 UnindexedOrderError::Rejected(ApiError::Unauthenticated(_))
4654 ));
4655 }
4656
4657 #[test]
4658 fn parse_order_error_403_maps_to_unauthenticated() {
4659 assert!(matches!(
4662 parse_order_error(reqwest::StatusCode::FORBIDDEN, "account suspended"),
4663 UnindexedOrderError::Rejected(ApiError::Unauthenticated(_))
4664 ));
4665 }
4666
4667 #[test]
4668 fn parse_order_error_404_maps_to_order_rejected_with_not_found_prefix() {
4669 let err = parse_order_error(reqwest::StatusCode::NOT_FOUND, "order not found");
4670 let UnindexedOrderError::Rejected(ApiError::OrderRejected(msg)) = err else {
4671 panic!("expected OrderRejected, got {err:?}");
4672 };
4673 assert!(
4674 msg.contains("order not found"),
4675 "message should contain 'order not found': {msg}"
4676 );
4677 }
4678
4679 #[test]
4680 fn parse_order_error_422_insufficient_only_maps_to_balance_insufficient() {
4681 assert!(matches!(
4683 parse_order_error(
4684 reqwest::StatusCode::UNPROCESSABLE_ENTITY,
4685 "insufficient funds for this order"
4686 ),
4687 UnindexedOrderError::Rejected(ApiError::BalanceInsufficient(_, _))
4688 ));
4689 }
4690
4691 #[test]
4692 fn parse_order_error_429_maps_to_rate_limit() {
4693 assert!(matches!(
4694 parse_order_error(reqwest::StatusCode::TOO_MANY_REQUESTS, "rate limited"),
4695 UnindexedOrderError::Rejected(ApiError::RateLimit)
4696 ));
4697 }
4698
4699 #[test]
4703 fn parse_time_in_force_unknown_value_falls_back_to_good_until_end_of_day() {
4704 assert_eq!(
4705 parse_time_in_force("opg"),
4706 TimeInForce::GoodUntilEndOfDay,
4707 "unknown TIF must fall back to GoodUntilEndOfDay (with a warn! in production)"
4708 );
4709 }
4710
4711 #[test]
4714 fn build_instrument_snapshots_output_order_matches_instruments_slice() {
4715 let orders = vec![
4717 make_order_response("o1", "SPY"),
4718 make_order_response("o2", "AAPL"),
4719 make_order_response("o3", "MSFT"),
4720 ];
4721 let instruments = vec![
4723 InstrumentNameExchange::new("MSFT"),
4724 InstrumentNameExchange::new("AAPL"),
4725 ];
4726 let snapshots = build_instrument_snapshots(orders, &instruments);
4727 assert_eq!(snapshots.len(), 2);
4728 assert_eq!(
4729 snapshots[0].instrument.name().as_str(),
4730 "MSFT",
4731 "first snapshot must be MSFT (first in instruments slice)"
4732 );
4733 assert_eq!(
4734 snapshots[1].instrument.name().as_str(),
4735 "AAPL",
4736 "second snapshot must be AAPL (second in instruments slice)"
4737 );
4738 }
4739
4740 mod http_tests {
4747 use super::super::*;
4748 use wiremock::matchers::{method, path};
4749 use wiremock::{Mock, MockServer, Request, Respond, ResponseTemplate};
4750
4751 struct Sequential {
4758 call: std::sync::atomic::AtomicU32,
4759 pages: Vec<serde_json::Value>,
4760 }
4761
4762 impl Sequential {
4763 fn new(pages: Vec<serde_json::Value>) -> Self {
4764 Self {
4765 call: std::sync::atomic::AtomicU32::new(0),
4766 pages,
4767 }
4768 }
4769 }
4770
4771 impl Respond for Sequential {
4772 fn respond(&self, _: &Request) -> ResponseTemplate {
4773 let i = self.call.fetch_add(1, std::sync::atomic::Ordering::Relaxed) as usize;
4774 let body = self.pages.get(i).unwrap_or_else(|| {
4775 panic!(
4776 "Sequential: request #{i} has no configured response \
4777 (only {} page(s) supplied)",
4778 self.pages.len()
4779 )
4780 });
4781 ResponseTemplate::new(200).set_body_json(body)
4782 }
4783 }
4784
4785 fn make_activities_json(count: usize, id_prefix: &str) -> serde_json::Value {
4787 serde_json::Value::Array(
4788 (0..count)
4789 .map(|i| {
4790 serde_json::json!({
4791 "id": format!("{id_prefix}-{i:05}"),
4792 "order_id": "ord-1",
4793 "symbol": "SPY",
4794 "side": "buy",
4795 "price": "100.00",
4796 "qty": "1",
4797 "transaction_time": "2025-04-18T14:30:00Z"
4798 })
4799 })
4800 .collect(),
4801 )
4802 }
4803
4804 fn make_orders_json(count: usize) -> serde_json::Value {
4806 serde_json::Value::Array(
4807 (0..count)
4808 .map(|i| {
4809 serde_json::json!({
4810 "id": format!("order-{i:05}"),
4811 "client_order_id": null,
4812 "symbol": "SPY",
4813 "qty": "1",
4814 "filled_qty": "0",
4815 "side": "buy",
4816 "type": "limit",
4817 "time_in_force": "day",
4818 "limit_price": "100.00",
4819 "created_at": "2025-04-18T14:30:00Z"
4820 })
4821 })
4822 .collect(),
4823 )
4824 }
4825
4826 #[tokio::test]
4831 async fn paginate_activities_single_page_below_max_returns_all_not_truncated() {
4832 let server = MockServer::start().await;
4833 Mock::given(method("GET"))
4834 .and(path("/v2/account/activities"))
4835 .respond_with(
4836 ResponseTemplate::new(200).set_body_json(make_activities_json(5, "act")),
4837 )
4838 .mount(&server)
4839 .await;
4840
4841 let http = reqwest::Client::new();
4842 let rl = RateLimitTracker::new();
4843 let result = paginate_activities(&http, &rl, &server.uri(), "2025-01-01T00:00:00Z")
4844 .await
4845 .unwrap();
4846
4847 assert_eq!(result.activities.len(), 5);
4848 assert!(!result.truncated);
4849 assert_eq!(server.received_requests().await.unwrap().len(), 1);
4850 }
4851
4852 #[tokio::test]
4855 async fn paginate_activities_exactly_page_size_items_fetches_second_page() {
4856 let server = MockServer::start().await;
4857 Mock::given(method("GET"))
4858 .and(path("/v2/account/activities"))
4859 .respond_with(Sequential::new(vec![
4860 make_activities_json(ALPACA_MAX_ACTIVITIES, "act"),
4861 serde_json::json!([]), ]))
4863 .mount(&server)
4864 .await;
4865
4866 let http = reqwest::Client::new();
4867 let rl = RateLimitTracker::new();
4868 let result = paginate_activities(&http, &rl, &server.uri(), "2025-01-01T00:00:00Z")
4869 .await
4870 .unwrap();
4871
4872 assert_eq!(result.activities.len(), ALPACA_MAX_ACTIVITIES);
4873 assert!(!result.truncated);
4874 assert_eq!(
4875 server.received_requests().await.unwrap().len(),
4876 2,
4877 "exactly 2 requests: first full page + second empty page"
4878 );
4879 }
4880
4881 #[tokio::test]
4884 async fn paginate_activities_two_pages_returns_combined_activities_not_truncated() {
4885 let server = MockServer::start().await;
4886 Mock::given(method("GET"))
4887 .and(path("/v2/account/activities"))
4888 .respond_with(Sequential::new(vec![
4889 make_activities_json(ALPACA_MAX_ACTIVITIES, "p1"),
4890 make_activities_json(37, "p2"),
4891 ]))
4892 .mount(&server)
4893 .await;
4894
4895 let http = reqwest::Client::new();
4896 let rl = RateLimitTracker::new();
4897 let result = paginate_activities(&http, &rl, &server.uri(), "2025-01-01T00:00:00Z")
4898 .await
4899 .unwrap();
4900
4901 assert_eq!(result.activities.len(), ALPACA_MAX_ACTIVITIES + 37);
4902 assert!(!result.truncated);
4903 assert_eq!(server.received_requests().await.unwrap().len(), 2);
4904 }
4905
4906 #[tokio::test]
4910 async fn paginate_activities_at_max_pages_sets_truncated_true() {
4911 let server = MockServer::start().await;
4912
4913 Mock::given(method("GET"))
4915 .and(path("/v2/account/activities"))
4916 .respond_with(
4917 ResponseTemplate::new(200)
4918 .set_body_json(make_activities_json(ALPACA_MAX_ACTIVITIES, "act")),
4919 )
4920 .mount(&server)
4921 .await;
4922
4923 let http = reqwest::Client::new();
4924 let rl = RateLimitTracker::new();
4925 let result = paginate_activities(&http, &rl, &server.uri(), "2025-01-01T00:00:00Z")
4926 .await
4927 .unwrap();
4928
4929 assert!(
4930 result.truncated,
4931 "must be truncated after MAX_ACTIVITY_PAGES pages"
4932 );
4933 assert_eq!(
4934 result.activities.len(),
4935 MAX_ACTIVITY_PAGES * ALPACA_MAX_ACTIVITIES,
4936 "must accumulate exactly MAX_ACTIVITY_PAGES * page_size activities"
4937 );
4938 assert_eq!(
4941 server.received_requests().await.unwrap().len(),
4942 MAX_ACTIVITY_PAGES,
4943 "loop must issue exactly MAX_ACTIVITY_PAGES requests then stop"
4944 );
4945 }
4946
4947 #[tokio::test]
4951 async fn fetch_raw_open_orders_499_results_returns_ok() {
4952 let server = MockServer::start().await;
4953 Mock::given(method("GET"))
4954 .and(path("/v2/orders"))
4955 .respond_with(
4956 ResponseTemplate::new(200).set_body_json(make_orders_json(MAX_OPEN_ORDERS - 1)),
4957 )
4958 .mount(&server)
4959 .await;
4960
4961 let http = reqwest::Client::new();
4962 let rl = RateLimitTracker::new();
4963 let result = fetch_raw_open_orders(&http, &rl, &server.uri(), &[]).await;
4964
4965 assert!(
4966 result.is_ok(),
4967 "499 orders must not trigger truncation: {result:?}"
4968 );
4969 assert_eq!(result.unwrap().len(), MAX_OPEN_ORDERS - 1);
4970 }
4971
4972 #[tokio::test]
4976 async fn fetch_raw_open_orders_500_results_returns_truncated_snapshot_error() {
4977 let server = MockServer::start().await;
4978 Mock::given(method("GET"))
4979 .and(path("/v2/orders"))
4980 .respond_with(
4981 ResponseTemplate::new(200).set_body_json(make_orders_json(MAX_OPEN_ORDERS)),
4982 )
4983 .mount(&server)
4984 .await;
4985
4986 let http = reqwest::Client::new();
4987 let rl = RateLimitTracker::new();
4988 let result = fetch_raw_open_orders(&http, &rl, &server.uri(), &[]).await;
4989
4990 assert!(
4991 matches!(
4992 result,
4993 Err(UnindexedClientError::TruncatedSnapshot { limit }) if limit == MAX_OPEN_ORDERS
4994 ),
4995 "500 orders must return TruncatedSnapshot, got: {result:?}"
4996 );
4997 }
4998
4999 #[tokio::test]
5007 async fn open_order_reduce_only_sell_sends_sell_to_close_intent() {
5008 use crate::client::ExecutionClient;
5009 use crate::order::request::{OrderRequestOpen, RequestOpen};
5010 use crate::order::{
5011 OrderKey, OrderKind, TimeInForce,
5012 id::{ClientOrderId, StrategyId},
5013 };
5014 use rust_decimal::Decimal;
5015 use rustrade_instrument::Side;
5016 use rustrade_instrument::exchange::ExchangeId;
5017 use rustrade_instrument::instrument::name::InstrumentNameExchange;
5018 use wiremock::matchers::{method, path};
5019
5020 let server = MockServer::start().await;
5021
5022 let captured_body = std::sync::Arc::new(parking_lot::Mutex::new(None));
5025 let captured_clone = captured_body.clone();
5026
5027 Mock::given(method("POST"))
5028 .and(path("/v2/orders"))
5029 .respond_with(move |req: &Request| {
5030 let body: serde_json::Value = serde_json::from_slice(&req.body).unwrap();
5032 *captured_clone.lock() = Some(body);
5033
5034 ResponseTemplate::new(200).set_body_json(serde_json::json!({
5035 "id": "test-order-id",
5036 "client_order_id": "test-cid",
5037 "symbol": "AAPL",
5038 "qty": "10",
5039 "filled_qty": "0",
5040 "side": "sell",
5041 "type": "market",
5042 "time_in_force": "ioc",
5043 "limit_price": null,
5044 "created_at": "2025-04-18T14:30:00Z"
5045 }))
5046 })
5047 .mount(&server)
5048 .await;
5049
5050 let config =
5052 AlpacaConfig::with_base_url("test-key".into(), "test-secret".into(), server.uri());
5053 let client = AlpacaClient::new(config);
5054
5055 let request = OrderRequestOpen {
5057 key: OrderKey {
5058 exchange: ExchangeId::AlpacaBroker,
5059 instrument: InstrumentNameExchange::new("AAPL"),
5060 strategy: StrategyId::new("test-strategy"),
5061 cid: ClientOrderId::new("test-cid"),
5062 },
5063 state: RequestOpen {
5064 side: Side::Sell,
5065 price: None,
5066 quantity: Decimal::new(10, 0),
5067 kind: OrderKind::Market,
5068 time_in_force: TimeInForce::ImmediateOrCancel,
5069 position_id: None,
5070 reduce_only: true, },
5072 };
5073
5074 let result = client
5076 .open_order(OrderRequestOpen {
5077 key: OrderKey {
5078 exchange: request.key.exchange,
5079 instrument: &request.key.instrument,
5080 strategy: request.key.strategy.clone(),
5081 cid: request.key.cid.clone(),
5082 },
5083 state: request.state.clone(),
5084 })
5085 .await;
5086
5087 assert!(result.is_some(), "open_order should return a result");
5089 let order = result.unwrap();
5090 assert!(
5091 order.state.is_accepted(),
5092 "order should be accepted: {:?}",
5093 order.state
5094 );
5095
5096 let body = captured_body
5098 .lock()
5099 .take()
5100 .expect("request body should be captured");
5101 assert_eq!(
5102 body.get("position_intent").and_then(|v| v.as_str()),
5103 Some("sell_to_close"),
5104 "reduce_only=true + Side::Sell should produce position_intent=sell_to_close, got: {body}"
5105 );
5106 }
5107
5108 #[tokio::test]
5110 async fn open_order_not_reduce_only_buy_sends_buy_to_open_intent() {
5111 use crate::client::ExecutionClient;
5112 use crate::order::request::{OrderRequestOpen, RequestOpen};
5113 use crate::order::{
5114 OrderKey, OrderKind, TimeInForce,
5115 id::{ClientOrderId, StrategyId},
5116 };
5117 use rust_decimal::Decimal;
5118 use rustrade_instrument::Side;
5119 use rustrade_instrument::exchange::ExchangeId;
5120 use rustrade_instrument::instrument::name::InstrumentNameExchange;
5121
5122 let server = MockServer::start().await;
5123
5124 let captured_body = std::sync::Arc::new(parking_lot::Mutex::new(None));
5125 let captured_clone = captured_body.clone();
5126
5127 Mock::given(method("POST"))
5128 .and(path("/v2/orders"))
5129 .respond_with(move |req: &Request| {
5130 let body: serde_json::Value = serde_json::from_slice(&req.body).unwrap();
5131 *captured_clone.lock() = Some(body);
5132
5133 ResponseTemplate::new(200).set_body_json(serde_json::json!({
5134 "id": "test-order-id",
5135 "client_order_id": "test-cid",
5136 "symbol": "AAPL",
5137 "qty": "10",
5138 "filled_qty": "0",
5139 "side": "buy",
5140 "type": "market",
5141 "time_in_force": "ioc",
5142 "limit_price": null,
5143 "created_at": "2025-04-18T14:30:00Z"
5144 }))
5145 })
5146 .mount(&server)
5147 .await;
5148
5149 let config =
5150 AlpacaConfig::with_base_url("test-key".into(), "test-secret".into(), server.uri());
5151 let client = AlpacaClient::new(config);
5152
5153 let instrument = InstrumentNameExchange::new("AAPL");
5154 let request = OrderRequestOpen {
5155 key: OrderKey {
5156 exchange: ExchangeId::AlpacaBroker,
5157 instrument: &instrument,
5158 strategy: StrategyId::new("test-strategy"),
5159 cid: ClientOrderId::new("test-cid"),
5160 },
5161 state: RequestOpen {
5162 side: Side::Buy,
5163 price: None,
5164 quantity: Decimal::new(10, 0),
5165 kind: OrderKind::Market,
5166 time_in_force: TimeInForce::ImmediateOrCancel,
5167 position_id: None,
5168 reduce_only: false, },
5170 };
5171
5172 let result = client.open_order(request).await;
5173
5174 assert!(result.is_some(), "open_order should return a result");
5175 let order = result.unwrap();
5176 assert!(
5177 order.state.is_accepted(),
5178 "order should be accepted: {:?}",
5179 order.state
5180 );
5181
5182 let body = captured_body
5183 .lock()
5184 .take()
5185 .expect("request body should be captured");
5186 assert_eq!(
5187 body.get("position_intent").and_then(|v| v.as_str()),
5188 Some("buy_to_open"),
5189 "reduce_only=false + Side::Buy should produce position_intent=buy_to_open, got: {body}"
5190 );
5191 }
5192 }
5193}