feat(#99): introduce spot REST client

This commit is contained in:
tiagosiebler
2021-08-15 12:12:10 +01:00
parent 10ac2ec384
commit 72c7630a29
7 changed files with 449 additions and 7 deletions

View File

@@ -26,6 +26,7 @@ This project uses typescript. Resources are stored in 3 key structures:
- [src](./src) - the whole connector written in typescript - [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. - [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. - [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 ## 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): 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 // 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. // defaults to inverse:
// linear: true // market: 'inverse'
// market: 'linear'
// defaults to false == inverse. Set to true for spot trading. These booleans will be changed into a single setting in future. // market: 'spot'
// spot: true
// how long to wait (in ms) before deciding the connection should be terminated & reconnected // how long to wait (in ms) before deciding the connection should be terminated & reconnected
// pongTimeout: 1000, // pongTimeout: 1000,

View File

@@ -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);
}
})();

View File

@@ -1,5 +1,6 @@
export * from './inverse-client'; export * from './inverse-client';
export * from './inverse-futures-client'; export * from './inverse-futures-client';
export * from './linear-client'; export * from './linear-client';
export * from './spot-client';
export * from './websocket-client'; export * from './websocket-client';
export * from './logger'; export * from './logger';

157
src/spot-client.ts Normal file
View File

@@ -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<number> {
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');
}
}

18
src/types/spot.ts Normal file
View File

@@ -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;
}

208
src/util/BaseRestClient.ts Normal file
View File

@@ -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<any>;
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<any> {
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<number>;
/**
* Estimate drift based on client<->server latency
*/
async fetchTimeOffset(): Promise<number> {
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;
}
}
};

View File

@@ -734,6 +734,4 @@ export class WebsocketClient extends EventEmitter {
return this.tryWsSend(wsKeySpotPublic, JSON.stringify(msg)); return this.tryWsSend(wsKeySpotPublic, JSON.stringify(msg));
} }
}; };