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)

299 lines (272 loc) 10.8 kB
'use strict'; import socketIO from 'socket.io-client'; import LoggerManager, {Logger} from '../../logger'; import type MetaApiWebsocketClient from './metaApiWebsocket.client'; /** * Service for managing account replicas based on region latency */ export default class LatencyService { private _websocketClient: MetaApiWebsocketClient; private _token: any; private _connectTimeout: any; private _latencyCache: {}; private _connectedInstancesCache: {}; private _synchronizedInstancesCache: {}; private _refreshPromisesByRegion: {}; private _waitConnectPromises: {}; private _logger: Logger; private _refreshRegionLatencyInterval: NodeJS.Timeout; /** * Constructs latency service instance * @param {MetaApiWebsocketClient} websocketClient MetaApi websocket client * @param {String} token authorization token * @param {Number} connectTimeout websocket connect timeout in seconds */ constructor(websocketClient, token, connectTimeout) { this._websocketClient = websocketClient; this._token = token; this._connectTimeout = connectTimeout; this._latencyCache = {}; this._connectedInstancesCache = {}; this._synchronizedInstancesCache = {}; this._refreshPromisesByRegion = {}; this._waitConnectPromises = {}; this._logger = LoggerManager.getLogger('LatencyService'); this._refreshRegionLatencyJob = this._refreshRegionLatencyJob.bind(this); this._refreshRegionLatencyInterval = setInterval(this._refreshRegionLatencyJob, 15 * 60 * 1000); } /** * Stops the service */ stop() { clearInterval(this._refreshRegionLatencyInterval); } /** * Returns the list of regions sorted by latency * @returns {String[]} list of regions sorted by latency */ get regionsSortedByLatency() { const regions = Object.keys(this._latencyCache); regions.sort((a, b) => this._latencyCache[a] - this._latencyCache[b]); return regions; } /** * Invoked when an instance has been disconnected * @param {String} instanceId instance id */ onDisconnected(instanceId) { try { const accountId = this._getAccountIdFromInstance(instanceId); const disconnectedRegion = this._getRegionFromInstance(instanceId); this._disconnectInstance(instanceId); const instances = this._getAccountInstances(accountId); if (!instances.map(instance => this._connectedInstancesCache[instance]).includes(true)) { const regions = this._getAccountRegions(accountId); regions.filter(region => region !== disconnectedRegion) .forEach(region => this._subscribeAccountReplica(accountId, region)); } } catch (err) { this._logger.error(`Failed to process onDisconnected event for instance ${instanceId}`, err); } } /** * Invoked when an account has been unsubscribed * @param {String} accountId account id */ onUnsubscribe(accountId) { try { const region = this._websocketClient.getAccountRegion(accountId); const primaryAccountId = this._websocketClient.accountsByReplicaId[accountId]; const instances = this._getAccountInstances(primaryAccountId); instances.filter(instanceId => instanceId.startsWith(`${primaryAccountId}:${region}:`)) .forEach(instanceId => this._disconnectInstance(instanceId)); } catch (err) { this._logger.error(`Failed to process onUnsubscribe event for account ${accountId}`, err); } } /** * Invoked when an instance has been connected * @param {String} instanceId instance id */ async onConnected(instanceId) { try { this._connectedInstancesCache[instanceId] = true; const accountId = this._getAccountIdFromInstance(instanceId); const region = this._getRegionFromInstance(instanceId); if (!this._latencyCache[region]) { await this._refreshLatency(region); } const instances = this.getActiveAccountInstances(accountId); const synchronizedInstances = this.getSynchronizedAccountInstances(accountId); const regions = instances.map(instance => this._getRegionFromInstance(instance)); if (instances.length > 1 && !synchronizedInstances.length) { const regionsToDisconnect = this.regionsSortedByLatency .filter(sortedRegion => regions.includes(sortedRegion)).slice(1); regionsToDisconnect.forEach(regionItem => { this._websocketClient.unsubscribe(this._websocketClient.accountReplicas[accountId][regionItem]); this._websocketClient.unsubscribeAccountRegion(accountId, regionItem); }); } if (this._waitConnectPromises[accountId]) { this._waitConnectPromises[accountId].resolve(); delete this._waitConnectPromises[accountId]; } } catch (err) { this._logger.error(`Failed to process onConnected event for instance ${instanceId}`, err); } } /** * Invoked when an instance has been synchronized * @param {String} instanceId instance id */ async onDealsSynchronized(instanceId) { try { this._synchronizedInstancesCache[instanceId] = true; const accountId = this._getAccountIdFromInstance(instanceId); const region = this._getRegionFromInstance(instanceId); if (!this._latencyCache[region]) { await this._refreshLatency(region); } const instances = this.getSynchronizedAccountInstances(accountId); const regions = [...new Set(instances.map(instance => this._getRegionFromInstance(instance)))]; if (instances.length > 1) { const regionsToDisconnect = this.regionsSortedByLatency .filter(sortedRegion => regions.includes(sortedRegion)).slice(1); regionsToDisconnect.forEach(regionItem => { this._websocketClient.unsubscribe(this._websocketClient.accountReplicas[accountId][regionItem]); this._websocketClient.unsubscribeAccountRegion(accountId, regionItem); }); } } catch (err) { this._logger.error(`Failed to process onDealsSynchronized event for instance ${instanceId}`, err); } } /** * Returns the list of currently connected account instances * @param {String} accountId account id * @returns {String[]} list of connected account instances */ getActiveAccountInstances(accountId) { return this._getAccountInstances(accountId).filter(instance => this._connectedInstancesCache[instance]); } /** * Returns the list of currently synchronized account instances * @param {String} accountId account id * @returns {String[]} list of synchronized account instances */ getSynchronizedAccountInstances(accountId) { return this._getAccountInstances(accountId).filter(instance => this._synchronizedInstancesCache[instance]); } /** * Waits for connected instance * @param {String} accountId account id * @returns {String} instance id */ async waitConnectedInstance(accountId) { let instances = this.getActiveAccountInstances(accountId); if (!instances.length) { if (!this._waitConnectPromises[accountId]) { let resolve; let promise = new Promise((res, rej) => { resolve = res; }); this._waitConnectPromises[accountId] = {promise, resolve}; } await this._waitConnectPromises[accountId].promise; instances = this.getActiveAccountInstances(accountId); } return instances[0]; } _getAccountInstances(accountId) { return Object.keys(this._connectedInstancesCache).filter(instanceId => instanceId.startsWith(`${accountId}:`)); } _getAccountRegions(accountId) { const regions = []; const instances = this._getAccountInstances(accountId); instances.forEach(instance => { const region = this._getRegionFromInstance(instance); if (!regions.includes(region)) { regions.push(region); } }); return regions; } _getAccountIdFromInstance(instanceId) { return instanceId.split(':')[0]; } _getRegionFromInstance(instanceId) { return instanceId.split(':')[1]; } _disconnectInstance(instanceId) { this._connectedInstancesCache[instanceId] = false; if (this._synchronizedInstancesCache[instanceId]) { this._synchronizedInstancesCache[instanceId] = false; } } _subscribeAccountReplica(accountId, region) { const instanceId = this._websocketClient.accountReplicas[accountId][region]; if (instanceId) { this._websocketClient.ensureSubscribe(instanceId, 0); this._websocketClient.ensureSubscribe(instanceId, 1); } } async _refreshRegionLatencyJob() { for(let region of Object.keys(this._latencyCache)) { await this._refreshLatency(region); } // For every account, switch to a better region if such exists const accountIds = []; Object.keys(this._connectedInstancesCache) .filter(instanceId => this._connectedInstancesCache[instanceId]) .forEach(instanceId => { const accountId = this._getAccountIdFromInstance(instanceId); if (!accountIds.includes(accountId)) { accountIds.push(accountId); } }); const sortedRegions = this.regionsSortedByLatency; accountIds.forEach(accountId => { const accountRegions = this._getAccountRegions(accountId); const activeInstances = this.getActiveAccountInstances(accountId); if (activeInstances.length === 1) { const activeInstance = activeInstances[0]; const activeRegion = this._getRegionFromInstance(activeInstance); const accountBestRegions = sortedRegions.filter(region => accountRegions.includes(region)); if (accountBestRegions[0] !== activeRegion) { this._subscribeAccountReplica(accountId, accountBestRegions[0]); } } }); } async _refreshLatency(region) { if (this._refreshPromisesByRegion[region]) { return await this._refreshPromisesByRegion[region]; } let resolve; this._refreshPromisesByRegion[region] = new Promise((res, rej) => { resolve = res; }); const serverUrl = await this._websocketClient.getUrlSettings(0, region); const startDate = Date.now(); const socketInstance = socketIO(serverUrl.url, { path: '/ws', reconnection: true, reconnectionDelay: 1000, reconnectionDelayMax: 5000, reconnectionAttempts: Infinity, timeout: this._connectTimeout, query: { 'auth-token': this._token, protocol: 3 } }); socketInstance.on('connect', async () => { resolve(); const latency = Date.now() - startDate; this._latencyCache[region] = latency; socketInstance.close(); }); await this._refreshPromisesByRegion[region]; delete this._refreshPromisesByRegion[region]; } }