Files
trading_bot_v4/app/api/optimization/analyze/route.ts
copilot-swe-agent[bot] 4f913a7ab8 feat: Add indicator version filtering to analytics dashboard
- Add version dropdown selector (v9, v8, v6, v5, all) to frontend
- Update backend API to accept ?version= query parameter
- Add version filter to all 5 broken SQL queries using Prisma parameterized queries
- Update Data Collection Status to use selected version instead of hardcoded v8
- Add version context to all recommendations
- Add URL encoding for version parameter (security best practice)
- Validate version parameter against whitelist (SQL injection protection)

Co-authored-by: mindesbunister <32161838+mindesbunister@users.noreply.github.com>
2025-12-05 14:01:41 +00:00

550 lines
24 KiB
TypeScript

import { NextRequest, NextResponse } from 'next/server'
import { getPrismaClient } from '../../../../lib/database/trades'
import { Prisma } from '@prisma/client'
export const dynamic = 'force-dynamic'
// Valid indicator versions for filtering
const VALID_VERSIONS = ['v5', 'v6', 'v8', 'v9', 'all']
export async function GET(request: NextRequest) {
const searchParams = request.nextUrl.searchParams
const version = searchParams.get('version') || 'v9' // Default to current production
// Validate version parameter to prevent SQL injection
if (!VALID_VERSIONS.includes(version)) {
return NextResponse.json({
success: false,
error: `Invalid version parameter. Must be one of: ${VALID_VERSIONS.join(', ')}`
}, { status: 400 })
}
const prisma = getPrismaClient()
const analyses = []
// Version label for recommendations
const versionLabel = version === 'all' ? 'All versions combined' : `${version.toUpperCase()} only`
try {
// ============================================================================
// 1. QUALITY SCORE DISTRIBUTION
// ============================================================================
try {
// Build query with optional version filter
const qualityDistribution = version === 'all'
? 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
`
: 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
AND "indicatorVersion" = ${version}
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 (${versionLabel})`,
status: 'success',
data: formattedData,
recommendation: bestTier.win_rate >= 70
? `${versionLabel}: Quality ${bestTier.tier.split(' ')[0]} shows ${bestTier.win_rate}% WR. Consider raising threshold to this tier.`
: `${versionLabel}: 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 = version === 'all'
? 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
`
: 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
AND "indicatorVersion" = ${version}
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 = `${versionLabel}: ${better} signals perform ${wrDiff.toFixed(1)}% better. Consider raising ${worse} quality threshold or reducing ${worse} position size.`
} else {
recommendation = `${versionLabel}: Direction performance is balanced. No threshold adjustment needed.`
}
}
analyses.push({
name: 'Direction Performance',
description: `Compare LONG vs SHORT trade outcomes (${versionLabel})`,
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 = version === 'all'
? 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
`
: 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
AND "indicatorVersion" = ${version}
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 = `${versionLabel}: ${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 = `${versionLabel}: Need ${needed} more blocked signals for reliable analysis. Keep collecting data.`
}
analyses.push({
name: 'Blocked Signals Analysis',
description: `Signals rejected by quality filters (${versionLabel})`,
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 = version === 'all'
? await prisma.$queryRaw<any[]>`
SELECT
COUNT(*) as total_trades,
SUM(CASE WHEN "tp1Filled" = true THEN 1 ELSE 0 END) as tp1_hits,
SUM(CASE WHEN "tp2Filled" = true THEN 1 ELSE 0 END) as tp2_hits,
ROUND(100.0 * SUM(CASE WHEN "tp1Filled" = true THEN 1 ELSE 0 END) / NULLIF(COUNT(*), 0)::numeric, 1) as tp1_rate,
ROUND(100.0 * SUM(CASE WHEN "tp2Filled" = true THEN 1 ELSE 0 END) / NULLIF(COUNT(*), 0)::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'
`
: await prisma.$queryRaw<any[]>`
SELECT
COUNT(*) as total_trades,
SUM(CASE WHEN "tp1Filled" = true THEN 1 ELSE 0 END) as tp1_hits,
SUM(CASE WHEN "tp2Filled" = true THEN 1 ELSE 0 END) as tp2_hits,
ROUND(100.0 * SUM(CASE WHEN "tp1Filled" = true THEN 1 ELSE 0 END) / NULLIF(COUNT(*), 0)::numeric, 1) as tp1_rate,
ROUND(100.0 * SUM(CASE WHEN "tp2Filled" = true THEN 1 ELSE 0 END) / NULLIF(COUNT(*), 0)::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'
AND "indicatorVersion" = ${version}
`
const data = runnerPerformance[0]
const formattedData = {
total_trades: Number(data.total_trades) || 0,
tp1_hits: Number(data.tp1_hits) || 0,
tp2_hits: Number(data.tp2_hits) || 0,
tp1_rate: Number(data.tp1_rate) || 0,
tp2_rate: Number(data.tp2_rate) || 0,
avg_mfe: Number(data.avg_mfe) || 0,
avg_mae: Number(data.avg_mae) || 0
}
let recommendation = ''
if (formattedData.total_trades === 0) {
recommendation = `${versionLabel}: No trades found in last 30 days. Need data to analyze runner performance.`
} else if (formattedData.avg_mfe > formattedData.tp1_rate * 1.5) {
recommendation = `${versionLabel}: 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 = `${versionLabel}: TP2 hit rate is ${formattedData.tp2_rate}%. Trailing stop working well - keep current settings.`
} else {
recommendation = `${versionLabel}: Runner performance is within expected range. Continue monitoring.`
}
analyses.push({
name: 'Runner Performance',
description: `TP1/TP2 hit rates and max excursion analysis (${versionLabel})`,
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 = version === 'all'
? await prisma.$queryRaw<any[]>`
SELECT
CASE
WHEN "atrAtEntry" < 0.3 THEN '<0.3 (Low Vol)'
WHEN "atrAtEntry" < 0.5 THEN '0.3-0.5 (Med Vol)'
WHEN "atrAtEntry" < 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 "atrAtEntry" IS NOT NULL
GROUP BY atr_range
ORDER BY MIN("atrAtEntry")
`
: await prisma.$queryRaw<any[]>`
SELECT
CASE
WHEN "atrAtEntry" < 0.3 THEN '<0.3 (Low Vol)'
WHEN "atrAtEntry" < 0.5 THEN '0.3-0.5 (Med Vol)'
WHEN "atrAtEntry" < 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 "atrAtEntry" IS NOT NULL
AND "indicatorVersion" = ${version}
GROUP BY atr_range
ORDER BY MIN("atrAtEntry")
`
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 = `${versionLabel}: 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 = `${versionLabel}: ${highestMFE.atr_range} shows highest avg MFE (${highestMFE.avg_mfe}%). ATR-based targets adapting correctly.`
} else if (formattedData.length === 0) {
recommendation = `${versionLabel}: No ATR data available. Need trades with ATR tracking enabled.`
}
analyses.push({
name: 'ATR vs MFE Correlation',
description: `How volatility affects profit potential (${versionLabel})`,
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 {
// For 'all' version, show overall data collection status
// For specific version, show that version's data status
const dataStatus = version === 'all'
? 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 "atrAtEntry" IS NOT NULL) as with_atr,
COUNT(*) FILTER (WHERE "maxFavorableExcursion" IS NOT NULL) as with_mfe,
COUNT(*) FILTER (WHERE "indicatorVersion" = 'v9') as version_trades,
(SELECT COUNT(*) FROM "BlockedSignal" WHERE "blockReason" = 'QUALITY_SCORE_TOO_LOW') as blocked_quality
FROM "Trade"
`
: await prisma.$queryRaw<any[]>`
SELECT
COUNT(*) FILTER (WHERE "exitReason" IS NOT NULL AND "indicatorVersion" = ${version}) as completed_trades,
COUNT(*) FILTER (WHERE "signalQualityScore" IS NOT NULL AND "indicatorVersion" = ${version}) as with_quality,
COUNT(*) FILTER (WHERE "atrAtEntry" IS NOT NULL AND "indicatorVersion" = ${version}) as with_atr,
COUNT(*) FILTER (WHERE "maxFavorableExcursion" IS NOT NULL AND "indicatorVersion" = ${version}) as with_mfe,
COUNT(*) FILTER (WHERE "indicatorVersion" = ${version}) as version_trades,
(SELECT COUNT(*) FROM "BlockedSignal" WHERE "blockReason" = 'QUALITY_SCORE_TOO_LOW' AND "indicatorVersion" = ${version}) as blocked_quality
FROM "Trade"
`
const data = dataStatus[0]
const formattedData = {
completed_trades: Number(data.completed_trades) || 0,
with_quality: Number(data.with_quality) || 0,
with_atr: Number(data.with_atr) || 0,
with_mfe: Number(data.with_mfe) || 0,
version_trades: Number(data.version_trades) || 0,
blocked_quality: Number(data.blocked_quality) || 0
}
const blockedNeeded = Math.max(0, 20 - formattedData.blocked_quality)
const versionNeeded = Math.max(0, 50 - formattedData.version_trades)
const targetVersion = version === 'all' ? 'v9' : version
let action = ''
if (blockedNeeded > 0) {
action = `${versionLabel}: Need ${blockedNeeded} more blocked signals for Phase 2 quality threshold analysis.`
}
if (versionNeeded > 0) {
if (action) action += ' '
action += `Need ${versionNeeded} more ${targetVersion} indicator trades for statistical validation.`
}
analyses.push({
name: 'Data Collection Status',
description: `Progress toward analysis milestones (${versionLabel})`,
status: 'success',
data: formattedData,
recommendation: action || `${versionLabel}: 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,
selectedVersion: version,
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 })
}
}