UNPKG

algotrader

Version:

Algorithmically trade stocks and options using Robinhood, Yahoo Finance, and more.

640 lines (603 loc) 19 kB
const LibraryError = require('../../globals/LibraryError'); const Robinhood = require('./Robinhood'); const Instrument = require('./Instrument'); const Portfolio = require('./Portfolio'); const Order = require('./Order'); const OptionOrder = require('./OptionOrder'); const request = require('request'); const fs = require('fs'); const async = require('async'); const path = require('path'); const prompt = require('prompt'); const moment = require('moment'); /** * Represents the user that is logged in while accessing the Robinhood API. */ class User extends Robinhood { /** * Creates a new User object. * @param {String} username * @param {String} password - Optional. If not provided the user will be prompted via CLI. */ constructor(username, password) { super(); this.username = username; this.password = password; this.token = null; // Authentication token this.account = null; // Account number this.expires = null; // Auth expiration date (24 hours after login) } /** * Authenticates a user using the inputted username and password. * @param {String|Undefined} password - Optional if not provided in constructor or re-authenticating a saved user. * @param {Function|Undefined} mfaFunction - Optional function that is called when prompted for multi-factor authentication. Must return a promise with a six-character string. If not provided the CLI will be prompted. * @returns {Promise<Boolean>} */ authenticate(password, mfaFunction) { const _this = this; return new Promise((resolve, reject) => { if (_this.password === undefined && password === undefined) { console.log("You didn't include a password in the constructor of your Robinhood user or when calling the authenticate function and it is required to authenticate your account."); prompt.get({ properties: { password: { required: true, hidden: true } } }, (error, result) => { _this.password = result.password; _preAuth(resolve, reject); }) } else _preAuth(resolve, reject); }); function _preAuth(resolve, reject) { if (_this.password === undefined) _this.password = password; request.post({ uri: _this.url + "/oauth2/token/", form: { username: _this.username, password: _this.password, client_id: 'c82SH0WZOsabOXGP2sxqcj34FxkvfnWRZBKlBjFS', grant_type: 'password', scope: 'internal' } }, (error, response, body) => { if (error) reject(error); else if (response.statusCode !== 200) reject(new LibraryError(body)); else { const json = JSON.parse(body); if (json.mfa_required) { if (mfaFunction !== undefined) { console.log("Multi-factor authentication detected. Executing the provided function..."); mfaFunction() .then(mfa => { if (!mfa instanceof String) reject(new Error("The provided function did not return a string after the promise resolved.")); else if (mfa.length !== 6) reject(new Error("The provided function returned a string, but it is not six-characters in length.")); else _sendMFA(mfa, resolve, reject); }) .catch(error => { console.log("An error occurred while executing the provided MFA function."); reject(error); }) } else { console.log("Multi-factor authentication detected. Please enter your six-digit code below:"); console.log(" - This can be entered programmatically by passing a function when authenticating. See documentation for more."); prompt.get({ properties: { code: { pattern: /^[0-9]{6}$/, message: "Your Robinhood code will most likely be texted to you and should only contain 6 integers.", required: true } } }, (error, mfaCode) => { _sendMFA(mfaCode.code, resolve, reject); }) } } else _postAuth(json, resolve, reject); } }) } function _sendMFA(mfaCode, resolve, reject) { request.post({ uri: _this.url + '/oauth2/token/', form: { username: _this.username, password: _this.password, client_id: 'c82SH0WZOsabOXGP2sxqcj34FxkvfnWRZBKlBjFS', grant_type: 'password', scope: 'internal', mfa_code: mfaCode } }, (error, response, body) => { if (error) reject(error); else if (response.statusCode !== 200) reject(new LibraryError(body)); else _postAuth(JSON.parse(body), resolve, reject); }) } function _postAuth(json, resolve, reject) { _this.expires = moment().add(json.expires_in, 'seconds'); _this.token = json.access_token; _this.getAccount().then(account => { _this.account = account.account_number; delete _this.password; resolve(_this); }).catch(error => reject(error)); } } /** * Logout the user by expiring the authentication token and removing any saved data. * @returns {Promise<Boolean>} */ logout() { const _this = this; return new Promise((resolve, reject) => { request.post({ uri: _this.url + "/api-token-logout/", headers: { 'Authorization': 'Bearer ' + _this.token } }, (error, response, body) => { if (error) reject(error); else if (response.statusCode !== 200) reject(new LibraryError(body)); else { try { fs.unlinkSync(dir); } catch (e) {} resolve(true); } }) }) } /** * Save the user to disk. Prevents having to login and logout each run. * @returns {Promise<Boolean>} */ save() { const _this = this; return new Promise((resolve, reject) => { if (!_this.isAuthenticated()) reject(new Error('You cannot save an unauthenticated user!')); else { const dir = path.join(__dirname, 'User.json'); try { fs.unlinkSync(dir); } catch (e) {} fs.writeFile(dir, JSON.stringify(_this, null, 2), error => { if (error) reject(error); else resolve(true); }) } }) } /** * If a saved user exists, this will load it into system memory. Recommended if using multi-factor authentication. * @returns {Promise<User>} */ static load() { return new Promise((resolve, reject) => { fs.readFile(path.join(__dirname, 'User.json'), 'utf8', (error, data) => { if (error) { if (error.errno === -2) reject(new Error("A saved user does not exist!")); else reject(error); } else { const json = JSON.parse(data); if (moment().isBefore(json.expires)) { const u = new User(json.username, json.password); u.token = json.token; u.account = json.account; u.expires = json.expires; resolve(u); } else { reject(new Error("User session has expired. Please authenticate again.")) } } }); }) } static isUser(object) { return object instanceof this.constructor; } // GET isAuthenticated() { return this.token !== undefined && moment().isBefore(this.expires); } getAuthToken() { return this.token; } getAccountNumber() { return this.account; } getUsername() { return this.username; } /** * Returns vital information about balances and enabled features. * @returns {Promise} */ getAccount() { const _this = this; return new Promise((resolve, reject) => { request({ uri: _this.url + "/accounts/", headers: { 'Authorization': 'Bearer ' + _this.token } }, (error, response, body) => { return Robinhood.handleResponse(error, response, body, _this.token, resolve, reject); }) }) } /** * Returns an object containing details on the user's cash and marginbalance. * @returns {Promise<Object>} */ getBalances() { const _this = this; return new Promise((resolve, reject) => { _this.getAccount().then(res => { resolve({ unsettledFunds: res.unsettled_funds, unsettledDebit: res.unsettled_debit, unclearedDeposits: res.uncleared_deposits, smaHeldForOrders: res.sma_held_for_orders, cash: res.cash, cashHeldForOrders: res.cash_held_for_orders, cashAvailableForWithdraw: res.cash_available_for_withdraw, buyingPower: res.buying_power, sma: res.sma, accountType: res.type, margin: { goldEquityRequirement: res.margin_balances.gold_equity_requirement, outstandingInterest: res.margin_balances.outstanding_interest, cashHeldForOptionsCollateral: res.margin_balances.cash_held_for_options_collateral, dayTradeBuyingPower: res.margin_balances.day_trade_buying_power, unallocatedMarginCash: res.margin_balances.unallocated_margin_cash, startOfDayOvernightBuyingPower: res.margin_balances.start_of_day_overnight_buying_power, marginLimit: res.margin_balances.margin_limit, overnightBuyingPower: res.margin_balances.overnight_buying_power, startOfDayDtbp: res.margin_balances.start_of_day_dtbp, dayTradeBuyingPowerHeldForOrders: res.margin_balances.day_trade_buying_power_held_for_orders } }); }).catch(error => reject(error)); }) } /** * Returns the amount of money available to be spent. * @returns {Promise} */ getBuyingPower() { const _this = this; return new Promise((resolve, reject) => { _this.getAccount().then(res => { resolve(Number(res.buying_power)); }).catch(error => reject(error)); }) } /** * Returns information like username, first / last name, creation date, id, and more. * @returns {Promise<Object>} */ getUserInfo() { const _this = this; return new Promise((resolve, reject) => { request({ uri: _this.url + "/user/", headers: { 'Authorization': 'Bearer ' + _this.token } }, (error, response, body) => { return Robinhood.handleResponse(error, response, body, _this.token, resolve, reject); }) }) } /** * Returns the user's unique ID. * @returns {Promise<String>} */ getUID() { const _this = this; return new Promise((resolve, reject) => { request({ uri: _this.url + "/user/id/", headers: { 'Authorization': 'Bearer ' + _this.token } }, (error, response, body) => { return Robinhood.handleResponse(error, response, body, _this.token, res => { resolve(res.id); }, reject); }) }) } /** * Returns information like address, citizenship, SSN, date of birth, and more. * @returns {Promise<Object>} */ getTaxInfo() { const _this = this; return new Promise((resolve, reject) => { request({ uri: _this.url + "/user/basic_info/", headers: { 'Authorization': 'Bearer ' + _this.token } }, (error, response, body) => { return Robinhood.handleResponse(error, response, body, _this.token, resolve, reject); }) }) } /** * Returns information on the user pertaining to SEC rule 405. * @returns {Promise<Object>} */ getDisclosureInfo() { const _this = this; return new Promise((resolve, reject) => { request({ uri: _this.url + "/user/additional_info/", headers: { 'Authorization': 'Bearer ' + _this.token } }, (error, response, body) => { return Robinhood.handleResponse(error, response, body, _this.token, resolve, reject); }) }) } /** * Returns information on the user's employment. * @returns {Promise<Object>} */ getEmployerInfo() { const _this = this; return new Promise((resolve, reject) => { request({ uri: _this.url + "/user/employment/", headers: { 'Authorization': 'Bearer ' + _this.token } }, (error, response, body) => { return Robinhood.handleResponse(error, response, body, _this.token, resolve, reject); }) }) } /** * Returns the user's answers to basic questions regarding investment experiences. * @returns {Promise<Object>} */ getInvestmentProfile() { const _this = this; return new Promise((resolve, reject) => { request({ uri: _this.url + "/user/investment_profile/", headers: { 'Authorization': 'Bearer ' + _this.token } }, (error, response, body) => { return Robinhood.handleResponse(error, response, body, _this.token, resolve, reject); }) }) } /** * Returns arrays of recent option and equity day trades. * @returns {Promise<Object>} */ getRecentDayTrades() { const _this = this; return new Promise((resolve, reject) => { request({ uri: _this.url + "/accounts/" + _this.account + "/recent_day_trades/", headers: { 'Authorization': 'Bearer ' + _this.token } }, (error, response, body) => { return Robinhood.handleResponse(error, response, body, _this.token, resolve, reject); }) }) } /** * Returns an array of recent orders. * @returns {Promise<Order[]>} */ getRecentOrders() { return Order.getRecentOrders(this); } /** * Cancels all open orders. * @returns {Promise} */ cancelOpenOrders() { return Order.cancelOpenOrders(this); } /** * Returns an array of option orders. * @returns {Promise<Array>} */ getOptionOrders() { return OptionOrder.getOrders(this); } /** * Returns a Portfolio object containing all open positions in a user's portfolio. * @returns {Promise<Object>} */ getPortfolio() { const _this = this; return new Promise((resolve, reject) => { request({ uri: _this.url + "/accounts/" + _this.account + "/positions/", headers: { 'Authorization': 'Bearer ' + _this.token } }, (error, response, body) => { Robinhood.handleResponse(error, response, body, _this.token, res => { let array = []; async.forEachOf(res, (position, key, callback) => { position.quantity = Number(position.quantity); if (position.quantity !== 0) { Instrument.getByURL(position.instrument).then(instrument => { position.InstrumentObject = instrument; array.push(position); callback(); }); } else callback(); }, () => { resolve(new Portfolio(_this, array)); } ); }, reject); }) }) } /** * Returns an object that can be used to create a chart, show total return, etc. * @returns {Promise<Object>} */ getHistoricals(span, interval) { const _this = this; return new Promise((resolve, reject) => { request({ uri: _this.url + "/portfolios/historicals/" + _this.account, headers: { 'Authorization': 'Bearer ' + _this.token }, qs: { span: span, interval: interval } }, (error, response, body) => { Robinhood.handleResponse(error, response, body, _this.token, res => { resolve(res); }, reject); }) }) } // Invalid token? // // getNotifications() { // const _this = this; // return new Promise((resolve, reject) => { // request({ // uri: _this.url + "/midlands/notifications/stack/", // headers: { // 'Authorization': 'Bearer ' + _this.token // } // }, (error, response, body) => { // return Robinhood.handleResponse(error, response, body, _this.token, resolve, reject); // }) // }) // } // BANKING /** * Returns an object representing the user's linked bank account. If the user has linked multiple, this returns an array. * @returns {Promise<Object>} */ getLinkedBanks() { const _this = this; return new Promise((resolve, reject) => { request({ uri: _this.url + "/ach/relationships/", headers: { 'Authorization': 'Bearer ' + _this.token } }, (error, response, body) => { return Robinhood.handleResponse(error, response, body, _this.token, resolve, reject); }) }) } /** * Deposits money into the user's account. If frequency is not empty, this becomes an automatic deposit. * @param {String} bankID - This ID can be found from getLinkedBanks(). * @param {String} amount - How much money should be deposited, represented as a string. * @param {String} frequency - Empty string if one-time deposit, otherwise: 'weekly,' 'biweekly,' 'monthly,' or 'quarterly.' * @returns {Promise<Object>} */ addDeposit(bankID, amount, frequency) { const _this = this; return new Promise((resolve, reject) => { if (!bankID instanceof String) reject(new Error("Parameter 'bankID' must be a string.")); else if (!amount instanceof String) reject(new Error("Parameter 'amount' must be a string.")); else if (!frequency instanceof String) reject(new Error("Parameter 'frequency' must be a string.")); else if (["", "weekly", "biweekly", "monthly", "quarterly"].indexOf(frequency) === -1) reject(new Error("Provided frequency parameter is invalid: " + frequency + "\nValid input: empty string (one-time deposit), 'weekly,' 'biweekly,' 'monthly,' or 'quarterly.'")); else { request({ uri: _this.url + "/ach/deposit_schedules/", headers: { 'Authorization': 'Bearer ' + _this.token }, qs: { achRelationship: _this.url + "/ach/relationships/" + bankID + "/", amount: amount, frequency: frequency } }, (error, response, body) => { return Robinhood.handleResponse(error, response, body, _this.token, resolve, reject); }) } }) } // DOCUMENTS /** * Returns an array of account documents (taxes, statements, etc). Use 'downloadDocuments()' to view them. * @returns {Promise<Array>} */ getDocuments() { const _this = this; return new Promise((resolve, reject) => { request({ uri: _this.url + /documents/, headers: { 'Authorization': 'Bearer ' + _this.token } }, (error, response, body) => { return Robinhood.handleResponse(error, response, body, _this.token, resolve, reject); }) }); }; /** * Downloads all account documents to the given folder path. * Note that, because of Robinhood's connection throttling, this will take a while for accounts with high activity. * Downloads will be attempted every second and will wait for any connection throttling to end before continuing. * @param {String} folder * @returns {Promise} */ downloadDocuments(folder) { const _this = this; return new Promise((resolve, reject) => { if (!fs.existsSync(folder)) fs.mkdirSync(folder); _this.getDocuments().then(array => { async.eachSeries(array, (document, eachCallback) => { const dir = path.join(folder, document.type); const file = path.join(dir, document.id + ".pdf"); if (!fs.existsSync(dir)) fs.mkdirSync(dir); let downloaded = false; async.whilst(() => { return !downloaded; }, whilstCallback => { let seconds = 0; const req = request({ uri: document.download_url, headers: { 'Authorization': 'Bearer ' + _this.token } }, (error, response, body) => { if (error) reject(error); else if (response.statusCode !== 200) { seconds = Number(body.split("available in ")[1].split(" seconds")[0]); } else downloaded = true; }); req.on('end', () => { setTimeout(() => { if (seconds === 0) whilstCallback(); else setTimeout(() => { whilstCallback(); }, seconds * 1000); }, 1000); }); req.pipe(fs.createWriteStream(file)) }, () => { eachCallback(); }) }, () => { resolve(); }) }) }) } } module.exports = User;