/** * Risk Check API Endpoint * * Called by n8n workflow before executing trade * POST /api/trading/check-risk */ import { NextRequest, NextResponse } from 'next/server' import { getMergedConfig } from '@/config/trading' import { getInitializedPositionManager } from '@/lib/trading/position-manager' import { getLastTradeTime, getLastTradeTimeForSymbol, getTradesInLastHour, getTodayPnL } from '@/lib/database/trades' export interface RiskCheckRequest { symbol: string direction: 'long' | 'short' // Optional context metrics from TradingView atr?: number adx?: number rsi?: number volumeRatio?: number pricePosition?: number } export interface RiskCheckResponse { allowed: boolean reason?: string details?: string qualityScore?: number qualityReasons?: string[] } 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() console.log('🔍 Risk check for:', body) 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) { // Check if it's the SAME direction (duplicate - block it) if (existingPosition.direction === body.direction) { 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})`, }) } // OPPOSITE direction - this is a signal flip/reversal (ALLOW IT) console.log('🔄 Risk check: Signal flip detected', { symbol: body.symbol, existingDirection: existingPosition.direction, newDirection: body.direction, note: 'Will close existing and open opposite', }) return NextResponse.json({ allowed: true, reason: 'Signal flip', details: `Signal reversed from ${existingPosition.direction} to ${body.direction} - will flip position`, }) } // 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, }) 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, }) 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 signal quality (if context metrics provided) const hasContextMetrics = body.atr !== undefined && body.atr > 0 if (hasContextMetrics) { const qualityScore = scoreSignalQuality({ atr: body.atr || 0, adx: body.adx || 0, rsi: body.rsi || 0, volumeRatio: body.volumeRatio || 0, pricePosition: body.pricePosition || 0, direction: body.direction, minScore: 60 // Default minimum quality score threshold }) if (!qualityScore.passed) { console.log('🚫 Risk check BLOCKED: Signal quality too low', { score: qualityScore.score, reasons: qualityScore.reasons }) 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: 'Risk check failed', details: error instanceof Error ? error.message : 'Unknown error', }, { status: 500 } ) } } interface SignalQualityResult { passed: boolean score: number reasons: string[] } /** * Score signal quality based on context metrics from TradingView * Returns score 0-100 and array of reasons */ function scoreSignalQuality(params: { atr: number adx: number rsi: number volumeRatio: number pricePosition: number direction: 'long' | 'short' minScore?: number // Configurable minimum score threshold }): SignalQualityResult { let score = 50 // Base score const reasons: string[] = [] // ATR check (volatility gate: 0.15% - 2.5%) if (params.atr > 0) { if (params.atr < 0.15) { score -= 15 reasons.push(`ATR too low (${params.atr.toFixed(2)}% - dead market)`) } else if (params.atr > 2.5) { score -= 20 reasons.push(`ATR too high (${params.atr.toFixed(2)}% - too volatile)`) } else if (params.atr >= 0.15 && params.atr < 0.4) { score += 5 reasons.push(`ATR moderate (${params.atr.toFixed(2)}%)`) } else { score += 10 reasons.push(`ATR healthy (${params.atr.toFixed(2)}%)`) } } // ADX check (trend strength: want >18) if (params.adx > 0) { if (params.adx > 25) { score += 15 reasons.push(`Strong trend (ADX ${params.adx.toFixed(1)})`) } else if (params.adx < 18) { score -= 15 reasons.push(`Weak trend (ADX ${params.adx.toFixed(1)})`) } else { score += 5 reasons.push(`Moderate trend (ADX ${params.adx.toFixed(1)})`) } } // RSI check (momentum confirmation) if (params.rsi > 0) { if (params.direction === 'long') { if (params.rsi > 50 && params.rsi < 70) { score += 10 reasons.push(`RSI supports long (${params.rsi.toFixed(1)})`) } else if (params.rsi > 70) { score -= 10 reasons.push(`RSI overbought (${params.rsi.toFixed(1)})`) } } else { // short if (params.rsi < 50 && params.rsi > 30) { score += 10 reasons.push(`RSI supports short (${params.rsi.toFixed(1)})`) } else if (params.rsi < 30) { score -= 10 reasons.push(`RSI oversold (${params.rsi.toFixed(1)})`) } } } // Volume check (want > 1.0 = above average) if (params.volumeRatio > 0) { if (params.volumeRatio > 1.5) { score += 15 reasons.push(`Very strong volume (${params.volumeRatio.toFixed(2)}x avg)`) } else if (params.volumeRatio > 1.2) { score += 10 reasons.push(`Strong volume (${params.volumeRatio.toFixed(2)}x avg)`) } else if (params.volumeRatio < 0.8) { score -= 10 reasons.push(`Weak volume (${params.volumeRatio.toFixed(2)}x avg)`) } } // Price position check (avoid chasing vs breakout detection) if (params.pricePosition > 0) { if (params.direction === 'long' && params.pricePosition > 95) { // High volume breakout at range top can be good if (params.volumeRatio > 1.4) { score += 5 reasons.push(`Volume breakout at range top (${params.pricePosition.toFixed(0)}%, vol ${params.volumeRatio.toFixed(2)}x)`) } else { score -= 15 reasons.push(`Price near top of range (${params.pricePosition.toFixed(0)}%) - risky long`) } } else if (params.direction === 'short' && params.pricePosition < 5) { // High volume breakdown at range bottom can be good if (params.volumeRatio > 1.4) { score += 5 reasons.push(`Volume breakdown at range bottom (${params.pricePosition.toFixed(0)}%, vol ${params.volumeRatio.toFixed(2)}x)`) } else { score -= 15 reasons.push(`Price near bottom of range (${params.pricePosition.toFixed(0)}%) - risky short`) } } else { score += 5 reasons.push(`Price position OK (${params.pricePosition.toFixed(0)}%)`) } } // Volume breakout bonus (high volume can override other weaknesses) if (params.volumeRatio > 1.8 && params.atr < 0.6) { score += 10 reasons.push(`Volume breakout compensates for low ATR`) } const minScore = params.minScore ?? 60 // Use config value or default to 60 return { passed: score >= minScore, score, reasons } }