1use crate::{
47 AccountEventKind, AccountSnapshot, FillRecoveryFailure, FillRecoveryGap, FillRecoveryScope,
48 InstrumentAccountSnapshot, UnindexedAccountEvent, UnindexedAccountSnapshot,
49 balance::{AssetBalance, Balance},
50 cash_flow::{CashFlow, CashFlowId, CashFlowKind, CashFlowsRead},
51 client::{
52 BracketOrderClient, ExecutionClient, OrderStatusClient, ShortabilityClient,
53 order_recovery::{
54 KnownLiveOrders, NoPendingFills, OpenListing, OrderLookup, SharedKnownLiveOrders,
55 UncheckedOrders, fetch_ended_by_key, recover_ended_orders,
56 },
57 reconnect_budget::{BackoffLimits, ReconnectBudget},
58 venue_clock::{HostMoment, VenueClock},
59 },
60 emit_stream_terminated,
61 error::{
62 ApiError, BorrowReject, BorrowRejectReason, ConnectivityError, OrderError,
63 StreamTerminationReason, UnindexedApiError, UnindexedClientError, UnindexedOrderError,
64 },
65 order::{
66 Order, OrderKey, OrderKind, TimeInForce, TrailingOffsetType, UnindexedInactiveOrder,
67 UnindexedOrderKey, UnindexedOrderSnapshot,
68 bracket::{
69 BracketOrderRequest as UnifiedBracketOrderRequest,
70 BracketOrderResult as UnifiedBracketOrderResult,
71 },
72 id::{ClientOrderId, OrderId, StrategyId, VenueOrderId},
73 request::{OrderRequestCancel, OrderRequestOpen, ReduceOnly, UnindexedOrderResponseCancel},
74 state::{
75 ActiveOrderState, Cancelled, Expired, Filled, InactiveOrderState, Open, OrderState,
76 UnindexedOrderState,
77 },
78 },
79 parse_env_bool,
80 position::{Position, PositionReport},
81 shortability::Shortability,
82 trade::{AssetFees, TimeSource, Trade, TradeAmendmentKind, TradeId, TradeOrigin, TradesRead},
83};
84use chrono::{DateTime, NaiveDate, NaiveTime, SecondsFormat, TimeDelta, Utc};
85use fnv::{FnvHashMap, FnvHashSet};
86use futures::{SinkExt as _, StreamExt as _, stream::BoxStream};
87use indexmap::IndexMap;
88use itertools::Itertools as _;
89use lru::LruCache;
90use rust_decimal::Decimal;
91use rustrade_instrument::{
92 Side,
93 asset::name::AssetNameExchange,
94 exchange::ExchangeId,
95 instrument::{kind::InstrumentKindDiscriminant, name::InstrumentNameExchange},
96};
97use rustrade_integration::protocol::websocket::{WebSocket, WsMessage};
98use serde::{Deserialize, Serialize, de::DeserializeOwned};
99use smol_str::{SmolStr, format_smolstr};
100use std::{num::NonZeroUsize, pin::Pin, str::FromStr, sync::Arc, time::Duration};
101use tokio::sync::mpsc;
102use tracing::{debug, error, info, trace, warn};
103
104const HEARTBEAT_TIMEOUT_SECS: u64 = 35;
110const FILL_RECOVERY_TIMEOUT_SECS: u64 = 30;
112const SIGNAL_RECOVERY_LOOKBACK_MS: i64 = 1_500;
115const UNSAMPLED_CLOCK_SKEW: TimeDelta = TimeDelta::seconds(30);
119const ALPACA_MAX_ACTIVITIES: usize = 100;
121const DEFAULT_RATE_LIMIT_DELAY_SECS: u64 = 60;
124const MAX_RATE_LIMIT_ATTEMPTS: u32 = 4;
127const DEDUP_CACHE_SIZE: usize = 4_000;
131
132const USD: &str = "USD";
135const WS_HANDSHAKE_TIMEOUT_SECS: u64 = 15;
137const WS_CLOSE_TIMEOUT_SECS: u64 = 5;
140
141struct GracefulShutdownStream<S> {
158 inner: S,
159 _handle: tokio::task::JoinHandle<()>,
165}
166
167impl<S> GracefulShutdownStream<S> {
168 fn new(inner: S, handle: tokio::task::JoinHandle<()>) -> Self {
169 Self {
170 inner,
171 _handle: handle,
172 }
173 }
174}
175
176impl<S: futures::Stream + Unpin> futures::Stream for GracefulShutdownStream<S> {
177 type Item = S::Item;
178 fn poll_next(
179 mut self: Pin<&mut Self>,
180 cx: &mut std::task::Context<'_>,
181 ) -> std::task::Poll<Option<Self::Item>> {
182 Pin::new(&mut self.inner).poll_next(cx)
183 }
184 fn size_hint(&self) -> (usize, Option<usize>) {
185 self.inner.size_hint()
186 }
187}
188
189impl<S> Drop for GracefulShutdownStream<S> {
190 fn drop(&mut self) {
191 }
195}
196
197struct RateLimitTracker {
203 blocked_until: parking_lot::Mutex<Option<tokio::time::Instant>>,
204}
205
206impl RateLimitTracker {
207 fn new() -> Self {
208 Self {
209 blocked_until: parking_lot::Mutex::new(None),
210 }
211 }
212
213 async fn wait_if_blocked(&self) {
215 loop {
216 let now = tokio::time::Instant::now();
220 let deadline = {
228 let mut guard = self.blocked_until.lock();
229 let d = *guard;
230 if matches!(d, Some(t) if t <= now) {
231 *guard = None;
234 }
235 d
236 };
237 match deadline {
238 None => return,
239 Some(until) => {
240 if until <= now {
241 return;
243 }
244 #[allow(clippy::cast_possible_truncation)]
246 let delay_ms = (until - now).as_millis() as u64;
247 debug!(delay_ms, "Alpaca REST rate-limited, waiting before request");
248 tokio::time::sleep_until(until).await;
249 }
250 }
251 }
252 }
253
254 fn on_rate_limited(&self, retry_after: Option<Duration>) {
256 let delay = retry_after.unwrap_or(Duration::from_secs(DEFAULT_RATE_LIMIT_DELAY_SECS));
257 let was_blocked = self.extend(delay);
258 if was_blocked {
259 debug!(
260 delay_secs = delay.as_secs(),
261 "Alpaca rate-limit cooldown extended"
262 );
263 } else {
264 warn!(
265 delay_secs = delay.as_secs(),
266 "Alpaca entering rate-limit degradation mode"
267 );
268 }
269 }
270
271 fn on_bucket_exhausted(&self, reset: Duration) {
274 let was_blocked = self.extend(reset);
275 if !was_blocked {
277 info!(
278 delay_ms = u64::try_from(reset.as_millis()).unwrap_or(u64::MAX),
279 "Alpaca rate-limit window exhausted (X-Ratelimit-Remaining: 0), pausing requests until it resets"
280 );
281 }
282 }
283
284 fn extend(&self, delay: Duration) -> bool {
287 let now = tokio::time::Instant::now();
288 let new_deadline = now + delay;
289 let mut guard = self.blocked_until.lock();
290 let was_blocked = guard.is_some_and(|until| until > now);
291 *guard = Some(guard.map_or(new_deadline, |existing| existing.max(new_deadline)));
292 was_blocked
293 }
294}
295
296type SharedDedupCache = Arc<parking_lot::Mutex<LruCache<FillKey, ()>>>;
304
305#[derive(Debug, Clone, PartialEq, Eq, Hash)]
310enum FillKey {
311 Execution(SmolStr),
314 Cumulative(SmolStr),
316 CumulativeWithoutId(SmolStr),
318}
319
320fn new_dedup_cache() -> SharedDedupCache {
321 #[allow(clippy::unwrap_used)]
324 Arc::new(parking_lot::Mutex::new(LruCache::new(
325 NonZeroUsize::new(DEDUP_CACHE_SIZE).unwrap(),
326 )))
327}
328
329fn is_duplicate_fill(
349 cache: &SharedDedupCache,
350 execution_id: Option<&str>,
351 cumulative: SmolStr,
352) -> bool {
353 let execution = execution_id.map(|id| FillKey::Execution(SmolStr::new(id)));
355 let mut guard = cache.lock();
356 match execution {
358 Some(execution) => {
359 if guard.peek(&execution).is_some()
360 || guard
361 .peek(&FillKey::CumulativeWithoutId(cumulative.clone()))
362 .is_some()
363 {
364 return true;
365 }
366 guard.put(execution, ());
367 guard.put(FillKey::Cumulative(cumulative), ());
368 }
369 None => {
370 if guard
371 .peek(&FillKey::Cumulative(cumulative.clone()))
372 .is_some()
373 {
374 return true;
375 }
376 guard.put(FillKey::Cumulative(cumulative.clone()), ());
377 guard.put(FillKey::CumulativeWithoutId(cumulative), ());
378 }
379 }
380 false
381}
382
383fn record_execution(cache: &SharedDedupCache, execution_id: &str) {
386 let execution = FillKey::Execution(SmolStr::new(execution_id));
387 cache.lock().put(execution, ());
388}
389
390#[derive(Clone, Deserialize)]
397#[serde(deny_unknown_fields)]
398pub struct AlpacaConfig {
399 api_key: String,
401 secret_key: String,
402
403 #[serde(default = "default_paper")]
405 pub paper: bool,
406 #[cfg(test)]
408 #[serde(skip)]
409 base_url_override: Option<String>,
410 #[cfg(test)]
412 #[serde(skip)]
413 ws_url_override: Option<String>,
414}
415
416fn default_paper() -> bool {
422 true
423}
424
425impl std::fmt::Debug for AlpacaConfig {
427 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
428 f.debug_struct("AlpacaConfig")
429 .field("api_key", &"***")
430 .field("secret_key", &"***")
431 .field("paper", &self.paper)
432 .finish()
433 }
434}
435
436impl AlpacaConfig {
437 pub fn new(api_key: String, secret_key: String) -> Self {
440 Self::paper(api_key, secret_key)
441 }
442
443 pub fn paper(api_key: String, secret_key: String) -> Self {
445 Self {
446 api_key,
447 secret_key,
448 paper: true,
449 #[cfg(test)]
450 base_url_override: None,
451 #[cfg(test)]
452 ws_url_override: None,
453 }
454 }
455
456 pub fn production(api_key: String, secret_key: String) -> Self {
461 Self {
462 api_key,
463 secret_key,
464 paper: false,
465 #[cfg(test)]
466 base_url_override: None,
467 #[cfg(test)]
468 ws_url_override: None,
469 }
470 }
471
472 pub fn from_env() -> Result<Self, AlpacaConfigError> {
490 let api_key = match std::env::var("ALPACA_API_KEY") {
491 Ok(value) => value,
492 Err(std::env::VarError::NotPresent) => return Err(AlpacaConfigError::MissingApiKey),
493 Err(std::env::VarError::NotUnicode(_)) => {
494 return Err(AlpacaConfigError::InvalidApiKey);
495 }
496 };
497 let secret_key = match std::env::var("ALPACA_SECRET_KEY") {
498 Ok(value) => value,
499 Err(std::env::VarError::NotPresent) => return Err(AlpacaConfigError::MissingSecretKey),
500 Err(std::env::VarError::NotUnicode(_)) => {
501 return Err(AlpacaConfigError::InvalidSecretKey);
502 }
503 };
504
505 let paper = match std::env::var("ALPACA_PAPER") {
506 Ok(value) => parse_env_bool(&value).ok_or(AlpacaConfigError::InvalidPaper(value))?,
507 Err(std::env::VarError::NotPresent) => true,
508 Err(std::env::VarError::NotUnicode(value)) => {
511 return Err(AlpacaConfigError::InvalidPaper(
512 value.to_string_lossy().into_owned(),
513 ));
514 }
515 };
516
517 if paper {
518 Ok(Self::paper(api_key, secret_key))
519 } else {
520 Ok(Self::production(api_key, secret_key))
521 }
522 }
523
524 #[cfg(test)]
526 pub fn with_base_url(api_key: String, secret_key: String, base_url: String) -> Self {
527 Self {
528 api_key,
529 secret_key,
530 paper: true,
531 base_url_override: Some(base_url),
532 ws_url_override: None,
533 }
534 }
535
536 pub fn api_key(&self) -> &str {
537 &self.api_key
538 }
539
540 pub fn rest_base_url(&self) -> &str {
544 #[cfg(test)]
545 if let Some(ref url) = self.base_url_override {
546 return url.as_str();
547 }
548 if self.paper {
549 "https://paper-api.alpaca.markets"
550 } else {
551 "https://api.alpaca.markets"
552 }
553 }
554
555 pub fn ws_url(&self) -> &'static str {
557 if self.paper {
558 "wss://paper-api.alpaca.markets/stream"
559 } else {
560 "wss://api.alpaca.markets/stream"
561 }
562 }
563
564 fn stream_url(&self) -> &str {
567 #[cfg(test)]
568 if let Some(ref url) = self.ws_url_override {
569 return url.as_str();
570 }
571 self.ws_url()
572 }
573}
574
575#[derive(Debug, PartialEq, thiserror::Error)]
576pub enum AlpacaConfigError {
577 #[error("ALPACA_API_KEY environment variable not set")]
578 MissingApiKey,
579
580 #[error("ALPACA_API_KEY environment variable is not valid UTF-8")]
583 InvalidApiKey,
584
585 #[error("ALPACA_SECRET_KEY environment variable not set")]
586 MissingSecretKey,
587
588 #[error("ALPACA_SECRET_KEY environment variable is not valid UTF-8")]
589 InvalidSecretKey,
590
591 #[error("ALPACA_PAPER must be true or false, got {0}")]
592 InvalidPaper(String),
593}
594
595#[derive(Debug, Deserialize)]
604struct AlpacaAccount {
605 #[serde(with = "rust_decimal::serde::str")]
608 cash: Decimal,
609 #[serde(with = "rust_decimal::serde::str")]
613 non_marginable_buying_power: Decimal,
614}
615
616#[derive(Debug, Deserialize)]
621struct AlpacaPosition {
622 symbol: String,
625 asset_class: String,
627 #[serde(default)]
631 side: AlpacaPositionSide,
632 #[serde(with = "rust_decimal::serde::str")]
634 qty: Decimal,
635 #[serde(with = "rust_decimal::serde::str")]
637 qty_available: Decimal,
638 #[serde(default, with = "rust_decimal::serde::str_option")]
640 avg_entry_price: Option<Decimal>,
641 #[serde(default, with = "rust_decimal::serde::str_option")]
643 unrealized_pl: Option<Decimal>,
644}
645
646#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize)]
648#[serde(rename_all = "lowercase")]
649enum AlpacaPositionSide {
650 Long,
651 Short,
652 #[default]
656 #[serde(other)]
657 Unknown,
658}
659
660#[derive(Debug, Deserialize)]
661struct AlpacaOrderResponse {
662 id: String,
663 client_order_id: Option<String>,
664 symbol: String,
665 qty: Option<String>,
666 filled_qty: String,
667 #[serde(default)]
669 filled_avg_price: Option<String>,
670 #[serde(default)]
674 status: Option<String>,
675 side: String,
676 #[serde(rename = "type")]
677 order_type: String,
678 time_in_force: String,
679 limit_price: Option<String>,
680 stop_price: Option<String>,
681 trail_percent: Option<String>,
682 trail_price: Option<String>,
683 created_at: String,
684 updated_at: Option<String>,
687}
688
689#[derive(Debug, Deserialize)]
690struct AlpacaActivity {
691 id: String,
692 order_id: String,
693 symbol: String,
694 side: String,
695 price: String,
696 qty: String,
697 transaction_time: String,
698 cum_qty: Option<String>,
706}
707
708#[derive(Debug, Deserialize)]
715struct AlpacaNonTradeActivity {
716 id: String,
717 activity_type: String,
718 #[serde(default)]
719 activity_sub_type: Option<String>,
720 #[serde(default)]
723 created_at: Option<String>,
724 #[serde(default)]
728 date: Option<String>,
729 #[serde(default)]
731 net_amount: Option<String>,
732 #[serde(default)]
735 qty: Option<String>,
736 #[serde(default)]
738 price: Option<String>,
739 #[serde(default)]
740 currency: Option<String>,
741 #[serde(default)]
742 symbol: Option<String>,
743 #[serde(default)]
745 status: Option<String>,
746}
747
748#[derive(Debug, Deserialize)]
753struct AlpacaApiError {
754 code: Option<i64>,
755 message: String,
756}
757
758impl AlpacaApiError {
759 fn from_body(bytes: &[u8]) -> Self {
761 serde_json::from_slice(bytes).unwrap_or_else(|_| Self {
762 code: None,
763 message: String::from_utf8_lossy(bytes).into_owned(),
764 })
765 }
766}
767
768#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
780#[serde(rename_all = "snake_case")]
781pub enum AlpacaPositionIntent {
782 BuyToOpen,
783 BuyToClose,
784 SellToOpen,
785 SellToClose,
786}
787
788#[derive(Debug, Serialize)]
796struct TakeProfitParams {
797 limit_price: String,
798}
799
800#[derive(Debug, Serialize)]
805struct StopLossParams {
806 stop_price: String,
807 #[serde(skip_serializing_if = "Option::is_none")]
808 limit_price: Option<String>,
809}
810
811#[non_exhaustive]
846#[derive(Debug, Clone)]
847pub struct AlpacaBracketOrderRequest {
848 pub instrument: InstrumentNameExchange,
850 pub strategy: StrategyId,
852 pub cid: ClientOrderId,
854 pub side: Side,
856 pub quantity: Decimal,
858 pub entry_price: Decimal,
860 pub take_profit_price: Decimal,
862 pub stop_loss_price: Decimal,
864 pub stop_loss_limit_price: Option<Decimal>,
866 pub time_in_force: TimeInForce,
868}
869
870impl AlpacaBracketOrderRequest {
871 #[allow(clippy::too_many_arguments)] pub fn new(
876 instrument: InstrumentNameExchange,
877 strategy: StrategyId,
878 cid: ClientOrderId,
879 side: Side,
880 quantity: Decimal,
881 entry_price: Decimal,
882 take_profit_price: Decimal,
883 stop_loss_price: Decimal,
884 time_in_force: TimeInForce,
885 ) -> Self {
886 Self {
887 instrument,
888 strategy,
889 cid,
890 side,
891 quantity,
892 entry_price,
893 take_profit_price,
894 stop_loss_price,
895 stop_loss_limit_price: None,
896 time_in_force,
897 }
898 }
899
900 #[must_use]
902 pub fn with_stop_loss_limit_price(mut self, price: Decimal) -> Self {
903 self.stop_loss_limit_price = Some(price);
904 self
905 }
906}
907
908#[non_exhaustive]
919#[derive(Debug, Clone)]
920pub struct AlpacaBracketOrderResult {
921 pub parent: Order<ExchangeId, InstrumentNameExchange, UnindexedOrderState>,
923}
924
925#[derive(Debug, Serialize)]
930struct AlpacaOrderRequest<'a> {
931 symbol: &'a str,
932 qty: String,
933 side: &'static str,
934 #[serde(rename = "type")]
935 order_type: &'static str,
936 time_in_force: &'static str,
937 #[serde(skip_serializing_if = "Option::is_none")]
938 limit_price: Option<String>,
939 #[serde(skip_serializing_if = "Option::is_none")]
940 stop_price: Option<String>,
941 #[serde(skip_serializing_if = "Option::is_none")]
942 trail_percent: Option<String>,
943 #[serde(skip_serializing_if = "Option::is_none")]
944 trail_price: Option<String>,
945 #[serde(skip_serializing_if = "Option::is_none")]
946 client_order_id: Option<&'a str>,
947 #[serde(skip_serializing_if = "Option::is_none")]
951 position_intent: Option<AlpacaPositionIntent>,
952 #[serde(skip_serializing_if = "Option::is_none")]
955 order_class: Option<&'static str>,
956 #[serde(skip_serializing_if = "Option::is_none")]
957 take_profit: Option<TakeProfitParams>,
958 #[serde(skip_serializing_if = "Option::is_none")]
959 stop_loss: Option<StopLossParams>,
960}
961
962#[derive(Debug, Deserialize)]
971struct AlpacaStreamMessage<'a> {
972 stream: SmolStr,
975 #[serde(borrow)]
976 data: &'a serde_json::value::RawValue,
977}
978
979#[derive(Debug, Deserialize)]
987struct AlpacaTradeUpdate<'a> {
988 event: SmolStr,
990 #[serde(borrow)]
991 order: AlpacaOrderWs<'a>,
992 #[serde(borrow)]
994 price: Option<&'a str>,
995 #[serde(borrow)]
997 qty: Option<&'a str>,
998 #[serde(borrow)]
999 timestamp: Option<&'a str>,
1000 #[serde(borrow)]
1004 execution_id: Option<&'a str>,
1005 #[serde(borrow)]
1009 previous_execution_id: Option<&'a str>,
1010}
1011
1012#[derive(Debug, Deserialize)]
1014struct AlpacaOrderWs<'a> {
1015 id: SmolStr,
1018 client_order_id: Option<SmolStr>,
1019 symbol: SmolStr,
1022 #[serde(borrow)]
1023 qty: Option<&'a str>,
1024 #[serde(borrow)]
1031 filled_qty: Option<&'a str>,
1032 side: SmolStr,
1035 #[serde(rename = "type")]
1036 order_type: SmolStr,
1037 time_in_force: SmolStr,
1038 #[serde(borrow)]
1039 limit_price: Option<&'a str>,
1040 #[serde(borrow)]
1041 stop_price: Option<&'a str>,
1042 #[serde(borrow)]
1043 trail_percent: Option<&'a str>,
1044 #[serde(borrow)]
1045 trail_price: Option<&'a str>,
1046 status: SmolStr,
1047 #[serde(borrow, default)]
1050 updated_at: Option<&'a str>,
1051}
1052
1053#[derive(Clone)]
1087pub struct AlpacaClient {
1088 config: Arc<AlpacaConfig>,
1089 http: reqwest::Client,
1092 rate_limiter: Arc<RateLimitTracker>,
1093 orders_url: String,
1095 known_live: SharedKnownLiveOrders,
1098}
1099
1100impl std::fmt::Debug for AlpacaClient {
1101 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1102 f.debug_struct("AlpacaClient")
1103 .field("paper", &self.config.paper)
1104 .finish_non_exhaustive()
1105 }
1106}
1107
1108impl AlpacaClient {
1109 #[allow(clippy::expect_used)] fn build_http(config: &AlpacaConfig) -> reqwest::Client {
1117 use reqwest::header::{HeaderMap, HeaderName, HeaderValue};
1118 let mut headers = HeaderMap::new();
1119 headers.insert(
1121 HeaderName::from_static("apca-api-key-id"),
1122 HeaderValue::from_str(&config.api_key)
1123 .expect("Alpaca API key contains invalid header characters"),
1124 );
1125 headers.insert(
1126 HeaderName::from_static("apca-api-secret-key"),
1127 HeaderValue::from_str(&config.secret_key)
1128 .expect("Alpaca secret key contains invalid header characters"),
1129 );
1130 reqwest::Client::builder()
1131 .default_headers(headers)
1132 .build()
1133 .expect("failed to build reqwest client for Alpaca")
1134 }
1135
1136 fn base_url(&self) -> &str {
1137 self.config.rest_base_url()
1138 }
1139
1140 fn remember_live<'a>(
1142 &self,
1143 orders: impl IntoIterator<Item = &'a Order<ExchangeId, InstrumentNameExchange, Open>>,
1144 ) {
1145 let mut known = self.known_live.lock();
1146 for order in orders {
1147 known.live(&order.key, order.quantity, order.kind, &order.state);
1148 }
1149 }
1150}
1151
1152fn parse_rate_limit_delay(headers: &reqwest::header::HeaderMap) -> Option<Duration> {
1170 let header = |name: &str| {
1171 headers
1172 .get(name)
1173 .and_then(|value| value.to_str().ok())
1174 .and_then(|value| value.trim().parse::<u64>().ok())
1175 };
1176
1177 let seconds = match header("retry-after") {
1178 Some(seconds) => seconds,
1179 None => {
1180 let reset = header("x-ratelimit-reset")?;
1181 let now = std::time::SystemTime::now()
1182 .duration_since(std::time::UNIX_EPOCH)
1183 .unwrap_or_default()
1184 .as_secs();
1185 reset.saturating_sub(now).max(1)
1186 }
1187 };
1188 Some(Duration::from_secs(
1189 seconds.min(DEFAULT_RATE_LIMIT_DELAY_SECS),
1190 ))
1191}
1192
1193fn observe_rate_limit_remaining(
1201 rate_limiter: &RateLimitTracker,
1202 headers: &reqwest::header::HeaderMap,
1203) {
1204 let exhausted = headers
1205 .get("x-ratelimit-remaining")
1206 .and_then(|v| v.to_str().ok())
1207 .and_then(|s| s.parse::<u32>().ok())
1208 == Some(0);
1209 if !exhausted {
1210 return;
1211 }
1212 match parse_rate_limit_delay(headers) {
1213 Some(reset) => rate_limiter.on_bucket_exhausted(reset),
1214 None => debug!(
1215 "Alpaca REST rate-limit window exhausted (X-Ratelimit-Remaining: 0) without X-Ratelimit-Reset"
1216 ),
1217 }
1218}
1219
1220async fn rest_with_retry<T>(
1241 rate_limiter: &RateLimitTracker,
1242 build_request: impl FnMut() -> reqwest::RequestBuilder,
1243) -> Result<T, UnindexedClientError>
1244where
1245 T: for<'de> Deserialize<'de>,
1246{
1247 match rest_request(rate_limiter, build_request).await? {
1248 Fetched::Found(value) => Ok(value),
1249 Fetched::NotFound(message) => Err(UnindexedClientError::Api(parse_api_error(
1250 reqwest::StatusCode::NOT_FOUND,
1251 None,
1252 &message,
1253 ))),
1254 }
1255}
1256
1257async fn rest_lookup_with_retry<T>(
1260 rate_limiter: &RateLimitTracker,
1261 build_request: impl FnMut() -> reqwest::RequestBuilder,
1262) -> Result<Option<T>, UnindexedClientError>
1263where
1264 T: for<'de> Deserialize<'de>,
1265{
1266 match rest_request(rate_limiter, build_request).await? {
1267 Fetched::Found(value) => Ok(Some(value)),
1268 Fetched::NotFound(_) => Ok(None),
1269 }
1270}
1271
1272#[derive(Debug)]
1274enum Fetched<T> {
1275 Found(T),
1277 NotFound(String),
1279}
1280
1281async fn rest_request<T>(
1283 rate_limiter: &RateLimitTracker,
1284 mut build_request: impl FnMut() -> reqwest::RequestBuilder,
1285) -> Result<Fetched<T>, UnindexedClientError>
1286where
1287 T: for<'de> Deserialize<'de>,
1288{
1289 for attempt in 0..MAX_RATE_LIMIT_ATTEMPTS {
1290 rate_limiter.wait_if_blocked().await;
1291 let response = build_request()
1292 .send()
1293 .await
1294 .map_err(|e| connectivity_err(format!("Alpaca REST request failed: {e}")))?;
1295
1296 let status = response.status();
1297
1298 if status == reqwest::StatusCode::TOO_MANY_REQUESTS {
1299 let reset_delay = parse_rate_limit_delay(response.headers());
1300 rate_limiter.on_rate_limited(reset_delay);
1302
1303 if attempt + 1 < MAX_RATE_LIMIT_ATTEMPTS {
1304 warn!(
1305 attempt = attempt + 1,
1306 max_attempts = MAX_RATE_LIMIT_ATTEMPTS,
1307 "Alpaca REST rate-limited (429), retrying"
1308 );
1309 continue;
1310 }
1311
1312 warn!(
1314 max_attempts = MAX_RATE_LIMIT_ATTEMPTS,
1315 "Alpaca REST rate-limit retries exhausted"
1316 );
1317 return Err(UnindexedClientError::Api(ApiError::rate_limited(
1318 reset_delay,
1319 )));
1320 }
1321
1322 observe_rate_limit_remaining(rate_limiter, response.headers());
1323
1324 if status == reqwest::StatusCode::NO_CONTENT {
1328 return Err(UnindexedClientError::Internal(
1329 "Alpaca REST returned 204 No Content — use rest_delete_with_retry for DELETE endpoints"
1330 .to_string(),
1331 ));
1332 }
1333
1334 let bytes = response
1335 .bytes()
1336 .await
1337 .map_err(|e| connectivity_err(format!("Alpaca REST read body failed: {e}")))?;
1338
1339 if status.is_success() {
1342 return serde_json::from_slice::<T>(&bytes)
1343 .map(Fetched::Found)
1344 .map_err(|e| {
1345 UnindexedClientError::Internal(format!(
1346 "Alpaca REST JSON parse error ({status}): {e} | body: {}",
1347 String::from_utf8_lossy(&bytes)
1348 .chars()
1349 .take(200)
1350 .collect::<String>()
1351 ))
1352 });
1353 }
1354
1355 let api_err = AlpacaApiError::from_body(&bytes);
1357
1358 if status == reqwest::StatusCode::NOT_FOUND {
1359 return Ok(Fetched::NotFound(api_err.message));
1360 }
1361
1362 if status.is_client_error() {
1371 return Err(UnindexedClientError::Api(parse_api_error(
1372 status,
1373 api_err.code,
1374 &api_err.message,
1375 )));
1376 }
1377 return Err(connectivity_err(format!(
1378 "Alpaca REST error {status}: {}",
1379 api_err.message
1380 )));
1381 }
1382 unreachable!("Alpaca REST retry loop exited without returning")
1383}
1384
1385async fn rest_delete_with_retry(
1392 rate_limiter: &RateLimitTracker,
1393 mut build_request: impl FnMut() -> reqwest::RequestBuilder,
1394) -> Result<(), UnindexedOrderError> {
1395 for attempt in 0..MAX_RATE_LIMIT_ATTEMPTS {
1396 rate_limiter.wait_if_blocked().await;
1397 let response = build_request().send().await.map_err(|e| {
1398 UnindexedOrderError::Connectivity(ConnectivityError::Socket(format!(
1399 "Alpaca cancel request failed: {e}"
1400 )))
1401 })?;
1402
1403 let status = response.status();
1404
1405 if status == reqwest::StatusCode::TOO_MANY_REQUESTS {
1406 let reset_delay = parse_rate_limit_delay(response.headers());
1407 rate_limiter.on_rate_limited(reset_delay);
1409
1410 if attempt + 1 < MAX_RATE_LIMIT_ATTEMPTS {
1411 warn!(
1412 attempt = attempt + 1,
1413 max_attempts = MAX_RATE_LIMIT_ATTEMPTS,
1414 "Alpaca cancel rate-limited (429), retrying"
1415 );
1416 continue;
1417 }
1418
1419 warn!(
1421 max_attempts = MAX_RATE_LIMIT_ATTEMPTS,
1422 "Alpaca cancel rate-limit retries exhausted"
1423 );
1424 return Err(UnindexedOrderError::Rejected(ApiError::rate_limited(
1425 reset_delay,
1426 )));
1427 }
1428
1429 observe_rate_limit_remaining(rate_limiter, response.headers());
1430
1431 if status == reqwest::StatusCode::NO_CONTENT || status.is_success() {
1433 return Ok(());
1434 }
1435
1436 let bytes = response
1437 .bytes()
1438 .await
1439 .inspect_err(
1440 |e| warn!(%e, %status, "Alpaca cancel_order: failed to read error response body"),
1441 )
1442 .unwrap_or_default();
1443 let api_err = AlpacaApiError::from_body(&bytes);
1444
1445 return Err(parse_order_error(status, api_err.code, &api_err.message));
1446 }
1447 unreachable!("Alpaca cancel retry loop exited without returning")
1448}
1449
1450impl ExecutionClient for AlpacaClient {
1455 const EXCHANGE: ExchangeId = ExchangeId::AlpacaBroker;
1456
1457 const SUPPORTED_KINDS: &'static [InstrumentKindDiscriminant] = &[
1464 InstrumentKindDiscriminant::Spot,
1465 InstrumentKindDiscriminant::Option,
1466 ];
1467 type Config = AlpacaConfig;
1468 type AccountStream = BoxStream<'static, UnindexedAccountEvent>;
1469
1470 fn new(config: Self::Config) -> Self {
1475 let http = Self::build_http(&config);
1476 let orders_url = format!("{}/v2/orders", config.rest_base_url());
1477 Self {
1478 config: Arc::new(config),
1479 http,
1480 rate_limiter: Arc::new(RateLimitTracker::new()),
1481 orders_url,
1482 known_live: KnownLiveOrders::shared(ExchangeId::AlpacaBroker),
1483 }
1484 }
1485
1486 async fn account_snapshot(
1533 &self,
1534 assets: &[AssetNameExchange],
1535 instruments: &[InstrumentNameExchange],
1536 ) -> Result<UnindexedAccountSnapshot, UnindexedClientError> {
1537 let base = self.base_url();
1538 let http = self.http.clone();
1539 let rl = &self.rate_limiter;
1540
1541 let wants_usd = assets.is_empty() || assets.iter().any(is_usd);
1542 let wants_non_usd = assets.is_empty() || assets.iter().any(|a| !is_usd(a));
1543
1544 let account_url = format!("{base}/v2/account");
1546 let positions_url = format!("{base}/v2/positions");
1547 let sent = Utc::now();
1549 let (account, positions): (Option<AlpacaAccount>, Vec<AlpacaPosition>) = tokio::try_join!(
1550 async {
1551 if wants_usd {
1552 rest_with_retry(rl, || http.get(&account_url))
1553 .await
1554 .map(Some)
1555 } else {
1556 Ok(None)
1557 }
1558 },
1559 rest_with_retry(rl, || http.get(&positions_url)),
1560 )?;
1561
1562 let mut balances = account
1563 .map(|account| convert_account_to_balances(&account, assets, sent))
1564 .unwrap_or_default();
1565 if wants_non_usd {
1566 balances.extend(convert_positions_to_balances(&positions, assets, sent));
1567 }
1568
1569 let converted_positions = convert_positions(&positions, Utc::now())?;
1572 let open_orders = fetch_raw_open_orders(&http, rl, base, instruments).await?;
1573
1574 let instrument_snapshots =
1576 build_instrument_snapshots(open_orders, converted_positions, instruments);
1577 {
1578 let mut known = self.known_live.lock();
1579 for order in instrument_snapshots
1580 .iter()
1581 .flat_map(|snapshot| &snapshot.orders)
1582 {
1583 if let OrderState::Active(ActiveOrderState::Open(open)) = &order.state {
1584 known.live(&order.key, order.quantity, order.kind, open);
1585 }
1586 }
1587 }
1588
1589 Ok(AccountSnapshot::new(
1590 ExchangeId::AlpacaBroker,
1591 balances,
1592 instrument_snapshots,
1593 ))
1594 }
1595
1596 async fn account_stream(
1748 &self,
1749 _assets: &[AssetNameExchange], instruments: &[InstrumentNameExchange],
1754 ) -> Result<Self::AccountStream, UnindexedClientError> {
1755 let initial_ws = connect_and_subscribe(&self.config).await?;
1758
1759 let (tx, rx) = mpsc::unbounded_channel::<UnindexedAccountEvent>();
1764 let dedup = new_dedup_cache();
1765 let config = self.config.clone();
1766 let http = self.http.clone();
1767 let rate_limiter = self.rate_limiter.clone();
1768 let known = self.known_live.clone();
1769 let instruments = instruments.to_vec();
1770
1771 let cm_handle = tokio::spawn(connection_manager(
1772 tx,
1773 dedup,
1774 config,
1775 http,
1776 rate_limiter,
1777 known,
1778 instruments,
1779 Some(initial_ws),
1780 ));
1781
1782 let rx_stream = tokio_stream::wrappers::UnboundedReceiverStream::new(rx);
1783 let guarded = GracefulShutdownStream::new(rx_stream, cm_handle);
1784 Ok(futures::StreamExt::boxed(guarded))
1785 }
1786
1787 async fn cancel_order(
1806 &self,
1807 request: OrderRequestCancel<ExchangeId, &InstrumentNameExchange>,
1808 ) -> UnindexedOrderResponseCancel {
1809 let key = crate::order::OrderKey {
1810 exchange: request.key.exchange,
1811 instrument: request.key.instrument.clone(),
1812 strategy: request.key.strategy.clone(),
1813 cid: request.key.cid.clone(),
1814 };
1815
1816 let order_id: SmolStr = match request.state.id.as_ref().and_then(VenueOrderId::assigned) {
1820 Some(id) => id.0.clone(),
1821 None => {
1822 warn!(
1823 instrument = %key.instrument,
1824 "Alpaca cancel_order: no exchange order ID available (clientOrderId-only cancel not supported)"
1825 );
1826 return crate::order::request::OrderResponseCancel {
1827 key,
1828 state: Err(UnindexedOrderError::Rejected(ApiError::OrderRejected(
1829 "exchange order ID required for cancel (fetch_open_orders to resolve)"
1830 .into(),
1831 ))),
1832 };
1833 }
1834 };
1835
1836 let base = self.base_url();
1837 let http = self.http.clone();
1838 let url = format!("{base}/v2/orders/{order_id}");
1839
1840 match rest_delete_with_retry(&self.rate_limiter, || http.delete(&url)).await {
1841 Ok(()) => {
1842 let exchange_order_id = OrderId(order_id);
1843 crate::order::request::OrderResponseCancel {
1846 key,
1847 state: Ok(Cancelled::new(exchange_order_id, Utc::now(), None)),
1848 }
1849 }
1850 Err(e) => crate::order::request::OrderResponseCancel { key, state: Err(e) },
1851 }
1852 }
1853
1854 async fn open_order(
1875 &self,
1876 request: OrderRequestOpen<ExchangeId, &InstrumentNameExchange>,
1877 ) -> Order<ExchangeId, InstrumentNameExchange, UnindexedOrderState> {
1878 let side = request.state.side;
1879 let reduce_only = request.state.reduce_only;
1880 self.open_order_inner(request, map_position_intent(side, reduce_only))
1881 .await
1882 }
1883
1884 async fn fetch_balances(
1892 &self,
1893 assets: &[AssetNameExchange],
1894 ) -> Result<Vec<AssetBalance<AssetNameExchange>>, UnindexedClientError> {
1895 let base = self.base_url();
1896 let http = self.http.clone();
1897 let mut result = Vec::new();
1898
1899 let wants_usd = assets.is_empty() || assets.iter().any(is_usd);
1902 if wants_usd {
1903 let account_url = format!("{base}/v2/account");
1905 let sent = Utc::now();
1906 let account: AlpacaAccount =
1907 rest_with_retry(&self.rate_limiter, || http.get(&account_url)).await?;
1908 result.extend(convert_account_to_balances(&account, assets, sent));
1909 }
1910
1911 let wants_non_usd = assets.is_empty() || assets.iter().any(|a| !is_usd(a));
1913 if wants_non_usd {
1914 let positions_url = format!("{base}/v2/positions");
1916 let sent = Utc::now();
1917 let positions: Vec<AlpacaPosition> =
1918 rest_with_retry(&self.rate_limiter, || http.get(&positions_url)).await?;
1919 result.extend(convert_positions_to_balances(&positions, assets, sent));
1920 }
1921
1922 Ok(result)
1923 }
1924
1925 async fn fetch_open_orders(
1926 &self,
1927 instruments: &[InstrumentNameExchange],
1928 ) -> Result<Vec<Order<ExchangeId, InstrumentNameExchange, Open>>, UnindexedClientError> {
1929 let base = self.base_url();
1930 let http = self.http.clone();
1931
1932 let open_orders =
1933 fetch_raw_open_orders(&http, &self.rate_limiter, base, instruments).await?;
1934
1935 let result: Vec<_> = open_orders
1936 .into_iter()
1937 .filter_map(|o| convert_open_order(&o))
1938 .collect();
1939 self.remember_live(&result);
1940 Ok(result)
1941 }
1942
1943 async fn fetch_trades(
1956 &self,
1957 start: DateTime<Utc>,
1958 end: DateTime<Utc>,
1959 instruments: &[InstrumentNameExchange],
1960 ) -> Result<TradesRead<AssetNameExchange, InstrumentNameExchange>, UnindexedClientError> {
1961 if start > end || end < DateTime::UNIX_EPOCH {
1963 return Ok(TradesRead::complete(Vec::new()));
1964 }
1965 let page = paginate_activities::<AlpacaActivity>(
1966 &self.http,
1967 &self.rate_limiter,
1968 self.base_url(),
1969 FILL_ACTIVITIES,
1970 start,
1971 Some(end),
1972 )
1973 .await?;
1974
1975 let Ok(resume) = span_resume(page.resume, start, end) else {
1976 error!(
1977 %start,
1978 fills_read = page.activities.len(),
1979 "Alpaca fetch_trades: a full read did not advance past the span's first millisecond"
1980 );
1981 return Err(UnindexedClientError::Truncated {
1982 fills_read: page.activities.len(),
1983 });
1984 };
1985
1986 let instrument_set: fnv::FnvHashSet<&str> =
1989 instruments.iter().map(|i| i.name().as_str()).collect();
1990 let trades = page
1991 .activities
1992 .iter()
1993 .filter(|a| instrument_set.is_empty() || instrument_set.contains(a.symbol.as_str()))
1994 .filter(|a| {
1997 parse_timestamp(&a.transaction_time)
1998 .is_none_or(|time| (start..=end).contains(&time))
1999 })
2000 .filter_map(convert_activity_to_trade)
2001 .collect();
2002
2003 Ok(TradesRead::new(trades, resume))
2004 }
2005}
2006
2007impl AlpacaClient {
2012 pub async fn fetch_cash_flows(
2062 &self,
2063 start: DateTime<Utc>,
2064 end: DateTime<Utc>,
2065 ) -> Result<CashFlowsRead<AssetNameExchange, InstrumentNameExchange>, UnindexedClientError>
2066 {
2067 if start > end || end < DateTime::UNIX_EPOCH {
2069 return Ok(CashFlowsRead::complete(Vec::new()));
2070 }
2071 let page = paginate_activities::<AlpacaNonTradeActivity>(
2072 &self.http,
2073 &self.rate_limiter,
2074 self.base_url(),
2075 CASH_FLOW_ACTIVITIES,
2076 start,
2077 Some(end),
2078 )
2079 .await?;
2080 let Ok(resume) = span_resume(page.resume, start, end) else {
2081 error!(
2082 %start,
2083 activities_read = page.activities.len(),
2084 "Alpaca fetch_cash_flows: a full read did not advance past the span's first \
2085 millisecond"
2086 );
2087 return Err(UnindexedClientError::Internal(format!(
2088 "Alpaca cash-flow read of {} activities did not advance past {start}",
2089 page.activities.len()
2090 )));
2091 };
2092
2093 let flows = page
2094 .activities
2095 .iter()
2096 .filter_map(convert_cash_flow)
2097 .filter(|flow| (start..=end).contains(&flow.time_exchange))
2100 .collect();
2101 Ok(CashFlowsRead::new(flows, resume))
2102 }
2103
2104 pub async fn fetch_option_events(
2172 &self,
2173 start: DateTime<Utc>,
2174 end: DateTime<Utc>,
2175 ) -> Result<TradesRead<AssetNameExchange, InstrumentNameExchange>, UnindexedClientError> {
2176 if start > end || end < DateTime::UNIX_EPOCH {
2178 return Ok(TradesRead::complete(Vec::new()));
2179 }
2180 let read_start = start.date_naive().and_time(NaiveTime::MIN).and_utc();
2182 let read_end = end.date_naive().and_time(LAST_MILLISECOND).and_utc();
2183 let page = paginate_activities::<AlpacaNonTradeActivity>(
2184 &self.http,
2185 &self.rate_limiter,
2186 self.base_url(),
2187 OPTION_EVENT_ACTIVITIES,
2188 read_start,
2189 Some(read_end),
2190 )
2191 .await?;
2192 let Ok(resume) = span_resume(page.resume, start, end) else {
2193 error!(
2194 %start,
2195 activities_read = page.activities.len(),
2196 "Alpaca fetch_option_events: a full read did not advance past the span's start"
2197 );
2198 return Err(UnindexedClientError::Internal(format!(
2199 "Alpaca option-event read of {} activities did not advance past {start}",
2200 page.activities.len()
2201 )));
2202 };
2203
2204 Ok(TradesRead::new(
2205 convert_option_events(&page.activities, start, end),
2206 resume,
2207 ))
2208 }
2209
2210 pub async fn open_order_with_intent(
2223 &self,
2224 request: OrderRequestOpen<ExchangeId, &InstrumentNameExchange>,
2225 intent: AlpacaPositionIntent,
2226 ) -> Order<ExchangeId, InstrumentNameExchange, UnindexedOrderState> {
2227 self.open_order_inner(request, intent).await
2228 }
2229
2230 pub async fn open_bracket_order(
2263 &self,
2264 request: AlpacaBracketOrderRequest,
2265 ) -> AlpacaBracketOrderResult {
2266 let order_key = crate::order::OrderKey::new(
2267 ExchangeId::AlpacaBroker,
2268 request.instrument.clone(),
2269 request.strategy.clone(),
2270 request.cid.clone(),
2271 );
2272
2273 let tif_str = match request.time_in_force {
2275 TimeInForce::GoodUntilEndOfDay => "day",
2276 TimeInForce::GoodUntilCancelled { post_only } => {
2277 if post_only {
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(
2287 ApiError::OrderRejected(
2288 "Alpaca does not support post_only for bracket orders"
2289 .to_string(),
2290 ),
2291 )),
2292 },
2293 };
2294 }
2295 "gtc"
2296 }
2297 other => {
2298 return AlpacaBracketOrderResult {
2299 parent: Order {
2300 key: order_key,
2301 side: request.side,
2302 price: Some(request.entry_price),
2303 quantity: request.quantity,
2304 kind: OrderKind::Limit,
2305 time_in_force: request.time_in_force,
2306 state: OrderState::inactive(OrderError::Rejected(ApiError::OrderRejected(
2307 format!(
2308 "Alpaca bracket orders only support Day or GTC time_in_force, got {:?}",
2309 other
2310 ),
2311 ))),
2312 },
2313 };
2314 }
2315 };
2316
2317 let price_ordering_ok = match request.side {
2320 Side::Buy => {
2321 request.stop_loss_price < request.entry_price
2322 && request.entry_price < request.take_profit_price
2323 }
2324 Side::Sell => {
2325 request.take_profit_price < request.entry_price
2326 && request.entry_price < request.stop_loss_price
2327 }
2328 };
2329 if !price_ordering_ok {
2330 return AlpacaBracketOrderResult {
2331 parent: Order {
2332 key: order_key,
2333 side: request.side,
2334 price: Some(request.entry_price),
2335 quantity: request.quantity,
2336 kind: OrderKind::Limit,
2337 time_in_force: request.time_in_force,
2338 state: OrderState::inactive(OrderError::Rejected(ApiError::OrderRejected(
2339 format!(
2340 "Invalid bracket price ordering for {:?} side: entry={}, take_profit={}, stop_loss={}",
2341 request.side,
2342 request.entry_price,
2343 request.take_profit_price,
2344 request.stop_loss_price,
2345 ),
2346 ))),
2347 },
2348 };
2349 }
2350
2351 if let Some(sl_limit) = request.stop_loss_limit_price {
2355 let sl_limit_ok = match request.side {
2356 Side::Buy => sl_limit <= request.stop_loss_price,
2357 Side::Sell => sl_limit >= request.stop_loss_price,
2358 };
2359 if !sl_limit_ok {
2360 return AlpacaBracketOrderResult {
2361 parent: Order {
2362 key: order_key,
2363 side: request.side,
2364 price: Some(request.entry_price),
2365 quantity: request.quantity,
2366 kind: OrderKind::Limit,
2367 time_in_force: request.time_in_force,
2368 state: OrderState::inactive(OrderError::Rejected(ApiError::OrderRejected(
2369 format!(
2370 "Invalid stop-loss limit price for {:?} bracket: \
2371 stop_loss_price={}, stop_loss_limit_price={}",
2372 request.side, request.stop_loss_price, sl_limit,
2373 ),
2374 ))),
2375 },
2376 };
2377 }
2378 }
2379
2380 let body = AlpacaOrderRequest {
2382 symbol: request.instrument.name().as_str(),
2383 qty: request.quantity.to_string(),
2384 side: map_side(request.side),
2385 order_type: "limit",
2386 time_in_force: tif_str,
2387 limit_price: Some(request.entry_price.to_string()),
2388 stop_price: None,
2389 trail_percent: None,
2390 trail_price: None,
2391 client_order_id: Some(request.cid.0.as_str()),
2392 position_intent: if is_options_or_equity_symbol(request.instrument.name().as_str()) {
2393 Some(map_position_intent(request.side, ReduceOnly::No))
2394 } else {
2395 None
2396 },
2397 order_class: Some("bracket"),
2398 take_profit: Some(TakeProfitParams {
2399 limit_price: request.take_profit_price.to_string(),
2400 }),
2401 stop_loss: Some(StopLossParams {
2402 stop_price: request.stop_loss_price.to_string(),
2403 limit_price: request.stop_loss_limit_price.map(|p| p.to_string()),
2404 }),
2405 };
2406
2407 let http = self.http.clone();
2408 let rl = &self.rate_limiter;
2409
2410 let result: Result<AlpacaOrderResponse, UnindexedClientError> =
2411 rest_with_retry(rl, || http.post(&self.orders_url).json(&body)).await;
2412
2413 match result {
2414 Ok(resp) => {
2415 let state = placed_order_state(&resp, &order_key.instrument, request.quantity);
2416 self.known_live.lock().placed(
2417 &order_key,
2418 request.quantity,
2419 OrderKind::Limit,
2420 &state,
2421 );
2422
2423 AlpacaBracketOrderResult {
2424 parent: Order {
2425 key: order_key,
2426 side: request.side,
2427 price: Some(request.entry_price),
2428 quantity: request.quantity,
2429 kind: OrderKind::Limit,
2430 time_in_force: request.time_in_force,
2431 state,
2432 },
2433 }
2434 }
2435 Err(e) => {
2436 let order_err = order_post_error(e);
2437 AlpacaBracketOrderResult {
2438 parent: Order {
2439 key: order_key,
2440 side: request.side,
2441 price: Some(request.entry_price),
2442 quantity: request.quantity,
2443 kind: OrderKind::Limit,
2444 time_in_force: request.time_in_force,
2445 state: OrderState::inactive(order_err),
2446 },
2447 }
2448 }
2449 }
2450 }
2451
2452 async fn open_order_inner(
2453 &self,
2454 request: OrderRequestOpen<ExchangeId, &InstrumentNameExchange>,
2455 intent: AlpacaPositionIntent,
2456 ) -> Order<ExchangeId, InstrumentNameExchange, UnindexedOrderState> {
2457 let instrument = request.key.instrument.clone();
2458 let side = request.state.side;
2459 let price = request.state.price;
2460 let quantity = request.state.quantity;
2461 let kind = request.state.kind;
2462 let time_in_force = request.state.time_in_force;
2463 let cid = request.key.cid.clone();
2464
2465 let order_key = crate::order::OrderKey::new(
2466 ExchangeId::AlpacaBroker,
2467 instrument.clone(),
2468 request.key.strategy.clone(),
2469 cid.clone(),
2470 );
2471
2472 if let Some(effect) = request.state.margin_effect {
2475 return Order {
2476 key: order_key,
2477 side,
2478 price,
2479 quantity,
2480 kind,
2481 time_in_force,
2482 state: OrderState::inactive(OrderError::UnsupportedMarginEffect(effect)),
2483 };
2484 }
2485
2486 let tif_str = match map_time_in_force(time_in_force) {
2488 Ok(s) => s,
2489 Err(msg) => {
2490 return Order {
2491 key: order_key,
2492 side,
2493 price,
2494 quantity,
2495 kind,
2496 time_in_force,
2497 state: OrderState::inactive(OrderError::Rejected(ApiError::OrderRejected(
2498 msg.to_string(),
2499 ))),
2500 };
2501 }
2502 };
2503
2504 let order_type_str = match map_order_kind(kind) {
2506 Some(s) => s,
2507 None => {
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(format!(
2516 "Alpaca connector does not yet support OrderKind::{kind:?}"
2517 ))),
2518 };
2519 }
2520 };
2521
2522 if let OrderKind::TrailingStop {
2524 offset_type: TrailingOffsetType::BasisPoints,
2525 ..
2526 } = kind
2527 {
2528 return Order {
2529 key: order_key,
2530 side,
2531 price,
2532 quantity,
2533 kind,
2534 time_in_force,
2535 state: OrderState::inactive(OrderError::UnsupportedOrderType(
2536 "Alpaca does not support TrailingOffsetType::BasisPoints; \
2537 use Percentage or Absolute"
2538 .to_string(),
2539 )),
2540 };
2541 }
2542
2543 if matches!(kind, OrderKind::StopLimit { .. }) && price.is_none() {
2546 return Order {
2547 key: order_key,
2548 side,
2549 price,
2550 quantity,
2551 kind,
2552 time_in_force,
2553 state: OrderState::inactive(OrderError::Rejected(ApiError::OrderRejected(
2554 "StopLimit order requires Order.price (the limit price) to be set".to_string(),
2555 ))),
2556 };
2557 }
2558
2559 let (stop_price, trail_percent, trail_price) = match kind {
2561 OrderKind::Stop { trigger_price } | OrderKind::StopLimit { trigger_price } => {
2562 (Some(trigger_price.to_string()), None, None)
2563 }
2564 OrderKind::TrailingStop {
2565 offset,
2566 offset_type,
2567 ..
2568 } => match offset_type {
2569 TrailingOffsetType::Percentage => (None, Some(offset.to_string()), None),
2570 TrailingOffsetType::Absolute => (None, None, Some(offset.to_string())),
2571 TrailingOffsetType::BasisPoints => unreachable!("validated above"),
2572 },
2573 OrderKind::Market
2575 | OrderKind::Limit
2576 | OrderKind::TakeProfit { .. }
2577 | OrderKind::TakeProfitLimit { .. }
2578 | OrderKind::TrailingStopLimit { .. }
2579 | OrderKind::TrailingStopFixedLimit { .. }
2580 | OrderKind::ConditionalUnresolved { .. } => (None, None, None),
2581 };
2582
2583 let body = AlpacaOrderRequest {
2584 symbol: instrument.name().as_str(),
2585 qty: quantity.to_string(),
2586 side: map_side(side),
2587 order_type: order_type_str,
2588 time_in_force: tif_str,
2589 limit_price: match kind {
2590 OrderKind::Limit | OrderKind::StopLimit { .. } => price.map(|p| p.to_string()),
2591 _ => None,
2592 },
2593 stop_price,
2594 trail_percent,
2595 trail_price,
2596 client_order_id: Some(cid.0.as_str()),
2597 position_intent: if is_options_or_equity_symbol(instrument.name().as_str()) {
2603 Some(intent)
2604 } else {
2605 None
2606 },
2607 order_class: None,
2609 take_profit: None,
2610 stop_loss: None,
2611 };
2612
2613 let http = self.http.clone();
2614 let rl = &self.rate_limiter;
2615
2616 let result: Result<AlpacaOrderResponse, UnindexedClientError> =
2617 rest_with_retry(rl, || http.post(&self.orders_url).json(&body)).await;
2618
2619 match result {
2620 Ok(resp) => {
2621 let state = placed_order_state(&resp, &order_key.instrument, quantity);
2622 self.known_live
2623 .lock()
2624 .placed(&order_key, quantity, kind, &state);
2625
2626 Order {
2627 key: order_key,
2628 side,
2629 price,
2630 quantity,
2631 kind,
2632 time_in_force,
2633 state,
2634 }
2635 }
2636 Err(e) => {
2637 let order_err = order_post_error(e);
2638 Order {
2639 key: order_key,
2640 side,
2641 price,
2642 quantity,
2643 kind,
2644 time_in_force,
2645 state: OrderState::inactive(order_err),
2646 }
2647 }
2648 }
2649 }
2650}
2651
2652const MAX_OPEN_ORDERS: usize = 500;
2660
2661async fn fetch_raw_open_orders(
2669 http: &reqwest::Client,
2670 rate_limiter: &RateLimitTracker,
2671 base: &str,
2672 instruments: &[InstrumentNameExchange],
2673) -> Result<Vec<AlpacaOrderResponse>, UnindexedClientError> {
2674 let orders: Vec<AlpacaOrderResponse> = if instruments.is_empty() {
2675 rest_with_retry(rate_limiter, || {
2676 http.get(format!("{base}/v2/orders"))
2677 .query(&[("status", "open"), ("limit", "500")])
2678 })
2679 .await?
2680 } else {
2681 let symbols = instruments.iter().map(|i| i.name().as_str()).join(",");
2682 rest_with_retry(rate_limiter, || {
2683 http.get(format!("{base}/v2/orders")).query(&[
2684 ("status", "open"),
2685 ("limit", "500"),
2686 ("symbols", &symbols),
2687 ])
2688 })
2689 .await?
2690 };
2691 if orders.len() == MAX_OPEN_ORDERS {
2692 warn!(
2693 limit = MAX_OPEN_ORDERS,
2694 "Alpaca fetch_raw_open_orders: received exactly {MAX_OPEN_ORDERS} results — \
2695 response is likely truncated"
2696 );
2697 return Err(UnindexedClientError::TruncatedSnapshot {
2698 limit: MAX_OPEN_ORDERS,
2699 });
2700 }
2701 Ok(orders)
2702}
2703
2704const MAX_ACTIVITY_PAGES: usize = 50;
2713
2714trait PagedActivity: DeserializeOwned {
2716 fn id(&self) -> &str;
2718 fn time(&self) -> Option<DateTime<Utc>>;
2721}
2722
2723impl PagedActivity for AlpacaActivity {
2724 fn id(&self) -> &str {
2725 &self.id
2726 }
2727
2728 fn time(&self) -> Option<DateTime<Utc>> {
2729 parse_timestamp(&self.transaction_time)
2730 }
2731}
2732
2733impl PagedActivity for AlpacaNonTradeActivity {
2734 fn id(&self) -> &str {
2735 &self.id
2736 }
2737
2738 fn time(&self) -> Option<DateTime<Utc>> {
2739 self.created_at.as_deref().and_then(parse_timestamp)
2740 }
2741}
2742
2743const FILL_ACTIVITIES: &str = "FILL";
2745
2746const CASH_FLOW_ACTIVITIES: &str = "INT,FEE,PTC,PTR";
2748
2749const OPTION_EVENT_ACTIVITIES: &str = "OPASN,OPEXC,OPEXP,OPTRD";
2751
2752const LAST_MILLISECOND: NaiveTime = match NaiveTime::from_hms_milli_opt(23, 59, 59, 999) {
2754 Some(time) => time,
2755 None => panic!("23:59:59.999 is a time of day"),
2756};
2757
2758fn span_resume(
2765 resume: Option<DateTime<Utc>>,
2766 start: DateTime<Utc>,
2767 end: DateTime<Utc>,
2768) -> Result<Option<DateTime<Utc>>, ()> {
2769 let resume = resume.filter(|resume| *resume <= end);
2770 if resume.is_some_and(|resume| resume <= floor_millis(start)) {
2771 return Err(());
2772 }
2773 Ok(resume)
2774}
2775
2776struct ActivityPage<A> {
2778 activities: Vec<A>,
2780 resume: Option<DateTime<Utc>>,
2783}
2784
2785fn floor_millis(time: DateTime<Utc>) -> DateTime<Utc> {
2787 DateTime::from_timestamp_millis(time.timestamp_millis()).unwrap_or(time)
2788}
2789
2790const PAGE_SIZE_STR: &str = "100"; const _: () = assert!(
2820 ALPACA_MAX_ACTIVITIES == 100,
2821 "PAGE_SIZE_STR must be updated to match ALPACA_MAX_ACTIVITIES",
2822);
2823
2824async fn paginate_activities<A: PagedActivity>(
2825 http: &reqwest::Client,
2826 rate_limiter: &RateLimitTracker,
2827 base: &str,
2828 types: &str,
2829 start: DateTime<Utc>,
2830 end: Option<DateTime<Utc>>,
2831) -> Result<ActivityPage<A>, UnindexedClientError> {
2832 let after =
2835 floor_millis(start.max(DateTime::UNIX_EPOCH)).to_rfc3339_opts(SecondsFormat::Millis, true);
2836 let until = end.filter(|end| *end < Utc::now()).map(|end| {
2837 (floor_millis(end) + TimeDelta::milliseconds(1))
2838 .to_rfc3339_opts(SecondsFormat::Millis, true)
2839 });
2840 let mut all = Vec::with_capacity(ALPACA_MAX_ACTIVITIES);
2841 let mut page_token: Option<String> = None;
2842 let mut pages = 0usize;
2843 let mut truncated = false;
2844
2845 loop {
2846 if pages >= MAX_ACTIVITY_PAGES {
2847 truncated = true;
2848 break;
2849 }
2850 pages += 1;
2851 let page_token_ref = page_token.as_deref();
2853 let activities: Vec<A> = rest_with_retry(rate_limiter, || {
2854 let mut req = http.get(format!("{base}/v2/account/activities")).query(&[
2855 ("activity_types", types),
2856 ("after", after.as_str()),
2857 ("page_size", PAGE_SIZE_STR),
2858 ("direction", "asc"),
2859 ]);
2860 if let Some(until) = until.as_deref() {
2861 req = req.query(&[("until", until)]);
2862 }
2863 if let Some(token) = page_token_ref {
2864 req = req.query(&[("page_token", token)]);
2865 }
2866 req
2867 })
2868 .await?;
2869
2870 let page_len = activities.len();
2871 let page_token_candidate = activities.last().map(|a| a.id().to_owned());
2881 all.extend(activities);
2882
2883 if page_len < ALPACA_MAX_ACTIVITIES {
2884 break;
2885 }
2886 match page_token_candidate {
2887 Some(token) if !token.is_empty() => {
2888 debug!("Alpaca paginate_activities: fetching next page ({page_len} results)");
2889 page_token = Some(token);
2890 }
2891 _ => break,
2895 }
2896 }
2897
2898 let resume = truncated.then(|| {
2901 floor_millis(
2902 all.iter()
2903 .rev()
2904 .find_map(PagedActivity::time)
2905 .unwrap_or(start),
2906 )
2907 });
2908 Ok(ActivityPage {
2909 activities: all,
2910 resume,
2911 })
2912}
2913
2914#[allow(clippy::cognitive_complexity)] async fn connection_manager(
2930 tx: mpsc::UnboundedSender<UnindexedAccountEvent>,
2931 dedup: SharedDedupCache,
2932 config: Arc<AlpacaConfig>,
2933 http: reqwest::Client,
2934 rate_limiter: Arc<RateLimitTracker>,
2935 known: SharedKnownLiveOrders,
2936 instruments: Vec<InstrumentNameExchange>,
2937 initial_ws: Option<WebSocket>,
2938) {
2939 let mut budget = ReconnectBudget::new("Alpaca", BackoffLimits::ACCOUNT_STREAM);
2940 let mut disconnected: Option<HostMoment> = None;
2942 let mut clock = VenueClock::new();
2944 let mut unchecked = UncheckedOrders::default();
2947 let mut current_ws = initial_ws;
2948
2949 'outer: loop {
2950 let mut ws = match current_ws.take() {
2952 Some(ws) => ws,
2953 None => match connect_and_subscribe(&config).await {
2954 Ok(ws) => ws,
2955 Err(e) => {
2956 error!(%e, "Alpaca WS connect/subscribe failed");
2957 if !budget.wait().await {
2958 error!("Alpaca max reconnect attempts exhausted");
2959 emit_stream_terminated(
2961 &tx,
2962 ExchangeId::AlpacaBroker,
2963 StreamTerminationReason::ReconnectBudgetExhausted {
2964 attempts: budget.attempts(),
2965 last_error: e.to_string(),
2966 },
2967 );
2968 break;
2969 }
2970 continue;
2971 }
2972 },
2973 };
2974 info!("Alpaca account_stream connected and subscribed");
2975 if let Some(last) = disconnected.take() {
2985 let end = clock.span_end(last, HostMoment::now());
2988 recover_fills_or_report(
2989 &http,
2990 &rate_limiter,
2991 &instruments,
2992 config.rest_base_url(),
2993 fill_recovery_start(&clock, last),
2994 end,
2995 Duration::from_secs(FILL_RECOVERY_TIMEOUT_SECS),
2996 &tx,
2997 &dedup,
2998 &known,
2999 )
3000 .await;
3001 let held = {
3003 let known = known.lock();
3004 if instruments.is_empty() {
3005 known.instruments()
3006 } else {
3007 known.instruments_among(&instruments)
3008 }
3009 };
3010 unchecked.open(held);
3011 }
3012
3013 let mut last_message = HostMoment::now();
3026 let heartbeat = tokio::time::sleep(Duration::from_secs(HEARTBEAT_TIMEOUT_SECS));
3027 tokio::pin!(heartbeat);
3028 let stable_from = tokio::time::Instant::now() + Duration::from_secs(HEARTBEAT_TIMEOUT_SECS);
3031
3032 let order_checks =
3037 run_order_checks(&http, &rate_limiter, &config, &known, &mut unchecked, &tx);
3038 tokio::pin!(order_checks);
3039
3040 macro_rules! reset_heartbeat {
3046 () => {
3047 let now = tokio::time::Instant::now();
3048 heartbeat
3049 .as_mut()
3050 .reset(now + Duration::from_secs(HEARTBEAT_TIMEOUT_SECS));
3051 last_message = HostMoment::new(Utc::now(), now);
3052 if now >= stable_from {
3053 budget.reset();
3054 }
3055 };
3056 }
3057 enum DisconnectReason {
3060 ServerClose,
3061 Error(String),
3062 StreamEnded,
3063 HeartbeatTimeout,
3064 }
3065 let reason = loop {
3066 tokio::select! {
3067 msg = ws.next() => {
3068 match msg {
3069 Some(Ok(WsMessage::Ping(_))) => {
3070 reset_heartbeat!();
3073 }
3074 Some(Ok(WsMessage::Text(text))) => {
3075 process_ws_text(text.as_str(), &tx, &dedup, &known, &mut clock);
3076 reset_heartbeat!();
3077 }
3078 Some(Ok(WsMessage::Binary(bytes))) => {
3079 match std::str::from_utf8(&bytes) {
3081 Ok(text) => {
3082 process_ws_text(text, &tx, &dedup, &known, &mut clock);
3083 reset_heartbeat!();
3087 }
3088 Err(e) => warn!(%e, "Alpaca WS binary frame: not valid UTF-8"),
3089 }
3090 }
3091 Some(Ok(WsMessage::Close(frame))) => {
3092 warn!(frame = ?frame, "Alpaca WS closed by server");
3093 break DisconnectReason::ServerClose;
3094 }
3095 Some(Ok(_)) => {} Some(Err(e)) => {
3097 warn!(%e, "Alpaca WS error, reconnecting");
3098 break DisconnectReason::Error(e.to_string());
3099 }
3100 None => {
3101 warn!("Alpaca WS stream ended, reconnecting");
3102 break DisconnectReason::StreamEnded;
3103 }
3104 }
3105 }
3106 () = &mut order_checks => {}
3107 _ = &mut heartbeat => {
3108 warn!(
3109 timeout_secs = HEARTBEAT_TIMEOUT_SECS,
3110 "Alpaca heartbeat timeout, reconnecting"
3111 );
3112 break DisconnectReason::HeartbeatTimeout;
3114 }
3115 _ = tx.closed() => {
3116 debug!("Alpaca account_stream consumer dropped, terminating");
3117 let _ = tokio::time::timeout(
3118 Duration::from_secs(WS_CLOSE_TIMEOUT_SECS),
3119 ws.close(None),
3120 ).await;
3121 break 'outer;
3124 }
3125 }
3126 };
3127
3128 disconnected = Some(last_message);
3133
3134 let _ =
3136 tokio::time::timeout(Duration::from_secs(WS_CLOSE_TIMEOUT_SECS), ws.close(None)).await;
3137
3138 if tx.is_closed() {
3139 break;
3142 }
3143 if !budget.wait().await {
3144 error!("Alpaca max reconnect attempts exhausted, stream terminating");
3145 let last_error = match reason {
3148 DisconnectReason::HeartbeatTimeout => {
3149 format!("heartbeat timeout ({HEARTBEAT_TIMEOUT_SECS}s)")
3150 }
3151 DisconnectReason::ServerClose => "WebSocket closed by server".to_string(),
3152 DisconnectReason::Error(e) => e,
3153 DisconnectReason::StreamEnded => "WebSocket stream ended".to_string(),
3154 };
3155 emit_stream_terminated(
3156 &tx,
3157 ExchangeId::AlpacaBroker,
3158 StreamTerminationReason::ReconnectBudgetExhausted {
3159 attempts: budget.attempts(),
3160 last_error,
3161 },
3162 );
3163 break;
3164 }
3165 }
3166}
3167
3168#[derive(Debug)]
3173enum HandshakeError {
3174 Transport(String),
3177 Auth(String),
3180}
3181
3182async fn connect_and_subscribe(config: &AlpacaConfig) -> Result<WebSocket, UnindexedClientError> {
3186 let url = config.stream_url();
3187 debug!(%url, "Alpaca: connecting to WebSocket");
3188
3189 let mut ws = rustrade_integration::protocol::websocket::connect(url)
3190 .await
3191 .map_err(|e| {
3192 UnindexedClientError::Connectivity(ConnectivityError::Socket(format!(
3193 "WS connect: {e}"
3194 )))
3195 })?;
3196
3197 let result = tokio::time::timeout(
3199 Duration::from_secs(WS_HANDSHAKE_TIMEOUT_SECS),
3200 ws_handshake(&mut ws, config),
3201 )
3202 .await;
3203
3204 match result {
3205 Ok(Ok(())) => Ok(ws),
3206 Ok(Err(HandshakeError::Transport(e))) => {
3207 let _ = ws.close(None).await;
3208 Err(UnindexedClientError::Connectivity(
3209 ConnectivityError::Socket(e),
3210 ))
3211 }
3212 Ok(Err(HandshakeError::Auth(e))) => {
3213 let _ = ws.close(None).await;
3214 Err(UnindexedClientError::Api(ApiError::Unauthenticated(e)))
3215 }
3216 Err(_) => {
3217 let _ = ws.close(None).await;
3218 Err(UnindexedClientError::Connectivity(
3219 ConnectivityError::Timeout,
3220 ))
3221 }
3222 }
3223}
3224
3225async fn ws_handshake(ws: &mut WebSocket, config: &AlpacaConfig) -> Result<(), HandshakeError> {
3233 let auth = serde_json::json!({
3235 "action": "auth",
3236 "key": config.api_key(),
3237 "secret": config.secret_key,
3240 })
3241 .to_string();
3242 ws.send(WsMessage::Text(auth.into()))
3243 .await
3244 .map_err(|e| HandshakeError::Transport(format!("WS auth send: {e}")))?;
3245
3246 loop {
3248 match ws.next().await {
3249 Some(Ok(WsMessage::Text(text))) => {
3250 if let Some(result) = check_auth_response(text.as_str()) {
3251 result?;
3252 break;
3253 }
3254 }
3255 Some(Ok(WsMessage::Binary(bytes))) => {
3256 if let Ok(text) = std::str::from_utf8(&bytes)
3257 && let Some(result) = check_auth_response(text)
3258 {
3259 result?;
3260 break;
3261 }
3262 }
3263 Some(Err(e)) => {
3264 return Err(HandshakeError::Transport(format!(
3265 "WS error during auth: {e}"
3266 )));
3267 }
3268 None => {
3269 return Err(HandshakeError::Transport(
3270 "WS closed before auth response".into(),
3271 ));
3272 }
3273 _ => {} }
3275 }
3276
3277 let sub = serde_json::json!({
3279 "action": "listen",
3280 "data": { "streams": ["trade_updates"] }
3281 })
3282 .to_string();
3283 ws.send(WsMessage::Text(sub.into()))
3284 .await
3285 .map_err(|e| HandshakeError::Transport(format!("WS subscribe send: {e}")))?;
3286
3287 loop {
3289 match ws.next().await {
3290 Some(Ok(WsMessage::Text(text))) => {
3291 if check_listen_ack(text.as_str()) {
3292 break;
3293 }
3294 if let Ok(msg) = serde_json::from_str::<AlpacaStreamMessage<'_>>(text.as_str()) {
3301 if msg.stream == "trade_updates" {
3302 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");
3303 } else {
3304 trace!(stream = %msg.stream, "WS message dropped during listen-ack handshake");
3305 }
3306 } else {
3307 trace!(
3308 bytes = text.len(),
3309 "WS non-stream message dropped during listen-ack handshake"
3310 );
3311 }
3312 }
3313 Some(Ok(WsMessage::Binary(bytes))) => {
3314 if let Ok(text) = std::str::from_utf8(&bytes)
3315 && check_listen_ack(text)
3316 {
3317 break;
3318 }
3319 trace!(
3320 bytes = bytes.len(),
3321 "WS binary message dropped during listen-ack handshake"
3322 );
3323 }
3324 Some(Err(e)) => {
3325 return Err(HandshakeError::Transport(format!(
3326 "WS error during subscribe: {e}"
3327 )));
3328 }
3329 None => {
3330 return Err(HandshakeError::Transport(
3331 "WS closed before subscribe ack".into(),
3332 ));
3333 }
3334 _ => {}
3335 }
3336 }
3337
3338 info!("Alpaca WS authenticated and subscribed to trade_updates");
3339 Ok(())
3340}
3341
3342fn check_auth_response(text: &str) -> Option<Result<(), HandshakeError>> {
3348 let msg = serde_json::from_str::<AlpacaStreamMessage<'_>>(text).ok()?;
3349 if msg.stream != "authorization" {
3350 return None;
3351 }
3352 #[derive(Deserialize)]
3353 struct AuthData<'a> {
3354 status: &'a str,
3355 }
3356 let data = serde_json::from_str::<AuthData<'_>>(msg.data.get()).ok()?;
3357 if data.status == "authorized" {
3358 Some(Ok(()))
3359 } else {
3360 Some(Err(HandshakeError::Auth(format!(
3361 "Alpaca WS auth failed: status={}",
3362 data.status
3363 ))))
3364 }
3365}
3366
3367fn check_listen_ack(text: &str) -> bool {
3371 let Ok(msg) = serde_json::from_str::<AlpacaStreamMessage<'_>>(text) else {
3372 return false;
3373 };
3374 if msg.stream != "listening" {
3375 return false;
3376 }
3377 #[derive(Deserialize)]
3378 struct ListenData<'a> {
3379 #[serde(borrow)]
3380 streams: Vec<&'a str>,
3381 }
3382 let Ok(data) = serde_json::from_str::<ListenData<'_>>(msg.data.get()) else {
3383 return false;
3384 };
3385 data.streams.contains(&"trade_updates")
3386}
3387
3388fn process_ws_text(
3402 text: &str,
3403 tx: &mpsc::UnboundedSender<UnindexedAccountEvent>,
3404 dedup: &SharedDedupCache,
3405 known: &SharedKnownLiveOrders,
3406 clock: &mut VenueClock,
3407) {
3408 let msg: AlpacaStreamMessage<'_> = match serde_json::from_str(text) {
3409 Ok(m) => m,
3410 Err(e) => {
3411 trace!(
3412 %e,
3413 raw = ?&text[..text.len().min(200)],
3414 "Alpaca WS: skipped non-JSON message"
3415 );
3416 return;
3417 }
3418 };
3419
3420 match msg.stream.as_str() {
3421 "trade_updates" => {
3422 let update: AlpacaTradeUpdate<'_> = match serde_json::from_str(msg.data.get()) {
3423 Ok(u) => u,
3424 Err(e) => {
3425 warn!(
3430 %e,
3431 raw = ?&msg.data.get()[..msg.data.get().len().min(200)],
3432 "Alpaca WS trade_updates: failed to deserialize event — event dropped"
3433 );
3434 return;
3435 }
3436 };
3437
3438 let time = trade_update_time(&update);
3442 if time.1 == TimeSource::Venue {
3443 clock.observe(time.0, tokio::time::Instant::now());
3444 }
3445
3446 if is_fill_event(&update)
3450 && is_duplicate_fill(dedup, ws_execution_id(&update), early_dedup_key(&update))
3451 {
3452 trace!("Alpaca WS: skipping duplicate fill event (early check)");
3453 return;
3454 }
3455
3456 if is_trade_amendment(&update) {
3459 warn!(
3460 event = %update.event,
3461 raw = %msg.data.get(),
3462 "Alpaca WS: a fill reported earlier was amended — reporting it as TradeAmended"
3463 );
3464 }
3465
3466 for event in convert_trade_update_at(update, time).into_iter().flatten() {
3467 if let AccountEventKind::TradeAmended(amendment) = &event.kind
3471 && let TradeAmendmentKind::Corrected { replacement } = &amendment.kind
3472 {
3473 record_execution(dedup, &replacement.id.0);
3474 }
3475 let known = KnownLiveOrders::observes(&event.kind).then(|| {
3476 let mut known = known.lock();
3477 known.observe(&event);
3478 known
3479 });
3480 let _ = tx.send(event);
3483 drop(known);
3484 }
3485 }
3486 "authorization" | "listening" => {
3487 trace!(stream = %msg.stream, "Alpaca WS: auth/listen ack received during stream");
3490 }
3491 other => {
3492 trace!(%other, "Alpaca WS: ignoring unknown stream type");
3493 }
3494 }
3495}
3496
3497fn fill_dedup_key(order_id: &str, cumulative: Decimal) -> SmolStr {
3508 format_smolstr!("{}:{}", order_id, cumulative.normalize())
3509}
3510
3511fn is_trade_amendment(update: &AlpacaTradeUpdate<'_>) -> bool {
3513 matches!(update.event.as_str(), "trade_bust" | "trade_correct")
3514}
3515
3516#[inline]
3520fn is_fill_event(update: &AlpacaTradeUpdate<'_>) -> bool {
3521 matches!(update.event.as_str(), "fill" | "partial_fill")
3522}
3523
3524fn ws_execution_id<'a>(update: &AlpacaTradeUpdate<'a>) -> Option<&'a str> {
3526 update.execution_id.filter(|id| !id.is_empty())
3527}
3528
3529fn early_dedup_key(update: &AlpacaTradeUpdate<'_>) -> SmolStr {
3533 let filled_qty = update.order.filled_qty.unwrap_or("0");
3534 let qty = Decimal::from_str(filled_qty).unwrap_or(Decimal::ZERO);
3535 fill_dedup_key(&update.order.id, qty)
3536}
3537
3538fn activity_execution_id(activity_id: &str) -> &str {
3542 activity_execution_id_part(activity_id).unwrap_or(activity_id)
3543}
3544
3545fn activity_execution_id_part(activity_id: &str) -> Option<&str> {
3549 activity_id
3550 .split_once("::")
3551 .map(|(_, execution_id)| execution_id)
3552 .filter(|execution_id| !execution_id.is_empty())
3553}
3554
3555#[derive(Debug, PartialEq)]
3561struct UnreadFills {
3562 start: DateTime<Utc>,
3563 reason: FillRecoveryFailure,
3564}
3565
3566fn fill_recovery_start(clock: &VenueClock, last: HostMoment) -> DateTime<Utc> {
3574 let earlier = |time: DateTime<Utc>, by: TimeDelta| {
3575 time.checked_sub_signed(by)
3576 .unwrap_or(DateTime::<Utc>::MIN_UTC)
3577 };
3578 let at = clock
3579 .span_start(last)
3580 .unwrap_or_else(|| earlier(last.time, UNSAMPLED_CLOCK_SKEW));
3581 earlier(at, TimeDelta::milliseconds(SIGNAL_RECOVERY_LOOKBACK_MS))
3582}
3583
3584async fn recover_fills_or_report(
3591 http: &reqwest::Client,
3592 rate_limiter: &RateLimitTracker,
3593 instruments: &[InstrumentNameExchange],
3594 base: &str,
3595 start: DateTime<Utc>,
3596 end: DateTime<Utc>,
3597 timeout: Duration,
3598 tx: &mpsc::UnboundedSender<UnindexedAccountEvent>,
3599 dedup: &SharedDedupCache,
3600 known: &SharedKnownLiveOrders,
3601) {
3602 let outcome = match tokio::time::timeout(
3603 timeout,
3604 recover_fills(
3605 http,
3606 rate_limiter,
3607 instruments,
3608 base,
3609 start,
3610 tx,
3611 dedup,
3612 known,
3613 ),
3614 )
3615 .await
3616 {
3617 Ok(outcome) => outcome,
3618 Err(_) => {
3622 warn!(
3623 timeout_secs = timeout.as_secs(),
3624 instruments = ?instruments
3625 .iter()
3626 .map(|instrument| instrument.name().as_str())
3627 .collect::<Vec<_>>(),
3628 "Alpaca fill recovery timed out — fills since the disconnect may be missing for \
3629 any of the instruments (every instrument when the list is empty)"
3630 );
3631 Err(UnreadFills {
3632 start,
3633 reason: FillRecoveryFailure::TimedOut {
3634 timeout_secs: timeout.as_secs(),
3635 },
3636 })
3637 }
3638 };
3639 if let Err(unread) = outcome
3640 && tx
3641 .send(fill_recovery_gave_up(instruments, unread, end))
3642 .is_err()
3643 {
3644 debug!("Alpaca fill recovery: consumer dropped before the give-up was sent");
3645 }
3646}
3647
3648fn fill_recovery_gave_up(
3657 instruments: &[InstrumentNameExchange],
3658 unread: UnreadFills,
3659 end: DateTime<Utc>,
3660) -> UnindexedAccountEvent {
3661 let UnreadFills { start, reason } = unread;
3662 let scope = if instruments.is_empty() {
3664 FillRecoveryScope::AllInstruments
3665 } else {
3666 FillRecoveryScope::Instruments(instruments.to_vec())
3667 };
3668 UnindexedAccountEvent::new(
3669 ExchangeId::AlpacaBroker,
3670 AccountEventKind::FillRecoveryGaveUp(FillRecoveryGap::new(
3671 scope,
3672 start,
3673 end.max(start),
3674 1,
3675 reason,
3676 )),
3677 )
3678}
3679
3680async fn recover_fills(
3689 http: &reqwest::Client,
3690 rate_limiter: &RateLimitTracker,
3691 instruments: &[InstrumentNameExchange],
3692 base: &str,
3693 after: DateTime<Utc>,
3694 tx: &mpsc::UnboundedSender<UnindexedAccountEvent>,
3695 dedup: &SharedDedupCache,
3696 known: &SharedKnownLiveOrders,
3697) -> Result<(), UnreadFills> {
3698 info!(%after, instruments = instruments.len(), "Alpaca recovering fills after reconnect");
3701 let instrument_set: fnv::FnvHashSet<&str> = if instruments.is_empty() {
3702 fnv::FnvHashSet::default()
3703 } else {
3704 instruments.iter().map(|i| i.name().as_str()).collect()
3705 };
3706
3707 let page = match paginate_activities::<AlpacaActivity>(
3708 http,
3709 rate_limiter,
3710 base,
3711 FILL_ACTIVITIES,
3712 after,
3713 None,
3714 )
3715 .await
3716 {
3717 Ok(p) => p,
3718 Err(e) => {
3719 error!(%e, "Alpaca fill recovery: REST request failed");
3720 return Err(UnreadFills {
3721 start: after,
3722 reason: FillRecoveryFailure::Request(e.to_string()),
3723 });
3724 }
3725 };
3726
3727 let activities = page.activities;
3728
3729 let unread = page.resume.map(|start| {
3733 error!(
3734 max_pages = MAX_ACTIVITY_PAGES,
3735 fills_read = activities.len(),
3736 %start,
3737 "Alpaca fill recovery: max page limit reached, truncating — fills from this time on \
3738 are not delivered; read them with fetch_trades"
3739 );
3740 UnreadFills {
3741 start,
3742 reason: FillRecoveryFailure::Truncated {
3743 fills_read: activities.len(),
3744 },
3745 }
3746 });
3747
3748 let mut recovered = 0u32;
3749 let mut duplicates = 0u32;
3750
3751 let mut cumulative_qty: FnvHashMap<&str, Decimal> = FnvHashMap::default();
3768
3769 for activity in &activities {
3770 if !instrument_set.is_empty() && !instrument_set.contains(activity.symbol.as_str()) {
3771 continue;
3775 }
3776
3777 let exec_qty = Decimal::from_str(&activity.qty).unwrap_or(Decimal::ZERO);
3792 let cum = cumulative_qty
3793 .entry(activity.order_id.as_str())
3794 .or_default();
3795 *cum += exec_qty;
3796 let cumulative = *cum;
3797
3798 let trade = match convert_activity_to_trade(activity) {
3799 Some(t) => t,
3800 None => {
3801 warn!(id = %activity.id, symbol = %activity.symbol, "Alpaca: skipping activity with unparseable fields");
3802 continue; }
3804 };
3805
3806 let key = fill_dedup_key(
3816 &activity.order_id,
3817 trade.order_filled_quantity.unwrap_or(cumulative),
3818 );
3819 if is_duplicate_fill(dedup, activity_execution_id_part(&activity.id), key) {
3820 duplicates += 1;
3821 continue;
3822 }
3823
3824 let event =
3825 UnindexedAccountEvent::new(ExchangeId::AlpacaBroker, AccountEventKind::Trade(trade));
3826 let mut held = known.lock();
3827 held.observe(&event);
3828 let sent = tx.send(event);
3829 drop(held);
3830 if sent.is_err() {
3831 debug!("Alpaca fill recovery: consumer dropped during recovery");
3832 return Ok(());
3833 }
3834 recovered += 1;
3835 }
3836
3837 info!(recovered, duplicates, "Alpaca fill recovery complete");
3838 unread.map_or(Ok(()), Err)
3839}
3840
3841async fn fetch_order_lookup(
3850 http: reqwest::Client,
3851 rate_limiter: Arc<RateLimitTracker>,
3852 config: Arc<AlpacaConfig>,
3853 key: UnindexedOrderKey,
3854) -> Result<OrderLookup, UnindexedClientError> {
3855 let url = format!("{}/v2/orders:by_client_order_id", config.rest_base_url());
3856 let cid = key.cid.0.as_str();
3857 let found: Option<AlpacaOrderResponse> = rest_lookup_with_retry(&rate_limiter, || {
3858 http.get(&url).query(&[("client_order_id", cid)])
3859 })
3860 .await?;
3861 let Some(order) = found else {
3862 debug!(instrument = %key.instrument, cid = %key.cid, "Alpaca does not know this order");
3863 return Ok(OrderLookup::Unknown);
3864 };
3865 if !order
3866 .symbol
3867 .eq_ignore_ascii_case(key.instrument.name().as_str())
3868 {
3869 debug!(
3870 instrument = %key.instrument,
3871 cid = %key.cid,
3872 symbol = %order.symbol,
3873 "Alpaca knows this client order id on another symbol"
3874 );
3875 return Ok(OrderLookup::Unknown);
3876 }
3877 Ok(
3878 convert_ended_order(&order, &key).map_or(OrderLookup::NotEnded, |order| {
3879 OrderLookup::Ended(Box::new(order))
3880 }),
3881 )
3882}
3883
3884async fn listed_open_cids(
3887 http: reqwest::Client,
3888 rate_limiter: Arc<RateLimitTracker>,
3889 config: Arc<AlpacaConfig>,
3890 instruments: Vec<InstrumentNameExchange>,
3891) -> Result<FnvHashSet<ClientOrderId>, UnindexedClientError> {
3892 let orders =
3893 fetch_raw_open_orders(&http, &rate_limiter, config.rest_base_url(), &instruments).await?;
3894 Ok(orders
3895 .iter()
3896 .map(|order| alpaca_cid(order.client_order_id.as_deref(), &order.id))
3897 .collect())
3898}
3899
3900async fn recover_alpaca_ended_orders(
3904 http: &reqwest::Client,
3905 rate_limiter: &Arc<RateLimitTracker>,
3906 config: &Arc<AlpacaConfig>,
3907 known: &SharedKnownLiveOrders,
3908 unchecked: &mut UncheckedOrders,
3909 tx: &mpsc::UnboundedSender<UnindexedAccountEvent>,
3910) {
3911 recover_ended_orders(
3912 ExchangeId::AlpacaBroker,
3913 known,
3914 unchecked,
3915 &NoPendingFills,
3916 tx,
3917 OpenListing::Batched,
3918 |instruments| {
3919 listed_open_cids(
3920 http.clone(),
3921 rate_limiter.clone(),
3922 config.clone(),
3923 instruments,
3924 )
3925 },
3926 |key| fetch_order_lookup(http.clone(), rate_limiter.clone(), config.clone(), key),
3927 )
3928 .await;
3929}
3930
3931async fn run_order_checks(
3936 http: &reqwest::Client,
3937 rate_limiter: &Arc<RateLimitTracker>,
3938 config: &Arc<AlpacaConfig>,
3939 known: &SharedKnownLiveOrders,
3940 unchecked: &mut UncheckedOrders,
3941 tx: &mpsc::UnboundedSender<UnindexedAccountEvent>,
3942) {
3943 loop {
3944 if tx.is_closed() {
3946 return std::future::pending().await;
3947 }
3948 match unchecked.next_due(&NoPendingFills) {
3949 Some(due) => tokio::time::sleep_until(due).await,
3950 None => return std::future::pending().await,
3951 }
3952 recover_alpaca_ended_orders(http, rate_limiter, config, known, unchecked, tx).await;
3953 }
3954}
3955
3956fn convert_ended_order(
3964 o: &AlpacaOrderResponse,
3965 key: &UnindexedOrderKey,
3966) -> Option<UnindexedInactiveOrder> {
3967 let instrument = &key.instrument;
3968 let Some(status) = o.status.as_deref() else {
3969 warn!(%instrument, cid = %key.cid, order_id = %o.id, "Alpaca order missing status");
3970 return None;
3971 };
3972 if order_status_is_live(status) {
3973 return None;
3974 }
3975 let Some(order) = convert_representable_open_order(o) else {
3976 warn!(%instrument, cid = %key.cid, order_id = %o.id, status, "Alpaca order cannot be represented, treating it as not ended");
3977 return None;
3978 };
3979 let filled_qty = Decimal::from_str(&o.filled_qty).ok();
3982 let Some(state) = ended_order_state(
3983 status,
3984 OrderId(SmolStr::new(&o.id)),
3985 order.state.time_exchange,
3986 order.quantity,
3987 filled_qty,
3988 o.filled_avg_price.as_deref(),
3989 ) else {
3990 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");
3991 return None;
3992 };
3993 let mut order = order.map_state(|_| state);
3994 order.key = key.clone();
3995 Some(order)
3996}
3997
3998fn order_status_is_live(status: &str) -> bool {
4004 matches!(
4005 status,
4006 "new"
4007 | "partially_filled"
4008 | "done_for_day"
4009 | "pending_cancel"
4010 | "pending_replace"
4011 | "accepted"
4012 | "pending_new"
4013 | "accepted_for_bidding"
4014 | "stopped"
4015 | "suspended"
4016 | "calculated"
4017 | "held"
4018 )
4019}
4020
4021fn ended_order_state<AssetKey, InstrumentKey>(
4031 status: &str,
4032 order_id: OrderId,
4033 time_exchange: DateTime<Utc>,
4034 quantity: Decimal,
4035 filled_qty: Option<Decimal>,
4036 filled_avg_price: Option<&str>,
4037) -> Option<InactiveOrderState<AssetKey, InstrumentKey>> {
4038 Some(match status {
4039 "filled" => InactiveOrderState::FullyFilled(Filled::new(
4041 order_id,
4042 time_exchange,
4043 filled_qty.unwrap_or(quantity),
4044 alpaca_avg_price(filled_avg_price),
4045 )),
4046 "canceled" | "replaced" => {
4047 InactiveOrderState::Cancelled(Cancelled::new(order_id, time_exchange, filled_qty))
4048 }
4049 "expired" => InactiveOrderState::Expired(Expired::new(order_id, time_exchange, filled_qty)),
4050 "rejected" => {
4051 InactiveOrderState::OpenFailed(OrderError::Rejected(ApiError::OrderRejected(format!(
4052 "Alpaca rejected order {order_id} after accepting it"
4053 ))))
4054 }
4055 _ => return None,
4056 })
4057}
4058
4059fn placed_order_state(
4069 resp: &AlpacaOrderResponse,
4070 instrument: &InstrumentNameExchange,
4071 quantity: Decimal,
4072) -> UnindexedOrderState {
4073 let order_id = OrderId(SmolStr::new(&resp.id));
4074 let time_exchange = order_state_time(resp);
4075 let filled_qty = alpaca_filled_qty(&resp.id, Some(&resp.filled_qty));
4076 let filled_avg_price = resp.filled_avg_price.as_deref();
4077 match resp.status.as_deref() {
4078 Some(status) => {
4079 if let Some(ended) = ended_order_state(
4080 status,
4081 order_id.clone(),
4082 time_exchange,
4083 quantity,
4084 filled_qty,
4085 filled_avg_price,
4086 ) {
4087 return OrderState::Inactive(ended);
4088 }
4089 if !order_status_is_live(status) {
4090 warn!(%instrument, %order_id, status, "Alpaca placed an order with a status this version does not know, reading it from what filled");
4091 }
4092 }
4093 None => {
4094 warn!(%instrument, %order_id, "Alpaca placed an order without a status, reading it from what filled");
4095 }
4096 }
4097 match filled_qty {
4098 Some(filled_qty) if filled_qty >= quantity => OrderState::fully_filled(Filled::new(
4099 order_id,
4100 time_exchange,
4101 filled_qty,
4102 alpaca_avg_price(filled_avg_price),
4103 )),
4104 _ => OrderState::active(Open::new(
4107 VenueOrderId::Assigned(order_id),
4108 time_exchange,
4109 filled_qty.unwrap_or(Decimal::ZERO),
4110 )),
4111 }
4112}
4113
4114fn alpaca_avg_price(filled_avg_price: Option<&str>) -> Option<Decimal> {
4117 filled_avg_price.and_then(|price| Decimal::from_str(price).ok())
4118}
4119
4120fn is_usd(asset: &AssetNameExchange) -> bool {
4127 asset.name().as_str().eq_ignore_ascii_case(USD)
4128}
4129
4130fn convert_account_to_balances(
4148 account: &AlpacaAccount,
4149 assets: &[AssetNameExchange],
4150 time: DateTime<Utc>,
4151) -> Vec<AssetBalance<AssetNameExchange>> {
4152 if !assets.is_empty() && !assets.iter().any(is_usd) {
4153 return Vec::new();
4154 }
4155
4156 vec![AssetBalance::new(
4157 AssetNameExchange::new(USD),
4158 Balance::new(
4159 account.cash,
4160 account.cash.min(account.non_marginable_buying_power),
4161 ),
4162 time,
4163 )]
4164}
4165
4166fn convert_positions_to_balances(
4178 positions: &[AlpacaPosition],
4179 assets: &[AssetNameExchange],
4180 time: DateTime<Utc>,
4181) -> Vec<AssetBalance<AssetNameExchange>> {
4182 positions
4183 .iter()
4184 .filter(|p| is_crypto_position(p))
4185 .filter_map(|p| {
4186 let base = crypto_position_asset(&p.symbol);
4187
4188 if !assets.is_empty()
4190 && !assets
4191 .iter()
4192 .any(|a| a.name().as_str().eq_ignore_ascii_case(base))
4193 {
4194 return None;
4195 }
4196
4197 let asset_name = AssetNameExchange::new(base);
4200 Some(AssetBalance::new(
4201 asset_name,
4202 Balance::new(p.qty, p.qty_available),
4203 time,
4204 ))
4205 })
4206 .collect()
4207}
4208
4209fn crypto_position_asset(symbol: &str) -> &str {
4217 let base = match symbol.split_once('/') {
4218 Some((base, _)) => Some(base),
4219 None => {
4220 let base_len = symbol.len().saturating_sub(USD.len());
4221 symbol
4222 .get(base_len..)
4223 .filter(|suffix| suffix.eq_ignore_ascii_case(USD))
4224 .and_then(|_| symbol.get(..base_len))
4225 }
4226 };
4227 match base {
4228 Some(base) if !base.is_empty() => base,
4229 _ => {
4230 warn!(
4231 %symbol,
4232 "Alpaca crypto position symbol is neither ASSETUSD nor BASE/QUOTE; using it whole"
4233 );
4234 symbol
4235 }
4236 }
4237}
4238
4239fn is_crypto_position(position: &AlpacaPosition) -> bool {
4242 position.asset_class.eq_ignore_ascii_case("crypto")
4243}
4244
4245fn convert_positions(
4267 positions: &[AlpacaPosition],
4268 now: DateTime<Utc>,
4269) -> Result<Vec<(&str, Position)>, UnindexedClientError> {
4270 positions
4271 .iter()
4272 .filter(|p| !is_crypto_position(p) && !p.qty.is_zero())
4273 .map(|p| {
4274 let size = p.qty.abs();
4275 let quantity = match p.side {
4276 AlpacaPositionSide::Long => size,
4277 AlpacaPositionSide::Short => -size,
4278 AlpacaPositionSide::Unknown => {
4279 return Err(UnindexedClientError::Internal(format!(
4280 "Alpaca position {} has a missing or unrecognised side",
4281 p.symbol
4282 )));
4283 }
4284 };
4285 let position = Position::new(
4286 quantity,
4287 p.avg_entry_price,
4288 p.unrealized_pl,
4289 None,
4290 None,
4291 None,
4292 None,
4293 now,
4294 );
4295 Ok((p.symbol.as_str(), position))
4296 })
4297 .collect()
4298}
4299
4300fn build_instrument_snapshots(
4321 orders: Vec<AlpacaOrderResponse>,
4322 positions: Vec<(&str, Position)>,
4323 instruments: &[InstrumentNameExchange],
4324) -> Vec<InstrumentAccountSnapshot<ExchangeId, AssetNameExchange, InstrumentNameExchange>> {
4325 struct Listing {
4327 orders: Vec<UnindexedOrderSnapshot>,
4328 complete: bool,
4329 position: Option<Position>,
4330 }
4331
4332 impl Listing {
4333 fn empty() -> Self {
4334 Self {
4335 orders: Vec::new(),
4336 complete: true,
4337 position: None,
4338 }
4339 }
4340 }
4341
4342 let mut by_symbol: IndexMap<SmolStr, Listing> = IndexMap::new();
4344
4345 for order in orders {
4348 let listing = by_symbol
4349 .entry(order.symbol.to_ascii_uppercase().into())
4350 .or_insert_with(Listing::empty);
4351 match convert_open_order(&order) {
4352 Some(converted) => listing.orders.push(converted.into()),
4353 None => listing.complete = false,
4354 }
4355 }
4356
4357 for (symbol, position) in positions {
4358 by_symbol
4359 .entry(symbol.to_ascii_uppercase().into())
4360 .or_insert_with(Listing::empty)
4361 .position = Some(position);
4362 }
4363
4364 let snapshot = |instrument: InstrumentNameExchange, listing: Listing| {
4365 let position = if is_options_or_equity_symbol(instrument.name().as_str()) {
4368 listing
4369 .position
4370 .map_or(PositionReport::Flat, PositionReport::from_position)
4371 } else {
4372 PositionReport::Unreported
4373 };
4374 InstrumentAccountSnapshot::new(instrument, listing.orders, listing.complete, position, None)
4375 };
4376
4377 if instruments.is_empty() {
4379 by_symbol
4380 .into_iter()
4381 .map(|(sym, listing)| snapshot(InstrumentNameExchange::new(sym), listing))
4382 .collect()
4383 } else {
4384 let mut claimed = std::collections::HashSet::new();
4386 instruments
4387 .iter()
4388 .map(|inst| {
4389 let symbol = inst.name().as_str().to_ascii_uppercase();
4390 if !claimed.insert(symbol.clone()) {
4393 return InstrumentAccountSnapshot::new(
4394 inst.clone(),
4395 Vec::new(),
4396 false,
4397 PositionReport::Unreported,
4398 None,
4399 );
4400 }
4401 let listing = by_symbol
4404 .swap_remove(symbol.as_str())
4405 .unwrap_or_else(Listing::empty);
4406 snapshot(inst.clone(), listing)
4407 })
4408 .collect()
4409 }
4410}
4411
4412fn convert_open_order(
4422 o: &AlpacaOrderResponse,
4423) -> Option<Order<ExchangeId, InstrumentNameExchange, Open>> {
4424 let converted = convert_representable_open_order(o);
4425 if converted.is_none() {
4426 warn!(
4427 order_id = %o.id,
4428 symbol = %o.symbol,
4429 qty = ?o.qty,
4430 side = %o.side,
4431 order_type = %o.order_type,
4432 "Alpaca open order cannot be represented - leaving it out"
4433 );
4434 }
4435 converted
4436}
4437
4438fn alpaca_cid(client_order_id: Option<&str>, order_id: &str) -> ClientOrderId {
4444 ClientOrderId::new(client_order_id.unwrap_or(order_id))
4445}
4446
4447fn convert_representable_open_order(
4449 o: &AlpacaOrderResponse,
4450) -> Option<Order<ExchangeId, InstrumentNameExchange, Open>> {
4451 let order_id = OrderId(SmolStr::new(&o.id));
4452 let cid = alpaca_cid(o.client_order_id.as_deref(), &o.id);
4453
4454 let instrument = InstrumentNameExchange::new(&o.symbol);
4455 let side = parse_side(&o.side)?;
4456 let quantity = Decimal::from_str(o.qty.as_deref().unwrap_or("0")).ok()?;
4457 if quantity.is_zero() {
4461 return None;
4462 }
4463 let price = o
4464 .limit_price
4465 .as_deref()
4466 .and_then(|s| Decimal::from_str(s).ok());
4467 let filled_qty = alpaca_filled_qty(&o.id, Some(&o.filled_qty)).unwrap_or(Decimal::ZERO);
4468 let kind = parse_order_kind(
4469 &o.order_type,
4470 o.stop_price.as_deref(),
4471 o.trail_percent.as_deref(),
4472 o.trail_price.as_deref(),
4473 )?;
4474 let time_in_force = parse_time_in_force(&o.time_in_force);
4475 let time_exchange = order_state_time(o);
4476
4477 Some(Order {
4478 key: OrderKey::new(
4479 ExchangeId::AlpacaBroker,
4480 instrument,
4481 StrategyId::unknown(),
4483 cid,
4484 ),
4485 side,
4486 price,
4487 quantity,
4488 kind,
4489 time_in_force,
4490 state: Open::new(VenueOrderId::Assigned(order_id), time_exchange, filled_qty),
4491 })
4492}
4493
4494fn convert_activity_to_trade(
4496 a: &AlpacaActivity,
4497) -> Option<Trade<AssetNameExchange, InstrumentNameExchange>> {
4498 let trade_id = TradeId::new(activity_execution_id(&a.id));
4499 let order_id = OrderId(SmolStr::new(&a.order_id));
4500 let instrument = InstrumentNameExchange::new(&a.symbol);
4501 let side = parse_side(&a.side)?;
4502 let price = Decimal::from_str(&a.price).ok()?;
4503 let quantity = Decimal::from_str(&a.qty).ok()?;
4504 let order_filled_quantity = a.cum_qty.as_deref().and_then(|s| Decimal::from_str(s).ok());
4507 let (time_exchange, time_source) = match parse_timestamp(&a.transaction_time) {
4510 Some(time) => (time, TimeSource::Venue),
4511 None => {
4512 warn!(id = %a.id, "Alpaca activity: unparseable transaction_time, using now");
4513 (Utc::now(), TimeSource::Host)
4514 }
4515 };
4516
4517 Some(
4522 Trade::new(
4523 trade_id,
4524 order_id,
4525 instrument,
4526 StrategyId::unknown(),
4527 time_exchange,
4528 side,
4529 price,
4530 quantity,
4531 order_filled_quantity,
4532 AssetFees::new(
4533 AssetNameExchange::from(USD),
4534 Decimal::ZERO,
4535 Some(Decimal::ZERO),
4536 ),
4537 )
4538 .with_time_source(time_source),
4539 )
4540}
4541
4542fn ws_order_snapshot(
4552 order: &AlpacaOrderWs<'_>,
4553 instrument: InstrumentNameExchange,
4554 cid: ClientOrderId,
4555 order_id: OrderId,
4556 time_exchange: DateTime<Utc>,
4557) -> Option<UnindexedAccountEvent> {
4558 let side = parse_side(&order.side)?;
4559 let quantity = Decimal::from_str(order.qty.unwrap_or("0")).unwrap_or(Decimal::ZERO);
4560 if quantity.is_zero() {
4561 trace!(order_id = %order.id, "Alpaca WS: skipping notional order snapshot (qty=None)");
4562 return None;
4563 }
4564 let price = order.limit_price.and_then(|s| Decimal::from_str(s).ok());
4565 let filled_qty = order
4568 .filled_qty
4569 .and_then(|_| alpaca_filled_qty(&order.id, order.filled_qty))
4570 .unwrap_or(Decimal::ZERO);
4571 let kind = parse_order_kind(
4572 &order.order_type,
4573 order.stop_price,
4574 order.trail_percent,
4575 order.trail_price,
4576 )?;
4577 let time_in_force = parse_time_in_force(&order.time_in_force);
4578
4579 let order_snapshot = crate::order::Order {
4580 key: OrderKey::new(
4581 ExchangeId::AlpacaBroker,
4582 instrument,
4583 StrategyId::unknown(),
4584 cid,
4585 ),
4586 side,
4587 price,
4588 quantity,
4589 kind,
4590 time_in_force,
4591 state: OrderState::active(Open::new(
4592 VenueOrderId::Assigned(order_id),
4593 time_exchange,
4594 filled_qty,
4595 )),
4596 };
4597 Some(UnindexedAccountEvent::new(
4598 ExchangeId::AlpacaBroker,
4599 AccountEventKind::OrderSnapshot(rustrade_integration::collection::snapshot::Snapshot(
4600 order_snapshot,
4601 )),
4602 ))
4603}
4604
4605fn ws_fill_order_is_live(status: &str) -> bool {
4615 matches!(status, "partially_filled" | "filled")
4616}
4617
4618fn trade_amendment_event(
4630 update: &AlpacaTradeUpdate<'_>,
4631 instrument: InstrumentNameExchange,
4632 order_id: OrderId,
4633 (time_exchange, time_source): (DateTime<Utc>, TimeSource),
4634) -> UnindexedAccountEvent {
4635 let order = &update.order;
4636 let original = update
4637 .previous_execution_id
4638 .filter(|id| !id.is_empty())
4639 .map(TradeId::new);
4640 let quantity = update.qty.and_then(|s| Decimal::from_str(s).ok());
4641
4642 let kind = if update.event == "trade_bust" {
4643 TradeAmendmentKind::Busted {
4644 quantity: quantity.map(|quantity| quantity.abs()),
4645 }
4646 } else {
4647 let id = ws_execution_id(update).map(TradeId::new);
4648 let price = update.price.and_then(|s| Decimal::from_str(s).ok());
4649 match (id, price, quantity, parse_side(&order.side)) {
4650 (Some(id), Some(price), Some(quantity), Some(side)) if quantity > Decimal::ZERO => {
4651 TradeAmendmentKind::Corrected {
4652 replacement: with_reported_cid(
4653 Trade::new(
4654 id,
4655 order_id.clone(),
4656 instrument.clone(),
4657 StrategyId::unknown(),
4658 time_exchange,
4659 side,
4660 price,
4661 quantity,
4662 None,
4665 AssetFees::new(
4667 AssetNameExchange::from(USD),
4668 Decimal::ZERO,
4669 Some(Decimal::ZERO),
4670 ),
4671 )
4672 .with_time_source(time_source),
4673 order.client_order_id.as_ref(),
4674 ),
4675 }
4676 }
4677 (id, price, quantity, _) => TradeAmendmentKind::CorrectedUnresolved {
4678 id,
4679 price,
4680 quantity,
4681 },
4682 }
4683 };
4684
4685 UnindexedAccountEvent::new(
4686 ExchangeId::AlpacaBroker,
4687 AccountEventKind::TradeAmended(crate::trade::TradeAmendment::new(
4688 instrument,
4689 order_id,
4690 time_exchange,
4691 original,
4692 kind,
4693 )),
4694 )
4695}
4696
4697fn trade_update_time(update: &AlpacaTradeUpdate<'_>) -> (DateTime<Utc>, TimeSource) {
4705 if let Some(time) = update.timestamp.and_then(parse_timestamp) {
4706 return (time, TimeSource::Venue);
4707 }
4708 let order = &update.order;
4709 if let Some(time) = order.updated_at.and_then(parse_timestamp) {
4710 warn!(order_id = %order.id, event = %update.event, timestamp = ?update.timestamp, "Alpaca WS: event missing/unparseable timestamp, using its order's updated_at");
4711 return (time, TimeSource::Venue);
4712 }
4713 warn!(order_id = %order.id, event = %update.event, timestamp = ?update.timestamp, updated_at = ?order.updated_at, "Alpaca WS: event missing/unparseable timestamp and order updated_at, using now");
4714 (Utc::now(), TimeSource::Host)
4715}
4716
4717#[cfg(test)]
4719fn convert_trade_update(update: AlpacaTradeUpdate<'_>) -> [Option<UnindexedAccountEvent>; 2] {
4720 let time = trade_update_time(&update);
4721 convert_trade_update_at(update, time)
4722}
4723
4724fn convert_trade_update_at(
4736 update: AlpacaTradeUpdate<'_>,
4737 (time_exchange, time_source): (DateTime<Utc>, TimeSource),
4738) -> [Option<UnindexedAccountEvent>; 2] {
4739 let event_str = update.event.as_str();
4742 if !matches!(
4743 event_str,
4744 "fill"
4745 | "partial_fill"
4746 | "new"
4747 | "accepted"
4748 | "pending_new"
4749 | "canceled"
4750 | "expired"
4751 | "replaced"
4752 | "done_for_day"
4753 | "rejected"
4754 | "trade_bust"
4755 | "trade_correct"
4756 ) {
4757 trace!(event = %event_str, "Alpaca WS: ignoring trade_updates event type");
4758 return [None, None];
4759 }
4760
4761 let order = &update.order;
4762 let instrument = InstrumentNameExchange::new(&*order.symbol);
4763 let order_id = OrderId(order.id.clone());
4764 let cid = alpaca_cid(order.client_order_id.as_deref(), &order.id);
4765
4766 match event_str {
4767 "fill" | "partial_fill" => {
4768 let (Some(price), Some(quantity), Some(side)) = (
4770 update.price.and_then(|s| Decimal::from_str(s).ok()),
4771 update.qty.and_then(|s| Decimal::from_str(s).ok()),
4772 parse_side(&order.side),
4773 ) else {
4774 return [None, None];
4775 };
4776
4777 let cum_qty = Decimal::from_str(order.filled_qty.unwrap_or("0"))
4782 .inspect_err(|e| {
4783 warn!(
4784 order_id = %order.id,
4785 filled_qty = ?order.filled_qty,
4786 %e,
4787 "Alpaca WS: failed to parse filled_qty — the fill reports a cumulative \
4788 of 0, and without an execution id a second such fill on the same order \
4789 would be deduplicated away"
4790 );
4791 })
4792 .unwrap_or(Decimal::ZERO);
4793 let trade_id = match ws_execution_id(&update) {
4797 Some(execution_id) => TradeId::new(execution_id),
4798 None => {
4799 warn!(
4800 order_id = %order.id,
4801 "Alpaca WS: fill without a usable execution_id — its TradeId is \
4802 \"{{order_id}}:{{filled_qty}}\", which fetch_trades will not match"
4803 );
4804 TradeId(fill_dedup_key(&order.id, cum_qty))
4805 }
4806 };
4807
4808 let trade = Trade::new(
4812 trade_id,
4813 order_id.clone(),
4814 instrument.clone(),
4815 StrategyId::unknown(),
4816 time_exchange,
4817 side,
4818 price,
4819 quantity,
4820 Some(cum_qty),
4822 AssetFees::new(
4823 AssetNameExchange::from(USD),
4824 Decimal::ZERO,
4825 Some(Decimal::ZERO),
4826 ),
4827 )
4828 .with_time_source(time_source);
4829 let trade = with_reported_cid(trade, order.client_order_id.as_ref());
4830 let trade_event = UnindexedAccountEvent::new(
4831 ExchangeId::AlpacaBroker,
4832 AccountEventKind::Trade(trade),
4833 );
4834
4835 let snapshot_event = if ws_fill_order_is_live(&order.status) {
4839 ws_order_snapshot(order, instrument, cid, order_id, time_exchange)
4840 } else {
4841 trace!(
4842 order_id = %order.id,
4843 status = %order.status,
4844 "Alpaca WS: fill for an order the exchange no longer reports as working — \
4845 emitting the execution without an order snapshot"
4846 );
4847 None
4848 };
4849
4850 [Some(trade_event), snapshot_event]
4851 }
4852
4853 "new" | "accepted" | "pending_new" | "done_for_day" => {
4854 [
4859 ws_order_snapshot(order, instrument, cid, order_id, time_exchange),
4860 None,
4861 ]
4862 }
4863
4864 "canceled" | "expired" | "replaced" => {
4865 let filled_qty = alpaca_filled_qty(&order.id, order.filled_qty);
4873 let cancelled = Cancelled::new(order_id, time_exchange, filled_qty);
4874 let response = crate::order::request::OrderResponseCancel {
4875 key: OrderKey::new(
4876 ExchangeId::AlpacaBroker,
4877 instrument,
4878 StrategyId::unknown(),
4879 cid,
4880 ),
4881 state: Ok(cancelled),
4882 };
4883 [
4884 Some(UnindexedAccountEvent::new(
4885 ExchangeId::AlpacaBroker,
4886 AccountEventKind::OrderCancelled(response),
4887 )),
4888 None,
4889 ]
4890 }
4891
4892 "trade_bust" | "trade_correct" => [
4893 Some(trade_amendment_event(
4894 &update,
4895 instrument,
4896 order_id,
4897 (time_exchange, time_source),
4898 )),
4899 None,
4900 ],
4901
4902 "rejected" => {
4903 let response = crate::order::request::OrderResponseCancel {
4904 key: OrderKey::new(
4905 ExchangeId::AlpacaBroker,
4906 instrument,
4907 StrategyId::unknown(),
4908 cid,
4909 ),
4910 state: Err(UnindexedOrderError::Rejected(ApiError::OrderRejected(
4911 format!("order rejected: status={}", order.status),
4912 ))),
4913 };
4914 [
4915 Some(UnindexedAccountEvent::new(
4916 ExchangeId::AlpacaBroker,
4917 AccountEventKind::OrderCancelled(response),
4918 )),
4919 None,
4920 ]
4921 }
4922
4923 _ => unreachable!("convert_trade_update: unrecognised event passed early-return guard"),
4926 }
4927}
4928
4929fn parse_side(s: &str) -> Option<Side> {
4934 match s {
4935 "buy" | "Buy" | "BUY" => Some(Side::Buy),
4936 "sell" | "Sell" | "SELL" => Some(Side::Sell),
4937 other => {
4938 trace!(%other, "Alpaca: unknown order side");
4939 None
4940 }
4941 }
4942}
4943
4944fn parse_order_kind(
4945 order_type: &str,
4946 stop_price: Option<&str>,
4947 trail_percent: Option<&str>,
4948 trail_price: Option<&str>,
4949) -> Option<OrderKind> {
4950 match order_type {
4951 "market" | "Market" => Some(OrderKind::Market),
4952 "limit" | "Limit" => Some(OrderKind::Limit),
4953 "stop" | "Stop" => {
4954 let trigger_price = stop_price.and_then(|s| Decimal::from_str(s).ok())?;
4955 Some(OrderKind::Stop { trigger_price })
4956 }
4957 "stop_limit" | "Stop_limit" => {
4958 let trigger_price = stop_price.and_then(|s| Decimal::from_str(s).ok())?;
4959 Some(OrderKind::StopLimit { trigger_price })
4960 }
4961 "trailing_stop" | "Trailing_stop" => {
4962 if let Some(pct) = trail_percent.and_then(|s| Decimal::from_str(s).ok()) {
4964 Some(OrderKind::TrailingStop {
4965 offset: pct,
4966 offset_type: TrailingOffsetType::Percentage,
4967 activation_price: None,
4968 })
4969 } else if let Some(price) = trail_price.and_then(|s| Decimal::from_str(s).ok()) {
4970 Some(OrderKind::TrailingStop {
4971 offset: price,
4972 offset_type: TrailingOffsetType::Absolute,
4973 activation_price: None,
4974 })
4975 } else {
4976 trace!("Alpaca: trailing_stop missing trail_percent and trail_price");
4977 None
4978 }
4979 }
4980 other => {
4981 trace!(%other, "Alpaca: unsupported order type, skipping");
4982 None
4983 }
4984 }
4985}
4986
4987fn parse_time_in_force(s: &str) -> TimeInForce {
4988 match s {
4989 "gtc" | "GTC" => TimeInForce::GoodUntilCancelled { post_only: false },
4990 "day" | "DAY" => TimeInForce::GoodUntilEndOfDay,
4991 "fok" | "FOK" => TimeInForce::FillOrKill,
4992 "ioc" | "IOC" => TimeInForce::ImmediateOrCancel,
4993 other => {
4994 warn!(%other, "Alpaca: unknown time_in_force, defaulting to GoodUntilEndOfDay");
4995 TimeInForce::GoodUntilEndOfDay
4996 }
4997 }
4998}
4999
5000fn order_state_time(order: &AlpacaOrderResponse) -> DateTime<Utc> {
5010 order
5011 .updated_at
5012 .as_deref()
5013 .and_then(parse_timestamp)
5014 .or_else(|| parse_timestamp(&order.created_at))
5015 .unwrap_or_else(Utc::now)
5016}
5017
5018fn alpaca_filled_qty(order_id: &str, filled_qty: Option<&str>) -> Option<Decimal> {
5021 let Some(raw) = filled_qty else {
5022 warn!(%order_id, "Alpaca did not report how much the order filled");
5023 return None;
5024 };
5025 match Decimal::from_str(raw) {
5026 Ok(filled_qty) => Some(filled_qty),
5027 Err(_) => {
5028 warn!(%order_id, filled_qty = raw, "Alpaca reported an unparseable filled_qty, treating it as unknown");
5029 None
5030 }
5031 }
5032}
5033
5034fn convert_cash_flow(
5037 activity: &AlpacaNonTradeActivity,
5038) -> Option<CashFlow<AssetNameExchange, InstrumentNameExchange>> {
5039 let id = activity.id.as_str();
5040 if activity.status.as_deref() == Some("canceled") {
5041 info!(id, activity_type = %activity.activity_type, "Alpaca activity canceled — left out");
5042 return None;
5043 }
5044 let Some(amount) = activity
5045 .net_amount
5046 .as_deref()
5047 .and_then(|amount| Decimal::from_str(amount).ok())
5048 else {
5049 warn!(id, net_amount = ?activity.net_amount, "Alpaca activity missing/unparseable net_amount — left out");
5050 return None;
5051 };
5052 let Some(time_exchange) = activity.time() else {
5053 warn!(
5054 id,
5055 created_at = ?activity.created_at,
5056 date = ?activity.date,
5057 "Alpaca activity missing/unparseable time — left out"
5058 );
5059 return None;
5060 };
5061 let sub_type = activity
5062 .activity_sub_type
5063 .as_deref()
5064 .filter(|sub_type| !sub_type.is_empty());
5065 let kind = match (activity.activity_type.as_str(), sub_type) {
5066 ("INT", Some("MGN")) => CashFlowKind::MarginInterest {
5067 rate: None,
5068 principal: None,
5069 timing: None,
5070 },
5071 ("INT", None) if amount.is_sign_negative() => CashFlowKind::MarginInterest {
5073 rate: None,
5074 principal: None,
5075 timing: None,
5076 },
5077 ("PTR", _) => CashFlowKind::Rebate,
5078 (activity_type, Some(sub_type)) => {
5079 CashFlowKind::Other(format!("{activity_type}.{sub_type}"))
5080 }
5081 (activity_type, None) => CashFlowKind::Other(activity_type.to_owned()),
5082 };
5083 Some(CashFlow::new(
5084 kind,
5085 AssetNameExchange::new(
5086 activity
5087 .currency
5088 .as_deref()
5089 .filter(|currency| !currency.is_empty())
5090 .unwrap_or("USD"),
5091 ),
5092 amount,
5093 activity
5094 .symbol
5095 .as_deref()
5096 .filter(|symbol| !symbol.is_empty())
5097 .map(InstrumentNameExchange::new),
5098 time_exchange,
5099 Some(CashFlowId::new(id)),
5100 ))
5101}
5102
5103struct OptionLeg<'a> {
5105 id: &'a str,
5106 symbol: &'a str,
5107 quantity: Decimal,
5109 price: Option<Decimal>,
5111 date: Option<NaiveDate>,
5112 time: DateTime<Utc>,
5113 created: bool,
5115 origin: Option<TradeOrigin>,
5117 contract: Option<(&'a str, bool, Decimal)>,
5119}
5120
5121impl OptionLeg<'_> {
5122 fn is_within(&self, start: DateTime<Utc>, end: DateTime<Utc>) -> bool {
5125 if self.created {
5126 (start..=end).contains(&self.time)
5127 } else {
5128 (start.date_naive()..=end.date_naive()).contains(&self.time.date_naive())
5129 }
5130 }
5131}
5132
5133fn option_leg(activity: &AlpacaNonTradeActivity) -> Option<OptionLeg<'_>> {
5136 let id = activity.id.as_str();
5137 let activity_type = activity.activity_type.as_str();
5138 if activity.status.as_deref() == Some("canceled") {
5139 info!(
5140 id,
5141 activity_type, "Alpaca option activity canceled — left out"
5142 );
5143 return None;
5144 }
5145 let origin = match activity_type {
5146 "OPEXP" => Some(TradeOrigin::Expiry),
5147 "OPEXC" => Some(TradeOrigin::Exercise),
5148 "OPASN" => Some(TradeOrigin::Assignment),
5149 "OPTRD" => None,
5150 _ => {
5151 warn!(
5152 id,
5153 activity_type, "Alpaca activity is not an option event — left out"
5154 );
5155 return None;
5156 }
5157 };
5158 let date = activity
5159 .date
5160 .as_deref()
5161 .and_then(|date| NaiveDate::parse_from_str(date, "%Y-%m-%d").ok());
5162 let created_at = activity.created_at.as_deref().and_then(parse_timestamp);
5163 let time = created_at.or_else(|| date.map(|date| date.and_time(NaiveTime::MIN).and_utc()));
5164 let symbol = activity
5165 .symbol
5166 .as_deref()
5167 .filter(|symbol| !symbol.is_empty());
5168 let quantity = activity
5169 .qty
5170 .as_deref()
5171 .and_then(|qty| Decimal::from_str(qty).ok())
5172 .filter(|qty| !qty.is_zero());
5173 let price = activity
5174 .price
5175 .as_deref()
5176 .and_then(|price| Decimal::from_str(price).ok());
5177 let (Some(symbol), Some(quantity), Some(time)) = (symbol, quantity, time) else {
5178 warn!(
5179 id,
5180 activity_type,
5181 symbol = ?activity.symbol,
5182 qty = ?activity.qty,
5183 created_at = ?activity.created_at,
5184 date = ?activity.date,
5185 "Alpaca option activity missing/unparseable symbol, quantity or time — left out"
5186 );
5187 return None;
5188 };
5189 if origin.is_none() && price.is_none() {
5190 warn!(id, price = ?activity.price, "Alpaca OPTRD missing/unparseable price — left out");
5191 return None;
5192 }
5193 let contract = matches!(
5194 origin,
5195 Some(TradeOrigin::Exercise | TradeOrigin::Assignment)
5196 )
5197 .then(|| occ_contract(symbol))
5198 .flatten();
5199 Some(OptionLeg {
5200 id,
5201 symbol,
5202 quantity,
5203 price,
5204 date,
5205 time,
5206 created: created_at.is_some(),
5207 origin,
5208 contract,
5209 })
5210}
5211
5212fn occ_contract(symbol: &str) -> Option<(&str, bool, Decimal)> {
5215 let split = symbol.len().checked_sub(15)?;
5216 let (root, tail) = (
5217 symbol.get(..split)?.trim_end(),
5218 symbol.get(split..)?.as_bytes(),
5219 );
5220 let is_call = match tail[6] {
5221 b'C' => true,
5222 b'P' => false,
5223 _ => return None,
5224 };
5225 if root.is_empty() || !tail[..6].iter().chain(&tail[7..]).all(u8::is_ascii_digit) {
5226 return None;
5227 }
5228 let strike = std::str::from_utf8(&tail[7..]).ok()?.parse::<i64>().ok()?;
5229 Some((root, is_call, Decimal::new(strike, 3)))
5230}
5231
5232fn is_share_leg_of(shares: &OptionLeg<'_>, contract: &OptionLeg<'_>) -> bool {
5235 let Some((root, is_call, strike)) = contract.contract else {
5236 return false;
5237 };
5238 let buys = match contract.origin {
5240 Some(TradeOrigin::Exercise) => is_call,
5241 Some(TradeOrigin::Assignment) => !is_call,
5242 _ => return false,
5243 };
5244 shares.date.is_some()
5245 && shares.date == contract.date
5246 && shares.symbol == root
5247 && shares.price == Some(strike)
5248 && shares.quantity.is_sign_positive() == buys
5249}
5250
5251fn convert_option_events(
5255 activities: &[AlpacaNonTradeActivity],
5256 start: DateTime<Utc>,
5257 end: DateTime<Utc>,
5258) -> Vec<Trade<AssetNameExchange, InstrumentNameExchange>> {
5259 let legs: Vec<_> = activities.iter().filter_map(option_leg).collect();
5260 let mut paired = vec![false; legs.len()];
5261 legs.iter()
5262 .filter_map(|leg| {
5263 let origin = leg.origin.unwrap_or_else(|| {
5266 let pairs = |index: &usize| !paired[*index] && is_share_leg_of(leg, &legs[*index]);
5267 let delivers_its_shares = |index: &usize| {
5271 legs[*index].quantity.abs() * Decimal::ONE_HUNDRED == leg.quantity.abs()
5272 };
5273 let partner = (0..legs.len())
5274 .filter(pairs)
5275 .find(delivers_its_shares)
5276 .or_else(|| (0..legs.len()).find(pairs));
5277 match partner {
5278 Some(index) => {
5279 paired[index] = true;
5280 legs[index].origin.unwrap_or(TradeOrigin::External)
5281 }
5282 None => {
5283 warn!(
5284 id = leg.id,
5285 symbol = leg.symbol,
5286 date = ?leg.date,
5287 "Alpaca OPTRD pairs with no exercise or assignment read — reported as \
5288 External"
5289 );
5290 TradeOrigin::External
5291 }
5292 }
5293 });
5294 if !leg.is_within(start, end) {
5295 return None;
5296 }
5297 let side = if leg.quantity.is_sign_negative() {
5298 Side::Sell
5299 } else {
5300 Side::Buy
5301 };
5302 let trade = Trade::new(
5303 TradeId::new(leg.id),
5304 OrderId::new(leg.id),
5305 InstrumentNameExchange::new(leg.symbol),
5306 StrategyId::unknown(),
5307 leg.time,
5308 side,
5309 leg.price.unwrap_or(Decimal::ZERO),
5310 leg.quantity.abs(),
5311 None,
5312 AssetFees::new(
5313 AssetNameExchange::from(USD),
5314 Decimal::ZERO,
5315 Some(Decimal::ZERO),
5316 ),
5317 )
5318 .with_origin(origin);
5319 Some(trade)
5320 })
5321 .collect()
5322}
5323
5324fn with_reported_cid<AssetKey, InstrumentKey>(
5326 trade: Trade<AssetKey, InstrumentKey>,
5327 client_order_id: Option<&SmolStr>,
5328) -> Trade<AssetKey, InstrumentKey> {
5329 match client_order_id {
5330 Some(cid) if !cid.is_empty() => trade.with_client_order_id(ClientOrderId(cid.clone())),
5332 _ => trade,
5333 }
5334}
5335
5336fn parse_timestamp(s: &str) -> Option<DateTime<Utc>> {
5337 DateTime::parse_from_rfc3339(s)
5338 .ok()
5339 .map(|dt| dt.with_timezone(&Utc))
5340}
5341
5342fn map_side(side: Side) -> &'static str {
5343 match side {
5344 Side::Buy => "buy",
5345 Side::Sell => "sell",
5346 }
5347}
5348
5349fn map_order_kind(kind: OrderKind) -> Option<&'static str> {
5350 match kind {
5351 OrderKind::Market => Some("market"),
5352 OrderKind::Limit => Some("limit"),
5353 OrderKind::Stop { .. } => Some("stop"),
5354 OrderKind::StopLimit { .. } => Some("stop_limit"),
5355 OrderKind::TrailingStop {
5356 activation_price: None,
5357 ..
5358 } => Some("trailing_stop"),
5359 OrderKind::TakeProfit { .. }
5362 | OrderKind::TakeProfitLimit { .. }
5363 | OrderKind::TrailingStop {
5364 activation_price: Some(_),
5365 ..
5366 }
5367 | OrderKind::TrailingStopLimit { .. }
5368 | OrderKind::TrailingStopFixedLimit { .. }
5369 | OrderKind::ConditionalUnresolved { .. } => None,
5370 }
5371}
5372
5373fn map_time_in_force(tif: TimeInForce) -> Result<&'static str, &'static str> {
5382 match tif {
5383 TimeInForce::GoodUntilCancelled { post_only } => {
5384 if post_only {
5385 return Err("Alpaca does not support post_only orders");
5386 }
5387 Ok("gtc")
5388 }
5389 TimeInForce::GoodUntilEndOfDay => Ok("day"),
5390 TimeInForce::FillOrKill => Ok("fok"),
5391 TimeInForce::ImmediateOrCancel => Ok("ioc"),
5392 TimeInForce::AtOpen => Ok("opg"),
5393 TimeInForce::AtClose => Ok("cls"),
5394 TimeInForce::GoodTillDate { .. } => {
5398 Err("Alpaca GoodTillDate is not yet wired through this client")
5399 }
5400 }
5401}
5402
5403fn is_options_or_equity_symbol(symbol: &str) -> bool {
5414 !symbol.contains('/')
5415}
5416
5417fn map_position_intent(side: Side, reduce_only: ReduceOnly) -> AlpacaPositionIntent {
5426 match (reduce_only.is_reduce_only(), side) {
5427 (false, Side::Buy) => AlpacaPositionIntent::BuyToOpen,
5428 (false, Side::Sell) => AlpacaPositionIntent::SellToOpen,
5429 (true, Side::Buy) => AlpacaPositionIntent::BuyToClose,
5430 (true, Side::Sell) => AlpacaPositionIntent::SellToClose,
5431 }
5432}
5433
5434fn parse_api_error(
5445 status: reqwest::StatusCode,
5446 code: Option<i64>,
5447 message: &str,
5448) -> crate::error::UnindexedApiError {
5449 if status == reqwest::StatusCode::TOO_MANY_REQUESTS {
5452 return ApiError::rate_limited(None);
5453 }
5454
5455 let lower = message.to_ascii_lowercase();
5457 match status.as_u16() {
5458 422 if lower.contains("already") => ApiError::OrderAlreadyCancelled,
5462 422 => parse_business_refusal(code, message, &lower),
5463 401 => ApiError::Unauthenticated(format!("unauthorized: {message}")),
5464 403 => match code {
5471 None => ApiError::Unauthenticated(format!("forbidden: {message}")),
5472 Some(_) => parse_business_refusal(code, message, &lower),
5473 },
5474 404 => ApiError::OrderRejected(format!("order not found: {message}")),
5475 _ => ApiError::OrderRejected(with_alpaca_code(code, message)),
5476 }
5477}
5478
5479fn parse_business_refusal(code: Option<i64>, message: &str, lower: &str) -> UnindexedApiError {
5493 let borrow = if lower.contains("not allowed to short") {
5494 Some(BorrowRejectReason::BorrowDisabled)
5495 } else if lower.contains("cannot be sold short") && !lower.contains("fractional") {
5496 Some(BorrowRejectReason::NotShortable)
5497 } else {
5498 None
5499 };
5500 if let Some(reason) = borrow {
5501 ApiError::BorrowRejected(BorrowReject::new(
5502 reason,
5503 code.map(|code| code.to_string()),
5504 message.to_owned(),
5505 ))
5506 } else if lower.contains("insufficient") {
5507 ApiError::BalanceInsufficient(None, with_alpaca_code(code, message))
5508 } else {
5509 ApiError::OrderRejected(with_alpaca_code(code, message))
5510 }
5511}
5512
5513fn with_alpaca_code(code: Option<i64>, message: &str) -> String {
5515 match code {
5516 Some(code) => format!("{code} {message}"),
5517 None => message.to_owned(),
5518 }
5519}
5520
5521fn parse_order_error(
5523 status: reqwest::StatusCode,
5524 code: Option<i64>,
5525 message: &str,
5526) -> UnindexedOrderError {
5527 UnindexedOrderError::Rejected(parse_api_error(status, code, message))
5528}
5529
5530fn connectivity_err(msg: impl Into<String>) -> UnindexedClientError {
5531 UnindexedClientError::Connectivity(ConnectivityError::Socket(msg.into()))
5532}
5533
5534fn order_post_error(error: UnindexedClientError) -> UnindexedOrderError {
5541 match error {
5542 UnindexedClientError::Connectivity(ce) => OrderError::Connectivity(ce),
5543 UnindexedClientError::Api(ae) => OrderError::Rejected(ae),
5544 UnindexedClientError::Internal(msg) => OrderError::Connectivity(ConnectivityError::Socket(
5545 format!("Alpaca order status unknown, its 2xx response could not be read: {msg}"),
5546 )),
5547 other @ (UnindexedClientError::TaskFailed(_)
5551 | UnindexedClientError::Truncated { .. }
5552 | UnindexedClientError::TruncatedSnapshot { .. }) => OrderError::Connectivity(
5553 ConnectivityError::Socket(format!("Alpaca order status unknown: {other}")),
5554 ),
5555 }
5556}
5557
5558impl OrderStatusClient for AlpacaClient {
5563 async fn fetch_ended_orders(
5571 &self,
5572 orders: &[UnindexedOrderKey],
5573 ) -> Result<Vec<UnindexedInactiveOrder>, UnindexedClientError> {
5574 fetch_ended_by_key(orders, |key| {
5575 fetch_order_lookup(
5576 self.http.clone(),
5577 self.rate_limiter.clone(),
5578 self.config.clone(),
5579 key,
5580 )
5581 })
5582 .await
5583 }
5584}
5585
5586impl ShortabilityClient for AlpacaClient {
5591 async fn fetch_shortability(
5610 &self,
5611 instrument: &InstrumentNameExchange,
5612 ) -> Result<Shortability, UnindexedClientError> {
5613 let url = asset_url(self.base_url(), instrument)?;
5614 let found: Option<AlpacaAsset> =
5615 rest_lookup_with_retry(&self.rate_limiter, || self.http.get(url.clone())).await?;
5616 let Some(asset) = found else {
5617 return Err(instrument_invalid(
5618 instrument,
5619 "Alpaca knows no asset under this symbol",
5620 ));
5621 };
5622 shortability_from_asset(instrument, &asset)
5623 }
5624}
5625
5626fn asset_url(
5629 base_url: &str,
5630 instrument: &InstrumentNameExchange,
5631) -> Result<reqwest::Url, UnindexedClientError> {
5632 let mut url = reqwest::Url::parse(base_url).map_err(|error| {
5633 UnindexedClientError::Internal(format!("Alpaca base URL {base_url:?}: {error}"))
5634 })?;
5635 url.path_segments_mut()
5636 .map_err(|()| {
5637 UnindexedClientError::Internal(format!("Alpaca base URL {base_url:?} has no path"))
5638 })?
5639 .pop_if_empty()
5640 .extend(["v2", "assets", instrument.name().as_str()]);
5641 Ok(url)
5642}
5643
5644fn instrument_invalid(instrument: &InstrumentNameExchange, reason: &str) -> UnindexedClientError {
5645 UnindexedClientError::Api(ApiError::InstrumentInvalid(
5646 instrument.clone(),
5647 reason.to_owned(),
5648 ))
5649}
5650
5651fn shortability_from_asset(
5653 instrument: &InstrumentNameExchange,
5654 asset: &AlpacaAsset,
5655) -> Result<Shortability, UnindexedClientError> {
5656 if asset.class == AlpacaAssetClass::UsOption {
5657 return Err(instrument_invalid(
5658 instrument,
5659 "shortability does not apply to an option: writing one borrows nothing",
5660 ));
5661 }
5662 let easy_to_borrow = match asset.borrow_status {
5663 Some(AlpacaBorrowStatus::EasyToBorrow) => Some(true),
5664 Some(AlpacaBorrowStatus::HardToBorrow) => Some(false),
5665 Some(AlpacaBorrowStatus::Other) | None => asset.easy_to_borrow,
5666 };
5667 let mut shortability = Shortability::new();
5668 if let Some(shortable) = asset.shortable {
5669 shortability = shortability.with_shortable(shortable);
5670 }
5671 if let Some(easy_to_borrow) = easy_to_borrow {
5672 shortability = shortability.with_easy_to_borrow(easy_to_borrow);
5673 if easy_to_borrow {
5674 shortability = shortability.with_fee_rate(Decimal::ZERO);
5675 }
5676 }
5677 Ok(shortability)
5678}
5679
5680#[derive(Debug, Deserialize)]
5683struct AlpacaAsset {
5684 #[serde(default)]
5685 class: AlpacaAssetClass,
5686 #[serde(default)]
5687 shortable: Option<bool>,
5688 #[serde(default)]
5690 borrow_status: Option<AlpacaBorrowStatus>,
5691 #[serde(default)]
5693 easy_to_borrow: Option<bool>,
5694}
5695
5696#[derive(Debug, Default, PartialEq, Eq, Deserialize)]
5697#[serde(rename_all = "snake_case")]
5698enum AlpacaAssetClass {
5699 UsOption,
5700 #[default]
5701 #[serde(other)]
5702 Other,
5703}
5704
5705#[derive(Debug, PartialEq, Eq, Deserialize)]
5706#[serde(rename_all = "snake_case")]
5707enum AlpacaBorrowStatus {
5708 EasyToBorrow,
5709 HardToBorrow,
5710 #[serde(other)]
5711 Other,
5712}
5713
5714impl BracketOrderClient for AlpacaClient {
5719 async fn open_bracket_order(
5720 &self,
5721 request: UnifiedBracketOrderRequest<ExchangeId, &InstrumentNameExchange>,
5722 ) -> UnifiedBracketOrderResult {
5723 let alpaca_request = AlpacaBracketOrderRequest {
5724 instrument: request.key.instrument.clone(),
5725 strategy: request.key.strategy.clone(),
5726 cid: request.key.cid.clone(),
5727 side: request.state.side,
5728 quantity: request.state.quantity,
5729 entry_price: request.state.entry_price,
5730 take_profit_price: request.state.take_profit_price,
5731 stop_loss_price: request.state.stop_loss_price,
5732 stop_loss_limit_price: request.state.stop_loss_limit_price,
5733 time_in_force: request.state.time_in_force,
5734 };
5735
5736 let result = self.open_bracket_order(alpaca_request).await;
5737
5738 UnifiedBracketOrderResult::parent_only(result.parent)
5739 }
5740}
5741
5742#[cfg(test)]
5747#[allow(clippy::unwrap_used, clippy::expect_used)] mod tests {
5749 use super::*;
5750 use rust_decimal_macros::dec;
5751
5752 #[test]
5753 fn test_alpaca_config_new_uses_paper_trading_by_default() {
5754 let cfg = AlpacaConfig::new("my_key".into(), "my_secret".into());
5755 assert!(cfg.paper);
5756 }
5757
5758 #[test]
5759 fn test_alpaca_config_debug_redacts_credentials() {
5760 let cfg = AlpacaConfig::new("my_key".into(), "my_secret".into());
5761 let debug = format!("{cfg:?}");
5762 assert!(!debug.contains("my_key"), "api_key should be redacted");
5763 assert!(
5764 !debug.contains("my_secret"),
5765 "secret_key should be redacted"
5766 );
5767 assert!(debug.contains("paper: true"));
5768 }
5769
5770 #[test]
5771 fn test_alpaca_config_deserialize_omitted_paper_defaults_to_paper() {
5772 let cfg: AlpacaConfig = serde_json::from_str(
5773 r#"{
5774 "api_key": "my_key",
5775 "secret_key": "my_secret"
5776 }"#,
5777 )
5778 .unwrap();
5779
5780 assert!(cfg.paper);
5781 assert_eq!(cfg.rest_base_url(), "https://paper-api.alpaca.markets");
5782 }
5783
5784 #[test]
5785 fn test_alpaca_config_deserialize_paper_true() {
5786 let cfg: AlpacaConfig = serde_json::from_str(
5787 r#"{
5788 "api_key": "my_key",
5789 "secret_key": "my_secret",
5790 "paper": true
5791 }"#,
5792 )
5793 .unwrap();
5794
5795 assert!(cfg.paper);
5796 assert_eq!(cfg.rest_base_url(), "https://paper-api.alpaca.markets");
5797 }
5798
5799 #[test]
5800 fn test_alpaca_config_deserialize_paper_false() {
5801 let cfg: AlpacaConfig = serde_json::from_str(
5802 r#"{
5803 "api_key": "my_key",
5804 "secret_key": "my_secret",
5805 "paper": false
5806 }"#,
5807 )
5808 .unwrap();
5809
5810 assert!(!cfg.paper);
5811 assert_eq!(cfg.rest_base_url(), "https://api.alpaca.markets");
5812 }
5813
5814 #[test]
5815 fn test_alpaca_config_deserialize_rejects_test_url_overrides() {
5816 for key in ["base_url_override", "ws_url_override"] {
5817 let json = format!(
5818 r#"{{"api_key": "my_key", "secret_key": "my_secret", "{key}": "http://127.0.0.1:1"}}"#
5819 );
5820 let Err(err) = serde_json::from_str::<AlpacaConfig>(&json) else {
5821 panic!("a config with `{key}` must not deserialize");
5822 };
5823 assert!(
5824 err.to_string().contains(&format!("unknown field `{key}`")),
5825 "unexpected error for `{key}`: {err}"
5826 );
5827 }
5828 }
5829
5830 #[test]
5831 fn test_alpaca_config_urls() {
5832 let paper = AlpacaConfig::paper("k".into(), "s".into());
5833 assert!(paper.rest_base_url().contains("paper-api"));
5834 assert!(paper.ws_url().contains("paper-api"));
5835
5836 let live = AlpacaConfig::production("k".into(), "s".into());
5837 assert!(!live.rest_base_url().contains("paper-api"));
5838 assert!(!live.ws_url().contains("paper-api"));
5839 }
5840
5841 #[test]
5842 #[serial_test::serial]
5843 fn test_alpaca_config_from_env_defaults_to_paper_trading() {
5844 temp_env::with_vars(
5845 [
5846 ("ALPACA_API_KEY", Some("my_key")),
5847 ("ALPACA_SECRET_KEY", Some("my_secret")),
5848 ("ALPACA_PAPER", None),
5849 ],
5850 || {
5851 let cfg = AlpacaConfig::from_env().unwrap();
5852 assert!(cfg.paper);
5853 assert_eq!(cfg.rest_base_url(), "https://paper-api.alpaca.markets");
5854 },
5855 );
5856 }
5857
5858 #[test]
5859 #[serial_test::serial]
5860 fn test_alpaca_config_from_env_accepts_explicit_production() {
5861 temp_env::with_vars(
5862 [
5863 ("ALPACA_API_KEY", Some("my_key")),
5864 ("ALPACA_SECRET_KEY", Some("my_secret")),
5865 ("ALPACA_PAPER", Some("false")),
5866 ],
5867 || {
5868 let cfg = AlpacaConfig::from_env().unwrap();
5869 assert!(!cfg.paper);
5870 assert_eq!(cfg.rest_base_url(), "https://api.alpaca.markets");
5871 },
5872 );
5873 }
5874
5875 #[test]
5876 #[serial_test::serial]
5877 fn test_alpaca_config_from_env_rejects_invalid_paper() {
5878 temp_env::with_vars(
5879 [
5880 ("ALPACA_API_KEY", Some("my_key")),
5881 ("ALPACA_SECRET_KEY", Some("my_secret")),
5882 ("ALPACA_PAPER", Some("maybe")),
5883 ],
5884 || {
5885 let err = AlpacaConfig::from_env().unwrap_err();
5886 assert!(matches!(err, AlpacaConfigError::InvalidPaper(value) if value == "maybe"));
5887 },
5888 );
5889 }
5890
5891 #[test]
5892 #[serial_test::serial]
5893 fn test_alpaca_config_from_env_requires_credentials() {
5894 temp_env::with_vars(
5895 [
5896 ("ALPACA_API_KEY", None),
5897 ("ALPACA_SECRET_KEY", Some("my_secret")),
5898 ("ALPACA_PAPER", None),
5899 ],
5900 || {
5901 let err = AlpacaConfig::from_env().unwrap_err();
5902 assert!(matches!(err, AlpacaConfigError::MissingApiKey));
5903 },
5904 );
5905 }
5906
5907 #[test]
5908 fn test_parse_side() {
5909 assert_eq!(parse_side("buy"), Some(Side::Buy));
5910 assert_eq!(parse_side("sell"), Some(Side::Sell));
5911 assert_eq!(parse_side("Buy"), Some(Side::Buy));
5912 assert_eq!(parse_side("BUY"), Some(Side::Buy));
5913 assert_eq!(parse_side("unknown"), None);
5914 }
5915
5916 #[test]
5917 fn test_parse_order_kind() {
5918 assert_eq!(
5920 parse_order_kind("market", None, None, None),
5921 Some(OrderKind::Market)
5922 );
5923 assert_eq!(
5924 parse_order_kind("limit", None, None, None),
5925 Some(OrderKind::Limit)
5926 );
5927
5928 assert_eq!(
5930 parse_order_kind("stop", Some("150.00"), None, None),
5931 Some(OrderKind::Stop {
5932 trigger_price: Decimal::from_str("150.00").unwrap()
5933 })
5934 );
5935 assert_eq!(parse_order_kind("stop", None, None, None), None);
5936
5937 assert_eq!(
5939 parse_order_kind("stop_limit", Some("145.00"), None, None),
5940 Some(OrderKind::StopLimit {
5941 trigger_price: Decimal::from_str("145.00").unwrap()
5942 })
5943 );
5944
5945 assert_eq!(
5947 parse_order_kind("trailing_stop", None, Some("5.0"), None),
5948 Some(OrderKind::TrailingStop {
5949 offset: Decimal::from_str("5.0").unwrap(),
5950 offset_type: TrailingOffsetType::Percentage,
5951 activation_price: None,
5952 })
5953 );
5954
5955 assert_eq!(
5957 parse_order_kind("trailing_stop", None, None, Some("2.50")),
5958 Some(OrderKind::TrailingStop {
5959 offset: Decimal::from_str("2.50").unwrap(),
5960 offset_type: TrailingOffsetType::Absolute,
5961 activation_price: None,
5962 })
5963 );
5964
5965 assert_eq!(parse_order_kind("trailing_stop", None, None, None), None);
5967
5968 assert_eq!(parse_order_kind("unknown", None, None, None), None);
5970 }
5971
5972 #[test]
5973 fn test_map_order_kind() {
5974 assert_eq!(map_order_kind(OrderKind::Market), Some("market"));
5975 assert_eq!(map_order_kind(OrderKind::Limit), Some("limit"));
5976 assert_eq!(
5977 map_order_kind(OrderKind::Stop {
5978 trigger_price: Decimal::from_str("150.00").unwrap()
5979 }),
5980 Some("stop")
5981 );
5982 assert_eq!(
5983 map_order_kind(OrderKind::StopLimit {
5984 trigger_price: Decimal::from_str("145.00").unwrap()
5985 }),
5986 Some("stop_limit")
5987 );
5988 assert_eq!(
5989 map_order_kind(OrderKind::TrailingStop {
5990 offset: Decimal::from_str("5.0").unwrap(),
5991 offset_type: TrailingOffsetType::Percentage,
5992 activation_price: None,
5993 }),
5994 Some("trailing_stop")
5995 );
5996 assert_eq!(
5997 map_order_kind(OrderKind::TrailingStop {
5998 offset: Decimal::from_str("2.50").unwrap(),
5999 offset_type: TrailingOffsetType::Absolute,
6000 activation_price: None,
6001 }),
6002 Some("trailing_stop")
6003 );
6004 assert_eq!(
6006 map_order_kind(OrderKind::TrailingStop {
6007 offset: Decimal::from_str("5.0").unwrap(),
6008 offset_type: TrailingOffsetType::Percentage,
6009 activation_price: Some(Decimal::from(100)),
6010 }),
6011 None
6012 );
6013 assert_eq!(
6014 map_order_kind(OrderKind::TrailingStopFixedLimit {
6015 offset: Decimal::from_str("5.0").unwrap(),
6016 offset_type: TrailingOffsetType::Percentage,
6017 activation_price: None,
6018 }),
6019 None
6020 );
6021 assert_eq!(
6023 map_order_kind(OrderKind::TrailingStopLimit {
6024 offset: Decimal::from_str("5.0").unwrap(),
6025 offset_type: TrailingOffsetType::Percentage,
6026 stop_price: Decimal::from_str("95.0").unwrap(),
6027 limit_offset: Decimal::from_str("1.0").unwrap(),
6028 }),
6029 None
6030 );
6031 assert_eq!(
6033 map_order_kind(OrderKind::ConditionalUnresolved {
6034 family: crate::order::ConditionalFamily::Stop,
6035 trigger_price: None,
6036 }),
6037 None
6038 );
6039 assert_eq!(
6041 map_order_kind(OrderKind::TakeProfit {
6042 trigger_price: Decimal::from_str("160.00").unwrap()
6043 }),
6044 None
6045 );
6046 assert_eq!(
6047 map_order_kind(OrderKind::TakeProfitLimit {
6048 trigger_price: Decimal::from_str("160.00").unwrap()
6049 }),
6050 None
6051 );
6052 }
6053
6054 #[test]
6055 fn test_map_time_in_force_roundtrip() {
6056 assert_eq!(
6057 map_time_in_force(TimeInForce::GoodUntilCancelled { post_only: false }),
6058 Ok("gtc")
6059 );
6060 assert_eq!(map_time_in_force(TimeInForce::GoodUntilEndOfDay), Ok("day"));
6061 assert_eq!(map_time_in_force(TimeInForce::FillOrKill), Ok("fok"));
6062 assert_eq!(map_time_in_force(TimeInForce::ImmediateOrCancel), Ok("ioc"));
6063 }
6064
6065 #[test]
6066 fn test_map_time_in_force_rejects_post_only() {
6067 let result = map_time_in_force(TimeInForce::GoodUntilCancelled { post_only: true });
6068 assert!(result.is_err(), "post_only must be rejected");
6069 assert!(result.unwrap_err().contains("post_only"));
6070 }
6071
6072 #[test]
6077 fn test_bracket_order_serializes_with_stop_loss_stop_order() {
6078 let body = AlpacaOrderRequest {
6080 symbol: "AAPL",
6081 qty: "10".to_string(),
6082 side: "buy",
6083 order_type: "limit",
6084 time_in_force: "gtc",
6085 limit_price: Some("150.00".to_string()),
6086 stop_price: None,
6087 trail_percent: None,
6088 trail_price: None,
6089 client_order_id: Some("bracket-001"),
6090 position_intent: Some(AlpacaPositionIntent::BuyToOpen),
6091 order_class: Some("bracket"),
6092 take_profit: Some(TakeProfitParams {
6093 limit_price: "160.00".to_string(),
6094 }),
6095 stop_loss: Some(StopLossParams {
6096 stop_price: "145.00".to_string(),
6097 limit_price: None,
6098 }),
6099 };
6100
6101 let json = serde_json::to_value(&body).unwrap();
6102
6103 assert_eq!(json["symbol"], "AAPL");
6104 assert_eq!(json["qty"], "10");
6105 assert_eq!(json["side"], "buy");
6106 assert_eq!(json["type"], "limit");
6107 assert_eq!(json["time_in_force"], "gtc");
6108 assert_eq!(json["limit_price"], "150.00");
6109 assert_eq!(json["order_class"], "bracket");
6110
6111 assert_eq!(json["take_profit"]["limit_price"], "160.00");
6113
6114 assert_eq!(json["stop_loss"]["stop_price"], "145.00");
6116 assert!(
6117 json["stop_loss"].get("limit_price").is_none(),
6118 "stop_loss.limit_price should be omitted when None"
6119 );
6120 }
6121
6122 #[test]
6123 fn test_bracket_order_serializes_with_stop_loss_stop_limit_order() {
6124 let body = AlpacaOrderRequest {
6126 symbol: "SPY",
6127 qty: "5".to_string(),
6128 side: "sell",
6129 order_type: "limit",
6130 time_in_force: "day",
6131 limit_price: Some("450.00".to_string()),
6132 stop_price: None,
6133 trail_percent: None,
6134 trail_price: None,
6135 client_order_id: Some("bracket-002"),
6136 position_intent: Some(AlpacaPositionIntent::SellToClose),
6137 order_class: Some("bracket"),
6138 take_profit: Some(TakeProfitParams {
6139 limit_price: "440.00".to_string(),
6140 }),
6141 stop_loss: Some(StopLossParams {
6142 stop_price: "455.00".to_string(),
6143 limit_price: Some("456.00".to_string()),
6144 }),
6145 };
6146
6147 let json = serde_json::to_value(&body).unwrap();
6148
6149 assert_eq!(json["symbol"], "SPY");
6150 assert_eq!(json["side"], "sell");
6151 assert_eq!(json["time_in_force"], "day");
6152 assert_eq!(json["order_class"], "bracket");
6153
6154 assert_eq!(json["take_profit"]["limit_price"], "440.00");
6156
6157 assert_eq!(json["stop_loss"]["stop_price"], "455.00");
6159 assert_eq!(
6160 json["stop_loss"]["limit_price"], "456.00",
6161 "stop_loss.limit_price should be present for stop-limit orders"
6162 );
6163 }
6164
6165 #[tokio::test]
6170 async fn test_open_bracket_order_rejects_invalid_tif() {
6171 use rust_decimal_macros::dec;
6172 use rustrade_instrument::instrument::name::InstrumentNameExchange;
6173
6174 let config = AlpacaConfig::new("dummy_key".into(), "dummy_secret".into());
6176 let client = AlpacaClient::new(config);
6177
6178 let request = AlpacaBracketOrderRequest::new(
6179 InstrumentNameExchange::new("SPY"),
6180 crate::order::id::StrategyId::new("test"),
6181 crate::order::id::ClientOrderId::new("test-tif"),
6182 Side::Buy,
6183 dec!(1),
6184 dec!(100.00),
6185 dec!(120.00),
6186 dec!(90.00),
6187 TimeInForce::ImmediateOrCancel, );
6189
6190 let result = client.open_bracket_order(request).await;
6191
6192 assert!(
6193 result.parent.state.is_failed(),
6194 "Bracket order with IOC TIF should be rejected locally"
6195 );
6196 }
6197
6198 #[tokio::test]
6199 async fn test_open_bracket_order_rejects_invalid_price_ordering() {
6200 use rust_decimal_macros::dec;
6201 use rustrade_instrument::instrument::name::InstrumentNameExchange;
6202
6203 let config = AlpacaConfig::new("dummy_key".into(), "dummy_secret".into());
6204 let client = AlpacaClient::new(config);
6205
6206 let request = AlpacaBracketOrderRequest::new(
6208 InstrumentNameExchange::new("SPY"),
6209 crate::order::id::StrategyId::new("test"),
6210 crate::order::id::ClientOrderId::new("test-price"),
6211 Side::Buy,
6212 dec!(1),
6213 dec!(100.00),
6214 dec!(120.00),
6215 dec!(105.00), TimeInForce::GoodUntilCancelled { post_only: false },
6217 );
6218
6219 let result = client.open_bracket_order(request).await;
6220
6221 assert!(
6222 result.parent.state.is_failed(),
6223 "Bracket order with invalid price ordering should be rejected locally"
6224 );
6225 }
6226
6227 #[tokio::test]
6228 async fn test_open_bracket_order_rejects_invalid_sl_limit_price() {
6229 use rust_decimal_macros::dec;
6230 use rustrade_instrument::instrument::name::InstrumentNameExchange;
6231
6232 let config = AlpacaConfig::new("dummy_key".into(), "dummy_secret".into());
6233 let client = AlpacaClient::new(config);
6234
6235 let request = AlpacaBracketOrderRequest::new(
6237 InstrumentNameExchange::new("SPY"),
6238 crate::order::id::StrategyId::new("test"),
6239 crate::order::id::ClientOrderId::new("test-sl-limit"),
6240 Side::Buy,
6241 dec!(1),
6242 dec!(100.00),
6243 dec!(120.00),
6244 dec!(90.00),
6245 TimeInForce::GoodUntilCancelled { post_only: false },
6246 )
6247 .with_stop_loss_limit_price(dec!(95.00)); let result = client.open_bracket_order(request).await;
6250
6251 assert!(
6252 result.parent.state.is_failed(),
6253 "Bracket order with invalid SL limit price should be rejected locally"
6254 );
6255 }
6256
6257 #[test]
6258 fn test_non_bracket_order_omits_bracket_fields() {
6259 let body = AlpacaOrderRequest {
6261 symbol: "AAPL",
6262 qty: "1".to_string(),
6263 side: "buy",
6264 order_type: "limit",
6265 time_in_force: "gtc",
6266 limit_price: Some("150.00".to_string()),
6267 stop_price: None,
6268 trail_percent: None,
6269 trail_price: None,
6270 client_order_id: Some("regular-001"),
6271 position_intent: Some(AlpacaPositionIntent::BuyToOpen),
6272 order_class: None,
6273 take_profit: None,
6274 stop_loss: None,
6275 };
6276
6277 let json = serde_json::to_value(&body).unwrap();
6278
6279 assert_eq!(json["symbol"], "AAPL");
6280 assert!(
6281 json.get("order_class").is_none(),
6282 "order_class should be omitted for non-bracket orders"
6283 );
6284 assert!(
6285 json.get("take_profit").is_none(),
6286 "take_profit should be omitted for non-bracket orders"
6287 );
6288 assert!(
6289 json.get("stop_loss").is_none(),
6290 "stop_loss should be omitted for non-bracket orders"
6291 );
6292 }
6293
6294 #[test]
6295 fn test_parse_timestamp_valid() {
6296 let ts = parse_timestamp("2025-04-18T14:30:00Z");
6297 assert!(ts.is_some());
6298 let ts2 = parse_timestamp("2025-04-18T14:30:00.123456Z");
6299 assert!(ts2.is_some());
6300 assert_eq!(parse_timestamp("not-a-timestamp"), None);
6301 }
6302
6303 #[test]
6304 fn test_check_auth_response_authorized() {
6305 let msg =
6306 r#"{"stream":"authorization","data":{"status":"authorized","action":"authenticate"}}"#;
6307 assert!(matches!(check_auth_response(msg), Some(Ok(()))));
6308 }
6309
6310 #[test]
6311 fn test_check_auth_response_unauthorized() {
6312 let msg = r#"{"stream":"authorization","data":{"status":"unauthorized"}}"#;
6313 assert!(matches!(
6314 check_auth_response(msg),
6315 Some(Err(HandshakeError::Auth(_)))
6316 ));
6317 }
6318
6319 #[test]
6320 fn test_check_auth_response_non_auth_message() {
6321 let msg = r#"{"stream":"trade_updates","data":{}}"#;
6322 assert!(check_auth_response(msg).is_none());
6323 }
6324
6325 #[test]
6326 fn test_check_listen_ack() {
6327 let ack = r#"{"stream":"listening","data":{"streams":["trade_updates"]}}"#;
6328 assert!(check_listen_ack(ack));
6329
6330 let other = r#"{"stream":"authorization","data":{}}"#;
6331 assert!(!check_listen_ack(other));
6332 }
6333
6334 #[test]
6335 fn test_dedup_cache() {
6336 let cache = new_dedup_cache();
6337 let key = || SmolStr::new("order-1:1");
6338 assert!(
6339 !is_duplicate_fill(&cache, Some("exec-1"), key()),
6340 "first time should not be duplicate"
6341 );
6342 assert!(
6343 is_duplicate_fill(&cache, Some("exec-1"), key()),
6344 "second time should be duplicate"
6345 );
6346 }
6347
6348 #[test]
6351 fn dedup_tells_fills_apart_by_execution_id_not_cumulative() {
6352 let cache = new_dedup_cache();
6353 assert!(!is_duplicate_fill(
6354 &cache,
6355 Some("exec-a"),
6356 SmolStr::new("ord-1:5")
6357 ));
6358 assert!(
6360 !is_duplicate_fill(&cache, Some("exec-b"), SmolStr::new("ord-1:5")),
6361 "a different execution at a cumulative seen before is a new fill"
6362 );
6363 }
6364
6365 #[test]
6368 fn dedup_matches_a_fill_with_an_execution_id_to_the_same_fill_without_one() {
6369 let cache = new_dedup_cache();
6370
6371 assert!(!is_duplicate_fill(&cache, None, SmolStr::new("ord-1:2")));
6372 assert!(
6373 is_duplicate_fill(&cache, Some("exec-a"), SmolStr::new("ord-1:2")),
6374 "without an id first, then with one"
6375 );
6376
6377 assert!(!is_duplicate_fill(
6378 &cache,
6379 Some("exec-b"),
6380 SmolStr::new("ord-1:4")
6381 ));
6382 assert!(
6383 is_duplicate_fill(&cache, None, SmolStr::new("ord-1:4")),
6384 "with an id first, then without one"
6385 );
6386 }
6387
6388 #[test]
6391 fn the_heartbeat_window_is_as_documented() {
6392 assert_eq!(HEARTBEAT_TIMEOUT_SECS, 35);
6393 }
6394
6395 #[test]
6396 fn test_convert_account_to_balances_empty_assets() {
6397 let account: AlpacaAccount = serde_json::from_value(serde_json::json!({
6400 "cash": "9000.50",
6401 "equity": "12000.00",
6402 "buying_power": "36000.00",
6403 "regt_buying_power": "18000.00",
6404 "options_buying_power": "8000.00",
6405 "non_marginable_buying_power": "8500.25",
6406 }))
6407 .unwrap();
6408 let balances = convert_account_to_balances(&account, &[], Utc::now());
6409 assert_eq!(balances.len(), 1);
6410 assert_eq!(balances[0].asset, AssetNameExchange::new("USD"));
6411 assert_eq!(balances[0].balance.total, dec!(9000.50), "total is cash");
6412 assert_eq!(
6413 balances[0].balance.free,
6414 dec!(8500.25),
6415 "free is non-marginable buying power when below cash"
6416 );
6417 assert_eq!(balances[0].balance.margin, None);
6418 }
6419
6420 #[test]
6423 fn test_convert_account_to_balances_free_capped_at_cash_with_long_stock() {
6424 let account: AlpacaAccount = serde_json::from_value(serde_json::json!({
6425 "cash": "50000",
6426 "equity": "100000",
6427 "initial_margin": "25000",
6428 "non_marginable_buying_power": "75000",
6429 }))
6430 .unwrap();
6431 let balances = convert_account_to_balances(&account, &[], Utc::now());
6432 assert_eq!(balances[0].balance.total, dec!(50000));
6433 assert_eq!(balances[0].balance.free, dec!(50000));
6434 }
6435
6436 #[test]
6439 fn test_convert_account_to_balances_open_short_keeps_free_below_cash() {
6440 let account: AlpacaAccount = serde_json::from_value(serde_json::json!({
6441 "cash": "10200",
6442 "equity": "10000",
6443 "initial_margin": "100",
6444 "non_marginable_buying_power": "9900",
6445 }))
6446 .unwrap();
6447 let balances = convert_account_to_balances(&account, &[], Utc::now());
6448 assert_eq!(balances[0].balance.total, dec!(10200));
6449 assert_eq!(balances[0].balance.free, dec!(9900));
6450 }
6451
6452 #[test]
6453 fn test_convert_account_to_balances_negative_cash_on_margin() {
6454 let account: AlpacaAccount = serde_json::from_value(serde_json::json!({
6455 "cash": "-2500.75",
6456 "equity": "7500.00",
6457 "buying_power": "5000.00",
6458 "non_marginable_buying_power": "1250.00",
6459 }))
6460 .unwrap();
6461 let balances = convert_account_to_balances(&account, &[], Utc::now());
6462 assert_eq!(balances.len(), 1);
6463 assert_eq!(balances[0].balance.total, dec!(-2500.75));
6464 assert_eq!(
6465 balances[0].balance.free,
6466 dec!(-2500.75),
6467 "no free cash while borrowing; free <= total still holds"
6468 );
6469 }
6470
6471 #[test]
6472 fn test_alpaca_account_malformed_cash_fails_to_decode() {
6473 let result = serde_json::from_value::<AlpacaAccount>(serde_json::json!({
6474 "cash": "not-a-number",
6475 "non_marginable_buying_power": "0",
6476 }));
6477 assert!(result.is_err(), "a malformed cash must not read as zero");
6478 }
6479
6480 #[test]
6481 fn test_convert_account_to_balances_usd_filter() {
6482 let account = AlpacaAccount {
6483 cash: dec!(12000.00),
6484 non_marginable_buying_power: dec!(10000.00),
6485 };
6486 let usd = vec![AssetNameExchange::new("USD")];
6487 let balances = convert_account_to_balances(&account, &usd, Utc::now());
6488 assert_eq!(balances.len(), 1);
6489 assert_eq!(balances[0].asset, AssetNameExchange::new("USD"));
6490
6491 let non_usd = vec![AssetNameExchange::new("BTC")];
6492 let balances = convert_account_to_balances(&account, &non_usd, Utc::now());
6493 assert!(balances.is_empty());
6494 }
6495
6496 #[test]
6499 fn a_usd_balance_is_named_usd_however_the_filter_spells_it() {
6500 let account = AlpacaAccount {
6501 cash: dec!(12000.00),
6502 non_marginable_buying_power: dec!(10000.00),
6503 };
6504 let balances =
6505 convert_account_to_balances(&account, &[AssetNameExchange::new("usd")], Utc::now());
6506 assert_eq!(balances.len(), 1);
6507 assert_eq!(balances[0].asset, AssetNameExchange::new("USD"));
6508 }
6509
6510 #[test]
6511 fn test_is_options_or_equity_symbol() {
6512 assert!(!is_options_or_equity_symbol("BTC/USD"));
6514 assert!(!is_options_or_equity_symbol("ETH/USD"));
6515 assert!(!is_options_or_equity_symbol("SOL/USD"));
6516
6517 assert!(is_options_or_equity_symbol("AAPL"));
6519 assert!(is_options_or_equity_symbol("SPY"));
6520 assert!(is_options_or_equity_symbol("MSFT"));
6521
6522 assert!(is_options_or_equity_symbol("SPY250418C00450000"));
6524 assert!(is_options_or_equity_symbol("AAPL250418P00145000"));
6525 }
6526
6527 #[test]
6528 fn test_parse_order_error_already_cancelled() {
6529 assert!(matches!(
6532 parse_order_error(
6533 reqwest::StatusCode::UNPROCESSABLE_ENTITY,
6534 None,
6535 "order is already cancelled"
6536 ),
6537 UnindexedOrderError::Rejected(ApiError::OrderAlreadyCancelled)
6538 ));
6539 }
6540
6541 #[test]
6542 fn test_parse_order_error_already_wins_over_insufficient_on_422() {
6543 assert!(matches!(
6546 parse_order_error(
6547 reqwest::StatusCode::UNPROCESSABLE_ENTITY,
6548 None,
6549 "order already cancelled due to insufficient margin"
6550 ),
6551 UnindexedOrderError::Rejected(ApiError::OrderAlreadyCancelled)
6552 ));
6553 }
6554
6555 fn make_order_ws<'a>(
6556 id: &str,
6557 symbol: &str,
6558 side: &str,
6559 filled_qty: &'a str,
6560 ) -> AlpacaOrderWs<'a> {
6561 AlpacaOrderWs {
6562 id: SmolStr::new(id),
6563 client_order_id: None,
6564 symbol: SmolStr::new(symbol),
6565 qty: Some("2"),
6566 filled_qty: Some(filled_qty),
6567 side: SmolStr::new(side),
6568 order_type: SmolStr::new("limit"),
6569 time_in_force: SmolStr::new("day"),
6570 limit_price: Some("100.00"),
6571 stop_price: None,
6572 trail_percent: None,
6573 trail_price: None,
6574 status: SmolStr::new("partially_filled"),
6575 updated_at: None,
6576 }
6577 }
6578
6579 fn sole_event(events: [Option<UnindexedAccountEvent>; 2]) -> Option<UnindexedAccountEvent> {
6584 let [first, second] = events;
6585 assert!(
6586 second.is_none(),
6587 "expected a single event, got a second: {second:?}"
6588 );
6589 first
6590 }
6591
6592 fn fill_events(
6594 events: [Option<UnindexedAccountEvent>; 2],
6595 ) -> (
6596 Trade<AssetNameExchange, InstrumentNameExchange>,
6597 crate::order::Order<
6598 ExchangeId,
6599 InstrumentNameExchange,
6600 OrderState<AssetNameExchange, InstrumentNameExchange>,
6601 >,
6602 ) {
6603 let [first, second] = events;
6604 let first = first.expect("a fill frame must produce an execution");
6605 let second = second.expect("a fill frame must produce an order snapshot");
6606 let AccountEventKind::Trade(trade) = first.kind else {
6607 panic!("first event must be the Trade, got {:?}", first.kind);
6608 };
6609 let AccountEventKind::OrderSnapshot(rustrade_integration::collection::snapshot::Snapshot(
6610 order,
6611 )) = second.kind
6612 else {
6613 panic!(
6614 "second event must be the OrderSnapshot, got {:?}",
6615 second.kind
6616 );
6617 };
6618 (trade, order)
6619 }
6620
6621 #[test]
6624 fn test_convert_trade_update_fill_carries_the_client_order_id() {
6625 let mut order = make_order_ws("ord-1", "SPY", "buy", "1");
6626 order.client_order_id = Some(SmolStr::new("outside-1"));
6627 let update = AlpacaTradeUpdate {
6628 event: SmolStr::new("fill"),
6629 execution_id: Some("exec-1"),
6630 previous_execution_id: None,
6631 order,
6632 price: Some("150.00"),
6633 qty: Some("1"),
6634 timestamp: Some("2025-04-18T14:30:00Z"),
6635 };
6636 let (trade, _order) = fill_events(convert_trade_update(update));
6637 assert_eq!(trade.client_order_id, Some(ClientOrderId::new("outside-1")));
6638 }
6639
6640 #[test]
6643 fn a_fills_time_is_its_timestamp_then_its_orders_update_time_then_this_hosts() {
6644 let fill = |timestamp, updated_at| {
6645 let mut order = make_order_ws("ord-1", "SPY", "buy", "1");
6646 order.updated_at = updated_at;
6647 let update = AlpacaTradeUpdate {
6648 event: SmolStr::new("fill"),
6649 execution_id: Some("exec-1"),
6650 previous_execution_id: None,
6651 order,
6652 price: Some("150.00"),
6653 qty: Some("1"),
6654 timestamp,
6655 };
6656 let (trade, order) = fill_events(convert_trade_update(update));
6657 let OrderState::Active(crate::order::state::ActiveOrderState::Open(open)) = order.state
6658 else {
6659 panic!("expected an Open snapshot, got {:?}", order.state);
6660 };
6661 assert_eq!(open.time_exchange, trade.time_exchange);
6662 (trade.time_exchange, trade.time_source)
6663 };
6664 let at = |time| parse_timestamp(time).unwrap();
6665
6666 assert_eq!(
6667 fill(Some("2025-04-18T14:30:00Z"), Some("2025-04-18T14:30:01Z")),
6668 (at("2025-04-18T14:30:00Z"), TimeSource::Venue)
6669 );
6670 for timestamp in [None, Some("not a time")] {
6671 assert_eq!(
6672 fill(timestamp, Some("2025-04-18T14:30:01Z")),
6673 (at("2025-04-18T14:30:01Z"), TimeSource::Venue),
6674 "{timestamp:?}"
6675 );
6676 }
6677 let before = Utc::now();
6678 let (time, source) = fill(None, None);
6679 assert_eq!(source, TimeSource::Host);
6680 assert!((before..=Utc::now()).contains(&time));
6681 }
6682
6683 #[test]
6684 fn test_convert_trade_update_fill_produces_trade_with_execution_id() {
6685 let update = AlpacaTradeUpdate {
6686 event: SmolStr::new("fill"),
6687 execution_id: Some("exec-1"),
6688 previous_execution_id: None,
6689 order: make_order_ws("ord-1", "SPY", "buy", "1"),
6690 price: Some("150.00"),
6691 qty: Some("1"),
6692 timestamp: Some("2025-04-18T14:30:00Z"),
6693 };
6694 let (trade, order) = fill_events(convert_trade_update(update));
6695 assert_eq!(trade.id.0.as_str(), "exec-1");
6697 assert_eq!(
6698 trade.client_order_id, None,
6699 "the update names no client order id"
6700 );
6701 assert_eq!(trade.price, Decimal::from_str("150.00").unwrap());
6702 assert_eq!(trade.quantity, Decimal::from_str("1").unwrap());
6703
6704 let OrderState::Active(crate::order::state::ActiveOrderState::Open(open)) = order.state
6707 else {
6708 panic!("expected an Open snapshot, got {:?}", order.state);
6709 };
6710 assert_eq!(open.filled_quantity, Decimal::from_str("1").unwrap());
6711 assert_eq!(open.id.assigned().map(|id| id.0.as_str()), Some("ord-1"));
6712 }
6713
6714 #[test]
6715 fn test_convert_trade_update_partial_fill() {
6716 let update = AlpacaTradeUpdate {
6717 event: SmolStr::new("partial_fill"),
6718 execution_id: None,
6719 previous_execution_id: None,
6720 order: make_order_ws("ord-2", "AAPL", "sell", "0.5"),
6721 price: Some("200.00"),
6722 qty: Some("0.5"),
6723 timestamp: None,
6724 };
6725 let (trade, order) = fill_events(convert_trade_update(update));
6726 assert_eq!(trade.quantity, Decimal::from_str("0.5").unwrap());
6727
6728 let OrderState::Active(crate::order::state::ActiveOrderState::Open(open)) = order.state
6731 else {
6732 panic!("expected an Open snapshot, got {:?}", order.state);
6733 };
6734 assert_eq!(open.filled_quantity, Decimal::from_str("0.5").unwrap());
6735 assert_eq!(
6736 open.quantity_remaining(order.quantity),
6737 Decimal::from_str("1.5").unwrap()
6738 );
6739 }
6740
6741 #[test]
6745 fn a_full_fill_snapshot_reports_nothing_left_to_fill() {
6746 let update = AlpacaTradeUpdate {
6747 event: SmolStr::new("fill"),
6748 execution_id: None,
6749 previous_execution_id: None,
6750 order: AlpacaOrderWs {
6752 status: SmolStr::new("filled"),
6753 updated_at: None,
6754 ..make_order_ws("ord-full", "SPY", "buy", "2")
6755 },
6756 price: Some("150.00"),
6757 qty: Some("1"),
6758 timestamp: Some("2025-04-18T14:30:00Z"),
6759 };
6760 let (_trade, order) = fill_events(convert_trade_update(update));
6761 let OrderState::Active(crate::order::state::ActiveOrderState::Open(open)) = order.state
6762 else {
6763 panic!("expected an Open snapshot, got {:?}", order.state);
6764 };
6765 assert_eq!(open.filled_quantity, Decimal::from_str("2").unwrap());
6766 assert!(
6767 open.quantity_remaining(order.quantity).is_zero(),
6768 "a completed order must report nothing left to fill so the engine retires it"
6769 );
6770 }
6771
6772 #[test]
6776 fn a_fill_for_an_order_no_longer_working_emits_the_execution_without_a_snapshot() {
6777 for status in [
6778 "canceled",
6779 "expired",
6780 "rejected",
6781 "done_for_day",
6782 "pending_cancel",
6783 ] {
6784 let update = AlpacaTradeUpdate {
6785 event: SmolStr::new("partial_fill"),
6786 execution_id: None,
6787 previous_execution_id: None,
6788 order: AlpacaOrderWs {
6789 status: SmolStr::new(status),
6790 updated_at: None,
6791 ..make_order_ws("ord-late", "SPY", "buy", "1")
6792 },
6793 price: Some("150.00"),
6794 qty: Some("1"),
6795 timestamp: None,
6796 };
6797 let [trade, snapshot] = convert_trade_update(update);
6798 assert!(
6799 matches!(trade.map(|e| e.kind), Some(AccountEventKind::Trade(_))),
6800 "the execution must still be reported for status {status}"
6801 );
6802 assert!(
6803 snapshot.is_none(),
6804 "status {status} must not produce an order snapshot"
6805 );
6806 }
6807 }
6808
6809 #[test]
6812 fn a_notional_order_fill_emits_the_execution_without_a_snapshot() {
6813 let update = AlpacaTradeUpdate {
6814 event: SmolStr::new("partial_fill"),
6815 execution_id: None,
6816 previous_execution_id: None,
6817 order: AlpacaOrderWs {
6818 qty: None,
6819 ..make_order_ws("ord-notional-ws", "SPY", "buy", "1")
6820 },
6821 price: Some("150.00"),
6822 qty: Some("1"),
6823 timestamp: None,
6824 };
6825 let [trade, snapshot] = convert_trade_update(update);
6826 assert!(matches!(
6827 trade.map(|e| e.kind),
6828 Some(AccountEventKind::Trade(_))
6829 ));
6830 assert!(
6831 snapshot.is_none(),
6832 "a notional order has no quantity to snapshot against"
6833 );
6834 }
6835
6836 #[test]
6837 fn test_convert_trade_update_new_order_produces_snapshot() {
6838 let update = AlpacaTradeUpdate {
6839 event: SmolStr::new("new"),
6840 execution_id: None,
6841 previous_execution_id: None,
6842 order: AlpacaOrderWs {
6843 id: SmolStr::new("ord-new"),
6844 client_order_id: Some(SmolStr::new("cid-1")),
6845 symbol: SmolStr::new("AAPL"),
6846 qty: Some("10"),
6847 filled_qty: Some("0"),
6848 side: SmolStr::new("buy"),
6849 order_type: SmolStr::new("limit"),
6850 time_in_force: SmolStr::new("day"),
6851 limit_price: Some("150.00"),
6852 stop_price: None,
6853 trail_percent: None,
6854 trail_price: None,
6855 status: SmolStr::new("new"),
6856 updated_at: None,
6857 },
6858 price: None,
6859 qty: None,
6860 timestamp: Some("2025-04-18T14:30:00Z"),
6861 };
6862 let event =
6863 sole_event(convert_trade_update(update)).expect("new event should produce an event");
6864 assert!(matches!(event.kind, AccountEventKind::OrderSnapshot(_)));
6865 }
6866
6867 #[test]
6868 fn test_convert_trade_update_canceled_produces_cancel() {
6869 let update = AlpacaTradeUpdate {
6870 event: SmolStr::new("canceled"),
6871 execution_id: None,
6872 previous_execution_id: None,
6873 order: make_order_ws("ord-3", "AAPL", "sell", "0"),
6874 price: None,
6875 qty: None,
6876 timestamp: Some("2025-04-18T14:30:00Z"),
6877 };
6878 let event =
6879 sole_event(convert_trade_update(update)).expect("canceled should produce an event");
6880 let AccountEventKind::OrderCancelled(response) = event.kind else {
6881 panic!("expected OrderCancelled, got {:?}", event.kind);
6882 };
6883 assert!(response.state.is_ok());
6884 }
6885
6886 #[test]
6888 fn a_canceled_update_carries_its_fill_or_none() {
6889 let cancelled = |filled_qty: Option<&'static str>| {
6890 let mut order = make_order_ws("ord-3", "AAPL", "sell", "0");
6891 order.filled_qty = filled_qty;
6892 let update = AlpacaTradeUpdate {
6893 event: SmolStr::new("canceled"),
6894 execution_id: None,
6895 previous_execution_id: None,
6896 order,
6897 price: None,
6898 qty: None,
6899 timestamp: Some("2025-04-18T14:30:00Z"),
6900 };
6901 let event =
6902 sole_event(convert_trade_update(update)).expect("canceled should produce an event");
6903 let AccountEventKind::OrderCancelled(response) = event.kind else {
6904 panic!("expected OrderCancelled, got {:?}", event.kind);
6905 };
6906 let Ok(cancelled) = response.state else {
6907 panic!("expected Ok, got {:?}", response.state);
6908 };
6909 cancelled.filled_quantity
6910 };
6911
6912 assert_eq!(cancelled(Some("1")), Some(Decimal::ONE));
6913 assert_eq!(cancelled(Some("0")), Some(Decimal::ZERO));
6914 assert_eq!(cancelled(None), None);
6915 assert_eq!(cancelled(Some("not a number")), None);
6916 }
6917
6918 #[test]
6921 fn an_ended_order_with_an_unparseable_fill_reports_it_unknown() {
6922 let key = OrderKey::new(
6923 ExchangeId::AlpacaBroker,
6924 InstrumentNameExchange::new("AAPL"),
6925 StrategyId::new("strategy"),
6926 ClientOrderId::new("c"),
6927 );
6928 let ended = |status: &str| {
6929 let mut order = make_order_response("o1", "AAPL");
6930 order.status = Some(status.to_string());
6931 order.filled_qty = "not a number".to_string();
6932 convert_ended_order(&order, &key).map(|order| order.state)
6933 };
6934
6935 assert!(matches!(
6936 ended("canceled"),
6937 Some(InactiveOrderState::Cancelled(Cancelled {
6938 filled_quantity: None,
6939 ..
6940 }))
6941 ));
6942 assert!(matches!(
6943 ended("expired"),
6944 Some(InactiveOrderState::Expired(Expired {
6945 filled_quantity: None,
6946 ..
6947 }))
6948 ));
6949 assert!(matches!(
6950 ended("filled"),
6951 Some(InactiveOrderState::FullyFilled(Filled { filled_quantity, .. }))
6952 if filled_quantity == Decimal::ONE
6953 ));
6954 }
6955
6956 #[test]
6957 fn test_convert_trade_update_rejected_produces_error() {
6958 let update = AlpacaTradeUpdate {
6959 event: SmolStr::new("rejected"),
6960 execution_id: None,
6961 previous_execution_id: None,
6962 order: make_order_ws("ord-4", "SPY", "buy", "0"),
6963 price: None,
6964 qty: None,
6965 timestamp: None,
6966 };
6967 let event =
6968 sole_event(convert_trade_update(update)).expect("rejected should produce an event");
6969 let AccountEventKind::OrderCancelled(response) = event.kind else {
6970 panic!("expected OrderCancelled, got {:?}", event.kind);
6971 };
6972 assert!(response.state.is_err());
6973 }
6974
6975 #[test]
6976 fn test_convert_open_order_notional_qty_none_is_skipped() {
6977 let order = AlpacaOrderResponse {
6980 id: "ord-notional".to_string(),
6981 client_order_id: None,
6982 symbol: "SPY".to_string(),
6983 qty: None,
6984 filled_qty: "0".to_string(),
6985 filled_avg_price: None,
6986 status: None,
6987 side: "buy".to_string(),
6988 order_type: "market".to_string(),
6989 time_in_force: "day".to_string(),
6990 limit_price: None,
6991 stop_price: None,
6992 trail_percent: None,
6993 trail_price: None,
6994 created_at: "2025-04-18T14:30:00Z".to_string(),
6995 updated_at: None,
6996 };
6997 assert!(convert_open_order(&order).is_none());
6998 }
6999
7000 #[test]
7003 fn an_activity_without_a_transaction_time_is_at_this_hosts_time() {
7004 let activity = |transaction_time: &str| AlpacaActivity {
7005 id: "act-1".to_string(),
7006 order_id: "ord-1".to_string(),
7007 symbol: "SPY".to_string(),
7008 side: "buy".to_string(),
7009 price: "150.00".to_string(),
7010 qty: "1".to_string(),
7011 transaction_time: transaction_time.to_string(),
7012 cum_qty: Some("1".to_string()),
7013 };
7014
7015 let trade = convert_activity_to_trade(&activity("2025-04-18T14:30:00Z")).unwrap();
7016 assert_eq!(
7017 (trade.time_exchange, trade.time_source),
7018 (
7019 parse_timestamp("2025-04-18T14:30:00Z").unwrap(),
7020 TimeSource::Venue
7021 )
7022 );
7023 let before = Utc::now();
7024 let trade = convert_activity_to_trade(&activity("not a time")).unwrap();
7025 assert_eq!(trade.time_source, TimeSource::Host);
7026 assert!((before..=Utc::now()).contains(&trade.time_exchange));
7027 }
7028
7029 #[test]
7030 fn test_convert_activity_to_trade_bad_price_returns_none() {
7031 let activity = AlpacaActivity {
7035 id: "act-1".to_string(),
7036 order_id: "ord-1".to_string(),
7037 symbol: "SPY250418C00450000".to_string(),
7038 side: "buy".to_string(),
7039 price: "not-a-number".to_string(),
7040 qty: "1".to_string(),
7041 transaction_time: "2025-04-18T14:30:00Z".to_string(),
7042 cum_qty: Some("1".to_string()),
7043 };
7044 assert!(convert_activity_to_trade(&activity).is_none());
7045 }
7046
7047 #[test]
7048 fn test_convert_positions_to_balances_crypto() {
7049 let positions = vec![
7050 AlpacaPosition {
7052 qty_available: dec!(0.4),
7053 ..alpaca_position("BTCUSD", "crypto", AlpacaPositionSide::Long, dec!(0.5))
7054 },
7055 alpaca_position("ETH/USD", "crypto", AlpacaPositionSide::Long, dec!(2.0)),
7057 alpaca_position("AAPL", "us_equity", AlpacaPositionSide::Long, dec!(10)),
7059 ];
7060
7061 let balances = convert_positions_to_balances(&positions, &[], Utc::now());
7063 assert_eq!(balances.len(), 2, "only crypto positions returned");
7064 assert_eq!(balances[0].asset.name().as_str(), "BTC");
7065 assert_eq!(balances[1].asset.name().as_str(), "ETH");
7066 assert_eq!(balances[0].balance.total, dec!(0.5));
7068 assert_eq!(balances[0].balance.free, dec!(0.4));
7069
7070 let btc_only = vec![AssetNameExchange::new("btc")];
7072 let balances = convert_positions_to_balances(&positions, &btc_only, Utc::now());
7073 assert_eq!(balances.len(), 1);
7074 assert_eq!(balances[0].asset.name().as_str(), "BTC");
7075 }
7076
7077 #[test]
7078 fn test_crypto_position_asset() {
7079 assert_eq!(crypto_position_asset("BTCUSD"), "BTC");
7080 assert_eq!(
7081 crypto_position_asset("btcusd"),
7082 "btc",
7083 "suffix matched ignoring case"
7084 );
7085 assert_eq!(
7086 crypto_position_asset("USDTUSD"),
7087 "USDT",
7088 "only one USD is stripped"
7089 );
7090 assert_eq!(crypto_position_asset("BTC/USD"), "BTC");
7091 assert_eq!(crypto_position_asset("BTC/USDT"), "BTC");
7092 assert_eq!(crypto_position_asset("€USD"), "€");
7093 assert_eq!(crypto_position_asset("USD"), "USD");
7095 assert_eq!(crypto_position_asset("/USD"), "/USD");
7096 assert_eq!(crypto_position_asset("BTCUSDT"), "BTCUSDT");
7097 assert_eq!(
7098 crypto_position_asset("BTCێ"),
7099 "BTCێ",
7100 "no char boundary at the suffix"
7101 );
7102 assert_eq!(crypto_position_asset(""), "");
7103 }
7104
7105 fn alpaca_position(
7107 symbol: &str,
7108 asset_class: &str,
7109 side: AlpacaPositionSide,
7110 qty: Decimal,
7111 ) -> AlpacaPosition {
7112 AlpacaPosition {
7113 symbol: symbol.into(),
7114 asset_class: asset_class.into(),
7115 side,
7116 qty,
7117 qty_available: qty,
7118 avg_entry_price: Some(dec!(100)),
7119 unrealized_pl: Some(dec!(0)),
7120 }
7121 }
7122
7123 #[test]
7124 fn test_convert_positions_sign_comes_from_side_not_qty() {
7125 let now = Utc::now();
7126 let positions = vec![
7127 alpaca_position("AAPL", "us_equity", AlpacaPositionSide::Long, dec!(10)),
7128 alpaca_position("TSLA", "us_equity", AlpacaPositionSide::Short, dec!(-3)),
7129 alpaca_position("MSFT", "us_equity", AlpacaPositionSide::Short, dec!(4)),
7131 ];
7132
7133 let converted = convert_positions(&positions, now).unwrap();
7134 let quantities: Vec<_> = converted.iter().map(|(s, p)| (*s, p.quantity)).collect();
7135 assert_eq!(
7136 quantities,
7137 vec![("AAPL", dec!(10)), ("TSLA", dec!(-3)), ("MSFT", dec!(-4))]
7138 );
7139 assert!(converted.iter().all(|(_, p)| p.time_exchange == now));
7140 }
7141
7142 #[test]
7143 fn test_convert_positions_leaves_out_crypto_and_flat() {
7144 let positions = vec![
7145 alpaca_position("BTC/USD", "crypto", AlpacaPositionSide::Long, dec!(0.5)),
7146 alpaca_position("AAPL", "us_equity", AlpacaPositionSide::Long, dec!(0)),
7147 alpaca_position("SPY", "us_equity", AlpacaPositionSide::Long, dec!(1)),
7148 ];
7149
7150 let converted = convert_positions(&positions, Utc::now()).unwrap();
7151 let symbols: Vec<_> = converted.iter().map(|(s, _)| *s).collect();
7152 assert_eq!(symbols, vec!["SPY"]);
7153 }
7154
7155 #[test]
7156 fn test_alpaca_position_decodes_equity_option_and_fractional() {
7157 let positions: Vec<AlpacaPosition> = serde_json::from_value(serde_json::json!([
7158 {
7159 "asset_id": "00000000-0000-0000-0000-000000000001",
7160 "symbol": "XYZ",
7161 "exchange": "NASDAQ",
7162 "asset_class": "us_equity",
7163 "qty": "-7",
7164 "qty_available": "-7",
7165 "side": "short",
7166 "avg_entry_price": "50.25",
7167 "market_value": "-351.75",
7168 "unrealized_pl": "0",
7169 },
7170 {
7171 "symbol": "XYZ271217C00055000",
7172 "asset_class": "us_option",
7173 "qty": "2",
7174 "qty_available": "2",
7175 "side": "long",
7176 "avg_entry_price": "1.35",
7177 "unrealized_pl": "-20",
7178 },
7179 {
7180 "symbol": "ABC",
7181 "asset_class": "us_equity",
7182 "qty": "0.125",
7183 "qty_available": "0.125",
7184 "side": "long",
7185 "avg_entry_price": "80",
7186 "unrealized_pl": null,
7187 },
7188 ]))
7189 .unwrap();
7190
7191 let converted = convert_positions(&positions, Utc::now()).unwrap();
7192 let [
7193 (short_sym, short),
7194 (option_sym, option),
7195 (fraction_sym, fraction),
7196 ] = converted.as_slice()
7197 else {
7198 panic!("expected three positions, got {converted:?}");
7199 };
7200
7201 assert_eq!(*short_sym, "XYZ");
7202 assert_eq!(short.quantity, dec!(-7));
7203 assert_eq!(short.entry_price, Some(dec!(50.25)));
7204 assert!(short.is_short());
7205
7206 assert_eq!(*option_sym, "XYZ271217C00055000");
7207 assert_eq!(option.quantity, dec!(2));
7208 assert_eq!(option.entry_price, Some(dec!(1.35)), "per-share premium");
7209 assert_eq!(option.unrealized_pnl, Some(dec!(-20)));
7210
7211 assert_eq!(*fraction_sym, "ABC");
7212 assert_eq!(fraction.quantity, dec!(0.125));
7213 assert_eq!(fraction.unrealized_pnl, None);
7214
7215 for (_, p) in &converted {
7216 assert_eq!(p.margin_used, None);
7217 assert_eq!(p.liquidation_price, None);
7218 assert_eq!(p.leverage, None);
7219 }
7220 }
7221
7222 #[test]
7225 fn test_alpaca_position_unknown_side_fails_only_where_a_direction_is_needed() {
7226 let positions: Vec<AlpacaPosition> = serde_json::from_value(serde_json::json!([
7227 {
7228 "symbol": "BTC/USD",
7229 "asset_class": "crypto",
7230 "qty": "0.5",
7231 "qty_available": "0.5",
7232 },
7233 {
7234 "symbol": "XYZ",
7235 "asset_class": "us_equity",
7236 "qty": "1",
7237 "qty_available": "1",
7238 "side": "sideways",
7239 },
7240 ]))
7241 .unwrap();
7242 assert_eq!(positions[0].side, AlpacaPositionSide::Unknown);
7243 assert_eq!(positions[1].side, AlpacaPositionSide::Unknown);
7244
7245 let balances = convert_positions_to_balances(&positions, &[], Utc::now());
7246 assert_eq!(balances.len(), 1);
7247 assert_eq!(balances[0].balance.total, dec!(0.5));
7248
7249 let result = convert_positions(&positions, Utc::now());
7250 assert!(
7251 matches!(&result, Err(UnindexedClientError::Internal(msg)) if msg.contains("XYZ")),
7252 "an unknown side must not guess a direction: {result:?}"
7253 );
7254 }
7255
7256 #[test]
7258 fn order_state_time_prefers_updated_at() {
7259 let mut resp = make_order_response("ord-1", "SPY");
7260 resp.created_at = "2025-04-18T14:30:00Z".to_string();
7261 resp.updated_at = Some("2025-04-18T15:45:00Z".to_string());
7262 assert_eq!(
7263 order_state_time(&resp),
7264 parse_timestamp("2025-04-18T15:45:00Z").unwrap(),
7265 "updated_at must win over created_at"
7266 );
7267
7268 resp.updated_at = None;
7270 assert_eq!(
7271 order_state_time(&resp),
7272 parse_timestamp("2025-04-18T14:30:00Z").unwrap(),
7273 "absent updated_at falls back to created_at"
7274 );
7275
7276 resp.updated_at = Some("not-a-timestamp".to_string());
7279 assert_eq!(
7280 order_state_time(&resp),
7281 parse_timestamp("2025-04-18T14:30:00Z").unwrap(),
7282 "unparseable updated_at falls back to created_at"
7283 );
7284 }
7285
7286 #[test]
7289 fn a_placement_response_without_a_status_reads_from_what_filled() {
7290 let instrument = InstrumentNameExchange::new("SPY");
7291 let mut resp = make_order_response("ord-1", "SPY");
7292 resp.filled_qty = "0.4".to_string();
7293 let OrderState::Active(ActiveOrderState::Open(open)) =
7294 placed_order_state(&resp, &instrument, Decimal::ONE)
7295 else {
7296 panic!("expected Open");
7297 };
7298 assert_eq!(open.filled_quantity, Decimal::new(4, 1));
7299
7300 resp.filled_qty = "1".to_string();
7301 resp.filled_avg_price = Some("100.5".to_string());
7302 let state = placed_order_state(&resp, &instrument, Decimal::ONE);
7303 let OrderState::Inactive(InactiveOrderState::FullyFilled(filled)) = state else {
7304 panic!("expected FullyFilled, got {state:?}");
7305 };
7306 assert_eq!(filled.filled_quantity, Decimal::ONE);
7307 assert_eq!(filled.avg_price, Some(Decimal::new(1005, 1)));
7308 }
7309
7310 fn make_order_response(id: &str, symbol: &str) -> AlpacaOrderResponse {
7311 AlpacaOrderResponse {
7312 id: id.to_string(),
7313 client_order_id: None,
7314 symbol: symbol.to_string(),
7315 qty: Some("1".to_string()),
7316 filled_qty: "0".to_string(),
7317 filled_avg_price: None,
7318 status: None,
7319 side: "buy".to_string(),
7320 order_type: "limit".to_string(),
7321 time_in_force: "day".to_string(),
7322 limit_price: Some("100.00".to_string()),
7323 stop_price: None,
7324 trail_percent: None,
7325 trail_price: None,
7326 created_at: "2025-04-18T14:30:00Z".to_string(),
7327 updated_at: None,
7328 }
7329 }
7330
7331 #[test]
7332 fn test_build_instrument_snapshots_empty_instruments_returns_only_with_orders() {
7333 let orders = vec![
7334 make_order_response("o1", "AAPL"),
7335 make_order_response("o2", "SPY"),
7336 ];
7337 let snapshots = build_instrument_snapshots(orders, Vec::new(), &[]);
7338 assert_eq!(snapshots.len(), 2);
7339 let symbols: Vec<&str> = snapshots
7340 .iter()
7341 .map(|s| s.instrument.name().as_str())
7342 .collect();
7343 assert!(symbols.contains(&"AAPL"));
7344 assert!(symbols.contains(&"SPY"));
7345 }
7346
7347 #[test]
7348 fn test_build_instrument_snapshots_requested_instrument_no_orders_gets_empty_snapshot() {
7349 let orders = vec![make_order_response("o1", "AAPL")];
7352 let instruments = vec![
7353 InstrumentNameExchange::new("AAPL"),
7354 InstrumentNameExchange::new("SPY"),
7355 ];
7356 let snapshots = build_instrument_snapshots(orders, Vec::new(), &instruments);
7357 assert_eq!(snapshots.len(), 2);
7358 let spy = snapshots
7359 .iter()
7360 .find(|s| s.instrument.name().as_str() == "SPY")
7361 .expect("SPY snapshot must be present even with no orders");
7362 assert!(spy.orders.is_empty());
7363 }
7364
7365 fn make_notional_order_response(id: &str, symbol: &str) -> AlpacaOrderResponse {
7367 AlpacaOrderResponse {
7368 qty: None,
7369 ..make_order_response(id, symbol)
7370 }
7371 }
7372
7373 fn snapshot_for<'a>(
7374 snapshots: &'a [InstrumentAccountSnapshot<
7375 ExchangeId,
7376 AssetNameExchange,
7377 InstrumentNameExchange,
7378 >],
7379 symbol: &str,
7380 ) -> &'a InstrumentAccountSnapshot<ExchangeId, AssetNameExchange, InstrumentNameExchange> {
7381 snapshots
7382 .iter()
7383 .find(|snapshot| snapshot.instrument.name().as_str() == symbol)
7384 .unwrap_or_else(|| panic!("no snapshot for {symbol}"))
7385 }
7386
7387 #[test]
7391 fn test_build_instrument_snapshots_declares_complete_lists_complete() {
7392 let instruments = [
7393 InstrumentNameExchange::new("AAPL"),
7394 InstrumentNameExchange::new("SPY"),
7395 ];
7396 for requested in [&instruments[..], &[]] {
7397 let orders = vec![make_order_response("o1", "AAPL")];
7398 let snapshots = build_instrument_snapshots(orders, Vec::new(), requested);
7399 assert!(!snapshots.is_empty());
7400 assert!(snapshots.iter().all(|snapshot| snapshot.orders_complete));
7401 }
7402 }
7403
7404 #[test]
7408 fn test_build_instrument_snapshots_notional_order_leaves_only_its_instrument_incomplete() {
7409 let orders = vec![
7410 make_order_response("o1", "AAPL"),
7411 make_order_response("o2", "SPY"),
7412 make_notional_order_response("o3", "SPY"),
7413 ];
7414 let instruments = [
7415 InstrumentNameExchange::new("AAPL"),
7416 InstrumentNameExchange::new("SPY"),
7417 ];
7418 let snapshots = build_instrument_snapshots(orders, Vec::new(), &instruments);
7419
7420 assert!(snapshot_for(&snapshots, "AAPL").orders_complete);
7421 let spy = snapshot_for(&snapshots, "SPY");
7422 assert!(!spy.orders_complete);
7423 assert_eq!(spy.orders.len(), 1);
7424 }
7425
7426 #[test]
7429 fn test_build_instrument_snapshots_unfiltered_lists_an_instrument_with_only_a_notional_order() {
7430 let orders = vec![make_notional_order_response("o1", "SPY")];
7431 let snapshots = build_instrument_snapshots(orders, Vec::new(), &[]);
7432
7433 let spy = snapshot_for(&snapshots, "SPY");
7434 assert!(!spy.orders_complete);
7435 assert!(spy.orders.is_empty());
7436 }
7437
7438 #[test]
7439 fn test_build_instrument_snapshots_non_requested_instrument_excluded() {
7440 let orders = vec![
7443 make_order_response("o1", "AAPL"),
7444 make_order_response("o2", "MSFT"), ];
7446 let instruments = vec![InstrumentNameExchange::new("AAPL")];
7447 let snapshots = build_instrument_snapshots(orders, Vec::new(), &instruments);
7448 assert_eq!(snapshots.len(), 1);
7449 assert_eq!(snapshots[0].instrument.name().as_str(), "AAPL");
7450 }
7451
7452 fn position(quantity: Decimal) -> Position {
7453 Position::new(
7454 quantity,
7455 Some(dec!(100)),
7456 None,
7457 None,
7458 None,
7459 None,
7460 None,
7461 Utc::now(),
7462 )
7463 }
7464
7465 #[test]
7466 fn test_build_instrument_snapshots_unfiltered_lists_positions_without_orders() {
7467 let orders = vec![
7468 make_order_response("o1", "AAPL"),
7469 make_order_response("o2", "MSFT"),
7470 make_order_response("o3", "BTC/USD"),
7471 ];
7472 let positions = vec![("AAPL", position(dec!(5))), ("TSLA", position(dec!(-2)))];
7473
7474 let snapshots = build_instrument_snapshots(orders, positions, &[]);
7475 let listed: Vec<_> = snapshots
7476 .iter()
7477 .map(|s| {
7478 (
7479 s.instrument.name().as_str(),
7480 s.orders.len(),
7481 s.position.quantity(),
7482 )
7483 })
7484 .collect();
7485 assert_eq!(
7487 listed,
7488 vec![
7489 ("AAPL", 1, Some(dec!(5))),
7490 ("MSFT", 1, Some(Decimal::ZERO)),
7491 ("BTC/USD", 1, None),
7492 ("TSLA", 0, Some(dec!(-2)))
7493 ]
7494 );
7495 assert!(snapshots.iter().all(|s| s.orders_complete));
7496 }
7497
7498 #[test]
7499 fn test_build_instrument_snapshots_filtered_attaches_only_requested_positions() {
7500 let positions = vec![("AAPL", position(dec!(5))), ("TSLA", position(dec!(-2)))];
7501 let instruments = vec![
7502 InstrumentNameExchange::new("tsla"),
7503 InstrumentNameExchange::new("MSFT"),
7504 InstrumentNameExchange::new("BTC/USD"),
7505 InstrumentNameExchange::new("TSLA"),
7506 ];
7507
7508 let snapshots = build_instrument_snapshots(Vec::new(), positions, &instruments);
7509 let listed: Vec<_> = snapshots
7510 .iter()
7511 .map(|s| (s.instrument.name().as_str(), s.position.quantity()))
7512 .collect();
7513 assert_eq!(
7517 listed,
7518 vec![
7519 ("tsla", Some(dec!(-2))),
7520 ("MSFT", Some(Decimal::ZERO)),
7521 ("BTC/USD", None),
7522 ("TSLA", None)
7523 ]
7524 );
7525 assert!(!snapshots[3].orders_complete);
7526 }
7527
7528 fn epoch(secs: i64) -> DateTime<Utc> {
7529 DateTime::<Utc>::from_timestamp(1_800_000_000 + secs, 0).unwrap()
7530 }
7531
7532 #[test]
7536 fn fill_recovery_from_a_host_clock_ahead_starts_at_alpacas_time() {
7537 let base = tokio::time::Instant::now();
7538 let mut clock = VenueClock::new();
7539 clock.observe(epoch(0), base);
7540 let last = HostMoment::new(epoch(60), base);
7542
7543 assert_eq!(
7544 fill_recovery_start(&clock, last),
7545 epoch(0) - TimeDelta::milliseconds(SIGNAL_RECOVERY_LOOKBACK_MS)
7546 );
7547 }
7548
7549 #[test]
7552 fn fill_recovery_with_no_sample_allows_for_the_unsampled_skew() {
7553 let last = HostMoment::new(epoch(60), tokio::time::Instant::now());
7554
7555 assert_eq!(
7556 fill_recovery_start(&VenueClock::new(), last),
7557 epoch(30) - TimeDelta::milliseconds(SIGNAL_RECOVERY_LOOKBACK_MS)
7558 );
7559 assert_eq!(UNSAMPLED_CLOCK_SKEW, TimeDelta::seconds(30));
7560 }
7561
7562 #[test]
7565 fn a_trade_update_samples_the_streams_clock() {
7566 let (tx, _rx) = mpsc::unbounded_channel();
7567 let known = KnownLiveOrders::shared(ExchangeId::AlpacaBroker);
7568 let frame = |stamps: &str| {
7569 format!(
7570 r#"{{"stream":"trade_updates","data":{{"event":"new",{stamps}"order":{{"id":"id-a","client_order_id":"a","symbol":"SPY","qty":"2","filled_qty":"0","side":"buy","type":"limit","time_in_force":"gtc","limit_price":"100","status":"new"{order_stamp}}}}}}}"#,
7571 order_stamp = if stamps.is_empty() {
7572 r#","updated_at":"2027-01-15T08:00:05Z""#
7573 } else {
7574 ""
7575 },
7576 )
7577 };
7578 let stamped = |frame: &str| {
7579 let mut clock = VenueClock::new();
7580 let before = tokio::time::Instant::now();
7581 process_ws_text(frame, &tx, &new_dedup_cache(), &known, &mut clock);
7582 clock.earliest_at(before)
7583 };
7584 let utc = |s: &str| DateTime::parse_from_rfc3339(s).unwrap().with_timezone(&Utc);
7585
7586 let by_timestamp = stamped(&frame(r#""timestamp":"2027-01-15T08:00:00Z","#)).unwrap();
7587 assert!(
7588 by_timestamp <= utc("2027-01-15T08:00:00Z"),
7589 "{by_timestamp}"
7590 );
7591 assert!(by_timestamp > utc("2027-01-15T07:59:59Z"), "{by_timestamp}");
7592 let by_updated_at = stamped(&frame("")).unwrap();
7593 assert!(
7594 by_updated_at <= utc("2027-01-15T08:00:05Z"),
7595 "{by_updated_at}"
7596 );
7597 assert!(
7598 by_updated_at > utc("2027-01-15T08:00:04Z"),
7599 "{by_updated_at}"
7600 );
7601 }
7602
7603 #[test]
7609 fn test_recover_fills_dedup_key_matches_ws_path() {
7610 let order_id = "ord-1";
7611
7612 let ws_keys: Vec<SmolStr> = ["1", "2"]
7615 .iter()
7616 .map(|filled_qty| {
7617 early_dedup_key(&AlpacaTradeUpdate {
7618 event: SmolStr::new("partial_fill"),
7619 execution_id: None,
7620 previous_execution_id: None,
7621 order: make_order_ws(order_id, "SPY", "buy", filled_qty),
7622 price: Some("150.00"),
7623 qty: Some("1"),
7624 timestamp: None,
7625 })
7626 })
7627 .collect();
7628
7629 let mut cumulative = Decimal::ZERO;
7632 let rest_keys: Vec<SmolStr> = ["1", "1"]
7633 .iter()
7634 .map(|exec_qty| {
7635 cumulative += Decimal::from_str(exec_qty).unwrap();
7636 fill_dedup_key(order_id, cumulative)
7637 })
7638 .collect();
7639
7640 assert_eq!(
7641 ws_keys, rest_keys,
7642 "REST recovery dedup keys must match WS path keys for cross-source dedup to work"
7643 );
7644 assert_eq!(ws_keys[0].as_str(), "ord-1:1");
7645 assert_eq!(ws_keys[1].as_str(), "ord-1:2");
7646 }
7647
7648 #[test]
7653 fn a_ws_fill_without_an_execution_id_is_identified_by_its_dedup_key() {
7654 let update = AlpacaTradeUpdate {
7655 event: SmolStr::new("fill"),
7656 execution_id: None,
7657 previous_execution_id: None,
7658 order: make_order_ws("ord-abc", "SPY", "buy", "5"),
7659 price: Some("150.00"),
7660 qty: Some("5"),
7661 timestamp: None,
7662 };
7663
7664 let early_key = early_dedup_key(&update);
7666
7667 let [event, _snapshot] = convert_trade_update(update);
7669 let Some(UnindexedAccountEvent {
7670 kind: AccountEventKind::Trade(trade),
7671 ..
7672 }) = event
7673 else {
7674 panic!("fill should produce an execution");
7675 };
7676
7677 assert_eq!(
7678 trade.id.0.as_str(),
7679 early_key.as_str(),
7680 "without an execution_id the TradeId is the dedup key"
7681 );
7682 assert_eq!(early_key.as_str(), "ord-abc:5");
7683 }
7684
7685 #[test]
7690 fn early_dedup_key_normalizes_decimal_strings() {
7691 let update1 = AlpacaTradeUpdate {
7693 event: SmolStr::new("fill"),
7694 execution_id: None,
7695 previous_execution_id: None,
7696 order: AlpacaOrderWs {
7697 id: SmolStr::new("ord-x"),
7698 client_order_id: Some(SmolStr::new("cid")),
7699 symbol: SmolStr::new("AAPL"),
7700 qty: Some("10"),
7701 filled_qty: Some("1.00"),
7702 side: SmolStr::new("buy"),
7703 order_type: SmolStr::new("market"),
7704 time_in_force: SmolStr::new("day"),
7705 limit_price: None,
7706 stop_price: None,
7707 trail_percent: None,
7708 trail_price: None,
7709 status: SmolStr::new("filled"),
7710 updated_at: None,
7711 },
7712 price: Some("100.00"),
7713 qty: Some("10"),
7714 timestamp: None,
7715 };
7716 assert_eq!(early_dedup_key(&update1).as_str(), "ord-x:1");
7717
7718 let update2 = AlpacaTradeUpdate {
7720 event: SmolStr::new("fill"),
7721 execution_id: None,
7722 previous_execution_id: None,
7723 order: AlpacaOrderWs {
7724 id: SmolStr::new("ord-x"),
7725 client_order_id: Some(SmolStr::new("cid")),
7726 symbol: SmolStr::new("AAPL"),
7727 qty: Some("10"),
7728 filled_qty: Some("1"),
7729 side: SmolStr::new("buy"),
7730 order_type: SmolStr::new("market"),
7731 time_in_force: SmolStr::new("day"),
7732 limit_price: None,
7733 stop_price: None,
7734 trail_percent: None,
7735 trail_price: None,
7736 status: SmolStr::new("filled"),
7737 updated_at: None,
7738 },
7739 price: Some("100.00"),
7740 qty: Some("10"),
7741 timestamp: None,
7742 };
7743 assert_eq!(early_dedup_key(&update2).as_str(), "ord-x:1");
7744
7745 let update3 = AlpacaTradeUpdate {
7747 event: SmolStr::new("fill"),
7748 execution_id: None,
7749 previous_execution_id: None,
7750 order: AlpacaOrderWs {
7751 id: SmolStr::new("ord-x"),
7752 client_order_id: Some(SmolStr::new("cid")),
7753 symbol: SmolStr::new("AAPL"),
7754 qty: Some("10"),
7755 filled_qty: Some("1.0"),
7756 side: SmolStr::new("buy"),
7757 order_type: SmolStr::new("market"),
7758 time_in_force: SmolStr::new("day"),
7759 limit_price: None,
7760 stop_price: None,
7761 trail_percent: None,
7762 trail_price: None,
7763 status: SmolStr::new("filled"),
7764 updated_at: None,
7765 },
7766 price: Some("100.00"),
7767 qty: Some("10"),
7768 timestamp: None,
7769 };
7770 assert_eq!(early_dedup_key(&update3).as_str(), "ord-x:1");
7771 }
7772
7773 #[test]
7778 fn process_ws_text_rejected_event_without_filled_qty_is_not_dropped() {
7779 let (tx, mut rx) = mpsc::unbounded_channel();
7780 let dedup = new_dedup_cache();
7781
7782 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"}}}"#;
7784
7785 let known = KnownLiveOrders::shared(ExchangeId::AlpacaBroker);
7786 process_ws_text(json, &tx, &dedup, &known, &mut VenueClock::new());
7787
7788 let event = rx.try_recv()
7790 .expect("rejected event without filled_qty must produce an AccountEvent, not be silently dropped");
7791 assert!(
7792 matches!(event.kind, AccountEventKind::OrderCancelled(_)),
7793 "rejected event must map to OrderCancelled, got: {:?}",
7794 event.kind
7795 );
7796 }
7797
7798 #[test]
7801 fn process_ws_text_identifies_a_fill_by_its_execution_id() {
7802 let frame = |execution_id: &str| {
7803 format!(
7804 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"}}}}"#
7805 )
7806 };
7807 let cases = [
7808 (
7809 r#""execution_id":"524b1902-817e-446c-825b-a9fcfebbc17e","#,
7810 "524b1902-817e-446c-825b-a9fcfebbc17e",
7811 ),
7812 (r#""execution_id":null,"#, "ord-1:2"),
7813 (r#""execution_id":"","#, "ord-1:2"),
7814 ("", "ord-1:2"),
7815 ];
7816 for (field, expected) in cases {
7817 let (tx, mut rx) = mpsc::unbounded_channel();
7818 let known = KnownLiveOrders::shared(ExchangeId::AlpacaBroker);
7819 process_ws_text(
7820 &frame(field),
7821 &tx,
7822 &new_dedup_cache(),
7823 &known,
7824 &mut VenueClock::new(),
7825 );
7826 let Ok(UnindexedAccountEvent {
7827 kind: AccountEventKind::Trade(trade),
7828 ..
7829 }) = rx.try_recv()
7830 else {
7831 panic!("the fill frame {field:?} produces a Trade");
7832 };
7833 assert_eq!(trade.id.0.as_str(), expected, "frame {field:?}");
7834 }
7835 }
7836
7837 fn amendment_frame(event: &str, fields: &str) -> String {
7840 format!(
7841 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"}}}}"#
7842 )
7843 }
7844
7845 fn stream_events(frames: &[String]) -> Vec<UnindexedAccountEvent> {
7847 let (tx, mut rx) = mpsc::unbounded_channel();
7848 let dedup = new_dedup_cache();
7849 let known = KnownLiveOrders::shared(ExchangeId::AlpacaBroker);
7850 for frame in frames {
7851 process_ws_text(frame, &tx, &dedup, &known, &mut VenueClock::new());
7852 }
7853 std::iter::from_fn(|| rx.try_recv().ok()).collect()
7854 }
7855
7856 fn amendment_of(
7858 frame: String,
7859 ) -> crate::trade::TradeAmendment<AssetNameExchange, InstrumentNameExchange> {
7860 let events = stream_events(&[frame]);
7861 let [
7862 UnindexedAccountEvent {
7863 kind: AccountEventKind::TradeAmended(amendment),
7864 ..
7865 },
7866 ] = <[_; 1]>::try_from(events).unwrap_or_else(|events| {
7867 panic!("one TradeAmended, got {events:?}");
7868 })
7869 else {
7870 panic!("expected TradeAmended");
7871 };
7872 amendment
7873 }
7874
7875 #[test]
7878 fn process_ws_text_reports_a_trade_bust() {
7879 let amendment = amendment_of(amendment_frame(
7880 "trade_bust",
7881 r#""execution_id":"exec-x","previous_execution_id":"exec-a","qty":"-2","#,
7882 ));
7883 assert_eq!(amendment.original, Some(TradeId::new("exec-a")));
7884 assert_eq!(amendment.order_id, OrderId::new("ord-1"));
7885 assert_eq!(amendment.instrument, InstrumentNameExchange::new("SPY"));
7886 assert_eq!(
7887 Some(amendment.time_exchange),
7888 parse_timestamp("2025-04-18T14:31:00Z")
7889 );
7890 assert_eq!(
7891 amendment.kind,
7892 TradeAmendmentKind::Busted {
7893 quantity: Some(Decimal::TWO)
7894 }
7895 );
7896 }
7897
7898 #[test]
7900 fn process_ws_text_reports_a_bust_that_names_no_fill() {
7901 let amendment = amendment_of(amendment_frame("trade_bust", ""));
7902 assert_eq!(amendment.original, None);
7903 assert_eq!(
7904 amendment.kind,
7905 TradeAmendmentKind::Busted { quantity: None }
7906 );
7907 }
7908
7909 #[test]
7912 fn process_ws_text_reports_a_trade_correct_with_its_replacement() {
7913 let amendment = amendment_of(amendment_frame(
7914 "trade_correct",
7915 r#""execution_id":"exec-c","previous_execution_id":"exec-a","price":"101.50","qty":"2","#,
7916 ));
7917 assert_eq!(amendment.original, Some(TradeId::new("exec-a")));
7918 let TradeAmendmentKind::Corrected { replacement } = amendment.kind else {
7919 panic!("a full replacement: {:?}", amendment.kind);
7920 };
7921 assert_eq!(replacement.id, TradeId::new("exec-c"));
7922 assert_eq!(replacement.order_id, OrderId::new("ord-1"));
7923 assert_eq!(replacement.side, Side::Buy);
7924 assert_eq!(replacement.price, Decimal::new(10150, 2));
7925 assert_eq!(replacement.quantity, Decimal::TWO);
7926 assert_eq!(replacement.order_filled_quantity, None);
7927 assert_eq!(replacement.time_exchange, amendment.time_exchange);
7928 }
7929
7930 #[test]
7933 fn process_ws_text_reports_a_correction_it_cannot_resolve() {
7934 let price = Some(Decimal::new(10150, 2));
7935 let cases = [
7936 (
7937 r#""execution_id":"exec-c","qty":"2","#,
7938 (Some("exec-c"), None, Some(Decimal::TWO)),
7939 ),
7940 (
7941 r#""price":"101.50","qty":"2","#,
7942 (None, price, Some(Decimal::TWO)),
7943 ),
7944 (
7945 r#""execution_id":"exec-c","price":"101.50","qty":"0","#,
7946 (Some("exec-c"), price, Some(Decimal::ZERO)),
7947 ),
7948 (
7949 r#""execution_id":"exec-c","price":"101.50","#,
7950 (Some("exec-c"), price, None),
7951 ),
7952 ];
7953 for (fields, (id, price, quantity)) in cases {
7954 let amendment = amendment_of(amendment_frame("trade_correct", fields));
7955 assert_eq!(
7956 amendment.kind,
7957 TradeAmendmentKind::CorrectedUnresolved {
7958 id: id.map(TradeId::new),
7959 price,
7960 quantity,
7961 },
7962 "{fields}"
7963 );
7964 }
7965 }
7966
7967 #[test]
7970 fn a_fill_after_a_bust_is_delivered_at_a_cumulative_seen_before() {
7971 let fill = |execution_id: &str| {
7972 format!(
7973 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"}}}}"#
7974 )
7975 };
7976 let events = stream_events(&[
7977 fill("exec-a"),
7978 amendment_frame(
7979 "trade_bust",
7980 r#""execution_id":"exec-x","previous_execution_id":"exec-a","qty":"-2","#,
7981 ),
7982 fill("exec-b"),
7983 ]);
7984
7985 let kinds: Vec<_> = events
7986 .iter()
7987 .filter_map(|event| match &event.kind {
7988 AccountEventKind::Trade(trade) => Some(format!("trade {}", trade.id)),
7989 AccountEventKind::TradeAmended(amendment) => {
7990 Some(format!("amended {:?}", amendment.original))
7991 }
7992 _ => None,
7993 })
7994 .collect();
7995 assert_eq!(
7996 kinds,
7997 vec![
7998 "trade exec-a",
7999 "amended Some(TradeId(\"exec-a\"))",
8000 "trade exec-b"
8001 ]
8002 );
8003 }
8004
8005 #[test]
8008 fn decimal_zero_normalize_is_zero_str() {
8009 assert_eq!(Decimal::ZERO.normalize().to_string(), "0");
8010 }
8011
8012 #[test]
8016 fn decimal_normalize_strips_trailing_zeros() {
8017 let from_rest = Decimal::from_str("1.00").unwrap().normalize();
8019 let from_ws = Decimal::from_str("1").unwrap().normalize();
8020 assert_eq!(from_rest.to_string(), from_ws.to_string());
8021 assert_eq!(from_rest.to_string(), "1");
8022
8023 assert_eq!(
8025 Decimal::from_str("100.000")
8026 .unwrap()
8027 .normalize()
8028 .to_string(),
8029 "100"
8030 );
8031 assert_eq!(
8032 Decimal::from_str("0.10").unwrap().normalize().to_string(),
8033 "0.1"
8034 );
8035 assert_eq!(
8036 Decimal::from_str("0.100").unwrap().normalize().to_string(),
8037 "0.1"
8038 );
8039 }
8040
8041 #[test]
8043 fn map_position_intent_open_buy_maps_to_buy_to_open() {
8044 assert_eq!(
8045 map_position_intent(Side::Buy, ReduceOnly::No),
8046 AlpacaPositionIntent::BuyToOpen
8047 );
8048 }
8049
8050 #[test]
8051 fn map_position_intent_open_sell_maps_to_sell_to_open() {
8052 assert_eq!(
8053 map_position_intent(Side::Sell, ReduceOnly::No),
8054 AlpacaPositionIntent::SellToOpen
8055 );
8056 }
8057
8058 #[test]
8059 fn map_position_intent_reduce_buy_maps_to_buy_to_close() {
8060 assert_eq!(
8061 map_position_intent(Side::Buy, ReduceOnly::Position),
8062 AlpacaPositionIntent::BuyToClose
8063 );
8064 }
8065
8066 #[test]
8067 fn map_position_intent_reduce_sell_maps_to_sell_to_close() {
8068 assert_eq!(
8069 map_position_intent(Side::Sell, ReduceOnly::Position),
8070 AlpacaPositionIntent::SellToClose
8071 );
8072 }
8073
8074 #[test]
8075 fn map_position_intent_maps_a_free_base_close_as_any_other_close() {
8076 assert_eq!(
8077 map_position_intent(Side::Sell, ReduceOnly::PositionAndFreeBase),
8078 AlpacaPositionIntent::SellToClose
8079 );
8080 assert_eq!(
8081 map_position_intent(Side::Buy, ReduceOnly::PositionAndFreeBase),
8082 AlpacaPositionIntent::BuyToClose
8083 );
8084 }
8085
8086 #[test]
8088 fn parse_order_error_401_maps_to_unauthenticated() {
8089 assert!(matches!(
8091 parse_order_error(reqwest::StatusCode::UNAUTHORIZED, None, "bad credentials"),
8092 UnindexedOrderError::Rejected(ApiError::Unauthenticated(_))
8093 ));
8094 }
8095
8096 #[test]
8097 fn parse_order_error_403_without_code_maps_to_unauthenticated() {
8098 assert!(matches!(
8100 parse_order_error(reqwest::StatusCode::FORBIDDEN, None, "forbidden."),
8101 UnindexedOrderError::Rejected(ApiError::Unauthenticated(_))
8102 ));
8103 }
8104
8105 #[test]
8106 fn parse_order_error_403_with_code_and_insufficient_maps_to_balance_insufficient() {
8107 for message in [
8108 "insufficient buying power",
8109 "insufficient day trading buying power",
8110 "insufficient qty available for order (requested: 10, available: 0)",
8111 ] {
8112 let err = parse_order_error(reqwest::StatusCode::FORBIDDEN, Some(40310000), message);
8113 let UnindexedOrderError::Rejected(ApiError::BalanceInsufficient(None, text)) = err
8114 else {
8115 panic!("expected BalanceInsufficient for {message:?}, got {err:?}");
8116 };
8117 assert_eq!(text, format!("40310000 {message}"));
8118 }
8119 }
8120
8121 #[test]
8122 fn parse_order_error_reads_a_refused_short_by_its_text() {
8123 for (status, code, message, reason) in [
8124 (
8125 reqwest::StatusCode::FORBIDDEN,
8126 Some(40310000),
8127 "account is not allowed to short",
8128 BorrowRejectReason::BorrowDisabled,
8129 ),
8130 (
8132 reqwest::StatusCode::FORBIDDEN,
8133 Some(40310000),
8134 "asset XYZ cannot be sold short",
8135 BorrowRejectReason::NotShortable,
8136 ),
8137 (
8138 reqwest::StatusCode::UNPROCESSABLE_ENTITY,
8139 Some(42210000),
8140 "asset XYZ cannot be sold short",
8141 BorrowRejectReason::NotShortable,
8142 ),
8143 (
8144 reqwest::StatusCode::UNPROCESSABLE_ENTITY,
8145 None,
8146 "account is not allowed to short",
8147 BorrowRejectReason::BorrowDisabled,
8148 ),
8149 ] {
8150 assert_eq!(
8151 parse_order_error(status, code, message),
8152 UnindexedOrderError::Rejected(ApiError::BorrowRejected(BorrowReject::new(
8153 reason,
8154 code.map(|code| code.to_string()),
8155 message.to_owned(),
8156 ))),
8157 "{status} {message:?}"
8158 );
8159 }
8160
8161 for (status, code) in [
8164 (reqwest::StatusCode::UNPROCESSABLE_ENTITY, 42210000),
8165 (reqwest::StatusCode::FORBIDDEN, 40310000),
8166 ] {
8167 assert_eq!(
8168 parse_order_error(status, Some(code), "fractional orders cannot be sold short"),
8169 UnindexedOrderError::Rejected(ApiError::OrderRejected(format!(
8170 "{code} fractional orders cannot be sold short"
8171 ))),
8172 "{status}"
8173 );
8174 }
8175
8176 assert!(matches!(
8178 parse_order_error(
8179 reqwest::StatusCode::FORBIDDEN,
8180 Some(40310000),
8181 "insufficient qty: account is not allowed to short",
8182 ),
8183 UnindexedOrderError::Rejected(ApiError::BorrowRejected(_))
8184 ));
8185
8186 assert!(matches!(
8188 parse_order_error(
8189 reqwest::StatusCode::FORBIDDEN,
8190 None,
8191 "account is not allowed to short"
8192 ),
8193 UnindexedOrderError::Rejected(ApiError::Unauthenticated(_))
8194 ));
8195 }
8196
8197 #[test]
8198 fn parse_order_error_403_with_code_maps_to_order_rejected() {
8199 for (code, message) in [
8200 (40310000, "account is restricted to liquidation only"),
8201 (
8202 40310100,
8203 "trade denied due to pattern day trading protection",
8204 ),
8205 ] {
8206 let err = parse_order_error(reqwest::StatusCode::FORBIDDEN, Some(code), message);
8207 let UnindexedOrderError::Rejected(ApiError::OrderRejected(text)) = err else {
8208 panic!("expected OrderRejected for {message:?}, got {err:?}");
8209 };
8210 assert_eq!(text, format!("{code} {message}"));
8211 }
8212 }
8213
8214 #[test]
8215 fn alpaca_api_error_reads_code_and_falls_back_to_raw_text() {
8216 let coded = AlpacaApiError::from_body(
8217 br#"{"code": 40310000, "message": "insufficient buying power"}"#,
8218 );
8219 assert_eq!(coded.code, Some(40310000));
8220 assert_eq!(coded.message, "insufficient buying power");
8221
8222 let bare = AlpacaApiError::from_body(br#"{"message": "forbidden."}"#);
8223 assert_eq!(bare.code, None);
8224 assert_eq!(bare.message, "forbidden.");
8225
8226 let raw = AlpacaApiError::from_body(b"upstream connect error");
8227 assert_eq!(raw.code, None);
8228 assert_eq!(raw.message, "upstream connect error");
8229 }
8230
8231 #[test]
8232 fn parse_order_error_404_maps_to_order_rejected_with_not_found_prefix() {
8233 let err = parse_order_error(reqwest::StatusCode::NOT_FOUND, None, "order not found");
8234 let UnindexedOrderError::Rejected(ApiError::OrderRejected(msg)) = err else {
8235 panic!("expected OrderRejected, got {err:?}");
8236 };
8237 assert!(
8238 msg.contains("order not found"),
8239 "message should contain 'order not found': {msg}"
8240 );
8241 }
8242
8243 #[test]
8244 fn parse_order_error_422_insufficient_only_maps_to_balance_insufficient() {
8245 assert!(matches!(
8247 parse_order_error(
8248 reqwest::StatusCode::UNPROCESSABLE_ENTITY,
8249 None,
8250 "insufficient funds for this order"
8251 ),
8252 UnindexedOrderError::Rejected(ApiError::BalanceInsufficient(None, _))
8253 ));
8254 }
8255
8256 #[test]
8257 fn parse_order_error_429_maps_to_rate_limit() {
8258 assert!(matches!(
8259 parse_order_error(reqwest::StatusCode::TOO_MANY_REQUESTS, None, "rate limited"),
8260 UnindexedOrderError::Rejected(ApiError::RateLimit {
8261 retry_after: None,
8262 ..
8263 })
8264 ));
8265 }
8266
8267 #[test]
8268 fn a_rate_limit_delay_is_capped_at_alpacas_window() {
8269 let window = Some(Duration::from_secs(DEFAULT_RATE_LIMIT_DELAY_SECS));
8270 for (name, value) in [("retry-after", u64::MAX), ("x-ratelimit-reset", u64::MAX)] {
8271 let mut headers = reqwest::header::HeaderMap::new();
8272 headers.insert(name, value.to_string().parse().unwrap());
8273 assert_eq!(parse_rate_limit_delay(&headers), window, "{name}");
8274 RateLimitTracker::new().on_rate_limited(parse_rate_limit_delay(&headers));
8276 }
8277
8278 let mut headers = reqwest::header::HeaderMap::new();
8279 headers.insert("retry-after", "0".parse().unwrap());
8280 assert_eq!(
8281 parse_rate_limit_delay(&headers),
8282 Some(Duration::ZERO),
8283 "taken as given"
8284 );
8285 }
8286
8287 #[test]
8291 fn parse_time_in_force_unknown_value_falls_back_to_good_until_end_of_day() {
8292 assert_eq!(
8293 parse_time_in_force("opg"),
8294 TimeInForce::GoodUntilEndOfDay,
8295 "unknown TIF must fall back to GoodUntilEndOfDay (with a warn! in production)"
8296 );
8297 }
8298
8299 #[test]
8302 fn build_instrument_snapshots_output_order_matches_instruments_slice() {
8303 let orders = vec![
8305 make_order_response("o1", "SPY"),
8306 make_order_response("o2", "AAPL"),
8307 make_order_response("o3", "MSFT"),
8308 ];
8309 let instruments = vec![
8311 InstrumentNameExchange::new("MSFT"),
8312 InstrumentNameExchange::new("AAPL"),
8313 ];
8314 let snapshots = build_instrument_snapshots(orders, Vec::new(), &instruments);
8315 assert_eq!(snapshots.len(), 2);
8316 assert_eq!(
8317 snapshots[0].instrument.name().as_str(),
8318 "MSFT",
8319 "first snapshot must be MSFT (first in instruments slice)"
8320 );
8321 assert_eq!(
8322 snapshots[1].instrument.name().as_str(),
8323 "AAPL",
8324 "second snapshot must be AAPL (second in instruments slice)"
8325 );
8326 }
8327
8328 mod http_tests {
8335 use super::super::*;
8336 use wiremock::matchers::{method, path};
8337 use wiremock::{Mock, MockServer, Request, Respond, ResponseTemplate};
8338
8339 struct Sequential {
8346 call: std::sync::atomic::AtomicU32,
8347 pages: Vec<serde_json::Value>,
8348 }
8349
8350 impl Sequential {
8351 fn new(pages: Vec<serde_json::Value>) -> Self {
8352 Self {
8353 call: std::sync::atomic::AtomicU32::new(0),
8354 pages,
8355 }
8356 }
8357 }
8358
8359 impl Respond for Sequential {
8360 fn respond(&self, _: &Request) -> ResponseTemplate {
8361 let i = self.call.fetch_add(1, std::sync::atomic::Ordering::Relaxed) as usize;
8362 let body = self.pages.get(i).unwrap_or_else(|| {
8363 panic!(
8364 "Sequential: request #{i} has no configured response \
8365 (only {} page(s) supplied)",
8366 self.pages.len()
8367 )
8368 });
8369 ResponseTemplate::new(200).set_body_json(body)
8370 }
8371 }
8372
8373 fn make_activities_json(count: usize, id_prefix: &str) -> serde_json::Value {
8375 serde_json::Value::Array(
8376 (0..count)
8377 .map(|i| {
8378 serde_json::json!({
8379 "id": format!("{id_prefix}-{i:05}"),
8380 "order_id": "ord-1",
8381 "symbol": "SPY",
8382 "side": "buy",
8383 "price": "100.00",
8384 "qty": "1",
8385 "transaction_time": "2025-04-18T14:30:00Z"
8386 })
8387 })
8388 .collect(),
8389 )
8390 }
8391
8392 fn make_orders_json(count: usize) -> serde_json::Value {
8394 serde_json::Value::Array(
8395 (0..count)
8396 .map(|i| {
8397 serde_json::json!({
8398 "id": format!("order-{i:05}"),
8399 "client_order_id": null,
8400 "symbol": "SPY",
8401 "qty": "1",
8402 "filled_qty": "0",
8403 "side": "buy",
8404 "type": "limit",
8405 "time_in_force": "day",
8406 "limit_price": "100.00",
8407 "created_at": "2025-04-18T14:30:00Z"
8408 })
8409 })
8410 .collect(),
8411 )
8412 }
8413
8414 #[tokio::test]
8419 async fn paginate_activities_single_page_below_max_returns_all_not_truncated() {
8420 let server = MockServer::start().await;
8421 Mock::given(method("GET"))
8422 .and(path("/v2/account/activities"))
8423 .respond_with(
8424 ResponseTemplate::new(200).set_body_json(make_activities_json(5, "act")),
8425 )
8426 .mount(&server)
8427 .await;
8428
8429 let http = reqwest::Client::new();
8430 let rl = RateLimitTracker::new();
8431 let result = paginate_activities::<AlpacaActivity>(
8432 &http,
8433 &rl,
8434 &server.uri(),
8435 FILL_ACTIVITIES,
8436 recovery_after(),
8437 None,
8438 )
8439 .await
8440 .unwrap();
8441
8442 assert_eq!(result.activities.len(), 5);
8443 assert_eq!(result.resume, None);
8444 assert_eq!(server.received_requests().await.unwrap().len(), 1);
8445 }
8446
8447 #[tokio::test]
8450 async fn paginate_activities_exactly_page_size_items_fetches_second_page() {
8451 let server = MockServer::start().await;
8452 Mock::given(method("GET"))
8453 .and(path("/v2/account/activities"))
8454 .respond_with(Sequential::new(vec![
8455 make_activities_json(ALPACA_MAX_ACTIVITIES, "act"),
8456 serde_json::json!([]), ]))
8458 .mount(&server)
8459 .await;
8460
8461 let http = reqwest::Client::new();
8462 let rl = RateLimitTracker::new();
8463 let result = paginate_activities::<AlpacaActivity>(
8464 &http,
8465 &rl,
8466 &server.uri(),
8467 FILL_ACTIVITIES,
8468 recovery_after(),
8469 None,
8470 )
8471 .await
8472 .unwrap();
8473
8474 assert_eq!(result.activities.len(), ALPACA_MAX_ACTIVITIES);
8475 assert_eq!(result.resume, None);
8476 assert_eq!(
8477 server.received_requests().await.unwrap().len(),
8478 2,
8479 "exactly 2 requests: first full page + second empty page"
8480 );
8481 }
8482
8483 #[tokio::test]
8486 async fn paginate_activities_two_pages_returns_combined_activities_not_truncated() {
8487 let server = MockServer::start().await;
8488 Mock::given(method("GET"))
8489 .and(path("/v2/account/activities"))
8490 .respond_with(Sequential::new(vec![
8491 make_activities_json(ALPACA_MAX_ACTIVITIES, "p1"),
8492 make_activities_json(37, "p2"),
8493 ]))
8494 .mount(&server)
8495 .await;
8496
8497 let http = reqwest::Client::new();
8498 let rl = RateLimitTracker::new();
8499 let result = paginate_activities::<AlpacaActivity>(
8500 &http,
8501 &rl,
8502 &server.uri(),
8503 FILL_ACTIVITIES,
8504 recovery_after(),
8505 None,
8506 )
8507 .await
8508 .unwrap();
8509
8510 assert_eq!(result.activities.len(), ALPACA_MAX_ACTIVITIES + 37);
8511 assert_eq!(result.resume, None);
8512 assert_eq!(server.received_requests().await.unwrap().len(), 2);
8513 }
8514
8515 #[tokio::test]
8519 async fn paginate_activities_at_max_pages_sets_truncated_true() {
8520 let server = MockServer::start().await;
8521
8522 Mock::given(method("GET"))
8524 .and(path("/v2/account/activities"))
8525 .respond_with(
8526 ResponseTemplate::new(200)
8527 .set_body_json(make_activities_json(ALPACA_MAX_ACTIVITIES, "act")),
8528 )
8529 .mount(&server)
8530 .await;
8531
8532 let http = reqwest::Client::new();
8533 let rl = RateLimitTracker::new();
8534 let result = paginate_activities::<AlpacaActivity>(
8535 &http,
8536 &rl,
8537 &server.uri(),
8538 FILL_ACTIVITIES,
8539 recovery_after(),
8540 None,
8541 )
8542 .await
8543 .unwrap();
8544
8545 assert!(
8546 result.resume.is_some(),
8547 "must say where to read on after MAX_ACTIVITY_PAGES pages"
8548 );
8549 assert_eq!(
8550 result.activities.len(),
8551 MAX_ACTIVITY_PAGES * ALPACA_MAX_ACTIVITIES,
8552 "must accumulate exactly MAX_ACTIVITY_PAGES * page_size activities"
8553 );
8554 assert_eq!(
8557 server.received_requests().await.unwrap().len(),
8558 MAX_ACTIVITY_PAGES,
8559 "loop must issue exactly MAX_ACTIVITY_PAGES requests then stop"
8560 );
8561 }
8562
8563 #[tokio::test]
8567 async fn fetch_raw_open_orders_499_results_returns_ok() {
8568 let server = MockServer::start().await;
8569 Mock::given(method("GET"))
8570 .and(path("/v2/orders"))
8571 .respond_with(
8572 ResponseTemplate::new(200).set_body_json(make_orders_json(MAX_OPEN_ORDERS - 1)),
8573 )
8574 .mount(&server)
8575 .await;
8576
8577 let http = reqwest::Client::new();
8578 let rl = RateLimitTracker::new();
8579 let result = fetch_raw_open_orders(&http, &rl, &server.uri(), &[]).await;
8580
8581 assert!(
8582 result.is_ok(),
8583 "499 orders must not trigger truncation: {result:?}"
8584 );
8585 assert_eq!(result.unwrap().len(), MAX_OPEN_ORDERS - 1);
8586 }
8587
8588 #[tokio::test]
8592 async fn fetch_raw_open_orders_500_results_returns_truncated_snapshot_error() {
8593 let server = MockServer::start().await;
8594 Mock::given(method("GET"))
8595 .and(path("/v2/orders"))
8596 .respond_with(
8597 ResponseTemplate::new(200).set_body_json(make_orders_json(MAX_OPEN_ORDERS)),
8598 )
8599 .mount(&server)
8600 .await;
8601
8602 let http = reqwest::Client::new();
8603 let rl = RateLimitTracker::new();
8604 let result = fetch_raw_open_orders(&http, &rl, &server.uri(), &[]).await;
8605
8606 assert!(
8607 matches!(
8608 result,
8609 Err(UnindexedClientError::TruncatedSnapshot { limit }) if limit == MAX_OPEN_ORDERS
8610 ),
8611 "500 orders must return TruncatedSnapshot, got: {result:?}"
8612 );
8613 }
8614
8615 async fn mount_account_and_positions(server: &MockServer) {
8620 mount_account_and_positions_answering_after(server, Duration::ZERO).await;
8621 }
8622
8623 async fn mount_account_and_positions_answering_after(server: &MockServer, delay: Duration) {
8626 Mock::given(method("GET"))
8627 .and(path("/v2/account"))
8628 .respond_with(
8629 ResponseTemplate::new(200)
8630 .set_body_json(serde_json::json!({
8631 "status": "ACTIVE",
8632 "currency": "USD",
8633 "cash": "-1500.50",
8634 "equity": "4000.00",
8635 "buying_power": "5000.00",
8636 "options_buying_power": "2000.00",
8637 "non_marginable_buying_power": "1000.00",
8638 }))
8639 .set_delay(delay),
8640 )
8641 .mount(server)
8642 .await;
8643 Mock::given(method("GET"))
8644 .and(path("/v2/positions"))
8645 .respond_with(
8646 ResponseTemplate::new(200)
8647 .set_body_json(serde_json::json!([
8648 {
8649 "symbol": "XYZ",
8650 "asset_class": "us_equity",
8651 "side": "short",
8652 "qty": "-10",
8653 "qty_available": "-10",
8654 "avg_entry_price": "20.00",
8655 "unrealized_pl": "15.50",
8656 },
8657 {
8658 "symbol": "XYZ271217C00025000",
8659 "asset_class": "us_option",
8660 "side": "long",
8661 "qty": "3",
8662 "qty_available": "3",
8663 "avg_entry_price": "0.85",
8664 "unrealized_pl": "-45",
8665 },
8666 {
8667 "symbol": "ABC",
8668 "asset_class": "us_equity",
8669 "side": "long",
8670 "qty": "2.5",
8671 "qty_available": "2.5",
8672 "avg_entry_price": "40.00",
8673 "unrealized_pl": "1.25",
8674 },
8675 {
8676 "symbol": "BTCUSD",
8677 "asset_class": "crypto",
8678 "side": "long",
8679 "qty": "0.75",
8680 "qty_available": "0.5",
8681 "avg_entry_price": "50000",
8682 "unrealized_pl": "0",
8683 },
8684 ]))
8685 .set_delay(delay),
8686 )
8687 .mount(server)
8688 .await;
8689 Mock::given(method("GET"))
8690 .and(path("/v2/orders"))
8691 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([])))
8692 .mount(server)
8693 .await;
8694 }
8695
8696 fn client_for(server: &MockServer) -> AlpacaClient {
8697 AlpacaClient::new(AlpacaConfig::with_base_url(
8698 "test-key".into(),
8699 "test-secret".into(),
8700 server.uri(),
8701 ))
8702 }
8703
8704 #[tokio::test]
8705 async fn account_snapshot_reports_cash_positions_and_crypto_balances() {
8706 use crate::client::ExecutionClient;
8707 use rust_decimal_macros::dec;
8708
8709 let server = MockServer::start().await;
8710 mount_account_and_positions(&server).await;
8711
8712 let snapshot = client_for(&server)
8713 .account_snapshot(&[], &[])
8714 .await
8715 .unwrap();
8716
8717 let balances: Vec<_> = snapshot
8719 .balances
8720 .iter()
8721 .map(|b| (b.asset.name().as_str(), b.balance.total, b.balance.free))
8722 .collect();
8723 assert_eq!(
8724 balances,
8725 vec![
8726 ("USD", dec!(-1500.50), dec!(-1500.50)),
8727 ("BTC", dec!(0.75), dec!(0.5)),
8728 ],
8729 "crypto stays a balance; equities and options do not appear as balances"
8730 );
8731
8732 let positions: Vec<_> = snapshot
8733 .instruments
8734 .iter()
8735 .map(|i| {
8736 let p = i.position.open().unwrap();
8737 (
8738 i.instrument.name().as_str(),
8739 p.quantity,
8740 p.entry_price,
8741 p.unrealized_pnl,
8742 )
8743 })
8744 .collect();
8745 assert_eq!(
8746 positions,
8747 vec![
8748 ("XYZ", dec!(-10), Some(dec!(20.00)), Some(dec!(15.50))),
8749 (
8750 "XYZ271217C00025000",
8751 dec!(3),
8752 Some(dec!(0.85)),
8753 Some(dec!(-45))
8754 ),
8755 ("ABC", dec!(2.5), Some(dec!(40.00)), Some(dec!(1.25))),
8756 ],
8757 "short equity, option and fractional positions; no crypto position"
8758 );
8759 }
8760
8761 #[tokio::test]
8765 async fn balances_are_stamped_before_their_reads_were_sent() {
8766 use crate::client::ExecutionClient;
8767
8768 const ANSWER_DELAY: Duration = Duration::from_millis(200);
8769 let server = MockServer::start().await;
8770 mount_account_and_positions_answering_after(&server, ANSWER_DELAY).await;
8771 let client = client_for(&server);
8772
8773 let before = Utc::now();
8774 let fetched = client.fetch_balances(&[]).await.unwrap();
8775 let between = Utc::now();
8776 let snapshot = client.account_snapshot(&[], &[]).await.unwrap();
8777 let after = Utc::now();
8778
8779 assert_eq!((fetched.len(), snapshot.balances.len()), (2, 2));
8780 assert_eq!(fetched[0].asset.name().as_str(), USD);
8783 assert!(fetched[0].time_exchange + 2 * ANSWER_DELAY <= between);
8784 for (balances, from, to) in [
8786 (&fetched, before, between),
8787 (&snapshot.balances, between, after),
8788 ] {
8789 for balance in balances {
8790 assert!(
8791 from <= balance.time_exchange && balance.time_exchange + ANSWER_DELAY <= to,
8792 "{balance:?} not within {from} ..= {to} - {ANSWER_DELAY:?}"
8793 );
8794 }
8795 }
8796 }
8797
8798 #[tokio::test]
8801 async fn account_snapshot_usd_only_still_reports_positions() {
8802 use crate::client::ExecutionClient;
8803 use rust_decimal_macros::dec;
8804
8805 let server = MockServer::start().await;
8806 mount_account_and_positions(&server).await;
8807
8808 let usd = [AssetNameExchange::new("USD")];
8809 let instruments = [InstrumentNameExchange::new("XYZ")];
8810 let snapshot = client_for(&server)
8811 .account_snapshot(&usd, &instruments)
8812 .await
8813 .unwrap();
8814
8815 assert_eq!(snapshot.balances.len(), 1);
8816 assert_eq!(snapshot.balances[0].asset, AssetNameExchange::new("USD"));
8817 assert_eq!(snapshot.balances[0].balance.total, dec!(-1500.50));
8818
8819 assert_eq!(snapshot.instruments.len(), 1);
8820 assert_eq!(snapshot.instruments[0].position.quantity(), Some(dec!(-10)));
8821 }
8822
8823 #[tokio::test]
8825 async fn account_snapshot_non_usd_only_skips_account_request() {
8826 use crate::client::ExecutionClient;
8827
8828 let server = MockServer::start().await;
8829 mount_account_and_positions(&server).await;
8830
8831 let btc = [AssetNameExchange::new("btc")];
8832 let snapshot = client_for(&server)
8833 .account_snapshot(&btc, &[])
8834 .await
8835 .unwrap();
8836
8837 assert_eq!(snapshot.balances.len(), 1);
8838 assert_eq!(snapshot.balances[0].asset, AssetNameExchange::new("BTC"));
8839 let requests = server.received_requests().await.unwrap();
8840 assert!(
8841 requests.iter().all(|r| r.url.path() != "/v2/account"),
8842 "no USD requested, so /v2/account must not be fetched"
8843 );
8844 }
8845
8846 #[tokio::test]
8851 async fn open_order_refuses_every_margin_effect_without_sending() {
8852 use crate::client::ExecutionClient;
8853 use crate::error::OrderError;
8854 use crate::margin::MarginEffect;
8855 use crate::order::request::{OrderRequestOpen, RequestOpen};
8856 use crate::order::state::InactiveOrderState;
8857 use crate::order::{
8858 OrderKey, OrderKind, TimeInForce,
8859 id::{ClientOrderId, StrategyId},
8860 };
8861 use rust_decimal::Decimal;
8862 use rustrade_instrument::Side;
8863 use rustrade_instrument::exchange::ExchangeId;
8864 use rustrade_instrument::instrument::name::InstrumentNameExchange;
8865
8866 let server = MockServer::start().await;
8867 let client = client_for(&server);
8868 let instrument = InstrumentNameExchange::new("AAPL");
8869
8870 for effect in [
8871 MarginEffect::NoSideEffect,
8872 MarginEffect::Borrow,
8873 MarginEffect::Repay,
8874 ] {
8875 let order = client
8876 .open_order(OrderRequestOpen {
8877 key: OrderKey {
8878 exchange: ExchangeId::AlpacaBroker,
8879 instrument: &instrument,
8880 strategy: StrategyId::new("test-strategy"),
8881 cid: ClientOrderId::new("test-cid"),
8882 },
8883 state: RequestOpen::new(
8884 Side::Buy,
8885 None,
8886 Decimal::new(10, 0),
8887 OrderKind::Market,
8888 TimeInForce::ImmediateOrCancel,
8889 )
8890 .with_margin_effect(effect),
8891 })
8892 .await;
8893
8894 assert!(
8895 matches!(
8896 order.state,
8897 OrderState::Inactive(InactiveOrderState::OpenFailed(
8898 OrderError::UnsupportedMarginEffect(refused)
8899 )) if refused == effect
8900 ),
8901 "{order:?}"
8902 );
8903 }
8904 assert!(
8905 server.received_requests().await.unwrap().is_empty(),
8906 "a refused order is never sent"
8907 );
8908 }
8909
8910 #[tokio::test]
8916 async fn open_order_reduce_only_sell_sends_sell_to_close_intent() {
8917 use crate::client::ExecutionClient;
8918 use crate::order::request::{OrderRequestOpen, RequestOpen};
8919 use crate::order::{
8920 OrderKey, OrderKind, TimeInForce,
8921 id::{ClientOrderId, StrategyId},
8922 };
8923 use rust_decimal::Decimal;
8924 use rustrade_instrument::Side;
8925 use rustrade_instrument::exchange::ExchangeId;
8926 use rustrade_instrument::instrument::name::InstrumentNameExchange;
8927 use wiremock::matchers::{method, path};
8928
8929 let server = MockServer::start().await;
8930
8931 let captured_body = std::sync::Arc::new(parking_lot::Mutex::new(None));
8934 let captured_clone = captured_body.clone();
8935
8936 Mock::given(method("POST"))
8937 .and(path("/v2/orders"))
8938 .respond_with(move |req: &Request| {
8939 let body: serde_json::Value = serde_json::from_slice(&req.body).unwrap();
8941 *captured_clone.lock() = Some(body);
8942
8943 ResponseTemplate::new(200).set_body_json(serde_json::json!({
8944 "id": "test-order-id",
8945 "client_order_id": "test-cid",
8946 "symbol": "AAPL",
8947 "qty": "10",
8948 "filled_qty": "0",
8949 "side": "sell",
8950 "type": "market",
8951 "time_in_force": "ioc",
8952 "limit_price": null,
8953 "created_at": "2025-04-18T14:30:00Z"
8954 }))
8955 })
8956 .mount(&server)
8957 .await;
8958
8959 let config =
8961 AlpacaConfig::with_base_url("test-key".into(), "test-secret".into(), server.uri());
8962 let client = AlpacaClient::new(config);
8963
8964 let request = OrderRequestOpen {
8966 key: OrderKey {
8967 exchange: ExchangeId::AlpacaBroker,
8968 instrument: InstrumentNameExchange::new("AAPL"),
8969 strategy: StrategyId::new("test-strategy"),
8970 cid: ClientOrderId::new("test-cid"),
8971 },
8972 state: RequestOpen::new(
8973 Side::Sell,
8974 None,
8975 Decimal::new(10, 0),
8976 OrderKind::Market,
8977 TimeInForce::ImmediateOrCancel,
8978 )
8979 .with_reduce_only(ReduceOnly::Position), };
8981
8982 let order = client
8984 .open_order(OrderRequestOpen {
8985 key: OrderKey {
8986 exchange: request.key.exchange,
8987 instrument: &request.key.instrument,
8988 strategy: request.key.strategy.clone(),
8989 cid: request.key.cid.clone(),
8990 },
8991 state: request.state.clone(),
8992 })
8993 .await;
8994
8995 assert!(
8997 order.state.is_accepted(),
8998 "order should be accepted: {:?}",
8999 order.state
9000 );
9001
9002 let body = captured_body
9004 .lock()
9005 .take()
9006 .expect("request body should be captured");
9007 assert_eq!(
9008 body.get("position_intent").and_then(|v| v.as_str()),
9009 Some("sell_to_close"),
9010 "reduce_only Position + Side::Sell should produce position_intent=sell_to_close, got: {body}"
9011 );
9012 }
9013
9014 #[tokio::test]
9016 async fn open_order_not_reduce_only_buy_sends_buy_to_open_intent() {
9017 use crate::client::ExecutionClient;
9018 use crate::order::request::{OrderRequestOpen, RequestOpen};
9019 use crate::order::{
9020 OrderKey, OrderKind, TimeInForce,
9021 id::{ClientOrderId, StrategyId},
9022 };
9023 use rust_decimal::Decimal;
9024 use rustrade_instrument::Side;
9025 use rustrade_instrument::exchange::ExchangeId;
9026 use rustrade_instrument::instrument::name::InstrumentNameExchange;
9027
9028 let server = MockServer::start().await;
9029
9030 let captured_body = std::sync::Arc::new(parking_lot::Mutex::new(None));
9031 let captured_clone = captured_body.clone();
9032
9033 Mock::given(method("POST"))
9034 .and(path("/v2/orders"))
9035 .respond_with(move |req: &Request| {
9036 let body: serde_json::Value = serde_json::from_slice(&req.body).unwrap();
9037 *captured_clone.lock() = Some(body);
9038
9039 ResponseTemplate::new(200).set_body_json(serde_json::json!({
9040 "id": "test-order-id",
9041 "client_order_id": "test-cid",
9042 "symbol": "AAPL",
9043 "qty": "10",
9044 "filled_qty": "0",
9045 "side": "buy",
9046 "type": "market",
9047 "time_in_force": "ioc",
9048 "limit_price": null,
9049 "created_at": "2025-04-18T14:30:00Z"
9050 }))
9051 })
9052 .mount(&server)
9053 .await;
9054
9055 let config =
9056 AlpacaConfig::with_base_url("test-key".into(), "test-secret".into(), server.uri());
9057 let client = AlpacaClient::new(config);
9058
9059 let instrument = InstrumentNameExchange::new("AAPL");
9060 let request = OrderRequestOpen {
9061 key: OrderKey {
9062 exchange: ExchangeId::AlpacaBroker,
9063 instrument: &instrument,
9064 strategy: StrategyId::new("test-strategy"),
9065 cid: ClientOrderId::new("test-cid"),
9066 },
9067 state: RequestOpen::new(
9069 Side::Buy,
9070 None,
9071 Decimal::new(10, 0),
9072 OrderKind::Market,
9073 TimeInForce::ImmediateOrCancel,
9074 ),
9075 };
9076
9077 let order = client.open_order(request).await;
9078
9079 assert!(
9080 order.state.is_accepted(),
9081 "order should be accepted: {:?}",
9082 order.state
9083 );
9084
9085 let body = captured_body
9086 .lock()
9087 .take()
9088 .expect("request body should be captured");
9089 assert_eq!(
9090 body.get("position_intent").and_then(|v| v.as_str()),
9091 Some("buy_to_open"),
9092 "reduce_only No + Side::Buy should produce position_intent=buy_to_open, got: {body}"
9093 );
9094 }
9095
9096 #[tokio::test]
9099 async fn rest_with_retry_reports_an_undecodable_2xx_body_as_internal() {
9100 let server = MockServer::start().await;
9101 Mock::given(method("GET"))
9102 .and(path("/v2/account"))
9103 .respond_with(ResponseTemplate::new(200).set_body_string("not json"))
9104 .mount(&server)
9105 .await;
9106
9107 let http = reqwest::Client::new();
9108 let rl = RateLimitTracker::new();
9109 let url = format!("{}/v2/account", server.uri());
9110 let result: Result<serde_json::Value, _> =
9111 rest_with_retry(&rl, || http.get(&url)).await;
9112 assert!(
9113 matches!(result, Err(UnindexedClientError::Internal(_))),
9114 "{result:?}"
9115 );
9116 }
9117
9118 async fn holds_back(rl: &RateLimitTracker) -> bool {
9120 tokio::time::timeout(Duration::from_millis(50), rl.wait_if_blocked())
9121 .await
9122 .is_err()
9123 }
9124
9125 fn with_rate_limit(
9127 template: ResponseTemplate,
9128 remaining: u32,
9129 reset: bool,
9130 ) -> ResponseTemplate {
9131 let template = template.insert_header("x-ratelimit-remaining", remaining.to_string());
9132 if !reset {
9133 return template;
9134 }
9135 let now_secs = std::time::SystemTime::now()
9136 .duration_since(std::time::UNIX_EPOCH)
9137 .unwrap()
9138 .as_secs();
9139 template.insert_header("x-ratelimit-reset", (now_secs + 30).to_string())
9140 }
9141
9142 #[tokio::test]
9145 async fn rest_with_retry_pauses_once_no_requests_remain() {
9146 for (remaining, reset, pauses) in [(0, true, true), (1, true, false), (0, false, false)]
9147 {
9148 let server = MockServer::start().await;
9149 Mock::given(method("GET"))
9150 .and(path("/v2/account"))
9151 .respond_with(with_rate_limit(
9152 ResponseTemplate::new(200).set_body_json(serde_json::json!({})),
9153 remaining,
9154 reset,
9155 ))
9156 .mount(&server)
9157 .await;
9158
9159 let http = reqwest::Client::new();
9160 let rl = RateLimitTracker::new();
9161 let url = format!("{}/v2/account", server.uri());
9162 let _: serde_json::Value = rest_with_retry(&rl, || http.get(&url)).await.unwrap();
9163 assert_eq!(
9164 holds_back(&rl).await,
9165 pauses,
9166 "remaining {remaining}, reset {reset}"
9167 );
9168 }
9169 }
9170
9171 #[tokio::test]
9174 async fn a_pause_lasts_a_minute_at_most() {
9175 tokio::time::pause();
9176 let now_secs = std::time::SystemTime::now()
9177 .duration_since(std::time::UNIX_EPOCH)
9178 .unwrap()
9179 .as_secs();
9180 let mut headers = reqwest::header::HeaderMap::new();
9181 headers.insert("x-ratelimit-remaining", "0".parse().unwrap());
9182 headers.insert(
9183 "x-ratelimit-reset",
9184 (now_secs + 3_600).to_string().parse().unwrap(),
9185 );
9186 let rl = RateLimitTracker::new();
9187
9188 observe_rate_limit_remaining(&rl, &headers);
9189 assert!(holds_back(&rl).await);
9190 tokio::time::advance(Duration::from_secs(DEFAULT_RATE_LIMIT_DELAY_SECS)).await;
9191 assert!(!holds_back(&rl).await);
9192 }
9193
9194 #[tokio::test]
9196 async fn rest_delete_with_retry_pauses_once_no_requests_remain() {
9197 let server = MockServer::start().await;
9198 Mock::given(method("DELETE"))
9199 .and(path("/v2/orders/abc"))
9200 .respond_with(with_rate_limit(ResponseTemplate::new(204), 0, true))
9201 .mount(&server)
9202 .await;
9203
9204 let http = reqwest::Client::new();
9205 let rl = RateLimitTracker::new();
9206 let url = format!("{}/v2/orders/abc", server.uri());
9207 rest_delete_with_retry(&rl, || http.delete(&url))
9208 .await
9209 .unwrap();
9210 assert!(holds_back(&rl).await);
9211 }
9212
9213 #[tokio::test]
9216 async fn open_order_with_an_undecodable_2xx_response_is_status_unknown() {
9217 use crate::client::ExecutionClient;
9218 use crate::error::OrderError;
9219 use crate::order::request::{OrderRequestOpen, RequestOpen};
9220 use crate::order::state::{InactiveOrderState, OrderState};
9221 use crate::order::{
9222 OrderKey, OrderKind, TimeInForce,
9223 id::{ClientOrderId, StrategyId},
9224 };
9225 use rustrade_instrument::Side;
9226 use rustrade_instrument::exchange::ExchangeId;
9227 use rustrade_instrument::instrument::name::InstrumentNameExchange;
9228
9229 let server = MockServer::start().await;
9230 Mock::given(method("POST"))
9231 .and(path("/v2/orders"))
9232 .respond_with(
9233 ResponseTemplate::new(200).set_body_json(serde_json::json!({ "id": 1 })),
9234 )
9235 .mount(&server)
9236 .await;
9237
9238 let instrument = InstrumentNameExchange::new("AAPL");
9239 let order = client_for(&server)
9240 .open_order(OrderRequestOpen {
9241 key: OrderKey {
9242 exchange: ExchangeId::AlpacaBroker,
9243 instrument: &instrument,
9244 strategy: StrategyId::new("test-strategy"),
9245 cid: ClientOrderId::new("test-cid"),
9246 },
9247 state: RequestOpen::new(
9248 Side::Buy,
9249 None,
9250 Decimal::new(10, 0),
9251 OrderKind::Market,
9252 TimeInForce::ImmediateOrCancel,
9253 ),
9254 })
9255 .await;
9256
9257 assert!(
9258 matches!(
9259 order.state,
9260 OrderState::Inactive(InactiveOrderState::OpenFailed(OrderError::Connectivity(
9261 _
9262 )))
9263 ),
9264 "{:?}",
9265 order.state
9266 );
9267 }
9268
9269 #[tokio::test]
9272 async fn open_order_reports_a_403_by_its_code() {
9273 use crate::client::ExecutionClient;
9274 use crate::error::OrderError;
9275 use crate::order::request::{OrderRequestOpen, RequestOpen};
9276 use crate::order::state::{InactiveOrderState, OrderState};
9277 use crate::order::{
9278 OrderKey, OrderKind, TimeInForce,
9279 id::{ClientOrderId, StrategyId},
9280 };
9281 use rustrade_instrument::Side;
9282 use rustrade_instrument::exchange::ExchangeId;
9283 use rustrade_instrument::instrument::name::InstrumentNameExchange;
9284
9285 async fn open_against(
9286 status: u16,
9287 body: serde_json::Value,
9288 ) -> OrderState<AssetNameExchange, InstrumentNameExchange> {
9289 let server = MockServer::start().await;
9290 Mock::given(method("POST"))
9291 .and(path("/v2/orders"))
9292 .respond_with(ResponseTemplate::new(status).set_body_json(body))
9293 .mount(&server)
9294 .await;
9295 let instrument = InstrumentNameExchange::new("AAPL");
9296 client_for(&server)
9297 .open_order(OrderRequestOpen {
9298 key: OrderKey {
9299 exchange: ExchangeId::AlpacaBroker,
9300 instrument: &instrument,
9301 strategy: StrategyId::new("test-strategy"),
9302 cid: ClientOrderId::new("test-cid"),
9303 },
9304 state: RequestOpen::new(
9305 Side::Sell,
9306 None,
9307 Decimal::new(10, 0),
9308 OrderKind::Market,
9309 TimeInForce::ImmediateOrCancel,
9310 ),
9311 })
9312 .await
9313 .state
9314 }
9315
9316 let rejected =
9317 |state: &OrderState<AssetNameExchange, InstrumentNameExchange>| match state {
9318 OrderState::Inactive(InactiveOrderState::OpenFailed(OrderError::Rejected(
9319 e,
9320 ))) => e.clone(),
9321 other => panic!("expected a rejection, got {other:?}"),
9322 };
9323
9324 let state = open_against(
9325 403,
9326 serde_json::json!({
9327 "code": 40310000,
9328 "message": "insufficient buying power"
9329 }),
9330 )
9331 .await;
9332 assert_eq!(
9333 rejected(&state),
9334 ApiError::BalanceInsufficient(None, "40310000 insufficient buying power".into())
9335 );
9336
9337 let state = open_against(
9338 403,
9339 serde_json::json!({
9340 "code": 40310000,
9341 "message": "account is not allowed to short"
9342 }),
9343 )
9344 .await;
9345 assert_eq!(
9346 rejected(&state),
9347 ApiError::BorrowRejected(BorrowReject::new(
9348 BorrowRejectReason::BorrowDisabled,
9349 Some("40310000".into()),
9350 "account is not allowed to short".into(),
9351 ))
9352 );
9353
9354 let state = open_against(
9356 422,
9357 serde_json::json!({
9358 "code": 42210000,
9359 "message": "asset XYZ cannot be sold short"
9360 }),
9361 )
9362 .await;
9363 assert_eq!(
9364 rejected(&state),
9365 ApiError::BorrowRejected(BorrowReject::new(
9366 BorrowRejectReason::NotShortable,
9367 Some("42210000".into()),
9368 "asset XYZ cannot be sold short".into(),
9369 ))
9370 );
9371
9372 let state = open_against(403, serde_json::json!({ "message": "forbidden." })).await;
9373 assert!(
9374 matches!(rejected(&state), ApiError::Unauthenticated(_)),
9375 "{state:?}"
9376 );
9377 }
9378
9379 #[tokio::test]
9382 async fn open_bracket_order_with_an_undecodable_2xx_response_is_status_unknown() {
9383 use crate::error::OrderError;
9384 use crate::order::state::{InactiveOrderState, OrderState};
9385 use rustrade_instrument::instrument::name::InstrumentNameExchange;
9386
9387 let server = MockServer::start().await;
9388 Mock::given(method("POST"))
9389 .and(path("/v2/orders"))
9390 .respond_with(
9391 ResponseTemplate::new(200).set_body_json(serde_json::json!({ "id": 1 })),
9392 )
9393 .mount(&server)
9394 .await;
9395
9396 let result = client_for(&server)
9397 .open_bracket_order(AlpacaBracketOrderRequest::new(
9398 InstrumentNameExchange::new("SPY"),
9399 crate::order::id::StrategyId::new("test"),
9400 crate::order::id::ClientOrderId::new("test-bracket"),
9401 Side::Buy,
9402 Decimal::ONE,
9403 Decimal::new(100, 0),
9404 Decimal::new(120, 0),
9405 Decimal::new(90, 0),
9406 TimeInForce::GoodUntilCancelled { post_only: false },
9407 ))
9408 .await;
9409
9410 assert!(
9411 matches!(
9412 result.parent.state,
9413 OrderState::Inactive(InactiveOrderState::OpenFailed(OrderError::Connectivity(
9414 _
9415 )))
9416 ),
9417 "{:?}",
9418 result.parent.state
9419 );
9420 }
9421
9422 fn recovery_after() -> DateTime<Utc> {
9431 "2025-01-01T00:00:00Z".parse().expect("valid time")
9432 }
9433
9434 fn activity_json(
9437 id: &str,
9438 order_id: &str,
9439 qty: &str,
9440 cum_qty: Option<&str>,
9441 ) -> serde_json::Value {
9442 let mut v = serde_json::json!({
9443 "id": id,
9444 "order_id": order_id,
9445 "symbol": "SPY",
9446 "side": "buy",
9447 "price": "100.00",
9448 "qty": qty,
9449 "transaction_time": "2025-04-18T14:30:00Z"
9450 });
9451 if let Some(c) = cum_qty {
9452 v["cum_qty"] = serde_json::Value::String(c.to_string());
9453 }
9454 v
9455 }
9456
9457 #[tokio::test(start_paused = true)]
9462 async fn the_reconnect_budget_resets_only_once_a_connection_proves_stable() {
9463 use futures::{SinkExt as _, StreamExt as _};
9464 use tokio_tungstenite::tungstenite::Message;
9465
9466 const STABLE: Duration = Duration::from_secs(HEARTBEAT_TIMEOUT_SECS + 3);
9468 const DROPPED: Duration = Duration::from_millis(100);
9469 let lifetime = |n: usize| match n {
9470 0 | 1 => Some(DROPPED),
9471 2 => Some(STABLE),
9472 _ => None,
9473 };
9474
9475 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
9478 let ws_url = format!("ws://{}", listener.local_addr().unwrap());
9479 let listens = Arc::new(parking_lot::Mutex::new(Vec::new()));
9480 let seen = Arc::clone(&listens);
9481 tokio::spawn(async move {
9482 let mut connections = 0..;
9483 while let Ok((stream, _)) = listener.accept().await {
9484 stream.set_nodelay(true).unwrap();
9487 let seen = Arc::clone(&seen);
9488 let lifetime = connections.next().and_then(lifetime);
9489 tokio::spawn(async move {
9490 let Ok(mut ws) = tokio_tungstenite::accept_async(stream).await else {
9491 return;
9492 };
9493 for answer in [
9494 r#"{"stream":"authorization","data":{"status":"authorized","action":"authenticate"}}"#,
9495 r#"{"stream":"listening","data":{"streams":["trade_updates"]}}"#,
9496 ] {
9497 let Some(Ok(_)) = ws.next().await else {
9498 return;
9499 };
9500 if ws.send(Message::text(answer)).await.is_err() {
9501 return;
9502 }
9503 }
9504 seen.lock().push(tokio::time::Instant::now());
9505 let closes = async {
9506 match lifetime {
9507 Some(after) => tokio::time::sleep(after).await,
9508 None => std::future::pending().await,
9509 }
9510 };
9511 tokio::pin!(closes);
9512 let mut pings = tokio::time::interval(Duration::from_secs(1));
9513 loop {
9514 tokio::select! {
9515 () = &mut closes => {
9516 let _ = ws.close(None).await;
9517 return;
9518 }
9519 _ = pings.tick() => {
9520 if ws.send(Message::Ping(Default::default())).await.is_err() {
9521 return;
9522 }
9523 }
9524 message = ws.next() => {
9525 if !matches!(message, Some(Ok(_))) {
9526 return;
9527 }
9528 }
9529 }
9530 }
9531 });
9532 }
9533 });
9534 let rest_url = crate::client::test_http::serve(|_, _| (404, String::new())).await;
9537 let mut config = AlpacaConfig::with_base_url("key".into(), "secret".into(), rest_url);
9538 config.ws_url_override = Some(ws_url);
9539 let (tx, _rx) = mpsc::unbounded_channel();
9540 tokio::spawn(connection_manager(
9541 tx,
9542 new_dedup_cache(),
9543 Arc::new(config),
9544 reqwest::Client::new(),
9545 Arc::new(RateLimitTracker::new()),
9546 KnownLiveOrders::shared(ExchangeId::AlpacaBroker),
9547 Vec::new(),
9548 None,
9549 ));
9550
9551 let deadline = tokio::time::Instant::now() + STABLE + Duration::from_secs(15);
9552 while listens.lock().len() < 4 {
9553 assert!(
9554 tokio::time::Instant::now() < deadline,
9555 "{} connections",
9556 listens.lock().len()
9557 );
9558 tokio::time::sleep(Duration::from_millis(50)).await;
9559 }
9560
9561 let initial = BackoffLimits::ACCOUNT_STREAM.initial;
9565 let waits: Vec<_> = listens.lock().windows(2).map(|w| w[1] - w[0]).collect();
9566 assert_eq!(
9567 waits,
9568 [DROPPED + initial, DROPPED + 2 * initial, STABLE + initial],
9569 "the waits between listens"
9570 );
9571 }
9572
9573 #[tokio::test(start_paused = true)]
9576 async fn failed_connects_end_the_stream_once_the_budget_is_spent() {
9577 let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
9579 let ws_url = format!("ws://{}", listener.local_addr().unwrap());
9580 drop(listener);
9581 let mut config = AlpacaConfig::paper("key".into(), "secret".into());
9582 config.ws_url_override = Some(ws_url);
9583 let (tx, mut rx) = mpsc::unbounded_channel();
9584 let started = tokio::time::Instant::now();
9585
9586 connection_manager(
9587 tx,
9588 new_dedup_cache(),
9589 Arc::new(config),
9590 reqwest::Client::new(),
9591 Arc::new(RateLimitTracker::new()),
9592 KnownLiveOrders::shared(ExchangeId::AlpacaBroker),
9593 Vec::new(),
9594 None,
9595 )
9596 .await;
9597
9598 let Ok(event) = rx.try_recv() else {
9599 panic!("the stream ended without reporting why");
9600 };
9601 let AccountEventKind::StreamTerminated(
9602 StreamTerminationReason::ReconnectBudgetExhausted {
9603 attempts,
9604 last_error,
9605 },
9606 ) = event.kind
9607 else {
9608 panic!("unexpected event: {event:?}");
9609 };
9610 assert_eq!(attempts, BackoffLimits::ACCOUNT_STREAM.max_attempts);
9611 assert!(
9612 !last_error.is_empty(),
9613 "the last connect failure is reported"
9614 );
9615 assert!(
9617 started.elapsed() >= Duration::from_secs(181),
9618 "{:?}",
9619 started.elapsed()
9620 );
9621 assert!(rx.try_recv().is_err(), "nothing follows the termination");
9622 }
9623
9624 #[tokio::test]
9629 async fn a_reconnect_reads_missed_fills_from_alpacas_time_when_the_host_is_ahead() {
9630 use futures::{SinkExt as _, StreamExt as _};
9631 use tokio_tungstenite::tungstenite::Message;
9632
9633 let stamped = Utc::now() - TimeDelta::seconds(120);
9635 let update = format!(
9636 r#"{{"stream":"trade_updates","data":{{"event":"new","timestamp":"{}","order":{{"id":"id-a","client_order_id":"a","symbol":"SPY","qty":"2","filled_qty":"0","side":"buy","type":"limit","time_in_force":"gtc","limit_price":"100","status":"new"}}}}}}"#,
9637 stamped.to_rfc3339_opts(SecondsFormat::Millis, true)
9638 );
9639
9640 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
9643 let ws_url = format!("ws://{}", listener.local_addr().unwrap());
9644 tokio::spawn(async move {
9645 let mut connections = 0..;
9646 while let Ok((stream, _)) = listener.accept().await {
9647 let first = connections.next() == Some(0);
9648 let update = update.clone();
9649 tokio::spawn(async move {
9650 let Ok(mut ws) = tokio_tungstenite::accept_async(stream).await else {
9651 return;
9652 };
9653 for answer in [
9654 r#"{"stream":"authorization","data":{"status":"authorized","action":"authenticate"}}"#,
9655 r#"{"stream":"listening","data":{"streams":["trade_updates"]}}"#,
9656 ] {
9657 let Some(Ok(_)) = ws.next().await else {
9658 return;
9659 };
9660 if ws.send(Message::text(answer)).await.is_err() {
9661 return;
9662 }
9663 }
9664 if first {
9665 let _ = ws.send(Message::text(update)).await;
9666 let _ = ws.close(None).await;
9667 }
9668 while let Some(Ok(_)) = ws.next().await {}
9669 });
9670 }
9671 });
9672 let rest = MockServer::start().await;
9673 Mock::given(method("GET"))
9674 .and(path("/v2/account/activities"))
9675 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([])))
9676 .mount(&rest)
9677 .await;
9678 let mut config = AlpacaConfig::with_base_url("key".into(), "secret".into(), rest.uri());
9679 config.ws_url_override = Some(ws_url);
9680 let (tx, _rx) = mpsc::unbounded_channel();
9681 let task = tokio::spawn(connection_manager(
9682 tx,
9683 new_dedup_cache(),
9684 Arc::new(config),
9685 reqwest::Client::new(),
9686 Arc::new(RateLimitTracker::new()),
9687 KnownLiveOrders::shared(ExchangeId::AlpacaBroker),
9688 Vec::new(),
9689 None,
9690 ));
9691
9692 let after = tokio::time::timeout(Duration::from_secs(10), async {
9694 loop {
9695 let requests = rest.received_requests().await.unwrap_or_default();
9696 if let Some(after) = requests
9697 .iter()
9698 .filter(|request| request.url.path() == "/v2/account/activities")
9699 .find_map(|request| {
9700 request
9701 .url
9702 .query_pairs()
9703 .find(|(key, _)| key == "after")
9704 .map(|(_, after)| after.into_owned())
9705 })
9706 {
9707 break after;
9708 }
9709 tokio::time::sleep(Duration::from_millis(20)).await;
9710 }
9711 })
9712 .await
9713 .expect("the reconnect reads the missed fills");
9714 let after = DateTime::parse_from_rfc3339(&after)
9715 .unwrap()
9716 .with_timezone(&Utc);
9717
9718 let lookback = TimeDelta::milliseconds(SIGNAL_RECOVERY_LOOKBACK_MS);
9720 assert!(
9721 after <= stamped + TimeDelta::seconds(1) - lookback,
9722 "{after}"
9723 );
9724 assert!(
9725 after >= stamped - TimeDelta::seconds(1) - lookback,
9726 "{after}"
9727 );
9728
9729 task.abort();
9730 }
9731
9732 async fn drive_recover_fills_with(
9735 activities: Vec<serde_json::Value>,
9736 dedup: SharedDedupCache,
9737 known: &SharedKnownLiveOrders,
9738 ) -> Vec<UnindexedAccountEvent> {
9739 let server = MockServer::start().await;
9740 Mock::given(method("GET"))
9741 .and(path("/v2/account/activities"))
9742 .respond_with(
9743 ResponseTemplate::new(200).set_body_json(serde_json::Value::Array(activities)),
9744 )
9745 .mount(&server)
9746 .await;
9747
9748 let http = reqwest::Client::new();
9749 let rl = RateLimitTracker::new();
9750 let (tx, mut rx) = mpsc::unbounded_channel();
9751 let outcome = recover_fills(
9752 &http,
9753 &rl,
9754 &[],
9755 &server.uri(),
9756 recovery_after(),
9757 &tx,
9758 &dedup,
9759 known,
9760 )
9761 .await;
9762 assert_eq!(outcome, Ok(()), "a full read leaves nothing unrecovered");
9763 drop(tx);
9764
9765 let mut out = Vec::new();
9766 while let Ok(event) = rx.try_recv() {
9767 out.push(event);
9768 }
9769 out
9770 }
9771
9772 async fn drive_recover_fills(
9773 activities: Vec<serde_json::Value>,
9774 ) -> Vec<UnindexedAccountEvent> {
9775 drive_recover_fills_with(
9776 activities,
9777 new_dedup_cache(),
9778 &KnownLiveOrders::shared(ExchangeId::AlpacaBroker),
9779 )
9780 .await
9781 }
9782
9783 async fn recover_fills_from(
9786 server: &MockServer,
9787 ) -> (Result<(), UnreadFills>, Vec<UnindexedAccountEvent>) {
9788 let (tx, mut rx) = mpsc::unbounded_channel();
9789 let outcome = recover_fills(
9790 &reqwest::Client::new(),
9791 &RateLimitTracker::new(),
9792 &[],
9793 &server.uri(),
9794 recovery_after(),
9795 &tx,
9796 &new_dedup_cache(),
9797 &KnownLiveOrders::shared(ExchangeId::AlpacaBroker),
9798 )
9799 .await;
9800 drop(tx);
9801 (outcome, std::iter::from_fn(|| rx.try_recv().ok()).collect())
9802 }
9803
9804 #[tokio::test]
9807 async fn a_failed_recovery_read_leaves_every_fill_since_the_disconnect_unread() {
9808 let server = MockServer::start().await;
9809 Mock::given(method("GET"))
9810 .and(path("/v2/account/activities"))
9811 .respond_with(ResponseTemplate::new(500).set_body_string("unavailable"))
9812 .mount(&server)
9813 .await;
9814
9815 let (outcome, events) = recover_fills_from(&server).await;
9816
9817 let Err(UnreadFills {
9818 start,
9819 reason: FillRecoveryFailure::Request(_),
9820 }) = outcome
9821 else {
9822 panic!("expected the request's failure, got {outcome:?}");
9823 };
9824 assert_eq!(start, recovery_after(), "from the disconnect");
9825 assert!(events.is_empty(), "nothing forwarded: {events:?}");
9826 }
9827
9828 #[tokio::test]
9833 async fn a_truncated_recovery_read_delivers_what_it_read_and_leaves_the_rest_unread() {
9834 let time = |s: &str| s.parse::<DateTime<Utc>>().expect("valid time");
9835 let cases = [
9837 (
9838 Some("2025-04-18T14:30:01.234567Z"),
9839 time("2025-04-18T14:30:01.234Z"),
9840 ),
9841 (Some("not a time"), time("2025-04-18T14:30:00Z")),
9842 (None, recovery_after()),
9843 ];
9844 for (last_time, expected_start) in cases {
9845 let mut page = make_activities_json(ALPACA_MAX_ACTIVITIES, "act");
9846 let Some(activities) = page.as_array_mut() else {
9847 panic!("an array of activities");
9848 };
9849 match last_time {
9850 Some(last_time) => {
9851 if let Some(last) = activities.last_mut() {
9852 last["transaction_time"] =
9853 serde_json::Value::String(last_time.to_string());
9854 }
9855 }
9856 None => {
9857 for activity in activities.iter_mut() {
9858 activity["transaction_time"] =
9859 serde_json::Value::String("not a time".to_string());
9860 }
9861 }
9862 }
9863 let server = MockServer::start().await;
9864 Mock::given(method("GET"))
9865 .and(path("/v2/account/activities"))
9866 .respond_with(ResponseTemplate::new(200).set_body_json(page))
9867 .mount(&server)
9868 .await;
9869
9870 let (outcome, events) = recover_fills_from(&server).await;
9871
9872 let fills_read = MAX_ACTIVITY_PAGES * ALPACA_MAX_ACTIVITIES;
9873 assert_eq!(
9874 outcome,
9875 Err(UnreadFills {
9876 start: expected_start,
9877 reason: FillRecoveryFailure::Truncated { fills_read },
9878 }),
9879 "last time {last_time:?}"
9880 );
9881 assert_eq!(events.len(), fills_read, "every fill read is forwarded");
9882 }
9883 }
9884
9885 #[test]
9888 fn a_fill_recovery_give_up_covers_every_instrument_the_stream_does() {
9889 let start = recovery_after();
9890 let end = start + chrono::Duration::minutes(5);
9891 let spy = InstrumentNameExchange::new("SPY");
9892 let qqq = InstrumentNameExchange::new("QQQ");
9893 let cases = [
9894 (vec![], FillRecoveryScope::AllInstruments),
9895 (
9896 vec![spy.clone(), qqq.clone()],
9897 FillRecoveryScope::Instruments(vec![spy, qqq]),
9898 ),
9899 ];
9900 for (instruments, scope) in cases {
9901 let reason = FillRecoveryFailure::TimedOut { timeout_secs: 30 };
9902 let unread = UnreadFills {
9903 start,
9904 reason: reason.clone(),
9905 };
9906 assert_eq!(
9907 fill_recovery_gave_up(&instruments, unread, end),
9908 UnindexedAccountEvent::new(
9909 ExchangeId::AlpacaBroker,
9910 AccountEventKind::FillRecoveryGaveUp(FillRecoveryGap::new(
9911 scope, start, end, 1, reason,
9912 )),
9913 )
9914 );
9915 }
9916 }
9917
9918 #[test]
9922 fn a_read_that_reached_past_the_recovery_start_still_reports() {
9923 let end = recovery_after();
9924 let start = end + chrono::Duration::seconds(1);
9925 let unread = UnreadFills {
9926 start,
9927 reason: FillRecoveryFailure::Truncated { fills_read: 5_000 },
9928 };
9929 let event = fill_recovery_gave_up(&[], unread, end);
9930 let AccountEventKind::FillRecoveryGaveUp(gave_up) = event.kind else {
9931 panic!("expected FillRecoveryGaveUp, got {event:?}");
9932 };
9933 assert_eq!((gave_up.start, gave_up.end), (start, start));
9934 }
9935
9936 async fn recover_fills_or_report_from(
9939 server: &MockServer,
9940 instruments: &[InstrumentNameExchange],
9941 timeout: Duration,
9942 ) -> Vec<UnindexedAccountEvent> {
9943 let (tx, mut rx) = mpsc::unbounded_channel();
9944 recover_fills_or_report(
9945 &reqwest::Client::new(),
9946 &RateLimitTracker::new(),
9947 instruments,
9948 &server.uri(),
9949 recovery_after(),
9950 Utc::now(),
9951 timeout,
9952 &tx,
9953 &new_dedup_cache(),
9954 &KnownLiveOrders::shared(ExchangeId::AlpacaBroker),
9955 )
9956 .await;
9957 drop(tx);
9958 std::iter::from_fn(|| rx.try_recv().ok()).collect()
9959 }
9960
9961 #[tokio::test]
9964 async fn a_timed_out_recovery_reports_every_fill_since_the_disconnect() {
9965 let server = MockServer::start().await;
9966 Mock::given(method("GET"))
9967 .and(path("/v2/account/activities"))
9968 .respond_with(
9969 ResponseTemplate::new(200)
9970 .set_body_json(serde_json::json!([]))
9971 .set_delay(Duration::from_secs(3_600)),
9972 )
9973 .mount(&server)
9974 .await;
9975 let spy = InstrumentNameExchange::new("SPY");
9976
9977 let events = recover_fills_or_report_from(
9978 &server,
9979 std::slice::from_ref(&spy),
9980 Duration::from_millis(50),
9981 )
9982 .await;
9983
9984 let [event] = events.as_slice() else {
9985 panic!("only the give-up is sent: {events:?}");
9986 };
9987 let AccountEventKind::FillRecoveryGaveUp(gave_up) = &event.kind else {
9988 panic!("expected FillRecoveryGaveUp, got {event:?}");
9989 };
9990 assert_eq!(gave_up.scope, FillRecoveryScope::Instruments(vec![spy]));
9991 assert_eq!(gave_up.start, recovery_after(), "from the disconnect");
9992 assert!(gave_up.end >= gave_up.start);
9993 assert_eq!(gave_up.attempts, 1);
9994 assert!(
9995 matches!(gave_up.reason, FillRecoveryFailure::TimedOut { .. }),
9996 "{:?}",
9997 gave_up.reason
9998 );
9999 }
10000
10001 #[tokio::test]
10004 async fn a_truncated_recovery_reports_after_the_fills_it_read() {
10005 let server = MockServer::start().await;
10006 Mock::given(method("GET"))
10007 .and(path("/v2/account/activities"))
10008 .respond_with(
10009 ResponseTemplate::new(200)
10010 .set_body_json(make_activities_json(ALPACA_MAX_ACTIVITIES, "act")),
10011 )
10012 .mount(&server)
10013 .await;
10014
10015 let events = recover_fills_or_report_from(&server, &[], Duration::from_secs(30)).await;
10016
10017 let Some((last, fills)) = events.split_last() else {
10018 panic!("events are sent");
10019 };
10020 assert_eq!(fills.len(), MAX_ACTIVITY_PAGES * ALPACA_MAX_ACTIVITIES);
10021 assert!(
10022 fills
10023 .iter()
10024 .all(|event| matches!(event.kind, AccountEventKind::Trade(_))),
10025 "every fill comes first"
10026 );
10027 let AccountEventKind::FillRecoveryGaveUp(gave_up) = &last.kind else {
10028 panic!("the give-up comes last, got {last:?}");
10029 };
10030 assert_eq!(gave_up.scope, FillRecoveryScope::AllInstruments);
10031 assert!(matches!(
10032 gave_up.reason,
10033 FillRecoveryFailure::Truncated { .. }
10034 ));
10035 }
10036
10037 #[test]
10040 fn an_activity_id_names_its_execution_after_the_separator() {
10041 assert_eq!(
10042 activity_execution_id("20261005041849348::524b1902-817e-446c-825b-a9fcfebbc17e"),
10043 "524b1902-817e-446c-825b-a9fcfebbc17e"
10044 );
10045 assert_eq!(activity_execution_id("act-1"), "act-1");
10046 assert_eq!(
10047 activity_execution_id("20261005041849348::"),
10048 "20261005041849348::"
10049 );
10050 }
10051
10052 #[tokio::test]
10056 async fn a_fill_has_one_trade_id_on_the_stream_in_recovery_and_from_fetch_trades() {
10057 use crate::client::ExecutionClient;
10058
10059 let execution_id = "524b1902-817e-446c-825b-a9fcfebbc17e";
10060 let activity = activity_json(
10061 &format!("20250418103000000::{execution_id}"),
10062 "ord-1",
10063 "2",
10064 Some("2"),
10065 );
10066
10067 let [streamed, _snapshot] = convert_trade_update(AlpacaTradeUpdate {
10068 event: SmolStr::new("fill"),
10069 execution_id: Some(execution_id),
10070 previous_execution_id: None,
10071 order: super::make_order_ws("ord-1", "SPY", "buy", "2"),
10072 price: Some("100.00"),
10073 qty: Some("2"),
10074 timestamp: Some("2025-04-18T14:30:00Z"),
10075 });
10076 let Some(streamed) = streamed else {
10077 panic!("a fill converts to a Trade");
10078 };
10079
10080 let recovered = drive_recover_fills(vec![activity.clone()]).await;
10081 let [recovered] = recovered.as_slice() else {
10082 panic!("one fill recovered: {recovered:?}");
10083 };
10084
10085 let server = MockServer::start().await;
10086 Mock::given(method("GET"))
10087 .and(path("/v2/account/activities"))
10088 .respond_with(
10089 ResponseTemplate::new(200)
10090 .set_body_json(serde_json::Value::Array(vec![activity])),
10091 )
10092 .mount(&server)
10093 .await;
10094 let Ok(fetched) = client_for(&server)
10095 .fetch_trades(recovery_after(), Utc::now(), &[])
10096 .await
10097 else {
10098 panic!("fetch_trades reads the activity");
10099 };
10100 let [fetched] = fetched.trades.as_slice() else {
10101 panic!("one fill fetched: {fetched:?}");
10102 };
10103
10104 assert_eq!(trade_of(&streamed).id.0.as_str(), execution_id);
10105 assert_eq!(trade_of(recovered).id.0.as_str(), execution_id);
10106 assert_eq!(fetched.id.0.as_str(), execution_id);
10107 }
10108
10109 fn time(s: &str) -> DateTime<Utc> {
10114 s.parse().expect("valid time")
10115 }
10116
10117 struct ActivitiesVenue {
10122 activities: Vec<serde_json::Value>,
10123 }
10124
10125 impl Respond for ActivitiesVenue {
10126 fn respond(&self, request: &Request) -> ResponseTemplate {
10127 let query: std::collections::HashMap<String, String> =
10128 request.url.query_pairs().into_owned().collect();
10129 let bound = |name: &str| query.get(name).map(|s| time(s));
10130 let (after, until) = (bound("after"), bound("until"));
10131 let size: usize = query
10132 .get("page_size")
10133 .and_then(|s| s.parse().ok())
10134 .unwrap_or(ALPACA_MAX_ACTIVITIES);
10135 let in_bounds = self.activities.iter().filter(|activity| {
10136 let at =
10137 floor_millis(time(activity["transaction_time"].as_str().expect("a time")));
10138 after.is_none_or(|after| at >= after) && until.is_none_or(|until| at < until)
10139 });
10140 let page: Vec<_> = match query.get("page_token") {
10141 Some(token) => in_bounds
10142 .skip_while(|activity| activity["id"] != token.as_str())
10143 .skip(1)
10144 .take(size)
10145 .cloned()
10146 .collect(),
10147 None => in_bounds.take(size).cloned().collect(),
10148 };
10149 ResponseTemplate::new(200).set_body_json(page)
10150 }
10151 }
10152
10153 fn fill_at(id: &str, symbol: &str, transaction_time: DateTime<Utc>) -> serde_json::Value {
10155 serde_json::json!({
10156 "id": id,
10157 "order_id": format!("ord-{id}"),
10158 "symbol": symbol,
10159 "side": "buy",
10160 "price": "100.00",
10161 "qty": "1",
10162 "cum_qty": "1",
10163 "transaction_time": transaction_time.to_rfc3339_opts(SecondsFormat::Micros, true),
10164 })
10165 }
10166
10167 async fn serve_activities(activities: Vec<serde_json::Value>) -> MockServer {
10168 let server = MockServer::start().await;
10169 Mock::given(method("GET"))
10170 .and(path("/v2/account/activities"))
10171 .respond_with(ActivitiesVenue { activities })
10172 .mount(&server)
10173 .await;
10174 server
10175 }
10176
10177 fn non_trade(
10180 id: &str,
10181 activity_type: &str,
10182 sub_type: Option<&str>,
10183 net_amount: &str,
10184 created_at: &str,
10185 ) -> serde_json::Value {
10186 let mut activity = serde_json::json!({
10187 "id": id, "activity_type": activity_type, "net_amount": net_amount,
10188 "created_at": created_at, "date": &created_at[..10], "currency": "USD",
10189 "status": "executed",
10190 });
10191 if let Some(sub_type) = sub_type {
10192 activity["activity_sub_type"] = serde_json::json!(sub_type);
10193 }
10194 activity
10195 }
10196
10197 async fn query_pairs(server: &MockServer) -> Vec<Vec<(String, String)>> {
10199 let Some(requests) = server.received_requests().await else {
10200 panic!("requests are recorded");
10201 };
10202 requests
10203 .iter()
10204 .map(|request| request.url.query_pairs().into_owned().collect())
10205 .collect()
10206 }
10207
10208 #[tokio::test]
10209 async fn fetch_cash_flows_maps_each_activity_type_and_leaves_out_what_it_cannot_use() {
10210 let at = "2025-04-18T05:00:00Z";
10211 let mut fee = non_trade("fee", "FEE", Some("TAF"), "-0.02", at);
10212 fee["symbol"] = serde_json::json!("SPY");
10213 let mut canceled = non_trade("canceled", "FEE", Some("REG"), "-0.01", at);
10214 canceled["status"] = serde_json::json!("canceled");
10215 let mut no_amount = non_trade("no-amount", "FEE", None, "0", at);
10216 no_amount.as_object_mut().unwrap().remove("net_amount");
10217 let mut no_created_at = non_trade("no-time", "PTC", None, "-0.50", at);
10218 no_created_at.as_object_mut().unwrap().remove("created_at");
10219 let mut bad_time = non_trade("bad-time", "PTC", None, "-0.50", at);
10220 bad_time["created_at"] = serde_json::json!("not-a-time");
10221 let mut corrected = non_trade("correct", "PTC", None, "-0.40", at);
10222 corrected["status"] = serde_json::json!("correct");
10223 let mut euro = non_trade("euro", "PTR", None, "0.30", at);
10224 euro["currency"] = serde_json::json!("EUR");
10225 let server = MockServer::start().await;
10226 Mock::given(method("GET"))
10227 .and(path("/v2/account/activities"))
10228 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([
10229 non_trade("mgn", "INT", Some("MGN"), "-12.50", at),
10230 non_trade("int-paid", "INT", None, "-3.00", at),
10231 non_trade("int-credit", "INT", Some("CDT"), "1.00", at),
10232 fee,
10233 canceled,
10234 no_amount,
10235 no_created_at,
10236 bad_time,
10237 corrected,
10238 euro,
10239 non_trade("late", "FEE", None, "-0.01", "2025-04-20T05:00:00Z"),
10240 ])))
10241 .mount(&server)
10242 .await;
10243 let client = client_for(&server);
10244
10245 let read = client
10246 .fetch_cash_flows(time("2025-04-18T00:00:00Z"), time("2025-04-19T00:00:00Z"))
10247 .await
10248 .unwrap();
10249
10250 assert_eq!(read.resume, None);
10251 let kinds: Vec<_> = read
10252 .flows
10253 .iter()
10254 .map(|flow| (flow.id.clone().unwrap().0.to_string(), flow.kind.clone()))
10255 .collect();
10256 let interest = CashFlowKind::MarginInterest {
10257 rate: None,
10258 principal: None,
10259 timing: None,
10260 };
10261 assert_eq!(
10262 kinds,
10263 [
10264 ("mgn", interest.clone()),
10265 ("int-paid", interest),
10266 ("int-credit", CashFlowKind::Other("INT.CDT".into())),
10267 ("fee", CashFlowKind::Other("FEE.TAF".into())),
10268 ("correct", CashFlowKind::Other("PTC".into())),
10269 ("euro", CashFlowKind::Rebate),
10270 ]
10271 .map(|(id, kind)| (id.to_owned(), kind))
10272 );
10273 let by_id = |id: &str| {
10274 read.flows
10275 .iter()
10276 .find(|flow| flow.id == Some(CashFlowId::new(id)))
10277 .unwrap()
10278 };
10279 assert_eq!(by_id("mgn").amount, Decimal::new(-1250, 2));
10280 assert_eq!(by_id("mgn").asset, AssetNameExchange::new("USD"));
10281 assert_eq!(by_id("mgn").instrument, None);
10282 assert_eq!(by_id("mgn").time_exchange, time(at));
10283 assert_eq!(
10284 by_id("fee").instrument,
10285 Some(InstrumentNameExchange::new("SPY"))
10286 );
10287 assert_eq!(
10288 by_id("correct").amount,
10289 Decimal::new(-40, 2),
10290 "a correction is returned as it is"
10291 );
10292 assert_eq!(by_id("euro").asset, AssetNameExchange::new("EUR"));
10293
10294 let queries = query_pairs(&server).await;
10295 let [query] = queries.as_slice() else {
10296 panic!("one request: {queries:?}");
10297 };
10298 assert!(
10299 query.contains(&("activity_types".into(), "INT,FEE,PTC,PTR".into())),
10300 "{query:?}"
10301 );
10302 }
10303
10304 #[tokio::test]
10305 async fn fetch_trades_asks_for_fills_by_the_documented_parameter() {
10306 use crate::client::ExecutionClient;
10307
10308 let server = serve_activities(Vec::new()).await;
10309 let client = client_for(&server);
10310
10311 let read = client
10312 .fetch_trades(
10313 time("2025-04-18T00:00:00Z"),
10314 time("2025-04-19T00:00:00Z"),
10315 &[],
10316 )
10317 .await
10318 .unwrap();
10319
10320 assert!(read.trades.is_empty());
10321 let queries = query_pairs(&server).await;
10322 let [query] = queries.as_slice() else {
10323 panic!("one request: {queries:?}");
10324 };
10325 assert!(
10326 query.contains(&("activity_types".into(), "FILL".into())),
10327 "{query:?}"
10328 );
10329 assert!(
10330 !query.iter().any(|(name, _)| name == "activity_type"),
10331 "{query:?}"
10332 );
10333 }
10334
10335 #[tokio::test]
10336 async fn fetch_cash_flows_reads_nothing_for_an_empty_span() {
10337 let server = MockServer::start().await;
10338 let client = client_for(&server);
10339
10340 let read = client
10341 .fetch_cash_flows(time("2025-04-19T00:00:00Z"), time("2025-04-18T00:00:00Z"))
10342 .await
10343 .unwrap();
10344
10345 assert!(read.flows.is_empty() && read.resume.is_none());
10346 assert!(query_pairs(&server).await.is_empty());
10347 }
10348
10349 #[tokio::test]
10352 async fn fetch_cash_flows_that_stops_at_its_bound_says_where_to_read_on() {
10353 let base = time("2025-04-18T05:00:00Z");
10354 let activities: Vec<_> = (0..6_000_i64)
10355 .map(|n| {
10356 let created =
10357 (base + TimeDelta::seconds(n)).to_rfc3339_opts(SecondsFormat::Micros, true);
10358 non_trade(&format!("{n:017}::fee"), "FEE", None, "-0.01", &created)
10359 })
10360 .collect();
10361 struct Paged(Vec<serde_json::Value>);
10363 impl Respond for Paged {
10364 fn respond(&self, request: &Request) -> ResponseTemplate {
10365 let token = request
10366 .url
10367 .query_pairs()
10368 .find(|(name, _)| name == "page_token")
10369 .map(|(_, value)| value.into_owned());
10370 let page: Vec<_> = self
10371 .0
10372 .iter()
10373 .skip_while(|activity| {
10374 token
10375 .as_deref()
10376 .is_some_and(|token| activity["id"] != token)
10377 })
10378 .skip(usize::from(token.is_some()))
10379 .take(ALPACA_MAX_ACTIVITIES)
10380 .cloned()
10381 .collect();
10382 ResponseTemplate::new(200).set_body_json(page)
10383 }
10384 }
10385 let server = MockServer::start().await;
10386 Mock::given(method("GET"))
10387 .and(path("/v2/account/activities"))
10388 .respond_with(Paged(activities))
10389 .mount(&server)
10390 .await;
10391 let client = client_for(&server);
10392
10393 let read = client
10394 .fetch_cash_flows(base, base + TimeDelta::days(1))
10395 .await
10396 .unwrap();
10397
10398 assert_eq!(read.flows.len(), 5_000);
10399 assert_eq!(read.resume, Some(base + TimeDelta::seconds(4_999)));
10400 }
10401
10402 #[tokio::test]
10405 async fn fetch_cash_flows_that_cannot_advance_fails() {
10406 let at = "2025-04-18T05:00:00Z";
10407 let full: Vec<_> = (0..ALPACA_MAX_ACTIVITIES)
10408 .map(|n| non_trade(&format!("fee-{n}"), "FEE", None, "-0.01", at))
10409 .collect();
10410 let server = MockServer::start().await;
10411 Mock::given(method("GET"))
10412 .and(path("/v2/account/activities"))
10413 .respond_with(ResponseTemplate::new(200).set_body_json(full))
10414 .mount(&server)
10415 .await;
10416 let client = client_for(&server);
10417
10418 let result = client
10419 .fetch_cash_flows(time(at), time("2025-04-19T00:00:00Z"))
10420 .await;
10421
10422 assert!(
10423 matches!(result, Err(UnindexedClientError::Internal(_))),
10424 "{result:?}"
10425 );
10426 }
10427
10428 fn option_activity(
10431 id: &str,
10432 activity_type: &str,
10433 symbol: &str,
10434 qty: &str,
10435 date: &str,
10436 ) -> serde_json::Value {
10437 serde_json::json!({
10438 "id": id, "activity_type": activity_type, "symbol": symbol, "qty": qty,
10439 "date": date, "net_amount": "0", "status": "executed",
10440 })
10441 }
10442
10443 fn share_leg(
10445 id: &str,
10446 symbol: &str,
10447 qty: &str,
10448 price: &str,
10449 date: &str,
10450 ) -> serde_json::Value {
10451 let mut activity = option_activity(id, "OPTRD", symbol, qty, date);
10452 activity["price"] = serde_json::json!(price);
10453 activity
10454 }
10455
10456 async fn serve_json(activities: serde_json::Value) -> MockServer {
10457 let server = MockServer::start().await;
10458 Mock::given(method("GET"))
10459 .and(path("/v2/account/activities"))
10460 .respond_with(ResponseTemplate::new(200).set_body_json(activities))
10461 .mount(&server)
10462 .await;
10463 server
10464 }
10465
10466 #[test]
10467 fn occ_contract_reads_the_root_kind_and_strike() {
10468 assert_eq!(
10469 occ_contract("QS240301C00006500"),
10470 Some(("QS", true, Decimal::new(65, 1)))
10471 );
10472 assert_eq!(
10473 occ_contract("AAPL240119P00150000"),
10474 Some(("AAPL", false, Decimal::from(150)))
10475 );
10476 for not_occ in [
10477 "QS",
10478 "240301C00006500",
10479 "QS240301X00006500",
10480 "QS2403O1C00006500",
10481 ] {
10482 assert_eq!(occ_contract(not_occ), None, "{not_occ}");
10483 }
10484 }
10485
10486 #[tokio::test]
10487 async fn fetch_option_events_maps_each_activity_and_pairs_the_share_legs() {
10488 let day = "2024-03-01";
10489 let mut expiry = option_activity("exp", "OPEXP", "QS240301C00010500", "-1", day);
10490 expiry["created_at"] = serde_json::json!("2024-03-01T21:30:00Z");
10491 let mut canceled = option_activity("canceled", "OPEXP", "QS240301P00000500", "-1", day);
10492 canceled["status"] = serde_json::json!("canceled");
10493 let server = serve_json(serde_json::json!([
10494 expiry,
10495 option_activity("exc", "OPEXC", "QS240301C00006500", "-1", day),
10496 share_leg("exc-shares", "QS", "100", "6.5", day),
10497 option_activity("asn", "OPASN", "QS240301P00009000", "1", day),
10498 share_leg("asn-shares", "QS", "100", "9", day),
10499 share_leg("orphan", "QS", "-100", "7", day),
10500 canceled,
10501 option_activity("no-price", "OPTRD", "QS", "100", day),
10502 option_activity("no-qty", "OPEXP", "QS240301C00011000", "0", day),
10503 option_activity("later", "OPEXP", "QS240308C00010500", "-1", "2024-03-02"),
10504 ]))
10505 .await;
10506 let client = client_for(&server);
10507
10508 let read = client
10509 .fetch_option_events(time("2024-03-01T00:00:00Z"), time("2024-03-01T23:00:00Z"))
10510 .await
10511 .unwrap();
10512
10513 assert_eq!(read.resume, None);
10514 let summary: Vec<_> = read
10515 .trades
10516 .iter()
10517 .map(|trade| {
10518 (
10519 trade.id.0.to_string(),
10520 trade.instrument.name().to_string(),
10521 trade.origin,
10522 trade.side,
10523 trade.price,
10524 trade.quantity,
10525 )
10526 })
10527 .collect();
10528 let row = |id: &str, symbol: &str, origin, side, price: Decimal, quantity: i64| {
10529 (
10530 id.to_owned(),
10531 symbol.to_owned(),
10532 origin,
10533 side,
10534 price,
10535 Decimal::from(quantity),
10536 )
10537 };
10538 assert_eq!(
10539 summary,
10540 [
10541 row(
10542 "exp",
10543 "QS240301C00010500",
10544 TradeOrigin::Expiry,
10545 Side::Sell,
10546 Decimal::ZERO,
10547 1
10548 ),
10549 row(
10550 "exc",
10551 "QS240301C00006500",
10552 TradeOrigin::Exercise,
10553 Side::Sell,
10554 Decimal::ZERO,
10555 1
10556 ),
10557 row(
10558 "exc-shares",
10559 "QS",
10560 TradeOrigin::Exercise,
10561 Side::Buy,
10562 Decimal::new(65, 1),
10563 100
10564 ),
10565 row(
10566 "asn",
10567 "QS240301P00009000",
10568 TradeOrigin::Assignment,
10569 Side::Buy,
10570 Decimal::ZERO,
10571 1
10572 ),
10573 row(
10574 "asn-shares",
10575 "QS",
10576 TradeOrigin::Assignment,
10577 Side::Buy,
10578 Decimal::from(9),
10579 100
10580 ),
10581 row(
10582 "orphan",
10583 "QS",
10584 TradeOrigin::External,
10585 Side::Sell,
10586 Decimal::from(7),
10587 100
10588 ),
10589 ]
10590 );
10591 let exp = &read.trades[0];
10592 assert_eq!(
10593 exp.time_exchange,
10594 time("2024-03-01T21:30:00Z"),
10595 "created_at"
10596 );
10597 assert_eq!(exp.order_id, OrderId::new("exp"));
10598 assert_eq!(exp.fees.fees, Decimal::ZERO);
10599 assert_eq!(
10600 read.trades[1].time_exchange,
10601 time("2024-03-01T00:00:00Z"),
10602 "no created_at: the start of its date"
10603 );
10604
10605 let queries = query_pairs(&server).await;
10606 let [query] = queries.as_slice() else {
10607 panic!("one request: {queries:?}");
10608 };
10609 assert!(
10610 query.contains(&("activity_types".into(), "OPASN,OPEXC,OPEXP,OPTRD".into())),
10611 "{query:?}"
10612 );
10613 }
10614
10615 #[tokio::test]
10618 async fn fetch_option_events_pairs_a_share_leg_by_its_side() {
10619 let day = "2024-03-01";
10620 let server = serve_json(serde_json::json!([
10621 option_activity("exc-call", "OPEXC", "QS240301C00006500", "-1", day),
10622 option_activity("asn-call", "OPASN", "QS240301C00006500", "1", day),
10623 share_leg("sold", "QS", "-100", "6.5", day),
10624 share_leg("bought", "QS", "100", "6.5", day),
10625 share_leg("bought-again", "QS", "100", "6.5", day),
10626 share_leg("other-day", "QS", "100", "6.5", "2024-02-29"),
10627 ]))
10628 .await;
10629 let client = client_for(&server);
10630
10631 let read = client
10632 .fetch_option_events(time("2024-02-29T00:00:00Z"), time("2024-03-01T23:00:00Z"))
10633 .await
10634 .unwrap();
10635
10636 let origin = |id: &str| {
10637 read.trades
10638 .iter()
10639 .find(|trade| trade.id == TradeId::new(id))
10640 .map(|trade| trade.origin)
10641 };
10642 assert_eq!(origin("sold"), Some(TradeOrigin::Assignment));
10643 assert_eq!(origin("bought"), Some(TradeOrigin::Exercise));
10644 assert_eq!(origin("bought-again"), Some(TradeOrigin::External));
10645 assert_eq!(origin("other-day"), Some(TradeOrigin::External));
10646 }
10647
10648 #[tokio::test]
10652 async fn fetch_option_events_pairs_a_share_leg_by_its_size() {
10653 let day = "2024-03-01";
10654 let server = serve_json(serde_json::json!([
10655 option_activity("asn-two", "OPASN", "QS240301P00006500", "2", day),
10657 option_activity("exc-one", "OPEXC", "QS240301C00006500", "-1", day),
10658 option_activity("exc-adjusted", "OPEXC", "QS240301C00006500", "-1", day),
10659 share_leg("hundred", "QS", "100", "6.5", day),
10660 share_leg("two-hundred", "QS", "200", "6.5", day),
10661 share_leg("hundred-fifty", "QS", "150", "6.5", day),
10662 ]))
10663 .await;
10664 let client = client_for(&server);
10665
10666 let read = client
10667 .fetch_option_events(time("2024-03-01T00:00:00Z"), time("2024-03-01T23:00:00Z"))
10668 .await
10669 .unwrap();
10670
10671 let origin = |id: &str| {
10672 read.trades
10673 .iter()
10674 .find(|trade| trade.id == TradeId::new(id))
10675 .map(|trade| trade.origin)
10676 };
10677 assert_eq!(origin("hundred"), Some(TradeOrigin::Exercise));
10678 assert_eq!(origin("two-hundred"), Some(TradeOrigin::Assignment));
10679 assert_eq!(origin("hundred-fifty"), Some(TradeOrigin::Exercise));
10680 }
10681
10682 #[tokio::test]
10686 async fn fetch_option_events_reads_whole_days_and_returns_the_span() {
10687 let mut early =
10688 option_activity("early", "OPEXP", "QS240301C00010500", "-1", "2024-03-01");
10689 early["created_at"] = serde_json::json!("2024-03-01T10:00:00Z");
10690 let mut inside =
10691 option_activity("inside", "OPEXP", "QS240301C00011500", "-1", "2024-03-01");
10692 inside["created_at"] = serde_json::json!("2024-03-01T16:00:00Z");
10693 let server = serve_json(serde_json::json!([
10694 option_activity(
10695 "dated-before",
10696 "OPEXP",
10697 "QS240229C00010500",
10698 "-1",
10699 "2024-02-29"
10700 ),
10701 early,
10702 option_activity("dated", "OPEXP", "QS240301C00012500", "-1", "2024-03-01"),
10703 inside,
10704 ]))
10705 .await;
10706 let client = client_for(&server);
10707
10708 let read = client
10709 .fetch_option_events(time("2024-03-01T15:00:00Z"), time("2024-03-01T17:00:00Z"))
10710 .await
10711 .unwrap();
10712
10713 let ids: Vec<_> = read
10714 .trades
10715 .iter()
10716 .map(|trade| trade.id.0.to_string())
10717 .collect();
10718 assert_eq!(ids, ["dated", "inside"]);
10719 let queries = query_pairs(&server).await;
10720 let [query] = queries.as_slice() else {
10721 panic!("one request: {queries:?}");
10722 };
10723 assert!(
10724 query.contains(&("after".into(), "2024-03-01T00:00:00.000Z".into())),
10725 "{query:?}"
10726 );
10727 assert!(
10728 query.contains(&("until".into(), "2024-03-02T00:00:00.000Z".into())),
10729 "{query:?}"
10730 );
10731 }
10732
10733 #[tokio::test]
10734 async fn fetch_option_events_reads_nothing_for_an_empty_span() {
10735 let server = MockServer::start().await;
10736 let client = client_for(&server);
10737
10738 let read = client
10739 .fetch_option_events(time("2024-03-02T00:00:00Z"), time("2024-03-01T00:00:00Z"))
10740 .await
10741 .unwrap();
10742
10743 assert!(read.trades.is_empty() && read.resume.is_none());
10744 assert!(query_pairs(&server).await.is_empty());
10745 }
10746
10747 async fn read_span(
10750 client: &AlpacaClient,
10751 start: DateTime<Utc>,
10752 end: DateTime<Utc>,
10753 instruments: &[InstrumentNameExchange],
10754 ) -> (std::collections::HashSet<String>, usize) {
10755 use crate::client::ExecutionClient;
10756
10757 let mut ids = std::collections::HashSet::new();
10758 let mut from = start;
10759 for calls in 1..=10 {
10760 let read = match client.fetch_trades(from, end, instruments).await {
10761 Ok(read) => read,
10762 Err(error) => panic!("call {calls} failed: {error:?}"),
10763 };
10764 ids.extend(read.trades.iter().map(|trade| trade.id.0.to_string()));
10765 match read.resume {
10766 Some(resume) => from = resume,
10767 None => return (ids, calls),
10768 }
10769 }
10770 panic!("the reads did not reach the span's end");
10771 }
10772
10773 #[tokio::test]
10779 async fn fetch_trades_reads_a_busy_span_completely_across_calls() {
10780 let base = time("2025-04-18T14:30:00Z");
10781 let mut activities = Vec::new();
10782 for ms in 0..4_000_i64 {
10783 let symbol = if ms % 2 == 0 { "SPY" } else { "QQQ" };
10784 for (k, micros) in [("a", 900), ("b", 500), ("c", 100)] {
10785 let at = base + TimeDelta::milliseconds(ms) + TimeDelta::microseconds(micros);
10786 activities.push(fill_at(&format!("{ms:017}::{ms}-{k}"), symbol, at));
10787 }
10788 }
10789 let server = serve_activities(activities).await;
10790 let client = client_for(&server);
10791 let end = time("2025-04-18T15:00:00Z");
10792
10793 let (ids, calls) = read_span(&client, recovery_after(), end, &[]).await;
10794 assert_eq!(ids.len(), 12_000, "every fill is read once or more");
10795 assert_eq!(calls, 3, "5,000 activities per call");
10796
10797 let spy = [InstrumentNameExchange::new("SPY")];
10798 let (ids, _) = read_span(&client, recovery_after(), end, &spy).await;
10799 assert_eq!(ids.len(), 6_000, "every SPY fill, and only those");
10800
10801 let Some(requests) = server.received_requests().await else {
10802 panic!("requests are recorded");
10803 };
10804 assert!(
10805 requests
10806 .iter()
10807 .all(|request| request.url.query_pairs().any(
10808 |(name, value)| name == "until" && value == "2025-04-18T15:00:00.001Z"
10809 )),
10810 "every request is bounded by the span's end"
10811 );
10812 }
10813
10814 #[tokio::test]
10817 async fn fetch_trades_says_where_to_read_on_after_a_call_without_matches() {
10818 use crate::client::ExecutionClient;
10819
10820 let server = MockServer::start().await;
10821 Mock::given(method("GET"))
10822 .and(path("/v2/account/activities"))
10823 .respond_with(
10824 ResponseTemplate::new(200)
10825 .set_body_json(make_activities_json(ALPACA_MAX_ACTIVITIES, "act")),
10826 )
10827 .mount(&server)
10828 .await;
10829
10830 let qqq = [InstrumentNameExchange::new("QQQ")];
10831 let Ok(read) = client_for(&server)
10832 .fetch_trades(recovery_after(), Utc::now(), &qqq)
10833 .await
10834 else {
10835 panic!("the read succeeds");
10836 };
10837 assert!(read.trades.is_empty(), "every fill read is SPY's");
10838 assert_eq!(read.resume, Some(time("2025-04-18T14:30:00Z")));
10839 }
10840
10841 #[tokio::test]
10844 async fn fetch_trades_that_cannot_advance_is_truncated() {
10845 use crate::client::ExecutionClient;
10846
10847 let server = MockServer::start().await;
10848 Mock::given(method("GET"))
10849 .and(path("/v2/account/activities"))
10850 .respond_with(
10851 ResponseTemplate::new(200)
10852 .set_body_json(make_activities_json(ALPACA_MAX_ACTIVITIES, "act")),
10853 )
10854 .mount(&server)
10855 .await;
10856
10857 let result = client_for(&server)
10858 .fetch_trades(time("2025-04-18T14:30:00.000500Z"), Utc::now(), &[])
10859 .await;
10860 assert!(
10861 matches!(
10862 result,
10863 Err(UnindexedClientError::Truncated { fills_read: 5_000 })
10864 ),
10865 "got {result:?}"
10866 );
10867 }
10868
10869 #[tokio::test]
10872 async fn fetch_trades_returns_only_the_span() {
10873 let (start, end) = (
10874 time("2025-04-18T14:30:00.123456Z"),
10875 time("2025-04-18T14:30:05.678901Z"),
10876 );
10877 let micro = TimeDelta::microseconds(1);
10878 let server = serve_activities(vec![
10879 fill_at("1::before", "SPY", start - micro),
10880 fill_at("2::start", "SPY", start),
10881 fill_at("3::end", "SPY", end),
10882 fill_at("4::after", "SPY", end + micro),
10883 ])
10884 .await;
10885
10886 let (ids, calls) = read_span(&client_for(&server), start, end, &[]).await;
10887 assert_eq!(calls, 1);
10888 let mut ids: Vec<_> = ids.into_iter().collect();
10889 ids.sort();
10890 assert_eq!(ids, ["end", "start"]);
10891 }
10892
10893 #[tokio::test]
10896 async fn fetch_trades_that_read_past_the_end_is_complete() {
10897 use crate::client::ExecutionClient;
10898
10899 let server = MockServer::start().await;
10900 Mock::given(method("GET"))
10902 .and(path("/v2/account/activities"))
10903 .respond_with(
10904 ResponseTemplate::new(200)
10905 .set_body_json(make_activities_json(ALPACA_MAX_ACTIVITIES, "act")),
10906 )
10907 .mount(&server)
10908 .await;
10909
10910 let Ok(read) = client_for(&server)
10911 .fetch_trades(recovery_after(), time("2025-04-18T14:29:00Z"), &[])
10912 .await
10913 else {
10914 panic!("the read succeeds");
10915 };
10916 assert_eq!(read, TradesRead::complete(Vec::new()));
10917 }
10918
10919 #[tokio::test]
10923 async fn fetch_trades_accepts_the_extreme_times() {
10924 use crate::client::ExecutionClient;
10925
10926 let server = serve_activities(vec![fill_at(
10927 "1::fill",
10928 "SPY",
10929 time("2025-04-18T14:30:00Z"),
10930 )])
10931 .await;
10932 let Ok(read) = client_for(&server)
10933 .fetch_trades(DateTime::<Utc>::MIN_UTC, DateTime::<Utc>::MAX_UTC, &[])
10934 .await
10935 else {
10936 panic!("the read succeeds");
10937 };
10938 assert_eq!(read.trades.len(), 1);
10939 assert_eq!(read.resume, None);
10940
10941 let Some(requests) = server.received_requests().await else {
10942 panic!("requests are recorded");
10943 };
10944 let [request] = requests.as_slice() else {
10945 panic!("one request: {requests:?}");
10946 };
10947 let query: std::collections::HashMap<_, _> =
10948 request.url.query_pairs().into_owned().collect();
10949 assert_eq!(
10950 query.get("after").map(String::as_str),
10951 Some("1970-01-01T00:00:00.000Z")
10952 );
10953 assert!(!query.contains_key("until"), "{query:?}");
10954
10955 let before_epoch = DateTime::UNIX_EPOCH - TimeDelta::days(1);
10957 let Ok(read) = client_for(&server)
10958 .fetch_trades(DateTime::<Utc>::MIN_UTC, before_epoch, &[])
10959 .await
10960 else {
10961 panic!("the read succeeds");
10962 };
10963 assert_eq!(read, TradesRead::complete(Vec::new()));
10964 assert_eq!(server.received_requests().await.map(|r| r.len()), Some(1));
10965 }
10966
10967 #[tokio::test]
10969 async fn fetch_trades_reads_nothing_for_an_empty_span() {
10970 use crate::client::ExecutionClient;
10971
10972 let server = MockServer::start().await;
10973 let start = recovery_after();
10974 let Ok(read) = client_for(&server)
10975 .fetch_trades(start, start - TimeDelta::seconds(1), &[])
10976 .await
10977 else {
10978 panic!("an empty span reads");
10979 };
10980 assert_eq!(read, TradesRead::complete(Vec::new()));
10981 assert_eq!(server.received_requests().await.map(|r| r.len()), Some(0));
10982 }
10983
10984 #[tokio::test]
10987 async fn recovery_reads_from_the_start_of_the_disconnects_millisecond() {
10988 let server = MockServer::start().await;
10989 Mock::given(method("GET"))
10990 .and(path("/v2/account/activities"))
10991 .and(wiremock::matchers::query_param(
10992 "after",
10993 "2025-01-01T00:00:00.123Z",
10994 ))
10995 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([])))
10996 .expect(1)
10997 .mount(&server)
10998 .await;
10999
11000 let (tx, _rx) = mpsc::unbounded_channel();
11001 let outcome = recover_fills(
11002 &reqwest::Client::new(),
11003 &RateLimitTracker::new(),
11004 &[],
11005 &server.uri(),
11006 time("2025-01-01T00:00:00.123456Z"),
11007 &tx,
11008 &new_dedup_cache(),
11009 &KnownLiveOrders::shared(ExchangeId::AlpacaBroker),
11010 )
11011 .await;
11012 assert_eq!(outcome, Ok(()));
11013 server.verify().await;
11014 }
11015
11016 fn dedup_keys(dedup: &SharedDedupCache) -> Vec<String> {
11018 let mut keys: Vec<_> = dedup
11019 .lock()
11020 .iter()
11021 .filter_map(|(key, _)| match key {
11022 FillKey::Cumulative(key) => Some(key.to_string()),
11023 FillKey::Execution(_) | FillKey::CumulativeWithoutId(_) => None,
11024 })
11025 .collect();
11026 keys.reverse();
11028 keys
11029 }
11030
11031 fn trade_of(
11032 event: &UnindexedAccountEvent,
11033 ) -> &Trade<AssetNameExchange, InstrumentNameExchange> {
11034 match &event.kind {
11035 AccountEventKind::Trade(t) => t,
11036 other => panic!("expected a Trade event, got {other:?}"),
11037 }
11038 }
11039
11040 #[tokio::test]
11043 async fn a_recovered_fill_reports_the_cumulative_alpaca_sent() {
11044 let events = drive_recover_fills(vec![
11045 activity_json("act-1", "ord-1", "2", Some("2")),
11046 activity_json("act-2", "ord-1", "3", Some("5")),
11047 ])
11048 .await;
11049
11050 let reported: Vec<_> = events
11051 .iter()
11052 .map(|e| trade_of(e).order_filled_quantity)
11053 .collect();
11054 assert_eq!(
11055 reported,
11056 vec![
11057 Some(Decimal::from_str("2").unwrap()),
11058 Some(Decimal::from_str("5").unwrap())
11059 ]
11060 );
11061 }
11062
11063 #[tokio::test]
11070 async fn the_dedup_key_uses_the_venue_cumulative_not_an_intra_batch_count() {
11071 let dedup = new_dedup_cache();
11073 drive_recover_fills_with(
11074 vec![activity_json("act-1", "ord-1", "2", Some("5"))],
11075 dedup.clone(),
11076 &KnownLiveOrders::shared(ExchangeId::AlpacaBroker),
11077 )
11078 .await;
11079
11080 assert_eq!(
11081 dedup_keys(&dedup),
11082 vec!["ord-1:5"],
11083 "counting executions within the batch would key this as ord-1:2"
11084 );
11085 }
11086
11087 async fn recover_after_ws_fill(
11091 execution_id: Option<&str>,
11092 activity_id: &str,
11093 ) -> Vec<UnindexedAccountEvent> {
11094 let dedup = new_dedup_cache();
11095 let update = AlpacaTradeUpdate {
11096 event: SmolStr::new("partial_fill"),
11097 execution_id,
11098 previous_execution_id: None,
11099 order: super::make_order_ws("ord-1", "SPY", "buy", "5"),
11100 price: Some("100.00"),
11101 qty: Some("2"),
11102 timestamp: None,
11103 };
11104 assert!(
11105 !is_duplicate_fill(&dedup, ws_execution_id(&update), early_dedup_key(&update)),
11106 "precondition: first sighting"
11107 );
11108
11109 drive_recover_fills_with(
11110 vec![activity_json(activity_id, "ord-1", "2", Some("5"))],
11111 dedup,
11112 &KnownLiveOrders::shared(ExchangeId::AlpacaBroker),
11113 )
11114 .await
11115 }
11116
11117 #[tokio::test]
11120 async fn a_fill_already_delivered_over_websocket_is_not_recovered_twice() {
11121 for (ws_execution_id, activity_id) in [
11122 (Some("exec-a"), "20250418103000000::exec-a"),
11123 (None, "20250418103000000::exec-a"),
11124 (Some("exec-a"), "act-1"),
11125 ] {
11126 let events = recover_after_ws_fill(ws_execution_id, activity_id).await;
11127 assert!(
11128 events.is_empty(),
11129 "a fill already delivered over WebSocket ({ws_execution_id:?}) must not be \
11130 re-delivered by recovery ({activity_id}), got {events:?}"
11131 );
11132 }
11133 }
11134
11135 async fn recover_after_correction(fields: &str) -> Vec<UnindexedAccountEvent> {
11138 let (tx, _rx) = mpsc::unbounded_channel();
11139 let dedup = new_dedup_cache();
11140 let known = KnownLiveOrders::shared(ExchangeId::AlpacaBroker);
11141 process_ws_text(
11142 &super::amendment_frame("trade_correct", fields),
11143 &tx,
11144 &dedup,
11145 &known,
11146 &mut VenueClock::new(),
11147 );
11148
11149 drive_recover_fills_with(
11150 vec![activity_json(
11151 "20250418103100000::exec-c",
11152 "ord-1",
11153 "2",
11154 Some("2"),
11155 )],
11156 dedup,
11157 &known,
11158 )
11159 .await
11160 }
11161
11162 #[tokio::test]
11165 async fn a_corrected_execution_is_not_recovered_again() {
11166 let events = recover_after_correction(
11167 r#""execution_id":"exec-c","previous_execution_id":"exec-a","price":"101.50","qty":"2","#,
11168 )
11169 .await;
11170 assert!(
11171 events.is_empty(),
11172 "the corrected execution was delivered as the replacement, got {events:?}"
11173 );
11174 }
11175
11176 #[tokio::test]
11178 async fn an_unresolved_correction_does_not_keep_its_execution_from_recovery() {
11179 let events = recover_after_correction(
11180 r#""execution_id":"exec-c","previous_execution_id":"exec-a","qty":"2","#,
11181 )
11182 .await;
11183 let ids: Vec<_> = events.iter().map(|e| trade_of(e).id.0.as_str()).collect();
11184 assert_eq!(ids, vec!["exec-c"]);
11185 }
11186
11187 #[tokio::test]
11190 async fn a_different_execution_at_a_cumulative_already_seen_is_recovered() {
11191 let events = recover_after_ws_fill(Some("exec-a"), "20250418103000000::exec-b").await;
11192 let ids: Vec<_> = events.iter().map(|e| trade_of(e).id.0.as_str()).collect();
11193 assert_eq!(ids, vec!["exec-b"]);
11194 }
11195
11196 #[tokio::test]
11199 async fn without_a_reported_cumulative_the_previous_behaviour_is_preserved() {
11200 let dedup = new_dedup_cache();
11201 let events = drive_recover_fills_with(
11202 vec![
11203 activity_json("act-1", "ord-1", "2", None),
11204 activity_json("act-2", "ord-1", "3", None),
11205 ],
11206 dedup.clone(),
11207 &KnownLiveOrders::shared(ExchangeId::AlpacaBroker),
11208 )
11209 .await;
11210
11211 assert_eq!(
11212 dedup_keys(&dedup),
11213 vec!["ord-1:2", "ord-1:5"],
11214 "intra-batch accumulation"
11215 );
11216 let ids: Vec<_> = events.iter().map(|e| trade_of(e).id.0.as_str()).collect();
11217 assert_eq!(ids, vec!["act-1", "act-2"], "each keeps its activity's id");
11218
11219 assert!(
11220 events
11221 .iter()
11222 .all(|e| trade_of(e).order_filled_quantity.is_none()),
11223 "a venue that reported no cumulative must not have one invented for it"
11224 );
11225 }
11226
11227 fn order_json(cid: &str, symbol: &str, status: &str, filled: &str) -> serde_json::Value {
11232 serde_json::json!({
11233 "id": format!("id-{cid}"),
11234 "client_order_id": cid,
11235 "symbol": symbol,
11236 "qty": "2",
11237 "filled_qty": filled,
11238 "filled_avg_price": if filled == "0" { None } else { Some("101.5") },
11239 "status": status,
11240 "side": "buy",
11241 "type": "limit",
11242 "time_in_force": "gtc",
11243 "limit_price": "100",
11244 "created_at": "2026-10-01T14:30:00Z",
11245 "updated_at": "2026-10-01T15:00:00Z"
11246 })
11247 }
11248
11249 async fn mount_lookup(server: &MockServer, cid: &str, order: ResponseTemplate, calls: u64) {
11251 use wiremock::matchers::query_param;
11252 Mock::given(method("GET"))
11253 .and(path("/v2/orders:by_client_order_id"))
11254 .and(query_param("client_order_id", cid))
11255 .respond_with(order)
11256 .expect(calls)
11257 .mount(server)
11258 .await;
11259 }
11260
11261 fn ok(body: serde_json::Value) -> ResponseTemplate {
11262 ResponseTemplate::new(200).set_body_json(body)
11263 }
11264
11265 fn alpaca_key(instrument: &str, cid: &str) -> UnindexedOrderKey {
11266 OrderKey::new(
11267 ExchangeId::AlpacaBroker,
11268 InstrumentNameExchange::new(instrument),
11269 StrategyId::new("strategy"),
11270 ClientOrderId::new(cid),
11271 )
11272 }
11273
11274 fn hold(known: &SharedKnownLiveOrders, key: &UnindexedOrderKey) {
11275 let open = Open::new(
11276 VenueOrderId::Assigned(OrderId::new(format!("id-{}", key.cid))),
11277 Utc::now(),
11278 Decimal::ZERO,
11279 );
11280 known
11281 .lock()
11282 .live(key, Decimal::TWO, OrderKind::Limit, &open);
11283 }
11284
11285 #[tokio::test]
11286 async fn fetch_ended_orders_reads_each_end_alpaca_reports() {
11287 use rust_decimal_macros::dec;
11288
11289 let server = MockServer::start().await;
11290 for (cid, status, filled) in [
11291 ("filled", "filled", "2"),
11292 ("canceled", "canceled", "1"),
11293 ("replaced", "replaced", "0"),
11294 ("expired", "expired", "1"),
11295 ("rejected", "rejected", "0"),
11296 ("done", "done_for_day", "1"),
11297 ("live", "new", "0"),
11298 ("unknown-status", "frozen", "0"),
11299 ] {
11300 mount_lookup(&server, cid, ok(order_json(cid, "SPY", status, filled)), 1).await;
11301 }
11302 mount_lookup(
11303 &server,
11304 "elsewhere",
11305 ok(order_json("elsewhere", "QQQ", "canceled", "0")),
11306 1,
11307 )
11308 .await;
11309 mount_lookup(
11310 &server,
11311 "gone",
11312 ResponseTemplate::new(404).set_body_json(serde_json::json!({
11313 "code": 40410000,
11314 "message": "order not found for gone"
11315 })),
11316 1,
11317 )
11318 .await;
11319
11320 let keys: Vec<_> = [
11321 "filled",
11322 "canceled",
11323 "replaced",
11324 "expired",
11325 "rejected",
11326 "done",
11327 "live",
11328 "unknown-status",
11329 "elsewhere",
11330 "gone",
11331 ]
11332 .into_iter()
11333 .map(|cid| alpaca_key("spy", cid))
11334 .collect();
11335 let ended = client_for(&server).fetch_ended_orders(&keys).await.unwrap();
11336
11337 let mut found: Vec<_> = ended
11338 .iter()
11339 .map(|order| (order.key.cid.0.as_str(), &order.state))
11340 .collect();
11341 found.sort_by_key(|(cid, _)| *cid);
11342 let [
11343 (canceled, InactiveOrderState::Cancelled(cancelled)),
11344 (expired, InactiveOrderState::Expired(expiry)),
11345 (filled, InactiveOrderState::FullyFilled(fill)),
11346 (rejected, InactiveOrderState::OpenFailed(failed)),
11347 (replaced, InactiveOrderState::Cancelled(replacement)),
11348 ] = found.as_slice()
11349 else {
11350 panic!("filled, canceled, replaced, expired and rejected: {found:?}");
11351 };
11352 assert_eq!(
11353 [*canceled, *expired, *filled, *rejected, *replaced],
11354 ["canceled", "expired", "filled", "rejected", "replaced"]
11355 );
11356 assert_eq!(
11357 (fill.filled_quantity, fill.avg_price),
11358 (dec!(2), Some(dec!(101.5)))
11359 );
11360 assert_eq!(
11361 cancelled.filled_quantity,
11362 Some(dec!(1)),
11363 "what filled before"
11364 );
11365 assert_eq!(expiry.filled_quantity, Some(dec!(1)));
11366 assert_eq!(replacement.filled_quantity, Some(Decimal::ZERO));
11367 assert_eq!(
11368 cancelled.time_exchange,
11369 parse_timestamp("2026-10-01T15:00:00Z").unwrap(),
11370 "stamped when the order last changed"
11371 );
11372 assert!(matches!(
11373 failed,
11374 OrderError::Rejected(ApiError::OrderRejected(message))
11375 if message.contains("id-rejected")
11376 ));
11377 assert!(
11378 ended
11379 .iter()
11380 .all(|order| order.key.strategy == StrategyId::new("strategy")
11381 && order.key.instrument == InstrumentNameExchange::new("spy")),
11382 "each carries the key it was asked for"
11383 );
11384 }
11385
11386 #[tokio::test]
11387 async fn fetch_ended_orders_fails_when_a_lookup_fails() {
11388 let server = MockServer::start().await;
11389 mount_lookup(&server, "a", ok(order_json("a", "SPY", "canceled", "0")), 1).await;
11390 mount_lookup(&server, "b", ResponseTemplate::new(500), 1).await;
11391
11392 let result = client_for(&server)
11393 .fetch_ended_orders(&[alpaca_key("SPY", "a"), alpaca_key("SPY", "b")])
11394 .await;
11395
11396 assert!(
11397 matches!(result, Err(UnindexedClientError::Connectivity(_))),
11398 "{result:?}"
11399 );
11400 }
11401
11402 #[tokio::test]
11405 async fn a_reconnect_check_lists_once_and_reports_the_orders_that_ended() {
11406 use wiremock::matchers::query_param;
11407
11408 let server = MockServer::start().await;
11409 Mock::given(method("GET"))
11410 .and(path("/v2/orders"))
11411 .and(query_param("status", "open"))
11412 .and(query_param("symbols", "QQQ,SPY"))
11413 .respond_with(ok(serde_json::json!([order_json(
11414 "spy-listed",
11415 "SPY",
11416 "new",
11417 "0"
11418 )])))
11419 .expect(1)
11420 .mount(&server)
11421 .await;
11422 mount_lookup(&server, "spy-listed", ResponseTemplate::new(500), 0).await;
11423 mount_lookup(
11424 &server,
11425 "spy-cancelled",
11426 ok(order_json("spy-cancelled", "SPY", "canceled", "1")),
11427 1,
11428 )
11429 .await;
11430 mount_lookup(
11431 &server,
11432 "spy-done",
11433 ok(order_json("spy-done", "SPY", "done_for_day", "1")),
11434 1,
11435 )
11436 .await;
11437 mount_lookup(
11438 &server,
11439 "qqq-filled",
11440 ok(order_json("qqq-filled", "QQQ", "filled", "2")),
11441 1,
11442 )
11443 .await;
11444
11445 let known = KnownLiveOrders::shared(ExchangeId::AlpacaBroker);
11446 let [listed, cancelled, done, filled] = [
11447 ("SPY", "spy-listed"),
11448 ("SPY", "spy-cancelled"),
11449 ("SPY", "spy-done"),
11450 ("QQQ", "qqq-filled"),
11451 ]
11452 .map(|(instrument, cid)| alpaca_key(instrument, cid));
11453 for key in [&listed, &cancelled, &done, &filled] {
11454 hold(&known, key);
11455 }
11456 let mut unchecked = UncheckedOrders::default();
11457 unchecked.open(known.lock().instruments());
11458 let config = Arc::new(AlpacaConfig::with_base_url(
11459 "test-key".into(),
11460 "test-secret".into(),
11461 server.uri(),
11462 ));
11463 let (tx, mut rx) = mpsc::unbounded_channel();
11464
11465 recover_alpaca_ended_orders(
11466 &reqwest::Client::new(),
11467 &Arc::new(RateLimitTracker::new()),
11468 &config,
11469 &known,
11470 &mut unchecked,
11471 &tx,
11472 )
11473 .await;
11474
11475 let mut reported: Vec<_> = std::iter::from_fn(|| rx.try_recv().ok())
11476 .map(|event| {
11477 let AccountEventKind::OrderSnapshot(
11478 rustrade_integration::collection::snapshot::Snapshot(order),
11479 ) = event.kind
11480 else {
11481 panic!("an order snapshot: {event:?}");
11482 };
11483 assert!(matches!(order.state, OrderState::Inactive(_)), "{order:?}");
11484 order.key.cid
11485 })
11486 .collect();
11487 reported.sort();
11488 assert_eq!(reported, [filled.cid.clone(), cancelled.cid.clone()]);
11489 let known = known.lock();
11490 assert!(known.contains(&listed.cid) && known.contains(&done.cid));
11491 assert!(!known.contains(&cancelled.cid) && !known.contains(&filled.cid));
11492 assert!(unchecked.is_empty(), "both instruments checked");
11493 }
11494
11495 #[tokio::test]
11497 async fn a_failed_listing_is_retried_for_every_instrument_in_it() {
11498 let server = MockServer::start().await;
11499 Mock::given(method("GET"))
11500 .and(path("/v2/orders"))
11501 .respond_with(ResponseTemplate::new(503))
11502 .expect(1)
11503 .mount(&server)
11504 .await;
11505
11506 let known = KnownLiveOrders::shared(ExchangeId::AlpacaBroker);
11507 hold(&known, &alpaca_key("SPY", "a"));
11508 hold(&known, &alpaca_key("QQQ", "b"));
11509 let mut unchecked = UncheckedOrders::default();
11510 unchecked.open(known.lock().instruments());
11511 let config = Arc::new(AlpacaConfig::with_base_url(
11512 "test-key".into(),
11513 "test-secret".into(),
11514 server.uri(),
11515 ));
11516 let (tx, mut rx) = mpsc::unbounded_channel();
11517
11518 recover_alpaca_ended_orders(
11519 &reqwest::Client::new(),
11520 &Arc::new(RateLimitTracker::new()),
11521 &config,
11522 &known,
11523 &mut unchecked,
11524 &tx,
11525 )
11526 .await;
11527
11528 assert!(rx.try_recv().is_err(), "nothing reported");
11529 let now = tokio::time::Instant::now();
11530 assert!(
11531 unchecked.ready(&NoPendingFills, now).is_empty(),
11532 "both wait"
11533 );
11534 for instrument in ["QQQ", "SPY"] {
11535 assert_eq!(
11536 unchecked.failed(
11537 &InstrumentNameExchange::new(instrument),
11538 now,
11539 Duration::ZERO
11540 ),
11541 Some(crate::client::order_recovery::GapFailure::Retry(
11542 Duration::from_secs(crate::client::order_recovery::GAP_RETRY_BASE_SECS * 2)
11543 )),
11544 "{instrument} has failed once already"
11545 );
11546 }
11547 assert_eq!(known.lock().instruments().len(), 2, "both still held");
11548 }
11549
11550 #[tokio::test]
11551 async fn placing_listing_and_cancelling_keep_the_held_orders_in_step() {
11552 use crate::order::request::{OrderRequestCancel, RequestCancel, RequestOpen};
11553
11554 let server = MockServer::start().await;
11555 Mock::given(method("POST"))
11556 .and(path("/v2/orders"))
11557 .respond_with(ok(order_json("resting", "SPY", "new", "0")))
11558 .up_to_n_times(1)
11559 .mount(&server)
11560 .await;
11561 Mock::given(method("POST"))
11562 .and(path("/v2/orders"))
11563 .respond_with(ok(order_json("filled", "SPY", "filled", "2")))
11564 .mount(&server)
11565 .await;
11566 Mock::given(method("GET"))
11567 .and(path("/v2/orders"))
11568 .respond_with(ok(serde_json::json!([order_json(
11569 "listed", "QQQ", "new", "0"
11570 )])))
11571 .mount(&server)
11572 .await;
11573 Mock::given(method("DELETE"))
11574 .and(path("/v2/orders/id-resting"))
11575 .respond_with(ResponseTemplate::new(204))
11576 .mount(&server)
11577 .await;
11578 let client = client_for(&server);
11579 let spy = InstrumentNameExchange::new("SPY");
11580
11581 for cid in ["resting", "filled"] {
11582 let request = OrderRequestOpen {
11583 key: OrderKey::new(
11584 ExchangeId::AlpacaBroker,
11585 &spy,
11586 StrategyId::new("strategy"),
11587 ClientOrderId::new(cid),
11588 ),
11589 state: RequestOpen::new(
11590 Side::Buy,
11591 Some(Decimal::ONE_HUNDRED),
11592 Decimal::TWO,
11593 OrderKind::Limit,
11594 TimeInForce::GoodUntilCancelled { post_only: false },
11595 ),
11596 };
11597 client.open_order(request).await;
11598 }
11599 client.fetch_open_orders(&[]).await.unwrap();
11600 {
11601 let known = client.known_live.lock();
11602 assert!(known.contains(&ClientOrderId::new("resting")));
11603 assert!(
11604 !known.contains(&ClientOrderId::new("filled")),
11605 "filled in its placement response"
11606 );
11607 assert!(known.contains(&ClientOrderId::new("listed")));
11608 }
11609
11610 let cancel = client
11611 .cancel_order(OrderRequestCancel {
11612 key: OrderKey::new(
11613 ExchangeId::AlpacaBroker,
11614 &spy,
11615 StrategyId::new("strategy"),
11616 ClientOrderId::new("resting"),
11617 ),
11618 state: RequestCancel {
11619 id: Some(VenueOrderId::Assigned(OrderId::new("id-resting"))),
11620 },
11621 })
11622 .await;
11623 let Ok(cancelled) = &cancel.state else {
11624 panic!("expected Ok, got {cancel:?}");
11625 };
11626 assert_eq!(
11627 cancelled.filled_quantity, None,
11628 "the DELETE response has no body, so what filled is unknown"
11629 );
11630 assert!(
11631 client
11632 .known_live
11633 .lock()
11634 .contains(&ClientOrderId::new("resting")),
11635 "a cancel Alpaca has only accepted has not ended the order"
11636 );
11637 }
11638
11639 #[tokio::test]
11646 async fn an_order_that_ended_in_its_placement_response_is_reported_as_it_ended() {
11647 use crate::order::request::RequestOpen;
11648
11649 let Some(time) = parse_timestamp("2026-10-01T15:00:00Z") else {
11650 panic!("order_json's updated_at parses");
11651 };
11652 let id = |cid: &str| OrderId::new(format!("id-{cid}"));
11653 let open = |cid: &str, filled| {
11654 OrderState::active(Open::new(VenueOrderId::Assigned(id(cid)), time, filled))
11655 };
11656 let cases: [(&str, &str, &str, UnindexedOrderState, bool); 13] = [
11657 (
11658 "ioc-unfilled",
11659 "expired",
11660 "0",
11661 OrderState::expired(Expired::new(
11662 id("ioc-unfilled"),
11663 time,
11664 Some(Decimal::ZERO),
11665 )),
11666 false,
11667 ),
11668 (
11669 "ioc-partial",
11670 "expired",
11671 "1",
11672 OrderState::expired(Expired::new(id("ioc-partial"), time, Some(Decimal::ONE))),
11673 false,
11674 ),
11675 (
11676 "fill-unknown",
11677 "expired",
11678 "not-a-number",
11679 OrderState::expired(Expired::new(id("fill-unknown"), time, None)),
11680 false,
11681 ),
11682 (
11683 "cancelled",
11684 "canceled",
11685 "1",
11686 OrderState::inactive(Cancelled::new(id("cancelled"), time, Some(Decimal::ONE))),
11687 false,
11688 ),
11689 (
11690 "rejected",
11691 "rejected",
11692 "0",
11693 OrderState::inactive(OrderError::Rejected(ApiError::OrderRejected(
11694 "Alpaca rejected order id-rejected after accepting it".to_string(),
11695 ))),
11696 false,
11697 ),
11698 (
11699 "filled",
11700 "filled",
11701 "2",
11702 OrderState::fully_filled(Filled::new(
11703 id("filled"),
11704 time,
11705 Decimal::TWO,
11706 Some(Decimal::new(1015, 1)),
11707 )),
11708 false,
11709 ),
11710 (
11711 "replaced",
11712 "replaced",
11713 "0",
11714 OrderState::inactive(Cancelled::new(id("replaced"), time, Some(Decimal::ZERO))),
11715 false,
11716 ),
11717 (
11718 "live-covered",
11719 "partially_filled",
11720 "2",
11721 OrderState::fully_filled(Filled::new(
11722 id("live-covered"),
11723 time,
11724 Decimal::TWO,
11725 Some(Decimal::new(1015, 1)),
11726 )),
11727 false,
11728 ),
11729 ("new", "new", "0", open("new", Decimal::ZERO), true),
11730 (
11731 "live-fill-unknown",
11732 "new",
11733 "not-a-number",
11734 open("live-fill-unknown", Decimal::ZERO),
11735 true,
11736 ),
11737 (
11738 "partial",
11739 "partially_filled",
11740 "1",
11741 open("partial", Decimal::ONE),
11742 true,
11743 ),
11744 (
11745 "for-the-day",
11746 "done_for_day",
11747 "1",
11748 open("for-the-day", Decimal::ONE),
11749 true,
11750 ),
11751 (
11752 "unknown",
11753 "a_status_not_yet_documented",
11754 "0",
11755 open("unknown", Decimal::ZERO),
11756 true,
11757 ),
11758 ];
11759
11760 for (cid, status, filled, expected, held) in cases {
11761 for bracket in [false, true] {
11762 let server = MockServer::start().await;
11763 Mock::given(method("POST"))
11764 .and(path("/v2/orders"))
11765 .respond_with(ok(order_json(cid, "SPY", status, filled)))
11766 .expect(1)
11767 .mount(&server)
11768 .await;
11769 let client = client_for(&server);
11770 let spy = InstrumentNameExchange::new("SPY");
11771 let time_in_force = TimeInForce::GoodUntilCancelled { post_only: false };
11772
11773 let state = if bracket {
11774 client
11775 .open_bracket_order(AlpacaBracketOrderRequest::new(
11776 spy,
11777 StrategyId::new("strategy"),
11778 ClientOrderId::new(cid),
11779 Side::Buy,
11780 Decimal::TWO,
11781 Decimal::ONE_HUNDRED,
11782 Decimal::new(120, 0),
11783 Decimal::new(90, 0),
11784 time_in_force,
11785 ))
11786 .await
11787 .parent
11788 .state
11789 } else {
11790 let request = OrderRequestOpen {
11791 key: OrderKey::new(
11792 ExchangeId::AlpacaBroker,
11793 &spy,
11794 StrategyId::new("strategy"),
11795 ClientOrderId::new(cid),
11796 ),
11797 state: RequestOpen::new(
11798 Side::Buy,
11799 Some(Decimal::ONE_HUNDRED),
11800 Decimal::TWO,
11801 OrderKind::Limit,
11802 time_in_force,
11803 ),
11804 };
11805 client.open_order(request).await.state
11806 };
11807
11808 assert_eq!(state, expected, "{status} {filled}, bracket: {bracket}");
11809 assert_eq!(
11810 client.known_live.lock().contains(&ClientOrderId::new(cid)),
11811 held,
11812 "{status} {filled}, bracket: {bracket}"
11813 );
11814 }
11815 }
11816 }
11817
11818 #[test]
11821 fn the_stream_keeps_the_held_orders_in_step() {
11822 let (tx, mut rx) = mpsc::unbounded_channel();
11823 let dedup = new_dedup_cache();
11824 let known = KnownLiveOrders::shared(ExchangeId::AlpacaBroker);
11825 let frame = |event: &str, filled: &str| {
11826 format!(
11827 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}"}}}}}}"#
11828 )
11829 };
11830 let cid = ClientOrderId::new("a");
11831
11832 process_ws_text(
11833 &frame("new", "0"),
11834 &tx,
11835 &dedup,
11836 &known,
11837 &mut VenueClock::new(),
11838 );
11839 assert!(known.lock().contains(&cid), "acknowledged");
11840
11841 process_ws_text(
11842 &frame("done_for_day", "1"),
11843 &tx,
11844 &dedup,
11845 &known,
11846 &mut VenueClock::new(),
11847 );
11848 assert!(known.lock().contains(&cid), "done for the day is not ended");
11849
11850 process_ws_text(
11851 &frame("canceled", "1"),
11852 &tx,
11853 &dedup,
11854 &known,
11855 &mut VenueClock::new(),
11856 );
11857 assert!(!known.lock().contains(&cid), "cancelled");
11858
11859 let events: Vec<_> = std::iter::from_fn(|| rx.try_recv().ok()).collect();
11860 let [_, done, cancelled] = events.as_slice() else {
11861 panic!("three events: {events:?}");
11862 };
11863 let AccountEventKind::OrderSnapshot(
11864 rustrade_integration::collection::snapshot::Snapshot(order),
11865 ) = &done.kind
11866 else {
11867 panic!("done_for_day reports an order snapshot: {done:?}");
11868 };
11869 let OrderState::Active(ActiveOrderState::Open(open)) = &order.state else {
11870 panic!("open: {order:?}");
11871 };
11872 assert_eq!(open.filled_quantity, Decimal::ONE, "with what it filled");
11873 assert!(matches!(
11874 cancelled.kind,
11875 AccountEventKind::OrderCancelled(_)
11876 ));
11877 }
11878
11879 #[tokio::test]
11882 async fn a_recovered_fill_that_completes_an_order_stops_it_being_held() {
11883 let known = KnownLiveOrders::shared(ExchangeId::AlpacaBroker);
11884 let key = alpaca_key("SPY", "a");
11885 known.lock().live(
11886 &key,
11887 Decimal::TWO,
11888 OrderKind::Limit,
11889 &Open::new(
11890 VenueOrderId::Assigned(OrderId::new("ord-1")),
11891 Utc::now(),
11892 Decimal::ZERO,
11893 ),
11894 );
11895
11896 drive_recover_fills_with(
11897 vec![activity_json("act-1", "ord-1", "1", Some("1"))],
11898 new_dedup_cache(),
11899 &known,
11900 )
11901 .await;
11902 assert!(known.lock().contains(&key.cid), "half filled");
11903
11904 drive_recover_fills_with(
11905 vec![activity_json("act-2", "ord-1", "1", Some("2"))],
11906 new_dedup_cache(),
11907 &known,
11908 )
11909 .await;
11910 assert!(!known.lock().contains(&key.cid), "filled");
11911 }
11912
11913 #[tokio::test]
11916 async fn the_check_loop_runs_at_once_then_waits_out_a_failure() {
11917 let server = MockServer::start().await;
11918 Mock::given(method("GET"))
11919 .and(path("/v2/orders"))
11920 .respond_with(ResponseTemplate::new(503))
11921 .expect(1)
11922 .mount(&server)
11923 .await;
11924 let known = KnownLiveOrders::shared(ExchangeId::AlpacaBroker);
11925 hold(&known, &alpaca_key("SPY", "a"));
11926 let mut unchecked = UncheckedOrders::default();
11927 unchecked.open(known.lock().instruments());
11928 let config = Arc::new(AlpacaConfig::with_base_url(
11929 "test-key".into(),
11930 "test-secret".into(),
11931 server.uri(),
11932 ));
11933 let (tx, _rx) = mpsc::unbounded_channel();
11934
11935 let ran = tokio::time::timeout(
11936 Duration::from_secs(1),
11937 run_order_checks(
11938 &reqwest::Client::new(),
11939 &Arc::new(RateLimitTracker::new()),
11940 &config,
11941 &known,
11942 &mut unchecked,
11943 &tx,
11944 ),
11945 )
11946 .await;
11947
11948 assert!(ran.is_err(), "the loop never completes");
11949 assert!(
11950 unchecked.contains(&InstrumentNameExchange::new("SPY")),
11951 "SPY waits for its retry"
11952 );
11953 assert_eq!(
11954 server.received_requests().await.unwrap().len(),
11955 1,
11956 "the failed check is not run again at once"
11957 );
11958 }
11959
11960 #[tokio::test]
11962 async fn the_check_loop_waits_once_the_consumer_has_gone() {
11963 let server = MockServer::start().await;
11964 Mock::given(method("GET"))
11965 .respond_with(ResponseTemplate::new(503))
11966 .expect(0)
11967 .mount(&server)
11968 .await;
11969 let known = KnownLiveOrders::shared(ExchangeId::AlpacaBroker);
11970 hold(&known, &alpaca_key("SPY", "a"));
11971 let mut unchecked = UncheckedOrders::default();
11972 unchecked.open(known.lock().instruments());
11973 let config = Arc::new(AlpacaConfig::with_base_url(
11974 "test-key".into(),
11975 "test-secret".into(),
11976 server.uri(),
11977 ));
11978 let (tx, rx) = mpsc::unbounded_channel();
11979 drop(rx);
11980
11981 let ran = tokio::time::timeout(
11982 Duration::from_millis(200),
11983 run_order_checks(
11984 &reqwest::Client::new(),
11985 &Arc::new(RateLimitTracker::new()),
11986 &config,
11987 &known,
11988 &mut unchecked,
11989 &tx,
11990 ),
11991 )
11992 .await;
11993
11994 assert!(ran.is_err(), "the loop never completes");
11995 assert!(
11996 server.received_requests().await.unwrap().is_empty(),
11997 "nothing asked once the consumer has gone"
11998 );
11999 }
12000
12001 async fn shortability_with_asset(
12002 symbol: &str,
12003 asset: serde_json::Value,
12004 ) -> (Result<Shortability, UnindexedClientError>, Vec<String>) {
12005 use crate::client::ShortabilityClient;
12006
12007 let server = MockServer::start().await;
12008 Mock::given(method("GET"))
12009 .respond_with(ResponseTemplate::new(200).set_body_json(asset))
12010 .mount(&server)
12011 .await;
12012 let result = client_for(&server)
12013 .fetch_shortability(&InstrumentNameExchange::new(symbol))
12014 .await;
12015 let paths = server
12016 .received_requests()
12017 .await
12018 .unwrap()
12019 .iter()
12020 .map(|request| request.url.path().to_owned())
12021 .collect();
12022 (result, paths)
12023 }
12024
12025 #[tokio::test]
12026 async fn an_easy_to_borrow_stock_is_shortable_at_no_fee() {
12027 let (result, paths) = shortability_with_asset(
12028 "AAPL",
12029 serde_json::json!({
12030 "class": "us_equity",
12031 "symbol": "AAPL",
12032 "shortable": true,
12033 "easy_to_borrow": true,
12034 "borrow_status": "easy_to_borrow",
12035 }),
12036 )
12037 .await;
12038
12039 assert_eq!(
12040 result.unwrap(),
12041 Shortability::new()
12042 .with_shortable(true)
12043 .with_easy_to_borrow(true)
12044 .with_fee_rate(Decimal::ZERO)
12045 );
12046 assert_eq!(paths, vec!["/v2/assets/AAPL"]);
12047 }
12048
12049 #[tokio::test]
12050 async fn a_hard_to_borrow_stock_has_no_known_fee() {
12051 let (result, _) = shortability_with_asset(
12052 "GME",
12053 serde_json::json!({
12054 "class": "us_equity",
12055 "shortable": true,
12056 "easy_to_borrow": true,
12057 "borrow_status": "hard_to_borrow",
12058 }),
12059 )
12060 .await;
12061
12062 assert_eq!(
12063 result.unwrap(),
12064 Shortability::new()
12065 .with_shortable(true)
12066 .with_easy_to_borrow(false),
12067 "borrow_status wins over the easy_to_borrow flag, and a hard-to-borrow fee is unknown"
12068 );
12069 }
12070
12071 #[tokio::test]
12072 async fn without_a_borrow_status_the_easy_to_borrow_flag_is_read() {
12073 let (result, _) = shortability_with_asset(
12074 "AAPL",
12075 serde_json::json!({ "class": "us_equity", "shortable": false, "easy_to_borrow": false }),
12076 )
12077 .await;
12078
12079 assert_eq!(
12080 result.unwrap(),
12081 Shortability::new()
12082 .with_shortable(false)
12083 .with_easy_to_borrow(false)
12084 );
12085 }
12086
12087 #[tokio::test]
12088 async fn an_asset_without_borrow_fields_is_unknown_but_for_shortable() {
12089 let (result, paths) = shortability_with_asset(
12090 "BTC/USD",
12091 serde_json::json!({ "class": "crypto", "symbol": "BTC/USD", "shortable": false }),
12092 )
12093 .await;
12094
12095 assert_eq!(result.unwrap(), Shortability::new().with_shortable(false));
12096 assert_eq!(
12097 paths,
12098 vec!["/v2/assets/BTC%2FUSD"],
12099 "a crypto pair's slash is escaped, not read as a path separator"
12100 );
12101 }
12102
12103 #[tokio::test]
12104 async fn an_option_has_no_shortability() {
12105 let (result, _) = shortability_with_asset(
12106 "AAPL250117C00150000",
12107 serde_json::json!({ "class": "us_option", "shortable": true }),
12108 )
12109 .await;
12110
12111 assert!(
12112 matches!(
12113 result,
12114 Err(UnindexedClientError::Api(ApiError::InstrumentInvalid(ref instrument, ref reason)))
12115 if instrument.name().as_str() == "AAPL250117C00150000"
12116 && reason.contains("option")
12117 ),
12118 "{result:?}"
12119 );
12120 }
12121
12122 #[tokio::test]
12123 async fn an_unknown_symbol_is_an_invalid_instrument() {
12124 use crate::client::ShortabilityClient;
12125
12126 let server = MockServer::start().await;
12127 Mock::given(method("GET"))
12128 .and(path("/v2/assets/NOPE"))
12129 .respond_with(
12130 ResponseTemplate::new(404).set_body_json(
12131 serde_json::json!({ "message": "asset not found for NOPE" }),
12132 ),
12133 )
12134 .mount(&server)
12135 .await;
12136
12137 let result = client_for(&server)
12138 .fetch_shortability(&InstrumentNameExchange::new("NOPE"))
12139 .await;
12140
12141 assert!(
12142 matches!(
12143 result,
12144 Err(UnindexedClientError::Api(ApiError::InstrumentInvalid(ref instrument, _)))
12145 if instrument.name().as_str() == "NOPE"
12146 ),
12147 "{result:?}"
12148 );
12149 }
12150
12151 struct RateLimited {
12154 call: std::sync::atomic::AtomicU32,
12155 last: Option<(&'static str, String)>,
12156 }
12157
12158 impl RateLimited {
12159 fn mount(last: Option<(&'static str, String)>) -> Self {
12160 Self {
12161 call: std::sync::atomic::AtomicU32::new(0),
12162 last,
12163 }
12164 }
12165 }
12166
12167 impl Respond for RateLimited {
12168 fn respond(&self, _: &Request) -> ResponseTemplate {
12169 let call = self.call.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1;
12170 let response = ResponseTemplate::new(429)
12171 .set_body_json(serde_json::json!({"message": "too many requests"}));
12172 if call < MAX_RATE_LIMIT_ATTEMPTS {
12173 return response.insert_header("retry-after", "0");
12174 }
12175 match &self.last {
12176 Some((name, value)) => response.insert_header(*name, value.as_str()),
12177 None => response,
12178 }
12179 }
12180 }
12181
12182 fn unix_now() -> u64 {
12183 std::time::SystemTime::now()
12184 .duration_since(std::time::UNIX_EPOCH)
12185 .unwrap()
12186 .as_secs()
12187 }
12188
12189 #[tokio::test]
12190 async fn exhausted_429_retries_report_alpacas_wait() {
12191 let server = MockServer::start().await;
12192 Mock::given(method("GET"))
12193 .and(path("/v2/account"))
12194 .respond_with(RateLimited::mount(Some(("retry-after", "7".to_string()))))
12195 .mount(&server)
12196 .await;
12197
12198 let http = reqwest::Client::new();
12199 let rl = RateLimitTracker::new();
12200 let url = format!("{}/v2/account", server.uri());
12201 let error = rest_with_retry::<serde_json::Value>(&rl, || http.get(&url))
12202 .await
12203 .unwrap_err();
12204
12205 let UnindexedClientError::Api(error) = error else {
12206 panic!("expected Api, got {error:?}");
12207 };
12208 assert_eq!(error.retry_after(), Some(Duration::from_secs(7)));
12209 let blocked_until = rl
12210 .blocked_until
12211 .lock()
12212 .expect("the last refusal pauses the client");
12213 assert!(
12214 blocked_until >= tokio::time::Instant::now() + Duration::from_secs(6),
12215 "the client's next request waits for Alpaca's wait"
12216 );
12217 assert_eq!(
12218 server.received_requests().await.unwrap().len(),
12219 MAX_RATE_LIMIT_ATTEMPTS as usize
12220 );
12221 }
12222
12223 #[tokio::test]
12224 async fn exhausted_429_retries_read_the_reset_time_without_retry_after() {
12225 let server = MockServer::start().await;
12226 Mock::given(method("GET"))
12227 .and(path("/v2/account"))
12228 .respond_with(RateLimited::mount(Some((
12229 "x-ratelimit-reset",
12230 (unix_now() + 30).to_string(),
12231 ))))
12232 .mount(&server)
12233 .await;
12234
12235 let http = reqwest::Client::new();
12236 let rl = RateLimitTracker::new();
12237 let url = format!("{}/v2/account", server.uri());
12238 let error = rest_with_retry::<serde_json::Value>(&rl, || http.get(&url))
12239 .await
12240 .unwrap_err();
12241
12242 let UnindexedClientError::Api(ApiError::RateLimit {
12243 retry_after: Some(wait),
12244 ..
12245 }) = error
12246 else {
12247 panic!("expected a RateLimit with a wait, got {error:?}");
12248 };
12249 assert!(
12250 (Duration::from_secs(29)..=Duration::from_secs(30)).contains(&wait),
12251 "{wait:?}"
12252 );
12253 }
12254
12255 #[tokio::test]
12256 async fn exhausted_429_retries_without_a_hint_report_none() {
12257 let server = MockServer::start().await;
12258 Mock::given(method("GET"))
12259 .and(path("/v2/account"))
12260 .respond_with(RateLimited::mount(None))
12261 .mount(&server)
12262 .await;
12263
12264 let http = reqwest::Client::new();
12265 let rl = RateLimitTracker::new();
12266 let url = format!("{}/v2/account", server.uri());
12267 let error = rest_with_retry::<serde_json::Value>(&rl, || http.get(&url))
12268 .await
12269 .unwrap_err();
12270
12271 assert!(
12272 matches!(
12273 error,
12274 UnindexedClientError::Api(ApiError::RateLimit {
12275 retry_after: None,
12276 ..
12277 })
12278 ),
12279 "{error:?}"
12280 );
12281 }
12282
12283 #[tokio::test]
12284 async fn an_exhausted_cancel_reports_alpacas_wait() {
12285 let server = MockServer::start().await;
12286 Mock::given(method("DELETE"))
12287 .and(path("/v2/orders/abc"))
12288 .respond_with(RateLimited::mount(Some(("retry-after", "5".to_string()))))
12289 .mount(&server)
12290 .await;
12291
12292 let http = reqwest::Client::new();
12293 let rl = RateLimitTracker::new();
12294 let url = format!("{}/v2/orders/abc", server.uri());
12295 let error = rest_delete_with_retry(&rl, || http.delete(&url))
12296 .await
12297 .unwrap_err();
12298
12299 let UnindexedOrderError::Rejected(error) = error else {
12300 panic!("expected Rejected, got {error:?}");
12301 };
12302 assert_eq!(error.retry_after(), Some(Duration::from_secs(5)));
12303 assert!(
12304 rl.blocked_until.lock().is_some(),
12305 "the last refusal pauses the client"
12306 );
12307 }
12308 }
12309}