Skip to content

Commit

Permalink
Merge pull request #7 from tiagosiebler/privatews
Browse files Browse the repository at this point in the history
feat(): private ws support for spot
  • Loading branch information
tiagosiebler authored Jun 3, 2024
2 parents 691b220 + 77527e6 commit 72cd258
Show file tree
Hide file tree
Showing 7 changed files with 301 additions and 70 deletions.
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

0 comments on commit 72cd258

Please sign in to comment.