hedgequantx 1.2.35 → 1.2.37

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hedgequantx",
3
- "version": "1.2.35",
3
+ "version": "1.2.37",
4
4
  "description": "Prop Futures Algo Trading CLI - Connect to Topstep, Alpha Futures, and other prop firms",
5
5
  "main": "src/app.js",
6
6
  "bin": {
package/src/app.js CHANGED
@@ -12,6 +12,7 @@ const path = require('path');
12
12
 
13
13
  const { ProjectXService, connections } = require('./services');
14
14
  const { RithmicService } = require('./services/rithmic');
15
+ const { TradovateService } = require('./services/tradovate');
15
16
  const { PROPFIRM_CHOICES, getPropFirmsByPlatform, getPropFirm } = require('./config');
16
17
  const { getDevice, getSeparator, printLogo, getLogoWidth, drawBoxHeader, drawBoxFooter, centerText, createBoxMenu } = require('./ui');
17
18
  const { validateUsername, validatePassword, maskSensitive } = require('./security');
@@ -372,6 +373,88 @@ const rithmicMenu = async () => {
372
373
  }
373
374
  };
374
375
 
376
+ /**
377
+ * Tradovate platform connection menu
378
+ */
379
+ const tradovateMenu = async () => {
380
+ const propfirms = getPropFirmsByPlatform('Tradovate');
381
+ const boxWidth = getLogoWidth();
382
+ const innerWidth = boxWidth - 2;
383
+
384
+ // Build numbered list
385
+ const numbered = propfirms.map((pf, i) => ({
386
+ num: i + 1,
387
+ key: pf.key,
388
+ name: pf.displayName
389
+ }));
390
+
391
+ // PropFirm selection box
392
+ console.log();
393
+ console.log(chalk.cyan('╔' + '═'.repeat(innerWidth) + '╗'));
394
+ console.log(chalk.cyan('║') + chalk.white.bold(centerText('SELECT PROPFIRM (TRADOVATE)', innerWidth)) + chalk.cyan('║'));
395
+ console.log(chalk.cyan('║') + ' '.repeat(innerWidth) + chalk.cyan('║'));
396
+
397
+ // Display propfirms
398
+ for (const item of numbered) {
399
+ const numStr = item.num.toString().padStart(2, ' ');
400
+ const text = ' ' + chalk.cyan(`[${numStr}]`) + ' ' + chalk.white(item.name);
401
+ const textLen = 4 + 1 + item.name.length + 2;
402
+ console.log(chalk.cyan('║') + text + ' '.repeat(innerWidth - textLen) + chalk.cyan('║'));
403
+ }
404
+
405
+ console.log(chalk.cyan('║') + ' '.repeat(innerWidth) + chalk.cyan('║'));
406
+ const backText = ' ' + chalk.red('[X] Back');
407
+ const backLen = '[X] Back'.length + 2;
408
+ console.log(chalk.cyan('║') + backText + ' '.repeat(innerWidth - backLen) + chalk.cyan('║'));
409
+ console.log(chalk.cyan('╚' + '═'.repeat(innerWidth) + '╝'));
410
+ console.log();
411
+
412
+ const validInputs = numbered.map(n => n.num.toString());
413
+ validInputs.push('x', 'X');
414
+
415
+ const { action } = await inquirer.prompt([
416
+ {
417
+ type: 'input',
418
+ name: 'action',
419
+ message: chalk.cyan(`Enter choice (1-${numbered.length}/X):`),
420
+ validate: (input) => {
421
+ if (validInputs.includes(input)) return true;
422
+ return `Please enter 1-${numbered.length} or X`;
423
+ }
424
+ }
425
+ ]);
426
+
427
+ if (action.toLowerCase() === 'x') return null;
428
+
429
+ const selectedIdx = parseInt(action) - 1;
430
+ const selectedPropfirm = numbered[selectedIdx];
431
+
432
+ const credentials = await loginPrompt(selectedPropfirm.name);
433
+ const spinner = ora('Connecting to Tradovate...').start();
434
+
435
+ try {
436
+ const service = new TradovateService(selectedPropfirm.key);
437
+ const result = await service.login(credentials.username, credentials.password);
438
+
439
+ if (result.success) {
440
+ spinner.text = 'Fetching accounts...';
441
+ await service.getTradingAccounts();
442
+
443
+ connections.add('tradovate', service, service.propfirm.name);
444
+ currentService = service;
445
+ currentPlatform = 'tradovate';
446
+ spinner.succeed(`Connected to ${service.propfirm.name}`);
447
+ return service;
448
+ } else {
449
+ spinner.fail(result.error || 'Authentication failed');
450
+ return null;
451
+ }
452
+ } catch (error) {
453
+ spinner.fail(error.message);
454
+ return null;
455
+ }
456
+ };
457
+
375
458
  /**
376
459
  * Main connection menu
377
460
  */
