Files
trading_bot_v4/app/api/trading/check-risk/route.ts

637 lines
25 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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 }
)
}
}