/** * Execute Trade API Endpoint * * Called by n8n workflow when TradingView signal is received * POST /api/trading/execute */ import { NextRequest, NextResponse } from 'next/server' import { initializeDriftService } from '@/lib/drift/client' import { openPosition, placeExitOrders, closePosition } from '@/lib/drift/orders' import { normalizeTradingViewSymbol } from '@/config/trading' import { getMergedConfig } from '@/config/trading' import { getInitializedPositionManager, ActiveTrade } from '@/lib/trading/position-manager' import { createTrade, updateTradeExit } from '@/lib/database/trades' import { scoreSignalQuality } from '@/lib/trading/signal-quality' import { getMarketDataCache } from '@/lib/trading/market-data-cache' export interface ExecuteTradeRequest { symbol: string // TradingView symbol (e.g., 'SOLUSDT') direction: 'long' | 'short' timeframe: string // e.g., '5' signalStrength?: 'strong' | 'moderate' | 'weak' signalPrice?: number // Context metrics from TradingView atr?: number adx?: number rsi?: number volumeRatio?: number pricePosition?: number indicatorVersion?: string // Pine Script version (v5, v6, etc.) } export interface ExecuteTradeResponse { success: boolean positionId?: string symbol?: string direction?: 'long' | 'short' entryPrice?: number positionSize?: number leverage?: number stopLoss?: number takeProfit1?: number takeProfit2?: number stopLossPercent?: number tp1Percent?: number tp2Percent?: number entrySlippage?: number timestamp?: string error?: string message?: string } export async function POST(request: NextRequest): Promise> { try { // Verify authorization const authHeader = request.headers.get('authorization') const expectedAuth = `Bearer ${process.env.API_SECRET_KEY}` if (!authHeader || authHeader !== expectedAuth) { return NextResponse.json( { success: false, error: 'Unauthorized', message: 'Invalid API key', }, { status: 401 } ) } // Parse request body const body: ExecuteTradeRequest = await request.json() console.log('🎯 Trade execution request received:', body) // Validate required fields if (!body.symbol || !body.direction) { return NextResponse.json( { success: false, error: 'Missing required fields', message: 'symbol and direction are required', }, { status: 400 } ) } // Normalize symbol const driftSymbol = normalizeTradingViewSymbol(body.symbol) console.log(`📊 Normalized symbol: ${body.symbol} → ${driftSymbol}`) // 🆕 Cache incoming market data from TradingView signals if (body.atr && body.adx && body.rsi) { const marketCache = getMarketDataCache() marketCache.set(driftSymbol, { symbol: driftSymbol, atr: body.atr, adx: body.adx, rsi: body.rsi, volumeRatio: body.volumeRatio || 1.0, pricePosition: body.pricePosition || 50, currentPrice: body.signalPrice || 0, timestamp: Date.now(), timeframe: body.timeframe || '5' }) console.log(`📊 Market data auto-cached for ${driftSymbol} from trade signal`) } // Get trading configuration const config = getMergedConfig() // Initialize Drift service and check account health before sizing const driftService = await initializeDriftService() const health = await driftService.getAccountHealth() console.log(`🩺 Account health: Free collateral $${health.freeCollateral.toFixed(2)}`) // Get symbol-specific position sizing (supports percentage-based sizing) const { getActualPositionSizeForSymbol } = await import('@/config/trading') const { size: positionSize, leverage, enabled, usePercentage } = await getActualPositionSizeForSymbol( driftSymbol, config, health.freeCollateral ) // Check if trading is enabled for this symbol if (!enabled) { console.log(`⛔ Trading disabled for ${driftSymbol}`) return NextResponse.json( { success: false, error: 'Symbol trading disabled', message: `Trading is currently disabled for ${driftSymbol}. Enable it in settings.`, }, { status: 400 } ) } console.log(`📐 Symbol-specific sizing for ${driftSymbol}:`) console.log(` Enabled: ${enabled}`) console.log(` Position size: $${positionSize.toFixed(2)} (${usePercentage ? 'percentage' : 'fixed'})`) console.log(` Leverage: ${leverage}x`) if (health.freeCollateral <= 0) { return NextResponse.json( { success: false, error: 'Insufficient collateral', message: `Free collateral: $${health.freeCollateral.toFixed(2)}`, }, { status: 400 } ) } // AUTO-FLIP: Check for existing opposite direction position const positionManager = await getInitializedPositionManager() const existingTrades = Array.from(positionManager.getActiveTrades().values()) const oppositePosition = existingTrades.find( trade => trade.symbol === driftSymbol && trade.direction !== body.direction ) // Check for same direction position (scaling vs duplicate) const sameDirectionPosition = existingTrades.find( trade => trade.symbol === driftSymbol && trade.direction === body.direction ) if (sameDirectionPosition) { // Position scaling enabled - scale into existing position if (config.enablePositionScaling) { console.log(`📈 POSITION SCALING: Adding to existing ${body.direction} position on ${driftSymbol}`) // Calculate scale size const scaleSize = (positionSize * leverage) * (config.scaleSizePercent / 100) console.log(`💰 Scaling position:`) console.log(` Original size: $${sameDirectionPosition.positionSize}`) console.log(` Scale size: $${scaleSize} (${config.scaleSizePercent}% of original)`) console.log(` Leverage: ${leverage}x`) // Open additional position const scaleResult = await openPosition({ symbol: driftSymbol, direction: body.direction, sizeUSD: scaleSize, slippageTolerance: config.slippageTolerance, }) if (!scaleResult.success) { console.error('❌ Failed to scale position:', scaleResult.error) return NextResponse.json( { success: false, error: 'Position scaling failed', message: scaleResult.error, }, { status: 500 } ) } console.log(`✅ Scaled into position at $${scaleResult.fillPrice?.toFixed(4)}`) // Update Position Manager tracking const timesScaled = (sameDirectionPosition.timesScaled || 0) + 1 const totalScaleAdded = (sameDirectionPosition.totalScaleAdded || 0) + scaleSize const newTotalSize = sameDirectionPosition.currentSize + (scaleResult.fillSize || 0) // Update the trade tracking (simplified - just update the active trade object) sameDirectionPosition.timesScaled = timesScaled sameDirectionPosition.totalScaleAdded = totalScaleAdded sameDirectionPosition.currentSize = newTotalSize console.log(`📊 Position scaled: ${timesScaled}x total, $${totalScaleAdded.toFixed(2)} added`) return NextResponse.json({ success: true, action: 'scaled', positionId: sameDirectionPosition.positionId, symbol: driftSymbol, direction: body.direction, scalePrice: scaleResult.fillPrice, scaleSize: scaleSize, totalSize: newTotalSize, timesScaled: timesScaled, timestamp: new Date().toISOString(), }) } // Scaling disabled - block duplicate console.log(`⛔ DUPLICATE POSITION BLOCKED: Already have ${body.direction} position on ${driftSymbol}`) return NextResponse.json( { success: false, error: 'Duplicate position detected', message: `Already have an active ${body.direction} position on ${driftSymbol}. Enable position scaling in settings to add to this position.`, }, { status: 400 } ) } if (oppositePosition) { console.log(`🔄 Signal flip detected! Closing ${oppositePosition.direction} to open ${body.direction}`) // CRITICAL: Remove from Position Manager FIRST to prevent race condition // where Position Manager detects "external closure" while we're deliberately closing it console.log(`🗑️ Removing ${oppositePosition.direction} position from Position Manager before flip...`) await positionManager.removeTrade(oppositePosition.id) console.log(`✅ Removed from Position Manager`) // Close opposite position on Drift const { closePosition } = await import('@/lib/drift/orders') const closeResult = await closePosition({ symbol: driftSymbol, percentToClose: 100, slippageTolerance: config.slippageTolerance, }) if (!closeResult.success) { console.error('❌ Failed to close opposite position:', closeResult.error) // Continue anyway - we'll try to open the new position } else { console.log(`✅ Closed ${oppositePosition.direction} position at $${closeResult.closePrice?.toFixed(4)} (P&L: $${closeResult.realizedPnL?.toFixed(2)})`) // Save the closure to database try { const holdTimeSeconds = Math.floor((Date.now() - oppositePosition.entryTime) / 1000) const priceProfitPercent = oppositePosition.direction === 'long' ? ((closeResult.closePrice! - oppositePosition.entryPrice) / oppositePosition.entryPrice) * 100 : ((oppositePosition.entryPrice - closeResult.closePrice!) / oppositePosition.entryPrice) * 100 const realizedPnL = closeResult.realizedPnL ?? (oppositePosition.currentSize * priceProfitPercent) / 100 await updateTradeExit({ positionId: oppositePosition.positionId, exitPrice: closeResult.closePrice!, exitReason: 'manual', // Manually closed for flip realizedPnL: realizedPnL, exitOrderTx: closeResult.transactionSignature || 'FLIP_CLOSE', holdTimeSeconds, maxDrawdown: Math.abs(Math.min(0, oppositePosition.maxAdverseExcursion)), maxGain: Math.max(0, oppositePosition.maxFavorableExcursion), maxFavorableExcursion: oppositePosition.maxFavorableExcursion, maxAdverseExcursion: oppositePosition.maxAdverseExcursion, maxFavorablePrice: oppositePosition.maxFavorablePrice, maxAdversePrice: oppositePosition.maxAdversePrice, }) console.log(`💾 Saved opposite position closure to database`) } catch (dbError) { console.error('❌ Failed to save opposite position closure:', dbError) } } // Small delay to ensure position is fully closed on-chain await new Promise(resolve => setTimeout(resolve, 2000)) } // Calculate position size with leverage const positionSizeUSD = positionSize * leverage console.log(`💰 Opening ${body.direction} position:`) console.log(` Symbol: ${driftSymbol}`) console.log(` Base size: $${positionSize}`) console.log(` Leverage: ${leverage}x`) console.log(` Total position: $${positionSizeUSD}`) // Helper function for rate limit spacing const rpcDelay = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)) // Open position const openResult = await openPosition({ symbol: driftSymbol, direction: body.direction, sizeUSD: positionSizeUSD, slippageTolerance: config.slippageTolerance, }) // Wait 2 seconds before placing exit orders to space out RPC calls await rpcDelay(2000) if (!openResult.success) { return NextResponse.json( { success: false, error: 'Position open failed', message: openResult.error, }, { status: 500 } ) } // CRITICAL: Check for phantom trade (position opened but size mismatch) if (openResult.isPhantom) { console.error(`🚨 PHANTOM TRADE DETECTED - Auto-closing for safety`) console.error(` Expected: $${positionSizeUSD.toFixed(2)}`) console.error(` Actual: $${openResult.actualSizeUSD?.toFixed(2)}`) // IMMEDIATELY close the phantom position (safety first) let closeResult let closedAtPrice = openResult.fillPrice! let closePnL = 0 try { console.log(`⚠️ Closing phantom position immediately for safety...`) // Wait 2 seconds to space out RPC calls await rpcDelay(2000) closeResult = await closePosition({ symbol: driftSymbol, percentToClose: 100, // Close 100% of whatever size exists slippageTolerance: config.slippageTolerance, }) if (closeResult.success) { closedAtPrice = closeResult.closePrice || openResult.fillPrice! // Calculate P&L (usually small loss/gain) const priceChange = body.direction === 'long' ? ((closedAtPrice - openResult.fillPrice!) / openResult.fillPrice!) : ((openResult.fillPrice! - closedAtPrice) / openResult.fillPrice!) closePnL = (openResult.actualSizeUSD || 0) * priceChange console.log(`✅ Phantom position closed at $${closedAtPrice.toFixed(2)}`) console.log(`💰 Phantom P&L: $${closePnL.toFixed(2)}`) } else { console.error(`❌ Failed to close phantom position: ${closeResult.error}`) } } catch (closeError) { console.error(`❌ Error closing phantom position:`, closeError) } // Save phantom trade to database for analysis let phantomTradeId: string | undefined try { const qualityResult = await scoreSignalQuality({ atr: body.atr || 0, adx: body.adx || 0, rsi: body.rsi || 0, volumeRatio: body.volumeRatio || 0, pricePosition: body.pricePosition || 0, direction: body.direction, symbol: driftSymbol, currentPrice: openResult.fillPrice, timeframe: body.timeframe, }) // Create trade record (without exit info initially) const trade = await createTrade({ positionId: openResult.transactionSignature!, symbol: driftSymbol, direction: body.direction, entryPrice: openResult.fillPrice!, positionSizeUSD: openResult.actualSizeUSD || positionSizeUSD, leverage: leverage, stopLossPrice: 0, takeProfit1Price: 0, takeProfit2Price: 0, tp1SizePercent: 0, tp2SizePercent: 0, configSnapshot: config, entryOrderTx: openResult.transactionSignature!, signalStrength: body.signalStrength, timeframe: body.timeframe, atrAtEntry: body.atr, adxAtEntry: body.adx, rsiAtEntry: body.rsi, volumeAtEntry: body.volumeRatio, pricePositionAtEntry: body.pricePosition, signalQualityScore: qualityResult.score, indicatorVersion: body.indicatorVersion || 'v5', status: 'phantom', isPhantom: true, expectedSizeUSD: positionSizeUSD, actualSizeUSD: openResult.actualSizeUSD, phantomReason: 'ORACLE_PRICE_MISMATCH', }) phantomTradeId = trade.id console.log(`💾 Phantom trade saved to database for analysis`) // If close succeeded, update with exit info if (closeResult?.success) { await updateTradeExit({ positionId: openResult.transactionSignature!, exitPrice: closedAtPrice, exitReason: 'manual', // Phantom auto-close (manual category) realizedPnL: closePnL, exitOrderTx: closeResult.transactionSignature || 'PHANTOM_CLOSE', holdTimeSeconds: 0, // Phantom trades close immediately maxDrawdown: Math.abs(Math.min(0, closePnL)), maxGain: Math.max(0, closePnL), maxFavorableExcursion: Math.max(0, closePnL), maxAdverseExcursion: Math.min(0, closePnL), }) console.log(`💾 Phantom exit info updated in database`) } } catch (dbError) { console.error('❌ Failed to save phantom trade:', dbError) } // Prepare notification message for n8n to send via Telegram const phantomNotification = `⚠️ PHANTOM TRADE AUTO-CLOSED\n\n` + `Symbol: ${driftSymbol}\n` + `Direction: ${body.direction.toUpperCase()}\n` + `Expected Size: $${positionSizeUSD.toFixed(2)}\n` + `Actual Size: $${(openResult.actualSizeUSD || 0).toFixed(2)} (${((openResult.actualSizeUSD || 0) / positionSizeUSD * 100).toFixed(1)}%)\n\n` + `Entry: $${openResult.fillPrice!.toFixed(2)}\n` + `Exit: $${closedAtPrice.toFixed(2)}\n` + `P&L: $${closePnL.toFixed(2)}\n\n` + `Reason: Size mismatch detected - likely oracle price issue or exchange rejection\n` + `Action: Position auto-closed for safety (unmonitored positions = risk)\n\n` + `TX: ${openResult.transactionSignature?.slice(0, 20)}...` console.log(`📱 Phantom notification prepared:`, phantomNotification) // Return HTTP 200 with warning (not 500) so n8n workflow continues to notification return NextResponse.json( { success: true, // Changed from false - position was handled safely warning: 'Phantom trade detected and auto-closed', isPhantom: true, message: phantomNotification, // Full notification message for n8n phantomDetails: { expectedSize: positionSizeUSD, actualSize: openResult.actualSizeUSD, sizeRatio: (openResult.actualSizeUSD || 0) / positionSizeUSD, autoClosed: closeResult?.success || false, pnl: closePnL, entryTx: openResult.transactionSignature, exitTx: closeResult?.transactionSignature, } }, { status: 200 } // Changed from 500 - allows n8n to continue ) } // Calculate stop loss and take profit prices const entryPrice = openResult.fillPrice! const stopLossPrice = calculatePrice( entryPrice, config.stopLossPercent, body.direction ) // Calculate dual stop prices if enabled let softStopPrice: number | undefined let hardStopPrice: number | undefined if (config.useDualStops) { softStopPrice = calculatePrice( entryPrice, config.softStopPercent, body.direction ) hardStopPrice = calculatePrice( entryPrice, config.hardStopPercent, body.direction ) console.log('🛡️🛡️ Dual stop system enabled:') console.log(` Soft stop: $${softStopPrice.toFixed(4)} (${config.softStopPercent}%)`) console.log(` Hard stop: $${hardStopPrice.toFixed(4)} (${config.hardStopPercent}%)`) } const tp1Price = calculatePrice( entryPrice, config.takeProfit1Percent, body.direction ) const tp2Price = calculatePrice( entryPrice, config.takeProfit2Percent, body.direction ) console.log('📊 Trade targets:') console.log(` Entry: $${entryPrice.toFixed(4)}`) console.log(` SL: $${stopLossPrice.toFixed(4)} (${config.stopLossPercent}%)`) console.log(` TP1: $${tp1Price.toFixed(4)} (${config.takeProfit1Percent}%)`) console.log(` TP2: $${tp2Price.toFixed(4)} (${config.takeProfit2Percent}%)`) // Calculate emergency stop const emergencyStopPrice = calculatePrice( entryPrice, config.emergencyStopPercent, body.direction ) // Create active trade object const activeTrade: ActiveTrade = { id: `trade-${Date.now()}`, positionId: openResult.transactionSignature!, symbol: driftSymbol, direction: body.direction, entryPrice, entryTime: Date.now(), positionSize: positionSizeUSD, leverage: leverage, // Use actual symbol-specific leverage stopLossPrice, tp1Price, tp2Price, emergencyStopPrice, currentSize: positionSizeUSD, tp1Hit: false, tp2Hit: false, slMovedToBreakeven: false, slMovedToProfit: false, trailingStopActive: false, realizedPnL: 0, unrealizedPnL: 0, peakPnL: 0, peakPrice: entryPrice, // MAE/MFE tracking maxFavorableExcursion: 0, maxAdverseExcursion: 0, maxFavorablePrice: entryPrice, maxAdversePrice: entryPrice, // Position scaling tracking originalAdx: body.adx, // Store for scaling validation timesScaled: 0, totalScaleAdded: 0, priceCheckCount: 0, lastPrice: entryPrice, lastUpdateTime: Date.now(), } // CRITICAL FIX: Place on-chain TP/SL orders BEFORE adding to Position Manager // This prevents race condition where Position Manager detects "external closure" // while orders are still being placed, leaving orphaned stop loss orders let exitOrderSignatures: string[] = [] try { console.log('🔍 DEBUG: About to call placeExitOrders()...') console.log('🔍 DEBUG: Parameters:', { symbol: driftSymbol, positionSizeUSD, entryPrice, tp1Price, tp2Price, stopLossPrice, direction: body.direction }) const exitRes = await placeExitOrders({ symbol: driftSymbol, positionSizeUSD: positionSizeUSD, entryPrice: entryPrice, tp1Price, tp2Price, stopLossPrice, tp1SizePercent: config.takeProfit1SizePercent ?? 75, tp2SizePercent: config.takeProfit2SizePercent ?? 0, // 0 = activate trailing stop, don't close direction: body.direction, // Dual stop parameters useDualStops: config.useDualStops, softStopPrice: softStopPrice, softStopBuffer: config.softStopBuffer, hardStopPrice: hardStopPrice, }) console.log('🔍 DEBUG: placeExitOrders() returned:', exitRes.success ? 'SUCCESS' : 'FAILED') if (!exitRes.success) { console.error('❌ Failed to place on-chain exit orders:', exitRes.error) } else { console.log('📨 Exit orders placed on-chain:', exitRes.signatures) exitOrderSignatures = exitRes.signatures || [] } } catch (err) { console.error('❌ Unexpected error placing exit orders:', err) } console.log('🔍 DEBUG: Exit orders section complete, about to calculate quality score...') // Save trade to database FIRST (CRITICAL: Must succeed before Position Manager) let qualityResult try { // Calculate quality score if metrics available console.log('🔍 DEBUG: Calling scoreSignalQuality()...') qualityResult = await scoreSignalQuality({ atr: body.atr || 0, adx: body.adx || 0, rsi: body.rsi || 0, volumeRatio: body.volumeRatio || 0, pricePosition: body.pricePosition || 0, direction: body.direction, symbol: driftSymbol, currentPrice: openResult.fillPrice, timeframe: body.timeframe, }) console.log('🔍 DEBUG: scoreSignalQuality() completed, score:', qualityResult.score) console.log('🔍 DEBUG: About to call createTrade()...') await createTrade({ positionId: openResult.transactionSignature!, symbol: driftSymbol, direction: body.direction, entryPrice, positionSizeUSD: positionSizeUSD, leverage: leverage, // Use actual symbol-specific leverage, not global config stopLossPrice, takeProfit1Price: tp1Price, takeProfit2Price: tp2Price, tp1SizePercent: config.takeProfit1SizePercent ?? 75, tp2SizePercent: config.takeProfit2SizePercent ?? 0, // Use ?? to allow 0 for runner system configSnapshot: config, entryOrderTx: openResult.transactionSignature!, tp1OrderTx: exitOrderSignatures[0], tp2OrderTx: exitOrderSignatures[1], slOrderTx: config.useDualStops ? undefined : exitOrderSignatures[2], softStopOrderTx: config.useDualStops ? exitOrderSignatures[2] : undefined, hardStopOrderTx: config.useDualStops ? exitOrderSignatures[3] : undefined, softStopPrice, hardStopPrice, signalSource: body.timeframe === 'manual' ? 'manual' : 'tradingview', // Identify manual Telegram trades signalStrength: body.signalStrength, timeframe: body.timeframe, // Context metrics from TradingView atrAtEntry: body.atr, adxAtEntry: body.adx, rsiAtEntry: body.rsi, volumeAtEntry: body.volumeRatio, pricePositionAtEntry: body.pricePosition, signalQualityScore: qualityResult.score, indicatorVersion: body.indicatorVersion || 'v5', // Default to v5 for backward compatibility }) console.log('🔍 DEBUG: createTrade() completed successfully') console.log(`💾 Trade saved with quality score: ${qualityResult.score}/100`) console.log(`📊 Quality reasons: ${qualityResult.reasons.join(', ')}`) } catch (dbError) { console.error('❌ CRITICAL: Failed to save trade to database:', dbError) console.error(' Position is OPEN on Drift but NOT tracked!') console.error(' Manual intervention required - close position immediately') // CRITICAL: If database save fails, we MUST NOT add to Position Manager // Return error to user so they know to close manually return NextResponse.json( { success: false, error: 'Database save failed - position unprotected', message: `Position opened on Drift but database save failed. CLOSE POSITION MANUALLY IMMEDIATELY. Transaction: ${openResult.transactionSignature}`, }, { status: 500 } ) } // Add to position manager for monitoring ONLY AFTER database save succeeds await positionManager.addTrade(activeTrade) console.log('✅ Trade added to position manager for monitoring') // Create response object const response: ExecuteTradeResponse = { success: true, positionId: openResult.transactionSignature, symbol: driftSymbol, direction: body.direction, entryPrice: entryPrice, positionSize: positionSizeUSD, leverage: leverage, // Use actual symbol-specific leverage, not global config stopLoss: stopLossPrice, takeProfit1: tp1Price, takeProfit2: tp2Price, stopLossPercent: config.stopLossPercent, tp1Percent: config.takeProfit1Percent, tp2Percent: config.takeProfit2Percent, entrySlippage: openResult.slippage, timestamp: new Date().toISOString(), } // Attach exit order signatures to response if (exitOrderSignatures.length > 0) { (response as any).exitOrderSignatures = exitOrderSignatures } console.log('✅ Trade executed successfully!') return NextResponse.json(response) } catch (error) { console.error('❌ Trade execution error:', error) return NextResponse.json( { success: false, error: 'Internal server error', message: error instanceof Error ? error.message : 'Unknown error', }, { status: 500 } ) } } /** * Helper function to calculate price based on percentage */ function calculatePrice( entryPrice: number, percent: number, direction: 'long' | 'short' ): number { if (direction === 'long') { return entryPrice * (1 + percent / 100) } else { return entryPrice * (1 - percent / 100) } }