@@ -398,7 +481,7 @@ const mainMenu = async () => {
398
481
  };
399
482
 
400
483
  menuRow(chalk.cyan('[1] ProjectX'), chalk.cyan('[2] Rithmic'));
401
- menuRow(chalk.gray('[3] Tradovate (Coming Soon)'), chalk.red('[X] Exit'));
484
+ menuRow(chalk.cyan('[3] Tradovate'), chalk.red('[X] Exit'));
402
485
 
403
486
  console.log(chalk.cyan('╚' + '═'.repeat(innerWidth) + '╝'));
404
487
  console.log();
@@ -407,11 +490,11 @@ const mainMenu = async () => {
407
490
  {
408
491
  type: 'input',
409
492
  name: 'action',
410
- message: chalk.cyan('Enter choice (1/2/X):'),
493
+ message: chalk.cyan('Enter choice (1/2/3/X):'),
411
494
  validate: (input) => {
412
- const valid = ['1', '2', 'x', 'X'];
495
+ const valid = ['1', '2', '3', 'x', 'X'];
413
496
  if (valid.includes(input)) return true;
414
- return 'Please enter 1, 2 or X';
497
+ return 'Please enter 1, 2, 3 or X';
415
498
  }
416
499
  }
417
500
  ]);
@@ -420,6 +503,7 @@ const mainMenu = async () => {
420
503
  const actionMap = {
421
504
  '1': 'projectx',
422
505
  '2': 'rithmic',
506
+ '3': 'tradovate',
423
507
  'x': 'exit',
424
508
  'X': 'exit'
425
509
  };
@@ -621,6 +705,11 @@ const run = async () => {
621
705
  const service = await rithmicMenu();
622
706
  if (service) currentService = service;
623
707
  }
708
+
709
+ if (choice === 'tradovate') {
710
+ const service = await tradovateMenu();
711
+ if (service) currentService = service;
712
+ }
624
713
  } else {
625
714
  const action = await dashboardMenu(currentService);
626
715
 
@@ -179,6 +179,14 @@ const PROPFIRMS = {
179
179
  userApi: 'userapi.takeprofittrader.tradovate.com',
180
180
  gatewayApi: 'api.takeprofittrader.tradovate.com'
181
181
  },
182
+ myfundedfutures: {
183
+ id: 'myfundedfutures',
184
+ name: 'MyFundedFutures',
185
+ displayName: 'MyFundedFutures',
186
+ platform: 'Tradovate',
187
+ userApi: 'live.tradovateapi.com',
188
+ gatewayApi: 'live.tradovateapi.com'
189
+ },
182
190
 
183
191
  // ==================== Rithmic Platform ====================
184
192
  apex_rithmic: {
@@ -70,21 +70,22 @@ class RithmicService extends EventEmitter {
70
70
  // Login
71
71
  return new Promise((resolve, reject) => {
72
72
  const timeout = setTimeout(() => {
73
- reject(new Error('Login timeout'));
74
- }, 15000);
73
+ resolve({ success: false, error: 'Login timeout - server did not respond' });
74
+ }, 30000);
75
75
 
76
76
  this.orderConn.once('loggedIn', async (data) => {
77
77
  clearTimeout(timeout);
78
78
  this.loginInfo = data;
79
- this.user = { userName: username };
79
+ this.user = { userName: username, fcmId: data.fcmId, ibId: data.ibId };
80
80
 
81
+ // Try to get accounts but don't fail if it doesn't work
81
82
  try {
82
- // Get accounts
83
83
  await this.fetchAccounts();
84
- resolve({ success: true });
85
84
  } catch (e) {
86
- resolve({ success: false, error: e.message });
85
+ // Accounts fetch failed, but login succeeded
86
+ console.log('Note: Could not fetch accounts');
87
87
  }
88
+ resolve({ success: true });
88
89
  });
89
90
 
90
91
  this.orderConn.once('loginFailed', (data) => {
@@ -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 };