1use crate::{
46 AccountEventKind, AccountSnapshot, FillRecoveryFailure, FillRecoveryGap, FillRecoveryScope,
47 InstrumentAccountSnapshot, UnindexedAccountEvent, UnindexedAccountSnapshot,
48 balance::{AssetBalance, Balance},
49 cash_flow::{CashFlow, CashFlowId, CashFlowKind, CashFlowsRead},
50 client::{
51 BracketOrderClient, ExecutionClient, OrderStatusClient, ShortabilityClient,
52 order_recovery::{
53 KnownLiveOrders, NoPendingFills, OpenListing, OrderLookup, SharedKnownLiveOrders,
54 UncheckedOrders, fetch_ended_by_key, recover_ended_orders,
55 },
56 },
57 emit_stream_terminated,
58 error::{
59 ApiError, BorrowReject, BorrowRejectReason, ConnectivityError, OrderError,
60 StreamTerminationReason, UnindexedApiError, UnindexedClientError, UnindexedOrderError,
61 },
62 order::{
63 Order, OrderKey, OrderKind, TimeInForce, TrailingOffsetType, UnindexedInactiveOrder,
64 UnindexedOrderKey, UnindexedOrderSnapshot,
65 bracket::{
66 BracketOrderRequest as UnifiedBracketOrderRequest,
67 BracketOrderResult as UnifiedBracketOrderResult,
68 },
69 id::{ClientOrderId, OrderId, StrategyId, VenueOrderId},
70 request::{OrderRequestCancel, OrderRequestOpen, UnindexedOrderResponseCancel},
71 state::{
72 ActiveOrderState, Cancelled, Expired, Filled, InactiveOrderState, Open, OrderState,
73 UnindexedOrderState,
74 },
75 },
76 parse_env_bool,
77 position::{Position, PositionReport},
78 shortability::Shortability,
79 trade::{AssetFees, Trade, TradeAmendmentKind, TradeId, TradeOrigin, TradesRead},
80};
81use chrono::{DateTime, NaiveDate, NaiveTime, SecondsFormat, TimeDelta, Utc};
82use fnv::{FnvHashMap, FnvHashSet};
83use futures::{SinkExt as _, StreamExt as _, stream::BoxStream};
84use indexmap::IndexMap;
85use itertools::Itertools as _;
86use lru::LruCache;
87use rust_decimal::Decimal;
88use rustrade_instrument::{
89 Side,
90 asset::name::AssetNameExchange,
91 exchange::ExchangeId,
92 instrument::{kind::InstrumentKindDiscriminant, name::InstrumentNameExchange},
93};
94use rustrade_integration::protocol::websocket::{WebSocket, WsMessage};
95use serde::{Deserialize, Serialize, de::DeserializeOwned};
96use smol_str::{SmolStr, format_smolstr};
97use std::{num::NonZeroUsize, pin::Pin, str::FromStr, sync::Arc, time::Duration};
98use tokio::sync::mpsc;
99use tracing::{debug, error, info, trace, warn};
100
101const INITIAL_BACKOFF_MS: u64 = 1_000;
106const MAX_BACKOFF_MS: u64 = 30_000;
107const MAX_RECONNECT_ATTEMPTS: u32 = 10;
108const HEARTBEAT_TIMEOUT_SECS: u64 = 35;
110const FILL_RECOVERY_TIMEOUT_SECS: u64 = 30;
112const SIGNAL_RECOVERY_LOOKBACK_MS: i64 = 1_500;
115const ALPACA_MAX_ACTIVITIES: usize = 100;
117const DEFAULT_RATE_LIMIT_DELAY_SECS: u64 = 60;
120const MAX_RATE_LIMIT_ATTEMPTS: u32 = 4;
123const DEDUP_CACHE_SIZE: usize = 4_000;
127
128const USD: &str = "USD";
131const WS_HANDSHAKE_TIMEOUT_SECS: u64 = 15;
133const WS_CLOSE_TIMEOUT_SECS: u64 = 5;
136
137struct GracefulShutdownStream<S> {
154 inner: S,
155 _handle: tokio::task::JoinHandle<()>,
161}
162
163impl<S> GracefulShutdownStream<S> {
164 fn new(inner: S, handle: tokio::task::JoinHandle<()>) -> Self {
165 Self {
166 inner,
167 _handle: handle,
168 }
169 }
170}
171
172impl<S: futures::Stream + Unpin> futures::Stream for GracefulShutdownStream<S> {
173 type Item = S::Item;
174 fn poll_next(
175 mut self: Pin<&mut Self>,
176 cx: &mut std::task::Context<'_>,
177 ) -> std::task::Poll<Option<Self::Item>> {
178 Pin::new(&mut self.inner).poll_next(cx)
179 }
180 fn size_hint(&self) -> (usize, Option<usize>) {
181 self.inner.size_hint()
182 }
183}
184
185impl<S> Drop for GracefulShutdownStream<S> {
186 fn drop(&mut self) {
187 }
191}
192
193struct RateLimitTracker {
199 blocked_until: parking_lot::Mutex<Option<tokio::time::Instant>>,
200}
201
202impl RateLimitTracker {
203 fn new() -> Self {
204 Self {
205 blocked_until: parking_lot::Mutex::new(None),
206 }
207 }
208
209 async fn wait_if_blocked(&self) {
211 loop {
212 let now = tokio::time::Instant::now();
216 let deadline = {
224 let mut guard = self.blocked_until.lock();
225 let d = *guard;
226 if matches!(d, Some(t) if t <= now) {
227 *guard = None;
230 }
231 d
232 };
233 match deadline {
234 None => return,
235 Some(until) => {
236 if until <= now {
237 return;
239 }
240 #[allow(clippy::cast_possible_truncation)]
242 let delay_ms = (until - now).as_millis() as u64;
243 debug!(delay_ms, "Alpaca REST rate-limited, waiting before request");
244 tokio::time::sleep_until(until).await;
245 }
246 }
247 }
248 }
249
250 fn on_rate_limited(&self, retry_after: Option<Duration>) {
252 let delay = retry_after.unwrap_or(Duration::from_secs(DEFAULT_RATE_LIMIT_DELAY_SECS));
253 let was_blocked = self.extend(delay);
254 if was_blocked {
255 debug!(
256 delay_secs = delay.as_secs(),
257 "Alpaca rate-limit cooldown extended"
258 );
259 } else {
260 warn!(
261 delay_secs = delay.as_secs(),
262 "Alpaca entering rate-limit degradation mode"
263 );
264 }
265 }
266
267 fn on_bucket_exhausted(&self, reset: Duration) {
270 let was_blocked = self.extend(reset);
271 if !was_blocked {
273 info!(
274 delay_ms = u64::try_from(reset.as_millis()).unwrap_or(u64::MAX),
275 "Alpaca rate-limit window exhausted (X-Ratelimit-Remaining: 0), pausing requests until it resets"
276 );
277 }
278 }
279
280 fn extend(&self, delay: Duration) -> bool {
283 let now = tokio::time::Instant::now();
284 let new_deadline = now + delay;
285 let mut guard = self.blocked_until.lock();
286 let was_blocked = guard.is_some_and(|until| until > now);
287 *guard = Some(guard.map_or(new_deadline, |existing| existing.max(new_deadline)));
288 was_blocked
289 }
290}
291
292struct ExponentialBackoff {
297 attempt: u32,
298 max_attempts: u32,
299 initial_ms: u64,
300 max_ms: u64,
301}
302
303impl ExponentialBackoff {
304 fn new() -> Self {
305 Self {
306 attempt: 0,
307 max_attempts: MAX_RECONNECT_ATTEMPTS,
308 initial_ms: INITIAL_BACKOFF_MS,
309 max_ms: MAX_BACKOFF_MS,
310 }
311 }
312
313 fn reset(&mut self) {
314 self.attempt = 0;
315 }
316
317 fn attempts(&self) -> u32 {
323 self.attempt
324 }
325
326 async fn wait(&mut self) -> bool {
328 if self.attempt >= self.max_attempts {
329 return false;
330 }
331 let delay_ms = self
332 .initial_ms
333 .saturating_mul(2u64.saturating_pow(self.attempt))
334 .min(self.max_ms);
335 self.attempt += 1;
336 debug!(
337 attempt = self.attempt,
338 max = self.max_attempts,
339 delay_ms,
340 "Alpaca reconnect backoff"
341 );
342 tokio::time::sleep(Duration::from_millis(delay_ms)).await;
343 true
344 }
345}
346
347type SharedDedupCache = Arc<parking_lot::Mutex<LruCache<FillKey, ()>>>;
355
356#[derive(Debug, Clone, PartialEq, Eq, Hash)]
361enum FillKey {
362 Execution(SmolStr),
365 Cumulative(SmolStr),
367 CumulativeWithoutId(SmolStr),
369}
370
371fn new_dedup_cache() -> SharedDedupCache {
372 #[allow(clippy::unwrap_used)]
375 Arc::new(parking_lot::Mutex::new(LruCache::new(
376 NonZeroUsize::new(DEDUP_CACHE_SIZE).unwrap(),
377 )))
378}
379
380fn is_duplicate_fill(
400 cache: &SharedDedupCache,
401 execution_id: Option<&str>,
402 cumulative: SmolStr,
403) -> bool {
404 let execution = execution_id.map(|id| FillKey::Execution(SmolStr::new(id)));
406 let mut guard = cache.lock();
407 match execution {
409 Some(execution) => {
410 if guard.peek(&execution).is_some()
411 || guard
412 .peek(&FillKey::CumulativeWithoutId(cumulative.clone()))
413 .is_some()
414 {
415 return true;
416 }
417 guard.put(execution, ());
418 guard.put(FillKey::Cumulative(cumulative), ());
419 }
420 None => {
421 if guard
422 .peek(&FillKey::Cumulative(cumulative.clone()))
423 .is_some()
424 {
425 return true;
426 }
427 guard.put(FillKey::Cumulative(cumulative.clone()), ());
428 guard.put(FillKey::CumulativeWithoutId(cumulative), ());
429 }
430 }
431 false
432}
433
434fn record_execution(cache: &SharedDedupCache, execution_id: &str) {
437 let execution = FillKey::Execution(SmolStr::new(execution_id));
438 cache.lock().put(execution, ());
439}
440
441#[derive(Clone, Deserialize)]
448#[serde(deny_unknown_fields)]
449pub struct AlpacaConfig {
450 api_key: String,
452 secret_key: String,
453
454 #[serde(default = "default_paper")]
456 pub paper: bool,
457 #[cfg(test)]
459 pub base_url_override: Option<String>,
460}
461
462fn default_paper() -> bool {
468 true
469}
470
471impl std::fmt::Debug for AlpacaConfig {
473 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
474 f.debug_struct("AlpacaConfig")
475 .field("api_key", &"***")
476 .field("secret_key", &"***")
477 .field("paper", &self.paper)
478 .finish()
479 }
480}
481
482impl AlpacaConfig {
483 pub fn new(api_key: String, secret_key: String) -> Self {
486 Self::paper(api_key, secret_key)
487 }
488
489 pub fn paper(api_key: String, secret_key: String) -> Self {
491 Self {
492 api_key,
493 secret_key,
494 paper: true,
495 #[cfg(test)]
496 base_url_override: None,
497 }
498 }
499
500 pub fn production(api_key: String, secret_key: String) -> Self {
505 Self {
506 api_key,
507 secret_key,
508 paper: false,
509 #[cfg(test)]
510 base_url_override: None,
511 }
512 }
513
514 pub fn from_env() -> Result<Self, AlpacaConfigError> {
532 let api_key = match std::env::var("ALPACA_API_KEY") {
533 Ok(value) => value,
534 Err(std::env::VarError::NotPresent) => return Err(AlpacaConfigError::MissingApiKey),
535 Err(std::env::VarError::NotUnicode(_)) => {
536 return Err(AlpacaConfigError::InvalidApiKey);
537 }
538 };
539 let secret_key = match std::env::var("ALPACA_SECRET_KEY") {
540 Ok(value) => value,
541 Err(std::env::VarError::NotPresent) => return Err(AlpacaConfigError::MissingSecretKey),
542 Err(std::env::VarError::NotUnicode(_)) => {
543 return Err(AlpacaConfigError::InvalidSecretKey);
544 }
545 };
546
547 let paper = match std::env::var("ALPACA_PAPER") {
548 Ok(value) => parse_env_bool(&value).ok_or(AlpacaConfigError::InvalidPaper(value))?,
549 Err(std::env::VarError::NotPresent) => true,
550 Err(std::env::VarError::NotUnicode(value)) => {
553 return Err(AlpacaConfigError::InvalidPaper(
554 value.to_string_lossy().into_owned(),
555 ));
556 }
557 };
558
559 if paper {
560 Ok(Self::paper(api_key, secret_key))
561 } else {
562 Ok(Self::production(api_key, secret_key))
563 }
564 }
565
566 #[cfg(test)]
568 pub fn with_base_url(api_key: String, secret_key: String, base_url: String) -> Self {
569 Self {
570 api_key,
571 secret_key,
572 paper: true,
573 base_url_override: Some(base_url),
574 }
575 }
576
577 pub fn api_key(&self) -> &str {
578 &self.api_key
579 }
580
581 pub fn rest_base_url(&self) -> &str {
585 #[cfg(test)]
586 if let Some(ref url) = self.base_url_override {
587 return url.as_str();
588 }
589 if self.paper {
590 "https://paper-api.alpaca.markets"
591 } else {
592 "https://api.alpaca.markets"
593 }
594 }
595
596 pub fn ws_url(&self) -> &'static str {
598 if self.paper {
599 "wss://paper-api.alpaca.markets/stream"
600 } else {
601 "wss://api.alpaca.markets/stream"
602 }
603 }
604}
605
606#[derive(Debug, PartialEq, thiserror::Error)]
607pub enum AlpacaConfigError {
608 #[error("ALPACA_API_KEY environment variable not set")]
609 MissingApiKey,
610
611 #[error("ALPACA_API_KEY environment variable is not valid UTF-8")]
614 InvalidApiKey,
615
616 #[error("ALPACA_SECRET_KEY environment variable not set")]
617 MissingSecretKey,
618
619 #[error("ALPACA_SECRET_KEY environment variable is not valid UTF-8")]
620 InvalidSecretKey,
621
622 #[error("ALPACA_PAPER must be true or false, got {0}")]
623 InvalidPaper(String),
624}
625
626#[derive(Debug, Deserialize)]
635struct AlpacaAccount {
636 #[serde(with = "rust_decimal::serde::str")]
639 cash: Decimal,
640 #[serde(with = "rust_decimal::serde::str")]
644 non_marginable_buying_power: Decimal,
645}
646
647#[derive(Debug, Deserialize)]
652struct AlpacaPosition {
653 symbol: String,
656 asset_class: String,
658 #[serde(default)]
662 side: AlpacaPositionSide,
663 #[serde(with = "rust_decimal::serde::str")]
665 qty: Decimal,
666 #[serde(with = "rust_decimal::serde::str")]
668 qty_available: Decimal,
669 #[serde(default, with = "rust_decimal::serde::str_option")]
671 avg_entry_price: Option<Decimal>,
672 #[serde(default, with = "rust_decimal::serde::str_option")]
674 unrealized_pl: Option<Decimal>,
675}
676
677#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize)]
679#[serde(rename_all = "lowercase")]
680enum AlpacaPositionSide {
681 Long,
682 Short,
683 #[default]
687 #[serde(other)]
688 Unknown,
689}
690
691#[derive(Debug, Deserialize)]
692struct AlpacaOrderResponse {
693 id: String,
694 client_order_id: Option<String>,
695 symbol: String,
696 qty: Option<String>,
697 filled_qty: String,
698 #[serde(default)]
700 filled_avg_price: Option<String>,
701 #[serde(default)]
705 status: Option<String>,
706 side: String,
707 #[serde(rename = "type")]
708 order_type: String,
709 time_in_force: String,
710 limit_price: Option<String>,
711 stop_price: Option<String>,
712 trail_percent: Option<String>,
713 trail_price: Option<String>,
714 created_at: String,
715 updated_at: Option<String>,
718}
719
720#[derive(Debug, Deserialize)]
721struct AlpacaActivity {
722 id: String,
723 order_id: String,
724 symbol: String,
725 side: String,
726 price: String,
727 qty: String,
728 transaction_time: String,
729 cum_qty: Option<String>,
737}
738
739#[derive(Debug, Deserialize)]
746struct AlpacaNonTradeActivity {
747 id: String,
748 activity_type: String,
749 #[serde(default)]
750 activity_sub_type: Option<String>,
751 #[serde(default)]
754 created_at: Option<String>,
755 #[serde(default)]
759 date: Option<String>,
760 #[serde(default)]
762 net_amount: Option<String>,
763 #[serde(default)]
766 qty: Option<String>,
767 #[serde(default)]
769 price: Option<String>,
770 #[serde(default)]
771 currency: Option<String>,
772 #[serde(default)]
773 symbol: Option<String>,
774 #[serde(default)]
776 status: Option<String>,
777}
778
779#[derive(Debug, Deserialize)]
784struct AlpacaApiError {
785 code: Option<i64>,
786 message: String,
787}
788
789impl AlpacaApiError {
790 fn from_body(bytes: &[u8]) -> Self {
792 serde_json::from_slice(bytes).unwrap_or_else(|_| Self {
793 code: None,
794 message: String::from_utf8_lossy(bytes).into_owned(),
795 })
796 }
797}
798
799#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
811#[serde(rename_all = "snake_case")]
812pub enum AlpacaPositionIntent {
813 BuyToOpen,
814 BuyToClose,
815 SellToOpen,
816 SellToClose,
817}
818
819#[derive(Debug, Serialize)]
827struct TakeProfitParams {
828 limit_price: String,
829}
830
831#[derive(Debug, Serialize)]
836struct StopLossParams {
837 stop_price: String,
838 #[serde(skip_serializing_if = "Option::is_none")]
839 limit_price: Option<String>,
840}
841
842#[non_exhaustive]
877#[derive(Debug, Clone)]
878pub struct AlpacaBracketOrderRequest {
879 pub instrument: InstrumentNameExchange,
881 pub strategy: StrategyId,
883 pub cid: ClientOrderId,
885 pub side: Side,
887 pub quantity: Decimal,
889 pub entry_price: Decimal,
891 pub take_profit_price: Decimal,
893 pub stop_loss_price: Decimal,
895 pub stop_loss_limit_price: Option<Decimal>,
897 pub time_in_force: TimeInForce,
899}
900
901impl AlpacaBracketOrderRequest {
902 #[allow(clippy::too_many_arguments)] pub fn new(
907 instrument: InstrumentNameExchange,
908 strategy: StrategyId,
909 cid: ClientOrderId,
910 side: Side,
911 quantity: Decimal,
912 entry_price: Decimal,
913 take_profit_price: Decimal,
914 stop_loss_price: Decimal,
915 time_in_force: TimeInForce,
916 ) -> Self {
917 Self {
918 instrument,
919 strategy,
920 cid,
921 side,
922 quantity,
923 entry_price,
924 take_profit_price,
925 stop_loss_price,
926 stop_loss_limit_price: None,
927 time_in_force,
928 }
929 }
930
931 #[must_use]
933 pub fn with_stop_loss_limit_price(mut self, price: Decimal) -> Self {
934 self.stop_loss_limit_price = Some(price);
935 self
936 }
937}
938
939#[non_exhaustive]
950#[derive(Debug, Clone)]
951pub struct AlpacaBracketOrderResult {
952 pub parent: Order<ExchangeId, InstrumentNameExchange, UnindexedOrderState>,
954}
955
956#[derive(Debug, Serialize)]
961struct AlpacaOrderRequest<'a> {
962 symbol: &'a str,
963 qty: String,
964 side: &'static str,
965 #[serde(rename = "type")]
966 order_type: &'static str,
967 time_in_force: &'static str,
968 #[serde(skip_serializing_if = "Option::is_none")]
969 limit_price: Option<String>,
970 #[serde(skip_serializing_if = "Option::is_none")]
971 stop_price: Option<String>,
972 #[serde(skip_serializing_if = "Option::is_none")]
973 trail_percent: Option<String>,
974 #[serde(skip_serializing_if = "Option::is_none")]
975 trail_price: Option<String>,
976 #[serde(skip_serializing_if = "Option::is_none")]
977 client_order_id: Option<&'a str>,
978 #[serde(skip_serializing_if = "Option::is_none")]
982 position_intent: Option<AlpacaPositionIntent>,
983 #[serde(skip_serializing_if = "Option::is_none")]
986 order_class: Option<&'static str>,
987 #[serde(skip_serializing_if = "Option::is_none")]
988 take_profit: Option<TakeProfitParams>,
989 #[serde(skip_serializing_if = "Option::is_none")]
990 stop_loss: Option<StopLossParams>,
991}
992
993#[derive(Debug, Deserialize)]
1002struct AlpacaStreamMessage<'a> {
1003 stream: SmolStr,
1006 #[serde(borrow)]
1007 data: &'a serde_json::value::RawValue,
1008}
1009
1010#[derive(Debug, Deserialize)]
1018struct AlpacaTradeUpdate<'a> {
1019 event: SmolStr,
1021 #[serde(borrow)]
1022 order: AlpacaOrderWs<'a>,
1023 #[serde(borrow)]
1025 price: Option<&'a str>,
1026 #[serde(borrow)]
1028 qty: Option<&'a str>,
1029 #[serde(borrow)]
1030 timestamp: Option<&'a str>,
1031 #[serde(borrow)]
1035 execution_id: Option<&'a str>,
1036 #[serde(borrow)]
1040 previous_execution_id: Option<&'a str>,
1041}
1042
1043#[derive(Debug, Deserialize)]
1045struct AlpacaOrderWs<'a> {
1046 id: SmolStr,
1049 client_order_id: Option<SmolStr>,
1050 symbol: SmolStr,
1053 #[serde(borrow)]
1054 qty: Option<&'a str>,
1055 #[serde(borrow)]
1062 filled_qty: Option<&'a str>,
1063 side: SmolStr,
1066 #[serde(rename = "type")]
1067 order_type: SmolStr,
1068 time_in_force: SmolStr,
1069 #[serde(borrow)]
1070 limit_price: Option<&'a str>,
1071 #[serde(borrow)]
1072 stop_price: Option<&'a str>,
1073 #[serde(borrow)]
1074 trail_percent: Option<&'a str>,
1075 #[serde(borrow)]
1076 trail_price: Option<&'a str>,
1077 status: SmolStr,
1078}
1079
1080#[derive(Clone)]
1114pub struct AlpacaClient {
1115 config: Arc<AlpacaConfig>,
1116 http: reqwest::Client,
1119 rate_limiter: Arc<RateLimitTracker>,
1120 orders_url: String,
1122 known_live: SharedKnownLiveOrders,
1125}
1126
1127impl std::fmt::Debug for AlpacaClient {
1128 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1129 f.debug_struct("AlpacaClient")
1130 .field("paper", &self.config.paper)
1131 .finish_non_exhaustive()
1132 }
1133}
1134
1135impl AlpacaClient {
1136 #[allow(clippy::expect_used)] fn build_http(config: &AlpacaConfig) -> reqwest::Client {
1144 use reqwest::header::{HeaderMap, HeaderName, HeaderValue};
1145 let mut headers = HeaderMap::new();
1146 headers.insert(
1148 HeaderName::from_static("apca-api-key-id"),
1149 HeaderValue::from_str(&config.api_key)
1150 .expect("Alpaca API key contains invalid header characters"),
1151 );
1152 headers.insert(
1153 HeaderName::from_static("apca-api-secret-key"),
1154 HeaderValue::from_str(&config.secret_key)
1155 .expect("Alpaca secret key contains invalid header characters"),
1156 );
1157 reqwest::Client::builder()
1158 .default_headers(headers)
1159 .build()
1160 .expect("failed to build reqwest client for Alpaca")
1161 }
1162
1163 fn base_url(&self) -> &str {
1164 self.config.rest_base_url()
1165 }
1166
1167 fn remember_live<'a>(
1169 &self,
1170 orders: impl IntoIterator<Item = &'a Order<ExchangeId, InstrumentNameExchange, Open>>,
1171 ) {
1172 let mut known = self.known_live.lock();
1173 for order in orders {
1174 known.live(&order.key, order.quantity, order.kind, &order.state);
1175 }
1176 }
1177}
1178
1179fn parse_rate_limit_delay(headers: &reqwest::header::HeaderMap) -> Option<Duration> {
1197 let header = |name: &str| {
1198 headers
1199 .get(name)
1200 .and_then(|value| value.to_str().ok())
1201 .and_then(|value| value.trim().parse::<u64>().ok())
1202 };
1203
1204 let seconds = match header("retry-after") {
1205 Some(seconds) => seconds,
1206 None => {
1207 let reset = header("x-ratelimit-reset")?;
1208 let now = std::time::SystemTime::now()
1209 .duration_since(std::time::UNIX_EPOCH)
1210 .unwrap_or_default()
1211 .as_secs();
1212 reset.saturating_sub(now).max(1)
1213 }
1214 };
1215 Some(Duration::from_secs(
1216 seconds.min(DEFAULT_RATE_LIMIT_DELAY_SECS),
1217 ))
1218}
1219
1220fn observe_rate_limit_remaining(
1228 rate_limiter: &RateLimitTracker,
1229 headers: &reqwest::header::HeaderMap,
1230) {
1231 let exhausted = headers
1232 .get("x-ratelimit-remaining")
1233 .and_then(|v| v.to_str().ok())
1234 .and_then(|s| s.parse::<u32>().ok())
1235 == Some(0);
1236 if !exhausted {
1237 return;
1238 }
1239 match parse_rate_limit_delay(headers) {
1240 Some(reset) => rate_limiter.on_bucket_exhausted(reset),
1241 None => debug!(
1242 "Alpaca REST rate-limit window exhausted (X-Ratelimit-Remaining: 0) without X-Ratelimit-Reset"
1243 ),
1244 }
1245}
1246
1247async fn rest_with_retry<T>(
1268 rate_limiter: &RateLimitTracker,
1269 build_request: impl FnMut() -> reqwest::RequestBuilder,
1270) -> Result<T, UnindexedClientError>
1271where
1272 T: for<'de> Deserialize<'de>,
1273{
1274 match rest_request(rate_limiter, build_request).await? {
1275 Fetched::Found(value) => Ok(value),
1276 Fetched::NotFound(message) => Err(UnindexedClientError::Api(parse_api_error(
1277 reqwest::StatusCode::NOT_FOUND,
1278 None,
1279 &message,
1280 ))),
1281 }
1282}
1283
1284async fn rest_lookup_with_retry<T>(
1287 rate_limiter: &RateLimitTracker,
1288 build_request: impl FnMut() -> reqwest::RequestBuilder,
1289) -> Result<Option<T>, UnindexedClientError>
1290where
1291 T: for<'de> Deserialize<'de>,
1292{
1293 match rest_request(rate_limiter, build_request).await? {
1294 Fetched::Found(value) => Ok(Some(value)),
1295 Fetched::NotFound(_) => Ok(None),
1296 }
1297}
1298
1299#[derive(Debug)]
1301enum Fetched<T> {
1302 Found(T),
1304 NotFound(String),
1306}
1307
1308async fn rest_request<T>(
1310 rate_limiter: &RateLimitTracker,
1311 mut build_request: impl FnMut() -> reqwest::RequestBuilder,
1312) -> Result<Fetched<T>, UnindexedClientError>
1313where
1314 T: for<'de> Deserialize<'de>,
1315{
1316 for attempt in 0..MAX_RATE_LIMIT_ATTEMPTS {
1317 rate_limiter.wait_if_blocked().await;
1318 let response = build_request()
1319 .send()
1320 .await
1321 .map_err(|e| connectivity_err(format!("Alpaca REST request failed: {e}")))?;
1322
1323 let status = response.status();
1324
1325 if status == reqwest::StatusCode::TOO_MANY_REQUESTS {
1326 let reset_delay = parse_rate_limit_delay(response.headers());
1327 rate_limiter.on_rate_limited(reset_delay);
1329
1330 if attempt + 1 < MAX_RATE_LIMIT_ATTEMPTS {
1331 warn!(
1332 attempt = attempt + 1,
1333 max_attempts = MAX_RATE_LIMIT_ATTEMPTS,
1334 "Alpaca REST rate-limited (429), retrying"
1335 );
1336 continue;
1337 }
1338
1339 warn!(
1341 max_attempts = MAX_RATE_LIMIT_ATTEMPTS,
1342 "Alpaca REST rate-limit retries exhausted"
1343 );
1344 return Err(UnindexedClientError::Api(ApiError::rate_limited(
1345 reset_delay,
1346 )));
1347 }
1348
1349 observe_rate_limit_remaining(rate_limiter, response.headers());
1350
1351 if status == reqwest::StatusCode::NO_CONTENT {
1355 return Err(UnindexedClientError::Internal(
1356 "Alpaca REST returned 204 No Content — use rest_delete_with_retry for DELETE endpoints"
1357 .to_string(),
1358 ));
1359 }
1360
1361 let bytes = response
1362 .bytes()
1363 .await
1364 .map_err(|e| connectivity_err(format!("Alpaca REST read body failed: {e}")))?;
1365
1366 if status.is_success() {
1369 return serde_json::from_slice::<T>(&bytes)
1370 .map(Fetched::Found)
1371 .map_err(|e| {
1372 UnindexedClientError::Internal(format!(
1373 "Alpaca REST JSON parse error ({status}): {e} | body: {}",
1374 String::from_utf8_lossy(&bytes)
1375 .chars()
1376 .take(200)
1377 .collect::<String>()
1378 ))
1379 });
1380 }
1381
1382 let api_err = AlpacaApiError::from_body(&bytes);
1384
1385 if status == reqwest::StatusCode::NOT_FOUND {
1386 return Ok(Fetched::NotFound(api_err.message));
1387 }
1388
1389 if status.is_client_error() {
1398 return Err(UnindexedClientError::Api(parse_api_error(
1399 status,
1400 api_err.code,
1401 &api_err.message,
1402 )));
1403 }
1404 return Err(connectivity_err(format!(
1405 "Alpaca REST error {status}: {}",
1406 api_err.message
1407 )));
1408 }
1409 unreachable!("Alpaca REST retry loop exited without returning")
1410}
1411
1412async fn rest_delete_with_retry(
1419 rate_limiter: &RateLimitTracker,
1420 mut build_request: impl FnMut() -> reqwest::RequestBuilder,
1421) -> Result<(), UnindexedOrderError> {
1422 for attempt in 0..MAX_RATE_LIMIT_ATTEMPTS {
1423 rate_limiter.wait_if_blocked().await;
1424 let response = build_request().send().await.map_err(|e| {
1425 UnindexedOrderError::Connectivity(ConnectivityError::Socket(format!(
1426 "Alpaca cancel request failed: {e}"
1427 )))
1428 })?;
1429
1430 let status = response.status();
1431
1432 if status == reqwest::StatusCode::TOO_MANY_REQUESTS {
1433 let reset_delay = parse_rate_limit_delay(response.headers());
1434 rate_limiter.on_rate_limited(reset_delay);
1436
1437 if attempt + 1 < MAX_RATE_LIMIT_ATTEMPTS {
1438 warn!(
1439 attempt = attempt + 1,
1440 max_attempts = MAX_RATE_LIMIT_ATTEMPTS,
1441 "Alpaca cancel rate-limited (429), retrying"
1442 );
1443 continue;
1444 }
1445
1446 warn!(
1448 max_attempts = MAX_RATE_LIMIT_ATTEMPTS,
1449 "Alpaca cancel rate-limit retries exhausted"
1450 );
1451 return Err(UnindexedOrderError::Rejected(ApiError::rate_limited(
1452 reset_delay,
1453 )));
1454 }
1455
1456 observe_rate_limit_remaining(rate_limiter, response.headers());
1457
1458 if status == reqwest::StatusCode::NO_CONTENT || status.is_success() {
1460 return Ok(());
1461 }
1462
1463 let bytes = response
1464 .bytes()
1465 .await
1466 .inspect_err(
1467 |e| warn!(%e, %status, "Alpaca cancel_order: failed to read error response body"),
1468 )
1469 .unwrap_or_default();
1470 let api_err = AlpacaApiError::from_body(&bytes);
1471
1472 return Err(parse_order_error(status, api_err.code, &api_err.message));
1473 }
1474 unreachable!("Alpaca cancel retry loop exited without returning")
1475}
1476
1477impl ExecutionClient for AlpacaClient {
1482 const EXCHANGE: ExchangeId = ExchangeId::AlpacaBroker;
1483
1484 const SUPPORTED_KINDS: &'static [InstrumentKindDiscriminant] = &[
1491 InstrumentKindDiscriminant::Spot,
1492 InstrumentKindDiscriminant::Option,
1493 ];
1494 type Config = AlpacaConfig;
1495 type AccountStream = BoxStream<'static, UnindexedAccountEvent>;
1496
1497 fn new(config: Self::Config) -> Self {
1502 let http = Self::build_http(&config);
1503 let orders_url = format!("{}/v2/orders", config.rest_base_url());
1504 Self {
1505 config: Arc::new(config),
1506 http,
1507 rate_limiter: Arc::new(RateLimitTracker::new()),
1508 orders_url,
1509 known_live: KnownLiveOrders::shared(ExchangeId::AlpacaBroker),
1510 }
1511 }
1512
1513 async fn account_snapshot(
1560 &self,
1561 assets: &[AssetNameExchange],
1562 instruments: &[InstrumentNameExchange],
1563 ) -> Result<UnindexedAccountSnapshot, UnindexedClientError> {
1564 let base = self.base_url();
1565 let http = self.http.clone();
1566 let rl = &self.rate_limiter;
1567
1568 let wants_usd = assets.is_empty() || assets.iter().any(is_usd);
1569 let wants_non_usd = assets.is_empty() || assets.iter().any(|a| !is_usd(a));
1570
1571 let account_url = format!("{base}/v2/account");
1573 let positions_url = format!("{base}/v2/positions");
1574 let (account, positions): (Option<AlpacaAccount>, Vec<AlpacaPosition>) = tokio::try_join!(
1575 async {
1576 if wants_usd {
1577 rest_with_retry(rl, || http.get(&account_url))
1578 .await
1579 .map(Some)
1580 } else {
1581 Ok(None)
1582 }
1583 },
1584 rest_with_retry(rl, || http.get(&positions_url)),
1585 )?;
1586
1587 let mut balances = account
1588 .map(|account| convert_account_to_balances(&account, assets))
1589 .unwrap_or_default();
1590 if wants_non_usd {
1591 balances.extend(convert_positions_to_balances(&positions, assets));
1592 }
1593
1594 let converted_positions = convert_positions(&positions, Utc::now())?;
1597 let open_orders = fetch_raw_open_orders(&http, rl, base, instruments).await?;
1598
1599 let instrument_snapshots =
1601 build_instrument_snapshots(open_orders, converted_positions, instruments);
1602 {
1603 let mut known = self.known_live.lock();
1604 for order in instrument_snapshots
1605 .iter()
1606 .flat_map(|snapshot| &snapshot.orders)
1607 {
1608 if let OrderState::Active(ActiveOrderState::Open(open)) = &order.state {
1609 known.live(&order.key, order.quantity, order.kind, open);
1610 }
1611 }
1612 }
1613
1614 Ok(AccountSnapshot::new(
1615 ExchangeId::AlpacaBroker,
1616 balances,
1617 instrument_snapshots,
1618 ))
1619 }
1620
1621 async fn account_stream(
1747 &self,
1748 _assets: &[AssetNameExchange], instruments: &[InstrumentNameExchange],
1753 ) -> Result<Self::AccountStream, UnindexedClientError> {
1754 let initial_ws = connect_and_subscribe(&self.config).await?;
1757
1758 let (tx, rx) = mpsc::unbounded_channel::<UnindexedAccountEvent>();
1763 let dedup = new_dedup_cache();
1764 let config = self.config.clone();
1765 let http = self.http.clone();
1766 let rate_limiter = self.rate_limiter.clone();
1767 let known = self.known_live.clone();
1768 let instruments = instruments.to_vec();
1769
1770 let cm_handle = tokio::spawn(connection_manager(
1771 tx,
1772 dedup,
1773 config,
1774 http,
1775 rate_limiter,
1776 known,
1777 instruments,
1778 Some(initial_ws),
1779 ));
1780
1781 let rx_stream = tokio_stream::wrappers::UnboundedReceiverStream::new(rx);
1782 let guarded = GracefulShutdownStream::new(rx_stream, cm_handle);
1783 Ok(futures::StreamExt::boxed(guarded))
1784 }
1785
1786 async fn cancel_order(
1805 &self,
1806 request: OrderRequestCancel<ExchangeId, &InstrumentNameExchange>,
1807 ) -> UnindexedOrderResponseCancel {
1808 let key = crate::order::OrderKey {
1809 exchange: request.key.exchange,
1810 instrument: request.key.instrument.clone(),
1811 strategy: request.key.strategy.clone(),
1812 cid: request.key.cid.clone(),
1813 };
1814
1815 let order_id: SmolStr = match request.state.id.as_ref().and_then(VenueOrderId::assigned) {
1819 Some(id) => id.0.clone(),
1820 None => {
1821 warn!(
1822 instrument = %key.instrument,
1823 "Alpaca cancel_order: no exchange order ID available (clientOrderId-only cancel not supported)"
1824 );
1825 return crate::order::request::OrderResponseCancel {
1826 key,
1827 state: Err(UnindexedOrderError::Rejected(ApiError::OrderRejected(
1828 "exchange order ID required for cancel (fetch_open_orders to resolve)"
1829 .into(),
1830 ))),
1831 };
1832 }
1833 };
1834
1835 let base = self.base_url();
1836 let http = self.http.clone();
1837 let url = format!("{base}/v2/orders/{order_id}");
1838
1839 match rest_delete_with_retry(&self.rate_limiter, || http.delete(&url)).await {
1840 Ok(()) => {
1841 let exchange_order_id = OrderId(order_id);
1842 crate::order::request::OrderResponseCancel {
1845 key,
1846 state: Ok(Cancelled::new(exchange_order_id, Utc::now(), None)),
1847 }
1848 }
1849 Err(e) => crate::order::request::OrderResponseCancel { key, state: Err(e) },
1850 }
1851 }
1852
1853 async fn open_order(
1874 &self,
1875 request: OrderRequestOpen<ExchangeId, &InstrumentNameExchange>,
1876 ) -> Order<ExchangeId, InstrumentNameExchange, UnindexedOrderState> {
1877 let side = request.state.side;
1878 let reduce_only = request.state.reduce_only;
1879 self.open_order_inner(request, map_position_intent(side, reduce_only))
1880 .await
1881 }
1882
1883 async fn fetch_balances(
1891 &self,
1892 assets: &[AssetNameExchange],
1893 ) -> Result<Vec<AssetBalance<AssetNameExchange>>, UnindexedClientError> {
1894 let base = self.base_url();
1895 let http = self.http.clone();
1896 let mut result = Vec::new();
1897
1898 let wants_usd = assets.is_empty() || assets.iter().any(is_usd);
1901 if wants_usd {
1902 let account_url = format!("{base}/v2/account");
1904 let account: AlpacaAccount =
1905 rest_with_retry(&self.rate_limiter, || http.get(&account_url)).await?;
1906 result.extend(convert_account_to_balances(&account, assets));
1907 }
1908
1909 let wants_non_usd = assets.is_empty() || assets.iter().any(|a| !is_usd(a));
1911 if wants_non_usd {
1912 let positions_url = format!("{base}/v2/positions");
1914 let positions: Vec<AlpacaPosition> =
1915 rest_with_retry(&self.rate_limiter, || http.get(&positions_url)).await?;
1916 result.extend(convert_positions_to_balances(&positions, assets));
1917 }
1918
1919 Ok(result)
1920 }
1921
1922 async fn fetch_open_orders(
1923 &self,
1924 instruments: &[InstrumentNameExchange],
1925 ) -> Result<Vec<Order<ExchangeId, InstrumentNameExchange, Open>>, UnindexedClientError> {
1926 let base = self.base_url();
1927 let http = self.http.clone();
1928
1929 let open_orders =
1930 fetch_raw_open_orders(&http, &self.rate_limiter, base, instruments).await?;
1931
1932 let result: Vec<_> = open_orders
1933 .into_iter()
1934 .filter_map(|o| convert_open_order(&o))
1935 .collect();
1936 self.remember_live(&result);
1937 Ok(result)
1938 }
1939
1940 async fn fetch_trades(
1953 &self,
1954 start: DateTime<Utc>,
1955 end: DateTime<Utc>,
1956 instruments: &[InstrumentNameExchange],
1957 ) -> Result<TradesRead<AssetNameExchange, InstrumentNameExchange>, UnindexedClientError> {
1958 if start > end || end < DateTime::UNIX_EPOCH {
1960 return Ok(TradesRead::complete(Vec::new()));
1961 }
1962 let page = paginate_activities::<AlpacaActivity>(
1963 &self.http,
1964 &self.rate_limiter,
1965 self.base_url(),
1966 FILL_ACTIVITIES,
1967 start,
1968 Some(end),
1969 )
1970 .await?;
1971
1972 let Ok(resume) = span_resume(page.resume, start, end) else {
1973 error!(
1974 %start,
1975 fills_read = page.activities.len(),
1976 "Alpaca fetch_trades: a full read did not advance past the span's first millisecond"
1977 );
1978 return Err(UnindexedClientError::Truncated {
1979 fills_read: page.activities.len(),
1980 });
1981 };
1982
1983 let instrument_set: fnv::FnvHashSet<&str> =
1986 instruments.iter().map(|i| i.name().as_str()).collect();
1987 let trades = page
1988 .activities
1989 .iter()
1990 .filter(|a| instrument_set.is_empty() || instrument_set.contains(a.symbol.as_str()))
1991 .filter(|a| {
1994 parse_timestamp(&a.transaction_time)
1995 .is_none_or(|time| (start..=end).contains(&time))
1996 })
1997 .filter_map(convert_activity_to_trade)
1998 .collect();
1999
2000 Ok(TradesRead::new(trades, resume))
2001 }
2002}
2003
2004impl AlpacaClient {
2009 pub async fn fetch_cash_flows(
2059 &self,
2060 start: DateTime<Utc>,
2061 end: DateTime<Utc>,
2062 ) -> Result<CashFlowsRead<AssetNameExchange, InstrumentNameExchange>, UnindexedClientError>
2063 {
2064 if start > end || end < DateTime::UNIX_EPOCH {
2066 return Ok(CashFlowsRead::complete(Vec::new()));
2067 }
2068 let page = paginate_activities::<AlpacaNonTradeActivity>(
2069 &self.http,
2070 &self.rate_limiter,
2071 self.base_url(),
2072 CASH_FLOW_ACTIVITIES,
2073 start,
2074 Some(end),
2075 )
2076 .await?;
2077 let Ok(resume) = span_resume(page.resume, start, end) else {
2078 error!(
2079 %start,
2080 activities_read = page.activities.len(),
2081 "Alpaca fetch_cash_flows: a full read did not advance past the span's first \
2082 millisecond"
2083 );
2084 return Err(UnindexedClientError::Internal(format!(
2085 "Alpaca cash-flow read of {} activities did not advance past {start}",
2086 page.activities.len()
2087 )));
2088 };
2089
2090 let flows = page
2091 .activities
2092 .iter()
2093 .filter_map(convert_cash_flow)
2094 .filter(|flow| (start..=end).contains(&flow.time_exchange))
2097 .collect();
2098 Ok(CashFlowsRead::new(flows, resume))
2099 }
2100
2101 pub async fn fetch_option_events(
2169 &self,
2170 start: DateTime<Utc>,
2171 end: DateTime<Utc>,
2172 ) -> Result<TradesRead<AssetNameExchange, InstrumentNameExchange>, UnindexedClientError> {
2173 if start > end || end < DateTime::UNIX_EPOCH {
2175 return Ok(TradesRead::complete(Vec::new()));
2176 }
2177 let read_start = start.date_naive().and_time(NaiveTime::MIN).and_utc();
2179 let read_end = end.date_naive().and_time(LAST_MILLISECOND).and_utc();
2180 let page = paginate_activities::<AlpacaNonTradeActivity>(
2181 &self.http,
2182 &self.rate_limiter,
2183 self.base_url(),
2184 OPTION_EVENT_ACTIVITIES,
2185 read_start,
2186 Some(read_end),
2187 )
2188 .await?;
2189 let Ok(resume) = span_resume(page.resume, start, end) else {
2190 error!(
2191 %start,
2192 activities_read = page.activities.len(),
2193 "Alpaca fetch_option_events: a full read did not advance past the span's start"
2194 );
2195 return Err(UnindexedClientError::Internal(format!(
2196 "Alpaca option-event read of {} activities did not advance past {start}",
2197 page.activities.len()
2198 )));
2199 };
2200
2201 Ok(TradesRead::new(
2202 convert_option_events(&page.activities, start, end),
2203 resume,
2204 ))
2205 }
2206
2207 pub async fn open_order_with_intent(
2220 &self,
2221 request: OrderRequestOpen<ExchangeId, &InstrumentNameExchange>,
2222 intent: AlpacaPositionIntent,
2223 ) -> Order<ExchangeId, InstrumentNameExchange, UnindexedOrderState> {
2224 self.open_order_inner(request, intent).await
2225 }
2226
2227 pub async fn open_bracket_order(
2260 &self,
2261 request: AlpacaBracketOrderRequest,
2262 ) -> AlpacaBracketOrderResult {
2263 let order_key = crate::order::OrderKey::new(
2264 ExchangeId::AlpacaBroker,
2265 request.instrument.clone(),
2266 request.strategy.clone(),
2267 request.cid.clone(),
2268 );
2269
2270 let tif_str = match request.time_in_force {
2272 TimeInForce::GoodUntilEndOfDay => "day",
2273 TimeInForce::GoodUntilCancelled { post_only } => {
2274 if post_only {
2275 return AlpacaBracketOrderResult {
2276 parent: Order {
2277 key: order_key,
2278 side: request.side,
2279 price: Some(request.entry_price),
2280 quantity: request.quantity,
2281 kind: OrderKind::Limit,
2282 time_in_force: request.time_in_force,
2283 state: OrderState::inactive(OrderError::Rejected(
2284 ApiError::OrderRejected(
2285 "Alpaca does not support post_only for bracket orders"
2286 .to_string(),
2287 ),
2288 )),
2289 },
2290 };
2291 }
2292 "gtc"
2293 }
2294 other => {
2295 return AlpacaBracketOrderResult {
2296 parent: Order {
2297 key: order_key,
2298 side: request.side,
2299 price: Some(request.entry_price),
2300 quantity: request.quantity,
2301 kind: OrderKind::Limit,
2302 time_in_force: request.time_in_force,
2303 state: OrderState::inactive(OrderError::Rejected(ApiError::OrderRejected(
2304 format!(
2305 "Alpaca bracket orders only support Day or GTC time_in_force, got {:?}",
2306 other
2307 ),
2308 ))),
2309 },
2310 };
2311 }
2312 };
2313
2314 let price_ordering_ok = match request.side {
2317 Side::Buy => {
2318 request.stop_loss_price < request.entry_price
2319 && request.entry_price < request.take_profit_price
2320 }
2321 Side::Sell => {
2322 request.take_profit_price < request.entry_price
2323 && request.entry_price < request.stop_loss_price
2324 }
2325 };
2326 if !price_ordering_ok {
2327 return AlpacaBracketOrderResult {
2328 parent: Order {
2329 key: order_key,
2330 side: request.side,
2331 price: Some(request.entry_price),
2332 quantity: request.quantity,
2333 kind: OrderKind::Limit,
2334 time_in_force: request.time_in_force,
2335 state: OrderState::inactive(OrderError::Rejected(ApiError::OrderRejected(
2336 format!(
2337 "Invalid bracket price ordering for {:?} side: entry={}, take_profit={}, stop_loss={}",
2338 request.side,
2339 request.entry_price,
2340 request.take_profit_price,
2341 request.stop_loss_price,
2342 ),
2343 ))),
2344 },
2345 };
2346 }
2347
2348 if let Some(sl_limit) = request.stop_loss_limit_price {
2352 let sl_limit_ok = match request.side {
2353 Side::Buy => sl_limit <= request.stop_loss_price,
2354 Side::Sell => sl_limit >= request.stop_loss_price,
2355 };
2356 if !sl_limit_ok {
2357 return AlpacaBracketOrderResult {
2358 parent: Order {
2359 key: order_key,
2360 side: request.side,
2361 price: Some(request.entry_price),
2362 quantity: request.quantity,
2363 kind: OrderKind::Limit,
2364 time_in_force: request.time_in_force,
2365 state: OrderState::inactive(OrderError::Rejected(ApiError::OrderRejected(
2366 format!(
2367 "Invalid stop-loss limit price for {:?} bracket: \
2368 stop_loss_price={}, stop_loss_limit_price={}",
2369 request.side, request.stop_loss_price, sl_limit,
2370 ),
2371 ))),
2372 },
2373 };
2374 }
2375 }
2376
2377 let body = AlpacaOrderRequest {
2379 symbol: request.instrument.name().as_str(),
2380 qty: request.quantity.to_string(),
2381 side: map_side(request.side),
2382 order_type: "limit",
2383 time_in_force: tif_str,
2384 limit_price: Some(request.entry_price.to_string()),
2385 stop_price: None,
2386 trail_percent: None,
2387 trail_price: None,
2388 client_order_id: Some(request.cid.0.as_str()),
2389 position_intent: if is_options_or_equity_symbol(request.instrument.name().as_str()) {
2390 Some(map_position_intent(request.side, false))
2391 } else {
2392 None
2393 },
2394 order_class: Some("bracket"),
2395 take_profit: Some(TakeProfitParams {
2396 limit_price: request.take_profit_price.to_string(),
2397 }),
2398 stop_loss: Some(StopLossParams {
2399 stop_price: request.stop_loss_price.to_string(),
2400 limit_price: request.stop_loss_limit_price.map(|p| p.to_string()),
2401 }),
2402 };
2403
2404 let http = self.http.clone();
2405 let rl = &self.rate_limiter;
2406
2407 let result: Result<AlpacaOrderResponse, UnindexedClientError> =
2408 rest_with_retry(rl, || http.post(&self.orders_url).json(&body)).await;
2409
2410 match result {
2411 Ok(resp) => {
2412 let state = placed_order_state(&resp, &order_key.instrument, request.quantity);
2413 self.known_live.lock().placed(
2414 &order_key,
2415 request.quantity,
2416 OrderKind::Limit,
2417 &state,
2418 );
2419
2420 AlpacaBracketOrderResult {
2421 parent: Order {
2422 key: order_key,
2423 side: request.side,
2424 price: Some(request.entry_price),
2425 quantity: request.quantity,
2426 kind: OrderKind::Limit,
2427 time_in_force: request.time_in_force,
2428 state,
2429 },
2430 }
2431 }
2432 Err(e) => {
2433 let order_err = order_post_error(e);
2434 AlpacaBracketOrderResult {
2435 parent: Order {
2436 key: order_key,
2437 side: request.side,
2438 price: Some(request.entry_price),
2439 quantity: request.quantity,
2440 kind: OrderKind::Limit,
2441 time_in_force: request.time_in_force,
2442 state: OrderState::inactive(order_err),
2443 },
2444 }
2445 }
2446 }
2447 }
2448
2449 async fn open_order_inner(
2450 &self,
2451 request: OrderRequestOpen<ExchangeId, &InstrumentNameExchange>,
2452 intent: AlpacaPositionIntent,
2453 ) -> Order<ExchangeId, InstrumentNameExchange, UnindexedOrderState> {
2454 let instrument = request.key.instrument.clone();
2455 let side = request.state.side;
2456 let price = request.state.price;
2457 let quantity = request.state.quantity;
2458 let kind = request.state.kind;
2459 let time_in_force = request.state.time_in_force;
2460 let cid = request.key.cid.clone();
2461
2462 let order_key = crate::order::OrderKey::new(
2463 ExchangeId::AlpacaBroker,
2464 instrument.clone(),
2465 request.key.strategy.clone(),
2466 cid.clone(),
2467 );
2468
2469 let tif_str = match map_time_in_force(time_in_force) {
2471 Ok(s) => s,
2472 Err(msg) => {
2473 return Order {
2474 key: order_key,
2475 side,
2476 price,
2477 quantity,
2478 kind,
2479 time_in_force,
2480 state: OrderState::inactive(OrderError::Rejected(ApiError::OrderRejected(
2481 msg.to_string(),
2482 ))),
2483 };
2484 }
2485 };
2486
2487 let order_type_str = match map_order_kind(kind) {
2489 Some(s) => s,
2490 None => {
2491 return Order {
2492 key: order_key,
2493 side,
2494 price,
2495 quantity,
2496 kind,
2497 time_in_force,
2498 state: OrderState::inactive(OrderError::UnsupportedOrderType(format!(
2499 "Alpaca connector does not yet support OrderKind::{kind:?}"
2500 ))),
2501 };
2502 }
2503 };
2504
2505 if let OrderKind::TrailingStop {
2507 offset_type: TrailingOffsetType::BasisPoints,
2508 ..
2509 } = kind
2510 {
2511 return Order {
2512 key: order_key,
2513 side,
2514 price,
2515 quantity,
2516 kind,
2517 time_in_force,
2518 state: OrderState::inactive(OrderError::UnsupportedOrderType(
2519 "Alpaca does not support TrailingOffsetType::BasisPoints; \
2520 use Percentage or Absolute"
2521 .to_string(),
2522 )),
2523 };
2524 }
2525
2526 if matches!(kind, OrderKind::StopLimit { .. }) && price.is_none() {
2529 return Order {
2530 key: order_key,
2531 side,
2532 price,
2533 quantity,
2534 kind,
2535 time_in_force,
2536 state: OrderState::inactive(OrderError::Rejected(ApiError::OrderRejected(
2537 "StopLimit order requires Order.price (the limit price) to be set".to_string(),
2538 ))),
2539 };
2540 }
2541
2542 let (stop_price, trail_percent, trail_price) = match kind {
2544 OrderKind::Stop { trigger_price } | OrderKind::StopLimit { trigger_price } => {
2545 (Some(trigger_price.to_string()), None, None)
2546 }
2547 OrderKind::TrailingStop {
2548 offset,
2549 offset_type,
2550 ..
2551 } => match offset_type {
2552 TrailingOffsetType::Percentage => (None, Some(offset.to_string()), None),
2553 TrailingOffsetType::Absolute => (None, None, Some(offset.to_string())),
2554 TrailingOffsetType::BasisPoints => unreachable!("validated above"),
2555 },
2556 OrderKind::Market
2558 | OrderKind::Limit
2559 | OrderKind::TakeProfit { .. }
2560 | OrderKind::TakeProfitLimit { .. }
2561 | OrderKind::TrailingStopLimit { .. }
2562 | OrderKind::TrailingStopFixedLimit { .. } => (None, None, None),
2563 };
2564
2565 let body = AlpacaOrderRequest {
2566 symbol: instrument.name().as_str(),
2567 qty: quantity.to_string(),
2568 side: map_side(side),
2569 order_type: order_type_str,
2570 time_in_force: tif_str,
2571 limit_price: match kind {
2572 OrderKind::Limit | OrderKind::StopLimit { .. } => price.map(|p| p.to_string()),
2573 _ => None,
2574 },
2575 stop_price,
2576 trail_percent,
2577 trail_price,
2578 client_order_id: Some(cid.0.as_str()),
2579 position_intent: if is_options_or_equity_symbol(instrument.name().as_str()) {
2585 Some(intent)
2586 } else {
2587 None
2588 },
2589 order_class: None,
2591 take_profit: None,
2592 stop_loss: None,
2593 };
2594
2595 let http = self.http.clone();
2596 let rl = &self.rate_limiter;
2597
2598 let result: Result<AlpacaOrderResponse, UnindexedClientError> =
2599 rest_with_retry(rl, || http.post(&self.orders_url).json(&body)).await;
2600
2601 match result {
2602 Ok(resp) => {
2603 let state = placed_order_state(&resp, &order_key.instrument, quantity);
2604 self.known_live
2605 .lock()
2606 .placed(&order_key, quantity, kind, &state);
2607
2608 Order {
2609 key: order_key,
2610 side,
2611 price,
2612 quantity,
2613 kind,
2614 time_in_force,
2615 state,
2616 }
2617 }
2618 Err(e) => {
2619 let order_err = order_post_error(e);
2620 Order {
2621 key: order_key,
2622 side,
2623 price,
2624 quantity,
2625 kind,
2626 time_in_force,
2627 state: OrderState::inactive(order_err),
2628 }
2629 }
2630 }
2631 }
2632}
2633
2634const MAX_OPEN_ORDERS: usize = 500;
2642
2643async fn fetch_raw_open_orders(
2651 http: &reqwest::Client,
2652 rate_limiter: &RateLimitTracker,
2653 base: &str,
2654 instruments: &[InstrumentNameExchange],
2655) -> Result<Vec<AlpacaOrderResponse>, UnindexedClientError> {
2656 let orders: Vec<AlpacaOrderResponse> = if instruments.is_empty() {
2657 rest_with_retry(rate_limiter, || {
2658 http.get(format!("{base}/v2/orders"))
2659 .query(&[("status", "open"), ("limit", "500")])
2660 })
2661 .await?
2662 } else {
2663 let symbols = instruments.iter().map(|i| i.name().as_str()).join(",");
2664 rest_with_retry(rate_limiter, || {
2665 http.get(format!("{base}/v2/orders")).query(&[
2666 ("status", "open"),
2667 ("limit", "500"),
2668 ("symbols", &symbols),
2669 ])
2670 })
2671 .await?
2672 };
2673 if orders.len() == MAX_OPEN_ORDERS {
2674 warn!(
2675 limit = MAX_OPEN_ORDERS,
2676 "Alpaca fetch_raw_open_orders: received exactly {MAX_OPEN_ORDERS} results — \
2677 response is likely truncated"
2678 );
2679 return Err(UnindexedClientError::TruncatedSnapshot {
2680 limit: MAX_OPEN_ORDERS,
2681 });
2682 }
2683 Ok(orders)
2684}
2685
2686const MAX_ACTIVITY_PAGES: usize = 50;
2695
2696trait PagedActivity: DeserializeOwned {
2698 fn id(&self) -> &str;
2700 fn time(&self) -> Option<DateTime<Utc>>;
2703}
2704
2705impl PagedActivity for AlpacaActivity {
2706 fn id(&self) -> &str {
2707 &self.id
2708 }
2709
2710 fn time(&self) -> Option<DateTime<Utc>> {
2711 parse_timestamp(&self.transaction_time)
2712 }
2713}
2714
2715impl PagedActivity for AlpacaNonTradeActivity {
2716 fn id(&self) -> &str {
2717 &self.id
2718 }
2719
2720 fn time(&self) -> Option<DateTime<Utc>> {
2721 self.created_at.as_deref().and_then(parse_timestamp)
2722 }
2723}
2724
2725const FILL_ACTIVITIES: &str = "FILL";
2727
2728const CASH_FLOW_ACTIVITIES: &str = "INT,FEE,PTC,PTR";
2730
2731const OPTION_EVENT_ACTIVITIES: &str = "OPASN,OPEXC,OPEXP,OPTRD";
2733
2734const LAST_MILLISECOND: NaiveTime = match NaiveTime::from_hms_milli_opt(23, 59, 59, 999) {
2736 Some(time) => time,
2737 None => panic!("23:59:59.999 is a time of day"),
2738};
2739
2740fn span_resume(
2747 resume: Option<DateTime<Utc>>,
2748 start: DateTime<Utc>,
2749 end: DateTime<Utc>,
2750) -> Result<Option<DateTime<Utc>>, ()> {
2751 let resume = resume.filter(|resume| *resume <= end);
2752 if resume.is_some_and(|resume| resume <= floor_millis(start)) {
2753 return Err(());
2754 }
2755 Ok(resume)
2756}
2757
2758struct ActivityPage<A> {
2760 activities: Vec<A>,
2762 resume: Option<DateTime<Utc>>,
2765}
2766
2767fn floor_millis(time: DateTime<Utc>) -> DateTime<Utc> {
2769 DateTime::from_timestamp_millis(time.timestamp_millis()).unwrap_or(time)
2770}
2771
2772const PAGE_SIZE_STR: &str = "100"; const _: () = assert!(
2802 ALPACA_MAX_ACTIVITIES == 100,
2803 "PAGE_SIZE_STR must be updated to match ALPACA_MAX_ACTIVITIES",
2804);
2805
2806async fn paginate_activities<A: PagedActivity>(
2807 http: &reqwest::Client,
2808 rate_limiter: &RateLimitTracker,
2809 base: &str,
2810 types: &str,
2811 start: DateTime<Utc>,
2812 end: Option<DateTime<Utc>>,
2813) -> Result<ActivityPage<A>, UnindexedClientError> {
2814 let after =
2817 floor_millis(start.max(DateTime::UNIX_EPOCH)).to_rfc3339_opts(SecondsFormat::Millis, true);
2818 let until = end.filter(|end| *end < Utc::now()).map(|end| {
2819 (floor_millis(end) + TimeDelta::milliseconds(1))
2820 .to_rfc3339_opts(SecondsFormat::Millis, true)
2821 });
2822 let mut all = Vec::with_capacity(ALPACA_MAX_ACTIVITIES);
2823 let mut page_token: Option<String> = None;
2824 let mut pages = 0usize;
2825 let mut truncated = false;
2826
2827 loop {
2828 if pages >= MAX_ACTIVITY_PAGES {
2829 truncated = true;
2830 break;
2831 }
2832 pages += 1;
2833 let page_token_ref = page_token.as_deref();
2835 let activities: Vec<A> = rest_with_retry(rate_limiter, || {
2836 let mut req = http.get(format!("{base}/v2/account/activities")).query(&[
2837 ("activity_types", types),
2838 ("after", after.as_str()),
2839 ("page_size", PAGE_SIZE_STR),
2840 ("direction", "asc"),
2841 ]);
2842 if let Some(until) = until.as_deref() {
2843 req = req.query(&[("until", until)]);
2844 }
2845 if let Some(token) = page_token_ref {
2846 req = req.query(&[("page_token", token)]);
2847 }
2848 req
2849 })
2850 .await?;
2851
2852 let page_len = activities.len();
2853 let page_token_candidate = activities.last().map(|a| a.id().to_owned());
2863 all.extend(activities);
2864
2865 if page_len < ALPACA_MAX_ACTIVITIES {
2866 break;
2867 }
2868 match page_token_candidate {
2869 Some(token) if !token.is_empty() => {
2870 debug!("Alpaca paginate_activities: fetching next page ({page_len} results)");
2871 page_token = Some(token);
2872 }
2873 _ => break,
2877 }
2878 }
2879
2880 let resume = truncated.then(|| {
2883 floor_millis(
2884 all.iter()
2885 .rev()
2886 .find_map(PagedActivity::time)
2887 .unwrap_or(start),
2888 )
2889 });
2890 Ok(ActivityPage {
2891 activities: all,
2892 resume,
2893 })
2894}
2895
2896#[allow(clippy::cognitive_complexity)] async fn connection_manager(
2912 tx: mpsc::UnboundedSender<UnindexedAccountEvent>,
2913 dedup: SharedDedupCache,
2914 config: Arc<AlpacaConfig>,
2915 http: reqwest::Client,
2916 rate_limiter: Arc<RateLimitTracker>,
2917 known: SharedKnownLiveOrders,
2918 instruments: Vec<InstrumentNameExchange>,
2919 initial_ws: Option<WebSocket>,
2920) {
2921 let mut backoff = ExponentialBackoff::new();
2922 let mut disconnect_time: Option<DateTime<Utc>> = None;
2923 let mut unchecked = UncheckedOrders::default();
2926 let mut current_ws = initial_ws;
2927
2928 'outer: loop {
2929 let mut ws = match current_ws.take() {
2931 Some(ws) => ws,
2932 None => match connect_and_subscribe(&config).await {
2933 Ok(ws) => ws,
2934 Err(e) => {
2935 error!(%e, "Alpaca WS connect/subscribe failed");
2936 if !backoff.wait().await {
2937 error!("Alpaca max reconnect attempts exhausted");
2938 emit_stream_terminated(
2940 &tx,
2941 ExchangeId::AlpacaBroker,
2942 StreamTerminationReason::ReconnectBudgetExhausted {
2943 attempts: backoff.attempts(),
2944 last_error: e.to_string(),
2945 },
2946 );
2947 break;
2948 }
2949 continue;
2950 }
2951 },
2952 };
2953 info!("Alpaca account_stream connected and subscribed");
2954 backoff.reset();
2965
2966 if let Some(dt) = disconnect_time.take() {
2971 recover_fills_or_report(
2972 &http,
2973 &rate_limiter,
2974 &instruments,
2975 config.rest_base_url(),
2976 dt,
2977 Duration::from_secs(FILL_RECOVERY_TIMEOUT_SECS),
2978 &tx,
2979 &dedup,
2980 &known,
2981 )
2982 .await;
2983 let held = {
2985 let known = known.lock();
2986 if instruments.is_empty() {
2987 known.instruments()
2988 } else {
2989 known.instruments_among(&instruments)
2990 }
2991 };
2992 unchecked.open(held);
2993 }
2994
2995 let mut last_message_time = Utc::now();
3008 let heartbeat = tokio::time::sleep(Duration::from_secs(HEARTBEAT_TIMEOUT_SECS));
3009 tokio::pin!(heartbeat);
3010
3011 let order_checks =
3016 run_order_checks(&http, &rate_limiter, &config, &known, &mut unchecked, &tx);
3017 tokio::pin!(order_checks);
3018
3019 macro_rules! reset_heartbeat {
3024 () => {
3025 heartbeat.as_mut().reset(
3026 tokio::time::Instant::now() + Duration::from_secs(HEARTBEAT_TIMEOUT_SECS),
3027 );
3028 last_message_time = Utc::now();
3029 };
3030 }
3031 enum DisconnectReason {
3034 ServerClose,
3035 Error(String),
3036 StreamEnded,
3037 HeartbeatTimeout,
3038 }
3039 let reason = loop {
3040 tokio::select! {
3041 msg = ws.next() => {
3042 match msg {
3043 Some(Ok(WsMessage::Ping(_))) => {
3044 reset_heartbeat!();
3047 }
3048 Some(Ok(WsMessage::Text(text))) => {
3049 process_ws_text(text.as_str(), &tx, &dedup, &known, &mut backoff);
3050 reset_heartbeat!();
3051 }
3052 Some(Ok(WsMessage::Binary(bytes))) => {
3053 match std::str::from_utf8(&bytes) {
3055 Ok(text) => {
3056 process_ws_text(text, &tx, &dedup, &known, &mut backoff);
3057 reset_heartbeat!();
3061 }
3062 Err(e) => warn!(%e, "Alpaca WS binary frame: not valid UTF-8"),
3063 }
3064 }
3065 Some(Ok(WsMessage::Close(frame))) => {
3066 warn!(frame = ?frame, "Alpaca WS closed by server");
3067 break DisconnectReason::ServerClose;
3068 }
3069 Some(Ok(_)) => {} Some(Err(e)) => {
3071 warn!(%e, "Alpaca WS error, reconnecting");
3072 break DisconnectReason::Error(e.to_string());
3073 }
3074 None => {
3075 warn!("Alpaca WS stream ended, reconnecting");
3076 break DisconnectReason::StreamEnded;
3077 }
3078 }
3079 }
3080 () = &mut order_checks => {}
3081 _ = &mut heartbeat => {
3082 warn!(
3083 timeout_secs = HEARTBEAT_TIMEOUT_SECS,
3084 "Alpaca heartbeat timeout, reconnecting"
3085 );
3086 break DisconnectReason::HeartbeatTimeout;
3089 }
3090 _ = tx.closed() => {
3091 debug!("Alpaca account_stream consumer dropped, terminating");
3092 let _ = tokio::time::timeout(
3093 Duration::from_secs(WS_CLOSE_TIMEOUT_SECS),
3094 ws.close(None),
3095 ).await;
3096 break 'outer;
3099 }
3100 }
3101 };
3102
3103 disconnect_time =
3108 Some(last_message_time - chrono::Duration::milliseconds(SIGNAL_RECOVERY_LOOKBACK_MS));
3109
3110 let _ =
3112 tokio::time::timeout(Duration::from_secs(WS_CLOSE_TIMEOUT_SECS), ws.close(None)).await;
3113
3114 if tx.is_closed() {
3115 break;
3118 }
3119 if !backoff.wait().await {
3120 error!("Alpaca max reconnect attempts exhausted, stream terminating");
3121 let last_error = match reason {
3124 DisconnectReason::HeartbeatTimeout => {
3125 format!("heartbeat timeout ({HEARTBEAT_TIMEOUT_SECS}s)")
3126 }
3127 DisconnectReason::ServerClose => "WebSocket closed by server".to_string(),
3128 DisconnectReason::Error(e) => e,
3129 DisconnectReason::StreamEnded => "WebSocket stream ended".to_string(),
3130 };
3131 emit_stream_terminated(
3132 &tx,
3133 ExchangeId::AlpacaBroker,
3134 StreamTerminationReason::ReconnectBudgetExhausted {
3135 attempts: backoff.attempts(),
3136 last_error,
3137 },
3138 );
3139 break;
3140 }
3141 }
3142}
3143
3144#[derive(Debug)]
3149enum HandshakeError {
3150 Transport(String),
3153 Auth(String),
3156}
3157
3158async fn connect_and_subscribe(config: &AlpacaConfig) -> Result<WebSocket, UnindexedClientError> {
3162 let url = config.ws_url();
3163 debug!(%url, "Alpaca: connecting to WebSocket");
3164
3165 let mut ws = rustrade_integration::protocol::websocket::connect(url)
3166 .await
3167 .map_err(|e| {
3168 UnindexedClientError::Connectivity(ConnectivityError::Socket(format!(
3169 "WS connect: {e}"
3170 )))
3171 })?;
3172
3173 let result = tokio::time::timeout(
3175 Duration::from_secs(WS_HANDSHAKE_TIMEOUT_SECS),
3176 ws_handshake(&mut ws, config),
3177 )
3178 .await;
3179
3180 match result {
3181 Ok(Ok(())) => Ok(ws),
3182 Ok(Err(HandshakeError::Transport(e))) => {
3183 let _ = ws.close(None).await;
3184 Err(UnindexedClientError::Connectivity(
3185 ConnectivityError::Socket(e),
3186 ))
3187 }
3188 Ok(Err(HandshakeError::Auth(e))) => {
3189 let _ = ws.close(None).await;
3190 Err(UnindexedClientError::Api(ApiError::Unauthenticated(e)))
3191 }
3192 Err(_) => {
3193 let _ = ws.close(None).await;
3194 Err(UnindexedClientError::Connectivity(
3195 ConnectivityError::Timeout,
3196 ))
3197 }
3198 }
3199}
3200
3201async fn ws_handshake(ws: &mut WebSocket, config: &AlpacaConfig) -> Result<(), HandshakeError> {
3209 let auth = serde_json::json!({
3211 "action": "auth",
3212 "key": config.api_key(),
3213 "secret": config.secret_key,
3216 })
3217 .to_string();
3218 ws.send(WsMessage::Text(auth.into()))
3219 .await
3220 .map_err(|e| HandshakeError::Transport(format!("WS auth send: {e}")))?;
3221
3222 loop {
3224 match ws.next().await {
3225 Some(Ok(WsMessage::Text(text))) => {
3226 if let Some(result) = check_auth_response(text.as_str()) {
3227 result?;
3228 break;
3229 }
3230 }
3231 Some(Ok(WsMessage::Binary(bytes))) => {
3232 if let Ok(text) = std::str::from_utf8(&bytes)
3233 && let Some(result) = check_auth_response(text)
3234 {
3235 result?;
3236 break;
3237 }
3238 }
3239 Some(Err(e)) => {
3240 return Err(HandshakeError::Transport(format!(
3241 "WS error during auth: {e}"
3242 )));
3243 }
3244 None => {
3245 return Err(HandshakeError::Transport(
3246 "WS closed before auth response".into(),
3247 ));
3248 }
3249 _ => {} }
3251 }
3252
3253 let sub = serde_json::json!({
3255 "action": "listen",
3256 "data": { "streams": ["trade_updates"] }
3257 })
3258 .to_string();
3259 ws.send(WsMessage::Text(sub.into()))
3260 .await
3261 .map_err(|e| HandshakeError::Transport(format!("WS subscribe send: {e}")))?;
3262
3263 loop {
3265 match ws.next().await {
3266 Some(Ok(WsMessage::Text(text))) => {
3267 if check_listen_ack(text.as_str()) {
3268 break;
3269 }
3270 if let Ok(msg) = serde_json::from_str::<AlpacaStreamMessage<'_>>(text.as_str()) {
3277 if msg.stream == "trade_updates" {
3278 warn!(stream = %msg.stream, "WS trade_updates event dropped during listen-ack handshake — on a reconnect, fills and how orders held as live ended are recovered via REST; other lifecycle events are lost");
3279 } else {
3280 trace!(stream = %msg.stream, "WS message dropped during listen-ack handshake");
3281 }
3282 } else {
3283 trace!(
3284 bytes = text.len(),
3285 "WS non-stream message dropped during listen-ack handshake"
3286 );
3287 }
3288 }
3289 Some(Ok(WsMessage::Binary(bytes))) => {
3290 if let Ok(text) = std::str::from_utf8(&bytes)
3291 && check_listen_ack(text)
3292 {
3293 break;
3294 }
3295 trace!(
3296 bytes = bytes.len(),
3297 "WS binary message dropped during listen-ack handshake"
3298 );
3299 }
3300 Some(Err(e)) => {
3301 return Err(HandshakeError::Transport(format!(
3302 "WS error during subscribe: {e}"
3303 )));
3304 }
3305 None => {
3306 return Err(HandshakeError::Transport(
3307 "WS closed before subscribe ack".into(),
3308 ));
3309 }
3310 _ => {}
3311 }
3312 }
3313
3314 info!("Alpaca WS authenticated and subscribed to trade_updates");
3315 Ok(())
3316}
3317
3318fn check_auth_response(text: &str) -> Option<Result<(), HandshakeError>> {
3324 let msg = serde_json::from_str::<AlpacaStreamMessage<'_>>(text).ok()?;
3325 if msg.stream != "authorization" {
3326 return None;
3327 }
3328 #[derive(Deserialize)]
3329 struct AuthData<'a> {
3330 status: &'a str,
3331 }
3332 let data = serde_json::from_str::<AuthData<'_>>(msg.data.get()).ok()?;
3333 if data.status == "authorized" {
3334 Some(Ok(()))
3335 } else {
3336 Some(Err(HandshakeError::Auth(format!(
3337 "Alpaca WS auth failed: status={}",
3338 data.status
3339 ))))
3340 }
3341}
3342
3343fn check_listen_ack(text: &str) -> bool {
3347 let Ok(msg) = serde_json::from_str::<AlpacaStreamMessage<'_>>(text) else {
3348 return false;
3349 };
3350 if msg.stream != "listening" {
3351 return false;
3352 }
3353 #[derive(Deserialize)]
3354 struct ListenData<'a> {
3355 #[serde(borrow)]
3356 streams: Vec<&'a str>,
3357 }
3358 let Ok(data) = serde_json::from_str::<ListenData<'_>>(msg.data.get()) else {
3359 return false;
3360 };
3361 data.streams.contains(&"trade_updates")
3362}
3363
3364fn process_ws_text(
3374 text: &str,
3375 tx: &mpsc::UnboundedSender<UnindexedAccountEvent>,
3376 dedup: &SharedDedupCache,
3377 known: &SharedKnownLiveOrders,
3378 backoff: &mut ExponentialBackoff,
3379) {
3380 let msg: AlpacaStreamMessage<'_> = match serde_json::from_str(text) {
3381 Ok(m) => m,
3382 Err(e) => {
3383 trace!(
3384 %e,
3385 raw = ?&text[..text.len().min(200)],
3386 "Alpaca WS: skipped non-JSON message"
3387 );
3388 return;
3389 }
3390 };
3391
3392 backoff.reset();
3395
3396 match msg.stream.as_str() {
3397 "trade_updates" => {
3398 let update: AlpacaTradeUpdate<'_> = match serde_json::from_str(msg.data.get()) {
3399 Ok(u) => u,
3400 Err(e) => {
3401 warn!(
3406 %e,
3407 raw = ?&msg.data.get()[..msg.data.get().len().min(200)],
3408 "Alpaca WS trade_updates: failed to deserialize event — event dropped"
3409 );
3410 return;
3411 }
3412 };
3413
3414 if is_fill_event(&update)
3418 && is_duplicate_fill(dedup, ws_execution_id(&update), early_dedup_key(&update))
3419 {
3420 trace!("Alpaca WS: skipping duplicate fill event (early check)");
3421 return;
3422 }
3423
3424 if is_trade_amendment(&update) {
3427 warn!(
3428 event = %update.event,
3429 raw = %msg.data.get(),
3430 "Alpaca WS: a fill reported earlier was amended — reporting it as TradeAmended"
3431 );
3432 }
3433
3434 for event in convert_trade_update(update).into_iter().flatten() {
3435 if let AccountEventKind::TradeAmended(amendment) = &event.kind
3439 && let TradeAmendmentKind::Corrected { replacement } = &amendment.kind
3440 {
3441 record_execution(dedup, &replacement.id.0);
3442 }
3443 let known = KnownLiveOrders::observes(&event.kind).then(|| {
3444 let mut known = known.lock();
3445 known.observe(&event);
3446 known
3447 });
3448 let _ = tx.send(event);
3451 drop(known);
3452 }
3453 }
3454 "authorization" | "listening" => {
3455 trace!(stream = %msg.stream, "Alpaca WS: auth/listen ack received during stream");
3458 }
3459 other => {
3460 trace!(%other, "Alpaca WS: ignoring unknown stream type");
3461 }
3462 }
3463}
3464
3465fn fill_dedup_key(order_id: &str, cumulative: Decimal) -> SmolStr {
3476 format_smolstr!("{}:{}", order_id, cumulative.normalize())
3477}
3478
3479fn is_trade_amendment(update: &AlpacaTradeUpdate<'_>) -> bool {
3481 matches!(update.event.as_str(), "trade_bust" | "trade_correct")
3482}
3483
3484#[inline]
3488fn is_fill_event(update: &AlpacaTradeUpdate<'_>) -> bool {
3489 matches!(update.event.as_str(), "fill" | "partial_fill")
3490}
3491
3492fn ws_execution_id<'a>(update: &AlpacaTradeUpdate<'a>) -> Option<&'a str> {
3494 update.execution_id.filter(|id| !id.is_empty())
3495}
3496
3497fn early_dedup_key(update: &AlpacaTradeUpdate<'_>) -> SmolStr {
3501 let filled_qty = update.order.filled_qty.unwrap_or("0");
3502 let qty = Decimal::from_str(filled_qty).unwrap_or(Decimal::ZERO);
3503 fill_dedup_key(&update.order.id, qty)
3504}
3505
3506fn activity_execution_id(activity_id: &str) -> &str {
3510 activity_execution_id_part(activity_id).unwrap_or(activity_id)
3511}
3512
3513fn activity_execution_id_part(activity_id: &str) -> Option<&str> {
3517 activity_id
3518 .split_once("::")
3519 .map(|(_, execution_id)| execution_id)
3520 .filter(|execution_id| !execution_id.is_empty())
3521}
3522
3523#[derive(Debug, PartialEq)]
3529struct UnreadFills {
3530 start: DateTime<Utc>,
3531 reason: FillRecoveryFailure,
3532}
3533
3534async fn recover_fills_or_report(
3538 http: &reqwest::Client,
3539 rate_limiter: &RateLimitTracker,
3540 instruments: &[InstrumentNameExchange],
3541 base: &str,
3542 disconnect: DateTime<Utc>,
3543 timeout: Duration,
3544 tx: &mpsc::UnboundedSender<UnindexedAccountEvent>,
3545 dedup: &SharedDedupCache,
3546 known: &SharedKnownLiveOrders,
3547) {
3548 let recovery_start = Utc::now();
3550 let outcome = match tokio::time::timeout(
3551 timeout,
3552 recover_fills(
3553 http,
3554 rate_limiter,
3555 instruments,
3556 base,
3557 disconnect,
3558 tx,
3559 dedup,
3560 known,
3561 ),
3562 )
3563 .await
3564 {
3565 Ok(outcome) => outcome,
3566 Err(_) => {
3570 warn!(
3571 timeout_secs = timeout.as_secs(),
3572 instruments = ?instruments
3573 .iter()
3574 .map(|instrument| instrument.name().as_str())
3575 .collect::<Vec<_>>(),
3576 "Alpaca fill recovery timed out — fills since the disconnect may be missing for \
3577 any of the instruments (every instrument when the list is empty)"
3578 );
3579 Err(UnreadFills {
3580 start: disconnect,
3581 reason: FillRecoveryFailure::TimedOut {
3582 timeout_secs: timeout.as_secs(),
3583 },
3584 })
3585 }
3586 };
3587 if let Err(unread) = outcome
3588 && tx
3589 .send(fill_recovery_gave_up(instruments, unread, recovery_start))
3590 .is_err()
3591 {
3592 debug!("Alpaca fill recovery: consumer dropped before the give-up was sent");
3593 }
3594}
3595
3596fn fill_recovery_gave_up(
3605 instruments: &[InstrumentNameExchange],
3606 unread: UnreadFills,
3607 end: DateTime<Utc>,
3608) -> UnindexedAccountEvent {
3609 let UnreadFills { start, reason } = unread;
3610 let scope = if instruments.is_empty() {
3612 FillRecoveryScope::AllInstruments
3613 } else {
3614 FillRecoveryScope::Instruments(instruments.to_vec())
3615 };
3616 UnindexedAccountEvent::new(
3617 ExchangeId::AlpacaBroker,
3618 AccountEventKind::FillRecoveryGaveUp(FillRecoveryGap::new(
3619 scope,
3620 start,
3621 end.max(start),
3622 1,
3623 reason,
3624 )),
3625 )
3626}
3627
3628async fn recover_fills(
3637 http: &reqwest::Client,
3638 rate_limiter: &RateLimitTracker,
3639 instruments: &[InstrumentNameExchange],
3640 base: &str,
3641 after: DateTime<Utc>,
3642 tx: &mpsc::UnboundedSender<UnindexedAccountEvent>,
3643 dedup: &SharedDedupCache,
3644 known: &SharedKnownLiveOrders,
3645) -> Result<(), UnreadFills> {
3646 info!(%after, instruments = instruments.len(), "Alpaca recovering fills after reconnect");
3649 let instrument_set: fnv::FnvHashSet<&str> = if instruments.is_empty() {
3650 fnv::FnvHashSet::default()
3651 } else {
3652 instruments.iter().map(|i| i.name().as_str()).collect()
3653 };
3654
3655 let page = match paginate_activities::<AlpacaActivity>(
3656 http,
3657 rate_limiter,
3658 base,
3659 FILL_ACTIVITIES,
3660 after,
3661 None,
3662 )
3663 .await
3664 {
3665 Ok(p) => p,
3666 Err(e) => {
3667 error!(%e, "Alpaca fill recovery: REST request failed");
3668 return Err(UnreadFills {
3669 start: after,
3670 reason: FillRecoveryFailure::Request(e.to_string()),
3671 });
3672 }
3673 };
3674
3675 let activities = page.activities;
3676
3677 let unread = page.resume.map(|start| {
3681 error!(
3682 max_pages = MAX_ACTIVITY_PAGES,
3683 fills_read = activities.len(),
3684 %start,
3685 "Alpaca fill recovery: max page limit reached, truncating — fills from this time on \
3686 are not delivered; read them with fetch_trades"
3687 );
3688 UnreadFills {
3689 start,
3690 reason: FillRecoveryFailure::Truncated {
3691 fills_read: activities.len(),
3692 },
3693 }
3694 });
3695
3696 let mut recovered = 0u32;
3697 let mut duplicates = 0u32;
3698
3699 let mut cumulative_qty: FnvHashMap<&str, Decimal> = FnvHashMap::default();
3716
3717 for activity in &activities {
3718 if !instrument_set.is_empty() && !instrument_set.contains(activity.symbol.as_str()) {
3719 continue;
3723 }
3724
3725 let exec_qty = Decimal::from_str(&activity.qty).unwrap_or(Decimal::ZERO);
3740 let cum = cumulative_qty
3741 .entry(activity.order_id.as_str())
3742 .or_default();
3743 *cum += exec_qty;
3744 let cumulative = *cum;
3745
3746 let trade = match convert_activity_to_trade(activity) {
3747 Some(t) => t,
3748 None => {
3749 warn!(id = %activity.id, symbol = %activity.symbol, "Alpaca: skipping activity with unparseable fields");
3750 continue; }
3752 };
3753
3754 let key = fill_dedup_key(
3764 &activity.order_id,
3765 trade.order_filled_quantity.unwrap_or(cumulative),
3766 );
3767 if is_duplicate_fill(dedup, activity_execution_id_part(&activity.id), key) {
3768 duplicates += 1;
3769 continue;
3770 }
3771
3772 let event =
3773 UnindexedAccountEvent::new(ExchangeId::AlpacaBroker, AccountEventKind::Trade(trade));
3774 let mut held = known.lock();
3775 held.observe(&event);
3776 let sent = tx.send(event);
3777 drop(held);
3778 if sent.is_err() {
3779 debug!("Alpaca fill recovery: consumer dropped during recovery");
3780 return Ok(());
3781 }
3782 recovered += 1;
3783 }
3784
3785 info!(recovered, duplicates, "Alpaca fill recovery complete");
3786 unread.map_or(Ok(()), Err)
3787}
3788
3789async fn fetch_order_lookup(
3798 http: reqwest::Client,
3799 rate_limiter: Arc<RateLimitTracker>,
3800 config: Arc<AlpacaConfig>,
3801 key: UnindexedOrderKey,
3802) -> Result<OrderLookup, UnindexedClientError> {
3803 let url = format!("{}/v2/orders:by_client_order_id", config.rest_base_url());
3804 let cid = key.cid.0.as_str();
3805 let found: Option<AlpacaOrderResponse> = rest_lookup_with_retry(&rate_limiter, || {
3806 http.get(&url).query(&[("client_order_id", cid)])
3807 })
3808 .await?;
3809 let Some(order) = found else {
3810 debug!(instrument = %key.instrument, cid = %key.cid, "Alpaca does not know this order");
3811 return Ok(OrderLookup::Unknown);
3812 };
3813 if !order
3814 .symbol
3815 .eq_ignore_ascii_case(key.instrument.name().as_str())
3816 {
3817 debug!(
3818 instrument = %key.instrument,
3819 cid = %key.cid,
3820 symbol = %order.symbol,
3821 "Alpaca knows this client order id on another symbol"
3822 );
3823 return Ok(OrderLookup::Unknown);
3824 }
3825 Ok(
3826 convert_ended_order(&order, &key).map_or(OrderLookup::NotEnded, |order| {
3827 OrderLookup::Ended(Box::new(order))
3828 }),
3829 )
3830}
3831
3832async fn listed_open_cids(
3835 http: reqwest::Client,
3836 rate_limiter: Arc<RateLimitTracker>,
3837 config: Arc<AlpacaConfig>,
3838 instruments: Vec<InstrumentNameExchange>,
3839) -> Result<FnvHashSet<ClientOrderId>, UnindexedClientError> {
3840 let orders =
3841 fetch_raw_open_orders(&http, &rate_limiter, config.rest_base_url(), &instruments).await?;
3842 Ok(orders
3843 .iter()
3844 .map(|order| alpaca_cid(order.client_order_id.as_deref(), &order.id))
3845 .collect())
3846}
3847
3848async fn recover_alpaca_ended_orders(
3852 http: &reqwest::Client,
3853 rate_limiter: &Arc<RateLimitTracker>,
3854 config: &Arc<AlpacaConfig>,
3855 known: &SharedKnownLiveOrders,
3856 unchecked: &mut UncheckedOrders,
3857 tx: &mpsc::UnboundedSender<UnindexedAccountEvent>,
3858) {
3859 recover_ended_orders(
3860 ExchangeId::AlpacaBroker,
3861 known,
3862 unchecked,
3863 &NoPendingFills,
3864 tx,
3865 OpenListing::Batched,
3866 |instruments| {
3867 listed_open_cids(
3868 http.clone(),
3869 rate_limiter.clone(),
3870 config.clone(),
3871 instruments,
3872 )
3873 },
3874 |key| fetch_order_lookup(http.clone(), rate_limiter.clone(), config.clone(), key),
3875 )
3876 .await;
3877}
3878
3879async fn run_order_checks(
3884 http: &reqwest::Client,
3885 rate_limiter: &Arc<RateLimitTracker>,
3886 config: &Arc<AlpacaConfig>,
3887 known: &SharedKnownLiveOrders,
3888 unchecked: &mut UncheckedOrders,
3889 tx: &mpsc::UnboundedSender<UnindexedAccountEvent>,
3890) {
3891 loop {
3892 if tx.is_closed() {
3894 return std::future::pending().await;
3895 }
3896 match unchecked.next_due(&NoPendingFills) {
3897 Some(due) => tokio::time::sleep_until(due).await,
3898 None => return std::future::pending().await,
3899 }
3900 recover_alpaca_ended_orders(http, rate_limiter, config, known, unchecked, tx).await;
3901 }
3902}
3903
3904fn convert_ended_order(
3912 o: &AlpacaOrderResponse,
3913 key: &UnindexedOrderKey,
3914) -> Option<UnindexedInactiveOrder> {
3915 let instrument = &key.instrument;
3916 let Some(status) = o.status.as_deref() else {
3917 warn!(%instrument, cid = %key.cid, order_id = %o.id, "Alpaca order missing status");
3918 return None;
3919 };
3920 if order_status_is_live(status) {
3921 return None;
3922 }
3923 let Some(order) = convert_representable_open_order(o) else {
3924 warn!(%instrument, cid = %key.cid, order_id = %o.id, status, "Alpaca order cannot be represented, treating it as not ended");
3925 return None;
3926 };
3927 let filled_qty = Decimal::from_str(&o.filled_qty).ok();
3930 let Some(state) = ended_order_state(
3931 status,
3932 OrderId(SmolStr::new(&o.id)),
3933 order.state.time_exchange,
3934 order.quantity,
3935 filled_qty,
3936 o.filled_avg_price.as_deref(),
3937 ) else {
3938 warn!(%instrument, cid = %key.cid, order_id = %o.id, status, "Alpaca order has a status this version does not know, treating it as not ended");
3939 return None;
3940 };
3941 let mut order = order.map_state(|_| state);
3942 order.key = key.clone();
3943 Some(order)
3944}
3945
3946fn order_status_is_live(status: &str) -> bool {
3952 matches!(
3953 status,
3954 "new"
3955 | "partially_filled"
3956 | "done_for_day"
3957 | "pending_cancel"
3958 | "pending_replace"
3959 | "accepted"
3960 | "pending_new"
3961 | "accepted_for_bidding"
3962 | "stopped"
3963 | "suspended"
3964 | "calculated"
3965 | "held"
3966 )
3967}
3968
3969fn ended_order_state<AssetKey, InstrumentKey>(
3979 status: &str,
3980 order_id: OrderId,
3981 time_exchange: DateTime<Utc>,
3982 quantity: Decimal,
3983 filled_qty: Option<Decimal>,
3984 filled_avg_price: Option<&str>,
3985) -> Option<InactiveOrderState<AssetKey, InstrumentKey>> {
3986 Some(match status {
3987 "filled" => InactiveOrderState::FullyFilled(Filled::new(
3989 order_id,
3990 time_exchange,
3991 filled_qty.unwrap_or(quantity),
3992 alpaca_avg_price(filled_avg_price),
3993 )),
3994 "canceled" | "replaced" => {
3995 InactiveOrderState::Cancelled(Cancelled::new(order_id, time_exchange, filled_qty))
3996 }
3997 "expired" => InactiveOrderState::Expired(Expired::new(order_id, time_exchange, filled_qty)),
3998 "rejected" => {
3999 InactiveOrderState::OpenFailed(OrderError::Rejected(ApiError::OrderRejected(format!(
4000 "Alpaca rejected order {order_id} after accepting it"
4001 ))))
4002 }
4003 _ => return None,
4004 })
4005}
4006
4007fn placed_order_state(
4017 resp: &AlpacaOrderResponse,
4018 instrument: &InstrumentNameExchange,
4019 quantity: Decimal,
4020) -> UnindexedOrderState {
4021 let order_id = OrderId(SmolStr::new(&resp.id));
4022 let time_exchange = order_state_time(resp);
4023 let filled_qty = alpaca_filled_qty(&resp.id, Some(&resp.filled_qty));
4024 let filled_avg_price = resp.filled_avg_price.as_deref();
4025 match resp.status.as_deref() {
4026 Some(status) => {
4027 if let Some(ended) = ended_order_state(
4028 status,
4029 order_id.clone(),
4030 time_exchange,
4031 quantity,
4032 filled_qty,
4033 filled_avg_price,
4034 ) {
4035 return OrderState::Inactive(ended);
4036 }
4037 if !order_status_is_live(status) {
4038 warn!(%instrument, %order_id, status, "Alpaca placed an order with a status this version does not know, reading it from what filled");
4039 }
4040 }
4041 None => {
4042 warn!(%instrument, %order_id, "Alpaca placed an order without a status, reading it from what filled");
4043 }
4044 }
4045 match filled_qty {
4046 Some(filled_qty) if filled_qty >= quantity => OrderState::fully_filled(Filled::new(
4047 order_id,
4048 time_exchange,
4049 filled_qty,
4050 alpaca_avg_price(filled_avg_price),
4051 )),
4052 _ => OrderState::active(Open::new(
4055 VenueOrderId::Assigned(order_id),
4056 time_exchange,
4057 filled_qty.unwrap_or(Decimal::ZERO),
4058 )),
4059 }
4060}
4061
4062fn alpaca_avg_price(filled_avg_price: Option<&str>) -> Option<Decimal> {
4065 filled_avg_price.and_then(|price| Decimal::from_str(price).ok())
4066}
4067
4068fn is_usd(asset: &AssetNameExchange) -> bool {
4075 asset.name().as_str().eq_ignore_ascii_case(USD)
4076}
4077
4078fn convert_account_to_balances(
4096 account: &AlpacaAccount,
4097 assets: &[AssetNameExchange],
4098) -> Vec<AssetBalance<AssetNameExchange>> {
4099 if !assets.is_empty() && !assets.iter().any(is_usd) {
4100 return Vec::new();
4101 }
4102
4103 vec![AssetBalance::new(
4104 AssetNameExchange::new(USD),
4105 Balance::new(
4106 account.cash,
4107 account.cash.min(account.non_marginable_buying_power),
4108 ),
4109 Utc::now(),
4110 )]
4111}
4112
4113fn convert_positions_to_balances(
4125 positions: &[AlpacaPosition],
4126 assets: &[AssetNameExchange],
4127) -> Vec<AssetBalance<AssetNameExchange>> {
4128 let now = Utc::now();
4129 positions
4130 .iter()
4131 .filter(|p| is_crypto_position(p))
4132 .filter_map(|p| {
4133 let base = crypto_position_asset(&p.symbol);
4134
4135 if !assets.is_empty()
4137 && !assets
4138 .iter()
4139 .any(|a| a.name().as_str().eq_ignore_ascii_case(base))
4140 {
4141 return None;
4142 }
4143
4144 let asset_name = AssetNameExchange::new(base);
4147 Some(AssetBalance::new(
4148 asset_name,
4149 Balance::new(p.qty, p.qty_available),
4150 now,
4151 ))
4152 })
4153 .collect()
4154}
4155
4156fn crypto_position_asset(symbol: &str) -> &str {
4164 let base = match symbol.split_once('/') {
4165 Some((base, _)) => Some(base),
4166 None => {
4167 let base_len = symbol.len().saturating_sub(USD.len());
4168 symbol
4169 .get(base_len..)
4170 .filter(|suffix| suffix.eq_ignore_ascii_case(USD))
4171 .and_then(|_| symbol.get(..base_len))
4172 }
4173 };
4174 match base {
4175 Some(base) if !base.is_empty() => base,
4176 _ => {
4177 warn!(
4178 %symbol,
4179 "Alpaca crypto position symbol is neither ASSETUSD nor BASE/QUOTE; using it whole"
4180 );
4181 symbol
4182 }
4183 }
4184}
4185
4186fn is_crypto_position(position: &AlpacaPosition) -> bool {
4189 position.asset_class.eq_ignore_ascii_case("crypto")
4190}
4191
4192fn convert_positions(
4214 positions: &[AlpacaPosition],
4215 now: DateTime<Utc>,
4216) -> Result<Vec<(&str, Position)>, UnindexedClientError> {
4217 positions
4218 .iter()
4219 .filter(|p| !is_crypto_position(p) && !p.qty.is_zero())
4220 .map(|p| {
4221 let size = p.qty.abs();
4222 let quantity = match p.side {
4223 AlpacaPositionSide::Long => size,
4224 AlpacaPositionSide::Short => -size,
4225 AlpacaPositionSide::Unknown => {
4226 return Err(UnindexedClientError::Internal(format!(
4227 "Alpaca position {} has a missing or unrecognised side",
4228 p.symbol
4229 )));
4230 }
4231 };
4232 let position = Position::new(
4233 quantity,
4234 p.avg_entry_price,
4235 p.unrealized_pl,
4236 None,
4237 None,
4238 None,
4239 None,
4240 now,
4241 );
4242 Ok((p.symbol.as_str(), position))
4243 })
4244 .collect()
4245}
4246
4247fn build_instrument_snapshots(
4268 orders: Vec<AlpacaOrderResponse>,
4269 positions: Vec<(&str, Position)>,
4270 instruments: &[InstrumentNameExchange],
4271) -> Vec<InstrumentAccountSnapshot<ExchangeId, AssetNameExchange, InstrumentNameExchange>> {
4272 struct Listing {
4274 orders: Vec<UnindexedOrderSnapshot>,
4275 complete: bool,
4276 position: Option<Position>,
4277 }
4278
4279 impl Listing {
4280 fn empty() -> Self {
4281 Self {
4282 orders: Vec::new(),
4283 complete: true,
4284 position: None,
4285 }
4286 }
4287 }
4288
4289 let mut by_symbol: IndexMap<SmolStr, Listing> = IndexMap::new();
4291
4292 for order in orders {
4295 let listing = by_symbol
4296 .entry(order.symbol.to_ascii_uppercase().into())
4297 .or_insert_with(Listing::empty);
4298 match convert_open_order(&order) {
4299 Some(converted) => listing.orders.push(converted.into()),
4300 None => listing.complete = false,
4301 }
4302 }
4303
4304 for (symbol, position) in positions {
4305 by_symbol
4306 .entry(symbol.to_ascii_uppercase().into())
4307 .or_insert_with(Listing::empty)
4308 .position = Some(position);
4309 }
4310
4311 let snapshot = |instrument: InstrumentNameExchange, listing: Listing| {
4312 let position = if is_options_or_equity_symbol(instrument.name().as_str()) {
4315 listing
4316 .position
4317 .map_or(PositionReport::Flat, PositionReport::from_position)
4318 } else {
4319 PositionReport::Unreported
4320 };
4321 InstrumentAccountSnapshot::new(instrument, listing.orders, listing.complete, position, None)
4322 };
4323
4324 if instruments.is_empty() {
4326 by_symbol
4327 .into_iter()
4328 .map(|(sym, listing)| snapshot(InstrumentNameExchange::new(sym), listing))
4329 .collect()
4330 } else {
4331 let mut claimed = std::collections::HashSet::new();
4333 instruments
4334 .iter()
4335 .map(|inst| {
4336 let symbol = inst.name().as_str().to_ascii_uppercase();
4337 if !claimed.insert(symbol.clone()) {
4340 return InstrumentAccountSnapshot::new(
4341 inst.clone(),
4342 Vec::new(),
4343 false,
4344 PositionReport::Unreported,
4345 None,
4346 );
4347 }
4348 let listing = by_symbol
4351 .swap_remove(symbol.as_str())
4352 .unwrap_or_else(Listing::empty);
4353 snapshot(inst.clone(), listing)
4354 })
4355 .collect()
4356 }
4357}
4358
4359fn convert_open_order(
4369 o: &AlpacaOrderResponse,
4370) -> Option<Order<ExchangeId, InstrumentNameExchange, Open>> {
4371 let converted = convert_representable_open_order(o);
4372 if converted.is_none() {
4373 warn!(
4374 order_id = %o.id,
4375 symbol = %o.symbol,
4376 qty = ?o.qty,
4377 side = %o.side,
4378 order_type = %o.order_type,
4379 "Alpaca open order cannot be represented - leaving it out"
4380 );
4381 }
4382 converted
4383}
4384
4385fn alpaca_cid(client_order_id: Option<&str>, order_id: &str) -> ClientOrderId {
4391 ClientOrderId::new(client_order_id.unwrap_or(order_id))
4392}
4393
4394fn convert_representable_open_order(
4396 o: &AlpacaOrderResponse,
4397) -> Option<Order<ExchangeId, InstrumentNameExchange, Open>> {
4398 let order_id = OrderId(SmolStr::new(&o.id));
4399 let cid = alpaca_cid(o.client_order_id.as_deref(), &o.id);
4400
4401 let instrument = InstrumentNameExchange::new(&o.symbol);
4402 let side = parse_side(&o.side)?;
4403 let quantity = Decimal::from_str(o.qty.as_deref().unwrap_or("0")).ok()?;
4404 if quantity.is_zero() {
4408 return None;
4409 }
4410 let price = o
4411 .limit_price
4412 .as_deref()
4413 .and_then(|s| Decimal::from_str(s).ok());
4414 let filled_qty = alpaca_filled_qty(&o.id, Some(&o.filled_qty)).unwrap_or(Decimal::ZERO);
4415 let kind = parse_order_kind(
4416 &o.order_type,
4417 o.stop_price.as_deref(),
4418 o.trail_percent.as_deref(),
4419 o.trail_price.as_deref(),
4420 )?;
4421 let time_in_force = parse_time_in_force(&o.time_in_force);
4422 let time_exchange = order_state_time(o);
4423
4424 Some(Order {
4425 key: OrderKey::new(
4426 ExchangeId::AlpacaBroker,
4427 instrument,
4428 StrategyId::unknown(),
4430 cid,
4431 ),
4432 side,
4433 price,
4434 quantity,
4435 kind,
4436 time_in_force,
4437 state: Open::new(VenueOrderId::Assigned(order_id), time_exchange, filled_qty),
4438 })
4439}
4440
4441fn convert_activity_to_trade(
4443 a: &AlpacaActivity,
4444) -> Option<Trade<AssetNameExchange, InstrumentNameExchange>> {
4445 let trade_id = TradeId::new(activity_execution_id(&a.id));
4446 let order_id = OrderId(SmolStr::new(&a.order_id));
4447 let instrument = InstrumentNameExchange::new(&a.symbol);
4448 let side = parse_side(&a.side)?;
4449 let price = Decimal::from_str(&a.price).ok()?;
4450 let quantity = Decimal::from_str(&a.qty).ok()?;
4451 let order_filled_quantity = a.cum_qty.as_deref().and_then(|s| Decimal::from_str(s).ok());
4454 let time_exchange = parse_timestamp(&a.transaction_time).unwrap_or_else(|| {
4455 warn!(id = %a.id, "Alpaca activity: unparseable transaction_time, using now");
4456 Utc::now()
4457 });
4458
4459 Some(Trade::new(
4464 trade_id,
4465 order_id,
4466 instrument,
4467 StrategyId::unknown(),
4468 time_exchange,
4469 side,
4470 price,
4471 quantity,
4472 order_filled_quantity,
4473 AssetFees::new(
4474 AssetNameExchange::from(USD),
4475 Decimal::ZERO,
4476 Some(Decimal::ZERO),
4477 ),
4478 ))
4479}
4480
4481fn ws_order_snapshot(
4491 order: &AlpacaOrderWs<'_>,
4492 instrument: InstrumentNameExchange,
4493 cid: ClientOrderId,
4494 order_id: OrderId,
4495 time_exchange: DateTime<Utc>,
4496) -> Option<UnindexedAccountEvent> {
4497 let side = parse_side(&order.side)?;
4498 let quantity = Decimal::from_str(order.qty.unwrap_or("0")).unwrap_or(Decimal::ZERO);
4499 if quantity.is_zero() {
4500 trace!(order_id = %order.id, "Alpaca WS: skipping notional order snapshot (qty=None)");
4501 return None;
4502 }
4503 let price = order.limit_price.and_then(|s| Decimal::from_str(s).ok());
4504 let filled_qty = order
4507 .filled_qty
4508 .and_then(|_| alpaca_filled_qty(&order.id, order.filled_qty))
4509 .unwrap_or(Decimal::ZERO);
4510 let kind = parse_order_kind(
4511 &order.order_type,
4512 order.stop_price,
4513 order.trail_percent,
4514 order.trail_price,
4515 )?;
4516 let time_in_force = parse_time_in_force(&order.time_in_force);
4517
4518 let order_snapshot = crate::order::Order {
4519 key: OrderKey::new(
4520 ExchangeId::AlpacaBroker,
4521 instrument,
4522 StrategyId::unknown(),
4523 cid,
4524 ),
4525 side,
4526 price,
4527 quantity,
4528 kind,
4529 time_in_force,
4530 state: OrderState::active(Open::new(
4531 VenueOrderId::Assigned(order_id),
4532 time_exchange,
4533 filled_qty,
4534 )),
4535 };
4536 Some(UnindexedAccountEvent::new(
4537 ExchangeId::AlpacaBroker,
4538 AccountEventKind::OrderSnapshot(rustrade_integration::collection::snapshot::Snapshot(
4539 order_snapshot,
4540 )),
4541 ))
4542}
4543
4544fn ws_fill_order_is_live(status: &str) -> bool {
4554 matches!(status, "partially_filled" | "filled")
4555}
4556
4557fn trade_amendment_event(
4569 update: &AlpacaTradeUpdate<'_>,
4570 instrument: InstrumentNameExchange,
4571 order_id: OrderId,
4572) -> UnindexedAccountEvent {
4573 let order = &update.order;
4574 let time_exchange = update
4575 .timestamp
4576 .and_then(parse_timestamp)
4577 .unwrap_or_else(Utc::now);
4578 let original = update
4579 .previous_execution_id
4580 .filter(|id| !id.is_empty())
4581 .map(TradeId::new);
4582 let quantity = update.qty.and_then(|s| Decimal::from_str(s).ok());
4583
4584 let kind = if update.event == "trade_bust" {
4585 TradeAmendmentKind::Busted {
4586 quantity: quantity.map(|quantity| quantity.abs()),
4587 }
4588 } else {
4589 let id = ws_execution_id(update).map(TradeId::new);
4590 let price = update.price.and_then(|s| Decimal::from_str(s).ok());
4591 match (id, price, quantity, parse_side(&order.side)) {
4592 (Some(id), Some(price), Some(quantity), Some(side)) if quantity > Decimal::ZERO => {
4593 TradeAmendmentKind::Corrected {
4594 replacement: with_reported_cid(
4595 Trade::new(
4596 id,
4597 order_id.clone(),
4598 instrument.clone(),
4599 StrategyId::unknown(),
4600 time_exchange,
4601 side,
4602 price,
4603 quantity,
4604 None,
4607 AssetFees::new(
4609 AssetNameExchange::from(USD),
4610 Decimal::ZERO,
4611 Some(Decimal::ZERO),
4612 ),
4613 ),
4614 order.client_order_id.as_ref(),
4615 ),
4616 }
4617 }
4618 (id, price, quantity, _) => TradeAmendmentKind::CorrectedUnresolved {
4619 id,
4620 price,
4621 quantity,
4622 },
4623 }
4624 };
4625
4626 UnindexedAccountEvent::new(
4627 ExchangeId::AlpacaBroker,
4628 AccountEventKind::TradeAmended(crate::trade::TradeAmendment::new(
4629 instrument,
4630 order_id,
4631 time_exchange,
4632 original,
4633 kind,
4634 )),
4635 )
4636}
4637
4638fn convert_trade_update(update: AlpacaTradeUpdate<'_>) -> [Option<UnindexedAccountEvent>; 2] {
4648 let event_str = update.event.as_str();
4651 if !matches!(
4652 event_str,
4653 "fill"
4654 | "partial_fill"
4655 | "new"
4656 | "accepted"
4657 | "pending_new"
4658 | "canceled"
4659 | "expired"
4660 | "replaced"
4661 | "done_for_day"
4662 | "rejected"
4663 | "trade_bust"
4664 | "trade_correct"
4665 ) {
4666 trace!(event = %event_str, "Alpaca WS: ignoring trade_updates event type");
4667 return [None, None];
4668 }
4669
4670 let order = &update.order;
4671 let instrument = InstrumentNameExchange::new(&*order.symbol);
4672 let order_id = OrderId(order.id.clone());
4673 let cid = alpaca_cid(order.client_order_id.as_deref(), &order.id);
4674
4675 match event_str {
4676 "fill" | "partial_fill" => {
4677 let (Some(price), Some(quantity), Some(side)) = (
4679 update.price.and_then(|s| Decimal::from_str(s).ok()),
4680 update.qty.and_then(|s| Decimal::from_str(s).ok()),
4681 parse_side(&order.side),
4682 ) else {
4683 return [None, None];
4684 };
4685 let time_exchange = update
4686 .timestamp
4687 .and_then(parse_timestamp)
4688 .unwrap_or_else(Utc::now);
4689
4690 let cum_qty = Decimal::from_str(order.filled_qty.unwrap_or("0"))
4695 .inspect_err(|e| {
4696 warn!(
4697 order_id = %order.id,
4698 filled_qty = ?order.filled_qty,
4699 %e,
4700 "Alpaca WS: failed to parse filled_qty — the fill reports a cumulative \
4701 of 0, and without an execution id a second such fill on the same order \
4702 would be deduplicated away"
4703 );
4704 })
4705 .unwrap_or(Decimal::ZERO);
4706 let trade_id = match ws_execution_id(&update) {
4710 Some(execution_id) => TradeId::new(execution_id),
4711 None => {
4712 warn!(
4713 order_id = %order.id,
4714 "Alpaca WS: fill without a usable execution_id — its TradeId is \
4715 \"{{order_id}}:{{filled_qty}}\", which fetch_trades will not match"
4716 );
4717 TradeId(fill_dedup_key(&order.id, cum_qty))
4718 }
4719 };
4720
4721 let trade = Trade::new(
4725 trade_id,
4726 order_id.clone(),
4727 instrument.clone(),
4728 StrategyId::unknown(),
4729 time_exchange,
4730 side,
4731 price,
4732 quantity,
4733 Some(cum_qty),
4735 AssetFees::new(
4736 AssetNameExchange::from(USD),
4737 Decimal::ZERO,
4738 Some(Decimal::ZERO),
4739 ),
4740 );
4741 let trade = with_reported_cid(trade, order.client_order_id.as_ref());
4742 let trade_event = UnindexedAccountEvent::new(
4743 ExchangeId::AlpacaBroker,
4744 AccountEventKind::Trade(trade),
4745 );
4746
4747 let snapshot_event = if ws_fill_order_is_live(&order.status) {
4751 ws_order_snapshot(order, instrument, cid, order_id, time_exchange)
4752 } else {
4753 trace!(
4754 order_id = %order.id,
4755 status = %order.status,
4756 "Alpaca WS: fill for an order the exchange no longer reports as working — \
4757 emitting the execution without an order snapshot"
4758 );
4759 None
4760 };
4761
4762 [Some(trade_event), snapshot_event]
4763 }
4764
4765 "new" | "accepted" | "pending_new" | "done_for_day" => {
4766 let time_exchange = update
4770 .timestamp
4771 .and_then(parse_timestamp)
4772 .unwrap_or_else(Utc::now);
4773
4774 [
4775 ws_order_snapshot(order, instrument, cid, order_id, time_exchange),
4776 None,
4777 ]
4778 }
4779
4780 "canceled" | "expired" | "replaced" => {
4781 let time_exchange = update
4789 .timestamp
4790 .and_then(parse_timestamp)
4791 .unwrap_or_else(Utc::now);
4792 let filled_qty = alpaca_filled_qty(&order.id, order.filled_qty);
4793 let cancelled = Cancelled::new(order_id, time_exchange, filled_qty);
4794 let response = crate::order::request::OrderResponseCancel {
4795 key: OrderKey::new(
4796 ExchangeId::AlpacaBroker,
4797 instrument,
4798 StrategyId::unknown(),
4799 cid,
4800 ),
4801 state: Ok(cancelled),
4802 };
4803 [
4804 Some(UnindexedAccountEvent::new(
4805 ExchangeId::AlpacaBroker,
4806 AccountEventKind::OrderCancelled(response),
4807 )),
4808 None,
4809 ]
4810 }
4811
4812 "trade_bust" | "trade_correct" => [
4813 Some(trade_amendment_event(&update, instrument, order_id)),
4814 None,
4815 ],
4816
4817 "rejected" => {
4818 let response = crate::order::request::OrderResponseCancel {
4819 key: OrderKey::new(
4820 ExchangeId::AlpacaBroker,
4821 instrument,
4822 StrategyId::unknown(),
4823 cid,
4824 ),
4825 state: Err(UnindexedOrderError::Rejected(ApiError::OrderRejected(
4826 format!("order rejected: status={}", order.status),
4827 ))),
4828 };
4829 [
4830 Some(UnindexedAccountEvent::new(
4831 ExchangeId::AlpacaBroker,
4832 AccountEventKind::OrderCancelled(response),
4833 )),
4834 None,
4835 ]
4836 }
4837
4838 _ => unreachable!("convert_trade_update: unrecognised event passed early-return guard"),
4841 }
4842}
4843
4844fn parse_side(s: &str) -> Option<Side> {
4849 match s {
4850 "buy" | "Buy" | "BUY" => Some(Side::Buy),
4851 "sell" | "Sell" | "SELL" => Some(Side::Sell),
4852 other => {
4853 trace!(%other, "Alpaca: unknown order side");
4854 None
4855 }
4856 }
4857}
4858
4859fn parse_order_kind(
4860 order_type: &str,
4861 stop_price: Option<&str>,
4862 trail_percent: Option<&str>,
4863 trail_price: Option<&str>,
4864) -> Option<OrderKind> {
4865 match order_type {
4866 "market" | "Market" => Some(OrderKind::Market),
4867 "limit" | "Limit" => Some(OrderKind::Limit),
4868 "stop" | "Stop" => {
4869 let trigger_price = stop_price.and_then(|s| Decimal::from_str(s).ok())?;
4870 Some(OrderKind::Stop { trigger_price })
4871 }
4872 "stop_limit" | "Stop_limit" => {
4873 let trigger_price = stop_price.and_then(|s| Decimal::from_str(s).ok())?;
4874 Some(OrderKind::StopLimit { trigger_price })
4875 }
4876 "trailing_stop" | "Trailing_stop" => {
4877 if let Some(pct) = trail_percent.and_then(|s| Decimal::from_str(s).ok()) {
4879 Some(OrderKind::TrailingStop {
4880 offset: pct,
4881 offset_type: TrailingOffsetType::Percentage,
4882 activation_price: None,
4883 })
4884 } else if let Some(price) = trail_price.and_then(|s| Decimal::from_str(s).ok()) {
4885 Some(OrderKind::TrailingStop {
4886 offset: price,
4887 offset_type: TrailingOffsetType::Absolute,
4888 activation_price: None,
4889 })
4890 } else {
4891 trace!("Alpaca: trailing_stop missing trail_percent and trail_price");
4892 None
4893 }
4894 }
4895 other => {
4896 trace!(%other, "Alpaca: unsupported order type, skipping");
4897 None
4898 }
4899 }
4900}
4901
4902fn parse_time_in_force(s: &str) -> TimeInForce {
4903 match s {
4904 "gtc" | "GTC" => TimeInForce::GoodUntilCancelled { post_only: false },
4905 "day" | "DAY" => TimeInForce::GoodUntilEndOfDay,
4906 "fok" | "FOK" => TimeInForce::FillOrKill,
4907 "ioc" | "IOC" => TimeInForce::ImmediateOrCancel,
4908 other => {
4909 warn!(%other, "Alpaca: unknown time_in_force, defaulting to GoodUntilEndOfDay");
4910 TimeInForce::GoodUntilEndOfDay
4911 }
4912 }
4913}
4914
4915fn order_state_time(order: &AlpacaOrderResponse) -> DateTime<Utc> {
4925 order
4926 .updated_at
4927 .as_deref()
4928 .and_then(parse_timestamp)
4929 .or_else(|| parse_timestamp(&order.created_at))
4930 .unwrap_or_else(Utc::now)
4931}
4932
4933fn alpaca_filled_qty(order_id: &str, filled_qty: Option<&str>) -> Option<Decimal> {
4936 let Some(raw) = filled_qty else {
4937 warn!(%order_id, "Alpaca did not report how much the order filled");
4938 return None;
4939 };
4940 match Decimal::from_str(raw) {
4941 Ok(filled_qty) => Some(filled_qty),
4942 Err(_) => {
4943 warn!(%order_id, filled_qty = raw, "Alpaca reported an unparseable filled_qty, treating it as unknown");
4944 None
4945 }
4946 }
4947}
4948
4949fn convert_cash_flow(
4952 activity: &AlpacaNonTradeActivity,
4953) -> Option<CashFlow<AssetNameExchange, InstrumentNameExchange>> {
4954 let id = activity.id.as_str();
4955 if activity.status.as_deref() == Some("canceled") {
4956 info!(id, activity_type = %activity.activity_type, "Alpaca activity canceled — left out");
4957 return None;
4958 }
4959 let Some(amount) = activity
4960 .net_amount
4961 .as_deref()
4962 .and_then(|amount| Decimal::from_str(amount).ok())
4963 else {
4964 warn!(id, net_amount = ?activity.net_amount, "Alpaca activity missing/unparseable net_amount — left out");
4965 return None;
4966 };
4967 let Some(time_exchange) = activity.time() else {
4968 warn!(
4969 id,
4970 created_at = ?activity.created_at,
4971 date = ?activity.date,
4972 "Alpaca activity missing/unparseable time — left out"
4973 );
4974 return None;
4975 };
4976 let sub_type = activity
4977 .activity_sub_type
4978 .as_deref()
4979 .filter(|sub_type| !sub_type.is_empty());
4980 let kind = match (activity.activity_type.as_str(), sub_type) {
4981 ("INT", Some("MGN")) => CashFlowKind::MarginInterest {
4982 rate: None,
4983 principal: None,
4984 },
4985 ("INT", None) if amount.is_sign_negative() => CashFlowKind::MarginInterest {
4987 rate: None,
4988 principal: None,
4989 },
4990 ("PTR", _) => CashFlowKind::Rebate,
4991 (activity_type, Some(sub_type)) => {
4992 CashFlowKind::Other(format!("{activity_type}.{sub_type}"))
4993 }
4994 (activity_type, None) => CashFlowKind::Other(activity_type.to_owned()),
4995 };
4996 Some(CashFlow::new(
4997 kind,
4998 AssetNameExchange::new(
4999 activity
5000 .currency
5001 .as_deref()
5002 .filter(|currency| !currency.is_empty())
5003 .unwrap_or("USD"),
5004 ),
5005 amount,
5006 activity
5007 .symbol
5008 .as_deref()
5009 .filter(|symbol| !symbol.is_empty())
5010 .map(InstrumentNameExchange::new),
5011 time_exchange,
5012 Some(CashFlowId::new(id)),
5013 ))
5014}
5015
5016struct OptionLeg<'a> {
5018 id: &'a str,
5019 symbol: &'a str,
5020 quantity: Decimal,
5022 price: Option<Decimal>,
5024 date: Option<NaiveDate>,
5025 time: DateTime<Utc>,
5026 created: bool,
5028 origin: Option<TradeOrigin>,
5030 contract: Option<(&'a str, bool, Decimal)>,
5032}
5033
5034impl OptionLeg<'_> {
5035 fn is_within(&self, start: DateTime<Utc>, end: DateTime<Utc>) -> bool {
5038 if self.created {
5039 (start..=end).contains(&self.time)
5040 } else {
5041 (start.date_naive()..=end.date_naive()).contains(&self.time.date_naive())
5042 }
5043 }
5044}
5045
5046fn option_leg(activity: &AlpacaNonTradeActivity) -> Option<OptionLeg<'_>> {
5049 let id = activity.id.as_str();
5050 let activity_type = activity.activity_type.as_str();
5051 if activity.status.as_deref() == Some("canceled") {
5052 info!(
5053 id,
5054 activity_type, "Alpaca option activity canceled — left out"
5055 );
5056 return None;
5057 }
5058 let origin = match activity_type {
5059 "OPEXP" => Some(TradeOrigin::Expiry),
5060 "OPEXC" => Some(TradeOrigin::Exercise),
5061 "OPASN" => Some(TradeOrigin::Assignment),
5062 "OPTRD" => None,
5063 _ => {
5064 warn!(
5065 id,
5066 activity_type, "Alpaca activity is not an option event — left out"
5067 );
5068 return None;
5069 }
5070 };
5071 let date = activity
5072 .date
5073 .as_deref()
5074 .and_then(|date| NaiveDate::parse_from_str(date, "%Y-%m-%d").ok());
5075 let created_at = activity.created_at.as_deref().and_then(parse_timestamp);
5076 let time = created_at.or_else(|| date.map(|date| date.and_time(NaiveTime::MIN).and_utc()));
5077 let symbol = activity
5078 .symbol
5079 .as_deref()
5080 .filter(|symbol| !symbol.is_empty());
5081 let quantity = activity
5082 .qty
5083 .as_deref()
5084 .and_then(|qty| Decimal::from_str(qty).ok())
5085 .filter(|qty| !qty.is_zero());
5086 let price = activity
5087 .price
5088 .as_deref()
5089 .and_then(|price| Decimal::from_str(price).ok());
5090 let (Some(symbol), Some(quantity), Some(time)) = (symbol, quantity, time) else {
5091 warn!(
5092 id,
5093 activity_type,
5094 symbol = ?activity.symbol,
5095 qty = ?activity.qty,
5096 created_at = ?activity.created_at,
5097 date = ?activity.date,
5098 "Alpaca option activity missing/unparseable symbol, quantity or time — left out"
5099 );
5100 return None;
5101 };
5102 if origin.is_none() && price.is_none() {
5103 warn!(id, price = ?activity.price, "Alpaca OPTRD missing/unparseable price — left out");
5104 return None;
5105 }
5106 let contract = matches!(
5107 origin,
5108 Some(TradeOrigin::Exercise | TradeOrigin::Assignment)
5109 )
5110 .then(|| occ_contract(symbol))
5111 .flatten();
5112 Some(OptionLeg {
5113 id,
5114 symbol,
5115 quantity,
5116 price,
5117 date,
5118 time,
5119 created: created_at.is_some(),
5120 origin,
5121 contract,
5122 })
5123}
5124
5125fn occ_contract(symbol: &str) -> Option<(&str, bool, Decimal)> {
5128 let split = symbol.len().checked_sub(15)?;
5129 let (root, tail) = (
5130 symbol.get(..split)?.trim_end(),
5131 symbol.get(split..)?.as_bytes(),
5132 );
5133 let is_call = match tail[6] {
5134 b'C' => true,
5135 b'P' => false,
5136 _ => return None,
5137 };
5138 if root.is_empty() || !tail[..6].iter().chain(&tail[7..]).all(u8::is_ascii_digit) {
5139 return None;
5140 }
5141 let strike = std::str::from_utf8(&tail[7..]).ok()?.parse::<i64>().ok()?;
5142 Some((root, is_call, Decimal::new(strike, 3)))
5143}
5144
5145fn is_share_leg_of(shares: &OptionLeg<'_>, contract: &OptionLeg<'_>) -> bool {
5148 let Some((root, is_call, strike)) = contract.contract else {
5149 return false;
5150 };
5151 let buys = match contract.origin {
5153 Some(TradeOrigin::Exercise) => is_call,
5154 Some(TradeOrigin::Assignment) => !is_call,
5155 _ => return false,
5156 };
5157 shares.date.is_some()
5158 && shares.date == contract.date
5159 && shares.symbol == root
5160 && shares.price == Some(strike)
5161 && shares.quantity.is_sign_positive() == buys
5162}
5163
5164fn convert_option_events(
5168 activities: &[AlpacaNonTradeActivity],
5169 start: DateTime<Utc>,
5170 end: DateTime<Utc>,
5171) -> Vec<Trade<AssetNameExchange, InstrumentNameExchange>> {
5172 let legs: Vec<_> = activities.iter().filter_map(option_leg).collect();
5173 let mut paired = vec![false; legs.len()];
5174 legs.iter()
5175 .filter_map(|leg| {
5176 let origin = leg.origin.unwrap_or_else(|| {
5179 let pairs = |index: &usize| !paired[*index] && is_share_leg_of(leg, &legs[*index]);
5180 let delivers_its_shares = |index: &usize| {
5184 legs[*index].quantity.abs() * Decimal::ONE_HUNDRED == leg.quantity.abs()
5185 };
5186 let partner = (0..legs.len())
5187 .filter(pairs)
5188 .find(delivers_its_shares)
5189 .or_else(|| (0..legs.len()).find(pairs));
5190 match partner {
5191 Some(index) => {
5192 paired[index] = true;
5193 legs[index].origin.unwrap_or(TradeOrigin::External)
5194 }
5195 None => {
5196 warn!(
5197 id = leg.id,
5198 symbol = leg.symbol,
5199 date = ?leg.date,
5200 "Alpaca OPTRD pairs with no exercise or assignment read — reported as \
5201 External"
5202 );
5203 TradeOrigin::External
5204 }
5205 }
5206 });
5207 if !leg.is_within(start, end) {
5208 return None;
5209 }
5210 let side = if leg.quantity.is_sign_negative() {
5211 Side::Sell
5212 } else {
5213 Side::Buy
5214 };
5215 let trade = Trade::new(
5216 TradeId::new(leg.id),
5217 OrderId::new(leg.id),
5218 InstrumentNameExchange::new(leg.symbol),
5219 StrategyId::unknown(),
5220 leg.time,
5221 side,
5222 leg.price.unwrap_or(Decimal::ZERO),
5223 leg.quantity.abs(),
5224 None,
5225 AssetFees::new(
5226 AssetNameExchange::from(USD),
5227 Decimal::ZERO,
5228 Some(Decimal::ZERO),
5229 ),
5230 )
5231 .with_origin(origin);
5232 Some(trade)
5233 })
5234 .collect()
5235}
5236
5237fn with_reported_cid<AssetKey, InstrumentKey>(
5239 trade: Trade<AssetKey, InstrumentKey>,
5240 client_order_id: Option<&SmolStr>,
5241) -> Trade<AssetKey, InstrumentKey> {
5242 match client_order_id {
5243 Some(cid) if !cid.is_empty() => trade.with_client_order_id(ClientOrderId(cid.clone())),
5245 _ => trade,
5246 }
5247}
5248
5249fn parse_timestamp(s: &str) -> Option<DateTime<Utc>> {
5250 DateTime::parse_from_rfc3339(s)
5251 .ok()
5252 .map(|dt| dt.with_timezone(&Utc))
5253}
5254
5255fn map_side(side: Side) -> &'static str {
5256 match side {
5257 Side::Buy => "buy",
5258 Side::Sell => "sell",
5259 }
5260}
5261
5262fn map_order_kind(kind: OrderKind) -> Option<&'static str> {
5263 match kind {
5264 OrderKind::Market => Some("market"),
5265 OrderKind::Limit => Some("limit"),
5266 OrderKind::Stop { .. } => Some("stop"),
5267 OrderKind::StopLimit { .. } => Some("stop_limit"),
5268 OrderKind::TrailingStop {
5269 activation_price: None,
5270 ..
5271 } => Some("trailing_stop"),
5272 OrderKind::TakeProfit { .. }
5275 | OrderKind::TakeProfitLimit { .. }
5276 | OrderKind::TrailingStop {
5277 activation_price: Some(_),
5278 ..
5279 }
5280 | OrderKind::TrailingStopLimit { .. }
5281 | OrderKind::TrailingStopFixedLimit { .. } => None,
5282 }
5283}
5284
5285fn map_time_in_force(tif: TimeInForce) -> Result<&'static str, &'static str> {
5294 match tif {
5295 TimeInForce::GoodUntilCancelled { post_only } => {
5296 if post_only {
5297 return Err("Alpaca does not support post_only orders");
5298 }
5299 Ok("gtc")
5300 }
5301 TimeInForce::GoodUntilEndOfDay => Ok("day"),
5302 TimeInForce::FillOrKill => Ok("fok"),
5303 TimeInForce::ImmediateOrCancel => Ok("ioc"),
5304 TimeInForce::AtOpen => Ok("opg"),
5305 TimeInForce::AtClose => Ok("cls"),
5306 TimeInForce::GoodTillDate { .. } => {
5310 Err("Alpaca GoodTillDate is not yet wired through this client")
5311 }
5312 }
5313}
5314
5315fn is_options_or_equity_symbol(symbol: &str) -> bool {
5326 !symbol.contains('/')
5327}
5328
5329fn map_position_intent(side: Side, reduce_only: bool) -> AlpacaPositionIntent {
5338 match (reduce_only, side) {
5339 (false, Side::Buy) => AlpacaPositionIntent::BuyToOpen,
5340 (false, Side::Sell) => AlpacaPositionIntent::SellToOpen,
5341 (true, Side::Buy) => AlpacaPositionIntent::BuyToClose,
5342 (true, Side::Sell) => AlpacaPositionIntent::SellToClose,
5343 }
5344}
5345
5346fn parse_api_error(
5357 status: reqwest::StatusCode,
5358 code: Option<i64>,
5359 message: &str,
5360) -> crate::error::UnindexedApiError {
5361 if status == reqwest::StatusCode::TOO_MANY_REQUESTS {
5364 return ApiError::rate_limited(None);
5365 }
5366
5367 let lower = message.to_ascii_lowercase();
5369 match status.as_u16() {
5370 422 if lower.contains("already") => ApiError::OrderAlreadyCancelled,
5374 422 => parse_business_refusal(code, message, &lower),
5375 401 => ApiError::Unauthenticated(format!("unauthorized: {message}")),
5376 403 => match code {
5383 None => ApiError::Unauthenticated(format!("forbidden: {message}")),
5384 Some(_) => parse_business_refusal(code, message, &lower),
5385 },
5386 404 => ApiError::OrderRejected(format!("order not found: {message}")),
5387 _ => ApiError::OrderRejected(with_alpaca_code(code, message)),
5388 }
5389}
5390
5391fn parse_business_refusal(code: Option<i64>, message: &str, lower: &str) -> UnindexedApiError {
5405 let borrow = if lower.contains("not allowed to short") {
5406 Some(BorrowRejectReason::BorrowDisabled)
5407 } else if lower.contains("cannot be sold short") && !lower.contains("fractional") {
5408 Some(BorrowRejectReason::NotShortable)
5409 } else {
5410 None
5411 };
5412 if let Some(reason) = borrow {
5413 ApiError::BorrowRejected(BorrowReject::new(
5414 reason,
5415 code.map(|code| code.to_string()),
5416 message.to_owned(),
5417 ))
5418 } else if lower.contains("insufficient") {
5419 ApiError::BalanceInsufficient(None, with_alpaca_code(code, message))
5420 } else {
5421 ApiError::OrderRejected(with_alpaca_code(code, message))
5422 }
5423}
5424
5425fn with_alpaca_code(code: Option<i64>, message: &str) -> String {
5427 match code {
5428 Some(code) => format!("{code} {message}"),
5429 None => message.to_owned(),
5430 }
5431}
5432
5433fn parse_order_error(
5435 status: reqwest::StatusCode,
5436 code: Option<i64>,
5437 message: &str,
5438) -> UnindexedOrderError {
5439 UnindexedOrderError::Rejected(parse_api_error(status, code, message))
5440}
5441
5442fn connectivity_err(msg: impl Into<String>) -> UnindexedClientError {
5443 UnindexedClientError::Connectivity(ConnectivityError::Socket(msg.into()))
5444}
5445
5446fn order_post_error(error: UnindexedClientError) -> UnindexedOrderError {
5453 match error {
5454 UnindexedClientError::Connectivity(ce) => OrderError::Connectivity(ce),
5455 UnindexedClientError::Api(ae) => OrderError::Rejected(ae),
5456 UnindexedClientError::Internal(msg) => OrderError::Connectivity(ConnectivityError::Socket(
5457 format!("Alpaca order status unknown, its 2xx response could not be read: {msg}"),
5458 )),
5459 other @ (UnindexedClientError::TaskFailed(_)
5463 | UnindexedClientError::Truncated { .. }
5464 | UnindexedClientError::TruncatedSnapshot { .. }) => OrderError::Connectivity(
5465 ConnectivityError::Socket(format!("Alpaca order status unknown: {other}")),
5466 ),
5467 }
5468}
5469
5470impl OrderStatusClient for AlpacaClient {
5475 async fn fetch_ended_orders(
5483 &self,
5484 orders: &[UnindexedOrderKey],
5485 ) -> Result<Vec<UnindexedInactiveOrder>, UnindexedClientError> {
5486 fetch_ended_by_key(orders, |key| {
5487 fetch_order_lookup(
5488 self.http.clone(),
5489 self.rate_limiter.clone(),
5490 self.config.clone(),
5491 key,
5492 )
5493 })
5494 .await
5495 }
5496}
5497
5498impl ShortabilityClient for AlpacaClient {
5503 async fn fetch_shortability(
5521 &self,
5522 instrument: &InstrumentNameExchange,
5523 ) -> Result<Shortability, UnindexedClientError> {
5524 let url = asset_url(self.base_url(), instrument)?;
5525 let found: Option<AlpacaAsset> =
5526 rest_lookup_with_retry(&self.rate_limiter, || self.http.get(url.clone())).await?;
5527 let Some(asset) = found else {
5528 return Err(instrument_invalid(
5529 instrument,
5530 "Alpaca knows no asset under this symbol",
5531 ));
5532 };
5533 shortability_from_asset(instrument, &asset)
5534 }
5535}
5536
5537fn asset_url(
5540 base_url: &str,
5541 instrument: &InstrumentNameExchange,
5542) -> Result<reqwest::Url, UnindexedClientError> {
5543 let mut url = reqwest::Url::parse(base_url).map_err(|error| {
5544 UnindexedClientError::Internal(format!("Alpaca base URL {base_url:?}: {error}"))
5545 })?;
5546 url.path_segments_mut()
5547 .map_err(|()| {
5548 UnindexedClientError::Internal(format!("Alpaca base URL {base_url:?} has no path"))
5549 })?
5550 .pop_if_empty()
5551 .extend(["v2", "assets", instrument.name().as_str()]);
5552 Ok(url)
5553}
5554
5555fn instrument_invalid(instrument: &InstrumentNameExchange, reason: &str) -> UnindexedClientError {
5556 UnindexedClientError::Api(ApiError::InstrumentInvalid(
5557 instrument.clone(),
5558 reason.to_owned(),
5559 ))
5560}
5561
5562fn shortability_from_asset(
5564 instrument: &InstrumentNameExchange,
5565 asset: &AlpacaAsset,
5566) -> Result<Shortability, UnindexedClientError> {
5567 if asset.class == AlpacaAssetClass::UsOption {
5568 return Err(instrument_invalid(
5569 instrument,
5570 "shortability does not apply to an option: writing one borrows nothing",
5571 ));
5572 }
5573 let easy_to_borrow = match asset.borrow_status {
5574 Some(AlpacaBorrowStatus::EasyToBorrow) => Some(true),
5575 Some(AlpacaBorrowStatus::HardToBorrow) => Some(false),
5576 Some(AlpacaBorrowStatus::Other) | None => asset.easy_to_borrow,
5577 };
5578 let mut shortability = Shortability::new();
5579 if let Some(shortable) = asset.shortable {
5580 shortability = shortability.with_shortable(shortable);
5581 }
5582 if let Some(easy_to_borrow) = easy_to_borrow {
5583 shortability = shortability.with_easy_to_borrow(easy_to_borrow);
5584 if easy_to_borrow {
5585 shortability = shortability.with_fee_rate(Decimal::ZERO);
5586 }
5587 }
5588 Ok(shortability)
5589}
5590
5591#[derive(Debug, Deserialize)]
5594struct AlpacaAsset {
5595 #[serde(default)]
5596 class: AlpacaAssetClass,
5597 #[serde(default)]
5598 shortable: Option<bool>,
5599 #[serde(default)]
5601 borrow_status: Option<AlpacaBorrowStatus>,
5602 #[serde(default)]
5604 easy_to_borrow: Option<bool>,
5605}
5606
5607#[derive(Debug, Default, PartialEq, Eq, Deserialize)]
5608#[serde(rename_all = "snake_case")]
5609enum AlpacaAssetClass {
5610 UsOption,
5611 #[default]
5612 #[serde(other)]
5613 Other,
5614}
5615
5616#[derive(Debug, PartialEq, Eq, Deserialize)]
5617#[serde(rename_all = "snake_case")]
5618enum AlpacaBorrowStatus {
5619 EasyToBorrow,
5620 HardToBorrow,
5621 #[serde(other)]
5622 Other,
5623}
5624
5625impl BracketOrderClient for AlpacaClient {
5630 async fn open_bracket_order(
5631 &self,
5632 request: UnifiedBracketOrderRequest<ExchangeId, &InstrumentNameExchange>,
5633 ) -> UnifiedBracketOrderResult {
5634 let alpaca_request = AlpacaBracketOrderRequest {
5635 instrument: request.key.instrument.clone(),
5636 strategy: request.key.strategy.clone(),
5637 cid: request.key.cid.clone(),
5638 side: request.state.side,
5639 quantity: request.state.quantity,
5640 entry_price: request.state.entry_price,
5641 take_profit_price: request.state.take_profit_price,
5642 stop_loss_price: request.state.stop_loss_price,
5643 stop_loss_limit_price: request.state.stop_loss_limit_price,
5644 time_in_force: request.state.time_in_force,
5645 };
5646
5647 let result = self.open_bracket_order(alpaca_request).await;
5648
5649 UnifiedBracketOrderResult::parent_only(result.parent)
5650 }
5651}
5652
5653#[cfg(test)]
5658#[allow(clippy::unwrap_used, clippy::expect_used)] mod tests {
5660 use super::*;
5661 use rust_decimal_macros::dec;
5662
5663 #[test]
5664 fn test_alpaca_config_new_uses_paper_trading_by_default() {
5665 let cfg = AlpacaConfig::new("my_key".into(), "my_secret".into());
5666 assert!(cfg.paper);
5667 }
5668
5669 #[test]
5670 fn test_alpaca_config_debug_redacts_credentials() {
5671 let cfg = AlpacaConfig::new("my_key".into(), "my_secret".into());
5672 let debug = format!("{cfg:?}");
5673 assert!(!debug.contains("my_key"), "api_key should be redacted");
5674 assert!(
5675 !debug.contains("my_secret"),
5676 "secret_key should be redacted"
5677 );
5678 assert!(debug.contains("paper: true"));
5679 }
5680
5681 #[test]
5682 fn test_alpaca_config_deserialize_omitted_paper_defaults_to_paper() {
5683 let cfg: AlpacaConfig = serde_json::from_str(
5684 r#"{
5685 "api_key": "my_key",
5686 "secret_key": "my_secret"
5687 }"#,
5688 )
5689 .unwrap();
5690
5691 assert!(cfg.paper);
5692 assert_eq!(cfg.rest_base_url(), "https://paper-api.alpaca.markets");
5693 }
5694
5695 #[test]
5696 fn test_alpaca_config_deserialize_paper_true() {
5697 let cfg: AlpacaConfig = serde_json::from_str(
5698 r#"{
5699 "api_key": "my_key",
5700 "secret_key": "my_secret",
5701 "paper": true
5702 }"#,
5703 )
5704 .unwrap();
5705
5706 assert!(cfg.paper);
5707 assert_eq!(cfg.rest_base_url(), "https://paper-api.alpaca.markets");
5708 }
5709
5710 #[test]
5711 fn test_alpaca_config_deserialize_paper_false() {
5712 let cfg: AlpacaConfig = serde_json::from_str(
5713 r#"{
5714 "api_key": "my_key",
5715 "secret_key": "my_secret",
5716 "paper": false
5717 }"#,
5718 )
5719 .unwrap();
5720
5721 assert!(!cfg.paper);
5722 assert_eq!(cfg.rest_base_url(), "https://api.alpaca.markets");
5723 }
5724
5725 #[test]
5726 fn test_alpaca_config_urls() {
5727 let paper = AlpacaConfig::paper("k".into(), "s".into());
5728 assert!(paper.rest_base_url().contains("paper-api"));
5729 assert!(paper.ws_url().contains("paper-api"));
5730
5731 let live = AlpacaConfig::production("k".into(), "s".into());
5732 assert!(!live.rest_base_url().contains("paper-api"));
5733 assert!(!live.ws_url().contains("paper-api"));
5734 }
5735
5736 #[test]
5737 #[serial_test::serial]
5738 fn test_alpaca_config_from_env_defaults_to_paper_trading() {
5739 temp_env::with_vars(
5740 [
5741 ("ALPACA_API_KEY", Some("my_key")),
5742 ("ALPACA_SECRET_KEY", Some("my_secret")),
5743 ("ALPACA_PAPER", None),
5744 ],
5745 || {
5746 let cfg = AlpacaConfig::from_env().unwrap();
5747 assert!(cfg.paper);
5748 assert_eq!(cfg.rest_base_url(), "https://paper-api.alpaca.markets");
5749 },
5750 );
5751 }
5752
5753 #[test]
5754 #[serial_test::serial]
5755 fn test_alpaca_config_from_env_accepts_explicit_production() {
5756 temp_env::with_vars(
5757 [
5758 ("ALPACA_API_KEY", Some("my_key")),
5759 ("ALPACA_SECRET_KEY", Some("my_secret")),
5760 ("ALPACA_PAPER", Some("false")),
5761 ],
5762 || {
5763 let cfg = AlpacaConfig::from_env().unwrap();
5764 assert!(!cfg.paper);
5765 assert_eq!(cfg.rest_base_url(), "https://api.alpaca.markets");
5766 },
5767 );
5768 }
5769
5770 #[test]
5771 #[serial_test::serial]
5772 fn test_alpaca_config_from_env_rejects_invalid_paper() {
5773 temp_env::with_vars(
5774 [
5775 ("ALPACA_API_KEY", Some("my_key")),
5776 ("ALPACA_SECRET_KEY", Some("my_secret")),
5777 ("ALPACA_PAPER", Some("maybe")),
5778 ],
5779 || {
5780 let err = AlpacaConfig::from_env().unwrap_err();
5781 assert!(matches!(err, AlpacaConfigError::InvalidPaper(value) if value == "maybe"));
5782 },
5783 );
5784 }
5785
5786 #[test]
5787 #[serial_test::serial]
5788 fn test_alpaca_config_from_env_requires_credentials() {
5789 temp_env::with_vars(
5790 [
5791 ("ALPACA_API_KEY", None),
5792 ("ALPACA_SECRET_KEY", Some("my_secret")),
5793 ("ALPACA_PAPER", None),
5794 ],
5795 || {
5796 let err = AlpacaConfig::from_env().unwrap_err();
5797 assert!(matches!(err, AlpacaConfigError::MissingApiKey));
5798 },
5799 );
5800 }
5801
5802 #[test]
5803 fn test_parse_side() {
5804 assert_eq!(parse_side("buy"), Some(Side::Buy));
5805 assert_eq!(parse_side("sell"), Some(Side::Sell));
5806 assert_eq!(parse_side("Buy"), Some(Side::Buy));
5807 assert_eq!(parse_side("BUY"), Some(Side::Buy));
5808 assert_eq!(parse_side("unknown"), None);
5809 }
5810
5811 #[test]
5812 fn test_parse_order_kind() {
5813 assert_eq!(
5815 parse_order_kind("market", None, None, None),
5816 Some(OrderKind::Market)
5817 );
5818 assert_eq!(
5819 parse_order_kind("limit", None, None, None),
5820 Some(OrderKind::Limit)
5821 );
5822
5823 assert_eq!(
5825 parse_order_kind("stop", Some("150.00"), None, None),
5826 Some(OrderKind::Stop {
5827 trigger_price: Decimal::from_str("150.00").unwrap()
5828 })
5829 );
5830 assert_eq!(parse_order_kind("stop", None, None, None), None);
5831
5832 assert_eq!(
5834 parse_order_kind("stop_limit", Some("145.00"), None, None),
5835 Some(OrderKind::StopLimit {
5836 trigger_price: Decimal::from_str("145.00").unwrap()
5837 })
5838 );
5839
5840 assert_eq!(
5842 parse_order_kind("trailing_stop", None, Some("5.0"), None),
5843 Some(OrderKind::TrailingStop {
5844 offset: Decimal::from_str("5.0").unwrap(),
5845 offset_type: TrailingOffsetType::Percentage,
5846 activation_price: None,
5847 })
5848 );
5849
5850 assert_eq!(
5852 parse_order_kind("trailing_stop", None, None, Some("2.50")),
5853 Some(OrderKind::TrailingStop {
5854 offset: Decimal::from_str("2.50").unwrap(),
5855 offset_type: TrailingOffsetType::Absolute,
5856 activation_price: None,
5857 })
5858 );
5859
5860 assert_eq!(parse_order_kind("trailing_stop", None, None, None), None);
5862
5863 assert_eq!(parse_order_kind("unknown", None, None, None), None);
5865 }
5866
5867 #[test]
5868 fn test_map_order_kind() {
5869 assert_eq!(map_order_kind(OrderKind::Market), Some("market"));
5870 assert_eq!(map_order_kind(OrderKind::Limit), Some("limit"));
5871 assert_eq!(
5872 map_order_kind(OrderKind::Stop {
5873 trigger_price: Decimal::from_str("150.00").unwrap()
5874 }),
5875 Some("stop")
5876 );
5877 assert_eq!(
5878 map_order_kind(OrderKind::StopLimit {
5879 trigger_price: Decimal::from_str("145.00").unwrap()
5880 }),
5881 Some("stop_limit")
5882 );
5883 assert_eq!(
5884 map_order_kind(OrderKind::TrailingStop {
5885 offset: Decimal::from_str("5.0").unwrap(),
5886 offset_type: TrailingOffsetType::Percentage,
5887 activation_price: None,
5888 }),
5889 Some("trailing_stop")
5890 );
5891 assert_eq!(
5892 map_order_kind(OrderKind::TrailingStop {
5893 offset: Decimal::from_str("2.50").unwrap(),
5894 offset_type: TrailingOffsetType::Absolute,
5895 activation_price: None,
5896 }),
5897 Some("trailing_stop")
5898 );
5899 assert_eq!(
5901 map_order_kind(OrderKind::TrailingStop {
5902 offset: Decimal::from_str("5.0").unwrap(),
5903 offset_type: TrailingOffsetType::Percentage,
5904 activation_price: Some(Decimal::from(100)),
5905 }),
5906 None
5907 );
5908 assert_eq!(
5909 map_order_kind(OrderKind::TrailingStopFixedLimit {
5910 offset: Decimal::from_str("5.0").unwrap(),
5911 offset_type: TrailingOffsetType::Percentage,
5912 activation_price: None,
5913 }),
5914 None
5915 );
5916 assert_eq!(
5918 map_order_kind(OrderKind::TrailingStopLimit {
5919 offset: Decimal::from_str("5.0").unwrap(),
5920 offset_type: TrailingOffsetType::Percentage,
5921 stop_price: Decimal::from_str("95.0").unwrap(),
5922 limit_offset: Decimal::from_str("1.0").unwrap(),
5923 }),
5924 None
5925 );
5926 assert_eq!(
5928 map_order_kind(OrderKind::TakeProfit {
5929 trigger_price: Decimal::from_str("160.00").unwrap()
5930 }),
5931 None
5932 );
5933 assert_eq!(
5934 map_order_kind(OrderKind::TakeProfitLimit {
5935 trigger_price: Decimal::from_str("160.00").unwrap()
5936 }),
5937 None
5938 );
5939 }
5940
5941 #[test]
5942 fn test_map_time_in_force_roundtrip() {
5943 assert_eq!(
5944 map_time_in_force(TimeInForce::GoodUntilCancelled { post_only: false }),
5945 Ok("gtc")
5946 );
5947 assert_eq!(map_time_in_force(TimeInForce::GoodUntilEndOfDay), Ok("day"));
5948 assert_eq!(map_time_in_force(TimeInForce::FillOrKill), Ok("fok"));
5949 assert_eq!(map_time_in_force(TimeInForce::ImmediateOrCancel), Ok("ioc"));
5950 }
5951
5952 #[test]
5953 fn test_map_time_in_force_rejects_post_only() {
5954 let result = map_time_in_force(TimeInForce::GoodUntilCancelled { post_only: true });
5955 assert!(result.is_err(), "post_only must be rejected");
5956 assert!(result.unwrap_err().contains("post_only"));
5957 }
5958
5959 #[test]
5964 fn test_bracket_order_serializes_with_stop_loss_stop_order() {
5965 let body = AlpacaOrderRequest {
5967 symbol: "AAPL",
5968 qty: "10".to_string(),
5969 side: "buy",
5970 order_type: "limit",
5971 time_in_force: "gtc",
5972 limit_price: Some("150.00".to_string()),
5973 stop_price: None,
5974 trail_percent: None,
5975 trail_price: None,
5976 client_order_id: Some("bracket-001"),
5977 position_intent: Some(AlpacaPositionIntent::BuyToOpen),
5978 order_class: Some("bracket"),
5979 take_profit: Some(TakeProfitParams {
5980 limit_price: "160.00".to_string(),
5981 }),
5982 stop_loss: Some(StopLossParams {
5983 stop_price: "145.00".to_string(),
5984 limit_price: None,
5985 }),
5986 };
5987
5988 let json = serde_json::to_value(&body).unwrap();
5989
5990 assert_eq!(json["symbol"], "AAPL");
5991 assert_eq!(json["qty"], "10");
5992 assert_eq!(json["side"], "buy");
5993 assert_eq!(json["type"], "limit");
5994 assert_eq!(json["time_in_force"], "gtc");
5995 assert_eq!(json["limit_price"], "150.00");
5996 assert_eq!(json["order_class"], "bracket");
5997
5998 assert_eq!(json["take_profit"]["limit_price"], "160.00");
6000
6001 assert_eq!(json["stop_loss"]["stop_price"], "145.00");
6003 assert!(
6004 json["stop_loss"].get("limit_price").is_none(),
6005 "stop_loss.limit_price should be omitted when None"
6006 );
6007 }
6008
6009 #[test]
6010 fn test_bracket_order_serializes_with_stop_loss_stop_limit_order() {
6011 let body = AlpacaOrderRequest {
6013 symbol: "SPY",
6014 qty: "5".to_string(),
6015 side: "sell",
6016 order_type: "limit",
6017 time_in_force: "day",
6018 limit_price: Some("450.00".to_string()),
6019 stop_price: None,
6020 trail_percent: None,
6021 trail_price: None,
6022 client_order_id: Some("bracket-002"),
6023 position_intent: Some(AlpacaPositionIntent::SellToClose),
6024 order_class: Some("bracket"),
6025 take_profit: Some(TakeProfitParams {
6026 limit_price: "440.00".to_string(),
6027 }),
6028 stop_loss: Some(StopLossParams {
6029 stop_price: "455.00".to_string(),
6030 limit_price: Some("456.00".to_string()),
6031 }),
6032 };
6033
6034 let json = serde_json::to_value(&body).unwrap();
6035
6036 assert_eq!(json["symbol"], "SPY");
6037 assert_eq!(json["side"], "sell");
6038 assert_eq!(json["time_in_force"], "day");
6039 assert_eq!(json["order_class"], "bracket");
6040
6041 assert_eq!(json["take_profit"]["limit_price"], "440.00");
6043
6044 assert_eq!(json["stop_loss"]["stop_price"], "455.00");
6046 assert_eq!(
6047 json["stop_loss"]["limit_price"], "456.00",
6048 "stop_loss.limit_price should be present for stop-limit orders"
6049 );
6050 }
6051
6052 #[tokio::test]
6057 async fn test_open_bracket_order_rejects_invalid_tif() {
6058 use rust_decimal_macros::dec;
6059 use rustrade_instrument::instrument::name::InstrumentNameExchange;
6060
6061 let config = AlpacaConfig::new("dummy_key".into(), "dummy_secret".into());
6063 let client = AlpacaClient::new(config);
6064
6065 let request = AlpacaBracketOrderRequest::new(
6066 InstrumentNameExchange::new("SPY"),
6067 crate::order::id::StrategyId::new("test"),
6068 crate::order::id::ClientOrderId::new("test-tif"),
6069 Side::Buy,
6070 dec!(1),
6071 dec!(100.00),
6072 dec!(120.00),
6073 dec!(90.00),
6074 TimeInForce::ImmediateOrCancel, );
6076
6077 let result = client.open_bracket_order(request).await;
6078
6079 assert!(
6080 result.parent.state.is_failed(),
6081 "Bracket order with IOC TIF should be rejected locally"
6082 );
6083 }
6084
6085 #[tokio::test]
6086 async fn test_open_bracket_order_rejects_invalid_price_ordering() {
6087 use rust_decimal_macros::dec;
6088 use rustrade_instrument::instrument::name::InstrumentNameExchange;
6089
6090 let config = AlpacaConfig::new("dummy_key".into(), "dummy_secret".into());
6091 let client = AlpacaClient::new(config);
6092
6093 let request = AlpacaBracketOrderRequest::new(
6095 InstrumentNameExchange::new("SPY"),
6096 crate::order::id::StrategyId::new("test"),
6097 crate::order::id::ClientOrderId::new("test-price"),
6098 Side::Buy,
6099 dec!(1),
6100 dec!(100.00),
6101 dec!(120.00),
6102 dec!(105.00), TimeInForce::GoodUntilCancelled { post_only: false },
6104 );
6105
6106 let result = client.open_bracket_order(request).await;
6107
6108 assert!(
6109 result.parent.state.is_failed(),
6110 "Bracket order with invalid price ordering should be rejected locally"
6111 );
6112 }
6113
6114 #[tokio::test]
6115 async fn test_open_bracket_order_rejects_invalid_sl_limit_price() {
6116 use rust_decimal_macros::dec;
6117 use rustrade_instrument::instrument::name::InstrumentNameExchange;
6118
6119 let config = AlpacaConfig::new("dummy_key".into(), "dummy_secret".into());
6120 let client = AlpacaClient::new(config);
6121
6122 let request = AlpacaBracketOrderRequest::new(
6124 InstrumentNameExchange::new("SPY"),
6125 crate::order::id::StrategyId::new("test"),
6126 crate::order::id::ClientOrderId::new("test-sl-limit"),
6127 Side::Buy,
6128 dec!(1),
6129 dec!(100.00),
6130 dec!(120.00),
6131 dec!(90.00),
6132 TimeInForce::GoodUntilCancelled { post_only: false },
6133 )
6134 .with_stop_loss_limit_price(dec!(95.00)); let result = client.open_bracket_order(request).await;
6137
6138 assert!(
6139 result.parent.state.is_failed(),
6140 "Bracket order with invalid SL limit price should be rejected locally"
6141 );
6142 }
6143
6144 #[test]
6145 fn test_non_bracket_order_omits_bracket_fields() {
6146 let body = AlpacaOrderRequest {
6148 symbol: "AAPL",
6149 qty: "1".to_string(),
6150 side: "buy",
6151 order_type: "limit",
6152 time_in_force: "gtc",
6153 limit_price: Some("150.00".to_string()),
6154 stop_price: None,
6155 trail_percent: None,
6156 trail_price: None,
6157 client_order_id: Some("regular-001"),
6158 position_intent: Some(AlpacaPositionIntent::BuyToOpen),
6159 order_class: None,
6160 take_profit: None,
6161 stop_loss: None,
6162 };
6163
6164 let json = serde_json::to_value(&body).unwrap();
6165
6166 assert_eq!(json["symbol"], "AAPL");
6167 assert!(
6168 json.get("order_class").is_none(),
6169 "order_class should be omitted for non-bracket orders"
6170 );
6171 assert!(
6172 json.get("take_profit").is_none(),
6173 "take_profit should be omitted for non-bracket orders"
6174 );
6175 assert!(
6176 json.get("stop_loss").is_none(),
6177 "stop_loss should be omitted for non-bracket orders"
6178 );
6179 }
6180
6181 #[test]
6182 fn test_parse_timestamp_valid() {
6183 let ts = parse_timestamp("2025-04-18T14:30:00Z");
6184 assert!(ts.is_some());
6185 let ts2 = parse_timestamp("2025-04-18T14:30:00.123456Z");
6186 assert!(ts2.is_some());
6187 assert_eq!(parse_timestamp("not-a-timestamp"), None);
6188 }
6189
6190 #[test]
6191 fn test_check_auth_response_authorized() {
6192 let msg =
6193 r#"{"stream":"authorization","data":{"status":"authorized","action":"authenticate"}}"#;
6194 assert!(matches!(check_auth_response(msg), Some(Ok(()))));
6195 }
6196
6197 #[test]
6198 fn test_check_auth_response_unauthorized() {
6199 let msg = r#"{"stream":"authorization","data":{"status":"unauthorized"}}"#;
6200 assert!(matches!(
6201 check_auth_response(msg),
6202 Some(Err(HandshakeError::Auth(_)))
6203 ));
6204 }
6205
6206 #[test]
6207 fn test_check_auth_response_non_auth_message() {
6208 let msg = r#"{"stream":"trade_updates","data":{}}"#;
6209 assert!(check_auth_response(msg).is_none());
6210 }
6211
6212 #[test]
6213 fn test_check_listen_ack() {
6214 let ack = r#"{"stream":"listening","data":{"streams":["trade_updates"]}}"#;
6215 assert!(check_listen_ack(ack));
6216
6217 let other = r#"{"stream":"authorization","data":{}}"#;
6218 assert!(!check_listen_ack(other));
6219 }
6220
6221 #[test]
6222 fn test_dedup_cache() {
6223 let cache = new_dedup_cache();
6224 let key = || SmolStr::new("order-1:1");
6225 assert!(
6226 !is_duplicate_fill(&cache, Some("exec-1"), key()),
6227 "first time should not be duplicate"
6228 );
6229 assert!(
6230 is_duplicate_fill(&cache, Some("exec-1"), key()),
6231 "second time should be duplicate"
6232 );
6233 }
6234
6235 #[test]
6238 fn dedup_tells_fills_apart_by_execution_id_not_cumulative() {
6239 let cache = new_dedup_cache();
6240 assert!(!is_duplicate_fill(
6241 &cache,
6242 Some("exec-a"),
6243 SmolStr::new("ord-1:5")
6244 ));
6245 assert!(
6247 !is_duplicate_fill(&cache, Some("exec-b"), SmolStr::new("ord-1:5")),
6248 "a different execution at a cumulative seen before is a new fill"
6249 );
6250 }
6251
6252 #[test]
6255 fn dedup_matches_a_fill_with_an_execution_id_to_the_same_fill_without_one() {
6256 let cache = new_dedup_cache();
6257
6258 assert!(!is_duplicate_fill(&cache, None, SmolStr::new("ord-1:2")));
6259 assert!(
6260 is_duplicate_fill(&cache, Some("exec-a"), SmolStr::new("ord-1:2")),
6261 "without an id first, then with one"
6262 );
6263
6264 assert!(!is_duplicate_fill(
6265 &cache,
6266 Some("exec-b"),
6267 SmolStr::new("ord-1:4")
6268 ));
6269 assert!(
6270 is_duplicate_fill(&cache, None, SmolStr::new("ord-1:4")),
6271 "with an id first, then without one"
6272 );
6273 }
6274
6275 #[tokio::test]
6276 async fn test_exponential_backoff_progression_and_exhaustion() {
6277 tokio::time::pause();
6278
6279 let mut b = ExponentialBackoff::new();
6280
6281 assert!(b.wait().await, "first wait should return true");
6283 assert_eq!(b.attempt, 1);
6284
6285 while b.wait().await {}
6287
6288 assert_eq!(b.attempt, MAX_RECONNECT_ATTEMPTS);
6290
6291 assert!(!b.wait().await, "exhausted backoff should return false");
6293
6294 b.reset();
6296 assert_eq!(b.attempt, 0);
6297
6298 assert!(b.wait().await, "wait should succeed after reset");
6300 assert_eq!(b.attempt, 1);
6301 }
6302
6303 #[test]
6304 fn test_convert_account_to_balances_empty_assets() {
6305 let account: AlpacaAccount = serde_json::from_value(serde_json::json!({
6308 "cash": "9000.50",
6309 "equity": "12000.00",
6310 "buying_power": "36000.00",
6311 "regt_buying_power": "18000.00",
6312 "options_buying_power": "8000.00",
6313 "non_marginable_buying_power": "8500.25",
6314 }))
6315 .unwrap();
6316 let balances = convert_account_to_balances(&account, &[]);
6317 assert_eq!(balances.len(), 1);
6318 assert_eq!(balances[0].asset, AssetNameExchange::new("USD"));
6319 assert_eq!(balances[0].balance.total, dec!(9000.50), "total is cash");
6320 assert_eq!(
6321 balances[0].balance.free,
6322 dec!(8500.25),
6323 "free is non-marginable buying power when below cash"
6324 );
6325 assert_eq!(balances[0].balance.margin, None);
6326 }
6327
6328 #[test]
6331 fn test_convert_account_to_balances_free_capped_at_cash_with_long_stock() {
6332 let account: AlpacaAccount = serde_json::from_value(serde_json::json!({
6333 "cash": "50000",
6334 "equity": "100000",
6335 "initial_margin": "25000",
6336 "non_marginable_buying_power": "75000",
6337 }))
6338 .unwrap();
6339 let balances = convert_account_to_balances(&account, &[]);
6340 assert_eq!(balances[0].balance.total, dec!(50000));
6341 assert_eq!(balances[0].balance.free, dec!(50000));
6342 }
6343
6344 #[test]
6347 fn test_convert_account_to_balances_open_short_keeps_free_below_cash() {
6348 let account: AlpacaAccount = serde_json::from_value(serde_json::json!({
6349 "cash": "10200",
6350 "equity": "10000",
6351 "initial_margin": "100",
6352 "non_marginable_buying_power": "9900",
6353 }))
6354 .unwrap();
6355 let balances = convert_account_to_balances(&account, &[]);
6356 assert_eq!(balances[0].balance.total, dec!(10200));
6357 assert_eq!(balances[0].balance.free, dec!(9900));
6358 }
6359
6360 #[test]
6361 fn test_convert_account_to_balances_negative_cash_on_margin() {
6362 let account: AlpacaAccount = serde_json::from_value(serde_json::json!({
6363 "cash": "-2500.75",
6364 "equity": "7500.00",
6365 "buying_power": "5000.00",
6366 "non_marginable_buying_power": "1250.00",
6367 }))
6368 .unwrap();
6369 let balances = convert_account_to_balances(&account, &[]);
6370 assert_eq!(balances.len(), 1);
6371 assert_eq!(balances[0].balance.total, dec!(-2500.75));
6372 assert_eq!(
6373 balances[0].balance.free,
6374 dec!(-2500.75),
6375 "no free cash while borrowing; free <= total still holds"
6376 );
6377 }
6378
6379 #[test]
6380 fn test_alpaca_account_malformed_cash_fails_to_decode() {
6381 let result = serde_json::from_value::<AlpacaAccount>(serde_json::json!({
6382 "cash": "not-a-number",
6383 "non_marginable_buying_power": "0",
6384 }));
6385 assert!(result.is_err(), "a malformed cash must not read as zero");
6386 }
6387
6388 #[test]
6389 fn test_convert_account_to_balances_usd_filter() {
6390 let account = AlpacaAccount {
6391 cash: dec!(12000.00),
6392 non_marginable_buying_power: dec!(10000.00),
6393 };
6394 let usd = vec![AssetNameExchange::new("USD")];
6395 let balances = convert_account_to_balances(&account, &usd);
6396 assert_eq!(balances.len(), 1);
6397 assert_eq!(balances[0].asset, AssetNameExchange::new("USD"));
6398
6399 let non_usd = vec![AssetNameExchange::new("BTC")];
6400 let balances = convert_account_to_balances(&account, &non_usd);
6401 assert!(balances.is_empty());
6402 }
6403
6404 #[test]
6407 fn a_usd_balance_is_named_usd_however_the_filter_spells_it() {
6408 let account = AlpacaAccount {
6409 cash: dec!(12000.00),
6410 non_marginable_buying_power: dec!(10000.00),
6411 };
6412 let balances = convert_account_to_balances(&account, &[AssetNameExchange::new("usd")]);
6413 assert_eq!(balances.len(), 1);
6414 assert_eq!(balances[0].asset, AssetNameExchange::new("USD"));
6415 }
6416
6417 #[test]
6418 fn test_is_options_or_equity_symbol() {
6419 assert!(!is_options_or_equity_symbol("BTC/USD"));
6421 assert!(!is_options_or_equity_symbol("ETH/USD"));
6422 assert!(!is_options_or_equity_symbol("SOL/USD"));
6423
6424 assert!(is_options_or_equity_symbol("AAPL"));
6426 assert!(is_options_or_equity_symbol("SPY"));
6427 assert!(is_options_or_equity_symbol("MSFT"));
6428
6429 assert!(is_options_or_equity_symbol("SPY250418C00450000"));
6431 assert!(is_options_or_equity_symbol("AAPL250418P00145000"));
6432 }
6433
6434 #[test]
6435 fn test_parse_order_error_already_cancelled() {
6436 assert!(matches!(
6439 parse_order_error(
6440 reqwest::StatusCode::UNPROCESSABLE_ENTITY,
6441 None,
6442 "order is already cancelled"
6443 ),
6444 UnindexedOrderError::Rejected(ApiError::OrderAlreadyCancelled)
6445 ));
6446 }
6447
6448 #[test]
6449 fn test_parse_order_error_already_wins_over_insufficient_on_422() {
6450 assert!(matches!(
6453 parse_order_error(
6454 reqwest::StatusCode::UNPROCESSABLE_ENTITY,
6455 None,
6456 "order already cancelled due to insufficient margin"
6457 ),
6458 UnindexedOrderError::Rejected(ApiError::OrderAlreadyCancelled)
6459 ));
6460 }
6461
6462 fn make_order_ws<'a>(
6463 id: &str,
6464 symbol: &str,
6465 side: &str,
6466 filled_qty: &'a str,
6467 ) -> AlpacaOrderWs<'a> {
6468 AlpacaOrderWs {
6469 id: SmolStr::new(id),
6470 client_order_id: None,
6471 symbol: SmolStr::new(symbol),
6472 qty: Some("2"),
6473 filled_qty: Some(filled_qty),
6474 side: SmolStr::new(side),
6475 order_type: SmolStr::new("limit"),
6476 time_in_force: SmolStr::new("day"),
6477 limit_price: Some("100.00"),
6478 stop_price: None,
6479 trail_percent: None,
6480 trail_price: None,
6481 status: SmolStr::new("partially_filled"),
6482 }
6483 }
6484
6485 fn sole_event(events: [Option<UnindexedAccountEvent>; 2]) -> Option<UnindexedAccountEvent> {
6490 let [first, second] = events;
6491 assert!(
6492 second.is_none(),
6493 "expected a single event, got a second: {second:?}"
6494 );
6495 first
6496 }
6497
6498 fn fill_events(
6500 events: [Option<UnindexedAccountEvent>; 2],
6501 ) -> (
6502 Trade<AssetNameExchange, InstrumentNameExchange>,
6503 crate::order::Order<
6504 ExchangeId,
6505 InstrumentNameExchange,
6506 OrderState<AssetNameExchange, InstrumentNameExchange>,
6507 >,
6508 ) {
6509 let [first, second] = events;
6510 let first = first.expect("a fill frame must produce an execution");
6511 let second = second.expect("a fill frame must produce an order snapshot");
6512 let AccountEventKind::Trade(trade) = first.kind else {
6513 panic!("first event must be the Trade, got {:?}", first.kind);
6514 };
6515 let AccountEventKind::OrderSnapshot(rustrade_integration::collection::snapshot::Snapshot(
6516 order,
6517 )) = second.kind
6518 else {
6519 panic!(
6520 "second event must be the OrderSnapshot, got {:?}",
6521 second.kind
6522 );
6523 };
6524 (trade, order)
6525 }
6526
6527 #[test]
6530 fn test_convert_trade_update_fill_carries_the_client_order_id() {
6531 let mut order = make_order_ws("ord-1", "SPY", "buy", "1");
6532 order.client_order_id = Some(SmolStr::new("outside-1"));
6533 let update = AlpacaTradeUpdate {
6534 event: SmolStr::new("fill"),
6535 execution_id: Some("exec-1"),
6536 previous_execution_id: None,
6537 order,
6538 price: Some("150.00"),
6539 qty: Some("1"),
6540 timestamp: Some("2025-04-18T14:30:00Z"),
6541 };
6542 let (trade, _order) = fill_events(convert_trade_update(update));
6543 assert_eq!(trade.client_order_id, Some(ClientOrderId::new("outside-1")));
6544 }
6545
6546 #[test]
6547 fn test_convert_trade_update_fill_produces_trade_with_execution_id() {
6548 let update = AlpacaTradeUpdate {
6549 event: SmolStr::new("fill"),
6550 execution_id: Some("exec-1"),
6551 previous_execution_id: None,
6552 order: make_order_ws("ord-1", "SPY", "buy", "1"),
6553 price: Some("150.00"),
6554 qty: Some("1"),
6555 timestamp: Some("2025-04-18T14:30:00Z"),
6556 };
6557 let (trade, order) = fill_events(convert_trade_update(update));
6558 assert_eq!(trade.id.0.as_str(), "exec-1");
6560 assert_eq!(
6561 trade.client_order_id, None,
6562 "the update names no client order id"
6563 );
6564 assert_eq!(trade.price, Decimal::from_str("150.00").unwrap());
6565 assert_eq!(trade.quantity, Decimal::from_str("1").unwrap());
6566
6567 let OrderState::Active(crate::order::state::ActiveOrderState::Open(open)) = order.state
6570 else {
6571 panic!("expected an Open snapshot, got {:?}", order.state);
6572 };
6573 assert_eq!(open.filled_quantity, Decimal::from_str("1").unwrap());
6574 assert_eq!(open.id.assigned().map(|id| id.0.as_str()), Some("ord-1"));
6575 }
6576
6577 #[test]
6578 fn test_convert_trade_update_partial_fill() {
6579 let update = AlpacaTradeUpdate {
6580 event: SmolStr::new("partial_fill"),
6581 execution_id: None,
6582 previous_execution_id: None,
6583 order: make_order_ws("ord-2", "AAPL", "sell", "0.5"),
6584 price: Some("200.00"),
6585 qty: Some("0.5"),
6586 timestamp: None,
6587 };
6588 let (trade, order) = fill_events(convert_trade_update(update));
6589 assert_eq!(trade.quantity, Decimal::from_str("0.5").unwrap());
6590
6591 let OrderState::Active(crate::order::state::ActiveOrderState::Open(open)) = order.state
6594 else {
6595 panic!("expected an Open snapshot, got {:?}", order.state);
6596 };
6597 assert_eq!(open.filled_quantity, Decimal::from_str("0.5").unwrap());
6598 assert_eq!(
6599 open.quantity_remaining(order.quantity),
6600 Decimal::from_str("1.5").unwrap()
6601 );
6602 }
6603
6604 #[test]
6608 fn a_full_fill_snapshot_reports_nothing_left_to_fill() {
6609 let update = AlpacaTradeUpdate {
6610 event: SmolStr::new("fill"),
6611 execution_id: None,
6612 previous_execution_id: None,
6613 order: AlpacaOrderWs {
6615 status: SmolStr::new("filled"),
6616 ..make_order_ws("ord-full", "SPY", "buy", "2")
6617 },
6618 price: Some("150.00"),
6619 qty: Some("1"),
6620 timestamp: Some("2025-04-18T14:30:00Z"),
6621 };
6622 let (_trade, order) = fill_events(convert_trade_update(update));
6623 let OrderState::Active(crate::order::state::ActiveOrderState::Open(open)) = order.state
6624 else {
6625 panic!("expected an Open snapshot, got {:?}", order.state);
6626 };
6627 assert_eq!(open.filled_quantity, Decimal::from_str("2").unwrap());
6628 assert!(
6629 open.quantity_remaining(order.quantity).is_zero(),
6630 "a completed order must report nothing left to fill so the engine retires it"
6631 );
6632 }
6633
6634 #[test]
6638 fn a_fill_for_an_order_no_longer_working_emits_the_execution_without_a_snapshot() {
6639 for status in [
6640 "canceled",
6641 "expired",
6642 "rejected",
6643 "done_for_day",
6644 "pending_cancel",
6645 ] {
6646 let update = AlpacaTradeUpdate {
6647 event: SmolStr::new("partial_fill"),
6648 execution_id: None,
6649 previous_execution_id: None,
6650 order: AlpacaOrderWs {
6651 status: SmolStr::new(status),
6652 ..make_order_ws("ord-late", "SPY", "buy", "1")
6653 },
6654 price: Some("150.00"),
6655 qty: Some("1"),
6656 timestamp: None,
6657 };
6658 let [trade, snapshot] = convert_trade_update(update);
6659 assert!(
6660 matches!(trade.map(|e| e.kind), Some(AccountEventKind::Trade(_))),
6661 "the execution must still be reported for status {status}"
6662 );
6663 assert!(
6664 snapshot.is_none(),
6665 "status {status} must not produce an order snapshot"
6666 );
6667 }
6668 }
6669
6670 #[test]
6673 fn a_notional_order_fill_emits_the_execution_without_a_snapshot() {
6674 let update = AlpacaTradeUpdate {
6675 event: SmolStr::new("partial_fill"),
6676 execution_id: None,
6677 previous_execution_id: None,
6678 order: AlpacaOrderWs {
6679 qty: None,
6680 ..make_order_ws("ord-notional-ws", "SPY", "buy", "1")
6681 },
6682 price: Some("150.00"),
6683 qty: Some("1"),
6684 timestamp: None,
6685 };
6686 let [trade, snapshot] = convert_trade_update(update);
6687 assert!(matches!(
6688 trade.map(|e| e.kind),
6689 Some(AccountEventKind::Trade(_))
6690 ));
6691 assert!(
6692 snapshot.is_none(),
6693 "a notional order has no quantity to snapshot against"
6694 );
6695 }
6696
6697 #[test]
6698 fn test_convert_trade_update_new_order_produces_snapshot() {
6699 let update = AlpacaTradeUpdate {
6700 event: SmolStr::new("new"),
6701 execution_id: None,
6702 previous_execution_id: None,
6703 order: AlpacaOrderWs {
6704 id: SmolStr::new("ord-new"),
6705 client_order_id: Some(SmolStr::new("cid-1")),
6706 symbol: SmolStr::new("AAPL"),
6707 qty: Some("10"),
6708 filled_qty: Some("0"),
6709 side: SmolStr::new("buy"),
6710 order_type: SmolStr::new("limit"),
6711 time_in_force: SmolStr::new("day"),
6712 limit_price: Some("150.00"),
6713 stop_price: None,
6714 trail_percent: None,
6715 trail_price: None,
6716 status: SmolStr::new("new"),
6717 },
6718 price: None,
6719 qty: None,
6720 timestamp: Some("2025-04-18T14:30:00Z"),
6721 };
6722 let event =
6723 sole_event(convert_trade_update(update)).expect("new event should produce an event");
6724 assert!(matches!(event.kind, AccountEventKind::OrderSnapshot(_)));
6725 }
6726
6727 #[test]
6728 fn test_convert_trade_update_canceled_produces_cancel() {
6729 let update = AlpacaTradeUpdate {
6730 event: SmolStr::new("canceled"),
6731 execution_id: None,
6732 previous_execution_id: None,
6733 order: make_order_ws("ord-3", "AAPL", "sell", "0"),
6734 price: None,
6735 qty: None,
6736 timestamp: Some("2025-04-18T14:30:00Z"),
6737 };
6738 let event =
6739 sole_event(convert_trade_update(update)).expect("canceled should produce an event");
6740 let AccountEventKind::OrderCancelled(response) = event.kind else {
6741 panic!("expected OrderCancelled, got {:?}", event.kind);
6742 };
6743 assert!(response.state.is_ok());
6744 }
6745
6746 #[test]
6748 fn a_canceled_update_carries_its_fill_or_none() {
6749 let cancelled = |filled_qty: Option<&'static str>| {
6750 let mut order = make_order_ws("ord-3", "AAPL", "sell", "0");
6751 order.filled_qty = filled_qty;
6752 let update = AlpacaTradeUpdate {
6753 event: SmolStr::new("canceled"),
6754 execution_id: None,
6755 previous_execution_id: None,
6756 order,
6757 price: None,
6758 qty: None,
6759 timestamp: Some("2025-04-18T14:30:00Z"),
6760 };
6761 let event =
6762 sole_event(convert_trade_update(update)).expect("canceled should produce an event");
6763 let AccountEventKind::OrderCancelled(response) = event.kind else {
6764 panic!("expected OrderCancelled, got {:?}", event.kind);
6765 };
6766 let Ok(cancelled) = response.state else {
6767 panic!("expected Ok, got {:?}", response.state);
6768 };
6769 cancelled.filled_quantity
6770 };
6771
6772 assert_eq!(cancelled(Some("1")), Some(Decimal::ONE));
6773 assert_eq!(cancelled(Some("0")), Some(Decimal::ZERO));
6774 assert_eq!(cancelled(None), None);
6775 assert_eq!(cancelled(Some("not a number")), None);
6776 }
6777
6778 #[test]
6781 fn an_ended_order_with_an_unparseable_fill_reports_it_unknown() {
6782 let key = OrderKey::new(
6783 ExchangeId::AlpacaBroker,
6784 InstrumentNameExchange::new("AAPL"),
6785 StrategyId::new("strategy"),
6786 ClientOrderId::new("c"),
6787 );
6788 let ended = |status: &str| {
6789 let mut order = make_order_response("o1", "AAPL");
6790 order.status = Some(status.to_string());
6791 order.filled_qty = "not a number".to_string();
6792 convert_ended_order(&order, &key).map(|order| order.state)
6793 };
6794
6795 assert!(matches!(
6796 ended("canceled"),
6797 Some(InactiveOrderState::Cancelled(Cancelled {
6798 filled_quantity: None,
6799 ..
6800 }))
6801 ));
6802 assert!(matches!(
6803 ended("expired"),
6804 Some(InactiveOrderState::Expired(Expired {
6805 filled_quantity: None,
6806 ..
6807 }))
6808 ));
6809 assert!(matches!(
6810 ended("filled"),
6811 Some(InactiveOrderState::FullyFilled(Filled { filled_quantity, .. }))
6812 if filled_quantity == Decimal::ONE
6813 ));
6814 }
6815
6816 #[test]
6817 fn test_convert_trade_update_rejected_produces_error() {
6818 let update = AlpacaTradeUpdate {
6819 event: SmolStr::new("rejected"),
6820 execution_id: None,
6821 previous_execution_id: None,
6822 order: make_order_ws("ord-4", "SPY", "buy", "0"),
6823 price: None,
6824 qty: None,
6825 timestamp: None,
6826 };
6827 let event =
6828 sole_event(convert_trade_update(update)).expect("rejected should produce an event");
6829 let AccountEventKind::OrderCancelled(response) = event.kind else {
6830 panic!("expected OrderCancelled, got {:?}", event.kind);
6831 };
6832 assert!(response.state.is_err());
6833 }
6834
6835 #[test]
6836 fn test_convert_open_order_notional_qty_none_is_skipped() {
6837 let order = AlpacaOrderResponse {
6840 id: "ord-notional".to_string(),
6841 client_order_id: None,
6842 symbol: "SPY".to_string(),
6843 qty: None,
6844 filled_qty: "0".to_string(),
6845 filled_avg_price: None,
6846 status: None,
6847 side: "buy".to_string(),
6848 order_type: "market".to_string(),
6849 time_in_force: "day".to_string(),
6850 limit_price: None,
6851 stop_price: None,
6852 trail_percent: None,
6853 trail_price: None,
6854 created_at: "2025-04-18T14:30:00Z".to_string(),
6855 updated_at: None,
6856 };
6857 assert!(convert_open_order(&order).is_none());
6858 }
6859
6860 #[test]
6861 fn test_convert_activity_to_trade_bad_price_returns_none() {
6862 let activity = AlpacaActivity {
6866 id: "act-1".to_string(),
6867 order_id: "ord-1".to_string(),
6868 symbol: "SPY250418C00450000".to_string(),
6869 side: "buy".to_string(),
6870 price: "not-a-number".to_string(),
6871 qty: "1".to_string(),
6872 transaction_time: "2025-04-18T14:30:00Z".to_string(),
6873 cum_qty: Some("1".to_string()),
6874 };
6875 assert!(convert_activity_to_trade(&activity).is_none());
6876 }
6877
6878 #[test]
6879 fn test_convert_positions_to_balances_crypto() {
6880 let positions = vec![
6881 AlpacaPosition {
6883 qty_available: dec!(0.4),
6884 ..alpaca_position("BTCUSD", "crypto", AlpacaPositionSide::Long, dec!(0.5))
6885 },
6886 alpaca_position("ETH/USD", "crypto", AlpacaPositionSide::Long, dec!(2.0)),
6888 alpaca_position("AAPL", "us_equity", AlpacaPositionSide::Long, dec!(10)),
6890 ];
6891
6892 let balances = convert_positions_to_balances(&positions, &[]);
6894 assert_eq!(balances.len(), 2, "only crypto positions returned");
6895 assert_eq!(balances[0].asset.name().as_str(), "BTC");
6896 assert_eq!(balances[1].asset.name().as_str(), "ETH");
6897 assert_eq!(balances[0].balance.total, dec!(0.5));
6899 assert_eq!(balances[0].balance.free, dec!(0.4));
6900
6901 let btc_only = vec![AssetNameExchange::new("btc")];
6903 let balances = convert_positions_to_balances(&positions, &btc_only);
6904 assert_eq!(balances.len(), 1);
6905 assert_eq!(balances[0].asset.name().as_str(), "BTC");
6906 }
6907
6908 #[test]
6909 fn test_crypto_position_asset() {
6910 assert_eq!(crypto_position_asset("BTCUSD"), "BTC");
6911 assert_eq!(
6912 crypto_position_asset("btcusd"),
6913 "btc",
6914 "suffix matched ignoring case"
6915 );
6916 assert_eq!(
6917 crypto_position_asset("USDTUSD"),
6918 "USDT",
6919 "only one USD is stripped"
6920 );
6921 assert_eq!(crypto_position_asset("BTC/USD"), "BTC");
6922 assert_eq!(crypto_position_asset("BTC/USDT"), "BTC");
6923 assert_eq!(crypto_position_asset("€USD"), "€");
6924 assert_eq!(crypto_position_asset("USD"), "USD");
6926 assert_eq!(crypto_position_asset("/USD"), "/USD");
6927 assert_eq!(crypto_position_asset("BTCUSDT"), "BTCUSDT");
6928 assert_eq!(
6929 crypto_position_asset("BTCێ"),
6930 "BTCێ",
6931 "no char boundary at the suffix"
6932 );
6933 assert_eq!(crypto_position_asset(""), "");
6934 }
6935
6936 fn alpaca_position(
6938 symbol: &str,
6939 asset_class: &str,
6940 side: AlpacaPositionSide,
6941 qty: Decimal,
6942 ) -> AlpacaPosition {
6943 AlpacaPosition {
6944 symbol: symbol.into(),
6945 asset_class: asset_class.into(),
6946 side,
6947 qty,
6948 qty_available: qty,
6949 avg_entry_price: Some(dec!(100)),
6950 unrealized_pl: Some(dec!(0)),
6951 }
6952 }
6953
6954 #[test]
6955 fn test_convert_positions_sign_comes_from_side_not_qty() {
6956 let now = Utc::now();
6957 let positions = vec![
6958 alpaca_position("AAPL", "us_equity", AlpacaPositionSide::Long, dec!(10)),
6959 alpaca_position("TSLA", "us_equity", AlpacaPositionSide::Short, dec!(-3)),
6960 alpaca_position("MSFT", "us_equity", AlpacaPositionSide::Short, dec!(4)),
6962 ];
6963
6964 let converted = convert_positions(&positions, now).unwrap();
6965 let quantities: Vec<_> = converted.iter().map(|(s, p)| (*s, p.quantity)).collect();
6966 assert_eq!(
6967 quantities,
6968 vec![("AAPL", dec!(10)), ("TSLA", dec!(-3)), ("MSFT", dec!(-4))]
6969 );
6970 assert!(converted.iter().all(|(_, p)| p.time_exchange == now));
6971 }
6972
6973 #[test]
6974 fn test_convert_positions_leaves_out_crypto_and_flat() {
6975 let positions = vec![
6976 alpaca_position("BTC/USD", "crypto", AlpacaPositionSide::Long, dec!(0.5)),
6977 alpaca_position("AAPL", "us_equity", AlpacaPositionSide::Long, dec!(0)),
6978 alpaca_position("SPY", "us_equity", AlpacaPositionSide::Long, dec!(1)),
6979 ];
6980
6981 let converted = convert_positions(&positions, Utc::now()).unwrap();
6982 let symbols: Vec<_> = converted.iter().map(|(s, _)| *s).collect();
6983 assert_eq!(symbols, vec!["SPY"]);
6984 }
6985
6986 #[test]
6987 fn test_alpaca_position_decodes_equity_option_and_fractional() {
6988 let positions: Vec<AlpacaPosition> = serde_json::from_value(serde_json::json!([
6989 {
6990 "asset_id": "00000000-0000-0000-0000-000000000001",
6991 "symbol": "XYZ",
6992 "exchange": "NASDAQ",
6993 "asset_class": "us_equity",
6994 "qty": "-7",
6995 "qty_available": "-7",
6996 "side": "short",
6997 "avg_entry_price": "50.25",
6998 "market_value": "-351.75",
6999 "unrealized_pl": "0",
7000 },
7001 {
7002 "symbol": "XYZ271217C00055000",
7003 "asset_class": "us_option",
7004 "qty": "2",
7005 "qty_available": "2",
7006 "side": "long",
7007 "avg_entry_price": "1.35",
7008 "unrealized_pl": "-20",
7009 },
7010 {
7011 "symbol": "ABC",
7012 "asset_class": "us_equity",
7013 "qty": "0.125",
7014 "qty_available": "0.125",
7015 "side": "long",
7016 "avg_entry_price": "80",
7017 "unrealized_pl": null,
7018 },
7019 ]))
7020 .unwrap();
7021
7022 let converted = convert_positions(&positions, Utc::now()).unwrap();
7023 let [
7024 (short_sym, short),
7025 (option_sym, option),
7026 (fraction_sym, fraction),
7027 ] = converted.as_slice()
7028 else {
7029 panic!("expected three positions, got {converted:?}");
7030 };
7031
7032 assert_eq!(*short_sym, "XYZ");
7033 assert_eq!(short.quantity, dec!(-7));
7034 assert_eq!(short.entry_price, Some(dec!(50.25)));
7035 assert!(short.is_short());
7036
7037 assert_eq!(*option_sym, "XYZ271217C00055000");
7038 assert_eq!(option.quantity, dec!(2));
7039 assert_eq!(option.entry_price, Some(dec!(1.35)), "per-share premium");
7040 assert_eq!(option.unrealized_pnl, Some(dec!(-20)));
7041
7042 assert_eq!(*fraction_sym, "ABC");
7043 assert_eq!(fraction.quantity, dec!(0.125));
7044 assert_eq!(fraction.unrealized_pnl, None);
7045
7046 for (_, p) in &converted {
7047 assert_eq!(p.margin_used, None);
7048 assert_eq!(p.liquidation_price, None);
7049 assert_eq!(p.leverage, None);
7050 }
7051 }
7052
7053 #[test]
7056 fn test_alpaca_position_unknown_side_fails_only_where_a_direction_is_needed() {
7057 let positions: Vec<AlpacaPosition> = serde_json::from_value(serde_json::json!([
7058 {
7059 "symbol": "BTC/USD",
7060 "asset_class": "crypto",
7061 "qty": "0.5",
7062 "qty_available": "0.5",
7063 },
7064 {
7065 "symbol": "XYZ",
7066 "asset_class": "us_equity",
7067 "qty": "1",
7068 "qty_available": "1",
7069 "side": "sideways",
7070 },
7071 ]))
7072 .unwrap();
7073 assert_eq!(positions[0].side, AlpacaPositionSide::Unknown);
7074 assert_eq!(positions[1].side, AlpacaPositionSide::Unknown);
7075
7076 let balances = convert_positions_to_balances(&positions, &[]);
7077 assert_eq!(balances.len(), 1);
7078 assert_eq!(balances[0].balance.total, dec!(0.5));
7079
7080 let result = convert_positions(&positions, Utc::now());
7081 assert!(
7082 matches!(&result, Err(UnindexedClientError::Internal(msg)) if msg.contains("XYZ")),
7083 "an unknown side must not guess a direction: {result:?}"
7084 );
7085 }
7086
7087 #[test]
7089 fn order_state_time_prefers_updated_at() {
7090 let mut resp = make_order_response("ord-1", "SPY");
7091 resp.created_at = "2025-04-18T14:30:00Z".to_string();
7092 resp.updated_at = Some("2025-04-18T15:45:00Z".to_string());
7093 assert_eq!(
7094 order_state_time(&resp),
7095 parse_timestamp("2025-04-18T15:45:00Z").unwrap(),
7096 "updated_at must win over created_at"
7097 );
7098
7099 resp.updated_at = None;
7101 assert_eq!(
7102 order_state_time(&resp),
7103 parse_timestamp("2025-04-18T14:30:00Z").unwrap(),
7104 "absent updated_at falls back to created_at"
7105 );
7106
7107 resp.updated_at = Some("not-a-timestamp".to_string());
7110 assert_eq!(
7111 order_state_time(&resp),
7112 parse_timestamp("2025-04-18T14:30:00Z").unwrap(),
7113 "unparseable updated_at falls back to created_at"
7114 );
7115 }
7116
7117 #[test]
7120 fn a_placement_response_without_a_status_reads_from_what_filled() {
7121 let instrument = InstrumentNameExchange::new("SPY");
7122 let mut resp = make_order_response("ord-1", "SPY");
7123 resp.filled_qty = "0.4".to_string();
7124 let OrderState::Active(ActiveOrderState::Open(open)) =
7125 placed_order_state(&resp, &instrument, Decimal::ONE)
7126 else {
7127 panic!("expected Open");
7128 };
7129 assert_eq!(open.filled_quantity, Decimal::new(4, 1));
7130
7131 resp.filled_qty = "1".to_string();
7132 resp.filled_avg_price = Some("100.5".to_string());
7133 let state = placed_order_state(&resp, &instrument, Decimal::ONE);
7134 let OrderState::Inactive(InactiveOrderState::FullyFilled(filled)) = state else {
7135 panic!("expected FullyFilled, got {state:?}");
7136 };
7137 assert_eq!(filled.filled_quantity, Decimal::ONE);
7138 assert_eq!(filled.avg_price, Some(Decimal::new(1005, 1)));
7139 }
7140
7141 fn make_order_response(id: &str, symbol: &str) -> AlpacaOrderResponse {
7142 AlpacaOrderResponse {
7143 id: id.to_string(),
7144 client_order_id: None,
7145 symbol: symbol.to_string(),
7146 qty: Some("1".to_string()),
7147 filled_qty: "0".to_string(),
7148 filled_avg_price: None,
7149 status: None,
7150 side: "buy".to_string(),
7151 order_type: "limit".to_string(),
7152 time_in_force: "day".to_string(),
7153 limit_price: Some("100.00".to_string()),
7154 stop_price: None,
7155 trail_percent: None,
7156 trail_price: None,
7157 created_at: "2025-04-18T14:30:00Z".to_string(),
7158 updated_at: None,
7159 }
7160 }
7161
7162 #[test]
7163 fn test_build_instrument_snapshots_empty_instruments_returns_only_with_orders() {
7164 let orders = vec![
7165 make_order_response("o1", "AAPL"),
7166 make_order_response("o2", "SPY"),
7167 ];
7168 let snapshots = build_instrument_snapshots(orders, Vec::new(), &[]);
7169 assert_eq!(snapshots.len(), 2);
7170 let symbols: Vec<&str> = snapshots
7171 .iter()
7172 .map(|s| s.instrument.name().as_str())
7173 .collect();
7174 assert!(symbols.contains(&"AAPL"));
7175 assert!(symbols.contains(&"SPY"));
7176 }
7177
7178 #[test]
7179 fn test_build_instrument_snapshots_requested_instrument_no_orders_gets_empty_snapshot() {
7180 let orders = vec![make_order_response("o1", "AAPL")];
7183 let instruments = vec![
7184 InstrumentNameExchange::new("AAPL"),
7185 InstrumentNameExchange::new("SPY"),
7186 ];
7187 let snapshots = build_instrument_snapshots(orders, Vec::new(), &instruments);
7188 assert_eq!(snapshots.len(), 2);
7189 let spy = snapshots
7190 .iter()
7191 .find(|s| s.instrument.name().as_str() == "SPY")
7192 .expect("SPY snapshot must be present even with no orders");
7193 assert!(spy.orders.is_empty());
7194 }
7195
7196 fn make_notional_order_response(id: &str, symbol: &str) -> AlpacaOrderResponse {
7198 AlpacaOrderResponse {
7199 qty: None,
7200 ..make_order_response(id, symbol)
7201 }
7202 }
7203
7204 fn snapshot_for<'a>(
7205 snapshots: &'a [InstrumentAccountSnapshot<
7206 ExchangeId,
7207 AssetNameExchange,
7208 InstrumentNameExchange,
7209 >],
7210 symbol: &str,
7211 ) -> &'a InstrumentAccountSnapshot<ExchangeId, AssetNameExchange, InstrumentNameExchange> {
7212 snapshots
7213 .iter()
7214 .find(|snapshot| snapshot.instrument.name().as_str() == symbol)
7215 .unwrap_or_else(|| panic!("no snapshot for {symbol}"))
7216 }
7217
7218 #[test]
7222 fn test_build_instrument_snapshots_declares_complete_lists_complete() {
7223 let instruments = [
7224 InstrumentNameExchange::new("AAPL"),
7225 InstrumentNameExchange::new("SPY"),
7226 ];
7227 for requested in [&instruments[..], &[]] {
7228 let orders = vec![make_order_response("o1", "AAPL")];
7229 let snapshots = build_instrument_snapshots(orders, Vec::new(), requested);
7230 assert!(!snapshots.is_empty());
7231 assert!(snapshots.iter().all(|snapshot| snapshot.orders_complete));
7232 }
7233 }
7234
7235 #[test]
7239 fn test_build_instrument_snapshots_notional_order_leaves_only_its_instrument_incomplete() {
7240 let orders = vec![
7241 make_order_response("o1", "AAPL"),
7242 make_order_response("o2", "SPY"),
7243 make_notional_order_response("o3", "SPY"),
7244 ];
7245 let instruments = [
7246 InstrumentNameExchange::new("AAPL"),
7247 InstrumentNameExchange::new("SPY"),
7248 ];
7249 let snapshots = build_instrument_snapshots(orders, Vec::new(), &instruments);
7250
7251 assert!(snapshot_for(&snapshots, "AAPL").orders_complete);
7252 let spy = snapshot_for(&snapshots, "SPY");
7253 assert!(!spy.orders_complete);
7254 assert_eq!(spy.orders.len(), 1);
7255 }
7256
7257 #[test]
7260 fn test_build_instrument_snapshots_unfiltered_lists_an_instrument_with_only_a_notional_order() {
7261 let orders = vec![make_notional_order_response("o1", "SPY")];
7262 let snapshots = build_instrument_snapshots(orders, Vec::new(), &[]);
7263
7264 let spy = snapshot_for(&snapshots, "SPY");
7265 assert!(!spy.orders_complete);
7266 assert!(spy.orders.is_empty());
7267 }
7268
7269 #[test]
7270 fn test_build_instrument_snapshots_non_requested_instrument_excluded() {
7271 let orders = vec![
7274 make_order_response("o1", "AAPL"),
7275 make_order_response("o2", "MSFT"), ];
7277 let instruments = vec![InstrumentNameExchange::new("AAPL")];
7278 let snapshots = build_instrument_snapshots(orders, Vec::new(), &instruments);
7279 assert_eq!(snapshots.len(), 1);
7280 assert_eq!(snapshots[0].instrument.name().as_str(), "AAPL");
7281 }
7282
7283 fn position(quantity: Decimal) -> Position {
7284 Position::new(
7285 quantity,
7286 Some(dec!(100)),
7287 None,
7288 None,
7289 None,
7290 None,
7291 None,
7292 Utc::now(),
7293 )
7294 }
7295
7296 #[test]
7297 fn test_build_instrument_snapshots_unfiltered_lists_positions_without_orders() {
7298 let orders = vec![
7299 make_order_response("o1", "AAPL"),
7300 make_order_response("o2", "MSFT"),
7301 make_order_response("o3", "BTC/USD"),
7302 ];
7303 let positions = vec![("AAPL", position(dec!(5))), ("TSLA", position(dec!(-2)))];
7304
7305 let snapshots = build_instrument_snapshots(orders, positions, &[]);
7306 let listed: Vec<_> = snapshots
7307 .iter()
7308 .map(|s| {
7309 (
7310 s.instrument.name().as_str(),
7311 s.orders.len(),
7312 s.position.quantity(),
7313 )
7314 })
7315 .collect();
7316 assert_eq!(
7318 listed,
7319 vec![
7320 ("AAPL", 1, Some(dec!(5))),
7321 ("MSFT", 1, Some(Decimal::ZERO)),
7322 ("BTC/USD", 1, None),
7323 ("TSLA", 0, Some(dec!(-2)))
7324 ]
7325 );
7326 assert!(snapshots.iter().all(|s| s.orders_complete));
7327 }
7328
7329 #[test]
7330 fn test_build_instrument_snapshots_filtered_attaches_only_requested_positions() {
7331 let positions = vec![("AAPL", position(dec!(5))), ("TSLA", position(dec!(-2)))];
7332 let instruments = vec![
7333 InstrumentNameExchange::new("tsla"),
7334 InstrumentNameExchange::new("MSFT"),
7335 InstrumentNameExchange::new("BTC/USD"),
7336 InstrumentNameExchange::new("TSLA"),
7337 ];
7338
7339 let snapshots = build_instrument_snapshots(Vec::new(), positions, &instruments);
7340 let listed: Vec<_> = snapshots
7341 .iter()
7342 .map(|s| (s.instrument.name().as_str(), s.position.quantity()))
7343 .collect();
7344 assert_eq!(
7348 listed,
7349 vec![
7350 ("tsla", Some(dec!(-2))),
7351 ("MSFT", Some(Decimal::ZERO)),
7352 ("BTC/USD", None),
7353 ("TSLA", None)
7354 ]
7355 );
7356 assert!(!snapshots[3].orders_complete);
7357 }
7358
7359 #[test]
7365 fn test_recover_fills_dedup_key_matches_ws_path() {
7366 let order_id = "ord-1";
7367
7368 let ws_keys: Vec<SmolStr> = ["1", "2"]
7371 .iter()
7372 .map(|filled_qty| {
7373 early_dedup_key(&AlpacaTradeUpdate {
7374 event: SmolStr::new("partial_fill"),
7375 execution_id: None,
7376 previous_execution_id: None,
7377 order: make_order_ws(order_id, "SPY", "buy", filled_qty),
7378 price: Some("150.00"),
7379 qty: Some("1"),
7380 timestamp: None,
7381 })
7382 })
7383 .collect();
7384
7385 let mut cumulative = Decimal::ZERO;
7388 let rest_keys: Vec<SmolStr> = ["1", "1"]
7389 .iter()
7390 .map(|exec_qty| {
7391 cumulative += Decimal::from_str(exec_qty).unwrap();
7392 fill_dedup_key(order_id, cumulative)
7393 })
7394 .collect();
7395
7396 assert_eq!(
7397 ws_keys, rest_keys,
7398 "REST recovery dedup keys must match WS path keys for cross-source dedup to work"
7399 );
7400 assert_eq!(ws_keys[0].as_str(), "ord-1:1");
7401 assert_eq!(ws_keys[1].as_str(), "ord-1:2");
7402 }
7403
7404 #[test]
7409 fn a_ws_fill_without_an_execution_id_is_identified_by_its_dedup_key() {
7410 let update = AlpacaTradeUpdate {
7411 event: SmolStr::new("fill"),
7412 execution_id: None,
7413 previous_execution_id: None,
7414 order: make_order_ws("ord-abc", "SPY", "buy", "5"),
7415 price: Some("150.00"),
7416 qty: Some("5"),
7417 timestamp: None,
7418 };
7419
7420 let early_key = early_dedup_key(&update);
7422
7423 let [event, _snapshot] = convert_trade_update(update);
7425 let Some(UnindexedAccountEvent {
7426 kind: AccountEventKind::Trade(trade),
7427 ..
7428 }) = event
7429 else {
7430 panic!("fill should produce an execution");
7431 };
7432
7433 assert_eq!(
7434 trade.id.0.as_str(),
7435 early_key.as_str(),
7436 "without an execution_id the TradeId is the dedup key"
7437 );
7438 assert_eq!(early_key.as_str(), "ord-abc:5");
7439 }
7440
7441 #[test]
7446 fn early_dedup_key_normalizes_decimal_strings() {
7447 let update1 = AlpacaTradeUpdate {
7449 event: SmolStr::new("fill"),
7450 execution_id: None,
7451 previous_execution_id: None,
7452 order: AlpacaOrderWs {
7453 id: SmolStr::new("ord-x"),
7454 client_order_id: Some(SmolStr::new("cid")),
7455 symbol: SmolStr::new("AAPL"),
7456 qty: Some("10"),
7457 filled_qty: Some("1.00"),
7458 side: SmolStr::new("buy"),
7459 order_type: SmolStr::new("market"),
7460 time_in_force: SmolStr::new("day"),
7461 limit_price: None,
7462 stop_price: None,
7463 trail_percent: None,
7464 trail_price: None,
7465 status: SmolStr::new("filled"),
7466 },
7467 price: Some("100.00"),
7468 qty: Some("10"),
7469 timestamp: None,
7470 };
7471 assert_eq!(early_dedup_key(&update1).as_str(), "ord-x:1");
7472
7473 let update2 = AlpacaTradeUpdate {
7475 event: SmolStr::new("fill"),
7476 execution_id: None,
7477 previous_execution_id: None,
7478 order: AlpacaOrderWs {
7479 id: SmolStr::new("ord-x"),
7480 client_order_id: Some(SmolStr::new("cid")),
7481 symbol: SmolStr::new("AAPL"),
7482 qty: Some("10"),
7483 filled_qty: Some("1"),
7484 side: SmolStr::new("buy"),
7485 order_type: SmolStr::new("market"),
7486 time_in_force: SmolStr::new("day"),
7487 limit_price: None,
7488 stop_price: None,
7489 trail_percent: None,
7490 trail_price: None,
7491 status: SmolStr::new("filled"),
7492 },
7493 price: Some("100.00"),
7494 qty: Some("10"),
7495 timestamp: None,
7496 };
7497 assert_eq!(early_dedup_key(&update2).as_str(), "ord-x:1");
7498
7499 let update3 = AlpacaTradeUpdate {
7501 event: SmolStr::new("fill"),
7502 execution_id: None,
7503 previous_execution_id: None,
7504 order: AlpacaOrderWs {
7505 id: SmolStr::new("ord-x"),
7506 client_order_id: Some(SmolStr::new("cid")),
7507 symbol: SmolStr::new("AAPL"),
7508 qty: Some("10"),
7509 filled_qty: Some("1.0"),
7510 side: SmolStr::new("buy"),
7511 order_type: SmolStr::new("market"),
7512 time_in_force: SmolStr::new("day"),
7513 limit_price: None,
7514 stop_price: None,
7515 trail_percent: None,
7516 trail_price: None,
7517 status: SmolStr::new("filled"),
7518 },
7519 price: Some("100.00"),
7520 qty: Some("10"),
7521 timestamp: None,
7522 };
7523 assert_eq!(early_dedup_key(&update3).as_str(), "ord-x:1");
7524 }
7525
7526 #[test]
7531 fn process_ws_text_rejected_event_without_filled_qty_is_not_dropped() {
7532 let (tx, mut rx) = mpsc::unbounded_channel();
7533 let dedup = new_dedup_cache();
7534 let mut backoff = ExponentialBackoff::new();
7535
7536 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"}}}"#;
7538
7539 let known = KnownLiveOrders::shared(ExchangeId::AlpacaBroker);
7540 process_ws_text(json, &tx, &dedup, &known, &mut backoff);
7541
7542 let event = rx.try_recv()
7544 .expect("rejected event without filled_qty must produce an AccountEvent, not be silently dropped");
7545 assert!(
7546 matches!(event.kind, AccountEventKind::OrderCancelled(_)),
7547 "rejected event must map to OrderCancelled, got: {:?}",
7548 event.kind
7549 );
7550 }
7551
7552 #[test]
7555 fn process_ws_text_identifies_a_fill_by_its_execution_id() {
7556 let frame = |execution_id: &str| {
7557 format!(
7558 r#"{{"stream":"trade_updates","data":{{"event":"fill",{execution_id}"order":{{"id":"ord-1","client_order_id":"cid-1","symbol":"SPY","qty":"2","filled_qty":"2","side":"buy","type":"market","time_in_force":"day","status":"filled"}},"price":"100.00","qty":"2","timestamp":"2025-04-18T14:30:00Z"}}}}"#
7559 )
7560 };
7561 let cases = [
7562 (
7563 r#""execution_id":"524b1902-817e-446c-825b-a9fcfebbc17e","#,
7564 "524b1902-817e-446c-825b-a9fcfebbc17e",
7565 ),
7566 (r#""execution_id":null,"#, "ord-1:2"),
7567 (r#""execution_id":"","#, "ord-1:2"),
7568 ("", "ord-1:2"),
7569 ];
7570 for (field, expected) in cases {
7571 let (tx, mut rx) = mpsc::unbounded_channel();
7572 let known = KnownLiveOrders::shared(ExchangeId::AlpacaBroker);
7573 process_ws_text(
7574 &frame(field),
7575 &tx,
7576 &new_dedup_cache(),
7577 &known,
7578 &mut ExponentialBackoff::new(),
7579 );
7580 let Ok(UnindexedAccountEvent {
7581 kind: AccountEventKind::Trade(trade),
7582 ..
7583 }) = rx.try_recv()
7584 else {
7585 panic!("the fill frame {field:?} produces a Trade");
7586 };
7587 assert_eq!(trade.id.0.as_str(), expected, "frame {field:?}");
7588 }
7589 }
7590
7591 fn amendment_frame(event: &str, fields: &str) -> String {
7594 format!(
7595 r#"{{"stream":"trade_updates","data":{{"event":"{event}",{fields}"order":{{"id":"ord-1","client_order_id":"cid-1","symbol":"SPY","qty":"2","filled_qty":"0","side":"buy","type":"market","time_in_force":"day","status":"filled"}},"timestamp":"2025-04-18T14:31:00Z"}}}}"#
7596 )
7597 }
7598
7599 fn stream_events(frames: &[String]) -> Vec<UnindexedAccountEvent> {
7601 let (tx, mut rx) = mpsc::unbounded_channel();
7602 let dedup = new_dedup_cache();
7603 let known = KnownLiveOrders::shared(ExchangeId::AlpacaBroker);
7604 let mut backoff = ExponentialBackoff::new();
7605 for frame in frames {
7606 process_ws_text(frame, &tx, &dedup, &known, &mut backoff);
7607 }
7608 std::iter::from_fn(|| rx.try_recv().ok()).collect()
7609 }
7610
7611 fn amendment_of(
7613 frame: String,
7614 ) -> crate::trade::TradeAmendment<AssetNameExchange, InstrumentNameExchange> {
7615 let events = stream_events(&[frame]);
7616 let [
7617 UnindexedAccountEvent {
7618 kind: AccountEventKind::TradeAmended(amendment),
7619 ..
7620 },
7621 ] = <[_; 1]>::try_from(events).unwrap_or_else(|events| {
7622 panic!("one TradeAmended, got {events:?}");
7623 })
7624 else {
7625 panic!("expected TradeAmended");
7626 };
7627 amendment
7628 }
7629
7630 #[test]
7633 fn process_ws_text_reports_a_trade_bust() {
7634 let amendment = amendment_of(amendment_frame(
7635 "trade_bust",
7636 r#""execution_id":"exec-x","previous_execution_id":"exec-a","qty":"-2","#,
7637 ));
7638 assert_eq!(amendment.original, Some(TradeId::new("exec-a")));
7639 assert_eq!(amendment.order_id, OrderId::new("ord-1"));
7640 assert_eq!(amendment.instrument, InstrumentNameExchange::new("SPY"));
7641 assert_eq!(
7642 Some(amendment.time_exchange),
7643 parse_timestamp("2025-04-18T14:31:00Z")
7644 );
7645 assert_eq!(
7646 amendment.kind,
7647 TradeAmendmentKind::Busted {
7648 quantity: Some(Decimal::TWO)
7649 }
7650 );
7651 }
7652
7653 #[test]
7655 fn process_ws_text_reports_a_bust_that_names_no_fill() {
7656 let amendment = amendment_of(amendment_frame("trade_bust", ""));
7657 assert_eq!(amendment.original, None);
7658 assert_eq!(
7659 amendment.kind,
7660 TradeAmendmentKind::Busted { quantity: None }
7661 );
7662 }
7663
7664 #[test]
7667 fn process_ws_text_reports_a_trade_correct_with_its_replacement() {
7668 let amendment = amendment_of(amendment_frame(
7669 "trade_correct",
7670 r#""execution_id":"exec-c","previous_execution_id":"exec-a","price":"101.50","qty":"2","#,
7671 ));
7672 assert_eq!(amendment.original, Some(TradeId::new("exec-a")));
7673 let TradeAmendmentKind::Corrected { replacement } = amendment.kind else {
7674 panic!("a full replacement: {:?}", amendment.kind);
7675 };
7676 assert_eq!(replacement.id, TradeId::new("exec-c"));
7677 assert_eq!(replacement.order_id, OrderId::new("ord-1"));
7678 assert_eq!(replacement.side, Side::Buy);
7679 assert_eq!(replacement.price, Decimal::new(10150, 2));
7680 assert_eq!(replacement.quantity, Decimal::TWO);
7681 assert_eq!(replacement.order_filled_quantity, None);
7682 assert_eq!(replacement.time_exchange, amendment.time_exchange);
7683 }
7684
7685 #[test]
7688 fn process_ws_text_reports_a_correction_it_cannot_resolve() {
7689 let price = Some(Decimal::new(10150, 2));
7690 let cases = [
7691 (
7692 r#""execution_id":"exec-c","qty":"2","#,
7693 (Some("exec-c"), None, Some(Decimal::TWO)),
7694 ),
7695 (
7696 r#""price":"101.50","qty":"2","#,
7697 (None, price, Some(Decimal::TWO)),
7698 ),
7699 (
7700 r#""execution_id":"exec-c","price":"101.50","qty":"0","#,
7701 (Some("exec-c"), price, Some(Decimal::ZERO)),
7702 ),
7703 (
7704 r#""execution_id":"exec-c","price":"101.50","#,
7705 (Some("exec-c"), price, None),
7706 ),
7707 ];
7708 for (fields, (id, price, quantity)) in cases {
7709 let amendment = amendment_of(amendment_frame("trade_correct", fields));
7710 assert_eq!(
7711 amendment.kind,
7712 TradeAmendmentKind::CorrectedUnresolved {
7713 id: id.map(TradeId::new),
7714 price,
7715 quantity,
7716 },
7717 "{fields}"
7718 );
7719 }
7720 }
7721
7722 #[test]
7725 fn a_fill_after_a_bust_is_delivered_at_a_cumulative_seen_before() {
7726 let fill = |execution_id: &str| {
7727 format!(
7728 r#"{{"stream":"trade_updates","data":{{"event":"partial_fill","execution_id":"{execution_id}","order":{{"id":"ord-1","client_order_id":"cid-1","symbol":"SPY","qty":"4","filled_qty":"2","side":"buy","type":"market","time_in_force":"day","status":"partially_filled"}},"price":"100.00","qty":"2","timestamp":"2025-04-18T14:30:00Z"}}}}"#
7729 )
7730 };
7731 let events = stream_events(&[
7732 fill("exec-a"),
7733 amendment_frame(
7734 "trade_bust",
7735 r#""execution_id":"exec-x","previous_execution_id":"exec-a","qty":"-2","#,
7736 ),
7737 fill("exec-b"),
7738 ]);
7739
7740 let kinds: Vec<_> = events
7741 .iter()
7742 .filter_map(|event| match &event.kind {
7743 AccountEventKind::Trade(trade) => Some(format!("trade {}", trade.id)),
7744 AccountEventKind::TradeAmended(amendment) => {
7745 Some(format!("amended {:?}", amendment.original))
7746 }
7747 _ => None,
7748 })
7749 .collect();
7750 assert_eq!(
7751 kinds,
7752 vec![
7753 "trade exec-a",
7754 "amended Some(TradeId(\"exec-a\"))",
7755 "trade exec-b"
7756 ]
7757 );
7758 }
7759
7760 #[test]
7763 fn decimal_zero_normalize_is_zero_str() {
7764 assert_eq!(Decimal::ZERO.normalize().to_string(), "0");
7765 }
7766
7767 #[test]
7771 fn decimal_normalize_strips_trailing_zeros() {
7772 let from_rest = Decimal::from_str("1.00").unwrap().normalize();
7774 let from_ws = Decimal::from_str("1").unwrap().normalize();
7775 assert_eq!(from_rest.to_string(), from_ws.to_string());
7776 assert_eq!(from_rest.to_string(), "1");
7777
7778 assert_eq!(
7780 Decimal::from_str("100.000")
7781 .unwrap()
7782 .normalize()
7783 .to_string(),
7784 "100"
7785 );
7786 assert_eq!(
7787 Decimal::from_str("0.10").unwrap().normalize().to_string(),
7788 "0.1"
7789 );
7790 assert_eq!(
7791 Decimal::from_str("0.100").unwrap().normalize().to_string(),
7792 "0.1"
7793 );
7794 }
7795
7796 #[test]
7798 fn map_position_intent_open_buy_maps_to_buy_to_open() {
7799 assert_eq!(
7800 map_position_intent(Side::Buy, false),
7801 AlpacaPositionIntent::BuyToOpen
7802 );
7803 }
7804
7805 #[test]
7806 fn map_position_intent_open_sell_maps_to_sell_to_open() {
7807 assert_eq!(
7808 map_position_intent(Side::Sell, false),
7809 AlpacaPositionIntent::SellToOpen
7810 );
7811 }
7812
7813 #[test]
7814 fn map_position_intent_reduce_buy_maps_to_buy_to_close() {
7815 assert_eq!(
7816 map_position_intent(Side::Buy, true),
7817 AlpacaPositionIntent::BuyToClose
7818 );
7819 }
7820
7821 #[test]
7822 fn map_position_intent_reduce_sell_maps_to_sell_to_close() {
7823 assert_eq!(
7824 map_position_intent(Side::Sell, true),
7825 AlpacaPositionIntent::SellToClose
7826 );
7827 }
7828
7829 #[test]
7831 fn parse_order_error_401_maps_to_unauthenticated() {
7832 assert!(matches!(
7834 parse_order_error(reqwest::StatusCode::UNAUTHORIZED, None, "bad credentials"),
7835 UnindexedOrderError::Rejected(ApiError::Unauthenticated(_))
7836 ));
7837 }
7838
7839 #[test]
7840 fn parse_order_error_403_without_code_maps_to_unauthenticated() {
7841 assert!(matches!(
7843 parse_order_error(reqwest::StatusCode::FORBIDDEN, None, "forbidden."),
7844 UnindexedOrderError::Rejected(ApiError::Unauthenticated(_))
7845 ));
7846 }
7847
7848 #[test]
7849 fn parse_order_error_403_with_code_and_insufficient_maps_to_balance_insufficient() {
7850 for message in [
7851 "insufficient buying power",
7852 "insufficient day trading buying power",
7853 "insufficient qty available for order (requested: 10, available: 0)",
7854 ] {
7855 let err = parse_order_error(reqwest::StatusCode::FORBIDDEN, Some(40310000), message);
7856 let UnindexedOrderError::Rejected(ApiError::BalanceInsufficient(None, text)) = err
7857 else {
7858 panic!("expected BalanceInsufficient for {message:?}, got {err:?}");
7859 };
7860 assert_eq!(text, format!("40310000 {message}"));
7861 }
7862 }
7863
7864 #[test]
7865 fn parse_order_error_reads_a_refused_short_by_its_text() {
7866 for (status, code, message, reason) in [
7867 (
7868 reqwest::StatusCode::FORBIDDEN,
7869 Some(40310000),
7870 "account is not allowed to short",
7871 BorrowRejectReason::BorrowDisabled,
7872 ),
7873 (
7875 reqwest::StatusCode::FORBIDDEN,
7876 Some(40310000),
7877 "asset XYZ cannot be sold short",
7878 BorrowRejectReason::NotShortable,
7879 ),
7880 (
7881 reqwest::StatusCode::UNPROCESSABLE_ENTITY,
7882 Some(42210000),
7883 "asset XYZ cannot be sold short",
7884 BorrowRejectReason::NotShortable,
7885 ),
7886 (
7887 reqwest::StatusCode::UNPROCESSABLE_ENTITY,
7888 None,
7889 "account is not allowed to short",
7890 BorrowRejectReason::BorrowDisabled,
7891 ),
7892 ] {
7893 assert_eq!(
7894 parse_order_error(status, code, message),
7895 UnindexedOrderError::Rejected(ApiError::BorrowRejected(BorrowReject::new(
7896 reason,
7897 code.map(|code| code.to_string()),
7898 message.to_owned(),
7899 ))),
7900 "{status} {message:?}"
7901 );
7902 }
7903
7904 for (status, code) in [
7907 (reqwest::StatusCode::UNPROCESSABLE_ENTITY, 42210000),
7908 (reqwest::StatusCode::FORBIDDEN, 40310000),
7909 ] {
7910 assert_eq!(
7911 parse_order_error(status, Some(code), "fractional orders cannot be sold short"),
7912 UnindexedOrderError::Rejected(ApiError::OrderRejected(format!(
7913 "{code} fractional orders cannot be sold short"
7914 ))),
7915 "{status}"
7916 );
7917 }
7918
7919 assert!(matches!(
7921 parse_order_error(
7922 reqwest::StatusCode::FORBIDDEN,
7923 Some(40310000),
7924 "insufficient qty: account is not allowed to short",
7925 ),
7926 UnindexedOrderError::Rejected(ApiError::BorrowRejected(_))
7927 ));
7928
7929 assert!(matches!(
7931 parse_order_error(
7932 reqwest::StatusCode::FORBIDDEN,
7933 None,
7934 "account is not allowed to short"
7935 ),
7936 UnindexedOrderError::Rejected(ApiError::Unauthenticated(_))
7937 ));
7938 }
7939
7940 #[test]
7941 fn parse_order_error_403_with_code_maps_to_order_rejected() {
7942 for (code, message) in [
7943 (40310000, "account is restricted to liquidation only"),
7944 (
7945 40310100,
7946 "trade denied due to pattern day trading protection",
7947 ),
7948 ] {
7949 let err = parse_order_error(reqwest::StatusCode::FORBIDDEN, Some(code), message);
7950 let UnindexedOrderError::Rejected(ApiError::OrderRejected(text)) = err else {
7951 panic!("expected OrderRejected for {message:?}, got {err:?}");
7952 };
7953 assert_eq!(text, format!("{code} {message}"));
7954 }
7955 }
7956
7957 #[test]
7958 fn alpaca_api_error_reads_code_and_falls_back_to_raw_text() {
7959 let coded = AlpacaApiError::from_body(
7960 br#"{"code": 40310000, "message": "insufficient buying power"}"#,
7961 );
7962 assert_eq!(coded.code, Some(40310000));
7963 assert_eq!(coded.message, "insufficient buying power");
7964
7965 let bare = AlpacaApiError::from_body(br#"{"message": "forbidden."}"#);
7966 assert_eq!(bare.code, None);
7967 assert_eq!(bare.message, "forbidden.");
7968
7969 let raw = AlpacaApiError::from_body(b"upstream connect error");
7970 assert_eq!(raw.code, None);
7971 assert_eq!(raw.message, "upstream connect error");
7972 }
7973
7974 #[test]
7975 fn parse_order_error_404_maps_to_order_rejected_with_not_found_prefix() {
7976 let err = parse_order_error(reqwest::StatusCode::NOT_FOUND, None, "order not found");
7977 let UnindexedOrderError::Rejected(ApiError::OrderRejected(msg)) = err else {
7978 panic!("expected OrderRejected, got {err:?}");
7979 };
7980 assert!(
7981 msg.contains("order not found"),
7982 "message should contain 'order not found': {msg}"
7983 );
7984 }
7985
7986 #[test]
7987 fn parse_order_error_422_insufficient_only_maps_to_balance_insufficient() {
7988 assert!(matches!(
7990 parse_order_error(
7991 reqwest::StatusCode::UNPROCESSABLE_ENTITY,
7992 None,
7993 "insufficient funds for this order"
7994 ),
7995 UnindexedOrderError::Rejected(ApiError::BalanceInsufficient(None, _))
7996 ));
7997 }
7998
7999 #[test]
8000 fn parse_order_error_429_maps_to_rate_limit() {
8001 assert!(matches!(
8002 parse_order_error(reqwest::StatusCode::TOO_MANY_REQUESTS, None, "rate limited"),
8003 UnindexedOrderError::Rejected(ApiError::RateLimit {
8004 retry_after: None,
8005 ..
8006 })
8007 ));
8008 }
8009
8010 #[test]
8011 fn a_rate_limit_delay_is_capped_at_alpacas_window() {
8012 let window = Some(Duration::from_secs(DEFAULT_RATE_LIMIT_DELAY_SECS));
8013 for (name, value) in [("retry-after", u64::MAX), ("x-ratelimit-reset", u64::MAX)] {
8014 let mut headers = reqwest::header::HeaderMap::new();
8015 headers.insert(name, value.to_string().parse().unwrap());
8016 assert_eq!(parse_rate_limit_delay(&headers), window, "{name}");
8017 RateLimitTracker::new().on_rate_limited(parse_rate_limit_delay(&headers));
8019 }
8020
8021 let mut headers = reqwest::header::HeaderMap::new();
8022 headers.insert("retry-after", "0".parse().unwrap());
8023 assert_eq!(
8024 parse_rate_limit_delay(&headers),
8025 Some(Duration::ZERO),
8026 "taken as given"
8027 );
8028 }
8029
8030 #[test]
8034 fn parse_time_in_force_unknown_value_falls_back_to_good_until_end_of_day() {
8035 assert_eq!(
8036 parse_time_in_force("opg"),
8037 TimeInForce::GoodUntilEndOfDay,
8038 "unknown TIF must fall back to GoodUntilEndOfDay (with a warn! in production)"
8039 );
8040 }
8041
8042 #[test]
8045 fn build_instrument_snapshots_output_order_matches_instruments_slice() {
8046 let orders = vec![
8048 make_order_response("o1", "SPY"),
8049 make_order_response("o2", "AAPL"),
8050 make_order_response("o3", "MSFT"),
8051 ];
8052 let instruments = vec![
8054 InstrumentNameExchange::new("MSFT"),
8055 InstrumentNameExchange::new("AAPL"),
8056 ];
8057 let snapshots = build_instrument_snapshots(orders, Vec::new(), &instruments);
8058 assert_eq!(snapshots.len(), 2);
8059 assert_eq!(
8060 snapshots[0].instrument.name().as_str(),
8061 "MSFT",
8062 "first snapshot must be MSFT (first in instruments slice)"
8063 );
8064 assert_eq!(
8065 snapshots[1].instrument.name().as_str(),
8066 "AAPL",
8067 "second snapshot must be AAPL (second in instruments slice)"
8068 );
8069 }
8070
8071 mod http_tests {
8078 use super::super::*;
8079 use wiremock::matchers::{method, path};
8080 use wiremock::{Mock, MockServer, Request, Respond, ResponseTemplate};
8081
8082 struct Sequential {
8089 call: std::sync::atomic::AtomicU32,
8090 pages: Vec<serde_json::Value>,
8091 }
8092
8093 impl Sequential {
8094 fn new(pages: Vec<serde_json::Value>) -> Self {
8095 Self {
8096 call: std::sync::atomic::AtomicU32::new(0),
8097 pages,
8098 }
8099 }
8100 }
8101
8102 impl Respond for Sequential {
8103 fn respond(&self, _: &Request) -> ResponseTemplate {
8104 let i = self.call.fetch_add(1, std::sync::atomic::Ordering::Relaxed) as usize;
8105 let body = self.pages.get(i).unwrap_or_else(|| {
8106 panic!(
8107 "Sequential: request #{i} has no configured response \
8108 (only {} page(s) supplied)",
8109 self.pages.len()
8110 )
8111 });
8112 ResponseTemplate::new(200).set_body_json(body)
8113 }
8114 }
8115
8116 fn make_activities_json(count: usize, id_prefix: &str) -> serde_json::Value {
8118 serde_json::Value::Array(
8119 (0..count)
8120 .map(|i| {
8121 serde_json::json!({
8122 "id": format!("{id_prefix}-{i:05}"),
8123 "order_id": "ord-1",
8124 "symbol": "SPY",
8125 "side": "buy",
8126 "price": "100.00",
8127 "qty": "1",
8128 "transaction_time": "2025-04-18T14:30:00Z"
8129 })
8130 })
8131 .collect(),
8132 )
8133 }
8134
8135 fn make_orders_json(count: usize) -> serde_json::Value {
8137 serde_json::Value::Array(
8138 (0..count)
8139 .map(|i| {
8140 serde_json::json!({
8141 "id": format!("order-{i:05}"),
8142 "client_order_id": null,
8143 "symbol": "SPY",
8144 "qty": "1",
8145 "filled_qty": "0",
8146 "side": "buy",
8147 "type": "limit",
8148 "time_in_force": "day",
8149 "limit_price": "100.00",
8150 "created_at": "2025-04-18T14:30:00Z"
8151 })
8152 })
8153 .collect(),
8154 )
8155 }
8156
8157 #[tokio::test]
8162 async fn paginate_activities_single_page_below_max_returns_all_not_truncated() {
8163 let server = MockServer::start().await;
8164 Mock::given(method("GET"))
8165 .and(path("/v2/account/activities"))
8166 .respond_with(
8167 ResponseTemplate::new(200).set_body_json(make_activities_json(5, "act")),
8168 )
8169 .mount(&server)
8170 .await;
8171
8172 let http = reqwest::Client::new();
8173 let rl = RateLimitTracker::new();
8174 let result = paginate_activities::<AlpacaActivity>(
8175 &http,
8176 &rl,
8177 &server.uri(),
8178 FILL_ACTIVITIES,
8179 recovery_after(),
8180 None,
8181 )
8182 .await
8183 .unwrap();
8184
8185 assert_eq!(result.activities.len(), 5);
8186 assert_eq!(result.resume, None);
8187 assert_eq!(server.received_requests().await.unwrap().len(), 1);
8188 }
8189
8190 #[tokio::test]
8193 async fn paginate_activities_exactly_page_size_items_fetches_second_page() {
8194 let server = MockServer::start().await;
8195 Mock::given(method("GET"))
8196 .and(path("/v2/account/activities"))
8197 .respond_with(Sequential::new(vec![
8198 make_activities_json(ALPACA_MAX_ACTIVITIES, "act"),
8199 serde_json::json!([]), ]))
8201 .mount(&server)
8202 .await;
8203
8204 let http = reqwest::Client::new();
8205 let rl = RateLimitTracker::new();
8206 let result = paginate_activities::<AlpacaActivity>(
8207 &http,
8208 &rl,
8209 &server.uri(),
8210 FILL_ACTIVITIES,
8211 recovery_after(),
8212 None,
8213 )
8214 .await
8215 .unwrap();
8216
8217 assert_eq!(result.activities.len(), ALPACA_MAX_ACTIVITIES);
8218 assert_eq!(result.resume, None);
8219 assert_eq!(
8220 server.received_requests().await.unwrap().len(),
8221 2,
8222 "exactly 2 requests: first full page + second empty page"
8223 );
8224 }
8225
8226 #[tokio::test]
8229 async fn paginate_activities_two_pages_returns_combined_activities_not_truncated() {
8230 let server = MockServer::start().await;
8231 Mock::given(method("GET"))
8232 .and(path("/v2/account/activities"))
8233 .respond_with(Sequential::new(vec![
8234 make_activities_json(ALPACA_MAX_ACTIVITIES, "p1"),
8235 make_activities_json(37, "p2"),
8236 ]))
8237 .mount(&server)
8238 .await;
8239
8240 let http = reqwest::Client::new();
8241 let rl = RateLimitTracker::new();
8242 let result = paginate_activities::<AlpacaActivity>(
8243 &http,
8244 &rl,
8245 &server.uri(),
8246 FILL_ACTIVITIES,
8247 recovery_after(),
8248 None,
8249 )
8250 .await
8251 .unwrap();
8252
8253 assert_eq!(result.activities.len(), ALPACA_MAX_ACTIVITIES + 37);
8254 assert_eq!(result.resume, None);
8255 assert_eq!(server.received_requests().await.unwrap().len(), 2);
8256 }
8257
8258 #[tokio::test]
8262 async fn paginate_activities_at_max_pages_sets_truncated_true() {
8263 let server = MockServer::start().await;
8264
8265 Mock::given(method("GET"))
8267 .and(path("/v2/account/activities"))
8268 .respond_with(
8269 ResponseTemplate::new(200)
8270 .set_body_json(make_activities_json(ALPACA_MAX_ACTIVITIES, "act")),
8271 )
8272 .mount(&server)
8273 .await;
8274
8275 let http = reqwest::Client::new();
8276 let rl = RateLimitTracker::new();
8277 let result = paginate_activities::<AlpacaActivity>(
8278 &http,
8279 &rl,
8280 &server.uri(),
8281 FILL_ACTIVITIES,
8282 recovery_after(),
8283 None,
8284 )
8285 .await
8286 .unwrap();
8287
8288 assert!(
8289 result.resume.is_some(),
8290 "must say where to read on after MAX_ACTIVITY_PAGES pages"
8291 );
8292 assert_eq!(
8293 result.activities.len(),
8294 MAX_ACTIVITY_PAGES * ALPACA_MAX_ACTIVITIES,
8295 "must accumulate exactly MAX_ACTIVITY_PAGES * page_size activities"
8296 );
8297 assert_eq!(
8300 server.received_requests().await.unwrap().len(),
8301 MAX_ACTIVITY_PAGES,
8302 "loop must issue exactly MAX_ACTIVITY_PAGES requests then stop"
8303 );
8304 }
8305
8306 #[tokio::test]
8310 async fn fetch_raw_open_orders_499_results_returns_ok() {
8311 let server = MockServer::start().await;
8312 Mock::given(method("GET"))
8313 .and(path("/v2/orders"))
8314 .respond_with(
8315 ResponseTemplate::new(200).set_body_json(make_orders_json(MAX_OPEN_ORDERS - 1)),
8316 )
8317 .mount(&server)
8318 .await;
8319
8320 let http = reqwest::Client::new();
8321 let rl = RateLimitTracker::new();
8322 let result = fetch_raw_open_orders(&http, &rl, &server.uri(), &[]).await;
8323
8324 assert!(
8325 result.is_ok(),
8326 "499 orders must not trigger truncation: {result:?}"
8327 );
8328 assert_eq!(result.unwrap().len(), MAX_OPEN_ORDERS - 1);
8329 }
8330
8331 #[tokio::test]
8335 async fn fetch_raw_open_orders_500_results_returns_truncated_snapshot_error() {
8336 let server = MockServer::start().await;
8337 Mock::given(method("GET"))
8338 .and(path("/v2/orders"))
8339 .respond_with(
8340 ResponseTemplate::new(200).set_body_json(make_orders_json(MAX_OPEN_ORDERS)),
8341 )
8342 .mount(&server)
8343 .await;
8344
8345 let http = reqwest::Client::new();
8346 let rl = RateLimitTracker::new();
8347 let result = fetch_raw_open_orders(&http, &rl, &server.uri(), &[]).await;
8348
8349 assert!(
8350 matches!(
8351 result,
8352 Err(UnindexedClientError::TruncatedSnapshot { limit }) if limit == MAX_OPEN_ORDERS
8353 ),
8354 "500 orders must return TruncatedSnapshot, got: {result:?}"
8355 );
8356 }
8357
8358 async fn mount_account_and_positions(server: &MockServer) {
8363 Mock::given(method("GET"))
8364 .and(path("/v2/account"))
8365 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
8366 "status": "ACTIVE",
8367 "currency": "USD",
8368 "cash": "-1500.50",
8369 "equity": "4000.00",
8370 "buying_power": "5000.00",
8371 "options_buying_power": "2000.00",
8372 "non_marginable_buying_power": "1000.00",
8373 })))
8374 .mount(server)
8375 .await;
8376 Mock::given(method("GET"))
8377 .and(path("/v2/positions"))
8378 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([
8379 {
8380 "symbol": "XYZ",
8381 "asset_class": "us_equity",
8382 "side": "short",
8383 "qty": "-10",
8384 "qty_available": "-10",
8385 "avg_entry_price": "20.00",
8386 "unrealized_pl": "15.50",
8387 },
8388 {
8389 "symbol": "XYZ271217C00025000",
8390 "asset_class": "us_option",
8391 "side": "long",
8392 "qty": "3",
8393 "qty_available": "3",
8394 "avg_entry_price": "0.85",
8395 "unrealized_pl": "-45",
8396 },
8397 {
8398 "symbol": "ABC",
8399 "asset_class": "us_equity",
8400 "side": "long",
8401 "qty": "2.5",
8402 "qty_available": "2.5",
8403 "avg_entry_price": "40.00",
8404 "unrealized_pl": "1.25",
8405 },
8406 {
8407 "symbol": "BTCUSD",
8408 "asset_class": "crypto",
8409 "side": "long",
8410 "qty": "0.75",
8411 "qty_available": "0.5",
8412 "avg_entry_price": "50000",
8413 "unrealized_pl": "0",
8414 },
8415 ])))
8416 .mount(server)
8417 .await;
8418 Mock::given(method("GET"))
8419 .and(path("/v2/orders"))
8420 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([])))
8421 .mount(server)
8422 .await;
8423 }
8424
8425 fn client_for(server: &MockServer) -> AlpacaClient {
8426 AlpacaClient::new(AlpacaConfig::with_base_url(
8427 "test-key".into(),
8428 "test-secret".into(),
8429 server.uri(),
8430 ))
8431 }
8432
8433 #[tokio::test]
8434 async fn account_snapshot_reports_cash_positions_and_crypto_balances() {
8435 use crate::client::ExecutionClient;
8436 use rust_decimal_macros::dec;
8437
8438 let server = MockServer::start().await;
8439 mount_account_and_positions(&server).await;
8440
8441 let snapshot = client_for(&server)
8442 .account_snapshot(&[], &[])
8443 .await
8444 .unwrap();
8445
8446 let balances: Vec<_> = snapshot
8448 .balances
8449 .iter()
8450 .map(|b| (b.asset.name().as_str(), b.balance.total, b.balance.free))
8451 .collect();
8452 assert_eq!(
8453 balances,
8454 vec![
8455 ("USD", dec!(-1500.50), dec!(-1500.50)),
8456 ("BTC", dec!(0.75), dec!(0.5)),
8457 ],
8458 "crypto stays a balance; equities and options do not appear as balances"
8459 );
8460
8461 let positions: Vec<_> = snapshot
8462 .instruments
8463 .iter()
8464 .map(|i| {
8465 let p = i.position.open().unwrap();
8466 (
8467 i.instrument.name().as_str(),
8468 p.quantity,
8469 p.entry_price,
8470 p.unrealized_pnl,
8471 )
8472 })
8473 .collect();
8474 assert_eq!(
8475 positions,
8476 vec![
8477 ("XYZ", dec!(-10), Some(dec!(20.00)), Some(dec!(15.50))),
8478 (
8479 "XYZ271217C00025000",
8480 dec!(3),
8481 Some(dec!(0.85)),
8482 Some(dec!(-45))
8483 ),
8484 ("ABC", dec!(2.5), Some(dec!(40.00)), Some(dec!(1.25))),
8485 ],
8486 "short equity, option and fractional positions; no crypto position"
8487 );
8488 }
8489
8490 #[tokio::test]
8493 async fn account_snapshot_usd_only_still_reports_positions() {
8494 use crate::client::ExecutionClient;
8495 use rust_decimal_macros::dec;
8496
8497 let server = MockServer::start().await;
8498 mount_account_and_positions(&server).await;
8499
8500 let usd = [AssetNameExchange::new("USD")];
8501 let instruments = [InstrumentNameExchange::new("XYZ")];
8502 let snapshot = client_for(&server)
8503 .account_snapshot(&usd, &instruments)
8504 .await
8505 .unwrap();
8506
8507 assert_eq!(snapshot.balances.len(), 1);
8508 assert_eq!(snapshot.balances[0].asset, AssetNameExchange::new("USD"));
8509 assert_eq!(snapshot.balances[0].balance.total, dec!(-1500.50));
8510
8511 assert_eq!(snapshot.instruments.len(), 1);
8512 assert_eq!(snapshot.instruments[0].position.quantity(), Some(dec!(-10)));
8513 }
8514
8515 #[tokio::test]
8517 async fn account_snapshot_non_usd_only_skips_account_request() {
8518 use crate::client::ExecutionClient;
8519
8520 let server = MockServer::start().await;
8521 mount_account_and_positions(&server).await;
8522
8523 let btc = [AssetNameExchange::new("btc")];
8524 let snapshot = client_for(&server)
8525 .account_snapshot(&btc, &[])
8526 .await
8527 .unwrap();
8528
8529 assert_eq!(snapshot.balances.len(), 1);
8530 assert_eq!(snapshot.balances[0].asset, AssetNameExchange::new("BTC"));
8531 let requests = server.received_requests().await.unwrap();
8532 assert!(
8533 requests.iter().all(|r| r.url.path() != "/v2/account"),
8534 "no USD requested, so /v2/account must not be fetched"
8535 );
8536 }
8537
8538 #[tokio::test]
8546 async fn open_order_reduce_only_sell_sends_sell_to_close_intent() {
8547 use crate::client::ExecutionClient;
8548 use crate::order::request::{OrderRequestOpen, RequestOpen};
8549 use crate::order::{
8550 OrderKey, OrderKind, TimeInForce,
8551 id::{ClientOrderId, StrategyId},
8552 };
8553 use rust_decimal::Decimal;
8554 use rustrade_instrument::Side;
8555 use rustrade_instrument::exchange::ExchangeId;
8556 use rustrade_instrument::instrument::name::InstrumentNameExchange;
8557 use wiremock::matchers::{method, path};
8558
8559 let server = MockServer::start().await;
8560
8561 let captured_body = std::sync::Arc::new(parking_lot::Mutex::new(None));
8564 let captured_clone = captured_body.clone();
8565
8566 Mock::given(method("POST"))
8567 .and(path("/v2/orders"))
8568 .respond_with(move |req: &Request| {
8569 let body: serde_json::Value = serde_json::from_slice(&req.body).unwrap();
8571 *captured_clone.lock() = Some(body);
8572
8573 ResponseTemplate::new(200).set_body_json(serde_json::json!({
8574 "id": "test-order-id",
8575 "client_order_id": "test-cid",
8576 "symbol": "AAPL",
8577 "qty": "10",
8578 "filled_qty": "0",
8579 "side": "sell",
8580 "type": "market",
8581 "time_in_force": "ioc",
8582 "limit_price": null,
8583 "created_at": "2025-04-18T14:30:00Z"
8584 }))
8585 })
8586 .mount(&server)
8587 .await;
8588
8589 let config =
8591 AlpacaConfig::with_base_url("test-key".into(), "test-secret".into(), server.uri());
8592 let client = AlpacaClient::new(config);
8593
8594 let request = OrderRequestOpen {
8596 key: OrderKey {
8597 exchange: ExchangeId::AlpacaBroker,
8598 instrument: InstrumentNameExchange::new("AAPL"),
8599 strategy: StrategyId::new("test-strategy"),
8600 cid: ClientOrderId::new("test-cid"),
8601 },
8602 state: RequestOpen {
8603 side: Side::Sell,
8604 price: None,
8605 quantity: Decimal::new(10, 0),
8606 kind: OrderKind::Market,
8607 time_in_force: TimeInForce::ImmediateOrCancel,
8608 position_id: None,
8609 reduce_only: true, market: None,
8611 },
8612 };
8613
8614 let order = client
8616 .open_order(OrderRequestOpen {
8617 key: OrderKey {
8618 exchange: request.key.exchange,
8619 instrument: &request.key.instrument,
8620 strategy: request.key.strategy.clone(),
8621 cid: request.key.cid.clone(),
8622 },
8623 state: request.state.clone(),
8624 })
8625 .await;
8626
8627 assert!(
8629 order.state.is_accepted(),
8630 "order should be accepted: {:?}",
8631 order.state
8632 );
8633
8634 let body = captured_body
8636 .lock()
8637 .take()
8638 .expect("request body should be captured");
8639 assert_eq!(
8640 body.get("position_intent").and_then(|v| v.as_str()),
8641 Some("sell_to_close"),
8642 "reduce_only=true + Side::Sell should produce position_intent=sell_to_close, got: {body}"
8643 );
8644 }
8645
8646 #[tokio::test]
8648 async fn open_order_not_reduce_only_buy_sends_buy_to_open_intent() {
8649 use crate::client::ExecutionClient;
8650 use crate::order::request::{OrderRequestOpen, RequestOpen};
8651 use crate::order::{
8652 OrderKey, OrderKind, TimeInForce,
8653 id::{ClientOrderId, StrategyId},
8654 };
8655 use rust_decimal::Decimal;
8656 use rustrade_instrument::Side;
8657 use rustrade_instrument::exchange::ExchangeId;
8658 use rustrade_instrument::instrument::name::InstrumentNameExchange;
8659
8660 let server = MockServer::start().await;
8661
8662 let captured_body = std::sync::Arc::new(parking_lot::Mutex::new(None));
8663 let captured_clone = captured_body.clone();
8664
8665 Mock::given(method("POST"))
8666 .and(path("/v2/orders"))
8667 .respond_with(move |req: &Request| {
8668 let body: serde_json::Value = serde_json::from_slice(&req.body).unwrap();
8669 *captured_clone.lock() = Some(body);
8670
8671 ResponseTemplate::new(200).set_body_json(serde_json::json!({
8672 "id": "test-order-id",
8673 "client_order_id": "test-cid",
8674 "symbol": "AAPL",
8675 "qty": "10",
8676 "filled_qty": "0",
8677 "side": "buy",
8678 "type": "market",
8679 "time_in_force": "ioc",
8680 "limit_price": null,
8681 "created_at": "2025-04-18T14:30:00Z"
8682 }))
8683 })
8684 .mount(&server)
8685 .await;
8686
8687 let config =
8688 AlpacaConfig::with_base_url("test-key".into(), "test-secret".into(), server.uri());
8689 let client = AlpacaClient::new(config);
8690
8691 let instrument = InstrumentNameExchange::new("AAPL");
8692 let request = OrderRequestOpen {
8693 key: OrderKey {
8694 exchange: ExchangeId::AlpacaBroker,
8695 instrument: &instrument,
8696 strategy: StrategyId::new("test-strategy"),
8697 cid: ClientOrderId::new("test-cid"),
8698 },
8699 state: RequestOpen {
8700 side: Side::Buy,
8701 price: None,
8702 quantity: Decimal::new(10, 0),
8703 kind: OrderKind::Market,
8704 time_in_force: TimeInForce::ImmediateOrCancel,
8705 position_id: None,
8706 reduce_only: false, market: None,
8708 },
8709 };
8710
8711 let order = client.open_order(request).await;
8712
8713 assert!(
8714 order.state.is_accepted(),
8715 "order should be accepted: {:?}",
8716 order.state
8717 );
8718
8719 let body = captured_body
8720 .lock()
8721 .take()
8722 .expect("request body should be captured");
8723 assert_eq!(
8724 body.get("position_intent").and_then(|v| v.as_str()),
8725 Some("buy_to_open"),
8726 "reduce_only=false + Side::Buy should produce position_intent=buy_to_open, got: {body}"
8727 );
8728 }
8729
8730 #[tokio::test]
8733 async fn rest_with_retry_reports_an_undecodable_2xx_body_as_internal() {
8734 let server = MockServer::start().await;
8735 Mock::given(method("GET"))
8736 .and(path("/v2/account"))
8737 .respond_with(ResponseTemplate::new(200).set_body_string("not json"))
8738 .mount(&server)
8739 .await;
8740
8741 let http = reqwest::Client::new();
8742 let rl = RateLimitTracker::new();
8743 let url = format!("{}/v2/account", server.uri());
8744 let result: Result<serde_json::Value, _> =
8745 rest_with_retry(&rl, || http.get(&url)).await;
8746 assert!(
8747 matches!(result, Err(UnindexedClientError::Internal(_))),
8748 "{result:?}"
8749 );
8750 }
8751
8752 async fn holds_back(rl: &RateLimitTracker) -> bool {
8754 tokio::time::timeout(Duration::from_millis(50), rl.wait_if_blocked())
8755 .await
8756 .is_err()
8757 }
8758
8759 fn with_rate_limit(
8761 template: ResponseTemplate,
8762 remaining: u32,
8763 reset: bool,
8764 ) -> ResponseTemplate {
8765 let template = template.insert_header("x-ratelimit-remaining", remaining.to_string());
8766 if !reset {
8767 return template;
8768 }
8769 let now_secs = std::time::SystemTime::now()
8770 .duration_since(std::time::UNIX_EPOCH)
8771 .unwrap()
8772 .as_secs();
8773 template.insert_header("x-ratelimit-reset", (now_secs + 30).to_string())
8774 }
8775
8776 #[tokio::test]
8779 async fn rest_with_retry_pauses_once_no_requests_remain() {
8780 for (remaining, reset, pauses) in [(0, true, true), (1, true, false), (0, false, false)]
8781 {
8782 let server = MockServer::start().await;
8783 Mock::given(method("GET"))
8784 .and(path("/v2/account"))
8785 .respond_with(with_rate_limit(
8786 ResponseTemplate::new(200).set_body_json(serde_json::json!({})),
8787 remaining,
8788 reset,
8789 ))
8790 .mount(&server)
8791 .await;
8792
8793 let http = reqwest::Client::new();
8794 let rl = RateLimitTracker::new();
8795 let url = format!("{}/v2/account", server.uri());
8796 let _: serde_json::Value = rest_with_retry(&rl, || http.get(&url)).await.unwrap();
8797 assert_eq!(
8798 holds_back(&rl).await,
8799 pauses,
8800 "remaining {remaining}, reset {reset}"
8801 );
8802 }
8803 }
8804
8805 #[tokio::test]
8808 async fn a_pause_lasts_a_minute_at_most() {
8809 tokio::time::pause();
8810 let now_secs = std::time::SystemTime::now()
8811 .duration_since(std::time::UNIX_EPOCH)
8812 .unwrap()
8813 .as_secs();
8814 let mut headers = reqwest::header::HeaderMap::new();
8815 headers.insert("x-ratelimit-remaining", "0".parse().unwrap());
8816 headers.insert(
8817 "x-ratelimit-reset",
8818 (now_secs + 3_600).to_string().parse().unwrap(),
8819 );
8820 let rl = RateLimitTracker::new();
8821
8822 observe_rate_limit_remaining(&rl, &headers);
8823 assert!(holds_back(&rl).await);
8824 tokio::time::advance(Duration::from_secs(DEFAULT_RATE_LIMIT_DELAY_SECS)).await;
8825 assert!(!holds_back(&rl).await);
8826 }
8827
8828 #[tokio::test]
8830 async fn rest_delete_with_retry_pauses_once_no_requests_remain() {
8831 let server = MockServer::start().await;
8832 Mock::given(method("DELETE"))
8833 .and(path("/v2/orders/abc"))
8834 .respond_with(with_rate_limit(ResponseTemplate::new(204), 0, true))
8835 .mount(&server)
8836 .await;
8837
8838 let http = reqwest::Client::new();
8839 let rl = RateLimitTracker::new();
8840 let url = format!("{}/v2/orders/abc", server.uri());
8841 rest_delete_with_retry(&rl, || http.delete(&url))
8842 .await
8843 .unwrap();
8844 assert!(holds_back(&rl).await);
8845 }
8846
8847 #[tokio::test]
8850 async fn open_order_with_an_undecodable_2xx_response_is_status_unknown() {
8851 use crate::client::ExecutionClient;
8852 use crate::error::OrderError;
8853 use crate::order::request::{OrderRequestOpen, RequestOpen};
8854 use crate::order::state::{InactiveOrderState, OrderState};
8855 use crate::order::{
8856 OrderKey, OrderKind, TimeInForce,
8857 id::{ClientOrderId, StrategyId},
8858 };
8859 use rustrade_instrument::Side;
8860 use rustrade_instrument::exchange::ExchangeId;
8861 use rustrade_instrument::instrument::name::InstrumentNameExchange;
8862
8863 let server = MockServer::start().await;
8864 Mock::given(method("POST"))
8865 .and(path("/v2/orders"))
8866 .respond_with(
8867 ResponseTemplate::new(200).set_body_json(serde_json::json!({ "id": 1 })),
8868 )
8869 .mount(&server)
8870 .await;
8871
8872 let instrument = InstrumentNameExchange::new("AAPL");
8873 let order = client_for(&server)
8874 .open_order(OrderRequestOpen {
8875 key: OrderKey {
8876 exchange: ExchangeId::AlpacaBroker,
8877 instrument: &instrument,
8878 strategy: StrategyId::new("test-strategy"),
8879 cid: ClientOrderId::new("test-cid"),
8880 },
8881 state: RequestOpen {
8882 side: Side::Buy,
8883 price: None,
8884 quantity: Decimal::new(10, 0),
8885 kind: OrderKind::Market,
8886 time_in_force: TimeInForce::ImmediateOrCancel,
8887 position_id: None,
8888 reduce_only: false,
8889 market: None,
8890 },
8891 })
8892 .await;
8893
8894 assert!(
8895 matches!(
8896 order.state,
8897 OrderState::Inactive(InactiveOrderState::OpenFailed(OrderError::Connectivity(
8898 _
8899 )))
8900 ),
8901 "{:?}",
8902 order.state
8903 );
8904 }
8905
8906 #[tokio::test]
8909 async fn open_order_reports_a_403_by_its_code() {
8910 use crate::client::ExecutionClient;
8911 use crate::error::OrderError;
8912 use crate::order::request::{OrderRequestOpen, RequestOpen};
8913 use crate::order::state::{InactiveOrderState, OrderState};
8914 use crate::order::{
8915 OrderKey, OrderKind, TimeInForce,
8916 id::{ClientOrderId, StrategyId},
8917 };
8918 use rustrade_instrument::Side;
8919 use rustrade_instrument::exchange::ExchangeId;
8920 use rustrade_instrument::instrument::name::InstrumentNameExchange;
8921
8922 async fn open_against(
8923 status: u16,
8924 body: serde_json::Value,
8925 ) -> OrderState<AssetNameExchange, InstrumentNameExchange> {
8926 let server = MockServer::start().await;
8927 Mock::given(method("POST"))
8928 .and(path("/v2/orders"))
8929 .respond_with(ResponseTemplate::new(status).set_body_json(body))
8930 .mount(&server)
8931 .await;
8932 let instrument = InstrumentNameExchange::new("AAPL");
8933 client_for(&server)
8934 .open_order(OrderRequestOpen {
8935 key: OrderKey {
8936 exchange: ExchangeId::AlpacaBroker,
8937 instrument: &instrument,
8938 strategy: StrategyId::new("test-strategy"),
8939 cid: ClientOrderId::new("test-cid"),
8940 },
8941 state: RequestOpen {
8942 side: Side::Sell,
8943 price: None,
8944 quantity: Decimal::new(10, 0),
8945 kind: OrderKind::Market,
8946 time_in_force: TimeInForce::ImmediateOrCancel,
8947 position_id: None,
8948 reduce_only: false,
8949 market: None,
8950 },
8951 })
8952 .await
8953 .state
8954 }
8955
8956 let rejected =
8957 |state: &OrderState<AssetNameExchange, InstrumentNameExchange>| match state {
8958 OrderState::Inactive(InactiveOrderState::OpenFailed(OrderError::Rejected(
8959 e,
8960 ))) => e.clone(),
8961 other => panic!("expected a rejection, got {other:?}"),
8962 };
8963
8964 let state = open_against(
8965 403,
8966 serde_json::json!({
8967 "code": 40310000,
8968 "message": "insufficient buying power"
8969 }),
8970 )
8971 .await;
8972 assert_eq!(
8973 rejected(&state),
8974 ApiError::BalanceInsufficient(None, "40310000 insufficient buying power".into())
8975 );
8976
8977 let state = open_against(
8978 403,
8979 serde_json::json!({
8980 "code": 40310000,
8981 "message": "account is not allowed to short"
8982 }),
8983 )
8984 .await;
8985 assert_eq!(
8986 rejected(&state),
8987 ApiError::BorrowRejected(BorrowReject::new(
8988 BorrowRejectReason::BorrowDisabled,
8989 Some("40310000".into()),
8990 "account is not allowed to short".into(),
8991 ))
8992 );
8993
8994 let state = open_against(
8996 422,
8997 serde_json::json!({
8998 "code": 42210000,
8999 "message": "asset XYZ cannot be sold short"
9000 }),
9001 )
9002 .await;
9003 assert_eq!(
9004 rejected(&state),
9005 ApiError::BorrowRejected(BorrowReject::new(
9006 BorrowRejectReason::NotShortable,
9007 Some("42210000".into()),
9008 "asset XYZ cannot be sold short".into(),
9009 ))
9010 );
9011
9012 let state = open_against(403, serde_json::json!({ "message": "forbidden." })).await;
9013 assert!(
9014 matches!(rejected(&state), ApiError::Unauthenticated(_)),
9015 "{state:?}"
9016 );
9017 }
9018
9019 #[tokio::test]
9022 async fn open_bracket_order_with_an_undecodable_2xx_response_is_status_unknown() {
9023 use crate::error::OrderError;
9024 use crate::order::state::{InactiveOrderState, OrderState};
9025 use rustrade_instrument::instrument::name::InstrumentNameExchange;
9026
9027 let server = MockServer::start().await;
9028 Mock::given(method("POST"))
9029 .and(path("/v2/orders"))
9030 .respond_with(
9031 ResponseTemplate::new(200).set_body_json(serde_json::json!({ "id": 1 })),
9032 )
9033 .mount(&server)
9034 .await;
9035
9036 let result = client_for(&server)
9037 .open_bracket_order(AlpacaBracketOrderRequest::new(
9038 InstrumentNameExchange::new("SPY"),
9039 crate::order::id::StrategyId::new("test"),
9040 crate::order::id::ClientOrderId::new("test-bracket"),
9041 Side::Buy,
9042 Decimal::ONE,
9043 Decimal::new(100, 0),
9044 Decimal::new(120, 0),
9045 Decimal::new(90, 0),
9046 TimeInForce::GoodUntilCancelled { post_only: false },
9047 ))
9048 .await;
9049
9050 assert!(
9051 matches!(
9052 result.parent.state,
9053 OrderState::Inactive(InactiveOrderState::OpenFailed(OrderError::Connectivity(
9054 _
9055 )))
9056 ),
9057 "{:?}",
9058 result.parent.state
9059 );
9060 }
9061
9062 fn recovery_after() -> DateTime<Utc> {
9071 "2025-01-01T00:00:00Z".parse().expect("valid time")
9072 }
9073
9074 fn activity_json(
9077 id: &str,
9078 order_id: &str,
9079 qty: &str,
9080 cum_qty: Option<&str>,
9081 ) -> serde_json::Value {
9082 let mut v = serde_json::json!({
9083 "id": id,
9084 "order_id": order_id,
9085 "symbol": "SPY",
9086 "side": "buy",
9087 "price": "100.00",
9088 "qty": qty,
9089 "transaction_time": "2025-04-18T14:30:00Z"
9090 });
9091 if let Some(c) = cum_qty {
9092 v["cum_qty"] = serde_json::Value::String(c.to_string());
9093 }
9094 v
9095 }
9096
9097 async fn drive_recover_fills_with(
9100 activities: Vec<serde_json::Value>,
9101 dedup: SharedDedupCache,
9102 known: &SharedKnownLiveOrders,
9103 ) -> Vec<UnindexedAccountEvent> {
9104 let server = MockServer::start().await;
9105 Mock::given(method("GET"))
9106 .and(path("/v2/account/activities"))
9107 .respond_with(
9108 ResponseTemplate::new(200).set_body_json(serde_json::Value::Array(activities)),
9109 )
9110 .mount(&server)
9111 .await;
9112
9113 let http = reqwest::Client::new();
9114 let rl = RateLimitTracker::new();
9115 let (tx, mut rx) = mpsc::unbounded_channel();
9116 let outcome = recover_fills(
9117 &http,
9118 &rl,
9119 &[],
9120 &server.uri(),
9121 recovery_after(),
9122 &tx,
9123 &dedup,
9124 known,
9125 )
9126 .await;
9127 assert_eq!(outcome, Ok(()), "a full read leaves nothing unrecovered");
9128 drop(tx);
9129
9130 let mut out = Vec::new();
9131 while let Ok(event) = rx.try_recv() {
9132 out.push(event);
9133 }
9134 out
9135 }
9136
9137 async fn drive_recover_fills(
9138 activities: Vec<serde_json::Value>,
9139 ) -> Vec<UnindexedAccountEvent> {
9140 drive_recover_fills_with(
9141 activities,
9142 new_dedup_cache(),
9143 &KnownLiveOrders::shared(ExchangeId::AlpacaBroker),
9144 )
9145 .await
9146 }
9147
9148 async fn recover_fills_from(
9151 server: &MockServer,
9152 ) -> (Result<(), UnreadFills>, Vec<UnindexedAccountEvent>) {
9153 let (tx, mut rx) = mpsc::unbounded_channel();
9154 let outcome = recover_fills(
9155 &reqwest::Client::new(),
9156 &RateLimitTracker::new(),
9157 &[],
9158 &server.uri(),
9159 recovery_after(),
9160 &tx,
9161 &new_dedup_cache(),
9162 &KnownLiveOrders::shared(ExchangeId::AlpacaBroker),
9163 )
9164 .await;
9165 drop(tx);
9166 (outcome, std::iter::from_fn(|| rx.try_recv().ok()).collect())
9167 }
9168
9169 #[tokio::test]
9172 async fn a_failed_recovery_read_leaves_every_fill_since_the_disconnect_unread() {
9173 let server = MockServer::start().await;
9174 Mock::given(method("GET"))
9175 .and(path("/v2/account/activities"))
9176 .respond_with(ResponseTemplate::new(500).set_body_string("unavailable"))
9177 .mount(&server)
9178 .await;
9179
9180 let (outcome, events) = recover_fills_from(&server).await;
9181
9182 let Err(UnreadFills {
9183 start,
9184 reason: FillRecoveryFailure::Request(_),
9185 }) = outcome
9186 else {
9187 panic!("expected the request's failure, got {outcome:?}");
9188 };
9189 assert_eq!(start, recovery_after(), "from the disconnect");
9190 assert!(events.is_empty(), "nothing forwarded: {events:?}");
9191 }
9192
9193 #[tokio::test]
9198 async fn a_truncated_recovery_read_delivers_what_it_read_and_leaves_the_rest_unread() {
9199 let time = |s: &str| s.parse::<DateTime<Utc>>().expect("valid time");
9200 let cases = [
9202 (
9203 Some("2025-04-18T14:30:01.234567Z"),
9204 time("2025-04-18T14:30:01.234Z"),
9205 ),
9206 (Some("not a time"), time("2025-04-18T14:30:00Z")),
9207 (None, recovery_after()),
9208 ];
9209 for (last_time, expected_start) in cases {
9210 let mut page = make_activities_json(ALPACA_MAX_ACTIVITIES, "act");
9211 let Some(activities) = page.as_array_mut() else {
9212 panic!("an array of activities");
9213 };
9214 match last_time {
9215 Some(last_time) => {
9216 if let Some(last) = activities.last_mut() {
9217 last["transaction_time"] =
9218 serde_json::Value::String(last_time.to_string());
9219 }
9220 }
9221 None => {
9222 for activity in activities.iter_mut() {
9223 activity["transaction_time"] =
9224 serde_json::Value::String("not a time".to_string());
9225 }
9226 }
9227 }
9228 let server = MockServer::start().await;
9229 Mock::given(method("GET"))
9230 .and(path("/v2/account/activities"))
9231 .respond_with(ResponseTemplate::new(200).set_body_json(page))
9232 .mount(&server)
9233 .await;
9234
9235 let (outcome, events) = recover_fills_from(&server).await;
9236
9237 let fills_read = MAX_ACTIVITY_PAGES * ALPACA_MAX_ACTIVITIES;
9238 assert_eq!(
9239 outcome,
9240 Err(UnreadFills {
9241 start: expected_start,
9242 reason: FillRecoveryFailure::Truncated { fills_read },
9243 }),
9244 "last time {last_time:?}"
9245 );
9246 assert_eq!(events.len(), fills_read, "every fill read is forwarded");
9247 }
9248 }
9249
9250 #[test]
9253 fn a_fill_recovery_give_up_covers_every_instrument_the_stream_does() {
9254 let start = recovery_after();
9255 let end = start + chrono::Duration::minutes(5);
9256 let spy = InstrumentNameExchange::new("SPY");
9257 let qqq = InstrumentNameExchange::new("QQQ");
9258 let cases = [
9259 (vec![], FillRecoveryScope::AllInstruments),
9260 (
9261 vec![spy.clone(), qqq.clone()],
9262 FillRecoveryScope::Instruments(vec![spy, qqq]),
9263 ),
9264 ];
9265 for (instruments, scope) in cases {
9266 let reason = FillRecoveryFailure::TimedOut { timeout_secs: 30 };
9267 let unread = UnreadFills {
9268 start,
9269 reason: reason.clone(),
9270 };
9271 assert_eq!(
9272 fill_recovery_gave_up(&instruments, unread, end),
9273 UnindexedAccountEvent::new(
9274 ExchangeId::AlpacaBroker,
9275 AccountEventKind::FillRecoveryGaveUp(FillRecoveryGap::new(
9276 scope, start, end, 1, reason,
9277 )),
9278 )
9279 );
9280 }
9281 }
9282
9283 #[test]
9287 fn a_read_that_reached_past_the_recovery_start_still_reports() {
9288 let end = recovery_after();
9289 let start = end + chrono::Duration::seconds(1);
9290 let unread = UnreadFills {
9291 start,
9292 reason: FillRecoveryFailure::Truncated { fills_read: 5_000 },
9293 };
9294 let event = fill_recovery_gave_up(&[], unread, end);
9295 let AccountEventKind::FillRecoveryGaveUp(gave_up) = event.kind else {
9296 panic!("expected FillRecoveryGaveUp, got {event:?}");
9297 };
9298 assert_eq!((gave_up.start, gave_up.end), (start, start));
9299 }
9300
9301 async fn recover_fills_or_report_from(
9304 server: &MockServer,
9305 instruments: &[InstrumentNameExchange],
9306 timeout: Duration,
9307 ) -> Vec<UnindexedAccountEvent> {
9308 let (tx, mut rx) = mpsc::unbounded_channel();
9309 recover_fills_or_report(
9310 &reqwest::Client::new(),
9311 &RateLimitTracker::new(),
9312 instruments,
9313 &server.uri(),
9314 recovery_after(),
9315 timeout,
9316 &tx,
9317 &new_dedup_cache(),
9318 &KnownLiveOrders::shared(ExchangeId::AlpacaBroker),
9319 )
9320 .await;
9321 drop(tx);
9322 std::iter::from_fn(|| rx.try_recv().ok()).collect()
9323 }
9324
9325 #[tokio::test]
9328 async fn a_timed_out_recovery_reports_every_fill_since_the_disconnect() {
9329 let server = MockServer::start().await;
9330 Mock::given(method("GET"))
9331 .and(path("/v2/account/activities"))
9332 .respond_with(
9333 ResponseTemplate::new(200)
9334 .set_body_json(serde_json::json!([]))
9335 .set_delay(Duration::from_secs(3_600)),
9336 )
9337 .mount(&server)
9338 .await;
9339 let spy = InstrumentNameExchange::new("SPY");
9340
9341 let events = recover_fills_or_report_from(
9342 &server,
9343 std::slice::from_ref(&spy),
9344 Duration::from_millis(50),
9345 )
9346 .await;
9347
9348 let [event] = events.as_slice() else {
9349 panic!("only the give-up is sent: {events:?}");
9350 };
9351 let AccountEventKind::FillRecoveryGaveUp(gave_up) = &event.kind else {
9352 panic!("expected FillRecoveryGaveUp, got {event:?}");
9353 };
9354 assert_eq!(gave_up.scope, FillRecoveryScope::Instruments(vec![spy]));
9355 assert_eq!(gave_up.start, recovery_after(), "from the disconnect");
9356 assert!(gave_up.end >= gave_up.start);
9357 assert_eq!(gave_up.attempts, 1);
9358 assert!(
9359 matches!(gave_up.reason, FillRecoveryFailure::TimedOut { .. }),
9360 "{:?}",
9361 gave_up.reason
9362 );
9363 }
9364
9365 #[tokio::test]
9368 async fn a_truncated_recovery_reports_after_the_fills_it_read() {
9369 let server = MockServer::start().await;
9370 Mock::given(method("GET"))
9371 .and(path("/v2/account/activities"))
9372 .respond_with(
9373 ResponseTemplate::new(200)
9374 .set_body_json(make_activities_json(ALPACA_MAX_ACTIVITIES, "act")),
9375 )
9376 .mount(&server)
9377 .await;
9378
9379 let events = recover_fills_or_report_from(&server, &[], Duration::from_secs(30)).await;
9380
9381 let Some((last, fills)) = events.split_last() else {
9382 panic!("events are sent");
9383 };
9384 assert_eq!(fills.len(), MAX_ACTIVITY_PAGES * ALPACA_MAX_ACTIVITIES);
9385 assert!(
9386 fills
9387 .iter()
9388 .all(|event| matches!(event.kind, AccountEventKind::Trade(_))),
9389 "every fill comes first"
9390 );
9391 let AccountEventKind::FillRecoveryGaveUp(gave_up) = &last.kind else {
9392 panic!("the give-up comes last, got {last:?}");
9393 };
9394 assert_eq!(gave_up.scope, FillRecoveryScope::AllInstruments);
9395 assert!(matches!(
9396 gave_up.reason,
9397 FillRecoveryFailure::Truncated { .. }
9398 ));
9399 }
9400
9401 #[test]
9404 fn an_activity_id_names_its_execution_after_the_separator() {
9405 assert_eq!(
9406 activity_execution_id("20261005041849348::524b1902-817e-446c-825b-a9fcfebbc17e"),
9407 "524b1902-817e-446c-825b-a9fcfebbc17e"
9408 );
9409 assert_eq!(activity_execution_id("act-1"), "act-1");
9410 assert_eq!(
9411 activity_execution_id("20261005041849348::"),
9412 "20261005041849348::"
9413 );
9414 }
9415
9416 #[tokio::test]
9420 async fn a_fill_has_one_trade_id_on_the_stream_in_recovery_and_from_fetch_trades() {
9421 use crate::client::ExecutionClient;
9422
9423 let execution_id = "524b1902-817e-446c-825b-a9fcfebbc17e";
9424 let activity = activity_json(
9425 &format!("20250418103000000::{execution_id}"),
9426 "ord-1",
9427 "2",
9428 Some("2"),
9429 );
9430
9431 let [streamed, _snapshot] = convert_trade_update(AlpacaTradeUpdate {
9432 event: SmolStr::new("fill"),
9433 execution_id: Some(execution_id),
9434 previous_execution_id: None,
9435 order: super::make_order_ws("ord-1", "SPY", "buy", "2"),
9436 price: Some("100.00"),
9437 qty: Some("2"),
9438 timestamp: Some("2025-04-18T14:30:00Z"),
9439 });
9440 let Some(streamed) = streamed else {
9441 panic!("a fill converts to a Trade");
9442 };
9443
9444 let recovered = drive_recover_fills(vec![activity.clone()]).await;
9445 let [recovered] = recovered.as_slice() else {
9446 panic!("one fill recovered: {recovered:?}");
9447 };
9448
9449 let server = MockServer::start().await;
9450 Mock::given(method("GET"))
9451 .and(path("/v2/account/activities"))
9452 .respond_with(
9453 ResponseTemplate::new(200)
9454 .set_body_json(serde_json::Value::Array(vec![activity])),
9455 )
9456 .mount(&server)
9457 .await;
9458 let Ok(fetched) = client_for(&server)
9459 .fetch_trades(recovery_after(), Utc::now(), &[])
9460 .await
9461 else {
9462 panic!("fetch_trades reads the activity");
9463 };
9464 let [fetched] = fetched.trades.as_slice() else {
9465 panic!("one fill fetched: {fetched:?}");
9466 };
9467
9468 assert_eq!(trade_of(&streamed).id.0.as_str(), execution_id);
9469 assert_eq!(trade_of(recovered).id.0.as_str(), execution_id);
9470 assert_eq!(fetched.id.0.as_str(), execution_id);
9471 }
9472
9473 fn time(s: &str) -> DateTime<Utc> {
9478 s.parse().expect("valid time")
9479 }
9480
9481 struct ActivitiesVenue {
9486 activities: Vec<serde_json::Value>,
9487 }
9488
9489 impl Respond for ActivitiesVenue {
9490 fn respond(&self, request: &Request) -> ResponseTemplate {
9491 let query: std::collections::HashMap<String, String> =
9492 request.url.query_pairs().into_owned().collect();
9493 let bound = |name: &str| query.get(name).map(|s| time(s));
9494 let (after, until) = (bound("after"), bound("until"));
9495 let size: usize = query
9496 .get("page_size")
9497 .and_then(|s| s.parse().ok())
9498 .unwrap_or(ALPACA_MAX_ACTIVITIES);
9499 let in_bounds = self.activities.iter().filter(|activity| {
9500 let at =
9501 floor_millis(time(activity["transaction_time"].as_str().expect("a time")));
9502 after.is_none_or(|after| at >= after) && until.is_none_or(|until| at < until)
9503 });
9504 let page: Vec<_> = match query.get("page_token") {
9505 Some(token) => in_bounds
9506 .skip_while(|activity| activity["id"] != token.as_str())
9507 .skip(1)
9508 .take(size)
9509 .cloned()
9510 .collect(),
9511 None => in_bounds.take(size).cloned().collect(),
9512 };
9513 ResponseTemplate::new(200).set_body_json(page)
9514 }
9515 }
9516
9517 fn fill_at(id: &str, symbol: &str, transaction_time: DateTime<Utc>) -> serde_json::Value {
9519 serde_json::json!({
9520 "id": id,
9521 "order_id": format!("ord-{id}"),
9522 "symbol": symbol,
9523 "side": "buy",
9524 "price": "100.00",
9525 "qty": "1",
9526 "cum_qty": "1",
9527 "transaction_time": transaction_time.to_rfc3339_opts(SecondsFormat::Micros, true),
9528 })
9529 }
9530
9531 async fn serve_activities(activities: Vec<serde_json::Value>) -> MockServer {
9532 let server = MockServer::start().await;
9533 Mock::given(method("GET"))
9534 .and(path("/v2/account/activities"))
9535 .respond_with(ActivitiesVenue { activities })
9536 .mount(&server)
9537 .await;
9538 server
9539 }
9540
9541 fn non_trade(
9544 id: &str,
9545 activity_type: &str,
9546 sub_type: Option<&str>,
9547 net_amount: &str,
9548 created_at: &str,
9549 ) -> serde_json::Value {
9550 let mut activity = serde_json::json!({
9551 "id": id, "activity_type": activity_type, "net_amount": net_amount,
9552 "created_at": created_at, "date": &created_at[..10], "currency": "USD",
9553 "status": "executed",
9554 });
9555 if let Some(sub_type) = sub_type {
9556 activity["activity_sub_type"] = serde_json::json!(sub_type);
9557 }
9558 activity
9559 }
9560
9561 async fn query_pairs(server: &MockServer) -> Vec<Vec<(String, String)>> {
9563 let Some(requests) = server.received_requests().await else {
9564 panic!("requests are recorded");
9565 };
9566 requests
9567 .iter()
9568 .map(|request| request.url.query_pairs().into_owned().collect())
9569 .collect()
9570 }
9571
9572 #[tokio::test]
9573 async fn fetch_cash_flows_maps_each_activity_type_and_leaves_out_what_it_cannot_use() {
9574 let at = "2025-04-18T05:00:00Z";
9575 let mut fee = non_trade("fee", "FEE", Some("TAF"), "-0.02", at);
9576 fee["symbol"] = serde_json::json!("SPY");
9577 let mut canceled = non_trade("canceled", "FEE", Some("REG"), "-0.01", at);
9578 canceled["status"] = serde_json::json!("canceled");
9579 let mut no_amount = non_trade("no-amount", "FEE", None, "0", at);
9580 no_amount.as_object_mut().unwrap().remove("net_amount");
9581 let mut no_created_at = non_trade("no-time", "PTC", None, "-0.50", at);
9582 no_created_at.as_object_mut().unwrap().remove("created_at");
9583 let mut bad_time = non_trade("bad-time", "PTC", None, "-0.50", at);
9584 bad_time["created_at"] = serde_json::json!("not-a-time");
9585 let mut corrected = non_trade("correct", "PTC", None, "-0.40", at);
9586 corrected["status"] = serde_json::json!("correct");
9587 let mut euro = non_trade("euro", "PTR", None, "0.30", at);
9588 euro["currency"] = serde_json::json!("EUR");
9589 let server = MockServer::start().await;
9590 Mock::given(method("GET"))
9591 .and(path("/v2/account/activities"))
9592 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([
9593 non_trade("mgn", "INT", Some("MGN"), "-12.50", at),
9594 non_trade("int-paid", "INT", None, "-3.00", at),
9595 non_trade("int-credit", "INT", Some("CDT"), "1.00", at),
9596 fee,
9597 canceled,
9598 no_amount,
9599 no_created_at,
9600 bad_time,
9601 corrected,
9602 euro,
9603 non_trade("late", "FEE", None, "-0.01", "2025-04-20T05:00:00Z"),
9604 ])))
9605 .mount(&server)
9606 .await;
9607 let client = client_for(&server);
9608
9609 let read = client
9610 .fetch_cash_flows(time("2025-04-18T00:00:00Z"), time("2025-04-19T00:00:00Z"))
9611 .await
9612 .unwrap();
9613
9614 assert_eq!(read.resume, None);
9615 let kinds: Vec<_> = read
9616 .flows
9617 .iter()
9618 .map(|flow| (flow.id.clone().unwrap().0.to_string(), flow.kind.clone()))
9619 .collect();
9620 let interest = CashFlowKind::MarginInterest {
9621 rate: None,
9622 principal: None,
9623 };
9624 assert_eq!(
9625 kinds,
9626 [
9627 ("mgn", interest.clone()),
9628 ("int-paid", interest),
9629 ("int-credit", CashFlowKind::Other("INT.CDT".into())),
9630 ("fee", CashFlowKind::Other("FEE.TAF".into())),
9631 ("correct", CashFlowKind::Other("PTC".into())),
9632 ("euro", CashFlowKind::Rebate),
9633 ]
9634 .map(|(id, kind)| (id.to_owned(), kind))
9635 );
9636 let by_id = |id: &str| {
9637 read.flows
9638 .iter()
9639 .find(|flow| flow.id == Some(CashFlowId::new(id)))
9640 .unwrap()
9641 };
9642 assert_eq!(by_id("mgn").amount, Decimal::new(-1250, 2));
9643 assert_eq!(by_id("mgn").asset, AssetNameExchange::new("USD"));
9644 assert_eq!(by_id("mgn").instrument, None);
9645 assert_eq!(by_id("mgn").time_exchange, time(at));
9646 assert_eq!(
9647 by_id("fee").instrument,
9648 Some(InstrumentNameExchange::new("SPY"))
9649 );
9650 assert_eq!(
9651 by_id("correct").amount,
9652 Decimal::new(-40, 2),
9653 "a correction is returned as it is"
9654 );
9655 assert_eq!(by_id("euro").asset, AssetNameExchange::new("EUR"));
9656
9657 let queries = query_pairs(&server).await;
9658 let [query] = queries.as_slice() else {
9659 panic!("one request: {queries:?}");
9660 };
9661 assert!(
9662 query.contains(&("activity_types".into(), "INT,FEE,PTC,PTR".into())),
9663 "{query:?}"
9664 );
9665 }
9666
9667 #[tokio::test]
9668 async fn fetch_trades_asks_for_fills_by_the_documented_parameter() {
9669 use crate::client::ExecutionClient;
9670
9671 let server = serve_activities(Vec::new()).await;
9672 let client = client_for(&server);
9673
9674 let read = client
9675 .fetch_trades(
9676 time("2025-04-18T00:00:00Z"),
9677 time("2025-04-19T00:00:00Z"),
9678 &[],
9679 )
9680 .await
9681 .unwrap();
9682
9683 assert!(read.trades.is_empty());
9684 let queries = query_pairs(&server).await;
9685 let [query] = queries.as_slice() else {
9686 panic!("one request: {queries:?}");
9687 };
9688 assert!(
9689 query.contains(&("activity_types".into(), "FILL".into())),
9690 "{query:?}"
9691 );
9692 assert!(
9693 !query.iter().any(|(name, _)| name == "activity_type"),
9694 "{query:?}"
9695 );
9696 }
9697
9698 #[tokio::test]
9699 async fn fetch_cash_flows_reads_nothing_for_an_empty_span() {
9700 let server = MockServer::start().await;
9701 let client = client_for(&server);
9702
9703 let read = client
9704 .fetch_cash_flows(time("2025-04-19T00:00:00Z"), time("2025-04-18T00:00:00Z"))
9705 .await
9706 .unwrap();
9707
9708 assert!(read.flows.is_empty() && read.resume.is_none());
9709 assert!(query_pairs(&server).await.is_empty());
9710 }
9711
9712 #[tokio::test]
9715 async fn fetch_cash_flows_that_stops_at_its_bound_says_where_to_read_on() {
9716 let base = time("2025-04-18T05:00:00Z");
9717 let activities: Vec<_> = (0..6_000_i64)
9718 .map(|n| {
9719 let created =
9720 (base + TimeDelta::seconds(n)).to_rfc3339_opts(SecondsFormat::Micros, true);
9721 non_trade(&format!("{n:017}::fee"), "FEE", None, "-0.01", &created)
9722 })
9723 .collect();
9724 struct Paged(Vec<serde_json::Value>);
9726 impl Respond for Paged {
9727 fn respond(&self, request: &Request) -> ResponseTemplate {
9728 let token = request
9729 .url
9730 .query_pairs()
9731 .find(|(name, _)| name == "page_token")
9732 .map(|(_, value)| value.into_owned());
9733 let page: Vec<_> = self
9734 .0
9735 .iter()
9736 .skip_while(|activity| {
9737 token
9738 .as_deref()
9739 .is_some_and(|token| activity["id"] != token)
9740 })
9741 .skip(usize::from(token.is_some()))
9742 .take(ALPACA_MAX_ACTIVITIES)
9743 .cloned()
9744 .collect();
9745 ResponseTemplate::new(200).set_body_json(page)
9746 }
9747 }
9748 let server = MockServer::start().await;
9749 Mock::given(method("GET"))
9750 .and(path("/v2/account/activities"))
9751 .respond_with(Paged(activities))
9752 .mount(&server)
9753 .await;
9754 let client = client_for(&server);
9755
9756 let read = client
9757 .fetch_cash_flows(base, base + TimeDelta::days(1))
9758 .await
9759 .unwrap();
9760
9761 assert_eq!(read.flows.len(), 5_000);
9762 assert_eq!(read.resume, Some(base + TimeDelta::seconds(4_999)));
9763 }
9764
9765 #[tokio::test]
9768 async fn fetch_cash_flows_that_cannot_advance_fails() {
9769 let at = "2025-04-18T05:00:00Z";
9770 let full: Vec<_> = (0..ALPACA_MAX_ACTIVITIES)
9771 .map(|n| non_trade(&format!("fee-{n}"), "FEE", None, "-0.01", at))
9772 .collect();
9773 let server = MockServer::start().await;
9774 Mock::given(method("GET"))
9775 .and(path("/v2/account/activities"))
9776 .respond_with(ResponseTemplate::new(200).set_body_json(full))
9777 .mount(&server)
9778 .await;
9779 let client = client_for(&server);
9780
9781 let result = client
9782 .fetch_cash_flows(time(at), time("2025-04-19T00:00:00Z"))
9783 .await;
9784
9785 assert!(
9786 matches!(result, Err(UnindexedClientError::Internal(_))),
9787 "{result:?}"
9788 );
9789 }
9790
9791 fn option_activity(
9794 id: &str,
9795 activity_type: &str,
9796 symbol: &str,
9797 qty: &str,
9798 date: &str,
9799 ) -> serde_json::Value {
9800 serde_json::json!({
9801 "id": id, "activity_type": activity_type, "symbol": symbol, "qty": qty,
9802 "date": date, "net_amount": "0", "status": "executed",
9803 })
9804 }
9805
9806 fn share_leg(
9808 id: &str,
9809 symbol: &str,
9810 qty: &str,
9811 price: &str,
9812 date: &str,
9813 ) -> serde_json::Value {
9814 let mut activity = option_activity(id, "OPTRD", symbol, qty, date);
9815 activity["price"] = serde_json::json!(price);
9816 activity
9817 }
9818
9819 async fn serve_json(activities: serde_json::Value) -> MockServer {
9820 let server = MockServer::start().await;
9821 Mock::given(method("GET"))
9822 .and(path("/v2/account/activities"))
9823 .respond_with(ResponseTemplate::new(200).set_body_json(activities))
9824 .mount(&server)
9825 .await;
9826 server
9827 }
9828
9829 #[test]
9830 fn occ_contract_reads_the_root_kind_and_strike() {
9831 assert_eq!(
9832 occ_contract("QS240301C00006500"),
9833 Some(("QS", true, Decimal::new(65, 1)))
9834 );
9835 assert_eq!(
9836 occ_contract("AAPL240119P00150000"),
9837 Some(("AAPL", false, Decimal::from(150)))
9838 );
9839 for not_occ in [
9840 "QS",
9841 "240301C00006500",
9842 "QS240301X00006500",
9843 "QS2403O1C00006500",
9844 ] {
9845 assert_eq!(occ_contract(not_occ), None, "{not_occ}");
9846 }
9847 }
9848
9849 #[tokio::test]
9850 async fn fetch_option_events_maps_each_activity_and_pairs_the_share_legs() {
9851 let day = "2024-03-01";
9852 let mut expiry = option_activity("exp", "OPEXP", "QS240301C00010500", "-1", day);
9853 expiry["created_at"] = serde_json::json!("2024-03-01T21:30:00Z");
9854 let mut canceled = option_activity("canceled", "OPEXP", "QS240301P00000500", "-1", day);
9855 canceled["status"] = serde_json::json!("canceled");
9856 let server = serve_json(serde_json::json!([
9857 expiry,
9858 option_activity("exc", "OPEXC", "QS240301C00006500", "-1", day),
9859 share_leg("exc-shares", "QS", "100", "6.5", day),
9860 option_activity("asn", "OPASN", "QS240301P00009000", "1", day),
9861 share_leg("asn-shares", "QS", "100", "9", day),
9862 share_leg("orphan", "QS", "-100", "7", day),
9863 canceled,
9864 option_activity("no-price", "OPTRD", "QS", "100", day),
9865 option_activity("no-qty", "OPEXP", "QS240301C00011000", "0", day),
9866 option_activity("later", "OPEXP", "QS240308C00010500", "-1", "2024-03-02"),
9867 ]))
9868 .await;
9869 let client = client_for(&server);
9870
9871 let read = client
9872 .fetch_option_events(time("2024-03-01T00:00:00Z"), time("2024-03-01T23:00:00Z"))
9873 .await
9874 .unwrap();
9875
9876 assert_eq!(read.resume, None);
9877 let summary: Vec<_> = read
9878 .trades
9879 .iter()
9880 .map(|trade| {
9881 (
9882 trade.id.0.to_string(),
9883 trade.instrument.name().to_string(),
9884 trade.origin,
9885 trade.side,
9886 trade.price,
9887 trade.quantity,
9888 )
9889 })
9890 .collect();
9891 let row = |id: &str, symbol: &str, origin, side, price: Decimal, quantity: i64| {
9892 (
9893 id.to_owned(),
9894 symbol.to_owned(),
9895 origin,
9896 side,
9897 price,
9898 Decimal::from(quantity),
9899 )
9900 };
9901 assert_eq!(
9902 summary,
9903 [
9904 row(
9905 "exp",
9906 "QS240301C00010500",
9907 TradeOrigin::Expiry,
9908 Side::Sell,
9909 Decimal::ZERO,
9910 1
9911 ),
9912 row(
9913 "exc",
9914 "QS240301C00006500",
9915 TradeOrigin::Exercise,
9916 Side::Sell,
9917 Decimal::ZERO,
9918 1
9919 ),
9920 row(
9921 "exc-shares",
9922 "QS",
9923 TradeOrigin::Exercise,
9924 Side::Buy,
9925 Decimal::new(65, 1),
9926 100
9927 ),
9928 row(
9929 "asn",
9930 "QS240301P00009000",
9931 TradeOrigin::Assignment,
9932 Side::Buy,
9933 Decimal::ZERO,
9934 1
9935 ),
9936 row(
9937 "asn-shares",
9938 "QS",
9939 TradeOrigin::Assignment,
9940 Side::Buy,
9941 Decimal::from(9),
9942 100
9943 ),
9944 row(
9945 "orphan",
9946 "QS",
9947 TradeOrigin::External,
9948 Side::Sell,
9949 Decimal::from(7),
9950 100
9951 ),
9952 ]
9953 );
9954 let exp = &read.trades[0];
9955 assert_eq!(
9956 exp.time_exchange,
9957 time("2024-03-01T21:30:00Z"),
9958 "created_at"
9959 );
9960 assert_eq!(exp.order_id, OrderId::new("exp"));
9961 assert_eq!(exp.fees.fees, Decimal::ZERO);
9962 assert_eq!(
9963 read.trades[1].time_exchange,
9964 time("2024-03-01T00:00:00Z"),
9965 "no created_at: the start of its date"
9966 );
9967
9968 let queries = query_pairs(&server).await;
9969 let [query] = queries.as_slice() else {
9970 panic!("one request: {queries:?}");
9971 };
9972 assert!(
9973 query.contains(&("activity_types".into(), "OPASN,OPEXC,OPEXP,OPTRD".into())),
9974 "{query:?}"
9975 );
9976 }
9977
9978 #[tokio::test]
9981 async fn fetch_option_events_pairs_a_share_leg_by_its_side() {
9982 let day = "2024-03-01";
9983 let server = serve_json(serde_json::json!([
9984 option_activity("exc-call", "OPEXC", "QS240301C00006500", "-1", day),
9985 option_activity("asn-call", "OPASN", "QS240301C00006500", "1", day),
9986 share_leg("sold", "QS", "-100", "6.5", day),
9987 share_leg("bought", "QS", "100", "6.5", day),
9988 share_leg("bought-again", "QS", "100", "6.5", day),
9989 share_leg("other-day", "QS", "100", "6.5", "2024-02-29"),
9990 ]))
9991 .await;
9992 let client = client_for(&server);
9993
9994 let read = client
9995 .fetch_option_events(time("2024-02-29T00:00:00Z"), time("2024-03-01T23:00:00Z"))
9996 .await
9997 .unwrap();
9998
9999 let origin = |id: &str| {
10000 read.trades
10001 .iter()
10002 .find(|trade| trade.id == TradeId::new(id))
10003 .map(|trade| trade.origin)
10004 };
10005 assert_eq!(origin("sold"), Some(TradeOrigin::Assignment));
10006 assert_eq!(origin("bought"), Some(TradeOrigin::Exercise));
10007 assert_eq!(origin("bought-again"), Some(TradeOrigin::External));
10008 assert_eq!(origin("other-day"), Some(TradeOrigin::External));
10009 }
10010
10011 #[tokio::test]
10015 async fn fetch_option_events_pairs_a_share_leg_by_its_size() {
10016 let day = "2024-03-01";
10017 let server = serve_json(serde_json::json!([
10018 option_activity("asn-two", "OPASN", "QS240301P00006500", "2", day),
10020 option_activity("exc-one", "OPEXC", "QS240301C00006500", "-1", day),
10021 option_activity("exc-adjusted", "OPEXC", "QS240301C00006500", "-1", day),
10022 share_leg("hundred", "QS", "100", "6.5", day),
10023 share_leg("two-hundred", "QS", "200", "6.5", day),
10024 share_leg("hundred-fifty", "QS", "150", "6.5", day),
10025 ]))
10026 .await;
10027 let client = client_for(&server);
10028
10029 let read = client
10030 .fetch_option_events(time("2024-03-01T00:00:00Z"), time("2024-03-01T23:00:00Z"))
10031 .await
10032 .unwrap();
10033
10034 let origin = |id: &str| {
10035 read.trades
10036 .iter()
10037 .find(|trade| trade.id == TradeId::new(id))
10038 .map(|trade| trade.origin)
10039 };
10040 assert_eq!(origin("hundred"), Some(TradeOrigin::Exercise));
10041 assert_eq!(origin("two-hundred"), Some(TradeOrigin::Assignment));
10042 assert_eq!(origin("hundred-fifty"), Some(TradeOrigin::Exercise));
10043 }
10044
10045 #[tokio::test]
10049 async fn fetch_option_events_reads_whole_days_and_returns_the_span() {
10050 let mut early =
10051 option_activity("early", "OPEXP", "QS240301C00010500", "-1", "2024-03-01");
10052 early["created_at"] = serde_json::json!("2024-03-01T10:00:00Z");
10053 let mut inside =
10054 option_activity("inside", "OPEXP", "QS240301C00011500", "-1", "2024-03-01");
10055 inside["created_at"] = serde_json::json!("2024-03-01T16:00:00Z");
10056 let server = serve_json(serde_json::json!([
10057 option_activity(
10058 "dated-before",
10059 "OPEXP",
10060 "QS240229C00010500",
10061 "-1",
10062 "2024-02-29"
10063 ),
10064 early,
10065 option_activity("dated", "OPEXP", "QS240301C00012500", "-1", "2024-03-01"),
10066 inside,
10067 ]))
10068 .await;
10069 let client = client_for(&server);
10070
10071 let read = client
10072 .fetch_option_events(time("2024-03-01T15:00:00Z"), time("2024-03-01T17:00:00Z"))
10073 .await
10074 .unwrap();
10075
10076 let ids: Vec<_> = read
10077 .trades
10078 .iter()
10079 .map(|trade| trade.id.0.to_string())
10080 .collect();
10081 assert_eq!(ids, ["dated", "inside"]);
10082 let queries = query_pairs(&server).await;
10083 let [query] = queries.as_slice() else {
10084 panic!("one request: {queries:?}");
10085 };
10086 assert!(
10087 query.contains(&("after".into(), "2024-03-01T00:00:00.000Z".into())),
10088 "{query:?}"
10089 );
10090 assert!(
10091 query.contains(&("until".into(), "2024-03-02T00:00:00.000Z".into())),
10092 "{query:?}"
10093 );
10094 }
10095
10096 #[tokio::test]
10097 async fn fetch_option_events_reads_nothing_for_an_empty_span() {
10098 let server = MockServer::start().await;
10099 let client = client_for(&server);
10100
10101 let read = client
10102 .fetch_option_events(time("2024-03-02T00:00:00Z"), time("2024-03-01T00:00:00Z"))
10103 .await
10104 .unwrap();
10105
10106 assert!(read.trades.is_empty() && read.resume.is_none());
10107 assert!(query_pairs(&server).await.is_empty());
10108 }
10109
10110 async fn read_span(
10113 client: &AlpacaClient,
10114 start: DateTime<Utc>,
10115 end: DateTime<Utc>,
10116 instruments: &[InstrumentNameExchange],
10117 ) -> (std::collections::HashSet<String>, usize) {
10118 use crate::client::ExecutionClient;
10119
10120 let mut ids = std::collections::HashSet::new();
10121 let mut from = start;
10122 for calls in 1..=10 {
10123 let read = match client.fetch_trades(from, end, instruments).await {
10124 Ok(read) => read,
10125 Err(error) => panic!("call {calls} failed: {error:?}"),
10126 };
10127 ids.extend(read.trades.iter().map(|trade| trade.id.0.to_string()));
10128 match read.resume {
10129 Some(resume) => from = resume,
10130 None => return (ids, calls),
10131 }
10132 }
10133 panic!("the reads did not reach the span's end");
10134 }
10135
10136 #[tokio::test]
10142 async fn fetch_trades_reads_a_busy_span_completely_across_calls() {
10143 let base = time("2025-04-18T14:30:00Z");
10144 let mut activities = Vec::new();
10145 for ms in 0..4_000_i64 {
10146 let symbol = if ms % 2 == 0 { "SPY" } else { "QQQ" };
10147 for (k, micros) in [("a", 900), ("b", 500), ("c", 100)] {
10148 let at = base + TimeDelta::milliseconds(ms) + TimeDelta::microseconds(micros);
10149 activities.push(fill_at(&format!("{ms:017}::{ms}-{k}"), symbol, at));
10150 }
10151 }
10152 let server = serve_activities(activities).await;
10153 let client = client_for(&server);
10154 let end = time("2025-04-18T15:00:00Z");
10155
10156 let (ids, calls) = read_span(&client, recovery_after(), end, &[]).await;
10157 assert_eq!(ids.len(), 12_000, "every fill is read once or more");
10158 assert_eq!(calls, 3, "5,000 activities per call");
10159
10160 let spy = [InstrumentNameExchange::new("SPY")];
10161 let (ids, _) = read_span(&client, recovery_after(), end, &spy).await;
10162 assert_eq!(ids.len(), 6_000, "every SPY fill, and only those");
10163
10164 let Some(requests) = server.received_requests().await else {
10165 panic!("requests are recorded");
10166 };
10167 assert!(
10168 requests
10169 .iter()
10170 .all(|request| request.url.query_pairs().any(
10171 |(name, value)| name == "until" && value == "2025-04-18T15:00:00.001Z"
10172 )),
10173 "every request is bounded by the span's end"
10174 );
10175 }
10176
10177 #[tokio::test]
10180 async fn fetch_trades_says_where_to_read_on_after_a_call_without_matches() {
10181 use crate::client::ExecutionClient;
10182
10183 let server = MockServer::start().await;
10184 Mock::given(method("GET"))
10185 .and(path("/v2/account/activities"))
10186 .respond_with(
10187 ResponseTemplate::new(200)
10188 .set_body_json(make_activities_json(ALPACA_MAX_ACTIVITIES, "act")),
10189 )
10190 .mount(&server)
10191 .await;
10192
10193 let qqq = [InstrumentNameExchange::new("QQQ")];
10194 let Ok(read) = client_for(&server)
10195 .fetch_trades(recovery_after(), Utc::now(), &qqq)
10196 .await
10197 else {
10198 panic!("the read succeeds");
10199 };
10200 assert!(read.trades.is_empty(), "every fill read is SPY's");
10201 assert_eq!(read.resume, Some(time("2025-04-18T14:30:00Z")));
10202 }
10203
10204 #[tokio::test]
10207 async fn fetch_trades_that_cannot_advance_is_truncated() {
10208 use crate::client::ExecutionClient;
10209
10210 let server = MockServer::start().await;
10211 Mock::given(method("GET"))
10212 .and(path("/v2/account/activities"))
10213 .respond_with(
10214 ResponseTemplate::new(200)
10215 .set_body_json(make_activities_json(ALPACA_MAX_ACTIVITIES, "act")),
10216 )
10217 .mount(&server)
10218 .await;
10219
10220 let result = client_for(&server)
10221 .fetch_trades(time("2025-04-18T14:30:00.000500Z"), Utc::now(), &[])
10222 .await;
10223 assert!(
10224 matches!(
10225 result,
10226 Err(UnindexedClientError::Truncated { fills_read: 5_000 })
10227 ),
10228 "got {result:?}"
10229 );
10230 }
10231
10232 #[tokio::test]
10235 async fn fetch_trades_returns_only_the_span() {
10236 let (start, end) = (
10237 time("2025-04-18T14:30:00.123456Z"),
10238 time("2025-04-18T14:30:05.678901Z"),
10239 );
10240 let micro = TimeDelta::microseconds(1);
10241 let server = serve_activities(vec![
10242 fill_at("1::before", "SPY", start - micro),
10243 fill_at("2::start", "SPY", start),
10244 fill_at("3::end", "SPY", end),
10245 fill_at("4::after", "SPY", end + micro),
10246 ])
10247 .await;
10248
10249 let (ids, calls) = read_span(&client_for(&server), start, end, &[]).await;
10250 assert_eq!(calls, 1);
10251 let mut ids: Vec<_> = ids.into_iter().collect();
10252 ids.sort();
10253 assert_eq!(ids, ["end", "start"]);
10254 }
10255
10256 #[tokio::test]
10259 async fn fetch_trades_that_read_past_the_end_is_complete() {
10260 use crate::client::ExecutionClient;
10261
10262 let server = MockServer::start().await;
10263 Mock::given(method("GET"))
10265 .and(path("/v2/account/activities"))
10266 .respond_with(
10267 ResponseTemplate::new(200)
10268 .set_body_json(make_activities_json(ALPACA_MAX_ACTIVITIES, "act")),
10269 )
10270 .mount(&server)
10271 .await;
10272
10273 let Ok(read) = client_for(&server)
10274 .fetch_trades(recovery_after(), time("2025-04-18T14:29:00Z"), &[])
10275 .await
10276 else {
10277 panic!("the read succeeds");
10278 };
10279 assert_eq!(read, TradesRead::complete(Vec::new()));
10280 }
10281
10282 #[tokio::test]
10286 async fn fetch_trades_accepts_the_extreme_times() {
10287 use crate::client::ExecutionClient;
10288
10289 let server = serve_activities(vec![fill_at(
10290 "1::fill",
10291 "SPY",
10292 time("2025-04-18T14:30:00Z"),
10293 )])
10294 .await;
10295 let Ok(read) = client_for(&server)
10296 .fetch_trades(DateTime::<Utc>::MIN_UTC, DateTime::<Utc>::MAX_UTC, &[])
10297 .await
10298 else {
10299 panic!("the read succeeds");
10300 };
10301 assert_eq!(read.trades.len(), 1);
10302 assert_eq!(read.resume, None);
10303
10304 let Some(requests) = server.received_requests().await else {
10305 panic!("requests are recorded");
10306 };
10307 let [request] = requests.as_slice() else {
10308 panic!("one request: {requests:?}");
10309 };
10310 let query: std::collections::HashMap<_, _> =
10311 request.url.query_pairs().into_owned().collect();
10312 assert_eq!(
10313 query.get("after").map(String::as_str),
10314 Some("1970-01-01T00:00:00.000Z")
10315 );
10316 assert!(!query.contains_key("until"), "{query:?}");
10317
10318 let before_epoch = DateTime::UNIX_EPOCH - TimeDelta::days(1);
10320 let Ok(read) = client_for(&server)
10321 .fetch_trades(DateTime::<Utc>::MIN_UTC, before_epoch, &[])
10322 .await
10323 else {
10324 panic!("the read succeeds");
10325 };
10326 assert_eq!(read, TradesRead::complete(Vec::new()));
10327 assert_eq!(server.received_requests().await.map(|r| r.len()), Some(1));
10328 }
10329
10330 #[tokio::test]
10332 async fn fetch_trades_reads_nothing_for_an_empty_span() {
10333 use crate::client::ExecutionClient;
10334
10335 let server = MockServer::start().await;
10336 let start = recovery_after();
10337 let Ok(read) = client_for(&server)
10338 .fetch_trades(start, start - TimeDelta::seconds(1), &[])
10339 .await
10340 else {
10341 panic!("an empty span reads");
10342 };
10343 assert_eq!(read, TradesRead::complete(Vec::new()));
10344 assert_eq!(server.received_requests().await.map(|r| r.len()), Some(0));
10345 }
10346
10347 #[tokio::test]
10350 async fn recovery_reads_from_the_start_of_the_disconnects_millisecond() {
10351 let server = MockServer::start().await;
10352 Mock::given(method("GET"))
10353 .and(path("/v2/account/activities"))
10354 .and(wiremock::matchers::query_param(
10355 "after",
10356 "2025-01-01T00:00:00.123Z",
10357 ))
10358 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([])))
10359 .expect(1)
10360 .mount(&server)
10361 .await;
10362
10363 let (tx, _rx) = mpsc::unbounded_channel();
10364 let outcome = recover_fills(
10365 &reqwest::Client::new(),
10366 &RateLimitTracker::new(),
10367 &[],
10368 &server.uri(),
10369 time("2025-01-01T00:00:00.123456Z"),
10370 &tx,
10371 &new_dedup_cache(),
10372 &KnownLiveOrders::shared(ExchangeId::AlpacaBroker),
10373 )
10374 .await;
10375 assert_eq!(outcome, Ok(()));
10376 server.verify().await;
10377 }
10378
10379 fn dedup_keys(dedup: &SharedDedupCache) -> Vec<String> {
10381 let mut keys: Vec<_> = dedup
10382 .lock()
10383 .iter()
10384 .filter_map(|(key, _)| match key {
10385 FillKey::Cumulative(key) => Some(key.to_string()),
10386 FillKey::Execution(_) | FillKey::CumulativeWithoutId(_) => None,
10387 })
10388 .collect();
10389 keys.reverse();
10391 keys
10392 }
10393
10394 fn trade_of(
10395 event: &UnindexedAccountEvent,
10396 ) -> &Trade<AssetNameExchange, InstrumentNameExchange> {
10397 match &event.kind {
10398 AccountEventKind::Trade(t) => t,
10399 other => panic!("expected a Trade event, got {other:?}"),
10400 }
10401 }
10402
10403 #[tokio::test]
10406 async fn a_recovered_fill_reports_the_cumulative_alpaca_sent() {
10407 let events = drive_recover_fills(vec![
10408 activity_json("act-1", "ord-1", "2", Some("2")),
10409 activity_json("act-2", "ord-1", "3", Some("5")),
10410 ])
10411 .await;
10412
10413 let reported: Vec<_> = events
10414 .iter()
10415 .map(|e| trade_of(e).order_filled_quantity)
10416 .collect();
10417 assert_eq!(
10418 reported,
10419 vec![
10420 Some(Decimal::from_str("2").unwrap()),
10421 Some(Decimal::from_str("5").unwrap())
10422 ]
10423 );
10424 }
10425
10426 #[tokio::test]
10433 async fn the_dedup_key_uses_the_venue_cumulative_not_an_intra_batch_count() {
10434 let dedup = new_dedup_cache();
10436 drive_recover_fills_with(
10437 vec![activity_json("act-1", "ord-1", "2", Some("5"))],
10438 dedup.clone(),
10439 &KnownLiveOrders::shared(ExchangeId::AlpacaBroker),
10440 )
10441 .await;
10442
10443 assert_eq!(
10444 dedup_keys(&dedup),
10445 vec!["ord-1:5"],
10446 "counting executions within the batch would key this as ord-1:2"
10447 );
10448 }
10449
10450 async fn recover_after_ws_fill(
10454 execution_id: Option<&str>,
10455 activity_id: &str,
10456 ) -> Vec<UnindexedAccountEvent> {
10457 let dedup = new_dedup_cache();
10458 let update = AlpacaTradeUpdate {
10459 event: SmolStr::new("partial_fill"),
10460 execution_id,
10461 previous_execution_id: None,
10462 order: super::make_order_ws("ord-1", "SPY", "buy", "5"),
10463 price: Some("100.00"),
10464 qty: Some("2"),
10465 timestamp: None,
10466 };
10467 assert!(
10468 !is_duplicate_fill(&dedup, ws_execution_id(&update), early_dedup_key(&update)),
10469 "precondition: first sighting"
10470 );
10471
10472 drive_recover_fills_with(
10473 vec![activity_json(activity_id, "ord-1", "2", Some("5"))],
10474 dedup,
10475 &KnownLiveOrders::shared(ExchangeId::AlpacaBroker),
10476 )
10477 .await
10478 }
10479
10480 #[tokio::test]
10483 async fn a_fill_already_delivered_over_websocket_is_not_recovered_twice() {
10484 for (ws_execution_id, activity_id) in [
10485 (Some("exec-a"), "20250418103000000::exec-a"),
10486 (None, "20250418103000000::exec-a"),
10487 (Some("exec-a"), "act-1"),
10488 ] {
10489 let events = recover_after_ws_fill(ws_execution_id, activity_id).await;
10490 assert!(
10491 events.is_empty(),
10492 "a fill already delivered over WebSocket ({ws_execution_id:?}) must not be \
10493 re-delivered by recovery ({activity_id}), got {events:?}"
10494 );
10495 }
10496 }
10497
10498 async fn recover_after_correction(fields: &str) -> Vec<UnindexedAccountEvent> {
10501 let (tx, _rx) = mpsc::unbounded_channel();
10502 let dedup = new_dedup_cache();
10503 let known = KnownLiveOrders::shared(ExchangeId::AlpacaBroker);
10504 process_ws_text(
10505 &super::amendment_frame("trade_correct", fields),
10506 &tx,
10507 &dedup,
10508 &known,
10509 &mut ExponentialBackoff::new(),
10510 );
10511
10512 drive_recover_fills_with(
10513 vec![activity_json(
10514 "20250418103100000::exec-c",
10515 "ord-1",
10516 "2",
10517 Some("2"),
10518 )],
10519 dedup,
10520 &known,
10521 )
10522 .await
10523 }
10524
10525 #[tokio::test]
10528 async fn a_corrected_execution_is_not_recovered_again() {
10529 let events = recover_after_correction(
10530 r#""execution_id":"exec-c","previous_execution_id":"exec-a","price":"101.50","qty":"2","#,
10531 )
10532 .await;
10533 assert!(
10534 events.is_empty(),
10535 "the corrected execution was delivered as the replacement, got {events:?}"
10536 );
10537 }
10538
10539 #[tokio::test]
10541 async fn an_unresolved_correction_does_not_keep_its_execution_from_recovery() {
10542 let events = recover_after_correction(
10543 r#""execution_id":"exec-c","previous_execution_id":"exec-a","qty":"2","#,
10544 )
10545 .await;
10546 let ids: Vec<_> = events.iter().map(|e| trade_of(e).id.0.as_str()).collect();
10547 assert_eq!(ids, vec!["exec-c"]);
10548 }
10549
10550 #[tokio::test]
10553 async fn a_different_execution_at_a_cumulative_already_seen_is_recovered() {
10554 let events = recover_after_ws_fill(Some("exec-a"), "20250418103000000::exec-b").await;
10555 let ids: Vec<_> = events.iter().map(|e| trade_of(e).id.0.as_str()).collect();
10556 assert_eq!(ids, vec!["exec-b"]);
10557 }
10558
10559 #[tokio::test]
10562 async fn without_a_reported_cumulative_the_previous_behaviour_is_preserved() {
10563 let dedup = new_dedup_cache();
10564 let events = drive_recover_fills_with(
10565 vec![
10566 activity_json("act-1", "ord-1", "2", None),
10567 activity_json("act-2", "ord-1", "3", None),
10568 ],
10569 dedup.clone(),
10570 &KnownLiveOrders::shared(ExchangeId::AlpacaBroker),
10571 )
10572 .await;
10573
10574 assert_eq!(
10575 dedup_keys(&dedup),
10576 vec!["ord-1:2", "ord-1:5"],
10577 "intra-batch accumulation"
10578 );
10579 let ids: Vec<_> = events.iter().map(|e| trade_of(e).id.0.as_str()).collect();
10580 assert_eq!(ids, vec!["act-1", "act-2"], "each keeps its activity's id");
10581
10582 assert!(
10583 events
10584 .iter()
10585 .all(|e| trade_of(e).order_filled_quantity.is_none()),
10586 "a venue that reported no cumulative must not have one invented for it"
10587 );
10588 }
10589
10590 fn order_json(cid: &str, symbol: &str, status: &str, filled: &str) -> serde_json::Value {
10595 serde_json::json!({
10596 "id": format!("id-{cid}"),
10597 "client_order_id": cid,
10598 "symbol": symbol,
10599 "qty": "2",
10600 "filled_qty": filled,
10601 "filled_avg_price": if filled == "0" { None } else { Some("101.5") },
10602 "status": status,
10603 "side": "buy",
10604 "type": "limit",
10605 "time_in_force": "gtc",
10606 "limit_price": "100",
10607 "created_at": "2026-10-01T14:30:00Z",
10608 "updated_at": "2026-10-01T15:00:00Z"
10609 })
10610 }
10611
10612 async fn mount_lookup(server: &MockServer, cid: &str, order: ResponseTemplate, calls: u64) {
10614 use wiremock::matchers::query_param;
10615 Mock::given(method("GET"))
10616 .and(path("/v2/orders:by_client_order_id"))
10617 .and(query_param("client_order_id", cid))
10618 .respond_with(order)
10619 .expect(calls)
10620 .mount(server)
10621 .await;
10622 }
10623
10624 fn ok(body: serde_json::Value) -> ResponseTemplate {
10625 ResponseTemplate::new(200).set_body_json(body)
10626 }
10627
10628 fn alpaca_key(instrument: &str, cid: &str) -> UnindexedOrderKey {
10629 OrderKey::new(
10630 ExchangeId::AlpacaBroker,
10631 InstrumentNameExchange::new(instrument),
10632 StrategyId::new("strategy"),
10633 ClientOrderId::new(cid),
10634 )
10635 }
10636
10637 fn hold(known: &SharedKnownLiveOrders, key: &UnindexedOrderKey) {
10638 let open = Open::new(
10639 VenueOrderId::Assigned(OrderId::new(format!("id-{}", key.cid))),
10640 Utc::now(),
10641 Decimal::ZERO,
10642 );
10643 known
10644 .lock()
10645 .live(key, Decimal::TWO, OrderKind::Limit, &open);
10646 }
10647
10648 #[tokio::test]
10649 async fn fetch_ended_orders_reads_each_end_alpaca_reports() {
10650 use rust_decimal_macros::dec;
10651
10652 let server = MockServer::start().await;
10653 for (cid, status, filled) in [
10654 ("filled", "filled", "2"),
10655 ("canceled", "canceled", "1"),
10656 ("replaced", "replaced", "0"),
10657 ("expired", "expired", "1"),
10658 ("rejected", "rejected", "0"),
10659 ("done", "done_for_day", "1"),
10660 ("live", "new", "0"),
10661 ("unknown-status", "frozen", "0"),
10662 ] {
10663 mount_lookup(&server, cid, ok(order_json(cid, "SPY", status, filled)), 1).await;
10664 }
10665 mount_lookup(
10666 &server,
10667 "elsewhere",
10668 ok(order_json("elsewhere", "QQQ", "canceled", "0")),
10669 1,
10670 )
10671 .await;
10672 mount_lookup(
10673 &server,
10674 "gone",
10675 ResponseTemplate::new(404).set_body_json(serde_json::json!({
10676 "code": 40410000,
10677 "message": "order not found for gone"
10678 })),
10679 1,
10680 )
10681 .await;
10682
10683 let keys: Vec<_> = [
10684 "filled",
10685 "canceled",
10686 "replaced",
10687 "expired",
10688 "rejected",
10689 "done",
10690 "live",
10691 "unknown-status",
10692 "elsewhere",
10693 "gone",
10694 ]
10695 .into_iter()
10696 .map(|cid| alpaca_key("spy", cid))
10697 .collect();
10698 let ended = client_for(&server).fetch_ended_orders(&keys).await.unwrap();
10699
10700 let mut found: Vec<_> = ended
10701 .iter()
10702 .map(|order| (order.key.cid.0.as_str(), &order.state))
10703 .collect();
10704 found.sort_by_key(|(cid, _)| *cid);
10705 let [
10706 (canceled, InactiveOrderState::Cancelled(cancelled)),
10707 (expired, InactiveOrderState::Expired(expiry)),
10708 (filled, InactiveOrderState::FullyFilled(fill)),
10709 (rejected, InactiveOrderState::OpenFailed(failed)),
10710 (replaced, InactiveOrderState::Cancelled(replacement)),
10711 ] = found.as_slice()
10712 else {
10713 panic!("filled, canceled, replaced, expired and rejected: {found:?}");
10714 };
10715 assert_eq!(
10716 [*canceled, *expired, *filled, *rejected, *replaced],
10717 ["canceled", "expired", "filled", "rejected", "replaced"]
10718 );
10719 assert_eq!(
10720 (fill.filled_quantity, fill.avg_price),
10721 (dec!(2), Some(dec!(101.5)))
10722 );
10723 assert_eq!(
10724 cancelled.filled_quantity,
10725 Some(dec!(1)),
10726 "what filled before"
10727 );
10728 assert_eq!(expiry.filled_quantity, Some(dec!(1)));
10729 assert_eq!(replacement.filled_quantity, Some(Decimal::ZERO));
10730 assert_eq!(
10731 cancelled.time_exchange,
10732 parse_timestamp("2026-10-01T15:00:00Z").unwrap(),
10733 "stamped when the order last changed"
10734 );
10735 assert!(matches!(
10736 failed,
10737 OrderError::Rejected(ApiError::OrderRejected(message))
10738 if message.contains("id-rejected")
10739 ));
10740 assert!(
10741 ended
10742 .iter()
10743 .all(|order| order.key.strategy == StrategyId::new("strategy")
10744 && order.key.instrument == InstrumentNameExchange::new("spy")),
10745 "each carries the key it was asked for"
10746 );
10747 }
10748
10749 #[tokio::test]
10750 async fn fetch_ended_orders_fails_when_a_lookup_fails() {
10751 let server = MockServer::start().await;
10752 mount_lookup(&server, "a", ok(order_json("a", "SPY", "canceled", "0")), 1).await;
10753 mount_lookup(&server, "b", ResponseTemplate::new(500), 1).await;
10754
10755 let result = client_for(&server)
10756 .fetch_ended_orders(&[alpaca_key("SPY", "a"), alpaca_key("SPY", "b")])
10757 .await;
10758
10759 assert!(
10760 matches!(result, Err(UnindexedClientError::Connectivity(_))),
10761 "{result:?}"
10762 );
10763 }
10764
10765 #[tokio::test]
10768 async fn a_reconnect_check_lists_once_and_reports_the_orders_that_ended() {
10769 use wiremock::matchers::query_param;
10770
10771 let server = MockServer::start().await;
10772 Mock::given(method("GET"))
10773 .and(path("/v2/orders"))
10774 .and(query_param("status", "open"))
10775 .and(query_param("symbols", "QQQ,SPY"))
10776 .respond_with(ok(serde_json::json!([order_json(
10777 "spy-listed",
10778 "SPY",
10779 "new",
10780 "0"
10781 )])))
10782 .expect(1)
10783 .mount(&server)
10784 .await;
10785 mount_lookup(&server, "spy-listed", ResponseTemplate::new(500), 0).await;
10786 mount_lookup(
10787 &server,
10788 "spy-cancelled",
10789 ok(order_json("spy-cancelled", "SPY", "canceled", "1")),
10790 1,
10791 )
10792 .await;
10793 mount_lookup(
10794 &server,
10795 "spy-done",
10796 ok(order_json("spy-done", "SPY", "done_for_day", "1")),
10797 1,
10798 )
10799 .await;
10800 mount_lookup(
10801 &server,
10802 "qqq-filled",
10803 ok(order_json("qqq-filled", "QQQ", "filled", "2")),
10804 1,
10805 )
10806 .await;
10807
10808 let known = KnownLiveOrders::shared(ExchangeId::AlpacaBroker);
10809 let [listed, cancelled, done, filled] = [
10810 ("SPY", "spy-listed"),
10811 ("SPY", "spy-cancelled"),
10812 ("SPY", "spy-done"),
10813 ("QQQ", "qqq-filled"),
10814 ]
10815 .map(|(instrument, cid)| alpaca_key(instrument, cid));
10816 for key in [&listed, &cancelled, &done, &filled] {
10817 hold(&known, key);
10818 }
10819 let mut unchecked = UncheckedOrders::default();
10820 unchecked.open(known.lock().instruments());
10821 let config = Arc::new(AlpacaConfig::with_base_url(
10822 "test-key".into(),
10823 "test-secret".into(),
10824 server.uri(),
10825 ));
10826 let (tx, mut rx) = mpsc::unbounded_channel();
10827
10828 recover_alpaca_ended_orders(
10829 &reqwest::Client::new(),
10830 &Arc::new(RateLimitTracker::new()),
10831 &config,
10832 &known,
10833 &mut unchecked,
10834 &tx,
10835 )
10836 .await;
10837
10838 let mut reported: Vec<_> = std::iter::from_fn(|| rx.try_recv().ok())
10839 .map(|event| {
10840 let AccountEventKind::OrderSnapshot(
10841 rustrade_integration::collection::snapshot::Snapshot(order),
10842 ) = event.kind
10843 else {
10844 panic!("an order snapshot: {event:?}");
10845 };
10846 assert!(matches!(order.state, OrderState::Inactive(_)), "{order:?}");
10847 order.key.cid
10848 })
10849 .collect();
10850 reported.sort();
10851 assert_eq!(reported, [filled.cid.clone(), cancelled.cid.clone()]);
10852 let known = known.lock();
10853 assert!(known.contains(&listed.cid) && known.contains(&done.cid));
10854 assert!(!known.contains(&cancelled.cid) && !known.contains(&filled.cid));
10855 assert!(unchecked.is_empty(), "both instruments checked");
10856 }
10857
10858 #[tokio::test]
10860 async fn a_failed_listing_is_retried_for_every_instrument_in_it() {
10861 let server = MockServer::start().await;
10862 Mock::given(method("GET"))
10863 .and(path("/v2/orders"))
10864 .respond_with(ResponseTemplate::new(503))
10865 .expect(1)
10866 .mount(&server)
10867 .await;
10868
10869 let known = KnownLiveOrders::shared(ExchangeId::AlpacaBroker);
10870 hold(&known, &alpaca_key("SPY", "a"));
10871 hold(&known, &alpaca_key("QQQ", "b"));
10872 let mut unchecked = UncheckedOrders::default();
10873 unchecked.open(known.lock().instruments());
10874 let config = Arc::new(AlpacaConfig::with_base_url(
10875 "test-key".into(),
10876 "test-secret".into(),
10877 server.uri(),
10878 ));
10879 let (tx, mut rx) = mpsc::unbounded_channel();
10880
10881 recover_alpaca_ended_orders(
10882 &reqwest::Client::new(),
10883 &Arc::new(RateLimitTracker::new()),
10884 &config,
10885 &known,
10886 &mut unchecked,
10887 &tx,
10888 )
10889 .await;
10890
10891 assert!(rx.try_recv().is_err(), "nothing reported");
10892 let now = tokio::time::Instant::now();
10893 assert!(
10894 unchecked.ready(&NoPendingFills, now).is_empty(),
10895 "both wait"
10896 );
10897 for instrument in ["QQQ", "SPY"] {
10898 assert_eq!(
10899 unchecked.failed(
10900 &InstrumentNameExchange::new(instrument),
10901 now,
10902 Duration::ZERO
10903 ),
10904 Some(crate::client::order_recovery::GapFailure::Retry(
10905 Duration::from_secs(crate::client::order_recovery::GAP_RETRY_BASE_SECS * 2)
10906 )),
10907 "{instrument} has failed once already"
10908 );
10909 }
10910 assert_eq!(known.lock().instruments().len(), 2, "both still held");
10911 }
10912
10913 #[tokio::test]
10914 async fn placing_listing_and_cancelling_keep_the_held_orders_in_step() {
10915 use crate::order::request::{OrderRequestCancel, RequestCancel, RequestOpen};
10916
10917 let server = MockServer::start().await;
10918 Mock::given(method("POST"))
10919 .and(path("/v2/orders"))
10920 .respond_with(ok(order_json("resting", "SPY", "new", "0")))
10921 .up_to_n_times(1)
10922 .mount(&server)
10923 .await;
10924 Mock::given(method("POST"))
10925 .and(path("/v2/orders"))
10926 .respond_with(ok(order_json("filled", "SPY", "filled", "2")))
10927 .mount(&server)
10928 .await;
10929 Mock::given(method("GET"))
10930 .and(path("/v2/orders"))
10931 .respond_with(ok(serde_json::json!([order_json(
10932 "listed", "QQQ", "new", "0"
10933 )])))
10934 .mount(&server)
10935 .await;
10936 Mock::given(method("DELETE"))
10937 .and(path("/v2/orders/id-resting"))
10938 .respond_with(ResponseTemplate::new(204))
10939 .mount(&server)
10940 .await;
10941 let client = client_for(&server);
10942 let spy = InstrumentNameExchange::new("SPY");
10943
10944 for cid in ["resting", "filled"] {
10945 let request = OrderRequestOpen {
10946 key: OrderKey::new(
10947 ExchangeId::AlpacaBroker,
10948 &spy,
10949 StrategyId::new("strategy"),
10950 ClientOrderId::new(cid),
10951 ),
10952 state: RequestOpen {
10953 side: Side::Buy,
10954 price: Some(Decimal::ONE_HUNDRED),
10955 quantity: Decimal::TWO,
10956 kind: OrderKind::Limit,
10957 time_in_force: TimeInForce::GoodUntilCancelled { post_only: false },
10958 position_id: None,
10959 reduce_only: false,
10960 market: None,
10961 },
10962 };
10963 client.open_order(request).await;
10964 }
10965 client.fetch_open_orders(&[]).await.unwrap();
10966 {
10967 let known = client.known_live.lock();
10968 assert!(known.contains(&ClientOrderId::new("resting")));
10969 assert!(
10970 !known.contains(&ClientOrderId::new("filled")),
10971 "filled in its placement response"
10972 );
10973 assert!(known.contains(&ClientOrderId::new("listed")));
10974 }
10975
10976 let cancel = client
10977 .cancel_order(OrderRequestCancel {
10978 key: OrderKey::new(
10979 ExchangeId::AlpacaBroker,
10980 &spy,
10981 StrategyId::new("strategy"),
10982 ClientOrderId::new("resting"),
10983 ),
10984 state: RequestCancel {
10985 id: Some(VenueOrderId::Assigned(OrderId::new("id-resting"))),
10986 },
10987 })
10988 .await;
10989 let Ok(cancelled) = &cancel.state else {
10990 panic!("expected Ok, got {cancel:?}");
10991 };
10992 assert_eq!(
10993 cancelled.filled_quantity, None,
10994 "the DELETE response has no body, so what filled is unknown"
10995 );
10996 assert!(
10997 client
10998 .known_live
10999 .lock()
11000 .contains(&ClientOrderId::new("resting")),
11001 "a cancel Alpaca has only accepted has not ended the order"
11002 );
11003 }
11004
11005 #[tokio::test]
11012 async fn an_order_that_ended_in_its_placement_response_is_reported_as_it_ended() {
11013 use crate::order::request::RequestOpen;
11014
11015 let Some(time) = parse_timestamp("2026-10-01T15:00:00Z") else {
11016 panic!("order_json's updated_at parses");
11017 };
11018 let id = |cid: &str| OrderId::new(format!("id-{cid}"));
11019 let open = |cid: &str, filled| {
11020 OrderState::active(Open::new(VenueOrderId::Assigned(id(cid)), time, filled))
11021 };
11022 let cases: [(&str, &str, &str, UnindexedOrderState, bool); 13] = [
11023 (
11024 "ioc-unfilled",
11025 "expired",
11026 "0",
11027 OrderState::expired(Expired::new(
11028 id("ioc-unfilled"),
11029 time,
11030 Some(Decimal::ZERO),
11031 )),
11032 false,
11033 ),
11034 (
11035 "ioc-partial",
11036 "expired",
11037 "1",
11038 OrderState::expired(Expired::new(id("ioc-partial"), time, Some(Decimal::ONE))),
11039 false,
11040 ),
11041 (
11042 "fill-unknown",
11043 "expired",
11044 "not-a-number",
11045 OrderState::expired(Expired::new(id("fill-unknown"), time, None)),
11046 false,
11047 ),
11048 (
11049 "cancelled",
11050 "canceled",
11051 "1",
11052 OrderState::inactive(Cancelled::new(id("cancelled"), time, Some(Decimal::ONE))),
11053 false,
11054 ),
11055 (
11056 "rejected",
11057 "rejected",
11058 "0",
11059 OrderState::inactive(OrderError::Rejected(ApiError::OrderRejected(
11060 "Alpaca rejected order id-rejected after accepting it".to_string(),
11061 ))),
11062 false,
11063 ),
11064 (
11065 "filled",
11066 "filled",
11067 "2",
11068 OrderState::fully_filled(Filled::new(
11069 id("filled"),
11070 time,
11071 Decimal::TWO,
11072 Some(Decimal::new(1015, 1)),
11073 )),
11074 false,
11075 ),
11076 (
11077 "replaced",
11078 "replaced",
11079 "0",
11080 OrderState::inactive(Cancelled::new(id("replaced"), time, Some(Decimal::ZERO))),
11081 false,
11082 ),
11083 (
11084 "live-covered",
11085 "partially_filled",
11086 "2",
11087 OrderState::fully_filled(Filled::new(
11088 id("live-covered"),
11089 time,
11090 Decimal::TWO,
11091 Some(Decimal::new(1015, 1)),
11092 )),
11093 false,
11094 ),
11095 ("new", "new", "0", open("new", Decimal::ZERO), true),
11096 (
11097 "live-fill-unknown",
11098 "new",
11099 "not-a-number",
11100 open("live-fill-unknown", Decimal::ZERO),
11101 true,
11102 ),
11103 (
11104 "partial",
11105 "partially_filled",
11106 "1",
11107 open("partial", Decimal::ONE),
11108 true,
11109 ),
11110 (
11111 "for-the-day",
11112 "done_for_day",
11113 "1",
11114 open("for-the-day", Decimal::ONE),
11115 true,
11116 ),
11117 (
11118 "unknown",
11119 "a_status_not_yet_documented",
11120 "0",
11121 open("unknown", Decimal::ZERO),
11122 true,
11123 ),
11124 ];
11125
11126 for (cid, status, filled, expected, held) in cases {
11127 for bracket in [false, true] {
11128 let server = MockServer::start().await;
11129 Mock::given(method("POST"))
11130 .and(path("/v2/orders"))
11131 .respond_with(ok(order_json(cid, "SPY", status, filled)))
11132 .expect(1)
11133 .mount(&server)
11134 .await;
11135 let client = client_for(&server);
11136 let spy = InstrumentNameExchange::new("SPY");
11137 let time_in_force = TimeInForce::GoodUntilCancelled { post_only: false };
11138
11139 let state = if bracket {
11140 client
11141 .open_bracket_order(AlpacaBracketOrderRequest::new(
11142 spy,
11143 StrategyId::new("strategy"),
11144 ClientOrderId::new(cid),
11145 Side::Buy,
11146 Decimal::TWO,
11147 Decimal::ONE_HUNDRED,
11148 Decimal::new(120, 0),
11149 Decimal::new(90, 0),
11150 time_in_force,
11151 ))
11152 .await
11153 .parent
11154 .state
11155 } else {
11156 let request = OrderRequestOpen {
11157 key: OrderKey::new(
11158 ExchangeId::AlpacaBroker,
11159 &spy,
11160 StrategyId::new("strategy"),
11161 ClientOrderId::new(cid),
11162 ),
11163 state: RequestOpen {
11164 side: Side::Buy,
11165 price: Some(Decimal::ONE_HUNDRED),
11166 quantity: Decimal::TWO,
11167 kind: OrderKind::Limit,
11168 time_in_force,
11169 position_id: None,
11170 reduce_only: false,
11171 market: None,
11172 },
11173 };
11174 client.open_order(request).await.state
11175 };
11176
11177 assert_eq!(state, expected, "{status} {filled}, bracket: {bracket}");
11178 assert_eq!(
11179 client.known_live.lock().contains(&ClientOrderId::new(cid)),
11180 held,
11181 "{status} {filled}, bracket: {bracket}"
11182 );
11183 }
11184 }
11185 }
11186
11187 #[test]
11190 fn the_stream_keeps_the_held_orders_in_step() {
11191 let (tx, mut rx) = mpsc::unbounded_channel();
11192 let dedup = new_dedup_cache();
11193 let known = KnownLiveOrders::shared(ExchangeId::AlpacaBroker);
11194 let mut backoff = ExponentialBackoff::new();
11195 let frame = |event: &str, filled: &str| {
11196 format!(
11197 r#"{{"stream":"trade_updates","data":{{"event":"{event}","order":{{"id":"id-a","client_order_id":"a","symbol":"SPY","qty":"2","filled_qty":"{filled}","side":"buy","type":"limit","time_in_force":"gtc","limit_price":"100","status":"{event}"}}}}}}"#
11198 )
11199 };
11200 let cid = ClientOrderId::new("a");
11201
11202 process_ws_text(&frame("new", "0"), &tx, &dedup, &known, &mut backoff);
11203 assert!(known.lock().contains(&cid), "acknowledged");
11204
11205 process_ws_text(
11206 &frame("done_for_day", "1"),
11207 &tx,
11208 &dedup,
11209 &known,
11210 &mut backoff,
11211 );
11212 assert!(known.lock().contains(&cid), "done for the day is not ended");
11213
11214 process_ws_text(&frame("canceled", "1"), &tx, &dedup, &known, &mut backoff);
11215 assert!(!known.lock().contains(&cid), "cancelled");
11216
11217 let events: Vec<_> = std::iter::from_fn(|| rx.try_recv().ok()).collect();
11218 let [_, done, cancelled] = events.as_slice() else {
11219 panic!("three events: {events:?}");
11220 };
11221 let AccountEventKind::OrderSnapshot(
11222 rustrade_integration::collection::snapshot::Snapshot(order),
11223 ) = &done.kind
11224 else {
11225 panic!("done_for_day reports an order snapshot: {done:?}");
11226 };
11227 let OrderState::Active(ActiveOrderState::Open(open)) = &order.state else {
11228 panic!("open: {order:?}");
11229 };
11230 assert_eq!(open.filled_quantity, Decimal::ONE, "with what it filled");
11231 assert!(matches!(
11232 cancelled.kind,
11233 AccountEventKind::OrderCancelled(_)
11234 ));
11235 }
11236
11237 #[tokio::test]
11240 async fn a_recovered_fill_that_completes_an_order_stops_it_being_held() {
11241 let known = KnownLiveOrders::shared(ExchangeId::AlpacaBroker);
11242 let key = alpaca_key("SPY", "a");
11243 known.lock().live(
11244 &key,
11245 Decimal::TWO,
11246 OrderKind::Limit,
11247 &Open::new(
11248 VenueOrderId::Assigned(OrderId::new("ord-1")),
11249 Utc::now(),
11250 Decimal::ZERO,
11251 ),
11252 );
11253
11254 drive_recover_fills_with(
11255 vec![activity_json("act-1", "ord-1", "1", Some("1"))],
11256 new_dedup_cache(),
11257 &known,
11258 )
11259 .await;
11260 assert!(known.lock().contains(&key.cid), "half filled");
11261
11262 drive_recover_fills_with(
11263 vec![activity_json("act-2", "ord-1", "1", Some("2"))],
11264 new_dedup_cache(),
11265 &known,
11266 )
11267 .await;
11268 assert!(!known.lock().contains(&key.cid), "filled");
11269 }
11270
11271 #[tokio::test]
11274 async fn the_check_loop_runs_at_once_then_waits_out_a_failure() {
11275 let server = MockServer::start().await;
11276 Mock::given(method("GET"))
11277 .and(path("/v2/orders"))
11278 .respond_with(ResponseTemplate::new(503))
11279 .expect(1)
11280 .mount(&server)
11281 .await;
11282 let known = KnownLiveOrders::shared(ExchangeId::AlpacaBroker);
11283 hold(&known, &alpaca_key("SPY", "a"));
11284 let mut unchecked = UncheckedOrders::default();
11285 unchecked.open(known.lock().instruments());
11286 let config = Arc::new(AlpacaConfig::with_base_url(
11287 "test-key".into(),
11288 "test-secret".into(),
11289 server.uri(),
11290 ));
11291 let (tx, _rx) = mpsc::unbounded_channel();
11292
11293 let ran = tokio::time::timeout(
11294 Duration::from_secs(1),
11295 run_order_checks(
11296 &reqwest::Client::new(),
11297 &Arc::new(RateLimitTracker::new()),
11298 &config,
11299 &known,
11300 &mut unchecked,
11301 &tx,
11302 ),
11303 )
11304 .await;
11305
11306 assert!(ran.is_err(), "the loop never completes");
11307 assert!(
11308 unchecked.contains(&InstrumentNameExchange::new("SPY")),
11309 "SPY waits for its retry"
11310 );
11311 assert_eq!(
11312 server.received_requests().await.unwrap().len(),
11313 1,
11314 "the failed check is not run again at once"
11315 );
11316 }
11317
11318 #[tokio::test]
11320 async fn the_check_loop_waits_once_the_consumer_has_gone() {
11321 let server = MockServer::start().await;
11322 Mock::given(method("GET"))
11323 .respond_with(ResponseTemplate::new(503))
11324 .expect(0)
11325 .mount(&server)
11326 .await;
11327 let known = KnownLiveOrders::shared(ExchangeId::AlpacaBroker);
11328 hold(&known, &alpaca_key("SPY", "a"));
11329 let mut unchecked = UncheckedOrders::default();
11330 unchecked.open(known.lock().instruments());
11331 let config = Arc::new(AlpacaConfig::with_base_url(
11332 "test-key".into(),
11333 "test-secret".into(),
11334 server.uri(),
11335 ));
11336 let (tx, rx) = mpsc::unbounded_channel();
11337 drop(rx);
11338
11339 let ran = tokio::time::timeout(
11340 Duration::from_millis(200),
11341 run_order_checks(
11342 &reqwest::Client::new(),
11343 &Arc::new(RateLimitTracker::new()),
11344 &config,
11345 &known,
11346 &mut unchecked,
11347 &tx,
11348 ),
11349 )
11350 .await;
11351
11352 assert!(ran.is_err(), "the loop never completes");
11353 assert!(
11354 server.received_requests().await.unwrap().is_empty(),
11355 "nothing asked once the consumer has gone"
11356 );
11357 }
11358
11359 async fn shortability_with_asset(
11360 symbol: &str,
11361 asset: serde_json::Value,
11362 ) -> (Result<Shortability, UnindexedClientError>, Vec<String>) {
11363 use crate::client::ShortabilityClient;
11364
11365 let server = MockServer::start().await;
11366 Mock::given(method("GET"))
11367 .respond_with(ResponseTemplate::new(200).set_body_json(asset))
11368 .mount(&server)
11369 .await;
11370 let result = client_for(&server)
11371 .fetch_shortability(&InstrumentNameExchange::new(symbol))
11372 .await;
11373 let paths = server
11374 .received_requests()
11375 .await
11376 .unwrap()
11377 .iter()
11378 .map(|request| request.url.path().to_owned())
11379 .collect();
11380 (result, paths)
11381 }
11382
11383 #[tokio::test]
11384 async fn an_easy_to_borrow_stock_is_shortable_at_no_fee() {
11385 let (result, paths) = shortability_with_asset(
11386 "AAPL",
11387 serde_json::json!({
11388 "class": "us_equity",
11389 "symbol": "AAPL",
11390 "shortable": true,
11391 "easy_to_borrow": true,
11392 "borrow_status": "easy_to_borrow",
11393 }),
11394 )
11395 .await;
11396
11397 assert_eq!(
11398 result.unwrap(),
11399 Shortability::new()
11400 .with_shortable(true)
11401 .with_easy_to_borrow(true)
11402 .with_fee_rate(Decimal::ZERO)
11403 );
11404 assert_eq!(paths, vec!["/v2/assets/AAPL"]);
11405 }
11406
11407 #[tokio::test]
11408 async fn a_hard_to_borrow_stock_has_no_known_fee() {
11409 let (result, _) = shortability_with_asset(
11410 "GME",
11411 serde_json::json!({
11412 "class": "us_equity",
11413 "shortable": true,
11414 "easy_to_borrow": true,
11415 "borrow_status": "hard_to_borrow",
11416 }),
11417 )
11418 .await;
11419
11420 assert_eq!(
11421 result.unwrap(),
11422 Shortability::new()
11423 .with_shortable(true)
11424 .with_easy_to_borrow(false),
11425 "borrow_status wins over the easy_to_borrow flag, and a hard-to-borrow fee is unknown"
11426 );
11427 }
11428
11429 #[tokio::test]
11430 async fn without_a_borrow_status_the_easy_to_borrow_flag_is_read() {
11431 let (result, _) = shortability_with_asset(
11432 "AAPL",
11433 serde_json::json!({ "class": "us_equity", "shortable": false, "easy_to_borrow": false }),
11434 )
11435 .await;
11436
11437 assert_eq!(
11438 result.unwrap(),
11439 Shortability::new()
11440 .with_shortable(false)
11441 .with_easy_to_borrow(false)
11442 );
11443 }
11444
11445 #[tokio::test]
11446 async fn an_asset_without_borrow_fields_is_unknown_but_for_shortable() {
11447 let (result, paths) = shortability_with_asset(
11448 "BTC/USD",
11449 serde_json::json!({ "class": "crypto", "symbol": "BTC/USD", "shortable": false }),
11450 )
11451 .await;
11452
11453 assert_eq!(result.unwrap(), Shortability::new().with_shortable(false));
11454 assert_eq!(
11455 paths,
11456 vec!["/v2/assets/BTC%2FUSD"],
11457 "a crypto pair's slash is escaped, not read as a path separator"
11458 );
11459 }
11460
11461 #[tokio::test]
11462 async fn an_option_has_no_shortability() {
11463 let (result, _) = shortability_with_asset(
11464 "AAPL250117C00150000",
11465 serde_json::json!({ "class": "us_option", "shortable": true }),
11466 )
11467 .await;
11468
11469 assert!(
11470 matches!(
11471 result,
11472 Err(UnindexedClientError::Api(ApiError::InstrumentInvalid(ref instrument, ref reason)))
11473 if instrument.name().as_str() == "AAPL250117C00150000"
11474 && reason.contains("option")
11475 ),
11476 "{result:?}"
11477 );
11478 }
11479
11480 #[tokio::test]
11481 async fn an_unknown_symbol_is_an_invalid_instrument() {
11482 use crate::client::ShortabilityClient;
11483
11484 let server = MockServer::start().await;
11485 Mock::given(method("GET"))
11486 .and(path("/v2/assets/NOPE"))
11487 .respond_with(
11488 ResponseTemplate::new(404).set_body_json(
11489 serde_json::json!({ "message": "asset not found for NOPE" }),
11490 ),
11491 )
11492 .mount(&server)
11493 .await;
11494
11495 let result = client_for(&server)
11496 .fetch_shortability(&InstrumentNameExchange::new("NOPE"))
11497 .await;
11498
11499 assert!(
11500 matches!(
11501 result,
11502 Err(UnindexedClientError::Api(ApiError::InstrumentInvalid(ref instrument, _)))
11503 if instrument.name().as_str() == "NOPE"
11504 ),
11505 "{result:?}"
11506 );
11507 }
11508
11509 struct RateLimited {
11512 call: std::sync::atomic::AtomicU32,
11513 last: Option<(&'static str, String)>,
11514 }
11515
11516 impl RateLimited {
11517 fn mount(last: Option<(&'static str, String)>) -> Self {
11518 Self {
11519 call: std::sync::atomic::AtomicU32::new(0),
11520 last,
11521 }
11522 }
11523 }
11524
11525 impl Respond for RateLimited {
11526 fn respond(&self, _: &Request) -> ResponseTemplate {
11527 let call = self.call.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1;
11528 let response = ResponseTemplate::new(429)
11529 .set_body_json(serde_json::json!({"message": "too many requests"}));
11530 if call < MAX_RATE_LIMIT_ATTEMPTS {
11531 return response.insert_header("retry-after", "0");
11532 }
11533 match &self.last {
11534 Some((name, value)) => response.insert_header(*name, value.as_str()),
11535 None => response,
11536 }
11537 }
11538 }
11539
11540 fn unix_now() -> u64 {
11541 std::time::SystemTime::now()
11542 .duration_since(std::time::UNIX_EPOCH)
11543 .unwrap()
11544 .as_secs()
11545 }
11546
11547 #[tokio::test]
11548 async fn exhausted_429_retries_report_alpacas_wait() {
11549 let server = MockServer::start().await;
11550 Mock::given(method("GET"))
11551 .and(path("/v2/account"))
11552 .respond_with(RateLimited::mount(Some(("retry-after", "7".to_string()))))
11553 .mount(&server)
11554 .await;
11555
11556 let http = reqwest::Client::new();
11557 let rl = RateLimitTracker::new();
11558 let url = format!("{}/v2/account", server.uri());
11559 let error = rest_with_retry::<serde_json::Value>(&rl, || http.get(&url))
11560 .await
11561 .unwrap_err();
11562
11563 let UnindexedClientError::Api(error) = error else {
11564 panic!("expected Api, got {error:?}");
11565 };
11566 assert_eq!(error.retry_after(), Some(Duration::from_secs(7)));
11567 let blocked_until = rl
11568 .blocked_until
11569 .lock()
11570 .expect("the last refusal pauses the client");
11571 assert!(
11572 blocked_until >= tokio::time::Instant::now() + Duration::from_secs(6),
11573 "the client's next request waits for Alpaca's wait"
11574 );
11575 assert_eq!(
11576 server.received_requests().await.unwrap().len(),
11577 MAX_RATE_LIMIT_ATTEMPTS as usize
11578 );
11579 }
11580
11581 #[tokio::test]
11582 async fn exhausted_429_retries_read_the_reset_time_without_retry_after() {
11583 let server = MockServer::start().await;
11584 Mock::given(method("GET"))
11585 .and(path("/v2/account"))
11586 .respond_with(RateLimited::mount(Some((
11587 "x-ratelimit-reset",
11588 (unix_now() + 30).to_string(),
11589 ))))
11590 .mount(&server)
11591 .await;
11592
11593 let http = reqwest::Client::new();
11594 let rl = RateLimitTracker::new();
11595 let url = format!("{}/v2/account", server.uri());
11596 let error = rest_with_retry::<serde_json::Value>(&rl, || http.get(&url))
11597 .await
11598 .unwrap_err();
11599
11600 let UnindexedClientError::Api(ApiError::RateLimit {
11601 retry_after: Some(wait),
11602 ..
11603 }) = error
11604 else {
11605 panic!("expected a RateLimit with a wait, got {error:?}");
11606 };
11607 assert!(
11608 (Duration::from_secs(29)..=Duration::from_secs(30)).contains(&wait),
11609 "{wait:?}"
11610 );
11611 }
11612
11613 #[tokio::test]
11614 async fn exhausted_429_retries_without_a_hint_report_none() {
11615 let server = MockServer::start().await;
11616 Mock::given(method("GET"))
11617 .and(path("/v2/account"))
11618 .respond_with(RateLimited::mount(None))
11619 .mount(&server)
11620 .await;
11621
11622 let http = reqwest::Client::new();
11623 let rl = RateLimitTracker::new();
11624 let url = format!("{}/v2/account", server.uri());
11625 let error = rest_with_retry::<serde_json::Value>(&rl, || http.get(&url))
11626 .await
11627 .unwrap_err();
11628
11629 assert!(
11630 matches!(
11631 error,
11632 UnindexedClientError::Api(ApiError::RateLimit {
11633 retry_after: None,
11634 ..
11635 })
11636 ),
11637 "{error:?}"
11638 );
11639 }
11640
11641 #[tokio::test]
11642 async fn an_exhausted_cancel_reports_alpacas_wait() {
11643 let server = MockServer::start().await;
11644 Mock::given(method("DELETE"))
11645 .and(path("/v2/orders/abc"))
11646 .respond_with(RateLimited::mount(Some(("retry-after", "5".to_string()))))
11647 .mount(&server)
11648 .await;
11649
11650 let http = reqwest::Client::new();
11651 let rl = RateLimitTracker::new();
11652 let url = format!("{}/v2/orders/abc", server.uri());
11653 let error = rest_delete_with_retry(&rl, || http.delete(&url))
11654 .await
11655 .unwrap_err();
11656
11657 let UnindexedOrderError::Rejected(error) = error else {
11658 panic!("expected Rejected, got {error:?}");
11659 };
11660 assert_eq!(error.retry_after(), Some(Duration::from_secs(5)));
11661 assert!(
11662 rl.blocked_until.lock().is_some(),
11663 "the last refusal pauses the client"
11664 );
11665 }
11666 }
11667}