hedgequantx 1.2.34 → 1.2.36
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -1
- package/src/app.js +204 -6
- package/src/config/propfirms.js +8 -0
- package/src/services/rithmic/connection.js +203 -0
- package/src/services/rithmic/constants.js +156 -0
- package/src/services/rithmic/index.js +487 -0
- package/src/services/rithmic/proto/account_pnl_position_update.proto +59 -0
- package/src/services/rithmic/proto/base.proto +7 -0
- package/src/services/rithmic/proto/best_bid_offer.proto +39 -0
- package/src/services/rithmic/proto/exchange_order_notification.proto +140 -0
- package/src/services/rithmic/proto/instrument_pnl_position_update.proto +50 -0
- package/src/services/rithmic/proto/last_trade.proto +53 -0
- package/src/services/rithmic/proto/request_account_list.proto +20 -0
- package/src/services/rithmic/proto/request_cancel_all_orders.proto +15 -0
- package/src/services/rithmic/proto/request_heartbeat.proto +13 -0
- package/src/services/rithmic/proto/request_login.proto +28 -0
- package/src/services/rithmic/proto/request_login_info.proto +10 -0
- package/src/services/rithmic/proto/request_logout.proto +10 -0
- package/src/services/rithmic/proto/request_market_data_update.proto +42 -0
- package/src/services/rithmic/proto/request_new_order.proto +84 -0
- package/src/services/rithmic/proto/request_pnl_position_snapshot.proto +14 -0
- package/src/services/rithmic/proto/request_pnl_position_updates.proto +20 -0
- package/src/services/rithmic/proto/request_rithmic_system_info.proto +8 -0
- package/src/services/rithmic/proto/request_show_order_history.proto +16 -0
- package/src/services/rithmic/proto/request_show_order_history_dates.proto +10 -0
- package/src/services/rithmic/proto/request_show_order_history_summary.proto +14 -0
- package/src/services/rithmic/proto/request_show_orders.proto +14 -0
- package/src/services/rithmic/proto/request_subscribe_for_order_updates.proto +14 -0
- package/src/services/rithmic/proto/request_tick_bar_replay.proto +48 -0
- package/src/services/rithmic/proto/request_trade_routes.proto +11 -0
- package/src/services/rithmic/proto/response_account_list.proto +18 -0
- package/src/services/rithmic/proto/response_heartbeat.proto +14 -0
- package/src/services/rithmic/proto/response_login.proto +18 -0
- package/src/services/rithmic/proto/response_login_info.proto +24 -0
- package/src/services/rithmic/proto/response_logout.proto +11 -0
- package/src/services/rithmic/proto/response_market_data_update.proto +9 -0
- package/src/services/rithmic/proto/response_new_order.proto +18 -0
- package/src/services/rithmic/proto/response_pnl_position_snapshot.proto +11 -0
- package/src/services/rithmic/proto/response_pnl_position_updates.proto +11 -0
- package/src/services/rithmic/proto/response_rithmic_system_info.proto +12 -0
- package/src/services/rithmic/proto/response_show_order_history.proto +11 -0
- package/src/services/rithmic/proto/response_show_order_history_dates.proto +13 -0
- package/src/services/rithmic/proto/response_show_order_history_summary.proto +11 -0
- package/src/services/rithmic/proto/response_show_orders.proto +11 -0
- package/src/services/rithmic/proto/response_subscribe_for_order_updates.proto +11 -0
- package/src/services/rithmic/proto/response_tick_bar_replay.proto +40 -0
- package/src/services/rithmic/proto/response_trade_routes.proto +19 -0
- package/src/services/rithmic/proto/rithmic_order_notification.proto +124 -0
- package/src/services/rithmic/protobuf.js +259 -0
- package/src/services/tradovate/constants.js +109 -0
- package/src/services/tradovate/index.js +508 -0
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tradovate Constants
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
// Base URLs
|
|
6
|
+
const TRADOVATE_URLS = {
|
|
7
|
+
// Live Trading
|
|
8
|
+
LIVE_API: 'https://live.tradovateapi.com/v1',
|
|
9
|
+
LIVE_MD_WS: 'wss://md.tradovateapi.com/v1/websocket',
|
|
10
|
+
LIVE_TRADING_WS: 'wss://live.tradovateapi.com/v1/websocket',
|
|
11
|
+
|
|
12
|
+
// Demo/Simulation Trading
|
|
13
|
+
DEMO_API: 'https://demo.tradovateapi.com/v1',
|
|
14
|
+
DEMO_MD_WS: 'wss://md-demo.tradovateapi.com/v1/websocket',
|
|
15
|
+
DEMO_TRADING_WS: 'wss://demo.tradovateapi.com/v1/websocket',
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
// API Paths
|
|
19
|
+
const API_PATHS = {
|
|
20
|
+
// Authentication
|
|
21
|
+
AUTH_TOKEN_REQUEST: '/auth/accesstokenrequest',
|
|
22
|
+
AUTH_RENEW_TOKEN: '/auth/renewaccesstoken',
|
|
23
|
+
AUTH_ME: '/auth/me',
|
|
24
|
+
|
|
25
|
+
// Account
|
|
26
|
+
ACCOUNT_LIST: '/account/list',
|
|
27
|
+
ACCOUNT_FIND: '/account/find',
|
|
28
|
+
ACCOUNT_ITEM: '/account/item',
|
|
29
|
+
|
|
30
|
+
// Cash Balance
|
|
31
|
+
CASH_BALANCE_LIST: '/cashBalance/list',
|
|
32
|
+
CASH_BALANCE_SNAPSHOT: '/cashBalance/getcashbalancesnapshot',
|
|
33
|
+
|
|
34
|
+
// Contract
|
|
35
|
+
CONTRACT_FIND: '/contract/find',
|
|
36
|
+
CONTRACT_ITEM: '/contract/item',
|
|
37
|
+
CONTRACT_SUGGEST: '/contract/suggest',
|
|
38
|
+
|
|
39
|
+
// Product
|
|
40
|
+
PRODUCT_LIST: '/product/list',
|
|
41
|
+
PRODUCT_FIND: '/product/find',
|
|
42
|
+
|
|
43
|
+
// Order
|
|
44
|
+
ORDER_LIST: '/order/list',
|
|
45
|
+
ORDER_PLACE: '/order/placeorder',
|
|
46
|
+
ORDER_MODIFY: '/order/modifyorder',
|
|
47
|
+
ORDER_CANCEL: '/order/cancelorder',
|
|
48
|
+
ORDER_LIQUIDATE_POSITION: '/order/liquidateposition',
|
|
49
|
+
|
|
50
|
+
// Position
|
|
51
|
+
POSITION_LIST: '/position/list',
|
|
52
|
+
POSITION_DEPS: '/position/deps',
|
|
53
|
+
|
|
54
|
+
// Fill
|
|
55
|
+
FILL_LIST: '/fill/list',
|
|
56
|
+
FILL_DEPS: '/fill/deps',
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
// WebSocket Events
|
|
60
|
+
const WS_EVENTS = {
|
|
61
|
+
CONNECTED: 'connected',
|
|
62
|
+
DISCONNECTED: 'disconnected',
|
|
63
|
+
ERROR: 'error',
|
|
64
|
+
QUOTE: 'md/quote',
|
|
65
|
+
DOM: 'md/dom',
|
|
66
|
+
ORDER: 'order',
|
|
67
|
+
FILL: 'fill',
|
|
68
|
+
POSITION: 'position',
|
|
69
|
+
ACCOUNT: 'account',
|
|
70
|
+
CASH_BALANCE: 'cashBalance',
|
|
71
|
+
HEARTBEAT: 'heartbeat',
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
// Token config
|
|
75
|
+
const TOKEN_CONFIG = {
|
|
76
|
+
EXPIRATION_MINUTES: 90,
|
|
77
|
+
RENEW_BEFORE_MINUTES: 15,
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Get base URL for Tradovate API
|
|
82
|
+
*/
|
|
83
|
+
function getBaseUrl(isDemo = true) {
|
|
84
|
+
return isDemo ? TRADOVATE_URLS.DEMO_API : TRADOVATE_URLS.LIVE_API;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Get WebSocket URL for trading
|
|
89
|
+
*/
|
|
90
|
+
function getTradingWebSocketUrl(isDemo = true) {
|
|
91
|
+
return isDemo ? TRADOVATE_URLS.DEMO_TRADING_WS : TRADOVATE_URLS.LIVE_TRADING_WS;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Get WebSocket URL for market data
|
|
96
|
+
*/
|
|
97
|
+
function getMdWebSocketUrl(isDemo = true) {
|
|
98
|
+
return isDemo ? TRADOVATE_URLS.DEMO_MD_WS : TRADOVATE_URLS.LIVE_MD_WS;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
module.exports = {
|
|
102
|
+
TRADOVATE_URLS,
|
|
103
|
+
API_PATHS,
|
|
104
|
+
WS_EVENTS,
|
|
105
|
+
TOKEN_CONFIG,
|
|
106
|
+
getBaseUrl,
|
|
107
|
+
getTradingWebSocketUrl,
|
|
108
|
+
getMdWebSocketUrl,
|
|
109
|
+
};
|
|
@@ -0,0 +1,508 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tradovate Service
|
|
3
|
+
* Main service for Tradovate prop firm connections (Apex, TakeProfitTrader)
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const https = require('https');
|
|
7
|
+
const WebSocket = require('ws');
|
|
8
|
+
const EventEmitter = require('events');
|
|
9
|
+
const { TRADOVATE_URLS, API_PATHS, WS_EVENTS, getBaseUrl, getTradingWebSocketUrl } = require('./constants');
|
|
10
|
+
|
|
11
|
+
class TradovateService extends EventEmitter {
|
|
12
|
+
constructor(propfirmKey) {
|
|
13
|
+
super();
|
|
14
|
+
this.propfirmKey = propfirmKey;
|
|
15
|
+
this.propfirm = this.getPropFirmConfig(propfirmKey);
|
|
16
|
+
this.accessToken = null;
|
|
17
|
+
this.mdAccessToken = null;
|
|
18
|
+
this.userId = null;
|
|
19
|
+
this.tokenExpiration = null;
|
|
20
|
+
this.accounts = [];
|
|
21
|
+
this.user = null;
|
|
22
|
+
this.isDemo = true; // Default to demo
|
|
23
|
+
this.ws = null;
|
|
24
|
+
this.renewalTimer = null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Get PropFirm configuration
|
|
29
|
+
*/
|
|
30
|
+
getPropFirmConfig(key) {
|
|
31
|
+
const propfirms = {
|
|
32
|
+
'apex_tradovate': { name: 'Apex (Tradovate)', isDemo: false, defaultBalance: 300000 },
|
|
33
|
+
'takeprofittrader': { name: 'TakeProfitTrader', isDemo: false, defaultBalance: 150000 },
|
|
34
|
+
'myfundedfutures': { name: 'MyFundedFutures', isDemo: false, defaultBalance: 150000 },
|
|
35
|
+
};
|
|
36
|
+
return propfirms[key] || { name: key, isDemo: false, defaultBalance: 150000 };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Login to Tradovate
|
|
41
|
+
* @param {string} username - Tradovate username
|
|
42
|
+
* @param {string} password - Tradovate password
|
|
43
|
+
* @param {object} options - Optional { cid, sec } for API key auth
|
|
44
|
+
*/
|
|
45
|
+
async login(username, password, options = {}) {
|
|
46
|
+
try {
|
|
47
|
+
const authData = {
|
|
48
|
+
name: username,
|
|
49
|
+
password: password,
|
|
50
|
+
appId: 'HQX-CLI',
|
|
51
|
+
appVersion: '1.0.0',
|
|
52
|
+
deviceId: this.generateDeviceId(),
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
// Add API key if provided
|
|
56
|
+
if (options.cid) authData.cid = options.cid;
|
|
57
|
+
if (options.sec) authData.sec = options.sec;
|
|
58
|
+
|
|
59
|
+
const result = await this._request(API_PATHS.AUTH_TOKEN_REQUEST, 'POST', authData);
|
|
60
|
+
|
|
61
|
+
if (result.errorText) {
|
|
62
|
+
return { success: false, error: result.errorText };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (!result.accessToken) {
|
|
66
|
+
return { success: false, error: 'No access token received' };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
this.accessToken = result.accessToken;
|
|
70
|
+
this.mdAccessToken = result.mdAccessToken;
|
|
71
|
+
this.userId = result.userId;
|
|
72
|
+
this.tokenExpiration = new Date(result.expirationTime);
|
|
73
|
+
this.user = { userName: result.name, userId: result.userId };
|
|
74
|
+
|
|
75
|
+
// Setup token renewal
|
|
76
|
+
this.setupTokenRenewal();
|
|
77
|
+
|
|
78
|
+
// Fetch accounts
|
|
79
|
+
await this.fetchAccounts();
|
|
80
|
+
|
|
81
|
+
return { success: true };
|
|
82
|
+
|
|
83
|
+
} catch (error) {
|
|
84
|
+
return { success: false, error: error.message };
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Fetch accounts
|
|
90
|
+
*/
|
|
91
|
+
async fetchAccounts() {
|
|
92
|
+
try {
|
|
93
|
+
const accounts = await this._request(API_PATHS.ACCOUNT_LIST, 'GET');
|
|
94
|
+
|
|
95
|
+
if (Array.isArray(accounts)) {
|
|
96
|
+
this.accounts = accounts;
|
|
97
|
+
|
|
98
|
+
// Fetch cash balance for each account
|
|
99
|
+
for (const acc of this.accounts) {
|
|
100
|
+
try {
|
|
101
|
+
const cashBalance = await this._request(
|
|
102
|
+
API_PATHS.CASH_BALANCE_SNAPSHOT,
|
|
103
|
+
'POST',
|
|
104
|
+
{ accountId: acc.id }
|
|
105
|
+
);
|
|
106
|
+
acc.cashBalance = cashBalance;
|
|
107
|
+
} catch (e) {
|
|
108
|
+
acc.cashBalance = null;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return this.accounts;
|
|
114
|
+
} catch (error) {
|
|
115
|
+
throw error;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Get trading accounts (formatted for HQX)
|
|
121
|
+
*/
|
|
122
|
+
async getTradingAccounts() {
|
|
123
|
+
if (this.accounts.length === 0) {
|
|
124
|
+
await this.fetchAccounts();
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const tradingAccounts = this.accounts.map((acc) => {
|
|
128
|
+
const cb = acc.cashBalance || {};
|
|
129
|
+
const balance = cb.totalCashValue || cb.netLiquidatingValue || this.propfirm.defaultBalance;
|
|
130
|
+
const startingBalance = this.propfirm.defaultBalance;
|
|
131
|
+
const profitAndLoss = cb.totalPnL || (balance - startingBalance);
|
|
132
|
+
const openPnL = cb.openPnL || 0;
|
|
133
|
+
|
|
134
|
+
return {
|
|
135
|
+
accountId: acc.id,
|
|
136
|
+
tradovateAccountId: acc.id,
|
|
137
|
+
accountName: acc.name,
|
|
138
|
+
name: acc.name,
|
|
139
|
+
balance: balance,
|
|
140
|
+
startingBalance: startingBalance,
|
|
141
|
+
profitAndLoss: profitAndLoss,
|
|
142
|
+
openPnL: openPnL,
|
|
143
|
+
status: acc.active ? 0 : 3, // 0=Active, 3=Inactive
|
|
144
|
+
platform: 'Tradovate',
|
|
145
|
+
propfirm: this.propfirm.name,
|
|
146
|
+
accountType: acc.accountType, // 'Customer' or 'Demo'
|
|
147
|
+
};
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
return { success: true, accounts: tradingAccounts };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Get positions for an account
|
|
155
|
+
*/
|
|
156
|
+
async getPositions(accountId) {
|
|
157
|
+
try {
|
|
158
|
+
const positions = await this._request(API_PATHS.POSITION_DEPS, 'GET', null, { masterid: accountId });
|
|
159
|
+
return positions.filter(p => p.netPos !== 0);
|
|
160
|
+
} catch (error) {
|
|
161
|
+
return [];
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Get fills/trades
|
|
167
|
+
*/
|
|
168
|
+
async getFills() {
|
|
169
|
+
try {
|
|
170
|
+
return await this._request(API_PATHS.FILL_LIST, 'GET');
|
|
171
|
+
} catch (error) {
|
|
172
|
+
return [];
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Place an order
|
|
178
|
+
*/
|
|
179
|
+
async placeOrder(orderData) {
|
|
180
|
+
try {
|
|
181
|
+
const result = await this._request(API_PATHS.ORDER_PLACE, 'POST', {
|
|
182
|
+
accountId: orderData.accountId,
|
|
183
|
+
action: orderData.side === 0 ? 'Buy' : 'Sell',
|
|
184
|
+
symbol: orderData.symbol,
|
|
185
|
+
orderQty: orderData.size,
|
|
186
|
+
orderType: orderData.type === 2 ? 'Market' : 'Limit',
|
|
187
|
+
price: orderData.price,
|
|
188
|
+
isAutomated: true,
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
if (result.errorText || result.failureReason) {
|
|
192
|
+
return { success: false, error: result.errorText || result.failureText };
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
return { success: true, orderId: result.orderId };
|
|
196
|
+
} catch (error) {
|
|
197
|
+
return { success: false, error: error.message };
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Cancel an order
|
|
203
|
+
*/
|
|
204
|
+
async cancelOrder(orderId) {
|
|
205
|
+
try {
|
|
206
|
+
const result = await this._request(API_PATHS.ORDER_CANCEL, 'POST', {
|
|
207
|
+
orderId: orderId,
|
|
208
|
+
isAutomated: true,
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
if (result.errorText) {
|
|
212
|
+
return { success: false, error: result.errorText };
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
return { success: true };
|
|
216
|
+
} catch (error) {
|
|
217
|
+
return { success: false, error: error.message };
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Close a position
|
|
223
|
+
*/
|
|
224
|
+
async closePosition(accountId, contractId) {
|
|
225
|
+
try {
|
|
226
|
+
const result = await this._request(API_PATHS.ORDER_LIQUIDATE_POSITION, 'POST', {
|
|
227
|
+
accountId: accountId,
|
|
228
|
+
contractId: contractId,
|
|
229
|
+
isAutomated: true,
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
if (result.errorText) {
|
|
233
|
+
return { success: false, error: result.errorText };
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
return { success: true };
|
|
237
|
+
} catch (error) {
|
|
238
|
+
return { success: false, error: error.message };
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Search contracts
|
|
244
|
+
*/
|
|
245
|
+
async searchContracts(text, limit = 10) {
|
|
246
|
+
try {
|
|
247
|
+
return await this._request(API_PATHS.CONTRACT_SUGGEST, 'GET', null, { t: text, l: limit });
|
|
248
|
+
} catch (error) {
|
|
249
|
+
return [];
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Get user info
|
|
255
|
+
*/
|
|
256
|
+
async getUser() {
|
|
257
|
+
return this.user;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Check market hours (same logic as ProjectX)
|
|
262
|
+
*/
|
|
263
|
+
checkMarketHours() {
|
|
264
|
+
const now = new Date();
|
|
265
|
+
const utcDay = now.getUTCDay();
|
|
266
|
+
const utcHour = now.getUTCHours();
|
|
267
|
+
|
|
268
|
+
const ctOffset = this.isDST(now) ? 5 : 6;
|
|
269
|
+
const ctHour = (utcHour - ctOffset + 24) % 24;
|
|
270
|
+
const ctDay = utcHour < ctOffset ? (utcDay + 6) % 7 : utcDay;
|
|
271
|
+
|
|
272
|
+
if (ctDay === 6) {
|
|
273
|
+
return { isOpen: false, message: 'Market closed (Saturday)' };
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
if (ctDay === 0 && ctHour < 17) {
|
|
277
|
+
return { isOpen: false, message: 'Market opens Sunday 5:00 PM CT' };
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
if (ctDay === 5 && ctHour >= 16) {
|
|
281
|
+
return { isOpen: false, message: 'Market closed (Friday after 4PM CT)' };
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
if (ctHour === 16 && ctDay >= 1 && ctDay <= 4) {
|
|
285
|
+
return { isOpen: false, message: 'Daily maintenance (4:00-5:00 PM CT)' };
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
return { isOpen: true, message: 'Market is open' };
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
isDST(date) {
|
|
292
|
+
const jan = new Date(date.getFullYear(), 0, 1);
|
|
293
|
+
const jul = new Date(date.getFullYear(), 6, 1);
|
|
294
|
+
const stdOffset = Math.max(jan.getTimezoneOffset(), jul.getTimezoneOffset());
|
|
295
|
+
return date.getTimezoneOffset() < stdOffset;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* Setup automatic token renewal
|
|
300
|
+
*/
|
|
301
|
+
setupTokenRenewal() {
|
|
302
|
+
if (this.renewalTimer) {
|
|
303
|
+
clearTimeout(this.renewalTimer);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// Renew 15 minutes before expiration
|
|
307
|
+
const renewInMs = (90 - 15) * 60 * 1000;
|
|
308
|
+
|
|
309
|
+
this.renewalTimer = setTimeout(async () => {
|
|
310
|
+
try {
|
|
311
|
+
await this.renewToken();
|
|
312
|
+
} catch (error) {
|
|
313
|
+
// Silent fail
|
|
314
|
+
}
|
|
315
|
+
}, renewInMs);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* Renew access token
|
|
320
|
+
*/
|
|
321
|
+
async renewToken() {
|
|
322
|
+
if (!this.accessToken) return;
|
|
323
|
+
|
|
324
|
+
try {
|
|
325
|
+
const result = await this._request(API_PATHS.AUTH_RENEW_TOKEN, 'GET');
|
|
326
|
+
|
|
327
|
+
if (result.accessToken) {
|
|
328
|
+
this.accessToken = result.accessToken;
|
|
329
|
+
this.mdAccessToken = result.mdAccessToken;
|
|
330
|
+
this.tokenExpiration = new Date(result.expirationTime);
|
|
331
|
+
this.setupTokenRenewal();
|
|
332
|
+
}
|
|
333
|
+
} catch (error) {
|
|
334
|
+
// Silent fail - will need to re-login
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* Connect to WebSocket for real-time updates
|
|
340
|
+
*/
|
|
341
|
+
async connectWebSocket() {
|
|
342
|
+
return new Promise((resolve, reject) => {
|
|
343
|
+
const wsUrl = getTradingWebSocketUrl(this.isDemo);
|
|
344
|
+
this.ws = new WebSocket(wsUrl);
|
|
345
|
+
|
|
346
|
+
this.ws.on('open', () => {
|
|
347
|
+
// Authorize
|
|
348
|
+
this.wsSend('authorize', '', { token: this.accessToken });
|
|
349
|
+
resolve(true);
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
this.ws.on('message', (data) => {
|
|
353
|
+
this.handleWsMessage(data);
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
this.ws.on('error', (err) => {
|
|
357
|
+
this.emit('error', err);
|
|
358
|
+
reject(err);
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
this.ws.on('close', () => {
|
|
362
|
+
this.emit('disconnected');
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
setTimeout(() => reject(new Error('WebSocket timeout')), 10000);
|
|
366
|
+
});
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/**
|
|
370
|
+
* Send WebSocket message
|
|
371
|
+
*/
|
|
372
|
+
wsSend(url, query = '', body = null) {
|
|
373
|
+
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) return;
|
|
374
|
+
|
|
375
|
+
const msg = body
|
|
376
|
+
? `${url}\n${this.wsRequestId++}\n${query}\n${JSON.stringify(body)}`
|
|
377
|
+
: `${url}\n${this.wsRequestId++}\n${query}\n`;
|
|
378
|
+
|
|
379
|
+
this.ws.send(msg);
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
wsRequestId = 1;
|
|
383
|
+
|
|
384
|
+
/**
|
|
385
|
+
* Handle WebSocket message
|
|
386
|
+
*/
|
|
387
|
+
handleWsMessage(data) {
|
|
388
|
+
try {
|
|
389
|
+
const str = data.toString();
|
|
390
|
+
|
|
391
|
+
// Tradovate WS format: frame\nid\ndata
|
|
392
|
+
if (str.startsWith('a')) {
|
|
393
|
+
const json = JSON.parse(str.slice(1));
|
|
394
|
+
if (Array.isArray(json)) {
|
|
395
|
+
json.forEach(msg => this.processWsEvent(msg));
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
} catch (e) {
|
|
399
|
+
// Ignore parse errors
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/**
|
|
404
|
+
* Process WebSocket event
|
|
405
|
+
*/
|
|
406
|
+
processWsEvent(msg) {
|
|
407
|
+
if (msg.e === 'props') {
|
|
408
|
+
// User data sync
|
|
409
|
+
if (msg.d?.orders) this.emit(WS_EVENTS.ORDER, msg.d.orders);
|
|
410
|
+
if (msg.d?.positions) this.emit(WS_EVENTS.POSITION, msg.d.positions);
|
|
411
|
+
if (msg.d?.cashBalances) this.emit(WS_EVENTS.CASH_BALANCE, msg.d.cashBalances);
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/**
|
|
416
|
+
* Disconnect
|
|
417
|
+
*/
|
|
418
|
+
async disconnect() {
|
|
419
|
+
if (this.renewalTimer) {
|
|
420
|
+
clearTimeout(this.renewalTimer);
|
|
421
|
+
this.renewalTimer = null;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
if (this.ws) {
|
|
425
|
+
this.ws.close();
|
|
426
|
+
this.ws = null;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
this.accessToken = null;
|
|
430
|
+
this.mdAccessToken = null;
|
|
431
|
+
this.accounts = [];
|
|
432
|
+
this.user = null;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
/**
|
|
436
|
+
* Generate device ID
|
|
437
|
+
*/
|
|
438
|
+
generateDeviceId() {
|
|
439
|
+
const crypto = require('crypto');
|
|
440
|
+
const os = require('os');
|
|
441
|
+
const data = `${os.hostname()}-${os.platform()}-${os.arch()}-hqx-cli`;
|
|
442
|
+
return crypto.createHash('md5').update(data).digest('hex');
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/**
|
|
446
|
+
* HTTP request helper
|
|
447
|
+
*/
|
|
448
|
+
_request(path, method = 'GET', body = null, queryParams = null) {
|
|
449
|
+
return new Promise((resolve, reject) => {
|
|
450
|
+
const baseUrl = getBaseUrl(this.isDemo);
|
|
451
|
+
const url = new URL(baseUrl + path);
|
|
452
|
+
|
|
453
|
+
if (queryParams) {
|
|
454
|
+
Object.entries(queryParams).forEach(([key, value]) => {
|
|
455
|
+
url.searchParams.append(key, value);
|
|
456
|
+
});
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
const postData = body ? JSON.stringify(body) : null;
|
|
460
|
+
|
|
461
|
+
const options = {
|
|
462
|
+
hostname: url.hostname,
|
|
463
|
+
port: 443,
|
|
464
|
+
path: url.pathname + url.search,
|
|
465
|
+
method: method,
|
|
466
|
+
headers: {
|
|
467
|
+
'Content-Type': 'application/json',
|
|
468
|
+
'Accept': 'application/json',
|
|
469
|
+
},
|
|
470
|
+
};
|
|
471
|
+
|
|
472
|
+
if (postData) {
|
|
473
|
+
options.headers['Content-Length'] = Buffer.byteLength(postData);
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
if (this.accessToken) {
|
|
477
|
+
options.headers['Authorization'] = `Bearer ${this.accessToken}`;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
const req = https.request(options, (res) => {
|
|
481
|
+
let data = '';
|
|
482
|
+
res.on('data', chunk => data += chunk);
|
|
483
|
+
res.on('end', () => {
|
|
484
|
+
try {
|
|
485
|
+
const json = JSON.parse(data);
|
|
486
|
+
resolve(json);
|
|
487
|
+
} catch (e) {
|
|
488
|
+
resolve(data);
|
|
489
|
+
}
|
|
490
|
+
});
|
|
491
|
+
});
|
|
492
|
+
|
|
493
|
+
req.on('error', reject);
|
|
494
|
+
req.setTimeout(30000, () => {
|
|
495
|
+
req.destroy();
|
|
496
|
+
reject(new Error('Request timeout'));
|
|
497
|
+
});
|
|
498
|
+
|
|
499
|
+
if (postData) {
|
|
500
|
+
req.write(postData);
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
req.end();
|
|
504
|
+
});
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
module.exports = { TradovateService };
|