- Moved scoreSignalQuality() to BEFORE timeframe check (line 112)
- Data collection signals now have real quality scores (not hardcoded 0)
- Enables quality-filtered win rate comparison across 5min/15min/1H/4H/Daily
- Fixed TypeScript errors: added symbol/currentPrice params, fixed interface refs
- Added getMinQualityScoreForDirection import for threshold calculation
- BlockedSignal table now populated with:
* signalQualityScore (real 0-100 score, not 0)
* signalQualityVersion ('v9', not 'data-collection')
* minScoreRequired (actual threshold, not 0)
* scoreBreakdown with reasons array
- Implementation: Nov 26, 2025
- Container restarted: 14:12:00 UTC (11 minutes after commit)
- Purpose: Enable SQL queries like WHERE signalQualityScore >= minScoreRequired
to compare quality-filtered win rates across timeframes
956 lines
37 KiB
TypeScript
956 lines
37 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 } 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'
|
|
|
|
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 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: body.timeframe || '5',
|
|
direction: body.direction,
|
|
symbol: driftSymbol,
|
|
currentPrice: body.signalPrice || 0,
|
|
})
|
|
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 (15min, 1H, 4H, Daily) for analysis
|
|
const timeframe = body.timeframe || '5'
|
|
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
|
|
const priceMonitor = getPythPriceMonitor()
|
|
const latestPrice = priceMonitor.getCachedPrice(driftSymbol)
|
|
const currentPrice = latestPrice?.price || body.signalPrice || 0
|
|
|
|
// 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}`)
|
|
|
|
// 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))
|
|
}
|
|
|