Skip to content

Commit

Permalink
Implement Last Trade Multi endpoint and convert last_quote to multi
Browse files Browse the repository at this point in the history
Add support for Latest Multi Trades endpoint to get the latest trade
data for one or more symbols in one call. For consistency, also
converted
the existing latest_quote mod to also fetch and represent multiple
symbols at once.
  • Loading branch information
sbeam committed Nov 15, 2022
1 parent 86184f9 commit 6934b5a
Show file tree
Hide file tree
Showing 4 changed files with 419 additions and 85 deletions.
41 changes: 41 additions & 0 deletions examples/latest-quote-and-trade.rs
Original file line number Diff line number Diff line change
@@ -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::<last_quote::Get>(&quote_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::<last_trade::Get>(&trade_req).await.unwrap();
trades.iter().for_each(|trade| {
println!(
"Latest trade for {}: {} @ {}",
trade.symbol, trade.size, trade.price
);
});
}
219 changes: 134 additions & 85 deletions src/data/v2/last_quote.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Feed>,
}


/// 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<Feed>,
#[doc(hidden)]
pub _non_exhaustive: (),
}

impl LastQuoteReqInit {
/// Create a [`LastQuoteReq`] from a `LastQuoteReqInit`.
#[inline]
pub fn init<S>(self, symbol: S) -> LastQuoteReq
where
S: Into<String>,
{
LastQuoteReq {
symbol: symbol.into(),
feed: self.feed,
impl LastQuoteReq {
/// Create a new LastQuoteReq with the given symbols.
pub fn new(symbols: Vec<String>) -> 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/<symbol>/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<Utc>,
/// 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<Vec<Quote>, serde_json::Error> {
from_json::<LastQuoteResponse>(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<Utc>,
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<String, QuoteDataPoint>,
}

EndpointNoParse! {
/// The representation of a GET request to the
/// /v2/stocks/<symbol>/quotes/latest endpoint.
/// /v2/stocks/quotes/latest endpoint.
pub Get(LastQuoteReq),
Ok => Quote, [
Ok => Vec<Quote>, [
/// The last quote was retrieved successfully.
/* 200 */ OK,
],
Expand All @@ -94,38 +118,23 @@ 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<Option<Str>, Self::ConversionError> {
Ok(Some(to_query(input)?.into()))
}

fn parse(body: &[u8]) -> Result<Self::Output, Self::ConversionError> {
/// 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::<Response>(body)
.map(|response| response.quote)
.map_err(Self::ConversionError::from)
Quote::parse(body).map_err(Self::ConversionError::from)
}

fn parse_err(body: &[u8]) -> Result<Self::ApiError, Vec<u8>> {
from_json::<Self::ApiError>(body).map_err(|_| body.to_vec())
}
}


#[cfg(test)]
mod tests {
use super::*;
Expand All @@ -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::<Quote>(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.
Expand All @@ -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::<Get>(&req).await.unwrap();
let req = LastQuoteReq::new(vec!["SPY".to_string()]);
let quotes = client.issue::<Get>(&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::<Get>(&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.
Expand All @@ -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::<Get>(&req).await;
// Unfortunately we can't really know whether the user has the
Expand All @@ -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::<Get>(&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::<Get>(&req).await.unwrap_err();
match err {
RequestError::Endpoint(GetError::InvalidInput(_)) => (),
Expand Down
Loading

0 comments on commit 6934b5a

Please sign in to comment.