diff --git a/examples/latest-quote-and-trade.rs b/examples/latest-quote-and-trade.rs new file mode 100644 index 00000000..95e6bf5b --- /dev/null +++ b/examples/latest-quote-and-trade.rs @@ -0,0 +1,41 @@ +// Copyright (C) 2020-2022 The apca Developers +// SPDX-License-Identifier: GPL-3.0-or-later + +use apca::data::v2::{last_quote, last_trade}; +use apca::ApiInfo; +use apca::Client; + +#[tokio::main] +async fn main() { + // Requires the following environment variables to be present: + // - APCA_API_KEY_ID -> your API key + // - APCA_API_SECRET_KEY -> your secret key + // + // Optionally, the following variable is honored: + // - APCA_API_BASE_URL -> the API base URL to use (set to + // https://api.alpaca.markets for live trading) + let api_info = ApiInfo::from_env().unwrap(); + let client = Client::new(api_info); + + let quote_req = last_quote::LastQuoteReq::new(vec!["AAPL".to_string(), "MSFT".to_string()]); + let quotes = client.issue::("e_req).await.unwrap(); + quotes.iter().for_each(|q| { + println!( + "Latest quote for {}: Ask {}/{} Bid {}/{}", + q.symbol, q.ask_price, q.ask_size, q.bid_price, q.bid_size + ) + }); + + let trade_req = last_trade::LastTradeRequest::new(vec![ + "SPY".to_string(), + "QQQ".to_string(), + "IWM".to_string(), + ]); + let trades = client.issue::(&trade_req).await.unwrap(); + trades.iter().for_each(|trade| { + println!( + "Latest trade for {}: {} @ {}", + trade.symbol, trade.size, trade.price + ); + }); +} diff --git a/src/data/v2/last_quote.rs b/src/data/v2/last_quote.rs index e0d8bfbd..29f39c26 100644 --- a/src/data/v2/last_quote.rs +++ b/src/data/v2/last_quote.rs @@ -10,77 +10,101 @@ use serde::Deserialize; use serde::Serialize; use serde_json::from_slice as from_json; use serde_urlencoded::to_string as to_query; +use std::collections::HashMap; use crate::data::v2::Feed; use crate::data::DATA_BASE_URL; use crate::Str; - -/// A GET request to be made to the /v2/stocks/{symbol}/quotes/latest endpoint. -#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +/// A GET request to be made to the /v2/stocks/quotes/latest endpoint. +#[derive(Clone, Serialize, Eq, PartialEq, Debug)] pub struct LastQuoteReq { - /// The symbol to retrieve the last quote for. - #[serde(skip)] - pub symbol: String, + /// Comma-separated list of symbols to retrieve the last quote for. + pub symbols: String, /// The data feed to use. - #[serde(rename = "feed")] pub feed: Option, } - -/// A helper for initializing [`LastQuoteReq`] objects. -#[derive(Clone, Debug, Default, Eq, PartialEq)] -#[allow(missing_copy_implementations)] -pub struct LastQuoteReqInit { - /// See `LastQuoteReq::feed`. - pub feed: Option, - #[doc(hidden)] - pub _non_exhaustive: (), -} - -impl LastQuoteReqInit { - /// Create a [`LastQuoteReq`] from a `LastQuoteReqInit`. - #[inline] - pub fn init(self, symbol: S) -> LastQuoteReq - where - S: Into, - { - LastQuoteReq { - symbol: symbol.into(), - feed: self.feed, +impl LastQuoteReq { + /// Create a new LastQuoteReq with the given symbols. + pub fn new(symbols: Vec) -> Self { + Self { + symbols: symbols.join(",").into(), + feed: None, } } + /// Set the data feed to use. + pub fn with_feed(mut self, feed: Feed) -> Self { + self.feed = Some(feed); + self + } } - -/// A quote bar as returned by the /v2/stocks//quotes/latest endpoint. +/// A quote bar as returned by the /v2/stocks/quotes/latest endpoint. +/// See +/// https://alpaca.markets/docs/api-references/market-data-api/stock-pricing-data/historical/#latest-multi-quotes // TODO: Not all fields are hooked up. -#[derive(Clone, Debug, Deserialize, Eq, PartialEq)] +#[derive(Clone, Debug, Serialize, Deserialize, Eq, PartialEq)] #[non_exhaustive] pub struct Quote { /// The time stamp of this quote. - #[serde(rename = "t")] pub time: DateTime, /// The ask price. - #[serde(rename = "ap")] pub ask_price: Num, /// The ask size. - #[serde(rename = "as")] pub ask_size: u64, /// The bid price. - #[serde(rename = "bp")] pub bid_price: Num, /// The bid size. - #[serde(rename = "bs")] pub bid_size: u64, + /// Symbol of this quote + pub symbol: String, } +impl Quote { + fn from(symbol: &str, point: QuoteDataPoint) -> Self { + Self { + time: point.t, + ask_price: point.ap.clone(), + ask_size: point.r#as, + bid_price: point.bp.clone(), + bid_size: point.bs, + symbol: symbol.to_string(), + } + } + + fn parse(body: &[u8]) -> Result, serde_json::Error> { + from_json::(body).map(|response| { + response + .quotes + .into_iter() + .map(|(sym, point)| Quote::from(&sym, point)) + .collect() + }) + } +} + +/// fields for individual data points in the response JSON +#[derive(Clone, Debug, Deserialize)] +pub struct QuoteDataPoint { + t: DateTime, + ap: Num, + r#as: u64, + bp: Num, + bs: u64, +} + +/// A representation of the JSON data in the response +#[derive(Debug, Deserialize)] +pub struct LastQuoteResponse { + quotes: HashMap, +} EndpointNoParse! { /// The representation of a GET request to the - /// /v2/stocks//quotes/latest endpoint. + /// /v2/stocks/quotes/latest endpoint. pub Get(LastQuoteReq), - Ok => Quote, [ + Ok => Vec, [ /// The last quote was retrieved successfully. /* 200 */ OK, ], @@ -94,8 +118,8 @@ EndpointNoParse! { Some(DATA_BASE_URL.into()) } - fn path(input: &Self::Input) -> Str { - format!("/v2/stocks/{}/quotes/latest", input.symbol).into() + fn path(_input: &Self::Input) -> Str { + format!("/v2/stocks/quotes/latest").into() } fn query(input: &Self::Input) -> Result, Self::ConversionError> { @@ -103,21 +127,7 @@ EndpointNoParse! { } fn parse(body: &[u8]) -> Result { - /// A helper object for parsing the response to a `Get` request. - #[derive(Deserialize)] - struct Response { - /// The symbol for which the quote was reported. - #[allow(unused)] - symbol: String, - /// The quote belonging to the provided symbol. - quote: Quote, - } - - // We are not interested in the actual `Response` object. Clients - // can keep track of what symbol they requested a quote for. - from_json::(body) - .map(|response| response.quote) - .map_err(Self::ConversionError::from) + Quote::parse(body).map_err(Self::ConversionError::from) } fn parse_err(body: &[u8]) -> Result> { @@ -125,7 +135,6 @@ EndpointNoParse! { } } - #[cfg(test)] mod tests { use super::*; @@ -138,33 +147,49 @@ mod tests { use crate::Client; use crate::RequestError; - /// Check that we can parse the reference quote from the /// documentation. #[test] fn parse_reference_quote() { let response = br#"{ - "t": "2021-02-06T13:35:08.946977536Z", - "ax": "C", - "ap": 387.7, - "as": 1, - "bx": "N", - "bp": 387.67, - "bs": 1, - "c": [ - "R" - ] -}"#; - - let quote = from_json::(response).unwrap(); + "quotes": { + "TSLA": { + "t": "2022-04-12T17:26:45.009288296Z", + "ax": "V", + "ap": 1020, + "as": 3, + "bx": "V", + "bp": 990, + "bs": 5, + "c": ["R"], + "z": "C" + }, + "AAPL": { + "t": "2022-04-12T17:26:44.962998616Z", + "ax": "V", + "ap": 170, + "as": 1, + "bx": "V", + "bp": 168.03, + "bs": 1, + "c": ["R"], + "z": "C" + } + } + }"#; + + let mut result = Quote::parse(response).unwrap(); + result.sort_by_key(|t| t.time); + assert_eq!(result.len(), 2); + assert_eq!(result[1].ask_price, Num::new(1020, 1)); + assert_eq!(result[1].ask_size, 3); + assert_eq!(result[1].bid_price, Num::new(990, 1)); + assert_eq!(result[1].bid_size, 5); + assert_eq!(result[1].symbol, "TSLA".to_string()); assert_eq!( - quote.time, - DateTime::parse_from_rfc3339("2021-02-06T13:35:08.946977536Z").unwrap() + result[1].time, + DateTime::parse_from_rfc3339("2022-04-12T17:26:45.009288296Z").unwrap() ); - assert_eq!(quote.ask_price, Num::new(3877, 10)); - assert_eq!(quote.ask_size, 1); - assert_eq!(quote.bid_price, Num::new(38767, 100)); - assert_eq!(quote.bid_size, 1); } /// Verify that we can retrieve the last quote for an asset. @@ -173,12 +198,28 @@ mod tests { let api_info = ApiInfo::from_env().unwrap(); let client = Client::new(api_info); - let req = LastQuoteReqInit::default().init("SPY"); - let quote = client.issue::(&req).await.unwrap(); + let req = LastQuoteReq::new(vec!["SPY".to_string()]); + let quotes = client.issue::(&req).await.unwrap(); // Just as a rough sanity check, we require that the reported time // is some time after two weeks before today. That should safely // account for any combination of holidays, weekends, etc. - assert!(quote.time >= Utc::now() - Duration::weeks(2)); + assert!(quotes[0].time >= Utc::now() - Duration::weeks(2)); + } + + /// Retrieve multiple symbols at once. + #[test(tokio::test)] + async fn request_last_quotes_multi() { + let api_info = ApiInfo::from_env().unwrap(); + let client = Client::new(api_info); + + let req = LastQuoteReq::new(vec![ + "SPY".to_string(), + "QQQ".to_string(), + "MSFT".to_string(), + ]); + let quotes = client.issue::(&req).await.unwrap(); + assert_eq!(quotes.len(), 3); + assert!(quotes[0].time >= Utc::now() - Duration::weeks(2)); } /// Verify that we can specify the SIP feed as the data source to use. @@ -187,10 +228,7 @@ mod tests { let api_info = ApiInfo::from_env().unwrap(); let client = Client::new(api_info); - let req = LastQuoteReq { - symbol: "SPY".to_string(), - feed: Some(Feed::SIP), - }; + let req = LastQuoteReq::new(vec!["SPY".to_string()]).with_feed(Feed::SIP); let result = client.issue::(&req).await; // Unfortunately we can't really know whether the user has the @@ -202,13 +240,24 @@ mod tests { } } - /// Verify that we can properly parse a reference bar response. + /// Non-existent symbol is skipped in the result. #[test(tokio::test)] async fn nonexistent_symbol() { let api_info = ApiInfo::from_env().unwrap(); let client = Client::new(api_info); - let req = LastQuoteReqInit::default().init("ABC123"); + let req = LastQuoteReq::new(vec!["SPY".to_string(), "NOSUCHSYMBOL".to_string()]); + let quotes = client.issue::(&req).await.unwrap(); + assert_eq!(quotes.len(), 1); + } + + /// Symbol with characters outside A-Z results in an error response from the server. + #[test(tokio::test)] + async fn bad_symbol() { + let api_info = ApiInfo::from_env().unwrap(); + let client = Client::new(api_info); + + let req = LastQuoteReq::new(vec!["ABC123".to_string()]); let err = client.issue::(&req).await.unwrap_err(); match err { RequestError::Endpoint(GetError::InvalidInput(_)) => (), diff --git a/src/data/v2/last_trade.rs b/src/data/v2/last_trade.rs new file mode 100644 index 00000000..dfeea61b --- /dev/null +++ b/src/data/v2/last_trade.rs @@ -0,0 +1,242 @@ +// Copyright (C) 2021-2022 The apca Developers +// SPDX-License-Identifier: GPL-3.0-or-later + +use chrono::DateTime; +use chrono::Utc; + +use num_decimal::Num; + +use serde::Deserialize; +use serde::Serialize; +use serde_json::from_slice as from_json; +use serde_urlencoded::to_string as to_query; +use std::collections::HashMap; + +use crate::data::v2::Feed; +use crate::data::DATA_BASE_URL; +use crate::Str; + +/// A GET request to be made to the /v2/stocks/{symbol}/trades/latest endpoint. +#[derive(Clone, Serialize, Eq, PartialEq, Debug)] +pub struct LastTradeRequest { + /// Symbols to retrieve the last trade for, comma separated. + pub symbols: String, + /// The data feed to use. + pub feed: Option, +} + +impl LastTradeRequest { + /// Create a new LastTradeRequest. + pub fn new(symbols: Vec) -> Self { + Self { + symbols: symbols.join(",").into(), + feed: None, + } + } + /// Set the data feed to use. + pub fn with_feed(mut self, feed: Feed) -> Self { + self.feed = Some(feed); + self + } +} + +/// A trade data point as returned by the /v2/stocks/{symbol}/trades/latest endpoint. +/// See +/// https://alpaca.markets/docs/api-references/market-data-api/stock-pricing-data/historical/#trade +// TODO: Not all fields are hooked up. +#[derive(Clone, Debug, Serialize, Deserialize, Eq, PartialEq)] +#[non_exhaustive] +pub struct Trade { + /// The time stamp of this trade. + pub time: DateTime, + /// Trade price + pub price: Num, + /// Trade size + pub size: u64, + /// Symbol + pub symbol: String, +} + +impl Trade { + fn from(symbol: &str, point: TradeDataPoint) -> Self { + Self { + time: point.t, + price: point.p, + size: point.s, + symbol: symbol.to_string(), + } + } + + fn parse(body: &[u8]) -> Result, serde_json::Error> { + from_json::(body).map(|response| { + response + .trades + .into_iter() + .map(|(sym, point)| Trade::from(&sym, point)) + .collect() + }) + } +} + +/// fields for individual data points in the response JSON +#[derive(Clone, Debug, Deserialize)] +struct TradeDataPoint { + t: DateTime, + p: Num, + s: u64, +} + +/// A representation of the JSON data in the response +#[derive(Deserialize)] +struct LastTradeResponse { + trades: HashMap, +} + +EndpointNoParse! { + /// The representation of a GET request to the + /// /v2/stocks/trades/latest endpoint. + pub Get(LastTradeRequest), + Ok => Vec, [ + /// The last Trade was retrieved successfully. + /* 200 */ OK, + ], + Err => GetError, [ + /// The provided symbol was invalid or not found or the data feed is + /// not supported. + /* 422 */ UNPROCESSABLE_ENTITY => InvalidInput, + ] + + fn base_url() -> Option { + Some(DATA_BASE_URL.into()) + } + + fn path(_input: &Self::Input) -> Str { + "/v2/stocks/trades/latest".into() + } + + fn query(input: &Self::Input) -> Result, Self::ConversionError> { + Ok(Some(to_query(input)?.into())) + } + + fn parse(body: &[u8]) -> Result { + Trade::parse(body).map_err(Self::ConversionError::from) + } + + fn parse_err(body: &[u8]) -> Result> { + from_json::(body).map_err(|_| body.to_vec()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + use chrono::Duration; + + use test_log::test; + + use crate::api_info::ApiInfo; + use crate::Client; + use crate::RequestError; + + /// Check that we can parse the reference trade from the + /// documentation. + #[test] + fn parse_reference_trade() { + let response = br#"{ + "trades": { + "TSLA": { + "t": "2022-04-12T17:05:06.936423531Z", + "x": "V", + "p": 995, + "s": 100, + "c": ["@"], + "i": 10741, + "z": "C" + }, + "AAPL": { + "t": "2022-04-12T17:05:17.428334819Z", + "x": "V", + "p": 167.86, + "s": 99, + "c": ["@"], + "i": 7980, + "z": "C" + } + } + }"#; + + let mut result = Trade::parse(response).unwrap(); + result.sort_by_key(|t| t.time); + assert_eq!(result.len(), 2); + assert_eq!(result[1].price, Num::new(16786, 100)); + assert_eq!(result[1].size, 99); + assert_eq!(result[1].symbol, "AAPL".to_string()); + assert_eq!( + result[1].time, + DateTime::parse_from_rfc3339("2022-04-12T17:05:17.428334819Z").unwrap() + ); + } + + /// Verify that we can retrieve the last trade for an asset. + #[test(tokio::test)] + async fn request_last_trade() { + let api_info = ApiInfo::from_env().unwrap(); + let client = Client::new(api_info); + + let req = LastTradeRequest::new(vec!["SPY".to_string()]); + let trades = client.issue::(&req).await.unwrap(); + // Just as a rough sanity check, we require that the reported time + // is some time after two weeks before today. That should safely + // account for any combination of holidays, weekends, etc. + assert!(trades[0].time >= Utc::now() - Duration::weeks(2)); + // This test will fail if SPY goes below $1, but in that case a lot else is wrong with the world. + assert!(trades[0].price >= Num::new(1, 1)); + } + + /// Retrieve multiple symbols at once. + #[test(tokio::test)] + async fn request_last_trades_multi() { + let api_info = ApiInfo::from_env().unwrap(); + let client = Client::new(api_info); + + let req = LastTradeRequest::new(vec![ + "SPY".to_string(), + "QQQ".to_string(), + "MSFT".to_string(), + ]); + let trades = client.issue::(&req).await.unwrap(); + assert_eq!(trades.len(), 3); + assert!(trades[0].time >= Utc::now() - Duration::weeks(2)); + assert!(trades[0].price >= Num::new(1, 1)); + } + + /// Verify that we can specify the SIP feed as the data source to use. + #[test(tokio::test)] + async fn sip_feed() { + let api_info = ApiInfo::from_env().unwrap(); + let client = Client::new(api_info); + + let req = LastTradeRequest::new(vec!["SPY".to_string()]).with_feed(Feed::SIP); + + let result = client.issue::(&req).await; + // Unfortunately we can't really know whether the user has the + // unlimited plan and can access the SIP feed. So really all we can + // do here is accept both possible outcomes. + match result { + Ok(_) | Err(RequestError::Endpoint(GetError::InvalidInput(_))) => (), + err => panic!("Received unexpected error: {:?}", err), + } + } + + /// A bad symbol should not result in an error, but skips it in the result + #[test(tokio::test)] + async fn nonexistent_symbol() { + let api_info = ApiInfo::from_env().unwrap(); + let client = Client::new(api_info); + + let req = LastTradeRequest::new(vec!["BZZZZZZT".to_string(), "AAPL".to_string()]); + let trades = client.issue::(&req).await.unwrap(); + assert_eq!(trades.len(), 1); + } +} diff --git a/src/data/v2/mod.rs b/src/data/v2/mod.rs index 4a4fab55..9f0c44ef 100644 --- a/src/data/v2/mod.rs +++ b/src/data/v2/mod.rs @@ -8,6 +8,8 @@ mod unfold; pub mod bars; /// Functionality for retrieval of the most recent quote. pub mod last_quote; +/// Functionality for retrieval of the most recent trade(s). +pub mod last_trade; /// Functionality for retrieving historic quotes. pub mod quotes; /// Definitions for real-time streaming of market data.