initial commit, add bitget rest api and websockets connector
This commit is contained in:
155
src/broker-client.ts
Normal file
155
src/broker-client.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
import {
|
||||
APIResponse,
|
||||
BrokerProductType,
|
||||
BrokerSubWithdrawalRequest,
|
||||
BrokerSubAPIKeyModifyRequest,
|
||||
BrokerSubListRequest,
|
||||
} from './types';
|
||||
import { REST_CLIENT_TYPE_ENUM } from './util';
|
||||
import BaseRestClient from './util/BaseRestClient';
|
||||
|
||||
/**
|
||||
* REST API client for broker APIs
|
||||
*/
|
||||
export class BrokerClient extends BaseRestClient {
|
||||
getClientType() {
|
||||
return REST_CLIENT_TYPE_ENUM.broker;
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* Sub Account Interface
|
||||
*
|
||||
*/
|
||||
|
||||
/** Get Broker Info */
|
||||
getBrokerInfo(): Promise<APIResponse<any>> {
|
||||
return this.getPrivate('/api/broker/v1/account/info');
|
||||
}
|
||||
|
||||
/** Create Sub Account */
|
||||
createSubAccount(
|
||||
subName: string,
|
||||
remark?: string
|
||||
): Promise<APIResponse<any>> {
|
||||
return this.postPrivate('/api/broker/v1/account/sub-create', {
|
||||
subName,
|
||||
remark,
|
||||
});
|
||||
}
|
||||
|
||||
/** Get Sub List */
|
||||
getSubAccounts(params?: BrokerSubListRequest): Promise<APIResponse<any>> {
|
||||
return this.getPrivate('/api/broker/v1/account/sub-list', params);
|
||||
}
|
||||
|
||||
/** Modify Sub Account */
|
||||
modifySubAccount(
|
||||
subUid: string,
|
||||
perm: string,
|
||||
status: 'normal' | 'freeze' | 'del'
|
||||
): Promise<APIResponse<any>> {
|
||||
return this.postPrivate('/api/broker/v1/account/sub-modify', {
|
||||
subUid,
|
||||
perm,
|
||||
status,
|
||||
});
|
||||
}
|
||||
|
||||
/** Modify Sub Email */
|
||||
modifySubEmail(subUid: string, subEmail: string): Promise<APIResponse<any>> {
|
||||
return this.postPrivate('/api/broker/v1/account/sub-modify-email', {
|
||||
subUid,
|
||||
subEmail,
|
||||
});
|
||||
}
|
||||
|
||||
/** Get Sub Email */
|
||||
getSubEmail(subUid: string): Promise<APIResponse<any>> {
|
||||
return this.getPrivate('/api/broker/v1/account/sub-email', { subUid });
|
||||
}
|
||||
|
||||
/** Get Sub Spot Assets */
|
||||
getSubSpotAssets(subUid: string): Promise<APIResponse<any>> {
|
||||
return this.getPrivate('/api/broker/v1/account/sub-spot-assets', {
|
||||
subUid,
|
||||
});
|
||||
}
|
||||
|
||||
/** Get Sub Future Assets */
|
||||
getSubFutureAssets(
|
||||
subUid: string,
|
||||
productType: BrokerProductType
|
||||
): Promise<APIResponse<any>> {
|
||||
return this.getPrivate('/api/broker/v1/account/sub-future-assets', {
|
||||
subUid,
|
||||
productType,
|
||||
});
|
||||
}
|
||||
|
||||
/** Get Sub Deposit Address (Only Broker) */
|
||||
getSubDepositAddress(
|
||||
subUid: string,
|
||||
coin: string,
|
||||
chain?: string
|
||||
): Promise<APIResponse<any>> {
|
||||
return this.postPrivate('/api/broker/v1/account/sub-address', {
|
||||
subUid,
|
||||
coin,
|
||||
chain,
|
||||
});
|
||||
}
|
||||
|
||||
/** Sub Withdrawal (Only Broker) */
|
||||
subWithdrawal(params: BrokerSubWithdrawalRequest): Promise<APIResponse<any>> {
|
||||
return this.postPrivate('/api/broker/v1/account/sub-withdrawal', params);
|
||||
}
|
||||
|
||||
/** Sub Deposit Auto Transfer (Only Broker) */
|
||||
setSubDepositAutoTransfer(
|
||||
subUid: string,
|
||||
coin: string,
|
||||
toAccountType: 'spot' | 'mix_usdt' | 'mix_usd' | 'mix_usdc'
|
||||
): Promise<APIResponse<any>> {
|
||||
return this.postPrivate('/api/broker/v1/account/sub-auto-transfer', {
|
||||
subUid,
|
||||
coin,
|
||||
toAccountType,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* Sub API Interface
|
||||
*
|
||||
*/
|
||||
|
||||
/** Create Sub ApiKey (Only Broker) */
|
||||
createSubAPIKey(
|
||||
subUid: string,
|
||||
passphrase: string,
|
||||
remark: string,
|
||||
ip: string,
|
||||
perm?: string
|
||||
): Promise<APIResponse<any>> {
|
||||
return this.postPrivate('/api/broker/v1/manage/sub-api-create', {
|
||||
subUid,
|
||||
passphrase,
|
||||
remark,
|
||||
ip,
|
||||
perm,
|
||||
});
|
||||
}
|
||||
|
||||
/** Get Sub ApiKey List */
|
||||
getSubAPIKeys(subUid: string): Promise<APIResponse<any>> {
|
||||
return this.getPrivate('/api/broker/v1/manage/sub-api-list', { subUid });
|
||||
}
|
||||
|
||||
/** Modify Sub ApiKey (Only Broker) */
|
||||
modifySubAPIKey(
|
||||
params: BrokerSubAPIKeyModifyRequest
|
||||
): Promise<APIResponse<any>> {
|
||||
return this.postPrivate('/api/broker/v1/manage/sub-api-modify', params);
|
||||
}
|
||||
}
|
||||
17
src/constants/enum.ts
Normal file
17
src/constants/enum.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
export const API_ERROR_CODE = {
|
||||
SUCCESS: '00000',
|
||||
INCORRECT_PERMISSIONS: '40014',
|
||||
ACCOUNT_NOT_COPY_TRADER: '40017',
|
||||
ACCOUNT_NOT_BROKER: '40029',
|
||||
FUTURES_ORDER_GET_NOT_FOUND: '40109',
|
||||
FUTURES_ORDER_CANCEL_NOT_FOUND: '40768',
|
||||
PLAN_ORDER_NOT_FOUND: '43025',
|
||||
QTY_LESS_THAN_MINIMUM: '43006',
|
||||
ORDER_NOT_FOUND: '43001',
|
||||
/** Parameter verification exception margin mode == FIXED */
|
||||
PARAMETER_EXCEPTION: '40808',
|
||||
INSUFFICIENT_BALANCE: '40754',
|
||||
SERVICE_RETURNED_ERROR: '40725',
|
||||
FUTURES_POSITION_DIRECTION_EMPTY: '40017',
|
||||
FUTURES_ORDER_TPSL_NOT_FOUND: '43020',
|
||||
} as const;
|
||||
593
src/futures-client.ts
Normal file
593
src/futures-client.ts
Normal file
@@ -0,0 +1,593 @@
|
||||
import {
|
||||
APIResponse,
|
||||
KlineInterval,
|
||||
FuturesProductType,
|
||||
FuturesAccountBillRequest,
|
||||
FuturesBusinessBillRequest,
|
||||
NewFuturesOrder,
|
||||
NewBatchFuturesOrder,
|
||||
FuturesPagination,
|
||||
NewFuturesPlanOrder,
|
||||
ModifyFuturesPlanOrder,
|
||||
ModifyFuturesPlanOrderTPSL,
|
||||
NewFuturesPlanPositionTPSL,
|
||||
ModifyFuturesPlanStopOrder,
|
||||
CancelFuturesPlanTPSL,
|
||||
HistoricPlanOrderTPSLRequest,
|
||||
NewFuturesPlanStopOrder,
|
||||
} from './types';
|
||||
import { REST_CLIENT_TYPE_ENUM } from './util';
|
||||
import BaseRestClient from './util/BaseRestClient';
|
||||
|
||||
/**
|
||||
* REST API client
|
||||
*/
|
||||
export class FuturesClient extends BaseRestClient {
|
||||
getClientType() {
|
||||
return REST_CLIENT_TYPE_ENUM.futures;
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* Market
|
||||
*
|
||||
*/
|
||||
|
||||
/** Get Symbols : Get basic configuration information of all trading pairs (including rules) */
|
||||
getSymbols(productType: FuturesProductType): Promise<APIResponse<any[]>> {
|
||||
return this.get('/api/mix/v1/market/contracts', { productType });
|
||||
}
|
||||
|
||||
/** Get Depth */
|
||||
getDepth(symbol: string, limit?: string): Promise<APIResponse<any>> {
|
||||
return this.get('/api/mix/v1/market/depth', { symbol, limit });
|
||||
}
|
||||
|
||||
/** Get Single Symbol Ticker */
|
||||
getTicker(symbol: string): Promise<APIResponse<any>> {
|
||||
return this.get('/api/mix/v1/market/ticker', { symbol });
|
||||
}
|
||||
|
||||
/** Get All Tickers */
|
||||
getAllTickers(productType: FuturesProductType): Promise<APIResponse<any>> {
|
||||
return this.get('/api/mix/v1/market/tickers', { productType });
|
||||
}
|
||||
|
||||
/** Get Market Trades */
|
||||
getMarketTrades(symbol: string, limit?: string): Promise<APIResponse<any>> {
|
||||
return this.get('/api/mix/v1/market/fills', { symbol, limit });
|
||||
}
|
||||
|
||||
/** Get Candle Data */
|
||||
getCandles(
|
||||
symbol: string,
|
||||
granularity: KlineInterval,
|
||||
startTime: string,
|
||||
endTime: string
|
||||
): Promise<any> {
|
||||
return this.get('/api/mix/v1/market/candles', {
|
||||
symbol,
|
||||
granularity,
|
||||
startTime,
|
||||
endTime,
|
||||
});
|
||||
}
|
||||
|
||||
/** Get symbol index price */
|
||||
getIndexPrice(symbol: string): Promise<APIResponse<any>> {
|
||||
return this.get('/api/mix/v1/market/index', { symbol });
|
||||
}
|
||||
|
||||
/** Get symbol next funding time */
|
||||
getNextFundingTime(symbol: string): Promise<APIResponse<any>> {
|
||||
return this.get('/api/mix/v1/market/funding-time', { symbol });
|
||||
}
|
||||
|
||||
/** Get Withdraw List */
|
||||
getHistoricFundingRate(
|
||||
symbol: string,
|
||||
pageSize?: string,
|
||||
pageNo?: string,
|
||||
nextPage?: boolean
|
||||
): Promise<APIResponse<any>> {
|
||||
return this.get('/api/mix/v1/market/history-fundRate', {
|
||||
symbol,
|
||||
nextPage,
|
||||
pageSize,
|
||||
pageNo,
|
||||
});
|
||||
}
|
||||
|
||||
/** Get symbol current funding time */
|
||||
getCurrentFundingRate(symbol: string): Promise<APIResponse<any>> {
|
||||
return this.get('/api/mix/v1/market/current-fundRate', { symbol });
|
||||
}
|
||||
|
||||
/** Get symbol open interest */
|
||||
getOpenInterest(symbol: string): Promise<APIResponse<any>> {
|
||||
return this.get('/api/mix/v1/market/open-interest', { symbol });
|
||||
}
|
||||
|
||||
/** Get symbol mark price */
|
||||
getMarkPrice(symbol: string): Promise<APIResponse<any>> {
|
||||
return this.get('/api/mix/v1/market/mark-price', { symbol });
|
||||
}
|
||||
|
||||
/** Get symbol min/max leverage rules */
|
||||
getLeverageMinMax(symbol: string): Promise<APIResponse<any>> {
|
||||
return this.get('/api/mix/v1/market/symbol-leverage', { symbol });
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* Account Endpoints
|
||||
*
|
||||
*/
|
||||
|
||||
/** Get Single Account */
|
||||
getAccount(symbol: string, marginCoin: string): Promise<APIResponse<any>> {
|
||||
return this.getPrivate('/api/mix/v1/account/account', {
|
||||
symbol,
|
||||
marginCoin,
|
||||
});
|
||||
}
|
||||
|
||||
/** Get Account List */
|
||||
getAccounts(productType: FuturesProductType): Promise<APIResponse<any>> {
|
||||
return this.getPrivate('/api/mix/v1/account/accounts', { productType });
|
||||
}
|
||||
|
||||
/**
|
||||
* This interface is only used to calculate the maximum number of positions that can be opened when the user does not hold a position by default.
|
||||
* The result does not represent the actual number of positions opened.
|
||||
*/
|
||||
getOpenCount(
|
||||
symbol: string,
|
||||
marginCoin: string,
|
||||
openPrice: number,
|
||||
openAmount: number,
|
||||
leverage?: number
|
||||
): Promise<APIResponse<any>> {
|
||||
return this.postPrivate('/api/mix/v1/account/open-count', {
|
||||
symbol,
|
||||
marginCoin,
|
||||
openPrice,
|
||||
openAmount,
|
||||
leverage,
|
||||
});
|
||||
}
|
||||
|
||||
/** Change Leverage */
|
||||
setLeverage(
|
||||
symbol: string,
|
||||
marginCoin: string,
|
||||
leverage: string,
|
||||
holdSide?: string
|
||||
): Promise<APIResponse<any>> {
|
||||
return this.postPrivate('/api/mix/v1/account/setLeverage', {
|
||||
symbol,
|
||||
marginCoin,
|
||||
leverage,
|
||||
holdSide,
|
||||
});
|
||||
}
|
||||
|
||||
/** Change Margin */
|
||||
setMargin(
|
||||
symbol: string,
|
||||
marginCoin: string,
|
||||
amount: string,
|
||||
holdSide?: string
|
||||
): Promise<APIResponse<any>> {
|
||||
return this.postPrivate('/api/mix/v1/account/setMargin', {
|
||||
symbol,
|
||||
marginCoin,
|
||||
amount,
|
||||
holdSide,
|
||||
});
|
||||
}
|
||||
|
||||
/** Change Margin Mode */
|
||||
setMarginMode(
|
||||
symbol: string,
|
||||
marginCoin: string,
|
||||
marginMode: 'fixed' | 'crossed'
|
||||
): Promise<APIResponse<any>> {
|
||||
return this.postPrivate('/api/mix/v1/account/setMarginMode', {
|
||||
symbol,
|
||||
marginCoin,
|
||||
marginMode,
|
||||
});
|
||||
}
|
||||
|
||||
/** Get Symbol Position */
|
||||
getPosition(symbol: string, marginCoin?: string): Promise<APIResponse<any>> {
|
||||
return this.getPrivate('/api/mix/v1/position/singlePosition', {
|
||||
symbol,
|
||||
marginCoin,
|
||||
});
|
||||
}
|
||||
|
||||
/** Get All Position */
|
||||
getPositions(
|
||||
productType: FuturesProductType,
|
||||
marginCoin?: string
|
||||
): Promise<APIResponse<any>> {
|
||||
return this.getPrivate('/api/mix/v1/position/allPosition', {
|
||||
productType,
|
||||
marginCoin,
|
||||
});
|
||||
}
|
||||
|
||||
/** Get Account Bill */
|
||||
getAccountBill(params: FuturesAccountBillRequest): Promise<APIResponse<any>> {
|
||||
return this.getPrivate('/api/mix/v1/account/accountBill', params);
|
||||
}
|
||||
|
||||
/** Get Business Account Bill */
|
||||
getBusinessBill(
|
||||
params: FuturesBusinessBillRequest
|
||||
): Promise<APIResponse<any>> {
|
||||
return this.getPrivate('/api/mix/v1/account/accountBusinessBill', params);
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* Trade Endpoints
|
||||
*
|
||||
*/
|
||||
|
||||
/** Place Order */
|
||||
submitOrder(params: NewFuturesOrder): Promise<APIResponse<any>> {
|
||||
return this.postPrivate('/api/mix/v1/order/placeOrder', params);
|
||||
}
|
||||
|
||||
/** Batch Order */
|
||||
batchSubmitOrder(
|
||||
symbol: string,
|
||||
marginCoin: string,
|
||||
orders: NewBatchFuturesOrder[]
|
||||
): Promise<APIResponse<any>> {
|
||||
return this.postPrivate('/api/mix/v1/order/batch-orders', {
|
||||
symbol,
|
||||
marginCoin,
|
||||
orderDataList: orders,
|
||||
});
|
||||
}
|
||||
|
||||
/** Cancel Order */
|
||||
cancelOrder(
|
||||
symbol: string,
|
||||
marginCoin: string,
|
||||
orderId: string
|
||||
): Promise<APIResponse<any>> {
|
||||
return this.postPrivate('/api/mix/v1/order/cancel-order', {
|
||||
symbol,
|
||||
marginCoin,
|
||||
orderId,
|
||||
});
|
||||
}
|
||||
|
||||
/** Batch Cancel Order */
|
||||
batchCancelOrder(
|
||||
symbol: string,
|
||||
marginCoin: string,
|
||||
orderIds: string[]
|
||||
): Promise<APIResponse<any>> {
|
||||
return this.postPrivate('/api/mix/v1/order/cancel-batch-orders', {
|
||||
symbol,
|
||||
marginCoin,
|
||||
orderIds,
|
||||
});
|
||||
}
|
||||
|
||||
/** Cancel All Order */
|
||||
cancelAllOrders(
|
||||
productType: FuturesProductType,
|
||||
marginCoin: string
|
||||
): Promise<APIResponse<any>> {
|
||||
return this.postPrivate('/api/mix/v1/order/cancel-all-orders', {
|
||||
productType,
|
||||
marginCoin,
|
||||
});
|
||||
}
|
||||
|
||||
/** Get Open Order */
|
||||
getOpenSymbolOrders(symbol: string): Promise<APIResponse<any>> {
|
||||
return this.getPrivate('/api/mix/v1/order/current', { symbol });
|
||||
}
|
||||
|
||||
/** Get All Open Order */
|
||||
getOpenOrders(
|
||||
productType: FuturesProductType,
|
||||
marginCoin: string
|
||||
): Promise<APIResponse<any>> {
|
||||
return this.getPrivate('/api/mix/v1/order/marginCoinCurrent', {
|
||||
productType,
|
||||
marginCoin,
|
||||
});
|
||||
}
|
||||
|
||||
/** Get History Orders */
|
||||
getOrderHistory(
|
||||
symbol: string,
|
||||
startTime: string,
|
||||
endTime: string,
|
||||
pageSize: string,
|
||||
lastEndId?: string,
|
||||
isPre?: boolean
|
||||
): Promise<APIResponse<any>> {
|
||||
return this.getPrivate('/api/mix/v1/order/history', {
|
||||
symbol,
|
||||
startTime,
|
||||
endTime,
|
||||
pageSize,
|
||||
lastEndId,
|
||||
isPre,
|
||||
});
|
||||
}
|
||||
|
||||
/** Get ProductType History Orders */
|
||||
getProductTypeOrderHistory(
|
||||
productType: FuturesProductType,
|
||||
startTime: string,
|
||||
endTime: string,
|
||||
pageSize: string,
|
||||
lastEndId?: string,
|
||||
isPre?: boolean
|
||||
): Promise<APIResponse<any>> {
|
||||
return this.getPrivate('/api/mix/v1/order/historyProductType', {
|
||||
productType,
|
||||
startTime,
|
||||
endTime,
|
||||
pageSize,
|
||||
lastEndId,
|
||||
isPre,
|
||||
});
|
||||
}
|
||||
|
||||
/** Get order details */
|
||||
getOrder(
|
||||
symbol: string,
|
||||
orderId?: string,
|
||||
clientOid?: string
|
||||
): Promise<APIResponse<any>> {
|
||||
return this.getPrivate('/api/mix/v1/order/detail', {
|
||||
symbol,
|
||||
orderId,
|
||||
clientOid,
|
||||
});
|
||||
}
|
||||
|
||||
/** Get transaction details / history (fills) */
|
||||
getOrderFills(
|
||||
symbol: string,
|
||||
orderId?: string,
|
||||
pagination?: FuturesPagination
|
||||
): Promise<APIResponse<any>> {
|
||||
return this.getPrivate('/api/mix/v1/order/fills', {
|
||||
symbol,
|
||||
orderId,
|
||||
...pagination,
|
||||
});
|
||||
}
|
||||
|
||||
/** Get ProductType Order fill detail */
|
||||
getProductTypeOrderFills(
|
||||
productType: FuturesProductType,
|
||||
pagination?: FuturesPagination
|
||||
): Promise<APIResponse<any>> {
|
||||
return this.getPrivate('/api/mix/v1/order/allFills', {
|
||||
productType: productType.toUpperCase(),
|
||||
...pagination,
|
||||
});
|
||||
}
|
||||
|
||||
/** Place Plan order */
|
||||
submitPlanOrder(params: NewFuturesPlanOrder): Promise<APIResponse<any>> {
|
||||
return this.postPrivate('/api/mix/v1/plan/placePlan', params);
|
||||
}
|
||||
|
||||
/** Modify Plan Order */
|
||||
modifyPlanOrder(params: ModifyFuturesPlanOrder): Promise<APIResponse<any>> {
|
||||
return this.postPrivate('/api/mix/v1/plan/modifyPlan', params);
|
||||
}
|
||||
|
||||
/** Modify Plan Order TPSL */
|
||||
modifyPlanOrderTPSL(
|
||||
params: ModifyFuturesPlanOrderTPSL
|
||||
): Promise<APIResponse<any>> {
|
||||
return this.postPrivate('/api/mix/v1/plan/modifyPlanPreset', params);
|
||||
}
|
||||
|
||||
/** Place Stop order */
|
||||
submitStopOrder(params: NewFuturesPlanStopOrder): Promise<APIResponse<any>> {
|
||||
return this.postPrivate('/api/mix/v1/plan/placeTPSL', params);
|
||||
}
|
||||
|
||||
/** Place Position TPSL */
|
||||
submitPositionTPSL(
|
||||
params: NewFuturesPlanPositionTPSL
|
||||
): Promise<APIResponse<any>> {
|
||||
return this.postPrivate('/api/mix/v1/plan/placePositionsTPSL', params);
|
||||
}
|
||||
|
||||
/** Modify Stop Order */
|
||||
modifyStopOrder(
|
||||
params: ModifyFuturesPlanStopOrder
|
||||
): Promise<APIResponse<any>> {
|
||||
return this.postPrivate('/api/mix/v1/plan/modifyTPSLPlan', params);
|
||||
}
|
||||
|
||||
/** Cancel Plan Order TPSL */
|
||||
cancelPlanOrderTPSL(
|
||||
params: CancelFuturesPlanTPSL
|
||||
): Promise<APIResponse<any>> {
|
||||
return this.postPrivate('/api/mix/v1/plan/cancelPlan', params);
|
||||
}
|
||||
|
||||
/** Get Plan Order (TPSL) List */
|
||||
getPlanOrderTPSLs(
|
||||
symbol: string,
|
||||
isPlan?: string,
|
||||
productType?: FuturesProductType
|
||||
): Promise<APIResponse<any>> {
|
||||
return this.getPrivate('/api/mix/v1/plan/currentPlan', {
|
||||
symbol,
|
||||
isPlan,
|
||||
productType,
|
||||
});
|
||||
}
|
||||
|
||||
/** Get History Plan Orders (TPSL) */
|
||||
getHistoricPlanOrdersTPSL(
|
||||
params: HistoricPlanOrderTPSLRequest
|
||||
): Promise<APIResponse<any>> {
|
||||
return this.getPrivate('/api/mix/v1/plan/historyPlan', params);
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* Trade Endpoints
|
||||
*
|
||||
*/
|
||||
|
||||
/** Get Trader Open order */
|
||||
getCopyTraderOpenOrder(
|
||||
symbol: string,
|
||||
productType: FuturesProductType,
|
||||
pageSize: number,
|
||||
pageNo: number
|
||||
): Promise<APIResponse<any>> {
|
||||
return this.getPrivate('/api/mix/v1/trace/currentTrack', {
|
||||
symbol,
|
||||
productType,
|
||||
pageSize,
|
||||
pageNo,
|
||||
});
|
||||
}
|
||||
|
||||
/** Get Followers Open Order */
|
||||
getCopyFollowersOpenOrder(
|
||||
symbol: string,
|
||||
productType: FuturesProductType,
|
||||
pageSize: number,
|
||||
pageNo: number
|
||||
): Promise<APIResponse<any>> {
|
||||
return this.getPrivate('/api/mix/v1/trace/followerOrder', {
|
||||
symbol,
|
||||
productType,
|
||||
pageSize,
|
||||
pageNo,
|
||||
});
|
||||
}
|
||||
|
||||
/** Trader Close Position */
|
||||
closeCopyTraderPosition(
|
||||
symbol: string,
|
||||
trackingNo: string
|
||||
): Promise<APIResponse<any>> {
|
||||
return this.postPrivate('/api/mix/v1/trace/closeTrackOrder', {
|
||||
symbol,
|
||||
trackingNo,
|
||||
});
|
||||
}
|
||||
|
||||
/** Trader Modify TPSL */
|
||||
modifyCopyTraderTPSL(
|
||||
symbol: string,
|
||||
trackingNo: string,
|
||||
changes?: {
|
||||
stopProfitPrice?: number;
|
||||
stopLossPrice?: number;
|
||||
}
|
||||
): Promise<APIResponse<any>> {
|
||||
return this.postPrivate('/api/mix/v1/trace/modifyTPSL', {
|
||||
symbol,
|
||||
trackingNo,
|
||||
...changes,
|
||||
});
|
||||
}
|
||||
|
||||
/** Get Traders History Orders */
|
||||
getCopyTraderOrderHistory(
|
||||
startTime: string,
|
||||
endTime: string,
|
||||
pageSize: number,
|
||||
pageNo: number
|
||||
): Promise<APIResponse<any>> {
|
||||
return this.getPrivate('/api/mix/v1/trace/historyTrack', {
|
||||
startTime,
|
||||
endTime,
|
||||
pageSize,
|
||||
pageNo,
|
||||
});
|
||||
}
|
||||
|
||||
/** Get Trader Profit Summary */
|
||||
getCopyTraderProfitSummary(): Promise<APIResponse<any>> {
|
||||
return this.getPrivate('/api/mix/v1/trace/summary');
|
||||
}
|
||||
|
||||
/** Get Trader History Profit Summary (according to settlement currency) */
|
||||
getCopyTraderHistoricProfitSummary(): Promise<APIResponse<any>> {
|
||||
return this.getPrivate('/api/mix/v1/trace/profitSettleTokenIdGroup');
|
||||
}
|
||||
|
||||
/** Get Trader History Profit Summary (according to settlement currency and date) */
|
||||
getCopyTraderHistoricProfitSummaryByDate(
|
||||
marginCoin: string,
|
||||
dateMs: string,
|
||||
pageSize: number,
|
||||
pageNo: number
|
||||
): Promise<APIResponse<any>> {
|
||||
return this.getPrivate('/api/mix/v1/trace/profitDateGroupList', {
|
||||
marginCoin,
|
||||
date: dateMs,
|
||||
pageSize,
|
||||
pageNo,
|
||||
});
|
||||
}
|
||||
|
||||
/** Get Trader Histroy Profit Detail */
|
||||
getCopyTraderHistoricProfitDetail(
|
||||
marginCoin: string,
|
||||
dateMs: string,
|
||||
pageSize: number,
|
||||
pageNo: number
|
||||
): Promise<APIResponse<any>> {
|
||||
return this.getPrivate('/api/mix/v1/trace/profitDateList', {
|
||||
marginCoin,
|
||||
date: dateMs,
|
||||
pageSize,
|
||||
pageNo,
|
||||
});
|
||||
}
|
||||
|
||||
/** Get Trader Profits Details */
|
||||
getCopyTraderProfitDetails(
|
||||
pageSize: number,
|
||||
pageNo: number
|
||||
): Promise<APIResponse<any>> {
|
||||
return this.getPrivate('/api/mix/v1/trace/waitProfitDateList', {
|
||||
pageSize,
|
||||
pageNo,
|
||||
});
|
||||
}
|
||||
|
||||
/** Get CopyTrade Symbols */
|
||||
getCopyTraderSymbols(): Promise<APIResponse<any>> {
|
||||
return this.getPrivate('/api/mix/v1/trace/traderSymbols');
|
||||
}
|
||||
|
||||
/** Trader Change CopyTrade symbol */
|
||||
setCopyTraderSymbols(
|
||||
symbol: string,
|
||||
operation: 'add' | 'delete'
|
||||
): Promise<APIResponse<any>> {
|
||||
return this.postPrivate('/api/mix/v1/trace/setUpCopySymbols', {
|
||||
symbol,
|
||||
operation,
|
||||
});
|
||||
}
|
||||
}
|
||||
8
src/index.ts
Normal file
8
src/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export * from './broker-client';
|
||||
export * from './futures-client';
|
||||
export * from './spot-client';
|
||||
export * from './websocket-client';
|
||||
export * from './util/logger';
|
||||
export * from './util';
|
||||
export * from './types';
|
||||
export * from './constants/enum';
|
||||
296
src/spot-client.ts
Normal file
296
src/spot-client.ts
Normal file
@@ -0,0 +1,296 @@
|
||||
import {
|
||||
NewBatchSpotOrder,
|
||||
NewSpotOrder,
|
||||
NewWalletTransfer,
|
||||
Pagination,
|
||||
APIResponse,
|
||||
KlineInterval,
|
||||
} from './types';
|
||||
import { REST_CLIENT_TYPE_ENUM } from './util';
|
||||
import BaseRestClient from './util/BaseRestClient';
|
||||
|
||||
/**
|
||||
* REST API client
|
||||
*/
|
||||
export class SpotClient extends BaseRestClient {
|
||||
getClientType() {
|
||||
return REST_CLIENT_TYPE_ENUM.spot;
|
||||
}
|
||||
|
||||
async fetchServerTime(): Promise<number> {
|
||||
const res = await this.getServerTime();
|
||||
return Number(res.data);
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* Public
|
||||
*
|
||||
*/
|
||||
|
||||
/** Get Server Time */
|
||||
getServerTime(): Promise<APIResponse<string>> {
|
||||
return this.get('/api/spot/v1/public/time');
|
||||
}
|
||||
|
||||
/** Get Coin List : Get all coins information on the platform */
|
||||
getCoins(): Promise<APIResponse<any[]>> {
|
||||
return this.get('/api/spot/v1/public/currencies');
|
||||
}
|
||||
|
||||
/** Get Symbols : Get basic configuration information of all trading pairs (including rules) */
|
||||
getSymbols(): Promise<APIResponse<any[]>> {
|
||||
return this.get('/api/spot/v1/public/products');
|
||||
}
|
||||
|
||||
/** Get Single Symbol : Get basic configuration information for one symbol */
|
||||
getSymbol(symbol: string): Promise<APIResponse<any>> {
|
||||
return this.get('/api/spot/v1/public/product', { symbol });
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* Market
|
||||
*
|
||||
*/
|
||||
|
||||
/** Get Single Ticker */
|
||||
getTicker(symbol: string): Promise<APIResponse<any>> {
|
||||
return this.get('/api/spot/v1/market/ticker', { symbol });
|
||||
}
|
||||
|
||||
/** Get All Tickers */
|
||||
getAllTickers(): Promise<APIResponse<any>> {
|
||||
return this.get('/api/spot/v1/market/tickers');
|
||||
}
|
||||
|
||||
/** Get Market Trades */
|
||||
getMarketTrades(symbol: string, limit?: string): Promise<APIResponse<any>> {
|
||||
return this.get('/api/spot/v1/market/fills', { symbol, limit });
|
||||
}
|
||||
|
||||
/** Get Candle Data */
|
||||
getCandles(
|
||||
symbol: string,
|
||||
period: KlineInterval,
|
||||
pagination?: Pagination
|
||||
): Promise<APIResponse<any>> {
|
||||
return this.get('/api/spot/v1/market/candles', {
|
||||
symbol,
|
||||
period,
|
||||
...pagination,
|
||||
});
|
||||
}
|
||||
|
||||
/** Get Depth */
|
||||
getDepth(
|
||||
symbol: string,
|
||||
type: 'step0' | 'step1' | 'step2' | 'step3' | 'step4' | 'step5',
|
||||
limit?: string
|
||||
): Promise<APIResponse<any>> {
|
||||
return this.get('/api/spot/v1/market/depth', { symbol, type, limit });
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* Wallet Endpoints
|
||||
*
|
||||
*/
|
||||
|
||||
/** Initiate wallet transfer */
|
||||
transfer(params: NewWalletTransfer): Promise<APIResponse<any>> {
|
||||
return this.postPrivate('/api/spot/v1/wallet/transfer', params);
|
||||
}
|
||||
|
||||
/** Get Coin Address */
|
||||
getDepositAddress(coin: string, chain?: string): Promise<APIResponse<any>> {
|
||||
return this.getPrivate('/api/spot/v1/wallet/deposit-address', {
|
||||
coin,
|
||||
chain,
|
||||
});
|
||||
}
|
||||
|
||||
/** Withdraw Coins On Chain*/
|
||||
withdraw(params: {
|
||||
coin: string;
|
||||
address: string;
|
||||
chain: string;
|
||||
tag?: string;
|
||||
amount: string;
|
||||
remark?: string;
|
||||
clientOid?: string;
|
||||
}): Promise<APIResponse<any>> {
|
||||
return this.postPrivate('/api/spot/v1/wallet/withdrawal', params);
|
||||
}
|
||||
|
||||
/** Inner Withdraw : Internal withdrawal means that both users are on the Bitget platform */
|
||||
innerWithdraw(
|
||||
coin: string,
|
||||
toUid: string,
|
||||
amount: string,
|
||||
clientOid?: string
|
||||
): Promise<APIResponse<any>> {
|
||||
return this.postPrivate('/api/spot/v1/wallet/withdrawal-inner', {
|
||||
coin,
|
||||
toUid,
|
||||
amount,
|
||||
clientOid,
|
||||
});
|
||||
}
|
||||
|
||||
/** Get Withdraw List */
|
||||
getWithdrawals(
|
||||
coin: string,
|
||||
startTime: string,
|
||||
endTime: string,
|
||||
pageSize?: string,
|
||||
pageNo?: string
|
||||
): Promise<APIResponse<any>> {
|
||||
return this.getPrivate('/api/spot/v1/wallet/withdrawal-list', {
|
||||
coin,
|
||||
startTime,
|
||||
endTime,
|
||||
pageSize,
|
||||
pageNo,
|
||||
});
|
||||
}
|
||||
|
||||
/** Get Deposit List */
|
||||
getDeposits(
|
||||
coin: string,
|
||||
startTime: string,
|
||||
endTime: string,
|
||||
pageSize?: string,
|
||||
pageNo?: string
|
||||
): Promise<APIResponse<any>> {
|
||||
return this.getPrivate('/api/spot/v1/wallet/deposit-list', {
|
||||
coin,
|
||||
startTime,
|
||||
endTime,
|
||||
pageSize,
|
||||
pageNo,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* Account Endpoints
|
||||
*
|
||||
*/
|
||||
|
||||
/** Get ApiKey Info */
|
||||
getApiKeyInfo(): Promise<APIResponse<any>> {
|
||||
return this.getPrivate('/api/spot/v1/account/getInfo');
|
||||
}
|
||||
|
||||
/** Get Account : get account assets */
|
||||
getBalance(coin?: string): Promise<APIResponse<any>> {
|
||||
return this.getPrivate('/api/spot/v1/account/assets', { coin });
|
||||
}
|
||||
|
||||
/** Get Bills : get transaction detail flow */
|
||||
getTransactionHistory(params?: {
|
||||
coinId?: number;
|
||||
groupType?: string;
|
||||
bizType?: string;
|
||||
after?: string;
|
||||
before?: string;
|
||||
limit?: number;
|
||||
}): Promise<APIResponse<any>> {
|
||||
return this.postPrivate('/api/spot/v1/account/bills', params);
|
||||
}
|
||||
|
||||
/** Get Transfer List */
|
||||
getTransferHistory(params?: {
|
||||
coinId?: number;
|
||||
fromType?: string;
|
||||
after?: string;
|
||||
before?: string;
|
||||
limit?: number;
|
||||
}): Promise<APIResponse<any>> {
|
||||
return this.getPrivate('/api/spot/v1/account/transferRecords', params);
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* Trade Endpoints
|
||||
*
|
||||
*/
|
||||
|
||||
/** Place order */
|
||||
submitOrder(params: NewSpotOrder): Promise<APIResponse<any>> {
|
||||
return this.postPrivate('/api/spot/v1/trade/orders', params);
|
||||
}
|
||||
|
||||
/** Place orders in batches, up to 50 at a time */
|
||||
batchSubmitOrder(
|
||||
symbol: string,
|
||||
orderList: NewBatchSpotOrder[]
|
||||
): Promise<APIResponse<any>> {
|
||||
return this.postPrivate('/api/spot/v1/trade/batch-orders', {
|
||||
symbol,
|
||||
orderList,
|
||||
});
|
||||
}
|
||||
|
||||
/** Cancel order */
|
||||
cancelOrder(symbol: string, orderId: string): Promise<APIResponse<any>> {
|
||||
return this.postPrivate('/api/spot/v1/trade/cancel-order', {
|
||||
symbol,
|
||||
orderId,
|
||||
});
|
||||
}
|
||||
|
||||
/** Cancel order in batch (per symbol) */
|
||||
batchCancelOrder(
|
||||
symbol: string,
|
||||
orderIds: string[]
|
||||
): Promise<APIResponse<any>> {
|
||||
return this.postPrivate('/api/spot/v1/trade/cancel-batch-orders', {
|
||||
symbol,
|
||||
orderIds,
|
||||
});
|
||||
}
|
||||
|
||||
/** Get order details */
|
||||
getOrder(
|
||||
symbol: string,
|
||||
orderId: string,
|
||||
clientOrderId?: string
|
||||
): Promise<APIResponse<any>> {
|
||||
return this.postPrivate('/api/spot/v1/trade/orderInfo', {
|
||||
symbol,
|
||||
orderId,
|
||||
clientOrderId,
|
||||
});
|
||||
}
|
||||
|
||||
/** Get order list (open orders) */
|
||||
getOpenOrders(symbol?: string): Promise<APIResponse<any>> {
|
||||
return this.postPrivate('/api/spot/v1/trade/open-orders', { symbol });
|
||||
}
|
||||
|
||||
/** Get order history for a symbol */
|
||||
getOrderHistory(
|
||||
symbol: string,
|
||||
pagination?: Pagination
|
||||
): Promise<APIResponse<any>> {
|
||||
return this.postPrivate('/api/spot/v1/trade/history', {
|
||||
symbol,
|
||||
...pagination,
|
||||
});
|
||||
}
|
||||
|
||||
/** Get transaction details / history (fills) for an order */
|
||||
getOrderFills(
|
||||
symbol: string,
|
||||
orderId: string,
|
||||
pagination?: Pagination
|
||||
): Promise<APIResponse<any>> {
|
||||
return this.postPrivate('/api/spot/v1/trade/fills', {
|
||||
symbol,
|
||||
orderId,
|
||||
...pagination,
|
||||
});
|
||||
}
|
||||
}
|
||||
4
src/types/index.ts
Normal file
4
src/types/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export * from './response';
|
||||
export * from './request';
|
||||
export * from './shared';
|
||||
export * from './websockets';
|
||||
32
src/types/request/broker.ts
Normal file
32
src/types/request/broker.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
export type BrokerProductType =
|
||||
| 'umcbl'
|
||||
| 'usdt'
|
||||
| 'swap'
|
||||
| 'dmcbl'
|
||||
| 'mix'
|
||||
| 'swap';
|
||||
|
||||
export interface BrokerSubListRequest {
|
||||
pageSize?: string;
|
||||
lastEndId?: number;
|
||||
status?: string;
|
||||
}
|
||||
|
||||
export interface BrokerSubWithdrawalRequest {
|
||||
subUid: string;
|
||||
coin: string;
|
||||
address: string;
|
||||
chain: string;
|
||||
tag?: string;
|
||||
amount: string;
|
||||
remark?: string;
|
||||
clientOid?: string;
|
||||
}
|
||||
|
||||
export interface BrokerSubAPIKeyModifyRequest {
|
||||
subUid: string;
|
||||
apikey: string;
|
||||
remark?: string;
|
||||
ip?: string;
|
||||
perm?: string;
|
||||
}
|
||||
136
src/types/request/futures.ts
Normal file
136
src/types/request/futures.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
export type FuturesProductType =
|
||||
| 'umcbl'
|
||||
| 'dmcbl'
|
||||
| 'cmcbl'
|
||||
| 'sumcbl'
|
||||
| 'sdmcbl'
|
||||
| 'scmcbl';
|
||||
|
||||
export interface FuturesAccountBillRequest {
|
||||
symbol: string;
|
||||
marginCoin: string;
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
pageSize?: number;
|
||||
lastEndId?: string;
|
||||
next?: boolean;
|
||||
}
|
||||
|
||||
export interface FuturesBusinessBillRequest {
|
||||
productType: FuturesProductType;
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
pageSize?: number;
|
||||
lastEndId?: string;
|
||||
next?: boolean;
|
||||
}
|
||||
|
||||
export type FuturesOrderType = 'limit' | 'market';
|
||||
export type FuturesOrderSide =
|
||||
| 'open_long'
|
||||
| 'open_short'
|
||||
| 'close_long'
|
||||
| 'close_short';
|
||||
|
||||
export interface NewFuturesOrder {
|
||||
symbol: string;
|
||||
marginCoin: string;
|
||||
size: string;
|
||||
price?: string;
|
||||
side: FuturesOrderSide;
|
||||
orderType: FuturesOrderType;
|
||||
timeInForceValue?: string;
|
||||
clientOid?: string;
|
||||
presetTakeProfitPrice?: string;
|
||||
presetStopLossPrice?: string;
|
||||
}
|
||||
|
||||
export interface NewBatchFuturesOrder {
|
||||
size: string;
|
||||
price?: string;
|
||||
side: string;
|
||||
orderType: string;
|
||||
timeInForceValue?: string;
|
||||
clientOid?: string;
|
||||
}
|
||||
|
||||
export interface FuturesPagination {
|
||||
startTime?: string;
|
||||
endTime?: string;
|
||||
lastEndId?: string;
|
||||
}
|
||||
|
||||
export interface NewFuturesPlanOrder {
|
||||
symbol: string;
|
||||
marginCoin: string;
|
||||
size: string;
|
||||
executePrice?: string;
|
||||
triggerPrice: string;
|
||||
triggerType: 'fill_price' | 'market_price';
|
||||
side: FuturesOrderSide;
|
||||
orderType: FuturesOrderType;
|
||||
clientOid?: string;
|
||||
presetTakeProfitPrice?: string;
|
||||
presetStopLossPrice?: string;
|
||||
}
|
||||
|
||||
export interface ModifyFuturesPlanOrder {
|
||||
orderId: string;
|
||||
marginCoin: string;
|
||||
symbol: string;
|
||||
executePrice?: string;
|
||||
triggerPrice: string;
|
||||
triggerType: string;
|
||||
orderType: FuturesOrderType;
|
||||
}
|
||||
|
||||
export interface ModifyFuturesPlanOrderTPSL {
|
||||
orderId: string;
|
||||
marginCoin: string;
|
||||
symbol: string;
|
||||
presetTakeProfitPrice?: string;
|
||||
presetStopLossPrice?: string;
|
||||
}
|
||||
|
||||
export type FuturesPlanType = 'profit_plan' | 'loss_plan';
|
||||
export type FuturesHoldSide = 'long' | 'short';
|
||||
|
||||
export interface NewFuturesPlanStopOrder {
|
||||
symbol: string;
|
||||
marginCoin: string;
|
||||
planType: FuturesPlanType;
|
||||
triggerPrice: string;
|
||||
holdSide?: FuturesHoldSide;
|
||||
size?: string;
|
||||
}
|
||||
|
||||
export interface NewFuturesPlanPositionTPSL {
|
||||
symbol: string;
|
||||
marginCoin: string;
|
||||
planType: FuturesPlanType;
|
||||
triggerPrice: string;
|
||||
holdSide: FuturesHoldSide;
|
||||
}
|
||||
|
||||
export interface ModifyFuturesPlanStopOrder {
|
||||
orderId: string;
|
||||
marginCoin: string;
|
||||
symbol: string;
|
||||
triggerPrice?: string;
|
||||
}
|
||||
|
||||
export interface CancelFuturesPlanTPSL {
|
||||
orderId: string;
|
||||
symbol: string;
|
||||
marginCoin: string;
|
||||
planType: FuturesPlanType;
|
||||
}
|
||||
|
||||
export interface HistoricPlanOrderTPSLRequest {
|
||||
symbol: string;
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
pageSize?: number;
|
||||
isPre?: boolean;
|
||||
isPlan?: string;
|
||||
}
|
||||
4
src/types/request/index.ts
Normal file
4
src/types/request/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export * from './broker';
|
||||
export * from './futures';
|
||||
export * from './shared';
|
||||
export * from './spot';
|
||||
9
src/types/request/shared.ts
Normal file
9
src/types/request/shared.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
/** Pagination */
|
||||
export interface Pagination {
|
||||
/** Time after */
|
||||
after?: string;
|
||||
/** Time before */
|
||||
before?: string;
|
||||
/** Elements per page */
|
||||
limit?: string;
|
||||
}
|
||||
26
src/types/request/spot.ts
Normal file
26
src/types/request/spot.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { numberInString, OrderSide } from '../shared';
|
||||
|
||||
export type OrderTypeSpot = 'LIMIT' | 'MARKET' | 'LIMIT_MAKER';
|
||||
export type OrderTimeInForce = 'GTC' | 'FOK' | 'IOC';
|
||||
|
||||
export type WalletType = 'spot' | 'mix_usdt' | 'mix_usd';
|
||||
|
||||
export interface NewWalletTransfer {
|
||||
fromType: WalletType;
|
||||
toType: WalletType;
|
||||
amount: string;
|
||||
coin: string;
|
||||
clientOid?: string;
|
||||
}
|
||||
|
||||
export interface NewSpotOrder {
|
||||
symbol: string;
|
||||
side: string;
|
||||
orderType: string;
|
||||
force: string;
|
||||
price?: string;
|
||||
quantity: string;
|
||||
clientOrderId?: string;
|
||||
}
|
||||
|
||||
export type NewBatchSpotOrder = Omit<NewSpotOrder, 'symbol'>;
|
||||
1
src/types/response/index.ts
Normal file
1
src/types/response/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './shared';
|
||||
6
src/types/response/shared.ts
Normal file
6
src/types/response/shared.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export interface APIResponse<T> {
|
||||
code: string;
|
||||
data: T;
|
||||
msg: 'success' | string;
|
||||
requestTime: number;
|
||||
}
|
||||
27
src/types/shared.ts
Normal file
27
src/types/shared.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { REST_CLIENT_TYPE_ENUM } from '../util';
|
||||
|
||||
export type numberInString = string;
|
||||
|
||||
export type OrderSide = 'Buy' | 'Sell';
|
||||
|
||||
export type KlineInterval =
|
||||
| '1min'
|
||||
| '5min'
|
||||
| '15min'
|
||||
| '30min'
|
||||
| '1h'
|
||||
| '4h'
|
||||
| '6h'
|
||||
| '12h'
|
||||
| '1M'
|
||||
| '1W'
|
||||
| '1week'
|
||||
| '6Hutc'
|
||||
| '12Hutc'
|
||||
| '1Dutc'
|
||||
| '3Dutc'
|
||||
| '1Wutc'
|
||||
| '1Mutc';
|
||||
|
||||
export type RestClientType =
|
||||
typeof REST_CLIENT_TYPE_ENUM[keyof typeof REST_CLIENT_TYPE_ENUM];
|
||||
70
src/types/websockets.ts
Normal file
70
src/types/websockets.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { WS_KEY_MAP } from '../util';
|
||||
|
||||
export type WsPublicSpotTopic =
|
||||
| 'ticker'
|
||||
| 'candle1W'
|
||||
| 'candle1D'
|
||||
| 'candle12H'
|
||||
| 'candle4H'
|
||||
| 'candle1H'
|
||||
| 'candle30m'
|
||||
| 'candle15m'
|
||||
| 'candle5m'
|
||||
| 'candle1m'
|
||||
| 'books'
|
||||
| 'books5'
|
||||
| 'trade';
|
||||
|
||||
// Futures currently has the same public topics as spot
|
||||
export type WsPublicFuturesTopic = WsPublicSpotTopic;
|
||||
export type WsPrivateSpotTopic = 'account' | 'orders';
|
||||
|
||||
export type WsPrivateFuturesTopic =
|
||||
| 'account'
|
||||
| 'positions'
|
||||
| 'orders'
|
||||
| 'ordersAlgo';
|
||||
|
||||
export type WsPublicTopic = WsPublicSpotTopic | WsPublicFuturesTopic;
|
||||
export type WsPrivateTopic = WsPrivateSpotTopic | WsPrivateFuturesTopic;
|
||||
export type WsTopic = WsPublicTopic | WsPrivateTopic;
|
||||
|
||||
/** This is used to differentiate between each of the available websocket streams */
|
||||
export type WsKey = typeof WS_KEY_MAP[keyof typeof WS_KEY_MAP];
|
||||
|
||||
export interface WSClientConfigurableOptions {
|
||||
/** Your API key */
|
||||
apiKey?: string;
|
||||
|
||||
/** Your API secret */
|
||||
apiSecret?: string;
|
||||
|
||||
/** The passphrase you set when creating the API Key (NOT your account password) */
|
||||
apiPass?: string;
|
||||
|
||||
/** How often to check if the connection is alive */
|
||||
pingInterval?: number;
|
||||
|
||||
/** How long to wait for a pong (heartbeat reply) before assuming the connection is dead */
|
||||
pongTimeout?: number;
|
||||
|
||||
/** Delay in milliseconds before respawning the connection */
|
||||
reconnectTimeout?: number;
|
||||
|
||||
requestOptions?: {
|
||||
/** override the user agent when opening the websocket connection (some proxies use this) */
|
||||
agent?: string;
|
||||
};
|
||||
|
||||
wsUrl?: string;
|
||||
|
||||
/** Define a recv window when preparing a private websocket signature. This is in milliseconds, so 5000 == 5 seconds */
|
||||
recvWindow?: number;
|
||||
}
|
||||
|
||||
export interface WebsocketClientOptions extends WSClientConfigurableOptions {
|
||||
pingInterval: number;
|
||||
pongTimeout: number;
|
||||
reconnectTimeout: number;
|
||||
recvWindow: number;
|
||||
}
|
||||
354
src/util/BaseRestClient.ts
Normal file
354
src/util/BaseRestClient.ts
Normal file
@@ -0,0 +1,354 @@
|
||||
import axios, { AxiosRequestConfig, AxiosResponse, Method } from 'axios';
|
||||
import { API_ERROR_CODE } from '../constants/enum';
|
||||
import { RestClientType } from '../types';
|
||||
|
||||
import { signMessage } from './node-support';
|
||||
import {
|
||||
RestClientOptions,
|
||||
serializeParams,
|
||||
getRestBaseUrl,
|
||||
} from './requestUtils';
|
||||
|
||||
// axios.interceptors.request.use((request) => {
|
||||
// console.log(
|
||||
// new Date(),
|
||||
// 'Starting Request',
|
||||
// JSON.stringify(
|
||||
// {
|
||||
// headers: request.headers,
|
||||
// url: request.url,
|
||||
// method: request.method,
|
||||
// params: request.params,
|
||||
// data: request.data,
|
||||
// },
|
||||
// null,
|
||||
// 2
|
||||
// )
|
||||
// );
|
||||
// return request;
|
||||
// });
|
||||
|
||||
// axios.interceptors.response.use((response) => {
|
||||
// console.log(new Date(), 'Response:', JSON.stringify(response, null, 2));
|
||||
// return response;
|
||||
// });
|
||||
|
||||
interface SignedRequest<T extends object | undefined = {}> {
|
||||
originalParams: T;
|
||||
paramsWithSign?: T & { sign: string };
|
||||
serializedParams: string;
|
||||
sign: string;
|
||||
queryParamsWithSign: string;
|
||||
timestamp: number;
|
||||
recvWindow: number;
|
||||
}
|
||||
|
||||
interface UnsignedRequest<T extends object | undefined = {}> {
|
||||
originalParams: T;
|
||||
paramsWithSign: T;
|
||||
}
|
||||
|
||||
type SignMethod = 'keyInBody' | 'usdc' | 'bitget';
|
||||
|
||||
export default abstract class BaseRestClient {
|
||||
private options: RestClientOptions;
|
||||
private baseUrl: string;
|
||||
private globalRequestOptions: AxiosRequestConfig;
|
||||
private apiKey: string | undefined;
|
||||
private apiSecret: string | undefined;
|
||||
private clientType: RestClientType;
|
||||
private apiPass: string | undefined;
|
||||
|
||||
/** Defines the client type (affecting how requests & signatures behave) */
|
||||
abstract getClientType(): RestClientType;
|
||||
|
||||
/**
|
||||
* Create an instance of the REST client. Pass API credentials in the object in the first parameter.
|
||||
* @param {RestClientOptions} [restClientOptions={}] options to configure REST API connectivity
|
||||
* @param {AxiosRequestConfig} [networkOptions={}] HTTP networking options for axios
|
||||
*/
|
||||
constructor(
|
||||
restOptions: RestClientOptions = {},
|
||||
networkOptions: AxiosRequestConfig = {}
|
||||
) {
|
||||
this.clientType = this.getClientType();
|
||||
|
||||
this.options = {
|
||||
recvWindow: 5000,
|
||||
/** Throw errors if any request params are empty */
|
||||
strictParamValidation: false,
|
||||
...restOptions,
|
||||
};
|
||||
|
||||
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
|
||||
...networkOptions,
|
||||
headers: {
|
||||
'X-CHANNEL-CODE': '3tem',
|
||||
'Content-Type': 'application/json',
|
||||
locale: 'en-US',
|
||||
},
|
||||
};
|
||||
|
||||
this.baseUrl = getRestBaseUrl(false, restOptions);
|
||||
this.apiKey = this.options.apiKey;
|
||||
this.apiSecret = this.options.apiSecret;
|
||||
this.apiPass = this.options.apiPass;
|
||||
|
||||
// Throw if one of the 3 values is missing, but at least one of them is set
|
||||
const credentials = [this.apiKey, this.apiSecret, this.apiPass];
|
||||
if (
|
||||
credentials.includes(undefined) &&
|
||||
credentials.some((v) => typeof v === 'string')
|
||||
) {
|
||||
throw new Error(
|
||||
'API Key, Secret & Passphrase are ALL required to use the authenticated REST client'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
get(endpoint: string, params?: any) {
|
||||
return this._call('GET', endpoint, params, true);
|
||||
}
|
||||
|
||||
getPrivate(endpoint: string, params?: any) {
|
||||
return this._call('GET', endpoint, params, false);
|
||||
}
|
||||
|
||||
post(endpoint: string, params?: any) {
|
||||
return this._call('POST', endpoint, params, true);
|
||||
}
|
||||
|
||||
postPrivate(endpoint: string, params?: any) {
|
||||
return this._call('POST', endpoint, params, false);
|
||||
}
|
||||
|
||||
deletePrivate(endpoint: string, params?: any) {
|
||||
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
|
||||
): Promise<any> {
|
||||
// Sanity check to make sure it's only ever prefixed by one forward slash
|
||||
const requestUrl = [this.baseUrl, endpoint].join(
|
||||
endpoint.startsWith('/') ? '' : '/'
|
||||
);
|
||||
|
||||
// Build a request and handle signature process
|
||||
const options = await this.buildRequest(
|
||||
method,
|
||||
endpoint,
|
||||
requestUrl,
|
||||
params,
|
||||
isPublicApi
|
||||
);
|
||||
|
||||
// console.log('full request: ', options);
|
||||
|
||||
// Dispatch request
|
||||
return axios(options)
|
||||
.then((response) => {
|
||||
// console.log('response: ', response.data);
|
||||
// console.error('res: ', response);
|
||||
// if (response.data && response.data?.code !== API_ERROR_CODE.SUCCESS) {
|
||||
// throw response.data;
|
||||
// }
|
||||
if (response.status == 200) {
|
||||
if (
|
||||
typeof response.data?.code === 'string' &&
|
||||
response.data?.code !== '00000'
|
||||
) {
|
||||
throw { response };
|
||||
}
|
||||
return response.data;
|
||||
}
|
||||
throw { response };
|
||||
})
|
||||
.catch((e) => this.parseException(e));
|
||||
}
|
||||
|
||||
/**
|
||||
* @private generic handler to parse request exceptions
|
||||
*/
|
||||
parseException(e: any): unknown {
|
||||
if (this.options.parseExceptions === 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;
|
||||
// console.error('err: ', response?.data);
|
||||
|
||||
throw {
|
||||
code: response.status,
|
||||
message: response.statusText,
|
||||
body: response.data,
|
||||
headers: response.headers,
|
||||
requestOptions: {
|
||||
...this.options,
|
||||
apiPass: 'omittedFromError',
|
||||
apiSecret: 'omittedFromError',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @private sign request and set recv window
|
||||
*/
|
||||
private async signRequest<T extends object | undefined = {}>(
|
||||
data: T,
|
||||
endpoint: string,
|
||||
method: Method,
|
||||
signMethod: SignMethod
|
||||
): Promise<SignedRequest<T>> {
|
||||
const timestamp = Date.now();
|
||||
|
||||
const res: SignedRequest<T> = {
|
||||
originalParams: {
|
||||
...data,
|
||||
},
|
||||
sign: '',
|
||||
timestamp,
|
||||
recvWindow: 0,
|
||||
serializedParams: '',
|
||||
queryParamsWithSign: '',
|
||||
};
|
||||
|
||||
if (!this.apiKey || !this.apiSecret) {
|
||||
return res;
|
||||
}
|
||||
|
||||
// It's possible to override the recv window on a per rquest level
|
||||
const strictParamValidation = this.options.strictParamValidation;
|
||||
|
||||
if (signMethod === 'bitget') {
|
||||
const signRequestParams =
|
||||
method === 'GET'
|
||||
? serializeParams(data, strictParamValidation, '?')
|
||||
: JSON.stringify(data) || '';
|
||||
|
||||
const paramsStr =
|
||||
timestamp + method.toUpperCase() + endpoint + signRequestParams;
|
||||
|
||||
// console.log('sign params: ', paramsStr);
|
||||
|
||||
res.sign = await signMessage(paramsStr, this.apiSecret, 'base64');
|
||||
res.queryParamsWithSign = signRequestParams;
|
||||
return res;
|
||||
}
|
||||
|
||||
return res;
|
||||
}
|
||||
|
||||
private async prepareSignParams<TParams extends object | undefined>(
|
||||
method: Method,
|
||||
endpoint: string,
|
||||
signMethod: SignMethod,
|
||||
params?: TParams,
|
||||
isPublicApi?: true
|
||||
): Promise<UnsignedRequest<TParams>>;
|
||||
private async prepareSignParams<TParams extends object | undefined>(
|
||||
method: Method,
|
||||
endpoint: string,
|
||||
signMethod: SignMethod,
|
||||
params?: TParams,
|
||||
isPublicApi?: false | undefined
|
||||
): Promise<SignedRequest<TParams>>;
|
||||
private async prepareSignParams<TParams extends object | undefined>(
|
||||
method: Method,
|
||||
endpoint: string,
|
||||
signMethod: SignMethod,
|
||||
params?: TParams,
|
||||
isPublicApi?: boolean
|
||||
) {
|
||||
if (isPublicApi) {
|
||||
return {
|
||||
originalParams: params,
|
||||
paramsWithSign: params,
|
||||
};
|
||||
}
|
||||
|
||||
if (!this.apiKey || !this.apiSecret) {
|
||||
throw new Error('Private endpoints require api and private keys set');
|
||||
}
|
||||
|
||||
return this.signRequest(params, endpoint, method, signMethod);
|
||||
}
|
||||
|
||||
/** Returns an axios request object. Handles signing process automatically if this is a private API call */
|
||||
private async buildRequest(
|
||||
method: Method,
|
||||
endpoint: string,
|
||||
url: string,
|
||||
params?: any,
|
||||
isPublicApi?: boolean
|
||||
): Promise<AxiosRequestConfig> {
|
||||
const options: AxiosRequestConfig = {
|
||||
...this.globalRequestOptions,
|
||||
url: url,
|
||||
method: method,
|
||||
};
|
||||
|
||||
for (const key in params) {
|
||||
if (typeof params[key] === 'undefined') {
|
||||
delete params[key];
|
||||
}
|
||||
}
|
||||
|
||||
if (isPublicApi || !this.apiKey || !this.apiPass) {
|
||||
return {
|
||||
...options,
|
||||
params: params,
|
||||
};
|
||||
}
|
||||
|
||||
const signResult = await this.prepareSignParams(
|
||||
method,
|
||||
endpoint,
|
||||
'bitget',
|
||||
params,
|
||||
isPublicApi
|
||||
);
|
||||
|
||||
if (!options.headers) {
|
||||
options.headers = {};
|
||||
}
|
||||
options.headers['ACCESS-KEY'] = this.apiKey;
|
||||
options.headers['ACCESS-PASSPHRASE'] = this.apiPass;
|
||||
options.headers['ACCESS-TIMESTAMP'] = signResult.timestamp;
|
||||
options.headers['ACCESS-SIGN'] = signResult.sign;
|
||||
options.headers['Content-Type'] = 'application/json';
|
||||
|
||||
if (method === 'GET') {
|
||||
return {
|
||||
...options,
|
||||
url: options.url + signResult.queryParamsWithSign,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...options,
|
||||
data: params,
|
||||
};
|
||||
}
|
||||
}
|
||||
205
src/util/WsStore.ts
Normal file
205
src/util/WsStore.ts
Normal file
@@ -0,0 +1,205 @@
|
||||
import WebSocket from 'isomorphic-ws';
|
||||
import { WsPrivateTopic, WsTopic } from '../types';
|
||||
import { DefaultLogger } from './logger';
|
||||
|
||||
export enum WsConnectionStateEnum {
|
||||
INITIAL = 0,
|
||||
CONNECTING = 1,
|
||||
CONNECTED = 2,
|
||||
CLOSING = 3,
|
||||
RECONNECTING = 4,
|
||||
// ERROR = 5,
|
||||
}
|
||||
/** A "topic" is always a string */
|
||||
|
||||
export type BitgetInstType = 'SP' | 'SPBL' | 'MC' | 'UMCBL' | 'DMCBL';
|
||||
|
||||
// TODO: generalise so this can be made a reusable module for other clients
|
||||
export interface WsTopicSubscribeEventArgs {
|
||||
instType: BitgetInstType;
|
||||
channel: WsTopic;
|
||||
/** The symbol, e.g. "BTCUSDT" */
|
||||
instId: string;
|
||||
}
|
||||
|
||||
type WsTopicList = Set<WsTopicSubscribeEventArgs>;
|
||||
|
||||
interface WsStoredState {
|
||||
/** The currently active websocket connection */
|
||||
ws?: WebSocket;
|
||||
/** The current lifecycle state of the connection (enum) */
|
||||
connectionState?: WsConnectionStateEnum;
|
||||
/** A timer that will send an upstream heartbeat (ping) when it expires */
|
||||
activePingTimer?: ReturnType<typeof setTimeout> | undefined;
|
||||
/** A timer tracking that an upstream heartbeat was sent, expecting a reply before it expires */
|
||||
activePongTimer?: ReturnType<typeof setTimeout> | undefined;
|
||||
/** If a reconnection is in progress, this will have the timer for the delayed reconnect */
|
||||
activeReconnectTimer?: ReturnType<typeof setTimeout> | undefined;
|
||||
/**
|
||||
* All the topics we are expected to be subscribed to (and we automatically resubscribe to if the connection drops)
|
||||
*
|
||||
* A "Set" and a deep object match are used to ensure we only subscribe to a topic once (tracking a list of unique topics we're expected to be connected to)
|
||||
*/
|
||||
subscribedTopics: WsTopicList;
|
||||
isAuthenticated?: boolean;
|
||||
}
|
||||
|
||||
function isDeepObjectMatch(object1: any, object2: any) {
|
||||
for (const key in object1) {
|
||||
if (object1[key] !== object2[key]) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export default class WsStore<WsKey extends string> {
|
||||
private wsState: Record<string, WsStoredState> = {};
|
||||
private logger: typeof DefaultLogger;
|
||||
|
||||
constructor(logger: typeof DefaultLogger) {
|
||||
this.logger = logger || DefaultLogger;
|
||||
}
|
||||
|
||||
/** Get WS stored state for key, optionally create if missing */
|
||||
get(key: WsKey, createIfMissing?: true): WsStoredState;
|
||||
get(key: WsKey, createIfMissing?: false): WsStoredState | undefined;
|
||||
get(key: WsKey, createIfMissing?: boolean): WsStoredState | undefined {
|
||||
if (this.wsState[key]) {
|
||||
return this.wsState[key];
|
||||
}
|
||||
|
||||
if (createIfMissing) {
|
||||
return this.create(key);
|
||||
}
|
||||
}
|
||||
|
||||
getKeys(): WsKey[] {
|
||||
return Object.keys(this.wsState) as WsKey[];
|
||||
}
|
||||
|
||||
create(key: WsKey): WsStoredState | undefined {
|
||||
if (this.hasExistingActiveConnection(key)) {
|
||||
this.logger.warning(
|
||||
'WsStore setConnection() overwriting existing open connection: ',
|
||||
this.getWs(key)
|
||||
);
|
||||
}
|
||||
this.wsState[key] = {
|
||||
subscribedTopics: new Set(),
|
||||
connectionState: WsConnectionStateEnum.INITIAL,
|
||||
};
|
||||
return this.get(key);
|
||||
}
|
||||
|
||||
delete(key: WsKey): void {
|
||||
// TODO: should we allow this at all? Perhaps block this from happening...
|
||||
if (this.hasExistingActiveConnection(key)) {
|
||||
const ws = this.getWs(key);
|
||||
this.logger.warning(
|
||||
'WsStore deleting state for connection still open: ',
|
||||
ws
|
||||
);
|
||||
ws?.close();
|
||||
}
|
||||
delete this.wsState[key];
|
||||
}
|
||||
|
||||
/* connection websocket */
|
||||
|
||||
hasExistingActiveConnection(key: WsKey): boolean {
|
||||
return this.get(key) && this.isWsOpen(key);
|
||||
}
|
||||
|
||||
getWs(key: WsKey): WebSocket | undefined {
|
||||
return this.get(key)?.ws;
|
||||
}
|
||||
|
||||
setWs(key: WsKey, wsConnection: WebSocket): WebSocket {
|
||||
if (this.isWsOpen(key)) {
|
||||
this.logger.warning(
|
||||
'WsStore setConnection() overwriting existing open connection: ',
|
||||
this.getWs(key)
|
||||
);
|
||||
}
|
||||
|
||||
this.get(key, true).ws = wsConnection;
|
||||
return wsConnection;
|
||||
}
|
||||
|
||||
/* connection state */
|
||||
|
||||
isWsOpen(key: WsKey): boolean {
|
||||
const existingConnection = this.getWs(key);
|
||||
return (
|
||||
!!existingConnection &&
|
||||
existingConnection.readyState === existingConnection.OPEN
|
||||
);
|
||||
}
|
||||
|
||||
getConnectionState(key: WsKey): WsConnectionStateEnum {
|
||||
return this.get(key, true).connectionState!;
|
||||
}
|
||||
|
||||
setConnectionState(key: WsKey, state: WsConnectionStateEnum) {
|
||||
this.get(key, true).connectionState = state;
|
||||
}
|
||||
|
||||
isConnectionState(key: WsKey, state: WsConnectionStateEnum): boolean {
|
||||
return this.getConnectionState(key) === state;
|
||||
}
|
||||
|
||||
/* subscribed topics */
|
||||
|
||||
getTopics(key: WsKey): WsTopicList {
|
||||
return this.get(key, true).subscribedTopics;
|
||||
}
|
||||
|
||||
getTopicsByKey(): Record<string, WsTopicList> {
|
||||
const result = {};
|
||||
for (const refKey in this.wsState) {
|
||||
result[refKey] = this.getTopics(refKey as WsKey);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// Since topics are objects we can't rely on the set to detect duplicates
|
||||
getMatchingTopic(key: WsKey, topic: WsTopicSubscribeEventArgs) {
|
||||
// if (typeof topic === 'string') {
|
||||
// return this.getMatchingTopic(key, { channel: topic });
|
||||
// }
|
||||
|
||||
const allTopics = this.getTopics(key).values();
|
||||
for (const storedTopic of allTopics) {
|
||||
if (isDeepObjectMatch(topic, storedTopic)) {
|
||||
return storedTopic;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
addTopic(key: WsKey, topic: WsTopicSubscribeEventArgs) {
|
||||
// if (typeof topic === 'string') {
|
||||
// return this.addTopic(key, {
|
||||
// instType: 'sp',
|
||||
// channel: topic,
|
||||
// instId: 'default',
|
||||
// };
|
||||
// }
|
||||
// Check for duplicate topic. If already tracked, don't store this one
|
||||
const existingTopic = this.getMatchingTopic(key, topic);
|
||||
if (existingTopic) {
|
||||
return this.getTopics(key);
|
||||
}
|
||||
return this.getTopics(key).add(topic);
|
||||
}
|
||||
|
||||
deleteTopic(key: WsKey, topic: WsTopicSubscribeEventArgs) {
|
||||
// Check if we're subscribed to a topic like this
|
||||
const storedTopic = this.getMatchingTopic(key, topic);
|
||||
if (storedTopic) {
|
||||
this.getTopics(key).delete(storedTopic);
|
||||
}
|
||||
|
||||
return this.getTopics(key);
|
||||
}
|
||||
}
|
||||
47
src/util/browser-support.ts
Normal file
47
src/util/browser-support.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
function _arrayBufferToBase64(buffer: ArrayBuffer) {
|
||||
let binary = '';
|
||||
const bytes = new Uint8Array(buffer);
|
||||
const len = bytes.byteLength;
|
||||
for (let i = 0; i < len; i++) {
|
||||
binary += String.fromCharCode(bytes[i]);
|
||||
}
|
||||
return window.btoa(binary);
|
||||
}
|
||||
|
||||
export async function signMessage(
|
||||
message: string,
|
||||
secret: string,
|
||||
method: 'hex' | 'base64'
|
||||
): Promise<string> {
|
||||
const encoder = new TextEncoder();
|
||||
const key = await window.crypto.subtle.importKey(
|
||||
'raw',
|
||||
encoder.encode(secret),
|
||||
{ name: 'HMAC', hash: { name: 'SHA-256' } },
|
||||
false,
|
||||
['sign']
|
||||
);
|
||||
|
||||
const signature = await window.crypto.subtle.sign(
|
||||
'HMAC',
|
||||
key,
|
||||
encoder.encode(message)
|
||||
);
|
||||
|
||||
switch (method) {
|
||||
case 'hex': {
|
||||
return Array.prototype.map
|
||||
.call(new Uint8Array(signature), (x: any) =>
|
||||
('00' + x.toString(16)).slice(-2)
|
||||
)
|
||||
.join('');
|
||||
}
|
||||
case 'base64': {
|
||||
return _arrayBufferToBase64(signature);
|
||||
}
|
||||
default: {
|
||||
((x: never) => {})(method);
|
||||
throw new Error(`Unhandled sign method: ${method}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
5
src/util/index.ts
Normal file
5
src/util/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export * from './BaseRestClient';
|
||||
export * from './requestUtils';
|
||||
export * from './WsStore';
|
||||
export * from './logger';
|
||||
export * from './websocket-util';
|
||||
22
src/util/logger.ts
Normal file
22
src/util/logger.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
export type LogParams = null | any;
|
||||
|
||||
export const DefaultLogger = {
|
||||
silly: (...params: LogParams): void => {
|
||||
// console.log(params);
|
||||
},
|
||||
debug: (...params: LogParams): void => {
|
||||
console.log(params);
|
||||
},
|
||||
notice: (...params: LogParams): void => {
|
||||
console.log(params);
|
||||
},
|
||||
info: (...params: LogParams): void => {
|
||||
console.info(params);
|
||||
},
|
||||
warning: (...params: LogParams): void => {
|
||||
console.error(params);
|
||||
},
|
||||
error: (...params: LogParams): void => {
|
||||
console.error(params);
|
||||
},
|
||||
};
|
||||
23
src/util/node-support.ts
Normal file
23
src/util/node-support.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { createHmac } from 'crypto';
|
||||
|
||||
/** This is async because the browser version uses a promise (browser-support) */
|
||||
export async function signMessage(
|
||||
message: string,
|
||||
secret: string,
|
||||
method: 'hex' | 'base64'
|
||||
): Promise<string> {
|
||||
const hmac = createHmac('sha256', secret).update(message);
|
||||
|
||||
switch (method) {
|
||||
case 'hex': {
|
||||
return hmac.digest('hex');
|
||||
}
|
||||
case 'base64': {
|
||||
return hmac.digest().toString('base64');
|
||||
}
|
||||
default: {
|
||||
((x: never) => {})(method);
|
||||
throw new Error(`Unhandled sign method: ${method}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
92
src/util/requestUtils.ts
Normal file
92
src/util/requestUtils.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
export interface RestClientOptions {
|
||||
/** Your API key */
|
||||
apiKey?: string;
|
||||
|
||||
/** Your API secret */
|
||||
apiSecret?: string;
|
||||
|
||||
/** The passphrase you set when creating the API Key (NOT your account password) */
|
||||
apiPass?: string;
|
||||
|
||||
/** Set to `true` to connect to testnet. Uses the live environment by default. */
|
||||
// testnet?: boolean;
|
||||
|
||||
/** Override the max size of the request window (in ms) */
|
||||
recvWindow?: number;
|
||||
|
||||
/** Default: false. If true, we'll throw errors if any params are undefined */
|
||||
strictParamValidation?: boolean;
|
||||
|
||||
/**
|
||||
* Optionally override API protocol + domain
|
||||
* e.g baseUrl: 'https://api.bitget.com'
|
||||
**/
|
||||
baseUrl?: string;
|
||||
|
||||
/** Default: true. whether to try and post-process request exceptions (and throw them). */
|
||||
parseExceptions?: boolean;
|
||||
}
|
||||
|
||||
export function serializeParams<T extends object | undefined = {}>(
|
||||
params: T,
|
||||
strict_validation = false,
|
||||
prefixWith: string = ''
|
||||
): string {
|
||||
if (!params) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const queryString = Object.keys(params)
|
||||
.sort()
|
||||
.map((key) => {
|
||||
const value = params[key];
|
||||
if (strict_validation === true && typeof value === 'undefined') {
|
||||
throw new Error(
|
||||
'Failed to sign API request due to undefined parameter'
|
||||
);
|
||||
}
|
||||
return `${key}=${value}`;
|
||||
})
|
||||
.join('&');
|
||||
|
||||
// Only prefix if there's a value
|
||||
return queryString ? prefixWith + queryString : queryString;
|
||||
}
|
||||
|
||||
export function getRestBaseUrl(
|
||||
useTestnet: boolean,
|
||||
restInverseOptions: RestClientOptions
|
||||
): string {
|
||||
const exchangeBaseUrls = {
|
||||
livenet: 'https://api.bitget.com',
|
||||
livenet2: 'https://capi.bitget.com',
|
||||
testnet: 'https://noTestnet',
|
||||
};
|
||||
|
||||
if (restInverseOptions.baseUrl) {
|
||||
return restInverseOptions.baseUrl;
|
||||
}
|
||||
|
||||
if (useTestnet) {
|
||||
return exchangeBaseUrls.testnet;
|
||||
}
|
||||
|
||||
return exchangeBaseUrls.livenet;
|
||||
}
|
||||
|
||||
export function isWsPong(msg: any): boolean {
|
||||
// bitget
|
||||
if (msg?.data === 'pong') {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Used to switch how authentication/requests work under the hood (primarily for SPOT since it's different there)
|
||||
*/
|
||||
export const REST_CLIENT_TYPE_ENUM = {
|
||||
spot: 'spot',
|
||||
futures: 'futures',
|
||||
broker: 'broker',
|
||||
} as const;
|
||||
135
src/util/websocket-util.ts
Normal file
135
src/util/websocket-util.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
import { WsKey } from '../types';
|
||||
import { signMessage } from './node-support';
|
||||
import { BitgetInstType, WsTopicSubscribeEventArgs } from './WsStore';
|
||||
|
||||
/**
|
||||
* Some exchanges have two livenet environments, some have test environments, some dont. This allows easy flexibility for different exchanges.
|
||||
* Examples:
|
||||
* - One livenet and one testnet: NetworkMap<'livenet' | 'testnet'>
|
||||
* - One livenet, sometimes two, one testnet: NetworkMap<'livenet' | 'testnet', 'livenet2'>
|
||||
* - Only one livenet, no other networks: NetworkMap<'livenet'>
|
||||
*/
|
||||
type NetworkMap<
|
||||
TRequiredKeys extends string,
|
||||
TOptionalKeys extends string | undefined = undefined
|
||||
> = Record<TRequiredKeys, string> &
|
||||
(TOptionalKeys extends string
|
||||
? Record<TOptionalKeys, string | undefined>
|
||||
: Record<TRequiredKeys, string>);
|
||||
|
||||
export const WS_BASE_URL_MAP: Record<
|
||||
WsKey,
|
||||
Record<'all', NetworkMap<'livenet'>>
|
||||
> = {
|
||||
mixv1: {
|
||||
all: {
|
||||
livenet: 'wss://ws.bitget.com/mix/v1/stream',
|
||||
},
|
||||
},
|
||||
spotv1: {
|
||||
all: {
|
||||
livenet: 'wss://ws.bitget.com/spot/v1/stream',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
/** Should be one WS key per unique URL */
|
||||
export const WS_KEY_MAP = {
|
||||
spotv1: 'spotv1',
|
||||
mixv1: 'mixv1',
|
||||
} as const;
|
||||
|
||||
/** Any WS keys in this list will trigger auth on connect, if credentials are available */
|
||||
export const WS_AUTH_ON_CONNECT_KEYS: WsKey[] = [
|
||||
WS_KEY_MAP.spotv1,
|
||||
WS_KEY_MAP.mixv1,
|
||||
];
|
||||
|
||||
/** Any WS keys in this list will ALWAYS skip the authentication process, even if credentials are available */
|
||||
export const PUBLIC_WS_KEYS = [] as WsKey[];
|
||||
|
||||
/**
|
||||
* Used to automatically determine if a sub request should be to a public or private ws (when there's two separate connections).
|
||||
* Unnecessary if there's only one connection to handle both public & private topics.
|
||||
*/
|
||||
export const PRIVATE_TOPICS = ['account', 'orders', 'positions', 'ordersAlgo'];
|
||||
|
||||
export function isPrivateChannel<TChannel extends string>(
|
||||
channel: TChannel
|
||||
): boolean {
|
||||
return PRIVATE_TOPICS.includes(channel);
|
||||
}
|
||||
|
||||
export function getWsKeyForTopic(
|
||||
subscribeEvent: WsTopicSubscribeEventArgs,
|
||||
isPrivate?: boolean
|
||||
): WsKey {
|
||||
const instType = subscribeEvent.instType.toUpperCase() as BitgetInstType;
|
||||
switch (instType) {
|
||||
case 'SP':
|
||||
case 'SPBL': {
|
||||
return WS_KEY_MAP.spotv1;
|
||||
}
|
||||
case 'MC':
|
||||
case 'UMCBL':
|
||||
case 'DMCBL': {
|
||||
return WS_KEY_MAP.mixv1;
|
||||
}
|
||||
default: {
|
||||
throw neverGuard(
|
||||
instType,
|
||||
`getWsKeyForTopic(): Unhandled market ${'instrumentId'}`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Force subscription requests to be sent in smaller batches, if a number is returned */
|
||||
export function getMaxTopicsPerSubscribeEvent(wsKey: WsKey): number | null {
|
||||
switch (wsKey) {
|
||||
case 'mixv1':
|
||||
case 'spotv1': {
|
||||
// Technically there doesn't seem to be a documented cap, but there is a size limit per request. Doesn't hurt to batch requests.
|
||||
return 15;
|
||||
}
|
||||
default: {
|
||||
throw neverGuard(wsKey, `getWsKeyForTopic(): Unhandled wsKey`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const WS_ERROR_ENUM = {
|
||||
INVALID_ACCESS_KEY: 30011,
|
||||
};
|
||||
|
||||
export function neverGuard(x: never, msg: string): Error {
|
||||
return new Error(`Unhandled value exception "${x}", ${msg}`);
|
||||
}
|
||||
|
||||
export async function getWsAuthSignature(
|
||||
apiKey: string | undefined,
|
||||
apiSecret: string | undefined,
|
||||
apiPass: string | undefined,
|
||||
recvWindow: number = 0
|
||||
): Promise<{
|
||||
expiresAt: number;
|
||||
signature: string;
|
||||
}> {
|
||||
if (!apiKey || !apiSecret || !apiPass) {
|
||||
throw new Error(
|
||||
`Cannot auth - missing api key, secret or passcode in config`
|
||||
);
|
||||
}
|
||||
const signatureExpiresAt = ((Date.now() + recvWindow) / 1000).toFixed(0);
|
||||
|
||||
const signature = await signMessage(
|
||||
signatureExpiresAt + 'GET' + '/user/verify',
|
||||
apiSecret,
|
||||
'base64'
|
||||
);
|
||||
|
||||
return {
|
||||
expiresAt: Number(signatureExpiresAt),
|
||||
signature,
|
||||
};
|
||||
}
|
||||
694
src/websocket-client.ts
Normal file
694
src/websocket-client.ts
Normal file
@@ -0,0 +1,694 @@
|
||||
import { EventEmitter } from 'events';
|
||||
import WebSocket from 'isomorphic-ws';
|
||||
|
||||
import WsStore, {
|
||||
BitgetInstType,
|
||||
WsTopicSubscribeEventArgs,
|
||||
} from './util/WsStore';
|
||||
|
||||
import {
|
||||
WebsocketClientOptions,
|
||||
WSClientConfigurableOptions,
|
||||
WsKey,
|
||||
WsTopic,
|
||||
} from './types';
|
||||
|
||||
import {
|
||||
isWsPong,
|
||||
WsConnectionStateEnum,
|
||||
WS_AUTH_ON_CONNECT_KEYS,
|
||||
WS_KEY_MAP,
|
||||
DefaultLogger,
|
||||
WS_BASE_URL_MAP,
|
||||
getWsKeyForTopic,
|
||||
neverGuard,
|
||||
getMaxTopicsPerSubscribeEvent,
|
||||
isPrivateChannel,
|
||||
getWsAuthSignature,
|
||||
} from './util';
|
||||
|
||||
const LOGGER_CATEGORY = { category: 'bitget-ws' };
|
||||
|
||||
export type WsClientEvent =
|
||||
| 'open'
|
||||
| 'update'
|
||||
| 'close'
|
||||
| 'exception'
|
||||
| 'reconnect'
|
||||
| 'reconnected'
|
||||
| 'response';
|
||||
|
||||
interface WebsocketClientEvents {
|
||||
/** Connection opened. If this connection was previously opened and reconnected, expect the reconnected event instead */
|
||||
open: (evt: { wsKey: WsKey; event: any }) => void;
|
||||
/** Reconnecting a dropped connection */
|
||||
reconnect: (evt: { wsKey: WsKey; event: any }) => void;
|
||||
/** Successfully reconnected a connection that dropped */
|
||||
reconnected: (evt: { wsKey: WsKey; event: any }) => void;
|
||||
/** Connection closed */
|
||||
close: (evt: { wsKey: WsKey; event: any }) => void;
|
||||
/** Received reply to websocket command (e.g. after subscribing to topics) */
|
||||
response: (response: any & { wsKey: WsKey }) => void;
|
||||
/** Received data for topic */
|
||||
update: (response: any & { wsKey: WsKey }) => void;
|
||||
/** Exception from ws client OR custom listeners (e.g. if you throw inside your event handler) */
|
||||
exception: (response: any & { wsKey: WsKey }) => void;
|
||||
/** Confirmation that a connection successfully authenticated */
|
||||
authenticated: (event: { wsKey: WsKey; event: any }) => void;
|
||||
}
|
||||
|
||||
// Type safety for on and emit handlers: https://stackoverflow.com/a/61609010/880837
|
||||
export declare interface WebsocketClient {
|
||||
on<U extends keyof WebsocketClientEvents>(
|
||||
event: U,
|
||||
listener: WebsocketClientEvents[U]
|
||||
): this;
|
||||
|
||||
emit<U extends keyof WebsocketClientEvents>(
|
||||
event: U,
|
||||
...args: Parameters<WebsocketClientEvents[U]>
|
||||
): boolean;
|
||||
}
|
||||
|
||||
export class WebsocketClient extends EventEmitter {
|
||||
private logger: typeof DefaultLogger;
|
||||
private options: WebsocketClientOptions;
|
||||
private wsStore: WsStore<WsKey>;
|
||||
|
||||
constructor(
|
||||
options: WSClientConfigurableOptions,
|
||||
logger?: typeof DefaultLogger
|
||||
) {
|
||||
super();
|
||||
|
||||
this.logger = logger || DefaultLogger;
|
||||
this.wsStore = new WsStore(this.logger);
|
||||
|
||||
this.options = {
|
||||
pongTimeout: 1000,
|
||||
pingInterval: 10000,
|
||||
reconnectTimeout: 500,
|
||||
recvWindow: 0,
|
||||
...options,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to topics & track/persist them. They will be automatically resubscribed to if the connection drops/reconnects.
|
||||
* @param wsTopics topic or list of topics
|
||||
* @param isPrivateTopic optional - the library will try to detect private topics, you can use this to mark a topic as private (if the topic isn't recognised yet)
|
||||
*/
|
||||
public subscribe(
|
||||
wsTopics: WsTopicSubscribeEventArgs[] | WsTopicSubscribeEventArgs,
|
||||
isPrivateTopic?: boolean
|
||||
) {
|
||||
const topics = Array.isArray(wsTopics) ? wsTopics : [wsTopics];
|
||||
|
||||
topics.forEach((topic) => {
|
||||
const wsKey = getWsKeyForTopic(topic, isPrivateTopic);
|
||||
|
||||
// Persist this topic to the expected topics list
|
||||
this.wsStore.addTopic(wsKey, topic);
|
||||
|
||||
// TODO: tidy up unsubscribe too, also in other connectors
|
||||
|
||||
// if connected, send subscription request
|
||||
if (
|
||||
this.wsStore.isConnectionState(wsKey, WsConnectionStateEnum.CONNECTED)
|
||||
) {
|
||||
// if not authenticated, dont sub to private topics yet.
|
||||
// This'll happen automatically once authenticated
|
||||
const isAuthenticated = this.wsStore.get(wsKey)?.isAuthenticated;
|
||||
if (!isAuthenticated) {
|
||||
return this.requestSubscribeTopics(
|
||||
wsKey,
|
||||
topics.filter((topic) => !isPrivateChannel(topic.channel))
|
||||
);
|
||||
}
|
||||
return this.requestSubscribeTopics(wsKey, topics);
|
||||
}
|
||||
|
||||
// start connection process if it hasn't yet begun. Topics are automatically subscribed to on-connect
|
||||
if (
|
||||
!this.wsStore.isConnectionState(
|
||||
wsKey,
|
||||
WsConnectionStateEnum.CONNECTING
|
||||
) &&
|
||||
!this.wsStore.isConnectionState(
|
||||
wsKey,
|
||||
WsConnectionStateEnum.RECONNECTING
|
||||
)
|
||||
) {
|
||||
return this.connect(wsKey);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Unsubscribe from topics & remove them from memory. They won't be re-subscribed to if the connection reconnects.
|
||||
* @param wsTopics topic or list of topics
|
||||
* @param isPrivateTopic optional - the library will try to detect private topics, you can use this to mark a topic as private (if the topic isn't recognised yet)
|
||||
*/
|
||||
public unsubscribe(
|
||||
wsTopics: WsTopicSubscribeEventArgs[] | WsTopicSubscribeEventArgs,
|
||||
isPrivateTopic?: boolean
|
||||
) {
|
||||
const topics = Array.isArray(wsTopics) ? wsTopics : [wsTopics];
|
||||
topics.forEach((topic) =>
|
||||
this.wsStore.deleteTopic(getWsKeyForTopic(topic, isPrivateTopic), topic)
|
||||
);
|
||||
|
||||
// TODO: should this really happen on each wsKey?? seems weird
|
||||
this.wsStore.getKeys().forEach((wsKey: WsKey) => {
|
||||
// unsubscribe request only necessary if active connection exists
|
||||
if (
|
||||
this.wsStore.isConnectionState(wsKey, WsConnectionStateEnum.CONNECTED)
|
||||
) {
|
||||
this.requestUnsubscribeTopics(wsKey, topics);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** Get the WsStore that tracks websockets & topics */
|
||||
public getWsStore(): typeof this.wsStore {
|
||||
return this.wsStore;
|
||||
}
|
||||
|
||||
public close(wsKey: WsKey, force?: boolean) {
|
||||
this.logger.info('Closing connection', { ...LOGGER_CATEGORY, wsKey });
|
||||
this.setWsState(wsKey, WsConnectionStateEnum.CLOSING);
|
||||
this.clearTimers(wsKey);
|
||||
|
||||
const ws = this.getWs(wsKey);
|
||||
ws?.close();
|
||||
if (force) {
|
||||
ws?.terminate();
|
||||
}
|
||||
}
|
||||
|
||||
public closeAll(force?: boolean) {
|
||||
this.wsStore.getKeys().forEach((key: WsKey) => {
|
||||
this.close(key, force);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Request connection of all dependent (public & private) websockets, instead of waiting for automatic connection by library
|
||||
*/
|
||||
public connectAll(): Promise<WebSocket | undefined>[] {
|
||||
return [this.connect(WS_KEY_MAP.spotv1), this.connect(WS_KEY_MAP.mixv1)];
|
||||
}
|
||||
|
||||
/**
|
||||
* Request connection to a specific websocket, instead of waiting for automatic connection.
|
||||
*/
|
||||
private async connect(wsKey: WsKey): Promise<WebSocket | undefined> {
|
||||
try {
|
||||
if (this.wsStore.isWsOpen(wsKey)) {
|
||||
this.logger.error(
|
||||
'Refused to connect to ws with existing active connection',
|
||||
{ ...LOGGER_CATEGORY, wsKey }
|
||||
);
|
||||
return this.wsStore.getWs(wsKey);
|
||||
}
|
||||
|
||||
if (
|
||||
this.wsStore.isConnectionState(wsKey, WsConnectionStateEnum.CONNECTING)
|
||||
) {
|
||||
this.logger.error(
|
||||
'Refused to connect to ws, connection attempt already active',
|
||||
{ ...LOGGER_CATEGORY, wsKey }
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
!this.wsStore.getConnectionState(wsKey) ||
|
||||
this.wsStore.isConnectionState(wsKey, WsConnectionStateEnum.INITIAL)
|
||||
) {
|
||||
this.setWsState(wsKey, WsConnectionStateEnum.CONNECTING);
|
||||
}
|
||||
|
||||
const url = this.getWsUrl(wsKey); // + authParams;
|
||||
const ws = this.connectToWsUrl(url, wsKey);
|
||||
|
||||
return this.wsStore.setWs(wsKey, ws);
|
||||
} catch (err) {
|
||||
this.parseWsError('Connection failed', err, wsKey);
|
||||
this.reconnectWithDelay(wsKey, this.options.reconnectTimeout!);
|
||||
}
|
||||
}
|
||||
|
||||
private parseWsError(context: string, error: any, wsKey: WsKey) {
|
||||
if (!error.message) {
|
||||
this.logger.error(`${context} due to unexpected error: `, error);
|
||||
this.emit('response', { ...error, wsKey });
|
||||
this.emit('exception', { ...error, wsKey });
|
||||
return;
|
||||
}
|
||||
|
||||
switch (error.message) {
|
||||
case 'Unexpected server response: 401':
|
||||
this.logger.error(`${context} due to 401 authorization failure.`, {
|
||||
...LOGGER_CATEGORY,
|
||||
wsKey,
|
||||
});
|
||||
break;
|
||||
|
||||
default:
|
||||
this.logger.error(
|
||||
`${context} due to unexpected response error: "${
|
||||
error?.msg || error?.message || error
|
||||
}"`,
|
||||
{ ...LOGGER_CATEGORY, wsKey, error }
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
this.emit('response', { ...error, wsKey });
|
||||
this.emit('exception', { ...error, wsKey });
|
||||
}
|
||||
|
||||
/** Get a signature, build the auth request and send it */
|
||||
private async sendAuthRequest(wsKey: WsKey): Promise<void> {
|
||||
try {
|
||||
const { apiKey, apiSecret, apiPass, recvWindow } = this.options;
|
||||
|
||||
const { signature, expiresAt } = await getWsAuthSignature(
|
||||
apiKey,
|
||||
apiSecret,
|
||||
apiPass,
|
||||
recvWindow
|
||||
);
|
||||
|
||||
this.logger.info(`Sending auth request...`, {
|
||||
...LOGGER_CATEGORY,
|
||||
wsKey,
|
||||
});
|
||||
|
||||
const request = {
|
||||
op: 'login',
|
||||
args: [
|
||||
{
|
||||
apiKey: this.options.apiKey,
|
||||
passphrase: this.options.apiPass,
|
||||
timestamp: expiresAt,
|
||||
sign: signature,
|
||||
},
|
||||
],
|
||||
};
|
||||
// console.log('ws auth req', request);
|
||||
|
||||
return this.tryWsSend(wsKey, JSON.stringify(request));
|
||||
} catch (e) {
|
||||
this.logger.silly(e, { ...LOGGER_CATEGORY, wsKey });
|
||||
}
|
||||
}
|
||||
|
||||
private reconnectWithDelay(wsKey: WsKey, connectionDelayMs: number) {
|
||||
this.clearTimers(wsKey);
|
||||
if (
|
||||
this.wsStore.getConnectionState(wsKey) !==
|
||||
WsConnectionStateEnum.CONNECTING
|
||||
) {
|
||||
this.setWsState(wsKey, WsConnectionStateEnum.RECONNECTING);
|
||||
}
|
||||
|
||||
this.wsStore.get(wsKey, true).activeReconnectTimer = setTimeout(() => {
|
||||
this.logger.info('Reconnecting to websocket', {
|
||||
...LOGGER_CATEGORY,
|
||||
wsKey,
|
||||
});
|
||||
this.connect(wsKey);
|
||||
}, connectionDelayMs);
|
||||
}
|
||||
|
||||
private ping(wsKey: WsKey) {
|
||||
if (this.wsStore.get(wsKey, true).activePongTimer) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.clearPongTimer(wsKey);
|
||||
|
||||
this.logger.silly('Sending ping', { ...LOGGER_CATEGORY, wsKey });
|
||||
this.tryWsSend(wsKey, JSON.stringify({ op: 'ping' }));
|
||||
|
||||
this.wsStore.get(wsKey, true).activePongTimer = setTimeout(() => {
|
||||
this.logger.info('Pong timeout - closing socket to reconnect', {
|
||||
...LOGGER_CATEGORY,
|
||||
wsKey,
|
||||
});
|
||||
this.getWs(wsKey)?.terminate();
|
||||
delete this.wsStore.get(wsKey, true).activePongTimer;
|
||||
}, this.options.pongTimeout);
|
||||
}
|
||||
|
||||
private clearTimers(wsKey: WsKey) {
|
||||
this.clearPingTimer(wsKey);
|
||||
this.clearPongTimer(wsKey);
|
||||
const wsState = this.wsStore.get(wsKey);
|
||||
if (wsState?.activeReconnectTimer) {
|
||||
clearTimeout(wsState.activeReconnectTimer);
|
||||
}
|
||||
}
|
||||
|
||||
// Send a ping at intervals
|
||||
private clearPingTimer(wsKey: WsKey) {
|
||||
const wsState = this.wsStore.get(wsKey);
|
||||
if (wsState?.activePingTimer) {
|
||||
clearInterval(wsState.activePingTimer);
|
||||
wsState.activePingTimer = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
// Expect a pong within a time limit
|
||||
private clearPongTimer(wsKey: WsKey) {
|
||||
const wsState = this.wsStore.get(wsKey);
|
||||
if (wsState?.activePongTimer) {
|
||||
clearTimeout(wsState.activePongTimer);
|
||||
wsState.activePongTimer = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @private Use the `subscribe(topics)` method to subscribe to topics. Send WS message to subscribe to topics.
|
||||
*/
|
||||
private requestSubscribeTopics(
|
||||
wsKey: WsKey,
|
||||
topics: WsTopicSubscribeEventArgs[]
|
||||
) {
|
||||
if (!topics.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const maxTopicsPerEvent = getMaxTopicsPerSubscribeEvent(wsKey);
|
||||
if (maxTopicsPerEvent && topics.length > maxTopicsPerEvent) {
|
||||
this.logger.silly(
|
||||
`Subscribing to topics in batches of ${maxTopicsPerEvent}`
|
||||
);
|
||||
for (var i = 0; i < topics.length; i += maxTopicsPerEvent) {
|
||||
const batch = topics.slice(i, i + maxTopicsPerEvent);
|
||||
this.logger.silly(`Subscribing to batch of ${batch.length}`);
|
||||
this.requestSubscribeTopics(wsKey, batch);
|
||||
}
|
||||
this.logger.silly(
|
||||
`Finished batch subscribing to ${topics.length} topics`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const wsMessage = JSON.stringify({
|
||||
op: 'subscribe',
|
||||
args: topics,
|
||||
});
|
||||
|
||||
this.tryWsSend(wsKey, wsMessage);
|
||||
}
|
||||
|
||||
/**
|
||||
* @private Use the `unsubscribe(topics)` method to unsubscribe from topics. Send WS message to unsubscribe from topics.
|
||||
*/
|
||||
private requestUnsubscribeTopics(
|
||||
wsKey: WsKey,
|
||||
topics: WsTopicSubscribeEventArgs[]
|
||||
) {
|
||||
if (!topics.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const maxTopicsPerEvent = getMaxTopicsPerSubscribeEvent(wsKey);
|
||||
if (maxTopicsPerEvent && topics.length > maxTopicsPerEvent) {
|
||||
this.logger.silly(
|
||||
`Unsubscribing to topics in batches of ${maxTopicsPerEvent}`
|
||||
);
|
||||
for (var i = 0; i < topics.length; i += maxTopicsPerEvent) {
|
||||
const batch = topics.slice(i, i + maxTopicsPerEvent);
|
||||
this.logger.silly(`Unsubscribing to batch of ${batch.length}`);
|
||||
this.requestUnsubscribeTopics(wsKey, batch);
|
||||
}
|
||||
this.logger.silly(
|
||||
`Finished batch unsubscribing to ${topics.length} topics`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const wsMessage = JSON.stringify({
|
||||
op: 'unsubscribe',
|
||||
args: topics,
|
||||
});
|
||||
|
||||
this.tryWsSend(wsKey, wsMessage);
|
||||
}
|
||||
|
||||
public tryWsSend(wsKey: WsKey, wsMessage: string) {
|
||||
try {
|
||||
this.logger.silly(`Sending upstream ws message: `, {
|
||||
...LOGGER_CATEGORY,
|
||||
wsMessage,
|
||||
wsKey,
|
||||
});
|
||||
if (!wsKey) {
|
||||
throw new Error(
|
||||
'Cannot send message due to no known websocket for this wsKey'
|
||||
);
|
||||
}
|
||||
const ws = this.getWs(wsKey);
|
||||
if (!ws) {
|
||||
throw new Error(
|
||||
`${wsKey} socket not connected yet, call "connectAll()" first then try again when the "open" event arrives`
|
||||
);
|
||||
}
|
||||
ws.send(wsMessage);
|
||||
} catch (e) {
|
||||
this.logger.error(`Failed to send WS message`, {
|
||||
...LOGGER_CATEGORY,
|
||||
wsMessage,
|
||||
wsKey,
|
||||
exception: e,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private connectToWsUrl(url: string, wsKey: WsKey): WebSocket {
|
||||
this.logger.silly(`Opening WS connection to URL: ${url}`, {
|
||||
...LOGGER_CATEGORY,
|
||||
wsKey,
|
||||
});
|
||||
|
||||
const agent = this.options.requestOptions?.agent;
|
||||
const ws = new WebSocket(url, undefined, agent ? { agent } : undefined);
|
||||
ws.onopen = (event) => this.onWsOpen(event, wsKey);
|
||||
ws.onmessage = (event) => this.onWsMessage(event, wsKey);
|
||||
ws.onerror = (event) => this.parseWsError('websocket error', event, wsKey);
|
||||
ws.onclose = (event) => this.onWsClose(event, wsKey);
|
||||
|
||||
return ws;
|
||||
}
|
||||
|
||||
private async onWsOpen(event, wsKey: WsKey) {
|
||||
if (
|
||||
this.wsStore.isConnectionState(wsKey, WsConnectionStateEnum.CONNECTING)
|
||||
) {
|
||||
this.logger.info('Websocket connected', {
|
||||
...LOGGER_CATEGORY,
|
||||
wsKey,
|
||||
});
|
||||
this.emit('open', { wsKey, event });
|
||||
} else if (
|
||||
this.wsStore.isConnectionState(wsKey, WsConnectionStateEnum.RECONNECTING)
|
||||
) {
|
||||
this.logger.info('Websocket reconnected', { ...LOGGER_CATEGORY, wsKey });
|
||||
this.emit('reconnected', { wsKey, event });
|
||||
}
|
||||
|
||||
this.setWsState(wsKey, WsConnectionStateEnum.CONNECTED);
|
||||
|
||||
// Some websockets require an auth packet to be sent after opening the connection
|
||||
if (WS_AUTH_ON_CONNECT_KEYS.includes(wsKey)) {
|
||||
await this.sendAuthRequest(wsKey);
|
||||
}
|
||||
|
||||
// Reconnect to topics known before it connected
|
||||
// Private topics will be resubscribed to once reconnected
|
||||
const topics = [...this.wsStore.getTopics(wsKey)];
|
||||
const publicTopics = topics.filter(
|
||||
(topic) => !isPrivateChannel(topic.channel)
|
||||
);
|
||||
this.requestSubscribeTopics(wsKey, publicTopics);
|
||||
|
||||
this.wsStore.get(wsKey, true)!.activePingTimer = setInterval(
|
||||
() => this.ping(wsKey),
|
||||
this.options.pingInterval
|
||||
);
|
||||
}
|
||||
|
||||
/** Handle subscription to private topics _after_ authentication successfully completes asynchronously */
|
||||
private onWsAuthenticated(wsKey: WsKey) {
|
||||
const wsState = this.wsStore.get(wsKey, true);
|
||||
wsState.isAuthenticated = true;
|
||||
|
||||
const topics = [...this.wsStore.getTopics(wsKey)];
|
||||
const privateTopics = topics.filter((topic) =>
|
||||
isPrivateChannel(topic.channel)
|
||||
);
|
||||
|
||||
if (privateTopics.length) {
|
||||
this.subscribe(privateTopics, true);
|
||||
}
|
||||
}
|
||||
|
||||
private onWsMessage(event: unknown, wsKey: WsKey) {
|
||||
try {
|
||||
// any message can clear the pong timer - wouldn't get a message if the ws wasn't working
|
||||
this.clearPongTimer(wsKey);
|
||||
|
||||
if (isWsPong(event)) {
|
||||
this.logger.silly('Received pong', { ...LOGGER_CATEGORY, wsKey });
|
||||
return;
|
||||
}
|
||||
|
||||
const msg = JSON.parse((event && event['data']) || event);
|
||||
const emittableEvent = { ...msg, wsKey };
|
||||
|
||||
if (typeof msg === 'object') {
|
||||
if (typeof msg['code'] === 'number') {
|
||||
if (msg.event === 'login' && msg.code === 0) {
|
||||
this.logger.info(`Successfully authenticated WS client`, {
|
||||
...LOGGER_CATEGORY,
|
||||
wsKey,
|
||||
});
|
||||
this.emit('response', emittableEvent);
|
||||
this.emit('authenticated', emittableEvent);
|
||||
this.onWsAuthenticated(wsKey);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (msg['event']) {
|
||||
if (msg.event === 'error') {
|
||||
this.logger.error(`WS Error received`, {
|
||||
...LOGGER_CATEGORY,
|
||||
wsKey,
|
||||
message: msg || 'no message',
|
||||
// messageType: typeof msg,
|
||||
// messageString: JSON.stringify(msg),
|
||||
event,
|
||||
});
|
||||
this.emit('exception', emittableEvent);
|
||||
this.emit('response', emittableEvent);
|
||||
return;
|
||||
}
|
||||
return this.emit('response', emittableEvent);
|
||||
}
|
||||
|
||||
if (msg['arg']) {
|
||||
return this.emit('update', emittableEvent);
|
||||
}
|
||||
}
|
||||
|
||||
this.logger.warning('Unhandled/unrecognised ws event message', {
|
||||
...LOGGER_CATEGORY,
|
||||
message: msg || 'no message',
|
||||
// messageType: typeof msg,
|
||||
// messageString: JSON.stringify(msg),
|
||||
event,
|
||||
wsKey,
|
||||
});
|
||||
|
||||
// fallback emit anyway
|
||||
return this.emit('update', emittableEvent);
|
||||
} catch (e) {
|
||||
this.logger.error('Failed to parse ws event message', {
|
||||
...LOGGER_CATEGORY,
|
||||
error: e,
|
||||
event,
|
||||
wsKey,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private onWsClose(event: unknown, wsKey: WsKey) {
|
||||
this.logger.info('Websocket connection closed', {
|
||||
...LOGGER_CATEGORY,
|
||||
wsKey,
|
||||
});
|
||||
|
||||
if (
|
||||
this.wsStore.getConnectionState(wsKey) !== WsConnectionStateEnum.CLOSING
|
||||
) {
|
||||
this.reconnectWithDelay(wsKey, this.options.reconnectTimeout!);
|
||||
this.emit('reconnect', { wsKey, event });
|
||||
} else {
|
||||
this.setWsState(wsKey, WsConnectionStateEnum.INITIAL);
|
||||
this.emit('close', { wsKey, event });
|
||||
}
|
||||
}
|
||||
|
||||
private getWs(wsKey: WsKey) {
|
||||
return this.wsStore.getWs(wsKey);
|
||||
}
|
||||
|
||||
private setWsState(wsKey: WsKey, state: WsConnectionStateEnum) {
|
||||
this.wsStore.setConnectionState(wsKey, state);
|
||||
}
|
||||
|
||||
private getWsUrl(wsKey: WsKey): string {
|
||||
if (this.options.wsUrl) {
|
||||
return this.options.wsUrl;
|
||||
}
|
||||
|
||||
const networkKey = 'livenet';
|
||||
|
||||
switch (wsKey) {
|
||||
case WS_KEY_MAP.spotv1: {
|
||||
return WS_BASE_URL_MAP.spotv1.all[networkKey];
|
||||
}
|
||||
case WS_KEY_MAP.mixv1: {
|
||||
return WS_BASE_URL_MAP.mixv1.all[networkKey];
|
||||
}
|
||||
default: {
|
||||
this.logger.error('getWsUrl(): Unhandled wsKey: ', {
|
||||
...LOGGER_CATEGORY,
|
||||
wsKey,
|
||||
});
|
||||
throw neverGuard(wsKey, `getWsUrl(): Unhandled wsKey`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to a topic
|
||||
* @param instType instrument type (refer to API docs).
|
||||
* @param topic topic name (e.g. "ticker").
|
||||
* @param instId instrument ID (e.g. "BTCUSDT"). Use "default" for private topics.
|
||||
*/
|
||||
public subscribeTopic(
|
||||
instType: BitgetInstType,
|
||||
topic: WsTopic,
|
||||
instId: string = 'default'
|
||||
) {
|
||||
return this.subscribe({
|
||||
instType,
|
||||
instId,
|
||||
channel: topic,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Unsubscribe from a topic
|
||||
* @param instType instrument type (refer to API docs).
|
||||
* @param topic topic name (e.g. "ticker").
|
||||
* @param instId instrument ID (e.g. "BTCUSDT"). Use "default" for private topics to get all symbols.
|
||||
*/
|
||||
public unsubscribeTopic(
|
||||
instType: BitgetInstType,
|
||||
topic: WsTopic,
|
||||
instId: string = 'default'
|
||||
) {
|
||||
return this.unsubscribe({
|
||||
instType,
|
||||
instId,
|
||||
channel: topic,
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user