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
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.
|
|
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
|
|
package/src/config/propfirms.js
CHANGED
|
@@ -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
|
-
|
|
74
|
-
},
|
|
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
|
-
|
|
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 };
|