feat: Add comprehensive optimization analytics dashboard
- Created /api/optimization/analyze endpoint with 7 SQL analyses - Replaced old TP/SL page with comprehensive dashboard - Analyses: Quality Score Distribution, Direction Performance, Blocked Signals, Runner Performance, ATR vs MFE, Indicator Versions, Data Collection Status - Real-time refresh capability - Actionable recommendations based on data thresholds - Roadmap links at bottom - Addresses user request for automated SQL analysis dashboard
This commit is contained in:
419
app/api/optimization/analyze/route.ts
Normal file
419
app/api/optimization/analyze/route.ts
Normal file
@@ -0,0 +1,419 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { getPrismaClient } from '../../../../lib/database/trades'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
export async function GET() {
|
||||
const prisma = getPrismaClient()
|
||||
const analyses = []
|
||||
|
||||
try {
|
||||
// ============================================================================
|
||||
// 1. QUALITY SCORE DISTRIBUTION
|
||||
// ============================================================================
|
||||
try {
|
||||
const qualityDistribution = await prisma.$queryRaw<any[]>`
|
||||
SELECT
|
||||
CASE
|
||||
WHEN "signalQualityScore" >= 95 THEN '95-100 (Excellent)'
|
||||
WHEN "signalQualityScore" >= 90 THEN '90-94 (Very Good)'
|
||||
WHEN "signalQualityScore" >= 85 THEN '85-89 (Good)'
|
||||
WHEN "signalQualityScore" >= 80 THEN '80-84 (Fair)'
|
||||
WHEN "signalQualityScore" >= 70 THEN '70-79 (Marginal)'
|
||||
WHEN "signalQualityScore" >= 60 THEN '60-69 (Weak)'
|
||||
ELSE '<60 (Very Weak)'
|
||||
END as tier,
|
||||
COUNT(*) as trades,
|
||||
ROUND(AVG("signalQualityScore")::numeric, 1) as avg_score,
|
||||
SUM(CASE WHEN "realizedPnL" > 0 THEN 1 ELSE 0 END) as wins,
|
||||
SUM(CASE WHEN "realizedPnL" <= 0 THEN 1 ELSE 0 END) as losses,
|
||||
ROUND(100.0 * SUM(CASE WHEN "realizedPnL" > 0 THEN 1 ELSE 0 END) / COUNT(*)::numeric, 1) as win_rate,
|
||||
ROUND(SUM("realizedPnL")::numeric, 2) as total_pnl,
|
||||
ROUND(AVG("realizedPnL")::numeric, 2) as avg_pnl
|
||||
FROM "Trade"
|
||||
WHERE "exitReason" IS NOT NULL
|
||||
AND "signalQualityScore" IS NOT NULL
|
||||
GROUP BY tier
|
||||
ORDER BY MIN("signalQualityScore") DESC
|
||||
`
|
||||
|
||||
const formattedData = qualityDistribution.map(row => ({
|
||||
tier: row.tier,
|
||||
trades: Number(row.trades),
|
||||
avg_score: Number(row.avg_score),
|
||||
win_rate: Number(row.win_rate),
|
||||
total_pnl: Number(row.total_pnl),
|
||||
avg_pnl: Number(row.avg_pnl)
|
||||
}))
|
||||
|
||||
// Find best performing tier
|
||||
const bestTier = formattedData.reduce((best, current) =>
|
||||
current.win_rate > best.win_rate ? current : best
|
||||
)
|
||||
|
||||
analyses.push({
|
||||
name: 'Quality Score Distribution',
|
||||
description: 'Win rate and P&L across quality score tiers',
|
||||
status: 'success',
|
||||
data: formattedData,
|
||||
recommendation: bestTier.win_rate >= 70
|
||||
? `Quality ${bestTier.tier.split(' ')[0]} shows ${bestTier.win_rate}% WR. Consider raising threshold to this tier.`
|
||||
: 'Continue collecting data for reliable quality threshold optimization.'
|
||||
})
|
||||
} catch (error) {
|
||||
analyses.push({
|
||||
name: 'Quality Score Distribution',
|
||||
description: 'Win rate and P&L across quality score tiers',
|
||||
status: 'error',
|
||||
data: { error: (error as Error).message }
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 2. DIRECTION PERFORMANCE (Long vs Short)
|
||||
// ============================================================================
|
||||
try {
|
||||
const directionPerformance = await prisma.$queryRaw<any[]>`
|
||||
SELECT
|
||||
direction,
|
||||
COUNT(*) as trades,
|
||||
SUM(CASE WHEN "realizedPnL" > 0 THEN 1 ELSE 0 END) as wins,
|
||||
ROUND(100.0 * SUM(CASE WHEN "realizedPnL" > 0 THEN 1 ELSE 0 END) / COUNT(*)::numeric, 1) as win_rate,
|
||||
ROUND(SUM("realizedPnL")::numeric, 2) as total_pnl,
|
||||
ROUND(AVG("realizedPnL")::numeric, 2) as avg_pnl,
|
||||
ROUND(AVG("signalQualityScore")::numeric, 1) as avg_quality
|
||||
FROM "Trade"
|
||||
WHERE "exitReason" IS NOT NULL
|
||||
AND direction IS NOT NULL
|
||||
GROUP BY direction
|
||||
ORDER BY win_rate DESC
|
||||
`
|
||||
|
||||
const formattedData = directionPerformance.map(row => ({
|
||||
direction: String(row.direction).toUpperCase(),
|
||||
trades: Number(row.trades),
|
||||
wins: Number(row.wins),
|
||||
win_rate: Number(row.win_rate),
|
||||
total_pnl: Number(row.total_pnl),
|
||||
avg_pnl: Number(row.avg_pnl),
|
||||
avg_quality: Number(row.avg_quality)
|
||||
}))
|
||||
|
||||
const longData = formattedData.find(d => d.direction === 'LONG')
|
||||
const shortData = formattedData.find(d => d.direction === 'SHORT')
|
||||
|
||||
let recommendation = ''
|
||||
if (longData && shortData) {
|
||||
const wrDiff = Math.abs(longData.win_rate - shortData.win_rate)
|
||||
if (wrDiff > 15) {
|
||||
const better = longData.win_rate > shortData.win_rate ? 'LONG' : 'SHORT'
|
||||
const worse = better === 'LONG' ? 'SHORT' : 'LONG'
|
||||
recommendation = `${better} signals perform ${wrDiff.toFixed(1)}% better. Consider raising ${worse} quality threshold or reducing ${worse} position size.`
|
||||
} else {
|
||||
recommendation = 'Direction performance is balanced. No threshold adjustment needed.'
|
||||
}
|
||||
}
|
||||
|
||||
analyses.push({
|
||||
name: 'Direction Performance',
|
||||
description: 'Compare LONG vs SHORT trade outcomes',
|
||||
status: 'success',
|
||||
data: formattedData,
|
||||
recommendation
|
||||
})
|
||||
} catch (error) {
|
||||
analyses.push({
|
||||
name: 'Direction Performance',
|
||||
description: 'Compare LONG vs SHORT trade outcomes',
|
||||
status: 'error',
|
||||
data: { error: (error as Error).message }
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 3. BLOCKED SIGNALS ANALYSIS
|
||||
// ============================================================================
|
||||
try {
|
||||
const blockedSignals = await prisma.$queryRaw<any[]>`
|
||||
SELECT
|
||||
"blockReason",
|
||||
COUNT(*) as count,
|
||||
ROUND(AVG("signalQualityScore")::numeric, 1) as avg_score,
|
||||
ROUND(AVG(adx)::numeric, 1) as avg_adx,
|
||||
ROUND(AVG(atr)::numeric, 3) as avg_atr
|
||||
FROM "BlockedSignal"
|
||||
WHERE "blockReason" IS NOT NULL
|
||||
GROUP BY "blockReason"
|
||||
ORDER BY count DESC
|
||||
`
|
||||
|
||||
const formattedData = blockedSignals.map(row => ({
|
||||
reason: String(row.blockReason),
|
||||
count: Number(row.count),
|
||||
avg_score: Number(row.avg_score),
|
||||
avg_adx: Number(row.avg_adx),
|
||||
avg_atr: Number(row.avg_atr)
|
||||
}))
|
||||
|
||||
const qualityBlocked = formattedData.find(d => d.reason === 'QUALITY_SCORE_TOO_LOW')
|
||||
let recommendation = ''
|
||||
if (qualityBlocked && qualityBlocked.count >= 20) {
|
||||
recommendation = `${qualityBlocked.count} signals blocked by quality threshold. Ready for Phase 2 analysis - check if these would have been profitable.`
|
||||
} else {
|
||||
const needed = qualityBlocked ? 20 - qualityBlocked.count : 20
|
||||
recommendation = `Need ${needed} more blocked signals for reliable analysis. Keep collecting data.`
|
||||
}
|
||||
|
||||
analyses.push({
|
||||
name: 'Blocked Signals Analysis',
|
||||
description: 'Signals rejected by quality filters',
|
||||
status: 'success',
|
||||
data: formattedData,
|
||||
recommendation,
|
||||
action: qualityBlocked && qualityBlocked.count >= 20
|
||||
? 'Run price tracking analysis to determine if blocked signals would have been profitable'
|
||||
: undefined
|
||||
})
|
||||
} catch (error) {
|
||||
analyses.push({
|
||||
name: 'Blocked Signals Analysis',
|
||||
description: 'Signals rejected by quality filters',
|
||||
status: 'error',
|
||||
data: { error: (error as Error).message }
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 4. RUNNER PERFORMANCE ANALYSIS
|
||||
// ============================================================================
|
||||
try {
|
||||
const runnerPerformance = await prisma.$queryRaw<any[]>`
|
||||
SELECT
|
||||
COUNT(*) as total_trades,
|
||||
SUM(CASE WHEN "tp1Hit" = true THEN 1 ELSE 0 END) as tp1_hits,
|
||||
SUM(CASE WHEN "tp2Hit" = true THEN 1 ELSE 0 END) as tp2_hits,
|
||||
ROUND(100.0 * SUM(CASE WHEN "tp1Hit" = true THEN 1 ELSE 0 END) / COUNT(*)::numeric, 1) as tp1_rate,
|
||||
ROUND(100.0 * SUM(CASE WHEN "tp2Hit" = true THEN 1 ELSE 0 END) / COUNT(*)::numeric, 1) as tp2_rate,
|
||||
ROUND(AVG("maxFavorableExcursion")::numeric, 2) as avg_mfe,
|
||||
ROUND(AVG("maxAdverseExcursion")::numeric, 2) as avg_mae
|
||||
FROM "Trade"
|
||||
WHERE "exitReason" IS NOT NULL
|
||||
AND "createdAt" >= NOW() - INTERVAL '30 days'
|
||||
`
|
||||
|
||||
const data = runnerPerformance[0]
|
||||
const formattedData = {
|
||||
total_trades: Number(data.total_trades),
|
||||
tp1_hits: Number(data.tp1_hits),
|
||||
tp2_hits: Number(data.tp2_hits),
|
||||
tp1_rate: Number(data.tp1_rate),
|
||||
tp2_rate: Number(data.tp2_rate),
|
||||
avg_mfe: Number(data.avg_mfe),
|
||||
avg_mae: Number(data.avg_mae)
|
||||
}
|
||||
|
||||
let recommendation = ''
|
||||
if (formattedData.avg_mfe > formattedData.tp1_rate * 1.5) {
|
||||
recommendation = `Avg MFE (${formattedData.avg_mfe.toFixed(2)}%) significantly exceeds TP1 rate. Consider widening TP1 or increasing runner size.`
|
||||
} else if (formattedData.tp2_rate > 50) {
|
||||
recommendation = `TP2 hit rate is ${formattedData.tp2_rate}%. Trailing stop working well - keep current settings.`
|
||||
} else {
|
||||
recommendation = 'Runner performance is within expected range. Continue monitoring.'
|
||||
}
|
||||
|
||||
analyses.push({
|
||||
name: 'Runner Performance',
|
||||
description: 'TP1/TP2 hit rates and max excursion analysis',
|
||||
status: 'success',
|
||||
data: formattedData,
|
||||
recommendation
|
||||
})
|
||||
} catch (error) {
|
||||
analyses.push({
|
||||
name: 'Runner Performance',
|
||||
description: 'TP1/TP2 hit rates and max excursion analysis',
|
||||
status: 'error',
|
||||
data: { error: (error as Error).message }
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 5. ATR CORRELATION WITH MFE
|
||||
// ============================================================================
|
||||
try {
|
||||
const atrCorrelation = await prisma.$queryRaw<any[]>`
|
||||
SELECT
|
||||
CASE
|
||||
WHEN atr < 0.3 THEN '<0.3 (Low Vol)'
|
||||
WHEN atr < 0.5 THEN '0.3-0.5 (Med Vol)'
|
||||
WHEN atr < 0.7 THEN '0.5-0.7 (High Vol)'
|
||||
ELSE '0.7+ (Very High Vol)'
|
||||
END as atr_range,
|
||||
COUNT(*) as trades,
|
||||
ROUND(AVG("maxFavorableExcursion")::numeric, 2) as avg_mfe,
|
||||
ROUND(AVG("realizedPnL")::numeric, 2) as avg_pnl,
|
||||
ROUND(100.0 * SUM(CASE WHEN "realizedPnL" > 0 THEN 1 ELSE 0 END) / COUNT(*)::numeric, 1) as win_rate
|
||||
FROM "Trade"
|
||||
WHERE "exitReason" IS NOT NULL
|
||||
AND atr IS NOT NULL
|
||||
GROUP BY atr_range
|
||||
ORDER BY MIN(atr)
|
||||
`
|
||||
|
||||
const formattedData = atrCorrelation.map(row => ({
|
||||
atr_range: String(row.atr_range),
|
||||
trades: Number(row.trades),
|
||||
avg_mfe: Number(row.avg_mfe),
|
||||
avg_pnl: Number(row.avg_pnl),
|
||||
win_rate: Number(row.win_rate)
|
||||
}))
|
||||
|
||||
let recommendation = 'ATR-based targets already implemented. Monitor correlation over time.'
|
||||
if (formattedData.length >= 3) {
|
||||
const highestMFE = formattedData.reduce((best, current) =>
|
||||
current.avg_mfe > best.avg_mfe ? current : best
|
||||
)
|
||||
recommendation = `${highestMFE.atr_range} shows highest avg MFE (${highestMFE.avg_mfe}%). ATR-based targets adapting correctly.`
|
||||
}
|
||||
|
||||
analyses.push({
|
||||
name: 'ATR vs MFE Correlation',
|
||||
description: 'How volatility affects profit potential',
|
||||
status: 'success',
|
||||
data: formattedData,
|
||||
recommendation
|
||||
})
|
||||
} catch (error) {
|
||||
analyses.push({
|
||||
name: 'ATR vs MFE Correlation',
|
||||
description: 'How volatility affects profit potential',
|
||||
status: 'error',
|
||||
data: { error: (error as Error).message }
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 6. INDICATOR VERSION COMPARISON
|
||||
// ============================================================================
|
||||
try {
|
||||
const indicatorComparison = await prisma.$queryRaw<any[]>`
|
||||
SELECT
|
||||
"indicatorVersion",
|
||||
COUNT(*) as trades,
|
||||
SUM(CASE WHEN "realizedPnL" > 0 THEN 1 ELSE 0 END) as wins,
|
||||
ROUND(100.0 * SUM(CASE WHEN "realizedPnL" > 0 THEN 1 ELSE 0 END) / COUNT(*)::numeric, 1) as win_rate,
|
||||
ROUND(SUM("realizedPnL")::numeric, 2) as total_pnl,
|
||||
ROUND(AVG("realizedPnL")::numeric, 2) as avg_pnl,
|
||||
ROUND(AVG("signalQualityScore")::numeric, 1) as avg_quality
|
||||
FROM "Trade"
|
||||
WHERE "exitReason" IS NOT NULL
|
||||
AND "indicatorVersion" IS NOT NULL
|
||||
GROUP BY "indicatorVersion"
|
||||
ORDER BY "indicatorVersion" DESC
|
||||
`
|
||||
|
||||
const formattedData = indicatorComparison.map(row => ({
|
||||
version: String(row.indicatorVersion),
|
||||
trades: Number(row.trades),
|
||||
wins: Number(row.wins),
|
||||
win_rate: Number(row.win_rate),
|
||||
total_pnl: Number(row.total_pnl),
|
||||
avg_pnl: Number(row.avg_pnl),
|
||||
avg_quality: Number(row.avg_quality)
|
||||
}))
|
||||
|
||||
const v8Data = formattedData.find(d => d.version === 'v8')
|
||||
let recommendation = ''
|
||||
if (v8Data && v8Data.trades >= 20) {
|
||||
recommendation = `v8 has ${v8Data.trades} trades with ${v8Data.win_rate}% WR. Sufficient data for statistical confidence.`
|
||||
} else if (v8Data) {
|
||||
recommendation = `v8 has ${v8Data.trades} trades. Need ${20 - v8Data.trades} more for statistical validation.`
|
||||
} else {
|
||||
recommendation = 'No v8 indicator data yet. Ensure TradingView alerts include IND:v8 field.'
|
||||
}
|
||||
|
||||
analyses.push({
|
||||
name: 'Indicator Version Comparison',
|
||||
description: 'Performance across TradingView strategy versions',
|
||||
status: 'success',
|
||||
data: formattedData,
|
||||
recommendation
|
||||
})
|
||||
} catch (error) {
|
||||
analyses.push({
|
||||
name: 'Indicator Version Comparison',
|
||||
description: 'Performance across TradingView strategy versions',
|
||||
status: 'error',
|
||||
data: { error: (error as Error).message }
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 7. DATA COLLECTION STATUS
|
||||
// ============================================================================
|
||||
try {
|
||||
const dataStatus = await prisma.$queryRaw<any[]>`
|
||||
SELECT
|
||||
COUNT(*) FILTER (WHERE "exitReason" IS NOT NULL) as completed_trades,
|
||||
COUNT(*) FILTER (WHERE "signalQualityScore" IS NOT NULL) as with_quality,
|
||||
COUNT(*) FILTER (WHERE atr IS NOT NULL) as with_atr,
|
||||
COUNT(*) FILTER (WHERE "maxFavorableExcursion" IS NOT NULL) as with_mfe,
|
||||
COUNT(*) FILTER (WHERE "indicatorVersion" = 'v8') as v8_trades,
|
||||
(SELECT COUNT(*) FROM "BlockedSignal" WHERE "blockReason" = 'QUALITY_SCORE_TOO_LOW') as blocked_quality
|
||||
FROM "Trade"
|
||||
`
|
||||
|
||||
const data = dataStatus[0]
|
||||
const formattedData = {
|
||||
completed_trades: Number(data.completed_trades),
|
||||
with_quality: Number(data.with_quality),
|
||||
with_atr: Number(data.with_atr),
|
||||
with_mfe: Number(data.with_mfe),
|
||||
v8_trades: Number(data.v8_trades),
|
||||
blocked_quality: Number(data.blocked_quality)
|
||||
}
|
||||
|
||||
const blockedNeeded = Math.max(0, 20 - formattedData.blocked_quality)
|
||||
const v8Needed = Math.max(0, 50 - formattedData.v8_trades)
|
||||
|
||||
let action = ''
|
||||
if (blockedNeeded > 0) {
|
||||
action = `Need ${blockedNeeded} more blocked signals for Phase 2 quality threshold analysis.`
|
||||
}
|
||||
if (v8Needed > 0) {
|
||||
if (action) action += ' '
|
||||
action += `Need ${v8Needed} more v8 indicator trades for statistical validation.`
|
||||
}
|
||||
|
||||
analyses.push({
|
||||
name: 'Data Collection Status',
|
||||
description: 'Progress toward analysis milestones',
|
||||
status: 'success',
|
||||
data: formattedData,
|
||||
recommendation: action || 'Data collection milestones reached! Ready for optimization decisions.',
|
||||
action: action || undefined
|
||||
})
|
||||
} catch (error) {
|
||||
analyses.push({
|
||||
name: 'Data Collection Status',
|
||||
description: 'Progress toward analysis milestones',
|
||||
status: 'error',
|
||||
data: { error: (error as Error).message }
|
||||
})
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
analyses,
|
||||
timestamp: new Date().toISOString()
|
||||
})
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ Optimization analysis failed:', error)
|
||||
return NextResponse.json({
|
||||
success: false,
|
||||
error: 'Failed to run optimization analyses',
|
||||
message: (error as Error).message
|
||||
}, { status: 500 })
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user