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.
package/dist/lib/m/s1-models.js
CHANGED
|
@@ -24,28 +24,30 @@
|
|
|
24
24
|
'use strict';
|
|
25
25
|
|
|
26
26
|
// =============================================================================
|
|
27
|
-
//
|
|
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
|
|
32
|
-
targetMultiplier: 0
|
|
33
|
-
zscoreThreshold:
|
|
34
|
-
confidenceBonus: 0.
|
|
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:
|
|
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.
|
|
46
|
-
targetMultiplier: 1.
|
|
47
|
-
zscoreThreshold: 2.
|
|
48
|
-
confidenceBonus:
|
|
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
|
|
69
|
-
*
|
|
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
|
-
*
|
|
72
|
-
*
|
|
73
|
-
* @param {number}
|
|
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,
|
|
88
|
+
function computeZScore(prices, lookback = 100) {
|
|
77
89
|
const length = prices.length;
|
|
78
|
-
|
|
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
|
-
//
|
|
83
|
-
|
|
84
|
-
const
|
|
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
|
|
101
|
+
// Single-pass mean and std calculation over lookback window
|
|
87
102
|
let sum = 0;
|
|
88
103
|
let sumSq = 0;
|
|
89
|
-
for (let i =
|
|
104
|
+
for (let i = windowStart; i < windowEnd; i++) {
|
|
90
105
|
const p = prices[i];
|
|
91
106
|
sum += p;
|
|
92
|
-
sumSq += p * p;
|
|
107
|
+
sumSq += p * p;
|
|
93
108
|
}
|
|
94
109
|
|
|
95
|
-
const mean = sum /
|
|
96
|
-
const variance = (sumSq /
|
|
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
|
-
//
|
|
83
|
-
|
|
84
|
-
|
|
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
|
-
//
|
|
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
|
|
175
|
-
this.
|
|
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
|
|
236
|
-
const
|
|
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.
|
|
246
|
-
|
|
247
|
-
|
|
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:
|
|
398
|
-
|
|
399
|
-
const
|
|
400
|
-
if (
|
|
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
|
-
//
|
|
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
|
|
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
|
-
},
|
|
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.
|
|
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
|
-
//
|
|
453
|
-
|
|
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.
|
|
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
|
@@ -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,
|
|
149
|
-
|
|
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:
|
|
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;
|