hedgequantx 2.6.24 → 2.6.26
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
|
@@ -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
|
|
@@ -347,7 +348,11 @@ const launchAlgo = async (service, account, contract, config) => {
|
|
|
347
348
|
}
|
|
348
349
|
|
|
349
350
|
// Initialize Market Data Feed
|
|
350
|
-
|
|
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 });
|
|
351
356
|
|
|
352
357
|
// Smart startup logs (same as HQX-TG)
|
|
353
358
|
const market = checkMarketHours();
|
|
@@ -605,29 +610,49 @@ const launchAlgo = async (service, account, contract, config) => {
|
|
|
605
610
|
|
|
606
611
|
// Connect to market data
|
|
607
612
|
try {
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
// TopStep invalidates WebSocket sessions for old tokens
|
|
612
|
-
algoLogger.info(ui, 'REFRESHING AUTH TOKEN...');
|
|
613
|
-
const token = await service.getFreshToken?.() || service.token || service.getToken?.();
|
|
614
|
-
|
|
615
|
-
if (!token) {
|
|
616
|
-
algoLogger.error(ui, 'NO AUTH TOKEN', 'Please reconnect');
|
|
617
|
-
} else {
|
|
618
|
-
algoLogger.info(ui, 'TOKEN OK', `${token.length} chars`);
|
|
619
|
-
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}`);
|
|
620
616
|
|
|
621
|
-
await marketFeed.connect(
|
|
617
|
+
await marketFeed.connect();
|
|
622
618
|
|
|
623
619
|
// Wait for connection to stabilize
|
|
624
|
-
await new Promise(r => setTimeout(r,
|
|
620
|
+
await new Promise(r => setTimeout(r, 1000));
|
|
625
621
|
|
|
626
622
|
if (marketFeed.isConnected()) {
|
|
627
|
-
|
|
628
|
-
|
|
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`);
|
|
629
627
|
} else {
|
|
630
|
-
algoLogger.error(ui, 'CONNECTION LOST', '
|
|
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
|
+
}
|
|
631
656
|
}
|
|
632
657
|
}
|
|
633
658
|
} catch (e) {
|
|
@@ -103,6 +103,32 @@ const checkHoliday = () => {
|
|
|
103
103
|
return { isHoliday: false };
|
|
104
104
|
};
|
|
105
105
|
|
|
106
|
+
/**
|
|
107
|
+
* Get current trading session based on ET time
|
|
108
|
+
* Sessions (in ET):
|
|
109
|
+
* - ASIA: 6:00 PM - 2:00 AM (18:00-02:00)
|
|
110
|
+
* - LONDON: 2:00 AM - 8:00 AM (02:00-08:00)
|
|
111
|
+
* - AMERICAN: 8:00 AM - 5:00 PM (08:00-17:00)
|
|
112
|
+
* - MAINTENANCE: 5:00 PM - 6:00 PM (17:00-18:00)
|
|
113
|
+
*/
|
|
114
|
+
const getTradingSession = () => {
|
|
115
|
+
const now = new Date();
|
|
116
|
+
// Convert to ET (UTC-5 or UTC-4 for DST)
|
|
117
|
+
const etOptions = { timeZone: 'America/New_York', hour: 'numeric', hour12: false };
|
|
118
|
+
const etHour = parseInt(new Intl.DateTimeFormat('en-US', etOptions).format(now));
|
|
119
|
+
|
|
120
|
+
// Determine session based on ET hour
|
|
121
|
+
if (etHour >= 18 || etHour < 2) {
|
|
122
|
+
return 'ASIA';
|
|
123
|
+
} else if (etHour >= 2 && etHour < 8) {
|
|
124
|
+
return 'LONDON';
|
|
125
|
+
} else if (etHour >= 8 && etHour < 17) {
|
|
126
|
+
return 'AMERICAN';
|
|
127
|
+
} else {
|
|
128
|
+
return 'MAINTENANCE';
|
|
129
|
+
}
|
|
130
|
+
};
|
|
131
|
+
|
|
106
132
|
/**
|
|
107
133
|
* Check if futures market is open based on CME hours and holidays
|
|
108
134
|
*/
|
|
@@ -113,29 +139,30 @@ const checkMarketHours = () => {
|
|
|
113
139
|
|
|
114
140
|
const holidayCheck = checkHoliday();
|
|
115
141
|
if (holidayCheck.isHoliday) {
|
|
116
|
-
return { isOpen: false, message: `Market closed - ${holidayCheck.holiday.name}
|
|
142
|
+
return { isOpen: false, message: `Market closed - ${holidayCheck.holiday.name}`, session: null };
|
|
117
143
|
}
|
|
118
144
|
if (holidayCheck.holiday && holidayCheck.holiday.earlyClose && utcHour >= 18) {
|
|
119
|
-
return { isOpen: false, message: `Market closed early - ${holidayCheck.holiday.name}
|
|
145
|
+
return { isOpen: false, message: `Market closed early - ${holidayCheck.holiday.name}`, session: null };
|
|
120
146
|
}
|
|
121
147
|
|
|
122
148
|
if (utcDay === 6) {
|
|
123
|
-
return { isOpen: false, message: 'Market closed - Weekend (Saturday)' };
|
|
149
|
+
return { isOpen: false, message: 'Market closed - Weekend (Saturday)', session: null };
|
|
124
150
|
}
|
|
125
151
|
|
|
126
152
|
if (utcDay === 0 && utcHour < 23) {
|
|
127
|
-
return { isOpen: false, message: 'Market closed - Opens Sunday 6:00 PM ET' };
|
|
153
|
+
return { isOpen: false, message: 'Market closed - Opens Sunday 6:00 PM ET', session: null };
|
|
128
154
|
}
|
|
129
155
|
|
|
130
156
|
if (utcDay === 5 && utcHour >= 22) {
|
|
131
|
-
return { isOpen: false, message: 'Market closed - Weekend' };
|
|
157
|
+
return { isOpen: false, message: 'Market closed - Weekend', session: null };
|
|
132
158
|
}
|
|
133
159
|
|
|
134
160
|
if (utcHour === 22 && utcDay !== 5) {
|
|
135
|
-
return { isOpen: false, message: 'Market closed - Daily maintenance (5:00-6:00 PM ET)' };
|
|
161
|
+
return { isOpen: false, message: 'Market closed - Daily maintenance (5:00-6:00 PM ET)', session: 'MAINTENANCE' };
|
|
136
162
|
}
|
|
137
163
|
|
|
138
|
-
|
|
164
|
+
const session = getTradingSession();
|
|
165
|
+
return { isOpen: true, message: 'Market is open', session };
|
|
139
166
|
};
|
|
140
167
|
|
|
141
168
|
module.exports = {
|
|
@@ -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
|
-
//
|
|
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
|
-
//
|
|
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 };
|