CRITICAL INVESTIGATION (Dec 15, 2025): - Monitoring loop runs every 2s: "🔍 Price check: SOL-PERP @ $124.47 (1 trades)" ✓ - But NO condition checks execute: No TP1/TP2/SL detection, no executeExit calls ❌ - Impact: ,000+ losses - 96% data loss ($32.98 actual vs $1.23 recorded) Added debug logging: - STARTCHK: Function entry (price, entry, check count) - DRIFT: Position size and existence from Drift API - AGE: Trade age in seconds vs 30s threshold Purpose: Identify WHERE checkTradeConditions() returns early before reaching condition checks at line 1497+ Hypothesis: Either Drift returns size=0 OR trade age check fails, causing early return at line 711
2223 lines
99 KiB
TypeScript
2223 lines
99 KiB
TypeScript
/**
|
||
* Position Manager
|
||
*
|
||
* Tracks active trades and manages automatic exits
|
||
*/
|
||
|
||
import { getDriftService, initializeDriftService } from '../drift/client'
|
||
import { logger } from '../utils/logger'
|
||
import { closePosition } from '../drift/orders'
|
||
import { getPythPriceMonitor, PriceUpdate } from '../pyth/price-monitor'
|
||
import { getMergedConfig, TradingConfig, getMarketConfig } from '../../config/trading'
|
||
import { updateTradeExit, updateTradeState, getOpenTrades } from '../database/trades'
|
||
import { sendPositionClosedNotification } from '../notifications/telegram'
|
||
import { getStopHuntTracker } from './stop-hunt-tracker'
|
||
import { getMarketDataCache } from './market-data-cache'
|
||
|
||
export interface ActiveTrade {
|
||
id: string
|
||
positionId: string // Transaction signature
|
||
symbol: string
|
||
direction: 'long' | 'short'
|
||
|
||
// Entry details
|
||
entryPrice: number
|
||
entryTime: number
|
||
positionSize: number
|
||
leverage: number
|
||
atrAtEntry?: number // ATR value at entry for ATR-based trailing stop
|
||
adxAtEntry?: number // ADX value at entry for trend strength multiplier
|
||
signalQualityScore?: number // Quality score for stop hunt tracking
|
||
signalSource?: string // Trade source: 'tradingview', 'manual', 'stop_hunt_revenge'
|
||
|
||
// Targets
|
||
stopLossPrice: number
|
||
tp1Price: number
|
||
tp2Price: number
|
||
emergencyStopPrice: number
|
||
|
||
// State
|
||
currentSize: number // Changes after TP1
|
||
originalPositionSize: number // Original entry size for accurate P&L on manual closes
|
||
takeProfitPrice1?: number // TP1 price for validation
|
||
takeProfitPrice2?: number // TP2 price for validation
|
||
tp1Hit: boolean
|
||
tp2Hit: boolean
|
||
slMovedToBreakeven: boolean
|
||
slMovedToProfit: boolean
|
||
trailingStopActive: boolean
|
||
|
||
// P&L tracking
|
||
realizedPnL: number
|
||
unrealizedPnL: number
|
||
peakPnL: number
|
||
peakPrice: number // Track highest price reached (for trailing)
|
||
|
||
// MAE/MFE tracking
|
||
maxFavorableExcursion: number // Best profit % reached
|
||
maxAdverseExcursion: number // Worst loss % reached
|
||
maxFavorablePrice: number // Price at best profit
|
||
maxAdversePrice: number // Price at worst loss
|
||
|
||
// Position scaling tracking
|
||
originalAdx?: number // ADX at initial entry (for scaling validation)
|
||
timesScaled?: number // How many times position has been scaled
|
||
totalScaleAdded?: number // Total USD added through scaling
|
||
|
||
// Close verification tracking (Nov 16, 2025)
|
||
closingInProgress?: boolean // True when close tx confirmed but Drift not yet propagated
|
||
closeConfirmedAt?: number // Timestamp when close was confirmed (for timeout)
|
||
|
||
// Monitoring
|
||
priceCheckCount: number
|
||
lastPrice: number
|
||
lastUpdateTime: number
|
||
}
|
||
|
||
export interface ExitResult {
|
||
success: boolean
|
||
reason: 'TP1' | 'TP2' | 'SL' | 'SOFT_SL' | 'HARD_SL' | 'TRAILING_SL' | 'emergency' | 'manual' | 'error'
|
||
closePrice?: number
|
||
closedSize?: number
|
||
realizedPnL?: number
|
||
transactionSignature?: string
|
||
error?: string
|
||
}
|
||
|
||
export class PositionManager {
|
||
private activeTrades: Map<string, ActiveTrade> = new Map()
|
||
private config: TradingConfig
|
||
private isMonitoring: boolean = false
|
||
private initialized: boolean = false
|
||
private validationInterval: NodeJS.Timeout | null = null
|
||
|
||
constructor(config?: Partial<TradingConfig>) {
|
||
this.config = getMergedConfig(config)
|
||
logger.log('✅ Position manager created')
|
||
}
|
||
|
||
/**
|
||
* Initialize and restore active trades from database
|
||
*/
|
||
async initialize(forceReload: boolean = false): Promise<void> {
|
||
if (this.initialized && !forceReload) {
|
||
return
|
||
}
|
||
|
||
if (forceReload) {
|
||
logger.log('🔄 Force reloading Position Manager state from database')
|
||
this.activeTrades.clear()
|
||
this.isMonitoring = false
|
||
}
|
||
|
||
logger.log('🔄 Restoring active trades from database...')
|
||
|
||
try {
|
||
const openTrades = await getOpenTrades()
|
||
|
||
for (const dbTrade of openTrades) {
|
||
// Extract Position Manager state from configSnapshot
|
||
const pmState = (dbTrade.configSnapshot as any)?.positionManagerState
|
||
|
||
// Reconstruct ActiveTrade object
|
||
const activeTrade: ActiveTrade = {
|
||
id: dbTrade.id,
|
||
positionId: dbTrade.positionId,
|
||
symbol: dbTrade.symbol,
|
||
direction: dbTrade.direction as 'long' | 'short',
|
||
entryPrice: dbTrade.entryPrice,
|
||
entryTime: dbTrade.entryTime.getTime(),
|
||
positionSize: dbTrade.positionSizeUSD,
|
||
leverage: dbTrade.leverage,
|
||
stopLossPrice: pmState?.stopLossPrice ?? dbTrade.stopLossPrice,
|
||
tp1Price: dbTrade.takeProfit1Price,
|
||
tp2Price: dbTrade.takeProfit2Price,
|
||
emergencyStopPrice: dbTrade.stopLossPrice * (dbTrade.direction === 'long' ? 0.98 : 1.02),
|
||
currentSize: pmState?.currentSize ?? dbTrade.positionSizeUSD,
|
||
originalPositionSize: dbTrade.positionSizeUSD, // Store original size for P&L
|
||
takeProfitPrice1: dbTrade.takeProfit1Price,
|
||
takeProfitPrice2: dbTrade.takeProfit2Price,
|
||
tp1Hit: pmState?.tp1Hit ?? false,
|
||
tp2Hit: pmState?.tp2Hit ?? false,
|
||
slMovedToBreakeven: pmState?.slMovedToBreakeven ?? false,
|
||
slMovedToProfit: pmState?.slMovedToProfit ?? false,
|
||
trailingStopActive: pmState?.trailingStopActive ?? false,
|
||
realizedPnL: pmState?.realizedPnL ?? 0,
|
||
unrealizedPnL: pmState?.unrealizedPnL ?? 0,
|
||
peakPnL: pmState?.peakPnL ?? 0,
|
||
peakPrice: pmState?.peakPrice ?? dbTrade.entryPrice,
|
||
maxFavorableExcursion: pmState?.maxFavorableExcursion ?? 0,
|
||
maxAdverseExcursion: pmState?.maxAdverseExcursion ?? 0,
|
||
maxFavorablePrice: pmState?.maxFavorablePrice ?? dbTrade.entryPrice,
|
||
maxAdversePrice: pmState?.maxAdversePrice ?? dbTrade.entryPrice,
|
||
priceCheckCount: 0,
|
||
lastPrice: pmState?.lastPrice ?? dbTrade.entryPrice,
|
||
lastUpdateTime: Date.now(),
|
||
}
|
||
|
||
this.activeTrades.set(activeTrade.id, activeTrade)
|
||
logger.log(`✅ Restored trade: ${activeTrade.symbol} ${activeTrade.direction} at $${activeTrade.entryPrice}`)
|
||
}
|
||
|
||
if (this.activeTrades.size > 0) {
|
||
logger.log(`🎯 Restored ${this.activeTrades.size} active trades`)
|
||
await this.startMonitoring()
|
||
} else {
|
||
logger.log('✅ No active trades to restore')
|
||
}
|
||
|
||
} catch (error) {
|
||
console.error('❌ Failed to restore active trades:', error)
|
||
}
|
||
|
||
this.initialized = true
|
||
}
|
||
|
||
/**
|
||
* Handle manual closures with proper exit reason detection
|
||
* Called when size reduction detected but price NOT at TP1 level
|
||
*/
|
||
private async handleManualClosure(
|
||
trade: ActiveTrade,
|
||
currentPrice: number,
|
||
remainingSize: number
|
||
): Promise<void> {
|
||
logger.log(`👤 Processing manual closure for ${trade.symbol}`)
|
||
|
||
// Determine exit reason based on price levels
|
||
let exitReason: 'TP1' | 'TP2' | 'SL' | 'SOFT_SL' | 'HARD_SL' | 'TRAILING_SL' | 'manual' | 'emergency' = 'manual'
|
||
const profitPercent = this.calculateProfitPercent(trade.entryPrice, currentPrice, trade.direction)
|
||
|
||
// Check if price is at TP2 or SL levels
|
||
const isAtTP2 = this.isPriceAtTarget(currentPrice, trade.takeProfitPrice2 || 0)
|
||
const isAtSL = this.isPriceAtTarget(currentPrice, trade.stopLossPrice || 0)
|
||
|
||
if (isAtTP2 && trade.tp1Hit) {
|
||
exitReason = 'TP2'
|
||
logger.log(`✅ Manual closure was TP2 (price at target)`)
|
||
} else if (isAtSL) {
|
||
// Check if trailing stop was active
|
||
if (trade.trailingStopActive && trade.tp2Hit) {
|
||
exitReason = 'TRAILING_SL'
|
||
logger.log(`🏃 Manual closure was Trailing SL (price at trailing stop target)`)
|
||
} else {
|
||
exitReason = 'SL'
|
||
logger.log(`🛑 Manual closure was SL (price at target)`)
|
||
}
|
||
} else {
|
||
logger.log(`👤 Manual closure confirmed (price not at any target)`)
|
||
logger.log(` Current: $${currentPrice.toFixed(4)}, TP1: $${trade.takeProfitPrice1?.toFixed(4)}, TP2: $${trade.takeProfitPrice2?.toFixed(4)}, SL: $${trade.stopLossPrice?.toFixed(4)}`)
|
||
}
|
||
|
||
// CRITICAL: Calculate P&L using originalPositionSize for accuracy
|
||
const realizedPnL = (trade.originalPositionSize * profitPercent) / 100
|
||
logger.log(`💰 Manual close P&L: ${profitPercent.toFixed(2)}% on $${trade.originalPositionSize.toFixed(2)} = $${realizedPnL.toFixed(2)}`)
|
||
|
||
// Remove from monitoring FIRST (prevent race conditions)
|
||
this.activeTrades.delete(trade.id)
|
||
|
||
// Update database
|
||
try {
|
||
await updateTradeExit({
|
||
positionId: trade.positionId,
|
||
exitPrice: currentPrice,
|
||
exitReason,
|
||
realizedPnL,
|
||
exitOrderTx: 'Manual closure detected',
|
||
holdTimeSeconds: Math.floor((Date.now() - trade.entryTime) / 1000),
|
||
maxDrawdown: Math.abs(Math.min(0, trade.maxAdverseExcursion)),
|
||
maxGain: Math.max(0, trade.maxFavorableExcursion),
|
||
maxFavorableExcursion: trade.maxFavorableExcursion,
|
||
maxAdverseExcursion: trade.maxAdverseExcursion,
|
||
maxFavorablePrice: trade.maxFavorablePrice,
|
||
maxAdversePrice: trade.maxAdversePrice,
|
||
})
|
||
|
||
logger.log(`✅ Manual closure recorded: ${trade.symbol} ${exitReason} P&L: $${realizedPnL.toFixed(2)}`)
|
||
|
||
// Send Telegram notification
|
||
await sendPositionClosedNotification({
|
||
symbol: trade.symbol,
|
||
direction: trade.direction,
|
||
entryPrice: trade.entryPrice,
|
||
exitPrice: currentPrice,
|
||
positionSize: trade.originalPositionSize,
|
||
realizedPnL,
|
||
exitReason,
|
||
holdTimeSeconds: Math.floor((Date.now() - trade.entryTime) / 1000),
|
||
maxGain: Math.max(0, trade.maxFavorableExcursion),
|
||
maxDrawdown: Math.abs(Math.min(0, trade.maxAdverseExcursion)),
|
||
})
|
||
} catch (error) {
|
||
console.error('❌ Failed to save manual closure:', error)
|
||
}
|
||
|
||
if (this.activeTrades.size === 0) {
|
||
this.stopMonitoring()
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Add a new trade to monitor
|
||
*/
|
||
async addTrade(trade: ActiveTrade): Promise<void> {
|
||
console.log(`📊 ADDTRADE: Adding trade to monitor: ${trade.symbol} ${trade.direction}`)
|
||
console.log(`📊 ADDTRADE: Trade ID: ${trade.id}`)
|
||
console.log(`📊 ADDTRADE: Before add - activeTrades.size: ${this.activeTrades.size}`)
|
||
|
||
this.activeTrades.set(trade.id, trade)
|
||
|
||
console.log(`📊 ADDTRADE: After add - activeTrades.size: ${this.activeTrades.size}`)
|
||
console.log(`📊 ADDTRADE: isMonitoring: ${this.isMonitoring}`)
|
||
|
||
// Note: Initial state is saved by the API endpoint that creates the trade
|
||
// We don't save here to avoid race condition (trade may not be in DB yet)
|
||
|
||
logger.log(`✅ Trade added. Active trades: ${this.activeTrades.size}`)
|
||
|
||
// Start monitoring if not already running
|
||
if (!this.isMonitoring && this.activeTrades.size > 0) {
|
||
console.log(`📊 ADDTRADE: Calling startMonitoring() - conditions met`)
|
||
await this.startMonitoring()
|
||
|
||
// BUG #77 FIX: Verify monitoring actually started
|
||
if (this.activeTrades.size > 0 && !this.isMonitoring) {
|
||
const errorMsg = `CRITICAL: Failed to start monitoring! activeTrades=${this.activeTrades.size}, isMonitoring=${this.isMonitoring}`
|
||
console.error(`❌ ${errorMsg}`)
|
||
|
||
// Log to persistent file
|
||
const { logCriticalError } = await import('../utils/persistent-logger')
|
||
await logCriticalError('MONITORING_START_FAILED', {
|
||
activeTradesCount: this.activeTrades.size,
|
||
isMonitoring: this.isMonitoring,
|
||
symbols: Array.from(this.activeTrades.values()).map(t => t.symbol),
|
||
tradeIds: Array.from(this.activeTrades.keys())
|
||
})
|
||
|
||
throw new Error(errorMsg)
|
||
}
|
||
|
||
logger.log(`✅ Monitoring verification passed: isMonitoring=${this.isMonitoring}`)
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Remove a trade from monitoring
|
||
* BUG #78 FIX: Safely handle order cancellation to avoid removing active position orders
|
||
*/
|
||
async removeTrade(tradeId: string): Promise<void> {
|
||
const trade = this.activeTrades.get(tradeId)
|
||
if (trade) {
|
||
logger.log(`🗑️ Removing trade: ${trade.symbol}`)
|
||
|
||
// BUG #78 FIX: Check Drift position size before canceling orders
|
||
// If Drift shows an open position, DON'T cancel orders (may belong to active position)
|
||
try {
|
||
const driftService = getDriftService()
|
||
const marketConfig = getMarketConfig(trade.symbol)
|
||
|
||
// Query Drift for current position
|
||
const driftPosition = await driftService.getPosition(marketConfig.driftMarketIndex)
|
||
|
||
if (driftPosition && Math.abs(driftPosition.size) >= 0.01) {
|
||
// Position still open on Drift - DO NOT cancel orders
|
||
console.warn(`⚠️ SAFETY CHECK: ${trade.symbol} position still open on Drift (size: ${driftPosition.size})`)
|
||
console.warn(` Skipping order cancellation to avoid removing active position protection`)
|
||
console.warn(` Removing from tracking only`)
|
||
|
||
// Just remove from map, don't cancel orders
|
||
this.activeTrades.delete(tradeId)
|
||
|
||
// Log for monitoring
|
||
const { logCriticalError } = await import('../utils/persistent-logger')
|
||
await logCriticalError('ORPHAN_REMOVAL_SKIPPED_ACTIVE_POSITION', {
|
||
tradeId,
|
||
symbol: trade.symbol,
|
||
driftSize: driftPosition.size,
|
||
reason: 'Drift position still open - preserved orders for safety'
|
||
})
|
||
} else {
|
||
// Position confirmed closed on Drift - safe to cancel orders
|
||
logger.log(`✅ Drift position confirmed closed (size: ${driftPosition?.size || 0})`)
|
||
logger.log(` Safe to cancel remaining orders`)
|
||
|
||
const { cancelAllOrders } = await import('../drift/orders')
|
||
const cancelResult = await cancelAllOrders(trade.symbol)
|
||
|
||
if (cancelResult.success && cancelResult.cancelledCount! > 0) {
|
||
logger.log(`✅ Cancelled ${cancelResult.cancelledCount} orphaned orders`)
|
||
} else if (!cancelResult.success) {
|
||
console.error(`❌ Failed to cancel orders: ${cancelResult.error}`)
|
||
} else {
|
||
logger.log(`ℹ️ No orders to cancel`)
|
||
}
|
||
|
||
this.activeTrades.delete(tradeId)
|
||
}
|
||
} catch (error) {
|
||
const errorMessage = `❌ Error checking Drift position during trade removal: ${error instanceof Error ? error.message : String(error)}`
|
||
console.error(errorMessage)
|
||
console.warn('⚠️ Removing from tracking without canceling orders (safety first)')
|
||
|
||
// On error, err on side of caution - don't cancel orders
|
||
this.activeTrades.delete(tradeId)
|
||
}
|
||
|
||
// Stop monitoring if no more trades
|
||
if (this.activeTrades.size === 0 && this.isMonitoring) {
|
||
this.stopMonitoring()
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Schedule periodic validation to detect ghost positions
|
||
*/
|
||
private scheduleValidation(): void {
|
||
// Clear any existing interval
|
||
if (this.validationInterval) {
|
||
clearInterval(this.validationInterval)
|
||
}
|
||
|
||
// Run validation every 5 minutes
|
||
const validationIntervalMs = 5 * 60 * 1000
|
||
this.validationInterval = setInterval(async () => {
|
||
await this.validatePositions()
|
||
}, validationIntervalMs)
|
||
|
||
logger.log('🔍 Scheduled position validation every 5 minutes')
|
||
}
|
||
|
||
/**
|
||
* Validate tracked positions against Drift to detect ghosts
|
||
*
|
||
* Ghost positions occur when:
|
||
* - Database has exitReason IS NULL (we think it's open)
|
||
* - But Drift shows position closed or missing
|
||
*
|
||
* This happens due to:
|
||
* - Failed database updates during external closures
|
||
* - Container restarts before cleanup completed
|
||
* - On-chain orders filled without Position Manager knowing
|
||
*
|
||
* CRITICAL (Nov 15, 2025): This MUST run even during rate limiting to prevent ghost accumulation
|
||
*/
|
||
private async validatePositions(): Promise<void> {
|
||
if (this.activeTrades.size === 0) {
|
||
return // Nothing to validate
|
||
}
|
||
|
||
logger.log('🔍 Validating positions against Drift...')
|
||
|
||
try {
|
||
const driftService = getDriftService()
|
||
|
||
// If Drift service not ready, skip this validation cycle
|
||
if (!driftService || !(driftService as any).isInitialized) {
|
||
logger.log('⚠️ Drift service not ready - skipping validation this cycle')
|
||
logger.log(` Positions in memory: ${this.activeTrades.size}`)
|
||
logger.log(` Will retry on next cycle (5 minutes) or during monitoring (40 seconds)`)
|
||
return
|
||
}
|
||
|
||
// Check each tracked trade individually
|
||
for (const [tradeId, trade] of this.activeTrades) {
|
||
const marketConfig = getMarketConfig(trade.symbol)
|
||
|
||
try {
|
||
const position = await driftService.getPosition(marketConfig.driftMarketIndex)
|
||
|
||
// Ghost detected: we're tracking it but Drift shows closed/missing
|
||
if (!position || Math.abs(position.size) < 0.01) {
|
||
logger.log(`🔴 Ghost position detected: ${trade.symbol} (${tradeId})`)
|
||
logger.log(` Database: exitReason IS NULL (thinks it's open)`)
|
||
logger.log(` Drift: Position ${position ? 'closed (size=' + position.size + ')' : 'missing'}`)
|
||
logger.log(` Cause: Likely failed DB update during external closure`)
|
||
|
||
// Auto-cleanup: Handle as external closure
|
||
await this.handleExternalClosure(trade, 'Ghost position cleanup')
|
||
logger.log(`✅ Ghost position cleaned up: ${trade.symbol}`)
|
||
}
|
||
} catch (posError) {
|
||
console.error(`⚠️ Could not check ${trade.symbol} on Drift:`, posError)
|
||
// Continue checking other positions
|
||
}
|
||
}
|
||
|
||
logger.log(`✅ Validation complete: ${this.activeTrades.size} positions healthy`)
|
||
|
||
} catch (error) {
|
||
console.error('❌ Position validation failed:', error)
|
||
// Don't throw - validation errors shouldn't break monitoring
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Handle external closure for ghost position cleanup
|
||
*
|
||
* Called when:
|
||
* - Periodic validation detects position closed on Drift but tracked in DB
|
||
* - Manual cleanup needed after failed database updates
|
||
*/
|
||
private async handleExternalClosure(trade: ActiveTrade, reason: string): Promise<void> {
|
||
logger.log(`🧹 Handling external closure: ${trade.symbol} (${reason})`)
|
||
|
||
// CRITICAL FIX (Dec 2, 2025): Remove from activeTrades FIRST, then check if already removed
|
||
// Bug: Multiple monitoring loops detect ghost simultaneously
|
||
// - Loop 1 checks has(tradeId) → true → proceeds
|
||
// - Loop 2 checks has(tradeId) → true → also proceeds (RACE CONDITION)
|
||
// - Both send Telegram notifications with compounding P&L
|
||
// Fix: Delete BEFORE check, so only first loop proceeds
|
||
const tradeId = trade.id
|
||
const wasInMap = this.activeTrades.delete(tradeId)
|
||
|
||
if (!wasInMap) {
|
||
logger.log(`⚠️ DUPLICATE PREVENTED: Trade ${tradeId} already processed, skipping`)
|
||
logger.log(` This prevents duplicate Telegram notifications with compounding P&L`)
|
||
return
|
||
}
|
||
|
||
logger.log(`🗑️ Removed ${trade.symbol} from monitoring (will not process duplicates)`)
|
||
|
||
// CRITICAL: Calculate P&L using originalPositionSize for accuracy
|
||
// currentSize may be stale if Drift propagation was interrupted
|
||
const profitPercent = this.calculateProfitPercent(
|
||
trade.entryPrice,
|
||
trade.lastPrice,
|
||
trade.direction
|
||
)
|
||
const sizeForPnL = trade.originalPositionSize // Use original, not currentSize
|
||
const estimatedPnL = (sizeForPnL * profitPercent) / 100
|
||
|
||
logger.log(`💰 Estimated P&L: ${profitPercent.toFixed(2)}% on $${sizeForPnL.toFixed(2)} → $${estimatedPnL.toFixed(2)}`)
|
||
|
||
// Update database
|
||
try {
|
||
const holdTimeSeconds = Math.floor((Date.now() - trade.entryTime) / 1000)
|
||
await updateTradeExit({
|
||
positionId: trade.positionId,
|
||
exitPrice: trade.lastPrice,
|
||
exitReason: 'manual', // Ghost closures treated as manual
|
||
realizedPnL: estimatedPnL,
|
||
exitOrderTx: reason, // Store cleanup reason
|
||
holdTimeSeconds,
|
||
maxDrawdown: Math.abs(Math.min(0, trade.maxAdverseExcursion)),
|
||
maxGain: Math.max(0, trade.maxFavorableExcursion),
|
||
maxFavorableExcursion: trade.maxFavorableExcursion,
|
||
maxAdverseExcursion: trade.maxAdverseExcursion,
|
||
maxFavorablePrice: trade.maxFavorablePrice,
|
||
maxAdversePrice: trade.maxAdversePrice,
|
||
})
|
||
logger.log(`💾 Ghost closure saved to database`)
|
||
|
||
// Send Telegram notification for ghost closure
|
||
await sendPositionClosedNotification({
|
||
symbol: trade.symbol,
|
||
direction: trade.direction,
|
||
entryPrice: trade.entryPrice,
|
||
exitPrice: trade.lastPrice,
|
||
positionSize: trade.currentSize,
|
||
realizedPnL: estimatedPnL,
|
||
exitReason: reason, // e.g., "Ghost position cleanup", "Layer 2: Ghost detected via Drift API"
|
||
holdTimeSeconds: Math.floor((Date.now() - trade.entryTime) / 1000),
|
||
maxDrawdown: Math.abs(Math.min(0, trade.maxAdverseExcursion)),
|
||
maxGain: Math.max(0, trade.maxFavorableExcursion),
|
||
})
|
||
} catch (dbError) {
|
||
console.error('❌ Failed to save ghost closure:', dbError)
|
||
}
|
||
|
||
// Stop monitoring if no more trades
|
||
if (this.activeTrades.size === 0 && this.isMonitoring) {
|
||
this.stopMonitoring()
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Get all active trades
|
||
*/
|
||
getActiveTrades(): ActiveTrade[] {
|
||
return Array.from(this.activeTrades.values())
|
||
}
|
||
|
||
/**
|
||
* Get specific trade
|
||
*/
|
||
getTrade(tradeId: string): ActiveTrade | null {
|
||
return this.activeTrades.get(tradeId) || null
|
||
}
|
||
|
||
/**
|
||
* Start price monitoring for all active trades
|
||
*/
|
||
private async startMonitoring(): Promise<void> {
|
||
console.log('🚀 STARTMON: Entered startMonitoring()')
|
||
console.log(`🚀 STARTMON: Current isMonitoring: ${this.isMonitoring}`)
|
||
|
||
if (this.isMonitoring) {
|
||
console.log('⚠️ STARTMON: Monitoring already active, skipping duplicate start')
|
||
logger.log('⚠️ Monitoring already active, skipping duplicate start')
|
||
return
|
||
}
|
||
|
||
// Get unique symbols from active trades
|
||
const symbols = [...new Set(
|
||
Array.from(this.activeTrades.values()).map(trade => trade.symbol)
|
||
)]
|
||
|
||
console.log(`🚀 STARTMON: Symbols to monitor: ${symbols.join(', ')} (count: ${symbols.length})`)
|
||
|
||
if (symbols.length === 0) {
|
||
console.log('⚠️ STARTMON: No symbols to monitor, skipping start')
|
||
logger.log('⚠️ No symbols to monitor, skipping start')
|
||
return
|
||
}
|
||
|
||
console.log('🚀 STARTMON: Starting price monitoring...')
|
||
logger.log('🚀 Starting price monitoring...')
|
||
logger.log(` Active trades: ${this.activeTrades.size}`)
|
||
logger.log(` Symbols: ${symbols.join(', ')}`)
|
||
logger.log(` Current isMonitoring: ${this.isMonitoring}`)
|
||
|
||
const priceMonitor = getPythPriceMonitor()
|
||
console.log('🚀 STARTMON: Got price monitor instance')
|
||
|
||
try {
|
||
console.log('📡 STARTMON: Calling priceMonitor.start()...')
|
||
logger.log('📡 Calling priceMonitor.start()...')
|
||
|
||
await priceMonitor.start({
|
||
symbols,
|
||
onPriceUpdate: async (update: PriceUpdate) => {
|
||
await this.handlePriceUpdate(update)
|
||
},
|
||
onError: (error: Error) => {
|
||
console.error('❌ Price monitor error:', error)
|
||
},
|
||
})
|
||
|
||
console.log('📡 STARTMON: priceMonitor.start() completed')
|
||
|
||
this.isMonitoring = true
|
||
console.log(`✅ STARTMON: Set isMonitoring = true`)
|
||
console.log(`✅ STARTMON: Position monitoring active, isMonitoring: ${this.isMonitoring}`)
|
||
logger.log('✅ Position monitoring active')
|
||
logger.log(` isMonitoring flag set to: ${this.isMonitoring}`)
|
||
|
||
// Schedule periodic validation to detect and cleanup ghost positions
|
||
this.scheduleValidation()
|
||
console.log('✅ STARTMON: Scheduled validation')
|
||
} catch (error) {
|
||
console.error('❌ STARTMON CRITICAL: Failed to start price monitoring:', error)
|
||
console.error('❌ STARTMON: Error details:', error instanceof Error ? error.stack : String(error))
|
||
|
||
// Log error to persistent file
|
||
const { logCriticalError } = await import('../utils/persistent-logger')
|
||
await logCriticalError('PRICE_MONITOR_START_FAILED', {
|
||
symbols,
|
||
activeTradesCount: this.activeTrades.size,
|
||
error: error instanceof Error ? error.message : String(error)
|
||
})
|
||
|
||
throw error // Re-throw so caller knows monitoring failed
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Stop price monitoring
|
||
*/
|
||
private async stopMonitoring(): Promise<void> {
|
||
if (!this.isMonitoring) {
|
||
return
|
||
}
|
||
|
||
logger.log('🛑 Stopping position monitoring...')
|
||
|
||
const priceMonitor = getPythPriceMonitor()
|
||
await priceMonitor.stop()
|
||
|
||
// Clear validation interval
|
||
if (this.validationInterval) {
|
||
clearInterval(this.validationInterval)
|
||
this.validationInterval = null
|
||
}
|
||
|
||
this.isMonitoring = false
|
||
logger.log('✅ Position monitoring stopped')
|
||
}
|
||
|
||
/**
|
||
* Handle price update for all relevant trades
|
||
*/
|
||
private async handlePriceUpdate(update: PriceUpdate): Promise<void> {
|
||
// Find all trades for this symbol
|
||
const tradesForSymbol = Array.from(this.activeTrades.values())
|
||
.filter(trade => trade.symbol === update.symbol)
|
||
|
||
// BUG #77 FIX: Log price updates so we can verify monitoring loop is running
|
||
if (tradesForSymbol.length > 0) {
|
||
console.log(`🔍 Price check: ${update.symbol} @ $${update.price.toFixed(2)} (${tradesForSymbol.length} trades)`)
|
||
}
|
||
|
||
for (const trade of tradesForSymbol) {
|
||
try {
|
||
await this.checkTradeConditions(trade, update.price)
|
||
} catch (error) {
|
||
console.error(`❌ Error checking trade ${trade.id}:`, error)
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Check if any exit conditions are met for a trade
|
||
*/
|
||
private async checkTradeConditions(
|
||
trade: ActiveTrade,
|
||
currentPrice: number
|
||
): Promise<void> {
|
||
// CRITICAL FIX (Nov 23, 2025): Check if trade still in monitoring
|
||
// Prevents duplicate processing when async operations remove trade during loop
|
||
if (!this.activeTrades.has(trade.id)) {
|
||
logger.log(`⏭️ Skipping ${trade.symbol} - already removed from monitoring`)
|
||
return
|
||
}
|
||
|
||
// CRITICAL: Update lastPrice FIRST so /status always shows current price
|
||
// (even if function returns early due to position checks)
|
||
trade.lastPrice = currentPrice
|
||
trade.lastUpdateTime = Date.now()
|
||
trade.priceCheckCount++
|
||
|
||
// BUG #77 RECURRENCE FIX (Dec 15, 2025): CRITICAL debugging to find why condition checks never execute
|
||
console.log(`🔍 STARTCHK: ${trade.symbol} @ $${currentPrice.toFixed(2)} | Entry: $${trade.entryPrice.toFixed(2)} | Checks: ${trade.priceCheckCount}`)
|
||
|
||
// CRITICAL: First check if on-chain position still exists
|
||
// (may have been closed by TP/SL orders without us knowing)
|
||
try {
|
||
// BUG #77 FIX (Dec 13, 2025): Don't skip price checks if Drift not initialized
|
||
// This early return was causing monitoring to never run!
|
||
// Position Manager price checking loop must run even if external closure detection is temporarily unavailable
|
||
// Let the external closure check fail gracefully later if Drift unavailable
|
||
const driftService = getDriftService()
|
||
|
||
const marketConfig = getMarketConfig(trade.symbol)
|
||
const position = await driftService.getPosition(marketConfig.driftMarketIndex)
|
||
|
||
console.log(`🔍 DRIFT: Position ${trade.symbol} | size=${position?.size || 'null'} | exists=${position !== null}`)
|
||
|
||
// Calculate trade age in seconds
|
||
const tradeAgeSeconds = (Date.now() - trade.entryTime) / 1000
|
||
console.log(`🔍 AGE: ${trade.symbol} age=${tradeAgeSeconds.toFixed(1)}s | threshold=30s`)
|
||
|
||
if (position === null || position.size === 0) {
|
||
// IMPORTANT: Skip "external closure" detection for NEW trades (<30 seconds old)
|
||
// Drift positions may not be immediately visible after opening due to blockchain delays
|
||
if (tradeAgeSeconds < 30) {
|
||
logger.log(`⏳ Trade ${trade.symbol} is new (${tradeAgeSeconds.toFixed(1)}s old) - skipping external closure check`)
|
||
return // Skip this check cycle, position might still be propagating
|
||
}
|
||
|
||
// CRITICAL FIX (Dec 7, 2025): DOUBLE-CHECK before processing external closure
|
||
// Root cause of 90-min monitoring gap: Drift state propagation delays cause false positives
|
||
// Position appears closed when it's actually still closing (state lag)
|
||
// Solution: Wait 10 seconds and re-query to confirm position truly closed
|
||
logger.log(`⚠️ Position ${trade.symbol} APPEARS closed - DOUBLE-CHECKING in 10 seconds...`)
|
||
logger.log(` First check: position=${position ? 'exists' : 'null'}, size=${position?.size || 0}`)
|
||
|
||
// Wait 10 seconds for Drift state to propagate
|
||
await new Promise(resolve => setTimeout(resolve, 10000))
|
||
|
||
// Re-query Drift to confirm position truly closed
|
||
logger.log(`🔍 Re-querying Drift after 10s delay...`)
|
||
const recheckPosition = await driftService.getPosition(marketConfig.driftMarketIndex)
|
||
|
||
if (recheckPosition && recheckPosition.size !== 0) {
|
||
// FALSE POSITIVE! Position still open after recheck
|
||
logger.log(`🚨 FALSE POSITIVE DETECTED: Position still open after double-check!`)
|
||
logger.log(` Recheck: position size = ${recheckPosition.size} tokens (NOT ZERO!)`)
|
||
logger.log(` This was Drift state lag, not an actual closure`)
|
||
logger.log(` Continuing monitoring - NOT removing from active trades`)
|
||
|
||
// Reset closingInProgress flag if it was set (allows normal monitoring)
|
||
if (trade.closingInProgress) {
|
||
logger.log(` Resetting closingInProgress flag (false alarm)`)
|
||
trade.closingInProgress = false
|
||
}
|
||
|
||
return // DON'T process as external closure, DON'T remove from monitoring
|
||
}
|
||
|
||
// Position confirmed closed after double-check
|
||
logger.log(`✅ Position confirmed CLOSED after double-check (size still 0)`)
|
||
logger.log(` Safe to proceed with external closure handling`)
|
||
|
||
// Position closed externally (by on-chain TP/SL order or manual closure)
|
||
logger.log(`⚠️ Position ${trade.symbol} was closed externally (by on-chain order)`)
|
||
} else {
|
||
// Position exists - check if size changed (TP1/TP2 filled)
|
||
// CRITICAL FIX: position.size from Drift SDK is base asset tokens, must convert to USD
|
||
const positionSizeUSD = Math.abs(position.size) * currentPrice // Convert tokens to USD
|
||
const trackedSizeUSD = trade.currentSize
|
||
const sizeDiffPercent = Math.abs(positionSizeUSD - trackedSizeUSD) / trackedSizeUSD * 100
|
||
|
||
logger.log(`📊 Position check: Drift=$${positionSizeUSD.toFixed(2)} Tracked=$${trackedSizeUSD.toFixed(2)} Diff=${sizeDiffPercent.toFixed(1)}%`)
|
||
|
||
// If position size reduced significantly, TP orders likely filled
|
||
if (positionSizeUSD < trackedSizeUSD * 0.9 && sizeDiffPercent > 10) {
|
||
logger.log(`✅ Position size reduced: tracking $${trackedSizeUSD.toFixed(2)} → found $${positionSizeUSD.toFixed(2)}`)
|
||
|
||
// Detect which TP filled based on size reduction
|
||
const reductionPercent = ((trackedSizeUSD - positionSizeUSD) / trade.positionSize) * 100
|
||
|
||
if (!trade.tp1Hit && reductionPercent >= (this.config.takeProfit1SizePercent * 0.8)) {
|
||
// CRITICAL: Validate price is actually at TP1 before marking as TP1 hit
|
||
const isPriceAtTP1 = this.isPriceAtTarget(currentPrice, trade.takeProfitPrice1 || 0, 0.002)
|
||
|
||
if (!isPriceAtTP1) {
|
||
logger.log(`⚠️ Size reduction detected (${reductionPercent.toFixed(1)}%) but price NOT at TP1`)
|
||
logger.log(` Current: $${currentPrice.toFixed(4)}, TP1: $${trade.takeProfitPrice1?.toFixed(4) || 'N/A'}`)
|
||
logger.log(` This is likely a MANUAL CLOSE or external order, not TP1`)
|
||
|
||
// Handle as external closure with proper exit reason detection
|
||
await this.handleManualClosure(trade, currentPrice, positionSizeUSD)
|
||
return
|
||
}
|
||
|
||
// TP1 fired (price validated at target)
|
||
logger.log(`🎯 TP1 detected as filled! Reduction: ${reductionPercent.toFixed(1)}%, price at TP1 target`)
|
||
trade.tp1Hit = true
|
||
trade.currentSize = positionSizeUSD
|
||
|
||
// ADX-based runner SL positioning (Nov 19, 2025)
|
||
// Strong trends get more room, weak trends protect capital
|
||
let runnerSlPercent: number
|
||
const adx = trade.adxAtEntry || 0
|
||
|
||
if (adx < 20) {
|
||
runnerSlPercent = 0 // Weak trend: breakeven, preserve capital
|
||
logger.log(`🔒 ADX-based runner SL: ${adx.toFixed(1)} → 0% (breakeven - weak trend)`)
|
||
} else if (adx < 25) {
|
||
runnerSlPercent = -0.3 // Moderate trend: some room
|
||
logger.log(`🔒 ADX-based runner SL: ${adx.toFixed(1)} → -0.3% (moderate trend)`)
|
||
} else {
|
||
runnerSlPercent = -0.55 // Strong trend: full retracement room
|
||
logger.log(`🔒 ADX-based runner SL: ${adx.toFixed(1)} → -0.55% (strong trend)`)
|
||
}
|
||
|
||
// CRITICAL: Use DATABASE entry price (Drift recalculates after partial closes)
|
||
const newStopLossPrice = this.calculatePrice(
|
||
trade.entryPrice,
|
||
runnerSlPercent,
|
||
trade.direction
|
||
)
|
||
|
||
logger.log(`📊 Runner SL calculation: Entry $${trade.entryPrice.toFixed(4)} ${runnerSlPercent >= 0 ? '+' : ''}${runnerSlPercent}% = $${newStopLossPrice.toFixed(4)}`)
|
||
logger.log(` (${this.config.takeProfit1SizePercent}% closed, ${100 - this.config.takeProfit1SizePercent}% remaining)`)
|
||
|
||
// Move SL to ADX-based position after TP1
|
||
trade.stopLossPrice = newStopLossPrice
|
||
trade.slMovedToBreakeven = true
|
||
logger.log(`🛡️ Stop loss moved to: $${trade.stopLossPrice.toFixed(4)}`)
|
||
|
||
// CRITICAL FIX (Dec 12, 2025): Check if we have order signatures
|
||
// Auto-synced positions may have NULL signatures, need fallback
|
||
const { updateTradeState, getPrismaClient } = await import('../database/trades')
|
||
const prisma = getPrismaClient()
|
||
const dbTrade = await prisma.trade.findUnique({
|
||
where: { id: trade.id },
|
||
select: { slOrderTx: true, softStopOrderTx: true, hardStopOrderTx: true }
|
||
})
|
||
|
||
const hasOrderSignatures = dbTrade && (
|
||
dbTrade.slOrderTx || dbTrade.softStopOrderTx || dbTrade.hardStopOrderTx
|
||
)
|
||
|
||
if (!hasOrderSignatures) {
|
||
logger.log(`⚠️ No order signatures found - auto-synced position detected`)
|
||
logger.log(`🔧 FALLBACK: Placing fresh SL order at breakeven $${trade.stopLossPrice.toFixed(4)}`)
|
||
|
||
// Place fresh SL order without trying to cancel (no signatures to cancel)
|
||
const { placeExitOrders } = await import('../drift/orders')
|
||
|
||
try {
|
||
const placeResult = await placeExitOrders({
|
||
symbol: trade.symbol,
|
||
positionSizeUSD: trade.currentSize,
|
||
entryPrice: trade.entryPrice,
|
||
tp1Price: trade.tp2Price || trade.entryPrice * (trade.direction === 'long' ? 1.02 : 0.98),
|
||
tp2Price: trade.tp2Price || trade.entryPrice * (trade.direction === 'long' ? 1.04 : 0.96),
|
||
stopLossPrice: trade.stopLossPrice,
|
||
tp1SizePercent: 0, // Already hit, don't place TP1
|
||
tp2SizePercent: 100, // Place TP2 on remaining position (runner)
|
||
direction: trade.direction,
|
||
useDualStops: this.config.useDualStops,
|
||
softStopPrice: this.config.useDualStops ? trade.stopLossPrice : undefined,
|
||
hardStopPrice: this.config.useDualStops
|
||
? (trade.direction === 'long' ? trade.stopLossPrice * 0.99 : trade.stopLossPrice * 1.01)
|
||
: undefined,
|
||
})
|
||
|
||
if (placeResult.success && placeResult.signatures) {
|
||
logger.log(`✅ Fresh SL order placed successfully`)
|
||
|
||
// Update database with new order signatures
|
||
const updateData: any = {
|
||
stopLossPrice: trade.stopLossPrice,
|
||
}
|
||
|
||
if (this.config.useDualStops && placeResult.signatures.length >= 2) {
|
||
updateData.softStopOrderTx = placeResult.signatures[0]
|
||
updateData.hardStopOrderTx = placeResult.signatures[1]
|
||
logger.log(`💾 Recorded dual SL signatures: soft=${placeResult.signatures[0].slice(0,8)}... hard=${placeResult.signatures[1].slice(0,8)}...`)
|
||
} else if (placeResult.signatures.length >= 1) {
|
||
updateData.slOrderTx = placeResult.signatures[0]
|
||
logger.log(`💾 Recorded SL signature: ${placeResult.signatures[0].slice(0,8)}...`)
|
||
}
|
||
|
||
await updateTradeState({
|
||
id: trade.id,
|
||
...updateData
|
||
})
|
||
logger.log(`✅ Database updated with new SL order signatures`)
|
||
} else {
|
||
console.error(`❌ Failed to place fresh SL order:`, placeResult.error)
|
||
logger.log(`⚠️ CRITICAL: Runner has NO STOP LOSS PROTECTION - manual intervention needed`)
|
||
}
|
||
} catch (placeError) {
|
||
console.error(`❌ Error placing fresh SL order:`, placeError)
|
||
logger.log(`⚠️ CRITICAL: Runner has NO STOP LOSS PROTECTION - manual intervention needed`)
|
||
}
|
||
} else {
|
||
// Normal flow: Has order signatures, can cancel and replace
|
||
logger.log(`✅ Order signatures found - normal order update flow`)
|
||
|
||
try {
|
||
const { cancelAllOrders, placeExitOrders } = await import('../drift/orders')
|
||
|
||
logger.log(`🔄 Cancelling old exit orders...`)
|
||
const cancelResult = await cancelAllOrders(trade.symbol)
|
||
if (cancelResult.success) {
|
||
logger.log(`✅ Cancelled ${cancelResult.cancelledCount} old orders`)
|
||
}
|
||
|
||
logger.log(`🛡️ Placing new exit orders with SL at breakeven...`)
|
||
const orderResult = await placeExitOrders({
|
||
symbol: trade.symbol,
|
||
direction: trade.direction,
|
||
entryPrice: trade.entryPrice,
|
||
positionSizeUSD: trade.currentSize, // Runner size
|
||
stopLossPrice: trade.stopLossPrice, // At breakeven now
|
||
tp1Price: trade.tp2Price, // TP2 becomes new TP1 for runner
|
||
tp2Price: 0, // No TP2 for runner
|
||
tp1SizePercent: 0, // Close 0% at TP2 (activates trailing)
|
||
tp2SizePercent: 0, // No TP2
|
||
softStopPrice: 0,
|
||
hardStopPrice: 0,
|
||
})
|
||
|
||
if (orderResult.success) {
|
||
logger.log(`✅ Exit orders updated with SL at breakeven`)
|
||
} else {
|
||
console.error(`❌ Failed to update exit orders:`, orderResult.error)
|
||
}
|
||
} catch (error) {
|
||
console.error(`❌ Failed to update on-chain orders after TP1:`, error)
|
||
}
|
||
}
|
||
|
||
await this.saveTradeState(trade)
|
||
|
||
} else if (trade.tp1Hit && !trade.tp2Hit && reductionPercent >= 85) {
|
||
// TP2 fired (total should be ~95% closed, 5% runner left)
|
||
logger.log(`🎯 TP2 detected as filled! Reduction: ${reductionPercent.toFixed(1)}%`)
|
||
trade.tp2Hit = true
|
||
trade.currentSize = positionSizeUSD
|
||
trade.trailingStopActive = true
|
||
logger.log(`🏃 Runner active: $${positionSizeUSD.toFixed(2)} with ${this.config.trailingStopPercent}% trailing stop`)
|
||
|
||
await this.saveTradeState(trade)
|
||
|
||
// CRITICAL: Don't return early! Continue monitoring the runner position
|
||
// The trailing stop logic at line 732 needs to run
|
||
|
||
} else {
|
||
// Partial fill detected but unclear which TP - just update size
|
||
logger.log(`⚠️ Unknown partial fill detected - updating tracked size to $${positionSizeUSD.toFixed(2)}`)
|
||
trade.currentSize = positionSizeUSD
|
||
await this.saveTradeState(trade)
|
||
}
|
||
}
|
||
|
||
// CRITICAL: Check for entry price mismatch (NEW position opened)
|
||
// This can happen if user manually closed and opened a new position
|
||
// Only check if we haven't detected TP fills (entry price changes after partial closes on Drift)
|
||
if (!trade.tp1Hit && !trade.tp2Hit) {
|
||
const entryPriceDiff = Math.abs(position.entryPrice - trade.entryPrice)
|
||
const entryPriceDiffPercent = (entryPriceDiff / trade.entryPrice) * 100
|
||
|
||
if (entryPriceDiffPercent > 0.5) {
|
||
// Entry prices differ by >0.5% - this is a DIFFERENT position
|
||
logger.log(`⚠️ Position ${trade.symbol} entry mismatch: tracking $${trade.entryPrice.toFixed(4)} but found $${position.entryPrice.toFixed(4)}`)
|
||
logger.log(`🗑️ This is a different/newer position - removing old trade from monitoring`)
|
||
|
||
// Mark the old trade as closed (we lost track of it)
|
||
// Calculate approximate P&L using last known price
|
||
const profitPercent = this.calculateProfitPercent(
|
||
trade.entryPrice,
|
||
trade.lastPrice,
|
||
trade.direction
|
||
)
|
||
const accountPnLPercent = profitPercent * trade.leverage
|
||
const estimatedPnL = (trade.currentSize * profitPercent) / 100
|
||
|
||
logger.log(`💰 Estimated P&L for lost trade: ${profitPercent.toFixed(2)}% price → ${accountPnLPercent.toFixed(2)}% account → $${estimatedPnL.toFixed(2)} realized`)
|
||
|
||
try {
|
||
await updateTradeExit({
|
||
positionId: trade.positionId,
|
||
exitPrice: trade.lastPrice,
|
||
exitReason: 'SOFT_SL', // Unknown - just mark as closed
|
||
realizedPnL: estimatedPnL,
|
||
exitOrderTx: 'UNKNOWN_CLOSURE',
|
||
holdTimeSeconds: Math.floor((Date.now() - trade.entryTime) / 1000),
|
||
maxDrawdown: Math.abs(Math.min(0, trade.maxAdverseExcursion)),
|
||
maxGain: Math.max(0, trade.maxFavorableExcursion),
|
||
maxFavorableExcursion: trade.maxFavorableExcursion,
|
||
maxAdverseExcursion: trade.maxAdverseExcursion,
|
||
maxFavorablePrice: trade.maxFavorablePrice,
|
||
maxAdversePrice: trade.maxAdversePrice,
|
||
})
|
||
logger.log(`💾 Old trade marked as closed (lost tracking) with estimated P&L: $${estimatedPnL.toFixed(2)}`)
|
||
} catch (dbError) {
|
||
console.error('❌ Failed to save lost trade closure:', dbError)
|
||
}
|
||
|
||
// Remove from monitoring WITHOUT cancelling orders (they belong to the new position!)
|
||
logger.log(`🗑️ Removing old trade WITHOUT cancelling orders`)
|
||
this.activeTrades.delete(trade.id)
|
||
|
||
if (this.activeTrades.size === 0 && this.isMonitoring) {
|
||
this.stopMonitoring()
|
||
}
|
||
|
||
return
|
||
}
|
||
}
|
||
}
|
||
|
||
// CRITICAL: Skip external closure detection if close is already in progress (Nov 16, 2025)
|
||
// This prevents duplicate P&L compounding when close tx confirmed but Drift not yet propagated
|
||
// CRITICAL FIX (Dec 7, 2025): Extended timeout from 60s to 5 minutes
|
||
// Root cause: Drift state propagation can take MUCH longer than 60 seconds
|
||
// 60s timeout caused false "external closure" detection while position actually still closing
|
||
// Result: Position removed from monitoring prematurely, left unprotected for 90+ minutes
|
||
if (trade.closingInProgress) {
|
||
// Check if close has been stuck for >5 minutes (abnormal - Drift should propagate by then)
|
||
const timeInClosing = Date.now() - (trade.closeConfirmedAt || Date.now())
|
||
if (timeInClosing > 300000) { // 5 minutes instead of 60 seconds
|
||
logger.log(`⚠️ Close stuck in progress for ${(timeInClosing / 1000).toFixed(0)}s (5+ min) - allowing external closure check`)
|
||
logger.log(` This is ABNORMAL - Drift state should have propagated within 5 minutes`)
|
||
trade.closingInProgress = false // Reset flag to allow cleanup
|
||
} else {
|
||
// Normal case: Close confirmed recently, waiting for Drift propagation (can take up to 5 min)
|
||
// Skip external closure detection entirely to prevent duplicate P&L updates
|
||
logger.log(`🔒 Close in progress (${(timeInClosing / 1000).toFixed(0)}s) - skipping external closure check`)
|
||
// Continue to price calculations below (monitoring continues normally)
|
||
}
|
||
}
|
||
|
||
// CRITICAL FIX (Nov 20, 2025): Check if price hit TP2 BEFORE external closure detection
|
||
// This activates trailing stop even if position fully closes before we detect TP2
|
||
if (trade.tp1Hit && !trade.tp2Hit && !trade.closingInProgress) {
|
||
const reachedTP2 = this.shouldTakeProfit2(currentPrice, trade)
|
||
if (reachedTP2) {
|
||
// Calculate profit percent for logging
|
||
const profitPercent = this.calculateProfitPercent(
|
||
trade.entryPrice,
|
||
currentPrice,
|
||
trade.direction
|
||
)
|
||
|
||
logger.log(`🎊 TP2 PRICE REACHED: ${trade.symbol} at ${profitPercent.toFixed(2)}%`)
|
||
logger.log(` Activating trailing stop for runner protection`)
|
||
trade.tp2Hit = true
|
||
trade.trailingStopActive = true
|
||
|
||
// Initialize peak price for trailing if not set
|
||
if (trade.peakPrice === 0 ||
|
||
(trade.direction === 'long' && currentPrice > trade.peakPrice) ||
|
||
(trade.direction === 'short' && currentPrice < trade.peakPrice)) {
|
||
trade.peakPrice = currentPrice
|
||
}
|
||
|
||
// Save state
|
||
await this.saveTradeState(trade)
|
||
}
|
||
}
|
||
|
||
if ((position === null || position.size === 0) && !trade.closingInProgress) {
|
||
|
||
// CRITICAL FIX (Nov 24, 2025): IMMEDIATELY mark as closingInProgress
|
||
// This prevents ANY duplicate processing before DB update completes
|
||
trade.closingInProgress = true
|
||
trade.closeConfirmedAt = Date.now()
|
||
logger.log(`🔒 Marked ${trade.symbol} as closingInProgress to prevent duplicate external closure processing`)
|
||
|
||
// CRITICAL FIX (Nov 20, 2025): If TP1 already hit, this is RUNNER closure
|
||
// We should have been monitoring with trailing stop active
|
||
// Check if we should have had trailing stop protection
|
||
if (trade.tp1Hit && !trade.tp2Hit) {
|
||
logger.log(`⚠️ RUNNER CLOSED EXTERNALLY: ${trade.symbol}`)
|
||
logger.log(` TP1 hit: true, TP2 hit: false`)
|
||
logger.log(` This runner should have had trailing stop protection!`)
|
||
logger.log(` Likely cause: Monitoring detected full closure before TP2 price check`)
|
||
|
||
// Check if price reached TP2 - if so, trailing should have been active
|
||
const reachedTP2 = trade.direction === 'long'
|
||
? currentPrice >= (trade.tp2Price || 0)
|
||
: currentPrice <= (trade.tp2Price || 0)
|
||
|
||
if (reachedTP2) {
|
||
logger.log(` ⚠️ Price reached TP2 ($${trade.tp2Price?.toFixed(4)}) but tp2Hit was false!`)
|
||
logger.log(` Trailing stop should have been active but wasn't`)
|
||
} else {
|
||
logger.log(` Runner hit SL before reaching TP2 ($${trade.tp2Price?.toFixed(4)})`)
|
||
}
|
||
}
|
||
|
||
// CRITICAL: Use original position size for P&L calculation on external closures
|
||
// trade.currentSize may already be 0 if on-chain orders closed the position before
|
||
// Position Manager detected it, causing zero P&L bug
|
||
// HOWEVER: If this was a phantom trade (extreme size mismatch), set P&L to 0
|
||
|
||
// CRITICAL: Determine size for P&L calculation based on TP1 status
|
||
// If TP1 already hit, we're closing the RUNNER only (currentSize)
|
||
// If TP1 not hit, we're closing the FULL position (originalPositionSize)
|
||
const sizeForPnL = trade.tp1Hit ? trade.currentSize : trade.originalPositionSize
|
||
|
||
// Check if this was a phantom trade by looking at ORIGINAL size mismatch
|
||
// Phantom = position opened but size was <50% of expected FROM THE START
|
||
// DO NOT flag runners after TP1 as phantom!
|
||
const wasPhantom = !trade.tp1Hit && trade.currentSize > 0 && (trade.currentSize / trade.positionSize) < 0.5
|
||
|
||
logger.log(`📊 External closure detected - Position size tracking:`)
|
||
logger.log(` Original size: $${trade.positionSize.toFixed(2)}`)
|
||
logger.log(` Tracked current size: $${trade.currentSize.toFixed(2)}`)
|
||
logger.log(` Using for P&L calc: $${sizeForPnL.toFixed(2)} (full position - exit reason will determine TP1 vs SL)`)
|
||
if (wasPhantom) {
|
||
logger.log(` ⚠️ PHANTOM TRADE: Setting P&L to 0 (size mismatch >50%)`)
|
||
}
|
||
|
||
// CRITICAL FIX (Nov 26, 2025): Calculate P&L from actual entry/exit prices
|
||
// ALWAYS use entry price vs current price with the ACTUAL position size in USD
|
||
// DO NOT rely on Drift settledPnL - it's zero for closed positions
|
||
// DO NOT use token size - use the USD notional size from when position opened
|
||
let totalRealizedPnL = 0
|
||
let runnerProfitPercent = 0
|
||
|
||
if (!wasPhantom) {
|
||
// Calculate profit percentage from entry to current price
|
||
runnerProfitPercent = this.calculateProfitPercent(
|
||
trade.entryPrice,
|
||
currentPrice,
|
||
trade.direction
|
||
)
|
||
|
||
// CRITICAL: Use USD notional size, NOT token size
|
||
// sizeForPnL is already in USD from above calculation
|
||
totalRealizedPnL = (sizeForPnL * runnerProfitPercent) / 100
|
||
|
||
logger.log(` 💰 P&L calculation:`)
|
||
logger.log(` Entry: $${trade.entryPrice.toFixed(4)} → Exit: $${currentPrice.toFixed(4)}`)
|
||
logger.log(` Profit %: ${runnerProfitPercent.toFixed(3)}%`)
|
||
logger.log(` Position size: $${sizeForPnL.toFixed(2)}`)
|
||
logger.log(` Realized P&L: $${totalRealizedPnL.toFixed(2)}`)
|
||
} else {
|
||
logger.log(` Phantom trade P&L: $0.00`)
|
||
}
|
||
|
||
// Determine exit reason from P&L percentage and trade state
|
||
// Use actual profit percent to determine what order filled
|
||
let exitReason: 'TP1' | 'TP2' | 'SL' | 'SOFT_SL' | 'HARD_SL' | 'TRAILING_SL' = 'SL'
|
||
|
||
// CRITICAL (Nov 20, 2025): Check if trailing stop was active
|
||
// If so, this is a trailing stop exit, not regular SL
|
||
if (trade.tp2Hit && trade.trailingStopActive) {
|
||
logger.log(` 🏃 Runner closed with TRAILING STOP active`)
|
||
logger.log(` Peak price: $${trade.peakPrice.toFixed(4)}, Current: $${currentPrice.toFixed(4)}`)
|
||
|
||
// Check if price dropped from peak (trailing stop hit)
|
||
const isPullback = trade.direction === 'long'
|
||
? currentPrice < trade.peakPrice * 0.99 // More than 1% below peak
|
||
: currentPrice > trade.peakPrice * 1.01 // More than 1% above peak
|
||
|
||
if (isPullback) {
|
||
exitReason = 'TRAILING_SL' // Distinguish from regular SL (Nov 24, 2025)
|
||
logger.log(` ✅ Confirmed: Trailing stop hit (pulled back from peak)`)
|
||
} else {
|
||
// Very close to peak - might be emergency close or manual
|
||
exitReason = 'TP2' // Give credit for reaching runner profit target
|
||
logger.log(` ✅ Closed near peak - counting as TP2`)
|
||
}
|
||
} else if (runnerProfitPercent > 0.3) {
|
||
// Positive profit - was a TP order
|
||
if (runnerProfitPercent >= 1.2) {
|
||
// Large profit (>1.2%) - TP2 range
|
||
exitReason = 'TP2'
|
||
} else {
|
||
// Moderate profit (0.3-1.2%) - TP1 range
|
||
exitReason = 'TP1'
|
||
}
|
||
} else {
|
||
// Negative or tiny profit - was SL
|
||
exitReason = 'SL'
|
||
}
|
||
|
||
// Update database - CRITICAL: Only update once per trade!
|
||
const holdTimeSeconds = Math.floor((Date.now() - trade.entryTime) / 1000)
|
||
|
||
// CRITICAL BUG FIX: Mark trade as processed IMMEDIATELY to prevent duplicate updates
|
||
// Remove from monitoring BEFORE database update to prevent race condition
|
||
const tradeId = trade.id
|
||
|
||
// VERIFICATION: Check if already removed (would indicate duplicate processing attempt)
|
||
if (!this.activeTrades.has(tradeId)) {
|
||
logger.log(`⚠️ DUPLICATE PROCESSING PREVENTED: Trade ${tradeId} already removed from monitoring`)
|
||
logger.log(` This is the bug fix working - without it, we'd update DB again with compounded P&L`)
|
||
return // Already processed, don't update DB again
|
||
}
|
||
|
||
this.activeTrades.delete(tradeId)
|
||
logger.log(`🗑️ Removed trade ${tradeId} from monitoring (BEFORE DB update to prevent duplicates)`)
|
||
logger.log(` Active trades remaining: ${this.activeTrades.size}`)
|
||
|
||
// CRITICAL: Cancel all remaining orders for this position (ghost order cleanup)
|
||
// When position closes externally (on-chain SL/TP), TP/SL orders may remain active
|
||
// These ghost orders can trigger unintended positions if price moves to those levels
|
||
logger.log(`🗑️ Cancelling remaining orders for ${trade.symbol}...`)
|
||
try {
|
||
const { cancelAllOrders } = await import('../drift/orders')
|
||
const cancelResult = await cancelAllOrders(trade.symbol)
|
||
if (cancelResult.success) {
|
||
logger.log(`✅ Cancelled ${cancelResult.cancelledCount || 0} ghost orders`)
|
||
} else {
|
||
console.error(`⚠️ Failed to cancel orders: ${cancelResult.error}`)
|
||
}
|
||
} catch (cancelError) {
|
||
console.error('❌ Error cancelling ghost orders:', cancelError)
|
||
// Don't fail the trade closure if order cancellation fails
|
||
}
|
||
|
||
try {
|
||
await updateTradeExit({
|
||
positionId: trade.positionId,
|
||
exitPrice: currentPrice,
|
||
exitReason,
|
||
realizedPnL: totalRealizedPnL,
|
||
exitOrderTx: 'ON_CHAIN_ORDER',
|
||
holdTimeSeconds,
|
||
maxDrawdown: Math.abs(Math.min(0, trade.maxAdverseExcursion)),
|
||
maxGain: Math.max(0, trade.maxFavorableExcursion),
|
||
maxFavorableExcursion: trade.maxFavorableExcursion,
|
||
maxAdverseExcursion: trade.maxAdverseExcursion,
|
||
maxFavorablePrice: trade.maxFavorablePrice,
|
||
maxAdversePrice: trade.maxAdversePrice,
|
||
})
|
||
logger.log(`💾 External closure recorded: ${exitReason} at $${currentPrice} | P&L: $${totalRealizedPnL.toFixed(2)}`)
|
||
|
||
// CRITICAL FIX (Dec 3, 2025): Check revenge eligibility for external closures
|
||
// Bug Fix: External closures (on-chain SL orders) weren't checking if quality 85+ for revenge
|
||
// Solution: After DB save, check if this was a quality 85+ SL stop-out and record for revenge
|
||
const qualityScore = trade.signalQualityScore || 0
|
||
if (exitReason === 'SL' && qualityScore >= 85) {
|
||
console.log(`🔍 Quality ${qualityScore} SL stop-out (external) - checking revenge eligibility...`)
|
||
try {
|
||
const { getStopHuntTracker } = await import('./stop-hunt-tracker')
|
||
const stopHuntTracker = getStopHuntTracker()
|
||
await stopHuntTracker.recordStopHunt({
|
||
originalTradeId: trade.id,
|
||
symbol: trade.symbol,
|
||
direction: trade.direction,
|
||
stopHuntPrice: currentPrice,
|
||
originalEntryPrice: trade.entryPrice,
|
||
originalQualityScore: qualityScore,
|
||
originalADX: trade.adxAtEntry || 0,
|
||
originalATR: trade.atrAtEntry || 0,
|
||
stopLossAmount: Math.abs(totalRealizedPnL)
|
||
})
|
||
console.log(`🎯 Stop hunt recorded (external closure) - revenge window active for 4 hours`)
|
||
} catch (revengeError) {
|
||
console.error('⚠️ Failed to record stop hunt for revenge:', revengeError)
|
||
// Don't fail external closure if revenge recording fails
|
||
}
|
||
}
|
||
} catch (dbError) {
|
||
console.error('❌ Failed to save external closure:', dbError)
|
||
}
|
||
|
||
// CRITICAL FIX (Dec 7, 2025): Stop monitoring ONLY if Drift confirms no open positions
|
||
// Root cause: activeTrades.size === 0 doesn't guarantee Drift has no positions
|
||
// Scenario: PM processes false "external closure", removes trade, tries to stop monitoring
|
||
// But position actually still open on Drift (state lag)!
|
||
// Solution: Query Drift to confirm no positions before stopping monitoring
|
||
if (this.activeTrades.size === 0 && this.isMonitoring) {
|
||
logger.log(`🔍 No active trades in Position Manager - verifying Drift has no open positions...`)
|
||
|
||
try {
|
||
const driftService = getDriftService()
|
||
const allPositions = await driftService.getAllPositions()
|
||
const openPositions = allPositions.filter(p => p.size !== 0)
|
||
|
||
if (openPositions.length > 0) {
|
||
logger.log(`🚨 CRITICAL SAFETY CHECK TRIGGERED!`)
|
||
logger.log(` Position Manager: 0 active trades`)
|
||
logger.log(` Drift Protocol: ${openPositions.length} open positions!`)
|
||
logger.log(` MISMATCH DETECTED - keeping monitoring ACTIVE for safety`)
|
||
|
||
// Log details of orphaned positions
|
||
for (const pos of openPositions) {
|
||
const marketConfig = Object.values(await import('../../config/trading').then(m => ({
|
||
'SOL-PERP': m.getMarketConfig('SOL-PERP'),
|
||
'BTC-PERP': m.getMarketConfig('BTC-PERP'),
|
||
'ETH-PERP': m.getMarketConfig('ETH-PERP')
|
||
}))).find(cfg => cfg.driftMarketIndex === pos.marketIndex)
|
||
|
||
logger.log(` - ${marketConfig?.symbol || `Market ${pos.marketIndex}`}: ${pos.size} tokens`)
|
||
}
|
||
|
||
logger.log(` Recommendation: Check /api/trading/positions and manually close if needed`)
|
||
logger.log(` DriftStateVerifier will attempt auto-recovery on next check`)
|
||
|
||
// DON'T stop monitoring - let DriftStateVerifier handle recovery
|
||
return
|
||
}
|
||
|
||
logger.log(`✅ Confirmed: Drift has no open positions, safe to stop monitoring`)
|
||
this.stopMonitoring()
|
||
} catch (error) {
|
||
console.error('❌ Error checking Drift positions before stop:', error)
|
||
logger.log(`⚠️ Could not verify Drift state - keeping monitoring ACTIVE for safety`)
|
||
// If we can't verify, DON'T stop monitoring (fail-safe)
|
||
}
|
||
}
|
||
|
||
return
|
||
}
|
||
|
||
// Position still exists on Drift - check for size mismatches
|
||
if (position && position.size !== 0 && !trade.closingInProgress) {
|
||
// CRITICAL: Convert position.size (base asset tokens) to USD for comparison
|
||
const positionSizeUSD = Math.abs(position.size) * currentPrice
|
||
|
||
// Position exists but size mismatch (partial close by TP1?)
|
||
if (positionSizeUSD < trade.currentSize * 0.95) { // 5% tolerance
|
||
logger.log(`⚠️ Position size mismatch: expected $${trade.currentSize.toFixed(2)}, got $${positionSizeUSD.toFixed(2)}`)
|
||
|
||
// CRITICAL: Check if position direction changed (signal flip, not TP1!)
|
||
const positionDirection = position.side === 'long' ? 'long' : 'short'
|
||
if (positionDirection !== trade.direction) {
|
||
logger.log(`🔄 DIRECTION CHANGE DETECTED: ${trade.direction} → ${positionDirection}`)
|
||
logger.log(` This is a signal flip, not TP1! Closing old position as manual.`)
|
||
|
||
// Calculate actual P&L on full position
|
||
const profitPercent = this.calculateProfitPercent(trade.entryPrice, currentPrice, trade.direction)
|
||
const actualPnL = (trade.positionSize * profitPercent) / 100
|
||
|
||
try {
|
||
const holdTimeSeconds = Math.floor((Date.now() - trade.entryTime) / 1000)
|
||
await updateTradeExit({
|
||
positionId: trade.positionId,
|
||
exitPrice: currentPrice,
|
||
exitReason: 'manual',
|
||
realizedPnL: actualPnL,
|
||
exitOrderTx: 'SIGNAL_FLIP',
|
||
holdTimeSeconds,
|
||
maxDrawdown: Math.abs(Math.min(0, trade.maxAdverseExcursion)),
|
||
maxGain: Math.max(0, trade.maxFavorableExcursion),
|
||
maxFavorableExcursion: trade.maxFavorableExcursion,
|
||
maxAdverseExcursion: trade.maxAdverseExcursion,
|
||
maxFavorablePrice: trade.maxFavorablePrice,
|
||
maxAdversePrice: trade.maxAdversePrice,
|
||
})
|
||
logger.log(`💾 Signal flip closure recorded: P&L $${actualPnL.toFixed(2)}`)
|
||
} catch (dbError) {
|
||
console.error('❌ Failed to save signal flip closure:', dbError)
|
||
}
|
||
|
||
await this.removeTrade(trade.id)
|
||
return
|
||
}
|
||
|
||
// CRITICAL: If mismatch is extreme (>50%), this is a phantom trade
|
||
const sizeRatio = positionSizeUSD / trade.currentSize
|
||
if (sizeRatio < 0.5) {
|
||
logger.log(`🚨 EXTREME SIZE MISMATCH (${(sizeRatio * 100).toFixed(1)}%) - Closing phantom trade`)
|
||
logger.log(` Expected: $${trade.currentSize.toFixed(2)}`)
|
||
logger.log(` Actual: $${positionSizeUSD.toFixed(2)}`)
|
||
|
||
// Close as phantom trade
|
||
try {
|
||
const holdTimeSeconds = Math.floor((Date.now() - trade.entryTime) / 1000)
|
||
await updateTradeExit({
|
||
positionId: trade.positionId,
|
||
exitPrice: currentPrice,
|
||
exitReason: 'manual',
|
||
realizedPnL: 0,
|
||
exitOrderTx: 'AUTO_CLEANUP',
|
||
holdTimeSeconds,
|
||
maxDrawdown: Math.abs(Math.min(0, trade.maxAdverseExcursion)),
|
||
maxGain: Math.max(0, trade.maxFavorableExcursion),
|
||
maxFavorableExcursion: trade.maxFavorableExcursion,
|
||
maxAdverseExcursion: trade.maxAdverseExcursion,
|
||
maxFavorablePrice: trade.maxFavorablePrice,
|
||
maxAdversePrice: trade.maxAdversePrice,
|
||
})
|
||
logger.log(`💾 Phantom trade closed`)
|
||
} catch (dbError) {
|
||
console.error('❌ Failed to close phantom trade:', dbError)
|
||
}
|
||
|
||
await this.removeTrade(trade.id)
|
||
return
|
||
}
|
||
|
||
// CRITICAL FIX (Nov 30, 2025): MUST verify price reached TP1 before setting flag
|
||
// BUG: Setting tp1Hit=true based ONLY on size mismatch caused premature order cancellation
|
||
// Size reduction could be: partial fill, slippage, external action, RPC staleness
|
||
// ONLY set tp1Hit when BOTH conditions met: size reduced AND price target reached
|
||
|
||
const tp1PriceReached = this.shouldTakeProfit1(currentPrice, trade)
|
||
|
||
if (tp1PriceReached) {
|
||
logger.log(`✅ TP1 VERIFIED: Size mismatch + price target reached`)
|
||
logger.log(` Size: $${trade.currentSize.toFixed(2)} → $${positionSizeUSD.toFixed(2)} (${((positionSizeUSD / trade.currentSize) * 100).toFixed(1)}%)`)
|
||
logger.log(` Price: ${currentPrice.toFixed(4)} crossed TP1 target ${trade.tp1Price.toFixed(4)}`)
|
||
|
||
// Update current size to match reality (already in USD)
|
||
trade.currentSize = positionSizeUSD
|
||
trade.tp1Hit = true
|
||
await this.saveTradeState(trade)
|
||
|
||
logger.log(`🎉 TP1 HIT: ${trade.symbol} via on-chain order (detected by size reduction)`)
|
||
} else {
|
||
logger.log(`⚠️ Size reduced but TP1 price NOT reached yet - NOT triggering TP1 logic`)
|
||
logger.log(` Current: ${currentPrice.toFixed(4)}, TP1 target: ${trade.tp1Price.toFixed(4)} (${trade.direction === 'long' ? 'need higher' : 'need lower'})`)
|
||
logger.log(` Size: $${trade.currentSize.toFixed(2)} → $${positionSizeUSD.toFixed(2)} (${((positionSizeUSD / trade.currentSize) * 100).toFixed(1)}%)`)
|
||
logger.log(` Likely: Partial fill, slippage, or external action`)
|
||
|
||
// Update tracked size but DON'T trigger TP1 logic
|
||
trade.currentSize = positionSizeUSD
|
||
await this.saveTradeState(trade)
|
||
|
||
// Continue monitoring - TP1 logic will trigger when price actually crosses target
|
||
}
|
||
}
|
||
} // End of: if (position && position.size !== 0 && !trade.closingInProgress)
|
||
|
||
} catch (error) {
|
||
// If we can't check position, continue with monitoring (don't want to false-positive)
|
||
// This can happen briefly during startup while Drift service initializes
|
||
if ((error as Error).message?.includes('not initialized')) {
|
||
// Silent - expected during initialization
|
||
} else {
|
||
console.error(`⚠️ Could not verify on-chain position for ${trade.symbol}:`, error)
|
||
}
|
||
}
|
||
|
||
// Calculate P&L
|
||
const profitPercent = this.calculateProfitPercent(
|
||
trade.entryPrice,
|
||
currentPrice,
|
||
trade.direction
|
||
)
|
||
|
||
const currentPnLDollars = (trade.currentSize * profitPercent) / 100
|
||
trade.unrealizedPnL = currentPnLDollars
|
||
|
||
// Track peak P&L (MFE - Maximum Favorable Excursion)
|
||
if (trade.unrealizedPnL > trade.peakPnL) {
|
||
trade.peakPnL = trade.unrealizedPnL
|
||
}
|
||
|
||
// Track MAE/MFE in PERCENTAGE (not dollars!)
|
||
// CRITICAL FIX (Nov 23, 2025): Schema expects % (0.48 = 0.48%), not dollar amounts
|
||
// Bug was storing $64.08 when actual was 0.48%, causing 100× inflation in analysis
|
||
if (profitPercent > trade.maxFavorableExcursion) {
|
||
trade.maxFavorableExcursion = profitPercent
|
||
trade.maxFavorablePrice = currentPrice
|
||
}
|
||
if (profitPercent < trade.maxAdverseExcursion) {
|
||
trade.maxAdverseExcursion = profitPercent
|
||
trade.maxAdversePrice = currentPrice
|
||
}
|
||
|
||
// Track peak price for trailing stop
|
||
if (trade.direction === 'long') {
|
||
if (currentPrice > trade.peakPrice) {
|
||
trade.peakPrice = currentPrice
|
||
}
|
||
} else {
|
||
if (currentPrice < trade.peakPrice || trade.peakPrice === 0) {
|
||
trade.peakPrice = currentPrice
|
||
}
|
||
}
|
||
|
||
// LAYER 3: Ghost detection during normal monitoring (Nov 15, 2025)
|
||
// Every 20 price checks (~40 seconds), verify position still exists on Drift
|
||
// This catches ghosts quickly without requiring 5-minute validation timer
|
||
if (trade.priceCheckCount % 20 === 0) {
|
||
try {
|
||
const driftService = getDriftService()
|
||
if (driftService && (driftService as any).isInitialized) {
|
||
const marketConfig = getMarketConfig(trade.symbol)
|
||
const position = await driftService.getPosition(marketConfig.driftMarketIndex)
|
||
|
||
// Position missing on Drift but we're still tracking it = ghost
|
||
if (!position || Math.abs(position.size) < 0.01) {
|
||
logger.log(`🔴 GHOST DETECTED in monitoring loop: ${trade.symbol}`)
|
||
logger.log(` Position Manager thinks it's open, but Drift shows closed`)
|
||
await this.handleExternalClosure(trade, 'Ghost detected during monitoring')
|
||
return // Exit monitoring for this position
|
||
}
|
||
}
|
||
} catch (checkError) {
|
||
// Silently skip this check on RPC errors - don't spam logs
|
||
}
|
||
}
|
||
|
||
// Log status every 10 checks (~20 seconds)
|
||
if (trade.priceCheckCount % 10 === 0) {
|
||
logger.log(
|
||
`📊 ${trade.symbol} | ` +
|
||
`Price: ${currentPrice.toFixed(4)} | ` +
|
||
`P&L: ${profitPercent.toFixed(2)}% | ` +
|
||
`Unrealized: $${trade.unrealizedPnL.toFixed(2)} | ` +
|
||
`Peak: $${trade.peakPnL.toFixed(2)} | ` +
|
||
`MFE: $${trade.maxFavorableExcursion.toFixed(2)} | ` +
|
||
`MAE: $${trade.maxAdverseExcursion.toFixed(2)}`
|
||
)
|
||
}
|
||
|
||
// Check exit conditions (in order of priority)
|
||
|
||
// 1. Emergency stop (-2%)
|
||
if (this.shouldEmergencyStop(currentPrice, trade)) {
|
||
logger.log(`🚨 EMERGENCY STOP: ${trade.symbol}`)
|
||
await this.executeExit(trade, 100, 'emergency', currentPrice)
|
||
return
|
||
}
|
||
|
||
// 2. Stop loss (BEFORE TP1)
|
||
if (!trade.tp1Hit && this.shouldStopLoss(currentPrice, trade)) {
|
||
logger.log(`🔴 STOP LOSS: ${trade.symbol} at ${profitPercent.toFixed(2)}%`)
|
||
await this.executeExit(trade, 100, 'SL', currentPrice)
|
||
return
|
||
}
|
||
|
||
// 2b. CRITICAL: Runner stop loss (AFTER TP1, BEFORE TP2)
|
||
// This protects the runner position after TP1 closes main position
|
||
if (trade.tp1Hit && !trade.tp2Hit && this.shouldStopLoss(currentPrice, trade)) {
|
||
logger.log(`🔴 RUNNER STOP LOSS: ${trade.symbol} at ${profitPercent.toFixed(2)}% (profit lock triggered)`)
|
||
await this.executeExit(trade, 100, 'SL', currentPrice)
|
||
return
|
||
}
|
||
|
||
// 3. Take profit 1 (closes configured %)
|
||
if (!trade.tp1Hit && this.shouldTakeProfit1(currentPrice, trade)) {
|
||
logger.log(`🎉 TP1 HIT: ${trade.symbol} at ${profitPercent.toFixed(2)}%`)
|
||
|
||
// CRITICAL: Set flag BEFORE async executeExit to prevent race condition
|
||
// Multiple monitoring loops can trigger TP1 simultaneously if we wait until after
|
||
trade.tp1Hit = true
|
||
|
||
await this.executeExit(trade, this.config.takeProfit1SizePercent, 'TP1', currentPrice)
|
||
trade.currentSize = trade.positionSize * ((100 - this.config.takeProfit1SizePercent) / 100)
|
||
|
||
// ADX-based runner SL positioning (Nov 19, 2025)
|
||
// Strong trends get more room, weak trends protect capital
|
||
let runnerSlPercent: number
|
||
const adx = trade.adxAtEntry || 0
|
||
|
||
if (adx < 20) {
|
||
runnerSlPercent = 0 // Weak trend: breakeven, preserve capital
|
||
} else if (adx < 25) {
|
||
runnerSlPercent = -0.3 // Moderate trend: some room
|
||
} else {
|
||
runnerSlPercent = -0.55 // Strong trend: full retracement room
|
||
}
|
||
|
||
const newStopLossPrice = this.calculatePrice(
|
||
trade.entryPrice,
|
||
runnerSlPercent,
|
||
trade.direction
|
||
)
|
||
trade.stopLossPrice = newStopLossPrice
|
||
trade.slMovedToBreakeven = true
|
||
|
||
logger.log(`🔒 ADX-based runner SL: ${adx.toFixed(1)} → ${runnerSlPercent}% (${this.config.takeProfit1SizePercent}% closed, ${100 - this.config.takeProfit1SizePercent}% remaining): ${newStopLossPrice.toFixed(4)}`)
|
||
|
||
// CRITICAL: Cancel old on-chain SL orders and place new ones at updated price
|
||
// BUT: Only if this is the ONLY active trade on this symbol
|
||
// Multiple positions on same symbol = can't distinguish which orders belong to which trade
|
||
try {
|
||
const otherTradesOnSymbol = Array.from(this.activeTrades.values()).filter(
|
||
t => t.symbol === trade.symbol && t.id !== trade.id
|
||
)
|
||
|
||
if (otherTradesOnSymbol.length > 0) {
|
||
logger.log(`⚠️ Multiple trades on ${trade.symbol} detected (${otherTradesOnSymbol.length + 1} total)`)
|
||
logger.log(`⚠️ Skipping order cancellation to avoid wiping other positions' orders`)
|
||
logger.log(`⚠️ Relying on Position Manager software monitoring for remaining ${100 - this.config.takeProfit1SizePercent}%`)
|
||
} else {
|
||
logger.log('🗑️ Cancelling old stop loss orders...')
|
||
const { cancelAllOrders, placeExitOrders } = await import('../drift/orders')
|
||
const cancelResult = await cancelAllOrders(trade.symbol)
|
||
if (cancelResult.success) {
|
||
logger.log(`✅ Cancelled ${cancelResult.cancelledCount || 0} old orders`)
|
||
|
||
// Place ONLY new SL orders at breakeven/profit level for remaining position
|
||
// DO NOT place TP2 order - trailing stop is software-only (Position Manager monitors)
|
||
logger.log(`🛡️ Placing only SL orders at $${newStopLossPrice.toFixed(4)} for remaining position...`)
|
||
logger.log(` TP2 at $${trade.tp2Price.toFixed(4)} is software-monitored only (activates trailing stop)`)
|
||
const exitOrdersResult = await placeExitOrders({
|
||
symbol: trade.symbol,
|
||
positionSizeUSD: trade.currentSize,
|
||
entryPrice: trade.entryPrice,
|
||
tp1Price: trade.tp2Price, // Dummy value, won't be used (tp1SizePercent=0)
|
||
tp2Price: trade.tp2Price, // Dummy value, won't be used (tp2SizePercent=0)
|
||
stopLossPrice: newStopLossPrice,
|
||
tp1SizePercent: 0, // No TP1 order
|
||
tp2SizePercent: 0, // No TP2 order - trailing stop is software-only
|
||
direction: trade.direction,
|
||
useDualStops: this.config.useDualStops,
|
||
softStopPrice: trade.direction === 'long'
|
||
? newStopLossPrice * 1.005 // 0.5% above for long
|
||
: newStopLossPrice * 0.995, // 0.5% below for short
|
||
hardStopPrice: newStopLossPrice,
|
||
})
|
||
|
||
if (exitOrdersResult.success) {
|
||
logger.log('✅ New SL orders placed on-chain at updated price')
|
||
} else {
|
||
console.error('❌ Failed to place new SL orders:', exitOrdersResult.error)
|
||
}
|
||
}
|
||
}
|
||
} catch (error) {
|
||
console.error('❌ Failed to update on-chain SL orders:', error)
|
||
// Don't fail the TP1 exit if SL update fails - software monitoring will handle it
|
||
}
|
||
|
||
// Save state after TP1
|
||
await this.saveTradeState(trade)
|
||
return
|
||
}
|
||
|
||
// 4. Profit lock trigger
|
||
if (
|
||
trade.tp1Hit &&
|
||
!trade.slMovedToProfit &&
|
||
profitPercent >= this.config.profitLockTriggerPercent
|
||
) {
|
||
logger.log(`🔐 Profit lock trigger: ${trade.symbol}`)
|
||
|
||
trade.stopLossPrice = this.calculatePrice(
|
||
trade.entryPrice,
|
||
this.config.profitLockPercent,
|
||
trade.direction
|
||
)
|
||
trade.slMovedToProfit = true
|
||
|
||
logger.log(`🎯 SL moved to +${this.config.profitLockPercent}%: ${trade.stopLossPrice.toFixed(4)}`)
|
||
|
||
// Save state after profit lock
|
||
await this.saveTradeState(trade)
|
||
}
|
||
|
||
// CRITICAL: Check stop loss for runner (after TP1, before TP2)
|
||
if (trade.tp1Hit && !trade.tp2Hit && this.shouldStopLoss(currentPrice, trade)) {
|
||
logger.log(`🔴 RUNNER STOP LOSS: ${trade.symbol} at ${profitPercent.toFixed(2)}% (profit lock triggered)`)
|
||
await this.executeExit(trade, 100, 'SL', currentPrice)
|
||
return
|
||
}
|
||
|
||
// 5. Take profit 2 (remaining position)
|
||
if (trade.tp1Hit && !trade.tp2Hit && this.shouldTakeProfit2(currentPrice, trade)) {
|
||
logger.log(`🎊 TP2 HIT: ${trade.symbol} at ${profitPercent.toFixed(2)}%`)
|
||
|
||
// CRITICAL: Set flag BEFORE any async operations to prevent race condition
|
||
trade.tp2Hit = true
|
||
|
||
// Calculate how much to close based on TP2 size percent
|
||
const percentToClose = this.config.takeProfit2SizePercent
|
||
|
||
// CRITICAL FIX: If percentToClose is 0, don't call executeExit (would close 100% due to minOrderSize)
|
||
// Instead, just mark TP2 as hit and activate trailing stop on full remaining position
|
||
if (percentToClose === 0) {
|
||
trade.trailingStopActive = true // Activate trailing stop immediately
|
||
|
||
logger.log(`🏃 TP2-as-Runner activated: ${((trade.currentSize / trade.positionSize) * 100).toFixed(1)}% remaining with trailing stop`)
|
||
logger.log(`📊 No position closed at TP2 - full ${trade.currentSize.toFixed(2)} USD remains as runner`)
|
||
|
||
// Save state after TP2
|
||
await this.saveTradeState(trade)
|
||
|
||
return
|
||
}
|
||
|
||
// If percentToClose > 0, execute partial close
|
||
await this.executeExit(trade, percentToClose, 'TP2', currentPrice)
|
||
|
||
// If some position remains, update size and activate trailing stop
|
||
if (percentToClose < 100) {
|
||
trade.currentSize = trade.currentSize * ((100 - percentToClose) / 100)
|
||
|
||
logger.log(`🏃 Runner activated: ${((trade.currentSize / trade.positionSize) * 100).toFixed(1)}% remaining with trailing stop`)
|
||
|
||
// Save state after TP2
|
||
await this.saveTradeState(trade)
|
||
}
|
||
|
||
return
|
||
}
|
||
|
||
// 6. Trailing stop for runner (after TP2)
|
||
if (trade.tp2Hit && this.config.useTrailingStop) {
|
||
// Check if trailing stop should be activated
|
||
if (!trade.trailingStopActive && profitPercent >= this.config.trailingStopActivation) {
|
||
trade.trailingStopActive = true
|
||
logger.log(`🎯 Trailing stop activated at +${profitPercent.toFixed(2)}%`)
|
||
}
|
||
|
||
// If trailing stop is active, adjust SL dynamically
|
||
if (trade.trailingStopActive) {
|
||
// PHASE 7.3: 1-Minute Adaptive TP/SL (Nov 27, 2025)
|
||
// Query fresh 1-minute ADX data and adjust trailing stop based on trend strength changes
|
||
let currentADX = trade.adxAtEntry || 0
|
||
let adxChange = 0
|
||
let usingFreshData = false
|
||
|
||
try {
|
||
const marketCache = getMarketDataCache()
|
||
const freshData = marketCache.get(trade.symbol)
|
||
|
||
if (freshData && freshData.adx) {
|
||
currentADX = freshData.adx
|
||
adxChange = currentADX - (trade.adxAtEntry || 0)
|
||
usingFreshData = true
|
||
|
||
logger.log(`📊 1-min ADX update: Entry ${(trade.adxAtEntry || 0).toFixed(1)} → Current ${currentADX.toFixed(1)} (${adxChange >= 0 ? '+' : ''}${adxChange.toFixed(1)} change)`)
|
||
}
|
||
} catch (error) {
|
||
logger.log(`⚠️ Could not fetch fresh ADX data, using entry ADX: ${error}`)
|
||
}
|
||
|
||
// Calculate ATR-based trailing distance with ADAPTIVE ADX multiplier
|
||
let trailingDistancePercent: number
|
||
|
||
if (trade.atrAtEntry && trade.atrAtEntry > 0) {
|
||
// Start with base ATR multiplier
|
||
let trailMultiplier = this.config.trailingStopAtrMultiplier
|
||
|
||
// ADAPTIVE ADX-based trend strength adjustment (Nov 27, 2025)
|
||
// Uses CURRENT 1-minute ADX if available, falls back to entry ADX
|
||
if (currentADX > 0) {
|
||
if (currentADX > 30) {
|
||
// Very strong trend (ADX > 30): 50% wider trail
|
||
trailMultiplier *= 1.5
|
||
logger.log(`📈 ${usingFreshData ? '1-min' : 'Entry'} ADX very strong (${currentADX.toFixed(1)}): Trail multiplier ${this.config.trailingStopAtrMultiplier}x → ${trailMultiplier.toFixed(2)}x`)
|
||
} else if (currentADX > 25) {
|
||
// Strong trend (ADX 25-30): 25% wider trail
|
||
trailMultiplier *= 1.25
|
||
logger.log(`📈 ${usingFreshData ? '1-min' : 'Entry'} ADX strong (${currentADX.toFixed(1)}): Trail multiplier ${this.config.trailingStopAtrMultiplier}x → ${trailMultiplier.toFixed(2)}x`)
|
||
}
|
||
// Else: weak/moderate trend, use base multiplier
|
||
|
||
// ACCELERATION BONUS: If ADX increased significantly, widen trail even more
|
||
if (usingFreshData && adxChange > 5) {
|
||
const oldMultiplier = trailMultiplier
|
||
trailMultiplier *= 1.3
|
||
logger.log(`🚀 ADX acceleration (+${adxChange.toFixed(1)} points): Trail multiplier ${oldMultiplier.toFixed(2)}x → ${trailMultiplier.toFixed(2)}x`)
|
||
}
|
||
|
||
// DECELERATION PENALTY: If ADX decreased significantly, tighten trail
|
||
if (usingFreshData && adxChange < -3) {
|
||
const oldMultiplier = trailMultiplier
|
||
trailMultiplier *= 0.7
|
||
logger.log(`⚠️ ADX deceleration (${adxChange.toFixed(1)} points): Trail multiplier ${oldMultiplier.toFixed(2)}x → ${trailMultiplier.toFixed(2)}x (tighter to protect)`)
|
||
}
|
||
}
|
||
|
||
// Profit acceleration: bigger profit = wider trail
|
||
if (profitPercent > 2.0) {
|
||
const oldMultiplier = trailMultiplier
|
||
trailMultiplier *= 1.3
|
||
logger.log(`💰 Large profit (${profitPercent.toFixed(2)}%): Trail multiplier ${oldMultiplier.toFixed(2)}x → ${trailMultiplier.toFixed(2)}x`)
|
||
}
|
||
|
||
// ATR-based: Use ATR% * adjusted multiplier
|
||
const atrPercent = (trade.atrAtEntry / currentPrice) * 100
|
||
const rawDistance = atrPercent * trailMultiplier
|
||
|
||
// Clamp between min and max
|
||
trailingDistancePercent = Math.max(
|
||
this.config.trailingStopMinPercent,
|
||
Math.min(this.config.trailingStopMaxPercent, rawDistance)
|
||
)
|
||
|
||
logger.log(`📊 Adaptive trailing: ATR ${trade.atrAtEntry.toFixed(4)} (${atrPercent.toFixed(2)}%) × ${trailMultiplier.toFixed(2)}x = ${trailingDistancePercent.toFixed(2)}%`)
|
||
} else {
|
||
// Fallback to configured legacy percent with min/max clamping
|
||
trailingDistancePercent = Math.max(
|
||
this.config.trailingStopMinPercent,
|
||
Math.min(this.config.trailingStopMaxPercent, this.config.trailingStopPercent)
|
||
)
|
||
|
||
logger.log(`⚠️ No ATR data, using fallback: ${trailingDistancePercent.toFixed(2)}%`)
|
||
}
|
||
|
||
const trailingStopPrice = this.calculatePrice(
|
||
trade.peakPrice,
|
||
-trailingDistancePercent, // Trail below peak
|
||
trade.direction
|
||
)
|
||
|
||
// Only move SL up (for long) or down (for short), never backwards
|
||
const shouldUpdate = trade.direction === 'long'
|
||
? trailingStopPrice > trade.stopLossPrice
|
||
: trailingStopPrice < trade.stopLossPrice
|
||
|
||
if (shouldUpdate) {
|
||
const oldSL = trade.stopLossPrice
|
||
trade.stopLossPrice = trailingStopPrice
|
||
|
||
logger.log(`📈 Trailing SL updated: ${oldSL.toFixed(4)} → ${trailingStopPrice.toFixed(4)} (${trailingDistancePercent.toFixed(2)}% below peak $${trade.peakPrice.toFixed(4)})`)
|
||
|
||
// Save state after trailing SL update (every 10 updates to avoid spam)
|
||
if (trade.priceCheckCount % 10 === 0) {
|
||
await this.saveTradeState(trade)
|
||
}
|
||
}
|
||
|
||
// Check if trailing stop hit
|
||
if (this.shouldStopLoss(currentPrice, trade)) {
|
||
logger.log(`🔴 TRAILING STOP HIT: ${trade.symbol} at ${profitPercent.toFixed(2)}%`)
|
||
await this.executeExit(trade, 100, 'TRAILING_SL', currentPrice)
|
||
return
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Execute exit (close position)
|
||
*
|
||
* Rate limit handling: If 429 error occurs, marks trade for retry
|
||
* instead of removing it from monitoring (prevents orphaned positions)
|
||
*
|
||
* CRITICAL FIX (Dec 2, 2025): Atomic deduplication at function entry
|
||
* Bug: Multiple monitoring loops detect SL/TP condition simultaneously
|
||
* - All call executeExit() before any can mark position as closing
|
||
* - Race condition in later removeTrade() call
|
||
* - Each execution sends Telegram notification
|
||
* - P&L values compound across notifications (16 duplicates, 796x inflation)
|
||
* Fix: Delete from activeTrades FIRST using atomic Map.delete()
|
||
* - Only first caller gets wasInMap=true, others get false and return
|
||
* - Prevents duplicate database updates, notifications, P&L compounding
|
||
* - Same pattern as ghost detection fix (handleExternalClosure)
|
||
*/
|
||
private async executeExit(
|
||
trade: ActiveTrade,
|
||
percentToClose: number,
|
||
reason: ExitResult['reason'],
|
||
currentPrice: number
|
||
): Promise<void> {
|
||
// CRITICAL FIX (Dec 2, 2025): Atomic deduplication for full closes
|
||
// For partial closes (TP1), we DON'T delete yet (position still monitored for TP2)
|
||
// For full closes (100%), delete FIRST to prevent duplicate execution
|
||
if (percentToClose >= 100) {
|
||
const tradeId = trade.id
|
||
const wasInMap = this.activeTrades.delete(tradeId)
|
||
|
||
if (!wasInMap) {
|
||
logger.log(`⚠️ DUPLICATE EXIT PREVENTED: ${tradeId} already processing ${reason}`)
|
||
logger.log(` This prevents duplicate Telegram notifications with compounding P&L`)
|
||
return
|
||
}
|
||
|
||
logger.log(`🗑️ Removed ${trade.symbol} from monitoring (${reason}) - atomic deduplication applied`)
|
||
}
|
||
|
||
try {
|
||
logger.log(`🔴 Executing ${reason} for ${trade.symbol} (${percentToClose}%)`)
|
||
|
||
const result = await closePosition({
|
||
symbol: trade.symbol,
|
||
percentToClose,
|
||
slippageTolerance: this.config.slippageTolerance,
|
||
})
|
||
|
||
if (!result.success) {
|
||
const errorMsg = result.error || 'Unknown error'
|
||
|
||
// Check if it's a rate limit error
|
||
if (errorMsg.includes('429') || errorMsg.toLowerCase().includes('rate limit')) {
|
||
console.error(`⚠️ Rate limited while closing ${trade.symbol} - will retry on next price update`)
|
||
|
||
// LAYER 2: Death spiral detector (Nov 15, 2025)
|
||
// If we've failed 20+ times, check Drift API to see if it's a ghost position
|
||
if (trade.priceCheckCount > 20 && !trade.closingInProgress) {
|
||
try {
|
||
const driftService = getDriftService()
|
||
const marketConfig = getMarketConfig(trade.symbol)
|
||
const position = await driftService.getPosition(marketConfig.driftMarketIndex)
|
||
|
||
// If position doesn't exist on Drift, it's a ghost - remove immediately
|
||
if (!position || Math.abs(position.size) < 0.01) {
|
||
logger.log(`🔴 LAYER 2: Ghost detected after ${trade.priceCheckCount} failures`)
|
||
logger.log(` Drift shows position closed/missing - removing from monitoring`)
|
||
|
||
// CRITICAL: Mark as closing to prevent duplicate processing
|
||
trade.closingInProgress = true
|
||
trade.closeConfirmedAt = Date.now()
|
||
|
||
await this.handleExternalClosure(trade, 'Layer 2: Ghost detected via Drift API')
|
||
return
|
||
} else {
|
||
logger.log(` Position verified on Drift (size: ${position.size}) - will keep retrying`)
|
||
}
|
||
} catch (checkError) {
|
||
console.error(` Could not verify position on Drift:`, checkError)
|
||
}
|
||
}
|
||
|
||
// DON'T remove trade from monitoring - let it retry naturally
|
||
// The retry logic in closePosition() already handled 3 attempts
|
||
// Next price update will trigger another exit attempt
|
||
return
|
||
}
|
||
|
||
console.error(`❌ Failed to close ${trade.symbol}:`, errorMsg)
|
||
return
|
||
}
|
||
|
||
// CRITICAL: Check if position needs verification (Nov 16, 2025)
|
||
// If close transaction confirmed but Drift still shows position open,
|
||
// DON'T mark as closed yet - keep monitoring until Drift confirms
|
||
if ((result as any).needsVerification) {
|
||
logger.log(`⚠️ Close transaction confirmed but position still exists on Drift`)
|
||
logger.log(` Keeping ${trade.symbol} in monitoring until Drift confirms closure`)
|
||
logger.log(` Ghost detection will handle final cleanup once Drift updates`)
|
||
|
||
// CRITICAL: Mark as "closing in progress" to prevent duplicate external closure detection
|
||
// Without this flag, the monitoring loop detects position as "externally closed"
|
||
// every 2 seconds and adds P&L repeatedly, causing 20x compounding bug
|
||
trade.closingInProgress = true
|
||
trade.closeConfirmedAt = Date.now()
|
||
logger.log(`🔒 Marked as closing in progress - external closure detection disabled`)
|
||
|
||
// Keep monitoring - ghost detection will eventually see it's closed
|
||
return
|
||
}
|
||
|
||
// CRITICAL FIX (Dec 15, 2025): Accumulate P&L for BOTH partial and full closes
|
||
// Bug was: TP1 (60% close) never updated trade.realizedPnL, only TP2 (100% close) did
|
||
// Result: Database only showed TP2 runner profit, missing TP1 profit
|
||
trade.realizedPnL += result.realizedPnL || 0
|
||
logger.log(`💰 P&L accumulated: +$${(result.realizedPnL || 0).toFixed(2)} | Total: $${trade.realizedPnL.toFixed(2)}`)
|
||
|
||
// Update trade state
|
||
if (percentToClose >= 100) {
|
||
// Full close - remove from monitoring
|
||
|
||
// Save to database (only for valid exit reasons)
|
||
if (reason !== 'error') {
|
||
try {
|
||
const holdTimeSeconds = Math.floor((Date.now() - trade.entryTime) / 1000)
|
||
await updateTradeExit({
|
||
positionId: trade.positionId,
|
||
exitPrice: result.closePrice || currentPrice,
|
||
exitReason: reason as 'TP1' | 'TP2' | 'SL' | 'SOFT_SL' | 'HARD_SL' | 'manual' | 'emergency',
|
||
realizedPnL: trade.realizedPnL,
|
||
exitOrderTx: result.transactionSignature || 'MANUAL_CLOSE',
|
||
holdTimeSeconds,
|
||
maxDrawdown: Math.abs(Math.min(0, trade.maxAdverseExcursion)),
|
||
maxGain: Math.max(0, trade.maxFavorableExcursion),
|
||
maxFavorableExcursion: trade.maxFavorableExcursion,
|
||
maxAdverseExcursion: trade.maxAdverseExcursion,
|
||
maxFavorablePrice: trade.maxFavorablePrice,
|
||
maxAdversePrice: trade.maxAdversePrice,
|
||
})
|
||
logger.log('💾 Trade saved to database')
|
||
|
||
// 🔥 REVENGE OUTCOME TRACKING (Enhancement #4 - Nov 27, 2025)
|
||
// If this was a revenge trade, record the outcome in StopHunt table
|
||
if (trade.signalSource === 'stop_hunt_revenge') {
|
||
try {
|
||
const { getStopHuntTracker } = await import('./stop-hunt-tracker')
|
||
const tracker = getStopHuntTracker()
|
||
|
||
await tracker.updateRevengeOutcome({
|
||
revengeTradeId: trade.id,
|
||
outcome: reason as string,
|
||
pnl: trade.realizedPnL,
|
||
failedReason: reason === 'SL' ? 'stopped_again' : undefined
|
||
})
|
||
logger.log(`🔥 Revenge outcome recorded: ${reason} (P&L: $${trade.realizedPnL.toFixed(2)})`)
|
||
} catch (revengeError) {
|
||
console.error('❌ Failed to record revenge outcome:', revengeError)
|
||
// Don't fail trade closure if revenge tracking fails
|
||
}
|
||
}
|
||
} catch (dbError) {
|
||
console.error('❌ Failed to save trade exit to database:', dbError)
|
||
// Don't fail the close if database fails
|
||
}
|
||
}
|
||
|
||
// CRITICAL: Trade already removed from activeTrades at function start (atomic delete)
|
||
// No need to call removeTrade() again - just stop monitoring if empty
|
||
if (this.activeTrades.size === 0 && this.isMonitoring) {
|
||
this.stopMonitoring()
|
||
}
|
||
|
||
logger.log(`✅ Position closed | P&L: $${trade.realizedPnL.toFixed(2)} | Reason: ${reason}`)
|
||
|
||
// Send Telegram notification
|
||
await sendPositionClosedNotification({
|
||
symbol: trade.symbol,
|
||
direction: trade.direction,
|
||
entryPrice: trade.entryPrice,
|
||
exitPrice: result.closePrice || currentPrice,
|
||
positionSize: trade.positionSize,
|
||
realizedPnL: trade.realizedPnL,
|
||
exitReason: reason,
|
||
holdTimeSeconds: Math.floor((Date.now() - trade.entryTime) / 1000),
|
||
maxDrawdown: Math.abs(Math.min(0, trade.maxAdverseExcursion)),
|
||
maxGain: Math.max(0, trade.maxFavorableExcursion),
|
||
})
|
||
|
||
// 🎯 STOP HUNT REVENGE SYSTEM (Nov 20, 2025)
|
||
// Record high-quality stop-outs for automatic revenge re-entry
|
||
if (reason === 'SL' && trade.signalQualityScore && trade.signalQualityScore >= 85) {
|
||
try {
|
||
const stopHuntTracker = getStopHuntTracker()
|
||
await stopHuntTracker.recordStopHunt({
|
||
originalTradeId: trade.id,
|
||
symbol: trade.symbol,
|
||
direction: trade.direction,
|
||
stopHuntPrice: result.closePrice || currentPrice,
|
||
originalEntryPrice: trade.entryPrice,
|
||
originalQualityScore: trade.signalQualityScore,
|
||
originalADX: trade.adxAtEntry,
|
||
originalATR: trade.atrAtEntry,
|
||
stopLossAmount: Math.abs(trade.realizedPnL), // Loss amount (positive)
|
||
})
|
||
console.log(`🎯 Stop hunt recorded - revenge window activated`)
|
||
} catch (stopHuntError) {
|
||
console.error('❌ Failed to record stop hunt:', stopHuntError)
|
||
}
|
||
}
|
||
} else {
|
||
// Partial close (TP1)
|
||
trade.realizedPnL += result.realizedPnL || 0
|
||
// result.closedSize is returned in base asset units (e.g., SOL), convert to USD using closePrice
|
||
const closePriceForCalc = result.closePrice || currentPrice
|
||
const closedSizeBase = result.closedSize || 0
|
||
const closedUSD = closedSizeBase * closePriceForCalc
|
||
trade.currentSize = Math.max(0, trade.currentSize - closedUSD)
|
||
|
||
logger.log(`✅ Partial close executed | Realized: $${(result.realizedPnL || 0).toFixed(2)} | Closed (base): ${closedSizeBase.toFixed(6)} | Closed (USD): $${closedUSD.toFixed(2)} | Remaining USD: $${trade.currentSize.toFixed(2)}`)
|
||
|
||
// Persist updated trade state so analytics reflect partial profits immediately
|
||
await this.saveTradeState(trade)
|
||
|
||
// Send Telegram notification for TP1 partial close
|
||
await sendPositionClosedNotification({
|
||
symbol: trade.symbol,
|
||
direction: trade.direction,
|
||
entryPrice: trade.entryPrice,
|
||
exitPrice: result.closePrice || currentPrice,
|
||
positionSize: closedUSD, // Show only the closed portion
|
||
realizedPnL: result.realizedPnL || 0,
|
||
exitReason: `${reason} (${percentToClose}% closed, ${(100 - percentToClose).toFixed(0)}% runner remaining)`,
|
||
holdTimeSeconds: Math.floor((Date.now() - trade.entryTime) / 1000),
|
||
maxDrawdown: Math.abs(Math.min(0, trade.maxAdverseExcursion)),
|
||
maxGain: Math.max(0, trade.maxFavorableExcursion),
|
||
})
|
||
}
|
||
|
||
} catch (error) {
|
||
console.error(`❌ Error executing exit for ${trade.symbol}:`, error)
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Decision helpers
|
||
*/
|
||
private shouldEmergencyStop(price: number, trade: ActiveTrade): boolean {
|
||
if (trade.direction === 'long') {
|
||
return price <= trade.emergencyStopPrice
|
||
} else {
|
||
return price >= trade.emergencyStopPrice
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Check if current price is at a target price within tolerance
|
||
* Used to validate TP/SL hits vs manual closes
|
||
*/
|
||
private isPriceAtTarget(currentPrice: number, targetPrice: number, tolerance: number = 0.002): boolean {
|
||
if (!targetPrice || targetPrice === 0) return false
|
||
const diff = Math.abs(currentPrice - targetPrice) / targetPrice
|
||
return diff <= tolerance
|
||
}
|
||
|
||
private shouldStopLoss(price: number, trade: ActiveTrade): boolean {
|
||
if (trade.direction === 'long') {
|
||
return price <= trade.stopLossPrice
|
||
} else {
|
||
return price >= trade.stopLossPrice
|
||
}
|
||
}
|
||
|
||
private shouldTakeProfit1(price: number, trade: ActiveTrade): boolean {
|
||
if (trade.direction === 'long') {
|
||
return price >= trade.tp1Price
|
||
} else {
|
||
return price <= trade.tp1Price
|
||
}
|
||
}
|
||
|
||
private shouldTakeProfit2(price: number, trade: ActiveTrade): boolean {
|
||
if (trade.direction === 'long') {
|
||
return price >= trade.tp2Price
|
||
} else {
|
||
return price <= trade.tp2Price
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Calculate profit percentage
|
||
*/
|
||
private calculateProfitPercent(
|
||
entryPrice: number,
|
||
currentPrice: number,
|
||
direction: 'long' | 'short'
|
||
): number {
|
||
if (direction === 'long') {
|
||
return ((currentPrice - entryPrice) / entryPrice) * 100
|
||
} else {
|
||
return ((entryPrice - currentPrice) / entryPrice) * 100
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Calculate price based on percentage
|
||
*/
|
||
private calculatePrice(
|
||
entryPrice: number,
|
||
percent: number,
|
||
direction: 'long' | 'short'
|
||
): number {
|
||
if (direction === 'long') {
|
||
return entryPrice * (1 + percent / 100)
|
||
} else {
|
||
return entryPrice * (1 - percent / 100)
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Emergency close all positions
|
||
*/
|
||
async closeAll(): Promise<void> {
|
||
logger.log('🚨 EMERGENCY: Closing all positions')
|
||
|
||
const trades = Array.from(this.activeTrades.values())
|
||
|
||
for (const trade of trades) {
|
||
await this.executeExit(trade, 100, 'emergency', trade.lastPrice)
|
||
}
|
||
|
||
logger.log('✅ All positions closed')
|
||
}
|
||
|
||
/**
|
||
* Save trade state to database (for persistence across restarts)
|
||
*/
|
||
private async saveTradeState(trade: ActiveTrade): Promise<void> {
|
||
try {
|
||
await updateTradeState({
|
||
positionId: trade.positionId,
|
||
currentSize: trade.currentSize,
|
||
tp1Hit: trade.tp1Hit,
|
||
slMovedToBreakeven: trade.slMovedToBreakeven,
|
||
slMovedToProfit: trade.slMovedToProfit,
|
||
stopLossPrice: trade.stopLossPrice,
|
||
realizedPnL: trade.realizedPnL,
|
||
unrealizedPnL: trade.unrealizedPnL,
|
||
peakPnL: trade.peakPnL,
|
||
lastPrice: trade.lastPrice,
|
||
})
|
||
} catch (error) {
|
||
const tradeId = (trade as any).id ?? 'unknown'
|
||
const positionId = trade.positionId ?? 'unknown'
|
||
console.error(`❌ Failed to save trade state (tradeId=${tradeId}, positionId=${positionId}, symbol=${trade.symbol}):`, error)
|
||
// Don't throw - state save is non-critical
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Reload configuration from merged sources (used after settings updates)
|
||
*/
|
||
refreshConfig(partial?: Partial<TradingConfig>): void {
|
||
this.config = getMergedConfig(partial)
|
||
logger.log('🔄 Position Manager config refreshed')
|
||
}
|
||
|
||
/**
|
||
* Get monitoring status
|
||
*/
|
||
getStatus(): {
|
||
isMonitoring: boolean
|
||
activeTradesCount: number
|
||
symbols: string[]
|
||
} {
|
||
const symbols = [...new Set(
|
||
Array.from(this.activeTrades.values()).map(t => t.symbol)
|
||
)]
|
||
|
||
return {
|
||
isMonitoring: this.isMonitoring,
|
||
activeTradesCount: this.activeTrades.size,
|
||
symbols,
|
||
}
|
||
}
|
||
}
|
||
|
||
// Singleton instance
|
||
let positionManagerInstance: PositionManager | null = null
|
||
let initPromise: Promise<void> | null = null
|
||
|
||
export function getPositionManager(): PositionManager {
|
||
if (!positionManagerInstance) {
|
||
positionManagerInstance = new PositionManager()
|
||
|
||
// Initialize asynchronously (restore trades from database)
|
||
if (!initPromise) {
|
||
initPromise = positionManagerInstance.initialize().catch(error => {
|
||
console.error('❌ Failed to initialize Position Manager:', error)
|
||
})
|
||
}
|
||
}
|
||
return positionManagerInstance
|
||
}
|
||
|
||
export async function getInitializedPositionManager(): Promise<PositionManager> {
|
||
const manager = getPositionManager()
|
||
if (initPromise) {
|
||
await initPromise
|
||
}
|
||
return manager
|
||
}
|