Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(): private ws support for spot #7

Merged
merged 2 commits into from
Jun 3, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 60 additions & 16 deletions examples/ws-private.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,23 @@
import { LogParams, WebsocketClient, WsTopic } from '../src';
/* eslint-disable @typescript-eslint/no-unused-vars */
import { LogParams, WebsocketClient } from '../src';
import { WsTopicRequest } from '../src/lib/websocket/websocket-util';

// eslint-disable-next-line @typescript-eslint/no-unused-vars
const account = {
key: process.env.API_KEY || 'apiKeyHere',
secret: process.env.API_SECRET || 'apiSecretHere',
apiApplicationId: process.env.API_APPLICATION_ID || 'apiMemoHere',
};

const customLogger = {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
trace: (...params: LogParams): void => {
// console.log('trace', ...params);
// console.log(new Date(), 'trace', ...params);
},
info: (...params: LogParams): void => {
console.log('info', ...params);
console.log(new Date(), 'info', ...params);
},
error: (...params: LogParams): void => {
console.error('error', ...params);
console.error(new Date(), 'error', ...params);
},
};

Expand All @@ -24,7 +26,6 @@ async function start() {
{
apiKey: account.key,
apiSecret: account.secret,
apiApplicationId: account.apiApplicationId,
},
customLogger,
);
Expand Down Expand Up @@ -56,7 +57,7 @@ async function start() {

// Reply to a request, e.g. "subscribe"/"unsubscribe"/"authenticate"
client.on('response', (data) => {
console.info('response: ', JSON.stringify(data));
console.info('server reply: ', JSON.stringify(data), '\n');
});

client.on('exception', (data) => {
Expand All @@ -68,15 +69,58 @@ async function start() {
});

try {
const topics: WsTopic[] = [
'balance',
'algoexecutionreportv2',
'executionreport',
'marginassignment',
'position',
];

client.subscribe(topics);
// client.subscribe(topics, 'spotV4');

const topicWithoutParamsAsString = 'spot.balances';

// This has the same effect as above, it's just a different way of messaging which topic this is for
// const topicWithoutParamsAsObject: WsTopicRequest = {
// topic: 'spot.balances',
// };

const userOrders: WsTopicRequest = {
topic: 'spot.orders',
payload: ['BTC_USDT', 'ETH_USDT', 'MATIC_USDT'],
};

const userTrades: WsTopicRequest = {
topic: 'spot.usertrades',
payload: ['BTC_USDT', 'ETH_USDT', 'MATIC_USDT'],
};

const userPriceOrders: WsTopicRequest = {
topic: 'spot.priceorders',
payload: ['!all'],
};

/**
* Either send one topic (with optional params) at a time
*/
// client.subscribe(topicWithoutParamsAsObject, 'spotV4');

/**
* Or send multiple topics in a batch (grouped by ws connection (WsKey))
* You can also use strings for topics that don't have any parameters, even if you mix multiple requests into one function call:
*/
client.subscribe(
[topicWithoutParamsAsString, userOrders, userTrades, userPriceOrders],
'spotV4',
);

/**
* You can also subscribe in separate function calls as you wish.
*
* Any duplicate requests should get filtered out (e.g. here we subscribed to "spot.balances" twice, but the WS client will filter this out)
*/
client.subscribe(
[
'spot.balances',
'spot.margin_balances',
'spot.funding_balances',
'spot.cross_balances',
],
'spotV4',
);
} catch (e) {
console.error(`Req error: `, e);
}
Expand Down
4 changes: 2 additions & 2 deletions examples/ws-public.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,12 +61,12 @@ async function start() {
try {
const tickersRequestWithParams: WsTopicRequest = {
topic: 'spot.tickers',
params: ['BTC_USDT', 'ETH_USDT', 'MATIC_USDT'],
payload: ['BTC_USDT', 'ETH_USDT', 'MATIC_USDT'],
};

const rawTradesRequestWithParams: WsTopicRequest = {
topic: 'spot.trades',
params: ['BTC_USDT', 'ETH_USDT', 'MATIC_USDT'],
payload: ['BTC_USDT', 'ETH_USDT', 'MATIC_USDT'],
};

// const topicWithoutParamsAsString = 'spot.balances';
Expand Down
172 changes: 145 additions & 27 deletions src/WebsocketClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,11 @@ import WebSocket from 'isomorphic-ws';
import { BaseWebsocketClient, EmittableEvent } from './lib/BaseWSClient.js';
import { neverGuard } from './lib/misc-util.js';
import { MessageEventLike } from './lib/requestUtils.js';
import { signMessage } from './lib/webCryptoAPI.js';
import {
SignAlgorithm,
SignEncodeMethod,
signMessage,
} from './lib/webCryptoAPI.js';
import {
WS_BASE_URL_MAP,
WS_KEY_MAP,
Expand All @@ -30,6 +34,32 @@ export const PUBLIC_WS_KEYS: WsKey[] = [];
*/
export type WsTopic = string;

function getPrivateSpotTopics(): string[] {
// Consumeable channels for spot
const privateSpotTopics = [
'spot.orders',
'spot.usertrades',
'spot.balances',
'spot.margin_balances',
'spot.funding_balances',
'spot.cross_balances',
'spot.priceorders',
];

// WebSocket API for spot
const privateSpotWSAPITopics = [
'spot.login',
'spot.order_place',
'spot.order_cancel',
'spot.order_cancel_ids',
'spot.order_cancel_cp',
'spot.order_amend',
'spot.order_status',
];

return [...privateSpotTopics, ...privateSpotWSAPITopics];
}

// /**
// * Used to split sub/unsub logic by websocket connection. Groups & dedupes requests into per-WsKey arrays
// */
Expand Down Expand Up @@ -300,24 +330,30 @@ export class WebsocketClient extends BaseWebsocketClient<WsKey> {
/**
* Determines if a topic is for a private channel, using a hardcoded list of strings
*/
protected isPrivateTopicRequest(request: WsTopicRequest<string>): boolean {
protected isPrivateTopicRequest(
request: WsTopicRequest<string>,
wsKey: WsKey,
): boolean {
const topicName = request?.topic?.toLowerCase();
if (!topicName) {
return false;
}

const privateTopics = [
'todo',
'todo',
'todo',
'todo',
'todo',
'todo',
'todo',
];
switch (wsKey) {
case 'spotV4':
return getPrivateSpotTopics().includes(topicName);

if (topicName && privateTopics.includes(topicName)) {
return true;
// TODO:
case 'announcementsV4':
case 'deliveryFuturesBTCV4':
case 'deliveryFuturesUSDTV4':
case 'optionsV4':
case 'perpFuturesBTCV4':
case 'perpFuturesUSDTV4':
return getPrivateSpotTopics().includes(topicName);

default:
throw neverGuard(wsKey, `Unhandled WsKey "${wsKey}"`);
}

return false;
Expand Down Expand Up @@ -373,11 +409,11 @@ export class WebsocketClient extends BaseWebsocketClient<WsKey> {
/**
* Map one or more topics into fully prepared "subscribe request" events (already stringified and ready to send)
*/
protected getWsOperationEventsForTopics(
protected async getWsOperationEventsForTopics(
topics: WsTopicRequest<string>[],
wsKey: WsKey,
operation: WsOperation,
): string[] {
): Promise<string[]> {
// console.log(new Date(), `called getWsSubscribeEventsForTopics()`, topics);
// console.trace();
if (!topics.length) {
Expand All @@ -396,10 +432,11 @@ export class WebsocketClient extends BaseWebsocketClient<WsKey> {
) {
for (let i = 0; i < topics.length; i += maxTopicsPerEvent) {
const batch = topics.slice(i, i + maxTopicsPerEvent);
const subscribeRequestEvents = this.getWsRequestEvent(
const subscribeRequestEvents = await this.getWsRequestEvents(
market,
operation,
batch,
wsKey,
);

for (const event of subscribeRequestEvents) {
Expand All @@ -410,10 +447,11 @@ export class WebsocketClient extends BaseWebsocketClient<WsKey> {
return jsonStringEvents;
}

const subscribeRequestEvents = this.getWsRequestEvent(
const subscribeRequestEvents = await this.getWsRequestEvents(
market,
operation,
topics,
wsKey,
);

for (const event of subscribeRequestEvents) {
Expand All @@ -425,33 +463,113 @@ export class WebsocketClient extends BaseWebsocketClient<WsKey> {
/**
* @returns a correctly structured events for performing an operation over WS. This can vary per exchange spec.
*/
private getWsRequestEvent(
private async getWsRequestEvents(
market: WsMarket,
operation: WsOperation,
requests: WsTopicRequest<string>[],
): WsRequestOperationGate<WsTopic>[] {
const timeInSeconds = +(Date.now() / 1000).toFixed(0);
wsKey: WsKey,
): Promise<WsRequestOperationGate<WsTopic>[]> {
const wsRequestEvents: WsRequestOperationGate<WsTopic>[] = [];
const wsRequestBuildingErrors: unknown[] = [];

switch (market) {
case 'all': {
return requests.map((request) => {
const wsRequestEvent: WsRequestOperationGate<WsTopic> = {
for (const request of requests) {
const timeInSeconds = +(Date.now() / 1000).toFixed(0);

const wsEvent: WsRequestOperationGate<WsTopic> = {
time: timeInSeconds,
channel: request.topic,
event: operation,
// payload: 'todo',
};

if (request.params) {
wsRequestEvent.payload = request.params;
if (request.payload) {
wsEvent.payload = request.payload;
}

return wsRequestEvent;
});
if (!this.isPrivateTopicRequest(request, wsKey)) {
wsRequestEvents.push(wsEvent);
continue;
}

// If private topic request, build auth part for request

// No key or secret, push event as failed
if (!this.options.apiKey || !this.options.apiSecret) {
wsRequestBuildingErrors.push({
error: `apiKey or apiSecret missing from config`,
operation,
event: wsEvent,
});
continue;
}

const signAlgoritm: SignAlgorithm = 'SHA-512';
const signEncoding: SignEncodeMethod = 'hex';

const signInput = `channel=${wsEvent.channel}&event=${wsEvent.event}&time=${timeInSeconds}`;

try {
const sign = await this.signMessage(
signInput,
this.options.apiSecret,
signEncoding,
signAlgoritm,
);

wsEvent.auth = {
method: 'api_key',
KEY: this.options.apiKey,
SIGN: sign,
};

wsRequestEvents.push(wsEvent);
} catch (e) {
wsRequestBuildingErrors.push({
error: `exception during sign`,
errorTrace: e,
operation,
event: wsEvent,
});
}
}
break;
}
default: {
throw neverGuard(market, `Unhandled market "${market}"`);
}
}

if (wsRequestBuildingErrors.length) {
const label =
wsRequestBuildingErrors.length === requests.length ? 'all' : 'some';
this.logger.error(
`Failed to build/send ${wsRequestBuildingErrors.length} event(s) for ${label} WS requests due to exceptions`,
{
...WS_LOGGER_CATEGORY,
wsRequestBuildingErrors,
wsRequestBuildingErrorsStringified: JSON.stringify(
wsRequestBuildingErrors,
null,
2,
),
},
);
}

return wsRequestEvents;
}

private async signMessage(
paramsStr: string,
secret: string,
method: 'hex' | 'base64',
algorithm: SignAlgorithm,
): Promise<string> {
if (typeof this.options.customSignMessageFn === 'function') {
return this.options.customSignMessageFn(paramsStr, secret);
}
return await signMessage(paramsStr, secret, method, algorithm);
}

protected async getWsAuthRequestEvent(wsKey: WsKey): Promise<object> {
Expand Down
2 changes: 1 addition & 1 deletion src/lib/BaseRestClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -244,7 +244,7 @@ export abstract class BaseRestClient {
// Dispatch request
return axios(options)
.then((response) => {
if (response.status == 200) {
if (response.status == 200 || response.status == 201) {
// Throw API rejections by parsing the response code from the body
if (
typeof response.data?.code === 'number' &&
Expand Down
Loading