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,
50 bracket::{
51 BracketOrderRequest as UnifiedBracketOrderRequest,
52 BracketOrderResult as UnifiedBracketOrderResult,
53 },
54 id::{ClientOrderId, OrderId, StrategyId},
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(
1179 &self,
1180 assets: &[AssetNameExchange],
1181 instruments: &[InstrumentNameExchange],
1182 ) -> Result<UnindexedAccountSnapshot, UnindexedClientError> {
1183 let base = self.base_url();
1184 let http = self.http.clone();
1185 let rl = &self.rate_limiter;
1186
1187 let wants_usd = assets.is_empty()
1188 || assets
1189 .iter()
1190 .any(|a| a.name().as_str().eq_ignore_ascii_case("usd"));
1191 let wants_non_usd = assets.is_empty()
1192 || assets
1193 .iter()
1194 .any(|a| !a.name().as_str().eq_ignore_ascii_case("usd"));
1195
1196 let account_url = format!("{base}/v2/account");
1199 let positions_url = format!("{base}/v2/positions");
1200 let balances = match (wants_usd, wants_non_usd) {
1201 (true, true) => {
1202 let (account, positions): (AlpacaAccount, Vec<AlpacaPosition>) = tokio::try_join!(
1203 rest_with_retry(rl, || http.get(&account_url)),
1204 rest_with_retry(rl, || http.get(&positions_url)),
1205 )?;
1206 let mut balances = convert_account_to_balances(&account, assets);
1207 balances.extend(convert_positions_to_balances(&positions, assets));
1208 balances
1209 }
1210 (true, false) => {
1211 let account: AlpacaAccount = rest_with_retry(rl, || http.get(&account_url)).await?;
1212 convert_account_to_balances(&account, assets)
1213 }
1214 (false, true) => {
1215 let positions: Vec<AlpacaPosition> =
1216 rest_with_retry(rl, || http.get(&positions_url)).await?;
1217 convert_positions_to_balances(&positions, assets)
1218 }
1219 (false, false) => Vec::new(),
1220 };
1221
1222 let open_orders = fetch_raw_open_orders(&http, rl, base, instruments).await?;
1223
1224 let instrument_snapshots = build_instrument_snapshots(open_orders, instruments);
1226
1227 Ok(AccountSnapshot::new(
1228 ExchangeId::AlpacaBroker,
1229 balances,
1230 instrument_snapshots,
1231 ))
1232 }
1233
1234 async fn account_stream(
1294 &self,
1295 _assets: &[AssetNameExchange], instruments: &[InstrumentNameExchange],
1300 ) -> Result<Self::AccountStream, UnindexedClientError> {
1301 let initial_ws = connect_and_subscribe(&self.config).await?;
1304
1305 let (tx, rx) = mpsc::unbounded_channel::<UnindexedAccountEvent>();
1310 let dedup = new_dedup_cache();
1311 let config = self.config.clone();
1312 let http = self.http.clone();
1313 let rate_limiter = self.rate_limiter.clone();
1314 let instruments = instruments.to_vec();
1315
1316 let cm_handle = tokio::spawn(connection_manager(
1317 tx,
1318 dedup,
1319 config,
1320 http,
1321 rate_limiter,
1322 instruments,
1323 Some(initial_ws),
1324 ));
1325
1326 let rx_stream = tokio_stream::wrappers::UnboundedReceiverStream::new(rx);
1327 let guarded = GracefulShutdownStream::new(rx_stream, cm_handle);
1328 Ok(futures::StreamExt::boxed(guarded))
1329 }
1330
1331 async fn cancel_order(
1332 &self,
1333 request: OrderRequestCancel<ExchangeId, &InstrumentNameExchange>,
1334 ) -> Option<UnindexedOrderResponseCancel> {
1335 let key = crate::order::OrderKey {
1336 exchange: request.key.exchange,
1337 instrument: request.key.instrument.clone(),
1338 strategy: request.key.strategy.clone(),
1339 cid: request.key.cid.clone(),
1340 };
1341
1342 let order_id: SmolStr = match &request.state.id {
1346 Some(id) => id.0.clone(),
1347 None => {
1348 warn!(
1349 instrument = %key.instrument,
1350 "Alpaca cancel_order: no exchange order ID available (clientOrderId-only cancel not supported)"
1351 );
1352 return Some(crate::order::request::OrderResponseCancel {
1353 key,
1354 state: Err(UnindexedOrderError::Rejected(ApiError::OrderRejected(
1355 "exchange order ID required for cancel (fetch_open_orders to resolve)"
1356 .into(),
1357 ))),
1358 });
1359 }
1360 };
1361
1362 let base = self.base_url();
1363 let http = self.http.clone();
1364 let url = format!("{base}/v2/orders/{order_id}");
1365
1366 match rest_delete_with_retry(&self.rate_limiter, || http.delete(&url)).await {
1367 Ok(()) => {
1368 let exchange_order_id = OrderId(order_id);
1369 Some(crate::order::request::OrderResponseCancel {
1372 key,
1373 state: Ok(Cancelled::new(exchange_order_id, Utc::now(), Decimal::ZERO)),
1374 })
1375 }
1376 Err(e) => Some(crate::order::request::OrderResponseCancel { key, state: Err(e) }),
1377 }
1378 }
1379
1380 async fn open_order(
1401 &self,
1402 request: OrderRequestOpen<ExchangeId, &InstrumentNameExchange>,
1403 ) -> Option<Order<ExchangeId, InstrumentNameExchange, UnindexedOrderState>> {
1404 let side = request.state.side;
1405 let reduce_only = request.state.reduce_only;
1406 self.open_order_inner(request, map_position_intent(side, reduce_only))
1407 .await
1408 }
1409
1410 async fn fetch_balances(
1418 &self,
1419 assets: &[AssetNameExchange],
1420 ) -> Result<Vec<AssetBalance<AssetNameExchange>>, UnindexedClientError> {
1421 let base = self.base_url();
1422 let http = self.http.clone();
1423 let mut result = Vec::new();
1424
1425 let wants_usd = assets.is_empty()
1428 || assets
1429 .iter()
1430 .any(|a| a.name().as_str().eq_ignore_ascii_case("usd"));
1431 if wants_usd {
1432 let account_url = format!("{base}/v2/account");
1434 let account: AlpacaAccount =
1435 rest_with_retry(&self.rate_limiter, || http.get(&account_url)).await?;
1436 result.extend(convert_account_to_balances(&account, assets));
1437 }
1438
1439 let wants_non_usd = assets.is_empty()
1441 || assets
1442 .iter()
1443 .any(|a| !a.name().as_str().eq_ignore_ascii_case("usd"));
1444 if wants_non_usd {
1445 let positions_url = format!("{base}/v2/positions");
1447 let positions: Vec<AlpacaPosition> =
1448 rest_with_retry(&self.rate_limiter, || http.get(&positions_url)).await?;
1449 result.extend(convert_positions_to_balances(&positions, assets));
1450 }
1451
1452 Ok(result)
1453 }
1454
1455 async fn fetch_open_orders(
1456 &self,
1457 instruments: &[InstrumentNameExchange],
1458 ) -> Result<Vec<Order<ExchangeId, InstrumentNameExchange, Open>>, UnindexedClientError> {
1459 let base = self.base_url();
1460 let http = self.http.clone();
1461
1462 let open_orders =
1463 fetch_raw_open_orders(&http, &self.rate_limiter, base, instruments).await?;
1464
1465 let result = open_orders
1466 .into_iter()
1467 .filter_map(|o| convert_open_order(&o))
1468 .collect();
1469 Ok(result)
1470 }
1471
1472 async fn fetch_trades(
1473 &self,
1474 time_since: DateTime<Utc>,
1475 instruments: &[InstrumentNameExchange],
1476 ) -> Result<Vec<Trade<AssetNameExchange, InstrumentNameExchange>>, UnindexedClientError> {
1477 let after_str = time_since.to_rfc3339();
1478 let base = self.base_url();
1479 let http = self.http.clone();
1480
1481 let page = paginate_activities(&http, &self.rate_limiter, base, &after_str).await?;
1482
1483 if page.truncated {
1486 return Err(UnindexedClientError::Truncated {
1487 limit: MAX_ACTIVITY_PAGES,
1488 });
1489 }
1490
1491 let trades = if instruments.is_empty() {
1494 page.activities
1495 .into_iter()
1496 .filter_map(|a| convert_activity_to_trade(&a))
1497 .collect()
1498 } else {
1499 let instrument_set: fnv::FnvHashSet<&str> =
1500 instruments.iter().map(|i| i.name().as_str()).collect();
1501 page.activities
1502 .into_iter()
1503 .filter(|a| instrument_set.contains(a.symbol.as_str()))
1504 .filter_map(|a| convert_activity_to_trade(&a))
1505 .collect()
1506 };
1507
1508 Ok(trades)
1509 }
1510}
1511
1512impl AlpacaClient {
1517 pub async fn open_order_with_intent(
1530 &self,
1531 request: OrderRequestOpen<ExchangeId, &InstrumentNameExchange>,
1532 intent: AlpacaPositionIntent,
1533 ) -> Option<Order<ExchangeId, InstrumentNameExchange, UnindexedOrderState>> {
1534 self.open_order_inner(request, intent).await
1535 }
1536
1537 pub async fn open_bracket_order(
1570 &self,
1571 request: AlpacaBracketOrderRequest,
1572 ) -> AlpacaBracketOrderResult {
1573 let order_key = crate::order::OrderKey::new(
1574 ExchangeId::AlpacaBroker,
1575 request.instrument.clone(),
1576 request.strategy.clone(),
1577 request.cid.clone(),
1578 );
1579
1580 let tif_str = match request.time_in_force {
1582 TimeInForce::GoodUntilEndOfDay => "day",
1583 TimeInForce::GoodUntilCancelled { post_only } => {
1584 if post_only {
1585 return AlpacaBracketOrderResult {
1586 parent: Order {
1587 key: order_key,
1588 side: request.side,
1589 price: Some(request.entry_price),
1590 quantity: request.quantity,
1591 kind: OrderKind::Limit,
1592 time_in_force: request.time_in_force,
1593 state: OrderState::inactive(OrderError::Rejected(
1594 ApiError::OrderRejected(
1595 "Alpaca does not support post_only for bracket orders"
1596 .to_string(),
1597 ),
1598 )),
1599 },
1600 };
1601 }
1602 "gtc"
1603 }
1604 other => {
1605 return AlpacaBracketOrderResult {
1606 parent: Order {
1607 key: order_key,
1608 side: request.side,
1609 price: Some(request.entry_price),
1610 quantity: request.quantity,
1611 kind: OrderKind::Limit,
1612 time_in_force: request.time_in_force,
1613 state: OrderState::inactive(OrderError::Rejected(ApiError::OrderRejected(
1614 format!(
1615 "Alpaca bracket orders only support Day or GTC time_in_force, got {:?}",
1616 other
1617 ),
1618 ))),
1619 },
1620 };
1621 }
1622 };
1623
1624 let price_ordering_ok = match request.side {
1627 Side::Buy => {
1628 request.stop_loss_price < request.entry_price
1629 && request.entry_price < request.take_profit_price
1630 }
1631 Side::Sell => {
1632 request.take_profit_price < request.entry_price
1633 && request.entry_price < request.stop_loss_price
1634 }
1635 };
1636 if !price_ordering_ok {
1637 return AlpacaBracketOrderResult {
1638 parent: Order {
1639 key: order_key,
1640 side: request.side,
1641 price: Some(request.entry_price),
1642 quantity: request.quantity,
1643 kind: OrderKind::Limit,
1644 time_in_force: request.time_in_force,
1645 state: OrderState::inactive(OrderError::Rejected(ApiError::OrderRejected(
1646 format!(
1647 "Invalid bracket price ordering for {:?} side: entry={}, take_profit={}, stop_loss={}",
1648 request.side,
1649 request.entry_price,
1650 request.take_profit_price,
1651 request.stop_loss_price,
1652 ),
1653 ))),
1654 },
1655 };
1656 }
1657
1658 if let Some(sl_limit) = request.stop_loss_limit_price {
1662 let sl_limit_ok = match request.side {
1663 Side::Buy => sl_limit <= request.stop_loss_price,
1664 Side::Sell => sl_limit >= request.stop_loss_price,
1665 };
1666 if !sl_limit_ok {
1667 return AlpacaBracketOrderResult {
1668 parent: Order {
1669 key: order_key,
1670 side: request.side,
1671 price: Some(request.entry_price),
1672 quantity: request.quantity,
1673 kind: OrderKind::Limit,
1674 time_in_force: request.time_in_force,
1675 state: OrderState::inactive(OrderError::Rejected(ApiError::OrderRejected(
1676 format!(
1677 "Invalid stop-loss limit price for {:?} bracket: \
1678 stop_loss_price={}, stop_loss_limit_price={}",
1679 request.side, request.stop_loss_price, sl_limit,
1680 ),
1681 ))),
1682 },
1683 };
1684 }
1685 }
1686
1687 let body = AlpacaOrderRequest {
1689 symbol: request.instrument.name().as_str(),
1690 qty: request.quantity.to_string(),
1691 side: map_side(request.side),
1692 order_type: "limit",
1693 time_in_force: tif_str,
1694 limit_price: Some(request.entry_price.to_string()),
1695 stop_price: None,
1696 trail_percent: None,
1697 trail_price: None,
1698 client_order_id: Some(request.cid.0.as_str()),
1699 position_intent: if is_options_or_equity_symbol(request.instrument.name().as_str()) {
1700 Some(map_position_intent(request.side, false))
1701 } else {
1702 None
1703 },
1704 order_class: Some("bracket"),
1705 take_profit: Some(TakeProfitParams {
1706 limit_price: request.take_profit_price.to_string(),
1707 }),
1708 stop_loss: Some(StopLossParams {
1709 stop_price: request.stop_loss_price.to_string(),
1710 limit_price: request.stop_loss_limit_price.map(|p| p.to_string()),
1711 }),
1712 };
1713
1714 let http = self.http.clone();
1715 let rl = &self.rate_limiter;
1716
1717 let result: Result<AlpacaOrderResponse, UnindexedClientError> =
1718 rest_with_retry(rl, || http.post(&self.orders_url).json(&body)).await;
1719
1720 match result {
1721 Ok(resp) => {
1722 let exchange_order_id = OrderId(SmolStr::new(&resp.id));
1723 let time_exchange = order_state_time(&resp);
1724 let filled_qty = Decimal::from_str(&resp.filled_qty).unwrap_or(Decimal::ZERO);
1725
1726 let state = if filled_qty >= request.quantity {
1727 OrderState::fully_filled(Filled::new(
1728 exchange_order_id,
1729 time_exchange,
1730 filled_qty,
1731 None,
1732 ))
1733 } else {
1734 OrderState::active(Open::new(exchange_order_id, time_exchange, filled_qty))
1735 };
1736
1737 AlpacaBracketOrderResult {
1738 parent: Order {
1739 key: order_key,
1740 side: request.side,
1741 price: Some(request.entry_price),
1742 quantity: request.quantity,
1743 kind: OrderKind::Limit,
1744 time_in_force: request.time_in_force,
1745 state,
1746 },
1747 }
1748 }
1749 Err(e) => {
1750 let order_err = match e {
1751 UnindexedClientError::Connectivity(ce) => OrderError::Connectivity(ce),
1752 UnindexedClientError::Api(ae) => OrderError::Rejected(ae),
1753 UnindexedClientError::TaskFailed(_)
1754 | UnindexedClientError::Internal(_)
1755 | UnindexedClientError::Truncated { .. }
1756 | UnindexedClientError::TruncatedSnapshot { .. } => {
1757 unreachable!("rest_with_retry (order path) does not produce these variants")
1758 }
1759 };
1760 AlpacaBracketOrderResult {
1761 parent: Order {
1762 key: order_key,
1763 side: request.side,
1764 price: Some(request.entry_price),
1765 quantity: request.quantity,
1766 kind: OrderKind::Limit,
1767 time_in_force: request.time_in_force,
1768 state: OrderState::inactive(order_err),
1769 },
1770 }
1771 }
1772 }
1773 }
1774
1775 async fn open_order_inner(
1776 &self,
1777 request: OrderRequestOpen<ExchangeId, &InstrumentNameExchange>,
1778 intent: AlpacaPositionIntent,
1779 ) -> Option<Order<ExchangeId, InstrumentNameExchange, UnindexedOrderState>> {
1780 let instrument = request.key.instrument.clone();
1781 let side = request.state.side;
1782 let price = request.state.price;
1783 let quantity = request.state.quantity;
1784 let kind = request.state.kind;
1785 let time_in_force = request.state.time_in_force;
1786 let cid = request.key.cid.clone();
1787
1788 let order_key = crate::order::OrderKey::new(
1789 ExchangeId::AlpacaBroker,
1790 instrument.clone(),
1791 request.key.strategy.clone(),
1792 cid.clone(),
1793 );
1794
1795 let tif_str = match map_time_in_force(time_in_force) {
1797 Ok(s) => s,
1798 Err(msg) => {
1799 return Some(Order {
1800 key: order_key,
1801 side,
1802 price,
1803 quantity,
1804 kind,
1805 time_in_force,
1806 state: OrderState::inactive(OrderError::Rejected(ApiError::OrderRejected(
1807 msg.to_string(),
1808 ))),
1809 });
1810 }
1811 };
1812
1813 let order_type_str = match map_order_kind(kind) {
1815 Some(s) => s,
1816 None => {
1817 return Some(Order {
1818 key: order_key,
1819 side,
1820 price,
1821 quantity,
1822 kind,
1823 time_in_force,
1824 state: OrderState::inactive(OrderError::UnsupportedOrderType(format!(
1825 "Alpaca connector does not yet support OrderKind::{kind:?}"
1826 ))),
1827 });
1828 }
1829 };
1830
1831 if let OrderKind::TrailingStop {
1833 offset_type: TrailingOffsetType::BasisPoints,
1834 ..
1835 } = kind
1836 {
1837 return Some(Order {
1838 key: order_key,
1839 side,
1840 price,
1841 quantity,
1842 kind,
1843 time_in_force,
1844 state: OrderState::inactive(OrderError::UnsupportedOrderType(
1845 "Alpaca does not support TrailingOffsetType::BasisPoints; \
1846 use Percentage or Absolute"
1847 .to_string(),
1848 )),
1849 });
1850 }
1851
1852 if matches!(kind, OrderKind::StopLimit { .. }) && price.is_none() {
1855 return Some(Order {
1856 key: order_key,
1857 side,
1858 price,
1859 quantity,
1860 kind,
1861 time_in_force,
1862 state: OrderState::inactive(OrderError::Rejected(ApiError::OrderRejected(
1863 "StopLimit order requires Order.price (the limit price) to be set".to_string(),
1864 ))),
1865 });
1866 }
1867
1868 let (stop_price, trail_percent, trail_price) = match kind {
1870 OrderKind::Stop { trigger_price } | OrderKind::StopLimit { trigger_price } => {
1871 (Some(trigger_price.to_string()), None, None)
1872 }
1873 OrderKind::TrailingStop {
1874 offset,
1875 offset_type,
1876 } => match offset_type {
1877 TrailingOffsetType::Percentage => (None, Some(offset.to_string()), None),
1878 TrailingOffsetType::Absolute => (None, None, Some(offset.to_string())),
1879 TrailingOffsetType::BasisPoints => unreachable!("validated above"),
1880 },
1881 OrderKind::Market
1883 | OrderKind::Limit
1884 | OrderKind::TakeProfit { .. }
1885 | OrderKind::TakeProfitLimit { .. }
1886 | OrderKind::TrailingStopLimit { .. } => (None, None, None),
1887 };
1888
1889 let body = AlpacaOrderRequest {
1890 symbol: instrument.name().as_str(),
1891 qty: quantity.to_string(),
1892 side: map_side(side),
1893 order_type: order_type_str,
1894 time_in_force: tif_str,
1895 limit_price: match kind {
1896 OrderKind::Limit | OrderKind::StopLimit { .. } => price.map(|p| p.to_string()),
1897 _ => None,
1898 },
1899 stop_price,
1900 trail_percent,
1901 trail_price,
1902 client_order_id: Some(cid.0.as_str()),
1903 position_intent: if is_options_or_equity_symbol(instrument.name().as_str()) {
1909 Some(intent)
1910 } else {
1911 None
1912 },
1913 order_class: None,
1915 take_profit: None,
1916 stop_loss: None,
1917 };
1918
1919 let http = self.http.clone();
1920 let rl = &self.rate_limiter;
1921
1922 let result: Result<AlpacaOrderResponse, UnindexedClientError> =
1923 rest_with_retry(rl, || http.post(&self.orders_url).json(&body)).await;
1924
1925 match result {
1926 Ok(resp) => {
1927 let exchange_order_id = OrderId(SmolStr::new(&resp.id));
1928 let time_exchange = order_state_time(&resp);
1929 let filled_qty = Decimal::from_str(&resp.filled_qty).unwrap_or(Decimal::ZERO);
1930
1931 let state = if filled_qty >= quantity {
1932 OrderState::fully_filled(Filled::new(
1934 exchange_order_id,
1935 time_exchange,
1936 filled_qty,
1937 None, ))
1939 } else {
1940 OrderState::active(Open::new(exchange_order_id, time_exchange, filled_qty))
1942 };
1943
1944 Some(Order {
1945 key: order_key,
1946 side,
1947 price,
1948 quantity,
1949 kind,
1950 time_in_force,
1951 state,
1952 })
1953 }
1954 Err(e) => {
1955 let order_err = match e {
1956 UnindexedClientError::Connectivity(ce) => OrderError::Connectivity(ce),
1957 UnindexedClientError::Api(ae) => OrderError::Rejected(ae),
1958 UnindexedClientError::TaskFailed(_)
1965 | UnindexedClientError::Internal(_)
1966 | UnindexedClientError::Truncated { .. }
1967 | UnindexedClientError::TruncatedSnapshot { .. } => {
1968 unreachable!(
1969 "rest_with_retry (order path) does not produce TaskFailed/Internal/Truncated/TruncatedSnapshot variants"
1970 )
1971 }
1972 };
1973 Some(Order {
1974 key: order_key,
1975 side,
1976 price,
1977 quantity,
1978 kind,
1979 time_in_force,
1980 state: OrderState::inactive(order_err),
1981 })
1982 }
1983 }
1984 }
1985}
1986
1987const MAX_OPEN_ORDERS: usize = 500;
1995
1996async fn fetch_raw_open_orders(
2004 http: &reqwest::Client,
2005 rate_limiter: &RateLimitTracker,
2006 base: &str,
2007 instruments: &[InstrumentNameExchange],
2008) -> Result<Vec<AlpacaOrderResponse>, UnindexedClientError> {
2009 let orders: Vec<AlpacaOrderResponse> = if instruments.is_empty() {
2010 rest_with_retry(rate_limiter, || {
2011 http.get(format!("{base}/v2/orders"))
2012 .query(&[("status", "open"), ("limit", "500")])
2013 })
2014 .await?
2015 } else {
2016 let symbols = instruments.iter().map(|i| i.name().as_str()).join(",");
2017 rest_with_retry(rate_limiter, || {
2018 http.get(format!("{base}/v2/orders")).query(&[
2019 ("status", "open"),
2020 ("limit", "500"),
2021 ("symbols", &symbols),
2022 ])
2023 })
2024 .await?
2025 };
2026 if orders.len() == MAX_OPEN_ORDERS {
2027 warn!(
2028 limit = MAX_OPEN_ORDERS,
2029 "Alpaca fetch_raw_open_orders: received exactly {MAX_OPEN_ORDERS} results — \
2030 response is likely truncated"
2031 );
2032 return Err(UnindexedClientError::TruncatedSnapshot {
2033 limit: MAX_OPEN_ORDERS,
2034 });
2035 }
2036 Ok(orders)
2037}
2038
2039const MAX_ACTIVITY_PAGES: usize = 50;
2048
2049struct ActivityPage {
2055 activities: Vec<AlpacaActivity>,
2056 truncated: bool,
2057}
2058
2059const PAGE_SIZE_STR: &str = "100"; const _: () = assert!(
2071 ALPACA_MAX_ACTIVITIES == 100,
2072 "PAGE_SIZE_STR must be updated to match ALPACA_MAX_ACTIVITIES",
2073);
2074
2075async fn paginate_activities(
2076 http: &reqwest::Client,
2077 rate_limiter: &RateLimitTracker,
2078 base: &str,
2079 after: &str,
2080) -> Result<ActivityPage, UnindexedClientError> {
2081 let mut all = Vec::with_capacity(ALPACA_MAX_ACTIVITIES);
2082 let mut page_token: Option<String> = None;
2083 let mut pages = 0usize;
2084 let mut truncated = false;
2085
2086 loop {
2087 if pages >= MAX_ACTIVITY_PAGES {
2088 truncated = true;
2089 break;
2090 }
2091 pages += 1;
2092 let page_token_ref = page_token.as_deref();
2094 let activities: Vec<AlpacaActivity> = rest_with_retry(rate_limiter, || {
2095 let mut req = http.get(format!("{base}/v2/account/activities")).query(&[
2096 ("activity_type", "FILL"),
2097 ("after", after),
2098 ("page_size", PAGE_SIZE_STR),
2099 ("direction", "asc"),
2100 ]);
2101 if let Some(token) = page_token_ref {
2102 req = req.query(&[("page_token", token)]);
2103 }
2104 req
2105 })
2106 .await?;
2107
2108 let page_len = activities.len();
2109 let page_token_candidate = activities.last().map(|a| a.id.clone());
2119 all.extend(activities);
2120
2121 if page_len < ALPACA_MAX_ACTIVITIES {
2122 break;
2123 }
2124 match page_token_candidate {
2125 Some(token) if !token.is_empty() => {
2126 debug!("Alpaca paginate_activities: fetching next page ({page_len} results)");
2127 page_token = Some(token);
2128 }
2129 _ => break,
2133 }
2134 }
2135
2136 Ok(ActivityPage {
2137 activities: all,
2138 truncated,
2139 })
2140}
2141
2142#[allow(clippy::cognitive_complexity)] async fn connection_manager(
2158 tx: mpsc::UnboundedSender<UnindexedAccountEvent>,
2159 dedup: SharedDedupCache,
2160 config: Arc<AlpacaConfig>,
2161 http: reqwest::Client,
2162 rate_limiter: Arc<RateLimitTracker>,
2163 instruments: Vec<InstrumentNameExchange>,
2164 initial_ws: Option<WebSocket>,
2165) {
2166 let mut backoff = ExponentialBackoff::new();
2167 let mut disconnect_time: Option<DateTime<Utc>> = None;
2168 let mut current_ws = initial_ws;
2169
2170 'outer: loop {
2171 let mut ws = match current_ws.take() {
2173 Some(ws) => ws,
2174 None => match connect_and_subscribe(&config).await {
2175 Ok(ws) => ws,
2176 Err(e) => {
2177 error!(%e, "Alpaca WS connect/subscribe failed");
2178 if !backoff.wait().await {
2179 error!("Alpaca max reconnect attempts exhausted");
2180 emit_stream_terminated(
2182 &tx,
2183 ExchangeId::AlpacaBroker,
2184 StreamTerminationReason::ReconnectBudgetExhausted {
2185 attempts: backoff.attempts(),
2186 last_error: e.to_string(),
2187 },
2188 );
2189 break;
2190 }
2191 continue;
2192 }
2193 },
2194 };
2195 info!("Alpaca account_stream connected and subscribed");
2196 backoff.reset();
2207
2208 if let Some(dt) = disconnect_time.take() {
2213 let base = config.rest_base_url();
2214 let after_str = dt.to_rfc3339();
2215 match tokio::time::timeout(
2216 Duration::from_secs(FILL_RECOVERY_TIMEOUT_SECS),
2217 recover_fills(
2218 &http,
2219 &rate_limiter,
2220 &instruments,
2221 base,
2222 &after_str,
2223 &tx,
2224 &dedup,
2225 ),
2226 )
2227 .await
2228 {
2229 Ok(()) => {}
2230 Err(_) => warn!(
2231 timeout_secs = FILL_RECOVERY_TIMEOUT_SECS,
2232 "Alpaca fill recovery timed out — some fills may be missing"
2233 ),
2234 }
2235 }
2236
2237 let mut last_message_time = Utc::now();
2250 let heartbeat = tokio::time::sleep(Duration::from_secs(HEARTBEAT_TIMEOUT_SECS));
2251 tokio::pin!(heartbeat);
2252
2253 macro_rules! reset_heartbeat {
2258 () => {
2259 heartbeat.as_mut().reset(
2260 tokio::time::Instant::now() + Duration::from_secs(HEARTBEAT_TIMEOUT_SECS),
2261 );
2262 last_message_time = Utc::now();
2263 };
2264 }
2265 enum DisconnectReason {
2268 ServerClose,
2269 Error(String),
2270 StreamEnded,
2271 HeartbeatTimeout,
2272 }
2273 let reason = loop {
2274 tokio::select! {
2275 msg = ws.next() => {
2276 match msg {
2277 Some(Ok(WsMessage::Ping(_))) => {
2278 reset_heartbeat!();
2281 }
2282 Some(Ok(WsMessage::Text(text))) => {
2283 process_ws_text(text.as_str(), &tx, &dedup, &mut backoff);
2284 reset_heartbeat!();
2285 }
2286 Some(Ok(WsMessage::Binary(bytes))) => {
2287 match std::str::from_utf8(&bytes) {
2289 Ok(text) => {
2290 process_ws_text(text, &tx, &dedup, &mut backoff);
2291 reset_heartbeat!();
2295 }
2296 Err(e) => warn!(%e, "Alpaca WS binary frame: not valid UTF-8"),
2297 }
2298 }
2299 Some(Ok(WsMessage::Close(frame))) => {
2300 warn!(frame = ?frame, "Alpaca WS closed by server");
2301 break DisconnectReason::ServerClose;
2302 }
2303 Some(Ok(_)) => {} Some(Err(e)) => {
2305 warn!(%e, "Alpaca WS error, reconnecting");
2306 break DisconnectReason::Error(e.to_string());
2307 }
2308 None => {
2309 warn!("Alpaca WS stream ended, reconnecting");
2310 break DisconnectReason::StreamEnded;
2311 }
2312 }
2313 }
2314 _ = &mut heartbeat => {
2315 warn!(
2316 timeout_secs = HEARTBEAT_TIMEOUT_SECS,
2317 "Alpaca heartbeat timeout, reconnecting"
2318 );
2319 break DisconnectReason::HeartbeatTimeout;
2322 }
2323 _ = tx.closed() => {
2324 debug!("Alpaca account_stream consumer dropped, terminating");
2325 let _ = tokio::time::timeout(
2326 Duration::from_secs(WS_CLOSE_TIMEOUT_SECS),
2327 ws.close(None),
2328 ).await;
2329 break 'outer;
2332 }
2333 }
2334 };
2335
2336 disconnect_time =
2341 Some(last_message_time - chrono::Duration::milliseconds(SIGNAL_RECOVERY_LOOKBACK_MS));
2342
2343 let _ =
2345 tokio::time::timeout(Duration::from_secs(WS_CLOSE_TIMEOUT_SECS), ws.close(None)).await;
2346
2347 if tx.is_closed() {
2348 break;
2351 }
2352 if !backoff.wait().await {
2353 error!("Alpaca max reconnect attempts exhausted, stream terminating");
2354 let last_error = match reason {
2357 DisconnectReason::HeartbeatTimeout => {
2358 format!("heartbeat timeout ({HEARTBEAT_TIMEOUT_SECS}s)")
2359 }
2360 DisconnectReason::ServerClose => "WebSocket closed by server".to_string(),
2361 DisconnectReason::Error(e) => e,
2362 DisconnectReason::StreamEnded => "WebSocket stream ended".to_string(),
2363 };
2364 emit_stream_terminated(
2365 &tx,
2366 ExchangeId::AlpacaBroker,
2367 StreamTerminationReason::ReconnectBudgetExhausted {
2368 attempts: backoff.attempts(),
2369 last_error,
2370 },
2371 );
2372 break;
2373 }
2374 }
2375}
2376
2377#[derive(Debug)]
2382enum HandshakeError {
2383 Transport(String),
2386 Auth(String),
2389}
2390
2391async fn connect_and_subscribe(config: &AlpacaConfig) -> Result<WebSocket, UnindexedClientError> {
2395 let url = config.ws_url();
2396 debug!(%url, "Alpaca: connecting to WebSocket");
2397
2398 let mut ws = rustrade_integration::protocol::websocket::connect(url)
2399 .await
2400 .map_err(|e| {
2401 UnindexedClientError::Connectivity(ConnectivityError::Socket(format!(
2402 "WS connect: {e}"
2403 )))
2404 })?;
2405
2406 let result = tokio::time::timeout(
2408 Duration::from_secs(WS_HANDSHAKE_TIMEOUT_SECS),
2409 ws_handshake(&mut ws, config),
2410 )
2411 .await;
2412
2413 match result {
2414 Ok(Ok(())) => Ok(ws),
2415 Ok(Err(HandshakeError::Transport(e))) => {
2416 let _ = ws.close(None).await;
2417 Err(UnindexedClientError::Connectivity(
2418 ConnectivityError::Socket(e),
2419 ))
2420 }
2421 Ok(Err(HandshakeError::Auth(e))) => {
2422 let _ = ws.close(None).await;
2423 Err(UnindexedClientError::Api(ApiError::Unauthenticated(e)))
2424 }
2425 Err(_) => {
2426 let _ = ws.close(None).await;
2427 Err(UnindexedClientError::Connectivity(
2428 ConnectivityError::Timeout,
2429 ))
2430 }
2431 }
2432}
2433
2434async fn ws_handshake(ws: &mut WebSocket, config: &AlpacaConfig) -> Result<(), HandshakeError> {
2442 let auth = serde_json::json!({
2444 "action": "auth",
2445 "key": config.api_key(),
2446 "secret": config.secret_key,
2449 })
2450 .to_string();
2451 ws.send(WsMessage::Text(auth.into()))
2452 .await
2453 .map_err(|e| HandshakeError::Transport(format!("WS auth send: {e}")))?;
2454
2455 loop {
2457 match ws.next().await {
2458 Some(Ok(WsMessage::Text(text))) => {
2459 if let Some(result) = check_auth_response(text.as_str()) {
2460 result?;
2461 break;
2462 }
2463 }
2464 Some(Ok(WsMessage::Binary(bytes))) => {
2465 if let Ok(text) = std::str::from_utf8(&bytes)
2466 && let Some(result) = check_auth_response(text)
2467 {
2468 result?;
2469 break;
2470 }
2471 }
2472 Some(Err(e)) => {
2473 return Err(HandshakeError::Transport(format!(
2474 "WS error during auth: {e}"
2475 )));
2476 }
2477 None => {
2478 return Err(HandshakeError::Transport(
2479 "WS closed before auth response".into(),
2480 ));
2481 }
2482 _ => {} }
2484 }
2485
2486 let sub = serde_json::json!({
2488 "action": "listen",
2489 "data": { "streams": ["trade_updates"] }
2490 })
2491 .to_string();
2492 ws.send(WsMessage::Text(sub.into()))
2493 .await
2494 .map_err(|e| HandshakeError::Transport(format!("WS subscribe send: {e}")))?;
2495
2496 loop {
2498 match ws.next().await {
2499 Some(Ok(WsMessage::Text(text))) => {
2500 if check_listen_ack(text.as_str()) {
2501 break;
2502 }
2503 if let Ok(msg) = serde_json::from_str::<AlpacaStreamMessage<'_>>(text.as_str()) {
2509 if msg.stream == "trade_updates" {
2510 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");
2511 } else {
2512 trace!(stream = %msg.stream, "WS message dropped during listen-ack handshake");
2513 }
2514 } else {
2515 trace!(
2516 bytes = text.len(),
2517 "WS non-stream message dropped during listen-ack handshake"
2518 );
2519 }
2520 }
2521 Some(Ok(WsMessage::Binary(bytes))) => {
2522 if let Ok(text) = std::str::from_utf8(&bytes)
2523 && check_listen_ack(text)
2524 {
2525 break;
2526 }
2527 trace!(
2528 bytes = bytes.len(),
2529 "WS binary message dropped during listen-ack handshake"
2530 );
2531 }
2532 Some(Err(e)) => {
2533 return Err(HandshakeError::Transport(format!(
2534 "WS error during subscribe: {e}"
2535 )));
2536 }
2537 None => {
2538 return Err(HandshakeError::Transport(
2539 "WS closed before subscribe ack".into(),
2540 ));
2541 }
2542 _ => {}
2543 }
2544 }
2545
2546 info!("Alpaca WS authenticated and subscribed to trade_updates");
2547 Ok(())
2548}
2549
2550fn check_auth_response(text: &str) -> Option<Result<(), HandshakeError>> {
2556 let msg = serde_json::from_str::<AlpacaStreamMessage<'_>>(text).ok()?;
2557 if msg.stream != "authorization" {
2558 return None;
2559 }
2560 #[derive(Deserialize)]
2561 struct AuthData<'a> {
2562 status: &'a str,
2563 }
2564 let data = serde_json::from_str::<AuthData<'_>>(msg.data.get()).ok()?;
2565 if data.status == "authorized" {
2566 Some(Ok(()))
2567 } else {
2568 Some(Err(HandshakeError::Auth(format!(
2569 "Alpaca WS auth failed: status={}",
2570 data.status
2571 ))))
2572 }
2573}
2574
2575fn check_listen_ack(text: &str) -> bool {
2579 let Ok(msg) = serde_json::from_str::<AlpacaStreamMessage<'_>>(text) else {
2580 return false;
2581 };
2582 if msg.stream != "listening" {
2583 return false;
2584 }
2585 #[derive(Deserialize)]
2586 struct ListenData<'a> {
2587 #[serde(borrow)]
2588 streams: Vec<&'a str>,
2589 }
2590 let Ok(data) = serde_json::from_str::<ListenData<'_>>(msg.data.get()) else {
2591 return false;
2592 };
2593 data.streams.contains(&"trade_updates")
2594}
2595
2596fn process_ws_text(
2602 text: &str,
2603 tx: &mpsc::UnboundedSender<UnindexedAccountEvent>,
2604 dedup: &SharedDedupCache,
2605 backoff: &mut ExponentialBackoff,
2606) {
2607 let msg: AlpacaStreamMessage<'_> = match serde_json::from_str(text) {
2608 Ok(m) => m,
2609 Err(e) => {
2610 trace!(
2611 %e,
2612 raw = ?&text[..text.len().min(200)],
2613 "Alpaca WS: skipped non-JSON message"
2614 );
2615 return;
2616 }
2617 };
2618
2619 backoff.reset();
2622
2623 match msg.stream.as_str() {
2624 "trade_updates" => {
2625 let update: AlpacaTradeUpdate<'_> = match serde_json::from_str(msg.data.get()) {
2626 Ok(u) => u,
2627 Err(e) => {
2628 warn!(
2633 %e,
2634 raw = ?&msg.data.get()[..msg.data.get().len().min(200)],
2635 "Alpaca WS trade_updates: failed to deserialize event — event dropped"
2636 );
2637 return;
2638 }
2639 };
2640
2641 if is_fill_event(&update) {
2645 let key = early_dedup_key(&update);
2646 if is_duplicate(dedup, &key) {
2647 trace!("Alpaca WS: skipping duplicate fill event (early check)");
2648 return;
2649 }
2650 }
2651
2652 for event in convert_trade_update(update).into_iter().flatten() {
2653 let _ = tx.send(event);
2656 }
2657 }
2658 "authorization" | "listening" => {
2659 trace!(stream = %msg.stream, "Alpaca WS: auth/listen ack received during stream");
2662 }
2663 other => {
2664 trace!(%other, "Alpaca WS: ignoring unknown stream type");
2665 }
2666 }
2667}
2668
2669fn fill_dedup_key_from_event(event: &UnindexedAccountEvent) -> Option<&SmolStr> {
2676 match &event.kind {
2677 AccountEventKind::Trade(trade) => Some(&trade.id.0),
2679 _ => None,
2680 }
2681}
2682
2683#[inline]
2687fn is_fill_event(update: &AlpacaTradeUpdate<'_>) -> bool {
2688 matches!(update.event.as_str(), "fill" | "partial_fill")
2689}
2690
2691fn early_dedup_key(update: &AlpacaTradeUpdate<'_>) -> SmolStr {
2701 let filled_qty = update.order.filled_qty.unwrap_or("0");
2702 let qty = Decimal::from_str(filled_qty).unwrap_or(Decimal::ZERO);
2705 format_smolstr!("{}:{}", update.order.id, qty.normalize())
2706}
2707
2708async fn recover_fills(
2714 http: &reqwest::Client,
2715 rate_limiter: &RateLimitTracker,
2716 instruments: &[InstrumentNameExchange],
2717 base: &str,
2718 after: &str,
2719 tx: &mpsc::UnboundedSender<UnindexedAccountEvent>,
2720 dedup: &SharedDedupCache,
2721) {
2722 info!(%after, instruments = instruments.len(), "Alpaca recovering fills after reconnect");
2725 let instrument_set: fnv::FnvHashSet<&str> = if instruments.is_empty() {
2726 fnv::FnvHashSet::default()
2727 } else {
2728 instruments.iter().map(|i| i.name().as_str()).collect()
2729 };
2730
2731 let page = match paginate_activities(http, rate_limiter, base, after).await {
2732 Ok(p) => p,
2733 Err(e) => {
2734 error!(%e, "Alpaca fill recovery: REST request failed");
2735 return;
2736 }
2737 };
2738
2739 if page.truncated {
2743 error!(
2744 max_pages = MAX_ACTIVITY_PAGES,
2745 "Alpaca fill recovery: max page limit reached, truncating — \
2746 fills from this outage are permanently lost. Manual reconciliation required."
2747 );
2748 }
2749
2750 let activities = page.activities;
2751
2752 let mut recovered = 0u32;
2753 let mut duplicates = 0u32;
2754
2755 let mut cumulative_qty: FnvHashMap<&str, Decimal> = FnvHashMap::default();
2772
2773 for activity in &activities {
2774 if !instrument_set.is_empty() && !instrument_set.contains(activity.symbol.as_str()) {
2775 continue;
2779 }
2780
2781 let exec_qty = Decimal::from_str(&activity.qty).unwrap_or(Decimal::ZERO);
2796 let cum = cumulative_qty
2797 .entry(activity.order_id.as_str())
2798 .or_default();
2799 *cum += exec_qty;
2800 let cumulative = *cum;
2801
2802 let mut trade = match convert_activity_to_trade(activity) {
2803 Some(t) => t,
2804 None => {
2805 warn!(id = %activity.id, symbol = %activity.symbol, "Alpaca: skipping activity with unparseable fields");
2806 continue; }
2808 };
2809
2810 let key_cumulative = trade.order_filled_quantity.unwrap_or(cumulative);
2824 trade.id = TradeId(format_smolstr!(
2825 "{}:{}",
2826 activity.order_id,
2827 key_cumulative.normalize()
2828 ));
2829
2830 let event =
2831 UnindexedAccountEvent::new(ExchangeId::AlpacaBroker, AccountEventKind::Trade(trade));
2832
2833 if fill_dedup_key_from_event(&event).is_some_and(|k| is_duplicate(dedup, k)) {
2835 duplicates += 1;
2836 continue;
2837 }
2838 if tx.send(event).is_err() {
2839 debug!("Alpaca fill recovery: consumer dropped during recovery");
2840 return;
2841 }
2842 recovered += 1;
2843 }
2844
2845 info!(recovered, duplicates, "Alpaca fill recovery complete");
2846}
2847
2848fn convert_account_to_balances(
2861 account: &AlpacaAccount,
2862 assets: &[AssetNameExchange],
2863) -> Vec<AssetBalance<AssetNameExchange>> {
2864 let usd_entry = assets
2867 .iter()
2868 .find(|a| a.name().as_str().eq_ignore_ascii_case("usd"));
2869
2870 if !assets.is_empty() && usd_entry.is_none() {
2872 return Vec::new();
2873 }
2874
2875 let usd_name = usd_entry
2876 .cloned()
2877 .unwrap_or_else(|| AssetNameExchange::new("usd"));
2878
2879 let total = Decimal::from_str(&account.equity).unwrap_or(Decimal::ZERO);
2880 let free = account
2881 .options_buying_power
2882 .as_deref()
2883 .and_then(|s| Decimal::from_str(s).ok())
2884 .filter(|d| !d.is_zero())
2889 .unwrap_or_else(|| Decimal::from_str(&account.buying_power).unwrap_or(Decimal::ZERO));
2890
2891 vec![AssetBalance::new(
2892 usd_name,
2893 Balance::new(total, free),
2894 Utc::now(),
2895 )]
2896}
2897
2898fn convert_positions_to_balances(
2909 positions: &[AlpacaPosition],
2910 assets: &[AssetNameExchange],
2911) -> Vec<AssetBalance<AssetNameExchange>> {
2912 let now = Utc::now();
2913 positions
2914 .iter()
2915 .filter(|p| p.asset_class.eq_ignore_ascii_case("crypto"))
2916 .filter_map(|p| {
2917 let base = p
2920 .symbol
2921 .split('/')
2922 .next()
2923 .map(|s| s.to_ascii_lowercase())
2924 .unwrap_or_else(|| p.symbol.to_ascii_lowercase());
2925
2926 if !assets.is_empty()
2928 && !assets
2929 .iter()
2930 .any(|a| a.name().as_str().eq_ignore_ascii_case(&base))
2931 {
2932 return None;
2933 }
2934
2935 let total = Decimal::from_str(&p.qty).unwrap_or(Decimal::ZERO);
2938 let free = Decimal::from_str(&p.qty_available).unwrap_or(Decimal::ZERO);
2939 let asset_name = AssetNameExchange::new(base);
2940 Some(AssetBalance::new(
2941 asset_name,
2942 Balance::new(total, free),
2943 now,
2944 ))
2945 })
2946 .collect()
2947}
2948
2949fn build_instrument_snapshots(
2955 orders: Vec<AlpacaOrderResponse>,
2956 instruments: &[InstrumentNameExchange],
2957) -> Vec<InstrumentAccountSnapshot<ExchangeId, AssetNameExchange, InstrumentNameExchange>> {
2958 let mut by_symbol: IndexMap<SmolStr, Vec<_>> = IndexMap::new();
2960
2961 for order in orders {
2962 let sym = SmolStr::new(&order.symbol);
2963 if let Some(converted) = convert_open_order(&order) {
2964 let wrapped = crate::order::Order {
2965 key: converted.key,
2966 side: converted.side,
2967 price: converted.price,
2968 quantity: converted.quantity,
2969 kind: converted.kind,
2970 time_in_force: converted.time_in_force,
2971 state: OrderState::active(converted.state),
2972 };
2973 by_symbol.entry(sym).or_default().push(wrapped);
2974 }
2975 }
2976
2977 if instruments.is_empty() {
2979 by_symbol
2980 .into_iter()
2981 .map(|(sym, orders)| {
2982 InstrumentAccountSnapshot::new(InstrumentNameExchange::new(sym), orders, None, None)
2983 })
2984 .collect()
2985 } else {
2986 instruments
2987 .iter()
2988 .map(|inst| {
2989 let orders = by_symbol
2992 .swap_remove(inst.name().as_str())
2993 .unwrap_or_default();
2994 InstrumentAccountSnapshot::new(inst.clone(), orders, None, None)
2995 })
2996 .collect()
2997 }
2998}
2999
3000fn convert_open_order(
3002 o: &AlpacaOrderResponse,
3003) -> Option<Order<ExchangeId, InstrumentNameExchange, Open>> {
3004 let order_id = OrderId(SmolStr::new(&o.id));
3005 let cid = o
3006 .client_order_id
3007 .as_deref()
3008 .map(ClientOrderId::new)
3009 .unwrap_or_else(|| ClientOrderId::new(o.id.as_str()));
3010
3011 let instrument = InstrumentNameExchange::new(&o.symbol);
3012 let side = parse_side(&o.side)?;
3013 let quantity = Decimal::from_str(o.qty.as_deref().unwrap_or("0")).ok()?;
3014 if quantity.is_zero() {
3017 return None;
3018 }
3019 let price = o
3020 .limit_price
3021 .as_deref()
3022 .and_then(|s| Decimal::from_str(s).ok());
3023 let filled_qty = Decimal::from_str(&o.filled_qty).unwrap_or(Decimal::ZERO);
3024 let kind = parse_order_kind(
3025 &o.order_type,
3026 o.stop_price.as_deref(),
3027 o.trail_percent.as_deref(),
3028 o.trail_price.as_deref(),
3029 )?;
3030 let time_in_force = parse_time_in_force(&o.time_in_force);
3031 let time_exchange = order_state_time(o);
3032
3033 Some(Order {
3034 key: OrderKey::new(
3035 ExchangeId::AlpacaBroker,
3036 instrument,
3037 StrategyId::unknown(),
3039 cid,
3040 ),
3041 side,
3042 price,
3043 quantity,
3044 kind,
3045 time_in_force,
3046 state: Open::new(order_id, time_exchange, filled_qty),
3047 })
3048}
3049
3050fn convert_activity_to_trade(
3052 a: &AlpacaActivity,
3053) -> Option<Trade<AssetNameExchange, InstrumentNameExchange>> {
3054 let trade_id = TradeId::new(&a.id);
3055 let order_id = OrderId(SmolStr::new(&a.order_id));
3056 let instrument = InstrumentNameExchange::new(&a.symbol);
3057 let side = parse_side(&a.side)?;
3058 let price = Decimal::from_str(&a.price).ok()?;
3059 let quantity = Decimal::from_str(&a.qty).ok()?;
3060 let order_filled_quantity = a.cum_qty.as_deref().and_then(|s| Decimal::from_str(s).ok());
3063 let time_exchange = parse_timestamp(&a.transaction_time).unwrap_or_else(|| {
3064 warn!(id = %a.id, "Alpaca activity: unparseable transaction_time, using now");
3065 Utc::now()
3066 });
3067
3068 Some(Trade::new(
3073 trade_id,
3074 order_id,
3075 instrument,
3076 StrategyId::unknown(),
3077 time_exchange,
3078 side,
3079 price,
3080 quantity,
3081 order_filled_quantity,
3082 AssetFees::new(
3083 AssetNameExchange::from("USD"),
3084 Decimal::ZERO,
3085 Some(Decimal::ZERO),
3086 ),
3087 ))
3088}
3089
3090fn ws_order_snapshot(
3100 order: &AlpacaOrderWs<'_>,
3101 instrument: InstrumentNameExchange,
3102 cid: ClientOrderId,
3103 order_id: OrderId,
3104 time_exchange: DateTime<Utc>,
3105) -> Option<UnindexedAccountEvent> {
3106 let side = parse_side(&order.side)?;
3107 let quantity = Decimal::from_str(order.qty.unwrap_or("0")).unwrap_or(Decimal::ZERO);
3108 if quantity.is_zero() {
3109 trace!(order_id = %order.id, "Alpaca WS: skipping notional order snapshot (qty=None)");
3110 return None;
3111 }
3112 let price = order.limit_price.and_then(|s| Decimal::from_str(s).ok());
3113 let filled_qty = Decimal::from_str(order.filled_qty.unwrap_or("0")).unwrap_or(Decimal::ZERO);
3114 let kind = parse_order_kind(
3115 &order.order_type,
3116 order.stop_price,
3117 order.trail_percent,
3118 order.trail_price,
3119 )?;
3120 let time_in_force = parse_time_in_force(&order.time_in_force);
3121
3122 let order_snapshot = crate::order::Order {
3123 key: OrderKey::new(
3124 ExchangeId::AlpacaBroker,
3125 instrument,
3126 StrategyId::unknown(),
3127 cid,
3128 ),
3129 side,
3130 price,
3131 quantity,
3132 kind,
3133 time_in_force,
3134 state: OrderState::active(Open::new(order_id, time_exchange, filled_qty)),
3135 };
3136 Some(UnindexedAccountEvent::new(
3137 ExchangeId::AlpacaBroker,
3138 AccountEventKind::OrderSnapshot(rustrade_integration::collection::snapshot::Snapshot(
3139 order_snapshot,
3140 )),
3141 ))
3142}
3143
3144fn ws_fill_order_is_live(status: &str) -> bool {
3154 matches!(status, "partially_filled" | "filled")
3155}
3156
3157fn convert_trade_update(update: AlpacaTradeUpdate<'_>) -> [Option<UnindexedAccountEvent>; 2] {
3167 let event_str = update.event.as_str();
3170 if !matches!(
3171 event_str,
3172 "fill"
3173 | "partial_fill"
3174 | "new"
3175 | "accepted"
3176 | "pending_new"
3177 | "canceled"
3178 | "expired"
3179 | "replaced"
3180 | "done_for_day"
3181 | "rejected"
3182 ) {
3183 trace!(event = %event_str, "Alpaca WS: ignoring trade_updates event type");
3184 return [None, None];
3185 }
3186
3187 let order = &update.order;
3188 let instrument = InstrumentNameExchange::new(&*order.symbol);
3189 let order_id = OrderId(order.id.clone());
3190 let cid = order
3191 .client_order_id
3192 .as_deref()
3193 .map(ClientOrderId::new)
3194 .unwrap_or_else(|| ClientOrderId::new(order.id.as_str()));
3195
3196 match event_str {
3197 "fill" | "partial_fill" => {
3198 let (Some(price), Some(quantity), Some(side)) = (
3200 update.price.and_then(|s| Decimal::from_str(s).ok()),
3201 update.qty.and_then(|s| Decimal::from_str(s).ok()),
3202 parse_side(&order.side),
3203 ) else {
3204 return [None, None];
3205 };
3206 let time_exchange = update
3207 .timestamp
3208 .and_then(parse_timestamp)
3209 .unwrap_or_else(Utc::now);
3210
3211 let cum_qty = Decimal::from_str(order.filled_qty.unwrap_or("0"))
3221 .inspect_err(|e| {
3222 warn!(
3223 order_id = %order.id,
3224 filled_qty = ?order.filled_qty,
3225 %e,
3226 "Alpaca WS: failed to parse filled_qty — dedup key will use 0, \
3227 a second malformed fill on the same order would be deduplicated away"
3228 );
3229 })
3230 .unwrap_or(Decimal::ZERO);
3231 let trade_id = TradeId(format_smolstr!("{}:{}", order.id, cum_qty.normalize()));
3232
3233 let trade = Trade::new(
3237 trade_id,
3238 order_id.clone(),
3239 instrument.clone(),
3240 StrategyId::unknown(),
3241 time_exchange,
3242 side,
3243 price,
3244 quantity,
3245 Some(cum_qty),
3247 AssetFees::new(
3248 AssetNameExchange::from("USD"),
3249 Decimal::ZERO,
3250 Some(Decimal::ZERO),
3251 ),
3252 );
3253 let trade_event = UnindexedAccountEvent::new(
3254 ExchangeId::AlpacaBroker,
3255 AccountEventKind::Trade(trade),
3256 );
3257
3258 let snapshot_event = if ws_fill_order_is_live(&order.status) {
3262 ws_order_snapshot(order, instrument, cid, order_id, time_exchange)
3263 } else {
3264 trace!(
3265 order_id = %order.id,
3266 status = %order.status,
3267 "Alpaca WS: fill for an order the exchange no longer reports as working — \
3268 emitting the execution without an order snapshot"
3269 );
3270 None
3271 };
3272
3273 [Some(trade_event), snapshot_event]
3274 }
3275
3276 "new" | "accepted" | "pending_new" => {
3277 let time_exchange = update
3279 .timestamp
3280 .and_then(parse_timestamp)
3281 .unwrap_or_else(Utc::now);
3282
3283 [
3284 ws_order_snapshot(order, instrument, cid, order_id, time_exchange),
3285 None,
3286 ]
3287 }
3288
3289 "canceled" | "expired" | "replaced" | "done_for_day" => {
3290 let time_exchange = update
3298 .timestamp
3299 .and_then(parse_timestamp)
3300 .unwrap_or_else(Utc::now);
3301 let filled_qty =
3302 Decimal::from_str(order.filled_qty.unwrap_or("0")).unwrap_or(Decimal::ZERO);
3303 let cancelled = Cancelled::new(order_id, time_exchange, filled_qty);
3304 let response = crate::order::request::OrderResponseCancel {
3305 key: OrderKey::new(
3306 ExchangeId::AlpacaBroker,
3307 instrument,
3308 StrategyId::unknown(),
3309 cid,
3310 ),
3311 state: Ok(cancelled),
3312 };
3313 [
3314 Some(UnindexedAccountEvent::new(
3315 ExchangeId::AlpacaBroker,
3316 AccountEventKind::OrderCancelled(response),
3317 )),
3318 None,
3319 ]
3320 }
3321
3322 "rejected" => {
3323 let response = crate::order::request::OrderResponseCancel {
3324 key: OrderKey::new(
3325 ExchangeId::AlpacaBroker,
3326 instrument,
3327 StrategyId::unknown(),
3328 cid,
3329 ),
3330 state: Err(UnindexedOrderError::Rejected(ApiError::OrderRejected(
3331 format!("order rejected: status={}", order.status),
3332 ))),
3333 };
3334 [
3335 Some(UnindexedAccountEvent::new(
3336 ExchangeId::AlpacaBroker,
3337 AccountEventKind::OrderCancelled(response),
3338 )),
3339 None,
3340 ]
3341 }
3342
3343 _ => unreachable!("convert_trade_update: unrecognised event passed early-return guard"),
3346 }
3347}
3348
3349fn parse_side(s: &str) -> Option<Side> {
3354 match s {
3355 "buy" | "Buy" | "BUY" => Some(Side::Buy),
3356 "sell" | "Sell" | "SELL" => Some(Side::Sell),
3357 other => {
3358 trace!(%other, "Alpaca: unknown order side");
3359 None
3360 }
3361 }
3362}
3363
3364fn parse_order_kind(
3365 order_type: &str,
3366 stop_price: Option<&str>,
3367 trail_percent: Option<&str>,
3368 trail_price: Option<&str>,
3369) -> Option<OrderKind> {
3370 match order_type {
3371 "market" | "Market" => Some(OrderKind::Market),
3372 "limit" | "Limit" => Some(OrderKind::Limit),
3373 "stop" | "Stop" => {
3374 let trigger_price = stop_price.and_then(|s| Decimal::from_str(s).ok())?;
3375 Some(OrderKind::Stop { trigger_price })
3376 }
3377 "stop_limit" | "Stop_limit" => {
3378 let trigger_price = stop_price.and_then(|s| Decimal::from_str(s).ok())?;
3379 Some(OrderKind::StopLimit { trigger_price })
3380 }
3381 "trailing_stop" | "Trailing_stop" => {
3382 if let Some(pct) = trail_percent.and_then(|s| Decimal::from_str(s).ok()) {
3384 Some(OrderKind::TrailingStop {
3385 offset: pct,
3386 offset_type: TrailingOffsetType::Percentage,
3387 })
3388 } else if let Some(price) = trail_price.and_then(|s| Decimal::from_str(s).ok()) {
3389 Some(OrderKind::TrailingStop {
3390 offset: price,
3391 offset_type: TrailingOffsetType::Absolute,
3392 })
3393 } else {
3394 trace!("Alpaca: trailing_stop missing trail_percent and trail_price");
3395 None
3396 }
3397 }
3398 other => {
3399 trace!(%other, "Alpaca: unsupported order type, skipping");
3400 None
3401 }
3402 }
3403}
3404
3405fn parse_time_in_force(s: &str) -> TimeInForce {
3406 match s {
3407 "gtc" | "GTC" => TimeInForce::GoodUntilCancelled { post_only: false },
3408 "day" | "DAY" => TimeInForce::GoodUntilEndOfDay,
3409 "fok" | "FOK" => TimeInForce::FillOrKill,
3410 "ioc" | "IOC" => TimeInForce::ImmediateOrCancel,
3411 other => {
3412 warn!(%other, "Alpaca: unknown time_in_force, defaulting to GoodUntilEndOfDay");
3413 TimeInForce::GoodUntilEndOfDay
3414 }
3415 }
3416}
3417
3418fn order_state_time(order: &AlpacaOrderResponse) -> DateTime<Utc> {
3428 order
3429 .updated_at
3430 .as_deref()
3431 .and_then(parse_timestamp)
3432 .or_else(|| parse_timestamp(&order.created_at))
3433 .unwrap_or_else(Utc::now)
3434}
3435
3436fn parse_timestamp(s: &str) -> Option<DateTime<Utc>> {
3437 DateTime::parse_from_rfc3339(s)
3438 .ok()
3439 .map(|dt| dt.with_timezone(&Utc))
3440}
3441
3442fn map_side(side: Side) -> &'static str {
3443 match side {
3444 Side::Buy => "buy",
3445 Side::Sell => "sell",
3446 }
3447}
3448
3449fn map_order_kind(kind: OrderKind) -> Option<&'static str> {
3450 match kind {
3451 OrderKind::Market => Some("market"),
3452 OrderKind::Limit => Some("limit"),
3453 OrderKind::Stop { .. } => Some("stop"),
3454 OrderKind::StopLimit { .. } => Some("stop_limit"),
3455 OrderKind::TrailingStop { .. } => Some("trailing_stop"),
3456 OrderKind::TakeProfit { .. }
3458 | OrderKind::TakeProfitLimit { .. }
3459 | OrderKind::TrailingStopLimit { .. } => None,
3460 }
3461}
3462
3463fn map_time_in_force(tif: TimeInForce) -> Result<&'static str, &'static str> {
3472 match tif {
3473 TimeInForce::GoodUntilCancelled { post_only } => {
3474 if post_only {
3475 return Err("Alpaca does not support post_only orders");
3476 }
3477 Ok("gtc")
3478 }
3479 TimeInForce::GoodUntilEndOfDay => Ok("day"),
3480 TimeInForce::FillOrKill => Ok("fok"),
3481 TimeInForce::ImmediateOrCancel => Ok("ioc"),
3482 TimeInForce::AtOpen => Ok("opg"),
3483 TimeInForce::AtClose => Ok("cls"),
3484 TimeInForce::GoodTillDate { .. } => {
3488 Err("Alpaca GoodTillDate is not yet wired through this client")
3489 }
3490 }
3491}
3492
3493fn is_options_or_equity_symbol(symbol: &str) -> bool {
3504 !symbol.contains('/')
3505}
3506
3507fn map_position_intent(side: Side, reduce_only: bool) -> AlpacaPositionIntent {
3516 match (reduce_only, side) {
3517 (false, Side::Buy) => AlpacaPositionIntent::BuyToOpen,
3518 (false, Side::Sell) => AlpacaPositionIntent::SellToOpen,
3519 (true, Side::Buy) => AlpacaPositionIntent::BuyToClose,
3520 (true, Side::Sell) => AlpacaPositionIntent::SellToClose,
3521 }
3522}
3523
3524fn parse_api_error(status: reqwest::StatusCode, message: &str) -> crate::error::UnindexedApiError {
3529 if status == reqwest::StatusCode::TOO_MANY_REQUESTS {
3531 return ApiError::RateLimit;
3532 }
3533
3534 let lower = message.to_ascii_lowercase();
3536 match status.as_u16() {
3537 422 if lower.contains("already") => ApiError::OrderAlreadyCancelled,
3541 422 if lower.contains("insufficient") => {
3546 ApiError::BalanceInsufficient(AssetNameExchange::new("usd"), message.to_owned())
3547 }
3548 401 => ApiError::Unauthenticated(format!("unauthorized: {message}")),
3549 403 => ApiError::Unauthenticated(format!("forbidden: {message}")),
3550 404 => ApiError::OrderRejected(format!("order not found: {message}")),
3551 _ => ApiError::OrderRejected(message.to_owned()),
3552 }
3553}
3554
3555fn parse_order_error(status: reqwest::StatusCode, message: &str) -> UnindexedOrderError {
3557 UnindexedOrderError::Rejected(parse_api_error(status, message))
3558}
3559
3560fn connectivity_err(msg: impl Into<String>) -> UnindexedClientError {
3561 UnindexedClientError::Connectivity(ConnectivityError::Socket(msg.into()))
3562}
3563
3564impl BracketOrderClient for AlpacaClient {
3569 async fn open_bracket_order(
3570 &self,
3571 request: UnifiedBracketOrderRequest<ExchangeId, &InstrumentNameExchange>,
3572 ) -> UnifiedBracketOrderResult {
3573 let alpaca_request = AlpacaBracketOrderRequest {
3574 instrument: request.key.instrument.clone(),
3575 strategy: request.key.strategy.clone(),
3576 cid: request.key.cid.clone(),
3577 side: request.state.side,
3578 quantity: request.state.quantity,
3579 entry_price: request.state.entry_price,
3580 take_profit_price: request.state.take_profit_price,
3581 stop_loss_price: request.state.stop_loss_price,
3582 stop_loss_limit_price: request.state.stop_loss_limit_price,
3583 time_in_force: request.state.time_in_force,
3584 };
3585
3586 let result = self.open_bracket_order(alpaca_request).await;
3587
3588 UnifiedBracketOrderResult::parent_only(result.parent)
3589 }
3590}
3591
3592#[cfg(test)]
3597#[allow(clippy::unwrap_used, clippy::expect_used)] mod tests {
3599 use super::*;
3600
3601 #[test]
3602 fn test_alpaca_config_new_uses_paper_trading_by_default() {
3603 let cfg = AlpacaConfig::new("my_key".into(), "my_secret".into());
3604 assert!(cfg.paper);
3605 }
3606
3607 #[test]
3608 fn test_alpaca_config_debug_redacts_credentials() {
3609 let cfg = AlpacaConfig::new("my_key".into(), "my_secret".into());
3610 let debug = format!("{cfg:?}");
3611 assert!(!debug.contains("my_key"), "api_key should be redacted");
3612 assert!(
3613 !debug.contains("my_secret"),
3614 "secret_key should be redacted"
3615 );
3616 assert!(debug.contains("paper: true"));
3617 }
3618
3619 #[test]
3620 fn test_alpaca_config_deserialize_omitted_paper_defaults_to_paper() {
3621 let cfg: AlpacaConfig = serde_json::from_str(
3622 r#"{
3623 "api_key": "my_key",
3624 "secret_key": "my_secret"
3625 }"#,
3626 )
3627 .unwrap();
3628
3629 assert!(cfg.paper);
3630 assert_eq!(cfg.rest_base_url(), "https://paper-api.alpaca.markets");
3631 }
3632
3633 #[test]
3634 fn test_alpaca_config_deserialize_paper_true() {
3635 let cfg: AlpacaConfig = serde_json::from_str(
3636 r#"{
3637 "api_key": "my_key",
3638 "secret_key": "my_secret",
3639 "paper": true
3640 }"#,
3641 )
3642 .unwrap();
3643
3644 assert!(cfg.paper);
3645 assert_eq!(cfg.rest_base_url(), "https://paper-api.alpaca.markets");
3646 }
3647
3648 #[test]
3649 fn test_alpaca_config_deserialize_paper_false() {
3650 let cfg: AlpacaConfig = serde_json::from_str(
3651 r#"{
3652 "api_key": "my_key",
3653 "secret_key": "my_secret",
3654 "paper": false
3655 }"#,
3656 )
3657 .unwrap();
3658
3659 assert!(!cfg.paper);
3660 assert_eq!(cfg.rest_base_url(), "https://api.alpaca.markets");
3661 }
3662
3663 #[test]
3664 fn test_alpaca_config_urls() {
3665 let paper = AlpacaConfig::paper("k".into(), "s".into());
3666 assert!(paper.rest_base_url().contains("paper-api"));
3667 assert!(paper.ws_url().contains("paper-api"));
3668
3669 let live = AlpacaConfig::production("k".into(), "s".into());
3670 assert!(!live.rest_base_url().contains("paper-api"));
3671 assert!(!live.ws_url().contains("paper-api"));
3672 }
3673
3674 #[test]
3675 #[serial_test::serial]
3676 fn test_alpaca_config_from_env_defaults_to_paper_trading() {
3677 temp_env::with_vars(
3678 [
3679 ("ALPACA_API_KEY", Some("my_key")),
3680 ("ALPACA_SECRET_KEY", Some("my_secret")),
3681 ("ALPACA_PAPER", None),
3682 ],
3683 || {
3684 let cfg = AlpacaConfig::from_env().unwrap();
3685 assert!(cfg.paper);
3686 assert_eq!(cfg.rest_base_url(), "https://paper-api.alpaca.markets");
3687 },
3688 );
3689 }
3690
3691 #[test]
3692 #[serial_test::serial]
3693 fn test_alpaca_config_from_env_accepts_explicit_production() {
3694 temp_env::with_vars(
3695 [
3696 ("ALPACA_API_KEY", Some("my_key")),
3697 ("ALPACA_SECRET_KEY", Some("my_secret")),
3698 ("ALPACA_PAPER", Some("false")),
3699 ],
3700 || {
3701 let cfg = AlpacaConfig::from_env().unwrap();
3702 assert!(!cfg.paper);
3703 assert_eq!(cfg.rest_base_url(), "https://api.alpaca.markets");
3704 },
3705 );
3706 }
3707
3708 #[test]
3709 #[serial_test::serial]
3710 fn test_alpaca_config_from_env_rejects_invalid_paper() {
3711 temp_env::with_vars(
3712 [
3713 ("ALPACA_API_KEY", Some("my_key")),
3714 ("ALPACA_SECRET_KEY", Some("my_secret")),
3715 ("ALPACA_PAPER", Some("maybe")),
3716 ],
3717 || {
3718 let err = AlpacaConfig::from_env().unwrap_err();
3719 assert!(matches!(err, AlpacaConfigError::InvalidPaper(value) if value == "maybe"));
3720 },
3721 );
3722 }
3723
3724 #[test]
3725 #[serial_test::serial]
3726 fn test_alpaca_config_from_env_requires_credentials() {
3727 temp_env::with_vars(
3728 [
3729 ("ALPACA_API_KEY", None),
3730 ("ALPACA_SECRET_KEY", Some("my_secret")),
3731 ("ALPACA_PAPER", None),
3732 ],
3733 || {
3734 let err = AlpacaConfig::from_env().unwrap_err();
3735 assert!(matches!(err, AlpacaConfigError::MissingApiKey));
3736 },
3737 );
3738 }
3739
3740 #[test]
3741 fn test_parse_side() {
3742 assert_eq!(parse_side("buy"), Some(Side::Buy));
3743 assert_eq!(parse_side("sell"), Some(Side::Sell));
3744 assert_eq!(parse_side("Buy"), Some(Side::Buy));
3745 assert_eq!(parse_side("BUY"), Some(Side::Buy));
3746 assert_eq!(parse_side("unknown"), None);
3747 }
3748
3749 #[test]
3750 fn test_parse_order_kind() {
3751 assert_eq!(
3753 parse_order_kind("market", None, None, None),
3754 Some(OrderKind::Market)
3755 );
3756 assert_eq!(
3757 parse_order_kind("limit", None, None, None),
3758 Some(OrderKind::Limit)
3759 );
3760
3761 assert_eq!(
3763 parse_order_kind("stop", Some("150.00"), None, None),
3764 Some(OrderKind::Stop {
3765 trigger_price: Decimal::from_str("150.00").unwrap()
3766 })
3767 );
3768 assert_eq!(parse_order_kind("stop", None, None, None), None);
3769
3770 assert_eq!(
3772 parse_order_kind("stop_limit", Some("145.00"), None, None),
3773 Some(OrderKind::StopLimit {
3774 trigger_price: Decimal::from_str("145.00").unwrap()
3775 })
3776 );
3777
3778 assert_eq!(
3780 parse_order_kind("trailing_stop", None, Some("5.0"), None),
3781 Some(OrderKind::TrailingStop {
3782 offset: Decimal::from_str("5.0").unwrap(),
3783 offset_type: TrailingOffsetType::Percentage,
3784 })
3785 );
3786
3787 assert_eq!(
3789 parse_order_kind("trailing_stop", None, None, Some("2.50")),
3790 Some(OrderKind::TrailingStop {
3791 offset: Decimal::from_str("2.50").unwrap(),
3792 offset_type: TrailingOffsetType::Absolute,
3793 })
3794 );
3795
3796 assert_eq!(parse_order_kind("trailing_stop", None, None, None), None);
3798
3799 assert_eq!(parse_order_kind("unknown", None, None, None), None);
3801 }
3802
3803 #[test]
3804 fn test_map_order_kind() {
3805 assert_eq!(map_order_kind(OrderKind::Market), Some("market"));
3806 assert_eq!(map_order_kind(OrderKind::Limit), Some("limit"));
3807 assert_eq!(
3808 map_order_kind(OrderKind::Stop {
3809 trigger_price: Decimal::from_str("150.00").unwrap()
3810 }),
3811 Some("stop")
3812 );
3813 assert_eq!(
3814 map_order_kind(OrderKind::StopLimit {
3815 trigger_price: Decimal::from_str("145.00").unwrap()
3816 }),
3817 Some("stop_limit")
3818 );
3819 assert_eq!(
3820 map_order_kind(OrderKind::TrailingStop {
3821 offset: Decimal::from_str("5.0").unwrap(),
3822 offset_type: TrailingOffsetType::Percentage,
3823 }),
3824 Some("trailing_stop")
3825 );
3826 assert_eq!(
3827 map_order_kind(OrderKind::TrailingStop {
3828 offset: Decimal::from_str("2.50").unwrap(),
3829 offset_type: TrailingOffsetType::Absolute,
3830 }),
3831 Some("trailing_stop")
3832 );
3833 assert_eq!(
3835 map_order_kind(OrderKind::TrailingStopLimit {
3836 offset: Decimal::from_str("5.0").unwrap(),
3837 offset_type: TrailingOffsetType::Percentage,
3838 limit_offset: Decimal::from_str("1.0").unwrap(),
3839 }),
3840 None
3841 );
3842 assert_eq!(
3844 map_order_kind(OrderKind::TakeProfit {
3845 trigger_price: Decimal::from_str("160.00").unwrap()
3846 }),
3847 None
3848 );
3849 assert_eq!(
3850 map_order_kind(OrderKind::TakeProfitLimit {
3851 trigger_price: Decimal::from_str("160.00").unwrap()
3852 }),
3853 None
3854 );
3855 }
3856
3857 #[test]
3858 fn test_map_time_in_force_roundtrip() {
3859 assert_eq!(
3860 map_time_in_force(TimeInForce::GoodUntilCancelled { post_only: false }),
3861 Ok("gtc")
3862 );
3863 assert_eq!(map_time_in_force(TimeInForce::GoodUntilEndOfDay), Ok("day"));
3864 assert_eq!(map_time_in_force(TimeInForce::FillOrKill), Ok("fok"));
3865 assert_eq!(map_time_in_force(TimeInForce::ImmediateOrCancel), Ok("ioc"));
3866 }
3867
3868 #[test]
3869 fn test_map_time_in_force_rejects_post_only() {
3870 let result = map_time_in_force(TimeInForce::GoodUntilCancelled { post_only: true });
3871 assert!(result.is_err(), "post_only must be rejected");
3872 assert!(result.unwrap_err().contains("post_only"));
3873 }
3874
3875 #[test]
3880 fn test_bracket_order_serializes_with_stop_loss_stop_order() {
3881 let body = AlpacaOrderRequest {
3883 symbol: "AAPL",
3884 qty: "10".to_string(),
3885 side: "buy",
3886 order_type: "limit",
3887 time_in_force: "gtc",
3888 limit_price: Some("150.00".to_string()),
3889 stop_price: None,
3890 trail_percent: None,
3891 trail_price: None,
3892 client_order_id: Some("bracket-001"),
3893 position_intent: Some(AlpacaPositionIntent::BuyToOpen),
3894 order_class: Some("bracket"),
3895 take_profit: Some(TakeProfitParams {
3896 limit_price: "160.00".to_string(),
3897 }),
3898 stop_loss: Some(StopLossParams {
3899 stop_price: "145.00".to_string(),
3900 limit_price: None,
3901 }),
3902 };
3903
3904 let json = serde_json::to_value(&body).unwrap();
3905
3906 assert_eq!(json["symbol"], "AAPL");
3907 assert_eq!(json["qty"], "10");
3908 assert_eq!(json["side"], "buy");
3909 assert_eq!(json["type"], "limit");
3910 assert_eq!(json["time_in_force"], "gtc");
3911 assert_eq!(json["limit_price"], "150.00");
3912 assert_eq!(json["order_class"], "bracket");
3913
3914 assert_eq!(json["take_profit"]["limit_price"], "160.00");
3916
3917 assert_eq!(json["stop_loss"]["stop_price"], "145.00");
3919 assert!(
3920 json["stop_loss"].get("limit_price").is_none(),
3921 "stop_loss.limit_price should be omitted when None"
3922 );
3923 }
3924
3925 #[test]
3926 fn test_bracket_order_serializes_with_stop_loss_stop_limit_order() {
3927 let body = AlpacaOrderRequest {
3929 symbol: "SPY",
3930 qty: "5".to_string(),
3931 side: "sell",
3932 order_type: "limit",
3933 time_in_force: "day",
3934 limit_price: Some("450.00".to_string()),
3935 stop_price: None,
3936 trail_percent: None,
3937 trail_price: None,
3938 client_order_id: Some("bracket-002"),
3939 position_intent: Some(AlpacaPositionIntent::SellToClose),
3940 order_class: Some("bracket"),
3941 take_profit: Some(TakeProfitParams {
3942 limit_price: "440.00".to_string(),
3943 }),
3944 stop_loss: Some(StopLossParams {
3945 stop_price: "455.00".to_string(),
3946 limit_price: Some("456.00".to_string()),
3947 }),
3948 };
3949
3950 let json = serde_json::to_value(&body).unwrap();
3951
3952 assert_eq!(json["symbol"], "SPY");
3953 assert_eq!(json["side"], "sell");
3954 assert_eq!(json["time_in_force"], "day");
3955 assert_eq!(json["order_class"], "bracket");
3956
3957 assert_eq!(json["take_profit"]["limit_price"], "440.00");
3959
3960 assert_eq!(json["stop_loss"]["stop_price"], "455.00");
3962 assert_eq!(
3963 json["stop_loss"]["limit_price"], "456.00",
3964 "stop_loss.limit_price should be present for stop-limit orders"
3965 );
3966 }
3967
3968 #[tokio::test]
3973 async fn test_open_bracket_order_rejects_invalid_tif() {
3974 use rust_decimal_macros::dec;
3975 use rustrade_instrument::instrument::name::InstrumentNameExchange;
3976
3977 let config = AlpacaConfig::new("dummy_key".into(), "dummy_secret".into());
3979 let client = AlpacaClient::new(config);
3980
3981 let request = AlpacaBracketOrderRequest::new(
3982 InstrumentNameExchange::new("SPY"),
3983 crate::order::id::StrategyId::new("test"),
3984 crate::order::id::ClientOrderId::new("test-tif"),
3985 Side::Buy,
3986 dec!(1),
3987 dec!(100.00),
3988 dec!(120.00),
3989 dec!(90.00),
3990 TimeInForce::ImmediateOrCancel, );
3992
3993 let result = client.open_bracket_order(request).await;
3994
3995 assert!(
3996 result.parent.state.is_failed(),
3997 "Bracket order with IOC TIF should be rejected locally"
3998 );
3999 }
4000
4001 #[tokio::test]
4002 async fn test_open_bracket_order_rejects_invalid_price_ordering() {
4003 use rust_decimal_macros::dec;
4004 use rustrade_instrument::instrument::name::InstrumentNameExchange;
4005
4006 let config = AlpacaConfig::new("dummy_key".into(), "dummy_secret".into());
4007 let client = AlpacaClient::new(config);
4008
4009 let request = AlpacaBracketOrderRequest::new(
4011 InstrumentNameExchange::new("SPY"),
4012 crate::order::id::StrategyId::new("test"),
4013 crate::order::id::ClientOrderId::new("test-price"),
4014 Side::Buy,
4015 dec!(1),
4016 dec!(100.00),
4017 dec!(120.00),
4018 dec!(105.00), TimeInForce::GoodUntilCancelled { post_only: false },
4020 );
4021
4022 let result = client.open_bracket_order(request).await;
4023
4024 assert!(
4025 result.parent.state.is_failed(),
4026 "Bracket order with invalid price ordering should be rejected locally"
4027 );
4028 }
4029
4030 #[tokio::test]
4031 async fn test_open_bracket_order_rejects_invalid_sl_limit_price() {
4032 use rust_decimal_macros::dec;
4033 use rustrade_instrument::instrument::name::InstrumentNameExchange;
4034
4035 let config = AlpacaConfig::new("dummy_key".into(), "dummy_secret".into());
4036 let client = AlpacaClient::new(config);
4037
4038 let request = AlpacaBracketOrderRequest::new(
4040 InstrumentNameExchange::new("SPY"),
4041 crate::order::id::StrategyId::new("test"),
4042 crate::order::id::ClientOrderId::new("test-sl-limit"),
4043 Side::Buy,
4044 dec!(1),
4045 dec!(100.00),
4046 dec!(120.00),
4047 dec!(90.00),
4048 TimeInForce::GoodUntilCancelled { post_only: false },
4049 )
4050 .with_stop_loss_limit_price(dec!(95.00)); let result = client.open_bracket_order(request).await;
4053
4054 assert!(
4055 result.parent.state.is_failed(),
4056 "Bracket order with invalid SL limit price should be rejected locally"
4057 );
4058 }
4059
4060 #[test]
4061 fn test_non_bracket_order_omits_bracket_fields() {
4062 let body = AlpacaOrderRequest {
4064 symbol: "AAPL",
4065 qty: "1".to_string(),
4066 side: "buy",
4067 order_type: "limit",
4068 time_in_force: "gtc",
4069 limit_price: Some("150.00".to_string()),
4070 stop_price: None,
4071 trail_percent: None,
4072 trail_price: None,
4073 client_order_id: Some("regular-001"),
4074 position_intent: Some(AlpacaPositionIntent::BuyToOpen),
4075 order_class: None,
4076 take_profit: None,
4077 stop_loss: None,
4078 };
4079
4080 let json = serde_json::to_value(&body).unwrap();
4081
4082 assert_eq!(json["symbol"], "AAPL");
4083 assert!(
4084 json.get("order_class").is_none(),
4085 "order_class should be omitted for non-bracket orders"
4086 );
4087 assert!(
4088 json.get("take_profit").is_none(),
4089 "take_profit should be omitted for non-bracket orders"
4090 );
4091 assert!(
4092 json.get("stop_loss").is_none(),
4093 "stop_loss should be omitted for non-bracket orders"
4094 );
4095 }
4096
4097 #[test]
4098 fn test_parse_timestamp_valid() {
4099 let ts = parse_timestamp("2025-04-18T14:30:00Z");
4100 assert!(ts.is_some());
4101 let ts2 = parse_timestamp("2025-04-18T14:30:00.123456Z");
4102 assert!(ts2.is_some());
4103 assert_eq!(parse_timestamp("not-a-timestamp"), None);
4104 }
4105
4106 #[test]
4107 fn test_check_auth_response_authorized() {
4108 let msg =
4109 r#"{"stream":"authorization","data":{"status":"authorized","action":"authenticate"}}"#;
4110 assert!(matches!(check_auth_response(msg), Some(Ok(()))));
4111 }
4112
4113 #[test]
4114 fn test_check_auth_response_unauthorized() {
4115 let msg = r#"{"stream":"authorization","data":{"status":"unauthorized"}}"#;
4116 assert!(matches!(
4117 check_auth_response(msg),
4118 Some(Err(HandshakeError::Auth(_)))
4119 ));
4120 }
4121
4122 #[test]
4123 fn test_check_auth_response_non_auth_message() {
4124 let msg = r#"{"stream":"trade_updates","data":{}}"#;
4125 assert!(check_auth_response(msg).is_none());
4126 }
4127
4128 #[test]
4129 fn test_check_listen_ack() {
4130 let ack = r#"{"stream":"listening","data":{"streams":["trade_updates"]}}"#;
4131 assert!(check_listen_ack(ack));
4132
4133 let other = r#"{"stream":"authorization","data":{}}"#;
4134 assert!(!check_listen_ack(other));
4135 }
4136
4137 #[test]
4138 fn test_dedup_cache() {
4139 let cache = new_dedup_cache();
4140 let key = SmolStr::new("order-1:1");
4141 assert!(
4142 !is_duplicate(&cache, &key),
4143 "first time should not be duplicate"
4144 );
4145 assert!(
4146 is_duplicate(&cache, &key),
4147 "second time should be duplicate"
4148 );
4149 }
4150
4151 #[tokio::test]
4152 async fn test_exponential_backoff_progression_and_exhaustion() {
4153 tokio::time::pause();
4154
4155 let mut b = ExponentialBackoff::new();
4156
4157 assert!(b.wait().await, "first wait should return true");
4159 assert_eq!(b.attempt, 1);
4160
4161 while b.wait().await {}
4163
4164 assert_eq!(b.attempt, MAX_RECONNECT_ATTEMPTS);
4166
4167 assert!(!b.wait().await, "exhausted backoff should return false");
4169
4170 b.reset();
4172 assert_eq!(b.attempt, 0);
4173
4174 assert!(b.wait().await, "wait should succeed after reset");
4176 assert_eq!(b.attempt, 1);
4177 }
4178
4179 #[test]
4180 fn test_convert_account_to_balances_empty_assets() {
4181 let account = AlpacaAccount {
4182 equity: "12000.00".into(),
4183 buying_power: "10000.00".into(),
4184 options_buying_power: Some("8000.00".into()),
4185 crypto_buying_power: None,
4186 };
4187 let balances = convert_account_to_balances(&account, &[]);
4188 assert_eq!(balances.len(), 1);
4189 assert_eq!(
4190 balances[0].balance.total,
4191 Decimal::from_str("12000.00").unwrap()
4192 );
4193 assert_eq!(
4195 balances[0].balance.free,
4196 Decimal::from_str("8000.00").unwrap()
4197 );
4198 }
4199
4200 #[test]
4201 fn test_convert_account_to_balances_usd_filter() {
4202 let account = AlpacaAccount {
4203 equity: "12000.00".into(),
4204 buying_power: "10000.00".into(),
4205 options_buying_power: None,
4206 crypto_buying_power: None,
4207 };
4208 let usd = vec![AssetNameExchange::new("USD")];
4209 let balances = convert_account_to_balances(&account, &usd);
4210 assert_eq!(balances.len(), 1);
4211
4212 let non_usd = vec![AssetNameExchange::new("BTC")];
4213 let balances = convert_account_to_balances(&account, &non_usd);
4214 assert!(balances.is_empty());
4215 }
4216
4217 #[test]
4218 fn test_is_options_or_equity_symbol() {
4219 assert!(!is_options_or_equity_symbol("BTC/USD"));
4221 assert!(!is_options_or_equity_symbol("ETH/USD"));
4222 assert!(!is_options_or_equity_symbol("SOL/USD"));
4223
4224 assert!(is_options_or_equity_symbol("AAPL"));
4226 assert!(is_options_or_equity_symbol("SPY"));
4227 assert!(is_options_or_equity_symbol("MSFT"));
4228
4229 assert!(is_options_or_equity_symbol("SPY250418C00450000"));
4231 assert!(is_options_or_equity_symbol("AAPL250418P00145000"));
4232 }
4233
4234 #[test]
4235 fn test_parse_order_error_already_cancelled() {
4236 assert!(matches!(
4239 parse_order_error(
4240 reqwest::StatusCode::UNPROCESSABLE_ENTITY,
4241 "order is already cancelled"
4242 ),
4243 UnindexedOrderError::Rejected(ApiError::OrderAlreadyCancelled)
4244 ));
4245 }
4246
4247 #[test]
4248 fn test_parse_order_error_already_wins_over_insufficient_on_422() {
4249 assert!(matches!(
4252 parse_order_error(
4253 reqwest::StatusCode::UNPROCESSABLE_ENTITY,
4254 "order already cancelled due to insufficient margin"
4255 ),
4256 UnindexedOrderError::Rejected(ApiError::OrderAlreadyCancelled)
4257 ));
4258 }
4259
4260 fn make_order_ws<'a>(
4261 id: &str,
4262 symbol: &str,
4263 side: &str,
4264 filled_qty: &'a str,
4265 ) -> AlpacaOrderWs<'a> {
4266 AlpacaOrderWs {
4267 id: SmolStr::new(id),
4268 client_order_id: None,
4269 symbol: SmolStr::new(symbol),
4270 qty: Some("2"),
4271 filled_qty: Some(filled_qty),
4272 side: SmolStr::new(side),
4273 order_type: SmolStr::new("limit"),
4274 time_in_force: SmolStr::new("day"),
4275 limit_price: Some("100.00"),
4276 stop_price: None,
4277 trail_percent: None,
4278 trail_price: None,
4279 status: SmolStr::new("partially_filled"),
4280 }
4281 }
4282
4283 fn sole_event(events: [Option<UnindexedAccountEvent>; 2]) -> Option<UnindexedAccountEvent> {
4288 let [first, second] = events;
4289 assert!(
4290 second.is_none(),
4291 "expected a single event, got a second: {second:?}"
4292 );
4293 first
4294 }
4295
4296 fn fill_events(
4298 events: [Option<UnindexedAccountEvent>; 2],
4299 ) -> (
4300 Trade<AssetNameExchange, InstrumentNameExchange>,
4301 crate::order::Order<
4302 ExchangeId,
4303 InstrumentNameExchange,
4304 OrderState<AssetNameExchange, InstrumentNameExchange>,
4305 >,
4306 ) {
4307 let [first, second] = events;
4308 let first = first.expect("a fill frame must produce an execution");
4309 let second = second.expect("a fill frame must produce an order snapshot");
4310 let AccountEventKind::Trade(trade) = first.kind else {
4311 panic!("first event must be the Trade, got {:?}", first.kind);
4312 };
4313 let AccountEventKind::OrderSnapshot(rustrade_integration::collection::snapshot::Snapshot(
4314 order,
4315 )) = second.kind
4316 else {
4317 panic!(
4318 "second event must be the OrderSnapshot, got {:?}",
4319 second.kind
4320 );
4321 };
4322 (trade, order)
4323 }
4324
4325 #[test]
4326 fn test_convert_trade_update_fill_produces_trade_with_dedup_key() {
4327 let update = AlpacaTradeUpdate {
4328 event: SmolStr::new("fill"),
4329 order: make_order_ws("ord-1", "SPY", "buy", "1"),
4330 price: Some("150.00"),
4331 qty: Some("1"),
4332 timestamp: Some("2025-04-18T14:30:00Z"),
4333 };
4334 let (trade, order) = fill_events(convert_trade_update(update));
4335 assert_eq!(trade.id.0.as_str(), "ord-1:1");
4337 assert_eq!(trade.price, Decimal::from_str("150.00").unwrap());
4338 assert_eq!(trade.quantity, Decimal::from_str("1").unwrap());
4339
4340 let OrderState::Active(crate::order::state::ActiveOrderState::Open(open)) = order.state
4343 else {
4344 panic!("expected an Open snapshot, got {:?}", order.state);
4345 };
4346 assert_eq!(open.filled_quantity, Decimal::from_str("1").unwrap());
4347 assert_eq!(open.id.0.as_str(), "ord-1");
4348 }
4349
4350 #[test]
4351 fn test_convert_trade_update_partial_fill() {
4352 let update = AlpacaTradeUpdate {
4353 event: SmolStr::new("partial_fill"),
4354 order: make_order_ws("ord-2", "AAPL", "sell", "0.5"),
4355 price: Some("200.00"),
4356 qty: Some("0.5"),
4357 timestamp: None,
4358 };
4359 let (trade, order) = fill_events(convert_trade_update(update));
4360 assert_eq!(trade.quantity, Decimal::from_str("0.5").unwrap());
4361
4362 let OrderState::Active(crate::order::state::ActiveOrderState::Open(open)) = order.state
4365 else {
4366 panic!("expected an Open snapshot, got {:?}", order.state);
4367 };
4368 assert_eq!(open.filled_quantity, Decimal::from_str("0.5").unwrap());
4369 assert_eq!(
4370 open.quantity_remaining(order.quantity),
4371 Decimal::from_str("1.5").unwrap()
4372 );
4373 }
4374
4375 #[test]
4379 fn a_full_fill_snapshot_reports_nothing_left_to_fill() {
4380 let update = AlpacaTradeUpdate {
4381 event: SmolStr::new("fill"),
4382 order: AlpacaOrderWs {
4384 status: SmolStr::new("filled"),
4385 ..make_order_ws("ord-full", "SPY", "buy", "2")
4386 },
4387 price: Some("150.00"),
4388 qty: Some("1"),
4389 timestamp: Some("2025-04-18T14:30:00Z"),
4390 };
4391 let (_trade, order) = fill_events(convert_trade_update(update));
4392 let OrderState::Active(crate::order::state::ActiveOrderState::Open(open)) = order.state
4393 else {
4394 panic!("expected an Open snapshot, got {:?}", order.state);
4395 };
4396 assert_eq!(open.filled_quantity, Decimal::from_str("2").unwrap());
4397 assert!(
4398 open.quantity_remaining(order.quantity).is_zero(),
4399 "a completed order must report nothing left to fill so the engine retires it"
4400 );
4401 }
4402
4403 #[test]
4407 fn a_fill_for_an_order_no_longer_working_emits_the_execution_without_a_snapshot() {
4408 for status in [
4409 "canceled",
4410 "expired",
4411 "rejected",
4412 "done_for_day",
4413 "pending_cancel",
4414 ] {
4415 let update = AlpacaTradeUpdate {
4416 event: SmolStr::new("partial_fill"),
4417 order: AlpacaOrderWs {
4418 status: SmolStr::new(status),
4419 ..make_order_ws("ord-late", "SPY", "buy", "1")
4420 },
4421 price: Some("150.00"),
4422 qty: Some("1"),
4423 timestamp: None,
4424 };
4425 let [trade, snapshot] = convert_trade_update(update);
4426 assert!(
4427 matches!(trade.map(|e| e.kind), Some(AccountEventKind::Trade(_))),
4428 "the execution must still be reported for status {status}"
4429 );
4430 assert!(
4431 snapshot.is_none(),
4432 "status {status} must not produce an order snapshot"
4433 );
4434 }
4435 }
4436
4437 #[test]
4440 fn a_notional_order_fill_emits_the_execution_without_a_snapshot() {
4441 let update = AlpacaTradeUpdate {
4442 event: SmolStr::new("partial_fill"),
4443 order: AlpacaOrderWs {
4444 qty: None,
4445 ..make_order_ws("ord-notional-ws", "SPY", "buy", "1")
4446 },
4447 price: Some("150.00"),
4448 qty: Some("1"),
4449 timestamp: None,
4450 };
4451 let [trade, snapshot] = convert_trade_update(update);
4452 assert!(matches!(
4453 trade.map(|e| e.kind),
4454 Some(AccountEventKind::Trade(_))
4455 ));
4456 assert!(
4457 snapshot.is_none(),
4458 "a notional order has no quantity to snapshot against"
4459 );
4460 }
4461
4462 #[test]
4463 fn test_convert_trade_update_new_order_produces_snapshot() {
4464 let update = AlpacaTradeUpdate {
4465 event: SmolStr::new("new"),
4466 order: AlpacaOrderWs {
4467 id: SmolStr::new("ord-new"),
4468 client_order_id: Some(SmolStr::new("cid-1")),
4469 symbol: SmolStr::new("AAPL"),
4470 qty: Some("10"),
4471 filled_qty: Some("0"),
4472 side: SmolStr::new("buy"),
4473 order_type: SmolStr::new("limit"),
4474 time_in_force: SmolStr::new("day"),
4475 limit_price: Some("150.00"),
4476 stop_price: None,
4477 trail_percent: None,
4478 trail_price: None,
4479 status: SmolStr::new("new"),
4480 },
4481 price: None,
4482 qty: None,
4483 timestamp: Some("2025-04-18T14:30:00Z"),
4484 };
4485 let event =
4486 sole_event(convert_trade_update(update)).expect("new event should produce an event");
4487 assert!(matches!(event.kind, AccountEventKind::OrderSnapshot(_)));
4488 }
4489
4490 #[test]
4491 fn test_convert_trade_update_canceled_produces_cancel() {
4492 let update = AlpacaTradeUpdate {
4493 event: SmolStr::new("canceled"),
4494 order: make_order_ws("ord-3", "AAPL", "sell", "0"),
4495 price: None,
4496 qty: None,
4497 timestamp: Some("2025-04-18T14:30:00Z"),
4498 };
4499 let event =
4500 sole_event(convert_trade_update(update)).expect("canceled should produce an event");
4501 let AccountEventKind::OrderCancelled(response) = event.kind else {
4502 panic!("expected OrderCancelled, got {:?}", event.kind);
4503 };
4504 assert!(response.state.is_ok());
4505 }
4506
4507 #[test]
4508 fn test_convert_trade_update_rejected_produces_error() {
4509 let update = AlpacaTradeUpdate {
4510 event: SmolStr::new("rejected"),
4511 order: make_order_ws("ord-4", "SPY", "buy", "0"),
4512 price: None,
4513 qty: None,
4514 timestamp: None,
4515 };
4516 let event =
4517 sole_event(convert_trade_update(update)).expect("rejected should produce an event");
4518 let AccountEventKind::OrderCancelled(response) = event.kind else {
4519 panic!("expected OrderCancelled, got {:?}", event.kind);
4520 };
4521 assert!(response.state.is_err());
4522 }
4523
4524 #[test]
4525 fn test_convert_open_order_notional_qty_none_is_skipped() {
4526 let order = AlpacaOrderResponse {
4529 id: "ord-notional".to_string(),
4530 client_order_id: None,
4531 symbol: "SPY".to_string(),
4532 qty: None,
4533 filled_qty: "0".to_string(),
4534 side: "buy".to_string(),
4535 order_type: "market".to_string(),
4536 time_in_force: "day".to_string(),
4537 limit_price: None,
4538 stop_price: None,
4539 trail_percent: None,
4540 trail_price: None,
4541 created_at: "2025-04-18T14:30:00Z".to_string(),
4542 updated_at: None,
4543 };
4544 assert!(convert_open_order(&order).is_none());
4545 }
4546
4547 #[test]
4548 fn test_convert_activity_to_trade_bad_price_returns_none() {
4549 let activity = AlpacaActivity {
4553 id: "act-1".to_string(),
4554 order_id: "ord-1".to_string(),
4555 symbol: "SPY250418C00450000".to_string(),
4556 side: "buy".to_string(),
4557 price: "not-a-number".to_string(),
4558 qty: "1".to_string(),
4559 transaction_time: "2025-04-18T14:30:00Z".to_string(),
4560 cum_qty: Some("1".to_string()),
4561 };
4562 assert!(convert_activity_to_trade(&activity).is_none());
4563 }
4564
4565 #[test]
4566 fn test_convert_positions_to_balances_crypto() {
4567 let positions = vec![
4568 AlpacaPosition {
4569 symbol: "BTC/USD".into(),
4570 asset_class: "crypto".into(),
4571 qty: "0.5".into(),
4572 qty_available: "0.4".into(),
4573 },
4574 AlpacaPosition {
4575 symbol: "ETH/USD".into(),
4576 asset_class: "crypto".into(),
4577 qty: "2.0".into(),
4578 qty_available: "2.0".into(),
4579 },
4580 AlpacaPosition {
4582 symbol: "AAPL".into(),
4583 asset_class: "us_equity".into(),
4584 qty: "10".into(),
4585 qty_available: "10".into(),
4586 },
4587 ];
4588
4589 let balances = convert_positions_to_balances(&positions, &[]);
4591 assert_eq!(balances.len(), 2, "only crypto positions returned");
4592 assert_eq!(balances[0].asset.name().as_str(), "btc");
4593 assert_eq!(balances[0].balance.total, Decimal::from_str("0.5").unwrap());
4595 assert_eq!(balances[0].balance.free, Decimal::from_str("0.4").unwrap());
4596
4597 let btc_only = vec![AssetNameExchange::new("BTC")];
4599 let balances = convert_positions_to_balances(&positions, &btc_only);
4600 assert_eq!(balances.len(), 1);
4601 assert_eq!(balances[0].asset.name().as_str(), "btc");
4602 }
4603
4604 #[test]
4606 fn order_state_time_prefers_updated_at() {
4607 let mut resp = make_order_response("ord-1", "SPY");
4608 resp.created_at = "2025-04-18T14:30:00Z".to_string();
4609 resp.updated_at = Some("2025-04-18T15:45:00Z".to_string());
4610 assert_eq!(
4611 order_state_time(&resp),
4612 parse_timestamp("2025-04-18T15:45:00Z").unwrap(),
4613 "updated_at must win over created_at"
4614 );
4615
4616 resp.updated_at = None;
4618 assert_eq!(
4619 order_state_time(&resp),
4620 parse_timestamp("2025-04-18T14:30:00Z").unwrap(),
4621 "absent updated_at falls back to created_at"
4622 );
4623
4624 resp.updated_at = Some("not-a-timestamp".to_string());
4627 assert_eq!(
4628 order_state_time(&resp),
4629 parse_timestamp("2025-04-18T14:30:00Z").unwrap(),
4630 "unparseable updated_at falls back to created_at"
4631 );
4632 }
4633
4634 fn make_order_response(id: &str, symbol: &str) -> AlpacaOrderResponse {
4635 AlpacaOrderResponse {
4636 id: id.to_string(),
4637 client_order_id: None,
4638 symbol: symbol.to_string(),
4639 qty: Some("1".to_string()),
4640 filled_qty: "0".to_string(),
4641 side: "buy".to_string(),
4642 order_type: "limit".to_string(),
4643 time_in_force: "day".to_string(),
4644 limit_price: Some("100.00".to_string()),
4645 stop_price: None,
4646 trail_percent: None,
4647 trail_price: None,
4648 created_at: "2025-04-18T14:30:00Z".to_string(),
4649 updated_at: None,
4650 }
4651 }
4652
4653 #[test]
4654 fn test_build_instrument_snapshots_empty_instruments_returns_only_with_orders() {
4655 let orders = vec![
4656 make_order_response("o1", "AAPL"),
4657 make_order_response("o2", "SPY"),
4658 ];
4659 let snapshots = build_instrument_snapshots(orders, &[]);
4660 assert_eq!(snapshots.len(), 2);
4661 let symbols: Vec<&str> = snapshots
4662 .iter()
4663 .map(|s| s.instrument.name().as_str())
4664 .collect();
4665 assert!(symbols.contains(&"AAPL"));
4666 assert!(symbols.contains(&"SPY"));
4667 }
4668
4669 #[test]
4670 fn test_build_instrument_snapshots_requested_instrument_no_orders_gets_empty_snapshot() {
4671 let orders = vec![make_order_response("o1", "AAPL")];
4674 let instruments = vec![
4675 InstrumentNameExchange::new("AAPL"),
4676 InstrumentNameExchange::new("SPY"),
4677 ];
4678 let snapshots = build_instrument_snapshots(orders, &instruments);
4679 assert_eq!(snapshots.len(), 2);
4680 let spy = snapshots
4681 .iter()
4682 .find(|s| s.instrument.name().as_str() == "SPY")
4683 .expect("SPY snapshot must be present even with no orders");
4684 assert!(spy.orders.is_empty());
4685 }
4686
4687 #[test]
4688 fn test_build_instrument_snapshots_non_requested_instrument_excluded() {
4689 let orders = vec![
4692 make_order_response("o1", "AAPL"),
4693 make_order_response("o2", "MSFT"), ];
4695 let instruments = vec![InstrumentNameExchange::new("AAPL")];
4696 let snapshots = build_instrument_snapshots(orders, &instruments);
4697 assert_eq!(snapshots.len(), 1);
4698 assert_eq!(snapshots[0].instrument.name().as_str(), "AAPL");
4699 }
4700
4701 #[test]
4707 fn test_recover_fills_dedup_key_matches_ws_path() {
4708 let order_id = "ord-1";
4709
4710 let ws_keys: Vec<SmolStr> = ["1", "2"]
4713 .iter()
4714 .filter_map(|filled_qty| {
4715 let update = AlpacaTradeUpdate {
4716 event: SmolStr::new("partial_fill"),
4717 order: make_order_ws(order_id, "SPY", "buy", filled_qty),
4718 price: Some("150.00"),
4719 qty: Some("1"),
4720 timestamp: None,
4721 };
4722 let [event, _snapshot] = convert_trade_update(update);
4723 let event = event?;
4724 fill_dedup_key_from_event(&event).cloned()
4725 })
4726 .collect();
4727
4728 let mut cumulative = Decimal::ZERO;
4731 let rest_keys: Vec<SmolStr> = ["1", "1"]
4732 .iter()
4733 .map(|exec_qty| {
4734 cumulative += Decimal::from_str(exec_qty).unwrap();
4735 format_smolstr!("{}:{}", order_id, cumulative.normalize())
4736 })
4737 .collect();
4738
4739 assert_eq!(
4740 ws_keys, rest_keys,
4741 "REST recovery dedup keys must match WS path keys for cross-source dedup to work"
4742 );
4743 assert_eq!(ws_keys[0].as_str(), "ord-1:1");
4744 assert_eq!(ws_keys[1].as_str(), "ord-1:2");
4745 }
4746
4747 #[test]
4753 fn early_dedup_key_matches_full_event_path() {
4754 let update = AlpacaTradeUpdate {
4755 event: SmolStr::new("fill"),
4756 order: make_order_ws("ord-abc", "SPY", "buy", "5"),
4757 price: Some("150.00"),
4758 qty: Some("5"),
4759 timestamp: None,
4760 };
4761
4762 let early_key = early_dedup_key(&update);
4764
4765 let [event, _snapshot] = convert_trade_update(update);
4768 let event = event.expect("fill should produce an execution");
4769 let full_key =
4770 fill_dedup_key_from_event(&event).expect("fill event should have a dedup key");
4771
4772 assert_eq!(
4773 early_key.as_str(),
4774 full_key.as_str(),
4775 "early_dedup_key must produce the same key as the full event path"
4776 );
4777 assert_eq!(early_key.as_str(), "ord-abc:5");
4778 }
4779
4780 #[test]
4785 fn early_dedup_key_normalizes_decimal_strings() {
4786 let update1 = AlpacaTradeUpdate {
4788 event: SmolStr::new("fill"),
4789 order: AlpacaOrderWs {
4790 id: SmolStr::new("ord-x"),
4791 client_order_id: Some(SmolStr::new("cid")),
4792 symbol: SmolStr::new("AAPL"),
4793 qty: Some("10"),
4794 filled_qty: Some("1.00"),
4795 side: SmolStr::new("buy"),
4796 order_type: SmolStr::new("market"),
4797 time_in_force: SmolStr::new("day"),
4798 limit_price: None,
4799 stop_price: None,
4800 trail_percent: None,
4801 trail_price: None,
4802 status: SmolStr::new("filled"),
4803 },
4804 price: Some("100.00"),
4805 qty: Some("10"),
4806 timestamp: None,
4807 };
4808 assert_eq!(early_dedup_key(&update1).as_str(), "ord-x:1");
4809
4810 let update2 = AlpacaTradeUpdate {
4812 event: SmolStr::new("fill"),
4813 order: AlpacaOrderWs {
4814 id: SmolStr::new("ord-x"),
4815 client_order_id: Some(SmolStr::new("cid")),
4816 symbol: SmolStr::new("AAPL"),
4817 qty: Some("10"),
4818 filled_qty: Some("1"),
4819 side: SmolStr::new("buy"),
4820 order_type: SmolStr::new("market"),
4821 time_in_force: SmolStr::new("day"),
4822 limit_price: None,
4823 stop_price: None,
4824 trail_percent: None,
4825 trail_price: None,
4826 status: SmolStr::new("filled"),
4827 },
4828 price: Some("100.00"),
4829 qty: Some("10"),
4830 timestamp: None,
4831 };
4832 assert_eq!(early_dedup_key(&update2).as_str(), "ord-x:1");
4833
4834 let update3 = AlpacaTradeUpdate {
4836 event: SmolStr::new("fill"),
4837 order: AlpacaOrderWs {
4838 id: SmolStr::new("ord-x"),
4839 client_order_id: Some(SmolStr::new("cid")),
4840 symbol: SmolStr::new("AAPL"),
4841 qty: Some("10"),
4842 filled_qty: Some("1.0"),
4843 side: SmolStr::new("buy"),
4844 order_type: SmolStr::new("market"),
4845 time_in_force: SmolStr::new("day"),
4846 limit_price: None,
4847 stop_price: None,
4848 trail_percent: None,
4849 trail_price: None,
4850 status: SmolStr::new("filled"),
4851 },
4852 price: Some("100.00"),
4853 qty: Some("10"),
4854 timestamp: None,
4855 };
4856 assert_eq!(early_dedup_key(&update3).as_str(), "ord-x:1");
4857 }
4858
4859 #[test]
4864 fn process_ws_text_rejected_event_without_filled_qty_is_not_dropped() {
4865 let (tx, mut rx) = mpsc::unbounded_channel();
4866 let dedup = new_dedup_cache();
4867 let mut backoff = ExponentialBackoff::new();
4868
4869 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"}}}"#;
4871
4872 process_ws_text(json, &tx, &dedup, &mut backoff);
4873
4874 let event = rx.try_recv()
4876 .expect("rejected event without filled_qty must produce an AccountEvent, not be silently dropped");
4877 assert!(
4878 matches!(event.kind, AccountEventKind::OrderCancelled(_)),
4879 "rejected event must map to OrderCancelled, got: {:?}",
4880 event.kind
4881 );
4882 }
4883
4884 #[test]
4887 fn decimal_zero_normalize_is_zero_str() {
4888 assert_eq!(Decimal::ZERO.normalize().to_string(), "0");
4889 }
4890
4891 #[test]
4895 fn decimal_normalize_strips_trailing_zeros() {
4896 let from_rest = Decimal::from_str("1.00").unwrap().normalize();
4898 let from_ws = Decimal::from_str("1").unwrap().normalize();
4899 assert_eq!(from_rest.to_string(), from_ws.to_string());
4900 assert_eq!(from_rest.to_string(), "1");
4901
4902 assert_eq!(
4904 Decimal::from_str("100.000")
4905 .unwrap()
4906 .normalize()
4907 .to_string(),
4908 "100"
4909 );
4910 assert_eq!(
4911 Decimal::from_str("0.10").unwrap().normalize().to_string(),
4912 "0.1"
4913 );
4914 assert_eq!(
4915 Decimal::from_str("0.100").unwrap().normalize().to_string(),
4916 "0.1"
4917 );
4918 }
4919
4920 #[test]
4925 fn convert_account_to_balances_zero_options_buying_power_falls_back_to_buying_power() {
4926 let account = AlpacaAccount {
4927 equity: "12000.00".into(),
4928 buying_power: "10000.00".into(),
4929 options_buying_power: Some("0.00".into()), crypto_buying_power: None,
4931 };
4932 let balances = convert_account_to_balances(&account, &[]);
4933 assert_eq!(balances.len(), 1);
4934 assert_eq!(
4935 balances[0].balance.free,
4936 Decimal::from_str("10000.00").unwrap(),
4937 "zero options_buying_power must fall back to buying_power, not report free=0"
4938 );
4939 }
4940
4941 #[test]
4943 fn map_position_intent_open_buy_maps_to_buy_to_open() {
4944 assert_eq!(
4945 map_position_intent(Side::Buy, false),
4946 AlpacaPositionIntent::BuyToOpen
4947 );
4948 }
4949
4950 #[test]
4951 fn map_position_intent_open_sell_maps_to_sell_to_open() {
4952 assert_eq!(
4953 map_position_intent(Side::Sell, false),
4954 AlpacaPositionIntent::SellToOpen
4955 );
4956 }
4957
4958 #[test]
4959 fn map_position_intent_reduce_buy_maps_to_buy_to_close() {
4960 assert_eq!(
4961 map_position_intent(Side::Buy, true),
4962 AlpacaPositionIntent::BuyToClose
4963 );
4964 }
4965
4966 #[test]
4967 fn map_position_intent_reduce_sell_maps_to_sell_to_close() {
4968 assert_eq!(
4969 map_position_intent(Side::Sell, true),
4970 AlpacaPositionIntent::SellToClose
4971 );
4972 }
4973
4974 #[test]
4976 fn parse_order_error_401_maps_to_unauthenticated() {
4977 assert!(matches!(
4979 parse_order_error(reqwest::StatusCode::UNAUTHORIZED, "bad credentials"),
4980 UnindexedOrderError::Rejected(ApiError::Unauthenticated(_))
4981 ));
4982 }
4983
4984 #[test]
4985 fn parse_order_error_403_maps_to_unauthenticated() {
4986 assert!(matches!(
4989 parse_order_error(reqwest::StatusCode::FORBIDDEN, "account suspended"),
4990 UnindexedOrderError::Rejected(ApiError::Unauthenticated(_))
4991 ));
4992 }
4993
4994 #[test]
4995 fn parse_order_error_404_maps_to_order_rejected_with_not_found_prefix() {
4996 let err = parse_order_error(reqwest::StatusCode::NOT_FOUND, "order not found");
4997 let UnindexedOrderError::Rejected(ApiError::OrderRejected(msg)) = err else {
4998 panic!("expected OrderRejected, got {err:?}");
4999 };
5000 assert!(
5001 msg.contains("order not found"),
5002 "message should contain 'order not found': {msg}"
5003 );
5004 }
5005
5006 #[test]
5007 fn parse_order_error_422_insufficient_only_maps_to_balance_insufficient() {
5008 assert!(matches!(
5010 parse_order_error(
5011 reqwest::StatusCode::UNPROCESSABLE_ENTITY,
5012 "insufficient funds for this order"
5013 ),
5014 UnindexedOrderError::Rejected(ApiError::BalanceInsufficient(_, _))
5015 ));
5016 }
5017
5018 #[test]
5019 fn parse_order_error_429_maps_to_rate_limit() {
5020 assert!(matches!(
5021 parse_order_error(reqwest::StatusCode::TOO_MANY_REQUESTS, "rate limited"),
5022 UnindexedOrderError::Rejected(ApiError::RateLimit)
5023 ));
5024 }
5025
5026 #[test]
5030 fn parse_time_in_force_unknown_value_falls_back_to_good_until_end_of_day() {
5031 assert_eq!(
5032 parse_time_in_force("opg"),
5033 TimeInForce::GoodUntilEndOfDay,
5034 "unknown TIF must fall back to GoodUntilEndOfDay (with a warn! in production)"
5035 );
5036 }
5037
5038 #[test]
5041 fn build_instrument_snapshots_output_order_matches_instruments_slice() {
5042 let orders = vec![
5044 make_order_response("o1", "SPY"),
5045 make_order_response("o2", "AAPL"),
5046 make_order_response("o3", "MSFT"),
5047 ];
5048 let instruments = vec![
5050 InstrumentNameExchange::new("MSFT"),
5051 InstrumentNameExchange::new("AAPL"),
5052 ];
5053 let snapshots = build_instrument_snapshots(orders, &instruments);
5054 assert_eq!(snapshots.len(), 2);
5055 assert_eq!(
5056 snapshots[0].instrument.name().as_str(),
5057 "MSFT",
5058 "first snapshot must be MSFT (first in instruments slice)"
5059 );
5060 assert_eq!(
5061 snapshots[1].instrument.name().as_str(),
5062 "AAPL",
5063 "second snapshot must be AAPL (second in instruments slice)"
5064 );
5065 }
5066
5067 mod http_tests {
5074 use super::super::*;
5075 use wiremock::matchers::{method, path};
5076 use wiremock::{Mock, MockServer, Request, Respond, ResponseTemplate};
5077
5078 struct Sequential {
5085 call: std::sync::atomic::AtomicU32,
5086 pages: Vec<serde_json::Value>,
5087 }
5088
5089 impl Sequential {
5090 fn new(pages: Vec<serde_json::Value>) -> Self {
5091 Self {
5092 call: std::sync::atomic::AtomicU32::new(0),
5093 pages,
5094 }
5095 }
5096 }
5097
5098 impl Respond for Sequential {
5099 fn respond(&self, _: &Request) -> ResponseTemplate {
5100 let i = self.call.fetch_add(1, std::sync::atomic::Ordering::Relaxed) as usize;
5101 let body = self.pages.get(i).unwrap_or_else(|| {
5102 panic!(
5103 "Sequential: request #{i} has no configured response \
5104 (only {} page(s) supplied)",
5105 self.pages.len()
5106 )
5107 });
5108 ResponseTemplate::new(200).set_body_json(body)
5109 }
5110 }
5111
5112 fn make_activities_json(count: usize, id_prefix: &str) -> serde_json::Value {
5114 serde_json::Value::Array(
5115 (0..count)
5116 .map(|i| {
5117 serde_json::json!({
5118 "id": format!("{id_prefix}-{i:05}"),
5119 "order_id": "ord-1",
5120 "symbol": "SPY",
5121 "side": "buy",
5122 "price": "100.00",
5123 "qty": "1",
5124 "transaction_time": "2025-04-18T14:30:00Z"
5125 })
5126 })
5127 .collect(),
5128 )
5129 }
5130
5131 fn make_orders_json(count: usize) -> serde_json::Value {
5133 serde_json::Value::Array(
5134 (0..count)
5135 .map(|i| {
5136 serde_json::json!({
5137 "id": format!("order-{i:05}"),
5138 "client_order_id": null,
5139 "symbol": "SPY",
5140 "qty": "1",
5141 "filled_qty": "0",
5142 "side": "buy",
5143 "type": "limit",
5144 "time_in_force": "day",
5145 "limit_price": "100.00",
5146 "created_at": "2025-04-18T14:30:00Z"
5147 })
5148 })
5149 .collect(),
5150 )
5151 }
5152
5153 #[tokio::test]
5158 async fn paginate_activities_single_page_below_max_returns_all_not_truncated() {
5159 let server = MockServer::start().await;
5160 Mock::given(method("GET"))
5161 .and(path("/v2/account/activities"))
5162 .respond_with(
5163 ResponseTemplate::new(200).set_body_json(make_activities_json(5, "act")),
5164 )
5165 .mount(&server)
5166 .await;
5167
5168 let http = reqwest::Client::new();
5169 let rl = RateLimitTracker::new();
5170 let result = paginate_activities(&http, &rl, &server.uri(), "2025-01-01T00:00:00Z")
5171 .await
5172 .unwrap();
5173
5174 assert_eq!(result.activities.len(), 5);
5175 assert!(!result.truncated);
5176 assert_eq!(server.received_requests().await.unwrap().len(), 1);
5177 }
5178
5179 #[tokio::test]
5182 async fn paginate_activities_exactly_page_size_items_fetches_second_page() {
5183 let server = MockServer::start().await;
5184 Mock::given(method("GET"))
5185 .and(path("/v2/account/activities"))
5186 .respond_with(Sequential::new(vec![
5187 make_activities_json(ALPACA_MAX_ACTIVITIES, "act"),
5188 serde_json::json!([]), ]))
5190 .mount(&server)
5191 .await;
5192
5193 let http = reqwest::Client::new();
5194 let rl = RateLimitTracker::new();
5195 let result = paginate_activities(&http, &rl, &server.uri(), "2025-01-01T00:00:00Z")
5196 .await
5197 .unwrap();
5198
5199 assert_eq!(result.activities.len(), ALPACA_MAX_ACTIVITIES);
5200 assert!(!result.truncated);
5201 assert_eq!(
5202 server.received_requests().await.unwrap().len(),
5203 2,
5204 "exactly 2 requests: first full page + second empty page"
5205 );
5206 }
5207
5208 #[tokio::test]
5211 async fn paginate_activities_two_pages_returns_combined_activities_not_truncated() {
5212 let server = MockServer::start().await;
5213 Mock::given(method("GET"))
5214 .and(path("/v2/account/activities"))
5215 .respond_with(Sequential::new(vec![
5216 make_activities_json(ALPACA_MAX_ACTIVITIES, "p1"),
5217 make_activities_json(37, "p2"),
5218 ]))
5219 .mount(&server)
5220 .await;
5221
5222 let http = reqwest::Client::new();
5223 let rl = RateLimitTracker::new();
5224 let result = paginate_activities(&http, &rl, &server.uri(), "2025-01-01T00:00:00Z")
5225 .await
5226 .unwrap();
5227
5228 assert_eq!(result.activities.len(), ALPACA_MAX_ACTIVITIES + 37);
5229 assert!(!result.truncated);
5230 assert_eq!(server.received_requests().await.unwrap().len(), 2);
5231 }
5232
5233 #[tokio::test]
5237 async fn paginate_activities_at_max_pages_sets_truncated_true() {
5238 let server = MockServer::start().await;
5239
5240 Mock::given(method("GET"))
5242 .and(path("/v2/account/activities"))
5243 .respond_with(
5244 ResponseTemplate::new(200)
5245 .set_body_json(make_activities_json(ALPACA_MAX_ACTIVITIES, "act")),
5246 )
5247 .mount(&server)
5248 .await;
5249
5250 let http = reqwest::Client::new();
5251 let rl = RateLimitTracker::new();
5252 let result = paginate_activities(&http, &rl, &server.uri(), "2025-01-01T00:00:00Z")
5253 .await
5254 .unwrap();
5255
5256 assert!(
5257 result.truncated,
5258 "must be truncated after MAX_ACTIVITY_PAGES pages"
5259 );
5260 assert_eq!(
5261 result.activities.len(),
5262 MAX_ACTIVITY_PAGES * ALPACA_MAX_ACTIVITIES,
5263 "must accumulate exactly MAX_ACTIVITY_PAGES * page_size activities"
5264 );
5265 assert_eq!(
5268 server.received_requests().await.unwrap().len(),
5269 MAX_ACTIVITY_PAGES,
5270 "loop must issue exactly MAX_ACTIVITY_PAGES requests then stop"
5271 );
5272 }
5273
5274 #[tokio::test]
5278 async fn fetch_raw_open_orders_499_results_returns_ok() {
5279 let server = MockServer::start().await;
5280 Mock::given(method("GET"))
5281 .and(path("/v2/orders"))
5282 .respond_with(
5283 ResponseTemplate::new(200).set_body_json(make_orders_json(MAX_OPEN_ORDERS - 1)),
5284 )
5285 .mount(&server)
5286 .await;
5287
5288 let http = reqwest::Client::new();
5289 let rl = RateLimitTracker::new();
5290 let result = fetch_raw_open_orders(&http, &rl, &server.uri(), &[]).await;
5291
5292 assert!(
5293 result.is_ok(),
5294 "499 orders must not trigger truncation: {result:?}"
5295 );
5296 assert_eq!(result.unwrap().len(), MAX_OPEN_ORDERS - 1);
5297 }
5298
5299 #[tokio::test]
5303 async fn fetch_raw_open_orders_500_results_returns_truncated_snapshot_error() {
5304 let server = MockServer::start().await;
5305 Mock::given(method("GET"))
5306 .and(path("/v2/orders"))
5307 .respond_with(
5308 ResponseTemplate::new(200).set_body_json(make_orders_json(MAX_OPEN_ORDERS)),
5309 )
5310 .mount(&server)
5311 .await;
5312
5313 let http = reqwest::Client::new();
5314 let rl = RateLimitTracker::new();
5315 let result = fetch_raw_open_orders(&http, &rl, &server.uri(), &[]).await;
5316
5317 assert!(
5318 matches!(
5319 result,
5320 Err(UnindexedClientError::TruncatedSnapshot { limit }) if limit == MAX_OPEN_ORDERS
5321 ),
5322 "500 orders must return TruncatedSnapshot, got: {result:?}"
5323 );
5324 }
5325
5326 #[tokio::test]
5334 async fn open_order_reduce_only_sell_sends_sell_to_close_intent() {
5335 use crate::client::ExecutionClient;
5336 use crate::order::request::{OrderRequestOpen, RequestOpen};
5337 use crate::order::{
5338 OrderKey, OrderKind, TimeInForce,
5339 id::{ClientOrderId, StrategyId},
5340 };
5341 use rust_decimal::Decimal;
5342 use rustrade_instrument::Side;
5343 use rustrade_instrument::exchange::ExchangeId;
5344 use rustrade_instrument::instrument::name::InstrumentNameExchange;
5345 use wiremock::matchers::{method, path};
5346
5347 let server = MockServer::start().await;
5348
5349 let captured_body = std::sync::Arc::new(parking_lot::Mutex::new(None));
5352 let captured_clone = captured_body.clone();
5353
5354 Mock::given(method("POST"))
5355 .and(path("/v2/orders"))
5356 .respond_with(move |req: &Request| {
5357 let body: serde_json::Value = serde_json::from_slice(&req.body).unwrap();
5359 *captured_clone.lock() = Some(body);
5360
5361 ResponseTemplate::new(200).set_body_json(serde_json::json!({
5362 "id": "test-order-id",
5363 "client_order_id": "test-cid",
5364 "symbol": "AAPL",
5365 "qty": "10",
5366 "filled_qty": "0",
5367 "side": "sell",
5368 "type": "market",
5369 "time_in_force": "ioc",
5370 "limit_price": null,
5371 "created_at": "2025-04-18T14:30:00Z"
5372 }))
5373 })
5374 .mount(&server)
5375 .await;
5376
5377 let config =
5379 AlpacaConfig::with_base_url("test-key".into(), "test-secret".into(), server.uri());
5380 let client = AlpacaClient::new(config);
5381
5382 let request = OrderRequestOpen {
5384 key: OrderKey {
5385 exchange: ExchangeId::AlpacaBroker,
5386 instrument: InstrumentNameExchange::new("AAPL"),
5387 strategy: StrategyId::new("test-strategy"),
5388 cid: ClientOrderId::new("test-cid"),
5389 },
5390 state: RequestOpen {
5391 side: Side::Sell,
5392 price: None,
5393 quantity: Decimal::new(10, 0),
5394 kind: OrderKind::Market,
5395 time_in_force: TimeInForce::ImmediateOrCancel,
5396 position_id: None,
5397 reduce_only: true, market: None,
5399 },
5400 };
5401
5402 let result = client
5404 .open_order(OrderRequestOpen {
5405 key: OrderKey {
5406 exchange: request.key.exchange,
5407 instrument: &request.key.instrument,
5408 strategy: request.key.strategy.clone(),
5409 cid: request.key.cid.clone(),
5410 },
5411 state: request.state.clone(),
5412 })
5413 .await;
5414
5415 assert!(result.is_some(), "open_order should return a result");
5417 let order = result.unwrap();
5418 assert!(
5419 order.state.is_accepted(),
5420 "order should be accepted: {:?}",
5421 order.state
5422 );
5423
5424 let body = captured_body
5426 .lock()
5427 .take()
5428 .expect("request body should be captured");
5429 assert_eq!(
5430 body.get("position_intent").and_then(|v| v.as_str()),
5431 Some("sell_to_close"),
5432 "reduce_only=true + Side::Sell should produce position_intent=sell_to_close, got: {body}"
5433 );
5434 }
5435
5436 #[tokio::test]
5438 async fn open_order_not_reduce_only_buy_sends_buy_to_open_intent() {
5439 use crate::client::ExecutionClient;
5440 use crate::order::request::{OrderRequestOpen, RequestOpen};
5441 use crate::order::{
5442 OrderKey, OrderKind, TimeInForce,
5443 id::{ClientOrderId, StrategyId},
5444 };
5445 use rust_decimal::Decimal;
5446 use rustrade_instrument::Side;
5447 use rustrade_instrument::exchange::ExchangeId;
5448 use rustrade_instrument::instrument::name::InstrumentNameExchange;
5449
5450 let server = MockServer::start().await;
5451
5452 let captured_body = std::sync::Arc::new(parking_lot::Mutex::new(None));
5453 let captured_clone = captured_body.clone();
5454
5455 Mock::given(method("POST"))
5456 .and(path("/v2/orders"))
5457 .respond_with(move |req: &Request| {
5458 let body: serde_json::Value = serde_json::from_slice(&req.body).unwrap();
5459 *captured_clone.lock() = Some(body);
5460
5461 ResponseTemplate::new(200).set_body_json(serde_json::json!({
5462 "id": "test-order-id",
5463 "client_order_id": "test-cid",
5464 "symbol": "AAPL",
5465 "qty": "10",
5466 "filled_qty": "0",
5467 "side": "buy",
5468 "type": "market",
5469 "time_in_force": "ioc",
5470 "limit_price": null,
5471 "created_at": "2025-04-18T14:30:00Z"
5472 }))
5473 })
5474 .mount(&server)
5475 .await;
5476
5477 let config =
5478 AlpacaConfig::with_base_url("test-key".into(), "test-secret".into(), server.uri());
5479 let client = AlpacaClient::new(config);
5480
5481 let instrument = InstrumentNameExchange::new("AAPL");
5482 let request = OrderRequestOpen {
5483 key: OrderKey {
5484 exchange: ExchangeId::AlpacaBroker,
5485 instrument: &instrument,
5486 strategy: StrategyId::new("test-strategy"),
5487 cid: ClientOrderId::new("test-cid"),
5488 },
5489 state: RequestOpen {
5490 side: Side::Buy,
5491 price: None,
5492 quantity: Decimal::new(10, 0),
5493 kind: OrderKind::Market,
5494 time_in_force: TimeInForce::ImmediateOrCancel,
5495 position_id: None,
5496 reduce_only: false, market: None,
5498 },
5499 };
5500
5501 let result = client.open_order(request).await;
5502
5503 assert!(result.is_some(), "open_order should return a result");
5504 let order = result.unwrap();
5505 assert!(
5506 order.state.is_accepted(),
5507 "order should be accepted: {:?}",
5508 order.state
5509 );
5510
5511 let body = captured_body
5512 .lock()
5513 .take()
5514 .expect("request body should be captured");
5515 assert_eq!(
5516 body.get("position_intent").and_then(|v| v.as_str()),
5517 Some("buy_to_open"),
5518 "reduce_only=false + Side::Buy should produce position_intent=buy_to_open, got: {body}"
5519 );
5520 }
5521
5522 fn activity_json(
5532 id: &str,
5533 order_id: &str,
5534 qty: &str,
5535 cum_qty: Option<&str>,
5536 ) -> serde_json::Value {
5537 let mut v = serde_json::json!({
5538 "id": id,
5539 "order_id": order_id,
5540 "symbol": "SPY",
5541 "side": "buy",
5542 "price": "100.00",
5543 "qty": qty,
5544 "transaction_time": "2025-04-18T14:30:00Z"
5545 });
5546 if let Some(c) = cum_qty {
5547 v["cum_qty"] = serde_json::Value::String(c.to_string());
5548 }
5549 v
5550 }
5551
5552 async fn drive_recover_fills_with(
5555 activities: Vec<serde_json::Value>,
5556 dedup: SharedDedupCache,
5557 ) -> Vec<UnindexedAccountEvent> {
5558 let server = MockServer::start().await;
5559 Mock::given(method("GET"))
5560 .and(path("/v2/account/activities"))
5561 .respond_with(
5562 ResponseTemplate::new(200).set_body_json(serde_json::Value::Array(activities)),
5563 )
5564 .mount(&server)
5565 .await;
5566
5567 let http = reqwest::Client::new();
5568 let rl = RateLimitTracker::new();
5569 let (tx, mut rx) = mpsc::unbounded_channel();
5570 recover_fills(
5571 &http,
5572 &rl,
5573 &[],
5574 &server.uri(),
5575 "2025-01-01T00:00:00Z",
5576 &tx,
5577 &dedup,
5578 )
5579 .await;
5580 drop(tx);
5581
5582 let mut out = Vec::new();
5583 while let Ok(event) = rx.try_recv() {
5584 out.push(event);
5585 }
5586 out
5587 }
5588
5589 async fn drive_recover_fills(
5590 activities: Vec<serde_json::Value>,
5591 ) -> Vec<UnindexedAccountEvent> {
5592 drive_recover_fills_with(activities, new_dedup_cache()).await
5593 }
5594
5595 fn trade_of(
5596 event: &UnindexedAccountEvent,
5597 ) -> &Trade<AssetNameExchange, InstrumentNameExchange> {
5598 match &event.kind {
5599 AccountEventKind::Trade(t) => t,
5600 other => panic!("expected a Trade event, got {other:?}"),
5601 }
5602 }
5603
5604 #[tokio::test]
5607 async fn a_recovered_fill_reports_the_cumulative_alpaca_sent() {
5608 let events = drive_recover_fills(vec![
5609 activity_json("act-1", "ord-1", "2", Some("2")),
5610 activity_json("act-2", "ord-1", "3", Some("5")),
5611 ])
5612 .await;
5613
5614 let reported: Vec<_> = events
5615 .iter()
5616 .map(|e| trade_of(e).order_filled_quantity)
5617 .collect();
5618 assert_eq!(
5619 reported,
5620 vec![
5621 Some(Decimal::from_str("2").unwrap()),
5622 Some(Decimal::from_str("5").unwrap())
5623 ]
5624 );
5625 }
5626
5627 #[tokio::test]
5634 async fn the_dedup_key_uses_the_venue_cumulative_not_an_intra_batch_count() {
5635 let events =
5637 drive_recover_fills(vec![activity_json("act-1", "ord-1", "2", Some("5"))]).await;
5638
5639 assert_eq!(
5640 trade_of(&events[0]).id.0.as_str(),
5641 "ord-1:5",
5642 "counting executions within the batch would key this as ord-1:2"
5643 );
5644 }
5645
5646 #[tokio::test]
5650 async fn a_fill_already_delivered_over_websocket_is_not_recovered_twice() {
5651 let dedup = new_dedup_cache();
5652
5653 let update = AlpacaTradeUpdate {
5655 event: SmolStr::new("partial_fill"),
5656 order: super::make_order_ws("ord-1", "SPY", "buy", "5"),
5657 price: Some("100.00"),
5658 qty: Some("2"),
5659 timestamp: None,
5660 };
5661 let [ws_event, _snapshot] = convert_trade_update(update);
5662 let ws_event = ws_event.expect("a partial_fill converts to a Trade");
5663 let ws_key = fill_dedup_key_from_event(&ws_event)
5664 .expect("a Trade carries a dedup key")
5665 .clone();
5666 assert!(
5667 !is_duplicate(&dedup, &ws_key),
5668 "precondition: first sighting"
5669 );
5670
5671 let events = drive_recover_fills_with(
5672 vec![activity_json("act-1", "ord-1", "2", Some("5"))],
5673 dedup,
5674 )
5675 .await;
5676
5677 assert!(
5678 events.is_empty(),
5679 "a fill already delivered over WebSocket must not be re-delivered by recovery, \
5680 got {events:?}"
5681 );
5682 }
5683
5684 #[tokio::test]
5687 async fn without_a_reported_cumulative_the_previous_behaviour_is_preserved() {
5688 let events = drive_recover_fills(vec![
5689 activity_json("act-1", "ord-1", "2", None),
5690 activity_json("act-2", "ord-1", "3", None),
5691 ])
5692 .await;
5693
5694 let keys: Vec<_> = events
5695 .iter()
5696 .map(|e| trade_of(e).id.0.as_str().to_owned())
5697 .collect();
5698 assert_eq!(keys, vec!["ord-1:2", "ord-1:5"], "intra-batch accumulation");
5699
5700 assert!(
5701 events
5702 .iter()
5703 .all(|e| trade_of(e).order_filled_quantity.is_none()),
5704 "a venue that reported no cumulative must not have one invented for it"
5705 );
5706 }
5707 }
5708}