637 lines
25 KiB
TypeScript
637 lines
25 KiB
TypeScript
/**
|
||
* 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<number> {
|
||
// 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<NextResponse<RiskCheckResponse>> {
|
||
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)
|
||
|
||
// Get configuration
|
||
const config = getMergedConfig()
|
||
|
||
// CRITICAL FIX (Dec 21, 2025): Check if symbol is enabled FIRST
|
||
// Reject disabled symbols early so n8n doesn't send Telegram notifications for them
|
||
// Only affects 5-minute trading signals (data collection signals bypass this check)
|
||
const normalizedSymbol = normalizeTradingViewSymbol(body.symbol)
|
||
const timeframe = body.timeframe || '5'
|
||
|
||
if (timeframe === '5') {
|
||
// Check if this symbol is enabled for trading
|
||
let symbolEnabled = true
|
||
if (normalizedSymbol === 'SOL-PERP' && config.solana) {
|
||
symbolEnabled = config.solana.enabled
|
||
} else if (normalizedSymbol === 'ETH-PERP' && config.ethereum) {
|
||
symbolEnabled = config.ethereum.enabled
|
||
} else if (normalizedSymbol === 'FARTCOIN-PERP' && config.fartcoin) {
|
||
symbolEnabled = config.fartcoin.enabled
|
||
}
|
||
|
||
if (!symbolEnabled) {
|
||
console.log(`⛔ Risk check BLOCKED: ${normalizedSymbol} trading disabled (data collection only)`)
|
||
return NextResponse.json({
|
||
allowed: false,
|
||
reason: 'Symbol trading disabled',
|
||
details: `${normalizedSymbol} is configured for data collection only (not trading)`,
|
||
})
|
||
}
|
||
}
|
||
|
||
// 🔬 MULTI-TIMEFRAME DATA COLLECTION
|
||
// Allow all non-5min signals to bypass risk checks (they'll be saved as data collection in execute endpoint)
|
||
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`,
|
||
})
|
||
}
|
||
|
||
// 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 { getPrismaClient } = await import('@/lib/database/trades')
|
||
const prisma = getPrismaClient()
|
||
|
||
const oneHourAgo = new Date(Date.now() - 60 * 60 * 1000)
|
||
const recentFastSLTrades = await prisma.trade.findMany({
|
||
where: {
|
||
symbol: body.symbol,
|
||
exitReason: 'SL',
|
||
exitTime: {
|
||
gte: oneHourAgo,
|
||
},
|
||
holdTimeSeconds: {
|
||
lte: 300, // 5 minutes = 1 candle
|
||
},
|
||
},
|
||
orderBy: {
|
||
exitTime: 'desc',
|
||
},
|
||
take: 1,
|
||
})
|
||
|
||
if (recentFastSLTrades.length > 0) {
|
||
const lastFastSL = recentFastSLTrades[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
|
||
// DISABLED (Dec 18, 2025): Validation queue inactive with Q≥95 strategy
|
||
// Check if signal quality is in validation range (50-89)
|
||
const isInValidationRange = false // 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 }
|
||
)
|
||
}
|
||
}
|