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.
1191 lines
49 KiB
TypeScript
1191 lines
49 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.)
|
||
// 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))
|
||
}
|
||
|