PHASE 1 IMPLEMENTATION: Signal quality scoring now checks database for recent trading patterns and applies penalties to prevent overtrading and flip-flop losses. NEW PENALTIES: 1. Overtrading: 3+ signals in 30min → -20 points - Detects consolidation zones where system generates excessive signals - Counts both executed trades AND blocked signals 2. Flip-flop: Opposite direction in last 15min → -25 points - Prevents rapid long→short→long whipsaws - Example: SHORT at 10:00, LONG at 10:12 = blocked 3. Alternating pattern: Last 3 trades flip directions → -30 points - Detects choppy market conditions - Pattern like long→short→long = system getting chopped DATABASE INTEGRATION: - New function: getRecentSignals() in lib/database/trades.ts - Queries last 30min of trades + blocked signals - Checks last 3 executed trades for alternating pattern - Zero performance impact (fast indexed queries) ARCHITECTURE: - scoreSignalQuality() now async (requires database access) - All callers updated: check-risk, execute, reentry-check - skipFrequencyCheck flag available for special cases - Frequency penalties included in qualityResult breakdown EXPECTED IMPACT: - Eliminate overnight flip-flop losses (like SOL $141-145 chop) - Reduce overtrading during sideways consolidation - Better capital preservation in non-trending markets - Should improve win rate by 5-10% by avoiding worst setups TESTING: - Deploy and monitor next 5 signals in choppy markets - Check logs for frequency penalty messages - Analyze if blocked signals would have been losers Files changed: - lib/database/trades.ts: Added getRecentSignals() - lib/trading/signal-quality.ts: Made async, added frequency checks - app/api/trading/check-risk/route.ts: await + symbol parameter - app/api/trading/execute/route.ts: await + symbol parameter - app/api/analytics/reentry-check/route.ts: await + skipFrequencyCheck
404 lines
15 KiB
TypeScript
404 lines
15 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 } 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'
|
|
|
|
export interface RiskCheckRequest {
|
|
symbol: string
|
|
direction: 'long' | 'short'
|
|
timeframe?: string // e.g., "5" for 5min, "60" for 1H, "D" for daily
|
|
// 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[]
|
|
}
|
|
|
|
/**
|
|
* 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,
|
|
direction: newSignal.direction,
|
|
symbol: newSignal.symbol,
|
|
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)
|
|
|
|
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 priceMonitor = getPythPriceMonitor()
|
|
const latestPrice = priceMonitor.getCachedPrice(body.symbol)
|
|
|
|
await createBlockedSignal({
|
|
symbol: body.symbol,
|
|
direction: body.direction,
|
|
timeframe: body.timeframe,
|
|
signalPrice: latestPrice?.price || 0,
|
|
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 priceMonitor = getPythPriceMonitor()
|
|
const latestPrice = priceMonitor.getCachedPrice(body.symbol)
|
|
|
|
await createBlockedSignal({
|
|
symbol: body.symbol,
|
|
direction: body.direction,
|
|
timeframe: body.timeframe,
|
|
signalPrice: latestPrice?.price || 0,
|
|
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) {
|
|
const qualityScore = await scoreSignalQuality({
|
|
atr: body.atr || 0,
|
|
adx: body.adx || 0,
|
|
rsi: body.rsi || 0,
|
|
volumeRatio: body.volumeRatio || 0,
|
|
pricePosition: body.pricePosition || 0,
|
|
direction: body.direction,
|
|
symbol: body.symbol,
|
|
timeframe: body.timeframe, // Pass timeframe for context-aware scoring
|
|
minScore: config.minSignalQualityScore // Use config value
|
|
})
|
|
|
|
if (!qualityScore.passed) {
|
|
console.log('🚫 Risk check BLOCKED: Signal quality too low', {
|
|
score: qualityScore.score,
|
|
threshold: config.minSignalQualityScore,
|
|
reasons: qualityScore.reasons
|
|
})
|
|
|
|
// Get current price for the blocked signal record
|
|
const priceMonitor = getPythPriceMonitor()
|
|
const latestPrice = priceMonitor.getCachedPrice(body.symbol)
|
|
|
|
// Save blocked signal to database for future analysis
|
|
await createBlockedSignal({
|
|
symbol: body.symbol,
|
|
direction: body.direction,
|
|
timeframe: body.timeframe,
|
|
signalPrice: latestPrice?.price || 0,
|
|
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: config.minSignalQualityScore,
|
|
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 }
|
|
)
|
|
}
|
|
}
|