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 if let Some(effect) = request.state.margin_effect {
2455 return Order {
2456 key: order_key,
2457 side,
2458 price,
2459 quantity,
2460 kind,
2461 time_in_force,
2462 state: OrderState::inactive(OrderError::UnsupportedMarginEffect(effect)),
2463 };
2464 }
2465
2466 let tif_str = match map_time_in_force(time_in_force) {
2468 Ok(s) => s,
2469 Err(msg) => {
2470 return Order {
2471 key: order_key,
2472 side,
2473 price,
2474 quantity,
2475 kind,
2476 time_in_force,
2477 state: OrderState::inactive(OrderError::Rejected(ApiError::OrderRejected(
2478 msg.to_string(),
2479 ))),
2480 };
2481 }
2482 };
2483
2484 let order_type_str = match map_order_kind(kind) {
2486 Some(s) => s,
2487 None => {
2488 return Order {
2489 key: order_key,
2490 side,
2491 price,
2492 quantity,
2493 kind,
2494 time_in_force,
2495 state: OrderState::inactive(OrderError::UnsupportedOrderType(format!(
2496 "Alpaca connector does not yet support OrderKind::{kind:?}"
2497 ))),
2498 };
2499 }
2500 };
2501
2502 if let OrderKind::TrailingStop {
2504 offset_type: TrailingOffsetType::BasisPoints,
2505 ..
2506 } = kind
2507 {
2508 return Order {
2509 key: order_key,
2510 side,
2511 price,
2512 quantity,
2513 kind,
2514 time_in_force,
2515 state: OrderState::inactive(OrderError::UnsupportedOrderType(
2516 "Alpaca does not support TrailingOffsetType::BasisPoints; \
2517 use Percentage or Absolute"
2518 .to_string(),
2519 )),
2520 };
2521 }
2522
2523 if matches!(kind, OrderKind::StopLimit { .. }) && price.is_none() {
2526 return Order {
2527 key: order_key,
2528 side,
2529 price,
2530 quantity,
2531 kind,
2532 time_in_force,
2533 state: OrderState::inactive(OrderError::Rejected(ApiError::OrderRejected(
2534 "StopLimit order requires Order.price (the limit price) to be set".to_string(),
2535 ))),
2536 };
2537 }
2538
2539 let (stop_price, trail_percent, trail_price) = match kind {
2541 OrderKind::Stop { trigger_price } | OrderKind::StopLimit { trigger_price } => {
2542 (Some(trigger_price.to_string()), None, None)
2543 }
2544 OrderKind::TrailingStop {
2545 offset,
2546 offset_type,
2547 ..
2548 } => match offset_type {
2549 TrailingOffsetType::Percentage => (None, Some(offset.to_string()), None),
2550 TrailingOffsetType::Absolute => (None, None, Some(offset.to_string())),
2551 TrailingOffsetType::BasisPoints => unreachable!("validated above"),
2552 },
2553 OrderKind::Market
2555 | OrderKind::Limit
2556 | OrderKind::TakeProfit { .. }
2557 | OrderKind::TakeProfitLimit { .. }
2558 | OrderKind::TrailingStopLimit { .. }
2559 | OrderKind::TrailingStopFixedLimit { .. } => (None, None, None),
2560 };
2561
2562 let body = AlpacaOrderRequest {
2563 symbol: instrument.name().as_str(),
2564 qty: quantity.to_string(),
2565 side: map_side(side),
2566 order_type: order_type_str,
2567 time_in_force: tif_str,
2568 limit_price: match kind {
2569 OrderKind::Limit | OrderKind::StopLimit { .. } => price.map(|p| p.to_string()),
2570 _ => None,
2571 },
2572 stop_price,
2573 trail_percent,
2574 trail_price,
2575 client_order_id: Some(cid.0.as_str()),
2576 position_intent: if is_options_or_equity_symbol(instrument.name().as_str()) {
2582 Some(intent)
2583 } else {
2584 None
2585 },
2586 order_class: None,
2588 take_profit: None,
2589 stop_loss: None,
2590 };
2591
2592 let http = self.http.clone();
2593 let rl = &self.rate_limiter;
2594
2595 let result: Result<AlpacaOrderResponse, UnindexedClientError> =
2596 rest_with_retry(rl, || http.post(&self.orders_url).json(&body)).await;
2597
2598 match result {
2599 Ok(resp) => {
2600 let state = placed_order_state(&resp, &order_key.instrument, quantity);
2601 self.known_live
2602 .lock()
2603 .placed(&order_key, quantity, kind, &state);
2604
2605 Order {
2606 key: order_key,
2607 side,
2608 price,
2609 quantity,
2610 kind,
2611 time_in_force,
2612 state,
2613 }
2614 }
2615 Err(e) => {
2616 let order_err = order_post_error(e);
2617 Order {
2618 key: order_key,
2619 side,
2620 price,
2621 quantity,
2622 kind,
2623 time_in_force,
2624 state: OrderState::inactive(order_err),
2625 }
2626 }
2627 }
2628 }
2629}
2630
2631const MAX_OPEN_ORDERS: usize = 500;
2639
2640async fn fetch_raw_open_orders(
2648 http: &reqwest::Client,
2649 rate_limiter: &RateLimitTracker,
2650 base: &str,
2651 instruments: &[InstrumentNameExchange],
2652) -> Result<Vec<AlpacaOrderResponse>, UnindexedClientError> {
2653 let orders: Vec<AlpacaOrderResponse> = if instruments.is_empty() {
2654 rest_with_retry(rate_limiter, || {
2655 http.get(format!("{base}/v2/orders"))
2656 .query(&[("status", "open"), ("limit", "500")])
2657 })
2658 .await?
2659 } else {
2660 let symbols = instruments.iter().map(|i| i.name().as_str()).join(",");
2661 rest_with_retry(rate_limiter, || {
2662 http.get(format!("{base}/v2/orders")).query(&[
2663 ("status", "open"),
2664 ("limit", "500"),
2665 ("symbols", &symbols),
2666 ])
2667 })
2668 .await?
2669 };
2670 if orders.len() == MAX_OPEN_ORDERS {
2671 warn!(
2672 limit = MAX_OPEN_ORDERS,
2673 "Alpaca fetch_raw_open_orders: received exactly {MAX_OPEN_ORDERS} results — \
2674 response is likely truncated"
2675 );
2676 return Err(UnindexedClientError::TruncatedSnapshot {
2677 limit: MAX_OPEN_ORDERS,
2678 });
2679 }
2680 Ok(orders)
2681}
2682
2683const MAX_ACTIVITY_PAGES: usize = 50;
2692
2693trait PagedActivity: DeserializeOwned {
2695 fn id(&self) -> &str;
2697 fn time(&self) -> Option<DateTime<Utc>>;
2700}
2701
2702impl PagedActivity for AlpacaActivity {
2703 fn id(&self) -> &str {
2704 &self.id
2705 }
2706
2707 fn time(&self) -> Option<DateTime<Utc>> {
2708 parse_timestamp(&self.transaction_time)
2709 }
2710}
2711
2712impl PagedActivity for AlpacaNonTradeActivity {
2713 fn id(&self) -> &str {
2714 &self.id
2715 }
2716
2717 fn time(&self) -> Option<DateTime<Utc>> {
2718 self.created_at.as_deref().and_then(parse_timestamp)
2719 }
2720}
2721
2722const FILL_ACTIVITIES: &str = "FILL";
2724
2725const CASH_FLOW_ACTIVITIES: &str = "INT,FEE,PTC,PTR";
2727
2728const OPTION_EVENT_ACTIVITIES: &str = "OPASN,OPEXC,OPEXP,OPTRD";
2730
2731const LAST_MILLISECOND: NaiveTime = match NaiveTime::from_hms_milli_opt(23, 59, 59, 999) {
2733 Some(time) => time,
2734 None => panic!("23:59:59.999 is a time of day"),
2735};
2736
2737fn span_resume(
2744 resume: Option<DateTime<Utc>>,
2745 start: DateTime<Utc>,
2746 end: DateTime<Utc>,
2747) -> Result<Option<DateTime<Utc>>, ()> {
2748 let resume = resume.filter(|resume| *resume <= end);
2749 if resume.is_some_and(|resume| resume <= floor_millis(start)) {
2750 return Err(());
2751 }
2752 Ok(resume)
2753}
2754
2755struct ActivityPage<A> {
2757 activities: Vec<A>,
2759 resume: Option<DateTime<Utc>>,
2762}
2763
2764fn floor_millis(time: DateTime<Utc>) -> DateTime<Utc> {
2766 DateTime::from_timestamp_millis(time.timestamp_millis()).unwrap_or(time)
2767}
2768
2769const PAGE_SIZE_STR: &str = "100"; const _: () = assert!(
2799 ALPACA_MAX_ACTIVITIES == 100,
2800 "PAGE_SIZE_STR must be updated to match ALPACA_MAX_ACTIVITIES",
2801);
2802
2803async fn paginate_activities<A: PagedActivity>(
2804 http: &reqwest::Client,
2805 rate_limiter: &RateLimitTracker,
2806 base: &str,
2807 types: &str,
2808 start: DateTime<Utc>,
2809 end: Option<DateTime<Utc>>,
2810) -> Result<ActivityPage<A>, UnindexedClientError> {
2811 let after =
2814 floor_millis(start.max(DateTime::UNIX_EPOCH)).to_rfc3339_opts(SecondsFormat::Millis, true);
2815 let until = end.filter(|end| *end < Utc::now()).map(|end| {
2816 (floor_millis(end) + TimeDelta::milliseconds(1))
2817 .to_rfc3339_opts(SecondsFormat::Millis, true)
2818 });
2819 let mut all = Vec::with_capacity(ALPACA_MAX_ACTIVITIES);
2820 let mut page_token: Option<String> = None;
2821 let mut pages = 0usize;
2822 let mut truncated = false;
2823
2824 loop {
2825 if pages >= MAX_ACTIVITY_PAGES {
2826 truncated = true;
2827 break;
2828 }
2829 pages += 1;
2830 let page_token_ref = page_token.as_deref();
2832 let activities: Vec<A> = rest_with_retry(rate_limiter, || {
2833 let mut req = http.get(format!("{base}/v2/account/activities")).query(&[
2834 ("activity_types", types),
2835 ("after", after.as_str()),
2836 ("page_size", PAGE_SIZE_STR),
2837 ("direction", "asc"),
2838 ]);
2839 if let Some(until) = until.as_deref() {
2840 req = req.query(&[("until", until)]);
2841 }
2842 if let Some(token) = page_token_ref {
2843 req = req.query(&[("page_token", token)]);
2844 }
2845 req
2846 })
2847 .await?;
2848
2849 let page_len = activities.len();
2850 let page_token_candidate = activities.last().map(|a| a.id().to_owned());
2860 all.extend(activities);
2861
2862 if page_len < ALPACA_MAX_ACTIVITIES {
2863 break;
2864 }
2865 match page_token_candidate {
2866 Some(token) if !token.is_empty() => {
2867 debug!("Alpaca paginate_activities: fetching next page ({page_len} results)");
2868 page_token = Some(token);
2869 }
2870 _ => break,
2874 }
2875 }
2876
2877 let resume = truncated.then(|| {
2880 floor_millis(
2881 all.iter()
2882 .rev()
2883 .find_map(PagedActivity::time)
2884 .unwrap_or(start),
2885 )
2886 });
2887 Ok(ActivityPage {
2888 activities: all,
2889 resume,
2890 })
2891}
2892
2893#[allow(clippy::cognitive_complexity)] async fn connection_manager(
2909 tx: mpsc::UnboundedSender<UnindexedAccountEvent>,
2910 dedup: SharedDedupCache,
2911 config: Arc<AlpacaConfig>,
2912 http: reqwest::Client,
2913 rate_limiter: Arc<RateLimitTracker>,
2914 known: SharedKnownLiveOrders,
2915 instruments: Vec<InstrumentNameExchange>,
2916 initial_ws: Option<WebSocket>,
2917) {
2918 let mut budget = ReconnectBudget::new("Alpaca", BackoffLimits::ACCOUNT_STREAM);
2919 let mut disconnect_time: Option<DateTime<Utc>> = None;
2920 let mut unchecked = UncheckedOrders::default();
2923 let mut current_ws = initial_ws;
2924
2925 'outer: loop {
2926 let mut ws = match current_ws.take() {
2928 Some(ws) => ws,
2929 None => match connect_and_subscribe(&config).await {
2930 Ok(ws) => ws,
2931 Err(e) => {
2932 error!(%e, "Alpaca WS connect/subscribe failed");
2933 if !budget.wait().await {
2934 error!("Alpaca max reconnect attempts exhausted");
2935 emit_stream_terminated(
2937 &tx,
2938 ExchangeId::AlpacaBroker,
2939 StreamTerminationReason::ReconnectBudgetExhausted {
2940 attempts: budget.attempts(),
2941 last_error: e.to_string(),
2942 },
2943 );
2944 break;
2945 }
2946 continue;
2947 }
2948 },
2949 };
2950 info!("Alpaca account_stream connected and subscribed");
2951 if let Some(dt) = disconnect_time.take() {
2961 recover_fills_or_report(
2962 &http,
2963 &rate_limiter,
2964 &instruments,
2965 config.rest_base_url(),
2966 dt,
2967 Duration::from_secs(FILL_RECOVERY_TIMEOUT_SECS),
2968 &tx,
2969 &dedup,
2970 &known,
2971 )
2972 .await;
2973 let held = {
2975 let known = known.lock();
2976 if instruments.is_empty() {
2977 known.instruments()
2978 } else {
2979 known.instruments_among(&instruments)
2980 }
2981 };
2982 unchecked.open(held);
2983 }
2984
2985 let mut last_message_time = Utc::now();
2998 let heartbeat = tokio::time::sleep(Duration::from_secs(HEARTBEAT_TIMEOUT_SECS));
2999 tokio::pin!(heartbeat);
3000 let stable_from = tokio::time::Instant::now() + Duration::from_secs(HEARTBEAT_TIMEOUT_SECS);
3003
3004 let order_checks =
3009 run_order_checks(&http, &rate_limiter, &config, &known, &mut unchecked, &tx);
3010 tokio::pin!(order_checks);
3011
3012 macro_rules! reset_heartbeat {
3018 () => {
3019 let now = tokio::time::Instant::now();
3020 heartbeat
3021 .as_mut()
3022 .reset(now + Duration::from_secs(HEARTBEAT_TIMEOUT_SECS));
3023 last_message_time = Utc::now();
3024 if now >= stable_from {
3025 budget.reset();
3026 }
3027 };
3028 }
3029 enum DisconnectReason {
3032 ServerClose,
3033 Error(String),
3034 StreamEnded,
3035 HeartbeatTimeout,
3036 }
3037 let reason = loop {
3038 tokio::select! {
3039 msg = ws.next() => {
3040 match msg {
3041 Some(Ok(WsMessage::Ping(_))) => {
3042 reset_heartbeat!();
3045 }
3046 Some(Ok(WsMessage::Text(text))) => {
3047 process_ws_text(text.as_str(), &tx, &dedup, &known);
3048 reset_heartbeat!();
3049 }
3050 Some(Ok(WsMessage::Binary(bytes))) => {
3051 match std::str::from_utf8(&bytes) {
3053 Ok(text) => {
3054 process_ws_text(text, &tx, &dedup, &known);
3055 reset_heartbeat!();
3059 }
3060 Err(e) => warn!(%e, "Alpaca WS binary frame: not valid UTF-8"),
3061 }
3062 }
3063 Some(Ok(WsMessage::Close(frame))) => {
3064 warn!(frame = ?frame, "Alpaca WS closed by server");
3065 break DisconnectReason::ServerClose;
3066 }
3067 Some(Ok(_)) => {} Some(Err(e)) => {
3069 warn!(%e, "Alpaca WS error, reconnecting");
3070 break DisconnectReason::Error(e.to_string());
3071 }
3072 None => {
3073 warn!("Alpaca WS stream ended, reconnecting");
3074 break DisconnectReason::StreamEnded;
3075 }
3076 }
3077 }
3078 () = &mut order_checks => {}
3079 _ = &mut heartbeat => {
3080 warn!(
3081 timeout_secs = HEARTBEAT_TIMEOUT_SECS,
3082 "Alpaca heartbeat timeout, reconnecting"
3083 );
3084 break DisconnectReason::HeartbeatTimeout;
3086 }
3087 _ = tx.closed() => {
3088 debug!("Alpaca account_stream consumer dropped, terminating");
3089 let _ = tokio::time::timeout(
3090 Duration::from_secs(WS_CLOSE_TIMEOUT_SECS),
3091 ws.close(None),
3092 ).await;
3093 break 'outer;
3096 }
3097 }
3098 };
3099
3100 disconnect_time =
3105 Some(last_message_time - chrono::Duration::milliseconds(SIGNAL_RECOVERY_LOOKBACK_MS));
3106
3107 let _ =
3109 tokio::time::timeout(Duration::from_secs(WS_CLOSE_TIMEOUT_SECS), ws.close(None)).await;
3110
3111 if tx.is_closed() {
3112 break;
3115 }
3116 if !budget.wait().await {
3117 error!("Alpaca max reconnect attempts exhausted, stream terminating");
3118 let last_error = match reason {
3121 DisconnectReason::HeartbeatTimeout => {
3122 format!("heartbeat timeout ({HEARTBEAT_TIMEOUT_SECS}s)")
3123 }
3124 DisconnectReason::ServerClose => "WebSocket closed by server".to_string(),
3125 DisconnectReason::Error(e) => e,
3126 DisconnectReason::StreamEnded => "WebSocket stream ended".to_string(),
3127 };
3128 emit_stream_terminated(
3129 &tx,
3130 ExchangeId::AlpacaBroker,
3131 StreamTerminationReason::ReconnectBudgetExhausted {
3132 attempts: budget.attempts(),
3133 last_error,
3134 },
3135 );
3136 break;
3137 }
3138 }
3139}
3140
3141#[derive(Debug)]
3146enum HandshakeError {
3147 Transport(String),
3150 Auth(String),
3153}
3154
3155async fn connect_and_subscribe(config: &AlpacaConfig) -> Result<WebSocket, UnindexedClientError> {
3159 let url = config.stream_url();
3160 debug!(%url, "Alpaca: connecting to WebSocket");
3161
3162 let mut ws = rustrade_integration::protocol::websocket::connect(url)
3163 .await
3164 .map_err(|e| {
3165 UnindexedClientError::Connectivity(ConnectivityError::Socket(format!(
3166 "WS connect: {e}"
3167 )))
3168 })?;
3169
3170 let result = tokio::time::timeout(
3172 Duration::from_secs(WS_HANDSHAKE_TIMEOUT_SECS),
3173 ws_handshake(&mut ws, config),
3174 )
3175 .await;
3176
3177 match result {
3178 Ok(Ok(())) => Ok(ws),
3179 Ok(Err(HandshakeError::Transport(e))) => {
3180 let _ = ws.close(None).await;
3181 Err(UnindexedClientError::Connectivity(
3182 ConnectivityError::Socket(e),
3183 ))
3184 }
3185 Ok(Err(HandshakeError::Auth(e))) => {
3186 let _ = ws.close(None).await;
3187 Err(UnindexedClientError::Api(ApiError::Unauthenticated(e)))
3188 }
3189 Err(_) => {
3190 let _ = ws.close(None).await;
3191 Err(UnindexedClientError::Connectivity(
3192 ConnectivityError::Timeout,
3193 ))
3194 }
3195 }
3196}
3197
3198async fn ws_handshake(ws: &mut WebSocket, config: &AlpacaConfig) -> Result<(), HandshakeError> {
3206 let auth = serde_json::json!({
3208 "action": "auth",
3209 "key": config.api_key(),
3210 "secret": config.secret_key,
3213 })
3214 .to_string();
3215 ws.send(WsMessage::Text(auth.into()))
3216 .await
3217 .map_err(|e| HandshakeError::Transport(format!("WS auth send: {e}")))?;
3218
3219 loop {
3221 match ws.next().await {
3222 Some(Ok(WsMessage::Text(text))) => {
3223 if let Some(result) = check_auth_response(text.as_str()) {
3224 result?;
3225 break;
3226 }
3227 }
3228 Some(Ok(WsMessage::Binary(bytes))) => {
3229 if let Ok(text) = std::str::from_utf8(&bytes)
3230 && let Some(result) = check_auth_response(text)
3231 {
3232 result?;
3233 break;
3234 }
3235 }
3236 Some(Err(e)) => {
3237 return Err(HandshakeError::Transport(format!(
3238 "WS error during auth: {e}"
3239 )));
3240 }
3241 None => {
3242 return Err(HandshakeError::Transport(
3243 "WS closed before auth response".into(),
3244 ));
3245 }
3246 _ => {} }
3248 }
3249
3250 let sub = serde_json::json!({
3252 "action": "listen",
3253 "data": { "streams": ["trade_updates"] }
3254 })
3255 .to_string();
3256 ws.send(WsMessage::Text(sub.into()))
3257 .await
3258 .map_err(|e| HandshakeError::Transport(format!("WS subscribe send: {e}")))?;
3259
3260 loop {
3262 match ws.next().await {
3263 Some(Ok(WsMessage::Text(text))) => {
3264 if check_listen_ack(text.as_str()) {
3265 break;
3266 }
3267 if let Ok(msg) = serde_json::from_str::<AlpacaStreamMessage<'_>>(text.as_str()) {
3274 if msg.stream == "trade_updates" {
3275 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");
3276 } else {
3277 trace!(stream = %msg.stream, "WS message dropped during listen-ack handshake");
3278 }
3279 } else {
3280 trace!(
3281 bytes = text.len(),
3282 "WS non-stream message dropped during listen-ack handshake"
3283 );
3284 }
3285 }
3286 Some(Ok(WsMessage::Binary(bytes))) => {
3287 if let Ok(text) = std::str::from_utf8(&bytes)
3288 && check_listen_ack(text)
3289 {
3290 break;
3291 }
3292 trace!(
3293 bytes = bytes.len(),
3294 "WS binary message dropped during listen-ack handshake"
3295 );
3296 }
3297 Some(Err(e)) => {
3298 return Err(HandshakeError::Transport(format!(
3299 "WS error during subscribe: {e}"
3300 )));
3301 }
3302 None => {
3303 return Err(HandshakeError::Transport(
3304 "WS closed before subscribe ack".into(),
3305 ));
3306 }
3307 _ => {}
3308 }
3309 }
3310
3311 info!("Alpaca WS authenticated and subscribed to trade_updates");
3312 Ok(())
3313}
3314
3315fn check_auth_response(text: &str) -> Option<Result<(), HandshakeError>> {
3321 let msg = serde_json::from_str::<AlpacaStreamMessage<'_>>(text).ok()?;
3322 if msg.stream != "authorization" {
3323 return None;
3324 }
3325 #[derive(Deserialize)]
3326 struct AuthData<'a> {
3327 status: &'a str,
3328 }
3329 let data = serde_json::from_str::<AuthData<'_>>(msg.data.get()).ok()?;
3330 if data.status == "authorized" {
3331 Some(Ok(()))
3332 } else {
3333 Some(Err(HandshakeError::Auth(format!(
3334 "Alpaca WS auth failed: status={}",
3335 data.status
3336 ))))
3337 }
3338}
3339
3340fn check_listen_ack(text: &str) -> bool {
3344 let Ok(msg) = serde_json::from_str::<AlpacaStreamMessage<'_>>(text) else {
3345 return false;
3346 };
3347 if msg.stream != "listening" {
3348 return false;
3349 }
3350 #[derive(Deserialize)]
3351 struct ListenData<'a> {
3352 #[serde(borrow)]
3353 streams: Vec<&'a str>,
3354 }
3355 let Ok(data) = serde_json::from_str::<ListenData<'_>>(msg.data.get()) else {
3356 return false;
3357 };
3358 data.streams.contains(&"trade_updates")
3359}
3360
3361fn process_ws_text(
3371 text: &str,
3372 tx: &mpsc::UnboundedSender<UnindexedAccountEvent>,
3373 dedup: &SharedDedupCache,
3374 known: &SharedKnownLiveOrders,
3375) {
3376 let msg: AlpacaStreamMessage<'_> = match serde_json::from_str(text) {
3377 Ok(m) => m,
3378 Err(e) => {
3379 trace!(
3380 %e,
3381 raw = ?&text[..text.len().min(200)],
3382 "Alpaca WS: skipped non-JSON message"
3383 );
3384 return;
3385 }
3386 };
3387
3388 match msg.stream.as_str() {
3389 "trade_updates" => {
3390 let update: AlpacaTradeUpdate<'_> = match serde_json::from_str(msg.data.get()) {
3391 Ok(u) => u,
3392 Err(e) => {
3393 warn!(
3398 %e,
3399 raw = ?&msg.data.get()[..msg.data.get().len().min(200)],
3400 "Alpaca WS trade_updates: failed to deserialize event — event dropped"
3401 );
3402 return;
3403 }
3404 };
3405
3406 if is_fill_event(&update)
3410 && is_duplicate_fill(dedup, ws_execution_id(&update), early_dedup_key(&update))
3411 {
3412 trace!("Alpaca WS: skipping duplicate fill event (early check)");
3413 return;
3414 }
3415
3416 if is_trade_amendment(&update) {
3419 warn!(
3420 event = %update.event,
3421 raw = %msg.data.get(),
3422 "Alpaca WS: a fill reported earlier was amended — reporting it as TradeAmended"
3423 );
3424 }
3425
3426 for event in convert_trade_update(update).into_iter().flatten() {
3427 if let AccountEventKind::TradeAmended(amendment) = &event.kind
3431 && let TradeAmendmentKind::Corrected { replacement } = &amendment.kind
3432 {
3433 record_execution(dedup, &replacement.id.0);
3434 }
3435 let known = KnownLiveOrders::observes(&event.kind).then(|| {
3436 let mut known = known.lock();
3437 known.observe(&event);
3438 known
3439 });
3440 let _ = tx.send(event);
3443 drop(known);
3444 }
3445 }
3446 "authorization" | "listening" => {
3447 trace!(stream = %msg.stream, "Alpaca WS: auth/listen ack received during stream");
3450 }
3451 other => {
3452 trace!(%other, "Alpaca WS: ignoring unknown stream type");
3453 }
3454 }
3455}
3456
3457fn fill_dedup_key(order_id: &str, cumulative: Decimal) -> SmolStr {
3468 format_smolstr!("{}:{}", order_id, cumulative.normalize())
3469}
3470
3471fn is_trade_amendment(update: &AlpacaTradeUpdate<'_>) -> bool {
3473 matches!(update.event.as_str(), "trade_bust" | "trade_correct")
3474}
3475
3476#[inline]
3480fn is_fill_event(update: &AlpacaTradeUpdate<'_>) -> bool {
3481 matches!(update.event.as_str(), "fill" | "partial_fill")
3482}
3483
3484fn ws_execution_id<'a>(update: &AlpacaTradeUpdate<'a>) -> Option<&'a str> {
3486 update.execution_id.filter(|id| !id.is_empty())
3487}
3488
3489fn early_dedup_key(update: &AlpacaTradeUpdate<'_>) -> SmolStr {
3493 let filled_qty = update.order.filled_qty.unwrap_or("0");
3494 let qty = Decimal::from_str(filled_qty).unwrap_or(Decimal::ZERO);
3495 fill_dedup_key(&update.order.id, qty)
3496}
3497
3498fn activity_execution_id(activity_id: &str) -> &str {
3502 activity_execution_id_part(activity_id).unwrap_or(activity_id)
3503}
3504
3505fn activity_execution_id_part(activity_id: &str) -> Option<&str> {
3509 activity_id
3510 .split_once("::")
3511 .map(|(_, execution_id)| execution_id)
3512 .filter(|execution_id| !execution_id.is_empty())
3513}
3514
3515#[derive(Debug, PartialEq)]
3521struct UnreadFills {
3522 start: DateTime<Utc>,
3523 reason: FillRecoveryFailure,
3524}
3525
3526async fn recover_fills_or_report(
3530 http: &reqwest::Client,
3531 rate_limiter: &RateLimitTracker,
3532 instruments: &[InstrumentNameExchange],
3533 base: &str,
3534 disconnect: DateTime<Utc>,
3535 timeout: Duration,
3536 tx: &mpsc::UnboundedSender<UnindexedAccountEvent>,
3537 dedup: &SharedDedupCache,
3538 known: &SharedKnownLiveOrders,
3539) {
3540 let recovery_start = Utc::now();
3542 let outcome = match tokio::time::timeout(
3543 timeout,
3544 recover_fills(
3545 http,
3546 rate_limiter,
3547 instruments,
3548 base,
3549 disconnect,
3550 tx,
3551 dedup,
3552 known,
3553 ),
3554 )
3555 .await
3556 {
3557 Ok(outcome) => outcome,
3558 Err(_) => {
3562 warn!(
3563 timeout_secs = timeout.as_secs(),
3564 instruments = ?instruments
3565 .iter()
3566 .map(|instrument| instrument.name().as_str())
3567 .collect::<Vec<_>>(),
3568 "Alpaca fill recovery timed out — fills since the disconnect may be missing for \
3569 any of the instruments (every instrument when the list is empty)"
3570 );
3571 Err(UnreadFills {
3572 start: disconnect,
3573 reason: FillRecoveryFailure::TimedOut {
3574 timeout_secs: timeout.as_secs(),
3575 },
3576 })
3577 }
3578 };
3579 if let Err(unread) = outcome
3580 && tx
3581 .send(fill_recovery_gave_up(instruments, unread, recovery_start))
3582 .is_err()
3583 {
3584 debug!("Alpaca fill recovery: consumer dropped before the give-up was sent");
3585 }
3586}
3587
3588fn fill_recovery_gave_up(
3597 instruments: &[InstrumentNameExchange],
3598 unread: UnreadFills,
3599 end: DateTime<Utc>,
3600) -> UnindexedAccountEvent {
3601 let UnreadFills { start, reason } = unread;
3602 let scope = if instruments.is_empty() {
3604 FillRecoveryScope::AllInstruments
3605 } else {
3606 FillRecoveryScope::Instruments(instruments.to_vec())
3607 };
3608 UnindexedAccountEvent::new(
3609 ExchangeId::AlpacaBroker,
3610 AccountEventKind::FillRecoveryGaveUp(FillRecoveryGap::new(
3611 scope,
3612 start,
3613 end.max(start),
3614 1,
3615 reason,
3616 )),
3617 )
3618}
3619
3620async fn recover_fills(
3629 http: &reqwest::Client,
3630 rate_limiter: &RateLimitTracker,
3631 instruments: &[InstrumentNameExchange],
3632 base: &str,
3633 after: DateTime<Utc>,
3634 tx: &mpsc::UnboundedSender<UnindexedAccountEvent>,
3635 dedup: &SharedDedupCache,
3636 known: &SharedKnownLiveOrders,
3637) -> Result<(), UnreadFills> {
3638 info!(%after, instruments = instruments.len(), "Alpaca recovering fills after reconnect");
3641 let instrument_set: fnv::FnvHashSet<&str> = if instruments.is_empty() {
3642 fnv::FnvHashSet::default()
3643 } else {
3644 instruments.iter().map(|i| i.name().as_str()).collect()
3645 };
3646
3647 let page = match paginate_activities::<AlpacaActivity>(
3648 http,
3649 rate_limiter,
3650 base,
3651 FILL_ACTIVITIES,
3652 after,
3653 None,
3654 )
3655 .await
3656 {
3657 Ok(p) => p,
3658 Err(e) => {
3659 error!(%e, "Alpaca fill recovery: REST request failed");
3660 return Err(UnreadFills {
3661 start: after,
3662 reason: FillRecoveryFailure::Request(e.to_string()),
3663 });
3664 }
3665 };
3666
3667 let activities = page.activities;
3668
3669 let unread = page.resume.map(|start| {
3673 error!(
3674 max_pages = MAX_ACTIVITY_PAGES,
3675 fills_read = activities.len(),
3676 %start,
3677 "Alpaca fill recovery: max page limit reached, truncating — fills from this time on \
3678 are not delivered; read them with fetch_trades"
3679 );
3680 UnreadFills {
3681 start,
3682 reason: FillRecoveryFailure::Truncated {
3683 fills_read: activities.len(),
3684 },
3685 }
3686 });
3687
3688 let mut recovered = 0u32;
3689 let mut duplicates = 0u32;
3690
3691 let mut cumulative_qty: FnvHashMap<&str, Decimal> = FnvHashMap::default();
3708
3709 for activity in &activities {
3710 if !instrument_set.is_empty() && !instrument_set.contains(activity.symbol.as_str()) {
3711 continue;
3715 }
3716
3717 let exec_qty = Decimal::from_str(&activity.qty).unwrap_or(Decimal::ZERO);
3732 let cum = cumulative_qty
3733 .entry(activity.order_id.as_str())
3734 .or_default();
3735 *cum += exec_qty;
3736 let cumulative = *cum;
3737
3738 let trade = match convert_activity_to_trade(activity) {
3739 Some(t) => t,
3740 None => {
3741 warn!(id = %activity.id, symbol = %activity.symbol, "Alpaca: skipping activity with unparseable fields");
3742 continue; }
3744 };
3745
3746 let key = fill_dedup_key(
3756 &activity.order_id,
3757 trade.order_filled_quantity.unwrap_or(cumulative),
3758 );
3759 if is_duplicate_fill(dedup, activity_execution_id_part(&activity.id), key) {
3760 duplicates += 1;
3761 continue;
3762 }
3763
3764 let event =
3765 UnindexedAccountEvent::new(ExchangeId::AlpacaBroker, AccountEventKind::Trade(trade));
3766 let mut held = known.lock();
3767 held.observe(&event);
3768 let sent = tx.send(event);
3769 drop(held);
3770 if sent.is_err() {
3771 debug!("Alpaca fill recovery: consumer dropped during recovery");
3772 return Ok(());
3773 }
3774 recovered += 1;
3775 }
3776
3777 info!(recovered, duplicates, "Alpaca fill recovery complete");
3778 unread.map_or(Ok(()), Err)
3779}
3780
3781async fn fetch_order_lookup(
3790 http: reqwest::Client,
3791 rate_limiter: Arc<RateLimitTracker>,
3792 config: Arc<AlpacaConfig>,
3793 key: UnindexedOrderKey,
3794) -> Result<OrderLookup, UnindexedClientError> {
3795 let url = format!("{}/v2/orders:by_client_order_id", config.rest_base_url());
3796 let cid = key.cid.0.as_str();
3797 let found: Option<AlpacaOrderResponse> = rest_lookup_with_retry(&rate_limiter, || {
3798 http.get(&url).query(&[("client_order_id", cid)])
3799 })
3800 .await?;
3801 let Some(order) = found else {
3802 debug!(instrument = %key.instrument, cid = %key.cid, "Alpaca does not know this order");
3803 return Ok(OrderLookup::Unknown);
3804 };
3805 if !order
3806 .symbol
3807 .eq_ignore_ascii_case(key.instrument.name().as_str())
3808 {
3809 debug!(
3810 instrument = %key.instrument,
3811 cid = %key.cid,
3812 symbol = %order.symbol,
3813 "Alpaca knows this client order id on another symbol"
3814 );
3815 return Ok(OrderLookup::Unknown);
3816 }
3817 Ok(
3818 convert_ended_order(&order, &key).map_or(OrderLookup::NotEnded, |order| {
3819 OrderLookup::Ended(Box::new(order))
3820 }),
3821 )
3822}
3823
3824async fn listed_open_cids(
3827 http: reqwest::Client,
3828 rate_limiter: Arc<RateLimitTracker>,
3829 config: Arc<AlpacaConfig>,
3830 instruments: Vec<InstrumentNameExchange>,
3831) -> Result<FnvHashSet<ClientOrderId>, UnindexedClientError> {
3832 let orders =
3833 fetch_raw_open_orders(&http, &rate_limiter, config.rest_base_url(), &instruments).await?;
3834 Ok(orders
3835 .iter()
3836 .map(|order| alpaca_cid(order.client_order_id.as_deref(), &order.id))
3837 .collect())
3838}
3839
3840async fn recover_alpaca_ended_orders(
3844 http: &reqwest::Client,
3845 rate_limiter: &Arc<RateLimitTracker>,
3846 config: &Arc<AlpacaConfig>,
3847 known: &SharedKnownLiveOrders,
3848 unchecked: &mut UncheckedOrders,
3849 tx: &mpsc::UnboundedSender<UnindexedAccountEvent>,
3850) {
3851 recover_ended_orders(
3852 ExchangeId::AlpacaBroker,
3853 known,
3854 unchecked,
3855 &NoPendingFills,
3856 tx,
3857 OpenListing::Batched,
3858 |instruments| {
3859 listed_open_cids(
3860 http.clone(),
3861 rate_limiter.clone(),
3862 config.clone(),
3863 instruments,
3864 )
3865 },
3866 |key| fetch_order_lookup(http.clone(), rate_limiter.clone(), config.clone(), key),
3867 )
3868 .await;
3869}
3870
3871async fn run_order_checks(
3876 http: &reqwest::Client,
3877 rate_limiter: &Arc<RateLimitTracker>,
3878 config: &Arc<AlpacaConfig>,
3879 known: &SharedKnownLiveOrders,
3880 unchecked: &mut UncheckedOrders,
3881 tx: &mpsc::UnboundedSender<UnindexedAccountEvent>,
3882) {
3883 loop {
3884 if tx.is_closed() {
3886 return std::future::pending().await;
3887 }
3888 match unchecked.next_due(&NoPendingFills) {
3889 Some(due) => tokio::time::sleep_until(due).await,
3890 None => return std::future::pending().await,
3891 }
3892 recover_alpaca_ended_orders(http, rate_limiter, config, known, unchecked, tx).await;
3893 }
3894}
3895
3896fn convert_ended_order(
3904 o: &AlpacaOrderResponse,
3905 key: &UnindexedOrderKey,
3906) -> Option<UnindexedInactiveOrder> {
3907 let instrument = &key.instrument;
3908 let Some(status) = o.status.as_deref() else {
3909 warn!(%instrument, cid = %key.cid, order_id = %o.id, "Alpaca order missing status");
3910 return None;
3911 };
3912 if order_status_is_live(status) {
3913 return None;
3914 }
3915 let Some(order) = convert_representable_open_order(o) else {
3916 warn!(%instrument, cid = %key.cid, order_id = %o.id, status, "Alpaca order cannot be represented, treating it as not ended");
3917 return None;
3918 };
3919 let filled_qty = Decimal::from_str(&o.filled_qty).ok();
3922 let Some(state) = ended_order_state(
3923 status,
3924 OrderId(SmolStr::new(&o.id)),
3925 order.state.time_exchange,
3926 order.quantity,
3927 filled_qty,
3928 o.filled_avg_price.as_deref(),
3929 ) else {
3930 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");
3931 return None;
3932 };
3933 let mut order = order.map_state(|_| state);
3934 order.key = key.clone();
3935 Some(order)
3936}
3937
3938fn order_status_is_live(status: &str) -> bool {
3944 matches!(
3945 status,
3946 "new"
3947 | "partially_filled"
3948 | "done_for_day"
3949 | "pending_cancel"
3950 | "pending_replace"
3951 | "accepted"
3952 | "pending_new"
3953 | "accepted_for_bidding"
3954 | "stopped"
3955 | "suspended"
3956 | "calculated"
3957 | "held"
3958 )
3959}
3960
3961fn ended_order_state<AssetKey, InstrumentKey>(
3971 status: &str,
3972 order_id: OrderId,
3973 time_exchange: DateTime<Utc>,
3974 quantity: Decimal,
3975 filled_qty: Option<Decimal>,
3976 filled_avg_price: Option<&str>,
3977) -> Option<InactiveOrderState<AssetKey, InstrumentKey>> {
3978 Some(match status {
3979 "filled" => InactiveOrderState::FullyFilled(Filled::new(
3981 order_id,
3982 time_exchange,
3983 filled_qty.unwrap_or(quantity),
3984 alpaca_avg_price(filled_avg_price),
3985 )),
3986 "canceled" | "replaced" => {
3987 InactiveOrderState::Cancelled(Cancelled::new(order_id, time_exchange, filled_qty))
3988 }
3989 "expired" => InactiveOrderState::Expired(Expired::new(order_id, time_exchange, filled_qty)),
3990 "rejected" => {
3991 InactiveOrderState::OpenFailed(OrderError::Rejected(ApiError::OrderRejected(format!(
3992 "Alpaca rejected order {order_id} after accepting it"
3993 ))))
3994 }
3995 _ => return None,
3996 })
3997}
3998
3999fn placed_order_state(
4009 resp: &AlpacaOrderResponse,
4010 instrument: &InstrumentNameExchange,
4011 quantity: Decimal,
4012) -> UnindexedOrderState {
4013 let order_id = OrderId(SmolStr::new(&resp.id));
4014 let time_exchange = order_state_time(resp);
4015 let filled_qty = alpaca_filled_qty(&resp.id, Some(&resp.filled_qty));
4016 let filled_avg_price = resp.filled_avg_price.as_deref();
4017 match resp.status.as_deref() {
4018 Some(status) => {
4019 if let Some(ended) = ended_order_state(
4020 status,
4021 order_id.clone(),
4022 time_exchange,
4023 quantity,
4024 filled_qty,
4025 filled_avg_price,
4026 ) {
4027 return OrderState::Inactive(ended);
4028 }
4029 if !order_status_is_live(status) {
4030 warn!(%instrument, %order_id, status, "Alpaca placed an order with a status this version does not know, reading it from what filled");
4031 }
4032 }
4033 None => {
4034 warn!(%instrument, %order_id, "Alpaca placed an order without a status, reading it from what filled");
4035 }
4036 }
4037 match filled_qty {
4038 Some(filled_qty) if filled_qty >= quantity => OrderState::fully_filled(Filled::new(
4039 order_id,
4040 time_exchange,
4041 filled_qty,
4042 alpaca_avg_price(filled_avg_price),
4043 )),
4044 _ => OrderState::active(Open::new(
4047 VenueOrderId::Assigned(order_id),
4048 time_exchange,
4049 filled_qty.unwrap_or(Decimal::ZERO),
4050 )),
4051 }
4052}
4053
4054fn alpaca_avg_price(filled_avg_price: Option<&str>) -> Option<Decimal> {
4057 filled_avg_price.and_then(|price| Decimal::from_str(price).ok())
4058}
4059
4060fn is_usd(asset: &AssetNameExchange) -> bool {
4067 asset.name().as_str().eq_ignore_ascii_case(USD)
4068}
4069
4070fn convert_account_to_balances(
4088 account: &AlpacaAccount,
4089 assets: &[AssetNameExchange],
4090) -> Vec<AssetBalance<AssetNameExchange>> {
4091 if !assets.is_empty() && !assets.iter().any(is_usd) {
4092 return Vec::new();
4093 }
4094
4095 vec![AssetBalance::new(
4096 AssetNameExchange::new(USD),
4097 Balance::new(
4098 account.cash,
4099 account.cash.min(account.non_marginable_buying_power),
4100 ),
4101 Utc::now(),
4102 )]
4103}
4104
4105fn convert_positions_to_balances(
4117 positions: &[AlpacaPosition],
4118 assets: &[AssetNameExchange],
4119) -> Vec<AssetBalance<AssetNameExchange>> {
4120 let now = Utc::now();
4121 positions
4122 .iter()
4123 .filter(|p| is_crypto_position(p))
4124 .filter_map(|p| {
4125 let base = crypto_position_asset(&p.symbol);
4126
4127 if !assets.is_empty()
4129 && !assets
4130 .iter()
4131 .any(|a| a.name().as_str().eq_ignore_ascii_case(base))
4132 {
4133 return None;
4134 }
4135
4136 let asset_name = AssetNameExchange::new(base);
4139 Some(AssetBalance::new(
4140 asset_name,
4141 Balance::new(p.qty, p.qty_available),
4142 now,
4143 ))
4144 })
4145 .collect()
4146}
4147
4148fn crypto_position_asset(symbol: &str) -> &str {
4156 let base = match symbol.split_once('/') {
4157 Some((base, _)) => Some(base),
4158 None => {
4159 let base_len = symbol.len().saturating_sub(USD.len());
4160 symbol
4161 .get(base_len..)
4162 .filter(|suffix| suffix.eq_ignore_ascii_case(USD))
4163 .and_then(|_| symbol.get(..base_len))
4164 }
4165 };
4166 match base {
4167 Some(base) if !base.is_empty() => base,
4168 _ => {
4169 warn!(
4170 %symbol,
4171 "Alpaca crypto position symbol is neither ASSETUSD nor BASE/QUOTE; using it whole"
4172 );
4173 symbol
4174 }
4175 }
4176}
4177
4178fn is_crypto_position(position: &AlpacaPosition) -> bool {
4181 position.asset_class.eq_ignore_ascii_case("crypto")
4182}
4183
4184fn convert_positions(
4206 positions: &[AlpacaPosition],
4207 now: DateTime<Utc>,
4208) -> Result<Vec<(&str, Position)>, UnindexedClientError> {
4209 positions
4210 .iter()
4211 .filter(|p| !is_crypto_position(p) && !p.qty.is_zero())
4212 .map(|p| {
4213 let size = p.qty.abs();
4214 let quantity = match p.side {
4215 AlpacaPositionSide::Long => size,
4216 AlpacaPositionSide::Short => -size,
4217 AlpacaPositionSide::Unknown => {
4218 return Err(UnindexedClientError::Internal(format!(
4219 "Alpaca position {} has a missing or unrecognised side",
4220 p.symbol
4221 )));
4222 }
4223 };
4224 let position = Position::new(
4225 quantity,
4226 p.avg_entry_price,
4227 p.unrealized_pl,
4228 None,
4229 None,
4230 None,
4231 None,
4232 now,
4233 );
4234 Ok((p.symbol.as_str(), position))
4235 })
4236 .collect()
4237}
4238
4239fn build_instrument_snapshots(
4260 orders: Vec<AlpacaOrderResponse>,
4261 positions: Vec<(&str, Position)>,
4262 instruments: &[InstrumentNameExchange],
4263) -> Vec<InstrumentAccountSnapshot<ExchangeId, AssetNameExchange, InstrumentNameExchange>> {
4264 struct Listing {
4266 orders: Vec<UnindexedOrderSnapshot>,
4267 complete: bool,
4268 position: Option<Position>,
4269 }
4270
4271 impl Listing {
4272 fn empty() -> Self {
4273 Self {
4274 orders: Vec::new(),
4275 complete: true,
4276 position: None,
4277 }
4278 }
4279 }
4280
4281 let mut by_symbol: IndexMap<SmolStr, Listing> = IndexMap::new();
4283
4284 for order in orders {
4287 let listing = by_symbol
4288 .entry(order.symbol.to_ascii_uppercase().into())
4289 .or_insert_with(Listing::empty);
4290 match convert_open_order(&order) {
4291 Some(converted) => listing.orders.push(converted.into()),
4292 None => listing.complete = false,
4293 }
4294 }
4295
4296 for (symbol, position) in positions {
4297 by_symbol
4298 .entry(symbol.to_ascii_uppercase().into())
4299 .or_insert_with(Listing::empty)
4300 .position = Some(position);
4301 }
4302
4303 let snapshot = |instrument: InstrumentNameExchange, listing: Listing| {
4304 let position = if is_options_or_equity_symbol(instrument.name().as_str()) {
4307 listing
4308 .position
4309 .map_or(PositionReport::Flat, PositionReport::from_position)
4310 } else {
4311 PositionReport::Unreported
4312 };
4313 InstrumentAccountSnapshot::new(instrument, listing.orders, listing.complete, position, None)
4314 };
4315
4316 if instruments.is_empty() {
4318 by_symbol
4319 .into_iter()
4320 .map(|(sym, listing)| snapshot(InstrumentNameExchange::new(sym), listing))
4321 .collect()
4322 } else {
4323 let mut claimed = std::collections::HashSet::new();
4325 instruments
4326 .iter()
4327 .map(|inst| {
4328 let symbol = inst.name().as_str().to_ascii_uppercase();
4329 if !claimed.insert(symbol.clone()) {
4332 return InstrumentAccountSnapshot::new(
4333 inst.clone(),
4334 Vec::new(),
4335 false,
4336 PositionReport::Unreported,
4337 None,
4338 );
4339 }
4340 let listing = by_symbol
4343 .swap_remove(symbol.as_str())
4344 .unwrap_or_else(Listing::empty);
4345 snapshot(inst.clone(), listing)
4346 })
4347 .collect()
4348 }
4349}
4350
4351fn convert_open_order(
4361 o: &AlpacaOrderResponse,
4362) -> Option<Order<ExchangeId, InstrumentNameExchange, Open>> {
4363 let converted = convert_representable_open_order(o);
4364 if converted.is_none() {
4365 warn!(
4366 order_id = %o.id,
4367 symbol = %o.symbol,
4368 qty = ?o.qty,
4369 side = %o.side,
4370 order_type = %o.order_type,
4371 "Alpaca open order cannot be represented - leaving it out"
4372 );
4373 }
4374 converted
4375}
4376
4377fn alpaca_cid(client_order_id: Option<&str>, order_id: &str) -> ClientOrderId {
4383 ClientOrderId::new(client_order_id.unwrap_or(order_id))
4384}
4385
4386fn convert_representable_open_order(
4388 o: &AlpacaOrderResponse,
4389) -> Option<Order<ExchangeId, InstrumentNameExchange, Open>> {
4390 let order_id = OrderId(SmolStr::new(&o.id));
4391 let cid = alpaca_cid(o.client_order_id.as_deref(), &o.id);
4392
4393 let instrument = InstrumentNameExchange::new(&o.symbol);
4394 let side = parse_side(&o.side)?;
4395 let quantity = Decimal::from_str(o.qty.as_deref().unwrap_or("0")).ok()?;
4396 if quantity.is_zero() {
4400 return None;
4401 }
4402 let price = o
4403 .limit_price
4404 .as_deref()
4405 .and_then(|s| Decimal::from_str(s).ok());
4406 let filled_qty = alpaca_filled_qty(&o.id, Some(&o.filled_qty)).unwrap_or(Decimal::ZERO);
4407 let kind = parse_order_kind(
4408 &o.order_type,
4409 o.stop_price.as_deref(),
4410 o.trail_percent.as_deref(),
4411 o.trail_price.as_deref(),
4412 )?;
4413 let time_in_force = parse_time_in_force(&o.time_in_force);
4414 let time_exchange = order_state_time(o);
4415
4416 Some(Order {
4417 key: OrderKey::new(
4418 ExchangeId::AlpacaBroker,
4419 instrument,
4420 StrategyId::unknown(),
4422 cid,
4423 ),
4424 side,
4425 price,
4426 quantity,
4427 kind,
4428 time_in_force,
4429 state: Open::new(VenueOrderId::Assigned(order_id), time_exchange, filled_qty),
4430 })
4431}
4432
4433fn convert_activity_to_trade(
4435 a: &AlpacaActivity,
4436) -> Option<Trade<AssetNameExchange, InstrumentNameExchange>> {
4437 let trade_id = TradeId::new(activity_execution_id(&a.id));
4438 let order_id = OrderId(SmolStr::new(&a.order_id));
4439 let instrument = InstrumentNameExchange::new(&a.symbol);
4440 let side = parse_side(&a.side)?;
4441 let price = Decimal::from_str(&a.price).ok()?;
4442 let quantity = Decimal::from_str(&a.qty).ok()?;
4443 let order_filled_quantity = a.cum_qty.as_deref().and_then(|s| Decimal::from_str(s).ok());
4446 let time_exchange = parse_timestamp(&a.transaction_time).unwrap_or_else(|| {
4447 warn!(id = %a.id, "Alpaca activity: unparseable transaction_time, using now");
4448 Utc::now()
4449 });
4450
4451 Some(Trade::new(
4456 trade_id,
4457 order_id,
4458 instrument,
4459 StrategyId::unknown(),
4460 time_exchange,
4461 side,
4462 price,
4463 quantity,
4464 order_filled_quantity,
4465 AssetFees::new(
4466 AssetNameExchange::from(USD),
4467 Decimal::ZERO,
4468 Some(Decimal::ZERO),
4469 ),
4470 ))
4471}
4472
4473fn ws_order_snapshot(
4483 order: &AlpacaOrderWs<'_>,
4484 instrument: InstrumentNameExchange,
4485 cid: ClientOrderId,
4486 order_id: OrderId,
4487 time_exchange: DateTime<Utc>,
4488) -> Option<UnindexedAccountEvent> {
4489 let side = parse_side(&order.side)?;
4490 let quantity = Decimal::from_str(order.qty.unwrap_or("0")).unwrap_or(Decimal::ZERO);
4491 if quantity.is_zero() {
4492 trace!(order_id = %order.id, "Alpaca WS: skipping notional order snapshot (qty=None)");
4493 return None;
4494 }
4495 let price = order.limit_price.and_then(|s| Decimal::from_str(s).ok());
4496 let filled_qty = order
4499 .filled_qty
4500 .and_then(|_| alpaca_filled_qty(&order.id, order.filled_qty))
4501 .unwrap_or(Decimal::ZERO);
4502 let kind = parse_order_kind(
4503 &order.order_type,
4504 order.stop_price,
4505 order.trail_percent,
4506 order.trail_price,
4507 )?;
4508 let time_in_force = parse_time_in_force(&order.time_in_force);
4509
4510 let order_snapshot = crate::order::Order {
4511 key: OrderKey::new(
4512 ExchangeId::AlpacaBroker,
4513 instrument,
4514 StrategyId::unknown(),
4515 cid,
4516 ),
4517 side,
4518 price,
4519 quantity,
4520 kind,
4521 time_in_force,
4522 state: OrderState::active(Open::new(
4523 VenueOrderId::Assigned(order_id),
4524 time_exchange,
4525 filled_qty,
4526 )),
4527 };
4528 Some(UnindexedAccountEvent::new(
4529 ExchangeId::AlpacaBroker,
4530 AccountEventKind::OrderSnapshot(rustrade_integration::collection::snapshot::Snapshot(
4531 order_snapshot,
4532 )),
4533 ))
4534}
4535
4536fn ws_fill_order_is_live(status: &str) -> bool {
4546 matches!(status, "partially_filled" | "filled")
4547}
4548
4549fn trade_amendment_event(
4561 update: &AlpacaTradeUpdate<'_>,
4562 instrument: InstrumentNameExchange,
4563 order_id: OrderId,
4564) -> UnindexedAccountEvent {
4565 let order = &update.order;
4566 let time_exchange = update
4567 .timestamp
4568 .and_then(parse_timestamp)
4569 .unwrap_or_else(Utc::now);
4570 let original = update
4571 .previous_execution_id
4572 .filter(|id| !id.is_empty())
4573 .map(TradeId::new);
4574 let quantity = update.qty.and_then(|s| Decimal::from_str(s).ok());
4575
4576 let kind = if update.event == "trade_bust" {
4577 TradeAmendmentKind::Busted {
4578 quantity: quantity.map(|quantity| quantity.abs()),
4579 }
4580 } else {
4581 let id = ws_execution_id(update).map(TradeId::new);
4582 let price = update.price.and_then(|s| Decimal::from_str(s).ok());
4583 match (id, price, quantity, parse_side(&order.side)) {
4584 (Some(id), Some(price), Some(quantity), Some(side)) if quantity > Decimal::ZERO => {
4585 TradeAmendmentKind::Corrected {
4586 replacement: with_reported_cid(
4587 Trade::new(
4588 id,
4589 order_id.clone(),
4590 instrument.clone(),
4591 StrategyId::unknown(),
4592 time_exchange,
4593 side,
4594 price,
4595 quantity,
4596 None,
4599 AssetFees::new(
4601 AssetNameExchange::from(USD),
4602 Decimal::ZERO,
4603 Some(Decimal::ZERO),
4604 ),
4605 ),
4606 order.client_order_id.as_ref(),
4607 ),
4608 }
4609 }
4610 (id, price, quantity, _) => TradeAmendmentKind::CorrectedUnresolved {
4611 id,
4612 price,
4613 quantity,
4614 },
4615 }
4616 };
4617
4618 UnindexedAccountEvent::new(
4619 ExchangeId::AlpacaBroker,
4620 AccountEventKind::TradeAmended(crate::trade::TradeAmendment::new(
4621 instrument,
4622 order_id,
4623 time_exchange,
4624 original,
4625 kind,
4626 )),
4627 )
4628}
4629
4630fn convert_trade_update(update: AlpacaTradeUpdate<'_>) -> [Option<UnindexedAccountEvent>; 2] {
4640 let event_str = update.event.as_str();
4643 if !matches!(
4644 event_str,
4645 "fill"
4646 | "partial_fill"
4647 | "new"
4648 | "accepted"
4649 | "pending_new"
4650 | "canceled"
4651 | "expired"
4652 | "replaced"
4653 | "done_for_day"
4654 | "rejected"
4655 | "trade_bust"
4656 | "trade_correct"
4657 ) {
4658 trace!(event = %event_str, "Alpaca WS: ignoring trade_updates event type");
4659 return [None, None];
4660 }
4661
4662 let order = &update.order;
4663 let instrument = InstrumentNameExchange::new(&*order.symbol);
4664 let order_id = OrderId(order.id.clone());
4665 let cid = alpaca_cid(order.client_order_id.as_deref(), &order.id);
4666
4667 match event_str {
4668 "fill" | "partial_fill" => {
4669 let (Some(price), Some(quantity), Some(side)) = (
4671 update.price.and_then(|s| Decimal::from_str(s).ok()),
4672 update.qty.and_then(|s| Decimal::from_str(s).ok()),
4673 parse_side(&order.side),
4674 ) else {
4675 return [None, None];
4676 };
4677 let time_exchange = update
4678 .timestamp
4679 .and_then(parse_timestamp)
4680 .unwrap_or_else(Utc::now);
4681
4682 let cum_qty = Decimal::from_str(order.filled_qty.unwrap_or("0"))
4687 .inspect_err(|e| {
4688 warn!(
4689 order_id = %order.id,
4690 filled_qty = ?order.filled_qty,
4691 %e,
4692 "Alpaca WS: failed to parse filled_qty — the fill reports a cumulative \
4693 of 0, and without an execution id a second such fill on the same order \
4694 would be deduplicated away"
4695 );
4696 })
4697 .unwrap_or(Decimal::ZERO);
4698 let trade_id = match ws_execution_id(&update) {
4702 Some(execution_id) => TradeId::new(execution_id),
4703 None => {
4704 warn!(
4705 order_id = %order.id,
4706 "Alpaca WS: fill without a usable execution_id — its TradeId is \
4707 \"{{order_id}}:{{filled_qty}}\", which fetch_trades will not match"
4708 );
4709 TradeId(fill_dedup_key(&order.id, cum_qty))
4710 }
4711 };
4712
4713 let trade = Trade::new(
4717 trade_id,
4718 order_id.clone(),
4719 instrument.clone(),
4720 StrategyId::unknown(),
4721 time_exchange,
4722 side,
4723 price,
4724 quantity,
4725 Some(cum_qty),
4727 AssetFees::new(
4728 AssetNameExchange::from(USD),
4729 Decimal::ZERO,
4730 Some(Decimal::ZERO),
4731 ),
4732 );
4733 let trade = with_reported_cid(trade, order.client_order_id.as_ref());
4734 let trade_event = UnindexedAccountEvent::new(
4735 ExchangeId::AlpacaBroker,
4736 AccountEventKind::Trade(trade),
4737 );
4738
4739 let snapshot_event = if ws_fill_order_is_live(&order.status) {
4743 ws_order_snapshot(order, instrument, cid, order_id, time_exchange)
4744 } else {
4745 trace!(
4746 order_id = %order.id,
4747 status = %order.status,
4748 "Alpaca WS: fill for an order the exchange no longer reports as working — \
4749 emitting the execution without an order snapshot"
4750 );
4751 None
4752 };
4753
4754 [Some(trade_event), snapshot_event]
4755 }
4756
4757 "new" | "accepted" | "pending_new" | "done_for_day" => {
4758 let time_exchange = update
4762 .timestamp
4763 .and_then(parse_timestamp)
4764 .unwrap_or_else(Utc::now);
4765
4766 [
4767 ws_order_snapshot(order, instrument, cid, order_id, time_exchange),
4768 None,
4769 ]
4770 }
4771
4772 "canceled" | "expired" | "replaced" => {
4773 let time_exchange = update
4781 .timestamp
4782 .and_then(parse_timestamp)
4783 .unwrap_or_else(Utc::now);
4784 let filled_qty = alpaca_filled_qty(&order.id, order.filled_qty);
4785 let cancelled = Cancelled::new(order_id, time_exchange, filled_qty);
4786 let response = crate::order::request::OrderResponseCancel {
4787 key: OrderKey::new(
4788 ExchangeId::AlpacaBroker,
4789 instrument,
4790 StrategyId::unknown(),
4791 cid,
4792 ),
4793 state: Ok(cancelled),
4794 };
4795 [
4796 Some(UnindexedAccountEvent::new(
4797 ExchangeId::AlpacaBroker,
4798 AccountEventKind::OrderCancelled(response),
4799 )),
4800 None,
4801 ]
4802 }
4803
4804 "trade_bust" | "trade_correct" => [
4805 Some(trade_amendment_event(&update, instrument, order_id)),
4806 None,
4807 ],
4808
4809 "rejected" => {
4810 let response = crate::order::request::OrderResponseCancel {
4811 key: OrderKey::new(
4812 ExchangeId::AlpacaBroker,
4813 instrument,
4814 StrategyId::unknown(),
4815 cid,
4816 ),
4817 state: Err(UnindexedOrderError::Rejected(ApiError::OrderRejected(
4818 format!("order rejected: status={}", order.status),
4819 ))),
4820 };
4821 [
4822 Some(UnindexedAccountEvent::new(
4823 ExchangeId::AlpacaBroker,
4824 AccountEventKind::OrderCancelled(response),
4825 )),
4826 None,
4827 ]
4828 }
4829
4830 _ => unreachable!("convert_trade_update: unrecognised event passed early-return guard"),
4833 }
4834}
4835
4836fn parse_side(s: &str) -> Option<Side> {
4841 match s {
4842 "buy" | "Buy" | "BUY" => Some(Side::Buy),
4843 "sell" | "Sell" | "SELL" => Some(Side::Sell),
4844 other => {
4845 trace!(%other, "Alpaca: unknown order side");
4846 None
4847 }
4848 }
4849}
4850
4851fn parse_order_kind(
4852 order_type: &str,
4853 stop_price: Option<&str>,
4854 trail_percent: Option<&str>,
4855 trail_price: Option<&str>,
4856) -> Option<OrderKind> {
4857 match order_type {
4858 "market" | "Market" => Some(OrderKind::Market),
4859 "limit" | "Limit" => Some(OrderKind::Limit),
4860 "stop" | "Stop" => {
4861 let trigger_price = stop_price.and_then(|s| Decimal::from_str(s).ok())?;
4862 Some(OrderKind::Stop { trigger_price })
4863 }
4864 "stop_limit" | "Stop_limit" => {
4865 let trigger_price = stop_price.and_then(|s| Decimal::from_str(s).ok())?;
4866 Some(OrderKind::StopLimit { trigger_price })
4867 }
4868 "trailing_stop" | "Trailing_stop" => {
4869 if let Some(pct) = trail_percent.and_then(|s| Decimal::from_str(s).ok()) {
4871 Some(OrderKind::TrailingStop {
4872 offset: pct,
4873 offset_type: TrailingOffsetType::Percentage,
4874 activation_price: None,
4875 })
4876 } else if let Some(price) = trail_price.and_then(|s| Decimal::from_str(s).ok()) {
4877 Some(OrderKind::TrailingStop {
4878 offset: price,
4879 offset_type: TrailingOffsetType::Absolute,
4880 activation_price: None,
4881 })
4882 } else {
4883 trace!("Alpaca: trailing_stop missing trail_percent and trail_price");
4884 None
4885 }
4886 }
4887 other => {
4888 trace!(%other, "Alpaca: unsupported order type, skipping");
4889 None
4890 }
4891 }
4892}
4893
4894fn parse_time_in_force(s: &str) -> TimeInForce {
4895 match s {
4896 "gtc" | "GTC" => TimeInForce::GoodUntilCancelled { post_only: false },
4897 "day" | "DAY" => TimeInForce::GoodUntilEndOfDay,
4898 "fok" | "FOK" => TimeInForce::FillOrKill,
4899 "ioc" | "IOC" => TimeInForce::ImmediateOrCancel,
4900 other => {
4901 warn!(%other, "Alpaca: unknown time_in_force, defaulting to GoodUntilEndOfDay");
4902 TimeInForce::GoodUntilEndOfDay
4903 }
4904 }
4905}
4906
4907fn order_state_time(order: &AlpacaOrderResponse) -> DateTime<Utc> {
4917 order
4918 .updated_at
4919 .as_deref()
4920 .and_then(parse_timestamp)
4921 .or_else(|| parse_timestamp(&order.created_at))
4922 .unwrap_or_else(Utc::now)
4923}
4924
4925fn alpaca_filled_qty(order_id: &str, filled_qty: Option<&str>) -> Option<Decimal> {
4928 let Some(raw) = filled_qty else {
4929 warn!(%order_id, "Alpaca did not report how much the order filled");
4930 return None;
4931 };
4932 match Decimal::from_str(raw) {
4933 Ok(filled_qty) => Some(filled_qty),
4934 Err(_) => {
4935 warn!(%order_id, filled_qty = raw, "Alpaca reported an unparseable filled_qty, treating it as unknown");
4936 None
4937 }
4938 }
4939}
4940
4941fn convert_cash_flow(
4944 activity: &AlpacaNonTradeActivity,
4945) -> Option<CashFlow<AssetNameExchange, InstrumentNameExchange>> {
4946 let id = activity.id.as_str();
4947 if activity.status.as_deref() == Some("canceled") {
4948 info!(id, activity_type = %activity.activity_type, "Alpaca activity canceled — left out");
4949 return None;
4950 }
4951 let Some(amount) = activity
4952 .net_amount
4953 .as_deref()
4954 .and_then(|amount| Decimal::from_str(amount).ok())
4955 else {
4956 warn!(id, net_amount = ?activity.net_amount, "Alpaca activity missing/unparseable net_amount — left out");
4957 return None;
4958 };
4959 let Some(time_exchange) = activity.time() else {
4960 warn!(
4961 id,
4962 created_at = ?activity.created_at,
4963 date = ?activity.date,
4964 "Alpaca activity missing/unparseable time — left out"
4965 );
4966 return None;
4967 };
4968 let sub_type = activity
4969 .activity_sub_type
4970 .as_deref()
4971 .filter(|sub_type| !sub_type.is_empty());
4972 let kind = match (activity.activity_type.as_str(), sub_type) {
4973 ("INT", Some("MGN")) => CashFlowKind::MarginInterest {
4974 rate: None,
4975 principal: None,
4976 timing: None,
4977 },
4978 ("INT", None) if amount.is_sign_negative() => CashFlowKind::MarginInterest {
4980 rate: None,
4981 principal: None,
4982 timing: None,
4983 },
4984 ("PTR", _) => CashFlowKind::Rebate,
4985 (activity_type, Some(sub_type)) => {
4986 CashFlowKind::Other(format!("{activity_type}.{sub_type}"))
4987 }
4988 (activity_type, None) => CashFlowKind::Other(activity_type.to_owned()),
4989 };
4990 Some(CashFlow::new(
4991 kind,
4992 AssetNameExchange::new(
4993 activity
4994 .currency
4995 .as_deref()
4996 .filter(|currency| !currency.is_empty())
4997 .unwrap_or("USD"),
4998 ),
4999 amount,
5000 activity
5001 .symbol
5002 .as_deref()
5003 .filter(|symbol| !symbol.is_empty())
5004 .map(InstrumentNameExchange::new),
5005 time_exchange,
5006 Some(CashFlowId::new(id)),
5007 ))
5008}
5009
5010struct OptionLeg<'a> {
5012 id: &'a str,
5013 symbol: &'a str,
5014 quantity: Decimal,
5016 price: Option<Decimal>,
5018 date: Option<NaiveDate>,
5019 time: DateTime<Utc>,
5020 created: bool,
5022 origin: Option<TradeOrigin>,
5024 contract: Option<(&'a str, bool, Decimal)>,
5026}
5027
5028impl OptionLeg<'_> {
5029 fn is_within(&self, start: DateTime<Utc>, end: DateTime<Utc>) -> bool {
5032 if self.created {
5033 (start..=end).contains(&self.time)
5034 } else {
5035 (start.date_naive()..=end.date_naive()).contains(&self.time.date_naive())
5036 }
5037 }
5038}
5039
5040fn option_leg(activity: &AlpacaNonTradeActivity) -> Option<OptionLeg<'_>> {
5043 let id = activity.id.as_str();
5044 let activity_type = activity.activity_type.as_str();
5045 if activity.status.as_deref() == Some("canceled") {
5046 info!(
5047 id,
5048 activity_type, "Alpaca option activity canceled — left out"
5049 );
5050 return None;
5051 }
5052 let origin = match activity_type {
5053 "OPEXP" => Some(TradeOrigin::Expiry),
5054 "OPEXC" => Some(TradeOrigin::Exercise),
5055 "OPASN" => Some(TradeOrigin::Assignment),
5056 "OPTRD" => None,
5057 _ => {
5058 warn!(
5059 id,
5060 activity_type, "Alpaca activity is not an option event — left out"
5061 );
5062 return None;
5063 }
5064 };
5065 let date = activity
5066 .date
5067 .as_deref()
5068 .and_then(|date| NaiveDate::parse_from_str(date, "%Y-%m-%d").ok());
5069 let created_at = activity.created_at.as_deref().and_then(parse_timestamp);
5070 let time = created_at.or_else(|| date.map(|date| date.and_time(NaiveTime::MIN).and_utc()));
5071 let symbol = activity
5072 .symbol
5073 .as_deref()
5074 .filter(|symbol| !symbol.is_empty());
5075 let quantity = activity
5076 .qty
5077 .as_deref()
5078 .and_then(|qty| Decimal::from_str(qty).ok())
5079 .filter(|qty| !qty.is_zero());
5080 let price = activity
5081 .price
5082 .as_deref()
5083 .and_then(|price| Decimal::from_str(price).ok());
5084 let (Some(symbol), Some(quantity), Some(time)) = (symbol, quantity, time) else {
5085 warn!(
5086 id,
5087 activity_type,
5088 symbol = ?activity.symbol,
5089 qty = ?activity.qty,
5090 created_at = ?activity.created_at,
5091 date = ?activity.date,
5092 "Alpaca option activity missing/unparseable symbol, quantity or time — left out"
5093 );
5094 return None;
5095 };
5096 if origin.is_none() && price.is_none() {
5097 warn!(id, price = ?activity.price, "Alpaca OPTRD missing/unparseable price — left out");
5098 return None;
5099 }
5100 let contract = matches!(
5101 origin,
5102 Some(TradeOrigin::Exercise | TradeOrigin::Assignment)
5103 )
5104 .then(|| occ_contract(symbol))
5105 .flatten();
5106 Some(OptionLeg {
5107 id,
5108 symbol,
5109 quantity,
5110 price,
5111 date,
5112 time,
5113 created: created_at.is_some(),
5114 origin,
5115 contract,
5116 })
5117}
5118
5119fn occ_contract(symbol: &str) -> Option<(&str, bool, Decimal)> {
5122 let split = symbol.len().checked_sub(15)?;
5123 let (root, tail) = (
5124 symbol.get(..split)?.trim_end(),
5125 symbol.get(split..)?.as_bytes(),
5126 );
5127 let is_call = match tail[6] {
5128 b'C' => true,
5129 b'P' => false,
5130 _ => return None,
5131 };
5132 if root.is_empty() || !tail[..6].iter().chain(&tail[7..]).all(u8::is_ascii_digit) {
5133 return None;
5134 }
5135 let strike = std::str::from_utf8(&tail[7..]).ok()?.parse::<i64>().ok()?;
5136 Some((root, is_call, Decimal::new(strike, 3)))
5137}
5138
5139fn is_share_leg_of(shares: &OptionLeg<'_>, contract: &OptionLeg<'_>) -> bool {
5142 let Some((root, is_call, strike)) = contract.contract else {
5143 return false;
5144 };
5145 let buys = match contract.origin {
5147 Some(TradeOrigin::Exercise) => is_call,
5148 Some(TradeOrigin::Assignment) => !is_call,
5149 _ => return false,
5150 };
5151 shares.date.is_some()
5152 && shares.date == contract.date
5153 && shares.symbol == root
5154 && shares.price == Some(strike)
5155 && shares.quantity.is_sign_positive() == buys
5156}
5157
5158fn convert_option_events(
5162 activities: &[AlpacaNonTradeActivity],
5163 start: DateTime<Utc>,
5164 end: DateTime<Utc>,
5165) -> Vec<Trade<AssetNameExchange, InstrumentNameExchange>> {
5166 let legs: Vec<_> = activities.iter().filter_map(option_leg).collect();
5167 let mut paired = vec![false; legs.len()];
5168 legs.iter()
5169 .filter_map(|leg| {
5170 let origin = leg.origin.unwrap_or_else(|| {
5173 let pairs = |index: &usize| !paired[*index] && is_share_leg_of(leg, &legs[*index]);
5174 let delivers_its_shares = |index: &usize| {
5178 legs[*index].quantity.abs() * Decimal::ONE_HUNDRED == leg.quantity.abs()
5179 };
5180 let partner = (0..legs.len())
5181 .filter(pairs)
5182 .find(delivers_its_shares)
5183 .or_else(|| (0..legs.len()).find(pairs));
5184 match partner {
5185 Some(index) => {
5186 paired[index] = true;
5187 legs[index].origin.unwrap_or(TradeOrigin::External)
5188 }
5189 None => {
5190 warn!(
5191 id = leg.id,
5192 symbol = leg.symbol,
5193 date = ?leg.date,
5194 "Alpaca OPTRD pairs with no exercise or assignment read — reported as \
5195 External"
5196 );
5197 TradeOrigin::External
5198 }
5199 }
5200 });
5201 if !leg.is_within(start, end) {
5202 return None;
5203 }
5204 let side = if leg.quantity.is_sign_negative() {
5205 Side::Sell
5206 } else {
5207 Side::Buy
5208 };
5209 let trade = Trade::new(
5210 TradeId::new(leg.id),
5211 OrderId::new(leg.id),
5212 InstrumentNameExchange::new(leg.symbol),
5213 StrategyId::unknown(),
5214 leg.time,
5215 side,
5216 leg.price.unwrap_or(Decimal::ZERO),
5217 leg.quantity.abs(),
5218 None,
5219 AssetFees::new(
5220 AssetNameExchange::from(USD),
5221 Decimal::ZERO,
5222 Some(Decimal::ZERO),
5223 ),
5224 )
5225 .with_origin(origin);
5226 Some(trade)
5227 })
5228 .collect()
5229}
5230
5231fn with_reported_cid<AssetKey, InstrumentKey>(
5233 trade: Trade<AssetKey, InstrumentKey>,
5234 client_order_id: Option<&SmolStr>,
5235) -> Trade<AssetKey, InstrumentKey> {
5236 match client_order_id {
5237 Some(cid) if !cid.is_empty() => trade.with_client_order_id(ClientOrderId(cid.clone())),
5239 _ => trade,
5240 }
5241}
5242
5243fn parse_timestamp(s: &str) -> Option<DateTime<Utc>> {
5244 DateTime::parse_from_rfc3339(s)
5245 .ok()
5246 .map(|dt| dt.with_timezone(&Utc))
5247}
5248
5249fn map_side(side: Side) -> &'static str {
5250 match side {
5251 Side::Buy => "buy",
5252 Side::Sell => "sell",
5253 }
5254}
5255
5256fn map_order_kind(kind: OrderKind) -> Option<&'static str> {
5257 match kind {
5258 OrderKind::Market => Some("market"),
5259 OrderKind::Limit => Some("limit"),
5260 OrderKind::Stop { .. } => Some("stop"),
5261 OrderKind::StopLimit { .. } => Some("stop_limit"),
5262 OrderKind::TrailingStop {
5263 activation_price: None,
5264 ..
5265 } => Some("trailing_stop"),
5266 OrderKind::TakeProfit { .. }
5269 | OrderKind::TakeProfitLimit { .. }
5270 | OrderKind::TrailingStop {
5271 activation_price: Some(_),
5272 ..
5273 }
5274 | OrderKind::TrailingStopLimit { .. }
5275 | OrderKind::TrailingStopFixedLimit { .. } => None,
5276 }
5277}
5278
5279fn map_time_in_force(tif: TimeInForce) -> Result<&'static str, &'static str> {
5288 match tif {
5289 TimeInForce::GoodUntilCancelled { post_only } => {
5290 if post_only {
5291 return Err("Alpaca does not support post_only orders");
5292 }
5293 Ok("gtc")
5294 }
5295 TimeInForce::GoodUntilEndOfDay => Ok("day"),
5296 TimeInForce::FillOrKill => Ok("fok"),
5297 TimeInForce::ImmediateOrCancel => Ok("ioc"),
5298 TimeInForce::AtOpen => Ok("opg"),
5299 TimeInForce::AtClose => Ok("cls"),
5300 TimeInForce::GoodTillDate { .. } => {
5304 Err("Alpaca GoodTillDate is not yet wired through this client")
5305 }
5306 }
5307}
5308
5309fn is_options_or_equity_symbol(symbol: &str) -> bool {
5320 !symbol.contains('/')
5321}
5322
5323fn map_position_intent(side: Side, reduce_only: bool) -> AlpacaPositionIntent {
5332 match (reduce_only, side) {
5333 (false, Side::Buy) => AlpacaPositionIntent::BuyToOpen,
5334 (false, Side::Sell) => AlpacaPositionIntent::SellToOpen,
5335 (true, Side::Buy) => AlpacaPositionIntent::BuyToClose,
5336 (true, Side::Sell) => AlpacaPositionIntent::SellToClose,
5337 }
5338}
5339
5340fn parse_api_error(
5351 status: reqwest::StatusCode,
5352 code: Option<i64>,
5353 message: &str,
5354) -> crate::error::UnindexedApiError {
5355 if status == reqwest::StatusCode::TOO_MANY_REQUESTS {
5358 return ApiError::rate_limited(None);
5359 }
5360
5361 let lower = message.to_ascii_lowercase();
5363 match status.as_u16() {
5364 422 if lower.contains("already") => ApiError::OrderAlreadyCancelled,
5368 422 => parse_business_refusal(code, message, &lower),
5369 401 => ApiError::Unauthenticated(format!("unauthorized: {message}")),
5370 403 => match code {
5377 None => ApiError::Unauthenticated(format!("forbidden: {message}")),
5378 Some(_) => parse_business_refusal(code, message, &lower),
5379 },
5380 404 => ApiError::OrderRejected(format!("order not found: {message}")),
5381 _ => ApiError::OrderRejected(with_alpaca_code(code, message)),
5382 }
5383}
5384
5385fn parse_business_refusal(code: Option<i64>, message: &str, lower: &str) -> UnindexedApiError {
5399 let borrow = if lower.contains("not allowed to short") {
5400 Some(BorrowRejectReason::BorrowDisabled)
5401 } else if lower.contains("cannot be sold short") && !lower.contains("fractional") {
5402 Some(BorrowRejectReason::NotShortable)
5403 } else {
5404 None
5405 };
5406 if let Some(reason) = borrow {
5407 ApiError::BorrowRejected(BorrowReject::new(
5408 reason,
5409 code.map(|code| code.to_string()),
5410 message.to_owned(),
5411 ))
5412 } else if lower.contains("insufficient") {
5413 ApiError::BalanceInsufficient(None, with_alpaca_code(code, message))
5414 } else {
5415 ApiError::OrderRejected(with_alpaca_code(code, message))
5416 }
5417}
5418
5419fn with_alpaca_code(code: Option<i64>, message: &str) -> String {
5421 match code {
5422 Some(code) => format!("{code} {message}"),
5423 None => message.to_owned(),
5424 }
5425}
5426
5427fn parse_order_error(
5429 status: reqwest::StatusCode,
5430 code: Option<i64>,
5431 message: &str,
5432) -> UnindexedOrderError {
5433 UnindexedOrderError::Rejected(parse_api_error(status, code, message))
5434}
5435
5436fn connectivity_err(msg: impl Into<String>) -> UnindexedClientError {
5437 UnindexedClientError::Connectivity(ConnectivityError::Socket(msg.into()))
5438}
5439
5440fn order_post_error(error: UnindexedClientError) -> UnindexedOrderError {
5447 match error {
5448 UnindexedClientError::Connectivity(ce) => OrderError::Connectivity(ce),
5449 UnindexedClientError::Api(ae) => OrderError::Rejected(ae),
5450 UnindexedClientError::Internal(msg) => OrderError::Connectivity(ConnectivityError::Socket(
5451 format!("Alpaca order status unknown, its 2xx response could not be read: {msg}"),
5452 )),
5453 other @ (UnindexedClientError::TaskFailed(_)
5457 | UnindexedClientError::Truncated { .. }
5458 | UnindexedClientError::TruncatedSnapshot { .. }) => OrderError::Connectivity(
5459 ConnectivityError::Socket(format!("Alpaca order status unknown: {other}")),
5460 ),
5461 }
5462}
5463
5464impl OrderStatusClient for AlpacaClient {
5469 async fn fetch_ended_orders(
5477 &self,
5478 orders: &[UnindexedOrderKey],
5479 ) -> Result<Vec<UnindexedInactiveOrder>, UnindexedClientError> {
5480 fetch_ended_by_key(orders, |key| {
5481 fetch_order_lookup(
5482 self.http.clone(),
5483 self.rate_limiter.clone(),
5484 self.config.clone(),
5485 key,
5486 )
5487 })
5488 .await
5489 }
5490}
5491
5492impl ShortabilityClient for AlpacaClient {
5497 async fn fetch_shortability(
5516 &self,
5517 instrument: &InstrumentNameExchange,
5518 ) -> Result<Shortability, UnindexedClientError> {
5519 let url = asset_url(self.base_url(), instrument)?;
5520 let found: Option<AlpacaAsset> =
5521 rest_lookup_with_retry(&self.rate_limiter, || self.http.get(url.clone())).await?;
5522 let Some(asset) = found else {
5523 return Err(instrument_invalid(
5524 instrument,
5525 "Alpaca knows no asset under this symbol",
5526 ));
5527 };
5528 shortability_from_asset(instrument, &asset)
5529 }
5530}
5531
5532fn asset_url(
5535 base_url: &str,
5536 instrument: &InstrumentNameExchange,
5537) -> Result<reqwest::Url, UnindexedClientError> {
5538 let mut url = reqwest::Url::parse(base_url).map_err(|error| {
5539 UnindexedClientError::Internal(format!("Alpaca base URL {base_url:?}: {error}"))
5540 })?;
5541 url.path_segments_mut()
5542 .map_err(|()| {
5543 UnindexedClientError::Internal(format!("Alpaca base URL {base_url:?} has no path"))
5544 })?
5545 .pop_if_empty()
5546 .extend(["v2", "assets", instrument.name().as_str()]);
5547 Ok(url)
5548}
5549
5550fn instrument_invalid(instrument: &InstrumentNameExchange, reason: &str) -> UnindexedClientError {
5551 UnindexedClientError::Api(ApiError::InstrumentInvalid(
5552 instrument.clone(),
5553 reason.to_owned(),
5554 ))
5555}
5556
5557fn shortability_from_asset(
5559 instrument: &InstrumentNameExchange,
5560 asset: &AlpacaAsset,
5561) -> Result<Shortability, UnindexedClientError> {
5562 if asset.class == AlpacaAssetClass::UsOption {
5563 return Err(instrument_invalid(
5564 instrument,
5565 "shortability does not apply to an option: writing one borrows nothing",
5566 ));
5567 }
5568 let easy_to_borrow = match asset.borrow_status {
5569 Some(AlpacaBorrowStatus::EasyToBorrow) => Some(true),
5570 Some(AlpacaBorrowStatus::HardToBorrow) => Some(false),
5571 Some(AlpacaBorrowStatus::Other) | None => asset.easy_to_borrow,
5572 };
5573 let mut shortability = Shortability::new();
5574 if let Some(shortable) = asset.shortable {
5575 shortability = shortability.with_shortable(shortable);
5576 }
5577 if let Some(easy_to_borrow) = easy_to_borrow {
5578 shortability = shortability.with_easy_to_borrow(easy_to_borrow);
5579 if easy_to_borrow {
5580 shortability = shortability.with_fee_rate(Decimal::ZERO);
5581 }
5582 }
5583 Ok(shortability)
5584}
5585
5586#[derive(Debug, Deserialize)]
5589struct AlpacaAsset {
5590 #[serde(default)]
5591 class: AlpacaAssetClass,
5592 #[serde(default)]
5593 shortable: Option<bool>,
5594 #[serde(default)]
5596 borrow_status: Option<AlpacaBorrowStatus>,
5597 #[serde(default)]
5599 easy_to_borrow: Option<bool>,
5600}
5601
5602#[derive(Debug, Default, PartialEq, Eq, Deserialize)]
5603#[serde(rename_all = "snake_case")]
5604enum AlpacaAssetClass {
5605 UsOption,
5606 #[default]
5607 #[serde(other)]
5608 Other,
5609}
5610
5611#[derive(Debug, PartialEq, Eq, Deserialize)]
5612#[serde(rename_all = "snake_case")]
5613enum AlpacaBorrowStatus {
5614 EasyToBorrow,
5615 HardToBorrow,
5616 #[serde(other)]
5617 Other,
5618}
5619
5620impl BracketOrderClient for AlpacaClient {
5625 async fn open_bracket_order(
5626 &self,
5627 request: UnifiedBracketOrderRequest<ExchangeId, &InstrumentNameExchange>,
5628 ) -> UnifiedBracketOrderResult {
5629 let alpaca_request = AlpacaBracketOrderRequest {
5630 instrument: request.key.instrument.clone(),
5631 strategy: request.key.strategy.clone(),
5632 cid: request.key.cid.clone(),
5633 side: request.state.side,
5634 quantity: request.state.quantity,
5635 entry_price: request.state.entry_price,
5636 take_profit_price: request.state.take_profit_price,
5637 stop_loss_price: request.state.stop_loss_price,
5638 stop_loss_limit_price: request.state.stop_loss_limit_price,
5639 time_in_force: request.state.time_in_force,
5640 };
5641
5642 let result = self.open_bracket_order(alpaca_request).await;
5643
5644 UnifiedBracketOrderResult::parent_only(result.parent)
5645 }
5646}
5647
5648#[cfg(test)]
5653#[allow(clippy::unwrap_used, clippy::expect_used)] mod tests {
5655 use super::*;
5656 use rust_decimal_macros::dec;
5657
5658 #[test]
5659 fn test_alpaca_config_new_uses_paper_trading_by_default() {
5660 let cfg = AlpacaConfig::new("my_key".into(), "my_secret".into());
5661 assert!(cfg.paper);
5662 }
5663
5664 #[test]
5665 fn test_alpaca_config_debug_redacts_credentials() {
5666 let cfg = AlpacaConfig::new("my_key".into(), "my_secret".into());
5667 let debug = format!("{cfg:?}");
5668 assert!(!debug.contains("my_key"), "api_key should be redacted");
5669 assert!(
5670 !debug.contains("my_secret"),
5671 "secret_key should be redacted"
5672 );
5673 assert!(debug.contains("paper: true"));
5674 }
5675
5676 #[test]
5677 fn test_alpaca_config_deserialize_omitted_paper_defaults_to_paper() {
5678 let cfg: AlpacaConfig = serde_json::from_str(
5679 r#"{
5680 "api_key": "my_key",
5681 "secret_key": "my_secret"
5682 }"#,
5683 )
5684 .unwrap();
5685
5686 assert!(cfg.paper);
5687 assert_eq!(cfg.rest_base_url(), "https://paper-api.alpaca.markets");
5688 }
5689
5690 #[test]
5691 fn test_alpaca_config_deserialize_paper_true() {
5692 let cfg: AlpacaConfig = serde_json::from_str(
5693 r#"{
5694 "api_key": "my_key",
5695 "secret_key": "my_secret",
5696 "paper": true
5697 }"#,
5698 )
5699 .unwrap();
5700
5701 assert!(cfg.paper);
5702 assert_eq!(cfg.rest_base_url(), "https://paper-api.alpaca.markets");
5703 }
5704
5705 #[test]
5706 fn test_alpaca_config_deserialize_paper_false() {
5707 let cfg: AlpacaConfig = serde_json::from_str(
5708 r#"{
5709 "api_key": "my_key",
5710 "secret_key": "my_secret",
5711 "paper": false
5712 }"#,
5713 )
5714 .unwrap();
5715
5716 assert!(!cfg.paper);
5717 assert_eq!(cfg.rest_base_url(), "https://api.alpaca.markets");
5718 }
5719
5720 #[test]
5721 fn test_alpaca_config_deserialize_rejects_test_url_overrides() {
5722 for key in ["base_url_override", "ws_url_override"] {
5723 let json = format!(
5724 r#"{{"api_key": "my_key", "secret_key": "my_secret", "{key}": "http://127.0.0.1:1"}}"#
5725 );
5726 let Err(err) = serde_json::from_str::<AlpacaConfig>(&json) else {
5727 panic!("a config with `{key}` must not deserialize");
5728 };
5729 assert!(
5730 err.to_string().contains(&format!("unknown field `{key}`")),
5731 "unexpected error for `{key}`: {err}"
5732 );
5733 }
5734 }
5735
5736 #[test]
5737 fn test_alpaca_config_urls() {
5738 let paper = AlpacaConfig::paper("k".into(), "s".into());
5739 assert!(paper.rest_base_url().contains("paper-api"));
5740 assert!(paper.ws_url().contains("paper-api"));
5741
5742 let live = AlpacaConfig::production("k".into(), "s".into());
5743 assert!(!live.rest_base_url().contains("paper-api"));
5744 assert!(!live.ws_url().contains("paper-api"));
5745 }
5746
5747 #[test]
5748 #[serial_test::serial]
5749 fn test_alpaca_config_from_env_defaults_to_paper_trading() {
5750 temp_env::with_vars(
5751 [
5752 ("ALPACA_API_KEY", Some("my_key")),
5753 ("ALPACA_SECRET_KEY", Some("my_secret")),
5754 ("ALPACA_PAPER", None),
5755 ],
5756 || {
5757 let cfg = AlpacaConfig::from_env().unwrap();
5758 assert!(cfg.paper);
5759 assert_eq!(cfg.rest_base_url(), "https://paper-api.alpaca.markets");
5760 },
5761 );
5762 }
5763
5764 #[test]
5765 #[serial_test::serial]
5766 fn test_alpaca_config_from_env_accepts_explicit_production() {
5767 temp_env::with_vars(
5768 [
5769 ("ALPACA_API_KEY", Some("my_key")),
5770 ("ALPACA_SECRET_KEY", Some("my_secret")),
5771 ("ALPACA_PAPER", Some("false")),
5772 ],
5773 || {
5774 let cfg = AlpacaConfig::from_env().unwrap();
5775 assert!(!cfg.paper);
5776 assert_eq!(cfg.rest_base_url(), "https://api.alpaca.markets");
5777 },
5778 );
5779 }
5780
5781 #[test]
5782 #[serial_test::serial]
5783 fn test_alpaca_config_from_env_rejects_invalid_paper() {
5784 temp_env::with_vars(
5785 [
5786 ("ALPACA_API_KEY", Some("my_key")),
5787 ("ALPACA_SECRET_KEY", Some("my_secret")),
5788 ("ALPACA_PAPER", Some("maybe")),
5789 ],
5790 || {
5791 let err = AlpacaConfig::from_env().unwrap_err();
5792 assert!(matches!(err, AlpacaConfigError::InvalidPaper(value) if value == "maybe"));
5793 },
5794 );
5795 }
5796
5797 #[test]
5798 #[serial_test::serial]
5799 fn test_alpaca_config_from_env_requires_credentials() {
5800 temp_env::with_vars(
5801 [
5802 ("ALPACA_API_KEY", None),
5803 ("ALPACA_SECRET_KEY", Some("my_secret")),
5804 ("ALPACA_PAPER", None),
5805 ],
5806 || {
5807 let err = AlpacaConfig::from_env().unwrap_err();
5808 assert!(matches!(err, AlpacaConfigError::MissingApiKey));
5809 },
5810 );
5811 }
5812
5813 #[test]
5814 fn test_parse_side() {
5815 assert_eq!(parse_side("buy"), Some(Side::Buy));
5816 assert_eq!(parse_side("sell"), Some(Side::Sell));
5817 assert_eq!(parse_side("Buy"), Some(Side::Buy));
5818 assert_eq!(parse_side("BUY"), Some(Side::Buy));
5819 assert_eq!(parse_side("unknown"), None);
5820 }
5821
5822 #[test]
5823 fn test_parse_order_kind() {
5824 assert_eq!(
5826 parse_order_kind("market", None, None, None),
5827 Some(OrderKind::Market)
5828 );
5829 assert_eq!(
5830 parse_order_kind("limit", None, None, None),
5831 Some(OrderKind::Limit)
5832 );
5833
5834 assert_eq!(
5836 parse_order_kind("stop", Some("150.00"), None, None),
5837 Some(OrderKind::Stop {
5838 trigger_price: Decimal::from_str("150.00").unwrap()
5839 })
5840 );
5841 assert_eq!(parse_order_kind("stop", None, None, None), None);
5842
5843 assert_eq!(
5845 parse_order_kind("stop_limit", Some("145.00"), None, None),
5846 Some(OrderKind::StopLimit {
5847 trigger_price: Decimal::from_str("145.00").unwrap()
5848 })
5849 );
5850
5851 assert_eq!(
5853 parse_order_kind("trailing_stop", None, Some("5.0"), None),
5854 Some(OrderKind::TrailingStop {
5855 offset: Decimal::from_str("5.0").unwrap(),
5856 offset_type: TrailingOffsetType::Percentage,
5857 activation_price: None,
5858 })
5859 );
5860
5861 assert_eq!(
5863 parse_order_kind("trailing_stop", None, None, Some("2.50")),
5864 Some(OrderKind::TrailingStop {
5865 offset: Decimal::from_str("2.50").unwrap(),
5866 offset_type: TrailingOffsetType::Absolute,
5867 activation_price: None,
5868 })
5869 );
5870
5871 assert_eq!(parse_order_kind("trailing_stop", None, None, None), None);
5873
5874 assert_eq!(parse_order_kind("unknown", None, None, None), None);
5876 }
5877
5878 #[test]
5879 fn test_map_order_kind() {
5880 assert_eq!(map_order_kind(OrderKind::Market), Some("market"));
5881 assert_eq!(map_order_kind(OrderKind::Limit), Some("limit"));
5882 assert_eq!(
5883 map_order_kind(OrderKind::Stop {
5884 trigger_price: Decimal::from_str("150.00").unwrap()
5885 }),
5886 Some("stop")
5887 );
5888 assert_eq!(
5889 map_order_kind(OrderKind::StopLimit {
5890 trigger_price: Decimal::from_str("145.00").unwrap()
5891 }),
5892 Some("stop_limit")
5893 );
5894 assert_eq!(
5895 map_order_kind(OrderKind::TrailingStop {
5896 offset: Decimal::from_str("5.0").unwrap(),
5897 offset_type: TrailingOffsetType::Percentage,
5898 activation_price: None,
5899 }),
5900 Some("trailing_stop")
5901 );
5902 assert_eq!(
5903 map_order_kind(OrderKind::TrailingStop {
5904 offset: Decimal::from_str("2.50").unwrap(),
5905 offset_type: TrailingOffsetType::Absolute,
5906 activation_price: None,
5907 }),
5908 Some("trailing_stop")
5909 );
5910 assert_eq!(
5912 map_order_kind(OrderKind::TrailingStop {
5913 offset: Decimal::from_str("5.0").unwrap(),
5914 offset_type: TrailingOffsetType::Percentage,
5915 activation_price: Some(Decimal::from(100)),
5916 }),
5917 None
5918 );
5919 assert_eq!(
5920 map_order_kind(OrderKind::TrailingStopFixedLimit {
5921 offset: Decimal::from_str("5.0").unwrap(),
5922 offset_type: TrailingOffsetType::Percentage,
5923 activation_price: None,
5924 }),
5925 None
5926 );
5927 assert_eq!(
5929 map_order_kind(OrderKind::TrailingStopLimit {
5930 offset: Decimal::from_str("5.0").unwrap(),
5931 offset_type: TrailingOffsetType::Percentage,
5932 stop_price: Decimal::from_str("95.0").unwrap(),
5933 limit_offset: Decimal::from_str("1.0").unwrap(),
5934 }),
5935 None
5936 );
5937 assert_eq!(
5939 map_order_kind(OrderKind::TakeProfit {
5940 trigger_price: Decimal::from_str("160.00").unwrap()
5941 }),
5942 None
5943 );
5944 assert_eq!(
5945 map_order_kind(OrderKind::TakeProfitLimit {
5946 trigger_price: Decimal::from_str("160.00").unwrap()
5947 }),
5948 None
5949 );
5950 }
5951
5952 #[test]
5953 fn test_map_time_in_force_roundtrip() {
5954 assert_eq!(
5955 map_time_in_force(TimeInForce::GoodUntilCancelled { post_only: false }),
5956 Ok("gtc")
5957 );
5958 assert_eq!(map_time_in_force(TimeInForce::GoodUntilEndOfDay), Ok("day"));
5959 assert_eq!(map_time_in_force(TimeInForce::FillOrKill), Ok("fok"));
5960 assert_eq!(map_time_in_force(TimeInForce::ImmediateOrCancel), Ok("ioc"));
5961 }
5962
5963 #[test]
5964 fn test_map_time_in_force_rejects_post_only() {
5965 let result = map_time_in_force(TimeInForce::GoodUntilCancelled { post_only: true });
5966 assert!(result.is_err(), "post_only must be rejected");
5967 assert!(result.unwrap_err().contains("post_only"));
5968 }
5969
5970 #[test]
5975 fn test_bracket_order_serializes_with_stop_loss_stop_order() {
5976 let body = AlpacaOrderRequest {
5978 symbol: "AAPL",
5979 qty: "10".to_string(),
5980 side: "buy",
5981 order_type: "limit",
5982 time_in_force: "gtc",
5983 limit_price: Some("150.00".to_string()),
5984 stop_price: None,
5985 trail_percent: None,
5986 trail_price: None,
5987 client_order_id: Some("bracket-001"),
5988 position_intent: Some(AlpacaPositionIntent::BuyToOpen),
5989 order_class: Some("bracket"),
5990 take_profit: Some(TakeProfitParams {
5991 limit_price: "160.00".to_string(),
5992 }),
5993 stop_loss: Some(StopLossParams {
5994 stop_price: "145.00".to_string(),
5995 limit_price: None,
5996 }),
5997 };
5998
5999 let json = serde_json::to_value(&body).unwrap();
6000
6001 assert_eq!(json["symbol"], "AAPL");
6002 assert_eq!(json["qty"], "10");
6003 assert_eq!(json["side"], "buy");
6004 assert_eq!(json["type"], "limit");
6005 assert_eq!(json["time_in_force"], "gtc");
6006 assert_eq!(json["limit_price"], "150.00");
6007 assert_eq!(json["order_class"], "bracket");
6008
6009 assert_eq!(json["take_profit"]["limit_price"], "160.00");
6011
6012 assert_eq!(json["stop_loss"]["stop_price"], "145.00");
6014 assert!(
6015 json["stop_loss"].get("limit_price").is_none(),
6016 "stop_loss.limit_price should be omitted when None"
6017 );
6018 }
6019
6020 #[test]
6021 fn test_bracket_order_serializes_with_stop_loss_stop_limit_order() {
6022 let body = AlpacaOrderRequest {
6024 symbol: "SPY",
6025 qty: "5".to_string(),
6026 side: "sell",
6027 order_type: "limit",
6028 time_in_force: "day",
6029 limit_price: Some("450.00".to_string()),
6030 stop_price: None,
6031 trail_percent: None,
6032 trail_price: None,
6033 client_order_id: Some("bracket-002"),
6034 position_intent: Some(AlpacaPositionIntent::SellToClose),
6035 order_class: Some("bracket"),
6036 take_profit: Some(TakeProfitParams {
6037 limit_price: "440.00".to_string(),
6038 }),
6039 stop_loss: Some(StopLossParams {
6040 stop_price: "455.00".to_string(),
6041 limit_price: Some("456.00".to_string()),
6042 }),
6043 };
6044
6045 let json = serde_json::to_value(&body).unwrap();
6046
6047 assert_eq!(json["symbol"], "SPY");
6048 assert_eq!(json["side"], "sell");
6049 assert_eq!(json["time_in_force"], "day");
6050 assert_eq!(json["order_class"], "bracket");
6051
6052 assert_eq!(json["take_profit"]["limit_price"], "440.00");
6054
6055 assert_eq!(json["stop_loss"]["stop_price"], "455.00");
6057 assert_eq!(
6058 json["stop_loss"]["limit_price"], "456.00",
6059 "stop_loss.limit_price should be present for stop-limit orders"
6060 );
6061 }
6062
6063 #[tokio::test]
6068 async fn test_open_bracket_order_rejects_invalid_tif() {
6069 use rust_decimal_macros::dec;
6070 use rustrade_instrument::instrument::name::InstrumentNameExchange;
6071
6072 let config = AlpacaConfig::new("dummy_key".into(), "dummy_secret".into());
6074 let client = AlpacaClient::new(config);
6075
6076 let request = AlpacaBracketOrderRequest::new(
6077 InstrumentNameExchange::new("SPY"),
6078 crate::order::id::StrategyId::new("test"),
6079 crate::order::id::ClientOrderId::new("test-tif"),
6080 Side::Buy,
6081 dec!(1),
6082 dec!(100.00),
6083 dec!(120.00),
6084 dec!(90.00),
6085 TimeInForce::ImmediateOrCancel, );
6087
6088 let result = client.open_bracket_order(request).await;
6089
6090 assert!(
6091 result.parent.state.is_failed(),
6092 "Bracket order with IOC TIF should be rejected locally"
6093 );
6094 }
6095
6096 #[tokio::test]
6097 async fn test_open_bracket_order_rejects_invalid_price_ordering() {
6098 use rust_decimal_macros::dec;
6099 use rustrade_instrument::instrument::name::InstrumentNameExchange;
6100
6101 let config = AlpacaConfig::new("dummy_key".into(), "dummy_secret".into());
6102 let client = AlpacaClient::new(config);
6103
6104 let request = AlpacaBracketOrderRequest::new(
6106 InstrumentNameExchange::new("SPY"),
6107 crate::order::id::StrategyId::new("test"),
6108 crate::order::id::ClientOrderId::new("test-price"),
6109 Side::Buy,
6110 dec!(1),
6111 dec!(100.00),
6112 dec!(120.00),
6113 dec!(105.00), TimeInForce::GoodUntilCancelled { post_only: false },
6115 );
6116
6117 let result = client.open_bracket_order(request).await;
6118
6119 assert!(
6120 result.parent.state.is_failed(),
6121 "Bracket order with invalid price ordering should be rejected locally"
6122 );
6123 }
6124
6125 #[tokio::test]
6126 async fn test_open_bracket_order_rejects_invalid_sl_limit_price() {
6127 use rust_decimal_macros::dec;
6128 use rustrade_instrument::instrument::name::InstrumentNameExchange;
6129
6130 let config = AlpacaConfig::new("dummy_key".into(), "dummy_secret".into());
6131 let client = AlpacaClient::new(config);
6132
6133 let request = AlpacaBracketOrderRequest::new(
6135 InstrumentNameExchange::new("SPY"),
6136 crate::order::id::StrategyId::new("test"),
6137 crate::order::id::ClientOrderId::new("test-sl-limit"),
6138 Side::Buy,
6139 dec!(1),
6140 dec!(100.00),
6141 dec!(120.00),
6142 dec!(90.00),
6143 TimeInForce::GoodUntilCancelled { post_only: false },
6144 )
6145 .with_stop_loss_limit_price(dec!(95.00)); let result = client.open_bracket_order(request).await;
6148
6149 assert!(
6150 result.parent.state.is_failed(),
6151 "Bracket order with invalid SL limit price should be rejected locally"
6152 );
6153 }
6154
6155 #[test]
6156 fn test_non_bracket_order_omits_bracket_fields() {
6157 let body = AlpacaOrderRequest {
6159 symbol: "AAPL",
6160 qty: "1".to_string(),
6161 side: "buy",
6162 order_type: "limit",
6163 time_in_force: "gtc",
6164 limit_price: Some("150.00".to_string()),
6165 stop_price: None,
6166 trail_percent: None,
6167 trail_price: None,
6168 client_order_id: Some("regular-001"),
6169 position_intent: Some(AlpacaPositionIntent::BuyToOpen),
6170 order_class: None,
6171 take_profit: None,
6172 stop_loss: None,
6173 };
6174
6175 let json = serde_json::to_value(&body).unwrap();
6176
6177 assert_eq!(json["symbol"], "AAPL");
6178 assert!(
6179 json.get("order_class").is_none(),
6180 "order_class should be omitted for non-bracket orders"
6181 );
6182 assert!(
6183 json.get("take_profit").is_none(),
6184 "take_profit should be omitted for non-bracket orders"
6185 );
6186 assert!(
6187 json.get("stop_loss").is_none(),
6188 "stop_loss should be omitted for non-bracket orders"
6189 );
6190 }
6191
6192 #[test]
6193 fn test_parse_timestamp_valid() {
6194 let ts = parse_timestamp("2025-04-18T14:30:00Z");
6195 assert!(ts.is_some());
6196 let ts2 = parse_timestamp("2025-04-18T14:30:00.123456Z");
6197 assert!(ts2.is_some());
6198 assert_eq!(parse_timestamp("not-a-timestamp"), None);
6199 }
6200
6201 #[test]
6202 fn test_check_auth_response_authorized() {
6203 let msg =
6204 r#"{"stream":"authorization","data":{"status":"authorized","action":"authenticate"}}"#;
6205 assert!(matches!(check_auth_response(msg), Some(Ok(()))));
6206 }
6207
6208 #[test]
6209 fn test_check_auth_response_unauthorized() {
6210 let msg = r#"{"stream":"authorization","data":{"status":"unauthorized"}}"#;
6211 assert!(matches!(
6212 check_auth_response(msg),
6213 Some(Err(HandshakeError::Auth(_)))
6214 ));
6215 }
6216
6217 #[test]
6218 fn test_check_auth_response_non_auth_message() {
6219 let msg = r#"{"stream":"trade_updates","data":{}}"#;
6220 assert!(check_auth_response(msg).is_none());
6221 }
6222
6223 #[test]
6224 fn test_check_listen_ack() {
6225 let ack = r#"{"stream":"listening","data":{"streams":["trade_updates"]}}"#;
6226 assert!(check_listen_ack(ack));
6227
6228 let other = r#"{"stream":"authorization","data":{}}"#;
6229 assert!(!check_listen_ack(other));
6230 }
6231
6232 #[test]
6233 fn test_dedup_cache() {
6234 let cache = new_dedup_cache();
6235 let key = || SmolStr::new("order-1:1");
6236 assert!(
6237 !is_duplicate_fill(&cache, Some("exec-1"), key()),
6238 "first time should not be duplicate"
6239 );
6240 assert!(
6241 is_duplicate_fill(&cache, Some("exec-1"), key()),
6242 "second time should be duplicate"
6243 );
6244 }
6245
6246 #[test]
6249 fn dedup_tells_fills_apart_by_execution_id_not_cumulative() {
6250 let cache = new_dedup_cache();
6251 assert!(!is_duplicate_fill(
6252 &cache,
6253 Some("exec-a"),
6254 SmolStr::new("ord-1:5")
6255 ));
6256 assert!(
6258 !is_duplicate_fill(&cache, Some("exec-b"), SmolStr::new("ord-1:5")),
6259 "a different execution at a cumulative seen before is a new fill"
6260 );
6261 }
6262
6263 #[test]
6266 fn dedup_matches_a_fill_with_an_execution_id_to_the_same_fill_without_one() {
6267 let cache = new_dedup_cache();
6268
6269 assert!(!is_duplicate_fill(&cache, None, SmolStr::new("ord-1:2")));
6270 assert!(
6271 is_duplicate_fill(&cache, Some("exec-a"), SmolStr::new("ord-1:2")),
6272 "without an id first, then with one"
6273 );
6274
6275 assert!(!is_duplicate_fill(
6276 &cache,
6277 Some("exec-b"),
6278 SmolStr::new("ord-1:4")
6279 ));
6280 assert!(
6281 is_duplicate_fill(&cache, None, SmolStr::new("ord-1:4")),
6282 "with an id first, then without one"
6283 );
6284 }
6285
6286 #[test]
6289 fn the_heartbeat_window_is_as_documented() {
6290 assert_eq!(HEARTBEAT_TIMEOUT_SECS, 35);
6291 }
6292
6293 #[test]
6294 fn test_convert_account_to_balances_empty_assets() {
6295 let account: AlpacaAccount = serde_json::from_value(serde_json::json!({
6298 "cash": "9000.50",
6299 "equity": "12000.00",
6300 "buying_power": "36000.00",
6301 "regt_buying_power": "18000.00",
6302 "options_buying_power": "8000.00",
6303 "non_marginable_buying_power": "8500.25",
6304 }))
6305 .unwrap();
6306 let balances = convert_account_to_balances(&account, &[]);
6307 assert_eq!(balances.len(), 1);
6308 assert_eq!(balances[0].asset, AssetNameExchange::new("USD"));
6309 assert_eq!(balances[0].balance.total, dec!(9000.50), "total is cash");
6310 assert_eq!(
6311 balances[0].balance.free,
6312 dec!(8500.25),
6313 "free is non-marginable buying power when below cash"
6314 );
6315 assert_eq!(balances[0].balance.margin, None);
6316 }
6317
6318 #[test]
6321 fn test_convert_account_to_balances_free_capped_at_cash_with_long_stock() {
6322 let account: AlpacaAccount = serde_json::from_value(serde_json::json!({
6323 "cash": "50000",
6324 "equity": "100000",
6325 "initial_margin": "25000",
6326 "non_marginable_buying_power": "75000",
6327 }))
6328 .unwrap();
6329 let balances = convert_account_to_balances(&account, &[]);
6330 assert_eq!(balances[0].balance.total, dec!(50000));
6331 assert_eq!(balances[0].balance.free, dec!(50000));
6332 }
6333
6334 #[test]
6337 fn test_convert_account_to_balances_open_short_keeps_free_below_cash() {
6338 let account: AlpacaAccount = serde_json::from_value(serde_json::json!({
6339 "cash": "10200",
6340 "equity": "10000",
6341 "initial_margin": "100",
6342 "non_marginable_buying_power": "9900",
6343 }))
6344 .unwrap();
6345 let balances = convert_account_to_balances(&account, &[]);
6346 assert_eq!(balances[0].balance.total, dec!(10200));
6347 assert_eq!(balances[0].balance.free, dec!(9900));
6348 }
6349
6350 #[test]
6351 fn test_convert_account_to_balances_negative_cash_on_margin() {
6352 let account: AlpacaAccount = serde_json::from_value(serde_json::json!({
6353 "cash": "-2500.75",
6354 "equity": "7500.00",
6355 "buying_power": "5000.00",
6356 "non_marginable_buying_power": "1250.00",
6357 }))
6358 .unwrap();
6359 let balances = convert_account_to_balances(&account, &[]);
6360 assert_eq!(balances.len(), 1);
6361 assert_eq!(balances[0].balance.total, dec!(-2500.75));
6362 assert_eq!(
6363 balances[0].balance.free,
6364 dec!(-2500.75),
6365 "no free cash while borrowing; free <= total still holds"
6366 );
6367 }
6368
6369 #[test]
6370 fn test_alpaca_account_malformed_cash_fails_to_decode() {
6371 let result = serde_json::from_value::<AlpacaAccount>(serde_json::json!({
6372 "cash": "not-a-number",
6373 "non_marginable_buying_power": "0",
6374 }));
6375 assert!(result.is_err(), "a malformed cash must not read as zero");
6376 }
6377
6378 #[test]
6379 fn test_convert_account_to_balances_usd_filter() {
6380 let account = AlpacaAccount {
6381 cash: dec!(12000.00),
6382 non_marginable_buying_power: dec!(10000.00),
6383 };
6384 let usd = vec![AssetNameExchange::new("USD")];
6385 let balances = convert_account_to_balances(&account, &usd);
6386 assert_eq!(balances.len(), 1);
6387 assert_eq!(balances[0].asset, AssetNameExchange::new("USD"));
6388
6389 let non_usd = vec![AssetNameExchange::new("BTC")];
6390 let balances = convert_account_to_balances(&account, &non_usd);
6391 assert!(balances.is_empty());
6392 }
6393
6394 #[test]
6397 fn a_usd_balance_is_named_usd_however_the_filter_spells_it() {
6398 let account = AlpacaAccount {
6399 cash: dec!(12000.00),
6400 non_marginable_buying_power: dec!(10000.00),
6401 };
6402 let balances = convert_account_to_balances(&account, &[AssetNameExchange::new("usd")]);
6403 assert_eq!(balances.len(), 1);
6404 assert_eq!(balances[0].asset, AssetNameExchange::new("USD"));
6405 }
6406
6407 #[test]
6408 fn test_is_options_or_equity_symbol() {
6409 assert!(!is_options_or_equity_symbol("BTC/USD"));
6411 assert!(!is_options_or_equity_symbol("ETH/USD"));
6412 assert!(!is_options_or_equity_symbol("SOL/USD"));
6413
6414 assert!(is_options_or_equity_symbol("AAPL"));
6416 assert!(is_options_or_equity_symbol("SPY"));
6417 assert!(is_options_or_equity_symbol("MSFT"));
6418
6419 assert!(is_options_or_equity_symbol("SPY250418C00450000"));
6421 assert!(is_options_or_equity_symbol("AAPL250418P00145000"));
6422 }
6423
6424 #[test]
6425 fn test_parse_order_error_already_cancelled() {
6426 assert!(matches!(
6429 parse_order_error(
6430 reqwest::StatusCode::UNPROCESSABLE_ENTITY,
6431 None,
6432 "order is already cancelled"
6433 ),
6434 UnindexedOrderError::Rejected(ApiError::OrderAlreadyCancelled)
6435 ));
6436 }
6437
6438 #[test]
6439 fn test_parse_order_error_already_wins_over_insufficient_on_422() {
6440 assert!(matches!(
6443 parse_order_error(
6444 reqwest::StatusCode::UNPROCESSABLE_ENTITY,
6445 None,
6446 "order already cancelled due to insufficient margin"
6447 ),
6448 UnindexedOrderError::Rejected(ApiError::OrderAlreadyCancelled)
6449 ));
6450 }
6451
6452 fn make_order_ws<'a>(
6453 id: &str,
6454 symbol: &str,
6455 side: &str,
6456 filled_qty: &'a str,
6457 ) -> AlpacaOrderWs<'a> {
6458 AlpacaOrderWs {
6459 id: SmolStr::new(id),
6460 client_order_id: None,
6461 symbol: SmolStr::new(symbol),
6462 qty: Some("2"),
6463 filled_qty: Some(filled_qty),
6464 side: SmolStr::new(side),
6465 order_type: SmolStr::new("limit"),
6466 time_in_force: SmolStr::new("day"),
6467 limit_price: Some("100.00"),
6468 stop_price: None,
6469 trail_percent: None,
6470 trail_price: None,
6471 status: SmolStr::new("partially_filled"),
6472 }
6473 }
6474
6475 fn sole_event(events: [Option<UnindexedAccountEvent>; 2]) -> Option<UnindexedAccountEvent> {
6480 let [first, second] = events;
6481 assert!(
6482 second.is_none(),
6483 "expected a single event, got a second: {second:?}"
6484 );
6485 first
6486 }
6487
6488 fn fill_events(
6490 events: [Option<UnindexedAccountEvent>; 2],
6491 ) -> (
6492 Trade<AssetNameExchange, InstrumentNameExchange>,
6493 crate::order::Order<
6494 ExchangeId,
6495 InstrumentNameExchange,
6496 OrderState<AssetNameExchange, InstrumentNameExchange>,
6497 >,
6498 ) {
6499 let [first, second] = events;
6500 let first = first.expect("a fill frame must produce an execution");
6501 let second = second.expect("a fill frame must produce an order snapshot");
6502 let AccountEventKind::Trade(trade) = first.kind else {
6503 panic!("first event must be the Trade, got {:?}", first.kind);
6504 };
6505 let AccountEventKind::OrderSnapshot(rustrade_integration::collection::snapshot::Snapshot(
6506 order,
6507 )) = second.kind
6508 else {
6509 panic!(
6510 "second event must be the OrderSnapshot, got {:?}",
6511 second.kind
6512 );
6513 };
6514 (trade, order)
6515 }
6516
6517 #[test]
6520 fn test_convert_trade_update_fill_carries_the_client_order_id() {
6521 let mut order = make_order_ws("ord-1", "SPY", "buy", "1");
6522 order.client_order_id = Some(SmolStr::new("outside-1"));
6523 let update = AlpacaTradeUpdate {
6524 event: SmolStr::new("fill"),
6525 execution_id: Some("exec-1"),
6526 previous_execution_id: None,
6527 order,
6528 price: Some("150.00"),
6529 qty: Some("1"),
6530 timestamp: Some("2025-04-18T14:30:00Z"),
6531 };
6532 let (trade, _order) = fill_events(convert_trade_update(update));
6533 assert_eq!(trade.client_order_id, Some(ClientOrderId::new("outside-1")));
6534 }
6535
6536 #[test]
6537 fn test_convert_trade_update_fill_produces_trade_with_execution_id() {
6538 let update = AlpacaTradeUpdate {
6539 event: SmolStr::new("fill"),
6540 execution_id: Some("exec-1"),
6541 previous_execution_id: None,
6542 order: make_order_ws("ord-1", "SPY", "buy", "1"),
6543 price: Some("150.00"),
6544 qty: Some("1"),
6545 timestamp: Some("2025-04-18T14:30:00Z"),
6546 };
6547 let (trade, order) = fill_events(convert_trade_update(update));
6548 assert_eq!(trade.id.0.as_str(), "exec-1");
6550 assert_eq!(
6551 trade.client_order_id, None,
6552 "the update names no client order id"
6553 );
6554 assert_eq!(trade.price, Decimal::from_str("150.00").unwrap());
6555 assert_eq!(trade.quantity, Decimal::from_str("1").unwrap());
6556
6557 let OrderState::Active(crate::order::state::ActiveOrderState::Open(open)) = order.state
6560 else {
6561 panic!("expected an Open snapshot, got {:?}", order.state);
6562 };
6563 assert_eq!(open.filled_quantity, Decimal::from_str("1").unwrap());
6564 assert_eq!(open.id.assigned().map(|id| id.0.as_str()), Some("ord-1"));
6565 }
6566
6567 #[test]
6568 fn test_convert_trade_update_partial_fill() {
6569 let update = AlpacaTradeUpdate {
6570 event: SmolStr::new("partial_fill"),
6571 execution_id: None,
6572 previous_execution_id: None,
6573 order: make_order_ws("ord-2", "AAPL", "sell", "0.5"),
6574 price: Some("200.00"),
6575 qty: Some("0.5"),
6576 timestamp: None,
6577 };
6578 let (trade, order) = fill_events(convert_trade_update(update));
6579 assert_eq!(trade.quantity, Decimal::from_str("0.5").unwrap());
6580
6581 let OrderState::Active(crate::order::state::ActiveOrderState::Open(open)) = order.state
6584 else {
6585 panic!("expected an Open snapshot, got {:?}", order.state);
6586 };
6587 assert_eq!(open.filled_quantity, Decimal::from_str("0.5").unwrap());
6588 assert_eq!(
6589 open.quantity_remaining(order.quantity),
6590 Decimal::from_str("1.5").unwrap()
6591 );
6592 }
6593
6594 #[test]
6598 fn a_full_fill_snapshot_reports_nothing_left_to_fill() {
6599 let update = AlpacaTradeUpdate {
6600 event: SmolStr::new("fill"),
6601 execution_id: None,
6602 previous_execution_id: None,
6603 order: AlpacaOrderWs {
6605 status: SmolStr::new("filled"),
6606 ..make_order_ws("ord-full", "SPY", "buy", "2")
6607 },
6608 price: Some("150.00"),
6609 qty: Some("1"),
6610 timestamp: Some("2025-04-18T14:30:00Z"),
6611 };
6612 let (_trade, order) = fill_events(convert_trade_update(update));
6613 let OrderState::Active(crate::order::state::ActiveOrderState::Open(open)) = order.state
6614 else {
6615 panic!("expected an Open snapshot, got {:?}", order.state);
6616 };
6617 assert_eq!(open.filled_quantity, Decimal::from_str("2").unwrap());
6618 assert!(
6619 open.quantity_remaining(order.quantity).is_zero(),
6620 "a completed order must report nothing left to fill so the engine retires it"
6621 );
6622 }
6623
6624 #[test]
6628 fn a_fill_for_an_order_no_longer_working_emits_the_execution_without_a_snapshot() {
6629 for status in [
6630 "canceled",
6631 "expired",
6632 "rejected",
6633 "done_for_day",
6634 "pending_cancel",
6635 ] {
6636 let update = AlpacaTradeUpdate {
6637 event: SmolStr::new("partial_fill"),
6638 execution_id: None,
6639 previous_execution_id: None,
6640 order: AlpacaOrderWs {
6641 status: SmolStr::new(status),
6642 ..make_order_ws("ord-late", "SPY", "buy", "1")
6643 },
6644 price: Some("150.00"),
6645 qty: Some("1"),
6646 timestamp: None,
6647 };
6648 let [trade, snapshot] = convert_trade_update(update);
6649 assert!(
6650 matches!(trade.map(|e| e.kind), Some(AccountEventKind::Trade(_))),
6651 "the execution must still be reported for status {status}"
6652 );
6653 assert!(
6654 snapshot.is_none(),
6655 "status {status} must not produce an order snapshot"
6656 );
6657 }
6658 }
6659
6660 #[test]
6663 fn a_notional_order_fill_emits_the_execution_without_a_snapshot() {
6664 let update = AlpacaTradeUpdate {
6665 event: SmolStr::new("partial_fill"),
6666 execution_id: None,
6667 previous_execution_id: None,
6668 order: AlpacaOrderWs {
6669 qty: None,
6670 ..make_order_ws("ord-notional-ws", "SPY", "buy", "1")
6671 },
6672 price: Some("150.00"),
6673 qty: Some("1"),
6674 timestamp: None,
6675 };
6676 let [trade, snapshot] = convert_trade_update(update);
6677 assert!(matches!(
6678 trade.map(|e| e.kind),
6679 Some(AccountEventKind::Trade(_))
6680 ));
6681 assert!(
6682 snapshot.is_none(),
6683 "a notional order has no quantity to snapshot against"
6684 );
6685 }
6686
6687 #[test]
6688 fn test_convert_trade_update_new_order_produces_snapshot() {
6689 let update = AlpacaTradeUpdate {
6690 event: SmolStr::new("new"),
6691 execution_id: None,
6692 previous_execution_id: None,
6693 order: AlpacaOrderWs {
6694 id: SmolStr::new("ord-new"),
6695 client_order_id: Some(SmolStr::new("cid-1")),
6696 symbol: SmolStr::new("AAPL"),
6697 qty: Some("10"),
6698 filled_qty: Some("0"),
6699 side: SmolStr::new("buy"),
6700 order_type: SmolStr::new("limit"),
6701 time_in_force: SmolStr::new("day"),
6702 limit_price: Some("150.00"),
6703 stop_price: None,
6704 trail_percent: None,
6705 trail_price: None,
6706 status: SmolStr::new("new"),
6707 },
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("new event should produce an event");
6714 assert!(matches!(event.kind, AccountEventKind::OrderSnapshot(_)));
6715 }
6716
6717 #[test]
6718 fn test_convert_trade_update_canceled_produces_cancel() {
6719 let update = AlpacaTradeUpdate {
6720 event: SmolStr::new("canceled"),
6721 execution_id: None,
6722 previous_execution_id: None,
6723 order: make_order_ws("ord-3", "AAPL", "sell", "0"),
6724 price: None,
6725 qty: None,
6726 timestamp: Some("2025-04-18T14:30:00Z"),
6727 };
6728 let event =
6729 sole_event(convert_trade_update(update)).expect("canceled should produce an event");
6730 let AccountEventKind::OrderCancelled(response) = event.kind else {
6731 panic!("expected OrderCancelled, got {:?}", event.kind);
6732 };
6733 assert!(response.state.is_ok());
6734 }
6735
6736 #[test]
6738 fn a_canceled_update_carries_its_fill_or_none() {
6739 let cancelled = |filled_qty: Option<&'static str>| {
6740 let mut order = make_order_ws("ord-3", "AAPL", "sell", "0");
6741 order.filled_qty = filled_qty;
6742 let update = AlpacaTradeUpdate {
6743 event: SmolStr::new("canceled"),
6744 execution_id: None,
6745 previous_execution_id: None,
6746 order,
6747 price: None,
6748 qty: None,
6749 timestamp: Some("2025-04-18T14:30:00Z"),
6750 };
6751 let event =
6752 sole_event(convert_trade_update(update)).expect("canceled should produce an event");
6753 let AccountEventKind::OrderCancelled(response) = event.kind else {
6754 panic!("expected OrderCancelled, got {:?}", event.kind);
6755 };
6756 let Ok(cancelled) = response.state else {
6757 panic!("expected Ok, got {:?}", response.state);
6758 };
6759 cancelled.filled_quantity
6760 };
6761
6762 assert_eq!(cancelled(Some("1")), Some(Decimal::ONE));
6763 assert_eq!(cancelled(Some("0")), Some(Decimal::ZERO));
6764 assert_eq!(cancelled(None), None);
6765 assert_eq!(cancelled(Some("not a number")), None);
6766 }
6767
6768 #[test]
6771 fn an_ended_order_with_an_unparseable_fill_reports_it_unknown() {
6772 let key = OrderKey::new(
6773 ExchangeId::AlpacaBroker,
6774 InstrumentNameExchange::new("AAPL"),
6775 StrategyId::new("strategy"),
6776 ClientOrderId::new("c"),
6777 );
6778 let ended = |status: &str| {
6779 let mut order = make_order_response("o1", "AAPL");
6780 order.status = Some(status.to_string());
6781 order.filled_qty = "not a number".to_string();
6782 convert_ended_order(&order, &key).map(|order| order.state)
6783 };
6784
6785 assert!(matches!(
6786 ended("canceled"),
6787 Some(InactiveOrderState::Cancelled(Cancelled {
6788 filled_quantity: None,
6789 ..
6790 }))
6791 ));
6792 assert!(matches!(
6793 ended("expired"),
6794 Some(InactiveOrderState::Expired(Expired {
6795 filled_quantity: None,
6796 ..
6797 }))
6798 ));
6799 assert!(matches!(
6800 ended("filled"),
6801 Some(InactiveOrderState::FullyFilled(Filled { filled_quantity, .. }))
6802 if filled_quantity == Decimal::ONE
6803 ));
6804 }
6805
6806 #[test]
6807 fn test_convert_trade_update_rejected_produces_error() {
6808 let update = AlpacaTradeUpdate {
6809 event: SmolStr::new("rejected"),
6810 execution_id: None,
6811 previous_execution_id: None,
6812 order: make_order_ws("ord-4", "SPY", "buy", "0"),
6813 price: None,
6814 qty: None,
6815 timestamp: None,
6816 };
6817 let event =
6818 sole_event(convert_trade_update(update)).expect("rejected should produce an event");
6819 let AccountEventKind::OrderCancelled(response) = event.kind else {
6820 panic!("expected OrderCancelled, got {:?}", event.kind);
6821 };
6822 assert!(response.state.is_err());
6823 }
6824
6825 #[test]
6826 fn test_convert_open_order_notional_qty_none_is_skipped() {
6827 let order = AlpacaOrderResponse {
6830 id: "ord-notional".to_string(),
6831 client_order_id: None,
6832 symbol: "SPY".to_string(),
6833 qty: None,
6834 filled_qty: "0".to_string(),
6835 filled_avg_price: None,
6836 status: None,
6837 side: "buy".to_string(),
6838 order_type: "market".to_string(),
6839 time_in_force: "day".to_string(),
6840 limit_price: None,
6841 stop_price: None,
6842 trail_percent: None,
6843 trail_price: None,
6844 created_at: "2025-04-18T14:30:00Z".to_string(),
6845 updated_at: None,
6846 };
6847 assert!(convert_open_order(&order).is_none());
6848 }
6849
6850 #[test]
6851 fn test_convert_activity_to_trade_bad_price_returns_none() {
6852 let activity = AlpacaActivity {
6856 id: "act-1".to_string(),
6857 order_id: "ord-1".to_string(),
6858 symbol: "SPY250418C00450000".to_string(),
6859 side: "buy".to_string(),
6860 price: "not-a-number".to_string(),
6861 qty: "1".to_string(),
6862 transaction_time: "2025-04-18T14:30:00Z".to_string(),
6863 cum_qty: Some("1".to_string()),
6864 };
6865 assert!(convert_activity_to_trade(&activity).is_none());
6866 }
6867
6868 #[test]
6869 fn test_convert_positions_to_balances_crypto() {
6870 let positions = vec![
6871 AlpacaPosition {
6873 qty_available: dec!(0.4),
6874 ..alpaca_position("BTCUSD", "crypto", AlpacaPositionSide::Long, dec!(0.5))
6875 },
6876 alpaca_position("ETH/USD", "crypto", AlpacaPositionSide::Long, dec!(2.0)),
6878 alpaca_position("AAPL", "us_equity", AlpacaPositionSide::Long, dec!(10)),
6880 ];
6881
6882 let balances = convert_positions_to_balances(&positions, &[]);
6884 assert_eq!(balances.len(), 2, "only crypto positions returned");
6885 assert_eq!(balances[0].asset.name().as_str(), "BTC");
6886 assert_eq!(balances[1].asset.name().as_str(), "ETH");
6887 assert_eq!(balances[0].balance.total, dec!(0.5));
6889 assert_eq!(balances[0].balance.free, dec!(0.4));
6890
6891 let btc_only = vec![AssetNameExchange::new("btc")];
6893 let balances = convert_positions_to_balances(&positions, &btc_only);
6894 assert_eq!(balances.len(), 1);
6895 assert_eq!(balances[0].asset.name().as_str(), "BTC");
6896 }
6897
6898 #[test]
6899 fn test_crypto_position_asset() {
6900 assert_eq!(crypto_position_asset("BTCUSD"), "BTC");
6901 assert_eq!(
6902 crypto_position_asset("btcusd"),
6903 "btc",
6904 "suffix matched ignoring case"
6905 );
6906 assert_eq!(
6907 crypto_position_asset("USDTUSD"),
6908 "USDT",
6909 "only one USD is stripped"
6910 );
6911 assert_eq!(crypto_position_asset("BTC/USD"), "BTC");
6912 assert_eq!(crypto_position_asset("BTC/USDT"), "BTC");
6913 assert_eq!(crypto_position_asset("€USD"), "€");
6914 assert_eq!(crypto_position_asset("USD"), "USD");
6916 assert_eq!(crypto_position_asset("/USD"), "/USD");
6917 assert_eq!(crypto_position_asset("BTCUSDT"), "BTCUSDT");
6918 assert_eq!(
6919 crypto_position_asset("BTCێ"),
6920 "BTCێ",
6921 "no char boundary at the suffix"
6922 );
6923 assert_eq!(crypto_position_asset(""), "");
6924 }
6925
6926 fn alpaca_position(
6928 symbol: &str,
6929 asset_class: &str,
6930 side: AlpacaPositionSide,
6931 qty: Decimal,
6932 ) -> AlpacaPosition {
6933 AlpacaPosition {
6934 symbol: symbol.into(),
6935 asset_class: asset_class.into(),
6936 side,
6937 qty,
6938 qty_available: qty,
6939 avg_entry_price: Some(dec!(100)),
6940 unrealized_pl: Some(dec!(0)),
6941 }
6942 }
6943
6944 #[test]
6945 fn test_convert_positions_sign_comes_from_side_not_qty() {
6946 let now = Utc::now();
6947 let positions = vec![
6948 alpaca_position("AAPL", "us_equity", AlpacaPositionSide::Long, dec!(10)),
6949 alpaca_position("TSLA", "us_equity", AlpacaPositionSide::Short, dec!(-3)),
6950 alpaca_position("MSFT", "us_equity", AlpacaPositionSide::Short, dec!(4)),
6952 ];
6953
6954 let converted = convert_positions(&positions, now).unwrap();
6955 let quantities: Vec<_> = converted.iter().map(|(s, p)| (*s, p.quantity)).collect();
6956 assert_eq!(
6957 quantities,
6958 vec![("AAPL", dec!(10)), ("TSLA", dec!(-3)), ("MSFT", dec!(-4))]
6959 );
6960 assert!(converted.iter().all(|(_, p)| p.time_exchange == now));
6961 }
6962
6963 #[test]
6964 fn test_convert_positions_leaves_out_crypto_and_flat() {
6965 let positions = vec![
6966 alpaca_position("BTC/USD", "crypto", AlpacaPositionSide::Long, dec!(0.5)),
6967 alpaca_position("AAPL", "us_equity", AlpacaPositionSide::Long, dec!(0)),
6968 alpaca_position("SPY", "us_equity", AlpacaPositionSide::Long, dec!(1)),
6969 ];
6970
6971 let converted = convert_positions(&positions, Utc::now()).unwrap();
6972 let symbols: Vec<_> = converted.iter().map(|(s, _)| *s).collect();
6973 assert_eq!(symbols, vec!["SPY"]);
6974 }
6975
6976 #[test]
6977 fn test_alpaca_position_decodes_equity_option_and_fractional() {
6978 let positions: Vec<AlpacaPosition> = serde_json::from_value(serde_json::json!([
6979 {
6980 "asset_id": "00000000-0000-0000-0000-000000000001",
6981 "symbol": "XYZ",
6982 "exchange": "NASDAQ",
6983 "asset_class": "us_equity",
6984 "qty": "-7",
6985 "qty_available": "-7",
6986 "side": "short",
6987 "avg_entry_price": "50.25",
6988 "market_value": "-351.75",
6989 "unrealized_pl": "0",
6990 },
6991 {
6992 "symbol": "XYZ271217C00055000",
6993 "asset_class": "us_option",
6994 "qty": "2",
6995 "qty_available": "2",
6996 "side": "long",
6997 "avg_entry_price": "1.35",
6998 "unrealized_pl": "-20",
6999 },
7000 {
7001 "symbol": "ABC",
7002 "asset_class": "us_equity",
7003 "qty": "0.125",
7004 "qty_available": "0.125",
7005 "side": "long",
7006 "avg_entry_price": "80",
7007 "unrealized_pl": null,
7008 },
7009 ]))
7010 .unwrap();
7011
7012 let converted = convert_positions(&positions, Utc::now()).unwrap();
7013 let [
7014 (short_sym, short),
7015 (option_sym, option),
7016 (fraction_sym, fraction),
7017 ] = converted.as_slice()
7018 else {
7019 panic!("expected three positions, got {converted:?}");
7020 };
7021
7022 assert_eq!(*short_sym, "XYZ");
7023 assert_eq!(short.quantity, dec!(-7));
7024 assert_eq!(short.entry_price, Some(dec!(50.25)));
7025 assert!(short.is_short());
7026
7027 assert_eq!(*option_sym, "XYZ271217C00055000");
7028 assert_eq!(option.quantity, dec!(2));
7029 assert_eq!(option.entry_price, Some(dec!(1.35)), "per-share premium");
7030 assert_eq!(option.unrealized_pnl, Some(dec!(-20)));
7031
7032 assert_eq!(*fraction_sym, "ABC");
7033 assert_eq!(fraction.quantity, dec!(0.125));
7034 assert_eq!(fraction.unrealized_pnl, None);
7035
7036 for (_, p) in &converted {
7037 assert_eq!(p.margin_used, None);
7038 assert_eq!(p.liquidation_price, None);
7039 assert_eq!(p.leverage, None);
7040 }
7041 }
7042
7043 #[test]
7046 fn test_alpaca_position_unknown_side_fails_only_where_a_direction_is_needed() {
7047 let positions: Vec<AlpacaPosition> = serde_json::from_value(serde_json::json!([
7048 {
7049 "symbol": "BTC/USD",
7050 "asset_class": "crypto",
7051 "qty": "0.5",
7052 "qty_available": "0.5",
7053 },
7054 {
7055 "symbol": "XYZ",
7056 "asset_class": "us_equity",
7057 "qty": "1",
7058 "qty_available": "1",
7059 "side": "sideways",
7060 },
7061 ]))
7062 .unwrap();
7063 assert_eq!(positions[0].side, AlpacaPositionSide::Unknown);
7064 assert_eq!(positions[1].side, AlpacaPositionSide::Unknown);
7065
7066 let balances = convert_positions_to_balances(&positions, &[]);
7067 assert_eq!(balances.len(), 1);
7068 assert_eq!(balances[0].balance.total, dec!(0.5));
7069
7070 let result = convert_positions(&positions, Utc::now());
7071 assert!(
7072 matches!(&result, Err(UnindexedClientError::Internal(msg)) if msg.contains("XYZ")),
7073 "an unknown side must not guess a direction: {result:?}"
7074 );
7075 }
7076
7077 #[test]
7079 fn order_state_time_prefers_updated_at() {
7080 let mut resp = make_order_response("ord-1", "SPY");
7081 resp.created_at = "2025-04-18T14:30:00Z".to_string();
7082 resp.updated_at = Some("2025-04-18T15:45:00Z".to_string());
7083 assert_eq!(
7084 order_state_time(&resp),
7085 parse_timestamp("2025-04-18T15:45:00Z").unwrap(),
7086 "updated_at must win over created_at"
7087 );
7088
7089 resp.updated_at = None;
7091 assert_eq!(
7092 order_state_time(&resp),
7093 parse_timestamp("2025-04-18T14:30:00Z").unwrap(),
7094 "absent updated_at falls back to created_at"
7095 );
7096
7097 resp.updated_at = Some("not-a-timestamp".to_string());
7100 assert_eq!(
7101 order_state_time(&resp),
7102 parse_timestamp("2025-04-18T14:30:00Z").unwrap(),
7103 "unparseable updated_at falls back to created_at"
7104 );
7105 }
7106
7107 #[test]
7110 fn a_placement_response_without_a_status_reads_from_what_filled() {
7111 let instrument = InstrumentNameExchange::new("SPY");
7112 let mut resp = make_order_response("ord-1", "SPY");
7113 resp.filled_qty = "0.4".to_string();
7114 let OrderState::Active(ActiveOrderState::Open(open)) =
7115 placed_order_state(&resp, &instrument, Decimal::ONE)
7116 else {
7117 panic!("expected Open");
7118 };
7119 assert_eq!(open.filled_quantity, Decimal::new(4, 1));
7120
7121 resp.filled_qty = "1".to_string();
7122 resp.filled_avg_price = Some("100.5".to_string());
7123 let state = placed_order_state(&resp, &instrument, Decimal::ONE);
7124 let OrderState::Inactive(InactiveOrderState::FullyFilled(filled)) = state else {
7125 panic!("expected FullyFilled, got {state:?}");
7126 };
7127 assert_eq!(filled.filled_quantity, Decimal::ONE);
7128 assert_eq!(filled.avg_price, Some(Decimal::new(1005, 1)));
7129 }
7130
7131 fn make_order_response(id: &str, symbol: &str) -> AlpacaOrderResponse {
7132 AlpacaOrderResponse {
7133 id: id.to_string(),
7134 client_order_id: None,
7135 symbol: symbol.to_string(),
7136 qty: Some("1".to_string()),
7137 filled_qty: "0".to_string(),
7138 filled_avg_price: None,
7139 status: None,
7140 side: "buy".to_string(),
7141 order_type: "limit".to_string(),
7142 time_in_force: "day".to_string(),
7143 limit_price: Some("100.00".to_string()),
7144 stop_price: None,
7145 trail_percent: None,
7146 trail_price: None,
7147 created_at: "2025-04-18T14:30:00Z".to_string(),
7148 updated_at: None,
7149 }
7150 }
7151
7152 #[test]
7153 fn test_build_instrument_snapshots_empty_instruments_returns_only_with_orders() {
7154 let orders = vec![
7155 make_order_response("o1", "AAPL"),
7156 make_order_response("o2", "SPY"),
7157 ];
7158 let snapshots = build_instrument_snapshots(orders, Vec::new(), &[]);
7159 assert_eq!(snapshots.len(), 2);
7160 let symbols: Vec<&str> = snapshots
7161 .iter()
7162 .map(|s| s.instrument.name().as_str())
7163 .collect();
7164 assert!(symbols.contains(&"AAPL"));
7165 assert!(symbols.contains(&"SPY"));
7166 }
7167
7168 #[test]
7169 fn test_build_instrument_snapshots_requested_instrument_no_orders_gets_empty_snapshot() {
7170 let orders = vec![make_order_response("o1", "AAPL")];
7173 let instruments = vec![
7174 InstrumentNameExchange::new("AAPL"),
7175 InstrumentNameExchange::new("SPY"),
7176 ];
7177 let snapshots = build_instrument_snapshots(orders, Vec::new(), &instruments);
7178 assert_eq!(snapshots.len(), 2);
7179 let spy = snapshots
7180 .iter()
7181 .find(|s| s.instrument.name().as_str() == "SPY")
7182 .expect("SPY snapshot must be present even with no orders");
7183 assert!(spy.orders.is_empty());
7184 }
7185
7186 fn make_notional_order_response(id: &str, symbol: &str) -> AlpacaOrderResponse {
7188 AlpacaOrderResponse {
7189 qty: None,
7190 ..make_order_response(id, symbol)
7191 }
7192 }
7193
7194 fn snapshot_for<'a>(
7195 snapshots: &'a [InstrumentAccountSnapshot<
7196 ExchangeId,
7197 AssetNameExchange,
7198 InstrumentNameExchange,
7199 >],
7200 symbol: &str,
7201 ) -> &'a InstrumentAccountSnapshot<ExchangeId, AssetNameExchange, InstrumentNameExchange> {
7202 snapshots
7203 .iter()
7204 .find(|snapshot| snapshot.instrument.name().as_str() == symbol)
7205 .unwrap_or_else(|| panic!("no snapshot for {symbol}"))
7206 }
7207
7208 #[test]
7212 fn test_build_instrument_snapshots_declares_complete_lists_complete() {
7213 let instruments = [
7214 InstrumentNameExchange::new("AAPL"),
7215 InstrumentNameExchange::new("SPY"),
7216 ];
7217 for requested in [&instruments[..], &[]] {
7218 let orders = vec![make_order_response("o1", "AAPL")];
7219 let snapshots = build_instrument_snapshots(orders, Vec::new(), requested);
7220 assert!(!snapshots.is_empty());
7221 assert!(snapshots.iter().all(|snapshot| snapshot.orders_complete));
7222 }
7223 }
7224
7225 #[test]
7229 fn test_build_instrument_snapshots_notional_order_leaves_only_its_instrument_incomplete() {
7230 let orders = vec![
7231 make_order_response("o1", "AAPL"),
7232 make_order_response("o2", "SPY"),
7233 make_notional_order_response("o3", "SPY"),
7234 ];
7235 let instruments = [
7236 InstrumentNameExchange::new("AAPL"),
7237 InstrumentNameExchange::new("SPY"),
7238 ];
7239 let snapshots = build_instrument_snapshots(orders, Vec::new(), &instruments);
7240
7241 assert!(snapshot_for(&snapshots, "AAPL").orders_complete);
7242 let spy = snapshot_for(&snapshots, "SPY");
7243 assert!(!spy.orders_complete);
7244 assert_eq!(spy.orders.len(), 1);
7245 }
7246
7247 #[test]
7250 fn test_build_instrument_snapshots_unfiltered_lists_an_instrument_with_only_a_notional_order() {
7251 let orders = vec![make_notional_order_response("o1", "SPY")];
7252 let snapshots = build_instrument_snapshots(orders, Vec::new(), &[]);
7253
7254 let spy = snapshot_for(&snapshots, "SPY");
7255 assert!(!spy.orders_complete);
7256 assert!(spy.orders.is_empty());
7257 }
7258
7259 #[test]
7260 fn test_build_instrument_snapshots_non_requested_instrument_excluded() {
7261 let orders = vec![
7264 make_order_response("o1", "AAPL"),
7265 make_order_response("o2", "MSFT"), ];
7267 let instruments = vec![InstrumentNameExchange::new("AAPL")];
7268 let snapshots = build_instrument_snapshots(orders, Vec::new(), &instruments);
7269 assert_eq!(snapshots.len(), 1);
7270 assert_eq!(snapshots[0].instrument.name().as_str(), "AAPL");
7271 }
7272
7273 fn position(quantity: Decimal) -> Position {
7274 Position::new(
7275 quantity,
7276 Some(dec!(100)),
7277 None,
7278 None,
7279 None,
7280 None,
7281 None,
7282 Utc::now(),
7283 )
7284 }
7285
7286 #[test]
7287 fn test_build_instrument_snapshots_unfiltered_lists_positions_without_orders() {
7288 let orders = vec![
7289 make_order_response("o1", "AAPL"),
7290 make_order_response("o2", "MSFT"),
7291 make_order_response("o3", "BTC/USD"),
7292 ];
7293 let positions = vec![("AAPL", position(dec!(5))), ("TSLA", position(dec!(-2)))];
7294
7295 let snapshots = build_instrument_snapshots(orders, positions, &[]);
7296 let listed: Vec<_> = snapshots
7297 .iter()
7298 .map(|s| {
7299 (
7300 s.instrument.name().as_str(),
7301 s.orders.len(),
7302 s.position.quantity(),
7303 )
7304 })
7305 .collect();
7306 assert_eq!(
7308 listed,
7309 vec![
7310 ("AAPL", 1, Some(dec!(5))),
7311 ("MSFT", 1, Some(Decimal::ZERO)),
7312 ("BTC/USD", 1, None),
7313 ("TSLA", 0, Some(dec!(-2)))
7314 ]
7315 );
7316 assert!(snapshots.iter().all(|s| s.orders_complete));
7317 }
7318
7319 #[test]
7320 fn test_build_instrument_snapshots_filtered_attaches_only_requested_positions() {
7321 let positions = vec![("AAPL", position(dec!(5))), ("TSLA", position(dec!(-2)))];
7322 let instruments = vec![
7323 InstrumentNameExchange::new("tsla"),
7324 InstrumentNameExchange::new("MSFT"),
7325 InstrumentNameExchange::new("BTC/USD"),
7326 InstrumentNameExchange::new("TSLA"),
7327 ];
7328
7329 let snapshots = build_instrument_snapshots(Vec::new(), positions, &instruments);
7330 let listed: Vec<_> = snapshots
7331 .iter()
7332 .map(|s| (s.instrument.name().as_str(), s.position.quantity()))
7333 .collect();
7334 assert_eq!(
7338 listed,
7339 vec![
7340 ("tsla", Some(dec!(-2))),
7341 ("MSFT", Some(Decimal::ZERO)),
7342 ("BTC/USD", None),
7343 ("TSLA", None)
7344 ]
7345 );
7346 assert!(!snapshots[3].orders_complete);
7347 }
7348
7349 #[test]
7355 fn test_recover_fills_dedup_key_matches_ws_path() {
7356 let order_id = "ord-1";
7357
7358 let ws_keys: Vec<SmolStr> = ["1", "2"]
7361 .iter()
7362 .map(|filled_qty| {
7363 early_dedup_key(&AlpacaTradeUpdate {
7364 event: SmolStr::new("partial_fill"),
7365 execution_id: None,
7366 previous_execution_id: None,
7367 order: make_order_ws(order_id, "SPY", "buy", filled_qty),
7368 price: Some("150.00"),
7369 qty: Some("1"),
7370 timestamp: None,
7371 })
7372 })
7373 .collect();
7374
7375 let mut cumulative = Decimal::ZERO;
7378 let rest_keys: Vec<SmolStr> = ["1", "1"]
7379 .iter()
7380 .map(|exec_qty| {
7381 cumulative += Decimal::from_str(exec_qty).unwrap();
7382 fill_dedup_key(order_id, cumulative)
7383 })
7384 .collect();
7385
7386 assert_eq!(
7387 ws_keys, rest_keys,
7388 "REST recovery dedup keys must match WS path keys for cross-source dedup to work"
7389 );
7390 assert_eq!(ws_keys[0].as_str(), "ord-1:1");
7391 assert_eq!(ws_keys[1].as_str(), "ord-1:2");
7392 }
7393
7394 #[test]
7399 fn a_ws_fill_without_an_execution_id_is_identified_by_its_dedup_key() {
7400 let update = AlpacaTradeUpdate {
7401 event: SmolStr::new("fill"),
7402 execution_id: None,
7403 previous_execution_id: None,
7404 order: make_order_ws("ord-abc", "SPY", "buy", "5"),
7405 price: Some("150.00"),
7406 qty: Some("5"),
7407 timestamp: None,
7408 };
7409
7410 let early_key = early_dedup_key(&update);
7412
7413 let [event, _snapshot] = convert_trade_update(update);
7415 let Some(UnindexedAccountEvent {
7416 kind: AccountEventKind::Trade(trade),
7417 ..
7418 }) = event
7419 else {
7420 panic!("fill should produce an execution");
7421 };
7422
7423 assert_eq!(
7424 trade.id.0.as_str(),
7425 early_key.as_str(),
7426 "without an execution_id the TradeId is the dedup key"
7427 );
7428 assert_eq!(early_key.as_str(), "ord-abc:5");
7429 }
7430
7431 #[test]
7436 fn early_dedup_key_normalizes_decimal_strings() {
7437 let update1 = AlpacaTradeUpdate {
7439 event: SmolStr::new("fill"),
7440 execution_id: None,
7441 previous_execution_id: None,
7442 order: AlpacaOrderWs {
7443 id: SmolStr::new("ord-x"),
7444 client_order_id: Some(SmolStr::new("cid")),
7445 symbol: SmolStr::new("AAPL"),
7446 qty: Some("10"),
7447 filled_qty: Some("1.00"),
7448 side: SmolStr::new("buy"),
7449 order_type: SmolStr::new("market"),
7450 time_in_force: SmolStr::new("day"),
7451 limit_price: None,
7452 stop_price: None,
7453 trail_percent: None,
7454 trail_price: None,
7455 status: SmolStr::new("filled"),
7456 },
7457 price: Some("100.00"),
7458 qty: Some("10"),
7459 timestamp: None,
7460 };
7461 assert_eq!(early_dedup_key(&update1).as_str(), "ord-x:1");
7462
7463 let update2 = AlpacaTradeUpdate {
7465 event: SmolStr::new("fill"),
7466 execution_id: None,
7467 previous_execution_id: None,
7468 order: AlpacaOrderWs {
7469 id: SmolStr::new("ord-x"),
7470 client_order_id: Some(SmolStr::new("cid")),
7471 symbol: SmolStr::new("AAPL"),
7472 qty: Some("10"),
7473 filled_qty: Some("1"),
7474 side: SmolStr::new("buy"),
7475 order_type: SmolStr::new("market"),
7476 time_in_force: SmolStr::new("day"),
7477 limit_price: None,
7478 stop_price: None,
7479 trail_percent: None,
7480 trail_price: None,
7481 status: SmolStr::new("filled"),
7482 },
7483 price: Some("100.00"),
7484 qty: Some("10"),
7485 timestamp: None,
7486 };
7487 assert_eq!(early_dedup_key(&update2).as_str(), "ord-x:1");
7488
7489 let update3 = AlpacaTradeUpdate {
7491 event: SmolStr::new("fill"),
7492 execution_id: None,
7493 previous_execution_id: None,
7494 order: AlpacaOrderWs {
7495 id: SmolStr::new("ord-x"),
7496 client_order_id: Some(SmolStr::new("cid")),
7497 symbol: SmolStr::new("AAPL"),
7498 qty: Some("10"),
7499 filled_qty: Some("1.0"),
7500 side: SmolStr::new("buy"),
7501 order_type: SmolStr::new("market"),
7502 time_in_force: SmolStr::new("day"),
7503 limit_price: None,
7504 stop_price: None,
7505 trail_percent: None,
7506 trail_price: None,
7507 status: SmolStr::new("filled"),
7508 },
7509 price: Some("100.00"),
7510 qty: Some("10"),
7511 timestamp: None,
7512 };
7513 assert_eq!(early_dedup_key(&update3).as_str(), "ord-x:1");
7514 }
7515
7516 #[test]
7521 fn process_ws_text_rejected_event_without_filled_qty_is_not_dropped() {
7522 let (tx, mut rx) = mpsc::unbounded_channel();
7523 let dedup = new_dedup_cache();
7524
7525 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"}}}"#;
7527
7528 let known = KnownLiveOrders::shared(ExchangeId::AlpacaBroker);
7529 process_ws_text(json, &tx, &dedup, &known);
7530
7531 let event = rx.try_recv()
7533 .expect("rejected event without filled_qty must produce an AccountEvent, not be silently dropped");
7534 assert!(
7535 matches!(event.kind, AccountEventKind::OrderCancelled(_)),
7536 "rejected event must map to OrderCancelled, got: {:?}",
7537 event.kind
7538 );
7539 }
7540
7541 #[test]
7544 fn process_ws_text_identifies_a_fill_by_its_execution_id() {
7545 let frame = |execution_id: &str| {
7546 format!(
7547 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"}}}}"#
7548 )
7549 };
7550 let cases = [
7551 (
7552 r#""execution_id":"524b1902-817e-446c-825b-a9fcfebbc17e","#,
7553 "524b1902-817e-446c-825b-a9fcfebbc17e",
7554 ),
7555 (r#""execution_id":null,"#, "ord-1:2"),
7556 (r#""execution_id":"","#, "ord-1:2"),
7557 ("", "ord-1:2"),
7558 ];
7559 for (field, expected) in cases {
7560 let (tx, mut rx) = mpsc::unbounded_channel();
7561 let known = KnownLiveOrders::shared(ExchangeId::AlpacaBroker);
7562 process_ws_text(&frame(field), &tx, &new_dedup_cache(), &known);
7563 let Ok(UnindexedAccountEvent {
7564 kind: AccountEventKind::Trade(trade),
7565 ..
7566 }) = rx.try_recv()
7567 else {
7568 panic!("the fill frame {field:?} produces a Trade");
7569 };
7570 assert_eq!(trade.id.0.as_str(), expected, "frame {field:?}");
7571 }
7572 }
7573
7574 fn amendment_frame(event: &str, fields: &str) -> String {
7577 format!(
7578 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"}}}}"#
7579 )
7580 }
7581
7582 fn stream_events(frames: &[String]) -> Vec<UnindexedAccountEvent> {
7584 let (tx, mut rx) = mpsc::unbounded_channel();
7585 let dedup = new_dedup_cache();
7586 let known = KnownLiveOrders::shared(ExchangeId::AlpacaBroker);
7587 for frame in frames {
7588 process_ws_text(frame, &tx, &dedup, &known);
7589 }
7590 std::iter::from_fn(|| rx.try_recv().ok()).collect()
7591 }
7592
7593 fn amendment_of(
7595 frame: String,
7596 ) -> crate::trade::TradeAmendment<AssetNameExchange, InstrumentNameExchange> {
7597 let events = stream_events(&[frame]);
7598 let [
7599 UnindexedAccountEvent {
7600 kind: AccountEventKind::TradeAmended(amendment),
7601 ..
7602 },
7603 ] = <[_; 1]>::try_from(events).unwrap_or_else(|events| {
7604 panic!("one TradeAmended, got {events:?}");
7605 })
7606 else {
7607 panic!("expected TradeAmended");
7608 };
7609 amendment
7610 }
7611
7612 #[test]
7615 fn process_ws_text_reports_a_trade_bust() {
7616 let amendment = amendment_of(amendment_frame(
7617 "trade_bust",
7618 r#""execution_id":"exec-x","previous_execution_id":"exec-a","qty":"-2","#,
7619 ));
7620 assert_eq!(amendment.original, Some(TradeId::new("exec-a")));
7621 assert_eq!(amendment.order_id, OrderId::new("ord-1"));
7622 assert_eq!(amendment.instrument, InstrumentNameExchange::new("SPY"));
7623 assert_eq!(
7624 Some(amendment.time_exchange),
7625 parse_timestamp("2025-04-18T14:31:00Z")
7626 );
7627 assert_eq!(
7628 amendment.kind,
7629 TradeAmendmentKind::Busted {
7630 quantity: Some(Decimal::TWO)
7631 }
7632 );
7633 }
7634
7635 #[test]
7637 fn process_ws_text_reports_a_bust_that_names_no_fill() {
7638 let amendment = amendment_of(amendment_frame("trade_bust", ""));
7639 assert_eq!(amendment.original, None);
7640 assert_eq!(
7641 amendment.kind,
7642 TradeAmendmentKind::Busted { quantity: None }
7643 );
7644 }
7645
7646 #[test]
7649 fn process_ws_text_reports_a_trade_correct_with_its_replacement() {
7650 let amendment = amendment_of(amendment_frame(
7651 "trade_correct",
7652 r#""execution_id":"exec-c","previous_execution_id":"exec-a","price":"101.50","qty":"2","#,
7653 ));
7654 assert_eq!(amendment.original, Some(TradeId::new("exec-a")));
7655 let TradeAmendmentKind::Corrected { replacement } = amendment.kind else {
7656 panic!("a full replacement: {:?}", amendment.kind);
7657 };
7658 assert_eq!(replacement.id, TradeId::new("exec-c"));
7659 assert_eq!(replacement.order_id, OrderId::new("ord-1"));
7660 assert_eq!(replacement.side, Side::Buy);
7661 assert_eq!(replacement.price, Decimal::new(10150, 2));
7662 assert_eq!(replacement.quantity, Decimal::TWO);
7663 assert_eq!(replacement.order_filled_quantity, None);
7664 assert_eq!(replacement.time_exchange, amendment.time_exchange);
7665 }
7666
7667 #[test]
7670 fn process_ws_text_reports_a_correction_it_cannot_resolve() {
7671 let price = Some(Decimal::new(10150, 2));
7672 let cases = [
7673 (
7674 r#""execution_id":"exec-c","qty":"2","#,
7675 (Some("exec-c"), None, Some(Decimal::TWO)),
7676 ),
7677 (
7678 r#""price":"101.50","qty":"2","#,
7679 (None, price, Some(Decimal::TWO)),
7680 ),
7681 (
7682 r#""execution_id":"exec-c","price":"101.50","qty":"0","#,
7683 (Some("exec-c"), price, Some(Decimal::ZERO)),
7684 ),
7685 (
7686 r#""execution_id":"exec-c","price":"101.50","#,
7687 (Some("exec-c"), price, None),
7688 ),
7689 ];
7690 for (fields, (id, price, quantity)) in cases {
7691 let amendment = amendment_of(amendment_frame("trade_correct", fields));
7692 assert_eq!(
7693 amendment.kind,
7694 TradeAmendmentKind::CorrectedUnresolved {
7695 id: id.map(TradeId::new),
7696 price,
7697 quantity,
7698 },
7699 "{fields}"
7700 );
7701 }
7702 }
7703
7704 #[test]
7707 fn a_fill_after_a_bust_is_delivered_at_a_cumulative_seen_before() {
7708 let fill = |execution_id: &str| {
7709 format!(
7710 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"}}}}"#
7711 )
7712 };
7713 let events = stream_events(&[
7714 fill("exec-a"),
7715 amendment_frame(
7716 "trade_bust",
7717 r#""execution_id":"exec-x","previous_execution_id":"exec-a","qty":"-2","#,
7718 ),
7719 fill("exec-b"),
7720 ]);
7721
7722 let kinds: Vec<_> = events
7723 .iter()
7724 .filter_map(|event| match &event.kind {
7725 AccountEventKind::Trade(trade) => Some(format!("trade {}", trade.id)),
7726 AccountEventKind::TradeAmended(amendment) => {
7727 Some(format!("amended {:?}", amendment.original))
7728 }
7729 _ => None,
7730 })
7731 .collect();
7732 assert_eq!(
7733 kinds,
7734 vec![
7735 "trade exec-a",
7736 "amended Some(TradeId(\"exec-a\"))",
7737 "trade exec-b"
7738 ]
7739 );
7740 }
7741
7742 #[test]
7745 fn decimal_zero_normalize_is_zero_str() {
7746 assert_eq!(Decimal::ZERO.normalize().to_string(), "0");
7747 }
7748
7749 #[test]
7753 fn decimal_normalize_strips_trailing_zeros() {
7754 let from_rest = Decimal::from_str("1.00").unwrap().normalize();
7756 let from_ws = Decimal::from_str("1").unwrap().normalize();
7757 assert_eq!(from_rest.to_string(), from_ws.to_string());
7758 assert_eq!(from_rest.to_string(), "1");
7759
7760 assert_eq!(
7762 Decimal::from_str("100.000")
7763 .unwrap()
7764 .normalize()
7765 .to_string(),
7766 "100"
7767 );
7768 assert_eq!(
7769 Decimal::from_str("0.10").unwrap().normalize().to_string(),
7770 "0.1"
7771 );
7772 assert_eq!(
7773 Decimal::from_str("0.100").unwrap().normalize().to_string(),
7774 "0.1"
7775 );
7776 }
7777
7778 #[test]
7780 fn map_position_intent_open_buy_maps_to_buy_to_open() {
7781 assert_eq!(
7782 map_position_intent(Side::Buy, false),
7783 AlpacaPositionIntent::BuyToOpen
7784 );
7785 }
7786
7787 #[test]
7788 fn map_position_intent_open_sell_maps_to_sell_to_open() {
7789 assert_eq!(
7790 map_position_intent(Side::Sell, false),
7791 AlpacaPositionIntent::SellToOpen
7792 );
7793 }
7794
7795 #[test]
7796 fn map_position_intent_reduce_buy_maps_to_buy_to_close() {
7797 assert_eq!(
7798 map_position_intent(Side::Buy, true),
7799 AlpacaPositionIntent::BuyToClose
7800 );
7801 }
7802
7803 #[test]
7804 fn map_position_intent_reduce_sell_maps_to_sell_to_close() {
7805 assert_eq!(
7806 map_position_intent(Side::Sell, true),
7807 AlpacaPositionIntent::SellToClose
7808 );
7809 }
7810
7811 #[test]
7813 fn parse_order_error_401_maps_to_unauthenticated() {
7814 assert!(matches!(
7816 parse_order_error(reqwest::StatusCode::UNAUTHORIZED, None, "bad credentials"),
7817 UnindexedOrderError::Rejected(ApiError::Unauthenticated(_))
7818 ));
7819 }
7820
7821 #[test]
7822 fn parse_order_error_403_without_code_maps_to_unauthenticated() {
7823 assert!(matches!(
7825 parse_order_error(reqwest::StatusCode::FORBIDDEN, None, "forbidden."),
7826 UnindexedOrderError::Rejected(ApiError::Unauthenticated(_))
7827 ));
7828 }
7829
7830 #[test]
7831 fn parse_order_error_403_with_code_and_insufficient_maps_to_balance_insufficient() {
7832 for message in [
7833 "insufficient buying power",
7834 "insufficient day trading buying power",
7835 "insufficient qty available for order (requested: 10, available: 0)",
7836 ] {
7837 let err = parse_order_error(reqwest::StatusCode::FORBIDDEN, Some(40310000), message);
7838 let UnindexedOrderError::Rejected(ApiError::BalanceInsufficient(None, text)) = err
7839 else {
7840 panic!("expected BalanceInsufficient for {message:?}, got {err:?}");
7841 };
7842 assert_eq!(text, format!("40310000 {message}"));
7843 }
7844 }
7845
7846 #[test]
7847 fn parse_order_error_reads_a_refused_short_by_its_text() {
7848 for (status, code, message, reason) in [
7849 (
7850 reqwest::StatusCode::FORBIDDEN,
7851 Some(40310000),
7852 "account is not allowed to short",
7853 BorrowRejectReason::BorrowDisabled,
7854 ),
7855 (
7857 reqwest::StatusCode::FORBIDDEN,
7858 Some(40310000),
7859 "asset XYZ cannot be sold short",
7860 BorrowRejectReason::NotShortable,
7861 ),
7862 (
7863 reqwest::StatusCode::UNPROCESSABLE_ENTITY,
7864 Some(42210000),
7865 "asset XYZ cannot be sold short",
7866 BorrowRejectReason::NotShortable,
7867 ),
7868 (
7869 reqwest::StatusCode::UNPROCESSABLE_ENTITY,
7870 None,
7871 "account is not allowed to short",
7872 BorrowRejectReason::BorrowDisabled,
7873 ),
7874 ] {
7875 assert_eq!(
7876 parse_order_error(status, code, message),
7877 UnindexedOrderError::Rejected(ApiError::BorrowRejected(BorrowReject::new(
7878 reason,
7879 code.map(|code| code.to_string()),
7880 message.to_owned(),
7881 ))),
7882 "{status} {message:?}"
7883 );
7884 }
7885
7886 for (status, code) in [
7889 (reqwest::StatusCode::UNPROCESSABLE_ENTITY, 42210000),
7890 (reqwest::StatusCode::FORBIDDEN, 40310000),
7891 ] {
7892 assert_eq!(
7893 parse_order_error(status, Some(code), "fractional orders cannot be sold short"),
7894 UnindexedOrderError::Rejected(ApiError::OrderRejected(format!(
7895 "{code} fractional orders cannot be sold short"
7896 ))),
7897 "{status}"
7898 );
7899 }
7900
7901 assert!(matches!(
7903 parse_order_error(
7904 reqwest::StatusCode::FORBIDDEN,
7905 Some(40310000),
7906 "insufficient qty: account is not allowed to short",
7907 ),
7908 UnindexedOrderError::Rejected(ApiError::BorrowRejected(_))
7909 ));
7910
7911 assert!(matches!(
7913 parse_order_error(
7914 reqwest::StatusCode::FORBIDDEN,
7915 None,
7916 "account is not allowed to short"
7917 ),
7918 UnindexedOrderError::Rejected(ApiError::Unauthenticated(_))
7919 ));
7920 }
7921
7922 #[test]
7923 fn parse_order_error_403_with_code_maps_to_order_rejected() {
7924 for (code, message) in [
7925 (40310000, "account is restricted to liquidation only"),
7926 (
7927 40310100,
7928 "trade denied due to pattern day trading protection",
7929 ),
7930 ] {
7931 let err = parse_order_error(reqwest::StatusCode::FORBIDDEN, Some(code), message);
7932 let UnindexedOrderError::Rejected(ApiError::OrderRejected(text)) = err else {
7933 panic!("expected OrderRejected for {message:?}, got {err:?}");
7934 };
7935 assert_eq!(text, format!("{code} {message}"));
7936 }
7937 }
7938
7939 #[test]
7940 fn alpaca_api_error_reads_code_and_falls_back_to_raw_text() {
7941 let coded = AlpacaApiError::from_body(
7942 br#"{"code": 40310000, "message": "insufficient buying power"}"#,
7943 );
7944 assert_eq!(coded.code, Some(40310000));
7945 assert_eq!(coded.message, "insufficient buying power");
7946
7947 let bare = AlpacaApiError::from_body(br#"{"message": "forbidden."}"#);
7948 assert_eq!(bare.code, None);
7949 assert_eq!(bare.message, "forbidden.");
7950
7951 let raw = AlpacaApiError::from_body(b"upstream connect error");
7952 assert_eq!(raw.code, None);
7953 assert_eq!(raw.message, "upstream connect error");
7954 }
7955
7956 #[test]
7957 fn parse_order_error_404_maps_to_order_rejected_with_not_found_prefix() {
7958 let err = parse_order_error(reqwest::StatusCode::NOT_FOUND, None, "order not found");
7959 let UnindexedOrderError::Rejected(ApiError::OrderRejected(msg)) = err else {
7960 panic!("expected OrderRejected, got {err:?}");
7961 };
7962 assert!(
7963 msg.contains("order not found"),
7964 "message should contain 'order not found': {msg}"
7965 );
7966 }
7967
7968 #[test]
7969 fn parse_order_error_422_insufficient_only_maps_to_balance_insufficient() {
7970 assert!(matches!(
7972 parse_order_error(
7973 reqwest::StatusCode::UNPROCESSABLE_ENTITY,
7974 None,
7975 "insufficient funds for this order"
7976 ),
7977 UnindexedOrderError::Rejected(ApiError::BalanceInsufficient(None, _))
7978 ));
7979 }
7980
7981 #[test]
7982 fn parse_order_error_429_maps_to_rate_limit() {
7983 assert!(matches!(
7984 parse_order_error(reqwest::StatusCode::TOO_MANY_REQUESTS, None, "rate limited"),
7985 UnindexedOrderError::Rejected(ApiError::RateLimit {
7986 retry_after: None,
7987 ..
7988 })
7989 ));
7990 }
7991
7992 #[test]
7993 fn a_rate_limit_delay_is_capped_at_alpacas_window() {
7994 let window = Some(Duration::from_secs(DEFAULT_RATE_LIMIT_DELAY_SECS));
7995 for (name, value) in [("retry-after", u64::MAX), ("x-ratelimit-reset", u64::MAX)] {
7996 let mut headers = reqwest::header::HeaderMap::new();
7997 headers.insert(name, value.to_string().parse().unwrap());
7998 assert_eq!(parse_rate_limit_delay(&headers), window, "{name}");
7999 RateLimitTracker::new().on_rate_limited(parse_rate_limit_delay(&headers));
8001 }
8002
8003 let mut headers = reqwest::header::HeaderMap::new();
8004 headers.insert("retry-after", "0".parse().unwrap());
8005 assert_eq!(
8006 parse_rate_limit_delay(&headers),
8007 Some(Duration::ZERO),
8008 "taken as given"
8009 );
8010 }
8011
8012 #[test]
8016 fn parse_time_in_force_unknown_value_falls_back_to_good_until_end_of_day() {
8017 assert_eq!(
8018 parse_time_in_force("opg"),
8019 TimeInForce::GoodUntilEndOfDay,
8020 "unknown TIF must fall back to GoodUntilEndOfDay (with a warn! in production)"
8021 );
8022 }
8023
8024 #[test]
8027 fn build_instrument_snapshots_output_order_matches_instruments_slice() {
8028 let orders = vec![
8030 make_order_response("o1", "SPY"),
8031 make_order_response("o2", "AAPL"),
8032 make_order_response("o3", "MSFT"),
8033 ];
8034 let instruments = vec![
8036 InstrumentNameExchange::new("MSFT"),
8037 InstrumentNameExchange::new("AAPL"),
8038 ];
8039 let snapshots = build_instrument_snapshots(orders, Vec::new(), &instruments);
8040 assert_eq!(snapshots.len(), 2);
8041 assert_eq!(
8042 snapshots[0].instrument.name().as_str(),
8043 "MSFT",
8044 "first snapshot must be MSFT (first in instruments slice)"
8045 );
8046 assert_eq!(
8047 snapshots[1].instrument.name().as_str(),
8048 "AAPL",
8049 "second snapshot must be AAPL (second in instruments slice)"
8050 );
8051 }
8052
8053 mod http_tests {
8060 use super::super::*;
8061 use wiremock::matchers::{method, path};
8062 use wiremock::{Mock, MockServer, Request, Respond, ResponseTemplate};
8063
8064 struct Sequential {
8071 call: std::sync::atomic::AtomicU32,
8072 pages: Vec<serde_json::Value>,
8073 }
8074
8075 impl Sequential {
8076 fn new(pages: Vec<serde_json::Value>) -> Self {
8077 Self {
8078 call: std::sync::atomic::AtomicU32::new(0),
8079 pages,
8080 }
8081 }
8082 }
8083
8084 impl Respond for Sequential {
8085 fn respond(&self, _: &Request) -> ResponseTemplate {
8086 let i = self.call.fetch_add(1, std::sync::atomic::Ordering::Relaxed) as usize;
8087 let body = self.pages.get(i).unwrap_or_else(|| {
8088 panic!(
8089 "Sequential: request #{i} has no configured response \
8090 (only {} page(s) supplied)",
8091 self.pages.len()
8092 )
8093 });
8094 ResponseTemplate::new(200).set_body_json(body)
8095 }
8096 }
8097
8098 fn make_activities_json(count: usize, id_prefix: &str) -> serde_json::Value {
8100 serde_json::Value::Array(
8101 (0..count)
8102 .map(|i| {
8103 serde_json::json!({
8104 "id": format!("{id_prefix}-{i:05}"),
8105 "order_id": "ord-1",
8106 "symbol": "SPY",
8107 "side": "buy",
8108 "price": "100.00",
8109 "qty": "1",
8110 "transaction_time": "2025-04-18T14:30:00Z"
8111 })
8112 })
8113 .collect(),
8114 )
8115 }
8116
8117 fn make_orders_json(count: usize) -> serde_json::Value {
8119 serde_json::Value::Array(
8120 (0..count)
8121 .map(|i| {
8122 serde_json::json!({
8123 "id": format!("order-{i:05}"),
8124 "client_order_id": null,
8125 "symbol": "SPY",
8126 "qty": "1",
8127 "filled_qty": "0",
8128 "side": "buy",
8129 "type": "limit",
8130 "time_in_force": "day",
8131 "limit_price": "100.00",
8132 "created_at": "2025-04-18T14:30:00Z"
8133 })
8134 })
8135 .collect(),
8136 )
8137 }
8138
8139 #[tokio::test]
8144 async fn paginate_activities_single_page_below_max_returns_all_not_truncated() {
8145 let server = MockServer::start().await;
8146 Mock::given(method("GET"))
8147 .and(path("/v2/account/activities"))
8148 .respond_with(
8149 ResponseTemplate::new(200).set_body_json(make_activities_json(5, "act")),
8150 )
8151 .mount(&server)
8152 .await;
8153
8154 let http = reqwest::Client::new();
8155 let rl = RateLimitTracker::new();
8156 let result = paginate_activities::<AlpacaActivity>(
8157 &http,
8158 &rl,
8159 &server.uri(),
8160 FILL_ACTIVITIES,
8161 recovery_after(),
8162 None,
8163 )
8164 .await
8165 .unwrap();
8166
8167 assert_eq!(result.activities.len(), 5);
8168 assert_eq!(result.resume, None);
8169 assert_eq!(server.received_requests().await.unwrap().len(), 1);
8170 }
8171
8172 #[tokio::test]
8175 async fn paginate_activities_exactly_page_size_items_fetches_second_page() {
8176 let server = MockServer::start().await;
8177 Mock::given(method("GET"))
8178 .and(path("/v2/account/activities"))
8179 .respond_with(Sequential::new(vec![
8180 make_activities_json(ALPACA_MAX_ACTIVITIES, "act"),
8181 serde_json::json!([]), ]))
8183 .mount(&server)
8184 .await;
8185
8186 let http = reqwest::Client::new();
8187 let rl = RateLimitTracker::new();
8188 let result = paginate_activities::<AlpacaActivity>(
8189 &http,
8190 &rl,
8191 &server.uri(),
8192 FILL_ACTIVITIES,
8193 recovery_after(),
8194 None,
8195 )
8196 .await
8197 .unwrap();
8198
8199 assert_eq!(result.activities.len(), ALPACA_MAX_ACTIVITIES);
8200 assert_eq!(result.resume, None);
8201 assert_eq!(
8202 server.received_requests().await.unwrap().len(),
8203 2,
8204 "exactly 2 requests: first full page + second empty page"
8205 );
8206 }
8207
8208 #[tokio::test]
8211 async fn paginate_activities_two_pages_returns_combined_activities_not_truncated() {
8212 let server = MockServer::start().await;
8213 Mock::given(method("GET"))
8214 .and(path("/v2/account/activities"))
8215 .respond_with(Sequential::new(vec![
8216 make_activities_json(ALPACA_MAX_ACTIVITIES, "p1"),
8217 make_activities_json(37, "p2"),
8218 ]))
8219 .mount(&server)
8220 .await;
8221
8222 let http = reqwest::Client::new();
8223 let rl = RateLimitTracker::new();
8224 let result = paginate_activities::<AlpacaActivity>(
8225 &http,
8226 &rl,
8227 &server.uri(),
8228 FILL_ACTIVITIES,
8229 recovery_after(),
8230 None,
8231 )
8232 .await
8233 .unwrap();
8234
8235 assert_eq!(result.activities.len(), ALPACA_MAX_ACTIVITIES + 37);
8236 assert_eq!(result.resume, None);
8237 assert_eq!(server.received_requests().await.unwrap().len(), 2);
8238 }
8239
8240 #[tokio::test]
8244 async fn paginate_activities_at_max_pages_sets_truncated_true() {
8245 let server = MockServer::start().await;
8246
8247 Mock::given(method("GET"))
8249 .and(path("/v2/account/activities"))
8250 .respond_with(
8251 ResponseTemplate::new(200)
8252 .set_body_json(make_activities_json(ALPACA_MAX_ACTIVITIES, "act")),
8253 )
8254 .mount(&server)
8255 .await;
8256
8257 let http = reqwest::Client::new();
8258 let rl = RateLimitTracker::new();
8259 let result = paginate_activities::<AlpacaActivity>(
8260 &http,
8261 &rl,
8262 &server.uri(),
8263 FILL_ACTIVITIES,
8264 recovery_after(),
8265 None,
8266 )
8267 .await
8268 .unwrap();
8269
8270 assert!(
8271 result.resume.is_some(),
8272 "must say where to read on after MAX_ACTIVITY_PAGES pages"
8273 );
8274 assert_eq!(
8275 result.activities.len(),
8276 MAX_ACTIVITY_PAGES * ALPACA_MAX_ACTIVITIES,
8277 "must accumulate exactly MAX_ACTIVITY_PAGES * page_size activities"
8278 );
8279 assert_eq!(
8282 server.received_requests().await.unwrap().len(),
8283 MAX_ACTIVITY_PAGES,
8284 "loop must issue exactly MAX_ACTIVITY_PAGES requests then stop"
8285 );
8286 }
8287
8288 #[tokio::test]
8292 async fn fetch_raw_open_orders_499_results_returns_ok() {
8293 let server = MockServer::start().await;
8294 Mock::given(method("GET"))
8295 .and(path("/v2/orders"))
8296 .respond_with(
8297 ResponseTemplate::new(200).set_body_json(make_orders_json(MAX_OPEN_ORDERS - 1)),
8298 )
8299 .mount(&server)
8300 .await;
8301
8302 let http = reqwest::Client::new();
8303 let rl = RateLimitTracker::new();
8304 let result = fetch_raw_open_orders(&http, &rl, &server.uri(), &[]).await;
8305
8306 assert!(
8307 result.is_ok(),
8308 "499 orders must not trigger truncation: {result:?}"
8309 );
8310 assert_eq!(result.unwrap().len(), MAX_OPEN_ORDERS - 1);
8311 }
8312
8313 #[tokio::test]
8317 async fn fetch_raw_open_orders_500_results_returns_truncated_snapshot_error() {
8318 let server = MockServer::start().await;
8319 Mock::given(method("GET"))
8320 .and(path("/v2/orders"))
8321 .respond_with(
8322 ResponseTemplate::new(200).set_body_json(make_orders_json(MAX_OPEN_ORDERS)),
8323 )
8324 .mount(&server)
8325 .await;
8326
8327 let http = reqwest::Client::new();
8328 let rl = RateLimitTracker::new();
8329 let result = fetch_raw_open_orders(&http, &rl, &server.uri(), &[]).await;
8330
8331 assert!(
8332 matches!(
8333 result,
8334 Err(UnindexedClientError::TruncatedSnapshot { limit }) if limit == MAX_OPEN_ORDERS
8335 ),
8336 "500 orders must return TruncatedSnapshot, got: {result:?}"
8337 );
8338 }
8339
8340 async fn mount_account_and_positions(server: &MockServer) {
8345 Mock::given(method("GET"))
8346 .and(path("/v2/account"))
8347 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
8348 "status": "ACTIVE",
8349 "currency": "USD",
8350 "cash": "-1500.50",
8351 "equity": "4000.00",
8352 "buying_power": "5000.00",
8353 "options_buying_power": "2000.00",
8354 "non_marginable_buying_power": "1000.00",
8355 })))
8356 .mount(server)
8357 .await;
8358 Mock::given(method("GET"))
8359 .and(path("/v2/positions"))
8360 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([
8361 {
8362 "symbol": "XYZ",
8363 "asset_class": "us_equity",
8364 "side": "short",
8365 "qty": "-10",
8366 "qty_available": "-10",
8367 "avg_entry_price": "20.00",
8368 "unrealized_pl": "15.50",
8369 },
8370 {
8371 "symbol": "XYZ271217C00025000",
8372 "asset_class": "us_option",
8373 "side": "long",
8374 "qty": "3",
8375 "qty_available": "3",
8376 "avg_entry_price": "0.85",
8377 "unrealized_pl": "-45",
8378 },
8379 {
8380 "symbol": "ABC",
8381 "asset_class": "us_equity",
8382 "side": "long",
8383 "qty": "2.5",
8384 "qty_available": "2.5",
8385 "avg_entry_price": "40.00",
8386 "unrealized_pl": "1.25",
8387 },
8388 {
8389 "symbol": "BTCUSD",
8390 "asset_class": "crypto",
8391 "side": "long",
8392 "qty": "0.75",
8393 "qty_available": "0.5",
8394 "avg_entry_price": "50000",
8395 "unrealized_pl": "0",
8396 },
8397 ])))
8398 .mount(server)
8399 .await;
8400 Mock::given(method("GET"))
8401 .and(path("/v2/orders"))
8402 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([])))
8403 .mount(server)
8404 .await;
8405 }
8406
8407 fn client_for(server: &MockServer) -> AlpacaClient {
8408 AlpacaClient::new(AlpacaConfig::with_base_url(
8409 "test-key".into(),
8410 "test-secret".into(),
8411 server.uri(),
8412 ))
8413 }
8414
8415 #[tokio::test]
8416 async fn account_snapshot_reports_cash_positions_and_crypto_balances() {
8417 use crate::client::ExecutionClient;
8418 use rust_decimal_macros::dec;
8419
8420 let server = MockServer::start().await;
8421 mount_account_and_positions(&server).await;
8422
8423 let snapshot = client_for(&server)
8424 .account_snapshot(&[], &[])
8425 .await
8426 .unwrap();
8427
8428 let balances: Vec<_> = snapshot
8430 .balances
8431 .iter()
8432 .map(|b| (b.asset.name().as_str(), b.balance.total, b.balance.free))
8433 .collect();
8434 assert_eq!(
8435 balances,
8436 vec![
8437 ("USD", dec!(-1500.50), dec!(-1500.50)),
8438 ("BTC", dec!(0.75), dec!(0.5)),
8439 ],
8440 "crypto stays a balance; equities and options do not appear as balances"
8441 );
8442
8443 let positions: Vec<_> = snapshot
8444 .instruments
8445 .iter()
8446 .map(|i| {
8447 let p = i.position.open().unwrap();
8448 (
8449 i.instrument.name().as_str(),
8450 p.quantity,
8451 p.entry_price,
8452 p.unrealized_pnl,
8453 )
8454 })
8455 .collect();
8456 assert_eq!(
8457 positions,
8458 vec![
8459 ("XYZ", dec!(-10), Some(dec!(20.00)), Some(dec!(15.50))),
8460 (
8461 "XYZ271217C00025000",
8462 dec!(3),
8463 Some(dec!(0.85)),
8464 Some(dec!(-45))
8465 ),
8466 ("ABC", dec!(2.5), Some(dec!(40.00)), Some(dec!(1.25))),
8467 ],
8468 "short equity, option and fractional positions; no crypto position"
8469 );
8470 }
8471
8472 #[tokio::test]
8475 async fn account_snapshot_usd_only_still_reports_positions() {
8476 use crate::client::ExecutionClient;
8477 use rust_decimal_macros::dec;
8478
8479 let server = MockServer::start().await;
8480 mount_account_and_positions(&server).await;
8481
8482 let usd = [AssetNameExchange::new("USD")];
8483 let instruments = [InstrumentNameExchange::new("XYZ")];
8484 let snapshot = client_for(&server)
8485 .account_snapshot(&usd, &instruments)
8486 .await
8487 .unwrap();
8488
8489 assert_eq!(snapshot.balances.len(), 1);
8490 assert_eq!(snapshot.balances[0].asset, AssetNameExchange::new("USD"));
8491 assert_eq!(snapshot.balances[0].balance.total, dec!(-1500.50));
8492
8493 assert_eq!(snapshot.instruments.len(), 1);
8494 assert_eq!(snapshot.instruments[0].position.quantity(), Some(dec!(-10)));
8495 }
8496
8497 #[tokio::test]
8499 async fn account_snapshot_non_usd_only_skips_account_request() {
8500 use crate::client::ExecutionClient;
8501
8502 let server = MockServer::start().await;
8503 mount_account_and_positions(&server).await;
8504
8505 let btc = [AssetNameExchange::new("btc")];
8506 let snapshot = client_for(&server)
8507 .account_snapshot(&btc, &[])
8508 .await
8509 .unwrap();
8510
8511 assert_eq!(snapshot.balances.len(), 1);
8512 assert_eq!(snapshot.balances[0].asset, AssetNameExchange::new("BTC"));
8513 let requests = server.received_requests().await.unwrap();
8514 assert!(
8515 requests.iter().all(|r| r.url.path() != "/v2/account"),
8516 "no USD requested, so /v2/account must not be fetched"
8517 );
8518 }
8519
8520 #[tokio::test]
8525 async fn open_order_refuses_every_margin_effect_without_sending() {
8526 use crate::client::ExecutionClient;
8527 use crate::error::OrderError;
8528 use crate::margin::MarginEffect;
8529 use crate::order::request::{OrderRequestOpen, RequestOpen};
8530 use crate::order::state::InactiveOrderState;
8531 use crate::order::{
8532 OrderKey, OrderKind, TimeInForce,
8533 id::{ClientOrderId, StrategyId},
8534 };
8535 use rust_decimal::Decimal;
8536 use rustrade_instrument::Side;
8537 use rustrade_instrument::exchange::ExchangeId;
8538 use rustrade_instrument::instrument::name::InstrumentNameExchange;
8539
8540 let server = MockServer::start().await;
8541 let client = client_for(&server);
8542 let instrument = InstrumentNameExchange::new("AAPL");
8543
8544 for effect in [
8545 MarginEffect::NoSideEffect,
8546 MarginEffect::Borrow,
8547 MarginEffect::Repay,
8548 ] {
8549 let order = client
8550 .open_order(OrderRequestOpen {
8551 key: OrderKey {
8552 exchange: ExchangeId::AlpacaBroker,
8553 instrument: &instrument,
8554 strategy: StrategyId::new("test-strategy"),
8555 cid: ClientOrderId::new("test-cid"),
8556 },
8557 state: RequestOpen::new(
8558 Side::Buy,
8559 None,
8560 Decimal::new(10, 0),
8561 OrderKind::Market,
8562 TimeInForce::ImmediateOrCancel,
8563 )
8564 .with_margin_effect(effect),
8565 })
8566 .await;
8567
8568 assert!(
8569 matches!(
8570 order.state,
8571 OrderState::Inactive(InactiveOrderState::OpenFailed(
8572 OrderError::UnsupportedMarginEffect(refused)
8573 )) if refused == effect
8574 ),
8575 "{order:?}"
8576 );
8577 }
8578 assert!(
8579 server.received_requests().await.unwrap().is_empty(),
8580 "a refused order is never sent"
8581 );
8582 }
8583
8584 #[tokio::test]
8590 async fn open_order_reduce_only_sell_sends_sell_to_close_intent() {
8591 use crate::client::ExecutionClient;
8592 use crate::order::request::{OrderRequestOpen, RequestOpen};
8593 use crate::order::{
8594 OrderKey, OrderKind, TimeInForce,
8595 id::{ClientOrderId, StrategyId},
8596 };
8597 use rust_decimal::Decimal;
8598 use rustrade_instrument::Side;
8599 use rustrade_instrument::exchange::ExchangeId;
8600 use rustrade_instrument::instrument::name::InstrumentNameExchange;
8601 use wiremock::matchers::{method, path};
8602
8603 let server = MockServer::start().await;
8604
8605 let captured_body = std::sync::Arc::new(parking_lot::Mutex::new(None));
8608 let captured_clone = captured_body.clone();
8609
8610 Mock::given(method("POST"))
8611 .and(path("/v2/orders"))
8612 .respond_with(move |req: &Request| {
8613 let body: serde_json::Value = serde_json::from_slice(&req.body).unwrap();
8615 *captured_clone.lock() = Some(body);
8616
8617 ResponseTemplate::new(200).set_body_json(serde_json::json!({
8618 "id": "test-order-id",
8619 "client_order_id": "test-cid",
8620 "symbol": "AAPL",
8621 "qty": "10",
8622 "filled_qty": "0",
8623 "side": "sell",
8624 "type": "market",
8625 "time_in_force": "ioc",
8626 "limit_price": null,
8627 "created_at": "2025-04-18T14:30:00Z"
8628 }))
8629 })
8630 .mount(&server)
8631 .await;
8632
8633 let config =
8635 AlpacaConfig::with_base_url("test-key".into(), "test-secret".into(), server.uri());
8636 let client = AlpacaClient::new(config);
8637
8638 let request = OrderRequestOpen {
8640 key: OrderKey {
8641 exchange: ExchangeId::AlpacaBroker,
8642 instrument: InstrumentNameExchange::new("AAPL"),
8643 strategy: StrategyId::new("test-strategy"),
8644 cid: ClientOrderId::new("test-cid"),
8645 },
8646 state: RequestOpen::new(
8647 Side::Sell,
8648 None,
8649 Decimal::new(10, 0),
8650 OrderKind::Market,
8651 TimeInForce::ImmediateOrCancel,
8652 )
8653 .with_reduce_only(true), };
8655
8656 let order = client
8658 .open_order(OrderRequestOpen {
8659 key: OrderKey {
8660 exchange: request.key.exchange,
8661 instrument: &request.key.instrument,
8662 strategy: request.key.strategy.clone(),
8663 cid: request.key.cid.clone(),
8664 },
8665 state: request.state.clone(),
8666 })
8667 .await;
8668
8669 assert!(
8671 order.state.is_accepted(),
8672 "order should be accepted: {:?}",
8673 order.state
8674 );
8675
8676 let body = captured_body
8678 .lock()
8679 .take()
8680 .expect("request body should be captured");
8681 assert_eq!(
8682 body.get("position_intent").and_then(|v| v.as_str()),
8683 Some("sell_to_close"),
8684 "reduce_only=true + Side::Sell should produce position_intent=sell_to_close, got: {body}"
8685 );
8686 }
8687
8688 #[tokio::test]
8690 async fn open_order_not_reduce_only_buy_sends_buy_to_open_intent() {
8691 use crate::client::ExecutionClient;
8692 use crate::order::request::{OrderRequestOpen, RequestOpen};
8693 use crate::order::{
8694 OrderKey, OrderKind, TimeInForce,
8695 id::{ClientOrderId, StrategyId},
8696 };
8697 use rust_decimal::Decimal;
8698 use rustrade_instrument::Side;
8699 use rustrade_instrument::exchange::ExchangeId;
8700 use rustrade_instrument::instrument::name::InstrumentNameExchange;
8701
8702 let server = MockServer::start().await;
8703
8704 let captured_body = std::sync::Arc::new(parking_lot::Mutex::new(None));
8705 let captured_clone = captured_body.clone();
8706
8707 Mock::given(method("POST"))
8708 .and(path("/v2/orders"))
8709 .respond_with(move |req: &Request| {
8710 let body: serde_json::Value = serde_json::from_slice(&req.body).unwrap();
8711 *captured_clone.lock() = Some(body);
8712
8713 ResponseTemplate::new(200).set_body_json(serde_json::json!({
8714 "id": "test-order-id",
8715 "client_order_id": "test-cid",
8716 "symbol": "AAPL",
8717 "qty": "10",
8718 "filled_qty": "0",
8719 "side": "buy",
8720 "type": "market",
8721 "time_in_force": "ioc",
8722 "limit_price": null,
8723 "created_at": "2025-04-18T14:30:00Z"
8724 }))
8725 })
8726 .mount(&server)
8727 .await;
8728
8729 let config =
8730 AlpacaConfig::with_base_url("test-key".into(), "test-secret".into(), server.uri());
8731 let client = AlpacaClient::new(config);
8732
8733 let instrument = InstrumentNameExchange::new("AAPL");
8734 let request = OrderRequestOpen {
8735 key: OrderKey {
8736 exchange: ExchangeId::AlpacaBroker,
8737 instrument: &instrument,
8738 strategy: StrategyId::new("test-strategy"),
8739 cid: ClientOrderId::new("test-cid"),
8740 },
8741 state: RequestOpen::new(
8743 Side::Buy,
8744 None,
8745 Decimal::new(10, 0),
8746 OrderKind::Market,
8747 TimeInForce::ImmediateOrCancel,
8748 ),
8749 };
8750
8751 let order = client.open_order(request).await;
8752
8753 assert!(
8754 order.state.is_accepted(),
8755 "order should be accepted: {:?}",
8756 order.state
8757 );
8758
8759 let body = captured_body
8760 .lock()
8761 .take()
8762 .expect("request body should be captured");
8763 assert_eq!(
8764 body.get("position_intent").and_then(|v| v.as_str()),
8765 Some("buy_to_open"),
8766 "reduce_only=false + Side::Buy should produce position_intent=buy_to_open, got: {body}"
8767 );
8768 }
8769
8770 #[tokio::test]
8773 async fn rest_with_retry_reports_an_undecodable_2xx_body_as_internal() {
8774 let server = MockServer::start().await;
8775 Mock::given(method("GET"))
8776 .and(path("/v2/account"))
8777 .respond_with(ResponseTemplate::new(200).set_body_string("not json"))
8778 .mount(&server)
8779 .await;
8780
8781 let http = reqwest::Client::new();
8782 let rl = RateLimitTracker::new();
8783 let url = format!("{}/v2/account", server.uri());
8784 let result: Result<serde_json::Value, _> =
8785 rest_with_retry(&rl, || http.get(&url)).await;
8786 assert!(
8787 matches!(result, Err(UnindexedClientError::Internal(_))),
8788 "{result:?}"
8789 );
8790 }
8791
8792 async fn holds_back(rl: &RateLimitTracker) -> bool {
8794 tokio::time::timeout(Duration::from_millis(50), rl.wait_if_blocked())
8795 .await
8796 .is_err()
8797 }
8798
8799 fn with_rate_limit(
8801 template: ResponseTemplate,
8802 remaining: u32,
8803 reset: bool,
8804 ) -> ResponseTemplate {
8805 let template = template.insert_header("x-ratelimit-remaining", remaining.to_string());
8806 if !reset {
8807 return template;
8808 }
8809 let now_secs = std::time::SystemTime::now()
8810 .duration_since(std::time::UNIX_EPOCH)
8811 .unwrap()
8812 .as_secs();
8813 template.insert_header("x-ratelimit-reset", (now_secs + 30).to_string())
8814 }
8815
8816 #[tokio::test]
8819 async fn rest_with_retry_pauses_once_no_requests_remain() {
8820 for (remaining, reset, pauses) in [(0, true, true), (1, true, false), (0, false, false)]
8821 {
8822 let server = MockServer::start().await;
8823 Mock::given(method("GET"))
8824 .and(path("/v2/account"))
8825 .respond_with(with_rate_limit(
8826 ResponseTemplate::new(200).set_body_json(serde_json::json!({})),
8827 remaining,
8828 reset,
8829 ))
8830 .mount(&server)
8831 .await;
8832
8833 let http = reqwest::Client::new();
8834 let rl = RateLimitTracker::new();
8835 let url = format!("{}/v2/account", server.uri());
8836 let _: serde_json::Value = rest_with_retry(&rl, || http.get(&url)).await.unwrap();
8837 assert_eq!(
8838 holds_back(&rl).await,
8839 pauses,
8840 "remaining {remaining}, reset {reset}"
8841 );
8842 }
8843 }
8844
8845 #[tokio::test]
8848 async fn a_pause_lasts_a_minute_at_most() {
8849 tokio::time::pause();
8850 let now_secs = std::time::SystemTime::now()
8851 .duration_since(std::time::UNIX_EPOCH)
8852 .unwrap()
8853 .as_secs();
8854 let mut headers = reqwest::header::HeaderMap::new();
8855 headers.insert("x-ratelimit-remaining", "0".parse().unwrap());
8856 headers.insert(
8857 "x-ratelimit-reset",
8858 (now_secs + 3_600).to_string().parse().unwrap(),
8859 );
8860 let rl = RateLimitTracker::new();
8861
8862 observe_rate_limit_remaining(&rl, &headers);
8863 assert!(holds_back(&rl).await);
8864 tokio::time::advance(Duration::from_secs(DEFAULT_RATE_LIMIT_DELAY_SECS)).await;
8865 assert!(!holds_back(&rl).await);
8866 }
8867
8868 #[tokio::test]
8870 async fn rest_delete_with_retry_pauses_once_no_requests_remain() {
8871 let server = MockServer::start().await;
8872 Mock::given(method("DELETE"))
8873 .and(path("/v2/orders/abc"))
8874 .respond_with(with_rate_limit(ResponseTemplate::new(204), 0, true))
8875 .mount(&server)
8876 .await;
8877
8878 let http = reqwest::Client::new();
8879 let rl = RateLimitTracker::new();
8880 let url = format!("{}/v2/orders/abc", server.uri());
8881 rest_delete_with_retry(&rl, || http.delete(&url))
8882 .await
8883 .unwrap();
8884 assert!(holds_back(&rl).await);
8885 }
8886
8887 #[tokio::test]
8890 async fn open_order_with_an_undecodable_2xx_response_is_status_unknown() {
8891 use crate::client::ExecutionClient;
8892 use crate::error::OrderError;
8893 use crate::order::request::{OrderRequestOpen, RequestOpen};
8894 use crate::order::state::{InactiveOrderState, OrderState};
8895 use crate::order::{
8896 OrderKey, OrderKind, TimeInForce,
8897 id::{ClientOrderId, StrategyId},
8898 };
8899 use rustrade_instrument::Side;
8900 use rustrade_instrument::exchange::ExchangeId;
8901 use rustrade_instrument::instrument::name::InstrumentNameExchange;
8902
8903 let server = MockServer::start().await;
8904 Mock::given(method("POST"))
8905 .and(path("/v2/orders"))
8906 .respond_with(
8907 ResponseTemplate::new(200).set_body_json(serde_json::json!({ "id": 1 })),
8908 )
8909 .mount(&server)
8910 .await;
8911
8912 let instrument = InstrumentNameExchange::new("AAPL");
8913 let order = client_for(&server)
8914 .open_order(OrderRequestOpen {
8915 key: OrderKey {
8916 exchange: ExchangeId::AlpacaBroker,
8917 instrument: &instrument,
8918 strategy: StrategyId::new("test-strategy"),
8919 cid: ClientOrderId::new("test-cid"),
8920 },
8921 state: RequestOpen::new(
8922 Side::Buy,
8923 None,
8924 Decimal::new(10, 0),
8925 OrderKind::Market,
8926 TimeInForce::ImmediateOrCancel,
8927 ),
8928 })
8929 .await;
8930
8931 assert!(
8932 matches!(
8933 order.state,
8934 OrderState::Inactive(InactiveOrderState::OpenFailed(OrderError::Connectivity(
8935 _
8936 )))
8937 ),
8938 "{:?}",
8939 order.state
8940 );
8941 }
8942
8943 #[tokio::test]
8946 async fn open_order_reports_a_403_by_its_code() {
8947 use crate::client::ExecutionClient;
8948 use crate::error::OrderError;
8949 use crate::order::request::{OrderRequestOpen, RequestOpen};
8950 use crate::order::state::{InactiveOrderState, OrderState};
8951 use crate::order::{
8952 OrderKey, OrderKind, TimeInForce,
8953 id::{ClientOrderId, StrategyId},
8954 };
8955 use rustrade_instrument::Side;
8956 use rustrade_instrument::exchange::ExchangeId;
8957 use rustrade_instrument::instrument::name::InstrumentNameExchange;
8958
8959 async fn open_against(
8960 status: u16,
8961 body: serde_json::Value,
8962 ) -> OrderState<AssetNameExchange, InstrumentNameExchange> {
8963 let server = MockServer::start().await;
8964 Mock::given(method("POST"))
8965 .and(path("/v2/orders"))
8966 .respond_with(ResponseTemplate::new(status).set_body_json(body))
8967 .mount(&server)
8968 .await;
8969 let instrument = InstrumentNameExchange::new("AAPL");
8970 client_for(&server)
8971 .open_order(OrderRequestOpen {
8972 key: OrderKey {
8973 exchange: ExchangeId::AlpacaBroker,
8974 instrument: &instrument,
8975 strategy: StrategyId::new("test-strategy"),
8976 cid: ClientOrderId::new("test-cid"),
8977 },
8978 state: RequestOpen::new(
8979 Side::Sell,
8980 None,
8981 Decimal::new(10, 0),
8982 OrderKind::Market,
8983 TimeInForce::ImmediateOrCancel,
8984 ),
8985 })
8986 .await
8987 .state
8988 }
8989
8990 let rejected =
8991 |state: &OrderState<AssetNameExchange, InstrumentNameExchange>| match state {
8992 OrderState::Inactive(InactiveOrderState::OpenFailed(OrderError::Rejected(
8993 e,
8994 ))) => e.clone(),
8995 other => panic!("expected a rejection, got {other:?}"),
8996 };
8997
8998 let state = open_against(
8999 403,
9000 serde_json::json!({
9001 "code": 40310000,
9002 "message": "insufficient buying power"
9003 }),
9004 )
9005 .await;
9006 assert_eq!(
9007 rejected(&state),
9008 ApiError::BalanceInsufficient(None, "40310000 insufficient buying power".into())
9009 );
9010
9011 let state = open_against(
9012 403,
9013 serde_json::json!({
9014 "code": 40310000,
9015 "message": "account is not allowed to short"
9016 }),
9017 )
9018 .await;
9019 assert_eq!(
9020 rejected(&state),
9021 ApiError::BorrowRejected(BorrowReject::new(
9022 BorrowRejectReason::BorrowDisabled,
9023 Some("40310000".into()),
9024 "account is not allowed to short".into(),
9025 ))
9026 );
9027
9028 let state = open_against(
9030 422,
9031 serde_json::json!({
9032 "code": 42210000,
9033 "message": "asset XYZ cannot be sold short"
9034 }),
9035 )
9036 .await;
9037 assert_eq!(
9038 rejected(&state),
9039 ApiError::BorrowRejected(BorrowReject::new(
9040 BorrowRejectReason::NotShortable,
9041 Some("42210000".into()),
9042 "asset XYZ cannot be sold short".into(),
9043 ))
9044 );
9045
9046 let state = open_against(403, serde_json::json!({ "message": "forbidden." })).await;
9047 assert!(
9048 matches!(rejected(&state), ApiError::Unauthenticated(_)),
9049 "{state:?}"
9050 );
9051 }
9052
9053 #[tokio::test]
9056 async fn open_bracket_order_with_an_undecodable_2xx_response_is_status_unknown() {
9057 use crate::error::OrderError;
9058 use crate::order::state::{InactiveOrderState, OrderState};
9059 use rustrade_instrument::instrument::name::InstrumentNameExchange;
9060
9061 let server = MockServer::start().await;
9062 Mock::given(method("POST"))
9063 .and(path("/v2/orders"))
9064 .respond_with(
9065 ResponseTemplate::new(200).set_body_json(serde_json::json!({ "id": 1 })),
9066 )
9067 .mount(&server)
9068 .await;
9069
9070 let result = client_for(&server)
9071 .open_bracket_order(AlpacaBracketOrderRequest::new(
9072 InstrumentNameExchange::new("SPY"),
9073 crate::order::id::StrategyId::new("test"),
9074 crate::order::id::ClientOrderId::new("test-bracket"),
9075 Side::Buy,
9076 Decimal::ONE,
9077 Decimal::new(100, 0),
9078 Decimal::new(120, 0),
9079 Decimal::new(90, 0),
9080 TimeInForce::GoodUntilCancelled { post_only: false },
9081 ))
9082 .await;
9083
9084 assert!(
9085 matches!(
9086 result.parent.state,
9087 OrderState::Inactive(InactiveOrderState::OpenFailed(OrderError::Connectivity(
9088 _
9089 )))
9090 ),
9091 "{:?}",
9092 result.parent.state
9093 );
9094 }
9095
9096 fn recovery_after() -> DateTime<Utc> {
9105 "2025-01-01T00:00:00Z".parse().expect("valid time")
9106 }
9107
9108 fn activity_json(
9111 id: &str,
9112 order_id: &str,
9113 qty: &str,
9114 cum_qty: Option<&str>,
9115 ) -> serde_json::Value {
9116 let mut v = serde_json::json!({
9117 "id": id,
9118 "order_id": order_id,
9119 "symbol": "SPY",
9120 "side": "buy",
9121 "price": "100.00",
9122 "qty": qty,
9123 "transaction_time": "2025-04-18T14:30:00Z"
9124 });
9125 if let Some(c) = cum_qty {
9126 v["cum_qty"] = serde_json::Value::String(c.to_string());
9127 }
9128 v
9129 }
9130
9131 #[tokio::test(start_paused = true)]
9136 async fn the_reconnect_budget_resets_only_once_a_connection_proves_stable() {
9137 use futures::{SinkExt as _, StreamExt as _};
9138 use tokio_tungstenite::tungstenite::Message;
9139
9140 const STABLE: Duration = Duration::from_secs(HEARTBEAT_TIMEOUT_SECS + 3);
9142 const DROPPED: Duration = Duration::from_millis(100);
9143 let lifetime = |n: usize| match n {
9144 0 | 1 => Some(DROPPED),
9145 2 => Some(STABLE),
9146 _ => None,
9147 };
9148
9149 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
9152 let ws_url = format!("ws://{}", listener.local_addr().unwrap());
9153 let listens = Arc::new(parking_lot::Mutex::new(Vec::new()));
9154 let seen = Arc::clone(&listens);
9155 tokio::spawn(async move {
9156 let mut connections = 0..;
9157 while let Ok((stream, _)) = listener.accept().await {
9158 stream.set_nodelay(true).unwrap();
9161 let seen = Arc::clone(&seen);
9162 let lifetime = connections.next().and_then(lifetime);
9163 tokio::spawn(async move {
9164 let Ok(mut ws) = tokio_tungstenite::accept_async(stream).await else {
9165 return;
9166 };
9167 for answer in [
9168 r#"{"stream":"authorization","data":{"status":"authorized","action":"authenticate"}}"#,
9169 r#"{"stream":"listening","data":{"streams":["trade_updates"]}}"#,
9170 ] {
9171 let Some(Ok(_)) = ws.next().await else {
9172 return;
9173 };
9174 if ws.send(Message::text(answer)).await.is_err() {
9175 return;
9176 }
9177 }
9178 seen.lock().push(tokio::time::Instant::now());
9179 let closes = async {
9180 match lifetime {
9181 Some(after) => tokio::time::sleep(after).await,
9182 None => std::future::pending().await,
9183 }
9184 };
9185 tokio::pin!(closes);
9186 let mut pings = tokio::time::interval(Duration::from_secs(1));
9187 loop {
9188 tokio::select! {
9189 () = &mut closes => {
9190 let _ = ws.close(None).await;
9191 return;
9192 }
9193 _ = pings.tick() => {
9194 if ws.send(Message::Ping(Default::default())).await.is_err() {
9195 return;
9196 }
9197 }
9198 message = ws.next() => {
9199 if !matches!(message, Some(Ok(_))) {
9200 return;
9201 }
9202 }
9203 }
9204 }
9205 });
9206 }
9207 });
9208 let rest_url = crate::client::test_http::serve(|_, _| (404, String::new())).await;
9211 let mut config = AlpacaConfig::with_base_url("key".into(), "secret".into(), rest_url);
9212 config.ws_url_override = Some(ws_url);
9213 let (tx, _rx) = mpsc::unbounded_channel();
9214 tokio::spawn(connection_manager(
9215 tx,
9216 new_dedup_cache(),
9217 Arc::new(config),
9218 reqwest::Client::new(),
9219 Arc::new(RateLimitTracker::new()),
9220 KnownLiveOrders::shared(ExchangeId::AlpacaBroker),
9221 Vec::new(),
9222 None,
9223 ));
9224
9225 let deadline = tokio::time::Instant::now() + STABLE + Duration::from_secs(15);
9226 while listens.lock().len() < 4 {
9227 assert!(
9228 tokio::time::Instant::now() < deadline,
9229 "{} connections",
9230 listens.lock().len()
9231 );
9232 tokio::time::sleep(Duration::from_millis(50)).await;
9233 }
9234
9235 let initial = BackoffLimits::ACCOUNT_STREAM.initial;
9239 let waits: Vec<_> = listens.lock().windows(2).map(|w| w[1] - w[0]).collect();
9240 assert_eq!(
9241 waits,
9242 [DROPPED + initial, DROPPED + 2 * initial, STABLE + initial],
9243 "the waits between listens"
9244 );
9245 }
9246
9247 #[tokio::test(start_paused = true)]
9250 async fn failed_connects_end_the_stream_once_the_budget_is_spent() {
9251 let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
9253 let ws_url = format!("ws://{}", listener.local_addr().unwrap());
9254 drop(listener);
9255 let mut config = AlpacaConfig::paper("key".into(), "secret".into());
9256 config.ws_url_override = Some(ws_url);
9257 let (tx, mut rx) = mpsc::unbounded_channel();
9258 let started = tokio::time::Instant::now();
9259
9260 connection_manager(
9261 tx,
9262 new_dedup_cache(),
9263 Arc::new(config),
9264 reqwest::Client::new(),
9265 Arc::new(RateLimitTracker::new()),
9266 KnownLiveOrders::shared(ExchangeId::AlpacaBroker),
9267 Vec::new(),
9268 None,
9269 )
9270 .await;
9271
9272 let Ok(event) = rx.try_recv() else {
9273 panic!("the stream ended without reporting why");
9274 };
9275 let AccountEventKind::StreamTerminated(
9276 StreamTerminationReason::ReconnectBudgetExhausted {
9277 attempts,
9278 last_error,
9279 },
9280 ) = event.kind
9281 else {
9282 panic!("unexpected event: {event:?}");
9283 };
9284 assert_eq!(attempts, BackoffLimits::ACCOUNT_STREAM.max_attempts);
9285 assert!(
9286 !last_error.is_empty(),
9287 "the last connect failure is reported"
9288 );
9289 assert!(
9291 started.elapsed() >= Duration::from_secs(181),
9292 "{:?}",
9293 started.elapsed()
9294 );
9295 assert!(rx.try_recv().is_err(), "nothing follows the termination");
9296 }
9297
9298 async fn drive_recover_fills_with(
9301 activities: Vec<serde_json::Value>,
9302 dedup: SharedDedupCache,
9303 known: &SharedKnownLiveOrders,
9304 ) -> Vec<UnindexedAccountEvent> {
9305 let server = MockServer::start().await;
9306 Mock::given(method("GET"))
9307 .and(path("/v2/account/activities"))
9308 .respond_with(
9309 ResponseTemplate::new(200).set_body_json(serde_json::Value::Array(activities)),
9310 )
9311 .mount(&server)
9312 .await;
9313
9314 let http = reqwest::Client::new();
9315 let rl = RateLimitTracker::new();
9316 let (tx, mut rx) = mpsc::unbounded_channel();
9317 let outcome = recover_fills(
9318 &http,
9319 &rl,
9320 &[],
9321 &server.uri(),
9322 recovery_after(),
9323 &tx,
9324 &dedup,
9325 known,
9326 )
9327 .await;
9328 assert_eq!(outcome, Ok(()), "a full read leaves nothing unrecovered");
9329 drop(tx);
9330
9331 let mut out = Vec::new();
9332 while let Ok(event) = rx.try_recv() {
9333 out.push(event);
9334 }
9335 out
9336 }
9337
9338 async fn drive_recover_fills(
9339 activities: Vec<serde_json::Value>,
9340 ) -> Vec<UnindexedAccountEvent> {
9341 drive_recover_fills_with(
9342 activities,
9343 new_dedup_cache(),
9344 &KnownLiveOrders::shared(ExchangeId::AlpacaBroker),
9345 )
9346 .await
9347 }
9348
9349 async fn recover_fills_from(
9352 server: &MockServer,
9353 ) -> (Result<(), UnreadFills>, Vec<UnindexedAccountEvent>) {
9354 let (tx, mut rx) = mpsc::unbounded_channel();
9355 let outcome = recover_fills(
9356 &reqwest::Client::new(),
9357 &RateLimitTracker::new(),
9358 &[],
9359 &server.uri(),
9360 recovery_after(),
9361 &tx,
9362 &new_dedup_cache(),
9363 &KnownLiveOrders::shared(ExchangeId::AlpacaBroker),
9364 )
9365 .await;
9366 drop(tx);
9367 (outcome, std::iter::from_fn(|| rx.try_recv().ok()).collect())
9368 }
9369
9370 #[tokio::test]
9373 async fn a_failed_recovery_read_leaves_every_fill_since_the_disconnect_unread() {
9374 let server = MockServer::start().await;
9375 Mock::given(method("GET"))
9376 .and(path("/v2/account/activities"))
9377 .respond_with(ResponseTemplate::new(500).set_body_string("unavailable"))
9378 .mount(&server)
9379 .await;
9380
9381 let (outcome, events) = recover_fills_from(&server).await;
9382
9383 let Err(UnreadFills {
9384 start,
9385 reason: FillRecoveryFailure::Request(_),
9386 }) = outcome
9387 else {
9388 panic!("expected the request's failure, got {outcome:?}");
9389 };
9390 assert_eq!(start, recovery_after(), "from the disconnect");
9391 assert!(events.is_empty(), "nothing forwarded: {events:?}");
9392 }
9393
9394 #[tokio::test]
9399 async fn a_truncated_recovery_read_delivers_what_it_read_and_leaves_the_rest_unread() {
9400 let time = |s: &str| s.parse::<DateTime<Utc>>().expect("valid time");
9401 let cases = [
9403 (
9404 Some("2025-04-18T14:30:01.234567Z"),
9405 time("2025-04-18T14:30:01.234Z"),
9406 ),
9407 (Some("not a time"), time("2025-04-18T14:30:00Z")),
9408 (None, recovery_after()),
9409 ];
9410 for (last_time, expected_start) in cases {
9411 let mut page = make_activities_json(ALPACA_MAX_ACTIVITIES, "act");
9412 let Some(activities) = page.as_array_mut() else {
9413 panic!("an array of activities");
9414 };
9415 match last_time {
9416 Some(last_time) => {
9417 if let Some(last) = activities.last_mut() {
9418 last["transaction_time"] =
9419 serde_json::Value::String(last_time.to_string());
9420 }
9421 }
9422 None => {
9423 for activity in activities.iter_mut() {
9424 activity["transaction_time"] =
9425 serde_json::Value::String("not a time".to_string());
9426 }
9427 }
9428 }
9429 let server = MockServer::start().await;
9430 Mock::given(method("GET"))
9431 .and(path("/v2/account/activities"))
9432 .respond_with(ResponseTemplate::new(200).set_body_json(page))
9433 .mount(&server)
9434 .await;
9435
9436 let (outcome, events) = recover_fills_from(&server).await;
9437
9438 let fills_read = MAX_ACTIVITY_PAGES * ALPACA_MAX_ACTIVITIES;
9439 assert_eq!(
9440 outcome,
9441 Err(UnreadFills {
9442 start: expected_start,
9443 reason: FillRecoveryFailure::Truncated { fills_read },
9444 }),
9445 "last time {last_time:?}"
9446 );
9447 assert_eq!(events.len(), fills_read, "every fill read is forwarded");
9448 }
9449 }
9450
9451 #[test]
9454 fn a_fill_recovery_give_up_covers_every_instrument_the_stream_does() {
9455 let start = recovery_after();
9456 let end = start + chrono::Duration::minutes(5);
9457 let spy = InstrumentNameExchange::new("SPY");
9458 let qqq = InstrumentNameExchange::new("QQQ");
9459 let cases = [
9460 (vec![], FillRecoveryScope::AllInstruments),
9461 (
9462 vec![spy.clone(), qqq.clone()],
9463 FillRecoveryScope::Instruments(vec![spy, qqq]),
9464 ),
9465 ];
9466 for (instruments, scope) in cases {
9467 let reason = FillRecoveryFailure::TimedOut { timeout_secs: 30 };
9468 let unread = UnreadFills {
9469 start,
9470 reason: reason.clone(),
9471 };
9472 assert_eq!(
9473 fill_recovery_gave_up(&instruments, unread, end),
9474 UnindexedAccountEvent::new(
9475 ExchangeId::AlpacaBroker,
9476 AccountEventKind::FillRecoveryGaveUp(FillRecoveryGap::new(
9477 scope, start, end, 1, reason,
9478 )),
9479 )
9480 );
9481 }
9482 }
9483
9484 #[test]
9488 fn a_read_that_reached_past_the_recovery_start_still_reports() {
9489 let end = recovery_after();
9490 let start = end + chrono::Duration::seconds(1);
9491 let unread = UnreadFills {
9492 start,
9493 reason: FillRecoveryFailure::Truncated { fills_read: 5_000 },
9494 };
9495 let event = fill_recovery_gave_up(&[], unread, end);
9496 let AccountEventKind::FillRecoveryGaveUp(gave_up) = event.kind else {
9497 panic!("expected FillRecoveryGaveUp, got {event:?}");
9498 };
9499 assert_eq!((gave_up.start, gave_up.end), (start, start));
9500 }
9501
9502 async fn recover_fills_or_report_from(
9505 server: &MockServer,
9506 instruments: &[InstrumentNameExchange],
9507 timeout: Duration,
9508 ) -> Vec<UnindexedAccountEvent> {
9509 let (tx, mut rx) = mpsc::unbounded_channel();
9510 recover_fills_or_report(
9511 &reqwest::Client::new(),
9512 &RateLimitTracker::new(),
9513 instruments,
9514 &server.uri(),
9515 recovery_after(),
9516 timeout,
9517 &tx,
9518 &new_dedup_cache(),
9519 &KnownLiveOrders::shared(ExchangeId::AlpacaBroker),
9520 )
9521 .await;
9522 drop(tx);
9523 std::iter::from_fn(|| rx.try_recv().ok()).collect()
9524 }
9525
9526 #[tokio::test]
9529 async fn a_timed_out_recovery_reports_every_fill_since_the_disconnect() {
9530 let server = MockServer::start().await;
9531 Mock::given(method("GET"))
9532 .and(path("/v2/account/activities"))
9533 .respond_with(
9534 ResponseTemplate::new(200)
9535 .set_body_json(serde_json::json!([]))
9536 .set_delay(Duration::from_secs(3_600)),
9537 )
9538 .mount(&server)
9539 .await;
9540 let spy = InstrumentNameExchange::new("SPY");
9541
9542 let events = recover_fills_or_report_from(
9543 &server,
9544 std::slice::from_ref(&spy),
9545 Duration::from_millis(50),
9546 )
9547 .await;
9548
9549 let [event] = events.as_slice() else {
9550 panic!("only the give-up is sent: {events:?}");
9551 };
9552 let AccountEventKind::FillRecoveryGaveUp(gave_up) = &event.kind else {
9553 panic!("expected FillRecoveryGaveUp, got {event:?}");
9554 };
9555 assert_eq!(gave_up.scope, FillRecoveryScope::Instruments(vec![spy]));
9556 assert_eq!(gave_up.start, recovery_after(), "from the disconnect");
9557 assert!(gave_up.end >= gave_up.start);
9558 assert_eq!(gave_up.attempts, 1);
9559 assert!(
9560 matches!(gave_up.reason, FillRecoveryFailure::TimedOut { .. }),
9561 "{:?}",
9562 gave_up.reason
9563 );
9564 }
9565
9566 #[tokio::test]
9569 async fn a_truncated_recovery_reports_after_the_fills_it_read() {
9570 let server = MockServer::start().await;
9571 Mock::given(method("GET"))
9572 .and(path("/v2/account/activities"))
9573 .respond_with(
9574 ResponseTemplate::new(200)
9575 .set_body_json(make_activities_json(ALPACA_MAX_ACTIVITIES, "act")),
9576 )
9577 .mount(&server)
9578 .await;
9579
9580 let events = recover_fills_or_report_from(&server, &[], Duration::from_secs(30)).await;
9581
9582 let Some((last, fills)) = events.split_last() else {
9583 panic!("events are sent");
9584 };
9585 assert_eq!(fills.len(), MAX_ACTIVITY_PAGES * ALPACA_MAX_ACTIVITIES);
9586 assert!(
9587 fills
9588 .iter()
9589 .all(|event| matches!(event.kind, AccountEventKind::Trade(_))),
9590 "every fill comes first"
9591 );
9592 let AccountEventKind::FillRecoveryGaveUp(gave_up) = &last.kind else {
9593 panic!("the give-up comes last, got {last:?}");
9594 };
9595 assert_eq!(gave_up.scope, FillRecoveryScope::AllInstruments);
9596 assert!(matches!(
9597 gave_up.reason,
9598 FillRecoveryFailure::Truncated { .. }
9599 ));
9600 }
9601
9602 #[test]
9605 fn an_activity_id_names_its_execution_after_the_separator() {
9606 assert_eq!(
9607 activity_execution_id("20261005041849348::524b1902-817e-446c-825b-a9fcfebbc17e"),
9608 "524b1902-817e-446c-825b-a9fcfebbc17e"
9609 );
9610 assert_eq!(activity_execution_id("act-1"), "act-1");
9611 assert_eq!(
9612 activity_execution_id("20261005041849348::"),
9613 "20261005041849348::"
9614 );
9615 }
9616
9617 #[tokio::test]
9621 async fn a_fill_has_one_trade_id_on_the_stream_in_recovery_and_from_fetch_trades() {
9622 use crate::client::ExecutionClient;
9623
9624 let execution_id = "524b1902-817e-446c-825b-a9fcfebbc17e";
9625 let activity = activity_json(
9626 &format!("20250418103000000::{execution_id}"),
9627 "ord-1",
9628 "2",
9629 Some("2"),
9630 );
9631
9632 let [streamed, _snapshot] = convert_trade_update(AlpacaTradeUpdate {
9633 event: SmolStr::new("fill"),
9634 execution_id: Some(execution_id),
9635 previous_execution_id: None,
9636 order: super::make_order_ws("ord-1", "SPY", "buy", "2"),
9637 price: Some("100.00"),
9638 qty: Some("2"),
9639 timestamp: Some("2025-04-18T14:30:00Z"),
9640 });
9641 let Some(streamed) = streamed else {
9642 panic!("a fill converts to a Trade");
9643 };
9644
9645 let recovered = drive_recover_fills(vec![activity.clone()]).await;
9646 let [recovered] = recovered.as_slice() else {
9647 panic!("one fill recovered: {recovered:?}");
9648 };
9649
9650 let server = MockServer::start().await;
9651 Mock::given(method("GET"))
9652 .and(path("/v2/account/activities"))
9653 .respond_with(
9654 ResponseTemplate::new(200)
9655 .set_body_json(serde_json::Value::Array(vec![activity])),
9656 )
9657 .mount(&server)
9658 .await;
9659 let Ok(fetched) = client_for(&server)
9660 .fetch_trades(recovery_after(), Utc::now(), &[])
9661 .await
9662 else {
9663 panic!("fetch_trades reads the activity");
9664 };
9665 let [fetched] = fetched.trades.as_slice() else {
9666 panic!("one fill fetched: {fetched:?}");
9667 };
9668
9669 assert_eq!(trade_of(&streamed).id.0.as_str(), execution_id);
9670 assert_eq!(trade_of(recovered).id.0.as_str(), execution_id);
9671 assert_eq!(fetched.id.0.as_str(), execution_id);
9672 }
9673
9674 fn time(s: &str) -> DateTime<Utc> {
9679 s.parse().expect("valid time")
9680 }
9681
9682 struct ActivitiesVenue {
9687 activities: Vec<serde_json::Value>,
9688 }
9689
9690 impl Respond for ActivitiesVenue {
9691 fn respond(&self, request: &Request) -> ResponseTemplate {
9692 let query: std::collections::HashMap<String, String> =
9693 request.url.query_pairs().into_owned().collect();
9694 let bound = |name: &str| query.get(name).map(|s| time(s));
9695 let (after, until) = (bound("after"), bound("until"));
9696 let size: usize = query
9697 .get("page_size")
9698 .and_then(|s| s.parse().ok())
9699 .unwrap_or(ALPACA_MAX_ACTIVITIES);
9700 let in_bounds = self.activities.iter().filter(|activity| {
9701 let at =
9702 floor_millis(time(activity["transaction_time"].as_str().expect("a time")));
9703 after.is_none_or(|after| at >= after) && until.is_none_or(|until| at < until)
9704 });
9705 let page: Vec<_> = match query.get("page_token") {
9706 Some(token) => in_bounds
9707 .skip_while(|activity| activity["id"] != token.as_str())
9708 .skip(1)
9709 .take(size)
9710 .cloned()
9711 .collect(),
9712 None => in_bounds.take(size).cloned().collect(),
9713 };
9714 ResponseTemplate::new(200).set_body_json(page)
9715 }
9716 }
9717
9718 fn fill_at(id: &str, symbol: &str, transaction_time: DateTime<Utc>) -> serde_json::Value {
9720 serde_json::json!({
9721 "id": id,
9722 "order_id": format!("ord-{id}"),
9723 "symbol": symbol,
9724 "side": "buy",
9725 "price": "100.00",
9726 "qty": "1",
9727 "cum_qty": "1",
9728 "transaction_time": transaction_time.to_rfc3339_opts(SecondsFormat::Micros, true),
9729 })
9730 }
9731
9732 async fn serve_activities(activities: Vec<serde_json::Value>) -> MockServer {
9733 let server = MockServer::start().await;
9734 Mock::given(method("GET"))
9735 .and(path("/v2/account/activities"))
9736 .respond_with(ActivitiesVenue { activities })
9737 .mount(&server)
9738 .await;
9739 server
9740 }
9741
9742 fn non_trade(
9745 id: &str,
9746 activity_type: &str,
9747 sub_type: Option<&str>,
9748 net_amount: &str,
9749 created_at: &str,
9750 ) -> serde_json::Value {
9751 let mut activity = serde_json::json!({
9752 "id": id, "activity_type": activity_type, "net_amount": net_amount,
9753 "created_at": created_at, "date": &created_at[..10], "currency": "USD",
9754 "status": "executed",
9755 });
9756 if let Some(sub_type) = sub_type {
9757 activity["activity_sub_type"] = serde_json::json!(sub_type);
9758 }
9759 activity
9760 }
9761
9762 async fn query_pairs(server: &MockServer) -> Vec<Vec<(String, String)>> {
9764 let Some(requests) = server.received_requests().await else {
9765 panic!("requests are recorded");
9766 };
9767 requests
9768 .iter()
9769 .map(|request| request.url.query_pairs().into_owned().collect())
9770 .collect()
9771 }
9772
9773 #[tokio::test]
9774 async fn fetch_cash_flows_maps_each_activity_type_and_leaves_out_what_it_cannot_use() {
9775 let at = "2025-04-18T05:00:00Z";
9776 let mut fee = non_trade("fee", "FEE", Some("TAF"), "-0.02", at);
9777 fee["symbol"] = serde_json::json!("SPY");
9778 let mut canceled = non_trade("canceled", "FEE", Some("REG"), "-0.01", at);
9779 canceled["status"] = serde_json::json!("canceled");
9780 let mut no_amount = non_trade("no-amount", "FEE", None, "0", at);
9781 no_amount.as_object_mut().unwrap().remove("net_amount");
9782 let mut no_created_at = non_trade("no-time", "PTC", None, "-0.50", at);
9783 no_created_at.as_object_mut().unwrap().remove("created_at");
9784 let mut bad_time = non_trade("bad-time", "PTC", None, "-0.50", at);
9785 bad_time["created_at"] = serde_json::json!("not-a-time");
9786 let mut corrected = non_trade("correct", "PTC", None, "-0.40", at);
9787 corrected["status"] = serde_json::json!("correct");
9788 let mut euro = non_trade("euro", "PTR", None, "0.30", at);
9789 euro["currency"] = serde_json::json!("EUR");
9790 let server = MockServer::start().await;
9791 Mock::given(method("GET"))
9792 .and(path("/v2/account/activities"))
9793 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([
9794 non_trade("mgn", "INT", Some("MGN"), "-12.50", at),
9795 non_trade("int-paid", "INT", None, "-3.00", at),
9796 non_trade("int-credit", "INT", Some("CDT"), "1.00", at),
9797 fee,
9798 canceled,
9799 no_amount,
9800 no_created_at,
9801 bad_time,
9802 corrected,
9803 euro,
9804 non_trade("late", "FEE", None, "-0.01", "2025-04-20T05:00:00Z"),
9805 ])))
9806 .mount(&server)
9807 .await;
9808 let client = client_for(&server);
9809
9810 let read = client
9811 .fetch_cash_flows(time("2025-04-18T00:00:00Z"), time("2025-04-19T00:00:00Z"))
9812 .await
9813 .unwrap();
9814
9815 assert_eq!(read.resume, None);
9816 let kinds: Vec<_> = read
9817 .flows
9818 .iter()
9819 .map(|flow| (flow.id.clone().unwrap().0.to_string(), flow.kind.clone()))
9820 .collect();
9821 let interest = CashFlowKind::MarginInterest {
9822 rate: None,
9823 principal: None,
9824 timing: None,
9825 };
9826 assert_eq!(
9827 kinds,
9828 [
9829 ("mgn", interest.clone()),
9830 ("int-paid", interest),
9831 ("int-credit", CashFlowKind::Other("INT.CDT".into())),
9832 ("fee", CashFlowKind::Other("FEE.TAF".into())),
9833 ("correct", CashFlowKind::Other("PTC".into())),
9834 ("euro", CashFlowKind::Rebate),
9835 ]
9836 .map(|(id, kind)| (id.to_owned(), kind))
9837 );
9838 let by_id = |id: &str| {
9839 read.flows
9840 .iter()
9841 .find(|flow| flow.id == Some(CashFlowId::new(id)))
9842 .unwrap()
9843 };
9844 assert_eq!(by_id("mgn").amount, Decimal::new(-1250, 2));
9845 assert_eq!(by_id("mgn").asset, AssetNameExchange::new("USD"));
9846 assert_eq!(by_id("mgn").instrument, None);
9847 assert_eq!(by_id("mgn").time_exchange, time(at));
9848 assert_eq!(
9849 by_id("fee").instrument,
9850 Some(InstrumentNameExchange::new("SPY"))
9851 );
9852 assert_eq!(
9853 by_id("correct").amount,
9854 Decimal::new(-40, 2),
9855 "a correction is returned as it is"
9856 );
9857 assert_eq!(by_id("euro").asset, AssetNameExchange::new("EUR"));
9858
9859 let queries = query_pairs(&server).await;
9860 let [query] = queries.as_slice() else {
9861 panic!("one request: {queries:?}");
9862 };
9863 assert!(
9864 query.contains(&("activity_types".into(), "INT,FEE,PTC,PTR".into())),
9865 "{query:?}"
9866 );
9867 }
9868
9869 #[tokio::test]
9870 async fn fetch_trades_asks_for_fills_by_the_documented_parameter() {
9871 use crate::client::ExecutionClient;
9872
9873 let server = serve_activities(Vec::new()).await;
9874 let client = client_for(&server);
9875
9876 let read = client
9877 .fetch_trades(
9878 time("2025-04-18T00:00:00Z"),
9879 time("2025-04-19T00:00:00Z"),
9880 &[],
9881 )
9882 .await
9883 .unwrap();
9884
9885 assert!(read.trades.is_empty());
9886 let queries = query_pairs(&server).await;
9887 let [query] = queries.as_slice() else {
9888 panic!("one request: {queries:?}");
9889 };
9890 assert!(
9891 query.contains(&("activity_types".into(), "FILL".into())),
9892 "{query:?}"
9893 );
9894 assert!(
9895 !query.iter().any(|(name, _)| name == "activity_type"),
9896 "{query:?}"
9897 );
9898 }
9899
9900 #[tokio::test]
9901 async fn fetch_cash_flows_reads_nothing_for_an_empty_span() {
9902 let server = MockServer::start().await;
9903 let client = client_for(&server);
9904
9905 let read = client
9906 .fetch_cash_flows(time("2025-04-19T00:00:00Z"), time("2025-04-18T00:00:00Z"))
9907 .await
9908 .unwrap();
9909
9910 assert!(read.flows.is_empty() && read.resume.is_none());
9911 assert!(query_pairs(&server).await.is_empty());
9912 }
9913
9914 #[tokio::test]
9917 async fn fetch_cash_flows_that_stops_at_its_bound_says_where_to_read_on() {
9918 let base = time("2025-04-18T05:00:00Z");
9919 let activities: Vec<_> = (0..6_000_i64)
9920 .map(|n| {
9921 let created =
9922 (base + TimeDelta::seconds(n)).to_rfc3339_opts(SecondsFormat::Micros, true);
9923 non_trade(&format!("{n:017}::fee"), "FEE", None, "-0.01", &created)
9924 })
9925 .collect();
9926 struct Paged(Vec<serde_json::Value>);
9928 impl Respond for Paged {
9929 fn respond(&self, request: &Request) -> ResponseTemplate {
9930 let token = request
9931 .url
9932 .query_pairs()
9933 .find(|(name, _)| name == "page_token")
9934 .map(|(_, value)| value.into_owned());
9935 let page: Vec<_> = self
9936 .0
9937 .iter()
9938 .skip_while(|activity| {
9939 token
9940 .as_deref()
9941 .is_some_and(|token| activity["id"] != token)
9942 })
9943 .skip(usize::from(token.is_some()))
9944 .take(ALPACA_MAX_ACTIVITIES)
9945 .cloned()
9946 .collect();
9947 ResponseTemplate::new(200).set_body_json(page)
9948 }
9949 }
9950 let server = MockServer::start().await;
9951 Mock::given(method("GET"))
9952 .and(path("/v2/account/activities"))
9953 .respond_with(Paged(activities))
9954 .mount(&server)
9955 .await;
9956 let client = client_for(&server);
9957
9958 let read = client
9959 .fetch_cash_flows(base, base + TimeDelta::days(1))
9960 .await
9961 .unwrap();
9962
9963 assert_eq!(read.flows.len(), 5_000);
9964 assert_eq!(read.resume, Some(base + TimeDelta::seconds(4_999)));
9965 }
9966
9967 #[tokio::test]
9970 async fn fetch_cash_flows_that_cannot_advance_fails() {
9971 let at = "2025-04-18T05:00:00Z";
9972 let full: Vec<_> = (0..ALPACA_MAX_ACTIVITIES)
9973 .map(|n| non_trade(&format!("fee-{n}"), "FEE", None, "-0.01", at))
9974 .collect();
9975 let server = MockServer::start().await;
9976 Mock::given(method("GET"))
9977 .and(path("/v2/account/activities"))
9978 .respond_with(ResponseTemplate::new(200).set_body_json(full))
9979 .mount(&server)
9980 .await;
9981 let client = client_for(&server);
9982
9983 let result = client
9984 .fetch_cash_flows(time(at), time("2025-04-19T00:00:00Z"))
9985 .await;
9986
9987 assert!(
9988 matches!(result, Err(UnindexedClientError::Internal(_))),
9989 "{result:?}"
9990 );
9991 }
9992
9993 fn option_activity(
9996 id: &str,
9997 activity_type: &str,
9998 symbol: &str,
9999 qty: &str,
10000 date: &str,
10001 ) -> serde_json::Value {
10002 serde_json::json!({
10003 "id": id, "activity_type": activity_type, "symbol": symbol, "qty": qty,
10004 "date": date, "net_amount": "0", "status": "executed",
10005 })
10006 }
10007
10008 fn share_leg(
10010 id: &str,
10011 symbol: &str,
10012 qty: &str,
10013 price: &str,
10014 date: &str,
10015 ) -> serde_json::Value {
10016 let mut activity = option_activity(id, "OPTRD", symbol, qty, date);
10017 activity["price"] = serde_json::json!(price);
10018 activity
10019 }
10020
10021 async fn serve_json(activities: serde_json::Value) -> MockServer {
10022 let server = MockServer::start().await;
10023 Mock::given(method("GET"))
10024 .and(path("/v2/account/activities"))
10025 .respond_with(ResponseTemplate::new(200).set_body_json(activities))
10026 .mount(&server)
10027 .await;
10028 server
10029 }
10030
10031 #[test]
10032 fn occ_contract_reads_the_root_kind_and_strike() {
10033 assert_eq!(
10034 occ_contract("QS240301C00006500"),
10035 Some(("QS", true, Decimal::new(65, 1)))
10036 );
10037 assert_eq!(
10038 occ_contract("AAPL240119P00150000"),
10039 Some(("AAPL", false, Decimal::from(150)))
10040 );
10041 for not_occ in [
10042 "QS",
10043 "240301C00006500",
10044 "QS240301X00006500",
10045 "QS2403O1C00006500",
10046 ] {
10047 assert_eq!(occ_contract(not_occ), None, "{not_occ}");
10048 }
10049 }
10050
10051 #[tokio::test]
10052 async fn fetch_option_events_maps_each_activity_and_pairs_the_share_legs() {
10053 let day = "2024-03-01";
10054 let mut expiry = option_activity("exp", "OPEXP", "QS240301C00010500", "-1", day);
10055 expiry["created_at"] = serde_json::json!("2024-03-01T21:30:00Z");
10056 let mut canceled = option_activity("canceled", "OPEXP", "QS240301P00000500", "-1", day);
10057 canceled["status"] = serde_json::json!("canceled");
10058 let server = serve_json(serde_json::json!([
10059 expiry,
10060 option_activity("exc", "OPEXC", "QS240301C00006500", "-1", day),
10061 share_leg("exc-shares", "QS", "100", "6.5", day),
10062 option_activity("asn", "OPASN", "QS240301P00009000", "1", day),
10063 share_leg("asn-shares", "QS", "100", "9", day),
10064 share_leg("orphan", "QS", "-100", "7", day),
10065 canceled,
10066 option_activity("no-price", "OPTRD", "QS", "100", day),
10067 option_activity("no-qty", "OPEXP", "QS240301C00011000", "0", day),
10068 option_activity("later", "OPEXP", "QS240308C00010500", "-1", "2024-03-02"),
10069 ]))
10070 .await;
10071 let client = client_for(&server);
10072
10073 let read = client
10074 .fetch_option_events(time("2024-03-01T00:00:00Z"), time("2024-03-01T23:00:00Z"))
10075 .await
10076 .unwrap();
10077
10078 assert_eq!(read.resume, None);
10079 let summary: Vec<_> = read
10080 .trades
10081 .iter()
10082 .map(|trade| {
10083 (
10084 trade.id.0.to_string(),
10085 trade.instrument.name().to_string(),
10086 trade.origin,
10087 trade.side,
10088 trade.price,
10089 trade.quantity,
10090 )
10091 })
10092 .collect();
10093 let row = |id: &str, symbol: &str, origin, side, price: Decimal, quantity: i64| {
10094 (
10095 id.to_owned(),
10096 symbol.to_owned(),
10097 origin,
10098 side,
10099 price,
10100 Decimal::from(quantity),
10101 )
10102 };
10103 assert_eq!(
10104 summary,
10105 [
10106 row(
10107 "exp",
10108 "QS240301C00010500",
10109 TradeOrigin::Expiry,
10110 Side::Sell,
10111 Decimal::ZERO,
10112 1
10113 ),
10114 row(
10115 "exc",
10116 "QS240301C00006500",
10117 TradeOrigin::Exercise,
10118 Side::Sell,
10119 Decimal::ZERO,
10120 1
10121 ),
10122 row(
10123 "exc-shares",
10124 "QS",
10125 TradeOrigin::Exercise,
10126 Side::Buy,
10127 Decimal::new(65, 1),
10128 100
10129 ),
10130 row(
10131 "asn",
10132 "QS240301P00009000",
10133 TradeOrigin::Assignment,
10134 Side::Buy,
10135 Decimal::ZERO,
10136 1
10137 ),
10138 row(
10139 "asn-shares",
10140 "QS",
10141 TradeOrigin::Assignment,
10142 Side::Buy,
10143 Decimal::from(9),
10144 100
10145 ),
10146 row(
10147 "orphan",
10148 "QS",
10149 TradeOrigin::External,
10150 Side::Sell,
10151 Decimal::from(7),
10152 100
10153 ),
10154 ]
10155 );
10156 let exp = &read.trades[0];
10157 assert_eq!(
10158 exp.time_exchange,
10159 time("2024-03-01T21:30:00Z"),
10160 "created_at"
10161 );
10162 assert_eq!(exp.order_id, OrderId::new("exp"));
10163 assert_eq!(exp.fees.fees, Decimal::ZERO);
10164 assert_eq!(
10165 read.trades[1].time_exchange,
10166 time("2024-03-01T00:00:00Z"),
10167 "no created_at: the start of its date"
10168 );
10169
10170 let queries = query_pairs(&server).await;
10171 let [query] = queries.as_slice() else {
10172 panic!("one request: {queries:?}");
10173 };
10174 assert!(
10175 query.contains(&("activity_types".into(), "OPASN,OPEXC,OPEXP,OPTRD".into())),
10176 "{query:?}"
10177 );
10178 }
10179
10180 #[tokio::test]
10183 async fn fetch_option_events_pairs_a_share_leg_by_its_side() {
10184 let day = "2024-03-01";
10185 let server = serve_json(serde_json::json!([
10186 option_activity("exc-call", "OPEXC", "QS240301C00006500", "-1", day),
10187 option_activity("asn-call", "OPASN", "QS240301C00006500", "1", day),
10188 share_leg("sold", "QS", "-100", "6.5", day),
10189 share_leg("bought", "QS", "100", "6.5", day),
10190 share_leg("bought-again", "QS", "100", "6.5", day),
10191 share_leg("other-day", "QS", "100", "6.5", "2024-02-29"),
10192 ]))
10193 .await;
10194 let client = client_for(&server);
10195
10196 let read = client
10197 .fetch_option_events(time("2024-02-29T00:00:00Z"), time("2024-03-01T23:00:00Z"))
10198 .await
10199 .unwrap();
10200
10201 let origin = |id: &str| {
10202 read.trades
10203 .iter()
10204 .find(|trade| trade.id == TradeId::new(id))
10205 .map(|trade| trade.origin)
10206 };
10207 assert_eq!(origin("sold"), Some(TradeOrigin::Assignment));
10208 assert_eq!(origin("bought"), Some(TradeOrigin::Exercise));
10209 assert_eq!(origin("bought-again"), Some(TradeOrigin::External));
10210 assert_eq!(origin("other-day"), Some(TradeOrigin::External));
10211 }
10212
10213 #[tokio::test]
10217 async fn fetch_option_events_pairs_a_share_leg_by_its_size() {
10218 let day = "2024-03-01";
10219 let server = serve_json(serde_json::json!([
10220 option_activity("asn-two", "OPASN", "QS240301P00006500", "2", day),
10222 option_activity("exc-one", "OPEXC", "QS240301C00006500", "-1", day),
10223 option_activity("exc-adjusted", "OPEXC", "QS240301C00006500", "-1", day),
10224 share_leg("hundred", "QS", "100", "6.5", day),
10225 share_leg("two-hundred", "QS", "200", "6.5", day),
10226 share_leg("hundred-fifty", "QS", "150", "6.5", day),
10227 ]))
10228 .await;
10229 let client = client_for(&server);
10230
10231 let read = client
10232 .fetch_option_events(time("2024-03-01T00:00:00Z"), time("2024-03-01T23:00:00Z"))
10233 .await
10234 .unwrap();
10235
10236 let origin = |id: &str| {
10237 read.trades
10238 .iter()
10239 .find(|trade| trade.id == TradeId::new(id))
10240 .map(|trade| trade.origin)
10241 };
10242 assert_eq!(origin("hundred"), Some(TradeOrigin::Exercise));
10243 assert_eq!(origin("two-hundred"), Some(TradeOrigin::Assignment));
10244 assert_eq!(origin("hundred-fifty"), Some(TradeOrigin::Exercise));
10245 }
10246
10247 #[tokio::test]
10251 async fn fetch_option_events_reads_whole_days_and_returns_the_span() {
10252 let mut early =
10253 option_activity("early", "OPEXP", "QS240301C00010500", "-1", "2024-03-01");
10254 early["created_at"] = serde_json::json!("2024-03-01T10:00:00Z");
10255 let mut inside =
10256 option_activity("inside", "OPEXP", "QS240301C00011500", "-1", "2024-03-01");
10257 inside["created_at"] = serde_json::json!("2024-03-01T16:00:00Z");
10258 let server = serve_json(serde_json::json!([
10259 option_activity(
10260 "dated-before",
10261 "OPEXP",
10262 "QS240229C00010500",
10263 "-1",
10264 "2024-02-29"
10265 ),
10266 early,
10267 option_activity("dated", "OPEXP", "QS240301C00012500", "-1", "2024-03-01"),
10268 inside,
10269 ]))
10270 .await;
10271 let client = client_for(&server);
10272
10273 let read = client
10274 .fetch_option_events(time("2024-03-01T15:00:00Z"), time("2024-03-01T17:00:00Z"))
10275 .await
10276 .unwrap();
10277
10278 let ids: Vec<_> = read
10279 .trades
10280 .iter()
10281 .map(|trade| trade.id.0.to_string())
10282 .collect();
10283 assert_eq!(ids, ["dated", "inside"]);
10284 let queries = query_pairs(&server).await;
10285 let [query] = queries.as_slice() else {
10286 panic!("one request: {queries:?}");
10287 };
10288 assert!(
10289 query.contains(&("after".into(), "2024-03-01T00:00:00.000Z".into())),
10290 "{query:?}"
10291 );
10292 assert!(
10293 query.contains(&("until".into(), "2024-03-02T00:00:00.000Z".into())),
10294 "{query:?}"
10295 );
10296 }
10297
10298 #[tokio::test]
10299 async fn fetch_option_events_reads_nothing_for_an_empty_span() {
10300 let server = MockServer::start().await;
10301 let client = client_for(&server);
10302
10303 let read = client
10304 .fetch_option_events(time("2024-03-02T00:00:00Z"), time("2024-03-01T00:00:00Z"))
10305 .await
10306 .unwrap();
10307
10308 assert!(read.trades.is_empty() && read.resume.is_none());
10309 assert!(query_pairs(&server).await.is_empty());
10310 }
10311
10312 async fn read_span(
10315 client: &AlpacaClient,
10316 start: DateTime<Utc>,
10317 end: DateTime<Utc>,
10318 instruments: &[InstrumentNameExchange],
10319 ) -> (std::collections::HashSet<String>, usize) {
10320 use crate::client::ExecutionClient;
10321
10322 let mut ids = std::collections::HashSet::new();
10323 let mut from = start;
10324 for calls in 1..=10 {
10325 let read = match client.fetch_trades(from, end, instruments).await {
10326 Ok(read) => read,
10327 Err(error) => panic!("call {calls} failed: {error:?}"),
10328 };
10329 ids.extend(read.trades.iter().map(|trade| trade.id.0.to_string()));
10330 match read.resume {
10331 Some(resume) => from = resume,
10332 None => return (ids, calls),
10333 }
10334 }
10335 panic!("the reads did not reach the span's end");
10336 }
10337
10338 #[tokio::test]
10344 async fn fetch_trades_reads_a_busy_span_completely_across_calls() {
10345 let base = time("2025-04-18T14:30:00Z");
10346 let mut activities = Vec::new();
10347 for ms in 0..4_000_i64 {
10348 let symbol = if ms % 2 == 0 { "SPY" } else { "QQQ" };
10349 for (k, micros) in [("a", 900), ("b", 500), ("c", 100)] {
10350 let at = base + TimeDelta::milliseconds(ms) + TimeDelta::microseconds(micros);
10351 activities.push(fill_at(&format!("{ms:017}::{ms}-{k}"), symbol, at));
10352 }
10353 }
10354 let server = serve_activities(activities).await;
10355 let client = client_for(&server);
10356 let end = time("2025-04-18T15:00:00Z");
10357
10358 let (ids, calls) = read_span(&client, recovery_after(), end, &[]).await;
10359 assert_eq!(ids.len(), 12_000, "every fill is read once or more");
10360 assert_eq!(calls, 3, "5,000 activities per call");
10361
10362 let spy = [InstrumentNameExchange::new("SPY")];
10363 let (ids, _) = read_span(&client, recovery_after(), end, &spy).await;
10364 assert_eq!(ids.len(), 6_000, "every SPY fill, and only those");
10365
10366 let Some(requests) = server.received_requests().await else {
10367 panic!("requests are recorded");
10368 };
10369 assert!(
10370 requests
10371 .iter()
10372 .all(|request| request.url.query_pairs().any(
10373 |(name, value)| name == "until" && value == "2025-04-18T15:00:00.001Z"
10374 )),
10375 "every request is bounded by the span's end"
10376 );
10377 }
10378
10379 #[tokio::test]
10382 async fn fetch_trades_says_where_to_read_on_after_a_call_without_matches() {
10383 use crate::client::ExecutionClient;
10384
10385 let server = MockServer::start().await;
10386 Mock::given(method("GET"))
10387 .and(path("/v2/account/activities"))
10388 .respond_with(
10389 ResponseTemplate::new(200)
10390 .set_body_json(make_activities_json(ALPACA_MAX_ACTIVITIES, "act")),
10391 )
10392 .mount(&server)
10393 .await;
10394
10395 let qqq = [InstrumentNameExchange::new("QQQ")];
10396 let Ok(read) = client_for(&server)
10397 .fetch_trades(recovery_after(), Utc::now(), &qqq)
10398 .await
10399 else {
10400 panic!("the read succeeds");
10401 };
10402 assert!(read.trades.is_empty(), "every fill read is SPY's");
10403 assert_eq!(read.resume, Some(time("2025-04-18T14:30:00Z")));
10404 }
10405
10406 #[tokio::test]
10409 async fn fetch_trades_that_cannot_advance_is_truncated() {
10410 use crate::client::ExecutionClient;
10411
10412 let server = MockServer::start().await;
10413 Mock::given(method("GET"))
10414 .and(path("/v2/account/activities"))
10415 .respond_with(
10416 ResponseTemplate::new(200)
10417 .set_body_json(make_activities_json(ALPACA_MAX_ACTIVITIES, "act")),
10418 )
10419 .mount(&server)
10420 .await;
10421
10422 let result = client_for(&server)
10423 .fetch_trades(time("2025-04-18T14:30:00.000500Z"), Utc::now(), &[])
10424 .await;
10425 assert!(
10426 matches!(
10427 result,
10428 Err(UnindexedClientError::Truncated { fills_read: 5_000 })
10429 ),
10430 "got {result:?}"
10431 );
10432 }
10433
10434 #[tokio::test]
10437 async fn fetch_trades_returns_only_the_span() {
10438 let (start, end) = (
10439 time("2025-04-18T14:30:00.123456Z"),
10440 time("2025-04-18T14:30:05.678901Z"),
10441 );
10442 let micro = TimeDelta::microseconds(1);
10443 let server = serve_activities(vec![
10444 fill_at("1::before", "SPY", start - micro),
10445 fill_at("2::start", "SPY", start),
10446 fill_at("3::end", "SPY", end),
10447 fill_at("4::after", "SPY", end + micro),
10448 ])
10449 .await;
10450
10451 let (ids, calls) = read_span(&client_for(&server), start, end, &[]).await;
10452 assert_eq!(calls, 1);
10453 let mut ids: Vec<_> = ids.into_iter().collect();
10454 ids.sort();
10455 assert_eq!(ids, ["end", "start"]);
10456 }
10457
10458 #[tokio::test]
10461 async fn fetch_trades_that_read_past_the_end_is_complete() {
10462 use crate::client::ExecutionClient;
10463
10464 let server = MockServer::start().await;
10465 Mock::given(method("GET"))
10467 .and(path("/v2/account/activities"))
10468 .respond_with(
10469 ResponseTemplate::new(200)
10470 .set_body_json(make_activities_json(ALPACA_MAX_ACTIVITIES, "act")),
10471 )
10472 .mount(&server)
10473 .await;
10474
10475 let Ok(read) = client_for(&server)
10476 .fetch_trades(recovery_after(), time("2025-04-18T14:29:00Z"), &[])
10477 .await
10478 else {
10479 panic!("the read succeeds");
10480 };
10481 assert_eq!(read, TradesRead::complete(Vec::new()));
10482 }
10483
10484 #[tokio::test]
10488 async fn fetch_trades_accepts_the_extreme_times() {
10489 use crate::client::ExecutionClient;
10490
10491 let server = serve_activities(vec![fill_at(
10492 "1::fill",
10493 "SPY",
10494 time("2025-04-18T14:30:00Z"),
10495 )])
10496 .await;
10497 let Ok(read) = client_for(&server)
10498 .fetch_trades(DateTime::<Utc>::MIN_UTC, DateTime::<Utc>::MAX_UTC, &[])
10499 .await
10500 else {
10501 panic!("the read succeeds");
10502 };
10503 assert_eq!(read.trades.len(), 1);
10504 assert_eq!(read.resume, None);
10505
10506 let Some(requests) = server.received_requests().await else {
10507 panic!("requests are recorded");
10508 };
10509 let [request] = requests.as_slice() else {
10510 panic!("one request: {requests:?}");
10511 };
10512 let query: std::collections::HashMap<_, _> =
10513 request.url.query_pairs().into_owned().collect();
10514 assert_eq!(
10515 query.get("after").map(String::as_str),
10516 Some("1970-01-01T00:00:00.000Z")
10517 );
10518 assert!(!query.contains_key("until"), "{query:?}");
10519
10520 let before_epoch = DateTime::UNIX_EPOCH - TimeDelta::days(1);
10522 let Ok(read) = client_for(&server)
10523 .fetch_trades(DateTime::<Utc>::MIN_UTC, before_epoch, &[])
10524 .await
10525 else {
10526 panic!("the read succeeds");
10527 };
10528 assert_eq!(read, TradesRead::complete(Vec::new()));
10529 assert_eq!(server.received_requests().await.map(|r| r.len()), Some(1));
10530 }
10531
10532 #[tokio::test]
10534 async fn fetch_trades_reads_nothing_for_an_empty_span() {
10535 use crate::client::ExecutionClient;
10536
10537 let server = MockServer::start().await;
10538 let start = recovery_after();
10539 let Ok(read) = client_for(&server)
10540 .fetch_trades(start, start - TimeDelta::seconds(1), &[])
10541 .await
10542 else {
10543 panic!("an empty span reads");
10544 };
10545 assert_eq!(read, TradesRead::complete(Vec::new()));
10546 assert_eq!(server.received_requests().await.map(|r| r.len()), Some(0));
10547 }
10548
10549 #[tokio::test]
10552 async fn recovery_reads_from_the_start_of_the_disconnects_millisecond() {
10553 let server = MockServer::start().await;
10554 Mock::given(method("GET"))
10555 .and(path("/v2/account/activities"))
10556 .and(wiremock::matchers::query_param(
10557 "after",
10558 "2025-01-01T00:00:00.123Z",
10559 ))
10560 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([])))
10561 .expect(1)
10562 .mount(&server)
10563 .await;
10564
10565 let (tx, _rx) = mpsc::unbounded_channel();
10566 let outcome = recover_fills(
10567 &reqwest::Client::new(),
10568 &RateLimitTracker::new(),
10569 &[],
10570 &server.uri(),
10571 time("2025-01-01T00:00:00.123456Z"),
10572 &tx,
10573 &new_dedup_cache(),
10574 &KnownLiveOrders::shared(ExchangeId::AlpacaBroker),
10575 )
10576 .await;
10577 assert_eq!(outcome, Ok(()));
10578 server.verify().await;
10579 }
10580
10581 fn dedup_keys(dedup: &SharedDedupCache) -> Vec<String> {
10583 let mut keys: Vec<_> = dedup
10584 .lock()
10585 .iter()
10586 .filter_map(|(key, _)| match key {
10587 FillKey::Cumulative(key) => Some(key.to_string()),
10588 FillKey::Execution(_) | FillKey::CumulativeWithoutId(_) => None,
10589 })
10590 .collect();
10591 keys.reverse();
10593 keys
10594 }
10595
10596 fn trade_of(
10597 event: &UnindexedAccountEvent,
10598 ) -> &Trade<AssetNameExchange, InstrumentNameExchange> {
10599 match &event.kind {
10600 AccountEventKind::Trade(t) => t,
10601 other => panic!("expected a Trade event, got {other:?}"),
10602 }
10603 }
10604
10605 #[tokio::test]
10608 async fn a_recovered_fill_reports_the_cumulative_alpaca_sent() {
10609 let events = drive_recover_fills(vec![
10610 activity_json("act-1", "ord-1", "2", Some("2")),
10611 activity_json("act-2", "ord-1", "3", Some("5")),
10612 ])
10613 .await;
10614
10615 let reported: Vec<_> = events
10616 .iter()
10617 .map(|e| trade_of(e).order_filled_quantity)
10618 .collect();
10619 assert_eq!(
10620 reported,
10621 vec![
10622 Some(Decimal::from_str("2").unwrap()),
10623 Some(Decimal::from_str("5").unwrap())
10624 ]
10625 );
10626 }
10627
10628 #[tokio::test]
10635 async fn the_dedup_key_uses_the_venue_cumulative_not_an_intra_batch_count() {
10636 let dedup = new_dedup_cache();
10638 drive_recover_fills_with(
10639 vec![activity_json("act-1", "ord-1", "2", Some("5"))],
10640 dedup.clone(),
10641 &KnownLiveOrders::shared(ExchangeId::AlpacaBroker),
10642 )
10643 .await;
10644
10645 assert_eq!(
10646 dedup_keys(&dedup),
10647 vec!["ord-1:5"],
10648 "counting executions within the batch would key this as ord-1:2"
10649 );
10650 }
10651
10652 async fn recover_after_ws_fill(
10656 execution_id: Option<&str>,
10657 activity_id: &str,
10658 ) -> Vec<UnindexedAccountEvent> {
10659 let dedup = new_dedup_cache();
10660 let update = AlpacaTradeUpdate {
10661 event: SmolStr::new("partial_fill"),
10662 execution_id,
10663 previous_execution_id: None,
10664 order: super::make_order_ws("ord-1", "SPY", "buy", "5"),
10665 price: Some("100.00"),
10666 qty: Some("2"),
10667 timestamp: None,
10668 };
10669 assert!(
10670 !is_duplicate_fill(&dedup, ws_execution_id(&update), early_dedup_key(&update)),
10671 "precondition: first sighting"
10672 );
10673
10674 drive_recover_fills_with(
10675 vec![activity_json(activity_id, "ord-1", "2", Some("5"))],
10676 dedup,
10677 &KnownLiveOrders::shared(ExchangeId::AlpacaBroker),
10678 )
10679 .await
10680 }
10681
10682 #[tokio::test]
10685 async fn a_fill_already_delivered_over_websocket_is_not_recovered_twice() {
10686 for (ws_execution_id, activity_id) in [
10687 (Some("exec-a"), "20250418103000000::exec-a"),
10688 (None, "20250418103000000::exec-a"),
10689 (Some("exec-a"), "act-1"),
10690 ] {
10691 let events = recover_after_ws_fill(ws_execution_id, activity_id).await;
10692 assert!(
10693 events.is_empty(),
10694 "a fill already delivered over WebSocket ({ws_execution_id:?}) must not be \
10695 re-delivered by recovery ({activity_id}), got {events:?}"
10696 );
10697 }
10698 }
10699
10700 async fn recover_after_correction(fields: &str) -> Vec<UnindexedAccountEvent> {
10703 let (tx, _rx) = mpsc::unbounded_channel();
10704 let dedup = new_dedup_cache();
10705 let known = KnownLiveOrders::shared(ExchangeId::AlpacaBroker);
10706 process_ws_text(
10707 &super::amendment_frame("trade_correct", fields),
10708 &tx,
10709 &dedup,
10710 &known,
10711 );
10712
10713 drive_recover_fills_with(
10714 vec![activity_json(
10715 "20250418103100000::exec-c",
10716 "ord-1",
10717 "2",
10718 Some("2"),
10719 )],
10720 dedup,
10721 &known,
10722 )
10723 .await
10724 }
10725
10726 #[tokio::test]
10729 async fn a_corrected_execution_is_not_recovered_again() {
10730 let events = recover_after_correction(
10731 r#""execution_id":"exec-c","previous_execution_id":"exec-a","price":"101.50","qty":"2","#,
10732 )
10733 .await;
10734 assert!(
10735 events.is_empty(),
10736 "the corrected execution was delivered as the replacement, got {events:?}"
10737 );
10738 }
10739
10740 #[tokio::test]
10742 async fn an_unresolved_correction_does_not_keep_its_execution_from_recovery() {
10743 let events = recover_after_correction(
10744 r#""execution_id":"exec-c","previous_execution_id":"exec-a","qty":"2","#,
10745 )
10746 .await;
10747 let ids: Vec<_> = events.iter().map(|e| trade_of(e).id.0.as_str()).collect();
10748 assert_eq!(ids, vec!["exec-c"]);
10749 }
10750
10751 #[tokio::test]
10754 async fn a_different_execution_at_a_cumulative_already_seen_is_recovered() {
10755 let events = recover_after_ws_fill(Some("exec-a"), "20250418103000000::exec-b").await;
10756 let ids: Vec<_> = events.iter().map(|e| trade_of(e).id.0.as_str()).collect();
10757 assert_eq!(ids, vec!["exec-b"]);
10758 }
10759
10760 #[tokio::test]
10763 async fn without_a_reported_cumulative_the_previous_behaviour_is_preserved() {
10764 let dedup = new_dedup_cache();
10765 let events = drive_recover_fills_with(
10766 vec![
10767 activity_json("act-1", "ord-1", "2", None),
10768 activity_json("act-2", "ord-1", "3", None),
10769 ],
10770 dedup.clone(),
10771 &KnownLiveOrders::shared(ExchangeId::AlpacaBroker),
10772 )
10773 .await;
10774
10775 assert_eq!(
10776 dedup_keys(&dedup),
10777 vec!["ord-1:2", "ord-1:5"],
10778 "intra-batch accumulation"
10779 );
10780 let ids: Vec<_> = events.iter().map(|e| trade_of(e).id.0.as_str()).collect();
10781 assert_eq!(ids, vec!["act-1", "act-2"], "each keeps its activity's id");
10782
10783 assert!(
10784 events
10785 .iter()
10786 .all(|e| trade_of(e).order_filled_quantity.is_none()),
10787 "a venue that reported no cumulative must not have one invented for it"
10788 );
10789 }
10790
10791 fn order_json(cid: &str, symbol: &str, status: &str, filled: &str) -> serde_json::Value {
10796 serde_json::json!({
10797 "id": format!("id-{cid}"),
10798 "client_order_id": cid,
10799 "symbol": symbol,
10800 "qty": "2",
10801 "filled_qty": filled,
10802 "filled_avg_price": if filled == "0" { None } else { Some("101.5") },
10803 "status": status,
10804 "side": "buy",
10805 "type": "limit",
10806 "time_in_force": "gtc",
10807 "limit_price": "100",
10808 "created_at": "2026-10-01T14:30:00Z",
10809 "updated_at": "2026-10-01T15:00:00Z"
10810 })
10811 }
10812
10813 async fn mount_lookup(server: &MockServer, cid: &str, order: ResponseTemplate, calls: u64) {
10815 use wiremock::matchers::query_param;
10816 Mock::given(method("GET"))
10817 .and(path("/v2/orders:by_client_order_id"))
10818 .and(query_param("client_order_id", cid))
10819 .respond_with(order)
10820 .expect(calls)
10821 .mount(server)
10822 .await;
10823 }
10824
10825 fn ok(body: serde_json::Value) -> ResponseTemplate {
10826 ResponseTemplate::new(200).set_body_json(body)
10827 }
10828
10829 fn alpaca_key(instrument: &str, cid: &str) -> UnindexedOrderKey {
10830 OrderKey::new(
10831 ExchangeId::AlpacaBroker,
10832 InstrumentNameExchange::new(instrument),
10833 StrategyId::new("strategy"),
10834 ClientOrderId::new(cid),
10835 )
10836 }
10837
10838 fn hold(known: &SharedKnownLiveOrders, key: &UnindexedOrderKey) {
10839 let open = Open::new(
10840 VenueOrderId::Assigned(OrderId::new(format!("id-{}", key.cid))),
10841 Utc::now(),
10842 Decimal::ZERO,
10843 );
10844 known
10845 .lock()
10846 .live(key, Decimal::TWO, OrderKind::Limit, &open);
10847 }
10848
10849 #[tokio::test]
10850 async fn fetch_ended_orders_reads_each_end_alpaca_reports() {
10851 use rust_decimal_macros::dec;
10852
10853 let server = MockServer::start().await;
10854 for (cid, status, filled) in [
10855 ("filled", "filled", "2"),
10856 ("canceled", "canceled", "1"),
10857 ("replaced", "replaced", "0"),
10858 ("expired", "expired", "1"),
10859 ("rejected", "rejected", "0"),
10860 ("done", "done_for_day", "1"),
10861 ("live", "new", "0"),
10862 ("unknown-status", "frozen", "0"),
10863 ] {
10864 mount_lookup(&server, cid, ok(order_json(cid, "SPY", status, filled)), 1).await;
10865 }
10866 mount_lookup(
10867 &server,
10868 "elsewhere",
10869 ok(order_json("elsewhere", "QQQ", "canceled", "0")),
10870 1,
10871 )
10872 .await;
10873 mount_lookup(
10874 &server,
10875 "gone",
10876 ResponseTemplate::new(404).set_body_json(serde_json::json!({
10877 "code": 40410000,
10878 "message": "order not found for gone"
10879 })),
10880 1,
10881 )
10882 .await;
10883
10884 let keys: Vec<_> = [
10885 "filled",
10886 "canceled",
10887 "replaced",
10888 "expired",
10889 "rejected",
10890 "done",
10891 "live",
10892 "unknown-status",
10893 "elsewhere",
10894 "gone",
10895 ]
10896 .into_iter()
10897 .map(|cid| alpaca_key("spy", cid))
10898 .collect();
10899 let ended = client_for(&server).fetch_ended_orders(&keys).await.unwrap();
10900
10901 let mut found: Vec<_> = ended
10902 .iter()
10903 .map(|order| (order.key.cid.0.as_str(), &order.state))
10904 .collect();
10905 found.sort_by_key(|(cid, _)| *cid);
10906 let [
10907 (canceled, InactiveOrderState::Cancelled(cancelled)),
10908 (expired, InactiveOrderState::Expired(expiry)),
10909 (filled, InactiveOrderState::FullyFilled(fill)),
10910 (rejected, InactiveOrderState::OpenFailed(failed)),
10911 (replaced, InactiveOrderState::Cancelled(replacement)),
10912 ] = found.as_slice()
10913 else {
10914 panic!("filled, canceled, replaced, expired and rejected: {found:?}");
10915 };
10916 assert_eq!(
10917 [*canceled, *expired, *filled, *rejected, *replaced],
10918 ["canceled", "expired", "filled", "rejected", "replaced"]
10919 );
10920 assert_eq!(
10921 (fill.filled_quantity, fill.avg_price),
10922 (dec!(2), Some(dec!(101.5)))
10923 );
10924 assert_eq!(
10925 cancelled.filled_quantity,
10926 Some(dec!(1)),
10927 "what filled before"
10928 );
10929 assert_eq!(expiry.filled_quantity, Some(dec!(1)));
10930 assert_eq!(replacement.filled_quantity, Some(Decimal::ZERO));
10931 assert_eq!(
10932 cancelled.time_exchange,
10933 parse_timestamp("2026-10-01T15:00:00Z").unwrap(),
10934 "stamped when the order last changed"
10935 );
10936 assert!(matches!(
10937 failed,
10938 OrderError::Rejected(ApiError::OrderRejected(message))
10939 if message.contains("id-rejected")
10940 ));
10941 assert!(
10942 ended
10943 .iter()
10944 .all(|order| order.key.strategy == StrategyId::new("strategy")
10945 && order.key.instrument == InstrumentNameExchange::new("spy")),
10946 "each carries the key it was asked for"
10947 );
10948 }
10949
10950 #[tokio::test]
10951 async fn fetch_ended_orders_fails_when_a_lookup_fails() {
10952 let server = MockServer::start().await;
10953 mount_lookup(&server, "a", ok(order_json("a", "SPY", "canceled", "0")), 1).await;
10954 mount_lookup(&server, "b", ResponseTemplate::new(500), 1).await;
10955
10956 let result = client_for(&server)
10957 .fetch_ended_orders(&[alpaca_key("SPY", "a"), alpaca_key("SPY", "b")])
10958 .await;
10959
10960 assert!(
10961 matches!(result, Err(UnindexedClientError::Connectivity(_))),
10962 "{result:?}"
10963 );
10964 }
10965
10966 #[tokio::test]
10969 async fn a_reconnect_check_lists_once_and_reports_the_orders_that_ended() {
10970 use wiremock::matchers::query_param;
10971
10972 let server = MockServer::start().await;
10973 Mock::given(method("GET"))
10974 .and(path("/v2/orders"))
10975 .and(query_param("status", "open"))
10976 .and(query_param("symbols", "QQQ,SPY"))
10977 .respond_with(ok(serde_json::json!([order_json(
10978 "spy-listed",
10979 "SPY",
10980 "new",
10981 "0"
10982 )])))
10983 .expect(1)
10984 .mount(&server)
10985 .await;
10986 mount_lookup(&server, "spy-listed", ResponseTemplate::new(500), 0).await;
10987 mount_lookup(
10988 &server,
10989 "spy-cancelled",
10990 ok(order_json("spy-cancelled", "SPY", "canceled", "1")),
10991 1,
10992 )
10993 .await;
10994 mount_lookup(
10995 &server,
10996 "spy-done",
10997 ok(order_json("spy-done", "SPY", "done_for_day", "1")),
10998 1,
10999 )
11000 .await;
11001 mount_lookup(
11002 &server,
11003 "qqq-filled",
11004 ok(order_json("qqq-filled", "QQQ", "filled", "2")),
11005 1,
11006 )
11007 .await;
11008
11009 let known = KnownLiveOrders::shared(ExchangeId::AlpacaBroker);
11010 let [listed, cancelled, done, filled] = [
11011 ("SPY", "spy-listed"),
11012 ("SPY", "spy-cancelled"),
11013 ("SPY", "spy-done"),
11014 ("QQQ", "qqq-filled"),
11015 ]
11016 .map(|(instrument, cid)| alpaca_key(instrument, cid));
11017 for key in [&listed, &cancelled, &done, &filled] {
11018 hold(&known, key);
11019 }
11020 let mut unchecked = UncheckedOrders::default();
11021 unchecked.open(known.lock().instruments());
11022 let config = Arc::new(AlpacaConfig::with_base_url(
11023 "test-key".into(),
11024 "test-secret".into(),
11025 server.uri(),
11026 ));
11027 let (tx, mut rx) = mpsc::unbounded_channel();
11028
11029 recover_alpaca_ended_orders(
11030 &reqwest::Client::new(),
11031 &Arc::new(RateLimitTracker::new()),
11032 &config,
11033 &known,
11034 &mut unchecked,
11035 &tx,
11036 )
11037 .await;
11038
11039 let mut reported: Vec<_> = std::iter::from_fn(|| rx.try_recv().ok())
11040 .map(|event| {
11041 let AccountEventKind::OrderSnapshot(
11042 rustrade_integration::collection::snapshot::Snapshot(order),
11043 ) = event.kind
11044 else {
11045 panic!("an order snapshot: {event:?}");
11046 };
11047 assert!(matches!(order.state, OrderState::Inactive(_)), "{order:?}");
11048 order.key.cid
11049 })
11050 .collect();
11051 reported.sort();
11052 assert_eq!(reported, [filled.cid.clone(), cancelled.cid.clone()]);
11053 let known = known.lock();
11054 assert!(known.contains(&listed.cid) && known.contains(&done.cid));
11055 assert!(!known.contains(&cancelled.cid) && !known.contains(&filled.cid));
11056 assert!(unchecked.is_empty(), "both instruments checked");
11057 }
11058
11059 #[tokio::test]
11061 async fn a_failed_listing_is_retried_for_every_instrument_in_it() {
11062 let server = MockServer::start().await;
11063 Mock::given(method("GET"))
11064 .and(path("/v2/orders"))
11065 .respond_with(ResponseTemplate::new(503))
11066 .expect(1)
11067 .mount(&server)
11068 .await;
11069
11070 let known = KnownLiveOrders::shared(ExchangeId::AlpacaBroker);
11071 hold(&known, &alpaca_key("SPY", "a"));
11072 hold(&known, &alpaca_key("QQQ", "b"));
11073 let mut unchecked = UncheckedOrders::default();
11074 unchecked.open(known.lock().instruments());
11075 let config = Arc::new(AlpacaConfig::with_base_url(
11076 "test-key".into(),
11077 "test-secret".into(),
11078 server.uri(),
11079 ));
11080 let (tx, mut rx) = mpsc::unbounded_channel();
11081
11082 recover_alpaca_ended_orders(
11083 &reqwest::Client::new(),
11084 &Arc::new(RateLimitTracker::new()),
11085 &config,
11086 &known,
11087 &mut unchecked,
11088 &tx,
11089 )
11090 .await;
11091
11092 assert!(rx.try_recv().is_err(), "nothing reported");
11093 let now = tokio::time::Instant::now();
11094 assert!(
11095 unchecked.ready(&NoPendingFills, now).is_empty(),
11096 "both wait"
11097 );
11098 for instrument in ["QQQ", "SPY"] {
11099 assert_eq!(
11100 unchecked.failed(
11101 &InstrumentNameExchange::new(instrument),
11102 now,
11103 Duration::ZERO
11104 ),
11105 Some(crate::client::order_recovery::GapFailure::Retry(
11106 Duration::from_secs(crate::client::order_recovery::GAP_RETRY_BASE_SECS * 2)
11107 )),
11108 "{instrument} has failed once already"
11109 );
11110 }
11111 assert_eq!(known.lock().instruments().len(), 2, "both still held");
11112 }
11113
11114 #[tokio::test]
11115 async fn placing_listing_and_cancelling_keep_the_held_orders_in_step() {
11116 use crate::order::request::{OrderRequestCancel, RequestCancel, RequestOpen};
11117
11118 let server = MockServer::start().await;
11119 Mock::given(method("POST"))
11120 .and(path("/v2/orders"))
11121 .respond_with(ok(order_json("resting", "SPY", "new", "0")))
11122 .up_to_n_times(1)
11123 .mount(&server)
11124 .await;
11125 Mock::given(method("POST"))
11126 .and(path("/v2/orders"))
11127 .respond_with(ok(order_json("filled", "SPY", "filled", "2")))
11128 .mount(&server)
11129 .await;
11130 Mock::given(method("GET"))
11131 .and(path("/v2/orders"))
11132 .respond_with(ok(serde_json::json!([order_json(
11133 "listed", "QQQ", "new", "0"
11134 )])))
11135 .mount(&server)
11136 .await;
11137 Mock::given(method("DELETE"))
11138 .and(path("/v2/orders/id-resting"))
11139 .respond_with(ResponseTemplate::new(204))
11140 .mount(&server)
11141 .await;
11142 let client = client_for(&server);
11143 let spy = InstrumentNameExchange::new("SPY");
11144
11145 for cid in ["resting", "filled"] {
11146 let request = OrderRequestOpen {
11147 key: OrderKey::new(
11148 ExchangeId::AlpacaBroker,
11149 &spy,
11150 StrategyId::new("strategy"),
11151 ClientOrderId::new(cid),
11152 ),
11153 state: RequestOpen::new(
11154 Side::Buy,
11155 Some(Decimal::ONE_HUNDRED),
11156 Decimal::TWO,
11157 OrderKind::Limit,
11158 TimeInForce::GoodUntilCancelled { post_only: false },
11159 ),
11160 };
11161 client.open_order(request).await;
11162 }
11163 client.fetch_open_orders(&[]).await.unwrap();
11164 {
11165 let known = client.known_live.lock();
11166 assert!(known.contains(&ClientOrderId::new("resting")));
11167 assert!(
11168 !known.contains(&ClientOrderId::new("filled")),
11169 "filled in its placement response"
11170 );
11171 assert!(known.contains(&ClientOrderId::new("listed")));
11172 }
11173
11174 let cancel = client
11175 .cancel_order(OrderRequestCancel {
11176 key: OrderKey::new(
11177 ExchangeId::AlpacaBroker,
11178 &spy,
11179 StrategyId::new("strategy"),
11180 ClientOrderId::new("resting"),
11181 ),
11182 state: RequestCancel {
11183 id: Some(VenueOrderId::Assigned(OrderId::new("id-resting"))),
11184 },
11185 })
11186 .await;
11187 let Ok(cancelled) = &cancel.state else {
11188 panic!("expected Ok, got {cancel:?}");
11189 };
11190 assert_eq!(
11191 cancelled.filled_quantity, None,
11192 "the DELETE response has no body, so what filled is unknown"
11193 );
11194 assert!(
11195 client
11196 .known_live
11197 .lock()
11198 .contains(&ClientOrderId::new("resting")),
11199 "a cancel Alpaca has only accepted has not ended the order"
11200 );
11201 }
11202
11203 #[tokio::test]
11210 async fn an_order_that_ended_in_its_placement_response_is_reported_as_it_ended() {
11211 use crate::order::request::RequestOpen;
11212
11213 let Some(time) = parse_timestamp("2026-10-01T15:00:00Z") else {
11214 panic!("order_json's updated_at parses");
11215 };
11216 let id = |cid: &str| OrderId::new(format!("id-{cid}"));
11217 let open = |cid: &str, filled| {
11218 OrderState::active(Open::new(VenueOrderId::Assigned(id(cid)), time, filled))
11219 };
11220 let cases: [(&str, &str, &str, UnindexedOrderState, bool); 13] = [
11221 (
11222 "ioc-unfilled",
11223 "expired",
11224 "0",
11225 OrderState::expired(Expired::new(
11226 id("ioc-unfilled"),
11227 time,
11228 Some(Decimal::ZERO),
11229 )),
11230 false,
11231 ),
11232 (
11233 "ioc-partial",
11234 "expired",
11235 "1",
11236 OrderState::expired(Expired::new(id("ioc-partial"), time, Some(Decimal::ONE))),
11237 false,
11238 ),
11239 (
11240 "fill-unknown",
11241 "expired",
11242 "not-a-number",
11243 OrderState::expired(Expired::new(id("fill-unknown"), time, None)),
11244 false,
11245 ),
11246 (
11247 "cancelled",
11248 "canceled",
11249 "1",
11250 OrderState::inactive(Cancelled::new(id("cancelled"), time, Some(Decimal::ONE))),
11251 false,
11252 ),
11253 (
11254 "rejected",
11255 "rejected",
11256 "0",
11257 OrderState::inactive(OrderError::Rejected(ApiError::OrderRejected(
11258 "Alpaca rejected order id-rejected after accepting it".to_string(),
11259 ))),
11260 false,
11261 ),
11262 (
11263 "filled",
11264 "filled",
11265 "2",
11266 OrderState::fully_filled(Filled::new(
11267 id("filled"),
11268 time,
11269 Decimal::TWO,
11270 Some(Decimal::new(1015, 1)),
11271 )),
11272 false,
11273 ),
11274 (
11275 "replaced",
11276 "replaced",
11277 "0",
11278 OrderState::inactive(Cancelled::new(id("replaced"), time, Some(Decimal::ZERO))),
11279 false,
11280 ),
11281 (
11282 "live-covered",
11283 "partially_filled",
11284 "2",
11285 OrderState::fully_filled(Filled::new(
11286 id("live-covered"),
11287 time,
11288 Decimal::TWO,
11289 Some(Decimal::new(1015, 1)),
11290 )),
11291 false,
11292 ),
11293 ("new", "new", "0", open("new", Decimal::ZERO), true),
11294 (
11295 "live-fill-unknown",
11296 "new",
11297 "not-a-number",
11298 open("live-fill-unknown", Decimal::ZERO),
11299 true,
11300 ),
11301 (
11302 "partial",
11303 "partially_filled",
11304 "1",
11305 open("partial", Decimal::ONE),
11306 true,
11307 ),
11308 (
11309 "for-the-day",
11310 "done_for_day",
11311 "1",
11312 open("for-the-day", Decimal::ONE),
11313 true,
11314 ),
11315 (
11316 "unknown",
11317 "a_status_not_yet_documented",
11318 "0",
11319 open("unknown", Decimal::ZERO),
11320 true,
11321 ),
11322 ];
11323
11324 for (cid, status, filled, expected, held) in cases {
11325 for bracket in [false, true] {
11326 let server = MockServer::start().await;
11327 Mock::given(method("POST"))
11328 .and(path("/v2/orders"))
11329 .respond_with(ok(order_json(cid, "SPY", status, filled)))
11330 .expect(1)
11331 .mount(&server)
11332 .await;
11333 let client = client_for(&server);
11334 let spy = InstrumentNameExchange::new("SPY");
11335 let time_in_force = TimeInForce::GoodUntilCancelled { post_only: false };
11336
11337 let state = if bracket {
11338 client
11339 .open_bracket_order(AlpacaBracketOrderRequest::new(
11340 spy,
11341 StrategyId::new("strategy"),
11342 ClientOrderId::new(cid),
11343 Side::Buy,
11344 Decimal::TWO,
11345 Decimal::ONE_HUNDRED,
11346 Decimal::new(120, 0),
11347 Decimal::new(90, 0),
11348 time_in_force,
11349 ))
11350 .await
11351 .parent
11352 .state
11353 } else {
11354 let request = OrderRequestOpen {
11355 key: OrderKey::new(
11356 ExchangeId::AlpacaBroker,
11357 &spy,
11358 StrategyId::new("strategy"),
11359 ClientOrderId::new(cid),
11360 ),
11361 state: RequestOpen::new(
11362 Side::Buy,
11363 Some(Decimal::ONE_HUNDRED),
11364 Decimal::TWO,
11365 OrderKind::Limit,
11366 time_in_force,
11367 ),
11368 };
11369 client.open_order(request).await.state
11370 };
11371
11372 assert_eq!(state, expected, "{status} {filled}, bracket: {bracket}");
11373 assert_eq!(
11374 client.known_live.lock().contains(&ClientOrderId::new(cid)),
11375 held,
11376 "{status} {filled}, bracket: {bracket}"
11377 );
11378 }
11379 }
11380 }
11381
11382 #[test]
11385 fn the_stream_keeps_the_held_orders_in_step() {
11386 let (tx, mut rx) = mpsc::unbounded_channel();
11387 let dedup = new_dedup_cache();
11388 let known = KnownLiveOrders::shared(ExchangeId::AlpacaBroker);
11389 let frame = |event: &str, filled: &str| {
11390 format!(
11391 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}"}}}}}}"#
11392 )
11393 };
11394 let cid = ClientOrderId::new("a");
11395
11396 process_ws_text(&frame("new", "0"), &tx, &dedup, &known);
11397 assert!(known.lock().contains(&cid), "acknowledged");
11398
11399 process_ws_text(&frame("done_for_day", "1"), &tx, &dedup, &known);
11400 assert!(known.lock().contains(&cid), "done for the day is not ended");
11401
11402 process_ws_text(&frame("canceled", "1"), &tx, &dedup, &known);
11403 assert!(!known.lock().contains(&cid), "cancelled");
11404
11405 let events: Vec<_> = std::iter::from_fn(|| rx.try_recv().ok()).collect();
11406 let [_, done, cancelled] = events.as_slice() else {
11407 panic!("three events: {events:?}");
11408 };
11409 let AccountEventKind::OrderSnapshot(
11410 rustrade_integration::collection::snapshot::Snapshot(order),
11411 ) = &done.kind
11412 else {
11413 panic!("done_for_day reports an order snapshot: {done:?}");
11414 };
11415 let OrderState::Active(ActiveOrderState::Open(open)) = &order.state else {
11416 panic!("open: {order:?}");
11417 };
11418 assert_eq!(open.filled_quantity, Decimal::ONE, "with what it filled");
11419 assert!(matches!(
11420 cancelled.kind,
11421 AccountEventKind::OrderCancelled(_)
11422 ));
11423 }
11424
11425 #[tokio::test]
11428 async fn a_recovered_fill_that_completes_an_order_stops_it_being_held() {
11429 let known = KnownLiveOrders::shared(ExchangeId::AlpacaBroker);
11430 let key = alpaca_key("SPY", "a");
11431 known.lock().live(
11432 &key,
11433 Decimal::TWO,
11434 OrderKind::Limit,
11435 &Open::new(
11436 VenueOrderId::Assigned(OrderId::new("ord-1")),
11437 Utc::now(),
11438 Decimal::ZERO,
11439 ),
11440 );
11441
11442 drive_recover_fills_with(
11443 vec![activity_json("act-1", "ord-1", "1", Some("1"))],
11444 new_dedup_cache(),
11445 &known,
11446 )
11447 .await;
11448 assert!(known.lock().contains(&key.cid), "half filled");
11449
11450 drive_recover_fills_with(
11451 vec![activity_json("act-2", "ord-1", "1", Some("2"))],
11452 new_dedup_cache(),
11453 &known,
11454 )
11455 .await;
11456 assert!(!known.lock().contains(&key.cid), "filled");
11457 }
11458
11459 #[tokio::test]
11462 async fn the_check_loop_runs_at_once_then_waits_out_a_failure() {
11463 let server = MockServer::start().await;
11464 Mock::given(method("GET"))
11465 .and(path("/v2/orders"))
11466 .respond_with(ResponseTemplate::new(503))
11467 .expect(1)
11468 .mount(&server)
11469 .await;
11470 let known = KnownLiveOrders::shared(ExchangeId::AlpacaBroker);
11471 hold(&known, &alpaca_key("SPY", "a"));
11472 let mut unchecked = UncheckedOrders::default();
11473 unchecked.open(known.lock().instruments());
11474 let config = Arc::new(AlpacaConfig::with_base_url(
11475 "test-key".into(),
11476 "test-secret".into(),
11477 server.uri(),
11478 ));
11479 let (tx, _rx) = mpsc::unbounded_channel();
11480
11481 let ran = tokio::time::timeout(
11482 Duration::from_secs(1),
11483 run_order_checks(
11484 &reqwest::Client::new(),
11485 &Arc::new(RateLimitTracker::new()),
11486 &config,
11487 &known,
11488 &mut unchecked,
11489 &tx,
11490 ),
11491 )
11492 .await;
11493
11494 assert!(ran.is_err(), "the loop never completes");
11495 assert!(
11496 unchecked.contains(&InstrumentNameExchange::new("SPY")),
11497 "SPY waits for its retry"
11498 );
11499 assert_eq!(
11500 server.received_requests().await.unwrap().len(),
11501 1,
11502 "the failed check is not run again at once"
11503 );
11504 }
11505
11506 #[tokio::test]
11508 async fn the_check_loop_waits_once_the_consumer_has_gone() {
11509 let server = MockServer::start().await;
11510 Mock::given(method("GET"))
11511 .respond_with(ResponseTemplate::new(503))
11512 .expect(0)
11513 .mount(&server)
11514 .await;
11515 let known = KnownLiveOrders::shared(ExchangeId::AlpacaBroker);
11516 hold(&known, &alpaca_key("SPY", "a"));
11517 let mut unchecked = UncheckedOrders::default();
11518 unchecked.open(known.lock().instruments());
11519 let config = Arc::new(AlpacaConfig::with_base_url(
11520 "test-key".into(),
11521 "test-secret".into(),
11522 server.uri(),
11523 ));
11524 let (tx, rx) = mpsc::unbounded_channel();
11525 drop(rx);
11526
11527 let ran = tokio::time::timeout(
11528 Duration::from_millis(200),
11529 run_order_checks(
11530 &reqwest::Client::new(),
11531 &Arc::new(RateLimitTracker::new()),
11532 &config,
11533 &known,
11534 &mut unchecked,
11535 &tx,
11536 ),
11537 )
11538 .await;
11539
11540 assert!(ran.is_err(), "the loop never completes");
11541 assert!(
11542 server.received_requests().await.unwrap().is_empty(),
11543 "nothing asked once the consumer has gone"
11544 );
11545 }
11546
11547 async fn shortability_with_asset(
11548 symbol: &str,
11549 asset: serde_json::Value,
11550 ) -> (Result<Shortability, UnindexedClientError>, Vec<String>) {
11551 use crate::client::ShortabilityClient;
11552
11553 let server = MockServer::start().await;
11554 Mock::given(method("GET"))
11555 .respond_with(ResponseTemplate::new(200).set_body_json(asset))
11556 .mount(&server)
11557 .await;
11558 let result = client_for(&server)
11559 .fetch_shortability(&InstrumentNameExchange::new(symbol))
11560 .await;
11561 let paths = server
11562 .received_requests()
11563 .await
11564 .unwrap()
11565 .iter()
11566 .map(|request| request.url.path().to_owned())
11567 .collect();
11568 (result, paths)
11569 }
11570
11571 #[tokio::test]
11572 async fn an_easy_to_borrow_stock_is_shortable_at_no_fee() {
11573 let (result, paths) = shortability_with_asset(
11574 "AAPL",
11575 serde_json::json!({
11576 "class": "us_equity",
11577 "symbol": "AAPL",
11578 "shortable": true,
11579 "easy_to_borrow": true,
11580 "borrow_status": "easy_to_borrow",
11581 }),
11582 )
11583 .await;
11584
11585 assert_eq!(
11586 result.unwrap(),
11587 Shortability::new()
11588 .with_shortable(true)
11589 .with_easy_to_borrow(true)
11590 .with_fee_rate(Decimal::ZERO)
11591 );
11592 assert_eq!(paths, vec!["/v2/assets/AAPL"]);
11593 }
11594
11595 #[tokio::test]
11596 async fn a_hard_to_borrow_stock_has_no_known_fee() {
11597 let (result, _) = shortability_with_asset(
11598 "GME",
11599 serde_json::json!({
11600 "class": "us_equity",
11601 "shortable": true,
11602 "easy_to_borrow": true,
11603 "borrow_status": "hard_to_borrow",
11604 }),
11605 )
11606 .await;
11607
11608 assert_eq!(
11609 result.unwrap(),
11610 Shortability::new()
11611 .with_shortable(true)
11612 .with_easy_to_borrow(false),
11613 "borrow_status wins over the easy_to_borrow flag, and a hard-to-borrow fee is unknown"
11614 );
11615 }
11616
11617 #[tokio::test]
11618 async fn without_a_borrow_status_the_easy_to_borrow_flag_is_read() {
11619 let (result, _) = shortability_with_asset(
11620 "AAPL",
11621 serde_json::json!({ "class": "us_equity", "shortable": false, "easy_to_borrow": false }),
11622 )
11623 .await;
11624
11625 assert_eq!(
11626 result.unwrap(),
11627 Shortability::new()
11628 .with_shortable(false)
11629 .with_easy_to_borrow(false)
11630 );
11631 }
11632
11633 #[tokio::test]
11634 async fn an_asset_without_borrow_fields_is_unknown_but_for_shortable() {
11635 let (result, paths) = shortability_with_asset(
11636 "BTC/USD",
11637 serde_json::json!({ "class": "crypto", "symbol": "BTC/USD", "shortable": false }),
11638 )
11639 .await;
11640
11641 assert_eq!(result.unwrap(), Shortability::new().with_shortable(false));
11642 assert_eq!(
11643 paths,
11644 vec!["/v2/assets/BTC%2FUSD"],
11645 "a crypto pair's slash is escaped, not read as a path separator"
11646 );
11647 }
11648
11649 #[tokio::test]
11650 async fn an_option_has_no_shortability() {
11651 let (result, _) = shortability_with_asset(
11652 "AAPL250117C00150000",
11653 serde_json::json!({ "class": "us_option", "shortable": true }),
11654 )
11655 .await;
11656
11657 assert!(
11658 matches!(
11659 result,
11660 Err(UnindexedClientError::Api(ApiError::InstrumentInvalid(ref instrument, ref reason)))
11661 if instrument.name().as_str() == "AAPL250117C00150000"
11662 && reason.contains("option")
11663 ),
11664 "{result:?}"
11665 );
11666 }
11667
11668 #[tokio::test]
11669 async fn an_unknown_symbol_is_an_invalid_instrument() {
11670 use crate::client::ShortabilityClient;
11671
11672 let server = MockServer::start().await;
11673 Mock::given(method("GET"))
11674 .and(path("/v2/assets/NOPE"))
11675 .respond_with(
11676 ResponseTemplate::new(404).set_body_json(
11677 serde_json::json!({ "message": "asset not found for NOPE" }),
11678 ),
11679 )
11680 .mount(&server)
11681 .await;
11682
11683 let result = client_for(&server)
11684 .fetch_shortability(&InstrumentNameExchange::new("NOPE"))
11685 .await;
11686
11687 assert!(
11688 matches!(
11689 result,
11690 Err(UnindexedClientError::Api(ApiError::InstrumentInvalid(ref instrument, _)))
11691 if instrument.name().as_str() == "NOPE"
11692 ),
11693 "{result:?}"
11694 );
11695 }
11696
11697 struct RateLimited {
11700 call: std::sync::atomic::AtomicU32,
11701 last: Option<(&'static str, String)>,
11702 }
11703
11704 impl RateLimited {
11705 fn mount(last: Option<(&'static str, String)>) -> Self {
11706 Self {
11707 call: std::sync::atomic::AtomicU32::new(0),
11708 last,
11709 }
11710 }
11711 }
11712
11713 impl Respond for RateLimited {
11714 fn respond(&self, _: &Request) -> ResponseTemplate {
11715 let call = self.call.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1;
11716 let response = ResponseTemplate::new(429)
11717 .set_body_json(serde_json::json!({"message": "too many requests"}));
11718 if call < MAX_RATE_LIMIT_ATTEMPTS {
11719 return response.insert_header("retry-after", "0");
11720 }
11721 match &self.last {
11722 Some((name, value)) => response.insert_header(*name, value.as_str()),
11723 None => response,
11724 }
11725 }
11726 }
11727
11728 fn unix_now() -> u64 {
11729 std::time::SystemTime::now()
11730 .duration_since(std::time::UNIX_EPOCH)
11731 .unwrap()
11732 .as_secs()
11733 }
11734
11735 #[tokio::test]
11736 async fn exhausted_429_retries_report_alpacas_wait() {
11737 let server = MockServer::start().await;
11738 Mock::given(method("GET"))
11739 .and(path("/v2/account"))
11740 .respond_with(RateLimited::mount(Some(("retry-after", "7".to_string()))))
11741 .mount(&server)
11742 .await;
11743
11744 let http = reqwest::Client::new();
11745 let rl = RateLimitTracker::new();
11746 let url = format!("{}/v2/account", server.uri());
11747 let error = rest_with_retry::<serde_json::Value>(&rl, || http.get(&url))
11748 .await
11749 .unwrap_err();
11750
11751 let UnindexedClientError::Api(error) = error else {
11752 panic!("expected Api, got {error:?}");
11753 };
11754 assert_eq!(error.retry_after(), Some(Duration::from_secs(7)));
11755 let blocked_until = rl
11756 .blocked_until
11757 .lock()
11758 .expect("the last refusal pauses the client");
11759 assert!(
11760 blocked_until >= tokio::time::Instant::now() + Duration::from_secs(6),
11761 "the client's next request waits for Alpaca's wait"
11762 );
11763 assert_eq!(
11764 server.received_requests().await.unwrap().len(),
11765 MAX_RATE_LIMIT_ATTEMPTS as usize
11766 );
11767 }
11768
11769 #[tokio::test]
11770 async fn exhausted_429_retries_read_the_reset_time_without_retry_after() {
11771 let server = MockServer::start().await;
11772 Mock::given(method("GET"))
11773 .and(path("/v2/account"))
11774 .respond_with(RateLimited::mount(Some((
11775 "x-ratelimit-reset",
11776 (unix_now() + 30).to_string(),
11777 ))))
11778 .mount(&server)
11779 .await;
11780
11781 let http = reqwest::Client::new();
11782 let rl = RateLimitTracker::new();
11783 let url = format!("{}/v2/account", server.uri());
11784 let error = rest_with_retry::<serde_json::Value>(&rl, || http.get(&url))
11785 .await
11786 .unwrap_err();
11787
11788 let UnindexedClientError::Api(ApiError::RateLimit {
11789 retry_after: Some(wait),
11790 ..
11791 }) = error
11792 else {
11793 panic!("expected a RateLimit with a wait, got {error:?}");
11794 };
11795 assert!(
11796 (Duration::from_secs(29)..=Duration::from_secs(30)).contains(&wait),
11797 "{wait:?}"
11798 );
11799 }
11800
11801 #[tokio::test]
11802 async fn exhausted_429_retries_without_a_hint_report_none() {
11803 let server = MockServer::start().await;
11804 Mock::given(method("GET"))
11805 .and(path("/v2/account"))
11806 .respond_with(RateLimited::mount(None))
11807 .mount(&server)
11808 .await;
11809
11810 let http = reqwest::Client::new();
11811 let rl = RateLimitTracker::new();
11812 let url = format!("{}/v2/account", server.uri());
11813 let error = rest_with_retry::<serde_json::Value>(&rl, || http.get(&url))
11814 .await
11815 .unwrap_err();
11816
11817 assert!(
11818 matches!(
11819 error,
11820 UnindexedClientError::Api(ApiError::RateLimit {
11821 retry_after: None,
11822 ..
11823 })
11824 ),
11825 "{error:?}"
11826 );
11827 }
11828
11829 #[tokio::test]
11830 async fn an_exhausted_cancel_reports_alpacas_wait() {
11831 let server = MockServer::start().await;
11832 Mock::given(method("DELETE"))
11833 .and(path("/v2/orders/abc"))
11834 .respond_with(RateLimited::mount(Some(("retry-after", "5".to_string()))))
11835 .mount(&server)
11836 .await;
11837
11838 let http = reqwest::Client::new();
11839 let rl = RateLimitTracker::new();
11840 let url = format!("{}/v2/orders/abc", server.uri());
11841 let error = rest_delete_with_retry(&rl, || http.delete(&url))
11842 .await
11843 .unwrap_err();
11844
11845 let UnindexedOrderError::Rejected(error) = error else {
11846 panic!("expected Rejected, got {error:?}");
11847 };
11848 assert_eq!(error.retry_after(), Some(Duration::from_secs(5)));
11849 assert!(
11850 rl.blocked_until.lock().is_some(),
11851 "the last refusal pauses the client"
11852 );
11853 }
11854 }
11855}