From 42054120e8f410e553d81f97da29c9e3970d2e43 Mon Sep 17 00:00:00 2001 From: Jas Date: Wed, 22 Jun 2022 10:07:59 -0400 Subject: [PATCH] Dev v0.4.4 (#30) * impl clone on TDAClient * remove leftover dbg * add access to auth token in auth client * doc update * Added error checking in TDauth (#29) * refactored auth module and added error capture * Updated readme * able to initiate TDAClientAuth with TDauth struct * allow TDauth struct to be serialized * don't allow new client if no token exists * cargo clippy and fmt * increment version * updated time expiry buffer on tokens * able to create new TDauth from api configuration * updated auth module * changed handling of client_id * fixed bug in auth token when refresh not present * added pricehistory model and tests * Updated Readme * implemented Quote Model * added quote impl functions * serde config on quotes model * added default fields to Account Model * managed client check if refresh avail * version bump * updated readme and changes --- Cargo.toml | 6 +- Changes.md | 8 ++ Readme.md | 10 +- examples/accountmodel.rs | 2 +- src/auth.rs | 246 +++++++++++++++++++++++--------------- src/authmanagedclient.rs | 66 ++++++++-- src/lib.rs | 4 +- src/model/account.rs | 25 +++- src/model/mod.rs | 7 +- src/model/pricehistory.rs | 26 ++++ src/model/quote.rs | 218 ++++++++++++++++++++++++++++++++- src/model/token.rs | 21 ++++ src/tdaclient.rs | 8 +- tests/clienttests.rs | 43 ++++++- 14 files changed, 567 insertions(+), 123 deletions(-) create mode 100644 src/model/pricehistory.rs create mode 100644 src/model/token.rs diff --git a/Cargo.toml b/Cargo.toml index 72652ce..d352d8f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,17 +1,17 @@ [package] name = "tdameritradeclient" -version = "0.4.3" +version = "0.4.4" authors = ["Jas Bertovic "] edition = "2018" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] -attohttpc = { version = "0.16.3", features = ["form"] } +attohttpc = { version = "0.19.1", features = ["form"] } serde_json = "1.0" serde = {version = "1.0", features = ["derive"]} url = "2.1.1" log = "0.4" [dev-dependencies] -env_logger = "0.7" +env_logger = "0.9" diff --git a/Changes.md b/Changes.md index 577d31a..a9b0e4d 100644 --- a/Changes.md +++ b/Changes.md @@ -1,3 +1,11 @@ +# v0.4.4 +- Added visibility to `auth::TDauth` from `TDAClientAuth`. Easier to manage when running library from a server +- Added error checking in `auth::TDauth` +- Able to now initialize `TDAClientAuth` with `auth::TDauth` +- Refactoring in both `auth::TDauth` and `TDAClientAuth`, including a couple of bug fixes +- Added new models: PriceHistory, Quotes and modified Account model +- Doc corrections + # v0.4.3 - Added a new client (`TDAClientAuth`) that manages ungoing token requirements. - `TDAClientAuth` is just a wrapper around `TDAClient` but includes token renewals as needed diff --git a/Readme.md b/Readme.md index 1fdd54e..23d98a9 100644 --- a/Readme.md +++ b/Readme.md @@ -1,9 +1,11 @@ **Disclaimer:** I'm not endorsing and am not affiliated with TD Ameritrade. Be careful using the API and understand that actual orders can be created through this library. Read and understand the TD Ameritrade's api terms of service and documentation before using. -## Note Version 0.4.3 Changes -- Added a new client (`TDAClientAuth`) that manages ungoing token requirements. -- `TDAClientAuth` is just a wrapper around `TDAClient` but includes token renewals as needed -- `auth::TDauth` was updated to include expire times of tokens +## Note Version 0.4.4 Changes +- Added error checking on TDauth module. Used when retrieving tokens. +- Gave TDAClientAuth acces to TDauth struct +- Managed TDAClientAuth has the ability to deal with no token returned due to some error in web retrieval or authorization +- Integrated TDauth module better with TDAClientAuth +- Added model::pricehistory, quotes and modified account model ## Note Version 0.4 Changes diff --git a/examples/accountmodel.rs b/examples/accountmodel.rs index 64d07c6..7ac82fe 100644 --- a/examples/accountmodel.rs +++ b/examples/accountmodel.rs @@ -26,7 +26,7 @@ fn main() { .unwrap(); // pull out positions - let positions = account_root.securities_account.positions; + let positions = account_root.securities_account.positions.unwrap(); // iterate through positions for p in positions { diff --git a/src/auth.rs b/src/auth.rs index ffb1e6a..dd1015f 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -1,7 +1,10 @@ +use crate::model::token::{ErrorResponse, TokenResponse}; +use log::info; +use serde::Serialize; use std::time::SystemTime; -static T_ERR: &str = "Error: Response returned no access token. Check input parameters"; -static R_ERR: &str = "Error: Trouble making request and parsing response"; +/// +/// Convenience function /// /// used to get a valid `token` from `refresh_token` and `clientid` /// @@ -9,15 +12,19 @@ pub fn get_token_from_refresh(refresh: &str, clientid: &str) -> String { // create new TDauth struct using refresh / clientid // return token let newauth = TDauth::new_from_refresh(refresh, clientid, false); + newauth.log_change("access token created from refresh"); newauth.token } /// +/// Convenience function +/// /// used to get a valid `refresh` from `refresh_token` and `clientid` /// pub fn get_refresh_from_refresh(refresh: &str, clientid: &str) -> String { // create new TDauth struct using refresh / clientid // return token let newauth = TDauth::new_from_refresh(refresh, clientid, true); + newauth.log_change("refresh token created from refresh"); newauth.refresh } /// @@ -25,7 +32,7 @@ pub fn get_refresh_from_refresh(refresh: &str, clientid: &str) -> String { /// /// you can use decode=true if you did **NOT** decode it **only useful if you are using the browser to get code from query string** /// -pub fn get_token_from_code( +pub fn get_refresh_from_code( code: &str, clientid: &str, redirecturi: &str, @@ -34,7 +41,7 @@ pub fn get_token_from_code( // create new TDauth struct using refresh / clientid // return token let newauth = TDauth::new_from_code(code, clientid, redirecturi, codedecode); - newauth.token + newauth.refresh } /// /// used to get code manually from tdameritrade website with redirect URI as localhost @@ -59,7 +66,7 @@ pub fn get_code_weblink(clientid: &str, redirecturi: &str) -> String { /// 2) `new_fromcode` will allow you to update tokens from the code retrieved in 1) /// 3) `new_fromrefresh` will allow you to update tokens from the `refresh_token`. The `refresh_token` will stay active for 90 days so you can save for reuse. /// -#[derive(Debug)] +#[derive(Debug, Clone, Default, Serialize)] pub struct TDauth { token: String, refresh: String, @@ -67,22 +74,30 @@ pub struct TDauth { redirect_uri: Option, token_expire_epoch: u64, refresh_expire_epoch: u64, + error: String, } impl TDauth { + + /// create new `TDauth` with configuration only + /// + pub fn new(refresh: String, client_id: String, redirect_uri: String) -> Self { + let mut newauth = TDauth::default(); + newauth.set_refresh(refresh); + newauth.set_client_id(client_id); + newauth.set_redirect_uri(redirect_uri); + newauth + } + /// create new `TDauth` with `refresh_token` and `clientid` /// if successful `TDauth` will carry new valid `token` /// if refreshupdate is true than `refresh_token` will also be updated - pub fn new_from_refresh(refresh: &str, clientid: &str, refreshupdate: bool) -> TDauth { - let mut newauth = TDauth { - token: String::new(), - refresh: refresh.to_owned(), - client_id: format!("{}{}", clientid, crate::APIKEY), - redirect_uri: None, - token_expire_epoch: 0, - refresh_expire_epoch: 0, - }; - newauth.resolve_token_from_refresh(refreshupdate); + pub fn new_from_refresh(refresh: &str, client_id: &str, refresh_update: bool) -> Self { + let mut newauth = TDauth::default(); + newauth.set_refresh(refresh.to_string()); + newauth.set_client_id(client_id.to_string()); + newauth.resolve_token_from_refresh(refresh_update); + newauth.log_change("TDauth tokens created from refresh grant"); newauth } /// create new `TDauth` with `code`, `redirecturi` and `clientid` @@ -92,19 +107,15 @@ impl TDauth { /// pub fn new_from_code( code: &str, - clientid: &str, - redirecturi: &str, - codedecode: bool, - ) -> TDauth { - let mut newauth = TDauth { - token: String::new(), - refresh: String::new(), - client_id: format!("{}{}", clientid, crate::APIKEY), - redirect_uri: Some(redirecturi.to_owned()), - token_expire_epoch: 0, - refresh_expire_epoch: 0, - }; - newauth.resolve_token_fromcode(code, codedecode); + client_id: &str, + redirect_uri: &str, + code_decode: bool, + ) -> Self { + let mut newauth = TDauth::default(); + newauth.set_client_id(client_id.to_string()); + newauth.set_redirect_uri(redirect_uri.to_string()); + newauth.resolve_token_fromcode(code, code_decode); + newauth.log_change("TDauth tokens created from code grant"); newauth } /// get /oauth2/token @@ -113,51 +124,23 @@ impl TDauth { /// /// returns full response and updates `TDauth` struct /// - pub fn resolve_token_from_refresh(&mut self, refreshupdate: bool) -> String { + pub fn resolve_token_from_refresh(&mut self, refresh_update: bool) { + let refresh = self.refresh.clone(); + let client_id = format!("{}{}", self.client_id.clone(), crate::APIKEY); + //body parameters - let mut p = vec![ + let mut body = vec![ ("grant_type", "refresh_token"), - ("refresh_token", &self.refresh), - ("client_id", &self.client_id), + ("refresh_token", &refresh), + ("client_id", &client_id), ]; - if refreshupdate { - p.push(("access_type", "offline")); + if refresh_update { + body.push(("access_type", "offline")); } - let response = attohttpc::post(format!("{}oauth2/token", crate::APIWWW)) - .form(&p) - .unwrap() - .send() - .expect(R_ERR) - .text() - .expect(R_ERR); - - let responsejson: serde_json::Value = serde_json::from_str(&response).expect(T_ERR); - dbg!(&responsejson); - let epoch = SystemTime::now() - .duration_since(SystemTime::UNIX_EPOCH) - .unwrap() - .as_secs(); - - self.token = responsejson["access_token"] - .as_str() - .expect(T_ERR) - .to_owned(); - self.token_expire_epoch = - responsejson["expires_in"].as_u64().expect(T_ERR).to_owned() + epoch; - if refreshupdate { - self.refresh = responsejson["refresh_token"] - .as_str() - .expect(T_ERR) - .to_owned(); - self.refresh_expire_epoch = responsejson["refresh_token_expires_in"] - .as_u64() - .expect(T_ERR) - .to_owned() - + epoch; - } - response + self.auth_request(body, refresh_update); } + /// get /oauth2/token /// token endpoint returns an access token along with an optional refresh token /// using `authorization_code` grant type and retrieves new `refresh_token` (response returned) while storing valid token inside client @@ -169,7 +152,7 @@ impl TDauth { /// /// you can use decode=true if you did **NOT** decode it **only useful if you are using the browser to get code from query string** /// - pub fn resolve_token_fromcode(&mut self, code: &str, codedecode: bool) -> String { + pub fn resolve_token_fromcode(&mut self, code: &str, codedecode: bool) { // is code already decoded or not? - ask did it come from a query parameter (codedecode=true) or some other way (codedecode=false) let decoded_code = if codedecode { let (_, decoded) = url::form_urlencoded::parse(format!("code={}", code).as_bytes()) @@ -181,47 +164,66 @@ impl TDauth { code.to_owned() }; + let redirect_uri = self.redirect_uri.as_ref().unwrap().clone(); + let client_id = format!("{}{}", self.client_id.clone(), crate::APIKEY); + //body parameters - let p = vec![ + let body = vec![ ("grant_type", "authorization_code"), ("access_type", "offline"), ("code", &decoded_code), - ("client_id", &self.client_id), - ("redirect_uri", self.redirect_uri.as_ref().unwrap()), + ("client_id", &client_id), + ("redirect_uri", &redirect_uri), ]; - let response = attohttpc::post(format!("{}oauth2/token", crate::APIWWW)) - .form(&p) - .unwrap() - .send() - .expect(R_ERR) - .text() - .expect(R_ERR); + self.auth_request(body, true); + } + + fn auth_request(&mut self, body: Vec<(&str, &str)>, refresh_update: bool) { + // any web issues + let response = match request_auth(body) { + Ok(r) => r, + Err(e) => { + self.error = e.to_string(); + self.reset_tokens(); + return; + } + }; + + // any authorization issues + if response.contains("\"error\" :") { + let error_response: ErrorResponse = serde_json::from_str(&response).unwrap(); + self.error = format!("Error response from server: {}", error_response.error); + self.reset_tokens(); + return; + } - let responsejson: serde_json::Value = - serde_json::from_str(&response).expect("Error: No access token retrieved"); + // any parsing issues + let token_response: TokenResponse = match serde_json::from_str(&response) { + Ok(t) => t, + Err(e) => { + self.error = e.to_string(); + self.reset_tokens(); + return; + } + }; let epoch = SystemTime::now() .duration_since(SystemTime::UNIX_EPOCH) .unwrap() .as_secs(); - self.token = responsejson["access_token"] - .as_str() - .expect(T_ERR) - .to_owned(); - self.token_expire_epoch = - responsejson["expires_in"].as_u64().expect(T_ERR).to_owned() + epoch; - self.refresh = responsejson["refresh_token"] - .as_str() - .expect(T_ERR) - .to_owned(); - self.refresh_expire_epoch = responsejson["refresh_token_expires_in"] - .as_u64() - .expect(T_ERR) - .to_owned() - + epoch; - response + self.token = token_response.access_token; + self.token_expire_epoch = token_response.expires_in + epoch; + if refresh_update { + self.refresh = token_response.refresh_token; + self.refresh_expire_epoch = token_response.refresh_token_expires_in + epoch; + } + + // reset error code if successful + if self.token_expire_epoch > 0 { + self.error = String::new() + } } pub fn is_token_valid(&self, buffer: u64) -> bool { @@ -254,6 +256,43 @@ impl TDauth { self.token_expire_epoch = 0; self.refresh_expire_epoch = 0; } + + fn reset_tokens(&mut self) { + self.token = String::new(); + self.refresh = String::new(); + self.reset_expire(); + } + + fn set_refresh(&mut self, refresh: String) { + self.refresh = refresh.to_owned(); + } + + fn set_client_id(&mut self, client_id: String) { + self.client_id = client_id; + } + + fn set_redirect_uri(&mut self, redirect_uri: String) { + self.redirect_uri = Some(redirect_uri.to_owned()); + } + + pub fn log_change(&self, desc: &str) { + if !self.error.is_empty() { + info!("{}-Error: {}", desc, &self.error); + } else { + info!("{}", desc); + } + } + + pub fn web_link_authorization(&self) -> String { + get_code_weblink(&self.client_id, self.redirect_uri.as_ref().unwrap()) + } +} + +fn request_auth(body: Vec<(&str, &str)>) -> Result { + Ok(attohttpc::post(format!("{}oauth2/token", crate::APIWWW)) + .form(&body)? + .send()? + .text()?) } #[cfg(test)] @@ -281,7 +320,7 @@ mod auth_tests { } #[test] - #[ignore] + #[ignore] fn check_new_fromrefresh_constructs_tdauth() { let refresh = env::var("TDREFRESHTOKEN").unwrap(); let clientid = env::var("TDCLIENTKEY").unwrap(); @@ -290,4 +329,17 @@ mod auth_tests { println!("token: {} \nrefresh: {} \n", t, r); println!("{:?}", newtdauth); } + + #[test] + #[ignore] + fn check_existing_tdauth_fromrefresh_constructs_tdauth() { + let mut auth = TDauth::default(); + auth.set_client_id(env::var("TDCLIENTKEY").unwrap()); + auth.set_refresh(env::var("TDREFRESHTOKEN").unwrap()); + auth.resolve_token_from_refresh(false); + let (t, r) = auth.get_tokens(); + println!("token: {} \nrefresh: {} \n", t, r); + println!("{:?}", auth); + } + } diff --git a/src/authmanagedclient.rs b/src/authmanagedclient.rs index 4c089ec..f1ca75e 100644 --- a/src/authmanagedclient.rs +++ b/src/authmanagedclient.rs @@ -10,15 +10,15 @@ use log::info; /// /// Initiate with a valid refresh token and client id /// -#[derive(Debug)] +#[derive(Debug, Clone)] pub struct TDAClientAuth { client: TDAClient, auth: TDauth, } impl TDAClientAuth { - /// create a new managed client that will check and refresh tokens as needed before - /// every use. + /// create a new managed client with refresh token and client id that will check and refresh tokens + /// as needed before every use. pub fn new(refresh_token: String, client_id: String) -> Self { info!("New Client (Auth Managed) initialized - from refresh token"); let auth = TDauth::new_from_refresh(&refresh_token, &client_id, true); @@ -28,18 +28,64 @@ impl TDAClientAuth { } } + /// create a new managed client from a TDauth configured struct that will check and refresh tokens + /// as needed before every use. + pub fn from_tdauth(auth: TDauth) -> Self { + info!("New Client (Auth Managed) initialized - from TDauth struct"); + TDAClientAuth { + client: TDAClient::new(auth.get_auth_token().to_owned()), + auth, + } + } + /// retrieve client with updated token to use - pub fn client(&mut self) -> &TDAClient { + /// return None if no token exists + pub fn client(&mut self) -> Option<&TDAClient> { // check validity of token - if !self.auth.is_token_valid(TOKENTIMEBUFFER) { + if !self.check_token_validity() && !self.auth.get_auth_token().is_empty() { + // update client with new token if token exists otherwise leave existing client + self.client = TDAClient::new(self.auth.get_auth_token().to_owned()); + } + if self.auth.get_auth_token().is_empty() { + None + } else { + Some(&self.client) + } + } + + /// get current authorization token for TD API + pub fn active_token(&mut self) -> Option<&str> { + self.check_token_validity(); + if self.auth.get_auth_token().is_empty() { + None + } else { + Some(self.auth.get_auth_token()) + } + } + + fn check_token_validity(&mut self) -> bool { + // check validity of token + if !self.auth.is_token_valid(TOKENTIMEBUFFER) || self.auth.get_auth_token().is_empty() { // if token needs updating check if refresh needs to be updated too let refresh_update = !self.auth.is_refresh_valid(REFRESHTIMEBUFFER); self.auth.resolve_token_from_refresh(refresh_update); - - // update client with new token - self.client = TDAClient::new(self.auth.get_auth_token().to_owned()); + false + } else { + true } - &self.client + } + + /// check that an active refresh token exists so client tokens can be updated as needed + /// + /// if this is false then `TDauth` will have to be resolved by getting a refresh token + /// probably using `tdauth_code_grant` + pub fn refresh_auth_active(&self) -> bool { + self.auth.is_refresh_valid(REFRESHTIMEBUFFER) + } + + /// get authorization tokens for TD API + pub fn get_auth(&self) -> &TDauth { + &self.auth } } @@ -59,6 +105,7 @@ mod managed_client_tests { let resptxt: String = managed_client .client() + .unwrap() .get(&Endpoint::Quotes, &[param::Quotes::Symbol("F,INTC,SPY")]); assert_eq!(resptxt.contains("\"assetType\""), true); @@ -74,6 +121,7 @@ mod managed_client_tests { // check that both tokens are valid after another request let resptxt: String = managed_client .client() + .unwrap() .get(&Endpoint::Quotes, &[param::Quotes::Symbol("F,INTC,SPY")]); assert_eq!(resptxt.contains("\"assetType\""), true); diff --git a/src/lib.rs b/src/lib.rs index 6dd7ec3..3caf584 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -64,8 +64,8 @@ static APIWWW: &str = "https://api.tdameritrade.com/v1/"; static APIKEY: &str = "@AMER.OAUTHAP"; -const TOKENTIMEBUFFER: u64 = 5 * 60; // 5 Minutes -const REFRESHTIMEBUFFER: u64 = 30 * 24 * 60; // 30 days +const TOKENTIMEBUFFER: u64 = 25 * 60; // 25 Minutes instead of 30 min +const REFRESHTIMEBUFFER: u64 = 60 * 24 * 60; // 60 days instead of 90 days /// /// utility module to help with authorization token, refresh token and grant code diff --git a/src/model/account.rs b/src/model/account.rs index 8444f71..95bc496 100644 --- a/src/model/account.rs +++ b/src/model/account.rs @@ -9,6 +9,9 @@ pub struct AccountRoot { pub securities_account: SecuritiesAccount, } +// TODO: Add the opportunity for either cash_account or margin_account. Currently only uses margin_account +// Need to test cash account to see if it works + #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct SecuritiesAccount { @@ -18,7 +21,8 @@ pub struct SecuritiesAccount { pub round_trips: i64, pub is_day_trader: bool, pub is_closing_only_restricted: bool, - pub positions: Vec, + pub positions: Option>, + pub order_strategies: Option>, pub initial_balances: InitialBalances, pub current_balances: CurrentBalances, pub projected_balances: ProjectedBalances, @@ -59,6 +63,7 @@ pub struct Instrument { #[serde(rename_all = "camelCase")] pub struct InitialBalances { pub accrued_interest: f64, + #[serde(default)] pub available_funds_non_marginable_trade: f64, pub bond_value: f64, pub buying_power: f64, @@ -68,27 +73,37 @@ pub struct InitialBalances { pub day_trading_buying_power: f64, pub day_trading_buying_power_call: f64, pub day_trading_equity_call: f64, + #[serde(default)] pub equity: f64, + #[serde(default)] pub equity_percentage: f64, pub liquidation_value: f64, + #[serde(default)] pub long_margin_value: f64, pub long_option_market_value: f64, pub long_stock_value: f64, + #[serde(default)] pub maintenance_call: f64, + #[serde(default)] pub maintenance_requirement: f64, + #[serde(default)] pub margin: f64, + #[serde(default)] pub margin_equity: f64, pub money_market_fund: f64, pub mutual_fund_value: f64, #[serde(rename = "regTCall")] pub reg_tcall: f64, + #[serde(default)] pub short_margin_value: f64, pub short_option_market_value: f64, pub short_stock_value: f64, pub total_cash: f64, pub is_in_call: bool, pub pending_deposits: f64, + #[serde(default)] pub margin_balance: f64, + #[serde(default)] pub short_balance: f64, pub account_value: f64, } @@ -111,15 +126,22 @@ pub struct CurrentBalances { pub buying_power: f64, pub buying_power_non_marginable_trade: f64, pub day_trading_buying_power: f64, + #[serde(default)] pub equity: f64, + #[serde(default)] pub equity_percentage: f64, + #[serde(default)] pub long_margin_value: f64, + #[serde(default)] pub maintenance_call: f64, + #[serde(default)] pub maintenance_requirement: f64, + #[serde(default)] pub margin_balance: f64, #[serde(rename = "regTCall")] pub reg_tcall: f64, pub short_balance: f64, + #[serde(default)] pub short_margin_value: f64, pub short_option_market_value: f64, pub sma: f64, @@ -135,6 +157,7 @@ pub struct ProjectedBalances { pub buying_power: f64, pub day_trading_buying_power: f64, pub day_trading_buying_power_call: f64, + #[serde(default)] pub maintenance_call: f64, #[serde(rename = "regTCall")] pub reg_tcall: f64, diff --git a/src/model/mod.rs b/src/model/mod.rs index c0f2c07..38851d0 100644 --- a/src/model/mod.rs +++ b/src/model/mod.rs @@ -1,5 +1,10 @@ /// type to respresent responses from /accounts/ endpoint pub mod account; +/// type to represent token authorization response from /oauth2/token endpoint +pub mod token; /// type to respresent responses from /userprincipals/ endpoint pub mod userprincipals; -//pub mod quote; +/// type to represent price history from /marketdata/SYM/pricehistory +pub mod pricehistory; +/// type to represent quotes from /marketdata/quotes?SYM1,SYM2,etc +pub mod quote; diff --git a/src/model/pricehistory.rs b/src/model/pricehistory.rs new file mode 100644 index 0000000..76e48ac --- /dev/null +++ b/src/model/pricehistory.rs @@ -0,0 +1,26 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct History { + pub candles: Vec, + pub symbol: String, + pub empty: bool, +} + +#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Candle { + pub open: f64, + pub high: f64, + pub low: f64, + pub close: f64, + pub volume: i64, + pub datetime: i64, +} + +impl History { + pub fn get_close_series(&self) -> Vec { + self.candles.iter().map(|candle| candle.close).collect() + } +} \ No newline at end of file diff --git a/src/model/quote.rs b/src/model/quote.rs index 5d724b8..22ed61c 100644 --- a/src/model/quote.rs +++ b/src/model/quote.rs @@ -1,5 +1,219 @@ +use std::collections::HashMap; +use serde_json::Value; use serde::{Deserialize, Serialize}; /// -/// Holds quote information derived from get quotes +/// Quote /// -/// not yet implemented +/// Deserialized through a HashMap +/// +#[derive(Debug, Serialize, Deserialize, Clone)] +#[serde(rename_all = "camelCase", untagged)] +pub enum Quote { + Equity(QEquity), + Index(QIndex), + Option(QOption), + Fund(QFund), + General(QGeneral), +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct QEquity { + pub asset_type: String, + pub symbol: String, + pub description: String, + pub bid_price: f64, + pub bid_size: f64, + pub bid_id: String, + pub ask_price: f64, + pub ask_size: f64, + pub ask_id: String, + pub last_price: f64, + pub last_size: f64, + pub last_id: String, + pub open_price: f64, + pub high_price: f64, + pub low_price: f64, + pub close_price: f64, + pub net_change: f64, + pub total_volume: i64, + pub quote_time_in_long: i64, + pub trade_time_in_long: i64, + pub mark: f64, + pub cusip: String, + pub exchange: String, + pub exchange_name: String, + pub marginable: bool, + pub shortable: bool, + pub volatility: f64, + pub digits: i64, + #[serde(rename = "52WkHigh")] + pub n52wk_high: f64, + #[serde(rename = "52WkLow")] + pub n52wk_low: f64, + pub pe_ratio: f64, + pub div_amount: f64, + pub div_yield: f64, + pub div_date: String, + pub security_status: String, + pub regular_market_last_price: f64, + pub regular_market_last_size: f64, + pub regular_market_net_change: f64, + pub regular_market_trade_time_in_long: f64, + pub net_percent_change_in_double: f64, + pub mark_change_in_double: f64, + #[serde(flatten)] + extra: HashMap, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct QIndex { + pub asset_type: String, + pub symbol: String, + pub description: String, + pub last_price: f64, + pub open_price: f64, + pub high_price: f64, + pub low_price: f64, + pub close_price: f64, + pub net_change: f64, + pub total_volume: i64, + pub trade_time_in_long: i64, + pub exchange: String, + pub exchange_name: String, + pub digits: i64, + #[serde(rename = "52WkHigh")] + pub n52wk_high: f64, + #[serde(rename = "52WkLow")] + pub n52wk_low: f64, + pub security_status: String, + pub net_percent_change_in_double: f64, + #[serde(flatten)] + extra: HashMap, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct QOption { + pub asset_type: String, + pub symbol: String, + pub description: String, + pub bid_price: f64, + pub bid_size: f64, + pub ask_price: f64, + pub ask_size: f64, + pub last_price: f64, + pub last_size: f64, + pub open_price: f64, + pub high_price: f64, + pub low_price: f64, + pub close_price: f64, + pub net_change: f64, + pub total_volume: i64, + pub quote_time_in_long: i64, + pub trade_time_in_long: i64, + pub mark: f64, + pub cusip: String, + pub open_interest: f64, + pub volatility: f64, + pub money_intrinsic_value: f64, + pub multiplier: f64, + pub strike_price: f64, + pub contract_type: String, + pub underlying: String, + pub time_value: f64, + pub deliverables: String, + pub delta: f64, + pub gamma: f64, + pub theta: f64, + pub vega: f64, + pub rho: f64, + pub security_status: String, + pub theoretical_option_value: f64, + pub underlying_price: f64, + pub uv_expiration_type: String, + pub exchange: String, + pub exchange_name: String, + pub settlement_type: String, + pub net_percent_change_in_double: f64, + pub mark_change_in_double: f64, + pub last_trading_day: i64, + pub expiration_day: i64, + pub expiration_month: i64, + pub expiration_year: i64, + pub days_to_expiration: i64, + pub implied_yield: f64, + #[serde(flatten)] + extra: HashMap, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct QFund { + pub asset_type: String, + pub symbol: String, + pub description: String, + pub close_price: f64, + pub net_change: f64, + pub total_volume: i64, + pub trade_time_in_long: i64, + pub cusip: String, + pub exchange: String, + pub exchange_name: String, + pub digits: i64, + #[serde(rename = "52WkHigh")] + pub n52wk_high: f64, + #[serde(rename = "52WkLow")] + pub n52wk_low: f64, + #[serde(rename = "nAV")] + pub nav: f64, + pub pe_ratio: f64, + pub div_amount: f64, + pub div_yield: f64, + pub div_date: String, + pub security_status: String, + pub net_percent_change_in_double: f64, + #[serde(flatten)] + extra: HashMap, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct QGeneral { + pub asset_type: String, + pub symbol: String, + #[serde(flatten)] + extra: HashMap, +} + + +impl Quote { + pub fn symbol(&self) -> &str { + match self { + Quote::Equity(quote) => "e.symbol, + Quote::Option(quote) => "e.symbol, + Quote::Fund(quote) => "e.symbol, + Quote::Index(quote) => "e.symbol, + Quote::General(quote) => "e.symbol, + } + } + pub fn mark_price(&self) -> f64 { + match self { + Quote::Equity(quote) => quote.mark, + Quote::Option(quote) => quote.mark, + Quote::Fund(quote) => quote.nav, + Quote::Index(quote) => quote.last_price, + _ => 0.0, + } + } + pub fn close_price(&self) -> f64 { + match self { + Quote::Equity(quote) => quote.close_price, + Quote::Option(quote) => quote.close_price, + Quote::Fund(quote) => quote.close_price, + Quote::Index(quote) => quote.close_price, + _ => 0.0, + } + } +} diff --git a/src/model/token.rs b/src/model/token.rs new file mode 100644 index 0000000..1744f2c --- /dev/null +++ b/src/model/token.rs @@ -0,0 +1,21 @@ +use serde::Deserialize; +/// +/// Holds Type Authorization response +/// +#[derive(Default, Debug, Deserialize)] +pub struct TokenResponse { + pub access_token: String, + #[serde(default)] + pub refresh_token: String, + pub token_type: String, + pub expires_in: u64, + #[serde(default)] + pub scope: String, + #[serde(default)] + pub refresh_token_expires_in: u64, +} + +#[derive(Default, Debug, Deserialize)] +pub struct ErrorResponse { + pub error: String, +} diff --git a/src/tdaclient.rs b/src/tdaclient.rs index 1af384c..e726fee 100644 --- a/src/tdaclient.rs +++ b/src/tdaclient.rs @@ -21,6 +21,12 @@ pub struct TDAClient { client: Session, } +impl Clone for TDAClient { + fn clone(&self) -> Self { + TDAClient::new(self.auth_token.clone()) + } +} + impl TDAClient { /// /// Create new base client that maintains Authorization Header @@ -45,7 +51,7 @@ impl TDAClient { /// /// get endpoint with query parameters /// - /// See `response::Endpoint` for available Endpoints + /// See `Endpoint` for available Endpoints /// /// See param for matching parameters /// diff --git a/tests/clienttests.rs b/tests/clienttests.rs index 9b2c3ad..0b2c4e2 100644 --- a/tests/clienttests.rs +++ b/tests/clienttests.rs @@ -5,6 +5,11 @@ // TODO: tests to add: watchlist endpoints use std::env; +use std::collections::HashMap; +use tdameritradeclient::model::quote::Quote; +use tdameritradeclient::model::pricehistory::History; +use tdameritradeclient::model::account::AccountRoot; +use tdameritradeclient::model::userprincipals::UserPrincipals; use tdameritradeclient::{param, Endpoint, TDAClient}; fn initialize_client() -> TDAClient { @@ -22,20 +27,32 @@ fn initialize_client_accountid() -> (TDAClient, String) { } #[test] -fn able_to_retrieve_user_data() { +fn able_to_retrieve_userprincipals() { let resptxt: String = initialize_client().get(&Endpoint::UserPrincipals, &[param::Empty]); println!("{:?}", resptxt); assert_eq!(resptxt.contains("\"userId\""), true); } +#[test] +fn able_to_retrieve_userprincipals_into_model() { + assert!(serde_json::from_value::(initialize_client().get(&Endpoint::UserPrincipals, &[param::Empty])).is_ok()); +} + #[test] fn able_to_retrieve_quotes() { let resptxt: String = - initialize_client().get(&Endpoint::Quotes, &[param::Quotes::Symbol("F,INTC,SPY")]); + initialize_client().get(&Endpoint::Quotes, &[param::Quotes::Symbol("F,VFIAX,SPY,$SPX.X")]); + println!("{:?}", resptxt); assert_eq!(resptxt.contains("\"assetType\""), true); } +#[test] +fn able_to_retrieve_quotes_into_model() { + let quote_symbols="F,VFIAX,SPY,$SPX.X"; + assert!(serde_json::from_value::>(initialize_client().get(&Endpoint::Quotes, &[param::Quotes::Symbol(quote_symbols)])).is_ok()); +} + #[test] fn able_to_retrieve_tojson() { let resptxt: serde_json::Value = @@ -59,6 +76,19 @@ fn able_to_retrieve_history() { assert_eq!(resptxt.contains("\"candles\""), true); } +#[test] +fn able_to_retrieve_pricehistory_into_model() { + assert!(serde_json::from_value::(initialize_client() + .get(&Endpoint::History("SPY"), + &[ + param::History::Period(1), + param::History::PeriodType("month"), + param::History::Frequency(1), + param::History::FrequencyType("daily"), + ], + )).is_ok()); +} + #[test] fn able_to_retrieve_optionchain() { let resptxt: String = initialize_client().get( @@ -88,6 +118,15 @@ fn able_to_retrieve_one_account() { assert_eq!(resptxt.contains("\"securitiesAccount\""), true); } +#[test] +fn able_to_retrieve_account_into_model() { + let (c, accountid) = initialize_client_accountid(); + assert!(serde_json::from_value::(c.get(&Endpoint::Account(&accountid), &[param::Empty])).is_ok()); + assert!(serde_json::from_value::(c.get(&Endpoint::Account(&accountid), &[param::Account::Positions])).is_ok()); + assert!(serde_json::from_value::(c.get(&Endpoint::Account(&accountid), &[param::Account::Orders])).is_ok()); +} + + #[test] fn able_to_retrieve_account_positions() { let (c, accountid) = initialize_client_accountid();