hedgequantx 2.9.142 → 2.9.144
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 +10 -0
- package/src/menus/connect.js +18 -8
- package/src/pages/algo/algo-executor.js +26 -22
- package/src/pages/algo/multi-symbol-executor.js +17 -14
- package/src/services/rithmic/index.js +26 -27
- package/src/services/rithmic-broker/client.js +300 -0
- package/src/services/rithmic-broker/daemon.js +305 -0
- package/src/services/rithmic-broker/index.js +46 -0
- package/src/services/rithmic-broker/manager.js +256 -0
- package/src/services/session.js +52 -16
package/package.json
CHANGED
|
@@ -214,15 +214,25 @@ const SYMBOLS = {
|
|
|
214
214
|
// S&P
|
|
215
215
|
ES: { name: 'ES', floor: 'Spooz', asset: 'index', slang: 'the Spooz' },
|
|
216
216
|
MES: { name: 'MES', floor: 'Micro S&P', asset: 'index', slang: 'micro spooz' },
|
|
217
|
+
// Dow
|
|
218
|
+
YM: { name: 'YM', floor: 'Dow', asset: 'index', slang: 'the Dow' },
|
|
219
|
+
MYM: { name: 'MYM', floor: 'Micro Dow', asset: 'index', slang: 'mini Dow' },
|
|
217
220
|
// Crude
|
|
218
221
|
CL: { name: 'CL', floor: 'Crude', asset: 'energy', slang: 'oil' },
|
|
219
222
|
MCL: { name: 'MCL', floor: 'Micro Crude', asset: 'energy', slang: 'micro crude' },
|
|
220
223
|
// Gold
|
|
221
224
|
GC: { name: 'GC', floor: 'Gold', asset: 'metals', slang: 'yellow metal' },
|
|
222
225
|
MGC: { name: 'MGC', floor: 'Micro Gold', asset: 'metals', slang: 'micro gold' },
|
|
226
|
+
'1OZ': { name: '1OZ', floor: 'Micro Gold', asset: 'metals', slang: '1oz gold' },
|
|
227
|
+
// Silver
|
|
228
|
+
SI: { name: 'SI', floor: 'Silver', asset: 'metals', slang: 'silver' },
|
|
229
|
+
SIL: { name: 'SIL', floor: 'Micro Silver', asset: 'metals', slang: 'micro silver' },
|
|
223
230
|
// Bonds
|
|
224
231
|
ZB: { name: 'ZB', floor: 'Bonds', asset: 'rates', slang: 'long bond' },
|
|
225
232
|
ZN: { name: 'ZN', floor: '10Y', asset: 'rates', slang: 'tens' },
|
|
233
|
+
// Russell
|
|
234
|
+
RTY: { name: 'RTY', floor: 'Russell', asset: 'index', slang: 'small caps' },
|
|
235
|
+
M2K: { name: 'M2K', floor: 'Micro Russell', asset: 'index', slang: 'micro russell' },
|
|
226
236
|
// Default
|
|
227
237
|
DEFAULT: { name: 'Contract', floor: 'Futures', asset: 'futures', slang: 'contract' }
|
|
228
238
|
};
|
package/src/menus/connect.js
CHANGED
|
@@ -6,7 +6,7 @@ const chalk = require('chalk');
|
|
|
6
6
|
const ora = require('ora');
|
|
7
7
|
|
|
8
8
|
const { connections } = require('../services');
|
|
9
|
-
const {
|
|
9
|
+
const { RithmicBrokerClient, manager: brokerManager } = require('../services/rithmic-broker');
|
|
10
10
|
const { PROPFIRM_CHOICES } = require('../config');
|
|
11
11
|
const { getLogoWidth, centerText, prepareStdin, displayBanner , clearScreen } = require('../ui');
|
|
12
12
|
const { validateUsername, validatePassword } = require('../security');
|
|
@@ -87,19 +87,29 @@ const rithmicMenu = async () => {
|
|
|
87
87
|
const credentials = await loginPrompt(selectedPropfirm.name);
|
|
88
88
|
if (!credentials) return null;
|
|
89
89
|
|
|
90
|
-
const spinner = ora({ text: '
|
|
90
|
+
const spinner = ora({ text: 'STARTING BROKER DAEMON...', color: 'yellow' }).start();
|
|
91
91
|
|
|
92
92
|
try {
|
|
93
|
-
|
|
94
|
-
const
|
|
93
|
+
// Ensure broker daemon is running
|
|
94
|
+
const daemonResult = await brokerManager.ensureRunning();
|
|
95
|
+
if (!daemonResult.success) {
|
|
96
|
+
spinner.fail('FAILED TO START BROKER DAEMON');
|
|
97
|
+
console.log(chalk.yellow(` → ${daemonResult.error}`));
|
|
98
|
+
await new Promise(r => setTimeout(r, 3000));
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
spinner.text = 'CONNECTING TO RITHMIC...';
|
|
103
|
+
const client = new RithmicBrokerClient(selectedPropfirm.key);
|
|
104
|
+
const result = await client.login(credentials.username, credentials.password);
|
|
95
105
|
|
|
96
106
|
if (result.success) {
|
|
97
107
|
spinner.text = 'FETCHING ACCOUNTS...';
|
|
98
|
-
const accResult = await
|
|
99
|
-
connections.add('rithmic',
|
|
100
|
-
spinner.succeed(`CONNECTED TO ${
|
|
108
|
+
const accResult = await client.getTradingAccounts();
|
|
109
|
+
connections.add('rithmic', client, client.propfirm.name || selectedPropfirm.name);
|
|
110
|
+
spinner.succeed(`CONNECTED TO ${selectedPropfirm.name.toUpperCase()} (${accResult.accounts?.length || 0} ACCOUNTS)`);
|
|
101
111
|
await new Promise(r => setTimeout(r, 1500));
|
|
102
|
-
return
|
|
112
|
+
return client;
|
|
103
113
|
} else {
|
|
104
114
|
// Detailed error messages for common Rithmic issues
|
|
105
115
|
const err = (result.error || '').toLowerCase();
|
|
@@ -358,32 +358,36 @@ const executeAlgo = async ({ service, account, contract, config, strategy: strat
|
|
|
358
358
|
ui.addLog('error', `Failed to connect: ${e.message}`);
|
|
359
359
|
}
|
|
360
360
|
|
|
361
|
+
// P&L polling - uses CACHED data (NO API CALLS to avoid Rithmic rate limits)
|
|
361
362
|
const pollPnL = async () => {
|
|
362
363
|
try {
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
364
|
+
// Get P&L from cache (no API call)
|
|
365
|
+
const accId = account.rithmicAccountId || account.accountId;
|
|
366
|
+
const pnlData = service.getAccountPnL ? service.getAccountPnL(accId) : null;
|
|
367
|
+
|
|
368
|
+
if (pnlData && pnlData.pnl !== null) {
|
|
369
|
+
if (startingPnL === null) startingPnL = pnlData.pnl;
|
|
370
|
+
stats.pnl = pnlData.pnl - startingPnL;
|
|
371
|
+
if (stats.pnl !== 0) strategy.recordTradeResult(stats.pnl);
|
|
371
372
|
}
|
|
372
373
|
|
|
373
|
-
|
|
374
|
-
if (
|
|
375
|
-
const
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
374
|
+
// Check positions (less frequent - every 10s to reduce API calls)
|
|
375
|
+
if (Date.now() % 10000 < 2000) {
|
|
376
|
+
const posResult = await service.getPositions(accId);
|
|
377
|
+
if (posResult.success && posResult.positions) {
|
|
378
|
+
const pos = posResult.positions.find(p => {
|
|
379
|
+
const sym = p.contractId || p.symbol || '';
|
|
380
|
+
return sym.includes(contract.name) || sym.includes(contractId);
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
if (pos && pos.quantity !== 0) {
|
|
384
|
+
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
|
+
} else {
|
|
389
|
+
currentPosition = 0;
|
|
390
|
+
}
|
|
387
391
|
}
|
|
388
392
|
}
|
|
389
393
|
|
|
@@ -282,24 +282,27 @@ const executeMultiSymbol = async ({ service, account, contracts, config, strateg
|
|
|
282
282
|
ui.addLog('error', `Failed to connect: ${e.message}`);
|
|
283
283
|
}
|
|
284
284
|
|
|
285
|
-
// P&L polling
|
|
285
|
+
// P&L polling - uses CACHED data (NO API CALLS)
|
|
286
|
+
let startingPnL = null;
|
|
286
287
|
const pollPnL = async () => {
|
|
287
288
|
try {
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
289
|
+
// Get P&L from cache (no API call)
|
|
290
|
+
const accId = account.rithmicAccountId || account.accountId;
|
|
291
|
+
const pnlData = service.getAccountPnL ? service.getAccountPnL(accId) : null;
|
|
292
|
+
|
|
293
|
+
if (pnlData && pnlData.pnl !== null) {
|
|
294
|
+
if (startingPnL === null) startingPnL = pnlData.pnl;
|
|
295
|
+
globalStats.pnl = pnlData.pnl - startingPnL;
|
|
295
296
|
}
|
|
296
297
|
|
|
297
|
-
// Check positions
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
298
|
+
// Check positions (less frequent - every 10s instead of 2s)
|
|
299
|
+
if (Date.now() % 10000 < 2000) {
|
|
300
|
+
const posResult = await service.getPositions(accId);
|
|
301
|
+
if (posResult.success && posResult.positions) {
|
|
302
|
+
for (const [sym, data] of symbolData) {
|
|
303
|
+
const pos = posResult.positions.find(p => (p.contractId || p.symbol || '').includes(sym));
|
|
304
|
+
data.stats.position = pos?.quantity || 0;
|
|
305
|
+
}
|
|
303
306
|
}
|
|
304
307
|
}
|
|
305
308
|
|
|
@@ -269,6 +269,23 @@ class RithmicService extends EventEmitter {
|
|
|
269
269
|
|
|
270
270
|
async getTradingAccounts() { return getTradingAccounts(this); }
|
|
271
271
|
async getPositions() { return getPositions(this); }
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Get cached P&L for an account (NO API CALL - from PNL_PLANT stream cache)
|
|
275
|
+
* @param {string} accountId - Account ID (rithmicAccountId)
|
|
276
|
+
* @returns {Object} { pnl, openPnl, closedPnl, balance }
|
|
277
|
+
*/
|
|
278
|
+
getAccountPnL(accountId) {
|
|
279
|
+
const pnlData = this.accountPnL.get(accountId);
|
|
280
|
+
if (!pnlData) return { pnl: null, openPnl: null, closedPnl: null, balance: null };
|
|
281
|
+
return {
|
|
282
|
+
pnl: pnlData.dayPnl !== undefined ? pnlData.dayPnl :
|
|
283
|
+
((pnlData.openPositionPnl || 0) + (pnlData.closedPositionPnl || 0)),
|
|
284
|
+
openPnl: pnlData.openPositionPnl || 0,
|
|
285
|
+
closedPnl: pnlData.closedPositionPnl || 0,
|
|
286
|
+
balance: pnlData.accountBalance || null,
|
|
287
|
+
};
|
|
288
|
+
}
|
|
272
289
|
async getOrders() { return getOrders(this); }
|
|
273
290
|
async getOrderHistory(date) { return getOrderHistory(this, date); }
|
|
274
291
|
async getOrderHistoryDates() { return getOrderHistoryDates(this); }
|
|
@@ -381,44 +398,26 @@ class RithmicService extends EventEmitter {
|
|
|
381
398
|
return { success: true, stats };
|
|
382
399
|
}
|
|
383
400
|
|
|
384
|
-
async getMarketStatus() {
|
|
385
|
-
const status = this.checkMarketHours();
|
|
386
|
-
return { success: true, isOpen: status.isOpen, message: status.message };
|
|
387
|
-
}
|
|
388
|
-
|
|
401
|
+
async getMarketStatus() { return { success: true, ...this.checkMarketHours() }; }
|
|
389
402
|
getToken() { return this.loginInfo ? 'connected' : null; }
|
|
390
403
|
getPropfirm() { return this.propfirmKey || 'apex'; }
|
|
391
|
-
|
|
392
404
|
getRithmicCredentials() {
|
|
393
405
|
if (!this.credentials) return null;
|
|
394
|
-
return {
|
|
395
|
-
|
|
396
|
-
password: this.credentials.password,
|
|
397
|
-
systemName: this.propfirm.systemName,
|
|
398
|
-
gateway: this.propfirm.gateway || RITHMIC_ENDPOINTS.CHICAGO,
|
|
399
|
-
};
|
|
406
|
+
return { userId: this.credentials.username, password: this.credentials.password,
|
|
407
|
+
systemName: this.propfirm.systemName, gateway: this.propfirm.gateway || RITHMIC_ENDPOINTS.CHICAGO };
|
|
400
408
|
}
|
|
401
409
|
|
|
402
|
-
// ==================== MARKET HOURS ====================
|
|
403
|
-
|
|
404
410
|
checkMarketHours() {
|
|
405
|
-
const now = new Date();
|
|
406
|
-
const utcDay = now.getUTCDay();
|
|
407
|
-
const utcHour = now.getUTCHours();
|
|
408
|
-
|
|
411
|
+
const now = new Date(), utcDay = now.getUTCDay(), utcHour = now.getUTCHours();
|
|
409
412
|
const isDST = now.getTimezoneOffset() < Math.max(
|
|
410
413
|
new Date(now.getFullYear(), 0, 1).getTimezoneOffset(),
|
|
411
|
-
new Date(now.getFullYear(), 6, 1).getTimezoneOffset()
|
|
412
|
-
);
|
|
413
|
-
const ctOffset = isDST ? 5 : 6;
|
|
414
|
-
const ctHour = (utcHour - ctOffset + 24) % 24;
|
|
414
|
+
new Date(now.getFullYear(), 6, 1).getTimezoneOffset());
|
|
415
|
+
const ctOffset = isDST ? 5 : 6, ctHour = (utcHour - ctOffset + 24) % 24;
|
|
415
416
|
const ctDay = utcHour < ctOffset ? (utcDay + 6) % 7 : utcDay;
|
|
416
|
-
|
|
417
417
|
if (ctDay === 6) return { isOpen: false, message: 'Market closed (Saturday)' };
|
|
418
|
-
if (ctDay === 0 && ctHour < 17) return { isOpen: false, message: 'Market opens Sunday
|
|
419
|
-
if (ctDay === 5 && ctHour >= 16) return { isOpen: false, message: 'Market closed (Friday
|
|
420
|
-
if (ctHour === 16 && ctDay >= 1 && ctDay <= 4) return { isOpen: false, message: 'Daily maintenance
|
|
421
|
-
|
|
418
|
+
if (ctDay === 0 && ctHour < 17) return { isOpen: false, message: 'Market opens Sunday 5PM CT' };
|
|
419
|
+
if (ctDay === 5 && ctHour >= 16) return { isOpen: false, message: 'Market closed (Friday 4PM CT)' };
|
|
420
|
+
if (ctHour === 16 && ctDay >= 1 && ctDay <= 4) return { isOpen: false, message: 'Daily maintenance' };
|
|
422
421
|
return { isOpen: true, message: 'Market is open' };
|
|
423
422
|
}
|
|
424
423
|
|
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RithmicBroker Client
|
|
3
|
+
*
|
|
4
|
+
* Client for CLI to communicate with the RithmicBroker daemon.
|
|
5
|
+
* Provides same interface as RithmicService for seamless integration.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
'use strict';
|
|
9
|
+
|
|
10
|
+
const WebSocket = require('ws');
|
|
11
|
+
const EventEmitter = require('events');
|
|
12
|
+
const { BROKER_PORT } = require('./daemon');
|
|
13
|
+
const manager = require('./manager');
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* RithmicBroker Client - connects to daemon via WebSocket
|
|
17
|
+
*/
|
|
18
|
+
class RithmicBrokerClient extends EventEmitter {
|
|
19
|
+
constructor(propfirmKey) {
|
|
20
|
+
super();
|
|
21
|
+
this.propfirmKey = propfirmKey;
|
|
22
|
+
this.ws = null;
|
|
23
|
+
this.connected = false;
|
|
24
|
+
this.requestId = 0;
|
|
25
|
+
this.pendingRequests = new Map();
|
|
26
|
+
this.credentials = null;
|
|
27
|
+
this.accounts = [];
|
|
28
|
+
this.propfirm = { name: propfirmKey };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Connect to daemon
|
|
33
|
+
*/
|
|
34
|
+
async connect() {
|
|
35
|
+
if (this.connected) return { success: true };
|
|
36
|
+
|
|
37
|
+
// Ensure daemon is running
|
|
38
|
+
const daemonStatus = await manager.ensureRunning();
|
|
39
|
+
if (!daemonStatus.success) {
|
|
40
|
+
return { success: false, error: daemonStatus.error || 'Failed to start daemon' };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return new Promise((resolve) => {
|
|
44
|
+
this.ws = new WebSocket(`ws://127.0.0.1:${BROKER_PORT}`);
|
|
45
|
+
|
|
46
|
+
const timeout = setTimeout(() => {
|
|
47
|
+
this.ws?.terminate();
|
|
48
|
+
resolve({ success: false, error: 'Connection timeout' });
|
|
49
|
+
}, 5000);
|
|
50
|
+
|
|
51
|
+
this.ws.on('open', () => {
|
|
52
|
+
clearTimeout(timeout);
|
|
53
|
+
this.connected = true;
|
|
54
|
+
resolve({ success: true });
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
this.ws.on('message', (data) => this._handleMessage(data));
|
|
58
|
+
|
|
59
|
+
this.ws.on('close', () => {
|
|
60
|
+
this.connected = false;
|
|
61
|
+
this.emit('disconnected');
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
this.ws.on('error', (err) => {
|
|
65
|
+
clearTimeout(timeout);
|
|
66
|
+
this.connected = false;
|
|
67
|
+
resolve({ success: false, error: err.message });
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Handle incoming message from daemon
|
|
74
|
+
*/
|
|
75
|
+
_handleMessage(data) {
|
|
76
|
+
try {
|
|
77
|
+
const msg = JSON.parse(data.toString());
|
|
78
|
+
|
|
79
|
+
// Handle response to pending request
|
|
80
|
+
if (msg.requestId && this.pendingRequests.has(msg.requestId)) {
|
|
81
|
+
const { resolve } = this.pendingRequests.get(msg.requestId);
|
|
82
|
+
this.pendingRequests.delete(msg.requestId);
|
|
83
|
+
resolve(msg);
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Handle broadcast events
|
|
88
|
+
if (msg.type === 'pnlUpdate') this.emit('pnlUpdate', msg.payload);
|
|
89
|
+
if (msg.type === 'positionUpdate') this.emit('positionUpdate', msg.payload);
|
|
90
|
+
if (msg.type === 'trade') this.emit('trade', msg.payload);
|
|
91
|
+
} catch (e) { /* ignore parse errors */ }
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Send request to daemon and wait for response
|
|
96
|
+
*/
|
|
97
|
+
async _request(type, payload = {}, timeout = 30000) {
|
|
98
|
+
if (!this.connected) {
|
|
99
|
+
const conn = await this.connect();
|
|
100
|
+
if (!conn.success) return { error: conn.error };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const requestId = String(++this.requestId);
|
|
104
|
+
|
|
105
|
+
return new Promise((resolve) => {
|
|
106
|
+
const timer = setTimeout(() => {
|
|
107
|
+
this.pendingRequests.delete(requestId);
|
|
108
|
+
resolve({ error: 'Request timeout' });
|
|
109
|
+
}, timeout);
|
|
110
|
+
|
|
111
|
+
this.pendingRequests.set(requestId, {
|
|
112
|
+
resolve: (msg) => {
|
|
113
|
+
clearTimeout(timer);
|
|
114
|
+
resolve(msg);
|
|
115
|
+
}
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
try {
|
|
119
|
+
this.ws.send(JSON.stringify({ type, payload, requestId }));
|
|
120
|
+
} catch (e) {
|
|
121
|
+
clearTimeout(timer);
|
|
122
|
+
this.pendingRequests.delete(requestId);
|
|
123
|
+
resolve({ error: e.message });
|
|
124
|
+
}
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// ==================== RithmicService-compatible API ====================
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Login to Rithmic via daemon
|
|
132
|
+
*/
|
|
133
|
+
async login(username, password) {
|
|
134
|
+
const result = await this._request('login', {
|
|
135
|
+
propfirmKey: this.propfirmKey,
|
|
136
|
+
username,
|
|
137
|
+
password,
|
|
138
|
+
}, 60000);
|
|
139
|
+
|
|
140
|
+
if (result.error) {
|
|
141
|
+
return { success: false, error: result.error };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
if (result.payload?.success) {
|
|
145
|
+
this.credentials = { username, password };
|
|
146
|
+
this.accounts = result.payload.accounts || [];
|
|
147
|
+
return { success: true, accounts: this.accounts, user: { userName: username } };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
return { success: false, error: result.payload?.error || 'Login failed' };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Get trading accounts
|
|
155
|
+
*/
|
|
156
|
+
async getTradingAccounts() {
|
|
157
|
+
const result = await this._request('getAccounts');
|
|
158
|
+
if (result.error) return { success: false, accounts: [] };
|
|
159
|
+
|
|
160
|
+
const accounts = (result.payload?.accounts || [])
|
|
161
|
+
.filter(a => a.propfirmKey === this.propfirmKey);
|
|
162
|
+
|
|
163
|
+
return { success: true, accounts };
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Get cached P&L (NO API CALL - from daemon cache)
|
|
168
|
+
*/
|
|
169
|
+
async getAccountPnL(accountId) {
|
|
170
|
+
const result = await this._request('getPnL', { accountId });
|
|
171
|
+
return result.payload || { pnl: null };
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Get positions
|
|
176
|
+
*/
|
|
177
|
+
async getPositions() {
|
|
178
|
+
const result = await this._request('getPositions', { propfirmKey: this.propfirmKey });
|
|
179
|
+
if (result.error) return { success: false, positions: [] };
|
|
180
|
+
return result.payload || { success: true, positions: [] };
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Place order
|
|
185
|
+
*/
|
|
186
|
+
async placeOrder(orderData) {
|
|
187
|
+
const result = await this._request('placeOrder', {
|
|
188
|
+
propfirmKey: this.propfirmKey,
|
|
189
|
+
orderData,
|
|
190
|
+
}, 15000);
|
|
191
|
+
|
|
192
|
+
if (result.error) return { success: false, error: result.error };
|
|
193
|
+
return result.payload || { success: false, error: 'No response' };
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Cancel order
|
|
198
|
+
*/
|
|
199
|
+
async cancelOrder(orderId) {
|
|
200
|
+
const result = await this._request('cancelOrder', {
|
|
201
|
+
propfirmKey: this.propfirmKey,
|
|
202
|
+
orderId,
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
if (result.error) return { success: false, error: result.error };
|
|
206
|
+
return result.payload || { success: false, error: 'No response' };
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Get contracts
|
|
211
|
+
*/
|
|
212
|
+
async getContracts() {
|
|
213
|
+
const result = await this._request('getContracts', { propfirmKey: this.propfirmKey });
|
|
214
|
+
if (result.error) return { success: false, contracts: [] };
|
|
215
|
+
return result.payload || { success: true, contracts: [] };
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Search contracts
|
|
220
|
+
*/
|
|
221
|
+
async searchContracts(searchText) {
|
|
222
|
+
const result = await this._request('searchContracts', {
|
|
223
|
+
propfirmKey: this.propfirmKey,
|
|
224
|
+
searchText,
|
|
225
|
+
});
|
|
226
|
+
if (result.error) return { success: false, contracts: [] };
|
|
227
|
+
return result.payload || { success: true, contracts: [] };
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Get Rithmic credentials for MarketDataFeed
|
|
232
|
+
*/
|
|
233
|
+
getRithmicCredentials() {
|
|
234
|
+
// Sync call - return cached credentials
|
|
235
|
+
// For async, use _request('getRithmicCredentials')
|
|
236
|
+
return this.credentials ? {
|
|
237
|
+
userId: this.credentials.username,
|
|
238
|
+
password: this.credentials.password,
|
|
239
|
+
systemName: this.propfirm?.systemName || 'Apex',
|
|
240
|
+
gateway: 'wss://rituz.rithmic.com:443',
|
|
241
|
+
} : null;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Get async Rithmic credentials from daemon
|
|
246
|
+
*/
|
|
247
|
+
async getRithmicCredentialsAsync() {
|
|
248
|
+
const result = await this._request('getRithmicCredentials', { propfirmKey: this.propfirmKey });
|
|
249
|
+
return result.payload || null;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Disconnect from daemon (does NOT stop daemon)
|
|
254
|
+
*/
|
|
255
|
+
disconnect() {
|
|
256
|
+
if (this.ws) {
|
|
257
|
+
this.ws.close();
|
|
258
|
+
this.ws = null;
|
|
259
|
+
}
|
|
260
|
+
this.connected = false;
|
|
261
|
+
this.credentials = null;
|
|
262
|
+
this.accounts = [];
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Logout from Rithmic (stops daemon connection for this propfirm)
|
|
267
|
+
*/
|
|
268
|
+
async logout() {
|
|
269
|
+
const result = await this._request('logout', { propfirmKey: this.propfirmKey });
|
|
270
|
+
this.disconnect();
|
|
271
|
+
return result.payload || { success: true };
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// ==================== Compatibility methods ====================
|
|
275
|
+
|
|
276
|
+
getToken() { return this.connected ? 'broker-connected' : null; }
|
|
277
|
+
getPropfirm() { return this.propfirmKey; }
|
|
278
|
+
async getUser() { return { userName: this.credentials?.username }; }
|
|
279
|
+
|
|
280
|
+
checkMarketHours() {
|
|
281
|
+
const now = new Date();
|
|
282
|
+
const utcDay = now.getUTCDay();
|
|
283
|
+
const utcHour = now.getUTCHours();
|
|
284
|
+
const isDST = now.getTimezoneOffset() < Math.max(
|
|
285
|
+
new Date(now.getFullYear(), 0, 1).getTimezoneOffset(),
|
|
286
|
+
new Date(now.getFullYear(), 6, 1).getTimezoneOffset()
|
|
287
|
+
);
|
|
288
|
+
const ctOffset = isDST ? 5 : 6;
|
|
289
|
+
const ctHour = (utcHour - ctOffset + 24) % 24;
|
|
290
|
+
const ctDay = utcHour < ctOffset ? (utcDay + 6) % 7 : utcDay;
|
|
291
|
+
|
|
292
|
+
if (ctDay === 6) return { isOpen: false, message: 'Market closed (Saturday)' };
|
|
293
|
+
if (ctDay === 0 && ctHour < 17) return { isOpen: false, message: 'Market opens Sunday 5:00 PM CT' };
|
|
294
|
+
if (ctDay === 5 && ctHour >= 16) return { isOpen: false, message: 'Market closed (Friday after 4PM CT)' };
|
|
295
|
+
if (ctHour === 16 && ctDay >= 1 && ctDay <= 4) return { isOpen: false, message: 'Daily maintenance' };
|
|
296
|
+
return { isOpen: true, message: 'Market is open' };
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
module.exports = { RithmicBrokerClient };
|
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RithmicBroker Daemon
|
|
3
|
+
*
|
|
4
|
+
* Background process that maintains persistent Rithmic connections.
|
|
5
|
+
* Survives CLI restarts/updates. Only stops on explicit logout or reboot.
|
|
6
|
+
*
|
|
7
|
+
* Communication: WebSocket server on port 18765
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
'use strict';
|
|
11
|
+
|
|
12
|
+
const WebSocket = require('ws');
|
|
13
|
+
const fs = require('fs');
|
|
14
|
+
const path = require('path');
|
|
15
|
+
const os = require('os');
|
|
16
|
+
|
|
17
|
+
// Paths
|
|
18
|
+
const BROKER_DIR = path.join(os.homedir(), '.hqx', 'rithmic-broker');
|
|
19
|
+
const PID_FILE = path.join(BROKER_DIR, 'broker.pid');
|
|
20
|
+
const LOG_FILE = path.join(BROKER_DIR, 'broker.log');
|
|
21
|
+
const STATE_FILE = path.join(BROKER_DIR, 'state.json');
|
|
22
|
+
const BROKER_PORT = 18765;
|
|
23
|
+
|
|
24
|
+
// Lazy load RithmicService
|
|
25
|
+
let RithmicService = null;
|
|
26
|
+
const loadRithmicService = () => {
|
|
27
|
+
if (!RithmicService) {
|
|
28
|
+
({ RithmicService } = require('../rithmic'));
|
|
29
|
+
}
|
|
30
|
+
return RithmicService;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
// Logger
|
|
34
|
+
const log = (level, msg, data = {}) => {
|
|
35
|
+
const ts = new Date().toISOString();
|
|
36
|
+
const line = `[${ts}] [${level}] ${msg} ${JSON.stringify(data)}\n`;
|
|
37
|
+
try { fs.appendFileSync(LOG_FILE, line); } catch (e) { /* ignore */ }
|
|
38
|
+
if (process.env.HQX_DEBUG === '1') console.log(`[Broker] [${level}] ${msg}`, data);
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* RithmicBroker Daemon Class
|
|
43
|
+
*/
|
|
44
|
+
class RithmicBrokerDaemon {
|
|
45
|
+
constructor() {
|
|
46
|
+
this.wss = null;
|
|
47
|
+
this.clients = new Set();
|
|
48
|
+
this.connections = new Map(); // propfirmKey -> { service, credentials, connectedAt, accounts }
|
|
49
|
+
this.pnlCache = new Map(); // accountId -> { pnl, openPnl, closedPnl, balance, updatedAt }
|
|
50
|
+
this.running = false;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async start() {
|
|
54
|
+
if (this.running) return;
|
|
55
|
+
|
|
56
|
+
if (!fs.existsSync(BROKER_DIR)) fs.mkdirSync(BROKER_DIR, { recursive: true });
|
|
57
|
+
fs.writeFileSync(PID_FILE, String(process.pid));
|
|
58
|
+
|
|
59
|
+
await this._restoreState();
|
|
60
|
+
|
|
61
|
+
this.wss = new WebSocket.Server({ port: BROKER_PORT, host: '127.0.0.1' });
|
|
62
|
+
this.wss.on('connection', (ws) => this._handleClient(ws));
|
|
63
|
+
this.wss.on('error', (err) => log('ERROR', 'WSS error', { error: err.message }));
|
|
64
|
+
|
|
65
|
+
this.running = true;
|
|
66
|
+
log('INFO', 'Daemon started', { pid: process.pid, port: BROKER_PORT });
|
|
67
|
+
|
|
68
|
+
process.on('SIGTERM', () => this.stop());
|
|
69
|
+
process.on('SIGINT', () => this.stop());
|
|
70
|
+
setInterval(() => this._saveState(), 30000);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async stop() {
|
|
74
|
+
log('INFO', 'Daemon stopping...');
|
|
75
|
+
this.running = false;
|
|
76
|
+
|
|
77
|
+
for (const [key, conn] of this.connections) {
|
|
78
|
+
try { if (conn.service?.disconnect) await conn.service.disconnect(); }
|
|
79
|
+
catch (e) { log('WARN', 'Disconnect error', { propfirm: key, error: e.message }); }
|
|
80
|
+
}
|
|
81
|
+
this.connections.clear();
|
|
82
|
+
|
|
83
|
+
if (this.wss) {
|
|
84
|
+
for (const client of this.clients) client.close(1000, 'Daemon shutting down');
|
|
85
|
+
this.wss.close();
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (fs.existsSync(PID_FILE)) fs.unlinkSync(PID_FILE);
|
|
89
|
+
if (fs.existsSync(STATE_FILE)) fs.unlinkSync(STATE_FILE);
|
|
90
|
+
|
|
91
|
+
log('INFO', 'Daemon stopped');
|
|
92
|
+
process.exit(0);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
_handleClient(ws) {
|
|
96
|
+
this.clients.add(ws);
|
|
97
|
+
log('DEBUG', 'Client connected', { total: this.clients.size });
|
|
98
|
+
|
|
99
|
+
ws.on('message', async (data) => {
|
|
100
|
+
try {
|
|
101
|
+
const msg = JSON.parse(data.toString());
|
|
102
|
+
const response = await this._handleMessage(msg);
|
|
103
|
+
ws.send(JSON.stringify(response));
|
|
104
|
+
} catch (e) {
|
|
105
|
+
ws.send(JSON.stringify({ error: e.message, requestId: null }));
|
|
106
|
+
}
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
ws.on('close', () => { this.clients.delete(ws); });
|
|
110
|
+
ws.on('error', () => { this.clients.delete(ws); });
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async _handleMessage(msg) {
|
|
114
|
+
const { type, payload = {}, requestId } = msg;
|
|
115
|
+
|
|
116
|
+
const handlers = {
|
|
117
|
+
ping: () => ({ type: 'pong', requestId }),
|
|
118
|
+
status: () => ({ type: 'status', payload: this._getStatus(), requestId }),
|
|
119
|
+
login: () => this._handleLogin(payload, requestId),
|
|
120
|
+
logout: () => this._handleLogout(payload, requestId),
|
|
121
|
+
getAccounts: () => this._handleGetAccounts(requestId),
|
|
122
|
+
getPnL: () => this._handleGetPnL(payload, requestId),
|
|
123
|
+
getPositions: () => this._handleGetPositions(payload, requestId),
|
|
124
|
+
placeOrder: () => this._handlePlaceOrder(payload, requestId),
|
|
125
|
+
cancelOrder: () => this._handleCancelOrder(payload, requestId),
|
|
126
|
+
getContracts: () => this._handleGetContracts(payload, requestId),
|
|
127
|
+
searchContracts: () => this._handleSearchContracts(payload, requestId),
|
|
128
|
+
getRithmicCredentials: () => this._handleGetCredentials(payload, requestId),
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
if (handlers[type]) {
|
|
132
|
+
try { return await handlers[type](); }
|
|
133
|
+
catch (e) { return { error: e.message, requestId }; }
|
|
134
|
+
}
|
|
135
|
+
return { error: `Unknown type: ${type}`, requestId };
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
_getStatus() {
|
|
139
|
+
const conns = [];
|
|
140
|
+
for (const [key, conn] of this.connections) {
|
|
141
|
+
conns.push({
|
|
142
|
+
propfirmKey: key,
|
|
143
|
+
propfirm: conn.service?.propfirm?.name || key,
|
|
144
|
+
connectedAt: conn.connectedAt,
|
|
145
|
+
accountCount: conn.accounts?.length || 0,
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
return { running: this.running, pid: process.pid, uptime: process.uptime(), connections: conns };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
async _handleLogin(payload, requestId) {
|
|
152
|
+
const { propfirmKey, username, password } = payload;
|
|
153
|
+
if (!propfirmKey || !username || !password) {
|
|
154
|
+
return { error: 'Missing credentials', requestId };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Already connected?
|
|
158
|
+
if (this.connections.has(propfirmKey)) {
|
|
159
|
+
const conn = this.connections.get(propfirmKey);
|
|
160
|
+
if (conn.service?.loginInfo) {
|
|
161
|
+
return { type: 'loginResult', payload: { success: true, accounts: conn.accounts, alreadyConnected: true }, requestId };
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const Service = loadRithmicService();
|
|
166
|
+
const service = new Service(propfirmKey);
|
|
167
|
+
|
|
168
|
+
log('INFO', 'Logging in...', { propfirm: propfirmKey });
|
|
169
|
+
const result = await service.login(username, password);
|
|
170
|
+
|
|
171
|
+
if (result.success) {
|
|
172
|
+
this.connections.set(propfirmKey, {
|
|
173
|
+
service,
|
|
174
|
+
credentials: { username, password },
|
|
175
|
+
connectedAt: new Date().toISOString(),
|
|
176
|
+
accounts: result.accounts || [],
|
|
177
|
+
});
|
|
178
|
+
this._setupPnLUpdates(propfirmKey, service);
|
|
179
|
+
this._saveState();
|
|
180
|
+
log('INFO', 'Login successful', { propfirm: propfirmKey, accounts: result.accounts?.length });
|
|
181
|
+
return { type: 'loginResult', payload: { success: true, accounts: result.accounts }, requestId };
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
log('WARN', 'Login failed', { propfirm: propfirmKey, error: result.error });
|
|
185
|
+
return { type: 'loginResult', payload: { success: false, error: result.error }, requestId };
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
_setupPnLUpdates(propfirmKey, service) {
|
|
189
|
+
service.on('pnlUpdate', (pnl) => {
|
|
190
|
+
if (pnl.accountId) {
|
|
191
|
+
this.pnlCache.set(pnl.accountId, {
|
|
192
|
+
pnl: pnl.dayPnl || ((pnl.openPositionPnl || 0) + (pnl.closedPositionPnl || 0)),
|
|
193
|
+
openPnl: pnl.openPositionPnl || 0,
|
|
194
|
+
closedPnl: pnl.closedPositionPnl || 0,
|
|
195
|
+
balance: pnl.accountBalance || 0,
|
|
196
|
+
updatedAt: Date.now(),
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
this._broadcast({ type: 'pnlUpdate', payload: pnl });
|
|
200
|
+
});
|
|
201
|
+
service.on('positionUpdate', (pos) => this._broadcast({ type: 'positionUpdate', payload: pos }));
|
|
202
|
+
service.on('trade', (trade) => this._broadcast({ type: 'trade', payload: trade }));
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
_broadcast(msg) {
|
|
206
|
+
const data = JSON.stringify(msg);
|
|
207
|
+
for (const client of this.clients) {
|
|
208
|
+
if (client.readyState === WebSocket.OPEN) {
|
|
209
|
+
try { client.send(data); } catch (e) { /* ignore */ }
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
async _handleLogout(payload, requestId) {
|
|
215
|
+
const { propfirmKey } = payload;
|
|
216
|
+
if (propfirmKey) {
|
|
217
|
+
const conn = this.connections.get(propfirmKey);
|
|
218
|
+
if (conn?.service) { await conn.service.disconnect(); this.connections.delete(propfirmKey); }
|
|
219
|
+
} else {
|
|
220
|
+
await this.stop();
|
|
221
|
+
}
|
|
222
|
+
this._saveState();
|
|
223
|
+
return { type: 'logoutResult', payload: { success: true }, requestId };
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
async _handleGetAccounts(requestId) {
|
|
227
|
+
const allAccounts = [];
|
|
228
|
+
for (const [propfirmKey, conn] of this.connections) {
|
|
229
|
+
for (const acc of conn.accounts || []) {
|
|
230
|
+
allAccounts.push({ ...acc, propfirmKey, propfirm: conn.service.propfirm?.name || propfirmKey });
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
return { type: 'accounts', payload: { accounts: allAccounts }, requestId };
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
_handleGetPnL(payload, requestId) {
|
|
237
|
+
const cached = this.pnlCache.get(payload.accountId);
|
|
238
|
+
return { type: 'pnl', payload: cached || { pnl: null }, requestId };
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
async _handleGetPositions(payload, requestId) {
|
|
242
|
+
const conn = this.connections.get(payload.propfirmKey);
|
|
243
|
+
if (!conn?.service) return { error: 'Not connected', requestId };
|
|
244
|
+
return { type: 'positions', payload: await conn.service.getPositions(), requestId };
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
async _handlePlaceOrder(payload, requestId) {
|
|
248
|
+
const conn = this.connections.get(payload.propfirmKey);
|
|
249
|
+
if (!conn?.service) return { error: 'Not connected', requestId };
|
|
250
|
+
return { type: 'orderResult', payload: await conn.service.placeOrder(payload.orderData), requestId };
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
async _handleCancelOrder(payload, requestId) {
|
|
254
|
+
const conn = this.connections.get(payload.propfirmKey);
|
|
255
|
+
if (!conn?.service) return { error: 'Not connected', requestId };
|
|
256
|
+
return { type: 'cancelResult', payload: await conn.service.cancelOrder(payload.orderId), requestId };
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
async _handleGetContracts(payload, requestId) {
|
|
260
|
+
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 };
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
async _handleSearchContracts(payload, requestId) {
|
|
266
|
+
const conn = this.connections.get(payload.propfirmKey);
|
|
267
|
+
if (!conn?.service) return { error: 'Not connected', requestId };
|
|
268
|
+
return { type: 'searchResults', payload: await conn.service.searchContracts(payload.searchText), requestId };
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
_handleGetCredentials(payload, requestId) {
|
|
272
|
+
const conn = this.connections.get(payload.propfirmKey);
|
|
273
|
+
if (!conn?.service) return { error: 'Not connected', requestId };
|
|
274
|
+
return { type: 'credentials', payload: conn.service.getRithmicCredentials?.() || null, requestId };
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
_saveState() {
|
|
278
|
+
const state = { connections: [] };
|
|
279
|
+
for (const [key, conn] of this.connections) {
|
|
280
|
+
if (conn.credentials) state.connections.push({ propfirmKey: key, credentials: conn.credentials });
|
|
281
|
+
}
|
|
282
|
+
try { fs.writeFileSync(STATE_FILE, JSON.stringify(state)); } catch (e) { /* ignore */ }
|
|
283
|
+
}
|
|
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
|
+
}
|
|
298
|
+
|
|
299
|
+
// Main entry point
|
|
300
|
+
if (require.main === module) {
|
|
301
|
+
const daemon = new RithmicBrokerDaemon();
|
|
302
|
+
daemon.start().catch((e) => { console.error('Daemon failed:', e.message); process.exit(1); });
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
module.exports = { RithmicBrokerDaemon, BROKER_PORT, BROKER_DIR, PID_FILE, LOG_FILE, STATE_FILE };
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RithmicBroker Service
|
|
3
|
+
*
|
|
4
|
+
* Persistent Rithmic connection manager.
|
|
5
|
+
* Daemon runs in background, survives CLI restarts.
|
|
6
|
+
*
|
|
7
|
+
* Usage:
|
|
8
|
+
* const { RithmicBrokerClient, manager } = require('./rithmic-broker');
|
|
9
|
+
*
|
|
10
|
+
* // Start daemon if not running
|
|
11
|
+
* await manager.ensureRunning();
|
|
12
|
+
*
|
|
13
|
+
* // Create client (same API as RithmicService)
|
|
14
|
+
* const client = new RithmicBrokerClient('apex');
|
|
15
|
+
* await client.login(username, password);
|
|
16
|
+
*
|
|
17
|
+
* // Use like RithmicService
|
|
18
|
+
* const accounts = await client.getTradingAccounts();
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
'use strict';
|
|
22
|
+
|
|
23
|
+
const { RithmicBrokerClient } = require('./client');
|
|
24
|
+
const manager = require('./manager');
|
|
25
|
+
const { BROKER_PORT, BROKER_DIR, PID_FILE, LOG_FILE, STATE_FILE } = require('./daemon');
|
|
26
|
+
|
|
27
|
+
module.exports = {
|
|
28
|
+
// Client class (use instead of RithmicService)
|
|
29
|
+
RithmicBrokerClient,
|
|
30
|
+
|
|
31
|
+
// Manager functions
|
|
32
|
+
manager,
|
|
33
|
+
isRunning: manager.isRunning,
|
|
34
|
+
start: manager.start,
|
|
35
|
+
stop: manager.stop,
|
|
36
|
+
getStatus: manager.getStatus,
|
|
37
|
+
ensureRunning: manager.ensureRunning,
|
|
38
|
+
restart: manager.restart,
|
|
39
|
+
|
|
40
|
+
// Constants
|
|
41
|
+
BROKER_PORT,
|
|
42
|
+
BROKER_DIR,
|
|
43
|
+
PID_FILE,
|
|
44
|
+
LOG_FILE,
|
|
45
|
+
STATE_FILE,
|
|
46
|
+
};
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RithmicBroker Manager
|
|
3
|
+
*
|
|
4
|
+
* Start/stop/status functions for the RithmicBroker daemon.
|
|
5
|
+
* Similar pattern to cliproxy/manager.js
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
'use strict';
|
|
9
|
+
|
|
10
|
+
const { spawn } = require('child_process');
|
|
11
|
+
const path = require('path');
|
|
12
|
+
const fs = require('fs');
|
|
13
|
+
const http = require('http');
|
|
14
|
+
const WebSocket = require('ws');
|
|
15
|
+
|
|
16
|
+
const { BROKER_PORT, BROKER_DIR, PID_FILE, LOG_FILE } = require('./daemon');
|
|
17
|
+
|
|
18
|
+
// Path to daemon script
|
|
19
|
+
const DAEMON_SCRIPT = path.join(__dirname, 'daemon.js');
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Check if daemon is running
|
|
23
|
+
* @returns {Promise<{running: boolean, pid: number|null}>}
|
|
24
|
+
*/
|
|
25
|
+
const isRunning = async () => {
|
|
26
|
+
// Check PID file first
|
|
27
|
+
if (fs.existsSync(PID_FILE)) {
|
|
28
|
+
try {
|
|
29
|
+
const pid = parseInt(fs.readFileSync(PID_FILE, 'utf8').trim(), 10);
|
|
30
|
+
process.kill(pid, 0); // Test if process exists
|
|
31
|
+
return { running: true, pid };
|
|
32
|
+
} catch (e) {
|
|
33
|
+
// Process doesn't exist, clean up stale PID file
|
|
34
|
+
try { fs.unlinkSync(PID_FILE); } catch (e2) { /* ignore */ }
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Try connecting to WebSocket
|
|
39
|
+
return new Promise((resolve) => {
|
|
40
|
+
const ws = new WebSocket(`ws://127.0.0.1:${BROKER_PORT}`);
|
|
41
|
+
const timeout = setTimeout(() => {
|
|
42
|
+
ws.terminate();
|
|
43
|
+
resolve({ running: false, pid: null });
|
|
44
|
+
}, 2000);
|
|
45
|
+
|
|
46
|
+
ws.on('open', () => {
|
|
47
|
+
clearTimeout(timeout);
|
|
48
|
+
ws.close();
|
|
49
|
+
resolve({ running: true, pid: null });
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
ws.on('error', () => {
|
|
53
|
+
clearTimeout(timeout);
|
|
54
|
+
resolve({ running: false, pid: null });
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Start the daemon
|
|
61
|
+
* @returns {Promise<{success: boolean, error: string|null, pid: number|null}>}
|
|
62
|
+
*/
|
|
63
|
+
const start = async () => {
|
|
64
|
+
const status = await isRunning();
|
|
65
|
+
if (status.running) {
|
|
66
|
+
return { success: true, error: null, pid: status.pid, alreadyRunning: true };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Ensure directory exists
|
|
70
|
+
if (!fs.existsSync(BROKER_DIR)) {
|
|
71
|
+
fs.mkdirSync(BROKER_DIR, { recursive: true });
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
try {
|
|
75
|
+
// Open log file for daemon output
|
|
76
|
+
const logFd = fs.openSync(LOG_FILE, 'a');
|
|
77
|
+
|
|
78
|
+
// Spawn detached daemon process
|
|
79
|
+
const child = spawn(process.execPath, [DAEMON_SCRIPT], {
|
|
80
|
+
detached: true,
|
|
81
|
+
stdio: ['ignore', logFd, logFd],
|
|
82
|
+
cwd: BROKER_DIR,
|
|
83
|
+
env: { ...process.env, HQX_BROKER_DAEMON: '1' },
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
child.unref();
|
|
87
|
+
fs.closeSync(logFd);
|
|
88
|
+
|
|
89
|
+
// Wait for daemon to start
|
|
90
|
+
await new Promise(r => setTimeout(r, 2000));
|
|
91
|
+
|
|
92
|
+
const runStatus = await isRunning();
|
|
93
|
+
if (runStatus.running) {
|
|
94
|
+
return { success: true, error: null, pid: runStatus.pid || child.pid };
|
|
95
|
+
} else {
|
|
96
|
+
// Read log for error details
|
|
97
|
+
let errorDetail = 'Failed to start RithmicBroker daemon';
|
|
98
|
+
if (fs.existsSync(LOG_FILE)) {
|
|
99
|
+
const log = fs.readFileSync(LOG_FILE, 'utf8').slice(-500);
|
|
100
|
+
if (log) errorDetail += `: ${log.split('\n').filter(l => l).pop()}`;
|
|
101
|
+
}
|
|
102
|
+
return { success: false, error: errorDetail, pid: null };
|
|
103
|
+
}
|
|
104
|
+
} catch (error) {
|
|
105
|
+
return { success: false, error: error.message, pid: null };
|
|
106
|
+
}
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Stop the daemon
|
|
111
|
+
* @returns {Promise<{success: boolean, error: string|null}>}
|
|
112
|
+
*/
|
|
113
|
+
const stop = async () => {
|
|
114
|
+
const status = await isRunning();
|
|
115
|
+
if (!status.running) {
|
|
116
|
+
return { success: true, error: null };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
try {
|
|
120
|
+
// Try graceful shutdown via WebSocket
|
|
121
|
+
const ws = new WebSocket(`ws://127.0.0.1:${BROKER_PORT}`);
|
|
122
|
+
|
|
123
|
+
await new Promise((resolve, reject) => {
|
|
124
|
+
const timeout = setTimeout(() => {
|
|
125
|
+
ws.terminate();
|
|
126
|
+
reject(new Error('Shutdown timeout'));
|
|
127
|
+
}, 5000);
|
|
128
|
+
|
|
129
|
+
ws.on('open', () => {
|
|
130
|
+
ws.send(JSON.stringify({ type: 'logout', payload: {}, requestId: 'shutdown' }));
|
|
131
|
+
setTimeout(() => {
|
|
132
|
+
clearTimeout(timeout);
|
|
133
|
+
ws.close();
|
|
134
|
+
resolve();
|
|
135
|
+
}, 1000);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
ws.on('error', () => {
|
|
139
|
+
clearTimeout(timeout);
|
|
140
|
+
reject(new Error('Connection failed'));
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
// Wait for process to exit
|
|
145
|
+
await new Promise(r => setTimeout(r, 1000));
|
|
146
|
+
|
|
147
|
+
// Verify stopped
|
|
148
|
+
const newStatus = await isRunning();
|
|
149
|
+
if (!newStatus.running) {
|
|
150
|
+
return { success: true, error: null };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Force kill if still running
|
|
154
|
+
if (status.pid) {
|
|
155
|
+
try {
|
|
156
|
+
process.kill(status.pid, 'SIGKILL');
|
|
157
|
+
} catch (e) { /* ignore */ }
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Clean up PID file
|
|
161
|
+
if (fs.existsSync(PID_FILE)) {
|
|
162
|
+
try { fs.unlinkSync(PID_FILE); } catch (e) { /* ignore */ }
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
return { success: true, error: null };
|
|
166
|
+
} catch (error) {
|
|
167
|
+
// Force kill via PID
|
|
168
|
+
if (status.pid) {
|
|
169
|
+
try {
|
|
170
|
+
process.kill(status.pid, 'SIGKILL');
|
|
171
|
+
if (fs.existsSync(PID_FILE)) fs.unlinkSync(PID_FILE);
|
|
172
|
+
return { success: true, error: null };
|
|
173
|
+
} catch (e) { /* ignore */ }
|
|
174
|
+
}
|
|
175
|
+
return { success: false, error: error.message };
|
|
176
|
+
}
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Get daemon status
|
|
181
|
+
* @returns {Promise<Object>}
|
|
182
|
+
*/
|
|
183
|
+
const getStatus = async () => {
|
|
184
|
+
const status = await isRunning();
|
|
185
|
+
|
|
186
|
+
if (!status.running) {
|
|
187
|
+
return { running: false, pid: null, connections: [], uptime: 0 };
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// Get detailed status from daemon
|
|
191
|
+
return new Promise((resolve) => {
|
|
192
|
+
const ws = new WebSocket(`ws://127.0.0.1:${BROKER_PORT}`);
|
|
193
|
+
const timeout = setTimeout(() => {
|
|
194
|
+
ws.terminate();
|
|
195
|
+
resolve({ running: true, pid: status.pid, connections: [], uptime: 0 });
|
|
196
|
+
}, 3000);
|
|
197
|
+
|
|
198
|
+
ws.on('open', () => {
|
|
199
|
+
ws.send(JSON.stringify({ type: 'status', requestId: 'status' }));
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
ws.on('message', (data) => {
|
|
203
|
+
clearTimeout(timeout);
|
|
204
|
+
try {
|
|
205
|
+
const msg = JSON.parse(data.toString());
|
|
206
|
+
if (msg.type === 'status') {
|
|
207
|
+
ws.close();
|
|
208
|
+
resolve(msg.payload);
|
|
209
|
+
}
|
|
210
|
+
} catch (e) {
|
|
211
|
+
ws.close();
|
|
212
|
+
resolve({ running: true, pid: status.pid, connections: [], uptime: 0 });
|
|
213
|
+
}
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
ws.on('error', () => {
|
|
217
|
+
clearTimeout(timeout);
|
|
218
|
+
resolve({ running: true, pid: status.pid, connections: [], uptime: 0 });
|
|
219
|
+
});
|
|
220
|
+
});
|
|
221
|
+
};
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Ensure daemon is running (start if not)
|
|
225
|
+
* @returns {Promise<{success: boolean, error: string|null}>}
|
|
226
|
+
*/
|
|
227
|
+
const ensureRunning = async () => {
|
|
228
|
+
const status = await isRunning();
|
|
229
|
+
if (status.running) {
|
|
230
|
+
return { success: true, error: null };
|
|
231
|
+
}
|
|
232
|
+
return start();
|
|
233
|
+
};
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Restart the daemon
|
|
237
|
+
* @returns {Promise<{success: boolean, error: string|null}>}
|
|
238
|
+
*/
|
|
239
|
+
const restart = async () => {
|
|
240
|
+
await stop();
|
|
241
|
+
await new Promise(r => setTimeout(r, 1000));
|
|
242
|
+
return start();
|
|
243
|
+
};
|
|
244
|
+
|
|
245
|
+
module.exports = {
|
|
246
|
+
isRunning,
|
|
247
|
+
start,
|
|
248
|
+
stop,
|
|
249
|
+
getStatus,
|
|
250
|
+
ensureRunning,
|
|
251
|
+
restart,
|
|
252
|
+
BROKER_PORT,
|
|
253
|
+
BROKER_DIR,
|
|
254
|
+
PID_FILE,
|
|
255
|
+
LOG_FILE,
|
|
256
|
+
};
|
package/src/services/session.js
CHANGED
|
@@ -84,11 +84,11 @@ const storage = {
|
|
|
84
84
|
},
|
|
85
85
|
};
|
|
86
86
|
|
|
87
|
-
// Lazy load
|
|
88
|
-
let
|
|
87
|
+
// Lazy load services to avoid circular dependencies
|
|
88
|
+
let RithmicBrokerClient, brokerManager;
|
|
89
89
|
const loadServices = () => {
|
|
90
|
-
if (!
|
|
91
|
-
({
|
|
90
|
+
if (!RithmicBrokerClient) {
|
|
91
|
+
({ RithmicBrokerClient, manager: brokerManager } = require('./rithmic-broker'));
|
|
92
92
|
}
|
|
93
93
|
};
|
|
94
94
|
|
|
@@ -130,16 +130,44 @@ const connections = {
|
|
|
130
130
|
|
|
131
131
|
async restoreFromStorage() {
|
|
132
132
|
loadServices();
|
|
133
|
-
const sessions = storage.load();
|
|
134
133
|
|
|
135
|
-
//
|
|
134
|
+
// Check if daemon is already running with active connections
|
|
135
|
+
const daemonStatus = await brokerManager.getStatus();
|
|
136
|
+
|
|
137
|
+
if (daemonStatus.running && daemonStatus.connections?.length > 0) {
|
|
138
|
+
// Daemon has active connections - just create clients (NO API calls)
|
|
139
|
+
log.info('Daemon active, restoring from broker', { connections: daemonStatus.connections.length });
|
|
140
|
+
|
|
141
|
+
for (const conn of daemonStatus.connections) {
|
|
142
|
+
const client = new RithmicBrokerClient(conn.propfirmKey);
|
|
143
|
+
await client.connect();
|
|
144
|
+
|
|
145
|
+
// Get accounts from daemon cache
|
|
146
|
+
const accountsResult = await client.getTradingAccounts();
|
|
147
|
+
client.accounts = accountsResult.accounts || [];
|
|
148
|
+
|
|
149
|
+
this.services.push({
|
|
150
|
+
type: 'rithmic',
|
|
151
|
+
service: client,
|
|
152
|
+
propfirm: conn.propfirm,
|
|
153
|
+
propfirmKey: conn.propfirmKey,
|
|
154
|
+
connectedAt: new Date(conn.connectedAt),
|
|
155
|
+
});
|
|
156
|
+
log.debug('Restored from broker', { propfirm: conn.propfirmKey });
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
return this.services.length > 0;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Daemon not running or no connections - check local storage
|
|
163
|
+
const sessions = storage.load();
|
|
136
164
|
const rithmicSessions = sessions.filter(s => s.type === 'rithmic');
|
|
137
165
|
|
|
138
166
|
if (!rithmicSessions.length) {
|
|
139
167
|
return false;
|
|
140
168
|
}
|
|
141
169
|
|
|
142
|
-
log.info('Restoring sessions', { count: rithmicSessions.length });
|
|
170
|
+
log.info('Restoring sessions via broker', { count: rithmicSessions.length });
|
|
143
171
|
|
|
144
172
|
for (const session of rithmicSessions) {
|
|
145
173
|
try {
|
|
@@ -155,20 +183,20 @@ const connections = {
|
|
|
155
183
|
async _restoreSession(session) {
|
|
156
184
|
const { type, propfirm, propfirmKey } = session;
|
|
157
185
|
|
|
158
|
-
//
|
|
186
|
+
// Use broker client (daemon handles persistence)
|
|
159
187
|
if (type === 'rithmic' && session.credentials) {
|
|
160
|
-
const
|
|
161
|
-
const result = await
|
|
188
|
+
const client = new RithmicBrokerClient(propfirmKey || 'apex_rithmic');
|
|
189
|
+
const result = await client.login(session.credentials.username, session.credentials.password);
|
|
162
190
|
|
|
163
191
|
if (result.success) {
|
|
164
192
|
this.services.push({
|
|
165
193
|
type,
|
|
166
|
-
service,
|
|
194
|
+
service: client,
|
|
167
195
|
propfirm,
|
|
168
196
|
propfirmKey,
|
|
169
197
|
connectedAt: new Date(),
|
|
170
198
|
});
|
|
171
|
-
log.debug('Rithmic session restored');
|
|
199
|
+
log.debug('Rithmic session restored via broker');
|
|
172
200
|
}
|
|
173
201
|
}
|
|
174
202
|
},
|
|
@@ -251,15 +279,23 @@ const connections = {
|
|
|
251
279
|
return this.services.length > 0;
|
|
252
280
|
},
|
|
253
281
|
|
|
254
|
-
disconnectAll() {
|
|
282
|
+
async disconnectAll() {
|
|
283
|
+
loadServices();
|
|
284
|
+
|
|
285
|
+
// Stop the broker daemon (closes all Rithmic connections)
|
|
286
|
+
try {
|
|
287
|
+
await brokerManager.stop();
|
|
288
|
+
log.info('Broker daemon stopped');
|
|
289
|
+
} catch (err) {
|
|
290
|
+
log.warn('Broker stop failed', { error: err.message });
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// Disconnect local clients
|
|
255
294
|
for (const conn of this.services) {
|
|
256
295
|
try {
|
|
257
296
|
if (conn.service?.disconnect) {
|
|
258
297
|
conn.service.disconnect();
|
|
259
298
|
}
|
|
260
|
-
if (conn.service?.credentials) {
|
|
261
|
-
conn.service.credentials = null;
|
|
262
|
-
}
|
|
263
299
|
} catch (err) {
|
|
264
300
|
log.warn('Disconnect failed', { type: conn.type, error: err.message });
|
|
265
301
|
}
|