apca/data/v2/
last_quotes.rs

1// Copyright (C) 2021-2024 The apca Developers
2// SPDX-License-Identifier: GPL-3.0-or-later
3
4use std::collections::BTreeMap;
5
6use chrono::DateTime;
7use chrono::Utc;
8
9use num_decimal::Num;
10
11use serde::Deserialize;
12use serde::Serialize;
13use serde_json::from_slice as from_json;
14use serde_urlencoded::to_string as to_query;
15
16use crate::data::v2::Feed;
17use crate::data::DATA_BASE_URL;
18use crate::util::string_slice_to_str;
19use crate::Str;
20
21
22/// A GET request to be made to the /v2/stocks/quotes/latest endpoint.
23#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
24pub struct GetReq {
25  /// The symbols to retrieve the last quote for.
26  #[serde(rename = "symbols", serialize_with = "string_slice_to_str")]
27  pub symbols: Vec<String>,
28  /// The data feed to use.
29  #[serde(rename = "feed")]
30  pub feed: Option<Feed>,
31  /// The type is non-exhaustive and open to extension.
32  #[doc(hidden)]
33  #[serde(skip)]
34  pub _non_exhaustive: (),
35}
36
37
38/// A helper for initializing [`GetReq`] objects.
39#[derive(Clone, Debug, Default, Eq, PartialEq)]
40#[allow(missing_copy_implementations)]
41pub struct GetReqInit {
42  /// See `GetReq::feed`.
43  pub feed: Option<Feed>,
44  /// The type is non-exhaustive and open to extension.
45  #[doc(hidden)]
46  pub _non_exhaustive: (),
47}
48
49impl GetReqInit {
50  /// Create a [`GetReq`] from a `GetReqInit`.
51  #[inline]
52  pub fn init<I, S>(self, symbols: I) -> GetReq
53  where
54    I: IntoIterator<Item = S>,
55    S: Into<String>,
56  {
57    GetReq {
58      symbols: symbols.into_iter().map(S::into).collect(),
59      feed: self.feed,
60      _non_exhaustive: (),
61    }
62  }
63}
64
65
66/// A quote as returned by the /v2/stocks/quotes/latest endpoint.
67// TODO: Not all fields are hooked up.
68#[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
69pub struct Quote {
70  /// The time stamp of this quote.
71  #[serde(rename = "t")]
72  pub time: DateTime<Utc>,
73  /// The ask price.
74  #[serde(rename = "ap")]
75  pub ask_price: Num,
76  /// The ask size.
77  #[serde(rename = "as")]
78  pub ask_size: u64,
79  /// The bid price.
80  #[serde(rename = "bp")]
81  pub bid_price: Num,
82  /// The bid size.
83  #[serde(rename = "bs")]
84  pub bid_size: u64,
85  /// The type is non-exhaustive and open to extension.
86  #[doc(hidden)]
87  #[serde(skip)]
88  pub _non_exhaustive: (),
89}
90
91
92EndpointNoParse! {
93  /// The representation of a GET request to the
94  /// /v2/stocks/quotes/latest endpoint.
95  pub Get(GetReq),
96  Ok => Vec<(String, Quote)>, [
97    /// The last quotes were retrieved successfully.
98    /* 200 */ OK,
99  ],
100  Err => GetError, [
101    /// The provided symbol was invalid or not found or the data feed is
102    /// not supported.
103    /* 400 */ BAD_REQUEST => InvalidInput,
104  ]
105
106  fn base_url() -> Option<Str> {
107    Some(DATA_BASE_URL.into())
108  }
109
110  fn path(_input: &Self::Input) -> Str {
111    "/v2/stocks/quotes/latest".into()
112  }
113
114  fn query(input: &Self::Input) -> Result<Option<Str>, Self::ConversionError> {
115    Ok(Some(to_query(input)?.into()))
116  }
117
118  fn parse(body: &[u8]) -> Result<Self::Output, Self::ConversionError> {
119    // TODO: Ideally we'd write our own deserialize implementation here
120    //       to create a vector right away instead of going through a
121    //       BTreeMap.
122
123    /// A helper object for parsing the response to a `Get` request.
124    #[derive(Deserialize)]
125    struct Response {
126      /// A mapping from symbols to quote objects.
127      // We use a `BTreeMap` here to have a consistent ordering of
128      // quotes.
129      quotes: BTreeMap<String, Quote>,
130    }
131
132    // We are not interested in the actual `Response` object. Clients
133    // can keep track of what symbol they requested a quote for.
134    from_json::<Response>(body)
135      .map(|response| {
136        response
137          .quotes
138          .into_iter()
139          .collect()
140      })
141      .map_err(Self::ConversionError::from)
142  }
143
144  fn parse_err(body: &[u8]) -> Result<Self::ApiError, Vec<u8>> {
145    from_json::<Self::ApiError>(body).map_err(|_| body.to_vec())
146  }
147}
148
149
150#[cfg(test)]
151mod tests {
152  use super::*;
153
154  use chrono::Duration;
155
156  use http_endpoint::Endpoint as _;
157
158  use test_log::test;
159
160  use crate::api_info::ApiInfo;
161  use crate::Client;
162  use crate::RequestError;
163
164
165  /// Check that we can parse the reference quotes from the
166  /// documentation.
167  #[test]
168  fn parse_reference_quotes() {
169    let response = br#"{
170      "quotes": {
171        "TSLA": {
172          "t": "2022-04-12T17:26:45.009288296Z",
173          "ax": "V",
174          "ap": 1020,
175          "as": 3,
176          "bx": "V",
177          "bp": 990,
178          "bs": 5,
179          "c": ["R"],
180          "z": "C"
181        },
182        "AAPL": {
183          "t": "2022-04-12T17:26:44.962998616Z",
184          "ax": "V",
185          "ap": 170,
186          "as": 1,
187          "bx": "V",
188          "bp": 168.03,
189          "bs": 1,
190          "c": ["R"],
191          "z": "C"
192        }
193      }
194    }"#;
195
196    let quotes = Get::parse(response).unwrap();
197    assert_eq!(quotes.len(), 2);
198
199    assert_eq!(quotes[0].0, "AAPL");
200    let aapl = &quotes[0].1;
201    assert_eq!(
202      aapl.time,
203      DateTime::parse_from_rfc3339("2022-04-12T17:26:44.962998616Z").unwrap()
204    );
205    assert_eq!(aapl.ask_price, Num::new(170, 1));
206    assert_eq!(aapl.ask_size, 1);
207    assert_eq!(aapl.bid_price, Num::new(16803, 100));
208    assert_eq!(aapl.bid_size, 1);
209
210    assert_eq!(quotes[1].0, "TSLA");
211    let tsla = &quotes[1].1;
212    assert_eq!(
213      tsla.time,
214      DateTime::parse_from_rfc3339("2022-04-12T17:26:45.009288296Z").unwrap()
215    );
216    assert_eq!(tsla.ask_price, Num::new(1020, 1));
217    assert_eq!(tsla.ask_size, 3);
218    assert_eq!(tsla.bid_price, Num::new(990, 1));
219    assert_eq!(tsla.bid_size, 5);
220  }
221
222  /// Verify that we can retrieve the last quote for an asset.
223  #[test(tokio::test)]
224  async fn request_last_quotes() {
225    let api_info = ApiInfo::from_env().unwrap();
226    let client = Client::new(api_info);
227
228    let req = GetReqInit::default().init(["SPY"]);
229    let quotes = client.issue::<Get>(&req).await.unwrap();
230    assert_eq!(quotes.len(), 1);
231    assert_eq!(quotes[0].0, "SPY");
232    // Just as a rough sanity check, we require that the reported time
233    // is some time after two weeks before today. That should safely
234    // account for any combination of holidays, weekends, etc.
235    assert!(quotes[0].1.time >= Utc::now() - Duration::try_weeks(2).unwrap());
236  }
237
238  /// Retrieve multiple symbols at once.
239  #[test(tokio::test)]
240  async fn request_last_quotes_multi() {
241    let api_info = ApiInfo::from_env().unwrap();
242    let client = Client::new(api_info);
243
244    let req = GetReqInit::default().init(["MSFT", "SPY", "AAPL"]);
245    let quotes = client.issue::<Get>(&req).await.unwrap();
246    assert_eq!(quotes.len(), 3);
247
248    // We always guarantee lexical order of quotes by symbol.
249    assert_eq!(quotes[0].0, "AAPL");
250    assert!(quotes[0].1.time >= Utc::now() - Duration::try_weeks(2).unwrap());
251    assert_eq!(quotes[1].0, "MSFT");
252    assert!(quotes[1].1.time >= Utc::now() - Duration::try_weeks(2).unwrap());
253    assert_eq!(quotes[2].0, "SPY");
254    assert!(quotes[2].1.time >= Utc::now() - Duration::try_weeks(2).unwrap());
255  }
256
257  /// Verify that we can specify the SIP feed as the data source to use.
258  #[test(tokio::test)]
259  async fn sip_feed() {
260    let api_info = ApiInfo::from_env().unwrap();
261    let client = Client::new(api_info);
262
263    let req = GetReqInit {
264      feed: Some(Feed::SIP),
265      ..Default::default()
266    }
267    .init(["SPY"]);
268
269    let result = client.issue::<Get>(&req).await;
270    // Unfortunately we can't really know whether the user has the
271    // unlimited plan and can access the SIP feed. So really all we can
272    // do here is accept both possible outcomes.
273    match result {
274      Ok(_) | Err(RequestError::Endpoint(GetError::NotPermitted(_))) => (),
275      err => panic!("Received unexpected error: {err:?}"),
276    }
277  }
278
279  /// Verify that we error out as expected when attempting to retrieve
280  /// the last quote for an invalid symbol.
281  #[test(tokio::test)]
282  async fn invalid_symbol() {
283    let api_info = ApiInfo::from_env().unwrap();
284    let client = Client::new(api_info);
285
286    let req = GetReqInit::default().init(["ABC123"]);
287    let err = client.issue::<Get>(&req).await.unwrap_err();
288    match err {
289      RequestError::Endpoint(GetError::InvalidInput(_)) => (),
290      _ => panic!("Received unexpected error: {err:?}"),
291    };
292  }
293
294  /// Check that a non-existent symbol is simply ignored in a request
295  /// for multiple symbols.
296  #[test(tokio::test)]
297  async fn nonexistent_symbol() {
298    let api_info = ApiInfo::from_env().unwrap();
299    let client = Client::new(api_info);
300
301    let req = GetReqInit::default().init(["SPY", "NOSUCHSYMBOL"]);
302    let quotes = client.issue::<Get>(&req).await.unwrap();
303    assert_eq!(quotes.len(), 1);
304  }
305}