mod auth;
mod error;
pub use crate::auth::AuthenticationInfo;
pub use crate::error::ApiError;
use chrono::{DateTime, Utc};
use http::StatusCode;
use itertools::Itertools;
use reqwest::header::AUTHORIZATION;
use reqwest::{Client, RequestBuilder};
use serde::de::Error as SerdeError;
use serde::{Deserialize, Deserializer, Serialize};
use serde_json::{json, Number, Value};
use std::cell::RefCell;
use std::error::Error;
type SymbolId = u32;
type OrderId = u32;
type ExecutionId = u32;
type UserId = u32;
const API_VERSION: &str = "v1";
pub struct Questrade {
client: Client,
auth_info: RefCell<Option<AuthenticationInfo>>,
}
impl Questrade {
pub fn new() -> Self {
Self::with_client(Client::new())
}
pub fn with_client(client: Client) -> Self {
Questrade {
client,
auth_info: RefCell::new(None),
}
}
pub fn with_authentication(auth_info: AuthenticationInfo, client: Client) -> Self {
Questrade {
client,
auth_info: RefCell::new(Some(auth_info)),
}
}
pub async fn authenticate(
&self,
refresh_token: &str,
is_demo: bool,
) -> Result<(), Box<dyn Error>> {
self.auth_info.replace(Some(
AuthenticationInfo::authenticate(refresh_token, is_demo, &self.client).await?,
));
Ok(())
}
pub fn get_auth_info(&self) -> Option<AuthenticationInfo> {
self.auth_info.borrow().clone()
}
fn get_active_auth(&self) -> Result<AuthenticationInfo, ApiError> {
self.auth_info
.borrow()
.clone()
.ok_or(ApiError::NotAuthenticatedError(StatusCode::UNAUTHORIZED))
}
pub async fn accounts(&self) -> Result<Vec<Account>, Box<dyn Error>> {
#[derive(Serialize, Deserialize)]
struct AccountsResponse {
accounts: Vec<Account>,
}
let response = self
.get_request_builder("accounts")?
.send()
.await?
.error_for_status()
.map_err(|e| wrap_error(e))?
.json::<AccountsResponse>()
.await?;
Ok(response.accounts)
}
pub async fn account_activity(
&self,
account_number: &str,
start_time: DateTime<Utc>,
end_time: DateTime<Utc>,
) -> Result<Vec<AccountActivity>, Box<dyn Error>> {
#[derive(Serialize, Deserialize)]
struct AccountActivityResponse {
activities: Vec<AccountActivity>,
}
let response = self
.get_request_builder(format!("accounts/{}/activities", account_number).as_str())?
.query(&[
("startTime", start_time.to_rfc3339()),
("endTime", end_time.to_rfc3339()),
])
.send()
.await?
.error_for_status()
.map_err(|e| wrap_error(e))?
.json::<AccountActivityResponse>()
.await?;
Ok(response.activities)
}
pub async fn account_orders(
&self,
account_number: &str,
start_time: Option<DateTime<Utc>>,
end_time: Option<DateTime<Utc>>,
state: Option<OrderStateFilter>,
) -> Result<Vec<AccountOrder>, Box<dyn Error>> {
#[derive(Debug, Serialize, Deserialize)]
struct AccountOrdersResponse {
orders: Vec<AccountOrder>,
}
let mut query_params: Vec<(&str, String)> = Vec::new();
if let Some(start_time) = start_time {
query_params.push(("startTime", start_time.to_rfc3339()))
}
if let Some(end_time) = end_time {
query_params.push(("endTime", end_time.to_rfc3339()))
}
if let Some(state) = state {
let state = match state {
OrderStateFilter::All => "All",
OrderStateFilter::Open => "Open",
OrderStateFilter::Closed => "Closed",
};
query_params.push(("stateFilter", state.to_string()))
}
let response = self
.get_request_builder(format!("accounts/{}/orders", account_number).as_str())?
.query(query_params.as_slice())
.send()
.await?
.error_for_status()
.map_err(|e| wrap_error(e))?
.json::<AccountOrdersResponse>()
.await?;
Ok(response.orders)
}
pub async fn account_order(
&self,
account_number: &str,
order_id: OrderId,
) -> Result<Option<AccountOrder>, Box<dyn Error>> {
#[derive(Serialize, Deserialize)]
struct AccountOrdersResponse {
orders: Vec<AccountOrder>,
}
let mut response = self
.get_request_builder(
format!("accounts/{}/orders/{}", account_number, order_id).as_str(),
)?
.send()
.await?
.error_for_status()
.map_err(|e| wrap_error(e))?
.json::<AccountOrdersResponse>()
.await?;
return Ok(response.orders.pop());
}
pub async fn account_executions(
&self,
account_number: &str,
start_time: Option<DateTime<Utc>>,
end_time: Option<DateTime<Utc>>,
) -> Result<Vec<AccountExecution>, Box<dyn Error>> {
#[derive(Serialize, Deserialize)]
struct AccountExecutionsResponse {
executions: Vec<AccountExecution>,
}
let mut query_params: Vec<(&str, String)> = Vec::new();
if let Some(start_time) = start_time {
query_params.push(("startTime", start_time.to_rfc3339()))
}
if let Some(end_time) = end_time {
query_params.push(("endTime", end_time.to_rfc3339()))
}
let response = self
.get_request_builder(format!("accounts/{}/executions", account_number).as_str())?
.query(query_params.as_slice())
.send()
.await?
.error_for_status()
.map_err(|e| wrap_error(e))?
.json::<AccountExecutionsResponse>()
.await?;
Ok(response.executions)
}
pub async fn account_balance(
&self,
account_number: &str,
) -> Result<AccountBalances, Box<dyn Error>> {
let response = self
.get_request_builder(format!("accounts/{}/balances", account_number).as_str())?
.send()
.await?
.error_for_status()
.map_err(|e| wrap_error(e))?
.json::<AccountBalances>()
.await?;
Ok(response)
}
pub async fn account_positions(
&self,
account_number: &str,
) -> Result<Vec<AccountPosition>, Box<dyn Error>> {
#[derive(Serialize, Deserialize)]
struct AccountPositionsResponse {
positions: Vec<AccountPosition>,
}
let response = self
.get_request_builder(format!("accounts/{}/positions", account_number).as_str())?
.send()
.await?
.error_for_status()
.map_err(|e| wrap_error(e))?
.json::<AccountPositionsResponse>()
.await?;
Ok(response.positions)
}
pub async fn market_quote(&self, ids: &[SymbolId]) -> Result<Vec<MarketQuote>, Box<dyn Error>> {
#[derive(Serialize, Deserialize)]
struct MarketQuoteResponse {
quotes: Vec<MarketQuote>,
}
let ids = ids.iter().map(ToString::to_string).join(",");
let response = self
.get_request_builder("markets/quotes")?
.query(&[("ids", ids)])
.send()
.await?
.error_for_status()
.map_err(|e| wrap_error(e))?
.json::<MarketQuoteResponse>()
.await?;
Ok(response.quotes)
}
pub async fn symbol_search(
&self,
prefix: &str,
offset: u32,
) -> Result<Vec<SearchEquitySymbol>, Box<dyn Error>> {
#[derive(Serialize, Deserialize)]
struct SymbolSearchResponse {
symbols: Vec<SearchEquitySymbol>,
}
let response = self
.get_request_builder("symbols/search")?
.query(&[("prefix", prefix), ("offset", &offset.to_string())])
.send()
.await?
.error_for_status()
.map_err(|e| wrap_error(e))?
.json::<SymbolSearchResponse>()
.await?;
Ok(response.symbols)
}
pub async fn time(&self) -> Result<DateTime<Utc>, Box<dyn Error>> {
#[derive(Serialize, Deserialize)]
struct TimeResponse {
time: DateTime<Utc>,
}
let response = self
.get_request_builder("time")?
.send()
.await?
.error_for_status()
.map_err(|e| wrap_error(e))?
.json::<TimeResponse>()
.await?;
Ok(response.time)
}
fn get_request_builder(&self, url_suffix: &str) -> Result<RequestBuilder, Box<dyn Error>> {
let auth_info = self.get_active_auth()?;
Ok(self
.client
.get(&format!(
"{}/{}/{}",
auth_info.api_server, API_VERSION, url_suffix
))
.header(AUTHORIZATION, format!("Bearer {}", auth_info.access_token)))
}
}
fn wrap_error(e: reqwest::Error) -> Box<dyn Error> {
if e.is_status() {
let status = e.status().unwrap();
if status == 401 || status == 403 {
return Box::new(ApiError::NotAuthenticatedError(status));
}
}
Box::new(e)
}
#[derive(Serialize, Deserialize, Clone, PartialEq, Debug)]
pub struct Account {
#[serde(rename = "type")]
pub account_type: AccountType,
pub number: String,
pub status: AccountStatus,
#[serde(rename = "isPrimary")]
pub is_primary: bool,
#[serde(rename = "isBilling")]
pub is_billing: bool,
#[serde(rename = "clientAccountType")]
pub client_account_type: ClientAccountType,
}
#[derive(Serialize, Deserialize, Clone, PartialEq, Debug)]
pub enum AccountType {
Cash,
Margin,
TFSA,
RRSP,
SRRSP,
LRRSP,
LIRA,
LIF,
RIF,
SRIF,
LRIF,
RRIF,
PRIF,
RESP,
FRESP,
}
#[derive(Serialize, Deserialize, Clone, PartialEq, Debug)]
pub enum AccountStatus {
Active,
#[serde(rename = "Suspended (Closed)")]
SuspendedClosed,
#[serde(rename = "Suspended (View Only)")]
SuspendedViewOnly,
#[serde(rename = "Liquidate Only")]
Liquidate,
Closed,
}
#[derive(Serialize, Deserialize, Clone, PartialEq, Debug)]
pub enum ClientAccountType {
Individual,
Joint,
#[serde(rename = "Informal Trust")]
InformalTrust,
Corporation,
#[serde(rename = "Investment Club")]
InvestmentClub,
#[serde(rename = "Formal Trust")]
FormalTrust,
Partnership,
#[serde(rename = "Sole Proprietorship")]
SoleProprietorship,
Family,
#[serde(rename = "Joint and Informal Trust")]
JointAndInformalTrust,
Institution,
}
#[derive(Serialize, Deserialize, Clone, PartialEq, Debug)]
pub struct AccountActivity {
#[serde(rename = "tradeDate")]
pub trade_date: DateTime<Utc>,
#[serde(rename = "transactionDate")]
pub transaction_date: DateTime<Utc>,
#[serde(rename = "settlementDate")]
pub settlement_date: DateTime<Utc>,
pub action: String,
pub symbol: String,
#[serde(rename = "symbolId")]
pub symbol_id: SymbolId,
pub description: String,
pub currency: String,
pub quantity: Number,
pub price: Number,
#[serde(rename = "grossAmount")]
pub gross_amount: Number,
pub commission: Number,
#[serde(rename = "netAmount")]
pub net_amount: Number,
#[serde(rename = "type")]
pub activity_type: String,
}
#[derive(Serialize, Deserialize, Clone, PartialEq, Debug)]
pub struct AccountOrder {
pub id: OrderId,
pub symbol: String,
#[serde(rename = "symbolId")]
pub symbol_id: SymbolId,
#[serde(rename = "totalQuantity")]
pub total_quantity: Number,
#[serde(rename = "openQuantity")]
#[serde(deserialize_with = "deserialize_nullable_number")]
pub open_quantity: Number,
#[serde(rename = "filledQuantity")]
#[serde(deserialize_with = "deserialize_nullable_number")]
pub filled_quantity: Number,
#[serde(rename = "canceledQuantity")]
#[serde(deserialize_with = "deserialize_nullable_number")]
pub canceled_quantity: Number,
pub side: OrderSide,
#[serde(rename = "orderType")]
#[serde(alias = "type")]
pub order_type: OrderType,
#[serde(rename = "limitPrice")]
pub limit_price: Option<Number>,
#[serde(rename = "stopPrice")]
pub stop_price: Option<Number>,
#[serde(rename = "isAllOrNone")]
pub is_all_or_none: bool,
#[serde(rename = "isAnonymous")]
pub is_anonymous: bool,
#[serde(rename = "icebergQuantity")]
pub iceberg_quantity: Option<Number>,
#[serde(rename = "minQuantity")]
pub min_quantity: Option<Number>,
#[serde(rename = "avgExecPrice")]
pub avg_execution_price: Option<Number>,
#[serde(rename = "lastExecPrice")]
pub last_execution_price: Option<Number>,
pub source: String,
#[serde(rename = "timeInForce")]
pub time_in_force: OrderTimeInForce,
#[serde(rename = "gtdDate")]
pub good_till_date: Option<DateTime<Utc>>,
pub state: OrderState,
#[serde(rename = "clientReasonStr")]
#[serde(alias = "rejectionReason")]
#[serde(deserialize_with = "serde_with::rust::string_empty_as_none::deserialize")]
pub rejection_reason: Option<String>,
#[serde(rename = "chainId")]
pub chain_id: OrderId,
#[serde(rename = "creationTime")]
pub creation_time: DateTime<Utc>,
#[serde(rename = "updateTime")]
pub update_time: DateTime<Utc>,
#[serde(deserialize_with = "serde_with::rust::string_empty_as_none::deserialize")]
pub notes: Option<String>,
#[serde(rename = "primaryRoute")]
pub primary_route: String,
#[serde(rename = "secondaryRoute")]
#[serde(deserialize_with = "serde_with::rust::string_empty_as_none::deserialize")]
pub secondary_route: Option<String>,
#[serde(rename = "orderRoute")]
pub order_route: String,
#[serde(rename = "venueHoldingOrder")]
#[serde(deserialize_with = "serde_with::rust::string_empty_as_none::deserialize")]
pub venue_holding_order: Option<String>,
#[serde(rename = "comissionCharged")]
#[serde(deserialize_with = "deserialize_nullable_number")]
pub commission_charged: Number,
#[serde(rename = "exchangeOrderId")]
pub exchange_order_id: String,
#[serde(rename = "isSignificantShareHolder")]
pub is_significant_shareholder: bool,
#[serde(rename = "isInsider")]
pub is_insider: bool,
#[serde(rename = "isLimitOffsetInDollar")]
pub is_limit_offset_in_dollars: bool,
#[serde(rename = "userId")]
pub user_id: UserId,
#[serde(rename = "placementCommission")]
#[serde(deserialize_with = "deserialize_nullable_number")]
pub placement_commission: Number,
#[serde(rename = "strategyType")]
pub strategy_type: String,
#[serde(rename = "triggerStopPrice")]
pub trigger_stop_price: Option<Number>,
#[serde(rename = "orderGroupId")]
pub order_group_id: OrderId,
#[serde(rename = "orderClass")]
pub order_class: Option<String>,
}
fn deserialize_nullable_number<'de, D>(deserializer: D) -> Result<Number, D::Error>
where
D: Deserializer<'de>,
{
let number: Option<Number> = Deserialize::deserialize(deserializer)?;
match number {
Some(num) => Ok(num),
None => match json!(0) {
Value::Number(n) => Ok(n),
_ => Err(D::Error::custom(format!(
"json!(0) did not return a Value::Number",
))),
},
}
}
#[derive(Serialize, Deserialize, Clone, PartialEq, Debug)]
pub enum OrderSide {
Buy,
Sell,
Short,
#[serde(rename = "Cov")]
Cover,
#[serde(rename = "BTO")]
BuyToOpen,
#[serde(rename = "STC")]
SellToClose,
#[serde(rename = "STO")]
SellToOpen,
#[serde(rename = "BTC")]
BuyToClose,
}
#[derive(Serialize, Deserialize, Clone, PartialEq, Debug)]
pub enum OrderType {
Market,
Limit,
Stop,
StopLimit,
TrailStopInPercentage,
TrailStopInDollar,
TrailStopLimitInPercentage,
TrailStopLimitInDollar,
LimitOnOpen,
LimitOnClose,
}
#[derive(Serialize, Deserialize, Clone, PartialEq, Debug)]
pub enum OrderTimeInForce {
Day,
GoodTillCanceled,
GoodTillExtendedDay,
GoodTillDate,
ImmediateOrCancel,
FillOrKill,
}
#[derive(Serialize, Deserialize, Clone, PartialEq, Debug)]
pub enum OrderState {
Failed,
Pending,
Accepted,
Rejected,
CancelPending,
Canceled,
PartialCanceled,
Partial,
Executed,
ReplacePending,
Replaced,
Stopped,
Suspended,
Expired,
Queued,
Triggered,
Activated,
PendingRiskReview,
ContingentOrder,
}
#[derive(Clone, PartialEq, Debug)]
pub enum OrderStateFilter {
All,
Open,
Closed,
}
#[derive(Serialize, Deserialize, Clone, PartialEq, Debug)]
pub struct AccountExecution {
pub id: ExecutionId,
#[serde(rename = "orderId")]
pub order_id: OrderId,
pub symbol: String,
#[serde(rename = "symbolId")]
pub symbol_id: SymbolId,
#[serde(rename = "quantity")]
pub quantity: Number,
pub side: OrderSide,
pub price: Number,
#[serde(rename = "orderChainId")]
pub order_chain_id: OrderId,
pub timestamp: DateTime<Utc>,
#[serde(deserialize_with = "serde_with::rust::string_empty_as_none::deserialize")]
pub notes: Option<String>,
pub commission: Number,
#[serde(rename = "executionFee")]
pub execution_fee: Number,
#[serde(rename = "secFee")]
pub sec_fee: Number,
#[serde(rename = "canadianExecutionFee")]
pub canadian_execution_fee: Number,
#[serde(rename = "parentId")]
pub parent_id: OrderId,
}
#[derive(Serialize, Deserialize, Clone, PartialEq, Debug)]
pub struct AccountBalance {
pub currency: Currency,
pub cash: Number,
#[serde(rename = "marketValue")]
pub market_value: Number,
#[serde(rename = "totalEquity")]
pub total_equity: Number,
#[serde(rename = "buyingPower")]
pub buying_power: Number,
#[serde(rename = "maintenanceExcess")]
pub maintenance_excess: Number,
#[serde(rename = "isRealTime")]
pub is_real_time: bool,
}
#[derive(Serialize, Deserialize, Clone, PartialEq, Debug)]
pub enum Currency {
CAD,
USD,
}
#[derive(Serialize, Deserialize, Clone, PartialEq, Debug)]
pub struct AccountBalances {
#[serde(rename = "perCurrencyBalances")]
pub per_currency_balances: Vec<AccountBalance>,
#[serde(rename = "combinedBalances")]
pub combined_balances: Vec<AccountBalance>,
#[serde(rename = "sodPerCurrencyBalances")]
pub sod_per_currency_balances: Vec<AccountBalance>,
#[serde(rename = "sodCombinedBalances")]
pub sod_combined_balances: Vec<AccountBalance>,
}
fn none_is_zero<'de, D>(deserializer: D) -> Result<Number, D::Error>
where
D: Deserializer<'de>,
{
let o: Option<Number> = Option::deserialize(deserializer)?;
Ok(o.unwrap_or(Number::from(0)))
}
#[derive(Serialize, Deserialize, Clone, PartialEq, Debug)]
pub struct AccountPosition {
pub symbol: String,
#[serde(rename = "symbolId")]
pub symbol_id: SymbolId,
#[serde(rename = "openQuantity")]
pub open_quantity: Number,
#[serde(rename = "closedQuantity")]
pub closed_quantity: Number,
#[serde(rename = "currentMarketValue")]
pub current_market_value: Number,
#[serde(rename = "currentPrice")]
pub current_price: Number,
#[serde(rename = "dayPnl")]
#[serde(deserialize_with = "none_is_zero")]
pub day_profit_and_loss: Number,
#[serde(rename = "averageEntryPrice")]
pub average_entry_price: Number,
#[serde(rename = "closedPnl")]
pub closed_profit_and_loss: Number,
#[serde(rename = "openPnl")]
pub open_profit_and_loss: Number,
#[serde(rename = "totalCost")]
pub total_cost: Number,
#[serde(rename = "isRealTime")]
pub is_real_time: bool,
#[serde(rename = "isUnderReorg")]
pub is_under_reorg: bool,
}
#[derive(Serialize, Deserialize, Clone, PartialEq, Debug)]
pub struct MarketQuote {
pub symbol: String,
#[serde(rename = "symbolId")]
pub symbol_id: SymbolId,
#[serde(deserialize_with = "serde_with::rust::string_empty_as_none::deserialize")]
pub tier: Option<String>,
#[serde(rename = "bidPrice")]
pub bid_price: Option<Number>,
#[serde(rename = "bidSize")]
pub bid_size: u32,
#[serde(rename = "askPrice")]
pub ask_price: Option<Number>,
#[serde(rename = "askSize")]
pub ask_size: u32,
#[serde(rename = "lastTradePriceTrHrs")]
pub last_trade_price_tr_hrs: Number,
#[serde(rename = "lastTradePrice")]
pub last_trade_price: Number,
#[serde(rename = "lastTradeSize")]
pub last_trade_size: u32,
#[serde(rename = "lastTradeTick")]
pub last_trade_tick: TickType,
pub volume: u32,
#[serde(rename = "openPrice")]
pub open_price: Number,
#[serde(rename = "highPrice")]
pub high_price: Number,
#[serde(rename = "lowPrice")]
pub low_price: Number,
#[serde(deserialize_with = "deserialize_delay")]
pub delay: bool,
#[serde(rename = "isHalted")]
pub is_halted: bool,
}
fn deserialize_delay<'de, D>(deserializer: D) -> Result<bool, D::Error>
where
D: Deserializer<'de>,
{
let delay: u8 = Deserialize::deserialize(deserializer)?;
match delay {
0 => Ok(false),
1 => Ok(true),
_ => Err(D::Error::custom(format!(
"expected delay to be '0' or '1'. Got: {}",
delay
))),
}
}
#[derive(Serialize, Deserialize, Clone, PartialEq, Debug)]
pub struct SearchEquitySymbol {
pub symbol: String,
#[serde(rename = "symbolId")]
pub symbol_id: SymbolId,
pub description: String,
#[serde(rename = "securityType")]
pub security_type: SecurityType,
#[serde(rename = "listingExchange")]
pub listing_exchange: ListingExchange,
#[serde(rename = "isQuotable")]
pub is_quotable: bool,
#[serde(rename = "isTradable")]
pub is_tradable: bool,
pub currency: Currency,
}
#[derive(Serialize, Deserialize, Clone, PartialEq, Debug)]
pub enum ListingExchange {
TSX,
TSXI,
TSXV,
CNSX,
MX,
NASDAQ,
NASDAQI,
NYSE,
NYSEAM,
NYSEGIF,
ARCA,
OPRA,
#[serde(rename = "PINX")]
PinkSheets,
OTCBB,
BATS,
#[serde(rename = "DJI")]
DowJonesAverage,
#[serde(rename = "S&P")]
SP,
NEO,
RUSSELL,
#[serde(rename = "")]
None,
}
#[derive(Serialize, Deserialize, Clone, PartialEq, Debug)]
pub enum SecurityType {
Stock,
Option,
Bond,
Right,
Gold,
MutualFund,
Index,
}
#[derive(Serialize, Deserialize, Clone, PartialEq, Debug)]
pub enum TickType {
Up,
Down,
Equal,
}
#[cfg(test)]
mod tests {
use crate::auth::AuthenticationInfo;
use crate::{
Account, AccountBalance, AccountBalances, AccountExecution, AccountOrder, AccountPosition,
AccountStatus, AccountType, ClientAccountType, Currency, ListingExchange, MarketQuote,
OrderSide, OrderState, OrderTimeInForce, OrderType, Questrade, SearchEquitySymbol,
SecurityType, TickType,
};
use chrono::{FixedOffset, TimeZone, Utc};
use reqwest::Client;
use std::error::Error;
use std::time::Instant;
use mockito;
use mockito::{mock, Matcher};
use serde_json::{json, Number, Value};
use std::fs::read_to_string;
trait AsNumber {
fn to_number(self) -> Number;
}
impl AsNumber for Value {
fn to_number(self) -> Number {
match self {
Value::Number(n) => n,
_ => panic!("Not a number"),
}
}
}
fn get_api() -> Questrade {
let auth_info = AuthenticationInfo {
access_token: "mock-access-token".to_string(),
api_server: mockito::server_url(),
refresh_token: "".to_string(),
expires_at: Instant::now(),
is_demo: false,
};
Questrade::with_authentication(auth_info, Client::new())
}
#[tokio::test]
async fn accounts() -> Result<(), Box<dyn Error>> {
let _m = mock("GET", "/v1/accounts")
.with_status(200)
.with_header("content-type", "text/json")
.with_body(read_to_string("test/response/accounts.json")?)
.create();
let result = get_api().accounts().await;
assert_eq!(
result?,
vec![
Account {
account_type: AccountType::Margin,
number: "123456".to_string(),
status: AccountStatus::Active,
is_primary: false,
is_billing: false,
client_account_type: ClientAccountType::Joint,
},
Account {
account_type: AccountType::Cash,
number: "26598145".to_string(),
status: AccountStatus::Active,
is_primary: true,
is_billing: true,
client_account_type: ClientAccountType::Individual,
},
]
);
Ok(())
}
#[tokio::test]
async fn account_orders() -> Result<(), Box<dyn Error>> {
let _m = mock("GET", "/v1/accounts/123456/orders")
.with_status(200)
.with_header("content-type", "text/json")
.with_body(read_to_string("test/response/account-orders.json")?)
.create();
let result = get_api().account_orders("123456", None, None, None).await;
assert_eq!(
result?,
vec![
AccountOrder {
id: 173577870,
symbol: "AAPL".to_string(),
symbol_id: 8049,
total_quantity: json!(100).to_number(),
open_quantity: json!(100).to_number(),
filled_quantity: json!(0).to_number(),
canceled_quantity: json!(0).to_number(),
side: OrderSide::Buy,
order_type: OrderType::Limit,
limit_price: Some(json!(500.95).to_number()),
stop_price: None,
is_all_or_none: false,
is_anonymous: false,
iceberg_quantity: None,
min_quantity: None,
avg_execution_price: None,
last_execution_price: None,
source: "TradingAPI".to_string(),
time_in_force: OrderTimeInForce::Day,
good_till_date: None,
state: OrderState::Canceled,
rejection_reason: None,
chain_id: 173577870,
creation_time: FixedOffset::west(4 * 3600)
.ymd(2014, 10, 23)
.and_hms_micro(20, 3, 41, 636000)
.with_timezone(&Utc),
update_time: FixedOffset::west(4 * 3600)
.ymd(2014, 10, 23)
.and_hms_micro(20, 3, 42, 890000)
.with_timezone(&Utc),
notes: None,
primary_route: "AUTO".to_string(),
secondary_route: None,
order_route: "LAMP".to_string(),
venue_holding_order: None,
commission_charged: json!(0).to_number(),
exchange_order_id: "XS173577870".to_string(),
is_significant_shareholder: false,
is_insider: false,
is_limit_offset_in_dollars: false,
user_id: 3000124,
placement_commission: json!(0).to_number(),
strategy_type: "SingleLeg".to_string(),
trigger_stop_price: None,
order_group_id: 0,
order_class: None
},
AccountOrder {
id: 173567569,
symbol: "XSP".to_string(),
symbol_id: 12873,
total_quantity: json!(3).to_number(),
open_quantity: json!(0).to_number(),
filled_quantity: json!(0).to_number(),
canceled_quantity: json!(0).to_number(),
side: OrderSide::Buy,
order_type: OrderType::Limit,
limit_price: Some(json!(35.05).to_number()),
stop_price: None,
is_all_or_none: false,
is_anonymous: false,
iceberg_quantity: None,
min_quantity: None,
avg_execution_price: None,
last_execution_price: None,
source: "QuestradeIQEdge".to_string(),
time_in_force: OrderTimeInForce::Day,
good_till_date: None,
state: OrderState::Replaced,
rejection_reason: None,
chain_id: 173567569,
creation_time: FixedOffset::west(4 * 3600)
.ymd(2015, 08, 12)
.and_hms_micro(11, 2, 37, 86000)
.with_timezone(&Utc),
update_time: FixedOffset::west(4 * 3600)
.ymd(2015, 08, 12)
.and_hms_micro(11, 2, 41, 241000)
.with_timezone(&Utc),
notes: None,
primary_route: "AUTO".to_string(),
secondary_route: Some("AUTO".to_string()),
order_route: "ITSR".to_string(),
venue_holding_order: None,
commission_charged: json!(0).to_number(),
exchange_order_id: "XS173577869".to_string(),
is_significant_shareholder: false,
is_insider: false,
is_limit_offset_in_dollars: false,
user_id: 3000124,
placement_commission: json!(0).to_number(),
strategy_type: "SingleLeg".to_string(),
trigger_stop_price: None,
order_group_id: 0,
order_class: None
},
AccountOrder {
id: 173567570,
symbol: "XSP".to_string(),
symbol_id: 12873,
total_quantity: json!(3).to_number(),
open_quantity: json!(0).to_number(),
filled_quantity: json!(3).to_number(),
canceled_quantity: json!(0).to_number(),
side: OrderSide::Buy,
order_type: OrderType::Limit,
limit_price: Some(json!(15.52).to_number()),
stop_price: None,
is_all_or_none: false,
is_anonymous: false,
iceberg_quantity: None,
min_quantity: None,
avg_execution_price: Some(json!(15.52).to_number()),
last_execution_price: None,
source: "QuestradeIQEdge".to_string(),
time_in_force: OrderTimeInForce::Day,
good_till_date: None,
state: OrderState::Executed,
rejection_reason: None,
chain_id: 173567570,
creation_time: FixedOffset::west(4 * 3600)
.ymd(2015, 08, 12)
.and_hms_micro(11, 3, 37, 86000)
.with_timezone(&Utc),
update_time: FixedOffset::west(4 * 3600)
.ymd(2015, 08, 12)
.and_hms_micro(11, 03, 41, 241000)
.with_timezone(&Utc),
notes: None,
primary_route: "AUTO".to_string(),
secondary_route: Some("AUTO".to_string()),
order_route: "ITSR".to_string(),
venue_holding_order: Some("ITSR".to_string()),
commission_charged: json!(0.0105).to_number(),
exchange_order_id: "XS173577870".to_string(),
is_significant_shareholder: false,
is_insider: false,
is_limit_offset_in_dollars: false,
user_id: 3000124,
placement_commission: json!(0).to_number(),
strategy_type: "SingleLeg".to_string(),
trigger_stop_price: None,
order_group_id: 0,
order_class: None
}
]
);
Ok(())
}
#[tokio::test]
async fn account_order() -> Result<(), Box<dyn Error>> {
let _m = mock("GET", "/v1/accounts/123456/orders/173577870")
.with_status(200)
.with_header("content-type", "text/json")
.with_body(read_to_string(
"test/response/account-order-173577870.json",
)?)
.create();
let result = get_api().account_order("123456", 173577870).await;
assert_eq!(
result?,
Some(AccountOrder {
id: 173577870,
symbol: "AAPL".to_string(),
symbol_id: 8049,
total_quantity: json!(100).to_number(),
open_quantity: json!(100).to_number(),
filled_quantity: json!(0).to_number(),
canceled_quantity: json!(0).to_number(),
side: OrderSide::Buy,
order_type: OrderType::Limit,
limit_price: Some(json!(500.95).to_number()),
stop_price: None,
is_all_or_none: false,
is_anonymous: false,
iceberg_quantity: None,
min_quantity: None,
avg_execution_price: None,
last_execution_price: None,
source: "TradingAPI".to_string(),
time_in_force: OrderTimeInForce::Day,
good_till_date: None,
state: OrderState::Canceled,
rejection_reason: None,
chain_id: 173577870,
creation_time: FixedOffset::west(4 * 3600)
.ymd(2014, 10, 23)
.and_hms_micro(20, 3, 41, 636000)
.with_timezone(&Utc),
update_time: FixedOffset::west(4 * 3600)
.ymd(2014, 10, 23)
.and_hms_micro(20, 3, 42, 890000)
.with_timezone(&Utc),
notes: None,
primary_route: "AUTO".to_string(),
secondary_route: None,
order_route: "LAMP".to_string(),
venue_holding_order: None,
commission_charged: json!(0).to_number(),
exchange_order_id: "XS173577870".to_string(),
is_significant_shareholder: false,
is_insider: false,
is_limit_offset_in_dollars: false,
user_id: 3000124,
placement_commission: json!(0).to_number(),
strategy_type: "SingleLeg".to_string(),
trigger_stop_price: None,
order_group_id: 0,
order_class: None
})
);
Ok(())
}
#[tokio::test]
async fn account_order_empty() -> Result<(), Box<dyn Error>> {
let _m = mock("GET", "/v1/accounts/123456/orders/123456")
.with_status(200)
.with_header("content-type", "text/json")
.with_body(read_to_string("test/response/account-order-empty.json")?)
.create();
let result = get_api().account_order("123456", 123456).await;
assert_eq!(result?, None);
Ok(())
}
#[tokio::test]
async fn account_executions() -> Result<(), Box<dyn Error>> {
let _m = mock("GET", "/v1/accounts/26598145/executions")
.with_status(200)
.with_header("content-type", "text/json")
.with_body(read_to_string("test/response/account-executions.json")?)
.create();
let result = get_api().account_executions("26598145", None, None).await;
assert_eq!(
result?,
vec![
AccountExecution {
id: 53817310,
order_id: 177106005,
symbol: "AAPL".to_string(),
symbol_id: 8049,
quantity: json!(10).to_number(),
side: OrderSide::Buy,
price: json!(536.87).to_number(),
order_chain_id: 17710600,
timestamp: FixedOffset::west(4 * 3600)
.ymd(2014, 03, 31)
.and_hms(13, 38, 29)
.with_timezone(&Utc),
notes: None,
commission: json!(4.95).to_number(),
execution_fee: json!(0).to_number(),
sec_fee: json!(0).to_number(),
canadian_execution_fee: json!(0).to_number(),
parent_id: 0
},
AccountExecution {
id: 710654134,
order_id: 700046545,
symbol: "XSP.TO".to_string(),
symbol_id: 23963,
quantity: json!(3).to_number(),
side: OrderSide::Buy,
price: json!(36.52).to_number(),
order_chain_id: 700065471,
timestamp: FixedOffset::west(4 * 3600)
.ymd(2015, 08, 19)
.and_hms(11, 03, 41)
.with_timezone(&Utc),
notes: None,
commission: json!(0).to_number(),
execution_fee: json!(0.0105).to_number(),
sec_fee: json!(0).to_number(),
canadian_execution_fee: json!(0).to_number(),
parent_id: 710651321
}
]
);
Ok(())
}
#[tokio::test]
async fn account_balance() -> Result<(), Box<dyn Error>> {
let _m = mock("GET", "/v1/accounts/26598145/balances")
.with_status(200)
.with_header("content-type", "text/json")
.with_body(read_to_string("test/response/account-balances.json")?)
.create();
let result = get_api().account_balance("26598145").await;
assert_eq!(
result?,
AccountBalances {
per_currency_balances: vec![
AccountBalance {
currency: Currency::CAD,
cash: json!(322.7015).to_number(),
market_value: json!(6239.64).to_number(),
total_equity: json!(6562.3415).to_number(),
buying_power: json!(15473.182995).to_number(),
maintenance_excess: json!(4646.6015).to_number(),
is_real_time: true
},
AccountBalance {
currency: Currency::USD,
cash: json!(0).to_number(),
market_value: json!(0).to_number(),
total_equity: json!(0).to_number(),
buying_power: json!(0).to_number(),
maintenance_excess: json!(0).to_number(),
is_real_time: true
}
],
combined_balances: vec![
AccountBalance {
currency: Currency::CAD,
cash: json!(322.7015).to_number(),
market_value: json!(6239.64).to_number(),
total_equity: json!(6562.3415).to_number(),
buying_power: json!(15473.182995).to_number(),
maintenance_excess: json!(4646.6015).to_number(),
is_real_time: true
},
AccountBalance {
currency: Currency::USD,
cash: json!(242.541526).to_number(),
market_value: json!(4689.695603).to_number(),
total_equity: json!(4932.237129).to_number(),
buying_power: json!(11629.600147).to_number(),
maintenance_excess: json!(3492.372416).to_number(),
is_real_time: true
}
],
sod_per_currency_balances: vec![
AccountBalance {
currency: Currency::CAD,
cash: json!(322.7015).to_number(),
market_value: json!(6177).to_number(),
total_equity: json!(6499.7015).to_number(),
buying_power: json!(15473.182995).to_number(),
maintenance_excess: json!(4646.6015).to_number(),
is_real_time: true
},
AccountBalance {
currency: Currency::USD,
cash: json!(0).to_number(),
market_value: json!(0).to_number(),
total_equity: json!(0).to_number(),
buying_power: json!(0).to_number(),
maintenance_excess: json!(0).to_number(),
is_real_time: true
}
],
sod_combined_balances: vec![
AccountBalance {
currency: Currency::CAD,
cash: json!(322.7015).to_number(),
market_value: json!(6177).to_number(),
total_equity: json!(6499.7015).to_number(),
buying_power: json!(15473.182995).to_number(),
maintenance_excess: json!(4646.6015).to_number(),
is_real_time: true
},
AccountBalance {
currency: Currency::USD,
cash: json!(242.541526).to_number(),
market_value: json!(4642.615558).to_number(),
total_equity: json!(4885.157084).to_number(),
buying_power: json!(11629.600147).to_number(),
maintenance_excess: json!(3492.372416).to_number(),
is_real_time: true
}
]
}
);
Ok(())
}
#[tokio::test]
async fn account_positions() -> Result<(), Box<dyn Error>> {
let _m = mock("GET", "/v1/accounts/26598145/positions")
.with_status(200)
.with_header("content-type", "text/json")
.with_body(read_to_string("test/response/account-positions.json")?)
.create();
let result = get_api().account_positions("26598145").await;
assert_eq!(
result?,
vec![
AccountPosition {
symbol: "THI.TO".to_string(),
symbol_id: 38738,
open_quantity: json!(100).to_number(),
closed_quantity: json!(0).to_number(),
current_market_value: json!(6017).to_number(),
current_price: json!(60.17).to_number(),
average_entry_price: json!(60.23).to_number(),
closed_profit_and_loss: json!(0).to_number(),
day_profit_and_loss: json!(0).to_number(),
open_profit_and_loss: json!(-6).to_number(),
total_cost: json!(6023).to_number(),
is_real_time: true,
is_under_reorg: false
},
AccountPosition {
symbol: "XSP.TO".to_string(),
symbol_id: 38738,
open_quantity: json!(100).to_number(),
closed_quantity: json!(0).to_number(),
current_market_value: json!(3571).to_number(),
current_price: json!(35.71).to_number(),
average_entry_price: json!(32.831898).to_number(),
closed_profit_and_loss: json!(0).to_number(),
day_profit_and_loss: json!(0).to_number(),
open_profit_and_loss: json!(500.789748).to_number(),
total_cost: json!(3070.750252).to_number(),
is_real_time: false,
is_under_reorg: false
},
]
);
Ok(())
}
#[tokio::test]
async fn market_quote() -> Result<(), Box<dyn Error>> {
let _m = mock("GET", "/v1/markets/quotes")
.match_query(Matcher::UrlEncoded("ids".into(), "2434553,27725609".into()))
.with_status(200)
.with_header("content-type", "text/json")
.with_body(read_to_string("test/response/market-quotes.json")?)
.create();
let result = get_api().market_quote(&[2434553, 27725609]).await;
assert_eq!(
result?,
vec![
MarketQuote {
symbol: "XMU.TO".to_string(),
symbol_id: 2434553,
tier: None,
bid_price: Some(json!(57.01).to_number()),
bid_size: 24,
ask_price: Some(json!(57.13).to_number()),
ask_size: 33,
last_trade_price_tr_hrs: json!(57.15).to_number(),
last_trade_price: json!(57.15).to_number(),
last_trade_size: 100,
last_trade_tick: TickType::Up,
volume: 2728,
open_price: json!(55.76).to_number(),
high_price: json!(57.15).to_number(),
low_price: json!(55.76).to_number(),
delay: false,
is_halted: false
},
MarketQuote {
symbol: "XMU.U.TO".to_string(),
symbol_id: 27725609,
tier: None,
bid_price: Some(json!(42.65).to_number()),
bid_size: 10,
ask_price: Some(json!(42.79).to_number()),
ask_size: 10,
last_trade_price_tr_hrs: json!(44.22).to_number(),
last_trade_price: json!(44.22).to_number(),
last_trade_size: 0,
last_trade_tick: TickType::Equal,
volume: 0,
open_price: json!(0).to_number(),
high_price: json!(0).to_number(),
low_price: json!(0).to_number(),
delay: false,
is_halted: false
}
]
);
Ok(())
}
#[tokio::test]
async fn symbol_search() -> Result<(), Box<dyn Error>> {
let _m = mock("GET", "/v1/symbols/search?prefix=V&offset=0")
.with_status(200)
.with_header("content-type", "text/json")
.with_body(read_to_string("test/response/symbol-search.json")?)
.create();
let result = get_api().symbol_search("V", 0).await;
assert_eq!(
result?,
vec![
SearchEquitySymbol {
symbol: "V".into(),
symbol_id: 40825,
description: "VISA INC".into(),
security_type: SecurityType::Stock,
listing_exchange: ListingExchange::NYSE,
is_quotable: true,
is_tradable: true,
currency: Currency::USD
},
SearchEquitySymbol {
symbol: "VA.TO".into(),
symbol_id: 11419773,
description: "VANGUARD FTSE DEV ASIA PAC ALL CAP IDX".into(),
security_type: SecurityType::Stock,
listing_exchange: ListingExchange::TSX,
is_quotable: true,
is_tradable: true,
currency: Currency::CAD
},
SearchEquitySymbol {
symbol: "VABB".into(),
symbol_id: 40790,
description: "VIRGINIA BANK BANKSHARES INC".into(),
security_type: SecurityType::Stock,
listing_exchange: ListingExchange::PinkSheets,
is_quotable: true,
is_tradable: true,
currency: Currency::USD
},
SearchEquitySymbol {
symbol: "VAC".into(),
symbol_id: 1261992,
description: "MARRIOTT VACATIONS WORLDWIDE CORP".into(),
security_type: SecurityType::Stock,
listing_exchange: ListingExchange::NYSE,
is_quotable: true,
is_tradable: true,
currency: Currency::USD
},
SearchEquitySymbol {
symbol: "VACNY".into(),
symbol_id: 20491473,
description: "VAT GROUP AG".into(),
security_type: SecurityType::Stock,
listing_exchange: ListingExchange::PinkSheets,
is_quotable: true,
is_tradable: true,
currency: Currency::USD
},
SearchEquitySymbol {
symbol: "VACQU".into(),
symbol_id: 32441174,
description: "VECTOR ACQUISITION CORP UNITS(1 ORD A & 1/3 WT)30/09/2027".into(),
security_type: SecurityType::Stock,
listing_exchange: ListingExchange::NASDAQ,
is_quotable: true,
is_tradable: true,
currency: Currency::USD
},
SearchEquitySymbol {
symbol: "VAEEM.IN".into(),
symbol_id: 1630037,
description: "CBOE VXEEM Ask Index".into(),
security_type: SecurityType::Index,
listing_exchange: ListingExchange::SP,
is_quotable: true,
is_tradable: false,
currency: Currency::USD
}
]
);
Ok(())
}
}