hedgequantx 2.6.23 → 2.6.25

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.6.23",
3
+ "version": "2.6.25",
4
4
  "description": "HedgeQuantX - Prop Futures Trading CLI",
5
5
  "main": "src/app.js",
6
6
  "bin": {
@@ -19,6 +19,7 @@ const { PositionManager } = require('../../services/position-manager');
19
19
  // Strategy & Market Data (obfuscated)
20
20
  const { M1 } = require('../../../dist/lib/m/s1');
21
21
  const { MarketDataFeed } = require('../../../dist/lib/data');
22
+ const { RithmicMarketDataFeed } = require('../../services/rithmic/market-data');
22
23
  const { algoLogger } = require('./logger');
23
24
 
24
25
  // AI Strategy Supervisor - observes, learns, and optimizes the strategy
@@ -335,8 +336,10 @@ const launchAlgo = async (service, account, contract, config) => {
335
336
 
336
337
  // Initialize AI Strategy Supervisor - agents observe, learn & optimize
337
338
  // Only if user enabled AI in config
339
+ let aiAgentCount = 0;
338
340
  if (config.enableAI) {
339
341
  const aiAgents = aiService.getAgents();
342
+ aiAgentCount = aiAgents.length;
340
343
  if (aiAgents.length > 0) {
341
344
  const supervisorResult = StrategySupervisor.initialize(strategy, aiAgents, service, account.accountId);
342
345
  stats.aiSupervision = supervisorResult.success;
@@ -345,7 +348,11 @@ const launchAlgo = async (service, account, contract, config) => {
345
348
  }
346
349
 
347
350
  // Initialize Market Data Feed
348
- const marketFeed = new MarketDataFeed({ propfirm: account.propfirm });
351
+ // Use RithmicMarketDataFeed for Rithmic accounts (fast path), MarketDataFeed for ProjectX
352
+ const isRithmic = useFastPath && service.tickerConn;
353
+ const marketFeed = isRithmic
354
+ ? new RithmicMarketDataFeed(service)
355
+ : new MarketDataFeed({ propfirm: account.propfirm });
349
356
 
350
357
  // Smart startup logs (same as HQX-TG)
351
358
  const market = checkMarketHours();
@@ -358,7 +365,7 @@ const launchAlgo = async (service, account, contract, config) => {
358
365
 
359
366
  // Log AI supervision status
360
367
  if (stats.aiSupervision) {
361
- algoLogger.info(ui, 'AI SUPERVISION', `${aiAgents.length} agent(s) - ${stats.aiMode} mode - LEARNING ACTIVE`);
368
+ algoLogger.info(ui, 'AI SUPERVISION', `${aiAgentCount} agent(s) - ${stats.aiMode} mode - LEARNING ACTIVE`);
362
369
  }
363
370
 
364
371
  // Log execution path
@@ -603,29 +610,49 @@ const launchAlgo = async (service, account, contract, config) => {
603
610
 
604
611
  // Connect to market data
605
612
  try {
606
- const propfirmKey = (account.propfirm || 'topstep').toLowerCase().replace(/\s+/g, '_');
607
-
608
- // CRITICAL: Get a fresh token for WebSocket connection
609
- // TopStep invalidates WebSocket sessions for old tokens
610
- algoLogger.info(ui, 'REFRESHING AUTH TOKEN...');
611
- const token = await service.getFreshToken?.() || service.token || service.getToken?.();
612
-
613
- if (!token) {
614
- algoLogger.error(ui, 'NO AUTH TOKEN', 'Please reconnect');
615
- } else {
616
- algoLogger.info(ui, 'TOKEN OK', `${token.length} chars`);
617
- algoLogger.info(ui, 'CONNECTING', `${propfirmKey.toUpperCase()} | ${contractId}`);
613
+ if (isRithmic) {
614
+ // Rithmic: Use existing tickerConn from RithmicService
615
+ algoLogger.info(ui, 'CONNECTING', `RITHMIC TICKER | ${symbolName}`);
618
616
 
619
- await marketFeed.connect(token, propfirmKey);
617
+ await marketFeed.connect();
620
618
 
621
619
  // Wait for connection to stabilize
622
- await new Promise(r => setTimeout(r, 2000));
620
+ await new Promise(r => setTimeout(r, 1000));
623
621
 
624
622
  if (marketFeed.isConnected()) {
625
- await marketFeed.subscribe(symbolName, contractId);
626
- algoLogger.info(ui, 'SUBSCRIBED', `${symbolName} real-time feed active`);
623
+ // Use contract exchange or default to CME
624
+ const exchange = contract.exchange || 'CME';
625
+ marketFeed.subscribe(symbolName, exchange);
626
+ algoLogger.info(ui, 'SUBSCRIBED', `${symbolName} Rithmic real-time feed active`);
627
627
  } else {
628
- algoLogger.error(ui, 'CONNECTION LOST', 'Before subscribe');
628
+ algoLogger.error(ui, 'CONNECTION LOST', 'Rithmic ticker not ready');
629
+ }
630
+ } else {
631
+ // ProjectX: Use HTTP token-based WebSocket
632
+ const propfirmKey = (account.propfirm || 'topstep').toLowerCase().replace(/\s+/g, '_');
633
+
634
+ // CRITICAL: Get a fresh token for WebSocket connection
635
+ // TopStep invalidates WebSocket sessions for old tokens
636
+ algoLogger.info(ui, 'REFRESHING AUTH TOKEN...');
637
+ const token = await service.getFreshToken?.() || service.token || service.getToken?.();
638
+
639
+ if (!token) {
640
+ algoLogger.error(ui, 'NO AUTH TOKEN', 'Please reconnect');
641
+ } else {
642
+ algoLogger.info(ui, 'TOKEN OK', `${token.length} chars`);
643
+ algoLogger.info(ui, 'CONNECTING', `${propfirmKey.toUpperCase()} | ${contractId}`);
644
+
645
+ await marketFeed.connect(token, propfirmKey);
646
+
647
+ // Wait for connection to stabilize
648
+ await new Promise(r => setTimeout(r, 2000));
649
+
650
+ if (marketFeed.isConnected()) {
651
+ await marketFeed.subscribe(symbolName, contractId);
652
+ algoLogger.info(ui, 'SUBSCRIBED', `${symbolName} real-time feed active`);
653
+ } else {
654
+ algoLogger.error(ui, 'CONNECTION LOST', 'Before subscribe');
655
+ }
629
656
  }
630
657
  }
631
658
  } catch (e) {
@@ -24,6 +24,63 @@ const { logger } = require('../../utils/logger');
24
24
 
25
25
  const log = logger.scope('Rithmic');
26
26
 
27
+ /**
28
+ * CME Contract Specifications - Official exchange tick sizes and values
29
+ * These are technical constants defined by the exchange, not market data.
30
+ * Source: CME Group contract specifications
31
+ */
32
+ const CME_CONTRACT_SPECS = {
33
+ // E-mini Index Futures (CME)
34
+ ES: { tickSize: 0.25, tickValue: 12.50 }, // E-mini S&P 500
35
+ NQ: { tickSize: 0.25, tickValue: 5.00 }, // E-mini NASDAQ-100
36
+ RTY: { tickSize: 0.10, tickValue: 5.00 }, // E-mini Russell 2000
37
+ YM: { tickSize: 1.00, tickValue: 5.00 }, // E-mini Dow ($5)
38
+
39
+ // Micro Index Futures (CME)
40
+ MES: { tickSize: 0.25, tickValue: 1.25 }, // Micro E-mini S&P 500
41
+ MNQ: { tickSize: 0.25, tickValue: 0.50 }, // Micro E-mini NASDAQ-100
42
+ M2K: { tickSize: 0.10, tickValue: 0.50 }, // Micro E-mini Russell 2000
43
+ MYM: { tickSize: 1.00, tickValue: 0.50 }, // Micro E-mini Dow
44
+
45
+ // Energy Futures (NYMEX)
46
+ CL: { tickSize: 0.01, tickValue: 10.00 }, // Crude Oil
47
+ QM: { tickSize: 0.025, tickValue: 12.50 }, // E-mini Crude Oil
48
+ MCL: { tickSize: 0.01, tickValue: 1.00 }, // Micro Crude Oil
49
+ NG: { tickSize: 0.001, tickValue: 10.00 }, // Natural Gas
50
+ QG: { tickSize: 0.005, tickValue: 12.50 }, // E-mini Natural Gas
51
+
52
+ // Metal Futures (COMEX)
53
+ GC: { tickSize: 0.10, tickValue: 10.00 }, // Gold
54
+ MGC: { tickSize: 0.10, tickValue: 1.00 }, // Micro Gold
55
+ SI: { tickSize: 0.005, tickValue: 25.00 }, // Silver
56
+ SIL: { tickSize: 0.005, tickValue: 2.50 }, // Micro Silver (1000oz)
57
+ HG: { tickSize: 0.0005, tickValue: 12.50 }, // Copper
58
+ MHG: { tickSize: 0.0005, tickValue: 1.25 }, // Micro Copper
59
+
60
+ // Treasury Futures (CBOT)
61
+ ZB: { tickSize: 0.03125, tickValue: 31.25 }, // 30-Year T-Bond
62
+ ZN: { tickSize: 0.015625, tickValue: 15.625 }, // 10-Year T-Note
63
+ ZF: { tickSize: 0.0078125, tickValue: 7.8125 }, // 5-Year T-Note
64
+ ZT: { tickSize: 0.0078125, tickValue: 15.625 }, // 2-Year T-Note
65
+
66
+ // Agricultural Futures (CBOT)
67
+ ZC: { tickSize: 0.25, tickValue: 12.50 }, // Corn
68
+ ZS: { tickSize: 0.25, tickValue: 12.50 }, // Soybeans
69
+ ZW: { tickSize: 0.25, tickValue: 12.50 }, // Wheat
70
+ ZL: { tickSize: 0.01, tickValue: 6.00 }, // Soybean Oil
71
+ ZM: { tickSize: 0.10, tickValue: 10.00 }, // Soybean Meal
72
+
73
+ // Currency Futures (CME)
74
+ '6E': { tickSize: 0.00005, tickValue: 6.25 }, // Euro FX
75
+ '6J': { tickSize: 0.0000005, tickValue: 6.25 }, // Japanese Yen
76
+ '6B': { tickSize: 0.0001, tickValue: 6.25 }, // British Pound
77
+ '6A': { tickSize: 0.0001, tickValue: 10.00 }, // Australian Dollar
78
+ '6C': { tickSize: 0.00005, tickValue: 5.00 }, // Canadian Dollar
79
+
80
+ // VIX Futures (CFE)
81
+ VX: { tickSize: 0.05, tickValue: 50.00 }, // VIX Futures
82
+ };
83
+
27
84
  /** PropFirm configurations */
28
85
  const PROPFIRM_CONFIGS = {
29
86
  apex: { name: 'Apex Trader Funding', systemName: 'Apex', gateway: RITHMIC_ENDPOINTS.CHICAGO },
@@ -767,15 +824,19 @@ class RithmicService extends EventEmitter {
767
824
  const productKey = `${baseSymbol}:${contract.exchange}`;
768
825
  const product = productsToCheck.get(productKey);
769
826
 
770
- // 100% API data - no static symbol info
827
+ // API data + CME contract specs (technical constants)
771
828
  const productName = product?.productName || baseSymbol;
829
+ const specs = CME_CONTRACT_SPECS[baseSymbol] || null;
830
+
772
831
  results.push({
773
832
  symbol: contract.symbol,
774
833
  baseSymbol,
775
834
  name: contract.symbol, // Use trading symbol as name
776
835
  description: productName, // Product name as description (like ProjectX)
777
836
  exchange: contract.exchange,
778
- // All other data comes from API at runtime
837
+ // CME contract specifications (official exchange constants)
838
+ tickSize: specs?.tickSize ?? null,
839
+ tickValue: specs?.tickValue ?? null,
779
840
  });
780
841
  }
781
842
 
@@ -0,0 +1,528 @@
1
+ /**
2
+ * =============================================================================
3
+ * Rithmic Market Data Feed
4
+ * =============================================================================
5
+ * Handles real-time quotes and trades streaming via Rithmic WebSocket
6
+ *
7
+ * Based on HQX-TG RithmicMarketData implementation
8
+ *
9
+ * IMPORTANT: Use continuous/front-month symbols for subscriptions:
10
+ * - ES, NQ, MES, MNQ (NOT ESH25, NQH25, etc.)
11
+ * - Rithmic automatically routes to the current front-month contract
12
+ */
13
+
14
+ const EventEmitter = require('events');
15
+ const { logger } = require('../../utils/logger');
16
+
17
+ const log = logger.scope('RithmicMD');
18
+
19
+ // Template IDs for Rithmic messages
20
+ const TEMPLATE_IDS = {
21
+ // Request
22
+ MARKET_DATA_UPDATE: 100,
23
+ // Response/Stream
24
+ LAST_TRADE: 150,
25
+ BEST_BID_OFFER: 151,
26
+ };
27
+
28
+ // Rithmic field IDs for LastTrade (from protobuf)
29
+ const LAST_TRADE_FIELDS = {
30
+ TEMPLATE_ID: 154467,
31
+ SYMBOL: 110100,
32
+ EXCHANGE: 110101,
33
+ TRADE_PRICE: 100006,
34
+ TRADE_SIZE: 100178,
35
+ AGGRESSOR: 112003, // 1=BUY, 2=SELL
36
+ SSBOE: 150100,
37
+ USECS: 150101,
38
+ };
39
+
40
+ // Rithmic field IDs for BestBidOffer (from protobuf)
41
+ const BBO_FIELDS = {
42
+ TEMPLATE_ID: 154467,
43
+ SYMBOL: 110100,
44
+ EXCHANGE: 110101,
45
+ BID_PRICE: 100022,
46
+ BID_SIZE: 100030,
47
+ ASK_PRICE: 100025,
48
+ ASK_SIZE: 100031,
49
+ SSBOE: 150100,
50
+ USECS: 150101,
51
+ };
52
+
53
+ /**
54
+ * Read a varint from buffer starting at offset
55
+ * Uses BigInt internally to handle large field IDs correctly
56
+ * @param {Buffer} buffer
57
+ * @param {number} offset
58
+ * @returns {[number, number]} [value, newOffset]
59
+ */
60
+ function readVarint(buffer, offset) {
61
+ let result = BigInt(0);
62
+ let shift = BigInt(0);
63
+ let pos = offset;
64
+
65
+ while (pos < buffer.length) {
66
+ const byte = buffer[pos++];
67
+ result |= BigInt(byte & 0x7f) << shift;
68
+ if ((byte & 0x80) === 0) {
69
+ return [Number(result), pos];
70
+ }
71
+ shift += BigInt(7);
72
+ if (shift > BigInt(63)) {
73
+ throw new Error('Varint too large');
74
+ }
75
+ }
76
+
77
+ throw new Error('Incomplete varint');
78
+ }
79
+
80
+ /**
81
+ * Read a length-delimited field (string/bytes)
82
+ * @param {Buffer} buffer
83
+ * @param {number} offset
84
+ * @returns {[string, number]} [value, newOffset]
85
+ */
86
+ function readLengthDelimited(buffer, offset) {
87
+ const [length, newOffset] = readVarint(buffer, offset);
88
+ const value = buffer.slice(newOffset, newOffset + length).toString('utf8');
89
+ return [value, newOffset + length];
90
+ }
91
+
92
+ /**
93
+ * Skip a field based on wire type
94
+ * @param {Buffer} buffer
95
+ * @param {number} offset
96
+ * @param {number} wireType
97
+ * @returns {number} newOffset
98
+ */
99
+ function skipField(buffer, offset, wireType) {
100
+ switch (wireType) {
101
+ case 0: // Varint
102
+ const [, newOffset] = readVarint(buffer, offset);
103
+ return newOffset;
104
+ case 1: // 64-bit
105
+ return offset + 8;
106
+ case 2: // Length-delimited
107
+ const [length, lenOffset] = readVarint(buffer, offset);
108
+ return lenOffset + length;
109
+ case 5: // 32-bit
110
+ return offset + 4;
111
+ default:
112
+ throw new Error(`Unknown wire type: ${wireType}`);
113
+ }
114
+ }
115
+
116
+ /**
117
+ * Manually decode LastTrade message from Rithmic
118
+ * Required because protobufjs can't handle field IDs > 100000
119
+ * @param {Buffer} buffer
120
+ * @returns {Object}
121
+ */
122
+ function decodeLastTrade(buffer) {
123
+ const result = {};
124
+ let offset = 0;
125
+
126
+ while (offset < buffer.length) {
127
+ try {
128
+ const [tag, newOffset] = readVarint(buffer, offset);
129
+ const fieldNumber = tag >>> 3;
130
+ const wireType = tag & 0x7;
131
+ offset = newOffset;
132
+
133
+ switch (fieldNumber) {
134
+ case LAST_TRADE_FIELDS.SYMBOL:
135
+ if (wireType === 2) {
136
+ const [val, next] = readLengthDelimited(buffer, offset);
137
+ result.symbol = val;
138
+ offset = next;
139
+ } else {
140
+ offset = skipField(buffer, offset, wireType);
141
+ }
142
+ break;
143
+ case LAST_TRADE_FIELDS.EXCHANGE:
144
+ if (wireType === 2) {
145
+ const [val, next] = readLengthDelimited(buffer, offset);
146
+ result.exchange = val;
147
+ offset = next;
148
+ } else {
149
+ offset = skipField(buffer, offset, wireType);
150
+ }
151
+ break;
152
+ case LAST_TRADE_FIELDS.TRADE_PRICE:
153
+ if (wireType === 1) {
154
+ result.tradePrice = buffer.readDoubleLE(offset);
155
+ offset += 8;
156
+ } else {
157
+ offset = skipField(buffer, offset, wireType);
158
+ }
159
+ break;
160
+ case LAST_TRADE_FIELDS.TRADE_SIZE:
161
+ if (wireType === 0) {
162
+ const [val, next] = readVarint(buffer, offset);
163
+ result.tradeSize = val;
164
+ offset = next;
165
+ } else {
166
+ offset = skipField(buffer, offset, wireType);
167
+ }
168
+ break;
169
+ case LAST_TRADE_FIELDS.AGGRESSOR:
170
+ if (wireType === 0) {
171
+ const [val, next] = readVarint(buffer, offset);
172
+ result.aggressor = val;
173
+ offset = next;
174
+ } else {
175
+ offset = skipField(buffer, offset, wireType);
176
+ }
177
+ break;
178
+ case LAST_TRADE_FIELDS.SSBOE:
179
+ if (wireType === 0) {
180
+ const [val, next] = readVarint(buffer, offset);
181
+ result.ssboe = val;
182
+ offset = next;
183
+ } else {
184
+ offset = skipField(buffer, offset, wireType);
185
+ }
186
+ break;
187
+ case LAST_TRADE_FIELDS.USECS:
188
+ if (wireType === 0) {
189
+ const [val, next] = readVarint(buffer, offset);
190
+ result.usecs = val;
191
+ offset = next;
192
+ } else {
193
+ offset = skipField(buffer, offset, wireType);
194
+ }
195
+ break;
196
+ default:
197
+ offset = skipField(buffer, offset, wireType);
198
+ }
199
+ } catch {
200
+ break;
201
+ }
202
+ }
203
+
204
+ return result;
205
+ }
206
+
207
+ /**
208
+ * Manually decode BestBidOffer message from Rithmic
209
+ * Required because protobufjs can't handle field IDs > 100000
210
+ * @param {Buffer} buffer
211
+ * @returns {Object}
212
+ */
213
+ function decodeBestBidOffer(buffer) {
214
+ const result = {};
215
+ let offset = 0;
216
+
217
+ while (offset < buffer.length) {
218
+ try {
219
+ const [tag, newOffset] = readVarint(buffer, offset);
220
+ const fieldNumber = tag >>> 3;
221
+ const wireType = tag & 0x7;
222
+ offset = newOffset;
223
+
224
+ switch (fieldNumber) {
225
+ case BBO_FIELDS.SYMBOL:
226
+ if (wireType === 2) {
227
+ const [val, next] = readLengthDelimited(buffer, offset);
228
+ result.symbol = val;
229
+ offset = next;
230
+ } else {
231
+ offset = skipField(buffer, offset, wireType);
232
+ }
233
+ break;
234
+ case BBO_FIELDS.EXCHANGE:
235
+ if (wireType === 2) {
236
+ const [val, next] = readLengthDelimited(buffer, offset);
237
+ result.exchange = val;
238
+ offset = next;
239
+ } else {
240
+ offset = skipField(buffer, offset, wireType);
241
+ }
242
+ break;
243
+ case BBO_FIELDS.BID_PRICE:
244
+ if (wireType === 1) {
245
+ result.bidPrice = buffer.readDoubleLE(offset);
246
+ offset += 8;
247
+ } else {
248
+ offset = skipField(buffer, offset, wireType);
249
+ }
250
+ break;
251
+ case BBO_FIELDS.BID_SIZE:
252
+ if (wireType === 0) {
253
+ const [val, next] = readVarint(buffer, offset);
254
+ result.bidSize = val;
255
+ offset = next;
256
+ } else {
257
+ offset = skipField(buffer, offset, wireType);
258
+ }
259
+ break;
260
+ case BBO_FIELDS.ASK_PRICE:
261
+ if (wireType === 1) {
262
+ result.askPrice = buffer.readDoubleLE(offset);
263
+ offset += 8;
264
+ } else {
265
+ offset = skipField(buffer, offset, wireType);
266
+ }
267
+ break;
268
+ case BBO_FIELDS.ASK_SIZE:
269
+ if (wireType === 0) {
270
+ const [val, next] = readVarint(buffer, offset);
271
+ result.askSize = val;
272
+ offset = next;
273
+ } else {
274
+ offset = skipField(buffer, offset, wireType);
275
+ }
276
+ break;
277
+ case BBO_FIELDS.SSBOE:
278
+ if (wireType === 0) {
279
+ const [val, next] = readVarint(buffer, offset);
280
+ result.ssboe = val;
281
+ offset = next;
282
+ } else {
283
+ offset = skipField(buffer, offset, wireType);
284
+ }
285
+ break;
286
+ case BBO_FIELDS.USECS:
287
+ if (wireType === 0) {
288
+ const [val, next] = readVarint(buffer, offset);
289
+ result.usecs = val;
290
+ offset = next;
291
+ } else {
292
+ offset = skipField(buffer, offset, wireType);
293
+ }
294
+ break;
295
+ default:
296
+ offset = skipField(buffer, offset, wireType);
297
+ }
298
+ } catch {
299
+ break;
300
+ }
301
+ }
302
+
303
+ return result;
304
+ }
305
+
306
+ /**
307
+ * Rithmic Market Data Feed
308
+ * Provides real-time market data via Rithmic WebSocket connection
309
+ */
310
+ class RithmicMarketDataFeed extends EventEmitter {
311
+ constructor(rithmicService) {
312
+ super();
313
+ this.service = rithmicService;
314
+ this.subscriptions = new Set();
315
+ this.connected = false;
316
+ this.messageHandler = null;
317
+
318
+ // Stats for debugging
319
+ this.tradeCount = 0;
320
+ this.quoteCount = 0;
321
+ this.lastLogTime = 0;
322
+ }
323
+
324
+ /**
325
+ * Check if connected
326
+ * @returns {boolean}
327
+ */
328
+ isConnected() {
329
+ return this.connected && this.service?.tickerConn?.isConnected;
330
+ }
331
+
332
+ /**
333
+ * Connect to market data (uses existing tickerConn from RithmicService)
334
+ * @returns {Promise<boolean>}
335
+ */
336
+ async connect() {
337
+ if (!this.service || !this.service.tickerConn) {
338
+ throw new Error('RithmicService or tickerConn not available');
339
+ }
340
+
341
+ // Check if ticker connection is ready
342
+ if (!this.service.tickerConn.isConnected) {
343
+ throw new Error('Ticker connection not established');
344
+ }
345
+
346
+ // Setup message handler
347
+ this.messageHandler = ({ templateId, data }) => {
348
+ if (templateId === TEMPLATE_IDS.LAST_TRADE) {
349
+ this._handleTrade(data);
350
+ } else if (templateId === TEMPLATE_IDS.BEST_BID_OFFER) {
351
+ this._handleQuote(data);
352
+ }
353
+ };
354
+
355
+ this.service.tickerConn.on('message', this.messageHandler);
356
+ this.connected = true;
357
+
358
+ log.info('Market data feed connected');
359
+ this.emit('connected');
360
+
361
+ return true;
362
+ }
363
+
364
+ /**
365
+ * Disconnect from market data
366
+ */
367
+ disconnect() {
368
+ if (this.messageHandler && this.service?.tickerConn) {
369
+ this.service.tickerConn.removeListener('message', this.messageHandler);
370
+ }
371
+
372
+ // Unsubscribe all
373
+ for (const key of this.subscriptions) {
374
+ const [exchange, symbol] = key.split(':');
375
+ this._sendUnsubscribe(exchange, symbol);
376
+ }
377
+
378
+ this.subscriptions.clear();
379
+ this.connected = false;
380
+ this.messageHandler = null;
381
+
382
+ log.info('Market data feed disconnected');
383
+ this.emit('disconnected');
384
+ }
385
+
386
+ /**
387
+ * Subscribe to market data for a symbol
388
+ * @param {string} symbol - Trading symbol (e.g., ESH6, NQH6)
389
+ * @param {string} [exchange='CME'] - Exchange
390
+ */
391
+ subscribe(symbol, exchange = 'CME') {
392
+ const key = `${exchange}:${symbol}`;
393
+ if (this.subscriptions.has(key)) return;
394
+
395
+ if (!this.service?.tickerConn?.isConnected) {
396
+ log.warn('Cannot subscribe - ticker connection not ready');
397
+ return;
398
+ }
399
+
400
+ // Extract base symbol for subscription (ES from ESH6)
401
+ // Rithmic uses continuous symbols for subscriptions
402
+ const baseSymbol = symbol.replace(/[A-Z]\d+$/, '');
403
+
404
+ this.service.tickerConn.send('RequestMarketDataUpdate', {
405
+ templateId: TEMPLATE_IDS.MARKET_DATA_UPDATE,
406
+ userMsg: ['HQX-CLI'],
407
+ symbol: baseSymbol,
408
+ exchange,
409
+ request: 1, // SUBSCRIBE
410
+ updateBits: 3, // LAST_TRADE | BBO
411
+ });
412
+
413
+ this.subscriptions.add(key);
414
+ log.info(`Subscribed to ${key} (base: ${baseSymbol})`);
415
+ }
416
+
417
+ /**
418
+ * Unsubscribe from market data for a symbol
419
+ * @param {string} symbol
420
+ * @param {string} [exchange='CME']
421
+ */
422
+ unsubscribe(symbol, exchange = 'CME') {
423
+ const key = `${exchange}:${symbol}`;
424
+ if (!this.subscriptions.has(key)) return;
425
+
426
+ this._sendUnsubscribe(exchange, symbol);
427
+ this.subscriptions.delete(key);
428
+ log.info(`Unsubscribed from ${key}`);
429
+ }
430
+
431
+ /**
432
+ * Send unsubscribe request
433
+ * @private
434
+ */
435
+ _sendUnsubscribe(exchange, symbol) {
436
+ if (!this.service?.tickerConn?.isConnected) return;
437
+
438
+ const baseSymbol = symbol.replace(/[A-Z]\d+$/, '');
439
+
440
+ this.service.tickerConn.send('RequestMarketDataUpdate', {
441
+ templateId: TEMPLATE_IDS.MARKET_DATA_UPDATE,
442
+ userMsg: ['HQX-CLI'],
443
+ symbol: baseSymbol,
444
+ exchange,
445
+ request: 2, // UNSUBSCRIBE
446
+ updateBits: 3,
447
+ });
448
+ }
449
+
450
+ /**
451
+ * Handle incoming trade data
452
+ * @private
453
+ */
454
+ _handleTrade(data) {
455
+ const t = decodeLastTrade(data);
456
+
457
+ if (!t.symbol || t.tradePrice === undefined || t.tradeSize === undefined) {
458
+ return;
459
+ }
460
+
461
+ this.tradeCount++;
462
+
463
+ // Log periodically
464
+ const now = Date.now();
465
+ if (now - this.lastLogTime > 30000) {
466
+ this.lastLogTime = now;
467
+ log.debug(`Stats: ${this.tradeCount} trades, ${this.quoteCount} quotes`);
468
+ }
469
+
470
+ // First trade and every 500th
471
+ if (this.tradeCount === 1 || this.tradeCount % 500 === 0) {
472
+ log.info(`Trade #${this.tradeCount}: ${t.symbol} ${t.tradeSize}@${t.tradePrice}`);
473
+ }
474
+
475
+ const trade = {
476
+ symbol: t.symbol,
477
+ exchange: t.exchange || 'CME',
478
+ price: t.tradePrice,
479
+ lastPrice: t.tradePrice,
480
+ size: t.tradeSize,
481
+ volume: t.tradeSize,
482
+ side: t.aggressor === 1 ? 'BUY' : 'SELL',
483
+ lastTradeSide: t.aggressor === 1 ? 'BUY' : 'SELL',
484
+ timestamp: (t.ssboe || 0) * 1000 + Math.floor((t.usecs || 0) / 1000),
485
+ };
486
+
487
+ this.emit('tick', trade);
488
+ this.emit('trade', trade);
489
+ }
490
+
491
+ /**
492
+ * Handle incoming quote data
493
+ * @private
494
+ */
495
+ _handleQuote(data) {
496
+ const q = decodeBestBidOffer(data);
497
+
498
+ if (!q.symbol) return;
499
+
500
+ this.quoteCount++;
501
+
502
+ const quote = {
503
+ symbol: q.symbol,
504
+ exchange: q.exchange || 'CME',
505
+ bid: q.bidPrice || 0,
506
+ bidPrice: q.bidPrice || 0,
507
+ bidSize: q.bidSize || 0,
508
+ ask: q.askPrice || 0,
509
+ askPrice: q.askPrice || 0,
510
+ askSize: q.askSize || 0,
511
+ timestamp: (q.ssboe || 0) * 1000 + Math.floor((q.usecs || 0) / 1000),
512
+ };
513
+
514
+ // Emit as tick with mid price
515
+ if (quote.bid && quote.ask) {
516
+ const tick = {
517
+ ...quote,
518
+ price: (quote.bid + quote.ask) / 2,
519
+ lastPrice: (quote.bid + quote.ask) / 2,
520
+ };
521
+ this.emit('tick', tick);
522
+ }
523
+
524
+ this.emit('quote', quote);
525
+ }
526
+ }
527
+
528
+ module.exports = { RithmicMarketDataFeed };