/** * 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 } 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' /** * Calculate signal quality score (same logic as check-risk endpoint) */ function calculateQualityScore(params: { atr?: number adx?: number rsi?: number volumeRatio?: number pricePosition?: number direction: 'long' | 'short' }): number | undefined { // If no metrics provided, return undefined if (!params.atr || params.atr === 0) { return undefined } let score = 50 // Base score // ATR check if (params.atr < 0.6) { score -= 15 } else if (params.atr > 2.5) { score -= 20 } else { score += 10 } // ADX check if (params.adx && params.adx > 0) { if (params.adx > 25) { score += 15 } else if (params.adx < 18) { score -= 15 } else { score += 5 } } // RSI check if (params.rsi && params.rsi > 0) { if (params.direction === 'long') { if (params.rsi > 50 && params.rsi < 70) { score += 10 } else if (params.rsi > 70) { score -= 10 } } else { if (params.rsi < 50 && params.rsi > 30) { score += 10 } else if (params.rsi < 30) { score -= 10 } } } // Volume check if (params.volumeRatio && params.volumeRatio > 0) { if (params.volumeRatio > 1.2) { score += 10 } else if (params.volumeRatio < 0.8) { score -= 10 } } // Price position check if (params.pricePosition && params.pricePosition > 0) { if (params.direction === 'long' && params.pricePosition > 90) { score -= 15 } else if (params.direction === 'short' && params.pricePosition < 10) { score -= 15 } else { score += 5 } } return score } 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 } 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}`) // Get trading configuration const config = getMergedConfig() // Get symbol-specific position sizing const { getPositionSizeForSymbol } = await import('@/config/trading') const { size: positionSize, leverage, enabled } = getPositionSizeForSymbol(driftSymbol, config) // 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}`) console.log(` Leverage: ${leverage}x`) // Initialize Drift service if not already initialized const driftService = await initializeDriftService() // Check account health before trading const health = await driftService.getAccountHealth() console.log('💊 Account health:', health) 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 profitPercent = ((closeResult.closePrice! - oppositePosition.entryPrice) / oppositePosition.entryPrice) * 100 const accountPnL = profitPercent * oppositePosition.leverage * (oppositePosition.direction === 'long' ? 1 : -1) const realizedPnL = (oppositePosition.currentSize * accountPnL) / 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}`) // Open position const openResult = await openPosition({ symbol: driftSymbol, direction: body.direction, sizeUSD: positionSizeUSD, slippageTolerance: config.slippageTolerance, }) 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 - Not adding to Position Manager`) console.error(` Expected: $${positionSizeUSD.toFixed(2)}`) console.error(` Actual: $${openResult.actualSizeUSD?.toFixed(2)}`) // Save phantom trade to database for analysis try { const qualityScore = calculateQualityScore({ atr: body.atr, adx: body.adx, rsi: body.rsi, volumeRatio: body.volumeRatio, pricePosition: body.pricePosition, direction: body.direction, }) await createTrade({ positionId: openResult.transactionSignature!, symbol: driftSymbol, direction: body.direction, entryPrice: openResult.fillPrice!, positionSizeUSD: positionSizeUSD, leverage: config.leverage, stopLossPrice: 0, // Not applicable for phantom 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: qualityScore, // Phantom-specific fields status: 'phantom', isPhantom: true, expectedSizeUSD: positionSizeUSD, actualSizeUSD: openResult.actualSizeUSD, phantomReason: 'ORACLE_PRICE_MISMATCH', // Likely cause based on logs }) console.log(`💾 Phantom trade saved to database for analysis`) } catch (dbError) { console.error('❌ Failed to save phantom trade:', dbError) } return NextResponse.json( { success: false, error: 'Phantom trade detected', message: `Position opened but size mismatch detected. Expected $${positionSizeUSD.toFixed(2)}, got $${openResult.actualSizeUSD?.toFixed(2)}. This usually indicates oracle price was stale or order was rejected by exchange.`, }, { status: 500 } ) } // 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: config.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 { const exitRes = await placeExitOrders({ symbol: driftSymbol, positionSizeUSD: positionSizeUSD, entryPrice: entryPrice, tp1Price, tp2Price, stopLossPrice, tp1SizePercent: config.takeProfit1SizePercent || 50, tp2SizePercent: config.takeProfit2SizePercent || 100, direction: body.direction, // Dual stop parameters useDualStops: config.useDualStops, softStopPrice: softStopPrice, softStopBuffer: config.softStopBuffer, hardStopPrice: hardStopPrice, }) 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) } // Add to position manager for monitoring AFTER orders are placed 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: config.leverage, 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 } // Save trade to database try { // Calculate quality score if metrics available const qualityScore = calculateQualityScore({ atr: body.atr, adx: body.adx, rsi: body.rsi, volumeRatio: body.volumeRatio, pricePosition: body.pricePosition, direction: body.direction, }) await createTrade({ positionId: openResult.transactionSignature!, symbol: driftSymbol, direction: body.direction, entryPrice, positionSizeUSD: positionSizeUSD, leverage: config.leverage, stopLossPrice, takeProfit1Price: tp1Price, takeProfit2Price: tp2Price, tp1SizePercent: config.takeProfit1SizePercent || 50, tp2SizePercent: config.takeProfit2SizePercent || 100, 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, 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: qualityScore, }) if (qualityScore !== undefined) { console.log(`💾 Trade saved with quality score: ${qualityScore}/100`) } else { console.log('💾 Trade saved to database') } } catch (dbError) { console.error('❌ Failed to save trade to database:', dbError) // Don't fail the trade if database save fails } 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) } }