Files
trading_bot_v4/lib/trading/position-manager.ts
mindesbunister b19f156822 critical: Fix Layer 2 ghost detection causing duplicate Telegram notifications
Bug: Trade #8 (SHORT SOL-PERP) sent 13 duplicate 'POSITION CLOSED' notifications
- P&L compounded: $11.50 → $38.56 → $64.70 → ... → $155.05
- Root cause: Layer 2 ghost detection (failureCount > 20) didn't check closingInProgress flag
- Called handleExternalClosure() every 2 seconds during rate limit storm (6,581 failures)
- Each call sent Telegram notification with compounding P&L

Fix:
- Added closingInProgress check before Layer 2 ghost detection
- Mark trade as closing BEFORE calling handleExternalClosure()
- Prevents duplicate processing during async database updates

Location: lib/trading/position-manager.ts lines 1477-1490
Prevents: Common Pitfall #49 (P&L compounding) in Layer 2 death spiral scenario
Related: Common Pitfall #40 (ghost death spiral), #48 (closingInProgress flag)

Impact: No more duplicate notifications, accurate P&L reporting
2025-11-22 14:09:24 +01:00

1802 lines
75 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Position Manager
*
* Tracks active trades and manages automatic exits
*/
import { getDriftService, initializeDriftService } from '../drift/client'
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'
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
// 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' | '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)
console.log('✅ Position manager created')
}
/**
* Initialize and restore active trades from database
*/
async initialize(): Promise<void> {
if (this.initialized) {
return
}
console.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)
console.log(`✅ Restored trade: ${activeTrade.symbol} ${activeTrade.direction} at $${activeTrade.entryPrice}`)
}
if (this.activeTrades.size > 0) {
console.log(`🎯 Restored ${this.activeTrades.size} active trades`)
await this.startMonitoring()
} else {
console.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> {
console.log(`👤 Processing manual closure for ${trade.symbol}`)
// Determine exit reason based on price levels
let exitReason: 'TP1' | 'TP2' | 'SL' | 'SOFT_SL' | 'HARD_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'
console.log(`✅ Manual closure was TP2 (price at target)`)
} else if (isAtSL) {
exitReason = 'SL'
console.log(`🛑 Manual closure was SL (price at target)`)
} else {
console.log(`👤 Manual closure confirmed (price not at any target)`)
console.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
console.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,
})
console.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(`📊 Adding trade to monitor: ${trade.symbol} ${trade.direction}`)
this.activeTrades.set(trade.id, trade)
// 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)
console.log(`✅ Trade added. Active trades: ${this.activeTrades.size}`)
// Start monitoring if not already running
if (!this.isMonitoring && this.activeTrades.size > 0) {
await this.startMonitoring()
}
}
/**
* Remove a trade from monitoring
*/
async removeTrade(tradeId: string): Promise<void> {
const trade = this.activeTrades.get(tradeId)
if (trade) {
console.log(`🗑️ Removing trade: ${trade.symbol}`)
// Cancel all orders for this symbol (cleanup orphaned orders)
try {
const { cancelAllOrders } = await import('../drift/orders')
const cancelResult = await cancelAllOrders(trade.symbol)
if (cancelResult.success && cancelResult.cancelledCount! > 0) {
console.log(`✅ Cancelled ${cancelResult.cancelledCount} orphaned orders`)
}
} catch (error) {
console.error('❌ Failed to cancel orders during trade removal:', error)
// Continue with removal even if cancel fails
}
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)
console.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
}
console.log('🔍 Validating positions against Drift...')
try {
const driftService = getDriftService()
// If Drift service not ready, skip this validation cycle
if (!driftService || !(driftService as any).isInitialized) {
console.log('⚠️ Drift service not ready - skipping validation this cycle')
console.log(` Positions in memory: ${this.activeTrades.size}`)
console.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) {
console.log(`🔴 Ghost position detected: ${trade.symbol} (${tradeId})`)
console.log(` Database: exitReason IS NULL (thinks it's open)`)
console.log(` Drift: Position ${position ? 'closed (size=' + position.size + ')' : 'missing'}`)
console.log(` Cause: Likely failed DB update during external closure`)
// Auto-cleanup: Handle as external closure
await this.handleExternalClosure(trade, 'Ghost position cleanup')
console.log(`✅ Ghost position cleaned up: ${trade.symbol}`)
}
} catch (posError) {
console.error(`⚠️ Could not check ${trade.symbol} on Drift:`, posError)
// Continue checking other positions
}
}
console.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> {
console.log(`🧹 Handling external closure: ${trade.symbol} (${reason})`)
// CRITICAL: Check if already processed to prevent duplicate notifications
const tradeId = trade.id
if (!this.activeTrades.has(tradeId)) {
console.log(`⚠️ DUPLICATE PREVENTED: Trade ${tradeId} already processed, skipping`)
console.log(` This prevents duplicate Telegram notifications with compounding P&L`)
return
}
// 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
console.log(`💰 Estimated P&L: ${profitPercent.toFixed(2)}% on $${sizeForPnL.toFixed(2)}$${estimatedPnL.toFixed(2)}`)
// Remove from monitoring IMMEDIATELY to prevent race conditions
this.activeTrades.delete(tradeId)
console.log(`🗑️ Removed ${trade.symbol} from monitoring`)
// 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,
})
console.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> {
if (this.isMonitoring) {
return
}
// Get unique symbols from active trades
const symbols = [...new Set(
Array.from(this.activeTrades.values()).map(trade => trade.symbol)
)]
if (symbols.length === 0) {
return
}
console.log('🚀 Starting price monitoring for:', symbols)
const priceMonitor = getPythPriceMonitor()
await priceMonitor.start({
symbols,
onPriceUpdate: async (update: PriceUpdate) => {
await this.handlePriceUpdate(update)
},
onError: (error: Error) => {
console.error('❌ Price monitor error:', error)
},
})
this.isMonitoring = true
console.log('✅ Position monitoring active')
// Schedule periodic validation to detect and cleanup ghost positions
this.scheduleValidation()
}
/**
* Stop price monitoring
*/
private async stopMonitoring(): Promise<void> {
if (!this.isMonitoring) {
return
}
console.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
console.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)
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: 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++
// CRITICAL: First check if on-chain position still exists
// (may have been closed by TP/SL orders without us knowing)
try {
const driftService = getDriftService()
// Skip position verification if Drift service isn't initialized yet
// (happens briefly after restart while service initializes)
if (!driftService || !(driftService as any).isInitialized) {
// Service still initializing, skip this check cycle
return
}
const marketConfig = getMarketConfig(trade.symbol)
const position = await driftService.getPosition(marketConfig.driftMarketIndex)
// Calculate trade age in seconds
const tradeAgeSeconds = (Date.now() - trade.entryTime) / 1000
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) {
console.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
}
// Position closed externally (by on-chain TP/SL order or manual closure)
console.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
console.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) {
console.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) {
console.log(`⚠️ Size reduction detected (${reductionPercent.toFixed(1)}%) but price NOT at TP1`)
console.log(` Current: $${currentPrice.toFixed(4)}, TP1: $${trade.takeProfitPrice1?.toFixed(4) || 'N/A'}`)
console.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)
console.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
console.log(`🔒 ADX-based runner SL: ${adx.toFixed(1)} → 0% (breakeven - weak trend)`)
} else if (adx < 25) {
runnerSlPercent = -0.3 // Moderate trend: some room
console.log(`🔒 ADX-based runner SL: ${adx.toFixed(1)} → -0.3% (moderate trend)`)
} else {
runnerSlPercent = -0.55 // Strong trend: full retracement room
console.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
)
console.log(`📊 Runner SL calculation: Entry $${trade.entryPrice.toFixed(4)} ${runnerSlPercent >= 0 ? '+' : ''}${runnerSlPercent}% = $${newStopLossPrice.toFixed(4)}`)
console.log(` (${this.config.takeProfit1SizePercent}% closed, ${100 - this.config.takeProfit1SizePercent}% remaining)`)
// Move SL to ADX-based position after TP1
trade.stopLossPrice = newStopLossPrice
trade.slMovedToBreakeven = true
console.log(`🛡️ Stop loss moved to: $${trade.stopLossPrice.toFixed(4)}`)
// CRITICAL: Update on-chain orders to reflect new SL at breakeven
try {
const { cancelAllOrders, placeExitOrders } = await import('../drift/orders')
console.log(`🔄 Cancelling old exit orders...`)
const cancelResult = await cancelAllOrders(trade.symbol)
if (cancelResult.success) {
console.log(`✅ Cancelled ${cancelResult.cancelledCount} old orders`)
}
console.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) {
console.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)
console.log(`🎯 TP2 detected as filled! Reduction: ${reductionPercent.toFixed(1)}%`)
trade.tp2Hit = true
trade.currentSize = positionSizeUSD
trade.trailingStopActive = true
console.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
console.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
console.log(`⚠️ Position ${trade.symbol} entry mismatch: tracking $${trade.entryPrice.toFixed(4)} but found $${position.entryPrice.toFixed(4)}`)
console.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
console.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,
})
console.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!)
console.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
if (trade.closingInProgress) {
// Check if close has been stuck for >60 seconds (abnormal)
const timeInClosing = Date.now() - (trade.closeConfirmedAt || Date.now())
if (timeInClosing > 60000) {
console.log(`⚠️ Close stuck in progress for ${(timeInClosing / 1000).toFixed(0)}s - allowing external closure check`)
trade.closingInProgress = false // Reset flag to allow cleanup
} else {
// Normal case: Close confirmed recently, waiting for Drift propagation (5-10s)
// Skip external closure detection entirely to prevent duplicate P&L updates
console.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
)
console.log(`🎊 TP2 PRICE REACHED: ${trade.symbol} at ${profitPercent.toFixed(2)}%`)
console.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 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) {
console.log(`⚠️ RUNNER CLOSED EXTERNALLY: ${trade.symbol}`)
console.log(` TP1 hit: true, TP2 hit: false`)
console.log(` This runner should have had trailing stop protection!`)
console.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) {
console.log(` ⚠️ Price reached TP2 ($${trade.tp2Price?.toFixed(4)}) but tp2Hit was false!`)
console.log(` Trailing stop should have been active but wasn't`)
} else {
console.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
console.log(`📊 External closure detected - Position size tracking:`)
console.log(` Original size: $${trade.positionSize.toFixed(2)}`)
console.log(` Tracked current size: $${trade.currentSize.toFixed(2)}`)
console.log(` Using for P&L calc: $${sizeForPnL.toFixed(2)} (full position - exit reason will determine TP1 vs SL)`)
if (wasPhantom) {
console.log(` ⚠️ PHANTOM TRADE: Setting P&L to 0 (size mismatch >50%)`)
}
// CRITICAL FIX (Nov 20, 2025): Query Drift's ACTUAL P&L instead of calculating
// Previous bug: Calculated P&L from monitoring loop price (currentPrice)
// But actual fill price can differ significantly (36% error in real case)
// Solution: Query Drift's last known unrealizedPnL before position closed
// That unrealizedPnL IS the realizedPnL once position is gone
let totalRealizedPnL = 0
let runnerProfitPercent = 0
if (!wasPhantom) {
// Try to get actual P&L from Drift's position data stored in trade
// If position was just closed, we can use the last unrealized P&L
// Otherwise fall back to calculation (less accurate but better than nothing)
const driftService = await initializeDriftService()
const marketConfig = getMarketConfig(trade.symbol)
// Check if Drift has cached P&L data for this position
try {
const userAccount = driftService.getClient().getUserAccount()
if (userAccount) {
// Get the perp position (even if closed, might still have P&L data)
const position = userAccount.perpPositions.find((p: any) =>
p.marketIndex === marketConfig.driftMarketIndex
)
if (position) {
// Use Drift's settled P&L if available
const settledPnL = Number(position.settledPnl || 0) / 1e6
if (Math.abs(settledPnL) > 0.01) {
totalRealizedPnL = settledPnL
runnerProfitPercent = (totalRealizedPnL / sizeForPnL) * 100
console.log(` ✅ Using Drift's actual P&L: $${totalRealizedPnL.toFixed(2)} (settled)`)
}
}
}
} catch (driftError) {
console.error('⚠️ Failed to query Drift P&L, falling back to calculation:', driftError)
}
// Fallback: Calculate from price (less accurate)
if (totalRealizedPnL === 0) {
runnerProfitPercent = this.calculateProfitPercent(
trade.entryPrice,
currentPrice,
trade.direction
)
totalRealizedPnL = (sizeForPnL * runnerProfitPercent) / 100
console.log(` ⚠️ Using calculated P&L (fallback): ${runnerProfitPercent.toFixed(2)}% on $${sizeForPnL.toFixed(2)} = $${totalRealizedPnL.toFixed(2)}`)
}
} else {
console.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' = '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) {
console.log(` 🏃 Runner closed with TRAILING STOP active`)
console.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 = 'SL' // Trailing stop counts as SL
console.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
console.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)) {
console.log(`⚠️ DUPLICATE PROCESSING PREVENTED: Trade ${tradeId} already removed from monitoring`)
console.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)
console.log(`🗑️ Removed trade ${tradeId} from monitoring (BEFORE DB update to prevent duplicates)`)
console.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
console.log(`🗑️ Cancelling remaining orders for ${trade.symbol}...`)
try {
const { cancelAllOrders } = await import('../drift/orders')
const cancelResult = await cancelAllOrders(trade.symbol)
if (cancelResult.success) {
console.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,
})
console.log(`💾 External closure recorded: ${exitReason} at $${currentPrice} | P&L: $${totalRealizedPnL.toFixed(2)}`)
} catch (dbError) {
console.error('❌ Failed to save external closure:', dbError)
}
// Stop monitoring if no more trades
if (this.activeTrades.size === 0 && this.isMonitoring) {
this.stopMonitoring()
}
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
console.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) {
console.log(`🔄 DIRECTION CHANGE DETECTED: ${trade.direction}${positionDirection}`)
console.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,
})
console.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) {
console.log(`🚨 EXTREME SIZE MISMATCH (${(sizeRatio * 100).toFixed(1)}%) - Closing phantom trade`)
console.log(` Expected: $${trade.currentSize.toFixed(2)}`)
console.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,
})
console.log(`💾 Phantom trade closed`)
} catch (dbError) {
console.error('❌ Failed to close phantom trade:', dbError)
}
await this.removeTrade(trade.id)
return
}
// Update current size to match reality (already in USD)
trade.currentSize = positionSizeUSD
trade.tp1Hit = true
await this.saveTradeState(trade)
}
} // 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 DOLLAR amounts (not percentages!)
// CRITICAL: Database schema expects DOLLARS for analysis and TP/SL optimization
if (currentPnLDollars > trade.maxFavorableExcursion) {
trade.maxFavorableExcursion = currentPnLDollars
trade.maxFavorablePrice = currentPrice
}
if (currentPnLDollars < trade.maxAdverseExcursion) {
trade.maxAdverseExcursion = currentPnLDollars
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) {
console.log(`🔴 GHOST DETECTED in monitoring loop: ${trade.symbol}`)
console.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) {
console.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)) {
console.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)) {
console.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)) {
console.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)) {
console.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
console.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) {
console.log(`⚠️ Multiple trades on ${trade.symbol} detected (${otherTradesOnSymbol.length + 1} total)`)
console.log(`⚠️ Skipping order cancellation to avoid wiping other positions' orders`)
console.log(`⚠️ Relying on Position Manager software monitoring for remaining ${100 - this.config.takeProfit1SizePercent}%`)
} else {
console.log('🗑️ Cancelling old stop loss orders...')
const { cancelAllOrders, placeExitOrders } = await import('../drift/orders')
const cancelResult = await cancelAllOrders(trade.symbol)
if (cancelResult.success) {
console.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)
console.log(`🛡️ Placing only SL orders at $${newStopLossPrice.toFixed(4)} for remaining position...`)
console.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) {
console.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
) {
console.log(`🔐 Profit lock trigger: ${trade.symbol}`)
trade.stopLossPrice = this.calculatePrice(
trade.entryPrice,
this.config.profitLockPercent,
trade.direction
)
trade.slMovedToProfit = true
console.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)) {
console.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)) {
console.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
console.log(`🏃 TP2-as-Runner activated: ${((trade.currentSize / trade.positionSize) * 100).toFixed(1)}% remaining with trailing stop`)
console.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)
console.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
console.log(`🎯 Trailing stop activated at +${profitPercent.toFixed(2)}%`)
}
// If trailing stop is active, adjust SL dynamically
if (trade.trailingStopActive) {
// Calculate ATR-based trailing distance with ADX trend strength multiplier
let trailingDistancePercent: number
if (trade.atrAtEntry && trade.atrAtEntry > 0) {
// Start with base ATR multiplier
let trailMultiplier = this.config.trailingStopAtrMultiplier
// ADX-based trend strength adjustment (graduated)
if (trade.adxAtEntry && trade.adxAtEntry > 0) {
if (trade.adxAtEntry > 30) {
// Very strong trend (ADX > 30): 50% wider trail
trailMultiplier *= 1.5
console.log(`📈 Very strong trend (ADX ${trade.adxAtEntry.toFixed(1)}): Trail multiplier ${this.config.trailingStopAtrMultiplier}x → ${trailMultiplier.toFixed(2)}x`)
} else if (trade.adxAtEntry > 25) {
// Strong trend (ADX 25-30): 25% wider trail
trailMultiplier *= 1.25
console.log(`📈 Strong trend (ADX ${trade.adxAtEntry.toFixed(1)}): Trail multiplier ${this.config.trailingStopAtrMultiplier}x → ${trailMultiplier.toFixed(2)}x`)
}
// Else: weak/moderate trend, use base multiplier
}
// Profit acceleration: bigger profit = wider trail
if (profitPercent > 2.0) {
const oldMultiplier = trailMultiplier
trailMultiplier *= 1.3
console.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)
)
console.log(`📊 ATR-based trailing: ${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)
)
console.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
console.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)) {
console.log(`🔴 TRAILING STOP HIT: ${trade.symbol} at ${profitPercent.toFixed(2)}%`)
await this.executeExit(trade, 100, '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)
*/
private async executeExit(
trade: ActiveTrade,
percentToClose: number,
reason: ExitResult['reason'],
currentPrice: number
): Promise<void> {
try {
console.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) {
console.log(`🔴 LAYER 2: Ghost detected after ${trade.priceCheckCount} failures`)
console.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 {
console.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) {
console.log(`⚠️ Close transaction confirmed but position still exists on Drift`)
console.log(` Keeping ${trade.symbol} in monitoring until Drift confirms closure`)
console.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()
console.log(`🔒 Marked as closing in progress - external closure detection disabled`)
// Keep monitoring - ghost detection will eventually see it's closed
return
}
// Update trade state
if (percentToClose >= 100) {
// Full close - remove from monitoring
trade.realizedPnL += result.realizedPnL || 0
// 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,
})
console.log('💾 Trade saved to database')
} catch (dbError) {
console.error('❌ Failed to save trade exit to database:', dbError)
// Don't fail the close if database fails
}
}
await this.removeTrade(trade.id)
console.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)
console.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> {
console.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)
}
console.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) {
console.error('❌ Failed to save trade state:', 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)
console.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
}