Problem: - 1-minute signals logged wrong prices (4-55 vs actual 41-144 SOL) - Pyth price cache empty for 1-minute signals - Fallback to body.signalPrice (from n8n) contained pricePosition value - signalPrice === pricePosition in every request (39.29 = 39.29) Failed Attempts (5): 1. Added @ close to TradingView indicator (commit212a36f) 2. Added signalPrice regex to n8n (commit99a5223) 3. Fixed regex to avoid POS collision (commitff402ed) 4. Added DEBUG logging to n8n (no output visible) 5. FINAL FIX: Query Drift oracle directly Solution: - Bypass Pyth cache and n8n signalPrice entirely - Query Drift Protocol oracle directly for real-time price - Lines 145-149 in execute/route.ts: * OLD: latestPrice?.price || body.signalPrice || 0 * NEW: await driftService.getOraclePrice(marketConfig.driftMarketIndex) - Drift oracle is source of truth (on-chain), no caching issues - Added getMarketConfig import to resolve marketConfig Impact: - Logs will show actual SOL price ($141.XX) instead of indicator values ($24-55) - Accurate price logging critical for REAL MONEY system P&L tracking - BlockedSignal database records will have correct signalPrice values Files Changed: - app/api/trading/execute/route.ts (lines 11, 145-149) * Added getMarketConfig to imports * Replaced Pyth cache fallback with direct Drift oracle query
1026 lines
40 KiB
TypeScript
1026 lines
40 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
|
|
// Context metrics from TradingView
|
|
atr?: number
|
|
adx?: number
|
|
rsi?: number
|
|
volumeRatio?: number
|
|
pricePosition?: number
|
|
maGap?: number // V9: MA gap convergence metric
|
|
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()
|
|
|
|
console.log('🎯 Trade execution request received:', body)
|
|
|
|
// Validate required fields
|
|
if (!body.symbol || !body.direction) {
|
|
return NextResponse.json(
|
|
{
|
|
success: false,
|
|
error: 'Missing required fields',
|
|
message: 'symbol and direction are required',
|
|
},
|
|
{ status: 400 }
|
|
)
|
|
}
|
|
|
|
// Normalize symbol
|
|
const driftSymbol = normalizeTradingViewSymbol(body.symbol)
|
|
console.log(`📊 Normalized symbol: ${body.symbol} → ${driftSymbol}`)
|
|
|
|
// 🆕 Cache incoming market data from TradingView signals
|
|
if (body.atr && body.adx && body.rsi) {
|
|
const marketCache = getMarketDataCache()
|
|
marketCache.set(driftSymbol, {
|
|
symbol: driftSymbol,
|
|
atr: body.atr,
|
|
adx: body.adx,
|
|
rsi: body.rsi,
|
|
volumeRatio: body.volumeRatio || 1.0,
|
|
pricePosition: body.pricePosition || 50,
|
|
currentPrice: body.signalPrice || 0,
|
|
timestamp: Date.now(),
|
|
timeframe: body.timeframe || '5'
|
|
})
|
|
console.log(`📊 Market data auto-cached for ${driftSymbol} from trade signal`)
|
|
}
|
|
|
|
// 📊 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: body.signalPrice || 0,
|
|
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 },
|
|
})
|
|
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)
|
|
}
|
|
|
|
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`)
|
|
|
|
// 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...`)
|
|
|
|
// Get current price to check if already at favorable level
|
|
const priceMonitor = getPythPriceMonitor()
|
|
const latestPrice = priceMonitor.getCachedPrice(driftSymbol)
|
|
const currentPrice = latestPrice?.price || body.signalPrice
|
|
|
|
const priceChange = ((currentPrice - body.signalPrice) / body.signalPrice) * 100
|
|
const isPullbackDirection = body.direction === 'long' ? priceChange < 0 : priceChange > 0
|
|
const pullbackMagnitude = Math.abs(priceChange)
|
|
|
|
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: $${body.signalPrice.toFixed(2)}`)
|
|
console.log(` Current Price: $${currentPrice.toFixed(2)} (${priceChange >= 0 ? '+' : ''}${priceChange.toFixed(2)}%)`)
|
|
|
|
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 full context
|
|
const queuedSignal = smartEntryTimer.queueSignal({
|
|
symbol: driftSymbol,
|
|
direction: body.direction,
|
|
signalPrice: 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))
|
|
}
|
|
|