hedgequantx 2.9.145 → 2.9.147
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 +1 -1
- package/src/pages/algo/algo-executor.js +26 -20
- package/src/pages/algo/one-account.js +6 -3
- package/src/services/rithmic/index.js +29 -32
- package/src/services/rithmic/reconnect.js +138 -0
- package/src/services/rithmic-broker/client.js +1 -1
- package/src/services/rithmic-broker/daemon-reconnect.js +312 -0
- package/src/services/rithmic-broker/daemon.js +71 -26
package/package.json
CHANGED
|
@@ -361,17 +361,25 @@ const executeAlgo = async ({ service, account, contract, config, strategy: strat
|
|
|
361
361
|
// P&L polling - uses CACHED data (NO API CALLS to avoid Rithmic rate limits)
|
|
362
362
|
const pollPnL = async () => {
|
|
363
363
|
try {
|
|
364
|
-
// Get P&L from cache (no API call)
|
|
365
364
|
const accId = account.rithmicAccountId || account.accountId;
|
|
366
|
-
const pnlData = service.getAccountPnL ? service.getAccountPnL(accId) : null;
|
|
367
365
|
|
|
368
|
-
|
|
366
|
+
// Get P&L from cache (handle sync/async)
|
|
367
|
+
let pnlData = null;
|
|
368
|
+
if (service.getAccountPnL) {
|
|
369
|
+
const result = service.getAccountPnL(accId);
|
|
370
|
+
pnlData = result && result.then ? await result : result;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
if (pnlData && pnlData.pnl !== null && pnlData.pnl !== undefined && !isNaN(pnlData.pnl)) {
|
|
369
374
|
if (startingPnL === null) startingPnL = pnlData.pnl;
|
|
370
|
-
|
|
371
|
-
if (
|
|
375
|
+
const newPnl = pnlData.pnl - startingPnL;
|
|
376
|
+
if (!isNaN(newPnl)) {
|
|
377
|
+
stats.pnl = newPnl;
|
|
378
|
+
if (stats.pnl !== 0) strategy.recordTradeResult(stats.pnl);
|
|
379
|
+
}
|
|
372
380
|
}
|
|
373
381
|
|
|
374
|
-
// Check positions (
|
|
382
|
+
// Check positions (every 10s)
|
|
375
383
|
if (Date.now() % 10000 < 2000) {
|
|
376
384
|
const posResult = await service.getPositions(accId);
|
|
377
385
|
if (posResult.success && posResult.positions) {
|
|
@@ -379,29 +387,27 @@ const executeAlgo = async ({ service, account, contract, config, strategy: strat
|
|
|
379
387
|
const sym = p.contractId || p.symbol || '';
|
|
380
388
|
return sym.includes(contract.name) || sym.includes(contractId);
|
|
381
389
|
});
|
|
382
|
-
|
|
383
390
|
if (pos && pos.quantity !== 0) {
|
|
384
391
|
currentPosition = pos.quantity;
|
|
385
|
-
const pnl = pos.profitAndLoss || 0;
|
|
386
|
-
if (pnl > 0) stats.wins = Math.max(stats.wins, 1);
|
|
387
|
-
else if (pnl < 0) stats.losses = Math.max(stats.losses, 1);
|
|
388
392
|
} else {
|
|
389
393
|
currentPosition = 0;
|
|
390
394
|
}
|
|
391
395
|
}
|
|
392
396
|
}
|
|
393
397
|
|
|
394
|
-
if
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
398
|
+
// Risk checks (only if pnl is valid)
|
|
399
|
+
if (!isNaN(stats.pnl)) {
|
|
400
|
+
if (stats.pnl >= dailyTarget) {
|
|
401
|
+
stopReason = 'target'; running = false;
|
|
402
|
+
ui.addLog('fill_win', `TARGET REACHED! +$${stats.pnl.toFixed(2)}`);
|
|
403
|
+
sessionLogger.log('TARGET', `Daily target reached: +$${stats.pnl.toFixed(2)}`);
|
|
404
|
+
} else if (stats.pnl <= -maxRisk) {
|
|
405
|
+
stopReason = 'risk'; running = false;
|
|
406
|
+
ui.addLog('fill_loss', `MAX RISK! -$${Math.abs(stats.pnl).toFixed(2)}`);
|
|
407
|
+
sessionLogger.log('RISK', `Max risk hit: -$${Math.abs(stats.pnl).toFixed(2)}`);
|
|
408
|
+
}
|
|
409
|
+
sessionLogger.pnl(stats.pnl, 0, currentPosition);
|
|
402
410
|
}
|
|
403
|
-
// Log P&L every poll
|
|
404
|
-
sessionLogger.pnl(stats.pnl, 0, currentPosition);
|
|
405
411
|
} catch (e) { /* silent */ }
|
|
406
412
|
};
|
|
407
413
|
|
|
@@ -389,8 +389,8 @@ const selectMultipleSymbols = async (service, account) => {
|
|
|
389
389
|
const selectSymbol = async (service, account) => {
|
|
390
390
|
const spinner = ora({ text: 'Loading symbols...', color: 'yellow' }).start();
|
|
391
391
|
|
|
392
|
-
// Ensure we have a logged-in service
|
|
393
|
-
if (!service.loginInfo && service.credentials) {
|
|
392
|
+
// Ensure we have a logged-in service (for direct RithmicService, not BrokerClient)
|
|
393
|
+
if (!service.loginInfo && service.credentials && typeof service.login === 'function') {
|
|
394
394
|
spinner.text = 'Reconnecting to broker...';
|
|
395
395
|
const loginResult = await service.login(service.credentials.username, service.credentials.password);
|
|
396
396
|
if (!loginResult.success) {
|
|
@@ -400,8 +400,11 @@ const selectSymbol = async (service, account) => {
|
|
|
400
400
|
}
|
|
401
401
|
|
|
402
402
|
const contractsResult = await service.getContracts();
|
|
403
|
+
|
|
403
404
|
if (!contractsResult.success || !contractsResult.contracts?.length) {
|
|
404
|
-
|
|
405
|
+
const errorMsg = contractsResult.error || 'No contracts available';
|
|
406
|
+
spinner.fail(`Failed to load contracts: ${errorMsg}`);
|
|
407
|
+
console.log(chalk.gray(' Tip: Try reconnecting with "hqx login"'));
|
|
405
408
|
return null;
|
|
406
409
|
}
|
|
407
410
|
|
|
@@ -19,6 +19,7 @@ const {
|
|
|
19
19
|
const { placeOrder, cancelOrder, getOrders, getOrderHistory, getOrderHistoryDates, getTradeHistoryFull, closePosition } = require('./orders');
|
|
20
20
|
const { fillsToRoundTrips, calculateTradeStats } = require('./trades');
|
|
21
21
|
const { getContracts, searchContracts } = require('./contracts');
|
|
22
|
+
const { handleAutoReconnect } = require('./reconnect');
|
|
22
23
|
const { TIMEOUTS } = require('../../config/settings');
|
|
23
24
|
const { logger } = require('../../utils/logger');
|
|
24
25
|
|
|
@@ -83,7 +84,15 @@ class RithmicService extends EventEmitter {
|
|
|
83
84
|
|
|
84
85
|
// ==================== AUTH ====================
|
|
85
86
|
|
|
86
|
-
|
|
87
|
+
/**
|
|
88
|
+
* Login to Rithmic
|
|
89
|
+
* @param {string} username - Rithmic username
|
|
90
|
+
* @param {string} password - Rithmic password
|
|
91
|
+
* @param {Object} options - Login options
|
|
92
|
+
* @param {boolean} options.skipFetchAccounts - Skip fetchAccounts API call (use cached)
|
|
93
|
+
* @param {Array} options.cachedAccounts - Cached accounts to use instead of fetching
|
|
94
|
+
*/
|
|
95
|
+
async login(username, password, options = {}) {
|
|
87
96
|
try {
|
|
88
97
|
this.orderConn = new RithmicConnection();
|
|
89
98
|
|
|
@@ -121,11 +130,17 @@ class RithmicService extends EventEmitter {
|
|
|
121
130
|
this.loginInfo = data;
|
|
122
131
|
this.user = { userName: username, fcmId: data.fcmId, ibId: data.ibId };
|
|
123
132
|
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
133
|
+
// Skip fetchAccounts if using cached accounts (avoids API quota)
|
|
134
|
+
if (options.skipFetchAccounts && options.cachedAccounts) {
|
|
135
|
+
this.accounts = options.cachedAccounts;
|
|
136
|
+
log.debug('Using cached accounts', { count: this.accounts.length });
|
|
137
|
+
} else {
|
|
138
|
+
try {
|
|
139
|
+
await fetchAccounts(this);
|
|
140
|
+
log.debug('Fetched accounts', { count: this.accounts.length });
|
|
141
|
+
} catch (err) {
|
|
142
|
+
log.warn('Failed to fetch accounts', { error: err.message });
|
|
143
|
+
}
|
|
129
144
|
}
|
|
130
145
|
|
|
131
146
|
this.credentials = { username, password };
|
|
@@ -140,6 +155,7 @@ class RithmicService extends EventEmitter {
|
|
|
140
155
|
log.warn('PnL connection failed', { error: err.message });
|
|
141
156
|
}
|
|
142
157
|
|
|
158
|
+
// Get trading accounts (uses existing this.accounts, no new API call)
|
|
143
159
|
const result = await getTradingAccounts(this);
|
|
144
160
|
log.info('Login successful', { accounts: result.accounts.length });
|
|
145
161
|
|
|
@@ -423,33 +439,14 @@ class RithmicService extends EventEmitter {
|
|
|
423
439
|
|
|
424
440
|
// ==================== AUTO-RECONNECT ====================
|
|
425
441
|
|
|
442
|
+
/**
|
|
443
|
+
* Auto-reconnect with rate limiting (delegated to reconnect module)
|
|
444
|
+
* - Minimum 1 hour between attempts
|
|
445
|
+
* - Maximum 10 attempts per 24 hours
|
|
446
|
+
* - Reuses cached accounts (no fetchAccounts API call)
|
|
447
|
+
*/
|
|
426
448
|
async _autoReconnect() {
|
|
427
|
-
|
|
428
|
-
log.warn('Cannot auto-reconnect: no credentials');
|
|
429
|
-
return;
|
|
430
|
-
}
|
|
431
|
-
|
|
432
|
-
const { username, password } = this.credentials;
|
|
433
|
-
log.info('Auto-reconnecting...');
|
|
434
|
-
this.emit('reconnecting');
|
|
435
|
-
|
|
436
|
-
try {
|
|
437
|
-
const result = await this.login(username, password);
|
|
438
|
-
if (result.success) {
|
|
439
|
-
log.info('Auto-reconnect successful');
|
|
440
|
-
this.emit('reconnected', { accounts: result.accounts });
|
|
441
|
-
} else {
|
|
442
|
-
log.warn('Auto-reconnect failed', { error: result.error });
|
|
443
|
-
this.emit('reconnectFailed', { error: result.error });
|
|
444
|
-
// Retry in 10s
|
|
445
|
-
setTimeout(() => this._autoReconnect(), 10000);
|
|
446
|
-
}
|
|
447
|
-
} catch (err) {
|
|
448
|
-
log.error('Auto-reconnect error', { error: err.message });
|
|
449
|
-
this.emit('reconnectFailed', { error: err.message });
|
|
450
|
-
// Retry in 10s
|
|
451
|
-
setTimeout(() => this._autoReconnect(), 10000);
|
|
452
|
-
}
|
|
449
|
+
return handleAutoReconnect(this);
|
|
453
450
|
}
|
|
454
451
|
|
|
455
452
|
// ==================== CLEANUP ====================
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rithmic Reconnection Module
|
|
3
|
+
*
|
|
4
|
+
* Rate-limited reconnection logic to avoid API quota exhaustion.
|
|
5
|
+
* Rithmic limit: 2000 GetAccounts calls between system reboots.
|
|
6
|
+
*
|
|
7
|
+
* Rate limiting:
|
|
8
|
+
* - Minimum 1 hour between reconnection attempts
|
|
9
|
+
* - Maximum 10 reconnection attempts per 24 hours
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
'use strict';
|
|
13
|
+
|
|
14
|
+
const { logger } = require('../../utils/logger');
|
|
15
|
+
const log = logger.scope('Rithmic:Reconnect');
|
|
16
|
+
|
|
17
|
+
// Rate limiting constants
|
|
18
|
+
const RECONNECT_MIN_INTERVAL = 3600000; // 1 hour minimum between attempts
|
|
19
|
+
const RECONNECT_MAX_PER_DAY = 10; // Max 10 reconnects per 24h
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Check if reconnection is allowed (rate limiting)
|
|
23
|
+
* @param {Object} state - { lastAttempt, count, resetTime }
|
|
24
|
+
* @returns {{ allowed: boolean, waitMinutes?: number, reason?: string }}
|
|
25
|
+
*/
|
|
26
|
+
function canReconnect(state) {
|
|
27
|
+
const now = Date.now();
|
|
28
|
+
|
|
29
|
+
// Reset daily counter every 24h
|
|
30
|
+
if (now - state.resetTime > 86400000) {
|
|
31
|
+
state.count = 0;
|
|
32
|
+
state.resetTime = now;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Check minimum interval
|
|
36
|
+
const timeSinceLastAttempt = now - state.lastAttempt;
|
|
37
|
+
if (state.lastAttempt > 0 && timeSinceLastAttempt < RECONNECT_MIN_INTERVAL) {
|
|
38
|
+
const waitMinutes = Math.ceil((RECONNECT_MIN_INTERVAL - timeSinceLastAttempt) / 60000);
|
|
39
|
+
return { allowed: false, waitMinutes, reason: 'rate_limited' };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Check daily limit
|
|
43
|
+
if (state.count >= RECONNECT_MAX_PER_DAY) {
|
|
44
|
+
return { allowed: false, reason: 'daily_limit', waitMinutes: 0 };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return { allowed: true };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Record a reconnection attempt
|
|
52
|
+
* @param {Object} state - { lastAttempt, count, resetTime }
|
|
53
|
+
*/
|
|
54
|
+
function recordAttempt(state) {
|
|
55
|
+
state.lastAttempt = Date.now();
|
|
56
|
+
state.count++;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Create initial reconnect state
|
|
61
|
+
* @returns {Object} - { lastAttempt, count, resetTime }
|
|
62
|
+
*/
|
|
63
|
+
function createReconnectState() {
|
|
64
|
+
return {
|
|
65
|
+
lastAttempt: 0,
|
|
66
|
+
count: 0,
|
|
67
|
+
resetTime: Date.now()
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Auto-reconnect handler for RithmicService
|
|
73
|
+
* @param {RithmicService} service - The service instance
|
|
74
|
+
*/
|
|
75
|
+
async function handleAutoReconnect(service) {
|
|
76
|
+
if (!service.credentials) {
|
|
77
|
+
log.warn('Cannot auto-reconnect: no credentials');
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Initialize reconnect state if needed
|
|
82
|
+
if (!service._reconnectState) {
|
|
83
|
+
service._reconnectState = createReconnectState();
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Check rate limits
|
|
87
|
+
const check = canReconnect(service._reconnectState);
|
|
88
|
+
if (!check.allowed) {
|
|
89
|
+
if (check.reason === 'rate_limited') {
|
|
90
|
+
log.warn('Reconnect rate limited', { waitMinutes: check.waitMinutes });
|
|
91
|
+
service.emit('reconnectRateLimited', { waitMinutes: check.waitMinutes });
|
|
92
|
+
} else {
|
|
93
|
+
log.error('Daily reconnect limit reached', { limit: RECONNECT_MAX_PER_DAY });
|
|
94
|
+
service.emit('reconnectBlocked', { reason: 'Daily limit reached' });
|
|
95
|
+
}
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Record this attempt
|
|
100
|
+
recordAttempt(service._reconnectState);
|
|
101
|
+
|
|
102
|
+
const { username, password } = service.credentials;
|
|
103
|
+
const savedAccounts = [...service.accounts]; // Save accounts before reconnect
|
|
104
|
+
|
|
105
|
+
log.info('Auto-reconnecting...', {
|
|
106
|
+
attempt: service._reconnectState.count,
|
|
107
|
+
maxPerDay: RECONNECT_MAX_PER_DAY
|
|
108
|
+
});
|
|
109
|
+
service.emit('reconnecting');
|
|
110
|
+
|
|
111
|
+
try {
|
|
112
|
+
// Login with cached accounts (NO fetchAccounts API call)
|
|
113
|
+
const result = await service.login(username, password, {
|
|
114
|
+
skipFetchAccounts: true,
|
|
115
|
+
cachedAccounts: savedAccounts
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
if (result.success) {
|
|
119
|
+
log.info('Auto-reconnect successful');
|
|
120
|
+
service.emit('reconnected', { accounts: result.accounts });
|
|
121
|
+
} else {
|
|
122
|
+
log.warn('Auto-reconnect failed', { error: result.error });
|
|
123
|
+
service.emit('reconnectFailed', { error: result.error });
|
|
124
|
+
}
|
|
125
|
+
} catch (err) {
|
|
126
|
+
log.error('Auto-reconnect error', { error: err.message });
|
|
127
|
+
service.emit('reconnectFailed', { error: err.message });
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
module.exports = {
|
|
132
|
+
RECONNECT_MIN_INTERVAL,
|
|
133
|
+
RECONNECT_MAX_PER_DAY,
|
|
134
|
+
canReconnect,
|
|
135
|
+
recordAttempt,
|
|
136
|
+
createReconnectState,
|
|
137
|
+
handleAutoReconnect
|
|
138
|
+
};
|
|
@@ -211,7 +211,7 @@ class RithmicBrokerClient extends EventEmitter {
|
|
|
211
211
|
*/
|
|
212
212
|
async getContracts() {
|
|
213
213
|
const result = await this._request('getContracts', { propfirmKey: this.propfirmKey });
|
|
214
|
-
if (result.error) return { success: false, contracts: [] };
|
|
214
|
+
if (result.error) return { success: false, contracts: [], error: result.error };
|
|
215
215
|
return result.payload || { success: true, contracts: [] };
|
|
216
216
|
}
|
|
217
217
|
|
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Daemon Reconnection Module
|
|
3
|
+
*
|
|
4
|
+
* Handles connection monitoring and smart reconnection with rate limiting.
|
|
5
|
+
* Designed to avoid API quota exhaustion (Rithmic limit: 2000 GetAccounts calls)
|
|
6
|
+
*
|
|
7
|
+
* Key principles:
|
|
8
|
+
* - Never spam reconnection attempts
|
|
9
|
+
* - Reuse cached accounts (no fetchAccounts on reconnect)
|
|
10
|
+
* - Rate limit: 1 attempt per hour, max 10 per day
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
'use strict';
|
|
14
|
+
|
|
15
|
+
const fs = require('fs');
|
|
16
|
+
|
|
17
|
+
// Rate limiting configuration
|
|
18
|
+
const RECONNECT_CONFIG = {
|
|
19
|
+
MIN_INTERVAL_MS: 3600000, // 1 hour minimum between attempts
|
|
20
|
+
MAX_PER_DAY: 10, // Max 10 reconnects per 24h
|
|
21
|
+
HEALTH_CHECK_INTERVAL: 30000, // Check every 30s
|
|
22
|
+
RESTORE_RETRY_DELAY: 5000, // 5s between restore attempts
|
|
23
|
+
RESTORE_MAX_ATTEMPTS: 3, // Max 3 attempts on restore
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* ReconnectManager - Manages connection monitoring and reconnection
|
|
28
|
+
*/
|
|
29
|
+
class ReconnectManager {
|
|
30
|
+
constructor(daemon, logger) {
|
|
31
|
+
this.daemon = daemon;
|
|
32
|
+
this.log = logger;
|
|
33
|
+
this.healthCheckTimer = null;
|
|
34
|
+
|
|
35
|
+
// Rate limiting state per propfirm
|
|
36
|
+
this.reconnectState = new Map(); // propfirmKey -> { lastAttempt, countToday, resetTime }
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Start health check loop
|
|
41
|
+
*/
|
|
42
|
+
startHealthCheck() {
|
|
43
|
+
if (this.healthCheckTimer) clearInterval(this.healthCheckTimer);
|
|
44
|
+
|
|
45
|
+
this.healthCheckTimer = setInterval(() => {
|
|
46
|
+
this._checkAllConnections();
|
|
47
|
+
}, RECONNECT_CONFIG.HEALTH_CHECK_INTERVAL);
|
|
48
|
+
|
|
49
|
+
this.log('INFO', 'Health check started', { interval: RECONNECT_CONFIG.HEALTH_CHECK_INTERVAL });
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Stop health check loop
|
|
54
|
+
*/
|
|
55
|
+
stopHealthCheck() {
|
|
56
|
+
if (this.healthCheckTimer) {
|
|
57
|
+
clearInterval(this.healthCheckTimer);
|
|
58
|
+
this.healthCheckTimer = null;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Check all connections health
|
|
64
|
+
*/
|
|
65
|
+
_checkAllConnections() {
|
|
66
|
+
for (const [propfirmKey, conn] of this.daemon.connections) {
|
|
67
|
+
this._checkConnection(propfirmKey, conn);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Check single connection health
|
|
73
|
+
*/
|
|
74
|
+
_checkConnection(propfirmKey, conn) {
|
|
75
|
+
const service = conn.service;
|
|
76
|
+
|
|
77
|
+
// No service = not connected
|
|
78
|
+
if (!service) {
|
|
79
|
+
if (conn.status !== 'disconnected' && conn.status !== 'reconnecting') {
|
|
80
|
+
conn.status = 'disconnected';
|
|
81
|
+
this._broadcastStatus(propfirmKey, 'disconnected');
|
|
82
|
+
}
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Check if ORDER_PLANT is alive
|
|
87
|
+
const isAlive = service.orderConn?.isConnected &&
|
|
88
|
+
service.orderConn?.connectionState === 'LOGGED_IN';
|
|
89
|
+
|
|
90
|
+
if (!isAlive && conn.status === 'connected') {
|
|
91
|
+
this.log('WARN', 'Health check: connection lost', { propfirm: propfirmKey });
|
|
92
|
+
conn.status = 'disconnected';
|
|
93
|
+
this._broadcastStatus(propfirmKey, 'disconnected');
|
|
94
|
+
|
|
95
|
+
// Attempt reconnect if we have credentials
|
|
96
|
+
if (conn.credentials) {
|
|
97
|
+
this._attemptReconnect(propfirmKey, conn);
|
|
98
|
+
}
|
|
99
|
+
} else if (isAlive && conn.status !== 'connected') {
|
|
100
|
+
conn.status = 'connected';
|
|
101
|
+
this._broadcastStatus(propfirmKey, 'connected');
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Setup connection event monitoring for a service
|
|
107
|
+
*/
|
|
108
|
+
setupConnectionMonitoring(propfirmKey, service) {
|
|
109
|
+
const conn = this.daemon.connections.get(propfirmKey);
|
|
110
|
+
if (!conn) return;
|
|
111
|
+
|
|
112
|
+
service.on('disconnected', ({ plant, code, reason }) => {
|
|
113
|
+
this.log('WARN', 'Service disconnected', { propfirm: propfirmKey, plant, code, reason });
|
|
114
|
+
conn.status = 'disconnected';
|
|
115
|
+
this._broadcastStatus(propfirmKey, 'disconnected', { code, reason });
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
service.on('reconnecting', () => {
|
|
119
|
+
this.log('INFO', 'Service reconnecting', { propfirm: propfirmKey });
|
|
120
|
+
conn.status = 'reconnecting';
|
|
121
|
+
this._broadcastStatus(propfirmKey, 'reconnecting');
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
service.on('reconnected', ({ accounts }) => {
|
|
125
|
+
this.log('INFO', 'Service reconnected', { propfirm: propfirmKey });
|
|
126
|
+
conn.status = 'connected';
|
|
127
|
+
conn.connectedAt = new Date().toISOString();
|
|
128
|
+
if (accounts) conn.accounts = accounts;
|
|
129
|
+
this._broadcastStatus(propfirmKey, 'connected');
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
service.on('reconnectFailed', ({ error }) => {
|
|
133
|
+
this.log('WARN', 'Service reconnect failed', { propfirm: propfirmKey, error });
|
|
134
|
+
// Don't change status - let health check handle it
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Attempt reconnection with rate limiting
|
|
140
|
+
*/
|
|
141
|
+
async _attemptReconnect(propfirmKey, conn) {
|
|
142
|
+
// Check rate limits
|
|
143
|
+
if (!this._canReconnect(propfirmKey)) {
|
|
144
|
+
this.log('WARN', 'Reconnect rate limited', { propfirm: propfirmKey });
|
|
145
|
+
this._broadcastStatus(propfirmKey, 'rate_limited', {
|
|
146
|
+
message: 'Reconnection rate limited. Try again later or run "hqx login".'
|
|
147
|
+
});
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
conn.status = 'reconnecting';
|
|
152
|
+
this._broadcastStatus(propfirmKey, 'reconnecting');
|
|
153
|
+
this._recordReconnectAttempt(propfirmKey);
|
|
154
|
+
|
|
155
|
+
this.log('INFO', 'Attempting reconnect', { propfirm: propfirmKey });
|
|
156
|
+
|
|
157
|
+
try {
|
|
158
|
+
// Disconnect old service
|
|
159
|
+
if (conn.service) {
|
|
160
|
+
try { await conn.service.disconnect(); } catch (e) { /* ignore */ }
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Create new service and login WITH cached accounts (no API call for accounts)
|
|
164
|
+
const Service = this.daemon.loadRithmicService();
|
|
165
|
+
const service = new Service(propfirmKey);
|
|
166
|
+
|
|
167
|
+
const result = await service.login(conn.credentials.username, conn.credentials.password, {
|
|
168
|
+
skipFetchAccounts: true,
|
|
169
|
+
cachedAccounts: conn.accounts
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
if (result.success) {
|
|
173
|
+
this.log('INFO', 'Reconnect successful', { propfirm: propfirmKey });
|
|
174
|
+
|
|
175
|
+
conn.service = service;
|
|
176
|
+
conn.status = 'connected';
|
|
177
|
+
conn.connectedAt = new Date().toISOString();
|
|
178
|
+
// Keep existing accounts (from cache)
|
|
179
|
+
|
|
180
|
+
this.daemon._setupPnLUpdates(propfirmKey, service);
|
|
181
|
+
this.setupConnectionMonitoring(propfirmKey, service);
|
|
182
|
+
this.daemon._saveState();
|
|
183
|
+
|
|
184
|
+
this._broadcastStatus(propfirmKey, 'connected');
|
|
185
|
+
} else {
|
|
186
|
+
this.log('WARN', 'Reconnect failed', { propfirm: propfirmKey, error: result.error });
|
|
187
|
+
conn.status = 'disconnected';
|
|
188
|
+
this._broadcastStatus(propfirmKey, 'disconnected', { error: result.error });
|
|
189
|
+
}
|
|
190
|
+
} catch (err) {
|
|
191
|
+
this.log('ERROR', 'Reconnect error', { propfirm: propfirmKey, error: err.message });
|
|
192
|
+
conn.status = 'disconnected';
|
|
193
|
+
this._broadcastStatus(propfirmKey, 'disconnected', { error: err.message });
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Check if we can attempt reconnection (rate limiting)
|
|
199
|
+
*/
|
|
200
|
+
_canReconnect(propfirmKey) {
|
|
201
|
+
const now = Date.now();
|
|
202
|
+
let state = this.reconnectState.get(propfirmKey);
|
|
203
|
+
|
|
204
|
+
if (!state) {
|
|
205
|
+
state = { lastAttempt: 0, countToday: 0, resetTime: now };
|
|
206
|
+
this.reconnectState.set(propfirmKey, state);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// Reset daily counter
|
|
210
|
+
if (now - state.resetTime > 86400000) {
|
|
211
|
+
state.countToday = 0;
|
|
212
|
+
state.resetTime = now;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// Check minimum interval
|
|
216
|
+
if (now - state.lastAttempt < RECONNECT_CONFIG.MIN_INTERVAL_MS) {
|
|
217
|
+
return false;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// Check daily limit
|
|
221
|
+
if (state.countToday >= RECONNECT_CONFIG.MAX_PER_DAY) {
|
|
222
|
+
return false;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
return true;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Record a reconnect attempt
|
|
230
|
+
*/
|
|
231
|
+
_recordReconnectAttempt(propfirmKey) {
|
|
232
|
+
const state = this.reconnectState.get(propfirmKey) || {
|
|
233
|
+
lastAttempt: 0,
|
|
234
|
+
countToday: 0,
|
|
235
|
+
resetTime: Date.now()
|
|
236
|
+
};
|
|
237
|
+
|
|
238
|
+
state.lastAttempt = Date.now();
|
|
239
|
+
state.countToday++;
|
|
240
|
+
this.reconnectState.set(propfirmKey, state);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Restore connections from state with retry logic
|
|
245
|
+
*/
|
|
246
|
+
async restoreConnections(stateFile) {
|
|
247
|
+
if (!fs.existsSync(stateFile)) return;
|
|
248
|
+
|
|
249
|
+
try {
|
|
250
|
+
const data = JSON.parse(fs.readFileSync(stateFile, 'utf8'));
|
|
251
|
+
|
|
252
|
+
for (const saved of data.connections || []) {
|
|
253
|
+
if (!saved.credentials || !saved.propfirmKey) continue;
|
|
254
|
+
|
|
255
|
+
this.log('INFO', 'Restoring connection', { propfirm: saved.propfirmKey });
|
|
256
|
+
|
|
257
|
+
let success = false;
|
|
258
|
+
let attempts = 0;
|
|
259
|
+
|
|
260
|
+
while (!success && attempts < RECONNECT_CONFIG.RESTORE_MAX_ATTEMPTS) {
|
|
261
|
+
attempts++;
|
|
262
|
+
|
|
263
|
+
const result = await this.daemon._handleLogin({
|
|
264
|
+
...saved.credentials,
|
|
265
|
+
propfirmKey: saved.propfirmKey,
|
|
266
|
+
cachedAccounts: saved.accounts // Use cached accounts!
|
|
267
|
+
}, null);
|
|
268
|
+
|
|
269
|
+
if (result.payload?.success) {
|
|
270
|
+
success = true;
|
|
271
|
+
this.log('INFO', 'Connection restored', { propfirm: saved.propfirmKey });
|
|
272
|
+
} else {
|
|
273
|
+
this.log('WARN', 'Restore attempt failed', {
|
|
274
|
+
propfirm: saved.propfirmKey,
|
|
275
|
+
attempt: attempts,
|
|
276
|
+
error: result.payload?.error || result.error
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
if (attempts < RECONNECT_CONFIG.RESTORE_MAX_ATTEMPTS) {
|
|
280
|
+
await new Promise(r => setTimeout(r, RECONNECT_CONFIG.RESTORE_RETRY_DELAY));
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
if (!success) {
|
|
286
|
+
this.log('WARN', 'Failed to restore, storing for later', { propfirm: saved.propfirmKey });
|
|
287
|
+
this.daemon.connections.set(saved.propfirmKey, {
|
|
288
|
+
service: null,
|
|
289
|
+
credentials: saved.credentials,
|
|
290
|
+
connectedAt: null,
|
|
291
|
+
accounts: saved.accounts || [],
|
|
292
|
+
status: 'disconnected',
|
|
293
|
+
});
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
} catch (e) {
|
|
297
|
+
this.log('ERROR', 'Restore failed', { error: e.message });
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* Broadcast connection status to CLI clients
|
|
303
|
+
*/
|
|
304
|
+
_broadcastStatus(propfirmKey, status, extra = {}) {
|
|
305
|
+
this.daemon._broadcast({
|
|
306
|
+
type: 'connectionStatus',
|
|
307
|
+
payload: { propfirmKey, status, ...extra }
|
|
308
|
+
});
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
module.exports = { ReconnectManager, RECONNECT_CONFIG };
|
|
@@ -5,6 +5,11 @@
|
|
|
5
5
|
* Survives CLI restarts/updates. Only stops on explicit logout or reboot.
|
|
6
6
|
*
|
|
7
7
|
* Communication: WebSocket server on port 18765
|
|
8
|
+
*
|
|
9
|
+
* Key features:
|
|
10
|
+
* - Persistent connections (no disconnect on CLI restart)
|
|
11
|
+
* - Smart reconnection with rate limiting (max 10/day)
|
|
12
|
+
* - Cached accounts (no repeated API calls)
|
|
8
13
|
*/
|
|
9
14
|
|
|
10
15
|
'use strict';
|
|
@@ -13,6 +18,7 @@ const WebSocket = require('ws');
|
|
|
13
18
|
const fs = require('fs');
|
|
14
19
|
const path = require('path');
|
|
15
20
|
const os = require('os');
|
|
21
|
+
const { ReconnectManager } = require('./daemon-reconnect');
|
|
16
22
|
|
|
17
23
|
// Paths
|
|
18
24
|
const BROKER_DIR = path.join(os.homedir(), '.hqx', 'rithmic-broker');
|
|
@@ -45,9 +51,15 @@ class RithmicBrokerDaemon {
|
|
|
45
51
|
constructor() {
|
|
46
52
|
this.wss = null;
|
|
47
53
|
this.clients = new Set();
|
|
48
|
-
this.connections = new Map(); // propfirmKey -> { service, credentials, connectedAt, accounts }
|
|
54
|
+
this.connections = new Map(); // propfirmKey -> { service, credentials, connectedAt, accounts, status }
|
|
49
55
|
this.pnlCache = new Map(); // accountId -> { pnl, openPnl, closedPnl, balance, updatedAt }
|
|
50
56
|
this.running = false;
|
|
57
|
+
|
|
58
|
+
// Reconnection manager (handles health checks & reconnection with rate limiting)
|
|
59
|
+
this.reconnectManager = new ReconnectManager(this, log);
|
|
60
|
+
|
|
61
|
+
// Expose loadRithmicService for ReconnectManager
|
|
62
|
+
this.loadRithmicService = loadRithmicService;
|
|
51
63
|
}
|
|
52
64
|
|
|
53
65
|
async start() {
|
|
@@ -56,7 +68,8 @@ class RithmicBrokerDaemon {
|
|
|
56
68
|
if (!fs.existsSync(BROKER_DIR)) fs.mkdirSync(BROKER_DIR, { recursive: true });
|
|
57
69
|
fs.writeFileSync(PID_FILE, String(process.pid));
|
|
58
70
|
|
|
59
|
-
|
|
71
|
+
// Restore connections from state (with cached accounts - no API spam)
|
|
72
|
+
await this.reconnectManager.restoreConnections(STATE_FILE);
|
|
60
73
|
|
|
61
74
|
this.wss = new WebSocket.Server({ port: BROKER_PORT, host: '127.0.0.1' });
|
|
62
75
|
this.wss.on('connection', (ws) => this._handleClient(ws));
|
|
@@ -67,13 +80,21 @@ class RithmicBrokerDaemon {
|
|
|
67
80
|
|
|
68
81
|
process.on('SIGTERM', () => this.stop());
|
|
69
82
|
process.on('SIGINT', () => this.stop());
|
|
83
|
+
|
|
84
|
+
// Auto-save state every 30s
|
|
70
85
|
setInterval(() => this._saveState(), 30000);
|
|
86
|
+
|
|
87
|
+
// Start health check (monitoring + rate-limited reconnection)
|
|
88
|
+
this.reconnectManager.startHealthCheck();
|
|
71
89
|
}
|
|
72
90
|
|
|
73
91
|
async stop() {
|
|
74
92
|
log('INFO', 'Daemon stopping...');
|
|
75
93
|
this.running = false;
|
|
76
94
|
|
|
95
|
+
// Stop health check
|
|
96
|
+
this.reconnectManager.stopHealthCheck();
|
|
97
|
+
|
|
77
98
|
for (const [key, conn] of this.connections) {
|
|
78
99
|
try { if (conn.service?.disconnect) await conn.service.disconnect(); }
|
|
79
100
|
catch (e) { log('WARN', 'Disconnect error', { propfirm: key, error: e.message }); }
|
|
@@ -138,18 +159,22 @@ class RithmicBrokerDaemon {
|
|
|
138
159
|
_getStatus() {
|
|
139
160
|
const conns = [];
|
|
140
161
|
for (const [key, conn] of this.connections) {
|
|
162
|
+
const isAlive = conn.service?.orderConn?.isConnected &&
|
|
163
|
+
conn.service?.orderConn?.connectionState === 'LOGGED_IN';
|
|
141
164
|
conns.push({
|
|
142
165
|
propfirmKey: key,
|
|
143
166
|
propfirm: conn.service?.propfirm?.name || key,
|
|
144
167
|
connectedAt: conn.connectedAt,
|
|
145
168
|
accountCount: conn.accounts?.length || 0,
|
|
169
|
+
status: conn.status || (isAlive ? 'connected' : 'disconnected'),
|
|
170
|
+
isAlive,
|
|
146
171
|
});
|
|
147
172
|
}
|
|
148
173
|
return { running: this.running, pid: process.pid, uptime: process.uptime(), connections: conns };
|
|
149
174
|
}
|
|
150
175
|
|
|
151
176
|
async _handleLogin(payload, requestId) {
|
|
152
|
-
const { propfirmKey, username, password } = payload;
|
|
177
|
+
const { propfirmKey, username, password, cachedAccounts } = payload;
|
|
153
178
|
if (!propfirmKey || !username || !password) {
|
|
154
179
|
return { error: 'Missing credentials', requestId };
|
|
155
180
|
}
|
|
@@ -165,20 +190,30 @@ class RithmicBrokerDaemon {
|
|
|
165
190
|
const Service = loadRithmicService();
|
|
166
191
|
const service = new Service(propfirmKey);
|
|
167
192
|
|
|
168
|
-
log('INFO', 'Logging in...', { propfirm: propfirmKey });
|
|
169
|
-
|
|
193
|
+
log('INFO', 'Logging in...', { propfirm: propfirmKey, hasCachedAccounts: !!cachedAccounts });
|
|
194
|
+
|
|
195
|
+
// Login with optional cached accounts (skips fetchAccounts API call)
|
|
196
|
+
const loginOptions = cachedAccounts ? { skipFetchAccounts: true, cachedAccounts } : {};
|
|
197
|
+
const result = await service.login(username, password, loginOptions);
|
|
170
198
|
|
|
171
199
|
if (result.success) {
|
|
200
|
+
// Use cached accounts if provided, otherwise use result from login
|
|
201
|
+
const accounts = cachedAccounts || result.accounts || [];
|
|
202
|
+
|
|
172
203
|
this.connections.set(propfirmKey, {
|
|
173
204
|
service,
|
|
174
205
|
credentials: { username, password },
|
|
175
206
|
connectedAt: new Date().toISOString(),
|
|
176
|
-
accounts
|
|
207
|
+
accounts,
|
|
208
|
+
status: 'connected',
|
|
177
209
|
});
|
|
210
|
+
|
|
178
211
|
this._setupPnLUpdates(propfirmKey, service);
|
|
212
|
+
this.reconnectManager.setupConnectionMonitoring(propfirmKey, service);
|
|
179
213
|
this._saveState();
|
|
180
|
-
|
|
181
|
-
|
|
214
|
+
|
|
215
|
+
log('INFO', 'Login successful', { propfirm: propfirmKey, accounts: accounts.length });
|
|
216
|
+
return { type: 'loginResult', payload: { success: true, accounts }, requestId };
|
|
182
217
|
}
|
|
183
218
|
|
|
184
219
|
log('WARN', 'Login failed', { propfirm: propfirmKey, error: result.error });
|
|
@@ -226,8 +261,14 @@ class RithmicBrokerDaemon {
|
|
|
226
261
|
async _handleGetAccounts(requestId) {
|
|
227
262
|
const allAccounts = [];
|
|
228
263
|
for (const [propfirmKey, conn] of this.connections) {
|
|
264
|
+
// Include accounts even if service is temporarily disconnected (from cache)
|
|
229
265
|
for (const acc of conn.accounts || []) {
|
|
230
|
-
allAccounts.push({
|
|
266
|
+
allAccounts.push({
|
|
267
|
+
...acc,
|
|
268
|
+
propfirmKey,
|
|
269
|
+
propfirm: conn.service?.propfirm?.name || propfirmKey,
|
|
270
|
+
connectionStatus: conn.status
|
|
271
|
+
});
|
|
231
272
|
}
|
|
232
273
|
}
|
|
233
274
|
return { type: 'accounts', payload: { accounts: allAccounts }, requestId };
|
|
@@ -258,8 +299,15 @@ class RithmicBrokerDaemon {
|
|
|
258
299
|
|
|
259
300
|
async _handleGetContracts(payload, requestId) {
|
|
260
301
|
const conn = this.connections.get(payload.propfirmKey);
|
|
261
|
-
if (!conn?.service) return { error: 'Not connected', requestId };
|
|
262
|
-
|
|
302
|
+
if (!conn?.service) return { error: 'Not connected to broker', requestId };
|
|
303
|
+
|
|
304
|
+
try {
|
|
305
|
+
const result = await conn.service.getContracts();
|
|
306
|
+
return { type: 'contracts', payload: result, requestId };
|
|
307
|
+
} catch (err) {
|
|
308
|
+
log('ERROR', 'getContracts failed', { propfirm: payload.propfirmKey, error: err.message });
|
|
309
|
+
return { type: 'contracts', payload: { success: false, error: err.message, contracts: [] }, requestId };
|
|
310
|
+
}
|
|
263
311
|
}
|
|
264
312
|
|
|
265
313
|
async _handleSearchContracts(payload, requestId) {
|
|
@@ -274,26 +322,23 @@ class RithmicBrokerDaemon {
|
|
|
274
322
|
return { type: 'credentials', payload: conn.service.getRithmicCredentials?.() || null, requestId };
|
|
275
323
|
}
|
|
276
324
|
|
|
325
|
+
/**
|
|
326
|
+
* Save state including accounts (for reconnection without API calls)
|
|
327
|
+
*/
|
|
277
328
|
_saveState() {
|
|
278
|
-
const state = { connections: [] };
|
|
329
|
+
const state = { connections: [], savedAt: new Date().toISOString() };
|
|
279
330
|
for (const [key, conn] of this.connections) {
|
|
280
|
-
if (conn.credentials)
|
|
331
|
+
if (conn.credentials) {
|
|
332
|
+
state.connections.push({
|
|
333
|
+
propfirmKey: key,
|
|
334
|
+
credentials: conn.credentials,
|
|
335
|
+
accounts: conn.accounts || [], // Save accounts to avoid fetchAccounts on restore
|
|
336
|
+
connectedAt: conn.connectedAt
|
|
337
|
+
});
|
|
338
|
+
}
|
|
281
339
|
}
|
|
282
340
|
try { fs.writeFileSync(STATE_FILE, JSON.stringify(state)); } catch (e) { /* ignore */ }
|
|
283
341
|
}
|
|
284
|
-
|
|
285
|
-
async _restoreState() {
|
|
286
|
-
if (!fs.existsSync(STATE_FILE)) return;
|
|
287
|
-
try {
|
|
288
|
-
const data = JSON.parse(fs.readFileSync(STATE_FILE, 'utf8'));
|
|
289
|
-
for (const conn of data.connections || []) {
|
|
290
|
-
if (conn.credentials && conn.propfirmKey) {
|
|
291
|
-
log('INFO', 'Restoring connection...', { propfirm: conn.propfirmKey });
|
|
292
|
-
await this._handleLogin({ ...conn.credentials, propfirmKey: conn.propfirmKey }, null);
|
|
293
|
-
}
|
|
294
|
-
}
|
|
295
|
-
} catch (e) { log('WARN', 'Restore failed', { error: e.message }); }
|
|
296
|
-
}
|
|
297
342
|
}
|
|
298
343
|
|
|
299
344
|
// Main entry point
|