1use crate::{
40 AccountEventKind, AccountSnapshot, InstrumentAccountSnapshot, UnindexedAccountEvent,
41 UnindexedAccountSnapshot,
42 balance::{AssetBalance, Balance},
43 client::{BracketOrderClient, ExecutionClient},
44 emit_stream_terminated,
45 error::{
46 ApiError, ConnectivityError, OrderError, StreamTerminationReason, UnindexedClientError,
47 UnindexedOrderError,
48 },
49 order::{
50 Order, OrderKey, OrderKind, TimeInForce, TrailingOffsetType, UnindexedOrderSnapshot,
51 bracket::{
52 BracketOrderRequest as UnifiedBracketOrderRequest,
53 BracketOrderResult as UnifiedBracketOrderResult,
54 },
55 id::{ClientOrderId, OrderId, StrategyId, VenueOrderId},
56 request::{OrderRequestCancel, OrderRequestOpen, UnindexedOrderResponseCancel},
57 state::{Cancelled, Filled, Open, OrderState, UnindexedOrderState},
58 },
59 parse_env_bool,
60 position::{Position, PositionReport},
61 trade::{AssetFees, Trade, TradeId},
62};
63use chrono::{DateTime, Utc};
64use fnv::FnvHashMap;
65use futures::{SinkExt as _, StreamExt as _, stream::BoxStream};
66use indexmap::IndexMap;
67use itertools::Itertools as _;
68use lru::LruCache;
69use rust_decimal::Decimal;
70use rustrade_instrument::{
71 Side,
72 asset::name::AssetNameExchange,
73 exchange::ExchangeId,
74 instrument::{kind::InstrumentKindDiscriminant, name::InstrumentNameExchange},
75};
76use rustrade_integration::protocol::websocket::{WebSocket, WsMessage};
77use serde::{Deserialize, Serialize};
78use smol_str::{SmolStr, format_smolstr};
79use std::{num::NonZeroUsize, pin::Pin, str::FromStr, sync::Arc, time::Duration};
80use tokio::sync::mpsc;
81use tracing::{debug, error, info, trace, warn};
82
83const INITIAL_BACKOFF_MS: u64 = 1_000;
88const MAX_BACKOFF_MS: u64 = 30_000;
89const MAX_RECONNECT_ATTEMPTS: u32 = 10;
90const HEARTBEAT_TIMEOUT_SECS: u64 = 35;
92const FILL_RECOVERY_TIMEOUT_SECS: u64 = 30;
94const SIGNAL_RECOVERY_LOOKBACK_MS: i64 = 1_500;
97const ALPACA_MAX_ACTIVITIES: usize = 100;
99const DEFAULT_RATE_LIMIT_DELAY_SECS: u64 = 60;
101const MAX_RATE_LIMIT_ATTEMPTS: u32 = 4;
104const DEDUP_CACHE_SIZE: usize = 2_000;
107const WS_HANDSHAKE_TIMEOUT_SECS: u64 = 15;
109const WS_CLOSE_TIMEOUT_SECS: u64 = 5;
112
113struct GracefulShutdownStream<S> {
130 inner: S,
131 _handle: tokio::task::JoinHandle<()>,
137}
138
139impl<S> GracefulShutdownStream<S> {
140 fn new(inner: S, handle: tokio::task::JoinHandle<()>) -> Self {
141 Self {
142 inner,
143 _handle: handle,
144 }
145 }
146}
147
148impl<S: futures::Stream + Unpin> futures::Stream for GracefulShutdownStream<S> {
149 type Item = S::Item;
150 fn poll_next(
151 mut self: Pin<&mut Self>,
152 cx: &mut std::task::Context<'_>,
153 ) -> std::task::Poll<Option<Self::Item>> {
154 Pin::new(&mut self.inner).poll_next(cx)
155 }
156 fn size_hint(&self) -> (usize, Option<usize>) {
157 self.inner.size_hint()
158 }
159}
160
161impl<S> Drop for GracefulShutdownStream<S> {
162 fn drop(&mut self) {
163 }
167}
168
169struct RateLimitTracker {
175 blocked_until: parking_lot::Mutex<Option<tokio::time::Instant>>,
176}
177
178impl RateLimitTracker {
179 fn new() -> Self {
180 Self {
181 blocked_until: parking_lot::Mutex::new(None),
182 }
183 }
184
185 async fn wait_if_blocked(&self) {
187 loop {
188 let now = tokio::time::Instant::now();
192 let deadline = {
200 let mut guard = self.blocked_until.lock();
201 let d = *guard;
202 if matches!(d, Some(t) if t <= now) {
203 *guard = None;
206 }
207 d
208 };
209 match deadline {
210 None => return,
211 Some(until) => {
212 if until <= now {
213 return;
215 }
216 #[allow(clippy::cast_possible_truncation)]
218 let delay_ms = (until - now).as_millis() as u64;
219 debug!(delay_ms, "Alpaca REST rate-limited, waiting before request");
220 tokio::time::sleep_until(until).await;
221 }
222 }
223 }
224 }
225
226 fn on_rate_limited(&self, retry_after: Option<Duration>) {
228 let delay = retry_after.unwrap_or(Duration::from_secs(DEFAULT_RATE_LIMIT_DELAY_SECS));
229 let was_blocked = self.extend(delay);
230 if was_blocked {
231 debug!(
232 delay_secs = delay.as_secs(),
233 "Alpaca rate-limit cooldown extended"
234 );
235 } else {
236 warn!(
237 delay_secs = delay.as_secs(),
238 "Alpaca entering rate-limit degradation mode"
239 );
240 }
241 }
242
243 fn on_bucket_exhausted(&self, reset: Duration) {
246 let was_blocked = self.extend(reset);
247 if !was_blocked {
249 info!(
250 delay_ms = u64::try_from(reset.as_millis()).unwrap_or(u64::MAX),
251 "Alpaca rate-limit window exhausted (X-Ratelimit-Remaining: 0), pausing requests until it resets"
252 );
253 }
254 }
255
256 fn extend(&self, delay: Duration) -> bool {
259 let now = tokio::time::Instant::now();
260 let new_deadline = now + delay;
261 let mut guard = self.blocked_until.lock();
262 let was_blocked = guard.is_some_and(|until| until > now);
263 *guard = Some(guard.map_or(new_deadline, |existing| existing.max(new_deadline)));
264 was_blocked
265 }
266}
267
268struct ExponentialBackoff {
273 attempt: u32,
274 max_attempts: u32,
275 initial_ms: u64,
276 max_ms: u64,
277}
278
279impl ExponentialBackoff {
280 fn new() -> Self {
281 Self {
282 attempt: 0,
283 max_attempts: MAX_RECONNECT_ATTEMPTS,
284 initial_ms: INITIAL_BACKOFF_MS,
285 max_ms: MAX_BACKOFF_MS,
286 }
287 }
288
289 fn reset(&mut self) {
290 self.attempt = 0;
291 }
292
293 fn attempts(&self) -> u32 {
299 self.attempt
300 }
301
302 async fn wait(&mut self) -> bool {
304 if self.attempt >= self.max_attempts {
305 return false;
306 }
307 let delay_ms = self
308 .initial_ms
309 .saturating_mul(2u64.saturating_pow(self.attempt))
310 .min(self.max_ms);
311 self.attempt += 1;
312 debug!(
313 attempt = self.attempt,
314 max = self.max_attempts,
315 delay_ms,
316 "Alpaca reconnect backoff"
317 );
318 tokio::time::sleep(Duration::from_millis(delay_ms)).await;
319 true
320 }
321}
322
323type SharedDedupCache = Arc<parking_lot::Mutex<LruCache<SmolStr, ()>>>;
348
349fn new_dedup_cache() -> SharedDedupCache {
350 #[allow(clippy::unwrap_used)]
353 Arc::new(parking_lot::Mutex::new(LruCache::new(
354 NonZeroUsize::new(DEDUP_CACHE_SIZE).unwrap(),
355 )))
356}
357
358fn is_duplicate(cache: &SharedDedupCache, key: &SmolStr) -> bool {
360 let mut guard = cache.lock();
361 if guard.peek(key).is_some() {
363 return true;
364 }
365 guard.put(key.clone(), ());
369 false
370}
371
372#[derive(Clone, Deserialize)]
379#[serde(deny_unknown_fields)]
380pub struct AlpacaConfig {
381 api_key: String,
383 secret_key: String,
384
385 #[serde(default = "default_paper")]
387 pub paper: bool,
388 #[cfg(test)]
390 pub base_url_override: Option<String>,
391}
392
393fn default_paper() -> bool {
399 true
400}
401
402impl std::fmt::Debug for AlpacaConfig {
404 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
405 f.debug_struct("AlpacaConfig")
406 .field("api_key", &"***")
407 .field("secret_key", &"***")
408 .field("paper", &self.paper)
409 .finish()
410 }
411}
412
413impl AlpacaConfig {
414 pub fn new(api_key: String, secret_key: String) -> Self {
417 Self::paper(api_key, secret_key)
418 }
419
420 pub fn paper(api_key: String, secret_key: String) -> Self {
422 Self {
423 api_key,
424 secret_key,
425 paper: true,
426 #[cfg(test)]
427 base_url_override: None,
428 }
429 }
430
431 pub fn production(api_key: String, secret_key: String) -> Self {
436 Self {
437 api_key,
438 secret_key,
439 paper: false,
440 #[cfg(test)]
441 base_url_override: None,
442 }
443 }
444
445 pub fn from_env() -> Result<Self, AlpacaConfigError> {
463 let api_key = match std::env::var("ALPACA_API_KEY") {
464 Ok(value) => value,
465 Err(std::env::VarError::NotPresent) => return Err(AlpacaConfigError::MissingApiKey),
466 Err(std::env::VarError::NotUnicode(_)) => {
467 return Err(AlpacaConfigError::InvalidApiKey);
468 }
469 };
470 let secret_key = match std::env::var("ALPACA_SECRET_KEY") {
471 Ok(value) => value,
472 Err(std::env::VarError::NotPresent) => return Err(AlpacaConfigError::MissingSecretKey),
473 Err(std::env::VarError::NotUnicode(_)) => {
474 return Err(AlpacaConfigError::InvalidSecretKey);
475 }
476 };
477
478 let paper = match std::env::var("ALPACA_PAPER") {
479 Ok(value) => parse_env_bool(&value).ok_or(AlpacaConfigError::InvalidPaper(value))?,
480 Err(std::env::VarError::NotPresent) => true,
481 Err(std::env::VarError::NotUnicode(value)) => {
484 return Err(AlpacaConfigError::InvalidPaper(
485 value.to_string_lossy().into_owned(),
486 ));
487 }
488 };
489
490 if paper {
491 Ok(Self::paper(api_key, secret_key))
492 } else {
493 Ok(Self::production(api_key, secret_key))
494 }
495 }
496
497 #[cfg(test)]
499 pub fn with_base_url(api_key: String, secret_key: String, base_url: String) -> Self {
500 Self {
501 api_key,
502 secret_key,
503 paper: true,
504 base_url_override: Some(base_url),
505 }
506 }
507
508 pub fn api_key(&self) -> &str {
509 &self.api_key
510 }
511
512 pub fn rest_base_url(&self) -> &str {
516 #[cfg(test)]
517 if let Some(ref url) = self.base_url_override {
518 return url.as_str();
519 }
520 if self.paper {
521 "https://paper-api.alpaca.markets"
522 } else {
523 "https://api.alpaca.markets"
524 }
525 }
526
527 pub fn ws_url(&self) -> &'static str {
529 if self.paper {
530 "wss://paper-api.alpaca.markets/stream"
531 } else {
532 "wss://api.alpaca.markets/stream"
533 }
534 }
535}
536
537#[derive(Debug, PartialEq, thiserror::Error)]
538pub enum AlpacaConfigError {
539 #[error("ALPACA_API_KEY environment variable not set")]
540 MissingApiKey,
541
542 #[error("ALPACA_API_KEY environment variable is not valid UTF-8")]
545 InvalidApiKey,
546
547 #[error("ALPACA_SECRET_KEY environment variable not set")]
548 MissingSecretKey,
549
550 #[error("ALPACA_SECRET_KEY environment variable is not valid UTF-8")]
551 InvalidSecretKey,
552
553 #[error("ALPACA_PAPER must be true or false, got {0}")]
554 InvalidPaper(String),
555}
556
557#[derive(Debug, Deserialize)]
566struct AlpacaAccount {
567 #[serde(with = "rust_decimal::serde::str")]
570 cash: Decimal,
571 #[serde(with = "rust_decimal::serde::str")]
575 non_marginable_buying_power: Decimal,
576}
577
578#[derive(Debug, Deserialize)]
583struct AlpacaPosition {
584 symbol: String,
587 asset_class: String,
589 #[serde(default)]
593 side: AlpacaPositionSide,
594 #[serde(with = "rust_decimal::serde::str")]
596 qty: Decimal,
597 #[serde(with = "rust_decimal::serde::str")]
599 qty_available: Decimal,
600 #[serde(default, with = "rust_decimal::serde::str_option")]
602 avg_entry_price: Option<Decimal>,
603 #[serde(default, with = "rust_decimal::serde::str_option")]
605 unrealized_pl: Option<Decimal>,
606}
607
608#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize)]
610#[serde(rename_all = "lowercase")]
611enum AlpacaPositionSide {
612 Long,
613 Short,
614 #[default]
618 #[serde(other)]
619 Unknown,
620}
621
622#[derive(Debug, Deserialize)]
623struct AlpacaOrderResponse {
624 id: String,
625 client_order_id: Option<String>,
626 symbol: String,
627 qty: Option<String>,
628 filled_qty: String,
629 side: String,
630 #[serde(rename = "type")]
631 order_type: String,
632 time_in_force: String,
633 limit_price: Option<String>,
634 stop_price: Option<String>,
635 trail_percent: Option<String>,
636 trail_price: Option<String>,
637 created_at: String,
638 updated_at: Option<String>,
641}
642
643#[derive(Debug, Deserialize)]
644struct AlpacaActivity {
645 id: String,
646 order_id: String,
647 symbol: String,
648 side: String,
649 price: String,
650 qty: String,
651 transaction_time: String,
652 cum_qty: Option<String>,
660}
661
662#[derive(Debug, Deserialize)]
663struct AlpacaApiError {
664 message: String,
665}
666
667#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
679#[serde(rename_all = "snake_case")]
680pub enum AlpacaPositionIntent {
681 BuyToOpen,
682 BuyToClose,
683 SellToOpen,
684 SellToClose,
685}
686
687#[derive(Debug, Serialize)]
695struct TakeProfitParams {
696 limit_price: String,
697}
698
699#[derive(Debug, Serialize)]
704struct StopLossParams {
705 stop_price: String,
706 #[serde(skip_serializing_if = "Option::is_none")]
707 limit_price: Option<String>,
708}
709
710#[non_exhaustive]
745#[derive(Debug, Clone)]
746pub struct AlpacaBracketOrderRequest {
747 pub instrument: InstrumentNameExchange,
749 pub strategy: StrategyId,
751 pub cid: ClientOrderId,
753 pub side: Side,
755 pub quantity: Decimal,
757 pub entry_price: Decimal,
759 pub take_profit_price: Decimal,
761 pub stop_loss_price: Decimal,
763 pub stop_loss_limit_price: Option<Decimal>,
765 pub time_in_force: TimeInForce,
767}
768
769impl AlpacaBracketOrderRequest {
770 #[allow(clippy::too_many_arguments)] pub fn new(
775 instrument: InstrumentNameExchange,
776 strategy: StrategyId,
777 cid: ClientOrderId,
778 side: Side,
779 quantity: Decimal,
780 entry_price: Decimal,
781 take_profit_price: Decimal,
782 stop_loss_price: Decimal,
783 time_in_force: TimeInForce,
784 ) -> Self {
785 Self {
786 instrument,
787 strategy,
788 cid,
789 side,
790 quantity,
791 entry_price,
792 take_profit_price,
793 stop_loss_price,
794 stop_loss_limit_price: None,
795 time_in_force,
796 }
797 }
798
799 #[must_use]
801 pub fn with_stop_loss_limit_price(mut self, price: Decimal) -> Self {
802 self.stop_loss_limit_price = Some(price);
803 self
804 }
805}
806
807#[non_exhaustive]
818#[derive(Debug, Clone)]
819pub struct AlpacaBracketOrderResult {
820 pub parent: Order<ExchangeId, InstrumentNameExchange, UnindexedOrderState>,
822}
823
824#[derive(Debug, Serialize)]
829struct AlpacaOrderRequest<'a> {
830 symbol: &'a str,
831 qty: String,
832 side: &'static str,
833 #[serde(rename = "type")]
834 order_type: &'static str,
835 time_in_force: &'static str,
836 #[serde(skip_serializing_if = "Option::is_none")]
837 limit_price: Option<String>,
838 #[serde(skip_serializing_if = "Option::is_none")]
839 stop_price: Option<String>,
840 #[serde(skip_serializing_if = "Option::is_none")]
841 trail_percent: Option<String>,
842 #[serde(skip_serializing_if = "Option::is_none")]
843 trail_price: Option<String>,
844 #[serde(skip_serializing_if = "Option::is_none")]
845 client_order_id: Option<&'a str>,
846 #[serde(skip_serializing_if = "Option::is_none")]
850 position_intent: Option<AlpacaPositionIntent>,
851 #[serde(skip_serializing_if = "Option::is_none")]
854 order_class: Option<&'static str>,
855 #[serde(skip_serializing_if = "Option::is_none")]
856 take_profit: Option<TakeProfitParams>,
857 #[serde(skip_serializing_if = "Option::is_none")]
858 stop_loss: Option<StopLossParams>,
859}
860
861#[derive(Debug, Deserialize)]
870struct AlpacaStreamMessage<'a> {
871 stream: SmolStr,
874 #[serde(borrow)]
875 data: &'a serde_json::value::RawValue,
876}
877
878#[derive(Debug, Deserialize)]
885struct AlpacaTradeUpdate<'a> {
886 event: SmolStr,
888 #[serde(borrow)]
889 order: AlpacaOrderWs<'a>,
890 #[serde(borrow)]
892 price: Option<&'a str>,
893 #[serde(borrow)]
895 qty: Option<&'a str>,
896 #[serde(borrow)]
897 timestamp: Option<&'a str>,
898}
899
900#[derive(Debug, Deserialize)]
902struct AlpacaOrderWs<'a> {
903 id: SmolStr,
906 client_order_id: Option<SmolStr>,
907 symbol: SmolStr,
910 #[serde(borrow)]
911 qty: Option<&'a str>,
912 #[serde(borrow)]
917 filled_qty: Option<&'a str>,
918 side: SmolStr,
921 #[serde(rename = "type")]
922 order_type: SmolStr,
923 time_in_force: SmolStr,
924 #[serde(borrow)]
925 limit_price: Option<&'a str>,
926 #[serde(borrow)]
927 stop_price: Option<&'a str>,
928 #[serde(borrow)]
929 trail_percent: Option<&'a str>,
930 #[serde(borrow)]
931 trail_price: Option<&'a str>,
932 status: SmolStr,
933}
934
935#[derive(Clone)]
956pub struct AlpacaClient {
957 config: Arc<AlpacaConfig>,
958 http: reqwest::Client,
961 rate_limiter: Arc<RateLimitTracker>,
962 orders_url: String,
964}
965
966impl std::fmt::Debug for AlpacaClient {
967 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
968 f.debug_struct("AlpacaClient")
969 .field("paper", &self.config.paper)
970 .finish_non_exhaustive()
971 }
972}
973
974impl AlpacaClient {
975 #[allow(clippy::expect_used)] fn build_http(config: &AlpacaConfig) -> reqwest::Client {
983 use reqwest::header::{HeaderMap, HeaderName, HeaderValue};
984 let mut headers = HeaderMap::new();
985 headers.insert(
987 HeaderName::from_static("apca-api-key-id"),
988 HeaderValue::from_str(&config.api_key)
989 .expect("Alpaca API key contains invalid header characters"),
990 );
991 headers.insert(
992 HeaderName::from_static("apca-api-secret-key"),
993 HeaderValue::from_str(&config.secret_key)
994 .expect("Alpaca secret key contains invalid header characters"),
995 );
996 reqwest::Client::builder()
997 .default_headers(headers)
998 .build()
999 .expect("failed to build reqwest client for Alpaca")
1000 }
1001
1002 fn base_url(&self) -> &str {
1003 self.config.rest_base_url()
1004 }
1005}
1006
1007fn parse_rate_limit_delay(headers: &reqwest::header::HeaderMap) -> Option<Duration> {
1018 headers
1019 .get("x-ratelimit-reset")
1020 .and_then(|v| v.to_str().ok())
1021 .and_then(|s| s.parse::<u64>().ok())
1022 .map(|reset_ts| {
1023 let now_secs = std::time::SystemTime::now()
1024 .duration_since(std::time::UNIX_EPOCH)
1025 .unwrap_or_default()
1026 .as_secs();
1027 Duration::from_secs(reset_ts.saturating_sub(now_secs).max(1))
1028 })
1029}
1030
1031fn observe_rate_limit_remaining(
1038 rate_limiter: &RateLimitTracker,
1039 headers: &reqwest::header::HeaderMap,
1040) {
1041 let exhausted = headers
1042 .get("x-ratelimit-remaining")
1043 .and_then(|v| v.to_str().ok())
1044 .and_then(|s| s.parse::<u32>().ok())
1045 == Some(0);
1046 if !exhausted {
1047 return;
1048 }
1049 match parse_rate_limit_delay(headers) {
1050 Some(reset) => rate_limiter
1053 .on_bucket_exhausted(reset.min(Duration::from_secs(DEFAULT_RATE_LIMIT_DELAY_SECS))),
1054 None => debug!(
1055 "Alpaca REST rate-limit window exhausted (X-Ratelimit-Remaining: 0) without X-Ratelimit-Reset"
1056 ),
1057 }
1058}
1059
1060async fn rest_with_retry<T>(
1079 rate_limiter: &RateLimitTracker,
1080 mut build_request: impl FnMut() -> reqwest::RequestBuilder,
1081) -> Result<T, UnindexedClientError>
1082where
1083 T: for<'de> Deserialize<'de>,
1084{
1085 for attempt in 0..MAX_RATE_LIMIT_ATTEMPTS {
1086 rate_limiter.wait_if_blocked().await;
1087 let response = build_request()
1088 .send()
1089 .await
1090 .map_err(|e| connectivity_err(format!("Alpaca REST request failed: {e}")))?;
1091
1092 let status = response.status();
1093
1094 if status == reqwest::StatusCode::TOO_MANY_REQUESTS {
1095 let reset_delay = parse_rate_limit_delay(response.headers());
1096
1097 if attempt + 1 < MAX_RATE_LIMIT_ATTEMPTS {
1098 warn!(
1099 attempt = attempt + 1,
1100 max_attempts = MAX_RATE_LIMIT_ATTEMPTS,
1101 "Alpaca REST rate-limited (429), retrying"
1102 );
1103 rate_limiter.on_rate_limited(reset_delay);
1104 continue;
1105 }
1106
1107 warn!(
1109 max_attempts = MAX_RATE_LIMIT_ATTEMPTS,
1110 "Alpaca REST rate-limit retries exhausted"
1111 );
1112 return Err(UnindexedClientError::Api(ApiError::RateLimit));
1113 }
1114
1115 observe_rate_limit_remaining(rate_limiter, response.headers());
1116
1117 if status == reqwest::StatusCode::NO_CONTENT {
1121 return Err(UnindexedClientError::Internal(
1122 "Alpaca REST returned 204 No Content — use rest_delete_with_retry for DELETE endpoints"
1123 .to_string(),
1124 ));
1125 }
1126
1127 let bytes = response
1128 .bytes()
1129 .await
1130 .map_err(|e| connectivity_err(format!("Alpaca REST read body failed: {e}")))?;
1131
1132 if status.is_success() {
1135 return serde_json::from_slice::<T>(&bytes).map_err(|e| {
1136 UnindexedClientError::Internal(format!(
1137 "Alpaca REST JSON parse error ({status}): {e} | body: {}",
1138 String::from_utf8_lossy(&bytes)
1139 .chars()
1140 .take(200)
1141 .collect::<String>()
1142 ))
1143 });
1144 }
1145
1146 let api_err = serde_json::from_slice::<AlpacaApiError>(&bytes)
1148 .map(|e| e.message)
1149 .unwrap_or_else(|_| String::from_utf8_lossy(&bytes).into_owned());
1150
1151 if status.is_client_error() {
1160 return Err(UnindexedClientError::Api(parse_api_error(status, &api_err)));
1161 }
1162 return Err(connectivity_err(format!(
1163 "Alpaca REST error {status}: {api_err}"
1164 )));
1165 }
1166 unreachable!("Alpaca REST retry loop exited without returning")
1167}
1168
1169async fn rest_delete_with_retry(
1174 rate_limiter: &RateLimitTracker,
1175 mut build_request: impl FnMut() -> reqwest::RequestBuilder,
1176) -> Result<(), UnindexedOrderError> {
1177 for attempt in 0..MAX_RATE_LIMIT_ATTEMPTS {
1178 rate_limiter.wait_if_blocked().await;
1179 let response = build_request().send().await.map_err(|e| {
1180 UnindexedOrderError::Connectivity(ConnectivityError::Socket(format!(
1181 "Alpaca cancel request failed: {e}"
1182 )))
1183 })?;
1184
1185 let status = response.status();
1186
1187 if status == reqwest::StatusCode::TOO_MANY_REQUESTS {
1188 let reset_delay = parse_rate_limit_delay(response.headers());
1189
1190 if attempt + 1 < MAX_RATE_LIMIT_ATTEMPTS {
1191 warn!(
1192 attempt = attempt + 1,
1193 max_attempts = MAX_RATE_LIMIT_ATTEMPTS,
1194 "Alpaca cancel rate-limited (429), retrying"
1195 );
1196 rate_limiter.on_rate_limited(reset_delay);
1197 continue;
1198 }
1199
1200 warn!(
1202 max_attempts = MAX_RATE_LIMIT_ATTEMPTS,
1203 "Alpaca cancel rate-limit retries exhausted"
1204 );
1205 return Err(UnindexedOrderError::Rejected(ApiError::RateLimit));
1206 }
1207
1208 observe_rate_limit_remaining(rate_limiter, response.headers());
1209
1210 if status == reqwest::StatusCode::NO_CONTENT || status.is_success() {
1212 return Ok(());
1213 }
1214
1215 let bytes = response
1216 .bytes()
1217 .await
1218 .inspect_err(
1219 |e| warn!(%e, %status, "Alpaca cancel_order: failed to read error response body"),
1220 )
1221 .unwrap_or_default();
1222 let msg = serde_json::from_slice::<AlpacaApiError>(&bytes)
1223 .map(|e| e.message)
1224 .unwrap_or_else(|_| String::from_utf8_lossy(&bytes).into_owned());
1225
1226 return Err(parse_order_error(status, &msg));
1227 }
1228 unreachable!("Alpaca cancel retry loop exited without returning")
1229}
1230
1231impl ExecutionClient for AlpacaClient {
1236 const EXCHANGE: ExchangeId = ExchangeId::AlpacaBroker;
1237
1238 const SUPPORTED_KINDS: &'static [InstrumentKindDiscriminant] = &[
1245 InstrumentKindDiscriminant::Spot,
1246 InstrumentKindDiscriminant::Option,
1247 ];
1248 type Config = AlpacaConfig;
1249 type AccountStream = BoxStream<'static, UnindexedAccountEvent>;
1250
1251 fn new(config: Self::Config) -> Self {
1256 let http = Self::build_http(&config);
1257 let orders_url = format!("{}/v2/orders", config.rest_base_url());
1258 Self {
1259 config: Arc::new(config),
1260 http,
1261 rate_limiter: Arc::new(RateLimitTracker::new()),
1262 orders_url,
1263 }
1264 }
1265
1266 async fn account_snapshot(
1312 &self,
1313 assets: &[AssetNameExchange],
1314 instruments: &[InstrumentNameExchange],
1315 ) -> Result<UnindexedAccountSnapshot, UnindexedClientError> {
1316 let base = self.base_url();
1317 let http = self.http.clone();
1318 let rl = &self.rate_limiter;
1319
1320 let wants_usd = assets.is_empty()
1321 || assets
1322 .iter()
1323 .any(|a| a.name().as_str().eq_ignore_ascii_case("usd"));
1324 let wants_non_usd = assets.is_empty()
1325 || assets
1326 .iter()
1327 .any(|a| !a.name().as_str().eq_ignore_ascii_case("usd"));
1328
1329 let account_url = format!("{base}/v2/account");
1331 let positions_url = format!("{base}/v2/positions");
1332 let (account, positions): (Option<AlpacaAccount>, Vec<AlpacaPosition>) = tokio::try_join!(
1333 async {
1334 if wants_usd {
1335 rest_with_retry(rl, || http.get(&account_url))
1336 .await
1337 .map(Some)
1338 } else {
1339 Ok(None)
1340 }
1341 },
1342 rest_with_retry(rl, || http.get(&positions_url)),
1343 )?;
1344
1345 let mut balances = account
1346 .map(|account| convert_account_to_balances(&account, assets))
1347 .unwrap_or_default();
1348 if wants_non_usd {
1349 balances.extend(convert_positions_to_balances(&positions, assets));
1350 }
1351
1352 let converted_positions = convert_positions(&positions, Utc::now())?;
1355 let open_orders = fetch_raw_open_orders(&http, rl, base, instruments).await?;
1356
1357 let instrument_snapshots =
1359 build_instrument_snapshots(open_orders, converted_positions, instruments);
1360
1361 Ok(AccountSnapshot::new(
1362 ExchangeId::AlpacaBroker,
1363 balances,
1364 instrument_snapshots,
1365 ))
1366 }
1367
1368 async fn account_stream(
1428 &self,
1429 _assets: &[AssetNameExchange], instruments: &[InstrumentNameExchange],
1434 ) -> Result<Self::AccountStream, UnindexedClientError> {
1435 let initial_ws = connect_and_subscribe(&self.config).await?;
1438
1439 let (tx, rx) = mpsc::unbounded_channel::<UnindexedAccountEvent>();
1444 let dedup = new_dedup_cache();
1445 let config = self.config.clone();
1446 let http = self.http.clone();
1447 let rate_limiter = self.rate_limiter.clone();
1448 let instruments = instruments.to_vec();
1449
1450 let cm_handle = tokio::spawn(connection_manager(
1451 tx,
1452 dedup,
1453 config,
1454 http,
1455 rate_limiter,
1456 instruments,
1457 Some(initial_ws),
1458 ));
1459
1460 let rx_stream = tokio_stream::wrappers::UnboundedReceiverStream::new(rx);
1461 let guarded = GracefulShutdownStream::new(rx_stream, cm_handle);
1462 Ok(futures::StreamExt::boxed(guarded))
1463 }
1464
1465 async fn cancel_order(
1480 &self,
1481 request: OrderRequestCancel<ExchangeId, &InstrumentNameExchange>,
1482 ) -> Option<UnindexedOrderResponseCancel> {
1483 let key = crate::order::OrderKey {
1484 exchange: request.key.exchange,
1485 instrument: request.key.instrument.clone(),
1486 strategy: request.key.strategy.clone(),
1487 cid: request.key.cid.clone(),
1488 };
1489
1490 let order_id: SmolStr = match request.state.id.as_ref().and_then(VenueOrderId::assigned) {
1494 Some(id) => id.0.clone(),
1495 None => {
1496 warn!(
1497 instrument = %key.instrument,
1498 "Alpaca cancel_order: no exchange order ID available (clientOrderId-only cancel not supported)"
1499 );
1500 return Some(crate::order::request::OrderResponseCancel {
1501 key,
1502 state: Err(UnindexedOrderError::Rejected(ApiError::OrderRejected(
1503 "exchange order ID required for cancel (fetch_open_orders to resolve)"
1504 .into(),
1505 ))),
1506 });
1507 }
1508 };
1509
1510 let base = self.base_url();
1511 let http = self.http.clone();
1512 let url = format!("{base}/v2/orders/{order_id}");
1513
1514 match rest_delete_with_retry(&self.rate_limiter, || http.delete(&url)).await {
1515 Ok(()) => {
1516 let exchange_order_id = OrderId(order_id);
1517 Some(crate::order::request::OrderResponseCancel {
1520 key,
1521 state: Ok(Cancelled::new(exchange_order_id, Utc::now(), Decimal::ZERO)),
1522 })
1523 }
1524 Err(e) => Some(crate::order::request::OrderResponseCancel { key, state: Err(e) }),
1525 }
1526 }
1527
1528 async fn open_order(
1549 &self,
1550 request: OrderRequestOpen<ExchangeId, &InstrumentNameExchange>,
1551 ) -> Option<Order<ExchangeId, InstrumentNameExchange, UnindexedOrderState>> {
1552 let side = request.state.side;
1553 let reduce_only = request.state.reduce_only;
1554 self.open_order_inner(request, map_position_intent(side, reduce_only))
1555 .await
1556 }
1557
1558 async fn fetch_balances(
1566 &self,
1567 assets: &[AssetNameExchange],
1568 ) -> Result<Vec<AssetBalance<AssetNameExchange>>, UnindexedClientError> {
1569 let base = self.base_url();
1570 let http = self.http.clone();
1571 let mut result = Vec::new();
1572
1573 let wants_usd = assets.is_empty()
1576 || assets
1577 .iter()
1578 .any(|a| a.name().as_str().eq_ignore_ascii_case("usd"));
1579 if wants_usd {
1580 let account_url = format!("{base}/v2/account");
1582 let account: AlpacaAccount =
1583 rest_with_retry(&self.rate_limiter, || http.get(&account_url)).await?;
1584 result.extend(convert_account_to_balances(&account, assets));
1585 }
1586
1587 let wants_non_usd = assets.is_empty()
1589 || assets
1590 .iter()
1591 .any(|a| !a.name().as_str().eq_ignore_ascii_case("usd"));
1592 if wants_non_usd {
1593 let positions_url = format!("{base}/v2/positions");
1595 let positions: Vec<AlpacaPosition> =
1596 rest_with_retry(&self.rate_limiter, || http.get(&positions_url)).await?;
1597 result.extend(convert_positions_to_balances(&positions, assets));
1598 }
1599
1600 Ok(result)
1601 }
1602
1603 async fn fetch_open_orders(
1604 &self,
1605 instruments: &[InstrumentNameExchange],
1606 ) -> Result<Vec<Order<ExchangeId, InstrumentNameExchange, Open>>, UnindexedClientError> {
1607 let base = self.base_url();
1608 let http = self.http.clone();
1609
1610 let open_orders =
1611 fetch_raw_open_orders(&http, &self.rate_limiter, base, instruments).await?;
1612
1613 let result = open_orders
1614 .into_iter()
1615 .filter_map(|o| convert_open_order(&o))
1616 .collect();
1617 Ok(result)
1618 }
1619
1620 async fn fetch_trades(
1621 &self,
1622 time_since: DateTime<Utc>,
1623 instruments: &[InstrumentNameExchange],
1624 ) -> Result<Vec<Trade<AssetNameExchange, InstrumentNameExchange>>, UnindexedClientError> {
1625 let after_str = time_since.to_rfc3339();
1626 let base = self.base_url();
1627 let http = self.http.clone();
1628
1629 let page = paginate_activities(&http, &self.rate_limiter, base, &after_str).await?;
1630
1631 if page.truncated {
1634 return Err(UnindexedClientError::Truncated {
1635 limit: MAX_ACTIVITY_PAGES,
1636 });
1637 }
1638
1639 let trades = if instruments.is_empty() {
1642 page.activities
1643 .into_iter()
1644 .filter_map(|a| convert_activity_to_trade(&a))
1645 .collect()
1646 } else {
1647 let instrument_set: fnv::FnvHashSet<&str> =
1648 instruments.iter().map(|i| i.name().as_str()).collect();
1649 page.activities
1650 .into_iter()
1651 .filter(|a| instrument_set.contains(a.symbol.as_str()))
1652 .filter_map(|a| convert_activity_to_trade(&a))
1653 .collect()
1654 };
1655
1656 Ok(trades)
1657 }
1658}
1659
1660impl AlpacaClient {
1665 pub async fn open_order_with_intent(
1678 &self,
1679 request: OrderRequestOpen<ExchangeId, &InstrumentNameExchange>,
1680 intent: AlpacaPositionIntent,
1681 ) -> Option<Order<ExchangeId, InstrumentNameExchange, UnindexedOrderState>> {
1682 self.open_order_inner(request, intent).await
1683 }
1684
1685 pub async fn open_bracket_order(
1718 &self,
1719 request: AlpacaBracketOrderRequest,
1720 ) -> AlpacaBracketOrderResult {
1721 let order_key = crate::order::OrderKey::new(
1722 ExchangeId::AlpacaBroker,
1723 request.instrument.clone(),
1724 request.strategy.clone(),
1725 request.cid.clone(),
1726 );
1727
1728 let tif_str = match request.time_in_force {
1730 TimeInForce::GoodUntilEndOfDay => "day",
1731 TimeInForce::GoodUntilCancelled { post_only } => {
1732 if post_only {
1733 return AlpacaBracketOrderResult {
1734 parent: Order {
1735 key: order_key,
1736 side: request.side,
1737 price: Some(request.entry_price),
1738 quantity: request.quantity,
1739 kind: OrderKind::Limit,
1740 time_in_force: request.time_in_force,
1741 state: OrderState::inactive(OrderError::Rejected(
1742 ApiError::OrderRejected(
1743 "Alpaca does not support post_only for bracket orders"
1744 .to_string(),
1745 ),
1746 )),
1747 },
1748 };
1749 }
1750 "gtc"
1751 }
1752 other => {
1753 return AlpacaBracketOrderResult {
1754 parent: Order {
1755 key: order_key,
1756 side: request.side,
1757 price: Some(request.entry_price),
1758 quantity: request.quantity,
1759 kind: OrderKind::Limit,
1760 time_in_force: request.time_in_force,
1761 state: OrderState::inactive(OrderError::Rejected(ApiError::OrderRejected(
1762 format!(
1763 "Alpaca bracket orders only support Day or GTC time_in_force, got {:?}",
1764 other
1765 ),
1766 ))),
1767 },
1768 };
1769 }
1770 };
1771
1772 let price_ordering_ok = match request.side {
1775 Side::Buy => {
1776 request.stop_loss_price < request.entry_price
1777 && request.entry_price < request.take_profit_price
1778 }
1779 Side::Sell => {
1780 request.take_profit_price < request.entry_price
1781 && request.entry_price < request.stop_loss_price
1782 }
1783 };
1784 if !price_ordering_ok {
1785 return AlpacaBracketOrderResult {
1786 parent: Order {
1787 key: order_key,
1788 side: request.side,
1789 price: Some(request.entry_price),
1790 quantity: request.quantity,
1791 kind: OrderKind::Limit,
1792 time_in_force: request.time_in_force,
1793 state: OrderState::inactive(OrderError::Rejected(ApiError::OrderRejected(
1794 format!(
1795 "Invalid bracket price ordering for {:?} side: entry={}, take_profit={}, stop_loss={}",
1796 request.side,
1797 request.entry_price,
1798 request.take_profit_price,
1799 request.stop_loss_price,
1800 ),
1801 ))),
1802 },
1803 };
1804 }
1805
1806 if let Some(sl_limit) = request.stop_loss_limit_price {
1810 let sl_limit_ok = match request.side {
1811 Side::Buy => sl_limit <= request.stop_loss_price,
1812 Side::Sell => sl_limit >= request.stop_loss_price,
1813 };
1814 if !sl_limit_ok {
1815 return AlpacaBracketOrderResult {
1816 parent: Order {
1817 key: order_key,
1818 side: request.side,
1819 price: Some(request.entry_price),
1820 quantity: request.quantity,
1821 kind: OrderKind::Limit,
1822 time_in_force: request.time_in_force,
1823 state: OrderState::inactive(OrderError::Rejected(ApiError::OrderRejected(
1824 format!(
1825 "Invalid stop-loss limit price for {:?} bracket: \
1826 stop_loss_price={}, stop_loss_limit_price={}",
1827 request.side, request.stop_loss_price, sl_limit,
1828 ),
1829 ))),
1830 },
1831 };
1832 }
1833 }
1834
1835 let body = AlpacaOrderRequest {
1837 symbol: request.instrument.name().as_str(),
1838 qty: request.quantity.to_string(),
1839 side: map_side(request.side),
1840 order_type: "limit",
1841 time_in_force: tif_str,
1842 limit_price: Some(request.entry_price.to_string()),
1843 stop_price: None,
1844 trail_percent: None,
1845 trail_price: None,
1846 client_order_id: Some(request.cid.0.as_str()),
1847 position_intent: if is_options_or_equity_symbol(request.instrument.name().as_str()) {
1848 Some(map_position_intent(request.side, false))
1849 } else {
1850 None
1851 },
1852 order_class: Some("bracket"),
1853 take_profit: Some(TakeProfitParams {
1854 limit_price: request.take_profit_price.to_string(),
1855 }),
1856 stop_loss: Some(StopLossParams {
1857 stop_price: request.stop_loss_price.to_string(),
1858 limit_price: request.stop_loss_limit_price.map(|p| p.to_string()),
1859 }),
1860 };
1861
1862 let http = self.http.clone();
1863 let rl = &self.rate_limiter;
1864
1865 let result: Result<AlpacaOrderResponse, UnindexedClientError> =
1866 rest_with_retry(rl, || http.post(&self.orders_url).json(&body)).await;
1867
1868 match result {
1869 Ok(resp) => {
1870 let exchange_order_id = OrderId(SmolStr::new(&resp.id));
1871 let time_exchange = order_state_time(&resp);
1872 let filled_qty = Decimal::from_str(&resp.filled_qty).unwrap_or(Decimal::ZERO);
1873
1874 let state = if filled_qty >= request.quantity {
1875 OrderState::fully_filled(Filled::new(
1876 exchange_order_id,
1877 time_exchange,
1878 filled_qty,
1879 None,
1880 ))
1881 } else {
1882 OrderState::active(Open::new(
1883 VenueOrderId::Assigned(exchange_order_id),
1884 time_exchange,
1885 filled_qty,
1886 ))
1887 };
1888
1889 AlpacaBracketOrderResult {
1890 parent: Order {
1891 key: order_key,
1892 side: request.side,
1893 price: Some(request.entry_price),
1894 quantity: request.quantity,
1895 kind: OrderKind::Limit,
1896 time_in_force: request.time_in_force,
1897 state,
1898 },
1899 }
1900 }
1901 Err(e) => {
1902 let order_err = order_post_error(e);
1903 AlpacaBracketOrderResult {
1904 parent: Order {
1905 key: order_key,
1906 side: request.side,
1907 price: Some(request.entry_price),
1908 quantity: request.quantity,
1909 kind: OrderKind::Limit,
1910 time_in_force: request.time_in_force,
1911 state: OrderState::inactive(order_err),
1912 },
1913 }
1914 }
1915 }
1916 }
1917
1918 async fn open_order_inner(
1919 &self,
1920 request: OrderRequestOpen<ExchangeId, &InstrumentNameExchange>,
1921 intent: AlpacaPositionIntent,
1922 ) -> Option<Order<ExchangeId, InstrumentNameExchange, UnindexedOrderState>> {
1923 let instrument = request.key.instrument.clone();
1924 let side = request.state.side;
1925 let price = request.state.price;
1926 let quantity = request.state.quantity;
1927 let kind = request.state.kind;
1928 let time_in_force = request.state.time_in_force;
1929 let cid = request.key.cid.clone();
1930
1931 let order_key = crate::order::OrderKey::new(
1932 ExchangeId::AlpacaBroker,
1933 instrument.clone(),
1934 request.key.strategy.clone(),
1935 cid.clone(),
1936 );
1937
1938 let tif_str = match map_time_in_force(time_in_force) {
1940 Ok(s) => s,
1941 Err(msg) => {
1942 return Some(Order {
1943 key: order_key,
1944 side,
1945 price,
1946 quantity,
1947 kind,
1948 time_in_force,
1949 state: OrderState::inactive(OrderError::Rejected(ApiError::OrderRejected(
1950 msg.to_string(),
1951 ))),
1952 });
1953 }
1954 };
1955
1956 let order_type_str = match map_order_kind(kind) {
1958 Some(s) => s,
1959 None => {
1960 return Some(Order {
1961 key: order_key,
1962 side,
1963 price,
1964 quantity,
1965 kind,
1966 time_in_force,
1967 state: OrderState::inactive(OrderError::UnsupportedOrderType(format!(
1968 "Alpaca connector does not yet support OrderKind::{kind:?}"
1969 ))),
1970 });
1971 }
1972 };
1973
1974 if let OrderKind::TrailingStop {
1976 offset_type: TrailingOffsetType::BasisPoints,
1977 ..
1978 } = kind
1979 {
1980 return Some(Order {
1981 key: order_key,
1982 side,
1983 price,
1984 quantity,
1985 kind,
1986 time_in_force,
1987 state: OrderState::inactive(OrderError::UnsupportedOrderType(
1988 "Alpaca does not support TrailingOffsetType::BasisPoints; \
1989 use Percentage or Absolute"
1990 .to_string(),
1991 )),
1992 });
1993 }
1994
1995 if matches!(kind, OrderKind::StopLimit { .. }) && price.is_none() {
1998 return Some(Order {
1999 key: order_key,
2000 side,
2001 price,
2002 quantity,
2003 kind,
2004 time_in_force,
2005 state: OrderState::inactive(OrderError::Rejected(ApiError::OrderRejected(
2006 "StopLimit order requires Order.price (the limit price) to be set".to_string(),
2007 ))),
2008 });
2009 }
2010
2011 let (stop_price, trail_percent, trail_price) = match kind {
2013 OrderKind::Stop { trigger_price } | OrderKind::StopLimit { trigger_price } => {
2014 (Some(trigger_price.to_string()), None, None)
2015 }
2016 OrderKind::TrailingStop {
2017 offset,
2018 offset_type,
2019 } => match offset_type {
2020 TrailingOffsetType::Percentage => (None, Some(offset.to_string()), None),
2021 TrailingOffsetType::Absolute => (None, None, Some(offset.to_string())),
2022 TrailingOffsetType::BasisPoints => unreachable!("validated above"),
2023 },
2024 OrderKind::Market
2026 | OrderKind::Limit
2027 | OrderKind::TakeProfit { .. }
2028 | OrderKind::TakeProfitLimit { .. }
2029 | OrderKind::TrailingStopLimit { .. } => (None, None, None),
2030 };
2031
2032 let body = AlpacaOrderRequest {
2033 symbol: instrument.name().as_str(),
2034 qty: quantity.to_string(),
2035 side: map_side(side),
2036 order_type: order_type_str,
2037 time_in_force: tif_str,
2038 limit_price: match kind {
2039 OrderKind::Limit | OrderKind::StopLimit { .. } => price.map(|p| p.to_string()),
2040 _ => None,
2041 },
2042 stop_price,
2043 trail_percent,
2044 trail_price,
2045 client_order_id: Some(cid.0.as_str()),
2046 position_intent: if is_options_or_equity_symbol(instrument.name().as_str()) {
2052 Some(intent)
2053 } else {
2054 None
2055 },
2056 order_class: None,
2058 take_profit: None,
2059 stop_loss: None,
2060 };
2061
2062 let http = self.http.clone();
2063 let rl = &self.rate_limiter;
2064
2065 let result: Result<AlpacaOrderResponse, UnindexedClientError> =
2066 rest_with_retry(rl, || http.post(&self.orders_url).json(&body)).await;
2067
2068 match result {
2069 Ok(resp) => {
2070 let exchange_order_id = OrderId(SmolStr::new(&resp.id));
2071 let time_exchange = order_state_time(&resp);
2072 let filled_qty = Decimal::from_str(&resp.filled_qty).unwrap_or(Decimal::ZERO);
2073
2074 let state = if filled_qty >= quantity {
2075 OrderState::fully_filled(Filled::new(
2077 exchange_order_id,
2078 time_exchange,
2079 filled_qty,
2080 None, ))
2082 } else {
2083 OrderState::active(Open::new(
2085 VenueOrderId::Assigned(exchange_order_id),
2086 time_exchange,
2087 filled_qty,
2088 ))
2089 };
2090
2091 Some(Order {
2092 key: order_key,
2093 side,
2094 price,
2095 quantity,
2096 kind,
2097 time_in_force,
2098 state,
2099 })
2100 }
2101 Err(e) => {
2102 let order_err = order_post_error(e);
2103 Some(Order {
2104 key: order_key,
2105 side,
2106 price,
2107 quantity,
2108 kind,
2109 time_in_force,
2110 state: OrderState::inactive(order_err),
2111 })
2112 }
2113 }
2114 }
2115}
2116
2117const MAX_OPEN_ORDERS: usize = 500;
2125
2126async fn fetch_raw_open_orders(
2134 http: &reqwest::Client,
2135 rate_limiter: &RateLimitTracker,
2136 base: &str,
2137 instruments: &[InstrumentNameExchange],
2138) -> Result<Vec<AlpacaOrderResponse>, UnindexedClientError> {
2139 let orders: Vec<AlpacaOrderResponse> = if instruments.is_empty() {
2140 rest_with_retry(rate_limiter, || {
2141 http.get(format!("{base}/v2/orders"))
2142 .query(&[("status", "open"), ("limit", "500")])
2143 })
2144 .await?
2145 } else {
2146 let symbols = instruments.iter().map(|i| i.name().as_str()).join(",");
2147 rest_with_retry(rate_limiter, || {
2148 http.get(format!("{base}/v2/orders")).query(&[
2149 ("status", "open"),
2150 ("limit", "500"),
2151 ("symbols", &symbols),
2152 ])
2153 })
2154 .await?
2155 };
2156 if orders.len() == MAX_OPEN_ORDERS {
2157 warn!(
2158 limit = MAX_OPEN_ORDERS,
2159 "Alpaca fetch_raw_open_orders: received exactly {MAX_OPEN_ORDERS} results — \
2160 response is likely truncated"
2161 );
2162 return Err(UnindexedClientError::TruncatedSnapshot {
2163 limit: MAX_OPEN_ORDERS,
2164 });
2165 }
2166 Ok(orders)
2167}
2168
2169const MAX_ACTIVITY_PAGES: usize = 50;
2178
2179struct ActivityPage {
2185 activities: Vec<AlpacaActivity>,
2186 truncated: bool,
2187}
2188
2189const PAGE_SIZE_STR: &str = "100"; const _: () = assert!(
2201 ALPACA_MAX_ACTIVITIES == 100,
2202 "PAGE_SIZE_STR must be updated to match ALPACA_MAX_ACTIVITIES",
2203);
2204
2205async fn paginate_activities(
2206 http: &reqwest::Client,
2207 rate_limiter: &RateLimitTracker,
2208 base: &str,
2209 after: &str,
2210) -> Result<ActivityPage, UnindexedClientError> {
2211 let mut all = Vec::with_capacity(ALPACA_MAX_ACTIVITIES);
2212 let mut page_token: Option<String> = None;
2213 let mut pages = 0usize;
2214 let mut truncated = false;
2215
2216 loop {
2217 if pages >= MAX_ACTIVITY_PAGES {
2218 truncated = true;
2219 break;
2220 }
2221 pages += 1;
2222 let page_token_ref = page_token.as_deref();
2224 let activities: Vec<AlpacaActivity> = rest_with_retry(rate_limiter, || {
2225 let mut req = http.get(format!("{base}/v2/account/activities")).query(&[
2226 ("activity_type", "FILL"),
2227 ("after", after),
2228 ("page_size", PAGE_SIZE_STR),
2229 ("direction", "asc"),
2230 ]);
2231 if let Some(token) = page_token_ref {
2232 req = req.query(&[("page_token", token)]);
2233 }
2234 req
2235 })
2236 .await?;
2237
2238 let page_len = activities.len();
2239 let page_token_candidate = activities.last().map(|a| a.id.clone());
2249 all.extend(activities);
2250
2251 if page_len < ALPACA_MAX_ACTIVITIES {
2252 break;
2253 }
2254 match page_token_candidate {
2255 Some(token) if !token.is_empty() => {
2256 debug!("Alpaca paginate_activities: fetching next page ({page_len} results)");
2257 page_token = Some(token);
2258 }
2259 _ => break,
2263 }
2264 }
2265
2266 Ok(ActivityPage {
2267 activities: all,
2268 truncated,
2269 })
2270}
2271
2272#[allow(clippy::cognitive_complexity)] async fn connection_manager(
2288 tx: mpsc::UnboundedSender<UnindexedAccountEvent>,
2289 dedup: SharedDedupCache,
2290 config: Arc<AlpacaConfig>,
2291 http: reqwest::Client,
2292 rate_limiter: Arc<RateLimitTracker>,
2293 instruments: Vec<InstrumentNameExchange>,
2294 initial_ws: Option<WebSocket>,
2295) {
2296 let mut backoff = ExponentialBackoff::new();
2297 let mut disconnect_time: Option<DateTime<Utc>> = None;
2298 let mut current_ws = initial_ws;
2299
2300 'outer: loop {
2301 let mut ws = match current_ws.take() {
2303 Some(ws) => ws,
2304 None => match connect_and_subscribe(&config).await {
2305 Ok(ws) => ws,
2306 Err(e) => {
2307 error!(%e, "Alpaca WS connect/subscribe failed");
2308 if !backoff.wait().await {
2309 error!("Alpaca max reconnect attempts exhausted");
2310 emit_stream_terminated(
2312 &tx,
2313 ExchangeId::AlpacaBroker,
2314 StreamTerminationReason::ReconnectBudgetExhausted {
2315 attempts: backoff.attempts(),
2316 last_error: e.to_string(),
2317 },
2318 );
2319 break;
2320 }
2321 continue;
2322 }
2323 },
2324 };
2325 info!("Alpaca account_stream connected and subscribed");
2326 backoff.reset();
2337
2338 if let Some(dt) = disconnect_time.take() {
2343 let base = config.rest_base_url();
2344 let after_str = dt.to_rfc3339();
2345 match tokio::time::timeout(
2346 Duration::from_secs(FILL_RECOVERY_TIMEOUT_SECS),
2347 recover_fills(
2348 &http,
2349 &rate_limiter,
2350 &instruments,
2351 base,
2352 &after_str,
2353 &tx,
2354 &dedup,
2355 ),
2356 )
2357 .await
2358 {
2359 Ok(()) => {}
2360 Err(_) => warn!(
2363 timeout_secs = FILL_RECOVERY_TIMEOUT_SECS,
2364 instruments = ?instruments
2365 .iter()
2366 .map(|instrument| instrument.name().as_str())
2367 .collect::<Vec<_>>(),
2368 "Alpaca fill recovery timed out — fills since the disconnect may be missing \
2369 for any of the instruments (every instrument when the list is empty)"
2370 ),
2371 }
2372 }
2373
2374 let mut last_message_time = Utc::now();
2387 let heartbeat = tokio::time::sleep(Duration::from_secs(HEARTBEAT_TIMEOUT_SECS));
2388 tokio::pin!(heartbeat);
2389
2390 macro_rules! reset_heartbeat {
2395 () => {
2396 heartbeat.as_mut().reset(
2397 tokio::time::Instant::now() + Duration::from_secs(HEARTBEAT_TIMEOUT_SECS),
2398 );
2399 last_message_time = Utc::now();
2400 };
2401 }
2402 enum DisconnectReason {
2405 ServerClose,
2406 Error(String),
2407 StreamEnded,
2408 HeartbeatTimeout,
2409 }
2410 let reason = loop {
2411 tokio::select! {
2412 msg = ws.next() => {
2413 match msg {
2414 Some(Ok(WsMessage::Ping(_))) => {
2415 reset_heartbeat!();
2418 }
2419 Some(Ok(WsMessage::Text(text))) => {
2420 process_ws_text(text.as_str(), &tx, &dedup, &mut backoff);
2421 reset_heartbeat!();
2422 }
2423 Some(Ok(WsMessage::Binary(bytes))) => {
2424 match std::str::from_utf8(&bytes) {
2426 Ok(text) => {
2427 process_ws_text(text, &tx, &dedup, &mut backoff);
2428 reset_heartbeat!();
2432 }
2433 Err(e) => warn!(%e, "Alpaca WS binary frame: not valid UTF-8"),
2434 }
2435 }
2436 Some(Ok(WsMessage::Close(frame))) => {
2437 warn!(frame = ?frame, "Alpaca WS closed by server");
2438 break DisconnectReason::ServerClose;
2439 }
2440 Some(Ok(_)) => {} Some(Err(e)) => {
2442 warn!(%e, "Alpaca WS error, reconnecting");
2443 break DisconnectReason::Error(e.to_string());
2444 }
2445 None => {
2446 warn!("Alpaca WS stream ended, reconnecting");
2447 break DisconnectReason::StreamEnded;
2448 }
2449 }
2450 }
2451 _ = &mut heartbeat => {
2452 warn!(
2453 timeout_secs = HEARTBEAT_TIMEOUT_SECS,
2454 "Alpaca heartbeat timeout, reconnecting"
2455 );
2456 break DisconnectReason::HeartbeatTimeout;
2459 }
2460 _ = tx.closed() => {
2461 debug!("Alpaca account_stream consumer dropped, terminating");
2462 let _ = tokio::time::timeout(
2463 Duration::from_secs(WS_CLOSE_TIMEOUT_SECS),
2464 ws.close(None),
2465 ).await;
2466 break 'outer;
2469 }
2470 }
2471 };
2472
2473 disconnect_time =
2478 Some(last_message_time - chrono::Duration::milliseconds(SIGNAL_RECOVERY_LOOKBACK_MS));
2479
2480 let _ =
2482 tokio::time::timeout(Duration::from_secs(WS_CLOSE_TIMEOUT_SECS), ws.close(None)).await;
2483
2484 if tx.is_closed() {
2485 break;
2488 }
2489 if !backoff.wait().await {
2490 error!("Alpaca max reconnect attempts exhausted, stream terminating");
2491 let last_error = match reason {
2494 DisconnectReason::HeartbeatTimeout => {
2495 format!("heartbeat timeout ({HEARTBEAT_TIMEOUT_SECS}s)")
2496 }
2497 DisconnectReason::ServerClose => "WebSocket closed by server".to_string(),
2498 DisconnectReason::Error(e) => e,
2499 DisconnectReason::StreamEnded => "WebSocket stream ended".to_string(),
2500 };
2501 emit_stream_terminated(
2502 &tx,
2503 ExchangeId::AlpacaBroker,
2504 StreamTerminationReason::ReconnectBudgetExhausted {
2505 attempts: backoff.attempts(),
2506 last_error,
2507 },
2508 );
2509 break;
2510 }
2511 }
2512}
2513
2514#[derive(Debug)]
2519enum HandshakeError {
2520 Transport(String),
2523 Auth(String),
2526}
2527
2528async fn connect_and_subscribe(config: &AlpacaConfig) -> Result<WebSocket, UnindexedClientError> {
2532 let url = config.ws_url();
2533 debug!(%url, "Alpaca: connecting to WebSocket");
2534
2535 let mut ws = rustrade_integration::protocol::websocket::connect(url)
2536 .await
2537 .map_err(|e| {
2538 UnindexedClientError::Connectivity(ConnectivityError::Socket(format!(
2539 "WS connect: {e}"
2540 )))
2541 })?;
2542
2543 let result = tokio::time::timeout(
2545 Duration::from_secs(WS_HANDSHAKE_TIMEOUT_SECS),
2546 ws_handshake(&mut ws, config),
2547 )
2548 .await;
2549
2550 match result {
2551 Ok(Ok(())) => Ok(ws),
2552 Ok(Err(HandshakeError::Transport(e))) => {
2553 let _ = ws.close(None).await;
2554 Err(UnindexedClientError::Connectivity(
2555 ConnectivityError::Socket(e),
2556 ))
2557 }
2558 Ok(Err(HandshakeError::Auth(e))) => {
2559 let _ = ws.close(None).await;
2560 Err(UnindexedClientError::Api(ApiError::Unauthenticated(e)))
2561 }
2562 Err(_) => {
2563 let _ = ws.close(None).await;
2564 Err(UnindexedClientError::Connectivity(
2565 ConnectivityError::Timeout,
2566 ))
2567 }
2568 }
2569}
2570
2571async fn ws_handshake(ws: &mut WebSocket, config: &AlpacaConfig) -> Result<(), HandshakeError> {
2579 let auth = serde_json::json!({
2581 "action": "auth",
2582 "key": config.api_key(),
2583 "secret": config.secret_key,
2586 })
2587 .to_string();
2588 ws.send(WsMessage::Text(auth.into()))
2589 .await
2590 .map_err(|e| HandshakeError::Transport(format!("WS auth send: {e}")))?;
2591
2592 loop {
2594 match ws.next().await {
2595 Some(Ok(WsMessage::Text(text))) => {
2596 if let Some(result) = check_auth_response(text.as_str()) {
2597 result?;
2598 break;
2599 }
2600 }
2601 Some(Ok(WsMessage::Binary(bytes))) => {
2602 if let Ok(text) = std::str::from_utf8(&bytes)
2603 && let Some(result) = check_auth_response(text)
2604 {
2605 result?;
2606 break;
2607 }
2608 }
2609 Some(Err(e)) => {
2610 return Err(HandshakeError::Transport(format!(
2611 "WS error during auth: {e}"
2612 )));
2613 }
2614 None => {
2615 return Err(HandshakeError::Transport(
2616 "WS closed before auth response".into(),
2617 ));
2618 }
2619 _ => {} }
2621 }
2622
2623 let sub = serde_json::json!({
2625 "action": "listen",
2626 "data": { "streams": ["trade_updates"] }
2627 })
2628 .to_string();
2629 ws.send(WsMessage::Text(sub.into()))
2630 .await
2631 .map_err(|e| HandshakeError::Transport(format!("WS subscribe send: {e}")))?;
2632
2633 loop {
2635 match ws.next().await {
2636 Some(Ok(WsMessage::Text(text))) => {
2637 if check_listen_ack(text.as_str()) {
2638 break;
2639 }
2640 if let Ok(msg) = serde_json::from_str::<AlpacaStreamMessage<'_>>(text.as_str()) {
2646 if msg.stream == "trade_updates" {
2647 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");
2648 } else {
2649 trace!(stream = %msg.stream, "WS message dropped during listen-ack handshake");
2650 }
2651 } else {
2652 trace!(
2653 bytes = text.len(),
2654 "WS non-stream message dropped during listen-ack handshake"
2655 );
2656 }
2657 }
2658 Some(Ok(WsMessage::Binary(bytes))) => {
2659 if let Ok(text) = std::str::from_utf8(&bytes)
2660 && check_listen_ack(text)
2661 {
2662 break;
2663 }
2664 trace!(
2665 bytes = bytes.len(),
2666 "WS binary message dropped during listen-ack handshake"
2667 );
2668 }
2669 Some(Err(e)) => {
2670 return Err(HandshakeError::Transport(format!(
2671 "WS error during subscribe: {e}"
2672 )));
2673 }
2674 None => {
2675 return Err(HandshakeError::Transport(
2676 "WS closed before subscribe ack".into(),
2677 ));
2678 }
2679 _ => {}
2680 }
2681 }
2682
2683 info!("Alpaca WS authenticated and subscribed to trade_updates");
2684 Ok(())
2685}
2686
2687fn check_auth_response(text: &str) -> Option<Result<(), HandshakeError>> {
2693 let msg = serde_json::from_str::<AlpacaStreamMessage<'_>>(text).ok()?;
2694 if msg.stream != "authorization" {
2695 return None;
2696 }
2697 #[derive(Deserialize)]
2698 struct AuthData<'a> {
2699 status: &'a str,
2700 }
2701 let data = serde_json::from_str::<AuthData<'_>>(msg.data.get()).ok()?;
2702 if data.status == "authorized" {
2703 Some(Ok(()))
2704 } else {
2705 Some(Err(HandshakeError::Auth(format!(
2706 "Alpaca WS auth failed: status={}",
2707 data.status
2708 ))))
2709 }
2710}
2711
2712fn check_listen_ack(text: &str) -> bool {
2716 let Ok(msg) = serde_json::from_str::<AlpacaStreamMessage<'_>>(text) else {
2717 return false;
2718 };
2719 if msg.stream != "listening" {
2720 return false;
2721 }
2722 #[derive(Deserialize)]
2723 struct ListenData<'a> {
2724 #[serde(borrow)]
2725 streams: Vec<&'a str>,
2726 }
2727 let Ok(data) = serde_json::from_str::<ListenData<'_>>(msg.data.get()) else {
2728 return false;
2729 };
2730 data.streams.contains(&"trade_updates")
2731}
2732
2733fn process_ws_text(
2739 text: &str,
2740 tx: &mpsc::UnboundedSender<UnindexedAccountEvent>,
2741 dedup: &SharedDedupCache,
2742 backoff: &mut ExponentialBackoff,
2743) {
2744 let msg: AlpacaStreamMessage<'_> = match serde_json::from_str(text) {
2745 Ok(m) => m,
2746 Err(e) => {
2747 trace!(
2748 %e,
2749 raw = ?&text[..text.len().min(200)],
2750 "Alpaca WS: skipped non-JSON message"
2751 );
2752 return;
2753 }
2754 };
2755
2756 backoff.reset();
2759
2760 match msg.stream.as_str() {
2761 "trade_updates" => {
2762 let update: AlpacaTradeUpdate<'_> = match serde_json::from_str(msg.data.get()) {
2763 Ok(u) => u,
2764 Err(e) => {
2765 warn!(
2770 %e,
2771 raw = ?&msg.data.get()[..msg.data.get().len().min(200)],
2772 "Alpaca WS trade_updates: failed to deserialize event — event dropped"
2773 );
2774 return;
2775 }
2776 };
2777
2778 if is_fill_event(&update) {
2782 let key = early_dedup_key(&update);
2783 if is_duplicate(dedup, &key) {
2784 trace!("Alpaca WS: skipping duplicate fill event (early check)");
2785 return;
2786 }
2787 }
2788
2789 for event in convert_trade_update(update).into_iter().flatten() {
2790 let _ = tx.send(event);
2793 }
2794 }
2795 "authorization" | "listening" => {
2796 trace!(stream = %msg.stream, "Alpaca WS: auth/listen ack received during stream");
2799 }
2800 other => {
2801 trace!(%other, "Alpaca WS: ignoring unknown stream type");
2802 }
2803 }
2804}
2805
2806fn fill_dedup_key_from_event(event: &UnindexedAccountEvent) -> Option<&SmolStr> {
2813 match &event.kind {
2814 AccountEventKind::Trade(trade) => Some(&trade.id.0),
2816 _ => None,
2817 }
2818}
2819
2820#[inline]
2824fn is_fill_event(update: &AlpacaTradeUpdate<'_>) -> bool {
2825 matches!(update.event.as_str(), "fill" | "partial_fill")
2826}
2827
2828fn early_dedup_key(update: &AlpacaTradeUpdate<'_>) -> SmolStr {
2838 let filled_qty = update.order.filled_qty.unwrap_or("0");
2839 let qty = Decimal::from_str(filled_qty).unwrap_or(Decimal::ZERO);
2842 format_smolstr!("{}:{}", update.order.id, qty.normalize())
2843}
2844
2845async fn recover_fills(
2851 http: &reqwest::Client,
2852 rate_limiter: &RateLimitTracker,
2853 instruments: &[InstrumentNameExchange],
2854 base: &str,
2855 after: &str,
2856 tx: &mpsc::UnboundedSender<UnindexedAccountEvent>,
2857 dedup: &SharedDedupCache,
2858) {
2859 info!(%after, instruments = instruments.len(), "Alpaca recovering fills after reconnect");
2862 let instrument_set: fnv::FnvHashSet<&str> = if instruments.is_empty() {
2863 fnv::FnvHashSet::default()
2864 } else {
2865 instruments.iter().map(|i| i.name().as_str()).collect()
2866 };
2867
2868 let page = match paginate_activities(http, rate_limiter, base, after).await {
2869 Ok(p) => p,
2870 Err(e) => {
2871 error!(%e, "Alpaca fill recovery: REST request failed");
2872 return;
2873 }
2874 };
2875
2876 if page.truncated {
2880 error!(
2881 max_pages = MAX_ACTIVITY_PAGES,
2882 "Alpaca fill recovery: max page limit reached, truncating — \
2883 fills from this outage are permanently lost. Manual reconciliation required."
2884 );
2885 }
2886
2887 let activities = page.activities;
2888
2889 let mut recovered = 0u32;
2890 let mut duplicates = 0u32;
2891
2892 let mut cumulative_qty: FnvHashMap<&str, Decimal> = FnvHashMap::default();
2909
2910 for activity in &activities {
2911 if !instrument_set.is_empty() && !instrument_set.contains(activity.symbol.as_str()) {
2912 continue;
2916 }
2917
2918 let exec_qty = Decimal::from_str(&activity.qty).unwrap_or(Decimal::ZERO);
2933 let cum = cumulative_qty
2934 .entry(activity.order_id.as_str())
2935 .or_default();
2936 *cum += exec_qty;
2937 let cumulative = *cum;
2938
2939 let mut trade = match convert_activity_to_trade(activity) {
2940 Some(t) => t,
2941 None => {
2942 warn!(id = %activity.id, symbol = %activity.symbol, "Alpaca: skipping activity with unparseable fields");
2943 continue; }
2945 };
2946
2947 let key_cumulative = trade.order_filled_quantity.unwrap_or(cumulative);
2961 trade.id = TradeId(format_smolstr!(
2962 "{}:{}",
2963 activity.order_id,
2964 key_cumulative.normalize()
2965 ));
2966
2967 let event =
2968 UnindexedAccountEvent::new(ExchangeId::AlpacaBroker, AccountEventKind::Trade(trade));
2969
2970 if fill_dedup_key_from_event(&event).is_some_and(|k| is_duplicate(dedup, k)) {
2972 duplicates += 1;
2973 continue;
2974 }
2975 if tx.send(event).is_err() {
2976 debug!("Alpaca fill recovery: consumer dropped during recovery");
2977 return;
2978 }
2979 recovered += 1;
2980 }
2981
2982 info!(recovered, duplicates, "Alpaca fill recovery complete");
2983}
2984
2985fn convert_account_to_balances(
3006 account: &AlpacaAccount,
3007 assets: &[AssetNameExchange],
3008) -> Vec<AssetBalance<AssetNameExchange>> {
3009 let usd_entry = assets
3012 .iter()
3013 .find(|a| a.name().as_str().eq_ignore_ascii_case("usd"));
3014
3015 if !assets.is_empty() && usd_entry.is_none() {
3017 return Vec::new();
3018 }
3019
3020 let usd_name = usd_entry
3021 .cloned()
3022 .unwrap_or_else(|| AssetNameExchange::new("usd"));
3023
3024 vec![AssetBalance::new(
3025 usd_name,
3026 Balance::new(
3027 account.cash,
3028 account.cash.min(account.non_marginable_buying_power),
3029 ),
3030 Utc::now(),
3031 )]
3032}
3033
3034fn convert_positions_to_balances(
3045 positions: &[AlpacaPosition],
3046 assets: &[AssetNameExchange],
3047) -> Vec<AssetBalance<AssetNameExchange>> {
3048 let now = Utc::now();
3049 positions
3050 .iter()
3051 .filter(|p| is_crypto_position(p))
3052 .filter_map(|p| {
3053 let base = p
3056 .symbol
3057 .split('/')
3058 .next()
3059 .map(|s| s.to_ascii_lowercase())
3060 .unwrap_or_else(|| p.symbol.to_ascii_lowercase());
3061
3062 if !assets.is_empty()
3064 && !assets
3065 .iter()
3066 .any(|a| a.name().as_str().eq_ignore_ascii_case(&base))
3067 {
3068 return None;
3069 }
3070
3071 let asset_name = AssetNameExchange::new(base);
3074 Some(AssetBalance::new(
3075 asset_name,
3076 Balance::new(p.qty, p.qty_available),
3077 now,
3078 ))
3079 })
3080 .collect()
3081}
3082
3083fn is_crypto_position(position: &AlpacaPosition) -> bool {
3086 position.asset_class.eq_ignore_ascii_case("crypto")
3087}
3088
3089fn convert_positions(
3111 positions: &[AlpacaPosition],
3112 now: DateTime<Utc>,
3113) -> Result<Vec<(&str, Position)>, UnindexedClientError> {
3114 positions
3115 .iter()
3116 .filter(|p| !is_crypto_position(p) && !p.qty.is_zero())
3117 .map(|p| {
3118 let size = p.qty.abs();
3119 let quantity = match p.side {
3120 AlpacaPositionSide::Long => size,
3121 AlpacaPositionSide::Short => -size,
3122 AlpacaPositionSide::Unknown => {
3123 return Err(UnindexedClientError::Internal(format!(
3124 "Alpaca position {} has a missing or unrecognised side",
3125 p.symbol
3126 )));
3127 }
3128 };
3129 let position = Position::new(
3130 quantity,
3131 p.avg_entry_price,
3132 p.unrealized_pl,
3133 None,
3134 None,
3135 None,
3136 now,
3137 );
3138 Ok((p.symbol.as_str(), position))
3139 })
3140 .collect()
3141}
3142
3143fn build_instrument_snapshots(
3164 orders: Vec<AlpacaOrderResponse>,
3165 positions: Vec<(&str, Position)>,
3166 instruments: &[InstrumentNameExchange],
3167) -> Vec<InstrumentAccountSnapshot<ExchangeId, AssetNameExchange, InstrumentNameExchange>> {
3168 struct Listing {
3170 orders: Vec<UnindexedOrderSnapshot>,
3171 complete: bool,
3172 position: Option<Position>,
3173 }
3174
3175 impl Listing {
3176 fn empty() -> Self {
3177 Self {
3178 orders: Vec::new(),
3179 complete: true,
3180 position: None,
3181 }
3182 }
3183 }
3184
3185 let mut by_symbol: IndexMap<SmolStr, Listing> = IndexMap::new();
3187
3188 for order in orders {
3191 let listing = by_symbol
3192 .entry(order.symbol.to_ascii_uppercase().into())
3193 .or_insert_with(Listing::empty);
3194 match convert_open_order(&order) {
3195 Some(converted) => listing.orders.push(converted.into()),
3196 None => listing.complete = false,
3197 }
3198 }
3199
3200 for (symbol, position) in positions {
3201 by_symbol
3202 .entry(symbol.to_ascii_uppercase().into())
3203 .or_insert_with(Listing::empty)
3204 .position = Some(position);
3205 }
3206
3207 let snapshot = |instrument: InstrumentNameExchange, listing: Listing| {
3208 let position = if is_options_or_equity_symbol(instrument.name().as_str()) {
3211 listing
3212 .position
3213 .map_or(PositionReport::Flat, PositionReport::from_position)
3214 } else {
3215 PositionReport::Unreported
3216 };
3217 InstrumentAccountSnapshot::new(instrument, listing.orders, listing.complete, position, None)
3218 };
3219
3220 if instruments.is_empty() {
3222 by_symbol
3223 .into_iter()
3224 .map(|(sym, listing)| snapshot(InstrumentNameExchange::new(sym), listing))
3225 .collect()
3226 } else {
3227 let mut claimed = std::collections::HashSet::new();
3229 instruments
3230 .iter()
3231 .map(|inst| {
3232 let symbol = inst.name().as_str().to_ascii_uppercase();
3233 if !claimed.insert(symbol.clone()) {
3236 return InstrumentAccountSnapshot::new(
3237 inst.clone(),
3238 Vec::new(),
3239 false,
3240 PositionReport::Unreported,
3241 None,
3242 );
3243 }
3244 let listing = by_symbol
3247 .swap_remove(symbol.as_str())
3248 .unwrap_or_else(Listing::empty);
3249 snapshot(inst.clone(), listing)
3250 })
3251 .collect()
3252 }
3253}
3254
3255fn convert_open_order(
3265 o: &AlpacaOrderResponse,
3266) -> Option<Order<ExchangeId, InstrumentNameExchange, Open>> {
3267 let converted = convert_representable_open_order(o);
3268 if converted.is_none() {
3269 warn!(
3270 order_id = %o.id,
3271 symbol = %o.symbol,
3272 qty = ?o.qty,
3273 side = %o.side,
3274 order_type = %o.order_type,
3275 "Alpaca open order cannot be represented - leaving it out"
3276 );
3277 }
3278 converted
3279}
3280
3281fn alpaca_cid(client_order_id: Option<&str>, order_id: &str) -> ClientOrderId {
3287 ClientOrderId::new(client_order_id.unwrap_or(order_id))
3288}
3289
3290fn convert_representable_open_order(
3292 o: &AlpacaOrderResponse,
3293) -> Option<Order<ExchangeId, InstrumentNameExchange, Open>> {
3294 let order_id = OrderId(SmolStr::new(&o.id));
3295 let cid = alpaca_cid(o.client_order_id.as_deref(), &o.id);
3296
3297 let instrument = InstrumentNameExchange::new(&o.symbol);
3298 let side = parse_side(&o.side)?;
3299 let quantity = Decimal::from_str(o.qty.as_deref().unwrap_or("0")).ok()?;
3300 if quantity.is_zero() {
3304 return None;
3305 }
3306 let price = o
3307 .limit_price
3308 .as_deref()
3309 .and_then(|s| Decimal::from_str(s).ok());
3310 let filled_qty = Decimal::from_str(&o.filled_qty).unwrap_or(Decimal::ZERO);
3311 let kind = parse_order_kind(
3312 &o.order_type,
3313 o.stop_price.as_deref(),
3314 o.trail_percent.as_deref(),
3315 o.trail_price.as_deref(),
3316 )?;
3317 let time_in_force = parse_time_in_force(&o.time_in_force);
3318 let time_exchange = order_state_time(o);
3319
3320 Some(Order {
3321 key: OrderKey::new(
3322 ExchangeId::AlpacaBroker,
3323 instrument,
3324 StrategyId::unknown(),
3326 cid,
3327 ),
3328 side,
3329 price,
3330 quantity,
3331 kind,
3332 time_in_force,
3333 state: Open::new(VenueOrderId::Assigned(order_id), time_exchange, filled_qty),
3334 })
3335}
3336
3337fn convert_activity_to_trade(
3339 a: &AlpacaActivity,
3340) -> Option<Trade<AssetNameExchange, InstrumentNameExchange>> {
3341 let trade_id = TradeId::new(&a.id);
3342 let order_id = OrderId(SmolStr::new(&a.order_id));
3343 let instrument = InstrumentNameExchange::new(&a.symbol);
3344 let side = parse_side(&a.side)?;
3345 let price = Decimal::from_str(&a.price).ok()?;
3346 let quantity = Decimal::from_str(&a.qty).ok()?;
3347 let order_filled_quantity = a.cum_qty.as_deref().and_then(|s| Decimal::from_str(s).ok());
3350 let time_exchange = parse_timestamp(&a.transaction_time).unwrap_or_else(|| {
3351 warn!(id = %a.id, "Alpaca activity: unparseable transaction_time, using now");
3352 Utc::now()
3353 });
3354
3355 Some(Trade::new(
3360 trade_id,
3361 order_id,
3362 instrument,
3363 StrategyId::unknown(),
3364 time_exchange,
3365 side,
3366 price,
3367 quantity,
3368 order_filled_quantity,
3369 AssetFees::new(
3370 AssetNameExchange::from("USD"),
3371 Decimal::ZERO,
3372 Some(Decimal::ZERO),
3373 ),
3374 ))
3375}
3376
3377fn ws_order_snapshot(
3387 order: &AlpacaOrderWs<'_>,
3388 instrument: InstrumentNameExchange,
3389 cid: ClientOrderId,
3390 order_id: OrderId,
3391 time_exchange: DateTime<Utc>,
3392) -> Option<UnindexedAccountEvent> {
3393 let side = parse_side(&order.side)?;
3394 let quantity = Decimal::from_str(order.qty.unwrap_or("0")).unwrap_or(Decimal::ZERO);
3395 if quantity.is_zero() {
3396 trace!(order_id = %order.id, "Alpaca WS: skipping notional order snapshot (qty=None)");
3397 return None;
3398 }
3399 let price = order.limit_price.and_then(|s| Decimal::from_str(s).ok());
3400 let filled_qty = Decimal::from_str(order.filled_qty.unwrap_or("0")).unwrap_or(Decimal::ZERO);
3401 let kind = parse_order_kind(
3402 &order.order_type,
3403 order.stop_price,
3404 order.trail_percent,
3405 order.trail_price,
3406 )?;
3407 let time_in_force = parse_time_in_force(&order.time_in_force);
3408
3409 let order_snapshot = crate::order::Order {
3410 key: OrderKey::new(
3411 ExchangeId::AlpacaBroker,
3412 instrument,
3413 StrategyId::unknown(),
3414 cid,
3415 ),
3416 side,
3417 price,
3418 quantity,
3419 kind,
3420 time_in_force,
3421 state: OrderState::active(Open::new(
3422 VenueOrderId::Assigned(order_id),
3423 time_exchange,
3424 filled_qty,
3425 )),
3426 };
3427 Some(UnindexedAccountEvent::new(
3428 ExchangeId::AlpacaBroker,
3429 AccountEventKind::OrderSnapshot(rustrade_integration::collection::snapshot::Snapshot(
3430 order_snapshot,
3431 )),
3432 ))
3433}
3434
3435fn ws_fill_order_is_live(status: &str) -> bool {
3445 matches!(status, "partially_filled" | "filled")
3446}
3447
3448fn convert_trade_update(update: AlpacaTradeUpdate<'_>) -> [Option<UnindexedAccountEvent>; 2] {
3458 let event_str = update.event.as_str();
3461 if !matches!(
3462 event_str,
3463 "fill"
3464 | "partial_fill"
3465 | "new"
3466 | "accepted"
3467 | "pending_new"
3468 | "canceled"
3469 | "expired"
3470 | "replaced"
3471 | "done_for_day"
3472 | "rejected"
3473 ) {
3474 trace!(event = %event_str, "Alpaca WS: ignoring trade_updates event type");
3475 return [None, None];
3476 }
3477
3478 let order = &update.order;
3479 let instrument = InstrumentNameExchange::new(&*order.symbol);
3480 let order_id = OrderId(order.id.clone());
3481 let cid = alpaca_cid(order.client_order_id.as_deref(), &order.id);
3482
3483 match event_str {
3484 "fill" | "partial_fill" => {
3485 let (Some(price), Some(quantity), Some(side)) = (
3487 update.price.and_then(|s| Decimal::from_str(s).ok()),
3488 update.qty.and_then(|s| Decimal::from_str(s).ok()),
3489 parse_side(&order.side),
3490 ) else {
3491 return [None, None];
3492 };
3493 let time_exchange = update
3494 .timestamp
3495 .and_then(parse_timestamp)
3496 .unwrap_or_else(Utc::now);
3497
3498 let cum_qty = Decimal::from_str(order.filled_qty.unwrap_or("0"))
3508 .inspect_err(|e| {
3509 warn!(
3510 order_id = %order.id,
3511 filled_qty = ?order.filled_qty,
3512 %e,
3513 "Alpaca WS: failed to parse filled_qty — dedup key will use 0, \
3514 a second malformed fill on the same order would be deduplicated away"
3515 );
3516 })
3517 .unwrap_or(Decimal::ZERO);
3518 let trade_id = TradeId(format_smolstr!("{}:{}", order.id, cum_qty.normalize()));
3519
3520 let trade = Trade::new(
3524 trade_id,
3525 order_id.clone(),
3526 instrument.clone(),
3527 StrategyId::unknown(),
3528 time_exchange,
3529 side,
3530 price,
3531 quantity,
3532 Some(cum_qty),
3534 AssetFees::new(
3535 AssetNameExchange::from("USD"),
3536 Decimal::ZERO,
3537 Some(Decimal::ZERO),
3538 ),
3539 );
3540 let trade_event = UnindexedAccountEvent::new(
3541 ExchangeId::AlpacaBroker,
3542 AccountEventKind::Trade(trade),
3543 );
3544
3545 let snapshot_event = if ws_fill_order_is_live(&order.status) {
3549 ws_order_snapshot(order, instrument, cid, order_id, time_exchange)
3550 } else {
3551 trace!(
3552 order_id = %order.id,
3553 status = %order.status,
3554 "Alpaca WS: fill for an order the exchange no longer reports as working — \
3555 emitting the execution without an order snapshot"
3556 );
3557 None
3558 };
3559
3560 [Some(trade_event), snapshot_event]
3561 }
3562
3563 "new" | "accepted" | "pending_new" => {
3564 let time_exchange = update
3566 .timestamp
3567 .and_then(parse_timestamp)
3568 .unwrap_or_else(Utc::now);
3569
3570 [
3571 ws_order_snapshot(order, instrument, cid, order_id, time_exchange),
3572 None,
3573 ]
3574 }
3575
3576 "canceled" | "expired" | "replaced" | "done_for_day" => {
3577 let time_exchange = update
3585 .timestamp
3586 .and_then(parse_timestamp)
3587 .unwrap_or_else(Utc::now);
3588 let filled_qty =
3589 Decimal::from_str(order.filled_qty.unwrap_or("0")).unwrap_or(Decimal::ZERO);
3590 let cancelled = Cancelled::new(order_id, time_exchange, filled_qty);
3591 let response = crate::order::request::OrderResponseCancel {
3592 key: OrderKey::new(
3593 ExchangeId::AlpacaBroker,
3594 instrument,
3595 StrategyId::unknown(),
3596 cid,
3597 ),
3598 state: Ok(cancelled),
3599 };
3600 [
3601 Some(UnindexedAccountEvent::new(
3602 ExchangeId::AlpacaBroker,
3603 AccountEventKind::OrderCancelled(response),
3604 )),
3605 None,
3606 ]
3607 }
3608
3609 "rejected" => {
3610 let response = crate::order::request::OrderResponseCancel {
3611 key: OrderKey::new(
3612 ExchangeId::AlpacaBroker,
3613 instrument,
3614 StrategyId::unknown(),
3615 cid,
3616 ),
3617 state: Err(UnindexedOrderError::Rejected(ApiError::OrderRejected(
3618 format!("order rejected: status={}", order.status),
3619 ))),
3620 };
3621 [
3622 Some(UnindexedAccountEvent::new(
3623 ExchangeId::AlpacaBroker,
3624 AccountEventKind::OrderCancelled(response),
3625 )),
3626 None,
3627 ]
3628 }
3629
3630 _ => unreachable!("convert_trade_update: unrecognised event passed early-return guard"),
3633 }
3634}
3635
3636fn parse_side(s: &str) -> Option<Side> {
3641 match s {
3642 "buy" | "Buy" | "BUY" => Some(Side::Buy),
3643 "sell" | "Sell" | "SELL" => Some(Side::Sell),
3644 other => {
3645 trace!(%other, "Alpaca: unknown order side");
3646 None
3647 }
3648 }
3649}
3650
3651fn parse_order_kind(
3652 order_type: &str,
3653 stop_price: Option<&str>,
3654 trail_percent: Option<&str>,
3655 trail_price: Option<&str>,
3656) -> Option<OrderKind> {
3657 match order_type {
3658 "market" | "Market" => Some(OrderKind::Market),
3659 "limit" | "Limit" => Some(OrderKind::Limit),
3660 "stop" | "Stop" => {
3661 let trigger_price = stop_price.and_then(|s| Decimal::from_str(s).ok())?;
3662 Some(OrderKind::Stop { trigger_price })
3663 }
3664 "stop_limit" | "Stop_limit" => {
3665 let trigger_price = stop_price.and_then(|s| Decimal::from_str(s).ok())?;
3666 Some(OrderKind::StopLimit { trigger_price })
3667 }
3668 "trailing_stop" | "Trailing_stop" => {
3669 if let Some(pct) = trail_percent.and_then(|s| Decimal::from_str(s).ok()) {
3671 Some(OrderKind::TrailingStop {
3672 offset: pct,
3673 offset_type: TrailingOffsetType::Percentage,
3674 })
3675 } else if let Some(price) = trail_price.and_then(|s| Decimal::from_str(s).ok()) {
3676 Some(OrderKind::TrailingStop {
3677 offset: price,
3678 offset_type: TrailingOffsetType::Absolute,
3679 })
3680 } else {
3681 trace!("Alpaca: trailing_stop missing trail_percent and trail_price");
3682 None
3683 }
3684 }
3685 other => {
3686 trace!(%other, "Alpaca: unsupported order type, skipping");
3687 None
3688 }
3689 }
3690}
3691
3692fn parse_time_in_force(s: &str) -> TimeInForce {
3693 match s {
3694 "gtc" | "GTC" => TimeInForce::GoodUntilCancelled { post_only: false },
3695 "day" | "DAY" => TimeInForce::GoodUntilEndOfDay,
3696 "fok" | "FOK" => TimeInForce::FillOrKill,
3697 "ioc" | "IOC" => TimeInForce::ImmediateOrCancel,
3698 other => {
3699 warn!(%other, "Alpaca: unknown time_in_force, defaulting to GoodUntilEndOfDay");
3700 TimeInForce::GoodUntilEndOfDay
3701 }
3702 }
3703}
3704
3705fn order_state_time(order: &AlpacaOrderResponse) -> DateTime<Utc> {
3715 order
3716 .updated_at
3717 .as_deref()
3718 .and_then(parse_timestamp)
3719 .or_else(|| parse_timestamp(&order.created_at))
3720 .unwrap_or_else(Utc::now)
3721}
3722
3723fn parse_timestamp(s: &str) -> Option<DateTime<Utc>> {
3724 DateTime::parse_from_rfc3339(s)
3725 .ok()
3726 .map(|dt| dt.with_timezone(&Utc))
3727}
3728
3729fn map_side(side: Side) -> &'static str {
3730 match side {
3731 Side::Buy => "buy",
3732 Side::Sell => "sell",
3733 }
3734}
3735
3736fn map_order_kind(kind: OrderKind) -> Option<&'static str> {
3737 match kind {
3738 OrderKind::Market => Some("market"),
3739 OrderKind::Limit => Some("limit"),
3740 OrderKind::Stop { .. } => Some("stop"),
3741 OrderKind::StopLimit { .. } => Some("stop_limit"),
3742 OrderKind::TrailingStop { .. } => Some("trailing_stop"),
3743 OrderKind::TakeProfit { .. }
3745 | OrderKind::TakeProfitLimit { .. }
3746 | OrderKind::TrailingStopLimit { .. } => None,
3747 }
3748}
3749
3750fn map_time_in_force(tif: TimeInForce) -> Result<&'static str, &'static str> {
3759 match tif {
3760 TimeInForce::GoodUntilCancelled { post_only } => {
3761 if post_only {
3762 return Err("Alpaca does not support post_only orders");
3763 }
3764 Ok("gtc")
3765 }
3766 TimeInForce::GoodUntilEndOfDay => Ok("day"),
3767 TimeInForce::FillOrKill => Ok("fok"),
3768 TimeInForce::ImmediateOrCancel => Ok("ioc"),
3769 TimeInForce::AtOpen => Ok("opg"),
3770 TimeInForce::AtClose => Ok("cls"),
3771 TimeInForce::GoodTillDate { .. } => {
3775 Err("Alpaca GoodTillDate is not yet wired through this client")
3776 }
3777 }
3778}
3779
3780fn is_options_or_equity_symbol(symbol: &str) -> bool {
3791 !symbol.contains('/')
3792}
3793
3794fn map_position_intent(side: Side, reduce_only: bool) -> AlpacaPositionIntent {
3803 match (reduce_only, side) {
3804 (false, Side::Buy) => AlpacaPositionIntent::BuyToOpen,
3805 (false, Side::Sell) => AlpacaPositionIntent::SellToOpen,
3806 (true, Side::Buy) => AlpacaPositionIntent::BuyToClose,
3807 (true, Side::Sell) => AlpacaPositionIntent::SellToClose,
3808 }
3809}
3810
3811fn parse_api_error(status: reqwest::StatusCode, message: &str) -> crate::error::UnindexedApiError {
3816 if status == reqwest::StatusCode::TOO_MANY_REQUESTS {
3818 return ApiError::RateLimit;
3819 }
3820
3821 let lower = message.to_ascii_lowercase();
3823 match status.as_u16() {
3824 422 if lower.contains("already") => ApiError::OrderAlreadyCancelled,
3828 422 if lower.contains("insufficient") => {
3833 ApiError::BalanceInsufficient(AssetNameExchange::new("usd"), message.to_owned())
3834 }
3835 401 => ApiError::Unauthenticated(format!("unauthorized: {message}")),
3836 403 => ApiError::Unauthenticated(format!("forbidden: {message}")),
3837 404 => ApiError::OrderRejected(format!("order not found: {message}")),
3838 _ => ApiError::OrderRejected(message.to_owned()),
3839 }
3840}
3841
3842fn parse_order_error(status: reqwest::StatusCode, message: &str) -> UnindexedOrderError {
3844 UnindexedOrderError::Rejected(parse_api_error(status, message))
3845}
3846
3847fn connectivity_err(msg: impl Into<String>) -> UnindexedClientError {
3848 UnindexedClientError::Connectivity(ConnectivityError::Socket(msg.into()))
3849}
3850
3851fn order_post_error(error: UnindexedClientError) -> UnindexedOrderError {
3858 match error {
3859 UnindexedClientError::Connectivity(ce) => OrderError::Connectivity(ce),
3860 UnindexedClientError::Api(ae) => OrderError::Rejected(ae),
3861 UnindexedClientError::Internal(msg) => OrderError::Connectivity(ConnectivityError::Socket(
3862 format!("Alpaca order status unknown, its 2xx response could not be read: {msg}"),
3863 )),
3864 other @ (UnindexedClientError::TaskFailed(_)
3868 | UnindexedClientError::Truncated { .. }
3869 | UnindexedClientError::TruncatedSnapshot { .. }) => OrderError::Connectivity(
3870 ConnectivityError::Socket(format!("Alpaca order status unknown: {other}")),
3871 ),
3872 }
3873}
3874
3875impl BracketOrderClient for AlpacaClient {
3880 async fn open_bracket_order(
3881 &self,
3882 request: UnifiedBracketOrderRequest<ExchangeId, &InstrumentNameExchange>,
3883 ) -> UnifiedBracketOrderResult {
3884 let alpaca_request = AlpacaBracketOrderRequest {
3885 instrument: request.key.instrument.clone(),
3886 strategy: request.key.strategy.clone(),
3887 cid: request.key.cid.clone(),
3888 side: request.state.side,
3889 quantity: request.state.quantity,
3890 entry_price: request.state.entry_price,
3891 take_profit_price: request.state.take_profit_price,
3892 stop_loss_price: request.state.stop_loss_price,
3893 stop_loss_limit_price: request.state.stop_loss_limit_price,
3894 time_in_force: request.state.time_in_force,
3895 };
3896
3897 let result = self.open_bracket_order(alpaca_request).await;
3898
3899 UnifiedBracketOrderResult::parent_only(result.parent)
3900 }
3901}
3902
3903#[cfg(test)]
3908#[allow(clippy::unwrap_used, clippy::expect_used)] mod tests {
3910 use super::*;
3911 use rust_decimal_macros::dec;
3912
3913 #[test]
3914 fn test_alpaca_config_new_uses_paper_trading_by_default() {
3915 let cfg = AlpacaConfig::new("my_key".into(), "my_secret".into());
3916 assert!(cfg.paper);
3917 }
3918
3919 #[test]
3920 fn test_alpaca_config_debug_redacts_credentials() {
3921 let cfg = AlpacaConfig::new("my_key".into(), "my_secret".into());
3922 let debug = format!("{cfg:?}");
3923 assert!(!debug.contains("my_key"), "api_key should be redacted");
3924 assert!(
3925 !debug.contains("my_secret"),
3926 "secret_key should be redacted"
3927 );
3928 assert!(debug.contains("paper: true"));
3929 }
3930
3931 #[test]
3932 fn test_alpaca_config_deserialize_omitted_paper_defaults_to_paper() {
3933 let cfg: AlpacaConfig = serde_json::from_str(
3934 r#"{
3935 "api_key": "my_key",
3936 "secret_key": "my_secret"
3937 }"#,
3938 )
3939 .unwrap();
3940
3941 assert!(cfg.paper);
3942 assert_eq!(cfg.rest_base_url(), "https://paper-api.alpaca.markets");
3943 }
3944
3945 #[test]
3946 fn test_alpaca_config_deserialize_paper_true() {
3947 let cfg: AlpacaConfig = serde_json::from_str(
3948 r#"{
3949 "api_key": "my_key",
3950 "secret_key": "my_secret",
3951 "paper": true
3952 }"#,
3953 )
3954 .unwrap();
3955
3956 assert!(cfg.paper);
3957 assert_eq!(cfg.rest_base_url(), "https://paper-api.alpaca.markets");
3958 }
3959
3960 #[test]
3961 fn test_alpaca_config_deserialize_paper_false() {
3962 let cfg: AlpacaConfig = serde_json::from_str(
3963 r#"{
3964 "api_key": "my_key",
3965 "secret_key": "my_secret",
3966 "paper": false
3967 }"#,
3968 )
3969 .unwrap();
3970
3971 assert!(!cfg.paper);
3972 assert_eq!(cfg.rest_base_url(), "https://api.alpaca.markets");
3973 }
3974
3975 #[test]
3976 fn test_alpaca_config_urls() {
3977 let paper = AlpacaConfig::paper("k".into(), "s".into());
3978 assert!(paper.rest_base_url().contains("paper-api"));
3979 assert!(paper.ws_url().contains("paper-api"));
3980
3981 let live = AlpacaConfig::production("k".into(), "s".into());
3982 assert!(!live.rest_base_url().contains("paper-api"));
3983 assert!(!live.ws_url().contains("paper-api"));
3984 }
3985
3986 #[test]
3987 #[serial_test::serial]
3988 fn test_alpaca_config_from_env_defaults_to_paper_trading() {
3989 temp_env::with_vars(
3990 [
3991 ("ALPACA_API_KEY", Some("my_key")),
3992 ("ALPACA_SECRET_KEY", Some("my_secret")),
3993 ("ALPACA_PAPER", None),
3994 ],
3995 || {
3996 let cfg = AlpacaConfig::from_env().unwrap();
3997 assert!(cfg.paper);
3998 assert_eq!(cfg.rest_base_url(), "https://paper-api.alpaca.markets");
3999 },
4000 );
4001 }
4002
4003 #[test]
4004 #[serial_test::serial]
4005 fn test_alpaca_config_from_env_accepts_explicit_production() {
4006 temp_env::with_vars(
4007 [
4008 ("ALPACA_API_KEY", Some("my_key")),
4009 ("ALPACA_SECRET_KEY", Some("my_secret")),
4010 ("ALPACA_PAPER", Some("false")),
4011 ],
4012 || {
4013 let cfg = AlpacaConfig::from_env().unwrap();
4014 assert!(!cfg.paper);
4015 assert_eq!(cfg.rest_base_url(), "https://api.alpaca.markets");
4016 },
4017 );
4018 }
4019
4020 #[test]
4021 #[serial_test::serial]
4022 fn test_alpaca_config_from_env_rejects_invalid_paper() {
4023 temp_env::with_vars(
4024 [
4025 ("ALPACA_API_KEY", Some("my_key")),
4026 ("ALPACA_SECRET_KEY", Some("my_secret")),
4027 ("ALPACA_PAPER", Some("maybe")),
4028 ],
4029 || {
4030 let err = AlpacaConfig::from_env().unwrap_err();
4031 assert!(matches!(err, AlpacaConfigError::InvalidPaper(value) if value == "maybe"));
4032 },
4033 );
4034 }
4035
4036 #[test]
4037 #[serial_test::serial]
4038 fn test_alpaca_config_from_env_requires_credentials() {
4039 temp_env::with_vars(
4040 [
4041 ("ALPACA_API_KEY", None),
4042 ("ALPACA_SECRET_KEY", Some("my_secret")),
4043 ("ALPACA_PAPER", None),
4044 ],
4045 || {
4046 let err = AlpacaConfig::from_env().unwrap_err();
4047 assert!(matches!(err, AlpacaConfigError::MissingApiKey));
4048 },
4049 );
4050 }
4051
4052 #[test]
4053 fn test_parse_side() {
4054 assert_eq!(parse_side("buy"), Some(Side::Buy));
4055 assert_eq!(parse_side("sell"), Some(Side::Sell));
4056 assert_eq!(parse_side("Buy"), Some(Side::Buy));
4057 assert_eq!(parse_side("BUY"), Some(Side::Buy));
4058 assert_eq!(parse_side("unknown"), None);
4059 }
4060
4061 #[test]
4062 fn test_parse_order_kind() {
4063 assert_eq!(
4065 parse_order_kind("market", None, None, None),
4066 Some(OrderKind::Market)
4067 );
4068 assert_eq!(
4069 parse_order_kind("limit", None, None, None),
4070 Some(OrderKind::Limit)
4071 );
4072
4073 assert_eq!(
4075 parse_order_kind("stop", Some("150.00"), None, None),
4076 Some(OrderKind::Stop {
4077 trigger_price: Decimal::from_str("150.00").unwrap()
4078 })
4079 );
4080 assert_eq!(parse_order_kind("stop", None, None, None), None);
4081
4082 assert_eq!(
4084 parse_order_kind("stop_limit", Some("145.00"), None, None),
4085 Some(OrderKind::StopLimit {
4086 trigger_price: Decimal::from_str("145.00").unwrap()
4087 })
4088 );
4089
4090 assert_eq!(
4092 parse_order_kind("trailing_stop", None, Some("5.0"), None),
4093 Some(OrderKind::TrailingStop {
4094 offset: Decimal::from_str("5.0").unwrap(),
4095 offset_type: TrailingOffsetType::Percentage,
4096 })
4097 );
4098
4099 assert_eq!(
4101 parse_order_kind("trailing_stop", None, None, Some("2.50")),
4102 Some(OrderKind::TrailingStop {
4103 offset: Decimal::from_str("2.50").unwrap(),
4104 offset_type: TrailingOffsetType::Absolute,
4105 })
4106 );
4107
4108 assert_eq!(parse_order_kind("trailing_stop", None, None, None), None);
4110
4111 assert_eq!(parse_order_kind("unknown", None, None, None), None);
4113 }
4114
4115 #[test]
4116 fn test_map_order_kind() {
4117 assert_eq!(map_order_kind(OrderKind::Market), Some("market"));
4118 assert_eq!(map_order_kind(OrderKind::Limit), Some("limit"));
4119 assert_eq!(
4120 map_order_kind(OrderKind::Stop {
4121 trigger_price: Decimal::from_str("150.00").unwrap()
4122 }),
4123 Some("stop")
4124 );
4125 assert_eq!(
4126 map_order_kind(OrderKind::StopLimit {
4127 trigger_price: Decimal::from_str("145.00").unwrap()
4128 }),
4129 Some("stop_limit")
4130 );
4131 assert_eq!(
4132 map_order_kind(OrderKind::TrailingStop {
4133 offset: Decimal::from_str("5.0").unwrap(),
4134 offset_type: TrailingOffsetType::Percentage,
4135 }),
4136 Some("trailing_stop")
4137 );
4138 assert_eq!(
4139 map_order_kind(OrderKind::TrailingStop {
4140 offset: Decimal::from_str("2.50").unwrap(),
4141 offset_type: TrailingOffsetType::Absolute,
4142 }),
4143 Some("trailing_stop")
4144 );
4145 assert_eq!(
4147 map_order_kind(OrderKind::TrailingStopLimit {
4148 offset: Decimal::from_str("5.0").unwrap(),
4149 offset_type: TrailingOffsetType::Percentage,
4150 limit_offset: Decimal::from_str("1.0").unwrap(),
4151 }),
4152 None
4153 );
4154 assert_eq!(
4156 map_order_kind(OrderKind::TakeProfit {
4157 trigger_price: Decimal::from_str("160.00").unwrap()
4158 }),
4159 None
4160 );
4161 assert_eq!(
4162 map_order_kind(OrderKind::TakeProfitLimit {
4163 trigger_price: Decimal::from_str("160.00").unwrap()
4164 }),
4165 None
4166 );
4167 }
4168
4169 #[test]
4170 fn test_map_time_in_force_roundtrip() {
4171 assert_eq!(
4172 map_time_in_force(TimeInForce::GoodUntilCancelled { post_only: false }),
4173 Ok("gtc")
4174 );
4175 assert_eq!(map_time_in_force(TimeInForce::GoodUntilEndOfDay), Ok("day"));
4176 assert_eq!(map_time_in_force(TimeInForce::FillOrKill), Ok("fok"));
4177 assert_eq!(map_time_in_force(TimeInForce::ImmediateOrCancel), Ok("ioc"));
4178 }
4179
4180 #[test]
4181 fn test_map_time_in_force_rejects_post_only() {
4182 let result = map_time_in_force(TimeInForce::GoodUntilCancelled { post_only: true });
4183 assert!(result.is_err(), "post_only must be rejected");
4184 assert!(result.unwrap_err().contains("post_only"));
4185 }
4186
4187 #[test]
4192 fn test_bracket_order_serializes_with_stop_loss_stop_order() {
4193 let body = AlpacaOrderRequest {
4195 symbol: "AAPL",
4196 qty: "10".to_string(),
4197 side: "buy",
4198 order_type: "limit",
4199 time_in_force: "gtc",
4200 limit_price: Some("150.00".to_string()),
4201 stop_price: None,
4202 trail_percent: None,
4203 trail_price: None,
4204 client_order_id: Some("bracket-001"),
4205 position_intent: Some(AlpacaPositionIntent::BuyToOpen),
4206 order_class: Some("bracket"),
4207 take_profit: Some(TakeProfitParams {
4208 limit_price: "160.00".to_string(),
4209 }),
4210 stop_loss: Some(StopLossParams {
4211 stop_price: "145.00".to_string(),
4212 limit_price: None,
4213 }),
4214 };
4215
4216 let json = serde_json::to_value(&body).unwrap();
4217
4218 assert_eq!(json["symbol"], "AAPL");
4219 assert_eq!(json["qty"], "10");
4220 assert_eq!(json["side"], "buy");
4221 assert_eq!(json["type"], "limit");
4222 assert_eq!(json["time_in_force"], "gtc");
4223 assert_eq!(json["limit_price"], "150.00");
4224 assert_eq!(json["order_class"], "bracket");
4225
4226 assert_eq!(json["take_profit"]["limit_price"], "160.00");
4228
4229 assert_eq!(json["stop_loss"]["stop_price"], "145.00");
4231 assert!(
4232 json["stop_loss"].get("limit_price").is_none(),
4233 "stop_loss.limit_price should be omitted when None"
4234 );
4235 }
4236
4237 #[test]
4238 fn test_bracket_order_serializes_with_stop_loss_stop_limit_order() {
4239 let body = AlpacaOrderRequest {
4241 symbol: "SPY",
4242 qty: "5".to_string(),
4243 side: "sell",
4244 order_type: "limit",
4245 time_in_force: "day",
4246 limit_price: Some("450.00".to_string()),
4247 stop_price: None,
4248 trail_percent: None,
4249 trail_price: None,
4250 client_order_id: Some("bracket-002"),
4251 position_intent: Some(AlpacaPositionIntent::SellToClose),
4252 order_class: Some("bracket"),
4253 take_profit: Some(TakeProfitParams {
4254 limit_price: "440.00".to_string(),
4255 }),
4256 stop_loss: Some(StopLossParams {
4257 stop_price: "455.00".to_string(),
4258 limit_price: Some("456.00".to_string()),
4259 }),
4260 };
4261
4262 let json = serde_json::to_value(&body).unwrap();
4263
4264 assert_eq!(json["symbol"], "SPY");
4265 assert_eq!(json["side"], "sell");
4266 assert_eq!(json["time_in_force"], "day");
4267 assert_eq!(json["order_class"], "bracket");
4268
4269 assert_eq!(json["take_profit"]["limit_price"], "440.00");
4271
4272 assert_eq!(json["stop_loss"]["stop_price"], "455.00");
4274 assert_eq!(
4275 json["stop_loss"]["limit_price"], "456.00",
4276 "stop_loss.limit_price should be present for stop-limit orders"
4277 );
4278 }
4279
4280 #[tokio::test]
4285 async fn test_open_bracket_order_rejects_invalid_tif() {
4286 use rust_decimal_macros::dec;
4287 use rustrade_instrument::instrument::name::InstrumentNameExchange;
4288
4289 let config = AlpacaConfig::new("dummy_key".into(), "dummy_secret".into());
4291 let client = AlpacaClient::new(config);
4292
4293 let request = AlpacaBracketOrderRequest::new(
4294 InstrumentNameExchange::new("SPY"),
4295 crate::order::id::StrategyId::new("test"),
4296 crate::order::id::ClientOrderId::new("test-tif"),
4297 Side::Buy,
4298 dec!(1),
4299 dec!(100.00),
4300 dec!(120.00),
4301 dec!(90.00),
4302 TimeInForce::ImmediateOrCancel, );
4304
4305 let result = client.open_bracket_order(request).await;
4306
4307 assert!(
4308 result.parent.state.is_failed(),
4309 "Bracket order with IOC TIF should be rejected locally"
4310 );
4311 }
4312
4313 #[tokio::test]
4314 async fn test_open_bracket_order_rejects_invalid_price_ordering() {
4315 use rust_decimal_macros::dec;
4316 use rustrade_instrument::instrument::name::InstrumentNameExchange;
4317
4318 let config = AlpacaConfig::new("dummy_key".into(), "dummy_secret".into());
4319 let client = AlpacaClient::new(config);
4320
4321 let request = AlpacaBracketOrderRequest::new(
4323 InstrumentNameExchange::new("SPY"),
4324 crate::order::id::StrategyId::new("test"),
4325 crate::order::id::ClientOrderId::new("test-price"),
4326 Side::Buy,
4327 dec!(1),
4328 dec!(100.00),
4329 dec!(120.00),
4330 dec!(105.00), TimeInForce::GoodUntilCancelled { post_only: false },
4332 );
4333
4334 let result = client.open_bracket_order(request).await;
4335
4336 assert!(
4337 result.parent.state.is_failed(),
4338 "Bracket order with invalid price ordering should be rejected locally"
4339 );
4340 }
4341
4342 #[tokio::test]
4343 async fn test_open_bracket_order_rejects_invalid_sl_limit_price() {
4344 use rust_decimal_macros::dec;
4345 use rustrade_instrument::instrument::name::InstrumentNameExchange;
4346
4347 let config = AlpacaConfig::new("dummy_key".into(), "dummy_secret".into());
4348 let client = AlpacaClient::new(config);
4349
4350 let request = AlpacaBracketOrderRequest::new(
4352 InstrumentNameExchange::new("SPY"),
4353 crate::order::id::StrategyId::new("test"),
4354 crate::order::id::ClientOrderId::new("test-sl-limit"),
4355 Side::Buy,
4356 dec!(1),
4357 dec!(100.00),
4358 dec!(120.00),
4359 dec!(90.00),
4360 TimeInForce::GoodUntilCancelled { post_only: false },
4361 )
4362 .with_stop_loss_limit_price(dec!(95.00)); let result = client.open_bracket_order(request).await;
4365
4366 assert!(
4367 result.parent.state.is_failed(),
4368 "Bracket order with invalid SL limit price should be rejected locally"
4369 );
4370 }
4371
4372 #[test]
4373 fn test_non_bracket_order_omits_bracket_fields() {
4374 let body = AlpacaOrderRequest {
4376 symbol: "AAPL",
4377 qty: "1".to_string(),
4378 side: "buy",
4379 order_type: "limit",
4380 time_in_force: "gtc",
4381 limit_price: Some("150.00".to_string()),
4382 stop_price: None,
4383 trail_percent: None,
4384 trail_price: None,
4385 client_order_id: Some("regular-001"),
4386 position_intent: Some(AlpacaPositionIntent::BuyToOpen),
4387 order_class: None,
4388 take_profit: None,
4389 stop_loss: None,
4390 };
4391
4392 let json = serde_json::to_value(&body).unwrap();
4393
4394 assert_eq!(json["symbol"], "AAPL");
4395 assert!(
4396 json.get("order_class").is_none(),
4397 "order_class should be omitted for non-bracket orders"
4398 );
4399 assert!(
4400 json.get("take_profit").is_none(),
4401 "take_profit should be omitted for non-bracket orders"
4402 );
4403 assert!(
4404 json.get("stop_loss").is_none(),
4405 "stop_loss should be omitted for non-bracket orders"
4406 );
4407 }
4408
4409 #[test]
4410 fn test_parse_timestamp_valid() {
4411 let ts = parse_timestamp("2025-04-18T14:30:00Z");
4412 assert!(ts.is_some());
4413 let ts2 = parse_timestamp("2025-04-18T14:30:00.123456Z");
4414 assert!(ts2.is_some());
4415 assert_eq!(parse_timestamp("not-a-timestamp"), None);
4416 }
4417
4418 #[test]
4419 fn test_check_auth_response_authorized() {
4420 let msg =
4421 r#"{"stream":"authorization","data":{"status":"authorized","action":"authenticate"}}"#;
4422 assert!(matches!(check_auth_response(msg), Some(Ok(()))));
4423 }
4424
4425 #[test]
4426 fn test_check_auth_response_unauthorized() {
4427 let msg = r#"{"stream":"authorization","data":{"status":"unauthorized"}}"#;
4428 assert!(matches!(
4429 check_auth_response(msg),
4430 Some(Err(HandshakeError::Auth(_)))
4431 ));
4432 }
4433
4434 #[test]
4435 fn test_check_auth_response_non_auth_message() {
4436 let msg = r#"{"stream":"trade_updates","data":{}}"#;
4437 assert!(check_auth_response(msg).is_none());
4438 }
4439
4440 #[test]
4441 fn test_check_listen_ack() {
4442 let ack = r#"{"stream":"listening","data":{"streams":["trade_updates"]}}"#;
4443 assert!(check_listen_ack(ack));
4444
4445 let other = r#"{"stream":"authorization","data":{}}"#;
4446 assert!(!check_listen_ack(other));
4447 }
4448
4449 #[test]
4450 fn test_dedup_cache() {
4451 let cache = new_dedup_cache();
4452 let key = SmolStr::new("order-1:1");
4453 assert!(
4454 !is_duplicate(&cache, &key),
4455 "first time should not be duplicate"
4456 );
4457 assert!(
4458 is_duplicate(&cache, &key),
4459 "second time should be duplicate"
4460 );
4461 }
4462
4463 #[tokio::test]
4464 async fn test_exponential_backoff_progression_and_exhaustion() {
4465 tokio::time::pause();
4466
4467 let mut b = ExponentialBackoff::new();
4468
4469 assert!(b.wait().await, "first wait should return true");
4471 assert_eq!(b.attempt, 1);
4472
4473 while b.wait().await {}
4475
4476 assert_eq!(b.attempt, MAX_RECONNECT_ATTEMPTS);
4478
4479 assert!(!b.wait().await, "exhausted backoff should return false");
4481
4482 b.reset();
4484 assert_eq!(b.attempt, 0);
4485
4486 assert!(b.wait().await, "wait should succeed after reset");
4488 assert_eq!(b.attempt, 1);
4489 }
4490
4491 #[test]
4492 fn test_convert_account_to_balances_empty_assets() {
4493 let account: AlpacaAccount = serde_json::from_value(serde_json::json!({
4496 "cash": "9000.50",
4497 "equity": "12000.00",
4498 "buying_power": "36000.00",
4499 "regt_buying_power": "18000.00",
4500 "options_buying_power": "8000.00",
4501 "non_marginable_buying_power": "8500.25",
4502 }))
4503 .unwrap();
4504 let balances = convert_account_to_balances(&account, &[]);
4505 assert_eq!(balances.len(), 1);
4506 assert_eq!(balances[0].asset, AssetNameExchange::new("usd"));
4507 assert_eq!(balances[0].balance.total, dec!(9000.50), "total is cash");
4508 assert_eq!(
4509 balances[0].balance.free,
4510 dec!(8500.25),
4511 "free is non-marginable buying power when below cash"
4512 );
4513 assert_eq!(balances[0].balance.margin, None);
4514 }
4515
4516 #[test]
4519 fn test_convert_account_to_balances_free_capped_at_cash_with_long_stock() {
4520 let account: AlpacaAccount = serde_json::from_value(serde_json::json!({
4521 "cash": "50000",
4522 "equity": "100000",
4523 "initial_margin": "25000",
4524 "non_marginable_buying_power": "75000",
4525 }))
4526 .unwrap();
4527 let balances = convert_account_to_balances(&account, &[]);
4528 assert_eq!(balances[0].balance.total, dec!(50000));
4529 assert_eq!(balances[0].balance.free, dec!(50000));
4530 }
4531
4532 #[test]
4535 fn test_convert_account_to_balances_open_short_keeps_free_below_cash() {
4536 let account: AlpacaAccount = serde_json::from_value(serde_json::json!({
4537 "cash": "10200",
4538 "equity": "10000",
4539 "initial_margin": "100",
4540 "non_marginable_buying_power": "9900",
4541 }))
4542 .unwrap();
4543 let balances = convert_account_to_balances(&account, &[]);
4544 assert_eq!(balances[0].balance.total, dec!(10200));
4545 assert_eq!(balances[0].balance.free, dec!(9900));
4546 }
4547
4548 #[test]
4549 fn test_convert_account_to_balances_negative_cash_on_margin() {
4550 let account: AlpacaAccount = serde_json::from_value(serde_json::json!({
4551 "cash": "-2500.75",
4552 "equity": "7500.00",
4553 "buying_power": "5000.00",
4554 "non_marginable_buying_power": "1250.00",
4555 }))
4556 .unwrap();
4557 let balances = convert_account_to_balances(&account, &[]);
4558 assert_eq!(balances.len(), 1);
4559 assert_eq!(balances[0].balance.total, dec!(-2500.75));
4560 assert_eq!(
4561 balances[0].balance.free,
4562 dec!(-2500.75),
4563 "no free cash while borrowing; free <= total still holds"
4564 );
4565 }
4566
4567 #[test]
4568 fn test_alpaca_account_malformed_cash_fails_to_decode() {
4569 let result = serde_json::from_value::<AlpacaAccount>(serde_json::json!({
4570 "cash": "not-a-number",
4571 "non_marginable_buying_power": "0",
4572 }));
4573 assert!(result.is_err(), "a malformed cash must not read as zero");
4574 }
4575
4576 #[test]
4577 fn test_convert_account_to_balances_usd_filter() {
4578 let account = AlpacaAccount {
4579 cash: dec!(12000.00),
4580 non_marginable_buying_power: dec!(10000.00),
4581 };
4582 let usd = vec![AssetNameExchange::new("USD")];
4583 let balances = convert_account_to_balances(&account, &usd);
4584 assert_eq!(balances.len(), 1);
4585 assert_eq!(balances[0].asset, AssetNameExchange::new("USD"));
4586
4587 let non_usd = vec![AssetNameExchange::new("BTC")];
4588 let balances = convert_account_to_balances(&account, &non_usd);
4589 assert!(balances.is_empty());
4590 }
4591
4592 #[test]
4593 fn test_is_options_or_equity_symbol() {
4594 assert!(!is_options_or_equity_symbol("BTC/USD"));
4596 assert!(!is_options_or_equity_symbol("ETH/USD"));
4597 assert!(!is_options_or_equity_symbol("SOL/USD"));
4598
4599 assert!(is_options_or_equity_symbol("AAPL"));
4601 assert!(is_options_or_equity_symbol("SPY"));
4602 assert!(is_options_or_equity_symbol("MSFT"));
4603
4604 assert!(is_options_or_equity_symbol("SPY250418C00450000"));
4606 assert!(is_options_or_equity_symbol("AAPL250418P00145000"));
4607 }
4608
4609 #[test]
4610 fn test_parse_order_error_already_cancelled() {
4611 assert!(matches!(
4614 parse_order_error(
4615 reqwest::StatusCode::UNPROCESSABLE_ENTITY,
4616 "order is already cancelled"
4617 ),
4618 UnindexedOrderError::Rejected(ApiError::OrderAlreadyCancelled)
4619 ));
4620 }
4621
4622 #[test]
4623 fn test_parse_order_error_already_wins_over_insufficient_on_422() {
4624 assert!(matches!(
4627 parse_order_error(
4628 reqwest::StatusCode::UNPROCESSABLE_ENTITY,
4629 "order already cancelled due to insufficient margin"
4630 ),
4631 UnindexedOrderError::Rejected(ApiError::OrderAlreadyCancelled)
4632 ));
4633 }
4634
4635 fn make_order_ws<'a>(
4636 id: &str,
4637 symbol: &str,
4638 side: &str,
4639 filled_qty: &'a str,
4640 ) -> AlpacaOrderWs<'a> {
4641 AlpacaOrderWs {
4642 id: SmolStr::new(id),
4643 client_order_id: None,
4644 symbol: SmolStr::new(symbol),
4645 qty: Some("2"),
4646 filled_qty: Some(filled_qty),
4647 side: SmolStr::new(side),
4648 order_type: SmolStr::new("limit"),
4649 time_in_force: SmolStr::new("day"),
4650 limit_price: Some("100.00"),
4651 stop_price: None,
4652 trail_percent: None,
4653 trail_price: None,
4654 status: SmolStr::new("partially_filled"),
4655 }
4656 }
4657
4658 fn sole_event(events: [Option<UnindexedAccountEvent>; 2]) -> Option<UnindexedAccountEvent> {
4663 let [first, second] = events;
4664 assert!(
4665 second.is_none(),
4666 "expected a single event, got a second: {second:?}"
4667 );
4668 first
4669 }
4670
4671 fn fill_events(
4673 events: [Option<UnindexedAccountEvent>; 2],
4674 ) -> (
4675 Trade<AssetNameExchange, InstrumentNameExchange>,
4676 crate::order::Order<
4677 ExchangeId,
4678 InstrumentNameExchange,
4679 OrderState<AssetNameExchange, InstrumentNameExchange>,
4680 >,
4681 ) {
4682 let [first, second] = events;
4683 let first = first.expect("a fill frame must produce an execution");
4684 let second = second.expect("a fill frame must produce an order snapshot");
4685 let AccountEventKind::Trade(trade) = first.kind else {
4686 panic!("first event must be the Trade, got {:?}", first.kind);
4687 };
4688 let AccountEventKind::OrderSnapshot(rustrade_integration::collection::snapshot::Snapshot(
4689 order,
4690 )) = second.kind
4691 else {
4692 panic!(
4693 "second event must be the OrderSnapshot, got {:?}",
4694 second.kind
4695 );
4696 };
4697 (trade, order)
4698 }
4699
4700 #[test]
4701 fn test_convert_trade_update_fill_produces_trade_with_dedup_key() {
4702 let update = AlpacaTradeUpdate {
4703 event: SmolStr::new("fill"),
4704 order: make_order_ws("ord-1", "SPY", "buy", "1"),
4705 price: Some("150.00"),
4706 qty: Some("1"),
4707 timestamp: Some("2025-04-18T14:30:00Z"),
4708 };
4709 let (trade, order) = fill_events(convert_trade_update(update));
4710 assert_eq!(trade.id.0.as_str(), "ord-1:1");
4712 assert_eq!(trade.price, Decimal::from_str("150.00").unwrap());
4713 assert_eq!(trade.quantity, Decimal::from_str("1").unwrap());
4714
4715 let OrderState::Active(crate::order::state::ActiveOrderState::Open(open)) = order.state
4718 else {
4719 panic!("expected an Open snapshot, got {:?}", order.state);
4720 };
4721 assert_eq!(open.filled_quantity, Decimal::from_str("1").unwrap());
4722 assert_eq!(open.id.assigned().map(|id| id.0.as_str()), Some("ord-1"));
4723 }
4724
4725 #[test]
4726 fn test_convert_trade_update_partial_fill() {
4727 let update = AlpacaTradeUpdate {
4728 event: SmolStr::new("partial_fill"),
4729 order: make_order_ws("ord-2", "AAPL", "sell", "0.5"),
4730 price: Some("200.00"),
4731 qty: Some("0.5"),
4732 timestamp: None,
4733 };
4734 let (trade, order) = fill_events(convert_trade_update(update));
4735 assert_eq!(trade.quantity, Decimal::from_str("0.5").unwrap());
4736
4737 let OrderState::Active(crate::order::state::ActiveOrderState::Open(open)) = order.state
4740 else {
4741 panic!("expected an Open snapshot, got {:?}", order.state);
4742 };
4743 assert_eq!(open.filled_quantity, Decimal::from_str("0.5").unwrap());
4744 assert_eq!(
4745 open.quantity_remaining(order.quantity),
4746 Decimal::from_str("1.5").unwrap()
4747 );
4748 }
4749
4750 #[test]
4754 fn a_full_fill_snapshot_reports_nothing_left_to_fill() {
4755 let update = AlpacaTradeUpdate {
4756 event: SmolStr::new("fill"),
4757 order: AlpacaOrderWs {
4759 status: SmolStr::new("filled"),
4760 ..make_order_ws("ord-full", "SPY", "buy", "2")
4761 },
4762 price: Some("150.00"),
4763 qty: Some("1"),
4764 timestamp: Some("2025-04-18T14:30:00Z"),
4765 };
4766 let (_trade, order) = fill_events(convert_trade_update(update));
4767 let OrderState::Active(crate::order::state::ActiveOrderState::Open(open)) = order.state
4768 else {
4769 panic!("expected an Open snapshot, got {:?}", order.state);
4770 };
4771 assert_eq!(open.filled_quantity, Decimal::from_str("2").unwrap());
4772 assert!(
4773 open.quantity_remaining(order.quantity).is_zero(),
4774 "a completed order must report nothing left to fill so the engine retires it"
4775 );
4776 }
4777
4778 #[test]
4782 fn a_fill_for_an_order_no_longer_working_emits_the_execution_without_a_snapshot() {
4783 for status in [
4784 "canceled",
4785 "expired",
4786 "rejected",
4787 "done_for_day",
4788 "pending_cancel",
4789 ] {
4790 let update = AlpacaTradeUpdate {
4791 event: SmolStr::new("partial_fill"),
4792 order: AlpacaOrderWs {
4793 status: SmolStr::new(status),
4794 ..make_order_ws("ord-late", "SPY", "buy", "1")
4795 },
4796 price: Some("150.00"),
4797 qty: Some("1"),
4798 timestamp: None,
4799 };
4800 let [trade, snapshot] = convert_trade_update(update);
4801 assert!(
4802 matches!(trade.map(|e| e.kind), Some(AccountEventKind::Trade(_))),
4803 "the execution must still be reported for status {status}"
4804 );
4805 assert!(
4806 snapshot.is_none(),
4807 "status {status} must not produce an order snapshot"
4808 );
4809 }
4810 }
4811
4812 #[test]
4815 fn a_notional_order_fill_emits_the_execution_without_a_snapshot() {
4816 let update = AlpacaTradeUpdate {
4817 event: SmolStr::new("partial_fill"),
4818 order: AlpacaOrderWs {
4819 qty: None,
4820 ..make_order_ws("ord-notional-ws", "SPY", "buy", "1")
4821 },
4822 price: Some("150.00"),
4823 qty: Some("1"),
4824 timestamp: None,
4825 };
4826 let [trade, snapshot] = convert_trade_update(update);
4827 assert!(matches!(
4828 trade.map(|e| e.kind),
4829 Some(AccountEventKind::Trade(_))
4830 ));
4831 assert!(
4832 snapshot.is_none(),
4833 "a notional order has no quantity to snapshot against"
4834 );
4835 }
4836
4837 #[test]
4838 fn test_convert_trade_update_new_order_produces_snapshot() {
4839 let update = AlpacaTradeUpdate {
4840 event: SmolStr::new("new"),
4841 order: AlpacaOrderWs {
4842 id: SmolStr::new("ord-new"),
4843 client_order_id: Some(SmolStr::new("cid-1")),
4844 symbol: SmolStr::new("AAPL"),
4845 qty: Some("10"),
4846 filled_qty: Some("0"),
4847 side: SmolStr::new("buy"),
4848 order_type: SmolStr::new("limit"),
4849 time_in_force: SmolStr::new("day"),
4850 limit_price: Some("150.00"),
4851 stop_price: None,
4852 trail_percent: None,
4853 trail_price: None,
4854 status: SmolStr::new("new"),
4855 },
4856 price: None,
4857 qty: None,
4858 timestamp: Some("2025-04-18T14:30:00Z"),
4859 };
4860 let event =
4861 sole_event(convert_trade_update(update)).expect("new event should produce an event");
4862 assert!(matches!(event.kind, AccountEventKind::OrderSnapshot(_)));
4863 }
4864
4865 #[test]
4866 fn test_convert_trade_update_canceled_produces_cancel() {
4867 let update = AlpacaTradeUpdate {
4868 event: SmolStr::new("canceled"),
4869 order: make_order_ws("ord-3", "AAPL", "sell", "0"),
4870 price: None,
4871 qty: None,
4872 timestamp: Some("2025-04-18T14:30:00Z"),
4873 };
4874 let event =
4875 sole_event(convert_trade_update(update)).expect("canceled should produce an event");
4876 let AccountEventKind::OrderCancelled(response) = event.kind else {
4877 panic!("expected OrderCancelled, got {:?}", event.kind);
4878 };
4879 assert!(response.state.is_ok());
4880 }
4881
4882 #[test]
4883 fn test_convert_trade_update_rejected_produces_error() {
4884 let update = AlpacaTradeUpdate {
4885 event: SmolStr::new("rejected"),
4886 order: make_order_ws("ord-4", "SPY", "buy", "0"),
4887 price: None,
4888 qty: None,
4889 timestamp: None,
4890 };
4891 let event =
4892 sole_event(convert_trade_update(update)).expect("rejected should produce an event");
4893 let AccountEventKind::OrderCancelled(response) = event.kind else {
4894 panic!("expected OrderCancelled, got {:?}", event.kind);
4895 };
4896 assert!(response.state.is_err());
4897 }
4898
4899 #[test]
4900 fn test_convert_open_order_notional_qty_none_is_skipped() {
4901 let order = AlpacaOrderResponse {
4904 id: "ord-notional".to_string(),
4905 client_order_id: None,
4906 symbol: "SPY".to_string(),
4907 qty: None,
4908 filled_qty: "0".to_string(),
4909 side: "buy".to_string(),
4910 order_type: "market".to_string(),
4911 time_in_force: "day".to_string(),
4912 limit_price: None,
4913 stop_price: None,
4914 trail_percent: None,
4915 trail_price: None,
4916 created_at: "2025-04-18T14:30:00Z".to_string(),
4917 updated_at: None,
4918 };
4919 assert!(convert_open_order(&order).is_none());
4920 }
4921
4922 #[test]
4923 fn test_convert_activity_to_trade_bad_price_returns_none() {
4924 let activity = AlpacaActivity {
4928 id: "act-1".to_string(),
4929 order_id: "ord-1".to_string(),
4930 symbol: "SPY250418C00450000".to_string(),
4931 side: "buy".to_string(),
4932 price: "not-a-number".to_string(),
4933 qty: "1".to_string(),
4934 transaction_time: "2025-04-18T14:30:00Z".to_string(),
4935 cum_qty: Some("1".to_string()),
4936 };
4937 assert!(convert_activity_to_trade(&activity).is_none());
4938 }
4939
4940 #[test]
4941 fn test_convert_positions_to_balances_crypto() {
4942 let positions = vec![
4943 AlpacaPosition {
4944 qty_available: dec!(0.4),
4945 ..alpaca_position("BTC/USD", "crypto", AlpacaPositionSide::Long, dec!(0.5))
4946 },
4947 alpaca_position("ETH/USD", "crypto", AlpacaPositionSide::Long, dec!(2.0)),
4948 alpaca_position("AAPL", "us_equity", AlpacaPositionSide::Long, dec!(10)),
4950 ];
4951
4952 let balances = convert_positions_to_balances(&positions, &[]);
4954 assert_eq!(balances.len(), 2, "only crypto positions returned");
4955 assert_eq!(balances[0].asset.name().as_str(), "btc");
4956 assert_eq!(balances[0].balance.total, dec!(0.5));
4958 assert_eq!(balances[0].balance.free, dec!(0.4));
4959
4960 let btc_only = vec![AssetNameExchange::new("BTC")];
4962 let balances = convert_positions_to_balances(&positions, &btc_only);
4963 assert_eq!(balances.len(), 1);
4964 assert_eq!(balances[0].asset.name().as_str(), "btc");
4965 }
4966
4967 fn alpaca_position(
4969 symbol: &str,
4970 asset_class: &str,
4971 side: AlpacaPositionSide,
4972 qty: Decimal,
4973 ) -> AlpacaPosition {
4974 AlpacaPosition {
4975 symbol: symbol.into(),
4976 asset_class: asset_class.into(),
4977 side,
4978 qty,
4979 qty_available: qty,
4980 avg_entry_price: Some(dec!(100)),
4981 unrealized_pl: Some(dec!(0)),
4982 }
4983 }
4984
4985 #[test]
4986 fn test_convert_positions_sign_comes_from_side_not_qty() {
4987 let now = Utc::now();
4988 let positions = vec![
4989 alpaca_position("AAPL", "us_equity", AlpacaPositionSide::Long, dec!(10)),
4990 alpaca_position("TSLA", "us_equity", AlpacaPositionSide::Short, dec!(-3)),
4991 alpaca_position("MSFT", "us_equity", AlpacaPositionSide::Short, dec!(4)),
4993 ];
4994
4995 let converted = convert_positions(&positions, now).unwrap();
4996 let quantities: Vec<_> = converted.iter().map(|(s, p)| (*s, p.quantity)).collect();
4997 assert_eq!(
4998 quantities,
4999 vec![("AAPL", dec!(10)), ("TSLA", dec!(-3)), ("MSFT", dec!(-4))]
5000 );
5001 assert!(converted.iter().all(|(_, p)| p.time_exchange == now));
5002 }
5003
5004 #[test]
5005 fn test_convert_positions_leaves_out_crypto_and_flat() {
5006 let positions = vec![
5007 alpaca_position("BTC/USD", "crypto", AlpacaPositionSide::Long, dec!(0.5)),
5008 alpaca_position("AAPL", "us_equity", AlpacaPositionSide::Long, dec!(0)),
5009 alpaca_position("SPY", "us_equity", AlpacaPositionSide::Long, dec!(1)),
5010 ];
5011
5012 let converted = convert_positions(&positions, Utc::now()).unwrap();
5013 let symbols: Vec<_> = converted.iter().map(|(s, _)| *s).collect();
5014 assert_eq!(symbols, vec!["SPY"]);
5015 }
5016
5017 #[test]
5018 fn test_alpaca_position_decodes_equity_option_and_fractional() {
5019 let positions: Vec<AlpacaPosition> = serde_json::from_value(serde_json::json!([
5020 {
5021 "asset_id": "00000000-0000-0000-0000-000000000001",
5022 "symbol": "XYZ",
5023 "exchange": "NASDAQ",
5024 "asset_class": "us_equity",
5025 "qty": "-7",
5026 "qty_available": "-7",
5027 "side": "short",
5028 "avg_entry_price": "50.25",
5029 "market_value": "-351.75",
5030 "unrealized_pl": "0",
5031 },
5032 {
5033 "symbol": "XYZ271217C00055000",
5034 "asset_class": "us_option",
5035 "qty": "2",
5036 "qty_available": "2",
5037 "side": "long",
5038 "avg_entry_price": "1.35",
5039 "unrealized_pl": "-20",
5040 },
5041 {
5042 "symbol": "ABC",
5043 "asset_class": "us_equity",
5044 "qty": "0.125",
5045 "qty_available": "0.125",
5046 "side": "long",
5047 "avg_entry_price": "80",
5048 "unrealized_pl": null,
5049 },
5050 ]))
5051 .unwrap();
5052
5053 let converted = convert_positions(&positions, Utc::now()).unwrap();
5054 let [
5055 (short_sym, short),
5056 (option_sym, option),
5057 (fraction_sym, fraction),
5058 ] = converted.as_slice()
5059 else {
5060 panic!("expected three positions, got {converted:?}");
5061 };
5062
5063 assert_eq!(*short_sym, "XYZ");
5064 assert_eq!(short.quantity, dec!(-7));
5065 assert_eq!(short.entry_price, Some(dec!(50.25)));
5066 assert!(short.is_short());
5067
5068 assert_eq!(*option_sym, "XYZ271217C00055000");
5069 assert_eq!(option.quantity, dec!(2));
5070 assert_eq!(option.entry_price, Some(dec!(1.35)), "per-share premium");
5071 assert_eq!(option.unrealized_pnl, Some(dec!(-20)));
5072
5073 assert_eq!(*fraction_sym, "ABC");
5074 assert_eq!(fraction.quantity, dec!(0.125));
5075 assert_eq!(fraction.unrealized_pnl, None);
5076
5077 for (_, p) in &converted {
5078 assert_eq!(p.margin_used, None);
5079 assert_eq!(p.liquidation_price, None);
5080 assert_eq!(p.leverage, None);
5081 }
5082 }
5083
5084 #[test]
5087 fn test_alpaca_position_unknown_side_fails_only_where_a_direction_is_needed() {
5088 let positions: Vec<AlpacaPosition> = serde_json::from_value(serde_json::json!([
5089 {
5090 "symbol": "BTC/USD",
5091 "asset_class": "crypto",
5092 "qty": "0.5",
5093 "qty_available": "0.5",
5094 },
5095 {
5096 "symbol": "XYZ",
5097 "asset_class": "us_equity",
5098 "qty": "1",
5099 "qty_available": "1",
5100 "side": "sideways",
5101 },
5102 ]))
5103 .unwrap();
5104 assert_eq!(positions[0].side, AlpacaPositionSide::Unknown);
5105 assert_eq!(positions[1].side, AlpacaPositionSide::Unknown);
5106
5107 let balances = convert_positions_to_balances(&positions, &[]);
5108 assert_eq!(balances.len(), 1);
5109 assert_eq!(balances[0].balance.total, dec!(0.5));
5110
5111 let result = convert_positions(&positions, Utc::now());
5112 assert!(
5113 matches!(&result, Err(UnindexedClientError::Internal(msg)) if msg.contains("XYZ")),
5114 "an unknown side must not guess a direction: {result:?}"
5115 );
5116 }
5117
5118 #[test]
5120 fn order_state_time_prefers_updated_at() {
5121 let mut resp = make_order_response("ord-1", "SPY");
5122 resp.created_at = "2025-04-18T14:30:00Z".to_string();
5123 resp.updated_at = Some("2025-04-18T15:45:00Z".to_string());
5124 assert_eq!(
5125 order_state_time(&resp),
5126 parse_timestamp("2025-04-18T15:45:00Z").unwrap(),
5127 "updated_at must win over created_at"
5128 );
5129
5130 resp.updated_at = None;
5132 assert_eq!(
5133 order_state_time(&resp),
5134 parse_timestamp("2025-04-18T14:30:00Z").unwrap(),
5135 "absent updated_at falls back to created_at"
5136 );
5137
5138 resp.updated_at = Some("not-a-timestamp".to_string());
5141 assert_eq!(
5142 order_state_time(&resp),
5143 parse_timestamp("2025-04-18T14:30:00Z").unwrap(),
5144 "unparseable updated_at falls back to created_at"
5145 );
5146 }
5147
5148 fn make_order_response(id: &str, symbol: &str) -> AlpacaOrderResponse {
5149 AlpacaOrderResponse {
5150 id: id.to_string(),
5151 client_order_id: None,
5152 symbol: symbol.to_string(),
5153 qty: Some("1".to_string()),
5154 filled_qty: "0".to_string(),
5155 side: "buy".to_string(),
5156 order_type: "limit".to_string(),
5157 time_in_force: "day".to_string(),
5158 limit_price: Some("100.00".to_string()),
5159 stop_price: None,
5160 trail_percent: None,
5161 trail_price: None,
5162 created_at: "2025-04-18T14:30:00Z".to_string(),
5163 updated_at: None,
5164 }
5165 }
5166
5167 #[test]
5168 fn test_build_instrument_snapshots_empty_instruments_returns_only_with_orders() {
5169 let orders = vec![
5170 make_order_response("o1", "AAPL"),
5171 make_order_response("o2", "SPY"),
5172 ];
5173 let snapshots = build_instrument_snapshots(orders, Vec::new(), &[]);
5174 assert_eq!(snapshots.len(), 2);
5175 let symbols: Vec<&str> = snapshots
5176 .iter()
5177 .map(|s| s.instrument.name().as_str())
5178 .collect();
5179 assert!(symbols.contains(&"AAPL"));
5180 assert!(symbols.contains(&"SPY"));
5181 }
5182
5183 #[test]
5184 fn test_build_instrument_snapshots_requested_instrument_no_orders_gets_empty_snapshot() {
5185 let orders = vec![make_order_response("o1", "AAPL")];
5188 let instruments = vec![
5189 InstrumentNameExchange::new("AAPL"),
5190 InstrumentNameExchange::new("SPY"),
5191 ];
5192 let snapshots = build_instrument_snapshots(orders, Vec::new(), &instruments);
5193 assert_eq!(snapshots.len(), 2);
5194 let spy = snapshots
5195 .iter()
5196 .find(|s| s.instrument.name().as_str() == "SPY")
5197 .expect("SPY snapshot must be present even with no orders");
5198 assert!(spy.orders.is_empty());
5199 }
5200
5201 fn make_notional_order_response(id: &str, symbol: &str) -> AlpacaOrderResponse {
5203 AlpacaOrderResponse {
5204 qty: None,
5205 ..make_order_response(id, symbol)
5206 }
5207 }
5208
5209 fn snapshot_for<'a>(
5210 snapshots: &'a [InstrumentAccountSnapshot<
5211 ExchangeId,
5212 AssetNameExchange,
5213 InstrumentNameExchange,
5214 >],
5215 symbol: &str,
5216 ) -> &'a InstrumentAccountSnapshot<ExchangeId, AssetNameExchange, InstrumentNameExchange> {
5217 snapshots
5218 .iter()
5219 .find(|snapshot| snapshot.instrument.name().as_str() == symbol)
5220 .unwrap_or_else(|| panic!("no snapshot for {symbol}"))
5221 }
5222
5223 #[test]
5227 fn test_build_instrument_snapshots_declares_complete_lists_complete() {
5228 let instruments = [
5229 InstrumentNameExchange::new("AAPL"),
5230 InstrumentNameExchange::new("SPY"),
5231 ];
5232 for requested in [&instruments[..], &[]] {
5233 let orders = vec![make_order_response("o1", "AAPL")];
5234 let snapshots = build_instrument_snapshots(orders, Vec::new(), requested);
5235 assert!(!snapshots.is_empty());
5236 assert!(snapshots.iter().all(|snapshot| snapshot.orders_complete));
5237 }
5238 }
5239
5240 #[test]
5244 fn test_build_instrument_snapshots_notional_order_leaves_only_its_instrument_incomplete() {
5245 let orders = vec![
5246 make_order_response("o1", "AAPL"),
5247 make_order_response("o2", "SPY"),
5248 make_notional_order_response("o3", "SPY"),
5249 ];
5250 let instruments = [
5251 InstrumentNameExchange::new("AAPL"),
5252 InstrumentNameExchange::new("SPY"),
5253 ];
5254 let snapshots = build_instrument_snapshots(orders, Vec::new(), &instruments);
5255
5256 assert!(snapshot_for(&snapshots, "AAPL").orders_complete);
5257 let spy = snapshot_for(&snapshots, "SPY");
5258 assert!(!spy.orders_complete);
5259 assert_eq!(spy.orders.len(), 1);
5260 }
5261
5262 #[test]
5265 fn test_build_instrument_snapshots_unfiltered_lists_an_instrument_with_only_a_notional_order() {
5266 let orders = vec![make_notional_order_response("o1", "SPY")];
5267 let snapshots = build_instrument_snapshots(orders, Vec::new(), &[]);
5268
5269 let spy = snapshot_for(&snapshots, "SPY");
5270 assert!(!spy.orders_complete);
5271 assert!(spy.orders.is_empty());
5272 }
5273
5274 #[test]
5275 fn test_build_instrument_snapshots_non_requested_instrument_excluded() {
5276 let orders = vec![
5279 make_order_response("o1", "AAPL"),
5280 make_order_response("o2", "MSFT"), ];
5282 let instruments = vec![InstrumentNameExchange::new("AAPL")];
5283 let snapshots = build_instrument_snapshots(orders, Vec::new(), &instruments);
5284 assert_eq!(snapshots.len(), 1);
5285 assert_eq!(snapshots[0].instrument.name().as_str(), "AAPL");
5286 }
5287
5288 fn position(quantity: Decimal) -> Position {
5289 Position::new(
5290 quantity,
5291 Some(dec!(100)),
5292 None,
5293 None,
5294 None,
5295 None,
5296 Utc::now(),
5297 )
5298 }
5299
5300 #[test]
5301 fn test_build_instrument_snapshots_unfiltered_lists_positions_without_orders() {
5302 let orders = vec![
5303 make_order_response("o1", "AAPL"),
5304 make_order_response("o2", "MSFT"),
5305 make_order_response("o3", "BTC/USD"),
5306 ];
5307 let positions = vec![("AAPL", position(dec!(5))), ("TSLA", position(dec!(-2)))];
5308
5309 let snapshots = build_instrument_snapshots(orders, positions, &[]);
5310 let listed: Vec<_> = snapshots
5311 .iter()
5312 .map(|s| {
5313 (
5314 s.instrument.name().as_str(),
5315 s.orders.len(),
5316 s.position.quantity(),
5317 )
5318 })
5319 .collect();
5320 assert_eq!(
5322 listed,
5323 vec![
5324 ("AAPL", 1, Some(dec!(5))),
5325 ("MSFT", 1, Some(Decimal::ZERO)),
5326 ("BTC/USD", 1, None),
5327 ("TSLA", 0, Some(dec!(-2)))
5328 ]
5329 );
5330 assert!(snapshots.iter().all(|s| s.orders_complete));
5331 }
5332
5333 #[test]
5334 fn test_build_instrument_snapshots_filtered_attaches_only_requested_positions() {
5335 let positions = vec![("AAPL", position(dec!(5))), ("TSLA", position(dec!(-2)))];
5336 let instruments = vec![
5337 InstrumentNameExchange::new("tsla"),
5338 InstrumentNameExchange::new("MSFT"),
5339 InstrumentNameExchange::new("BTC/USD"),
5340 InstrumentNameExchange::new("TSLA"),
5341 ];
5342
5343 let snapshots = build_instrument_snapshots(Vec::new(), positions, &instruments);
5344 let listed: Vec<_> = snapshots
5345 .iter()
5346 .map(|s| (s.instrument.name().as_str(), s.position.quantity()))
5347 .collect();
5348 assert_eq!(
5352 listed,
5353 vec![
5354 ("tsla", Some(dec!(-2))),
5355 ("MSFT", Some(Decimal::ZERO)),
5356 ("BTC/USD", None),
5357 ("TSLA", None)
5358 ]
5359 );
5360 assert!(!snapshots[3].orders_complete);
5361 }
5362
5363 #[test]
5369 fn test_recover_fills_dedup_key_matches_ws_path() {
5370 let order_id = "ord-1";
5371
5372 let ws_keys: Vec<SmolStr> = ["1", "2"]
5375 .iter()
5376 .filter_map(|filled_qty| {
5377 let update = AlpacaTradeUpdate {
5378 event: SmolStr::new("partial_fill"),
5379 order: make_order_ws(order_id, "SPY", "buy", filled_qty),
5380 price: Some("150.00"),
5381 qty: Some("1"),
5382 timestamp: None,
5383 };
5384 let [event, _snapshot] = convert_trade_update(update);
5385 let event = event?;
5386 fill_dedup_key_from_event(&event).cloned()
5387 })
5388 .collect();
5389
5390 let mut cumulative = Decimal::ZERO;
5393 let rest_keys: Vec<SmolStr> = ["1", "1"]
5394 .iter()
5395 .map(|exec_qty| {
5396 cumulative += Decimal::from_str(exec_qty).unwrap();
5397 format_smolstr!("{}:{}", order_id, cumulative.normalize())
5398 })
5399 .collect();
5400
5401 assert_eq!(
5402 ws_keys, rest_keys,
5403 "REST recovery dedup keys must match WS path keys for cross-source dedup to work"
5404 );
5405 assert_eq!(ws_keys[0].as_str(), "ord-1:1");
5406 assert_eq!(ws_keys[1].as_str(), "ord-1:2");
5407 }
5408
5409 #[test]
5415 fn early_dedup_key_matches_full_event_path() {
5416 let update = AlpacaTradeUpdate {
5417 event: SmolStr::new("fill"),
5418 order: make_order_ws("ord-abc", "SPY", "buy", "5"),
5419 price: Some("150.00"),
5420 qty: Some("5"),
5421 timestamp: None,
5422 };
5423
5424 let early_key = early_dedup_key(&update);
5426
5427 let [event, _snapshot] = convert_trade_update(update);
5430 let event = event.expect("fill should produce an execution");
5431 let full_key =
5432 fill_dedup_key_from_event(&event).expect("fill event should have a dedup key");
5433
5434 assert_eq!(
5435 early_key.as_str(),
5436 full_key.as_str(),
5437 "early_dedup_key must produce the same key as the full event path"
5438 );
5439 assert_eq!(early_key.as_str(), "ord-abc:5");
5440 }
5441
5442 #[test]
5447 fn early_dedup_key_normalizes_decimal_strings() {
5448 let update1 = AlpacaTradeUpdate {
5450 event: SmolStr::new("fill"),
5451 order: AlpacaOrderWs {
5452 id: SmolStr::new("ord-x"),
5453 client_order_id: Some(SmolStr::new("cid")),
5454 symbol: SmolStr::new("AAPL"),
5455 qty: Some("10"),
5456 filled_qty: Some("1.00"),
5457 side: SmolStr::new("buy"),
5458 order_type: SmolStr::new("market"),
5459 time_in_force: SmolStr::new("day"),
5460 limit_price: None,
5461 stop_price: None,
5462 trail_percent: None,
5463 trail_price: None,
5464 status: SmolStr::new("filled"),
5465 },
5466 price: Some("100.00"),
5467 qty: Some("10"),
5468 timestamp: None,
5469 };
5470 assert_eq!(early_dedup_key(&update1).as_str(), "ord-x:1");
5471
5472 let update2 = AlpacaTradeUpdate {
5474 event: SmolStr::new("fill"),
5475 order: AlpacaOrderWs {
5476 id: SmolStr::new("ord-x"),
5477 client_order_id: Some(SmolStr::new("cid")),
5478 symbol: SmolStr::new("AAPL"),
5479 qty: Some("10"),
5480 filled_qty: Some("1"),
5481 side: SmolStr::new("buy"),
5482 order_type: SmolStr::new("market"),
5483 time_in_force: SmolStr::new("day"),
5484 limit_price: None,
5485 stop_price: None,
5486 trail_percent: None,
5487 trail_price: None,
5488 status: SmolStr::new("filled"),
5489 },
5490 price: Some("100.00"),
5491 qty: Some("10"),
5492 timestamp: None,
5493 };
5494 assert_eq!(early_dedup_key(&update2).as_str(), "ord-x:1");
5495
5496 let update3 = AlpacaTradeUpdate {
5498 event: SmolStr::new("fill"),
5499 order: AlpacaOrderWs {
5500 id: SmolStr::new("ord-x"),
5501 client_order_id: Some(SmolStr::new("cid")),
5502 symbol: SmolStr::new("AAPL"),
5503 qty: Some("10"),
5504 filled_qty: Some("1.0"),
5505 side: SmolStr::new("buy"),
5506 order_type: SmolStr::new("market"),
5507 time_in_force: SmolStr::new("day"),
5508 limit_price: None,
5509 stop_price: None,
5510 trail_percent: None,
5511 trail_price: None,
5512 status: SmolStr::new("filled"),
5513 },
5514 price: Some("100.00"),
5515 qty: Some("10"),
5516 timestamp: None,
5517 };
5518 assert_eq!(early_dedup_key(&update3).as_str(), "ord-x:1");
5519 }
5520
5521 #[test]
5526 fn process_ws_text_rejected_event_without_filled_qty_is_not_dropped() {
5527 let (tx, mut rx) = mpsc::unbounded_channel();
5528 let dedup = new_dedup_cache();
5529 let mut backoff = ExponentialBackoff::new();
5530
5531 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"}}}"#;
5533
5534 process_ws_text(json, &tx, &dedup, &mut backoff);
5535
5536 let event = rx.try_recv()
5538 .expect("rejected event without filled_qty must produce an AccountEvent, not be silently dropped");
5539 assert!(
5540 matches!(event.kind, AccountEventKind::OrderCancelled(_)),
5541 "rejected event must map to OrderCancelled, got: {:?}",
5542 event.kind
5543 );
5544 }
5545
5546 #[test]
5549 fn decimal_zero_normalize_is_zero_str() {
5550 assert_eq!(Decimal::ZERO.normalize().to_string(), "0");
5551 }
5552
5553 #[test]
5557 fn decimal_normalize_strips_trailing_zeros() {
5558 let from_rest = Decimal::from_str("1.00").unwrap().normalize();
5560 let from_ws = Decimal::from_str("1").unwrap().normalize();
5561 assert_eq!(from_rest.to_string(), from_ws.to_string());
5562 assert_eq!(from_rest.to_string(), "1");
5563
5564 assert_eq!(
5566 Decimal::from_str("100.000")
5567 .unwrap()
5568 .normalize()
5569 .to_string(),
5570 "100"
5571 );
5572 assert_eq!(
5573 Decimal::from_str("0.10").unwrap().normalize().to_string(),
5574 "0.1"
5575 );
5576 assert_eq!(
5577 Decimal::from_str("0.100").unwrap().normalize().to_string(),
5578 "0.1"
5579 );
5580 }
5581
5582 #[test]
5584 fn map_position_intent_open_buy_maps_to_buy_to_open() {
5585 assert_eq!(
5586 map_position_intent(Side::Buy, false),
5587 AlpacaPositionIntent::BuyToOpen
5588 );
5589 }
5590
5591 #[test]
5592 fn map_position_intent_open_sell_maps_to_sell_to_open() {
5593 assert_eq!(
5594 map_position_intent(Side::Sell, false),
5595 AlpacaPositionIntent::SellToOpen
5596 );
5597 }
5598
5599 #[test]
5600 fn map_position_intent_reduce_buy_maps_to_buy_to_close() {
5601 assert_eq!(
5602 map_position_intent(Side::Buy, true),
5603 AlpacaPositionIntent::BuyToClose
5604 );
5605 }
5606
5607 #[test]
5608 fn map_position_intent_reduce_sell_maps_to_sell_to_close() {
5609 assert_eq!(
5610 map_position_intent(Side::Sell, true),
5611 AlpacaPositionIntent::SellToClose
5612 );
5613 }
5614
5615 #[test]
5617 fn parse_order_error_401_maps_to_unauthenticated() {
5618 assert!(matches!(
5620 parse_order_error(reqwest::StatusCode::UNAUTHORIZED, "bad credentials"),
5621 UnindexedOrderError::Rejected(ApiError::Unauthenticated(_))
5622 ));
5623 }
5624
5625 #[test]
5626 fn parse_order_error_403_maps_to_unauthenticated() {
5627 assert!(matches!(
5630 parse_order_error(reqwest::StatusCode::FORBIDDEN, "account suspended"),
5631 UnindexedOrderError::Rejected(ApiError::Unauthenticated(_))
5632 ));
5633 }
5634
5635 #[test]
5636 fn parse_order_error_404_maps_to_order_rejected_with_not_found_prefix() {
5637 let err = parse_order_error(reqwest::StatusCode::NOT_FOUND, "order not found");
5638 let UnindexedOrderError::Rejected(ApiError::OrderRejected(msg)) = err else {
5639 panic!("expected OrderRejected, got {err:?}");
5640 };
5641 assert!(
5642 msg.contains("order not found"),
5643 "message should contain 'order not found': {msg}"
5644 );
5645 }
5646
5647 #[test]
5648 fn parse_order_error_422_insufficient_only_maps_to_balance_insufficient() {
5649 assert!(matches!(
5651 parse_order_error(
5652 reqwest::StatusCode::UNPROCESSABLE_ENTITY,
5653 "insufficient funds for this order"
5654 ),
5655 UnindexedOrderError::Rejected(ApiError::BalanceInsufficient(_, _))
5656 ));
5657 }
5658
5659 #[test]
5660 fn parse_order_error_429_maps_to_rate_limit() {
5661 assert!(matches!(
5662 parse_order_error(reqwest::StatusCode::TOO_MANY_REQUESTS, "rate limited"),
5663 UnindexedOrderError::Rejected(ApiError::RateLimit)
5664 ));
5665 }
5666
5667 #[test]
5671 fn parse_time_in_force_unknown_value_falls_back_to_good_until_end_of_day() {
5672 assert_eq!(
5673 parse_time_in_force("opg"),
5674 TimeInForce::GoodUntilEndOfDay,
5675 "unknown TIF must fall back to GoodUntilEndOfDay (with a warn! in production)"
5676 );
5677 }
5678
5679 #[test]
5682 fn build_instrument_snapshots_output_order_matches_instruments_slice() {
5683 let orders = vec![
5685 make_order_response("o1", "SPY"),
5686 make_order_response("o2", "AAPL"),
5687 make_order_response("o3", "MSFT"),
5688 ];
5689 let instruments = vec![
5691 InstrumentNameExchange::new("MSFT"),
5692 InstrumentNameExchange::new("AAPL"),
5693 ];
5694 let snapshots = build_instrument_snapshots(orders, Vec::new(), &instruments);
5695 assert_eq!(snapshots.len(), 2);
5696 assert_eq!(
5697 snapshots[0].instrument.name().as_str(),
5698 "MSFT",
5699 "first snapshot must be MSFT (first in instruments slice)"
5700 );
5701 assert_eq!(
5702 snapshots[1].instrument.name().as_str(),
5703 "AAPL",
5704 "second snapshot must be AAPL (second in instruments slice)"
5705 );
5706 }
5707
5708 mod http_tests {
5715 use super::super::*;
5716 use wiremock::matchers::{method, path};
5717 use wiremock::{Mock, MockServer, Request, Respond, ResponseTemplate};
5718
5719 struct Sequential {
5726 call: std::sync::atomic::AtomicU32,
5727 pages: Vec<serde_json::Value>,
5728 }
5729
5730 impl Sequential {
5731 fn new(pages: Vec<serde_json::Value>) -> Self {
5732 Self {
5733 call: std::sync::atomic::AtomicU32::new(0),
5734 pages,
5735 }
5736 }
5737 }
5738
5739 impl Respond for Sequential {
5740 fn respond(&self, _: &Request) -> ResponseTemplate {
5741 let i = self.call.fetch_add(1, std::sync::atomic::Ordering::Relaxed) as usize;
5742 let body = self.pages.get(i).unwrap_or_else(|| {
5743 panic!(
5744 "Sequential: request #{i} has no configured response \
5745 (only {} page(s) supplied)",
5746 self.pages.len()
5747 )
5748 });
5749 ResponseTemplate::new(200).set_body_json(body)
5750 }
5751 }
5752
5753 fn make_activities_json(count: usize, id_prefix: &str) -> serde_json::Value {
5755 serde_json::Value::Array(
5756 (0..count)
5757 .map(|i| {
5758 serde_json::json!({
5759 "id": format!("{id_prefix}-{i:05}"),
5760 "order_id": "ord-1",
5761 "symbol": "SPY",
5762 "side": "buy",
5763 "price": "100.00",
5764 "qty": "1",
5765 "transaction_time": "2025-04-18T14:30:00Z"
5766 })
5767 })
5768 .collect(),
5769 )
5770 }
5771
5772 fn make_orders_json(count: usize) -> serde_json::Value {
5774 serde_json::Value::Array(
5775 (0..count)
5776 .map(|i| {
5777 serde_json::json!({
5778 "id": format!("order-{i:05}"),
5779 "client_order_id": null,
5780 "symbol": "SPY",
5781 "qty": "1",
5782 "filled_qty": "0",
5783 "side": "buy",
5784 "type": "limit",
5785 "time_in_force": "day",
5786 "limit_price": "100.00",
5787 "created_at": "2025-04-18T14:30:00Z"
5788 })
5789 })
5790 .collect(),
5791 )
5792 }
5793
5794 #[tokio::test]
5799 async fn paginate_activities_single_page_below_max_returns_all_not_truncated() {
5800 let server = MockServer::start().await;
5801 Mock::given(method("GET"))
5802 .and(path("/v2/account/activities"))
5803 .respond_with(
5804 ResponseTemplate::new(200).set_body_json(make_activities_json(5, "act")),
5805 )
5806 .mount(&server)
5807 .await;
5808
5809 let http = reqwest::Client::new();
5810 let rl = RateLimitTracker::new();
5811 let result = paginate_activities(&http, &rl, &server.uri(), "2025-01-01T00:00:00Z")
5812 .await
5813 .unwrap();
5814
5815 assert_eq!(result.activities.len(), 5);
5816 assert!(!result.truncated);
5817 assert_eq!(server.received_requests().await.unwrap().len(), 1);
5818 }
5819
5820 #[tokio::test]
5823 async fn paginate_activities_exactly_page_size_items_fetches_second_page() {
5824 let server = MockServer::start().await;
5825 Mock::given(method("GET"))
5826 .and(path("/v2/account/activities"))
5827 .respond_with(Sequential::new(vec![
5828 make_activities_json(ALPACA_MAX_ACTIVITIES, "act"),
5829 serde_json::json!([]), ]))
5831 .mount(&server)
5832 .await;
5833
5834 let http = reqwest::Client::new();
5835 let rl = RateLimitTracker::new();
5836 let result = paginate_activities(&http, &rl, &server.uri(), "2025-01-01T00:00:00Z")
5837 .await
5838 .unwrap();
5839
5840 assert_eq!(result.activities.len(), ALPACA_MAX_ACTIVITIES);
5841 assert!(!result.truncated);
5842 assert_eq!(
5843 server.received_requests().await.unwrap().len(),
5844 2,
5845 "exactly 2 requests: first full page + second empty page"
5846 );
5847 }
5848
5849 #[tokio::test]
5852 async fn paginate_activities_two_pages_returns_combined_activities_not_truncated() {
5853 let server = MockServer::start().await;
5854 Mock::given(method("GET"))
5855 .and(path("/v2/account/activities"))
5856 .respond_with(Sequential::new(vec![
5857 make_activities_json(ALPACA_MAX_ACTIVITIES, "p1"),
5858 make_activities_json(37, "p2"),
5859 ]))
5860 .mount(&server)
5861 .await;
5862
5863 let http = reqwest::Client::new();
5864 let rl = RateLimitTracker::new();
5865 let result = paginate_activities(&http, &rl, &server.uri(), "2025-01-01T00:00:00Z")
5866 .await
5867 .unwrap();
5868
5869 assert_eq!(result.activities.len(), ALPACA_MAX_ACTIVITIES + 37);
5870 assert!(!result.truncated);
5871 assert_eq!(server.received_requests().await.unwrap().len(), 2);
5872 }
5873
5874 #[tokio::test]
5878 async fn paginate_activities_at_max_pages_sets_truncated_true() {
5879 let server = MockServer::start().await;
5880
5881 Mock::given(method("GET"))
5883 .and(path("/v2/account/activities"))
5884 .respond_with(
5885 ResponseTemplate::new(200)
5886 .set_body_json(make_activities_json(ALPACA_MAX_ACTIVITIES, "act")),
5887 )
5888 .mount(&server)
5889 .await;
5890
5891 let http = reqwest::Client::new();
5892 let rl = RateLimitTracker::new();
5893 let result = paginate_activities(&http, &rl, &server.uri(), "2025-01-01T00:00:00Z")
5894 .await
5895 .unwrap();
5896
5897 assert!(
5898 result.truncated,
5899 "must be truncated after MAX_ACTIVITY_PAGES pages"
5900 );
5901 assert_eq!(
5902 result.activities.len(),
5903 MAX_ACTIVITY_PAGES * ALPACA_MAX_ACTIVITIES,
5904 "must accumulate exactly MAX_ACTIVITY_PAGES * page_size activities"
5905 );
5906 assert_eq!(
5909 server.received_requests().await.unwrap().len(),
5910 MAX_ACTIVITY_PAGES,
5911 "loop must issue exactly MAX_ACTIVITY_PAGES requests then stop"
5912 );
5913 }
5914
5915 #[tokio::test]
5919 async fn fetch_raw_open_orders_499_results_returns_ok() {
5920 let server = MockServer::start().await;
5921 Mock::given(method("GET"))
5922 .and(path("/v2/orders"))
5923 .respond_with(
5924 ResponseTemplate::new(200).set_body_json(make_orders_json(MAX_OPEN_ORDERS - 1)),
5925 )
5926 .mount(&server)
5927 .await;
5928
5929 let http = reqwest::Client::new();
5930 let rl = RateLimitTracker::new();
5931 let result = fetch_raw_open_orders(&http, &rl, &server.uri(), &[]).await;
5932
5933 assert!(
5934 result.is_ok(),
5935 "499 orders must not trigger truncation: {result:?}"
5936 );
5937 assert_eq!(result.unwrap().len(), MAX_OPEN_ORDERS - 1);
5938 }
5939
5940 #[tokio::test]
5944 async fn fetch_raw_open_orders_500_results_returns_truncated_snapshot_error() {
5945 let server = MockServer::start().await;
5946 Mock::given(method("GET"))
5947 .and(path("/v2/orders"))
5948 .respond_with(
5949 ResponseTemplate::new(200).set_body_json(make_orders_json(MAX_OPEN_ORDERS)),
5950 )
5951 .mount(&server)
5952 .await;
5953
5954 let http = reqwest::Client::new();
5955 let rl = RateLimitTracker::new();
5956 let result = fetch_raw_open_orders(&http, &rl, &server.uri(), &[]).await;
5957
5958 assert!(
5959 matches!(
5960 result,
5961 Err(UnindexedClientError::TruncatedSnapshot { limit }) if limit == MAX_OPEN_ORDERS
5962 ),
5963 "500 orders must return TruncatedSnapshot, got: {result:?}"
5964 );
5965 }
5966
5967 async fn mount_account_and_positions(server: &MockServer) {
5972 Mock::given(method("GET"))
5973 .and(path("/v2/account"))
5974 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
5975 "status": "ACTIVE",
5976 "currency": "USD",
5977 "cash": "-1500.50",
5978 "equity": "4000.00",
5979 "buying_power": "5000.00",
5980 "options_buying_power": "2000.00",
5981 "non_marginable_buying_power": "1000.00",
5982 })))
5983 .mount(server)
5984 .await;
5985 Mock::given(method("GET"))
5986 .and(path("/v2/positions"))
5987 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([
5988 {
5989 "symbol": "XYZ",
5990 "asset_class": "us_equity",
5991 "side": "short",
5992 "qty": "-10",
5993 "qty_available": "-10",
5994 "avg_entry_price": "20.00",
5995 "unrealized_pl": "15.50",
5996 },
5997 {
5998 "symbol": "XYZ271217C00025000",
5999 "asset_class": "us_option",
6000 "side": "long",
6001 "qty": "3",
6002 "qty_available": "3",
6003 "avg_entry_price": "0.85",
6004 "unrealized_pl": "-45",
6005 },
6006 {
6007 "symbol": "ABC",
6008 "asset_class": "us_equity",
6009 "side": "long",
6010 "qty": "2.5",
6011 "qty_available": "2.5",
6012 "avg_entry_price": "40.00",
6013 "unrealized_pl": "1.25",
6014 },
6015 {
6016 "symbol": "BTC/USD",
6017 "asset_class": "crypto",
6018 "side": "long",
6019 "qty": "0.75",
6020 "qty_available": "0.5",
6021 "avg_entry_price": "50000",
6022 "unrealized_pl": "0",
6023 },
6024 ])))
6025 .mount(server)
6026 .await;
6027 Mock::given(method("GET"))
6028 .and(path("/v2/orders"))
6029 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([])))
6030 .mount(server)
6031 .await;
6032 }
6033
6034 fn client_for(server: &MockServer) -> AlpacaClient {
6035 AlpacaClient::new(AlpacaConfig::with_base_url(
6036 "test-key".into(),
6037 "test-secret".into(),
6038 server.uri(),
6039 ))
6040 }
6041
6042 #[tokio::test]
6043 async fn account_snapshot_reports_cash_positions_and_crypto_balances() {
6044 use crate::client::ExecutionClient;
6045 use rust_decimal_macros::dec;
6046
6047 let server = MockServer::start().await;
6048 mount_account_and_positions(&server).await;
6049
6050 let snapshot = client_for(&server)
6051 .account_snapshot(&[], &[])
6052 .await
6053 .unwrap();
6054
6055 let balances: Vec<_> = snapshot
6057 .balances
6058 .iter()
6059 .map(|b| (b.asset.name().as_str(), b.balance.total, b.balance.free))
6060 .collect();
6061 assert_eq!(
6062 balances,
6063 vec![
6064 ("usd", dec!(-1500.50), dec!(-1500.50)),
6065 ("btc", dec!(0.75), dec!(0.5)),
6066 ],
6067 "crypto stays a balance; equities and options do not appear as balances"
6068 );
6069
6070 let positions: Vec<_> = snapshot
6071 .instruments
6072 .iter()
6073 .map(|i| {
6074 let p = i.position.open().unwrap();
6075 (
6076 i.instrument.name().as_str(),
6077 p.quantity,
6078 p.entry_price,
6079 p.unrealized_pnl,
6080 )
6081 })
6082 .collect();
6083 assert_eq!(
6084 positions,
6085 vec![
6086 ("XYZ", dec!(-10), Some(dec!(20.00)), Some(dec!(15.50))),
6087 (
6088 "XYZ271217C00025000",
6089 dec!(3),
6090 Some(dec!(0.85)),
6091 Some(dec!(-45))
6092 ),
6093 ("ABC", dec!(2.5), Some(dec!(40.00)), Some(dec!(1.25))),
6094 ],
6095 "short equity, option and fractional positions; no crypto position"
6096 );
6097 }
6098
6099 #[tokio::test]
6102 async fn account_snapshot_usd_only_still_reports_positions() {
6103 use crate::client::ExecutionClient;
6104 use rust_decimal_macros::dec;
6105
6106 let server = MockServer::start().await;
6107 mount_account_and_positions(&server).await;
6108
6109 let usd = [AssetNameExchange::new("USD")];
6110 let instruments = [InstrumentNameExchange::new("XYZ")];
6111 let snapshot = client_for(&server)
6112 .account_snapshot(&usd, &instruments)
6113 .await
6114 .unwrap();
6115
6116 assert_eq!(snapshot.balances.len(), 1);
6117 assert_eq!(snapshot.balances[0].asset, AssetNameExchange::new("USD"));
6118 assert_eq!(snapshot.balances[0].balance.total, dec!(-1500.50));
6119
6120 assert_eq!(snapshot.instruments.len(), 1);
6121 assert_eq!(snapshot.instruments[0].position.quantity(), Some(dec!(-10)));
6122 }
6123
6124 #[tokio::test]
6126 async fn account_snapshot_non_usd_only_skips_account_request() {
6127 use crate::client::ExecutionClient;
6128
6129 let server = MockServer::start().await;
6130 mount_account_and_positions(&server).await;
6131
6132 let btc = [AssetNameExchange::new("btc")];
6133 let snapshot = client_for(&server)
6134 .account_snapshot(&btc, &[])
6135 .await
6136 .unwrap();
6137
6138 assert_eq!(snapshot.balances.len(), 1);
6139 assert_eq!(snapshot.balances[0].asset, AssetNameExchange::new("btc"));
6140 let requests = server.received_requests().await.unwrap();
6141 assert!(
6142 requests.iter().all(|r| r.url.path() != "/v2/account"),
6143 "no USD requested, so /v2/account must not be fetched"
6144 );
6145 }
6146
6147 #[tokio::test]
6155 async fn open_order_reduce_only_sell_sends_sell_to_close_intent() {
6156 use crate::client::ExecutionClient;
6157 use crate::order::request::{OrderRequestOpen, RequestOpen};
6158 use crate::order::{
6159 OrderKey, OrderKind, TimeInForce,
6160 id::{ClientOrderId, StrategyId},
6161 };
6162 use rust_decimal::Decimal;
6163 use rustrade_instrument::Side;
6164 use rustrade_instrument::exchange::ExchangeId;
6165 use rustrade_instrument::instrument::name::InstrumentNameExchange;
6166 use wiremock::matchers::{method, path};
6167
6168 let server = MockServer::start().await;
6169
6170 let captured_body = std::sync::Arc::new(parking_lot::Mutex::new(None));
6173 let captured_clone = captured_body.clone();
6174
6175 Mock::given(method("POST"))
6176 .and(path("/v2/orders"))
6177 .respond_with(move |req: &Request| {
6178 let body: serde_json::Value = serde_json::from_slice(&req.body).unwrap();
6180 *captured_clone.lock() = Some(body);
6181
6182 ResponseTemplate::new(200).set_body_json(serde_json::json!({
6183 "id": "test-order-id",
6184 "client_order_id": "test-cid",
6185 "symbol": "AAPL",
6186 "qty": "10",
6187 "filled_qty": "0",
6188 "side": "sell",
6189 "type": "market",
6190 "time_in_force": "ioc",
6191 "limit_price": null,
6192 "created_at": "2025-04-18T14:30:00Z"
6193 }))
6194 })
6195 .mount(&server)
6196 .await;
6197
6198 let config =
6200 AlpacaConfig::with_base_url("test-key".into(), "test-secret".into(), server.uri());
6201 let client = AlpacaClient::new(config);
6202
6203 let request = OrderRequestOpen {
6205 key: OrderKey {
6206 exchange: ExchangeId::AlpacaBroker,
6207 instrument: InstrumentNameExchange::new("AAPL"),
6208 strategy: StrategyId::new("test-strategy"),
6209 cid: ClientOrderId::new("test-cid"),
6210 },
6211 state: RequestOpen {
6212 side: Side::Sell,
6213 price: None,
6214 quantity: Decimal::new(10, 0),
6215 kind: OrderKind::Market,
6216 time_in_force: TimeInForce::ImmediateOrCancel,
6217 position_id: None,
6218 reduce_only: true, market: None,
6220 },
6221 };
6222
6223 let result = client
6225 .open_order(OrderRequestOpen {
6226 key: OrderKey {
6227 exchange: request.key.exchange,
6228 instrument: &request.key.instrument,
6229 strategy: request.key.strategy.clone(),
6230 cid: request.key.cid.clone(),
6231 },
6232 state: request.state.clone(),
6233 })
6234 .await;
6235
6236 assert!(result.is_some(), "open_order should return a result");
6238 let order = result.unwrap();
6239 assert!(
6240 order.state.is_accepted(),
6241 "order should be accepted: {:?}",
6242 order.state
6243 );
6244
6245 let body = captured_body
6247 .lock()
6248 .take()
6249 .expect("request body should be captured");
6250 assert_eq!(
6251 body.get("position_intent").and_then(|v| v.as_str()),
6252 Some("sell_to_close"),
6253 "reduce_only=true + Side::Sell should produce position_intent=sell_to_close, got: {body}"
6254 );
6255 }
6256
6257 #[tokio::test]
6259 async fn open_order_not_reduce_only_buy_sends_buy_to_open_intent() {
6260 use crate::client::ExecutionClient;
6261 use crate::order::request::{OrderRequestOpen, RequestOpen};
6262 use crate::order::{
6263 OrderKey, OrderKind, TimeInForce,
6264 id::{ClientOrderId, StrategyId},
6265 };
6266 use rust_decimal::Decimal;
6267 use rustrade_instrument::Side;
6268 use rustrade_instrument::exchange::ExchangeId;
6269 use rustrade_instrument::instrument::name::InstrumentNameExchange;
6270
6271 let server = MockServer::start().await;
6272
6273 let captured_body = std::sync::Arc::new(parking_lot::Mutex::new(None));
6274 let captured_clone = captured_body.clone();
6275
6276 Mock::given(method("POST"))
6277 .and(path("/v2/orders"))
6278 .respond_with(move |req: &Request| {
6279 let body: serde_json::Value = serde_json::from_slice(&req.body).unwrap();
6280 *captured_clone.lock() = Some(body);
6281
6282 ResponseTemplate::new(200).set_body_json(serde_json::json!({
6283 "id": "test-order-id",
6284 "client_order_id": "test-cid",
6285 "symbol": "AAPL",
6286 "qty": "10",
6287 "filled_qty": "0",
6288 "side": "buy",
6289 "type": "market",
6290 "time_in_force": "ioc",
6291 "limit_price": null,
6292 "created_at": "2025-04-18T14:30:00Z"
6293 }))
6294 })
6295 .mount(&server)
6296 .await;
6297
6298 let config =
6299 AlpacaConfig::with_base_url("test-key".into(), "test-secret".into(), server.uri());
6300 let client = AlpacaClient::new(config);
6301
6302 let instrument = InstrumentNameExchange::new("AAPL");
6303 let request = OrderRequestOpen {
6304 key: OrderKey {
6305 exchange: ExchangeId::AlpacaBroker,
6306 instrument: &instrument,
6307 strategy: StrategyId::new("test-strategy"),
6308 cid: ClientOrderId::new("test-cid"),
6309 },
6310 state: RequestOpen {
6311 side: Side::Buy,
6312 price: None,
6313 quantity: Decimal::new(10, 0),
6314 kind: OrderKind::Market,
6315 time_in_force: TimeInForce::ImmediateOrCancel,
6316 position_id: None,
6317 reduce_only: false, market: None,
6319 },
6320 };
6321
6322 let result = client.open_order(request).await;
6323
6324 assert!(result.is_some(), "open_order should return a result");
6325 let order = result.unwrap();
6326 assert!(
6327 order.state.is_accepted(),
6328 "order should be accepted: {:?}",
6329 order.state
6330 );
6331
6332 let body = captured_body
6333 .lock()
6334 .take()
6335 .expect("request body should be captured");
6336 assert_eq!(
6337 body.get("position_intent").and_then(|v| v.as_str()),
6338 Some("buy_to_open"),
6339 "reduce_only=false + Side::Buy should produce position_intent=buy_to_open, got: {body}"
6340 );
6341 }
6342
6343 #[tokio::test]
6346 async fn rest_with_retry_reports_an_undecodable_2xx_body_as_internal() {
6347 let server = MockServer::start().await;
6348 Mock::given(method("GET"))
6349 .and(path("/v2/account"))
6350 .respond_with(ResponseTemplate::new(200).set_body_string("not json"))
6351 .mount(&server)
6352 .await;
6353
6354 let http = reqwest::Client::new();
6355 let rl = RateLimitTracker::new();
6356 let url = format!("{}/v2/account", server.uri());
6357 let result: Result<serde_json::Value, _> =
6358 rest_with_retry(&rl, || http.get(&url)).await;
6359 assert!(
6360 matches!(result, Err(UnindexedClientError::Internal(_))),
6361 "{result:?}"
6362 );
6363 }
6364
6365 async fn holds_back(rl: &RateLimitTracker) -> bool {
6367 tokio::time::timeout(Duration::from_millis(50), rl.wait_if_blocked())
6368 .await
6369 .is_err()
6370 }
6371
6372 fn with_rate_limit(
6374 template: ResponseTemplate,
6375 remaining: u32,
6376 reset: bool,
6377 ) -> ResponseTemplate {
6378 let template = template.insert_header("x-ratelimit-remaining", remaining.to_string());
6379 if !reset {
6380 return template;
6381 }
6382 let now_secs = std::time::SystemTime::now()
6383 .duration_since(std::time::UNIX_EPOCH)
6384 .unwrap()
6385 .as_secs();
6386 template.insert_header("x-ratelimit-reset", (now_secs + 30).to_string())
6387 }
6388
6389 #[tokio::test]
6392 async fn rest_with_retry_pauses_once_no_requests_remain() {
6393 for (remaining, reset, pauses) in [(0, true, true), (1, true, false), (0, false, false)]
6394 {
6395 let server = MockServer::start().await;
6396 Mock::given(method("GET"))
6397 .and(path("/v2/account"))
6398 .respond_with(with_rate_limit(
6399 ResponseTemplate::new(200).set_body_json(serde_json::json!({})),
6400 remaining,
6401 reset,
6402 ))
6403 .mount(&server)
6404 .await;
6405
6406 let http = reqwest::Client::new();
6407 let rl = RateLimitTracker::new();
6408 let url = format!("{}/v2/account", server.uri());
6409 let _: serde_json::Value = rest_with_retry(&rl, || http.get(&url)).await.unwrap();
6410 assert_eq!(
6411 holds_back(&rl).await,
6412 pauses,
6413 "remaining {remaining}, reset {reset}"
6414 );
6415 }
6416 }
6417
6418 #[tokio::test]
6421 async fn a_pause_lasts_a_minute_at_most() {
6422 tokio::time::pause();
6423 let now_secs = std::time::SystemTime::now()
6424 .duration_since(std::time::UNIX_EPOCH)
6425 .unwrap()
6426 .as_secs();
6427 let mut headers = reqwest::header::HeaderMap::new();
6428 headers.insert("x-ratelimit-remaining", "0".parse().unwrap());
6429 headers.insert(
6430 "x-ratelimit-reset",
6431 (now_secs + 3_600).to_string().parse().unwrap(),
6432 );
6433 let rl = RateLimitTracker::new();
6434
6435 observe_rate_limit_remaining(&rl, &headers);
6436 assert!(holds_back(&rl).await);
6437 tokio::time::advance(Duration::from_secs(DEFAULT_RATE_LIMIT_DELAY_SECS)).await;
6438 assert!(!holds_back(&rl).await);
6439 }
6440
6441 #[tokio::test]
6443 async fn rest_delete_with_retry_pauses_once_no_requests_remain() {
6444 let server = MockServer::start().await;
6445 Mock::given(method("DELETE"))
6446 .and(path("/v2/orders/abc"))
6447 .respond_with(with_rate_limit(ResponseTemplate::new(204), 0, true))
6448 .mount(&server)
6449 .await;
6450
6451 let http = reqwest::Client::new();
6452 let rl = RateLimitTracker::new();
6453 let url = format!("{}/v2/orders/abc", server.uri());
6454 rest_delete_with_retry(&rl, || http.delete(&url))
6455 .await
6456 .unwrap();
6457 assert!(holds_back(&rl).await);
6458 }
6459
6460 #[tokio::test]
6463 async fn open_order_with_an_undecodable_2xx_response_is_status_unknown() {
6464 use crate::client::ExecutionClient;
6465 use crate::error::OrderError;
6466 use crate::order::request::{OrderRequestOpen, RequestOpen};
6467 use crate::order::state::{InactiveOrderState, OrderState};
6468 use crate::order::{
6469 OrderKey, OrderKind, TimeInForce,
6470 id::{ClientOrderId, StrategyId},
6471 };
6472 use rustrade_instrument::Side;
6473 use rustrade_instrument::exchange::ExchangeId;
6474 use rustrade_instrument::instrument::name::InstrumentNameExchange;
6475
6476 let server = MockServer::start().await;
6477 Mock::given(method("POST"))
6478 .and(path("/v2/orders"))
6479 .respond_with(
6480 ResponseTemplate::new(200).set_body_json(serde_json::json!({ "id": 1 })),
6481 )
6482 .mount(&server)
6483 .await;
6484
6485 let instrument = InstrumentNameExchange::new("AAPL");
6486 let order = client_for(&server)
6487 .open_order(OrderRequestOpen {
6488 key: OrderKey {
6489 exchange: ExchangeId::AlpacaBroker,
6490 instrument: &instrument,
6491 strategy: StrategyId::new("test-strategy"),
6492 cid: ClientOrderId::new("test-cid"),
6493 },
6494 state: RequestOpen {
6495 side: Side::Buy,
6496 price: None,
6497 quantity: Decimal::new(10, 0),
6498 kind: OrderKind::Market,
6499 time_in_force: TimeInForce::ImmediateOrCancel,
6500 position_id: None,
6501 reduce_only: false,
6502 market: None,
6503 },
6504 })
6505 .await
6506 .expect("open_order should return a result");
6507
6508 assert!(
6509 matches!(
6510 order.state,
6511 OrderState::Inactive(InactiveOrderState::OpenFailed(OrderError::Connectivity(
6512 _
6513 )))
6514 ),
6515 "{:?}",
6516 order.state
6517 );
6518 }
6519
6520 #[tokio::test]
6523 async fn open_bracket_order_with_an_undecodable_2xx_response_is_status_unknown() {
6524 use crate::error::OrderError;
6525 use crate::order::state::{InactiveOrderState, OrderState};
6526 use rustrade_instrument::instrument::name::InstrumentNameExchange;
6527
6528 let server = MockServer::start().await;
6529 Mock::given(method("POST"))
6530 .and(path("/v2/orders"))
6531 .respond_with(
6532 ResponseTemplate::new(200).set_body_json(serde_json::json!({ "id": 1 })),
6533 )
6534 .mount(&server)
6535 .await;
6536
6537 let result = client_for(&server)
6538 .open_bracket_order(AlpacaBracketOrderRequest::new(
6539 InstrumentNameExchange::new("SPY"),
6540 crate::order::id::StrategyId::new("test"),
6541 crate::order::id::ClientOrderId::new("test-bracket"),
6542 Side::Buy,
6543 Decimal::ONE,
6544 Decimal::new(100, 0),
6545 Decimal::new(120, 0),
6546 Decimal::new(90, 0),
6547 TimeInForce::GoodUntilCancelled { post_only: false },
6548 ))
6549 .await;
6550
6551 assert!(
6552 matches!(
6553 result.parent.state,
6554 OrderState::Inactive(InactiveOrderState::OpenFailed(OrderError::Connectivity(
6555 _
6556 )))
6557 ),
6558 "{:?}",
6559 result.parent.state
6560 );
6561 }
6562
6563 fn activity_json(
6573 id: &str,
6574 order_id: &str,
6575 qty: &str,
6576 cum_qty: Option<&str>,
6577 ) -> serde_json::Value {
6578 let mut v = serde_json::json!({
6579 "id": id,
6580 "order_id": order_id,
6581 "symbol": "SPY",
6582 "side": "buy",
6583 "price": "100.00",
6584 "qty": qty,
6585 "transaction_time": "2025-04-18T14:30:00Z"
6586 });
6587 if let Some(c) = cum_qty {
6588 v["cum_qty"] = serde_json::Value::String(c.to_string());
6589 }
6590 v
6591 }
6592
6593 async fn drive_recover_fills_with(
6596 activities: Vec<serde_json::Value>,
6597 dedup: SharedDedupCache,
6598 ) -> Vec<UnindexedAccountEvent> {
6599 let server = MockServer::start().await;
6600 Mock::given(method("GET"))
6601 .and(path("/v2/account/activities"))
6602 .respond_with(
6603 ResponseTemplate::new(200).set_body_json(serde_json::Value::Array(activities)),
6604 )
6605 .mount(&server)
6606 .await;
6607
6608 let http = reqwest::Client::new();
6609 let rl = RateLimitTracker::new();
6610 let (tx, mut rx) = mpsc::unbounded_channel();
6611 recover_fills(
6612 &http,
6613 &rl,
6614 &[],
6615 &server.uri(),
6616 "2025-01-01T00:00:00Z",
6617 &tx,
6618 &dedup,
6619 )
6620 .await;
6621 drop(tx);
6622
6623 let mut out = Vec::new();
6624 while let Ok(event) = rx.try_recv() {
6625 out.push(event);
6626 }
6627 out
6628 }
6629
6630 async fn drive_recover_fills(
6631 activities: Vec<serde_json::Value>,
6632 ) -> Vec<UnindexedAccountEvent> {
6633 drive_recover_fills_with(activities, new_dedup_cache()).await
6634 }
6635
6636 fn trade_of(
6637 event: &UnindexedAccountEvent,
6638 ) -> &Trade<AssetNameExchange, InstrumentNameExchange> {
6639 match &event.kind {
6640 AccountEventKind::Trade(t) => t,
6641 other => panic!("expected a Trade event, got {other:?}"),
6642 }
6643 }
6644
6645 #[tokio::test]
6648 async fn a_recovered_fill_reports_the_cumulative_alpaca_sent() {
6649 let events = drive_recover_fills(vec![
6650 activity_json("act-1", "ord-1", "2", Some("2")),
6651 activity_json("act-2", "ord-1", "3", Some("5")),
6652 ])
6653 .await;
6654
6655 let reported: Vec<_> = events
6656 .iter()
6657 .map(|e| trade_of(e).order_filled_quantity)
6658 .collect();
6659 assert_eq!(
6660 reported,
6661 vec![
6662 Some(Decimal::from_str("2").unwrap()),
6663 Some(Decimal::from_str("5").unwrap())
6664 ]
6665 );
6666 }
6667
6668 #[tokio::test]
6675 async fn the_dedup_key_uses_the_venue_cumulative_not_an_intra_batch_count() {
6676 let events =
6678 drive_recover_fills(vec![activity_json("act-1", "ord-1", "2", Some("5"))]).await;
6679
6680 assert_eq!(
6681 trade_of(&events[0]).id.0.as_str(),
6682 "ord-1:5",
6683 "counting executions within the batch would key this as ord-1:2"
6684 );
6685 }
6686
6687 #[tokio::test]
6691 async fn a_fill_already_delivered_over_websocket_is_not_recovered_twice() {
6692 let dedup = new_dedup_cache();
6693
6694 let update = AlpacaTradeUpdate {
6696 event: SmolStr::new("partial_fill"),
6697 order: super::make_order_ws("ord-1", "SPY", "buy", "5"),
6698 price: Some("100.00"),
6699 qty: Some("2"),
6700 timestamp: None,
6701 };
6702 let [ws_event, _snapshot] = convert_trade_update(update);
6703 let ws_event = ws_event.expect("a partial_fill converts to a Trade");
6704 let ws_key = fill_dedup_key_from_event(&ws_event)
6705 .expect("a Trade carries a dedup key")
6706 .clone();
6707 assert!(
6708 !is_duplicate(&dedup, &ws_key),
6709 "precondition: first sighting"
6710 );
6711
6712 let events = drive_recover_fills_with(
6713 vec![activity_json("act-1", "ord-1", "2", Some("5"))],
6714 dedup,
6715 )
6716 .await;
6717
6718 assert!(
6719 events.is_empty(),
6720 "a fill already delivered over WebSocket must not be re-delivered by recovery, \
6721 got {events:?}"
6722 );
6723 }
6724
6725 #[tokio::test]
6728 async fn without_a_reported_cumulative_the_previous_behaviour_is_preserved() {
6729 let events = drive_recover_fills(vec![
6730 activity_json("act-1", "ord-1", "2", None),
6731 activity_json("act-2", "ord-1", "3", None),
6732 ])
6733 .await;
6734
6735 let keys: Vec<_> = events
6736 .iter()
6737 .map(|e| trade_of(e).id.0.as_str().to_owned())
6738 .collect();
6739 assert_eq!(keys, vec!["ord-1:2", "ord-1:5"], "intra-batch accumulation");
6740
6741 assert!(
6742 events
6743 .iter()
6744 .all(|e| trade_of(e).order_filled_quantity.is_none()),
6745 "a venue that reported no cumulative must not have one invented for it"
6746 );
6747 }
6748 }
6749}