use super::{Credentials, Exchange, RequestHeaders};
use crate::data::DataApi;
use crate::trading::TradingApi;
use std::collections::HashMap;
use std::env;
use std::error::Error;
use std::io::Error as IoError;
use reqwest::{
Client, Method,
header::{CONTENT_TYPE, HeaderMap, HeaderName, HeaderValue},
};
use serde::de::DeserializeOwned;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use crate::model::{
AssetClass, CanceledOrderResponse, ListOrdersRequest, OptionChainSnapshot, OptionGreeks,
OptionQuoteSnapshot, OptionTradeSnapshot, Order, OrderClass, OrderLeg, OrderQueryStatus,
OrderRequest, OrderSide, OrderStatus, OrderType, PositionIntent, ReplaceOrderRequest,
SortDirection, TimeInForce, TradingAccount,
};
const ALPACA_LIVE_URL: &str = "https://api.alpaca.markets";
const ALPACA_PAPER_URL: &str = "https://paper-api.alpaca.markets";
const ALPACA_DATA_URL: &str = "https://data.alpaca.markets";
pub struct AlpacaCredentials {
key: String,
secret: String,
}
impl AlpacaCredentials {
pub fn new(key: String, secret: String) -> Self {
AlpacaCredentials { key, secret }
}
pub fn env() -> Result<Self, Box<dyn Error>> {
let _ = dotenvy::dotenv();
Ok(AlpacaCredentials {
key: env::var("APCA_API_KEY")?,
secret: env::var("APCA_API_SECRET")?,
})
}
}
impl Credentials for AlpacaCredentials {
fn sign(&self, _payload: &str) -> Result<String, Box<dyn Error>> {
Err(IoError::other("alpaca uses request headers instead of payload signing").into())
}
}
impl RequestHeaders for AlpacaCredentials {
fn headers(
&self,
_method: &str,
_path: &str,
_payload: &str,
) -> Result<HeaderMap, Box<dyn Error>> {
let mut headers = HeaderMap::new();
headers.insert(
HeaderName::from_static("apca-api-key-id"),
HeaderValue::from_str(&self.key)?,
);
headers.insert(
HeaderName::from_static("apca-api-secret-key"),
HeaderValue::from_str(&self.secret)?,
);
Ok(headers)
}
}
pub struct Alpaca {
client: Client,
credentials: AlpacaCredentials,
base_url: String,
}
impl Alpaca {
fn endpoint(&self, path: &str) -> String {
format!("{}{}", self.base_url, path)
}
fn data_endpoint(path: &str) -> String {
format!("{}{}", ALPACA_DATA_URL, path)
}
fn request_id(response: &reqwest::Response) -> Option<String> {
response
.headers()
.get("x-request-id")
.and_then(|value| value.to_str().ok())
.map(String::from)
}
fn boxed_error(message: String) -> Box<dyn Error> {
IoError::other(message).into()
}
async fn parse_json_response<T: DeserializeOwned>(
response: reqwest::Response,
) -> Result<T, Box<dyn Error>> {
let status = response.status();
let request_id = Self::request_id(&response);
let body = response.text().await?;
if !status.is_success() {
let message = match serde_json::from_str::<AlpacaErrorResponse>(&body) {
Ok(error) => match request_id {
Some(request_id) => format!(
"alpaca api error {} (request {}): {}",
error
.code
.map(|code| code.to_string())
.unwrap_or_else(|| status.as_u16().to_string()),
request_id,
error.message,
),
None => format!(
"alpaca api error {}: {}",
error
.code
.map(|code| code.to_string())
.unwrap_or_else(|| status.as_u16().to_string()),
error.message,
),
},
Err(_) => match request_id {
Some(request_id) => format!(
"alpaca api error {} (request {}): {}",
status.as_u16(),
request_id,
body,
),
None => format!("alpaca api error {}: {}", status.as_u16(), body),
},
};
return Err(Self::boxed_error(message));
}
Ok(serde_json::from_str(&body)?)
}
async fn parse_empty_response(response: reqwest::Response) -> Result<(), Box<dyn Error>> {
let status = response.status();
let request_id = Self::request_id(&response);
let body = response.text().await?;
if status.is_success() {
return Ok(());
}
let message = match serde_json::from_str::<AlpacaErrorResponse>(&body) {
Ok(error) => match request_id {
Some(request_id) => format!(
"alpaca api error {} (request {}): {}",
error
.code
.map(|code| code.to_string())
.unwrap_or_else(|| status.as_u16().to_string()),
request_id,
error.message,
),
None => format!(
"alpaca api error {}: {}",
error
.code
.map(|code| code.to_string())
.unwrap_or_else(|| status.as_u16().to_string()),
error.message,
),
},
Err(_) => match request_id {
Some(request_id) => format!(
"alpaca api error {} (request {}): {}",
status.as_u16(),
request_id,
body,
),
None => format!("alpaca api error {}: {}", status.as_u16(), body),
},
};
Err(Self::boxed_error(message))
}
async fn send_get<T: DeserializeOwned>(
&self,
path: &str,
query: Option<&Vec<(&'static str, String)>>,
) -> Result<T, Box<dyn Error>> {
let headers = self.credentials.headers(Method::GET.as_str(), path, "")?;
let mut request = self.client.get(self.endpoint(path)).headers(headers);
if let Some(query) = query {
request = request.query(query);
}
let response = request.send().await?;
Self::parse_json_response(response).await
}
async fn send_data_get<T: DeserializeOwned>(
&self,
path: &str,
query: Option<&Vec<(&'static str, String)>>,
) -> Result<T, Box<dyn Error>> {
let headers = self.credentials.headers(Method::GET.as_str(), path, "")?;
let mut request = self.client.get(Self::data_endpoint(path)).headers(headers);
if let Some(query) = query {
request = request.query(query);
}
let response = request.send().await?;
Self::parse_json_response(response).await
}
async fn send_post<B: Serialize, T: DeserializeOwned>(
&self,
path: &str,
body: &B,
) -> Result<T, Box<dyn Error>> {
let payload = serde_json::to_string(body)?;
let mut headers = self
.credentials
.headers(Method::POST.as_str(), path, &payload)?;
headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
let response = self
.client
.post(self.endpoint(path))
.headers(headers)
.body(payload)
.send()
.await?;
Self::parse_json_response(response).await
}
async fn send_patch<B: Serialize, T: DeserializeOwned>(
&self,
path: &str,
body: &B,
) -> Result<T, Box<dyn Error>> {
let payload = serde_json::to_string(body)?;
let mut headers = self
.credentials
.headers(Method::PATCH.as_str(), path, &payload)?;
headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
let response = self
.client
.patch(self.endpoint(path))
.headers(headers)
.body(payload)
.send()
.await?;
Self::parse_json_response(response).await
}
async fn send_delete_empty(&self, path: &str) -> Result<(), Box<dyn Error>> {
let headers = self
.credentials
.headers(Method::DELETE.as_str(), path, "")?;
let response = self
.client
.delete(self.endpoint(path))
.headers(headers)
.send()
.await?;
Self::parse_empty_response(response).await
}
async fn send_delete_json<T: DeserializeOwned>(&self, path: &str) -> Result<T, Box<dyn Error>> {
let headers = self
.credentials
.headers(Method::DELETE.as_str(), path, "")?;
let response = self
.client
.delete(self.endpoint(path))
.headers(headers)
.send()
.await?;
Self::parse_json_response(response).await
}
fn build_list_orders_query(request: &ListOrdersRequest) -> Vec<(&'static str, String)> {
let mut query = Vec::new();
if let Some(status) = &request.status {
query.push(("status", order_query_status_as_str(status).to_string()));
}
if let Some(limit) = request.limit {
query.push(("limit", limit.to_string()));
}
if let Some(after) = &request.after {
query.push(("after", after.clone()));
}
if let Some(until) = &request.until {
query.push(("until", until.clone()));
}
if let Some(direction) = &request.direction {
query.push(("direction", sort_direction_as_str(direction).to_string()));
}
if let Some(nested) = request.nested {
query.push(("nested", nested.to_string()));
}
if let Some(side) = &request.side {
query.push(("side", order_side_as_str(side).to_string()));
}
query
}
}
pub struct AlpacaConfig {
pub paper: bool,
}
impl Alpaca {
pub fn new(credentials: AlpacaCredentials, config: AlpacaConfig) -> Self {
let base_url = if config.paper {
ALPACA_PAPER_URL
} else {
ALPACA_LIVE_URL
};
Alpaca {
client: Client::new(),
credentials,
base_url: base_url.to_string(),
}
}
pub fn paper(credentials: AlpacaCredentials) -> Self {
Self::new(credentials, AlpacaConfig { paper: true })
}
pub fn live(credentials: AlpacaCredentials) -> Self {
Self::new(credentials, AlpacaConfig { paper: false })
}
}
impl Exchange for Alpaca {
type Credentials = AlpacaCredentials;
fn new(credentials: AlpacaCredentials) -> Self {
Alpaca::paper(credentials)
}
}
impl DataApi for Alpaca {
async fn get_account(&self) -> Result<TradingAccount, Box<dyn Error>> {
let response: AlpacaAccountResponse = self.send_get("/v2/account", None).await?;
Ok(response.into())
}
async fn get_order(&self, order_id: &str) -> Result<Order, Box<dyn Error>> {
let path = format!("/v2/orders/{}", order_id);
let response: AlpacaOrderResponse = self.send_get(&path, None).await?;
Ok(response.into())
}
async fn get_order_by_client_id(&self, client_order_id: &str) -> Result<Order, Box<dyn Error>> {
let query = vec![("client_order_id", client_order_id.to_string())];
let response: AlpacaOrderResponse = self
.send_get("/v2/orders:by_client_order_id", Some(&query))
.await?;
Ok(response.into())
}
async fn list_orders(&self, request: &ListOrdersRequest) -> Result<Vec<Order>, Box<dyn Error>> {
let query = Self::build_list_orders_query(request);
let response: Vec<AlpacaOrderResponse> = self.send_get("/v2/orders", Some(&query)).await?;
Ok(response.into_iter().map(Order::from).collect())
}
async fn get_option_chain(
&self,
underlying_symbol: &str,
) -> Result<Vec<OptionChainSnapshot>, Box<dyn Error>> {
let path = format!("/v1beta1/options/snapshots/{}", underlying_symbol);
let mut query = vec![("limit", "1000".to_string())];
let mut snapshots = Vec::new();
loop {
let response: AlpacaOptionChainResponse =
self.send_data_get(&path, Some(&query)).await?;
snapshots.extend(
response
.snapshots
.into_iter()
.map(|(symbol, snapshot)| snapshot.into_option_chain_snapshot(symbol)),
);
match response.next_page_token {
Some(next_page_token) => {
query.retain(|(key, _)| *key != "page_token");
query.push(("page_token", next_page_token));
}
None => break,
}
}
snapshots.sort_by(|left, right| left.symbol.cmp(&right.symbol));
Ok(snapshots)
}
}
impl TradingApi for Alpaca {
async fn submit(&self, order: &OrderRequest) -> Result<Order, Box<dyn Error>> {
let response: AlpacaOrderResponse = self
.send_post("/v2/orders", &AlpacaOrderRequest::from(order))
.await?;
Ok(response.into())
}
async fn replace(
&self,
order_id: &str,
request: &ReplaceOrderRequest,
) -> Result<Order, Box<dyn Error>> {
let path = format!("/v2/orders/{}", order_id);
let response: AlpacaOrderResponse = self
.send_patch(&path, &AlpacaReplaceOrderRequest::from(request))
.await?;
Ok(response.into())
}
async fn cancel(&self, order_id: &str) -> Result<(), Box<dyn Error>> {
let path = format!("/v2/orders/{}", order_id);
self.send_delete_empty(&path).await
}
async fn cancel_all(&self) -> Result<Vec<CanceledOrderResponse>, Box<dyn Error>> {
let response: Vec<AlpacaCanceledOrderResponse> =
self.send_delete_json("/v2/orders").await?;
Ok(response
.into_iter()
.map(CanceledOrderResponse::from)
.collect())
}
}
#[derive(Debug, Serialize)]
struct AlpacaOrderRequest<'a> {
#[serde(skip_serializing_if = "Option::is_none")]
symbol: Option<&'a String>,
#[serde(skip_serializing_if = "Option::is_none")]
side: Option<&'a OrderSide>,
#[serde(skip_serializing_if = "Option::is_none")]
qty: Option<&'a String>,
#[serde(skip_serializing_if = "Option::is_none")]
notional: Option<&'a String>,
#[serde(rename = "type")]
order_type: &'a OrderType,
time_in_force: &'a TimeInForce,
#[serde(skip_serializing_if = "Option::is_none")]
order_class: Option<&'a OrderClass>,
#[serde(skip_serializing_if = "Option::is_none")]
limit_price: Option<&'a String>,
#[serde(skip_serializing_if = "Option::is_none")]
stop_price: Option<&'a String>,
#[serde(skip_serializing_if = "Option::is_none")]
trail_price: Option<&'a String>,
#[serde(skip_serializing_if = "Option::is_none")]
trail_percent: Option<&'a String>,
#[serde(skip_serializing_if = "Option::is_none")]
client_order_id: Option<&'a String>,
#[serde(skip_serializing_if = "Option::is_none")]
extended_hours: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
position_intent: Option<&'a PositionIntent>,
#[serde(skip_serializing_if = "Option::is_none")]
legs: Option<Vec<AlpacaOrderLegRequest<'a>>>,
}
impl<'a> From<&'a OrderRequest> for AlpacaOrderRequest<'a> {
fn from(order: &'a OrderRequest) -> Self {
AlpacaOrderRequest {
symbol: order.symbol.as_ref(),
side: order.side.as_ref(),
qty: order.qty.as_ref(),
notional: order.notional.as_ref(),
order_type: &order.order_type,
time_in_force: &order.time_in_force,
order_class: order.order_class.as_ref(),
limit_price: order.limit_price.as_ref(),
stop_price: order.stop_price.as_ref(),
trail_price: order.trail_price.as_ref(),
trail_percent: order.trail_percent.as_ref(),
client_order_id: order.client_order_id.as_ref(),
extended_hours: order.extended_hours,
position_intent: order.position_intent.as_ref(),
legs: order
.legs
.as_ref()
.map(|legs| legs.iter().map(AlpacaOrderLegRequest::from).collect()),
}
}
}
#[derive(Debug, Serialize)]
struct AlpacaOrderLegRequest<'a> {
symbol: &'a String,
ratio_qty: &'a String,
side: &'a OrderSide,
#[serde(skip_serializing_if = "Option::is_none")]
position_intent: Option<&'a PositionIntent>,
}
impl<'a> From<&'a OrderLeg> for AlpacaOrderLegRequest<'a> {
fn from(leg: &'a OrderLeg) -> Self {
AlpacaOrderLegRequest {
symbol: &leg.symbol,
ratio_qty: &leg.ratio_qty,
side: &leg.side,
position_intent: leg.position_intent.as_ref(),
}
}
}
#[derive(Debug, Serialize)]
struct AlpacaReplaceOrderRequest<'a> {
#[serde(skip_serializing_if = "Option::is_none")]
qty: Option<&'a String>,
#[serde(skip_serializing_if = "Option::is_none")]
time_in_force: Option<&'a TimeInForce>,
#[serde(skip_serializing_if = "Option::is_none")]
limit_price: Option<&'a String>,
#[serde(skip_serializing_if = "Option::is_none")]
stop_price: Option<&'a String>,
#[serde(skip_serializing_if = "Option::is_none")]
trail: Option<&'a String>,
#[serde(skip_serializing_if = "Option::is_none")]
client_order_id: Option<&'a String>,
}
impl<'a> From<&'a ReplaceOrderRequest> for AlpacaReplaceOrderRequest<'a> {
fn from(request: &'a ReplaceOrderRequest) -> Self {
AlpacaReplaceOrderRequest {
qty: request.qty.as_ref(),
time_in_force: request.time_in_force.as_ref(),
limit_price: request.limit_price.as_ref(),
stop_price: request.stop_price.as_ref(),
trail: request.trail.as_ref(),
client_order_id: request.client_order_id.as_ref(),
}
}
}
#[derive(Debug, Deserialize)]
struct AlpacaOrderResponse {
id: String,
client_order_id: Option<String>,
symbol: Option<String>,
asset_class: Option<AssetClass>,
qty: Option<String>,
notional: Option<String>,
filled_qty: Option<String>,
side: Option<OrderSide>,
#[serde(rename = "order_type", alias = "type")]
order_type: OrderType,
time_in_force: TimeInForce,
status: OrderStatus,
order_class: Option<OrderClass>,
limit_price: Option<String>,
stop_price: Option<String>,
trail_price: Option<String>,
trail_percent: Option<String>,
created_at: Option<String>,
submitted_at: Option<String>,
extended_hours: Option<bool>,
position_intent: Option<PositionIntent>,
ratio_qty: Option<String>,
legs: Option<Vec<AlpacaOrderLegResponse>>,
}
impl From<AlpacaOrderResponse> for Order {
fn from(order: AlpacaOrderResponse) -> Self {
Order {
id: order.id,
client_order_id: order.client_order_id,
symbol: order.symbol,
asset_class: order.asset_class,
qty: order.qty,
notional: order.notional,
filled_qty: order.filled_qty,
side: order.side,
order_type: order.order_type,
time_in_force: order.time_in_force,
status: order.status,
order_class: order.order_class,
limit_price: order.limit_price,
stop_price: order.stop_price,
trail_price: order.trail_price,
trail_percent: order.trail_percent,
created_at: order.submitted_at.or(order.created_at),
extended_hours: order.extended_hours,
position_intent: order.position_intent,
ratio_qty: order.ratio_qty,
legs: order.legs.map(|legs| {
legs.into_iter()
.filter_map(|leg| OrderLeg::try_from(leg).ok())
.collect()
}),
}
}
}
#[derive(Debug, Deserialize)]
struct AlpacaOrderLegResponse {
symbol: Option<String>,
ratio_qty: Option<String>,
side: Option<OrderSide>,
position_intent: Option<PositionIntent>,
}
impl TryFrom<AlpacaOrderLegResponse> for OrderLeg {
type Error = Box<dyn Error>;
fn try_from(leg: AlpacaOrderLegResponse) -> Result<Self, Self::Error> {
Ok(OrderLeg {
symbol: leg
.symbol
.ok_or_else(|| IoError::other("alpaca leg response missing symbol"))?,
ratio_qty: leg
.ratio_qty
.ok_or_else(|| IoError::other("alpaca leg response missing ratio_qty"))?,
side: leg
.side
.ok_or_else(|| IoError::other("alpaca leg response missing side"))?,
position_intent: leg.position_intent,
})
}
}
#[derive(Debug, Deserialize)]
struct AlpacaAccountResponse {
id: String,
account_number: Option<String>,
status: String,
currency: String,
buying_power: Option<String>,
equity: Option<String>,
}
impl From<AlpacaAccountResponse> for TradingAccount {
fn from(account: AlpacaAccountResponse) -> Self {
TradingAccount {
id: account.id,
account_number: account.account_number,
status: account.status,
currency: account.currency,
buying_power: account.buying_power,
equity: account.equity,
}
}
}
#[derive(Debug, Deserialize)]
struct AlpacaCanceledOrderResponse {
id: String,
status: u16,
body: Option<Value>,
}
#[derive(Debug, Deserialize)]
struct AlpacaOptionChainResponse {
next_page_token: Option<String>,
snapshots: HashMap<String, AlpacaOptionChainSnapshotResponse>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct AlpacaOptionChainSnapshotResponse {
latest_trade: Option<AlpacaOptionTradeSnapshotResponse>,
latest_quote: Option<AlpacaOptionQuoteSnapshotResponse>,
implied_volatility: Option<f64>,
greeks: Option<AlpacaOptionGreeksResponse>,
}
impl AlpacaOptionChainSnapshotResponse {
fn into_option_chain_snapshot(self, symbol: String) -> OptionChainSnapshot {
OptionChainSnapshot {
symbol,
latest_trade: self.latest_trade.map(OptionTradeSnapshot::from),
latest_quote: self.latest_quote.map(OptionQuoteSnapshot::from),
implied_volatility: self.implied_volatility,
greeks: self.greeks.map(OptionGreeks::from),
}
}
}
#[derive(Debug, Deserialize)]
struct AlpacaOptionTradeSnapshotResponse {
#[serde(rename = "p")]
price: Option<f64>,
#[serde(rename = "s")]
size: Option<u64>,
#[serde(rename = "t")]
timestamp: Option<String>,
}
impl From<AlpacaOptionTradeSnapshotResponse> for OptionTradeSnapshot {
fn from(snapshot: AlpacaOptionTradeSnapshotResponse) -> Self {
OptionTradeSnapshot {
price: snapshot.price,
size: snapshot.size,
timestamp: snapshot.timestamp,
}
}
}
#[derive(Debug, Deserialize)]
struct AlpacaOptionQuoteSnapshotResponse {
#[serde(rename = "bp")]
bid_price: Option<f64>,
#[serde(rename = "bs")]
bid_size: Option<u64>,
#[serde(rename = "ap")]
ask_price: Option<f64>,
#[serde(rename = "as")]
ask_size: Option<u64>,
#[serde(rename = "t")]
timestamp: Option<String>,
}
impl From<AlpacaOptionQuoteSnapshotResponse> for OptionQuoteSnapshot {
fn from(snapshot: AlpacaOptionQuoteSnapshotResponse) -> Self {
OptionQuoteSnapshot {
bid_price: snapshot.bid_price,
bid_size: snapshot.bid_size,
ask_price: snapshot.ask_price,
ask_size: snapshot.ask_size,
timestamp: snapshot.timestamp,
}
}
}
#[derive(Debug, Deserialize)]
struct AlpacaOptionGreeksResponse {
delta: Option<f64>,
gamma: Option<f64>,
theta: Option<f64>,
vega: Option<f64>,
rho: Option<f64>,
}
impl From<AlpacaOptionGreeksResponse> for OptionGreeks {
fn from(greeks: AlpacaOptionGreeksResponse) -> Self {
OptionGreeks {
delta: greeks.delta,
gamma: greeks.gamma,
theta: greeks.theta,
vega: greeks.vega,
rho: greeks.rho,
}
}
}
impl From<AlpacaCanceledOrderResponse> for CanceledOrderResponse {
fn from(response: AlpacaCanceledOrderResponse) -> Self {
CanceledOrderResponse {
id: response.id,
status: response.status,
body: response.body,
}
}
}
#[derive(Debug, Deserialize)]
struct AlpacaErrorResponse {
code: Option<i64>,
message: String,
}
fn order_query_status_as_str(status: &OrderQueryStatus) -> &'static str {
match status {
OrderQueryStatus::Open => "open",
OrderQueryStatus::Closed => "closed",
OrderQueryStatus::All => "all",
}
}
fn sort_direction_as_str(direction: &SortDirection) -> &'static str {
match direction {
SortDirection::Asc => "asc",
SortDirection::Desc => "desc",
}
}
fn order_side_as_str(side: &OrderSide) -> &'static str {
match side {
OrderSide::Buy => "buy",
OrderSide::Sell => "sell",
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_alpaca_credentials_headers_include_api_keys() {
let credentials = AlpacaCredentials::new("key-id".to_string(), "secret-key".to_string());
let headers = credentials.headers("GET", "/v2/account", "").unwrap();
assert_eq!(
headers.get("apca-api-key-id").unwrap(),
&HeaderValue::from_static("key-id"),
);
assert_eq!(
headers.get("apca-api-secret-key").unwrap(),
&HeaderValue::from_static("secret-key"),
);
}
#[test]
fn test_alpaca_order_request_maps_common_fields() {
let request = OrderRequest {
symbol: Some("AAPL".to_string()),
asset_class: Some(AssetClass::UsEquity),
qty: Some("1".to_string()),
notional: None,
side: Some(OrderSide::Buy),
order_type: OrderType::Limit,
time_in_force: TimeInForce::Day,
order_class: Some(OrderClass::Simple),
limit_price: Some("200".to_string()),
stop_price: None,
trail_price: None,
trail_percent: None,
client_order_id: Some("strategy-1".to_string()),
extended_hours: Some(false),
position_intent: None,
legs: None,
};
let alpaca_request = AlpacaOrderRequest::from(&request);
assert_eq!(alpaca_request.symbol, Some(&"AAPL".to_string()));
assert_eq!(alpaca_request.qty, Some(&"1".to_string()));
assert_eq!(alpaca_request.limit_price, Some(&"200".to_string()));
assert_eq!(
alpaca_request.client_order_id,
Some(&"strategy-1".to_string()),
);
}
#[test]
fn test_alpaca_mleg_request_maps_option_legs() {
let request = OrderRequest {
symbol: None,
asset_class: Some(AssetClass::UsOption),
qty: Some("1".to_string()),
notional: None,
side: None,
order_type: OrderType::Limit,
time_in_force: TimeInForce::Day,
order_class: Some(OrderClass::Mleg),
limit_price: Some("1.00".to_string()),
stop_price: None,
trail_price: None,
trail_percent: None,
client_order_id: Some("spread-1".to_string()),
extended_hours: None,
position_intent: None,
legs: Some(vec![
OrderLeg {
symbol: "AAPL250117C00190000".to_string(),
ratio_qty: "1".to_string(),
side: OrderSide::Buy,
position_intent: Some(PositionIntent::BuyToOpen),
},
OrderLeg {
symbol: "AAPL250117C00210000".to_string(),
ratio_qty: "1".to_string(),
side: OrderSide::Sell,
position_intent: Some(PositionIntent::SellToOpen),
},
]),
};
let alpaca_request = AlpacaOrderRequest::from(&request);
assert_eq!(alpaca_request.symbol, None);
assert_eq!(alpaca_request.side, None);
assert_eq!(alpaca_request.order_class, Some(&OrderClass::Mleg));
assert_eq!(alpaca_request.legs.as_ref().map(Vec::len), Some(2));
}
#[test]
fn test_alpaca_replace_order_request_maps_common_fields() {
let request = ReplaceOrderRequest {
qty: Some("2".to_string()),
time_in_force: Some(TimeInForce::Gtc),
limit_price: Some("210".to_string()),
stop_price: None,
trail: None,
client_order_id: Some("replacement-1".to_string()),
};
let alpaca_request = AlpacaReplaceOrderRequest::from(&request);
assert_eq!(alpaca_request.qty, Some(&"2".to_string()));
assert_eq!(alpaca_request.limit_price, Some(&"210".to_string()));
assert_eq!(alpaca_request.time_in_force, Some(&TimeInForce::Gtc));
assert_eq!(
alpaca_request.client_order_id,
Some(&"replacement-1".to_string()),
);
}
#[test]
fn test_alpaca_list_orders_query_builds_supported_filters() {
let request = ListOrdersRequest {
status: Some(OrderQueryStatus::Closed),
limit: Some(100),
after: Some("2025-01-01T00:00:00Z".to_string()),
until: Some("2025-01-31T00:00:00Z".to_string()),
direction: Some(SortDirection::Desc),
nested: Some(true),
side: Some(OrderSide::Sell),
};
let query = Alpaca::build_list_orders_query(&request);
assert!(query.contains(&("status", "closed".to_string())));
assert!(query.contains(&("limit", "100".to_string())));
assert!(query.contains(&("after", "2025-01-01T00:00:00Z".to_string())));
assert!(query.contains(&("until", "2025-01-31T00:00:00Z".to_string())));
assert!(query.contains(&("direction", "desc".to_string())));
assert!(query.contains(&("nested", "true".to_string())));
assert!(query.contains(&("side", "sell".to_string())));
}
#[test]
fn test_alpaca_default_exchange_constructor_uses_paper_url() {
let exchange = <Alpaca as Exchange>::new(AlpacaCredentials::new(
"key-id".to_string(),
"secret-key".to_string(),
));
assert_eq!(exchange.base_url, ALPACA_PAPER_URL);
}
#[test]
fn test_alpaca_option_chain_response_flattens_snapshots() {
let response: AlpacaOptionChainResponse = serde_json::from_str(
r#"{
"snapshots": {
"NVDA250117C00120000": {
"latestTrade": {
"p": 12.5,
"s": 1,
"t": "2026-01-01T00:00:00Z"
},
"latestQuote": {
"bp": 12.3,
"bs": 10,
"ap": 12.7,
"as": 12,
"t": "2026-01-01T00:00:00Z"
},
"impliedVolatility": 0.45,
"greeks": {
"delta": 0.61,
"gamma": 0.05,
"theta": -0.02,
"vega": 0.14,
"rho": 0.03
}
}
}
}"#,
)
.unwrap();
let snapshots: Vec<OptionChainSnapshot> = response
.snapshots
.into_iter()
.map(|(symbol, snapshot)| snapshot.into_option_chain_snapshot(symbol))
.collect();
assert_eq!(snapshots.len(), 1);
assert_eq!(snapshots[0].symbol, "NVDA250117C00120000");
assert_eq!(snapshots[0].implied_volatility, Some(0.45));
assert_eq!(
snapshots[0].latest_quote.as_ref().and_then(|q| q.bid_price),
Some(12.3)
);
}
}