Files
trading_bot_v4/app/api/trading/check-risk/route.ts
mindesbunister f2bc13dba0 critical: Add maGap to TypeScript interfaces for v9 compatibility
- Added maGap field to RiskCheckRequest interface
- Added maGap field to ExecuteTradeRequest interface
- Health check already enhanced with database connectivity check
- Fixes TypeScript build errors blocking deployment
2025-11-26 14:01:11 +01:00

465 lines
18 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 } 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'
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)
// 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()
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, body.currentPrice)
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})`,
})
}
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, body.currentPrice)
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)`,
})
}
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) {
// Get current price from Pyth for flip-flop price context check
const priceMonitor = getPythPriceMonitor()
const latestPrice = priceMonitor.getCachedPrice(body.symbol)
const currentPrice = latestPrice?.price || body.currentPrice
// 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, body.currentPrice)
// Save blocked signal to database for future analysis
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', // Update this when scoring logic changes
scoreBreakdown: { reasons: qualityScore.reasons },
minScoreRequired: minQualityScore, // Use direction-specific threshold
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 }
)
}
}