UNPKG

metaapi.cloud-sdk

Version:

SDK for MetaApi, a professional cloud forex API which includes MetaTrader REST API and MetaTrader websocket API. Supports both MetaTrader 5 (MT5) and MetaTrader 4 (MT4). CopyFactory copy trading API included. (https://metaapi.cloud)

321 lines (304 loc) 13.3 kB
'use strict'; import LoggerManager from '../../logger'; import MetaApiWebsocketClient from './metaApiWebsocket.client'; /** * Subscription manager to handle account subscription logic */ export default class SubscriptionManager { private _websocketClient: MetaApiWebsocketClient; private _latencyService: any; private _metaApi: any; private _subscriptions: {}; private _awaitingResubscribe: {}; private _subscriptionState: {}; private _logger: any; private _timeoutErrorCounter: {}; private _recentlyDeletedAccounts: {}; /** * Constructs the subscription manager * @param {MetaApiWebsocketClient} websocketClient websocket client to use for sending requests * @param {MetaApi} metaApi metaApi instance */ constructor(websocketClient, metaApi) { this._websocketClient = websocketClient; this._latencyService = websocketClient.latencyService; this._metaApi = metaApi; this._subscriptions = {}; this._awaitingResubscribe = {}; this._subscriptionState = {}; this._logger = LoggerManager.getLogger('SubscriptionManager'); this._timeoutErrorCounter = {}; this._recentlyDeletedAccounts = {}; } /** * Returns whether an account is currently subscribing * @param {String} accountId account id * @param {Number} instanceNumber instance index number * @returns {Boolean} whether an account is currently subscribing */ isAccountSubscribing(accountId, instanceNumber) { if (instanceNumber !== undefined) { return Object.keys(this._subscriptions).includes(accountId + ':' + instanceNumber); } else { for (let key of Object.keys(this._subscriptions)) { if (key.startsWith(accountId)) { return true; } } return false; } } /** * Returns whether an instance is in disconnected retry mode * @param {String} accountId account id * @param {Number} instanceNumber instance index number * @returns {Boolean} whether an account is currently subscribing */ isDisconnectedRetryMode(accountId, instanceNumber) { let instanceId = accountId + ':' + (instanceNumber || 0); return this._subscriptions[instanceId] ? this._subscriptions[instanceId].isDisconnectedRetryMode : false; } /** * Returns whether an account subscription is active * @param {String} accountId account id * @returns {Boolean} instance actual subscribe state */ isSubscriptionActive(accountId) { return !!this._subscriptionState[accountId]; } /** * Subscribes to the Metatrader terminal events * @param {String} accountId id of the MetaTrader account to subscribe to * @param {Number} instanceNumber instance index number * @returns {Promise} promise which resolves when subscription started */ subscribe(accountId, instanceNumber) { this._subscriptionState[accountId] = true; return this._websocketClient.rpcRequest(accountId, {type: 'subscribe', instanceIndex: instanceNumber}); } /** * Schedules to send subscribe requests to an account until cancelled * @param {String} accountId id of the MetaTrader account * @param {Number} instanceNumber instance index number * @param {Boolean} isDisconnectedRetryMode whether to start subscription in disconnected retry * mode. Subscription task in disconnected mode will be immediately replaced when the status packet is received */ async scheduleSubscribe(accountId, instanceNumber, isDisconnectedRetryMode = false) { const client = this._websocketClient; let instanceId = accountId + ':' + (instanceNumber || 0); if (!this._subscriptions[instanceId]) { this._subscriptions[instanceId] = { shouldRetry: true, task: null, waitTask: null, future: null, isDisconnectedRetryMode }; let subscribeRetryIntervalInSeconds = 3; while (this._subscriptions[instanceId].shouldRetry) { let resolveSubscribe; this._subscriptions[instanceId].task = {promise: new Promise((res) => { resolveSubscribe = res; })}; this._subscriptions[instanceId].task.resolve = resolveSubscribe; // eslint-disable-next-line no-inner-declarations, complexity let subscribeTask = async () => { try { this._logger.debug(`${accountId}:${instanceNumber}: running subscribe task`); await this.subscribe(accountId, instanceNumber); } catch (err) { if (err.name === 'TooManyRequestsError') { const socketInstanceIndex = client.socketInstancesByAccounts[instanceNumber][accountId]; if (err.metadata.type === 'LIMIT_ACCOUNT_SUBSCRIPTIONS_PER_USER') { this._logSubscriptionError(accountId, `${instanceId}: Failed to subscribe`, err); } if (['LIMIT_ACCOUNT_SUBSCRIPTIONS_PER_USER', 'LIMIT_ACCOUNT_SUBSCRIPTIONS_PER_SERVER', 'LIMIT_ACCOUNT_SUBSCRIPTIONS_PER_USER_PER_SERVER'].includes(err.metadata.type)) { delete client.socketInstancesByAccounts[instanceNumber][accountId]; client.lockSocketInstance(instanceNumber, socketInstanceIndex, this._websocketClient.getAccountRegion(accountId), err.metadata); } else { const retryTime = new Date(err.metadata.recommendedRetryTime).getTime(); if (Date.now() + subscribeRetryIntervalInSeconds * 1000 < retryTime) { await new Promise(res => setTimeout(res, retryTime - Date.now() - subscribeRetryIntervalInSeconds * 1000)); } } } else { this._logSubscriptionError(accountId, `${instanceId}: Failed to subscribe`, err); if (err.name === 'NotFoundError') { this.refreshAccount(accountId); } if (err.name === 'TimeoutError') { const mainAccountId = this._websocketClient.accountsByReplicaId[accountId]; if (mainAccountId) { const region = this._websocketClient.getAccountRegion(accountId); const connectedInstances = this._latencyService.getActiveAccountInstances(mainAccountId); // eslint-disable-next-line max-depth if (!connectedInstances.some(instance => instance.startsWith(`${mainAccountId}:${region}`))) { this._timeoutErrorCounter[accountId] = this._timeoutErrorCounter[accountId] || 0; this._timeoutErrorCounter[accountId]++; // eslint-disable-next-line max-depth if (this._timeoutErrorCounter[accountId] > 4) { this._timeoutErrorCounter[accountId] = 0; this.refreshAccount(accountId); } } } } } } resolveSubscribe(); }; subscribeTask(); await this._subscriptions[instanceId].task.promise; if (!this._subscriptions[instanceId].shouldRetry) { break; } const retryInterval = subscribeRetryIntervalInSeconds; subscribeRetryIntervalInSeconds = Math.min(subscribeRetryIntervalInSeconds * 2, 300); let resolve; let subscribePromise = new Promise((res) => { resolve = res; }); this._subscriptions[instanceId].waitTask = setTimeout(() => { resolve(true); }, retryInterval * 1000); this._subscriptions[instanceId].future = {resolve, promise: subscribePromise}; const result = await this._subscriptions[instanceId].future.promise; this._subscriptions[instanceId].future = null; if (!result) { break; } } delete this._subscriptions[instanceId]; } } /** * Unsubscribe from account * @param {String} accountId id of the MetaTrader account to unsubscribe * @param {Number} instanceNumber instance index number * @returns {Promise} promise which resolves when socket unsubscribed */ async unsubscribe(accountId, instanceNumber) { this.cancelAccount(accountId); delete this._subscriptionState[accountId]; return this._websocketClient.rpcRequest(accountId, {type: 'unsubscribe', instanceIndex: instanceNumber}); } /** * Cancels active subscription tasks for an instance id * @param {String} instanceId instance id to cancel subscription task for */ cancelSubscribe(instanceId) { if (this._subscriptions[instanceId]) { const subscription = this._subscriptions[instanceId]; if (subscription.future) { subscription.future.resolve(false); clearTimeout(subscription.waitTask); } if (subscription.task) { subscription.task.resolve(false); } subscription.shouldRetry = false; } } /** * Cancels active subscription tasks for an account * @param {String} accountId account id to cancel subscription tasks for */ cancelAccount(accountId) { for (let instanceId of Object.keys(this._subscriptions).filter(key => key.startsWith(accountId))) { this.cancelSubscribe(instanceId); } Object.keys(this._awaitingResubscribe).forEach(instanceNumber => delete this._awaitingResubscribe[instanceNumber][accountId]); delete this._timeoutErrorCounter[accountId]; } /** * Invoked on account timeout. * @param {String} accountId id of the MetaTrader account * @param {Number} instanceNumber instance index number */ onTimeout(accountId, instanceNumber) { const region = this._websocketClient.getAccountRegion(accountId); if ( this._websocketClient.socketInstancesByAccounts[instanceNumber][accountId] !== undefined && this._websocketClient.connected( instanceNumber, this._websocketClient.socketInstancesByAccounts[instanceNumber][accountId], region ) ) { this._logger.debug(`${accountId}:${instanceNumber}: scheduling subscribe because of account timeout`); this.scheduleSubscribe(accountId, instanceNumber, true); } } /** * Invoked when connection to MetaTrader terminal terminated * @param {String} accountId id of the MetaTrader account * @param {Number} instanceNumber instance index number */ async onDisconnected(accountId, instanceNumber) { await new Promise(res => setTimeout(res, Math.max(Math.random() * 5, 1) * 1000)); if (this._websocketClient.socketInstancesByAccounts[instanceNumber][accountId] !== undefined) { this._logger.debug(`${accountId}:${instanceNumber}: scheduling subscribe because account disconnected`); this.scheduleSubscribe(accountId, instanceNumber, true); } } /** * Invoked when connection to MetaApi websocket API restored after a disconnect. * @param {Number} instanceNumber instance index number * @param {Number} socketInstanceIndex socket instance index * @param {String[]} reconnectAccountIds account ids to reconnect */ onReconnected(instanceNumber, socketInstanceIndex, reconnectAccountIds) { if (!this._awaitingResubscribe[instanceNumber]) { this._awaitingResubscribe[instanceNumber] = {}; } const socketInstancesByAccounts = this._websocketClient.socketInstancesByAccounts[instanceNumber]; for(let instanceId of Object.keys(this._subscriptions)){ const accountId = instanceId.split(':')[0]; if (socketInstancesByAccounts[accountId] === socketInstanceIndex) { this.cancelSubscribe(instanceId); } } reconnectAccountIds.forEach(async accountId => { if (!this._awaitingResubscribe[instanceNumber][accountId]) { this._awaitingResubscribe[instanceNumber][accountId] = true; while (this.isAccountSubscribing(accountId, instanceNumber)) { await new Promise(res => setTimeout(res, 1000)); } await new Promise(res => setTimeout(res, Math.random() * 5000)); if (this._awaitingResubscribe[instanceNumber][accountId]) { delete this._awaitingResubscribe[instanceNumber][accountId]; this._logger.debug(`${accountId}:${instanceNumber}: scheduling subscribe because account reconnected`); this.scheduleSubscribe(accountId, instanceNumber); } } }); } /** * Schedules a task to refresh the account data * @param {string} accountId account id */ refreshAccount(accountId) { const mainAccountId = this._websocketClient.accountsByReplicaId[accountId]; if (mainAccountId) { const registry = this._metaApi._connectionRegistry; const rpcConnection = registry.rpcConnections[mainAccountId]; const region = this._websocketClient.getAccountRegion(accountId); if (region) { if (rpcConnection) { rpcConnection.scheduleRefresh(region); } const streamingConnection = registry.streamingConnections[mainAccountId]; if (streamingConnection) { streamingConnection.scheduleRefresh(region); } } } } _logSubscriptionError(accountId, message, error) { const primaryAccountId = this._websocketClient.accountsByReplicaId[accountId]; const method = this._latencyService.getSynchronizedAccountInstances(primaryAccountId).length ? 'debug' : 'error'; this._logger[method](message, error); } }