- Updated execute endpoint to store context metrics in database - Updated CreateTradeParams interface with 5 context metrics - Updated Prisma schema with rsiAtEntry and pricePositionAtEntry - Ran migration: add_rsi_and_price_position_metrics - Complete flow: TradingView → n8n → check-risk (scores) → execute (stores)
317 lines
9.9 KiB
TypeScript
317 lines
9.9 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 } from '@/config/trading'
|
|
import { getInitializedPositionManager } from '@/lib/trading/position-manager'
|
|
import { getLastTradeTime, 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<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)
|
|
|
|
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
|
|
const lastTradeTime = await getLastTradeTime()
|
|
if (lastTradeTime && config.minTimeBetweenTrades > 0) {
|
|
const timeSinceLastTrade = Date.now() - lastTradeTime.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', {
|
|
lastTradeTime: lastTradeTime.toISOString(),
|
|
timeSinceLastTradeMs: timeSinceLastTrade,
|
|
cooldownMs,
|
|
remainingMinutes,
|
|
})
|
|
|
|
return NextResponse.json({
|
|
allowed: false,
|
|
reason: 'Cooldown period',
|
|
details: `Must wait ${remainingMinutes} more minute(s) before next 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
|
|
})
|
|
|
|
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: lastTradeTime ? 'yes' : 'no previous 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: lastTradeTime ? 'yes' : 'no previous 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'
|
|
}): SignalQualityResult {
|
|
let score = 50 // Base score
|
|
const reasons: string[] = []
|
|
|
|
// ATR check (volatility gate: 0.6% - 2.5%)
|
|
if (params.atr > 0) {
|
|
if (params.atr < 0.6) {
|
|
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 {
|
|
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.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)
|
|
if (params.pricePosition > 0) {
|
|
if (params.direction === 'long' && params.pricePosition > 90) {
|
|
score -= 15
|
|
reasons.push(`Price near top of range (${params.pricePosition.toFixed(0)}%) - risky long`)
|
|
} else if (params.direction === 'short' && params.pricePosition < 10) {
|
|
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)}%)`)
|
|
}
|
|
}
|
|
|
|
const minScore = 60 // Require 60+ to pass
|
|
return {
|
|
passed: score >= minScore,
|
|
score,
|
|
reasons
|
|
}
|
|
}
|