Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Latest trade data #43

Closed
wants to merge 1 commit into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 {
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't see a good reason why this type is being removed?

/// 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)]
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why is this change necessary?

#[non_exhaustive]
pub struct Quote {
/// The time stamp of this quote.
#[serde(rename = "t")]
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why are these renames being removed?

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>,
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why a HashMap repesentation? We should likely expose what we get.

}

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()
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Don't need to format if you are not formatting.

}

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())
}
}


Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Keep needless useless changes to a minimum?

#[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": {
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please no tabs in code using spaces?

"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