Implement signal quality scoring system

- Updated execute endpoint to store context metrics in database
- Updated CreateTradeParams interface with 5 context metrics
- Updated Prisma schema with rsiAtEntry and pricePositionAtEntry
- Ran migration: add_rsi_and_price_position_metrics
- Complete flow: TradingView → n8n → check-risk (scores) → execute (stores)
This commit is contained in:
mindesbunister
2025-10-30 19:31:32 +01:00
parent 781b88f803
commit 830468d524
7 changed files with 198 additions and 398 deletions

View File

@@ -13,12 +13,20 @@ import { getLastTradeTime, getTradesInLastHour, getTodayPnL } from '@/lib/databa
export interface RiskCheckRequest { export interface RiskCheckRequest {
symbol: string symbol: string
direction: 'long' | 'short' direction: 'long' | 'short'
// Optional context metrics from TradingView
atr?: number
adx?: number
rsi?: number
volumeRatio?: number
pricePosition?: number
} }
export interface RiskCheckResponse { export interface RiskCheckResponse {
allowed: boolean allowed: boolean
reason?: string reason?: string
details?: string details?: string
qualityScore?: number
qualityReasons?: string[]
} }
export async function POST(request: NextRequest): Promise<NextResponse<RiskCheckResponse>> { export async function POST(request: NextRequest): Promise<NextResponse<RiskCheckResponse>> {
@@ -135,6 +143,50 @@ export async function POST(request: NextRequest): Promise<NextResponse<RiskCheck
} }
} }
// 4. Check signal quality (if context metrics provided)
const hasContextMetrics = body.atr !== undefined && body.atr > 0
if (hasContextMetrics) {
const qualityScore = scoreSignalQuality({
atr: body.atr || 0,
adx: body.adx || 0,
rsi: body.rsi || 0,
volumeRatio: body.volumeRatio || 0,
pricePosition: body.pricePosition || 0,
direction: body.direction
})
if (!qualityScore.passed) {
console.log('🚫 Risk check BLOCKED: Signal quality too low', {
score: qualityScore.score,
reasons: qualityScore.reasons
})
return NextResponse.json({
allowed: false,
reason: 'Signal quality too low',
details: `Score: ${qualityScore.score}/100 - ${qualityScore.reasons.join(', ')}`,
qualityScore: qualityScore.score,
qualityReasons: qualityScore.reasons
})
}
console.log(`✅ Risk check PASSED: All checks passed`, {
todayPnL: todayPnL.toFixed(2),
tradesLastHour: tradesInLastHour,
cooldownPassed: lastTradeTime ? 'yes' : 'no previous trades',
qualityScore: qualityScore.score,
qualityReasons: qualityScore.reasons
})
return NextResponse.json({
allowed: true,
details: 'All risk checks passed',
qualityScore: qualityScore.score,
qualityReasons: qualityScore.reasons
})
}
console.log(`✅ Risk check PASSED: All checks passed`, { console.log(`✅ Risk check PASSED: All checks passed`, {
todayPnL: todayPnL.toFixed(2), todayPnL: todayPnL.toFixed(2),
tradesLastHour: tradesInLastHour, tradesLastHour: tradesInLastHour,
@@ -159,3 +211,106 @@ export async function POST(request: NextRequest): Promise<NextResponse<RiskCheck
) )
} }
} }
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'
}): SignalQualityResult {
let score = 50 // Base score
const reasons: string[] = []
// ATR check (volatility gate: 0.6% - 2.5%)
if (params.atr > 0) {
if (params.atr < 0.6) {
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 {
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.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)
if (params.pricePosition > 0) {
if (params.direction === 'long' && params.pricePosition > 90) {
score -= 15
reasons.push(`Price near top of range (${params.pricePosition.toFixed(0)}%) - risky long`)
} else if (params.direction === 'short' && params.pricePosition < 10) {
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)}%)`)
}
}
const minScore = 60 // Require 60+ to pass
return {
passed: score >= minScore,
score,
reasons
}
}

View File

@@ -19,6 +19,12 @@ export interface ExecuteTradeRequest {
timeframe: string // e.g., '5' timeframe: string // e.g., '5'
signalStrength?: 'strong' | 'moderate' | 'weak' signalStrength?: 'strong' | 'moderate' | 'weak'
signalPrice?: number signalPrice?: number
// Context metrics from TradingView
atr?: number
adx?: number
rsi?: number
volumeRatio?: number
pricePosition?: number
} }
export interface ExecuteTradeResponse { export interface ExecuteTradeResponse {
@@ -360,6 +366,12 @@ export async function POST(request: NextRequest): Promise<NextResponse<ExecuteTr
// Market context // Market context
expectedEntryPrice, expectedEntryPrice,
fundingRateAtEntry, fundingRateAtEntry,
// Context metrics from TradingView
atrAtEntry: body.atr,
adxAtEntry: body.adx,
rsiAtEntry: body.rsi,
volumeAtEntry: body.volumeRatio,
pricePositionAtEntry: body.pricePosition,
}) })
console.log('💾 Trade saved to database') console.log('💾 Trade saved to database')

View File

@@ -48,7 +48,9 @@ export interface CreateTradeParams {
fundingRateAtEntry?: number fundingRateAtEntry?: number
atrAtEntry?: number atrAtEntry?: number
adxAtEntry?: number adxAtEntry?: number
rsiAtEntry?: number
volumeAtEntry?: number volumeAtEntry?: number
pricePositionAtEntry?: number
} }
export interface UpdateTradeStateParams { export interface UpdateTradeStateParams {

View File

@@ -0,0 +1,3 @@
-- AlterTable
ALTER TABLE "Trade" ADD COLUMN "pricePositionAtEntry" DOUBLE PRECISION,
ADD COLUMN "rsiAtEntry" DOUBLE PRECISION;

View File

@@ -72,7 +72,9 @@ model Trade {
// Market context at entry // Market context at entry
atrAtEntry Float? // ATR% when trade opened atrAtEntry Float? // ATR% when trade opened
adxAtEntry Float? // ADX trend strength (0-50) adxAtEntry Float? // ADX trend strength (0-50)
rsiAtEntry Float? // RSI momentum (0-100)
volumeAtEntry Float? // Volume relative to MA volumeAtEntry Float? // Volume relative to MA
pricePositionAtEntry Float? // Price position in range (0-100%)
fundingRateAtEntry Float? // Perp funding rate at entry fundingRateAtEntry Float? // Perp funding rate at entry
basisAtEntry Float? // Perp-spot basis at entry basisAtEntry Float? // Perp-spot basis at entry

View File

@@ -1,398 +0,0 @@
{
"name": "TradingView → Trading Bot v4",
"nodes": [
{
"parameters": {
"httpMethod": "POST",
"path": "tradingview-bot-v4",
"options": {}
},
"id": "webhook-node",
"name": "Webhook",
"type": "n8n-nodes-base.webhook",
"typeVersion": 1,
"position": [240, 400],
"webhookId": "tradingview-bot-v4"
},
{
"parameters": {
"fields": {
"values": [
{
"name": "rawMessage",
"stringValue": "={{ $json.body }}"
},
{
"name": "symbol",
"stringValue": "={{ $json.body.match(/\\bSOL\\b/i) ? 'SOL-PERP' : ($json.body.match(/\\bBTC\\b/i) ? 'BTC-PERP' : ($json.body.match(/\\bETH\\b/i) ? 'ETH-PERP' : 'SOL-PERP')) }}"
},
{
"name": "direction",
"stringValue": "={{ $json.body.match(/\\b(sell|short)\\b/i) ? 'short' : 'long' }}"
},
{
"name": "timeframe",
"stringValue": "={{ $json.body.match(/\\.P\\s+(\\d+)/)?.[1] || '15' }}"
}
]
},
"options": {}
},
"id": "parse-fields",
"name": "Parse Signal",
"type": "n8n-nodes-base.set",
"typeVersion": 3.2,
"position": [440, 400]
},
{
"parameters": {
"conditions": {
"string": [
{
"value1": "={{ $json.timeframe }}",
"operation": "equals",
"value2": "15"
}
]
}
},
"id": "timeframe-filter",
"name": "15min Chart Only?",
"type": "n8n-nodes-base.if",
"typeVersion": 1,
"position": [540, 400]
},
{
"parameters": {
"method": "POST",
"url": "http://10.0.0.48:3001/api/trading/check-risk",
"authentication": "genericCredentialType",
"genericAuthType": "httpHeaderAuth",
"sendHeaders": true,
"headerParameters": {
"parameters": [
{
"name": "Authorization",
"value": "Bearer 2a344f0149442c857fb56c038c0c7d1b113883b830bec792c76f1e0efa15d6bb"
},
{
"name": "Content-Type",
"value": "application/json"
}
]
},
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={\n \"symbol\": \"{{ $json.symbol }}\",\n \"direction\": \"{{ $json.direction }}\"\n}",
"options": {}
},
"id": "check-risk",
"name": "Check Risk",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4,
"position": [740, 400]
},
{
"parameters": {
"conditions": {
"boolean": [
{
"value1": "={{ $json.allowed }}",
"value2": true
}
]
}
},
"id": "risk-if",
"name": "Risk Passed?",
"type": "n8n-nodes-base.if",
"typeVersion": 1,
"position": [940, 400]
},
{
"parameters": {
"method": "POST",
"url": "http://10.0.0.48:3001/api/trading/execute",
"authentication": "genericCredentialType",
"genericAuthType": "httpHeaderAuth",
"sendHeaders": true,
"headerParameters": {
"parameters": [
{
"name": "Authorization",
"value": "Bearer 2a344f0149442c857fb56c038c0c7d1b113883b830bec792c76f1e0efa15d6bb"
},
{
"name": "Content-Type",
"value": "application/json"
}
]
},
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={\n \"symbol\": \"{{ $('Parse Signal').item.json.symbol }}\",\n \"direction\": \"{{ $('Parse Signal').item.json.direction }}\",\n \"timeframe\": \"{{ $('Parse Signal').item.json.timeframe }}\",\n \"signalStrength\": \"strong\"\n}",
"options": {
"timeout": 30000
}
},
"id": "execute-trade",
"name": "Execute Trade",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4,
"position": [1040, 300]
},
{
"parameters": {
"conditions": {
"boolean": [
{
"value1": "={{ $json.success }}",
"value2": true
}
]
}
},
"id": "trade-if",
"name": "Trade Success?",
"type": "n8n-nodes-base.if",
"typeVersion": 1,
"position": [1240, 300]
},
{
"parameters": {
"fields": {
"values": [
{
"name": "message",
"stringValue": "🟢 TRADE OPENED\\n\\n{{ $('Parse Signal').item.json.rawMessage }}\\n\\n📊 Symbol: {{ $('Parse Signal').item.json.symbol }}\\n📈 Direction: {{ $('Parse Signal').item.json.direction }}\\n⏰ {{ $now.toFormat('HH:mm') }}\\n\\n✅ Position monitored automatically"
}
]
},
"options": {}
},
"id": "format-success",
"name": "Format Success",
"type": "n8n-nodes-base.set",
"typeVersion": 3.2,
"position": [1440, 200]
},
{
"parameters": {
"fields": {
"values": [
{
"name": "message",
"stringValue": "🔴 TRADE FAILED\\n\\n{{ $('Parse Signal').item.json.rawMessage }}\\n\\n❌ Error: {{ $json.error || $json.message }}\\n⏰ {{ $now.toFormat('HH:mm') }}"
}
]
},
"options": {}
},
"id": "format-error",
"name": "Format Error",
"type": "n8n-nodes-base.set",
"typeVersion": 3.2,
"position": [1440, 400]
},
{
"parameters": {
"fields": {
"values": [
{
"name": "message",
"stringValue": "⚠️ TRADE BLOCKED\\n\\n{{ $('Parse Signal').item.json.rawMessage }}\\n\\n🛑 Risk limits exceeded\\n⏰ {{ $now.toFormat('HH:mm') }}"
}
]
},
"options": {}
},
"id": "format-risk",
"name": "Format Risk",
"type": "n8n-nodes-base.set",
"typeVersion": 3.2,
"position": [1040, 500]
},
{
"parameters": {
"chatId": "579304651",
"text": "={{ $json.message }}",
"additionalFields": {
"appendAttribution": false
}
},
"id": "telegram-success",
"name": "Telegram Success",
"type": "n8n-nodes-base.telegram",
"typeVersion": 1.1,
"position": [1640, 200],
"credentials": {
"telegramApi": {
"id": "Csk5cg4HtaSqP5jJ",
"name": "Telegram account"
}
}
},
{
"parameters": {
"chatId": "579304651",
"text": "={{ $json.message }}",
"additionalFields": {
"appendAttribution": false
}
},
"id": "telegram-error",
"name": "Telegram Error",
"type": "n8n-nodes-base.telegram",
"typeVersion": 1.1,
"position": [1640, 400],
"credentials": {
"telegramApi": {
"id": "Csk5cg4HtaSqP5jJ",
"name": "Telegram account"
}
}
},
{
"parameters": {
"chatId": "579304651",
"text": "={{ $json.message }}",
"additionalFields": {
"appendAttribution": false
}
},
"id": "telegram-risk",
"name": "Telegram Risk",
"type": "n8n-nodes-base.telegram",
"typeVersion": 1.1,
"position": [1240, 500],
"credentials": {
"telegramApi": {
"id": "Csk5cg4HtaSqP5jJ",
"name": "Telegram account"
}
}
}
],
"connections": {
"Webhook": {
"main": [
[
{
"node": "Parse Signal",
"type": "main",
"index": 0
}
]
]
},
"Parse Signal": {
"main": [
[
{
"node": "Check Risk",
"type": "main",
"index": 0
}
]
]
},
"Check Risk": {
"main": [
[
{
"node": "Risk Passed?",
"type": "main",
"index": 0
}
]
]
},
"Risk Passed?": {
"main": [
[
{
"node": "Execute Trade",
"type": "main",
"index": 0
}
],
[
{
"node": "Format Risk",
"type": "main",
"index": 0
}
]
]
},
"Execute Trade": {
"main": [
[
{
"node": "Trade Success?",
"type": "main",
"index": 0
}
]
]
},
"Trade Success?": {
"main": [
[
{
"node": "Format Success",
"type": "main",
"index": 0
}
],
[
{
"node": "Format Error",
"type": "main",
"index": 0
}
]
]
},
"Format Success": {
"main": [
[
{
"node": "Telegram Success",
"type": "main",
"index": 0
}
]
]
},
"Format Error": {
"main": [
[
{
"node": "Telegram Error",
"type": "main",
"index": 0
}
]
]
},
"Format Risk": {
"main": [
[
{
"node": "Telegram Risk",
"type": "main",
"index": 0
}
]
]
}
},
"pinData": {},
"active": false,
"settings": {
"executionOrder": "v1"
},
"versionId": "1",
"tags": []
}

View File

@@ -0,0 +1,24 @@
{
"name": "Parse Signal Enhanced",
"nodes": [
{
"parameters": {
"jsCode": "// Get the body - it might be a string or nested in an object\nlet body = $json.body || $json.query?.body || JSON.stringify($json);\n\n// If body is an object, stringify it\nif (typeof body === 'object') {\n body = JSON.stringify(body);\n}\n\n// Parse basic signal (existing logic)\nconst symbolMatch = body.match(/\\b(SOL|BTC|ETH)\\b/i);\nconst symbol = symbolMatch ? symbolMatch[1].toUpperCase() + '-PERP' : 'SOL-PERP';\n\nconst direction = body.match(/\\b(sell|short)\\b/i) ? 'short' : 'long';\n\nconst timeframeMatch = body.match(/\\.P\\s+(\\d+)/);\nconst timeframe = timeframeMatch ? timeframeMatch[1] : '15';\n\n// Parse new context metrics from enhanced format:\n// \"SOL buy .P 15 | ATR:1.85 | ADX:28.3 | RSI:62.5 | VOL:1.45 | POS:75.3\"\nconst atrMatch = body.match(/ATR:([\\d.]+)/);\nconst atr = atrMatch ? parseFloat(atrMatch[1]) : 0;\n\nconst adxMatch = body.match(/ADX:([\\d.]+)/);\nconst adx = adxMatch ? parseFloat(adxMatch[1]) : 0;\n\nconst rsiMatch = body.match(/RSI:([\\d.]+)/);\nconst rsi = rsiMatch ? parseFloat(rsiMatch[1]) : 0;\n\nconst volumeMatch = body.match(/VOL:([\\d.]+)/);\nconst volumeRatio = volumeMatch ? parseFloat(volumeMatch[1]) : 0;\n\nconst pricePositionMatch = body.match(/POS:([\\d.]+)/);\nconst pricePosition = pricePositionMatch ? parseFloat(pricePositionMatch[1]) : 0;\n\nreturn {\n rawMessage: body,\n symbol,\n direction,\n timeframe,\n // New context fields\n atr,\n adx,\n rsi,\n volumeRatio,\n pricePosition\n};"
},
"id": "parse-signal-enhanced",
"name": "Parse Signal Enhanced",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [600, 300]
}
],
"connections": {},
"settings": {
"executionOrder": "v1"
},
"staticData": null,
"tags": [],
"triggerCount": 0,
"updatedAt": "2025-10-30T00:00:00.000Z",
"versionId": "1"
}