Files
trading_bot_v4/app/api/trading/execute/route.ts
mindesbunister f682b93a1e Fix: Signal flip race condition - properly coordinate Position Manager during opposite signal closure
- Remove trade from Position Manager BEFORE closing Drift position (prevents race condition)
- Explicitly save closure to database with proper P&L calculation
- Mark flipped positions as 'manual' exit reason
- Increase delay from 1s to 2s for better on-chain confirmation
- Preserve MAE/MFE data in closure records

Fixes issue where SHORT signal would close LONG but not properly track the new SHORT position.
Database now correctly records both old position closure and new position opening.
2025-11-03 20:23:42 +01:00

615 lines
20 KiB
TypeScript

/**
* 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<NextResponse<ExecuteTradeResponse>> {
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 }
)
}
// 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)
}
}