hedgequantx 2.9.239 → 2.9.241

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.
@@ -24,28 +24,30 @@
24
24
  'use strict';
25
25
 
26
26
  // =============================================================================
27
- // PRE-ALLOCATED REGIME PARAMETERS (avoid object creation)
27
+ // REGIME PARAMETERS - EXACT MATCH TO PYTHON BACKTEST
28
+ // Backtest Result: $2,012,373.75 | 146,685 trades | 71.1% WR
29
+ // Z-Score Entry: >2.5 | Exit: <0.5 | Stop: 8 ticks | Target: 16 ticks
28
30
  // =============================================================================
29
31
 
30
32
  const REGIME_LOW = Object.freeze({
31
- stopMultiplier: 0.8,
32
- targetMultiplier: 0.9,
33
- zscoreThreshold: 1.2,
34
- confidenceBonus: 0.05
33
+ stopMultiplier: 1.0,
34
+ targetMultiplier: 1.0,
35
+ zscoreThreshold: 2.5, // BACKTEST EXACT: 2.5
36
+ confidenceBonus: 0.0
35
37
  });
36
38
 
37
39
  const REGIME_NORMAL = Object.freeze({
38
40
  stopMultiplier: 1.0,
39
41
  targetMultiplier: 1.0,
40
- zscoreThreshold: 1.5,
42
+ zscoreThreshold: 2.5, // BACKTEST EXACT: 2.5
41
43
  confidenceBonus: 0.0
42
44
  });
43
45
 
44
46
  const REGIME_HIGH = Object.freeze({
45
- stopMultiplier: 1.3,
46
- targetMultiplier: 1.2,
47
- zscoreThreshold: 2.0,
48
- confidenceBonus: -0.05
47
+ stopMultiplier: 1.0,
48
+ targetMultiplier: 1.0,
49
+ zscoreThreshold: 2.5, // BACKTEST EXACT: 2.5
50
+ confidenceBonus: 0.0
49
51
  });
50
52
 
51
53
  // Pre-allocated result object for Kalman filter (reused)
