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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hedgequantx",
3
- "version": "2.9.145",
3
+ "version": "2.9.147",
4
4
  "description": "HedgeQuantX - Prop Futures Trading CLI",
5
5
  "main": "src/app.js",
6
6
  "bin": {
@@ -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
- if (pnlData && pnlData.pnl !== null) {
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
- stats.pnl = pnlData.pnl - startingPnL;
371
- if (stats.pnl !== 0) strategy.recordTradeResult(stats.pnl);
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 (less frequent - every 10s to reduce API calls)
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 (stats.pnl >= dailyTarget) {
395
- stopReason = 'target'; running = false;
396
- ui.addLog('fill_win', `TARGET REACHED! +$${stats.pnl.toFixed(2)}`);
397
- sessionLogger.log('TARGET', `Daily target reached: +$${stats.pnl.toFixed(2)}`);
398
- } else if (stats.pnl <= -maxRisk) {
399
- stopReason = 'risk'; running = false;
400
- ui.addLog('fill_loss', `MAX RISK! -$${Math.abs(stats.pnl).toFixed(2)}`);
401
- sessionLogger.log('RISK', `Max risk hit: -$${Math.abs(stats.pnl).toFixed(2)}`);
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
- spinner.fail(`Failed to load contracts: ${contractsResult.error || 'No contracts'}`);
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
- async login(username, password) {
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
- try {
125
- await fetchAccounts(this);
126
- log.debug('Fetched accounts', { count: this.accounts.length });
127
- } catch (err) {
128
- log.warn('Failed to fetch accounts', { error: err.message });
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
- if (!this.credentials) {
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
- await this._restoreState();
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
- const result = await service.login(username, password);
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: result.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
- log('INFO', 'Login successful', { propfirm: propfirmKey, accounts: result.accounts?.length });
181
- return { type: 'loginResult', payload: { success: true, accounts: result.accounts }, requestId };
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({ ...acc, propfirmKey, propfirm: conn.service.propfirm?.name || propfirmKey });
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
- return { type: 'contracts', payload: await conn.service.getContracts(), requestId };
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) state.connections.push({ propfirmKey: key, credentials: 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