hedgequantx 2.5.44 → 2.6.0

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.
@@ -0,0 +1,927 @@
1
+ /**
2
+ * @fileoverview Position Manager for Fast Scalping
3
+ * @module services/position-manager
4
+ *
5
+ * Manages position lifecycle with:
6
+ * - 10 second minimum hold (prop firm rule - NON-NEGOTIABLE)
7
+ * - Intelligent exit based on momentum (OFI 50%, Kalman 25%, Z-Score 25%)
8
+ * - Trailing stop after profit threshold
9
+ * - 60 second failsafe exit (NON-NEGOTIABLE)
10
+ * - VPIN protection filter
11
+ *
12
+ * USES EXISTING MATH MODELS from HQX Ultra Scalping Strategy:
13
+ * - OFI (Order Flow Imbalance)
14
+ * - Kalman Filter with velocity tracking
15
+ * - Z-Score Mean Reversion
16
+ * - VPIN for toxicity detection
17
+ *
18
+ * Data source: Rithmic ORDER_PLANT (fills), PNL_PLANT (positions), TICKER_PLANT (prices)
19
+ */
20
+
21
+ const EventEmitter = require('events');
22
+ const { performance } = require('perf_hooks');
23
+ const { FAST_SCALPING } = require('../config/settings');
24
+ const { logger } = require('../utils/logger');
25
+
26
+ const log = logger.scope('PositionMgr');
27
+
28
+ // =============================================================================
29
+ // MOMENTUM THRESHOLDS (from analysis)
30
+ // =============================================================================
31
+ const MOMENTUM = {
32
+ STRONG_FAVORABLE: 0.5, // momentum > 0.5 + profit → HOLD
33
+ WEAK_THRESHOLD: 0.2, // momentum < 0.2 + profit → EXIT with profit
34
+ ADVERSE_THRESHOLD: -0.3, // momentum < -0.3 → EXIT immediately
35
+ VPIN_DANGER: 0.7, // VPIN > 0.7 → informed traders = EXIT
36
+ };
37
+
38
+ // Momentum weights
39
+ const WEIGHTS = {
40
+ OFI: 0.50, // Order Flow Imbalance - 50%
41
+ KALMAN: 0.25, // Kalman Velocity - 25%
42
+ ZSCORE: 0.25, // Z-Score progression - 25%
43
+ };
44
+
45
+ /**
46
+ * Position state for tracking
47
+ * @typedef {Object} ManagedPosition
48
+ * @property {string} orderTag - Entry order correlation ID
49
+ * @property {string} accountId - Account ID
50
+ * @property {string} symbol - Trading symbol (e.g., NQH5)
51
+ * @property {string} exchange - Exchange (e.g., CME)
52
+ * @property {number} side - 0=Long, 1=Short
53
+ * @property {number} size - Position size
54
+ * @property {number} entryPrice - Average fill price
55
+ * @property {number} entryTime - Entry timestamp (ms)
56
+ * @property {number} fillTime - When fill was confirmed (ms)
57
+ * @property {number} highWaterMark - Highest price since entry (for trailing)
58
+ * @property {number} lowWaterMark - Lowest price since entry (for trailing)
59
+ * @property {string} status - 'pending' | 'active' | 'holding' | 'exiting' | 'closed'
60
+ * @property {boolean} holdComplete - True after MIN_HOLD_MS elapsed
61
+ * @property {Object|null} exitReason - Why position was exited
62
+ * @property {number} tickSize - Tick size from API
63
+ * @property {number} tickValue - Tick value from API
64
+ * @property {string} contractId - Contract ID for strategy lookups
65
+ */
66
+
67
+ /**
68
+ * Position Manager Service
69
+ * Handles position lifecycle for fast scalping strategy
70
+ */
71
+ class PositionManager extends EventEmitter {
72
+ /**
73
+ * @param {RithmicService} rithmicService - Connected Rithmic service
74
+ * @param {Object} strategy - HQX Ultra Scalping strategy instance (M1)
75
+ */
76
+ constructor(rithmicService, strategy = null) {
77
+ super();
78
+ this.rithmic = rithmicService;
79
+ this.strategy = strategy; // Reference to HQX Ultra Scalping strategy
80
+
81
+ /** @type {Map<string, ManagedPosition>} orderTag -> position */
82
+ this.positions = new Map();
83
+
84
+ /** @type {Map<string, number>} symbol -> latest price */
85
+ this.latestPrices = new Map();
86
+
87
+ /** @type {Map<string, Object>} symbol -> contract info (tickSize, tickValue) */
88
+ this.contractInfo = new Map();
89
+
90
+ /** @type {NodeJS.Timer|null} */
91
+ this._monitorInterval = null;
92
+
93
+ /** @type {boolean} */
94
+ this._isRunning = false;
95
+
96
+ // Bind event handlers
97
+ this._onOrderFilled = this._onOrderFilled.bind(this);
98
+ this._onPriceUpdate = this._onPriceUpdate.bind(this);
99
+ this._onPositionUpdate = this._onPositionUpdate.bind(this);
100
+ }
101
+
102
+ /**
103
+ * Set the strategy reference (for accessing math models)
104
+ * @param {Object} strategy - HQX Ultra Scalping strategy instance
105
+ */
106
+ setStrategy(strategy) {
107
+ this.strategy = strategy;
108
+ log.debug('Strategy reference set');
109
+ }
110
+
111
+ /**
112
+ * Set contract info from API (tick size, tick value)
113
+ * @param {string} symbol - Trading symbol
114
+ * @param {Object} info - { tickSize, tickValue, contractId }
115
+ */
116
+ setContractInfo(symbol, info) {
117
+ this.contractInfo.set(symbol, {
118
+ tickSize: info.tickSize,
119
+ tickValue: info.tickValue,
120
+ contractId: info.contractId,
121
+ });
122
+ log.debug('Contract info set', { symbol, tickSize: info.tickSize, tickValue: info.tickValue });
123
+ }
124
+
125
+ /**
126
+ * Start the position manager
127
+ * Attaches to Rithmic service events
128
+ */
129
+ start() {
130
+ if (this._isRunning) return;
131
+
132
+ log.info('Starting position manager', {
133
+ minHoldMs: FAST_SCALPING.MIN_HOLD_MS,
134
+ maxHoldMs: FAST_SCALPING.MAX_HOLD_MS,
135
+ targetTicks: FAST_SCALPING.TARGET_TICKS,
136
+ stopTicks: FAST_SCALPING.STOP_TICKS,
137
+ momentumWeights: WEIGHTS,
138
+ });
139
+
140
+ // Subscribe to Rithmic events
141
+ this.rithmic.on('orderFilled', this._onOrderFilled);
142
+ this.rithmic.on('priceUpdate', this._onPriceUpdate);
143
+ this.rithmic.on('positionUpdate', this._onPositionUpdate);
144
+
145
+ // Start monitoring loop
146
+ this._monitorInterval = setInterval(() => {
147
+ this._monitorPositions();
148
+ }, FAST_SCALPING.MONITOR_INTERVAL_MS);
149
+
150
+ this._isRunning = true;
151
+ log.debug('Position manager started');
152
+ }
153
+
154
+ /**
155
+ * Stop the position manager
156
+ * Removes event listeners and stops monitoring
157
+ */
158
+ stop() {
159
+ if (!this._isRunning) return;
160
+
161
+ log.info('Stopping position manager');
162
+
163
+ // Remove event listeners
164
+ this.rithmic.off('orderFilled', this._onOrderFilled);
165
+ this.rithmic.off('priceUpdate', this._onPriceUpdate);
166
+ this.rithmic.off('positionUpdate', this._onPositionUpdate);
167
+
168
+ // Stop monitoring
169
+ if (this._monitorInterval) {
170
+ clearInterval(this._monitorInterval);
171
+ this._monitorInterval = null;
172
+ }
173
+
174
+ this._isRunning = false;
175
+ log.debug('Position manager stopped');
176
+ }
177
+
178
+ /**
179
+ * Register a new entry order
180
+ * Called immediately after fastEntry() to track the position
181
+ *
182
+ * @param {Object} entryResult - Result from fastEntry()
183
+ * @param {Object} orderData - Original order data
184
+ * @param {Object} contractInfo - { tickSize, tickValue, contractId } from API
185
+ * @returns {string} orderTag for tracking
186
+ */
187
+ registerEntry(entryResult, orderData, contractInfo = null) {
188
+ if (!entryResult.success) {
189
+ log.warn('Cannot register failed entry', { error: entryResult.error });
190
+ return null;
191
+ }
192
+
193
+ const { orderTag, entryTime, latencyMs } = entryResult;
194
+
195
+ // Get contract info from cache or parameter
196
+ const info = contractInfo || this.contractInfo.get(orderData.symbol) || {
197
+ tickSize: null,
198
+ tickValue: null,
199
+ contractId: orderData.contractId || orderData.symbol,
200
+ };
201
+
202
+ /** @type {ManagedPosition} */
203
+ const position = {
204
+ orderTag,
205
+ accountId: orderData.accountId,
206
+ symbol: orderData.symbol,
207
+ exchange: orderData.exchange || 'CME',
208
+ side: orderData.side, // 0=Long, 1=Short
209
+ size: orderData.size,
210
+ entryPrice: null, // Will be filled from order notification (async)
211
+ entryTime,
212
+ fillTime: null,
213
+ highWaterMark: null,
214
+ lowWaterMark: null,
215
+ status: 'pending', // Waiting for fill confirmation
216
+ holdComplete: false,
217
+ exitReason: null,
218
+ latencyMs,
219
+ // Contract info from API (NOT hardcoded)
220
+ tickSize: info.tickSize,
221
+ tickValue: info.tickValue,
222
+ contractId: info.contractId,
223
+ };
224
+
225
+ this.positions.set(orderTag, position);
226
+
227
+ log.debug('Registered entry', {
228
+ orderTag,
229
+ symbol: orderData.symbol,
230
+ side: orderData.side === 0 ? 'LONG' : 'SHORT',
231
+ size: orderData.size,
232
+ latencyMs,
233
+ tickSize: info.tickSize,
234
+ });
235
+
236
+ return orderTag;
237
+ }
238
+
239
+ /**
240
+ * Handle order fill notification from Rithmic (templateId: 351)
241
+ * This is ASYNC - does not block fastEntry()
242
+ * @private
243
+ */
244
+ _onOrderFilled(fillInfo) {
245
+ const { orderTag, avgFillPrice, totalFillQuantity, symbol, transactionType, localTimestamp } = fillInfo;
246
+
247
+ if (!orderTag) return;
248
+
249
+ const position = this.positions.get(orderTag);
250
+ if (!position) {
251
+ // Could be an exit order fill - check if any position is exiting
252
+ for (const [tag, pos] of this.positions) {
253
+ if (pos.status === 'exiting' && pos.symbol === symbol) {
254
+ // This is likely our exit fill
255
+ pos.status = 'closed';
256
+ const holdDuration = Date.now() - pos.fillTime;
257
+ const pnlTicks = this._calculatePnlTicks(pos, avgFillPrice);
258
+
259
+ log.info('EXIT FILLED', {
260
+ orderTag: tag,
261
+ symbol,
262
+ exitPrice: avgFillPrice,
263
+ entryPrice: pos.entryPrice,
264
+ pnlTicks,
265
+ holdDurationMs: holdDuration,
266
+ reason: pos.exitReason,
267
+ });
268
+
269
+ this.emit('exitFilled', {
270
+ orderTag: tag,
271
+ position: pos,
272
+ exitPrice: avgFillPrice,
273
+ pnlTicks,
274
+ holdDurationMs: holdDuration,
275
+ });
276
+
277
+ this.positions.delete(tag);
278
+ return;
279
+ }
280
+ }
281
+ log.debug('Fill for untracked order', { orderTag });
282
+ return;
283
+ }
284
+
285
+ if (position.status === 'pending') {
286
+ // Entry fill confirmed - UPDATE with real fill price
287
+ position.entryPrice = avgFillPrice;
288
+ position.fillTime = Date.now();
289
+ position.highWaterMark = avgFillPrice;
290
+ position.lowWaterMark = avgFillPrice;
291
+ position.status = 'holding'; // Now in holding period
292
+
293
+ const fillLatency = position.fillTime - position.entryTime;
294
+
295
+ log.info('ENTRY FILLED', {
296
+ orderTag,
297
+ symbol,
298
+ side: position.side === 0 ? 'LONG' : 'SHORT',
299
+ size: position.size,
300
+ price: avgFillPrice,
301
+ entryLatencyMs: position.latencyMs,
302
+ fillLatencyMs: fillLatency,
303
+ });
304
+
305
+ this.emit('entryFilled', {
306
+ orderTag,
307
+ position,
308
+ fillLatencyMs: fillLatency,
309
+ });
310
+
311
+ // Schedule hold completion check
312
+ setTimeout(() => {
313
+ this._onHoldComplete(orderTag);
314
+ }, FAST_SCALPING.MIN_HOLD_MS);
315
+
316
+ // Schedule 60s failsafe (NON-NEGOTIABLE)
317
+ setTimeout(() => {
318
+ this._failsafeExit(orderTag);
319
+ }, FAST_SCALPING.MAX_HOLD_MS);
320
+
321
+ } else if (position.status === 'exiting') {
322
+ // Exit fill confirmed
323
+ position.status = 'closed';
324
+
325
+ const holdDuration = Date.now() - position.fillTime;
326
+ const pnlTicks = this._calculatePnlTicks(position, avgFillPrice);
327
+
328
+ log.info('EXIT FILLED', {
329
+ orderTag,
330
+ symbol,
331
+ exitPrice: avgFillPrice,
332
+ entryPrice: position.entryPrice,
333
+ pnlTicks,
334
+ holdDurationMs: holdDuration,
335
+ reason: position.exitReason,
336
+ });
337
+
338
+ this.emit('exitFilled', {
339
+ orderTag,
340
+ position,
341
+ exitPrice: avgFillPrice,
342
+ pnlTicks,
343
+ holdDurationMs: holdDuration,
344
+ });
345
+
346
+ // Clean up
347
+ this.positions.delete(orderTag);
348
+ }
349
+ }
350
+
351
+ /**
352
+ * 60 second failsafe exit (NON-NEGOTIABLE)
353
+ * Forces market exit if position still open
354
+ * @private
355
+ */
356
+ _failsafeExit(orderTag) {
357
+ const position = this.positions.get(orderTag);
358
+ if (!position) return;
359
+
360
+ // Only force exit if still active (not already exiting/closed)
361
+ if (position.status === 'holding' || position.status === 'active') {
362
+ const currentPrice = this.latestPrices.get(position.symbol);
363
+ const pnlTicks = currentPrice ? this._calculatePnlTicks(position, currentPrice) : 0;
364
+
365
+ log.warn('FAILSAFE EXIT - 60s max hold exceeded', {
366
+ orderTag,
367
+ symbol: position.symbol,
368
+ pnlTicks,
369
+ });
370
+
371
+ this._executeExit(orderTag, {
372
+ type: 'failsafe',
373
+ reason: '60s max hold exceeded (NON-NEGOTIABLE)',
374
+ pnlTicks,
375
+ });
376
+ }
377
+ }
378
+
379
+ /**
380
+ * Handle price update from market data
381
+ * @private
382
+ */
383
+ _onPriceUpdate(priceData) {
384
+ const { symbol, price, timestamp } = priceData;
385
+
386
+ this.latestPrices.set(symbol, price);
387
+
388
+ // Update high/low water marks for active positions
389
+ for (const [orderTag, position] of this.positions) {
390
+ if (position.symbol === symbol && (position.status === 'holding' || position.status === 'active') && position.entryPrice) {
391
+ if (position.side === 0) { // Long
392
+ position.highWaterMark = Math.max(position.highWaterMark, price);
393
+ } else { // Short
394
+ position.lowWaterMark = Math.min(position.lowWaterMark, price);
395
+ }
396
+ }
397
+ }
398
+ }
399
+
400
+ /**
401
+ * Handle position update from PNL_PLANT
402
+ * @private
403
+ */
404
+ _onPositionUpdate(posData) {
405
+ log.debug('Position update from PNL_PLANT', {
406
+ symbol: posData?.symbol,
407
+ qty: posData?.quantity,
408
+ });
409
+ }
410
+
411
+ /**
412
+ * Called when minimum hold period is complete
413
+ * @private
414
+ */
415
+ _onHoldComplete(orderTag) {
416
+ const position = this.positions.get(orderTag);
417
+ if (!position) return;
418
+
419
+ if (position.status === 'holding') {
420
+ position.holdComplete = true;
421
+ position.status = 'active'; // Now eligible for exit
422
+
423
+ log.info('Hold complete - now monitoring for exit', {
424
+ orderTag,
425
+ symbol: position.symbol,
426
+ entryPrice: position.entryPrice,
427
+ });
428
+
429
+ this.emit('holdComplete', { orderTag, position });
430
+ }
431
+ }
432
+
433
+ /**
434
+ * Main monitoring loop - runs every MONITOR_INTERVAL_MS
435
+ * @private
436
+ */
437
+ _monitorPositions() {
438
+ const now = Date.now();
439
+
440
+ for (const [orderTag, position] of this.positions) {
441
+ // Skip if not ready to exit
442
+ if (position.status !== 'active') continue;
443
+
444
+ const currentPrice = this.latestPrices.get(position.symbol);
445
+ if (!currentPrice) continue;
446
+
447
+ const holdDuration = now - position.fillTime;
448
+ const pnlTicks = this._calculatePnlTicks(position, currentPrice);
449
+
450
+ // Check exit conditions
451
+ const exitReason = this._checkExitConditions(position, currentPrice, pnlTicks, holdDuration);
452
+
453
+ if (exitReason) {
454
+ this._executeExit(orderTag, exitReason);
455
+ }
456
+ }
457
+ }
458
+
459
+ /**
460
+ * Check if position should be exited
461
+ * Uses momentum calculation with OFI/Kalman/Z-Score from strategy
462
+ *
463
+ * Data sources:
464
+ * - pnlTicks: Calculated from entryPrice (Rithmic fill) and currentPrice (market data)
465
+ * - VPIN: Strategy's computeVPIN()
466
+ * - Momentum: Strategy's OFI, Kalman, Z-Score
467
+ *
468
+ * @private
469
+ * @returns {Object|null} Exit reason or null
470
+ */
471
+ _checkExitConditions(position, currentPrice, pnlTicks, holdDuration) {
472
+ // Cannot evaluate exit conditions without PnL data
473
+ if (pnlTicks === null) {
474
+ log.debug('Cannot check exit - no PnL data', { symbol: position.symbol });
475
+ return null;
476
+ }
477
+
478
+ const targetTicks = FAST_SCALPING.TARGET_TICKS;
479
+ const stopTicks = FAST_SCALPING.STOP_TICKS;
480
+
481
+ // 1. TARGET HIT - Always exit at target
482
+ if (pnlTicks >= targetTicks) {
483
+ return { type: 'target', reason: 'Target reached', pnlTicks };
484
+ }
485
+
486
+ // 2. STOP HIT - Always exit at stop
487
+ if (pnlTicks <= -stopTicks) {
488
+ return { type: 'stop', reason: 'Stop loss hit', pnlTicks };
489
+ }
490
+
491
+ // 3. VPIN DANGER - Informed traders detected (from strategy)
492
+ const vpin = this._getVPIN(position);
493
+ if (vpin !== null && vpin > MOMENTUM.VPIN_DANGER) {
494
+ return { type: 'vpin', reason: `VPIN spike ${(vpin * 100).toFixed(0)}% - informed traders`, pnlTicks, vpin };
495
+ }
496
+
497
+ // 4. TRAILING STOP (only if in profit above threshold)
498
+ if (pnlTicks >= FAST_SCALPING.TRAILING_ACTIVATION_TICKS) {
499
+ const trailingPnl = this._calculateTrailingPnl(position, currentPrice);
500
+ if (trailingPnl !== null && trailingPnl <= -FAST_SCALPING.TRAILING_DISTANCE_TICKS) {
501
+ return { type: 'trailing', reason: 'Trailing stop triggered', pnlTicks, trailingPnl };
502
+ }
503
+ }
504
+
505
+ // 5. MOMENTUM-BASED EXIT (using strategy's math models)
506
+ const momentum = this._calculateMomentum(position);
507
+
508
+ if (momentum !== null) {
509
+ // Strong favorable momentum + profit → HOLD (let it run)
510
+ if (momentum > MOMENTUM.STRONG_FAVORABLE && pnlTicks > 4) {
511
+ // Don't exit - momentum is strong in our favor
512
+ return null;
513
+ }
514
+
515
+ // Weak momentum + profit → EXIT (secure profit)
516
+ if (momentum < MOMENTUM.WEAK_THRESHOLD && pnlTicks > 0) {
517
+ return { type: 'momentum_weak', reason: 'Weak momentum - securing profit', pnlTicks, momentum };
518
+ }
519
+
520
+ // Adverse momentum → EXIT immediately
521
+ if (momentum < MOMENTUM.ADVERSE_THRESHOLD) {
522
+ return { type: 'momentum_adverse', reason: 'Adverse momentum detected', pnlTicks, momentum };
523
+ }
524
+ }
525
+
526
+ return null;
527
+ }
528
+
529
+ /**
530
+ * Calculate momentum score using strategy's existing math models
531
+ * Weighted: OFI (50%) + Kalman Velocity (25%) + Z-Score (25%)
532
+ *
533
+ * Data sources:
534
+ * - OFI: Strategy's computeOrderFlowImbalance()
535
+ * - Kalman: Strategy's kalmanStates
536
+ * - Z-Score: Strategy's computeZScore()
537
+ *
538
+ * @private
539
+ * @param {ManagedPosition} position
540
+ * @returns {number|null} Momentum score [-1 to 1], positive = favorable, null if insufficient data
541
+ */
542
+ _calculateMomentum(position) {
543
+ if (!this.strategy) {
544
+ return null;
545
+ }
546
+
547
+ // Get individual model values (all from API/strategy, not invented)
548
+ const ofi = this._getOFI(position);
549
+ const velocity = this._getKalmanVelocity(position);
550
+ const zscore = this._getZScore(position);
551
+
552
+ // Count how many models have data
553
+ let availableModels = 0;
554
+ let totalWeight = 0;
555
+ let weightedSum = 0;
556
+
557
+ // 1. OFI (50%) - Order Flow Imbalance
558
+ if (ofi !== null) {
559
+ // For long: positive OFI = favorable, For short: negative OFI = favorable
560
+ const favorableOfi = position.side === 0 ? ofi : -ofi;
561
+ const ofiScore = Math.min(1, Math.max(-1, favorableOfi));
562
+ weightedSum += ofiScore * WEIGHTS.OFI;
563
+ totalWeight += WEIGHTS.OFI;
564
+ availableModels++;
565
+ }
566
+
567
+ // 2. Kalman Velocity (25%)
568
+ if (velocity !== null) {
569
+ const tickSize = this._getTickSize(position);
570
+ if (tickSize !== null) {
571
+ // Normalize velocity: favorable direction = positive
572
+ const favorableVelocity = position.side === 0 ? velocity : -velocity;
573
+ // Normalize to [-1, 1]: 4 ticks of velocity = 1.0 score
574
+ const normalizedVelocity = favorableVelocity / (tickSize * 4);
575
+ const velocityScore = Math.min(1, Math.max(-1, normalizedVelocity));
576
+ weightedSum += velocityScore * WEIGHTS.KALMAN;
577
+ totalWeight += WEIGHTS.KALMAN;
578
+ availableModels++;
579
+ }
580
+ }
581
+
582
+ // 3. Z-Score (25%) - Progression toward mean
583
+ if (zscore !== null) {
584
+ let zscoreScore;
585
+ if (position.side === 0) {
586
+ // Long: entered when Z < -threshold, improving = Z moving toward 0
587
+ // Z > -0.5 means close to mean = favorable
588
+ zscoreScore = zscore > -0.5 ? 0.5 : -0.5;
589
+ } else {
590
+ // Short: entered when Z > threshold, improving = Z moving toward 0
591
+ // Z < 0.5 means close to mean = favorable
592
+ zscoreScore = zscore < 0.5 ? 0.5 : -0.5;
593
+ }
594
+ weightedSum += zscoreScore * WEIGHTS.ZSCORE;
595
+ totalWeight += WEIGHTS.ZSCORE;
596
+ availableModels++;
597
+ }
598
+
599
+ // Need at least 1 model with data to calculate momentum
600
+ if (availableModels === 0 || totalWeight === 0) {
601
+ return null;
602
+ }
603
+
604
+ // Normalize by actual total weight (in case some models unavailable)
605
+ const momentum = weightedSum / totalWeight;
606
+
607
+ // Clamp to [-1, 1]
608
+ return Math.min(1, Math.max(-1, momentum));
609
+ }
610
+
611
+ /**
612
+ * Get OFI (Order Flow Imbalance) from strategy
613
+ * Data source: Strategy's computeOrderFlowImbalance() or getModelValues()
614
+ * @private
615
+ * @returns {number|null} OFI value [-1, 1] or null if unavailable
616
+ */
617
+ _getOFI(position) {
618
+ if (!this.strategy) return null;
619
+
620
+ const contractId = position.contractId || position.symbol;
621
+
622
+ // Try strategy's computeOrderFlowImbalance (direct calculation from bars)
623
+ if (typeof this.strategy.computeOrderFlowImbalance === 'function') {
624
+ const bars = this.strategy.getBarHistory?.(contractId);
625
+ if (bars && bars.length >= 20) {
626
+ try {
627
+ return this.strategy.computeOrderFlowImbalance(bars);
628
+ } catch (error) {
629
+ log.debug('OFI calculation failed', { error: error.message });
630
+ }
631
+ }
632
+ }
633
+
634
+ // Try getModelValues (pre-calculated values)
635
+ const modelValues = this.strategy.getModelValues?.(contractId);
636
+ if (modelValues && modelValues.rawOfi !== undefined) {
637
+ return modelValues.rawOfi;
638
+ }
639
+
640
+ return null;
641
+ }
642
+
643
+ /**
644
+ * Get Kalman velocity from strategy's Kalman filter
645
+ * Data source: Strategy's kalmanStates or kalmanFilterManager
646
+ * @private
647
+ * @returns {number|null} Velocity value or null if unavailable
648
+ */
649
+ _getKalmanVelocity(position) {
650
+ if (!this.strategy) return null;
651
+
652
+ const contractId = position.contractId || position.symbol;
653
+
654
+ // Try to access kalmanStates from strategy
655
+ if (this.strategy.kalmanStates) {
656
+ const state = this.strategy.kalmanStates.get(contractId);
657
+ if (state && typeof state.estimate === 'number') {
658
+ const currentPrice = this.latestPrices.get(position.symbol);
659
+ if (currentPrice !== undefined && currentPrice !== null) {
660
+ // Velocity = price difference from Kalman estimate
661
+ // Positive = price above estimate (upward momentum)
662
+ return currentPrice - state.estimate;
663
+ }
664
+ }
665
+ }
666
+
667
+ return null;
668
+ }
669
+
670
+ /**
671
+ * Get Z-Score from strategy
672
+ * Data source: Strategy's computeZScore() or priceBuffer
673
+ * @private
674
+ * @returns {number|null} Z-Score value or null if unavailable
675
+ */
676
+ _getZScore(position) {
677
+ if (!this.strategy) return null;
678
+
679
+ const contractId = position.contractId || position.symbol;
680
+
681
+ // Try strategy's computeZScore (direct calculation from price buffer)
682
+ if (typeof this.strategy.computeZScore === 'function') {
683
+ const prices = this.strategy.priceBuffer?.get(contractId);
684
+ if (prices && prices.length >= 50) {
685
+ try {
686
+ return this.strategy.computeZScore(prices);
687
+ } catch (error) {
688
+ log.debug('Z-Score calculation failed', { error: error.message });
689
+ }
690
+ }
691
+ }
692
+
693
+ return null;
694
+ }
695
+
696
+ /**
697
+ * Get VPIN from strategy
698
+ * Data source: Strategy's computeVPIN() or volumeBuffer
699
+ * @private
700
+ * @returns {number|null} VPIN value [0, 1] or null if unavailable
701
+ */
702
+ _getVPIN(position) {
703
+ if (!this.strategy) return null;
704
+
705
+ const contractId = position.contractId || position.symbol;
706
+
707
+ // Try strategy's computeVPIN (direct calculation from volume buffer)
708
+ if (typeof this.strategy.computeVPIN === 'function') {
709
+ const volumes = this.strategy.volumeBuffer?.get(contractId);
710
+ if (volumes && volumes.length >= 50) {
711
+ try {
712
+ return this.strategy.computeVPIN(volumes);
713
+ } catch (error) {
714
+ log.debug('VPIN calculation failed', { error: error.message });
715
+ }
716
+ }
717
+ }
718
+
719
+ // Try getModelValues (pre-calculated, stored as 1 - vpin for scoring)
720
+ const modelValues = this.strategy.getModelValues?.(contractId);
721
+ if (modelValues && typeof modelValues.vpin === 'number') {
722
+ // modelValues.vpin is normalized score (1 - vpin), convert back
723
+ return 1 - modelValues.vpin;
724
+ }
725
+
726
+ return null;
727
+ }
728
+
729
+ /**
730
+ * Execute exit order
731
+ * @private
732
+ */
733
+ _executeExit(orderTag, exitReason) {
734
+ const position = this.positions.get(orderTag);
735
+ if (!position || position.status === 'exiting' || position.status === 'closed') return;
736
+
737
+ position.status = 'exiting';
738
+ position.exitReason = exitReason;
739
+
740
+ log.info('Executing EXIT', {
741
+ orderTag,
742
+ symbol: position.symbol,
743
+ reason: exitReason.reason,
744
+ pnlTicks: exitReason.pnlTicks,
745
+ momentum: exitReason.momentum,
746
+ });
747
+
748
+ // Fire exit order (opposite side)
749
+ const exitSide = position.side === 0 ? 1 : 0; // Reverse: Long->Sell, Short->Buy
750
+
751
+ const exitResult = this.rithmic.fastExit({
752
+ accountId: position.accountId,
753
+ symbol: position.symbol,
754
+ exchange: position.exchange,
755
+ size: position.size,
756
+ side: exitSide,
757
+ });
758
+
759
+ if (exitResult.success) {
760
+ log.debug('Exit order fired', {
761
+ orderTag: position.orderTag,
762
+ exitOrderTag: exitResult.orderTag,
763
+ latencyMs: exitResult.latencyMs,
764
+ });
765
+
766
+ this.emit('exitOrderFired', {
767
+ orderTag,
768
+ exitOrderTag: exitResult.orderTag,
769
+ exitReason,
770
+ latencyMs: exitResult.latencyMs,
771
+ });
772
+ } else {
773
+ log.error('Exit order FAILED', {
774
+ orderTag,
775
+ error: exitResult.error,
776
+ });
777
+ // Reset status to try again next cycle
778
+ position.status = 'active';
779
+ position.exitReason = null;
780
+ }
781
+ }
782
+
783
+ /**
784
+ * Get tick size for position (from API, not hardcoded)
785
+ * @private
786
+ */
787
+ _getTickSize(position) {
788
+ // First try position's stored tickSize (from API)
789
+ if (position.tickSize !== null && position.tickSize !== undefined) {
790
+ return position.tickSize;
791
+ }
792
+
793
+ // Then try contract info cache
794
+ const info = this.contractInfo.get(position.symbol);
795
+ if (info && info.tickSize) {
796
+ return info.tickSize;
797
+ }
798
+
799
+ // Last resort: log warning and return null (will cause issues)
800
+ log.warn('No tick size available for symbol', { symbol: position.symbol });
801
+ return null;
802
+ }
803
+
804
+ /**
805
+ * Get tick value for position (from API, not hardcoded)
806
+ * @private
807
+ */
808
+ _getTickValue(position) {
809
+ if (position.tickValue !== null && position.tickValue !== undefined) {
810
+ return position.tickValue;
811
+ }
812
+
813
+ const info = this.contractInfo.get(position.symbol);
814
+ if (info && info.tickValue) {
815
+ return info.tickValue;
816
+ }
817
+
818
+ log.warn('No tick value available for symbol', { symbol: position.symbol });
819
+ return null;
820
+ }
821
+
822
+ /**
823
+ * Calculate P&L in ticks from entry
824
+ * Data source: position.entryPrice (from Rithmic fill), tickSize (from API)
825
+ * @private
826
+ * @returns {number|null} PnL in ticks, null if cannot calculate
827
+ */
828
+ _calculatePnlTicks(position, currentPrice) {
829
+ if (position.entryPrice === null || position.entryPrice === undefined) {
830
+ return null;
831
+ }
832
+
833
+ if (currentPrice === null || currentPrice === undefined) {
834
+ return null;
835
+ }
836
+
837
+ const tickSize = this._getTickSize(position);
838
+ if (tickSize === null || tickSize === undefined) {
839
+ log.error('Cannot calculate PnL - no tick size from API', { symbol: position.symbol });
840
+ return null;
841
+ }
842
+
843
+ const priceDiff = currentPrice - position.entryPrice;
844
+ const signedDiff = position.side === 0 ? priceDiff : -priceDiff;
845
+
846
+ return Math.round(signedDiff / tickSize);
847
+ }
848
+
849
+ /**
850
+ * Calculate trailing P&L from high/low water mark
851
+ * Data source: position water marks (from price updates), tickSize (from API)
852
+ * @private
853
+ * @returns {number|null} Trailing PnL in ticks, null if cannot calculate
854
+ */
855
+ _calculateTrailingPnl(position, currentPrice) {
856
+ const tickSize = this._getTickSize(position);
857
+ if (tickSize === null || tickSize === undefined) {
858
+ return null;
859
+ }
860
+
861
+ if (position.side === 0) { // Long
862
+ if (position.highWaterMark === null || position.highWaterMark === undefined) {
863
+ return null;
864
+ }
865
+ const dropFromHigh = position.highWaterMark - currentPrice;
866
+ return -Math.round(dropFromHigh / tickSize);
867
+ } else { // Short
868
+ if (position.lowWaterMark === null || position.lowWaterMark === undefined) {
869
+ return null;
870
+ }
871
+ const riseFromLow = currentPrice - position.lowWaterMark;
872
+ return -Math.round(riseFromLow / tickSize);
873
+ }
874
+ }
875
+
876
+ /**
877
+ * Get all active positions
878
+ * @returns {Array<ManagedPosition>}
879
+ */
880
+ getActivePositions() {
881
+ return Array.from(this.positions.values()).filter(
882
+ p => p.status === 'holding' || p.status === 'active'
883
+ );
884
+ }
885
+
886
+ /**
887
+ * Get position by order tag
888
+ * @param {string} orderTag
889
+ * @returns {ManagedPosition|null}
890
+ */
891
+ getPosition(orderTag) {
892
+ return this.positions.get(orderTag) || null;
893
+ }
894
+
895
+ /**
896
+ * Check if we can enter a new position
897
+ * (no existing position in same symbol - 1 position at a time)
898
+ * @param {string} symbol
899
+ * @returns {boolean}
900
+ */
901
+ canEnter(symbol) {
902
+ for (const position of this.positions.values()) {
903
+ if (position.symbol === symbol && position.status !== 'closed') {
904
+ return false;
905
+ }
906
+ }
907
+ return true;
908
+ }
909
+
910
+ /**
911
+ * Get momentum thresholds (for UI display)
912
+ * @returns {Object}
913
+ */
914
+ getMomentumThresholds() {
915
+ return { ...MOMENTUM };
916
+ }
917
+
918
+ /**
919
+ * Get momentum weights (for UI display)
920
+ * @returns {Object}
921
+ */
922
+ getMomentumWeights() {
923
+ return { ...WEIGHTS };
924
+ }
925
+ }
926
+
927
+ module.exports = { PositionManager };