apca/data/v2/
trades.rs

1// Copyright (C) 2022-2024 The apca Developers
2// SPDX-License-Identifier: GPL-3.0-or-later
3
4use chrono::DateTime;
5use chrono::Utc;
6
7use num_decimal::Num;
8
9use serde::Deserialize;
10use serde::Serialize;
11use serde_urlencoded::to_string as to_query;
12
13use crate::data::v2::Feed;
14use crate::data::DATA_BASE_URL;
15use crate::util::vec_from_str;
16use crate::Str;
17
18
19/// A GET request to be issued to the /v2/stocks/{symbol}/trades endpoint.
20#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
21pub struct ListReq {
22  /// The symbol for which to retrieve market data.
23  #[serde(skip)]
24  pub symbol: String,
25  /// The maximum number of trades to be returned for each symbol.
26  ///
27  /// It can be between 1 and 10000. Defaults to 1000 if the provided
28  /// value is `None`.
29  #[serde(rename = "limit")]
30  pub limit: Option<usize>,
31  /// Filter trades equal to or after this time.
32  #[serde(rename = "start")]
33  pub start: DateTime<Utc>,
34  /// Filter trades equal to or before this time.
35  #[serde(rename = "end")]
36  pub end: DateTime<Utc>,
37  /// The data feed to use.
38  ///
39  /// Defaults to [`IEX`][Feed::IEX] for free users and
40  /// [`SIP`][Feed::SIP] for users with an unlimited subscription.
41  #[serde(rename = "feed")]
42  pub feed: Option<Feed>,
43  /// If provided we will pass a page token to continue where we left off.
44  #[serde(rename = "page_token", skip_serializing_if = "Option::is_none")]
45  pub page_token: Option<String>,
46  /// The type is non-exhaustive and open to extension.
47  #[doc(hidden)]
48  #[serde(skip)]
49  pub _non_exhaustive: (),
50}
51
52
53/// A helper for initializing [`ListReq`] objects.
54#[derive(Clone, Debug, Default, Eq, PartialEq)]
55pub struct ListReqInit {
56  /// See `ListReq::limit`.
57  pub limit: Option<usize>,
58  /// See `ListReq::feed`.
59  pub feed: Option<Feed>,
60  /// See `ListReq::page_token`.
61  pub page_token: Option<String>,
62  /// The type is non-exhaustive and open to extension.
63  #[doc(hidden)]
64  pub _non_exhaustive: (),
65}
66
67impl ListReqInit {
68  /// Create a [`ListReq`] from a `ListReqInit`.
69  #[inline]
70  pub fn init<S>(self, symbol: S, start: DateTime<Utc>, end: DateTime<Utc>) -> ListReq
71  where
72    S: Into<String>,
73  {
74    ListReq {
75      symbol: symbol.into(),
76      start,
77      end,
78      limit: self.limit,
79      feed: self.feed,
80      page_token: self.page_token,
81      _non_exhaustive: (),
82    }
83  }
84}
85
86
87/// A market data trade as returned by the /v2/stocks/{symbol}/trades endpoint.
88#[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
89pub struct Trade {
90  /// Time of the trade.
91  #[serde(rename = "t")]
92  pub timestamp: DateTime<Utc>,
93  /// The price of the trade.
94  #[serde(rename = "p")]
95  pub price: Num,
96  /// The size of the trade.
97  #[serde(rename = "s")]
98  pub size: usize,
99  /// The type is non-exhaustive and open to extension.
100  #[doc(hidden)]
101  #[serde(skip)]
102  pub _non_exhaustive: (),
103}
104
105
106/// A collection of trades as returned by the API. This is one page of trades.
107#[derive(Debug, Deserialize, Eq, PartialEq)]
108pub struct Trades {
109  /// The list of returned trades.
110  #[serde(rename = "trades", deserialize_with = "vec_from_str")]
111  pub trades: Vec<Trade>,
112  /// The symbol the trades correspond to.
113  #[serde(rename = "symbol")]
114  pub symbol: String,
115  /// The token to provide to a request to get the next page of trades for this request.
116  #[serde(rename = "next_page_token")]
117  pub next_page_token: Option<String>,
118  /// The type is non-exhaustive and open to extension.
119  #[doc(hidden)]
120  #[serde(skip)]
121  pub _non_exhaustive: (),
122}
123
124
125Endpoint! {
126  /// The representation of a GET request to the /v2/stocks/{symbol}/trades endpoint.
127  pub List(ListReq),
128  Ok => Trades, [
129    /// The market data was retrieved successfully.
130    /* 200 */ OK,
131  ],
132  Err => ListError, [
133    /// A query parameter was invalid.
134    /* 400 */ BAD_REQUEST => InvalidInput,
135  ]
136
137  fn base_url() -> Option<Str> {
138    Some(DATA_BASE_URL.into())
139  }
140
141  fn path(input: &Self::Input) -> Str {
142    format!("/v2/stocks/{}/trades", input.symbol).into()
143  }
144
145  fn query(input: &Self::Input) -> Result<Option<Str>, Self::ConversionError> {
146    Ok(Some(to_query(input)?.into()))
147  }
148}
149
150
151#[cfg(test)]
152mod tests {
153  use super::*;
154
155  use std::str::FromStr as _;
156
157  use http_endpoint::Endpoint;
158
159  use serde_json::from_str as from_json;
160
161  use test_log::test;
162
163  use crate::api_info::ApiInfo;
164  use crate::Client;
165  use crate::RequestError;
166
167
168  /// Verify that we can properly parse a reference trades response.
169  #[test]
170  fn parse_reference_trades() {
171    let response = r#"{
172    "trades": [
173      {
174        "t": "2021-02-06T13:04:56.334320128Z",
175        "x": "C",
176        "p": 387.62,
177        "s": 100,
178        "c": [
179            " ",
180            "T"
181        ],
182        "i": 52983525029461,
183        "z": "B"
184      },
185      {
186        "t": "2021-02-06T13:09:42.325484032Z",
187        "x": "C",
188        "p": 387.69,
189        "s": 100,
190        "c": [
191            " ",
192            "T"
193        ],
194        "i": 52983525033813,
195        "z": "B"
196      }
197    ],
198    "symbol": "SPY",
199    "next_page_token": "MjAyMS0wMi0wNlQxMzowOTo0Mlo7MQ=="
200}"#;
201
202    let res = from_json::<<List as Endpoint>::Output>(response).unwrap();
203    let trades = res.trades;
204    let expected_time = "2021-02-06T13:04:56";
205    assert_eq!(trades.len(), 2);
206    let timestamp = trades[0].timestamp.to_rfc3339();
207    assert!(timestamp.starts_with(expected_time), "{timestamp}");
208    assert_eq!(trades[0].price, Num::new(38762, 100));
209    assert_eq!(trades[0].size, 100);
210    assert_eq!(res.symbol, "SPY".to_string());
211    assert!(res.next_page_token.is_some())
212  }
213
214  /// Check that we can decode a response containing no trades correctly.
215  #[test(tokio::test)]
216  async fn no_trades() {
217    let api_info = ApiInfo::from_env().unwrap();
218    let client = Client::new(api_info);
219    let start = DateTime::from_str("2021-11-05T00:00:00Z").unwrap();
220    let end = DateTime::from_str("2021-11-05T00:00:00Z").unwrap();
221    let request = ListReqInit::default().init("AAPL", start, end);
222
223    let res = client.issue::<List>(&request).await.unwrap();
224    assert_eq!(res.trades, Vec::new())
225  }
226
227  /// Check that we can request historic trade data for a stock.
228  #[test(tokio::test)]
229  async fn request_trades() {
230    let api_info = ApiInfo::from_env().unwrap();
231    let client = Client::new(api_info);
232    let start = DateTime::from_str("2018-12-03T21:47:00Z").unwrap();
233    let end = DateTime::from_str("2018-12-06T21:47:00Z").unwrap();
234    let request = ListReqInit {
235      limit: Some(2),
236      ..Default::default()
237    }
238    .init("AAPL", start, end);
239
240    let res = client.issue::<List>(&request).await.unwrap();
241    let trades = res.trades;
242
243    let expected_time = "2018-12-03T21:47:01";
244    assert_eq!(trades.len(), 2);
245    let timestamp = trades[0].timestamp.to_rfc3339();
246    assert!(timestamp.starts_with(expected_time), "{timestamp}");
247    assert_eq!(trades[0].price, Num::new(4608, 25));
248    assert_eq!(trades[0].size, 6);
249    assert_eq!(res.symbol, "AAPL".to_string());
250    assert!(res.next_page_token.is_some())
251  }
252
253  /// Verify that we can request data through a provided page token.
254  #[test(tokio::test)]
255  async fn can_follow_pagination() {
256    let api_info = ApiInfo::from_env().unwrap();
257    let client = Client::new(api_info);
258    let start = DateTime::from_str("2020-12-03T21:47:00Z").unwrap();
259    let end = DateTime::from_str("2020-12-07T21:47:00Z").unwrap();
260    let mut request = ListReqInit {
261      limit: Some(2),
262      ..Default::default()
263    }
264    .init("AAPL", start, end);
265
266    let mut res = client.issue::<List>(&request).await.unwrap();
267    let trades = res.trades;
268
269    assert_eq!(trades.len(), 2);
270    request.page_token = res.next_page_token;
271
272    res = client.issue::<List>(&request).await.unwrap();
273    let new_trades = res.trades;
274
275    assert_eq!(new_trades.len(), 2);
276    assert!(new_trades[0].timestamp > trades[1].timestamp);
277    assert!(res.next_page_token.is_some())
278  }
279
280  /// Verify that we can specify the SIP feed as the data source to use.
281  #[test(tokio::test)]
282  async fn sip_feed() {
283    let api_info = ApiInfo::from_env().unwrap();
284    let client = Client::new(api_info);
285    let start = DateTime::from_str("2018-12-03T21:47:00Z").unwrap();
286    let end = DateTime::from_str("2018-12-06T21:47:00Z").unwrap();
287    let request = ListReqInit {
288      limit: Some(2),
289      ..Default::default()
290    }
291    .init("AAPL", start, end);
292
293    let result = client.issue::<List>(&request).await;
294    // Unfortunately we can't really know whether the user has the
295    // unlimited plan and can access the SIP feed. So really all we can
296    // do here is accept both possible outcomes.
297    match result {
298      Ok(_) | Err(RequestError::Endpoint(ListError::NotPermitted(_))) => (),
299      err => panic!("Received unexpected error: {err:?}"),
300    }
301  }
302
303  /// Check that we fail as expected when an invalid page token is
304  /// specified.
305  #[test(tokio::test)]
306  async fn invalid_page_token() {
307    let api_info = ApiInfo::from_env().unwrap();
308    let client = Client::new(api_info);
309
310    let start = DateTime::from_str("2018-12-03T21:47:00Z").unwrap();
311    let end = DateTime::from_str("2018-12-07T21:47:00Z").unwrap();
312    let request = ListReqInit {
313      page_token: Some("123456789abcdefghi".to_string()),
314      ..Default::default()
315    }
316    .init("SPY", start, end);
317
318    let err = client.issue::<List>(&request).await.unwrap_err();
319    match err {
320      RequestError::Endpoint(ListError::InvalidInput(_)) => (),
321      _ => panic!("Received unexpected error: {err:?}"),
322    };
323  }
324
325  /// Verify that we error out as expected when attempting to retrieve
326  /// aggregate data trades for an invalid symbol.
327  #[test(tokio::test)]
328  async fn invalid_symbol() {
329    let api_info = ApiInfo::from_env().unwrap();
330    let client = Client::new(api_info);
331
332    let start = DateTime::from_str("2022-02-01T00:00:00Z").unwrap();
333    let end = DateTime::from_str("2022-02-20T00:00:00Z").unwrap();
334    let request = ListReqInit::default().init("ABC123", start, end);
335
336    let err = client.issue::<List>(&request).await.unwrap_err();
337    match err {
338      RequestError::Endpoint(ListError::InvalidInput(Ok(_))) => (),
339      _ => panic!("Received unexpected error: {err:?}"),
340    };
341  }
342}