hedgequantx 2.9.144 → 2.9.146
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/lib/smart-logs-engine.js +26 -42
- package/src/pages/algo/algo-executor.js +26 -20
- package/src/pages/algo/multi-symbol-executor.js +42 -25
- package/src/pages/algo/ui.js +3 -3
- package/src/services/rithmic/index.js +29 -32
- package/src/services/rithmic/reconnect.js +138 -0
- package/src/services/rithmic-broker/client.js +4 -4
- package/src/services/rithmic-broker/daemon-reconnect.js +312 -0
- package/src/services/rithmic-broker/daemon.js +62 -24
package/package.json
CHANGED
|
@@ -358,45 +358,7 @@ class SmartLogsEngine {
|
|
|
358
358
|
|
|
359
359
|
const t = [];
|
|
360
360
|
|
|
361
|
-
// ===
|
|
362
|
-
if (isBull) {
|
|
363
|
-
t.push({ type: 'bullish', message: `${S} ${P} heavy paper on the bid | Size lifting offers ${D} | Tape running` });
|
|
364
|
-
t.push({ type: 'bullish', message: `${SL} bid refreshing at ${P} | ${OFI} | Buyers in control` });
|
|
365
|
-
t.push({ type: 'bullish', message: `Large prints on ${S} ${P} | ${VPIN} toxic | Institutions lifting` });
|
|
366
|
-
t.push({ type: 'bullish', message: `${S} squeeze building at ${P} | Shorts underwater | ${D} accelerating` });
|
|
367
|
-
t.push({ type: 'bullish', message: `Block buyer ${SL} ${P} | ${IMB} buy tape | Iceberg absorbing supply` });
|
|
368
|
-
t.push({ type: 'bullish', message: `${S} momentum ignition at ${P} | DOM stacked bid | Offers lifted` });
|
|
369
|
-
t.push({ type: 'bullish', message: `Aggressive buying ${S} at ${P} | ${ZS} | Sellers capitulating` });
|
|
370
|
-
t.push({ type: 'bullish', message: `${S} ${P} bid wall holding | ${D} | Passive offers exhausted` });
|
|
371
|
-
t.push({ type: 'bullish', message: `Dark pool bid ${SL} ${P} | ${VPIN} | Smart money accumulating` });
|
|
372
|
-
t.push({ type: 'bullish', message: `${S} footprint bullish at ${P} | ${tps} tps | Institutional absorption` });
|
|
373
|
-
}
|
|
374
|
-
// === BEARISH FLOW ===
|
|
375
|
-
else if (isBear) {
|
|
376
|
-
t.push({ type: 'bearish', message: `${S} ${P} heavy paper on offer | Size hitting bids ${D} | Tape dumping` });
|
|
377
|
-
t.push({ type: 'bearish', message: `${SL} offer refreshing at ${P} | ${OFI} | Sellers in control` });
|
|
378
|
-
t.push({ type: 'bearish', message: `Large prints selling ${S} ${P} | ${VPIN} toxic | Institutions hitting` });
|
|
379
|
-
t.push({ type: 'bearish', message: `${S} breakdown at ${P} | Longs underwater | ${D} accelerating` });
|
|
380
|
-
t.push({ type: 'bearish', message: `Block seller ${SL} ${P} | ${IMB} sell tape | Iceberg absorbing demand` });
|
|
381
|
-
t.push({ type: 'bearish', message: `${S} momentum breakdown at ${P} | DOM stacked offer | Bids hit` });
|
|
382
|
-
t.push({ type: 'bearish', message: `Aggressive selling ${S} at ${P} | ${ZS} | Buyers capitulating` });
|
|
383
|
-
t.push({ type: 'bearish', message: `${S} ${P} offer wall capping | ${D} | Passive bids exhausted` });
|
|
384
|
-
t.push({ type: 'bearish', message: `Dark pool offer ${SL} ${P} | ${VPIN} | Smart money distributing` });
|
|
385
|
-
t.push({ type: 'bearish', message: `${S} footprint bearish at ${P} | ${tps} tps | Institutional distribution` });
|
|
386
|
-
}
|
|
387
|
-
// === NEUTRAL/CHOP ===
|
|
388
|
-
else {
|
|
389
|
-
t.push({ type: 'analysis', message: `${S} ${P} two-way paper | ${D} flat | Locals making markets` });
|
|
390
|
-
t.push({ type: 'analysis', message: `${SL} chopping at ${P} | Spread ${spd}t | No directional flow` });
|
|
391
|
-
t.push({ type: 'analysis', message: `${S} balanced at ${P} | ${VPIN} | Waiting for size to show` });
|
|
392
|
-
t.push({ type: 'analysis', message: `Range trade ${S} ${P} | Book symmetric | Mean reversion active` });
|
|
393
|
-
t.push({ type: 'analysis', message: `${S} consolidating at ${P} | ${ZS} | No edge either side` });
|
|
394
|
-
t.push({ type: 'analysis', message: `Quiet tape ${SL} ${P} | ${tps} tps | Institutions on sidelines` });
|
|
395
|
-
t.push({ type: 'analysis', message: `${S} ${P} fair value auction | ${D} | Algos scalping spread` });
|
|
396
|
-
t.push({ type: 'analysis', message: `Flow balanced ${S} at ${P} | ${OFI} | Awaiting catalyst` });
|
|
397
|
-
}
|
|
398
|
-
|
|
399
|
-
// === HQX-2B: Liquidity Sweep ===
|
|
361
|
+
// === HQX-2B: Liquidity Sweep Strategy ===
|
|
400
362
|
if (isHQX2B) {
|
|
401
363
|
if (phase === 'ready' && zones > 0) {
|
|
402
364
|
t.push({ type: 'signal', message: `${chalk.green.bold('SWEEP')} ${S} ${P} | ${zones} stops triggered | Reversal confirmed` });
|
|
@@ -406,12 +368,24 @@ class SmartLogsEngine {
|
|
|
406
368
|
t.push({ type: 'analysis', message: `${S} ${P} approaching liquidity | ${zones} clusters mapped | Sweep imminent` });
|
|
407
369
|
t.push({ type: 'analysis', message: `Stop hunt setup ${SL} ${P} | ${swings} swings | Watching penetration` });
|
|
408
370
|
t.push({ type: 'analysis', message: `${S} zone test at ${P} | ${zones} targets | Monitoring rejection` });
|
|
371
|
+
} else if (isBull) {
|
|
372
|
+
t.push({ type: 'bullish', message: `${S} ${P} bid absorption | ${D} | Stops below being hunted` });
|
|
373
|
+
t.push({ type: 'bullish', message: `${SL} liquidity building at ${P} | Sweep setup forming | ${swings} pivots` });
|
|
374
|
+
t.push({ type: 'bullish', message: `Institutional bid ${S} ${P} | ${OFI} | Retail shorts trapped` });
|
|
375
|
+
t.push({ type: 'bullish', message: `${S} false breakdown at ${P} | ${D} | Smart money buying dip` });
|
|
376
|
+
} else if (isBear) {
|
|
377
|
+
t.push({ type: 'bearish', message: `${S} ${P} offer absorption | ${D} | Stops above being hunted` });
|
|
378
|
+
t.push({ type: 'bearish', message: `${SL} liquidity building at ${P} | Sweep setup forming | ${swings} pivots` });
|
|
379
|
+
t.push({ type: 'bearish', message: `Institutional offer ${S} ${P} | ${OFI} | Retail longs trapped` });
|
|
380
|
+
t.push({ type: 'bearish', message: `${S} false breakout at ${P} | ${D} | Smart money selling rally` });
|
|
409
381
|
} else {
|
|
410
|
-
t.push({ type: 'system', message: `Mapping ${S} structure at ${P} | ${bars} bars | ${swings} swings
|
|
411
|
-
t.push({ type: 'system', message: `${SL}
|
|
382
|
+
t.push({ type: 'system', message: `Mapping ${S} structure at ${P} | ${bars} bars | ${swings} swings` });
|
|
383
|
+
t.push({ type: 'system', message: `${SL} scanning zones at ${P} | ${tickCount} ticks | Detecting clusters` });
|
|
384
|
+
t.push({ type: 'analysis', message: `${S} ${P} consolidating | ${D} | Liquidity pooling at extremes` });
|
|
385
|
+
t.push({ type: 'analysis', message: `${SL} range at ${P} | Stops accumulating | Sweep pending` });
|
|
412
386
|
}
|
|
413
387
|
}
|
|
414
|
-
// === ULTRA-SCALPING: Quant/Stats ===
|
|
388
|
+
// === ULTRA-SCALPING: Quant/Stats Strategy ===
|
|
415
389
|
else {
|
|
416
390
|
if (phase === 'ready') {
|
|
417
391
|
t.push({ type: 'signal', message: `${chalk.green.bold('EDGE')} ${S} ${P} | ${ZS} breach | ${VPIN} | Execute` });
|
|
@@ -421,9 +395,19 @@ class SmartLogsEngine {
|
|
|
421
395
|
t.push({ type: 'analysis', message: `${S} ${P} factors building | ${ZS} | Threshold proximity` });
|
|
422
396
|
t.push({ type: 'analysis', message: `${SL} edge forming at ${P} | ${VPIN} | Monitoring` });
|
|
423
397
|
t.push({ type: 'analysis', message: `Statistical setup ${S} ${P} | ${OFI} | Alignment ${IMB}` });
|
|
398
|
+
} else if (isBull) {
|
|
399
|
+
t.push({ type: 'bullish', message: `${S} ${P} heavy paper bid | ${D} | ${VPIN} rising` });
|
|
400
|
+
t.push({ type: 'bullish', message: `${SL} ${P} ${ZS} bullish | ${OFI} | Mean reversion long` });
|
|
401
|
+
t.push({ type: 'bullish', message: `${S} momentum ${P} | ${IMB} buy | Quant signal building` });
|
|
402
|
+
} else if (isBear) {
|
|
403
|
+
t.push({ type: 'bearish', message: `${S} ${P} heavy paper offer | ${D} | ${VPIN} rising` });
|
|
404
|
+
t.push({ type: 'bearish', message: `${SL} ${P} ${ZS} bearish | ${OFI} | Mean reversion short` });
|
|
405
|
+
t.push({ type: 'bearish', message: `${S} momentum ${P} | ${IMB} sell | Quant signal building` });
|
|
424
406
|
} else {
|
|
425
407
|
t.push({ type: 'system', message: `Calibrating ${S} models at ${P} | ${bars} bars | Computing factors` });
|
|
426
408
|
t.push({ type: 'system', message: `${SL} tick analysis at ${P} | ${tickCount} samples | ${tps} tps` });
|
|
409
|
+
t.push({ type: 'analysis', message: `${S} ${P} neutral | ${ZS} | ${VPIN} | Awaiting edge` });
|
|
410
|
+
t.push({ type: 'analysis', message: `${SL} range at ${P} | ${D} | Statistical mean reversion` });
|
|
427
411
|
}
|
|
428
412
|
}
|
|
429
413
|
|
|
@@ -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
|
|
|
@@ -286,16 +286,22 @@ const executeMultiSymbol = async ({ service, account, contracts, config, strateg
|
|
|
286
286
|
let startingPnL = null;
|
|
287
287
|
const pollPnL = async () => {
|
|
288
288
|
try {
|
|
289
|
-
// Get P&L from cache (no API call)
|
|
290
289
|
const accId = account.rithmicAccountId || account.accountId;
|
|
291
|
-
const pnlData = service.getAccountPnL ? service.getAccountPnL(accId) : null;
|
|
292
290
|
|
|
293
|
-
|
|
291
|
+
// Get P&L from cache (sync for RithmicService, async for BrokerClient)
|
|
292
|
+
let pnlData = null;
|
|
293
|
+
if (service.getAccountPnL) {
|
|
294
|
+
const result = service.getAccountPnL(accId);
|
|
295
|
+
pnlData = result && result.then ? await result : result; // Handle both sync/async
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
if (pnlData && pnlData.pnl !== null && pnlData.pnl !== undefined && !isNaN(pnlData.pnl)) {
|
|
294
299
|
if (startingPnL === null) startingPnL = pnlData.pnl;
|
|
295
|
-
|
|
300
|
+
const newPnl = pnlData.pnl - startingPnL;
|
|
301
|
+
if (!isNaN(newPnl)) globalStats.pnl = newPnl;
|
|
296
302
|
}
|
|
297
303
|
|
|
298
|
-
// Check positions (less frequent - every 10s
|
|
304
|
+
// Check positions (less frequent - every 10s)
|
|
299
305
|
if (Date.now() % 10000 < 2000) {
|
|
300
306
|
const posResult = await service.getPositions(accId);
|
|
301
307
|
if (posResult.success && posResult.positions) {
|
|
@@ -306,13 +312,15 @@ const executeMultiSymbol = async ({ service, account, contracts, config, strateg
|
|
|
306
312
|
}
|
|
307
313
|
}
|
|
308
314
|
|
|
309
|
-
// Risk checks
|
|
310
|
-
if (globalStats.pnl
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
315
|
+
// Risk checks (only if pnl is valid)
|
|
316
|
+
if (!isNaN(globalStats.pnl)) {
|
|
317
|
+
if (globalStats.pnl >= dailyTarget) {
|
|
318
|
+
stopReason = 'target'; running = false;
|
|
319
|
+
ui.addLog('fill_win', `TARGET REACHED! +$${globalStats.pnl.toFixed(2)}`);
|
|
320
|
+
} else if (globalStats.pnl <= -maxRisk) {
|
|
321
|
+
stopReason = 'risk'; running = false;
|
|
322
|
+
ui.addLog('fill_loss', `MAX RISK! -$${Math.abs(globalStats.pnl).toFixed(2)}`);
|
|
323
|
+
}
|
|
316
324
|
}
|
|
317
325
|
} catch (e) { /* silent */ }
|
|
318
326
|
};
|
|
@@ -321,23 +329,29 @@ const executeMultiSymbol = async ({ service, account, contracts, config, strateg
|
|
|
321
329
|
const pnlInterval = setInterval(() => { if (running) pollPnL(); }, 2000);
|
|
322
330
|
pollPnL();
|
|
323
331
|
|
|
324
|
-
// Live analysis logs every
|
|
332
|
+
// Live analysis logs every 5 seconds (rotates through symbols with data)
|
|
325
333
|
let liveLogSymbolIndex = 0;
|
|
326
|
-
let
|
|
334
|
+
let lastLiveLogTime = 0;
|
|
327
335
|
const liveLogInterval = setInterval(() => {
|
|
328
336
|
if (!running) return;
|
|
329
|
-
const now =
|
|
330
|
-
if (now
|
|
331
|
-
|
|
337
|
+
const now = Date.now();
|
|
338
|
+
if (now - lastLiveLogTime < 5000) return; // Every 5 seconds
|
|
339
|
+
lastLiveLogTime = now;
|
|
332
340
|
|
|
333
|
-
// Get
|
|
341
|
+
// Get symbols with tick data (skip symbols without data)
|
|
334
342
|
const symbolCodes = Array.from(symbolData.keys());
|
|
335
343
|
if (symbolCodes.length === 0) return;
|
|
336
344
|
|
|
337
|
-
|
|
338
|
-
|
|
345
|
+
// Find next symbol with data
|
|
346
|
+
let attempts = 0;
|
|
347
|
+
let symbolCode, data;
|
|
348
|
+
do {
|
|
349
|
+
symbolCode = symbolCodes[liveLogSymbolIndex % symbolCodes.length];
|
|
350
|
+
liveLogSymbolIndex++;
|
|
351
|
+
data = symbolData.get(symbolCode);
|
|
352
|
+
attempts++;
|
|
353
|
+
} while ((!data || data.stats.tickCount === 0) && attempts < symbolCodes.length);
|
|
339
354
|
|
|
340
|
-
const data = symbolData.get(symbolCode);
|
|
341
355
|
if (!data) return;
|
|
342
356
|
|
|
343
357
|
const state = data.strategy.getAnalysisState?.(symbolCode, data.stats.lastPrice);
|
|
@@ -353,10 +367,13 @@ const executeMultiSymbol = async ({ service, account, contracts, config, strateg
|
|
|
353
367
|
tickCount: data.stats.tickCount || 0,
|
|
354
368
|
};
|
|
355
369
|
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
370
|
+
// Only log if we have meaningful data
|
|
371
|
+
if (logState.price > 0 || logState.tickCount > 0) {
|
|
372
|
+
logsEngine.setSymbol(symbolCode);
|
|
373
|
+
const log = logsEngine.getLog(logState);
|
|
374
|
+
ui.addLog(log.type, log.message);
|
|
375
|
+
if (log.logToSession) sessionLogger.log('ANALYSIS', `[${symbolCode}] ${log.message}`);
|
|
376
|
+
}
|
|
360
377
|
}, 1000);
|
|
361
378
|
|
|
362
379
|
// Key handler
|
package/src/pages/algo/ui.js
CHANGED
|
@@ -188,9 +188,9 @@ class AlgoUI {
|
|
|
188
188
|
// Row 1: Account | Symbol(s)
|
|
189
189
|
const accountName = String(stats.accountName || 'N/A').substring(0, 40);
|
|
190
190
|
const isMultiSymbol = stats.symbolCount && stats.symbolCount > 1;
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
191
|
+
// Show actual symbols list, truncate if too long
|
|
192
|
+
const symbolsStr = String(stats.symbols || stats.symbol || 'N/A');
|
|
193
|
+
const symbolDisplay = symbolsStr.length > 35 ? symbolsStr.substring(0, 32) + '...' : symbolsStr;
|
|
194
194
|
const r1c1 = buildCell('Account', accountName, chalk.cyan, colL);
|
|
195
195
|
const r1c2 = buildCell(isMultiSymbol ? 'Symbols' : 'Symbol', symbolDisplay, chalk.yellow, colR);
|
|
196
196
|
row(r1c1.padded, r1c2.padded);
|
|
@@ -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
|
+
};
|
|
@@ -232,13 +232,13 @@ class RithmicBrokerClient extends EventEmitter {
|
|
|
232
232
|
*/
|
|
233
233
|
getRithmicCredentials() {
|
|
234
234
|
// Sync call - return cached credentials
|
|
235
|
-
|
|
236
|
-
return
|
|
235
|
+
if (!this.credentials) return null;
|
|
236
|
+
return {
|
|
237
237
|
userId: this.credentials.username,
|
|
238
238
|
password: this.credentials.password,
|
|
239
239
|
systemName: this.propfirm?.systemName || 'Apex',
|
|
240
|
-
gateway: 'wss://
|
|
241
|
-
}
|
|
240
|
+
gateway: 'wss://rprotocol.rithmic.com:443',
|
|
241
|
+
};
|
|
242
242
|
}
|
|
243
243
|
|
|
244
244
|
/**
|
|
@@ -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 };
|
|
@@ -274,26 +315,23 @@ class RithmicBrokerDaemon {
|
|
|
274
315
|
return { type: 'credentials', payload: conn.service.getRithmicCredentials?.() || null, requestId };
|
|
275
316
|
}
|
|
276
317
|
|
|
318
|
+
/**
|
|
319
|
+
* Save state including accounts (for reconnection without API calls)
|
|
320
|
+
*/
|
|
277
321
|
_saveState() {
|
|
278
|
-
const state = { connections: [] };
|
|
322
|
+
const state = { connections: [], savedAt: new Date().toISOString() };
|
|
279
323
|
for (const [key, conn] of this.connections) {
|
|
280
|
-
if (conn.credentials)
|
|
324
|
+
if (conn.credentials) {
|
|
325
|
+
state.connections.push({
|
|
326
|
+
propfirmKey: key,
|
|
327
|
+
credentials: conn.credentials,
|
|
328
|
+
accounts: conn.accounts || [], // Save accounts to avoid fetchAccounts on restore
|
|
329
|
+
connectedAt: conn.connectedAt
|
|
330
|
+
});
|
|
331
|
+
}
|
|
281
332
|
}
|
|
282
333
|
try { fs.writeFileSync(STATE_FILE, JSON.stringify(state)); } catch (e) { /* ignore */ }
|
|
283
334
|
}
|
|
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
335
|
}
|
|
298
336
|
|
|
299
337
|
// Main entry point
|