feat: phantom trade auto-closure system
- Auto-close phantom positions immediately via market order - Return HTTP 200 (not 500) to allow n8n workflow continuation - Save phantom trades to database with full P&L tracking - Exit reason: 'manual' category for phantom auto-closes - Protects user during unavailable hours (sleeping, no phone) - Add Docker build best practices to instructions (background + tail) - Document phantom system as Critical Component #1 - Add Common Pitfall #30: Phantom notification workflow Why auto-close: - User can't always respond to phantom alerts - Unmonitored position = unlimited risk exposure - Better to exit with small loss/gain than leave exposed - Re-entry possible if setup actually good Files changed: - app/api/trading/execute/route.ts: Auto-close logic - .github/copilot-instructions.md: Documentation + build pattern
This commit is contained in:
@@ -7,7 +7,7 @@
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { initializeDriftService } from '@/lib/drift/client'
|
||||
import { openPosition, placeExitOrders } from '@/lib/drift/orders'
|
||||
import { openPosition, placeExitOrders, closePosition } from '@/lib/drift/orders'
|
||||
import { normalizeTradingViewSymbol } from '@/config/trading'
|
||||
import { getMergedConfig } from '@/config/trading'
|
||||
import { getInitializedPositionManager, ActiveTrade } from '@/lib/trading/position-manager'
|
||||
@@ -320,11 +320,42 @@ export async function POST(request: NextRequest): Promise<NextResponse<ExecuteTr
|
||||
|
||||
// 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(`🚨 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...`)
|
||||
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 = scoreSignalQuality({
|
||||
atr: body.atr || 0,
|
||||
@@ -336,14 +367,15 @@ export async function POST(request: NextRequest): Promise<NextResponse<ExecuteTr
|
||||
timeframe: body.timeframe,
|
||||
})
|
||||
|
||||
await createTrade({
|
||||
// Create trade record (without exit info initially)
|
||||
const trade = await createTrade({
|
||||
positionId: openResult.transactionSignature!,
|
||||
symbol: driftSymbol,
|
||||
direction: body.direction,
|
||||
entryPrice: openResult.fillPrice!,
|
||||
positionSizeUSD: positionSizeUSD,
|
||||
leverage: leverage, // Use actual symbol-specific leverage
|
||||
stopLossPrice: 0, // Not applicable for phantom
|
||||
positionSizeUSD: openResult.actualSizeUSD || positionSizeUSD,
|
||||
leverage: leverage,
|
||||
stopLossPrice: 0,
|
||||
takeProfit1Price: 0,
|
||||
takeProfit2Price: 0,
|
||||
tp1SizePercent: 0,
|
||||
@@ -358,27 +390,72 @@ export async function POST(request: NextRequest): Promise<NextResponse<ExecuteTr
|
||||
volumeAtEntry: body.volumeRatio,
|
||||
pricePositionAtEntry: body.pricePosition,
|
||||
signalQualityScore: qualityResult.score,
|
||||
indicatorVersion: body.indicatorVersion || 'v5', // Default to v5 for backward compatibility
|
||||
// Phantom-specific fields
|
||||
indicatorVersion: body.indicatorVersion || 'v5',
|
||||
status: 'phantom',
|
||||
isPhantom: true,
|
||||
expectedSizeUSD: positionSizeUSD,
|
||||
actualSizeUSD: openResult.actualSizeUSD,
|
||||
phantomReason: 'ORACLE_PRICE_MISMATCH', // Likely cause based on logs
|
||||
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: false,
|
||||
error: 'Phantom trade detected',
|
||||
message: `Position opened but size mismatch detected. Expected $${positionSizeUSD.toFixed(2)}, got $${openResult.actualSizeUSD?.toFixed(2)}. This usually indicates oracle price was stale or order was rejected by exchange.`,
|
||||
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: 500 }
|
||||
{ status: 200 } // Changed from 500 - allows n8n to continue
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user