Files
trading_bot_v4/app/api/trading/execute/route.ts
mindesbunister 5aad42f25f critical: FIX smart entry timeout position sizing catastrophe (97.6% size loss) + Telegram null response
BUGS FIXED:
1. Position sizing: Smart entry timeout recalculated size fresh instead of using queued value
   - Symptom: 03.95 position instead of ,354 (97.6% loss)
   - Root cause: executeSignal() called getActualPositionSizeForSymbol() fresh
   - Fix: Store positionSizeUSD and leverage when queueing, use stored values during execution

2. Telegram null: Smart entry timeout executed outside API context, returned nothing
   - Symptom: Telegram bot receives 'null' message
   - Root cause: Timeout execution in background process doesn't return to API
   - Fix: Send Telegram notification directly from executeSignal() method

FILES CHANGED:
- app/api/trading/execute/route.ts: Pass positionSizeUSD and leverage to queueSignal()
- lib/trading/smart-entry-timer.ts:
  * Accept positionSizeUSD/leverage in queueSignal() params
  * Store values in QueuedSignal object
  * Use stored values in executeSignal() instead of recalculating
  * Send Telegram notification after successful execution

IMPACT:
- ALL smart entry timeout trades now use correct position size
- User receives proper Telegram notification for timeout executions
- ,000+ in lost profits prevented going forward

DEPLOYMENT:
- Built: Sun Dec 14 12:51:46 CET 2025
- Container restarted with --force-recreate
- Status: LIVE in production

See Common Pitfalls section for full details.
2025-12-14 12:51:46 +01:00

1191 lines
49 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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.)
// Smart Validation Queue integration (Bug 5 fix - Dec 3, 2025)
validatedEntry?: boolean // Flag indicating signal was validated by Smart Entry Queue
originalQualityScore?: number // Original quality score before validation
validationDelayMinutes?: number // Time spent in validation queue
}
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: timeframe === '1' ? undefined : 'v9', // 1-minute data = pure market sampling, no 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
// CRITICAL FIX (Dec 4, 2025): Manual Telegram trades bypass quality scoring
// User requirement: "when i say short or long it shall do it straight away and DO it"
// Manual trades (timeframe='manual') execute immediately without quality checks
const isManualTrade = timeframe === 'manual'
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`)
}
if (isManualTrade) {
console.log(`✅ MANUAL TRADE BYPASS: Quality scoring skipped (Telegram command - executes immediately)`)
}
// 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)
// ENHANCED (Dec 4, 2025): Skip this check if isManualTrade=true (Telegram commands execute immediately)
if (!isValidatedEntry && !isManualTrade && 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)
// CRITICAL FIX (Dec 13, 2025): Pass positionSizeUSD and leverage to prevent recalculation on timeout
// Bug: Timeout recalculates size fresh, gets $10.40 instead of $435 (97.6% loss)
// Fix: Store calculated size when queueing, use stored value during execution
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,
positionSizeUSD: positionSize, // CRITICAL: Store calculated USD size
leverage: leverage, // CRITICAL: Store calculated leverage
})
// 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
// TP2 is a software trigger only do not place on-chain TP2 orders so the runner remains intact
const effectiveTp2SizePercent = 0
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: effectiveTp2SizePercent, // Always trigger-only: trailing activation only
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)
// BUG #76 FIX: Log critical error for missing exit orders
logCriticalError('EXIT_ORDERS_PLACEMENT_FAILED', {
symbol: driftSymbol,
direction: body.direction,
entryPrice,
positionSize: positionSizeUSD,
transactionSignature: openResult.transactionSignature,
error: exitRes.error,
partialSignatures: exitRes.signatures || []
})
} else {
console.log('📨 Exit orders placed on-chain:', exitRes.signatures)
exitOrderSignatures = exitRes.signatures || []
// BUG #76 FIX: Validate expected signature count
const expectedCount = exitRes.expectedOrders ?? (config.useDualStops ? 3 : 2)
if (exitOrderSignatures.length < expectedCount) {
console.error(`❌ CRITICAL: Missing exit orders!`)
console.error(` Expected: ${expectedCount} signatures (TP1 + TP2 + ${config.useDualStops ? 'Soft SL + Hard SL' : 'SL'})`)
console.error(` Got: ${exitOrderSignatures.length} signatures`)
console.error(` Position is UNPROTECTED! Missing stop loss!`)
console.error(` ⚠️ CLOSING POSITION IMMEDIATELY FOR SAFETY`)
// Log to persistent file for post-mortem
await logCriticalError('MISSING_EXIT_ORDERS', {
symbol: driftSymbol,
direction: body.direction,
entryPrice,
positionSize: positionSizeUSD,
transactionSignature: openResult.transactionSignature,
expectedCount,
actualCount: exitOrderSignatures.length,
signatures: exitOrderSignatures,
useDualStops: config.useDualStops
})
// CRITICAL: Close the unprotected position immediately
try {
const closeResult = await closePosition({
symbol: driftSymbol,
percentToClose: 100,
slippageTolerance: config.slippageTolerance || 0.01
})
console.log(`✅ Emergency closed unprotected position:`, closeResult)
} catch (closeError) {
console.error(`❌ Failed to emergency close unprotected position:`, closeError)
}
// Return error response - DO NOT create trade in database
return NextResponse.json({
success: false,
error: `Missing exit orders: expected ${expectedCount}, got ${exitOrderSignatures.length}. Position emergency closed for safety.`,
details: {
entryTx: openResult.transactionSignature,
expectedOrders: expectedCount,
actualOrders: exitOrderSignatures.length
}
}, { status: 500 })
}
}
} catch (err) {
console.error('❌ Unexpected error placing exit orders:', err)
// Log unexpected error
logCriticalError('EXIT_ORDERS_UNEXPECTED_ERROR', {
symbol: driftSymbol,
direction: body.direction,
error: err instanceof Error ? err.message : String(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: effectiveTp2SizePercent, // Use ?? to allow 0 for runner system
configSnapshot: config,
entryOrderTx: openResult.transactionSignature!,
tp1OrderTx: exitOrderSignatures[0],
tp2OrderTx: undefined, // TP2 is software-only trigger; no on-chain TP2 order
slOrderTx: config.useDualStops ? undefined : exitOrderSignatures[1],
softStopOrderTx: config.useDualStops ? exitOrderSignatures[1] : undefined,
hardStopOrderTx: config.useDualStops ? exitOrderSignatures[2] : 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))
}