From 07db997e44b8832b733c838e0a5ef9759e5b0760 Mon Sep 17 00:00:00 2001 From: Reuben Wong Date: Fri, 20 Oct 2023 21:27:41 +0800 Subject: [PATCH] Implement Splits Endpoint (#16) * remove unused basic endpoint * add implementation for splits endpoint TODO: add unit tests * add unit tests for splits endpoint * add splits types and corresponding unit tests for deserialization * complete splits endpoint implementation --- Cargo.toml | 2 +- src/api.rs | 4 +- src/api/basic.rs | 45 --------- src/api/eod.rs | 4 +- src/api/splits.rs | 232 ++++++++++++++++++++++++++++++++++++++++++++++ src/types.rs | 120 +++++++++++++++++++----- tests/splits.rs | 66 +++++++++++++ 7 files changed, 401 insertions(+), 72 deletions(-) delete mode 100644 src/api/basic.rs create mode 100644 src/api/splits.rs create mode 100644 tests/splits.rs diff --git a/Cargo.toml b/Cargo.toml index d82d24c..7c1221c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "marketstack" -version = "0.0.3" +version = "0.0.4" edition = "2021" license = "MIT" description = "Rust bindings for Marketstack REST API" diff --git a/src/api.rs b/src/api.rs index a349a1a..457781c 100644 --- a/src/api.rs +++ b/src/api.rs @@ -44,7 +44,6 @@ //! let pageable_endpoint = eod::Eod::builder().symbol("AAPL").limit(5).unwrap().build().unwrap(); //! ``` -mod basic; mod client; mod endpoint; mod error; @@ -58,6 +57,7 @@ pub mod endpoint_prelude; pub mod common; pub mod eod; pub mod paged; +pub mod splits; pub use self::client::AsyncClient; pub use self::client::Client; @@ -81,6 +81,4 @@ pub use self::query::Query; pub use self::raw::raw; pub use self::raw::Raw; -pub use self::basic::BasicEndpoint; - pub use self::paged::PageLimit; diff --git a/src/api/basic.rs b/src/api/basic.rs deleted file mode 100644 index 2623ac9..0000000 --- a/src/api/basic.rs +++ /dev/null @@ -1,45 +0,0 @@ -use derive_builder::Builder; - -use crate::api::endpoint_prelude::*; - -/// Dummy endpoint that is not tied to a Marketstack endpoint. -#[derive(Debug, Clone, Copy, Builder)] -pub struct BasicEndpoint {} - -impl BasicEndpoint { - /// Create a builder for the endpoint. - pub fn builder() -> BasicEndpointBuilder { - BasicEndpointBuilder::default() - } -} - -impl Endpoint for BasicEndpoint { - fn method(&self) -> Method { - Method::GET - } - - fn endpoint(&self) -> Cow<'static, str> { - "".into() - } -} - -#[cfg(test)] -mod tests { - use crate::api::basic::BasicEndpoint; - use crate::api::{self, Query}; - use crate::test::client::{ExpectedUrl, SingleTestClient}; - - #[test] - fn defaults_are_sufficient() { - BasicEndpoint::builder().build().unwrap(); - } - - #[test] - fn endpoint() { - let endpoint = ExpectedUrl::builder().endpoint("").build().unwrap(); - let client = SingleTestClient::new_raw(endpoint, ""); - - let endpoint = BasicEndpoint::builder().build().unwrap(); - api::ignore(endpoint).query(&client).unwrap(); - } -} diff --git a/src/api/eod.rs b/src/api/eod.rs index ae0a1c2..671ef35 100644 --- a/src/api/eod.rs +++ b/src/api/eod.rs @@ -1,4 +1,4 @@ -//! Implemented endpoints for eod, eod/latest and eod/[date]. +//! Implemented endpoints for `eod`, `eod/latest `and `eod/[date]`. use std::collections::BTreeSet; @@ -9,7 +9,7 @@ use crate::api::common::SortOrder; use crate::api::paged::PaginationError; use crate::api::{endpoint_prelude::*, ApiError}; -/// Query for eod. +/// Query for `eod`. #[derive(Debug, Builder, Clone)] #[builder(setter(strip_option))] pub struct Eod<'a> { diff --git a/src/api/splits.rs b/src/api/splits.rs new file mode 100644 index 0000000..d434f68 --- /dev/null +++ b/src/api/splits.rs @@ -0,0 +1,232 @@ +//! Implemented endpoints for `splits` + +use std::collections::BTreeSet; + +use chrono::NaiveDate; +use derive_builder::Builder; + +use crate::api::common::SortOrder; +use crate::api::paged::PaginationError; +use crate::api::{endpoint_prelude::*, ApiError}; + +/// Query for `splits`. +#[derive(Debug, Builder, Clone)] +#[builder(setter(strip_option))] +pub struct Splits<'a> { + /// Search for `splits` for a symbol. + #[builder(setter(name = "_symbols"), default)] + symbols: BTreeSet>, + /// The sort order for the return results. + #[builder(default)] + sort: Option, + /// Date to query EOD data from. + #[builder(default)] + date_from: Option, + /// Date to query EOD date to. + #[builder(default)] + date_to: Option, + /// Pagination limit for API request. + #[builder(setter(name = "_limit"), default)] + limit: Option, + /// Pagination offset value for API request. + #[builder(default)] + offset: Option, +} + +impl<'a> Splits<'a> { + /// Create a bulder for this endpoint. + pub fn builder() -> SplitsBuilder<'a> { + SplitsBuilder::default() + } +} + +impl<'a> SplitsBuilder<'a> { + /// Search the given symbol. + /// + /// This provides sane defaults for the user to call symbol() + /// on the builder without needing to wrap his symbol in a + /// BTreeSet beforehand. + pub fn symbol(&mut self, symbol: &'a str) -> &mut Self { + self.symbols + .get_or_insert_with(BTreeSet::new) + .insert(symbol.into()); + self + } + + /// Search the given symbols. + pub fn symbols(&mut self, iter: I) -> &mut Self + where + I: Iterator, + V: Into>, + { + self.symbols + .get_or_insert_with(BTreeSet::new) + .extend(iter.map(|v| v.into())); + self + } + + /// Limit the number of results returned. + pub fn limit(&mut self, limit: u16) -> Result<&mut Self, ApiError> { + let new = self; + new.limit = Some(Some(PageLimit::new(limit)?)); + Ok(new) + } +} + +impl<'a> Endpoint for Splits<'a> { + fn method(&self) -> Method { + Method::GET + } + + fn endpoint(&self) -> Cow<'static, str> { + "splits".into() + } + + fn parameters(&self) -> QueryParams { + let mut params = QueryParams::default(); + + params + .extend(self.symbols.iter().map(|value| ("symbols", value))) + .push_opt("sort", self.sort) + .push_opt("date_from", self.date_from) + .push_opt("date_to", self.date_to) + .push_opt("limit", self.limit.clone()) + .push_opt("offset", self.offset); + + params + } +} + +#[cfg(test)] +mod tests { + + use chrono::NaiveDate; + + use crate::api::common::SortOrder; + use crate::api::splits::Splits; + use crate::api::{self, Query}; + use crate::test::client::{ExpectedUrl, SingleTestClient}; + + #[test] + fn splits_defaults_are_sufficient() { + Splits::builder().build().unwrap(); + } + + #[test] + fn splits_endpoint() { + let endpoint = ExpectedUrl::builder().endpoint("splits").build().unwrap(); + let client = SingleTestClient::new_raw(endpoint, ""); + + let endpoint = Splits::builder().build().unwrap(); + api::ignore(endpoint).query(&client).unwrap(); + } + + #[test] + fn splits_symbol() { + let endpoint = ExpectedUrl::builder() + .endpoint("splits") + .add_query_params(&[("symbols", "AAPL")]) + .build() + .unwrap(); + let client = SingleTestClient::new_raw(endpoint, ""); + + let endpoint = Splits::builder().symbol("AAPL").build().unwrap(); + api::ignore(endpoint).query(&client).unwrap(); + } + + #[test] + fn splits_symbols() { + let endpoint = ExpectedUrl::builder() + .endpoint("splits") + .add_query_params(&[("symbols", "AAPL"), ("symbols", "GOOG")]) + .build() + .unwrap(); + let client = SingleTestClient::new_raw(endpoint, ""); + + let endpoint = Splits::builder() + .symbol("AAPL") + .symbols(["AAPL", "GOOG"].iter().copied()) + .build() + .unwrap(); + api::ignore(endpoint).query(&client).unwrap(); + } + + #[test] + fn splits_sort() { + let endpoint = ExpectedUrl::builder() + .endpoint("splits") + .add_query_params(&[("sort", "ASC")]) + .build() + .unwrap(); + let client = SingleTestClient::new_raw(endpoint, ""); + + let endpoint = Splits::builder() + .sort(SortOrder::Ascending) + .build() + .unwrap(); + api::ignore(endpoint).query(&client).unwrap(); + } + + #[test] + fn splits_date_from() { + let endpoint = ExpectedUrl::builder() + .endpoint("splits") + .add_query_params(&[("date_from", "2020-01-01")]) + .build() + .unwrap(); + let client = SingleTestClient::new_raw(endpoint, ""); + + let endpoint = Splits::builder() + .date_from(NaiveDate::from_ymd_opt(2020, 1, 1).unwrap()) + .build() + .unwrap(); + api::ignore(endpoint).query(&client).unwrap(); + } + + #[test] + fn splits_date_to() { + let endpoint = ExpectedUrl::builder() + .endpoint("splits") + .add_query_params(&[("date_to", "2020-01-01")]) + .build() + .unwrap(); + let client = SingleTestClient::new_raw(endpoint, ""); + + let endpoint = Splits::builder() + .date_to(NaiveDate::from_ymd_opt(2020, 1, 1).unwrap()) + .build() + .unwrap(); + api::ignore(endpoint).query(&client).unwrap(); + } + + #[test] + fn splits_limit() { + let endpoint = ExpectedUrl::builder() + .endpoint("splits") + .add_query_params(&[("limit", "50")]) + .build() + .unwrap(); + let client = SingleTestClient::new_raw(endpoint, ""); + + let endpoint = Splits::builder().limit(50).unwrap().build().unwrap(); + api::ignore(endpoint).query(&client).unwrap(); + } + + #[test] + fn splits_over_limit() { + assert!(Splits::builder().limit(9999).is_err()); + } + + #[test] + fn splits_offset() { + let endpoint = ExpectedUrl::builder() + .endpoint("splits") + .add_query_params(&[("offset", "2")]) + .build() + .unwrap(); + let client = SingleTestClient::new_raw(endpoint, ""); + + let endpoint = Splits::builder().offset(2).build().unwrap(); + api::ignore(endpoint).query(&client).unwrap(); + } +} diff --git a/src/types.rs b/src/types.rs index b77f023..ec096dc 100644 --- a/src/types.rs +++ b/src/types.rs @@ -1,9 +1,7 @@ -use chrono::{DateTime, Utc}; -use serde::{Deserialize, Serialize}; +//! Contains Rust types of deserialized responses from Marketstack REST API. -/// Basic struct that acts as dummy. -#[derive(Serialize, Deserialize, Debug, Clone)] -pub struct BasicPublic {} +use chrono::{DateTime, NaiveDate, Utc}; +use serde::{Deserialize, Serialize}; /// Pagination Information returned by Marketstack API. #[derive(Serialize, Deserialize, Debug, Clone)] @@ -18,50 +16,76 @@ pub struct PaginationInfo { pub total: u64, } +/// Rust representation of single data item from Marketstack `eod` response. #[derive(Serialize, Deserialize, Debug, Clone)] pub struct EodDataItem { /// Exact date/time the given data was collected in ISO-8601 format. - date: DateTime, + pub date: DateTime, /// Stock ticker symbol of the current data object. - symbol: String, + pub symbol: String, /// Exchange MIC identification associated with the current data object. - exchange: String, + pub exchange: String, /// Split factor used to adjust prices when a company splits, reverse splits or pays a /// distribution. - split_factor: f64, + pub split_factor: f64, /// Distribution of earnings to shareholders. - dividend: f64, + pub dividend: f64, /// Raw opening price of the given stock ticker. - open: f64, + pub open: f64, /// Raw high price of the given stock ticker. - high: f64, + pub high: f64, /// Raw low price of the given stock ticker. - low: f64, + pub low: f64, /// Raw closing price of the given stock ticker. - close: f64, + pub close: f64, /// Raw volume of the given stock ticker. - volume: f64, + pub volume: f64, /// Adjusted opening price of the given stock ticker. - adj_open: f64, + pub adj_open: f64, /// Adjusted high price of the given stock ticker. - adj_high: f64, + pub adj_high: f64, /// Adjusted low price of the given stock ticker. - adj_low: f64, + pub adj_low: f64, /// Adjusted closing price of the given stock ticker. - adj_close: f64, + pub adj_close: f64, /// Adjusted volume of the given stock ticker. - adj_volume: f64, + pub adj_volume: f64, } +/// Rust representation of the JSON response from `eod` marketstack endpoint. #[derive(Serialize, Deserialize, Debug, Clone)] pub struct EodData { + /// Corresponds to pagination entry from JSON response from marketstack. pub pagination: PaginationInfo, + /// Corresponds to data entry from JSON response from marketstack. pub data: Vec, } +/// Rust representation of single data item from Marketstack `splits` response. +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct SplitsDataItem { + /// Exact date/time the given data was collected in ISO-8601 format. + pub date: NaiveDate, + /// Split factor for that symbol on the date. + pub split_factor: f64, + /// Stock ticker symbol of the current data object. + pub symbol: String, +} + +/// Rust representation of the JSON response from `splits` marketstack endpoint. +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct SplitsData { + /// Corresponds to pagination entry from JSON response from marketstack. + pub pagination: PaginationInfo, + /// Corresponds to data entry from JSON response from marketstack. + pub data: Vec, +} + #[cfg(test)] mod tests { - use super::EodData; + use chrono::NaiveDate; + + use super::{EodData, SplitsData}; #[test] fn test_deserialize_eod() { @@ -98,4 +122,58 @@ mod tests { assert_eq!(eod_data.data[0].symbol, "AAPL"); assert_eq!(eod_data.pagination.limit, 100); } + + #[test] + fn test_deserialize_splits() { + let json_data = r#"{ + "pagination": { + "limit": 100, + "offset": 0, + "count": 5, + "total": 5 + }, + "data": [ + { + "date": "2020-08-31", + "split_factor": 4, + "symbol": "AAPL" + }, + { + "date": "2014-06-09", + "split_factor": 7, + "symbol": "AAPL" + }, + { + "date": "2005-02-28", + "split_factor": 2, + "symbol": "AAPL" + }, + { + "date": "2000-06-21", + "split_factor": 2, + "symbol": "AAPL" + }, + { + "date": "1987-06-16", + "split_factor": 2, + "symbol": "AAPL" + } + ] + }"#; + + let splits_data: SplitsData = serde_json::from_str(json_data).unwrap(); + assert_eq!(splits_data.data[0].split_factor, 4.0); + assert_eq!( + splits_data.data[0].date, + NaiveDate::from_ymd_opt(2020, 8, 31).unwrap() + ); + assert_eq!(splits_data.data[0].symbol, "AAPL"); + + assert_eq!(splits_data.data[4].split_factor, 2.0); + assert_eq!( + splits_data.data[4].date, + NaiveDate::from_ymd_opt(1987, 6, 16).unwrap() + ); + assert_eq!(splits_data.data[4].symbol, "AAPL"); + } } diff --git a/tests/splits.rs b/tests/splits.rs new file mode 100644 index 0000000..add8494 --- /dev/null +++ b/tests/splits.rs @@ -0,0 +1,66 @@ +use chrono::NaiveDate; + +use marketstack::api::{splits, AsyncQuery, Query}; +use marketstack::{AsyncMarketstack, Marketstack, SplitsData}; + +mod setup; + +#[test] +#[ignore] +fn test_splits() { + let api_key = setup::setup_key(); + let client = Marketstack::new_insecure("api.marketstack.com", api_key).unwrap(); + + let endpoint = splits::Splits::builder() + .symbol("AAPL") + .limit(3) + .unwrap() + .build() + .unwrap(); + let splits_result: SplitsData = endpoint.query(&client).unwrap(); + + assert_eq!(splits_result.pagination.limit, 3); + assert_eq!(splits_result.pagination.offset, 0); + + assert_eq!(splits_result.data.len(), 3); +} + +#[tokio::test] +#[ignore] +async fn test_async_splits() { + let api_key = setup::setup_key(); + let client = AsyncMarketstack::new_insecure("api.marketstack.com", api_key) + .await + .unwrap(); + + let endpoint = splits::Splits::builder() + .limit(3) + .unwrap() + .symbol("AAPL") + .build() + .unwrap(); + let eod_result: SplitsData = endpoint.query_async(&client).await.unwrap(); + + assert_eq!(eod_result.pagination.limit, 3); + assert_eq!(eod_result.pagination.offset, 0); + + assert_eq!(eod_result.data.len(), 3); +} + +#[test] +#[ignore] +fn test_splits_date() { + let api_key = setup::setup_key(); + let client = Marketstack::new_insecure("api.marketstack.com", api_key).unwrap(); + + let endpoint = splits::Splits::builder() + .symbol("AAPL") + .date_from(NaiveDate::from_ymd_opt(2020, 8, 29).unwrap()) + .date_to(NaiveDate::from_ymd_opt(2020, 9, 2).unwrap()) + .build() + .unwrap(); + let splits_result: SplitsData = endpoint.query(&client).unwrap(); + + assert_eq!(splits_result.data.len(), 1); + assert_eq!(splits_result.data[0].split_factor, 4.0); +}