/** * 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 } 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' export interface RiskCheckRequest { symbol: string direction: 'long' | 'short' timeframe?: string // e.g., "5" for 5min, "60" for 1H, "D" for daily // 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[] } /** * 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, direction: newSignal.direction, symbol: newSignal.symbol, 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() 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) { // 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 priceMonitor = getPythPriceMonitor() const latestPrice = priceMonitor.getCachedPrice(body.symbol) await createBlockedSignal({ symbol: body.symbol, direction: body.direction, timeframe: body.timeframe, signalPrice: latestPrice?.price || 0, 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})`, }) } 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 priceMonitor = getPythPriceMonitor() const latestPrice = priceMonitor.getCachedPrice(body.symbol) await createBlockedSignal({ symbol: body.symbol, direction: body.direction, timeframe: body.timeframe, signalPrice: latestPrice?.price || 0, 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)`, }) } 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) if (hasContextMetrics) { const qualityScore = await scoreSignalQuality({ atr: body.atr || 0, adx: body.adx || 0, rsi: body.rsi || 0, volumeRatio: body.volumeRatio || 0, pricePosition: body.pricePosition || 0, direction: body.direction, symbol: body.symbol, timeframe: body.timeframe, // Pass timeframe for context-aware scoring minScore: config.minSignalQualityScore // Use config value }) if (!qualityScore.passed) { console.log('🚫 Risk check BLOCKED: Signal quality too low', { score: qualityScore.score, threshold: config.minSignalQualityScore, reasons: qualityScore.reasons }) // Get current price for the blocked signal record const priceMonitor = getPythPriceMonitor() const latestPrice = priceMonitor.getCachedPrice(body.symbol) // Save blocked signal to database for future analysis await createBlockedSignal({ symbol: body.symbol, direction: body.direction, timeframe: body.timeframe, signalPrice: latestPrice?.price || 0, atr: body.atr, adx: body.adx, rsi: body.rsi, volumeRatio: body.volumeRatio, pricePosition: body.pricePosition, signalQualityScore: qualityScore.score, signalQualityVersion: 'v4', // Update this when scoring logic changes scoreBreakdown: { reasons: qualityScore.reasons }, minScoreRequired: config.minSignalQualityScore, blockReason: 'QUALITY_SCORE_TOO_LOW', blockDetails: `Score: ${qualityScore.score}/${config.minSignalQualityScore} - ${qualityScore.reasons.join(', ')}`, }) 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 } ) } }