Improved flip-flop penalty logic to distinguish between: - Chop (bad): <2% price move from opposite signal → -25 penalty - Reversal (good): ≥2% price move from opposite signal → allowed Changes: - lib/database/trades.ts: getRecentSignals() now returns oppositeDirectionPrice - lib/trading/signal-quality.ts: Added currentPrice parameter, price movement check - app/api/trading/check-risk/route.ts: Added currentPrice to RiskCheckRequest interface - app/api/trading/execute/route.ts: Pass openResult.fillPrice as currentPrice - app/api/analytics/reentry-check/route.ts: Pass currentPrice from metrics Example scenarios: - ETH $170 SHORT → $153 LONG (10% move) = reversal allowed ✅ - ETH $154.50 SHORT → $154.30 LONG (0.13% move) = chop blocked ⚠️ Deployed: 09:18 CET Nov 14, 2025 Container: trading-bot-v4
241 lines
7.5 KiB
TypeScript
241 lines
7.5 KiB
TypeScript
import { NextRequest, NextResponse } from 'next/server'
|
|
import { getMarketDataCache } from '@/lib/trading/market-data-cache'
|
|
import { getPrismaClient } from '@/lib/database/trades'
|
|
import { scoreSignalQuality } from '@/lib/trading/signal-quality'
|
|
|
|
/**
|
|
* Re-Entry Analytics Endpoint
|
|
*
|
|
* Validates manual trades using:
|
|
* 1. Fresh TradingView market data (if available)
|
|
* 2. Recent trade performance (last 3 trades for symbol + direction)
|
|
* 3. Signal quality scoring with performance modifiers
|
|
*
|
|
* Called by Telegram bot before executing manual "long sol" / "short eth" commands
|
|
*/
|
|
|
|
interface ReentryAnalytics {
|
|
should_enter: boolean
|
|
score: number
|
|
reason: string
|
|
data_source: 'tradingview_real' | 'fallback_historical' | 'no_data'
|
|
data_age_seconds?: number
|
|
metrics: {
|
|
atr: number
|
|
adx: number
|
|
rsi: number
|
|
volumeRatio: number
|
|
pricePosition: number
|
|
timeframe: string
|
|
recentTradeStats: {
|
|
last3Trades: number
|
|
winRate: number
|
|
avgPnL: number
|
|
}
|
|
}
|
|
}
|
|
|
|
export async function POST(request: NextRequest) {
|
|
try {
|
|
const body = await request.json()
|
|
const { symbol, direction } = body
|
|
|
|
if (!symbol || !direction) {
|
|
return NextResponse.json(
|
|
{ error: 'Missing symbol or direction' },
|
|
{ status: 400 }
|
|
)
|
|
}
|
|
|
|
if (!['long', 'short'].includes(direction)) {
|
|
return NextResponse.json(
|
|
{ error: 'Direction must be "long" or "short"' },
|
|
{ status: 400 }
|
|
)
|
|
}
|
|
|
|
console.log(`🔍 Analyzing re-entry for ${direction.toUpperCase()} ${symbol}`)
|
|
|
|
// 1. Try to get REAL market data from TradingView cache
|
|
const marketCache = getMarketDataCache()
|
|
const cachedData = marketCache.get(symbol)
|
|
|
|
let metrics: any
|
|
let dataSource: 'tradingview_real' | 'fallback_historical' | 'no_data'
|
|
let dataAgeSeconds: number | undefined
|
|
|
|
if (cachedData) {
|
|
// Use REAL TradingView data (less than 5min old)
|
|
dataAgeSeconds = Math.round((Date.now() - cachedData.timestamp) / 1000)
|
|
dataSource = 'tradingview_real'
|
|
|
|
console.log(`✅ Using real TradingView data (${dataAgeSeconds}s old)`)
|
|
metrics = {
|
|
atr: cachedData.atr,
|
|
adx: cachedData.adx,
|
|
rsi: cachedData.rsi,
|
|
volumeRatio: cachedData.volumeRatio,
|
|
pricePosition: cachedData.pricePosition,
|
|
timeframe: cachedData.timeframe
|
|
}
|
|
} else {
|
|
// Fallback to most recent trade metrics
|
|
console.log(`⚠️ No fresh TradingView data, using historical metrics from last trade`)
|
|
const prisma = getPrismaClient()
|
|
const lastTrade = await prisma.trade.findFirst({
|
|
where: { symbol },
|
|
orderBy: { createdAt: 'desc' }
|
|
}) as any // Trade type has optional metric fields
|
|
|
|
if (lastTrade && lastTrade.atr && lastTrade.adx && lastTrade.rsi) {
|
|
dataSource = 'fallback_historical'
|
|
const tradeAge = Math.round((Date.now() - lastTrade.createdAt.getTime()) / 1000)
|
|
console.log(`📊 Using metrics from last trade (${tradeAge}s ago)`)
|
|
metrics = {
|
|
atr: lastTrade.atr,
|
|
adx: lastTrade.adx,
|
|
rsi: lastTrade.rsi,
|
|
volumeRatio: lastTrade.volumeRatio || 1.2,
|
|
pricePosition: lastTrade.pricePosition || 50,
|
|
timeframe: '5'
|
|
}
|
|
} else {
|
|
// No data available at all
|
|
console.log(`❌ No market data available for ${symbol}`)
|
|
dataSource = 'no_data'
|
|
metrics = {
|
|
atr: 1.0,
|
|
adx: 20,
|
|
rsi: direction === 'long' ? 45 : 55,
|
|
volumeRatio: 1.2,
|
|
pricePosition: 50,
|
|
timeframe: '5'
|
|
}
|
|
}
|
|
}
|
|
|
|
// 2. Get recent trade performance for this symbol + direction
|
|
const prisma = getPrismaClient()
|
|
const recentTrades = await prisma.trade.findMany({
|
|
where: {
|
|
symbol,
|
|
direction,
|
|
exitTime: { not: null },
|
|
createdAt: {
|
|
gte: new Date(Date.now() - 24 * 60 * 60 * 1000) // Last 24h
|
|
}
|
|
},
|
|
orderBy: { createdAt: 'desc' },
|
|
take: 3
|
|
})
|
|
|
|
const last3Count = recentTrades.length
|
|
const winningTrades = recentTrades.filter((t: any) => (t.realizedPnL || 0) > 0)
|
|
const winRate = last3Count > 0 ? (winningTrades.length / last3Count) * 100 : 0
|
|
const avgPnL = last3Count > 0
|
|
? recentTrades.reduce((sum: number, t: any) => sum + (t.realizedPnL || 0), 0) / last3Count
|
|
: 0
|
|
|
|
console.log(`📊 Recent performance: ${last3Count} trades, ${winRate.toFixed(0)}% WR, ${avgPnL.toFixed(2)}% avg P&L`)
|
|
|
|
// 3. Score the re-entry with real/fallback metrics
|
|
const qualityResult = await scoreSignalQuality({
|
|
atr: metrics.atr,
|
|
adx: metrics.adx,
|
|
rsi: metrics.rsi,
|
|
volumeRatio: metrics.volumeRatio,
|
|
pricePosition: metrics.pricePosition,
|
|
direction: direction as 'long' | 'short',
|
|
symbol: symbol,
|
|
currentPrice: metrics.currentPrice,
|
|
skipFrequencyCheck: true, // Re-entry check already considers recent trades
|
|
})
|
|
|
|
let finalScore = qualityResult.score
|
|
|
|
// 4. Apply recent performance modifiers
|
|
if (last3Count >= 2 && avgPnL < -5) {
|
|
finalScore -= 20
|
|
console.log(`⚠️ Recent trades losing (${avgPnL.toFixed(2)}% avg) - applying -20 penalty`)
|
|
}
|
|
|
|
if (last3Count >= 2 && avgPnL > 5 && winRate >= 66) {
|
|
finalScore += 10
|
|
console.log(`✨ Recent trades winning (${winRate.toFixed(0)}% WR) - applying +10 bonus`)
|
|
}
|
|
|
|
// 5. Penalize if using stale/no data
|
|
if (dataSource === 'fallback_historical') {
|
|
finalScore -= 5
|
|
console.log(`⚠️ Using historical data - applying -5 penalty`)
|
|
} else if (dataSource === 'no_data') {
|
|
finalScore -= 10
|
|
console.log(`⚠️ No market data available - applying -10 penalty`)
|
|
}
|
|
|
|
// 6. Determine if should enter
|
|
const MIN_REENTRY_SCORE = 55
|
|
const should_enter = finalScore >= MIN_REENTRY_SCORE
|
|
|
|
let reason = ''
|
|
if (!should_enter) {
|
|
if (dataSource === 'no_data') {
|
|
reason = `No market data available (score: ${finalScore})`
|
|
} else if (dataSource === 'fallback_historical') {
|
|
reason = `Using stale data (score: ${finalScore})`
|
|
} else if (finalScore < MIN_REENTRY_SCORE) {
|
|
reason = `Quality score too low (${finalScore} < ${MIN_REENTRY_SCORE})`
|
|
}
|
|
|
|
if (last3Count >= 2 && avgPnL < -5) {
|
|
reason += `. Recent ${direction} trades losing (${avgPnL.toFixed(2)}% avg)`
|
|
}
|
|
} else {
|
|
reason = `Quality score acceptable (${finalScore}/${MIN_REENTRY_SCORE})`
|
|
|
|
if (dataSource === 'tradingview_real') {
|
|
reason += ` [✅ FRESH TradingView data: ${dataAgeSeconds}s old]`
|
|
} else if (dataSource === 'fallback_historical') {
|
|
reason += ` [⚠️ Historical data - consider waiting for fresh signal]`
|
|
} else {
|
|
reason += ` [❌ No data - risky entry]`
|
|
}
|
|
|
|
if (winRate >= 66 && last3Count >= 2) {
|
|
reason += `. Recent win rate: ${winRate.toFixed(0)}%`
|
|
}
|
|
}
|
|
|
|
const response: ReentryAnalytics = {
|
|
should_enter,
|
|
score: finalScore,
|
|
reason,
|
|
data_source: dataSource,
|
|
data_age_seconds: dataAgeSeconds,
|
|
metrics: {
|
|
...metrics,
|
|
recentTradeStats: {
|
|
last3Trades: last3Count,
|
|
winRate,
|
|
avgPnL
|
|
}
|
|
}
|
|
}
|
|
|
|
console.log(`📊 Re-entry analysis complete:`, {
|
|
should_enter,
|
|
score: finalScore,
|
|
data_source: dataSource
|
|
})
|
|
|
|
return NextResponse.json(response)
|
|
|
|
} catch (error) {
|
|
console.error('❌ Re-entry analysis error:', error)
|
|
return NextResponse.json(
|
|
{ error: 'Internal server error' },
|
|
{ status: 500 }
|
|
)
|
|
}
|
|
}
|