@@ -65,61 +67,49 @@ const _regimeResult = {
65
67
  // =============================================================================
66
68
 
67
69
  /**
68
- * Compute Z-Score with zero intermediate array allocations
69
- * Uses in-place calculation with index arithmetic
70
+ * Compute Z-Score - EXACT MATCH TO PYTHON BACKTEST
71
+ *
72
+ * PYTHON CODE (from ultra_scalper_v2_protected.py):
73
+ * ```python
74
+ * for i in range(lookback, n):
75
+ * window = prices[i-lookback:i] # EXCLUDES current price!
76
+ * mean = np.mean(window)
77
+ * std = np.std(window)
78
+ * if std > 0:
79
+ * zscore[i] = (prices[i] - mean) / std
80
+ * ```
70
81
  *
71
- * @param {number[]} prices - Price buffer (circular or linear)
72
- * @param {number} length - Actual number of valid prices
73
- * @param {number} window - Lookback window (default 50)
82
+ * CRITICAL: Mean/std are calculated from PREVIOUS 100 prices, NOT including current!
83
+ *
84
+ * @param {number[]} prices - Price buffer
85
+ * @param {number} lookback - Lookback window (default 100 to match Python)
74
86
  * @returns {number} Z-Score value
75
87
  */
76
- function computeZScore(prices, window = 50) {
88
+ function computeZScore(prices, lookback = 100) {
77
89
  const length = prices.length;
78
- if (length === 0) return 0;
90
+
91
+ // Need at least lookback+1 prices (lookback for window + 1 for current)
92
+ if (length <= lookback) return 0;
79
93
 
80
94
  const currentPrice = prices[length - 1];
81
95
 
82
- // Determine effective window
83
- const n = length < window ? length : window;
84
- const startIdx = length - n;
96
+ // CRITICAL: Window is [i-lookback : i], which EXCLUDES current price
97
+ // This matches Python: window = prices[i-lookback:i]
98
+ const windowEnd = length - 1; // Exclude current price
99
+ const windowStart = windowEnd - lookback;
85
100
 
86
- // Single-pass mean calculation (no slice, no reduce)
101
+ // Single-pass mean and std calculation over lookback window
87
102
  let sum = 0;
88
103
  let sumSq = 0;
89
- for (let i = startIdx; i < length; i++) {
104
+ for (let i = windowStart; i < windowEnd; i++) {
90
105
  const p = prices[i];
91
106
  sum += p;
92
- sumSq += p * p; // Faster than Math.pow(p, 2)
107
+ sumSq += p * p;
93
108
  }
94
109
 
95
- const mean = sum / n;
96
- const variance = (sumSq / n) - (mean * mean);
97
-
98
- // Blend cumulative and rolling std if enough data (like Python backtest)
99
- let std;
100
- if (length >= 100) {
101
- const cumulativeStd = Math.sqrt(Math.max(0, variance));
102
-
103
- // Calculate rolling std over last 100 prices (in-place)
104
- const rollingStart = length - 100;
105
- let rollingSum = 0;
106
- for (let i = rollingStart; i < length; i++) {
107
- rollingSum += prices[i];
108
- }
109
- const rollingMean = rollingSum / 100;
110
-
111
- let rollingVarSum = 0;
112
- for (let i = rollingStart; i < length; i++) {
113
- const diff = prices[i] - rollingMean;
114
- rollingVarSum += diff * diff;
115
- }
116
- const rollingStd = Math.sqrt(rollingVarSum / 100);
117
-
118
- // Blend: 30% cumulative, 70% rolling (matches Python)
119
- std = cumulativeStd * 0.3 + rollingStd * 0.7;
120
- } else {
121
- std = Math.sqrt(Math.max(0, variance));
122
- }
110
+ const mean = sum / lookback;
111
+ const variance = (sumSq / lookback) - (mean * mean);
112
+ const std = Math.sqrt(Math.max(0, variance));
123
113
 
124
114
  if (std < 0.0001) return 0;
125
115
  return (currentPrice - mean) / std;
@@ -79,21 +79,33 @@ class HQXUltraScalpingStrategy extends EventEmitter {
79
79
  this.tickSize = 0.25;
80
80
  this.tickValue = 5.0;
81
81
 
82
- // === Model Parameters (BACKTEST VALIDATED - $2,012,373.75) ===
83
- this.zscoreEntryThreshold = 2.5; // BACKTEST: Z-Score Entry >2.5
84
- this.zscoreExitThreshold = 0.5;
82
+ // ==========================================================================
83
+ // EXACT MATCH TO PYTHON BACKTEST CONFIG
84
+ // Result: $2,012,373.75 | 146,685 trades | 71.1% WR
85
+ // ==========================================================================
86
+
87
+ // Z-Score Parameters (EXACT FROM PYTHON)
88
+ this.zscoreLookback = 100; // PYTHON: zscore_lookback: 100
89
+ this.zscoreEntryThreshold = 2.5; // PYTHON: zscore_entry: 2.5
90
+ this.zscoreExitThreshold = 0.5; // PYTHON: zscore_exit: 0.5
91
+
92
+ // Trade Parameters (EXACT FROM PYTHON)
93
+ this.baseStopTicks = 8; // PYTHON: stop_ticks: 8
94
+ this.baseTargetTicks = 16; // PYTHON: target_ticks: 16
95
+ this.breakevenTicks = 4; // PYTHON: be_ticks: 4
96
+ this.trailActivationTicks = 6; // PYTHON: trail_activation: 6
97
+ this.profitLockPct = 0.5; // PYTHON: trail_pct: 0.5
98
+
99
+ // Risk Management (EXACT FROM PYTHON)
100
+ this.cooldownTicks = 100; // PYTHON: cooldown: 100
101
+ this.maxConsecutiveLosses = 10; // PYTHON: max_consecutive_losses: 10
102
+
103
+ // NOT USED AS FILTERS (Python doesn't use these)
85
104
  this.vpinWindow = 50;
86
105
  this.vpinToxicThreshold = 0.7;
106
+ this.ofiLookback = 20;
87
107
  this.kalmanProcessNoise = 0.01;
88
108
  this.kalmanMeasurementNoise = 0.1;
89
- this.volatilityLookback = 100;
90
- this.ofiLookback = 20;
91
-
92
- // === Trade Parameters (BACKTEST VALIDATED) ===
93
- this.baseStopTicks = 8; // $40
94
- this.baseTargetTicks = 16; // $80
95
- this.breakevenTicks = 4; // Move to BE at +4 ticks
96
- this.profitLockPct = 0.5; // Lock 50% of profit
97
109
 
98
110
  // === State Storage ===
99
111
  this.barHistory = new Map();
@@ -102,22 +114,15 @@ class HQXUltraScalpingStrategy extends EventEmitter {
102
114
  this.volumeBuffer = new Map();
103
115
  this.tradesBuffer = new Map();
104
116
  this.atrHistory = new Map();
105
-
106
- // === Tick aggregation ===
107
- this.tickBuffer = new Map();
108
- this.lastBarTime = new Map();
109
- this.barIntervalMs = 5000; // 5-second bars
117
+ this.tickBuffer = new Map(); // For tick aggregation
118
+ this.lastBarTime = new Map(); // Last bar timestamp per contract
110
119
 
111
120
  // === Performance Tracking ===
121
+ this.tickCount = 0; // Total ticks processed
122
+ this.lastSignalTick = 0; // Tick count at last signal (for cooldown)
112
123
  this.recentTrades = [];
113
124
  this.winStreak = 0;
114
125
  this.lossStreak = 0;
115
-
116
- // === CRITICAL: Cooldown & Risk Management ===
117
- this.lastSignalTime = 0;
118
- this.signalCooldownMs = 30000; // 30 seconds minimum between signals
119
- this.maxConsecutiveLosses = 3; // Stop trading after 3 consecutive losses
120
- this.minConfidenceThreshold = 0.65; // Minimum 65% confidence (was 55%)
121
126
  this.tradingEnabled = true;
122
127
  }
123
128
 
@@ -171,8 +176,8 @@ class HQXUltraScalpingStrategy extends EventEmitter {
171
176
  this.initialize(contractId);
172
177
  }
173
178
 
174
- // Track total ticks and last price
175
- this._totalTicks = (this._totalTicks || 0) + 1;
179
+ // Track total ticks (CRITICAL for tick-based cooldown like Python)
180
+ this.tickCount++;
176
181
  this._lastPrice = price;
177
182
  this._currentContractId = contractId;
178
183
 
@@ -231,23 +236,21 @@ class HQXUltraScalpingStrategy extends EventEmitter {
231
236
  const vpinPct = (vpin * 100).toFixed(0);
232
237
  const zRounded = Math.round(zscore * 10) / 10; // Round to 0.1
233
238
 
234
- // Check cooldown
235
- const now = Date.now();
236
- const timeSinceLastSignal = now - this.lastSignalTime;
237
- const cooldownRemaining = Math.max(0, this.signalCooldownMs - timeSinceLastSignal);
239
+ // Check tick-based cooldown (matches Python)
240
+ const ticksSinceLastSignal = this.tickCount - this.lastSignalTick;
241
+ const cooldownRemaining = Math.max(0, this.cooldownTicks - ticksSinceLastSignal);
238
242
 
239
243
  // Trading disabled?
240
244
  if (!this.tradingEnabled) {
241
245
  state = 'paused';
242
246
  message = `[${sym}] ${priceStr} | PAUSED - ${this.lossStreak} losses | Cooldown active`;
243
247
  }
244
- // In cooldown?
245
- else if (cooldownRemaining > 0 && this.lastSignalTime > 0) {
246
- const secs = Math.ceil(cooldownRemaining / 1000);
247
- state = `cooldown-${secs}`;
248
- message = `[${sym}] ${priceStr} | Cooldown ${secs}s | Z:${zRounded}σ OFI:${ofiPct}%`;
248
+ // In tick-based cooldown?
249
+ else if (cooldownRemaining > 0 && this.lastSignalTick > 0) {
250
+ state = `cooldown-${cooldownRemaining}`;
251
+ message = `[${sym}] ${priceStr} | Cooldown ${cooldownRemaining} ticks | Z:${zRounded}σ OFI:${ofiPct}%`;
249
252
  }
250
- // VPIN toxic?
253
+ // VPIN toxic? (info only, not a hard filter)
251
254
  else if (vpin > this.vpinToxicThreshold) {
252
255
  state = 'vpin-toxic';
253
256
  message = `[${sym}] ${priceStr} | VPIN toxic ${vpinPct}% > 70% | No entry - informed traders active`;
@@ -394,51 +397,65 @@ class HQXUltraScalpingStrategy extends EventEmitter {
394
397
  return null;
395
398
  }
396
399
 
397
- // CRITICAL: Check cooldown
398
- const now = Date.now();
399
- const timeSinceLastSignal = now - this.lastSignalTime;
400
- if (timeSinceLastSignal < this.signalCooldownMs) {
400
+ // CRITICAL: Tick-based cooldown (MATCHES PYTHON: cooldown = 100 ticks)
401
+ // Python: if i - last_trade_idx < cooldown_ticks: continue
402
+ const ticksSinceLastSignal = this.tickCount - this.lastSignalTick;
403
+ if (ticksSinceLastSignal < this.cooldownTicks) {
401
404
  // Silent - don't spam logs
402
405
  return null;
403
406
  }
404
407
 
405
- // CRITICAL: Check consecutive losses
408
+ // Extra cooldown after losses (MATCHES PYTHON)
409
+ // Python: extra_cooldown = consecutive_losses * 50
410
+ if (this.lossStreak > 0) {
411
+ const extraCooldown = this.lossStreak * 50;
412
+ if (ticksSinceLastSignal < this.cooldownTicks + extraCooldown) {
413
+ return null;
414
+ }
415
+ }
416
+
417
+ // CRITICAL: Check consecutive losses (kill switch)
406
418
  if (this.lossStreak >= this.maxConsecutiveLosses) {
407
419
  this.tradingEnabled = false;
408
420
  this.emit('log', { type: 'info', message: `Trading paused: ${this.lossStreak} consecutive losses. Waiting for cooldown...` });
409
- // Auto re-enable after 2 minutes
421
+ // Auto re-enable after 1 hour (matches Python: 3600_000_000 us = 1 hour)
410
422
  setTimeout(() => {
411
423
  this.tradingEnabled = true;
412
424
  this.lossStreak = 0;
413
425
  this.emit('log', { type: 'info', message: 'Trading re-enabled after cooldown' });
414
- }, 120000);
426
+ }, 3600000);
415
427
  return null;
416
428
  }
417
429
 
430
+ // =========================================================================
431
+ // ENTRY LOGIC - MATCHES PYTHON BACKTEST EXACTLY
432
+ // Python backtest: Z-Score >2.5 = entry, no OFI/VPIN/Kalman filters
433
+ // Result: 146,685 trades, 71.1% WR, $2,012,373.75
434
+ // =========================================================================
435
+
418
436
  const absZscore = Math.abs(zscore);
437
+
438
+ // Z-Score threshold check (ONLY filter from Python backtest)
419
439
  if (absZscore < volParams.zscoreThreshold) return null;
420
- if (vpin > this.vpinToxicThreshold) return null;
421
440
 
441
+ // Determine direction based on Z-Score
422
442
  let direction;
423
- if (zscore < -volParams.zscoreThreshold) direction = 'long';
424
- else if (zscore > volParams.zscoreThreshold) direction = 'short';
443
+ if (zscore < -volParams.zscoreThreshold) direction = 'long'; // Price below mean = buy
444
+ else if (zscore > volParams.zscoreThreshold) direction = 'short'; // Price above mean = sell
425
445
  else return null;
426
-
427
- // CRITICAL: OFI must confirm direction (stronger filter)
428
- const ofiConfirms = (direction === 'long' && ofi > 0.15) || (direction === 'short' && ofi < -0.15);
429
- if (!ofiConfirms) {
430
- this.emit('log', { type: 'debug', message: `Signal rejected: OFI (${(ofi * 100).toFixed(1)}%) doesn't confirm ${direction}` });
431
- return null;
432
- }
433
446
 
447
+ // OFI/VPIN/Kalman used for confidence scoring only (NOT as hard filters)
448
+ const ofiConfirms = (direction === 'long' && ofi > 0.1) || (direction === 'short' && ofi < -0.1);
434
449
  const kalmanDiff = currentPrice - kalmanEstimate;
435
450
  const kalmanConfirms = (direction === 'long' && kalmanDiff < 0) || (direction === 'short' && kalmanDiff > 0);
451
+ const vpinOk = vpin < this.vpinToxicThreshold;
436
452
 
453
+ // Composite confidence score (informational, not blocking)
437
454
  const scores = {
438
455
  zscore: Math.min(1.0, absZscore / 4.0),
439
- vpin: 1.0 - vpin,
456
+ vpin: vpinOk ? (1.0 - vpin) : 0.3,
440
457
  kyleLambda: kyleLambda > 0.001 ? 0.5 : 0.8,
441
- kalman: kalmanConfirms ? 0.8 : 0.4,
458
+ kalman: kalmanConfirms ? 0.8 : 0.5,
442
459
  volatility: regime === 'normal' ? 0.8 : regime === 'low' ? 0.7 : 0.6,
443
460
  ofi: ofiConfirms ? 0.9 : 0.5,
444
461
  composite: 0
@@ -449,14 +466,8 @@ class HQXUltraScalpingStrategy extends EventEmitter {
449
466
 
450
467
  const confidence = Math.min(1.0, scores.composite + volParams.confidenceBonus);
451
468
 
452
- // CRITICAL: Higher confidence threshold (65% minimum)
453
- if (confidence < this.minConfidenceThreshold) {
454
- this.emit('log', { type: 'debug', message: `Signal rejected: confidence ${(confidence * 100).toFixed(1)}% < ${this.minConfidenceThreshold * 100}%` });
455
- return null;
456
- }
457
-
458
- // Update last signal time
459
- this.lastSignalTime = now;
469
+ // Update last signal tick (tick-based cooldown like Python)
470
+ this.lastSignalTick = this.tickCount;
460
471
 
461
472
  const stopTicks = Math.round(this.baseStopTicks * volParams.stopMultiplier);
462
473
  const targetTicks = Math.round(this.baseTargetTicks * volParams.targetMultiplier);
@@ -623,7 +634,8 @@ class HQXUltraScalpingStrategy extends EventEmitter {
623
634
  tradingEnabled: this.tradingEnabled,
624
635
  lossStreak: this.lossStreak,
625
636
  winStreak: this.winStreak,
626
- cooldownRemaining: Math.max(0, this.signalCooldownMs - (Date.now() - this.lastSignalTime)),
637
+ cooldownRemaining: Math.max(0, this.cooldownTicks - (this.tickCount - this.lastSignalTick)),
638
+ tickCount: this.tickCount,
627
639
  };
628
640
  }
629
641
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hedgequantx",
3
- "version": "2.9.239",
3
+ "version": "2.9.241",
4
4
  "description": "HedgeQuantX - Prop Futures Trading CLI",
5
5
  "main": "src/app.js",
6
6
  "bin": {
@@ -43,6 +43,21 @@ class DaemonClient extends EventEmitter {
43
43
 
44
44
  /** @type {Object|null} Cached daemon info */
45
45
  this.daemonInfo = null;
46
+
47
+ /** @type {boolean} Auto-reconnect enabled */
48
+ this.autoReconnect = true;
49
+
50
+ /** @type {number} Reconnect attempts */
51
+ this.reconnectAttempts = 0;
52
+
53
+ /** @type {number} Max reconnect attempts */
54
+ this.maxReconnectAttempts = 10;
55
+
56
+ /** @type {NodeJS.Timeout|null} Reconnect timer */
57
+ this.reconnectTimer = null;
58
+
59
+ /** @type {boolean} Intentionally disconnecting */
60
+ this._disconnecting = false;
46
61
  }
47
62
 
48
63
  /**
@@ -64,6 +79,9 @@ class DaemonClient extends EventEmitter {
64
79
  this.daemonInfo = await this._request(MSG_TYPE.HANDSHAKE, null, TIMEOUTS.HANDSHAKE);
65
80
  log.debug('Handshake complete', this.daemonInfo);
66
81
 
82
+ // Reset reconnect attempts on successful connection
83
+ this.reconnectAttempts = 0;
84
+
67
85
  // Start ping interval
68
86
  this._startPing();
69
87
 
@@ -84,8 +102,14 @@ class DaemonClient extends EventEmitter {
84
102
 
85
103
  this.socket.on('close', () => {
86
104
  log.debug('Disconnected from daemon');
105
+ const wasConnected = this.connected;
87
106
  this._cleanup();
88
107
  this.emit('disconnected');
108
+
109
+ // Auto-reconnect if enabled and not intentionally disconnecting
110
+ if (wasConnected && this.autoReconnect && !this._disconnecting) {
111
+ this._scheduleReconnect();
112
+ }
89
113
  });
90
114
 
91
115
  this.socket.on('error', (err) => {
@@ -113,16 +137,28 @@ class DaemonClient extends EventEmitter {
113
137
 
114
138
  /**
115
139
  * Disconnect from daemon
140
+ * @param {boolean} [permanent=false] - If true, disable auto-reconnect
116
141
  */
117
- disconnect() {
142
+ disconnect(permanent = false) {
143
+ this._disconnecting = true;
144
+ if (permanent) {
145
+ this.autoReconnect = false;
146
+ }
147
+
148
+ if (this.reconnectTimer) {
149
+ clearTimeout(this.reconnectTimer);
150
+ this.reconnectTimer = null;
151
+ }
152
+
118
153
  if (this.socket) {
119
154
  this.socket.destroy();
120
155
  }
121
156
  this._cleanup();
157
+ this._disconnecting = false;
122
158
  }
123
159
 
124
160
  /**
125
- * Cleanup state
161
+ * Cleanup state (does NOT clear reconnect state)
126
162
  */
127
163
  _cleanup() {
128
164
  this.connected = false;
@@ -137,6 +173,18 @@ class DaemonClient extends EventEmitter {
137
173
  this.socket = null;
138
174
  }
139
175
 
176
+ /**
177
+ * Enable/disable auto-reconnect
178
+ * @param {boolean} enable
179
+ */
180
+ setAutoReconnect(enable) {
181
+ this.autoReconnect = enable;
182
+ if (!enable && this.reconnectTimer) {
183
+ clearTimeout(this.reconnectTimer);
184
+ this.reconnectTimer = null;
185
+ }
186
+ }
187
+
140
188
  /**
141
189
  * Start ping interval
142
190
  */
@@ -145,12 +193,49 @@ class DaemonClient extends EventEmitter {
145
193
  try {
146
194
  await this._request(MSG_TYPE.PING, null, TIMEOUTS.PING_TIMEOUT);
147
195
  } catch (err) {
148
- log.warn('Ping failed, disconnecting');
149
- this.disconnect();
196
+ log.warn('Ping failed, will auto-reconnect');
197
+ // Don't call disconnect() - let socket close trigger reconnect
198
+ if (this.socket) {
199
+ this.socket.destroy();
200
+ }
150
201
  }
151
202
  }, TIMEOUTS.PING_INTERVAL);
152
203
  }
153
204
 
205
+ /**
206
+ * Schedule auto-reconnect with exponential backoff
207
+ */
208
+ _scheduleReconnect() {
209
+ if (this.reconnectTimer) {
210
+ clearTimeout(this.reconnectTimer);
211
+ }
212
+
213
+ if (this.reconnectAttempts >= this.maxReconnectAttempts) {
214
+ log.error('Max reconnect attempts reached, giving up');
215
+ this.emit('reconnectFailed');
216
+ return;
217
+ }
218
+
219
+ // Exponential backoff: 1s, 2s, 4s, 8s... max 30s
220
+ const delay = Math.min(1000 * Math.pow(2, this.reconnectAttempts), 30000);
221
+ this.reconnectAttempts++;
222
+
223
+ log.debug(`Scheduling reconnect in ${delay}ms (attempt ${this.reconnectAttempts})`);
224
+
225
+ this.reconnectTimer = setTimeout(async () => {
226
+ this.reconnectTimer = null;
227
+ log.debug('Attempting reconnect...');
228
+
229
+ const success = await this.connect();
230
+ if (success) {
231
+ log.debug('Reconnected successfully');
232
+ this.reconnectAttempts = 0;
233
+ this.emit('reconnected');
234
+ }
235
+ // If failed, the connect() will trigger another close -> scheduleReconnect
236
+ }, delay);
237
+ }
238
+
154
239
  /**
155
240
  * Handle incoming message
156
241
  * @param {Object} msg
@@ -145,11 +145,18 @@ const placeOrder = async (service, orderData) => {
145
145
  orderTag,
146
146
  });
147
147
  } else if (status === 5 || status === 6) {
148
+ // Extract rejection reason from rpCode[1] if available
149
+ const rpCode = order.rpCode;
150
+ let errorMsg = `Order rejected: status ${status}`;
151
+ if (rpCode && Array.isArray(rpCode) && rpCode.length > 1 && rpCode[1]) {
152
+ errorMsg = `Order rejected: ${rpCode[1]}`;
153
+ }
148
154
  resolve({
149
155
  success: false,
150
- error: `Order rejected: status ${status}`,
156
+ error: errorMsg,
151
157
  orderId: order.basketId,
152
158
  orderTag,
159
+ rpCode,
153
160
  });
154
161
  }
155
162
  }
@@ -169,6 +176,11 @@ const placeOrder = async (service, orderData) => {
169
176
  routes.values().next().value?.tradeRoute || null;
170
177
  }
171
178
 
179
+ // Warn if no trade route - Rithmic will reject the order
180
+ if (!tradeRoute) {
181
+ DEBUG && console.log('[Orders] WARNING: No trade route for', exchange, '- order may be rejected');
182
+ }
183
+
172
184
  // HFT: Reuse template and mutate (faster than object spread)
173
185
  ORDER_REQUEST_TEMPLATE.userMsg[0] = orderTag;
174
186
  ORDER_REQUEST_TEMPLATE.fcmId = service.loginInfo.fcmId;