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 #[cfg(test)]
462 #[serde(skip)]
463 ws_url_override: Option<String>,
464}
465
466fn default_paper() -> bool {
472 true
473}
474
475impl std::fmt::Debug for AlpacaConfig {
477 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
478 f.debug_struct("AlpacaConfig")
479 .field("api_key", &"***")
480 .field("secret_key", &"***")
481 .field("paper", &self.paper)
482 .finish()
483 }
484}
485
486impl AlpacaConfig {
487 pub fn new(api_key: String, secret_key: String) -> Self {
490 Self::paper(api_key, secret_key)
491 }
492
493 pub fn paper(api_key: String, secret_key: String) -> Self {
495 Self {
496 api_key,
497 secret_key,
498 paper: true,
499 #[cfg(test)]
500 base_url_override: None,
501 #[cfg(test)]
502 ws_url_override: None,
503 }
504 }
505
506 pub fn production(api_key: String, secret_key: String) -> Self {
511 Self {
512 api_key,
513 secret_key,
514 paper: false,
515 #[cfg(test)]
516 base_url_override: None,
517 #[cfg(test)]
518 ws_url_override: None,
519 }
520 }
521
522 pub fn from_env() -> Result<Self, AlpacaConfigError> {
540 let api_key = match std::env::var("ALPACA_API_KEY") {
541 Ok(value) => value,
542 Err(std::env::VarError::NotPresent) => return Err(AlpacaConfigError::MissingApiKey),
543 Err(std::env::VarError::NotUnicode(_)) => {
544 return Err(AlpacaConfigError::InvalidApiKey);
545 }
546 };
547 let secret_key = match std::env::var("ALPACA_SECRET_KEY") {
548 Ok(value) => value,
549 Err(std::env::VarError::NotPresent) => return Err(AlpacaConfigError::MissingSecretKey),
550 Err(std::env::VarError::NotUnicode(_)) => {
551 return Err(AlpacaConfigError::InvalidSecretKey);
552 }
553 };
554
555 let paper = match std::env::var("ALPACA_PAPER") {
556 Ok(value) => parse_env_bool(&value).ok_or(AlpacaConfigError::InvalidPaper(value))?,
557 Err(std::env::VarError::NotPresent) => true,
558 Err(std::env::VarError::NotUnicode(value)) => {
561 return Err(AlpacaConfigError::InvalidPaper(
562 value.to_string_lossy().into_owned(),
563 ));
564 }
565 };
566
567 if paper {
568 Ok(Self::paper(api_key, secret_key))
569 } else {
570 Ok(Self::production(api_key, secret_key))
571 }
572 }
573
574 #[cfg(test)]
576 pub fn with_base_url(api_key: String, secret_key: String, base_url: String) -> Self {
577 Self {
578 api_key,
579 secret_key,
580 paper: true,
581 base_url_override: Some(base_url),
582 ws_url_override: None,
583 }
584 }
585
586 pub fn api_key(&self) -> &str {
587 &self.api_key
588 }
589
590 pub fn rest_base_url(&self) -> &str {
594 #[cfg(test)]
595 if let Some(ref url) = self.base_url_override {
596 return url.as_str();
597 }
598 if self.paper {
599 "https://paper-api.alpaca.markets"
600 } else {
601 "https://api.alpaca.markets"
602 }
603 }
604
605 pub fn ws_url(&self) -> &'static str {
607 if self.paper {
608 "wss://paper-api.alpaca.markets/stream"
609 } else {
610 "wss://api.alpaca.markets/stream"
611 }
612 }
613
614 fn stream_url(&self) -> &str {
617 #[cfg(test)]
618 if let Some(ref url) = self.ws_url_override {
619 return url.as_str();
620 }
621 self.ws_url()
622 }
623}
624
625#[derive(Debug, PartialEq, thiserror::Error)]
626pub enum AlpacaConfigError {
627 #[error("ALPACA_API_KEY environment variable not set")]
628 MissingApiKey,
629
630 #[error("ALPACA_API_KEY environment variable is not valid UTF-8")]
633 InvalidApiKey,
634
635 #[error("ALPACA_SECRET_KEY environment variable not set")]
636 MissingSecretKey,
637
638 #[error("ALPACA_SECRET_KEY environment variable is not valid UTF-8")]
639 InvalidSecretKey,
640
641 #[error("ALPACA_PAPER must be true or false, got {0}")]
642 InvalidPaper(String),
643}
644
645#[derive(Debug, Deserialize)]
654struct AlpacaAccount {
655 #[serde(with = "rust_decimal::serde::str")]
658 cash: Decimal,
659 #[serde(with = "rust_decimal::serde::str")]
663 non_marginable_buying_power: Decimal,
664}
665
666#[derive(Debug, Deserialize)]
671struct AlpacaPosition {
672 symbol: String,
675 asset_class: String,
677 #[serde(default)]
681 side: AlpacaPositionSide,
682 #[serde(with = "rust_decimal::serde::str")]
684 qty: Decimal,
685 #[serde(with = "rust_decimal::serde::str")]
687 qty_available: Decimal,
688 #[serde(default, with = "rust_decimal::serde::str_option")]
690 avg_entry_price: Option<Decimal>,
691 #[serde(default, with = "rust_decimal::serde::str_option")]
693 unrealized_pl: Option<Decimal>,
694}
695
696#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize)]
698#[serde(rename_all = "lowercase")]
699enum AlpacaPositionSide {
700 Long,
701 Short,
702 #[default]
706 #[serde(other)]
707 Unknown,
708}
709
710#[derive(Debug, Deserialize)]
711struct AlpacaOrderResponse {
712 id: String,
713 client_order_id: Option<String>,
714 symbol: String,
715 qty: Option<String>,
716 filled_qty: String,
717 #[serde(default)]
719 filled_avg_price: Option<String>,
720 #[serde(default)]
724 status: Option<String>,
725 side: String,
726 #[serde(rename = "type")]
727 order_type: String,
728 time_in_force: String,
729 limit_price: Option<String>,
730 stop_price: Option<String>,
731 trail_percent: Option<String>,
732 trail_price: Option<String>,
733 created_at: String,
734 updated_at: Option<String>,
737}
738
739#[derive(Debug, Deserialize)]
740struct AlpacaActivity {
741 id: String,
742 order_id: String,
743 symbol: String,
744 side: String,
745 price: String,
746 qty: String,
747 transaction_time: String,
748 cum_qty: Option<String>,
756}
757
758#[derive(Debug, Deserialize)]
765struct AlpacaNonTradeActivity {
766 id: String,
767 activity_type: String,
768 #[serde(default)]
769 activity_sub_type: Option<String>,
770 #[serde(default)]
773 created_at: Option<String>,
774 #[serde(default)]
778 date: Option<String>,
779 #[serde(default)]
781 net_amount: Option<String>,
782 #[serde(default)]
785 qty: Option<String>,
786 #[serde(default)]
788 price: Option<String>,
789 #[serde(default)]
790 currency: Option<String>,
791 #[serde(default)]
792 symbol: Option<String>,
793 #[serde(default)]
795 status: Option<String>,
796}
797
798#[derive(Debug, Deserialize)]
803struct AlpacaApiError {
804 code: Option<i64>,
805 message: String,
806}
807
808impl AlpacaApiError {
809 fn from_body(bytes: &[u8]) -> Self {
811 serde_json::from_slice(bytes).unwrap_or_else(|_| Self {
812 code: None,
813 message: String::from_utf8_lossy(bytes).into_owned(),
814 })
815 }
816}
817
818#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
830#[serde(rename_all = "snake_case")]
831pub enum AlpacaPositionIntent {
832 BuyToOpen,
833 BuyToClose,
834 SellToOpen,
835 SellToClose,
836}
837
838#[derive(Debug, Serialize)]
846struct TakeProfitParams {
847 limit_price: String,
848}
849
850#[derive(Debug, Serialize)]
855struct StopLossParams {
856 stop_price: String,
857 #[serde(skip_serializing_if = "Option::is_none")]
858 limit_price: Option<String>,
859}
860
861#[non_exhaustive]
896#[derive(Debug, Clone)]
897pub struct AlpacaBracketOrderRequest {
898 pub instrument: InstrumentNameExchange,
900 pub strategy: StrategyId,
902 pub cid: ClientOrderId,
904 pub side: Side,
906 pub quantity: Decimal,
908 pub entry_price: Decimal,
910 pub take_profit_price: Decimal,
912 pub stop_loss_price: Decimal,
914 pub stop_loss_limit_price: Option<Decimal>,
916 pub time_in_force: TimeInForce,
918}
919
920impl AlpacaBracketOrderRequest {
921 #[allow(clippy::too_many_arguments)] pub fn new(
926 instrument: InstrumentNameExchange,
927 strategy: StrategyId,
928 cid: ClientOrderId,
929 side: Side,
930 quantity: Decimal,
931 entry_price: Decimal,
932 take_profit_price: Decimal,
933 stop_loss_price: Decimal,
934 time_in_force: TimeInForce,
935 ) -> Self {
936 Self {
937 instrument,
938 strategy,
939 cid,
940 side,
941 quantity,
942 entry_price,
943 take_profit_price,
944 stop_loss_price,
945 stop_loss_limit_price: None,
946 time_in_force,
947 }
948 }
949
950 #[must_use]
952 pub fn with_stop_loss_limit_price(mut self, price: Decimal) -> Self {
953 self.stop_loss_limit_price = Some(price);
954 self
955 }
956}
957
958#[non_exhaustive]
969#[derive(Debug, Clone)]
970pub struct AlpacaBracketOrderResult {
971 pub parent: Order<ExchangeId, InstrumentNameExchange, UnindexedOrderState>,
973}
974
975#[derive(Debug, Serialize)]
980struct AlpacaOrderRequest<'a> {
981 symbol: &'a str,
982 qty: String,
983 side: &'static str,
984 #[serde(rename = "type")]
985 order_type: &'static str,
986 time_in_force: &'static str,
987 #[serde(skip_serializing_if = "Option::is_none")]
988 limit_price: Option<String>,
989 #[serde(skip_serializing_if = "Option::is_none")]
990 stop_price: Option<String>,
991 #[serde(skip_serializing_if = "Option::is_none")]
992 trail_percent: Option<String>,
993 #[serde(skip_serializing_if = "Option::is_none")]
994 trail_price: Option<String>,
995 #[serde(skip_serializing_if = "Option::is_none")]
996 client_order_id: Option<&'a str>,
997 #[serde(skip_serializing_if = "Option::is_none")]
1001 position_intent: Option<AlpacaPositionIntent>,
1002 #[serde(skip_serializing_if = "Option::is_none")]
1005 order_class: Option<&'static str>,
1006 #[serde(skip_serializing_if = "Option::is_none")]
1007 take_profit: Option<TakeProfitParams>,
1008 #[serde(skip_serializing_if = "Option::is_none")]
1009 stop_loss: Option<StopLossParams>,
1010}
1011
1012#[derive(Debug, Deserialize)]
1021struct AlpacaStreamMessage<'a> {
1022 stream: SmolStr,
1025 #[serde(borrow)]
1026 data: &'a serde_json::value::RawValue,
1027}
1028
1029#[derive(Debug, Deserialize)]
1037struct AlpacaTradeUpdate<'a> {
1038 event: SmolStr,
1040 #[serde(borrow)]
1041 order: AlpacaOrderWs<'a>,
1042 #[serde(borrow)]
1044 price: Option<&'a str>,
1045 #[serde(borrow)]
1047 qty: Option<&'a str>,
1048 #[serde(borrow)]
1049 timestamp: Option<&'a str>,
1050 #[serde(borrow)]
1054 execution_id: Option<&'a str>,
1055 #[serde(borrow)]
1059 previous_execution_id: Option<&'a str>,
1060}
1061
1062#[derive(Debug, Deserialize)]
1064struct AlpacaOrderWs<'a> {
1065 id: SmolStr,
1068 client_order_id: Option<SmolStr>,
1069 symbol: SmolStr,
1072 #[serde(borrow)]
1073 qty: Option<&'a str>,
1074 #[serde(borrow)]
1081 filled_qty: Option<&'a str>,
1082 side: SmolStr,
1085 #[serde(rename = "type")]
1086 order_type: SmolStr,
1087 time_in_force: SmolStr,
1088 #[serde(borrow)]
1089 limit_price: Option<&'a str>,
1090 #[serde(borrow)]
1091 stop_price: Option<&'a str>,
1092 #[serde(borrow)]
1093 trail_percent: Option<&'a str>,
1094 #[serde(borrow)]
1095 trail_price: Option<&'a str>,
1096 status: SmolStr,
1097}
1098
1099#[derive(Clone)]
1133pub struct AlpacaClient {
1134 config: Arc<AlpacaConfig>,
1135 http: reqwest::Client,
1138 rate_limiter: Arc<RateLimitTracker>,
1139 orders_url: String,
1141 known_live: SharedKnownLiveOrders,
1144}
1145
1146impl std::fmt::Debug for AlpacaClient {
1147 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1148 f.debug_struct("AlpacaClient")
1149 .field("paper", &self.config.paper)
1150 .finish_non_exhaustive()
1151 }
1152}
1153
1154impl AlpacaClient {
1155 #[allow(clippy::expect_used)] fn build_http(config: &AlpacaConfig) -> reqwest::Client {
1163 use reqwest::header::{HeaderMap, HeaderName, HeaderValue};
1164 let mut headers = HeaderMap::new();
1165 headers.insert(
1167 HeaderName::from_static("apca-api-key-id"),
1168 HeaderValue::from_str(&config.api_key)
1169 .expect("Alpaca API key contains invalid header characters"),
1170 );
1171 headers.insert(
1172 HeaderName::from_static("apca-api-secret-key"),
1173 HeaderValue::from_str(&config.secret_key)
1174 .expect("Alpaca secret key contains invalid header characters"),
1175 );
1176 reqwest::Client::builder()
1177 .default_headers(headers)
1178 .build()
1179 .expect("failed to build reqwest client for Alpaca")
1180 }
1181
1182 fn base_url(&self) -> &str {
1183 self.config.rest_base_url()
1184 }
1185
1186 fn remember_live<'a>(
1188 &self,
1189 orders: impl IntoIterator<Item = &'a Order<ExchangeId, InstrumentNameExchange, Open>>,
1190 ) {
1191 let mut known = self.known_live.lock();
1192 for order in orders {
1193 known.live(&order.key, order.quantity, order.kind, &order.state);
1194 }
1195 }
1196}
1197
1198fn parse_rate_limit_delay(headers: &reqwest::header::HeaderMap) -> Option<Duration> {
1216 let header = |name: &str| {
1217 headers
1218 .get(name)
1219 .and_then(|value| value.to_str().ok())
1220 .and_then(|value| value.trim().parse::<u64>().ok())
1221 };
1222
1223 let seconds = match header("retry-after") {
1224 Some(seconds) => seconds,
1225 None => {
1226 let reset = header("x-ratelimit-reset")?;
1227 let now = std::time::SystemTime::now()
1228 .duration_since(std::time::UNIX_EPOCH)
1229 .unwrap_or_default()
1230 .as_secs();
1231 reset.saturating_sub(now).max(1)
1232 }
1233 };
1234 Some(Duration::from_secs(
1235 seconds.min(DEFAULT_RATE_LIMIT_DELAY_SECS),
1236 ))
1237}
1238
1239fn observe_rate_limit_remaining(
1247 rate_limiter: &RateLimitTracker,
1248 headers: &reqwest::header::HeaderMap,
1249) {
1250 let exhausted = headers
1251 .get("x-ratelimit-remaining")
1252 .and_then(|v| v.to_str().ok())
1253 .and_then(|s| s.parse::<u32>().ok())
1254 == Some(0);
1255 if !exhausted {
1256 return;
1257 }
1258 match parse_rate_limit_delay(headers) {
1259 Some(reset) => rate_limiter.on_bucket_exhausted(reset),
1260 None => debug!(
1261 "Alpaca REST rate-limit window exhausted (X-Ratelimit-Remaining: 0) without X-Ratelimit-Reset"
1262 ),
1263 }
1264}
1265
1266async fn rest_with_retry<T>(
1287 rate_limiter: &RateLimitTracker,
1288 build_request: impl FnMut() -> reqwest::RequestBuilder,
1289) -> Result<T, UnindexedClientError>
1290where
1291 T: for<'de> Deserialize<'de>,
1292{
1293 match rest_request(rate_limiter, build_request).await? {
1294 Fetched::Found(value) => Ok(value),
1295 Fetched::NotFound(message) => Err(UnindexedClientError::Api(parse_api_error(
1296 reqwest::StatusCode::NOT_FOUND,
1297 None,
1298 &message,
1299 ))),
1300 }
1301}
1302
1303async fn rest_lookup_with_retry<T>(
1306 rate_limiter: &RateLimitTracker,
1307 build_request: impl FnMut() -> reqwest::RequestBuilder,
1308) -> Result<Option<T>, UnindexedClientError>
1309where
1310 T: for<'de> Deserialize<'de>,
1311{
1312 match rest_request(rate_limiter, build_request).await? {
1313 Fetched::Found(value) => Ok(Some(value)),
1314 Fetched::NotFound(_) => Ok(None),
1315 }
1316}
1317
1318#[derive(Debug)]
1320enum Fetched<T> {
1321 Found(T),
1323 NotFound(String),
1325}
1326
1327async fn rest_request<T>(
1329 rate_limiter: &RateLimitTracker,
1330 mut build_request: impl FnMut() -> reqwest::RequestBuilder,
1331) -> Result<Fetched<T>, UnindexedClientError>
1332where
1333 T: for<'de> Deserialize<'de>,
1334{
1335 for attempt in 0..MAX_RATE_LIMIT_ATTEMPTS {
1336 rate_limiter.wait_if_blocked().await;
1337 let response = build_request()
1338 .send()
1339 .await
1340 .map_err(|e| connectivity_err(format!("Alpaca REST request failed: {e}")))?;
1341
1342 let status = response.status();
1343
1344 if status == reqwest::StatusCode::TOO_MANY_REQUESTS {
1345 let reset_delay = parse_rate_limit_delay(response.headers());
1346 rate_limiter.on_rate_limited(reset_delay);
1348
1349 if attempt + 1 < MAX_RATE_LIMIT_ATTEMPTS {
1350 warn!(
1351 attempt = attempt + 1,
1352 max_attempts = MAX_RATE_LIMIT_ATTEMPTS,
1353 "Alpaca REST rate-limited (429), retrying"
1354 );
1355 continue;
1356 }
1357
1358 warn!(
1360 max_attempts = MAX_RATE_LIMIT_ATTEMPTS,
1361 "Alpaca REST rate-limit retries exhausted"
1362 );
1363 return Err(UnindexedClientError::Api(ApiError::rate_limited(
1364 reset_delay,
1365 )));
1366 }
1367
1368 observe_rate_limit_remaining(rate_limiter, response.headers());
1369
1370 if status == reqwest::StatusCode::NO_CONTENT {
1374 return Err(UnindexedClientError::Internal(
1375 "Alpaca REST returned 204 No Content — use rest_delete_with_retry for DELETE endpoints"
1376 .to_string(),
1377 ));
1378 }
1379
1380 let bytes = response
1381 .bytes()
1382 .await
1383 .map_err(|e| connectivity_err(format!("Alpaca REST read body failed: {e}")))?;
1384
1385 if status.is_success() {
1388 return serde_json::from_slice::<T>(&bytes)
1389 .map(Fetched::Found)
1390 .map_err(|e| {
1391 UnindexedClientError::Internal(format!(
1392 "Alpaca REST JSON parse error ({status}): {e} | body: {}",
1393 String::from_utf8_lossy(&bytes)
1394 .chars()
1395 .take(200)
1396 .collect::<String>()
1397 ))
1398 });
1399 }
1400
1401 let api_err = AlpacaApiError::from_body(&bytes);
1403
1404 if status == reqwest::StatusCode::NOT_FOUND {
1405 return Ok(Fetched::NotFound(api_err.message));
1406 }
1407
1408 if status.is_client_error() {
1417 return Err(UnindexedClientError::Api(parse_api_error(
1418 status,
1419 api_err.code,
1420 &api_err.message,
1421 )));
1422 }
1423 return Err(connectivity_err(format!(
1424 "Alpaca REST error {status}: {}",
1425 api_err.message
1426 )));
1427 }
1428 unreachable!("Alpaca REST retry loop exited without returning")
1429}
1430
1431async fn rest_delete_with_retry(
1438 rate_limiter: &RateLimitTracker,
1439 mut build_request: impl FnMut() -> reqwest::RequestBuilder,
1440) -> Result<(), UnindexedOrderError> {
1441 for attempt in 0..MAX_RATE_LIMIT_ATTEMPTS {
1442 rate_limiter.wait_if_blocked().await;
1443 let response = build_request().send().await.map_err(|e| {
1444 UnindexedOrderError::Connectivity(ConnectivityError::Socket(format!(
1445 "Alpaca cancel request failed: {e}"
1446 )))
1447 })?;
1448
1449 let status = response.status();
1450
1451 if status == reqwest::StatusCode::TOO_MANY_REQUESTS {
1452 let reset_delay = parse_rate_limit_delay(response.headers());
1453 rate_limiter.on_rate_limited(reset_delay);
1455
1456 if attempt + 1 < MAX_RATE_LIMIT_ATTEMPTS {
1457 warn!(
1458 attempt = attempt + 1,
1459 max_attempts = MAX_RATE_LIMIT_ATTEMPTS,
1460 "Alpaca cancel rate-limited (429), retrying"
1461 );
1462 continue;
1463 }
1464
1465 warn!(
1467 max_attempts = MAX_RATE_LIMIT_ATTEMPTS,
1468 "Alpaca cancel rate-limit retries exhausted"
1469 );
1470 return Err(UnindexedOrderError::Rejected(ApiError::rate_limited(
1471 reset_delay,
1472 )));
1473 }
1474
1475 observe_rate_limit_remaining(rate_limiter, response.headers());
1476
1477 if status == reqwest::StatusCode::NO_CONTENT || status.is_success() {
1479 return Ok(());
1480 }
1481
1482 let bytes = response
1483 .bytes()
1484 .await
1485 .inspect_err(
1486 |e| warn!(%e, %status, "Alpaca cancel_order: failed to read error response body"),
1487 )
1488 .unwrap_or_default();
1489 let api_err = AlpacaApiError::from_body(&bytes);
1490
1491 return Err(parse_order_error(status, api_err.code, &api_err.message));
1492 }
1493 unreachable!("Alpaca cancel retry loop exited without returning")
1494}
1495
1496impl ExecutionClient for AlpacaClient {
1501 const EXCHANGE: ExchangeId = ExchangeId::AlpacaBroker;
1502
1503 const SUPPORTED_KINDS: &'static [InstrumentKindDiscriminant] = &[
1510 InstrumentKindDiscriminant::Spot,
1511 InstrumentKindDiscriminant::Option,
1512 ];
1513 type Config = AlpacaConfig;
1514 type AccountStream = BoxStream<'static, UnindexedAccountEvent>;
1515
1516 fn new(config: Self::Config) -> Self {
1521 let http = Self::build_http(&config);
1522 let orders_url = format!("{}/v2/orders", config.rest_base_url());
1523 Self {
1524 config: Arc::new(config),
1525 http,
1526 rate_limiter: Arc::new(RateLimitTracker::new()),
1527 orders_url,
1528 known_live: KnownLiveOrders::shared(ExchangeId::AlpacaBroker),
1529 }
1530 }
1531
1532 async fn account_snapshot(
1579 &self,
1580 assets: &[AssetNameExchange],
1581 instruments: &[InstrumentNameExchange],
1582 ) -> Result<UnindexedAccountSnapshot, UnindexedClientError> {
1583 let base = self.base_url();
1584 let http = self.http.clone();
1585 let rl = &self.rate_limiter;
1586
1587 let wants_usd = assets.is_empty() || assets.iter().any(is_usd);
1588 let wants_non_usd = assets.is_empty() || assets.iter().any(|a| !is_usd(a));
1589
1590 let account_url = format!("{base}/v2/account");
1592 let positions_url = format!("{base}/v2/positions");
1593 let (account, positions): (Option<AlpacaAccount>, Vec<AlpacaPosition>) = tokio::try_join!(
1594 async {
1595 if wants_usd {
1596 rest_with_retry(rl, || http.get(&account_url))
1597 .await
1598 .map(Some)
1599 } else {
1600 Ok(None)
1601 }
1602 },
1603 rest_with_retry(rl, || http.get(&positions_url)),
1604 )?;
1605
1606 let mut balances = account
1607 .map(|account| convert_account_to_balances(&account, assets))
1608 .unwrap_or_default();
1609 if wants_non_usd {
1610 balances.extend(convert_positions_to_balances(&positions, assets));
1611 }
1612
1613 let converted_positions = convert_positions(&positions, Utc::now())?;
1616 let open_orders = fetch_raw_open_orders(&http, rl, base, instruments).await?;
1617
1618 let instrument_snapshots =
1620 build_instrument_snapshots(open_orders, converted_positions, instruments);
1621 {
1622 let mut known = self.known_live.lock();
1623 for order in instrument_snapshots
1624 .iter()
1625 .flat_map(|snapshot| &snapshot.orders)
1626 {
1627 if let OrderState::Active(ActiveOrderState::Open(open)) = &order.state {
1628 known.live(&order.key, order.quantity, order.kind, open);
1629 }
1630 }
1631 }
1632
1633 Ok(AccountSnapshot::new(
1634 ExchangeId::AlpacaBroker,
1635 balances,
1636 instrument_snapshots,
1637 ))
1638 }
1639
1640 async fn account_stream(
1786 &self,
1787 _assets: &[AssetNameExchange], instruments: &[InstrumentNameExchange],
1792 ) -> Result<Self::AccountStream, UnindexedClientError> {
1793 let initial_ws = connect_and_subscribe(&self.config).await?;
1796
1797 let (tx, rx) = mpsc::unbounded_channel::<UnindexedAccountEvent>();
1802 let dedup = new_dedup_cache();
1803 let config = self.config.clone();
1804 let http = self.http.clone();
1805 let rate_limiter = self.rate_limiter.clone();
1806 let known = self.known_live.clone();
1807 let instruments = instruments.to_vec();
1808
1809 let cm_handle = tokio::spawn(connection_manager(
1810 tx,
1811 dedup,
1812 config,
1813 http,
1814 rate_limiter,
1815 known,
1816 instruments,
1817 Some(initial_ws),
1818 ));
1819
1820 let rx_stream = tokio_stream::wrappers::UnboundedReceiverStream::new(rx);
1821 let guarded = GracefulShutdownStream::new(rx_stream, cm_handle);
1822 Ok(futures::StreamExt::boxed(guarded))
1823 }
1824
1825 async fn cancel_order(
1844 &self,
1845 request: OrderRequestCancel<ExchangeId, &InstrumentNameExchange>,
1846 ) -> UnindexedOrderResponseCancel {
1847 let key = crate::order::OrderKey {
1848 exchange: request.key.exchange,
1849 instrument: request.key.instrument.clone(),
1850 strategy: request.key.strategy.clone(),
1851 cid: request.key.cid.clone(),
1852 };
1853
1854 let order_id: SmolStr = match request.state.id.as_ref().and_then(VenueOrderId::assigned) {
1858 Some(id) => id.0.clone(),
1859 None => {
1860 warn!(
1861 instrument = %key.instrument,
1862 "Alpaca cancel_order: no exchange order ID available (clientOrderId-only cancel not supported)"
1863 );
1864 return crate::order::request::OrderResponseCancel {
1865 key,
1866 state: Err(UnindexedOrderError::Rejected(ApiError::OrderRejected(
1867 "exchange order ID required for cancel (fetch_open_orders to resolve)"
1868 .into(),
1869 ))),
1870 };
1871 }
1872 };
1873
1874 let base = self.base_url();
1875 let http = self.http.clone();
1876 let url = format!("{base}/v2/orders/{order_id}");
1877
1878 match rest_delete_with_retry(&self.rate_limiter, || http.delete(&url)).await {
1879 Ok(()) => {
1880 let exchange_order_id = OrderId(order_id);
1881 crate::order::request::OrderResponseCancel {
1884 key,
1885 state: Ok(Cancelled::new(exchange_order_id, Utc::now(), None)),
1886 }
1887 }
1888 Err(e) => crate::order::request::OrderResponseCancel { key, state: Err(e) },
1889 }
1890 }
1891
1892 async fn open_order(
1913 &self,
1914 request: OrderRequestOpen<ExchangeId, &InstrumentNameExchange>,
1915 ) -> Order<ExchangeId, InstrumentNameExchange, UnindexedOrderState> {
1916 let side = request.state.side;
1917 let reduce_only = request.state.reduce_only;
1918 self.open_order_inner(request, map_position_intent(side, reduce_only))
1919 .await
1920 }
1921
1922 async fn fetch_balances(
1930 &self,
1931 assets: &[AssetNameExchange],
1932 ) -> Result<Vec<AssetBalance<AssetNameExchange>>, UnindexedClientError> {
1933 let base = self.base_url();
1934 let http = self.http.clone();
1935 let mut result = Vec::new();
1936
1937 let wants_usd = assets.is_empty() || assets.iter().any(is_usd);
1940 if wants_usd {
1941 let account_url = format!("{base}/v2/account");
1943 let account: AlpacaAccount =
1944 rest_with_retry(&self.rate_limiter, || http.get(&account_url)).await?;
1945 result.extend(convert_account_to_balances(&account, assets));
1946 }
1947
1948 let wants_non_usd = assets.is_empty() || assets.iter().any(|a| !is_usd(a));
1950 if wants_non_usd {
1951 let positions_url = format!("{base}/v2/positions");
1953 let positions: Vec<AlpacaPosition> =
1954 rest_with_retry(&self.rate_limiter, || http.get(&positions_url)).await?;
1955 result.extend(convert_positions_to_balances(&positions, assets));
1956 }
1957
1958 Ok(result)
1959 }
1960
1961 async fn fetch_open_orders(
1962 &self,
1963 instruments: &[InstrumentNameExchange],
1964 ) -> Result<Vec<Order<ExchangeId, InstrumentNameExchange, Open>>, UnindexedClientError> {
1965 let base = self.base_url();
1966 let http = self.http.clone();
1967
1968 let open_orders =
1969 fetch_raw_open_orders(&http, &self.rate_limiter, base, instruments).await?;
1970
1971 let result: Vec<_> = open_orders
1972 .into_iter()
1973 .filter_map(|o| convert_open_order(&o))
1974 .collect();
1975 self.remember_live(&result);
1976 Ok(result)
1977 }
1978
1979 async fn fetch_trades(
1992 &self,
1993 start: DateTime<Utc>,
1994 end: DateTime<Utc>,
1995 instruments: &[InstrumentNameExchange],
1996 ) -> Result<TradesRead<AssetNameExchange, InstrumentNameExchange>, UnindexedClientError> {
1997 if start > end || end < DateTime::UNIX_EPOCH {
1999 return Ok(TradesRead::complete(Vec::new()));
2000 }
2001 let page = paginate_activities::<AlpacaActivity>(
2002 &self.http,
2003 &self.rate_limiter,
2004 self.base_url(),
2005 FILL_ACTIVITIES,
2006 start,
2007 Some(end),
2008 )
2009 .await?;
2010
2011 let Ok(resume) = span_resume(page.resume, start, end) else {
2012 error!(
2013 %start,
2014 fills_read = page.activities.len(),
2015 "Alpaca fetch_trades: a full read did not advance past the span's first millisecond"
2016 );
2017 return Err(UnindexedClientError::Truncated {
2018 fills_read: page.activities.len(),
2019 });
2020 };
2021
2022 let instrument_set: fnv::FnvHashSet<&str> =
2025 instruments.iter().map(|i| i.name().as_str()).collect();
2026 let trades = page
2027 .activities
2028 .iter()
2029 .filter(|a| instrument_set.is_empty() || instrument_set.contains(a.symbol.as_str()))
2030 .filter(|a| {
2033 parse_timestamp(&a.transaction_time)
2034 .is_none_or(|time| (start..=end).contains(&time))
2035 })
2036 .filter_map(convert_activity_to_trade)
2037 .collect();
2038
2039 Ok(TradesRead::new(trades, resume))
2040 }
2041}
2042
2043impl AlpacaClient {
2048 pub async fn fetch_cash_flows(
2098 &self,
2099 start: DateTime<Utc>,
2100 end: DateTime<Utc>,
2101 ) -> Result<CashFlowsRead<AssetNameExchange, InstrumentNameExchange>, UnindexedClientError>
2102 {
2103 if start > end || end < DateTime::UNIX_EPOCH {
2105 return Ok(CashFlowsRead::complete(Vec::new()));
2106 }
2107 let page = paginate_activities::<AlpacaNonTradeActivity>(
2108 &self.http,
2109 &self.rate_limiter,
2110 self.base_url(),
2111 CASH_FLOW_ACTIVITIES,
2112 start,
2113 Some(end),
2114 )
2115 .await?;
2116 let Ok(resume) = span_resume(page.resume, start, end) else {
2117 error!(
2118 %start,
2119 activities_read = page.activities.len(),
2120 "Alpaca fetch_cash_flows: a full read did not advance past the span's first \
2121 millisecond"
2122 );
2123 return Err(UnindexedClientError::Internal(format!(
2124 "Alpaca cash-flow read of {} activities did not advance past {start}",
2125 page.activities.len()
2126 )));
2127 };
2128
2129 let flows = page
2130 .activities
2131 .iter()
2132 .filter_map(convert_cash_flow)
2133 .filter(|flow| (start..=end).contains(&flow.time_exchange))
2136 .collect();
2137 Ok(CashFlowsRead::new(flows, resume))
2138 }
2139
2140 pub async fn fetch_option_events(
2208 &self,
2209 start: DateTime<Utc>,
2210 end: DateTime<Utc>,
2211 ) -> Result<TradesRead<AssetNameExchange, InstrumentNameExchange>, UnindexedClientError> {
2212 if start > end || end < DateTime::UNIX_EPOCH {
2214 return Ok(TradesRead::complete(Vec::new()));
2215 }
2216 let read_start = start.date_naive().and_time(NaiveTime::MIN).and_utc();
2218 let read_end = end.date_naive().and_time(LAST_MILLISECOND).and_utc();
2219 let page = paginate_activities::<AlpacaNonTradeActivity>(
2220 &self.http,
2221 &self.rate_limiter,
2222 self.base_url(),
2223 OPTION_EVENT_ACTIVITIES,
2224 read_start,
2225 Some(read_end),
2226 )
2227 .await?;
2228 let Ok(resume) = span_resume(page.resume, start, end) else {
2229 error!(
2230 %start,
2231 activities_read = page.activities.len(),
2232 "Alpaca fetch_option_events: a full read did not advance past the span's start"
2233 );
2234 return Err(UnindexedClientError::Internal(format!(
2235 "Alpaca option-event read of {} activities did not advance past {start}",
2236 page.activities.len()
2237 )));
2238 };
2239
2240 Ok(TradesRead::new(
2241 convert_option_events(&page.activities, start, end),
2242 resume,
2243 ))
2244 }
2245
2246 pub async fn open_order_with_intent(
2259 &self,
2260 request: OrderRequestOpen<ExchangeId, &InstrumentNameExchange>,
2261 intent: AlpacaPositionIntent,
2262 ) -> Order<ExchangeId, InstrumentNameExchange, UnindexedOrderState> {
2263 self.open_order_inner(request, intent).await
2264 }
2265
2266 pub async fn open_bracket_order(
2299 &self,
2300 request: AlpacaBracketOrderRequest,
2301 ) -> AlpacaBracketOrderResult {
2302 let order_key = crate::order::OrderKey::new(
2303 ExchangeId::AlpacaBroker,
2304 request.instrument.clone(),
2305 request.strategy.clone(),
2306 request.cid.clone(),
2307 );
2308
2309 let tif_str = match request.time_in_force {
2311 TimeInForce::GoodUntilEndOfDay => "day",
2312 TimeInForce::GoodUntilCancelled { post_only } => {
2313 if post_only {
2314 return AlpacaBracketOrderResult {
2315 parent: Order {
2316 key: order_key,
2317 side: request.side,
2318 price: Some(request.entry_price),
2319 quantity: request.quantity,
2320 kind: OrderKind::Limit,
2321 time_in_force: request.time_in_force,
2322 state: OrderState::inactive(OrderError::Rejected(
2323 ApiError::OrderRejected(
2324 "Alpaca does not support post_only for bracket orders"
2325 .to_string(),
2326 ),
2327 )),
2328 },
2329 };
2330 }
2331 "gtc"
2332 }
2333 other => {
2334 return AlpacaBracketOrderResult {
2335 parent: Order {
2336 key: order_key,
2337 side: request.side,
2338 price: Some(request.entry_price),
2339 quantity: request.quantity,
2340 kind: OrderKind::Limit,
2341 time_in_force: request.time_in_force,
2342 state: OrderState::inactive(OrderError::Rejected(ApiError::OrderRejected(
2343 format!(
2344 "Alpaca bracket orders only support Day or GTC time_in_force, got {:?}",
2345 other
2346 ),
2347 ))),
2348 },
2349 };
2350 }
2351 };
2352
2353 let price_ordering_ok = match request.side {
2356 Side::Buy => {
2357 request.stop_loss_price < request.entry_price
2358 && request.entry_price < request.take_profit_price
2359 }
2360 Side::Sell => {
2361 request.take_profit_price < request.entry_price
2362 && request.entry_price < request.stop_loss_price
2363 }
2364 };
2365 if !price_ordering_ok {
2366 return AlpacaBracketOrderResult {
2367 parent: Order {
2368 key: order_key,
2369 side: request.side,
2370 price: Some(request.entry_price),
2371 quantity: request.quantity,
2372 kind: OrderKind::Limit,
2373 time_in_force: request.time_in_force,
2374 state: OrderState::inactive(OrderError::Rejected(ApiError::OrderRejected(
2375 format!(
2376 "Invalid bracket price ordering for {:?} side: entry={}, take_profit={}, stop_loss={}",
2377 request.side,
2378 request.entry_price,
2379 request.take_profit_price,
2380 request.stop_loss_price,
2381 ),
2382 ))),
2383 },
2384 };
2385 }
2386
2387 if let Some(sl_limit) = request.stop_loss_limit_price {
2391 let sl_limit_ok = match request.side {
2392 Side::Buy => sl_limit <= request.stop_loss_price,
2393 Side::Sell => sl_limit >= request.stop_loss_price,
2394 };
2395 if !sl_limit_ok {
2396 return AlpacaBracketOrderResult {
2397 parent: Order {
2398 key: order_key,
2399 side: request.side,
2400 price: Some(request.entry_price),
2401 quantity: request.quantity,
2402 kind: OrderKind::Limit,
2403 time_in_force: request.time_in_force,
2404 state: OrderState::inactive(OrderError::Rejected(ApiError::OrderRejected(
2405 format!(
2406 "Invalid stop-loss limit price for {:?} bracket: \
2407 stop_loss_price={}, stop_loss_limit_price={}",
2408 request.side, request.stop_loss_price, sl_limit,
2409 ),
2410 ))),
2411 },
2412 };
2413 }
2414 }
2415
2416 let body = AlpacaOrderRequest {
2418 symbol: request.instrument.name().as_str(),
2419 qty: request.quantity.to_string(),
2420 side: map_side(request.side),
2421 order_type: "limit",
2422 time_in_force: tif_str,
2423 limit_price: Some(request.entry_price.to_string()),
2424 stop_price: None,
2425 trail_percent: None,
2426 trail_price: None,
2427 client_order_id: Some(request.cid.0.as_str()),
2428 position_intent: if is_options_or_equity_symbol(request.instrument.name().as_str()) {
2429 Some(map_position_intent(request.side, false))
2430 } else {
2431 None
2432 },
2433 order_class: Some("bracket"),
2434 take_profit: Some(TakeProfitParams {
2435 limit_price: request.take_profit_price.to_string(),
2436 }),
2437 stop_loss: Some(StopLossParams {
2438 stop_price: request.stop_loss_price.to_string(),
2439 limit_price: request.stop_loss_limit_price.map(|p| p.to_string()),
2440 }),
2441 };
2442
2443 let http = self.http.clone();
2444 let rl = &self.rate_limiter;
2445
2446 let result: Result<AlpacaOrderResponse, UnindexedClientError> =
2447 rest_with_retry(rl, || http.post(&self.orders_url).json(&body)).await;
2448
2449 match result {
2450 Ok(resp) => {
2451 let state = placed_order_state(&resp, &order_key.instrument, request.quantity);
2452 self.known_live.lock().placed(
2453 &order_key,
2454 request.quantity,
2455 OrderKind::Limit,
2456 &state,
2457 );
2458
2459 AlpacaBracketOrderResult {
2460 parent: Order {
2461 key: order_key,
2462 side: request.side,
2463 price: Some(request.entry_price),
2464 quantity: request.quantity,
2465 kind: OrderKind::Limit,
2466 time_in_force: request.time_in_force,
2467 state,
2468 },
2469 }
2470 }
2471 Err(e) => {
2472 let order_err = order_post_error(e);
2473 AlpacaBracketOrderResult {
2474 parent: Order {
2475 key: order_key,
2476 side: request.side,
2477 price: Some(request.entry_price),
2478 quantity: request.quantity,
2479 kind: OrderKind::Limit,
2480 time_in_force: request.time_in_force,
2481 state: OrderState::inactive(order_err),
2482 },
2483 }
2484 }
2485 }
2486 }
2487
2488 async fn open_order_inner(
2489 &self,
2490 request: OrderRequestOpen<ExchangeId, &InstrumentNameExchange>,
2491 intent: AlpacaPositionIntent,
2492 ) -> Order<ExchangeId, InstrumentNameExchange, UnindexedOrderState> {
2493 let instrument = request.key.instrument.clone();
2494 let side = request.state.side;
2495 let price = request.state.price;
2496 let quantity = request.state.quantity;
2497 let kind = request.state.kind;
2498 let time_in_force = request.state.time_in_force;
2499 let cid = request.key.cid.clone();
2500
2501 let order_key = crate::order::OrderKey::new(
2502 ExchangeId::AlpacaBroker,
2503 instrument.clone(),
2504 request.key.strategy.clone(),
2505 cid.clone(),
2506 );
2507
2508 let tif_str = match map_time_in_force(time_in_force) {
2510 Ok(s) => s,
2511 Err(msg) => {
2512 return Order {
2513 key: order_key,
2514 side,
2515 price,
2516 quantity,
2517 kind,
2518 time_in_force,
2519 state: OrderState::inactive(OrderError::Rejected(ApiError::OrderRejected(
2520 msg.to_string(),
2521 ))),
2522 };
2523 }
2524 };
2525
2526 let order_type_str = match map_order_kind(kind) {
2528 Some(s) => s,
2529 None => {
2530 return Order {
2531 key: order_key,
2532 side,
2533 price,
2534 quantity,
2535 kind,
2536 time_in_force,
2537 state: OrderState::inactive(OrderError::UnsupportedOrderType(format!(
2538 "Alpaca connector does not yet support OrderKind::{kind:?}"
2539 ))),
2540 };
2541 }
2542 };
2543
2544 if let OrderKind::TrailingStop {
2546 offset_type: TrailingOffsetType::BasisPoints,
2547 ..
2548 } = kind
2549 {
2550 return Order {
2551 key: order_key,
2552 side,
2553 price,
2554 quantity,
2555 kind,
2556 time_in_force,
2557 state: OrderState::inactive(OrderError::UnsupportedOrderType(
2558 "Alpaca does not support TrailingOffsetType::BasisPoints; \
2559 use Percentage or Absolute"
2560 .to_string(),
2561 )),
2562 };
2563 }
2564
2565 if matches!(kind, OrderKind::StopLimit { .. }) && price.is_none() {
2568 return Order {
2569 key: order_key,
2570 side,
2571 price,
2572 quantity,
2573 kind,
2574 time_in_force,
2575 state: OrderState::inactive(OrderError::Rejected(ApiError::OrderRejected(
2576 "StopLimit order requires Order.price (the limit price) to be set".to_string(),
2577 ))),
2578 };
2579 }
2580
2581 let (stop_price, trail_percent, trail_price) = match kind {
2583 OrderKind::Stop { trigger_price } | OrderKind::StopLimit { trigger_price } => {
2584 (Some(trigger_price.to_string()), None, None)
2585 }
2586 OrderKind::TrailingStop {
2587 offset,
2588 offset_type,
2589 ..
2590 } => match offset_type {
2591 TrailingOffsetType::Percentage => (None, Some(offset.to_string()), None),
2592 TrailingOffsetType::Absolute => (None, None, Some(offset.to_string())),
2593 TrailingOffsetType::BasisPoints => unreachable!("validated above"),
2594 },
2595 OrderKind::Market
2597 | OrderKind::Limit
2598 | OrderKind::TakeProfit { .. }
2599 | OrderKind::TakeProfitLimit { .. }
2600 | OrderKind::TrailingStopLimit { .. }
2601 | OrderKind::TrailingStopFixedLimit { .. } => (None, None, None),
2602 };
2603
2604 let body = AlpacaOrderRequest {
2605 symbol: instrument.name().as_str(),
2606 qty: quantity.to_string(),
2607 side: map_side(side),
2608 order_type: order_type_str,
2609 time_in_force: tif_str,
2610 limit_price: match kind {
2611 OrderKind::Limit | OrderKind::StopLimit { .. } => price.map(|p| p.to_string()),
2612 _ => None,
2613 },
2614 stop_price,
2615 trail_percent,
2616 trail_price,
2617 client_order_id: Some(cid.0.as_str()),
2618 position_intent: if is_options_or_equity_symbol(instrument.name().as_str()) {
2624 Some(intent)
2625 } else {
2626 None
2627 },
2628 order_class: None,
2630 take_profit: None,
2631 stop_loss: None,
2632 };
2633
2634 let http = self.http.clone();
2635 let rl = &self.rate_limiter;
2636
2637 let result: Result<AlpacaOrderResponse, UnindexedClientError> =
2638 rest_with_retry(rl, || http.post(&self.orders_url).json(&body)).await;
2639
2640 match result {
2641 Ok(resp) => {
2642 let state = placed_order_state(&resp, &order_key.instrument, quantity);
2643 self.known_live
2644 .lock()
2645 .placed(&order_key, quantity, kind, &state);
2646
2647 Order {
2648 key: order_key,
2649 side,
2650 price,
2651 quantity,
2652 kind,
2653 time_in_force,
2654 state,
2655 }
2656 }
2657 Err(e) => {
2658 let order_err = order_post_error(e);
2659 Order {
2660 key: order_key,
2661 side,
2662 price,
2663 quantity,
2664 kind,
2665 time_in_force,
2666 state: OrderState::inactive(order_err),
2667 }
2668 }
2669 }
2670 }
2671}
2672
2673const MAX_OPEN_ORDERS: usize = 500;
2681
2682async fn fetch_raw_open_orders(
2690 http: &reqwest::Client,
2691 rate_limiter: &RateLimitTracker,
2692 base: &str,
2693 instruments: &[InstrumentNameExchange],
2694) -> Result<Vec<AlpacaOrderResponse>, UnindexedClientError> {
2695 let orders: Vec<AlpacaOrderResponse> = if instruments.is_empty() {
2696 rest_with_retry(rate_limiter, || {
2697 http.get(format!("{base}/v2/orders"))
2698 .query(&[("status", "open"), ("limit", "500")])
2699 })
2700 .await?
2701 } else {
2702 let symbols = instruments.iter().map(|i| i.name().as_str()).join(",");
2703 rest_with_retry(rate_limiter, || {
2704 http.get(format!("{base}/v2/orders")).query(&[
2705 ("status", "open"),
2706 ("limit", "500"),
2707 ("symbols", &symbols),
2708 ])
2709 })
2710 .await?
2711 };
2712 if orders.len() == MAX_OPEN_ORDERS {
2713 warn!(
2714 limit = MAX_OPEN_ORDERS,
2715 "Alpaca fetch_raw_open_orders: received exactly {MAX_OPEN_ORDERS} results — \
2716 response is likely truncated"
2717 );
2718 return Err(UnindexedClientError::TruncatedSnapshot {
2719 limit: MAX_OPEN_ORDERS,
2720 });
2721 }
2722 Ok(orders)
2723}
2724
2725const MAX_ACTIVITY_PAGES: usize = 50;
2734
2735trait PagedActivity: DeserializeOwned {
2737 fn id(&self) -> &str;
2739 fn time(&self) -> Option<DateTime<Utc>>;
2742}
2743
2744impl PagedActivity for AlpacaActivity {
2745 fn id(&self) -> &str {
2746 &self.id
2747 }
2748
2749 fn time(&self) -> Option<DateTime<Utc>> {
2750 parse_timestamp(&self.transaction_time)
2751 }
2752}
2753
2754impl PagedActivity for AlpacaNonTradeActivity {
2755 fn id(&self) -> &str {
2756 &self.id
2757 }
2758
2759 fn time(&self) -> Option<DateTime<Utc>> {
2760 self.created_at.as_deref().and_then(parse_timestamp)
2761 }
2762}
2763
2764const FILL_ACTIVITIES: &str = "FILL";
2766
2767const CASH_FLOW_ACTIVITIES: &str = "INT,FEE,PTC,PTR";
2769
2770const OPTION_EVENT_ACTIVITIES: &str = "OPASN,OPEXC,OPEXP,OPTRD";
2772
2773const LAST_MILLISECOND: NaiveTime = match NaiveTime::from_hms_milli_opt(23, 59, 59, 999) {
2775 Some(time) => time,
2776 None => panic!("23:59:59.999 is a time of day"),
2777};
2778
2779fn span_resume(
2786 resume: Option<DateTime<Utc>>,
2787 start: DateTime<Utc>,
2788 end: DateTime<Utc>,
2789) -> Result<Option<DateTime<Utc>>, ()> {
2790 let resume = resume.filter(|resume| *resume <= end);
2791 if resume.is_some_and(|resume| resume <= floor_millis(start)) {
2792 return Err(());
2793 }
2794 Ok(resume)
2795}
2796
2797struct ActivityPage<A> {
2799 activities: Vec<A>,
2801 resume: Option<DateTime<Utc>>,
2804}
2805
2806fn floor_millis(time: DateTime<Utc>) -> DateTime<Utc> {
2808 DateTime::from_timestamp_millis(time.timestamp_millis()).unwrap_or(time)
2809}
2810
2811const PAGE_SIZE_STR: &str = "100"; const _: () = assert!(
2841 ALPACA_MAX_ACTIVITIES == 100,
2842 "PAGE_SIZE_STR must be updated to match ALPACA_MAX_ACTIVITIES",
2843);
2844
2845async fn paginate_activities<A: PagedActivity>(
2846 http: &reqwest::Client,
2847 rate_limiter: &RateLimitTracker,
2848 base: &str,
2849 types: &str,
2850 start: DateTime<Utc>,
2851 end: Option<DateTime<Utc>>,
2852) -> Result<ActivityPage<A>, UnindexedClientError> {
2853 let after =
2856 floor_millis(start.max(DateTime::UNIX_EPOCH)).to_rfc3339_opts(SecondsFormat::Millis, true);
2857 let until = end.filter(|end| *end < Utc::now()).map(|end| {
2858 (floor_millis(end) + TimeDelta::milliseconds(1))
2859 .to_rfc3339_opts(SecondsFormat::Millis, true)
2860 });
2861 let mut all = Vec::with_capacity(ALPACA_MAX_ACTIVITIES);
2862 let mut page_token: Option<String> = None;
2863 let mut pages = 0usize;
2864 let mut truncated = false;
2865
2866 loop {
2867 if pages >= MAX_ACTIVITY_PAGES {
2868 truncated = true;
2869 break;
2870 }
2871 pages += 1;
2872 let page_token_ref = page_token.as_deref();
2874 let activities: Vec<A> = rest_with_retry(rate_limiter, || {
2875 let mut req = http.get(format!("{base}/v2/account/activities")).query(&[
2876 ("activity_types", types),
2877 ("after", after.as_str()),
2878 ("page_size", PAGE_SIZE_STR),
2879 ("direction", "asc"),
2880 ]);
2881 if let Some(until) = until.as_deref() {
2882 req = req.query(&[("until", until)]);
2883 }
2884 if let Some(token) = page_token_ref {
2885 req = req.query(&[("page_token", token)]);
2886 }
2887 req
2888 })
2889 .await?;
2890
2891 let page_len = activities.len();
2892 let page_token_candidate = activities.last().map(|a| a.id().to_owned());
2902 all.extend(activities);
2903
2904 if page_len < ALPACA_MAX_ACTIVITIES {
2905 break;
2906 }
2907 match page_token_candidate {
2908 Some(token) if !token.is_empty() => {
2909 debug!("Alpaca paginate_activities: fetching next page ({page_len} results)");
2910 page_token = Some(token);
2911 }
2912 _ => break,
2916 }
2917 }
2918
2919 let resume = truncated.then(|| {
2922 floor_millis(
2923 all.iter()
2924 .rev()
2925 .find_map(PagedActivity::time)
2926 .unwrap_or(start),
2927 )
2928 });
2929 Ok(ActivityPage {
2930 activities: all,
2931 resume,
2932 })
2933}
2934
2935#[allow(clippy::cognitive_complexity)] async fn connection_manager(
2951 tx: mpsc::UnboundedSender<UnindexedAccountEvent>,
2952 dedup: SharedDedupCache,
2953 config: Arc<AlpacaConfig>,
2954 http: reqwest::Client,
2955 rate_limiter: Arc<RateLimitTracker>,
2956 known: SharedKnownLiveOrders,
2957 instruments: Vec<InstrumentNameExchange>,
2958 initial_ws: Option<WebSocket>,
2959) {
2960 let mut backoff = ExponentialBackoff::new();
2961 let mut disconnect_time: Option<DateTime<Utc>> = None;
2962 let mut unchecked = UncheckedOrders::default();
2965 let mut current_ws = initial_ws;
2966
2967 'outer: loop {
2968 let mut ws = match current_ws.take() {
2970 Some(ws) => ws,
2971 None => match connect_and_subscribe(&config).await {
2972 Ok(ws) => ws,
2973 Err(e) => {
2974 error!(%e, "Alpaca WS connect/subscribe failed");
2975 if !backoff.wait().await {
2976 error!("Alpaca max reconnect attempts exhausted");
2977 emit_stream_terminated(
2979 &tx,
2980 ExchangeId::AlpacaBroker,
2981 StreamTerminationReason::ReconnectBudgetExhausted {
2982 attempts: backoff.attempts(),
2983 last_error: e.to_string(),
2984 },
2985 );
2986 break;
2987 }
2988 continue;
2989 }
2990 },
2991 };
2992 info!("Alpaca account_stream connected and subscribed");
2993 if let Some(dt) = disconnect_time.take() {
3003 recover_fills_or_report(
3004 &http,
3005 &rate_limiter,
3006 &instruments,
3007 config.rest_base_url(),
3008 dt,
3009 Duration::from_secs(FILL_RECOVERY_TIMEOUT_SECS),
3010 &tx,
3011 &dedup,
3012 &known,
3013 )
3014 .await;
3015 let held = {
3017 let known = known.lock();
3018 if instruments.is_empty() {
3019 known.instruments()
3020 } else {
3021 known.instruments_among(&instruments)
3022 }
3023 };
3024 unchecked.open(held);
3025 }
3026
3027 let mut last_message_time = Utc::now();
3040 let heartbeat = tokio::time::sleep(Duration::from_secs(HEARTBEAT_TIMEOUT_SECS));
3041 tokio::pin!(heartbeat);
3042 let stable_from = tokio::time::Instant::now() + Duration::from_secs(HEARTBEAT_TIMEOUT_SECS);
3045
3046 let order_checks =
3051 run_order_checks(&http, &rate_limiter, &config, &known, &mut unchecked, &tx);
3052 tokio::pin!(order_checks);
3053
3054 macro_rules! reset_heartbeat {
3060 () => {
3061 let now = tokio::time::Instant::now();
3062 heartbeat
3063 .as_mut()
3064 .reset(now + Duration::from_secs(HEARTBEAT_TIMEOUT_SECS));
3065 last_message_time = Utc::now();
3066 if now >= stable_from {
3067 backoff.reset();
3068 }
3069 };
3070 }
3071 enum DisconnectReason {
3074 ServerClose,
3075 Error(String),
3076 StreamEnded,
3077 HeartbeatTimeout,
3078 }
3079 let reason = loop {
3080 tokio::select! {
3081 msg = ws.next() => {
3082 match msg {
3083 Some(Ok(WsMessage::Ping(_))) => {
3084 reset_heartbeat!();
3087 }
3088 Some(Ok(WsMessage::Text(text))) => {
3089 process_ws_text(text.as_str(), &tx, &dedup, &known);
3090 reset_heartbeat!();
3091 }
3092 Some(Ok(WsMessage::Binary(bytes))) => {
3093 match std::str::from_utf8(&bytes) {
3095 Ok(text) => {
3096 process_ws_text(text, &tx, &dedup, &known);
3097 reset_heartbeat!();
3101 }
3102 Err(e) => warn!(%e, "Alpaca WS binary frame: not valid UTF-8"),
3103 }
3104 }
3105 Some(Ok(WsMessage::Close(frame))) => {
3106 warn!(frame = ?frame, "Alpaca WS closed by server");
3107 break DisconnectReason::ServerClose;
3108 }
3109 Some(Ok(_)) => {} Some(Err(e)) => {
3111 warn!(%e, "Alpaca WS error, reconnecting");
3112 break DisconnectReason::Error(e.to_string());
3113 }
3114 None => {
3115 warn!("Alpaca WS stream ended, reconnecting");
3116 break DisconnectReason::StreamEnded;
3117 }
3118 }
3119 }
3120 () = &mut order_checks => {}
3121 _ = &mut heartbeat => {
3122 warn!(
3123 timeout_secs = HEARTBEAT_TIMEOUT_SECS,
3124 "Alpaca heartbeat timeout, reconnecting"
3125 );
3126 break DisconnectReason::HeartbeatTimeout;
3128 }
3129 _ = tx.closed() => {
3130 debug!("Alpaca account_stream consumer dropped, terminating");
3131 let _ = tokio::time::timeout(
3132 Duration::from_secs(WS_CLOSE_TIMEOUT_SECS),
3133 ws.close(None),
3134 ).await;
3135 break 'outer;
3138 }
3139 }
3140 };
3141
3142 disconnect_time =
3147 Some(last_message_time - chrono::Duration::milliseconds(SIGNAL_RECOVERY_LOOKBACK_MS));
3148
3149 let _ =
3151 tokio::time::timeout(Duration::from_secs(WS_CLOSE_TIMEOUT_SECS), ws.close(None)).await;
3152
3153 if tx.is_closed() {
3154 break;
3157 }
3158 if !backoff.wait().await {
3159 error!("Alpaca max reconnect attempts exhausted, stream terminating");
3160 let last_error = match reason {
3163 DisconnectReason::HeartbeatTimeout => {
3164 format!("heartbeat timeout ({HEARTBEAT_TIMEOUT_SECS}s)")
3165 }
3166 DisconnectReason::ServerClose => "WebSocket closed by server".to_string(),
3167 DisconnectReason::Error(e) => e,
3168 DisconnectReason::StreamEnded => "WebSocket stream ended".to_string(),
3169 };
3170 emit_stream_terminated(
3171 &tx,
3172 ExchangeId::AlpacaBroker,
3173 StreamTerminationReason::ReconnectBudgetExhausted {
3174 attempts: backoff.attempts(),
3175 last_error,
3176 },
3177 );
3178 break;
3179 }
3180 }
3181}
3182
3183#[derive(Debug)]
3188enum HandshakeError {
3189 Transport(String),
3192 Auth(String),
3195}
3196
3197async fn connect_and_subscribe(config: &AlpacaConfig) -> Result<WebSocket, UnindexedClientError> {
3201 let url = config.stream_url();
3202 debug!(%url, "Alpaca: connecting to WebSocket");
3203
3204 let mut ws = rustrade_integration::protocol::websocket::connect(url)
3205 .await
3206 .map_err(|e| {
3207 UnindexedClientError::Connectivity(ConnectivityError::Socket(format!(
3208 "WS connect: {e}"
3209 )))
3210 })?;
3211
3212 let result = tokio::time::timeout(
3214 Duration::from_secs(WS_HANDSHAKE_TIMEOUT_SECS),
3215 ws_handshake(&mut ws, config),
3216 )
3217 .await;
3218
3219 match result {
3220 Ok(Ok(())) => Ok(ws),
3221 Ok(Err(HandshakeError::Transport(e))) => {
3222 let _ = ws.close(None).await;
3223 Err(UnindexedClientError::Connectivity(
3224 ConnectivityError::Socket(e),
3225 ))
3226 }
3227 Ok(Err(HandshakeError::Auth(e))) => {
3228 let _ = ws.close(None).await;
3229 Err(UnindexedClientError::Api(ApiError::Unauthenticated(e)))
3230 }
3231 Err(_) => {
3232 let _ = ws.close(None).await;
3233 Err(UnindexedClientError::Connectivity(
3234 ConnectivityError::Timeout,
3235 ))
3236 }
3237 }
3238}
3239
3240async fn ws_handshake(ws: &mut WebSocket, config: &AlpacaConfig) -> Result<(), HandshakeError> {
3248 let auth = serde_json::json!({
3250 "action": "auth",
3251 "key": config.api_key(),
3252 "secret": config.secret_key,
3255 })
3256 .to_string();
3257 ws.send(WsMessage::Text(auth.into()))
3258 .await
3259 .map_err(|e| HandshakeError::Transport(format!("WS auth send: {e}")))?;
3260
3261 loop {
3263 match ws.next().await {
3264 Some(Ok(WsMessage::Text(text))) => {
3265 if let Some(result) = check_auth_response(text.as_str()) {
3266 result?;
3267 break;
3268 }
3269 }
3270 Some(Ok(WsMessage::Binary(bytes))) => {
3271 if let Ok(text) = std::str::from_utf8(&bytes)
3272 && let Some(result) = check_auth_response(text)
3273 {
3274 result?;
3275 break;
3276 }
3277 }
3278 Some(Err(e)) => {
3279 return Err(HandshakeError::Transport(format!(
3280 "WS error during auth: {e}"
3281 )));
3282 }
3283 None => {
3284 return Err(HandshakeError::Transport(
3285 "WS closed before auth response".into(),
3286 ));
3287 }
3288 _ => {} }
3290 }
3291
3292 let sub = serde_json::json!({
3294 "action": "listen",
3295 "data": { "streams": ["trade_updates"] }
3296 })
3297 .to_string();
3298 ws.send(WsMessage::Text(sub.into()))
3299 .await
3300 .map_err(|e| HandshakeError::Transport(format!("WS subscribe send: {e}")))?;
3301
3302 loop {
3304 match ws.next().await {
3305 Some(Ok(WsMessage::Text(text))) => {
3306 if check_listen_ack(text.as_str()) {
3307 break;
3308 }
3309 if let Ok(msg) = serde_json::from_str::<AlpacaStreamMessage<'_>>(text.as_str()) {
3316 if msg.stream == "trade_updates" {
3317 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");
3318 } else {
3319 trace!(stream = %msg.stream, "WS message dropped during listen-ack handshake");
3320 }
3321 } else {
3322 trace!(
3323 bytes = text.len(),
3324 "WS non-stream message dropped during listen-ack handshake"
3325 );
3326 }
3327 }
3328 Some(Ok(WsMessage::Binary(bytes))) => {
3329 if let Ok(text) = std::str::from_utf8(&bytes)
3330 && check_listen_ack(text)
3331 {
3332 break;
3333 }
3334 trace!(
3335 bytes = bytes.len(),
3336 "WS binary message dropped during listen-ack handshake"
3337 );
3338 }
3339 Some(Err(e)) => {
3340 return Err(HandshakeError::Transport(format!(
3341 "WS error during subscribe: {e}"
3342 )));
3343 }
3344 None => {
3345 return Err(HandshakeError::Transport(
3346 "WS closed before subscribe ack".into(),
3347 ));
3348 }
3349 _ => {}
3350 }
3351 }
3352
3353 info!("Alpaca WS authenticated and subscribed to trade_updates");
3354 Ok(())
3355}
3356
3357fn check_auth_response(text: &str) -> Option<Result<(), HandshakeError>> {
3363 let msg = serde_json::from_str::<AlpacaStreamMessage<'_>>(text).ok()?;
3364 if msg.stream != "authorization" {
3365 return None;
3366 }
3367 #[derive(Deserialize)]
3368 struct AuthData<'a> {
3369 status: &'a str,
3370 }
3371 let data = serde_json::from_str::<AuthData<'_>>(msg.data.get()).ok()?;
3372 if data.status == "authorized" {
3373 Some(Ok(()))
3374 } else {
3375 Some(Err(HandshakeError::Auth(format!(
3376 "Alpaca WS auth failed: status={}",
3377 data.status
3378 ))))
3379 }
3380}
3381
3382fn check_listen_ack(text: &str) -> bool {
3386 let Ok(msg) = serde_json::from_str::<AlpacaStreamMessage<'_>>(text) else {
3387 return false;
3388 };
3389 if msg.stream != "listening" {
3390 return false;
3391 }
3392 #[derive(Deserialize)]
3393 struct ListenData<'a> {
3394 #[serde(borrow)]
3395 streams: Vec<&'a str>,
3396 }
3397 let Ok(data) = serde_json::from_str::<ListenData<'_>>(msg.data.get()) else {
3398 return false;
3399 };
3400 data.streams.contains(&"trade_updates")
3401}
3402
3403fn process_ws_text(
3413 text: &str,
3414 tx: &mpsc::UnboundedSender<UnindexedAccountEvent>,
3415 dedup: &SharedDedupCache,
3416 known: &SharedKnownLiveOrders,
3417) {
3418 let msg: AlpacaStreamMessage<'_> = match serde_json::from_str(text) {
3419 Ok(m) => m,
3420 Err(e) => {
3421 trace!(
3422 %e,
3423 raw = ?&text[..text.len().min(200)],
3424 "Alpaca WS: skipped non-JSON message"
3425 );
3426 return;
3427 }
3428 };
3429
3430 match msg.stream.as_str() {
3431 "trade_updates" => {
3432 let update: AlpacaTradeUpdate<'_> = match serde_json::from_str(msg.data.get()) {
3433 Ok(u) => u,
3434 Err(e) => {
3435 warn!(
3440 %e,
3441 raw = ?&msg.data.get()[..msg.data.get().len().min(200)],
3442 "Alpaca WS trade_updates: failed to deserialize event — event dropped"
3443 );
3444 return;
3445 }
3446 };
3447
3448 if is_fill_event(&update)
3452 && is_duplicate_fill(dedup, ws_execution_id(&update), early_dedup_key(&update))
3453 {
3454 trace!("Alpaca WS: skipping duplicate fill event (early check)");
3455 return;
3456 }
3457
3458 if is_trade_amendment(&update) {
3461 warn!(
3462 event = %update.event,
3463 raw = %msg.data.get(),
3464 "Alpaca WS: a fill reported earlier was amended — reporting it as TradeAmended"
3465 );
3466 }
3467
3468 for event in convert_trade_update(update).into_iter().flatten() {
3469 if let AccountEventKind::TradeAmended(amendment) = &event.kind
3473 && let TradeAmendmentKind::Corrected { replacement } = &amendment.kind
3474 {
3475 record_execution(dedup, &replacement.id.0);
3476 }
3477 let known = KnownLiveOrders::observes(&event.kind).then(|| {
3478 let mut known = known.lock();
3479 known.observe(&event);
3480 known
3481 });
3482 let _ = tx.send(event);
3485 drop(known);
3486 }
3487 }
3488 "authorization" | "listening" => {
3489 trace!(stream = %msg.stream, "Alpaca WS: auth/listen ack received during stream");
3492 }
3493 other => {
3494 trace!(%other, "Alpaca WS: ignoring unknown stream type");
3495 }
3496 }
3497}
3498
3499fn fill_dedup_key(order_id: &str, cumulative: Decimal) -> SmolStr {
3510 format_smolstr!("{}:{}", order_id, cumulative.normalize())
3511}
3512
3513fn is_trade_amendment(update: &AlpacaTradeUpdate<'_>) -> bool {
3515 matches!(update.event.as_str(), "trade_bust" | "trade_correct")
3516}
3517
3518#[inline]
3522fn is_fill_event(update: &AlpacaTradeUpdate<'_>) -> bool {
3523 matches!(update.event.as_str(), "fill" | "partial_fill")
3524}
3525
3526fn ws_execution_id<'a>(update: &AlpacaTradeUpdate<'a>) -> Option<&'a str> {
3528 update.execution_id.filter(|id| !id.is_empty())
3529}
3530
3531fn early_dedup_key(update: &AlpacaTradeUpdate<'_>) -> SmolStr {
3535 let filled_qty = update.order.filled_qty.unwrap_or("0");
3536 let qty = Decimal::from_str(filled_qty).unwrap_or(Decimal::ZERO);
3537 fill_dedup_key(&update.order.id, qty)
3538}
3539
3540fn activity_execution_id(activity_id: &str) -> &str {
3544 activity_execution_id_part(activity_id).unwrap_or(activity_id)
3545}
3546
3547fn activity_execution_id_part(activity_id: &str) -> Option<&str> {
3551 activity_id
3552 .split_once("::")
3553 .map(|(_, execution_id)| execution_id)
3554 .filter(|execution_id| !execution_id.is_empty())
3555}
3556
3557#[derive(Debug, PartialEq)]
3563struct UnreadFills {
3564 start: DateTime<Utc>,
3565 reason: FillRecoveryFailure,
3566}
3567
3568async fn recover_fills_or_report(
3572 http: &reqwest::Client,
3573 rate_limiter: &RateLimitTracker,
3574 instruments: &[InstrumentNameExchange],
3575 base: &str,
3576 disconnect: DateTime<Utc>,
3577 timeout: Duration,
3578 tx: &mpsc::UnboundedSender<UnindexedAccountEvent>,
3579 dedup: &SharedDedupCache,
3580 known: &SharedKnownLiveOrders,
3581) {
3582 let recovery_start = Utc::now();
3584 let outcome = match tokio::time::timeout(
3585 timeout,
3586 recover_fills(
3587 http,
3588 rate_limiter,
3589 instruments,
3590 base,
3591 disconnect,
3592 tx,
3593 dedup,
3594 known,
3595 ),
3596 )
3597 .await
3598 {
3599 Ok(outcome) => outcome,
3600 Err(_) => {
3604 warn!(
3605 timeout_secs = timeout.as_secs(),
3606 instruments = ?instruments
3607 .iter()
3608 .map(|instrument| instrument.name().as_str())
3609 .collect::<Vec<_>>(),
3610 "Alpaca fill recovery timed out — fills since the disconnect may be missing for \
3611 any of the instruments (every instrument when the list is empty)"
3612 );
3613 Err(UnreadFills {
3614 start: disconnect,
3615 reason: FillRecoveryFailure::TimedOut {
3616 timeout_secs: timeout.as_secs(),
3617 },
3618 })
3619 }
3620 };
3621 if let Err(unread) = outcome
3622 && tx
3623 .send(fill_recovery_gave_up(instruments, unread, recovery_start))
3624 .is_err()
3625 {
3626 debug!("Alpaca fill recovery: consumer dropped before the give-up was sent");
3627 }
3628}
3629
3630fn fill_recovery_gave_up(
3639 instruments: &[InstrumentNameExchange],
3640 unread: UnreadFills,
3641 end: DateTime<Utc>,
3642) -> UnindexedAccountEvent {
3643 let UnreadFills { start, reason } = unread;
3644 let scope = if instruments.is_empty() {
3646 FillRecoveryScope::AllInstruments
3647 } else {
3648 FillRecoveryScope::Instruments(instruments.to_vec())
3649 };
3650 UnindexedAccountEvent::new(
3651 ExchangeId::AlpacaBroker,
3652 AccountEventKind::FillRecoveryGaveUp(FillRecoveryGap::new(
3653 scope,
3654 start,
3655 end.max(start),
3656 1,
3657 reason,
3658 )),
3659 )
3660}
3661
3662async fn recover_fills(
3671 http: &reqwest::Client,
3672 rate_limiter: &RateLimitTracker,
3673 instruments: &[InstrumentNameExchange],
3674 base: &str,
3675 after: DateTime<Utc>,
3676 tx: &mpsc::UnboundedSender<UnindexedAccountEvent>,
3677 dedup: &SharedDedupCache,
3678 known: &SharedKnownLiveOrders,
3679) -> Result<(), UnreadFills> {
3680 info!(%after, instruments = instruments.len(), "Alpaca recovering fills after reconnect");
3683 let instrument_set: fnv::FnvHashSet<&str> = if instruments.is_empty() {
3684 fnv::FnvHashSet::default()
3685 } else {
3686 instruments.iter().map(|i| i.name().as_str()).collect()
3687 };
3688
3689 let page = match paginate_activities::<AlpacaActivity>(
3690 http,
3691 rate_limiter,
3692 base,
3693 FILL_ACTIVITIES,
3694 after,
3695 None,
3696 )
3697 .await
3698 {
3699 Ok(p) => p,
3700 Err(e) => {
3701 error!(%e, "Alpaca fill recovery: REST request failed");
3702 return Err(UnreadFills {
3703 start: after,
3704 reason: FillRecoveryFailure::Request(e.to_string()),
3705 });
3706 }
3707 };
3708
3709 let activities = page.activities;
3710
3711 let unread = page.resume.map(|start| {
3715 error!(
3716 max_pages = MAX_ACTIVITY_PAGES,
3717 fills_read = activities.len(),
3718 %start,
3719 "Alpaca fill recovery: max page limit reached, truncating — fills from this time on \
3720 are not delivered; read them with fetch_trades"
3721 );
3722 UnreadFills {
3723 start,
3724 reason: FillRecoveryFailure::Truncated {
3725 fills_read: activities.len(),
3726 },
3727 }
3728 });
3729
3730 let mut recovered = 0u32;
3731 let mut duplicates = 0u32;
3732
3733 let mut cumulative_qty: FnvHashMap<&str, Decimal> = FnvHashMap::default();
3750
3751 for activity in &activities {
3752 if !instrument_set.is_empty() && !instrument_set.contains(activity.symbol.as_str()) {
3753 continue;
3757 }
3758
3759 let exec_qty = Decimal::from_str(&activity.qty).unwrap_or(Decimal::ZERO);
3774 let cum = cumulative_qty
3775 .entry(activity.order_id.as_str())
3776 .or_default();
3777 *cum += exec_qty;
3778 let cumulative = *cum;
3779
3780 let trade = match convert_activity_to_trade(activity) {
3781 Some(t) => t,
3782 None => {
3783 warn!(id = %activity.id, symbol = %activity.symbol, "Alpaca: skipping activity with unparseable fields");
3784 continue; }
3786 };
3787
3788 let key = fill_dedup_key(
3798 &activity.order_id,
3799 trade.order_filled_quantity.unwrap_or(cumulative),
3800 );
3801 if is_duplicate_fill(dedup, activity_execution_id_part(&activity.id), key) {
3802 duplicates += 1;
3803 continue;
3804 }
3805
3806 let event =
3807 UnindexedAccountEvent::new(ExchangeId::AlpacaBroker, AccountEventKind::Trade(trade));
3808 let mut held = known.lock();
3809 held.observe(&event);
3810 let sent = tx.send(event);
3811 drop(held);
3812 if sent.is_err() {
3813 debug!("Alpaca fill recovery: consumer dropped during recovery");
3814 return Ok(());
3815 }
3816 recovered += 1;
3817 }
3818
3819 info!(recovered, duplicates, "Alpaca fill recovery complete");
3820 unread.map_or(Ok(()), Err)
3821}
3822
3823async fn fetch_order_lookup(
3832 http: reqwest::Client,
3833 rate_limiter: Arc<RateLimitTracker>,
3834 config: Arc<AlpacaConfig>,
3835 key: UnindexedOrderKey,
3836) -> Result<OrderLookup, UnindexedClientError> {
3837 let url = format!("{}/v2/orders:by_client_order_id", config.rest_base_url());
3838 let cid = key.cid.0.as_str();
3839 let found: Option<AlpacaOrderResponse> = rest_lookup_with_retry(&rate_limiter, || {
3840 http.get(&url).query(&[("client_order_id", cid)])
3841 })
3842 .await?;
3843 let Some(order) = found else {
3844 debug!(instrument = %key.instrument, cid = %key.cid, "Alpaca does not know this order");
3845 return Ok(OrderLookup::Unknown);
3846 };
3847 if !order
3848 .symbol
3849 .eq_ignore_ascii_case(key.instrument.name().as_str())
3850 {
3851 debug!(
3852 instrument = %key.instrument,
3853 cid = %key.cid,
3854 symbol = %order.symbol,
3855 "Alpaca knows this client order id on another symbol"
3856 );
3857 return Ok(OrderLookup::Unknown);
3858 }
3859 Ok(
3860 convert_ended_order(&order, &key).map_or(OrderLookup::NotEnded, |order| {
3861 OrderLookup::Ended(Box::new(order))
3862 }),
3863 )
3864}
3865
3866async fn listed_open_cids(
3869 http: reqwest::Client,
3870 rate_limiter: Arc<RateLimitTracker>,
3871 config: Arc<AlpacaConfig>,
3872 instruments: Vec<InstrumentNameExchange>,
3873) -> Result<FnvHashSet<ClientOrderId>, UnindexedClientError> {
3874 let orders =
3875 fetch_raw_open_orders(&http, &rate_limiter, config.rest_base_url(), &instruments).await?;
3876 Ok(orders
3877 .iter()
3878 .map(|order| alpaca_cid(order.client_order_id.as_deref(), &order.id))
3879 .collect())
3880}
3881
3882async fn recover_alpaca_ended_orders(
3886 http: &reqwest::Client,
3887 rate_limiter: &Arc<RateLimitTracker>,
3888 config: &Arc<AlpacaConfig>,
3889 known: &SharedKnownLiveOrders,
3890 unchecked: &mut UncheckedOrders,
3891 tx: &mpsc::UnboundedSender<UnindexedAccountEvent>,
3892) {
3893 recover_ended_orders(
3894 ExchangeId::AlpacaBroker,
3895 known,
3896 unchecked,
3897 &NoPendingFills,
3898 tx,
3899 OpenListing::Batched,
3900 |instruments| {
3901 listed_open_cids(
3902 http.clone(),
3903 rate_limiter.clone(),
3904 config.clone(),
3905 instruments,
3906 )
3907 },
3908 |key| fetch_order_lookup(http.clone(), rate_limiter.clone(), config.clone(), key),
3909 )
3910 .await;
3911}
3912
3913async fn run_order_checks(
3918 http: &reqwest::Client,
3919 rate_limiter: &Arc<RateLimitTracker>,
3920 config: &Arc<AlpacaConfig>,
3921 known: &SharedKnownLiveOrders,
3922 unchecked: &mut UncheckedOrders,
3923 tx: &mpsc::UnboundedSender<UnindexedAccountEvent>,
3924) {
3925 loop {
3926 if tx.is_closed() {
3928 return std::future::pending().await;
3929 }
3930 match unchecked.next_due(&NoPendingFills) {
3931 Some(due) => tokio::time::sleep_until(due).await,
3932 None => return std::future::pending().await,
3933 }
3934 recover_alpaca_ended_orders(http, rate_limiter, config, known, unchecked, tx).await;
3935 }
3936}
3937
3938fn convert_ended_order(
3946 o: &AlpacaOrderResponse,
3947 key: &UnindexedOrderKey,
3948) -> Option<UnindexedInactiveOrder> {
3949 let instrument = &key.instrument;
3950 let Some(status) = o.status.as_deref() else {
3951 warn!(%instrument, cid = %key.cid, order_id = %o.id, "Alpaca order missing status");
3952 return None;
3953 };
3954 if order_status_is_live(status) {
3955 return None;
3956 }
3957 let Some(order) = convert_representable_open_order(o) else {
3958 warn!(%instrument, cid = %key.cid, order_id = %o.id, status, "Alpaca order cannot be represented, treating it as not ended");
3959 return None;
3960 };
3961 let filled_qty = Decimal::from_str(&o.filled_qty).ok();
3964 let Some(state) = ended_order_state(
3965 status,
3966 OrderId(SmolStr::new(&o.id)),
3967 order.state.time_exchange,
3968 order.quantity,
3969 filled_qty,
3970 o.filled_avg_price.as_deref(),
3971 ) else {
3972 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");
3973 return None;
3974 };
3975 let mut order = order.map_state(|_| state);
3976 order.key = key.clone();
3977 Some(order)
3978}
3979
3980fn order_status_is_live(status: &str) -> bool {
3986 matches!(
3987 status,
3988 "new"
3989 | "partially_filled"
3990 | "done_for_day"
3991 | "pending_cancel"
3992 | "pending_replace"
3993 | "accepted"
3994 | "pending_new"
3995 | "accepted_for_bidding"
3996 | "stopped"
3997 | "suspended"
3998 | "calculated"
3999 | "held"
4000 )
4001}
4002
4003fn ended_order_state<AssetKey, InstrumentKey>(
4013 status: &str,
4014 order_id: OrderId,
4015 time_exchange: DateTime<Utc>,
4016 quantity: Decimal,
4017 filled_qty: Option<Decimal>,
4018 filled_avg_price: Option<&str>,
4019) -> Option<InactiveOrderState<AssetKey, InstrumentKey>> {
4020 Some(match status {
4021 "filled" => InactiveOrderState::FullyFilled(Filled::new(
4023 order_id,
4024 time_exchange,
4025 filled_qty.unwrap_or(quantity),
4026 alpaca_avg_price(filled_avg_price),
4027 )),
4028 "canceled" | "replaced" => {
4029 InactiveOrderState::Cancelled(Cancelled::new(order_id, time_exchange, filled_qty))
4030 }
4031 "expired" => InactiveOrderState::Expired(Expired::new(order_id, time_exchange, filled_qty)),
4032 "rejected" => {
4033 InactiveOrderState::OpenFailed(OrderError::Rejected(ApiError::OrderRejected(format!(
4034 "Alpaca rejected order {order_id} after accepting it"
4035 ))))
4036 }
4037 _ => return None,
4038 })
4039}
4040
4041fn placed_order_state(
4051 resp: &AlpacaOrderResponse,
4052 instrument: &InstrumentNameExchange,
4053 quantity: Decimal,
4054) -> UnindexedOrderState {
4055 let order_id = OrderId(SmolStr::new(&resp.id));
4056 let time_exchange = order_state_time(resp);
4057 let filled_qty = alpaca_filled_qty(&resp.id, Some(&resp.filled_qty));
4058 let filled_avg_price = resp.filled_avg_price.as_deref();
4059 match resp.status.as_deref() {
4060 Some(status) => {
4061 if let Some(ended) = ended_order_state(
4062 status,
4063 order_id.clone(),
4064 time_exchange,
4065 quantity,
4066 filled_qty,
4067 filled_avg_price,
4068 ) {
4069 return OrderState::Inactive(ended);
4070 }
4071 if !order_status_is_live(status) {
4072 warn!(%instrument, %order_id, status, "Alpaca placed an order with a status this version does not know, reading it from what filled");
4073 }
4074 }
4075 None => {
4076 warn!(%instrument, %order_id, "Alpaca placed an order without a status, reading it from what filled");
4077 }
4078 }
4079 match filled_qty {
4080 Some(filled_qty) if filled_qty >= quantity => OrderState::fully_filled(Filled::new(
4081 order_id,
4082 time_exchange,
4083 filled_qty,
4084 alpaca_avg_price(filled_avg_price),
4085 )),
4086 _ => OrderState::active(Open::new(
4089 VenueOrderId::Assigned(order_id),
4090 time_exchange,
4091 filled_qty.unwrap_or(Decimal::ZERO),
4092 )),
4093 }
4094}
4095
4096fn alpaca_avg_price(filled_avg_price: Option<&str>) -> Option<Decimal> {
4099 filled_avg_price.and_then(|price| Decimal::from_str(price).ok())
4100}
4101
4102fn is_usd(asset: &AssetNameExchange) -> bool {
4109 asset.name().as_str().eq_ignore_ascii_case(USD)
4110}
4111
4112fn convert_account_to_balances(
4130 account: &AlpacaAccount,
4131 assets: &[AssetNameExchange],
4132) -> Vec<AssetBalance<AssetNameExchange>> {
4133 if !assets.is_empty() && !assets.iter().any(is_usd) {
4134 return Vec::new();
4135 }
4136
4137 vec![AssetBalance::new(
4138 AssetNameExchange::new(USD),
4139 Balance::new(
4140 account.cash,
4141 account.cash.min(account.non_marginable_buying_power),
4142 ),
4143 Utc::now(),
4144 )]
4145}
4146
4147fn convert_positions_to_balances(
4159 positions: &[AlpacaPosition],
4160 assets: &[AssetNameExchange],
4161) -> Vec<AssetBalance<AssetNameExchange>> {
4162 let now = Utc::now();
4163 positions
4164 .iter()
4165 .filter(|p| is_crypto_position(p))
4166 .filter_map(|p| {
4167 let base = crypto_position_asset(&p.symbol);
4168
4169 if !assets.is_empty()
4171 && !assets
4172 .iter()
4173 .any(|a| a.name().as_str().eq_ignore_ascii_case(base))
4174 {
4175 return None;
4176 }
4177
4178 let asset_name = AssetNameExchange::new(base);
4181 Some(AssetBalance::new(
4182 asset_name,
4183 Balance::new(p.qty, p.qty_available),
4184 now,
4185 ))
4186 })
4187 .collect()
4188}
4189
4190fn crypto_position_asset(symbol: &str) -> &str {
4198 let base = match symbol.split_once('/') {
4199 Some((base, _)) => Some(base),
4200 None => {
4201 let base_len = symbol.len().saturating_sub(USD.len());
4202 symbol
4203 .get(base_len..)
4204 .filter(|suffix| suffix.eq_ignore_ascii_case(USD))
4205 .and_then(|_| symbol.get(..base_len))
4206 }
4207 };
4208 match base {
4209 Some(base) if !base.is_empty() => base,
4210 _ => {
4211 warn!(
4212 %symbol,
4213 "Alpaca crypto position symbol is neither ASSETUSD nor BASE/QUOTE; using it whole"
4214 );
4215 symbol
4216 }
4217 }
4218}
4219
4220fn is_crypto_position(position: &AlpacaPosition) -> bool {
4223 position.asset_class.eq_ignore_ascii_case("crypto")
4224}
4225
4226fn convert_positions(
4248 positions: &[AlpacaPosition],
4249 now: DateTime<Utc>,
4250) -> Result<Vec<(&str, Position)>, UnindexedClientError> {
4251 positions
4252 .iter()
4253 .filter(|p| !is_crypto_position(p) && !p.qty.is_zero())
4254 .map(|p| {
4255 let size = p.qty.abs();
4256 let quantity = match p.side {
4257 AlpacaPositionSide::Long => size,
4258 AlpacaPositionSide::Short => -size,
4259 AlpacaPositionSide::Unknown => {
4260 return Err(UnindexedClientError::Internal(format!(
4261 "Alpaca position {} has a missing or unrecognised side",
4262 p.symbol
4263 )));
4264 }
4265 };
4266 let position = Position::new(
4267 quantity,
4268 p.avg_entry_price,
4269 p.unrealized_pl,
4270 None,
4271 None,
4272 None,
4273 None,
4274 now,
4275 );
4276 Ok((p.symbol.as_str(), position))
4277 })
4278 .collect()
4279}
4280
4281fn build_instrument_snapshots(
4302 orders: Vec<AlpacaOrderResponse>,
4303 positions: Vec<(&str, Position)>,
4304 instruments: &[InstrumentNameExchange],
4305) -> Vec<InstrumentAccountSnapshot<ExchangeId, AssetNameExchange, InstrumentNameExchange>> {
4306 struct Listing {
4308 orders: Vec<UnindexedOrderSnapshot>,
4309 complete: bool,
4310 position: Option<Position>,
4311 }
4312
4313 impl Listing {
4314 fn empty() -> Self {
4315 Self {
4316 orders: Vec::new(),
4317 complete: true,
4318 position: None,
4319 }
4320 }
4321 }
4322
4323 let mut by_symbol: IndexMap<SmolStr, Listing> = IndexMap::new();
4325
4326 for order in orders {
4329 let listing = by_symbol
4330 .entry(order.symbol.to_ascii_uppercase().into())
4331 .or_insert_with(Listing::empty);
4332 match convert_open_order(&order) {
4333 Some(converted) => listing.orders.push(converted.into()),
4334 None => listing.complete = false,
4335 }
4336 }
4337
4338 for (symbol, position) in positions {
4339 by_symbol
4340 .entry(symbol.to_ascii_uppercase().into())
4341 .or_insert_with(Listing::empty)
4342 .position = Some(position);
4343 }
4344
4345 let snapshot = |instrument: InstrumentNameExchange, listing: Listing| {
4346 let position = if is_options_or_equity_symbol(instrument.name().as_str()) {
4349 listing
4350 .position
4351 .map_or(PositionReport::Flat, PositionReport::from_position)
4352 } else {
4353 PositionReport::Unreported
4354 };
4355 InstrumentAccountSnapshot::new(instrument, listing.orders, listing.complete, position, None)
4356 };
4357
4358 if instruments.is_empty() {
4360 by_symbol
4361 .into_iter()
4362 .map(|(sym, listing)| snapshot(InstrumentNameExchange::new(sym), listing))
4363 .collect()
4364 } else {
4365 let mut claimed = std::collections::HashSet::new();
4367 instruments
4368 .iter()
4369 .map(|inst| {
4370 let symbol = inst.name().as_str().to_ascii_uppercase();
4371 if !claimed.insert(symbol.clone()) {
4374 return InstrumentAccountSnapshot::new(
4375 inst.clone(),
4376 Vec::new(),
4377 false,
4378 PositionReport::Unreported,
4379 None,
4380 );
4381 }
4382 let listing = by_symbol
4385 .swap_remove(symbol.as_str())
4386 .unwrap_or_else(Listing::empty);
4387 snapshot(inst.clone(), listing)
4388 })
4389 .collect()
4390 }
4391}
4392
4393fn convert_open_order(
4403 o: &AlpacaOrderResponse,
4404) -> Option<Order<ExchangeId, InstrumentNameExchange, Open>> {
4405 let converted = convert_representable_open_order(o);
4406 if converted.is_none() {
4407 warn!(
4408 order_id = %o.id,
4409 symbol = %o.symbol,
4410 qty = ?o.qty,
4411 side = %o.side,
4412 order_type = %o.order_type,
4413 "Alpaca open order cannot be represented - leaving it out"
4414 );
4415 }
4416 converted
4417}
4418
4419fn alpaca_cid(client_order_id: Option<&str>, order_id: &str) -> ClientOrderId {
4425 ClientOrderId::new(client_order_id.unwrap_or(order_id))
4426}
4427
4428fn convert_representable_open_order(
4430 o: &AlpacaOrderResponse,
4431) -> Option<Order<ExchangeId, InstrumentNameExchange, Open>> {
4432 let order_id = OrderId(SmolStr::new(&o.id));
4433 let cid = alpaca_cid(o.client_order_id.as_deref(), &o.id);
4434
4435 let instrument = InstrumentNameExchange::new(&o.symbol);
4436 let side = parse_side(&o.side)?;
4437 let quantity = Decimal::from_str(o.qty.as_deref().unwrap_or("0")).ok()?;
4438 if quantity.is_zero() {
4442 return None;
4443 }
4444 let price = o
4445 .limit_price
4446 .as_deref()
4447 .and_then(|s| Decimal::from_str(s).ok());
4448 let filled_qty = alpaca_filled_qty(&o.id, Some(&o.filled_qty)).unwrap_or(Decimal::ZERO);
4449 let kind = parse_order_kind(
4450 &o.order_type,
4451 o.stop_price.as_deref(),
4452 o.trail_percent.as_deref(),
4453 o.trail_price.as_deref(),
4454 )?;
4455 let time_in_force = parse_time_in_force(&o.time_in_force);
4456 let time_exchange = order_state_time(o);
4457
4458 Some(Order {
4459 key: OrderKey::new(
4460 ExchangeId::AlpacaBroker,
4461 instrument,
4462 StrategyId::unknown(),
4464 cid,
4465 ),
4466 side,
4467 price,
4468 quantity,
4469 kind,
4470 time_in_force,
4471 state: Open::new(VenueOrderId::Assigned(order_id), time_exchange, filled_qty),
4472 })
4473}
4474
4475fn convert_activity_to_trade(
4477 a: &AlpacaActivity,
4478) -> Option<Trade<AssetNameExchange, InstrumentNameExchange>> {
4479 let trade_id = TradeId::new(activity_execution_id(&a.id));
4480 let order_id = OrderId(SmolStr::new(&a.order_id));
4481 let instrument = InstrumentNameExchange::new(&a.symbol);
4482 let side = parse_side(&a.side)?;
4483 let price = Decimal::from_str(&a.price).ok()?;
4484 let quantity = Decimal::from_str(&a.qty).ok()?;
4485 let order_filled_quantity = a.cum_qty.as_deref().and_then(|s| Decimal::from_str(s).ok());
4488 let time_exchange = parse_timestamp(&a.transaction_time).unwrap_or_else(|| {
4489 warn!(id = %a.id, "Alpaca activity: unparseable transaction_time, using now");
4490 Utc::now()
4491 });
4492
4493 Some(Trade::new(
4498 trade_id,
4499 order_id,
4500 instrument,
4501 StrategyId::unknown(),
4502 time_exchange,
4503 side,
4504 price,
4505 quantity,
4506 order_filled_quantity,
4507 AssetFees::new(
4508 AssetNameExchange::from(USD),
4509 Decimal::ZERO,
4510 Some(Decimal::ZERO),
4511 ),
4512 ))
4513}
4514
4515fn ws_order_snapshot(
4525 order: &AlpacaOrderWs<'_>,
4526 instrument: InstrumentNameExchange,
4527 cid: ClientOrderId,
4528 order_id: OrderId,
4529 time_exchange: DateTime<Utc>,
4530) -> Option<UnindexedAccountEvent> {
4531 let side = parse_side(&order.side)?;
4532 let quantity = Decimal::from_str(order.qty.unwrap_or("0")).unwrap_or(Decimal::ZERO);
4533 if quantity.is_zero() {
4534 trace!(order_id = %order.id, "Alpaca WS: skipping notional order snapshot (qty=None)");
4535 return None;
4536 }
4537 let price = order.limit_price.and_then(|s| Decimal::from_str(s).ok());
4538 let filled_qty = order
4541 .filled_qty
4542 .and_then(|_| alpaca_filled_qty(&order.id, order.filled_qty))
4543 .unwrap_or(Decimal::ZERO);
4544 let kind = parse_order_kind(
4545 &order.order_type,
4546 order.stop_price,
4547 order.trail_percent,
4548 order.trail_price,
4549 )?;
4550 let time_in_force = parse_time_in_force(&order.time_in_force);
4551
4552 let order_snapshot = crate::order::Order {
4553 key: OrderKey::new(
4554 ExchangeId::AlpacaBroker,
4555 instrument,
4556 StrategyId::unknown(),
4557 cid,
4558 ),
4559 side,
4560 price,
4561 quantity,
4562 kind,
4563 time_in_force,
4564 state: OrderState::active(Open::new(
4565 VenueOrderId::Assigned(order_id),
4566 time_exchange,
4567 filled_qty,
4568 )),
4569 };
4570 Some(UnindexedAccountEvent::new(
4571 ExchangeId::AlpacaBroker,
4572 AccountEventKind::OrderSnapshot(rustrade_integration::collection::snapshot::Snapshot(
4573 order_snapshot,
4574 )),
4575 ))
4576}
4577
4578fn ws_fill_order_is_live(status: &str) -> bool {
4588 matches!(status, "partially_filled" | "filled")
4589}
4590
4591fn trade_amendment_event(
4603 update: &AlpacaTradeUpdate<'_>,
4604 instrument: InstrumentNameExchange,
4605 order_id: OrderId,
4606) -> UnindexedAccountEvent {
4607 let order = &update.order;
4608 let time_exchange = update
4609 .timestamp
4610 .and_then(parse_timestamp)
4611 .unwrap_or_else(Utc::now);
4612 let original = update
4613 .previous_execution_id
4614 .filter(|id| !id.is_empty())
4615 .map(TradeId::new);
4616 let quantity = update.qty.and_then(|s| Decimal::from_str(s).ok());
4617
4618 let kind = if update.event == "trade_bust" {
4619 TradeAmendmentKind::Busted {
4620 quantity: quantity.map(|quantity| quantity.abs()),
4621 }
4622 } else {
4623 let id = ws_execution_id(update).map(TradeId::new);
4624 let price = update.price.and_then(|s| Decimal::from_str(s).ok());
4625 match (id, price, quantity, parse_side(&order.side)) {
4626 (Some(id), Some(price), Some(quantity), Some(side)) if quantity > Decimal::ZERO => {
4627 TradeAmendmentKind::Corrected {
4628 replacement: with_reported_cid(
4629 Trade::new(
4630 id,
4631 order_id.clone(),
4632 instrument.clone(),
4633 StrategyId::unknown(),
4634 time_exchange,
4635 side,
4636 price,
4637 quantity,
4638 None,
4641 AssetFees::new(
4643 AssetNameExchange::from(USD),
4644 Decimal::ZERO,
4645 Some(Decimal::ZERO),
4646 ),
4647 ),
4648 order.client_order_id.as_ref(),
4649 ),
4650 }
4651 }
4652 (id, price, quantity, _) => TradeAmendmentKind::CorrectedUnresolved {
4653 id,
4654 price,
4655 quantity,
4656 },
4657 }
4658 };
4659
4660 UnindexedAccountEvent::new(
4661 ExchangeId::AlpacaBroker,
4662 AccountEventKind::TradeAmended(crate::trade::TradeAmendment::new(
4663 instrument,
4664 order_id,
4665 time_exchange,
4666 original,
4667 kind,
4668 )),
4669 )
4670}
4671
4672fn convert_trade_update(update: AlpacaTradeUpdate<'_>) -> [Option<UnindexedAccountEvent>; 2] {
4682 let event_str = update.event.as_str();
4685 if !matches!(
4686 event_str,
4687 "fill"
4688 | "partial_fill"
4689 | "new"
4690 | "accepted"
4691 | "pending_new"
4692 | "canceled"
4693 | "expired"
4694 | "replaced"
4695 | "done_for_day"
4696 | "rejected"
4697 | "trade_bust"
4698 | "trade_correct"
4699 ) {
4700 trace!(event = %event_str, "Alpaca WS: ignoring trade_updates event type");
4701 return [None, None];
4702 }
4703
4704 let order = &update.order;
4705 let instrument = InstrumentNameExchange::new(&*order.symbol);
4706 let order_id = OrderId(order.id.clone());
4707 let cid = alpaca_cid(order.client_order_id.as_deref(), &order.id);
4708
4709 match event_str {
4710 "fill" | "partial_fill" => {
4711 let (Some(price), Some(quantity), Some(side)) = (
4713 update.price.and_then(|s| Decimal::from_str(s).ok()),
4714 update.qty.and_then(|s| Decimal::from_str(s).ok()),
4715 parse_side(&order.side),
4716 ) else {
4717 return [None, None];
4718 };
4719 let time_exchange = update
4720 .timestamp
4721 .and_then(parse_timestamp)
4722 .unwrap_or_else(Utc::now);
4723
4724 let cum_qty = Decimal::from_str(order.filled_qty.unwrap_or("0"))
4729 .inspect_err(|e| {
4730 warn!(
4731 order_id = %order.id,
4732 filled_qty = ?order.filled_qty,
4733 %e,
4734 "Alpaca WS: failed to parse filled_qty — the fill reports a cumulative \
4735 of 0, and without an execution id a second such fill on the same order \
4736 would be deduplicated away"
4737 );
4738 })
4739 .unwrap_or(Decimal::ZERO);
4740 let trade_id = match ws_execution_id(&update) {
4744 Some(execution_id) => TradeId::new(execution_id),
4745 None => {
4746 warn!(
4747 order_id = %order.id,
4748 "Alpaca WS: fill without a usable execution_id — its TradeId is \
4749 \"{{order_id}}:{{filled_qty}}\", which fetch_trades will not match"
4750 );
4751 TradeId(fill_dedup_key(&order.id, cum_qty))
4752 }
4753 };
4754
4755 let trade = Trade::new(
4759 trade_id,
4760 order_id.clone(),
4761 instrument.clone(),
4762 StrategyId::unknown(),
4763 time_exchange,
4764 side,
4765 price,
4766 quantity,
4767 Some(cum_qty),
4769 AssetFees::new(
4770 AssetNameExchange::from(USD),
4771 Decimal::ZERO,
4772 Some(Decimal::ZERO),
4773 ),
4774 );
4775 let trade = with_reported_cid(trade, order.client_order_id.as_ref());
4776 let trade_event = UnindexedAccountEvent::new(
4777 ExchangeId::AlpacaBroker,
4778 AccountEventKind::Trade(trade),
4779 );
4780
4781 let snapshot_event = if ws_fill_order_is_live(&order.status) {
4785 ws_order_snapshot(order, instrument, cid, order_id, time_exchange)
4786 } else {
4787 trace!(
4788 order_id = %order.id,
4789 status = %order.status,
4790 "Alpaca WS: fill for an order the exchange no longer reports as working — \
4791 emitting the execution without an order snapshot"
4792 );
4793 None
4794 };
4795
4796 [Some(trade_event), snapshot_event]
4797 }
4798
4799 "new" | "accepted" | "pending_new" | "done_for_day" => {
4800 let time_exchange = update
4804 .timestamp
4805 .and_then(parse_timestamp)
4806 .unwrap_or_else(Utc::now);
4807
4808 [
4809 ws_order_snapshot(order, instrument, cid, order_id, time_exchange),
4810 None,
4811 ]
4812 }
4813
4814 "canceled" | "expired" | "replaced" => {
4815 let time_exchange = update
4823 .timestamp
4824 .and_then(parse_timestamp)
4825 .unwrap_or_else(Utc::now);
4826 let filled_qty = alpaca_filled_qty(&order.id, order.filled_qty);
4827 let cancelled = Cancelled::new(order_id, time_exchange, filled_qty);
4828 let response = crate::order::request::OrderResponseCancel {
4829 key: OrderKey::new(
4830 ExchangeId::AlpacaBroker,
4831 instrument,
4832 StrategyId::unknown(),
4833 cid,
4834 ),
4835 state: Ok(cancelled),
4836 };
4837 [
4838 Some(UnindexedAccountEvent::new(
4839 ExchangeId::AlpacaBroker,
4840 AccountEventKind::OrderCancelled(response),
4841 )),
4842 None,
4843 ]
4844 }
4845
4846 "trade_bust" | "trade_correct" => [
4847 Some(trade_amendment_event(&update, instrument, order_id)),
4848 None,
4849 ],
4850
4851 "rejected" => {
4852 let response = crate::order::request::OrderResponseCancel {
4853 key: OrderKey::new(
4854 ExchangeId::AlpacaBroker,
4855 instrument,
4856 StrategyId::unknown(),
4857 cid,
4858 ),
4859 state: Err(UnindexedOrderError::Rejected(ApiError::OrderRejected(
4860 format!("order rejected: status={}", order.status),
4861 ))),
4862 };
4863 [
4864 Some(UnindexedAccountEvent::new(
4865 ExchangeId::AlpacaBroker,
4866 AccountEventKind::OrderCancelled(response),
4867 )),
4868 None,
4869 ]
4870 }
4871
4872 _ => unreachable!("convert_trade_update: unrecognised event passed early-return guard"),
4875 }
4876}
4877
4878fn parse_side(s: &str) -> Option<Side> {
4883 match s {
4884 "buy" | "Buy" | "BUY" => Some(Side::Buy),
4885 "sell" | "Sell" | "SELL" => Some(Side::Sell),
4886 other => {
4887 trace!(%other, "Alpaca: unknown order side");
4888 None
4889 }
4890 }
4891}
4892
4893fn parse_order_kind(
4894 order_type: &str,
4895 stop_price: Option<&str>,
4896 trail_percent: Option<&str>,
4897 trail_price: Option<&str>,
4898) -> Option<OrderKind> {
4899 match order_type {
4900 "market" | "Market" => Some(OrderKind::Market),
4901 "limit" | "Limit" => Some(OrderKind::Limit),
4902 "stop" | "Stop" => {
4903 let trigger_price = stop_price.and_then(|s| Decimal::from_str(s).ok())?;
4904 Some(OrderKind::Stop { trigger_price })
4905 }
4906 "stop_limit" | "Stop_limit" => {
4907 let trigger_price = stop_price.and_then(|s| Decimal::from_str(s).ok())?;
4908 Some(OrderKind::StopLimit { trigger_price })
4909 }
4910 "trailing_stop" | "Trailing_stop" => {
4911 if let Some(pct) = trail_percent.and_then(|s| Decimal::from_str(s).ok()) {
4913 Some(OrderKind::TrailingStop {
4914 offset: pct,
4915 offset_type: TrailingOffsetType::Percentage,
4916 activation_price: None,
4917 })
4918 } else if let Some(price) = trail_price.and_then(|s| Decimal::from_str(s).ok()) {
4919 Some(OrderKind::TrailingStop {
4920 offset: price,
4921 offset_type: TrailingOffsetType::Absolute,
4922 activation_price: None,
4923 })
4924 } else {
4925 trace!("Alpaca: trailing_stop missing trail_percent and trail_price");
4926 None
4927 }
4928 }
4929 other => {
4930 trace!(%other, "Alpaca: unsupported order type, skipping");
4931 None
4932 }
4933 }
4934}
4935
4936fn parse_time_in_force(s: &str) -> TimeInForce {
4937 match s {
4938 "gtc" | "GTC" => TimeInForce::GoodUntilCancelled { post_only: false },
4939 "day" | "DAY" => TimeInForce::GoodUntilEndOfDay,
4940 "fok" | "FOK" => TimeInForce::FillOrKill,
4941 "ioc" | "IOC" => TimeInForce::ImmediateOrCancel,
4942 other => {
4943 warn!(%other, "Alpaca: unknown time_in_force, defaulting to GoodUntilEndOfDay");
4944 TimeInForce::GoodUntilEndOfDay
4945 }
4946 }
4947}
4948
4949fn order_state_time(order: &AlpacaOrderResponse) -> DateTime<Utc> {
4959 order
4960 .updated_at
4961 .as_deref()
4962 .and_then(parse_timestamp)
4963 .or_else(|| parse_timestamp(&order.created_at))
4964 .unwrap_or_else(Utc::now)
4965}
4966
4967fn alpaca_filled_qty(order_id: &str, filled_qty: Option<&str>) -> Option<Decimal> {
4970 let Some(raw) = filled_qty else {
4971 warn!(%order_id, "Alpaca did not report how much the order filled");
4972 return None;
4973 };
4974 match Decimal::from_str(raw) {
4975 Ok(filled_qty) => Some(filled_qty),
4976 Err(_) => {
4977 warn!(%order_id, filled_qty = raw, "Alpaca reported an unparseable filled_qty, treating it as unknown");
4978 None
4979 }
4980 }
4981}
4982
4983fn convert_cash_flow(
4986 activity: &AlpacaNonTradeActivity,
4987) -> Option<CashFlow<AssetNameExchange, InstrumentNameExchange>> {
4988 let id = activity.id.as_str();
4989 if activity.status.as_deref() == Some("canceled") {
4990 info!(id, activity_type = %activity.activity_type, "Alpaca activity canceled — left out");
4991 return None;
4992 }
4993 let Some(amount) = activity
4994 .net_amount
4995 .as_deref()
4996 .and_then(|amount| Decimal::from_str(amount).ok())
4997 else {
4998 warn!(id, net_amount = ?activity.net_amount, "Alpaca activity missing/unparseable net_amount — left out");
4999 return None;
5000 };
5001 let Some(time_exchange) = activity.time() else {
5002 warn!(
5003 id,
5004 created_at = ?activity.created_at,
5005 date = ?activity.date,
5006 "Alpaca activity missing/unparseable time — left out"
5007 );
5008 return None;
5009 };
5010 let sub_type = activity
5011 .activity_sub_type
5012 .as_deref()
5013 .filter(|sub_type| !sub_type.is_empty());
5014 let kind = match (activity.activity_type.as_str(), sub_type) {
5015 ("INT", Some("MGN")) => CashFlowKind::MarginInterest {
5016 rate: None,
5017 principal: None,
5018 },
5019 ("INT", None) if amount.is_sign_negative() => CashFlowKind::MarginInterest {
5021 rate: None,
5022 principal: None,
5023 },
5024 ("PTR", _) => CashFlowKind::Rebate,
5025 (activity_type, Some(sub_type)) => {
5026 CashFlowKind::Other(format!("{activity_type}.{sub_type}"))
5027 }
5028 (activity_type, None) => CashFlowKind::Other(activity_type.to_owned()),
5029 };
5030 Some(CashFlow::new(
5031 kind,
5032 AssetNameExchange::new(
5033 activity
5034 .currency
5035 .as_deref()
5036 .filter(|currency| !currency.is_empty())
5037 .unwrap_or("USD"),
5038 ),
5039 amount,
5040 activity
5041 .symbol
5042 .as_deref()
5043 .filter(|symbol| !symbol.is_empty())
5044 .map(InstrumentNameExchange::new),
5045 time_exchange,
5046 Some(CashFlowId::new(id)),
5047 ))
5048}
5049
5050struct OptionLeg<'a> {
5052 id: &'a str,
5053 symbol: &'a str,
5054 quantity: Decimal,
5056 price: Option<Decimal>,
5058 date: Option<NaiveDate>,
5059 time: DateTime<Utc>,
5060 created: bool,
5062 origin: Option<TradeOrigin>,
5064 contract: Option<(&'a str, bool, Decimal)>,
5066}
5067
5068impl OptionLeg<'_> {
5069 fn is_within(&self, start: DateTime<Utc>, end: DateTime<Utc>) -> bool {
5072 if self.created {
5073 (start..=end).contains(&self.time)
5074 } else {
5075 (start.date_naive()..=end.date_naive()).contains(&self.time.date_naive())
5076 }
5077 }
5078}
5079
5080fn option_leg(activity: &AlpacaNonTradeActivity) -> Option<OptionLeg<'_>> {
5083 let id = activity.id.as_str();
5084 let activity_type = activity.activity_type.as_str();
5085 if activity.status.as_deref() == Some("canceled") {
5086 info!(
5087 id,
5088 activity_type, "Alpaca option activity canceled — left out"
5089 );
5090 return None;
5091 }
5092 let origin = match activity_type {
5093 "OPEXP" => Some(TradeOrigin::Expiry),
5094 "OPEXC" => Some(TradeOrigin::Exercise),
5095 "OPASN" => Some(TradeOrigin::Assignment),
5096 "OPTRD" => None,
5097 _ => {
5098 warn!(
5099 id,
5100 activity_type, "Alpaca activity is not an option event — left out"
5101 );
5102 return None;
5103 }
5104 };
5105 let date = activity
5106 .date
5107 .as_deref()
5108 .and_then(|date| NaiveDate::parse_from_str(date, "%Y-%m-%d").ok());
5109 let created_at = activity.created_at.as_deref().and_then(parse_timestamp);
5110 let time = created_at.or_else(|| date.map(|date| date.and_time(NaiveTime::MIN).and_utc()));
5111 let symbol = activity
5112 .symbol
5113 .as_deref()
5114 .filter(|symbol| !symbol.is_empty());
5115 let quantity = activity
5116 .qty
5117 .as_deref()
5118 .and_then(|qty| Decimal::from_str(qty).ok())
5119 .filter(|qty| !qty.is_zero());
5120 let price = activity
5121 .price
5122 .as_deref()
5123 .and_then(|price| Decimal::from_str(price).ok());
5124 let (Some(symbol), Some(quantity), Some(time)) = (symbol, quantity, time) else {
5125 warn!(
5126 id,
5127 activity_type,
5128 symbol = ?activity.symbol,
5129 qty = ?activity.qty,
5130 created_at = ?activity.created_at,
5131 date = ?activity.date,
5132 "Alpaca option activity missing/unparseable symbol, quantity or time — left out"
5133 );
5134 return None;
5135 };
5136 if origin.is_none() && price.is_none() {
5137 warn!(id, price = ?activity.price, "Alpaca OPTRD missing/unparseable price — left out");
5138 return None;
5139 }
5140 let contract = matches!(
5141 origin,
5142 Some(TradeOrigin::Exercise | TradeOrigin::Assignment)
5143 )
5144 .then(|| occ_contract(symbol))
5145 .flatten();
5146 Some(OptionLeg {
5147 id,
5148 symbol,
5149 quantity,
5150 price,
5151 date,
5152 time,
5153 created: created_at.is_some(),
5154 origin,
5155 contract,
5156 })
5157}
5158
5159fn occ_contract(symbol: &str) -> Option<(&str, bool, Decimal)> {
5162 let split = symbol.len().checked_sub(15)?;
5163 let (root, tail) = (
5164 symbol.get(..split)?.trim_end(),
5165 symbol.get(split..)?.as_bytes(),
5166 );
5167 let is_call = match tail[6] {
5168 b'C' => true,
5169 b'P' => false,
5170 _ => return None,
5171 };
5172 if root.is_empty() || !tail[..6].iter().chain(&tail[7..]).all(u8::is_ascii_digit) {
5173 return None;
5174 }
5175 let strike = std::str::from_utf8(&tail[7..]).ok()?.parse::<i64>().ok()?;
5176 Some((root, is_call, Decimal::new(strike, 3)))
5177}
5178
5179fn is_share_leg_of(shares: &OptionLeg<'_>, contract: &OptionLeg<'_>) -> bool {
5182 let Some((root, is_call, strike)) = contract.contract else {
5183 return false;
5184 };
5185 let buys = match contract.origin {
5187 Some(TradeOrigin::Exercise) => is_call,
5188 Some(TradeOrigin::Assignment) => !is_call,
5189 _ => return false,
5190 };
5191 shares.date.is_some()
5192 && shares.date == contract.date
5193 && shares.symbol == root
5194 && shares.price == Some(strike)
5195 && shares.quantity.is_sign_positive() == buys
5196}
5197
5198fn convert_option_events(
5202 activities: &[AlpacaNonTradeActivity],
5203 start: DateTime<Utc>,
5204 end: DateTime<Utc>,
5205) -> Vec<Trade<AssetNameExchange, InstrumentNameExchange>> {
5206 let legs: Vec<_> = activities.iter().filter_map(option_leg).collect();
5207 let mut paired = vec![false; legs.len()];
5208 legs.iter()
5209 .filter_map(|leg| {
5210 let origin = leg.origin.unwrap_or_else(|| {
5213 let pairs = |index: &usize| !paired[*index] && is_share_leg_of(leg, &legs[*index]);
5214 let delivers_its_shares = |index: &usize| {
5218 legs[*index].quantity.abs() * Decimal::ONE_HUNDRED == leg.quantity.abs()
5219 };
5220 let partner = (0..legs.len())
5221 .filter(pairs)
5222 .find(delivers_its_shares)
5223 .or_else(|| (0..legs.len()).find(pairs));
5224 match partner {
5225 Some(index) => {
5226 paired[index] = true;
5227 legs[index].origin.unwrap_or(TradeOrigin::External)
5228 }
5229 None => {
5230 warn!(
5231 id = leg.id,
5232 symbol = leg.symbol,
5233 date = ?leg.date,
5234 "Alpaca OPTRD pairs with no exercise or assignment read — reported as \
5235 External"
5236 );
5237 TradeOrigin::External
5238 }
5239 }
5240 });
5241 if !leg.is_within(start, end) {
5242 return None;
5243 }
5244 let side = if leg.quantity.is_sign_negative() {
5245 Side::Sell
5246 } else {
5247 Side::Buy
5248 };
5249 let trade = Trade::new(
5250 TradeId::new(leg.id),
5251 OrderId::new(leg.id),
5252 InstrumentNameExchange::new(leg.symbol),
5253 StrategyId::unknown(),
5254 leg.time,
5255 side,
5256 leg.price.unwrap_or(Decimal::ZERO),
5257 leg.quantity.abs(),
5258 None,
5259 AssetFees::new(
5260 AssetNameExchange::from(USD),
5261 Decimal::ZERO,
5262 Some(Decimal::ZERO),
5263 ),
5264 )
5265 .with_origin(origin);
5266 Some(trade)
5267 })
5268 .collect()
5269}
5270
5271fn with_reported_cid<AssetKey, InstrumentKey>(
5273 trade: Trade<AssetKey, InstrumentKey>,
5274 client_order_id: Option<&SmolStr>,
5275) -> Trade<AssetKey, InstrumentKey> {
5276 match client_order_id {
5277 Some(cid) if !cid.is_empty() => trade.with_client_order_id(ClientOrderId(cid.clone())),
5279 _ => trade,
5280 }
5281}
5282
5283fn parse_timestamp(s: &str) -> Option<DateTime<Utc>> {
5284 DateTime::parse_from_rfc3339(s)
5285 .ok()
5286 .map(|dt| dt.with_timezone(&Utc))
5287}
5288
5289fn map_side(side: Side) -> &'static str {
5290 match side {
5291 Side::Buy => "buy",
5292 Side::Sell => "sell",
5293 }
5294}
5295
5296fn map_order_kind(kind: OrderKind) -> Option<&'static str> {
5297 match kind {
5298 OrderKind::Market => Some("market"),
5299 OrderKind::Limit => Some("limit"),
5300 OrderKind::Stop { .. } => Some("stop"),
5301 OrderKind::StopLimit { .. } => Some("stop_limit"),
5302 OrderKind::TrailingStop {
5303 activation_price: None,
5304 ..
5305 } => Some("trailing_stop"),
5306 OrderKind::TakeProfit { .. }
5309 | OrderKind::TakeProfitLimit { .. }
5310 | OrderKind::TrailingStop {
5311 activation_price: Some(_),
5312 ..
5313 }
5314 | OrderKind::TrailingStopLimit { .. }
5315 | OrderKind::TrailingStopFixedLimit { .. } => None,
5316 }
5317}
5318
5319fn map_time_in_force(tif: TimeInForce) -> Result<&'static str, &'static str> {
5328 match tif {
5329 TimeInForce::GoodUntilCancelled { post_only } => {
5330 if post_only {
5331 return Err("Alpaca does not support post_only orders");
5332 }
5333 Ok("gtc")
5334 }
5335 TimeInForce::GoodUntilEndOfDay => Ok("day"),
5336 TimeInForce::FillOrKill => Ok("fok"),
5337 TimeInForce::ImmediateOrCancel => Ok("ioc"),
5338 TimeInForce::AtOpen => Ok("opg"),
5339 TimeInForce::AtClose => Ok("cls"),
5340 TimeInForce::GoodTillDate { .. } => {
5344 Err("Alpaca GoodTillDate is not yet wired through this client")
5345 }
5346 }
5347}
5348
5349fn is_options_or_equity_symbol(symbol: &str) -> bool {
5360 !symbol.contains('/')
5361}
5362
5363fn map_position_intent(side: Side, reduce_only: bool) -> AlpacaPositionIntent {
5372 match (reduce_only, side) {
5373 (false, Side::Buy) => AlpacaPositionIntent::BuyToOpen,
5374 (false, Side::Sell) => AlpacaPositionIntent::SellToOpen,
5375 (true, Side::Buy) => AlpacaPositionIntent::BuyToClose,
5376 (true, Side::Sell) => AlpacaPositionIntent::SellToClose,
5377 }
5378}
5379
5380fn parse_api_error(
5391 status: reqwest::StatusCode,
5392 code: Option<i64>,
5393 message: &str,
5394) -> crate::error::UnindexedApiError {
5395 if status == reqwest::StatusCode::TOO_MANY_REQUESTS {
5398 return ApiError::rate_limited(None);
5399 }
5400
5401 let lower = message.to_ascii_lowercase();
5403 match status.as_u16() {
5404 422 if lower.contains("already") => ApiError::OrderAlreadyCancelled,
5408 422 => parse_business_refusal(code, message, &lower),
5409 401 => ApiError::Unauthenticated(format!("unauthorized: {message}")),
5410 403 => match code {
5417 None => ApiError::Unauthenticated(format!("forbidden: {message}")),
5418 Some(_) => parse_business_refusal(code, message, &lower),
5419 },
5420 404 => ApiError::OrderRejected(format!("order not found: {message}")),
5421 _ => ApiError::OrderRejected(with_alpaca_code(code, message)),
5422 }
5423}
5424
5425fn parse_business_refusal(code: Option<i64>, message: &str, lower: &str) -> UnindexedApiError {
5439 let borrow = if lower.contains("not allowed to short") {
5440 Some(BorrowRejectReason::BorrowDisabled)
5441 } else if lower.contains("cannot be sold short") && !lower.contains("fractional") {
5442 Some(BorrowRejectReason::NotShortable)
5443 } else {
5444 None
5445 };
5446 if let Some(reason) = borrow {
5447 ApiError::BorrowRejected(BorrowReject::new(
5448 reason,
5449 code.map(|code| code.to_string()),
5450 message.to_owned(),
5451 ))
5452 } else if lower.contains("insufficient") {
5453 ApiError::BalanceInsufficient(None, with_alpaca_code(code, message))
5454 } else {
5455 ApiError::OrderRejected(with_alpaca_code(code, message))
5456 }
5457}
5458
5459fn with_alpaca_code(code: Option<i64>, message: &str) -> String {
5461 match code {
5462 Some(code) => format!("{code} {message}"),
5463 None => message.to_owned(),
5464 }
5465}
5466
5467fn parse_order_error(
5469 status: reqwest::StatusCode,
5470 code: Option<i64>,
5471 message: &str,
5472) -> UnindexedOrderError {
5473 UnindexedOrderError::Rejected(parse_api_error(status, code, message))
5474}
5475
5476fn connectivity_err(msg: impl Into<String>) -> UnindexedClientError {
5477 UnindexedClientError::Connectivity(ConnectivityError::Socket(msg.into()))
5478}
5479
5480fn order_post_error(error: UnindexedClientError) -> UnindexedOrderError {
5487 match error {
5488 UnindexedClientError::Connectivity(ce) => OrderError::Connectivity(ce),
5489 UnindexedClientError::Api(ae) => OrderError::Rejected(ae),
5490 UnindexedClientError::Internal(msg) => OrderError::Connectivity(ConnectivityError::Socket(
5491 format!("Alpaca order status unknown, its 2xx response could not be read: {msg}"),
5492 )),
5493 other @ (UnindexedClientError::TaskFailed(_)
5497 | UnindexedClientError::Truncated { .. }
5498 | UnindexedClientError::TruncatedSnapshot { .. }) => OrderError::Connectivity(
5499 ConnectivityError::Socket(format!("Alpaca order status unknown: {other}")),
5500 ),
5501 }
5502}
5503
5504impl OrderStatusClient for AlpacaClient {
5509 async fn fetch_ended_orders(
5517 &self,
5518 orders: &[UnindexedOrderKey],
5519 ) -> Result<Vec<UnindexedInactiveOrder>, UnindexedClientError> {
5520 fetch_ended_by_key(orders, |key| {
5521 fetch_order_lookup(
5522 self.http.clone(),
5523 self.rate_limiter.clone(),
5524 self.config.clone(),
5525 key,
5526 )
5527 })
5528 .await
5529 }
5530}
5531
5532impl ShortabilityClient for AlpacaClient {
5537 async fn fetch_shortability(
5556 &self,
5557 instrument: &InstrumentNameExchange,
5558 ) -> Result<Shortability, UnindexedClientError> {
5559 let url = asset_url(self.base_url(), instrument)?;
5560 let found: Option<AlpacaAsset> =
5561 rest_lookup_with_retry(&self.rate_limiter, || self.http.get(url.clone())).await?;
5562 let Some(asset) = found else {
5563 return Err(instrument_invalid(
5564 instrument,
5565 "Alpaca knows no asset under this symbol",
5566 ));
5567 };
5568 shortability_from_asset(instrument, &asset)
5569 }
5570}
5571
5572fn asset_url(
5575 base_url: &str,
5576 instrument: &InstrumentNameExchange,
5577) -> Result<reqwest::Url, UnindexedClientError> {
5578 let mut url = reqwest::Url::parse(base_url).map_err(|error| {
5579 UnindexedClientError::Internal(format!("Alpaca base URL {base_url:?}: {error}"))
5580 })?;
5581 url.path_segments_mut()
5582 .map_err(|()| {
5583 UnindexedClientError::Internal(format!("Alpaca base URL {base_url:?} has no path"))
5584 })?
5585 .pop_if_empty()
5586 .extend(["v2", "assets", instrument.name().as_str()]);
5587 Ok(url)
5588}
5589
5590fn instrument_invalid(instrument: &InstrumentNameExchange, reason: &str) -> UnindexedClientError {
5591 UnindexedClientError::Api(ApiError::InstrumentInvalid(
5592 instrument.clone(),
5593 reason.to_owned(),
5594 ))
5595}
5596
5597fn shortability_from_asset(
5599 instrument: &InstrumentNameExchange,
5600 asset: &AlpacaAsset,
5601) -> Result<Shortability, UnindexedClientError> {
5602 if asset.class == AlpacaAssetClass::UsOption {
5603 return Err(instrument_invalid(
5604 instrument,
5605 "shortability does not apply to an option: writing one borrows nothing",
5606 ));
5607 }
5608 let easy_to_borrow = match asset.borrow_status {
5609 Some(AlpacaBorrowStatus::EasyToBorrow) => Some(true),
5610 Some(AlpacaBorrowStatus::HardToBorrow) => Some(false),
5611 Some(AlpacaBorrowStatus::Other) | None => asset.easy_to_borrow,
5612 };
5613 let mut shortability = Shortability::new();
5614 if let Some(shortable) = asset.shortable {
5615 shortability = shortability.with_shortable(shortable);
5616 }
5617 if let Some(easy_to_borrow) = easy_to_borrow {
5618 shortability = shortability.with_easy_to_borrow(easy_to_borrow);
5619 if easy_to_borrow {
5620 shortability = shortability.with_fee_rate(Decimal::ZERO);
5621 }
5622 }
5623 Ok(shortability)
5624}
5625
5626#[derive(Debug, Deserialize)]
5629struct AlpacaAsset {
5630 #[serde(default)]
5631 class: AlpacaAssetClass,
5632 #[serde(default)]
5633 shortable: Option<bool>,
5634 #[serde(default)]
5636 borrow_status: Option<AlpacaBorrowStatus>,
5637 #[serde(default)]
5639 easy_to_borrow: Option<bool>,
5640}
5641
5642#[derive(Debug, Default, PartialEq, Eq, Deserialize)]
5643#[serde(rename_all = "snake_case")]
5644enum AlpacaAssetClass {
5645 UsOption,
5646 #[default]
5647 #[serde(other)]
5648 Other,
5649}
5650
5651#[derive(Debug, PartialEq, Eq, Deserialize)]
5652#[serde(rename_all = "snake_case")]
5653enum AlpacaBorrowStatus {
5654 EasyToBorrow,
5655 HardToBorrow,
5656 #[serde(other)]
5657 Other,
5658}
5659
5660impl BracketOrderClient for AlpacaClient {
5665 async fn open_bracket_order(
5666 &self,
5667 request: UnifiedBracketOrderRequest<ExchangeId, &InstrumentNameExchange>,
5668 ) -> UnifiedBracketOrderResult {
5669 let alpaca_request = AlpacaBracketOrderRequest {
5670 instrument: request.key.instrument.clone(),
5671 strategy: request.key.strategy.clone(),
5672 cid: request.key.cid.clone(),
5673 side: request.state.side,
5674 quantity: request.state.quantity,
5675 entry_price: request.state.entry_price,
5676 take_profit_price: request.state.take_profit_price,
5677 stop_loss_price: request.state.stop_loss_price,
5678 stop_loss_limit_price: request.state.stop_loss_limit_price,
5679 time_in_force: request.state.time_in_force,
5680 };
5681
5682 let result = self.open_bracket_order(alpaca_request).await;
5683
5684 UnifiedBracketOrderResult::parent_only(result.parent)
5685 }
5686}
5687
5688#[cfg(test)]
5693#[allow(clippy::unwrap_used, clippy::expect_used)] mod tests {
5695 use super::*;
5696 use rust_decimal_macros::dec;
5697
5698 #[test]
5699 fn test_alpaca_config_new_uses_paper_trading_by_default() {
5700 let cfg = AlpacaConfig::new("my_key".into(), "my_secret".into());
5701 assert!(cfg.paper);
5702 }
5703
5704 #[test]
5705 fn test_alpaca_config_debug_redacts_credentials() {
5706 let cfg = AlpacaConfig::new("my_key".into(), "my_secret".into());
5707 let debug = format!("{cfg:?}");
5708 assert!(!debug.contains("my_key"), "api_key should be redacted");
5709 assert!(
5710 !debug.contains("my_secret"),
5711 "secret_key should be redacted"
5712 );
5713 assert!(debug.contains("paper: true"));
5714 }
5715
5716 #[test]
5717 fn test_alpaca_config_deserialize_omitted_paper_defaults_to_paper() {
5718 let cfg: AlpacaConfig = serde_json::from_str(
5719 r#"{
5720 "api_key": "my_key",
5721 "secret_key": "my_secret"
5722 }"#,
5723 )
5724 .unwrap();
5725
5726 assert!(cfg.paper);
5727 assert_eq!(cfg.rest_base_url(), "https://paper-api.alpaca.markets");
5728 }
5729
5730 #[test]
5731 fn test_alpaca_config_deserialize_paper_true() {
5732 let cfg: AlpacaConfig = serde_json::from_str(
5733 r#"{
5734 "api_key": "my_key",
5735 "secret_key": "my_secret",
5736 "paper": true
5737 }"#,
5738 )
5739 .unwrap();
5740
5741 assert!(cfg.paper);
5742 assert_eq!(cfg.rest_base_url(), "https://paper-api.alpaca.markets");
5743 }
5744
5745 #[test]
5746 fn test_alpaca_config_deserialize_paper_false() {
5747 let cfg: AlpacaConfig = serde_json::from_str(
5748 r#"{
5749 "api_key": "my_key",
5750 "secret_key": "my_secret",
5751 "paper": false
5752 }"#,
5753 )
5754 .unwrap();
5755
5756 assert!(!cfg.paper);
5757 assert_eq!(cfg.rest_base_url(), "https://api.alpaca.markets");
5758 }
5759
5760 #[test]
5761 fn test_alpaca_config_urls() {
5762 let paper = AlpacaConfig::paper("k".into(), "s".into());
5763 assert!(paper.rest_base_url().contains("paper-api"));
5764 assert!(paper.ws_url().contains("paper-api"));
5765
5766 let live = AlpacaConfig::production("k".into(), "s".into());
5767 assert!(!live.rest_base_url().contains("paper-api"));
5768 assert!(!live.ws_url().contains("paper-api"));
5769 }
5770
5771 #[test]
5772 #[serial_test::serial]
5773 fn test_alpaca_config_from_env_defaults_to_paper_trading() {
5774 temp_env::with_vars(
5775 [
5776 ("ALPACA_API_KEY", Some("my_key")),
5777 ("ALPACA_SECRET_KEY", Some("my_secret")),
5778 ("ALPACA_PAPER", None),
5779 ],
5780 || {
5781 let cfg = AlpacaConfig::from_env().unwrap();
5782 assert!(cfg.paper);
5783 assert_eq!(cfg.rest_base_url(), "https://paper-api.alpaca.markets");
5784 },
5785 );
5786 }
5787
5788 #[test]
5789 #[serial_test::serial]
5790 fn test_alpaca_config_from_env_accepts_explicit_production() {
5791 temp_env::with_vars(
5792 [
5793 ("ALPACA_API_KEY", Some("my_key")),
5794 ("ALPACA_SECRET_KEY", Some("my_secret")),
5795 ("ALPACA_PAPER", Some("false")),
5796 ],
5797 || {
5798 let cfg = AlpacaConfig::from_env().unwrap();
5799 assert!(!cfg.paper);
5800 assert_eq!(cfg.rest_base_url(), "https://api.alpaca.markets");
5801 },
5802 );
5803 }
5804
5805 #[test]
5806 #[serial_test::serial]
5807 fn test_alpaca_config_from_env_rejects_invalid_paper() {
5808 temp_env::with_vars(
5809 [
5810 ("ALPACA_API_KEY", Some("my_key")),
5811 ("ALPACA_SECRET_KEY", Some("my_secret")),
5812 ("ALPACA_PAPER", Some("maybe")),
5813 ],
5814 || {
5815 let err = AlpacaConfig::from_env().unwrap_err();
5816 assert!(matches!(err, AlpacaConfigError::InvalidPaper(value) if value == "maybe"));
5817 },
5818 );
5819 }
5820
5821 #[test]
5822 #[serial_test::serial]
5823 fn test_alpaca_config_from_env_requires_credentials() {
5824 temp_env::with_vars(
5825 [
5826 ("ALPACA_API_KEY", None),
5827 ("ALPACA_SECRET_KEY", Some("my_secret")),
5828 ("ALPACA_PAPER", None),
5829 ],
5830 || {
5831 let err = AlpacaConfig::from_env().unwrap_err();
5832 assert!(matches!(err, AlpacaConfigError::MissingApiKey));
5833 },
5834 );
5835 }
5836
5837 #[test]
5838 fn test_parse_side() {
5839 assert_eq!(parse_side("buy"), Some(Side::Buy));
5840 assert_eq!(parse_side("sell"), Some(Side::Sell));
5841 assert_eq!(parse_side("Buy"), Some(Side::Buy));
5842 assert_eq!(parse_side("BUY"), Some(Side::Buy));
5843 assert_eq!(parse_side("unknown"), None);
5844 }
5845
5846 #[test]
5847 fn test_parse_order_kind() {
5848 assert_eq!(
5850 parse_order_kind("market", None, None, None),
5851 Some(OrderKind::Market)
5852 );
5853 assert_eq!(
5854 parse_order_kind("limit", None, None, None),
5855 Some(OrderKind::Limit)
5856 );
5857
5858 assert_eq!(
5860 parse_order_kind("stop", Some("150.00"), None, None),
5861 Some(OrderKind::Stop {
5862 trigger_price: Decimal::from_str("150.00").unwrap()
5863 })
5864 );
5865 assert_eq!(parse_order_kind("stop", None, None, None), None);
5866
5867 assert_eq!(
5869 parse_order_kind("stop_limit", Some("145.00"), None, None),
5870 Some(OrderKind::StopLimit {
5871 trigger_price: Decimal::from_str("145.00").unwrap()
5872 })
5873 );
5874
5875 assert_eq!(
5877 parse_order_kind("trailing_stop", None, Some("5.0"), None),
5878 Some(OrderKind::TrailingStop {
5879 offset: Decimal::from_str("5.0").unwrap(),
5880 offset_type: TrailingOffsetType::Percentage,
5881 activation_price: None,
5882 })
5883 );
5884
5885 assert_eq!(
5887 parse_order_kind("trailing_stop", None, None, Some("2.50")),
5888 Some(OrderKind::TrailingStop {
5889 offset: Decimal::from_str("2.50").unwrap(),
5890 offset_type: TrailingOffsetType::Absolute,
5891 activation_price: None,
5892 })
5893 );
5894
5895 assert_eq!(parse_order_kind("trailing_stop", None, None, None), None);
5897
5898 assert_eq!(parse_order_kind("unknown", None, None, None), None);
5900 }
5901
5902 #[test]
5903 fn test_map_order_kind() {
5904 assert_eq!(map_order_kind(OrderKind::Market), Some("market"));
5905 assert_eq!(map_order_kind(OrderKind::Limit), Some("limit"));
5906 assert_eq!(
5907 map_order_kind(OrderKind::Stop {
5908 trigger_price: Decimal::from_str("150.00").unwrap()
5909 }),
5910 Some("stop")
5911 );
5912 assert_eq!(
5913 map_order_kind(OrderKind::StopLimit {
5914 trigger_price: Decimal::from_str("145.00").unwrap()
5915 }),
5916 Some("stop_limit")
5917 );
5918 assert_eq!(
5919 map_order_kind(OrderKind::TrailingStop {
5920 offset: Decimal::from_str("5.0").unwrap(),
5921 offset_type: TrailingOffsetType::Percentage,
5922 activation_price: None,
5923 }),
5924 Some("trailing_stop")
5925 );
5926 assert_eq!(
5927 map_order_kind(OrderKind::TrailingStop {
5928 offset: Decimal::from_str("2.50").unwrap(),
5929 offset_type: TrailingOffsetType::Absolute,
5930 activation_price: None,
5931 }),
5932 Some("trailing_stop")
5933 );
5934 assert_eq!(
5936 map_order_kind(OrderKind::TrailingStop {
5937 offset: Decimal::from_str("5.0").unwrap(),
5938 offset_type: TrailingOffsetType::Percentage,
5939 activation_price: Some(Decimal::from(100)),
5940 }),
5941 None
5942 );
5943 assert_eq!(
5944 map_order_kind(OrderKind::TrailingStopFixedLimit {
5945 offset: Decimal::from_str("5.0").unwrap(),
5946 offset_type: TrailingOffsetType::Percentage,
5947 activation_price: None,
5948 }),
5949 None
5950 );
5951 assert_eq!(
5953 map_order_kind(OrderKind::TrailingStopLimit {
5954 offset: Decimal::from_str("5.0").unwrap(),
5955 offset_type: TrailingOffsetType::Percentage,
5956 stop_price: Decimal::from_str("95.0").unwrap(),
5957 limit_offset: Decimal::from_str("1.0").unwrap(),
5958 }),
5959 None
5960 );
5961 assert_eq!(
5963 map_order_kind(OrderKind::TakeProfit {
5964 trigger_price: Decimal::from_str("160.00").unwrap()
5965 }),
5966 None
5967 );
5968 assert_eq!(
5969 map_order_kind(OrderKind::TakeProfitLimit {
5970 trigger_price: Decimal::from_str("160.00").unwrap()
5971 }),
5972 None
5973 );
5974 }
5975
5976 #[test]
5977 fn test_map_time_in_force_roundtrip() {
5978 assert_eq!(
5979 map_time_in_force(TimeInForce::GoodUntilCancelled { post_only: false }),
5980 Ok("gtc")
5981 );
5982 assert_eq!(map_time_in_force(TimeInForce::GoodUntilEndOfDay), Ok("day"));
5983 assert_eq!(map_time_in_force(TimeInForce::FillOrKill), Ok("fok"));
5984 assert_eq!(map_time_in_force(TimeInForce::ImmediateOrCancel), Ok("ioc"));
5985 }
5986
5987 #[test]
5988 fn test_map_time_in_force_rejects_post_only() {
5989 let result = map_time_in_force(TimeInForce::GoodUntilCancelled { post_only: true });
5990 assert!(result.is_err(), "post_only must be rejected");
5991 assert!(result.unwrap_err().contains("post_only"));
5992 }
5993
5994 #[test]
5999 fn test_bracket_order_serializes_with_stop_loss_stop_order() {
6000 let body = AlpacaOrderRequest {
6002 symbol: "AAPL",
6003 qty: "10".to_string(),
6004 side: "buy",
6005 order_type: "limit",
6006 time_in_force: "gtc",
6007 limit_price: Some("150.00".to_string()),
6008 stop_price: None,
6009 trail_percent: None,
6010 trail_price: None,
6011 client_order_id: Some("bracket-001"),
6012 position_intent: Some(AlpacaPositionIntent::BuyToOpen),
6013 order_class: Some("bracket"),
6014 take_profit: Some(TakeProfitParams {
6015 limit_price: "160.00".to_string(),
6016 }),
6017 stop_loss: Some(StopLossParams {
6018 stop_price: "145.00".to_string(),
6019 limit_price: None,
6020 }),
6021 };
6022
6023 let json = serde_json::to_value(&body).unwrap();
6024
6025 assert_eq!(json["symbol"], "AAPL");
6026 assert_eq!(json["qty"], "10");
6027 assert_eq!(json["side"], "buy");
6028 assert_eq!(json["type"], "limit");
6029 assert_eq!(json["time_in_force"], "gtc");
6030 assert_eq!(json["limit_price"], "150.00");
6031 assert_eq!(json["order_class"], "bracket");
6032
6033 assert_eq!(json["take_profit"]["limit_price"], "160.00");
6035
6036 assert_eq!(json["stop_loss"]["stop_price"], "145.00");
6038 assert!(
6039 json["stop_loss"].get("limit_price").is_none(),
6040 "stop_loss.limit_price should be omitted when None"
6041 );
6042 }
6043
6044 #[test]
6045 fn test_bracket_order_serializes_with_stop_loss_stop_limit_order() {
6046 let body = AlpacaOrderRequest {
6048 symbol: "SPY",
6049 qty: "5".to_string(),
6050 side: "sell",
6051 order_type: "limit",
6052 time_in_force: "day",
6053 limit_price: Some("450.00".to_string()),
6054 stop_price: None,
6055 trail_percent: None,
6056 trail_price: None,
6057 client_order_id: Some("bracket-002"),
6058 position_intent: Some(AlpacaPositionIntent::SellToClose),
6059 order_class: Some("bracket"),
6060 take_profit: Some(TakeProfitParams {
6061 limit_price: "440.00".to_string(),
6062 }),
6063 stop_loss: Some(StopLossParams {
6064 stop_price: "455.00".to_string(),
6065 limit_price: Some("456.00".to_string()),
6066 }),
6067 };
6068
6069 let json = serde_json::to_value(&body).unwrap();
6070
6071 assert_eq!(json["symbol"], "SPY");
6072 assert_eq!(json["side"], "sell");
6073 assert_eq!(json["time_in_force"], "day");
6074 assert_eq!(json["order_class"], "bracket");
6075
6076 assert_eq!(json["take_profit"]["limit_price"], "440.00");
6078
6079 assert_eq!(json["stop_loss"]["stop_price"], "455.00");
6081 assert_eq!(
6082 json["stop_loss"]["limit_price"], "456.00",
6083 "stop_loss.limit_price should be present for stop-limit orders"
6084 );
6085 }
6086
6087 #[tokio::test]
6092 async fn test_open_bracket_order_rejects_invalid_tif() {
6093 use rust_decimal_macros::dec;
6094 use rustrade_instrument::instrument::name::InstrumentNameExchange;
6095
6096 let config = AlpacaConfig::new("dummy_key".into(), "dummy_secret".into());
6098 let client = AlpacaClient::new(config);
6099
6100 let request = AlpacaBracketOrderRequest::new(
6101 InstrumentNameExchange::new("SPY"),
6102 crate::order::id::StrategyId::new("test"),
6103 crate::order::id::ClientOrderId::new("test-tif"),
6104 Side::Buy,
6105 dec!(1),
6106 dec!(100.00),
6107 dec!(120.00),
6108 dec!(90.00),
6109 TimeInForce::ImmediateOrCancel, );
6111
6112 let result = client.open_bracket_order(request).await;
6113
6114 assert!(
6115 result.parent.state.is_failed(),
6116 "Bracket order with IOC TIF should be rejected locally"
6117 );
6118 }
6119
6120 #[tokio::test]
6121 async fn test_open_bracket_order_rejects_invalid_price_ordering() {
6122 use rust_decimal_macros::dec;
6123 use rustrade_instrument::instrument::name::InstrumentNameExchange;
6124
6125 let config = AlpacaConfig::new("dummy_key".into(), "dummy_secret".into());
6126 let client = AlpacaClient::new(config);
6127
6128 let request = AlpacaBracketOrderRequest::new(
6130 InstrumentNameExchange::new("SPY"),
6131 crate::order::id::StrategyId::new("test"),
6132 crate::order::id::ClientOrderId::new("test-price"),
6133 Side::Buy,
6134 dec!(1),
6135 dec!(100.00),
6136 dec!(120.00),
6137 dec!(105.00), TimeInForce::GoodUntilCancelled { post_only: false },
6139 );
6140
6141 let result = client.open_bracket_order(request).await;
6142
6143 assert!(
6144 result.parent.state.is_failed(),
6145 "Bracket order with invalid price ordering should be rejected locally"
6146 );
6147 }
6148
6149 #[tokio::test]
6150 async fn test_open_bracket_order_rejects_invalid_sl_limit_price() {
6151 use rust_decimal_macros::dec;
6152 use rustrade_instrument::instrument::name::InstrumentNameExchange;
6153
6154 let config = AlpacaConfig::new("dummy_key".into(), "dummy_secret".into());
6155 let client = AlpacaClient::new(config);
6156
6157 let request = AlpacaBracketOrderRequest::new(
6159 InstrumentNameExchange::new("SPY"),
6160 crate::order::id::StrategyId::new("test"),
6161 crate::order::id::ClientOrderId::new("test-sl-limit"),
6162 Side::Buy,
6163 dec!(1),
6164 dec!(100.00),
6165 dec!(120.00),
6166 dec!(90.00),
6167 TimeInForce::GoodUntilCancelled { post_only: false },
6168 )
6169 .with_stop_loss_limit_price(dec!(95.00)); let result = client.open_bracket_order(request).await;
6172
6173 assert!(
6174 result.parent.state.is_failed(),
6175 "Bracket order with invalid SL limit price should be rejected locally"
6176 );
6177 }
6178
6179 #[test]
6180 fn test_non_bracket_order_omits_bracket_fields() {
6181 let body = AlpacaOrderRequest {
6183 symbol: "AAPL",
6184 qty: "1".to_string(),
6185 side: "buy",
6186 order_type: "limit",
6187 time_in_force: "gtc",
6188 limit_price: Some("150.00".to_string()),
6189 stop_price: None,
6190 trail_percent: None,
6191 trail_price: None,
6192 client_order_id: Some("regular-001"),
6193 position_intent: Some(AlpacaPositionIntent::BuyToOpen),
6194 order_class: None,
6195 take_profit: None,
6196 stop_loss: None,
6197 };
6198
6199 let json = serde_json::to_value(&body).unwrap();
6200
6201 assert_eq!(json["symbol"], "AAPL");
6202 assert!(
6203 json.get("order_class").is_none(),
6204 "order_class should be omitted for non-bracket orders"
6205 );
6206 assert!(
6207 json.get("take_profit").is_none(),
6208 "take_profit should be omitted for non-bracket orders"
6209 );
6210 assert!(
6211 json.get("stop_loss").is_none(),
6212 "stop_loss should be omitted for non-bracket orders"
6213 );
6214 }
6215
6216 #[test]
6217 fn test_parse_timestamp_valid() {
6218 let ts = parse_timestamp("2025-04-18T14:30:00Z");
6219 assert!(ts.is_some());
6220 let ts2 = parse_timestamp("2025-04-18T14:30:00.123456Z");
6221 assert!(ts2.is_some());
6222 assert_eq!(parse_timestamp("not-a-timestamp"), None);
6223 }
6224
6225 #[test]
6226 fn test_check_auth_response_authorized() {
6227 let msg =
6228 r#"{"stream":"authorization","data":{"status":"authorized","action":"authenticate"}}"#;
6229 assert!(matches!(check_auth_response(msg), Some(Ok(()))));
6230 }
6231
6232 #[test]
6233 fn test_check_auth_response_unauthorized() {
6234 let msg = r#"{"stream":"authorization","data":{"status":"unauthorized"}}"#;
6235 assert!(matches!(
6236 check_auth_response(msg),
6237 Some(Err(HandshakeError::Auth(_)))
6238 ));
6239 }
6240
6241 #[test]
6242 fn test_check_auth_response_non_auth_message() {
6243 let msg = r#"{"stream":"trade_updates","data":{}}"#;
6244 assert!(check_auth_response(msg).is_none());
6245 }
6246
6247 #[test]
6248 fn test_check_listen_ack() {
6249 let ack = r#"{"stream":"listening","data":{"streams":["trade_updates"]}}"#;
6250 assert!(check_listen_ack(ack));
6251
6252 let other = r#"{"stream":"authorization","data":{}}"#;
6253 assert!(!check_listen_ack(other));
6254 }
6255
6256 #[test]
6257 fn test_dedup_cache() {
6258 let cache = new_dedup_cache();
6259 let key = || SmolStr::new("order-1:1");
6260 assert!(
6261 !is_duplicate_fill(&cache, Some("exec-1"), key()),
6262 "first time should not be duplicate"
6263 );
6264 assert!(
6265 is_duplicate_fill(&cache, Some("exec-1"), key()),
6266 "second time should be duplicate"
6267 );
6268 }
6269
6270 #[test]
6273 fn dedup_tells_fills_apart_by_execution_id_not_cumulative() {
6274 let cache = new_dedup_cache();
6275 assert!(!is_duplicate_fill(
6276 &cache,
6277 Some("exec-a"),
6278 SmolStr::new("ord-1:5")
6279 ));
6280 assert!(
6282 !is_duplicate_fill(&cache, Some("exec-b"), SmolStr::new("ord-1:5")),
6283 "a different execution at a cumulative seen before is a new fill"
6284 );
6285 }
6286
6287 #[test]
6290 fn dedup_matches_a_fill_with_an_execution_id_to_the_same_fill_without_one() {
6291 let cache = new_dedup_cache();
6292
6293 assert!(!is_duplicate_fill(&cache, None, SmolStr::new("ord-1:2")));
6294 assert!(
6295 is_duplicate_fill(&cache, Some("exec-a"), SmolStr::new("ord-1:2")),
6296 "without an id first, then with one"
6297 );
6298
6299 assert!(!is_duplicate_fill(
6300 &cache,
6301 Some("exec-b"),
6302 SmolStr::new("ord-1:4")
6303 ));
6304 assert!(
6305 is_duplicate_fill(&cache, None, SmolStr::new("ord-1:4")),
6306 "with an id first, then without one"
6307 );
6308 }
6309
6310 #[test]
6313 fn the_reconnect_budget_is_as_documented() {
6314 assert_eq!(MAX_RECONNECT_ATTEMPTS, 10);
6315 assert_eq!(INITIAL_BACKOFF_MS, 1_000);
6316 assert_eq!(MAX_BACKOFF_MS, 30_000);
6317 assert_eq!(HEARTBEAT_TIMEOUT_SECS, 35);
6318 }
6319
6320 #[tokio::test]
6321 async fn test_exponential_backoff_progression_and_exhaustion() {
6322 tokio::time::pause();
6323
6324 let mut b = ExponentialBackoff::new();
6325
6326 assert!(b.wait().await, "first wait should return true");
6328 assert_eq!(b.attempt, 1);
6329
6330 while b.wait().await {}
6332
6333 assert_eq!(b.attempt, MAX_RECONNECT_ATTEMPTS);
6335
6336 assert!(!b.wait().await, "exhausted backoff should return false");
6338
6339 b.reset();
6341 assert_eq!(b.attempt, 0);
6342
6343 assert!(b.wait().await, "wait should succeed after reset");
6345 assert_eq!(b.attempt, 1);
6346 }
6347
6348 #[test]
6349 fn test_convert_account_to_balances_empty_assets() {
6350 let account: AlpacaAccount = serde_json::from_value(serde_json::json!({
6353 "cash": "9000.50",
6354 "equity": "12000.00",
6355 "buying_power": "36000.00",
6356 "regt_buying_power": "18000.00",
6357 "options_buying_power": "8000.00",
6358 "non_marginable_buying_power": "8500.25",
6359 }))
6360 .unwrap();
6361 let balances = convert_account_to_balances(&account, &[]);
6362 assert_eq!(balances.len(), 1);
6363 assert_eq!(balances[0].asset, AssetNameExchange::new("USD"));
6364 assert_eq!(balances[0].balance.total, dec!(9000.50), "total is cash");
6365 assert_eq!(
6366 balances[0].balance.free,
6367 dec!(8500.25),
6368 "free is non-marginable buying power when below cash"
6369 );
6370 assert_eq!(balances[0].balance.margin, None);
6371 }
6372
6373 #[test]
6376 fn test_convert_account_to_balances_free_capped_at_cash_with_long_stock() {
6377 let account: AlpacaAccount = serde_json::from_value(serde_json::json!({
6378 "cash": "50000",
6379 "equity": "100000",
6380 "initial_margin": "25000",
6381 "non_marginable_buying_power": "75000",
6382 }))
6383 .unwrap();
6384 let balances = convert_account_to_balances(&account, &[]);
6385 assert_eq!(balances[0].balance.total, dec!(50000));
6386 assert_eq!(balances[0].balance.free, dec!(50000));
6387 }
6388
6389 #[test]
6392 fn test_convert_account_to_balances_open_short_keeps_free_below_cash() {
6393 let account: AlpacaAccount = serde_json::from_value(serde_json::json!({
6394 "cash": "10200",
6395 "equity": "10000",
6396 "initial_margin": "100",
6397 "non_marginable_buying_power": "9900",
6398 }))
6399 .unwrap();
6400 let balances = convert_account_to_balances(&account, &[]);
6401 assert_eq!(balances[0].balance.total, dec!(10200));
6402 assert_eq!(balances[0].balance.free, dec!(9900));
6403 }
6404
6405 #[test]
6406 fn test_convert_account_to_balances_negative_cash_on_margin() {
6407 let account: AlpacaAccount = serde_json::from_value(serde_json::json!({
6408 "cash": "-2500.75",
6409 "equity": "7500.00",
6410 "buying_power": "5000.00",
6411 "non_marginable_buying_power": "1250.00",
6412 }))
6413 .unwrap();
6414 let balances = convert_account_to_balances(&account, &[]);
6415 assert_eq!(balances.len(), 1);
6416 assert_eq!(balances[0].balance.total, dec!(-2500.75));
6417 assert_eq!(
6418 balances[0].balance.free,
6419 dec!(-2500.75),
6420 "no free cash while borrowing; free <= total still holds"
6421 );
6422 }
6423
6424 #[test]
6425 fn test_alpaca_account_malformed_cash_fails_to_decode() {
6426 let result = serde_json::from_value::<AlpacaAccount>(serde_json::json!({
6427 "cash": "not-a-number",
6428 "non_marginable_buying_power": "0",
6429 }));
6430 assert!(result.is_err(), "a malformed cash must not read as zero");
6431 }
6432
6433 #[test]
6434 fn test_convert_account_to_balances_usd_filter() {
6435 let account = AlpacaAccount {
6436 cash: dec!(12000.00),
6437 non_marginable_buying_power: dec!(10000.00),
6438 };
6439 let usd = vec![AssetNameExchange::new("USD")];
6440 let balances = convert_account_to_balances(&account, &usd);
6441 assert_eq!(balances.len(), 1);
6442 assert_eq!(balances[0].asset, AssetNameExchange::new("USD"));
6443
6444 let non_usd = vec![AssetNameExchange::new("BTC")];
6445 let balances = convert_account_to_balances(&account, &non_usd);
6446 assert!(balances.is_empty());
6447 }
6448
6449 #[test]
6452 fn a_usd_balance_is_named_usd_however_the_filter_spells_it() {
6453 let account = AlpacaAccount {
6454 cash: dec!(12000.00),
6455 non_marginable_buying_power: dec!(10000.00),
6456 };
6457 let balances = convert_account_to_balances(&account, &[AssetNameExchange::new("usd")]);
6458 assert_eq!(balances.len(), 1);
6459 assert_eq!(balances[0].asset, AssetNameExchange::new("USD"));
6460 }
6461
6462 #[test]
6463 fn test_is_options_or_equity_symbol() {
6464 assert!(!is_options_or_equity_symbol("BTC/USD"));
6466 assert!(!is_options_or_equity_symbol("ETH/USD"));
6467 assert!(!is_options_or_equity_symbol("SOL/USD"));
6468
6469 assert!(is_options_or_equity_symbol("AAPL"));
6471 assert!(is_options_or_equity_symbol("SPY"));
6472 assert!(is_options_or_equity_symbol("MSFT"));
6473
6474 assert!(is_options_or_equity_symbol("SPY250418C00450000"));
6476 assert!(is_options_or_equity_symbol("AAPL250418P00145000"));
6477 }
6478
6479 #[test]
6480 fn test_parse_order_error_already_cancelled() {
6481 assert!(matches!(
6484 parse_order_error(
6485 reqwest::StatusCode::UNPROCESSABLE_ENTITY,
6486 None,
6487 "order is already cancelled"
6488 ),
6489 UnindexedOrderError::Rejected(ApiError::OrderAlreadyCancelled)
6490 ));
6491 }
6492
6493 #[test]
6494 fn test_parse_order_error_already_wins_over_insufficient_on_422() {
6495 assert!(matches!(
6498 parse_order_error(
6499 reqwest::StatusCode::UNPROCESSABLE_ENTITY,
6500 None,
6501 "order already cancelled due to insufficient margin"
6502 ),
6503 UnindexedOrderError::Rejected(ApiError::OrderAlreadyCancelled)
6504 ));
6505 }
6506
6507 fn make_order_ws<'a>(
6508 id: &str,
6509 symbol: &str,
6510 side: &str,
6511 filled_qty: &'a str,
6512 ) -> AlpacaOrderWs<'a> {
6513 AlpacaOrderWs {
6514 id: SmolStr::new(id),
6515 client_order_id: None,
6516 symbol: SmolStr::new(symbol),
6517 qty: Some("2"),
6518 filled_qty: Some(filled_qty),
6519 side: SmolStr::new(side),
6520 order_type: SmolStr::new("limit"),
6521 time_in_force: SmolStr::new("day"),
6522 limit_price: Some("100.00"),
6523 stop_price: None,
6524 trail_percent: None,
6525 trail_price: None,
6526 status: SmolStr::new("partially_filled"),
6527 }
6528 }
6529
6530 fn sole_event(events: [Option<UnindexedAccountEvent>; 2]) -> Option<UnindexedAccountEvent> {
6535 let [first, second] = events;
6536 assert!(
6537 second.is_none(),
6538 "expected a single event, got a second: {second:?}"
6539 );
6540 first
6541 }
6542
6543 fn fill_events(
6545 events: [Option<UnindexedAccountEvent>; 2],
6546 ) -> (
6547 Trade<AssetNameExchange, InstrumentNameExchange>,
6548 crate::order::Order<
6549 ExchangeId,
6550 InstrumentNameExchange,
6551 OrderState<AssetNameExchange, InstrumentNameExchange>,
6552 >,
6553 ) {
6554 let [first, second] = events;
6555 let first = first.expect("a fill frame must produce an execution");
6556 let second = second.expect("a fill frame must produce an order snapshot");
6557 let AccountEventKind::Trade(trade) = first.kind else {
6558 panic!("first event must be the Trade, got {:?}", first.kind);
6559 };
6560 let AccountEventKind::OrderSnapshot(rustrade_integration::collection::snapshot::Snapshot(
6561 order,
6562 )) = second.kind
6563 else {
6564 panic!(
6565 "second event must be the OrderSnapshot, got {:?}",
6566 second.kind
6567 );
6568 };
6569 (trade, order)
6570 }
6571
6572 #[test]
6575 fn test_convert_trade_update_fill_carries_the_client_order_id() {
6576 let mut order = make_order_ws("ord-1", "SPY", "buy", "1");
6577 order.client_order_id = Some(SmolStr::new("outside-1"));
6578 let update = AlpacaTradeUpdate {
6579 event: SmolStr::new("fill"),
6580 execution_id: Some("exec-1"),
6581 previous_execution_id: None,
6582 order,
6583 price: Some("150.00"),
6584 qty: Some("1"),
6585 timestamp: Some("2025-04-18T14:30:00Z"),
6586 };
6587 let (trade, _order) = fill_events(convert_trade_update(update));
6588 assert_eq!(trade.client_order_id, Some(ClientOrderId::new("outside-1")));
6589 }
6590
6591 #[test]
6592 fn test_convert_trade_update_fill_produces_trade_with_execution_id() {
6593 let update = AlpacaTradeUpdate {
6594 event: SmolStr::new("fill"),
6595 execution_id: Some("exec-1"),
6596 previous_execution_id: None,
6597 order: make_order_ws("ord-1", "SPY", "buy", "1"),
6598 price: Some("150.00"),
6599 qty: Some("1"),
6600 timestamp: Some("2025-04-18T14:30:00Z"),
6601 };
6602 let (trade, order) = fill_events(convert_trade_update(update));
6603 assert_eq!(trade.id.0.as_str(), "exec-1");
6605 assert_eq!(
6606 trade.client_order_id, None,
6607 "the update names no client order id"
6608 );
6609 assert_eq!(trade.price, Decimal::from_str("150.00").unwrap());
6610 assert_eq!(trade.quantity, Decimal::from_str("1").unwrap());
6611
6612 let OrderState::Active(crate::order::state::ActiveOrderState::Open(open)) = order.state
6615 else {
6616 panic!("expected an Open snapshot, got {:?}", order.state);
6617 };
6618 assert_eq!(open.filled_quantity, Decimal::from_str("1").unwrap());
6619 assert_eq!(open.id.assigned().map(|id| id.0.as_str()), Some("ord-1"));
6620 }
6621
6622 #[test]
6623 fn test_convert_trade_update_partial_fill() {
6624 let update = AlpacaTradeUpdate {
6625 event: SmolStr::new("partial_fill"),
6626 execution_id: None,
6627 previous_execution_id: None,
6628 order: make_order_ws("ord-2", "AAPL", "sell", "0.5"),
6629 price: Some("200.00"),
6630 qty: Some("0.5"),
6631 timestamp: None,
6632 };
6633 let (trade, order) = fill_events(convert_trade_update(update));
6634 assert_eq!(trade.quantity, Decimal::from_str("0.5").unwrap());
6635
6636 let OrderState::Active(crate::order::state::ActiveOrderState::Open(open)) = order.state
6639 else {
6640 panic!("expected an Open snapshot, got {:?}", order.state);
6641 };
6642 assert_eq!(open.filled_quantity, Decimal::from_str("0.5").unwrap());
6643 assert_eq!(
6644 open.quantity_remaining(order.quantity),
6645 Decimal::from_str("1.5").unwrap()
6646 );
6647 }
6648
6649 #[test]
6653 fn a_full_fill_snapshot_reports_nothing_left_to_fill() {
6654 let update = AlpacaTradeUpdate {
6655 event: SmolStr::new("fill"),
6656 execution_id: None,
6657 previous_execution_id: None,
6658 order: AlpacaOrderWs {
6660 status: SmolStr::new("filled"),
6661 ..make_order_ws("ord-full", "SPY", "buy", "2")
6662 },
6663 price: Some("150.00"),
6664 qty: Some("1"),
6665 timestamp: Some("2025-04-18T14:30:00Z"),
6666 };
6667 let (_trade, order) = fill_events(convert_trade_update(update));
6668 let OrderState::Active(crate::order::state::ActiveOrderState::Open(open)) = order.state
6669 else {
6670 panic!("expected an Open snapshot, got {:?}", order.state);
6671 };
6672 assert_eq!(open.filled_quantity, Decimal::from_str("2").unwrap());
6673 assert!(
6674 open.quantity_remaining(order.quantity).is_zero(),
6675 "a completed order must report nothing left to fill so the engine retires it"
6676 );
6677 }
6678
6679 #[test]
6683 fn a_fill_for_an_order_no_longer_working_emits_the_execution_without_a_snapshot() {
6684 for status in [
6685 "canceled",
6686 "expired",
6687 "rejected",
6688 "done_for_day",
6689 "pending_cancel",
6690 ] {
6691 let update = AlpacaTradeUpdate {
6692 event: SmolStr::new("partial_fill"),
6693 execution_id: None,
6694 previous_execution_id: None,
6695 order: AlpacaOrderWs {
6696 status: SmolStr::new(status),
6697 ..make_order_ws("ord-late", "SPY", "buy", "1")
6698 },
6699 price: Some("150.00"),
6700 qty: Some("1"),
6701 timestamp: None,
6702 };
6703 let [trade, snapshot] = convert_trade_update(update);
6704 assert!(
6705 matches!(trade.map(|e| e.kind), Some(AccountEventKind::Trade(_))),
6706 "the execution must still be reported for status {status}"
6707 );
6708 assert!(
6709 snapshot.is_none(),
6710 "status {status} must not produce an order snapshot"
6711 );
6712 }
6713 }
6714
6715 #[test]
6718 fn a_notional_order_fill_emits_the_execution_without_a_snapshot() {
6719 let update = AlpacaTradeUpdate {
6720 event: SmolStr::new("partial_fill"),
6721 execution_id: None,
6722 previous_execution_id: None,
6723 order: AlpacaOrderWs {
6724 qty: None,
6725 ..make_order_ws("ord-notional-ws", "SPY", "buy", "1")
6726 },
6727 price: Some("150.00"),
6728 qty: Some("1"),
6729 timestamp: None,
6730 };
6731 let [trade, snapshot] = convert_trade_update(update);
6732 assert!(matches!(
6733 trade.map(|e| e.kind),
6734 Some(AccountEventKind::Trade(_))
6735 ));
6736 assert!(
6737 snapshot.is_none(),
6738 "a notional order has no quantity to snapshot against"
6739 );
6740 }
6741
6742 #[test]
6743 fn test_convert_trade_update_new_order_produces_snapshot() {
6744 let update = AlpacaTradeUpdate {
6745 event: SmolStr::new("new"),
6746 execution_id: None,
6747 previous_execution_id: None,
6748 order: AlpacaOrderWs {
6749 id: SmolStr::new("ord-new"),
6750 client_order_id: Some(SmolStr::new("cid-1")),
6751 symbol: SmolStr::new("AAPL"),
6752 qty: Some("10"),
6753 filled_qty: Some("0"),
6754 side: SmolStr::new("buy"),
6755 order_type: SmolStr::new("limit"),
6756 time_in_force: SmolStr::new("day"),
6757 limit_price: Some("150.00"),
6758 stop_price: None,
6759 trail_percent: None,
6760 trail_price: None,
6761 status: SmolStr::new("new"),
6762 },
6763 price: None,
6764 qty: None,
6765 timestamp: Some("2025-04-18T14:30:00Z"),
6766 };
6767 let event =
6768 sole_event(convert_trade_update(update)).expect("new event should produce an event");
6769 assert!(matches!(event.kind, AccountEventKind::OrderSnapshot(_)));
6770 }
6771
6772 #[test]
6773 fn test_convert_trade_update_canceled_produces_cancel() {
6774 let update = AlpacaTradeUpdate {
6775 event: SmolStr::new("canceled"),
6776 execution_id: None,
6777 previous_execution_id: None,
6778 order: make_order_ws("ord-3", "AAPL", "sell", "0"),
6779 price: None,
6780 qty: None,
6781 timestamp: Some("2025-04-18T14:30:00Z"),
6782 };
6783 let event =
6784 sole_event(convert_trade_update(update)).expect("canceled should produce an event");
6785 let AccountEventKind::OrderCancelled(response) = event.kind else {
6786 panic!("expected OrderCancelled, got {:?}", event.kind);
6787 };
6788 assert!(response.state.is_ok());
6789 }
6790
6791 #[test]
6793 fn a_canceled_update_carries_its_fill_or_none() {
6794 let cancelled = |filled_qty: Option<&'static str>| {
6795 let mut order = make_order_ws("ord-3", "AAPL", "sell", "0");
6796 order.filled_qty = filled_qty;
6797 let update = AlpacaTradeUpdate {
6798 event: SmolStr::new("canceled"),
6799 execution_id: None,
6800 previous_execution_id: None,
6801 order,
6802 price: None,
6803 qty: None,
6804 timestamp: Some("2025-04-18T14:30:00Z"),
6805 };
6806 let event =
6807 sole_event(convert_trade_update(update)).expect("canceled should produce an event");
6808 let AccountEventKind::OrderCancelled(response) = event.kind else {
6809 panic!("expected OrderCancelled, got {:?}", event.kind);
6810 };
6811 let Ok(cancelled) = response.state else {
6812 panic!("expected Ok, got {:?}", response.state);
6813 };
6814 cancelled.filled_quantity
6815 };
6816
6817 assert_eq!(cancelled(Some("1")), Some(Decimal::ONE));
6818 assert_eq!(cancelled(Some("0")), Some(Decimal::ZERO));
6819 assert_eq!(cancelled(None), None);
6820 assert_eq!(cancelled(Some("not a number")), None);
6821 }
6822
6823 #[test]
6826 fn an_ended_order_with_an_unparseable_fill_reports_it_unknown() {
6827 let key = OrderKey::new(
6828 ExchangeId::AlpacaBroker,
6829 InstrumentNameExchange::new("AAPL"),
6830 StrategyId::new("strategy"),
6831 ClientOrderId::new("c"),
6832 );
6833 let ended = |status: &str| {
6834 let mut order = make_order_response("o1", "AAPL");
6835 order.status = Some(status.to_string());
6836 order.filled_qty = "not a number".to_string();
6837 convert_ended_order(&order, &key).map(|order| order.state)
6838 };
6839
6840 assert!(matches!(
6841 ended("canceled"),
6842 Some(InactiveOrderState::Cancelled(Cancelled {
6843 filled_quantity: None,
6844 ..
6845 }))
6846 ));
6847 assert!(matches!(
6848 ended("expired"),
6849 Some(InactiveOrderState::Expired(Expired {
6850 filled_quantity: None,
6851 ..
6852 }))
6853 ));
6854 assert!(matches!(
6855 ended("filled"),
6856 Some(InactiveOrderState::FullyFilled(Filled { filled_quantity, .. }))
6857 if filled_quantity == Decimal::ONE
6858 ));
6859 }
6860
6861 #[test]
6862 fn test_convert_trade_update_rejected_produces_error() {
6863 let update = AlpacaTradeUpdate {
6864 event: SmolStr::new("rejected"),
6865 execution_id: None,
6866 previous_execution_id: None,
6867 order: make_order_ws("ord-4", "SPY", "buy", "0"),
6868 price: None,
6869 qty: None,
6870 timestamp: None,
6871 };
6872 let event =
6873 sole_event(convert_trade_update(update)).expect("rejected should produce an event");
6874 let AccountEventKind::OrderCancelled(response) = event.kind else {
6875 panic!("expected OrderCancelled, got {:?}", event.kind);
6876 };
6877 assert!(response.state.is_err());
6878 }
6879
6880 #[test]
6881 fn test_convert_open_order_notional_qty_none_is_skipped() {
6882 let order = AlpacaOrderResponse {
6885 id: "ord-notional".to_string(),
6886 client_order_id: None,
6887 symbol: "SPY".to_string(),
6888 qty: None,
6889 filled_qty: "0".to_string(),
6890 filled_avg_price: None,
6891 status: None,
6892 side: "buy".to_string(),
6893 order_type: "market".to_string(),
6894 time_in_force: "day".to_string(),
6895 limit_price: None,
6896 stop_price: None,
6897 trail_percent: None,
6898 trail_price: None,
6899 created_at: "2025-04-18T14:30:00Z".to_string(),
6900 updated_at: None,
6901 };
6902 assert!(convert_open_order(&order).is_none());
6903 }
6904
6905 #[test]
6906 fn test_convert_activity_to_trade_bad_price_returns_none() {
6907 let activity = AlpacaActivity {
6911 id: "act-1".to_string(),
6912 order_id: "ord-1".to_string(),
6913 symbol: "SPY250418C00450000".to_string(),
6914 side: "buy".to_string(),
6915 price: "not-a-number".to_string(),
6916 qty: "1".to_string(),
6917 transaction_time: "2025-04-18T14:30:00Z".to_string(),
6918 cum_qty: Some("1".to_string()),
6919 };
6920 assert!(convert_activity_to_trade(&activity).is_none());
6921 }
6922
6923 #[test]
6924 fn test_convert_positions_to_balances_crypto() {
6925 let positions = vec![
6926 AlpacaPosition {
6928 qty_available: dec!(0.4),
6929 ..alpaca_position("BTCUSD", "crypto", AlpacaPositionSide::Long, dec!(0.5))
6930 },
6931 alpaca_position("ETH/USD", "crypto", AlpacaPositionSide::Long, dec!(2.0)),
6933 alpaca_position("AAPL", "us_equity", AlpacaPositionSide::Long, dec!(10)),
6935 ];
6936
6937 let balances = convert_positions_to_balances(&positions, &[]);
6939 assert_eq!(balances.len(), 2, "only crypto positions returned");
6940 assert_eq!(balances[0].asset.name().as_str(), "BTC");
6941 assert_eq!(balances[1].asset.name().as_str(), "ETH");
6942 assert_eq!(balances[0].balance.total, dec!(0.5));
6944 assert_eq!(balances[0].balance.free, dec!(0.4));
6945
6946 let btc_only = vec![AssetNameExchange::new("btc")];
6948 let balances = convert_positions_to_balances(&positions, &btc_only);
6949 assert_eq!(balances.len(), 1);
6950 assert_eq!(balances[0].asset.name().as_str(), "BTC");
6951 }
6952
6953 #[test]
6954 fn test_crypto_position_asset() {
6955 assert_eq!(crypto_position_asset("BTCUSD"), "BTC");
6956 assert_eq!(
6957 crypto_position_asset("btcusd"),
6958 "btc",
6959 "suffix matched ignoring case"
6960 );
6961 assert_eq!(
6962 crypto_position_asset("USDTUSD"),
6963 "USDT",
6964 "only one USD is stripped"
6965 );
6966 assert_eq!(crypto_position_asset("BTC/USD"), "BTC");
6967 assert_eq!(crypto_position_asset("BTC/USDT"), "BTC");
6968 assert_eq!(crypto_position_asset("€USD"), "€");
6969 assert_eq!(crypto_position_asset("USD"), "USD");
6971 assert_eq!(crypto_position_asset("/USD"), "/USD");
6972 assert_eq!(crypto_position_asset("BTCUSDT"), "BTCUSDT");
6973 assert_eq!(
6974 crypto_position_asset("BTCێ"),
6975 "BTCێ",
6976 "no char boundary at the suffix"
6977 );
6978 assert_eq!(crypto_position_asset(""), "");
6979 }
6980
6981 fn alpaca_position(
6983 symbol: &str,
6984 asset_class: &str,
6985 side: AlpacaPositionSide,
6986 qty: Decimal,
6987 ) -> AlpacaPosition {
6988 AlpacaPosition {
6989 symbol: symbol.into(),
6990 asset_class: asset_class.into(),
6991 side,
6992 qty,
6993 qty_available: qty,
6994 avg_entry_price: Some(dec!(100)),
6995 unrealized_pl: Some(dec!(0)),
6996 }
6997 }
6998
6999 #[test]
7000 fn test_convert_positions_sign_comes_from_side_not_qty() {
7001 let now = Utc::now();
7002 let positions = vec![
7003 alpaca_position("AAPL", "us_equity", AlpacaPositionSide::Long, dec!(10)),
7004 alpaca_position("TSLA", "us_equity", AlpacaPositionSide::Short, dec!(-3)),
7005 alpaca_position("MSFT", "us_equity", AlpacaPositionSide::Short, dec!(4)),
7007 ];
7008
7009 let converted = convert_positions(&positions, now).unwrap();
7010 let quantities: Vec<_> = converted.iter().map(|(s, p)| (*s, p.quantity)).collect();
7011 assert_eq!(
7012 quantities,
7013 vec![("AAPL", dec!(10)), ("TSLA", dec!(-3)), ("MSFT", dec!(-4))]
7014 );
7015 assert!(converted.iter().all(|(_, p)| p.time_exchange == now));
7016 }
7017
7018 #[test]
7019 fn test_convert_positions_leaves_out_crypto_and_flat() {
7020 let positions = vec![
7021 alpaca_position("BTC/USD", "crypto", AlpacaPositionSide::Long, dec!(0.5)),
7022 alpaca_position("AAPL", "us_equity", AlpacaPositionSide::Long, dec!(0)),
7023 alpaca_position("SPY", "us_equity", AlpacaPositionSide::Long, dec!(1)),
7024 ];
7025
7026 let converted = convert_positions(&positions, Utc::now()).unwrap();
7027 let symbols: Vec<_> = converted.iter().map(|(s, _)| *s).collect();
7028 assert_eq!(symbols, vec!["SPY"]);
7029 }
7030
7031 #[test]
7032 fn test_alpaca_position_decodes_equity_option_and_fractional() {
7033 let positions: Vec<AlpacaPosition> = serde_json::from_value(serde_json::json!([
7034 {
7035 "asset_id": "00000000-0000-0000-0000-000000000001",
7036 "symbol": "XYZ",
7037 "exchange": "NASDAQ",
7038 "asset_class": "us_equity",
7039 "qty": "-7",
7040 "qty_available": "-7",
7041 "side": "short",
7042 "avg_entry_price": "50.25",
7043 "market_value": "-351.75",
7044 "unrealized_pl": "0",
7045 },
7046 {
7047 "symbol": "XYZ271217C00055000",
7048 "asset_class": "us_option",
7049 "qty": "2",
7050 "qty_available": "2",
7051 "side": "long",
7052 "avg_entry_price": "1.35",
7053 "unrealized_pl": "-20",
7054 },
7055 {
7056 "symbol": "ABC",
7057 "asset_class": "us_equity",
7058 "qty": "0.125",
7059 "qty_available": "0.125",
7060 "side": "long",
7061 "avg_entry_price": "80",
7062 "unrealized_pl": null,
7063 },
7064 ]))
7065 .unwrap();
7066
7067 let converted = convert_positions(&positions, Utc::now()).unwrap();
7068 let [
7069 (short_sym, short),
7070 (option_sym, option),
7071 (fraction_sym, fraction),
7072 ] = converted.as_slice()
7073 else {
7074 panic!("expected three positions, got {converted:?}");
7075 };
7076
7077 assert_eq!(*short_sym, "XYZ");
7078 assert_eq!(short.quantity, dec!(-7));
7079 assert_eq!(short.entry_price, Some(dec!(50.25)));
7080 assert!(short.is_short());
7081
7082 assert_eq!(*option_sym, "XYZ271217C00055000");
7083 assert_eq!(option.quantity, dec!(2));
7084 assert_eq!(option.entry_price, Some(dec!(1.35)), "per-share premium");
7085 assert_eq!(option.unrealized_pnl, Some(dec!(-20)));
7086
7087 assert_eq!(*fraction_sym, "ABC");
7088 assert_eq!(fraction.quantity, dec!(0.125));
7089 assert_eq!(fraction.unrealized_pnl, None);
7090
7091 for (_, p) in &converted {
7092 assert_eq!(p.margin_used, None);
7093 assert_eq!(p.liquidation_price, None);
7094 assert_eq!(p.leverage, None);
7095 }
7096 }
7097
7098 #[test]
7101 fn test_alpaca_position_unknown_side_fails_only_where_a_direction_is_needed() {
7102 let positions: Vec<AlpacaPosition> = serde_json::from_value(serde_json::json!([
7103 {
7104 "symbol": "BTC/USD",
7105 "asset_class": "crypto",
7106 "qty": "0.5",
7107 "qty_available": "0.5",
7108 },
7109 {
7110 "symbol": "XYZ",
7111 "asset_class": "us_equity",
7112 "qty": "1",
7113 "qty_available": "1",
7114 "side": "sideways",
7115 },
7116 ]))
7117 .unwrap();
7118 assert_eq!(positions[0].side, AlpacaPositionSide::Unknown);
7119 assert_eq!(positions[1].side, AlpacaPositionSide::Unknown);
7120
7121 let balances = convert_positions_to_balances(&positions, &[]);
7122 assert_eq!(balances.len(), 1);
7123 assert_eq!(balances[0].balance.total, dec!(0.5));
7124
7125 let result = convert_positions(&positions, Utc::now());
7126 assert!(
7127 matches!(&result, Err(UnindexedClientError::Internal(msg)) if msg.contains("XYZ")),
7128 "an unknown side must not guess a direction: {result:?}"
7129 );
7130 }
7131
7132 #[test]
7134 fn order_state_time_prefers_updated_at() {
7135 let mut resp = make_order_response("ord-1", "SPY");
7136 resp.created_at = "2025-04-18T14:30:00Z".to_string();
7137 resp.updated_at = Some("2025-04-18T15:45:00Z".to_string());
7138 assert_eq!(
7139 order_state_time(&resp),
7140 parse_timestamp("2025-04-18T15:45:00Z").unwrap(),
7141 "updated_at must win over created_at"
7142 );
7143
7144 resp.updated_at = None;
7146 assert_eq!(
7147 order_state_time(&resp),
7148 parse_timestamp("2025-04-18T14:30:00Z").unwrap(),
7149 "absent updated_at falls back to created_at"
7150 );
7151
7152 resp.updated_at = Some("not-a-timestamp".to_string());
7155 assert_eq!(
7156 order_state_time(&resp),
7157 parse_timestamp("2025-04-18T14:30:00Z").unwrap(),
7158 "unparseable updated_at falls back to created_at"
7159 );
7160 }
7161
7162 #[test]
7165 fn a_placement_response_without_a_status_reads_from_what_filled() {
7166 let instrument = InstrumentNameExchange::new("SPY");
7167 let mut resp = make_order_response("ord-1", "SPY");
7168 resp.filled_qty = "0.4".to_string();
7169 let OrderState::Active(ActiveOrderState::Open(open)) =
7170 placed_order_state(&resp, &instrument, Decimal::ONE)
7171 else {
7172 panic!("expected Open");
7173 };
7174 assert_eq!(open.filled_quantity, Decimal::new(4, 1));
7175
7176 resp.filled_qty = "1".to_string();
7177 resp.filled_avg_price = Some("100.5".to_string());
7178 let state = placed_order_state(&resp, &instrument, Decimal::ONE);
7179 let OrderState::Inactive(InactiveOrderState::FullyFilled(filled)) = state else {
7180 panic!("expected FullyFilled, got {state:?}");
7181 };
7182 assert_eq!(filled.filled_quantity, Decimal::ONE);
7183 assert_eq!(filled.avg_price, Some(Decimal::new(1005, 1)));
7184 }
7185
7186 fn make_order_response(id: &str, symbol: &str) -> AlpacaOrderResponse {
7187 AlpacaOrderResponse {
7188 id: id.to_string(),
7189 client_order_id: None,
7190 symbol: symbol.to_string(),
7191 qty: Some("1".to_string()),
7192 filled_qty: "0".to_string(),
7193 filled_avg_price: None,
7194 status: None,
7195 side: "buy".to_string(),
7196 order_type: "limit".to_string(),
7197 time_in_force: "day".to_string(),
7198 limit_price: Some("100.00".to_string()),
7199 stop_price: None,
7200 trail_percent: None,
7201 trail_price: None,
7202 created_at: "2025-04-18T14:30:00Z".to_string(),
7203 updated_at: None,
7204 }
7205 }
7206
7207 #[test]
7208 fn test_build_instrument_snapshots_empty_instruments_returns_only_with_orders() {
7209 let orders = vec![
7210 make_order_response("o1", "AAPL"),
7211 make_order_response("o2", "SPY"),
7212 ];
7213 let snapshots = build_instrument_snapshots(orders, Vec::new(), &[]);
7214 assert_eq!(snapshots.len(), 2);
7215 let symbols: Vec<&str> = snapshots
7216 .iter()
7217 .map(|s| s.instrument.name().as_str())
7218 .collect();
7219 assert!(symbols.contains(&"AAPL"));
7220 assert!(symbols.contains(&"SPY"));
7221 }
7222
7223 #[test]
7224 fn test_build_instrument_snapshots_requested_instrument_no_orders_gets_empty_snapshot() {
7225 let orders = vec![make_order_response("o1", "AAPL")];
7228 let instruments = vec![
7229 InstrumentNameExchange::new("AAPL"),
7230 InstrumentNameExchange::new("SPY"),
7231 ];
7232 let snapshots = build_instrument_snapshots(orders, Vec::new(), &instruments);
7233 assert_eq!(snapshots.len(), 2);
7234 let spy = snapshots
7235 .iter()
7236 .find(|s| s.instrument.name().as_str() == "SPY")
7237 .expect("SPY snapshot must be present even with no orders");
7238 assert!(spy.orders.is_empty());
7239 }
7240
7241 fn make_notional_order_response(id: &str, symbol: &str) -> AlpacaOrderResponse {
7243 AlpacaOrderResponse {
7244 qty: None,
7245 ..make_order_response(id, symbol)
7246 }
7247 }
7248
7249 fn snapshot_for<'a>(
7250 snapshots: &'a [InstrumentAccountSnapshot<
7251 ExchangeId,
7252 AssetNameExchange,
7253 InstrumentNameExchange,
7254 >],
7255 symbol: &str,
7256 ) -> &'a InstrumentAccountSnapshot<ExchangeId, AssetNameExchange, InstrumentNameExchange> {
7257 snapshots
7258 .iter()
7259 .find(|snapshot| snapshot.instrument.name().as_str() == symbol)
7260 .unwrap_or_else(|| panic!("no snapshot for {symbol}"))
7261 }
7262
7263 #[test]
7267 fn test_build_instrument_snapshots_declares_complete_lists_complete() {
7268 let instruments = [
7269 InstrumentNameExchange::new("AAPL"),
7270 InstrumentNameExchange::new("SPY"),
7271 ];
7272 for requested in [&instruments[..], &[]] {
7273 let orders = vec![make_order_response("o1", "AAPL")];
7274 let snapshots = build_instrument_snapshots(orders, Vec::new(), requested);
7275 assert!(!snapshots.is_empty());
7276 assert!(snapshots.iter().all(|snapshot| snapshot.orders_complete));
7277 }
7278 }
7279
7280 #[test]
7284 fn test_build_instrument_snapshots_notional_order_leaves_only_its_instrument_incomplete() {
7285 let orders = vec![
7286 make_order_response("o1", "AAPL"),
7287 make_order_response("o2", "SPY"),
7288 make_notional_order_response("o3", "SPY"),
7289 ];
7290 let instruments = [
7291 InstrumentNameExchange::new("AAPL"),
7292 InstrumentNameExchange::new("SPY"),
7293 ];
7294 let snapshots = build_instrument_snapshots(orders, Vec::new(), &instruments);
7295
7296 assert!(snapshot_for(&snapshots, "AAPL").orders_complete);
7297 let spy = snapshot_for(&snapshots, "SPY");
7298 assert!(!spy.orders_complete);
7299 assert_eq!(spy.orders.len(), 1);
7300 }
7301
7302 #[test]
7305 fn test_build_instrument_snapshots_unfiltered_lists_an_instrument_with_only_a_notional_order() {
7306 let orders = vec![make_notional_order_response("o1", "SPY")];
7307 let snapshots = build_instrument_snapshots(orders, Vec::new(), &[]);
7308
7309 let spy = snapshot_for(&snapshots, "SPY");
7310 assert!(!spy.orders_complete);
7311 assert!(spy.orders.is_empty());
7312 }
7313
7314 #[test]
7315 fn test_build_instrument_snapshots_non_requested_instrument_excluded() {
7316 let orders = vec![
7319 make_order_response("o1", "AAPL"),
7320 make_order_response("o2", "MSFT"), ];
7322 let instruments = vec![InstrumentNameExchange::new("AAPL")];
7323 let snapshots = build_instrument_snapshots(orders, Vec::new(), &instruments);
7324 assert_eq!(snapshots.len(), 1);
7325 assert_eq!(snapshots[0].instrument.name().as_str(), "AAPL");
7326 }
7327
7328 fn position(quantity: Decimal) -> Position {
7329 Position::new(
7330 quantity,
7331 Some(dec!(100)),
7332 None,
7333 None,
7334 None,
7335 None,
7336 None,
7337 Utc::now(),
7338 )
7339 }
7340
7341 #[test]
7342 fn test_build_instrument_snapshots_unfiltered_lists_positions_without_orders() {
7343 let orders = vec![
7344 make_order_response("o1", "AAPL"),
7345 make_order_response("o2", "MSFT"),
7346 make_order_response("o3", "BTC/USD"),
7347 ];
7348 let positions = vec![("AAPL", position(dec!(5))), ("TSLA", position(dec!(-2)))];
7349
7350 let snapshots = build_instrument_snapshots(orders, positions, &[]);
7351 let listed: Vec<_> = snapshots
7352 .iter()
7353 .map(|s| {
7354 (
7355 s.instrument.name().as_str(),
7356 s.orders.len(),
7357 s.position.quantity(),
7358 )
7359 })
7360 .collect();
7361 assert_eq!(
7363 listed,
7364 vec![
7365 ("AAPL", 1, Some(dec!(5))),
7366 ("MSFT", 1, Some(Decimal::ZERO)),
7367 ("BTC/USD", 1, None),
7368 ("TSLA", 0, Some(dec!(-2)))
7369 ]
7370 );
7371 assert!(snapshots.iter().all(|s| s.orders_complete));
7372 }
7373
7374 #[test]
7375 fn test_build_instrument_snapshots_filtered_attaches_only_requested_positions() {
7376 let positions = vec![("AAPL", position(dec!(5))), ("TSLA", position(dec!(-2)))];
7377 let instruments = vec![
7378 InstrumentNameExchange::new("tsla"),
7379 InstrumentNameExchange::new("MSFT"),
7380 InstrumentNameExchange::new("BTC/USD"),
7381 InstrumentNameExchange::new("TSLA"),
7382 ];
7383
7384 let snapshots = build_instrument_snapshots(Vec::new(), positions, &instruments);
7385 let listed: Vec<_> = snapshots
7386 .iter()
7387 .map(|s| (s.instrument.name().as_str(), s.position.quantity()))
7388 .collect();
7389 assert_eq!(
7393 listed,
7394 vec![
7395 ("tsla", Some(dec!(-2))),
7396 ("MSFT", Some(Decimal::ZERO)),
7397 ("BTC/USD", None),
7398 ("TSLA", None)
7399 ]
7400 );
7401 assert!(!snapshots[3].orders_complete);
7402 }
7403
7404 #[test]
7410 fn test_recover_fills_dedup_key_matches_ws_path() {
7411 let order_id = "ord-1";
7412
7413 let ws_keys: Vec<SmolStr> = ["1", "2"]
7416 .iter()
7417 .map(|filled_qty| {
7418 early_dedup_key(&AlpacaTradeUpdate {
7419 event: SmolStr::new("partial_fill"),
7420 execution_id: None,
7421 previous_execution_id: None,
7422 order: make_order_ws(order_id, "SPY", "buy", filled_qty),
7423 price: Some("150.00"),
7424 qty: Some("1"),
7425 timestamp: None,
7426 })
7427 })
7428 .collect();
7429
7430 let mut cumulative = Decimal::ZERO;
7433 let rest_keys: Vec<SmolStr> = ["1", "1"]
7434 .iter()
7435 .map(|exec_qty| {
7436 cumulative += Decimal::from_str(exec_qty).unwrap();
7437 fill_dedup_key(order_id, cumulative)
7438 })
7439 .collect();
7440
7441 assert_eq!(
7442 ws_keys, rest_keys,
7443 "REST recovery dedup keys must match WS path keys for cross-source dedup to work"
7444 );
7445 assert_eq!(ws_keys[0].as_str(), "ord-1:1");
7446 assert_eq!(ws_keys[1].as_str(), "ord-1:2");
7447 }
7448
7449 #[test]
7454 fn a_ws_fill_without_an_execution_id_is_identified_by_its_dedup_key() {
7455 let update = AlpacaTradeUpdate {
7456 event: SmolStr::new("fill"),
7457 execution_id: None,
7458 previous_execution_id: None,
7459 order: make_order_ws("ord-abc", "SPY", "buy", "5"),
7460 price: Some("150.00"),
7461 qty: Some("5"),
7462 timestamp: None,
7463 };
7464
7465 let early_key = early_dedup_key(&update);
7467
7468 let [event, _snapshot] = convert_trade_update(update);
7470 let Some(UnindexedAccountEvent {
7471 kind: AccountEventKind::Trade(trade),
7472 ..
7473 }) = event
7474 else {
7475 panic!("fill should produce an execution");
7476 };
7477
7478 assert_eq!(
7479 trade.id.0.as_str(),
7480 early_key.as_str(),
7481 "without an execution_id the TradeId is the dedup key"
7482 );
7483 assert_eq!(early_key.as_str(), "ord-abc:5");
7484 }
7485
7486 #[test]
7491 fn early_dedup_key_normalizes_decimal_strings() {
7492 let update1 = AlpacaTradeUpdate {
7494 event: SmolStr::new("fill"),
7495 execution_id: None,
7496 previous_execution_id: None,
7497 order: AlpacaOrderWs {
7498 id: SmolStr::new("ord-x"),
7499 client_order_id: Some(SmolStr::new("cid")),
7500 symbol: SmolStr::new("AAPL"),
7501 qty: Some("10"),
7502 filled_qty: Some("1.00"),
7503 side: SmolStr::new("buy"),
7504 order_type: SmolStr::new("market"),
7505 time_in_force: SmolStr::new("day"),
7506 limit_price: None,
7507 stop_price: None,
7508 trail_percent: None,
7509 trail_price: None,
7510 status: SmolStr::new("filled"),
7511 },
7512 price: Some("100.00"),
7513 qty: Some("10"),
7514 timestamp: None,
7515 };
7516 assert_eq!(early_dedup_key(&update1).as_str(), "ord-x:1");
7517
7518 let update2 = AlpacaTradeUpdate {
7520 event: SmolStr::new("fill"),
7521 execution_id: None,
7522 previous_execution_id: None,
7523 order: AlpacaOrderWs {
7524 id: SmolStr::new("ord-x"),
7525 client_order_id: Some(SmolStr::new("cid")),
7526 symbol: SmolStr::new("AAPL"),
7527 qty: Some("10"),
7528 filled_qty: Some("1"),
7529 side: SmolStr::new("buy"),
7530 order_type: SmolStr::new("market"),
7531 time_in_force: SmolStr::new("day"),
7532 limit_price: None,
7533 stop_price: None,
7534 trail_percent: None,
7535 trail_price: None,
7536 status: SmolStr::new("filled"),
7537 },
7538 price: Some("100.00"),
7539 qty: Some("10"),
7540 timestamp: None,
7541 };
7542 assert_eq!(early_dedup_key(&update2).as_str(), "ord-x:1");
7543
7544 let update3 = AlpacaTradeUpdate {
7546 event: SmolStr::new("fill"),
7547 execution_id: None,
7548 previous_execution_id: None,
7549 order: AlpacaOrderWs {
7550 id: SmolStr::new("ord-x"),
7551 client_order_id: Some(SmolStr::new("cid")),
7552 symbol: SmolStr::new("AAPL"),
7553 qty: Some("10"),
7554 filled_qty: Some("1.0"),
7555 side: SmolStr::new("buy"),
7556 order_type: SmolStr::new("market"),
7557 time_in_force: SmolStr::new("day"),
7558 limit_price: None,
7559 stop_price: None,
7560 trail_percent: None,
7561 trail_price: None,
7562 status: SmolStr::new("filled"),
7563 },
7564 price: Some("100.00"),
7565 qty: Some("10"),
7566 timestamp: None,
7567 };
7568 assert_eq!(early_dedup_key(&update3).as_str(), "ord-x:1");
7569 }
7570
7571 #[test]
7576 fn process_ws_text_rejected_event_without_filled_qty_is_not_dropped() {
7577 let (tx, mut rx) = mpsc::unbounded_channel();
7578 let dedup = new_dedup_cache();
7579
7580 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"}}}"#;
7582
7583 let known = KnownLiveOrders::shared(ExchangeId::AlpacaBroker);
7584 process_ws_text(json, &tx, &dedup, &known);
7585
7586 let event = rx.try_recv()
7588 .expect("rejected event without filled_qty must produce an AccountEvent, not be silently dropped");
7589 assert!(
7590 matches!(event.kind, AccountEventKind::OrderCancelled(_)),
7591 "rejected event must map to OrderCancelled, got: {:?}",
7592 event.kind
7593 );
7594 }
7595
7596 #[test]
7599 fn process_ws_text_identifies_a_fill_by_its_execution_id() {
7600 let frame = |execution_id: &str| {
7601 format!(
7602 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"}}}}"#
7603 )
7604 };
7605 let cases = [
7606 (
7607 r#""execution_id":"524b1902-817e-446c-825b-a9fcfebbc17e","#,
7608 "524b1902-817e-446c-825b-a9fcfebbc17e",
7609 ),
7610 (r#""execution_id":null,"#, "ord-1:2"),
7611 (r#""execution_id":"","#, "ord-1:2"),
7612 ("", "ord-1:2"),
7613 ];
7614 for (field, expected) in cases {
7615 let (tx, mut rx) = mpsc::unbounded_channel();
7616 let known = KnownLiveOrders::shared(ExchangeId::AlpacaBroker);
7617 process_ws_text(&frame(field), &tx, &new_dedup_cache(), &known);
7618 let Ok(UnindexedAccountEvent {
7619 kind: AccountEventKind::Trade(trade),
7620 ..
7621 }) = rx.try_recv()
7622 else {
7623 panic!("the fill frame {field:?} produces a Trade");
7624 };
7625 assert_eq!(trade.id.0.as_str(), expected, "frame {field:?}");
7626 }
7627 }
7628
7629 fn amendment_frame(event: &str, fields: &str) -> String {
7632 format!(
7633 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"}}}}"#
7634 )
7635 }
7636
7637 fn stream_events(frames: &[String]) -> Vec<UnindexedAccountEvent> {
7639 let (tx, mut rx) = mpsc::unbounded_channel();
7640 let dedup = new_dedup_cache();
7641 let known = KnownLiveOrders::shared(ExchangeId::AlpacaBroker);
7642 for frame in frames {
7643 process_ws_text(frame, &tx, &dedup, &known);
7644 }
7645 std::iter::from_fn(|| rx.try_recv().ok()).collect()
7646 }
7647
7648 fn amendment_of(
7650 frame: String,
7651 ) -> crate::trade::TradeAmendment<AssetNameExchange, InstrumentNameExchange> {
7652 let events = stream_events(&[frame]);
7653 let [
7654 UnindexedAccountEvent {
7655 kind: AccountEventKind::TradeAmended(amendment),
7656 ..
7657 },
7658 ] = <[_; 1]>::try_from(events).unwrap_or_else(|events| {
7659 panic!("one TradeAmended, got {events:?}");
7660 })
7661 else {
7662 panic!("expected TradeAmended");
7663 };
7664 amendment
7665 }
7666
7667 #[test]
7670 fn process_ws_text_reports_a_trade_bust() {
7671 let amendment = amendment_of(amendment_frame(
7672 "trade_bust",
7673 r#""execution_id":"exec-x","previous_execution_id":"exec-a","qty":"-2","#,
7674 ));
7675 assert_eq!(amendment.original, Some(TradeId::new("exec-a")));
7676 assert_eq!(amendment.order_id, OrderId::new("ord-1"));
7677 assert_eq!(amendment.instrument, InstrumentNameExchange::new("SPY"));
7678 assert_eq!(
7679 Some(amendment.time_exchange),
7680 parse_timestamp("2025-04-18T14:31:00Z")
7681 );
7682 assert_eq!(
7683 amendment.kind,
7684 TradeAmendmentKind::Busted {
7685 quantity: Some(Decimal::TWO)
7686 }
7687 );
7688 }
7689
7690 #[test]
7692 fn process_ws_text_reports_a_bust_that_names_no_fill() {
7693 let amendment = amendment_of(amendment_frame("trade_bust", ""));
7694 assert_eq!(amendment.original, None);
7695 assert_eq!(
7696 amendment.kind,
7697 TradeAmendmentKind::Busted { quantity: None }
7698 );
7699 }
7700
7701 #[test]
7704 fn process_ws_text_reports_a_trade_correct_with_its_replacement() {
7705 let amendment = amendment_of(amendment_frame(
7706 "trade_correct",
7707 r#""execution_id":"exec-c","previous_execution_id":"exec-a","price":"101.50","qty":"2","#,
7708 ));
7709 assert_eq!(amendment.original, Some(TradeId::new("exec-a")));
7710 let TradeAmendmentKind::Corrected { replacement } = amendment.kind else {
7711 panic!("a full replacement: {:?}", amendment.kind);
7712 };
7713 assert_eq!(replacement.id, TradeId::new("exec-c"));
7714 assert_eq!(replacement.order_id, OrderId::new("ord-1"));
7715 assert_eq!(replacement.side, Side::Buy);
7716 assert_eq!(replacement.price, Decimal::new(10150, 2));
7717 assert_eq!(replacement.quantity, Decimal::TWO);
7718 assert_eq!(replacement.order_filled_quantity, None);
7719 assert_eq!(replacement.time_exchange, amendment.time_exchange);
7720 }
7721
7722 #[test]
7725 fn process_ws_text_reports_a_correction_it_cannot_resolve() {
7726 let price = Some(Decimal::new(10150, 2));
7727 let cases = [
7728 (
7729 r#""execution_id":"exec-c","qty":"2","#,
7730 (Some("exec-c"), None, Some(Decimal::TWO)),
7731 ),
7732 (
7733 r#""price":"101.50","qty":"2","#,
7734 (None, price, Some(Decimal::TWO)),
7735 ),
7736 (
7737 r#""execution_id":"exec-c","price":"101.50","qty":"0","#,
7738 (Some("exec-c"), price, Some(Decimal::ZERO)),
7739 ),
7740 (
7741 r#""execution_id":"exec-c","price":"101.50","#,
7742 (Some("exec-c"), price, None),
7743 ),
7744 ];
7745 for (fields, (id, price, quantity)) in cases {
7746 let amendment = amendment_of(amendment_frame("trade_correct", fields));
7747 assert_eq!(
7748 amendment.kind,
7749 TradeAmendmentKind::CorrectedUnresolved {
7750 id: id.map(TradeId::new),
7751 price,
7752 quantity,
7753 },
7754 "{fields}"
7755 );
7756 }
7757 }
7758
7759 #[test]
7762 fn a_fill_after_a_bust_is_delivered_at_a_cumulative_seen_before() {
7763 let fill = |execution_id: &str| {
7764 format!(
7765 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"}}}}"#
7766 )
7767 };
7768 let events = stream_events(&[
7769 fill("exec-a"),
7770 amendment_frame(
7771 "trade_bust",
7772 r#""execution_id":"exec-x","previous_execution_id":"exec-a","qty":"-2","#,
7773 ),
7774 fill("exec-b"),
7775 ]);
7776
7777 let kinds: Vec<_> = events
7778 .iter()
7779 .filter_map(|event| match &event.kind {
7780 AccountEventKind::Trade(trade) => Some(format!("trade {}", trade.id)),
7781 AccountEventKind::TradeAmended(amendment) => {
7782 Some(format!("amended {:?}", amendment.original))
7783 }
7784 _ => None,
7785 })
7786 .collect();
7787 assert_eq!(
7788 kinds,
7789 vec![
7790 "trade exec-a",
7791 "amended Some(TradeId(\"exec-a\"))",
7792 "trade exec-b"
7793 ]
7794 );
7795 }
7796
7797 #[test]
7800 fn decimal_zero_normalize_is_zero_str() {
7801 assert_eq!(Decimal::ZERO.normalize().to_string(), "0");
7802 }
7803
7804 #[test]
7808 fn decimal_normalize_strips_trailing_zeros() {
7809 let from_rest = Decimal::from_str("1.00").unwrap().normalize();
7811 let from_ws = Decimal::from_str("1").unwrap().normalize();
7812 assert_eq!(from_rest.to_string(), from_ws.to_string());
7813 assert_eq!(from_rest.to_string(), "1");
7814
7815 assert_eq!(
7817 Decimal::from_str("100.000")
7818 .unwrap()
7819 .normalize()
7820 .to_string(),
7821 "100"
7822 );
7823 assert_eq!(
7824 Decimal::from_str("0.10").unwrap().normalize().to_string(),
7825 "0.1"
7826 );
7827 assert_eq!(
7828 Decimal::from_str("0.100").unwrap().normalize().to_string(),
7829 "0.1"
7830 );
7831 }
7832
7833 #[test]
7835 fn map_position_intent_open_buy_maps_to_buy_to_open() {
7836 assert_eq!(
7837 map_position_intent(Side::Buy, false),
7838 AlpacaPositionIntent::BuyToOpen
7839 );
7840 }
7841
7842 #[test]
7843 fn map_position_intent_open_sell_maps_to_sell_to_open() {
7844 assert_eq!(
7845 map_position_intent(Side::Sell, false),
7846 AlpacaPositionIntent::SellToOpen
7847 );
7848 }
7849
7850 #[test]
7851 fn map_position_intent_reduce_buy_maps_to_buy_to_close() {
7852 assert_eq!(
7853 map_position_intent(Side::Buy, true),
7854 AlpacaPositionIntent::BuyToClose
7855 );
7856 }
7857
7858 #[test]
7859 fn map_position_intent_reduce_sell_maps_to_sell_to_close() {
7860 assert_eq!(
7861 map_position_intent(Side::Sell, true),
7862 AlpacaPositionIntent::SellToClose
7863 );
7864 }
7865
7866 #[test]
7868 fn parse_order_error_401_maps_to_unauthenticated() {
7869 assert!(matches!(
7871 parse_order_error(reqwest::StatusCode::UNAUTHORIZED, None, "bad credentials"),
7872 UnindexedOrderError::Rejected(ApiError::Unauthenticated(_))
7873 ));
7874 }
7875
7876 #[test]
7877 fn parse_order_error_403_without_code_maps_to_unauthenticated() {
7878 assert!(matches!(
7880 parse_order_error(reqwest::StatusCode::FORBIDDEN, None, "forbidden."),
7881 UnindexedOrderError::Rejected(ApiError::Unauthenticated(_))
7882 ));
7883 }
7884
7885 #[test]
7886 fn parse_order_error_403_with_code_and_insufficient_maps_to_balance_insufficient() {
7887 for message in [
7888 "insufficient buying power",
7889 "insufficient day trading buying power",
7890 "insufficient qty available for order (requested: 10, available: 0)",
7891 ] {
7892 let err = parse_order_error(reqwest::StatusCode::FORBIDDEN, Some(40310000), message);
7893 let UnindexedOrderError::Rejected(ApiError::BalanceInsufficient(None, text)) = err
7894 else {
7895 panic!("expected BalanceInsufficient for {message:?}, got {err:?}");
7896 };
7897 assert_eq!(text, format!("40310000 {message}"));
7898 }
7899 }
7900
7901 #[test]
7902 fn parse_order_error_reads_a_refused_short_by_its_text() {
7903 for (status, code, message, reason) in [
7904 (
7905 reqwest::StatusCode::FORBIDDEN,
7906 Some(40310000),
7907 "account is not allowed to short",
7908 BorrowRejectReason::BorrowDisabled,
7909 ),
7910 (
7912 reqwest::StatusCode::FORBIDDEN,
7913 Some(40310000),
7914 "asset XYZ cannot be sold short",
7915 BorrowRejectReason::NotShortable,
7916 ),
7917 (
7918 reqwest::StatusCode::UNPROCESSABLE_ENTITY,
7919 Some(42210000),
7920 "asset XYZ cannot be sold short",
7921 BorrowRejectReason::NotShortable,
7922 ),
7923 (
7924 reqwest::StatusCode::UNPROCESSABLE_ENTITY,
7925 None,
7926 "account is not allowed to short",
7927 BorrowRejectReason::BorrowDisabled,
7928 ),
7929 ] {
7930 assert_eq!(
7931 parse_order_error(status, code, message),
7932 UnindexedOrderError::Rejected(ApiError::BorrowRejected(BorrowReject::new(
7933 reason,
7934 code.map(|code| code.to_string()),
7935 message.to_owned(),
7936 ))),
7937 "{status} {message:?}"
7938 );
7939 }
7940
7941 for (status, code) in [
7944 (reqwest::StatusCode::UNPROCESSABLE_ENTITY, 42210000),
7945 (reqwest::StatusCode::FORBIDDEN, 40310000),
7946 ] {
7947 assert_eq!(
7948 parse_order_error(status, Some(code), "fractional orders cannot be sold short"),
7949 UnindexedOrderError::Rejected(ApiError::OrderRejected(format!(
7950 "{code} fractional orders cannot be sold short"
7951 ))),
7952 "{status}"
7953 );
7954 }
7955
7956 assert!(matches!(
7958 parse_order_error(
7959 reqwest::StatusCode::FORBIDDEN,
7960 Some(40310000),
7961 "insufficient qty: account is not allowed to short",
7962 ),
7963 UnindexedOrderError::Rejected(ApiError::BorrowRejected(_))
7964 ));
7965
7966 assert!(matches!(
7968 parse_order_error(
7969 reqwest::StatusCode::FORBIDDEN,
7970 None,
7971 "account is not allowed to short"
7972 ),
7973 UnindexedOrderError::Rejected(ApiError::Unauthenticated(_))
7974 ));
7975 }
7976
7977 #[test]
7978 fn parse_order_error_403_with_code_maps_to_order_rejected() {
7979 for (code, message) in [
7980 (40310000, "account is restricted to liquidation only"),
7981 (
7982 40310100,
7983 "trade denied due to pattern day trading protection",
7984 ),
7985 ] {
7986 let err = parse_order_error(reqwest::StatusCode::FORBIDDEN, Some(code), message);
7987 let UnindexedOrderError::Rejected(ApiError::OrderRejected(text)) = err else {
7988 panic!("expected OrderRejected for {message:?}, got {err:?}");
7989 };
7990 assert_eq!(text, format!("{code} {message}"));
7991 }
7992 }
7993
7994 #[test]
7995 fn alpaca_api_error_reads_code_and_falls_back_to_raw_text() {
7996 let coded = AlpacaApiError::from_body(
7997 br#"{"code": 40310000, "message": "insufficient buying power"}"#,
7998 );
7999 assert_eq!(coded.code, Some(40310000));
8000 assert_eq!(coded.message, "insufficient buying power");
8001
8002 let bare = AlpacaApiError::from_body(br#"{"message": "forbidden."}"#);
8003 assert_eq!(bare.code, None);
8004 assert_eq!(bare.message, "forbidden.");
8005
8006 let raw = AlpacaApiError::from_body(b"upstream connect error");
8007 assert_eq!(raw.code, None);
8008 assert_eq!(raw.message, "upstream connect error");
8009 }
8010
8011 #[test]
8012 fn parse_order_error_404_maps_to_order_rejected_with_not_found_prefix() {
8013 let err = parse_order_error(reqwest::StatusCode::NOT_FOUND, None, "order not found");
8014 let UnindexedOrderError::Rejected(ApiError::OrderRejected(msg)) = err else {
8015 panic!("expected OrderRejected, got {err:?}");
8016 };
8017 assert!(
8018 msg.contains("order not found"),
8019 "message should contain 'order not found': {msg}"
8020 );
8021 }
8022
8023 #[test]
8024 fn parse_order_error_422_insufficient_only_maps_to_balance_insufficient() {
8025 assert!(matches!(
8027 parse_order_error(
8028 reqwest::StatusCode::UNPROCESSABLE_ENTITY,
8029 None,
8030 "insufficient funds for this order"
8031 ),
8032 UnindexedOrderError::Rejected(ApiError::BalanceInsufficient(None, _))
8033 ));
8034 }
8035
8036 #[test]
8037 fn parse_order_error_429_maps_to_rate_limit() {
8038 assert!(matches!(
8039 parse_order_error(reqwest::StatusCode::TOO_MANY_REQUESTS, None, "rate limited"),
8040 UnindexedOrderError::Rejected(ApiError::RateLimit {
8041 retry_after: None,
8042 ..
8043 })
8044 ));
8045 }
8046
8047 #[test]
8048 fn a_rate_limit_delay_is_capped_at_alpacas_window() {
8049 let window = Some(Duration::from_secs(DEFAULT_RATE_LIMIT_DELAY_SECS));
8050 for (name, value) in [("retry-after", u64::MAX), ("x-ratelimit-reset", u64::MAX)] {
8051 let mut headers = reqwest::header::HeaderMap::new();
8052 headers.insert(name, value.to_string().parse().unwrap());
8053 assert_eq!(parse_rate_limit_delay(&headers), window, "{name}");
8054 RateLimitTracker::new().on_rate_limited(parse_rate_limit_delay(&headers));
8056 }
8057
8058 let mut headers = reqwest::header::HeaderMap::new();
8059 headers.insert("retry-after", "0".parse().unwrap());
8060 assert_eq!(
8061 parse_rate_limit_delay(&headers),
8062 Some(Duration::ZERO),
8063 "taken as given"
8064 );
8065 }
8066
8067 #[test]
8071 fn parse_time_in_force_unknown_value_falls_back_to_good_until_end_of_day() {
8072 assert_eq!(
8073 parse_time_in_force("opg"),
8074 TimeInForce::GoodUntilEndOfDay,
8075 "unknown TIF must fall back to GoodUntilEndOfDay (with a warn! in production)"
8076 );
8077 }
8078
8079 #[test]
8082 fn build_instrument_snapshots_output_order_matches_instruments_slice() {
8083 let orders = vec![
8085 make_order_response("o1", "SPY"),
8086 make_order_response("o2", "AAPL"),
8087 make_order_response("o3", "MSFT"),
8088 ];
8089 let instruments = vec![
8091 InstrumentNameExchange::new("MSFT"),
8092 InstrumentNameExchange::new("AAPL"),
8093 ];
8094 let snapshots = build_instrument_snapshots(orders, Vec::new(), &instruments);
8095 assert_eq!(snapshots.len(), 2);
8096 assert_eq!(
8097 snapshots[0].instrument.name().as_str(),
8098 "MSFT",
8099 "first snapshot must be MSFT (first in instruments slice)"
8100 );
8101 assert_eq!(
8102 snapshots[1].instrument.name().as_str(),
8103 "AAPL",
8104 "second snapshot must be AAPL (second in instruments slice)"
8105 );
8106 }
8107
8108 mod http_tests {
8115 use super::super::*;
8116 use wiremock::matchers::{method, path};
8117 use wiremock::{Mock, MockServer, Request, Respond, ResponseTemplate};
8118
8119 struct Sequential {
8126 call: std::sync::atomic::AtomicU32,
8127 pages: Vec<serde_json::Value>,
8128 }
8129
8130 impl Sequential {
8131 fn new(pages: Vec<serde_json::Value>) -> Self {
8132 Self {
8133 call: std::sync::atomic::AtomicU32::new(0),
8134 pages,
8135 }
8136 }
8137 }
8138
8139 impl Respond for Sequential {
8140 fn respond(&self, _: &Request) -> ResponseTemplate {
8141 let i = self.call.fetch_add(1, std::sync::atomic::Ordering::Relaxed) as usize;
8142 let body = self.pages.get(i).unwrap_or_else(|| {
8143 panic!(
8144 "Sequential: request #{i} has no configured response \
8145 (only {} page(s) supplied)",
8146 self.pages.len()
8147 )
8148 });
8149 ResponseTemplate::new(200).set_body_json(body)
8150 }
8151 }
8152
8153 fn make_activities_json(count: usize, id_prefix: &str) -> serde_json::Value {
8155 serde_json::Value::Array(
8156 (0..count)
8157 .map(|i| {
8158 serde_json::json!({
8159 "id": format!("{id_prefix}-{i:05}"),
8160 "order_id": "ord-1",
8161 "symbol": "SPY",
8162 "side": "buy",
8163 "price": "100.00",
8164 "qty": "1",
8165 "transaction_time": "2025-04-18T14:30:00Z"
8166 })
8167 })
8168 .collect(),
8169 )
8170 }
8171
8172 fn make_orders_json(count: usize) -> serde_json::Value {
8174 serde_json::Value::Array(
8175 (0..count)
8176 .map(|i| {
8177 serde_json::json!({
8178 "id": format!("order-{i:05}"),
8179 "client_order_id": null,
8180 "symbol": "SPY",
8181 "qty": "1",
8182 "filled_qty": "0",
8183 "side": "buy",
8184 "type": "limit",
8185 "time_in_force": "day",
8186 "limit_price": "100.00",
8187 "created_at": "2025-04-18T14:30:00Z"
8188 })
8189 })
8190 .collect(),
8191 )
8192 }
8193
8194 #[tokio::test]
8199 async fn paginate_activities_single_page_below_max_returns_all_not_truncated() {
8200 let server = MockServer::start().await;
8201 Mock::given(method("GET"))
8202 .and(path("/v2/account/activities"))
8203 .respond_with(
8204 ResponseTemplate::new(200).set_body_json(make_activities_json(5, "act")),
8205 )
8206 .mount(&server)
8207 .await;
8208
8209 let http = reqwest::Client::new();
8210 let rl = RateLimitTracker::new();
8211 let result = paginate_activities::<AlpacaActivity>(
8212 &http,
8213 &rl,
8214 &server.uri(),
8215 FILL_ACTIVITIES,
8216 recovery_after(),
8217 None,
8218 )
8219 .await
8220 .unwrap();
8221
8222 assert_eq!(result.activities.len(), 5);
8223 assert_eq!(result.resume, None);
8224 assert_eq!(server.received_requests().await.unwrap().len(), 1);
8225 }
8226
8227 #[tokio::test]
8230 async fn paginate_activities_exactly_page_size_items_fetches_second_page() {
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, "act"),
8236 serde_json::json!([]), ]))
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);
8255 assert_eq!(result.resume, None);
8256 assert_eq!(
8257 server.received_requests().await.unwrap().len(),
8258 2,
8259 "exactly 2 requests: first full page + second empty page"
8260 );
8261 }
8262
8263 #[tokio::test]
8266 async fn paginate_activities_two_pages_returns_combined_activities_not_truncated() {
8267 let server = MockServer::start().await;
8268 Mock::given(method("GET"))
8269 .and(path("/v2/account/activities"))
8270 .respond_with(Sequential::new(vec![
8271 make_activities_json(ALPACA_MAX_ACTIVITIES, "p1"),
8272 make_activities_json(37, "p2"),
8273 ]))
8274 .mount(&server)
8275 .await;
8276
8277 let http = reqwest::Client::new();
8278 let rl = RateLimitTracker::new();
8279 let result = paginate_activities::<AlpacaActivity>(
8280 &http,
8281 &rl,
8282 &server.uri(),
8283 FILL_ACTIVITIES,
8284 recovery_after(),
8285 None,
8286 )
8287 .await
8288 .unwrap();
8289
8290 assert_eq!(result.activities.len(), ALPACA_MAX_ACTIVITIES + 37);
8291 assert_eq!(result.resume, None);
8292 assert_eq!(server.received_requests().await.unwrap().len(), 2);
8293 }
8294
8295 #[tokio::test]
8299 async fn paginate_activities_at_max_pages_sets_truncated_true() {
8300 let server = MockServer::start().await;
8301
8302 Mock::given(method("GET"))
8304 .and(path("/v2/account/activities"))
8305 .respond_with(
8306 ResponseTemplate::new(200)
8307 .set_body_json(make_activities_json(ALPACA_MAX_ACTIVITIES, "act")),
8308 )
8309 .mount(&server)
8310 .await;
8311
8312 let http = reqwest::Client::new();
8313 let rl = RateLimitTracker::new();
8314 let result = paginate_activities::<AlpacaActivity>(
8315 &http,
8316 &rl,
8317 &server.uri(),
8318 FILL_ACTIVITIES,
8319 recovery_after(),
8320 None,
8321 )
8322 .await
8323 .unwrap();
8324
8325 assert!(
8326 result.resume.is_some(),
8327 "must say where to read on after MAX_ACTIVITY_PAGES pages"
8328 );
8329 assert_eq!(
8330 result.activities.len(),
8331 MAX_ACTIVITY_PAGES * ALPACA_MAX_ACTIVITIES,
8332 "must accumulate exactly MAX_ACTIVITY_PAGES * page_size activities"
8333 );
8334 assert_eq!(
8337 server.received_requests().await.unwrap().len(),
8338 MAX_ACTIVITY_PAGES,
8339 "loop must issue exactly MAX_ACTIVITY_PAGES requests then stop"
8340 );
8341 }
8342
8343 #[tokio::test]
8347 async fn fetch_raw_open_orders_499_results_returns_ok() {
8348 let server = MockServer::start().await;
8349 Mock::given(method("GET"))
8350 .and(path("/v2/orders"))
8351 .respond_with(
8352 ResponseTemplate::new(200).set_body_json(make_orders_json(MAX_OPEN_ORDERS - 1)),
8353 )
8354 .mount(&server)
8355 .await;
8356
8357 let http = reqwest::Client::new();
8358 let rl = RateLimitTracker::new();
8359 let result = fetch_raw_open_orders(&http, &rl, &server.uri(), &[]).await;
8360
8361 assert!(
8362 result.is_ok(),
8363 "499 orders must not trigger truncation: {result:?}"
8364 );
8365 assert_eq!(result.unwrap().len(), MAX_OPEN_ORDERS - 1);
8366 }
8367
8368 #[tokio::test]
8372 async fn fetch_raw_open_orders_500_results_returns_truncated_snapshot_error() {
8373 let server = MockServer::start().await;
8374 Mock::given(method("GET"))
8375 .and(path("/v2/orders"))
8376 .respond_with(
8377 ResponseTemplate::new(200).set_body_json(make_orders_json(MAX_OPEN_ORDERS)),
8378 )
8379 .mount(&server)
8380 .await;
8381
8382 let http = reqwest::Client::new();
8383 let rl = RateLimitTracker::new();
8384 let result = fetch_raw_open_orders(&http, &rl, &server.uri(), &[]).await;
8385
8386 assert!(
8387 matches!(
8388 result,
8389 Err(UnindexedClientError::TruncatedSnapshot { limit }) if limit == MAX_OPEN_ORDERS
8390 ),
8391 "500 orders must return TruncatedSnapshot, got: {result:?}"
8392 );
8393 }
8394
8395 async fn mount_account_and_positions(server: &MockServer) {
8400 Mock::given(method("GET"))
8401 .and(path("/v2/account"))
8402 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
8403 "status": "ACTIVE",
8404 "currency": "USD",
8405 "cash": "-1500.50",
8406 "equity": "4000.00",
8407 "buying_power": "5000.00",
8408 "options_buying_power": "2000.00",
8409 "non_marginable_buying_power": "1000.00",
8410 })))
8411 .mount(server)
8412 .await;
8413 Mock::given(method("GET"))
8414 .and(path("/v2/positions"))
8415 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([
8416 {
8417 "symbol": "XYZ",
8418 "asset_class": "us_equity",
8419 "side": "short",
8420 "qty": "-10",
8421 "qty_available": "-10",
8422 "avg_entry_price": "20.00",
8423 "unrealized_pl": "15.50",
8424 },
8425 {
8426 "symbol": "XYZ271217C00025000",
8427 "asset_class": "us_option",
8428 "side": "long",
8429 "qty": "3",
8430 "qty_available": "3",
8431 "avg_entry_price": "0.85",
8432 "unrealized_pl": "-45",
8433 },
8434 {
8435 "symbol": "ABC",
8436 "asset_class": "us_equity",
8437 "side": "long",
8438 "qty": "2.5",
8439 "qty_available": "2.5",
8440 "avg_entry_price": "40.00",
8441 "unrealized_pl": "1.25",
8442 },
8443 {
8444 "symbol": "BTCUSD",
8445 "asset_class": "crypto",
8446 "side": "long",
8447 "qty": "0.75",
8448 "qty_available": "0.5",
8449 "avg_entry_price": "50000",
8450 "unrealized_pl": "0",
8451 },
8452 ])))
8453 .mount(server)
8454 .await;
8455 Mock::given(method("GET"))
8456 .and(path("/v2/orders"))
8457 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([])))
8458 .mount(server)
8459 .await;
8460 }
8461
8462 fn client_for(server: &MockServer) -> AlpacaClient {
8463 AlpacaClient::new(AlpacaConfig::with_base_url(
8464 "test-key".into(),
8465 "test-secret".into(),
8466 server.uri(),
8467 ))
8468 }
8469
8470 #[tokio::test]
8471 async fn account_snapshot_reports_cash_positions_and_crypto_balances() {
8472 use crate::client::ExecutionClient;
8473 use rust_decimal_macros::dec;
8474
8475 let server = MockServer::start().await;
8476 mount_account_and_positions(&server).await;
8477
8478 let snapshot = client_for(&server)
8479 .account_snapshot(&[], &[])
8480 .await
8481 .unwrap();
8482
8483 let balances: Vec<_> = snapshot
8485 .balances
8486 .iter()
8487 .map(|b| (b.asset.name().as_str(), b.balance.total, b.balance.free))
8488 .collect();
8489 assert_eq!(
8490 balances,
8491 vec![
8492 ("USD", dec!(-1500.50), dec!(-1500.50)),
8493 ("BTC", dec!(0.75), dec!(0.5)),
8494 ],
8495 "crypto stays a balance; equities and options do not appear as balances"
8496 );
8497
8498 let positions: Vec<_> = snapshot
8499 .instruments
8500 .iter()
8501 .map(|i| {
8502 let p = i.position.open().unwrap();
8503 (
8504 i.instrument.name().as_str(),
8505 p.quantity,
8506 p.entry_price,
8507 p.unrealized_pnl,
8508 )
8509 })
8510 .collect();
8511 assert_eq!(
8512 positions,
8513 vec![
8514 ("XYZ", dec!(-10), Some(dec!(20.00)), Some(dec!(15.50))),
8515 (
8516 "XYZ271217C00025000",
8517 dec!(3),
8518 Some(dec!(0.85)),
8519 Some(dec!(-45))
8520 ),
8521 ("ABC", dec!(2.5), Some(dec!(40.00)), Some(dec!(1.25))),
8522 ],
8523 "short equity, option and fractional positions; no crypto position"
8524 );
8525 }
8526
8527 #[tokio::test]
8530 async fn account_snapshot_usd_only_still_reports_positions() {
8531 use crate::client::ExecutionClient;
8532 use rust_decimal_macros::dec;
8533
8534 let server = MockServer::start().await;
8535 mount_account_and_positions(&server).await;
8536
8537 let usd = [AssetNameExchange::new("USD")];
8538 let instruments = [InstrumentNameExchange::new("XYZ")];
8539 let snapshot = client_for(&server)
8540 .account_snapshot(&usd, &instruments)
8541 .await
8542 .unwrap();
8543
8544 assert_eq!(snapshot.balances.len(), 1);
8545 assert_eq!(snapshot.balances[0].asset, AssetNameExchange::new("USD"));
8546 assert_eq!(snapshot.balances[0].balance.total, dec!(-1500.50));
8547
8548 assert_eq!(snapshot.instruments.len(), 1);
8549 assert_eq!(snapshot.instruments[0].position.quantity(), Some(dec!(-10)));
8550 }
8551
8552 #[tokio::test]
8554 async fn account_snapshot_non_usd_only_skips_account_request() {
8555 use crate::client::ExecutionClient;
8556
8557 let server = MockServer::start().await;
8558 mount_account_and_positions(&server).await;
8559
8560 let btc = [AssetNameExchange::new("btc")];
8561 let snapshot = client_for(&server)
8562 .account_snapshot(&btc, &[])
8563 .await
8564 .unwrap();
8565
8566 assert_eq!(snapshot.balances.len(), 1);
8567 assert_eq!(snapshot.balances[0].asset, AssetNameExchange::new("BTC"));
8568 let requests = server.received_requests().await.unwrap();
8569 assert!(
8570 requests.iter().all(|r| r.url.path() != "/v2/account"),
8571 "no USD requested, so /v2/account must not be fetched"
8572 );
8573 }
8574
8575 #[tokio::test]
8583 async fn open_order_reduce_only_sell_sends_sell_to_close_intent() {
8584 use crate::client::ExecutionClient;
8585 use crate::order::request::{OrderRequestOpen, RequestOpen};
8586 use crate::order::{
8587 OrderKey, OrderKind, TimeInForce,
8588 id::{ClientOrderId, StrategyId},
8589 };
8590 use rust_decimal::Decimal;
8591 use rustrade_instrument::Side;
8592 use rustrade_instrument::exchange::ExchangeId;
8593 use rustrade_instrument::instrument::name::InstrumentNameExchange;
8594 use wiremock::matchers::{method, path};
8595
8596 let server = MockServer::start().await;
8597
8598 let captured_body = std::sync::Arc::new(parking_lot::Mutex::new(None));
8601 let captured_clone = captured_body.clone();
8602
8603 Mock::given(method("POST"))
8604 .and(path("/v2/orders"))
8605 .respond_with(move |req: &Request| {
8606 let body: serde_json::Value = serde_json::from_slice(&req.body).unwrap();
8608 *captured_clone.lock() = Some(body);
8609
8610 ResponseTemplate::new(200).set_body_json(serde_json::json!({
8611 "id": "test-order-id",
8612 "client_order_id": "test-cid",
8613 "symbol": "AAPL",
8614 "qty": "10",
8615 "filled_qty": "0",
8616 "side": "sell",
8617 "type": "market",
8618 "time_in_force": "ioc",
8619 "limit_price": null,
8620 "created_at": "2025-04-18T14:30:00Z"
8621 }))
8622 })
8623 .mount(&server)
8624 .await;
8625
8626 let config =
8628 AlpacaConfig::with_base_url("test-key".into(), "test-secret".into(), server.uri());
8629 let client = AlpacaClient::new(config);
8630
8631 let request = OrderRequestOpen {
8633 key: OrderKey {
8634 exchange: ExchangeId::AlpacaBroker,
8635 instrument: InstrumentNameExchange::new("AAPL"),
8636 strategy: StrategyId::new("test-strategy"),
8637 cid: ClientOrderId::new("test-cid"),
8638 },
8639 state: RequestOpen {
8640 side: Side::Sell,
8641 price: None,
8642 quantity: Decimal::new(10, 0),
8643 kind: OrderKind::Market,
8644 time_in_force: TimeInForce::ImmediateOrCancel,
8645 position_id: None,
8646 reduce_only: true, market: None,
8648 },
8649 };
8650
8651 let order = client
8653 .open_order(OrderRequestOpen {
8654 key: OrderKey {
8655 exchange: request.key.exchange,
8656 instrument: &request.key.instrument,
8657 strategy: request.key.strategy.clone(),
8658 cid: request.key.cid.clone(),
8659 },
8660 state: request.state.clone(),
8661 })
8662 .await;
8663
8664 assert!(
8666 order.state.is_accepted(),
8667 "order should be accepted: {:?}",
8668 order.state
8669 );
8670
8671 let body = captured_body
8673 .lock()
8674 .take()
8675 .expect("request body should be captured");
8676 assert_eq!(
8677 body.get("position_intent").and_then(|v| v.as_str()),
8678 Some("sell_to_close"),
8679 "reduce_only=true + Side::Sell should produce position_intent=sell_to_close, got: {body}"
8680 );
8681 }
8682
8683 #[tokio::test]
8685 async fn open_order_not_reduce_only_buy_sends_buy_to_open_intent() {
8686 use crate::client::ExecutionClient;
8687 use crate::order::request::{OrderRequestOpen, RequestOpen};
8688 use crate::order::{
8689 OrderKey, OrderKind, TimeInForce,
8690 id::{ClientOrderId, StrategyId},
8691 };
8692 use rust_decimal::Decimal;
8693 use rustrade_instrument::Side;
8694 use rustrade_instrument::exchange::ExchangeId;
8695 use rustrade_instrument::instrument::name::InstrumentNameExchange;
8696
8697 let server = MockServer::start().await;
8698
8699 let captured_body = std::sync::Arc::new(parking_lot::Mutex::new(None));
8700 let captured_clone = captured_body.clone();
8701
8702 Mock::given(method("POST"))
8703 .and(path("/v2/orders"))
8704 .respond_with(move |req: &Request| {
8705 let body: serde_json::Value = serde_json::from_slice(&req.body).unwrap();
8706 *captured_clone.lock() = Some(body);
8707
8708 ResponseTemplate::new(200).set_body_json(serde_json::json!({
8709 "id": "test-order-id",
8710 "client_order_id": "test-cid",
8711 "symbol": "AAPL",
8712 "qty": "10",
8713 "filled_qty": "0",
8714 "side": "buy",
8715 "type": "market",
8716 "time_in_force": "ioc",
8717 "limit_price": null,
8718 "created_at": "2025-04-18T14:30:00Z"
8719 }))
8720 })
8721 .mount(&server)
8722 .await;
8723
8724 let config =
8725 AlpacaConfig::with_base_url("test-key".into(), "test-secret".into(), server.uri());
8726 let client = AlpacaClient::new(config);
8727
8728 let instrument = InstrumentNameExchange::new("AAPL");
8729 let request = OrderRequestOpen {
8730 key: OrderKey {
8731 exchange: ExchangeId::AlpacaBroker,
8732 instrument: &instrument,
8733 strategy: StrategyId::new("test-strategy"),
8734 cid: ClientOrderId::new("test-cid"),
8735 },
8736 state: RequestOpen {
8737 side: Side::Buy,
8738 price: None,
8739 quantity: Decimal::new(10, 0),
8740 kind: OrderKind::Market,
8741 time_in_force: TimeInForce::ImmediateOrCancel,
8742 position_id: None,
8743 reduce_only: false, market: None,
8745 },
8746 };
8747
8748 let order = client.open_order(request).await;
8749
8750 assert!(
8751 order.state.is_accepted(),
8752 "order should be accepted: {:?}",
8753 order.state
8754 );
8755
8756 let body = captured_body
8757 .lock()
8758 .take()
8759 .expect("request body should be captured");
8760 assert_eq!(
8761 body.get("position_intent").and_then(|v| v.as_str()),
8762 Some("buy_to_open"),
8763 "reduce_only=false + Side::Buy should produce position_intent=buy_to_open, got: {body}"
8764 );
8765 }
8766
8767 #[tokio::test]
8770 async fn rest_with_retry_reports_an_undecodable_2xx_body_as_internal() {
8771 let server = MockServer::start().await;
8772 Mock::given(method("GET"))
8773 .and(path("/v2/account"))
8774 .respond_with(ResponseTemplate::new(200).set_body_string("not json"))
8775 .mount(&server)
8776 .await;
8777
8778 let http = reqwest::Client::new();
8779 let rl = RateLimitTracker::new();
8780 let url = format!("{}/v2/account", server.uri());
8781 let result: Result<serde_json::Value, _> =
8782 rest_with_retry(&rl, || http.get(&url)).await;
8783 assert!(
8784 matches!(result, Err(UnindexedClientError::Internal(_))),
8785 "{result:?}"
8786 );
8787 }
8788
8789 async fn holds_back(rl: &RateLimitTracker) -> bool {
8791 tokio::time::timeout(Duration::from_millis(50), rl.wait_if_blocked())
8792 .await
8793 .is_err()
8794 }
8795
8796 fn with_rate_limit(
8798 template: ResponseTemplate,
8799 remaining: u32,
8800 reset: bool,
8801 ) -> ResponseTemplate {
8802 let template = template.insert_header("x-ratelimit-remaining", remaining.to_string());
8803 if !reset {
8804 return template;
8805 }
8806 let now_secs = std::time::SystemTime::now()
8807 .duration_since(std::time::UNIX_EPOCH)
8808 .unwrap()
8809 .as_secs();
8810 template.insert_header("x-ratelimit-reset", (now_secs + 30).to_string())
8811 }
8812
8813 #[tokio::test]
8816 async fn rest_with_retry_pauses_once_no_requests_remain() {
8817 for (remaining, reset, pauses) in [(0, true, true), (1, true, false), (0, false, false)]
8818 {
8819 let server = MockServer::start().await;
8820 Mock::given(method("GET"))
8821 .and(path("/v2/account"))
8822 .respond_with(with_rate_limit(
8823 ResponseTemplate::new(200).set_body_json(serde_json::json!({})),
8824 remaining,
8825 reset,
8826 ))
8827 .mount(&server)
8828 .await;
8829
8830 let http = reqwest::Client::new();
8831 let rl = RateLimitTracker::new();
8832 let url = format!("{}/v2/account", server.uri());
8833 let _: serde_json::Value = rest_with_retry(&rl, || http.get(&url)).await.unwrap();
8834 assert_eq!(
8835 holds_back(&rl).await,
8836 pauses,
8837 "remaining {remaining}, reset {reset}"
8838 );
8839 }
8840 }
8841
8842 #[tokio::test]
8845 async fn a_pause_lasts_a_minute_at_most() {
8846 tokio::time::pause();
8847 let now_secs = std::time::SystemTime::now()
8848 .duration_since(std::time::UNIX_EPOCH)
8849 .unwrap()
8850 .as_secs();
8851 let mut headers = reqwest::header::HeaderMap::new();
8852 headers.insert("x-ratelimit-remaining", "0".parse().unwrap());
8853 headers.insert(
8854 "x-ratelimit-reset",
8855 (now_secs + 3_600).to_string().parse().unwrap(),
8856 );
8857 let rl = RateLimitTracker::new();
8858
8859 observe_rate_limit_remaining(&rl, &headers);
8860 assert!(holds_back(&rl).await);
8861 tokio::time::advance(Duration::from_secs(DEFAULT_RATE_LIMIT_DELAY_SECS)).await;
8862 assert!(!holds_back(&rl).await);
8863 }
8864
8865 #[tokio::test]
8867 async fn rest_delete_with_retry_pauses_once_no_requests_remain() {
8868 let server = MockServer::start().await;
8869 Mock::given(method("DELETE"))
8870 .and(path("/v2/orders/abc"))
8871 .respond_with(with_rate_limit(ResponseTemplate::new(204), 0, true))
8872 .mount(&server)
8873 .await;
8874
8875 let http = reqwest::Client::new();
8876 let rl = RateLimitTracker::new();
8877 let url = format!("{}/v2/orders/abc", server.uri());
8878 rest_delete_with_retry(&rl, || http.delete(&url))
8879 .await
8880 .unwrap();
8881 assert!(holds_back(&rl).await);
8882 }
8883
8884 #[tokio::test]
8887 async fn open_order_with_an_undecodable_2xx_response_is_status_unknown() {
8888 use crate::client::ExecutionClient;
8889 use crate::error::OrderError;
8890 use crate::order::request::{OrderRequestOpen, RequestOpen};
8891 use crate::order::state::{InactiveOrderState, OrderState};
8892 use crate::order::{
8893 OrderKey, OrderKind, TimeInForce,
8894 id::{ClientOrderId, StrategyId},
8895 };
8896 use rustrade_instrument::Side;
8897 use rustrade_instrument::exchange::ExchangeId;
8898 use rustrade_instrument::instrument::name::InstrumentNameExchange;
8899
8900 let server = MockServer::start().await;
8901 Mock::given(method("POST"))
8902 .and(path("/v2/orders"))
8903 .respond_with(
8904 ResponseTemplate::new(200).set_body_json(serde_json::json!({ "id": 1 })),
8905 )
8906 .mount(&server)
8907 .await;
8908
8909 let instrument = InstrumentNameExchange::new("AAPL");
8910 let order = client_for(&server)
8911 .open_order(OrderRequestOpen {
8912 key: OrderKey {
8913 exchange: ExchangeId::AlpacaBroker,
8914 instrument: &instrument,
8915 strategy: StrategyId::new("test-strategy"),
8916 cid: ClientOrderId::new("test-cid"),
8917 },
8918 state: RequestOpen {
8919 side: Side::Buy,
8920 price: None,
8921 quantity: Decimal::new(10, 0),
8922 kind: OrderKind::Market,
8923 time_in_force: TimeInForce::ImmediateOrCancel,
8924 position_id: None,
8925 reduce_only: false,
8926 market: None,
8927 },
8928 })
8929 .await;
8930
8931 assert!(
8932 matches!(
8933 order.state,
8934 OrderState::Inactive(InactiveOrderState::OpenFailed(OrderError::Connectivity(
8935 _
8936 )))
8937 ),
8938 "{:?}",
8939 order.state
8940 );
8941 }
8942
8943 #[tokio::test]
8946 async fn open_order_reports_a_403_by_its_code() {
8947 use crate::client::ExecutionClient;
8948 use crate::error::OrderError;
8949 use crate::order::request::{OrderRequestOpen, RequestOpen};
8950 use crate::order::state::{InactiveOrderState, OrderState};
8951 use crate::order::{
8952 OrderKey, OrderKind, TimeInForce,
8953 id::{ClientOrderId, StrategyId},
8954 };
8955 use rustrade_instrument::Side;
8956 use rustrade_instrument::exchange::ExchangeId;
8957 use rustrade_instrument::instrument::name::InstrumentNameExchange;
8958
8959 async fn open_against(
8960 status: u16,
8961 body: serde_json::Value,
8962 ) -> OrderState<AssetNameExchange, InstrumentNameExchange> {
8963 let server = MockServer::start().await;
8964 Mock::given(method("POST"))
8965 .and(path("/v2/orders"))
8966 .respond_with(ResponseTemplate::new(status).set_body_json(body))
8967 .mount(&server)
8968 .await;
8969 let instrument = InstrumentNameExchange::new("AAPL");
8970 client_for(&server)
8971 .open_order(OrderRequestOpen {
8972 key: OrderKey {
8973 exchange: ExchangeId::AlpacaBroker,
8974 instrument: &instrument,
8975 strategy: StrategyId::new("test-strategy"),
8976 cid: ClientOrderId::new("test-cid"),
8977 },
8978 state: RequestOpen {
8979 side: Side::Sell,
8980 price: None,
8981 quantity: Decimal::new(10, 0),
8982 kind: OrderKind::Market,
8983 time_in_force: TimeInForce::ImmediateOrCancel,
8984 position_id: None,
8985 reduce_only: false,
8986 market: None,
8987 },
8988 })
8989 .await
8990 .state
8991 }
8992
8993 let rejected =
8994 |state: &OrderState<AssetNameExchange, InstrumentNameExchange>| match state {
8995 OrderState::Inactive(InactiveOrderState::OpenFailed(OrderError::Rejected(
8996 e,
8997 ))) => e.clone(),
8998 other => panic!("expected a rejection, got {other:?}"),
8999 };
9000
9001 let state = open_against(
9002 403,
9003 serde_json::json!({
9004 "code": 40310000,
9005 "message": "insufficient buying power"
9006 }),
9007 )
9008 .await;
9009 assert_eq!(
9010 rejected(&state),
9011 ApiError::BalanceInsufficient(None, "40310000 insufficient buying power".into())
9012 );
9013
9014 let state = open_against(
9015 403,
9016 serde_json::json!({
9017 "code": 40310000,
9018 "message": "account is not allowed to short"
9019 }),
9020 )
9021 .await;
9022 assert_eq!(
9023 rejected(&state),
9024 ApiError::BorrowRejected(BorrowReject::new(
9025 BorrowRejectReason::BorrowDisabled,
9026 Some("40310000".into()),
9027 "account is not allowed to short".into(),
9028 ))
9029 );
9030
9031 let state = open_against(
9033 422,
9034 serde_json::json!({
9035 "code": 42210000,
9036 "message": "asset XYZ cannot be sold short"
9037 }),
9038 )
9039 .await;
9040 assert_eq!(
9041 rejected(&state),
9042 ApiError::BorrowRejected(BorrowReject::new(
9043 BorrowRejectReason::NotShortable,
9044 Some("42210000".into()),
9045 "asset XYZ cannot be sold short".into(),
9046 ))
9047 );
9048
9049 let state = open_against(403, serde_json::json!({ "message": "forbidden." })).await;
9050 assert!(
9051 matches!(rejected(&state), ApiError::Unauthenticated(_)),
9052 "{state:?}"
9053 );
9054 }
9055
9056 #[tokio::test]
9059 async fn open_bracket_order_with_an_undecodable_2xx_response_is_status_unknown() {
9060 use crate::error::OrderError;
9061 use crate::order::state::{InactiveOrderState, OrderState};
9062 use rustrade_instrument::instrument::name::InstrumentNameExchange;
9063
9064 let server = MockServer::start().await;
9065 Mock::given(method("POST"))
9066 .and(path("/v2/orders"))
9067 .respond_with(
9068 ResponseTemplate::new(200).set_body_json(serde_json::json!({ "id": 1 })),
9069 )
9070 .mount(&server)
9071 .await;
9072
9073 let result = client_for(&server)
9074 .open_bracket_order(AlpacaBracketOrderRequest::new(
9075 InstrumentNameExchange::new("SPY"),
9076 crate::order::id::StrategyId::new("test"),
9077 crate::order::id::ClientOrderId::new("test-bracket"),
9078 Side::Buy,
9079 Decimal::ONE,
9080 Decimal::new(100, 0),
9081 Decimal::new(120, 0),
9082 Decimal::new(90, 0),
9083 TimeInForce::GoodUntilCancelled { post_only: false },
9084 ))
9085 .await;
9086
9087 assert!(
9088 matches!(
9089 result.parent.state,
9090 OrderState::Inactive(InactiveOrderState::OpenFailed(OrderError::Connectivity(
9091 _
9092 )))
9093 ),
9094 "{:?}",
9095 result.parent.state
9096 );
9097 }
9098
9099 fn recovery_after() -> DateTime<Utc> {
9108 "2025-01-01T00:00:00Z".parse().expect("valid time")
9109 }
9110
9111 fn activity_json(
9114 id: &str,
9115 order_id: &str,
9116 qty: &str,
9117 cum_qty: Option<&str>,
9118 ) -> serde_json::Value {
9119 let mut v = serde_json::json!({
9120 "id": id,
9121 "order_id": order_id,
9122 "symbol": "SPY",
9123 "side": "buy",
9124 "price": "100.00",
9125 "qty": qty,
9126 "transaction_time": "2025-04-18T14:30:00Z"
9127 });
9128 if let Some(c) = cum_qty {
9129 v["cum_qty"] = serde_json::Value::String(c.to_string());
9130 }
9131 v
9132 }
9133
9134 #[tokio::test]
9139 async fn the_reconnect_budget_resets_only_once_a_connection_proves_stable() {
9140 use futures::{SinkExt as _, StreamExt as _};
9141 use tokio_tungstenite::tungstenite::Message;
9142
9143 const STABLE: Duration = Duration::from_secs(HEARTBEAT_TIMEOUT_SECS + 3);
9145 let lifetime = |n: usize| match n {
9146 0 | 1 => Some(Duration::from_millis(100)),
9147 2 => Some(STABLE),
9148 _ => None,
9149 };
9150
9151 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
9154 let ws_url = format!("ws://{}", listener.local_addr().unwrap());
9155 let listens = Arc::new(parking_lot::Mutex::new(Vec::new()));
9156 let seen = Arc::clone(&listens);
9157 tokio::spawn(async move {
9158 let mut connections = 0..;
9159 while let Ok((stream, _)) = listener.accept().await {
9160 let seen = Arc::clone(&seen);
9161 let lifetime = connections.next().and_then(lifetime);
9162 tokio::spawn(async move {
9163 let Ok(mut ws) = tokio_tungstenite::accept_async(stream).await else {
9164 return;
9165 };
9166 for answer in [
9167 r#"{"stream":"authorization","data":{"status":"authorized","action":"authenticate"}}"#,
9168 r#"{"stream":"listening","data":{"streams":["trade_updates"]}}"#,
9169 ] {
9170 let Some(Ok(_)) = ws.next().await else {
9171 return;
9172 };
9173 if ws.send(Message::text(answer)).await.is_err() {
9174 return;
9175 }
9176 }
9177 seen.lock().push(tokio::time::Instant::now());
9178 let closes = async {
9179 match lifetime {
9180 Some(after) => tokio::time::sleep(after).await,
9181 None => std::future::pending().await,
9182 }
9183 };
9184 tokio::pin!(closes);
9185 let mut pings = tokio::time::interval(Duration::from_secs(1));
9186 loop {
9187 tokio::select! {
9188 () = &mut closes => {
9189 let _ = ws.close(None).await;
9190 return;
9191 }
9192 _ = pings.tick() => {
9193 if ws.send(Message::Ping(Default::default())).await.is_err() {
9194 return;
9195 }
9196 }
9197 message = ws.next() => {
9198 if !matches!(message, Some(Ok(_))) {
9199 return;
9200 }
9201 }
9202 }
9203 }
9204 });
9205 }
9206 });
9207 let server = MockServer::start().await;
9209 let mut config =
9210 AlpacaConfig::with_base_url("key".into(), "secret".into(), server.uri());
9211 config.ws_url_override = Some(ws_url);
9212 let (tx, _rx) = mpsc::unbounded_channel();
9213 tokio::spawn(connection_manager(
9214 tx,
9215 new_dedup_cache(),
9216 Arc::new(config),
9217 reqwest::Client::new(),
9218 Arc::new(RateLimitTracker::new()),
9219 KnownLiveOrders::shared(ExchangeId::AlpacaBroker),
9220 Vec::new(),
9221 None,
9222 ));
9223
9224 let deadline = tokio::time::Instant::now() + STABLE + Duration::from_secs(15);
9225 while listens.lock().len() < 4 {
9226 assert!(
9227 tokio::time::Instant::now() < deadline,
9228 "{} connections",
9229 listens.lock().len()
9230 );
9231 tokio::time::sleep(Duration::from_millis(50)).await;
9232 }
9233
9234 let listens = listens.lock();
9235 let escalated = listens[2] - listens[1];
9237 assert!(
9238 escalated >= Duration::from_millis(2 * INITIAL_BACKOFF_MS),
9239 "reconnected {escalated:?} after a closed connection"
9240 );
9241 let after_stable = listens[3] - listens[2] - STABLE;
9244 assert!(
9245 after_stable < Duration::from_millis(3 * INITIAL_BACKOFF_MS),
9246 "reconnected {after_stable:?} after a stable connection"
9247 );
9248 }
9249
9250 async fn drive_recover_fills_with(
9253 activities: Vec<serde_json::Value>,
9254 dedup: SharedDedupCache,
9255 known: &SharedKnownLiveOrders,
9256 ) -> Vec<UnindexedAccountEvent> {
9257 let server = MockServer::start().await;
9258 Mock::given(method("GET"))
9259 .and(path("/v2/account/activities"))
9260 .respond_with(
9261 ResponseTemplate::new(200).set_body_json(serde_json::Value::Array(activities)),
9262 )
9263 .mount(&server)
9264 .await;
9265
9266 let http = reqwest::Client::new();
9267 let rl = RateLimitTracker::new();
9268 let (tx, mut rx) = mpsc::unbounded_channel();
9269 let outcome = recover_fills(
9270 &http,
9271 &rl,
9272 &[],
9273 &server.uri(),
9274 recovery_after(),
9275 &tx,
9276 &dedup,
9277 known,
9278 )
9279 .await;
9280 assert_eq!(outcome, Ok(()), "a full read leaves nothing unrecovered");
9281 drop(tx);
9282
9283 let mut out = Vec::new();
9284 while let Ok(event) = rx.try_recv() {
9285 out.push(event);
9286 }
9287 out
9288 }
9289
9290 async fn drive_recover_fills(
9291 activities: Vec<serde_json::Value>,
9292 ) -> Vec<UnindexedAccountEvent> {
9293 drive_recover_fills_with(
9294 activities,
9295 new_dedup_cache(),
9296 &KnownLiveOrders::shared(ExchangeId::AlpacaBroker),
9297 )
9298 .await
9299 }
9300
9301 async fn recover_fills_from(
9304 server: &MockServer,
9305 ) -> (Result<(), UnreadFills>, Vec<UnindexedAccountEvent>) {
9306 let (tx, mut rx) = mpsc::unbounded_channel();
9307 let outcome = recover_fills(
9308 &reqwest::Client::new(),
9309 &RateLimitTracker::new(),
9310 &[],
9311 &server.uri(),
9312 recovery_after(),
9313 &tx,
9314 &new_dedup_cache(),
9315 &KnownLiveOrders::shared(ExchangeId::AlpacaBroker),
9316 )
9317 .await;
9318 drop(tx);
9319 (outcome, std::iter::from_fn(|| rx.try_recv().ok()).collect())
9320 }
9321
9322 #[tokio::test]
9325 async fn a_failed_recovery_read_leaves_every_fill_since_the_disconnect_unread() {
9326 let server = MockServer::start().await;
9327 Mock::given(method("GET"))
9328 .and(path("/v2/account/activities"))
9329 .respond_with(ResponseTemplate::new(500).set_body_string("unavailable"))
9330 .mount(&server)
9331 .await;
9332
9333 let (outcome, events) = recover_fills_from(&server).await;
9334
9335 let Err(UnreadFills {
9336 start,
9337 reason: FillRecoveryFailure::Request(_),
9338 }) = outcome
9339 else {
9340 panic!("expected the request's failure, got {outcome:?}");
9341 };
9342 assert_eq!(start, recovery_after(), "from the disconnect");
9343 assert!(events.is_empty(), "nothing forwarded: {events:?}");
9344 }
9345
9346 #[tokio::test]
9351 async fn a_truncated_recovery_read_delivers_what_it_read_and_leaves_the_rest_unread() {
9352 let time = |s: &str| s.parse::<DateTime<Utc>>().expect("valid time");
9353 let cases = [
9355 (
9356 Some("2025-04-18T14:30:01.234567Z"),
9357 time("2025-04-18T14:30:01.234Z"),
9358 ),
9359 (Some("not a time"), time("2025-04-18T14:30:00Z")),
9360 (None, recovery_after()),
9361 ];
9362 for (last_time, expected_start) in cases {
9363 let mut page = make_activities_json(ALPACA_MAX_ACTIVITIES, "act");
9364 let Some(activities) = page.as_array_mut() else {
9365 panic!("an array of activities");
9366 };
9367 match last_time {
9368 Some(last_time) => {
9369 if let Some(last) = activities.last_mut() {
9370 last["transaction_time"] =
9371 serde_json::Value::String(last_time.to_string());
9372 }
9373 }
9374 None => {
9375 for activity in activities.iter_mut() {
9376 activity["transaction_time"] =
9377 serde_json::Value::String("not a time".to_string());
9378 }
9379 }
9380 }
9381 let server = MockServer::start().await;
9382 Mock::given(method("GET"))
9383 .and(path("/v2/account/activities"))
9384 .respond_with(ResponseTemplate::new(200).set_body_json(page))
9385 .mount(&server)
9386 .await;
9387
9388 let (outcome, events) = recover_fills_from(&server).await;
9389
9390 let fills_read = MAX_ACTIVITY_PAGES * ALPACA_MAX_ACTIVITIES;
9391 assert_eq!(
9392 outcome,
9393 Err(UnreadFills {
9394 start: expected_start,
9395 reason: FillRecoveryFailure::Truncated { fills_read },
9396 }),
9397 "last time {last_time:?}"
9398 );
9399 assert_eq!(events.len(), fills_read, "every fill read is forwarded");
9400 }
9401 }
9402
9403 #[test]
9406 fn a_fill_recovery_give_up_covers_every_instrument_the_stream_does() {
9407 let start = recovery_after();
9408 let end = start + chrono::Duration::minutes(5);
9409 let spy = InstrumentNameExchange::new("SPY");
9410 let qqq = InstrumentNameExchange::new("QQQ");
9411 let cases = [
9412 (vec![], FillRecoveryScope::AllInstruments),
9413 (
9414 vec![spy.clone(), qqq.clone()],
9415 FillRecoveryScope::Instruments(vec![spy, qqq]),
9416 ),
9417 ];
9418 for (instruments, scope) in cases {
9419 let reason = FillRecoveryFailure::TimedOut { timeout_secs: 30 };
9420 let unread = UnreadFills {
9421 start,
9422 reason: reason.clone(),
9423 };
9424 assert_eq!(
9425 fill_recovery_gave_up(&instruments, unread, end),
9426 UnindexedAccountEvent::new(
9427 ExchangeId::AlpacaBroker,
9428 AccountEventKind::FillRecoveryGaveUp(FillRecoveryGap::new(
9429 scope, start, end, 1, reason,
9430 )),
9431 )
9432 );
9433 }
9434 }
9435
9436 #[test]
9440 fn a_read_that_reached_past_the_recovery_start_still_reports() {
9441 let end = recovery_after();
9442 let start = end + chrono::Duration::seconds(1);
9443 let unread = UnreadFills {
9444 start,
9445 reason: FillRecoveryFailure::Truncated { fills_read: 5_000 },
9446 };
9447 let event = fill_recovery_gave_up(&[], unread, end);
9448 let AccountEventKind::FillRecoveryGaveUp(gave_up) = event.kind else {
9449 panic!("expected FillRecoveryGaveUp, got {event:?}");
9450 };
9451 assert_eq!((gave_up.start, gave_up.end), (start, start));
9452 }
9453
9454 async fn recover_fills_or_report_from(
9457 server: &MockServer,
9458 instruments: &[InstrumentNameExchange],
9459 timeout: Duration,
9460 ) -> Vec<UnindexedAccountEvent> {
9461 let (tx, mut rx) = mpsc::unbounded_channel();
9462 recover_fills_or_report(
9463 &reqwest::Client::new(),
9464 &RateLimitTracker::new(),
9465 instruments,
9466 &server.uri(),
9467 recovery_after(),
9468 timeout,
9469 &tx,
9470 &new_dedup_cache(),
9471 &KnownLiveOrders::shared(ExchangeId::AlpacaBroker),
9472 )
9473 .await;
9474 drop(tx);
9475 std::iter::from_fn(|| rx.try_recv().ok()).collect()
9476 }
9477
9478 #[tokio::test]
9481 async fn a_timed_out_recovery_reports_every_fill_since_the_disconnect() {
9482 let server = MockServer::start().await;
9483 Mock::given(method("GET"))
9484 .and(path("/v2/account/activities"))
9485 .respond_with(
9486 ResponseTemplate::new(200)
9487 .set_body_json(serde_json::json!([]))
9488 .set_delay(Duration::from_secs(3_600)),
9489 )
9490 .mount(&server)
9491 .await;
9492 let spy = InstrumentNameExchange::new("SPY");
9493
9494 let events = recover_fills_or_report_from(
9495 &server,
9496 std::slice::from_ref(&spy),
9497 Duration::from_millis(50),
9498 )
9499 .await;
9500
9501 let [event] = events.as_slice() else {
9502 panic!("only the give-up is sent: {events:?}");
9503 };
9504 let AccountEventKind::FillRecoveryGaveUp(gave_up) = &event.kind else {
9505 panic!("expected FillRecoveryGaveUp, got {event:?}");
9506 };
9507 assert_eq!(gave_up.scope, FillRecoveryScope::Instruments(vec![spy]));
9508 assert_eq!(gave_up.start, recovery_after(), "from the disconnect");
9509 assert!(gave_up.end >= gave_up.start);
9510 assert_eq!(gave_up.attempts, 1);
9511 assert!(
9512 matches!(gave_up.reason, FillRecoveryFailure::TimedOut { .. }),
9513 "{:?}",
9514 gave_up.reason
9515 );
9516 }
9517
9518 #[tokio::test]
9521 async fn a_truncated_recovery_reports_after_the_fills_it_read() {
9522 let server = MockServer::start().await;
9523 Mock::given(method("GET"))
9524 .and(path("/v2/account/activities"))
9525 .respond_with(
9526 ResponseTemplate::new(200)
9527 .set_body_json(make_activities_json(ALPACA_MAX_ACTIVITIES, "act")),
9528 )
9529 .mount(&server)
9530 .await;
9531
9532 let events = recover_fills_or_report_from(&server, &[], Duration::from_secs(30)).await;
9533
9534 let Some((last, fills)) = events.split_last() else {
9535 panic!("events are sent");
9536 };
9537 assert_eq!(fills.len(), MAX_ACTIVITY_PAGES * ALPACA_MAX_ACTIVITIES);
9538 assert!(
9539 fills
9540 .iter()
9541 .all(|event| matches!(event.kind, AccountEventKind::Trade(_))),
9542 "every fill comes first"
9543 );
9544 let AccountEventKind::FillRecoveryGaveUp(gave_up) = &last.kind else {
9545 panic!("the give-up comes last, got {last:?}");
9546 };
9547 assert_eq!(gave_up.scope, FillRecoveryScope::AllInstruments);
9548 assert!(matches!(
9549 gave_up.reason,
9550 FillRecoveryFailure::Truncated { .. }
9551 ));
9552 }
9553
9554 #[test]
9557 fn an_activity_id_names_its_execution_after_the_separator() {
9558 assert_eq!(
9559 activity_execution_id("20261005041849348::524b1902-817e-446c-825b-a9fcfebbc17e"),
9560 "524b1902-817e-446c-825b-a9fcfebbc17e"
9561 );
9562 assert_eq!(activity_execution_id("act-1"), "act-1");
9563 assert_eq!(
9564 activity_execution_id("20261005041849348::"),
9565 "20261005041849348::"
9566 );
9567 }
9568
9569 #[tokio::test]
9573 async fn a_fill_has_one_trade_id_on_the_stream_in_recovery_and_from_fetch_trades() {
9574 use crate::client::ExecutionClient;
9575
9576 let execution_id = "524b1902-817e-446c-825b-a9fcfebbc17e";
9577 let activity = activity_json(
9578 &format!("20250418103000000::{execution_id}"),
9579 "ord-1",
9580 "2",
9581 Some("2"),
9582 );
9583
9584 let [streamed, _snapshot] = convert_trade_update(AlpacaTradeUpdate {
9585 event: SmolStr::new("fill"),
9586 execution_id: Some(execution_id),
9587 previous_execution_id: None,
9588 order: super::make_order_ws("ord-1", "SPY", "buy", "2"),
9589 price: Some("100.00"),
9590 qty: Some("2"),
9591 timestamp: Some("2025-04-18T14:30:00Z"),
9592 });
9593 let Some(streamed) = streamed else {
9594 panic!("a fill converts to a Trade");
9595 };
9596
9597 let recovered = drive_recover_fills(vec![activity.clone()]).await;
9598 let [recovered] = recovered.as_slice() else {
9599 panic!("one fill recovered: {recovered:?}");
9600 };
9601
9602 let server = MockServer::start().await;
9603 Mock::given(method("GET"))
9604 .and(path("/v2/account/activities"))
9605 .respond_with(
9606 ResponseTemplate::new(200)
9607 .set_body_json(serde_json::Value::Array(vec![activity])),
9608 )
9609 .mount(&server)
9610 .await;
9611 let Ok(fetched) = client_for(&server)
9612 .fetch_trades(recovery_after(), Utc::now(), &[])
9613 .await
9614 else {
9615 panic!("fetch_trades reads the activity");
9616 };
9617 let [fetched] = fetched.trades.as_slice() else {
9618 panic!("one fill fetched: {fetched:?}");
9619 };
9620
9621 assert_eq!(trade_of(&streamed).id.0.as_str(), execution_id);
9622 assert_eq!(trade_of(recovered).id.0.as_str(), execution_id);
9623 assert_eq!(fetched.id.0.as_str(), execution_id);
9624 }
9625
9626 fn time(s: &str) -> DateTime<Utc> {
9631 s.parse().expect("valid time")
9632 }
9633
9634 struct ActivitiesVenue {
9639 activities: Vec<serde_json::Value>,
9640 }
9641
9642 impl Respond for ActivitiesVenue {
9643 fn respond(&self, request: &Request) -> ResponseTemplate {
9644 let query: std::collections::HashMap<String, String> =
9645 request.url.query_pairs().into_owned().collect();
9646 let bound = |name: &str| query.get(name).map(|s| time(s));
9647 let (after, until) = (bound("after"), bound("until"));
9648 let size: usize = query
9649 .get("page_size")
9650 .and_then(|s| s.parse().ok())
9651 .unwrap_or(ALPACA_MAX_ACTIVITIES);
9652 let in_bounds = self.activities.iter().filter(|activity| {
9653 let at =
9654 floor_millis(time(activity["transaction_time"].as_str().expect("a time")));
9655 after.is_none_or(|after| at >= after) && until.is_none_or(|until| at < until)
9656 });
9657 let page: Vec<_> = match query.get("page_token") {
9658 Some(token) => in_bounds
9659 .skip_while(|activity| activity["id"] != token.as_str())
9660 .skip(1)
9661 .take(size)
9662 .cloned()
9663 .collect(),
9664 None => in_bounds.take(size).cloned().collect(),
9665 };
9666 ResponseTemplate::new(200).set_body_json(page)
9667 }
9668 }
9669
9670 fn fill_at(id: &str, symbol: &str, transaction_time: DateTime<Utc>) -> serde_json::Value {
9672 serde_json::json!({
9673 "id": id,
9674 "order_id": format!("ord-{id}"),
9675 "symbol": symbol,
9676 "side": "buy",
9677 "price": "100.00",
9678 "qty": "1",
9679 "cum_qty": "1",
9680 "transaction_time": transaction_time.to_rfc3339_opts(SecondsFormat::Micros, true),
9681 })
9682 }
9683
9684 async fn serve_activities(activities: Vec<serde_json::Value>) -> MockServer {
9685 let server = MockServer::start().await;
9686 Mock::given(method("GET"))
9687 .and(path("/v2/account/activities"))
9688 .respond_with(ActivitiesVenue { activities })
9689 .mount(&server)
9690 .await;
9691 server
9692 }
9693
9694 fn non_trade(
9697 id: &str,
9698 activity_type: &str,
9699 sub_type: Option<&str>,
9700 net_amount: &str,
9701 created_at: &str,
9702 ) -> serde_json::Value {
9703 let mut activity = serde_json::json!({
9704 "id": id, "activity_type": activity_type, "net_amount": net_amount,
9705 "created_at": created_at, "date": &created_at[..10], "currency": "USD",
9706 "status": "executed",
9707 });
9708 if let Some(sub_type) = sub_type {
9709 activity["activity_sub_type"] = serde_json::json!(sub_type);
9710 }
9711 activity
9712 }
9713
9714 async fn query_pairs(server: &MockServer) -> Vec<Vec<(String, String)>> {
9716 let Some(requests) = server.received_requests().await else {
9717 panic!("requests are recorded");
9718 };
9719 requests
9720 .iter()
9721 .map(|request| request.url.query_pairs().into_owned().collect())
9722 .collect()
9723 }
9724
9725 #[tokio::test]
9726 async fn fetch_cash_flows_maps_each_activity_type_and_leaves_out_what_it_cannot_use() {
9727 let at = "2025-04-18T05:00:00Z";
9728 let mut fee = non_trade("fee", "FEE", Some("TAF"), "-0.02", at);
9729 fee["symbol"] = serde_json::json!("SPY");
9730 let mut canceled = non_trade("canceled", "FEE", Some("REG"), "-0.01", at);
9731 canceled["status"] = serde_json::json!("canceled");
9732 let mut no_amount = non_trade("no-amount", "FEE", None, "0", at);
9733 no_amount.as_object_mut().unwrap().remove("net_amount");
9734 let mut no_created_at = non_trade("no-time", "PTC", None, "-0.50", at);
9735 no_created_at.as_object_mut().unwrap().remove("created_at");
9736 let mut bad_time = non_trade("bad-time", "PTC", None, "-0.50", at);
9737 bad_time["created_at"] = serde_json::json!("not-a-time");
9738 let mut corrected = non_trade("correct", "PTC", None, "-0.40", at);
9739 corrected["status"] = serde_json::json!("correct");
9740 let mut euro = non_trade("euro", "PTR", None, "0.30", at);
9741 euro["currency"] = serde_json::json!("EUR");
9742 let server = MockServer::start().await;
9743 Mock::given(method("GET"))
9744 .and(path("/v2/account/activities"))
9745 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([
9746 non_trade("mgn", "INT", Some("MGN"), "-12.50", at),
9747 non_trade("int-paid", "INT", None, "-3.00", at),
9748 non_trade("int-credit", "INT", Some("CDT"), "1.00", at),
9749 fee,
9750 canceled,
9751 no_amount,
9752 no_created_at,
9753 bad_time,
9754 corrected,
9755 euro,
9756 non_trade("late", "FEE", None, "-0.01", "2025-04-20T05:00:00Z"),
9757 ])))
9758 .mount(&server)
9759 .await;
9760 let client = client_for(&server);
9761
9762 let read = client
9763 .fetch_cash_flows(time("2025-04-18T00:00:00Z"), time("2025-04-19T00:00:00Z"))
9764 .await
9765 .unwrap();
9766
9767 assert_eq!(read.resume, None);
9768 let kinds: Vec<_> = read
9769 .flows
9770 .iter()
9771 .map(|flow| (flow.id.clone().unwrap().0.to_string(), flow.kind.clone()))
9772 .collect();
9773 let interest = CashFlowKind::MarginInterest {
9774 rate: None,
9775 principal: None,
9776 };
9777 assert_eq!(
9778 kinds,
9779 [
9780 ("mgn", interest.clone()),
9781 ("int-paid", interest),
9782 ("int-credit", CashFlowKind::Other("INT.CDT".into())),
9783 ("fee", CashFlowKind::Other("FEE.TAF".into())),
9784 ("correct", CashFlowKind::Other("PTC".into())),
9785 ("euro", CashFlowKind::Rebate),
9786 ]
9787 .map(|(id, kind)| (id.to_owned(), kind))
9788 );
9789 let by_id = |id: &str| {
9790 read.flows
9791 .iter()
9792 .find(|flow| flow.id == Some(CashFlowId::new(id)))
9793 .unwrap()
9794 };
9795 assert_eq!(by_id("mgn").amount, Decimal::new(-1250, 2));
9796 assert_eq!(by_id("mgn").asset, AssetNameExchange::new("USD"));
9797 assert_eq!(by_id("mgn").instrument, None);
9798 assert_eq!(by_id("mgn").time_exchange, time(at));
9799 assert_eq!(
9800 by_id("fee").instrument,
9801 Some(InstrumentNameExchange::new("SPY"))
9802 );
9803 assert_eq!(
9804 by_id("correct").amount,
9805 Decimal::new(-40, 2),
9806 "a correction is returned as it is"
9807 );
9808 assert_eq!(by_id("euro").asset, AssetNameExchange::new("EUR"));
9809
9810 let queries = query_pairs(&server).await;
9811 let [query] = queries.as_slice() else {
9812 panic!("one request: {queries:?}");
9813 };
9814 assert!(
9815 query.contains(&("activity_types".into(), "INT,FEE,PTC,PTR".into())),
9816 "{query:?}"
9817 );
9818 }
9819
9820 #[tokio::test]
9821 async fn fetch_trades_asks_for_fills_by_the_documented_parameter() {
9822 use crate::client::ExecutionClient;
9823
9824 let server = serve_activities(Vec::new()).await;
9825 let client = client_for(&server);
9826
9827 let read = client
9828 .fetch_trades(
9829 time("2025-04-18T00:00:00Z"),
9830 time("2025-04-19T00:00:00Z"),
9831 &[],
9832 )
9833 .await
9834 .unwrap();
9835
9836 assert!(read.trades.is_empty());
9837 let queries = query_pairs(&server).await;
9838 let [query] = queries.as_slice() else {
9839 panic!("one request: {queries:?}");
9840 };
9841 assert!(
9842 query.contains(&("activity_types".into(), "FILL".into())),
9843 "{query:?}"
9844 );
9845 assert!(
9846 !query.iter().any(|(name, _)| name == "activity_type"),
9847 "{query:?}"
9848 );
9849 }
9850
9851 #[tokio::test]
9852 async fn fetch_cash_flows_reads_nothing_for_an_empty_span() {
9853 let server = MockServer::start().await;
9854 let client = client_for(&server);
9855
9856 let read = client
9857 .fetch_cash_flows(time("2025-04-19T00:00:00Z"), time("2025-04-18T00:00:00Z"))
9858 .await
9859 .unwrap();
9860
9861 assert!(read.flows.is_empty() && read.resume.is_none());
9862 assert!(query_pairs(&server).await.is_empty());
9863 }
9864
9865 #[tokio::test]
9868 async fn fetch_cash_flows_that_stops_at_its_bound_says_where_to_read_on() {
9869 let base = time("2025-04-18T05:00:00Z");
9870 let activities: Vec<_> = (0..6_000_i64)
9871 .map(|n| {
9872 let created =
9873 (base + TimeDelta::seconds(n)).to_rfc3339_opts(SecondsFormat::Micros, true);
9874 non_trade(&format!("{n:017}::fee"), "FEE", None, "-0.01", &created)
9875 })
9876 .collect();
9877 struct Paged(Vec<serde_json::Value>);
9879 impl Respond for Paged {
9880 fn respond(&self, request: &Request) -> ResponseTemplate {
9881 let token = request
9882 .url
9883 .query_pairs()
9884 .find(|(name, _)| name == "page_token")
9885 .map(|(_, value)| value.into_owned());
9886 let page: Vec<_> = self
9887 .0
9888 .iter()
9889 .skip_while(|activity| {
9890 token
9891 .as_deref()
9892 .is_some_and(|token| activity["id"] != token)
9893 })
9894 .skip(usize::from(token.is_some()))
9895 .take(ALPACA_MAX_ACTIVITIES)
9896 .cloned()
9897 .collect();
9898 ResponseTemplate::new(200).set_body_json(page)
9899 }
9900 }
9901 let server = MockServer::start().await;
9902 Mock::given(method("GET"))
9903 .and(path("/v2/account/activities"))
9904 .respond_with(Paged(activities))
9905 .mount(&server)
9906 .await;
9907 let client = client_for(&server);
9908
9909 let read = client
9910 .fetch_cash_flows(base, base + TimeDelta::days(1))
9911 .await
9912 .unwrap();
9913
9914 assert_eq!(read.flows.len(), 5_000);
9915 assert_eq!(read.resume, Some(base + TimeDelta::seconds(4_999)));
9916 }
9917
9918 #[tokio::test]
9921 async fn fetch_cash_flows_that_cannot_advance_fails() {
9922 let at = "2025-04-18T05:00:00Z";
9923 let full: Vec<_> = (0..ALPACA_MAX_ACTIVITIES)
9924 .map(|n| non_trade(&format!("fee-{n}"), "FEE", None, "-0.01", at))
9925 .collect();
9926 let server = MockServer::start().await;
9927 Mock::given(method("GET"))
9928 .and(path("/v2/account/activities"))
9929 .respond_with(ResponseTemplate::new(200).set_body_json(full))
9930 .mount(&server)
9931 .await;
9932 let client = client_for(&server);
9933
9934 let result = client
9935 .fetch_cash_flows(time(at), time("2025-04-19T00:00:00Z"))
9936 .await;
9937
9938 assert!(
9939 matches!(result, Err(UnindexedClientError::Internal(_))),
9940 "{result:?}"
9941 );
9942 }
9943
9944 fn option_activity(
9947 id: &str,
9948 activity_type: &str,
9949 symbol: &str,
9950 qty: &str,
9951 date: &str,
9952 ) -> serde_json::Value {
9953 serde_json::json!({
9954 "id": id, "activity_type": activity_type, "symbol": symbol, "qty": qty,
9955 "date": date, "net_amount": "0", "status": "executed",
9956 })
9957 }
9958
9959 fn share_leg(
9961 id: &str,
9962 symbol: &str,
9963 qty: &str,
9964 price: &str,
9965 date: &str,
9966 ) -> serde_json::Value {
9967 let mut activity = option_activity(id, "OPTRD", symbol, qty, date);
9968 activity["price"] = serde_json::json!(price);
9969 activity
9970 }
9971
9972 async fn serve_json(activities: serde_json::Value) -> MockServer {
9973 let server = MockServer::start().await;
9974 Mock::given(method("GET"))
9975 .and(path("/v2/account/activities"))
9976 .respond_with(ResponseTemplate::new(200).set_body_json(activities))
9977 .mount(&server)
9978 .await;
9979 server
9980 }
9981
9982 #[test]
9983 fn occ_contract_reads_the_root_kind_and_strike() {
9984 assert_eq!(
9985 occ_contract("QS240301C00006500"),
9986 Some(("QS", true, Decimal::new(65, 1)))
9987 );
9988 assert_eq!(
9989 occ_contract("AAPL240119P00150000"),
9990 Some(("AAPL", false, Decimal::from(150)))
9991 );
9992 for not_occ in [
9993 "QS",
9994 "240301C00006500",
9995 "QS240301X00006500",
9996 "QS2403O1C00006500",
9997 ] {
9998 assert_eq!(occ_contract(not_occ), None, "{not_occ}");
9999 }
10000 }
10001
10002 #[tokio::test]
10003 async fn fetch_option_events_maps_each_activity_and_pairs_the_share_legs() {
10004 let day = "2024-03-01";
10005 let mut expiry = option_activity("exp", "OPEXP", "QS240301C00010500", "-1", day);
10006 expiry["created_at"] = serde_json::json!("2024-03-01T21:30:00Z");
10007 let mut canceled = option_activity("canceled", "OPEXP", "QS240301P00000500", "-1", day);
10008 canceled["status"] = serde_json::json!("canceled");
10009 let server = serve_json(serde_json::json!([
10010 expiry,
10011 option_activity("exc", "OPEXC", "QS240301C00006500", "-1", day),
10012 share_leg("exc-shares", "QS", "100", "6.5", day),
10013 option_activity("asn", "OPASN", "QS240301P00009000", "1", day),
10014 share_leg("asn-shares", "QS", "100", "9", day),
10015 share_leg("orphan", "QS", "-100", "7", day),
10016 canceled,
10017 option_activity("no-price", "OPTRD", "QS", "100", day),
10018 option_activity("no-qty", "OPEXP", "QS240301C00011000", "0", day),
10019 option_activity("later", "OPEXP", "QS240308C00010500", "-1", "2024-03-02"),
10020 ]))
10021 .await;
10022 let client = client_for(&server);
10023
10024 let read = client
10025 .fetch_option_events(time("2024-03-01T00:00:00Z"), time("2024-03-01T23:00:00Z"))
10026 .await
10027 .unwrap();
10028
10029 assert_eq!(read.resume, None);
10030 let summary: Vec<_> = read
10031 .trades
10032 .iter()
10033 .map(|trade| {
10034 (
10035 trade.id.0.to_string(),
10036 trade.instrument.name().to_string(),
10037 trade.origin,
10038 trade.side,
10039 trade.price,
10040 trade.quantity,
10041 )
10042 })
10043 .collect();
10044 let row = |id: &str, symbol: &str, origin, side, price: Decimal, quantity: i64| {
10045 (
10046 id.to_owned(),
10047 symbol.to_owned(),
10048 origin,
10049 side,
10050 price,
10051 Decimal::from(quantity),
10052 )
10053 };
10054 assert_eq!(
10055 summary,
10056 [
10057 row(
10058 "exp",
10059 "QS240301C00010500",
10060 TradeOrigin::Expiry,
10061 Side::Sell,
10062 Decimal::ZERO,
10063 1
10064 ),
10065 row(
10066 "exc",
10067 "QS240301C00006500",
10068 TradeOrigin::Exercise,
10069 Side::Sell,
10070 Decimal::ZERO,
10071 1
10072 ),
10073 row(
10074 "exc-shares",
10075 "QS",
10076 TradeOrigin::Exercise,
10077 Side::Buy,
10078 Decimal::new(65, 1),
10079 100
10080 ),
10081 row(
10082 "asn",
10083 "QS240301P00009000",
10084 TradeOrigin::Assignment,
10085 Side::Buy,
10086 Decimal::ZERO,
10087 1
10088 ),
10089 row(
10090 "asn-shares",
10091 "QS",
10092 TradeOrigin::Assignment,
10093 Side::Buy,
10094 Decimal::from(9),
10095 100
10096 ),
10097 row(
10098 "orphan",
10099 "QS",
10100 TradeOrigin::External,
10101 Side::Sell,
10102 Decimal::from(7),
10103 100
10104 ),
10105 ]
10106 );
10107 let exp = &read.trades[0];
10108 assert_eq!(
10109 exp.time_exchange,
10110 time("2024-03-01T21:30:00Z"),
10111 "created_at"
10112 );
10113 assert_eq!(exp.order_id, OrderId::new("exp"));
10114 assert_eq!(exp.fees.fees, Decimal::ZERO);
10115 assert_eq!(
10116 read.trades[1].time_exchange,
10117 time("2024-03-01T00:00:00Z"),
10118 "no created_at: the start of its date"
10119 );
10120
10121 let queries = query_pairs(&server).await;
10122 let [query] = queries.as_slice() else {
10123 panic!("one request: {queries:?}");
10124 };
10125 assert!(
10126 query.contains(&("activity_types".into(), "OPASN,OPEXC,OPEXP,OPTRD".into())),
10127 "{query:?}"
10128 );
10129 }
10130
10131 #[tokio::test]
10134 async fn fetch_option_events_pairs_a_share_leg_by_its_side() {
10135 let day = "2024-03-01";
10136 let server = serve_json(serde_json::json!([
10137 option_activity("exc-call", "OPEXC", "QS240301C00006500", "-1", day),
10138 option_activity("asn-call", "OPASN", "QS240301C00006500", "1", day),
10139 share_leg("sold", "QS", "-100", "6.5", day),
10140 share_leg("bought", "QS", "100", "6.5", day),
10141 share_leg("bought-again", "QS", "100", "6.5", day),
10142 share_leg("other-day", "QS", "100", "6.5", "2024-02-29"),
10143 ]))
10144 .await;
10145 let client = client_for(&server);
10146
10147 let read = client
10148 .fetch_option_events(time("2024-02-29T00:00:00Z"), time("2024-03-01T23:00:00Z"))
10149 .await
10150 .unwrap();
10151
10152 let origin = |id: &str| {
10153 read.trades
10154 .iter()
10155 .find(|trade| trade.id == TradeId::new(id))
10156 .map(|trade| trade.origin)
10157 };
10158 assert_eq!(origin("sold"), Some(TradeOrigin::Assignment));
10159 assert_eq!(origin("bought"), Some(TradeOrigin::Exercise));
10160 assert_eq!(origin("bought-again"), Some(TradeOrigin::External));
10161 assert_eq!(origin("other-day"), Some(TradeOrigin::External));
10162 }
10163
10164 #[tokio::test]
10168 async fn fetch_option_events_pairs_a_share_leg_by_its_size() {
10169 let day = "2024-03-01";
10170 let server = serve_json(serde_json::json!([
10171 option_activity("asn-two", "OPASN", "QS240301P00006500", "2", day),
10173 option_activity("exc-one", "OPEXC", "QS240301C00006500", "-1", day),
10174 option_activity("exc-adjusted", "OPEXC", "QS240301C00006500", "-1", day),
10175 share_leg("hundred", "QS", "100", "6.5", day),
10176 share_leg("two-hundred", "QS", "200", "6.5", day),
10177 share_leg("hundred-fifty", "QS", "150", "6.5", day),
10178 ]))
10179 .await;
10180 let client = client_for(&server);
10181
10182 let read = client
10183 .fetch_option_events(time("2024-03-01T00:00:00Z"), time("2024-03-01T23:00:00Z"))
10184 .await
10185 .unwrap();
10186
10187 let origin = |id: &str| {
10188 read.trades
10189 .iter()
10190 .find(|trade| trade.id == TradeId::new(id))
10191 .map(|trade| trade.origin)
10192 };
10193 assert_eq!(origin("hundred"), Some(TradeOrigin::Exercise));
10194 assert_eq!(origin("two-hundred"), Some(TradeOrigin::Assignment));
10195 assert_eq!(origin("hundred-fifty"), Some(TradeOrigin::Exercise));
10196 }
10197
10198 #[tokio::test]
10202 async fn fetch_option_events_reads_whole_days_and_returns_the_span() {
10203 let mut early =
10204 option_activity("early", "OPEXP", "QS240301C00010500", "-1", "2024-03-01");
10205 early["created_at"] = serde_json::json!("2024-03-01T10:00:00Z");
10206 let mut inside =
10207 option_activity("inside", "OPEXP", "QS240301C00011500", "-1", "2024-03-01");
10208 inside["created_at"] = serde_json::json!("2024-03-01T16:00:00Z");
10209 let server = serve_json(serde_json::json!([
10210 option_activity(
10211 "dated-before",
10212 "OPEXP",
10213 "QS240229C00010500",
10214 "-1",
10215 "2024-02-29"
10216 ),
10217 early,
10218 option_activity("dated", "OPEXP", "QS240301C00012500", "-1", "2024-03-01"),
10219 inside,
10220 ]))
10221 .await;
10222 let client = client_for(&server);
10223
10224 let read = client
10225 .fetch_option_events(time("2024-03-01T15:00:00Z"), time("2024-03-01T17:00:00Z"))
10226 .await
10227 .unwrap();
10228
10229 let ids: Vec<_> = read
10230 .trades
10231 .iter()
10232 .map(|trade| trade.id.0.to_string())
10233 .collect();
10234 assert_eq!(ids, ["dated", "inside"]);
10235 let queries = query_pairs(&server).await;
10236 let [query] = queries.as_slice() else {
10237 panic!("one request: {queries:?}");
10238 };
10239 assert!(
10240 query.contains(&("after".into(), "2024-03-01T00:00:00.000Z".into())),
10241 "{query:?}"
10242 );
10243 assert!(
10244 query.contains(&("until".into(), "2024-03-02T00:00:00.000Z".into())),
10245 "{query:?}"
10246 );
10247 }
10248
10249 #[tokio::test]
10250 async fn fetch_option_events_reads_nothing_for_an_empty_span() {
10251 let server = MockServer::start().await;
10252 let client = client_for(&server);
10253
10254 let read = client
10255 .fetch_option_events(time("2024-03-02T00:00:00Z"), time("2024-03-01T00:00:00Z"))
10256 .await
10257 .unwrap();
10258
10259 assert!(read.trades.is_empty() && read.resume.is_none());
10260 assert!(query_pairs(&server).await.is_empty());
10261 }
10262
10263 async fn read_span(
10266 client: &AlpacaClient,
10267 start: DateTime<Utc>,
10268 end: DateTime<Utc>,
10269 instruments: &[InstrumentNameExchange],
10270 ) -> (std::collections::HashSet<String>, usize) {
10271 use crate::client::ExecutionClient;
10272
10273 let mut ids = std::collections::HashSet::new();
10274 let mut from = start;
10275 for calls in 1..=10 {
10276 let read = match client.fetch_trades(from, end, instruments).await {
10277 Ok(read) => read,
10278 Err(error) => panic!("call {calls} failed: {error:?}"),
10279 };
10280 ids.extend(read.trades.iter().map(|trade| trade.id.0.to_string()));
10281 match read.resume {
10282 Some(resume) => from = resume,
10283 None => return (ids, calls),
10284 }
10285 }
10286 panic!("the reads did not reach the span's end");
10287 }
10288
10289 #[tokio::test]
10295 async fn fetch_trades_reads_a_busy_span_completely_across_calls() {
10296 let base = time("2025-04-18T14:30:00Z");
10297 let mut activities = Vec::new();
10298 for ms in 0..4_000_i64 {
10299 let symbol = if ms % 2 == 0 { "SPY" } else { "QQQ" };
10300 for (k, micros) in [("a", 900), ("b", 500), ("c", 100)] {
10301 let at = base + TimeDelta::milliseconds(ms) + TimeDelta::microseconds(micros);
10302 activities.push(fill_at(&format!("{ms:017}::{ms}-{k}"), symbol, at));
10303 }
10304 }
10305 let server = serve_activities(activities).await;
10306 let client = client_for(&server);
10307 let end = time("2025-04-18T15:00:00Z");
10308
10309 let (ids, calls) = read_span(&client, recovery_after(), end, &[]).await;
10310 assert_eq!(ids.len(), 12_000, "every fill is read once or more");
10311 assert_eq!(calls, 3, "5,000 activities per call");
10312
10313 let spy = [InstrumentNameExchange::new("SPY")];
10314 let (ids, _) = read_span(&client, recovery_after(), end, &spy).await;
10315 assert_eq!(ids.len(), 6_000, "every SPY fill, and only those");
10316
10317 let Some(requests) = server.received_requests().await else {
10318 panic!("requests are recorded");
10319 };
10320 assert!(
10321 requests
10322 .iter()
10323 .all(|request| request.url.query_pairs().any(
10324 |(name, value)| name == "until" && value == "2025-04-18T15:00:00.001Z"
10325 )),
10326 "every request is bounded by the span's end"
10327 );
10328 }
10329
10330 #[tokio::test]
10333 async fn fetch_trades_says_where_to_read_on_after_a_call_without_matches() {
10334 use crate::client::ExecutionClient;
10335
10336 let server = MockServer::start().await;
10337 Mock::given(method("GET"))
10338 .and(path("/v2/account/activities"))
10339 .respond_with(
10340 ResponseTemplate::new(200)
10341 .set_body_json(make_activities_json(ALPACA_MAX_ACTIVITIES, "act")),
10342 )
10343 .mount(&server)
10344 .await;
10345
10346 let qqq = [InstrumentNameExchange::new("QQQ")];
10347 let Ok(read) = client_for(&server)
10348 .fetch_trades(recovery_after(), Utc::now(), &qqq)
10349 .await
10350 else {
10351 panic!("the read succeeds");
10352 };
10353 assert!(read.trades.is_empty(), "every fill read is SPY's");
10354 assert_eq!(read.resume, Some(time("2025-04-18T14:30:00Z")));
10355 }
10356
10357 #[tokio::test]
10360 async fn fetch_trades_that_cannot_advance_is_truncated() {
10361 use crate::client::ExecutionClient;
10362
10363 let server = MockServer::start().await;
10364 Mock::given(method("GET"))
10365 .and(path("/v2/account/activities"))
10366 .respond_with(
10367 ResponseTemplate::new(200)
10368 .set_body_json(make_activities_json(ALPACA_MAX_ACTIVITIES, "act")),
10369 )
10370 .mount(&server)
10371 .await;
10372
10373 let result = client_for(&server)
10374 .fetch_trades(time("2025-04-18T14:30:00.000500Z"), Utc::now(), &[])
10375 .await;
10376 assert!(
10377 matches!(
10378 result,
10379 Err(UnindexedClientError::Truncated { fills_read: 5_000 })
10380 ),
10381 "got {result:?}"
10382 );
10383 }
10384
10385 #[tokio::test]
10388 async fn fetch_trades_returns_only_the_span() {
10389 let (start, end) = (
10390 time("2025-04-18T14:30:00.123456Z"),
10391 time("2025-04-18T14:30:05.678901Z"),
10392 );
10393 let micro = TimeDelta::microseconds(1);
10394 let server = serve_activities(vec![
10395 fill_at("1::before", "SPY", start - micro),
10396 fill_at("2::start", "SPY", start),
10397 fill_at("3::end", "SPY", end),
10398 fill_at("4::after", "SPY", end + micro),
10399 ])
10400 .await;
10401
10402 let (ids, calls) = read_span(&client_for(&server), start, end, &[]).await;
10403 assert_eq!(calls, 1);
10404 let mut ids: Vec<_> = ids.into_iter().collect();
10405 ids.sort();
10406 assert_eq!(ids, ["end", "start"]);
10407 }
10408
10409 #[tokio::test]
10412 async fn fetch_trades_that_read_past_the_end_is_complete() {
10413 use crate::client::ExecutionClient;
10414
10415 let server = MockServer::start().await;
10416 Mock::given(method("GET"))
10418 .and(path("/v2/account/activities"))
10419 .respond_with(
10420 ResponseTemplate::new(200)
10421 .set_body_json(make_activities_json(ALPACA_MAX_ACTIVITIES, "act")),
10422 )
10423 .mount(&server)
10424 .await;
10425
10426 let Ok(read) = client_for(&server)
10427 .fetch_trades(recovery_after(), time("2025-04-18T14:29:00Z"), &[])
10428 .await
10429 else {
10430 panic!("the read succeeds");
10431 };
10432 assert_eq!(read, TradesRead::complete(Vec::new()));
10433 }
10434
10435 #[tokio::test]
10439 async fn fetch_trades_accepts_the_extreme_times() {
10440 use crate::client::ExecutionClient;
10441
10442 let server = serve_activities(vec![fill_at(
10443 "1::fill",
10444 "SPY",
10445 time("2025-04-18T14:30:00Z"),
10446 )])
10447 .await;
10448 let Ok(read) = client_for(&server)
10449 .fetch_trades(DateTime::<Utc>::MIN_UTC, DateTime::<Utc>::MAX_UTC, &[])
10450 .await
10451 else {
10452 panic!("the read succeeds");
10453 };
10454 assert_eq!(read.trades.len(), 1);
10455 assert_eq!(read.resume, None);
10456
10457 let Some(requests) = server.received_requests().await else {
10458 panic!("requests are recorded");
10459 };
10460 let [request] = requests.as_slice() else {
10461 panic!("one request: {requests:?}");
10462 };
10463 let query: std::collections::HashMap<_, _> =
10464 request.url.query_pairs().into_owned().collect();
10465 assert_eq!(
10466 query.get("after").map(String::as_str),
10467 Some("1970-01-01T00:00:00.000Z")
10468 );
10469 assert!(!query.contains_key("until"), "{query:?}");
10470
10471 let before_epoch = DateTime::UNIX_EPOCH - TimeDelta::days(1);
10473 let Ok(read) = client_for(&server)
10474 .fetch_trades(DateTime::<Utc>::MIN_UTC, before_epoch, &[])
10475 .await
10476 else {
10477 panic!("the read succeeds");
10478 };
10479 assert_eq!(read, TradesRead::complete(Vec::new()));
10480 assert_eq!(server.received_requests().await.map(|r| r.len()), Some(1));
10481 }
10482
10483 #[tokio::test]
10485 async fn fetch_trades_reads_nothing_for_an_empty_span() {
10486 use crate::client::ExecutionClient;
10487
10488 let server = MockServer::start().await;
10489 let start = recovery_after();
10490 let Ok(read) = client_for(&server)
10491 .fetch_trades(start, start - TimeDelta::seconds(1), &[])
10492 .await
10493 else {
10494 panic!("an empty span reads");
10495 };
10496 assert_eq!(read, TradesRead::complete(Vec::new()));
10497 assert_eq!(server.received_requests().await.map(|r| r.len()), Some(0));
10498 }
10499
10500 #[tokio::test]
10503 async fn recovery_reads_from_the_start_of_the_disconnects_millisecond() {
10504 let server = MockServer::start().await;
10505 Mock::given(method("GET"))
10506 .and(path("/v2/account/activities"))
10507 .and(wiremock::matchers::query_param(
10508 "after",
10509 "2025-01-01T00:00:00.123Z",
10510 ))
10511 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([])))
10512 .expect(1)
10513 .mount(&server)
10514 .await;
10515
10516 let (tx, _rx) = mpsc::unbounded_channel();
10517 let outcome = recover_fills(
10518 &reqwest::Client::new(),
10519 &RateLimitTracker::new(),
10520 &[],
10521 &server.uri(),
10522 time("2025-01-01T00:00:00.123456Z"),
10523 &tx,
10524 &new_dedup_cache(),
10525 &KnownLiveOrders::shared(ExchangeId::AlpacaBroker),
10526 )
10527 .await;
10528 assert_eq!(outcome, Ok(()));
10529 server.verify().await;
10530 }
10531
10532 fn dedup_keys(dedup: &SharedDedupCache) -> Vec<String> {
10534 let mut keys: Vec<_> = dedup
10535 .lock()
10536 .iter()
10537 .filter_map(|(key, _)| match key {
10538 FillKey::Cumulative(key) => Some(key.to_string()),
10539 FillKey::Execution(_) | FillKey::CumulativeWithoutId(_) => None,
10540 })
10541 .collect();
10542 keys.reverse();
10544 keys
10545 }
10546
10547 fn trade_of(
10548 event: &UnindexedAccountEvent,
10549 ) -> &Trade<AssetNameExchange, InstrumentNameExchange> {
10550 match &event.kind {
10551 AccountEventKind::Trade(t) => t,
10552 other => panic!("expected a Trade event, got {other:?}"),
10553 }
10554 }
10555
10556 #[tokio::test]
10559 async fn a_recovered_fill_reports_the_cumulative_alpaca_sent() {
10560 let events = drive_recover_fills(vec![
10561 activity_json("act-1", "ord-1", "2", Some("2")),
10562 activity_json("act-2", "ord-1", "3", Some("5")),
10563 ])
10564 .await;
10565
10566 let reported: Vec<_> = events
10567 .iter()
10568 .map(|e| trade_of(e).order_filled_quantity)
10569 .collect();
10570 assert_eq!(
10571 reported,
10572 vec![
10573 Some(Decimal::from_str("2").unwrap()),
10574 Some(Decimal::from_str("5").unwrap())
10575 ]
10576 );
10577 }
10578
10579 #[tokio::test]
10586 async fn the_dedup_key_uses_the_venue_cumulative_not_an_intra_batch_count() {
10587 let dedup = new_dedup_cache();
10589 drive_recover_fills_with(
10590 vec![activity_json("act-1", "ord-1", "2", Some("5"))],
10591 dedup.clone(),
10592 &KnownLiveOrders::shared(ExchangeId::AlpacaBroker),
10593 )
10594 .await;
10595
10596 assert_eq!(
10597 dedup_keys(&dedup),
10598 vec!["ord-1:5"],
10599 "counting executions within the batch would key this as ord-1:2"
10600 );
10601 }
10602
10603 async fn recover_after_ws_fill(
10607 execution_id: Option<&str>,
10608 activity_id: &str,
10609 ) -> Vec<UnindexedAccountEvent> {
10610 let dedup = new_dedup_cache();
10611 let update = AlpacaTradeUpdate {
10612 event: SmolStr::new("partial_fill"),
10613 execution_id,
10614 previous_execution_id: None,
10615 order: super::make_order_ws("ord-1", "SPY", "buy", "5"),
10616 price: Some("100.00"),
10617 qty: Some("2"),
10618 timestamp: None,
10619 };
10620 assert!(
10621 !is_duplicate_fill(&dedup, ws_execution_id(&update), early_dedup_key(&update)),
10622 "precondition: first sighting"
10623 );
10624
10625 drive_recover_fills_with(
10626 vec![activity_json(activity_id, "ord-1", "2", Some("5"))],
10627 dedup,
10628 &KnownLiveOrders::shared(ExchangeId::AlpacaBroker),
10629 )
10630 .await
10631 }
10632
10633 #[tokio::test]
10636 async fn a_fill_already_delivered_over_websocket_is_not_recovered_twice() {
10637 for (ws_execution_id, activity_id) in [
10638 (Some("exec-a"), "20250418103000000::exec-a"),
10639 (None, "20250418103000000::exec-a"),
10640 (Some("exec-a"), "act-1"),
10641 ] {
10642 let events = recover_after_ws_fill(ws_execution_id, activity_id).await;
10643 assert!(
10644 events.is_empty(),
10645 "a fill already delivered over WebSocket ({ws_execution_id:?}) must not be \
10646 re-delivered by recovery ({activity_id}), got {events:?}"
10647 );
10648 }
10649 }
10650
10651 async fn recover_after_correction(fields: &str) -> Vec<UnindexedAccountEvent> {
10654 let (tx, _rx) = mpsc::unbounded_channel();
10655 let dedup = new_dedup_cache();
10656 let known = KnownLiveOrders::shared(ExchangeId::AlpacaBroker);
10657 process_ws_text(
10658 &super::amendment_frame("trade_correct", fields),
10659 &tx,
10660 &dedup,
10661 &known,
10662 );
10663
10664 drive_recover_fills_with(
10665 vec![activity_json(
10666 "20250418103100000::exec-c",
10667 "ord-1",
10668 "2",
10669 Some("2"),
10670 )],
10671 dedup,
10672 &known,
10673 )
10674 .await
10675 }
10676
10677 #[tokio::test]
10680 async fn a_corrected_execution_is_not_recovered_again() {
10681 let events = recover_after_correction(
10682 r#""execution_id":"exec-c","previous_execution_id":"exec-a","price":"101.50","qty":"2","#,
10683 )
10684 .await;
10685 assert!(
10686 events.is_empty(),
10687 "the corrected execution was delivered as the replacement, got {events:?}"
10688 );
10689 }
10690
10691 #[tokio::test]
10693 async fn an_unresolved_correction_does_not_keep_its_execution_from_recovery() {
10694 let events = recover_after_correction(
10695 r#""execution_id":"exec-c","previous_execution_id":"exec-a","qty":"2","#,
10696 )
10697 .await;
10698 let ids: Vec<_> = events.iter().map(|e| trade_of(e).id.0.as_str()).collect();
10699 assert_eq!(ids, vec!["exec-c"]);
10700 }
10701
10702 #[tokio::test]
10705 async fn a_different_execution_at_a_cumulative_already_seen_is_recovered() {
10706 let events = recover_after_ws_fill(Some("exec-a"), "20250418103000000::exec-b").await;
10707 let ids: Vec<_> = events.iter().map(|e| trade_of(e).id.0.as_str()).collect();
10708 assert_eq!(ids, vec!["exec-b"]);
10709 }
10710
10711 #[tokio::test]
10714 async fn without_a_reported_cumulative_the_previous_behaviour_is_preserved() {
10715 let dedup = new_dedup_cache();
10716 let events = drive_recover_fills_with(
10717 vec![
10718 activity_json("act-1", "ord-1", "2", None),
10719 activity_json("act-2", "ord-1", "3", None),
10720 ],
10721 dedup.clone(),
10722 &KnownLiveOrders::shared(ExchangeId::AlpacaBroker),
10723 )
10724 .await;
10725
10726 assert_eq!(
10727 dedup_keys(&dedup),
10728 vec!["ord-1:2", "ord-1:5"],
10729 "intra-batch accumulation"
10730 );
10731 let ids: Vec<_> = events.iter().map(|e| trade_of(e).id.0.as_str()).collect();
10732 assert_eq!(ids, vec!["act-1", "act-2"], "each keeps its activity's id");
10733
10734 assert!(
10735 events
10736 .iter()
10737 .all(|e| trade_of(e).order_filled_quantity.is_none()),
10738 "a venue that reported no cumulative must not have one invented for it"
10739 );
10740 }
10741
10742 fn order_json(cid: &str, symbol: &str, status: &str, filled: &str) -> serde_json::Value {
10747 serde_json::json!({
10748 "id": format!("id-{cid}"),
10749 "client_order_id": cid,
10750 "symbol": symbol,
10751 "qty": "2",
10752 "filled_qty": filled,
10753 "filled_avg_price": if filled == "0" { None } else { Some("101.5") },
10754 "status": status,
10755 "side": "buy",
10756 "type": "limit",
10757 "time_in_force": "gtc",
10758 "limit_price": "100",
10759 "created_at": "2026-10-01T14:30:00Z",
10760 "updated_at": "2026-10-01T15:00:00Z"
10761 })
10762 }
10763
10764 async fn mount_lookup(server: &MockServer, cid: &str, order: ResponseTemplate, calls: u64) {
10766 use wiremock::matchers::query_param;
10767 Mock::given(method("GET"))
10768 .and(path("/v2/orders:by_client_order_id"))
10769 .and(query_param("client_order_id", cid))
10770 .respond_with(order)
10771 .expect(calls)
10772 .mount(server)
10773 .await;
10774 }
10775
10776 fn ok(body: serde_json::Value) -> ResponseTemplate {
10777 ResponseTemplate::new(200).set_body_json(body)
10778 }
10779
10780 fn alpaca_key(instrument: &str, cid: &str) -> UnindexedOrderKey {
10781 OrderKey::new(
10782 ExchangeId::AlpacaBroker,
10783 InstrumentNameExchange::new(instrument),
10784 StrategyId::new("strategy"),
10785 ClientOrderId::new(cid),
10786 )
10787 }
10788
10789 fn hold(known: &SharedKnownLiveOrders, key: &UnindexedOrderKey) {
10790 let open = Open::new(
10791 VenueOrderId::Assigned(OrderId::new(format!("id-{}", key.cid))),
10792 Utc::now(),
10793 Decimal::ZERO,
10794 );
10795 known
10796 .lock()
10797 .live(key, Decimal::TWO, OrderKind::Limit, &open);
10798 }
10799
10800 #[tokio::test]
10801 async fn fetch_ended_orders_reads_each_end_alpaca_reports() {
10802 use rust_decimal_macros::dec;
10803
10804 let server = MockServer::start().await;
10805 for (cid, status, filled) in [
10806 ("filled", "filled", "2"),
10807 ("canceled", "canceled", "1"),
10808 ("replaced", "replaced", "0"),
10809 ("expired", "expired", "1"),
10810 ("rejected", "rejected", "0"),
10811 ("done", "done_for_day", "1"),
10812 ("live", "new", "0"),
10813 ("unknown-status", "frozen", "0"),
10814 ] {
10815 mount_lookup(&server, cid, ok(order_json(cid, "SPY", status, filled)), 1).await;
10816 }
10817 mount_lookup(
10818 &server,
10819 "elsewhere",
10820 ok(order_json("elsewhere", "QQQ", "canceled", "0")),
10821 1,
10822 )
10823 .await;
10824 mount_lookup(
10825 &server,
10826 "gone",
10827 ResponseTemplate::new(404).set_body_json(serde_json::json!({
10828 "code": 40410000,
10829 "message": "order not found for gone"
10830 })),
10831 1,
10832 )
10833 .await;
10834
10835 let keys: Vec<_> = [
10836 "filled",
10837 "canceled",
10838 "replaced",
10839 "expired",
10840 "rejected",
10841 "done",
10842 "live",
10843 "unknown-status",
10844 "elsewhere",
10845 "gone",
10846 ]
10847 .into_iter()
10848 .map(|cid| alpaca_key("spy", cid))
10849 .collect();
10850 let ended = client_for(&server).fetch_ended_orders(&keys).await.unwrap();
10851
10852 let mut found: Vec<_> = ended
10853 .iter()
10854 .map(|order| (order.key.cid.0.as_str(), &order.state))
10855 .collect();
10856 found.sort_by_key(|(cid, _)| *cid);
10857 let [
10858 (canceled, InactiveOrderState::Cancelled(cancelled)),
10859 (expired, InactiveOrderState::Expired(expiry)),
10860 (filled, InactiveOrderState::FullyFilled(fill)),
10861 (rejected, InactiveOrderState::OpenFailed(failed)),
10862 (replaced, InactiveOrderState::Cancelled(replacement)),
10863 ] = found.as_slice()
10864 else {
10865 panic!("filled, canceled, replaced, expired and rejected: {found:?}");
10866 };
10867 assert_eq!(
10868 [*canceled, *expired, *filled, *rejected, *replaced],
10869 ["canceled", "expired", "filled", "rejected", "replaced"]
10870 );
10871 assert_eq!(
10872 (fill.filled_quantity, fill.avg_price),
10873 (dec!(2), Some(dec!(101.5)))
10874 );
10875 assert_eq!(
10876 cancelled.filled_quantity,
10877 Some(dec!(1)),
10878 "what filled before"
10879 );
10880 assert_eq!(expiry.filled_quantity, Some(dec!(1)));
10881 assert_eq!(replacement.filled_quantity, Some(Decimal::ZERO));
10882 assert_eq!(
10883 cancelled.time_exchange,
10884 parse_timestamp("2026-10-01T15:00:00Z").unwrap(),
10885 "stamped when the order last changed"
10886 );
10887 assert!(matches!(
10888 failed,
10889 OrderError::Rejected(ApiError::OrderRejected(message))
10890 if message.contains("id-rejected")
10891 ));
10892 assert!(
10893 ended
10894 .iter()
10895 .all(|order| order.key.strategy == StrategyId::new("strategy")
10896 && order.key.instrument == InstrumentNameExchange::new("spy")),
10897 "each carries the key it was asked for"
10898 );
10899 }
10900
10901 #[tokio::test]
10902 async fn fetch_ended_orders_fails_when_a_lookup_fails() {
10903 let server = MockServer::start().await;
10904 mount_lookup(&server, "a", ok(order_json("a", "SPY", "canceled", "0")), 1).await;
10905 mount_lookup(&server, "b", ResponseTemplate::new(500), 1).await;
10906
10907 let result = client_for(&server)
10908 .fetch_ended_orders(&[alpaca_key("SPY", "a"), alpaca_key("SPY", "b")])
10909 .await;
10910
10911 assert!(
10912 matches!(result, Err(UnindexedClientError::Connectivity(_))),
10913 "{result:?}"
10914 );
10915 }
10916
10917 #[tokio::test]
10920 async fn a_reconnect_check_lists_once_and_reports_the_orders_that_ended() {
10921 use wiremock::matchers::query_param;
10922
10923 let server = MockServer::start().await;
10924 Mock::given(method("GET"))
10925 .and(path("/v2/orders"))
10926 .and(query_param("status", "open"))
10927 .and(query_param("symbols", "QQQ,SPY"))
10928 .respond_with(ok(serde_json::json!([order_json(
10929 "spy-listed",
10930 "SPY",
10931 "new",
10932 "0"
10933 )])))
10934 .expect(1)
10935 .mount(&server)
10936 .await;
10937 mount_lookup(&server, "spy-listed", ResponseTemplate::new(500), 0).await;
10938 mount_lookup(
10939 &server,
10940 "spy-cancelled",
10941 ok(order_json("spy-cancelled", "SPY", "canceled", "1")),
10942 1,
10943 )
10944 .await;
10945 mount_lookup(
10946 &server,
10947 "spy-done",
10948 ok(order_json("spy-done", "SPY", "done_for_day", "1")),
10949 1,
10950 )
10951 .await;
10952 mount_lookup(
10953 &server,
10954 "qqq-filled",
10955 ok(order_json("qqq-filled", "QQQ", "filled", "2")),
10956 1,
10957 )
10958 .await;
10959
10960 let known = KnownLiveOrders::shared(ExchangeId::AlpacaBroker);
10961 let [listed, cancelled, done, filled] = [
10962 ("SPY", "spy-listed"),
10963 ("SPY", "spy-cancelled"),
10964 ("SPY", "spy-done"),
10965 ("QQQ", "qqq-filled"),
10966 ]
10967 .map(|(instrument, cid)| alpaca_key(instrument, cid));
10968 for key in [&listed, &cancelled, &done, &filled] {
10969 hold(&known, key);
10970 }
10971 let mut unchecked = UncheckedOrders::default();
10972 unchecked.open(known.lock().instruments());
10973 let config = Arc::new(AlpacaConfig::with_base_url(
10974 "test-key".into(),
10975 "test-secret".into(),
10976 server.uri(),
10977 ));
10978 let (tx, mut rx) = mpsc::unbounded_channel();
10979
10980 recover_alpaca_ended_orders(
10981 &reqwest::Client::new(),
10982 &Arc::new(RateLimitTracker::new()),
10983 &config,
10984 &known,
10985 &mut unchecked,
10986 &tx,
10987 )
10988 .await;
10989
10990 let mut reported: Vec<_> = std::iter::from_fn(|| rx.try_recv().ok())
10991 .map(|event| {
10992 let AccountEventKind::OrderSnapshot(
10993 rustrade_integration::collection::snapshot::Snapshot(order),
10994 ) = event.kind
10995 else {
10996 panic!("an order snapshot: {event:?}");
10997 };
10998 assert!(matches!(order.state, OrderState::Inactive(_)), "{order:?}");
10999 order.key.cid
11000 })
11001 .collect();
11002 reported.sort();
11003 assert_eq!(reported, [filled.cid.clone(), cancelled.cid.clone()]);
11004 let known = known.lock();
11005 assert!(known.contains(&listed.cid) && known.contains(&done.cid));
11006 assert!(!known.contains(&cancelled.cid) && !known.contains(&filled.cid));
11007 assert!(unchecked.is_empty(), "both instruments checked");
11008 }
11009
11010 #[tokio::test]
11012 async fn a_failed_listing_is_retried_for_every_instrument_in_it() {
11013 let server = MockServer::start().await;
11014 Mock::given(method("GET"))
11015 .and(path("/v2/orders"))
11016 .respond_with(ResponseTemplate::new(503))
11017 .expect(1)
11018 .mount(&server)
11019 .await;
11020
11021 let known = KnownLiveOrders::shared(ExchangeId::AlpacaBroker);
11022 hold(&known, &alpaca_key("SPY", "a"));
11023 hold(&known, &alpaca_key("QQQ", "b"));
11024 let mut unchecked = UncheckedOrders::default();
11025 unchecked.open(known.lock().instruments());
11026 let config = Arc::new(AlpacaConfig::with_base_url(
11027 "test-key".into(),
11028 "test-secret".into(),
11029 server.uri(),
11030 ));
11031 let (tx, mut rx) = mpsc::unbounded_channel();
11032
11033 recover_alpaca_ended_orders(
11034 &reqwest::Client::new(),
11035 &Arc::new(RateLimitTracker::new()),
11036 &config,
11037 &known,
11038 &mut unchecked,
11039 &tx,
11040 )
11041 .await;
11042
11043 assert!(rx.try_recv().is_err(), "nothing reported");
11044 let now = tokio::time::Instant::now();
11045 assert!(
11046 unchecked.ready(&NoPendingFills, now).is_empty(),
11047 "both wait"
11048 );
11049 for instrument in ["QQQ", "SPY"] {
11050 assert_eq!(
11051 unchecked.failed(
11052 &InstrumentNameExchange::new(instrument),
11053 now,
11054 Duration::ZERO
11055 ),
11056 Some(crate::client::order_recovery::GapFailure::Retry(
11057 Duration::from_secs(crate::client::order_recovery::GAP_RETRY_BASE_SECS * 2)
11058 )),
11059 "{instrument} has failed once already"
11060 );
11061 }
11062 assert_eq!(known.lock().instruments().len(), 2, "both still held");
11063 }
11064
11065 #[tokio::test]
11066 async fn placing_listing_and_cancelling_keep_the_held_orders_in_step() {
11067 use crate::order::request::{OrderRequestCancel, RequestCancel, RequestOpen};
11068
11069 let server = MockServer::start().await;
11070 Mock::given(method("POST"))
11071 .and(path("/v2/orders"))
11072 .respond_with(ok(order_json("resting", "SPY", "new", "0")))
11073 .up_to_n_times(1)
11074 .mount(&server)
11075 .await;
11076 Mock::given(method("POST"))
11077 .and(path("/v2/orders"))
11078 .respond_with(ok(order_json("filled", "SPY", "filled", "2")))
11079 .mount(&server)
11080 .await;
11081 Mock::given(method("GET"))
11082 .and(path("/v2/orders"))
11083 .respond_with(ok(serde_json::json!([order_json(
11084 "listed", "QQQ", "new", "0"
11085 )])))
11086 .mount(&server)
11087 .await;
11088 Mock::given(method("DELETE"))
11089 .and(path("/v2/orders/id-resting"))
11090 .respond_with(ResponseTemplate::new(204))
11091 .mount(&server)
11092 .await;
11093 let client = client_for(&server);
11094 let spy = InstrumentNameExchange::new("SPY");
11095
11096 for cid in ["resting", "filled"] {
11097 let request = OrderRequestOpen {
11098 key: OrderKey::new(
11099 ExchangeId::AlpacaBroker,
11100 &spy,
11101 StrategyId::new("strategy"),
11102 ClientOrderId::new(cid),
11103 ),
11104 state: RequestOpen {
11105 side: Side::Buy,
11106 price: Some(Decimal::ONE_HUNDRED),
11107 quantity: Decimal::TWO,
11108 kind: OrderKind::Limit,
11109 time_in_force: TimeInForce::GoodUntilCancelled { post_only: false },
11110 position_id: None,
11111 reduce_only: false,
11112 market: None,
11113 },
11114 };
11115 client.open_order(request).await;
11116 }
11117 client.fetch_open_orders(&[]).await.unwrap();
11118 {
11119 let known = client.known_live.lock();
11120 assert!(known.contains(&ClientOrderId::new("resting")));
11121 assert!(
11122 !known.contains(&ClientOrderId::new("filled")),
11123 "filled in its placement response"
11124 );
11125 assert!(known.contains(&ClientOrderId::new("listed")));
11126 }
11127
11128 let cancel = client
11129 .cancel_order(OrderRequestCancel {
11130 key: OrderKey::new(
11131 ExchangeId::AlpacaBroker,
11132 &spy,
11133 StrategyId::new("strategy"),
11134 ClientOrderId::new("resting"),
11135 ),
11136 state: RequestCancel {
11137 id: Some(VenueOrderId::Assigned(OrderId::new("id-resting"))),
11138 },
11139 })
11140 .await;
11141 let Ok(cancelled) = &cancel.state else {
11142 panic!("expected Ok, got {cancel:?}");
11143 };
11144 assert_eq!(
11145 cancelled.filled_quantity, None,
11146 "the DELETE response has no body, so what filled is unknown"
11147 );
11148 assert!(
11149 client
11150 .known_live
11151 .lock()
11152 .contains(&ClientOrderId::new("resting")),
11153 "a cancel Alpaca has only accepted has not ended the order"
11154 );
11155 }
11156
11157 #[tokio::test]
11164 async fn an_order_that_ended_in_its_placement_response_is_reported_as_it_ended() {
11165 use crate::order::request::RequestOpen;
11166
11167 let Some(time) = parse_timestamp("2026-10-01T15:00:00Z") else {
11168 panic!("order_json's updated_at parses");
11169 };
11170 let id = |cid: &str| OrderId::new(format!("id-{cid}"));
11171 let open = |cid: &str, filled| {
11172 OrderState::active(Open::new(VenueOrderId::Assigned(id(cid)), time, filled))
11173 };
11174 let cases: [(&str, &str, &str, UnindexedOrderState, bool); 13] = [
11175 (
11176 "ioc-unfilled",
11177 "expired",
11178 "0",
11179 OrderState::expired(Expired::new(
11180 id("ioc-unfilled"),
11181 time,
11182 Some(Decimal::ZERO),
11183 )),
11184 false,
11185 ),
11186 (
11187 "ioc-partial",
11188 "expired",
11189 "1",
11190 OrderState::expired(Expired::new(id("ioc-partial"), time, Some(Decimal::ONE))),
11191 false,
11192 ),
11193 (
11194 "fill-unknown",
11195 "expired",
11196 "not-a-number",
11197 OrderState::expired(Expired::new(id("fill-unknown"), time, None)),
11198 false,
11199 ),
11200 (
11201 "cancelled",
11202 "canceled",
11203 "1",
11204 OrderState::inactive(Cancelled::new(id("cancelled"), time, Some(Decimal::ONE))),
11205 false,
11206 ),
11207 (
11208 "rejected",
11209 "rejected",
11210 "0",
11211 OrderState::inactive(OrderError::Rejected(ApiError::OrderRejected(
11212 "Alpaca rejected order id-rejected after accepting it".to_string(),
11213 ))),
11214 false,
11215 ),
11216 (
11217 "filled",
11218 "filled",
11219 "2",
11220 OrderState::fully_filled(Filled::new(
11221 id("filled"),
11222 time,
11223 Decimal::TWO,
11224 Some(Decimal::new(1015, 1)),
11225 )),
11226 false,
11227 ),
11228 (
11229 "replaced",
11230 "replaced",
11231 "0",
11232 OrderState::inactive(Cancelled::new(id("replaced"), time, Some(Decimal::ZERO))),
11233 false,
11234 ),
11235 (
11236 "live-covered",
11237 "partially_filled",
11238 "2",
11239 OrderState::fully_filled(Filled::new(
11240 id("live-covered"),
11241 time,
11242 Decimal::TWO,
11243 Some(Decimal::new(1015, 1)),
11244 )),
11245 false,
11246 ),
11247 ("new", "new", "0", open("new", Decimal::ZERO), true),
11248 (
11249 "live-fill-unknown",
11250 "new",
11251 "not-a-number",
11252 open("live-fill-unknown", Decimal::ZERO),
11253 true,
11254 ),
11255 (
11256 "partial",
11257 "partially_filled",
11258 "1",
11259 open("partial", Decimal::ONE),
11260 true,
11261 ),
11262 (
11263 "for-the-day",
11264 "done_for_day",
11265 "1",
11266 open("for-the-day", Decimal::ONE),
11267 true,
11268 ),
11269 (
11270 "unknown",
11271 "a_status_not_yet_documented",
11272 "0",
11273 open("unknown", Decimal::ZERO),
11274 true,
11275 ),
11276 ];
11277
11278 for (cid, status, filled, expected, held) in cases {
11279 for bracket in [false, true] {
11280 let server = MockServer::start().await;
11281 Mock::given(method("POST"))
11282 .and(path("/v2/orders"))
11283 .respond_with(ok(order_json(cid, "SPY", status, filled)))
11284 .expect(1)
11285 .mount(&server)
11286 .await;
11287 let client = client_for(&server);
11288 let spy = InstrumentNameExchange::new("SPY");
11289 let time_in_force = TimeInForce::GoodUntilCancelled { post_only: false };
11290
11291 let state = if bracket {
11292 client
11293 .open_bracket_order(AlpacaBracketOrderRequest::new(
11294 spy,
11295 StrategyId::new("strategy"),
11296 ClientOrderId::new(cid),
11297 Side::Buy,
11298 Decimal::TWO,
11299 Decimal::ONE_HUNDRED,
11300 Decimal::new(120, 0),
11301 Decimal::new(90, 0),
11302 time_in_force,
11303 ))
11304 .await
11305 .parent
11306 .state
11307 } else {
11308 let request = OrderRequestOpen {
11309 key: OrderKey::new(
11310 ExchangeId::AlpacaBroker,
11311 &spy,
11312 StrategyId::new("strategy"),
11313 ClientOrderId::new(cid),
11314 ),
11315 state: RequestOpen {
11316 side: Side::Buy,
11317 price: Some(Decimal::ONE_HUNDRED),
11318 quantity: Decimal::TWO,
11319 kind: OrderKind::Limit,
11320 time_in_force,
11321 position_id: None,
11322 reduce_only: false,
11323 market: None,
11324 },
11325 };
11326 client.open_order(request).await.state
11327 };
11328
11329 assert_eq!(state, expected, "{status} {filled}, bracket: {bracket}");
11330 assert_eq!(
11331 client.known_live.lock().contains(&ClientOrderId::new(cid)),
11332 held,
11333 "{status} {filled}, bracket: {bracket}"
11334 );
11335 }
11336 }
11337 }
11338
11339 #[test]
11342 fn the_stream_keeps_the_held_orders_in_step() {
11343 let (tx, mut rx) = mpsc::unbounded_channel();
11344 let dedup = new_dedup_cache();
11345 let known = KnownLiveOrders::shared(ExchangeId::AlpacaBroker);
11346 let frame = |event: &str, filled: &str| {
11347 format!(
11348 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}"}}}}}}"#
11349 )
11350 };
11351 let cid = ClientOrderId::new("a");
11352
11353 process_ws_text(&frame("new", "0"), &tx, &dedup, &known);
11354 assert!(known.lock().contains(&cid), "acknowledged");
11355
11356 process_ws_text(&frame("done_for_day", "1"), &tx, &dedup, &known);
11357 assert!(known.lock().contains(&cid), "done for the day is not ended");
11358
11359 process_ws_text(&frame("canceled", "1"), &tx, &dedup, &known);
11360 assert!(!known.lock().contains(&cid), "cancelled");
11361
11362 let events: Vec<_> = std::iter::from_fn(|| rx.try_recv().ok()).collect();
11363 let [_, done, cancelled] = events.as_slice() else {
11364 panic!("three events: {events:?}");
11365 };
11366 let AccountEventKind::OrderSnapshot(
11367 rustrade_integration::collection::snapshot::Snapshot(order),
11368 ) = &done.kind
11369 else {
11370 panic!("done_for_day reports an order snapshot: {done:?}");
11371 };
11372 let OrderState::Active(ActiveOrderState::Open(open)) = &order.state else {
11373 panic!("open: {order:?}");
11374 };
11375 assert_eq!(open.filled_quantity, Decimal::ONE, "with what it filled");
11376 assert!(matches!(
11377 cancelled.kind,
11378 AccountEventKind::OrderCancelled(_)
11379 ));
11380 }
11381
11382 #[tokio::test]
11385 async fn a_recovered_fill_that_completes_an_order_stops_it_being_held() {
11386 let known = KnownLiveOrders::shared(ExchangeId::AlpacaBroker);
11387 let key = alpaca_key("SPY", "a");
11388 known.lock().live(
11389 &key,
11390 Decimal::TWO,
11391 OrderKind::Limit,
11392 &Open::new(
11393 VenueOrderId::Assigned(OrderId::new("ord-1")),
11394 Utc::now(),
11395 Decimal::ZERO,
11396 ),
11397 );
11398
11399 drive_recover_fills_with(
11400 vec![activity_json("act-1", "ord-1", "1", Some("1"))],
11401 new_dedup_cache(),
11402 &known,
11403 )
11404 .await;
11405 assert!(known.lock().contains(&key.cid), "half filled");
11406
11407 drive_recover_fills_with(
11408 vec![activity_json("act-2", "ord-1", "1", Some("2"))],
11409 new_dedup_cache(),
11410 &known,
11411 )
11412 .await;
11413 assert!(!known.lock().contains(&key.cid), "filled");
11414 }
11415
11416 #[tokio::test]
11419 async fn the_check_loop_runs_at_once_then_waits_out_a_failure() {
11420 let server = MockServer::start().await;
11421 Mock::given(method("GET"))
11422 .and(path("/v2/orders"))
11423 .respond_with(ResponseTemplate::new(503))
11424 .expect(1)
11425 .mount(&server)
11426 .await;
11427 let known = KnownLiveOrders::shared(ExchangeId::AlpacaBroker);
11428 hold(&known, &alpaca_key("SPY", "a"));
11429 let mut unchecked = UncheckedOrders::default();
11430 unchecked.open(known.lock().instruments());
11431 let config = Arc::new(AlpacaConfig::with_base_url(
11432 "test-key".into(),
11433 "test-secret".into(),
11434 server.uri(),
11435 ));
11436 let (tx, _rx) = mpsc::unbounded_channel();
11437
11438 let ran = tokio::time::timeout(
11439 Duration::from_secs(1),
11440 run_order_checks(
11441 &reqwest::Client::new(),
11442 &Arc::new(RateLimitTracker::new()),
11443 &config,
11444 &known,
11445 &mut unchecked,
11446 &tx,
11447 ),
11448 )
11449 .await;
11450
11451 assert!(ran.is_err(), "the loop never completes");
11452 assert!(
11453 unchecked.contains(&InstrumentNameExchange::new("SPY")),
11454 "SPY waits for its retry"
11455 );
11456 assert_eq!(
11457 server.received_requests().await.unwrap().len(),
11458 1,
11459 "the failed check is not run again at once"
11460 );
11461 }
11462
11463 #[tokio::test]
11465 async fn the_check_loop_waits_once_the_consumer_has_gone() {
11466 let server = MockServer::start().await;
11467 Mock::given(method("GET"))
11468 .respond_with(ResponseTemplate::new(503))
11469 .expect(0)
11470 .mount(&server)
11471 .await;
11472 let known = KnownLiveOrders::shared(ExchangeId::AlpacaBroker);
11473 hold(&known, &alpaca_key("SPY", "a"));
11474 let mut unchecked = UncheckedOrders::default();
11475 unchecked.open(known.lock().instruments());
11476 let config = Arc::new(AlpacaConfig::with_base_url(
11477 "test-key".into(),
11478 "test-secret".into(),
11479 server.uri(),
11480 ));
11481 let (tx, rx) = mpsc::unbounded_channel();
11482 drop(rx);
11483
11484 let ran = tokio::time::timeout(
11485 Duration::from_millis(200),
11486 run_order_checks(
11487 &reqwest::Client::new(),
11488 &Arc::new(RateLimitTracker::new()),
11489 &config,
11490 &known,
11491 &mut unchecked,
11492 &tx,
11493 ),
11494 )
11495 .await;
11496
11497 assert!(ran.is_err(), "the loop never completes");
11498 assert!(
11499 server.received_requests().await.unwrap().is_empty(),
11500 "nothing asked once the consumer has gone"
11501 );
11502 }
11503
11504 async fn shortability_with_asset(
11505 symbol: &str,
11506 asset: serde_json::Value,
11507 ) -> (Result<Shortability, UnindexedClientError>, Vec<String>) {
11508 use crate::client::ShortabilityClient;
11509
11510 let server = MockServer::start().await;
11511 Mock::given(method("GET"))
11512 .respond_with(ResponseTemplate::new(200).set_body_json(asset))
11513 .mount(&server)
11514 .await;
11515 let result = client_for(&server)
11516 .fetch_shortability(&InstrumentNameExchange::new(symbol))
11517 .await;
11518 let paths = server
11519 .received_requests()
11520 .await
11521 .unwrap()
11522 .iter()
11523 .map(|request| request.url.path().to_owned())
11524 .collect();
11525 (result, paths)
11526 }
11527
11528 #[tokio::test]
11529 async fn an_easy_to_borrow_stock_is_shortable_at_no_fee() {
11530 let (result, paths) = shortability_with_asset(
11531 "AAPL",
11532 serde_json::json!({
11533 "class": "us_equity",
11534 "symbol": "AAPL",
11535 "shortable": true,
11536 "easy_to_borrow": true,
11537 "borrow_status": "easy_to_borrow",
11538 }),
11539 )
11540 .await;
11541
11542 assert_eq!(
11543 result.unwrap(),
11544 Shortability::new()
11545 .with_shortable(true)
11546 .with_easy_to_borrow(true)
11547 .with_fee_rate(Decimal::ZERO)
11548 );
11549 assert_eq!(paths, vec!["/v2/assets/AAPL"]);
11550 }
11551
11552 #[tokio::test]
11553 async fn a_hard_to_borrow_stock_has_no_known_fee() {
11554 let (result, _) = shortability_with_asset(
11555 "GME",
11556 serde_json::json!({
11557 "class": "us_equity",
11558 "shortable": true,
11559 "easy_to_borrow": true,
11560 "borrow_status": "hard_to_borrow",
11561 }),
11562 )
11563 .await;
11564
11565 assert_eq!(
11566 result.unwrap(),
11567 Shortability::new()
11568 .with_shortable(true)
11569 .with_easy_to_borrow(false),
11570 "borrow_status wins over the easy_to_borrow flag, and a hard-to-borrow fee is unknown"
11571 );
11572 }
11573
11574 #[tokio::test]
11575 async fn without_a_borrow_status_the_easy_to_borrow_flag_is_read() {
11576 let (result, _) = shortability_with_asset(
11577 "AAPL",
11578 serde_json::json!({ "class": "us_equity", "shortable": false, "easy_to_borrow": false }),
11579 )
11580 .await;
11581
11582 assert_eq!(
11583 result.unwrap(),
11584 Shortability::new()
11585 .with_shortable(false)
11586 .with_easy_to_borrow(false)
11587 );
11588 }
11589
11590 #[tokio::test]
11591 async fn an_asset_without_borrow_fields_is_unknown_but_for_shortable() {
11592 let (result, paths) = shortability_with_asset(
11593 "BTC/USD",
11594 serde_json::json!({ "class": "crypto", "symbol": "BTC/USD", "shortable": false }),
11595 )
11596 .await;
11597
11598 assert_eq!(result.unwrap(), Shortability::new().with_shortable(false));
11599 assert_eq!(
11600 paths,
11601 vec!["/v2/assets/BTC%2FUSD"],
11602 "a crypto pair's slash is escaped, not read as a path separator"
11603 );
11604 }
11605
11606 #[tokio::test]
11607 async fn an_option_has_no_shortability() {
11608 let (result, _) = shortability_with_asset(
11609 "AAPL250117C00150000",
11610 serde_json::json!({ "class": "us_option", "shortable": true }),
11611 )
11612 .await;
11613
11614 assert!(
11615 matches!(
11616 result,
11617 Err(UnindexedClientError::Api(ApiError::InstrumentInvalid(ref instrument, ref reason)))
11618 if instrument.name().as_str() == "AAPL250117C00150000"
11619 && reason.contains("option")
11620 ),
11621 "{result:?}"
11622 );
11623 }
11624
11625 #[tokio::test]
11626 async fn an_unknown_symbol_is_an_invalid_instrument() {
11627 use crate::client::ShortabilityClient;
11628
11629 let server = MockServer::start().await;
11630 Mock::given(method("GET"))
11631 .and(path("/v2/assets/NOPE"))
11632 .respond_with(
11633 ResponseTemplate::new(404).set_body_json(
11634 serde_json::json!({ "message": "asset not found for NOPE" }),
11635 ),
11636 )
11637 .mount(&server)
11638 .await;
11639
11640 let result = client_for(&server)
11641 .fetch_shortability(&InstrumentNameExchange::new("NOPE"))
11642 .await;
11643
11644 assert!(
11645 matches!(
11646 result,
11647 Err(UnindexedClientError::Api(ApiError::InstrumentInvalid(ref instrument, _)))
11648 if instrument.name().as_str() == "NOPE"
11649 ),
11650 "{result:?}"
11651 );
11652 }
11653
11654 struct RateLimited {
11657 call: std::sync::atomic::AtomicU32,
11658 last: Option<(&'static str, String)>,
11659 }
11660
11661 impl RateLimited {
11662 fn mount(last: Option<(&'static str, String)>) -> Self {
11663 Self {
11664 call: std::sync::atomic::AtomicU32::new(0),
11665 last,
11666 }
11667 }
11668 }
11669
11670 impl Respond for RateLimited {
11671 fn respond(&self, _: &Request) -> ResponseTemplate {
11672 let call = self.call.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1;
11673 let response = ResponseTemplate::new(429)
11674 .set_body_json(serde_json::json!({"message": "too many requests"}));
11675 if call < MAX_RATE_LIMIT_ATTEMPTS {
11676 return response.insert_header("retry-after", "0");
11677 }
11678 match &self.last {
11679 Some((name, value)) => response.insert_header(*name, value.as_str()),
11680 None => response,
11681 }
11682 }
11683 }
11684
11685 fn unix_now() -> u64 {
11686 std::time::SystemTime::now()
11687 .duration_since(std::time::UNIX_EPOCH)
11688 .unwrap()
11689 .as_secs()
11690 }
11691
11692 #[tokio::test]
11693 async fn exhausted_429_retries_report_alpacas_wait() {
11694 let server = MockServer::start().await;
11695 Mock::given(method("GET"))
11696 .and(path("/v2/account"))
11697 .respond_with(RateLimited::mount(Some(("retry-after", "7".to_string()))))
11698 .mount(&server)
11699 .await;
11700
11701 let http = reqwest::Client::new();
11702 let rl = RateLimitTracker::new();
11703 let url = format!("{}/v2/account", server.uri());
11704 let error = rest_with_retry::<serde_json::Value>(&rl, || http.get(&url))
11705 .await
11706 .unwrap_err();
11707
11708 let UnindexedClientError::Api(error) = error else {
11709 panic!("expected Api, got {error:?}");
11710 };
11711 assert_eq!(error.retry_after(), Some(Duration::from_secs(7)));
11712 let blocked_until = rl
11713 .blocked_until
11714 .lock()
11715 .expect("the last refusal pauses the client");
11716 assert!(
11717 blocked_until >= tokio::time::Instant::now() + Duration::from_secs(6),
11718 "the client's next request waits for Alpaca's wait"
11719 );
11720 assert_eq!(
11721 server.received_requests().await.unwrap().len(),
11722 MAX_RATE_LIMIT_ATTEMPTS as usize
11723 );
11724 }
11725
11726 #[tokio::test]
11727 async fn exhausted_429_retries_read_the_reset_time_without_retry_after() {
11728 let server = MockServer::start().await;
11729 Mock::given(method("GET"))
11730 .and(path("/v2/account"))
11731 .respond_with(RateLimited::mount(Some((
11732 "x-ratelimit-reset",
11733 (unix_now() + 30).to_string(),
11734 ))))
11735 .mount(&server)
11736 .await;
11737
11738 let http = reqwest::Client::new();
11739 let rl = RateLimitTracker::new();
11740 let url = format!("{}/v2/account", server.uri());
11741 let error = rest_with_retry::<serde_json::Value>(&rl, || http.get(&url))
11742 .await
11743 .unwrap_err();
11744
11745 let UnindexedClientError::Api(ApiError::RateLimit {
11746 retry_after: Some(wait),
11747 ..
11748 }) = error
11749 else {
11750 panic!("expected a RateLimit with a wait, got {error:?}");
11751 };
11752 assert!(
11753 (Duration::from_secs(29)..=Duration::from_secs(30)).contains(&wait),
11754 "{wait:?}"
11755 );
11756 }
11757
11758 #[tokio::test]
11759 async fn exhausted_429_retries_without_a_hint_report_none() {
11760 let server = MockServer::start().await;
11761 Mock::given(method("GET"))
11762 .and(path("/v2/account"))
11763 .respond_with(RateLimited::mount(None))
11764 .mount(&server)
11765 .await;
11766
11767 let http = reqwest::Client::new();
11768 let rl = RateLimitTracker::new();
11769 let url = format!("{}/v2/account", server.uri());
11770 let error = rest_with_retry::<serde_json::Value>(&rl, || http.get(&url))
11771 .await
11772 .unwrap_err();
11773
11774 assert!(
11775 matches!(
11776 error,
11777 UnindexedClientError::Api(ApiError::RateLimit {
11778 retry_after: None,
11779 ..
11780 })
11781 ),
11782 "{error:?}"
11783 );
11784 }
11785
11786 #[tokio::test]
11787 async fn an_exhausted_cancel_reports_alpacas_wait() {
11788 let server = MockServer::start().await;
11789 Mock::given(method("DELETE"))
11790 .and(path("/v2/orders/abc"))
11791 .respond_with(RateLimited::mount(Some(("retry-after", "5".to_string()))))
11792 .mount(&server)
11793 .await;
11794
11795 let http = reqwest::Client::new();
11796 let rl = RateLimitTracker::new();
11797 let url = format!("{}/v2/orders/abc", server.uri());
11798 let error = rest_delete_with_retry(&rl, || http.delete(&url))
11799 .await
11800 .unwrap_err();
11801
11802 let UnindexedOrderError::Rejected(error) = error else {
11803 panic!("expected Rejected, got {error:?}");
11804 };
11805 assert_eq!(error.retry_after(), Some(Duration::from_secs(5)));
11806 assert!(
11807 rl.blocked_until.lock().is_some(),
11808 "the last refusal pauses the client"
11809 );
11810 }
11811 }
11812}