- Quality-based risk adjustment: 95+ = 15x, 90-94 = 10x, <90 = blocked - Data-driven decision: v8 quality 95+ = 100% WR (4/4 wins) - Config fields: useAdaptiveLeverage, highQualityLeverage, lowQualityLeverage, qualityLeverageThreshold - Helper function: getLeverageForQualityScore() returns appropriate leverage tier - Position sizing: Modified getActualPositionSizeForSymbol() to accept optional qualityScore param - Execute endpoint: Calculate quality score early (before sizing) for leverage determination - Test endpoint: Uses quality 100 for maximum leverage on manual test trades - ENV variables: USE_ADAPTIVE_LEVERAGE, HIGH_QUALITY_LEVERAGE, LOW_QUALITY_LEVERAGE, QUALITY_LEVERAGE_THRESHOLD - Impact: 33% less exposure on borderline quality signals (90-94) - Example: $540 × 10x = $5,400 vs $8,100 (saves $2,700 exposure on volatile signals) - Files changed: * config/trading.ts (interface, config, ENV, helper function, position sizing) * app/api/trading/execute/route.ts (early quality calculation, pass to sizing) * app/api/trading/test/route.ts (quality 100 for test trades)
386 lines
13 KiB
TypeScript
386 lines
13 KiB
TypeScript
/**
|
|
* Test Trade API Endpoint
|
|
*
|
|
* Executes a test trade with current settings (no authentication required from settings page)
|
|
* POST /api/trading/test
|
|
*/
|
|
|
|
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 } from '@/lib/database/trades'
|
|
|
|
export interface TestTradeRequest {
|
|
symbol?: string // Default: SOLUSDT
|
|
direction?: 'long' | 'short' // Default: long
|
|
}
|
|
|
|
export interface TestTradeResponse {
|
|
success: boolean
|
|
positionId?: string
|
|
symbol?: string
|
|
direction?: 'long' | 'short'
|
|
entryPrice?: number
|
|
positionSize?: number
|
|
requestedPositionSize?: number
|
|
fillCoveragePercent?: number
|
|
stopLoss?: number
|
|
takeProfit1?: number
|
|
takeProfit2?: number
|
|
softStopPrice?: number
|
|
hardStopPrice?: number
|
|
useDualStops?: boolean
|
|
timestamp?: string
|
|
error?: string
|
|
message?: string
|
|
}
|
|
|
|
export async function POST(request: NextRequest): Promise<NextResponse<TestTradeResponse>> {
|
|
try {
|
|
// Parse request body
|
|
const body: TestTradeRequest = await request.json().catch(() => ({}))
|
|
|
|
const symbol = body.symbol || 'SOLUSDT'
|
|
const direction = body.direction || 'long'
|
|
|
|
console.log('🧪 Test trade request:', { symbol, direction })
|
|
|
|
// Normalize symbol
|
|
const driftSymbol = normalizeTradingViewSymbol(symbol)
|
|
console.log(`📊 Normalized symbol: ${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)
|
|
// Test trades use quality score 100 for maximum leverage (manual override)
|
|
const { getActualPositionSizeForSymbol } = await import('@/config/trading')
|
|
const { size: positionSize, leverage, enabled, usePercentage } = await getActualPositionSizeForSymbol(
|
|
driftSymbol,
|
|
config,
|
|
health.freeCollateral,
|
|
100 // Test trades always use max leverage (quality 100)
|
|
)
|
|
|
|
// 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)}`)
|
|
|
|
// Calculate position size with leverage
|
|
const requestedPositionSizeUSD = positionSize * leverage
|
|
|
|
console.log(`💰 Opening ${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: direction,
|
|
sizeUSD: requestedPositionSizeUSD,
|
|
slippageTolerance: config.slippageTolerance,
|
|
})
|
|
|
|
if (!openResult.success) {
|
|
return NextResponse.json(
|
|
{
|
|
success: false,
|
|
error: 'Position open failed',
|
|
message: openResult.error,
|
|
},
|
|
{ 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,
|
|
direction
|
|
)
|
|
|
|
// Calculate dual stop prices if enabled
|
|
let softStopPrice: number | undefined
|
|
let hardStopPrice: number | undefined
|
|
|
|
if (config.useDualStops) {
|
|
softStopPrice = calculatePrice(
|
|
entryPrice,
|
|
config.softStopPercent,
|
|
direction
|
|
)
|
|
hardStopPrice = calculatePrice(
|
|
entryPrice,
|
|
config.hardStopPercent,
|
|
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,
|
|
direction
|
|
)
|
|
|
|
// Use ATR-based dynamic TP2 with simulated ATR for testing
|
|
const simulatedATR = entryPrice * 0.008 // Simulate 0.8% ATR for testing
|
|
|
|
const dynamicTp2Percent = calculateDynamicTp2(
|
|
entryPrice,
|
|
simulatedATR,
|
|
config
|
|
)
|
|
|
|
const tp2Price = calculatePrice(
|
|
entryPrice,
|
|
dynamicTp2Percent,
|
|
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 test)`)
|
|
|
|
// Calculate emergency stop
|
|
const emergencyStopPrice = calculatePrice(
|
|
entryPrice,
|
|
config.emergencyStopPercent,
|
|
direction
|
|
)
|
|
|
|
// Create active trade object
|
|
const activeTrade: ActiveTrade = {
|
|
id: `test-trade-${Date.now()}`,
|
|
positionId: openResult.transactionSignature!,
|
|
symbol: driftSymbol,
|
|
direction: direction,
|
|
entryPrice,
|
|
entryTime: Date.now(),
|
|
positionSize: actualPositionSizeUSD,
|
|
leverage: leverage,
|
|
stopLossPrice,
|
|
tp1Price,
|
|
tp2Price,
|
|
emergencyStopPrice,
|
|
currentSize: actualPositionSizeUSD,
|
|
originalPositionSize: actualPositionSizeUSD, // Store original size for 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,
|
|
priceCheckCount: 0,
|
|
lastPrice: entryPrice,
|
|
lastUpdateTime: Date.now(),
|
|
}
|
|
|
|
// Create response object (prepare before database save)
|
|
const response: TestTradeResponse = {
|
|
success: true,
|
|
positionId: openResult.transactionSignature,
|
|
symbol: driftSymbol,
|
|
direction: direction,
|
|
entryPrice: entryPrice,
|
|
positionSize: actualPositionSizeUSD,
|
|
requestedPositionSize: requestedPositionSizeUSD,
|
|
fillCoveragePercent: Number(fillCoverage.toFixed(2)),
|
|
stopLoss: stopLossPrice,
|
|
takeProfit1: tp1Price,
|
|
takeProfit2: tp2Price,
|
|
softStopPrice: softStopPrice,
|
|
hardStopPrice: hardStopPrice,
|
|
useDualStops: config.useDualStops,
|
|
timestamp: new Date().toISOString(),
|
|
}
|
|
|
|
// Place on-chain TP/SL orders so they appear in Drift UI
|
|
let exitOrderSignatures: string[] = []
|
|
try {
|
|
const exitRes = await placeExitOrders({
|
|
symbol: driftSymbol,
|
|
positionSizeUSD: actualPositionSizeUSD,
|
|
entryPrice: entryPrice,
|
|
tp1Price,
|
|
tp2Price,
|
|
stopLossPrice,
|
|
tp1SizePercent: config.takeProfit1SizePercent ?? 50,
|
|
tp2SizePercent: config.takeProfit2SizePercent ?? 0, // 0 = activate trailing stop for runner
|
|
direction: 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 || []
|
|
}
|
|
|
|
// Attach signatures to response when available
|
|
if (exitRes.signatures && exitRes.signatures.length > 0) {
|
|
;(response as any).exitOrderSignatures = exitRes.signatures
|
|
}
|
|
} catch (err) {
|
|
console.error('❌ Unexpected error placing exit orders:', err)
|
|
}
|
|
|
|
// Save trade to database
|
|
try {
|
|
await createTrade({
|
|
positionId: openResult.transactionSignature!,
|
|
symbol: driftSymbol,
|
|
direction: direction,
|
|
entryPrice,
|
|
positionSizeUSD: actualPositionSizeUSD,
|
|
leverage: leverage,
|
|
stopLossPrice,
|
|
takeProfit1Price: tp1Price,
|
|
takeProfit2Price: tp2Price,
|
|
tp1SizePercent: config.takeProfit1SizePercent ?? 50,
|
|
tp2SizePercent: config.takeProfit2SizePercent ?? 0, // 0 = activate trailing stop for runner
|
|
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: 'test',
|
|
timeframe: 'manual',
|
|
expectedSizeUSD: requestedPositionSizeUSD,
|
|
actualSizeUSD: actualPositionSizeUSD,
|
|
})
|
|
|
|
console.log('💾 Trade saved to database')
|
|
} 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')
|
|
|
|
// 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
|
|
const positionManager = await getInitializedPositionManager()
|
|
await positionManager.addTrade(activeTrade)
|
|
|
|
console.log('✅ Trade added to position manager for monitoring')
|
|
console.log('✅ Test trade executed successfully!')
|
|
|
|
return NextResponse.json(response)
|
|
|
|
} catch (error) {
|
|
console.error('❌ Test 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)
|
|
}
|
|
}
|