fix(critical): Unify quality score calculation across check-risk and execute
PROBLEM: - check-risk calculated quality score: 60, 70 (PASSED) - execute calculated quality score: 35, 45 (should have BLOCKED) - Two different functions with different logic caused trades to bypass validation ROOT CAUSE: Two separate scoring functions existed: 1. scoreSignalQuality() in check-risk (detailed, 95% price threshold) 2. calculateQualityScore() in execute (simpler, 90% price threshold) Example with pricePosition=96.4%, volumeRatio=0.9: - check-risk: Checks >95, volumeRatio>1.4 failed → -15 + bonuses = 60 ✅ PASSED - execute: Checks >90 → -15 + bonuses = 35 ❌ Should block but already opened SOLUTION: 1. Created lib/trading/signal-quality.ts with unified scoreSignalQuality() 2. Both endpoints now import and use SAME function 3. Consistent scoring logic: 95% price threshold, volume breakout bonus 4. Returns detailed reasons for debugging IMPACT: - Quality scores now MATCH between check-risk and execute - No more trades bypassing validation due to calculation differences - Better debugging with quality reasons logged Files changed: - NEW: lib/trading/signal-quality.ts (unified scoring function) - MODIFIED: app/api/trading/check-risk/route.ts (import shared function) - MODIFIED: app/api/trading/execute/route.ts (import shared function) - REMOVED: Duplicate calculateQualityScore() from execute - REMOVED: Duplicate scoreSignalQuality() from check-risk
This commit is contained in:
@@ -10,6 +10,7 @@ import { getMergedConfig, TradingConfig } from '@/config/trading'
|
|||||||
import { getInitializedPositionManager, ActiveTrade } from '@/lib/trading/position-manager'
|
import { getInitializedPositionManager, ActiveTrade } from '@/lib/trading/position-manager'
|
||||||
import { getLastTradeTime, getLastTradeTimeForSymbol, getTradesInLastHour, getTodayPnL } from '@/lib/database/trades'
|
import { getLastTradeTime, getLastTradeTimeForSymbol, getTradesInLastHour, getTodayPnL } from '@/lib/database/trades'
|
||||||
import { getPythPriceMonitor } from '@/lib/pyth/price-monitor'
|
import { getPythPriceMonitor } from '@/lib/pyth/price-monitor'
|
||||||
|
import { scoreSignalQuality, SignalQualityResult } from '@/lib/trading/signal-quality'
|
||||||
|
|
||||||
export interface RiskCheckRequest {
|
export interface RiskCheckRequest {
|
||||||
symbol: string
|
symbol: string
|
||||||
@@ -320,138 +321,10 @@ export async function POST(request: NextRequest): Promise<NextResponse<RiskCheck
|
|||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{
|
{
|
||||||
allowed: false,
|
allowed: false,
|
||||||
reason: 'Risk check failed',
|
reason: 'Server error',
|
||||||
details: error instanceof Error ? error.message : 'Unknown error',
|
details: error instanceof Error ? error.message : 'Unknown error',
|
||||||
},
|
},
|
||||||
{ status: 500 }
|
{ status: 500 }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SignalQualityResult {
|
|
||||||
passed: boolean
|
|
||||||
score: number
|
|
||||||
reasons: string[]
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Score signal quality based on context metrics from TradingView
|
|
||||||
* Returns score 0-100 and array of reasons
|
|
||||||
*/
|
|
||||||
function scoreSignalQuality(params: {
|
|
||||||
atr: number
|
|
||||||
adx: number
|
|
||||||
rsi: number
|
|
||||||
volumeRatio: number
|
|
||||||
pricePosition: number
|
|
||||||
direction: 'long' | 'short'
|
|
||||||
minScore?: number // Configurable minimum score threshold
|
|
||||||
}): SignalQualityResult {
|
|
||||||
let score = 50 // Base score
|
|
||||||
const reasons: string[] = []
|
|
||||||
|
|
||||||
// ATR check (volatility gate: 0.15% - 2.5%)
|
|
||||||
if (params.atr > 0) {
|
|
||||||
if (params.atr < 0.15) {
|
|
||||||
score -= 15
|
|
||||||
reasons.push(`ATR too low (${params.atr.toFixed(2)}% - dead market)`)
|
|
||||||
} else if (params.atr > 2.5) {
|
|
||||||
score -= 20
|
|
||||||
reasons.push(`ATR too high (${params.atr.toFixed(2)}% - too volatile)`)
|
|
||||||
} else if (params.atr >= 0.15 && params.atr < 0.4) {
|
|
||||||
score += 5
|
|
||||||
reasons.push(`ATR moderate (${params.atr.toFixed(2)}%)`)
|
|
||||||
} else {
|
|
||||||
score += 10
|
|
||||||
reasons.push(`ATR healthy (${params.atr.toFixed(2)}%)`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ADX check (trend strength: want >18)
|
|
||||||
if (params.adx > 0) {
|
|
||||||
if (params.adx > 25) {
|
|
||||||
score += 15
|
|
||||||
reasons.push(`Strong trend (ADX ${params.adx.toFixed(1)})`)
|
|
||||||
} else if (params.adx < 18) {
|
|
||||||
score -= 15
|
|
||||||
reasons.push(`Weak trend (ADX ${params.adx.toFixed(1)})`)
|
|
||||||
} else {
|
|
||||||
score += 5
|
|
||||||
reasons.push(`Moderate trend (ADX ${params.adx.toFixed(1)})`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// RSI check (momentum confirmation)
|
|
||||||
if (params.rsi > 0) {
|
|
||||||
if (params.direction === 'long') {
|
|
||||||
if (params.rsi > 50 && params.rsi < 70) {
|
|
||||||
score += 10
|
|
||||||
reasons.push(`RSI supports long (${params.rsi.toFixed(1)})`)
|
|
||||||
} else if (params.rsi > 70) {
|
|
||||||
score -= 10
|
|
||||||
reasons.push(`RSI overbought (${params.rsi.toFixed(1)})`)
|
|
||||||
}
|
|
||||||
} else { // short
|
|
||||||
if (params.rsi < 50 && params.rsi > 30) {
|
|
||||||
score += 10
|
|
||||||
reasons.push(`RSI supports short (${params.rsi.toFixed(1)})`)
|
|
||||||
} else if (params.rsi < 30) {
|
|
||||||
score -= 10
|
|
||||||
reasons.push(`RSI oversold (${params.rsi.toFixed(1)})`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Volume check (want > 1.0 = above average)
|
|
||||||
if (params.volumeRatio > 0) {
|
|
||||||
if (params.volumeRatio > 1.5) {
|
|
||||||
score += 15
|
|
||||||
reasons.push(`Very strong volume (${params.volumeRatio.toFixed(2)}x avg)`)
|
|
||||||
} else if (params.volumeRatio > 1.2) {
|
|
||||||
score += 10
|
|
||||||
reasons.push(`Strong volume (${params.volumeRatio.toFixed(2)}x avg)`)
|
|
||||||
} else if (params.volumeRatio < 0.8) {
|
|
||||||
score -= 10
|
|
||||||
reasons.push(`Weak volume (${params.volumeRatio.toFixed(2)}x avg)`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Price position check (avoid chasing vs breakout detection)
|
|
||||||
if (params.pricePosition > 0) {
|
|
||||||
if (params.direction === 'long' && params.pricePosition > 95) {
|
|
||||||
// High volume breakout at range top can be good
|
|
||||||
if (params.volumeRatio > 1.4) {
|
|
||||||
score += 5
|
|
||||||
reasons.push(`Volume breakout at range top (${params.pricePosition.toFixed(0)}%, vol ${params.volumeRatio.toFixed(2)}x)`)
|
|
||||||
} else {
|
|
||||||
score -= 15
|
|
||||||
reasons.push(`Price near top of range (${params.pricePosition.toFixed(0)}%) - risky long`)
|
|
||||||
}
|
|
||||||
} else if (params.direction === 'short' && params.pricePosition < 5) {
|
|
||||||
// High volume breakdown at range bottom can be good
|
|
||||||
if (params.volumeRatio > 1.4) {
|
|
||||||
score += 5
|
|
||||||
reasons.push(`Volume breakdown at range bottom (${params.pricePosition.toFixed(0)}%, vol ${params.volumeRatio.toFixed(2)}x)`)
|
|
||||||
} else {
|
|
||||||
score -= 15
|
|
||||||
reasons.push(`Price near bottom of range (${params.pricePosition.toFixed(0)}%) - risky short`)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
score += 5
|
|
||||||
reasons.push(`Price position OK (${params.pricePosition.toFixed(0)}%)`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Volume breakout bonus (high volume can override other weaknesses)
|
|
||||||
if (params.volumeRatio > 1.8 && params.atr < 0.6) {
|
|
||||||
score += 10
|
|
||||||
reasons.push(`Volume breakout compensates for low ATR`)
|
|
||||||
}
|
|
||||||
|
|
||||||
const minScore = params.minScore ?? 60 // Use config value or default to 60
|
|
||||||
return {
|
|
||||||
passed: score >= minScore,
|
|
||||||
score,
|
|
||||||
reasons
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -12,84 +12,7 @@ import { normalizeTradingViewSymbol } from '@/config/trading'
|
|||||||
import { getMergedConfig } from '@/config/trading'
|
import { getMergedConfig } from '@/config/trading'
|
||||||
import { getInitializedPositionManager, ActiveTrade } from '@/lib/trading/position-manager'
|
import { getInitializedPositionManager, ActiveTrade } from '@/lib/trading/position-manager'
|
||||||
import { createTrade, updateTradeExit } from '@/lib/database/trades'
|
import { createTrade, updateTradeExit } from '@/lib/database/trades'
|
||||||
|
import { scoreSignalQuality } from '@/lib/trading/signal-quality'
|
||||||
/**
|
|
||||||
* Calculate signal quality score (same logic as check-risk endpoint)
|
|
||||||
*/
|
|
||||||
function calculateQualityScore(params: {
|
|
||||||
atr?: number
|
|
||||||
adx?: number
|
|
||||||
rsi?: number
|
|
||||||
volumeRatio?: number
|
|
||||||
pricePosition?: number
|
|
||||||
direction: 'long' | 'short'
|
|
||||||
}): number | undefined {
|
|
||||||
// If no metrics provided, return undefined
|
|
||||||
if (!params.atr || params.atr === 0) {
|
|
||||||
return undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
let score = 50 // Base score
|
|
||||||
|
|
||||||
// ATR check
|
|
||||||
if (params.atr < 0.6) {
|
|
||||||
score -= 15
|
|
||||||
} else if (params.atr > 2.5) {
|
|
||||||
score -= 20
|
|
||||||
} else {
|
|
||||||
score += 10
|
|
||||||
}
|
|
||||||
|
|
||||||
// ADX check
|
|
||||||
if (params.adx && params.adx > 0) {
|
|
||||||
if (params.adx > 25) {
|
|
||||||
score += 15
|
|
||||||
} else if (params.adx < 18) {
|
|
||||||
score -= 15
|
|
||||||
} else {
|
|
||||||
score += 5
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// RSI check
|
|
||||||
if (params.rsi && params.rsi > 0) {
|
|
||||||
if (params.direction === 'long') {
|
|
||||||
if (params.rsi > 50 && params.rsi < 70) {
|
|
||||||
score += 10
|
|
||||||
} else if (params.rsi > 70) {
|
|
||||||
score -= 10
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if (params.rsi < 50 && params.rsi > 30) {
|
|
||||||
score += 10
|
|
||||||
} else if (params.rsi < 30) {
|
|
||||||
score -= 10
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Volume check
|
|
||||||
if (params.volumeRatio && params.volumeRatio > 0) {
|
|
||||||
if (params.volumeRatio > 1.2) {
|
|
||||||
score += 10
|
|
||||||
} else if (params.volumeRatio < 0.8) {
|
|
||||||
score -= 10
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Price position check
|
|
||||||
if (params.pricePosition && params.pricePosition > 0) {
|
|
||||||
if (params.direction === 'long' && params.pricePosition > 90) {
|
|
||||||
score -= 15
|
|
||||||
} else if (params.direction === 'short' && params.pricePosition < 10) {
|
|
||||||
score -= 15
|
|
||||||
} else {
|
|
||||||
score += 5
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return score
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ExecuteTradeRequest {
|
export interface ExecuteTradeRequest {
|
||||||
symbol: string // TradingView symbol (e.g., 'SOLUSDT')
|
symbol: string // TradingView symbol (e.g., 'SOLUSDT')
|
||||||
@@ -381,12 +304,12 @@ export async function POST(request: NextRequest): Promise<NextResponse<ExecuteTr
|
|||||||
|
|
||||||
// Save phantom trade to database for analysis
|
// Save phantom trade to database for analysis
|
||||||
try {
|
try {
|
||||||
const qualityScore = calculateQualityScore({
|
const qualityResult = scoreSignalQuality({
|
||||||
atr: body.atr,
|
atr: body.atr || 0,
|
||||||
adx: body.adx,
|
adx: body.adx || 0,
|
||||||
rsi: body.rsi,
|
rsi: body.rsi || 0,
|
||||||
volumeRatio: body.volumeRatio,
|
volumeRatio: body.volumeRatio || 0,
|
||||||
pricePosition: body.pricePosition,
|
pricePosition: body.pricePosition || 0,
|
||||||
direction: body.direction,
|
direction: body.direction,
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -411,7 +334,7 @@ export async function POST(request: NextRequest): Promise<NextResponse<ExecuteTr
|
|||||||
rsiAtEntry: body.rsi,
|
rsiAtEntry: body.rsi,
|
||||||
volumeAtEntry: body.volumeRatio,
|
volumeAtEntry: body.volumeRatio,
|
||||||
pricePositionAtEntry: body.pricePosition,
|
pricePositionAtEntry: body.pricePosition,
|
||||||
signalQualityScore: qualityScore,
|
signalQualityScore: qualityResult.score,
|
||||||
// Phantom-specific fields
|
// Phantom-specific fields
|
||||||
status: 'phantom',
|
status: 'phantom',
|
||||||
isPhantom: true,
|
isPhantom: true,
|
||||||
@@ -591,12 +514,12 @@ export async function POST(request: NextRequest): Promise<NextResponse<ExecuteTr
|
|||||||
// Save trade to database
|
// Save trade to database
|
||||||
try {
|
try {
|
||||||
// Calculate quality score if metrics available
|
// Calculate quality score if metrics available
|
||||||
const qualityScore = calculateQualityScore({
|
const qualityResult = scoreSignalQuality({
|
||||||
atr: body.atr,
|
atr: body.atr || 0,
|
||||||
adx: body.adx,
|
adx: body.adx || 0,
|
||||||
rsi: body.rsi,
|
rsi: body.rsi || 0,
|
||||||
volumeRatio: body.volumeRatio,
|
volumeRatio: body.volumeRatio || 0,
|
||||||
pricePosition: body.pricePosition,
|
pricePosition: body.pricePosition || 0,
|
||||||
direction: body.direction,
|
direction: body.direction,
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -629,14 +552,11 @@ export async function POST(request: NextRequest): Promise<NextResponse<ExecuteTr
|
|||||||
rsiAtEntry: body.rsi,
|
rsiAtEntry: body.rsi,
|
||||||
volumeAtEntry: body.volumeRatio,
|
volumeAtEntry: body.volumeRatio,
|
||||||
pricePositionAtEntry: body.pricePosition,
|
pricePositionAtEntry: body.pricePosition,
|
||||||
signalQualityScore: qualityScore,
|
signalQualityScore: qualityResult.score,
|
||||||
})
|
})
|
||||||
|
|
||||||
if (qualityScore !== undefined) {
|
console.log(`💾 Trade saved with quality score: ${qualityResult.score}/100`)
|
||||||
console.log(`💾 Trade saved with quality score: ${qualityScore}/100`)
|
console.log(`📊 Quality reasons: ${qualityResult.reasons.join(', ')}`)
|
||||||
} else {
|
|
||||||
console.log('💾 Trade saved to database')
|
|
||||||
}
|
|
||||||
} catch (dbError) {
|
} catch (dbError) {
|
||||||
console.error('❌ Failed to save trade to database:', dbError)
|
console.error('❌ Failed to save trade to database:', dbError)
|
||||||
// Don't fail the trade if database save fails
|
// Don't fail the trade if database save fails
|
||||||
|
|||||||
147
lib/trading/signal-quality.ts
Normal file
147
lib/trading/signal-quality.ts
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
/**
|
||||||
|
* Signal Quality Scoring
|
||||||
|
*
|
||||||
|
* Unified quality scoring logic used by both check-risk and execute endpoints.
|
||||||
|
* Ensures consistent scoring across the trading pipeline.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface SignalQualityResult {
|
||||||
|
score: number
|
||||||
|
passed: boolean
|
||||||
|
reasons: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate signal quality score based on technical indicators
|
||||||
|
*
|
||||||
|
* Scoring breakdown:
|
||||||
|
* - Base: 50 points
|
||||||
|
* - ATR (volatility): -20 to +10 points
|
||||||
|
* - ADX (trend strength): -15 to +15 points
|
||||||
|
* - RSI (momentum): -10 to +10 points
|
||||||
|
* - Volume: -10 to +15 points
|
||||||
|
* - Price position: -15 to +5 points
|
||||||
|
* - Volume breakout bonus: +10 points
|
||||||
|
*
|
||||||
|
* Total range: ~15-115 points (realistically 30-100)
|
||||||
|
* Threshold: 60 points minimum for execution
|
||||||
|
*/
|
||||||
|
export function scoreSignalQuality(params: {
|
||||||
|
atr: number
|
||||||
|
adx: number
|
||||||
|
rsi: number
|
||||||
|
volumeRatio: number
|
||||||
|
pricePosition: number
|
||||||
|
direction: 'long' | 'short'
|
||||||
|
minScore?: number // Configurable minimum score threshold
|
||||||
|
}): SignalQualityResult {
|
||||||
|
let score = 50 // Base score
|
||||||
|
const reasons: string[] = []
|
||||||
|
|
||||||
|
// ATR check (volatility gate: 0.15% - 2.5%)
|
||||||
|
if (params.atr > 0) {
|
||||||
|
if (params.atr < 0.15) {
|
||||||
|
score -= 15
|
||||||
|
reasons.push(`ATR too low (${params.atr.toFixed(2)}% - dead market)`)
|
||||||
|
} else if (params.atr > 2.5) {
|
||||||
|
score -= 20
|
||||||
|
reasons.push(`ATR too high (${params.atr.toFixed(2)}% - too volatile)`)
|
||||||
|
} else if (params.atr >= 0.15 && params.atr < 0.4) {
|
||||||
|
score += 5
|
||||||
|
reasons.push(`ATR moderate (${params.atr.toFixed(2)}%)`)
|
||||||
|
} else {
|
||||||
|
score += 10
|
||||||
|
reasons.push(`ATR healthy (${params.atr.toFixed(2)}%)`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ADX check (trend strength: want >18)
|
||||||
|
if (params.adx > 0) {
|
||||||
|
if (params.adx > 25) {
|
||||||
|
score += 15
|
||||||
|
reasons.push(`Strong trend (ADX ${params.adx.toFixed(1)})`)
|
||||||
|
} else if (params.adx < 18) {
|
||||||
|
score -= 15
|
||||||
|
reasons.push(`Weak trend (ADX ${params.adx.toFixed(1)})`)
|
||||||
|
} else {
|
||||||
|
score += 5
|
||||||
|
reasons.push(`Moderate trend (ADX ${params.adx.toFixed(1)})`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// RSI check (momentum confirmation)
|
||||||
|
if (params.rsi > 0) {
|
||||||
|
if (params.direction === 'long') {
|
||||||
|
if (params.rsi > 50 && params.rsi < 70) {
|
||||||
|
score += 10
|
||||||
|
reasons.push(`RSI supports long (${params.rsi.toFixed(1)})`)
|
||||||
|
} else if (params.rsi > 70) {
|
||||||
|
score -= 10
|
||||||
|
reasons.push(`RSI overbought (${params.rsi.toFixed(1)})`)
|
||||||
|
}
|
||||||
|
} else { // short
|
||||||
|
if (params.rsi < 50 && params.rsi > 30) {
|
||||||
|
score += 10
|
||||||
|
reasons.push(`RSI supports short (${params.rsi.toFixed(1)})`)
|
||||||
|
} else if (params.rsi < 30) {
|
||||||
|
score -= 10
|
||||||
|
reasons.push(`RSI oversold (${params.rsi.toFixed(1)})`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Volume check (want > 1.0 = above average)
|
||||||
|
if (params.volumeRatio > 0) {
|
||||||
|
if (params.volumeRatio > 1.5) {
|
||||||
|
score += 15
|
||||||
|
reasons.push(`Very strong volume (${params.volumeRatio.toFixed(2)}x avg)`)
|
||||||
|
} else if (params.volumeRatio > 1.2) {
|
||||||
|
score += 10
|
||||||
|
reasons.push(`Strong volume (${params.volumeRatio.toFixed(2)}x avg)`)
|
||||||
|
} else if (params.volumeRatio < 0.8) {
|
||||||
|
score -= 10
|
||||||
|
reasons.push(`Weak volume (${params.volumeRatio.toFixed(2)}x avg)`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Price position check (avoid chasing vs breakout detection)
|
||||||
|
if (params.pricePosition > 0) {
|
||||||
|
if (params.direction === 'long' && params.pricePosition > 95) {
|
||||||
|
// High volume breakout at range top can be good
|
||||||
|
if (params.volumeRatio > 1.4) {
|
||||||
|
score += 5
|
||||||
|
reasons.push(`Volume breakout at range top (${params.pricePosition.toFixed(0)}%, vol ${params.volumeRatio.toFixed(2)}x)`)
|
||||||
|
} else {
|
||||||
|
score -= 15
|
||||||
|
reasons.push(`Price near top of range (${params.pricePosition.toFixed(0)}%) - risky long`)
|
||||||
|
}
|
||||||
|
} else if (params.direction === 'short' && params.pricePosition < 5) {
|
||||||
|
// High volume breakdown at range bottom can be good
|
||||||
|
if (params.volumeRatio > 1.4) {
|
||||||
|
score += 5
|
||||||
|
reasons.push(`Volume breakdown at range bottom (${params.pricePosition.toFixed(0)}%, vol ${params.volumeRatio.toFixed(2)}x)`)
|
||||||
|
} else {
|
||||||
|
score -= 15
|
||||||
|
reasons.push(`Price near bottom of range (${params.pricePosition.toFixed(0)}%) - risky short`)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
score += 5
|
||||||
|
reasons.push(`Price position OK (${params.pricePosition.toFixed(0)}%)`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Volume breakout bonus (high volume can override other weaknesses)
|
||||||
|
if (params.volumeRatio > 1.8 && params.atr < 0.6) {
|
||||||
|
score += 10
|
||||||
|
reasons.push(`Volume breakout compensates for low ATR`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const minScore = params.minScore || 60
|
||||||
|
const passed = score >= minScore
|
||||||
|
|
||||||
|
return {
|
||||||
|
score,
|
||||||
|
passed,
|
||||||
|
reasons,
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user