/** * Risk Check API Endpoint * * Called by n8n workflow before executing trade * POST /api/trading/check-risk */ import { NextRequest, NextResponse } from 'next/server' import { getMergedConfig, TradingConfig, getMinQualityScoreForDirection, normalizeTradingViewSymbol } from '@/config/trading' import { getInitializedPositionManager, ActiveTrade } from '@/lib/trading/position-manager' import { getLastTradeTime, getLastTradeTimeForSymbol, getTradesInLastHour, getTodayPnL, createBlockedSignal } from '@/lib/database/trades' import { getPythPriceMonitor } from '@/lib/pyth/price-monitor' import { scoreSignalQuality, SignalQualityResult } from '@/lib/trading/signal-quality' import { initializeDriftService } from '@/lib/drift/client' import { SUPPORTED_MARKETS } from '@/config/trading' import { getSmartValidationQueue } from '@/lib/trading/smart-validation-queue' export interface RiskCheckRequest { symbol: string direction: 'long' | 'short' timeframe?: string // e.g., "5" for 5min, "60" for 1H, "D" for daily currentPrice?: number // Current market price (for flip-flop context) signalPrice?: number // TradingView-provided price snapshot indicatorVersion?: string // Pine Script version tag (v8/v9/v10) // Optional context metrics from TradingView atr?: number adx?: number rsi?: number volumeRatio?: number pricePosition?: number maGap?: number // V9: MA gap convergence metric } export interface RiskCheckResponse { allowed: boolean reason?: string details?: string qualityScore?: number qualityReasons?: string[] } /** * Get current price reliably using multiple fallback methods * Priority: Pyth cache → Drift oracle → TradingView signal price */ async function getCurrentPrice(symbol: string, fallbackPrice?: number): Promise { // Try Pyth cache first (fastest) const priceMonitor = getPythPriceMonitor() const latestPrice = priceMonitor.getCachedPrice(symbol) if (latestPrice?.price && latestPrice.price > 0) { return latestPrice.price } // Try Drift oracle (authoritative) try { const driftService = await initializeDriftService() const marketConfig = SUPPORTED_MARKETS[symbol] if (marketConfig && driftService) { const oraclePrice = await driftService.getOraclePrice(marketConfig.driftMarketIndex) if (oraclePrice > 0) { return oraclePrice } } } catch (error) { console.warn('⚠️ Failed to get Drift oracle price:', error) } // Fallback to TradingView signal price if (fallbackPrice && fallbackPrice > 0) { return fallbackPrice } console.error('❌ Unable to get current price from any source') return 0 } /** * Position Scaling Validation * Determines if adding to an existing position is allowed */ async function shouldAllowScaling( existingTrade: ActiveTrade, newSignal: RiskCheckRequest, config: TradingConfig ): Promise<{ allowed: boolean; reasons: string[]; qualityScore?: number; qualityReasons?: string[] }> { const reasons: string[] = [] // Check if we have context metrics if (!newSignal.atr || !newSignal.adx || !newSignal.pricePosition) { reasons.push('Missing signal metrics for scaling validation') return { allowed: false, reasons } } // 1. Calculate new signal quality score const qualityScore = await scoreSignalQuality({ atr: newSignal.atr, adx: newSignal.adx, rsi: newSignal.rsi || 50, volumeRatio: newSignal.volumeRatio || 1, pricePosition: newSignal.pricePosition, maGap: newSignal.maGap, // V9: MA gap convergence scoring direction: newSignal.direction, symbol: newSignal.symbol, currentPrice: newSignal.currentPrice, minScore: config.minScaleQualityScore, }) // 2. Check quality score (higher bar than initial entry) if (qualityScore.score < config.minScaleQualityScore) { reasons.push(`Quality score too low: ${qualityScore.score} (need ${config.minScaleQualityScore}+)`) return { allowed: false, reasons, qualityScore: qualityScore.score, qualityReasons: qualityScore.reasons } } // 3. Check current position profitability const priceMonitor = getPythPriceMonitor() const latestPrice = priceMonitor.getCachedPrice(newSignal.symbol) const currentPrice = latestPrice?.price if (!currentPrice) { reasons.push('Unable to fetch current price') return { allowed: false, reasons, qualityScore: qualityScore.score } } const pnlPercent = existingTrade.direction === 'long' ? ((currentPrice - existingTrade.entryPrice) / existingTrade.entryPrice) * 100 : ((existingTrade.entryPrice - currentPrice) / existingTrade.entryPrice) * 100 if (pnlPercent < config.minProfitForScale) { reasons.push(`Position not profitable enough: ${pnlPercent.toFixed(2)}% (need ${config.minProfitForScale}%+)`) return { allowed: false, reasons, qualityScore: qualityScore.score } } // 4. Check ADX trend strengthening const originalAdx = existingTrade.originalAdx || 0 const adxIncrease = newSignal.adx - originalAdx if (adxIncrease < config.minAdxIncrease) { reasons.push(`ADX not strengthening enough: +${adxIncrease.toFixed(1)} (need +${config.minAdxIncrease})`) return { allowed: false, reasons, qualityScore: qualityScore.score } } // 5. Check price position (don't chase near resistance) if (newSignal.pricePosition > config.maxPricePositionForScale) { reasons.push(`Price too high in range: ${newSignal.pricePosition.toFixed(0)}% (max ${config.maxPricePositionForScale}%)`) return { allowed: false, reasons, qualityScore: qualityScore.score } } // 6. Check max position size (if already scaled) const totalScaled = existingTrade.timesScaled || 0 const currentMultiplier = 1 + (totalScaled * (config.scaleSizePercent / 100)) const newMultiplier = currentMultiplier + (config.scaleSizePercent / 100) if (newMultiplier > config.maxScaleMultiplier) { reasons.push(`Max position size reached: ${(currentMultiplier * 100).toFixed(0)}% (max ${(config.maxScaleMultiplier * 100).toFixed(0)}%)`) return { allowed: false, reasons, qualityScore: qualityScore.score } } // All checks passed! reasons.push(`Quality: ${qualityScore.score}/100`) reasons.push(`P&L: +${pnlPercent.toFixed(2)}%`) reasons.push(`ADX increased: +${adxIncrease.toFixed(1)}`) reasons.push(`Price position: ${newSignal.pricePosition.toFixed(0)}%`) return { allowed: true, reasons, qualityScore: qualityScore.score, qualityReasons: qualityScore.reasons } } export async function POST(request: NextRequest): Promise> { try { // Verify authorization const authHeader = request.headers.get('authorization') const expectedAuth = `Bearer ${process.env.API_SECRET_KEY}` if (!authHeader || authHeader !== expectedAuth) { return NextResponse.json( { allowed: false, reason: 'Unauthorized', }, { status: 401 } ) } const body: RiskCheckRequest = await request.json() const fallbackPrice = body.currentPrice ?? body.signalPrice console.log('🔍 Risk check for:', body) // 🔬 MULTI-TIMEFRAME DATA COLLECTION // Allow all non-5min signals to bypass risk checks (they'll be saved as data collection in execute endpoint) const timeframe = body.timeframe || '5' if (timeframe !== '5') { console.log(`📊 DATA COLLECTION: ${timeframe}min signal bypassing risk checks (will save in execute endpoint)`) return NextResponse.json({ allowed: true, reason: 'Multi-timeframe data collection', details: `${timeframe}min signal will be saved for analysis but not executed`, }) } const config = getMergedConfig() // Check for existing positions on the same symbol const positionManager = await getInitializedPositionManager() const existingTrades = Array.from(positionManager.getActiveTrades().values()) const existingPosition = existingTrades.find(trade => trade.symbol === body.symbol) if (existingPosition) { // SAME direction - check if position scaling is allowed if (existingPosition.direction === body.direction) { // Position scaling feature if (config.enablePositionScaling) { const scalingCheck = await shouldAllowScaling(existingPosition, body, config) if (scalingCheck.allowed) { console.log('✅ Position scaling ALLOWED:', scalingCheck.reasons) return NextResponse.json({ allowed: true, reason: 'Position scaling', details: `Scaling into ${body.direction} position - ${scalingCheck.reasons.join(', ')}`, qualityScore: scalingCheck.qualityScore, qualityReasons: scalingCheck.qualityReasons, }) } else { console.log('🚫 Position scaling BLOCKED:', scalingCheck.reasons) return NextResponse.json({ allowed: false, reason: 'Scaling not allowed', details: scalingCheck.reasons.join(', '), qualityScore: scalingCheck.qualityScore, }) } } // Scaling disabled - block duplicate position console.log('🚫 Risk check BLOCKED: Duplicate position (same direction)', { symbol: body.symbol, existingDirection: existingPosition.direction, requestedDirection: body.direction, existingEntry: existingPosition.entryPrice, }) return NextResponse.json({ allowed: false, reason: 'Duplicate position', details: `Already have ${existingPosition.direction} position on ${body.symbol} (entry: $${existingPosition.entryPrice}). Enable scaling in settings to add to position.`, }) } // OPPOSITE direction - potential signal flip // Don't auto-allow! Let it go through normal quality checks below console.log('🔄 Potential signal flip detected - checking quality score', { symbol: body.symbol, existingDirection: existingPosition.direction, newDirection: body.direction, note: 'Will flip IF signal quality passes', }) // Continue to quality checks below instead of returning early } // Check if we have context metrics (used throughout the function) const hasContextMetrics = body.atr !== undefined && body.atr > 0 // 1. Check daily drawdown limit const todayPnL = await getTodayPnL() if (todayPnL < config.maxDailyDrawdown) { console.log('🚫 Risk check BLOCKED: Daily drawdown limit reached', { todayPnL: todayPnL.toFixed(2), maxDrawdown: config.maxDailyDrawdown, }) return NextResponse.json({ allowed: false, reason: 'Daily drawdown limit', details: `Today's P&L ($${todayPnL.toFixed(2)}) has reached max drawdown limit ($${config.maxDailyDrawdown})`, }) } // 2. Check trades per hour limit const tradesInLastHour = await getTradesInLastHour() if (tradesInLastHour >= config.maxTradesPerHour) { console.log('🚫 Risk check BLOCKED: Hourly trade limit reached', { tradesInLastHour, maxTradesPerHour: config.maxTradesPerHour, }) // Save blocked signal if we have metrics if (hasContextMetrics) { const currentPrice = await getCurrentPrice(body.symbol, fallbackPrice) if (currentPrice > 0) { await createBlockedSignal({ symbol: body.symbol, direction: body.direction, timeframe: body.timeframe, signalPrice: currentPrice, atr: body.atr, adx: body.adx, rsi: body.rsi, volumeRatio: body.volumeRatio, pricePosition: body.pricePosition, signalQualityScore: 0, // Not calculated yet minScoreRequired: config.minSignalQualityScore, blockReason: 'HOURLY_TRADE_LIMIT', blockDetails: `${tradesInLastHour} trades in last hour (max: ${config.maxTradesPerHour})`, indicatorVersion: body.indicatorVersion || 'v5', }) } else { console.warn('⚠️ Skipping blocked signal save: price unavailable (hourly limit)') } } return NextResponse.json({ allowed: false, reason: 'Hourly trade limit', details: `Already placed ${tradesInLastHour} trades in the last hour (max: ${config.maxTradesPerHour})`, }) } // 3. Check cooldown period PER SYMBOL (not global) const lastTradeTimeForSymbol = await getLastTradeTimeForSymbol(body.symbol) if (lastTradeTimeForSymbol && config.minTimeBetweenTrades > 0) { const timeSinceLastTrade = Date.now() - lastTradeTimeForSymbol.getTime() const cooldownMs = config.minTimeBetweenTrades * 60 * 1000 // Convert minutes to milliseconds if (timeSinceLastTrade < cooldownMs) { const remainingMs = cooldownMs - timeSinceLastTrade const remainingMinutes = Math.ceil(remainingMs / 60000) console.log('🚫 Risk check BLOCKED: Cooldown period active for', body.symbol, { lastTradeTime: lastTradeTimeForSymbol.toISOString(), timeSinceLastTradeMs: timeSinceLastTrade, cooldownMs, remainingMinutes, }) // Save blocked signal if we have metrics if (hasContextMetrics) { const currentPrice = await getCurrentPrice(body.symbol, fallbackPrice) if (currentPrice > 0) { await createBlockedSignal({ symbol: body.symbol, direction: body.direction, timeframe: body.timeframe, signalPrice: currentPrice, atr: body.atr, adx: body.adx, rsi: body.rsi, volumeRatio: body.volumeRatio, pricePosition: body.pricePosition, signalQualityScore: 0, // Not calculated yet minScoreRequired: config.minSignalQualityScore, blockReason: 'COOLDOWN_PERIOD', blockDetails: `Wait ${remainingMinutes} more min (cooldown: ${config.minTimeBetweenTrades} min)`, indicatorVersion: body.indicatorVersion || 'v5', }) } else { console.warn('⚠️ Skipping blocked signal save: price unavailable (cooldown)') } } return NextResponse.json({ allowed: false, reason: 'Cooldown period', details: `Must wait ${remainingMinutes} more minute(s) before next ${body.symbol} trade (cooldown: ${config.minTimeBetweenTrades} min)`, }) } } // 4. Check for instant reversal (fast SL within 1 candle - Dec 17, 2025) // Detect if most recent trade on this symbol was stopped out within 5 minutes // This prevents re-entering immediately after being whipsawed if (hasContextMetrics && body.timeframe === '5') { const recentTrades = await getTradesInLastHour() const symbolRecentTrades = recentTrades.filter(t => t.symbol === body.symbol && t.exitReason === 'SL' && t.holdTimeSeconds !== null && t.holdTimeSeconds <= 300 // 5 minutes = 1 candle ) if (symbolRecentTrades.length > 0) { const lastFastSL = symbolRecentTrades[0] const timeSinceLastSL = Date.now() - new Date(lastFastSL.exitTime!).getTime() const cooldownMs = 15 * 60 * 1000 // 15 minute cooldown after instant reversal if (timeSinceLastSL < cooldownMs) { const remainingMinutes = Math.ceil((cooldownMs - timeSinceLastSL) / 60000) console.log('🚫 Risk check BLOCKED: Instant reversal detected', { symbol: body.symbol, lastSLTime: lastFastSL.exitTime, holdTime: lastFastSL.holdTimeSeconds, remainingCooldown: remainingMinutes, }) const currentPrice = await getCurrentPrice(body.symbol, fallbackPrice) if (currentPrice > 0) { await createBlockedSignal({ symbol: body.symbol, direction: body.direction, timeframe: body.timeframe, signalPrice: currentPrice, atr: body.atr, adx: body.adx, rsi: body.rsi, volumeRatio: body.volumeRatio, pricePosition: body.pricePosition, signalQualityScore: 0, minScoreRequired: config.minSignalQualityScore, blockReason: 'INSTANT_REVERSAL_RISK', blockDetails: `Fast SL ${remainingMinutes}min ago (${lastFastSL.holdTimeSeconds}s hold). Wait ${remainingMinutes}min.`, indicatorVersion: body.indicatorVersion || 'v5', }) } return NextResponse.json({ allowed: false, reason: 'Instant reversal risk', details: `Last trade stopped out in ${lastFastSL.holdTimeSeconds}s. Wait ${remainingMinutes} more minutes to avoid whipsaw.`, }) } } } // 5. Check signal quality (if context metrics provided) if (hasContextMetrics) { // Get current price from Pyth for flip-flop price context check const priceMonitor = getPythPriceMonitor() const latestPrice = priceMonitor.getCachedPrice(body.symbol) const currentPrice = latestPrice?.price || fallbackPrice || 0 // Use direction-specific quality threshold (Nov 23, 2025) const minQualityScore = getMinQualityScoreForDirection(body.direction, config) const qualityScore = 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: body.symbol, currentPrice: currentPrice, timeframe: body.timeframe, // Pass timeframe for context-aware scoring minScore: minQualityScore // Use direction-specific threshold }) if (!qualityScore.passed) { console.log('🚫 Risk check BLOCKED: Signal quality too low', { score: qualityScore.score, direction: body.direction, threshold: minQualityScore, reasons: qualityScore.reasons }) // Get current price for the blocked signal record const currentPrice = await getCurrentPrice(body.symbol, fallbackPrice) // CRITICAL FIX (Dec 12, 2025): Smart validation integration // Check if signal quality is in validation range (50-89) const isInValidationRange = qualityScore.score >= 50 && qualityScore.score < 90 // Save blocked signal to database for future analysis if (currentPrice > 0) { // SMART VALIDATION QUEUE (Nov 30, 2025 - FIXED Dec 12, 2025) // Queue marginal quality signals (50-89) for validation instead of hard-blocking const blockReason = isInValidationRange ? 'SMART_VALIDATION_QUEUED' : 'QUALITY_SCORE_TOO_LOW' const blockDetails = isInValidationRange ? `Score: ${qualityScore.score}/${minQualityScore} - Queued for validation (will enter if +0.3%, abandon if -1.0%)` : `Score: ${qualityScore.score}/${minQualityScore} - ${qualityScore.reasons.join(', ')}` await createBlockedSignal({ symbol: body.symbol, direction: body.direction, timeframe: body.timeframe, signalPrice: currentPrice, atr: body.atr, adx: body.adx, rsi: body.rsi, volumeRatio: body.volumeRatio, pricePosition: body.pricePosition, signalQualityScore: qualityScore.score, signalQualityVersion: 'v4', scoreBreakdown: { reasons: qualityScore.reasons }, minScoreRequired: minQualityScore, blockReason: blockReason, blockDetails: blockDetails, indicatorVersion: body.indicatorVersion || 'v5', }) // Add to validation queue if in range if (isInValidationRange) { const validationQueue = getSmartValidationQueue() // CRITICAL FIX (Dec 1, 2025): Normalize TradingView symbol format to Drift format const normalizedSymbol = normalizeTradingViewSymbol(body.symbol) // CRITICAL FIX (Dec 15, 2025): Check if symbol trading is enabled BEFORE queueing // Don't send Telegram notifications for data-collection-only symbols let enabled = true if (normalizedSymbol === 'SOL-PERP' && config.solana) { enabled = config.solana.enabled } else if (normalizedSymbol === 'ETH-PERP' && config.ethereum) { enabled = config.ethereum.enabled } else if (normalizedSymbol === 'FARTCOIN-PERP' && config.fartcoin) { enabled = config.fartcoin.enabled } if (!enabled) { console.log(`⛔ Skipping validation queue: ${normalizedSymbol} trading disabled (data collection only)`) console.log(` Signal will be saved to database for analysis without notifications`) } else { const queued = await validationQueue.addSignal({ blockReason: 'SMART_VALIDATION_QUEUED', symbol: normalizedSymbol, direction: body.direction, originalPrice: currentPrice, qualityScore: qualityScore.score, atr: body.atr, adx: body.adx, rsi: body.rsi, volumeRatio: body.volumeRatio, pricePosition: body.pricePosition, indicatorVersion: body.indicatorVersion || 'v5', timeframe: body.timeframe || '5', }) if (queued) { console.log(`🧠 Signal queued for smart validation: ${normalizedSymbol} ${body.direction} (quality ${qualityScore.score})`) } } } else { console.log(`❌ Signal quality too low for validation: ${qualityScore.score} (need 50-89 range)`) } } else { console.warn('⚠️ Skipping blocked signal save: price unavailable (quality block)') } return NextResponse.json({ allowed: false, reason: 'Signal quality too low', details: `Score: ${qualityScore.score}/100 - ${qualityScore.reasons.join(', ')}`, qualityScore: qualityScore.score, qualityReasons: qualityScore.reasons }) } console.log(`✅ Risk check PASSED: All checks passed`, { todayPnL: todayPnL.toFixed(2), tradesLastHour: tradesInLastHour, cooldownPassed: lastTradeTimeForSymbol ? 'yes' : `no previous ${body.symbol} trades`, qualityScore: qualityScore.score, qualityReasons: qualityScore.reasons }) return NextResponse.json({ allowed: true, details: 'All risk checks passed', qualityScore: qualityScore.score, qualityReasons: qualityScore.reasons }) } console.log(`✅ Risk check PASSED: All checks passed`, { todayPnL: todayPnL.toFixed(2), tradesLastHour: tradesInLastHour, cooldownPassed: lastTradeTimeForSymbol ? 'yes' : `no previous ${body.symbol} trades`, }) return NextResponse.json({ allowed: true, details: 'All risk checks passed', }) } catch (error) { console.error('❌ Risk check error:', error) return NextResponse.json( { allowed: false, reason: 'Server error', details: error instanceof Error ? error.message : 'Unknown error', }, { status: 500 } ) } }