- 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.
615 lines
20 KiB
TypeScript
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)
|
|
}
|
|
}
|