use std::time::Duration;
pub const DATA_MAX_LIMIT: u32 = 10_000;
pub const DEFAULT_STABLE_SESSION: Duration = Duration::from_secs(30);
pub const ORDERS_MAX_LIMIT: u32 = 500;
pub const ACCOUNT_ACTIVITIES_DEFAULT_PAGE_SIZE: u32 = 100;
pub const BROKER_DOCUMENT_UPLOAD_LIMIT: usize = 10;
pub const DEFAULT_RETRY_ATTEMPTS: u32 = 3;
pub const DEFAULT_RETRY_WAIT: Duration = crate::backoff::DEFAULT_MIN_BACKOFF;
pub const DEFAULT_RETRY_MAX_WAIT: Duration = crate::backoff::DEFAULT_MAX_BACKOFF;
pub const DEFAULT_RETRY_STATUS_CODES: &[u16] = &[429, 504];
#[must_use]
pub fn user_agent() -> &'static str {
concat!(
"APCA-RS/",
env!("CARGO_PKG_VERSION"),
" Rust/",
env!("ALPACA_RUSTC_VERSION"),
)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum BaseUrl {
BrokerSandbox,
BrokerProduction,
TradingPaper,
TradingLive,
Data,
DataSandbox,
MarketDataStream,
TradingStreamPaper,
TradingStreamLive,
}
impl BaseUrl {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::BrokerSandbox => "https://broker-api.sandbox.alpaca.markets",
Self::BrokerProduction => "https://broker-api.alpaca.markets",
Self::TradingPaper => "https://paper-api.alpaca.markets",
Self::TradingLive => "https://api.alpaca.markets",
Self::Data => "https://data.alpaca.markets",
Self::DataSandbox => "https://data.sandbox.alpaca.markets",
Self::MarketDataStream => "wss://stream.data.alpaca.markets",
Self::TradingStreamPaper => "wss://paper-api.alpaca.markets/stream",
Self::TradingStreamLive => "wss://api.alpaca.markets/stream",
}
}
#[must_use]
pub const fn trading(paper: bool) -> Self {
if paper {
Self::TradingPaper
} else {
Self::TradingLive
}
}
#[must_use]
pub const fn trading_stream(paper: bool) -> Self {
if paper {
Self::TradingStreamPaper
} else {
Self::TradingStreamLive
}
}
#[must_use]
pub const fn broker(sandbox: bool) -> Self {
if sandbox {
Self::BrokerSandbox
} else {
Self::BrokerProduction
}
}
#[must_use]
pub const fn data(sandbox: bool) -> Self {
if sandbox {
Self::DataSandbox
} else {
Self::Data
}
}
}
impl std::fmt::Display for BaseUrl {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
impl From<BaseUrl> for String {
fn from(url: BaseUrl) -> Self {
url.as_str().to_owned()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum RetryBackoff {
Flat,
Exponential {
max: Duration,
},
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct RetryConfig {
pub attempts: u32,
pub wait: Duration,
pub backoff: RetryBackoff,
pub status_codes: Vec<u16>,
}
impl RetryConfig {
#[must_use]
pub fn none() -> Self {
Self {
attempts: 0,
wait: Duration::ZERO,
backoff: RetryBackoff::Flat,
status_codes: Vec::new(),
}
}
#[must_use]
pub fn attempts(mut self, attempts: u32) -> Self {
self.attempts = attempts;
self
}
#[must_use]
pub fn wait(mut self, wait: Duration) -> Self {
self.wait = wait;
self
}
#[must_use]
pub fn backoff(mut self, backoff: RetryBackoff) -> Self {
self.backoff = backoff;
self
}
#[must_use]
pub fn status_codes(mut self, status_codes: impl Into<Vec<u16>>) -> Self {
self.status_codes = status_codes.into();
self
}
#[must_use]
pub fn should_retry(&self, status: u16) -> bool {
self.status_codes.contains(&status)
}
pub(crate) fn retry_after_cap(&self) -> Duration {
match self.backoff {
RetryBackoff::Exponential { max } => max,
RetryBackoff::Flat => DEFAULT_RETRY_MAX_WAIT,
}
}
#[must_use]
pub fn delay(&self, failures: u32) -> Duration {
match self.backoff {
RetryBackoff::Flat => self.wait,
RetryBackoff::Exponential { max } => {
crate::backoff::reconnect_delay(failures, self.wait, max)
}
}
}
}
impl Default for RetryConfig {
fn default() -> Self {
Self {
attempts: DEFAULT_RETRY_ATTEMPTS,
wait: DEFAULT_RETRY_WAIT,
backoff: RetryBackoff::Exponential {
max: DEFAULT_RETRY_MAX_WAIT,
},
status_codes: DEFAULT_RETRY_STATUS_CODES.to_vec(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn user_agent_has_the_apca_rs_shape() {
let ua = user_agent();
assert!(ua.starts_with("APCA-RS/"), "{ua}");
assert!(ua.contains(" Rust/"), "{ua}");
}
#[test]
fn endpoints_are_the_documented_hosts() {
assert_eq!(
BaseUrl::TradingPaper.as_str(),
"https://paper-api.alpaca.markets"
);
assert_eq!(BaseUrl::TradingLive.as_str(), "https://api.alpaca.markets");
assert_eq!(BaseUrl::Data.as_str(), "https://data.alpaca.markets");
assert_eq!(
BaseUrl::MarketDataStream.as_str(),
"wss://stream.data.alpaca.markets"
);
assert_eq!(
BaseUrl::BrokerSandbox.as_str(),
"https://broker-api.sandbox.alpaca.markets"
);
}
#[test]
fn environment_selectors_pick_the_right_endpoint() {
assert_eq!(BaseUrl::trading(true), BaseUrl::TradingPaper);
assert_eq!(BaseUrl::trading(false), BaseUrl::TradingLive);
assert_eq!(BaseUrl::trading_stream(true), BaseUrl::TradingStreamPaper);
assert_eq!(BaseUrl::broker(true), BaseUrl::BrokerSandbox);
assert_eq!(BaseUrl::data(false), BaseUrl::Data);
}
#[test]
fn retry_after_is_capped_by_the_exponential_ceiling() {
let cfg = RetryConfig::default().backoff(RetryBackoff::Exponential {
max: Duration::from_secs(5),
});
assert_eq!(cfg.retry_after_cap(), Duration::from_secs(5));
}
#[test]
fn a_flat_policy_caps_retry_after_at_the_default_ceiling() {
let cfg = RetryConfig::default().backoff(RetryBackoff::Flat);
assert_eq!(cfg.retry_after_cap(), DEFAULT_RETRY_MAX_WAIT);
}
#[test]
fn retry_defaults_follow_the_rate_limit_page() {
let cfg = RetryConfig::default();
assert_eq!(cfg.attempts, 3);
assert_eq!(cfg.wait, Duration::from_secs(1));
assert_eq!(
cfg.backoff,
RetryBackoff::Exponential {
max: Duration::from_secs(30)
}
);
assert!(cfg.should_retry(429));
assert!(cfg.should_retry(504));
assert!(!cfg.should_retry(500));
}
#[test]
fn flat_backoff_waits_the_same_amount_every_time() {
let cfg = RetryConfig::default()
.backoff(RetryBackoff::Flat)
.wait(Duration::from_secs(3));
for failures in 1..=5 {
assert_eq!(cfg.delay(failures), Duration::from_secs(3));
}
}
#[test]
fn exponential_backoff_grows_and_stays_under_the_cap() {
let cfg = RetryConfig::default();
for failures in 1..=10 {
let capped = crate::backoff::capped_delay(failures, cfg.wait, Duration::from_secs(30));
let delay = cfg.delay(failures);
assert!(
delay >= capped / 2 && delay <= capped,
"failures={failures}: {delay:?} outside [{:?}, {capped:?}]",
capped / 2
);
assert!(delay <= Duration::from_secs(30));
}
assert!(cfg.delay(4) > Duration::from_secs(3));
}
#[test]
fn a_zero_wait_stays_instant_under_either_strategy() {
assert_eq!(RetryConfig::none().delay(1), Duration::ZERO);
assert_eq!(
RetryConfig::default().wait(Duration::ZERO).delay(7),
Duration::ZERO
);
}
#[test]
fn retry_builders_replace_one_field_at_a_time() {
let cfg = RetryConfig::default()
.attempts(5)
.wait(Duration::ZERO)
.status_codes([500, 502]);
assert_eq!(cfg.attempts, 5);
assert_eq!(cfg.wait, Duration::ZERO);
assert!(cfg.should_retry(500));
assert!(!cfg.should_retry(429));
let none = RetryConfig::none();
assert_eq!(none.attempts, 0);
assert!(!none.should_retry(429));
}
}