mod contracts;
mod snapshots;
pub use contracts::{AlpacaOptionContract, AlpacaOptionContractQuery};
pub use snapshots::{AlpacaOptionFeed, AlpacaOptionQuote, AlpacaOptionSnapshot, AlpacaOptionTrade};
use reqwest::header::{HeaderMap, HeaderValue};
use std::{env, time::Duration};
use thiserror::Error;
use tracing::{debug, warn};
const BROKER_API_BASE: &str = "https://api.alpaca.markets";
const DATA_API_BASE: &str = "https://data.alpaca.markets";
const PAPER_API_BASE: &str = "https://paper-api.alpaca.markets";
const REQUEST_TIMEOUT_SECS: u64 = 30;
const MAX_RETRY_ATTEMPTS: u32 = 3;
const DEFAULT_RATE_LIMIT_DELAY_SECS: u64 = 60;
#[non_exhaustive]
#[derive(Debug, Error)]
pub enum AlpacaOptionsError {
#[error("environment variable error: {0}")]
EnvVar(String),
#[error("HTTP error: {0}")]
Http(#[from] reqwest::Error),
#[error("API error ({status}): {message}")]
Api { status: u16, message: String },
#[error("rate limit exceeded after {attempts} attempts")]
RateLimitExceeded { attempts: u32 },
#[error("invalid response: {0}")]
InvalidResponse(String),
}
#[derive(Clone)]
pub struct AlpacaOptionsClient {
http: reqwest::Client,
broker_base: String,
data_base: String,
}
impl std::fmt::Debug for AlpacaOptionsClient {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("AlpacaOptionsClient")
.field("broker_base", &self.broker_base)
.field("data_base", &self.data_base)
.finish_non_exhaustive()
}
}
impl AlpacaOptionsClient {
pub fn new(api_key: &str, api_secret: &str, paper: bool) -> Result<Self, AlpacaOptionsError> {
let mut headers = HeaderMap::new();
headers.insert(
"APCA-API-KEY-ID",
HeaderValue::from_str(api_key)
.map_err(|e| AlpacaOptionsError::EnvVar(format!("invalid API key: {e}")))?,
);
headers.insert(
"APCA-API-SECRET-KEY",
HeaderValue::from_str(api_secret)
.map_err(|e| AlpacaOptionsError::EnvVar(format!("invalid API secret: {e}")))?,
);
let http = reqwest::Client::builder()
.default_headers(headers)
.timeout(Duration::from_secs(REQUEST_TIMEOUT_SECS))
.build()?;
let broker_base = if paper {
PAPER_API_BASE.to_string()
} else {
BROKER_API_BASE.to_string()
};
Ok(Self {
http,
broker_base,
data_base: DATA_API_BASE.to_string(),
})
}
pub fn from_env() -> Result<Self, AlpacaOptionsError> {
let api_key = env::var("ALPACA_API_KEY")
.map_err(|e| AlpacaOptionsError::EnvVar(format!("ALPACA_API_KEY: {e}")))?;
let api_secret = env::var("ALPACA_SECRET_KEY")
.map_err(|e| AlpacaOptionsError::EnvVar(format!("ALPACA_SECRET_KEY: {e}")))?;
let paper = env::var("ALPACA_PAPER")
.map(|v| v.eq_ignore_ascii_case("true"))
.unwrap_or(false);
Self::new(&api_key, &api_secret, paper)
}
async fn request_with_retry<T>(
&self,
request: reqwest::RequestBuilder,
) -> Result<T, AlpacaOptionsError>
where
T: serde::de::DeserializeOwned,
{
let mut attempts = 0u32;
loop {
attempts += 1;
let response = request
.try_clone()
.ok_or_else(|| {
AlpacaOptionsError::InvalidResponse(
"request body is not cloneable; cannot retry".into(),
)
})?
.send()
.await?;
let status = response.status();
if status == reqwest::StatusCode::TOO_MANY_REQUESTS {
if attempts >= MAX_RETRY_ATTEMPTS {
return Err(AlpacaOptionsError::RateLimitExceeded { attempts });
}
let delay = parse_retry_after(response.headers())
.unwrap_or(Duration::from_secs(DEFAULT_RATE_LIMIT_DELAY_SECS));
warn!(
attempt = attempts,
delay_secs = delay.as_secs(),
"Alpaca rate limited, retrying"
);
tokio::time::sleep(delay).await;
continue;
}
if !status.is_success() {
let message = response.text().await.unwrap_or_default();
return Err(AlpacaOptionsError::Api {
status: status.as_u16(),
message,
});
}
let body = response.text().await?;
debug!(len = body.len(), "Alpaca response received");
return serde_json::from_str(&body).map_err(|e| {
AlpacaOptionsError::InvalidResponse(format!("JSON parse error: {e}"))
});
}
}
}
fn parse_retry_after(headers: &reqwest::header::HeaderMap) -> Option<Duration> {
headers
.get("retry-after")
.and_then(|v| v.to_str().ok())
.and_then(|s| s.parse::<u64>().ok())
.map(Duration::from_secs)
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)] mod tests {
use super::AlpacaOptionsClient;
#[test]
fn client_debug_hides_credentials() {
let client = AlpacaOptionsClient::new("test-key-id", "test-secret-value", true)
.expect("client construction with ASCII credentials should succeed");
let debug_str = format!("{client:?}");
assert!(!debug_str.contains("test-key-id"));
assert!(!debug_str.contains("test-secret-value"));
}
}