Three critical bugs fixed: 1. P&L calculation (65x inflation) - now uses collateralUSD not notional 2. handlePostTp1Adjustments() - checks tp2SizePercent===0 for runner mode 3. JavaScript || operator bug - changed to ?? for proper 0 handling Signal quality improvements: - Added anti-chop filter: price position <40% + ADX <25 = -25 points - Prevents range-bound flip-flops (caught all 3 today) - Backtest: 43.8% → 55.6% win rate, +86% profit per trade Changes: - lib/trading/signal-quality.ts: RANGE-BOUND CHOP penalty - lib/drift/orders.ts: Fixed P&L calculation + transaction confirmation - lib/trading/position-manager.ts: Runner system logic - app/api/trading/execute/route.ts: || to ?? for tp2SizePercent - app/api/trading/test/route.ts: || to ?? for tp1/tp2SizePercent - prisma/schema.prisma: Added collateralUSD field - scripts/fix_pnl_calculations.sql: Historical P&L correction
401 lines
12 KiB
JavaScript
401 lines
12 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* Signal Quality Optimization Script
|
|
*
|
|
* Brute-force tests different threshold combinations to find optimal parameters
|
|
* for signal quality scoring that maximize win rate and P&L
|
|
*/
|
|
|
|
import { PrismaClient } from '@prisma/client'
|
|
|
|
const prisma = new PrismaClient()
|
|
|
|
// Current thresholds from signal-quality.ts
|
|
const CURRENT_THRESHOLDS = {
|
|
atr: { veryLow: 0.25, low: 0.4, healthy: 0.7, high: 2.0 },
|
|
adx: { weak: 10, moderate: 18, strong: 30, veryStrong: 40 },
|
|
rsi: { oversold: 30, neutral: 50, overbought: 70 },
|
|
volume: { low: 0.8, normal: 1.0, high: 1.5 },
|
|
pricePosition: { extreme: 5, moderate: 15, safe: 30 }
|
|
}
|
|
|
|
// Test ranges for each parameter
|
|
const TEST_RANGES = {
|
|
atr: {
|
|
veryLow: [0.15, 0.20, 0.25, 0.30],
|
|
low: [0.3, 0.4, 0.5],
|
|
healthy: [0.6, 0.7, 0.8],
|
|
high: [1.5, 2.0, 2.5]
|
|
},
|
|
adx: {
|
|
weak: [8, 10, 12],
|
|
moderate: [15, 18, 20],
|
|
strong: [25, 30, 35],
|
|
veryStrong: [38, 40, 45]
|
|
},
|
|
rsi: {
|
|
oversold: [25, 30, 35],
|
|
neutral: [45, 50, 55],
|
|
overbought: [65, 70, 75]
|
|
},
|
|
volume: {
|
|
low: [0.7, 0.8, 0.9],
|
|
normal: [1.0, 1.1],
|
|
high: [1.3, 1.5, 1.7]
|
|
},
|
|
pricePosition: {
|
|
extreme: [5, 10, 15],
|
|
moderate: [15, 20, 25],
|
|
safe: [25, 30, 35]
|
|
}
|
|
}
|
|
|
|
// Score a single trade with given thresholds
|
|
function scoreTradeWithThresholds(trade, thresholds, timeframe = null) {
|
|
let score = 50 // Base score
|
|
const reasons = []
|
|
|
|
const atr = trade.atrAtEntry
|
|
const adx = trade.adxAtEntry
|
|
const rsi = trade.rsiAtEntry
|
|
const volumeRatio = trade.volumeAtEntry || 1.0
|
|
const pricePosition = trade.pricePositionAtEntry || 50
|
|
const direction = trade.direction
|
|
|
|
// Determine if short timeframe (5min, 15min)
|
|
const is5minChart = timeframe === '5'
|
|
const is15minChart = timeframe === '15'
|
|
const isShortTimeframe = is5minChart || is15minChart
|
|
|
|
// ATR scoring
|
|
if (atr) {
|
|
if (isShortTimeframe) {
|
|
if (atr < thresholds.atr.veryLow) {
|
|
score -= 20
|
|
} else if (atr >= thresholds.atr.veryLow && atr < thresholds.atr.healthy) {
|
|
score += 5
|
|
} else if (atr >= thresholds.atr.healthy && atr <= thresholds.atr.high) {
|
|
score -= 10
|
|
}
|
|
} else {
|
|
if (atr < thresholds.atr.low) {
|
|
score -= 20
|
|
} else if (atr >= thresholds.atr.low && atr < thresholds.atr.healthy) {
|
|
score += 5
|
|
} else if (atr >= thresholds.atr.healthy && atr <= thresholds.atr.high) {
|
|
score += 10
|
|
} else {
|
|
score -= 15
|
|
}
|
|
}
|
|
}
|
|
|
|
// ADX scoring
|
|
if (adx) {
|
|
if (isShortTimeframe) {
|
|
if (adx < thresholds.adx.weak) {
|
|
score -= 15
|
|
} else if (adx >= thresholds.adx.weak && adx < thresholds.adx.moderate) {
|
|
score += 5
|
|
} else if (adx >= thresholds.adx.moderate && adx <= thresholds.adx.strong) {
|
|
score += 15
|
|
} else {
|
|
score -= 5
|
|
}
|
|
} else {
|
|
if (adx < thresholds.adx.moderate) {
|
|
score -= 15
|
|
} else if (adx >= thresholds.adx.moderate && adx <= thresholds.adx.strong) {
|
|
score += 15
|
|
} else if (adx > thresholds.adx.veryStrong) {
|
|
score -= 10
|
|
}
|
|
}
|
|
}
|
|
|
|
// RSI scoring
|
|
if (rsi) {
|
|
if (direction === 'long') {
|
|
if (rsi < thresholds.rsi.oversold) {
|
|
score += 10
|
|
} else if (rsi >= thresholds.rsi.oversold && rsi < thresholds.rsi.neutral) {
|
|
score += 5
|
|
} else if (rsi > thresholds.rsi.overbought) {
|
|
score -= 10
|
|
}
|
|
} else {
|
|
if (rsi > thresholds.rsi.overbought) {
|
|
score += 10
|
|
} else if (rsi > thresholds.rsi.neutral && rsi <= thresholds.rsi.overbought) {
|
|
score += 5
|
|
} else if (rsi < thresholds.rsi.oversold) {
|
|
score -= 10
|
|
}
|
|
}
|
|
}
|
|
|
|
// Volume scoring
|
|
if (volumeRatio) {
|
|
if (volumeRatio < thresholds.volume.low) {
|
|
score -= 10
|
|
} else if (volumeRatio >= thresholds.volume.normal && volumeRatio < thresholds.volume.high) {
|
|
score += 5
|
|
} else if (volumeRatio >= thresholds.volume.high) {
|
|
score += 15
|
|
}
|
|
}
|
|
|
|
// Price position scoring
|
|
if (pricePosition !== null) {
|
|
if (direction === 'long') {
|
|
if (pricePosition > 90) {
|
|
score -= 30
|
|
} else if (pricePosition > 80) {
|
|
score -= 15
|
|
}
|
|
} else {
|
|
if (pricePosition < 10) {
|
|
score -= 30
|
|
} else if (pricePosition < 20) {
|
|
score -= 15
|
|
}
|
|
}
|
|
}
|
|
|
|
// Anti-chop filter
|
|
if (adx && atr && volumeRatio) {
|
|
if (adx < thresholds.adx.weak && atr < thresholds.atr.veryLow && volumeRatio < thresholds.volume.low) {
|
|
score -= 20
|
|
}
|
|
}
|
|
|
|
return Math.max(0, Math.min(100, score))
|
|
}
|
|
|
|
// Evaluate performance for a set of thresholds
|
|
function evaluateThresholds(trades, thresholds, minScore = 65) {
|
|
const results = {
|
|
totalTrades: 0,
|
|
acceptedTrades: 0,
|
|
rejectedTrades: 0,
|
|
wins: 0,
|
|
losses: 0,
|
|
totalPnL: 0,
|
|
winRate: 0,
|
|
avgWin: 0,
|
|
avgLoss: 0,
|
|
profitFactor: 0,
|
|
avgScore: 0,
|
|
acceptanceRate: 0
|
|
}
|
|
|
|
const acceptedTrades = []
|
|
const rejectedTrades = []
|
|
|
|
for (const trade of trades) {
|
|
const score = scoreTradeWithThresholds(trade, thresholds, trade.timeframe)
|
|
results.totalTrades++
|
|
|
|
if (score >= minScore) {
|
|
// Trade would be accepted
|
|
results.acceptedTrades++
|
|
acceptedTrades.push({ ...trade, score })
|
|
|
|
if (trade.realizedPnL > 0) {
|
|
results.wins++
|
|
results.totalPnL += trade.realizedPnL
|
|
} else {
|
|
results.losses++
|
|
results.totalPnL += trade.realizedPnL
|
|
}
|
|
} else {
|
|
// Trade would be rejected
|
|
results.rejectedTrades++
|
|
rejectedTrades.push({ ...trade, score })
|
|
}
|
|
}
|
|
|
|
// Calculate metrics
|
|
if (results.acceptedTrades > 0) {
|
|
results.winRate = (results.wins / results.acceptedTrades) * 100
|
|
results.acceptanceRate = (results.acceptedTrades / results.totalTrades) * 100
|
|
|
|
const winningTrades = acceptedTrades.filter(t => t.realizedPnL > 0)
|
|
const losingTrades = acceptedTrades.filter(t => t.realizedPnL <= 0)
|
|
|
|
if (winningTrades.length > 0) {
|
|
results.avgWin = winningTrades.reduce((sum, t) => sum + t.realizedPnL, 0) / winningTrades.length
|
|
}
|
|
|
|
if (losingTrades.length > 0) {
|
|
results.avgLoss = losingTrades.reduce((sum, t) => sum + t.realizedPnL, 0) / losingTrades.length
|
|
}
|
|
|
|
if (results.avgLoss !== 0) {
|
|
results.profitFactor = Math.abs(results.avgWin / results.avgLoss)
|
|
}
|
|
|
|
results.avgScore = acceptedTrades.reduce((sum, t) => sum + t.score, 0) / acceptedTrades.length
|
|
}
|
|
|
|
// Calculate what we would have saved/lost by rejecting trades
|
|
results.rejectedPnL = rejectedTrades.reduce((sum, t) => sum + (t.realizedPnL || 0), 0)
|
|
|
|
return results
|
|
}
|
|
|
|
// Main optimization function
|
|
async function optimizeSignalQuality() {
|
|
console.log('🔬 Signal Quality Optimization Starting...\n')
|
|
|
|
// Fetch all closed trades with metrics
|
|
const trades = await prisma.trade.findMany({
|
|
where: {
|
|
exitReason: { not: null },
|
|
realizedPnL: { not: null },
|
|
atrAtEntry: { not: null },
|
|
adxAtEntry: { not: null },
|
|
rsiAtEntry: { not: null }
|
|
},
|
|
select: {
|
|
id: true,
|
|
direction: true,
|
|
realizedPnL: true,
|
|
atrAtEntry: true,
|
|
adxAtEntry: true,
|
|
rsiAtEntry: true,
|
|
volumeAtEntry: true,
|
|
pricePositionAtEntry: true,
|
|
timeframe: true,
|
|
signalQualityScore: true
|
|
}
|
|
})
|
|
|
|
console.log(`📊 Analyzing ${trades.length} trades with complete metrics\n`)
|
|
|
|
if (trades.length < 20) {
|
|
console.log('⚠️ Warning: Less than 20 trades available. Results may not be statistically significant.\n')
|
|
}
|
|
|
|
// Baseline: Current thresholds
|
|
console.log('📈 BASELINE (Current Thresholds):')
|
|
console.log('='.repeat(60))
|
|
const baseline = evaluateThresholds(trades, CURRENT_THRESHOLDS)
|
|
console.log(`Total Trades: ${baseline.totalTrades}`)
|
|
console.log(`Accepted: ${baseline.acceptedTrades} (${baseline.acceptanceRate.toFixed(1)}%)`)
|
|
console.log(`Win Rate: ${baseline.winRate.toFixed(1)}%`)
|
|
console.log(`Total P&L: $${baseline.totalPnL.toFixed(2)}`)
|
|
console.log(`Avg Win: $${baseline.avgWin.toFixed(2)} | Avg Loss: $${baseline.avgLoss.toFixed(2)}`)
|
|
console.log(`Profit Factor: ${baseline.profitFactor.toFixed(2)}`)
|
|
console.log(`Avg Score: ${baseline.avgScore.toFixed(1)}`)
|
|
console.log(`Rejected P&L: $${baseline.rejectedPnL.toFixed(2)} (would have saved/lost)\n`)
|
|
|
|
// Test different minimum score thresholds
|
|
console.log('🎯 Testing Different Minimum Score Thresholds:')
|
|
console.log('='.repeat(60))
|
|
const scoreThresholds = [50, 55, 60, 65, 70, 75, 80]
|
|
|
|
let bestScoreThreshold = { minScore: 65, result: baseline }
|
|
|
|
for (const minScore of scoreThresholds) {
|
|
const result = evaluateThresholds(trades, CURRENT_THRESHOLDS, minScore)
|
|
|
|
console.log(`\nMin Score: ${minScore}`)
|
|
console.log(` Accepted: ${result.acceptedTrades}/${result.totalTrades} (${result.acceptanceRate.toFixed(1)}%)`)
|
|
console.log(` Win Rate: ${result.winRate.toFixed(1)}%`)
|
|
console.log(` Total P&L: $${result.totalPnL.toFixed(2)}`)
|
|
console.log(` Profit Factor: ${result.profitFactor.toFixed(2)}`)
|
|
|
|
// Best = highest P&L with decent acceptance rate (>30%)
|
|
if (result.acceptanceRate > 30 && result.totalPnL > bestScoreThreshold.result.totalPnL) {
|
|
bestScoreThreshold = { minScore, result }
|
|
}
|
|
}
|
|
|
|
console.log('\n\n🏆 BEST MINIMUM SCORE THRESHOLD:')
|
|
console.log('='.repeat(60))
|
|
console.log(`Min Score: ${bestScoreThreshold.minScore}`)
|
|
console.log(`Win Rate: ${bestScoreThreshold.result.winRate.toFixed(1)}%`)
|
|
console.log(`Total P&L: $${bestScoreThreshold.result.totalPnL.toFixed(2)}`)
|
|
console.log(`Acceptance Rate: ${bestScoreThreshold.result.acceptanceRate.toFixed(1)}%`)
|
|
console.log(`Profit Factor: ${bestScoreThreshold.result.profitFactor.toFixed(2)}\n`)
|
|
|
|
// Now test key threshold variations
|
|
console.log('\n🔧 Testing Key Threshold Variations:')
|
|
console.log('='.repeat(60))
|
|
|
|
const variations = []
|
|
|
|
// Test ADX thresholds (most impactful)
|
|
for (const moderate of TEST_RANGES.adx.moderate) {
|
|
const testThresholds = {
|
|
...CURRENT_THRESHOLDS,
|
|
adx: { ...CURRENT_THRESHOLDS.adx, moderate }
|
|
}
|
|
const result = evaluateThresholds(trades, testThresholds, bestScoreThreshold.minScore)
|
|
variations.push({
|
|
name: `ADX Moderate: ${moderate}`,
|
|
thresholds: testThresholds,
|
|
result
|
|
})
|
|
}
|
|
|
|
// Test ATR thresholds
|
|
for (const low of TEST_RANGES.atr.low) {
|
|
const testThresholds = {
|
|
...CURRENT_THRESHOLDS,
|
|
atr: { ...CURRENT_THRESHOLDS.atr, low }
|
|
}
|
|
const result = evaluateThresholds(trades, testThresholds, bestScoreThreshold.minScore)
|
|
variations.push({
|
|
name: `ATR Low: ${low}`,
|
|
thresholds: testThresholds,
|
|
result
|
|
})
|
|
}
|
|
|
|
// Test price position thresholds
|
|
for (const extreme of TEST_RANGES.pricePosition.extreme) {
|
|
const testThresholds = {
|
|
...CURRENT_THRESHOLDS,
|
|
pricePosition: { ...CURRENT_THRESHOLDS.pricePosition, extreme }
|
|
}
|
|
const result = evaluateThresholds(trades, testThresholds, bestScoreThreshold.minScore)
|
|
variations.push({
|
|
name: `Price Extreme: ${extreme}`,
|
|
thresholds: testThresholds,
|
|
result
|
|
})
|
|
}
|
|
|
|
// Sort by P&L
|
|
variations.sort((a, b) => b.result.totalPnL - a.result.totalPnL)
|
|
|
|
console.log('\nTop 10 Variations by P&L:')
|
|
console.log('-'.repeat(60))
|
|
variations.slice(0, 10).forEach((v, i) => {
|
|
console.log(`${i + 1}. ${v.name}`)
|
|
console.log(` Win Rate: ${v.result.winRate.toFixed(1)}% | P&L: $${v.result.totalPnL.toFixed(2)} | Accepted: ${v.result.acceptedTrades}/${v.result.totalTrades}`)
|
|
})
|
|
|
|
console.log('\n\n📋 FINAL RECOMMENDATIONS:')
|
|
console.log('='.repeat(60))
|
|
|
|
const best = variations[0]
|
|
console.log(`\nBest Configuration Found:`)
|
|
console.log(`- ${best.name}`)
|
|
console.log(`- Min Score Threshold: ${bestScoreThreshold.minScore}`)
|
|
console.log(`\nPerformance Improvement:`)
|
|
console.log(`- Current P&L: $${baseline.totalPnL.toFixed(2)}`)
|
|
console.log(`- Optimized P&L: $${best.result.totalPnL.toFixed(2)}`)
|
|
console.log(`- Improvement: $${(best.result.totalPnL - baseline.totalPnL).toFixed(2)} (${(((best.result.totalPnL - baseline.totalPnL) / Math.abs(baseline.totalPnL)) * 100).toFixed(1)}%)`)
|
|
console.log(`- Current Win Rate: ${baseline.winRate.toFixed(1)}%`)
|
|
console.log(`- Optimized Win Rate: ${best.result.winRate.toFixed(1)}%`)
|
|
console.log(`- Acceptance Rate: ${best.result.acceptanceRate.toFixed(1)}% (${best.result.acceptedTrades}/${best.result.totalTrades} trades)`)
|
|
|
|
await prisma.$disconnect()
|
|
}
|
|
|
|
// Run optimization
|
|
optimizeSignalQuality().catch(console.error)
|