hedgequantx 1.8.33 → 1.8.35

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": "1.8.33",
3
+ "version": "1.8.35",
4
4
  "description": "Prop Futures Algo Trading CLI - Connect to Topstep, Alpha Futures, and other prop firms",
5
5
  "main": "src/app.js",
6
6
  "bin": {
@@ -180,7 +180,7 @@ const getContractsFromAPI = async () => {
180
180
  };
181
181
 
182
182
  /**
183
- * Symbol selection helper - uses API contracts for all services
183
+ * Symbol selection helper - grouped by category with indices first
184
184
  */
185
185
  const selectSymbol = async (service, label) => {
186
186
  const spinner = ora({ text: 'Loading symbols...', color: 'yellow' }).start();
@@ -193,6 +193,7 @@ const selectSymbol = async (service, label) => {
193
193
  if (!contracts && typeof service.getContracts === 'function') {
194
194
  const result = await service.getContracts();
195
195
  if (result.success && result.contracts?.length > 0) {
196
+ // Contracts from Rithmic are already categorized and sorted
196
197
  contracts = result.contracts;
197
198
  }
198
199
  }
@@ -203,23 +204,36 @@ const selectSymbol = async (service, label) => {
203
204
  return null;
204
205
  }
205
206
 
206
- // Sort: Popular indices first (ES, NQ, YM, RTY, MES, MNQ)
207
- const prioritySymbols = ['ES', 'NQ', 'YM', 'RTY', 'MES', 'MNQ', 'MYM', 'M2K', 'NKD', 'CL', 'GC'];
208
- contracts.sort((a, b) => {
209
- const aSymbol = (a.symbol || a.name || '').toUpperCase();
210
- const bSymbol = (b.symbol || b.name || '').toUpperCase();
211
- const aIdx = prioritySymbols.findIndex(p => aSymbol.startsWith(p));
212
- const bIdx = prioritySymbols.findIndex(p => bSymbol.startsWith(p));
213
- if (aIdx !== -1 && bIdx !== -1) return aIdx - bIdx;
214
- if (aIdx !== -1) return -1;
215
- if (bIdx !== -1) return 1;
216
- return (a.name || '').localeCompare(b.name || '');
217
- });
218
-
219
207
  spinner.succeed(`Found ${contracts.length} contracts`);
220
208
 
221
- const options = contracts.map(c => ({ label: c.name || c.symbol, value: c }));
222
- options.push({ label: '< Cancel', value: null });
209
+ // Build options with category headers (if contracts have category info)
210
+ const options = [];
211
+ let currentCategory = null;
212
+
213
+ for (const c of contracts) {
214
+ // Add category header when category changes (if available)
215
+ if (c.categoryName && c.categoryName !== currentCategory) {
216
+ currentCategory = c.categoryName;
217
+ options.push({
218
+ label: chalk.cyan.bold(`── ${currentCategory} ──`),
219
+ value: null,
220
+ disabled: true
221
+ });
222
+ }
223
+
224
+ // Format label based on available data
225
+ const symbolDisplay = c.symbol || c.name;
226
+ const nameDisplay = c.name || c.symbol;
227
+ const exchangeDisplay = c.exchange ? ` (${c.exchange})` : '';
228
+ const label = c.categoryName
229
+ ? ` ${symbolDisplay} - ${nameDisplay}${exchangeDisplay}`
230
+ : `${nameDisplay}${exchangeDisplay}`;
231
+
232
+ options.push({ label, value: c });
233
+ }
234
+
235
+ options.push({ label: '', value: null, disabled: true }); // Spacer
236
+ options.push({ label: chalk.gray('< Cancel'), value: null });
223
237
 
224
238
  return await prompts.selectOption(`${label} Symbol:`, options);
225
239
  } catch (e) {
@@ -72,7 +72,7 @@ const oneAccountMenu = async (service) => {
72
72
  };
73
73
 
74
74
  /**
75
- * Symbol selection - same as copy-trading
75
+ * Symbol selection - grouped by category with indices first
76
76
  */
77
77
  const selectSymbol = async (service, account) => {
78
78
  const spinner = ora({ text: 'Loading symbols...', color: 'yellow' }).start();
@@ -83,33 +83,36 @@ const selectSymbol = async (service, account) => {
83
83
  return null;
84
84
  }
85
85
 
86
- // Normalize contract structure - API returns { name: "ESH6", description: "E-mini S&P 500..." }
87
- const contracts = contractsResult.contracts.map(c => ({
88
- ...c,
89
- symbol: c.name || c.symbol,
90
- name: c.description || c.name || c.symbol
91
- }));
92
-
93
- // Sort: Popular indices first (ES, NQ, YM, RTY, MES, MNQ)
94
- const prioritySymbols = ['ES', 'NQ', 'YM', 'RTY', 'MES', 'MNQ', 'MYM', 'M2K', 'NKD', 'CL', 'GC'];
95
- contracts.sort((a, b) => {
96
- const aSymbol = (a.symbol || '').toUpperCase();
97
- const bSymbol = (b.symbol || '').toUpperCase();
98
- const aIdx = prioritySymbols.findIndex(p => aSymbol.startsWith(p));
99
- const bIdx = prioritySymbols.findIndex(p => bSymbol.startsWith(p));
100
- if (aIdx !== -1 && bIdx !== -1) return aIdx - bIdx;
101
- if (aIdx !== -1) return -1;
102
- if (bIdx !== -1) return 1;
103
- return (a.name || '').localeCompare(b.name || '');
104
- });
86
+ // Contracts are already sorted by category from getContracts()
87
+ const contracts = contractsResult.contracts;
105
88
 
106
89
  spinner.succeed(`Found ${contracts.length} contracts`);
107
90
 
108
- const options = contracts.map(c => ({ label: c.name, value: c }));
109
- options.push({ label: '< Back', value: 'back' });
91
+ // Build options with category headers
92
+ const options = [];
93
+ let currentCategory = null;
94
+
95
+ for (const c of contracts) {
96
+ // Add category header when category changes
97
+ if (c.categoryName !== currentCategory) {
98
+ currentCategory = c.categoryName;
99
+ options.push({
100
+ label: chalk.cyan.bold(`── ${currentCategory} ──`),
101
+ value: null,
102
+ disabled: true
103
+ });
104
+ }
105
+
106
+ // Format: "ESH6 - E-mini S&P 500 (CME)"
107
+ const label = ` ${c.symbol} - ${c.name} (${c.exchange})`;
108
+ options.push({ label, value: c });
109
+ }
110
+
111
+ options.push({ label: '', value: null, disabled: true }); // Spacer
112
+ options.push({ label: chalk.gray('< Back'), value: 'back' });
110
113
 
111
114
  const contract = await prompts.selectOption(chalk.yellow('Select Symbol:'), options);
112
- return contract === 'back' ? null : contract;
115
+ return contract === 'back' || contract === null ? null : contract;
113
116
  };
114
117
 
115
118
  /**
@@ -165,6 +165,155 @@ const PROTO_FILES = [
165
165
  'response_front_month_contract.proto',
166
166
  ];
167
167
 
168
+ // Symbol Categories - Order matters for display (indices first)
169
+ const SYMBOL_CATEGORIES = {
170
+ INDICES: {
171
+ name: 'Indices',
172
+ order: 1,
173
+ symbols: {
174
+ // E-mini Indices
175
+ 'ES': { name: 'E-mini S&P 500', tickSize: 0.25, tickValue: 12.50 },
176
+ 'NQ': { name: 'E-mini Nasdaq-100', tickSize: 0.25, tickValue: 5.00 },
177
+ 'YM': { name: 'E-mini Dow Jones', tickSize: 1.00, tickValue: 5.00 },
178
+ 'RTY': { name: 'E-mini Russell 2000', tickSize: 0.10, tickValue: 5.00 },
179
+ 'EMD': { name: 'E-mini S&P MidCap 400', tickSize: 0.10, tickValue: 10.00 },
180
+ // Micro Indices
181
+ 'MES': { name: 'Micro E-mini S&P 500', tickSize: 0.25, tickValue: 1.25 },
182
+ 'MNQ': { name: 'Micro E-mini Nasdaq-100', tickSize: 0.25, tickValue: 0.50 },
183
+ 'MYM': { name: 'Micro E-mini Dow Jones', tickSize: 1.00, tickValue: 0.50 },
184
+ 'M2K': { name: 'Micro E-mini Russell 2000', tickSize: 0.10, tickValue: 0.50 },
185
+ // International Indices
186
+ 'NKD': { name: 'Nikkei 225 (USD)', tickSize: 5.00, tickValue: 25.00 },
187
+ }
188
+ },
189
+ ENERGY: {
190
+ name: 'Energy',
191
+ order: 2,
192
+ symbols: {
193
+ 'CL': { name: 'Crude Oil WTI', tickSize: 0.01, tickValue: 10.00 },
194
+ 'MCL': { name: 'Micro Crude Oil WTI', tickSize: 0.01, tickValue: 1.00 },
195
+ 'BZ': { name: 'Brent Crude Oil', tickSize: 0.01, tickValue: 10.00 },
196
+ 'NG': { name: 'Natural Gas', tickSize: 0.001, tickValue: 10.00 },
197
+ 'HO': { name: 'Heating Oil', tickSize: 0.0001, tickValue: 4.20 },
198
+ 'RB': { name: 'RBOB Gasoline', tickSize: 0.0001, tickValue: 4.20 },
199
+ }
200
+ },
201
+ METALS: {
202
+ name: 'Metals',
203
+ order: 3,
204
+ symbols: {
205
+ 'GC': { name: 'Gold', tickSize: 0.10, tickValue: 10.00 },
206
+ 'MGC': { name: 'Micro Gold', tickSize: 0.10, tickValue: 1.00 },
207
+ '1OZ': { name: '1 Ounce Gold', tickSize: 0.25, tickValue: 0.25 },
208
+ 'SI': { name: 'Silver', tickSize: 0.005, tickValue: 25.00 },
209
+ 'SIL': { name: 'Silver 1000oz', tickSize: 0.005, tickValue: 5.00 },
210
+ 'HG': { name: 'Copper', tickSize: 0.0005, tickValue: 12.50 },
211
+ 'MHG': { name: 'Micro Copper', tickSize: 0.0005, tickValue: 1.25 },
212
+ 'PL': { name: 'Platinum', tickSize: 0.10, tickValue: 5.00 },
213
+ 'PA': { name: 'Palladium', tickSize: 0.10, tickValue: 10.00 },
214
+ }
215
+ },
216
+ CURRENCIES: {
217
+ name: 'Currencies (FX)',
218
+ order: 4,
219
+ symbols: {
220
+ '6E': { name: 'Euro FX', tickSize: 0.00005, tickValue: 6.25 },
221
+ 'M6E': { name: 'Micro Euro FX', tickSize: 0.0001, tickValue: 1.25 },
222
+ '6B': { name: 'British Pound', tickSize: 0.0001, tickValue: 6.25 },
223
+ 'M6B': { name: 'Micro British Pound', tickSize: 0.0001, tickValue: 0.625 },
224
+ '6J': { name: 'Japanese Yen', tickSize: 0.0000005, tickValue: 6.25 },
225
+ '6A': { name: 'Australian Dollar', tickSize: 0.0001, tickValue: 10.00 },
226
+ 'M6A': { name: 'Micro Australian Dollar', tickSize: 0.0001, tickValue: 1.00 },
227
+ '6C': { name: 'Canadian Dollar', tickSize: 0.00005, tickValue: 5.00 },
228
+ '6S': { name: 'Swiss Franc', tickSize: 0.0001, tickValue: 12.50 },
229
+ '6N': { name: 'New Zealand Dollar', tickSize: 0.0001, tickValue: 10.00 },
230
+ '6M': { name: 'Mexican Peso', tickSize: 0.00001, tickValue: 5.00 },
231
+ 'E7': { name: 'E-mini Euro FX', tickSize: 0.0001, tickValue: 6.25 },
232
+ 'RF': { name: 'Euro FX/Swiss Franc', tickSize: 0.0001, tickValue: 12.50 },
233
+ 'RP': { name: 'Euro FX/British Pound', tickSize: 0.00005, tickValue: 6.25 },
234
+ 'RY': { name: 'Euro FX/Japanese Yen', tickSize: 0.01, tickValue: 6.25 },
235
+ 'SEK': { name: 'Swedish Krona', tickSize: 0.00001, tickValue: 12.50 },
236
+ }
237
+ },
238
+ CRYPTO: {
239
+ name: 'Crypto',
240
+ order: 5,
241
+ symbols: {
242
+ 'BTC': { name: 'Bitcoin', tickSize: 5.00, tickValue: 25.00 },
243
+ 'MBT': { name: 'Micro Bitcoin', tickSize: 5.00, tickValue: 0.50 },
244
+ 'ETH': { name: 'Ether', tickSize: 0.25, tickValue: 12.50 },
245
+ 'MET': { name: 'Micro Ether', tickSize: 0.05, tickValue: 0.25 },
246
+ }
247
+ },
248
+ RATES: {
249
+ name: 'Interest Rates',
250
+ order: 6,
251
+ symbols: {
252
+ 'ZB': { name: '30-Year T-Bond', tickSize: 0.03125, tickValue: 31.25 },
253
+ 'ZN': { name: '10-Year T-Note', tickSize: 0.015625, tickValue: 15.625 },
254
+ 'ZF': { name: '5-Year T-Note', tickSize: 0.0078125, tickValue: 7.8125 },
255
+ 'ZT': { name: '2-Year T-Note', tickSize: 0.0078125, tickValue: 15.625 },
256
+ 'TN': { name: 'Ultra 10-Year T-Note', tickSize: 0.015625, tickValue: 15.625 },
257
+ 'ZQ': { name: '30-Day Fed Funds', tickSize: 0.0025, tickValue: 10.4167 },
258
+ }
259
+ },
260
+ AGRICULTURE: {
261
+ name: 'Agriculture',
262
+ order: 7,
263
+ symbols: {
264
+ 'ZC': { name: 'Corn', tickSize: 0.25, tickValue: 12.50 },
265
+ 'ZS': { name: 'Soybeans', tickSize: 0.25, tickValue: 12.50 },
266
+ 'ZW': { name: 'Wheat', tickSize: 0.25, tickValue: 12.50 },
267
+ 'ZL': { name: 'Soybean Oil', tickSize: 0.01, tickValue: 6.00 },
268
+ 'ZM': { name: 'Soybean Meal', tickSize: 0.10, tickValue: 10.00 },
269
+ 'ZO': { name: 'Oats', tickSize: 0.25, tickValue: 12.50 },
270
+ }
271
+ },
272
+ MEATS: {
273
+ name: 'Meats',
274
+ order: 8,
275
+ symbols: {
276
+ 'LE': { name: 'Live Cattle', tickSize: 0.025, tickValue: 10.00 },
277
+ 'HE': { name: 'Lean Hogs', tickSize: 0.025, tickValue: 10.00 },
278
+ 'GF': { name: 'Feeder Cattle', tickSize: 0.025, tickValue: 12.50 },
279
+ }
280
+ },
281
+ };
282
+
283
+ /**
284
+ * Get symbol info (category, name, tick info)
285
+ */
286
+ function getSymbolInfo(baseSymbol) {
287
+ for (const [catKey, category] of Object.entries(SYMBOL_CATEGORIES)) {
288
+ if (category.symbols[baseSymbol]) {
289
+ return {
290
+ category: catKey,
291
+ categoryName: category.name,
292
+ categoryOrder: category.order,
293
+ ...category.symbols[baseSymbol]
294
+ };
295
+ }
296
+ }
297
+ // Unknown symbol
298
+ return {
299
+ category: 'OTHER',
300
+ categoryName: 'Other',
301
+ categoryOrder: 99,
302
+ name: baseSymbol,
303
+ tickSize: null,
304
+ tickValue: null
305
+ };
306
+ }
307
+
308
+ /**
309
+ * Get all categories in display order
310
+ */
311
+ function getCategoryOrder() {
312
+ return Object.entries(SYMBOL_CATEGORIES)
313
+ .sort((a, b) => a[1].order - b[1].order)
314
+ .map(([key, val]) => ({ key, name: val.name, order: val.order }));
315
+ }
316
+
168
317
  module.exports = {
169
318
  RITHMIC_ENDPOINTS,
170
319
  RITHMIC_SYSTEMS,
@@ -173,4 +322,7 @@ module.exports = {
173
322
  RES,
174
323
  STREAM,
175
324
  PROTO_FILES,
325
+ SYMBOL_CATEGORIES,
326
+ getSymbolInfo,
327
+ getCategoryOrder,
176
328
  };
@@ -5,53 +5,16 @@
5
5
 
6
6
  const EventEmitter = require('events');
7
7
  const { RithmicConnection } = require('./connection');
8
- const { RITHMIC_ENDPOINTS, RITHMIC_SYSTEMS } = require('./constants');
8
+ const { RITHMIC_ENDPOINTS, RITHMIC_SYSTEMS, getSymbolInfo, getCategoryOrder } = require('./constants');
9
9
  const { createOrderHandler, createPnLHandler } = require('./handlers');
10
10
  const { fetchAccounts, getTradingAccounts, requestPnLSnapshot, subscribePnLUpdates, getPositions, hashAccountId } = require('./accounts');
11
11
  const { placeOrder, cancelOrder, getOrders, getOrderHistory, closePosition } = require('./orders');
12
- const { decodeFrontMonthContract } = require('./protobuf');
12
+ const { decodeFrontMonthContract, readVarint, readLengthDelimited, skipField } = require('./protobuf');
13
13
 
14
14
  // Debug mode
15
15
  const DEBUG = process.env.HQX_DEBUG === '1';
16
16
  const debug = (...args) => DEBUG && console.log('[Rithmic:Service]', ...args);
17
17
 
18
- // Base symbols for futures contracts
19
- const BASE_SYMBOLS = {
20
- // Equity Index (Quarterly: H, M, U, Z)
21
- quarterly: [
22
- { base: 'ES', name: 'E-mini S&P 500', exchange: 'CME', category: 'Index' },
23
- { base: 'NQ', name: 'E-mini NASDAQ-100', exchange: 'CME', category: 'Index' },
24
- { base: 'YM', name: 'E-mini Dow Jones', exchange: 'CBOT', category: 'Index' },
25
- { base: 'RTY', name: 'E-mini Russell 2000', exchange: 'CME', category: 'Index' },
26
- { base: 'MES', name: 'Micro E-mini S&P 500', exchange: 'CME', category: 'Micro Index' },
27
- { base: 'MNQ', name: 'Micro E-mini NASDAQ-100', exchange: 'CME', category: 'Micro Index' },
28
- { base: 'MYM', name: 'Micro E-mini Dow Jones', exchange: 'CBOT', category: 'Micro Index' },
29
- { base: 'M2K', name: 'Micro E-mini Russell 2000', exchange: 'CME', category: 'Micro Index' },
30
- // Currencies (Quarterly)
31
- { base: '6E', name: 'Euro FX', exchange: 'CME', category: 'Currency' },
32
- { base: 'M6E', name: 'Micro Euro FX', exchange: 'CME', category: 'Currency' },
33
- { base: '6B', name: 'British Pound', exchange: 'CME', category: 'Currency' },
34
- { base: '6J', name: 'Japanese Yen', exchange: 'CME', category: 'Currency' },
35
- { base: '6A', name: 'Australian Dollar', exchange: 'CME', category: 'Currency' },
36
- { base: '6C', name: 'Canadian Dollar', exchange: 'CME', category: 'Currency' },
37
- // Bonds (Quarterly)
38
- { base: 'ZB', name: '30-Year T-Bond', exchange: 'CBOT', category: 'Bonds' },
39
- { base: 'ZN', name: '10-Year T-Note', exchange: 'CBOT', category: 'Bonds' },
40
- { base: 'ZF', name: '5-Year T-Note', exchange: 'CBOT', category: 'Bonds' },
41
- { base: 'ZT', name: '2-Year T-Note', exchange: 'CBOT', category: 'Bonds' },
42
- ],
43
- // Energy & Metals (Monthly)
44
- monthly: [
45
- { base: 'CL', name: 'Crude Oil WTI', exchange: 'NYMEX', category: 'Energy' },
46
- { base: 'MCL', name: 'Micro Crude Oil', exchange: 'NYMEX', category: 'Energy' },
47
- { base: 'NG', name: 'Natural Gas', exchange: 'NYMEX', category: 'Energy' },
48
- { base: 'GC', name: 'Gold', exchange: 'COMEX', category: 'Metals' },
49
- { base: 'MGC', name: 'Micro Gold', exchange: 'COMEX', category: 'Metals' },
50
- { base: 'SI', name: 'Silver', exchange: 'COMEX', category: 'Metals' },
51
- { base: 'HG', name: 'Copper', exchange: 'COMEX', category: 'Metals' },
52
- ],
53
- };
54
-
55
18
  // PropFirm configurations - NO FAKE DATA
56
19
  const PROPFIRM_CONFIGS = {
57
20
  'apex': { name: 'Apex Trader Funding', systemName: 'Apex', gateway: RITHMIC_ENDPOINTS.CHICAGO },
@@ -293,39 +256,31 @@ class RithmicService extends EventEmitter {
293
256
  }
294
257
 
295
258
  /**
296
- * Get front month contract from Rithmic API
259
+ * Get front month contracts for multiple symbols at once - batch request
297
260
  */
298
- async getFrontMonth(baseSymbol, exchange) {
261
+ async getFrontMonthsBatch(products) {
299
262
  if (!this.tickerConn) {
300
- if (!this.credentials) {
301
- throw new Error('Not logged in - cannot fetch front month');
302
- }
303
- const connected = await this.connectTicker(this.credentials.username, this.credentials.password);
304
- if (!connected) {
305
- throw new Error('Failed to connect to TICKER_PLANT');
306
- }
263
+ throw new Error('TICKER_PLANT not connected');
307
264
  }
308
265
 
309
- return new Promise((resolve, reject) => {
310
- const timeout = setTimeout(() => {
311
- reject(new Error(`Timeout getting front month for ${baseSymbol}`));
312
- }, 10000);
266
+ return new Promise((resolve) => {
267
+ const results = new Map();
268
+ const pending = new Set(products.map(p => p.productCode));
313
269
 
314
270
  const handler = (msg) => {
315
271
  if (msg.templateId === 114) { // ResponseFrontMonthContract
316
272
  const decoded = decodeFrontMonthContract(msg.data);
317
- if (decoded.userMsg === baseSymbol) {
318
- clearTimeout(timeout);
319
- this.tickerConn.removeListener('message', handler);
273
+ const baseSymbol = decoded.userMsg;
274
+
275
+ if (pending.has(baseSymbol)) {
276
+ pending.delete(baseSymbol);
320
277
 
321
- if (decoded.rpCode[0] === '0') {
322
- resolve({
278
+ if (decoded.rpCode[0] === '0' && decoded.tradingSymbol) {
279
+ results.set(baseSymbol, {
280
+ symbol: decoded.tradingSymbol,
323
281
  baseSymbol: baseSymbol,
324
- symbol: decoded.tradingSymbol || decoded.symbol,
325
- exchange: decoded.exchange || exchange,
282
+ exchange: decoded.exchange,
326
283
  });
327
- } else {
328
- reject(new Error(`API error for ${baseSymbol}: ${decoded.rpCode.join(' ')}`));
329
284
  }
330
285
  }
331
286
  }
@@ -333,18 +288,181 @@ class RithmicService extends EventEmitter {
333
288
 
334
289
  this.tickerConn.on('message', handler);
335
290
 
336
- // Send RequestFrontMonthContract (template 113)
337
- this.tickerConn.send('RequestFrontMonthContract', {
338
- templateId: 113,
339
- userMsg: [baseSymbol],
340
- symbol: baseSymbol,
341
- exchange: exchange,
291
+ // Send all requests
292
+ for (const product of products) {
293
+ this.tickerConn.send('RequestFrontMonthContract', {
294
+ templateId: 113,
295
+ userMsg: [product.productCode],
296
+ symbol: product.productCode,
297
+ exchange: product.exchange,
298
+ });
299
+ }
300
+
301
+ // Wait for responses (with timeout)
302
+ setTimeout(() => {
303
+ this.tickerConn.removeListener('message', handler);
304
+ resolve(Array.from(results.values()));
305
+ }, 5000);
306
+ });
307
+ }
308
+
309
+ /**
310
+ * Decode ProductCodes response (template 112) - field IDs from Rithmic API
311
+ */
312
+ decodeProductCodesResponse(buffer) {
313
+ const result = {};
314
+ let offset = 0;
315
+
316
+ while (offset < buffer.length) {
317
+ try {
318
+ const [tag, tagOffset] = readVarint(buffer, offset);
319
+ const wireType = tag & 0x7;
320
+ const fieldNumber = tag >>> 3;
321
+ offset = tagOffset;
322
+
323
+ if (wireType === 0) {
324
+ const [val, newOff] = readVarint(buffer, offset);
325
+ offset = newOff;
326
+ if (fieldNumber === 154467) result.templateId = val;
327
+ } else if (wireType === 2) {
328
+ const [val, newOff] = readLengthDelimited(buffer, offset);
329
+ offset = newOff;
330
+ if (fieldNumber === 110101) result.exchange = val; // exchange
331
+ if (fieldNumber === 100749) result.productCode = val; // product_code (base symbol)
332
+ if (fieldNumber === 100003) result.productName = val; // symbol_name
333
+ if (fieldNumber === 132760) result.userMsg = val; // user_msg
334
+ if (fieldNumber === 132764) result.rpCode = val; // rp_code
335
+ } else {
336
+ offset = skipField(buffer, offset, wireType);
337
+ }
338
+ } catch (e) { break; }
339
+ }
340
+
341
+ return result;
342
+ }
343
+
344
+ /**
345
+ * Get all tradeable futures from Rithmic API - 100% REAL DATA
346
+ * Sends RequestFrontMonthContract for known futures and gets real trading symbols
347
+ */
348
+ async fetchAllFrontMonths() {
349
+ if (!this.tickerConn) {
350
+ throw new Error('TICKER_PLANT not connected');
351
+ }
352
+
353
+ // Request front months for ALL products that might be futures
354
+ // The API will return the current trading symbol for each
355
+ return new Promise((resolve) => {
356
+ const contracts = new Map();
357
+ const productsToCheck = new Map(); // Will collect from ProductCodes
358
+
359
+ // Handler for ProductCodes responses
360
+ const productHandler = (msg) => {
361
+ if (msg.templateId === 112) {
362
+ const decoded = this.decodeProductCodesResponse(msg.data);
363
+ if (!decoded.productCode || !decoded.exchange) return;
364
+
365
+ // Only main futures exchanges
366
+ const validExchanges = ['CME', 'CBOT', 'NYMEX', 'COMEX', 'NYBOT', 'CFE'];
367
+ if (!validExchanges.includes(decoded.exchange)) return;
368
+
369
+ // Skip obvious non-futures
370
+ const name = (decoded.productName || '').toLowerCase();
371
+ if (name.includes('option')) return;
372
+ if (name.includes('swap')) return;
373
+ if (name.includes('spread')) return;
374
+
375
+ const key = `${decoded.productCode}:${decoded.exchange}`;
376
+ if (!productsToCheck.has(key)) {
377
+ productsToCheck.set(key, {
378
+ productCode: decoded.productCode,
379
+ productName: decoded.productName || decoded.productCode,
380
+ exchange: decoded.exchange,
381
+ });
382
+ }
383
+ }
384
+ };
385
+
386
+ // Handler for FrontMonth responses
387
+ const frontMonthHandler = (msg) => {
388
+ if (msg.templateId === 114) {
389
+ const decoded = decodeFrontMonthContract(msg.data);
390
+ if (decoded.rpCode[0] === '0' && decoded.tradingSymbol) {
391
+ contracts.set(decoded.userMsg, {
392
+ symbol: decoded.tradingSymbol,
393
+ baseSymbol: decoded.userMsg,
394
+ exchange: decoded.exchange,
395
+ });
396
+ }
397
+ }
398
+ };
399
+
400
+ this.tickerConn.on('message', productHandler);
401
+ this.tickerConn.on('message', frontMonthHandler);
402
+
403
+ // Step 1: Get all product codes
404
+ this.tickerConn.send('RequestProductCodes', {
405
+ templateId: 111,
406
+ userMsg: ['get-products'],
342
407
  });
408
+
409
+ // After 5 seconds, request front months for all collected products
410
+ setTimeout(() => {
411
+ this.tickerConn.removeListener('message', productHandler);
412
+ debug(`Collected ${productsToCheck.size} products, requesting front months...`);
413
+
414
+ // Send front month requests for all products
415
+ for (const [key, product] of productsToCheck) {
416
+ this.tickerConn.send('RequestFrontMonthContract', {
417
+ templateId: 113,
418
+ userMsg: [product.productCode],
419
+ symbol: product.productCode,
420
+ exchange: product.exchange,
421
+ });
422
+ }
423
+
424
+ // After another 5 seconds, collect results
425
+ setTimeout(() => {
426
+ this.tickerConn.removeListener('message', frontMonthHandler);
427
+
428
+ // Merge with product names and add category info
429
+ const results = [];
430
+ for (const [baseSymbol, contract] of contracts) {
431
+ const productKey = `${baseSymbol}:${contract.exchange}`;
432
+ const product = productsToCheck.get(productKey);
433
+ const symbolInfo = getSymbolInfo(baseSymbol);
434
+
435
+ results.push({
436
+ symbol: contract.symbol,
437
+ baseSymbol: baseSymbol,
438
+ name: symbolInfo.name || product?.productName || baseSymbol,
439
+ exchange: contract.exchange,
440
+ category: symbolInfo.category,
441
+ categoryName: symbolInfo.categoryName,
442
+ categoryOrder: symbolInfo.categoryOrder,
443
+ tickSize: symbolInfo.tickSize,
444
+ tickValue: symbolInfo.tickValue,
445
+ });
446
+ }
447
+
448
+ // Sort by category order, then by symbol name within category
449
+ results.sort((a, b) => {
450
+ if (a.categoryOrder !== b.categoryOrder) {
451
+ return a.categoryOrder - b.categoryOrder;
452
+ }
453
+ return a.baseSymbol.localeCompare(b.baseSymbol);
454
+ });
455
+
456
+ debug(`Got ${results.length} tradeable contracts from API`);
457
+ resolve(results);
458
+ }, 8000);
459
+ }, 5000);
343
460
  });
344
461
  }
345
462
 
346
463
  /**
347
- * Get all available contracts - REAL DATA from Rithmic API
464
+ * Get all available contracts - 100% REAL DATA from Rithmic API
465
+ * Fetches product codes and front months directly from the API
348
466
  */
349
467
  async getContracts() {
350
468
  // Return cached if available and fresh (5 min cache)
@@ -366,37 +484,14 @@ class RithmicService extends EventEmitter {
366
484
  }
367
485
  }
368
486
 
369
- const contracts = [];
370
- const allSymbols = [...BASE_SYMBOLS.quarterly, ...BASE_SYMBOLS.monthly];
371
-
372
- debug(`Fetching front months for ${allSymbols.length} symbols...`);
487
+ // Increase max listeners to avoid warnings
488
+ this.tickerConn.setMaxListeners(5000);
373
489
 
374
- // Fetch front months in parallel batches
375
- const batchSize = 10;
376
- for (let i = 0; i < allSymbols.length; i += batchSize) {
377
- const batch = allSymbols.slice(i, i + batchSize);
378
- const promises = batch.map(async (sym) => {
379
- try {
380
- const result = await this.getFrontMonth(sym.base, sym.exchange);
381
- return {
382
- symbol: result.symbol,
383
- name: `${sym.name} (${result.symbol})`,
384
- exchange: result.exchange,
385
- category: sym.category,
386
- baseSymbol: sym.base,
387
- };
388
- } catch (e) {
389
- debug(`Failed to get front month for ${sym.base}: ${e.message}`);
390
- return null;
391
- }
392
- });
393
-
394
- const results = await Promise.all(promises);
395
- contracts.push(...results.filter(r => r !== null));
396
- }
490
+ debug('Fetching all tradeable contracts from Rithmic API...');
491
+ const contracts = await this.fetchAllFrontMonths();
397
492
 
398
493
  if (contracts.length === 0) {
399
- return { success: false, error: 'No contracts returned from API' };
494
+ return { success: false, error: 'No tradeable contracts found' };
400
495
  }
401
496
 
402
497
  // Cache the results
@@ -126,6 +126,7 @@ const numberInput = async (message, defaultVal = 1, min = 1, max = 1000) => {
126
126
 
127
127
  /**
128
128
  * Select - arrow keys navigation
129
+ * Supports disabled options (separators) via opt.disabled
129
130
  */
130
131
  const selectOption = async (message, options) => {
131
132
  // Close shared readline before inquirer to avoid conflicts
@@ -135,10 +136,16 @@ const selectOption = async (message, options) => {
135
136
  }
136
137
  prepareStdin();
137
138
 
138
- const choices = options.map(opt => ({
139
- name: opt.label,
140
- value: opt.value
141
- }));
139
+ const choices = options.map(opt => {
140
+ if (opt.disabled) {
141
+ // Use inquirer Separator for disabled items (category headers)
142
+ return new inquirer.Separator(opt.label);
143
+ }
144
+ return {
145
+ name: opt.label,
146
+ value: opt.value
147
+ };
148
+ });
142
149
 
143
150
  const { value } = await inquirer.prompt([{
144
151
  type: 'list',
@@ -147,7 +154,7 @@ const selectOption = async (message, options) => {
147
154
  choices,
148
155
  prefix: '',
149
156
  loop: false,
150
- pageSize: 15
157
+ pageSize: 20 // Increased to show more symbols
151
158
  }]);
152
159
 
153
160
  return value;