diff --git a/README.md b/README.md index 5fb3ca4..95cd303 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,7 @@ This project uses typescript. Resources are stored in 3 key structures: - [src](./src) - the whole connector written in typescript - [lib](./lib) - the javascript version of the project (compiled from typescript). This should not be edited directly, as it will be overwritten with each release. - [dist](./dist) - the packed bundle of the project for use in browser environments. +- [examples](./examples) - some implementation examples & demonstrations. Contributions are welcome! --- @@ -181,6 +182,48 @@ client.getOrderBook({ symbol: 'BTCUSDT' }) }); ``` +See [linear-client.ts](./src/linear-client.ts) for further information. + +### REST Spot +To use the Spot REST APIs, import the `SpotClient`: + +```javascript +const { SpotClient } = require('bybit-api'); + +const API_KEY = 'xxx'; +const PRIVATE_KEY = 'yyy'; +const useLivenet = false; + +const client = new javascript( + API_KEY, + PRIVATE_KEY, + + // optional, uses testnet by default. Set to 'true' to use livenet. + useLivenet, + + // restClientOptions, + // requestLibraryOptions +); + +client.getSymbols() + .then(result => { + console.log(result); + }) + .catch(err => { + console.error(err); + }); + +client.getBalances() + .then(result => { + console.log("getBalances result: ", result); + }) + .catch(err => { + console.error("getBalances error: ", err); + }); +``` + +See [spot-client.ts](./src/spot-client.ts) for further information. + ## WebSockets Inverse, linear & spot WebSockets can be used via a shared `WebsocketClient`. However, make sure to make one instance of WebsocketClient per market type (spot vs inverse vs linear vs linearfutures): @@ -203,11 +246,10 @@ const wsConfig = { // NOTE: to listen to multiple markets (spot vs inverse vs linear vs linearfutures) at once, make one WebsocketClient instance per market - // defaults to false == inverse. Set to true for linear (USDT) trading. - // linear: true - - // defaults to false == inverse. Set to true for spot trading. These booleans will be changed into a single setting in future. - // spot: true + // defaults to inverse: + // market: 'inverse' + // market: 'linear' + // market: 'spot' // how long to wait (in ms) before deciding the connection should be terminated & reconnected // pongTimeout: 1000, diff --git a/examples/rest-spot-public.ts b/examples/rest-spot-public.ts new file mode 100644 index 0000000..151d71d --- /dev/null +++ b/examples/rest-spot-public.ts @@ -0,0 +1,18 @@ +import { SpotClient } from '../src/index'; + +// or +// import { SpotClient } from 'bybit-api'; + +const client = new SpotClient(); + +const symbol = 'BTCUSDT'; + +(async () => { + try { + // console.log('getSymbols: ', await client.getSymbols()); + // console.log('getOrderBook: ', await client.getOrderBook(symbol)); + console.log('getOrderBook: ', await client.getOrderBook(symbol)); + } catch (e) { + console.error('request failed: ', e); + } +})(); diff --git a/src/index.ts b/src/index.ts index bdd9850..7f3aa47 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,6 @@ export * from './inverse-client'; export * from './inverse-futures-client'; export * from './linear-client'; +export * from './spot-client'; export * from './websocket-client'; export * from './logger'; diff --git a/src/spot-client.ts b/src/spot-client.ts new file mode 100644 index 0000000..2c67f0a --- /dev/null +++ b/src/spot-client.ts @@ -0,0 +1,157 @@ +import { AxiosRequestConfig } from 'axios'; +import { KlineInterval } from './types/shared'; +import { NewSpotOrder, OrderSide, OrderTypeSpot, SpotOrderQueryById } from './types/spot'; +import BaseRestClient from './util/BaseRestClient'; +import { GenericAPIResponse, getRestBaseUrl, RestClientOptions } from './util/requestUtils'; +import RequestWrapper from './util/requestWrapper'; + +export class SpotClient extends BaseRestClient { + protected requestWrapper: RequestWrapper; + + /** + * @public Creates an instance of the Spot REST API client. + * + * @param {string} key - your API key + * @param {string} secret - your API secret + * @param {boolean} [useLivenet=false] + * @param {RestClientOptions} [restClientOptions={}] options to configure REST API connectivity + * @param {AxiosRequestConfig} [requestOptions={}] HTTP networking options for axios + */ + constructor( + key?: string | undefined, + secret?: string | undefined, + useLivenet: boolean = false, + restClientOptions: RestClientOptions = {}, + requestOptions: AxiosRequestConfig = {} + ) { + super(key, secret, getRestBaseUrl(useLivenet, restClientOptions), restClientOptions, requestOptions); + + // this.requestWrapper = new RequestWrapper( + // key, + // secret, + // getRestBaseUrl(useLivenet, restClientOptions), + // restClientOptions, + // requestOptions + // ); + return this; + } + + async getServerTime(urlKeyOverride?: string): Promise { + const result = await this.get('/spot/v1/time'); + return result.serverTime; + } + + /** + * + * Market Data Endpoints + * + **/ + + getSymbols() { + return this.get('/spot/v1/symbols'); + } + + getOrderBook(symbol: string, limit?: number) { + return this.get('/spot/quote/v1/depth', { + symbol, limit + }); + } + + getMergedOrderBook(symbol: string, scale?: number, limit?: number) { + return this.get('/spot/quote/v1/depth/merged', { + symbol, + scale, + limit, + }); + } + + getTrades(symbol: string, limit?: number) { + return this.get('/spot/v1/trades', { + symbol, + limit, + }); + } + + getCandles(symbol: string, interval: KlineInterval, limit?: number, startTime?: number, endTime?: number) { + return this.get('/spot/v1/trades', { + symbol, + interval, + limit, + startTime, + endTime, + }); + } + + get24hrTicker(symbol?: string) { + return this.get('/spot/quote/v1/ticker/24hr', { symbol }); + } + + getLastTradedPrice(symbol?: string) { + return this.get('/spot/quote/v1/ticker/price', { symbol }); + } + + getBestBidAskPrice(symbol?: string) { + return this.get('/spot/quote/v1/ticker/book_ticker', { symbol }); + } + + /** + * Account Data Endpoints + */ + + submitOrder(params: NewSpotOrder) { + return this.postPrivate('/spot/v1/order', params); + } + + getOrder(params: SpotOrderQueryById) { + return this.getPrivate('/spot/v1/order', params); + } + + cancelOrder(params: SpotOrderQueryById) { + return this.deletePrivate('/spot/v1/order', params); + } + + cancelOrderBatch(params: { + symbol: string; + side?: OrderSide; + orderTypes: OrderTypeSpot[] + }) { + const orderTypes = params.orderTypes ? params.orderTypes.join(',') : undefined; + return this.deletePrivate('/spot/order/batch-cancel', { + ...params, + orderTypes, + }); + } + + getOpenOrders(symbol?: string, orderId?: string, limit?: number) { + return this.getPrivate('/spot/v1/open-orders', { + symbol, + orderId, + limit, + }); + } + + getPastOrders(symbol?: string, orderId?: string, limit?: number) { + return this.getPrivate('/spot/v1/history-orders', { + symbol, + orderId, + limit, + }); + } + + getMyTrades(symbol?: string, limit?: number, fromId?: number, toId?: number) { + return this.getPrivate('/spot/v1/myTrades', { + symbol, + limit, + fromId, + toId, + }); + } + + /** + * Wallet Data Endpoints + */ + + getBalances() { + return this.getPrivate('/spot/v1/account'); + } +} diff --git a/src/types/spot.ts b/src/types/spot.ts new file mode 100644 index 0000000..9e92081 --- /dev/null +++ b/src/types/spot.ts @@ -0,0 +1,18 @@ +export type OrderSide = 'Buy' | 'Sell'; +export type OrderTypeSpot = 'LIMIT' | 'MARKET' | 'LIMIT_MAKER'; +export type OrderTimeInForce = 'GTC' | 'FOK' | 'IOC'; + +export interface NewSpotOrder { + symbol: string; + qty: number; + side: OrderSide; + type: OrderTypeSpot; + timeInForce?: OrderTimeInForce; + price?: number; + orderLinkId?: string; +} + +export interface SpotOrderQueryById { + orderId?: string; + orderLinkId?: string; +} diff --git a/src/util/BaseRestClient.ts b/src/util/BaseRestClient.ts new file mode 100644 index 0000000..62db52e --- /dev/null +++ b/src/util/BaseRestClient.ts @@ -0,0 +1,208 @@ +import axios, { AxiosError, AxiosRequestConfig, AxiosResponse, Method } from 'axios'; + +import { signMessage } from './node-support'; +import { RestClientOptions, GenericAPIResponse, getRestBaseUrl, serializeParams, isPublicEndpoint } from './requestUtils'; + +export default abstract class BaseRestClient { + private timeOffset: number | null; + private syncTimePromise: null | Promise; + private options: RestClientOptions; + private baseUrl: string; + private globalRequestOptions: AxiosRequestConfig; + private key: string | undefined; + private secret: string | undefined; + + constructor( + key: string | undefined, + secret: string | undefined, + baseUrl: string, + options: RestClientOptions = {}, + requestOptions: AxiosRequestConfig = {} + ) { + this.timeOffset = null; + this.syncTimePromise = null; + + this.options = { + recv_window: 5000, + // how often to sync time drift with bybit servers + sync_interval_ms: 3600000, + // if true, we'll throw errors if any params are undefined + strict_param_validation: false, + ...options + }; + + this.globalRequestOptions = { + // in ms == 5 minutes by default + timeout: 1000 * 60 * 5, + // custom request options based on axios specs - see: https://github.com/axios/axios#request-config + ...requestOptions, + headers: { + 'x-referer': 'bybitapinode' + }, + }; + + this.baseUrl = baseUrl; + + if (key && !secret) { + throw new Error('API Key & Secret are both required for private enpoints') + } + + if (this.options.disable_time_sync !== true) { + this.syncTime(); + setInterval(this.syncTime.bind(this), +this.options.sync_interval_ms!); + } + + this.key = key; + this.secret = secret; + } + + get(endpoint: string, params?: any): GenericAPIResponse { + return this._call('GET', endpoint, params, true); + } + + post(endpoint: string, params?: any): GenericAPIResponse { + return this._call('POST', endpoint, params, true); + } + + getPrivate(endpoint: string, params?: any): GenericAPIResponse { + return this._call('GET', endpoint, params, false); + } + + postPrivate(endpoint: string, params?: any): GenericAPIResponse { + return this._call('POST', endpoint, params, false); + } + + deletePrivate(endpoint: string, params?: any): GenericAPIResponse { + return this._call('DELETE', endpoint, params, false); + } + + /** + * @private Make a HTTP request to a specific endpoint. Private endpoints are automatically signed. + */ + private async _call(method: Method, endpoint: string, params?: any, isPublicApi?: boolean): GenericAPIResponse { + if (!isPublicApi) { + if (!this.key || !this.secret) { + throw new Error('Private endpoints require api and private keys set'); + } + + if (this.timeOffset === null) { + await this.syncTime(); + } + + params = await this.signRequest(params); + } + + const options = { + ...this.globalRequestOptions, + url: [this.baseUrl, endpoint].join(endpoint.startsWith('/') ? '' : '/'), + method: method, + json: true + }; + + if (method === 'GET') { + options.params = params; + } else { + options.data = params; + } + + return axios(options).then(response => { + if (response.status == 200) { + return response.data; + } + + throw response; + }).catch(e => this.parseException(e)); + } + + /** + * @private generic handler to parse request exceptions + */ + parseException(e: any): unknown { + if (this.options.parse_exceptions === false) { + throw e; + } + + // Something happened in setting up the request that triggered an Error + if (!e.response) { + if (!e.request) { + throw e.message; + } + + // request made but no response received + throw e; + } + + // The request was made and the server responded with a status code + // that falls out of the range of 2xx + const response: AxiosResponse = e.response; + throw { + code: response.status, + message: response.statusText, + body: response.data, + headers: response.headers, + requestOptions: this.options + }; + } + + /** + * @private sign request and set recv window + */ + async signRequest(data: any): Promise { + const params = { + ...data, + api_key: this.key, + timestamp: Date.now() + (this.timeOffset || 0) + }; + + // Optional, set to 5000 by default. Increase if timestamp/recv_window errors are seen. + if (this.options.recv_window && !params.recv_window) { + params.recv_window = this.options.recv_window; + } + + if (this.key && this.secret) { + const serializedParams = serializeParams(params, this.options.strict_param_validation); + params.sign = await signMessage(serializedParams, this.secret); + } + + return params; + } + + /** + * Trigger time sync and store promise + */ + private syncTime(): GenericAPIResponse { + if (this.options.disable_time_sync === true) { + return Promise.resolve(false); + } + + if (this.syncTimePromise !== null) { + return this.syncTimePromise; + } + + this.syncTimePromise = this.fetchTimeOffset().then(offset => { + this.timeOffset = offset; + this.syncTimePromise = null; + }); + + return this.syncTimePromise; + } + + abstract getServerTime(baseUrlKeyOverride?: string): Promise; + + /** + * Estimate drift based on client<->server latency + */ + async fetchTimeOffset(): Promise { + try { + const start = Date.now(); + const serverTime = await this.getServerTime(); + const end = Date.now(); + + const avgDrift = ((end - start) / 2); + return Math.ceil(serverTime - end + avgDrift); + } catch (e) { + console.error('Failed to fetch get time offset: ', e); + return 0; + } + } +}; diff --git a/src/websocket-client.ts b/src/websocket-client.ts index b7ccb1c..e3829be 100644 --- a/src/websocket-client.ts +++ b/src/websocket-client.ts @@ -734,6 +734,4 @@ export class WebsocketClient extends EventEmitter { return this.tryWsSend(wsKeySpotPublic, JSON.stringify(msg)); } - - };