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