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 reconnect_budget::{BackoffLimits, ReconnectBudget},
57 },
58 emit_stream_terminated,
59 error::{
60 ApiError, BorrowReject, BorrowRejectReason, ConnectivityError, OrderError,
61 StreamTerminationReason, UnindexedApiError, UnindexedClientError, UnindexedOrderError,
62 },
63 order::{
64 Order, OrderKey, OrderKind, TimeInForce, TrailingOffsetType, UnindexedInactiveOrder,
65 UnindexedOrderKey, UnindexedOrderSnapshot,
66 bracket::{
67 BracketOrderRequest as UnifiedBracketOrderRequest,
68 BracketOrderResult as UnifiedBracketOrderResult,
69 },
70 id::{ClientOrderId, OrderId, StrategyId, VenueOrderId},
71 request::{OrderRequestCancel, OrderRequestOpen, UnindexedOrderResponseCancel},
72 state::{
73 ActiveOrderState, Cancelled, Expired, Filled, InactiveOrderState, Open, OrderState,
74 UnindexedOrderState,
75 },
76 },
77 parse_env_bool,
78 position::{Position, PositionReport},
79 shortability::Shortability,
80 trade::{AssetFees, Trade, TradeAmendmentKind, TradeId, TradeOrigin, TradesRead},
81};
82use chrono::{DateTime, NaiveDate, NaiveTime, SecondsFormat, TimeDelta, Utc};
83use fnv::{FnvHashMap, FnvHashSet};
84use futures::{SinkExt as _, StreamExt as _, stream::BoxStream};
85use indexmap::IndexMap;
86use itertools::Itertools as _;
87use lru::LruCache;
88use rust_decimal::Decimal;
89use rustrade_instrument::{
90 Side,
91 asset::name::AssetNameExchange,
92 exchange::ExchangeId,
93 instrument::{kind::InstrumentKindDiscriminant, name::InstrumentNameExchange},
94};
95use rustrade_integration::protocol::websocket::{WebSocket, WsMessage};
96use serde::{Deserialize, Serialize, de::DeserializeOwned};
97use smol_str::{SmolStr, format_smolstr};
98use std::{num::NonZeroUsize, pin::Pin, str::FromStr, sync::Arc, time::Duration};
99use tokio::sync::mpsc;
100use tracing::{debug, error, info, trace, warn};
101
102const HEARTBEAT_TIMEOUT_SECS: u64 = 35;
108const FILL_RECOVERY_TIMEOUT_SECS: u64 = 30;
110const SIGNAL_RECOVERY_LOOKBACK_MS: i64 = 1_500;
113const ALPACA_MAX_ACTIVITIES: usize = 100;
115const DEFAULT_RATE_LIMIT_DELAY_SECS: u64 = 60;
118const MAX_RATE_LIMIT_ATTEMPTS: u32 = 4;
121const DEDUP_CACHE_SIZE: usize = 4_000;
125
126const USD: &str = "USD";
129const WS_HANDSHAKE_TIMEOUT_SECS: u64 = 15;
131const WS_CLOSE_TIMEOUT_SECS: u64 = 5;
134
135struct GracefulShutdownStream<S> {
152 inner: S,
153 _handle: tokio::task::JoinHandle<()>,
159}
160
161impl<S> GracefulShutdownStream<S> {
162 fn new(inner: S, handle: tokio::task::JoinHandle<()>) -> Self {
163 Self {
164 inner,
165 _handle: handle,
166 }
167 }
168}
169
170impl<S: futures::Stream + Unpin> futures::Stream for GracefulShutdownStream<S> {
171 type Item = S::Item;
172 fn poll_next(
173 mut self: Pin<&mut Self>,
174 cx: &mut std::task::Context<'_>,
175 ) -> std::task::Poll<Option<Self::Item>> {
176 Pin::new(&mut self.inner).poll_next(cx)
177 }
178 fn size_hint(&self) -> (usize, Option<usize>) {
179 self.inner.size_hint()
180 }
181}
182
183impl<S> Drop for GracefulShutdownStream<S> {
184 fn drop(&mut self) {
185 }
189}
190
191struct RateLimitTracker {
197 blocked_until: parking_lot::Mutex<Option<tokio::time::Instant>>,
198}
199
200impl RateLimitTracker {
201 fn new() -> Self {
202 Self {
203 blocked_until: parking_lot::Mutex::new(None),
204 }
205 }
206
207 async fn wait_if_blocked(&self) {
209 loop {
210 let now = tokio::time::Instant::now();
214 let deadline = {
222 let mut guard = self.blocked_until.lock();
223 let d = *guard;
224 if matches!(d, Some(t) if t <= now) {
225 *guard = None;
228 }
229 d
230 };
231 match deadline {
232 None => return,
233 Some(until) => {
234 if until <= now {
235 return;
237 }
238 #[allow(clippy::cast_possible_truncation)]
240 let delay_ms = (until - now).as_millis() as u64;
241 debug!(delay_ms, "Alpaca REST rate-limited, waiting before request");
242 tokio::time::sleep_until(until).await;
243 }
244 }
245 }
246 }
247
248 fn on_rate_limited(&self, retry_after: Option<Duration>) {
250 let delay = retry_after.unwrap_or(Duration::from_secs(DEFAULT_RATE_LIMIT_DELAY_SECS));
251 let was_blocked = self.extend(delay);
252 if was_blocked {
253 debug!(
254 delay_secs = delay.as_secs(),
255 "Alpaca rate-limit cooldown extended"
256 );
257 } else {
258 warn!(
259 delay_secs = delay.as_secs(),
260 "Alpaca entering rate-limit degradation mode"
261 );
262 }
263 }
264
265 fn on_bucket_exhausted(&self, reset: Duration) {
268 let was_blocked = self.extend(reset);
269 if !was_blocked {
271 info!(
272 delay_ms = u64::try_from(reset.as_millis()).unwrap_or(u64::MAX),
273 "Alpaca rate-limit window exhausted (X-Ratelimit-Remaining: 0), pausing requests until it resets"
274 );
275 }
276 }
277
278 fn extend(&self, delay: Duration) -> bool {
281 let now = tokio::time::Instant::now();
282 let new_deadline = now + delay;
283 let mut guard = self.blocked_until.lock();
284 let was_blocked = guard.is_some_and(|until| until > now);
285 *guard = Some(guard.map_or(new_deadline, |existing| existing.max(new_deadline)));
286 was_blocked
287 }
288}
289
290type SharedDedupCache = Arc<parking_lot::Mutex<LruCache<FillKey, ()>>>;
298
299#[derive(Debug, Clone, PartialEq, Eq, Hash)]
304enum FillKey {
305 Execution(SmolStr),
308 Cumulative(SmolStr),
310 CumulativeWithoutId(SmolStr),
312}
313
314fn new_dedup_cache() -> SharedDedupCache {
315 #[allow(clippy::unwrap_used)]
318 Arc::new(parking_lot::Mutex::new(LruCache::new(
319 NonZeroUsize::new(DEDUP_CACHE_SIZE).unwrap(),
320 )))
321}
322
323fn is_duplicate_fill(
343 cache: &SharedDedupCache,
344 execution_id: Option<&str>,
345 cumulative: SmolStr,
346) -> bool {
347 let execution = execution_id.map(|id| FillKey::Execution(SmolStr::new(id)));
349 let mut guard = cache.lock();
350 match execution {
352 Some(execution) => {
353 if guard.peek(&execution).is_some()
354 || guard
355 .peek(&FillKey::CumulativeWithoutId(cumulative.clone()))
356 .is_some()
357 {
358 return true;
359 }
360 guard.put(execution, ());
361 guard.put(FillKey::Cumulative(cumulative), ());
362 }
363 None => {
364 if guard
365 .peek(&FillKey::Cumulative(cumulative.clone()))
366 .is_some()
367 {
368 return true;
369 }
370 guard.put(FillKey::Cumulative(cumulative.clone()), ());
371 guard.put(FillKey::CumulativeWithoutId(cumulative), ());
372 }
373 }
374 false
375}
376
377fn record_execution(cache: &SharedDedupCache, execution_id: &str) {
380 let execution = FillKey::Execution(SmolStr::new(execution_id));
381 cache.lock().put(execution, ());
382}
383
384#[derive(Clone, Deserialize)]
391#[serde(deny_unknown_fields)]
392pub struct AlpacaConfig {
393 api_key: String,
395 secret_key: String,
396
397 #[serde(default = "default_paper")]
399 pub paper: bool,
400 #[cfg(test)]
402 #[serde(skip)]
403 base_url_override: Option<String>,
404 #[cfg(test)]
406 #[serde(skip)]
407 ws_url_override: Option<String>,
408}
409
410fn default_paper() -> bool {
416 true
417}
418
419impl std::fmt::Debug for AlpacaConfig {
421 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
422 f.debug_struct("AlpacaConfig")
423 .field("api_key", &"***")
424 .field("secret_key", &"***")
425 .field("paper", &self.paper)
426 .finish()
427 }
428}
429
430impl AlpacaConfig {
431 pub fn new(api_key: String, secret_key: String) -> Self {
434 Self::paper(api_key, secret_key)
435 }
436
437 pub fn paper(api_key: String, secret_key: String) -> Self {
439 Self {
440 api_key,
441 secret_key,
442 paper: true,
443 #[cfg(test)]
444 base_url_override: None,
445 #[cfg(test)]
446 ws_url_override: None,
447 }
448 }
449
450 pub fn production(api_key: String, secret_key: String) -> Self {
455 Self {
456 api_key,
457 secret_key,
458 paper: false,
459 #[cfg(test)]
460 base_url_override: None,
461 #[cfg(test)]
462 ws_url_override: None,
463 }
464 }
465
466 pub fn from_env() -> Result<Self, AlpacaConfigError> {
484 let api_key = match std::env::var("ALPACA_API_KEY") {
485 Ok(value) => value,
486 Err(std::env::VarError::NotPresent) => return Err(AlpacaConfigError::MissingApiKey),
487 Err(std::env::VarError::NotUnicode(_)) => {
488 return Err(AlpacaConfigError::InvalidApiKey);
489 }
490 };
491 let secret_key = match std::env::var("ALPACA_SECRET_KEY") {
492 Ok(value) => value,
493 Err(std::env::VarError::NotPresent) => return Err(AlpacaConfigError::MissingSecretKey),
494 Err(std::env::VarError::NotUnicode(_)) => {
495 return Err(AlpacaConfigError::InvalidSecretKey);
496 }
497 };
498
499 let paper = match std::env::var("ALPACA_PAPER") {
500 Ok(value) => parse_env_bool(&value).ok_or(AlpacaConfigError::InvalidPaper(value))?,
501 Err(std::env::VarError::NotPresent) => true,
502 Err(std::env::VarError::NotUnicode(value)) => {
505 return Err(AlpacaConfigError::InvalidPaper(
506 value.to_string_lossy().into_owned(),
507 ));
508 }
509 };
510
511 if paper {
512 Ok(Self::paper(api_key, secret_key))
513 } else {
514 Ok(Self::production(api_key, secret_key))
515 }
516 }
517
518 #[cfg(test)]
520 pub fn with_base_url(api_key: String, secret_key: String, base_url: String) -> Self {
521 Self {
522 api_key,
523 secret_key,
524 paper: true,
525 base_url_override: Some(base_url),
526 ws_url_override: None,
527 }
528 }
529
530 pub fn api_key(&self) -> &str {
531 &self.api_key
532 }
533
534 pub fn rest_base_url(&self) -> &str {
538 #[cfg(test)]
539 if let Some(ref url) = self.base_url_override {
540 return url.as_str();
541 }
542 if self.paper {
543 "https://paper-api.alpaca.markets"
544 } else {
545 "https://api.alpaca.markets"
546 }
547 }
548
549 pub fn ws_url(&self) -> &'static str {
551 if self.paper {
552 "wss://paper-api.alpaca.markets/stream"
553 } else {
554 "wss://api.alpaca.markets/stream"
555 }
556 }
557
558 fn stream_url(&self) -> &str {
561 #[cfg(test)]
562 if let Some(ref url) = self.ws_url_override {
563 return url.as_str();
564 }
565 self.ws_url()
566 }
567}
568
569#[derive(Debug, PartialEq, thiserror::Error)]
570pub enum AlpacaConfigError {
571 #[error("ALPACA_API_KEY environment variable not set")]
572 MissingApiKey,
573
574 #[error("ALPACA_API_KEY environment variable is not valid UTF-8")]
577 InvalidApiKey,
578
579 #[error("ALPACA_SECRET_KEY environment variable not set")]
580 MissingSecretKey,
581
582 #[error("ALPACA_SECRET_KEY environment variable is not valid UTF-8")]
583 InvalidSecretKey,
584
585 #[error("ALPACA_PAPER must be true or false, got {0}")]
586 InvalidPaper(String),
587}
588
589#[derive(Debug, Deserialize)]
598struct AlpacaAccount {
599 #[serde(with = "rust_decimal::serde::str")]
602 cash: Decimal,
603 #[serde(with = "rust_decimal::serde::str")]
607 non_marginable_buying_power: Decimal,
608}
609
610#[derive(Debug, Deserialize)]
615struct AlpacaPosition {
616 symbol: String,
619 asset_class: String,
621 #[serde(default)]
625 side: AlpacaPositionSide,
626 #[serde(with = "rust_decimal::serde::str")]
628 qty: Decimal,
629 #[serde(with = "rust_decimal::serde::str")]
631 qty_available: Decimal,
632 #[serde(default, with = "rust_decimal::serde::str_option")]
634 avg_entry_price: Option<Decimal>,
635 #[serde(default, with = "rust_decimal::serde::str_option")]
637 unrealized_pl: Option<Decimal>,
638}
639
640#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize)]
642#[serde(rename_all = "lowercase")]
643enum AlpacaPositionSide {
644 Long,
645 Short,
646 #[default]
650 #[serde(other)]
651 Unknown,
652}
653
654#[derive(Debug, Deserialize)]
655struct AlpacaOrderResponse {
656 id: String,
657 client_order_id: Option<String>,
658 symbol: String,
659 qty: Option<String>,
660 filled_qty: String,
661 #[serde(default)]
663 filled_avg_price: Option<String>,
664 #[serde(default)]
668 status: Option<String>,
669 side: String,
670 #[serde(rename = "type")]
671 order_type: String,
672 time_in_force: String,
673 limit_price: Option<String>,
674 stop_price: Option<String>,
675 trail_percent: Option<String>,
676 trail_price: Option<String>,
677 created_at: String,
678 updated_at: Option<String>,
681}
682
683#[derive(Debug, Deserialize)]
684struct AlpacaActivity {
685 id: String,
686 order_id: String,
687 symbol: String,
688 side: String,
689 price: String,
690 qty: String,
691 transaction_time: String,
692 cum_qty: Option<String>,
700}
701
702#[derive(Debug, Deserialize)]
709struct AlpacaNonTradeActivity {
710 id: String,
711 activity_type: String,
712 #[serde(default)]
713 activity_sub_type: Option<String>,
714 #[serde(default)]
717 created_at: Option<String>,
718 #[serde(default)]
722 date: Option<String>,
723 #[serde(default)]
725 net_amount: Option<String>,
726 #[serde(default)]
729 qty: Option<String>,
730 #[serde(default)]
732 price: Option<String>,
733 #[serde(default)]
734 currency: Option<String>,
735 #[serde(default)]
736 symbol: Option<String>,
737 #[serde(default)]
739 status: Option<String>,
740}
741
742#[derive(Debug, Deserialize)]
747struct AlpacaApiError {
748 code: Option<i64>,
749 message: String,
750}
751
752impl AlpacaApiError {
753 fn from_body(bytes: &[u8]) -> Self {
755 serde_json::from_slice(bytes).unwrap_or_else(|_| Self {
756 code: None,
757 message: String::from_utf8_lossy(bytes).into_owned(),
758 })
759 }
760}
761
762#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
774#[serde(rename_all = "snake_case")]
775pub enum AlpacaPositionIntent {
776 BuyToOpen,
777 BuyToClose,
778 SellToOpen,
779 SellToClose,
780}
781
782#[derive(Debug, Serialize)]
790struct TakeProfitParams {
791 limit_price: String,
792}
793
794#[derive(Debug, Serialize)]
799struct StopLossParams {
800 stop_price: String,
801 #[serde(skip_serializing_if = "Option::is_none")]
802 limit_price: Option<String>,
803}
804
805#[non_exhaustive]
840#[derive(Debug, Clone)]
841pub struct AlpacaBracketOrderRequest {
842 pub instrument: InstrumentNameExchange,
844 pub strategy: StrategyId,
846 pub cid: ClientOrderId,
848 pub side: Side,
850 pub quantity: Decimal,
852 pub entry_price: Decimal,
854 pub take_profit_price: Decimal,
856 pub stop_loss_price: Decimal,
858 pub stop_loss_limit_price: Option<Decimal>,
860 pub time_in_force: TimeInForce,
862}
863
864impl AlpacaBracketOrderRequest {
865 #[allow(clippy::too_many_arguments)] pub fn new(
870 instrument: InstrumentNameExchange,
871 strategy: StrategyId,
872 cid: ClientOrderId,
873 side: Side,
874 quantity: Decimal,
875 entry_price: Decimal,
876 take_profit_price: Decimal,
877 stop_loss_price: Decimal,
878 time_in_force: TimeInForce,
879 ) -> Self {
880 Self {
881 instrument,
882 strategy,
883 cid,
884 side,
885 quantity,
886 entry_price,
887 take_profit_price,
888 stop_loss_price,
889 stop_loss_limit_price: None,
890 time_in_force,
891 }
892 }
893
894 #[must_use]
896 pub fn with_stop_loss_limit_price(mut self, price: Decimal) -> Self {
897 self.stop_loss_limit_price = Some(price);
898 self
899 }
900}
901
902#[non_exhaustive]
913#[derive(Debug, Clone)]
914pub struct AlpacaBracketOrderResult {
915 pub parent: Order<ExchangeId, InstrumentNameExchange, UnindexedOrderState>,
917}
918
919#[derive(Debug, Serialize)]
924struct AlpacaOrderRequest<'a> {
925 symbol: &'a str,
926 qty: String,
927 side: &'static str,
928 #[serde(rename = "type")]
929 order_type: &'static str,
930 time_in_force: &'static str,
931 #[serde(skip_serializing_if = "Option::is_none")]
932 limit_price: Option<String>,
933 #[serde(skip_serializing_if = "Option::is_none")]
934 stop_price: Option<String>,
935 #[serde(skip_serializing_if = "Option::is_none")]
936 trail_percent: Option<String>,
937 #[serde(skip_serializing_if = "Option::is_none")]
938 trail_price: Option<String>,
939 #[serde(skip_serializing_if = "Option::is_none")]
940 client_order_id: Option<&'a str>,
941 #[serde(skip_serializing_if = "Option::is_none")]
945 position_intent: Option<AlpacaPositionIntent>,
946 #[serde(skip_serializing_if = "Option::is_none")]
949 order_class: Option<&'static str>,
950 #[serde(skip_serializing_if = "Option::is_none")]
951 take_profit: Option<TakeProfitParams>,
952 #[serde(skip_serializing_if = "Option::is_none")]
953 stop_loss: Option<StopLossParams>,
954}
955
956#[derive(Debug, Deserialize)]
965struct AlpacaStreamMessage<'a> {
966 stream: SmolStr,
969 #[serde(borrow)]
970 data: &'a serde_json::value::RawValue,
971}
972
973#[derive(Debug, Deserialize)]
981struct AlpacaTradeUpdate<'a> {
982 event: SmolStr,
984 #[serde(borrow)]
985 order: AlpacaOrderWs<'a>,
986 #[serde(borrow)]
988 price: Option<&'a str>,
989 #[serde(borrow)]
991 qty: Option<&'a str>,
992 #[serde(borrow)]
993 timestamp: Option<&'a str>,
994 #[serde(borrow)]
998 execution_id: Option<&'a str>,
999 #[serde(borrow)]
1003 previous_execution_id: Option<&'a str>,
1004}
1005
1006#[derive(Debug, Deserialize)]
1008struct AlpacaOrderWs<'a> {
1009 id: SmolStr,
1012 client_order_id: Option<SmolStr>,
1013 symbol: SmolStr,
1016 #[serde(borrow)]
1017 qty: Option<&'a str>,
1018 #[serde(borrow)]
1025 filled_qty: Option<&'a str>,
1026 side: SmolStr,
1029 #[serde(rename = "type")]
1030 order_type: SmolStr,
1031 time_in_force: SmolStr,
1032 #[serde(borrow)]
1033 limit_price: Option<&'a str>,
1034 #[serde(borrow)]
1035 stop_price: Option<&'a str>,
1036 #[serde(borrow)]
1037 trail_percent: Option<&'a str>,
1038 #[serde(borrow)]
1039 trail_price: Option<&'a str>,
1040 status: SmolStr,
1041}
1042
1043#[derive(Clone)]
1077pub struct AlpacaClient {
1078 config: Arc<AlpacaConfig>,
1079 http: reqwest::Client,
1082 rate_limiter: Arc<RateLimitTracker>,
1083 orders_url: String,
1085 known_live: SharedKnownLiveOrders,
1088}
1089
1090impl std::fmt::Debug for AlpacaClient {
1091 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1092 f.debug_struct("AlpacaClient")
1093 .field("paper", &self.config.paper)
1094 .finish_non_exhaustive()
1095 }
1096}
1097
1098impl AlpacaClient {
1099 #[allow(clippy::expect_used)] fn build_http(config: &AlpacaConfig) -> reqwest::Client {
1107 use reqwest::header::{HeaderMap, HeaderName, HeaderValue};
1108 let mut headers = HeaderMap::new();
1109 headers.insert(
1111 HeaderName::from_static("apca-api-key-id"),
1112 HeaderValue::from_str(&config.api_key)
1113 .expect("Alpaca API key contains invalid header characters"),
1114 );
1115 headers.insert(
1116 HeaderName::from_static("apca-api-secret-key"),
1117 HeaderValue::from_str(&config.secret_key)
1118 .expect("Alpaca secret key contains invalid header characters"),
1119 );
1120 reqwest::Client::builder()
1121 .default_headers(headers)
1122 .build()
1123 .expect("failed to build reqwest client for Alpaca")
1124 }
1125
1126 fn base_url(&self) -> &str {
1127 self.config.rest_base_url()
1128 }
1129
1130 fn remember_live<'a>(
1132 &self,
1133 orders: impl IntoIterator<Item = &'a Order<ExchangeId, InstrumentNameExchange, Open>>,
1134 ) {
1135 let mut known = self.known_live.lock();
1136 for order in orders {
1137 known.live(&order.key, order.quantity, order.kind, &order.state);
1138 }
1139 }
1140}
1141
1142fn parse_rate_limit_delay(headers: &reqwest::header::HeaderMap) -> Option<Duration> {
1160 let header = |name: &str| {
1161 headers
1162 .get(name)
1163 .and_then(|value| value.to_str().ok())
1164 .and_then(|value| value.trim().parse::<u64>().ok())
1165 };
1166
1167 let seconds = match header("retry-after") {
1168 Some(seconds) => seconds,
1169 None => {
1170 let reset = header("x-ratelimit-reset")?;
1171 let now = std::time::SystemTime::now()
1172 .duration_since(std::time::UNIX_EPOCH)
1173 .unwrap_or_default()
1174 .as_secs();
1175 reset.saturating_sub(now).max(1)
1176 }
1177 };
1178 Some(Duration::from_secs(
1179 seconds.min(DEFAULT_RATE_LIMIT_DELAY_SECS),
1180 ))
1181}
1182
1183fn observe_rate_limit_remaining(
1191 rate_limiter: &RateLimitTracker,
1192 headers: &reqwest::header::HeaderMap,
1193) {
1194 let exhausted = headers
1195 .get("x-ratelimit-remaining")
1196 .and_then(|v| v.to_str().ok())
1197 .and_then(|s| s.parse::<u32>().ok())
1198 == Some(0);
1199 if !exhausted {
1200 return;
1201 }
1202 match parse_rate_limit_delay(headers) {
1203 Some(reset) => rate_limiter.on_bucket_exhausted(reset),
1204 None => debug!(
1205 "Alpaca REST rate-limit window exhausted (X-Ratelimit-Remaining: 0) without X-Ratelimit-Reset"
1206 ),
1207 }
1208}
1209
1210async fn rest_with_retry<T>(
1231 rate_limiter: &RateLimitTracker,
1232 build_request: impl FnMut() -> reqwest::RequestBuilder,
1233) -> Result<T, UnindexedClientError>
1234where
1235 T: for<'de> Deserialize<'de>,
1236{
1237 match rest_request(rate_limiter, build_request).await? {
1238 Fetched::Found(value) => Ok(value),
1239 Fetched::NotFound(message) => Err(UnindexedClientError::Api(parse_api_error(
1240 reqwest::StatusCode::NOT_FOUND,
1241 None,
1242 &message,
1243 ))),
1244 }
1245}
1246
1247async fn rest_lookup_with_retry<T>(
1250 rate_limiter: &RateLimitTracker,
1251 build_request: impl FnMut() -> reqwest::RequestBuilder,
1252) -> Result<Option<T>, UnindexedClientError>
1253where
1254 T: for<'de> Deserialize<'de>,
1255{
1256 match rest_request(rate_limiter, build_request).await? {
1257 Fetched::Found(value) => Ok(Some(value)),
1258 Fetched::NotFound(_) => Ok(None),
1259 }
1260}
1261
1262#[derive(Debug)]
1264enum Fetched<T> {
1265 Found(T),
1267 NotFound(String),
1269}
1270
1271async fn rest_request<T>(
1273 rate_limiter: &RateLimitTracker,
1274 mut build_request: impl FnMut() -> reqwest::RequestBuilder,
1275) -> Result<Fetched<T>, UnindexedClientError>
1276where
1277 T: for<'de> Deserialize<'de>,
1278{
1279 for attempt in 0..MAX_RATE_LIMIT_ATTEMPTS {
1280 rate_limiter.wait_if_blocked().await;
1281 let response = build_request()
1282 .send()
1283 .await
1284 .map_err(|e| connectivity_err(format!("Alpaca REST request failed: {e}")))?;
1285
1286 let status = response.status();
1287
1288 if status == reqwest::StatusCode::TOO_MANY_REQUESTS {
1289 let reset_delay = parse_rate_limit_delay(response.headers());
1290 rate_limiter.on_rate_limited(reset_delay);
1292
1293 if attempt + 1 < MAX_RATE_LIMIT_ATTEMPTS {
1294 warn!(
1295 attempt = attempt + 1,
1296 max_attempts = MAX_RATE_LIMIT_ATTEMPTS,
1297 "Alpaca REST rate-limited (429), retrying"
1298 );
1299 continue;
1300 }
1301
1302 warn!(
1304 max_attempts = MAX_RATE_LIMIT_ATTEMPTS,
1305 "Alpaca REST rate-limit retries exhausted"
1306 );
1307 return Err(UnindexedClientError::Api(ApiError::rate_limited(
1308 reset_delay,
1309 )));
1310 }
1311
1312 observe_rate_limit_remaining(rate_limiter, response.headers());
1313
1314 if status == reqwest::StatusCode::NO_CONTENT {
1318 return Err(UnindexedClientError::Internal(
1319 "Alpaca REST returned 204 No Content — use rest_delete_with_retry for DELETE endpoints"
1320 .to_string(),
1321 ));
1322 }
1323
1324 let bytes = response
1325 .bytes()
1326 .await
1327 .map_err(|e| connectivity_err(format!("Alpaca REST read body failed: {e}")))?;
1328
1329 if status.is_success() {
1332 return serde_json::from_slice::<T>(&bytes)
1333 .map(Fetched::Found)
1334 .map_err(|e| {
1335 UnindexedClientError::Internal(format!(
1336 "Alpaca REST JSON parse error ({status}): {e} | body: {}",
1337 String::from_utf8_lossy(&bytes)
1338 .chars()
1339 .take(200)
1340 .collect::<String>()
1341 ))
1342 });
1343 }
1344
1345 let api_err = AlpacaApiError::from_body(&bytes);
1347
1348 if status == reqwest::StatusCode::NOT_FOUND {
1349 return Ok(Fetched::NotFound(api_err.message));
1350 }
1351
1352 if status.is_client_error() {
1361 return Err(UnindexedClientError::Api(parse_api_error(
1362 status,
1363 api_err.code,
1364 &api_err.message,
1365 )));
1366 }
1367 return Err(connectivity_err(format!(
1368 "Alpaca REST error {status}: {}",
1369 api_err.message
1370 )));
1371 }
1372 unreachable!("Alpaca REST retry loop exited without returning")
1373}
1374
1375async fn rest_delete_with_retry(
1382 rate_limiter: &RateLimitTracker,
1383 mut build_request: impl FnMut() -> reqwest::RequestBuilder,
1384) -> Result<(), UnindexedOrderError> {
1385 for attempt in 0..MAX_RATE_LIMIT_ATTEMPTS {
1386 rate_limiter.wait_if_blocked().await;
1387 let response = build_request().send().await.map_err(|e| {
1388 UnindexedOrderError::Connectivity(ConnectivityError::Socket(format!(
1389 "Alpaca cancel request failed: {e}"
1390 )))
1391 })?;
1392
1393 let status = response.status();
1394
1395 if status == reqwest::StatusCode::TOO_MANY_REQUESTS {
1396 let reset_delay = parse_rate_limit_delay(response.headers());
1397 rate_limiter.on_rate_limited(reset_delay);
1399
1400 if attempt + 1 < MAX_RATE_LIMIT_ATTEMPTS {
1401 warn!(
1402 attempt = attempt + 1,
1403 max_attempts = MAX_RATE_LIMIT_ATTEMPTS,
1404 "Alpaca cancel rate-limited (429), retrying"
1405 );
1406 continue;
1407 }
1408
1409 warn!(
1411 max_attempts = MAX_RATE_LIMIT_ATTEMPTS,
1412 "Alpaca cancel rate-limit retries exhausted"
1413 );
1414 return Err(UnindexedOrderError::Rejected(ApiError::rate_limited(
1415 reset_delay,
1416 )));
1417 }
1418
1419 observe_rate_limit_remaining(rate_limiter, response.headers());
1420
1421 if status == reqwest::StatusCode::NO_CONTENT || status.is_success() {
1423 return Ok(());
1424 }
1425
1426 let bytes = response
1427 .bytes()
1428 .await
1429 .inspect_err(
1430 |e| warn!(%e, %status, "Alpaca cancel_order: failed to read error response body"),
1431 )
1432 .unwrap_or_default();
1433 let api_err = AlpacaApiError::from_body(&bytes);
1434
1435 return Err(parse_order_error(status, api_err.code, &api_err.message));
1436 }
1437 unreachable!("Alpaca cancel retry loop exited without returning")
1438}
1439
1440impl ExecutionClient for AlpacaClient {
1445 const EXCHANGE: ExchangeId = ExchangeId::AlpacaBroker;
1446
1447 const SUPPORTED_KINDS: &'static [InstrumentKindDiscriminant] = &[
1454 InstrumentKindDiscriminant::Spot,
1455 InstrumentKindDiscriminant::Option,
1456 ];
1457 type Config = AlpacaConfig;
1458 type AccountStream = BoxStream<'static, UnindexedAccountEvent>;
1459
1460 fn new(config: Self::Config) -> Self {
1465 let http = Self::build_http(&config);
1466 let orders_url = format!("{}/v2/orders", config.rest_base_url());
1467 Self {
1468 config: Arc::new(config),
1469 http,
1470 rate_limiter: Arc::new(RateLimitTracker::new()),
1471 orders_url,
1472 known_live: KnownLiveOrders::shared(ExchangeId::AlpacaBroker),
1473 }
1474 }
1475
1476 async fn account_snapshot(
1523 &self,
1524 assets: &[AssetNameExchange],
1525 instruments: &[InstrumentNameExchange],
1526 ) -> Result<UnindexedAccountSnapshot, UnindexedClientError> {
1527 let base = self.base_url();
1528 let http = self.http.clone();
1529 let rl = &self.rate_limiter;
1530
1531 let wants_usd = assets.is_empty() || assets.iter().any(is_usd);
1532 let wants_non_usd = assets.is_empty() || assets.iter().any(|a| !is_usd(a));
1533
1534 let account_url = format!("{base}/v2/account");
1536 let positions_url = format!("{base}/v2/positions");
1537 let (account, positions): (Option<AlpacaAccount>, Vec<AlpacaPosition>) = tokio::try_join!(
1538 async {
1539 if wants_usd {
1540 rest_with_retry(rl, || http.get(&account_url))
1541 .await
1542 .map(Some)
1543 } else {
1544 Ok(None)
1545 }
1546 },
1547 rest_with_retry(rl, || http.get(&positions_url)),
1548 )?;
1549
1550 let mut balances = account
1551 .map(|account| convert_account_to_balances(&account, assets))
1552 .unwrap_or_default();
1553 if wants_non_usd {
1554 balances.extend(convert_positions_to_balances(&positions, assets));
1555 }
1556
1557 let converted_positions = convert_positions(&positions, Utc::now())?;
1560 let open_orders = fetch_raw_open_orders(&http, rl, base, instruments).await?;
1561
1562 let instrument_snapshots =
1564 build_instrument_snapshots(open_orders, converted_positions, instruments);
1565 {
1566 let mut known = self.known_live.lock();
1567 for order in instrument_snapshots
1568 .iter()
1569 .flat_map(|snapshot| &snapshot.orders)
1570 {
1571 if let OrderState::Active(ActiveOrderState::Open(open)) = &order.state {
1572 known.live(&order.key, order.quantity, order.kind, open);
1573 }
1574 }
1575 }
1576
1577 Ok(AccountSnapshot::new(
1578 ExchangeId::AlpacaBroker,
1579 balances,
1580 instrument_snapshots,
1581 ))
1582 }
1583
1584 async fn account_stream(
1730 &self,
1731 _assets: &[AssetNameExchange], instruments: &[InstrumentNameExchange],
1736 ) -> Result<Self::AccountStream, UnindexedClientError> {
1737 let initial_ws = connect_and_subscribe(&self.config).await?;
1740
1741 let (tx, rx) = mpsc::unbounded_channel::<UnindexedAccountEvent>();
1746 let dedup = new_dedup_cache();
1747 let config = self.config.clone();
1748 let http = self.http.clone();
1749 let rate_limiter = self.rate_limiter.clone();
1750 let known = self.known_live.clone();
1751 let instruments = instruments.to_vec();
1752
1753 let cm_handle = tokio::spawn(connection_manager(
1754 tx,
1755 dedup,
1756 config,
1757 http,
1758 rate_limiter,
1759 known,
1760 instruments,
1761 Some(initial_ws),
1762 ));
1763
1764 let rx_stream = tokio_stream::wrappers::UnboundedReceiverStream::new(rx);
1765 let guarded = GracefulShutdownStream::new(rx_stream, cm_handle);
1766 Ok(futures::StreamExt::boxed(guarded))
1767 }
1768
1769 async fn cancel_order(
1788 &self,
1789 request: OrderRequestCancel<ExchangeId, &InstrumentNameExchange>,
1790 ) -> UnindexedOrderResponseCancel {
1791 let key = crate::order::OrderKey {
1792 exchange: request.key.exchange,
1793 instrument: request.key.instrument.clone(),
1794 strategy: request.key.strategy.clone(),
1795 cid: request.key.cid.clone(),
1796 };
1797
1798 let order_id: SmolStr = match request.state.id.as_ref().and_then(VenueOrderId::assigned) {
1802 Some(id) => id.0.clone(),
1803 None => {
1804 warn!(
1805 instrument = %key.instrument,
1806 "Alpaca cancel_order: no exchange order ID available (clientOrderId-only cancel not supported)"
1807 );
1808 return crate::order::request::OrderResponseCancel {
1809 key,
1810 state: Err(UnindexedOrderError::Rejected(ApiError::OrderRejected(
1811 "exchange order ID required for cancel (fetch_open_orders to resolve)"
1812 .into(),
1813 ))),
1814 };
1815 }
1816 };
1817
1818 let base = self.base_url();
1819 let http = self.http.clone();
1820 let url = format!("{base}/v2/orders/{order_id}");
1821
1822 match rest_delete_with_retry(&self.rate_limiter, || http.delete(&url)).await {
1823 Ok(()) => {
1824 let exchange_order_id = OrderId(order_id);
1825 crate::order::request::OrderResponseCancel {
1828 key,
1829 state: Ok(Cancelled::new(exchange_order_id, Utc::now(), None)),
1830 }
1831 }
1832 Err(e) => crate::order::request::OrderResponseCancel { key, state: Err(e) },
1833 }
1834 }
1835
1836 async fn open_order(
1857 &self,
1858 request: OrderRequestOpen<ExchangeId, &InstrumentNameExchange>,
1859 ) -> Order<ExchangeId, InstrumentNameExchange, UnindexedOrderState> {
1860 let side = request.state.side;
1861 let reduce_only = request.state.reduce_only;
1862 self.open_order_inner(request, map_position_intent(side, reduce_only))
1863 .await
1864 }
1865
1866 async fn fetch_balances(
1874 &self,
1875 assets: &[AssetNameExchange],
1876 ) -> Result<Vec<AssetBalance<AssetNameExchange>>, UnindexedClientError> {
1877 let base = self.base_url();
1878 let http = self.http.clone();
1879 let mut result = Vec::new();
1880
1881 let wants_usd = assets.is_empty() || assets.iter().any(is_usd);
1884 if wants_usd {
1885 let account_url = format!("{base}/v2/account");
1887 let account: AlpacaAccount =
1888 rest_with_retry(&self.rate_limiter, || http.get(&account_url)).await?;
1889 result.extend(convert_account_to_balances(&account, assets));
1890 }
1891
1892 let wants_non_usd = assets.is_empty() || assets.iter().any(|a| !is_usd(a));
1894 if wants_non_usd {
1895 let positions_url = format!("{base}/v2/positions");
1897 let positions: Vec<AlpacaPosition> =
1898 rest_with_retry(&self.rate_limiter, || http.get(&positions_url)).await?;
1899 result.extend(convert_positions_to_balances(&positions, assets));
1900 }
1901
1902 Ok(result)
1903 }
1904
1905 async fn fetch_open_orders(
1906 &self,
1907 instruments: &[InstrumentNameExchange],
1908 ) -> Result<Vec<Order<ExchangeId, InstrumentNameExchange, Open>>, UnindexedClientError> {
1909 let base = self.base_url();
1910 let http = self.http.clone();
1911
1912 let open_orders =
1913 fetch_raw_open_orders(&http, &self.rate_limiter, base, instruments).await?;
1914
1915 let result: Vec<_> = open_orders
1916 .into_iter()
1917 .filter_map(|o| convert_open_order(&o))
1918 .collect();
1919 self.remember_live(&result);
1920 Ok(result)
1921 }
1922
1923 async fn fetch_trades(
1936 &self,
1937 start: DateTime<Utc>,
1938 end: DateTime<Utc>,
1939 instruments: &[InstrumentNameExchange],
1940 ) -> Result<TradesRead<AssetNameExchange, InstrumentNameExchange>, UnindexedClientError> {
1941 if start > end || end < DateTime::UNIX_EPOCH {
1943 return Ok(TradesRead::complete(Vec::new()));
1944 }
1945 let page = paginate_activities::<AlpacaActivity>(
1946 &self.http,
1947 &self.rate_limiter,
1948 self.base_url(),
1949 FILL_ACTIVITIES,
1950 start,
1951 Some(end),
1952 )
1953 .await?;
1954
1955 let Ok(resume) = span_resume(page.resume, start, end) else {
1956 error!(
1957 %start,
1958 fills_read = page.activities.len(),
1959 "Alpaca fetch_trades: a full read did not advance past the span's first millisecond"
1960 );
1961 return Err(UnindexedClientError::Truncated {
1962 fills_read: page.activities.len(),
1963 });
1964 };
1965
1966 let instrument_set: fnv::FnvHashSet<&str> =
1969 instruments.iter().map(|i| i.name().as_str()).collect();
1970 let trades = page
1971 .activities
1972 .iter()
1973 .filter(|a| instrument_set.is_empty() || instrument_set.contains(a.symbol.as_str()))
1974 .filter(|a| {
1977 parse_timestamp(&a.transaction_time)
1978 .is_none_or(|time| (start..=end).contains(&time))
1979 })
1980 .filter_map(convert_activity_to_trade)
1981 .collect();
1982
1983 Ok(TradesRead::new(trades, resume))
1984 }
1985}
1986
1987impl AlpacaClient {
1992 pub async fn fetch_cash_flows(
2042 &self,
2043 start: DateTime<Utc>,
2044 end: DateTime<Utc>,
2045 ) -> Result<CashFlowsRead<AssetNameExchange, InstrumentNameExchange>, UnindexedClientError>
2046 {
2047 if start > end || end < DateTime::UNIX_EPOCH {
2049 return Ok(CashFlowsRead::complete(Vec::new()));
2050 }
2051 let page = paginate_activities::<AlpacaNonTradeActivity>(
2052 &self.http,
2053 &self.rate_limiter,
2054 self.base_url(),
2055 CASH_FLOW_ACTIVITIES,
2056 start,
2057 Some(end),
2058 )
2059 .await?;
2060 let Ok(resume) = span_resume(page.resume, start, end) else {
2061 error!(
2062 %start,
2063 activities_read = page.activities.len(),
2064 "Alpaca fetch_cash_flows: a full read did not advance past the span's first \
2065 millisecond"
2066 );
2067 return Err(UnindexedClientError::Internal(format!(
2068 "Alpaca cash-flow read of {} activities did not advance past {start}",
2069 page.activities.len()
2070 )));
2071 };
2072
2073 let flows = page
2074 .activities
2075 .iter()
2076 .filter_map(convert_cash_flow)
2077 .filter(|flow| (start..=end).contains(&flow.time_exchange))
2080 .collect();
2081 Ok(CashFlowsRead::new(flows, resume))
2082 }
2083
2084 pub async fn fetch_option_events(
2152 &self,
2153 start: DateTime<Utc>,
2154 end: DateTime<Utc>,
2155 ) -> Result<TradesRead<AssetNameExchange, InstrumentNameExchange>, UnindexedClientError> {
2156 if start > end || end < DateTime::UNIX_EPOCH {
2158 return Ok(TradesRead::complete(Vec::new()));
2159 }
2160 let read_start = start.date_naive().and_time(NaiveTime::MIN).and_utc();
2162 let read_end = end.date_naive().and_time(LAST_MILLISECOND).and_utc();
2163 let page = paginate_activities::<AlpacaNonTradeActivity>(
2164 &self.http,
2165 &self.rate_limiter,
2166 self.base_url(),
2167 OPTION_EVENT_ACTIVITIES,
2168 read_start,
2169 Some(read_end),
2170 )
2171 .await?;
2172 let Ok(resume) = span_resume(page.resume, start, end) else {
2173 error!(
2174 %start,
2175 activities_read = page.activities.len(),
2176 "Alpaca fetch_option_events: a full read did not advance past the span's start"
2177 );
2178 return Err(UnindexedClientError::Internal(format!(
2179 "Alpaca option-event read of {} activities did not advance past {start}",
2180 page.activities.len()
2181 )));
2182 };
2183
2184 Ok(TradesRead::new(
2185 convert_option_events(&page.activities, start, end),
2186 resume,
2187 ))
2188 }
2189
2190 pub async fn open_order_with_intent(
2203 &self,
2204 request: OrderRequestOpen<ExchangeId, &InstrumentNameExchange>,
2205 intent: AlpacaPositionIntent,
2206 ) -> Order<ExchangeId, InstrumentNameExchange, UnindexedOrderState> {
2207 self.open_order_inner(request, intent).await
2208 }
2209
2210 pub async fn open_bracket_order(
2243 &self,
2244 request: AlpacaBracketOrderRequest,
2245 ) -> AlpacaBracketOrderResult {
2246 let order_key = crate::order::OrderKey::new(
2247 ExchangeId::AlpacaBroker,
2248 request.instrument.clone(),
2249 request.strategy.clone(),
2250 request.cid.clone(),
2251 );
2252
2253 let tif_str = match request.time_in_force {
2255 TimeInForce::GoodUntilEndOfDay => "day",
2256 TimeInForce::GoodUntilCancelled { post_only } => {
2257 if post_only {
2258 return AlpacaBracketOrderResult {
2259 parent: Order {
2260 key: order_key,
2261 side: request.side,
2262 price: Some(request.entry_price),
2263 quantity: request.quantity,
2264 kind: OrderKind::Limit,
2265 time_in_force: request.time_in_force,
2266 state: OrderState::inactive(OrderError::Rejected(
2267 ApiError::OrderRejected(
2268 "Alpaca does not support post_only for bracket orders"
2269 .to_string(),
2270 ),
2271 )),
2272 },
2273 };
2274 }
2275 "gtc"
2276 }
2277 other => {
2278 return AlpacaBracketOrderResult {
2279 parent: Order {
2280 key: order_key,
2281 side: request.side,
2282 price: Some(request.entry_price),
2283 quantity: request.quantity,
2284 kind: OrderKind::Limit,
2285 time_in_force: request.time_in_force,
2286 state: OrderState::inactive(OrderError::Rejected(ApiError::OrderRejected(
2287 format!(
2288 "Alpaca bracket orders only support Day or GTC time_in_force, got {:?}",
2289 other
2290 ),
2291 ))),
2292 },
2293 };
2294 }
2295 };
2296
2297 let price_ordering_ok = match request.side {
2300 Side::Buy => {
2301 request.stop_loss_price < request.entry_price
2302 && request.entry_price < request.take_profit_price
2303 }
2304 Side::Sell => {
2305 request.take_profit_price < request.entry_price
2306 && request.entry_price < request.stop_loss_price
2307 }
2308 };
2309 if !price_ordering_ok {
2310 return AlpacaBracketOrderResult {
2311 parent: Order {
2312 key: order_key,
2313 side: request.side,
2314 price: Some(request.entry_price),
2315 quantity: request.quantity,
2316 kind: OrderKind::Limit,
2317 time_in_force: request.time_in_force,
2318 state: OrderState::inactive(OrderError::Rejected(ApiError::OrderRejected(
2319 format!(
2320 "Invalid bracket price ordering for {:?} side: entry={}, take_profit={}, stop_loss={}",
2321 request.side,
2322 request.entry_price,
2323 request.take_profit_price,
2324 request.stop_loss_price,
2325 ),
2326 ))),
2327 },
2328 };
2329 }
2330
2331 if let Some(sl_limit) = request.stop_loss_limit_price {
2335 let sl_limit_ok = match request.side {
2336 Side::Buy => sl_limit <= request.stop_loss_price,
2337 Side::Sell => sl_limit >= request.stop_loss_price,
2338 };
2339 if !sl_limit_ok {
2340 return AlpacaBracketOrderResult {
2341 parent: Order {
2342 key: order_key,
2343 side: request.side,
2344 price: Some(request.entry_price),
2345 quantity: request.quantity,
2346 kind: OrderKind::Limit,
2347 time_in_force: request.time_in_force,
2348 state: OrderState::inactive(OrderError::Rejected(ApiError::OrderRejected(
2349 format!(
2350 "Invalid stop-loss limit price for {:?} bracket: \
2351 stop_loss_price={}, stop_loss_limit_price={}",
2352 request.side, request.stop_loss_price, sl_limit,
2353 ),
2354 ))),
2355 },
2356 };
2357 }
2358 }
2359
2360 let body = AlpacaOrderRequest {
2362 symbol: request.instrument.name().as_str(),
2363 qty: request.quantity.to_string(),
2364 side: map_side(request.side),
2365 order_type: "limit",
2366 time_in_force: tif_str,
2367 limit_price: Some(request.entry_price.to_string()),
2368 stop_price: None,
2369 trail_percent: None,
2370 trail_price: None,
2371 client_order_id: Some(request.cid.0.as_str()),
2372 position_intent: if is_options_or_equity_symbol(request.instrument.name().as_str()) {
2373 Some(map_position_intent(request.side, false))
2374 } else {
2375 None
2376 },
2377 order_class: Some("bracket"),
2378 take_profit: Some(TakeProfitParams {
2379 limit_price: request.take_profit_price.to_string(),
2380 }),
2381 stop_loss: Some(StopLossParams {
2382 stop_price: request.stop_loss_price.to_string(),
2383 limit_price: request.stop_loss_limit_price.map(|p| p.to_string()),
2384 }),
2385 };
2386
2387 let http = self.http.clone();
2388 let rl = &self.rate_limiter;
2389
2390 let result: Result<AlpacaOrderResponse, UnindexedClientError> =
2391 rest_with_retry(rl, || http.post(&self.orders_url).json(&body)).await;
2392
2393 match result {
2394 Ok(resp) => {
2395 let state = placed_order_state(&resp, &order_key.instrument, request.quantity);
2396 self.known_live.lock().placed(
2397 &order_key,
2398 request.quantity,
2399 OrderKind::Limit,
2400 &state,
2401 );
2402
2403 AlpacaBracketOrderResult {
2404 parent: Order {
2405 key: order_key,
2406 side: request.side,
2407 price: Some(request.entry_price),
2408 quantity: request.quantity,
2409 kind: OrderKind::Limit,
2410 time_in_force: request.time_in_force,
2411 state,
2412 },
2413 }
2414 }
2415 Err(e) => {
2416 let order_err = order_post_error(e);
2417 AlpacaBracketOrderResult {
2418 parent: Order {
2419 key: order_key,
2420 side: request.side,
2421 price: Some(request.entry_price),
2422 quantity: request.quantity,
2423 kind: OrderKind::Limit,
2424 time_in_force: request.time_in_force,
2425 state: OrderState::inactive(order_err),
2426 },
2427 }
2428 }
2429 }
2430 }
2431
2432 async fn open_order_inner(
2433 &self,
2434 request: OrderRequestOpen<ExchangeId, &InstrumentNameExchange>,
2435 intent: AlpacaPositionIntent,
2436 ) -> Order<ExchangeId, InstrumentNameExchange, UnindexedOrderState> {
2437 let instrument = request.key.instrument.clone();
2438 let side = request.state.side;
2439 let price = request.state.price;
2440 let quantity = request.state.quantity;
2441 let kind = request.state.kind;
2442 let time_in_force = request.state.time_in_force;
2443 let cid = request.key.cid.clone();
2444
2445 let order_key = crate::order::OrderKey::new(
2446 ExchangeId::AlpacaBroker,
2447 instrument.clone(),
2448 request.key.strategy.clone(),
2449 cid.clone(),
2450 );
2451
2452 let tif_str = match map_time_in_force(time_in_force) {
2454 Ok(s) => s,
2455 Err(msg) => {
2456 return Order {
2457 key: order_key,
2458 side,
2459 price,
2460 quantity,
2461 kind,
2462 time_in_force,
2463 state: OrderState::inactive(OrderError::Rejected(ApiError::OrderRejected(
2464 msg.to_string(),
2465 ))),
2466 };
2467 }
2468 };
2469
2470 let order_type_str = match map_order_kind(kind) {
2472 Some(s) => s,
2473 None => {
2474 return Order {
2475 key: order_key,
2476 side,
2477 price,
2478 quantity,
2479 kind,
2480 time_in_force,
2481 state: OrderState::inactive(OrderError::UnsupportedOrderType(format!(
2482 "Alpaca connector does not yet support OrderKind::{kind:?}"
2483 ))),
2484 };
2485 }
2486 };
2487
2488 if let OrderKind::TrailingStop {
2490 offset_type: TrailingOffsetType::BasisPoints,
2491 ..
2492 } = kind
2493 {
2494 return Order {
2495 key: order_key,
2496 side,
2497 price,
2498 quantity,
2499 kind,
2500 time_in_force,
2501 state: OrderState::inactive(OrderError::UnsupportedOrderType(
2502 "Alpaca does not support TrailingOffsetType::BasisPoints; \
2503 use Percentage or Absolute"
2504 .to_string(),
2505 )),
2506 };
2507 }
2508
2509 if matches!(kind, OrderKind::StopLimit { .. }) && price.is_none() {
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 "StopLimit order requires Order.price (the limit price) to be set".to_string(),
2521 ))),
2522 };
2523 }
2524
2525 let (stop_price, trail_percent, trail_price) = match kind {
2527 OrderKind::Stop { trigger_price } | OrderKind::StopLimit { trigger_price } => {
2528 (Some(trigger_price.to_string()), None, None)
2529 }
2530 OrderKind::TrailingStop {
2531 offset,
2532 offset_type,
2533 ..
2534 } => match offset_type {
2535 TrailingOffsetType::Percentage => (None, Some(offset.to_string()), None),
2536 TrailingOffsetType::Absolute => (None, None, Some(offset.to_string())),
2537 TrailingOffsetType::BasisPoints => unreachable!("validated above"),
2538 },
2539 OrderKind::Market
2541 | OrderKind::Limit
2542 | OrderKind::TakeProfit { .. }
2543 | OrderKind::TakeProfitLimit { .. }
2544 | OrderKind::TrailingStopLimit { .. }
2545 | OrderKind::TrailingStopFixedLimit { .. } => (None, None, None),
2546 };
2547
2548 let body = AlpacaOrderRequest {
2549 symbol: instrument.name().as_str(),
2550 qty: quantity.to_string(),
2551 side: map_side(side),
2552 order_type: order_type_str,
2553 time_in_force: tif_str,
2554 limit_price: match kind {
2555 OrderKind::Limit | OrderKind::StopLimit { .. } => price.map(|p| p.to_string()),
2556 _ => None,
2557 },
2558 stop_price,
2559 trail_percent,
2560 trail_price,
2561 client_order_id: Some(cid.0.as_str()),
2562 position_intent: if is_options_or_equity_symbol(instrument.name().as_str()) {
2568 Some(intent)
2569 } else {
2570 None
2571 },
2572 order_class: None,
2574 take_profit: None,
2575 stop_loss: None,
2576 };
2577
2578 let http = self.http.clone();
2579 let rl = &self.rate_limiter;
2580
2581 let result: Result<AlpacaOrderResponse, UnindexedClientError> =
2582 rest_with_retry(rl, || http.post(&self.orders_url).json(&body)).await;
2583
2584 match result {
2585 Ok(resp) => {
2586 let state = placed_order_state(&resp, &order_key.instrument, quantity);
2587 self.known_live
2588 .lock()
2589 .placed(&order_key, quantity, kind, &state);
2590
2591 Order {
2592 key: order_key,
2593 side,
2594 price,
2595 quantity,
2596 kind,
2597 time_in_force,
2598 state,
2599 }
2600 }
2601 Err(e) => {
2602 let order_err = order_post_error(e);
2603 Order {
2604 key: order_key,
2605 side,
2606 price,
2607 quantity,
2608 kind,
2609 time_in_force,
2610 state: OrderState::inactive(order_err),
2611 }
2612 }
2613 }
2614 }
2615}
2616
2617const MAX_OPEN_ORDERS: usize = 500;
2625
2626async fn fetch_raw_open_orders(
2634 http: &reqwest::Client,
2635 rate_limiter: &RateLimitTracker,
2636 base: &str,
2637 instruments: &[InstrumentNameExchange],
2638) -> Result<Vec<AlpacaOrderResponse>, UnindexedClientError> {
2639 let orders: Vec<AlpacaOrderResponse> = if instruments.is_empty() {
2640 rest_with_retry(rate_limiter, || {
2641 http.get(format!("{base}/v2/orders"))
2642 .query(&[("status", "open"), ("limit", "500")])
2643 })
2644 .await?
2645 } else {
2646 let symbols = instruments.iter().map(|i| i.name().as_str()).join(",");
2647 rest_with_retry(rate_limiter, || {
2648 http.get(format!("{base}/v2/orders")).query(&[
2649 ("status", "open"),
2650 ("limit", "500"),
2651 ("symbols", &symbols),
2652 ])
2653 })
2654 .await?
2655 };
2656 if orders.len() == MAX_OPEN_ORDERS {
2657 warn!(
2658 limit = MAX_OPEN_ORDERS,
2659 "Alpaca fetch_raw_open_orders: received exactly {MAX_OPEN_ORDERS} results — \
2660 response is likely truncated"
2661 );
2662 return Err(UnindexedClientError::TruncatedSnapshot {
2663 limit: MAX_OPEN_ORDERS,
2664 });
2665 }
2666 Ok(orders)
2667}
2668
2669const MAX_ACTIVITY_PAGES: usize = 50;
2678
2679trait PagedActivity: DeserializeOwned {
2681 fn id(&self) -> &str;
2683 fn time(&self) -> Option<DateTime<Utc>>;
2686}
2687
2688impl PagedActivity for AlpacaActivity {
2689 fn id(&self) -> &str {
2690 &self.id
2691 }
2692
2693 fn time(&self) -> Option<DateTime<Utc>> {
2694 parse_timestamp(&self.transaction_time)
2695 }
2696}
2697
2698impl PagedActivity for AlpacaNonTradeActivity {
2699 fn id(&self) -> &str {
2700 &self.id
2701 }
2702
2703 fn time(&self) -> Option<DateTime<Utc>> {
2704 self.created_at.as_deref().and_then(parse_timestamp)
2705 }
2706}
2707
2708const FILL_ACTIVITIES: &str = "FILL";
2710
2711const CASH_FLOW_ACTIVITIES: &str = "INT,FEE,PTC,PTR";
2713
2714const OPTION_EVENT_ACTIVITIES: &str = "OPASN,OPEXC,OPEXP,OPTRD";
2716
2717const LAST_MILLISECOND: NaiveTime = match NaiveTime::from_hms_milli_opt(23, 59, 59, 999) {
2719 Some(time) => time,
2720 None => panic!("23:59:59.999 is a time of day"),
2721};
2722
2723fn span_resume(
2730 resume: Option<DateTime<Utc>>,
2731 start: DateTime<Utc>,
2732 end: DateTime<Utc>,
2733) -> Result<Option<DateTime<Utc>>, ()> {
2734 let resume = resume.filter(|resume| *resume <= end);
2735 if resume.is_some_and(|resume| resume <= floor_millis(start)) {
2736 return Err(());
2737 }
2738 Ok(resume)
2739}
2740
2741struct ActivityPage<A> {
2743 activities: Vec<A>,
2745 resume: Option<DateTime<Utc>>,
2748}
2749
2750fn floor_millis(time: DateTime<Utc>) -> DateTime<Utc> {
2752 DateTime::from_timestamp_millis(time.timestamp_millis()).unwrap_or(time)
2753}
2754
2755const PAGE_SIZE_STR: &str = "100"; const _: () = assert!(
2785 ALPACA_MAX_ACTIVITIES == 100,
2786 "PAGE_SIZE_STR must be updated to match ALPACA_MAX_ACTIVITIES",
2787);
2788
2789async fn paginate_activities<A: PagedActivity>(
2790 http: &reqwest::Client,
2791 rate_limiter: &RateLimitTracker,
2792 base: &str,
2793 types: &str,
2794 start: DateTime<Utc>,
2795 end: Option<DateTime<Utc>>,
2796) -> Result<ActivityPage<A>, UnindexedClientError> {
2797 let after =
2800 floor_millis(start.max(DateTime::UNIX_EPOCH)).to_rfc3339_opts(SecondsFormat::Millis, true);
2801 let until = end.filter(|end| *end < Utc::now()).map(|end| {
2802 (floor_millis(end) + TimeDelta::milliseconds(1))
2803 .to_rfc3339_opts(SecondsFormat::Millis, true)
2804 });
2805 let mut all = Vec::with_capacity(ALPACA_MAX_ACTIVITIES);
2806 let mut page_token: Option<String> = None;
2807 let mut pages = 0usize;
2808 let mut truncated = false;
2809
2810 loop {
2811 if pages >= MAX_ACTIVITY_PAGES {
2812 truncated = true;
2813 break;
2814 }
2815 pages += 1;
2816 let page_token_ref = page_token.as_deref();
2818 let activities: Vec<A> = rest_with_retry(rate_limiter, || {
2819 let mut req = http.get(format!("{base}/v2/account/activities")).query(&[
2820 ("activity_types", types),
2821 ("after", after.as_str()),
2822 ("page_size", PAGE_SIZE_STR),
2823 ("direction", "asc"),
2824 ]);
2825 if let Some(until) = until.as_deref() {
2826 req = req.query(&[("until", until)]);
2827 }
2828 if let Some(token) = page_token_ref {
2829 req = req.query(&[("page_token", token)]);
2830 }
2831 req
2832 })
2833 .await?;
2834
2835 let page_len = activities.len();
2836 let page_token_candidate = activities.last().map(|a| a.id().to_owned());
2846 all.extend(activities);
2847
2848 if page_len < ALPACA_MAX_ACTIVITIES {
2849 break;
2850 }
2851 match page_token_candidate {
2852 Some(token) if !token.is_empty() => {
2853 debug!("Alpaca paginate_activities: fetching next page ({page_len} results)");
2854 page_token = Some(token);
2855 }
2856 _ => break,
2860 }
2861 }
2862
2863 let resume = truncated.then(|| {
2866 floor_millis(
2867 all.iter()
2868 .rev()
2869 .find_map(PagedActivity::time)
2870 .unwrap_or(start),
2871 )
2872 });
2873 Ok(ActivityPage {
2874 activities: all,
2875 resume,
2876 })
2877}
2878
2879#[allow(clippy::cognitive_complexity)] async fn connection_manager(
2895 tx: mpsc::UnboundedSender<UnindexedAccountEvent>,
2896 dedup: SharedDedupCache,
2897 config: Arc<AlpacaConfig>,
2898 http: reqwest::Client,
2899 rate_limiter: Arc<RateLimitTracker>,
2900 known: SharedKnownLiveOrders,
2901 instruments: Vec<InstrumentNameExchange>,
2902 initial_ws: Option<WebSocket>,
2903) {
2904 let mut budget = ReconnectBudget::new("Alpaca", BackoffLimits::ACCOUNT_STREAM);
2905 let mut disconnect_time: Option<DateTime<Utc>> = None;
2906 let mut unchecked = UncheckedOrders::default();
2909 let mut current_ws = initial_ws;
2910
2911 'outer: loop {
2912 let mut ws = match current_ws.take() {
2914 Some(ws) => ws,
2915 None => match connect_and_subscribe(&config).await {
2916 Ok(ws) => ws,
2917 Err(e) => {
2918 error!(%e, "Alpaca WS connect/subscribe failed");
2919 if !budget.wait().await {
2920 error!("Alpaca max reconnect attempts exhausted");
2921 emit_stream_terminated(
2923 &tx,
2924 ExchangeId::AlpacaBroker,
2925 StreamTerminationReason::ReconnectBudgetExhausted {
2926 attempts: budget.attempts(),
2927 last_error: e.to_string(),
2928 },
2929 );
2930 break;
2931 }
2932 continue;
2933 }
2934 },
2935 };
2936 info!("Alpaca account_stream connected and subscribed");
2937 if let Some(dt) = disconnect_time.take() {
2947 recover_fills_or_report(
2948 &http,
2949 &rate_limiter,
2950 &instruments,
2951 config.rest_base_url(),
2952 dt,
2953 Duration::from_secs(FILL_RECOVERY_TIMEOUT_SECS),
2954 &tx,
2955 &dedup,
2956 &known,
2957 )
2958 .await;
2959 let held = {
2961 let known = known.lock();
2962 if instruments.is_empty() {
2963 known.instruments()
2964 } else {
2965 known.instruments_among(&instruments)
2966 }
2967 };
2968 unchecked.open(held);
2969 }
2970
2971 let mut last_message_time = Utc::now();
2984 let heartbeat = tokio::time::sleep(Duration::from_secs(HEARTBEAT_TIMEOUT_SECS));
2985 tokio::pin!(heartbeat);
2986 let stable_from = tokio::time::Instant::now() + Duration::from_secs(HEARTBEAT_TIMEOUT_SECS);
2989
2990 let order_checks =
2995 run_order_checks(&http, &rate_limiter, &config, &known, &mut unchecked, &tx);
2996 tokio::pin!(order_checks);
2997
2998 macro_rules! reset_heartbeat {
3004 () => {
3005 let now = tokio::time::Instant::now();
3006 heartbeat
3007 .as_mut()
3008 .reset(now + Duration::from_secs(HEARTBEAT_TIMEOUT_SECS));
3009 last_message_time = Utc::now();
3010 if now >= stable_from {
3011 budget.reset();
3012 }
3013 };
3014 }
3015 enum DisconnectReason {
3018 ServerClose,
3019 Error(String),
3020 StreamEnded,
3021 HeartbeatTimeout,
3022 }
3023 let reason = loop {
3024 tokio::select! {
3025 msg = ws.next() => {
3026 match msg {
3027 Some(Ok(WsMessage::Ping(_))) => {
3028 reset_heartbeat!();
3031 }
3032 Some(Ok(WsMessage::Text(text))) => {
3033 process_ws_text(text.as_str(), &tx, &dedup, &known);
3034 reset_heartbeat!();
3035 }
3036 Some(Ok(WsMessage::Binary(bytes))) => {
3037 match std::str::from_utf8(&bytes) {
3039 Ok(text) => {
3040 process_ws_text(text, &tx, &dedup, &known);
3041 reset_heartbeat!();
3045 }
3046 Err(e) => warn!(%e, "Alpaca WS binary frame: not valid UTF-8"),
3047 }
3048 }
3049 Some(Ok(WsMessage::Close(frame))) => {
3050 warn!(frame = ?frame, "Alpaca WS closed by server");
3051 break DisconnectReason::ServerClose;
3052 }
3053 Some(Ok(_)) => {} Some(Err(e)) => {
3055 warn!(%e, "Alpaca WS error, reconnecting");
3056 break DisconnectReason::Error(e.to_string());
3057 }
3058 None => {
3059 warn!("Alpaca WS stream ended, reconnecting");
3060 break DisconnectReason::StreamEnded;
3061 }
3062 }
3063 }
3064 () = &mut order_checks => {}
3065 _ = &mut heartbeat => {
3066 warn!(
3067 timeout_secs = HEARTBEAT_TIMEOUT_SECS,
3068 "Alpaca heartbeat timeout, reconnecting"
3069 );
3070 break DisconnectReason::HeartbeatTimeout;
3072 }
3073 _ = tx.closed() => {
3074 debug!("Alpaca account_stream consumer dropped, terminating");
3075 let _ = tokio::time::timeout(
3076 Duration::from_secs(WS_CLOSE_TIMEOUT_SECS),
3077 ws.close(None),
3078 ).await;
3079 break 'outer;
3082 }
3083 }
3084 };
3085
3086 disconnect_time =
3091 Some(last_message_time - chrono::Duration::milliseconds(SIGNAL_RECOVERY_LOOKBACK_MS));
3092
3093 let _ =
3095 tokio::time::timeout(Duration::from_secs(WS_CLOSE_TIMEOUT_SECS), ws.close(None)).await;
3096
3097 if tx.is_closed() {
3098 break;
3101 }
3102 if !budget.wait().await {
3103 error!("Alpaca max reconnect attempts exhausted, stream terminating");
3104 let last_error = match reason {
3107 DisconnectReason::HeartbeatTimeout => {
3108 format!("heartbeat timeout ({HEARTBEAT_TIMEOUT_SECS}s)")
3109 }
3110 DisconnectReason::ServerClose => "WebSocket closed by server".to_string(),
3111 DisconnectReason::Error(e) => e,
3112 DisconnectReason::StreamEnded => "WebSocket stream ended".to_string(),
3113 };
3114 emit_stream_terminated(
3115 &tx,
3116 ExchangeId::AlpacaBroker,
3117 StreamTerminationReason::ReconnectBudgetExhausted {
3118 attempts: budget.attempts(),
3119 last_error,
3120 },
3121 );
3122 break;
3123 }
3124 }
3125}
3126
3127#[derive(Debug)]
3132enum HandshakeError {
3133 Transport(String),
3136 Auth(String),
3139}
3140
3141async fn connect_and_subscribe(config: &AlpacaConfig) -> Result<WebSocket, UnindexedClientError> {
3145 let url = config.stream_url();
3146 debug!(%url, "Alpaca: connecting to WebSocket");
3147
3148 let mut ws = rustrade_integration::protocol::websocket::connect(url)
3149 .await
3150 .map_err(|e| {
3151 UnindexedClientError::Connectivity(ConnectivityError::Socket(format!(
3152 "WS connect: {e}"
3153 )))
3154 })?;
3155
3156 let result = tokio::time::timeout(
3158 Duration::from_secs(WS_HANDSHAKE_TIMEOUT_SECS),
3159 ws_handshake(&mut ws, config),
3160 )
3161 .await;
3162
3163 match result {
3164 Ok(Ok(())) => Ok(ws),
3165 Ok(Err(HandshakeError::Transport(e))) => {
3166 let _ = ws.close(None).await;
3167 Err(UnindexedClientError::Connectivity(
3168 ConnectivityError::Socket(e),
3169 ))
3170 }
3171 Ok(Err(HandshakeError::Auth(e))) => {
3172 let _ = ws.close(None).await;
3173 Err(UnindexedClientError::Api(ApiError::Unauthenticated(e)))
3174 }
3175 Err(_) => {
3176 let _ = ws.close(None).await;
3177 Err(UnindexedClientError::Connectivity(
3178 ConnectivityError::Timeout,
3179 ))
3180 }
3181 }
3182}
3183
3184async fn ws_handshake(ws: &mut WebSocket, config: &AlpacaConfig) -> Result<(), HandshakeError> {
3192 let auth = serde_json::json!({
3194 "action": "auth",
3195 "key": config.api_key(),
3196 "secret": config.secret_key,
3199 })
3200 .to_string();
3201 ws.send(WsMessage::Text(auth.into()))
3202 .await
3203 .map_err(|e| HandshakeError::Transport(format!("WS auth send: {e}")))?;
3204
3205 loop {
3207 match ws.next().await {
3208 Some(Ok(WsMessage::Text(text))) => {
3209 if let Some(result) = check_auth_response(text.as_str()) {
3210 result?;
3211 break;
3212 }
3213 }
3214 Some(Ok(WsMessage::Binary(bytes))) => {
3215 if let Ok(text) = std::str::from_utf8(&bytes)
3216 && let Some(result) = check_auth_response(text)
3217 {
3218 result?;
3219 break;
3220 }
3221 }
3222 Some(Err(e)) => {
3223 return Err(HandshakeError::Transport(format!(
3224 "WS error during auth: {e}"
3225 )));
3226 }
3227 None => {
3228 return Err(HandshakeError::Transport(
3229 "WS closed before auth response".into(),
3230 ));
3231 }
3232 _ => {} }
3234 }
3235
3236 let sub = serde_json::json!({
3238 "action": "listen",
3239 "data": { "streams": ["trade_updates"] }
3240 })
3241 .to_string();
3242 ws.send(WsMessage::Text(sub.into()))
3243 .await
3244 .map_err(|e| HandshakeError::Transport(format!("WS subscribe send: {e}")))?;
3245
3246 loop {
3248 match ws.next().await {
3249 Some(Ok(WsMessage::Text(text))) => {
3250 if check_listen_ack(text.as_str()) {
3251 break;
3252 }
3253 if let Ok(msg) = serde_json::from_str::<AlpacaStreamMessage<'_>>(text.as_str()) {
3260 if msg.stream == "trade_updates" {
3261 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");
3262 } else {
3263 trace!(stream = %msg.stream, "WS message dropped during listen-ack handshake");
3264 }
3265 } else {
3266 trace!(
3267 bytes = text.len(),
3268 "WS non-stream message dropped during listen-ack handshake"
3269 );
3270 }
3271 }
3272 Some(Ok(WsMessage::Binary(bytes))) => {
3273 if let Ok(text) = std::str::from_utf8(&bytes)
3274 && check_listen_ack(text)
3275 {
3276 break;
3277 }
3278 trace!(
3279 bytes = bytes.len(),
3280 "WS binary message dropped during listen-ack handshake"
3281 );
3282 }
3283 Some(Err(e)) => {
3284 return Err(HandshakeError::Transport(format!(
3285 "WS error during subscribe: {e}"
3286 )));
3287 }
3288 None => {
3289 return Err(HandshakeError::Transport(
3290 "WS closed before subscribe ack".into(),
3291 ));
3292 }
3293 _ => {}
3294 }
3295 }
3296
3297 info!("Alpaca WS authenticated and subscribed to trade_updates");
3298 Ok(())
3299}
3300
3301fn check_auth_response(text: &str) -> Option<Result<(), HandshakeError>> {
3307 let msg = serde_json::from_str::<AlpacaStreamMessage<'_>>(text).ok()?;
3308 if msg.stream != "authorization" {
3309 return None;
3310 }
3311 #[derive(Deserialize)]
3312 struct AuthData<'a> {
3313 status: &'a str,
3314 }
3315 let data = serde_json::from_str::<AuthData<'_>>(msg.data.get()).ok()?;
3316 if data.status == "authorized" {
3317 Some(Ok(()))
3318 } else {
3319 Some(Err(HandshakeError::Auth(format!(
3320 "Alpaca WS auth failed: status={}",
3321 data.status
3322 ))))
3323 }
3324}
3325
3326fn check_listen_ack(text: &str) -> bool {
3330 let Ok(msg) = serde_json::from_str::<AlpacaStreamMessage<'_>>(text) else {
3331 return false;
3332 };
3333 if msg.stream != "listening" {
3334 return false;
3335 }
3336 #[derive(Deserialize)]
3337 struct ListenData<'a> {
3338 #[serde(borrow)]
3339 streams: Vec<&'a str>,
3340 }
3341 let Ok(data) = serde_json::from_str::<ListenData<'_>>(msg.data.get()) else {
3342 return false;
3343 };
3344 data.streams.contains(&"trade_updates")
3345}
3346
3347fn process_ws_text(
3357 text: &str,
3358 tx: &mpsc::UnboundedSender<UnindexedAccountEvent>,
3359 dedup: &SharedDedupCache,
3360 known: &SharedKnownLiveOrders,
3361) {
3362 let msg: AlpacaStreamMessage<'_> = match serde_json::from_str(text) {
3363 Ok(m) => m,
3364 Err(e) => {
3365 trace!(
3366 %e,
3367 raw = ?&text[..text.len().min(200)],
3368 "Alpaca WS: skipped non-JSON message"
3369 );
3370 return;
3371 }
3372 };
3373
3374 match msg.stream.as_str() {
3375 "trade_updates" => {
3376 let update: AlpacaTradeUpdate<'_> = match serde_json::from_str(msg.data.get()) {
3377 Ok(u) => u,
3378 Err(e) => {
3379 warn!(
3384 %e,
3385 raw = ?&msg.data.get()[..msg.data.get().len().min(200)],
3386 "Alpaca WS trade_updates: failed to deserialize event — event dropped"
3387 );
3388 return;
3389 }
3390 };
3391
3392 if is_fill_event(&update)
3396 && is_duplicate_fill(dedup, ws_execution_id(&update), early_dedup_key(&update))
3397 {
3398 trace!("Alpaca WS: skipping duplicate fill event (early check)");
3399 return;
3400 }
3401
3402 if is_trade_amendment(&update) {
3405 warn!(
3406 event = %update.event,
3407 raw = %msg.data.get(),
3408 "Alpaca WS: a fill reported earlier was amended — reporting it as TradeAmended"
3409 );
3410 }
3411
3412 for event in convert_trade_update(update).into_iter().flatten() {
3413 if let AccountEventKind::TradeAmended(amendment) = &event.kind
3417 && let TradeAmendmentKind::Corrected { replacement } = &amendment.kind
3418 {
3419 record_execution(dedup, &replacement.id.0);
3420 }
3421 let known = KnownLiveOrders::observes(&event.kind).then(|| {
3422 let mut known = known.lock();
3423 known.observe(&event);
3424 known
3425 });
3426 let _ = tx.send(event);
3429 drop(known);
3430 }
3431 }
3432 "authorization" | "listening" => {
3433 trace!(stream = %msg.stream, "Alpaca WS: auth/listen ack received during stream");
3436 }
3437 other => {
3438 trace!(%other, "Alpaca WS: ignoring unknown stream type");
3439 }
3440 }
3441}
3442
3443fn fill_dedup_key(order_id: &str, cumulative: Decimal) -> SmolStr {
3454 format_smolstr!("{}:{}", order_id, cumulative.normalize())
3455}
3456
3457fn is_trade_amendment(update: &AlpacaTradeUpdate<'_>) -> bool {
3459 matches!(update.event.as_str(), "trade_bust" | "trade_correct")
3460}
3461
3462#[inline]
3466fn is_fill_event(update: &AlpacaTradeUpdate<'_>) -> bool {
3467 matches!(update.event.as_str(), "fill" | "partial_fill")
3468}
3469
3470fn ws_execution_id<'a>(update: &AlpacaTradeUpdate<'a>) -> Option<&'a str> {
3472 update.execution_id.filter(|id| !id.is_empty())
3473}
3474
3475fn early_dedup_key(update: &AlpacaTradeUpdate<'_>) -> SmolStr {
3479 let filled_qty = update.order.filled_qty.unwrap_or("0");
3480 let qty = Decimal::from_str(filled_qty).unwrap_or(Decimal::ZERO);
3481 fill_dedup_key(&update.order.id, qty)
3482}
3483
3484fn activity_execution_id(activity_id: &str) -> &str {
3488 activity_execution_id_part(activity_id).unwrap_or(activity_id)
3489}
3490
3491fn activity_execution_id_part(activity_id: &str) -> Option<&str> {
3495 activity_id
3496 .split_once("::")
3497 .map(|(_, execution_id)| execution_id)
3498 .filter(|execution_id| !execution_id.is_empty())
3499}
3500
3501#[derive(Debug, PartialEq)]
3507struct UnreadFills {
3508 start: DateTime<Utc>,
3509 reason: FillRecoveryFailure,
3510}
3511
3512async fn recover_fills_or_report(
3516 http: &reqwest::Client,
3517 rate_limiter: &RateLimitTracker,
3518 instruments: &[InstrumentNameExchange],
3519 base: &str,
3520 disconnect: DateTime<Utc>,
3521 timeout: Duration,
3522 tx: &mpsc::UnboundedSender<UnindexedAccountEvent>,
3523 dedup: &SharedDedupCache,
3524 known: &SharedKnownLiveOrders,
3525) {
3526 let recovery_start = Utc::now();
3528 let outcome = match tokio::time::timeout(
3529 timeout,
3530 recover_fills(
3531 http,
3532 rate_limiter,
3533 instruments,
3534 base,
3535 disconnect,
3536 tx,
3537 dedup,
3538 known,
3539 ),
3540 )
3541 .await
3542 {
3543 Ok(outcome) => outcome,
3544 Err(_) => {
3548 warn!(
3549 timeout_secs = timeout.as_secs(),
3550 instruments = ?instruments
3551 .iter()
3552 .map(|instrument| instrument.name().as_str())
3553 .collect::<Vec<_>>(),
3554 "Alpaca fill recovery timed out — fills since the disconnect may be missing for \
3555 any of the instruments (every instrument when the list is empty)"
3556 );
3557 Err(UnreadFills {
3558 start: disconnect,
3559 reason: FillRecoveryFailure::TimedOut {
3560 timeout_secs: timeout.as_secs(),
3561 },
3562 })
3563 }
3564 };
3565 if let Err(unread) = outcome
3566 && tx
3567 .send(fill_recovery_gave_up(instruments, unread, recovery_start))
3568 .is_err()
3569 {
3570 debug!("Alpaca fill recovery: consumer dropped before the give-up was sent");
3571 }
3572}
3573
3574fn fill_recovery_gave_up(
3583 instruments: &[InstrumentNameExchange],
3584 unread: UnreadFills,
3585 end: DateTime<Utc>,
3586) -> UnindexedAccountEvent {
3587 let UnreadFills { start, reason } = unread;
3588 let scope = if instruments.is_empty() {
3590 FillRecoveryScope::AllInstruments
3591 } else {
3592 FillRecoveryScope::Instruments(instruments.to_vec())
3593 };
3594 UnindexedAccountEvent::new(
3595 ExchangeId::AlpacaBroker,
3596 AccountEventKind::FillRecoveryGaveUp(FillRecoveryGap::new(
3597 scope,
3598 start,
3599 end.max(start),
3600 1,
3601 reason,
3602 )),
3603 )
3604}
3605
3606async fn recover_fills(
3615 http: &reqwest::Client,
3616 rate_limiter: &RateLimitTracker,
3617 instruments: &[InstrumentNameExchange],
3618 base: &str,
3619 after: DateTime<Utc>,
3620 tx: &mpsc::UnboundedSender<UnindexedAccountEvent>,
3621 dedup: &SharedDedupCache,
3622 known: &SharedKnownLiveOrders,
3623) -> Result<(), UnreadFills> {
3624 info!(%after, instruments = instruments.len(), "Alpaca recovering fills after reconnect");
3627 let instrument_set: fnv::FnvHashSet<&str> = if instruments.is_empty() {
3628 fnv::FnvHashSet::default()
3629 } else {
3630 instruments.iter().map(|i| i.name().as_str()).collect()
3631 };
3632
3633 let page = match paginate_activities::<AlpacaActivity>(
3634 http,
3635 rate_limiter,
3636 base,
3637 FILL_ACTIVITIES,
3638 after,
3639 None,
3640 )
3641 .await
3642 {
3643 Ok(p) => p,
3644 Err(e) => {
3645 error!(%e, "Alpaca fill recovery: REST request failed");
3646 return Err(UnreadFills {
3647 start: after,
3648 reason: FillRecoveryFailure::Request(e.to_string()),
3649 });
3650 }
3651 };
3652
3653 let activities = page.activities;
3654
3655 let unread = page.resume.map(|start| {
3659 error!(
3660 max_pages = MAX_ACTIVITY_PAGES,
3661 fills_read = activities.len(),
3662 %start,
3663 "Alpaca fill recovery: max page limit reached, truncating — fills from this time on \
3664 are not delivered; read them with fetch_trades"
3665 );
3666 UnreadFills {
3667 start,
3668 reason: FillRecoveryFailure::Truncated {
3669 fills_read: activities.len(),
3670 },
3671 }
3672 });
3673
3674 let mut recovered = 0u32;
3675 let mut duplicates = 0u32;
3676
3677 let mut cumulative_qty: FnvHashMap<&str, Decimal> = FnvHashMap::default();
3694
3695 for activity in &activities {
3696 if !instrument_set.is_empty() && !instrument_set.contains(activity.symbol.as_str()) {
3697 continue;
3701 }
3702
3703 let exec_qty = Decimal::from_str(&activity.qty).unwrap_or(Decimal::ZERO);
3718 let cum = cumulative_qty
3719 .entry(activity.order_id.as_str())
3720 .or_default();
3721 *cum += exec_qty;
3722 let cumulative = *cum;
3723
3724 let trade = match convert_activity_to_trade(activity) {
3725 Some(t) => t,
3726 None => {
3727 warn!(id = %activity.id, symbol = %activity.symbol, "Alpaca: skipping activity with unparseable fields");
3728 continue; }
3730 };
3731
3732 let key = fill_dedup_key(
3742 &activity.order_id,
3743 trade.order_filled_quantity.unwrap_or(cumulative),
3744 );
3745 if is_duplicate_fill(dedup, activity_execution_id_part(&activity.id), key) {
3746 duplicates += 1;
3747 continue;
3748 }
3749
3750 let event =
3751 UnindexedAccountEvent::new(ExchangeId::AlpacaBroker, AccountEventKind::Trade(trade));
3752 let mut held = known.lock();
3753 held.observe(&event);
3754 let sent = tx.send(event);
3755 drop(held);
3756 if sent.is_err() {
3757 debug!("Alpaca fill recovery: consumer dropped during recovery");
3758 return Ok(());
3759 }
3760 recovered += 1;
3761 }
3762
3763 info!(recovered, duplicates, "Alpaca fill recovery complete");
3764 unread.map_or(Ok(()), Err)
3765}
3766
3767async fn fetch_order_lookup(
3776 http: reqwest::Client,
3777 rate_limiter: Arc<RateLimitTracker>,
3778 config: Arc<AlpacaConfig>,
3779 key: UnindexedOrderKey,
3780) -> Result<OrderLookup, UnindexedClientError> {
3781 let url = format!("{}/v2/orders:by_client_order_id", config.rest_base_url());
3782 let cid = key.cid.0.as_str();
3783 let found: Option<AlpacaOrderResponse> = rest_lookup_with_retry(&rate_limiter, || {
3784 http.get(&url).query(&[("client_order_id", cid)])
3785 })
3786 .await?;
3787 let Some(order) = found else {
3788 debug!(instrument = %key.instrument, cid = %key.cid, "Alpaca does not know this order");
3789 return Ok(OrderLookup::Unknown);
3790 };
3791 if !order
3792 .symbol
3793 .eq_ignore_ascii_case(key.instrument.name().as_str())
3794 {
3795 debug!(
3796 instrument = %key.instrument,
3797 cid = %key.cid,
3798 symbol = %order.symbol,
3799 "Alpaca knows this client order id on another symbol"
3800 );
3801 return Ok(OrderLookup::Unknown);
3802 }
3803 Ok(
3804 convert_ended_order(&order, &key).map_or(OrderLookup::NotEnded, |order| {
3805 OrderLookup::Ended(Box::new(order))
3806 }),
3807 )
3808}
3809
3810async fn listed_open_cids(
3813 http: reqwest::Client,
3814 rate_limiter: Arc<RateLimitTracker>,
3815 config: Arc<AlpacaConfig>,
3816 instruments: Vec<InstrumentNameExchange>,
3817) -> Result<FnvHashSet<ClientOrderId>, UnindexedClientError> {
3818 let orders =
3819 fetch_raw_open_orders(&http, &rate_limiter, config.rest_base_url(), &instruments).await?;
3820 Ok(orders
3821 .iter()
3822 .map(|order| alpaca_cid(order.client_order_id.as_deref(), &order.id))
3823 .collect())
3824}
3825
3826async fn recover_alpaca_ended_orders(
3830 http: &reqwest::Client,
3831 rate_limiter: &Arc<RateLimitTracker>,
3832 config: &Arc<AlpacaConfig>,
3833 known: &SharedKnownLiveOrders,
3834 unchecked: &mut UncheckedOrders,
3835 tx: &mpsc::UnboundedSender<UnindexedAccountEvent>,
3836) {
3837 recover_ended_orders(
3838 ExchangeId::AlpacaBroker,
3839 known,
3840 unchecked,
3841 &NoPendingFills,
3842 tx,
3843 OpenListing::Batched,
3844 |instruments| {
3845 listed_open_cids(
3846 http.clone(),
3847 rate_limiter.clone(),
3848 config.clone(),
3849 instruments,
3850 )
3851 },
3852 |key| fetch_order_lookup(http.clone(), rate_limiter.clone(), config.clone(), key),
3853 )
3854 .await;
3855}
3856
3857async fn run_order_checks(
3862 http: &reqwest::Client,
3863 rate_limiter: &Arc<RateLimitTracker>,
3864 config: &Arc<AlpacaConfig>,
3865 known: &SharedKnownLiveOrders,
3866 unchecked: &mut UncheckedOrders,
3867 tx: &mpsc::UnboundedSender<UnindexedAccountEvent>,
3868) {
3869 loop {
3870 if tx.is_closed() {
3872 return std::future::pending().await;
3873 }
3874 match unchecked.next_due(&NoPendingFills) {
3875 Some(due) => tokio::time::sleep_until(due).await,
3876 None => return std::future::pending().await,
3877 }
3878 recover_alpaca_ended_orders(http, rate_limiter, config, known, unchecked, tx).await;
3879 }
3880}
3881
3882fn convert_ended_order(
3890 o: &AlpacaOrderResponse,
3891 key: &UnindexedOrderKey,
3892) -> Option<UnindexedInactiveOrder> {
3893 let instrument = &key.instrument;
3894 let Some(status) = o.status.as_deref() else {
3895 warn!(%instrument, cid = %key.cid, order_id = %o.id, "Alpaca order missing status");
3896 return None;
3897 };
3898 if order_status_is_live(status) {
3899 return None;
3900 }
3901 let Some(order) = convert_representable_open_order(o) else {
3902 warn!(%instrument, cid = %key.cid, order_id = %o.id, status, "Alpaca order cannot be represented, treating it as not ended");
3903 return None;
3904 };
3905 let filled_qty = Decimal::from_str(&o.filled_qty).ok();
3908 let Some(state) = ended_order_state(
3909 status,
3910 OrderId(SmolStr::new(&o.id)),
3911 order.state.time_exchange,
3912 order.quantity,
3913 filled_qty,
3914 o.filled_avg_price.as_deref(),
3915 ) else {
3916 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");
3917 return None;
3918 };
3919 let mut order = order.map_state(|_| state);
3920 order.key = key.clone();
3921 Some(order)
3922}
3923
3924fn order_status_is_live(status: &str) -> bool {
3930 matches!(
3931 status,
3932 "new"
3933 | "partially_filled"
3934 | "done_for_day"
3935 | "pending_cancel"
3936 | "pending_replace"
3937 | "accepted"
3938 | "pending_new"
3939 | "accepted_for_bidding"
3940 | "stopped"
3941 | "suspended"
3942 | "calculated"
3943 | "held"
3944 )
3945}
3946
3947fn ended_order_state<AssetKey, InstrumentKey>(
3957 status: &str,
3958 order_id: OrderId,
3959 time_exchange: DateTime<Utc>,
3960 quantity: Decimal,
3961 filled_qty: Option<Decimal>,
3962 filled_avg_price: Option<&str>,
3963) -> Option<InactiveOrderState<AssetKey, InstrumentKey>> {
3964 Some(match status {
3965 "filled" => InactiveOrderState::FullyFilled(Filled::new(
3967 order_id,
3968 time_exchange,
3969 filled_qty.unwrap_or(quantity),
3970 alpaca_avg_price(filled_avg_price),
3971 )),
3972 "canceled" | "replaced" => {
3973 InactiveOrderState::Cancelled(Cancelled::new(order_id, time_exchange, filled_qty))
3974 }
3975 "expired" => InactiveOrderState::Expired(Expired::new(order_id, time_exchange, filled_qty)),
3976 "rejected" => {
3977 InactiveOrderState::OpenFailed(OrderError::Rejected(ApiError::OrderRejected(format!(
3978 "Alpaca rejected order {order_id} after accepting it"
3979 ))))
3980 }
3981 _ => return None,
3982 })
3983}
3984
3985fn placed_order_state(
3995 resp: &AlpacaOrderResponse,
3996 instrument: &InstrumentNameExchange,
3997 quantity: Decimal,
3998) -> UnindexedOrderState {
3999 let order_id = OrderId(SmolStr::new(&resp.id));
4000 let time_exchange = order_state_time(resp);
4001 let filled_qty = alpaca_filled_qty(&resp.id, Some(&resp.filled_qty));
4002 let filled_avg_price = resp.filled_avg_price.as_deref();
4003 match resp.status.as_deref() {
4004 Some(status) => {
4005 if let Some(ended) = ended_order_state(
4006 status,
4007 order_id.clone(),
4008 time_exchange,
4009 quantity,
4010 filled_qty,
4011 filled_avg_price,
4012 ) {
4013 return OrderState::Inactive(ended);
4014 }
4015 if !order_status_is_live(status) {
4016 warn!(%instrument, %order_id, status, "Alpaca placed an order with a status this version does not know, reading it from what filled");
4017 }
4018 }
4019 None => {
4020 warn!(%instrument, %order_id, "Alpaca placed an order without a status, reading it from what filled");
4021 }
4022 }
4023 match filled_qty {
4024 Some(filled_qty) if filled_qty >= quantity => OrderState::fully_filled(Filled::new(
4025 order_id,
4026 time_exchange,
4027 filled_qty,
4028 alpaca_avg_price(filled_avg_price),
4029 )),
4030 _ => OrderState::active(Open::new(
4033 VenueOrderId::Assigned(order_id),
4034 time_exchange,
4035 filled_qty.unwrap_or(Decimal::ZERO),
4036 )),
4037 }
4038}
4039
4040fn alpaca_avg_price(filled_avg_price: Option<&str>) -> Option<Decimal> {
4043 filled_avg_price.and_then(|price| Decimal::from_str(price).ok())
4044}
4045
4046fn is_usd(asset: &AssetNameExchange) -> bool {
4053 asset.name().as_str().eq_ignore_ascii_case(USD)
4054}
4055
4056fn convert_account_to_balances(
4074 account: &AlpacaAccount,
4075 assets: &[AssetNameExchange],
4076) -> Vec<AssetBalance<AssetNameExchange>> {
4077 if !assets.is_empty() && !assets.iter().any(is_usd) {
4078 return Vec::new();
4079 }
4080
4081 vec![AssetBalance::new(
4082 AssetNameExchange::new(USD),
4083 Balance::new(
4084 account.cash,
4085 account.cash.min(account.non_marginable_buying_power),
4086 ),
4087 Utc::now(),
4088 )]
4089}
4090
4091fn convert_positions_to_balances(
4103 positions: &[AlpacaPosition],
4104 assets: &[AssetNameExchange],
4105) -> Vec<AssetBalance<AssetNameExchange>> {
4106 let now = Utc::now();
4107 positions
4108 .iter()
4109 .filter(|p| is_crypto_position(p))
4110 .filter_map(|p| {
4111 let base = crypto_position_asset(&p.symbol);
4112
4113 if !assets.is_empty()
4115 && !assets
4116 .iter()
4117 .any(|a| a.name().as_str().eq_ignore_ascii_case(base))
4118 {
4119 return None;
4120 }
4121
4122 let asset_name = AssetNameExchange::new(base);
4125 Some(AssetBalance::new(
4126 asset_name,
4127 Balance::new(p.qty, p.qty_available),
4128 now,
4129 ))
4130 })
4131 .collect()
4132}
4133
4134fn crypto_position_asset(symbol: &str) -> &str {
4142 let base = match symbol.split_once('/') {
4143 Some((base, _)) => Some(base),
4144 None => {
4145 let base_len = symbol.len().saturating_sub(USD.len());
4146 symbol
4147 .get(base_len..)
4148 .filter(|suffix| suffix.eq_ignore_ascii_case(USD))
4149 .and_then(|_| symbol.get(..base_len))
4150 }
4151 };
4152 match base {
4153 Some(base) if !base.is_empty() => base,
4154 _ => {
4155 warn!(
4156 %symbol,
4157 "Alpaca crypto position symbol is neither ASSETUSD nor BASE/QUOTE; using it whole"
4158 );
4159 symbol
4160 }
4161 }
4162}
4163
4164fn is_crypto_position(position: &AlpacaPosition) -> bool {
4167 position.asset_class.eq_ignore_ascii_case("crypto")
4168}
4169
4170fn convert_positions(
4192 positions: &[AlpacaPosition],
4193 now: DateTime<Utc>,
4194) -> Result<Vec<(&str, Position)>, UnindexedClientError> {
4195 positions
4196 .iter()
4197 .filter(|p| !is_crypto_position(p) && !p.qty.is_zero())
4198 .map(|p| {
4199 let size = p.qty.abs();
4200 let quantity = match p.side {
4201 AlpacaPositionSide::Long => size,
4202 AlpacaPositionSide::Short => -size,
4203 AlpacaPositionSide::Unknown => {
4204 return Err(UnindexedClientError::Internal(format!(
4205 "Alpaca position {} has a missing or unrecognised side",
4206 p.symbol
4207 )));
4208 }
4209 };
4210 let position = Position::new(
4211 quantity,
4212 p.avg_entry_price,
4213 p.unrealized_pl,
4214 None,
4215 None,
4216 None,
4217 None,
4218 now,
4219 );
4220 Ok((p.symbol.as_str(), position))
4221 })
4222 .collect()
4223}
4224
4225fn build_instrument_snapshots(
4246 orders: Vec<AlpacaOrderResponse>,
4247 positions: Vec<(&str, Position)>,
4248 instruments: &[InstrumentNameExchange],
4249) -> Vec<InstrumentAccountSnapshot<ExchangeId, AssetNameExchange, InstrumentNameExchange>> {
4250 struct Listing {
4252 orders: Vec<UnindexedOrderSnapshot>,
4253 complete: bool,
4254 position: Option<Position>,
4255 }
4256
4257 impl Listing {
4258 fn empty() -> Self {
4259 Self {
4260 orders: Vec::new(),
4261 complete: true,
4262 position: None,
4263 }
4264 }
4265 }
4266
4267 let mut by_symbol: IndexMap<SmolStr, Listing> = IndexMap::new();
4269
4270 for order in orders {
4273 let listing = by_symbol
4274 .entry(order.symbol.to_ascii_uppercase().into())
4275 .or_insert_with(Listing::empty);
4276 match convert_open_order(&order) {
4277 Some(converted) => listing.orders.push(converted.into()),
4278 None => listing.complete = false,
4279 }
4280 }
4281
4282 for (symbol, position) in positions {
4283 by_symbol
4284 .entry(symbol.to_ascii_uppercase().into())
4285 .or_insert_with(Listing::empty)
4286 .position = Some(position);
4287 }
4288
4289 let snapshot = |instrument: InstrumentNameExchange, listing: Listing| {
4290 let position = if is_options_or_equity_symbol(instrument.name().as_str()) {
4293 listing
4294 .position
4295 .map_or(PositionReport::Flat, PositionReport::from_position)
4296 } else {
4297 PositionReport::Unreported
4298 };
4299 InstrumentAccountSnapshot::new(instrument, listing.orders, listing.complete, position, None)
4300 };
4301
4302 if instruments.is_empty() {
4304 by_symbol
4305 .into_iter()
4306 .map(|(sym, listing)| snapshot(InstrumentNameExchange::new(sym), listing))
4307 .collect()
4308 } else {
4309 let mut claimed = std::collections::HashSet::new();
4311 instruments
4312 .iter()
4313 .map(|inst| {
4314 let symbol = inst.name().as_str().to_ascii_uppercase();
4315 if !claimed.insert(symbol.clone()) {
4318 return InstrumentAccountSnapshot::new(
4319 inst.clone(),
4320 Vec::new(),
4321 false,
4322 PositionReport::Unreported,
4323 None,
4324 );
4325 }
4326 let listing = by_symbol
4329 .swap_remove(symbol.as_str())
4330 .unwrap_or_else(Listing::empty);
4331 snapshot(inst.clone(), listing)
4332 })
4333 .collect()
4334 }
4335}
4336
4337fn convert_open_order(
4347 o: &AlpacaOrderResponse,
4348) -> Option<Order<ExchangeId, InstrumentNameExchange, Open>> {
4349 let converted = convert_representable_open_order(o);
4350 if converted.is_none() {
4351 warn!(
4352 order_id = %o.id,
4353 symbol = %o.symbol,
4354 qty = ?o.qty,
4355 side = %o.side,
4356 order_type = %o.order_type,
4357 "Alpaca open order cannot be represented - leaving it out"
4358 );
4359 }
4360 converted
4361}
4362
4363fn alpaca_cid(client_order_id: Option<&str>, order_id: &str) -> ClientOrderId {
4369 ClientOrderId::new(client_order_id.unwrap_or(order_id))
4370}
4371
4372fn convert_representable_open_order(
4374 o: &AlpacaOrderResponse,
4375) -> Option<Order<ExchangeId, InstrumentNameExchange, Open>> {
4376 let order_id = OrderId(SmolStr::new(&o.id));
4377 let cid = alpaca_cid(o.client_order_id.as_deref(), &o.id);
4378
4379 let instrument = InstrumentNameExchange::new(&o.symbol);
4380 let side = parse_side(&o.side)?;
4381 let quantity = Decimal::from_str(o.qty.as_deref().unwrap_or("0")).ok()?;
4382 if quantity.is_zero() {
4386 return None;
4387 }
4388 let price = o
4389 .limit_price
4390 .as_deref()
4391 .and_then(|s| Decimal::from_str(s).ok());
4392 let filled_qty = alpaca_filled_qty(&o.id, Some(&o.filled_qty)).unwrap_or(Decimal::ZERO);
4393 let kind = parse_order_kind(
4394 &o.order_type,
4395 o.stop_price.as_deref(),
4396 o.trail_percent.as_deref(),
4397 o.trail_price.as_deref(),
4398 )?;
4399 let time_in_force = parse_time_in_force(&o.time_in_force);
4400 let time_exchange = order_state_time(o);
4401
4402 Some(Order {
4403 key: OrderKey::new(
4404 ExchangeId::AlpacaBroker,
4405 instrument,
4406 StrategyId::unknown(),
4408 cid,
4409 ),
4410 side,
4411 price,
4412 quantity,
4413 kind,
4414 time_in_force,
4415 state: Open::new(VenueOrderId::Assigned(order_id), time_exchange, filled_qty),
4416 })
4417}
4418
4419fn convert_activity_to_trade(
4421 a: &AlpacaActivity,
4422) -> Option<Trade<AssetNameExchange, InstrumentNameExchange>> {
4423 let trade_id = TradeId::new(activity_execution_id(&a.id));
4424 let order_id = OrderId(SmolStr::new(&a.order_id));
4425 let instrument = InstrumentNameExchange::new(&a.symbol);
4426 let side = parse_side(&a.side)?;
4427 let price = Decimal::from_str(&a.price).ok()?;
4428 let quantity = Decimal::from_str(&a.qty).ok()?;
4429 let order_filled_quantity = a.cum_qty.as_deref().and_then(|s| Decimal::from_str(s).ok());
4432 let time_exchange = parse_timestamp(&a.transaction_time).unwrap_or_else(|| {
4433 warn!(id = %a.id, "Alpaca activity: unparseable transaction_time, using now");
4434 Utc::now()
4435 });
4436
4437 Some(Trade::new(
4442 trade_id,
4443 order_id,
4444 instrument,
4445 StrategyId::unknown(),
4446 time_exchange,
4447 side,
4448 price,
4449 quantity,
4450 order_filled_quantity,
4451 AssetFees::new(
4452 AssetNameExchange::from(USD),
4453 Decimal::ZERO,
4454 Some(Decimal::ZERO),
4455 ),
4456 ))
4457}
4458
4459fn ws_order_snapshot(
4469 order: &AlpacaOrderWs<'_>,
4470 instrument: InstrumentNameExchange,
4471 cid: ClientOrderId,
4472 order_id: OrderId,
4473 time_exchange: DateTime<Utc>,
4474) -> Option<UnindexedAccountEvent> {
4475 let side = parse_side(&order.side)?;
4476 let quantity = Decimal::from_str(order.qty.unwrap_or("0")).unwrap_or(Decimal::ZERO);
4477 if quantity.is_zero() {
4478 trace!(order_id = %order.id, "Alpaca WS: skipping notional order snapshot (qty=None)");
4479 return None;
4480 }
4481 let price = order.limit_price.and_then(|s| Decimal::from_str(s).ok());
4482 let filled_qty = order
4485 .filled_qty
4486 .and_then(|_| alpaca_filled_qty(&order.id, order.filled_qty))
4487 .unwrap_or(Decimal::ZERO);
4488 let kind = parse_order_kind(
4489 &order.order_type,
4490 order.stop_price,
4491 order.trail_percent,
4492 order.trail_price,
4493 )?;
4494 let time_in_force = parse_time_in_force(&order.time_in_force);
4495
4496 let order_snapshot = crate::order::Order {
4497 key: OrderKey::new(
4498 ExchangeId::AlpacaBroker,
4499 instrument,
4500 StrategyId::unknown(),
4501 cid,
4502 ),
4503 side,
4504 price,
4505 quantity,
4506 kind,
4507 time_in_force,
4508 state: OrderState::active(Open::new(
4509 VenueOrderId::Assigned(order_id),
4510 time_exchange,
4511 filled_qty,
4512 )),
4513 };
4514 Some(UnindexedAccountEvent::new(
4515 ExchangeId::AlpacaBroker,
4516 AccountEventKind::OrderSnapshot(rustrade_integration::collection::snapshot::Snapshot(
4517 order_snapshot,
4518 )),
4519 ))
4520}
4521
4522fn ws_fill_order_is_live(status: &str) -> bool {
4532 matches!(status, "partially_filled" | "filled")
4533}
4534
4535fn trade_amendment_event(
4547 update: &AlpacaTradeUpdate<'_>,
4548 instrument: InstrumentNameExchange,
4549 order_id: OrderId,
4550) -> UnindexedAccountEvent {
4551 let order = &update.order;
4552 let time_exchange = update
4553 .timestamp
4554 .and_then(parse_timestamp)
4555 .unwrap_or_else(Utc::now);
4556 let original = update
4557 .previous_execution_id
4558 .filter(|id| !id.is_empty())
4559 .map(TradeId::new);
4560 let quantity = update.qty.and_then(|s| Decimal::from_str(s).ok());
4561
4562 let kind = if update.event == "trade_bust" {
4563 TradeAmendmentKind::Busted {
4564 quantity: quantity.map(|quantity| quantity.abs()),
4565 }
4566 } else {
4567 let id = ws_execution_id(update).map(TradeId::new);
4568 let price = update.price.and_then(|s| Decimal::from_str(s).ok());
4569 match (id, price, quantity, parse_side(&order.side)) {
4570 (Some(id), Some(price), Some(quantity), Some(side)) if quantity > Decimal::ZERO => {
4571 TradeAmendmentKind::Corrected {
4572 replacement: with_reported_cid(
4573 Trade::new(
4574 id,
4575 order_id.clone(),
4576 instrument.clone(),
4577 StrategyId::unknown(),
4578 time_exchange,
4579 side,
4580 price,
4581 quantity,
4582 None,
4585 AssetFees::new(
4587 AssetNameExchange::from(USD),
4588 Decimal::ZERO,
4589 Some(Decimal::ZERO),
4590 ),
4591 ),
4592 order.client_order_id.as_ref(),
4593 ),
4594 }
4595 }
4596 (id, price, quantity, _) => TradeAmendmentKind::CorrectedUnresolved {
4597 id,
4598 price,
4599 quantity,
4600 },
4601 }
4602 };
4603
4604 UnindexedAccountEvent::new(
4605 ExchangeId::AlpacaBroker,
4606 AccountEventKind::TradeAmended(crate::trade::TradeAmendment::new(
4607 instrument,
4608 order_id,
4609 time_exchange,
4610 original,
4611 kind,
4612 )),
4613 )
4614}
4615
4616fn convert_trade_update(update: AlpacaTradeUpdate<'_>) -> [Option<UnindexedAccountEvent>; 2] {
4626 let event_str = update.event.as_str();
4629 if !matches!(
4630 event_str,
4631 "fill"
4632 | "partial_fill"
4633 | "new"
4634 | "accepted"
4635 | "pending_new"
4636 | "canceled"
4637 | "expired"
4638 | "replaced"
4639 | "done_for_day"
4640 | "rejected"
4641 | "trade_bust"
4642 | "trade_correct"
4643 ) {
4644 trace!(event = %event_str, "Alpaca WS: ignoring trade_updates event type");
4645 return [None, None];
4646 }
4647
4648 let order = &update.order;
4649 let instrument = InstrumentNameExchange::new(&*order.symbol);
4650 let order_id = OrderId(order.id.clone());
4651 let cid = alpaca_cid(order.client_order_id.as_deref(), &order.id);
4652
4653 match event_str {
4654 "fill" | "partial_fill" => {
4655 let (Some(price), Some(quantity), Some(side)) = (
4657 update.price.and_then(|s| Decimal::from_str(s).ok()),
4658 update.qty.and_then(|s| Decimal::from_str(s).ok()),
4659 parse_side(&order.side),
4660 ) else {
4661 return [None, None];
4662 };
4663 let time_exchange = update
4664 .timestamp
4665 .and_then(parse_timestamp)
4666 .unwrap_or_else(Utc::now);
4667
4668 let cum_qty = Decimal::from_str(order.filled_qty.unwrap_or("0"))
4673 .inspect_err(|e| {
4674 warn!(
4675 order_id = %order.id,
4676 filled_qty = ?order.filled_qty,
4677 %e,
4678 "Alpaca WS: failed to parse filled_qty — the fill reports a cumulative \
4679 of 0, and without an execution id a second such fill on the same order \
4680 would be deduplicated away"
4681 );
4682 })
4683 .unwrap_or(Decimal::ZERO);
4684 let trade_id = match ws_execution_id(&update) {
4688 Some(execution_id) => TradeId::new(execution_id),
4689 None => {
4690 warn!(
4691 order_id = %order.id,
4692 "Alpaca WS: fill without a usable execution_id — its TradeId is \
4693 \"{{order_id}}:{{filled_qty}}\", which fetch_trades will not match"
4694 );
4695 TradeId(fill_dedup_key(&order.id, cum_qty))
4696 }
4697 };
4698
4699 let trade = Trade::new(
4703 trade_id,
4704 order_id.clone(),
4705 instrument.clone(),
4706 StrategyId::unknown(),
4707 time_exchange,
4708 side,
4709 price,
4710 quantity,
4711 Some(cum_qty),
4713 AssetFees::new(
4714 AssetNameExchange::from(USD),
4715 Decimal::ZERO,
4716 Some(Decimal::ZERO),
4717 ),
4718 );
4719 let trade = with_reported_cid(trade, order.client_order_id.as_ref());
4720 let trade_event = UnindexedAccountEvent::new(
4721 ExchangeId::AlpacaBroker,
4722 AccountEventKind::Trade(trade),
4723 );
4724
4725 let snapshot_event = if ws_fill_order_is_live(&order.status) {
4729 ws_order_snapshot(order, instrument, cid, order_id, time_exchange)
4730 } else {
4731 trace!(
4732 order_id = %order.id,
4733 status = %order.status,
4734 "Alpaca WS: fill for an order the exchange no longer reports as working — \
4735 emitting the execution without an order snapshot"
4736 );
4737 None
4738 };
4739
4740 [Some(trade_event), snapshot_event]
4741 }
4742
4743 "new" | "accepted" | "pending_new" | "done_for_day" => {
4744 let time_exchange = update
4748 .timestamp
4749 .and_then(parse_timestamp)
4750 .unwrap_or_else(Utc::now);
4751
4752 [
4753 ws_order_snapshot(order, instrument, cid, order_id, time_exchange),
4754 None,
4755 ]
4756 }
4757
4758 "canceled" | "expired" | "replaced" => {
4759 let time_exchange = update
4767 .timestamp
4768 .and_then(parse_timestamp)
4769 .unwrap_or_else(Utc::now);
4770 let filled_qty = alpaca_filled_qty(&order.id, order.filled_qty);
4771 let cancelled = Cancelled::new(order_id, time_exchange, filled_qty);
4772 let response = crate::order::request::OrderResponseCancel {
4773 key: OrderKey::new(
4774 ExchangeId::AlpacaBroker,
4775 instrument,
4776 StrategyId::unknown(),
4777 cid,
4778 ),
4779 state: Ok(cancelled),
4780 };
4781 [
4782 Some(UnindexedAccountEvent::new(
4783 ExchangeId::AlpacaBroker,
4784 AccountEventKind::OrderCancelled(response),
4785 )),
4786 None,
4787 ]
4788 }
4789
4790 "trade_bust" | "trade_correct" => [
4791 Some(trade_amendment_event(&update, instrument, order_id)),
4792 None,
4793 ],
4794
4795 "rejected" => {
4796 let response = crate::order::request::OrderResponseCancel {
4797 key: OrderKey::new(
4798 ExchangeId::AlpacaBroker,
4799 instrument,
4800 StrategyId::unknown(),
4801 cid,
4802 ),
4803 state: Err(UnindexedOrderError::Rejected(ApiError::OrderRejected(
4804 format!("order rejected: status={}", order.status),
4805 ))),
4806 };
4807 [
4808 Some(UnindexedAccountEvent::new(
4809 ExchangeId::AlpacaBroker,
4810 AccountEventKind::OrderCancelled(response),
4811 )),
4812 None,
4813 ]
4814 }
4815
4816 _ => unreachable!("convert_trade_update: unrecognised event passed early-return guard"),
4819 }
4820}
4821
4822fn parse_side(s: &str) -> Option<Side> {
4827 match s {
4828 "buy" | "Buy" | "BUY" => Some(Side::Buy),
4829 "sell" | "Sell" | "SELL" => Some(Side::Sell),
4830 other => {
4831 trace!(%other, "Alpaca: unknown order side");
4832 None
4833 }
4834 }
4835}
4836
4837fn parse_order_kind(
4838 order_type: &str,
4839 stop_price: Option<&str>,
4840 trail_percent: Option<&str>,
4841 trail_price: Option<&str>,
4842) -> Option<OrderKind> {
4843 match order_type {
4844 "market" | "Market" => Some(OrderKind::Market),
4845 "limit" | "Limit" => Some(OrderKind::Limit),
4846 "stop" | "Stop" => {
4847 let trigger_price = stop_price.and_then(|s| Decimal::from_str(s).ok())?;
4848 Some(OrderKind::Stop { trigger_price })
4849 }
4850 "stop_limit" | "Stop_limit" => {
4851 let trigger_price = stop_price.and_then(|s| Decimal::from_str(s).ok())?;
4852 Some(OrderKind::StopLimit { trigger_price })
4853 }
4854 "trailing_stop" | "Trailing_stop" => {
4855 if let Some(pct) = trail_percent.and_then(|s| Decimal::from_str(s).ok()) {
4857 Some(OrderKind::TrailingStop {
4858 offset: pct,
4859 offset_type: TrailingOffsetType::Percentage,
4860 activation_price: None,
4861 })
4862 } else if let Some(price) = trail_price.and_then(|s| Decimal::from_str(s).ok()) {
4863 Some(OrderKind::TrailingStop {
4864 offset: price,
4865 offset_type: TrailingOffsetType::Absolute,
4866 activation_price: None,
4867 })
4868 } else {
4869 trace!("Alpaca: trailing_stop missing trail_percent and trail_price");
4870 None
4871 }
4872 }
4873 other => {
4874 trace!(%other, "Alpaca: unsupported order type, skipping");
4875 None
4876 }
4877 }
4878}
4879
4880fn parse_time_in_force(s: &str) -> TimeInForce {
4881 match s {
4882 "gtc" | "GTC" => TimeInForce::GoodUntilCancelled { post_only: false },
4883 "day" | "DAY" => TimeInForce::GoodUntilEndOfDay,
4884 "fok" | "FOK" => TimeInForce::FillOrKill,
4885 "ioc" | "IOC" => TimeInForce::ImmediateOrCancel,
4886 other => {
4887 warn!(%other, "Alpaca: unknown time_in_force, defaulting to GoodUntilEndOfDay");
4888 TimeInForce::GoodUntilEndOfDay
4889 }
4890 }
4891}
4892
4893fn order_state_time(order: &AlpacaOrderResponse) -> DateTime<Utc> {
4903 order
4904 .updated_at
4905 .as_deref()
4906 .and_then(parse_timestamp)
4907 .or_else(|| parse_timestamp(&order.created_at))
4908 .unwrap_or_else(Utc::now)
4909}
4910
4911fn alpaca_filled_qty(order_id: &str, filled_qty: Option<&str>) -> Option<Decimal> {
4914 let Some(raw) = filled_qty else {
4915 warn!(%order_id, "Alpaca did not report how much the order filled");
4916 return None;
4917 };
4918 match Decimal::from_str(raw) {
4919 Ok(filled_qty) => Some(filled_qty),
4920 Err(_) => {
4921 warn!(%order_id, filled_qty = raw, "Alpaca reported an unparseable filled_qty, treating it as unknown");
4922 None
4923 }
4924 }
4925}
4926
4927fn convert_cash_flow(
4930 activity: &AlpacaNonTradeActivity,
4931) -> Option<CashFlow<AssetNameExchange, InstrumentNameExchange>> {
4932 let id = activity.id.as_str();
4933 if activity.status.as_deref() == Some("canceled") {
4934 info!(id, activity_type = %activity.activity_type, "Alpaca activity canceled — left out");
4935 return None;
4936 }
4937 let Some(amount) = activity
4938 .net_amount
4939 .as_deref()
4940 .and_then(|amount| Decimal::from_str(amount).ok())
4941 else {
4942 warn!(id, net_amount = ?activity.net_amount, "Alpaca activity missing/unparseable net_amount — left out");
4943 return None;
4944 };
4945 let Some(time_exchange) = activity.time() else {
4946 warn!(
4947 id,
4948 created_at = ?activity.created_at,
4949 date = ?activity.date,
4950 "Alpaca activity missing/unparseable time — left out"
4951 );
4952 return None;
4953 };
4954 let sub_type = activity
4955 .activity_sub_type
4956 .as_deref()
4957 .filter(|sub_type| !sub_type.is_empty());
4958 let kind = match (activity.activity_type.as_str(), sub_type) {
4959 ("INT", Some("MGN")) => CashFlowKind::MarginInterest {
4960 rate: None,
4961 principal: None,
4962 },
4963 ("INT", None) if amount.is_sign_negative() => CashFlowKind::MarginInterest {
4965 rate: None,
4966 principal: None,
4967 },
4968 ("PTR", _) => CashFlowKind::Rebate,
4969 (activity_type, Some(sub_type)) => {
4970 CashFlowKind::Other(format!("{activity_type}.{sub_type}"))
4971 }
4972 (activity_type, None) => CashFlowKind::Other(activity_type.to_owned()),
4973 };
4974 Some(CashFlow::new(
4975 kind,
4976 AssetNameExchange::new(
4977 activity
4978 .currency
4979 .as_deref()
4980 .filter(|currency| !currency.is_empty())
4981 .unwrap_or("USD"),
4982 ),
4983 amount,
4984 activity
4985 .symbol
4986 .as_deref()
4987 .filter(|symbol| !symbol.is_empty())
4988 .map(InstrumentNameExchange::new),
4989 time_exchange,
4990 Some(CashFlowId::new(id)),
4991 ))
4992}
4993
4994struct OptionLeg<'a> {
4996 id: &'a str,
4997 symbol: &'a str,
4998 quantity: Decimal,
5000 price: Option<Decimal>,
5002 date: Option<NaiveDate>,
5003 time: DateTime<Utc>,
5004 created: bool,
5006 origin: Option<TradeOrigin>,
5008 contract: Option<(&'a str, bool, Decimal)>,
5010}
5011
5012impl OptionLeg<'_> {
5013 fn is_within(&self, start: DateTime<Utc>, end: DateTime<Utc>) -> bool {
5016 if self.created {
5017 (start..=end).contains(&self.time)
5018 } else {
5019 (start.date_naive()..=end.date_naive()).contains(&self.time.date_naive())
5020 }
5021 }
5022}
5023
5024fn option_leg(activity: &AlpacaNonTradeActivity) -> Option<OptionLeg<'_>> {
5027 let id = activity.id.as_str();
5028 let activity_type = activity.activity_type.as_str();
5029 if activity.status.as_deref() == Some("canceled") {
5030 info!(
5031 id,
5032 activity_type, "Alpaca option activity canceled — left out"
5033 );
5034 return None;
5035 }
5036 let origin = match activity_type {
5037 "OPEXP" => Some(TradeOrigin::Expiry),
5038 "OPEXC" => Some(TradeOrigin::Exercise),
5039 "OPASN" => Some(TradeOrigin::Assignment),
5040 "OPTRD" => None,
5041 _ => {
5042 warn!(
5043 id,
5044 activity_type, "Alpaca activity is not an option event — left out"
5045 );
5046 return None;
5047 }
5048 };
5049 let date = activity
5050 .date
5051 .as_deref()
5052 .and_then(|date| NaiveDate::parse_from_str(date, "%Y-%m-%d").ok());
5053 let created_at = activity.created_at.as_deref().and_then(parse_timestamp);
5054 let time = created_at.or_else(|| date.map(|date| date.and_time(NaiveTime::MIN).and_utc()));
5055 let symbol = activity
5056 .symbol
5057 .as_deref()
5058 .filter(|symbol| !symbol.is_empty());
5059 let quantity = activity
5060 .qty
5061 .as_deref()
5062 .and_then(|qty| Decimal::from_str(qty).ok())
5063 .filter(|qty| !qty.is_zero());
5064 let price = activity
5065 .price
5066 .as_deref()
5067 .and_then(|price| Decimal::from_str(price).ok());
5068 let (Some(symbol), Some(quantity), Some(time)) = (symbol, quantity, time) else {
5069 warn!(
5070 id,
5071 activity_type,
5072 symbol = ?activity.symbol,
5073 qty = ?activity.qty,
5074 created_at = ?activity.created_at,
5075 date = ?activity.date,
5076 "Alpaca option activity missing/unparseable symbol, quantity or time — left out"
5077 );
5078 return None;
5079 };
5080 if origin.is_none() && price.is_none() {
5081 warn!(id, price = ?activity.price, "Alpaca OPTRD missing/unparseable price — left out");
5082 return None;
5083 }
5084 let contract = matches!(
5085 origin,
5086 Some(TradeOrigin::Exercise | TradeOrigin::Assignment)
5087 )
5088 .then(|| occ_contract(symbol))
5089 .flatten();
5090 Some(OptionLeg {
5091 id,
5092 symbol,
5093 quantity,
5094 price,
5095 date,
5096 time,
5097 created: created_at.is_some(),
5098 origin,
5099 contract,
5100 })
5101}
5102
5103fn occ_contract(symbol: &str) -> Option<(&str, bool, Decimal)> {
5106 let split = symbol.len().checked_sub(15)?;
5107 let (root, tail) = (
5108 symbol.get(..split)?.trim_end(),
5109 symbol.get(split..)?.as_bytes(),
5110 );
5111 let is_call = match tail[6] {
5112 b'C' => true,
5113 b'P' => false,
5114 _ => return None,
5115 };
5116 if root.is_empty() || !tail[..6].iter().chain(&tail[7..]).all(u8::is_ascii_digit) {
5117 return None;
5118 }
5119 let strike = std::str::from_utf8(&tail[7..]).ok()?.parse::<i64>().ok()?;
5120 Some((root, is_call, Decimal::new(strike, 3)))
5121}
5122
5123fn is_share_leg_of(shares: &OptionLeg<'_>, contract: &OptionLeg<'_>) -> bool {
5126 let Some((root, is_call, strike)) = contract.contract else {
5127 return false;
5128 };
5129 let buys = match contract.origin {
5131 Some(TradeOrigin::Exercise) => is_call,
5132 Some(TradeOrigin::Assignment) => !is_call,
5133 _ => return false,
5134 };
5135 shares.date.is_some()
5136 && shares.date == contract.date
5137 && shares.symbol == root
5138 && shares.price == Some(strike)
5139 && shares.quantity.is_sign_positive() == buys
5140}
5141
5142fn convert_option_events(
5146 activities: &[AlpacaNonTradeActivity],
5147 start: DateTime<Utc>,
5148 end: DateTime<Utc>,
5149) -> Vec<Trade<AssetNameExchange, InstrumentNameExchange>> {
5150 let legs: Vec<_> = activities.iter().filter_map(option_leg).collect();
5151 let mut paired = vec![false; legs.len()];
5152 legs.iter()
5153 .filter_map(|leg| {
5154 let origin = leg.origin.unwrap_or_else(|| {
5157 let pairs = |index: &usize| !paired[*index] && is_share_leg_of(leg, &legs[*index]);
5158 let delivers_its_shares = |index: &usize| {
5162 legs[*index].quantity.abs() * Decimal::ONE_HUNDRED == leg.quantity.abs()
5163 };
5164 let partner = (0..legs.len())
5165 .filter(pairs)
5166 .find(delivers_its_shares)
5167 .or_else(|| (0..legs.len()).find(pairs));
5168 match partner {
5169 Some(index) => {
5170 paired[index] = true;
5171 legs[index].origin.unwrap_or(TradeOrigin::External)
5172 }
5173 None => {
5174 warn!(
5175 id = leg.id,
5176 symbol = leg.symbol,
5177 date = ?leg.date,
5178 "Alpaca OPTRD pairs with no exercise or assignment read — reported as \
5179 External"
5180 );
5181 TradeOrigin::External
5182 }
5183 }
5184 });
5185 if !leg.is_within(start, end) {
5186 return None;
5187 }
5188 let side = if leg.quantity.is_sign_negative() {
5189 Side::Sell
5190 } else {
5191 Side::Buy
5192 };
5193 let trade = Trade::new(
5194 TradeId::new(leg.id),
5195 OrderId::new(leg.id),
5196 InstrumentNameExchange::new(leg.symbol),
5197 StrategyId::unknown(),
5198 leg.time,
5199 side,
5200 leg.price.unwrap_or(Decimal::ZERO),
5201 leg.quantity.abs(),
5202 None,
5203 AssetFees::new(
5204 AssetNameExchange::from(USD),
5205 Decimal::ZERO,
5206 Some(Decimal::ZERO),
5207 ),
5208 )
5209 .with_origin(origin);
5210 Some(trade)
5211 })
5212 .collect()
5213}
5214
5215fn with_reported_cid<AssetKey, InstrumentKey>(
5217 trade: Trade<AssetKey, InstrumentKey>,
5218 client_order_id: Option<&SmolStr>,
5219) -> Trade<AssetKey, InstrumentKey> {
5220 match client_order_id {
5221 Some(cid) if !cid.is_empty() => trade.with_client_order_id(ClientOrderId(cid.clone())),
5223 _ => trade,
5224 }
5225}
5226
5227fn parse_timestamp(s: &str) -> Option<DateTime<Utc>> {
5228 DateTime::parse_from_rfc3339(s)
5229 .ok()
5230 .map(|dt| dt.with_timezone(&Utc))
5231}
5232
5233fn map_side(side: Side) -> &'static str {
5234 match side {
5235 Side::Buy => "buy",
5236 Side::Sell => "sell",
5237 }
5238}
5239
5240fn map_order_kind(kind: OrderKind) -> Option<&'static str> {
5241 match kind {
5242 OrderKind::Market => Some("market"),
5243 OrderKind::Limit => Some("limit"),
5244 OrderKind::Stop { .. } => Some("stop"),
5245 OrderKind::StopLimit { .. } => Some("stop_limit"),
5246 OrderKind::TrailingStop {
5247 activation_price: None,
5248 ..
5249 } => Some("trailing_stop"),
5250 OrderKind::TakeProfit { .. }
5253 | OrderKind::TakeProfitLimit { .. }
5254 | OrderKind::TrailingStop {
5255 activation_price: Some(_),
5256 ..
5257 }
5258 | OrderKind::TrailingStopLimit { .. }
5259 | OrderKind::TrailingStopFixedLimit { .. } => None,
5260 }
5261}
5262
5263fn map_time_in_force(tif: TimeInForce) -> Result<&'static str, &'static str> {
5272 match tif {
5273 TimeInForce::GoodUntilCancelled { post_only } => {
5274 if post_only {
5275 return Err("Alpaca does not support post_only orders");
5276 }
5277 Ok("gtc")
5278 }
5279 TimeInForce::GoodUntilEndOfDay => Ok("day"),
5280 TimeInForce::FillOrKill => Ok("fok"),
5281 TimeInForce::ImmediateOrCancel => Ok("ioc"),
5282 TimeInForce::AtOpen => Ok("opg"),
5283 TimeInForce::AtClose => Ok("cls"),
5284 TimeInForce::GoodTillDate { .. } => {
5288 Err("Alpaca GoodTillDate is not yet wired through this client")
5289 }
5290 }
5291}
5292
5293fn is_options_or_equity_symbol(symbol: &str) -> bool {
5304 !symbol.contains('/')
5305}
5306
5307fn map_position_intent(side: Side, reduce_only: bool) -> AlpacaPositionIntent {
5316 match (reduce_only, side) {
5317 (false, Side::Buy) => AlpacaPositionIntent::BuyToOpen,
5318 (false, Side::Sell) => AlpacaPositionIntent::SellToOpen,
5319 (true, Side::Buy) => AlpacaPositionIntent::BuyToClose,
5320 (true, Side::Sell) => AlpacaPositionIntent::SellToClose,
5321 }
5322}
5323
5324fn parse_api_error(
5335 status: reqwest::StatusCode,
5336 code: Option<i64>,
5337 message: &str,
5338) -> crate::error::UnindexedApiError {
5339 if status == reqwest::StatusCode::TOO_MANY_REQUESTS {
5342 return ApiError::rate_limited(None);
5343 }
5344
5345 let lower = message.to_ascii_lowercase();
5347 match status.as_u16() {
5348 422 if lower.contains("already") => ApiError::OrderAlreadyCancelled,
5352 422 => parse_business_refusal(code, message, &lower),
5353 401 => ApiError::Unauthenticated(format!("unauthorized: {message}")),
5354 403 => match code {
5361 None => ApiError::Unauthenticated(format!("forbidden: {message}")),
5362 Some(_) => parse_business_refusal(code, message, &lower),
5363 },
5364 404 => ApiError::OrderRejected(format!("order not found: {message}")),
5365 _ => ApiError::OrderRejected(with_alpaca_code(code, message)),
5366 }
5367}
5368
5369fn parse_business_refusal(code: Option<i64>, message: &str, lower: &str) -> UnindexedApiError {
5383 let borrow = if lower.contains("not allowed to short") {
5384 Some(BorrowRejectReason::BorrowDisabled)
5385 } else if lower.contains("cannot be sold short") && !lower.contains("fractional") {
5386 Some(BorrowRejectReason::NotShortable)
5387 } else {
5388 None
5389 };
5390 if let Some(reason) = borrow {
5391 ApiError::BorrowRejected(BorrowReject::new(
5392 reason,
5393 code.map(|code| code.to_string()),
5394 message.to_owned(),
5395 ))
5396 } else if lower.contains("insufficient") {
5397 ApiError::BalanceInsufficient(None, with_alpaca_code(code, message))
5398 } else {
5399 ApiError::OrderRejected(with_alpaca_code(code, message))
5400 }
5401}
5402
5403fn with_alpaca_code(code: Option<i64>, message: &str) -> String {
5405 match code {
5406 Some(code) => format!("{code} {message}"),
5407 None => message.to_owned(),
5408 }
5409}
5410
5411fn parse_order_error(
5413 status: reqwest::StatusCode,
5414 code: Option<i64>,
5415 message: &str,
5416) -> UnindexedOrderError {
5417 UnindexedOrderError::Rejected(parse_api_error(status, code, message))
5418}
5419
5420fn connectivity_err(msg: impl Into<String>) -> UnindexedClientError {
5421 UnindexedClientError::Connectivity(ConnectivityError::Socket(msg.into()))
5422}
5423
5424fn order_post_error(error: UnindexedClientError) -> UnindexedOrderError {
5431 match error {
5432 UnindexedClientError::Connectivity(ce) => OrderError::Connectivity(ce),
5433 UnindexedClientError::Api(ae) => OrderError::Rejected(ae),
5434 UnindexedClientError::Internal(msg) => OrderError::Connectivity(ConnectivityError::Socket(
5435 format!("Alpaca order status unknown, its 2xx response could not be read: {msg}"),
5436 )),
5437 other @ (UnindexedClientError::TaskFailed(_)
5441 | UnindexedClientError::Truncated { .. }
5442 | UnindexedClientError::TruncatedSnapshot { .. }) => OrderError::Connectivity(
5443 ConnectivityError::Socket(format!("Alpaca order status unknown: {other}")),
5444 ),
5445 }
5446}
5447
5448impl OrderStatusClient for AlpacaClient {
5453 async fn fetch_ended_orders(
5461 &self,
5462 orders: &[UnindexedOrderKey],
5463 ) -> Result<Vec<UnindexedInactiveOrder>, UnindexedClientError> {
5464 fetch_ended_by_key(orders, |key| {
5465 fetch_order_lookup(
5466 self.http.clone(),
5467 self.rate_limiter.clone(),
5468 self.config.clone(),
5469 key,
5470 )
5471 })
5472 .await
5473 }
5474}
5475
5476impl ShortabilityClient for AlpacaClient {
5481 async fn fetch_shortability(
5500 &self,
5501 instrument: &InstrumentNameExchange,
5502 ) -> Result<Shortability, UnindexedClientError> {
5503 let url = asset_url(self.base_url(), instrument)?;
5504 let found: Option<AlpacaAsset> =
5505 rest_lookup_with_retry(&self.rate_limiter, || self.http.get(url.clone())).await?;
5506 let Some(asset) = found else {
5507 return Err(instrument_invalid(
5508 instrument,
5509 "Alpaca knows no asset under this symbol",
5510 ));
5511 };
5512 shortability_from_asset(instrument, &asset)
5513 }
5514}
5515
5516fn asset_url(
5519 base_url: &str,
5520 instrument: &InstrumentNameExchange,
5521) -> Result<reqwest::Url, UnindexedClientError> {
5522 let mut url = reqwest::Url::parse(base_url).map_err(|error| {
5523 UnindexedClientError::Internal(format!("Alpaca base URL {base_url:?}: {error}"))
5524 })?;
5525 url.path_segments_mut()
5526 .map_err(|()| {
5527 UnindexedClientError::Internal(format!("Alpaca base URL {base_url:?} has no path"))
5528 })?
5529 .pop_if_empty()
5530 .extend(["v2", "assets", instrument.name().as_str()]);
5531 Ok(url)
5532}
5533
5534fn instrument_invalid(instrument: &InstrumentNameExchange, reason: &str) -> UnindexedClientError {
5535 UnindexedClientError::Api(ApiError::InstrumentInvalid(
5536 instrument.clone(),
5537 reason.to_owned(),
5538 ))
5539}
5540
5541fn shortability_from_asset(
5543 instrument: &InstrumentNameExchange,
5544 asset: &AlpacaAsset,
5545) -> Result<Shortability, UnindexedClientError> {
5546 if asset.class == AlpacaAssetClass::UsOption {
5547 return Err(instrument_invalid(
5548 instrument,
5549 "shortability does not apply to an option: writing one borrows nothing",
5550 ));
5551 }
5552 let easy_to_borrow = match asset.borrow_status {
5553 Some(AlpacaBorrowStatus::EasyToBorrow) => Some(true),
5554 Some(AlpacaBorrowStatus::HardToBorrow) => Some(false),
5555 Some(AlpacaBorrowStatus::Other) | None => asset.easy_to_borrow,
5556 };
5557 let mut shortability = Shortability::new();
5558 if let Some(shortable) = asset.shortable {
5559 shortability = shortability.with_shortable(shortable);
5560 }
5561 if let Some(easy_to_borrow) = easy_to_borrow {
5562 shortability = shortability.with_easy_to_borrow(easy_to_borrow);
5563 if easy_to_borrow {
5564 shortability = shortability.with_fee_rate(Decimal::ZERO);
5565 }
5566 }
5567 Ok(shortability)
5568}
5569
5570#[derive(Debug, Deserialize)]
5573struct AlpacaAsset {
5574 #[serde(default)]
5575 class: AlpacaAssetClass,
5576 #[serde(default)]
5577 shortable: Option<bool>,
5578 #[serde(default)]
5580 borrow_status: Option<AlpacaBorrowStatus>,
5581 #[serde(default)]
5583 easy_to_borrow: Option<bool>,
5584}
5585
5586#[derive(Debug, Default, PartialEq, Eq, Deserialize)]
5587#[serde(rename_all = "snake_case")]
5588enum AlpacaAssetClass {
5589 UsOption,
5590 #[default]
5591 #[serde(other)]
5592 Other,
5593}
5594
5595#[derive(Debug, PartialEq, Eq, Deserialize)]
5596#[serde(rename_all = "snake_case")]
5597enum AlpacaBorrowStatus {
5598 EasyToBorrow,
5599 HardToBorrow,
5600 #[serde(other)]
5601 Other,
5602}
5603
5604impl BracketOrderClient for AlpacaClient {
5609 async fn open_bracket_order(
5610 &self,
5611 request: UnifiedBracketOrderRequest<ExchangeId, &InstrumentNameExchange>,
5612 ) -> UnifiedBracketOrderResult {
5613 let alpaca_request = AlpacaBracketOrderRequest {
5614 instrument: request.key.instrument.clone(),
5615 strategy: request.key.strategy.clone(),
5616 cid: request.key.cid.clone(),
5617 side: request.state.side,
5618 quantity: request.state.quantity,
5619 entry_price: request.state.entry_price,
5620 take_profit_price: request.state.take_profit_price,
5621 stop_loss_price: request.state.stop_loss_price,
5622 stop_loss_limit_price: request.state.stop_loss_limit_price,
5623 time_in_force: request.state.time_in_force,
5624 };
5625
5626 let result = self.open_bracket_order(alpaca_request).await;
5627
5628 UnifiedBracketOrderResult::parent_only(result.parent)
5629 }
5630}
5631
5632#[cfg(test)]
5637#[allow(clippy::unwrap_used, clippy::expect_used)] mod tests {
5639 use super::*;
5640 use rust_decimal_macros::dec;
5641
5642 #[test]
5643 fn test_alpaca_config_new_uses_paper_trading_by_default() {
5644 let cfg = AlpacaConfig::new("my_key".into(), "my_secret".into());
5645 assert!(cfg.paper);
5646 }
5647
5648 #[test]
5649 fn test_alpaca_config_debug_redacts_credentials() {
5650 let cfg = AlpacaConfig::new("my_key".into(), "my_secret".into());
5651 let debug = format!("{cfg:?}");
5652 assert!(!debug.contains("my_key"), "api_key should be redacted");
5653 assert!(
5654 !debug.contains("my_secret"),
5655 "secret_key should be redacted"
5656 );
5657 assert!(debug.contains("paper: true"));
5658 }
5659
5660 #[test]
5661 fn test_alpaca_config_deserialize_omitted_paper_defaults_to_paper() {
5662 let cfg: AlpacaConfig = serde_json::from_str(
5663 r#"{
5664 "api_key": "my_key",
5665 "secret_key": "my_secret"
5666 }"#,
5667 )
5668 .unwrap();
5669
5670 assert!(cfg.paper);
5671 assert_eq!(cfg.rest_base_url(), "https://paper-api.alpaca.markets");
5672 }
5673
5674 #[test]
5675 fn test_alpaca_config_deserialize_paper_true() {
5676 let cfg: AlpacaConfig = serde_json::from_str(
5677 r#"{
5678 "api_key": "my_key",
5679 "secret_key": "my_secret",
5680 "paper": true
5681 }"#,
5682 )
5683 .unwrap();
5684
5685 assert!(cfg.paper);
5686 assert_eq!(cfg.rest_base_url(), "https://paper-api.alpaca.markets");
5687 }
5688
5689 #[test]
5690 fn test_alpaca_config_deserialize_paper_false() {
5691 let cfg: AlpacaConfig = serde_json::from_str(
5692 r#"{
5693 "api_key": "my_key",
5694 "secret_key": "my_secret",
5695 "paper": false
5696 }"#,
5697 )
5698 .unwrap();
5699
5700 assert!(!cfg.paper);
5701 assert_eq!(cfg.rest_base_url(), "https://api.alpaca.markets");
5702 }
5703
5704 #[test]
5705 fn test_alpaca_config_deserialize_rejects_test_url_overrides() {
5706 for key in ["base_url_override", "ws_url_override"] {
5707 let json = format!(
5708 r#"{{"api_key": "my_key", "secret_key": "my_secret", "{key}": "http://127.0.0.1:1"}}"#
5709 );
5710 let Err(err) = serde_json::from_str::<AlpacaConfig>(&json) else {
5711 panic!("a config with `{key}` must not deserialize");
5712 };
5713 assert!(
5714 err.to_string().contains(&format!("unknown field `{key}`")),
5715 "unexpected error for `{key}`: {err}"
5716 );
5717 }
5718 }
5719
5720 #[test]
5721 fn test_alpaca_config_urls() {
5722 let paper = AlpacaConfig::paper("k".into(), "s".into());
5723 assert!(paper.rest_base_url().contains("paper-api"));
5724 assert!(paper.ws_url().contains("paper-api"));
5725
5726 let live = AlpacaConfig::production("k".into(), "s".into());
5727 assert!(!live.rest_base_url().contains("paper-api"));
5728 assert!(!live.ws_url().contains("paper-api"));
5729 }
5730
5731 #[test]
5732 #[serial_test::serial]
5733 fn test_alpaca_config_from_env_defaults_to_paper_trading() {
5734 temp_env::with_vars(
5735 [
5736 ("ALPACA_API_KEY", Some("my_key")),
5737 ("ALPACA_SECRET_KEY", Some("my_secret")),
5738 ("ALPACA_PAPER", None),
5739 ],
5740 || {
5741 let cfg = AlpacaConfig::from_env().unwrap();
5742 assert!(cfg.paper);
5743 assert_eq!(cfg.rest_base_url(), "https://paper-api.alpaca.markets");
5744 },
5745 );
5746 }
5747
5748 #[test]
5749 #[serial_test::serial]
5750 fn test_alpaca_config_from_env_accepts_explicit_production() {
5751 temp_env::with_vars(
5752 [
5753 ("ALPACA_API_KEY", Some("my_key")),
5754 ("ALPACA_SECRET_KEY", Some("my_secret")),
5755 ("ALPACA_PAPER", Some("false")),
5756 ],
5757 || {
5758 let cfg = AlpacaConfig::from_env().unwrap();
5759 assert!(!cfg.paper);
5760 assert_eq!(cfg.rest_base_url(), "https://api.alpaca.markets");
5761 },
5762 );
5763 }
5764
5765 #[test]
5766 #[serial_test::serial]
5767 fn test_alpaca_config_from_env_rejects_invalid_paper() {
5768 temp_env::with_vars(
5769 [
5770 ("ALPACA_API_KEY", Some("my_key")),
5771 ("ALPACA_SECRET_KEY", Some("my_secret")),
5772 ("ALPACA_PAPER", Some("maybe")),
5773 ],
5774 || {
5775 let err = AlpacaConfig::from_env().unwrap_err();
5776 assert!(matches!(err, AlpacaConfigError::InvalidPaper(value) if value == "maybe"));
5777 },
5778 );
5779 }
5780
5781 #[test]
5782 #[serial_test::serial]
5783 fn test_alpaca_config_from_env_requires_credentials() {
5784 temp_env::with_vars(
5785 [
5786 ("ALPACA_API_KEY", None),
5787 ("ALPACA_SECRET_KEY", Some("my_secret")),
5788 ("ALPACA_PAPER", None),
5789 ],
5790 || {
5791 let err = AlpacaConfig::from_env().unwrap_err();
5792 assert!(matches!(err, AlpacaConfigError::MissingApiKey));
5793 },
5794 );
5795 }
5796
5797 #[test]
5798 fn test_parse_side() {
5799 assert_eq!(parse_side("buy"), Some(Side::Buy));
5800 assert_eq!(parse_side("sell"), Some(Side::Sell));
5801 assert_eq!(parse_side("Buy"), Some(Side::Buy));
5802 assert_eq!(parse_side("BUY"), Some(Side::Buy));
5803 assert_eq!(parse_side("unknown"), None);
5804 }
5805
5806 #[test]
5807 fn test_parse_order_kind() {
5808 assert_eq!(
5810 parse_order_kind("market", None, None, None),
5811 Some(OrderKind::Market)
5812 );
5813 assert_eq!(
5814 parse_order_kind("limit", None, None, None),
5815 Some(OrderKind::Limit)
5816 );
5817
5818 assert_eq!(
5820 parse_order_kind("stop", Some("150.00"), None, None),
5821 Some(OrderKind::Stop {
5822 trigger_price: Decimal::from_str("150.00").unwrap()
5823 })
5824 );
5825 assert_eq!(parse_order_kind("stop", None, None, None), None);
5826
5827 assert_eq!(
5829 parse_order_kind("stop_limit", Some("145.00"), None, None),
5830 Some(OrderKind::StopLimit {
5831 trigger_price: Decimal::from_str("145.00").unwrap()
5832 })
5833 );
5834
5835 assert_eq!(
5837 parse_order_kind("trailing_stop", None, Some("5.0"), None),
5838 Some(OrderKind::TrailingStop {
5839 offset: Decimal::from_str("5.0").unwrap(),
5840 offset_type: TrailingOffsetType::Percentage,
5841 activation_price: None,
5842 })
5843 );
5844
5845 assert_eq!(
5847 parse_order_kind("trailing_stop", None, None, Some("2.50")),
5848 Some(OrderKind::TrailingStop {
5849 offset: Decimal::from_str("2.50").unwrap(),
5850 offset_type: TrailingOffsetType::Absolute,
5851 activation_price: None,
5852 })
5853 );
5854
5855 assert_eq!(parse_order_kind("trailing_stop", None, None, None), None);
5857
5858 assert_eq!(parse_order_kind("unknown", None, None, None), None);
5860 }
5861
5862 #[test]
5863 fn test_map_order_kind() {
5864 assert_eq!(map_order_kind(OrderKind::Market), Some("market"));
5865 assert_eq!(map_order_kind(OrderKind::Limit), Some("limit"));
5866 assert_eq!(
5867 map_order_kind(OrderKind::Stop {
5868 trigger_price: Decimal::from_str("150.00").unwrap()
5869 }),
5870 Some("stop")
5871 );
5872 assert_eq!(
5873 map_order_kind(OrderKind::StopLimit {
5874 trigger_price: Decimal::from_str("145.00").unwrap()
5875 }),
5876 Some("stop_limit")
5877 );
5878 assert_eq!(
5879 map_order_kind(OrderKind::TrailingStop {
5880 offset: Decimal::from_str("5.0").unwrap(),
5881 offset_type: TrailingOffsetType::Percentage,
5882 activation_price: None,
5883 }),
5884 Some("trailing_stop")
5885 );
5886 assert_eq!(
5887 map_order_kind(OrderKind::TrailingStop {
5888 offset: Decimal::from_str("2.50").unwrap(),
5889 offset_type: TrailingOffsetType::Absolute,
5890 activation_price: None,
5891 }),
5892 Some("trailing_stop")
5893 );
5894 assert_eq!(
5896 map_order_kind(OrderKind::TrailingStop {
5897 offset: Decimal::from_str("5.0").unwrap(),
5898 offset_type: TrailingOffsetType::Percentage,
5899 activation_price: Some(Decimal::from(100)),
5900 }),
5901 None
5902 );
5903 assert_eq!(
5904 map_order_kind(OrderKind::TrailingStopFixedLimit {
5905 offset: Decimal::from_str("5.0").unwrap(),
5906 offset_type: TrailingOffsetType::Percentage,
5907 activation_price: None,
5908 }),
5909 None
5910 );
5911 assert_eq!(
5913 map_order_kind(OrderKind::TrailingStopLimit {
5914 offset: Decimal::from_str("5.0").unwrap(),
5915 offset_type: TrailingOffsetType::Percentage,
5916 stop_price: Decimal::from_str("95.0").unwrap(),
5917 limit_offset: Decimal::from_str("1.0").unwrap(),
5918 }),
5919 None
5920 );
5921 assert_eq!(
5923 map_order_kind(OrderKind::TakeProfit {
5924 trigger_price: Decimal::from_str("160.00").unwrap()
5925 }),
5926 None
5927 );
5928 assert_eq!(
5929 map_order_kind(OrderKind::TakeProfitLimit {
5930 trigger_price: Decimal::from_str("160.00").unwrap()
5931 }),
5932 None
5933 );
5934 }
5935
5936 #[test]
5937 fn test_map_time_in_force_roundtrip() {
5938 assert_eq!(
5939 map_time_in_force(TimeInForce::GoodUntilCancelled { post_only: false }),
5940 Ok("gtc")
5941 );
5942 assert_eq!(map_time_in_force(TimeInForce::GoodUntilEndOfDay), Ok("day"));
5943 assert_eq!(map_time_in_force(TimeInForce::FillOrKill), Ok("fok"));
5944 assert_eq!(map_time_in_force(TimeInForce::ImmediateOrCancel), Ok("ioc"));
5945 }
5946
5947 #[test]
5948 fn test_map_time_in_force_rejects_post_only() {
5949 let result = map_time_in_force(TimeInForce::GoodUntilCancelled { post_only: true });
5950 assert!(result.is_err(), "post_only must be rejected");
5951 assert!(result.unwrap_err().contains("post_only"));
5952 }
5953
5954 #[test]
5959 fn test_bracket_order_serializes_with_stop_loss_stop_order() {
5960 let body = AlpacaOrderRequest {
5962 symbol: "AAPL",
5963 qty: "10".to_string(),
5964 side: "buy",
5965 order_type: "limit",
5966 time_in_force: "gtc",
5967 limit_price: Some("150.00".to_string()),
5968 stop_price: None,
5969 trail_percent: None,
5970 trail_price: None,
5971 client_order_id: Some("bracket-001"),
5972 position_intent: Some(AlpacaPositionIntent::BuyToOpen),
5973 order_class: Some("bracket"),
5974 take_profit: Some(TakeProfitParams {
5975 limit_price: "160.00".to_string(),
5976 }),
5977 stop_loss: Some(StopLossParams {
5978 stop_price: "145.00".to_string(),
5979 limit_price: None,
5980 }),
5981 };
5982
5983 let json = serde_json::to_value(&body).unwrap();
5984
5985 assert_eq!(json["symbol"], "AAPL");
5986 assert_eq!(json["qty"], "10");
5987 assert_eq!(json["side"], "buy");
5988 assert_eq!(json["type"], "limit");
5989 assert_eq!(json["time_in_force"], "gtc");
5990 assert_eq!(json["limit_price"], "150.00");
5991 assert_eq!(json["order_class"], "bracket");
5992
5993 assert_eq!(json["take_profit"]["limit_price"], "160.00");
5995
5996 assert_eq!(json["stop_loss"]["stop_price"], "145.00");
5998 assert!(
5999 json["stop_loss"].get("limit_price").is_none(),
6000 "stop_loss.limit_price should be omitted when None"
6001 );
6002 }
6003
6004 #[test]
6005 fn test_bracket_order_serializes_with_stop_loss_stop_limit_order() {
6006 let body = AlpacaOrderRequest {
6008 symbol: "SPY",
6009 qty: "5".to_string(),
6010 side: "sell",
6011 order_type: "limit",
6012 time_in_force: "day",
6013 limit_price: Some("450.00".to_string()),
6014 stop_price: None,
6015 trail_percent: None,
6016 trail_price: None,
6017 client_order_id: Some("bracket-002"),
6018 position_intent: Some(AlpacaPositionIntent::SellToClose),
6019 order_class: Some("bracket"),
6020 take_profit: Some(TakeProfitParams {
6021 limit_price: "440.00".to_string(),
6022 }),
6023 stop_loss: Some(StopLossParams {
6024 stop_price: "455.00".to_string(),
6025 limit_price: Some("456.00".to_string()),
6026 }),
6027 };
6028
6029 let json = serde_json::to_value(&body).unwrap();
6030
6031 assert_eq!(json["symbol"], "SPY");
6032 assert_eq!(json["side"], "sell");
6033 assert_eq!(json["time_in_force"], "day");
6034 assert_eq!(json["order_class"], "bracket");
6035
6036 assert_eq!(json["take_profit"]["limit_price"], "440.00");
6038
6039 assert_eq!(json["stop_loss"]["stop_price"], "455.00");
6041 assert_eq!(
6042 json["stop_loss"]["limit_price"], "456.00",
6043 "stop_loss.limit_price should be present for stop-limit orders"
6044 );
6045 }
6046
6047 #[tokio::test]
6052 async fn test_open_bracket_order_rejects_invalid_tif() {
6053 use rust_decimal_macros::dec;
6054 use rustrade_instrument::instrument::name::InstrumentNameExchange;
6055
6056 let config = AlpacaConfig::new("dummy_key".into(), "dummy_secret".into());
6058 let client = AlpacaClient::new(config);
6059
6060 let request = AlpacaBracketOrderRequest::new(
6061 InstrumentNameExchange::new("SPY"),
6062 crate::order::id::StrategyId::new("test"),
6063 crate::order::id::ClientOrderId::new("test-tif"),
6064 Side::Buy,
6065 dec!(1),
6066 dec!(100.00),
6067 dec!(120.00),
6068 dec!(90.00),
6069 TimeInForce::ImmediateOrCancel, );
6071
6072 let result = client.open_bracket_order(request).await;
6073
6074 assert!(
6075 result.parent.state.is_failed(),
6076 "Bracket order with IOC TIF should be rejected locally"
6077 );
6078 }
6079
6080 #[tokio::test]
6081 async fn test_open_bracket_order_rejects_invalid_price_ordering() {
6082 use rust_decimal_macros::dec;
6083 use rustrade_instrument::instrument::name::InstrumentNameExchange;
6084
6085 let config = AlpacaConfig::new("dummy_key".into(), "dummy_secret".into());
6086 let client = AlpacaClient::new(config);
6087
6088 let request = AlpacaBracketOrderRequest::new(
6090 InstrumentNameExchange::new("SPY"),
6091 crate::order::id::StrategyId::new("test"),
6092 crate::order::id::ClientOrderId::new("test-price"),
6093 Side::Buy,
6094 dec!(1),
6095 dec!(100.00),
6096 dec!(120.00),
6097 dec!(105.00), TimeInForce::GoodUntilCancelled { post_only: false },
6099 );
6100
6101 let result = client.open_bracket_order(request).await;
6102
6103 assert!(
6104 result.parent.state.is_failed(),
6105 "Bracket order with invalid price ordering should be rejected locally"
6106 );
6107 }
6108
6109 #[tokio::test]
6110 async fn test_open_bracket_order_rejects_invalid_sl_limit_price() {
6111 use rust_decimal_macros::dec;
6112 use rustrade_instrument::instrument::name::InstrumentNameExchange;
6113
6114 let config = AlpacaConfig::new("dummy_key".into(), "dummy_secret".into());
6115 let client = AlpacaClient::new(config);
6116
6117 let request = AlpacaBracketOrderRequest::new(
6119 InstrumentNameExchange::new("SPY"),
6120 crate::order::id::StrategyId::new("test"),
6121 crate::order::id::ClientOrderId::new("test-sl-limit"),
6122 Side::Buy,
6123 dec!(1),
6124 dec!(100.00),
6125 dec!(120.00),
6126 dec!(90.00),
6127 TimeInForce::GoodUntilCancelled { post_only: false },
6128 )
6129 .with_stop_loss_limit_price(dec!(95.00)); let result = client.open_bracket_order(request).await;
6132
6133 assert!(
6134 result.parent.state.is_failed(),
6135 "Bracket order with invalid SL limit price should be rejected locally"
6136 );
6137 }
6138
6139 #[test]
6140 fn test_non_bracket_order_omits_bracket_fields() {
6141 let body = AlpacaOrderRequest {
6143 symbol: "AAPL",
6144 qty: "1".to_string(),
6145 side: "buy",
6146 order_type: "limit",
6147 time_in_force: "gtc",
6148 limit_price: Some("150.00".to_string()),
6149 stop_price: None,
6150 trail_percent: None,
6151 trail_price: None,
6152 client_order_id: Some("regular-001"),
6153 position_intent: Some(AlpacaPositionIntent::BuyToOpen),
6154 order_class: None,
6155 take_profit: None,
6156 stop_loss: None,
6157 };
6158
6159 let json = serde_json::to_value(&body).unwrap();
6160
6161 assert_eq!(json["symbol"], "AAPL");
6162 assert!(
6163 json.get("order_class").is_none(),
6164 "order_class should be omitted for non-bracket orders"
6165 );
6166 assert!(
6167 json.get("take_profit").is_none(),
6168 "take_profit should be omitted for non-bracket orders"
6169 );
6170 assert!(
6171 json.get("stop_loss").is_none(),
6172 "stop_loss should be omitted for non-bracket orders"
6173 );
6174 }
6175
6176 #[test]
6177 fn test_parse_timestamp_valid() {
6178 let ts = parse_timestamp("2025-04-18T14:30:00Z");
6179 assert!(ts.is_some());
6180 let ts2 = parse_timestamp("2025-04-18T14:30:00.123456Z");
6181 assert!(ts2.is_some());
6182 assert_eq!(parse_timestamp("not-a-timestamp"), None);
6183 }
6184
6185 #[test]
6186 fn test_check_auth_response_authorized() {
6187 let msg =
6188 r#"{"stream":"authorization","data":{"status":"authorized","action":"authenticate"}}"#;
6189 assert!(matches!(check_auth_response(msg), Some(Ok(()))));
6190 }
6191
6192 #[test]
6193 fn test_check_auth_response_unauthorized() {
6194 let msg = r#"{"stream":"authorization","data":{"status":"unauthorized"}}"#;
6195 assert!(matches!(
6196 check_auth_response(msg),
6197 Some(Err(HandshakeError::Auth(_)))
6198 ));
6199 }
6200
6201 #[test]
6202 fn test_check_auth_response_non_auth_message() {
6203 let msg = r#"{"stream":"trade_updates","data":{}}"#;
6204 assert!(check_auth_response(msg).is_none());
6205 }
6206
6207 #[test]
6208 fn test_check_listen_ack() {
6209 let ack = r#"{"stream":"listening","data":{"streams":["trade_updates"]}}"#;
6210 assert!(check_listen_ack(ack));
6211
6212 let other = r#"{"stream":"authorization","data":{}}"#;
6213 assert!(!check_listen_ack(other));
6214 }
6215
6216 #[test]
6217 fn test_dedup_cache() {
6218 let cache = new_dedup_cache();
6219 let key = || SmolStr::new("order-1:1");
6220 assert!(
6221 !is_duplicate_fill(&cache, Some("exec-1"), key()),
6222 "first time should not be duplicate"
6223 );
6224 assert!(
6225 is_duplicate_fill(&cache, Some("exec-1"), key()),
6226 "second time should be duplicate"
6227 );
6228 }
6229
6230 #[test]
6233 fn dedup_tells_fills_apart_by_execution_id_not_cumulative() {
6234 let cache = new_dedup_cache();
6235 assert!(!is_duplicate_fill(
6236 &cache,
6237 Some("exec-a"),
6238 SmolStr::new("ord-1:5")
6239 ));
6240 assert!(
6242 !is_duplicate_fill(&cache, Some("exec-b"), SmolStr::new("ord-1:5")),
6243 "a different execution at a cumulative seen before is a new fill"
6244 );
6245 }
6246
6247 #[test]
6250 fn dedup_matches_a_fill_with_an_execution_id_to_the_same_fill_without_one() {
6251 let cache = new_dedup_cache();
6252
6253 assert!(!is_duplicate_fill(&cache, None, SmolStr::new("ord-1:2")));
6254 assert!(
6255 is_duplicate_fill(&cache, Some("exec-a"), SmolStr::new("ord-1:2")),
6256 "without an id first, then with one"
6257 );
6258
6259 assert!(!is_duplicate_fill(
6260 &cache,
6261 Some("exec-b"),
6262 SmolStr::new("ord-1:4")
6263 ));
6264 assert!(
6265 is_duplicate_fill(&cache, None, SmolStr::new("ord-1:4")),
6266 "with an id first, then without one"
6267 );
6268 }
6269
6270 #[test]
6273 fn the_heartbeat_window_is_as_documented() {
6274 assert_eq!(HEARTBEAT_TIMEOUT_SECS, 35);
6275 }
6276
6277 #[test]
6278 fn test_convert_account_to_balances_empty_assets() {
6279 let account: AlpacaAccount = serde_json::from_value(serde_json::json!({
6282 "cash": "9000.50",
6283 "equity": "12000.00",
6284 "buying_power": "36000.00",
6285 "regt_buying_power": "18000.00",
6286 "options_buying_power": "8000.00",
6287 "non_marginable_buying_power": "8500.25",
6288 }))
6289 .unwrap();
6290 let balances = convert_account_to_balances(&account, &[]);
6291 assert_eq!(balances.len(), 1);
6292 assert_eq!(balances[0].asset, AssetNameExchange::new("USD"));
6293 assert_eq!(balances[0].balance.total, dec!(9000.50), "total is cash");
6294 assert_eq!(
6295 balances[0].balance.free,
6296 dec!(8500.25),
6297 "free is non-marginable buying power when below cash"
6298 );
6299 assert_eq!(balances[0].balance.margin, None);
6300 }
6301
6302 #[test]
6305 fn test_convert_account_to_balances_free_capped_at_cash_with_long_stock() {
6306 let account: AlpacaAccount = serde_json::from_value(serde_json::json!({
6307 "cash": "50000",
6308 "equity": "100000",
6309 "initial_margin": "25000",
6310 "non_marginable_buying_power": "75000",
6311 }))
6312 .unwrap();
6313 let balances = convert_account_to_balances(&account, &[]);
6314 assert_eq!(balances[0].balance.total, dec!(50000));
6315 assert_eq!(balances[0].balance.free, dec!(50000));
6316 }
6317
6318 #[test]
6321 fn test_convert_account_to_balances_open_short_keeps_free_below_cash() {
6322 let account: AlpacaAccount = serde_json::from_value(serde_json::json!({
6323 "cash": "10200",
6324 "equity": "10000",
6325 "initial_margin": "100",
6326 "non_marginable_buying_power": "9900",
6327 }))
6328 .unwrap();
6329 let balances = convert_account_to_balances(&account, &[]);
6330 assert_eq!(balances[0].balance.total, dec!(10200));
6331 assert_eq!(balances[0].balance.free, dec!(9900));
6332 }
6333
6334 #[test]
6335 fn test_convert_account_to_balances_negative_cash_on_margin() {
6336 let account: AlpacaAccount = serde_json::from_value(serde_json::json!({
6337 "cash": "-2500.75",
6338 "equity": "7500.00",
6339 "buying_power": "5000.00",
6340 "non_marginable_buying_power": "1250.00",
6341 }))
6342 .unwrap();
6343 let balances = convert_account_to_balances(&account, &[]);
6344 assert_eq!(balances.len(), 1);
6345 assert_eq!(balances[0].balance.total, dec!(-2500.75));
6346 assert_eq!(
6347 balances[0].balance.free,
6348 dec!(-2500.75),
6349 "no free cash while borrowing; free <= total still holds"
6350 );
6351 }
6352
6353 #[test]
6354 fn test_alpaca_account_malformed_cash_fails_to_decode() {
6355 let result = serde_json::from_value::<AlpacaAccount>(serde_json::json!({
6356 "cash": "not-a-number",
6357 "non_marginable_buying_power": "0",
6358 }));
6359 assert!(result.is_err(), "a malformed cash must not read as zero");
6360 }
6361
6362 #[test]
6363 fn test_convert_account_to_balances_usd_filter() {
6364 let account = AlpacaAccount {
6365 cash: dec!(12000.00),
6366 non_marginable_buying_power: dec!(10000.00),
6367 };
6368 let usd = vec![AssetNameExchange::new("USD")];
6369 let balances = convert_account_to_balances(&account, &usd);
6370 assert_eq!(balances.len(), 1);
6371 assert_eq!(balances[0].asset, AssetNameExchange::new("USD"));
6372
6373 let non_usd = vec![AssetNameExchange::new("BTC")];
6374 let balances = convert_account_to_balances(&account, &non_usd);
6375 assert!(balances.is_empty());
6376 }
6377
6378 #[test]
6381 fn a_usd_balance_is_named_usd_however_the_filter_spells_it() {
6382 let account = AlpacaAccount {
6383 cash: dec!(12000.00),
6384 non_marginable_buying_power: dec!(10000.00),
6385 };
6386 let balances = convert_account_to_balances(&account, &[AssetNameExchange::new("usd")]);
6387 assert_eq!(balances.len(), 1);
6388 assert_eq!(balances[0].asset, AssetNameExchange::new("USD"));
6389 }
6390
6391 #[test]
6392 fn test_is_options_or_equity_symbol() {
6393 assert!(!is_options_or_equity_symbol("BTC/USD"));
6395 assert!(!is_options_or_equity_symbol("ETH/USD"));
6396 assert!(!is_options_or_equity_symbol("SOL/USD"));
6397
6398 assert!(is_options_or_equity_symbol("AAPL"));
6400 assert!(is_options_or_equity_symbol("SPY"));
6401 assert!(is_options_or_equity_symbol("MSFT"));
6402
6403 assert!(is_options_or_equity_symbol("SPY250418C00450000"));
6405 assert!(is_options_or_equity_symbol("AAPL250418P00145000"));
6406 }
6407
6408 #[test]
6409 fn test_parse_order_error_already_cancelled() {
6410 assert!(matches!(
6413 parse_order_error(
6414 reqwest::StatusCode::UNPROCESSABLE_ENTITY,
6415 None,
6416 "order is already cancelled"
6417 ),
6418 UnindexedOrderError::Rejected(ApiError::OrderAlreadyCancelled)
6419 ));
6420 }
6421
6422 #[test]
6423 fn test_parse_order_error_already_wins_over_insufficient_on_422() {
6424 assert!(matches!(
6427 parse_order_error(
6428 reqwest::StatusCode::UNPROCESSABLE_ENTITY,
6429 None,
6430 "order already cancelled due to insufficient margin"
6431 ),
6432 UnindexedOrderError::Rejected(ApiError::OrderAlreadyCancelled)
6433 ));
6434 }
6435
6436 fn make_order_ws<'a>(
6437 id: &str,
6438 symbol: &str,
6439 side: &str,
6440 filled_qty: &'a str,
6441 ) -> AlpacaOrderWs<'a> {
6442 AlpacaOrderWs {
6443 id: SmolStr::new(id),
6444 client_order_id: None,
6445 symbol: SmolStr::new(symbol),
6446 qty: Some("2"),
6447 filled_qty: Some(filled_qty),
6448 side: SmolStr::new(side),
6449 order_type: SmolStr::new("limit"),
6450 time_in_force: SmolStr::new("day"),
6451 limit_price: Some("100.00"),
6452 stop_price: None,
6453 trail_percent: None,
6454 trail_price: None,
6455 status: SmolStr::new("partially_filled"),
6456 }
6457 }
6458
6459 fn sole_event(events: [Option<UnindexedAccountEvent>; 2]) -> Option<UnindexedAccountEvent> {
6464 let [first, second] = events;
6465 assert!(
6466 second.is_none(),
6467 "expected a single event, got a second: {second:?}"
6468 );
6469 first
6470 }
6471
6472 fn fill_events(
6474 events: [Option<UnindexedAccountEvent>; 2],
6475 ) -> (
6476 Trade<AssetNameExchange, InstrumentNameExchange>,
6477 crate::order::Order<
6478 ExchangeId,
6479 InstrumentNameExchange,
6480 OrderState<AssetNameExchange, InstrumentNameExchange>,
6481 >,
6482 ) {
6483 let [first, second] = events;
6484 let first = first.expect("a fill frame must produce an execution");
6485 let second = second.expect("a fill frame must produce an order snapshot");
6486 let AccountEventKind::Trade(trade) = first.kind else {
6487 panic!("first event must be the Trade, got {:?}", first.kind);
6488 };
6489 let AccountEventKind::OrderSnapshot(rustrade_integration::collection::snapshot::Snapshot(
6490 order,
6491 )) = second.kind
6492 else {
6493 panic!(
6494 "second event must be the OrderSnapshot, got {:?}",
6495 second.kind
6496 );
6497 };
6498 (trade, order)
6499 }
6500
6501 #[test]
6504 fn test_convert_trade_update_fill_carries_the_client_order_id() {
6505 let mut order = make_order_ws("ord-1", "SPY", "buy", "1");
6506 order.client_order_id = Some(SmolStr::new("outside-1"));
6507 let update = AlpacaTradeUpdate {
6508 event: SmolStr::new("fill"),
6509 execution_id: Some("exec-1"),
6510 previous_execution_id: None,
6511 order,
6512 price: Some("150.00"),
6513 qty: Some("1"),
6514 timestamp: Some("2025-04-18T14:30:00Z"),
6515 };
6516 let (trade, _order) = fill_events(convert_trade_update(update));
6517 assert_eq!(trade.client_order_id, Some(ClientOrderId::new("outside-1")));
6518 }
6519
6520 #[test]
6521 fn test_convert_trade_update_fill_produces_trade_with_execution_id() {
6522 let update = AlpacaTradeUpdate {
6523 event: SmolStr::new("fill"),
6524 execution_id: Some("exec-1"),
6525 previous_execution_id: None,
6526 order: make_order_ws("ord-1", "SPY", "buy", "1"),
6527 price: Some("150.00"),
6528 qty: Some("1"),
6529 timestamp: Some("2025-04-18T14:30:00Z"),
6530 };
6531 let (trade, order) = fill_events(convert_trade_update(update));
6532 assert_eq!(trade.id.0.as_str(), "exec-1");
6534 assert_eq!(
6535 trade.client_order_id, None,
6536 "the update names no client order id"
6537 );
6538 assert_eq!(trade.price, Decimal::from_str("150.00").unwrap());
6539 assert_eq!(trade.quantity, Decimal::from_str("1").unwrap());
6540
6541 let OrderState::Active(crate::order::state::ActiveOrderState::Open(open)) = order.state
6544 else {
6545 panic!("expected an Open snapshot, got {:?}", order.state);
6546 };
6547 assert_eq!(open.filled_quantity, Decimal::from_str("1").unwrap());
6548 assert_eq!(open.id.assigned().map(|id| id.0.as_str()), Some("ord-1"));
6549 }
6550
6551 #[test]
6552 fn test_convert_trade_update_partial_fill() {
6553 let update = AlpacaTradeUpdate {
6554 event: SmolStr::new("partial_fill"),
6555 execution_id: None,
6556 previous_execution_id: None,
6557 order: make_order_ws("ord-2", "AAPL", "sell", "0.5"),
6558 price: Some("200.00"),
6559 qty: Some("0.5"),
6560 timestamp: None,
6561 };
6562 let (trade, order) = fill_events(convert_trade_update(update));
6563 assert_eq!(trade.quantity, Decimal::from_str("0.5").unwrap());
6564
6565 let OrderState::Active(crate::order::state::ActiveOrderState::Open(open)) = order.state
6568 else {
6569 panic!("expected an Open snapshot, got {:?}", order.state);
6570 };
6571 assert_eq!(open.filled_quantity, Decimal::from_str("0.5").unwrap());
6572 assert_eq!(
6573 open.quantity_remaining(order.quantity),
6574 Decimal::from_str("1.5").unwrap()
6575 );
6576 }
6577
6578 #[test]
6582 fn a_full_fill_snapshot_reports_nothing_left_to_fill() {
6583 let update = AlpacaTradeUpdate {
6584 event: SmolStr::new("fill"),
6585 execution_id: None,
6586 previous_execution_id: None,
6587 order: AlpacaOrderWs {
6589 status: SmolStr::new("filled"),
6590 ..make_order_ws("ord-full", "SPY", "buy", "2")
6591 },
6592 price: Some("150.00"),
6593 qty: Some("1"),
6594 timestamp: Some("2025-04-18T14:30:00Z"),
6595 };
6596 let (_trade, order) = fill_events(convert_trade_update(update));
6597 let OrderState::Active(crate::order::state::ActiveOrderState::Open(open)) = order.state
6598 else {
6599 panic!("expected an Open snapshot, got {:?}", order.state);
6600 };
6601 assert_eq!(open.filled_quantity, Decimal::from_str("2").unwrap());
6602 assert!(
6603 open.quantity_remaining(order.quantity).is_zero(),
6604 "a completed order must report nothing left to fill so the engine retires it"
6605 );
6606 }
6607
6608 #[test]
6612 fn a_fill_for_an_order_no_longer_working_emits_the_execution_without_a_snapshot() {
6613 for status in [
6614 "canceled",
6615 "expired",
6616 "rejected",
6617 "done_for_day",
6618 "pending_cancel",
6619 ] {
6620 let update = AlpacaTradeUpdate {
6621 event: SmolStr::new("partial_fill"),
6622 execution_id: None,
6623 previous_execution_id: None,
6624 order: AlpacaOrderWs {
6625 status: SmolStr::new(status),
6626 ..make_order_ws("ord-late", "SPY", "buy", "1")
6627 },
6628 price: Some("150.00"),
6629 qty: Some("1"),
6630 timestamp: None,
6631 };
6632 let [trade, snapshot] = convert_trade_update(update);
6633 assert!(
6634 matches!(trade.map(|e| e.kind), Some(AccountEventKind::Trade(_))),
6635 "the execution must still be reported for status {status}"
6636 );
6637 assert!(
6638 snapshot.is_none(),
6639 "status {status} must not produce an order snapshot"
6640 );
6641 }
6642 }
6643
6644 #[test]
6647 fn a_notional_order_fill_emits_the_execution_without_a_snapshot() {
6648 let update = AlpacaTradeUpdate {
6649 event: SmolStr::new("partial_fill"),
6650 execution_id: None,
6651 previous_execution_id: None,
6652 order: AlpacaOrderWs {
6653 qty: None,
6654 ..make_order_ws("ord-notional-ws", "SPY", "buy", "1")
6655 },
6656 price: Some("150.00"),
6657 qty: Some("1"),
6658 timestamp: None,
6659 };
6660 let [trade, snapshot] = convert_trade_update(update);
6661 assert!(matches!(
6662 trade.map(|e| e.kind),
6663 Some(AccountEventKind::Trade(_))
6664 ));
6665 assert!(
6666 snapshot.is_none(),
6667 "a notional order has no quantity to snapshot against"
6668 );
6669 }
6670
6671 #[test]
6672 fn test_convert_trade_update_new_order_produces_snapshot() {
6673 let update = AlpacaTradeUpdate {
6674 event: SmolStr::new("new"),
6675 execution_id: None,
6676 previous_execution_id: None,
6677 order: AlpacaOrderWs {
6678 id: SmolStr::new("ord-new"),
6679 client_order_id: Some(SmolStr::new("cid-1")),
6680 symbol: SmolStr::new("AAPL"),
6681 qty: Some("10"),
6682 filled_qty: Some("0"),
6683 side: SmolStr::new("buy"),
6684 order_type: SmolStr::new("limit"),
6685 time_in_force: SmolStr::new("day"),
6686 limit_price: Some("150.00"),
6687 stop_price: None,
6688 trail_percent: None,
6689 trail_price: None,
6690 status: SmolStr::new("new"),
6691 },
6692 price: None,
6693 qty: None,
6694 timestamp: Some("2025-04-18T14:30:00Z"),
6695 };
6696 let event =
6697 sole_event(convert_trade_update(update)).expect("new event should produce an event");
6698 assert!(matches!(event.kind, AccountEventKind::OrderSnapshot(_)));
6699 }
6700
6701 #[test]
6702 fn test_convert_trade_update_canceled_produces_cancel() {
6703 let update = AlpacaTradeUpdate {
6704 event: SmolStr::new("canceled"),
6705 execution_id: None,
6706 previous_execution_id: None,
6707 order: make_order_ws("ord-3", "AAPL", "sell", "0"),
6708 price: None,
6709 qty: None,
6710 timestamp: Some("2025-04-18T14:30:00Z"),
6711 };
6712 let event =
6713 sole_event(convert_trade_update(update)).expect("canceled should produce an event");
6714 let AccountEventKind::OrderCancelled(response) = event.kind else {
6715 panic!("expected OrderCancelled, got {:?}", event.kind);
6716 };
6717 assert!(response.state.is_ok());
6718 }
6719
6720 #[test]
6722 fn a_canceled_update_carries_its_fill_or_none() {
6723 let cancelled = |filled_qty: Option<&'static str>| {
6724 let mut order = make_order_ws("ord-3", "AAPL", "sell", "0");
6725 order.filled_qty = filled_qty;
6726 let update = AlpacaTradeUpdate {
6727 event: SmolStr::new("canceled"),
6728 execution_id: None,
6729 previous_execution_id: None,
6730 order,
6731 price: None,
6732 qty: None,
6733 timestamp: Some("2025-04-18T14:30:00Z"),
6734 };
6735 let event =
6736 sole_event(convert_trade_update(update)).expect("canceled should produce an event");
6737 let AccountEventKind::OrderCancelled(response) = event.kind else {
6738 panic!("expected OrderCancelled, got {:?}", event.kind);
6739 };
6740 let Ok(cancelled) = response.state else {
6741 panic!("expected Ok, got {:?}", response.state);
6742 };
6743 cancelled.filled_quantity
6744 };
6745
6746 assert_eq!(cancelled(Some("1")), Some(Decimal::ONE));
6747 assert_eq!(cancelled(Some("0")), Some(Decimal::ZERO));
6748 assert_eq!(cancelled(None), None);
6749 assert_eq!(cancelled(Some("not a number")), None);
6750 }
6751
6752 #[test]
6755 fn an_ended_order_with_an_unparseable_fill_reports_it_unknown() {
6756 let key = OrderKey::new(
6757 ExchangeId::AlpacaBroker,
6758 InstrumentNameExchange::new("AAPL"),
6759 StrategyId::new("strategy"),
6760 ClientOrderId::new("c"),
6761 );
6762 let ended = |status: &str| {
6763 let mut order = make_order_response("o1", "AAPL");
6764 order.status = Some(status.to_string());
6765 order.filled_qty = "not a number".to_string();
6766 convert_ended_order(&order, &key).map(|order| order.state)
6767 };
6768
6769 assert!(matches!(
6770 ended("canceled"),
6771 Some(InactiveOrderState::Cancelled(Cancelled {
6772 filled_quantity: None,
6773 ..
6774 }))
6775 ));
6776 assert!(matches!(
6777 ended("expired"),
6778 Some(InactiveOrderState::Expired(Expired {
6779 filled_quantity: None,
6780 ..
6781 }))
6782 ));
6783 assert!(matches!(
6784 ended("filled"),
6785 Some(InactiveOrderState::FullyFilled(Filled { filled_quantity, .. }))
6786 if filled_quantity == Decimal::ONE
6787 ));
6788 }
6789
6790 #[test]
6791 fn test_convert_trade_update_rejected_produces_error() {
6792 let update = AlpacaTradeUpdate {
6793 event: SmolStr::new("rejected"),
6794 execution_id: None,
6795 previous_execution_id: None,
6796 order: make_order_ws("ord-4", "SPY", "buy", "0"),
6797 price: None,
6798 qty: None,
6799 timestamp: None,
6800 };
6801 let event =
6802 sole_event(convert_trade_update(update)).expect("rejected should produce an event");
6803 let AccountEventKind::OrderCancelled(response) = event.kind else {
6804 panic!("expected OrderCancelled, got {:?}", event.kind);
6805 };
6806 assert!(response.state.is_err());
6807 }
6808
6809 #[test]
6810 fn test_convert_open_order_notional_qty_none_is_skipped() {
6811 let order = AlpacaOrderResponse {
6814 id: "ord-notional".to_string(),
6815 client_order_id: None,
6816 symbol: "SPY".to_string(),
6817 qty: None,
6818 filled_qty: "0".to_string(),
6819 filled_avg_price: None,
6820 status: None,
6821 side: "buy".to_string(),
6822 order_type: "market".to_string(),
6823 time_in_force: "day".to_string(),
6824 limit_price: None,
6825 stop_price: None,
6826 trail_percent: None,
6827 trail_price: None,
6828 created_at: "2025-04-18T14:30:00Z".to_string(),
6829 updated_at: None,
6830 };
6831 assert!(convert_open_order(&order).is_none());
6832 }
6833
6834 #[test]
6835 fn test_convert_activity_to_trade_bad_price_returns_none() {
6836 let activity = AlpacaActivity {
6840 id: "act-1".to_string(),
6841 order_id: "ord-1".to_string(),
6842 symbol: "SPY250418C00450000".to_string(),
6843 side: "buy".to_string(),
6844 price: "not-a-number".to_string(),
6845 qty: "1".to_string(),
6846 transaction_time: "2025-04-18T14:30:00Z".to_string(),
6847 cum_qty: Some("1".to_string()),
6848 };
6849 assert!(convert_activity_to_trade(&activity).is_none());
6850 }
6851
6852 #[test]
6853 fn test_convert_positions_to_balances_crypto() {
6854 let positions = vec![
6855 AlpacaPosition {
6857 qty_available: dec!(0.4),
6858 ..alpaca_position("BTCUSD", "crypto", AlpacaPositionSide::Long, dec!(0.5))
6859 },
6860 alpaca_position("ETH/USD", "crypto", AlpacaPositionSide::Long, dec!(2.0)),
6862 alpaca_position("AAPL", "us_equity", AlpacaPositionSide::Long, dec!(10)),
6864 ];
6865
6866 let balances = convert_positions_to_balances(&positions, &[]);
6868 assert_eq!(balances.len(), 2, "only crypto positions returned");
6869 assert_eq!(balances[0].asset.name().as_str(), "BTC");
6870 assert_eq!(balances[1].asset.name().as_str(), "ETH");
6871 assert_eq!(balances[0].balance.total, dec!(0.5));
6873 assert_eq!(balances[0].balance.free, dec!(0.4));
6874
6875 let btc_only = vec![AssetNameExchange::new("btc")];
6877 let balances = convert_positions_to_balances(&positions, &btc_only);
6878 assert_eq!(balances.len(), 1);
6879 assert_eq!(balances[0].asset.name().as_str(), "BTC");
6880 }
6881
6882 #[test]
6883 fn test_crypto_position_asset() {
6884 assert_eq!(crypto_position_asset("BTCUSD"), "BTC");
6885 assert_eq!(
6886 crypto_position_asset("btcusd"),
6887 "btc",
6888 "suffix matched ignoring case"
6889 );
6890 assert_eq!(
6891 crypto_position_asset("USDTUSD"),
6892 "USDT",
6893 "only one USD is stripped"
6894 );
6895 assert_eq!(crypto_position_asset("BTC/USD"), "BTC");
6896 assert_eq!(crypto_position_asset("BTC/USDT"), "BTC");
6897 assert_eq!(crypto_position_asset("€USD"), "€");
6898 assert_eq!(crypto_position_asset("USD"), "USD");
6900 assert_eq!(crypto_position_asset("/USD"), "/USD");
6901 assert_eq!(crypto_position_asset("BTCUSDT"), "BTCUSDT");
6902 assert_eq!(
6903 crypto_position_asset("BTCێ"),
6904 "BTCێ",
6905 "no char boundary at the suffix"
6906 );
6907 assert_eq!(crypto_position_asset(""), "");
6908 }
6909
6910 fn alpaca_position(
6912 symbol: &str,
6913 asset_class: &str,
6914 side: AlpacaPositionSide,
6915 qty: Decimal,
6916 ) -> AlpacaPosition {
6917 AlpacaPosition {
6918 symbol: symbol.into(),
6919 asset_class: asset_class.into(),
6920 side,
6921 qty,
6922 qty_available: qty,
6923 avg_entry_price: Some(dec!(100)),
6924 unrealized_pl: Some(dec!(0)),
6925 }
6926 }
6927
6928 #[test]
6929 fn test_convert_positions_sign_comes_from_side_not_qty() {
6930 let now = Utc::now();
6931 let positions = vec![
6932 alpaca_position("AAPL", "us_equity", AlpacaPositionSide::Long, dec!(10)),
6933 alpaca_position("TSLA", "us_equity", AlpacaPositionSide::Short, dec!(-3)),
6934 alpaca_position("MSFT", "us_equity", AlpacaPositionSide::Short, dec!(4)),
6936 ];
6937
6938 let converted = convert_positions(&positions, now).unwrap();
6939 let quantities: Vec<_> = converted.iter().map(|(s, p)| (*s, p.quantity)).collect();
6940 assert_eq!(
6941 quantities,
6942 vec![("AAPL", dec!(10)), ("TSLA", dec!(-3)), ("MSFT", dec!(-4))]
6943 );
6944 assert!(converted.iter().all(|(_, p)| p.time_exchange == now));
6945 }
6946
6947 #[test]
6948 fn test_convert_positions_leaves_out_crypto_and_flat() {
6949 let positions = vec![
6950 alpaca_position("BTC/USD", "crypto", AlpacaPositionSide::Long, dec!(0.5)),
6951 alpaca_position("AAPL", "us_equity", AlpacaPositionSide::Long, dec!(0)),
6952 alpaca_position("SPY", "us_equity", AlpacaPositionSide::Long, dec!(1)),
6953 ];
6954
6955 let converted = convert_positions(&positions, Utc::now()).unwrap();
6956 let symbols: Vec<_> = converted.iter().map(|(s, _)| *s).collect();
6957 assert_eq!(symbols, vec!["SPY"]);
6958 }
6959
6960 #[test]
6961 fn test_alpaca_position_decodes_equity_option_and_fractional() {
6962 let positions: Vec<AlpacaPosition> = serde_json::from_value(serde_json::json!([
6963 {
6964 "asset_id": "00000000-0000-0000-0000-000000000001",
6965 "symbol": "XYZ",
6966 "exchange": "NASDAQ",
6967 "asset_class": "us_equity",
6968 "qty": "-7",
6969 "qty_available": "-7",
6970 "side": "short",
6971 "avg_entry_price": "50.25",
6972 "market_value": "-351.75",
6973 "unrealized_pl": "0",
6974 },
6975 {
6976 "symbol": "XYZ271217C00055000",
6977 "asset_class": "us_option",
6978 "qty": "2",
6979 "qty_available": "2",
6980 "side": "long",
6981 "avg_entry_price": "1.35",
6982 "unrealized_pl": "-20",
6983 },
6984 {
6985 "symbol": "ABC",
6986 "asset_class": "us_equity",
6987 "qty": "0.125",
6988 "qty_available": "0.125",
6989 "side": "long",
6990 "avg_entry_price": "80",
6991 "unrealized_pl": null,
6992 },
6993 ]))
6994 .unwrap();
6995
6996 let converted = convert_positions(&positions, Utc::now()).unwrap();
6997 let [
6998 (short_sym, short),
6999 (option_sym, option),
7000 (fraction_sym, fraction),
7001 ] = converted.as_slice()
7002 else {
7003 panic!("expected three positions, got {converted:?}");
7004 };
7005
7006 assert_eq!(*short_sym, "XYZ");
7007 assert_eq!(short.quantity, dec!(-7));
7008 assert_eq!(short.entry_price, Some(dec!(50.25)));
7009 assert!(short.is_short());
7010
7011 assert_eq!(*option_sym, "XYZ271217C00055000");
7012 assert_eq!(option.quantity, dec!(2));
7013 assert_eq!(option.entry_price, Some(dec!(1.35)), "per-share premium");
7014 assert_eq!(option.unrealized_pnl, Some(dec!(-20)));
7015
7016 assert_eq!(*fraction_sym, "ABC");
7017 assert_eq!(fraction.quantity, dec!(0.125));
7018 assert_eq!(fraction.unrealized_pnl, None);
7019
7020 for (_, p) in &converted {
7021 assert_eq!(p.margin_used, None);
7022 assert_eq!(p.liquidation_price, None);
7023 assert_eq!(p.leverage, None);
7024 }
7025 }
7026
7027 #[test]
7030 fn test_alpaca_position_unknown_side_fails_only_where_a_direction_is_needed() {
7031 let positions: Vec<AlpacaPosition> = serde_json::from_value(serde_json::json!([
7032 {
7033 "symbol": "BTC/USD",
7034 "asset_class": "crypto",
7035 "qty": "0.5",
7036 "qty_available": "0.5",
7037 },
7038 {
7039 "symbol": "XYZ",
7040 "asset_class": "us_equity",
7041 "qty": "1",
7042 "qty_available": "1",
7043 "side": "sideways",
7044 },
7045 ]))
7046 .unwrap();
7047 assert_eq!(positions[0].side, AlpacaPositionSide::Unknown);
7048 assert_eq!(positions[1].side, AlpacaPositionSide::Unknown);
7049
7050 let balances = convert_positions_to_balances(&positions, &[]);
7051 assert_eq!(balances.len(), 1);
7052 assert_eq!(balances[0].balance.total, dec!(0.5));
7053
7054 let result = convert_positions(&positions, Utc::now());
7055 assert!(
7056 matches!(&result, Err(UnindexedClientError::Internal(msg)) if msg.contains("XYZ")),
7057 "an unknown side must not guess a direction: {result:?}"
7058 );
7059 }
7060
7061 #[test]
7063 fn order_state_time_prefers_updated_at() {
7064 let mut resp = make_order_response("ord-1", "SPY");
7065 resp.created_at = "2025-04-18T14:30:00Z".to_string();
7066 resp.updated_at = Some("2025-04-18T15:45:00Z".to_string());
7067 assert_eq!(
7068 order_state_time(&resp),
7069 parse_timestamp("2025-04-18T15:45:00Z").unwrap(),
7070 "updated_at must win over created_at"
7071 );
7072
7073 resp.updated_at = None;
7075 assert_eq!(
7076 order_state_time(&resp),
7077 parse_timestamp("2025-04-18T14:30:00Z").unwrap(),
7078 "absent updated_at falls back to created_at"
7079 );
7080
7081 resp.updated_at = Some("not-a-timestamp".to_string());
7084 assert_eq!(
7085 order_state_time(&resp),
7086 parse_timestamp("2025-04-18T14:30:00Z").unwrap(),
7087 "unparseable updated_at falls back to created_at"
7088 );
7089 }
7090
7091 #[test]
7094 fn a_placement_response_without_a_status_reads_from_what_filled() {
7095 let instrument = InstrumentNameExchange::new("SPY");
7096 let mut resp = make_order_response("ord-1", "SPY");
7097 resp.filled_qty = "0.4".to_string();
7098 let OrderState::Active(ActiveOrderState::Open(open)) =
7099 placed_order_state(&resp, &instrument, Decimal::ONE)
7100 else {
7101 panic!("expected Open");
7102 };
7103 assert_eq!(open.filled_quantity, Decimal::new(4, 1));
7104
7105 resp.filled_qty = "1".to_string();
7106 resp.filled_avg_price = Some("100.5".to_string());
7107 let state = placed_order_state(&resp, &instrument, Decimal::ONE);
7108 let OrderState::Inactive(InactiveOrderState::FullyFilled(filled)) = state else {
7109 panic!("expected FullyFilled, got {state:?}");
7110 };
7111 assert_eq!(filled.filled_quantity, Decimal::ONE);
7112 assert_eq!(filled.avg_price, Some(Decimal::new(1005, 1)));
7113 }
7114
7115 fn make_order_response(id: &str, symbol: &str) -> AlpacaOrderResponse {
7116 AlpacaOrderResponse {
7117 id: id.to_string(),
7118 client_order_id: None,
7119 symbol: symbol.to_string(),
7120 qty: Some("1".to_string()),
7121 filled_qty: "0".to_string(),
7122 filled_avg_price: None,
7123 status: None,
7124 side: "buy".to_string(),
7125 order_type: "limit".to_string(),
7126 time_in_force: "day".to_string(),
7127 limit_price: Some("100.00".to_string()),
7128 stop_price: None,
7129 trail_percent: None,
7130 trail_price: None,
7131 created_at: "2025-04-18T14:30:00Z".to_string(),
7132 updated_at: None,
7133 }
7134 }
7135
7136 #[test]
7137 fn test_build_instrument_snapshots_empty_instruments_returns_only_with_orders() {
7138 let orders = vec![
7139 make_order_response("o1", "AAPL"),
7140 make_order_response("o2", "SPY"),
7141 ];
7142 let snapshots = build_instrument_snapshots(orders, Vec::new(), &[]);
7143 assert_eq!(snapshots.len(), 2);
7144 let symbols: Vec<&str> = snapshots
7145 .iter()
7146 .map(|s| s.instrument.name().as_str())
7147 .collect();
7148 assert!(symbols.contains(&"AAPL"));
7149 assert!(symbols.contains(&"SPY"));
7150 }
7151
7152 #[test]
7153 fn test_build_instrument_snapshots_requested_instrument_no_orders_gets_empty_snapshot() {
7154 let orders = vec![make_order_response("o1", "AAPL")];
7157 let instruments = vec![
7158 InstrumentNameExchange::new("AAPL"),
7159 InstrumentNameExchange::new("SPY"),
7160 ];
7161 let snapshots = build_instrument_snapshots(orders, Vec::new(), &instruments);
7162 assert_eq!(snapshots.len(), 2);
7163 let spy = snapshots
7164 .iter()
7165 .find(|s| s.instrument.name().as_str() == "SPY")
7166 .expect("SPY snapshot must be present even with no orders");
7167 assert!(spy.orders.is_empty());
7168 }
7169
7170 fn make_notional_order_response(id: &str, symbol: &str) -> AlpacaOrderResponse {
7172 AlpacaOrderResponse {
7173 qty: None,
7174 ..make_order_response(id, symbol)
7175 }
7176 }
7177
7178 fn snapshot_for<'a>(
7179 snapshots: &'a [InstrumentAccountSnapshot<
7180 ExchangeId,
7181 AssetNameExchange,
7182 InstrumentNameExchange,
7183 >],
7184 symbol: &str,
7185 ) -> &'a InstrumentAccountSnapshot<ExchangeId, AssetNameExchange, InstrumentNameExchange> {
7186 snapshots
7187 .iter()
7188 .find(|snapshot| snapshot.instrument.name().as_str() == symbol)
7189 .unwrap_or_else(|| panic!("no snapshot for {symbol}"))
7190 }
7191
7192 #[test]
7196 fn test_build_instrument_snapshots_declares_complete_lists_complete() {
7197 let instruments = [
7198 InstrumentNameExchange::new("AAPL"),
7199 InstrumentNameExchange::new("SPY"),
7200 ];
7201 for requested in [&instruments[..], &[]] {
7202 let orders = vec![make_order_response("o1", "AAPL")];
7203 let snapshots = build_instrument_snapshots(orders, Vec::new(), requested);
7204 assert!(!snapshots.is_empty());
7205 assert!(snapshots.iter().all(|snapshot| snapshot.orders_complete));
7206 }
7207 }
7208
7209 #[test]
7213 fn test_build_instrument_snapshots_notional_order_leaves_only_its_instrument_incomplete() {
7214 let orders = vec![
7215 make_order_response("o1", "AAPL"),
7216 make_order_response("o2", "SPY"),
7217 make_notional_order_response("o3", "SPY"),
7218 ];
7219 let instruments = [
7220 InstrumentNameExchange::new("AAPL"),
7221 InstrumentNameExchange::new("SPY"),
7222 ];
7223 let snapshots = build_instrument_snapshots(orders, Vec::new(), &instruments);
7224
7225 assert!(snapshot_for(&snapshots, "AAPL").orders_complete);
7226 let spy = snapshot_for(&snapshots, "SPY");
7227 assert!(!spy.orders_complete);
7228 assert_eq!(spy.orders.len(), 1);
7229 }
7230
7231 #[test]
7234 fn test_build_instrument_snapshots_unfiltered_lists_an_instrument_with_only_a_notional_order() {
7235 let orders = vec![make_notional_order_response("o1", "SPY")];
7236 let snapshots = build_instrument_snapshots(orders, Vec::new(), &[]);
7237
7238 let spy = snapshot_for(&snapshots, "SPY");
7239 assert!(!spy.orders_complete);
7240 assert!(spy.orders.is_empty());
7241 }
7242
7243 #[test]
7244 fn test_build_instrument_snapshots_non_requested_instrument_excluded() {
7245 let orders = vec![
7248 make_order_response("o1", "AAPL"),
7249 make_order_response("o2", "MSFT"), ];
7251 let instruments = vec![InstrumentNameExchange::new("AAPL")];
7252 let snapshots = build_instrument_snapshots(orders, Vec::new(), &instruments);
7253 assert_eq!(snapshots.len(), 1);
7254 assert_eq!(snapshots[0].instrument.name().as_str(), "AAPL");
7255 }
7256
7257 fn position(quantity: Decimal) -> Position {
7258 Position::new(
7259 quantity,
7260 Some(dec!(100)),
7261 None,
7262 None,
7263 None,
7264 None,
7265 None,
7266 Utc::now(),
7267 )
7268 }
7269
7270 #[test]
7271 fn test_build_instrument_snapshots_unfiltered_lists_positions_without_orders() {
7272 let orders = vec![
7273 make_order_response("o1", "AAPL"),
7274 make_order_response("o2", "MSFT"),
7275 make_order_response("o3", "BTC/USD"),
7276 ];
7277 let positions = vec![("AAPL", position(dec!(5))), ("TSLA", position(dec!(-2)))];
7278
7279 let snapshots = build_instrument_snapshots(orders, positions, &[]);
7280 let listed: Vec<_> = snapshots
7281 .iter()
7282 .map(|s| {
7283 (
7284 s.instrument.name().as_str(),
7285 s.orders.len(),
7286 s.position.quantity(),
7287 )
7288 })
7289 .collect();
7290 assert_eq!(
7292 listed,
7293 vec![
7294 ("AAPL", 1, Some(dec!(5))),
7295 ("MSFT", 1, Some(Decimal::ZERO)),
7296 ("BTC/USD", 1, None),
7297 ("TSLA", 0, Some(dec!(-2)))
7298 ]
7299 );
7300 assert!(snapshots.iter().all(|s| s.orders_complete));
7301 }
7302
7303 #[test]
7304 fn test_build_instrument_snapshots_filtered_attaches_only_requested_positions() {
7305 let positions = vec![("AAPL", position(dec!(5))), ("TSLA", position(dec!(-2)))];
7306 let instruments = vec![
7307 InstrumentNameExchange::new("tsla"),
7308 InstrumentNameExchange::new("MSFT"),
7309 InstrumentNameExchange::new("BTC/USD"),
7310 InstrumentNameExchange::new("TSLA"),
7311 ];
7312
7313 let snapshots = build_instrument_snapshots(Vec::new(), positions, &instruments);
7314 let listed: Vec<_> = snapshots
7315 .iter()
7316 .map(|s| (s.instrument.name().as_str(), s.position.quantity()))
7317 .collect();
7318 assert_eq!(
7322 listed,
7323 vec![
7324 ("tsla", Some(dec!(-2))),
7325 ("MSFT", Some(Decimal::ZERO)),
7326 ("BTC/USD", None),
7327 ("TSLA", None)
7328 ]
7329 );
7330 assert!(!snapshots[3].orders_complete);
7331 }
7332
7333 #[test]
7339 fn test_recover_fills_dedup_key_matches_ws_path() {
7340 let order_id = "ord-1";
7341
7342 let ws_keys: Vec<SmolStr> = ["1", "2"]
7345 .iter()
7346 .map(|filled_qty| {
7347 early_dedup_key(&AlpacaTradeUpdate {
7348 event: SmolStr::new("partial_fill"),
7349 execution_id: None,
7350 previous_execution_id: None,
7351 order: make_order_ws(order_id, "SPY", "buy", filled_qty),
7352 price: Some("150.00"),
7353 qty: Some("1"),
7354 timestamp: None,
7355 })
7356 })
7357 .collect();
7358
7359 let mut cumulative = Decimal::ZERO;
7362 let rest_keys: Vec<SmolStr> = ["1", "1"]
7363 .iter()
7364 .map(|exec_qty| {
7365 cumulative += Decimal::from_str(exec_qty).unwrap();
7366 fill_dedup_key(order_id, cumulative)
7367 })
7368 .collect();
7369
7370 assert_eq!(
7371 ws_keys, rest_keys,
7372 "REST recovery dedup keys must match WS path keys for cross-source dedup to work"
7373 );
7374 assert_eq!(ws_keys[0].as_str(), "ord-1:1");
7375 assert_eq!(ws_keys[1].as_str(), "ord-1:2");
7376 }
7377
7378 #[test]
7383 fn a_ws_fill_without_an_execution_id_is_identified_by_its_dedup_key() {
7384 let update = AlpacaTradeUpdate {
7385 event: SmolStr::new("fill"),
7386 execution_id: None,
7387 previous_execution_id: None,
7388 order: make_order_ws("ord-abc", "SPY", "buy", "5"),
7389 price: Some("150.00"),
7390 qty: Some("5"),
7391 timestamp: None,
7392 };
7393
7394 let early_key = early_dedup_key(&update);
7396
7397 let [event, _snapshot] = convert_trade_update(update);
7399 let Some(UnindexedAccountEvent {
7400 kind: AccountEventKind::Trade(trade),
7401 ..
7402 }) = event
7403 else {
7404 panic!("fill should produce an execution");
7405 };
7406
7407 assert_eq!(
7408 trade.id.0.as_str(),
7409 early_key.as_str(),
7410 "without an execution_id the TradeId is the dedup key"
7411 );
7412 assert_eq!(early_key.as_str(), "ord-abc:5");
7413 }
7414
7415 #[test]
7420 fn early_dedup_key_normalizes_decimal_strings() {
7421 let update1 = AlpacaTradeUpdate {
7423 event: SmolStr::new("fill"),
7424 execution_id: None,
7425 previous_execution_id: None,
7426 order: AlpacaOrderWs {
7427 id: SmolStr::new("ord-x"),
7428 client_order_id: Some(SmolStr::new("cid")),
7429 symbol: SmolStr::new("AAPL"),
7430 qty: Some("10"),
7431 filled_qty: Some("1.00"),
7432 side: SmolStr::new("buy"),
7433 order_type: SmolStr::new("market"),
7434 time_in_force: SmolStr::new("day"),
7435 limit_price: None,
7436 stop_price: None,
7437 trail_percent: None,
7438 trail_price: None,
7439 status: SmolStr::new("filled"),
7440 },
7441 price: Some("100.00"),
7442 qty: Some("10"),
7443 timestamp: None,
7444 };
7445 assert_eq!(early_dedup_key(&update1).as_str(), "ord-x:1");
7446
7447 let update2 = AlpacaTradeUpdate {
7449 event: SmolStr::new("fill"),
7450 execution_id: None,
7451 previous_execution_id: None,
7452 order: AlpacaOrderWs {
7453 id: SmolStr::new("ord-x"),
7454 client_order_id: Some(SmolStr::new("cid")),
7455 symbol: SmolStr::new("AAPL"),
7456 qty: Some("10"),
7457 filled_qty: Some("1"),
7458 side: SmolStr::new("buy"),
7459 order_type: SmolStr::new("market"),
7460 time_in_force: SmolStr::new("day"),
7461 limit_price: None,
7462 stop_price: None,
7463 trail_percent: None,
7464 trail_price: None,
7465 status: SmolStr::new("filled"),
7466 },
7467 price: Some("100.00"),
7468 qty: Some("10"),
7469 timestamp: None,
7470 };
7471 assert_eq!(early_dedup_key(&update2).as_str(), "ord-x:1");
7472
7473 let update3 = AlpacaTradeUpdate {
7475 event: SmolStr::new("fill"),
7476 execution_id: None,
7477 previous_execution_id: None,
7478 order: AlpacaOrderWs {
7479 id: SmolStr::new("ord-x"),
7480 client_order_id: Some(SmolStr::new("cid")),
7481 symbol: SmolStr::new("AAPL"),
7482 qty: Some("10"),
7483 filled_qty: Some("1.0"),
7484 side: SmolStr::new("buy"),
7485 order_type: SmolStr::new("market"),
7486 time_in_force: SmolStr::new("day"),
7487 limit_price: None,
7488 stop_price: None,
7489 trail_percent: None,
7490 trail_price: None,
7491 status: SmolStr::new("filled"),
7492 },
7493 price: Some("100.00"),
7494 qty: Some("10"),
7495 timestamp: None,
7496 };
7497 assert_eq!(early_dedup_key(&update3).as_str(), "ord-x:1");
7498 }
7499
7500 #[test]
7505 fn process_ws_text_rejected_event_without_filled_qty_is_not_dropped() {
7506 let (tx, mut rx) = mpsc::unbounded_channel();
7507 let dedup = new_dedup_cache();
7508
7509 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"}}}"#;
7511
7512 let known = KnownLiveOrders::shared(ExchangeId::AlpacaBroker);
7513 process_ws_text(json, &tx, &dedup, &known);
7514
7515 let event = rx.try_recv()
7517 .expect("rejected event without filled_qty must produce an AccountEvent, not be silently dropped");
7518 assert!(
7519 matches!(event.kind, AccountEventKind::OrderCancelled(_)),
7520 "rejected event must map to OrderCancelled, got: {:?}",
7521 event.kind
7522 );
7523 }
7524
7525 #[test]
7528 fn process_ws_text_identifies_a_fill_by_its_execution_id() {
7529 let frame = |execution_id: &str| {
7530 format!(
7531 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"}}}}"#
7532 )
7533 };
7534 let cases = [
7535 (
7536 r#""execution_id":"524b1902-817e-446c-825b-a9fcfebbc17e","#,
7537 "524b1902-817e-446c-825b-a9fcfebbc17e",
7538 ),
7539 (r#""execution_id":null,"#, "ord-1:2"),
7540 (r#""execution_id":"","#, "ord-1:2"),
7541 ("", "ord-1:2"),
7542 ];
7543 for (field, expected) in cases {
7544 let (tx, mut rx) = mpsc::unbounded_channel();
7545 let known = KnownLiveOrders::shared(ExchangeId::AlpacaBroker);
7546 process_ws_text(&frame(field), &tx, &new_dedup_cache(), &known);
7547 let Ok(UnindexedAccountEvent {
7548 kind: AccountEventKind::Trade(trade),
7549 ..
7550 }) = rx.try_recv()
7551 else {
7552 panic!("the fill frame {field:?} produces a Trade");
7553 };
7554 assert_eq!(trade.id.0.as_str(), expected, "frame {field:?}");
7555 }
7556 }
7557
7558 fn amendment_frame(event: &str, fields: &str) -> String {
7561 format!(
7562 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"}}}}"#
7563 )
7564 }
7565
7566 fn stream_events(frames: &[String]) -> Vec<UnindexedAccountEvent> {
7568 let (tx, mut rx) = mpsc::unbounded_channel();
7569 let dedup = new_dedup_cache();
7570 let known = KnownLiveOrders::shared(ExchangeId::AlpacaBroker);
7571 for frame in frames {
7572 process_ws_text(frame, &tx, &dedup, &known);
7573 }
7574 std::iter::from_fn(|| rx.try_recv().ok()).collect()
7575 }
7576
7577 fn amendment_of(
7579 frame: String,
7580 ) -> crate::trade::TradeAmendment<AssetNameExchange, InstrumentNameExchange> {
7581 let events = stream_events(&[frame]);
7582 let [
7583 UnindexedAccountEvent {
7584 kind: AccountEventKind::TradeAmended(amendment),
7585 ..
7586 },
7587 ] = <[_; 1]>::try_from(events).unwrap_or_else(|events| {
7588 panic!("one TradeAmended, got {events:?}");
7589 })
7590 else {
7591 panic!("expected TradeAmended");
7592 };
7593 amendment
7594 }
7595
7596 #[test]
7599 fn process_ws_text_reports_a_trade_bust() {
7600 let amendment = amendment_of(amendment_frame(
7601 "trade_bust",
7602 r#""execution_id":"exec-x","previous_execution_id":"exec-a","qty":"-2","#,
7603 ));
7604 assert_eq!(amendment.original, Some(TradeId::new("exec-a")));
7605 assert_eq!(amendment.order_id, OrderId::new("ord-1"));
7606 assert_eq!(amendment.instrument, InstrumentNameExchange::new("SPY"));
7607 assert_eq!(
7608 Some(amendment.time_exchange),
7609 parse_timestamp("2025-04-18T14:31:00Z")
7610 );
7611 assert_eq!(
7612 amendment.kind,
7613 TradeAmendmentKind::Busted {
7614 quantity: Some(Decimal::TWO)
7615 }
7616 );
7617 }
7618
7619 #[test]
7621 fn process_ws_text_reports_a_bust_that_names_no_fill() {
7622 let amendment = amendment_of(amendment_frame("trade_bust", ""));
7623 assert_eq!(amendment.original, None);
7624 assert_eq!(
7625 amendment.kind,
7626 TradeAmendmentKind::Busted { quantity: None }
7627 );
7628 }
7629
7630 #[test]
7633 fn process_ws_text_reports_a_trade_correct_with_its_replacement() {
7634 let amendment = amendment_of(amendment_frame(
7635 "trade_correct",
7636 r#""execution_id":"exec-c","previous_execution_id":"exec-a","price":"101.50","qty":"2","#,
7637 ));
7638 assert_eq!(amendment.original, Some(TradeId::new("exec-a")));
7639 let TradeAmendmentKind::Corrected { replacement } = amendment.kind else {
7640 panic!("a full replacement: {:?}", amendment.kind);
7641 };
7642 assert_eq!(replacement.id, TradeId::new("exec-c"));
7643 assert_eq!(replacement.order_id, OrderId::new("ord-1"));
7644 assert_eq!(replacement.side, Side::Buy);
7645 assert_eq!(replacement.price, Decimal::new(10150, 2));
7646 assert_eq!(replacement.quantity, Decimal::TWO);
7647 assert_eq!(replacement.order_filled_quantity, None);
7648 assert_eq!(replacement.time_exchange, amendment.time_exchange);
7649 }
7650
7651 #[test]
7654 fn process_ws_text_reports_a_correction_it_cannot_resolve() {
7655 let price = Some(Decimal::new(10150, 2));
7656 let cases = [
7657 (
7658 r#""execution_id":"exec-c","qty":"2","#,
7659 (Some("exec-c"), None, Some(Decimal::TWO)),
7660 ),
7661 (
7662 r#""price":"101.50","qty":"2","#,
7663 (None, price, Some(Decimal::TWO)),
7664 ),
7665 (
7666 r#""execution_id":"exec-c","price":"101.50","qty":"0","#,
7667 (Some("exec-c"), price, Some(Decimal::ZERO)),
7668 ),
7669 (
7670 r#""execution_id":"exec-c","price":"101.50","#,
7671 (Some("exec-c"), price, None),
7672 ),
7673 ];
7674 for (fields, (id, price, quantity)) in cases {
7675 let amendment = amendment_of(amendment_frame("trade_correct", fields));
7676 assert_eq!(
7677 amendment.kind,
7678 TradeAmendmentKind::CorrectedUnresolved {
7679 id: id.map(TradeId::new),
7680 price,
7681 quantity,
7682 },
7683 "{fields}"
7684 );
7685 }
7686 }
7687
7688 #[test]
7691 fn a_fill_after_a_bust_is_delivered_at_a_cumulative_seen_before() {
7692 let fill = |execution_id: &str| {
7693 format!(
7694 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"}}}}"#
7695 )
7696 };
7697 let events = stream_events(&[
7698 fill("exec-a"),
7699 amendment_frame(
7700 "trade_bust",
7701 r#""execution_id":"exec-x","previous_execution_id":"exec-a","qty":"-2","#,
7702 ),
7703 fill("exec-b"),
7704 ]);
7705
7706 let kinds: Vec<_> = events
7707 .iter()
7708 .filter_map(|event| match &event.kind {
7709 AccountEventKind::Trade(trade) => Some(format!("trade {}", trade.id)),
7710 AccountEventKind::TradeAmended(amendment) => {
7711 Some(format!("amended {:?}", amendment.original))
7712 }
7713 _ => None,
7714 })
7715 .collect();
7716 assert_eq!(
7717 kinds,
7718 vec![
7719 "trade exec-a",
7720 "amended Some(TradeId(\"exec-a\"))",
7721 "trade exec-b"
7722 ]
7723 );
7724 }
7725
7726 #[test]
7729 fn decimal_zero_normalize_is_zero_str() {
7730 assert_eq!(Decimal::ZERO.normalize().to_string(), "0");
7731 }
7732
7733 #[test]
7737 fn decimal_normalize_strips_trailing_zeros() {
7738 let from_rest = Decimal::from_str("1.00").unwrap().normalize();
7740 let from_ws = Decimal::from_str("1").unwrap().normalize();
7741 assert_eq!(from_rest.to_string(), from_ws.to_string());
7742 assert_eq!(from_rest.to_string(), "1");
7743
7744 assert_eq!(
7746 Decimal::from_str("100.000")
7747 .unwrap()
7748 .normalize()
7749 .to_string(),
7750 "100"
7751 );
7752 assert_eq!(
7753 Decimal::from_str("0.10").unwrap().normalize().to_string(),
7754 "0.1"
7755 );
7756 assert_eq!(
7757 Decimal::from_str("0.100").unwrap().normalize().to_string(),
7758 "0.1"
7759 );
7760 }
7761
7762 #[test]
7764 fn map_position_intent_open_buy_maps_to_buy_to_open() {
7765 assert_eq!(
7766 map_position_intent(Side::Buy, false),
7767 AlpacaPositionIntent::BuyToOpen
7768 );
7769 }
7770
7771 #[test]
7772 fn map_position_intent_open_sell_maps_to_sell_to_open() {
7773 assert_eq!(
7774 map_position_intent(Side::Sell, false),
7775 AlpacaPositionIntent::SellToOpen
7776 );
7777 }
7778
7779 #[test]
7780 fn map_position_intent_reduce_buy_maps_to_buy_to_close() {
7781 assert_eq!(
7782 map_position_intent(Side::Buy, true),
7783 AlpacaPositionIntent::BuyToClose
7784 );
7785 }
7786
7787 #[test]
7788 fn map_position_intent_reduce_sell_maps_to_sell_to_close() {
7789 assert_eq!(
7790 map_position_intent(Side::Sell, true),
7791 AlpacaPositionIntent::SellToClose
7792 );
7793 }
7794
7795 #[test]
7797 fn parse_order_error_401_maps_to_unauthenticated() {
7798 assert!(matches!(
7800 parse_order_error(reqwest::StatusCode::UNAUTHORIZED, None, "bad credentials"),
7801 UnindexedOrderError::Rejected(ApiError::Unauthenticated(_))
7802 ));
7803 }
7804
7805 #[test]
7806 fn parse_order_error_403_without_code_maps_to_unauthenticated() {
7807 assert!(matches!(
7809 parse_order_error(reqwest::StatusCode::FORBIDDEN, None, "forbidden."),
7810 UnindexedOrderError::Rejected(ApiError::Unauthenticated(_))
7811 ));
7812 }
7813
7814 #[test]
7815 fn parse_order_error_403_with_code_and_insufficient_maps_to_balance_insufficient() {
7816 for message in [
7817 "insufficient buying power",
7818 "insufficient day trading buying power",
7819 "insufficient qty available for order (requested: 10, available: 0)",
7820 ] {
7821 let err = parse_order_error(reqwest::StatusCode::FORBIDDEN, Some(40310000), message);
7822 let UnindexedOrderError::Rejected(ApiError::BalanceInsufficient(None, text)) = err
7823 else {
7824 panic!("expected BalanceInsufficient for {message:?}, got {err:?}");
7825 };
7826 assert_eq!(text, format!("40310000 {message}"));
7827 }
7828 }
7829
7830 #[test]
7831 fn parse_order_error_reads_a_refused_short_by_its_text() {
7832 for (status, code, message, reason) in [
7833 (
7834 reqwest::StatusCode::FORBIDDEN,
7835 Some(40310000),
7836 "account is not allowed to short",
7837 BorrowRejectReason::BorrowDisabled,
7838 ),
7839 (
7841 reqwest::StatusCode::FORBIDDEN,
7842 Some(40310000),
7843 "asset XYZ cannot be sold short",
7844 BorrowRejectReason::NotShortable,
7845 ),
7846 (
7847 reqwest::StatusCode::UNPROCESSABLE_ENTITY,
7848 Some(42210000),
7849 "asset XYZ cannot be sold short",
7850 BorrowRejectReason::NotShortable,
7851 ),
7852 (
7853 reqwest::StatusCode::UNPROCESSABLE_ENTITY,
7854 None,
7855 "account is not allowed to short",
7856 BorrowRejectReason::BorrowDisabled,
7857 ),
7858 ] {
7859 assert_eq!(
7860 parse_order_error(status, code, message),
7861 UnindexedOrderError::Rejected(ApiError::BorrowRejected(BorrowReject::new(
7862 reason,
7863 code.map(|code| code.to_string()),
7864 message.to_owned(),
7865 ))),
7866 "{status} {message:?}"
7867 );
7868 }
7869
7870 for (status, code) in [
7873 (reqwest::StatusCode::UNPROCESSABLE_ENTITY, 42210000),
7874 (reqwest::StatusCode::FORBIDDEN, 40310000),
7875 ] {
7876 assert_eq!(
7877 parse_order_error(status, Some(code), "fractional orders cannot be sold short"),
7878 UnindexedOrderError::Rejected(ApiError::OrderRejected(format!(
7879 "{code} fractional orders cannot be sold short"
7880 ))),
7881 "{status}"
7882 );
7883 }
7884
7885 assert!(matches!(
7887 parse_order_error(
7888 reqwest::StatusCode::FORBIDDEN,
7889 Some(40310000),
7890 "insufficient qty: account is not allowed to short",
7891 ),
7892 UnindexedOrderError::Rejected(ApiError::BorrowRejected(_))
7893 ));
7894
7895 assert!(matches!(
7897 parse_order_error(
7898 reqwest::StatusCode::FORBIDDEN,
7899 None,
7900 "account is not allowed to short"
7901 ),
7902 UnindexedOrderError::Rejected(ApiError::Unauthenticated(_))
7903 ));
7904 }
7905
7906 #[test]
7907 fn parse_order_error_403_with_code_maps_to_order_rejected() {
7908 for (code, message) in [
7909 (40310000, "account is restricted to liquidation only"),
7910 (
7911 40310100,
7912 "trade denied due to pattern day trading protection",
7913 ),
7914 ] {
7915 let err = parse_order_error(reqwest::StatusCode::FORBIDDEN, Some(code), message);
7916 let UnindexedOrderError::Rejected(ApiError::OrderRejected(text)) = err else {
7917 panic!("expected OrderRejected for {message:?}, got {err:?}");
7918 };
7919 assert_eq!(text, format!("{code} {message}"));
7920 }
7921 }
7922
7923 #[test]
7924 fn alpaca_api_error_reads_code_and_falls_back_to_raw_text() {
7925 let coded = AlpacaApiError::from_body(
7926 br#"{"code": 40310000, "message": "insufficient buying power"}"#,
7927 );
7928 assert_eq!(coded.code, Some(40310000));
7929 assert_eq!(coded.message, "insufficient buying power");
7930
7931 let bare = AlpacaApiError::from_body(br#"{"message": "forbidden."}"#);
7932 assert_eq!(bare.code, None);
7933 assert_eq!(bare.message, "forbidden.");
7934
7935 let raw = AlpacaApiError::from_body(b"upstream connect error");
7936 assert_eq!(raw.code, None);
7937 assert_eq!(raw.message, "upstream connect error");
7938 }
7939
7940 #[test]
7941 fn parse_order_error_404_maps_to_order_rejected_with_not_found_prefix() {
7942 let err = parse_order_error(reqwest::StatusCode::NOT_FOUND, None, "order not found");
7943 let UnindexedOrderError::Rejected(ApiError::OrderRejected(msg)) = err else {
7944 panic!("expected OrderRejected, got {err:?}");
7945 };
7946 assert!(
7947 msg.contains("order not found"),
7948 "message should contain 'order not found': {msg}"
7949 );
7950 }
7951
7952 #[test]
7953 fn parse_order_error_422_insufficient_only_maps_to_balance_insufficient() {
7954 assert!(matches!(
7956 parse_order_error(
7957 reqwest::StatusCode::UNPROCESSABLE_ENTITY,
7958 None,
7959 "insufficient funds for this order"
7960 ),
7961 UnindexedOrderError::Rejected(ApiError::BalanceInsufficient(None, _))
7962 ));
7963 }
7964
7965 #[test]
7966 fn parse_order_error_429_maps_to_rate_limit() {
7967 assert!(matches!(
7968 parse_order_error(reqwest::StatusCode::TOO_MANY_REQUESTS, None, "rate limited"),
7969 UnindexedOrderError::Rejected(ApiError::RateLimit {
7970 retry_after: None,
7971 ..
7972 })
7973 ));
7974 }
7975
7976 #[test]
7977 fn a_rate_limit_delay_is_capped_at_alpacas_window() {
7978 let window = Some(Duration::from_secs(DEFAULT_RATE_LIMIT_DELAY_SECS));
7979 for (name, value) in [("retry-after", u64::MAX), ("x-ratelimit-reset", u64::MAX)] {
7980 let mut headers = reqwest::header::HeaderMap::new();
7981 headers.insert(name, value.to_string().parse().unwrap());
7982 assert_eq!(parse_rate_limit_delay(&headers), window, "{name}");
7983 RateLimitTracker::new().on_rate_limited(parse_rate_limit_delay(&headers));
7985 }
7986
7987 let mut headers = reqwest::header::HeaderMap::new();
7988 headers.insert("retry-after", "0".parse().unwrap());
7989 assert_eq!(
7990 parse_rate_limit_delay(&headers),
7991 Some(Duration::ZERO),
7992 "taken as given"
7993 );
7994 }
7995
7996 #[test]
8000 fn parse_time_in_force_unknown_value_falls_back_to_good_until_end_of_day() {
8001 assert_eq!(
8002 parse_time_in_force("opg"),
8003 TimeInForce::GoodUntilEndOfDay,
8004 "unknown TIF must fall back to GoodUntilEndOfDay (with a warn! in production)"
8005 );
8006 }
8007
8008 #[test]
8011 fn build_instrument_snapshots_output_order_matches_instruments_slice() {
8012 let orders = vec![
8014 make_order_response("o1", "SPY"),
8015 make_order_response("o2", "AAPL"),
8016 make_order_response("o3", "MSFT"),
8017 ];
8018 let instruments = vec![
8020 InstrumentNameExchange::new("MSFT"),
8021 InstrumentNameExchange::new("AAPL"),
8022 ];
8023 let snapshots = build_instrument_snapshots(orders, Vec::new(), &instruments);
8024 assert_eq!(snapshots.len(), 2);
8025 assert_eq!(
8026 snapshots[0].instrument.name().as_str(),
8027 "MSFT",
8028 "first snapshot must be MSFT (first in instruments slice)"
8029 );
8030 assert_eq!(
8031 snapshots[1].instrument.name().as_str(),
8032 "AAPL",
8033 "second snapshot must be AAPL (second in instruments slice)"
8034 );
8035 }
8036
8037 mod http_tests {
8044 use super::super::*;
8045 use wiremock::matchers::{method, path};
8046 use wiremock::{Mock, MockServer, Request, Respond, ResponseTemplate};
8047
8048 struct Sequential {
8055 call: std::sync::atomic::AtomicU32,
8056 pages: Vec<serde_json::Value>,
8057 }
8058
8059 impl Sequential {
8060 fn new(pages: Vec<serde_json::Value>) -> Self {
8061 Self {
8062 call: std::sync::atomic::AtomicU32::new(0),
8063 pages,
8064 }
8065 }
8066 }
8067
8068 impl Respond for Sequential {
8069 fn respond(&self, _: &Request) -> ResponseTemplate {
8070 let i = self.call.fetch_add(1, std::sync::atomic::Ordering::Relaxed) as usize;
8071 let body = self.pages.get(i).unwrap_or_else(|| {
8072 panic!(
8073 "Sequential: request #{i} has no configured response \
8074 (only {} page(s) supplied)",
8075 self.pages.len()
8076 )
8077 });
8078 ResponseTemplate::new(200).set_body_json(body)
8079 }
8080 }
8081
8082 fn make_activities_json(count: usize, id_prefix: &str) -> serde_json::Value {
8084 serde_json::Value::Array(
8085 (0..count)
8086 .map(|i| {
8087 serde_json::json!({
8088 "id": format!("{id_prefix}-{i:05}"),
8089 "order_id": "ord-1",
8090 "symbol": "SPY",
8091 "side": "buy",
8092 "price": "100.00",
8093 "qty": "1",
8094 "transaction_time": "2025-04-18T14:30:00Z"
8095 })
8096 })
8097 .collect(),
8098 )
8099 }
8100
8101 fn make_orders_json(count: usize) -> serde_json::Value {
8103 serde_json::Value::Array(
8104 (0..count)
8105 .map(|i| {
8106 serde_json::json!({
8107 "id": format!("order-{i:05}"),
8108 "client_order_id": null,
8109 "symbol": "SPY",
8110 "qty": "1",
8111 "filled_qty": "0",
8112 "side": "buy",
8113 "type": "limit",
8114 "time_in_force": "day",
8115 "limit_price": "100.00",
8116 "created_at": "2025-04-18T14:30:00Z"
8117 })
8118 })
8119 .collect(),
8120 )
8121 }
8122
8123 #[tokio::test]
8128 async fn paginate_activities_single_page_below_max_returns_all_not_truncated() {
8129 let server = MockServer::start().await;
8130 Mock::given(method("GET"))
8131 .and(path("/v2/account/activities"))
8132 .respond_with(
8133 ResponseTemplate::new(200).set_body_json(make_activities_json(5, "act")),
8134 )
8135 .mount(&server)
8136 .await;
8137
8138 let http = reqwest::Client::new();
8139 let rl = RateLimitTracker::new();
8140 let result = paginate_activities::<AlpacaActivity>(
8141 &http,
8142 &rl,
8143 &server.uri(),
8144 FILL_ACTIVITIES,
8145 recovery_after(),
8146 None,
8147 )
8148 .await
8149 .unwrap();
8150
8151 assert_eq!(result.activities.len(), 5);
8152 assert_eq!(result.resume, None);
8153 assert_eq!(server.received_requests().await.unwrap().len(), 1);
8154 }
8155
8156 #[tokio::test]
8159 async fn paginate_activities_exactly_page_size_items_fetches_second_page() {
8160 let server = MockServer::start().await;
8161 Mock::given(method("GET"))
8162 .and(path("/v2/account/activities"))
8163 .respond_with(Sequential::new(vec![
8164 make_activities_json(ALPACA_MAX_ACTIVITIES, "act"),
8165 serde_json::json!([]), ]))
8167 .mount(&server)
8168 .await;
8169
8170 let http = reqwest::Client::new();
8171 let rl = RateLimitTracker::new();
8172 let result = paginate_activities::<AlpacaActivity>(
8173 &http,
8174 &rl,
8175 &server.uri(),
8176 FILL_ACTIVITIES,
8177 recovery_after(),
8178 None,
8179 )
8180 .await
8181 .unwrap();
8182
8183 assert_eq!(result.activities.len(), ALPACA_MAX_ACTIVITIES);
8184 assert_eq!(result.resume, None);
8185 assert_eq!(
8186 server.received_requests().await.unwrap().len(),
8187 2,
8188 "exactly 2 requests: first full page + second empty page"
8189 );
8190 }
8191
8192 #[tokio::test]
8195 async fn paginate_activities_two_pages_returns_combined_activities_not_truncated() {
8196 let server = MockServer::start().await;
8197 Mock::given(method("GET"))
8198 .and(path("/v2/account/activities"))
8199 .respond_with(Sequential::new(vec![
8200 make_activities_json(ALPACA_MAX_ACTIVITIES, "p1"),
8201 make_activities_json(37, "p2"),
8202 ]))
8203 .mount(&server)
8204 .await;
8205
8206 let http = reqwest::Client::new();
8207 let rl = RateLimitTracker::new();
8208 let result = paginate_activities::<AlpacaActivity>(
8209 &http,
8210 &rl,
8211 &server.uri(),
8212 FILL_ACTIVITIES,
8213 recovery_after(),
8214 None,
8215 )
8216 .await
8217 .unwrap();
8218
8219 assert_eq!(result.activities.len(), ALPACA_MAX_ACTIVITIES + 37);
8220 assert_eq!(result.resume, None);
8221 assert_eq!(server.received_requests().await.unwrap().len(), 2);
8222 }
8223
8224 #[tokio::test]
8228 async fn paginate_activities_at_max_pages_sets_truncated_true() {
8229 let server = MockServer::start().await;
8230
8231 Mock::given(method("GET"))
8233 .and(path("/v2/account/activities"))
8234 .respond_with(
8235 ResponseTemplate::new(200)
8236 .set_body_json(make_activities_json(ALPACA_MAX_ACTIVITIES, "act")),
8237 )
8238 .mount(&server)
8239 .await;
8240
8241 let http = reqwest::Client::new();
8242 let rl = RateLimitTracker::new();
8243 let result = paginate_activities::<AlpacaActivity>(
8244 &http,
8245 &rl,
8246 &server.uri(),
8247 FILL_ACTIVITIES,
8248 recovery_after(),
8249 None,
8250 )
8251 .await
8252 .unwrap();
8253
8254 assert!(
8255 result.resume.is_some(),
8256 "must say where to read on after MAX_ACTIVITY_PAGES pages"
8257 );
8258 assert_eq!(
8259 result.activities.len(),
8260 MAX_ACTIVITY_PAGES * ALPACA_MAX_ACTIVITIES,
8261 "must accumulate exactly MAX_ACTIVITY_PAGES * page_size activities"
8262 );
8263 assert_eq!(
8266 server.received_requests().await.unwrap().len(),
8267 MAX_ACTIVITY_PAGES,
8268 "loop must issue exactly MAX_ACTIVITY_PAGES requests then stop"
8269 );
8270 }
8271
8272 #[tokio::test]
8276 async fn fetch_raw_open_orders_499_results_returns_ok() {
8277 let server = MockServer::start().await;
8278 Mock::given(method("GET"))
8279 .and(path("/v2/orders"))
8280 .respond_with(
8281 ResponseTemplate::new(200).set_body_json(make_orders_json(MAX_OPEN_ORDERS - 1)),
8282 )
8283 .mount(&server)
8284 .await;
8285
8286 let http = reqwest::Client::new();
8287 let rl = RateLimitTracker::new();
8288 let result = fetch_raw_open_orders(&http, &rl, &server.uri(), &[]).await;
8289
8290 assert!(
8291 result.is_ok(),
8292 "499 orders must not trigger truncation: {result:?}"
8293 );
8294 assert_eq!(result.unwrap().len(), MAX_OPEN_ORDERS - 1);
8295 }
8296
8297 #[tokio::test]
8301 async fn fetch_raw_open_orders_500_results_returns_truncated_snapshot_error() {
8302 let server = MockServer::start().await;
8303 Mock::given(method("GET"))
8304 .and(path("/v2/orders"))
8305 .respond_with(
8306 ResponseTemplate::new(200).set_body_json(make_orders_json(MAX_OPEN_ORDERS)),
8307 )
8308 .mount(&server)
8309 .await;
8310
8311 let http = reqwest::Client::new();
8312 let rl = RateLimitTracker::new();
8313 let result = fetch_raw_open_orders(&http, &rl, &server.uri(), &[]).await;
8314
8315 assert!(
8316 matches!(
8317 result,
8318 Err(UnindexedClientError::TruncatedSnapshot { limit }) if limit == MAX_OPEN_ORDERS
8319 ),
8320 "500 orders must return TruncatedSnapshot, got: {result:?}"
8321 );
8322 }
8323
8324 async fn mount_account_and_positions(server: &MockServer) {
8329 Mock::given(method("GET"))
8330 .and(path("/v2/account"))
8331 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
8332 "status": "ACTIVE",
8333 "currency": "USD",
8334 "cash": "-1500.50",
8335 "equity": "4000.00",
8336 "buying_power": "5000.00",
8337 "options_buying_power": "2000.00",
8338 "non_marginable_buying_power": "1000.00",
8339 })))
8340 .mount(server)
8341 .await;
8342 Mock::given(method("GET"))
8343 .and(path("/v2/positions"))
8344 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([
8345 {
8346 "symbol": "XYZ",
8347 "asset_class": "us_equity",
8348 "side": "short",
8349 "qty": "-10",
8350 "qty_available": "-10",
8351 "avg_entry_price": "20.00",
8352 "unrealized_pl": "15.50",
8353 },
8354 {
8355 "symbol": "XYZ271217C00025000",
8356 "asset_class": "us_option",
8357 "side": "long",
8358 "qty": "3",
8359 "qty_available": "3",
8360 "avg_entry_price": "0.85",
8361 "unrealized_pl": "-45",
8362 },
8363 {
8364 "symbol": "ABC",
8365 "asset_class": "us_equity",
8366 "side": "long",
8367 "qty": "2.5",
8368 "qty_available": "2.5",
8369 "avg_entry_price": "40.00",
8370 "unrealized_pl": "1.25",
8371 },
8372 {
8373 "symbol": "BTCUSD",
8374 "asset_class": "crypto",
8375 "side": "long",
8376 "qty": "0.75",
8377 "qty_available": "0.5",
8378 "avg_entry_price": "50000",
8379 "unrealized_pl": "0",
8380 },
8381 ])))
8382 .mount(server)
8383 .await;
8384 Mock::given(method("GET"))
8385 .and(path("/v2/orders"))
8386 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([])))
8387 .mount(server)
8388 .await;
8389 }
8390
8391 fn client_for(server: &MockServer) -> AlpacaClient {
8392 AlpacaClient::new(AlpacaConfig::with_base_url(
8393 "test-key".into(),
8394 "test-secret".into(),
8395 server.uri(),
8396 ))
8397 }
8398
8399 #[tokio::test]
8400 async fn account_snapshot_reports_cash_positions_and_crypto_balances() {
8401 use crate::client::ExecutionClient;
8402 use rust_decimal_macros::dec;
8403
8404 let server = MockServer::start().await;
8405 mount_account_and_positions(&server).await;
8406
8407 let snapshot = client_for(&server)
8408 .account_snapshot(&[], &[])
8409 .await
8410 .unwrap();
8411
8412 let balances: Vec<_> = snapshot
8414 .balances
8415 .iter()
8416 .map(|b| (b.asset.name().as_str(), b.balance.total, b.balance.free))
8417 .collect();
8418 assert_eq!(
8419 balances,
8420 vec![
8421 ("USD", dec!(-1500.50), dec!(-1500.50)),
8422 ("BTC", dec!(0.75), dec!(0.5)),
8423 ],
8424 "crypto stays a balance; equities and options do not appear as balances"
8425 );
8426
8427 let positions: Vec<_> = snapshot
8428 .instruments
8429 .iter()
8430 .map(|i| {
8431 let p = i.position.open().unwrap();
8432 (
8433 i.instrument.name().as_str(),
8434 p.quantity,
8435 p.entry_price,
8436 p.unrealized_pnl,
8437 )
8438 })
8439 .collect();
8440 assert_eq!(
8441 positions,
8442 vec![
8443 ("XYZ", dec!(-10), Some(dec!(20.00)), Some(dec!(15.50))),
8444 (
8445 "XYZ271217C00025000",
8446 dec!(3),
8447 Some(dec!(0.85)),
8448 Some(dec!(-45))
8449 ),
8450 ("ABC", dec!(2.5), Some(dec!(40.00)), Some(dec!(1.25))),
8451 ],
8452 "short equity, option and fractional positions; no crypto position"
8453 );
8454 }
8455
8456 #[tokio::test]
8459 async fn account_snapshot_usd_only_still_reports_positions() {
8460 use crate::client::ExecutionClient;
8461 use rust_decimal_macros::dec;
8462
8463 let server = MockServer::start().await;
8464 mount_account_and_positions(&server).await;
8465
8466 let usd = [AssetNameExchange::new("USD")];
8467 let instruments = [InstrumentNameExchange::new("XYZ")];
8468 let snapshot = client_for(&server)
8469 .account_snapshot(&usd, &instruments)
8470 .await
8471 .unwrap();
8472
8473 assert_eq!(snapshot.balances.len(), 1);
8474 assert_eq!(snapshot.balances[0].asset, AssetNameExchange::new("USD"));
8475 assert_eq!(snapshot.balances[0].balance.total, dec!(-1500.50));
8476
8477 assert_eq!(snapshot.instruments.len(), 1);
8478 assert_eq!(snapshot.instruments[0].position.quantity(), Some(dec!(-10)));
8479 }
8480
8481 #[tokio::test]
8483 async fn account_snapshot_non_usd_only_skips_account_request() {
8484 use crate::client::ExecutionClient;
8485
8486 let server = MockServer::start().await;
8487 mount_account_and_positions(&server).await;
8488
8489 let btc = [AssetNameExchange::new("btc")];
8490 let snapshot = client_for(&server)
8491 .account_snapshot(&btc, &[])
8492 .await
8493 .unwrap();
8494
8495 assert_eq!(snapshot.balances.len(), 1);
8496 assert_eq!(snapshot.balances[0].asset, AssetNameExchange::new("BTC"));
8497 let requests = server.received_requests().await.unwrap();
8498 assert!(
8499 requests.iter().all(|r| r.url.path() != "/v2/account"),
8500 "no USD requested, so /v2/account must not be fetched"
8501 );
8502 }
8503
8504 #[tokio::test]
8512 async fn open_order_reduce_only_sell_sends_sell_to_close_intent() {
8513 use crate::client::ExecutionClient;
8514 use crate::order::request::{OrderRequestOpen, RequestOpen};
8515 use crate::order::{
8516 OrderKey, OrderKind, TimeInForce,
8517 id::{ClientOrderId, StrategyId},
8518 };
8519 use rust_decimal::Decimal;
8520 use rustrade_instrument::Side;
8521 use rustrade_instrument::exchange::ExchangeId;
8522 use rustrade_instrument::instrument::name::InstrumentNameExchange;
8523 use wiremock::matchers::{method, path};
8524
8525 let server = MockServer::start().await;
8526
8527 let captured_body = std::sync::Arc::new(parking_lot::Mutex::new(None));
8530 let captured_clone = captured_body.clone();
8531
8532 Mock::given(method("POST"))
8533 .and(path("/v2/orders"))
8534 .respond_with(move |req: &Request| {
8535 let body: serde_json::Value = serde_json::from_slice(&req.body).unwrap();
8537 *captured_clone.lock() = Some(body);
8538
8539 ResponseTemplate::new(200).set_body_json(serde_json::json!({
8540 "id": "test-order-id",
8541 "client_order_id": "test-cid",
8542 "symbol": "AAPL",
8543 "qty": "10",
8544 "filled_qty": "0",
8545 "side": "sell",
8546 "type": "market",
8547 "time_in_force": "ioc",
8548 "limit_price": null,
8549 "created_at": "2025-04-18T14:30:00Z"
8550 }))
8551 })
8552 .mount(&server)
8553 .await;
8554
8555 let config =
8557 AlpacaConfig::with_base_url("test-key".into(), "test-secret".into(), server.uri());
8558 let client = AlpacaClient::new(config);
8559
8560 let request = OrderRequestOpen {
8562 key: OrderKey {
8563 exchange: ExchangeId::AlpacaBroker,
8564 instrument: InstrumentNameExchange::new("AAPL"),
8565 strategy: StrategyId::new("test-strategy"),
8566 cid: ClientOrderId::new("test-cid"),
8567 },
8568 state: RequestOpen {
8569 side: Side::Sell,
8570 price: None,
8571 quantity: Decimal::new(10, 0),
8572 kind: OrderKind::Market,
8573 time_in_force: TimeInForce::ImmediateOrCancel,
8574 position_id: None,
8575 reduce_only: true, market: None,
8577 },
8578 };
8579
8580 let order = client
8582 .open_order(OrderRequestOpen {
8583 key: OrderKey {
8584 exchange: request.key.exchange,
8585 instrument: &request.key.instrument,
8586 strategy: request.key.strategy.clone(),
8587 cid: request.key.cid.clone(),
8588 },
8589 state: request.state.clone(),
8590 })
8591 .await;
8592
8593 assert!(
8595 order.state.is_accepted(),
8596 "order should be accepted: {:?}",
8597 order.state
8598 );
8599
8600 let body = captured_body
8602 .lock()
8603 .take()
8604 .expect("request body should be captured");
8605 assert_eq!(
8606 body.get("position_intent").and_then(|v| v.as_str()),
8607 Some("sell_to_close"),
8608 "reduce_only=true + Side::Sell should produce position_intent=sell_to_close, got: {body}"
8609 );
8610 }
8611
8612 #[tokio::test]
8614 async fn open_order_not_reduce_only_buy_sends_buy_to_open_intent() {
8615 use crate::client::ExecutionClient;
8616 use crate::order::request::{OrderRequestOpen, RequestOpen};
8617 use crate::order::{
8618 OrderKey, OrderKind, TimeInForce,
8619 id::{ClientOrderId, StrategyId},
8620 };
8621 use rust_decimal::Decimal;
8622 use rustrade_instrument::Side;
8623 use rustrade_instrument::exchange::ExchangeId;
8624 use rustrade_instrument::instrument::name::InstrumentNameExchange;
8625
8626 let server = MockServer::start().await;
8627
8628 let captured_body = std::sync::Arc::new(parking_lot::Mutex::new(None));
8629 let captured_clone = captured_body.clone();
8630
8631 Mock::given(method("POST"))
8632 .and(path("/v2/orders"))
8633 .respond_with(move |req: &Request| {
8634 let body: serde_json::Value = serde_json::from_slice(&req.body).unwrap();
8635 *captured_clone.lock() = Some(body);
8636
8637 ResponseTemplate::new(200).set_body_json(serde_json::json!({
8638 "id": "test-order-id",
8639 "client_order_id": "test-cid",
8640 "symbol": "AAPL",
8641 "qty": "10",
8642 "filled_qty": "0",
8643 "side": "buy",
8644 "type": "market",
8645 "time_in_force": "ioc",
8646 "limit_price": null,
8647 "created_at": "2025-04-18T14:30:00Z"
8648 }))
8649 })
8650 .mount(&server)
8651 .await;
8652
8653 let config =
8654 AlpacaConfig::with_base_url("test-key".into(), "test-secret".into(), server.uri());
8655 let client = AlpacaClient::new(config);
8656
8657 let instrument = InstrumentNameExchange::new("AAPL");
8658 let request = OrderRequestOpen {
8659 key: OrderKey {
8660 exchange: ExchangeId::AlpacaBroker,
8661 instrument: &instrument,
8662 strategy: StrategyId::new("test-strategy"),
8663 cid: ClientOrderId::new("test-cid"),
8664 },
8665 state: RequestOpen {
8666 side: Side::Buy,
8667 price: None,
8668 quantity: Decimal::new(10, 0),
8669 kind: OrderKind::Market,
8670 time_in_force: TimeInForce::ImmediateOrCancel,
8671 position_id: None,
8672 reduce_only: false, market: None,
8674 },
8675 };
8676
8677 let order = client.open_order(request).await;
8678
8679 assert!(
8680 order.state.is_accepted(),
8681 "order should be accepted: {:?}",
8682 order.state
8683 );
8684
8685 let body = captured_body
8686 .lock()
8687 .take()
8688 .expect("request body should be captured");
8689 assert_eq!(
8690 body.get("position_intent").and_then(|v| v.as_str()),
8691 Some("buy_to_open"),
8692 "reduce_only=false + Side::Buy should produce position_intent=buy_to_open, got: {body}"
8693 );
8694 }
8695
8696 #[tokio::test]
8699 async fn rest_with_retry_reports_an_undecodable_2xx_body_as_internal() {
8700 let server = MockServer::start().await;
8701 Mock::given(method("GET"))
8702 .and(path("/v2/account"))
8703 .respond_with(ResponseTemplate::new(200).set_body_string("not json"))
8704 .mount(&server)
8705 .await;
8706
8707 let http = reqwest::Client::new();
8708 let rl = RateLimitTracker::new();
8709 let url = format!("{}/v2/account", server.uri());
8710 let result: Result<serde_json::Value, _> =
8711 rest_with_retry(&rl, || http.get(&url)).await;
8712 assert!(
8713 matches!(result, Err(UnindexedClientError::Internal(_))),
8714 "{result:?}"
8715 );
8716 }
8717
8718 async fn holds_back(rl: &RateLimitTracker) -> bool {
8720 tokio::time::timeout(Duration::from_millis(50), rl.wait_if_blocked())
8721 .await
8722 .is_err()
8723 }
8724
8725 fn with_rate_limit(
8727 template: ResponseTemplate,
8728 remaining: u32,
8729 reset: bool,
8730 ) -> ResponseTemplate {
8731 let template = template.insert_header("x-ratelimit-remaining", remaining.to_string());
8732 if !reset {
8733 return template;
8734 }
8735 let now_secs = std::time::SystemTime::now()
8736 .duration_since(std::time::UNIX_EPOCH)
8737 .unwrap()
8738 .as_secs();
8739 template.insert_header("x-ratelimit-reset", (now_secs + 30).to_string())
8740 }
8741
8742 #[tokio::test]
8745 async fn rest_with_retry_pauses_once_no_requests_remain() {
8746 for (remaining, reset, pauses) in [(0, true, true), (1, true, false), (0, false, false)]
8747 {
8748 let server = MockServer::start().await;
8749 Mock::given(method("GET"))
8750 .and(path("/v2/account"))
8751 .respond_with(with_rate_limit(
8752 ResponseTemplate::new(200).set_body_json(serde_json::json!({})),
8753 remaining,
8754 reset,
8755 ))
8756 .mount(&server)
8757 .await;
8758
8759 let http = reqwest::Client::new();
8760 let rl = RateLimitTracker::new();
8761 let url = format!("{}/v2/account", server.uri());
8762 let _: serde_json::Value = rest_with_retry(&rl, || http.get(&url)).await.unwrap();
8763 assert_eq!(
8764 holds_back(&rl).await,
8765 pauses,
8766 "remaining {remaining}, reset {reset}"
8767 );
8768 }
8769 }
8770
8771 #[tokio::test]
8774 async fn a_pause_lasts_a_minute_at_most() {
8775 tokio::time::pause();
8776 let now_secs = std::time::SystemTime::now()
8777 .duration_since(std::time::UNIX_EPOCH)
8778 .unwrap()
8779 .as_secs();
8780 let mut headers = reqwest::header::HeaderMap::new();
8781 headers.insert("x-ratelimit-remaining", "0".parse().unwrap());
8782 headers.insert(
8783 "x-ratelimit-reset",
8784 (now_secs + 3_600).to_string().parse().unwrap(),
8785 );
8786 let rl = RateLimitTracker::new();
8787
8788 observe_rate_limit_remaining(&rl, &headers);
8789 assert!(holds_back(&rl).await);
8790 tokio::time::advance(Duration::from_secs(DEFAULT_RATE_LIMIT_DELAY_SECS)).await;
8791 assert!(!holds_back(&rl).await);
8792 }
8793
8794 #[tokio::test]
8796 async fn rest_delete_with_retry_pauses_once_no_requests_remain() {
8797 let server = MockServer::start().await;
8798 Mock::given(method("DELETE"))
8799 .and(path("/v2/orders/abc"))
8800 .respond_with(with_rate_limit(ResponseTemplate::new(204), 0, true))
8801 .mount(&server)
8802 .await;
8803
8804 let http = reqwest::Client::new();
8805 let rl = RateLimitTracker::new();
8806 let url = format!("{}/v2/orders/abc", server.uri());
8807 rest_delete_with_retry(&rl, || http.delete(&url))
8808 .await
8809 .unwrap();
8810 assert!(holds_back(&rl).await);
8811 }
8812
8813 #[tokio::test]
8816 async fn open_order_with_an_undecodable_2xx_response_is_status_unknown() {
8817 use crate::client::ExecutionClient;
8818 use crate::error::OrderError;
8819 use crate::order::request::{OrderRequestOpen, RequestOpen};
8820 use crate::order::state::{InactiveOrderState, OrderState};
8821 use crate::order::{
8822 OrderKey, OrderKind, TimeInForce,
8823 id::{ClientOrderId, StrategyId},
8824 };
8825 use rustrade_instrument::Side;
8826 use rustrade_instrument::exchange::ExchangeId;
8827 use rustrade_instrument::instrument::name::InstrumentNameExchange;
8828
8829 let server = MockServer::start().await;
8830 Mock::given(method("POST"))
8831 .and(path("/v2/orders"))
8832 .respond_with(
8833 ResponseTemplate::new(200).set_body_json(serde_json::json!({ "id": 1 })),
8834 )
8835 .mount(&server)
8836 .await;
8837
8838 let instrument = InstrumentNameExchange::new("AAPL");
8839 let order = client_for(&server)
8840 .open_order(OrderRequestOpen {
8841 key: OrderKey {
8842 exchange: ExchangeId::AlpacaBroker,
8843 instrument: &instrument,
8844 strategy: StrategyId::new("test-strategy"),
8845 cid: ClientOrderId::new("test-cid"),
8846 },
8847 state: RequestOpen {
8848 side: Side::Buy,
8849 price: None,
8850 quantity: Decimal::new(10, 0),
8851 kind: OrderKind::Market,
8852 time_in_force: TimeInForce::ImmediateOrCancel,
8853 position_id: None,
8854 reduce_only: false,
8855 market: None,
8856 },
8857 })
8858 .await;
8859
8860 assert!(
8861 matches!(
8862 order.state,
8863 OrderState::Inactive(InactiveOrderState::OpenFailed(OrderError::Connectivity(
8864 _
8865 )))
8866 ),
8867 "{:?}",
8868 order.state
8869 );
8870 }
8871
8872 #[tokio::test]
8875 async fn open_order_reports_a_403_by_its_code() {
8876 use crate::client::ExecutionClient;
8877 use crate::error::OrderError;
8878 use crate::order::request::{OrderRequestOpen, RequestOpen};
8879 use crate::order::state::{InactiveOrderState, OrderState};
8880 use crate::order::{
8881 OrderKey, OrderKind, TimeInForce,
8882 id::{ClientOrderId, StrategyId},
8883 };
8884 use rustrade_instrument::Side;
8885 use rustrade_instrument::exchange::ExchangeId;
8886 use rustrade_instrument::instrument::name::InstrumentNameExchange;
8887
8888 async fn open_against(
8889 status: u16,
8890 body: serde_json::Value,
8891 ) -> OrderState<AssetNameExchange, InstrumentNameExchange> {
8892 let server = MockServer::start().await;
8893 Mock::given(method("POST"))
8894 .and(path("/v2/orders"))
8895 .respond_with(ResponseTemplate::new(status).set_body_json(body))
8896 .mount(&server)
8897 .await;
8898 let instrument = InstrumentNameExchange::new("AAPL");
8899 client_for(&server)
8900 .open_order(OrderRequestOpen {
8901 key: OrderKey {
8902 exchange: ExchangeId::AlpacaBroker,
8903 instrument: &instrument,
8904 strategy: StrategyId::new("test-strategy"),
8905 cid: ClientOrderId::new("test-cid"),
8906 },
8907 state: RequestOpen {
8908 side: Side::Sell,
8909 price: None,
8910 quantity: Decimal::new(10, 0),
8911 kind: OrderKind::Market,
8912 time_in_force: TimeInForce::ImmediateOrCancel,
8913 position_id: None,
8914 reduce_only: false,
8915 market: None,
8916 },
8917 })
8918 .await
8919 .state
8920 }
8921
8922 let rejected =
8923 |state: &OrderState<AssetNameExchange, InstrumentNameExchange>| match state {
8924 OrderState::Inactive(InactiveOrderState::OpenFailed(OrderError::Rejected(
8925 e,
8926 ))) => e.clone(),
8927 other => panic!("expected a rejection, got {other:?}"),
8928 };
8929
8930 let state = open_against(
8931 403,
8932 serde_json::json!({
8933 "code": 40310000,
8934 "message": "insufficient buying power"
8935 }),
8936 )
8937 .await;
8938 assert_eq!(
8939 rejected(&state),
8940 ApiError::BalanceInsufficient(None, "40310000 insufficient buying power".into())
8941 );
8942
8943 let state = open_against(
8944 403,
8945 serde_json::json!({
8946 "code": 40310000,
8947 "message": "account is not allowed to short"
8948 }),
8949 )
8950 .await;
8951 assert_eq!(
8952 rejected(&state),
8953 ApiError::BorrowRejected(BorrowReject::new(
8954 BorrowRejectReason::BorrowDisabled,
8955 Some("40310000".into()),
8956 "account is not allowed to short".into(),
8957 ))
8958 );
8959
8960 let state = open_against(
8962 422,
8963 serde_json::json!({
8964 "code": 42210000,
8965 "message": "asset XYZ cannot be sold short"
8966 }),
8967 )
8968 .await;
8969 assert_eq!(
8970 rejected(&state),
8971 ApiError::BorrowRejected(BorrowReject::new(
8972 BorrowRejectReason::NotShortable,
8973 Some("42210000".into()),
8974 "asset XYZ cannot be sold short".into(),
8975 ))
8976 );
8977
8978 let state = open_against(403, serde_json::json!({ "message": "forbidden." })).await;
8979 assert!(
8980 matches!(rejected(&state), ApiError::Unauthenticated(_)),
8981 "{state:?}"
8982 );
8983 }
8984
8985 #[tokio::test]
8988 async fn open_bracket_order_with_an_undecodable_2xx_response_is_status_unknown() {
8989 use crate::error::OrderError;
8990 use crate::order::state::{InactiveOrderState, OrderState};
8991 use rustrade_instrument::instrument::name::InstrumentNameExchange;
8992
8993 let server = MockServer::start().await;
8994 Mock::given(method("POST"))
8995 .and(path("/v2/orders"))
8996 .respond_with(
8997 ResponseTemplate::new(200).set_body_json(serde_json::json!({ "id": 1 })),
8998 )
8999 .mount(&server)
9000 .await;
9001
9002 let result = client_for(&server)
9003 .open_bracket_order(AlpacaBracketOrderRequest::new(
9004 InstrumentNameExchange::new("SPY"),
9005 crate::order::id::StrategyId::new("test"),
9006 crate::order::id::ClientOrderId::new("test-bracket"),
9007 Side::Buy,
9008 Decimal::ONE,
9009 Decimal::new(100, 0),
9010 Decimal::new(120, 0),
9011 Decimal::new(90, 0),
9012 TimeInForce::GoodUntilCancelled { post_only: false },
9013 ))
9014 .await;
9015
9016 assert!(
9017 matches!(
9018 result.parent.state,
9019 OrderState::Inactive(InactiveOrderState::OpenFailed(OrderError::Connectivity(
9020 _
9021 )))
9022 ),
9023 "{:?}",
9024 result.parent.state
9025 );
9026 }
9027
9028 fn recovery_after() -> DateTime<Utc> {
9037 "2025-01-01T00:00:00Z".parse().expect("valid time")
9038 }
9039
9040 fn activity_json(
9043 id: &str,
9044 order_id: &str,
9045 qty: &str,
9046 cum_qty: Option<&str>,
9047 ) -> serde_json::Value {
9048 let mut v = serde_json::json!({
9049 "id": id,
9050 "order_id": order_id,
9051 "symbol": "SPY",
9052 "side": "buy",
9053 "price": "100.00",
9054 "qty": qty,
9055 "transaction_time": "2025-04-18T14:30:00Z"
9056 });
9057 if let Some(c) = cum_qty {
9058 v["cum_qty"] = serde_json::Value::String(c.to_string());
9059 }
9060 v
9061 }
9062
9063 #[tokio::test]
9068 async fn the_reconnect_budget_resets_only_once_a_connection_proves_stable() {
9069 use futures::{SinkExt as _, StreamExt as _};
9070 use tokio_tungstenite::tungstenite::Message;
9071
9072 const STABLE: Duration = Duration::from_secs(HEARTBEAT_TIMEOUT_SECS + 3);
9074 let lifetime = |n: usize| match n {
9075 0 | 1 => Some(Duration::from_millis(100)),
9076 2 => Some(STABLE),
9077 _ => None,
9078 };
9079
9080 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
9083 let ws_url = format!("ws://{}", listener.local_addr().unwrap());
9084 let listens = Arc::new(parking_lot::Mutex::new(Vec::new()));
9085 let seen = Arc::clone(&listens);
9086 tokio::spawn(async move {
9087 let mut connections = 0..;
9088 while let Ok((stream, _)) = listener.accept().await {
9089 let seen = Arc::clone(&seen);
9090 let lifetime = connections.next().and_then(lifetime);
9091 tokio::spawn(async move {
9092 let Ok(mut ws) = tokio_tungstenite::accept_async(stream).await else {
9093 return;
9094 };
9095 for answer in [
9096 r#"{"stream":"authorization","data":{"status":"authorized","action":"authenticate"}}"#,
9097 r#"{"stream":"listening","data":{"streams":["trade_updates"]}}"#,
9098 ] {
9099 let Some(Ok(_)) = ws.next().await else {
9100 return;
9101 };
9102 if ws.send(Message::text(answer)).await.is_err() {
9103 return;
9104 }
9105 }
9106 seen.lock().push(tokio::time::Instant::now());
9107 let closes = async {
9108 match lifetime {
9109 Some(after) => tokio::time::sleep(after).await,
9110 None => std::future::pending().await,
9111 }
9112 };
9113 tokio::pin!(closes);
9114 let mut pings = tokio::time::interval(Duration::from_secs(1));
9115 loop {
9116 tokio::select! {
9117 () = &mut closes => {
9118 let _ = ws.close(None).await;
9119 return;
9120 }
9121 _ = pings.tick() => {
9122 if ws.send(Message::Ping(Default::default())).await.is_err() {
9123 return;
9124 }
9125 }
9126 message = ws.next() => {
9127 if !matches!(message, Some(Ok(_))) {
9128 return;
9129 }
9130 }
9131 }
9132 }
9133 });
9134 }
9135 });
9136 let server = MockServer::start().await;
9138 let mut config =
9139 AlpacaConfig::with_base_url("key".into(), "secret".into(), server.uri());
9140 config.ws_url_override = Some(ws_url);
9141 let (tx, _rx) = mpsc::unbounded_channel();
9142 tokio::spawn(connection_manager(
9143 tx,
9144 new_dedup_cache(),
9145 Arc::new(config),
9146 reqwest::Client::new(),
9147 Arc::new(RateLimitTracker::new()),
9148 KnownLiveOrders::shared(ExchangeId::AlpacaBroker),
9149 Vec::new(),
9150 None,
9151 ));
9152
9153 let deadline = tokio::time::Instant::now() + STABLE + Duration::from_secs(15);
9154 while listens.lock().len() < 4 {
9155 assert!(
9156 tokio::time::Instant::now() < deadline,
9157 "{} connections",
9158 listens.lock().len()
9159 );
9160 tokio::time::sleep(Duration::from_millis(50)).await;
9161 }
9162
9163 let listens = listens.lock();
9164 let escalated = listens[2] - listens[1];
9166 assert!(
9167 escalated >= 2 * BackoffLimits::ACCOUNT_STREAM.initial,
9168 "reconnected {escalated:?} after a closed connection"
9169 );
9170 let after_stable = listens[3] - listens[2] - STABLE;
9173 assert!(
9174 after_stable < 3 * BackoffLimits::ACCOUNT_STREAM.initial,
9175 "reconnected {after_stable:?} after a stable connection"
9176 );
9177 }
9178
9179 #[tokio::test(start_paused = true)]
9182 async fn failed_connects_end_the_stream_once_the_budget_is_spent() {
9183 let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
9185 let ws_url = format!("ws://{}", listener.local_addr().unwrap());
9186 drop(listener);
9187 let mut config = AlpacaConfig::paper("key".into(), "secret".into());
9188 config.ws_url_override = Some(ws_url);
9189 let (tx, mut rx) = mpsc::unbounded_channel();
9190 let started = tokio::time::Instant::now();
9191
9192 connection_manager(
9193 tx,
9194 new_dedup_cache(),
9195 Arc::new(config),
9196 reqwest::Client::new(),
9197 Arc::new(RateLimitTracker::new()),
9198 KnownLiveOrders::shared(ExchangeId::AlpacaBroker),
9199 Vec::new(),
9200 None,
9201 )
9202 .await;
9203
9204 let Ok(event) = rx.try_recv() else {
9205 panic!("the stream ended without reporting why");
9206 };
9207 let AccountEventKind::StreamTerminated(
9208 StreamTerminationReason::ReconnectBudgetExhausted {
9209 attempts,
9210 last_error,
9211 },
9212 ) = event.kind
9213 else {
9214 panic!("unexpected event: {event:?}");
9215 };
9216 assert_eq!(attempts, BackoffLimits::ACCOUNT_STREAM.max_attempts);
9217 assert!(
9218 !last_error.is_empty(),
9219 "the last connect failure is reported"
9220 );
9221 assert!(
9223 started.elapsed() >= Duration::from_secs(181),
9224 "{:?}",
9225 started.elapsed()
9226 );
9227 assert!(rx.try_recv().is_err(), "nothing follows the termination");
9228 }
9229
9230 async fn drive_recover_fills_with(
9233 activities: Vec<serde_json::Value>,
9234 dedup: SharedDedupCache,
9235 known: &SharedKnownLiveOrders,
9236 ) -> Vec<UnindexedAccountEvent> {
9237 let server = MockServer::start().await;
9238 Mock::given(method("GET"))
9239 .and(path("/v2/account/activities"))
9240 .respond_with(
9241 ResponseTemplate::new(200).set_body_json(serde_json::Value::Array(activities)),
9242 )
9243 .mount(&server)
9244 .await;
9245
9246 let http = reqwest::Client::new();
9247 let rl = RateLimitTracker::new();
9248 let (tx, mut rx) = mpsc::unbounded_channel();
9249 let outcome = recover_fills(
9250 &http,
9251 &rl,
9252 &[],
9253 &server.uri(),
9254 recovery_after(),
9255 &tx,
9256 &dedup,
9257 known,
9258 )
9259 .await;
9260 assert_eq!(outcome, Ok(()), "a full read leaves nothing unrecovered");
9261 drop(tx);
9262
9263 let mut out = Vec::new();
9264 while let Ok(event) = rx.try_recv() {
9265 out.push(event);
9266 }
9267 out
9268 }
9269
9270 async fn drive_recover_fills(
9271 activities: Vec<serde_json::Value>,
9272 ) -> Vec<UnindexedAccountEvent> {
9273 drive_recover_fills_with(
9274 activities,
9275 new_dedup_cache(),
9276 &KnownLiveOrders::shared(ExchangeId::AlpacaBroker),
9277 )
9278 .await
9279 }
9280
9281 async fn recover_fills_from(
9284 server: &MockServer,
9285 ) -> (Result<(), UnreadFills>, Vec<UnindexedAccountEvent>) {
9286 let (tx, mut rx) = mpsc::unbounded_channel();
9287 let outcome = recover_fills(
9288 &reqwest::Client::new(),
9289 &RateLimitTracker::new(),
9290 &[],
9291 &server.uri(),
9292 recovery_after(),
9293 &tx,
9294 &new_dedup_cache(),
9295 &KnownLiveOrders::shared(ExchangeId::AlpacaBroker),
9296 )
9297 .await;
9298 drop(tx);
9299 (outcome, std::iter::from_fn(|| rx.try_recv().ok()).collect())
9300 }
9301
9302 #[tokio::test]
9305 async fn a_failed_recovery_read_leaves_every_fill_since_the_disconnect_unread() {
9306 let server = MockServer::start().await;
9307 Mock::given(method("GET"))
9308 .and(path("/v2/account/activities"))
9309 .respond_with(ResponseTemplate::new(500).set_body_string("unavailable"))
9310 .mount(&server)
9311 .await;
9312
9313 let (outcome, events) = recover_fills_from(&server).await;
9314
9315 let Err(UnreadFills {
9316 start,
9317 reason: FillRecoveryFailure::Request(_),
9318 }) = outcome
9319 else {
9320 panic!("expected the request's failure, got {outcome:?}");
9321 };
9322 assert_eq!(start, recovery_after(), "from the disconnect");
9323 assert!(events.is_empty(), "nothing forwarded: {events:?}");
9324 }
9325
9326 #[tokio::test]
9331 async fn a_truncated_recovery_read_delivers_what_it_read_and_leaves_the_rest_unread() {
9332 let time = |s: &str| s.parse::<DateTime<Utc>>().expect("valid time");
9333 let cases = [
9335 (
9336 Some("2025-04-18T14:30:01.234567Z"),
9337 time("2025-04-18T14:30:01.234Z"),
9338 ),
9339 (Some("not a time"), time("2025-04-18T14:30:00Z")),
9340 (None, recovery_after()),
9341 ];
9342 for (last_time, expected_start) in cases {
9343 let mut page = make_activities_json(ALPACA_MAX_ACTIVITIES, "act");
9344 let Some(activities) = page.as_array_mut() else {
9345 panic!("an array of activities");
9346 };
9347 match last_time {
9348 Some(last_time) => {
9349 if let Some(last) = activities.last_mut() {
9350 last["transaction_time"] =
9351 serde_json::Value::String(last_time.to_string());
9352 }
9353 }
9354 None => {
9355 for activity in activities.iter_mut() {
9356 activity["transaction_time"] =
9357 serde_json::Value::String("not a time".to_string());
9358 }
9359 }
9360 }
9361 let server = MockServer::start().await;
9362 Mock::given(method("GET"))
9363 .and(path("/v2/account/activities"))
9364 .respond_with(ResponseTemplate::new(200).set_body_json(page))
9365 .mount(&server)
9366 .await;
9367
9368 let (outcome, events) = recover_fills_from(&server).await;
9369
9370 let fills_read = MAX_ACTIVITY_PAGES * ALPACA_MAX_ACTIVITIES;
9371 assert_eq!(
9372 outcome,
9373 Err(UnreadFills {
9374 start: expected_start,
9375 reason: FillRecoveryFailure::Truncated { fills_read },
9376 }),
9377 "last time {last_time:?}"
9378 );
9379 assert_eq!(events.len(), fills_read, "every fill read is forwarded");
9380 }
9381 }
9382
9383 #[test]
9386 fn a_fill_recovery_give_up_covers_every_instrument_the_stream_does() {
9387 let start = recovery_after();
9388 let end = start + chrono::Duration::minutes(5);
9389 let spy = InstrumentNameExchange::new("SPY");
9390 let qqq = InstrumentNameExchange::new("QQQ");
9391 let cases = [
9392 (vec![], FillRecoveryScope::AllInstruments),
9393 (
9394 vec![spy.clone(), qqq.clone()],
9395 FillRecoveryScope::Instruments(vec![spy, qqq]),
9396 ),
9397 ];
9398 for (instruments, scope) in cases {
9399 let reason = FillRecoveryFailure::TimedOut { timeout_secs: 30 };
9400 let unread = UnreadFills {
9401 start,
9402 reason: reason.clone(),
9403 };
9404 assert_eq!(
9405 fill_recovery_gave_up(&instruments, unread, end),
9406 UnindexedAccountEvent::new(
9407 ExchangeId::AlpacaBroker,
9408 AccountEventKind::FillRecoveryGaveUp(FillRecoveryGap::new(
9409 scope, start, end, 1, reason,
9410 )),
9411 )
9412 );
9413 }
9414 }
9415
9416 #[test]
9420 fn a_read_that_reached_past_the_recovery_start_still_reports() {
9421 let end = recovery_after();
9422 let start = end + chrono::Duration::seconds(1);
9423 let unread = UnreadFills {
9424 start,
9425 reason: FillRecoveryFailure::Truncated { fills_read: 5_000 },
9426 };
9427 let event = fill_recovery_gave_up(&[], unread, end);
9428 let AccountEventKind::FillRecoveryGaveUp(gave_up) = event.kind else {
9429 panic!("expected FillRecoveryGaveUp, got {event:?}");
9430 };
9431 assert_eq!((gave_up.start, gave_up.end), (start, start));
9432 }
9433
9434 async fn recover_fills_or_report_from(
9437 server: &MockServer,
9438 instruments: &[InstrumentNameExchange],
9439 timeout: Duration,
9440 ) -> Vec<UnindexedAccountEvent> {
9441 let (tx, mut rx) = mpsc::unbounded_channel();
9442 recover_fills_or_report(
9443 &reqwest::Client::new(),
9444 &RateLimitTracker::new(),
9445 instruments,
9446 &server.uri(),
9447 recovery_after(),
9448 timeout,
9449 &tx,
9450 &new_dedup_cache(),
9451 &KnownLiveOrders::shared(ExchangeId::AlpacaBroker),
9452 )
9453 .await;
9454 drop(tx);
9455 std::iter::from_fn(|| rx.try_recv().ok()).collect()
9456 }
9457
9458 #[tokio::test]
9461 async fn a_timed_out_recovery_reports_every_fill_since_the_disconnect() {
9462 let server = MockServer::start().await;
9463 Mock::given(method("GET"))
9464 .and(path("/v2/account/activities"))
9465 .respond_with(
9466 ResponseTemplate::new(200)
9467 .set_body_json(serde_json::json!([]))
9468 .set_delay(Duration::from_secs(3_600)),
9469 )
9470 .mount(&server)
9471 .await;
9472 let spy = InstrumentNameExchange::new("SPY");
9473
9474 let events = recover_fills_or_report_from(
9475 &server,
9476 std::slice::from_ref(&spy),
9477 Duration::from_millis(50),
9478 )
9479 .await;
9480
9481 let [event] = events.as_slice() else {
9482 panic!("only the give-up is sent: {events:?}");
9483 };
9484 let AccountEventKind::FillRecoveryGaveUp(gave_up) = &event.kind else {
9485 panic!("expected FillRecoveryGaveUp, got {event:?}");
9486 };
9487 assert_eq!(gave_up.scope, FillRecoveryScope::Instruments(vec![spy]));
9488 assert_eq!(gave_up.start, recovery_after(), "from the disconnect");
9489 assert!(gave_up.end >= gave_up.start);
9490 assert_eq!(gave_up.attempts, 1);
9491 assert!(
9492 matches!(gave_up.reason, FillRecoveryFailure::TimedOut { .. }),
9493 "{:?}",
9494 gave_up.reason
9495 );
9496 }
9497
9498 #[tokio::test]
9501 async fn a_truncated_recovery_reports_after_the_fills_it_read() {
9502 let server = MockServer::start().await;
9503 Mock::given(method("GET"))
9504 .and(path("/v2/account/activities"))
9505 .respond_with(
9506 ResponseTemplate::new(200)
9507 .set_body_json(make_activities_json(ALPACA_MAX_ACTIVITIES, "act")),
9508 )
9509 .mount(&server)
9510 .await;
9511
9512 let events = recover_fills_or_report_from(&server, &[], Duration::from_secs(30)).await;
9513
9514 let Some((last, fills)) = events.split_last() else {
9515 panic!("events are sent");
9516 };
9517 assert_eq!(fills.len(), MAX_ACTIVITY_PAGES * ALPACA_MAX_ACTIVITIES);
9518 assert!(
9519 fills
9520 .iter()
9521 .all(|event| matches!(event.kind, AccountEventKind::Trade(_))),
9522 "every fill comes first"
9523 );
9524 let AccountEventKind::FillRecoveryGaveUp(gave_up) = &last.kind else {
9525 panic!("the give-up comes last, got {last:?}");
9526 };
9527 assert_eq!(gave_up.scope, FillRecoveryScope::AllInstruments);
9528 assert!(matches!(
9529 gave_up.reason,
9530 FillRecoveryFailure::Truncated { .. }
9531 ));
9532 }
9533
9534 #[test]
9537 fn an_activity_id_names_its_execution_after_the_separator() {
9538 assert_eq!(
9539 activity_execution_id("20261005041849348::524b1902-817e-446c-825b-a9fcfebbc17e"),
9540 "524b1902-817e-446c-825b-a9fcfebbc17e"
9541 );
9542 assert_eq!(activity_execution_id("act-1"), "act-1");
9543 assert_eq!(
9544 activity_execution_id("20261005041849348::"),
9545 "20261005041849348::"
9546 );
9547 }
9548
9549 #[tokio::test]
9553 async fn a_fill_has_one_trade_id_on_the_stream_in_recovery_and_from_fetch_trades() {
9554 use crate::client::ExecutionClient;
9555
9556 let execution_id = "524b1902-817e-446c-825b-a9fcfebbc17e";
9557 let activity = activity_json(
9558 &format!("20250418103000000::{execution_id}"),
9559 "ord-1",
9560 "2",
9561 Some("2"),
9562 );
9563
9564 let [streamed, _snapshot] = convert_trade_update(AlpacaTradeUpdate {
9565 event: SmolStr::new("fill"),
9566 execution_id: Some(execution_id),
9567 previous_execution_id: None,
9568 order: super::make_order_ws("ord-1", "SPY", "buy", "2"),
9569 price: Some("100.00"),
9570 qty: Some("2"),
9571 timestamp: Some("2025-04-18T14:30:00Z"),
9572 });
9573 let Some(streamed) = streamed else {
9574 panic!("a fill converts to a Trade");
9575 };
9576
9577 let recovered = drive_recover_fills(vec![activity.clone()]).await;
9578 let [recovered] = recovered.as_slice() else {
9579 panic!("one fill recovered: {recovered:?}");
9580 };
9581
9582 let server = MockServer::start().await;
9583 Mock::given(method("GET"))
9584 .and(path("/v2/account/activities"))
9585 .respond_with(
9586 ResponseTemplate::new(200)
9587 .set_body_json(serde_json::Value::Array(vec![activity])),
9588 )
9589 .mount(&server)
9590 .await;
9591 let Ok(fetched) = client_for(&server)
9592 .fetch_trades(recovery_after(), Utc::now(), &[])
9593 .await
9594 else {
9595 panic!("fetch_trades reads the activity");
9596 };
9597 let [fetched] = fetched.trades.as_slice() else {
9598 panic!("one fill fetched: {fetched:?}");
9599 };
9600
9601 assert_eq!(trade_of(&streamed).id.0.as_str(), execution_id);
9602 assert_eq!(trade_of(recovered).id.0.as_str(), execution_id);
9603 assert_eq!(fetched.id.0.as_str(), execution_id);
9604 }
9605
9606 fn time(s: &str) -> DateTime<Utc> {
9611 s.parse().expect("valid time")
9612 }
9613
9614 struct ActivitiesVenue {
9619 activities: Vec<serde_json::Value>,
9620 }
9621
9622 impl Respond for ActivitiesVenue {
9623 fn respond(&self, request: &Request) -> ResponseTemplate {
9624 let query: std::collections::HashMap<String, String> =
9625 request.url.query_pairs().into_owned().collect();
9626 let bound = |name: &str| query.get(name).map(|s| time(s));
9627 let (after, until) = (bound("after"), bound("until"));
9628 let size: usize = query
9629 .get("page_size")
9630 .and_then(|s| s.parse().ok())
9631 .unwrap_or(ALPACA_MAX_ACTIVITIES);
9632 let in_bounds = self.activities.iter().filter(|activity| {
9633 let at =
9634 floor_millis(time(activity["transaction_time"].as_str().expect("a time")));
9635 after.is_none_or(|after| at >= after) && until.is_none_or(|until| at < until)
9636 });
9637 let page: Vec<_> = match query.get("page_token") {
9638 Some(token) => in_bounds
9639 .skip_while(|activity| activity["id"] != token.as_str())
9640 .skip(1)
9641 .take(size)
9642 .cloned()
9643 .collect(),
9644 None => in_bounds.take(size).cloned().collect(),
9645 };
9646 ResponseTemplate::new(200).set_body_json(page)
9647 }
9648 }
9649
9650 fn fill_at(id: &str, symbol: &str, transaction_time: DateTime<Utc>) -> serde_json::Value {
9652 serde_json::json!({
9653 "id": id,
9654 "order_id": format!("ord-{id}"),
9655 "symbol": symbol,
9656 "side": "buy",
9657 "price": "100.00",
9658 "qty": "1",
9659 "cum_qty": "1",
9660 "transaction_time": transaction_time.to_rfc3339_opts(SecondsFormat::Micros, true),
9661 })
9662 }
9663
9664 async fn serve_activities(activities: Vec<serde_json::Value>) -> MockServer {
9665 let server = MockServer::start().await;
9666 Mock::given(method("GET"))
9667 .and(path("/v2/account/activities"))
9668 .respond_with(ActivitiesVenue { activities })
9669 .mount(&server)
9670 .await;
9671 server
9672 }
9673
9674 fn non_trade(
9677 id: &str,
9678 activity_type: &str,
9679 sub_type: Option<&str>,
9680 net_amount: &str,
9681 created_at: &str,
9682 ) -> serde_json::Value {
9683 let mut activity = serde_json::json!({
9684 "id": id, "activity_type": activity_type, "net_amount": net_amount,
9685 "created_at": created_at, "date": &created_at[..10], "currency": "USD",
9686 "status": "executed",
9687 });
9688 if let Some(sub_type) = sub_type {
9689 activity["activity_sub_type"] = serde_json::json!(sub_type);
9690 }
9691 activity
9692 }
9693
9694 async fn query_pairs(server: &MockServer) -> Vec<Vec<(String, String)>> {
9696 let Some(requests) = server.received_requests().await else {
9697 panic!("requests are recorded");
9698 };
9699 requests
9700 .iter()
9701 .map(|request| request.url.query_pairs().into_owned().collect())
9702 .collect()
9703 }
9704
9705 #[tokio::test]
9706 async fn fetch_cash_flows_maps_each_activity_type_and_leaves_out_what_it_cannot_use() {
9707 let at = "2025-04-18T05:00:00Z";
9708 let mut fee = non_trade("fee", "FEE", Some("TAF"), "-0.02", at);
9709 fee["symbol"] = serde_json::json!("SPY");
9710 let mut canceled = non_trade("canceled", "FEE", Some("REG"), "-0.01", at);
9711 canceled["status"] = serde_json::json!("canceled");
9712 let mut no_amount = non_trade("no-amount", "FEE", None, "0", at);
9713 no_amount.as_object_mut().unwrap().remove("net_amount");
9714 let mut no_created_at = non_trade("no-time", "PTC", None, "-0.50", at);
9715 no_created_at.as_object_mut().unwrap().remove("created_at");
9716 let mut bad_time = non_trade("bad-time", "PTC", None, "-0.50", at);
9717 bad_time["created_at"] = serde_json::json!("not-a-time");
9718 let mut corrected = non_trade("correct", "PTC", None, "-0.40", at);
9719 corrected["status"] = serde_json::json!("correct");
9720 let mut euro = non_trade("euro", "PTR", None, "0.30", at);
9721 euro["currency"] = serde_json::json!("EUR");
9722 let server = MockServer::start().await;
9723 Mock::given(method("GET"))
9724 .and(path("/v2/account/activities"))
9725 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([
9726 non_trade("mgn", "INT", Some("MGN"), "-12.50", at),
9727 non_trade("int-paid", "INT", None, "-3.00", at),
9728 non_trade("int-credit", "INT", Some("CDT"), "1.00", at),
9729 fee,
9730 canceled,
9731 no_amount,
9732 no_created_at,
9733 bad_time,
9734 corrected,
9735 euro,
9736 non_trade("late", "FEE", None, "-0.01", "2025-04-20T05:00:00Z"),
9737 ])))
9738 .mount(&server)
9739 .await;
9740 let client = client_for(&server);
9741
9742 let read = client
9743 .fetch_cash_flows(time("2025-04-18T00:00:00Z"), time("2025-04-19T00:00:00Z"))
9744 .await
9745 .unwrap();
9746
9747 assert_eq!(read.resume, None);
9748 let kinds: Vec<_> = read
9749 .flows
9750 .iter()
9751 .map(|flow| (flow.id.clone().unwrap().0.to_string(), flow.kind.clone()))
9752 .collect();
9753 let interest = CashFlowKind::MarginInterest {
9754 rate: None,
9755 principal: None,
9756 };
9757 assert_eq!(
9758 kinds,
9759 [
9760 ("mgn", interest.clone()),
9761 ("int-paid", interest),
9762 ("int-credit", CashFlowKind::Other("INT.CDT".into())),
9763 ("fee", CashFlowKind::Other("FEE.TAF".into())),
9764 ("correct", CashFlowKind::Other("PTC".into())),
9765 ("euro", CashFlowKind::Rebate),
9766 ]
9767 .map(|(id, kind)| (id.to_owned(), kind))
9768 );
9769 let by_id = |id: &str| {
9770 read.flows
9771 .iter()
9772 .find(|flow| flow.id == Some(CashFlowId::new(id)))
9773 .unwrap()
9774 };
9775 assert_eq!(by_id("mgn").amount, Decimal::new(-1250, 2));
9776 assert_eq!(by_id("mgn").asset, AssetNameExchange::new("USD"));
9777 assert_eq!(by_id("mgn").instrument, None);
9778 assert_eq!(by_id("mgn").time_exchange, time(at));
9779 assert_eq!(
9780 by_id("fee").instrument,
9781 Some(InstrumentNameExchange::new("SPY"))
9782 );
9783 assert_eq!(
9784 by_id("correct").amount,
9785 Decimal::new(-40, 2),
9786 "a correction is returned as it is"
9787 );
9788 assert_eq!(by_id("euro").asset, AssetNameExchange::new("EUR"));
9789
9790 let queries = query_pairs(&server).await;
9791 let [query] = queries.as_slice() else {
9792 panic!("one request: {queries:?}");
9793 };
9794 assert!(
9795 query.contains(&("activity_types".into(), "INT,FEE,PTC,PTR".into())),
9796 "{query:?}"
9797 );
9798 }
9799
9800 #[tokio::test]
9801 async fn fetch_trades_asks_for_fills_by_the_documented_parameter() {
9802 use crate::client::ExecutionClient;
9803
9804 let server = serve_activities(Vec::new()).await;
9805 let client = client_for(&server);
9806
9807 let read = client
9808 .fetch_trades(
9809 time("2025-04-18T00:00:00Z"),
9810 time("2025-04-19T00:00:00Z"),
9811 &[],
9812 )
9813 .await
9814 .unwrap();
9815
9816 assert!(read.trades.is_empty());
9817 let queries = query_pairs(&server).await;
9818 let [query] = queries.as_slice() else {
9819 panic!("one request: {queries:?}");
9820 };
9821 assert!(
9822 query.contains(&("activity_types".into(), "FILL".into())),
9823 "{query:?}"
9824 );
9825 assert!(
9826 !query.iter().any(|(name, _)| name == "activity_type"),
9827 "{query:?}"
9828 );
9829 }
9830
9831 #[tokio::test]
9832 async fn fetch_cash_flows_reads_nothing_for_an_empty_span() {
9833 let server = MockServer::start().await;
9834 let client = client_for(&server);
9835
9836 let read = client
9837 .fetch_cash_flows(time("2025-04-19T00:00:00Z"), time("2025-04-18T00:00:00Z"))
9838 .await
9839 .unwrap();
9840
9841 assert!(read.flows.is_empty() && read.resume.is_none());
9842 assert!(query_pairs(&server).await.is_empty());
9843 }
9844
9845 #[tokio::test]
9848 async fn fetch_cash_flows_that_stops_at_its_bound_says_where_to_read_on() {
9849 let base = time("2025-04-18T05:00:00Z");
9850 let activities: Vec<_> = (0..6_000_i64)
9851 .map(|n| {
9852 let created =
9853 (base + TimeDelta::seconds(n)).to_rfc3339_opts(SecondsFormat::Micros, true);
9854 non_trade(&format!("{n:017}::fee"), "FEE", None, "-0.01", &created)
9855 })
9856 .collect();
9857 struct Paged(Vec<serde_json::Value>);
9859 impl Respond for Paged {
9860 fn respond(&self, request: &Request) -> ResponseTemplate {
9861 let token = request
9862 .url
9863 .query_pairs()
9864 .find(|(name, _)| name == "page_token")
9865 .map(|(_, value)| value.into_owned());
9866 let page: Vec<_> = self
9867 .0
9868 .iter()
9869 .skip_while(|activity| {
9870 token
9871 .as_deref()
9872 .is_some_and(|token| activity["id"] != token)
9873 })
9874 .skip(usize::from(token.is_some()))
9875 .take(ALPACA_MAX_ACTIVITIES)
9876 .cloned()
9877 .collect();
9878 ResponseTemplate::new(200).set_body_json(page)
9879 }
9880 }
9881 let server = MockServer::start().await;
9882 Mock::given(method("GET"))
9883 .and(path("/v2/account/activities"))
9884 .respond_with(Paged(activities))
9885 .mount(&server)
9886 .await;
9887 let client = client_for(&server);
9888
9889 let read = client
9890 .fetch_cash_flows(base, base + TimeDelta::days(1))
9891 .await
9892 .unwrap();
9893
9894 assert_eq!(read.flows.len(), 5_000);
9895 assert_eq!(read.resume, Some(base + TimeDelta::seconds(4_999)));
9896 }
9897
9898 #[tokio::test]
9901 async fn fetch_cash_flows_that_cannot_advance_fails() {
9902 let at = "2025-04-18T05:00:00Z";
9903 let full: Vec<_> = (0..ALPACA_MAX_ACTIVITIES)
9904 .map(|n| non_trade(&format!("fee-{n}"), "FEE", None, "-0.01", at))
9905 .collect();
9906 let server = MockServer::start().await;
9907 Mock::given(method("GET"))
9908 .and(path("/v2/account/activities"))
9909 .respond_with(ResponseTemplate::new(200).set_body_json(full))
9910 .mount(&server)
9911 .await;
9912 let client = client_for(&server);
9913
9914 let result = client
9915 .fetch_cash_flows(time(at), time("2025-04-19T00:00:00Z"))
9916 .await;
9917
9918 assert!(
9919 matches!(result, Err(UnindexedClientError::Internal(_))),
9920 "{result:?}"
9921 );
9922 }
9923
9924 fn option_activity(
9927 id: &str,
9928 activity_type: &str,
9929 symbol: &str,
9930 qty: &str,
9931 date: &str,
9932 ) -> serde_json::Value {
9933 serde_json::json!({
9934 "id": id, "activity_type": activity_type, "symbol": symbol, "qty": qty,
9935 "date": date, "net_amount": "0", "status": "executed",
9936 })
9937 }
9938
9939 fn share_leg(
9941 id: &str,
9942 symbol: &str,
9943 qty: &str,
9944 price: &str,
9945 date: &str,
9946 ) -> serde_json::Value {
9947 let mut activity = option_activity(id, "OPTRD", symbol, qty, date);
9948 activity["price"] = serde_json::json!(price);
9949 activity
9950 }
9951
9952 async fn serve_json(activities: serde_json::Value) -> MockServer {
9953 let server = MockServer::start().await;
9954 Mock::given(method("GET"))
9955 .and(path("/v2/account/activities"))
9956 .respond_with(ResponseTemplate::new(200).set_body_json(activities))
9957 .mount(&server)
9958 .await;
9959 server
9960 }
9961
9962 #[test]
9963 fn occ_contract_reads_the_root_kind_and_strike() {
9964 assert_eq!(
9965 occ_contract("QS240301C00006500"),
9966 Some(("QS", true, Decimal::new(65, 1)))
9967 );
9968 assert_eq!(
9969 occ_contract("AAPL240119P00150000"),
9970 Some(("AAPL", false, Decimal::from(150)))
9971 );
9972 for not_occ in [
9973 "QS",
9974 "240301C00006500",
9975 "QS240301X00006500",
9976 "QS2403O1C00006500",
9977 ] {
9978 assert_eq!(occ_contract(not_occ), None, "{not_occ}");
9979 }
9980 }
9981
9982 #[tokio::test]
9983 async fn fetch_option_events_maps_each_activity_and_pairs_the_share_legs() {
9984 let day = "2024-03-01";
9985 let mut expiry = option_activity("exp", "OPEXP", "QS240301C00010500", "-1", day);
9986 expiry["created_at"] = serde_json::json!("2024-03-01T21:30:00Z");
9987 let mut canceled = option_activity("canceled", "OPEXP", "QS240301P00000500", "-1", day);
9988 canceled["status"] = serde_json::json!("canceled");
9989 let server = serve_json(serde_json::json!([
9990 expiry,
9991 option_activity("exc", "OPEXC", "QS240301C00006500", "-1", day),
9992 share_leg("exc-shares", "QS", "100", "6.5", day),
9993 option_activity("asn", "OPASN", "QS240301P00009000", "1", day),
9994 share_leg("asn-shares", "QS", "100", "9", day),
9995 share_leg("orphan", "QS", "-100", "7", day),
9996 canceled,
9997 option_activity("no-price", "OPTRD", "QS", "100", day),
9998 option_activity("no-qty", "OPEXP", "QS240301C00011000", "0", day),
9999 option_activity("later", "OPEXP", "QS240308C00010500", "-1", "2024-03-02"),
10000 ]))
10001 .await;
10002 let client = client_for(&server);
10003
10004 let read = client
10005 .fetch_option_events(time("2024-03-01T00:00:00Z"), time("2024-03-01T23:00:00Z"))
10006 .await
10007 .unwrap();
10008
10009 assert_eq!(read.resume, None);
10010 let summary: Vec<_> = read
10011 .trades
10012 .iter()
10013 .map(|trade| {
10014 (
10015 trade.id.0.to_string(),
10016 trade.instrument.name().to_string(),
10017 trade.origin,
10018 trade.side,
10019 trade.price,
10020 trade.quantity,
10021 )
10022 })
10023 .collect();
10024 let row = |id: &str, symbol: &str, origin, side, price: Decimal, quantity: i64| {
10025 (
10026 id.to_owned(),
10027 symbol.to_owned(),
10028 origin,
10029 side,
10030 price,
10031 Decimal::from(quantity),
10032 )
10033 };
10034 assert_eq!(
10035 summary,
10036 [
10037 row(
10038 "exp",
10039 "QS240301C00010500",
10040 TradeOrigin::Expiry,
10041 Side::Sell,
10042 Decimal::ZERO,
10043 1
10044 ),
10045 row(
10046 "exc",
10047 "QS240301C00006500",
10048 TradeOrigin::Exercise,
10049 Side::Sell,
10050 Decimal::ZERO,
10051 1
10052 ),
10053 row(
10054 "exc-shares",
10055 "QS",
10056 TradeOrigin::Exercise,
10057 Side::Buy,
10058 Decimal::new(65, 1),
10059 100
10060 ),
10061 row(
10062 "asn",
10063 "QS240301P00009000",
10064 TradeOrigin::Assignment,
10065 Side::Buy,
10066 Decimal::ZERO,
10067 1
10068 ),
10069 row(
10070 "asn-shares",
10071 "QS",
10072 TradeOrigin::Assignment,
10073 Side::Buy,
10074 Decimal::from(9),
10075 100
10076 ),
10077 row(
10078 "orphan",
10079 "QS",
10080 TradeOrigin::External,
10081 Side::Sell,
10082 Decimal::from(7),
10083 100
10084 ),
10085 ]
10086 );
10087 let exp = &read.trades[0];
10088 assert_eq!(
10089 exp.time_exchange,
10090 time("2024-03-01T21:30:00Z"),
10091 "created_at"
10092 );
10093 assert_eq!(exp.order_id, OrderId::new("exp"));
10094 assert_eq!(exp.fees.fees, Decimal::ZERO);
10095 assert_eq!(
10096 read.trades[1].time_exchange,
10097 time("2024-03-01T00:00:00Z"),
10098 "no created_at: the start of its date"
10099 );
10100
10101 let queries = query_pairs(&server).await;
10102 let [query] = queries.as_slice() else {
10103 panic!("one request: {queries:?}");
10104 };
10105 assert!(
10106 query.contains(&("activity_types".into(), "OPASN,OPEXC,OPEXP,OPTRD".into())),
10107 "{query:?}"
10108 );
10109 }
10110
10111 #[tokio::test]
10114 async fn fetch_option_events_pairs_a_share_leg_by_its_side() {
10115 let day = "2024-03-01";
10116 let server = serve_json(serde_json::json!([
10117 option_activity("exc-call", "OPEXC", "QS240301C00006500", "-1", day),
10118 option_activity("asn-call", "OPASN", "QS240301C00006500", "1", day),
10119 share_leg("sold", "QS", "-100", "6.5", day),
10120 share_leg("bought", "QS", "100", "6.5", day),
10121 share_leg("bought-again", "QS", "100", "6.5", day),
10122 share_leg("other-day", "QS", "100", "6.5", "2024-02-29"),
10123 ]))
10124 .await;
10125 let client = client_for(&server);
10126
10127 let read = client
10128 .fetch_option_events(time("2024-02-29T00:00:00Z"), time("2024-03-01T23:00:00Z"))
10129 .await
10130 .unwrap();
10131
10132 let origin = |id: &str| {
10133 read.trades
10134 .iter()
10135 .find(|trade| trade.id == TradeId::new(id))
10136 .map(|trade| trade.origin)
10137 };
10138 assert_eq!(origin("sold"), Some(TradeOrigin::Assignment));
10139 assert_eq!(origin("bought"), Some(TradeOrigin::Exercise));
10140 assert_eq!(origin("bought-again"), Some(TradeOrigin::External));
10141 assert_eq!(origin("other-day"), Some(TradeOrigin::External));
10142 }
10143
10144 #[tokio::test]
10148 async fn fetch_option_events_pairs_a_share_leg_by_its_size() {
10149 let day = "2024-03-01";
10150 let server = serve_json(serde_json::json!([
10151 option_activity("asn-two", "OPASN", "QS240301P00006500", "2", day),
10153 option_activity("exc-one", "OPEXC", "QS240301C00006500", "-1", day),
10154 option_activity("exc-adjusted", "OPEXC", "QS240301C00006500", "-1", day),
10155 share_leg("hundred", "QS", "100", "6.5", day),
10156 share_leg("two-hundred", "QS", "200", "6.5", day),
10157 share_leg("hundred-fifty", "QS", "150", "6.5", day),
10158 ]))
10159 .await;
10160 let client = client_for(&server);
10161
10162 let read = client
10163 .fetch_option_events(time("2024-03-01T00:00:00Z"), time("2024-03-01T23:00:00Z"))
10164 .await
10165 .unwrap();
10166
10167 let origin = |id: &str| {
10168 read.trades
10169 .iter()
10170 .find(|trade| trade.id == TradeId::new(id))
10171 .map(|trade| trade.origin)
10172 };
10173 assert_eq!(origin("hundred"), Some(TradeOrigin::Exercise));
10174 assert_eq!(origin("two-hundred"), Some(TradeOrigin::Assignment));
10175 assert_eq!(origin("hundred-fifty"), Some(TradeOrigin::Exercise));
10176 }
10177
10178 #[tokio::test]
10182 async fn fetch_option_events_reads_whole_days_and_returns_the_span() {
10183 let mut early =
10184 option_activity("early", "OPEXP", "QS240301C00010500", "-1", "2024-03-01");
10185 early["created_at"] = serde_json::json!("2024-03-01T10:00:00Z");
10186 let mut inside =
10187 option_activity("inside", "OPEXP", "QS240301C00011500", "-1", "2024-03-01");
10188 inside["created_at"] = serde_json::json!("2024-03-01T16:00:00Z");
10189 let server = serve_json(serde_json::json!([
10190 option_activity(
10191 "dated-before",
10192 "OPEXP",
10193 "QS240229C00010500",
10194 "-1",
10195 "2024-02-29"
10196 ),
10197 early,
10198 option_activity("dated", "OPEXP", "QS240301C00012500", "-1", "2024-03-01"),
10199 inside,
10200 ]))
10201 .await;
10202 let client = client_for(&server);
10203
10204 let read = client
10205 .fetch_option_events(time("2024-03-01T15:00:00Z"), time("2024-03-01T17:00:00Z"))
10206 .await
10207 .unwrap();
10208
10209 let ids: Vec<_> = read
10210 .trades
10211 .iter()
10212 .map(|trade| trade.id.0.to_string())
10213 .collect();
10214 assert_eq!(ids, ["dated", "inside"]);
10215 let queries = query_pairs(&server).await;
10216 let [query] = queries.as_slice() else {
10217 panic!("one request: {queries:?}");
10218 };
10219 assert!(
10220 query.contains(&("after".into(), "2024-03-01T00:00:00.000Z".into())),
10221 "{query:?}"
10222 );
10223 assert!(
10224 query.contains(&("until".into(), "2024-03-02T00:00:00.000Z".into())),
10225 "{query:?}"
10226 );
10227 }
10228
10229 #[tokio::test]
10230 async fn fetch_option_events_reads_nothing_for_an_empty_span() {
10231 let server = MockServer::start().await;
10232 let client = client_for(&server);
10233
10234 let read = client
10235 .fetch_option_events(time("2024-03-02T00:00:00Z"), time("2024-03-01T00:00:00Z"))
10236 .await
10237 .unwrap();
10238
10239 assert!(read.trades.is_empty() && read.resume.is_none());
10240 assert!(query_pairs(&server).await.is_empty());
10241 }
10242
10243 async fn read_span(
10246 client: &AlpacaClient,
10247 start: DateTime<Utc>,
10248 end: DateTime<Utc>,
10249 instruments: &[InstrumentNameExchange],
10250 ) -> (std::collections::HashSet<String>, usize) {
10251 use crate::client::ExecutionClient;
10252
10253 let mut ids = std::collections::HashSet::new();
10254 let mut from = start;
10255 for calls in 1..=10 {
10256 let read = match client.fetch_trades(from, end, instruments).await {
10257 Ok(read) => read,
10258 Err(error) => panic!("call {calls} failed: {error:?}"),
10259 };
10260 ids.extend(read.trades.iter().map(|trade| trade.id.0.to_string()));
10261 match read.resume {
10262 Some(resume) => from = resume,
10263 None => return (ids, calls),
10264 }
10265 }
10266 panic!("the reads did not reach the span's end");
10267 }
10268
10269 #[tokio::test]
10275 async fn fetch_trades_reads_a_busy_span_completely_across_calls() {
10276 let base = time("2025-04-18T14:30:00Z");
10277 let mut activities = Vec::new();
10278 for ms in 0..4_000_i64 {
10279 let symbol = if ms % 2 == 0 { "SPY" } else { "QQQ" };
10280 for (k, micros) in [("a", 900), ("b", 500), ("c", 100)] {
10281 let at = base + TimeDelta::milliseconds(ms) + TimeDelta::microseconds(micros);
10282 activities.push(fill_at(&format!("{ms:017}::{ms}-{k}"), symbol, at));
10283 }
10284 }
10285 let server = serve_activities(activities).await;
10286 let client = client_for(&server);
10287 let end = time("2025-04-18T15:00:00Z");
10288
10289 let (ids, calls) = read_span(&client, recovery_after(), end, &[]).await;
10290 assert_eq!(ids.len(), 12_000, "every fill is read once or more");
10291 assert_eq!(calls, 3, "5,000 activities per call");
10292
10293 let spy = [InstrumentNameExchange::new("SPY")];
10294 let (ids, _) = read_span(&client, recovery_after(), end, &spy).await;
10295 assert_eq!(ids.len(), 6_000, "every SPY fill, and only those");
10296
10297 let Some(requests) = server.received_requests().await else {
10298 panic!("requests are recorded");
10299 };
10300 assert!(
10301 requests
10302 .iter()
10303 .all(|request| request.url.query_pairs().any(
10304 |(name, value)| name == "until" && value == "2025-04-18T15:00:00.001Z"
10305 )),
10306 "every request is bounded by the span's end"
10307 );
10308 }
10309
10310 #[tokio::test]
10313 async fn fetch_trades_says_where_to_read_on_after_a_call_without_matches() {
10314 use crate::client::ExecutionClient;
10315
10316 let server = MockServer::start().await;
10317 Mock::given(method("GET"))
10318 .and(path("/v2/account/activities"))
10319 .respond_with(
10320 ResponseTemplate::new(200)
10321 .set_body_json(make_activities_json(ALPACA_MAX_ACTIVITIES, "act")),
10322 )
10323 .mount(&server)
10324 .await;
10325
10326 let qqq = [InstrumentNameExchange::new("QQQ")];
10327 let Ok(read) = client_for(&server)
10328 .fetch_trades(recovery_after(), Utc::now(), &qqq)
10329 .await
10330 else {
10331 panic!("the read succeeds");
10332 };
10333 assert!(read.trades.is_empty(), "every fill read is SPY's");
10334 assert_eq!(read.resume, Some(time("2025-04-18T14:30:00Z")));
10335 }
10336
10337 #[tokio::test]
10340 async fn fetch_trades_that_cannot_advance_is_truncated() {
10341 use crate::client::ExecutionClient;
10342
10343 let server = MockServer::start().await;
10344 Mock::given(method("GET"))
10345 .and(path("/v2/account/activities"))
10346 .respond_with(
10347 ResponseTemplate::new(200)
10348 .set_body_json(make_activities_json(ALPACA_MAX_ACTIVITIES, "act")),
10349 )
10350 .mount(&server)
10351 .await;
10352
10353 let result = client_for(&server)
10354 .fetch_trades(time("2025-04-18T14:30:00.000500Z"), Utc::now(), &[])
10355 .await;
10356 assert!(
10357 matches!(
10358 result,
10359 Err(UnindexedClientError::Truncated { fills_read: 5_000 })
10360 ),
10361 "got {result:?}"
10362 );
10363 }
10364
10365 #[tokio::test]
10368 async fn fetch_trades_returns_only_the_span() {
10369 let (start, end) = (
10370 time("2025-04-18T14:30:00.123456Z"),
10371 time("2025-04-18T14:30:05.678901Z"),
10372 );
10373 let micro = TimeDelta::microseconds(1);
10374 let server = serve_activities(vec![
10375 fill_at("1::before", "SPY", start - micro),
10376 fill_at("2::start", "SPY", start),
10377 fill_at("3::end", "SPY", end),
10378 fill_at("4::after", "SPY", end + micro),
10379 ])
10380 .await;
10381
10382 let (ids, calls) = read_span(&client_for(&server), start, end, &[]).await;
10383 assert_eq!(calls, 1);
10384 let mut ids: Vec<_> = ids.into_iter().collect();
10385 ids.sort();
10386 assert_eq!(ids, ["end", "start"]);
10387 }
10388
10389 #[tokio::test]
10392 async fn fetch_trades_that_read_past_the_end_is_complete() {
10393 use crate::client::ExecutionClient;
10394
10395 let server = MockServer::start().await;
10396 Mock::given(method("GET"))
10398 .and(path("/v2/account/activities"))
10399 .respond_with(
10400 ResponseTemplate::new(200)
10401 .set_body_json(make_activities_json(ALPACA_MAX_ACTIVITIES, "act")),
10402 )
10403 .mount(&server)
10404 .await;
10405
10406 let Ok(read) = client_for(&server)
10407 .fetch_trades(recovery_after(), time("2025-04-18T14:29:00Z"), &[])
10408 .await
10409 else {
10410 panic!("the read succeeds");
10411 };
10412 assert_eq!(read, TradesRead::complete(Vec::new()));
10413 }
10414
10415 #[tokio::test]
10419 async fn fetch_trades_accepts_the_extreme_times() {
10420 use crate::client::ExecutionClient;
10421
10422 let server = serve_activities(vec![fill_at(
10423 "1::fill",
10424 "SPY",
10425 time("2025-04-18T14:30:00Z"),
10426 )])
10427 .await;
10428 let Ok(read) = client_for(&server)
10429 .fetch_trades(DateTime::<Utc>::MIN_UTC, DateTime::<Utc>::MAX_UTC, &[])
10430 .await
10431 else {
10432 panic!("the read succeeds");
10433 };
10434 assert_eq!(read.trades.len(), 1);
10435 assert_eq!(read.resume, None);
10436
10437 let Some(requests) = server.received_requests().await else {
10438 panic!("requests are recorded");
10439 };
10440 let [request] = requests.as_slice() else {
10441 panic!("one request: {requests:?}");
10442 };
10443 let query: std::collections::HashMap<_, _> =
10444 request.url.query_pairs().into_owned().collect();
10445 assert_eq!(
10446 query.get("after").map(String::as_str),
10447 Some("1970-01-01T00:00:00.000Z")
10448 );
10449 assert!(!query.contains_key("until"), "{query:?}");
10450
10451 let before_epoch = DateTime::UNIX_EPOCH - TimeDelta::days(1);
10453 let Ok(read) = client_for(&server)
10454 .fetch_trades(DateTime::<Utc>::MIN_UTC, before_epoch, &[])
10455 .await
10456 else {
10457 panic!("the read succeeds");
10458 };
10459 assert_eq!(read, TradesRead::complete(Vec::new()));
10460 assert_eq!(server.received_requests().await.map(|r| r.len()), Some(1));
10461 }
10462
10463 #[tokio::test]
10465 async fn fetch_trades_reads_nothing_for_an_empty_span() {
10466 use crate::client::ExecutionClient;
10467
10468 let server = MockServer::start().await;
10469 let start = recovery_after();
10470 let Ok(read) = client_for(&server)
10471 .fetch_trades(start, start - TimeDelta::seconds(1), &[])
10472 .await
10473 else {
10474 panic!("an empty span reads");
10475 };
10476 assert_eq!(read, TradesRead::complete(Vec::new()));
10477 assert_eq!(server.received_requests().await.map(|r| r.len()), Some(0));
10478 }
10479
10480 #[tokio::test]
10483 async fn recovery_reads_from_the_start_of_the_disconnects_millisecond() {
10484 let server = MockServer::start().await;
10485 Mock::given(method("GET"))
10486 .and(path("/v2/account/activities"))
10487 .and(wiremock::matchers::query_param(
10488 "after",
10489 "2025-01-01T00:00:00.123Z",
10490 ))
10491 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([])))
10492 .expect(1)
10493 .mount(&server)
10494 .await;
10495
10496 let (tx, _rx) = mpsc::unbounded_channel();
10497 let outcome = recover_fills(
10498 &reqwest::Client::new(),
10499 &RateLimitTracker::new(),
10500 &[],
10501 &server.uri(),
10502 time("2025-01-01T00:00:00.123456Z"),
10503 &tx,
10504 &new_dedup_cache(),
10505 &KnownLiveOrders::shared(ExchangeId::AlpacaBroker),
10506 )
10507 .await;
10508 assert_eq!(outcome, Ok(()));
10509 server.verify().await;
10510 }
10511
10512 fn dedup_keys(dedup: &SharedDedupCache) -> Vec<String> {
10514 let mut keys: Vec<_> = dedup
10515 .lock()
10516 .iter()
10517 .filter_map(|(key, _)| match key {
10518 FillKey::Cumulative(key) => Some(key.to_string()),
10519 FillKey::Execution(_) | FillKey::CumulativeWithoutId(_) => None,
10520 })
10521 .collect();
10522 keys.reverse();
10524 keys
10525 }
10526
10527 fn trade_of(
10528 event: &UnindexedAccountEvent,
10529 ) -> &Trade<AssetNameExchange, InstrumentNameExchange> {
10530 match &event.kind {
10531 AccountEventKind::Trade(t) => t,
10532 other => panic!("expected a Trade event, got {other:?}"),
10533 }
10534 }
10535
10536 #[tokio::test]
10539 async fn a_recovered_fill_reports_the_cumulative_alpaca_sent() {
10540 let events = drive_recover_fills(vec![
10541 activity_json("act-1", "ord-1", "2", Some("2")),
10542 activity_json("act-2", "ord-1", "3", Some("5")),
10543 ])
10544 .await;
10545
10546 let reported: Vec<_> = events
10547 .iter()
10548 .map(|e| trade_of(e).order_filled_quantity)
10549 .collect();
10550 assert_eq!(
10551 reported,
10552 vec![
10553 Some(Decimal::from_str("2").unwrap()),
10554 Some(Decimal::from_str("5").unwrap())
10555 ]
10556 );
10557 }
10558
10559 #[tokio::test]
10566 async fn the_dedup_key_uses_the_venue_cumulative_not_an_intra_batch_count() {
10567 let dedup = new_dedup_cache();
10569 drive_recover_fills_with(
10570 vec![activity_json("act-1", "ord-1", "2", Some("5"))],
10571 dedup.clone(),
10572 &KnownLiveOrders::shared(ExchangeId::AlpacaBroker),
10573 )
10574 .await;
10575
10576 assert_eq!(
10577 dedup_keys(&dedup),
10578 vec!["ord-1:5"],
10579 "counting executions within the batch would key this as ord-1:2"
10580 );
10581 }
10582
10583 async fn recover_after_ws_fill(
10587 execution_id: Option<&str>,
10588 activity_id: &str,
10589 ) -> Vec<UnindexedAccountEvent> {
10590 let dedup = new_dedup_cache();
10591 let update = AlpacaTradeUpdate {
10592 event: SmolStr::new("partial_fill"),
10593 execution_id,
10594 previous_execution_id: None,
10595 order: super::make_order_ws("ord-1", "SPY", "buy", "5"),
10596 price: Some("100.00"),
10597 qty: Some("2"),
10598 timestamp: None,
10599 };
10600 assert!(
10601 !is_duplicate_fill(&dedup, ws_execution_id(&update), early_dedup_key(&update)),
10602 "precondition: first sighting"
10603 );
10604
10605 drive_recover_fills_with(
10606 vec![activity_json(activity_id, "ord-1", "2", Some("5"))],
10607 dedup,
10608 &KnownLiveOrders::shared(ExchangeId::AlpacaBroker),
10609 )
10610 .await
10611 }
10612
10613 #[tokio::test]
10616 async fn a_fill_already_delivered_over_websocket_is_not_recovered_twice() {
10617 for (ws_execution_id, activity_id) in [
10618 (Some("exec-a"), "20250418103000000::exec-a"),
10619 (None, "20250418103000000::exec-a"),
10620 (Some("exec-a"), "act-1"),
10621 ] {
10622 let events = recover_after_ws_fill(ws_execution_id, activity_id).await;
10623 assert!(
10624 events.is_empty(),
10625 "a fill already delivered over WebSocket ({ws_execution_id:?}) must not be \
10626 re-delivered by recovery ({activity_id}), got {events:?}"
10627 );
10628 }
10629 }
10630
10631 async fn recover_after_correction(fields: &str) -> Vec<UnindexedAccountEvent> {
10634 let (tx, _rx) = mpsc::unbounded_channel();
10635 let dedup = new_dedup_cache();
10636 let known = KnownLiveOrders::shared(ExchangeId::AlpacaBroker);
10637 process_ws_text(
10638 &super::amendment_frame("trade_correct", fields),
10639 &tx,
10640 &dedup,
10641 &known,
10642 );
10643
10644 drive_recover_fills_with(
10645 vec![activity_json(
10646 "20250418103100000::exec-c",
10647 "ord-1",
10648 "2",
10649 Some("2"),
10650 )],
10651 dedup,
10652 &known,
10653 )
10654 .await
10655 }
10656
10657 #[tokio::test]
10660 async fn a_corrected_execution_is_not_recovered_again() {
10661 let events = recover_after_correction(
10662 r#""execution_id":"exec-c","previous_execution_id":"exec-a","price":"101.50","qty":"2","#,
10663 )
10664 .await;
10665 assert!(
10666 events.is_empty(),
10667 "the corrected execution was delivered as the replacement, got {events:?}"
10668 );
10669 }
10670
10671 #[tokio::test]
10673 async fn an_unresolved_correction_does_not_keep_its_execution_from_recovery() {
10674 let events = recover_after_correction(
10675 r#""execution_id":"exec-c","previous_execution_id":"exec-a","qty":"2","#,
10676 )
10677 .await;
10678 let ids: Vec<_> = events.iter().map(|e| trade_of(e).id.0.as_str()).collect();
10679 assert_eq!(ids, vec!["exec-c"]);
10680 }
10681
10682 #[tokio::test]
10685 async fn a_different_execution_at_a_cumulative_already_seen_is_recovered() {
10686 let events = recover_after_ws_fill(Some("exec-a"), "20250418103000000::exec-b").await;
10687 let ids: Vec<_> = events.iter().map(|e| trade_of(e).id.0.as_str()).collect();
10688 assert_eq!(ids, vec!["exec-b"]);
10689 }
10690
10691 #[tokio::test]
10694 async fn without_a_reported_cumulative_the_previous_behaviour_is_preserved() {
10695 let dedup = new_dedup_cache();
10696 let events = drive_recover_fills_with(
10697 vec![
10698 activity_json("act-1", "ord-1", "2", None),
10699 activity_json("act-2", "ord-1", "3", None),
10700 ],
10701 dedup.clone(),
10702 &KnownLiveOrders::shared(ExchangeId::AlpacaBroker),
10703 )
10704 .await;
10705
10706 assert_eq!(
10707 dedup_keys(&dedup),
10708 vec!["ord-1:2", "ord-1:5"],
10709 "intra-batch accumulation"
10710 );
10711 let ids: Vec<_> = events.iter().map(|e| trade_of(e).id.0.as_str()).collect();
10712 assert_eq!(ids, vec!["act-1", "act-2"], "each keeps its activity's id");
10713
10714 assert!(
10715 events
10716 .iter()
10717 .all(|e| trade_of(e).order_filled_quantity.is_none()),
10718 "a venue that reported no cumulative must not have one invented for it"
10719 );
10720 }
10721
10722 fn order_json(cid: &str, symbol: &str, status: &str, filled: &str) -> serde_json::Value {
10727 serde_json::json!({
10728 "id": format!("id-{cid}"),
10729 "client_order_id": cid,
10730 "symbol": symbol,
10731 "qty": "2",
10732 "filled_qty": filled,
10733 "filled_avg_price": if filled == "0" { None } else { Some("101.5") },
10734 "status": status,
10735 "side": "buy",
10736 "type": "limit",
10737 "time_in_force": "gtc",
10738 "limit_price": "100",
10739 "created_at": "2026-10-01T14:30:00Z",
10740 "updated_at": "2026-10-01T15:00:00Z"
10741 })
10742 }
10743
10744 async fn mount_lookup(server: &MockServer, cid: &str, order: ResponseTemplate, calls: u64) {
10746 use wiremock::matchers::query_param;
10747 Mock::given(method("GET"))
10748 .and(path("/v2/orders:by_client_order_id"))
10749 .and(query_param("client_order_id", cid))
10750 .respond_with(order)
10751 .expect(calls)
10752 .mount(server)
10753 .await;
10754 }
10755
10756 fn ok(body: serde_json::Value) -> ResponseTemplate {
10757 ResponseTemplate::new(200).set_body_json(body)
10758 }
10759
10760 fn alpaca_key(instrument: &str, cid: &str) -> UnindexedOrderKey {
10761 OrderKey::new(
10762 ExchangeId::AlpacaBroker,
10763 InstrumentNameExchange::new(instrument),
10764 StrategyId::new("strategy"),
10765 ClientOrderId::new(cid),
10766 )
10767 }
10768
10769 fn hold(known: &SharedKnownLiveOrders, key: &UnindexedOrderKey) {
10770 let open = Open::new(
10771 VenueOrderId::Assigned(OrderId::new(format!("id-{}", key.cid))),
10772 Utc::now(),
10773 Decimal::ZERO,
10774 );
10775 known
10776 .lock()
10777 .live(key, Decimal::TWO, OrderKind::Limit, &open);
10778 }
10779
10780 #[tokio::test]
10781 async fn fetch_ended_orders_reads_each_end_alpaca_reports() {
10782 use rust_decimal_macros::dec;
10783
10784 let server = MockServer::start().await;
10785 for (cid, status, filled) in [
10786 ("filled", "filled", "2"),
10787 ("canceled", "canceled", "1"),
10788 ("replaced", "replaced", "0"),
10789 ("expired", "expired", "1"),
10790 ("rejected", "rejected", "0"),
10791 ("done", "done_for_day", "1"),
10792 ("live", "new", "0"),
10793 ("unknown-status", "frozen", "0"),
10794 ] {
10795 mount_lookup(&server, cid, ok(order_json(cid, "SPY", status, filled)), 1).await;
10796 }
10797 mount_lookup(
10798 &server,
10799 "elsewhere",
10800 ok(order_json("elsewhere", "QQQ", "canceled", "0")),
10801 1,
10802 )
10803 .await;
10804 mount_lookup(
10805 &server,
10806 "gone",
10807 ResponseTemplate::new(404).set_body_json(serde_json::json!({
10808 "code": 40410000,
10809 "message": "order not found for gone"
10810 })),
10811 1,
10812 )
10813 .await;
10814
10815 let keys: Vec<_> = [
10816 "filled",
10817 "canceled",
10818 "replaced",
10819 "expired",
10820 "rejected",
10821 "done",
10822 "live",
10823 "unknown-status",
10824 "elsewhere",
10825 "gone",
10826 ]
10827 .into_iter()
10828 .map(|cid| alpaca_key("spy", cid))
10829 .collect();
10830 let ended = client_for(&server).fetch_ended_orders(&keys).await.unwrap();
10831
10832 let mut found: Vec<_> = ended
10833 .iter()
10834 .map(|order| (order.key.cid.0.as_str(), &order.state))
10835 .collect();
10836 found.sort_by_key(|(cid, _)| *cid);
10837 let [
10838 (canceled, InactiveOrderState::Cancelled(cancelled)),
10839 (expired, InactiveOrderState::Expired(expiry)),
10840 (filled, InactiveOrderState::FullyFilled(fill)),
10841 (rejected, InactiveOrderState::OpenFailed(failed)),
10842 (replaced, InactiveOrderState::Cancelled(replacement)),
10843 ] = found.as_slice()
10844 else {
10845 panic!("filled, canceled, replaced, expired and rejected: {found:?}");
10846 };
10847 assert_eq!(
10848 [*canceled, *expired, *filled, *rejected, *replaced],
10849 ["canceled", "expired", "filled", "rejected", "replaced"]
10850 );
10851 assert_eq!(
10852 (fill.filled_quantity, fill.avg_price),
10853 (dec!(2), Some(dec!(101.5)))
10854 );
10855 assert_eq!(
10856 cancelled.filled_quantity,
10857 Some(dec!(1)),
10858 "what filled before"
10859 );
10860 assert_eq!(expiry.filled_quantity, Some(dec!(1)));
10861 assert_eq!(replacement.filled_quantity, Some(Decimal::ZERO));
10862 assert_eq!(
10863 cancelled.time_exchange,
10864 parse_timestamp("2026-10-01T15:00:00Z").unwrap(),
10865 "stamped when the order last changed"
10866 );
10867 assert!(matches!(
10868 failed,
10869 OrderError::Rejected(ApiError::OrderRejected(message))
10870 if message.contains("id-rejected")
10871 ));
10872 assert!(
10873 ended
10874 .iter()
10875 .all(|order| order.key.strategy == StrategyId::new("strategy")
10876 && order.key.instrument == InstrumentNameExchange::new("spy")),
10877 "each carries the key it was asked for"
10878 );
10879 }
10880
10881 #[tokio::test]
10882 async fn fetch_ended_orders_fails_when_a_lookup_fails() {
10883 let server = MockServer::start().await;
10884 mount_lookup(&server, "a", ok(order_json("a", "SPY", "canceled", "0")), 1).await;
10885 mount_lookup(&server, "b", ResponseTemplate::new(500), 1).await;
10886
10887 let result = client_for(&server)
10888 .fetch_ended_orders(&[alpaca_key("SPY", "a"), alpaca_key("SPY", "b")])
10889 .await;
10890
10891 assert!(
10892 matches!(result, Err(UnindexedClientError::Connectivity(_))),
10893 "{result:?}"
10894 );
10895 }
10896
10897 #[tokio::test]
10900 async fn a_reconnect_check_lists_once_and_reports_the_orders_that_ended() {
10901 use wiremock::matchers::query_param;
10902
10903 let server = MockServer::start().await;
10904 Mock::given(method("GET"))
10905 .and(path("/v2/orders"))
10906 .and(query_param("status", "open"))
10907 .and(query_param("symbols", "QQQ,SPY"))
10908 .respond_with(ok(serde_json::json!([order_json(
10909 "spy-listed",
10910 "SPY",
10911 "new",
10912 "0"
10913 )])))
10914 .expect(1)
10915 .mount(&server)
10916 .await;
10917 mount_lookup(&server, "spy-listed", ResponseTemplate::new(500), 0).await;
10918 mount_lookup(
10919 &server,
10920 "spy-cancelled",
10921 ok(order_json("spy-cancelled", "SPY", "canceled", "1")),
10922 1,
10923 )
10924 .await;
10925 mount_lookup(
10926 &server,
10927 "spy-done",
10928 ok(order_json("spy-done", "SPY", "done_for_day", "1")),
10929 1,
10930 )
10931 .await;
10932 mount_lookup(
10933 &server,
10934 "qqq-filled",
10935 ok(order_json("qqq-filled", "QQQ", "filled", "2")),
10936 1,
10937 )
10938 .await;
10939
10940 let known = KnownLiveOrders::shared(ExchangeId::AlpacaBroker);
10941 let [listed, cancelled, done, filled] = [
10942 ("SPY", "spy-listed"),
10943 ("SPY", "spy-cancelled"),
10944 ("SPY", "spy-done"),
10945 ("QQQ", "qqq-filled"),
10946 ]
10947 .map(|(instrument, cid)| alpaca_key(instrument, cid));
10948 for key in [&listed, &cancelled, &done, &filled] {
10949 hold(&known, key);
10950 }
10951 let mut unchecked = UncheckedOrders::default();
10952 unchecked.open(known.lock().instruments());
10953 let config = Arc::new(AlpacaConfig::with_base_url(
10954 "test-key".into(),
10955 "test-secret".into(),
10956 server.uri(),
10957 ));
10958 let (tx, mut rx) = mpsc::unbounded_channel();
10959
10960 recover_alpaca_ended_orders(
10961 &reqwest::Client::new(),
10962 &Arc::new(RateLimitTracker::new()),
10963 &config,
10964 &known,
10965 &mut unchecked,
10966 &tx,
10967 )
10968 .await;
10969
10970 let mut reported: Vec<_> = std::iter::from_fn(|| rx.try_recv().ok())
10971 .map(|event| {
10972 let AccountEventKind::OrderSnapshot(
10973 rustrade_integration::collection::snapshot::Snapshot(order),
10974 ) = event.kind
10975 else {
10976 panic!("an order snapshot: {event:?}");
10977 };
10978 assert!(matches!(order.state, OrderState::Inactive(_)), "{order:?}");
10979 order.key.cid
10980 })
10981 .collect();
10982 reported.sort();
10983 assert_eq!(reported, [filled.cid.clone(), cancelled.cid.clone()]);
10984 let known = known.lock();
10985 assert!(known.contains(&listed.cid) && known.contains(&done.cid));
10986 assert!(!known.contains(&cancelled.cid) && !known.contains(&filled.cid));
10987 assert!(unchecked.is_empty(), "both instruments checked");
10988 }
10989
10990 #[tokio::test]
10992 async fn a_failed_listing_is_retried_for_every_instrument_in_it() {
10993 let server = MockServer::start().await;
10994 Mock::given(method("GET"))
10995 .and(path("/v2/orders"))
10996 .respond_with(ResponseTemplate::new(503))
10997 .expect(1)
10998 .mount(&server)
10999 .await;
11000
11001 let known = KnownLiveOrders::shared(ExchangeId::AlpacaBroker);
11002 hold(&known, &alpaca_key("SPY", "a"));
11003 hold(&known, &alpaca_key("QQQ", "b"));
11004 let mut unchecked = UncheckedOrders::default();
11005 unchecked.open(known.lock().instruments());
11006 let config = Arc::new(AlpacaConfig::with_base_url(
11007 "test-key".into(),
11008 "test-secret".into(),
11009 server.uri(),
11010 ));
11011 let (tx, mut rx) = mpsc::unbounded_channel();
11012
11013 recover_alpaca_ended_orders(
11014 &reqwest::Client::new(),
11015 &Arc::new(RateLimitTracker::new()),
11016 &config,
11017 &known,
11018 &mut unchecked,
11019 &tx,
11020 )
11021 .await;
11022
11023 assert!(rx.try_recv().is_err(), "nothing reported");
11024 let now = tokio::time::Instant::now();
11025 assert!(
11026 unchecked.ready(&NoPendingFills, now).is_empty(),
11027 "both wait"
11028 );
11029 for instrument in ["QQQ", "SPY"] {
11030 assert_eq!(
11031 unchecked.failed(
11032 &InstrumentNameExchange::new(instrument),
11033 now,
11034 Duration::ZERO
11035 ),
11036 Some(crate::client::order_recovery::GapFailure::Retry(
11037 Duration::from_secs(crate::client::order_recovery::GAP_RETRY_BASE_SECS * 2)
11038 )),
11039 "{instrument} has failed once already"
11040 );
11041 }
11042 assert_eq!(known.lock().instruments().len(), 2, "both still held");
11043 }
11044
11045 #[tokio::test]
11046 async fn placing_listing_and_cancelling_keep_the_held_orders_in_step() {
11047 use crate::order::request::{OrderRequestCancel, RequestCancel, RequestOpen};
11048
11049 let server = MockServer::start().await;
11050 Mock::given(method("POST"))
11051 .and(path("/v2/orders"))
11052 .respond_with(ok(order_json("resting", "SPY", "new", "0")))
11053 .up_to_n_times(1)
11054 .mount(&server)
11055 .await;
11056 Mock::given(method("POST"))
11057 .and(path("/v2/orders"))
11058 .respond_with(ok(order_json("filled", "SPY", "filled", "2")))
11059 .mount(&server)
11060 .await;
11061 Mock::given(method("GET"))
11062 .and(path("/v2/orders"))
11063 .respond_with(ok(serde_json::json!([order_json(
11064 "listed", "QQQ", "new", "0"
11065 )])))
11066 .mount(&server)
11067 .await;
11068 Mock::given(method("DELETE"))
11069 .and(path("/v2/orders/id-resting"))
11070 .respond_with(ResponseTemplate::new(204))
11071 .mount(&server)
11072 .await;
11073 let client = client_for(&server);
11074 let spy = InstrumentNameExchange::new("SPY");
11075
11076 for cid in ["resting", "filled"] {
11077 let request = OrderRequestOpen {
11078 key: OrderKey::new(
11079 ExchangeId::AlpacaBroker,
11080 &spy,
11081 StrategyId::new("strategy"),
11082 ClientOrderId::new(cid),
11083 ),
11084 state: RequestOpen {
11085 side: Side::Buy,
11086 price: Some(Decimal::ONE_HUNDRED),
11087 quantity: Decimal::TWO,
11088 kind: OrderKind::Limit,
11089 time_in_force: TimeInForce::GoodUntilCancelled { post_only: false },
11090 position_id: None,
11091 reduce_only: false,
11092 market: None,
11093 },
11094 };
11095 client.open_order(request).await;
11096 }
11097 client.fetch_open_orders(&[]).await.unwrap();
11098 {
11099 let known = client.known_live.lock();
11100 assert!(known.contains(&ClientOrderId::new("resting")));
11101 assert!(
11102 !known.contains(&ClientOrderId::new("filled")),
11103 "filled in its placement response"
11104 );
11105 assert!(known.contains(&ClientOrderId::new("listed")));
11106 }
11107
11108 let cancel = client
11109 .cancel_order(OrderRequestCancel {
11110 key: OrderKey::new(
11111 ExchangeId::AlpacaBroker,
11112 &spy,
11113 StrategyId::new("strategy"),
11114 ClientOrderId::new("resting"),
11115 ),
11116 state: RequestCancel {
11117 id: Some(VenueOrderId::Assigned(OrderId::new("id-resting"))),
11118 },
11119 })
11120 .await;
11121 let Ok(cancelled) = &cancel.state else {
11122 panic!("expected Ok, got {cancel:?}");
11123 };
11124 assert_eq!(
11125 cancelled.filled_quantity, None,
11126 "the DELETE response has no body, so what filled is unknown"
11127 );
11128 assert!(
11129 client
11130 .known_live
11131 .lock()
11132 .contains(&ClientOrderId::new("resting")),
11133 "a cancel Alpaca has only accepted has not ended the order"
11134 );
11135 }
11136
11137 #[tokio::test]
11144 async fn an_order_that_ended_in_its_placement_response_is_reported_as_it_ended() {
11145 use crate::order::request::RequestOpen;
11146
11147 let Some(time) = parse_timestamp("2026-10-01T15:00:00Z") else {
11148 panic!("order_json's updated_at parses");
11149 };
11150 let id = |cid: &str| OrderId::new(format!("id-{cid}"));
11151 let open = |cid: &str, filled| {
11152 OrderState::active(Open::new(VenueOrderId::Assigned(id(cid)), time, filled))
11153 };
11154 let cases: [(&str, &str, &str, UnindexedOrderState, bool); 13] = [
11155 (
11156 "ioc-unfilled",
11157 "expired",
11158 "0",
11159 OrderState::expired(Expired::new(
11160 id("ioc-unfilled"),
11161 time,
11162 Some(Decimal::ZERO),
11163 )),
11164 false,
11165 ),
11166 (
11167 "ioc-partial",
11168 "expired",
11169 "1",
11170 OrderState::expired(Expired::new(id("ioc-partial"), time, Some(Decimal::ONE))),
11171 false,
11172 ),
11173 (
11174 "fill-unknown",
11175 "expired",
11176 "not-a-number",
11177 OrderState::expired(Expired::new(id("fill-unknown"), time, None)),
11178 false,
11179 ),
11180 (
11181 "cancelled",
11182 "canceled",
11183 "1",
11184 OrderState::inactive(Cancelled::new(id("cancelled"), time, Some(Decimal::ONE))),
11185 false,
11186 ),
11187 (
11188 "rejected",
11189 "rejected",
11190 "0",
11191 OrderState::inactive(OrderError::Rejected(ApiError::OrderRejected(
11192 "Alpaca rejected order id-rejected after accepting it".to_string(),
11193 ))),
11194 false,
11195 ),
11196 (
11197 "filled",
11198 "filled",
11199 "2",
11200 OrderState::fully_filled(Filled::new(
11201 id("filled"),
11202 time,
11203 Decimal::TWO,
11204 Some(Decimal::new(1015, 1)),
11205 )),
11206 false,
11207 ),
11208 (
11209 "replaced",
11210 "replaced",
11211 "0",
11212 OrderState::inactive(Cancelled::new(id("replaced"), time, Some(Decimal::ZERO))),
11213 false,
11214 ),
11215 (
11216 "live-covered",
11217 "partially_filled",
11218 "2",
11219 OrderState::fully_filled(Filled::new(
11220 id("live-covered"),
11221 time,
11222 Decimal::TWO,
11223 Some(Decimal::new(1015, 1)),
11224 )),
11225 false,
11226 ),
11227 ("new", "new", "0", open("new", Decimal::ZERO), true),
11228 (
11229 "live-fill-unknown",
11230 "new",
11231 "not-a-number",
11232 open("live-fill-unknown", Decimal::ZERO),
11233 true,
11234 ),
11235 (
11236 "partial",
11237 "partially_filled",
11238 "1",
11239 open("partial", Decimal::ONE),
11240 true,
11241 ),
11242 (
11243 "for-the-day",
11244 "done_for_day",
11245 "1",
11246 open("for-the-day", Decimal::ONE),
11247 true,
11248 ),
11249 (
11250 "unknown",
11251 "a_status_not_yet_documented",
11252 "0",
11253 open("unknown", Decimal::ZERO),
11254 true,
11255 ),
11256 ];
11257
11258 for (cid, status, filled, expected, held) in cases {
11259 for bracket in [false, true] {
11260 let server = MockServer::start().await;
11261 Mock::given(method("POST"))
11262 .and(path("/v2/orders"))
11263 .respond_with(ok(order_json(cid, "SPY", status, filled)))
11264 .expect(1)
11265 .mount(&server)
11266 .await;
11267 let client = client_for(&server);
11268 let spy = InstrumentNameExchange::new("SPY");
11269 let time_in_force = TimeInForce::GoodUntilCancelled { post_only: false };
11270
11271 let state = if bracket {
11272 client
11273 .open_bracket_order(AlpacaBracketOrderRequest::new(
11274 spy,
11275 StrategyId::new("strategy"),
11276 ClientOrderId::new(cid),
11277 Side::Buy,
11278 Decimal::TWO,
11279 Decimal::ONE_HUNDRED,
11280 Decimal::new(120, 0),
11281 Decimal::new(90, 0),
11282 time_in_force,
11283 ))
11284 .await
11285 .parent
11286 .state
11287 } else {
11288 let request = OrderRequestOpen {
11289 key: OrderKey::new(
11290 ExchangeId::AlpacaBroker,
11291 &spy,
11292 StrategyId::new("strategy"),
11293 ClientOrderId::new(cid),
11294 ),
11295 state: RequestOpen {
11296 side: Side::Buy,
11297 price: Some(Decimal::ONE_HUNDRED),
11298 quantity: Decimal::TWO,
11299 kind: OrderKind::Limit,
11300 time_in_force,
11301 position_id: None,
11302 reduce_only: false,
11303 market: None,
11304 },
11305 };
11306 client.open_order(request).await.state
11307 };
11308
11309 assert_eq!(state, expected, "{status} {filled}, bracket: {bracket}");
11310 assert_eq!(
11311 client.known_live.lock().contains(&ClientOrderId::new(cid)),
11312 held,
11313 "{status} {filled}, bracket: {bracket}"
11314 );
11315 }
11316 }
11317 }
11318
11319 #[test]
11322 fn the_stream_keeps_the_held_orders_in_step() {
11323 let (tx, mut rx) = mpsc::unbounded_channel();
11324 let dedup = new_dedup_cache();
11325 let known = KnownLiveOrders::shared(ExchangeId::AlpacaBroker);
11326 let frame = |event: &str, filled: &str| {
11327 format!(
11328 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}"}}}}}}"#
11329 )
11330 };
11331 let cid = ClientOrderId::new("a");
11332
11333 process_ws_text(&frame("new", "0"), &tx, &dedup, &known);
11334 assert!(known.lock().contains(&cid), "acknowledged");
11335
11336 process_ws_text(&frame("done_for_day", "1"), &tx, &dedup, &known);
11337 assert!(known.lock().contains(&cid), "done for the day is not ended");
11338
11339 process_ws_text(&frame("canceled", "1"), &tx, &dedup, &known);
11340 assert!(!known.lock().contains(&cid), "cancelled");
11341
11342 let events: Vec<_> = std::iter::from_fn(|| rx.try_recv().ok()).collect();
11343 let [_, done, cancelled] = events.as_slice() else {
11344 panic!("three events: {events:?}");
11345 };
11346 let AccountEventKind::OrderSnapshot(
11347 rustrade_integration::collection::snapshot::Snapshot(order),
11348 ) = &done.kind
11349 else {
11350 panic!("done_for_day reports an order snapshot: {done:?}");
11351 };
11352 let OrderState::Active(ActiveOrderState::Open(open)) = &order.state else {
11353 panic!("open: {order:?}");
11354 };
11355 assert_eq!(open.filled_quantity, Decimal::ONE, "with what it filled");
11356 assert!(matches!(
11357 cancelled.kind,
11358 AccountEventKind::OrderCancelled(_)
11359 ));
11360 }
11361
11362 #[tokio::test]
11365 async fn a_recovered_fill_that_completes_an_order_stops_it_being_held() {
11366 let known = KnownLiveOrders::shared(ExchangeId::AlpacaBroker);
11367 let key = alpaca_key("SPY", "a");
11368 known.lock().live(
11369 &key,
11370 Decimal::TWO,
11371 OrderKind::Limit,
11372 &Open::new(
11373 VenueOrderId::Assigned(OrderId::new("ord-1")),
11374 Utc::now(),
11375 Decimal::ZERO,
11376 ),
11377 );
11378
11379 drive_recover_fills_with(
11380 vec![activity_json("act-1", "ord-1", "1", Some("1"))],
11381 new_dedup_cache(),
11382 &known,
11383 )
11384 .await;
11385 assert!(known.lock().contains(&key.cid), "half filled");
11386
11387 drive_recover_fills_with(
11388 vec![activity_json("act-2", "ord-1", "1", Some("2"))],
11389 new_dedup_cache(),
11390 &known,
11391 )
11392 .await;
11393 assert!(!known.lock().contains(&key.cid), "filled");
11394 }
11395
11396 #[tokio::test]
11399 async fn the_check_loop_runs_at_once_then_waits_out_a_failure() {
11400 let server = MockServer::start().await;
11401 Mock::given(method("GET"))
11402 .and(path("/v2/orders"))
11403 .respond_with(ResponseTemplate::new(503))
11404 .expect(1)
11405 .mount(&server)
11406 .await;
11407 let known = KnownLiveOrders::shared(ExchangeId::AlpacaBroker);
11408 hold(&known, &alpaca_key("SPY", "a"));
11409 let mut unchecked = UncheckedOrders::default();
11410 unchecked.open(known.lock().instruments());
11411 let config = Arc::new(AlpacaConfig::with_base_url(
11412 "test-key".into(),
11413 "test-secret".into(),
11414 server.uri(),
11415 ));
11416 let (tx, _rx) = mpsc::unbounded_channel();
11417
11418 let ran = tokio::time::timeout(
11419 Duration::from_secs(1),
11420 run_order_checks(
11421 &reqwest::Client::new(),
11422 &Arc::new(RateLimitTracker::new()),
11423 &config,
11424 &known,
11425 &mut unchecked,
11426 &tx,
11427 ),
11428 )
11429 .await;
11430
11431 assert!(ran.is_err(), "the loop never completes");
11432 assert!(
11433 unchecked.contains(&InstrumentNameExchange::new("SPY")),
11434 "SPY waits for its retry"
11435 );
11436 assert_eq!(
11437 server.received_requests().await.unwrap().len(),
11438 1,
11439 "the failed check is not run again at once"
11440 );
11441 }
11442
11443 #[tokio::test]
11445 async fn the_check_loop_waits_once_the_consumer_has_gone() {
11446 let server = MockServer::start().await;
11447 Mock::given(method("GET"))
11448 .respond_with(ResponseTemplate::new(503))
11449 .expect(0)
11450 .mount(&server)
11451 .await;
11452 let known = KnownLiveOrders::shared(ExchangeId::AlpacaBroker);
11453 hold(&known, &alpaca_key("SPY", "a"));
11454 let mut unchecked = UncheckedOrders::default();
11455 unchecked.open(known.lock().instruments());
11456 let config = Arc::new(AlpacaConfig::with_base_url(
11457 "test-key".into(),
11458 "test-secret".into(),
11459 server.uri(),
11460 ));
11461 let (tx, rx) = mpsc::unbounded_channel();
11462 drop(rx);
11463
11464 let ran = tokio::time::timeout(
11465 Duration::from_millis(200),
11466 run_order_checks(
11467 &reqwest::Client::new(),
11468 &Arc::new(RateLimitTracker::new()),
11469 &config,
11470 &known,
11471 &mut unchecked,
11472 &tx,
11473 ),
11474 )
11475 .await;
11476
11477 assert!(ran.is_err(), "the loop never completes");
11478 assert!(
11479 server.received_requests().await.unwrap().is_empty(),
11480 "nothing asked once the consumer has gone"
11481 );
11482 }
11483
11484 async fn shortability_with_asset(
11485 symbol: &str,
11486 asset: serde_json::Value,
11487 ) -> (Result<Shortability, UnindexedClientError>, Vec<String>) {
11488 use crate::client::ShortabilityClient;
11489
11490 let server = MockServer::start().await;
11491 Mock::given(method("GET"))
11492 .respond_with(ResponseTemplate::new(200).set_body_json(asset))
11493 .mount(&server)
11494 .await;
11495 let result = client_for(&server)
11496 .fetch_shortability(&InstrumentNameExchange::new(symbol))
11497 .await;
11498 let paths = server
11499 .received_requests()
11500 .await
11501 .unwrap()
11502 .iter()
11503 .map(|request| request.url.path().to_owned())
11504 .collect();
11505 (result, paths)
11506 }
11507
11508 #[tokio::test]
11509 async fn an_easy_to_borrow_stock_is_shortable_at_no_fee() {
11510 let (result, paths) = shortability_with_asset(
11511 "AAPL",
11512 serde_json::json!({
11513 "class": "us_equity",
11514 "symbol": "AAPL",
11515 "shortable": true,
11516 "easy_to_borrow": true,
11517 "borrow_status": "easy_to_borrow",
11518 }),
11519 )
11520 .await;
11521
11522 assert_eq!(
11523 result.unwrap(),
11524 Shortability::new()
11525 .with_shortable(true)
11526 .with_easy_to_borrow(true)
11527 .with_fee_rate(Decimal::ZERO)
11528 );
11529 assert_eq!(paths, vec!["/v2/assets/AAPL"]);
11530 }
11531
11532 #[tokio::test]
11533 async fn a_hard_to_borrow_stock_has_no_known_fee() {
11534 let (result, _) = shortability_with_asset(
11535 "GME",
11536 serde_json::json!({
11537 "class": "us_equity",
11538 "shortable": true,
11539 "easy_to_borrow": true,
11540 "borrow_status": "hard_to_borrow",
11541 }),
11542 )
11543 .await;
11544
11545 assert_eq!(
11546 result.unwrap(),
11547 Shortability::new()
11548 .with_shortable(true)
11549 .with_easy_to_borrow(false),
11550 "borrow_status wins over the easy_to_borrow flag, and a hard-to-borrow fee is unknown"
11551 );
11552 }
11553
11554 #[tokio::test]
11555 async fn without_a_borrow_status_the_easy_to_borrow_flag_is_read() {
11556 let (result, _) = shortability_with_asset(
11557 "AAPL",
11558 serde_json::json!({ "class": "us_equity", "shortable": false, "easy_to_borrow": false }),
11559 )
11560 .await;
11561
11562 assert_eq!(
11563 result.unwrap(),
11564 Shortability::new()
11565 .with_shortable(false)
11566 .with_easy_to_borrow(false)
11567 );
11568 }
11569
11570 #[tokio::test]
11571 async fn an_asset_without_borrow_fields_is_unknown_but_for_shortable() {
11572 let (result, paths) = shortability_with_asset(
11573 "BTC/USD",
11574 serde_json::json!({ "class": "crypto", "symbol": "BTC/USD", "shortable": false }),
11575 )
11576 .await;
11577
11578 assert_eq!(result.unwrap(), Shortability::new().with_shortable(false));
11579 assert_eq!(
11580 paths,
11581 vec!["/v2/assets/BTC%2FUSD"],
11582 "a crypto pair's slash is escaped, not read as a path separator"
11583 );
11584 }
11585
11586 #[tokio::test]
11587 async fn an_option_has_no_shortability() {
11588 let (result, _) = shortability_with_asset(
11589 "AAPL250117C00150000",
11590 serde_json::json!({ "class": "us_option", "shortable": true }),
11591 )
11592 .await;
11593
11594 assert!(
11595 matches!(
11596 result,
11597 Err(UnindexedClientError::Api(ApiError::InstrumentInvalid(ref instrument, ref reason)))
11598 if instrument.name().as_str() == "AAPL250117C00150000"
11599 && reason.contains("option")
11600 ),
11601 "{result:?}"
11602 );
11603 }
11604
11605 #[tokio::test]
11606 async fn an_unknown_symbol_is_an_invalid_instrument() {
11607 use crate::client::ShortabilityClient;
11608
11609 let server = MockServer::start().await;
11610 Mock::given(method("GET"))
11611 .and(path("/v2/assets/NOPE"))
11612 .respond_with(
11613 ResponseTemplate::new(404).set_body_json(
11614 serde_json::json!({ "message": "asset not found for NOPE" }),
11615 ),
11616 )
11617 .mount(&server)
11618 .await;
11619
11620 let result = client_for(&server)
11621 .fetch_shortability(&InstrumentNameExchange::new("NOPE"))
11622 .await;
11623
11624 assert!(
11625 matches!(
11626 result,
11627 Err(UnindexedClientError::Api(ApiError::InstrumentInvalid(ref instrument, _)))
11628 if instrument.name().as_str() == "NOPE"
11629 ),
11630 "{result:?}"
11631 );
11632 }
11633
11634 struct RateLimited {
11637 call: std::sync::atomic::AtomicU32,
11638 last: Option<(&'static str, String)>,
11639 }
11640
11641 impl RateLimited {
11642 fn mount(last: Option<(&'static str, String)>) -> Self {
11643 Self {
11644 call: std::sync::atomic::AtomicU32::new(0),
11645 last,
11646 }
11647 }
11648 }
11649
11650 impl Respond for RateLimited {
11651 fn respond(&self, _: &Request) -> ResponseTemplate {
11652 let call = self.call.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1;
11653 let response = ResponseTemplate::new(429)
11654 .set_body_json(serde_json::json!({"message": "too many requests"}));
11655 if call < MAX_RATE_LIMIT_ATTEMPTS {
11656 return response.insert_header("retry-after", "0");
11657 }
11658 match &self.last {
11659 Some((name, value)) => response.insert_header(*name, value.as_str()),
11660 None => response,
11661 }
11662 }
11663 }
11664
11665 fn unix_now() -> u64 {
11666 std::time::SystemTime::now()
11667 .duration_since(std::time::UNIX_EPOCH)
11668 .unwrap()
11669 .as_secs()
11670 }
11671
11672 #[tokio::test]
11673 async fn exhausted_429_retries_report_alpacas_wait() {
11674 let server = MockServer::start().await;
11675 Mock::given(method("GET"))
11676 .and(path("/v2/account"))
11677 .respond_with(RateLimited::mount(Some(("retry-after", "7".to_string()))))
11678 .mount(&server)
11679 .await;
11680
11681 let http = reqwest::Client::new();
11682 let rl = RateLimitTracker::new();
11683 let url = format!("{}/v2/account", server.uri());
11684 let error = rest_with_retry::<serde_json::Value>(&rl, || http.get(&url))
11685 .await
11686 .unwrap_err();
11687
11688 let UnindexedClientError::Api(error) = error else {
11689 panic!("expected Api, got {error:?}");
11690 };
11691 assert_eq!(error.retry_after(), Some(Duration::from_secs(7)));
11692 let blocked_until = rl
11693 .blocked_until
11694 .lock()
11695 .expect("the last refusal pauses the client");
11696 assert!(
11697 blocked_until >= tokio::time::Instant::now() + Duration::from_secs(6),
11698 "the client's next request waits for Alpaca's wait"
11699 );
11700 assert_eq!(
11701 server.received_requests().await.unwrap().len(),
11702 MAX_RATE_LIMIT_ATTEMPTS as usize
11703 );
11704 }
11705
11706 #[tokio::test]
11707 async fn exhausted_429_retries_read_the_reset_time_without_retry_after() {
11708 let server = MockServer::start().await;
11709 Mock::given(method("GET"))
11710 .and(path("/v2/account"))
11711 .respond_with(RateLimited::mount(Some((
11712 "x-ratelimit-reset",
11713 (unix_now() + 30).to_string(),
11714 ))))
11715 .mount(&server)
11716 .await;
11717
11718 let http = reqwest::Client::new();
11719 let rl = RateLimitTracker::new();
11720 let url = format!("{}/v2/account", server.uri());
11721 let error = rest_with_retry::<serde_json::Value>(&rl, || http.get(&url))
11722 .await
11723 .unwrap_err();
11724
11725 let UnindexedClientError::Api(ApiError::RateLimit {
11726 retry_after: Some(wait),
11727 ..
11728 }) = error
11729 else {
11730 panic!("expected a RateLimit with a wait, got {error:?}");
11731 };
11732 assert!(
11733 (Duration::from_secs(29)..=Duration::from_secs(30)).contains(&wait),
11734 "{wait:?}"
11735 );
11736 }
11737
11738 #[tokio::test]
11739 async fn exhausted_429_retries_without_a_hint_report_none() {
11740 let server = MockServer::start().await;
11741 Mock::given(method("GET"))
11742 .and(path("/v2/account"))
11743 .respond_with(RateLimited::mount(None))
11744 .mount(&server)
11745 .await;
11746
11747 let http = reqwest::Client::new();
11748 let rl = RateLimitTracker::new();
11749 let url = format!("{}/v2/account", server.uri());
11750 let error = rest_with_retry::<serde_json::Value>(&rl, || http.get(&url))
11751 .await
11752 .unwrap_err();
11753
11754 assert!(
11755 matches!(
11756 error,
11757 UnindexedClientError::Api(ApiError::RateLimit {
11758 retry_after: None,
11759 ..
11760 })
11761 ),
11762 "{error:?}"
11763 );
11764 }
11765
11766 #[tokio::test]
11767 async fn an_exhausted_cancel_reports_alpacas_wait() {
11768 let server = MockServer::start().await;
11769 Mock::given(method("DELETE"))
11770 .and(path("/v2/orders/abc"))
11771 .respond_with(RateLimited::mount(Some(("retry-after", "5".to_string()))))
11772 .mount(&server)
11773 .await;
11774
11775 let http = reqwest::Client::new();
11776 let rl = RateLimitTracker::new();
11777 let url = format!("{}/v2/orders/abc", server.uri());
11778 let error = rest_delete_with_retry(&rl, || http.delete(&url))
11779 .await
11780 .unwrap_err();
11781
11782 let UnindexedOrderError::Rejected(error) = error else {
11783 panic!("expected Rejected, got {error:?}");
11784 };
11785 assert_eq!(error.retry_after(), Some(Duration::from_secs(5)));
11786 assert!(
11787 rl.blocked_until.lock().is_some(),
11788 "the last refusal pauses the client"
11789 );
11790 }
11791 }
11792}