hedgequantx 2.7.10 → 2.7.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hedgequantx",
3
- "version": "2.7.10",
3
+ "version": "2.7.12",
4
4
  "description": "HedgeQuantX - Prop Futures Trading CLI",
5
5
  "main": "src/app.js",
6
6
  "bin": {
package/src/app.js CHANGED
@@ -20,6 +20,7 @@ const { algoTradingMenu } = require('./pages/algo');
20
20
 
21
21
  // Menus
22
22
  const { rithmicMenu, dashboardMenu, handleUpdate } = require('./menus');
23
+ const { PROPFIRM_CHOICES } = require('./config');
23
24
 
24
25
  /** @type {Object|null} */
25
26
  let currentService = null;
@@ -177,29 +178,94 @@ const run = async () => {
177
178
  await banner();
178
179
 
179
180
  if (!connections.isConnected()) {
180
- // Not connected - show Rithmic menu directly
181
+ // Not connected - show propfirm selection directly
181
182
  const boxWidth = getLogoWidth();
182
183
  const innerWidth = boxWidth - 2;
184
+ const numCols = 3;
185
+
186
+ const propfirms = PROPFIRM_CHOICES;
187
+ const numbered = propfirms.map((pf, i) => ({ num: i + 1, key: pf.value, name: pf.name }));
188
+
189
+ // Find max name length for alignment
190
+ const maxNameLen = Math.max(...numbered.map(n => n.name.length));
191
+ const colWidth = 4 + 1 + maxNameLen + 2; // [##] + space + name + gap
192
+ const totalContentWidth = numCols * colWidth;
193
+ const leftMargin = Math.max(2, Math.floor((innerWidth - totalContentWidth) / 2));
183
194
 
184
195
  console.log(chalk.cyan('╠' + '═'.repeat(innerWidth) + '╣'));
185
- console.log(chalk.cyan('║') + chalk.white.bold(centerText('CONNECT TO PROPFIRM', innerWidth)) + chalk.cyan('║'));
186
- console.log(chalk.cyan('' + ''.repeat(innerWidth) + ''));
187
- console.log(chalk.cyan('║') + ' ' + chalk.cyan('[1] Connect') + ' '.repeat(innerWidth - 14) + chalk.cyan('║'));
188
- console.log(chalk.cyan('║') + ' ' + chalk.red('[X] Exit') + ' '.repeat(innerWidth - 11) + chalk.cyan('║'));
196
+ console.log(chalk.cyan('║') + chalk.white.bold(centerText('SELECT PROPFIRM', innerWidth)) + chalk.cyan('║'));
197
+ console.log(chalk.cyan('') + ' '.repeat(innerWidth) + chalk.cyan(''));
198
+
199
+ const rows = Math.ceil(numbered.length / numCols);
200
+ for (let row = 0; row < rows; row++) {
201
+ let lineParts = [];
202
+ for (let col = 0; col < numCols; col++) {
203
+ const idx = row + col * rows;
204
+ if (idx < numbered.length) {
205
+ const item = numbered[idx];
206
+ const numStr = item.num.toString().padStart(2, ' ');
207
+ const namePadded = item.name.padEnd(maxNameLen);
208
+ lineParts.push({ num: `[${numStr}]`, name: namePadded });
209
+ } else {
210
+ lineParts.push(null);
211
+ }
212
+ }
213
+
214
+ let line = ' '.repeat(leftMargin);
215
+ for (let i = 0; i < lineParts.length; i++) {
216
+ if (lineParts[i]) {
217
+ line += chalk.cyan(lineParts[i].num) + ' ' + chalk.white(lineParts[i].name);
218
+ } else {
219
+ line += ' '.repeat(4 + 1 + maxNameLen);
220
+ }
221
+ if (i < lineParts.length - 1) line += ' ';
222
+ }
223
+
224
+ const lineLen = line.replace(/\x1b\[[0-9;]*m/g, '').length;
225
+ const rightPad = Math.max(0, innerWidth - lineLen);
226
+ console.log(chalk.cyan('║') + line + ' '.repeat(rightPad) + chalk.cyan('║'));
227
+ }
228
+
229
+ console.log(chalk.cyan('╠' + '─'.repeat(innerWidth) + '╣'));
230
+ console.log(chalk.cyan('║') + chalk.red(centerText('[X] Exit', innerWidth)) + chalk.cyan('║'));
189
231
  console.log(chalk.cyan('╚' + '═'.repeat(innerWidth) + '╝'));
190
232
 
191
- const input = await prompts.textInput(chalk.cyan('Select (1/X):'));
233
+ const input = await prompts.textInput(chalk.cyan('Select number (or X):'));
192
234
 
193
235
  if (!input || input.toLowerCase() === 'x') {
194
236
  console.log(chalk.gray('Goodbye!'));
195
237
  process.exit(0);
196
238
  }
197
239
 
198
- if (input === '1') {
199
- const service = await rithmicMenu();
200
- if (service) {
201
- currentService = service;
202
- await refreshStats();
240
+ const action = parseInt(input);
241
+ if (!isNaN(action) && action >= 1 && action <= numbered.length) {
242
+ const selectedPropfirm = numbered[action - 1];
243
+ const { loginPrompt } = require('./menus/connect');
244
+ const credentials = await loginPrompt(selectedPropfirm.name);
245
+
246
+ if (credentials) {
247
+ const spinner = ora({ text: 'Connecting to Rithmic...', color: 'yellow' }).start();
248
+ try {
249
+ const { RithmicService } = require('./services/rithmic');
250
+ const service = new RithmicService(selectedPropfirm.key);
251
+ const result = await service.login(credentials.username, credentials.password);
252
+
253
+ if (result.success) {
254
+ spinner.text = 'Fetching accounts...';
255
+ const accResult = await service.getTradingAccounts();
256
+ connections.add('rithmic', service, service.propfirm.name);
257
+ spinner.succeed(`Connected to ${service.propfirm.name} (${accResult.accounts?.length || 0} accounts)`);
258
+ currentService = service;
259
+ await refreshStats();
260
+ await new Promise(r => setTimeout(r, 1500));
261
+ } else {
262
+ spinner.fail(result.error || 'Authentication failed');
263
+ await new Promise(r => setTimeout(r, 2000));
264
+ }
265
+ } catch (error) {
266
+ spinner.fail(`Connection error: ${error.message}`);
267
+ await new Promise(r => setTimeout(r, 2000));
268
+ }
203
269
  }
204
270
  }
205
271
  } else {
@@ -10,7 +10,7 @@ const PROPFIRMS = {
10
10
  apex_rithmic: {
11
11
  id: 'rithmic-apex',
12
12
  name: 'Apex',
13
- displayName: 'Apex (Rithmic)',
13
+ displayName: 'Apex',
14
14
  platform: 'Rithmic',
15
15
  rithmicSystem: 'Apex',
16
16
  wsEndpoint: 'wss://ritpa11120.11.rithmic.com:443',
@@ -34,7 +34,7 @@ const PROPFIRMS = {
34
34
  bulenox_rithmic: {
35
35
  id: 'rithmic-bulenox',
36
36
  name: 'Bulenox',
37
- displayName: 'Bulenox (Rithmic)',
37
+ displayName: 'Bulenox',
38
38
  platform: 'Rithmic',
39
39
  rithmicSystem: 'Bulenox',
40
40
  wsEndpoint: 'wss://ritpa11120.11.rithmic.com:443'
@@ -138,11 +138,23 @@ const PROPFIRMS = {
138
138
  };
139
139
 
140
140
  /**
141
- * PropFirm choices for menus
141
+ * PropFirm choices for menus (Apex first, 4PropTrader/10XFutures last, then alphabetical)
142
142
  */
143
143
  const PROPFIRM_CHOICES = Object.entries(PROPFIRMS)
144
144
  .map(([key, val]) => ({ name: val.displayName, value: key }))
145
- .sort((a, b) => a.name.localeCompare(b.name));
145
+ .sort((a, b) => {
146
+ // Apex always first
147
+ if (a.name === 'Apex') return -1;
148
+ if (b.name === 'Apex') return 1;
149
+ // 4PropTrader and 10XFutures always last
150
+ const lastItems = ['4PropTrader', '10XFutures'];
151
+ const aIsLast = lastItems.includes(a.name);
152
+ const bIsLast = lastItems.includes(b.name);
153
+ if (aIsLast && !bIsLast) return 1;
154
+ if (!aIsLast && bIsLast) return -1;
155
+ // Then alphabetical
156
+ return a.name.localeCompare(b.name);
157
+ });
146
158
 
147
159
  /**
148
160
  * Gets a PropFirm by key
@@ -70,8 +70,8 @@ const rithmicMenu = async () => {
70
70
  console.log(chalk.cyan('║') + line + ' '.repeat(Math.max(0, innerWidth - lineLen)) + chalk.cyan('║'));
71
71
  }
72
72
 
73
- console.log(chalk.cyan('') + ' '.repeat(innerWidth) + chalk.cyan(''));
74
- console.log(chalk.cyan('║') + ' ' + chalk.red('[X] Back') + ' '.repeat(innerWidth - 10) + chalk.cyan('║'));
73
+ console.log(chalk.cyan('' + ''.repeat(innerWidth) + ''));
74
+ console.log(chalk.cyan('║') + chalk.red(centerText('[X] Exit', innerWidth)) + chalk.cyan('║'));
75
75
  console.log(chalk.cyan('╚' + '═'.repeat(innerWidth) + '╝'));
76
76
 
77
77
  const input = await prompts.textInput(chalk.cyan('Select number (or X):'));
@@ -100,7 +100,7 @@ const renderQuantMetrics = (data) => {
100
100
  };
101
101
 
102
102
  /**
103
- * Render trades history section
103
+ * Render trades history section (round-trips)
104
104
  */
105
105
  const renderTradesHistory = (data) => {
106
106
  const { allTrades } = data;
@@ -108,60 +108,73 @@ const renderTradesHistory = (data) => {
108
108
  const innerWidth = boxWidth - 2;
109
109
 
110
110
  console.log();
111
- drawBoxHeader('TRADES HISTORY', boxWidth);
111
+ drawBoxHeader('TRADES HISTORY (Round-Trips)', boxWidth);
112
112
 
113
113
  const extractSymbol = (contractId) => {
114
114
  if (!contractId) return 'N/A';
115
- if (contractId.length <= 10) return contractId;
116
- return contractId.substring(0, 10);
115
+ if (contractId.length <= 8) return contractId;
116
+ return contractId.substring(0, 8);
117
117
  };
118
118
 
119
119
  if (allTrades.length > 0) {
120
- const colTime = 10;
121
- const colSymbol = 12;
122
- const colPrice = 12;
123
- const colPnl = 12;
120
+ const colSymbol = 9;
124
121
  const colSide = 6;
125
- const separators = 15;
126
- const fixedWidth = colTime + colSymbol + colPrice + colPnl + colSide + separators;
127
- const colAccount = Math.max(10, innerWidth - fixedWidth);
122
+ const colQty = 4;
123
+ const colEntry = 11;
124
+ const colExit = 11;
125
+ const colPnl = 10;
126
+ const separators = 18;
127
+ const fixedWidth = colSymbol + colSide + colQty + colEntry + colExit + colPnl + separators;
128
+ const colDate = Math.max(10, innerWidth - fixedWidth);
128
129
 
129
- const header = ` ${'Time'.padEnd(colTime)}| ${'Symbol'.padEnd(colSymbol)}| ${'Price'.padEnd(colPrice)}| ${'P&L'.padEnd(colPnl)}| ${'Side'.padEnd(colSide)}| ${'Account'.padEnd(colAccount - 2)}`;
130
+ const header = ` ${'Symbol'.padEnd(colSymbol)}| ${'Side'.padEnd(colSide)}| ${'Qty'.padEnd(colQty)}| ${'Entry'.padEnd(colEntry)}| ${'Exit'.padEnd(colExit)}| ${'P&L'.padEnd(colPnl)}| ${'Date'.padEnd(colDate - 2)}`;
130
131
  console.log(chalk.cyan('\u2551') + chalk.white(header) + chalk.cyan('\u2551'));
131
132
  console.log(chalk.cyan('\u2551') + chalk.gray('\u2500'.repeat(innerWidth)) + chalk.cyan('\u2551'));
132
133
 
133
- const recentTrades = allTrades.slice(-10).reverse();
134
+ // Show most recent trades first (already sorted by exitTime desc)
135
+ const recentTrades = allTrades.slice(0, 10);
134
136
 
135
137
  for (const trade of recentTrades) {
136
- const timestamp = trade.creationTimestamp || trade.timestamp;
137
- const time = timestamp ? new Date(timestamp).toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', hour12: true }) : '--:--';
138
- const symbol = extractSymbol(trade.contractId || trade.symbol);
139
- const price = (trade.price || 0).toFixed(2);
140
- const pnl = trade.profitAndLoss || trade.pnl || 0;
138
+ const symbol = extractSymbol(trade.symbol);
139
+ // Round-trip: side=1 means Long (BUY then SELL), side=2 means Short (SELL then BUY)
140
+ const side = trade.side === 1 ? 'LONG' : trade.side === 2 ? 'SHORT' : 'N/A';
141
+ const qty = String(trade.quantity || 1);
142
+ const entry = (trade.entryPrice || 0).toFixed(2);
143
+ const exit = (trade.exitPrice || 0).toFixed(2);
144
+ const pnl = trade.pnl || trade.profitAndLoss || 0;
141
145
  const pnlText = pnl >= 0 ? `+$${pnl.toFixed(0)}` : `-$${Math.abs(pnl).toFixed(0)}`;
142
- const side = trade.side === 0 ? 'BUY' : trade.side === 1 ? 'SELL' : 'N/A';
143
- const accountName = (trade.accountName || 'N/A').substring(0, colAccount - 3);
144
146
 
145
- const timeStr = time.padEnd(colTime);
147
+ // Format date from exitDate (YYYYMMDD) or exitTime
148
+ let dateStr = '--/--';
149
+ if (trade.exitDate) {
150
+ const d = trade.exitDate;
151
+ dateStr = `${d.slice(4,6)}/${d.slice(6,8)}`;
152
+ } else if (trade.exitTime) {
153
+ const dt = new Date(trade.exitTime);
154
+ dateStr = `${(dt.getMonth()+1).toString().padStart(2,'0')}/${dt.getDate().toString().padStart(2,'0')}`;
155
+ }
156
+
146
157
  const symbolStr = symbol.padEnd(colSymbol);
147
- const priceStr = price.padEnd(colPrice);
148
- const pnlStr = pnlText.padEnd(colPnl);
149
158
  const sideStr = side.padEnd(colSide);
150
- const accountStr = accountName.padEnd(colAccount - 2);
159
+ const qtyStr = qty.padEnd(colQty);
160
+ const entryStr = entry.padEnd(colEntry);
161
+ const exitStr = exit.padEnd(colExit);
162
+ const pnlStr = pnlText.padEnd(colPnl);
163
+ const dateStrPad = dateStr.padEnd(colDate - 2);
151
164
 
152
165
  const pnlColored = pnl >= 0 ? chalk.green(pnlStr) : chalk.red(pnlStr);
153
- const sideColored = trade.side === 0 ? chalk.green(sideStr) : chalk.red(sideStr);
166
+ const sideColored = trade.side === 1 ? chalk.green(sideStr) : chalk.red(sideStr);
154
167
 
155
- const row = ` ${timeStr}| ${symbolStr}| ${priceStr}| ${pnlColored}| ${sideColored}| ${accountStr}`;
168
+ const row = ` ${symbolStr}| ${sideColored}| ${qtyStr}| ${entryStr}| ${exitStr}| ${pnlColored}| ${dateStrPad}`;
156
169
  console.log(chalk.cyan('\u2551') + row + chalk.cyan('\u2551'));
157
170
  }
158
171
 
159
172
  if (allTrades.length > 10) {
160
- const moreMsg = ` ... and ${allTrades.length - 10} more trades`;
173
+ const moreMsg = ` ... and ${allTrades.length - 10} more round-trips`;
161
174
  console.log(chalk.cyan('\u2551') + moreMsg.padEnd(innerWidth) + chalk.cyan('\u2551'));
162
175
  }
163
176
  } else {
164
- const msg = ' No trades found';
177
+ const msg = ' No round-trip trades found';
165
178
  console.log(chalk.cyan('\u2551') + chalk.gray(msg.padEnd(innerWidth)) + chalk.cyan('\u2551'));
166
179
  }
167
180
 
@@ -100,17 +100,25 @@ const aggregateStats = (activeAccounts, allTrades) => {
100
100
 
101
101
  for (const trade of allTrades) {
102
102
  const pnl = trade.profitAndLoss || trade.pnl || 0;
103
- const size = trade.size || trade.quantity || 1;
103
+ const size = trade.size || trade.fillSize || trade.quantity || 1;
104
+ // Rithmic: 1=BUY, 2=SELL. Other APIs: 0=BUY, 1=SELL
104
105
  const side = trade.side;
106
+ const isBuy = side === 0 || side === 1; // 0 or 1 = BUY depending on API
107
+ const isSell = side === 2 || (side === 1 && trade.connectionType !== 'rithmic'); // 2 = SELL for Rithmic
105
108
 
106
109
  stats.totalVolume += Math.abs(size);
107
110
 
108
- if (side === 0) {
111
+ // For Rithmic: 1=BUY (long), 2=SELL (short)
112
+ if (side === 1) {
109
113
  stats.longTrades++;
110
114
  if (pnl > 0) stats.longWins++;
111
- } else if (side === 1) {
115
+ } else if (side === 2) {
112
116
  stats.shortTrades++;
113
117
  if (pnl > 0) stats.shortWins++;
118
+ } else if (side === 0) {
119
+ // Other APIs: 0=BUY
120
+ stats.longTrades++;
121
+ if (pnl > 0) stats.longWins++;
114
122
  }
115
123
 
116
124
  if (pnl > 0) {
@@ -149,6 +149,8 @@ const PROTO_FILES = [
149
149
  'response_show_order_history.proto',
150
150
  'request_show_order_history_dates.proto',
151
151
  'response_show_order_history_dates.proto',
152
+ 'request_show_order_history_summary.proto',
153
+ 'response_show_order_history_summary.proto',
152
154
  'request_market_data_update.proto',
153
155
  'response_market_data_update.proto',
154
156
  'last_trade.proto',
@@ -17,6 +17,7 @@ const {
17
17
  getPositions,
18
18
  } = require('./accounts');
19
19
  const { placeOrder, cancelOrder, getOrders, getOrderHistory, getOrderHistoryDates, getTradeHistoryFull, closePosition } = require('./orders');
20
+ const { fillsToRoundTrips, calculateTradeStats } = require('./trades');
20
21
  const { getContracts, searchContracts } = require('./contracts');
21
22
  const { TIMEOUTS } = require('../../config/settings');
22
23
  const { logger } = require('../../utils/logger');
@@ -242,53 +243,50 @@ class RithmicService extends EventEmitter {
242
243
  async getUser() { return this.user; }
243
244
 
244
245
  /**
245
- * Get trade history from Rithmic API
246
+ * Get trade history from Rithmic API as round-trips
246
247
  * @param {string} accountId - Optional account filter
247
248
  * @param {number} days - Number of days to look back (default 30)
248
249
  */
249
250
  async getTradeHistory(accountId, days = 30) {
250
- // Fetch from API
251
+ // Fetch fills from API
251
252
  const result = await getTradeHistoryFull(this, days);
252
253
 
253
254
  if (!result.success) {
254
255
  return { success: false, trades: [] };
255
256
  }
256
257
 
257
- let trades = result.trades || [];
258
+ let fills = result.trades || [];
258
259
 
259
260
  // Filter by account if specified
260
261
  if (accountId) {
261
- trades = trades.filter(t => t.accountId === accountId);
262
+ fills = fills.filter(t => t.accountId === accountId);
262
263
  }
263
264
 
264
- // Add timestamp from fillDate/fillTime if not present
265
- trades = trades.map(t => ({
266
- ...t,
267
- timestamp: t.timestamp || this._parseDateTime(t.fillDate, t.fillTime),
268
- }));
265
+ // Convert fills to round-trips with P&L
266
+ const roundTrips = fillsToRoundTrips(fills);
269
267
 
270
- // Sort by timestamp descending (newest first)
271
- trades.sort((a, b) => (b.timestamp || 0) - (a.timestamp || 0));
272
-
273
- return { success: true, trades };
268
+ return { success: true, trades: roundTrips };
274
269
  }
275
270
 
276
271
  /**
277
- * Parse Rithmic date/time to timestamp
278
- * @private
272
+ * Get raw fills (not matched to round-trips)
273
+ * @param {string} accountId - Optional account filter
274
+ * @param {number} days - Number of days to look back (default 30)
279
275
  */
280
- _parseDateTime(dateStr, timeStr) {
281
- if (!dateStr) return Date.now();
282
- try {
283
- // dateStr format: YYYYMMDD, timeStr format: HH:MM:SS
284
- const year = dateStr.slice(0, 4);
285
- const month = dateStr.slice(4, 6);
286
- const day = dateStr.slice(6, 8);
287
- const time = timeStr || '00:00:00';
288
- return new Date(`${year}-${month}-${day}T${time}Z`).getTime();
289
- } catch (e) {
290
- return Date.now();
276
+ async getRawFills(accountId, days = 30) {
277
+ const result = await getTradeHistoryFull(this, days);
278
+
279
+ if (!result.success) {
280
+ return { success: false, fills: [] };
281
+ }
282
+
283
+ let fills = result.trades || [];
284
+
285
+ if (accountId) {
286
+ fills = fills.filter(t => t.accountId === accountId);
291
287
  }
288
+
289
+ return { success: true, fills };
292
290
  }
293
291
 
294
292
  /**
@@ -297,54 +295,12 @@ class RithmicService extends EventEmitter {
297
295
  async getLifetimeStats(accountId) {
298
296
  const { trades } = await this.getTradeHistory(accountId, 365);
299
297
 
300
- if (trades.length === 0) {
298
+ if (!trades || trades.length === 0) {
301
299
  return { success: true, stats: null };
302
300
  }
303
301
 
304
- // Calculate stats from trades
305
- let totalTrades = trades.length;
306
- let winningTrades = 0;
307
- let losingTrades = 0;
308
- let totalProfit = 0;
309
- let totalLoss = 0;
310
- let longTrades = 0;
311
- let shortTrades = 0;
312
- let totalVolume = 0;
313
-
314
- // Group fills by basketId to calculate P&L per trade
315
- const tradeGroups = new Map();
316
- for (const trade of trades) {
317
- const key = trade.basketId || trade.id;
318
- if (!tradeGroups.has(key)) {
319
- tradeGroups.set(key, []);
320
- }
321
- tradeGroups.get(key).push(trade);
322
- }
323
-
324
- for (const [, fills] of tradeGroups) {
325
- const firstFill = fills[0];
326
- totalVolume += fills.reduce((sum, f) => sum + f.size, 0);
327
-
328
- if (firstFill.side === 1) longTrades++;
329
- else if (firstFill.side === 2) shortTrades++;
330
-
331
- // P&L calculation requires entry/exit matching which needs position tracking
332
- // For now, count trades
333
- totalTrades = tradeGroups.size;
334
- }
335
-
336
- const stats = {
337
- totalTrades,
338
- winningTrades,
339
- losingTrades,
340
- winRate: totalTrades > 0 ? ((winningTrades / totalTrades) * 100).toFixed(2) : 0,
341
- totalProfit,
342
- totalLoss,
343
- netPnL: totalProfit - totalLoss,
344
- longTrades,
345
- shortTrades,
346
- totalVolume,
347
- };
302
+ // Calculate stats from round-trips
303
+ const stats = calculateTradeStats(trades);
348
304
 
349
305
  return { success: true, stats };
350
306
  }
@@ -5,6 +5,9 @@
5
5
 
6
6
  const { REQ } = require('./constants');
7
7
 
8
+ // Debug mode
9
+ const DEBUG = process.env.HQX_DEBUG === '1';
10
+
8
11
  /**
9
12
  * Place order via ORDER_PLANT
10
13
  * @param {RithmicService} service - The Rithmic service instance
@@ -120,6 +123,7 @@ const getOrders = async (service) => {
120
123
 
121
124
  /**
122
125
  * Get available order history dates
126
+ * RequestShowOrderHistoryDates (318) does NOT require account_id
123
127
  * @param {RithmicService} service - The Rithmic service instance
124
128
  * @returns {Promise<{success: boolean, dates: string[]}>}
125
129
  */
@@ -128,35 +132,53 @@ const getOrderHistoryDates = async (service) => {
128
132
  return { success: false, dates: [] };
129
133
  }
130
134
 
135
+ const { proto } = require('./protobuf');
136
+
131
137
  return new Promise((resolve) => {
132
138
  const dates = [];
133
139
  const timeout = setTimeout(() => {
140
+ service.orderConn.removeListener('message', handler);
134
141
  resolve({ success: true, dates });
135
142
  }, 5000);
136
143
 
137
144
  const handler = (msg) => {
138
- if (msg.templateId === 319 && msg.date) {
139
- // ResponseShowOrderHistoryDates returns dates array
140
- if (Array.isArray(msg.date)) {
141
- dates.push(...msg.date);
142
- } else {
143
- dates.push(msg.date);
145
+ // msg contains { templateId, data }
146
+ if (msg.templateId === 319) {
147
+ try {
148
+ const res = proto.decode('ResponseShowOrderHistoryDates', msg.data);
149
+ DEBUG && console.log('[OrderHistory] 319 response:', JSON.stringify(res));
150
+
151
+ // Dates come as repeated string field
152
+ if (res.date) {
153
+ const dateList = Array.isArray(res.date) ? res.date : [res.date];
154
+ for (const d of dateList) {
155
+ if (d && !dates.includes(d)) {
156
+ dates.push(d);
157
+ }
158
+ }
159
+ }
160
+
161
+ // Check for completion (rpCode = '0')
162
+ if (res.rpCode && res.rpCode.length > 0 && res.rpCode[0] === '0') {
163
+ clearTimeout(timeout);
164
+ service.orderConn.removeListener('message', handler);
165
+ resolve({ success: true, dates });
166
+ }
167
+ } catch (e) {
168
+ DEBUG && console.log('[OrderHistory] Error decoding 319:', e.message);
144
169
  }
145
170
  }
146
- if (msg.templateId === 319 && msg.rpCode && msg.rpCode[0] === '0') {
147
- clearTimeout(timeout);
148
- service.orderConn.removeListener('message', handler);
149
- resolve({ success: true, dates });
150
- }
151
171
  };
152
172
 
153
173
  service.orderConn.on('message', handler);
154
174
 
155
175
  try {
176
+ // Request 318 does NOT need account_id - just template_id and user_msg
156
177
  service.orderConn.send('RequestShowOrderHistoryDates', {
157
178
  templateId: REQ.SHOW_ORDER_HISTORY_DATES,
158
179
  userMsg: ['HQX'],
159
180
  });
181
+ DEBUG && console.log('[OrderHistory] Sent request 318 (ShowOrderHistoryDates)');
160
182
  } catch (e) {
161
183
  clearTimeout(timeout);
162
184
  service.orderConn.removeListener('message', handler);
@@ -166,52 +188,82 @@ const getOrderHistoryDates = async (service) => {
166
188
  };
167
189
 
168
190
  /**
169
- * Get order history for a specific date
191
+ * Get order history for a specific date using show_order_history_summary
192
+ * RequestShowOrderHistorySummary (324) returns ExchangeOrderNotification (352) with is_snapshot=true
170
193
  * @param {RithmicService} service - The Rithmic service instance
171
194
  * @param {string} date - Date in YYYYMMDD format
172
195
  * @returns {Promise<{success: boolean, orders: Array}>}
173
196
  */
174
197
  const getOrderHistory = async (service, date) => {
175
- if (!service.orderConn || !service.loginInfo) {
198
+ if (!service.orderConn || !service.loginInfo || service.accounts.length === 0) {
176
199
  return { success: true, orders: [] };
177
200
  }
178
201
 
202
+ const { proto } = require('./protobuf');
179
203
  const dateStr = date || new Date().toISOString().slice(0, 10).replace(/-/g, '');
180
204
 
181
205
  return new Promise((resolve) => {
182
206
  const orders = [];
207
+ let receivedCount = 0;
208
+ const expectedAccounts = service.accounts.length;
209
+ const requestId = `HQX-${Date.now()}`;
210
+
183
211
  const timeout = setTimeout(() => {
184
212
  service.orderConn.removeListener('message', handler);
213
+ DEBUG && console.log(`[OrderHistory] Timeout. Got ${orders.length} orders`);
185
214
  resolve({ success: true, orders });
186
215
  }, 10000);
187
216
 
188
217
  const handler = (msg) => {
189
- // ExchangeOrderNotification (352) contains order/fill details
190
- if (msg.templateId === 352 && msg.data) {
218
+ // Response comes as template 352 (ExchangeOrderNotification) with is_snapshot=true
219
+ if (msg.templateId === 352) {
191
220
  try {
192
- const notification = service.orderConn.proto.decode('ExchangeOrderNotification', msg.data);
193
- if (notification && notification.symbol) {
194
- orders.push({
195
- id: notification.fillId || notification.basketId || `${Date.now()}`,
196
- accountId: notification.accountId,
197
- symbol: notification.symbol,
198
- exchange: notification.exchange || 'CME',
199
- side: notification.transactionType, // 1=BUY, 2=SELL
200
- quantity: notification.quantity,
201
- price: notification.price,
202
- fillPrice: notification.fillPrice,
203
- fillSize: notification.fillSize,
204
- fillTime: notification.fillTime,
205
- fillDate: notification.fillDate,
206
- avgFillPrice: notification.avgFillPrice,
207
- totalFillSize: notification.totalFillSize,
208
- status: notification.status,
209
- notifyType: notification.notifyType,
210
- isSnapshot: notification.isSnapshot,
211
- });
221
+ const notification = proto.decode('ExchangeOrderNotification', msg.data);
222
+
223
+ // Only process snapshot data (historical orders)
224
+ if (notification.isSnapshot) {
225
+ DEBUG && console.log('[OrderHistory] 352 snapshot:', notification.symbol, notification.notifyType);
226
+
227
+ if (notification.symbol) {
228
+ orders.push({
229
+ id: notification.fillId || notification.basketId || `${Date.now()}-${orders.length}`,
230
+ accountId: notification.accountId,
231
+ symbol: notification.symbol,
232
+ exchange: notification.exchange || 'CME',
233
+ side: notification.transactionType, // 1=BUY, 2=SELL
234
+ quantity: parseInt(notification.quantity) || 0,
235
+ price: parseFloat(notification.price) || 0,
236
+ fillPrice: parseFloat(notification.fillPrice) || 0,
237
+ fillSize: parseInt(notification.fillSize) || 0,
238
+ fillTime: notification.fillTime,
239
+ fillDate: notification.fillDate,
240
+ avgFillPrice: parseFloat(notification.avgFillPrice) || 0,
241
+ totalFillSize: parseInt(notification.totalFillSize) || 0,
242
+ status: notification.status,
243
+ notifyType: notification.notifyType,
244
+ isSnapshot: true,
245
+ });
246
+ }
212
247
  }
213
248
  } catch (e) {
214
- // Ignore decode errors
249
+ DEBUG && console.log('[OrderHistory] Error decoding 352:', e.message);
250
+ }
251
+ }
252
+
253
+ // Template 325 signals completion of order history summary
254
+ if (msg.templateId === 325) {
255
+ try {
256
+ const res = proto.decode('ResponseShowOrderHistorySummary', msg.data);
257
+ DEBUG && console.log('[OrderHistory] 325 response:', JSON.stringify(res));
258
+ receivedCount++;
259
+
260
+ if (receivedCount >= expectedAccounts) {
261
+ clearTimeout(timeout);
262
+ service.orderConn.removeListener('message', handler);
263
+ resolve({ success: true, orders });
264
+ }
265
+ } catch (e) {
266
+ DEBUG && console.log('[OrderHistory] Error decoding 325:', e.message);
215
267
  }
216
268
  }
217
269
  };
@@ -219,23 +271,18 @@ const getOrderHistory = async (service, date) => {
219
271
  service.orderConn.on('message', handler);
220
272
 
221
273
  try {
274
+ // Send request 324 for each account
222
275
  for (const acc of service.accounts) {
223
- service.orderConn.send('RequestShowOrderHistory', {
276
+ DEBUG && console.log(`[OrderHistory] Sending 324 for account ${acc.accountId}, date ${dateStr}`);
277
+ service.orderConn.send('RequestShowOrderHistorySummary', {
224
278
  templateId: REQ.SHOW_ORDER_HISTORY,
225
- userMsg: ['HQX'],
279
+ userMsg: [requestId],
226
280
  fcmId: acc.fcmId || service.loginInfo.fcmId,
227
281
  ibId: acc.ibId || service.loginInfo.ibId,
228
282
  accountId: acc.accountId,
229
283
  date: dateStr,
230
284
  });
231
285
  }
232
-
233
- // Wait for responses
234
- setTimeout(() => {
235
- clearTimeout(timeout);
236
- service.orderConn.removeListener('message', handler);
237
- resolve({ success: true, orders });
238
- }, 5000);
239
286
  } catch (e) {
240
287
  clearTimeout(timeout);
241
288
  service.orderConn.removeListener('message', handler);
@@ -3,9 +3,9 @@ package rti;
3
3
 
4
4
  message ResponseShowOrderHistorySummary
5
5
  {
6
- // Based on async_rithmic proto definitions
7
- optional int32 template_id = 156580;
8
- repeated string user_msg = 142744;
9
- repeated string rp_code = 142750;
10
- optional int32 fills_collected = 156569;
6
+ // Field IDs from rundef/async_rithmic (verified Jan 2026)
7
+ // Same field IDs as other messages (PB_OFFSET = 100000)
8
+ optional int32 template_id = 154467;
9
+ repeated string user_msg = 132760;
10
+ repeated string rp_code = 132766;
11
11
  }
@@ -0,0 +1,334 @@
1
+ /**
2
+ * Rithmic Trades Module
3
+ * Convert individual fills to round-trip trades with P&L
4
+ *
5
+ * NO MOCK DATA - Only real fills from Rithmic API
6
+ */
7
+
8
+ // Tick values for common futures contracts
9
+ const TICK_VALUES = {
10
+ // E-mini contracts
11
+ 'ES': { tickSize: 0.25, tickValue: 12.50 },
12
+ 'NQ': { tickSize: 0.25, tickValue: 5.00 },
13
+ 'YM': { tickSize: 1.00, tickValue: 5.00 },
14
+ 'RTY': { tickSize: 0.10, tickValue: 5.00 },
15
+ // Micro contracts
16
+ 'MES': { tickSize: 0.25, tickValue: 1.25 },
17
+ 'MNQ': { tickSize: 0.25, tickValue: 0.50 },
18
+ 'MYM': { tickSize: 1.00, tickValue: 0.50 },
19
+ 'M2K': { tickSize: 0.10, tickValue: 0.50 },
20
+ // Commodities
21
+ 'CL': { tickSize: 0.01, tickValue: 10.00 },
22
+ 'GC': { tickSize: 0.10, tickValue: 10.00 },
23
+ 'SI': { tickSize: 0.005, tickValue: 25.00 },
24
+ 'NG': { tickSize: 0.001, tickValue: 10.00 },
25
+ // Bonds
26
+ 'ZB': { tickSize: 0.03125, tickValue: 31.25 },
27
+ 'ZN': { tickSize: 0.015625, tickValue: 15.625 },
28
+ 'ZF': { tickSize: 0.0078125, tickValue: 7.8125 },
29
+ // Default
30
+ 'DEFAULT': { tickSize: 0.25, tickValue: 1.25 },
31
+ };
32
+
33
+ /**
34
+ * Get base symbol from contract (e.g., "MNQH6" -> "MNQ")
35
+ * @param {string} symbol - Full contract symbol
36
+ * @returns {string} Base symbol
37
+ */
38
+ const getBaseSymbol = (symbol) => {
39
+ if (!symbol) return 'DEFAULT';
40
+ // Remove month/year suffix (e.g., H6, M5, Z4)
41
+ const match = symbol.match(/^([A-Z0-9]+?)([FGHJKMNQUVXZ]\d{1,2})?$/i);
42
+ return match ? match[1].toUpperCase() : symbol.toUpperCase();
43
+ };
44
+
45
+ /**
46
+ * Get tick value for a symbol
47
+ * @param {string} symbol - Contract symbol
48
+ * @returns {Object} { tickSize, tickValue }
49
+ */
50
+ const getTickInfo = (symbol) => {
51
+ const base = getBaseSymbol(symbol);
52
+ return TICK_VALUES[base] || TICK_VALUES['DEFAULT'];
53
+ };
54
+
55
+ /**
56
+ * Calculate P&L for a round-trip trade
57
+ * @param {number} entryPrice - Entry price
58
+ * @param {number} exitPrice - Exit price
59
+ * @param {number} quantity - Number of contracts
60
+ * @param {number} side - 1=Long (BUY first), 2=Short (SELL first)
61
+ * @param {string} symbol - Contract symbol
62
+ * @returns {number} P&L in dollars
63
+ */
64
+ const calculatePnL = (entryPrice, exitPrice, quantity, side, symbol) => {
65
+ const { tickSize, tickValue } = getTickInfo(symbol);
66
+ const priceDiff = side === 1 ? (exitPrice - entryPrice) : (entryPrice - exitPrice);
67
+ const ticks = priceDiff / tickSize;
68
+ return ticks * tickValue * quantity;
69
+ };
70
+
71
+ /**
72
+ * Convert fills to round-trip trades using FIFO matching
73
+ * @param {Array} fills - Array of fill objects from Rithmic API
74
+ * @returns {Array} Array of round-trip trade objects
75
+ */
76
+ const fillsToRoundTrips = (fills) => {
77
+ if (!fills || fills.length === 0) return [];
78
+
79
+ // Group fills by account and symbol
80
+ const groups = new Map();
81
+
82
+ for (const fill of fills) {
83
+ const key = `${fill.accountId}:${fill.symbol}`;
84
+ if (!groups.has(key)) {
85
+ groups.set(key, []);
86
+ }
87
+ groups.get(key).push({
88
+ ...fill,
89
+ size: fill.fillSize || fill.quantity || 1,
90
+ price: fill.fillPrice || fill.price || 0,
91
+ side: fill.side || fill.transactionType, // 1=BUY, 2=SELL
92
+ timestamp: fill.timestamp || parseDateTime(fill.fillDate, fill.fillTime),
93
+ });
94
+ }
95
+
96
+ const roundTrips = [];
97
+
98
+ // Process each symbol group
99
+ for (const [key, symbolFills] of groups) {
100
+ // Sort by timestamp ascending (oldest first for FIFO)
101
+ symbolFills.sort((a, b) => (a.timestamp || 0) - (b.timestamp || 0));
102
+
103
+ // Position tracking: positive = long, negative = short
104
+ let position = 0;
105
+ let openTrades = []; // Stack of open fills for matching
106
+
107
+ for (const fill of symbolFills) {
108
+ const fillSide = fill.side; // 1=BUY, 2=SELL
109
+ const fillQty = fill.size;
110
+ const fillPrice = fill.price;
111
+
112
+ if (fillSide === 1) {
113
+ // BUY
114
+ if (position >= 0) {
115
+ // Opening or adding to long position
116
+ openTrades.push({ ...fill, remainingQty: fillQty });
117
+ position += fillQty;
118
+ } else {
119
+ // Closing short position (BUY to cover)
120
+ let qtyToClose = fillQty;
121
+
122
+ while (qtyToClose > 0 && openTrades.length > 0) {
123
+ const openTrade = openTrades[0];
124
+ const closeQty = Math.min(qtyToClose, openTrade.remainingQty);
125
+
126
+ // Create round-trip (short trade)
127
+ const pnl = calculatePnL(openTrade.price, fillPrice, closeQty, 2, fill.symbol);
128
+ roundTrips.push({
129
+ id: `${openTrade.id || openTrade.fillId}-${fill.id || fill.fillId}`,
130
+ accountId: fill.accountId,
131
+ symbol: fill.symbol,
132
+ exchange: fill.exchange,
133
+ side: 2, // Short
134
+ quantity: closeQty,
135
+ entryPrice: openTrade.price,
136
+ exitPrice: fillPrice,
137
+ entryTime: openTrade.timestamp,
138
+ exitTime: fill.timestamp,
139
+ entryDate: openTrade.fillDate,
140
+ exitDate: fill.fillDate,
141
+ pnl: pnl,
142
+ profitAndLoss: pnl,
143
+ });
144
+
145
+ openTrade.remainingQty -= closeQty;
146
+ qtyToClose -= closeQty;
147
+ position += closeQty;
148
+
149
+ if (openTrade.remainingQty === 0) {
150
+ openTrades.shift();
151
+ }
152
+ }
153
+
154
+ // If still have qty, it's opening a new long
155
+ if (qtyToClose > 0) {
156
+ openTrades.push({ ...fill, remainingQty: qtyToClose });
157
+ position += qtyToClose;
158
+ }
159
+ }
160
+ } else if (fillSide === 2) {
161
+ // SELL
162
+ if (position <= 0) {
163
+ // Opening or adding to short position
164
+ openTrades.push({ ...fill, remainingQty: fillQty });
165
+ position -= fillQty;
166
+ } else {
167
+ // Closing long position (SELL to close)
168
+ let qtyToClose = fillQty;
169
+
170
+ while (qtyToClose > 0 && openTrades.length > 0) {
171
+ const openTrade = openTrades[0];
172
+ const closeQty = Math.min(qtyToClose, openTrade.remainingQty);
173
+
174
+ // Create round-trip (long trade)
175
+ const pnl = calculatePnL(openTrade.price, fillPrice, closeQty, 1, fill.symbol);
176
+ roundTrips.push({
177
+ id: `${openTrade.id || openTrade.fillId}-${fill.id || fill.fillId}`,
178
+ accountId: fill.accountId,
179
+ symbol: fill.symbol,
180
+ exchange: fill.exchange,
181
+ side: 1, // Long
182
+ quantity: closeQty,
183
+ entryPrice: openTrade.price,
184
+ exitPrice: fillPrice,
185
+ entryTime: openTrade.timestamp,
186
+ exitTime: fill.timestamp,
187
+ entryDate: openTrade.fillDate,
188
+ exitDate: fill.fillDate,
189
+ pnl: pnl,
190
+ profitAndLoss: pnl,
191
+ });
192
+
193
+ openTrade.remainingQty -= closeQty;
194
+ qtyToClose -= closeQty;
195
+ position -= closeQty;
196
+
197
+ if (openTrade.remainingQty === 0) {
198
+ openTrades.shift();
199
+ }
200
+ }
201
+
202
+ // If still have qty, it's opening a new short
203
+ if (qtyToClose > 0) {
204
+ openTrades.push({ ...fill, remainingQty: qtyToClose });
205
+ position -= qtyToClose;
206
+ }
207
+ }
208
+ }
209
+ }
210
+ }
211
+
212
+ // Sort round-trips by exit time descending (newest first)
213
+ roundTrips.sort((a, b) => (b.exitTime || 0) - (a.exitTime || 0));
214
+
215
+ return roundTrips;
216
+ };
217
+
218
+ /**
219
+ * Parse Rithmic date/time to timestamp
220
+ * @param {string} dateStr - Date in YYYYMMDD format
221
+ * @param {string} timeStr - Time in HH:MM:SS format
222
+ * @returns {number} Unix timestamp in milliseconds
223
+ */
224
+ const parseDateTime = (dateStr, timeStr) => {
225
+ if (!dateStr) return Date.now();
226
+ try {
227
+ const year = dateStr.slice(0, 4);
228
+ const month = dateStr.slice(4, 6);
229
+ const day = dateStr.slice(6, 8);
230
+ const time = timeStr || '00:00:00';
231
+ return new Date(`${year}-${month}-${day}T${time}Z`).getTime();
232
+ } catch (e) {
233
+ return Date.now();
234
+ }
235
+ };
236
+
237
+ /**
238
+ * Calculate summary statistics from round-trips
239
+ * @param {Array} roundTrips - Array of round-trip trades
240
+ * @returns {Object} Summary statistics
241
+ */
242
+ const calculateTradeStats = (roundTrips) => {
243
+ if (!roundTrips || roundTrips.length === 0) {
244
+ return {
245
+ totalTrades: 0,
246
+ winningTrades: 0,
247
+ losingTrades: 0,
248
+ breakEvenTrades: 0,
249
+ totalPnL: 0,
250
+ totalProfit: 0,
251
+ totalLoss: 0,
252
+ winRate: 0,
253
+ avgWin: 0,
254
+ avgLoss: 0,
255
+ largestWin: 0,
256
+ largestLoss: 0,
257
+ profitFactor: 0,
258
+ longTrades: 0,
259
+ shortTrades: 0,
260
+ longWins: 0,
261
+ shortWins: 0,
262
+ totalVolume: 0,
263
+ };
264
+ }
265
+
266
+ let stats = {
267
+ totalTrades: roundTrips.length,
268
+ winningTrades: 0,
269
+ losingTrades: 0,
270
+ breakEvenTrades: 0,
271
+ totalPnL: 0,
272
+ totalProfit: 0,
273
+ totalLoss: 0,
274
+ largestWin: 0,
275
+ largestLoss: 0,
276
+ longTrades: 0,
277
+ shortTrades: 0,
278
+ longWins: 0,
279
+ shortWins: 0,
280
+ totalVolume: 0,
281
+ };
282
+
283
+ for (const trade of roundTrips) {
284
+ const pnl = trade.pnl || 0;
285
+ stats.totalPnL += pnl;
286
+ stats.totalVolume += trade.quantity || 1;
287
+
288
+ if (trade.side === 1) {
289
+ stats.longTrades++;
290
+ if (pnl > 0) stats.longWins++;
291
+ } else {
292
+ stats.shortTrades++;
293
+ if (pnl > 0) stats.shortWins++;
294
+ }
295
+
296
+ if (pnl > 0) {
297
+ stats.winningTrades++;
298
+ stats.totalProfit += pnl;
299
+ if (pnl > stats.largestWin) stats.largestWin = pnl;
300
+ } else if (pnl < 0) {
301
+ stats.losingTrades++;
302
+ stats.totalLoss += Math.abs(pnl);
303
+ if (pnl < stats.largestLoss) stats.largestLoss = pnl;
304
+ } else {
305
+ stats.breakEvenTrades++;
306
+ }
307
+ }
308
+
309
+ // Calculate derived metrics
310
+ stats.winRate = stats.totalTrades > 0
311
+ ? (stats.winningTrades / stats.totalTrades) * 100
312
+ : 0;
313
+ stats.avgWin = stats.winningTrades > 0
314
+ ? stats.totalProfit / stats.winningTrades
315
+ : 0;
316
+ stats.avgLoss = stats.losingTrades > 0
317
+ ? stats.totalLoss / stats.losingTrades
318
+ : 0;
319
+ stats.profitFactor = stats.totalLoss > 0
320
+ ? stats.totalProfit / stats.totalLoss
321
+ : (stats.totalProfit > 0 ? Infinity : 0);
322
+
323
+ return stats;
324
+ };
325
+
326
+ module.exports = {
327
+ fillsToRoundTrips,
328
+ calculateTradeStats,
329
+ calculatePnL,
330
+ getTickInfo,
331
+ getBaseSymbol,
332
+ parseDateTime,
333
+ TICK_VALUES,
334
+ };