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(
5522 &self,
5523 instrument: &InstrumentNameExchange,
5524 ) -> Result<Shortability, UnindexedClientError> {
5525 let url = asset_url(self.base_url(), instrument)?;
5526 let found: Option<AlpacaAsset> =
5527 rest_lookup_with_retry(&self.rate_limiter, || self.http.get(url.clone())).await?;
5528 let Some(asset) = found else {
5529 return Err(instrument_invalid(
5530 instrument,
5531 "Alpaca knows no asset under this symbol",
5532 ));
5533 };
5534 shortability_from_asset(instrument, &asset)
5535 }
5536}
5537
5538fn asset_url(
5541 base_url: &str,
5542 instrument: &InstrumentNameExchange,
5543) -> Result<reqwest::Url, UnindexedClientError> {
5544 let mut url = reqwest::Url::parse(base_url).map_err(|error| {
5545 UnindexedClientError::Internal(format!("Alpaca base URL {base_url:?}: {error}"))
5546 })?;
5547 url.path_segments_mut()
5548 .map_err(|()| {
5549 UnindexedClientError::Internal(format!("Alpaca base URL {base_url:?} has no path"))
5550 })?
5551 .pop_if_empty()
5552 .extend(["v2", "assets", instrument.name().as_str()]);
5553 Ok(url)
5554}
5555
5556fn instrument_invalid(instrument: &InstrumentNameExchange, reason: &str) -> UnindexedClientError {
5557 UnindexedClientError::Api(ApiError::InstrumentInvalid(
5558 instrument.clone(),
5559 reason.to_owned(),
5560 ))
5561}
5562
5563fn shortability_from_asset(
5565 instrument: &InstrumentNameExchange,
5566 asset: &AlpacaAsset,
5567) -> Result<Shortability, UnindexedClientError> {
5568 if asset.class == AlpacaAssetClass::UsOption {
5569 return Err(instrument_invalid(
5570 instrument,
5571 "shortability does not apply to an option: writing one borrows nothing",
5572 ));
5573 }
5574 let easy_to_borrow = match asset.borrow_status {
5575 Some(AlpacaBorrowStatus::EasyToBorrow) => Some(true),
5576 Some(AlpacaBorrowStatus::HardToBorrow) => Some(false),
5577 Some(AlpacaBorrowStatus::Other) | None => asset.easy_to_borrow,
5578 };
5579 let mut shortability = Shortability::new();
5580 if let Some(shortable) = asset.shortable {
5581 shortability = shortability.with_shortable(shortable);
5582 }
5583 if let Some(easy_to_borrow) = easy_to_borrow {
5584 shortability = shortability.with_easy_to_borrow(easy_to_borrow);
5585 if easy_to_borrow {
5586 shortability = shortability.with_fee_rate(Decimal::ZERO);
5587 }
5588 }
5589 Ok(shortability)
5590}
5591
5592#[derive(Debug, Deserialize)]
5595struct AlpacaAsset {
5596 #[serde(default)]
5597 class: AlpacaAssetClass,
5598 #[serde(default)]
5599 shortable: Option<bool>,
5600 #[serde(default)]
5602 borrow_status: Option<AlpacaBorrowStatus>,
5603 #[serde(default)]
5605 easy_to_borrow: Option<bool>,
5606}
5607
5608#[derive(Debug, Default, PartialEq, Eq, Deserialize)]
5609#[serde(rename_all = "snake_case")]
5610enum AlpacaAssetClass {
5611 UsOption,
5612 #[default]
5613 #[serde(other)]
5614 Other,
5615}
5616
5617#[derive(Debug, PartialEq, Eq, Deserialize)]
5618#[serde(rename_all = "snake_case")]
5619enum AlpacaBorrowStatus {
5620 EasyToBorrow,
5621 HardToBorrow,
5622 #[serde(other)]
5623 Other,
5624}
5625
5626impl BracketOrderClient for AlpacaClient {
5631 async fn open_bracket_order(
5632 &self,
5633 request: UnifiedBracketOrderRequest<ExchangeId, &InstrumentNameExchange>,
5634 ) -> UnifiedBracketOrderResult {
5635 let alpaca_request = AlpacaBracketOrderRequest {
5636 instrument: request.key.instrument.clone(),
5637 strategy: request.key.strategy.clone(),
5638 cid: request.key.cid.clone(),
5639 side: request.state.side,
5640 quantity: request.state.quantity,
5641 entry_price: request.state.entry_price,
5642 take_profit_price: request.state.take_profit_price,
5643 stop_loss_price: request.state.stop_loss_price,
5644 stop_loss_limit_price: request.state.stop_loss_limit_price,
5645 time_in_force: request.state.time_in_force,
5646 };
5647
5648 let result = self.open_bracket_order(alpaca_request).await;
5649
5650 UnifiedBracketOrderResult::parent_only(result.parent)
5651 }
5652}
5653
5654#[cfg(test)]
5659#[allow(clippy::unwrap_used, clippy::expect_used)] mod tests {
5661 use super::*;
5662 use rust_decimal_macros::dec;
5663
5664 #[test]
5665 fn test_alpaca_config_new_uses_paper_trading_by_default() {
5666 let cfg = AlpacaConfig::new("my_key".into(), "my_secret".into());
5667 assert!(cfg.paper);
5668 }
5669
5670 #[test]
5671 fn test_alpaca_config_debug_redacts_credentials() {
5672 let cfg = AlpacaConfig::new("my_key".into(), "my_secret".into());
5673 let debug = format!("{cfg:?}");
5674 assert!(!debug.contains("my_key"), "api_key should be redacted");
5675 assert!(
5676 !debug.contains("my_secret"),
5677 "secret_key should be redacted"
5678 );
5679 assert!(debug.contains("paper: true"));
5680 }
5681
5682 #[test]
5683 fn test_alpaca_config_deserialize_omitted_paper_defaults_to_paper() {
5684 let cfg: AlpacaConfig = serde_json::from_str(
5685 r#"{
5686 "api_key": "my_key",
5687 "secret_key": "my_secret"
5688 }"#,
5689 )
5690 .unwrap();
5691
5692 assert!(cfg.paper);
5693 assert_eq!(cfg.rest_base_url(), "https://paper-api.alpaca.markets");
5694 }
5695
5696 #[test]
5697 fn test_alpaca_config_deserialize_paper_true() {
5698 let cfg: AlpacaConfig = serde_json::from_str(
5699 r#"{
5700 "api_key": "my_key",
5701 "secret_key": "my_secret",
5702 "paper": true
5703 }"#,
5704 )
5705 .unwrap();
5706
5707 assert!(cfg.paper);
5708 assert_eq!(cfg.rest_base_url(), "https://paper-api.alpaca.markets");
5709 }
5710
5711 #[test]
5712 fn test_alpaca_config_deserialize_paper_false() {
5713 let cfg: AlpacaConfig = serde_json::from_str(
5714 r#"{
5715 "api_key": "my_key",
5716 "secret_key": "my_secret",
5717 "paper": false
5718 }"#,
5719 )
5720 .unwrap();
5721
5722 assert!(!cfg.paper);
5723 assert_eq!(cfg.rest_base_url(), "https://api.alpaca.markets");
5724 }
5725
5726 #[test]
5727 fn test_alpaca_config_urls() {
5728 let paper = AlpacaConfig::paper("k".into(), "s".into());
5729 assert!(paper.rest_base_url().contains("paper-api"));
5730 assert!(paper.ws_url().contains("paper-api"));
5731
5732 let live = AlpacaConfig::production("k".into(), "s".into());
5733 assert!(!live.rest_base_url().contains("paper-api"));
5734 assert!(!live.ws_url().contains("paper-api"));
5735 }
5736
5737 #[test]
5738 #[serial_test::serial]
5739 fn test_alpaca_config_from_env_defaults_to_paper_trading() {
5740 temp_env::with_vars(
5741 [
5742 ("ALPACA_API_KEY", Some("my_key")),
5743 ("ALPACA_SECRET_KEY", Some("my_secret")),
5744 ("ALPACA_PAPER", None),
5745 ],
5746 || {
5747 let cfg = AlpacaConfig::from_env().unwrap();
5748 assert!(cfg.paper);
5749 assert_eq!(cfg.rest_base_url(), "https://paper-api.alpaca.markets");
5750 },
5751 );
5752 }
5753
5754 #[test]
5755 #[serial_test::serial]
5756 fn test_alpaca_config_from_env_accepts_explicit_production() {
5757 temp_env::with_vars(
5758 [
5759 ("ALPACA_API_KEY", Some("my_key")),
5760 ("ALPACA_SECRET_KEY", Some("my_secret")),
5761 ("ALPACA_PAPER", Some("false")),
5762 ],
5763 || {
5764 let cfg = AlpacaConfig::from_env().unwrap();
5765 assert!(!cfg.paper);
5766 assert_eq!(cfg.rest_base_url(), "https://api.alpaca.markets");
5767 },
5768 );
5769 }
5770
5771 #[test]
5772 #[serial_test::serial]
5773 fn test_alpaca_config_from_env_rejects_invalid_paper() {
5774 temp_env::with_vars(
5775 [
5776 ("ALPACA_API_KEY", Some("my_key")),
5777 ("ALPACA_SECRET_KEY", Some("my_secret")),
5778 ("ALPACA_PAPER", Some("maybe")),
5779 ],
5780 || {
5781 let err = AlpacaConfig::from_env().unwrap_err();
5782 assert!(matches!(err, AlpacaConfigError::InvalidPaper(value) if value == "maybe"));
5783 },
5784 );
5785 }
5786
5787 #[test]
5788 #[serial_test::serial]
5789 fn test_alpaca_config_from_env_requires_credentials() {
5790 temp_env::with_vars(
5791 [
5792 ("ALPACA_API_KEY", None),
5793 ("ALPACA_SECRET_KEY", Some("my_secret")),
5794 ("ALPACA_PAPER", None),
5795 ],
5796 || {
5797 let err = AlpacaConfig::from_env().unwrap_err();
5798 assert!(matches!(err, AlpacaConfigError::MissingApiKey));
5799 },
5800 );
5801 }
5802
5803 #[test]
5804 fn test_parse_side() {
5805 assert_eq!(parse_side("buy"), Some(Side::Buy));
5806 assert_eq!(parse_side("sell"), Some(Side::Sell));
5807 assert_eq!(parse_side("Buy"), Some(Side::Buy));
5808 assert_eq!(parse_side("BUY"), Some(Side::Buy));
5809 assert_eq!(parse_side("unknown"), None);
5810 }
5811
5812 #[test]
5813 fn test_parse_order_kind() {
5814 assert_eq!(
5816 parse_order_kind("market", None, None, None),
5817 Some(OrderKind::Market)
5818 );
5819 assert_eq!(
5820 parse_order_kind("limit", None, None, None),
5821 Some(OrderKind::Limit)
5822 );
5823
5824 assert_eq!(
5826 parse_order_kind("stop", Some("150.00"), None, None),
5827 Some(OrderKind::Stop {
5828 trigger_price: Decimal::from_str("150.00").unwrap()
5829 })
5830 );
5831 assert_eq!(parse_order_kind("stop", None, None, None), None);
5832
5833 assert_eq!(
5835 parse_order_kind("stop_limit", Some("145.00"), None, None),
5836 Some(OrderKind::StopLimit {
5837 trigger_price: Decimal::from_str("145.00").unwrap()
5838 })
5839 );
5840
5841 assert_eq!(
5843 parse_order_kind("trailing_stop", None, Some("5.0"), None),
5844 Some(OrderKind::TrailingStop {
5845 offset: Decimal::from_str("5.0").unwrap(),
5846 offset_type: TrailingOffsetType::Percentage,
5847 activation_price: None,
5848 })
5849 );
5850
5851 assert_eq!(
5853 parse_order_kind("trailing_stop", None, None, Some("2.50")),
5854 Some(OrderKind::TrailingStop {
5855 offset: Decimal::from_str("2.50").unwrap(),
5856 offset_type: TrailingOffsetType::Absolute,
5857 activation_price: None,
5858 })
5859 );
5860
5861 assert_eq!(parse_order_kind("trailing_stop", None, None, None), None);
5863
5864 assert_eq!(parse_order_kind("unknown", None, None, None), None);
5866 }
5867
5868 #[test]
5869 fn test_map_order_kind() {
5870 assert_eq!(map_order_kind(OrderKind::Market), Some("market"));
5871 assert_eq!(map_order_kind(OrderKind::Limit), Some("limit"));
5872 assert_eq!(
5873 map_order_kind(OrderKind::Stop {
5874 trigger_price: Decimal::from_str("150.00").unwrap()
5875 }),
5876 Some("stop")
5877 );
5878 assert_eq!(
5879 map_order_kind(OrderKind::StopLimit {
5880 trigger_price: Decimal::from_str("145.00").unwrap()
5881 }),
5882 Some("stop_limit")
5883 );
5884 assert_eq!(
5885 map_order_kind(OrderKind::TrailingStop {
5886 offset: Decimal::from_str("5.0").unwrap(),
5887 offset_type: TrailingOffsetType::Percentage,
5888 activation_price: None,
5889 }),
5890 Some("trailing_stop")
5891 );
5892 assert_eq!(
5893 map_order_kind(OrderKind::TrailingStop {
5894 offset: Decimal::from_str("2.50").unwrap(),
5895 offset_type: TrailingOffsetType::Absolute,
5896 activation_price: None,
5897 }),
5898 Some("trailing_stop")
5899 );
5900 assert_eq!(
5902 map_order_kind(OrderKind::TrailingStop {
5903 offset: Decimal::from_str("5.0").unwrap(),
5904 offset_type: TrailingOffsetType::Percentage,
5905 activation_price: Some(Decimal::from(100)),
5906 }),
5907 None
5908 );
5909 assert_eq!(
5910 map_order_kind(OrderKind::TrailingStopFixedLimit {
5911 offset: Decimal::from_str("5.0").unwrap(),
5912 offset_type: TrailingOffsetType::Percentage,
5913 activation_price: None,
5914 }),
5915 None
5916 );
5917 assert_eq!(
5919 map_order_kind(OrderKind::TrailingStopLimit {
5920 offset: Decimal::from_str("5.0").unwrap(),
5921 offset_type: TrailingOffsetType::Percentage,
5922 stop_price: Decimal::from_str("95.0").unwrap(),
5923 limit_offset: Decimal::from_str("1.0").unwrap(),
5924 }),
5925 None
5926 );
5927 assert_eq!(
5929 map_order_kind(OrderKind::TakeProfit {
5930 trigger_price: Decimal::from_str("160.00").unwrap()
5931 }),
5932 None
5933 );
5934 assert_eq!(
5935 map_order_kind(OrderKind::TakeProfitLimit {
5936 trigger_price: Decimal::from_str("160.00").unwrap()
5937 }),
5938 None
5939 );
5940 }
5941
5942 #[test]
5943 fn test_map_time_in_force_roundtrip() {
5944 assert_eq!(
5945 map_time_in_force(TimeInForce::GoodUntilCancelled { post_only: false }),
5946 Ok("gtc")
5947 );
5948 assert_eq!(map_time_in_force(TimeInForce::GoodUntilEndOfDay), Ok("day"));
5949 assert_eq!(map_time_in_force(TimeInForce::FillOrKill), Ok("fok"));
5950 assert_eq!(map_time_in_force(TimeInForce::ImmediateOrCancel), Ok("ioc"));
5951 }
5952
5953 #[test]
5954 fn test_map_time_in_force_rejects_post_only() {
5955 let result = map_time_in_force(TimeInForce::GoodUntilCancelled { post_only: true });
5956 assert!(result.is_err(), "post_only must be rejected");
5957 assert!(result.unwrap_err().contains("post_only"));
5958 }
5959
5960 #[test]
5965 fn test_bracket_order_serializes_with_stop_loss_stop_order() {
5966 let body = AlpacaOrderRequest {
5968 symbol: "AAPL",
5969 qty: "10".to_string(),
5970 side: "buy",
5971 order_type: "limit",
5972 time_in_force: "gtc",
5973 limit_price: Some("150.00".to_string()),
5974 stop_price: None,
5975 trail_percent: None,
5976 trail_price: None,
5977 client_order_id: Some("bracket-001"),
5978 position_intent: Some(AlpacaPositionIntent::BuyToOpen),
5979 order_class: Some("bracket"),
5980 take_profit: Some(TakeProfitParams {
5981 limit_price: "160.00".to_string(),
5982 }),
5983 stop_loss: Some(StopLossParams {
5984 stop_price: "145.00".to_string(),
5985 limit_price: None,
5986 }),
5987 };
5988
5989 let json = serde_json::to_value(&body).unwrap();
5990
5991 assert_eq!(json["symbol"], "AAPL");
5992 assert_eq!(json["qty"], "10");
5993 assert_eq!(json["side"], "buy");
5994 assert_eq!(json["type"], "limit");
5995 assert_eq!(json["time_in_force"], "gtc");
5996 assert_eq!(json["limit_price"], "150.00");
5997 assert_eq!(json["order_class"], "bracket");
5998
5999 assert_eq!(json["take_profit"]["limit_price"], "160.00");
6001
6002 assert_eq!(json["stop_loss"]["stop_price"], "145.00");
6004 assert!(
6005 json["stop_loss"].get("limit_price").is_none(),
6006 "stop_loss.limit_price should be omitted when None"
6007 );
6008 }
6009
6010 #[test]
6011 fn test_bracket_order_serializes_with_stop_loss_stop_limit_order() {
6012 let body = AlpacaOrderRequest {
6014 symbol: "SPY",
6015 qty: "5".to_string(),
6016 side: "sell",
6017 order_type: "limit",
6018 time_in_force: "day",
6019 limit_price: Some("450.00".to_string()),
6020 stop_price: None,
6021 trail_percent: None,
6022 trail_price: None,
6023 client_order_id: Some("bracket-002"),
6024 position_intent: Some(AlpacaPositionIntent::SellToClose),
6025 order_class: Some("bracket"),
6026 take_profit: Some(TakeProfitParams {
6027 limit_price: "440.00".to_string(),
6028 }),
6029 stop_loss: Some(StopLossParams {
6030 stop_price: "455.00".to_string(),
6031 limit_price: Some("456.00".to_string()),
6032 }),
6033 };
6034
6035 let json = serde_json::to_value(&body).unwrap();
6036
6037 assert_eq!(json["symbol"], "SPY");
6038 assert_eq!(json["side"], "sell");
6039 assert_eq!(json["time_in_force"], "day");
6040 assert_eq!(json["order_class"], "bracket");
6041
6042 assert_eq!(json["take_profit"]["limit_price"], "440.00");
6044
6045 assert_eq!(json["stop_loss"]["stop_price"], "455.00");
6047 assert_eq!(
6048 json["stop_loss"]["limit_price"], "456.00",
6049 "stop_loss.limit_price should be present for stop-limit orders"
6050 );
6051 }
6052
6053 #[tokio::test]
6058 async fn test_open_bracket_order_rejects_invalid_tif() {
6059 use rust_decimal_macros::dec;
6060 use rustrade_instrument::instrument::name::InstrumentNameExchange;
6061
6062 let config = AlpacaConfig::new("dummy_key".into(), "dummy_secret".into());
6064 let client = AlpacaClient::new(config);
6065
6066 let request = AlpacaBracketOrderRequest::new(
6067 InstrumentNameExchange::new("SPY"),
6068 crate::order::id::StrategyId::new("test"),
6069 crate::order::id::ClientOrderId::new("test-tif"),
6070 Side::Buy,
6071 dec!(1),
6072 dec!(100.00),
6073 dec!(120.00),
6074 dec!(90.00),
6075 TimeInForce::ImmediateOrCancel, );
6077
6078 let result = client.open_bracket_order(request).await;
6079
6080 assert!(
6081 result.parent.state.is_failed(),
6082 "Bracket order with IOC TIF should be rejected locally"
6083 );
6084 }
6085
6086 #[tokio::test]
6087 async fn test_open_bracket_order_rejects_invalid_price_ordering() {
6088 use rust_decimal_macros::dec;
6089 use rustrade_instrument::instrument::name::InstrumentNameExchange;
6090
6091 let config = AlpacaConfig::new("dummy_key".into(), "dummy_secret".into());
6092 let client = AlpacaClient::new(config);
6093
6094 let request = AlpacaBracketOrderRequest::new(
6096 InstrumentNameExchange::new("SPY"),
6097 crate::order::id::StrategyId::new("test"),
6098 crate::order::id::ClientOrderId::new("test-price"),
6099 Side::Buy,
6100 dec!(1),
6101 dec!(100.00),
6102 dec!(120.00),
6103 dec!(105.00), TimeInForce::GoodUntilCancelled { post_only: false },
6105 );
6106
6107 let result = client.open_bracket_order(request).await;
6108
6109 assert!(
6110 result.parent.state.is_failed(),
6111 "Bracket order with invalid price ordering should be rejected locally"
6112 );
6113 }
6114
6115 #[tokio::test]
6116 async fn test_open_bracket_order_rejects_invalid_sl_limit_price() {
6117 use rust_decimal_macros::dec;
6118 use rustrade_instrument::instrument::name::InstrumentNameExchange;
6119
6120 let config = AlpacaConfig::new("dummy_key".into(), "dummy_secret".into());
6121 let client = AlpacaClient::new(config);
6122
6123 let request = AlpacaBracketOrderRequest::new(
6125 InstrumentNameExchange::new("SPY"),
6126 crate::order::id::StrategyId::new("test"),
6127 crate::order::id::ClientOrderId::new("test-sl-limit"),
6128 Side::Buy,
6129 dec!(1),
6130 dec!(100.00),
6131 dec!(120.00),
6132 dec!(90.00),
6133 TimeInForce::GoodUntilCancelled { post_only: false },
6134 )
6135 .with_stop_loss_limit_price(dec!(95.00)); let result = client.open_bracket_order(request).await;
6138
6139 assert!(
6140 result.parent.state.is_failed(),
6141 "Bracket order with invalid SL limit price should be rejected locally"
6142 );
6143 }
6144
6145 #[test]
6146 fn test_non_bracket_order_omits_bracket_fields() {
6147 let body = AlpacaOrderRequest {
6149 symbol: "AAPL",
6150 qty: "1".to_string(),
6151 side: "buy",
6152 order_type: "limit",
6153 time_in_force: "gtc",
6154 limit_price: Some("150.00".to_string()),
6155 stop_price: None,
6156 trail_percent: None,
6157 trail_price: None,
6158 client_order_id: Some("regular-001"),
6159 position_intent: Some(AlpacaPositionIntent::BuyToOpen),
6160 order_class: None,
6161 take_profit: None,
6162 stop_loss: None,
6163 };
6164
6165 let json = serde_json::to_value(&body).unwrap();
6166
6167 assert_eq!(json["symbol"], "AAPL");
6168 assert!(
6169 json.get("order_class").is_none(),
6170 "order_class should be omitted for non-bracket orders"
6171 );
6172 assert!(
6173 json.get("take_profit").is_none(),
6174 "take_profit should be omitted for non-bracket orders"
6175 );
6176 assert!(
6177 json.get("stop_loss").is_none(),
6178 "stop_loss should be omitted for non-bracket orders"
6179 );
6180 }
6181
6182 #[test]
6183 fn test_parse_timestamp_valid() {
6184 let ts = parse_timestamp("2025-04-18T14:30:00Z");
6185 assert!(ts.is_some());
6186 let ts2 = parse_timestamp("2025-04-18T14:30:00.123456Z");
6187 assert!(ts2.is_some());
6188 assert_eq!(parse_timestamp("not-a-timestamp"), None);
6189 }
6190
6191 #[test]
6192 fn test_check_auth_response_authorized() {
6193 let msg =
6194 r#"{"stream":"authorization","data":{"status":"authorized","action":"authenticate"}}"#;
6195 assert!(matches!(check_auth_response(msg), Some(Ok(()))));
6196 }
6197
6198 #[test]
6199 fn test_check_auth_response_unauthorized() {
6200 let msg = r#"{"stream":"authorization","data":{"status":"unauthorized"}}"#;
6201 assert!(matches!(
6202 check_auth_response(msg),
6203 Some(Err(HandshakeError::Auth(_)))
6204 ));
6205 }
6206
6207 #[test]
6208 fn test_check_auth_response_non_auth_message() {
6209 let msg = r#"{"stream":"trade_updates","data":{}}"#;
6210 assert!(check_auth_response(msg).is_none());
6211 }
6212
6213 #[test]
6214 fn test_check_listen_ack() {
6215 let ack = r#"{"stream":"listening","data":{"streams":["trade_updates"]}}"#;
6216 assert!(check_listen_ack(ack));
6217
6218 let other = r#"{"stream":"authorization","data":{}}"#;
6219 assert!(!check_listen_ack(other));
6220 }
6221
6222 #[test]
6223 fn test_dedup_cache() {
6224 let cache = new_dedup_cache();
6225 let key = || SmolStr::new("order-1:1");
6226 assert!(
6227 !is_duplicate_fill(&cache, Some("exec-1"), key()),
6228 "first time should not be duplicate"
6229 );
6230 assert!(
6231 is_duplicate_fill(&cache, Some("exec-1"), key()),
6232 "second time should be duplicate"
6233 );
6234 }
6235
6236 #[test]
6239 fn dedup_tells_fills_apart_by_execution_id_not_cumulative() {
6240 let cache = new_dedup_cache();
6241 assert!(!is_duplicate_fill(
6242 &cache,
6243 Some("exec-a"),
6244 SmolStr::new("ord-1:5")
6245 ));
6246 assert!(
6248 !is_duplicate_fill(&cache, Some("exec-b"), SmolStr::new("ord-1:5")),
6249 "a different execution at a cumulative seen before is a new fill"
6250 );
6251 }
6252
6253 #[test]
6256 fn dedup_matches_a_fill_with_an_execution_id_to_the_same_fill_without_one() {
6257 let cache = new_dedup_cache();
6258
6259 assert!(!is_duplicate_fill(&cache, None, SmolStr::new("ord-1:2")));
6260 assert!(
6261 is_duplicate_fill(&cache, Some("exec-a"), SmolStr::new("ord-1:2")),
6262 "without an id first, then with one"
6263 );
6264
6265 assert!(!is_duplicate_fill(
6266 &cache,
6267 Some("exec-b"),
6268 SmolStr::new("ord-1:4")
6269 ));
6270 assert!(
6271 is_duplicate_fill(&cache, None, SmolStr::new("ord-1:4")),
6272 "with an id first, then without one"
6273 );
6274 }
6275
6276 #[tokio::test]
6277 async fn test_exponential_backoff_progression_and_exhaustion() {
6278 tokio::time::pause();
6279
6280 let mut b = ExponentialBackoff::new();
6281
6282 assert!(b.wait().await, "first wait should return true");
6284 assert_eq!(b.attempt, 1);
6285
6286 while b.wait().await {}
6288
6289 assert_eq!(b.attempt, MAX_RECONNECT_ATTEMPTS);
6291
6292 assert!(!b.wait().await, "exhausted backoff should return false");
6294
6295 b.reset();
6297 assert_eq!(b.attempt, 0);
6298
6299 assert!(b.wait().await, "wait should succeed after reset");
6301 assert_eq!(b.attempt, 1);
6302 }
6303
6304 #[test]
6305 fn test_convert_account_to_balances_empty_assets() {
6306 let account: AlpacaAccount = serde_json::from_value(serde_json::json!({
6309 "cash": "9000.50",
6310 "equity": "12000.00",
6311 "buying_power": "36000.00",
6312 "regt_buying_power": "18000.00",
6313 "options_buying_power": "8000.00",
6314 "non_marginable_buying_power": "8500.25",
6315 }))
6316 .unwrap();
6317 let balances = convert_account_to_balances(&account, &[]);
6318 assert_eq!(balances.len(), 1);
6319 assert_eq!(balances[0].asset, AssetNameExchange::new("USD"));
6320 assert_eq!(balances[0].balance.total, dec!(9000.50), "total is cash");
6321 assert_eq!(
6322 balances[0].balance.free,
6323 dec!(8500.25),
6324 "free is non-marginable buying power when below cash"
6325 );
6326 assert_eq!(balances[0].balance.margin, None);
6327 }
6328
6329 #[test]
6332 fn test_convert_account_to_balances_free_capped_at_cash_with_long_stock() {
6333 let account: AlpacaAccount = serde_json::from_value(serde_json::json!({
6334 "cash": "50000",
6335 "equity": "100000",
6336 "initial_margin": "25000",
6337 "non_marginable_buying_power": "75000",
6338 }))
6339 .unwrap();
6340 let balances = convert_account_to_balances(&account, &[]);
6341 assert_eq!(balances[0].balance.total, dec!(50000));
6342 assert_eq!(balances[0].balance.free, dec!(50000));
6343 }
6344
6345 #[test]
6348 fn test_convert_account_to_balances_open_short_keeps_free_below_cash() {
6349 let account: AlpacaAccount = serde_json::from_value(serde_json::json!({
6350 "cash": "10200",
6351 "equity": "10000",
6352 "initial_margin": "100",
6353 "non_marginable_buying_power": "9900",
6354 }))
6355 .unwrap();
6356 let balances = convert_account_to_balances(&account, &[]);
6357 assert_eq!(balances[0].balance.total, dec!(10200));
6358 assert_eq!(balances[0].balance.free, dec!(9900));
6359 }
6360
6361 #[test]
6362 fn test_convert_account_to_balances_negative_cash_on_margin() {
6363 let account: AlpacaAccount = serde_json::from_value(serde_json::json!({
6364 "cash": "-2500.75",
6365 "equity": "7500.00",
6366 "buying_power": "5000.00",
6367 "non_marginable_buying_power": "1250.00",
6368 }))
6369 .unwrap();
6370 let balances = convert_account_to_balances(&account, &[]);
6371 assert_eq!(balances.len(), 1);
6372 assert_eq!(balances[0].balance.total, dec!(-2500.75));
6373 assert_eq!(
6374 balances[0].balance.free,
6375 dec!(-2500.75),
6376 "no free cash while borrowing; free <= total still holds"
6377 );
6378 }
6379
6380 #[test]
6381 fn test_alpaca_account_malformed_cash_fails_to_decode() {
6382 let result = serde_json::from_value::<AlpacaAccount>(serde_json::json!({
6383 "cash": "not-a-number",
6384 "non_marginable_buying_power": "0",
6385 }));
6386 assert!(result.is_err(), "a malformed cash must not read as zero");
6387 }
6388
6389 #[test]
6390 fn test_convert_account_to_balances_usd_filter() {
6391 let account = AlpacaAccount {
6392 cash: dec!(12000.00),
6393 non_marginable_buying_power: dec!(10000.00),
6394 };
6395 let usd = vec![AssetNameExchange::new("USD")];
6396 let balances = convert_account_to_balances(&account, &usd);
6397 assert_eq!(balances.len(), 1);
6398 assert_eq!(balances[0].asset, AssetNameExchange::new("USD"));
6399
6400 let non_usd = vec![AssetNameExchange::new("BTC")];
6401 let balances = convert_account_to_balances(&account, &non_usd);
6402 assert!(balances.is_empty());
6403 }
6404
6405 #[test]
6408 fn a_usd_balance_is_named_usd_however_the_filter_spells_it() {
6409 let account = AlpacaAccount {
6410 cash: dec!(12000.00),
6411 non_marginable_buying_power: dec!(10000.00),
6412 };
6413 let balances = convert_account_to_balances(&account, &[AssetNameExchange::new("usd")]);
6414 assert_eq!(balances.len(), 1);
6415 assert_eq!(balances[0].asset, AssetNameExchange::new("USD"));
6416 }
6417
6418 #[test]
6419 fn test_is_options_or_equity_symbol() {
6420 assert!(!is_options_or_equity_symbol("BTC/USD"));
6422 assert!(!is_options_or_equity_symbol("ETH/USD"));
6423 assert!(!is_options_or_equity_symbol("SOL/USD"));
6424
6425 assert!(is_options_or_equity_symbol("AAPL"));
6427 assert!(is_options_or_equity_symbol("SPY"));
6428 assert!(is_options_or_equity_symbol("MSFT"));
6429
6430 assert!(is_options_or_equity_symbol("SPY250418C00450000"));
6432 assert!(is_options_or_equity_symbol("AAPL250418P00145000"));
6433 }
6434
6435 #[test]
6436 fn test_parse_order_error_already_cancelled() {
6437 assert!(matches!(
6440 parse_order_error(
6441 reqwest::StatusCode::UNPROCESSABLE_ENTITY,
6442 None,
6443 "order is already cancelled"
6444 ),
6445 UnindexedOrderError::Rejected(ApiError::OrderAlreadyCancelled)
6446 ));
6447 }
6448
6449 #[test]
6450 fn test_parse_order_error_already_wins_over_insufficient_on_422() {
6451 assert!(matches!(
6454 parse_order_error(
6455 reqwest::StatusCode::UNPROCESSABLE_ENTITY,
6456 None,
6457 "order already cancelled due to insufficient margin"
6458 ),
6459 UnindexedOrderError::Rejected(ApiError::OrderAlreadyCancelled)
6460 ));
6461 }
6462
6463 fn make_order_ws<'a>(
6464 id: &str,
6465 symbol: &str,
6466 side: &str,
6467 filled_qty: &'a str,
6468 ) -> AlpacaOrderWs<'a> {
6469 AlpacaOrderWs {
6470 id: SmolStr::new(id),
6471 client_order_id: None,
6472 symbol: SmolStr::new(symbol),
6473 qty: Some("2"),
6474 filled_qty: Some(filled_qty),
6475 side: SmolStr::new(side),
6476 order_type: SmolStr::new("limit"),
6477 time_in_force: SmolStr::new("day"),
6478 limit_price: Some("100.00"),
6479 stop_price: None,
6480 trail_percent: None,
6481 trail_price: None,
6482 status: SmolStr::new("partially_filled"),
6483 }
6484 }
6485
6486 fn sole_event(events: [Option<UnindexedAccountEvent>; 2]) -> Option<UnindexedAccountEvent> {
6491 let [first, second] = events;
6492 assert!(
6493 second.is_none(),
6494 "expected a single event, got a second: {second:?}"
6495 );
6496 first
6497 }
6498
6499 fn fill_events(
6501 events: [Option<UnindexedAccountEvent>; 2],
6502 ) -> (
6503 Trade<AssetNameExchange, InstrumentNameExchange>,
6504 crate::order::Order<
6505 ExchangeId,
6506 InstrumentNameExchange,
6507 OrderState<AssetNameExchange, InstrumentNameExchange>,
6508 >,
6509 ) {
6510 let [first, second] = events;
6511 let first = first.expect("a fill frame must produce an execution");
6512 let second = second.expect("a fill frame must produce an order snapshot");
6513 let AccountEventKind::Trade(trade) = first.kind else {
6514 panic!("first event must be the Trade, got {:?}", first.kind);
6515 };
6516 let AccountEventKind::OrderSnapshot(rustrade_integration::collection::snapshot::Snapshot(
6517 order,
6518 )) = second.kind
6519 else {
6520 panic!(
6521 "second event must be the OrderSnapshot, got {:?}",
6522 second.kind
6523 );
6524 };
6525 (trade, order)
6526 }
6527
6528 #[test]
6531 fn test_convert_trade_update_fill_carries_the_client_order_id() {
6532 let mut order = make_order_ws("ord-1", "SPY", "buy", "1");
6533 order.client_order_id = Some(SmolStr::new("outside-1"));
6534 let update = AlpacaTradeUpdate {
6535 event: SmolStr::new("fill"),
6536 execution_id: Some("exec-1"),
6537 previous_execution_id: None,
6538 order,
6539 price: Some("150.00"),
6540 qty: Some("1"),
6541 timestamp: Some("2025-04-18T14:30:00Z"),
6542 };
6543 let (trade, _order) = fill_events(convert_trade_update(update));
6544 assert_eq!(trade.client_order_id, Some(ClientOrderId::new("outside-1")));
6545 }
6546
6547 #[test]
6548 fn test_convert_trade_update_fill_produces_trade_with_execution_id() {
6549 let update = AlpacaTradeUpdate {
6550 event: SmolStr::new("fill"),
6551 execution_id: Some("exec-1"),
6552 previous_execution_id: None,
6553 order: make_order_ws("ord-1", "SPY", "buy", "1"),
6554 price: Some("150.00"),
6555 qty: Some("1"),
6556 timestamp: Some("2025-04-18T14:30:00Z"),
6557 };
6558 let (trade, order) = fill_events(convert_trade_update(update));
6559 assert_eq!(trade.id.0.as_str(), "exec-1");
6561 assert_eq!(
6562 trade.client_order_id, None,
6563 "the update names no client order id"
6564 );
6565 assert_eq!(trade.price, Decimal::from_str("150.00").unwrap());
6566 assert_eq!(trade.quantity, Decimal::from_str("1").unwrap());
6567
6568 let OrderState::Active(crate::order::state::ActiveOrderState::Open(open)) = order.state
6571 else {
6572 panic!("expected an Open snapshot, got {:?}", order.state);
6573 };
6574 assert_eq!(open.filled_quantity, Decimal::from_str("1").unwrap());
6575 assert_eq!(open.id.assigned().map(|id| id.0.as_str()), Some("ord-1"));
6576 }
6577
6578 #[test]
6579 fn test_convert_trade_update_partial_fill() {
6580 let update = AlpacaTradeUpdate {
6581 event: SmolStr::new("partial_fill"),
6582 execution_id: None,
6583 previous_execution_id: None,
6584 order: make_order_ws("ord-2", "AAPL", "sell", "0.5"),
6585 price: Some("200.00"),
6586 qty: Some("0.5"),
6587 timestamp: None,
6588 };
6589 let (trade, order) = fill_events(convert_trade_update(update));
6590 assert_eq!(trade.quantity, Decimal::from_str("0.5").unwrap());
6591
6592 let OrderState::Active(crate::order::state::ActiveOrderState::Open(open)) = order.state
6595 else {
6596 panic!("expected an Open snapshot, got {:?}", order.state);
6597 };
6598 assert_eq!(open.filled_quantity, Decimal::from_str("0.5").unwrap());
6599 assert_eq!(
6600 open.quantity_remaining(order.quantity),
6601 Decimal::from_str("1.5").unwrap()
6602 );
6603 }
6604
6605 #[test]
6609 fn a_full_fill_snapshot_reports_nothing_left_to_fill() {
6610 let update = AlpacaTradeUpdate {
6611 event: SmolStr::new("fill"),
6612 execution_id: None,
6613 previous_execution_id: None,
6614 order: AlpacaOrderWs {
6616 status: SmolStr::new("filled"),
6617 ..make_order_ws("ord-full", "SPY", "buy", "2")
6618 },
6619 price: Some("150.00"),
6620 qty: Some("1"),
6621 timestamp: Some("2025-04-18T14:30:00Z"),
6622 };
6623 let (_trade, order) = fill_events(convert_trade_update(update));
6624 let OrderState::Active(crate::order::state::ActiveOrderState::Open(open)) = order.state
6625 else {
6626 panic!("expected an Open snapshot, got {:?}", order.state);
6627 };
6628 assert_eq!(open.filled_quantity, Decimal::from_str("2").unwrap());
6629 assert!(
6630 open.quantity_remaining(order.quantity).is_zero(),
6631 "a completed order must report nothing left to fill so the engine retires it"
6632 );
6633 }
6634
6635 #[test]
6639 fn a_fill_for_an_order_no_longer_working_emits_the_execution_without_a_snapshot() {
6640 for status in [
6641 "canceled",
6642 "expired",
6643 "rejected",
6644 "done_for_day",
6645 "pending_cancel",
6646 ] {
6647 let update = AlpacaTradeUpdate {
6648 event: SmolStr::new("partial_fill"),
6649 execution_id: None,
6650 previous_execution_id: None,
6651 order: AlpacaOrderWs {
6652 status: SmolStr::new(status),
6653 ..make_order_ws("ord-late", "SPY", "buy", "1")
6654 },
6655 price: Some("150.00"),
6656 qty: Some("1"),
6657 timestamp: None,
6658 };
6659 let [trade, snapshot] = convert_trade_update(update);
6660 assert!(
6661 matches!(trade.map(|e| e.kind), Some(AccountEventKind::Trade(_))),
6662 "the execution must still be reported for status {status}"
6663 );
6664 assert!(
6665 snapshot.is_none(),
6666 "status {status} must not produce an order snapshot"
6667 );
6668 }
6669 }
6670
6671 #[test]
6674 fn a_notional_order_fill_emits_the_execution_without_a_snapshot() {
6675 let update = AlpacaTradeUpdate {
6676 event: SmolStr::new("partial_fill"),
6677 execution_id: None,
6678 previous_execution_id: None,
6679 order: AlpacaOrderWs {
6680 qty: None,
6681 ..make_order_ws("ord-notional-ws", "SPY", "buy", "1")
6682 },
6683 price: Some("150.00"),
6684 qty: Some("1"),
6685 timestamp: None,
6686 };
6687 let [trade, snapshot] = convert_trade_update(update);
6688 assert!(matches!(
6689 trade.map(|e| e.kind),
6690 Some(AccountEventKind::Trade(_))
6691 ));
6692 assert!(
6693 snapshot.is_none(),
6694 "a notional order has no quantity to snapshot against"
6695 );
6696 }
6697
6698 #[test]
6699 fn test_convert_trade_update_new_order_produces_snapshot() {
6700 let update = AlpacaTradeUpdate {
6701 event: SmolStr::new("new"),
6702 execution_id: None,
6703 previous_execution_id: None,
6704 order: AlpacaOrderWs {
6705 id: SmolStr::new("ord-new"),
6706 client_order_id: Some(SmolStr::new("cid-1")),
6707 symbol: SmolStr::new("AAPL"),
6708 qty: Some("10"),
6709 filled_qty: Some("0"),
6710 side: SmolStr::new("buy"),
6711 order_type: SmolStr::new("limit"),
6712 time_in_force: SmolStr::new("day"),
6713 limit_price: Some("150.00"),
6714 stop_price: None,
6715 trail_percent: None,
6716 trail_price: None,
6717 status: SmolStr::new("new"),
6718 },
6719 price: None,
6720 qty: None,
6721 timestamp: Some("2025-04-18T14:30:00Z"),
6722 };
6723 let event =
6724 sole_event(convert_trade_update(update)).expect("new event should produce an event");
6725 assert!(matches!(event.kind, AccountEventKind::OrderSnapshot(_)));
6726 }
6727
6728 #[test]
6729 fn test_convert_trade_update_canceled_produces_cancel() {
6730 let update = AlpacaTradeUpdate {
6731 event: SmolStr::new("canceled"),
6732 execution_id: None,
6733 previous_execution_id: None,
6734 order: make_order_ws("ord-3", "AAPL", "sell", "0"),
6735 price: None,
6736 qty: None,
6737 timestamp: Some("2025-04-18T14:30:00Z"),
6738 };
6739 let event =
6740 sole_event(convert_trade_update(update)).expect("canceled should produce an event");
6741 let AccountEventKind::OrderCancelled(response) = event.kind else {
6742 panic!("expected OrderCancelled, got {:?}", event.kind);
6743 };
6744 assert!(response.state.is_ok());
6745 }
6746
6747 #[test]
6749 fn a_canceled_update_carries_its_fill_or_none() {
6750 let cancelled = |filled_qty: Option<&'static str>| {
6751 let mut order = make_order_ws("ord-3", "AAPL", "sell", "0");
6752 order.filled_qty = filled_qty;
6753 let update = AlpacaTradeUpdate {
6754 event: SmolStr::new("canceled"),
6755 execution_id: None,
6756 previous_execution_id: None,
6757 order,
6758 price: None,
6759 qty: None,
6760 timestamp: Some("2025-04-18T14:30:00Z"),
6761 };
6762 let event =
6763 sole_event(convert_trade_update(update)).expect("canceled should produce an event");
6764 let AccountEventKind::OrderCancelled(response) = event.kind else {
6765 panic!("expected OrderCancelled, got {:?}", event.kind);
6766 };
6767 let Ok(cancelled) = response.state else {
6768 panic!("expected Ok, got {:?}", response.state);
6769 };
6770 cancelled.filled_quantity
6771 };
6772
6773 assert_eq!(cancelled(Some("1")), Some(Decimal::ONE));
6774 assert_eq!(cancelled(Some("0")), Some(Decimal::ZERO));
6775 assert_eq!(cancelled(None), None);
6776 assert_eq!(cancelled(Some("not a number")), None);
6777 }
6778
6779 #[test]
6782 fn an_ended_order_with_an_unparseable_fill_reports_it_unknown() {
6783 let key = OrderKey::new(
6784 ExchangeId::AlpacaBroker,
6785 InstrumentNameExchange::new("AAPL"),
6786 StrategyId::new("strategy"),
6787 ClientOrderId::new("c"),
6788 );
6789 let ended = |status: &str| {
6790 let mut order = make_order_response("o1", "AAPL");
6791 order.status = Some(status.to_string());
6792 order.filled_qty = "not a number".to_string();
6793 convert_ended_order(&order, &key).map(|order| order.state)
6794 };
6795
6796 assert!(matches!(
6797 ended("canceled"),
6798 Some(InactiveOrderState::Cancelled(Cancelled {
6799 filled_quantity: None,
6800 ..
6801 }))
6802 ));
6803 assert!(matches!(
6804 ended("expired"),
6805 Some(InactiveOrderState::Expired(Expired {
6806 filled_quantity: None,
6807 ..
6808 }))
6809 ));
6810 assert!(matches!(
6811 ended("filled"),
6812 Some(InactiveOrderState::FullyFilled(Filled { filled_quantity, .. }))
6813 if filled_quantity == Decimal::ONE
6814 ));
6815 }
6816
6817 #[test]
6818 fn test_convert_trade_update_rejected_produces_error() {
6819 let update = AlpacaTradeUpdate {
6820 event: SmolStr::new("rejected"),
6821 execution_id: None,
6822 previous_execution_id: None,
6823 order: make_order_ws("ord-4", "SPY", "buy", "0"),
6824 price: None,
6825 qty: None,
6826 timestamp: None,
6827 };
6828 let event =
6829 sole_event(convert_trade_update(update)).expect("rejected should produce an event");
6830 let AccountEventKind::OrderCancelled(response) = event.kind else {
6831 panic!("expected OrderCancelled, got {:?}", event.kind);
6832 };
6833 assert!(response.state.is_err());
6834 }
6835
6836 #[test]
6837 fn test_convert_open_order_notional_qty_none_is_skipped() {
6838 let order = AlpacaOrderResponse {
6841 id: "ord-notional".to_string(),
6842 client_order_id: None,
6843 symbol: "SPY".to_string(),
6844 qty: None,
6845 filled_qty: "0".to_string(),
6846 filled_avg_price: None,
6847 status: None,
6848 side: "buy".to_string(),
6849 order_type: "market".to_string(),
6850 time_in_force: "day".to_string(),
6851 limit_price: None,
6852 stop_price: None,
6853 trail_percent: None,
6854 trail_price: None,
6855 created_at: "2025-04-18T14:30:00Z".to_string(),
6856 updated_at: None,
6857 };
6858 assert!(convert_open_order(&order).is_none());
6859 }
6860
6861 #[test]
6862 fn test_convert_activity_to_trade_bad_price_returns_none() {
6863 let activity = AlpacaActivity {
6867 id: "act-1".to_string(),
6868 order_id: "ord-1".to_string(),
6869 symbol: "SPY250418C00450000".to_string(),
6870 side: "buy".to_string(),
6871 price: "not-a-number".to_string(),
6872 qty: "1".to_string(),
6873 transaction_time: "2025-04-18T14:30:00Z".to_string(),
6874 cum_qty: Some("1".to_string()),
6875 };
6876 assert!(convert_activity_to_trade(&activity).is_none());
6877 }
6878
6879 #[test]
6880 fn test_convert_positions_to_balances_crypto() {
6881 let positions = vec![
6882 AlpacaPosition {
6884 qty_available: dec!(0.4),
6885 ..alpaca_position("BTCUSD", "crypto", AlpacaPositionSide::Long, dec!(0.5))
6886 },
6887 alpaca_position("ETH/USD", "crypto", AlpacaPositionSide::Long, dec!(2.0)),
6889 alpaca_position("AAPL", "us_equity", AlpacaPositionSide::Long, dec!(10)),
6891 ];
6892
6893 let balances = convert_positions_to_balances(&positions, &[]);
6895 assert_eq!(balances.len(), 2, "only crypto positions returned");
6896 assert_eq!(balances[0].asset.name().as_str(), "BTC");
6897 assert_eq!(balances[1].asset.name().as_str(), "ETH");
6898 assert_eq!(balances[0].balance.total, dec!(0.5));
6900 assert_eq!(balances[0].balance.free, dec!(0.4));
6901
6902 let btc_only = vec![AssetNameExchange::new("btc")];
6904 let balances = convert_positions_to_balances(&positions, &btc_only);
6905 assert_eq!(balances.len(), 1);
6906 assert_eq!(balances[0].asset.name().as_str(), "BTC");
6907 }
6908
6909 #[test]
6910 fn test_crypto_position_asset() {
6911 assert_eq!(crypto_position_asset("BTCUSD"), "BTC");
6912 assert_eq!(
6913 crypto_position_asset("btcusd"),
6914 "btc",
6915 "suffix matched ignoring case"
6916 );
6917 assert_eq!(
6918 crypto_position_asset("USDTUSD"),
6919 "USDT",
6920 "only one USD is stripped"
6921 );
6922 assert_eq!(crypto_position_asset("BTC/USD"), "BTC");
6923 assert_eq!(crypto_position_asset("BTC/USDT"), "BTC");
6924 assert_eq!(crypto_position_asset("€USD"), "€");
6925 assert_eq!(crypto_position_asset("USD"), "USD");
6927 assert_eq!(crypto_position_asset("/USD"), "/USD");
6928 assert_eq!(crypto_position_asset("BTCUSDT"), "BTCUSDT");
6929 assert_eq!(
6930 crypto_position_asset("BTCێ"),
6931 "BTCێ",
6932 "no char boundary at the suffix"
6933 );
6934 assert_eq!(crypto_position_asset(""), "");
6935 }
6936
6937 fn alpaca_position(
6939 symbol: &str,
6940 asset_class: &str,
6941 side: AlpacaPositionSide,
6942 qty: Decimal,
6943 ) -> AlpacaPosition {
6944 AlpacaPosition {
6945 symbol: symbol.into(),
6946 asset_class: asset_class.into(),
6947 side,
6948 qty,
6949 qty_available: qty,
6950 avg_entry_price: Some(dec!(100)),
6951 unrealized_pl: Some(dec!(0)),
6952 }
6953 }
6954
6955 #[test]
6956 fn test_convert_positions_sign_comes_from_side_not_qty() {
6957 let now = Utc::now();
6958 let positions = vec![
6959 alpaca_position("AAPL", "us_equity", AlpacaPositionSide::Long, dec!(10)),
6960 alpaca_position("TSLA", "us_equity", AlpacaPositionSide::Short, dec!(-3)),
6961 alpaca_position("MSFT", "us_equity", AlpacaPositionSide::Short, dec!(4)),
6963 ];
6964
6965 let converted = convert_positions(&positions, now).unwrap();
6966 let quantities: Vec<_> = converted.iter().map(|(s, p)| (*s, p.quantity)).collect();
6967 assert_eq!(
6968 quantities,
6969 vec![("AAPL", dec!(10)), ("TSLA", dec!(-3)), ("MSFT", dec!(-4))]
6970 );
6971 assert!(converted.iter().all(|(_, p)| p.time_exchange == now));
6972 }
6973
6974 #[test]
6975 fn test_convert_positions_leaves_out_crypto_and_flat() {
6976 let positions = vec![
6977 alpaca_position("BTC/USD", "crypto", AlpacaPositionSide::Long, dec!(0.5)),
6978 alpaca_position("AAPL", "us_equity", AlpacaPositionSide::Long, dec!(0)),
6979 alpaca_position("SPY", "us_equity", AlpacaPositionSide::Long, dec!(1)),
6980 ];
6981
6982 let converted = convert_positions(&positions, Utc::now()).unwrap();
6983 let symbols: Vec<_> = converted.iter().map(|(s, _)| *s).collect();
6984 assert_eq!(symbols, vec!["SPY"]);
6985 }
6986
6987 #[test]
6988 fn test_alpaca_position_decodes_equity_option_and_fractional() {
6989 let positions: Vec<AlpacaPosition> = serde_json::from_value(serde_json::json!([
6990 {
6991 "asset_id": "00000000-0000-0000-0000-000000000001",
6992 "symbol": "XYZ",
6993 "exchange": "NASDAQ",
6994 "asset_class": "us_equity",
6995 "qty": "-7",
6996 "qty_available": "-7",
6997 "side": "short",
6998 "avg_entry_price": "50.25",
6999 "market_value": "-351.75",
7000 "unrealized_pl": "0",
7001 },
7002 {
7003 "symbol": "XYZ271217C00055000",
7004 "asset_class": "us_option",
7005 "qty": "2",
7006 "qty_available": "2",
7007 "side": "long",
7008 "avg_entry_price": "1.35",
7009 "unrealized_pl": "-20",
7010 },
7011 {
7012 "symbol": "ABC",
7013 "asset_class": "us_equity",
7014 "qty": "0.125",
7015 "qty_available": "0.125",
7016 "side": "long",
7017 "avg_entry_price": "80",
7018 "unrealized_pl": null,
7019 },
7020 ]))
7021 .unwrap();
7022
7023 let converted = convert_positions(&positions, Utc::now()).unwrap();
7024 let [
7025 (short_sym, short),
7026 (option_sym, option),
7027 (fraction_sym, fraction),
7028 ] = converted.as_slice()
7029 else {
7030 panic!("expected three positions, got {converted:?}");
7031 };
7032
7033 assert_eq!(*short_sym, "XYZ");
7034 assert_eq!(short.quantity, dec!(-7));
7035 assert_eq!(short.entry_price, Some(dec!(50.25)));
7036 assert!(short.is_short());
7037
7038 assert_eq!(*option_sym, "XYZ271217C00055000");
7039 assert_eq!(option.quantity, dec!(2));
7040 assert_eq!(option.entry_price, Some(dec!(1.35)), "per-share premium");
7041 assert_eq!(option.unrealized_pnl, Some(dec!(-20)));
7042
7043 assert_eq!(*fraction_sym, "ABC");
7044 assert_eq!(fraction.quantity, dec!(0.125));
7045 assert_eq!(fraction.unrealized_pnl, None);
7046
7047 for (_, p) in &converted {
7048 assert_eq!(p.margin_used, None);
7049 assert_eq!(p.liquidation_price, None);
7050 assert_eq!(p.leverage, None);
7051 }
7052 }
7053
7054 #[test]
7057 fn test_alpaca_position_unknown_side_fails_only_where_a_direction_is_needed() {
7058 let positions: Vec<AlpacaPosition> = serde_json::from_value(serde_json::json!([
7059 {
7060 "symbol": "BTC/USD",
7061 "asset_class": "crypto",
7062 "qty": "0.5",
7063 "qty_available": "0.5",
7064 },
7065 {
7066 "symbol": "XYZ",
7067 "asset_class": "us_equity",
7068 "qty": "1",
7069 "qty_available": "1",
7070 "side": "sideways",
7071 },
7072 ]))
7073 .unwrap();
7074 assert_eq!(positions[0].side, AlpacaPositionSide::Unknown);
7075 assert_eq!(positions[1].side, AlpacaPositionSide::Unknown);
7076
7077 let balances = convert_positions_to_balances(&positions, &[]);
7078 assert_eq!(balances.len(), 1);
7079 assert_eq!(balances[0].balance.total, dec!(0.5));
7080
7081 let result = convert_positions(&positions, Utc::now());
7082 assert!(
7083 matches!(&result, Err(UnindexedClientError::Internal(msg)) if msg.contains("XYZ")),
7084 "an unknown side must not guess a direction: {result:?}"
7085 );
7086 }
7087
7088 #[test]
7090 fn order_state_time_prefers_updated_at() {
7091 let mut resp = make_order_response("ord-1", "SPY");
7092 resp.created_at = "2025-04-18T14:30:00Z".to_string();
7093 resp.updated_at = Some("2025-04-18T15:45:00Z".to_string());
7094 assert_eq!(
7095 order_state_time(&resp),
7096 parse_timestamp("2025-04-18T15:45:00Z").unwrap(),
7097 "updated_at must win over created_at"
7098 );
7099
7100 resp.updated_at = None;
7102 assert_eq!(
7103 order_state_time(&resp),
7104 parse_timestamp("2025-04-18T14:30:00Z").unwrap(),
7105 "absent updated_at falls back to created_at"
7106 );
7107
7108 resp.updated_at = Some("not-a-timestamp".to_string());
7111 assert_eq!(
7112 order_state_time(&resp),
7113 parse_timestamp("2025-04-18T14:30:00Z").unwrap(),
7114 "unparseable updated_at falls back to created_at"
7115 );
7116 }
7117
7118 #[test]
7121 fn a_placement_response_without_a_status_reads_from_what_filled() {
7122 let instrument = InstrumentNameExchange::new("SPY");
7123 let mut resp = make_order_response("ord-1", "SPY");
7124 resp.filled_qty = "0.4".to_string();
7125 let OrderState::Active(ActiveOrderState::Open(open)) =
7126 placed_order_state(&resp, &instrument, Decimal::ONE)
7127 else {
7128 panic!("expected Open");
7129 };
7130 assert_eq!(open.filled_quantity, Decimal::new(4, 1));
7131
7132 resp.filled_qty = "1".to_string();
7133 resp.filled_avg_price = Some("100.5".to_string());
7134 let state = placed_order_state(&resp, &instrument, Decimal::ONE);
7135 let OrderState::Inactive(InactiveOrderState::FullyFilled(filled)) = state else {
7136 panic!("expected FullyFilled, got {state:?}");
7137 };
7138 assert_eq!(filled.filled_quantity, Decimal::ONE);
7139 assert_eq!(filled.avg_price, Some(Decimal::new(1005, 1)));
7140 }
7141
7142 fn make_order_response(id: &str, symbol: &str) -> AlpacaOrderResponse {
7143 AlpacaOrderResponse {
7144 id: id.to_string(),
7145 client_order_id: None,
7146 symbol: symbol.to_string(),
7147 qty: Some("1".to_string()),
7148 filled_qty: "0".to_string(),
7149 filled_avg_price: None,
7150 status: None,
7151 side: "buy".to_string(),
7152 order_type: "limit".to_string(),
7153 time_in_force: "day".to_string(),
7154 limit_price: Some("100.00".to_string()),
7155 stop_price: None,
7156 trail_percent: None,
7157 trail_price: None,
7158 created_at: "2025-04-18T14:30:00Z".to_string(),
7159 updated_at: None,
7160 }
7161 }
7162
7163 #[test]
7164 fn test_build_instrument_snapshots_empty_instruments_returns_only_with_orders() {
7165 let orders = vec![
7166 make_order_response("o1", "AAPL"),
7167 make_order_response("o2", "SPY"),
7168 ];
7169 let snapshots = build_instrument_snapshots(orders, Vec::new(), &[]);
7170 assert_eq!(snapshots.len(), 2);
7171 let symbols: Vec<&str> = snapshots
7172 .iter()
7173 .map(|s| s.instrument.name().as_str())
7174 .collect();
7175 assert!(symbols.contains(&"AAPL"));
7176 assert!(symbols.contains(&"SPY"));
7177 }
7178
7179 #[test]
7180 fn test_build_instrument_snapshots_requested_instrument_no_orders_gets_empty_snapshot() {
7181 let orders = vec![make_order_response("o1", "AAPL")];
7184 let instruments = vec![
7185 InstrumentNameExchange::new("AAPL"),
7186 InstrumentNameExchange::new("SPY"),
7187 ];
7188 let snapshots = build_instrument_snapshots(orders, Vec::new(), &instruments);
7189 assert_eq!(snapshots.len(), 2);
7190 let spy = snapshots
7191 .iter()
7192 .find(|s| s.instrument.name().as_str() == "SPY")
7193 .expect("SPY snapshot must be present even with no orders");
7194 assert!(spy.orders.is_empty());
7195 }
7196
7197 fn make_notional_order_response(id: &str, symbol: &str) -> AlpacaOrderResponse {
7199 AlpacaOrderResponse {
7200 qty: None,
7201 ..make_order_response(id, symbol)
7202 }
7203 }
7204
7205 fn snapshot_for<'a>(
7206 snapshots: &'a [InstrumentAccountSnapshot<
7207 ExchangeId,
7208 AssetNameExchange,
7209 InstrumentNameExchange,
7210 >],
7211 symbol: &str,
7212 ) -> &'a InstrumentAccountSnapshot<ExchangeId, AssetNameExchange, InstrumentNameExchange> {
7213 snapshots
7214 .iter()
7215 .find(|snapshot| snapshot.instrument.name().as_str() == symbol)
7216 .unwrap_or_else(|| panic!("no snapshot for {symbol}"))
7217 }
7218
7219 #[test]
7223 fn test_build_instrument_snapshots_declares_complete_lists_complete() {
7224 let instruments = [
7225 InstrumentNameExchange::new("AAPL"),
7226 InstrumentNameExchange::new("SPY"),
7227 ];
7228 for requested in [&instruments[..], &[]] {
7229 let orders = vec![make_order_response("o1", "AAPL")];
7230 let snapshots = build_instrument_snapshots(orders, Vec::new(), requested);
7231 assert!(!snapshots.is_empty());
7232 assert!(snapshots.iter().all(|snapshot| snapshot.orders_complete));
7233 }
7234 }
7235
7236 #[test]
7240 fn test_build_instrument_snapshots_notional_order_leaves_only_its_instrument_incomplete() {
7241 let orders = vec![
7242 make_order_response("o1", "AAPL"),
7243 make_order_response("o2", "SPY"),
7244 make_notional_order_response("o3", "SPY"),
7245 ];
7246 let instruments = [
7247 InstrumentNameExchange::new("AAPL"),
7248 InstrumentNameExchange::new("SPY"),
7249 ];
7250 let snapshots = build_instrument_snapshots(orders, Vec::new(), &instruments);
7251
7252 assert!(snapshot_for(&snapshots, "AAPL").orders_complete);
7253 let spy = snapshot_for(&snapshots, "SPY");
7254 assert!(!spy.orders_complete);
7255 assert_eq!(spy.orders.len(), 1);
7256 }
7257
7258 #[test]
7261 fn test_build_instrument_snapshots_unfiltered_lists_an_instrument_with_only_a_notional_order() {
7262 let orders = vec![make_notional_order_response("o1", "SPY")];
7263 let snapshots = build_instrument_snapshots(orders, Vec::new(), &[]);
7264
7265 let spy = snapshot_for(&snapshots, "SPY");
7266 assert!(!spy.orders_complete);
7267 assert!(spy.orders.is_empty());
7268 }
7269
7270 #[test]
7271 fn test_build_instrument_snapshots_non_requested_instrument_excluded() {
7272 let orders = vec![
7275 make_order_response("o1", "AAPL"),
7276 make_order_response("o2", "MSFT"), ];
7278 let instruments = vec![InstrumentNameExchange::new("AAPL")];
7279 let snapshots = build_instrument_snapshots(orders, Vec::new(), &instruments);
7280 assert_eq!(snapshots.len(), 1);
7281 assert_eq!(snapshots[0].instrument.name().as_str(), "AAPL");
7282 }
7283
7284 fn position(quantity: Decimal) -> Position {
7285 Position::new(
7286 quantity,
7287 Some(dec!(100)),
7288 None,
7289 None,
7290 None,
7291 None,
7292 None,
7293 Utc::now(),
7294 )
7295 }
7296
7297 #[test]
7298 fn test_build_instrument_snapshots_unfiltered_lists_positions_without_orders() {
7299 let orders = vec![
7300 make_order_response("o1", "AAPL"),
7301 make_order_response("o2", "MSFT"),
7302 make_order_response("o3", "BTC/USD"),
7303 ];
7304 let positions = vec![("AAPL", position(dec!(5))), ("TSLA", position(dec!(-2)))];
7305
7306 let snapshots = build_instrument_snapshots(orders, positions, &[]);
7307 let listed: Vec<_> = snapshots
7308 .iter()
7309 .map(|s| {
7310 (
7311 s.instrument.name().as_str(),
7312 s.orders.len(),
7313 s.position.quantity(),
7314 )
7315 })
7316 .collect();
7317 assert_eq!(
7319 listed,
7320 vec![
7321 ("AAPL", 1, Some(dec!(5))),
7322 ("MSFT", 1, Some(Decimal::ZERO)),
7323 ("BTC/USD", 1, None),
7324 ("TSLA", 0, Some(dec!(-2)))
7325 ]
7326 );
7327 assert!(snapshots.iter().all(|s| s.orders_complete));
7328 }
7329
7330 #[test]
7331 fn test_build_instrument_snapshots_filtered_attaches_only_requested_positions() {
7332 let positions = vec![("AAPL", position(dec!(5))), ("TSLA", position(dec!(-2)))];
7333 let instruments = vec![
7334 InstrumentNameExchange::new("tsla"),
7335 InstrumentNameExchange::new("MSFT"),
7336 InstrumentNameExchange::new("BTC/USD"),
7337 InstrumentNameExchange::new("TSLA"),
7338 ];
7339
7340 let snapshots = build_instrument_snapshots(Vec::new(), positions, &instruments);
7341 let listed: Vec<_> = snapshots
7342 .iter()
7343 .map(|s| (s.instrument.name().as_str(), s.position.quantity()))
7344 .collect();
7345 assert_eq!(
7349 listed,
7350 vec![
7351 ("tsla", Some(dec!(-2))),
7352 ("MSFT", Some(Decimal::ZERO)),
7353 ("BTC/USD", None),
7354 ("TSLA", None)
7355 ]
7356 );
7357 assert!(!snapshots[3].orders_complete);
7358 }
7359
7360 #[test]
7366 fn test_recover_fills_dedup_key_matches_ws_path() {
7367 let order_id = "ord-1";
7368
7369 let ws_keys: Vec<SmolStr> = ["1", "2"]
7372 .iter()
7373 .map(|filled_qty| {
7374 early_dedup_key(&AlpacaTradeUpdate {
7375 event: SmolStr::new("partial_fill"),
7376 execution_id: None,
7377 previous_execution_id: None,
7378 order: make_order_ws(order_id, "SPY", "buy", filled_qty),
7379 price: Some("150.00"),
7380 qty: Some("1"),
7381 timestamp: None,
7382 })
7383 })
7384 .collect();
7385
7386 let mut cumulative = Decimal::ZERO;
7389 let rest_keys: Vec<SmolStr> = ["1", "1"]
7390 .iter()
7391 .map(|exec_qty| {
7392 cumulative += Decimal::from_str(exec_qty).unwrap();
7393 fill_dedup_key(order_id, cumulative)
7394 })
7395 .collect();
7396
7397 assert_eq!(
7398 ws_keys, rest_keys,
7399 "REST recovery dedup keys must match WS path keys for cross-source dedup to work"
7400 );
7401 assert_eq!(ws_keys[0].as_str(), "ord-1:1");
7402 assert_eq!(ws_keys[1].as_str(), "ord-1:2");
7403 }
7404
7405 #[test]
7410 fn a_ws_fill_without_an_execution_id_is_identified_by_its_dedup_key() {
7411 let update = AlpacaTradeUpdate {
7412 event: SmolStr::new("fill"),
7413 execution_id: None,
7414 previous_execution_id: None,
7415 order: make_order_ws("ord-abc", "SPY", "buy", "5"),
7416 price: Some("150.00"),
7417 qty: Some("5"),
7418 timestamp: None,
7419 };
7420
7421 let early_key = early_dedup_key(&update);
7423
7424 let [event, _snapshot] = convert_trade_update(update);
7426 let Some(UnindexedAccountEvent {
7427 kind: AccountEventKind::Trade(trade),
7428 ..
7429 }) = event
7430 else {
7431 panic!("fill should produce an execution");
7432 };
7433
7434 assert_eq!(
7435 trade.id.0.as_str(),
7436 early_key.as_str(),
7437 "without an execution_id the TradeId is the dedup key"
7438 );
7439 assert_eq!(early_key.as_str(), "ord-abc:5");
7440 }
7441
7442 #[test]
7447 fn early_dedup_key_normalizes_decimal_strings() {
7448 let update1 = AlpacaTradeUpdate {
7450 event: SmolStr::new("fill"),
7451 execution_id: None,
7452 previous_execution_id: None,
7453 order: AlpacaOrderWs {
7454 id: SmolStr::new("ord-x"),
7455 client_order_id: Some(SmolStr::new("cid")),
7456 symbol: SmolStr::new("AAPL"),
7457 qty: Some("10"),
7458 filled_qty: Some("1.00"),
7459 side: SmolStr::new("buy"),
7460 order_type: SmolStr::new("market"),
7461 time_in_force: SmolStr::new("day"),
7462 limit_price: None,
7463 stop_price: None,
7464 trail_percent: None,
7465 trail_price: None,
7466 status: SmolStr::new("filled"),
7467 },
7468 price: Some("100.00"),
7469 qty: Some("10"),
7470 timestamp: None,
7471 };
7472 assert_eq!(early_dedup_key(&update1).as_str(), "ord-x:1");
7473
7474 let update2 = AlpacaTradeUpdate {
7476 event: SmolStr::new("fill"),
7477 execution_id: None,
7478 previous_execution_id: None,
7479 order: AlpacaOrderWs {
7480 id: SmolStr::new("ord-x"),
7481 client_order_id: Some(SmolStr::new("cid")),
7482 symbol: SmolStr::new("AAPL"),
7483 qty: Some("10"),
7484 filled_qty: Some("1"),
7485 side: SmolStr::new("buy"),
7486 order_type: SmolStr::new("market"),
7487 time_in_force: SmolStr::new("day"),
7488 limit_price: None,
7489 stop_price: None,
7490 trail_percent: None,
7491 trail_price: None,
7492 status: SmolStr::new("filled"),
7493 },
7494 price: Some("100.00"),
7495 qty: Some("10"),
7496 timestamp: None,
7497 };
7498 assert_eq!(early_dedup_key(&update2).as_str(), "ord-x:1");
7499
7500 let update3 = AlpacaTradeUpdate {
7502 event: SmolStr::new("fill"),
7503 execution_id: None,
7504 previous_execution_id: None,
7505 order: AlpacaOrderWs {
7506 id: SmolStr::new("ord-x"),
7507 client_order_id: Some(SmolStr::new("cid")),
7508 symbol: SmolStr::new("AAPL"),
7509 qty: Some("10"),
7510 filled_qty: Some("1.0"),
7511 side: SmolStr::new("buy"),
7512 order_type: SmolStr::new("market"),
7513 time_in_force: SmolStr::new("day"),
7514 limit_price: None,
7515 stop_price: None,
7516 trail_percent: None,
7517 trail_price: None,
7518 status: SmolStr::new("filled"),
7519 },
7520 price: Some("100.00"),
7521 qty: Some("10"),
7522 timestamp: None,
7523 };
7524 assert_eq!(early_dedup_key(&update3).as_str(), "ord-x:1");
7525 }
7526
7527 #[test]
7532 fn process_ws_text_rejected_event_without_filled_qty_is_not_dropped() {
7533 let (tx, mut rx) = mpsc::unbounded_channel();
7534 let dedup = new_dedup_cache();
7535 let mut backoff = ExponentialBackoff::new();
7536
7537 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"}}}"#;
7539
7540 let known = KnownLiveOrders::shared(ExchangeId::AlpacaBroker);
7541 process_ws_text(json, &tx, &dedup, &known, &mut backoff);
7542
7543 let event = rx.try_recv()
7545 .expect("rejected event without filled_qty must produce an AccountEvent, not be silently dropped");
7546 assert!(
7547 matches!(event.kind, AccountEventKind::OrderCancelled(_)),
7548 "rejected event must map to OrderCancelled, got: {:?}",
7549 event.kind
7550 );
7551 }
7552
7553 #[test]
7556 fn process_ws_text_identifies_a_fill_by_its_execution_id() {
7557 let frame = |execution_id: &str| {
7558 format!(
7559 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"}}}}"#
7560 )
7561 };
7562 let cases = [
7563 (
7564 r#""execution_id":"524b1902-817e-446c-825b-a9fcfebbc17e","#,
7565 "524b1902-817e-446c-825b-a9fcfebbc17e",
7566 ),
7567 (r#""execution_id":null,"#, "ord-1:2"),
7568 (r#""execution_id":"","#, "ord-1:2"),
7569 ("", "ord-1:2"),
7570 ];
7571 for (field, expected) in cases {
7572 let (tx, mut rx) = mpsc::unbounded_channel();
7573 let known = KnownLiveOrders::shared(ExchangeId::AlpacaBroker);
7574 process_ws_text(
7575 &frame(field),
7576 &tx,
7577 &new_dedup_cache(),
7578 &known,
7579 &mut ExponentialBackoff::new(),
7580 );
7581 let Ok(UnindexedAccountEvent {
7582 kind: AccountEventKind::Trade(trade),
7583 ..
7584 }) = rx.try_recv()
7585 else {
7586 panic!("the fill frame {field:?} produces a Trade");
7587 };
7588 assert_eq!(trade.id.0.as_str(), expected, "frame {field:?}");
7589 }
7590 }
7591
7592 fn amendment_frame(event: &str, fields: &str) -> String {
7595 format!(
7596 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"}}}}"#
7597 )
7598 }
7599
7600 fn stream_events(frames: &[String]) -> Vec<UnindexedAccountEvent> {
7602 let (tx, mut rx) = mpsc::unbounded_channel();
7603 let dedup = new_dedup_cache();
7604 let known = KnownLiveOrders::shared(ExchangeId::AlpacaBroker);
7605 let mut backoff = ExponentialBackoff::new();
7606 for frame in frames {
7607 process_ws_text(frame, &tx, &dedup, &known, &mut backoff);
7608 }
7609 std::iter::from_fn(|| rx.try_recv().ok()).collect()
7610 }
7611
7612 fn amendment_of(
7614 frame: String,
7615 ) -> crate::trade::TradeAmendment<AssetNameExchange, InstrumentNameExchange> {
7616 let events = stream_events(&[frame]);
7617 let [
7618 UnindexedAccountEvent {
7619 kind: AccountEventKind::TradeAmended(amendment),
7620 ..
7621 },
7622 ] = <[_; 1]>::try_from(events).unwrap_or_else(|events| {
7623 panic!("one TradeAmended, got {events:?}");
7624 })
7625 else {
7626 panic!("expected TradeAmended");
7627 };
7628 amendment
7629 }
7630
7631 #[test]
7634 fn process_ws_text_reports_a_trade_bust() {
7635 let amendment = amendment_of(amendment_frame(
7636 "trade_bust",
7637 r#""execution_id":"exec-x","previous_execution_id":"exec-a","qty":"-2","#,
7638 ));
7639 assert_eq!(amendment.original, Some(TradeId::new("exec-a")));
7640 assert_eq!(amendment.order_id, OrderId::new("ord-1"));
7641 assert_eq!(amendment.instrument, InstrumentNameExchange::new("SPY"));
7642 assert_eq!(
7643 Some(amendment.time_exchange),
7644 parse_timestamp("2025-04-18T14:31:00Z")
7645 );
7646 assert_eq!(
7647 amendment.kind,
7648 TradeAmendmentKind::Busted {
7649 quantity: Some(Decimal::TWO)
7650 }
7651 );
7652 }
7653
7654 #[test]
7656 fn process_ws_text_reports_a_bust_that_names_no_fill() {
7657 let amendment = amendment_of(amendment_frame("trade_bust", ""));
7658 assert_eq!(amendment.original, None);
7659 assert_eq!(
7660 amendment.kind,
7661 TradeAmendmentKind::Busted { quantity: None }
7662 );
7663 }
7664
7665 #[test]
7668 fn process_ws_text_reports_a_trade_correct_with_its_replacement() {
7669 let amendment = amendment_of(amendment_frame(
7670 "trade_correct",
7671 r#""execution_id":"exec-c","previous_execution_id":"exec-a","price":"101.50","qty":"2","#,
7672 ));
7673 assert_eq!(amendment.original, Some(TradeId::new("exec-a")));
7674 let TradeAmendmentKind::Corrected { replacement } = amendment.kind else {
7675 panic!("a full replacement: {:?}", amendment.kind);
7676 };
7677 assert_eq!(replacement.id, TradeId::new("exec-c"));
7678 assert_eq!(replacement.order_id, OrderId::new("ord-1"));
7679 assert_eq!(replacement.side, Side::Buy);
7680 assert_eq!(replacement.price, Decimal::new(10150, 2));
7681 assert_eq!(replacement.quantity, Decimal::TWO);
7682 assert_eq!(replacement.order_filled_quantity, None);
7683 assert_eq!(replacement.time_exchange, amendment.time_exchange);
7684 }
7685
7686 #[test]
7689 fn process_ws_text_reports_a_correction_it_cannot_resolve() {
7690 let price = Some(Decimal::new(10150, 2));
7691 let cases = [
7692 (
7693 r#""execution_id":"exec-c","qty":"2","#,
7694 (Some("exec-c"), None, Some(Decimal::TWO)),
7695 ),
7696 (
7697 r#""price":"101.50","qty":"2","#,
7698 (None, price, Some(Decimal::TWO)),
7699 ),
7700 (
7701 r#""execution_id":"exec-c","price":"101.50","qty":"0","#,
7702 (Some("exec-c"), price, Some(Decimal::ZERO)),
7703 ),
7704 (
7705 r#""execution_id":"exec-c","price":"101.50","#,
7706 (Some("exec-c"), price, None),
7707 ),
7708 ];
7709 for (fields, (id, price, quantity)) in cases {
7710 let amendment = amendment_of(amendment_frame("trade_correct", fields));
7711 assert_eq!(
7712 amendment.kind,
7713 TradeAmendmentKind::CorrectedUnresolved {
7714 id: id.map(TradeId::new),
7715 price,
7716 quantity,
7717 },
7718 "{fields}"
7719 );
7720 }
7721 }
7722
7723 #[test]
7726 fn a_fill_after_a_bust_is_delivered_at_a_cumulative_seen_before() {
7727 let fill = |execution_id: &str| {
7728 format!(
7729 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"}}}}"#
7730 )
7731 };
7732 let events = stream_events(&[
7733 fill("exec-a"),
7734 amendment_frame(
7735 "trade_bust",
7736 r#""execution_id":"exec-x","previous_execution_id":"exec-a","qty":"-2","#,
7737 ),
7738 fill("exec-b"),
7739 ]);
7740
7741 let kinds: Vec<_> = events
7742 .iter()
7743 .filter_map(|event| match &event.kind {
7744 AccountEventKind::Trade(trade) => Some(format!("trade {}", trade.id)),
7745 AccountEventKind::TradeAmended(amendment) => {
7746 Some(format!("amended {:?}", amendment.original))
7747 }
7748 _ => None,
7749 })
7750 .collect();
7751 assert_eq!(
7752 kinds,
7753 vec![
7754 "trade exec-a",
7755 "amended Some(TradeId(\"exec-a\"))",
7756 "trade exec-b"
7757 ]
7758 );
7759 }
7760
7761 #[test]
7764 fn decimal_zero_normalize_is_zero_str() {
7765 assert_eq!(Decimal::ZERO.normalize().to_string(), "0");
7766 }
7767
7768 #[test]
7772 fn decimal_normalize_strips_trailing_zeros() {
7773 let from_rest = Decimal::from_str("1.00").unwrap().normalize();
7775 let from_ws = Decimal::from_str("1").unwrap().normalize();
7776 assert_eq!(from_rest.to_string(), from_ws.to_string());
7777 assert_eq!(from_rest.to_string(), "1");
7778
7779 assert_eq!(
7781 Decimal::from_str("100.000")
7782 .unwrap()
7783 .normalize()
7784 .to_string(),
7785 "100"
7786 );
7787 assert_eq!(
7788 Decimal::from_str("0.10").unwrap().normalize().to_string(),
7789 "0.1"
7790 );
7791 assert_eq!(
7792 Decimal::from_str("0.100").unwrap().normalize().to_string(),
7793 "0.1"
7794 );
7795 }
7796
7797 #[test]
7799 fn map_position_intent_open_buy_maps_to_buy_to_open() {
7800 assert_eq!(
7801 map_position_intent(Side::Buy, false),
7802 AlpacaPositionIntent::BuyToOpen
7803 );
7804 }
7805
7806 #[test]
7807 fn map_position_intent_open_sell_maps_to_sell_to_open() {
7808 assert_eq!(
7809 map_position_intent(Side::Sell, false),
7810 AlpacaPositionIntent::SellToOpen
7811 );
7812 }
7813
7814 #[test]
7815 fn map_position_intent_reduce_buy_maps_to_buy_to_close() {
7816 assert_eq!(
7817 map_position_intent(Side::Buy, true),
7818 AlpacaPositionIntent::BuyToClose
7819 );
7820 }
7821
7822 #[test]
7823 fn map_position_intent_reduce_sell_maps_to_sell_to_close() {
7824 assert_eq!(
7825 map_position_intent(Side::Sell, true),
7826 AlpacaPositionIntent::SellToClose
7827 );
7828 }
7829
7830 #[test]
7832 fn parse_order_error_401_maps_to_unauthenticated() {
7833 assert!(matches!(
7835 parse_order_error(reqwest::StatusCode::UNAUTHORIZED, None, "bad credentials"),
7836 UnindexedOrderError::Rejected(ApiError::Unauthenticated(_))
7837 ));
7838 }
7839
7840 #[test]
7841 fn parse_order_error_403_without_code_maps_to_unauthenticated() {
7842 assert!(matches!(
7844 parse_order_error(reqwest::StatusCode::FORBIDDEN, None, "forbidden."),
7845 UnindexedOrderError::Rejected(ApiError::Unauthenticated(_))
7846 ));
7847 }
7848
7849 #[test]
7850 fn parse_order_error_403_with_code_and_insufficient_maps_to_balance_insufficient() {
7851 for message in [
7852 "insufficient buying power",
7853 "insufficient day trading buying power",
7854 "insufficient qty available for order (requested: 10, available: 0)",
7855 ] {
7856 let err = parse_order_error(reqwest::StatusCode::FORBIDDEN, Some(40310000), message);
7857 let UnindexedOrderError::Rejected(ApiError::BalanceInsufficient(None, text)) = err
7858 else {
7859 panic!("expected BalanceInsufficient for {message:?}, got {err:?}");
7860 };
7861 assert_eq!(text, format!("40310000 {message}"));
7862 }
7863 }
7864
7865 #[test]
7866 fn parse_order_error_reads_a_refused_short_by_its_text() {
7867 for (status, code, message, reason) in [
7868 (
7869 reqwest::StatusCode::FORBIDDEN,
7870 Some(40310000),
7871 "account is not allowed to short",
7872 BorrowRejectReason::BorrowDisabled,
7873 ),
7874 (
7876 reqwest::StatusCode::FORBIDDEN,
7877 Some(40310000),
7878 "asset XYZ cannot be sold short",
7879 BorrowRejectReason::NotShortable,
7880 ),
7881 (
7882 reqwest::StatusCode::UNPROCESSABLE_ENTITY,
7883 Some(42210000),
7884 "asset XYZ cannot be sold short",
7885 BorrowRejectReason::NotShortable,
7886 ),
7887 (
7888 reqwest::StatusCode::UNPROCESSABLE_ENTITY,
7889 None,
7890 "account is not allowed to short",
7891 BorrowRejectReason::BorrowDisabled,
7892 ),
7893 ] {
7894 assert_eq!(
7895 parse_order_error(status, code, message),
7896 UnindexedOrderError::Rejected(ApiError::BorrowRejected(BorrowReject::new(
7897 reason,
7898 code.map(|code| code.to_string()),
7899 message.to_owned(),
7900 ))),
7901 "{status} {message:?}"
7902 );
7903 }
7904
7905 for (status, code) in [
7908 (reqwest::StatusCode::UNPROCESSABLE_ENTITY, 42210000),
7909 (reqwest::StatusCode::FORBIDDEN, 40310000),
7910 ] {
7911 assert_eq!(
7912 parse_order_error(status, Some(code), "fractional orders cannot be sold short"),
7913 UnindexedOrderError::Rejected(ApiError::OrderRejected(format!(
7914 "{code} fractional orders cannot be sold short"
7915 ))),
7916 "{status}"
7917 );
7918 }
7919
7920 assert!(matches!(
7922 parse_order_error(
7923 reqwest::StatusCode::FORBIDDEN,
7924 Some(40310000),
7925 "insufficient qty: account is not allowed to short",
7926 ),
7927 UnindexedOrderError::Rejected(ApiError::BorrowRejected(_))
7928 ));
7929
7930 assert!(matches!(
7932 parse_order_error(
7933 reqwest::StatusCode::FORBIDDEN,
7934 None,
7935 "account is not allowed to short"
7936 ),
7937 UnindexedOrderError::Rejected(ApiError::Unauthenticated(_))
7938 ));
7939 }
7940
7941 #[test]
7942 fn parse_order_error_403_with_code_maps_to_order_rejected() {
7943 for (code, message) in [
7944 (40310000, "account is restricted to liquidation only"),
7945 (
7946 40310100,
7947 "trade denied due to pattern day trading protection",
7948 ),
7949 ] {
7950 let err = parse_order_error(reqwest::StatusCode::FORBIDDEN, Some(code), message);
7951 let UnindexedOrderError::Rejected(ApiError::OrderRejected(text)) = err else {
7952 panic!("expected OrderRejected for {message:?}, got {err:?}");
7953 };
7954 assert_eq!(text, format!("{code} {message}"));
7955 }
7956 }
7957
7958 #[test]
7959 fn alpaca_api_error_reads_code_and_falls_back_to_raw_text() {
7960 let coded = AlpacaApiError::from_body(
7961 br#"{"code": 40310000, "message": "insufficient buying power"}"#,
7962 );
7963 assert_eq!(coded.code, Some(40310000));
7964 assert_eq!(coded.message, "insufficient buying power");
7965
7966 let bare = AlpacaApiError::from_body(br#"{"message": "forbidden."}"#);
7967 assert_eq!(bare.code, None);
7968 assert_eq!(bare.message, "forbidden.");
7969
7970 let raw = AlpacaApiError::from_body(b"upstream connect error");
7971 assert_eq!(raw.code, None);
7972 assert_eq!(raw.message, "upstream connect error");
7973 }
7974
7975 #[test]
7976 fn parse_order_error_404_maps_to_order_rejected_with_not_found_prefix() {
7977 let err = parse_order_error(reqwest::StatusCode::NOT_FOUND, None, "order not found");
7978 let UnindexedOrderError::Rejected(ApiError::OrderRejected(msg)) = err else {
7979 panic!("expected OrderRejected, got {err:?}");
7980 };
7981 assert!(
7982 msg.contains("order not found"),
7983 "message should contain 'order not found': {msg}"
7984 );
7985 }
7986
7987 #[test]
7988 fn parse_order_error_422_insufficient_only_maps_to_balance_insufficient() {
7989 assert!(matches!(
7991 parse_order_error(
7992 reqwest::StatusCode::UNPROCESSABLE_ENTITY,
7993 None,
7994 "insufficient funds for this order"
7995 ),
7996 UnindexedOrderError::Rejected(ApiError::BalanceInsufficient(None, _))
7997 ));
7998 }
7999
8000 #[test]
8001 fn parse_order_error_429_maps_to_rate_limit() {
8002 assert!(matches!(
8003 parse_order_error(reqwest::StatusCode::TOO_MANY_REQUESTS, None, "rate limited"),
8004 UnindexedOrderError::Rejected(ApiError::RateLimit {
8005 retry_after: None,
8006 ..
8007 })
8008 ));
8009 }
8010
8011 #[test]
8012 fn a_rate_limit_delay_is_capped_at_alpacas_window() {
8013 let window = Some(Duration::from_secs(DEFAULT_RATE_LIMIT_DELAY_SECS));
8014 for (name, value) in [("retry-after", u64::MAX), ("x-ratelimit-reset", u64::MAX)] {
8015 let mut headers = reqwest::header::HeaderMap::new();
8016 headers.insert(name, value.to_string().parse().unwrap());
8017 assert_eq!(parse_rate_limit_delay(&headers), window, "{name}");
8018 RateLimitTracker::new().on_rate_limited(parse_rate_limit_delay(&headers));
8020 }
8021
8022 let mut headers = reqwest::header::HeaderMap::new();
8023 headers.insert("retry-after", "0".parse().unwrap());
8024 assert_eq!(
8025 parse_rate_limit_delay(&headers),
8026 Some(Duration::ZERO),
8027 "taken as given"
8028 );
8029 }
8030
8031 #[test]
8035 fn parse_time_in_force_unknown_value_falls_back_to_good_until_end_of_day() {
8036 assert_eq!(
8037 parse_time_in_force("opg"),
8038 TimeInForce::GoodUntilEndOfDay,
8039 "unknown TIF must fall back to GoodUntilEndOfDay (with a warn! in production)"
8040 );
8041 }
8042
8043 #[test]
8046 fn build_instrument_snapshots_output_order_matches_instruments_slice() {
8047 let orders = vec![
8049 make_order_response("o1", "SPY"),
8050 make_order_response("o2", "AAPL"),
8051 make_order_response("o3", "MSFT"),
8052 ];
8053 let instruments = vec![
8055 InstrumentNameExchange::new("MSFT"),
8056 InstrumentNameExchange::new("AAPL"),
8057 ];
8058 let snapshots = build_instrument_snapshots(orders, Vec::new(), &instruments);
8059 assert_eq!(snapshots.len(), 2);
8060 assert_eq!(
8061 snapshots[0].instrument.name().as_str(),
8062 "MSFT",
8063 "first snapshot must be MSFT (first in instruments slice)"
8064 );
8065 assert_eq!(
8066 snapshots[1].instrument.name().as_str(),
8067 "AAPL",
8068 "second snapshot must be AAPL (second in instruments slice)"
8069 );
8070 }
8071
8072 mod http_tests {
8079 use super::super::*;
8080 use wiremock::matchers::{method, path};
8081 use wiremock::{Mock, MockServer, Request, Respond, ResponseTemplate};
8082
8083 struct Sequential {
8090 call: std::sync::atomic::AtomicU32,
8091 pages: Vec<serde_json::Value>,
8092 }
8093
8094 impl Sequential {
8095 fn new(pages: Vec<serde_json::Value>) -> Self {
8096 Self {
8097 call: std::sync::atomic::AtomicU32::new(0),
8098 pages,
8099 }
8100 }
8101 }
8102
8103 impl Respond for Sequential {
8104 fn respond(&self, _: &Request) -> ResponseTemplate {
8105 let i = self.call.fetch_add(1, std::sync::atomic::Ordering::Relaxed) as usize;
8106 let body = self.pages.get(i).unwrap_or_else(|| {
8107 panic!(
8108 "Sequential: request #{i} has no configured response \
8109 (only {} page(s) supplied)",
8110 self.pages.len()
8111 )
8112 });
8113 ResponseTemplate::new(200).set_body_json(body)
8114 }
8115 }
8116
8117 fn make_activities_json(count: usize, id_prefix: &str) -> serde_json::Value {
8119 serde_json::Value::Array(
8120 (0..count)
8121 .map(|i| {
8122 serde_json::json!({
8123 "id": format!("{id_prefix}-{i:05}"),
8124 "order_id": "ord-1",
8125 "symbol": "SPY",
8126 "side": "buy",
8127 "price": "100.00",
8128 "qty": "1",
8129 "transaction_time": "2025-04-18T14:30:00Z"
8130 })
8131 })
8132 .collect(),
8133 )
8134 }
8135
8136 fn make_orders_json(count: usize) -> serde_json::Value {
8138 serde_json::Value::Array(
8139 (0..count)
8140 .map(|i| {
8141 serde_json::json!({
8142 "id": format!("order-{i:05}"),
8143 "client_order_id": null,
8144 "symbol": "SPY",
8145 "qty": "1",
8146 "filled_qty": "0",
8147 "side": "buy",
8148 "type": "limit",
8149 "time_in_force": "day",
8150 "limit_price": "100.00",
8151 "created_at": "2025-04-18T14:30:00Z"
8152 })
8153 })
8154 .collect(),
8155 )
8156 }
8157
8158 #[tokio::test]
8163 async fn paginate_activities_single_page_below_max_returns_all_not_truncated() {
8164 let server = MockServer::start().await;
8165 Mock::given(method("GET"))
8166 .and(path("/v2/account/activities"))
8167 .respond_with(
8168 ResponseTemplate::new(200).set_body_json(make_activities_json(5, "act")),
8169 )
8170 .mount(&server)
8171 .await;
8172
8173 let http = reqwest::Client::new();
8174 let rl = RateLimitTracker::new();
8175 let result = paginate_activities::<AlpacaActivity>(
8176 &http,
8177 &rl,
8178 &server.uri(),
8179 FILL_ACTIVITIES,
8180 recovery_after(),
8181 None,
8182 )
8183 .await
8184 .unwrap();
8185
8186 assert_eq!(result.activities.len(), 5);
8187 assert_eq!(result.resume, None);
8188 assert_eq!(server.received_requests().await.unwrap().len(), 1);
8189 }
8190
8191 #[tokio::test]
8194 async fn paginate_activities_exactly_page_size_items_fetches_second_page() {
8195 let server = MockServer::start().await;
8196 Mock::given(method("GET"))
8197 .and(path("/v2/account/activities"))
8198 .respond_with(Sequential::new(vec![
8199 make_activities_json(ALPACA_MAX_ACTIVITIES, "act"),
8200 serde_json::json!([]), ]))
8202 .mount(&server)
8203 .await;
8204
8205 let http = reqwest::Client::new();
8206 let rl = RateLimitTracker::new();
8207 let result = paginate_activities::<AlpacaActivity>(
8208 &http,
8209 &rl,
8210 &server.uri(),
8211 FILL_ACTIVITIES,
8212 recovery_after(),
8213 None,
8214 )
8215 .await
8216 .unwrap();
8217
8218 assert_eq!(result.activities.len(), ALPACA_MAX_ACTIVITIES);
8219 assert_eq!(result.resume, None);
8220 assert_eq!(
8221 server.received_requests().await.unwrap().len(),
8222 2,
8223 "exactly 2 requests: first full page + second empty page"
8224 );
8225 }
8226
8227 #[tokio::test]
8230 async fn paginate_activities_two_pages_returns_combined_activities_not_truncated() {
8231 let server = MockServer::start().await;
8232 Mock::given(method("GET"))
8233 .and(path("/v2/account/activities"))
8234 .respond_with(Sequential::new(vec![
8235 make_activities_json(ALPACA_MAX_ACTIVITIES, "p1"),
8236 make_activities_json(37, "p2"),
8237 ]))
8238 .mount(&server)
8239 .await;
8240
8241 let http = reqwest::Client::new();
8242 let rl = RateLimitTracker::new();
8243 let result = paginate_activities::<AlpacaActivity>(
8244 &http,
8245 &rl,
8246 &server.uri(),
8247 FILL_ACTIVITIES,
8248 recovery_after(),
8249 None,
8250 )
8251 .await
8252 .unwrap();
8253
8254 assert_eq!(result.activities.len(), ALPACA_MAX_ACTIVITIES + 37);
8255 assert_eq!(result.resume, None);
8256 assert_eq!(server.received_requests().await.unwrap().len(), 2);
8257 }
8258
8259 #[tokio::test]
8263 async fn paginate_activities_at_max_pages_sets_truncated_true() {
8264 let server = MockServer::start().await;
8265
8266 Mock::given(method("GET"))
8268 .and(path("/v2/account/activities"))
8269 .respond_with(
8270 ResponseTemplate::new(200)
8271 .set_body_json(make_activities_json(ALPACA_MAX_ACTIVITIES, "act")),
8272 )
8273 .mount(&server)
8274 .await;
8275
8276 let http = reqwest::Client::new();
8277 let rl = RateLimitTracker::new();
8278 let result = paginate_activities::<AlpacaActivity>(
8279 &http,
8280 &rl,
8281 &server.uri(),
8282 FILL_ACTIVITIES,
8283 recovery_after(),
8284 None,
8285 )
8286 .await
8287 .unwrap();
8288
8289 assert!(
8290 result.resume.is_some(),
8291 "must say where to read on after MAX_ACTIVITY_PAGES pages"
8292 );
8293 assert_eq!(
8294 result.activities.len(),
8295 MAX_ACTIVITY_PAGES * ALPACA_MAX_ACTIVITIES,
8296 "must accumulate exactly MAX_ACTIVITY_PAGES * page_size activities"
8297 );
8298 assert_eq!(
8301 server.received_requests().await.unwrap().len(),
8302 MAX_ACTIVITY_PAGES,
8303 "loop must issue exactly MAX_ACTIVITY_PAGES requests then stop"
8304 );
8305 }
8306
8307 #[tokio::test]
8311 async fn fetch_raw_open_orders_499_results_returns_ok() {
8312 let server = MockServer::start().await;
8313 Mock::given(method("GET"))
8314 .and(path("/v2/orders"))
8315 .respond_with(
8316 ResponseTemplate::new(200).set_body_json(make_orders_json(MAX_OPEN_ORDERS - 1)),
8317 )
8318 .mount(&server)
8319 .await;
8320
8321 let http = reqwest::Client::new();
8322 let rl = RateLimitTracker::new();
8323 let result = fetch_raw_open_orders(&http, &rl, &server.uri(), &[]).await;
8324
8325 assert!(
8326 result.is_ok(),
8327 "499 orders must not trigger truncation: {result:?}"
8328 );
8329 assert_eq!(result.unwrap().len(), MAX_OPEN_ORDERS - 1);
8330 }
8331
8332 #[tokio::test]
8336 async fn fetch_raw_open_orders_500_results_returns_truncated_snapshot_error() {
8337 let server = MockServer::start().await;
8338 Mock::given(method("GET"))
8339 .and(path("/v2/orders"))
8340 .respond_with(
8341 ResponseTemplate::new(200).set_body_json(make_orders_json(MAX_OPEN_ORDERS)),
8342 )
8343 .mount(&server)
8344 .await;
8345
8346 let http = reqwest::Client::new();
8347 let rl = RateLimitTracker::new();
8348 let result = fetch_raw_open_orders(&http, &rl, &server.uri(), &[]).await;
8349
8350 assert!(
8351 matches!(
8352 result,
8353 Err(UnindexedClientError::TruncatedSnapshot { limit }) if limit == MAX_OPEN_ORDERS
8354 ),
8355 "500 orders must return TruncatedSnapshot, got: {result:?}"
8356 );
8357 }
8358
8359 async fn mount_account_and_positions(server: &MockServer) {
8364 Mock::given(method("GET"))
8365 .and(path("/v2/account"))
8366 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
8367 "status": "ACTIVE",
8368 "currency": "USD",
8369 "cash": "-1500.50",
8370 "equity": "4000.00",
8371 "buying_power": "5000.00",
8372 "options_buying_power": "2000.00",
8373 "non_marginable_buying_power": "1000.00",
8374 })))
8375 .mount(server)
8376 .await;
8377 Mock::given(method("GET"))
8378 .and(path("/v2/positions"))
8379 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([
8380 {
8381 "symbol": "XYZ",
8382 "asset_class": "us_equity",
8383 "side": "short",
8384 "qty": "-10",
8385 "qty_available": "-10",
8386 "avg_entry_price": "20.00",
8387 "unrealized_pl": "15.50",
8388 },
8389 {
8390 "symbol": "XYZ271217C00025000",
8391 "asset_class": "us_option",
8392 "side": "long",
8393 "qty": "3",
8394 "qty_available": "3",
8395 "avg_entry_price": "0.85",
8396 "unrealized_pl": "-45",
8397 },
8398 {
8399 "symbol": "ABC",
8400 "asset_class": "us_equity",
8401 "side": "long",
8402 "qty": "2.5",
8403 "qty_available": "2.5",
8404 "avg_entry_price": "40.00",
8405 "unrealized_pl": "1.25",
8406 },
8407 {
8408 "symbol": "BTCUSD",
8409 "asset_class": "crypto",
8410 "side": "long",
8411 "qty": "0.75",
8412 "qty_available": "0.5",
8413 "avg_entry_price": "50000",
8414 "unrealized_pl": "0",
8415 },
8416 ])))
8417 .mount(server)
8418 .await;
8419 Mock::given(method("GET"))
8420 .and(path("/v2/orders"))
8421 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([])))
8422 .mount(server)
8423 .await;
8424 }
8425
8426 fn client_for(server: &MockServer) -> AlpacaClient {
8427 AlpacaClient::new(AlpacaConfig::with_base_url(
8428 "test-key".into(),
8429 "test-secret".into(),
8430 server.uri(),
8431 ))
8432 }
8433
8434 #[tokio::test]
8435 async fn account_snapshot_reports_cash_positions_and_crypto_balances() {
8436 use crate::client::ExecutionClient;
8437 use rust_decimal_macros::dec;
8438
8439 let server = MockServer::start().await;
8440 mount_account_and_positions(&server).await;
8441
8442 let snapshot = client_for(&server)
8443 .account_snapshot(&[], &[])
8444 .await
8445 .unwrap();
8446
8447 let balances: Vec<_> = snapshot
8449 .balances
8450 .iter()
8451 .map(|b| (b.asset.name().as_str(), b.balance.total, b.balance.free))
8452 .collect();
8453 assert_eq!(
8454 balances,
8455 vec![
8456 ("USD", dec!(-1500.50), dec!(-1500.50)),
8457 ("BTC", dec!(0.75), dec!(0.5)),
8458 ],
8459 "crypto stays a balance; equities and options do not appear as balances"
8460 );
8461
8462 let positions: Vec<_> = snapshot
8463 .instruments
8464 .iter()
8465 .map(|i| {
8466 let p = i.position.open().unwrap();
8467 (
8468 i.instrument.name().as_str(),
8469 p.quantity,
8470 p.entry_price,
8471 p.unrealized_pnl,
8472 )
8473 })
8474 .collect();
8475 assert_eq!(
8476 positions,
8477 vec![
8478 ("XYZ", dec!(-10), Some(dec!(20.00)), Some(dec!(15.50))),
8479 (
8480 "XYZ271217C00025000",
8481 dec!(3),
8482 Some(dec!(0.85)),
8483 Some(dec!(-45))
8484 ),
8485 ("ABC", dec!(2.5), Some(dec!(40.00)), Some(dec!(1.25))),
8486 ],
8487 "short equity, option and fractional positions; no crypto position"
8488 );
8489 }
8490
8491 #[tokio::test]
8494 async fn account_snapshot_usd_only_still_reports_positions() {
8495 use crate::client::ExecutionClient;
8496 use rust_decimal_macros::dec;
8497
8498 let server = MockServer::start().await;
8499 mount_account_and_positions(&server).await;
8500
8501 let usd = [AssetNameExchange::new("USD")];
8502 let instruments = [InstrumentNameExchange::new("XYZ")];
8503 let snapshot = client_for(&server)
8504 .account_snapshot(&usd, &instruments)
8505 .await
8506 .unwrap();
8507
8508 assert_eq!(snapshot.balances.len(), 1);
8509 assert_eq!(snapshot.balances[0].asset, AssetNameExchange::new("USD"));
8510 assert_eq!(snapshot.balances[0].balance.total, dec!(-1500.50));
8511
8512 assert_eq!(snapshot.instruments.len(), 1);
8513 assert_eq!(snapshot.instruments[0].position.quantity(), Some(dec!(-10)));
8514 }
8515
8516 #[tokio::test]
8518 async fn account_snapshot_non_usd_only_skips_account_request() {
8519 use crate::client::ExecutionClient;
8520
8521 let server = MockServer::start().await;
8522 mount_account_and_positions(&server).await;
8523
8524 let btc = [AssetNameExchange::new("btc")];
8525 let snapshot = client_for(&server)
8526 .account_snapshot(&btc, &[])
8527 .await
8528 .unwrap();
8529
8530 assert_eq!(snapshot.balances.len(), 1);
8531 assert_eq!(snapshot.balances[0].asset, AssetNameExchange::new("BTC"));
8532 let requests = server.received_requests().await.unwrap();
8533 assert!(
8534 requests.iter().all(|r| r.url.path() != "/v2/account"),
8535 "no USD requested, so /v2/account must not be fetched"
8536 );
8537 }
8538
8539 #[tokio::test]
8547 async fn open_order_reduce_only_sell_sends_sell_to_close_intent() {
8548 use crate::client::ExecutionClient;
8549 use crate::order::request::{OrderRequestOpen, RequestOpen};
8550 use crate::order::{
8551 OrderKey, OrderKind, TimeInForce,
8552 id::{ClientOrderId, StrategyId},
8553 };
8554 use rust_decimal::Decimal;
8555 use rustrade_instrument::Side;
8556 use rustrade_instrument::exchange::ExchangeId;
8557 use rustrade_instrument::instrument::name::InstrumentNameExchange;
8558 use wiremock::matchers::{method, path};
8559
8560 let server = MockServer::start().await;
8561
8562 let captured_body = std::sync::Arc::new(parking_lot::Mutex::new(None));
8565 let captured_clone = captured_body.clone();
8566
8567 Mock::given(method("POST"))
8568 .and(path("/v2/orders"))
8569 .respond_with(move |req: &Request| {
8570 let body: serde_json::Value = serde_json::from_slice(&req.body).unwrap();
8572 *captured_clone.lock() = Some(body);
8573
8574 ResponseTemplate::new(200).set_body_json(serde_json::json!({
8575 "id": "test-order-id",
8576 "client_order_id": "test-cid",
8577 "symbol": "AAPL",
8578 "qty": "10",
8579 "filled_qty": "0",
8580 "side": "sell",
8581 "type": "market",
8582 "time_in_force": "ioc",
8583 "limit_price": null,
8584 "created_at": "2025-04-18T14:30:00Z"
8585 }))
8586 })
8587 .mount(&server)
8588 .await;
8589
8590 let config =
8592 AlpacaConfig::with_base_url("test-key".into(), "test-secret".into(), server.uri());
8593 let client = AlpacaClient::new(config);
8594
8595 let request = OrderRequestOpen {
8597 key: OrderKey {
8598 exchange: ExchangeId::AlpacaBroker,
8599 instrument: InstrumentNameExchange::new("AAPL"),
8600 strategy: StrategyId::new("test-strategy"),
8601 cid: ClientOrderId::new("test-cid"),
8602 },
8603 state: RequestOpen {
8604 side: Side::Sell,
8605 price: None,
8606 quantity: Decimal::new(10, 0),
8607 kind: OrderKind::Market,
8608 time_in_force: TimeInForce::ImmediateOrCancel,
8609 position_id: None,
8610 reduce_only: true, market: None,
8612 },
8613 };
8614
8615 let order = client
8617 .open_order(OrderRequestOpen {
8618 key: OrderKey {
8619 exchange: request.key.exchange,
8620 instrument: &request.key.instrument,
8621 strategy: request.key.strategy.clone(),
8622 cid: request.key.cid.clone(),
8623 },
8624 state: request.state.clone(),
8625 })
8626 .await;
8627
8628 assert!(
8630 order.state.is_accepted(),
8631 "order should be accepted: {:?}",
8632 order.state
8633 );
8634
8635 let body = captured_body
8637 .lock()
8638 .take()
8639 .expect("request body should be captured");
8640 assert_eq!(
8641 body.get("position_intent").and_then(|v| v.as_str()),
8642 Some("sell_to_close"),
8643 "reduce_only=true + Side::Sell should produce position_intent=sell_to_close, got: {body}"
8644 );
8645 }
8646
8647 #[tokio::test]
8649 async fn open_order_not_reduce_only_buy_sends_buy_to_open_intent() {
8650 use crate::client::ExecutionClient;
8651 use crate::order::request::{OrderRequestOpen, RequestOpen};
8652 use crate::order::{
8653 OrderKey, OrderKind, TimeInForce,
8654 id::{ClientOrderId, StrategyId},
8655 };
8656 use rust_decimal::Decimal;
8657 use rustrade_instrument::Side;
8658 use rustrade_instrument::exchange::ExchangeId;
8659 use rustrade_instrument::instrument::name::InstrumentNameExchange;
8660
8661 let server = MockServer::start().await;
8662
8663 let captured_body = std::sync::Arc::new(parking_lot::Mutex::new(None));
8664 let captured_clone = captured_body.clone();
8665
8666 Mock::given(method("POST"))
8667 .and(path("/v2/orders"))
8668 .respond_with(move |req: &Request| {
8669 let body: serde_json::Value = serde_json::from_slice(&req.body).unwrap();
8670 *captured_clone.lock() = Some(body);
8671
8672 ResponseTemplate::new(200).set_body_json(serde_json::json!({
8673 "id": "test-order-id",
8674 "client_order_id": "test-cid",
8675 "symbol": "AAPL",
8676 "qty": "10",
8677 "filled_qty": "0",
8678 "side": "buy",
8679 "type": "market",
8680 "time_in_force": "ioc",
8681 "limit_price": null,
8682 "created_at": "2025-04-18T14:30:00Z"
8683 }))
8684 })
8685 .mount(&server)
8686 .await;
8687
8688 let config =
8689 AlpacaConfig::with_base_url("test-key".into(), "test-secret".into(), server.uri());
8690 let client = AlpacaClient::new(config);
8691
8692 let instrument = InstrumentNameExchange::new("AAPL");
8693 let request = OrderRequestOpen {
8694 key: OrderKey {
8695 exchange: ExchangeId::AlpacaBroker,
8696 instrument: &instrument,
8697 strategy: StrategyId::new("test-strategy"),
8698 cid: ClientOrderId::new("test-cid"),
8699 },
8700 state: RequestOpen {
8701 side: Side::Buy,
8702 price: None,
8703 quantity: Decimal::new(10, 0),
8704 kind: OrderKind::Market,
8705 time_in_force: TimeInForce::ImmediateOrCancel,
8706 position_id: None,
8707 reduce_only: false, market: None,
8709 },
8710 };
8711
8712 let order = client.open_order(request).await;
8713
8714 assert!(
8715 order.state.is_accepted(),
8716 "order should be accepted: {:?}",
8717 order.state
8718 );
8719
8720 let body = captured_body
8721 .lock()
8722 .take()
8723 .expect("request body should be captured");
8724 assert_eq!(
8725 body.get("position_intent").and_then(|v| v.as_str()),
8726 Some("buy_to_open"),
8727 "reduce_only=false + Side::Buy should produce position_intent=buy_to_open, got: {body}"
8728 );
8729 }
8730
8731 #[tokio::test]
8734 async fn rest_with_retry_reports_an_undecodable_2xx_body_as_internal() {
8735 let server = MockServer::start().await;
8736 Mock::given(method("GET"))
8737 .and(path("/v2/account"))
8738 .respond_with(ResponseTemplate::new(200).set_body_string("not json"))
8739 .mount(&server)
8740 .await;
8741
8742 let http = reqwest::Client::new();
8743 let rl = RateLimitTracker::new();
8744 let url = format!("{}/v2/account", server.uri());
8745 let result: Result<serde_json::Value, _> =
8746 rest_with_retry(&rl, || http.get(&url)).await;
8747 assert!(
8748 matches!(result, Err(UnindexedClientError::Internal(_))),
8749 "{result:?}"
8750 );
8751 }
8752
8753 async fn holds_back(rl: &RateLimitTracker) -> bool {
8755 tokio::time::timeout(Duration::from_millis(50), rl.wait_if_blocked())
8756 .await
8757 .is_err()
8758 }
8759
8760 fn with_rate_limit(
8762 template: ResponseTemplate,
8763 remaining: u32,
8764 reset: bool,
8765 ) -> ResponseTemplate {
8766 let template = template.insert_header("x-ratelimit-remaining", remaining.to_string());
8767 if !reset {
8768 return template;
8769 }
8770 let now_secs = std::time::SystemTime::now()
8771 .duration_since(std::time::UNIX_EPOCH)
8772 .unwrap()
8773 .as_secs();
8774 template.insert_header("x-ratelimit-reset", (now_secs + 30).to_string())
8775 }
8776
8777 #[tokio::test]
8780 async fn rest_with_retry_pauses_once_no_requests_remain() {
8781 for (remaining, reset, pauses) in [(0, true, true), (1, true, false), (0, false, false)]
8782 {
8783 let server = MockServer::start().await;
8784 Mock::given(method("GET"))
8785 .and(path("/v2/account"))
8786 .respond_with(with_rate_limit(
8787 ResponseTemplate::new(200).set_body_json(serde_json::json!({})),
8788 remaining,
8789 reset,
8790 ))
8791 .mount(&server)
8792 .await;
8793
8794 let http = reqwest::Client::new();
8795 let rl = RateLimitTracker::new();
8796 let url = format!("{}/v2/account", server.uri());
8797 let _: serde_json::Value = rest_with_retry(&rl, || http.get(&url)).await.unwrap();
8798 assert_eq!(
8799 holds_back(&rl).await,
8800 pauses,
8801 "remaining {remaining}, reset {reset}"
8802 );
8803 }
8804 }
8805
8806 #[tokio::test]
8809 async fn a_pause_lasts_a_minute_at_most() {
8810 tokio::time::pause();
8811 let now_secs = std::time::SystemTime::now()
8812 .duration_since(std::time::UNIX_EPOCH)
8813 .unwrap()
8814 .as_secs();
8815 let mut headers = reqwest::header::HeaderMap::new();
8816 headers.insert("x-ratelimit-remaining", "0".parse().unwrap());
8817 headers.insert(
8818 "x-ratelimit-reset",
8819 (now_secs + 3_600).to_string().parse().unwrap(),
8820 );
8821 let rl = RateLimitTracker::new();
8822
8823 observe_rate_limit_remaining(&rl, &headers);
8824 assert!(holds_back(&rl).await);
8825 tokio::time::advance(Duration::from_secs(DEFAULT_RATE_LIMIT_DELAY_SECS)).await;
8826 assert!(!holds_back(&rl).await);
8827 }
8828
8829 #[tokio::test]
8831 async fn rest_delete_with_retry_pauses_once_no_requests_remain() {
8832 let server = MockServer::start().await;
8833 Mock::given(method("DELETE"))
8834 .and(path("/v2/orders/abc"))
8835 .respond_with(with_rate_limit(ResponseTemplate::new(204), 0, true))
8836 .mount(&server)
8837 .await;
8838
8839 let http = reqwest::Client::new();
8840 let rl = RateLimitTracker::new();
8841 let url = format!("{}/v2/orders/abc", server.uri());
8842 rest_delete_with_retry(&rl, || http.delete(&url))
8843 .await
8844 .unwrap();
8845 assert!(holds_back(&rl).await);
8846 }
8847
8848 #[tokio::test]
8851 async fn open_order_with_an_undecodable_2xx_response_is_status_unknown() {
8852 use crate::client::ExecutionClient;
8853 use crate::error::OrderError;
8854 use crate::order::request::{OrderRequestOpen, RequestOpen};
8855 use crate::order::state::{InactiveOrderState, OrderState};
8856 use crate::order::{
8857 OrderKey, OrderKind, TimeInForce,
8858 id::{ClientOrderId, StrategyId},
8859 };
8860 use rustrade_instrument::Side;
8861 use rustrade_instrument::exchange::ExchangeId;
8862 use rustrade_instrument::instrument::name::InstrumentNameExchange;
8863
8864 let server = MockServer::start().await;
8865 Mock::given(method("POST"))
8866 .and(path("/v2/orders"))
8867 .respond_with(
8868 ResponseTemplate::new(200).set_body_json(serde_json::json!({ "id": 1 })),
8869 )
8870 .mount(&server)
8871 .await;
8872
8873 let instrument = InstrumentNameExchange::new("AAPL");
8874 let order = client_for(&server)
8875 .open_order(OrderRequestOpen {
8876 key: OrderKey {
8877 exchange: ExchangeId::AlpacaBroker,
8878 instrument: &instrument,
8879 strategy: StrategyId::new("test-strategy"),
8880 cid: ClientOrderId::new("test-cid"),
8881 },
8882 state: RequestOpen {
8883 side: Side::Buy,
8884 price: None,
8885 quantity: Decimal::new(10, 0),
8886 kind: OrderKind::Market,
8887 time_in_force: TimeInForce::ImmediateOrCancel,
8888 position_id: None,
8889 reduce_only: false,
8890 market: None,
8891 },
8892 })
8893 .await;
8894
8895 assert!(
8896 matches!(
8897 order.state,
8898 OrderState::Inactive(InactiveOrderState::OpenFailed(OrderError::Connectivity(
8899 _
8900 )))
8901 ),
8902 "{:?}",
8903 order.state
8904 );
8905 }
8906
8907 #[tokio::test]
8910 async fn open_order_reports_a_403_by_its_code() {
8911 use crate::client::ExecutionClient;
8912 use crate::error::OrderError;
8913 use crate::order::request::{OrderRequestOpen, RequestOpen};
8914 use crate::order::state::{InactiveOrderState, OrderState};
8915 use crate::order::{
8916 OrderKey, OrderKind, TimeInForce,
8917 id::{ClientOrderId, StrategyId},
8918 };
8919 use rustrade_instrument::Side;
8920 use rustrade_instrument::exchange::ExchangeId;
8921 use rustrade_instrument::instrument::name::InstrumentNameExchange;
8922
8923 async fn open_against(
8924 status: u16,
8925 body: serde_json::Value,
8926 ) -> OrderState<AssetNameExchange, InstrumentNameExchange> {
8927 let server = MockServer::start().await;
8928 Mock::given(method("POST"))
8929 .and(path("/v2/orders"))
8930 .respond_with(ResponseTemplate::new(status).set_body_json(body))
8931 .mount(&server)
8932 .await;
8933 let instrument = InstrumentNameExchange::new("AAPL");
8934 client_for(&server)
8935 .open_order(OrderRequestOpen {
8936 key: OrderKey {
8937 exchange: ExchangeId::AlpacaBroker,
8938 instrument: &instrument,
8939 strategy: StrategyId::new("test-strategy"),
8940 cid: ClientOrderId::new("test-cid"),
8941 },
8942 state: RequestOpen {
8943 side: Side::Sell,
8944 price: None,
8945 quantity: Decimal::new(10, 0),
8946 kind: OrderKind::Market,
8947 time_in_force: TimeInForce::ImmediateOrCancel,
8948 position_id: None,
8949 reduce_only: false,
8950 market: None,
8951 },
8952 })
8953 .await
8954 .state
8955 }
8956
8957 let rejected =
8958 |state: &OrderState<AssetNameExchange, InstrumentNameExchange>| match state {
8959 OrderState::Inactive(InactiveOrderState::OpenFailed(OrderError::Rejected(
8960 e,
8961 ))) => e.clone(),
8962 other => panic!("expected a rejection, got {other:?}"),
8963 };
8964
8965 let state = open_against(
8966 403,
8967 serde_json::json!({
8968 "code": 40310000,
8969 "message": "insufficient buying power"
8970 }),
8971 )
8972 .await;
8973 assert_eq!(
8974 rejected(&state),
8975 ApiError::BalanceInsufficient(None, "40310000 insufficient buying power".into())
8976 );
8977
8978 let state = open_against(
8979 403,
8980 serde_json::json!({
8981 "code": 40310000,
8982 "message": "account is not allowed to short"
8983 }),
8984 )
8985 .await;
8986 assert_eq!(
8987 rejected(&state),
8988 ApiError::BorrowRejected(BorrowReject::new(
8989 BorrowRejectReason::BorrowDisabled,
8990 Some("40310000".into()),
8991 "account is not allowed to short".into(),
8992 ))
8993 );
8994
8995 let state = open_against(
8997 422,
8998 serde_json::json!({
8999 "code": 42210000,
9000 "message": "asset XYZ cannot be sold short"
9001 }),
9002 )
9003 .await;
9004 assert_eq!(
9005 rejected(&state),
9006 ApiError::BorrowRejected(BorrowReject::new(
9007 BorrowRejectReason::NotShortable,
9008 Some("42210000".into()),
9009 "asset XYZ cannot be sold short".into(),
9010 ))
9011 );
9012
9013 let state = open_against(403, serde_json::json!({ "message": "forbidden." })).await;
9014 assert!(
9015 matches!(rejected(&state), ApiError::Unauthenticated(_)),
9016 "{state:?}"
9017 );
9018 }
9019
9020 #[tokio::test]
9023 async fn open_bracket_order_with_an_undecodable_2xx_response_is_status_unknown() {
9024 use crate::error::OrderError;
9025 use crate::order::state::{InactiveOrderState, OrderState};
9026 use rustrade_instrument::instrument::name::InstrumentNameExchange;
9027
9028 let server = MockServer::start().await;
9029 Mock::given(method("POST"))
9030 .and(path("/v2/orders"))
9031 .respond_with(
9032 ResponseTemplate::new(200).set_body_json(serde_json::json!({ "id": 1 })),
9033 )
9034 .mount(&server)
9035 .await;
9036
9037 let result = client_for(&server)
9038 .open_bracket_order(AlpacaBracketOrderRequest::new(
9039 InstrumentNameExchange::new("SPY"),
9040 crate::order::id::StrategyId::new("test"),
9041 crate::order::id::ClientOrderId::new("test-bracket"),
9042 Side::Buy,
9043 Decimal::ONE,
9044 Decimal::new(100, 0),
9045 Decimal::new(120, 0),
9046 Decimal::new(90, 0),
9047 TimeInForce::GoodUntilCancelled { post_only: false },
9048 ))
9049 .await;
9050
9051 assert!(
9052 matches!(
9053 result.parent.state,
9054 OrderState::Inactive(InactiveOrderState::OpenFailed(OrderError::Connectivity(
9055 _
9056 )))
9057 ),
9058 "{:?}",
9059 result.parent.state
9060 );
9061 }
9062
9063 fn recovery_after() -> DateTime<Utc> {
9072 "2025-01-01T00:00:00Z".parse().expect("valid time")
9073 }
9074
9075 fn activity_json(
9078 id: &str,
9079 order_id: &str,
9080 qty: &str,
9081 cum_qty: Option<&str>,
9082 ) -> serde_json::Value {
9083 let mut v = serde_json::json!({
9084 "id": id,
9085 "order_id": order_id,
9086 "symbol": "SPY",
9087 "side": "buy",
9088 "price": "100.00",
9089 "qty": qty,
9090 "transaction_time": "2025-04-18T14:30:00Z"
9091 });
9092 if let Some(c) = cum_qty {
9093 v["cum_qty"] = serde_json::Value::String(c.to_string());
9094 }
9095 v
9096 }
9097
9098 async fn drive_recover_fills_with(
9101 activities: Vec<serde_json::Value>,
9102 dedup: SharedDedupCache,
9103 known: &SharedKnownLiveOrders,
9104 ) -> Vec<UnindexedAccountEvent> {
9105 let server = MockServer::start().await;
9106 Mock::given(method("GET"))
9107 .and(path("/v2/account/activities"))
9108 .respond_with(
9109 ResponseTemplate::new(200).set_body_json(serde_json::Value::Array(activities)),
9110 )
9111 .mount(&server)
9112 .await;
9113
9114 let http = reqwest::Client::new();
9115 let rl = RateLimitTracker::new();
9116 let (tx, mut rx) = mpsc::unbounded_channel();
9117 let outcome = recover_fills(
9118 &http,
9119 &rl,
9120 &[],
9121 &server.uri(),
9122 recovery_after(),
9123 &tx,
9124 &dedup,
9125 known,
9126 )
9127 .await;
9128 assert_eq!(outcome, Ok(()), "a full read leaves nothing unrecovered");
9129 drop(tx);
9130
9131 let mut out = Vec::new();
9132 while let Ok(event) = rx.try_recv() {
9133 out.push(event);
9134 }
9135 out
9136 }
9137
9138 async fn drive_recover_fills(
9139 activities: Vec<serde_json::Value>,
9140 ) -> Vec<UnindexedAccountEvent> {
9141 drive_recover_fills_with(
9142 activities,
9143 new_dedup_cache(),
9144 &KnownLiveOrders::shared(ExchangeId::AlpacaBroker),
9145 )
9146 .await
9147 }
9148
9149 async fn recover_fills_from(
9152 server: &MockServer,
9153 ) -> (Result<(), UnreadFills>, Vec<UnindexedAccountEvent>) {
9154 let (tx, mut rx) = mpsc::unbounded_channel();
9155 let outcome = recover_fills(
9156 &reqwest::Client::new(),
9157 &RateLimitTracker::new(),
9158 &[],
9159 &server.uri(),
9160 recovery_after(),
9161 &tx,
9162 &new_dedup_cache(),
9163 &KnownLiveOrders::shared(ExchangeId::AlpacaBroker),
9164 )
9165 .await;
9166 drop(tx);
9167 (outcome, std::iter::from_fn(|| rx.try_recv().ok()).collect())
9168 }
9169
9170 #[tokio::test]
9173 async fn a_failed_recovery_read_leaves_every_fill_since_the_disconnect_unread() {
9174 let server = MockServer::start().await;
9175 Mock::given(method("GET"))
9176 .and(path("/v2/account/activities"))
9177 .respond_with(ResponseTemplate::new(500).set_body_string("unavailable"))
9178 .mount(&server)
9179 .await;
9180
9181 let (outcome, events) = recover_fills_from(&server).await;
9182
9183 let Err(UnreadFills {
9184 start,
9185 reason: FillRecoveryFailure::Request(_),
9186 }) = outcome
9187 else {
9188 panic!("expected the request's failure, got {outcome:?}");
9189 };
9190 assert_eq!(start, recovery_after(), "from the disconnect");
9191 assert!(events.is_empty(), "nothing forwarded: {events:?}");
9192 }
9193
9194 #[tokio::test]
9199 async fn a_truncated_recovery_read_delivers_what_it_read_and_leaves_the_rest_unread() {
9200 let time = |s: &str| s.parse::<DateTime<Utc>>().expect("valid time");
9201 let cases = [
9203 (
9204 Some("2025-04-18T14:30:01.234567Z"),
9205 time("2025-04-18T14:30:01.234Z"),
9206 ),
9207 (Some("not a time"), time("2025-04-18T14:30:00Z")),
9208 (None, recovery_after()),
9209 ];
9210 for (last_time, expected_start) in cases {
9211 let mut page = make_activities_json(ALPACA_MAX_ACTIVITIES, "act");
9212 let Some(activities) = page.as_array_mut() else {
9213 panic!("an array of activities");
9214 };
9215 match last_time {
9216 Some(last_time) => {
9217 if let Some(last) = activities.last_mut() {
9218 last["transaction_time"] =
9219 serde_json::Value::String(last_time.to_string());
9220 }
9221 }
9222 None => {
9223 for activity in activities.iter_mut() {
9224 activity["transaction_time"] =
9225 serde_json::Value::String("not a time".to_string());
9226 }
9227 }
9228 }
9229 let server = MockServer::start().await;
9230 Mock::given(method("GET"))
9231 .and(path("/v2/account/activities"))
9232 .respond_with(ResponseTemplate::new(200).set_body_json(page))
9233 .mount(&server)
9234 .await;
9235
9236 let (outcome, events) = recover_fills_from(&server).await;
9237
9238 let fills_read = MAX_ACTIVITY_PAGES * ALPACA_MAX_ACTIVITIES;
9239 assert_eq!(
9240 outcome,
9241 Err(UnreadFills {
9242 start: expected_start,
9243 reason: FillRecoveryFailure::Truncated { fills_read },
9244 }),
9245 "last time {last_time:?}"
9246 );
9247 assert_eq!(events.len(), fills_read, "every fill read is forwarded");
9248 }
9249 }
9250
9251 #[test]
9254 fn a_fill_recovery_give_up_covers_every_instrument_the_stream_does() {
9255 let start = recovery_after();
9256 let end = start + chrono::Duration::minutes(5);
9257 let spy = InstrumentNameExchange::new("SPY");
9258 let qqq = InstrumentNameExchange::new("QQQ");
9259 let cases = [
9260 (vec![], FillRecoveryScope::AllInstruments),
9261 (
9262 vec![spy.clone(), qqq.clone()],
9263 FillRecoveryScope::Instruments(vec![spy, qqq]),
9264 ),
9265 ];
9266 for (instruments, scope) in cases {
9267 let reason = FillRecoveryFailure::TimedOut { timeout_secs: 30 };
9268 let unread = UnreadFills {
9269 start,
9270 reason: reason.clone(),
9271 };
9272 assert_eq!(
9273 fill_recovery_gave_up(&instruments, unread, end),
9274 UnindexedAccountEvent::new(
9275 ExchangeId::AlpacaBroker,
9276 AccountEventKind::FillRecoveryGaveUp(FillRecoveryGap::new(
9277 scope, start, end, 1, reason,
9278 )),
9279 )
9280 );
9281 }
9282 }
9283
9284 #[test]
9288 fn a_read_that_reached_past_the_recovery_start_still_reports() {
9289 let end = recovery_after();
9290 let start = end + chrono::Duration::seconds(1);
9291 let unread = UnreadFills {
9292 start,
9293 reason: FillRecoveryFailure::Truncated { fills_read: 5_000 },
9294 };
9295 let event = fill_recovery_gave_up(&[], unread, end);
9296 let AccountEventKind::FillRecoveryGaveUp(gave_up) = event.kind else {
9297 panic!("expected FillRecoveryGaveUp, got {event:?}");
9298 };
9299 assert_eq!((gave_up.start, gave_up.end), (start, start));
9300 }
9301
9302 async fn recover_fills_or_report_from(
9305 server: &MockServer,
9306 instruments: &[InstrumentNameExchange],
9307 timeout: Duration,
9308 ) -> Vec<UnindexedAccountEvent> {
9309 let (tx, mut rx) = mpsc::unbounded_channel();
9310 recover_fills_or_report(
9311 &reqwest::Client::new(),
9312 &RateLimitTracker::new(),
9313 instruments,
9314 &server.uri(),
9315 recovery_after(),
9316 timeout,
9317 &tx,
9318 &new_dedup_cache(),
9319 &KnownLiveOrders::shared(ExchangeId::AlpacaBroker),
9320 )
9321 .await;
9322 drop(tx);
9323 std::iter::from_fn(|| rx.try_recv().ok()).collect()
9324 }
9325
9326 #[tokio::test]
9329 async fn a_timed_out_recovery_reports_every_fill_since_the_disconnect() {
9330 let server = MockServer::start().await;
9331 Mock::given(method("GET"))
9332 .and(path("/v2/account/activities"))
9333 .respond_with(
9334 ResponseTemplate::new(200)
9335 .set_body_json(serde_json::json!([]))
9336 .set_delay(Duration::from_secs(3_600)),
9337 )
9338 .mount(&server)
9339 .await;
9340 let spy = InstrumentNameExchange::new("SPY");
9341
9342 let events = recover_fills_or_report_from(
9343 &server,
9344 std::slice::from_ref(&spy),
9345 Duration::from_millis(50),
9346 )
9347 .await;
9348
9349 let [event] = events.as_slice() else {
9350 panic!("only the give-up is sent: {events:?}");
9351 };
9352 let AccountEventKind::FillRecoveryGaveUp(gave_up) = &event.kind else {
9353 panic!("expected FillRecoveryGaveUp, got {event:?}");
9354 };
9355 assert_eq!(gave_up.scope, FillRecoveryScope::Instruments(vec![spy]));
9356 assert_eq!(gave_up.start, recovery_after(), "from the disconnect");
9357 assert!(gave_up.end >= gave_up.start);
9358 assert_eq!(gave_up.attempts, 1);
9359 assert!(
9360 matches!(gave_up.reason, FillRecoveryFailure::TimedOut { .. }),
9361 "{:?}",
9362 gave_up.reason
9363 );
9364 }
9365
9366 #[tokio::test]
9369 async fn a_truncated_recovery_reports_after_the_fills_it_read() {
9370 let server = MockServer::start().await;
9371 Mock::given(method("GET"))
9372 .and(path("/v2/account/activities"))
9373 .respond_with(
9374 ResponseTemplate::new(200)
9375 .set_body_json(make_activities_json(ALPACA_MAX_ACTIVITIES, "act")),
9376 )
9377 .mount(&server)
9378 .await;
9379
9380 let events = recover_fills_or_report_from(&server, &[], Duration::from_secs(30)).await;
9381
9382 let Some((last, fills)) = events.split_last() else {
9383 panic!("events are sent");
9384 };
9385 assert_eq!(fills.len(), MAX_ACTIVITY_PAGES * ALPACA_MAX_ACTIVITIES);
9386 assert!(
9387 fills
9388 .iter()
9389 .all(|event| matches!(event.kind, AccountEventKind::Trade(_))),
9390 "every fill comes first"
9391 );
9392 let AccountEventKind::FillRecoveryGaveUp(gave_up) = &last.kind else {
9393 panic!("the give-up comes last, got {last:?}");
9394 };
9395 assert_eq!(gave_up.scope, FillRecoveryScope::AllInstruments);
9396 assert!(matches!(
9397 gave_up.reason,
9398 FillRecoveryFailure::Truncated { .. }
9399 ));
9400 }
9401
9402 #[test]
9405 fn an_activity_id_names_its_execution_after_the_separator() {
9406 assert_eq!(
9407 activity_execution_id("20261005041849348::524b1902-817e-446c-825b-a9fcfebbc17e"),
9408 "524b1902-817e-446c-825b-a9fcfebbc17e"
9409 );
9410 assert_eq!(activity_execution_id("act-1"), "act-1");
9411 assert_eq!(
9412 activity_execution_id("20261005041849348::"),
9413 "20261005041849348::"
9414 );
9415 }
9416
9417 #[tokio::test]
9421 async fn a_fill_has_one_trade_id_on_the_stream_in_recovery_and_from_fetch_trades() {
9422 use crate::client::ExecutionClient;
9423
9424 let execution_id = "524b1902-817e-446c-825b-a9fcfebbc17e";
9425 let activity = activity_json(
9426 &format!("20250418103000000::{execution_id}"),
9427 "ord-1",
9428 "2",
9429 Some("2"),
9430 );
9431
9432 let [streamed, _snapshot] = convert_trade_update(AlpacaTradeUpdate {
9433 event: SmolStr::new("fill"),
9434 execution_id: Some(execution_id),
9435 previous_execution_id: None,
9436 order: super::make_order_ws("ord-1", "SPY", "buy", "2"),
9437 price: Some("100.00"),
9438 qty: Some("2"),
9439 timestamp: Some("2025-04-18T14:30:00Z"),
9440 });
9441 let Some(streamed) = streamed else {
9442 panic!("a fill converts to a Trade");
9443 };
9444
9445 let recovered = drive_recover_fills(vec![activity.clone()]).await;
9446 let [recovered] = recovered.as_slice() else {
9447 panic!("one fill recovered: {recovered:?}");
9448 };
9449
9450 let server = MockServer::start().await;
9451 Mock::given(method("GET"))
9452 .and(path("/v2/account/activities"))
9453 .respond_with(
9454 ResponseTemplate::new(200)
9455 .set_body_json(serde_json::Value::Array(vec![activity])),
9456 )
9457 .mount(&server)
9458 .await;
9459 let Ok(fetched) = client_for(&server)
9460 .fetch_trades(recovery_after(), Utc::now(), &[])
9461 .await
9462 else {
9463 panic!("fetch_trades reads the activity");
9464 };
9465 let [fetched] = fetched.trades.as_slice() else {
9466 panic!("one fill fetched: {fetched:?}");
9467 };
9468
9469 assert_eq!(trade_of(&streamed).id.0.as_str(), execution_id);
9470 assert_eq!(trade_of(recovered).id.0.as_str(), execution_id);
9471 assert_eq!(fetched.id.0.as_str(), execution_id);
9472 }
9473
9474 fn time(s: &str) -> DateTime<Utc> {
9479 s.parse().expect("valid time")
9480 }
9481
9482 struct ActivitiesVenue {
9487 activities: Vec<serde_json::Value>,
9488 }
9489
9490 impl Respond for ActivitiesVenue {
9491 fn respond(&self, request: &Request) -> ResponseTemplate {
9492 let query: std::collections::HashMap<String, String> =
9493 request.url.query_pairs().into_owned().collect();
9494 let bound = |name: &str| query.get(name).map(|s| time(s));
9495 let (after, until) = (bound("after"), bound("until"));
9496 let size: usize = query
9497 .get("page_size")
9498 .and_then(|s| s.parse().ok())
9499 .unwrap_or(ALPACA_MAX_ACTIVITIES);
9500 let in_bounds = self.activities.iter().filter(|activity| {
9501 let at =
9502 floor_millis(time(activity["transaction_time"].as_str().expect("a time")));
9503 after.is_none_or(|after| at >= after) && until.is_none_or(|until| at < until)
9504 });
9505 let page: Vec<_> = match query.get("page_token") {
9506 Some(token) => in_bounds
9507 .skip_while(|activity| activity["id"] != token.as_str())
9508 .skip(1)
9509 .take(size)
9510 .cloned()
9511 .collect(),
9512 None => in_bounds.take(size).cloned().collect(),
9513 };
9514 ResponseTemplate::new(200).set_body_json(page)
9515 }
9516 }
9517
9518 fn fill_at(id: &str, symbol: &str, transaction_time: DateTime<Utc>) -> serde_json::Value {
9520 serde_json::json!({
9521 "id": id,
9522 "order_id": format!("ord-{id}"),
9523 "symbol": symbol,
9524 "side": "buy",
9525 "price": "100.00",
9526 "qty": "1",
9527 "cum_qty": "1",
9528 "transaction_time": transaction_time.to_rfc3339_opts(SecondsFormat::Micros, true),
9529 })
9530 }
9531
9532 async fn serve_activities(activities: Vec<serde_json::Value>) -> MockServer {
9533 let server = MockServer::start().await;
9534 Mock::given(method("GET"))
9535 .and(path("/v2/account/activities"))
9536 .respond_with(ActivitiesVenue { activities })
9537 .mount(&server)
9538 .await;
9539 server
9540 }
9541
9542 fn non_trade(
9545 id: &str,
9546 activity_type: &str,
9547 sub_type: Option<&str>,
9548 net_amount: &str,
9549 created_at: &str,
9550 ) -> serde_json::Value {
9551 let mut activity = serde_json::json!({
9552 "id": id, "activity_type": activity_type, "net_amount": net_amount,
9553 "created_at": created_at, "date": &created_at[..10], "currency": "USD",
9554 "status": "executed",
9555 });
9556 if let Some(sub_type) = sub_type {
9557 activity["activity_sub_type"] = serde_json::json!(sub_type);
9558 }
9559 activity
9560 }
9561
9562 async fn query_pairs(server: &MockServer) -> Vec<Vec<(String, String)>> {
9564 let Some(requests) = server.received_requests().await else {
9565 panic!("requests are recorded");
9566 };
9567 requests
9568 .iter()
9569 .map(|request| request.url.query_pairs().into_owned().collect())
9570 .collect()
9571 }
9572
9573 #[tokio::test]
9574 async fn fetch_cash_flows_maps_each_activity_type_and_leaves_out_what_it_cannot_use() {
9575 let at = "2025-04-18T05:00:00Z";
9576 let mut fee = non_trade("fee", "FEE", Some("TAF"), "-0.02", at);
9577 fee["symbol"] = serde_json::json!("SPY");
9578 let mut canceled = non_trade("canceled", "FEE", Some("REG"), "-0.01", at);
9579 canceled["status"] = serde_json::json!("canceled");
9580 let mut no_amount = non_trade("no-amount", "FEE", None, "0", at);
9581 no_amount.as_object_mut().unwrap().remove("net_amount");
9582 let mut no_created_at = non_trade("no-time", "PTC", None, "-0.50", at);
9583 no_created_at.as_object_mut().unwrap().remove("created_at");
9584 let mut bad_time = non_trade("bad-time", "PTC", None, "-0.50", at);
9585 bad_time["created_at"] = serde_json::json!("not-a-time");
9586 let mut corrected = non_trade("correct", "PTC", None, "-0.40", at);
9587 corrected["status"] = serde_json::json!("correct");
9588 let mut euro = non_trade("euro", "PTR", None, "0.30", at);
9589 euro["currency"] = serde_json::json!("EUR");
9590 let server = MockServer::start().await;
9591 Mock::given(method("GET"))
9592 .and(path("/v2/account/activities"))
9593 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([
9594 non_trade("mgn", "INT", Some("MGN"), "-12.50", at),
9595 non_trade("int-paid", "INT", None, "-3.00", at),
9596 non_trade("int-credit", "INT", Some("CDT"), "1.00", at),
9597 fee,
9598 canceled,
9599 no_amount,
9600 no_created_at,
9601 bad_time,
9602 corrected,
9603 euro,
9604 non_trade("late", "FEE", None, "-0.01", "2025-04-20T05:00:00Z"),
9605 ])))
9606 .mount(&server)
9607 .await;
9608 let client = client_for(&server);
9609
9610 let read = client
9611 .fetch_cash_flows(time("2025-04-18T00:00:00Z"), time("2025-04-19T00:00:00Z"))
9612 .await
9613 .unwrap();
9614
9615 assert_eq!(read.resume, None);
9616 let kinds: Vec<_> = read
9617 .flows
9618 .iter()
9619 .map(|flow| (flow.id.clone().unwrap().0.to_string(), flow.kind.clone()))
9620 .collect();
9621 let interest = CashFlowKind::MarginInterest {
9622 rate: None,
9623 principal: None,
9624 };
9625 assert_eq!(
9626 kinds,
9627 [
9628 ("mgn", interest.clone()),
9629 ("int-paid", interest),
9630 ("int-credit", CashFlowKind::Other("INT.CDT".into())),
9631 ("fee", CashFlowKind::Other("FEE.TAF".into())),
9632 ("correct", CashFlowKind::Other("PTC".into())),
9633 ("euro", CashFlowKind::Rebate),
9634 ]
9635 .map(|(id, kind)| (id.to_owned(), kind))
9636 );
9637 let by_id = |id: &str| {
9638 read.flows
9639 .iter()
9640 .find(|flow| flow.id == Some(CashFlowId::new(id)))
9641 .unwrap()
9642 };
9643 assert_eq!(by_id("mgn").amount, Decimal::new(-1250, 2));
9644 assert_eq!(by_id("mgn").asset, AssetNameExchange::new("USD"));
9645 assert_eq!(by_id("mgn").instrument, None);
9646 assert_eq!(by_id("mgn").time_exchange, time(at));
9647 assert_eq!(
9648 by_id("fee").instrument,
9649 Some(InstrumentNameExchange::new("SPY"))
9650 );
9651 assert_eq!(
9652 by_id("correct").amount,
9653 Decimal::new(-40, 2),
9654 "a correction is returned as it is"
9655 );
9656 assert_eq!(by_id("euro").asset, AssetNameExchange::new("EUR"));
9657
9658 let queries = query_pairs(&server).await;
9659 let [query] = queries.as_slice() else {
9660 panic!("one request: {queries:?}");
9661 };
9662 assert!(
9663 query.contains(&("activity_types".into(), "INT,FEE,PTC,PTR".into())),
9664 "{query:?}"
9665 );
9666 }
9667
9668 #[tokio::test]
9669 async fn fetch_trades_asks_for_fills_by_the_documented_parameter() {
9670 use crate::client::ExecutionClient;
9671
9672 let server = serve_activities(Vec::new()).await;
9673 let client = client_for(&server);
9674
9675 let read = client
9676 .fetch_trades(
9677 time("2025-04-18T00:00:00Z"),
9678 time("2025-04-19T00:00:00Z"),
9679 &[],
9680 )
9681 .await
9682 .unwrap();
9683
9684 assert!(read.trades.is_empty());
9685 let queries = query_pairs(&server).await;
9686 let [query] = queries.as_slice() else {
9687 panic!("one request: {queries:?}");
9688 };
9689 assert!(
9690 query.contains(&("activity_types".into(), "FILL".into())),
9691 "{query:?}"
9692 );
9693 assert!(
9694 !query.iter().any(|(name, _)| name == "activity_type"),
9695 "{query:?}"
9696 );
9697 }
9698
9699 #[tokio::test]
9700 async fn fetch_cash_flows_reads_nothing_for_an_empty_span() {
9701 let server = MockServer::start().await;
9702 let client = client_for(&server);
9703
9704 let read = client
9705 .fetch_cash_flows(time("2025-04-19T00:00:00Z"), time("2025-04-18T00:00:00Z"))
9706 .await
9707 .unwrap();
9708
9709 assert!(read.flows.is_empty() && read.resume.is_none());
9710 assert!(query_pairs(&server).await.is_empty());
9711 }
9712
9713 #[tokio::test]
9716 async fn fetch_cash_flows_that_stops_at_its_bound_says_where_to_read_on() {
9717 let base = time("2025-04-18T05:00:00Z");
9718 let activities: Vec<_> = (0..6_000_i64)
9719 .map(|n| {
9720 let created =
9721 (base + TimeDelta::seconds(n)).to_rfc3339_opts(SecondsFormat::Micros, true);
9722 non_trade(&format!("{n:017}::fee"), "FEE", None, "-0.01", &created)
9723 })
9724 .collect();
9725 struct Paged(Vec<serde_json::Value>);
9727 impl Respond for Paged {
9728 fn respond(&self, request: &Request) -> ResponseTemplate {
9729 let token = request
9730 .url
9731 .query_pairs()
9732 .find(|(name, _)| name == "page_token")
9733 .map(|(_, value)| value.into_owned());
9734 let page: Vec<_> = self
9735 .0
9736 .iter()
9737 .skip_while(|activity| {
9738 token
9739 .as_deref()
9740 .is_some_and(|token| activity["id"] != token)
9741 })
9742 .skip(usize::from(token.is_some()))
9743 .take(ALPACA_MAX_ACTIVITIES)
9744 .cloned()
9745 .collect();
9746 ResponseTemplate::new(200).set_body_json(page)
9747 }
9748 }
9749 let server = MockServer::start().await;
9750 Mock::given(method("GET"))
9751 .and(path("/v2/account/activities"))
9752 .respond_with(Paged(activities))
9753 .mount(&server)
9754 .await;
9755 let client = client_for(&server);
9756
9757 let read = client
9758 .fetch_cash_flows(base, base + TimeDelta::days(1))
9759 .await
9760 .unwrap();
9761
9762 assert_eq!(read.flows.len(), 5_000);
9763 assert_eq!(read.resume, Some(base + TimeDelta::seconds(4_999)));
9764 }
9765
9766 #[tokio::test]
9769 async fn fetch_cash_flows_that_cannot_advance_fails() {
9770 let at = "2025-04-18T05:00:00Z";
9771 let full: Vec<_> = (0..ALPACA_MAX_ACTIVITIES)
9772 .map(|n| non_trade(&format!("fee-{n}"), "FEE", None, "-0.01", at))
9773 .collect();
9774 let server = MockServer::start().await;
9775 Mock::given(method("GET"))
9776 .and(path("/v2/account/activities"))
9777 .respond_with(ResponseTemplate::new(200).set_body_json(full))
9778 .mount(&server)
9779 .await;
9780 let client = client_for(&server);
9781
9782 let result = client
9783 .fetch_cash_flows(time(at), time("2025-04-19T00:00:00Z"))
9784 .await;
9785
9786 assert!(
9787 matches!(result, Err(UnindexedClientError::Internal(_))),
9788 "{result:?}"
9789 );
9790 }
9791
9792 fn option_activity(
9795 id: &str,
9796 activity_type: &str,
9797 symbol: &str,
9798 qty: &str,
9799 date: &str,
9800 ) -> serde_json::Value {
9801 serde_json::json!({
9802 "id": id, "activity_type": activity_type, "symbol": symbol, "qty": qty,
9803 "date": date, "net_amount": "0", "status": "executed",
9804 })
9805 }
9806
9807 fn share_leg(
9809 id: &str,
9810 symbol: &str,
9811 qty: &str,
9812 price: &str,
9813 date: &str,
9814 ) -> serde_json::Value {
9815 let mut activity = option_activity(id, "OPTRD", symbol, qty, date);
9816 activity["price"] = serde_json::json!(price);
9817 activity
9818 }
9819
9820 async fn serve_json(activities: serde_json::Value) -> MockServer {
9821 let server = MockServer::start().await;
9822 Mock::given(method("GET"))
9823 .and(path("/v2/account/activities"))
9824 .respond_with(ResponseTemplate::new(200).set_body_json(activities))
9825 .mount(&server)
9826 .await;
9827 server
9828 }
9829
9830 #[test]
9831 fn occ_contract_reads_the_root_kind_and_strike() {
9832 assert_eq!(
9833 occ_contract("QS240301C00006500"),
9834 Some(("QS", true, Decimal::new(65, 1)))
9835 );
9836 assert_eq!(
9837 occ_contract("AAPL240119P00150000"),
9838 Some(("AAPL", false, Decimal::from(150)))
9839 );
9840 for not_occ in [
9841 "QS",
9842 "240301C00006500",
9843 "QS240301X00006500",
9844 "QS2403O1C00006500",
9845 ] {
9846 assert_eq!(occ_contract(not_occ), None, "{not_occ}");
9847 }
9848 }
9849
9850 #[tokio::test]
9851 async fn fetch_option_events_maps_each_activity_and_pairs_the_share_legs() {
9852 let day = "2024-03-01";
9853 let mut expiry = option_activity("exp", "OPEXP", "QS240301C00010500", "-1", day);
9854 expiry["created_at"] = serde_json::json!("2024-03-01T21:30:00Z");
9855 let mut canceled = option_activity("canceled", "OPEXP", "QS240301P00000500", "-1", day);
9856 canceled["status"] = serde_json::json!("canceled");
9857 let server = serve_json(serde_json::json!([
9858 expiry,
9859 option_activity("exc", "OPEXC", "QS240301C00006500", "-1", day),
9860 share_leg("exc-shares", "QS", "100", "6.5", day),
9861 option_activity("asn", "OPASN", "QS240301P00009000", "1", day),
9862 share_leg("asn-shares", "QS", "100", "9", day),
9863 share_leg("orphan", "QS", "-100", "7", day),
9864 canceled,
9865 option_activity("no-price", "OPTRD", "QS", "100", day),
9866 option_activity("no-qty", "OPEXP", "QS240301C00011000", "0", day),
9867 option_activity("later", "OPEXP", "QS240308C00010500", "-1", "2024-03-02"),
9868 ]))
9869 .await;
9870 let client = client_for(&server);
9871
9872 let read = client
9873 .fetch_option_events(time("2024-03-01T00:00:00Z"), time("2024-03-01T23:00:00Z"))
9874 .await
9875 .unwrap();
9876
9877 assert_eq!(read.resume, None);
9878 let summary: Vec<_> = read
9879 .trades
9880 .iter()
9881 .map(|trade| {
9882 (
9883 trade.id.0.to_string(),
9884 trade.instrument.name().to_string(),
9885 trade.origin,
9886 trade.side,
9887 trade.price,
9888 trade.quantity,
9889 )
9890 })
9891 .collect();
9892 let row = |id: &str, symbol: &str, origin, side, price: Decimal, quantity: i64| {
9893 (
9894 id.to_owned(),
9895 symbol.to_owned(),
9896 origin,
9897 side,
9898 price,
9899 Decimal::from(quantity),
9900 )
9901 };
9902 assert_eq!(
9903 summary,
9904 [
9905 row(
9906 "exp",
9907 "QS240301C00010500",
9908 TradeOrigin::Expiry,
9909 Side::Sell,
9910 Decimal::ZERO,
9911 1
9912 ),
9913 row(
9914 "exc",
9915 "QS240301C00006500",
9916 TradeOrigin::Exercise,
9917 Side::Sell,
9918 Decimal::ZERO,
9919 1
9920 ),
9921 row(
9922 "exc-shares",
9923 "QS",
9924 TradeOrigin::Exercise,
9925 Side::Buy,
9926 Decimal::new(65, 1),
9927 100
9928 ),
9929 row(
9930 "asn",
9931 "QS240301P00009000",
9932 TradeOrigin::Assignment,
9933 Side::Buy,
9934 Decimal::ZERO,
9935 1
9936 ),
9937 row(
9938 "asn-shares",
9939 "QS",
9940 TradeOrigin::Assignment,
9941 Side::Buy,
9942 Decimal::from(9),
9943 100
9944 ),
9945 row(
9946 "orphan",
9947 "QS",
9948 TradeOrigin::External,
9949 Side::Sell,
9950 Decimal::from(7),
9951 100
9952 ),
9953 ]
9954 );
9955 let exp = &read.trades[0];
9956 assert_eq!(
9957 exp.time_exchange,
9958 time("2024-03-01T21:30:00Z"),
9959 "created_at"
9960 );
9961 assert_eq!(exp.order_id, OrderId::new("exp"));
9962 assert_eq!(exp.fees.fees, Decimal::ZERO);
9963 assert_eq!(
9964 read.trades[1].time_exchange,
9965 time("2024-03-01T00:00:00Z"),
9966 "no created_at: the start of its date"
9967 );
9968
9969 let queries = query_pairs(&server).await;
9970 let [query] = queries.as_slice() else {
9971 panic!("one request: {queries:?}");
9972 };
9973 assert!(
9974 query.contains(&("activity_types".into(), "OPASN,OPEXC,OPEXP,OPTRD".into())),
9975 "{query:?}"
9976 );
9977 }
9978
9979 #[tokio::test]
9982 async fn fetch_option_events_pairs_a_share_leg_by_its_side() {
9983 let day = "2024-03-01";
9984 let server = serve_json(serde_json::json!([
9985 option_activity("exc-call", "OPEXC", "QS240301C00006500", "-1", day),
9986 option_activity("asn-call", "OPASN", "QS240301C00006500", "1", day),
9987 share_leg("sold", "QS", "-100", "6.5", day),
9988 share_leg("bought", "QS", "100", "6.5", day),
9989 share_leg("bought-again", "QS", "100", "6.5", day),
9990 share_leg("other-day", "QS", "100", "6.5", "2024-02-29"),
9991 ]))
9992 .await;
9993 let client = client_for(&server);
9994
9995 let read = client
9996 .fetch_option_events(time("2024-02-29T00:00:00Z"), time("2024-03-01T23:00:00Z"))
9997 .await
9998 .unwrap();
9999
10000 let origin = |id: &str| {
10001 read.trades
10002 .iter()
10003 .find(|trade| trade.id == TradeId::new(id))
10004 .map(|trade| trade.origin)
10005 };
10006 assert_eq!(origin("sold"), Some(TradeOrigin::Assignment));
10007 assert_eq!(origin("bought"), Some(TradeOrigin::Exercise));
10008 assert_eq!(origin("bought-again"), Some(TradeOrigin::External));
10009 assert_eq!(origin("other-day"), Some(TradeOrigin::External));
10010 }
10011
10012 #[tokio::test]
10016 async fn fetch_option_events_pairs_a_share_leg_by_its_size() {
10017 let day = "2024-03-01";
10018 let server = serve_json(serde_json::json!([
10019 option_activity("asn-two", "OPASN", "QS240301P00006500", "2", day),
10021 option_activity("exc-one", "OPEXC", "QS240301C00006500", "-1", day),
10022 option_activity("exc-adjusted", "OPEXC", "QS240301C00006500", "-1", day),
10023 share_leg("hundred", "QS", "100", "6.5", day),
10024 share_leg("two-hundred", "QS", "200", "6.5", day),
10025 share_leg("hundred-fifty", "QS", "150", "6.5", day),
10026 ]))
10027 .await;
10028 let client = client_for(&server);
10029
10030 let read = client
10031 .fetch_option_events(time("2024-03-01T00:00:00Z"), time("2024-03-01T23:00:00Z"))
10032 .await
10033 .unwrap();
10034
10035 let origin = |id: &str| {
10036 read.trades
10037 .iter()
10038 .find(|trade| trade.id == TradeId::new(id))
10039 .map(|trade| trade.origin)
10040 };
10041 assert_eq!(origin("hundred"), Some(TradeOrigin::Exercise));
10042 assert_eq!(origin("two-hundred"), Some(TradeOrigin::Assignment));
10043 assert_eq!(origin("hundred-fifty"), Some(TradeOrigin::Exercise));
10044 }
10045
10046 #[tokio::test]
10050 async fn fetch_option_events_reads_whole_days_and_returns_the_span() {
10051 let mut early =
10052 option_activity("early", "OPEXP", "QS240301C00010500", "-1", "2024-03-01");
10053 early["created_at"] = serde_json::json!("2024-03-01T10:00:00Z");
10054 let mut inside =
10055 option_activity("inside", "OPEXP", "QS240301C00011500", "-1", "2024-03-01");
10056 inside["created_at"] = serde_json::json!("2024-03-01T16:00:00Z");
10057 let server = serve_json(serde_json::json!([
10058 option_activity(
10059 "dated-before",
10060 "OPEXP",
10061 "QS240229C00010500",
10062 "-1",
10063 "2024-02-29"
10064 ),
10065 early,
10066 option_activity("dated", "OPEXP", "QS240301C00012500", "-1", "2024-03-01"),
10067 inside,
10068 ]))
10069 .await;
10070 let client = client_for(&server);
10071
10072 let read = client
10073 .fetch_option_events(time("2024-03-01T15:00:00Z"), time("2024-03-01T17:00:00Z"))
10074 .await
10075 .unwrap();
10076
10077 let ids: Vec<_> = read
10078 .trades
10079 .iter()
10080 .map(|trade| trade.id.0.to_string())
10081 .collect();
10082 assert_eq!(ids, ["dated", "inside"]);
10083 let queries = query_pairs(&server).await;
10084 let [query] = queries.as_slice() else {
10085 panic!("one request: {queries:?}");
10086 };
10087 assert!(
10088 query.contains(&("after".into(), "2024-03-01T00:00:00.000Z".into())),
10089 "{query:?}"
10090 );
10091 assert!(
10092 query.contains(&("until".into(), "2024-03-02T00:00:00.000Z".into())),
10093 "{query:?}"
10094 );
10095 }
10096
10097 #[tokio::test]
10098 async fn fetch_option_events_reads_nothing_for_an_empty_span() {
10099 let server = MockServer::start().await;
10100 let client = client_for(&server);
10101
10102 let read = client
10103 .fetch_option_events(time("2024-03-02T00:00:00Z"), time("2024-03-01T00:00:00Z"))
10104 .await
10105 .unwrap();
10106
10107 assert!(read.trades.is_empty() && read.resume.is_none());
10108 assert!(query_pairs(&server).await.is_empty());
10109 }
10110
10111 async fn read_span(
10114 client: &AlpacaClient,
10115 start: DateTime<Utc>,
10116 end: DateTime<Utc>,
10117 instruments: &[InstrumentNameExchange],
10118 ) -> (std::collections::HashSet<String>, usize) {
10119 use crate::client::ExecutionClient;
10120
10121 let mut ids = std::collections::HashSet::new();
10122 let mut from = start;
10123 for calls in 1..=10 {
10124 let read = match client.fetch_trades(from, end, instruments).await {
10125 Ok(read) => read,
10126 Err(error) => panic!("call {calls} failed: {error:?}"),
10127 };
10128 ids.extend(read.trades.iter().map(|trade| trade.id.0.to_string()));
10129 match read.resume {
10130 Some(resume) => from = resume,
10131 None => return (ids, calls),
10132 }
10133 }
10134 panic!("the reads did not reach the span's end");
10135 }
10136
10137 #[tokio::test]
10143 async fn fetch_trades_reads_a_busy_span_completely_across_calls() {
10144 let base = time("2025-04-18T14:30:00Z");
10145 let mut activities = Vec::new();
10146 for ms in 0..4_000_i64 {
10147 let symbol = if ms % 2 == 0 { "SPY" } else { "QQQ" };
10148 for (k, micros) in [("a", 900), ("b", 500), ("c", 100)] {
10149 let at = base + TimeDelta::milliseconds(ms) + TimeDelta::microseconds(micros);
10150 activities.push(fill_at(&format!("{ms:017}::{ms}-{k}"), symbol, at));
10151 }
10152 }
10153 let server = serve_activities(activities).await;
10154 let client = client_for(&server);
10155 let end = time("2025-04-18T15:00:00Z");
10156
10157 let (ids, calls) = read_span(&client, recovery_after(), end, &[]).await;
10158 assert_eq!(ids.len(), 12_000, "every fill is read once or more");
10159 assert_eq!(calls, 3, "5,000 activities per call");
10160
10161 let spy = [InstrumentNameExchange::new("SPY")];
10162 let (ids, _) = read_span(&client, recovery_after(), end, &spy).await;
10163 assert_eq!(ids.len(), 6_000, "every SPY fill, and only those");
10164
10165 let Some(requests) = server.received_requests().await else {
10166 panic!("requests are recorded");
10167 };
10168 assert!(
10169 requests
10170 .iter()
10171 .all(|request| request.url.query_pairs().any(
10172 |(name, value)| name == "until" && value == "2025-04-18T15:00:00.001Z"
10173 )),
10174 "every request is bounded by the span's end"
10175 );
10176 }
10177
10178 #[tokio::test]
10181 async fn fetch_trades_says_where_to_read_on_after_a_call_without_matches() {
10182 use crate::client::ExecutionClient;
10183
10184 let server = MockServer::start().await;
10185 Mock::given(method("GET"))
10186 .and(path("/v2/account/activities"))
10187 .respond_with(
10188 ResponseTemplate::new(200)
10189 .set_body_json(make_activities_json(ALPACA_MAX_ACTIVITIES, "act")),
10190 )
10191 .mount(&server)
10192 .await;
10193
10194 let qqq = [InstrumentNameExchange::new("QQQ")];
10195 let Ok(read) = client_for(&server)
10196 .fetch_trades(recovery_after(), Utc::now(), &qqq)
10197 .await
10198 else {
10199 panic!("the read succeeds");
10200 };
10201 assert!(read.trades.is_empty(), "every fill read is SPY's");
10202 assert_eq!(read.resume, Some(time("2025-04-18T14:30:00Z")));
10203 }
10204
10205 #[tokio::test]
10208 async fn fetch_trades_that_cannot_advance_is_truncated() {
10209 use crate::client::ExecutionClient;
10210
10211 let server = MockServer::start().await;
10212 Mock::given(method("GET"))
10213 .and(path("/v2/account/activities"))
10214 .respond_with(
10215 ResponseTemplate::new(200)
10216 .set_body_json(make_activities_json(ALPACA_MAX_ACTIVITIES, "act")),
10217 )
10218 .mount(&server)
10219 .await;
10220
10221 let result = client_for(&server)
10222 .fetch_trades(time("2025-04-18T14:30:00.000500Z"), Utc::now(), &[])
10223 .await;
10224 assert!(
10225 matches!(
10226 result,
10227 Err(UnindexedClientError::Truncated { fills_read: 5_000 })
10228 ),
10229 "got {result:?}"
10230 );
10231 }
10232
10233 #[tokio::test]
10236 async fn fetch_trades_returns_only_the_span() {
10237 let (start, end) = (
10238 time("2025-04-18T14:30:00.123456Z"),
10239 time("2025-04-18T14:30:05.678901Z"),
10240 );
10241 let micro = TimeDelta::microseconds(1);
10242 let server = serve_activities(vec![
10243 fill_at("1::before", "SPY", start - micro),
10244 fill_at("2::start", "SPY", start),
10245 fill_at("3::end", "SPY", end),
10246 fill_at("4::after", "SPY", end + micro),
10247 ])
10248 .await;
10249
10250 let (ids, calls) = read_span(&client_for(&server), start, end, &[]).await;
10251 assert_eq!(calls, 1);
10252 let mut ids: Vec<_> = ids.into_iter().collect();
10253 ids.sort();
10254 assert_eq!(ids, ["end", "start"]);
10255 }
10256
10257 #[tokio::test]
10260 async fn fetch_trades_that_read_past_the_end_is_complete() {
10261 use crate::client::ExecutionClient;
10262
10263 let server = MockServer::start().await;
10264 Mock::given(method("GET"))
10266 .and(path("/v2/account/activities"))
10267 .respond_with(
10268 ResponseTemplate::new(200)
10269 .set_body_json(make_activities_json(ALPACA_MAX_ACTIVITIES, "act")),
10270 )
10271 .mount(&server)
10272 .await;
10273
10274 let Ok(read) = client_for(&server)
10275 .fetch_trades(recovery_after(), time("2025-04-18T14:29:00Z"), &[])
10276 .await
10277 else {
10278 panic!("the read succeeds");
10279 };
10280 assert_eq!(read, TradesRead::complete(Vec::new()));
10281 }
10282
10283 #[tokio::test]
10287 async fn fetch_trades_accepts_the_extreme_times() {
10288 use crate::client::ExecutionClient;
10289
10290 let server = serve_activities(vec![fill_at(
10291 "1::fill",
10292 "SPY",
10293 time("2025-04-18T14:30:00Z"),
10294 )])
10295 .await;
10296 let Ok(read) = client_for(&server)
10297 .fetch_trades(DateTime::<Utc>::MIN_UTC, DateTime::<Utc>::MAX_UTC, &[])
10298 .await
10299 else {
10300 panic!("the read succeeds");
10301 };
10302 assert_eq!(read.trades.len(), 1);
10303 assert_eq!(read.resume, None);
10304
10305 let Some(requests) = server.received_requests().await else {
10306 panic!("requests are recorded");
10307 };
10308 let [request] = requests.as_slice() else {
10309 panic!("one request: {requests:?}");
10310 };
10311 let query: std::collections::HashMap<_, _> =
10312 request.url.query_pairs().into_owned().collect();
10313 assert_eq!(
10314 query.get("after").map(String::as_str),
10315 Some("1970-01-01T00:00:00.000Z")
10316 );
10317 assert!(!query.contains_key("until"), "{query:?}");
10318
10319 let before_epoch = DateTime::UNIX_EPOCH - TimeDelta::days(1);
10321 let Ok(read) = client_for(&server)
10322 .fetch_trades(DateTime::<Utc>::MIN_UTC, before_epoch, &[])
10323 .await
10324 else {
10325 panic!("the read succeeds");
10326 };
10327 assert_eq!(read, TradesRead::complete(Vec::new()));
10328 assert_eq!(server.received_requests().await.map(|r| r.len()), Some(1));
10329 }
10330
10331 #[tokio::test]
10333 async fn fetch_trades_reads_nothing_for_an_empty_span() {
10334 use crate::client::ExecutionClient;
10335
10336 let server = MockServer::start().await;
10337 let start = recovery_after();
10338 let Ok(read) = client_for(&server)
10339 .fetch_trades(start, start - TimeDelta::seconds(1), &[])
10340 .await
10341 else {
10342 panic!("an empty span reads");
10343 };
10344 assert_eq!(read, TradesRead::complete(Vec::new()));
10345 assert_eq!(server.received_requests().await.map(|r| r.len()), Some(0));
10346 }
10347
10348 #[tokio::test]
10351 async fn recovery_reads_from_the_start_of_the_disconnects_millisecond() {
10352 let server = MockServer::start().await;
10353 Mock::given(method("GET"))
10354 .and(path("/v2/account/activities"))
10355 .and(wiremock::matchers::query_param(
10356 "after",
10357 "2025-01-01T00:00:00.123Z",
10358 ))
10359 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([])))
10360 .expect(1)
10361 .mount(&server)
10362 .await;
10363
10364 let (tx, _rx) = mpsc::unbounded_channel();
10365 let outcome = recover_fills(
10366 &reqwest::Client::new(),
10367 &RateLimitTracker::new(),
10368 &[],
10369 &server.uri(),
10370 time("2025-01-01T00:00:00.123456Z"),
10371 &tx,
10372 &new_dedup_cache(),
10373 &KnownLiveOrders::shared(ExchangeId::AlpacaBroker),
10374 )
10375 .await;
10376 assert_eq!(outcome, Ok(()));
10377 server.verify().await;
10378 }
10379
10380 fn dedup_keys(dedup: &SharedDedupCache) -> Vec<String> {
10382 let mut keys: Vec<_> = dedup
10383 .lock()
10384 .iter()
10385 .filter_map(|(key, _)| match key {
10386 FillKey::Cumulative(key) => Some(key.to_string()),
10387 FillKey::Execution(_) | FillKey::CumulativeWithoutId(_) => None,
10388 })
10389 .collect();
10390 keys.reverse();
10392 keys
10393 }
10394
10395 fn trade_of(
10396 event: &UnindexedAccountEvent,
10397 ) -> &Trade<AssetNameExchange, InstrumentNameExchange> {
10398 match &event.kind {
10399 AccountEventKind::Trade(t) => t,
10400 other => panic!("expected a Trade event, got {other:?}"),
10401 }
10402 }
10403
10404 #[tokio::test]
10407 async fn a_recovered_fill_reports_the_cumulative_alpaca_sent() {
10408 let events = drive_recover_fills(vec![
10409 activity_json("act-1", "ord-1", "2", Some("2")),
10410 activity_json("act-2", "ord-1", "3", Some("5")),
10411 ])
10412 .await;
10413
10414 let reported: Vec<_> = events
10415 .iter()
10416 .map(|e| trade_of(e).order_filled_quantity)
10417 .collect();
10418 assert_eq!(
10419 reported,
10420 vec![
10421 Some(Decimal::from_str("2").unwrap()),
10422 Some(Decimal::from_str("5").unwrap())
10423 ]
10424 );
10425 }
10426
10427 #[tokio::test]
10434 async fn the_dedup_key_uses_the_venue_cumulative_not_an_intra_batch_count() {
10435 let dedup = new_dedup_cache();
10437 drive_recover_fills_with(
10438 vec![activity_json("act-1", "ord-1", "2", Some("5"))],
10439 dedup.clone(),
10440 &KnownLiveOrders::shared(ExchangeId::AlpacaBroker),
10441 )
10442 .await;
10443
10444 assert_eq!(
10445 dedup_keys(&dedup),
10446 vec!["ord-1:5"],
10447 "counting executions within the batch would key this as ord-1:2"
10448 );
10449 }
10450
10451 async fn recover_after_ws_fill(
10455 execution_id: Option<&str>,
10456 activity_id: &str,
10457 ) -> Vec<UnindexedAccountEvent> {
10458 let dedup = new_dedup_cache();
10459 let update = AlpacaTradeUpdate {
10460 event: SmolStr::new("partial_fill"),
10461 execution_id,
10462 previous_execution_id: None,
10463 order: super::make_order_ws("ord-1", "SPY", "buy", "5"),
10464 price: Some("100.00"),
10465 qty: Some("2"),
10466 timestamp: None,
10467 };
10468 assert!(
10469 !is_duplicate_fill(&dedup, ws_execution_id(&update), early_dedup_key(&update)),
10470 "precondition: first sighting"
10471 );
10472
10473 drive_recover_fills_with(
10474 vec![activity_json(activity_id, "ord-1", "2", Some("5"))],
10475 dedup,
10476 &KnownLiveOrders::shared(ExchangeId::AlpacaBroker),
10477 )
10478 .await
10479 }
10480
10481 #[tokio::test]
10484 async fn a_fill_already_delivered_over_websocket_is_not_recovered_twice() {
10485 for (ws_execution_id, activity_id) in [
10486 (Some("exec-a"), "20250418103000000::exec-a"),
10487 (None, "20250418103000000::exec-a"),
10488 (Some("exec-a"), "act-1"),
10489 ] {
10490 let events = recover_after_ws_fill(ws_execution_id, activity_id).await;
10491 assert!(
10492 events.is_empty(),
10493 "a fill already delivered over WebSocket ({ws_execution_id:?}) must not be \
10494 re-delivered by recovery ({activity_id}), got {events:?}"
10495 );
10496 }
10497 }
10498
10499 async fn recover_after_correction(fields: &str) -> Vec<UnindexedAccountEvent> {
10502 let (tx, _rx) = mpsc::unbounded_channel();
10503 let dedup = new_dedup_cache();
10504 let known = KnownLiveOrders::shared(ExchangeId::AlpacaBroker);
10505 process_ws_text(
10506 &super::amendment_frame("trade_correct", fields),
10507 &tx,
10508 &dedup,
10509 &known,
10510 &mut ExponentialBackoff::new(),
10511 );
10512
10513 drive_recover_fills_with(
10514 vec![activity_json(
10515 "20250418103100000::exec-c",
10516 "ord-1",
10517 "2",
10518 Some("2"),
10519 )],
10520 dedup,
10521 &known,
10522 )
10523 .await
10524 }
10525
10526 #[tokio::test]
10529 async fn a_corrected_execution_is_not_recovered_again() {
10530 let events = recover_after_correction(
10531 r#""execution_id":"exec-c","previous_execution_id":"exec-a","price":"101.50","qty":"2","#,
10532 )
10533 .await;
10534 assert!(
10535 events.is_empty(),
10536 "the corrected execution was delivered as the replacement, got {events:?}"
10537 );
10538 }
10539
10540 #[tokio::test]
10542 async fn an_unresolved_correction_does_not_keep_its_execution_from_recovery() {
10543 let events = recover_after_correction(
10544 r#""execution_id":"exec-c","previous_execution_id":"exec-a","qty":"2","#,
10545 )
10546 .await;
10547 let ids: Vec<_> = events.iter().map(|e| trade_of(e).id.0.as_str()).collect();
10548 assert_eq!(ids, vec!["exec-c"]);
10549 }
10550
10551 #[tokio::test]
10554 async fn a_different_execution_at_a_cumulative_already_seen_is_recovered() {
10555 let events = recover_after_ws_fill(Some("exec-a"), "20250418103000000::exec-b").await;
10556 let ids: Vec<_> = events.iter().map(|e| trade_of(e).id.0.as_str()).collect();
10557 assert_eq!(ids, vec!["exec-b"]);
10558 }
10559
10560 #[tokio::test]
10563 async fn without_a_reported_cumulative_the_previous_behaviour_is_preserved() {
10564 let dedup = new_dedup_cache();
10565 let events = drive_recover_fills_with(
10566 vec![
10567 activity_json("act-1", "ord-1", "2", None),
10568 activity_json("act-2", "ord-1", "3", None),
10569 ],
10570 dedup.clone(),
10571 &KnownLiveOrders::shared(ExchangeId::AlpacaBroker),
10572 )
10573 .await;
10574
10575 assert_eq!(
10576 dedup_keys(&dedup),
10577 vec!["ord-1:2", "ord-1:5"],
10578 "intra-batch accumulation"
10579 );
10580 let ids: Vec<_> = events.iter().map(|e| trade_of(e).id.0.as_str()).collect();
10581 assert_eq!(ids, vec!["act-1", "act-2"], "each keeps its activity's id");
10582
10583 assert!(
10584 events
10585 .iter()
10586 .all(|e| trade_of(e).order_filled_quantity.is_none()),
10587 "a venue that reported no cumulative must not have one invented for it"
10588 );
10589 }
10590
10591 fn order_json(cid: &str, symbol: &str, status: &str, filled: &str) -> serde_json::Value {
10596 serde_json::json!({
10597 "id": format!("id-{cid}"),
10598 "client_order_id": cid,
10599 "symbol": symbol,
10600 "qty": "2",
10601 "filled_qty": filled,
10602 "filled_avg_price": if filled == "0" { None } else { Some("101.5") },
10603 "status": status,
10604 "side": "buy",
10605 "type": "limit",
10606 "time_in_force": "gtc",
10607 "limit_price": "100",
10608 "created_at": "2026-10-01T14:30:00Z",
10609 "updated_at": "2026-10-01T15:00:00Z"
10610 })
10611 }
10612
10613 async fn mount_lookup(server: &MockServer, cid: &str, order: ResponseTemplate, calls: u64) {
10615 use wiremock::matchers::query_param;
10616 Mock::given(method("GET"))
10617 .and(path("/v2/orders:by_client_order_id"))
10618 .and(query_param("client_order_id", cid))
10619 .respond_with(order)
10620 .expect(calls)
10621 .mount(server)
10622 .await;
10623 }
10624
10625 fn ok(body: serde_json::Value) -> ResponseTemplate {
10626 ResponseTemplate::new(200).set_body_json(body)
10627 }
10628
10629 fn alpaca_key(instrument: &str, cid: &str) -> UnindexedOrderKey {
10630 OrderKey::new(
10631 ExchangeId::AlpacaBroker,
10632 InstrumentNameExchange::new(instrument),
10633 StrategyId::new("strategy"),
10634 ClientOrderId::new(cid),
10635 )
10636 }
10637
10638 fn hold(known: &SharedKnownLiveOrders, key: &UnindexedOrderKey) {
10639 let open = Open::new(
10640 VenueOrderId::Assigned(OrderId::new(format!("id-{}", key.cid))),
10641 Utc::now(),
10642 Decimal::ZERO,
10643 );
10644 known
10645 .lock()
10646 .live(key, Decimal::TWO, OrderKind::Limit, &open);
10647 }
10648
10649 #[tokio::test]
10650 async fn fetch_ended_orders_reads_each_end_alpaca_reports() {
10651 use rust_decimal_macros::dec;
10652
10653 let server = MockServer::start().await;
10654 for (cid, status, filled) in [
10655 ("filled", "filled", "2"),
10656 ("canceled", "canceled", "1"),
10657 ("replaced", "replaced", "0"),
10658 ("expired", "expired", "1"),
10659 ("rejected", "rejected", "0"),
10660 ("done", "done_for_day", "1"),
10661 ("live", "new", "0"),
10662 ("unknown-status", "frozen", "0"),
10663 ] {
10664 mount_lookup(&server, cid, ok(order_json(cid, "SPY", status, filled)), 1).await;
10665 }
10666 mount_lookup(
10667 &server,
10668 "elsewhere",
10669 ok(order_json("elsewhere", "QQQ", "canceled", "0")),
10670 1,
10671 )
10672 .await;
10673 mount_lookup(
10674 &server,
10675 "gone",
10676 ResponseTemplate::new(404).set_body_json(serde_json::json!({
10677 "code": 40410000,
10678 "message": "order not found for gone"
10679 })),
10680 1,
10681 )
10682 .await;
10683
10684 let keys: Vec<_> = [
10685 "filled",
10686 "canceled",
10687 "replaced",
10688 "expired",
10689 "rejected",
10690 "done",
10691 "live",
10692 "unknown-status",
10693 "elsewhere",
10694 "gone",
10695 ]
10696 .into_iter()
10697 .map(|cid| alpaca_key("spy", cid))
10698 .collect();
10699 let ended = client_for(&server).fetch_ended_orders(&keys).await.unwrap();
10700
10701 let mut found: Vec<_> = ended
10702 .iter()
10703 .map(|order| (order.key.cid.0.as_str(), &order.state))
10704 .collect();
10705 found.sort_by_key(|(cid, _)| *cid);
10706 let [
10707 (canceled, InactiveOrderState::Cancelled(cancelled)),
10708 (expired, InactiveOrderState::Expired(expiry)),
10709 (filled, InactiveOrderState::FullyFilled(fill)),
10710 (rejected, InactiveOrderState::OpenFailed(failed)),
10711 (replaced, InactiveOrderState::Cancelled(replacement)),
10712 ] = found.as_slice()
10713 else {
10714 panic!("filled, canceled, replaced, expired and rejected: {found:?}");
10715 };
10716 assert_eq!(
10717 [*canceled, *expired, *filled, *rejected, *replaced],
10718 ["canceled", "expired", "filled", "rejected", "replaced"]
10719 );
10720 assert_eq!(
10721 (fill.filled_quantity, fill.avg_price),
10722 (dec!(2), Some(dec!(101.5)))
10723 );
10724 assert_eq!(
10725 cancelled.filled_quantity,
10726 Some(dec!(1)),
10727 "what filled before"
10728 );
10729 assert_eq!(expiry.filled_quantity, Some(dec!(1)));
10730 assert_eq!(replacement.filled_quantity, Some(Decimal::ZERO));
10731 assert_eq!(
10732 cancelled.time_exchange,
10733 parse_timestamp("2026-10-01T15:00:00Z").unwrap(),
10734 "stamped when the order last changed"
10735 );
10736 assert!(matches!(
10737 failed,
10738 OrderError::Rejected(ApiError::OrderRejected(message))
10739 if message.contains("id-rejected")
10740 ));
10741 assert!(
10742 ended
10743 .iter()
10744 .all(|order| order.key.strategy == StrategyId::new("strategy")
10745 && order.key.instrument == InstrumentNameExchange::new("spy")),
10746 "each carries the key it was asked for"
10747 );
10748 }
10749
10750 #[tokio::test]
10751 async fn fetch_ended_orders_fails_when_a_lookup_fails() {
10752 let server = MockServer::start().await;
10753 mount_lookup(&server, "a", ok(order_json("a", "SPY", "canceled", "0")), 1).await;
10754 mount_lookup(&server, "b", ResponseTemplate::new(500), 1).await;
10755
10756 let result = client_for(&server)
10757 .fetch_ended_orders(&[alpaca_key("SPY", "a"), alpaca_key("SPY", "b")])
10758 .await;
10759
10760 assert!(
10761 matches!(result, Err(UnindexedClientError::Connectivity(_))),
10762 "{result:?}"
10763 );
10764 }
10765
10766 #[tokio::test]
10769 async fn a_reconnect_check_lists_once_and_reports_the_orders_that_ended() {
10770 use wiremock::matchers::query_param;
10771
10772 let server = MockServer::start().await;
10773 Mock::given(method("GET"))
10774 .and(path("/v2/orders"))
10775 .and(query_param("status", "open"))
10776 .and(query_param("symbols", "QQQ,SPY"))
10777 .respond_with(ok(serde_json::json!([order_json(
10778 "spy-listed",
10779 "SPY",
10780 "new",
10781 "0"
10782 )])))
10783 .expect(1)
10784 .mount(&server)
10785 .await;
10786 mount_lookup(&server, "spy-listed", ResponseTemplate::new(500), 0).await;
10787 mount_lookup(
10788 &server,
10789 "spy-cancelled",
10790 ok(order_json("spy-cancelled", "SPY", "canceled", "1")),
10791 1,
10792 )
10793 .await;
10794 mount_lookup(
10795 &server,
10796 "spy-done",
10797 ok(order_json("spy-done", "SPY", "done_for_day", "1")),
10798 1,
10799 )
10800 .await;
10801 mount_lookup(
10802 &server,
10803 "qqq-filled",
10804 ok(order_json("qqq-filled", "QQQ", "filled", "2")),
10805 1,
10806 )
10807 .await;
10808
10809 let known = KnownLiveOrders::shared(ExchangeId::AlpacaBroker);
10810 let [listed, cancelled, done, filled] = [
10811 ("SPY", "spy-listed"),
10812 ("SPY", "spy-cancelled"),
10813 ("SPY", "spy-done"),
10814 ("QQQ", "qqq-filled"),
10815 ]
10816 .map(|(instrument, cid)| alpaca_key(instrument, cid));
10817 for key in [&listed, &cancelled, &done, &filled] {
10818 hold(&known, key);
10819 }
10820 let mut unchecked = UncheckedOrders::default();
10821 unchecked.open(known.lock().instruments());
10822 let config = Arc::new(AlpacaConfig::with_base_url(
10823 "test-key".into(),
10824 "test-secret".into(),
10825 server.uri(),
10826 ));
10827 let (tx, mut rx) = mpsc::unbounded_channel();
10828
10829 recover_alpaca_ended_orders(
10830 &reqwest::Client::new(),
10831 &Arc::new(RateLimitTracker::new()),
10832 &config,
10833 &known,
10834 &mut unchecked,
10835 &tx,
10836 )
10837 .await;
10838
10839 let mut reported: Vec<_> = std::iter::from_fn(|| rx.try_recv().ok())
10840 .map(|event| {
10841 let AccountEventKind::OrderSnapshot(
10842 rustrade_integration::collection::snapshot::Snapshot(order),
10843 ) = event.kind
10844 else {
10845 panic!("an order snapshot: {event:?}");
10846 };
10847 assert!(matches!(order.state, OrderState::Inactive(_)), "{order:?}");
10848 order.key.cid
10849 })
10850 .collect();
10851 reported.sort();
10852 assert_eq!(reported, [filled.cid.clone(), cancelled.cid.clone()]);
10853 let known = known.lock();
10854 assert!(known.contains(&listed.cid) && known.contains(&done.cid));
10855 assert!(!known.contains(&cancelled.cid) && !known.contains(&filled.cid));
10856 assert!(unchecked.is_empty(), "both instruments checked");
10857 }
10858
10859 #[tokio::test]
10861 async fn a_failed_listing_is_retried_for_every_instrument_in_it() {
10862 let server = MockServer::start().await;
10863 Mock::given(method("GET"))
10864 .and(path("/v2/orders"))
10865 .respond_with(ResponseTemplate::new(503))
10866 .expect(1)
10867 .mount(&server)
10868 .await;
10869
10870 let known = KnownLiveOrders::shared(ExchangeId::AlpacaBroker);
10871 hold(&known, &alpaca_key("SPY", "a"));
10872 hold(&known, &alpaca_key("QQQ", "b"));
10873 let mut unchecked = UncheckedOrders::default();
10874 unchecked.open(known.lock().instruments());
10875 let config = Arc::new(AlpacaConfig::with_base_url(
10876 "test-key".into(),
10877 "test-secret".into(),
10878 server.uri(),
10879 ));
10880 let (tx, mut rx) = mpsc::unbounded_channel();
10881
10882 recover_alpaca_ended_orders(
10883 &reqwest::Client::new(),
10884 &Arc::new(RateLimitTracker::new()),
10885 &config,
10886 &known,
10887 &mut unchecked,
10888 &tx,
10889 )
10890 .await;
10891
10892 assert!(rx.try_recv().is_err(), "nothing reported");
10893 let now = tokio::time::Instant::now();
10894 assert!(
10895 unchecked.ready(&NoPendingFills, now).is_empty(),
10896 "both wait"
10897 );
10898 for instrument in ["QQQ", "SPY"] {
10899 assert_eq!(
10900 unchecked.failed(
10901 &InstrumentNameExchange::new(instrument),
10902 now,
10903 Duration::ZERO
10904 ),
10905 Some(crate::client::order_recovery::GapFailure::Retry(
10906 Duration::from_secs(crate::client::order_recovery::GAP_RETRY_BASE_SECS * 2)
10907 )),
10908 "{instrument} has failed once already"
10909 );
10910 }
10911 assert_eq!(known.lock().instruments().len(), 2, "both still held");
10912 }
10913
10914 #[tokio::test]
10915 async fn placing_listing_and_cancelling_keep_the_held_orders_in_step() {
10916 use crate::order::request::{OrderRequestCancel, RequestCancel, RequestOpen};
10917
10918 let server = MockServer::start().await;
10919 Mock::given(method("POST"))
10920 .and(path("/v2/orders"))
10921 .respond_with(ok(order_json("resting", "SPY", "new", "0")))
10922 .up_to_n_times(1)
10923 .mount(&server)
10924 .await;
10925 Mock::given(method("POST"))
10926 .and(path("/v2/orders"))
10927 .respond_with(ok(order_json("filled", "SPY", "filled", "2")))
10928 .mount(&server)
10929 .await;
10930 Mock::given(method("GET"))
10931 .and(path("/v2/orders"))
10932 .respond_with(ok(serde_json::json!([order_json(
10933 "listed", "QQQ", "new", "0"
10934 )])))
10935 .mount(&server)
10936 .await;
10937 Mock::given(method("DELETE"))
10938 .and(path("/v2/orders/id-resting"))
10939 .respond_with(ResponseTemplate::new(204))
10940 .mount(&server)
10941 .await;
10942 let client = client_for(&server);
10943 let spy = InstrumentNameExchange::new("SPY");
10944
10945 for cid in ["resting", "filled"] {
10946 let request = OrderRequestOpen {
10947 key: OrderKey::new(
10948 ExchangeId::AlpacaBroker,
10949 &spy,
10950 StrategyId::new("strategy"),
10951 ClientOrderId::new(cid),
10952 ),
10953 state: RequestOpen {
10954 side: Side::Buy,
10955 price: Some(Decimal::ONE_HUNDRED),
10956 quantity: Decimal::TWO,
10957 kind: OrderKind::Limit,
10958 time_in_force: TimeInForce::GoodUntilCancelled { post_only: false },
10959 position_id: None,
10960 reduce_only: false,
10961 market: None,
10962 },
10963 };
10964 client.open_order(request).await;
10965 }
10966 client.fetch_open_orders(&[]).await.unwrap();
10967 {
10968 let known = client.known_live.lock();
10969 assert!(known.contains(&ClientOrderId::new("resting")));
10970 assert!(
10971 !known.contains(&ClientOrderId::new("filled")),
10972 "filled in its placement response"
10973 );
10974 assert!(known.contains(&ClientOrderId::new("listed")));
10975 }
10976
10977 let cancel = client
10978 .cancel_order(OrderRequestCancel {
10979 key: OrderKey::new(
10980 ExchangeId::AlpacaBroker,
10981 &spy,
10982 StrategyId::new("strategy"),
10983 ClientOrderId::new("resting"),
10984 ),
10985 state: RequestCancel {
10986 id: Some(VenueOrderId::Assigned(OrderId::new("id-resting"))),
10987 },
10988 })
10989 .await;
10990 let Ok(cancelled) = &cancel.state else {
10991 panic!("expected Ok, got {cancel:?}");
10992 };
10993 assert_eq!(
10994 cancelled.filled_quantity, None,
10995 "the DELETE response has no body, so what filled is unknown"
10996 );
10997 assert!(
10998 client
10999 .known_live
11000 .lock()
11001 .contains(&ClientOrderId::new("resting")),
11002 "a cancel Alpaca has only accepted has not ended the order"
11003 );
11004 }
11005
11006 #[tokio::test]
11013 async fn an_order_that_ended_in_its_placement_response_is_reported_as_it_ended() {
11014 use crate::order::request::RequestOpen;
11015
11016 let Some(time) = parse_timestamp("2026-10-01T15:00:00Z") else {
11017 panic!("order_json's updated_at parses");
11018 };
11019 let id = |cid: &str| OrderId::new(format!("id-{cid}"));
11020 let open = |cid: &str, filled| {
11021 OrderState::active(Open::new(VenueOrderId::Assigned(id(cid)), time, filled))
11022 };
11023 let cases: [(&str, &str, &str, UnindexedOrderState, bool); 13] = [
11024 (
11025 "ioc-unfilled",
11026 "expired",
11027 "0",
11028 OrderState::expired(Expired::new(
11029 id("ioc-unfilled"),
11030 time,
11031 Some(Decimal::ZERO),
11032 )),
11033 false,
11034 ),
11035 (
11036 "ioc-partial",
11037 "expired",
11038 "1",
11039 OrderState::expired(Expired::new(id("ioc-partial"), time, Some(Decimal::ONE))),
11040 false,
11041 ),
11042 (
11043 "fill-unknown",
11044 "expired",
11045 "not-a-number",
11046 OrderState::expired(Expired::new(id("fill-unknown"), time, None)),
11047 false,
11048 ),
11049 (
11050 "cancelled",
11051 "canceled",
11052 "1",
11053 OrderState::inactive(Cancelled::new(id("cancelled"), time, Some(Decimal::ONE))),
11054 false,
11055 ),
11056 (
11057 "rejected",
11058 "rejected",
11059 "0",
11060 OrderState::inactive(OrderError::Rejected(ApiError::OrderRejected(
11061 "Alpaca rejected order id-rejected after accepting it".to_string(),
11062 ))),
11063 false,
11064 ),
11065 (
11066 "filled",
11067 "filled",
11068 "2",
11069 OrderState::fully_filled(Filled::new(
11070 id("filled"),
11071 time,
11072 Decimal::TWO,
11073 Some(Decimal::new(1015, 1)),
11074 )),
11075 false,
11076 ),
11077 (
11078 "replaced",
11079 "replaced",
11080 "0",
11081 OrderState::inactive(Cancelled::new(id("replaced"), time, Some(Decimal::ZERO))),
11082 false,
11083 ),
11084 (
11085 "live-covered",
11086 "partially_filled",
11087 "2",
11088 OrderState::fully_filled(Filled::new(
11089 id("live-covered"),
11090 time,
11091 Decimal::TWO,
11092 Some(Decimal::new(1015, 1)),
11093 )),
11094 false,
11095 ),
11096 ("new", "new", "0", open("new", Decimal::ZERO), true),
11097 (
11098 "live-fill-unknown",
11099 "new",
11100 "not-a-number",
11101 open("live-fill-unknown", Decimal::ZERO),
11102 true,
11103 ),
11104 (
11105 "partial",
11106 "partially_filled",
11107 "1",
11108 open("partial", Decimal::ONE),
11109 true,
11110 ),
11111 (
11112 "for-the-day",
11113 "done_for_day",
11114 "1",
11115 open("for-the-day", Decimal::ONE),
11116 true,
11117 ),
11118 (
11119 "unknown",
11120 "a_status_not_yet_documented",
11121 "0",
11122 open("unknown", Decimal::ZERO),
11123 true,
11124 ),
11125 ];
11126
11127 for (cid, status, filled, expected, held) in cases {
11128 for bracket in [false, true] {
11129 let server = MockServer::start().await;
11130 Mock::given(method("POST"))
11131 .and(path("/v2/orders"))
11132 .respond_with(ok(order_json(cid, "SPY", status, filled)))
11133 .expect(1)
11134 .mount(&server)
11135 .await;
11136 let client = client_for(&server);
11137 let spy = InstrumentNameExchange::new("SPY");
11138 let time_in_force = TimeInForce::GoodUntilCancelled { post_only: false };
11139
11140 let state = if bracket {
11141 client
11142 .open_bracket_order(AlpacaBracketOrderRequest::new(
11143 spy,
11144 StrategyId::new("strategy"),
11145 ClientOrderId::new(cid),
11146 Side::Buy,
11147 Decimal::TWO,
11148 Decimal::ONE_HUNDRED,
11149 Decimal::new(120, 0),
11150 Decimal::new(90, 0),
11151 time_in_force,
11152 ))
11153 .await
11154 .parent
11155 .state
11156 } else {
11157 let request = OrderRequestOpen {
11158 key: OrderKey::new(
11159 ExchangeId::AlpacaBroker,
11160 &spy,
11161 StrategyId::new("strategy"),
11162 ClientOrderId::new(cid),
11163 ),
11164 state: RequestOpen {
11165 side: Side::Buy,
11166 price: Some(Decimal::ONE_HUNDRED),
11167 quantity: Decimal::TWO,
11168 kind: OrderKind::Limit,
11169 time_in_force,
11170 position_id: None,
11171 reduce_only: false,
11172 market: None,
11173 },
11174 };
11175 client.open_order(request).await.state
11176 };
11177
11178 assert_eq!(state, expected, "{status} {filled}, bracket: {bracket}");
11179 assert_eq!(
11180 client.known_live.lock().contains(&ClientOrderId::new(cid)),
11181 held,
11182 "{status} {filled}, bracket: {bracket}"
11183 );
11184 }
11185 }
11186 }
11187
11188 #[test]
11191 fn the_stream_keeps_the_held_orders_in_step() {
11192 let (tx, mut rx) = mpsc::unbounded_channel();
11193 let dedup = new_dedup_cache();
11194 let known = KnownLiveOrders::shared(ExchangeId::AlpacaBroker);
11195 let mut backoff = ExponentialBackoff::new();
11196 let frame = |event: &str, filled: &str| {
11197 format!(
11198 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}"}}}}}}"#
11199 )
11200 };
11201 let cid = ClientOrderId::new("a");
11202
11203 process_ws_text(&frame("new", "0"), &tx, &dedup, &known, &mut backoff);
11204 assert!(known.lock().contains(&cid), "acknowledged");
11205
11206 process_ws_text(
11207 &frame("done_for_day", "1"),
11208 &tx,
11209 &dedup,
11210 &known,
11211 &mut backoff,
11212 );
11213 assert!(known.lock().contains(&cid), "done for the day is not ended");
11214
11215 process_ws_text(&frame("canceled", "1"), &tx, &dedup, &known, &mut backoff);
11216 assert!(!known.lock().contains(&cid), "cancelled");
11217
11218 let events: Vec<_> = std::iter::from_fn(|| rx.try_recv().ok()).collect();
11219 let [_, done, cancelled] = events.as_slice() else {
11220 panic!("three events: {events:?}");
11221 };
11222 let AccountEventKind::OrderSnapshot(
11223 rustrade_integration::collection::snapshot::Snapshot(order),
11224 ) = &done.kind
11225 else {
11226 panic!("done_for_day reports an order snapshot: {done:?}");
11227 };
11228 let OrderState::Active(ActiveOrderState::Open(open)) = &order.state else {
11229 panic!("open: {order:?}");
11230 };
11231 assert_eq!(open.filled_quantity, Decimal::ONE, "with what it filled");
11232 assert!(matches!(
11233 cancelled.kind,
11234 AccountEventKind::OrderCancelled(_)
11235 ));
11236 }
11237
11238 #[tokio::test]
11241 async fn a_recovered_fill_that_completes_an_order_stops_it_being_held() {
11242 let known = KnownLiveOrders::shared(ExchangeId::AlpacaBroker);
11243 let key = alpaca_key("SPY", "a");
11244 known.lock().live(
11245 &key,
11246 Decimal::TWO,
11247 OrderKind::Limit,
11248 &Open::new(
11249 VenueOrderId::Assigned(OrderId::new("ord-1")),
11250 Utc::now(),
11251 Decimal::ZERO,
11252 ),
11253 );
11254
11255 drive_recover_fills_with(
11256 vec![activity_json("act-1", "ord-1", "1", Some("1"))],
11257 new_dedup_cache(),
11258 &known,
11259 )
11260 .await;
11261 assert!(known.lock().contains(&key.cid), "half filled");
11262
11263 drive_recover_fills_with(
11264 vec![activity_json("act-2", "ord-1", "1", Some("2"))],
11265 new_dedup_cache(),
11266 &known,
11267 )
11268 .await;
11269 assert!(!known.lock().contains(&key.cid), "filled");
11270 }
11271
11272 #[tokio::test]
11275 async fn the_check_loop_runs_at_once_then_waits_out_a_failure() {
11276 let server = MockServer::start().await;
11277 Mock::given(method("GET"))
11278 .and(path("/v2/orders"))
11279 .respond_with(ResponseTemplate::new(503))
11280 .expect(1)
11281 .mount(&server)
11282 .await;
11283 let known = KnownLiveOrders::shared(ExchangeId::AlpacaBroker);
11284 hold(&known, &alpaca_key("SPY", "a"));
11285 let mut unchecked = UncheckedOrders::default();
11286 unchecked.open(known.lock().instruments());
11287 let config = Arc::new(AlpacaConfig::with_base_url(
11288 "test-key".into(),
11289 "test-secret".into(),
11290 server.uri(),
11291 ));
11292 let (tx, _rx) = mpsc::unbounded_channel();
11293
11294 let ran = tokio::time::timeout(
11295 Duration::from_secs(1),
11296 run_order_checks(
11297 &reqwest::Client::new(),
11298 &Arc::new(RateLimitTracker::new()),
11299 &config,
11300 &known,
11301 &mut unchecked,
11302 &tx,
11303 ),
11304 )
11305 .await;
11306
11307 assert!(ran.is_err(), "the loop never completes");
11308 assert!(
11309 unchecked.contains(&InstrumentNameExchange::new("SPY")),
11310 "SPY waits for its retry"
11311 );
11312 assert_eq!(
11313 server.received_requests().await.unwrap().len(),
11314 1,
11315 "the failed check is not run again at once"
11316 );
11317 }
11318
11319 #[tokio::test]
11321 async fn the_check_loop_waits_once_the_consumer_has_gone() {
11322 let server = MockServer::start().await;
11323 Mock::given(method("GET"))
11324 .respond_with(ResponseTemplate::new(503))
11325 .expect(0)
11326 .mount(&server)
11327 .await;
11328 let known = KnownLiveOrders::shared(ExchangeId::AlpacaBroker);
11329 hold(&known, &alpaca_key("SPY", "a"));
11330 let mut unchecked = UncheckedOrders::default();
11331 unchecked.open(known.lock().instruments());
11332 let config = Arc::new(AlpacaConfig::with_base_url(
11333 "test-key".into(),
11334 "test-secret".into(),
11335 server.uri(),
11336 ));
11337 let (tx, rx) = mpsc::unbounded_channel();
11338 drop(rx);
11339
11340 let ran = tokio::time::timeout(
11341 Duration::from_millis(200),
11342 run_order_checks(
11343 &reqwest::Client::new(),
11344 &Arc::new(RateLimitTracker::new()),
11345 &config,
11346 &known,
11347 &mut unchecked,
11348 &tx,
11349 ),
11350 )
11351 .await;
11352
11353 assert!(ran.is_err(), "the loop never completes");
11354 assert!(
11355 server.received_requests().await.unwrap().is_empty(),
11356 "nothing asked once the consumer has gone"
11357 );
11358 }
11359
11360 async fn shortability_with_asset(
11361 symbol: &str,
11362 asset: serde_json::Value,
11363 ) -> (Result<Shortability, UnindexedClientError>, Vec<String>) {
11364 use crate::client::ShortabilityClient;
11365
11366 let server = MockServer::start().await;
11367 Mock::given(method("GET"))
11368 .respond_with(ResponseTemplate::new(200).set_body_json(asset))
11369 .mount(&server)
11370 .await;
11371 let result = client_for(&server)
11372 .fetch_shortability(&InstrumentNameExchange::new(symbol))
11373 .await;
11374 let paths = server
11375 .received_requests()
11376 .await
11377 .unwrap()
11378 .iter()
11379 .map(|request| request.url.path().to_owned())
11380 .collect();
11381 (result, paths)
11382 }
11383
11384 #[tokio::test]
11385 async fn an_easy_to_borrow_stock_is_shortable_at_no_fee() {
11386 let (result, paths) = shortability_with_asset(
11387 "AAPL",
11388 serde_json::json!({
11389 "class": "us_equity",
11390 "symbol": "AAPL",
11391 "shortable": true,
11392 "easy_to_borrow": true,
11393 "borrow_status": "easy_to_borrow",
11394 }),
11395 )
11396 .await;
11397
11398 assert_eq!(
11399 result.unwrap(),
11400 Shortability::new()
11401 .with_shortable(true)
11402 .with_easy_to_borrow(true)
11403 .with_fee_rate(Decimal::ZERO)
11404 );
11405 assert_eq!(paths, vec!["/v2/assets/AAPL"]);
11406 }
11407
11408 #[tokio::test]
11409 async fn a_hard_to_borrow_stock_has_no_known_fee() {
11410 let (result, _) = shortability_with_asset(
11411 "GME",
11412 serde_json::json!({
11413 "class": "us_equity",
11414 "shortable": true,
11415 "easy_to_borrow": true,
11416 "borrow_status": "hard_to_borrow",
11417 }),
11418 )
11419 .await;
11420
11421 assert_eq!(
11422 result.unwrap(),
11423 Shortability::new()
11424 .with_shortable(true)
11425 .with_easy_to_borrow(false),
11426 "borrow_status wins over the easy_to_borrow flag, and a hard-to-borrow fee is unknown"
11427 );
11428 }
11429
11430 #[tokio::test]
11431 async fn without_a_borrow_status_the_easy_to_borrow_flag_is_read() {
11432 let (result, _) = shortability_with_asset(
11433 "AAPL",
11434 serde_json::json!({ "class": "us_equity", "shortable": false, "easy_to_borrow": false }),
11435 )
11436 .await;
11437
11438 assert_eq!(
11439 result.unwrap(),
11440 Shortability::new()
11441 .with_shortable(false)
11442 .with_easy_to_borrow(false)
11443 );
11444 }
11445
11446 #[tokio::test]
11447 async fn an_asset_without_borrow_fields_is_unknown_but_for_shortable() {
11448 let (result, paths) = shortability_with_asset(
11449 "BTC/USD",
11450 serde_json::json!({ "class": "crypto", "symbol": "BTC/USD", "shortable": false }),
11451 )
11452 .await;
11453
11454 assert_eq!(result.unwrap(), Shortability::new().with_shortable(false));
11455 assert_eq!(
11456 paths,
11457 vec!["/v2/assets/BTC%2FUSD"],
11458 "a crypto pair's slash is escaped, not read as a path separator"
11459 );
11460 }
11461
11462 #[tokio::test]
11463 async fn an_option_has_no_shortability() {
11464 let (result, _) = shortability_with_asset(
11465 "AAPL250117C00150000",
11466 serde_json::json!({ "class": "us_option", "shortable": true }),
11467 )
11468 .await;
11469
11470 assert!(
11471 matches!(
11472 result,
11473 Err(UnindexedClientError::Api(ApiError::InstrumentInvalid(ref instrument, ref reason)))
11474 if instrument.name().as_str() == "AAPL250117C00150000"
11475 && reason.contains("option")
11476 ),
11477 "{result:?}"
11478 );
11479 }
11480
11481 #[tokio::test]
11482 async fn an_unknown_symbol_is_an_invalid_instrument() {
11483 use crate::client::ShortabilityClient;
11484
11485 let server = MockServer::start().await;
11486 Mock::given(method("GET"))
11487 .and(path("/v2/assets/NOPE"))
11488 .respond_with(
11489 ResponseTemplate::new(404).set_body_json(
11490 serde_json::json!({ "message": "asset not found for NOPE" }),
11491 ),
11492 )
11493 .mount(&server)
11494 .await;
11495
11496 let result = client_for(&server)
11497 .fetch_shortability(&InstrumentNameExchange::new("NOPE"))
11498 .await;
11499
11500 assert!(
11501 matches!(
11502 result,
11503 Err(UnindexedClientError::Api(ApiError::InstrumentInvalid(ref instrument, _)))
11504 if instrument.name().as_str() == "NOPE"
11505 ),
11506 "{result:?}"
11507 );
11508 }
11509
11510 struct RateLimited {
11513 call: std::sync::atomic::AtomicU32,
11514 last: Option<(&'static str, String)>,
11515 }
11516
11517 impl RateLimited {
11518 fn mount(last: Option<(&'static str, String)>) -> Self {
11519 Self {
11520 call: std::sync::atomic::AtomicU32::new(0),
11521 last,
11522 }
11523 }
11524 }
11525
11526 impl Respond for RateLimited {
11527 fn respond(&self, _: &Request) -> ResponseTemplate {
11528 let call = self.call.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1;
11529 let response = ResponseTemplate::new(429)
11530 .set_body_json(serde_json::json!({"message": "too many requests"}));
11531 if call < MAX_RATE_LIMIT_ATTEMPTS {
11532 return response.insert_header("retry-after", "0");
11533 }
11534 match &self.last {
11535 Some((name, value)) => response.insert_header(*name, value.as_str()),
11536 None => response,
11537 }
11538 }
11539 }
11540
11541 fn unix_now() -> u64 {
11542 std::time::SystemTime::now()
11543 .duration_since(std::time::UNIX_EPOCH)
11544 .unwrap()
11545 .as_secs()
11546 }
11547
11548 #[tokio::test]
11549 async fn exhausted_429_retries_report_alpacas_wait() {
11550 let server = MockServer::start().await;
11551 Mock::given(method("GET"))
11552 .and(path("/v2/account"))
11553 .respond_with(RateLimited::mount(Some(("retry-after", "7".to_string()))))
11554 .mount(&server)
11555 .await;
11556
11557 let http = reqwest::Client::new();
11558 let rl = RateLimitTracker::new();
11559 let url = format!("{}/v2/account", server.uri());
11560 let error = rest_with_retry::<serde_json::Value>(&rl, || http.get(&url))
11561 .await
11562 .unwrap_err();
11563
11564 let UnindexedClientError::Api(error) = error else {
11565 panic!("expected Api, got {error:?}");
11566 };
11567 assert_eq!(error.retry_after(), Some(Duration::from_secs(7)));
11568 let blocked_until = rl
11569 .blocked_until
11570 .lock()
11571 .expect("the last refusal pauses the client");
11572 assert!(
11573 blocked_until >= tokio::time::Instant::now() + Duration::from_secs(6),
11574 "the client's next request waits for Alpaca's wait"
11575 );
11576 assert_eq!(
11577 server.received_requests().await.unwrap().len(),
11578 MAX_RATE_LIMIT_ATTEMPTS as usize
11579 );
11580 }
11581
11582 #[tokio::test]
11583 async fn exhausted_429_retries_read_the_reset_time_without_retry_after() {
11584 let server = MockServer::start().await;
11585 Mock::given(method("GET"))
11586 .and(path("/v2/account"))
11587 .respond_with(RateLimited::mount(Some((
11588 "x-ratelimit-reset",
11589 (unix_now() + 30).to_string(),
11590 ))))
11591 .mount(&server)
11592 .await;
11593
11594 let http = reqwest::Client::new();
11595 let rl = RateLimitTracker::new();
11596 let url = format!("{}/v2/account", server.uri());
11597 let error = rest_with_retry::<serde_json::Value>(&rl, || http.get(&url))
11598 .await
11599 .unwrap_err();
11600
11601 let UnindexedClientError::Api(ApiError::RateLimit {
11602 retry_after: Some(wait),
11603 ..
11604 }) = error
11605 else {
11606 panic!("expected a RateLimit with a wait, got {error:?}");
11607 };
11608 assert!(
11609 (Duration::from_secs(29)..=Duration::from_secs(30)).contains(&wait),
11610 "{wait:?}"
11611 );
11612 }
11613
11614 #[tokio::test]
11615 async fn exhausted_429_retries_without_a_hint_report_none() {
11616 let server = MockServer::start().await;
11617 Mock::given(method("GET"))
11618 .and(path("/v2/account"))
11619 .respond_with(RateLimited::mount(None))
11620 .mount(&server)
11621 .await;
11622
11623 let http = reqwest::Client::new();
11624 let rl = RateLimitTracker::new();
11625 let url = format!("{}/v2/account", server.uri());
11626 let error = rest_with_retry::<serde_json::Value>(&rl, || http.get(&url))
11627 .await
11628 .unwrap_err();
11629
11630 assert!(
11631 matches!(
11632 error,
11633 UnindexedClientError::Api(ApiError::RateLimit {
11634 retry_after: None,
11635 ..
11636 })
11637 ),
11638 "{error:?}"
11639 );
11640 }
11641
11642 #[tokio::test]
11643 async fn an_exhausted_cancel_reports_alpacas_wait() {
11644 let server = MockServer::start().await;
11645 Mock::given(method("DELETE"))
11646 .and(path("/v2/orders/abc"))
11647 .respond_with(RateLimited::mount(Some(("retry-after", "5".to_string()))))
11648 .mount(&server)
11649 .await;
11650
11651 let http = reqwest::Client::new();
11652 let rl = RateLimitTracker::new();
11653 let url = format!("{}/v2/orders/abc", server.uri());
11654 let error = rest_delete_with_retry(&rl, || http.delete(&url))
11655 .await
11656 .unwrap_err();
11657
11658 let UnindexedOrderError::Rejected(error) = error else {
11659 panic!("expected Rejected, got {error:?}");
11660 };
11661 assert_eq!(error.retry_after(), Some(Duration::from_secs(5)));
11662 assert!(
11663 rl.blocked_until.lock().is_some(),
11664 "the last refusal pauses the client"
11665 );
11666 }
11667 }
11668}