From 111e3ed12a4bd5e39265baeeb543581fda9ca896 Mon Sep 17 00:00:00 2001 From: mindesbunister Date: Fri, 14 Nov 2025 06:41:03 +0100 Subject: [PATCH] feat: implement signal frequency penalties for flip-flop detection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- app/api/analytics/reentry-check/route.ts | 6 +- app/api/trading/check-risk/route.ts | 12 ++-- app/api/trading/execute/route.ts | 6 +- lib/database/trades.ts | 82 ++++++++++++++++++++++++ lib/trading/signal-quality.ts | 63 +++++++++++++++++- 5 files changed, 158 insertions(+), 11 deletions(-) diff --git a/app/api/analytics/reentry-check/route.ts b/app/api/analytics/reentry-check/route.ts index 16454cd..fe2edfa 100644 --- a/app/api/analytics/reentry-check/route.ts +++ b/app/api/analytics/reentry-check/route.ts @@ -139,13 +139,15 @@ export async function POST(request: NextRequest) { 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 = scoreSignalQuality({ + const qualityResult = await scoreSignalQuality({ atr: metrics.atr, adx: metrics.adx, rsi: metrics.rsi, volumeRatio: metrics.volumeRatio, pricePosition: metrics.pricePosition, - direction: direction as 'long' | 'short' + direction: direction as 'long' | 'short', + symbol: symbol, + skipFrequencyCheck: true, // Re-entry check already considers recent trades }) let finalScore = qualityResult.score diff --git a/app/api/trading/check-risk/route.ts b/app/api/trading/check-risk/route.ts index d338c70..5bce618 100644 --- a/app/api/trading/check-risk/route.ts +++ b/app/api/trading/check-risk/route.ts @@ -36,11 +36,11 @@ export interface RiskCheckResponse { * Position Scaling Validation * Determines if adding to an existing position is allowed */ -function shouldAllowScaling( +async function shouldAllowScaling( existingTrade: ActiveTrade, newSignal: RiskCheckRequest, config: TradingConfig -): { allowed: boolean; reasons: string[]; qualityScore?: number; qualityReasons?: string[] } { +): Promise<{ allowed: boolean; reasons: string[]; qualityScore?: number; qualityReasons?: string[] }> { const reasons: string[] = [] // Check if we have context metrics @@ -50,13 +50,14 @@ function shouldAllowScaling( } // 1. Calculate new signal quality score - const qualityScore = scoreSignalQuality({ + 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, }) @@ -156,7 +157,7 @@ export async function POST(request: NextRequest): Promise + isAlternatingPattern: boolean +}> { + const prisma = getPrismaClient() + + const timeAgo = new Date(Date.now() - params.timeWindowMinutes * 60 * 1000) + + // Get all signals for this symbol in the time window (including blocked signals) + const [trades, blockedSignals] = await Promise.all([ + prisma.trade.findMany({ + where: { + symbol: params.symbol, + createdAt: { gte: timeAgo }, + }, + select: { direction: true, createdAt: true }, + orderBy: { createdAt: 'desc' }, + }), + prisma.blockedSignal.findMany({ + where: { + symbol: params.symbol, + createdAt: { gte: timeAgo }, + }, + select: { direction: true, createdAt: true }, + orderBy: { createdAt: 'desc' }, + }), + ]) + + // Combine and sort all signals + const allSignals = [ + ...trades.map(t => ({ direction: t.direction as 'long' | 'short', createdAt: t.createdAt })), + ...blockedSignals.map(b => ({ direction: b.direction as 'long' | 'short', createdAt: b.createdAt })), + ].sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime()) + + // Check for opposite direction in last 15 minutes + const fifteenMinAgo = new Date(Date.now() - 15 * 60 * 1000) + const oppositeInLast15 = allSignals.find( + s => s.direction !== params.direction && s.createdAt >= fifteenMinAgo + ) + + // Get last 3 executed trades (not blocked signals) for alternating pattern check + const last3Trades = await prisma.trade.findMany({ + where: { symbol: params.symbol }, + select: { direction: true, createdAt: true }, + orderBy: { createdAt: 'desc' }, + take: 3, + }) + + // Check if last 3 trades alternate (long → short → long OR short → long → short) + let isAlternating = false + if (last3Trades.length === 3) { + const dirs = last3Trades.map(t => t.direction) + isAlternating = ( + (dirs[0] !== dirs[1] && dirs[1] !== dirs[2] && dirs[0] !== dirs[2]) // All different in alternating pattern + ) + } + + return { + totalSignals: allSignals.length, + oppositeDirectionInWindow: !!oppositeInLast15, + oppositeDirectionMinutesAgo: oppositeInLast15 + ? Math.floor((Date.now() - oppositeInLast15.createdAt.getTime()) / 60000) + : undefined, + last3Trades: last3Trades.map(t => ({ + direction: t.direction as 'long' | 'short', + createdAt: t.createdAt + })), + isAlternatingPattern: isAlternating, + } +} + /** * Disconnect Prisma client (for graceful shutdown) */ diff --git a/lib/trading/signal-quality.ts b/lib/trading/signal-quality.ts index 2a53544..84530be 100644 --- a/lib/trading/signal-quality.ts +++ b/lib/trading/signal-quality.ts @@ -5,10 +5,17 @@ * Ensures consistent scoring across the trading pipeline. */ +import { getRecentSignals } from '../database/trades' + export interface SignalQualityResult { score: number passed: boolean reasons: string[] + frequencyPenalties?: { + overtrading: number + flipFlop: number + alternating: number + } } /** @@ -22,6 +29,7 @@ export interface SignalQualityResult { * - Volume: -10 to +15 points * - Price position: -15 to +5 points * - Volume breakout bonus: +10 points + * - Signal frequency penalties: -20 to -30 points * * Total range: ~15-115 points (realistically 30-100) * Threshold: 60 points minimum for execution @@ -29,17 +37,24 @@ export interface SignalQualityResult { * TIMEFRAME-AWARE SCORING: * - 5min charts have lower ADX/ATR thresholds (trends develop slower) * - Higher timeframes require stronger confirmation + * + * SIGNAL FREQUENCY PENALTIES (NEW): + * - 3+ signals in 30 min: -20 points (overtrading zone) + * - Opposite direction in last 15 min: -25 points (flip-flop) + * - Last 3 trades alternating: -30 points (chop pattern) */ -export function scoreSignalQuality(params: { +export async function scoreSignalQuality(params: { atr: number adx: number rsi: number volumeRatio: number pricePosition: number direction: 'long' | 'short' + symbol: string // Required for frequency check timeframe?: string // "5" = 5min, "15" = 15min, "60" = 1H, "D" = daily minScore?: number // Configurable minimum score threshold -}): SignalQualityResult { + skipFrequencyCheck?: boolean // For testing or when frequency check not needed +}): Promise { let score = 50 // Base score const reasons: string[] = [] @@ -171,6 +186,49 @@ export function scoreSignalQuality(params: { reasons.push(`Volume breakout compensates for low ATR`) } + // Signal frequency penalties (check database for recent signals) + const frequencyPenalties = { + overtrading: 0, + flipFlop: 0, + alternating: 0, + } + + if (!params.skipFrequencyCheck) { + try { + const recentSignals = await getRecentSignals({ + symbol: params.symbol, + direction: params.direction, + timeWindowMinutes: 30, + }) + + // Penalty 1: Overtrading (3+ signals in 30 minutes) + if (recentSignals.totalSignals >= 3) { + frequencyPenalties.overtrading = -20 + score -= 20 + reasons.push(`⚠️ Overtrading zone: ${recentSignals.totalSignals} signals in 30min (-20 pts)`) + } + + // Penalty 2: Flip-flop (opposite direction in last 15 minutes) + if (recentSignals.oppositeDirectionInWindow) { + frequencyPenalties.flipFlop = -25 + score -= 25 + reasons.push(`⚠️ Flip-flop detected: opposite direction ${recentSignals.oppositeDirectionMinutesAgo}min ago (-25 pts)`) + } + + // Penalty 3: Alternating pattern (last 3 trades flip directions) + if (recentSignals.isAlternatingPattern) { + frequencyPenalties.alternating = -30 + score -= 30 + const pattern = recentSignals.last3Trades.map(t => t.direction).join(' → ') + reasons.push(`⚠️ Chop pattern: last 3 trades alternating (${pattern}) (-30 pts)`) + } + } catch (error) { + // Don't fail the whole score if frequency check fails + console.error('❌ Signal frequency check failed:', error) + reasons.push('⚠️ Frequency check unavailable (no penalty applied)') + } + } + const minScore = params.minScore || 60 const passed = score >= minScore @@ -178,5 +236,6 @@ export function scoreSignalQuality(params: { score, passed, reasons, + frequencyPenalties, } }