Files
trading_bot_v4/app/api/trading/check-risk/route.ts
mindesbunister 881a99242d feat: Add per-symbol trading controls for SOL and ETH
- Add SymbolSettings interface with enabled/positionSize/leverage fields
- Implement per-symbol ENV variables (SOLANA_*, ETHEREUM_*)
- Add SOL and ETH sections to settings UI with enable/disable toggles
- Add symbol-specific test buttons (SOL LONG/SHORT, ETH LONG/SHORT)
- Update execute and test endpoints to check symbol enabled status
- Add real-time risk/reward calculator per symbol
- Rename 'Position Sizing' to 'Global Fallback' for clarity
- Fix position manager P&L calculation for externally closed positions
- Fix zero P&L bug affecting 12 historical trades
- Add SQL scripts for recalculating historical P&L data
- Move archive TypeScript files to .archive to fix build

Defaults:
- SOL: 10 base × 10x leverage = 100 notional (profit trading)
- ETH:  base × 1x leverage =  notional (data collection)
- Global: 10 × 10x for BTC and other symbols

Configuration priority: Per-symbol ENV > Market config > Global ENV > Defaults
2025-11-03 10:28:48 +01:00

343 lines
11 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, getLastTradeTimeForSymbol, 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 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,
})
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)
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,
minScore: 60 // Default minimum quality score threshold
})
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: 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: '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'
minScore?: number // Configurable minimum score threshold
}): SignalQualityResult {
let score = 50 // Base score
const reasons: string[] = []
// ATR check (volatility gate: 0.15% - 2.5%)
if (params.atr > 0) {
if (params.atr < 0.15) {
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 if (params.atr >= 0.15 && params.atr < 0.4) {
score += 5
reasons.push(`ATR moderate (${params.atr.toFixed(2)}%)`)
} 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.5) {
score += 15
reasons.push(`Very strong volume (${params.volumeRatio.toFixed(2)}x avg)`)
} else 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 vs breakout detection)
if (params.pricePosition > 0) {
if (params.direction === 'long' && params.pricePosition > 95) {
// High volume breakout at range top can be good
if (params.volumeRatio > 1.4) {
score += 5
reasons.push(`Volume breakout at range top (${params.pricePosition.toFixed(0)}%, vol ${params.volumeRatio.toFixed(2)}x)`)
} else {
score -= 15
reasons.push(`Price near top of range (${params.pricePosition.toFixed(0)}%) - risky long`)
}
} else if (params.direction === 'short' && params.pricePosition < 5) {
// High volume breakdown at range bottom can be good
if (params.volumeRatio > 1.4) {
score += 5
reasons.push(`Volume breakdown at range bottom (${params.pricePosition.toFixed(0)}%, vol ${params.volumeRatio.toFixed(2)}x)`)
} else {
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)}%)`)
}
}
// Volume breakout bonus (high volume can override other weaknesses)
if (params.volumeRatio > 1.8 && params.atr < 0.6) {
score += 10
reasons.push(`Volume breakout compensates for low ATR`)
}
const minScore = params.minScore ?? 60 // Use config value or default to 60
return {
passed: score >= minScore,
score,
reasons
}
}