1use crate::{
39 AccountEventKind, AccountSnapshot, InstrumentAccountSnapshot, UnindexedAccountEvent,
40 UnindexedAccountSnapshot,
41 balance::{AssetBalance, Balance},
42 client::{BracketOrderClient, ExecutionClient},
43 emit_stream_terminated,
44 error::{
45 ApiError, ConnectivityError, OrderError, StreamTerminationReason, UnindexedClientError,
46 UnindexedOrderError,
47 },
48 order::{
49 Order, OrderKey, OrderKind, TimeInForce, TrailingOffsetType, UnindexedOrderSnapshot,
50 bracket::{
51 BracketOrderRequest as UnifiedBracketOrderRequest,
52 BracketOrderResult as UnifiedBracketOrderResult,
53 },
54 id::{ClientOrderId, OrderId, StrategyId, VenueOrderId},
55 request::{OrderRequestCancel, OrderRequestOpen, UnindexedOrderResponseCancel},
56 state::{Cancelled, Filled, Open, OrderState, UnindexedOrderState},
57 },
58 parse_env_bool,
59 trade::{AssetFees, Trade, TradeId},
60};
61use chrono::{DateTime, Utc};
62use fnv::FnvHashMap;
63use futures::{SinkExt as _, StreamExt as _, stream::BoxStream};
64use indexmap::IndexMap;
65use itertools::Itertools as _;
66use lru::LruCache;
67use rust_decimal::Decimal;
68use rustrade_instrument::{
69 Side,
70 asset::name::AssetNameExchange,
71 exchange::ExchangeId,
72 instrument::{kind::InstrumentKindDiscriminant, name::InstrumentNameExchange},
73};
74use rustrade_integration::protocol::websocket::{WebSocket, WsMessage};
75use serde::{Deserialize, Serialize};
76use smol_str::{SmolStr, format_smolstr};
77use std::{num::NonZeroUsize, pin::Pin, str::FromStr, sync::Arc, time::Duration};
78use tokio::sync::mpsc;
79use tracing::{debug, error, info, trace, warn};
80
81const INITIAL_BACKOFF_MS: u64 = 1_000;
86const MAX_BACKOFF_MS: u64 = 30_000;
87const MAX_RECONNECT_ATTEMPTS: u32 = 10;
88const HEARTBEAT_TIMEOUT_SECS: u64 = 35;
90const FILL_RECOVERY_TIMEOUT_SECS: u64 = 30;
92const SIGNAL_RECOVERY_LOOKBACK_MS: i64 = 1_500;
95const ALPACA_MAX_ACTIVITIES: usize = 100;
97const DEFAULT_RATE_LIMIT_DELAY_SECS: u64 = 60;
99const MAX_RATE_LIMIT_ATTEMPTS: u32 = 4;
102const DEDUP_CACHE_SIZE: usize = 2_000;
105const WS_HANDSHAKE_TIMEOUT_SECS: u64 = 15;
107const WS_CLOSE_TIMEOUT_SECS: u64 = 5;
110
111struct GracefulShutdownStream<S> {
128 inner: S,
129 _handle: tokio::task::JoinHandle<()>,
135}
136
137impl<S> GracefulShutdownStream<S> {
138 fn new(inner: S, handle: tokio::task::JoinHandle<()>) -> Self {
139 Self {
140 inner,
141 _handle: handle,
142 }
143 }
144}
145
146impl<S: futures::Stream + Unpin> futures::Stream for GracefulShutdownStream<S> {
147 type Item = S::Item;
148 fn poll_next(
149 mut self: Pin<&mut Self>,
150 cx: &mut std::task::Context<'_>,
151 ) -> std::task::Poll<Option<Self::Item>> {
152 Pin::new(&mut self.inner).poll_next(cx)
153 }
154 fn size_hint(&self) -> (usize, Option<usize>) {
155 self.inner.size_hint()
156 }
157}
158
159impl<S> Drop for GracefulShutdownStream<S> {
160 fn drop(&mut self) {
161 }
165}
166
167struct RateLimitTracker {
173 blocked_until: parking_lot::Mutex<Option<tokio::time::Instant>>,
174}
175
176impl RateLimitTracker {
177 fn new() -> Self {
178 Self {
179 blocked_until: parking_lot::Mutex::new(None),
180 }
181 }
182
183 async fn wait_if_blocked(&self) {
185 loop {
186 let now = tokio::time::Instant::now();
190 let deadline = {
198 let mut guard = self.blocked_until.lock();
199 let d = *guard;
200 if matches!(d, Some(t) if t <= now) {
201 *guard = None;
204 }
205 d
206 };
207 match deadline {
208 None => return,
209 Some(until) => {
210 if until <= now {
211 return;
213 }
214 #[allow(clippy::cast_possible_truncation)]
216 let delay_ms = (until - now).as_millis() as u64;
217 debug!(delay_ms, "Alpaca REST rate-limited, waiting before request");
218 tokio::time::sleep_until(until).await;
219 }
220 }
221 }
222 }
223
224 fn on_rate_limited(&self, retry_after: Option<Duration>) {
226 let delay = retry_after.unwrap_or(Duration::from_secs(DEFAULT_RATE_LIMIT_DELAY_SECS));
227 let new_deadline = tokio::time::Instant::now() + delay;
228 let mut guard = self.blocked_until.lock();
229 let was_blocked = guard.is_some();
230 *guard = Some(guard.map_or(new_deadline, |existing| existing.max(new_deadline)));
231 if was_blocked {
232 debug!(
233 delay_secs = delay.as_secs(),
234 "Alpaca rate-limit cooldown extended"
235 );
236 } else {
237 warn!(
238 delay_secs = delay.as_secs(),
239 "Alpaca entering rate-limit degradation mode"
240 );
241 }
242 }
243}
244
245struct ExponentialBackoff {
250 attempt: u32,
251 max_attempts: u32,
252 initial_ms: u64,
253 max_ms: u64,
254}
255
256impl ExponentialBackoff {
257 fn new() -> Self {
258 Self {
259 attempt: 0,
260 max_attempts: MAX_RECONNECT_ATTEMPTS,
261 initial_ms: INITIAL_BACKOFF_MS,
262 max_ms: MAX_BACKOFF_MS,
263 }
264 }
265
266 fn reset(&mut self) {
267 self.attempt = 0;
268 }
269
270 fn attempts(&self) -> u32 {
276 self.attempt
277 }
278
279 async fn wait(&mut self) -> bool {
281 if self.attempt >= self.max_attempts {
282 return false;
283 }
284 let delay_ms = self
285 .initial_ms
286 .saturating_mul(2u64.saturating_pow(self.attempt))
287 .min(self.max_ms);
288 self.attempt += 1;
289 debug!(
290 attempt = self.attempt,
291 max = self.max_attempts,
292 delay_ms,
293 "Alpaca reconnect backoff"
294 );
295 tokio::time::sleep(Duration::from_millis(delay_ms)).await;
296 true
297 }
298}
299
300type SharedDedupCache = Arc<parking_lot::Mutex<LruCache<SmolStr, ()>>>;
325
326fn new_dedup_cache() -> SharedDedupCache {
327 #[allow(clippy::unwrap_used)]
330 Arc::new(parking_lot::Mutex::new(LruCache::new(
331 NonZeroUsize::new(DEDUP_CACHE_SIZE).unwrap(),
332 )))
333}
334
335fn is_duplicate(cache: &SharedDedupCache, key: &SmolStr) -> bool {
337 let mut guard = cache.lock();
338 if guard.peek(key).is_some() {
340 return true;
341 }
342 guard.put(key.clone(), ());
346 false
347}
348
349#[derive(Clone, Deserialize)]
356#[serde(deny_unknown_fields)]
357pub struct AlpacaConfig {
358 api_key: String,
360 secret_key: String,
361
362 #[serde(default = "default_paper")]
364 pub paper: bool,
365 #[cfg(test)]
367 pub base_url_override: Option<String>,
368}
369
370fn default_paper() -> bool {
376 true
377}
378
379impl std::fmt::Debug for AlpacaConfig {
381 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
382 f.debug_struct("AlpacaConfig")
383 .field("api_key", &"***")
384 .field("secret_key", &"***")
385 .field("paper", &self.paper)
386 .finish()
387 }
388}
389
390impl AlpacaConfig {
391 pub fn new(api_key: String, secret_key: String) -> Self {
394 Self::paper(api_key, secret_key)
395 }
396
397 pub fn paper(api_key: String, secret_key: String) -> Self {
399 Self {
400 api_key,
401 secret_key,
402 paper: true,
403 #[cfg(test)]
404 base_url_override: None,
405 }
406 }
407
408 pub fn production(api_key: String, secret_key: String) -> Self {
413 Self {
414 api_key,
415 secret_key,
416 paper: false,
417 #[cfg(test)]
418 base_url_override: None,
419 }
420 }
421
422 pub fn from_env() -> Result<Self, AlpacaConfigError> {
440 let api_key = match std::env::var("ALPACA_API_KEY") {
441 Ok(value) => value,
442 Err(std::env::VarError::NotPresent) => return Err(AlpacaConfigError::MissingApiKey),
443 Err(std::env::VarError::NotUnicode(_)) => {
444 return Err(AlpacaConfigError::InvalidApiKey);
445 }
446 };
447 let secret_key = match std::env::var("ALPACA_SECRET_KEY") {
448 Ok(value) => value,
449 Err(std::env::VarError::NotPresent) => return Err(AlpacaConfigError::MissingSecretKey),
450 Err(std::env::VarError::NotUnicode(_)) => {
451 return Err(AlpacaConfigError::InvalidSecretKey);
452 }
453 };
454
455 let paper = match std::env::var("ALPACA_PAPER") {
456 Ok(value) => parse_env_bool(&value).ok_or(AlpacaConfigError::InvalidPaper(value))?,
457 Err(std::env::VarError::NotPresent) => true,
458 Err(std::env::VarError::NotUnicode(value)) => {
461 return Err(AlpacaConfigError::InvalidPaper(
462 value.to_string_lossy().into_owned(),
463 ));
464 }
465 };
466
467 if paper {
468 Ok(Self::paper(api_key, secret_key))
469 } else {
470 Ok(Self::production(api_key, secret_key))
471 }
472 }
473
474 #[cfg(test)]
476 pub fn with_base_url(api_key: String, secret_key: String, base_url: String) -> Self {
477 Self {
478 api_key,
479 secret_key,
480 paper: true,
481 base_url_override: Some(base_url),
482 }
483 }
484
485 pub fn api_key(&self) -> &str {
486 &self.api_key
487 }
488
489 pub fn rest_base_url(&self) -> &str {
493 #[cfg(test)]
494 if let Some(ref url) = self.base_url_override {
495 return url.as_str();
496 }
497 if self.paper {
498 "https://paper-api.alpaca.markets"
499 } else {
500 "https://api.alpaca.markets"
501 }
502 }
503
504 pub fn ws_url(&self) -> &'static str {
506 if self.paper {
507 "wss://paper-api.alpaca.markets/stream"
508 } else {
509 "wss://api.alpaca.markets/stream"
510 }
511 }
512}
513
514#[derive(Debug, PartialEq, thiserror::Error)]
515pub enum AlpacaConfigError {
516 #[error("ALPACA_API_KEY environment variable not set")]
517 MissingApiKey,
518
519 #[error("ALPACA_API_KEY environment variable is not valid UTF-8")]
522 InvalidApiKey,
523
524 #[error("ALPACA_SECRET_KEY environment variable not set")]
525 MissingSecretKey,
526
527 #[error("ALPACA_SECRET_KEY environment variable is not valid UTF-8")]
528 InvalidSecretKey,
529
530 #[error("ALPACA_PAPER must be true or false, got {0}")]
531 InvalidPaper(String),
532}
533
534#[derive(Debug, Deserialize)]
539struct AlpacaAccount {
540 equity: String,
541 buying_power: String,
542 options_buying_power: Option<String>,
543 #[allow(dead_code)]
548 crypto_buying_power: Option<String>,
550}
551
552#[derive(Debug, Deserialize)]
554struct AlpacaPosition {
555 symbol: String,
557 asset_class: String,
559 qty: String,
561 qty_available: String,
563}
564
565#[derive(Debug, Deserialize)]
566struct AlpacaOrderResponse {
567 id: String,
568 client_order_id: Option<String>,
569 symbol: String,
570 qty: Option<String>,
571 filled_qty: String,
572 side: String,
573 #[serde(rename = "type")]
574 order_type: String,
575 time_in_force: String,
576 limit_price: Option<String>,
577 stop_price: Option<String>,
578 trail_percent: Option<String>,
579 trail_price: Option<String>,
580 created_at: String,
581 updated_at: Option<String>,
584}
585
586#[derive(Debug, Deserialize)]
587struct AlpacaActivity {
588 id: String,
589 order_id: String,
590 symbol: String,
591 side: String,
592 price: String,
593 qty: String,
594 transaction_time: String,
595 cum_qty: Option<String>,
603}
604
605#[derive(Debug, Deserialize)]
606struct AlpacaApiError {
607 message: String,
608}
609
610#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
622#[serde(rename_all = "snake_case")]
623pub enum AlpacaPositionIntent {
624 BuyToOpen,
625 BuyToClose,
626 SellToOpen,
627 SellToClose,
628}
629
630#[derive(Debug, Serialize)]
638struct TakeProfitParams {
639 limit_price: String,
640}
641
642#[derive(Debug, Serialize)]
647struct StopLossParams {
648 stop_price: String,
649 #[serde(skip_serializing_if = "Option::is_none")]
650 limit_price: Option<String>,
651}
652
653#[non_exhaustive]
688#[derive(Debug, Clone)]
689pub struct AlpacaBracketOrderRequest {
690 pub instrument: InstrumentNameExchange,
692 pub strategy: StrategyId,
694 pub cid: ClientOrderId,
696 pub side: Side,
698 pub quantity: Decimal,
700 pub entry_price: Decimal,
702 pub take_profit_price: Decimal,
704 pub stop_loss_price: Decimal,
706 pub stop_loss_limit_price: Option<Decimal>,
708 pub time_in_force: TimeInForce,
710}
711
712impl AlpacaBracketOrderRequest {
713 #[allow(clippy::too_many_arguments)] pub fn new(
718 instrument: InstrumentNameExchange,
719 strategy: StrategyId,
720 cid: ClientOrderId,
721 side: Side,
722 quantity: Decimal,
723 entry_price: Decimal,
724 take_profit_price: Decimal,
725 stop_loss_price: Decimal,
726 time_in_force: TimeInForce,
727 ) -> Self {
728 Self {
729 instrument,
730 strategy,
731 cid,
732 side,
733 quantity,
734 entry_price,
735 take_profit_price,
736 stop_loss_price,
737 stop_loss_limit_price: None,
738 time_in_force,
739 }
740 }
741
742 #[must_use]
744 pub fn with_stop_loss_limit_price(mut self, price: Decimal) -> Self {
745 self.stop_loss_limit_price = Some(price);
746 self
747 }
748}
749
750#[non_exhaustive]
761#[derive(Debug, Clone)]
762pub struct AlpacaBracketOrderResult {
763 pub parent: Order<ExchangeId, InstrumentNameExchange, UnindexedOrderState>,
765}
766
767#[derive(Debug, Serialize)]
772struct AlpacaOrderRequest<'a> {
773 symbol: &'a str,
774 qty: String,
775 side: &'static str,
776 #[serde(rename = "type")]
777 order_type: &'static str,
778 time_in_force: &'static str,
779 #[serde(skip_serializing_if = "Option::is_none")]
780 limit_price: Option<String>,
781 #[serde(skip_serializing_if = "Option::is_none")]
782 stop_price: Option<String>,
783 #[serde(skip_serializing_if = "Option::is_none")]
784 trail_percent: Option<String>,
785 #[serde(skip_serializing_if = "Option::is_none")]
786 trail_price: Option<String>,
787 #[serde(skip_serializing_if = "Option::is_none")]
788 client_order_id: Option<&'a str>,
789 #[serde(skip_serializing_if = "Option::is_none")]
793 position_intent: Option<AlpacaPositionIntent>,
794 #[serde(skip_serializing_if = "Option::is_none")]
797 order_class: Option<&'static str>,
798 #[serde(skip_serializing_if = "Option::is_none")]
799 take_profit: Option<TakeProfitParams>,
800 #[serde(skip_serializing_if = "Option::is_none")]
801 stop_loss: Option<StopLossParams>,
802}
803
804#[derive(Debug, Deserialize)]
813struct AlpacaStreamMessage<'a> {
814 stream: SmolStr,
817 #[serde(borrow)]
818 data: &'a serde_json::value::RawValue,
819}
820
821#[derive(Debug, Deserialize)]
828struct AlpacaTradeUpdate<'a> {
829 event: SmolStr,
831 #[serde(borrow)]
832 order: AlpacaOrderWs<'a>,
833 #[serde(borrow)]
835 price: Option<&'a str>,
836 #[serde(borrow)]
838 qty: Option<&'a str>,
839 #[serde(borrow)]
840 timestamp: Option<&'a str>,
841}
842
843#[derive(Debug, Deserialize)]
845struct AlpacaOrderWs<'a> {
846 id: SmolStr,
849 client_order_id: Option<SmolStr>,
850 symbol: SmolStr,
853 #[serde(borrow)]
854 qty: Option<&'a str>,
855 #[serde(borrow)]
860 filled_qty: Option<&'a str>,
861 side: SmolStr,
864 #[serde(rename = "type")]
865 order_type: SmolStr,
866 time_in_force: SmolStr,
867 #[serde(borrow)]
868 limit_price: Option<&'a str>,
869 #[serde(borrow)]
870 stop_price: Option<&'a str>,
871 #[serde(borrow)]
872 trail_percent: Option<&'a str>,
873 #[serde(borrow)]
874 trail_price: Option<&'a str>,
875 status: SmolStr,
876}
877
878#[derive(Clone)]
894pub struct AlpacaClient {
895 config: Arc<AlpacaConfig>,
896 http: reqwest::Client,
899 rate_limiter: Arc<RateLimitTracker>,
900 orders_url: String,
902}
903
904impl std::fmt::Debug for AlpacaClient {
905 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
906 f.debug_struct("AlpacaClient")
907 .field("paper", &self.config.paper)
908 .finish_non_exhaustive()
909 }
910}
911
912impl AlpacaClient {
913 #[allow(clippy::expect_used)] fn build_http(config: &AlpacaConfig) -> reqwest::Client {
921 use reqwest::header::{HeaderMap, HeaderName, HeaderValue};
922 let mut headers = HeaderMap::new();
923 headers.insert(
925 HeaderName::from_static("apca-api-key-id"),
926 HeaderValue::from_str(&config.api_key)
927 .expect("Alpaca API key contains invalid header characters"),
928 );
929 headers.insert(
930 HeaderName::from_static("apca-api-secret-key"),
931 HeaderValue::from_str(&config.secret_key)
932 .expect("Alpaca secret key contains invalid header characters"),
933 );
934 reqwest::Client::builder()
935 .default_headers(headers)
936 .build()
937 .expect("failed to build reqwest client for Alpaca")
938 }
939
940 fn base_url(&self) -> &str {
941 self.config.rest_base_url()
942 }
943}
944
945fn parse_rate_limit_delay(headers: &reqwest::header::HeaderMap) -> Option<Duration> {
956 headers
957 .get("x-ratelimit-reset")
958 .and_then(|v| v.to_str().ok())
959 .and_then(|s| s.parse::<u64>().ok())
960 .map(|reset_ts| {
961 let now_secs = std::time::SystemTime::now()
962 .duration_since(std::time::UNIX_EPOCH)
963 .unwrap_or_default()
964 .as_secs();
965 Duration::from_secs(reset_ts.saturating_sub(now_secs).max(1))
966 })
967}
968
969async fn rest_with_retry<T>(
978 rate_limiter: &RateLimitTracker,
979 mut build_request: impl FnMut() -> reqwest::RequestBuilder,
980) -> Result<T, UnindexedClientError>
981where
982 T: for<'de> Deserialize<'de>,
983{
984 for attempt in 0..MAX_RATE_LIMIT_ATTEMPTS {
985 rate_limiter.wait_if_blocked().await;
986 let response = build_request()
987 .send()
988 .await
989 .map_err(|e| connectivity_err(format!("Alpaca REST request failed: {e}")))?;
990
991 if response
995 .headers()
996 .get("x-ratelimit-remaining")
997 .and_then(|v| v.to_str().ok())
998 .and_then(|s| s.parse::<u32>().ok())
999 == Some(0)
1000 {
1001 debug!("Alpaca REST rate-limit bucket exhausted (X-Ratelimit-Remaining: 0)");
1002 }
1003
1004 let status = response.status();
1005
1006 if status == reqwest::StatusCode::TOO_MANY_REQUESTS {
1007 let reset_delay = parse_rate_limit_delay(response.headers());
1008
1009 if attempt + 1 < MAX_RATE_LIMIT_ATTEMPTS {
1010 warn!(
1011 attempt = attempt + 1,
1012 max_attempts = MAX_RATE_LIMIT_ATTEMPTS,
1013 "Alpaca REST rate-limited (429), retrying"
1014 );
1015 rate_limiter.on_rate_limited(reset_delay);
1016 continue;
1017 }
1018
1019 warn!(
1021 max_attempts = MAX_RATE_LIMIT_ATTEMPTS,
1022 "Alpaca REST rate-limit retries exhausted"
1023 );
1024 return Err(UnindexedClientError::Api(ApiError::RateLimit));
1025 }
1026
1027 if status == reqwest::StatusCode::NO_CONTENT {
1031 return Err(connectivity_err(
1032 "Alpaca REST returned 204 No Content — use rest_delete_with_retry for DELETE endpoints"
1033 .to_string(),
1034 ));
1035 }
1036
1037 let bytes = response
1038 .bytes()
1039 .await
1040 .map_err(|e| connectivity_err(format!("Alpaca REST read body failed: {e}")))?;
1041
1042 if status.is_success() {
1043 return serde_json::from_slice::<T>(&bytes).map_err(|e| {
1044 connectivity_err(format!(
1045 "Alpaca REST JSON parse error ({status}): {e} | body: {}",
1046 String::from_utf8_lossy(&bytes)
1047 .chars()
1048 .take(200)
1049 .collect::<String>()
1050 ))
1051 });
1052 }
1053
1054 let api_err = serde_json::from_slice::<AlpacaApiError>(&bytes)
1056 .map(|e| e.message)
1057 .unwrap_or_else(|_| String::from_utf8_lossy(&bytes).into_owned());
1058
1059 if status.is_client_error() {
1068 return Err(UnindexedClientError::Api(parse_api_error(status, &api_err)));
1069 }
1070 return Err(connectivity_err(format!(
1071 "Alpaca REST error {status}: {api_err}"
1072 )));
1073 }
1074 unreachable!("Alpaca REST retry loop exited without returning")
1075}
1076
1077async fn rest_delete_with_retry(
1081 rate_limiter: &RateLimitTracker,
1082 mut build_request: impl FnMut() -> reqwest::RequestBuilder,
1083) -> Result<(), UnindexedOrderError> {
1084 for attempt in 0..MAX_RATE_LIMIT_ATTEMPTS {
1085 rate_limiter.wait_if_blocked().await;
1086 let response = build_request().send().await.map_err(|e| {
1087 UnindexedOrderError::Connectivity(ConnectivityError::Socket(format!(
1088 "Alpaca cancel request failed: {e}"
1089 )))
1090 })?;
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 cancel 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 cancel rate-limit retries exhausted"
1111 );
1112 return Err(UnindexedOrderError::Rejected(ApiError::RateLimit));
1113 }
1114
1115 if status == reqwest::StatusCode::NO_CONTENT || status.is_success() {
1117 return Ok(());
1118 }
1119
1120 let bytes = response
1121 .bytes()
1122 .await
1123 .inspect_err(
1124 |e| warn!(%e, %status, "Alpaca cancel_order: failed to read error response body"),
1125 )
1126 .unwrap_or_default();
1127 let msg = serde_json::from_slice::<AlpacaApiError>(&bytes)
1128 .map(|e| e.message)
1129 .unwrap_or_else(|_| String::from_utf8_lossy(&bytes).into_owned());
1130
1131 return Err(parse_order_error(status, &msg));
1132 }
1133 unreachable!("Alpaca cancel retry loop exited without returning")
1134}
1135
1136impl ExecutionClient for AlpacaClient {
1141 const EXCHANGE: ExchangeId = ExchangeId::AlpacaBroker;
1142
1143 const SUPPORTED_KINDS: &'static [InstrumentKindDiscriminant] = &[
1150 InstrumentKindDiscriminant::Spot,
1151 InstrumentKindDiscriminant::Option,
1152 ];
1153 type Config = AlpacaConfig;
1154 type AccountStream = BoxStream<'static, UnindexedAccountEvent>;
1155
1156 fn new(config: Self::Config) -> Self {
1161 let http = Self::build_http(&config);
1162 let orders_url = format!("{}/v2/orders", config.rest_base_url());
1163 Self {
1164 config: Arc::new(config),
1165 http,
1166 rate_limiter: Arc::new(RateLimitTracker::new()),
1167 orders_url,
1168 }
1169 }
1170
1171 async fn account_snapshot(
1186 &self,
1187 assets: &[AssetNameExchange],
1188 instruments: &[InstrumentNameExchange],
1189 ) -> Result<UnindexedAccountSnapshot, UnindexedClientError> {
1190 let base = self.base_url();
1191 let http = self.http.clone();
1192 let rl = &self.rate_limiter;
1193
1194 let wants_usd = assets.is_empty()
1195 || assets
1196 .iter()
1197 .any(|a| a.name().as_str().eq_ignore_ascii_case("usd"));
1198 let wants_non_usd = assets.is_empty()
1199 || assets
1200 .iter()
1201 .any(|a| !a.name().as_str().eq_ignore_ascii_case("usd"));
1202
1203 let account_url = format!("{base}/v2/account");
1206 let positions_url = format!("{base}/v2/positions");
1207 let balances = match (wants_usd, wants_non_usd) {
1208 (true, true) => {
1209 let (account, positions): (AlpacaAccount, Vec<AlpacaPosition>) = tokio::try_join!(
1210 rest_with_retry(rl, || http.get(&account_url)),
1211 rest_with_retry(rl, || http.get(&positions_url)),
1212 )?;
1213 let mut balances = convert_account_to_balances(&account, assets);
1214 balances.extend(convert_positions_to_balances(&positions, assets));
1215 balances
1216 }
1217 (true, false) => {
1218 let account: AlpacaAccount = rest_with_retry(rl, || http.get(&account_url)).await?;
1219 convert_account_to_balances(&account, assets)
1220 }
1221 (false, true) => {
1222 let positions: Vec<AlpacaPosition> =
1223 rest_with_retry(rl, || http.get(&positions_url)).await?;
1224 convert_positions_to_balances(&positions, assets)
1225 }
1226 (false, false) => Vec::new(),
1227 };
1228
1229 let open_orders = fetch_raw_open_orders(&http, rl, base, instruments).await?;
1230
1231 let instrument_snapshots = build_instrument_snapshots(open_orders, instruments);
1233
1234 Ok(AccountSnapshot::new(
1235 ExchangeId::AlpacaBroker,
1236 balances,
1237 instrument_snapshots,
1238 ))
1239 }
1240
1241 async fn account_stream(
1301 &self,
1302 _assets: &[AssetNameExchange], instruments: &[InstrumentNameExchange],
1307 ) -> Result<Self::AccountStream, UnindexedClientError> {
1308 let initial_ws = connect_and_subscribe(&self.config).await?;
1311
1312 let (tx, rx) = mpsc::unbounded_channel::<UnindexedAccountEvent>();
1317 let dedup = new_dedup_cache();
1318 let config = self.config.clone();
1319 let http = self.http.clone();
1320 let rate_limiter = self.rate_limiter.clone();
1321 let instruments = instruments.to_vec();
1322
1323 let cm_handle = tokio::spawn(connection_manager(
1324 tx,
1325 dedup,
1326 config,
1327 http,
1328 rate_limiter,
1329 instruments,
1330 Some(initial_ws),
1331 ));
1332
1333 let rx_stream = tokio_stream::wrappers::UnboundedReceiverStream::new(rx);
1334 let guarded = GracefulShutdownStream::new(rx_stream, cm_handle);
1335 Ok(futures::StreamExt::boxed(guarded))
1336 }
1337
1338 async fn cancel_order(
1353 &self,
1354 request: OrderRequestCancel<ExchangeId, &InstrumentNameExchange>,
1355 ) -> Option<UnindexedOrderResponseCancel> {
1356 let key = crate::order::OrderKey {
1357 exchange: request.key.exchange,
1358 instrument: request.key.instrument.clone(),
1359 strategy: request.key.strategy.clone(),
1360 cid: request.key.cid.clone(),
1361 };
1362
1363 let order_id: SmolStr = match request.state.id.as_ref().and_then(VenueOrderId::assigned) {
1367 Some(id) => id.0.clone(),
1368 None => {
1369 warn!(
1370 instrument = %key.instrument,
1371 "Alpaca cancel_order: no exchange order ID available (clientOrderId-only cancel not supported)"
1372 );
1373 return Some(crate::order::request::OrderResponseCancel {
1374 key,
1375 state: Err(UnindexedOrderError::Rejected(ApiError::OrderRejected(
1376 "exchange order ID required for cancel (fetch_open_orders to resolve)"
1377 .into(),
1378 ))),
1379 });
1380 }
1381 };
1382
1383 let base = self.base_url();
1384 let http = self.http.clone();
1385 let url = format!("{base}/v2/orders/{order_id}");
1386
1387 match rest_delete_with_retry(&self.rate_limiter, || http.delete(&url)).await {
1388 Ok(()) => {
1389 let exchange_order_id = OrderId(order_id);
1390 Some(crate::order::request::OrderResponseCancel {
1393 key,
1394 state: Ok(Cancelled::new(exchange_order_id, Utc::now(), Decimal::ZERO)),
1395 })
1396 }
1397 Err(e) => Some(crate::order::request::OrderResponseCancel { key, state: Err(e) }),
1398 }
1399 }
1400
1401 async fn open_order(
1422 &self,
1423 request: OrderRequestOpen<ExchangeId, &InstrumentNameExchange>,
1424 ) -> Option<Order<ExchangeId, InstrumentNameExchange, UnindexedOrderState>> {
1425 let side = request.state.side;
1426 let reduce_only = request.state.reduce_only;
1427 self.open_order_inner(request, map_position_intent(side, reduce_only))
1428 .await
1429 }
1430
1431 async fn fetch_balances(
1439 &self,
1440 assets: &[AssetNameExchange],
1441 ) -> Result<Vec<AssetBalance<AssetNameExchange>>, UnindexedClientError> {
1442 let base = self.base_url();
1443 let http = self.http.clone();
1444 let mut result = Vec::new();
1445
1446 let wants_usd = assets.is_empty()
1449 || assets
1450 .iter()
1451 .any(|a| a.name().as_str().eq_ignore_ascii_case("usd"));
1452 if wants_usd {
1453 let account_url = format!("{base}/v2/account");
1455 let account: AlpacaAccount =
1456 rest_with_retry(&self.rate_limiter, || http.get(&account_url)).await?;
1457 result.extend(convert_account_to_balances(&account, assets));
1458 }
1459
1460 let wants_non_usd = assets.is_empty()
1462 || assets
1463 .iter()
1464 .any(|a| !a.name().as_str().eq_ignore_ascii_case("usd"));
1465 if wants_non_usd {
1466 let positions_url = format!("{base}/v2/positions");
1468 let positions: Vec<AlpacaPosition> =
1469 rest_with_retry(&self.rate_limiter, || http.get(&positions_url)).await?;
1470 result.extend(convert_positions_to_balances(&positions, assets));
1471 }
1472
1473 Ok(result)
1474 }
1475
1476 async fn fetch_open_orders(
1477 &self,
1478 instruments: &[InstrumentNameExchange],
1479 ) -> Result<Vec<Order<ExchangeId, InstrumentNameExchange, Open>>, UnindexedClientError> {
1480 let base = self.base_url();
1481 let http = self.http.clone();
1482
1483 let open_orders =
1484 fetch_raw_open_orders(&http, &self.rate_limiter, base, instruments).await?;
1485
1486 let result = open_orders
1487 .into_iter()
1488 .filter_map(|o| convert_open_order(&o))
1489 .collect();
1490 Ok(result)
1491 }
1492
1493 async fn fetch_trades(
1494 &self,
1495 time_since: DateTime<Utc>,
1496 instruments: &[InstrumentNameExchange],
1497 ) -> Result<Vec<Trade<AssetNameExchange, InstrumentNameExchange>>, UnindexedClientError> {
1498 let after_str = time_since.to_rfc3339();
1499 let base = self.base_url();
1500 let http = self.http.clone();
1501
1502 let page = paginate_activities(&http, &self.rate_limiter, base, &after_str).await?;
1503
1504 if page.truncated {
1507 return Err(UnindexedClientError::Truncated {
1508 limit: MAX_ACTIVITY_PAGES,
1509 });
1510 }
1511
1512 let trades = if instruments.is_empty() {
1515 page.activities
1516 .into_iter()
1517 .filter_map(|a| convert_activity_to_trade(&a))
1518 .collect()
1519 } else {
1520 let instrument_set: fnv::FnvHashSet<&str> =
1521 instruments.iter().map(|i| i.name().as_str()).collect();
1522 page.activities
1523 .into_iter()
1524 .filter(|a| instrument_set.contains(a.symbol.as_str()))
1525 .filter_map(|a| convert_activity_to_trade(&a))
1526 .collect()
1527 };
1528
1529 Ok(trades)
1530 }
1531}
1532
1533impl AlpacaClient {
1538 pub async fn open_order_with_intent(
1551 &self,
1552 request: OrderRequestOpen<ExchangeId, &InstrumentNameExchange>,
1553 intent: AlpacaPositionIntent,
1554 ) -> Option<Order<ExchangeId, InstrumentNameExchange, UnindexedOrderState>> {
1555 self.open_order_inner(request, intent).await
1556 }
1557
1558 pub async fn open_bracket_order(
1591 &self,
1592 request: AlpacaBracketOrderRequest,
1593 ) -> AlpacaBracketOrderResult {
1594 let order_key = crate::order::OrderKey::new(
1595 ExchangeId::AlpacaBroker,
1596 request.instrument.clone(),
1597 request.strategy.clone(),
1598 request.cid.clone(),
1599 );
1600
1601 let tif_str = match request.time_in_force {
1603 TimeInForce::GoodUntilEndOfDay => "day",
1604 TimeInForce::GoodUntilCancelled { post_only } => {
1605 if post_only {
1606 return AlpacaBracketOrderResult {
1607 parent: Order {
1608 key: order_key,
1609 side: request.side,
1610 price: Some(request.entry_price),
1611 quantity: request.quantity,
1612 kind: OrderKind::Limit,
1613 time_in_force: request.time_in_force,
1614 state: OrderState::inactive(OrderError::Rejected(
1615 ApiError::OrderRejected(
1616 "Alpaca does not support post_only for bracket orders"
1617 .to_string(),
1618 ),
1619 )),
1620 },
1621 };
1622 }
1623 "gtc"
1624 }
1625 other => {
1626 return AlpacaBracketOrderResult {
1627 parent: Order {
1628 key: order_key,
1629 side: request.side,
1630 price: Some(request.entry_price),
1631 quantity: request.quantity,
1632 kind: OrderKind::Limit,
1633 time_in_force: request.time_in_force,
1634 state: OrderState::inactive(OrderError::Rejected(ApiError::OrderRejected(
1635 format!(
1636 "Alpaca bracket orders only support Day or GTC time_in_force, got {:?}",
1637 other
1638 ),
1639 ))),
1640 },
1641 };
1642 }
1643 };
1644
1645 let price_ordering_ok = match request.side {
1648 Side::Buy => {
1649 request.stop_loss_price < request.entry_price
1650 && request.entry_price < request.take_profit_price
1651 }
1652 Side::Sell => {
1653 request.take_profit_price < request.entry_price
1654 && request.entry_price < request.stop_loss_price
1655 }
1656 };
1657 if !price_ordering_ok {
1658 return AlpacaBracketOrderResult {
1659 parent: Order {
1660 key: order_key,
1661 side: request.side,
1662 price: Some(request.entry_price),
1663 quantity: request.quantity,
1664 kind: OrderKind::Limit,
1665 time_in_force: request.time_in_force,
1666 state: OrderState::inactive(OrderError::Rejected(ApiError::OrderRejected(
1667 format!(
1668 "Invalid bracket price ordering for {:?} side: entry={}, take_profit={}, stop_loss={}",
1669 request.side,
1670 request.entry_price,
1671 request.take_profit_price,
1672 request.stop_loss_price,
1673 ),
1674 ))),
1675 },
1676 };
1677 }
1678
1679 if let Some(sl_limit) = request.stop_loss_limit_price {
1683 let sl_limit_ok = match request.side {
1684 Side::Buy => sl_limit <= request.stop_loss_price,
1685 Side::Sell => sl_limit >= request.stop_loss_price,
1686 };
1687 if !sl_limit_ok {
1688 return AlpacaBracketOrderResult {
1689 parent: Order {
1690 key: order_key,
1691 side: request.side,
1692 price: Some(request.entry_price),
1693 quantity: request.quantity,
1694 kind: OrderKind::Limit,
1695 time_in_force: request.time_in_force,
1696 state: OrderState::inactive(OrderError::Rejected(ApiError::OrderRejected(
1697 format!(
1698 "Invalid stop-loss limit price for {:?} bracket: \
1699 stop_loss_price={}, stop_loss_limit_price={}",
1700 request.side, request.stop_loss_price, sl_limit,
1701 ),
1702 ))),
1703 },
1704 };
1705 }
1706 }
1707
1708 let body = AlpacaOrderRequest {
1710 symbol: request.instrument.name().as_str(),
1711 qty: request.quantity.to_string(),
1712 side: map_side(request.side),
1713 order_type: "limit",
1714 time_in_force: tif_str,
1715 limit_price: Some(request.entry_price.to_string()),
1716 stop_price: None,
1717 trail_percent: None,
1718 trail_price: None,
1719 client_order_id: Some(request.cid.0.as_str()),
1720 position_intent: if is_options_or_equity_symbol(request.instrument.name().as_str()) {
1721 Some(map_position_intent(request.side, false))
1722 } else {
1723 None
1724 },
1725 order_class: Some("bracket"),
1726 take_profit: Some(TakeProfitParams {
1727 limit_price: request.take_profit_price.to_string(),
1728 }),
1729 stop_loss: Some(StopLossParams {
1730 stop_price: request.stop_loss_price.to_string(),
1731 limit_price: request.stop_loss_limit_price.map(|p| p.to_string()),
1732 }),
1733 };
1734
1735 let http = self.http.clone();
1736 let rl = &self.rate_limiter;
1737
1738 let result: Result<AlpacaOrderResponse, UnindexedClientError> =
1739 rest_with_retry(rl, || http.post(&self.orders_url).json(&body)).await;
1740
1741 match result {
1742 Ok(resp) => {
1743 let exchange_order_id = OrderId(SmolStr::new(&resp.id));
1744 let time_exchange = order_state_time(&resp);
1745 let filled_qty = Decimal::from_str(&resp.filled_qty).unwrap_or(Decimal::ZERO);
1746
1747 let state = if filled_qty >= request.quantity {
1748 OrderState::fully_filled(Filled::new(
1749 exchange_order_id,
1750 time_exchange,
1751 filled_qty,
1752 None,
1753 ))
1754 } else {
1755 OrderState::active(Open::new(
1756 VenueOrderId::Assigned(exchange_order_id),
1757 time_exchange,
1758 filled_qty,
1759 ))
1760 };
1761
1762 AlpacaBracketOrderResult {
1763 parent: Order {
1764 key: order_key,
1765 side: request.side,
1766 price: Some(request.entry_price),
1767 quantity: request.quantity,
1768 kind: OrderKind::Limit,
1769 time_in_force: request.time_in_force,
1770 state,
1771 },
1772 }
1773 }
1774 Err(e) => {
1775 let order_err = match e {
1776 UnindexedClientError::Connectivity(ce) => OrderError::Connectivity(ce),
1777 UnindexedClientError::Api(ae) => OrderError::Rejected(ae),
1778 UnindexedClientError::TaskFailed(_)
1779 | UnindexedClientError::Internal(_)
1780 | UnindexedClientError::Truncated { .. }
1781 | UnindexedClientError::TruncatedSnapshot { .. } => {
1782 unreachable!("rest_with_retry (order path) does not produce these variants")
1783 }
1784 };
1785 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(order_err),
1794 },
1795 }
1796 }
1797 }
1798 }
1799
1800 async fn open_order_inner(
1801 &self,
1802 request: OrderRequestOpen<ExchangeId, &InstrumentNameExchange>,
1803 intent: AlpacaPositionIntent,
1804 ) -> Option<Order<ExchangeId, InstrumentNameExchange, UnindexedOrderState>> {
1805 let instrument = request.key.instrument.clone();
1806 let side = request.state.side;
1807 let price = request.state.price;
1808 let quantity = request.state.quantity;
1809 let kind = request.state.kind;
1810 let time_in_force = request.state.time_in_force;
1811 let cid = request.key.cid.clone();
1812
1813 let order_key = crate::order::OrderKey::new(
1814 ExchangeId::AlpacaBroker,
1815 instrument.clone(),
1816 request.key.strategy.clone(),
1817 cid.clone(),
1818 );
1819
1820 let tif_str = match map_time_in_force(time_in_force) {
1822 Ok(s) => s,
1823 Err(msg) => {
1824 return Some(Order {
1825 key: order_key,
1826 side,
1827 price,
1828 quantity,
1829 kind,
1830 time_in_force,
1831 state: OrderState::inactive(OrderError::Rejected(ApiError::OrderRejected(
1832 msg.to_string(),
1833 ))),
1834 });
1835 }
1836 };
1837
1838 let order_type_str = match map_order_kind(kind) {
1840 Some(s) => s,
1841 None => {
1842 return Some(Order {
1843 key: order_key,
1844 side,
1845 price,
1846 quantity,
1847 kind,
1848 time_in_force,
1849 state: OrderState::inactive(OrderError::UnsupportedOrderType(format!(
1850 "Alpaca connector does not yet support OrderKind::{kind:?}"
1851 ))),
1852 });
1853 }
1854 };
1855
1856 if let OrderKind::TrailingStop {
1858 offset_type: TrailingOffsetType::BasisPoints,
1859 ..
1860 } = kind
1861 {
1862 return Some(Order {
1863 key: order_key,
1864 side,
1865 price,
1866 quantity,
1867 kind,
1868 time_in_force,
1869 state: OrderState::inactive(OrderError::UnsupportedOrderType(
1870 "Alpaca does not support TrailingOffsetType::BasisPoints; \
1871 use Percentage or Absolute"
1872 .to_string(),
1873 )),
1874 });
1875 }
1876
1877 if matches!(kind, OrderKind::StopLimit { .. }) && price.is_none() {
1880 return Some(Order {
1881 key: order_key,
1882 side,
1883 price,
1884 quantity,
1885 kind,
1886 time_in_force,
1887 state: OrderState::inactive(OrderError::Rejected(ApiError::OrderRejected(
1888 "StopLimit order requires Order.price (the limit price) to be set".to_string(),
1889 ))),
1890 });
1891 }
1892
1893 let (stop_price, trail_percent, trail_price) = match kind {
1895 OrderKind::Stop { trigger_price } | OrderKind::StopLimit { trigger_price } => {
1896 (Some(trigger_price.to_string()), None, None)
1897 }
1898 OrderKind::TrailingStop {
1899 offset,
1900 offset_type,
1901 } => match offset_type {
1902 TrailingOffsetType::Percentage => (None, Some(offset.to_string()), None),
1903 TrailingOffsetType::Absolute => (None, None, Some(offset.to_string())),
1904 TrailingOffsetType::BasisPoints => unreachable!("validated above"),
1905 },
1906 OrderKind::Market
1908 | OrderKind::Limit
1909 | OrderKind::TakeProfit { .. }
1910 | OrderKind::TakeProfitLimit { .. }
1911 | OrderKind::TrailingStopLimit { .. } => (None, None, None),
1912 };
1913
1914 let body = AlpacaOrderRequest {
1915 symbol: instrument.name().as_str(),
1916 qty: quantity.to_string(),
1917 side: map_side(side),
1918 order_type: order_type_str,
1919 time_in_force: tif_str,
1920 limit_price: match kind {
1921 OrderKind::Limit | OrderKind::StopLimit { .. } => price.map(|p| p.to_string()),
1922 _ => None,
1923 },
1924 stop_price,
1925 trail_percent,
1926 trail_price,
1927 client_order_id: Some(cid.0.as_str()),
1928 position_intent: if is_options_or_equity_symbol(instrument.name().as_str()) {
1934 Some(intent)
1935 } else {
1936 None
1937 },
1938 order_class: None,
1940 take_profit: None,
1941 stop_loss: None,
1942 };
1943
1944 let http = self.http.clone();
1945 let rl = &self.rate_limiter;
1946
1947 let result: Result<AlpacaOrderResponse, UnindexedClientError> =
1948 rest_with_retry(rl, || http.post(&self.orders_url).json(&body)).await;
1949
1950 match result {
1951 Ok(resp) => {
1952 let exchange_order_id = OrderId(SmolStr::new(&resp.id));
1953 let time_exchange = order_state_time(&resp);
1954 let filled_qty = Decimal::from_str(&resp.filled_qty).unwrap_or(Decimal::ZERO);
1955
1956 let state = if filled_qty >= quantity {
1957 OrderState::fully_filled(Filled::new(
1959 exchange_order_id,
1960 time_exchange,
1961 filled_qty,
1962 None, ))
1964 } else {
1965 OrderState::active(Open::new(
1967 VenueOrderId::Assigned(exchange_order_id),
1968 time_exchange,
1969 filled_qty,
1970 ))
1971 };
1972
1973 Some(Order {
1974 key: order_key,
1975 side,
1976 price,
1977 quantity,
1978 kind,
1979 time_in_force,
1980 state,
1981 })
1982 }
1983 Err(e) => {
1984 let order_err = match e {
1985 UnindexedClientError::Connectivity(ce) => OrderError::Connectivity(ce),
1986 UnindexedClientError::Api(ae) => OrderError::Rejected(ae),
1987 UnindexedClientError::TaskFailed(_)
1994 | UnindexedClientError::Internal(_)
1995 | UnindexedClientError::Truncated { .. }
1996 | UnindexedClientError::TruncatedSnapshot { .. } => {
1997 unreachable!(
1998 "rest_with_retry (order path) does not produce TaskFailed/Internal/Truncated/TruncatedSnapshot variants"
1999 )
2000 }
2001 };
2002 Some(Order {
2003 key: order_key,
2004 side,
2005 price,
2006 quantity,
2007 kind,
2008 time_in_force,
2009 state: OrderState::inactive(order_err),
2010 })
2011 }
2012 }
2013 }
2014}
2015
2016const MAX_OPEN_ORDERS: usize = 500;
2024
2025async fn fetch_raw_open_orders(
2033 http: &reqwest::Client,
2034 rate_limiter: &RateLimitTracker,
2035 base: &str,
2036 instruments: &[InstrumentNameExchange],
2037) -> Result<Vec<AlpacaOrderResponse>, UnindexedClientError> {
2038 let orders: Vec<AlpacaOrderResponse> = if instruments.is_empty() {
2039 rest_with_retry(rate_limiter, || {
2040 http.get(format!("{base}/v2/orders"))
2041 .query(&[("status", "open"), ("limit", "500")])
2042 })
2043 .await?
2044 } else {
2045 let symbols = instruments.iter().map(|i| i.name().as_str()).join(",");
2046 rest_with_retry(rate_limiter, || {
2047 http.get(format!("{base}/v2/orders")).query(&[
2048 ("status", "open"),
2049 ("limit", "500"),
2050 ("symbols", &symbols),
2051 ])
2052 })
2053 .await?
2054 };
2055 if orders.len() == MAX_OPEN_ORDERS {
2056 warn!(
2057 limit = MAX_OPEN_ORDERS,
2058 "Alpaca fetch_raw_open_orders: received exactly {MAX_OPEN_ORDERS} results — \
2059 response is likely truncated"
2060 );
2061 return Err(UnindexedClientError::TruncatedSnapshot {
2062 limit: MAX_OPEN_ORDERS,
2063 });
2064 }
2065 Ok(orders)
2066}
2067
2068const MAX_ACTIVITY_PAGES: usize = 50;
2077
2078struct ActivityPage {
2084 activities: Vec<AlpacaActivity>,
2085 truncated: bool,
2086}
2087
2088const PAGE_SIZE_STR: &str = "100"; const _: () = assert!(
2100 ALPACA_MAX_ACTIVITIES == 100,
2101 "PAGE_SIZE_STR must be updated to match ALPACA_MAX_ACTIVITIES",
2102);
2103
2104async fn paginate_activities(
2105 http: &reqwest::Client,
2106 rate_limiter: &RateLimitTracker,
2107 base: &str,
2108 after: &str,
2109) -> Result<ActivityPage, UnindexedClientError> {
2110 let mut all = Vec::with_capacity(ALPACA_MAX_ACTIVITIES);
2111 let mut page_token: Option<String> = None;
2112 let mut pages = 0usize;
2113 let mut truncated = false;
2114
2115 loop {
2116 if pages >= MAX_ACTIVITY_PAGES {
2117 truncated = true;
2118 break;
2119 }
2120 pages += 1;
2121 let page_token_ref = page_token.as_deref();
2123 let activities: Vec<AlpacaActivity> = rest_with_retry(rate_limiter, || {
2124 let mut req = http.get(format!("{base}/v2/account/activities")).query(&[
2125 ("activity_type", "FILL"),
2126 ("after", after),
2127 ("page_size", PAGE_SIZE_STR),
2128 ("direction", "asc"),
2129 ]);
2130 if let Some(token) = page_token_ref {
2131 req = req.query(&[("page_token", token)]);
2132 }
2133 req
2134 })
2135 .await?;
2136
2137 let page_len = activities.len();
2138 let page_token_candidate = activities.last().map(|a| a.id.clone());
2148 all.extend(activities);
2149
2150 if page_len < ALPACA_MAX_ACTIVITIES {
2151 break;
2152 }
2153 match page_token_candidate {
2154 Some(token) if !token.is_empty() => {
2155 debug!("Alpaca paginate_activities: fetching next page ({page_len} results)");
2156 page_token = Some(token);
2157 }
2158 _ => break,
2162 }
2163 }
2164
2165 Ok(ActivityPage {
2166 activities: all,
2167 truncated,
2168 })
2169}
2170
2171#[allow(clippy::cognitive_complexity)] async fn connection_manager(
2187 tx: mpsc::UnboundedSender<UnindexedAccountEvent>,
2188 dedup: SharedDedupCache,
2189 config: Arc<AlpacaConfig>,
2190 http: reqwest::Client,
2191 rate_limiter: Arc<RateLimitTracker>,
2192 instruments: Vec<InstrumentNameExchange>,
2193 initial_ws: Option<WebSocket>,
2194) {
2195 let mut backoff = ExponentialBackoff::new();
2196 let mut disconnect_time: Option<DateTime<Utc>> = None;
2197 let mut current_ws = initial_ws;
2198
2199 'outer: loop {
2200 let mut ws = match current_ws.take() {
2202 Some(ws) => ws,
2203 None => match connect_and_subscribe(&config).await {
2204 Ok(ws) => ws,
2205 Err(e) => {
2206 error!(%e, "Alpaca WS connect/subscribe failed");
2207 if !backoff.wait().await {
2208 error!("Alpaca max reconnect attempts exhausted");
2209 emit_stream_terminated(
2211 &tx,
2212 ExchangeId::AlpacaBroker,
2213 StreamTerminationReason::ReconnectBudgetExhausted {
2214 attempts: backoff.attempts(),
2215 last_error: e.to_string(),
2216 },
2217 );
2218 break;
2219 }
2220 continue;
2221 }
2222 },
2223 };
2224 info!("Alpaca account_stream connected and subscribed");
2225 backoff.reset();
2236
2237 if let Some(dt) = disconnect_time.take() {
2242 let base = config.rest_base_url();
2243 let after_str = dt.to_rfc3339();
2244 match tokio::time::timeout(
2245 Duration::from_secs(FILL_RECOVERY_TIMEOUT_SECS),
2246 recover_fills(
2247 &http,
2248 &rate_limiter,
2249 &instruments,
2250 base,
2251 &after_str,
2252 &tx,
2253 &dedup,
2254 ),
2255 )
2256 .await
2257 {
2258 Ok(()) => {}
2259 Err(_) => warn!(
2260 timeout_secs = FILL_RECOVERY_TIMEOUT_SECS,
2261 "Alpaca fill recovery timed out — some fills may be missing"
2262 ),
2263 }
2264 }
2265
2266 let mut last_message_time = Utc::now();
2279 let heartbeat = tokio::time::sleep(Duration::from_secs(HEARTBEAT_TIMEOUT_SECS));
2280 tokio::pin!(heartbeat);
2281
2282 macro_rules! reset_heartbeat {
2287 () => {
2288 heartbeat.as_mut().reset(
2289 tokio::time::Instant::now() + Duration::from_secs(HEARTBEAT_TIMEOUT_SECS),
2290 );
2291 last_message_time = Utc::now();
2292 };
2293 }
2294 enum DisconnectReason {
2297 ServerClose,
2298 Error(String),
2299 StreamEnded,
2300 HeartbeatTimeout,
2301 }
2302 let reason = loop {
2303 tokio::select! {
2304 msg = ws.next() => {
2305 match msg {
2306 Some(Ok(WsMessage::Ping(_))) => {
2307 reset_heartbeat!();
2310 }
2311 Some(Ok(WsMessage::Text(text))) => {
2312 process_ws_text(text.as_str(), &tx, &dedup, &mut backoff);
2313 reset_heartbeat!();
2314 }
2315 Some(Ok(WsMessage::Binary(bytes))) => {
2316 match std::str::from_utf8(&bytes) {
2318 Ok(text) => {
2319 process_ws_text(text, &tx, &dedup, &mut backoff);
2320 reset_heartbeat!();
2324 }
2325 Err(e) => warn!(%e, "Alpaca WS binary frame: not valid UTF-8"),
2326 }
2327 }
2328 Some(Ok(WsMessage::Close(frame))) => {
2329 warn!(frame = ?frame, "Alpaca WS closed by server");
2330 break DisconnectReason::ServerClose;
2331 }
2332 Some(Ok(_)) => {} Some(Err(e)) => {
2334 warn!(%e, "Alpaca WS error, reconnecting");
2335 break DisconnectReason::Error(e.to_string());
2336 }
2337 None => {
2338 warn!("Alpaca WS stream ended, reconnecting");
2339 break DisconnectReason::StreamEnded;
2340 }
2341 }
2342 }
2343 _ = &mut heartbeat => {
2344 warn!(
2345 timeout_secs = HEARTBEAT_TIMEOUT_SECS,
2346 "Alpaca heartbeat timeout, reconnecting"
2347 );
2348 break DisconnectReason::HeartbeatTimeout;
2351 }
2352 _ = tx.closed() => {
2353 debug!("Alpaca account_stream consumer dropped, terminating");
2354 let _ = tokio::time::timeout(
2355 Duration::from_secs(WS_CLOSE_TIMEOUT_SECS),
2356 ws.close(None),
2357 ).await;
2358 break 'outer;
2361 }
2362 }
2363 };
2364
2365 disconnect_time =
2370 Some(last_message_time - chrono::Duration::milliseconds(SIGNAL_RECOVERY_LOOKBACK_MS));
2371
2372 let _ =
2374 tokio::time::timeout(Duration::from_secs(WS_CLOSE_TIMEOUT_SECS), ws.close(None)).await;
2375
2376 if tx.is_closed() {
2377 break;
2380 }
2381 if !backoff.wait().await {
2382 error!("Alpaca max reconnect attempts exhausted, stream terminating");
2383 let last_error = match reason {
2386 DisconnectReason::HeartbeatTimeout => {
2387 format!("heartbeat timeout ({HEARTBEAT_TIMEOUT_SECS}s)")
2388 }
2389 DisconnectReason::ServerClose => "WebSocket closed by server".to_string(),
2390 DisconnectReason::Error(e) => e,
2391 DisconnectReason::StreamEnded => "WebSocket stream ended".to_string(),
2392 };
2393 emit_stream_terminated(
2394 &tx,
2395 ExchangeId::AlpacaBroker,
2396 StreamTerminationReason::ReconnectBudgetExhausted {
2397 attempts: backoff.attempts(),
2398 last_error,
2399 },
2400 );
2401 break;
2402 }
2403 }
2404}
2405
2406#[derive(Debug)]
2411enum HandshakeError {
2412 Transport(String),
2415 Auth(String),
2418}
2419
2420async fn connect_and_subscribe(config: &AlpacaConfig) -> Result<WebSocket, UnindexedClientError> {
2424 let url = config.ws_url();
2425 debug!(%url, "Alpaca: connecting to WebSocket");
2426
2427 let mut ws = rustrade_integration::protocol::websocket::connect(url)
2428 .await
2429 .map_err(|e| {
2430 UnindexedClientError::Connectivity(ConnectivityError::Socket(format!(
2431 "WS connect: {e}"
2432 )))
2433 })?;
2434
2435 let result = tokio::time::timeout(
2437 Duration::from_secs(WS_HANDSHAKE_TIMEOUT_SECS),
2438 ws_handshake(&mut ws, config),
2439 )
2440 .await;
2441
2442 match result {
2443 Ok(Ok(())) => Ok(ws),
2444 Ok(Err(HandshakeError::Transport(e))) => {
2445 let _ = ws.close(None).await;
2446 Err(UnindexedClientError::Connectivity(
2447 ConnectivityError::Socket(e),
2448 ))
2449 }
2450 Ok(Err(HandshakeError::Auth(e))) => {
2451 let _ = ws.close(None).await;
2452 Err(UnindexedClientError::Api(ApiError::Unauthenticated(e)))
2453 }
2454 Err(_) => {
2455 let _ = ws.close(None).await;
2456 Err(UnindexedClientError::Connectivity(
2457 ConnectivityError::Timeout,
2458 ))
2459 }
2460 }
2461}
2462
2463async fn ws_handshake(ws: &mut WebSocket, config: &AlpacaConfig) -> Result<(), HandshakeError> {
2471 let auth = serde_json::json!({
2473 "action": "auth",
2474 "key": config.api_key(),
2475 "secret": config.secret_key,
2478 })
2479 .to_string();
2480 ws.send(WsMessage::Text(auth.into()))
2481 .await
2482 .map_err(|e| HandshakeError::Transport(format!("WS auth send: {e}")))?;
2483
2484 loop {
2486 match ws.next().await {
2487 Some(Ok(WsMessage::Text(text))) => {
2488 if let Some(result) = check_auth_response(text.as_str()) {
2489 result?;
2490 break;
2491 }
2492 }
2493 Some(Ok(WsMessage::Binary(bytes))) => {
2494 if let Ok(text) = std::str::from_utf8(&bytes)
2495 && let Some(result) = check_auth_response(text)
2496 {
2497 result?;
2498 break;
2499 }
2500 }
2501 Some(Err(e)) => {
2502 return Err(HandshakeError::Transport(format!(
2503 "WS error during auth: {e}"
2504 )));
2505 }
2506 None => {
2507 return Err(HandshakeError::Transport(
2508 "WS closed before auth response".into(),
2509 ));
2510 }
2511 _ => {} }
2513 }
2514
2515 let sub = serde_json::json!({
2517 "action": "listen",
2518 "data": { "streams": ["trade_updates"] }
2519 })
2520 .to_string();
2521 ws.send(WsMessage::Text(sub.into()))
2522 .await
2523 .map_err(|e| HandshakeError::Transport(format!("WS subscribe send: {e}")))?;
2524
2525 loop {
2527 match ws.next().await {
2528 Some(Ok(WsMessage::Text(text))) => {
2529 if check_listen_ack(text.as_str()) {
2530 break;
2531 }
2532 if let Ok(msg) = serde_json::from_str::<AlpacaStreamMessage<'_>>(text.as_str()) {
2538 if msg.stream == "trade_updates" {
2539 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");
2540 } else {
2541 trace!(stream = %msg.stream, "WS message dropped during listen-ack handshake");
2542 }
2543 } else {
2544 trace!(
2545 bytes = text.len(),
2546 "WS non-stream message dropped during listen-ack handshake"
2547 );
2548 }
2549 }
2550 Some(Ok(WsMessage::Binary(bytes))) => {
2551 if let Ok(text) = std::str::from_utf8(&bytes)
2552 && check_listen_ack(text)
2553 {
2554 break;
2555 }
2556 trace!(
2557 bytes = bytes.len(),
2558 "WS binary message dropped during listen-ack handshake"
2559 );
2560 }
2561 Some(Err(e)) => {
2562 return Err(HandshakeError::Transport(format!(
2563 "WS error during subscribe: {e}"
2564 )));
2565 }
2566 None => {
2567 return Err(HandshakeError::Transport(
2568 "WS closed before subscribe ack".into(),
2569 ));
2570 }
2571 _ => {}
2572 }
2573 }
2574
2575 info!("Alpaca WS authenticated and subscribed to trade_updates");
2576 Ok(())
2577}
2578
2579fn check_auth_response(text: &str) -> Option<Result<(), HandshakeError>> {
2585 let msg = serde_json::from_str::<AlpacaStreamMessage<'_>>(text).ok()?;
2586 if msg.stream != "authorization" {
2587 return None;
2588 }
2589 #[derive(Deserialize)]
2590 struct AuthData<'a> {
2591 status: &'a str,
2592 }
2593 let data = serde_json::from_str::<AuthData<'_>>(msg.data.get()).ok()?;
2594 if data.status == "authorized" {
2595 Some(Ok(()))
2596 } else {
2597 Some(Err(HandshakeError::Auth(format!(
2598 "Alpaca WS auth failed: status={}",
2599 data.status
2600 ))))
2601 }
2602}
2603
2604fn check_listen_ack(text: &str) -> bool {
2608 let Ok(msg) = serde_json::from_str::<AlpacaStreamMessage<'_>>(text) else {
2609 return false;
2610 };
2611 if msg.stream != "listening" {
2612 return false;
2613 }
2614 #[derive(Deserialize)]
2615 struct ListenData<'a> {
2616 #[serde(borrow)]
2617 streams: Vec<&'a str>,
2618 }
2619 let Ok(data) = serde_json::from_str::<ListenData<'_>>(msg.data.get()) else {
2620 return false;
2621 };
2622 data.streams.contains(&"trade_updates")
2623}
2624
2625fn process_ws_text(
2631 text: &str,
2632 tx: &mpsc::UnboundedSender<UnindexedAccountEvent>,
2633 dedup: &SharedDedupCache,
2634 backoff: &mut ExponentialBackoff,
2635) {
2636 let msg: AlpacaStreamMessage<'_> = match serde_json::from_str(text) {
2637 Ok(m) => m,
2638 Err(e) => {
2639 trace!(
2640 %e,
2641 raw = ?&text[..text.len().min(200)],
2642 "Alpaca WS: skipped non-JSON message"
2643 );
2644 return;
2645 }
2646 };
2647
2648 backoff.reset();
2651
2652 match msg.stream.as_str() {
2653 "trade_updates" => {
2654 let update: AlpacaTradeUpdate<'_> = match serde_json::from_str(msg.data.get()) {
2655 Ok(u) => u,
2656 Err(e) => {
2657 warn!(
2662 %e,
2663 raw = ?&msg.data.get()[..msg.data.get().len().min(200)],
2664 "Alpaca WS trade_updates: failed to deserialize event — event dropped"
2665 );
2666 return;
2667 }
2668 };
2669
2670 if is_fill_event(&update) {
2674 let key = early_dedup_key(&update);
2675 if is_duplicate(dedup, &key) {
2676 trace!("Alpaca WS: skipping duplicate fill event (early check)");
2677 return;
2678 }
2679 }
2680
2681 for event in convert_trade_update(update).into_iter().flatten() {
2682 let _ = tx.send(event);
2685 }
2686 }
2687 "authorization" | "listening" => {
2688 trace!(stream = %msg.stream, "Alpaca WS: auth/listen ack received during stream");
2691 }
2692 other => {
2693 trace!(%other, "Alpaca WS: ignoring unknown stream type");
2694 }
2695 }
2696}
2697
2698fn fill_dedup_key_from_event(event: &UnindexedAccountEvent) -> Option<&SmolStr> {
2705 match &event.kind {
2706 AccountEventKind::Trade(trade) => Some(&trade.id.0),
2708 _ => None,
2709 }
2710}
2711
2712#[inline]
2716fn is_fill_event(update: &AlpacaTradeUpdate<'_>) -> bool {
2717 matches!(update.event.as_str(), "fill" | "partial_fill")
2718}
2719
2720fn early_dedup_key(update: &AlpacaTradeUpdate<'_>) -> SmolStr {
2730 let filled_qty = update.order.filled_qty.unwrap_or("0");
2731 let qty = Decimal::from_str(filled_qty).unwrap_or(Decimal::ZERO);
2734 format_smolstr!("{}:{}", update.order.id, qty.normalize())
2735}
2736
2737async fn recover_fills(
2743 http: &reqwest::Client,
2744 rate_limiter: &RateLimitTracker,
2745 instruments: &[InstrumentNameExchange],
2746 base: &str,
2747 after: &str,
2748 tx: &mpsc::UnboundedSender<UnindexedAccountEvent>,
2749 dedup: &SharedDedupCache,
2750) {
2751 info!(%after, instruments = instruments.len(), "Alpaca recovering fills after reconnect");
2754 let instrument_set: fnv::FnvHashSet<&str> = if instruments.is_empty() {
2755 fnv::FnvHashSet::default()
2756 } else {
2757 instruments.iter().map(|i| i.name().as_str()).collect()
2758 };
2759
2760 let page = match paginate_activities(http, rate_limiter, base, after).await {
2761 Ok(p) => p,
2762 Err(e) => {
2763 error!(%e, "Alpaca fill recovery: REST request failed");
2764 return;
2765 }
2766 };
2767
2768 if page.truncated {
2772 error!(
2773 max_pages = MAX_ACTIVITY_PAGES,
2774 "Alpaca fill recovery: max page limit reached, truncating — \
2775 fills from this outage are permanently lost. Manual reconciliation required."
2776 );
2777 }
2778
2779 let activities = page.activities;
2780
2781 let mut recovered = 0u32;
2782 let mut duplicates = 0u32;
2783
2784 let mut cumulative_qty: FnvHashMap<&str, Decimal> = FnvHashMap::default();
2801
2802 for activity in &activities {
2803 if !instrument_set.is_empty() && !instrument_set.contains(activity.symbol.as_str()) {
2804 continue;
2808 }
2809
2810 let exec_qty = Decimal::from_str(&activity.qty).unwrap_or(Decimal::ZERO);
2825 let cum = cumulative_qty
2826 .entry(activity.order_id.as_str())
2827 .or_default();
2828 *cum += exec_qty;
2829 let cumulative = *cum;
2830
2831 let mut trade = match convert_activity_to_trade(activity) {
2832 Some(t) => t,
2833 None => {
2834 warn!(id = %activity.id, symbol = %activity.symbol, "Alpaca: skipping activity with unparseable fields");
2835 continue; }
2837 };
2838
2839 let key_cumulative = trade.order_filled_quantity.unwrap_or(cumulative);
2853 trade.id = TradeId(format_smolstr!(
2854 "{}:{}",
2855 activity.order_id,
2856 key_cumulative.normalize()
2857 ));
2858
2859 let event =
2860 UnindexedAccountEvent::new(ExchangeId::AlpacaBroker, AccountEventKind::Trade(trade));
2861
2862 if fill_dedup_key_from_event(&event).is_some_and(|k| is_duplicate(dedup, k)) {
2864 duplicates += 1;
2865 continue;
2866 }
2867 if tx.send(event).is_err() {
2868 debug!("Alpaca fill recovery: consumer dropped during recovery");
2869 return;
2870 }
2871 recovered += 1;
2872 }
2873
2874 info!(recovered, duplicates, "Alpaca fill recovery complete");
2875}
2876
2877fn convert_account_to_balances(
2890 account: &AlpacaAccount,
2891 assets: &[AssetNameExchange],
2892) -> Vec<AssetBalance<AssetNameExchange>> {
2893 let usd_entry = assets
2896 .iter()
2897 .find(|a| a.name().as_str().eq_ignore_ascii_case("usd"));
2898
2899 if !assets.is_empty() && usd_entry.is_none() {
2901 return Vec::new();
2902 }
2903
2904 let usd_name = usd_entry
2905 .cloned()
2906 .unwrap_or_else(|| AssetNameExchange::new("usd"));
2907
2908 let total = Decimal::from_str(&account.equity).unwrap_or(Decimal::ZERO);
2909 let free = account
2910 .options_buying_power
2911 .as_deref()
2912 .and_then(|s| Decimal::from_str(s).ok())
2913 .filter(|d| !d.is_zero())
2918 .unwrap_or_else(|| Decimal::from_str(&account.buying_power).unwrap_or(Decimal::ZERO));
2919
2920 vec![AssetBalance::new(
2921 usd_name,
2922 Balance::new(total, free),
2923 Utc::now(),
2924 )]
2925}
2926
2927fn convert_positions_to_balances(
2938 positions: &[AlpacaPosition],
2939 assets: &[AssetNameExchange],
2940) -> Vec<AssetBalance<AssetNameExchange>> {
2941 let now = Utc::now();
2942 positions
2943 .iter()
2944 .filter(|p| p.asset_class.eq_ignore_ascii_case("crypto"))
2945 .filter_map(|p| {
2946 let base = p
2949 .symbol
2950 .split('/')
2951 .next()
2952 .map(|s| s.to_ascii_lowercase())
2953 .unwrap_or_else(|| p.symbol.to_ascii_lowercase());
2954
2955 if !assets.is_empty()
2957 && !assets
2958 .iter()
2959 .any(|a| a.name().as_str().eq_ignore_ascii_case(&base))
2960 {
2961 return None;
2962 }
2963
2964 let total = Decimal::from_str(&p.qty).unwrap_or(Decimal::ZERO);
2967 let free = Decimal::from_str(&p.qty_available).unwrap_or(Decimal::ZERO);
2968 let asset_name = AssetNameExchange::new(base);
2969 Some(AssetBalance::new(
2970 asset_name,
2971 Balance::new(total, free),
2972 now,
2973 ))
2974 })
2975 .collect()
2976}
2977
2978fn build_instrument_snapshots(
2991 orders: Vec<AlpacaOrderResponse>,
2992 instruments: &[InstrumentNameExchange],
2993) -> Vec<InstrumentAccountSnapshot<ExchangeId, AssetNameExchange, InstrumentNameExchange>> {
2994 struct Listing {
2996 orders: Vec<UnindexedOrderSnapshot>,
2997 complete: bool,
2998 }
2999
3000 let mut by_symbol: IndexMap<SmolStr, Listing> = IndexMap::new();
3002
3003 for order in orders {
3004 let listing = by_symbol
3005 .entry(SmolStr::new(&order.symbol))
3006 .or_insert_with(|| Listing {
3007 orders: Vec::new(),
3008 complete: true,
3009 });
3010 match convert_open_order(&order) {
3011 Some(converted) => listing.orders.push(converted.into()),
3012 None => listing.complete = false,
3013 }
3014 }
3015
3016 let snapshot = |instrument, listing: Listing| {
3017 InstrumentAccountSnapshot::new(instrument, listing.orders, listing.complete, None, None)
3018 };
3019
3020 if instruments.is_empty() {
3022 by_symbol
3023 .into_iter()
3024 .map(|(sym, listing)| snapshot(InstrumentNameExchange::new(sym), listing))
3025 .collect()
3026 } else {
3027 instruments
3028 .iter()
3029 .map(|inst| {
3030 let listing = by_symbol
3033 .swap_remove(inst.name().as_str())
3034 .unwrap_or(Listing {
3035 orders: Vec::new(),
3036 complete: true,
3037 });
3038 snapshot(inst.clone(), listing)
3039 })
3040 .collect()
3041 }
3042}
3043
3044fn convert_open_order(
3054 o: &AlpacaOrderResponse,
3055) -> Option<Order<ExchangeId, InstrumentNameExchange, Open>> {
3056 let converted = convert_representable_open_order(o);
3057 if converted.is_none() {
3058 warn!(
3059 order_id = %o.id,
3060 symbol = %o.symbol,
3061 qty = ?o.qty,
3062 side = %o.side,
3063 order_type = %o.order_type,
3064 "Alpaca open order cannot be represented - leaving it out"
3065 );
3066 }
3067 converted
3068}
3069
3070fn alpaca_cid(client_order_id: Option<&str>, order_id: &str) -> ClientOrderId {
3076 ClientOrderId::new(client_order_id.unwrap_or(order_id))
3077}
3078
3079fn convert_representable_open_order(
3081 o: &AlpacaOrderResponse,
3082) -> Option<Order<ExchangeId, InstrumentNameExchange, Open>> {
3083 let order_id = OrderId(SmolStr::new(&o.id));
3084 let cid = alpaca_cid(o.client_order_id.as_deref(), &o.id);
3085
3086 let instrument = InstrumentNameExchange::new(&o.symbol);
3087 let side = parse_side(&o.side)?;
3088 let quantity = Decimal::from_str(o.qty.as_deref().unwrap_or("0")).ok()?;
3089 if quantity.is_zero() {
3093 return None;
3094 }
3095 let price = o
3096 .limit_price
3097 .as_deref()
3098 .and_then(|s| Decimal::from_str(s).ok());
3099 let filled_qty = Decimal::from_str(&o.filled_qty).unwrap_or(Decimal::ZERO);
3100 let kind = parse_order_kind(
3101 &o.order_type,
3102 o.stop_price.as_deref(),
3103 o.trail_percent.as_deref(),
3104 o.trail_price.as_deref(),
3105 )?;
3106 let time_in_force = parse_time_in_force(&o.time_in_force);
3107 let time_exchange = order_state_time(o);
3108
3109 Some(Order {
3110 key: OrderKey::new(
3111 ExchangeId::AlpacaBroker,
3112 instrument,
3113 StrategyId::unknown(),
3115 cid,
3116 ),
3117 side,
3118 price,
3119 quantity,
3120 kind,
3121 time_in_force,
3122 state: Open::new(VenueOrderId::Assigned(order_id), time_exchange, filled_qty),
3123 })
3124}
3125
3126fn convert_activity_to_trade(
3128 a: &AlpacaActivity,
3129) -> Option<Trade<AssetNameExchange, InstrumentNameExchange>> {
3130 let trade_id = TradeId::new(&a.id);
3131 let order_id = OrderId(SmolStr::new(&a.order_id));
3132 let instrument = InstrumentNameExchange::new(&a.symbol);
3133 let side = parse_side(&a.side)?;
3134 let price = Decimal::from_str(&a.price).ok()?;
3135 let quantity = Decimal::from_str(&a.qty).ok()?;
3136 let order_filled_quantity = a.cum_qty.as_deref().and_then(|s| Decimal::from_str(s).ok());
3139 let time_exchange = parse_timestamp(&a.transaction_time).unwrap_or_else(|| {
3140 warn!(id = %a.id, "Alpaca activity: unparseable transaction_time, using now");
3141 Utc::now()
3142 });
3143
3144 Some(Trade::new(
3149 trade_id,
3150 order_id,
3151 instrument,
3152 StrategyId::unknown(),
3153 time_exchange,
3154 side,
3155 price,
3156 quantity,
3157 order_filled_quantity,
3158 AssetFees::new(
3159 AssetNameExchange::from("USD"),
3160 Decimal::ZERO,
3161 Some(Decimal::ZERO),
3162 ),
3163 ))
3164}
3165
3166fn ws_order_snapshot(
3176 order: &AlpacaOrderWs<'_>,
3177 instrument: InstrumentNameExchange,
3178 cid: ClientOrderId,
3179 order_id: OrderId,
3180 time_exchange: DateTime<Utc>,
3181) -> Option<UnindexedAccountEvent> {
3182 let side = parse_side(&order.side)?;
3183 let quantity = Decimal::from_str(order.qty.unwrap_or("0")).unwrap_or(Decimal::ZERO);
3184 if quantity.is_zero() {
3185 trace!(order_id = %order.id, "Alpaca WS: skipping notional order snapshot (qty=None)");
3186 return None;
3187 }
3188 let price = order.limit_price.and_then(|s| Decimal::from_str(s).ok());
3189 let filled_qty = Decimal::from_str(order.filled_qty.unwrap_or("0")).unwrap_or(Decimal::ZERO);
3190 let kind = parse_order_kind(
3191 &order.order_type,
3192 order.stop_price,
3193 order.trail_percent,
3194 order.trail_price,
3195 )?;
3196 let time_in_force = parse_time_in_force(&order.time_in_force);
3197
3198 let order_snapshot = crate::order::Order {
3199 key: OrderKey::new(
3200 ExchangeId::AlpacaBroker,
3201 instrument,
3202 StrategyId::unknown(),
3203 cid,
3204 ),
3205 side,
3206 price,
3207 quantity,
3208 kind,
3209 time_in_force,
3210 state: OrderState::active(Open::new(
3211 VenueOrderId::Assigned(order_id),
3212 time_exchange,
3213 filled_qty,
3214 )),
3215 };
3216 Some(UnindexedAccountEvent::new(
3217 ExchangeId::AlpacaBroker,
3218 AccountEventKind::OrderSnapshot(rustrade_integration::collection::snapshot::Snapshot(
3219 order_snapshot,
3220 )),
3221 ))
3222}
3223
3224fn ws_fill_order_is_live(status: &str) -> bool {
3234 matches!(status, "partially_filled" | "filled")
3235}
3236
3237fn convert_trade_update(update: AlpacaTradeUpdate<'_>) -> [Option<UnindexedAccountEvent>; 2] {
3247 let event_str = update.event.as_str();
3250 if !matches!(
3251 event_str,
3252 "fill"
3253 | "partial_fill"
3254 | "new"
3255 | "accepted"
3256 | "pending_new"
3257 | "canceled"
3258 | "expired"
3259 | "replaced"
3260 | "done_for_day"
3261 | "rejected"
3262 ) {
3263 trace!(event = %event_str, "Alpaca WS: ignoring trade_updates event type");
3264 return [None, None];
3265 }
3266
3267 let order = &update.order;
3268 let instrument = InstrumentNameExchange::new(&*order.symbol);
3269 let order_id = OrderId(order.id.clone());
3270 let cid = alpaca_cid(order.client_order_id.as_deref(), &order.id);
3271
3272 match event_str {
3273 "fill" | "partial_fill" => {
3274 let (Some(price), Some(quantity), Some(side)) = (
3276 update.price.and_then(|s| Decimal::from_str(s).ok()),
3277 update.qty.and_then(|s| Decimal::from_str(s).ok()),
3278 parse_side(&order.side),
3279 ) else {
3280 return [None, None];
3281 };
3282 let time_exchange = update
3283 .timestamp
3284 .and_then(parse_timestamp)
3285 .unwrap_or_else(Utc::now);
3286
3287 let cum_qty = Decimal::from_str(order.filled_qty.unwrap_or("0"))
3297 .inspect_err(|e| {
3298 warn!(
3299 order_id = %order.id,
3300 filled_qty = ?order.filled_qty,
3301 %e,
3302 "Alpaca WS: failed to parse filled_qty — dedup key will use 0, \
3303 a second malformed fill on the same order would be deduplicated away"
3304 );
3305 })
3306 .unwrap_or(Decimal::ZERO);
3307 let trade_id = TradeId(format_smolstr!("{}:{}", order.id, cum_qty.normalize()));
3308
3309 let trade = Trade::new(
3313 trade_id,
3314 order_id.clone(),
3315 instrument.clone(),
3316 StrategyId::unknown(),
3317 time_exchange,
3318 side,
3319 price,
3320 quantity,
3321 Some(cum_qty),
3323 AssetFees::new(
3324 AssetNameExchange::from("USD"),
3325 Decimal::ZERO,
3326 Some(Decimal::ZERO),
3327 ),
3328 );
3329 let trade_event = UnindexedAccountEvent::new(
3330 ExchangeId::AlpacaBroker,
3331 AccountEventKind::Trade(trade),
3332 );
3333
3334 let snapshot_event = if ws_fill_order_is_live(&order.status) {
3338 ws_order_snapshot(order, instrument, cid, order_id, time_exchange)
3339 } else {
3340 trace!(
3341 order_id = %order.id,
3342 status = %order.status,
3343 "Alpaca WS: fill for an order the exchange no longer reports as working — \
3344 emitting the execution without an order snapshot"
3345 );
3346 None
3347 };
3348
3349 [Some(trade_event), snapshot_event]
3350 }
3351
3352 "new" | "accepted" | "pending_new" => {
3353 let time_exchange = update
3355 .timestamp
3356 .and_then(parse_timestamp)
3357 .unwrap_or_else(Utc::now);
3358
3359 [
3360 ws_order_snapshot(order, instrument, cid, order_id, time_exchange),
3361 None,
3362 ]
3363 }
3364
3365 "canceled" | "expired" | "replaced" | "done_for_day" => {
3366 let time_exchange = update
3374 .timestamp
3375 .and_then(parse_timestamp)
3376 .unwrap_or_else(Utc::now);
3377 let filled_qty =
3378 Decimal::from_str(order.filled_qty.unwrap_or("0")).unwrap_or(Decimal::ZERO);
3379 let cancelled = Cancelled::new(order_id, time_exchange, filled_qty);
3380 let response = crate::order::request::OrderResponseCancel {
3381 key: OrderKey::new(
3382 ExchangeId::AlpacaBroker,
3383 instrument,
3384 StrategyId::unknown(),
3385 cid,
3386 ),
3387 state: Ok(cancelled),
3388 };
3389 [
3390 Some(UnindexedAccountEvent::new(
3391 ExchangeId::AlpacaBroker,
3392 AccountEventKind::OrderCancelled(response),
3393 )),
3394 None,
3395 ]
3396 }
3397
3398 "rejected" => {
3399 let response = crate::order::request::OrderResponseCancel {
3400 key: OrderKey::new(
3401 ExchangeId::AlpacaBroker,
3402 instrument,
3403 StrategyId::unknown(),
3404 cid,
3405 ),
3406 state: Err(UnindexedOrderError::Rejected(ApiError::OrderRejected(
3407 format!("order rejected: status={}", order.status),
3408 ))),
3409 };
3410 [
3411 Some(UnindexedAccountEvent::new(
3412 ExchangeId::AlpacaBroker,
3413 AccountEventKind::OrderCancelled(response),
3414 )),
3415 None,
3416 ]
3417 }
3418
3419 _ => unreachable!("convert_trade_update: unrecognised event passed early-return guard"),
3422 }
3423}
3424
3425fn parse_side(s: &str) -> Option<Side> {
3430 match s {
3431 "buy" | "Buy" | "BUY" => Some(Side::Buy),
3432 "sell" | "Sell" | "SELL" => Some(Side::Sell),
3433 other => {
3434 trace!(%other, "Alpaca: unknown order side");
3435 None
3436 }
3437 }
3438}
3439
3440fn parse_order_kind(
3441 order_type: &str,
3442 stop_price: Option<&str>,
3443 trail_percent: Option<&str>,
3444 trail_price: Option<&str>,
3445) -> Option<OrderKind> {
3446 match order_type {
3447 "market" | "Market" => Some(OrderKind::Market),
3448 "limit" | "Limit" => Some(OrderKind::Limit),
3449 "stop" | "Stop" => {
3450 let trigger_price = stop_price.and_then(|s| Decimal::from_str(s).ok())?;
3451 Some(OrderKind::Stop { trigger_price })
3452 }
3453 "stop_limit" | "Stop_limit" => {
3454 let trigger_price = stop_price.and_then(|s| Decimal::from_str(s).ok())?;
3455 Some(OrderKind::StopLimit { trigger_price })
3456 }
3457 "trailing_stop" | "Trailing_stop" => {
3458 if let Some(pct) = trail_percent.and_then(|s| Decimal::from_str(s).ok()) {
3460 Some(OrderKind::TrailingStop {
3461 offset: pct,
3462 offset_type: TrailingOffsetType::Percentage,
3463 })
3464 } else if let Some(price) = trail_price.and_then(|s| Decimal::from_str(s).ok()) {
3465 Some(OrderKind::TrailingStop {
3466 offset: price,
3467 offset_type: TrailingOffsetType::Absolute,
3468 })
3469 } else {
3470 trace!("Alpaca: trailing_stop missing trail_percent and trail_price");
3471 None
3472 }
3473 }
3474 other => {
3475 trace!(%other, "Alpaca: unsupported order type, skipping");
3476 None
3477 }
3478 }
3479}
3480
3481fn parse_time_in_force(s: &str) -> TimeInForce {
3482 match s {
3483 "gtc" | "GTC" => TimeInForce::GoodUntilCancelled { post_only: false },
3484 "day" | "DAY" => TimeInForce::GoodUntilEndOfDay,
3485 "fok" | "FOK" => TimeInForce::FillOrKill,
3486 "ioc" | "IOC" => TimeInForce::ImmediateOrCancel,
3487 other => {
3488 warn!(%other, "Alpaca: unknown time_in_force, defaulting to GoodUntilEndOfDay");
3489 TimeInForce::GoodUntilEndOfDay
3490 }
3491 }
3492}
3493
3494fn order_state_time(order: &AlpacaOrderResponse) -> DateTime<Utc> {
3504 order
3505 .updated_at
3506 .as_deref()
3507 .and_then(parse_timestamp)
3508 .or_else(|| parse_timestamp(&order.created_at))
3509 .unwrap_or_else(Utc::now)
3510}
3511
3512fn parse_timestamp(s: &str) -> Option<DateTime<Utc>> {
3513 DateTime::parse_from_rfc3339(s)
3514 .ok()
3515 .map(|dt| dt.with_timezone(&Utc))
3516}
3517
3518fn map_side(side: Side) -> &'static str {
3519 match side {
3520 Side::Buy => "buy",
3521 Side::Sell => "sell",
3522 }
3523}
3524
3525fn map_order_kind(kind: OrderKind) -> Option<&'static str> {
3526 match kind {
3527 OrderKind::Market => Some("market"),
3528 OrderKind::Limit => Some("limit"),
3529 OrderKind::Stop { .. } => Some("stop"),
3530 OrderKind::StopLimit { .. } => Some("stop_limit"),
3531 OrderKind::TrailingStop { .. } => Some("trailing_stop"),
3532 OrderKind::TakeProfit { .. }
3534 | OrderKind::TakeProfitLimit { .. }
3535 | OrderKind::TrailingStopLimit { .. } => None,
3536 }
3537}
3538
3539fn map_time_in_force(tif: TimeInForce) -> Result<&'static str, &'static str> {
3548 match tif {
3549 TimeInForce::GoodUntilCancelled { post_only } => {
3550 if post_only {
3551 return Err("Alpaca does not support post_only orders");
3552 }
3553 Ok("gtc")
3554 }
3555 TimeInForce::GoodUntilEndOfDay => Ok("day"),
3556 TimeInForce::FillOrKill => Ok("fok"),
3557 TimeInForce::ImmediateOrCancel => Ok("ioc"),
3558 TimeInForce::AtOpen => Ok("opg"),
3559 TimeInForce::AtClose => Ok("cls"),
3560 TimeInForce::GoodTillDate { .. } => {
3564 Err("Alpaca GoodTillDate is not yet wired through this client")
3565 }
3566 }
3567}
3568
3569fn is_options_or_equity_symbol(symbol: &str) -> bool {
3580 !symbol.contains('/')
3581}
3582
3583fn map_position_intent(side: Side, reduce_only: bool) -> AlpacaPositionIntent {
3592 match (reduce_only, side) {
3593 (false, Side::Buy) => AlpacaPositionIntent::BuyToOpen,
3594 (false, Side::Sell) => AlpacaPositionIntent::SellToOpen,
3595 (true, Side::Buy) => AlpacaPositionIntent::BuyToClose,
3596 (true, Side::Sell) => AlpacaPositionIntent::SellToClose,
3597 }
3598}
3599
3600fn parse_api_error(status: reqwest::StatusCode, message: &str) -> crate::error::UnindexedApiError {
3605 if status == reqwest::StatusCode::TOO_MANY_REQUESTS {
3607 return ApiError::RateLimit;
3608 }
3609
3610 let lower = message.to_ascii_lowercase();
3612 match status.as_u16() {
3613 422 if lower.contains("already") => ApiError::OrderAlreadyCancelled,
3617 422 if lower.contains("insufficient") => {
3622 ApiError::BalanceInsufficient(AssetNameExchange::new("usd"), message.to_owned())
3623 }
3624 401 => ApiError::Unauthenticated(format!("unauthorized: {message}")),
3625 403 => ApiError::Unauthenticated(format!("forbidden: {message}")),
3626 404 => ApiError::OrderRejected(format!("order not found: {message}")),
3627 _ => ApiError::OrderRejected(message.to_owned()),
3628 }
3629}
3630
3631fn parse_order_error(status: reqwest::StatusCode, message: &str) -> UnindexedOrderError {
3633 UnindexedOrderError::Rejected(parse_api_error(status, message))
3634}
3635
3636fn connectivity_err(msg: impl Into<String>) -> UnindexedClientError {
3637 UnindexedClientError::Connectivity(ConnectivityError::Socket(msg.into()))
3638}
3639
3640impl BracketOrderClient for AlpacaClient {
3645 async fn open_bracket_order(
3646 &self,
3647 request: UnifiedBracketOrderRequest<ExchangeId, &InstrumentNameExchange>,
3648 ) -> UnifiedBracketOrderResult {
3649 let alpaca_request = AlpacaBracketOrderRequest {
3650 instrument: request.key.instrument.clone(),
3651 strategy: request.key.strategy.clone(),
3652 cid: request.key.cid.clone(),
3653 side: request.state.side,
3654 quantity: request.state.quantity,
3655 entry_price: request.state.entry_price,
3656 take_profit_price: request.state.take_profit_price,
3657 stop_loss_price: request.state.stop_loss_price,
3658 stop_loss_limit_price: request.state.stop_loss_limit_price,
3659 time_in_force: request.state.time_in_force,
3660 };
3661
3662 let result = self.open_bracket_order(alpaca_request).await;
3663
3664 UnifiedBracketOrderResult::parent_only(result.parent)
3665 }
3666}
3667
3668#[cfg(test)]
3673#[allow(clippy::unwrap_used, clippy::expect_used)] mod tests {
3675 use super::*;
3676
3677 #[test]
3678 fn test_alpaca_config_new_uses_paper_trading_by_default() {
3679 let cfg = AlpacaConfig::new("my_key".into(), "my_secret".into());
3680 assert!(cfg.paper);
3681 }
3682
3683 #[test]
3684 fn test_alpaca_config_debug_redacts_credentials() {
3685 let cfg = AlpacaConfig::new("my_key".into(), "my_secret".into());
3686 let debug = format!("{cfg:?}");
3687 assert!(!debug.contains("my_key"), "api_key should be redacted");
3688 assert!(
3689 !debug.contains("my_secret"),
3690 "secret_key should be redacted"
3691 );
3692 assert!(debug.contains("paper: true"));
3693 }
3694
3695 #[test]
3696 fn test_alpaca_config_deserialize_omitted_paper_defaults_to_paper() {
3697 let cfg: AlpacaConfig = serde_json::from_str(
3698 r#"{
3699 "api_key": "my_key",
3700 "secret_key": "my_secret"
3701 }"#,
3702 )
3703 .unwrap();
3704
3705 assert!(cfg.paper);
3706 assert_eq!(cfg.rest_base_url(), "https://paper-api.alpaca.markets");
3707 }
3708
3709 #[test]
3710 fn test_alpaca_config_deserialize_paper_true() {
3711 let cfg: AlpacaConfig = serde_json::from_str(
3712 r#"{
3713 "api_key": "my_key",
3714 "secret_key": "my_secret",
3715 "paper": true
3716 }"#,
3717 )
3718 .unwrap();
3719
3720 assert!(cfg.paper);
3721 assert_eq!(cfg.rest_base_url(), "https://paper-api.alpaca.markets");
3722 }
3723
3724 #[test]
3725 fn test_alpaca_config_deserialize_paper_false() {
3726 let cfg: AlpacaConfig = serde_json::from_str(
3727 r#"{
3728 "api_key": "my_key",
3729 "secret_key": "my_secret",
3730 "paper": false
3731 }"#,
3732 )
3733 .unwrap();
3734
3735 assert!(!cfg.paper);
3736 assert_eq!(cfg.rest_base_url(), "https://api.alpaca.markets");
3737 }
3738
3739 #[test]
3740 fn test_alpaca_config_urls() {
3741 let paper = AlpacaConfig::paper("k".into(), "s".into());
3742 assert!(paper.rest_base_url().contains("paper-api"));
3743 assert!(paper.ws_url().contains("paper-api"));
3744
3745 let live = AlpacaConfig::production("k".into(), "s".into());
3746 assert!(!live.rest_base_url().contains("paper-api"));
3747 assert!(!live.ws_url().contains("paper-api"));
3748 }
3749
3750 #[test]
3751 #[serial_test::serial]
3752 fn test_alpaca_config_from_env_defaults_to_paper_trading() {
3753 temp_env::with_vars(
3754 [
3755 ("ALPACA_API_KEY", Some("my_key")),
3756 ("ALPACA_SECRET_KEY", Some("my_secret")),
3757 ("ALPACA_PAPER", None),
3758 ],
3759 || {
3760 let cfg = AlpacaConfig::from_env().unwrap();
3761 assert!(cfg.paper);
3762 assert_eq!(cfg.rest_base_url(), "https://paper-api.alpaca.markets");
3763 },
3764 );
3765 }
3766
3767 #[test]
3768 #[serial_test::serial]
3769 fn test_alpaca_config_from_env_accepts_explicit_production() {
3770 temp_env::with_vars(
3771 [
3772 ("ALPACA_API_KEY", Some("my_key")),
3773 ("ALPACA_SECRET_KEY", Some("my_secret")),
3774 ("ALPACA_PAPER", Some("false")),
3775 ],
3776 || {
3777 let cfg = AlpacaConfig::from_env().unwrap();
3778 assert!(!cfg.paper);
3779 assert_eq!(cfg.rest_base_url(), "https://api.alpaca.markets");
3780 },
3781 );
3782 }
3783
3784 #[test]
3785 #[serial_test::serial]
3786 fn test_alpaca_config_from_env_rejects_invalid_paper() {
3787 temp_env::with_vars(
3788 [
3789 ("ALPACA_API_KEY", Some("my_key")),
3790 ("ALPACA_SECRET_KEY", Some("my_secret")),
3791 ("ALPACA_PAPER", Some("maybe")),
3792 ],
3793 || {
3794 let err = AlpacaConfig::from_env().unwrap_err();
3795 assert!(matches!(err, AlpacaConfigError::InvalidPaper(value) if value == "maybe"));
3796 },
3797 );
3798 }
3799
3800 #[test]
3801 #[serial_test::serial]
3802 fn test_alpaca_config_from_env_requires_credentials() {
3803 temp_env::with_vars(
3804 [
3805 ("ALPACA_API_KEY", None),
3806 ("ALPACA_SECRET_KEY", Some("my_secret")),
3807 ("ALPACA_PAPER", None),
3808 ],
3809 || {
3810 let err = AlpacaConfig::from_env().unwrap_err();
3811 assert!(matches!(err, AlpacaConfigError::MissingApiKey));
3812 },
3813 );
3814 }
3815
3816 #[test]
3817 fn test_parse_side() {
3818 assert_eq!(parse_side("buy"), Some(Side::Buy));
3819 assert_eq!(parse_side("sell"), Some(Side::Sell));
3820 assert_eq!(parse_side("Buy"), Some(Side::Buy));
3821 assert_eq!(parse_side("BUY"), Some(Side::Buy));
3822 assert_eq!(parse_side("unknown"), None);
3823 }
3824
3825 #[test]
3826 fn test_parse_order_kind() {
3827 assert_eq!(
3829 parse_order_kind("market", None, None, None),
3830 Some(OrderKind::Market)
3831 );
3832 assert_eq!(
3833 parse_order_kind("limit", None, None, None),
3834 Some(OrderKind::Limit)
3835 );
3836
3837 assert_eq!(
3839 parse_order_kind("stop", Some("150.00"), None, None),
3840 Some(OrderKind::Stop {
3841 trigger_price: Decimal::from_str("150.00").unwrap()
3842 })
3843 );
3844 assert_eq!(parse_order_kind("stop", None, None, None), None);
3845
3846 assert_eq!(
3848 parse_order_kind("stop_limit", Some("145.00"), None, None),
3849 Some(OrderKind::StopLimit {
3850 trigger_price: Decimal::from_str("145.00").unwrap()
3851 })
3852 );
3853
3854 assert_eq!(
3856 parse_order_kind("trailing_stop", None, Some("5.0"), None),
3857 Some(OrderKind::TrailingStop {
3858 offset: Decimal::from_str("5.0").unwrap(),
3859 offset_type: TrailingOffsetType::Percentage,
3860 })
3861 );
3862
3863 assert_eq!(
3865 parse_order_kind("trailing_stop", None, None, Some("2.50")),
3866 Some(OrderKind::TrailingStop {
3867 offset: Decimal::from_str("2.50").unwrap(),
3868 offset_type: TrailingOffsetType::Absolute,
3869 })
3870 );
3871
3872 assert_eq!(parse_order_kind("trailing_stop", None, None, None), None);
3874
3875 assert_eq!(parse_order_kind("unknown", None, None, None), None);
3877 }
3878
3879 #[test]
3880 fn test_map_order_kind() {
3881 assert_eq!(map_order_kind(OrderKind::Market), Some("market"));
3882 assert_eq!(map_order_kind(OrderKind::Limit), Some("limit"));
3883 assert_eq!(
3884 map_order_kind(OrderKind::Stop {
3885 trigger_price: Decimal::from_str("150.00").unwrap()
3886 }),
3887 Some("stop")
3888 );
3889 assert_eq!(
3890 map_order_kind(OrderKind::StopLimit {
3891 trigger_price: Decimal::from_str("145.00").unwrap()
3892 }),
3893 Some("stop_limit")
3894 );
3895 assert_eq!(
3896 map_order_kind(OrderKind::TrailingStop {
3897 offset: Decimal::from_str("5.0").unwrap(),
3898 offset_type: TrailingOffsetType::Percentage,
3899 }),
3900 Some("trailing_stop")
3901 );
3902 assert_eq!(
3903 map_order_kind(OrderKind::TrailingStop {
3904 offset: Decimal::from_str("2.50").unwrap(),
3905 offset_type: TrailingOffsetType::Absolute,
3906 }),
3907 Some("trailing_stop")
3908 );
3909 assert_eq!(
3911 map_order_kind(OrderKind::TrailingStopLimit {
3912 offset: Decimal::from_str("5.0").unwrap(),
3913 offset_type: TrailingOffsetType::Percentage,
3914 limit_offset: Decimal::from_str("1.0").unwrap(),
3915 }),
3916 None
3917 );
3918 assert_eq!(
3920 map_order_kind(OrderKind::TakeProfit {
3921 trigger_price: Decimal::from_str("160.00").unwrap()
3922 }),
3923 None
3924 );
3925 assert_eq!(
3926 map_order_kind(OrderKind::TakeProfitLimit {
3927 trigger_price: Decimal::from_str("160.00").unwrap()
3928 }),
3929 None
3930 );
3931 }
3932
3933 #[test]
3934 fn test_map_time_in_force_roundtrip() {
3935 assert_eq!(
3936 map_time_in_force(TimeInForce::GoodUntilCancelled { post_only: false }),
3937 Ok("gtc")
3938 );
3939 assert_eq!(map_time_in_force(TimeInForce::GoodUntilEndOfDay), Ok("day"));
3940 assert_eq!(map_time_in_force(TimeInForce::FillOrKill), Ok("fok"));
3941 assert_eq!(map_time_in_force(TimeInForce::ImmediateOrCancel), Ok("ioc"));
3942 }
3943
3944 #[test]
3945 fn test_map_time_in_force_rejects_post_only() {
3946 let result = map_time_in_force(TimeInForce::GoodUntilCancelled { post_only: true });
3947 assert!(result.is_err(), "post_only must be rejected");
3948 assert!(result.unwrap_err().contains("post_only"));
3949 }
3950
3951 #[test]
3956 fn test_bracket_order_serializes_with_stop_loss_stop_order() {
3957 let body = AlpacaOrderRequest {
3959 symbol: "AAPL",
3960 qty: "10".to_string(),
3961 side: "buy",
3962 order_type: "limit",
3963 time_in_force: "gtc",
3964 limit_price: Some("150.00".to_string()),
3965 stop_price: None,
3966 trail_percent: None,
3967 trail_price: None,
3968 client_order_id: Some("bracket-001"),
3969 position_intent: Some(AlpacaPositionIntent::BuyToOpen),
3970 order_class: Some("bracket"),
3971 take_profit: Some(TakeProfitParams {
3972 limit_price: "160.00".to_string(),
3973 }),
3974 stop_loss: Some(StopLossParams {
3975 stop_price: "145.00".to_string(),
3976 limit_price: None,
3977 }),
3978 };
3979
3980 let json = serde_json::to_value(&body).unwrap();
3981
3982 assert_eq!(json["symbol"], "AAPL");
3983 assert_eq!(json["qty"], "10");
3984 assert_eq!(json["side"], "buy");
3985 assert_eq!(json["type"], "limit");
3986 assert_eq!(json["time_in_force"], "gtc");
3987 assert_eq!(json["limit_price"], "150.00");
3988 assert_eq!(json["order_class"], "bracket");
3989
3990 assert_eq!(json["take_profit"]["limit_price"], "160.00");
3992
3993 assert_eq!(json["stop_loss"]["stop_price"], "145.00");
3995 assert!(
3996 json["stop_loss"].get("limit_price").is_none(),
3997 "stop_loss.limit_price should be omitted when None"
3998 );
3999 }
4000
4001 #[test]
4002 fn test_bracket_order_serializes_with_stop_loss_stop_limit_order() {
4003 let body = AlpacaOrderRequest {
4005 symbol: "SPY",
4006 qty: "5".to_string(),
4007 side: "sell",
4008 order_type: "limit",
4009 time_in_force: "day",
4010 limit_price: Some("450.00".to_string()),
4011 stop_price: None,
4012 trail_percent: None,
4013 trail_price: None,
4014 client_order_id: Some("bracket-002"),
4015 position_intent: Some(AlpacaPositionIntent::SellToClose),
4016 order_class: Some("bracket"),
4017 take_profit: Some(TakeProfitParams {
4018 limit_price: "440.00".to_string(),
4019 }),
4020 stop_loss: Some(StopLossParams {
4021 stop_price: "455.00".to_string(),
4022 limit_price: Some("456.00".to_string()),
4023 }),
4024 };
4025
4026 let json = serde_json::to_value(&body).unwrap();
4027
4028 assert_eq!(json["symbol"], "SPY");
4029 assert_eq!(json["side"], "sell");
4030 assert_eq!(json["time_in_force"], "day");
4031 assert_eq!(json["order_class"], "bracket");
4032
4033 assert_eq!(json["take_profit"]["limit_price"], "440.00");
4035
4036 assert_eq!(json["stop_loss"]["stop_price"], "455.00");
4038 assert_eq!(
4039 json["stop_loss"]["limit_price"], "456.00",
4040 "stop_loss.limit_price should be present for stop-limit orders"
4041 );
4042 }
4043
4044 #[tokio::test]
4049 async fn test_open_bracket_order_rejects_invalid_tif() {
4050 use rust_decimal_macros::dec;
4051 use rustrade_instrument::instrument::name::InstrumentNameExchange;
4052
4053 let config = AlpacaConfig::new("dummy_key".into(), "dummy_secret".into());
4055 let client = AlpacaClient::new(config);
4056
4057 let request = AlpacaBracketOrderRequest::new(
4058 InstrumentNameExchange::new("SPY"),
4059 crate::order::id::StrategyId::new("test"),
4060 crate::order::id::ClientOrderId::new("test-tif"),
4061 Side::Buy,
4062 dec!(1),
4063 dec!(100.00),
4064 dec!(120.00),
4065 dec!(90.00),
4066 TimeInForce::ImmediateOrCancel, );
4068
4069 let result = client.open_bracket_order(request).await;
4070
4071 assert!(
4072 result.parent.state.is_failed(),
4073 "Bracket order with IOC TIF should be rejected locally"
4074 );
4075 }
4076
4077 #[tokio::test]
4078 async fn test_open_bracket_order_rejects_invalid_price_ordering() {
4079 use rust_decimal_macros::dec;
4080 use rustrade_instrument::instrument::name::InstrumentNameExchange;
4081
4082 let config = AlpacaConfig::new("dummy_key".into(), "dummy_secret".into());
4083 let client = AlpacaClient::new(config);
4084
4085 let request = AlpacaBracketOrderRequest::new(
4087 InstrumentNameExchange::new("SPY"),
4088 crate::order::id::StrategyId::new("test"),
4089 crate::order::id::ClientOrderId::new("test-price"),
4090 Side::Buy,
4091 dec!(1),
4092 dec!(100.00),
4093 dec!(120.00),
4094 dec!(105.00), TimeInForce::GoodUntilCancelled { post_only: false },
4096 );
4097
4098 let result = client.open_bracket_order(request).await;
4099
4100 assert!(
4101 result.parent.state.is_failed(),
4102 "Bracket order with invalid price ordering should be rejected locally"
4103 );
4104 }
4105
4106 #[tokio::test]
4107 async fn test_open_bracket_order_rejects_invalid_sl_limit_price() {
4108 use rust_decimal_macros::dec;
4109 use rustrade_instrument::instrument::name::InstrumentNameExchange;
4110
4111 let config = AlpacaConfig::new("dummy_key".into(), "dummy_secret".into());
4112 let client = AlpacaClient::new(config);
4113
4114 let request = AlpacaBracketOrderRequest::new(
4116 InstrumentNameExchange::new("SPY"),
4117 crate::order::id::StrategyId::new("test"),
4118 crate::order::id::ClientOrderId::new("test-sl-limit"),
4119 Side::Buy,
4120 dec!(1),
4121 dec!(100.00),
4122 dec!(120.00),
4123 dec!(90.00),
4124 TimeInForce::GoodUntilCancelled { post_only: false },
4125 )
4126 .with_stop_loss_limit_price(dec!(95.00)); let result = client.open_bracket_order(request).await;
4129
4130 assert!(
4131 result.parent.state.is_failed(),
4132 "Bracket order with invalid SL limit price should be rejected locally"
4133 );
4134 }
4135
4136 #[test]
4137 fn test_non_bracket_order_omits_bracket_fields() {
4138 let body = AlpacaOrderRequest {
4140 symbol: "AAPL",
4141 qty: "1".to_string(),
4142 side: "buy",
4143 order_type: "limit",
4144 time_in_force: "gtc",
4145 limit_price: Some("150.00".to_string()),
4146 stop_price: None,
4147 trail_percent: None,
4148 trail_price: None,
4149 client_order_id: Some("regular-001"),
4150 position_intent: Some(AlpacaPositionIntent::BuyToOpen),
4151 order_class: None,
4152 take_profit: None,
4153 stop_loss: None,
4154 };
4155
4156 let json = serde_json::to_value(&body).unwrap();
4157
4158 assert_eq!(json["symbol"], "AAPL");
4159 assert!(
4160 json.get("order_class").is_none(),
4161 "order_class should be omitted for non-bracket orders"
4162 );
4163 assert!(
4164 json.get("take_profit").is_none(),
4165 "take_profit should be omitted for non-bracket orders"
4166 );
4167 assert!(
4168 json.get("stop_loss").is_none(),
4169 "stop_loss should be omitted for non-bracket orders"
4170 );
4171 }
4172
4173 #[test]
4174 fn test_parse_timestamp_valid() {
4175 let ts = parse_timestamp("2025-04-18T14:30:00Z");
4176 assert!(ts.is_some());
4177 let ts2 = parse_timestamp("2025-04-18T14:30:00.123456Z");
4178 assert!(ts2.is_some());
4179 assert_eq!(parse_timestamp("not-a-timestamp"), None);
4180 }
4181
4182 #[test]
4183 fn test_check_auth_response_authorized() {
4184 let msg =
4185 r#"{"stream":"authorization","data":{"status":"authorized","action":"authenticate"}}"#;
4186 assert!(matches!(check_auth_response(msg), Some(Ok(()))));
4187 }
4188
4189 #[test]
4190 fn test_check_auth_response_unauthorized() {
4191 let msg = r#"{"stream":"authorization","data":{"status":"unauthorized"}}"#;
4192 assert!(matches!(
4193 check_auth_response(msg),
4194 Some(Err(HandshakeError::Auth(_)))
4195 ));
4196 }
4197
4198 #[test]
4199 fn test_check_auth_response_non_auth_message() {
4200 let msg = r#"{"stream":"trade_updates","data":{}}"#;
4201 assert!(check_auth_response(msg).is_none());
4202 }
4203
4204 #[test]
4205 fn test_check_listen_ack() {
4206 let ack = r#"{"stream":"listening","data":{"streams":["trade_updates"]}}"#;
4207 assert!(check_listen_ack(ack));
4208
4209 let other = r#"{"stream":"authorization","data":{}}"#;
4210 assert!(!check_listen_ack(other));
4211 }
4212
4213 #[test]
4214 fn test_dedup_cache() {
4215 let cache = new_dedup_cache();
4216 let key = SmolStr::new("order-1:1");
4217 assert!(
4218 !is_duplicate(&cache, &key),
4219 "first time should not be duplicate"
4220 );
4221 assert!(
4222 is_duplicate(&cache, &key),
4223 "second time should be duplicate"
4224 );
4225 }
4226
4227 #[tokio::test]
4228 async fn test_exponential_backoff_progression_and_exhaustion() {
4229 tokio::time::pause();
4230
4231 let mut b = ExponentialBackoff::new();
4232
4233 assert!(b.wait().await, "first wait should return true");
4235 assert_eq!(b.attempt, 1);
4236
4237 while b.wait().await {}
4239
4240 assert_eq!(b.attempt, MAX_RECONNECT_ATTEMPTS);
4242
4243 assert!(!b.wait().await, "exhausted backoff should return false");
4245
4246 b.reset();
4248 assert_eq!(b.attempt, 0);
4249
4250 assert!(b.wait().await, "wait should succeed after reset");
4252 assert_eq!(b.attempt, 1);
4253 }
4254
4255 #[test]
4256 fn test_convert_account_to_balances_empty_assets() {
4257 let account = AlpacaAccount {
4258 equity: "12000.00".into(),
4259 buying_power: "10000.00".into(),
4260 options_buying_power: Some("8000.00".into()),
4261 crypto_buying_power: None,
4262 };
4263 let balances = convert_account_to_balances(&account, &[]);
4264 assert_eq!(balances.len(), 1);
4265 assert_eq!(
4266 balances[0].balance.total,
4267 Decimal::from_str("12000.00").unwrap()
4268 );
4269 assert_eq!(
4271 balances[0].balance.free,
4272 Decimal::from_str("8000.00").unwrap()
4273 );
4274 }
4275
4276 #[test]
4277 fn test_convert_account_to_balances_usd_filter() {
4278 let account = AlpacaAccount {
4279 equity: "12000.00".into(),
4280 buying_power: "10000.00".into(),
4281 options_buying_power: None,
4282 crypto_buying_power: None,
4283 };
4284 let usd = vec![AssetNameExchange::new("USD")];
4285 let balances = convert_account_to_balances(&account, &usd);
4286 assert_eq!(balances.len(), 1);
4287
4288 let non_usd = vec![AssetNameExchange::new("BTC")];
4289 let balances = convert_account_to_balances(&account, &non_usd);
4290 assert!(balances.is_empty());
4291 }
4292
4293 #[test]
4294 fn test_is_options_or_equity_symbol() {
4295 assert!(!is_options_or_equity_symbol("BTC/USD"));
4297 assert!(!is_options_or_equity_symbol("ETH/USD"));
4298 assert!(!is_options_or_equity_symbol("SOL/USD"));
4299
4300 assert!(is_options_or_equity_symbol("AAPL"));
4302 assert!(is_options_or_equity_symbol("SPY"));
4303 assert!(is_options_or_equity_symbol("MSFT"));
4304
4305 assert!(is_options_or_equity_symbol("SPY250418C00450000"));
4307 assert!(is_options_or_equity_symbol("AAPL250418P00145000"));
4308 }
4309
4310 #[test]
4311 fn test_parse_order_error_already_cancelled() {
4312 assert!(matches!(
4315 parse_order_error(
4316 reqwest::StatusCode::UNPROCESSABLE_ENTITY,
4317 "order is already cancelled"
4318 ),
4319 UnindexedOrderError::Rejected(ApiError::OrderAlreadyCancelled)
4320 ));
4321 }
4322
4323 #[test]
4324 fn test_parse_order_error_already_wins_over_insufficient_on_422() {
4325 assert!(matches!(
4328 parse_order_error(
4329 reqwest::StatusCode::UNPROCESSABLE_ENTITY,
4330 "order already cancelled due to insufficient margin"
4331 ),
4332 UnindexedOrderError::Rejected(ApiError::OrderAlreadyCancelled)
4333 ));
4334 }
4335
4336 fn make_order_ws<'a>(
4337 id: &str,
4338 symbol: &str,
4339 side: &str,
4340 filled_qty: &'a str,
4341 ) -> AlpacaOrderWs<'a> {
4342 AlpacaOrderWs {
4343 id: SmolStr::new(id),
4344 client_order_id: None,
4345 symbol: SmolStr::new(symbol),
4346 qty: Some("2"),
4347 filled_qty: Some(filled_qty),
4348 side: SmolStr::new(side),
4349 order_type: SmolStr::new("limit"),
4350 time_in_force: SmolStr::new("day"),
4351 limit_price: Some("100.00"),
4352 stop_price: None,
4353 trail_percent: None,
4354 trail_price: None,
4355 status: SmolStr::new("partially_filled"),
4356 }
4357 }
4358
4359 fn sole_event(events: [Option<UnindexedAccountEvent>; 2]) -> Option<UnindexedAccountEvent> {
4364 let [first, second] = events;
4365 assert!(
4366 second.is_none(),
4367 "expected a single event, got a second: {second:?}"
4368 );
4369 first
4370 }
4371
4372 fn fill_events(
4374 events: [Option<UnindexedAccountEvent>; 2],
4375 ) -> (
4376 Trade<AssetNameExchange, InstrumentNameExchange>,
4377 crate::order::Order<
4378 ExchangeId,
4379 InstrumentNameExchange,
4380 OrderState<AssetNameExchange, InstrumentNameExchange>,
4381 >,
4382 ) {
4383 let [first, second] = events;
4384 let first = first.expect("a fill frame must produce an execution");
4385 let second = second.expect("a fill frame must produce an order snapshot");
4386 let AccountEventKind::Trade(trade) = first.kind else {
4387 panic!("first event must be the Trade, got {:?}", first.kind);
4388 };
4389 let AccountEventKind::OrderSnapshot(rustrade_integration::collection::snapshot::Snapshot(
4390 order,
4391 )) = second.kind
4392 else {
4393 panic!(
4394 "second event must be the OrderSnapshot, got {:?}",
4395 second.kind
4396 );
4397 };
4398 (trade, order)
4399 }
4400
4401 #[test]
4402 fn test_convert_trade_update_fill_produces_trade_with_dedup_key() {
4403 let update = AlpacaTradeUpdate {
4404 event: SmolStr::new("fill"),
4405 order: make_order_ws("ord-1", "SPY", "buy", "1"),
4406 price: Some("150.00"),
4407 qty: Some("1"),
4408 timestamp: Some("2025-04-18T14:30:00Z"),
4409 };
4410 let (trade, order) = fill_events(convert_trade_update(update));
4411 assert_eq!(trade.id.0.as_str(), "ord-1:1");
4413 assert_eq!(trade.price, Decimal::from_str("150.00").unwrap());
4414 assert_eq!(trade.quantity, Decimal::from_str("1").unwrap());
4415
4416 let OrderState::Active(crate::order::state::ActiveOrderState::Open(open)) = order.state
4419 else {
4420 panic!("expected an Open snapshot, got {:?}", order.state);
4421 };
4422 assert_eq!(open.filled_quantity, Decimal::from_str("1").unwrap());
4423 assert_eq!(open.id.assigned().map(|id| id.0.as_str()), Some("ord-1"));
4424 }
4425
4426 #[test]
4427 fn test_convert_trade_update_partial_fill() {
4428 let update = AlpacaTradeUpdate {
4429 event: SmolStr::new("partial_fill"),
4430 order: make_order_ws("ord-2", "AAPL", "sell", "0.5"),
4431 price: Some("200.00"),
4432 qty: Some("0.5"),
4433 timestamp: None,
4434 };
4435 let (trade, order) = fill_events(convert_trade_update(update));
4436 assert_eq!(trade.quantity, Decimal::from_str("0.5").unwrap());
4437
4438 let OrderState::Active(crate::order::state::ActiveOrderState::Open(open)) = order.state
4441 else {
4442 panic!("expected an Open snapshot, got {:?}", order.state);
4443 };
4444 assert_eq!(open.filled_quantity, Decimal::from_str("0.5").unwrap());
4445 assert_eq!(
4446 open.quantity_remaining(order.quantity),
4447 Decimal::from_str("1.5").unwrap()
4448 );
4449 }
4450
4451 #[test]
4455 fn a_full_fill_snapshot_reports_nothing_left_to_fill() {
4456 let update = AlpacaTradeUpdate {
4457 event: SmolStr::new("fill"),
4458 order: AlpacaOrderWs {
4460 status: SmolStr::new("filled"),
4461 ..make_order_ws("ord-full", "SPY", "buy", "2")
4462 },
4463 price: Some("150.00"),
4464 qty: Some("1"),
4465 timestamp: Some("2025-04-18T14:30:00Z"),
4466 };
4467 let (_trade, order) = fill_events(convert_trade_update(update));
4468 let OrderState::Active(crate::order::state::ActiveOrderState::Open(open)) = order.state
4469 else {
4470 panic!("expected an Open snapshot, got {:?}", order.state);
4471 };
4472 assert_eq!(open.filled_quantity, Decimal::from_str("2").unwrap());
4473 assert!(
4474 open.quantity_remaining(order.quantity).is_zero(),
4475 "a completed order must report nothing left to fill so the engine retires it"
4476 );
4477 }
4478
4479 #[test]
4483 fn a_fill_for_an_order_no_longer_working_emits_the_execution_without_a_snapshot() {
4484 for status in [
4485 "canceled",
4486 "expired",
4487 "rejected",
4488 "done_for_day",
4489 "pending_cancel",
4490 ] {
4491 let update = AlpacaTradeUpdate {
4492 event: SmolStr::new("partial_fill"),
4493 order: AlpacaOrderWs {
4494 status: SmolStr::new(status),
4495 ..make_order_ws("ord-late", "SPY", "buy", "1")
4496 },
4497 price: Some("150.00"),
4498 qty: Some("1"),
4499 timestamp: None,
4500 };
4501 let [trade, snapshot] = convert_trade_update(update);
4502 assert!(
4503 matches!(trade.map(|e| e.kind), Some(AccountEventKind::Trade(_))),
4504 "the execution must still be reported for status {status}"
4505 );
4506 assert!(
4507 snapshot.is_none(),
4508 "status {status} must not produce an order snapshot"
4509 );
4510 }
4511 }
4512
4513 #[test]
4516 fn a_notional_order_fill_emits_the_execution_without_a_snapshot() {
4517 let update = AlpacaTradeUpdate {
4518 event: SmolStr::new("partial_fill"),
4519 order: AlpacaOrderWs {
4520 qty: None,
4521 ..make_order_ws("ord-notional-ws", "SPY", "buy", "1")
4522 },
4523 price: Some("150.00"),
4524 qty: Some("1"),
4525 timestamp: None,
4526 };
4527 let [trade, snapshot] = convert_trade_update(update);
4528 assert!(matches!(
4529 trade.map(|e| e.kind),
4530 Some(AccountEventKind::Trade(_))
4531 ));
4532 assert!(
4533 snapshot.is_none(),
4534 "a notional order has no quantity to snapshot against"
4535 );
4536 }
4537
4538 #[test]
4539 fn test_convert_trade_update_new_order_produces_snapshot() {
4540 let update = AlpacaTradeUpdate {
4541 event: SmolStr::new("new"),
4542 order: AlpacaOrderWs {
4543 id: SmolStr::new("ord-new"),
4544 client_order_id: Some(SmolStr::new("cid-1")),
4545 symbol: SmolStr::new("AAPL"),
4546 qty: Some("10"),
4547 filled_qty: Some("0"),
4548 side: SmolStr::new("buy"),
4549 order_type: SmolStr::new("limit"),
4550 time_in_force: SmolStr::new("day"),
4551 limit_price: Some("150.00"),
4552 stop_price: None,
4553 trail_percent: None,
4554 trail_price: None,
4555 status: SmolStr::new("new"),
4556 },
4557 price: None,
4558 qty: None,
4559 timestamp: Some("2025-04-18T14:30:00Z"),
4560 };
4561 let event =
4562 sole_event(convert_trade_update(update)).expect("new event should produce an event");
4563 assert!(matches!(event.kind, AccountEventKind::OrderSnapshot(_)));
4564 }
4565
4566 #[test]
4567 fn test_convert_trade_update_canceled_produces_cancel() {
4568 let update = AlpacaTradeUpdate {
4569 event: SmolStr::new("canceled"),
4570 order: make_order_ws("ord-3", "AAPL", "sell", "0"),
4571 price: None,
4572 qty: None,
4573 timestamp: Some("2025-04-18T14:30:00Z"),
4574 };
4575 let event =
4576 sole_event(convert_trade_update(update)).expect("canceled should produce an event");
4577 let AccountEventKind::OrderCancelled(response) = event.kind else {
4578 panic!("expected OrderCancelled, got {:?}", event.kind);
4579 };
4580 assert!(response.state.is_ok());
4581 }
4582
4583 #[test]
4584 fn test_convert_trade_update_rejected_produces_error() {
4585 let update = AlpacaTradeUpdate {
4586 event: SmolStr::new("rejected"),
4587 order: make_order_ws("ord-4", "SPY", "buy", "0"),
4588 price: None,
4589 qty: None,
4590 timestamp: None,
4591 };
4592 let event =
4593 sole_event(convert_trade_update(update)).expect("rejected should produce an event");
4594 let AccountEventKind::OrderCancelled(response) = event.kind else {
4595 panic!("expected OrderCancelled, got {:?}", event.kind);
4596 };
4597 assert!(response.state.is_err());
4598 }
4599
4600 #[test]
4601 fn test_convert_open_order_notional_qty_none_is_skipped() {
4602 let order = AlpacaOrderResponse {
4605 id: "ord-notional".to_string(),
4606 client_order_id: None,
4607 symbol: "SPY".to_string(),
4608 qty: None,
4609 filled_qty: "0".to_string(),
4610 side: "buy".to_string(),
4611 order_type: "market".to_string(),
4612 time_in_force: "day".to_string(),
4613 limit_price: None,
4614 stop_price: None,
4615 trail_percent: None,
4616 trail_price: None,
4617 created_at: "2025-04-18T14:30:00Z".to_string(),
4618 updated_at: None,
4619 };
4620 assert!(convert_open_order(&order).is_none());
4621 }
4622
4623 #[test]
4624 fn test_convert_activity_to_trade_bad_price_returns_none() {
4625 let activity = AlpacaActivity {
4629 id: "act-1".to_string(),
4630 order_id: "ord-1".to_string(),
4631 symbol: "SPY250418C00450000".to_string(),
4632 side: "buy".to_string(),
4633 price: "not-a-number".to_string(),
4634 qty: "1".to_string(),
4635 transaction_time: "2025-04-18T14:30:00Z".to_string(),
4636 cum_qty: Some("1".to_string()),
4637 };
4638 assert!(convert_activity_to_trade(&activity).is_none());
4639 }
4640
4641 #[test]
4642 fn test_convert_positions_to_balances_crypto() {
4643 let positions = vec![
4644 AlpacaPosition {
4645 symbol: "BTC/USD".into(),
4646 asset_class: "crypto".into(),
4647 qty: "0.5".into(),
4648 qty_available: "0.4".into(),
4649 },
4650 AlpacaPosition {
4651 symbol: "ETH/USD".into(),
4652 asset_class: "crypto".into(),
4653 qty: "2.0".into(),
4654 qty_available: "2.0".into(),
4655 },
4656 AlpacaPosition {
4658 symbol: "AAPL".into(),
4659 asset_class: "us_equity".into(),
4660 qty: "10".into(),
4661 qty_available: "10".into(),
4662 },
4663 ];
4664
4665 let balances = convert_positions_to_balances(&positions, &[]);
4667 assert_eq!(balances.len(), 2, "only crypto positions returned");
4668 assert_eq!(balances[0].asset.name().as_str(), "btc");
4669 assert_eq!(balances[0].balance.total, Decimal::from_str("0.5").unwrap());
4671 assert_eq!(balances[0].balance.free, Decimal::from_str("0.4").unwrap());
4672
4673 let btc_only = vec![AssetNameExchange::new("BTC")];
4675 let balances = convert_positions_to_balances(&positions, &btc_only);
4676 assert_eq!(balances.len(), 1);
4677 assert_eq!(balances[0].asset.name().as_str(), "btc");
4678 }
4679
4680 #[test]
4682 fn order_state_time_prefers_updated_at() {
4683 let mut resp = make_order_response("ord-1", "SPY");
4684 resp.created_at = "2025-04-18T14:30:00Z".to_string();
4685 resp.updated_at = Some("2025-04-18T15:45:00Z".to_string());
4686 assert_eq!(
4687 order_state_time(&resp),
4688 parse_timestamp("2025-04-18T15:45:00Z").unwrap(),
4689 "updated_at must win over created_at"
4690 );
4691
4692 resp.updated_at = None;
4694 assert_eq!(
4695 order_state_time(&resp),
4696 parse_timestamp("2025-04-18T14:30:00Z").unwrap(),
4697 "absent updated_at falls back to created_at"
4698 );
4699
4700 resp.updated_at = Some("not-a-timestamp".to_string());
4703 assert_eq!(
4704 order_state_time(&resp),
4705 parse_timestamp("2025-04-18T14:30:00Z").unwrap(),
4706 "unparseable updated_at falls back to created_at"
4707 );
4708 }
4709
4710 fn make_order_response(id: &str, symbol: &str) -> AlpacaOrderResponse {
4711 AlpacaOrderResponse {
4712 id: id.to_string(),
4713 client_order_id: None,
4714 symbol: symbol.to_string(),
4715 qty: Some("1".to_string()),
4716 filled_qty: "0".to_string(),
4717 side: "buy".to_string(),
4718 order_type: "limit".to_string(),
4719 time_in_force: "day".to_string(),
4720 limit_price: Some("100.00".to_string()),
4721 stop_price: None,
4722 trail_percent: None,
4723 trail_price: None,
4724 created_at: "2025-04-18T14:30:00Z".to_string(),
4725 updated_at: None,
4726 }
4727 }
4728
4729 #[test]
4730 fn test_build_instrument_snapshots_empty_instruments_returns_only_with_orders() {
4731 let orders = vec![
4732 make_order_response("o1", "AAPL"),
4733 make_order_response("o2", "SPY"),
4734 ];
4735 let snapshots = build_instrument_snapshots(orders, &[]);
4736 assert_eq!(snapshots.len(), 2);
4737 let symbols: Vec<&str> = snapshots
4738 .iter()
4739 .map(|s| s.instrument.name().as_str())
4740 .collect();
4741 assert!(symbols.contains(&"AAPL"));
4742 assert!(symbols.contains(&"SPY"));
4743 }
4744
4745 #[test]
4746 fn test_build_instrument_snapshots_requested_instrument_no_orders_gets_empty_snapshot() {
4747 let orders = vec![make_order_response("o1", "AAPL")];
4750 let instruments = vec![
4751 InstrumentNameExchange::new("AAPL"),
4752 InstrumentNameExchange::new("SPY"),
4753 ];
4754 let snapshots = build_instrument_snapshots(orders, &instruments);
4755 assert_eq!(snapshots.len(), 2);
4756 let spy = snapshots
4757 .iter()
4758 .find(|s| s.instrument.name().as_str() == "SPY")
4759 .expect("SPY snapshot must be present even with no orders");
4760 assert!(spy.orders.is_empty());
4761 }
4762
4763 fn make_notional_order_response(id: &str, symbol: &str) -> AlpacaOrderResponse {
4765 AlpacaOrderResponse {
4766 qty: None,
4767 ..make_order_response(id, symbol)
4768 }
4769 }
4770
4771 fn snapshot_for<'a>(
4772 snapshots: &'a [InstrumentAccountSnapshot<
4773 ExchangeId,
4774 AssetNameExchange,
4775 InstrumentNameExchange,
4776 >],
4777 symbol: &str,
4778 ) -> &'a InstrumentAccountSnapshot<ExchangeId, AssetNameExchange, InstrumentNameExchange> {
4779 snapshots
4780 .iter()
4781 .find(|snapshot| snapshot.instrument.name().as_str() == symbol)
4782 .unwrap_or_else(|| panic!("no snapshot for {symbol}"))
4783 }
4784
4785 #[test]
4789 fn test_build_instrument_snapshots_declares_complete_lists_complete() {
4790 let instruments = [
4791 InstrumentNameExchange::new("AAPL"),
4792 InstrumentNameExchange::new("SPY"),
4793 ];
4794 for requested in [&instruments[..], &[]] {
4795 let orders = vec![make_order_response("o1", "AAPL")];
4796 let snapshots = build_instrument_snapshots(orders, requested);
4797 assert!(!snapshots.is_empty());
4798 assert!(snapshots.iter().all(|snapshot| snapshot.orders_complete));
4799 }
4800 }
4801
4802 #[test]
4806 fn test_build_instrument_snapshots_notional_order_leaves_only_its_instrument_incomplete() {
4807 let orders = vec![
4808 make_order_response("o1", "AAPL"),
4809 make_order_response("o2", "SPY"),
4810 make_notional_order_response("o3", "SPY"),
4811 ];
4812 let instruments = [
4813 InstrumentNameExchange::new("AAPL"),
4814 InstrumentNameExchange::new("SPY"),
4815 ];
4816 let snapshots = build_instrument_snapshots(orders, &instruments);
4817
4818 assert!(snapshot_for(&snapshots, "AAPL").orders_complete);
4819 let spy = snapshot_for(&snapshots, "SPY");
4820 assert!(!spy.orders_complete);
4821 assert_eq!(spy.orders.len(), 1);
4822 }
4823
4824 #[test]
4827 fn test_build_instrument_snapshots_unfiltered_lists_an_instrument_with_only_a_notional_order() {
4828 let orders = vec![make_notional_order_response("o1", "SPY")];
4829 let snapshots = build_instrument_snapshots(orders, &[]);
4830
4831 let spy = snapshot_for(&snapshots, "SPY");
4832 assert!(!spy.orders_complete);
4833 assert!(spy.orders.is_empty());
4834 }
4835
4836 #[test]
4837 fn test_build_instrument_snapshots_non_requested_instrument_excluded() {
4838 let orders = vec![
4841 make_order_response("o1", "AAPL"),
4842 make_order_response("o2", "MSFT"), ];
4844 let instruments = vec![InstrumentNameExchange::new("AAPL")];
4845 let snapshots = build_instrument_snapshots(orders, &instruments);
4846 assert_eq!(snapshots.len(), 1);
4847 assert_eq!(snapshots[0].instrument.name().as_str(), "AAPL");
4848 }
4849
4850 #[test]
4856 fn test_recover_fills_dedup_key_matches_ws_path() {
4857 let order_id = "ord-1";
4858
4859 let ws_keys: Vec<SmolStr> = ["1", "2"]
4862 .iter()
4863 .filter_map(|filled_qty| {
4864 let update = AlpacaTradeUpdate {
4865 event: SmolStr::new("partial_fill"),
4866 order: make_order_ws(order_id, "SPY", "buy", filled_qty),
4867 price: Some("150.00"),
4868 qty: Some("1"),
4869 timestamp: None,
4870 };
4871 let [event, _snapshot] = convert_trade_update(update);
4872 let event = event?;
4873 fill_dedup_key_from_event(&event).cloned()
4874 })
4875 .collect();
4876
4877 let mut cumulative = Decimal::ZERO;
4880 let rest_keys: Vec<SmolStr> = ["1", "1"]
4881 .iter()
4882 .map(|exec_qty| {
4883 cumulative += Decimal::from_str(exec_qty).unwrap();
4884 format_smolstr!("{}:{}", order_id, cumulative.normalize())
4885 })
4886 .collect();
4887
4888 assert_eq!(
4889 ws_keys, rest_keys,
4890 "REST recovery dedup keys must match WS path keys for cross-source dedup to work"
4891 );
4892 assert_eq!(ws_keys[0].as_str(), "ord-1:1");
4893 assert_eq!(ws_keys[1].as_str(), "ord-1:2");
4894 }
4895
4896 #[test]
4902 fn early_dedup_key_matches_full_event_path() {
4903 let update = AlpacaTradeUpdate {
4904 event: SmolStr::new("fill"),
4905 order: make_order_ws("ord-abc", "SPY", "buy", "5"),
4906 price: Some("150.00"),
4907 qty: Some("5"),
4908 timestamp: None,
4909 };
4910
4911 let early_key = early_dedup_key(&update);
4913
4914 let [event, _snapshot] = convert_trade_update(update);
4917 let event = event.expect("fill should produce an execution");
4918 let full_key =
4919 fill_dedup_key_from_event(&event).expect("fill event should have a dedup key");
4920
4921 assert_eq!(
4922 early_key.as_str(),
4923 full_key.as_str(),
4924 "early_dedup_key must produce the same key as the full event path"
4925 );
4926 assert_eq!(early_key.as_str(), "ord-abc:5");
4927 }
4928
4929 #[test]
4934 fn early_dedup_key_normalizes_decimal_strings() {
4935 let update1 = AlpacaTradeUpdate {
4937 event: SmolStr::new("fill"),
4938 order: AlpacaOrderWs {
4939 id: SmolStr::new("ord-x"),
4940 client_order_id: Some(SmolStr::new("cid")),
4941 symbol: SmolStr::new("AAPL"),
4942 qty: Some("10"),
4943 filled_qty: Some("1.00"),
4944 side: SmolStr::new("buy"),
4945 order_type: SmolStr::new("market"),
4946 time_in_force: SmolStr::new("day"),
4947 limit_price: None,
4948 stop_price: None,
4949 trail_percent: None,
4950 trail_price: None,
4951 status: SmolStr::new("filled"),
4952 },
4953 price: Some("100.00"),
4954 qty: Some("10"),
4955 timestamp: None,
4956 };
4957 assert_eq!(early_dedup_key(&update1).as_str(), "ord-x:1");
4958
4959 let update2 = AlpacaTradeUpdate {
4961 event: SmolStr::new("fill"),
4962 order: AlpacaOrderWs {
4963 id: SmolStr::new("ord-x"),
4964 client_order_id: Some(SmolStr::new("cid")),
4965 symbol: SmolStr::new("AAPL"),
4966 qty: Some("10"),
4967 filled_qty: Some("1"),
4968 side: SmolStr::new("buy"),
4969 order_type: SmolStr::new("market"),
4970 time_in_force: SmolStr::new("day"),
4971 limit_price: None,
4972 stop_price: None,
4973 trail_percent: None,
4974 trail_price: None,
4975 status: SmolStr::new("filled"),
4976 },
4977 price: Some("100.00"),
4978 qty: Some("10"),
4979 timestamp: None,
4980 };
4981 assert_eq!(early_dedup_key(&update2).as_str(), "ord-x:1");
4982
4983 let update3 = AlpacaTradeUpdate {
4985 event: SmolStr::new("fill"),
4986 order: AlpacaOrderWs {
4987 id: SmolStr::new("ord-x"),
4988 client_order_id: Some(SmolStr::new("cid")),
4989 symbol: SmolStr::new("AAPL"),
4990 qty: Some("10"),
4991 filled_qty: Some("1.0"),
4992 side: SmolStr::new("buy"),
4993 order_type: SmolStr::new("market"),
4994 time_in_force: SmolStr::new("day"),
4995 limit_price: None,
4996 stop_price: None,
4997 trail_percent: None,
4998 trail_price: None,
4999 status: SmolStr::new("filled"),
5000 },
5001 price: Some("100.00"),
5002 qty: Some("10"),
5003 timestamp: None,
5004 };
5005 assert_eq!(early_dedup_key(&update3).as_str(), "ord-x:1");
5006 }
5007
5008 #[test]
5013 fn process_ws_text_rejected_event_without_filled_qty_is_not_dropped() {
5014 let (tx, mut rx) = mpsc::unbounded_channel();
5015 let dedup = new_dedup_cache();
5016 let mut backoff = ExponentialBackoff::new();
5017
5018 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"}}}"#;
5020
5021 process_ws_text(json, &tx, &dedup, &mut backoff);
5022
5023 let event = rx.try_recv()
5025 .expect("rejected event without filled_qty must produce an AccountEvent, not be silently dropped");
5026 assert!(
5027 matches!(event.kind, AccountEventKind::OrderCancelled(_)),
5028 "rejected event must map to OrderCancelled, got: {:?}",
5029 event.kind
5030 );
5031 }
5032
5033 #[test]
5036 fn decimal_zero_normalize_is_zero_str() {
5037 assert_eq!(Decimal::ZERO.normalize().to_string(), "0");
5038 }
5039
5040 #[test]
5044 fn decimal_normalize_strips_trailing_zeros() {
5045 let from_rest = Decimal::from_str("1.00").unwrap().normalize();
5047 let from_ws = Decimal::from_str("1").unwrap().normalize();
5048 assert_eq!(from_rest.to_string(), from_ws.to_string());
5049 assert_eq!(from_rest.to_string(), "1");
5050
5051 assert_eq!(
5053 Decimal::from_str("100.000")
5054 .unwrap()
5055 .normalize()
5056 .to_string(),
5057 "100"
5058 );
5059 assert_eq!(
5060 Decimal::from_str("0.10").unwrap().normalize().to_string(),
5061 "0.1"
5062 );
5063 assert_eq!(
5064 Decimal::from_str("0.100").unwrap().normalize().to_string(),
5065 "0.1"
5066 );
5067 }
5068
5069 #[test]
5074 fn convert_account_to_balances_zero_options_buying_power_falls_back_to_buying_power() {
5075 let account = AlpacaAccount {
5076 equity: "12000.00".into(),
5077 buying_power: "10000.00".into(),
5078 options_buying_power: Some("0.00".into()), crypto_buying_power: None,
5080 };
5081 let balances = convert_account_to_balances(&account, &[]);
5082 assert_eq!(balances.len(), 1);
5083 assert_eq!(
5084 balances[0].balance.free,
5085 Decimal::from_str("10000.00").unwrap(),
5086 "zero options_buying_power must fall back to buying_power, not report free=0"
5087 );
5088 }
5089
5090 #[test]
5092 fn map_position_intent_open_buy_maps_to_buy_to_open() {
5093 assert_eq!(
5094 map_position_intent(Side::Buy, false),
5095 AlpacaPositionIntent::BuyToOpen
5096 );
5097 }
5098
5099 #[test]
5100 fn map_position_intent_open_sell_maps_to_sell_to_open() {
5101 assert_eq!(
5102 map_position_intent(Side::Sell, false),
5103 AlpacaPositionIntent::SellToOpen
5104 );
5105 }
5106
5107 #[test]
5108 fn map_position_intent_reduce_buy_maps_to_buy_to_close() {
5109 assert_eq!(
5110 map_position_intent(Side::Buy, true),
5111 AlpacaPositionIntent::BuyToClose
5112 );
5113 }
5114
5115 #[test]
5116 fn map_position_intent_reduce_sell_maps_to_sell_to_close() {
5117 assert_eq!(
5118 map_position_intent(Side::Sell, true),
5119 AlpacaPositionIntent::SellToClose
5120 );
5121 }
5122
5123 #[test]
5125 fn parse_order_error_401_maps_to_unauthenticated() {
5126 assert!(matches!(
5128 parse_order_error(reqwest::StatusCode::UNAUTHORIZED, "bad credentials"),
5129 UnindexedOrderError::Rejected(ApiError::Unauthenticated(_))
5130 ));
5131 }
5132
5133 #[test]
5134 fn parse_order_error_403_maps_to_unauthenticated() {
5135 assert!(matches!(
5138 parse_order_error(reqwest::StatusCode::FORBIDDEN, "account suspended"),
5139 UnindexedOrderError::Rejected(ApiError::Unauthenticated(_))
5140 ));
5141 }
5142
5143 #[test]
5144 fn parse_order_error_404_maps_to_order_rejected_with_not_found_prefix() {
5145 let err = parse_order_error(reqwest::StatusCode::NOT_FOUND, "order not found");
5146 let UnindexedOrderError::Rejected(ApiError::OrderRejected(msg)) = err else {
5147 panic!("expected OrderRejected, got {err:?}");
5148 };
5149 assert!(
5150 msg.contains("order not found"),
5151 "message should contain 'order not found': {msg}"
5152 );
5153 }
5154
5155 #[test]
5156 fn parse_order_error_422_insufficient_only_maps_to_balance_insufficient() {
5157 assert!(matches!(
5159 parse_order_error(
5160 reqwest::StatusCode::UNPROCESSABLE_ENTITY,
5161 "insufficient funds for this order"
5162 ),
5163 UnindexedOrderError::Rejected(ApiError::BalanceInsufficient(_, _))
5164 ));
5165 }
5166
5167 #[test]
5168 fn parse_order_error_429_maps_to_rate_limit() {
5169 assert!(matches!(
5170 parse_order_error(reqwest::StatusCode::TOO_MANY_REQUESTS, "rate limited"),
5171 UnindexedOrderError::Rejected(ApiError::RateLimit)
5172 ));
5173 }
5174
5175 #[test]
5179 fn parse_time_in_force_unknown_value_falls_back_to_good_until_end_of_day() {
5180 assert_eq!(
5181 parse_time_in_force("opg"),
5182 TimeInForce::GoodUntilEndOfDay,
5183 "unknown TIF must fall back to GoodUntilEndOfDay (with a warn! in production)"
5184 );
5185 }
5186
5187 #[test]
5190 fn build_instrument_snapshots_output_order_matches_instruments_slice() {
5191 let orders = vec![
5193 make_order_response("o1", "SPY"),
5194 make_order_response("o2", "AAPL"),
5195 make_order_response("o3", "MSFT"),
5196 ];
5197 let instruments = vec![
5199 InstrumentNameExchange::new("MSFT"),
5200 InstrumentNameExchange::new("AAPL"),
5201 ];
5202 let snapshots = build_instrument_snapshots(orders, &instruments);
5203 assert_eq!(snapshots.len(), 2);
5204 assert_eq!(
5205 snapshots[0].instrument.name().as_str(),
5206 "MSFT",
5207 "first snapshot must be MSFT (first in instruments slice)"
5208 );
5209 assert_eq!(
5210 snapshots[1].instrument.name().as_str(),
5211 "AAPL",
5212 "second snapshot must be AAPL (second in instruments slice)"
5213 );
5214 }
5215
5216 mod http_tests {
5223 use super::super::*;
5224 use wiremock::matchers::{method, path};
5225 use wiremock::{Mock, MockServer, Request, Respond, ResponseTemplate};
5226
5227 struct Sequential {
5234 call: std::sync::atomic::AtomicU32,
5235 pages: Vec<serde_json::Value>,
5236 }
5237
5238 impl Sequential {
5239 fn new(pages: Vec<serde_json::Value>) -> Self {
5240 Self {
5241 call: std::sync::atomic::AtomicU32::new(0),
5242 pages,
5243 }
5244 }
5245 }
5246
5247 impl Respond for Sequential {
5248 fn respond(&self, _: &Request) -> ResponseTemplate {
5249 let i = self.call.fetch_add(1, std::sync::atomic::Ordering::Relaxed) as usize;
5250 let body = self.pages.get(i).unwrap_or_else(|| {
5251 panic!(
5252 "Sequential: request #{i} has no configured response \
5253 (only {} page(s) supplied)",
5254 self.pages.len()
5255 )
5256 });
5257 ResponseTemplate::new(200).set_body_json(body)
5258 }
5259 }
5260
5261 fn make_activities_json(count: usize, id_prefix: &str) -> serde_json::Value {
5263 serde_json::Value::Array(
5264 (0..count)
5265 .map(|i| {
5266 serde_json::json!({
5267 "id": format!("{id_prefix}-{i:05}"),
5268 "order_id": "ord-1",
5269 "symbol": "SPY",
5270 "side": "buy",
5271 "price": "100.00",
5272 "qty": "1",
5273 "transaction_time": "2025-04-18T14:30:00Z"
5274 })
5275 })
5276 .collect(),
5277 )
5278 }
5279
5280 fn make_orders_json(count: usize) -> serde_json::Value {
5282 serde_json::Value::Array(
5283 (0..count)
5284 .map(|i| {
5285 serde_json::json!({
5286 "id": format!("order-{i:05}"),
5287 "client_order_id": null,
5288 "symbol": "SPY",
5289 "qty": "1",
5290 "filled_qty": "0",
5291 "side": "buy",
5292 "type": "limit",
5293 "time_in_force": "day",
5294 "limit_price": "100.00",
5295 "created_at": "2025-04-18T14:30:00Z"
5296 })
5297 })
5298 .collect(),
5299 )
5300 }
5301
5302 #[tokio::test]
5307 async fn paginate_activities_single_page_below_max_returns_all_not_truncated() {
5308 let server = MockServer::start().await;
5309 Mock::given(method("GET"))
5310 .and(path("/v2/account/activities"))
5311 .respond_with(
5312 ResponseTemplate::new(200).set_body_json(make_activities_json(5, "act")),
5313 )
5314 .mount(&server)
5315 .await;
5316
5317 let http = reqwest::Client::new();
5318 let rl = RateLimitTracker::new();
5319 let result = paginate_activities(&http, &rl, &server.uri(), "2025-01-01T00:00:00Z")
5320 .await
5321 .unwrap();
5322
5323 assert_eq!(result.activities.len(), 5);
5324 assert!(!result.truncated);
5325 assert_eq!(server.received_requests().await.unwrap().len(), 1);
5326 }
5327
5328 #[tokio::test]
5331 async fn paginate_activities_exactly_page_size_items_fetches_second_page() {
5332 let server = MockServer::start().await;
5333 Mock::given(method("GET"))
5334 .and(path("/v2/account/activities"))
5335 .respond_with(Sequential::new(vec![
5336 make_activities_json(ALPACA_MAX_ACTIVITIES, "act"),
5337 serde_json::json!([]), ]))
5339 .mount(&server)
5340 .await;
5341
5342 let http = reqwest::Client::new();
5343 let rl = RateLimitTracker::new();
5344 let result = paginate_activities(&http, &rl, &server.uri(), "2025-01-01T00:00:00Z")
5345 .await
5346 .unwrap();
5347
5348 assert_eq!(result.activities.len(), ALPACA_MAX_ACTIVITIES);
5349 assert!(!result.truncated);
5350 assert_eq!(
5351 server.received_requests().await.unwrap().len(),
5352 2,
5353 "exactly 2 requests: first full page + second empty page"
5354 );
5355 }
5356
5357 #[tokio::test]
5360 async fn paginate_activities_two_pages_returns_combined_activities_not_truncated() {
5361 let server = MockServer::start().await;
5362 Mock::given(method("GET"))
5363 .and(path("/v2/account/activities"))
5364 .respond_with(Sequential::new(vec![
5365 make_activities_json(ALPACA_MAX_ACTIVITIES, "p1"),
5366 make_activities_json(37, "p2"),
5367 ]))
5368 .mount(&server)
5369 .await;
5370
5371 let http = reqwest::Client::new();
5372 let rl = RateLimitTracker::new();
5373 let result = paginate_activities(&http, &rl, &server.uri(), "2025-01-01T00:00:00Z")
5374 .await
5375 .unwrap();
5376
5377 assert_eq!(result.activities.len(), ALPACA_MAX_ACTIVITIES + 37);
5378 assert!(!result.truncated);
5379 assert_eq!(server.received_requests().await.unwrap().len(), 2);
5380 }
5381
5382 #[tokio::test]
5386 async fn paginate_activities_at_max_pages_sets_truncated_true() {
5387 let server = MockServer::start().await;
5388
5389 Mock::given(method("GET"))
5391 .and(path("/v2/account/activities"))
5392 .respond_with(
5393 ResponseTemplate::new(200)
5394 .set_body_json(make_activities_json(ALPACA_MAX_ACTIVITIES, "act")),
5395 )
5396 .mount(&server)
5397 .await;
5398
5399 let http = reqwest::Client::new();
5400 let rl = RateLimitTracker::new();
5401 let result = paginate_activities(&http, &rl, &server.uri(), "2025-01-01T00:00:00Z")
5402 .await
5403 .unwrap();
5404
5405 assert!(
5406 result.truncated,
5407 "must be truncated after MAX_ACTIVITY_PAGES pages"
5408 );
5409 assert_eq!(
5410 result.activities.len(),
5411 MAX_ACTIVITY_PAGES * ALPACA_MAX_ACTIVITIES,
5412 "must accumulate exactly MAX_ACTIVITY_PAGES * page_size activities"
5413 );
5414 assert_eq!(
5417 server.received_requests().await.unwrap().len(),
5418 MAX_ACTIVITY_PAGES,
5419 "loop must issue exactly MAX_ACTIVITY_PAGES requests then stop"
5420 );
5421 }
5422
5423 #[tokio::test]
5427 async fn fetch_raw_open_orders_499_results_returns_ok() {
5428 let server = MockServer::start().await;
5429 Mock::given(method("GET"))
5430 .and(path("/v2/orders"))
5431 .respond_with(
5432 ResponseTemplate::new(200).set_body_json(make_orders_json(MAX_OPEN_ORDERS - 1)),
5433 )
5434 .mount(&server)
5435 .await;
5436
5437 let http = reqwest::Client::new();
5438 let rl = RateLimitTracker::new();
5439 let result = fetch_raw_open_orders(&http, &rl, &server.uri(), &[]).await;
5440
5441 assert!(
5442 result.is_ok(),
5443 "499 orders must not trigger truncation: {result:?}"
5444 );
5445 assert_eq!(result.unwrap().len(), MAX_OPEN_ORDERS - 1);
5446 }
5447
5448 #[tokio::test]
5452 async fn fetch_raw_open_orders_500_results_returns_truncated_snapshot_error() {
5453 let server = MockServer::start().await;
5454 Mock::given(method("GET"))
5455 .and(path("/v2/orders"))
5456 .respond_with(
5457 ResponseTemplate::new(200).set_body_json(make_orders_json(MAX_OPEN_ORDERS)),
5458 )
5459 .mount(&server)
5460 .await;
5461
5462 let http = reqwest::Client::new();
5463 let rl = RateLimitTracker::new();
5464 let result = fetch_raw_open_orders(&http, &rl, &server.uri(), &[]).await;
5465
5466 assert!(
5467 matches!(
5468 result,
5469 Err(UnindexedClientError::TruncatedSnapshot { limit }) if limit == MAX_OPEN_ORDERS
5470 ),
5471 "500 orders must return TruncatedSnapshot, got: {result:?}"
5472 );
5473 }
5474
5475 #[tokio::test]
5483 async fn open_order_reduce_only_sell_sends_sell_to_close_intent() {
5484 use crate::client::ExecutionClient;
5485 use crate::order::request::{OrderRequestOpen, RequestOpen};
5486 use crate::order::{
5487 OrderKey, OrderKind, TimeInForce,
5488 id::{ClientOrderId, StrategyId},
5489 };
5490 use rust_decimal::Decimal;
5491 use rustrade_instrument::Side;
5492 use rustrade_instrument::exchange::ExchangeId;
5493 use rustrade_instrument::instrument::name::InstrumentNameExchange;
5494 use wiremock::matchers::{method, path};
5495
5496 let server = MockServer::start().await;
5497
5498 let captured_body = std::sync::Arc::new(parking_lot::Mutex::new(None));
5501 let captured_clone = captured_body.clone();
5502
5503 Mock::given(method("POST"))
5504 .and(path("/v2/orders"))
5505 .respond_with(move |req: &Request| {
5506 let body: serde_json::Value = serde_json::from_slice(&req.body).unwrap();
5508 *captured_clone.lock() = Some(body);
5509
5510 ResponseTemplate::new(200).set_body_json(serde_json::json!({
5511 "id": "test-order-id",
5512 "client_order_id": "test-cid",
5513 "symbol": "AAPL",
5514 "qty": "10",
5515 "filled_qty": "0",
5516 "side": "sell",
5517 "type": "market",
5518 "time_in_force": "ioc",
5519 "limit_price": null,
5520 "created_at": "2025-04-18T14:30:00Z"
5521 }))
5522 })
5523 .mount(&server)
5524 .await;
5525
5526 let config =
5528 AlpacaConfig::with_base_url("test-key".into(), "test-secret".into(), server.uri());
5529 let client = AlpacaClient::new(config);
5530
5531 let request = OrderRequestOpen {
5533 key: OrderKey {
5534 exchange: ExchangeId::AlpacaBroker,
5535 instrument: InstrumentNameExchange::new("AAPL"),
5536 strategy: StrategyId::new("test-strategy"),
5537 cid: ClientOrderId::new("test-cid"),
5538 },
5539 state: RequestOpen {
5540 side: Side::Sell,
5541 price: None,
5542 quantity: Decimal::new(10, 0),
5543 kind: OrderKind::Market,
5544 time_in_force: TimeInForce::ImmediateOrCancel,
5545 position_id: None,
5546 reduce_only: true, market: None,
5548 },
5549 };
5550
5551 let result = client
5553 .open_order(OrderRequestOpen {
5554 key: OrderKey {
5555 exchange: request.key.exchange,
5556 instrument: &request.key.instrument,
5557 strategy: request.key.strategy.clone(),
5558 cid: request.key.cid.clone(),
5559 },
5560 state: request.state.clone(),
5561 })
5562 .await;
5563
5564 assert!(result.is_some(), "open_order should return a result");
5566 let order = result.unwrap();
5567 assert!(
5568 order.state.is_accepted(),
5569 "order should be accepted: {:?}",
5570 order.state
5571 );
5572
5573 let body = captured_body
5575 .lock()
5576 .take()
5577 .expect("request body should be captured");
5578 assert_eq!(
5579 body.get("position_intent").and_then(|v| v.as_str()),
5580 Some("sell_to_close"),
5581 "reduce_only=true + Side::Sell should produce position_intent=sell_to_close, got: {body}"
5582 );
5583 }
5584
5585 #[tokio::test]
5587 async fn open_order_not_reduce_only_buy_sends_buy_to_open_intent() {
5588 use crate::client::ExecutionClient;
5589 use crate::order::request::{OrderRequestOpen, RequestOpen};
5590 use crate::order::{
5591 OrderKey, OrderKind, TimeInForce,
5592 id::{ClientOrderId, StrategyId},
5593 };
5594 use rust_decimal::Decimal;
5595 use rustrade_instrument::Side;
5596 use rustrade_instrument::exchange::ExchangeId;
5597 use rustrade_instrument::instrument::name::InstrumentNameExchange;
5598
5599 let server = MockServer::start().await;
5600
5601 let captured_body = std::sync::Arc::new(parking_lot::Mutex::new(None));
5602 let captured_clone = captured_body.clone();
5603
5604 Mock::given(method("POST"))
5605 .and(path("/v2/orders"))
5606 .respond_with(move |req: &Request| {
5607 let body: serde_json::Value = serde_json::from_slice(&req.body).unwrap();
5608 *captured_clone.lock() = Some(body);
5609
5610 ResponseTemplate::new(200).set_body_json(serde_json::json!({
5611 "id": "test-order-id",
5612 "client_order_id": "test-cid",
5613 "symbol": "AAPL",
5614 "qty": "10",
5615 "filled_qty": "0",
5616 "side": "buy",
5617 "type": "market",
5618 "time_in_force": "ioc",
5619 "limit_price": null,
5620 "created_at": "2025-04-18T14:30:00Z"
5621 }))
5622 })
5623 .mount(&server)
5624 .await;
5625
5626 let config =
5627 AlpacaConfig::with_base_url("test-key".into(), "test-secret".into(), server.uri());
5628 let client = AlpacaClient::new(config);
5629
5630 let instrument = InstrumentNameExchange::new("AAPL");
5631 let request = OrderRequestOpen {
5632 key: OrderKey {
5633 exchange: ExchangeId::AlpacaBroker,
5634 instrument: &instrument,
5635 strategy: StrategyId::new("test-strategy"),
5636 cid: ClientOrderId::new("test-cid"),
5637 },
5638 state: RequestOpen {
5639 side: Side::Buy,
5640 price: None,
5641 quantity: Decimal::new(10, 0),
5642 kind: OrderKind::Market,
5643 time_in_force: TimeInForce::ImmediateOrCancel,
5644 position_id: None,
5645 reduce_only: false, market: None,
5647 },
5648 };
5649
5650 let result = client.open_order(request).await;
5651
5652 assert!(result.is_some(), "open_order should return a result");
5653 let order = result.unwrap();
5654 assert!(
5655 order.state.is_accepted(),
5656 "order should be accepted: {:?}",
5657 order.state
5658 );
5659
5660 let body = captured_body
5661 .lock()
5662 .take()
5663 .expect("request body should be captured");
5664 assert_eq!(
5665 body.get("position_intent").and_then(|v| v.as_str()),
5666 Some("buy_to_open"),
5667 "reduce_only=false + Side::Buy should produce position_intent=buy_to_open, got: {body}"
5668 );
5669 }
5670
5671 fn activity_json(
5681 id: &str,
5682 order_id: &str,
5683 qty: &str,
5684 cum_qty: Option<&str>,
5685 ) -> serde_json::Value {
5686 let mut v = serde_json::json!({
5687 "id": id,
5688 "order_id": order_id,
5689 "symbol": "SPY",
5690 "side": "buy",
5691 "price": "100.00",
5692 "qty": qty,
5693 "transaction_time": "2025-04-18T14:30:00Z"
5694 });
5695 if let Some(c) = cum_qty {
5696 v["cum_qty"] = serde_json::Value::String(c.to_string());
5697 }
5698 v
5699 }
5700
5701 async fn drive_recover_fills_with(
5704 activities: Vec<serde_json::Value>,
5705 dedup: SharedDedupCache,
5706 ) -> Vec<UnindexedAccountEvent> {
5707 let server = MockServer::start().await;
5708 Mock::given(method("GET"))
5709 .and(path("/v2/account/activities"))
5710 .respond_with(
5711 ResponseTemplate::new(200).set_body_json(serde_json::Value::Array(activities)),
5712 )
5713 .mount(&server)
5714 .await;
5715
5716 let http = reqwest::Client::new();
5717 let rl = RateLimitTracker::new();
5718 let (tx, mut rx) = mpsc::unbounded_channel();
5719 recover_fills(
5720 &http,
5721 &rl,
5722 &[],
5723 &server.uri(),
5724 "2025-01-01T00:00:00Z",
5725 &tx,
5726 &dedup,
5727 )
5728 .await;
5729 drop(tx);
5730
5731 let mut out = Vec::new();
5732 while let Ok(event) = rx.try_recv() {
5733 out.push(event);
5734 }
5735 out
5736 }
5737
5738 async fn drive_recover_fills(
5739 activities: Vec<serde_json::Value>,
5740 ) -> Vec<UnindexedAccountEvent> {
5741 drive_recover_fills_with(activities, new_dedup_cache()).await
5742 }
5743
5744 fn trade_of(
5745 event: &UnindexedAccountEvent,
5746 ) -> &Trade<AssetNameExchange, InstrumentNameExchange> {
5747 match &event.kind {
5748 AccountEventKind::Trade(t) => t,
5749 other => panic!("expected a Trade event, got {other:?}"),
5750 }
5751 }
5752
5753 #[tokio::test]
5756 async fn a_recovered_fill_reports_the_cumulative_alpaca_sent() {
5757 let events = drive_recover_fills(vec![
5758 activity_json("act-1", "ord-1", "2", Some("2")),
5759 activity_json("act-2", "ord-1", "3", Some("5")),
5760 ])
5761 .await;
5762
5763 let reported: Vec<_> = events
5764 .iter()
5765 .map(|e| trade_of(e).order_filled_quantity)
5766 .collect();
5767 assert_eq!(
5768 reported,
5769 vec![
5770 Some(Decimal::from_str("2").unwrap()),
5771 Some(Decimal::from_str("5").unwrap())
5772 ]
5773 );
5774 }
5775
5776 #[tokio::test]
5783 async fn the_dedup_key_uses_the_venue_cumulative_not_an_intra_batch_count() {
5784 let events =
5786 drive_recover_fills(vec![activity_json("act-1", "ord-1", "2", Some("5"))]).await;
5787
5788 assert_eq!(
5789 trade_of(&events[0]).id.0.as_str(),
5790 "ord-1:5",
5791 "counting executions within the batch would key this as ord-1:2"
5792 );
5793 }
5794
5795 #[tokio::test]
5799 async fn a_fill_already_delivered_over_websocket_is_not_recovered_twice() {
5800 let dedup = new_dedup_cache();
5801
5802 let update = AlpacaTradeUpdate {
5804 event: SmolStr::new("partial_fill"),
5805 order: super::make_order_ws("ord-1", "SPY", "buy", "5"),
5806 price: Some("100.00"),
5807 qty: Some("2"),
5808 timestamp: None,
5809 };
5810 let [ws_event, _snapshot] = convert_trade_update(update);
5811 let ws_event = ws_event.expect("a partial_fill converts to a Trade");
5812 let ws_key = fill_dedup_key_from_event(&ws_event)
5813 .expect("a Trade carries a dedup key")
5814 .clone();
5815 assert!(
5816 !is_duplicate(&dedup, &ws_key),
5817 "precondition: first sighting"
5818 );
5819
5820 let events = drive_recover_fills_with(
5821 vec![activity_json("act-1", "ord-1", "2", Some("5"))],
5822 dedup,
5823 )
5824 .await;
5825
5826 assert!(
5827 events.is_empty(),
5828 "a fill already delivered over WebSocket must not be re-delivered by recovery, \
5829 got {events:?}"
5830 );
5831 }
5832
5833 #[tokio::test]
5836 async fn without_a_reported_cumulative_the_previous_behaviour_is_preserved() {
5837 let events = drive_recover_fills(vec![
5838 activity_json("act-1", "ord-1", "2", None),
5839 activity_json("act-2", "ord-1", "3", None),
5840 ])
5841 .await;
5842
5843 let keys: Vec<_> = events
5844 .iter()
5845 .map(|e| trade_of(e).id.0.as_str().to_owned())
5846 .collect();
5847 assert_eq!(keys, vec!["ord-1:2", "ord-1:5"], "intra-batch accumulation");
5848
5849 assert!(
5850 events
5851 .iter()
5852 .all(|e| trade_of(e).order_filled_quantity.is_none()),
5853 "a venue that reported no cumulative must not have one invented for it"
5854 );
5855 }
5856 }
5857}