Files
trading_bot_v4/app/api/trading/execute/route.ts
mindesbunister 785b09eeed critical: Fix Bug 1 (revenge external closures) & Bug 5 (validated entry bypass)
Bug 1 Fix - Revenge System External Closures:
- External closure handler now checks if SL stop-out with quality 85+
- Calls stopHuntTracker.recordStopHunt() after database save
- Enables revenge trading for on-chain order fills (not just Position Manager closes)
- Added null safety for trade.signalQualityScore (defaults to 0)
- Location: lib/trading/position-manager.ts line ~999

Bug 5 Fix - Execute Endpoint Validated Entry Bypass:
- Added isValidatedEntry check before quality threshold rejection
- Smart Validation Queue signals (quality 50-89) now execute successfully
- Logs show bypass reason and validation details (delay, original quality)
- Only affects signals with validatedEntry=true flag from queue
- Location: app/api/trading/execute/route.ts line ~228

User Clarification:
- TradingView price issue (4.47) was temporary glitch, not a bug
- Only Bug 1 (revenge) and Bug 5 (execute rejection) needed fixing
- Both fixes implemented and TypeScript errors resolved
2025-12-03 20:08:46 +01:00

1106 lines
44 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, closePosition } from '@/lib/drift/orders'
import { normalizeTradingViewSymbol, getMinQualityScoreForDirection, getMarketConfig } 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'
import { getPythPriceMonitor } from '@/lib/pyth/price-monitor'
import { logCriticalError, logTradeExecution } from '@/lib/utils/persistent-logger'
import { getSmartEntryTimer } from '@/lib/trading/smart-entry-timer'
export interface ExecuteTradeRequest {
symbol: string // TradingView symbol (e.g., 'SOLUSDT')
direction: 'long' | 'short'
timeframe: string // e.g., '5'
signalStrength?: 'strong' | 'moderate' | 'weak'
signalPrice?: number
currentPrice?: number
// Context metrics from TradingView
atr?: number
adx?: number
rsi?: number
volumeRatio?: number
pricePosition?: number
maGap?: number // V9: MA gap convergence metric
volume?: number // Raw volume value for time-series tracking
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
qualityScore?: number // Signal quality score for Telegram notification (Nov 24, 2025)
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()
const fallbackPrice = body.signalPrice ?? body.currentPrice ?? 0
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: fallbackPrice,
timestamp: Date.now(),
timeframe: body.timeframe || '5'
})
console.log(`📊 Market data auto-cached for ${driftSymbol} from trade signal`)
}
// 📊 CALCULATE QUALITY SCORE BEFORE TIMEFRAME CHECK (Nov 26, 2025)
// CRITICAL: Score ALL signals (5min + data collection) for proper multi-timeframe analysis
// This enables quality-filtered win rate comparison across timeframes
const timeframe = body.timeframe || '5'
const qualityResult = await scoreSignalQuality({
atr: body.atr || 0,
adx: body.adx || 0,
rsi: body.rsi || 0,
volumeRatio: body.volumeRatio || 0,
pricePosition: body.pricePosition || 0,
maGap: body.maGap, // V9: MA gap convergence scoring
timeframe: timeframe,
direction: body.direction,
symbol: driftSymbol,
currentPrice: fallbackPrice,
skipFrequencyCheck: timeframe !== '5', // Skip overtrading/flip-flop for data collection
})
console.log(`📊 Signal quality: ${qualityResult.score} (${qualityResult.score >= 90 ? 'PASS' : 'BLOCKED'})`)
if (qualityResult.reasons?.length > 0) {
console.log(` Reasons: ${qualityResult.reasons.join(', ')}`)
}
// Get min quality threshold for this direction
const config = getMergedConfig()
const minQualityScore = getMinQualityScoreForDirection(body.direction, config)
// 🔬 MULTI-TIMEFRAME DATA COLLECTION
// Only execute trades from 5min timeframe OR manual Telegram trades
// Save other timeframes (1min, 15min, 1H, 4H, Daily) for analysis
if (timeframe !== '5' && timeframe !== 'manual') {
console.log(`📊 DATA COLLECTION: ${timeframe}min signal from ${driftSymbol}, saving for analysis (not executing)`)
// Get current price for entry tracking
// CRITICAL FIX: Query Drift oracle directly instead of Pyth cache (cache might be stale for 1min signals)
const driftService = await initializeDriftService()
const marketConfig = getMarketConfig(driftSymbol)
const currentPrice = await driftService.getOraclePrice(marketConfig.driftMarketIndex)
// Save to BlockedSignal for cross-timeframe analysis
const { createBlockedSignal } = await import('@/lib/database/trades')
try {
await createBlockedSignal({
symbol: driftSymbol,
direction: body.direction,
blockReason: 'DATA_COLLECTION_ONLY',
blockDetails: `Multi-timeframe data collection: ${timeframe}min signals saved but not executed (only 5min executes). Quality score: ${qualityResult.score} (threshold: ${minQualityScore})`,
atr: body.atr,
adx: body.adx,
rsi: body.rsi,
volumeRatio: body.volumeRatio,
pricePosition: body.pricePosition,
timeframe: timeframe,
signalPrice: currentPrice,
signalQualityScore: qualityResult.score, // CRITICAL: Real quality score for analysis
signalQualityVersion: 'v9', // Current indicator version
minScoreRequired: minQualityScore,
scoreBreakdown: { reasons: qualityResult.reasons },
indicatorVersion: body.indicatorVersion || 'v5',
})
console.log(`${timeframe}min signal saved at $${currentPrice.toFixed(2)} for future analysis (quality: ${qualityResult.score}, threshold: ${minQualityScore})`)
} catch (dbError) {
console.error(`❌ Failed to save ${timeframe}min signal:`, dbError)
}
// CRITICAL (Dec 2, 2025): For 1-minute signals, ALSO store in MarketData table
// This enables historical time-series analysis with full indicator data
console.log(`🔍 DEBUG: timeframe value = "${timeframe}", type = ${typeof timeframe}, checking if === '1'`)
if (timeframe === '1') {
console.log(`✅ Conditional matched! Storing to MarketData...`)
try {
const { getPrismaClient } = await import('@/lib/database/trades')
const prisma = getPrismaClient()
await prisma.marketData.create({
data: {
symbol: driftSymbol,
timeframe: '1',
price: currentPrice,
atr: Number(body.atr) || 0,
adx: Number(body.adx) || 0,
rsi: Number(body.rsi) || 50,
volumeRatio: Number(body.volumeRatio) || 1.0,
pricePosition: Number(body.pricePosition) || 50,
maGap: Number(body.maGap) || undefined,
volume: Number(body.volume) || undefined,
timestamp: new Date()
}
})
console.log(`💾 Stored 1-minute data in database for ${driftSymbol} (from execute endpoint)`)
} catch (marketDataError) {
console.error('❌ Failed to store 1-minute market data:', marketDataError)
}
}
return NextResponse.json({
success: false,
error: 'Data collection only',
message: `Signal from ${timeframe}min timeframe saved for analysis. Only 5min signals are executed. Check BlockedSignal table for data.`,
dataCollection: {
timeframe: timeframe,
symbol: driftSymbol,
direction: body.direction,
qualityScore: qualityResult.score,
threshold: minQualityScore,
saved: true,
}
}, { status: 200 }) // 200 not 400 - this is expected behavior
}
console.log(`✅ 5min signal confirmed - proceeding with trade execution`)
// CRITICAL FIX (Dec 3, 2025): Check for validated entry bypass BEFORE quality threshold
// Bug Fix: Smart Validation Queue validates quality 50-89 signals, but execute endpoint was rejecting them
// Solution: If validatedEntry=true flag present, bypass quality check (signal already validated by queue)
const isValidatedEntry = body.validatedEntry === true
if (isValidatedEntry) {
console.log(`✅ VALIDATED ENTRY BYPASS: Quality ${qualityResult.score} accepted (validated by Smart Entry Queue)`)
console.log(` Original quality: ${body.originalQualityScore}, Validation delay: ${body.validationDelayMinutes}min`)
}
// CRITICAL FIX (Nov 27, 2025): Verify quality score meets minimum threshold
// Bug: Quality 30 trade executed because no quality check after timeframe validation
// ENHANCED (Dec 3, 2025): Skip this check if validatedEntry=true (already validated by queue)
if (!isValidatedEntry && qualityResult.score < minQualityScore) {
console.log(`❌ QUALITY TOO LOW: ${qualityResult.score} < ${minQualityScore} threshold for ${body.direction.toUpperCase()}`)
console.log(` Reasons: ${qualityResult.reasons.join(', ')}`)
return NextResponse.json({
success: false,
error: 'Quality score too low',
message: `Signal quality ${qualityResult.score} below ${minQualityScore} minimum for ${body.direction.toUpperCase()} (reasons: ${qualityResult.reasons.join(', ')})`,
quality: {
score: qualityResult.score,
threshold: minQualityScore,
reasons: qualityResult.reasons
}
}, { status: 400 })
}
console.log(`✅ Quality check passed: ${qualityResult.score} >= ${minQualityScore}`)
// 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)}`)
// Quality score already calculated above (before timeframe check)
// Now use it for adaptive leverage and position sizing
console.log(`📊 Signal quality score: ${qualityResult.score} (using for adaptive leverage)`)
// Get symbol-specific position sizing with quality score for adaptive leverage
// ENHANCED Nov 25, 2025: Pass direction for SHORT-specific leverage tiers
const { getActualPositionSizeForSymbol } = await import('@/config/trading')
const { size: positionSize, leverage, enabled, usePercentage } = await getActualPositionSizeForSymbol(
driftSymbol,
config,
health.freeCollateral,
qualityResult.score, // Pass quality score for adaptive leverage
body.direction // Pass direction for SHORT-specific tiers (Q90+=15x, Q80-89=10x)
)
// 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('❌ CRITICAL: Failed to close opposite position:', closeResult.error)
console.error(' Cannot open new position while opposite direction exists!')
return NextResponse.json(
{
success: false,
error: 'Flip failed - could not close opposite position',
message: `Failed to close ${oppositePosition.direction} position: ${closeResult.error}. Not opening new ${body.direction} position to avoid hedge.`,
},
{ status: 500 }
)
}
console.log(`✅ Closed ${oppositePosition.direction} position at $${closeResult.closePrice?.toFixed(4)} (P&L: $${closeResult.realizedPnL?.toFixed(2)})`)
// CRITICAL: Check if position actually closed on Drift (not just transaction confirmed)
// The needsVerification flag means transaction confirmed but position still exists
if (closeResult.needsVerification) {
console.log(`⚠️ Close tx confirmed but position still on Drift - waiting for propagation...`)
// Wait up to 15 seconds for Drift to update
let waitTime = 0
const maxWait = 15000
const checkInterval = 2000
while (waitTime < maxWait) {
await new Promise(resolve => setTimeout(resolve, checkInterval))
waitTime += checkInterval
const position = await driftService.getPosition((await import('@/config/trading')).getMarketConfig(driftSymbol).driftMarketIndex)
if (!position || Math.abs(position.size) < 0.01) {
console.log(`✅ Position confirmed closed on Drift after ${waitTime/1000}s`)
break
}
console.log(`⏳ Still waiting for Drift closure (${waitTime/1000}s elapsed)...`)
}
if (waitTime >= maxWait) {
console.error(`❌ CRITICAL: Position still on Drift after ${maxWait/1000}s!`)
return NextResponse.json(
{
success: false,
error: 'Flip failed - position did not close',
message: `Close transaction confirmed but position still exists on Drift after ${maxWait/1000}s. Not opening new position to avoid hedge.`,
},
{ status: 500 }
)
}
}
// 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)
}
console.log(`✅ Flip sequence complete - ready to open ${body.direction} position`)
}
// 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}`)
// 🎯 SMART ENTRY TIMING - Check if we should wait for better entry (Phase 2 - Nov 27, 2025)
const smartEntryTimer = getSmartEntryTimer()
if (smartEntryTimer.isEnabled() && body.signalPrice) {
console.log(`🎯 Smart Entry: Evaluating entry timing...`)
// CRITICAL FIX (Dec 3, 2025): Use current market price, not body.signalPrice
// Bug: TradingView webhook sends pricePosition (percentage 0-100) as signalPrice
// Result: Shows "$70.80" when actual price is $139.70, calculates wrong pullback
const priceMonitor = getPythPriceMonitor()
const latestPrice = priceMonitor.getCachedPrice(driftSymbol)
const currentPrice = latestPrice?.price
if (!currentPrice) {
console.warn(`⚠️ Smart Entry: No current price available, skipping timing check`)
} else {
// CRITICAL: Detect if body.signalPrice looks like percentage (< $10)
const signalPriceIsPercentage = body.signalPrice && body.signalPrice < 10
if (signalPriceIsPercentage) {
console.warn(`⚠️ signalPrice (${body.signalPrice.toFixed(2)}) looks like percentage, using current price instead`)
}
// FIXED: Use current price as both signal and entry price (not body.signalPrice)
const signalPrice = currentPrice
const priceChange = 0 // At signal time, price change is always 0
const isPullbackDirection = false // No pullback yet
const pullbackMagnitude = 0
const pullbackMin = parseFloat(process.env.SMART_ENTRY_PULLBACK_MIN || '0.15')
const pullbackMax = parseFloat(process.env.SMART_ENTRY_PULLBACK_MAX || '0.50')
console.log(` Signal Price: $${signalPrice.toFixed(2)} (using current market price)`)
console.log(` Current Price: $${currentPrice.toFixed(2)} (same as signal)`)
if (isPullbackDirection && pullbackMagnitude >= pullbackMin && pullbackMagnitude <= pullbackMax) {
// Already at favorable entry - execute immediately!
console.log(`✅ Smart Entry: Already at favorable level (${pullbackMagnitude.toFixed(2)}% pullback)`)
console.log(` Executing immediately - no need to wait`)
} else if (!isPullbackDirection || pullbackMagnitude < pullbackMin) {
// Not favorable yet - queue for smart entry
console.log(`⏳ Smart Entry: Queuing signal for optimal entry timing`)
console.log(` Waiting for ${body.direction === 'long' ? 'dip' : 'bounce'} of ${pullbackMin}-${pullbackMax}%`)
// Queue the signal with CORRECTED signal price (current market price)
const queuedSignal = smartEntryTimer.queueSignal({
symbol: driftSymbol,
direction: body.direction,
signalPrice: signalPrice, // FIXED: Use current price, not body.signalPrice
atr: body.atr,
adx: body.adx,
rsi: body.rsi,
volumeRatio: body.volumeRatio,
pricePosition: body.pricePosition,
indicatorVersion: body.indicatorVersion,
qualityScore: qualityResult.score,
})
// Return success immediately (n8n workflow continues)
return NextResponse.json({
success: true,
message: 'Signal queued for smart entry timing',
smartEntry: {
enabled: true,
queuedAt: new Date().toISOString(),
signalId: queuedSignal.id,
targetPullback: `${pullbackMin}-${pullbackMax}%`,
maxWait: `${parseInt(process.env.SMART_ENTRY_MAX_WAIT_MS || '120000') / 1000}s`,
currentPullback: `${priceChange.toFixed(2)}%`,
},
positionId: `queued-${queuedSignal.id}`,
symbol: driftSymbol,
direction: body.direction,
qualityScore: qualityResult.score,
}, { status: 200 })
} else if (pullbackMagnitude > pullbackMax) {
// Pullback too large - might be reversal, execute with caution
console.log(`⚠️ Smart Entry: Pullback too large (${pullbackMagnitude.toFixed(2)}% > ${pullbackMax}%)`)
console.log(` Possible reversal - executing at current price with caution`)
}
}
}
// 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,
maGap: body.maGap, // V9: MA gap convergence scoring
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!
// ATR-based TP/SL calculation (PRIMARY SYSTEM - Nov 17, 2025)
let tp1Percent = config.takeProfit1Percent // Fallback
let tp2Percent = config.takeProfit2Percent // Fallback
let slPercent = config.stopLossPercent // Fallback
if (config.useAtrBasedTargets && body.atr && body.atr > 0) {
// Calculate dynamic percentages from ATR
tp1Percent = calculatePercentFromAtr(
body.atr,
entryPrice,
config.atrMultiplierTp1,
config.minTp1Percent,
config.maxTp1Percent
)
tp2Percent = calculatePercentFromAtr(
body.atr,
entryPrice,
config.atrMultiplierTp2,
config.minTp2Percent,
config.maxTp2Percent
)
slPercent = -Math.abs(calculatePercentFromAtr(
body.atr,
entryPrice,
config.atrMultiplierSl,
config.minSlPercent,
config.maxSlPercent
))
console.log(`📊 ATR-based targets (ATR: ${body.atr.toFixed(4)} = ${((body.atr/entryPrice)*100).toFixed(2)}%):`)
console.log(` TP1: ${config.atrMultiplierTp1}x ATR = ${tp1Percent.toFixed(2)}%`)
console.log(` TP2: ${config.atrMultiplierTp2}x ATR = ${tp2Percent.toFixed(2)}%`)
console.log(` SL: ${config.atrMultiplierSl}x ATR = ${slPercent.toFixed(2)}%`)
} else {
console.log(`⚠️ Using fixed percentage targets (ATR not available or disabled)`)
}
const stopLossPrice = calculatePrice(
entryPrice,
slPercent,
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,
tp1Percent,
body.direction
)
const tp2Price = calculatePrice(
entryPrice,
tp2Percent,
body.direction
)
console.log('📊 Trade targets:')
console.log(` Entry: $${entryPrice.toFixed(4)}`)
console.log(` SL: $${stopLossPrice.toFixed(4)} (${slPercent.toFixed(2)}%)`)
console.log(` TP1: $${tp1Price.toFixed(4)} (${tp1Percent.toFixed(2)}%)`)
console.log(` TP2: $${tp2Price.toFixed(4)} (${tp2Percent.toFixed(2)}%)`)
// 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,
originalPositionSize: positionSizeUSD, // Store original size for accurate P&L
takeProfitPrice1: tp1Price,
takeProfitPrice2: tp2Price,
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)
try {
// Quality score already calculated earlier for adaptive leverage
console.log('🔍 DEBUG: Using quality score from earlier calculation:', 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(', ')}`)
// Log successful trade execution to persistent file
logTradeExecution(true, {
symbol: driftSymbol,
direction: body.direction,
entryPrice,
positionSize: positionSizeUSD,
transactionSignature: openResult.transactionSignature
})
} 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')
// Log to persistent file (survives container restarts)
logCriticalError('Database save failed during trade execution', {
symbol: driftSymbol,
direction: body.direction,
entryPrice,
positionSize: positionSizeUSD,
transactionSignature: openResult.transactionSignature,
error: dbError instanceof Error ? dbError.message : String(dbError),
stack: dbError instanceof Error ? dbError.stack : undefined
})
logTradeExecution(false, {
symbol: driftSymbol,
direction: body.direction,
entryPrice,
positionSize: positionSizeUSD,
transactionSignature: openResult.transactionSignature,
error: dbError instanceof Error ? dbError.message : 'Database save failed'
})
// 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(),
qualityScore: qualityResult.score, // Add quality score for Telegram notification (Nov 24, 2025)
}
// 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)
}
}
/**
* Calculate TP/SL from ATR with safety bounds (NEW - Nov 17, 2025)
* Returns percentage to use with calculatePrice()
*/
function calculatePercentFromAtr(
atrValue: number,
entryPrice: number,
atrMultiplier: number,
minPercent: number,
maxPercent: number
): number {
// Convert ATR to percentage of entry price
const atrPercent = (atrValue / entryPrice) * 100
// Apply multiplier
const targetPercent = atrPercent * atrMultiplier
// Clamp between min/max bounds
return Math.max(minPercent, Math.min(maxPercent, targetPercent))
}