Three critical bugs fixed: 1. P&L calculation (65x inflation) - now uses collateralUSD not notional 2. handlePostTp1Adjustments() - checks tp2SizePercent===0 for runner mode 3. JavaScript || operator bug - changed to ?? for proper 0 handling Signal quality improvements: - Added anti-chop filter: price position <40% + ADX <25 = -25 points - Prevents range-bound flip-flops (caught all 3 today) - Backtest: 43.8% → 55.6% win rate, +86% profit per trade Changes: - lib/trading/signal-quality.ts: RANGE-BOUND CHOP penalty - lib/drift/orders.ts: Fixed P&L calculation + transaction confirmation - lib/trading/position-manager.ts: Runner system logic - app/api/trading/execute/route.ts: || to ?? for tp2SizePercent - app/api/trading/test/route.ts: || to ?? for tp1/tp2SizePercent - prisma/schema.prisma: Added collateralUSD field - scripts/fix_pnl_calculations.sql: Historical P&L correction
642 lines
23 KiB
TypeScript
642 lines
23 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 } from '@/lib/drift/orders'
|
|
import { normalizeTradingViewSymbol, calculateDynamicTp2 } 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'
|
|
|
|
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
|
|
}
|
|
|
|
export interface ExecuteTradeResponse {
|
|
success: boolean
|
|
positionId?: string
|
|
symbol?: string
|
|
direction?: 'long' | 'short'
|
|
entryPrice?: number
|
|
positionSize?: number
|
|
requestedPositionSize?: number
|
|
fillCoveragePercent?: number
|
|
leverage?: number
|
|
stopLoss?: number
|
|
takeProfit1?: number
|
|
takeProfit2?: number
|
|
stopLossPercent?: number
|
|
tp1Percent?: number
|
|
tp2Percent?: number
|
|
entrySlippage?: number
|
|
timestamp?: string
|
|
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}`)
|
|
|
|
// Get trading configuration
|
|
const config = getMergedConfig()
|
|
|
|
// Initialize Drift service to get account balance
|
|
const driftService = await initializeDriftService()
|
|
|
|
// Check account health before trading
|
|
const health = await driftService.getAccountHealth()
|
|
console.log('💊 Account health:', health)
|
|
|
|
if (health.freeCollateral <= 0) {
|
|
return NextResponse.json(
|
|
{
|
|
success: false,
|
|
error: 'Insufficient collateral',
|
|
message: `Free collateral: $${health.freeCollateral.toFixed(2)}`,
|
|
},
|
|
{ status: 400 }
|
|
)
|
|
}
|
|
|
|
// Get symbol-specific position sizing (with percentage support)
|
|
const { getActualPositionSizeForSymbol } = await import('@/config/trading')
|
|
const { size: positionSize, leverage, enabled, usePercentage } = await getActualPositionSizeForSymbol(
|
|
driftSymbol,
|
|
config,
|
|
health.freeCollateral
|
|
)
|
|
|
|
// 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}`)
|
|
console.log(` Leverage: ${leverage}x`)
|
|
console.log(` Using percentage: ${usePercentage}`)
|
|
console.log(` Free collateral: $${health.freeCollateral.toFixed(2)}`)
|
|
|
|
// 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 actualScaleNotional = scaleResult.actualSizeUSD ?? scaleSize
|
|
const totalScaleAdded = (sameDirectionPosition.totalScaleAdded || 0) + actualScaleNotional
|
|
const newTotalSize = sameDirectionPosition.currentSize + actualScaleNotional
|
|
|
|
if (scaleSize > 0) {
|
|
const coverage = (actualScaleNotional / scaleSize) * 100
|
|
if (coverage < 99.5) {
|
|
console.log(`⚠️ Scale fill coverage: ${coverage.toFixed(2)}% of requested $${scaleSize.toFixed(2)}`)
|
|
}
|
|
}
|
|
|
|
// 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('❌ Failed to close opposite position:', closeResult.error)
|
|
// Continue anyway - we'll try to open the new position
|
|
} else {
|
|
console.log(`✅ Closed ${oppositePosition.direction} position at $${closeResult.closePrice?.toFixed(4)} (P&L: $${closeResult.realizedPnL?.toFixed(2)})`)
|
|
|
|
// 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)
|
|
}
|
|
}
|
|
|
|
// Small delay to ensure position is fully closed on-chain
|
|
await new Promise(resolve => setTimeout(resolve, 2000))
|
|
}
|
|
|
|
// Calculate requested position size with leverage
|
|
const requestedPositionSizeUSD = positionSize * leverage
|
|
|
|
console.log(`💰 Opening ${body.direction} position:`)
|
|
console.log(` Symbol: ${driftSymbol}`)
|
|
console.log(` Base size: $${positionSize}`)
|
|
console.log(` Leverage: ${leverage}x`)
|
|
console.log(` Requested notional: $${requestedPositionSizeUSD}`)
|
|
|
|
// Open position
|
|
const openResult = await openPosition({
|
|
symbol: driftSymbol,
|
|
direction: body.direction,
|
|
sizeUSD: requestedPositionSizeUSD,
|
|
slippageTolerance: config.slippageTolerance,
|
|
})
|
|
|
|
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 - Not adding to Position Manager`)
|
|
console.error(` Expected: $${requestedPositionSizeUSD.toFixed(2)}`)
|
|
console.error(` Actual: $${openResult.actualSizeUSD?.toFixed(2)}`)
|
|
|
|
// Save phantom trade to database for analysis
|
|
try {
|
|
const qualityResult = scoreSignalQuality({
|
|
atr: body.atr || 0,
|
|
adx: body.adx || 0,
|
|
rsi: body.rsi || 0,
|
|
volumeRatio: body.volumeRatio || 0,
|
|
pricePosition: body.pricePosition || 0,
|
|
direction: body.direction,
|
|
timeframe: body.timeframe,
|
|
})
|
|
|
|
await createTrade({
|
|
positionId: openResult.transactionSignature!,
|
|
symbol: driftSymbol,
|
|
direction: body.direction,
|
|
entryPrice: openResult.fillPrice!,
|
|
positionSizeUSD: requestedPositionSizeUSD,
|
|
leverage: config.leverage,
|
|
stopLossPrice: 0, // Not applicable for phantom
|
|
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,
|
|
// Phantom-specific fields
|
|
status: 'phantom',
|
|
isPhantom: true,
|
|
expectedSizeUSD: requestedPositionSizeUSD,
|
|
actualSizeUSD: openResult.actualSizeUSD,
|
|
phantomReason: 'ORACLE_PRICE_MISMATCH', // Likely cause based on logs
|
|
})
|
|
|
|
console.log(`💾 Phantom trade saved to database for analysis`)
|
|
} catch (dbError) {
|
|
console.error('❌ Failed to save phantom trade:', dbError)
|
|
}
|
|
|
|
return NextResponse.json(
|
|
{
|
|
success: false,
|
|
error: 'Phantom trade detected',
|
|
message: `Position opened but size mismatch detected. Expected $${requestedPositionSizeUSD.toFixed(2)}, got $${openResult.actualSizeUSD?.toFixed(2)}. This usually indicates oracle price was stale or order was rejected by exchange.`,
|
|
},
|
|
{ status: 500 }
|
|
)
|
|
}
|
|
|
|
// Calculate stop loss and take profit prices
|
|
const entryPrice = openResult.fillPrice!
|
|
const actualPositionSizeUSD = openResult.actualSizeUSD ?? requestedPositionSizeUSD
|
|
const filledBaseSize = openResult.fillSize !== undefined
|
|
? Math.abs(openResult.fillSize)
|
|
: (entryPrice > 0 ? actualPositionSizeUSD / entryPrice : 0)
|
|
const fillCoverage = requestedPositionSizeUSD > 0
|
|
? (actualPositionSizeUSD / requestedPositionSizeUSD) * 100
|
|
: 100
|
|
|
|
console.log('📏 Fill results:')
|
|
console.log(` Filled base size: ${filledBaseSize.toFixed(4)} ${driftSymbol.split('-')[0]}`)
|
|
console.log(` Filled notional: $${actualPositionSizeUSD.toFixed(2)}`)
|
|
if (fillCoverage < 99.5) {
|
|
console.log(` ⚠️ Partial fill: ${fillCoverage.toFixed(2)}% of requested size`)
|
|
}
|
|
|
|
const stopLossPrice = calculatePrice(
|
|
entryPrice,
|
|
config.stopLossPercent,
|
|
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,
|
|
config.takeProfit1Percent,
|
|
body.direction
|
|
)
|
|
|
|
const dynamicTp2Percent = calculateDynamicTp2(
|
|
entryPrice,
|
|
body.atr || 0, // ATR from TradingView signal
|
|
config
|
|
)
|
|
|
|
const tp2Price = calculatePrice(
|
|
entryPrice,
|
|
dynamicTp2Percent,
|
|
body.direction
|
|
)
|
|
|
|
console.log('📊 Trade targets:')
|
|
console.log(` Entry: $${entryPrice.toFixed(4)}`)
|
|
console.log(` SL: $${stopLossPrice.toFixed(4)} (${config.stopLossPercent}%)`)
|
|
console.log(` TP1: $${tp1Price.toFixed(4)} (${config.takeProfit1Percent}%)`)
|
|
console.log(` TP2: $${tp2Price.toFixed(4)} (${dynamicTp2Percent.toFixed(2)}% - ATR-based)`)
|
|
|
|
// 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: actualPositionSizeUSD,
|
|
leverage: config.leverage,
|
|
stopLossPrice,
|
|
tp1Price,
|
|
tp2Price,
|
|
emergencyStopPrice,
|
|
currentSize: actualPositionSizeUSD,
|
|
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,
|
|
atrAtEntry: body.atr,
|
|
runnerTrailingPercent: undefined,
|
|
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 {
|
|
const exitRes = await placeExitOrders({
|
|
symbol: driftSymbol,
|
|
positionSizeUSD: actualPositionSizeUSD,
|
|
entryPrice: entryPrice,
|
|
tp1Price,
|
|
tp2Price,
|
|
stopLossPrice,
|
|
tp1SizePercent: config.takeProfit1SizePercent ?? 50,
|
|
tp2SizePercent: config.takeProfit2SizePercent ?? 100, // Use ?? instead of || to allow 0
|
|
direction: body.direction,
|
|
// Dual stop parameters
|
|
useDualStops: config.useDualStops,
|
|
softStopPrice: softStopPrice,
|
|
softStopBuffer: config.softStopBuffer,
|
|
hardStopPrice: hardStopPrice,
|
|
})
|
|
|
|
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)
|
|
}
|
|
|
|
// Add to position manager for monitoring AFTER orders are placed
|
|
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: actualPositionSizeUSD,
|
|
requestedPositionSize: requestedPositionSizeUSD,
|
|
fillCoveragePercent: Number(fillCoverage.toFixed(2)),
|
|
leverage: config.leverage,
|
|
stopLoss: stopLossPrice,
|
|
takeProfit1: tp1Price,
|
|
takeProfit2: tp2Price,
|
|
stopLossPercent: config.stopLossPercent,
|
|
tp1Percent: config.takeProfit1Percent,
|
|
tp2Percent: dynamicTp2Percent,
|
|
entrySlippage: openResult.slippage,
|
|
timestamp: new Date().toISOString(),
|
|
}
|
|
|
|
// Attach exit order signatures to response
|
|
if (exitOrderSignatures.length > 0) {
|
|
(response as any).exitOrderSignatures = exitOrderSignatures
|
|
}
|
|
|
|
// Save trade to database
|
|
try {
|
|
// Calculate quality score if metrics available
|
|
const qualityResult = scoreSignalQuality({
|
|
atr: body.atr || 0,
|
|
adx: body.adx || 0,
|
|
rsi: body.rsi || 0,
|
|
volumeRatio: body.volumeRatio || 0,
|
|
pricePosition: body.pricePosition || 0,
|
|
direction: body.direction,
|
|
timeframe: body.timeframe,
|
|
})
|
|
|
|
await createTrade({
|
|
positionId: openResult.transactionSignature!,
|
|
symbol: driftSymbol,
|
|
direction: body.direction,
|
|
entryPrice,
|
|
positionSizeUSD: actualPositionSizeUSD,
|
|
leverage: config.leverage,
|
|
stopLossPrice,
|
|
takeProfit1Price: tp1Price,
|
|
takeProfit2Price: tp2Price,
|
|
tp1SizePercent: config.takeProfit1SizePercent || 50,
|
|
tp2SizePercent: config.takeProfit2SizePercent || 100,
|
|
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,
|
|
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,
|
|
expectedSizeUSD: requestedPositionSizeUSD,
|
|
actualSizeUSD: actualPositionSizeUSD,
|
|
})
|
|
|
|
console.log(`💾 Trade saved with quality score: ${qualityResult.score}/100`)
|
|
console.log(`📊 Quality reasons: ${qualityResult.reasons.join(', ')}`)
|
|
} catch (dbError) {
|
|
console.error('❌ Failed to save trade to database:', dbError)
|
|
// Don't fail the trade if database save fails
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|