UNPKG

crypto-janitor

Version:

The Crypto Janitor provides a simple interface for fetching and cleaning crypto account data.

656 lines (629 loc) 24.9 kB
/* eslint-disable new-cap */ /* eslint-disable max-len */ import BaseConnection, { Transaction, Order } from "./base"; import * as moment from "moment"; import * as ccxt from "ccxt"; import { sleep } from "../utils"; import * as _ from "lodash"; interface ExchangeCredentials { apiKey: string; secret: string; password?: string; } /** * Create ccxt exchange object * * @param {string} name - Name of exchange * @param {string} creds - User's exchange credentials * @param {number} rateLimit - API request rate limit * @return {ccxt.Exchange} ccxt exchange instance */ export function getExchange( name: string, creds?: ExchangeCredentials, rateLimit: number = 1000 ): ccxt.Exchange { const exchanges: any = { // public or private access bittrex: new ccxt.bittrex({ ...creds, enableRateLimit: true }), coinbase: new ccxt.coinbase({ ...creds, enableRateLimit: true }), coinbasepro: new ccxt.coinbasepro({ ...creds, enableRateLimit: true }), kucoin: new ccxt.kucoin({ ...creds, enableRateLimit: true }), // only public access gateio: new ccxt.gateio({ ...creds, enableRateLimit: true }), binance: new ccxt.binance({ ...creds, enableRateLimit: true }), bitstamp: new ccxt.bitstamp({ ...creds, enableRateLimit: true }), ftx: new ccxt.ftx({ ...creds, enableRateLimit: true }), hitbtc: new ccxt.hitbtc({ ...creds, enableRateLimit: true }), huobipro: new ccxt.huobipro({ ...creds, enableRateLimit: true }), kraken: new ccxt.kraken({ ...creds, enableRateLimit: true }), okcoin: new ccxt.okcoin({ ...creds, enableRateLimit: true }), poloniex: new ccxt.poloniex({ ...creds, enableRateLimit: true }), yobit: new ccxt.yobit({ ...creds, enableRateLimit: true }), }; const exchange: ccxt.Exchange | undefined = exchanges[name]; if (!exchange) throw new Error(`"${name}" is not a registered Exchange.`); if (rateLimit) exchange.rateLimit = rateLimit; return exchange; } /** * A base class to support general ccxt exchange operations */ export default class ccxtConnection extends BaseConnection { quoteCurrency: string; markets: Array<string>; fallbackMarketConnection: ccxtConnection | null; access: string; /** * Create BaseConnection instance * @param {string} name - Name of exchange (Ex: coinbase) * @param {any} credentials - API creds of exchange * @param {any} params - (Optional) Additional paramaters */ constructor(name: string, credentials: any = {}, params: any = {}) { super(name, "api", params); this.credentials = credentials; this.access = credentials && credentials.apiKey ? "private" : "public"; this.connection = getExchange(name, credentials, params.rateLimit); this.quoteCurrency = "USD"; this.fallbackMarketConnection = params.fallback ? new params.fallback({}) : null; this.markets = []; } /** * HELPER: Format transaction * * @param {any} transaction - Unformatted cctx unified transaction * @param {string} forceType - (Optional) Type to assign to transaction * @return {Transaction} Formatted transaction */ _formatTransaction(transaction: any, forceType?: string): Transaction { const fee: number = transaction.fee ? transaction.fee.cost : 0; const type: string = forceType ? forceType : _.kebabCase(transaction.type); const formatted: Transaction = { id: transaction.id, timestamp: moment.utc(transaction.timestamp).toDate(), type: type, baseCurrency: transaction.currency, baseQuantity: transaction.amount, baseUsdPrice: 0, feeCurrency: transaction.fee ? transaction.fee.currency : "USD", feeQuantity: fee, feeUsdPrice: 0, feeTotal: 0, subTotal: 0, total: 0, }; if (!this.requireUsdValuation) { formatted.feeUsdPrice = formatted.feeCurrency === "USD" ? 1 : formatted.feeUsdPrice; formatted.subTotal = transaction.amount; formatted.total = transaction.amount + fee; } return formatted; } /** * HELPER: Format orders * * @param {any} order - Unformatted cctx unified transaction * @return {Transaction} Formatted transaction */ _formatOrder(order: any): Order { const fee: number = order.fee ? order.fee.cost : 0; const feeCurrency: string = order.fee ? order.fee.currency : "USD"; const price: number = order.price ? order.price : order.average; const [baseCurrency, quoteCurrency] = order.symbol.split("/"); // use fill as default value with amount as fallback const baseQuantity: number = !isNaN(order.filled) ? order.filled : order.amount; const quoteQuantity: number = order.cost; const formatted: Order = { id: order.id, timestamp: moment.utc(order.timestamp).toDate(), type: order.side, baseCurrency: baseCurrency, baseQuantity: baseQuantity, baseUsdPrice: 0, quoteCurrency: quoteCurrency, quoteQuantity: quoteQuantity, quotePrice: price, quoteUsdPrice: 0, feeCurrency: feeCurrency, feeQuantity: fee, feeUsdPrice: 0, feeTotal: 0, subTotal: 0, total: 0, }; if (!this.requireUsdValuation || quoteCurrency === "USD") { formatted.subTotal = order.cost; formatted.total = order.side === "buy" ? order.cost + fee : order.cost - fee; formatted.quoteUsdPrice = 1; formatted.baseUsdPrice = quoteQuantity / baseQuantity; // formatted.quotePrice = formatted.baseUsdPrice / formatted.quoteUsdPrice; if (formatted.feeCurrency === formatted.quoteCurrency) { formatted.feeUsdPrice = formatted.quoteUsdPrice; formatted.feeTotal = formatted.feeUsdPrice * formatted.feeQuantity; } } return formatted; } /** * HELPER * @description Check if params are satified for call * @param {string} method CCXT exchange method name * @param {string} symbol Currency symbol * @return {void} */ _paramCheck(method: string, symbol?: string): void { if (!this.connection.has[method]) { throw Error( `${this.connection.name} does not support ${method} function` ); } if (this.requireSymbols && !symbol) { throw Error(`${this.connection.name}.${method} requires a symbol`); } } /** * @description Initialize exchange by fetching balances and loading markets * @override BaseConnection.initialize * @param {boolean} forceReload - (Optional) Additional paramaters * @return {Promise<void>} */ async initialize(forceReload: boolean = false): Promise<void> { if (!this.initialized || forceReload) { await this.connection.loadMarkets(); if (this.fallbackMarketConnection) { await this.fallbackMarketConnection.initialize(); } this.symbols = this.connection.symbols; this.markets = Object.values(this.connection.markets) .filter((market: any) => market.active) .map((market: any) => market.symbol); if (this.access === "private") { this.balances = await this.getBalances(); } else { const quotes: Array<string> = _.uniq( this.markets.map( (currencyPair: string) => currencyPair.split("/")[1] ) ); // detect USD (or equivelent) quote currency if (!quotes.includes("USD")) { if (quotes.includes("USDC")) { this.quoteCurrency = "USDC"; } else if (quotes.includes("USDT")) { this.quoteCurrency = "USDT"; } } } this.initialized = true; } } /** * @description Fetch Account Balances * @override BaseConnection.getBalances * @return {Promise<any>} Account balance object */ async getBalances(): Promise<any> { if (this.access === "private") { const balances: any = await this.connection.fetchBalance(); return balances; } return []; } /** * @description Fetch Account Withdrawals * @override BaseConnection.getWithdrawals * @param {string} symbol Currency symbol * @param {string} key (Default=Send) Transaction type key * @param {number} since (Optional) Timestamp to get transactions since * @param {number} limit (Optional) Max number of entries per request * @return {Promise<Array<any>>} Array of withdrawal objects */ async getWithdrawals( symbol?: string, key: string = "send", since?: number, limit: number = 100 ): Promise<Array<any>> { if (this.access === "private") { this._paramCheck("fetchWithdrawals", symbol); let withdrawals: any = await this.connection.fetchWithdrawals( symbol, since, limit ); withdrawals = withdrawals.map((deposit: any) => this._formatTransaction(deposit, key) ); if (since) { withdrawals = withdrawals.filter( (deposit: Transaction) => deposit.timestamp.getTime() > since ); } if (this.requireUsdValuation) { withdrawals = withdrawals.map((withdrawal: Transaction) => this.quoteTransaction(withdrawal) ); withdrawals = await Promise.all(withdrawals); } return withdrawals; } return []; } /** * @description Fetch Account Deposits * @override BaseConnection.getDeposits * @param {string} symbol Currency symbol * @param {string} key (Default=Receive) Transaction type key * @param {number} since (Optional) Timestamp to get transactions since * @param {number} limit (Optional) Max number of entries per request * @return {Promise<Array<any>>} Array of deposit objects */ async getDeposits( symbol?: string, key: string = "receive", since?: number, limit: number = 100 ): Promise<Array<any>> { if (this.access === "private") { this._paramCheck("fetchDeposits", symbol); let deposits: any = await this.connection.fetchDeposits( symbol, since, limit ); deposits = deposits.map((deposit: any) => this._formatTransaction(deposit, key) ); if (since) { deposits = deposits.filter( (deposit: Transaction) => deposit.timestamp.getTime() > since ); } if (this.requireUsdValuation) { deposits = deposits.map((deposit: Transaction) => this.quoteTransaction(deposit) ); deposits = await Promise.all(deposits); deposits = deposits.filter( (tx: Transaction) => tx.baseUsdPrice !== 0 ); } return deposits; } return []; } /** * @description Fetch Account Orders * @override BaseConnection.getOrders * @param {string} symbol Currency symbol * @param {number} since (Optional) Timestamp to get transactions since * @param {number} limit (Optional) Max number of entries per request * @return {Promise<Array<any>>} Array of order objects */ async getOrders( symbol?: string, since?: number, limit: number = 100 ): Promise<Array<any>> { if (this.access === "private") { this._paramCheck("fetchClosedOrders", symbol); let orders = await this.connection.fetchClosedOrders( symbol, since, limit ); orders = orders.filter((order: any) => order.filled > 0); orders = orders.map((order: any) => this._formatOrder(order)); if (since) { orders = orders.filter( (order: Order) => order.timestamp.getTime() > since ); } if (this.requireUsdValuation) { orders = orders.map((order: Order) => this.quoteOrder(order)); orders = await Promise.all(orders); orders = orders.map((order: Order) => this._attemptedSwapConversion(order) ); } return orders; } return []; } /** * @description Fetch all account transactions (withdrawals, deposits, and orders) * @override BaseConnection.getLedger * @param {string} symbol Currency symbol * @param {number} since (Optional) Timestamp to get transactions since * @param {number} limit (Optional) Max number of entries per request * @return {Promise<any>} Array of withdrawal objects */ async getLedger( symbol?: string, since?: number, limit: number = 100 ): Promise<any> { if (this.access === "private") { this._paramCheck("fetchLedger", symbol); const ledger = await this.connection.fetchLedger( symbol, since, limit ); // return ledger.map((transaction: any) => _formatTransaction(transaction)); return ledger; } return []; } /** * @description Fetch account transactions (withdrawals, deposits, and orders) * @override BaseConnection.getTransactions * @param {string} symbol Currency symbol * @param {number} since (Optional) Timestamp to get transactions since * @return {Promise<Array<any>>} Array of withdrawal objects */ async getTransactions( symbol?: string, since?: number ): Promise<Array<any>> { if (this.access === "private") { const results = await Promise.all([ this.getWithdrawals(symbol, "send", since), this.getDeposits(symbol, "receive", since), this.getOrders(symbol, since), ]); return _.sortBy(_.flatten(results), "timestamp"); } return []; } /** * @description Fetch all transactions (withdrawals, deposits, and orders) for all symbols * @override BaseConnection.getAllTransactions * @param {number} since (Optional) Timestamp to get transactions since * @return {Array<any>} Array of withdrawal objects */ async getAllTransactions(since?: number): Promise<any> { if (this.access === "private") { const catchErrors: Array<any> = [ ccxt.NetworkError, ccxt.ExchangeError, ccxt.InvalidNonce, ]; let allTransactions: Array<any> = []; if (this.requireSymbols) { for (const symbol of this.symbols) { let x = 0; while (x < 3) { try { const transactions = await this.getTransactions( symbol, since ); allTransactions = allTransactions.concat(transactions); break; } catch (e) { if ( catchErrors.some( (error: any) => e instanceof error ) ) { if ( e.message.includes( "does not have currency code" ) ) { break; } await sleep(1500); console.log( "rate limit exceed...sleeping", e.message ); x++; } else { console.log( `getTransactions failed => ${e.message}` ); break; } } } } } else { allTransactions = await this.getTransactions(undefined, since); } allTransactions = _.sortBy(allTransactions, "timestamp"); return allTransactions; } return []; } /** * @description Get preferred market(s) to convert asset to USD (or stablecoin) * @param {string} symbol Currency Symbol * @param {Array<string>} exclude Currency Symbol * @return {Promise<Array<string>>} Ex: ["USDC"] or ["BTC", "USD"] */ async getQuoteConversion( symbol: string, exclude: Array<string> = [] ): Promise<Array<string>> { const currencyPairs: Array<string> = this.markets.filter( (currencyPair: string) => { const [base, quote] = currencyPair.split("/"); return base === symbol && !exclude.includes(quote); } ); if (currencyPairs.length > 0) { const quoteOptions = [...this.stableCurrencies, "BTC", "ETH"]; const quoteCurrency: string = quoteOptions.find((quoteSymbol: string) => currencyPairs.includes(`${symbol}/${quoteSymbol}`) ) || ""; if (!this.stableCurrencies.includes(quoteCurrency)) { return [quoteCurrency, this.quoteCurrency]; } return [quoteCurrency]; } return []; } /** * @description Get price of an asset at a given time (only public access required) * @param {string} symbol Currency symbol * @param {number} timestamp Timestamp to get price at * @return {Promise<number>} Price of asset in USD */ async getQuote(symbol: string, timestamp: number): Promise<number> { if (this.stableCurrencies.includes(symbol)) { return 1; } const marketSources: Array<any> = [this]; if (this.fallbackMarketConnection) { marketSources.push(this.fallbackMarketConnection); } for (const source of marketSources) { // Must use this loop to make sure a market is found and that market returns a valid // price for the provided timestamp. Consider making this a shared function let price: number = 0; let quotes: Array<string> = await source.getQuoteConversion(symbol); let exclude: Array<string> = []; while (quotes.length > 0) { exclude = _.uniq(exclude.concat(quotes)); const candle = await source.connection.fetchOHLCV( `${symbol}/${quotes[0]}`, "1m", timestamp, 2 ); if (candle.length > 0) { price = candle[0][3]; if (quotes.length > 1) { const conversionCandle = await source.connection.fetchOHLCV( `${quotes[0]}/${quotes[1]}`, "1m", timestamp, 2 ); price = price * conversionCandle[0][3]; } return price; } else { quotes = await source.getQuoteConversion(symbol, exclude); } } } return 0; // if you are reaching this you should provide fallback market sources for your connection } /** * @description Get price of an asset at a given time (only public access required) * @param {number} timestamp Timestamp to get price at * @param {string} baseCurrency Base currency symbol * @param {string} feeCurrency (Optional) Fee currency symbol - will default to base or quote if not specifed * @param {string} quoteCurrency (Optional) Quote currency symbol * @param {number} quotePrice Price of base currency denominated in quote currency * @return {Promise<any>} Price of asset in USD */ async getCommonPrices( timestamp: number, baseCurrency: string, feeCurrency?: string, quoteCurrency?: string, quotePrice?: number ): Promise<number> { const symbol: string = quoteCurrency ? quoteCurrency : baseCurrency; const price: number = await this.getQuote(symbol, timestamp); const prices: any = {}; if (symbol === baseCurrency) { prices.baseUsdPrice = price; } else if (symbol === quoteCurrency) { prices.quoteUsdPrice = price; if (quotePrice !== undefined) { prices.baseUsdPrice = prices.quoteUsdPrice * quotePrice; } } // Fetch fee price if neccessary if (feeCurrency) { if (feeCurrency === "USD") { prices.feeUsdPrice = 1; } else if (feeCurrency === baseCurrency) { prices.feeUsdPrice = prices.baseUsdPrice; } else if (feeCurrency === quoteCurrency) { prices.feeUsdPrice = prices.quoteUsdPrice; } else { prices.feeUsdPrice = await this.getQuote( feeCurrency, timestamp ); } } return prices; } /** * @description Update USD quote values for a transaction (only public access required) * @param {Transaction} tx Transaction object * @param {any} prices Transaction prices * @return {Promise<Transaction>} Transaction with updated pricing data */ async quoteTransaction( tx: Transaction, prices?: any ): Promise<Transaction> { // Get and update prices if (!prices) { prices = await this.getCommonPrices( tx.timestamp.getTime(), tx.baseCurrency, tx.feeCurrency ); } tx.baseUsdPrice = prices.baseUsdPrice; tx.feeUsdPrice = prices.feeUsdPrice; // Update totals tx.feeTotal = tx.feeUsdPrice * tx.feeQuantity; tx.subTotal = tx.baseQuantity * tx.baseUsdPrice; tx.total = tx.subTotal + tx.feeUsdPrice * tx.feeQuantity; return tx; } /** * @description Update USD quote values for an order (only public access required) * @param {Order} order Order object * @param {any} prices Order prices * @return {Promise<Order>} Order with updated pricing data */ async quoteOrder(order: Order, prices?: any): Promise<Order> { // Get and update prices if (!prices) { prices = await this.getCommonPrices( order.timestamp.getTime(), order.baseCurrency, order.feeCurrency, order.quoteCurrency, order.quotePrice ); } order.baseUsdPrice = prices.baseUsdPrice; order.feeUsdPrice = prices.feeUsdPrice; order.quoteUsdPrice = prices.quoteUsdPrice; // Update totals order.feeTotal = order.feeUsdPrice * order.feeQuantity; order.subTotal = order.baseQuantity * order.baseUsdPrice; order.total = order.type === "buy" ? order.subTotal + order.feeTotal : order.subTotal - order.feeTotal; return order; } }