From 9b767342dc4bb1701876a7796b0b74faaa3d5db3 Mon Sep 17 00:00:00 2001 From: mindesbunister Date: Fri, 7 Nov 2025 20:40:07 +0100 Subject: [PATCH] feat: Implement re-entry analytics system with fresh TradingView data - Add market data cache service (5min expiry) for storing TradingView metrics - Create /api/trading/market-data webhook endpoint for continuous data updates - Add /api/analytics/reentry-check endpoint for validating manual trades - Update execute endpoint to auto-cache metrics from incoming signals - Enhance Telegram bot with pre-execution analytics validation - Support --force flag to override analytics blocks - Use fresh ADX/ATR/RSI data when available, fallback to historical - Apply performance modifiers: -20 for losing streaks, +10 for winning - Minimum re-entry score 55 (vs 60 for new signals) - Fail-open design: proceeds if analytics unavailable - Show data freshness and source in Telegram responses - Add comprehensive setup guide in docs/guides/REENTRY_ANALYTICS_QUICKSTART.md Phase 1 implementation for smart manual trade validation. --- .github/copilot-instructions.md | 90 ++++- app/api/analytics/reentry-check/route.ts | 237 +++++++++++ app/api/trading/check-risk/route.ts | 10 +- app/api/trading/execute/route.ts | 96 ++--- app/api/trading/market-data/route.ts | 145 +++++++ app/api/trading/test/route.ts | 33 +- docs/guides/REENTRY_ANALYTICS_QUICKSTART.md | 243 +++++++++++ lib/database/trades.ts | 3 - lib/drift/orders.ts | 84 ++-- lib/trading/market-data-cache.ts | 117 ++++++ lib/trading/position-manager.ts | 427 +++++++------------- lib/trading/signal-quality.ts | 143 ++----- prisma/schema.prisma | 6 - telegram_command_bot.py | 84 +++- 14 files changed, 1150 insertions(+), 568 deletions(-) create mode 100644 app/api/analytics/reentry-check/route.ts create mode 100644 app/api/trading/market-data/route.ts create mode 100644 docs/guides/REENTRY_ANALYTICS_QUICKSTART.md create mode 100644 lib/trading/market-data-cache.ts diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 2a2855a..b4186ce 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -30,6 +30,13 @@ **Manual Trading via Telegram:** Send plain-text messages like `long sol`, `short eth`, `long btc` to open positions instantly (bypasses n8n, calls `/api/trading/execute` directly with preset healthy metrics). +**Re-Entry Analytics System:** Manual trades are validated before execution using fresh TradingView data: +- Market data cached from TradingView signals (5min expiry) +- `/api/analytics/reentry-check` scores re-entry based on fresh metrics + recent performance +- Telegram bot blocks low-quality re-entries unless `--force` flag used +- Uses real TradingView ADX/ATR/RSI when available, falls back to historical data +- Penalty for recent losing trades, bonus for winning streaks + ## Critical Components ### 1. Signal Quality Scoring (`lib/trading/signal-quality.ts`) @@ -87,18 +94,23 @@ await positionManager.addTrade(activeTrade) **Manual trade commands via plain text:** ```python # User sends plain text message (not slash commands) -"long sol" → Opens SOL-PERP long position -"short eth" → Opens ETH-PERP short position -"long btc" → Opens BTC-PERP long position +"long sol" → Validates via analytics, then opens SOL-PERP long +"short eth" → Validates via analytics, then opens ETH-PERP short +"long btc --force" → Skips analytics validation, opens BTC-PERP long immediately ``` **Key behaviors:** - MessageHandler processes all text messages (not just commands) - Maps user-friendly symbols (sol, eth, btc) to Drift format (SOL-PERP, etc.) -- Calls `/api/trading/execute` directly with preset healthy metrics (ATR=1.0, ADX=25, RSI=50, volumeRatio=1.2) +- **Analytics validation:** Calls `/api/analytics/reentry-check` before execution + - Blocks trades with score <55 unless `--force` flag used + - Uses fresh TradingView data (<5min old) when available + - Falls back to historical metrics with penalty + - Considers recent trade performance (last 3 trades) +- Calls `/api/trading/execute` directly with preset healthy metrics (ATR=0.45, ADX=32, RSI=58/42) - Bypasses n8n workflow and TradingView requirements - 60-second timeout for API calls -- Responds with trade confirmation or error message +- Responds with trade confirmation or analytics rejection message **Status command:** ```python @@ -218,13 +230,15 @@ const driftSymbol = normalizeTradingViewSymbol(body.symbol) 7. Add to Position Manager if applicable **Key endpoints:** -- `/api/trading/execute` - Main entry point from n8n (production, requires auth) +- `/api/trading/execute` - Main entry point from n8n (production, requires auth), **auto-caches market data** - `/api/trading/check-risk` - Pre-execution validation (duplicate check, quality score, **per-symbol cooldown**, rate limits, **symbol enabled check**) - `/api/trading/test` - Test trades from settings UI (no auth required, **respects symbol enable/disable**) - `/api/trading/close` - Manual position closing - `/api/trading/positions` - Query open positions from Drift +- `/api/trading/market-data` - Webhook for TradingView market data updates (GET for debug, POST for data) - `/api/settings` - Get/update config (writes to .env file, **includes per-symbol settings**) - `/api/analytics/last-trade` - Fetch most recent trade details for dashboard (includes quality score) +- `/api/analytics/reentry-check` - **Validate manual re-entry** with fresh TradingView data + recent performance - `/api/analytics/version-comparison` - Compare performance across signal quality logic versions (v1/v2/v3) - `/api/restart` - Create restart flag for watch-restart.sh script @@ -442,6 +456,70 @@ trade.realizedPnL += actualRealizedPnL // NOT: result.realizedPnL from SDK - **Types:** Define interfaces in same file as implementation (not separate types directory) - **Console logs:** Use emojis for visual scanning: 🎯 🚀 ✅ ❌ 💰 📊 🛡️ +## Re-Entry Analytics System (Phase 1) + +**Purpose:** Validate manual Telegram trades using fresh TradingView data + recent performance analysis + +**Components:** +1. **Market Data Cache** (`lib/trading/market-data-cache.ts`) + - Singleton service storing TradingView metrics + - 5-minute expiry on cached data + - Tracks: ATR, ADX, RSI, volume ratio, price position, timeframe + +2. **Market Data Webhook** (`app/api/trading/market-data/route.ts`) + - Receives TradingView alerts every 1-5 minutes + - POST: Updates cache with fresh metrics + - GET: View cached data (debugging) + +3. **Re-Entry Check Endpoint** (`app/api/analytics/reentry-check/route.ts`) + - Validates manual trade requests + - Uses fresh TradingView data if available (<5min old) + - Falls back to historical metrics from last trade + - Scores signal quality + applies performance modifiers: + - **-20 points** if last 3 trades lost money (avgPnL < -5%) + - **+10 points** if last 3 trades won (avgPnL > +5%, WR >= 66%) + - **-5 points** for stale data, **-10 points** for no data + - Minimum score: 55 (vs 60 for new signals) + +4. **Auto-Caching** (`app/api/trading/execute/route.ts`) + - Every trade signal from TradingView auto-caches metrics + - Ensures fresh data available for manual re-entries + +5. **Telegram Integration** (`telegram_command_bot.py`) + - Calls `/api/analytics/reentry-check` before executing manual trades + - Shows data freshness ("✅ FRESH 23s old" vs "⚠️ Historical") + - Blocks low-quality re-entries unless `--force` flag used + - Fail-open: Proceeds if analytics check fails + +**User Flow:** +``` +User: "long sol" + ↓ Check cache for SOL-PERP + ↓ Fresh data? → Use real TradingView metrics + ↓ Stale/missing? → Use historical + penalty + ↓ Score quality + recent performance + ↓ Score >= 55? → Execute + ↓ Score < 55? → Block (unless --force) +``` + +**TradingView Setup:** +Create alerts that fire every 1-5 minutes with this webhook message: +```json +{ + "action": "market_data", + "symbol": "{{ticker}}", + "timeframe": "{{interval}}", + "atr": {{ta.atr(14)}}, + "adx": {{ta.dmi(14, 14)}}, + "rsi": {{ta.rsi(14)}}, + "volumeRatio": {{volume / ta.sma(volume, 20)}}, + "pricePosition": {{(close - ta.lowest(low, 100)) / (ta.highest(high, 100) - ta.lowest(low, 100)) * 100}}, + "currentPrice": {{close}} +} +``` + +Webhook URL: `https://your-domain.com/api/trading/market-data` + ## Per-Symbol Trading Controls **Purpose:** Independent enable/disable toggles and position sizing for SOL and ETH to support different trading strategies (e.g., ETH for data collection at minimal size, SOL for profit generation). diff --git a/app/api/analytics/reentry-check/route.ts b/app/api/analytics/reentry-check/route.ts new file mode 100644 index 0000000..16454cd --- /dev/null +++ b/app/api/analytics/reentry-check/route.ts @@ -0,0 +1,237 @@ +import { NextRequest, NextResponse } from 'next/server' +import { getMarketDataCache } from '@/lib/trading/market-data-cache' +import { getPrismaClient } from '@/lib/database/trades' +import { scoreSignalQuality } from '@/lib/trading/signal-quality' + +/** + * Re-Entry Analytics Endpoint + * + * Validates manual trades using: + * 1. Fresh TradingView market data (if available) + * 2. Recent trade performance (last 3 trades for symbol + direction) + * 3. Signal quality scoring with performance modifiers + * + * Called by Telegram bot before executing manual "long sol" / "short eth" commands + */ + +interface ReentryAnalytics { + should_enter: boolean + score: number + reason: string + data_source: 'tradingview_real' | 'fallback_historical' | 'no_data' + data_age_seconds?: number + metrics: { + atr: number + adx: number + rsi: number + volumeRatio: number + pricePosition: number + timeframe: string + recentTradeStats: { + last3Trades: number + winRate: number + avgPnL: number + } + } +} + +export async function POST(request: NextRequest) { + try { + const body = await request.json() + const { symbol, direction } = body + + if (!symbol || !direction) { + return NextResponse.json( + { error: 'Missing symbol or direction' }, + { status: 400 } + ) + } + + if (!['long', 'short'].includes(direction)) { + return NextResponse.json( + { error: 'Direction must be "long" or "short"' }, + { status: 400 } + ) + } + + console.log(`🔍 Analyzing re-entry for ${direction.toUpperCase()} ${symbol}`) + + // 1. Try to get REAL market data from TradingView cache + const marketCache = getMarketDataCache() + const cachedData = marketCache.get(symbol) + + let metrics: any + let dataSource: 'tradingview_real' | 'fallback_historical' | 'no_data' + let dataAgeSeconds: number | undefined + + if (cachedData) { + // Use REAL TradingView data (less than 5min old) + dataAgeSeconds = Math.round((Date.now() - cachedData.timestamp) / 1000) + dataSource = 'tradingview_real' + + console.log(`✅ Using real TradingView data (${dataAgeSeconds}s old)`) + metrics = { + atr: cachedData.atr, + adx: cachedData.adx, + rsi: cachedData.rsi, + volumeRatio: cachedData.volumeRatio, + pricePosition: cachedData.pricePosition, + timeframe: cachedData.timeframe + } + } else { + // Fallback to most recent trade metrics + console.log(`⚠️ No fresh TradingView data, using historical metrics from last trade`) + const prisma = getPrismaClient() + const lastTrade = await prisma.trade.findFirst({ + where: { symbol }, + orderBy: { createdAt: 'desc' } + }) as any // Trade type has optional metric fields + + if (lastTrade && lastTrade.atr && lastTrade.adx && lastTrade.rsi) { + dataSource = 'fallback_historical' + const tradeAge = Math.round((Date.now() - lastTrade.createdAt.getTime()) / 1000) + console.log(`📊 Using metrics from last trade (${tradeAge}s ago)`) + metrics = { + atr: lastTrade.atr, + adx: lastTrade.adx, + rsi: lastTrade.rsi, + volumeRatio: lastTrade.volumeRatio || 1.2, + pricePosition: lastTrade.pricePosition || 50, + timeframe: '5' + } + } else { + // No data available at all + console.log(`❌ No market data available for ${symbol}`) + dataSource = 'no_data' + metrics = { + atr: 1.0, + adx: 20, + rsi: direction === 'long' ? 45 : 55, + volumeRatio: 1.2, + pricePosition: 50, + timeframe: '5' + } + } + } + + // 2. Get recent trade performance for this symbol + direction + const prisma = getPrismaClient() + const recentTrades = await prisma.trade.findMany({ + where: { + symbol, + direction, + exitTime: { not: null }, + createdAt: { + gte: new Date(Date.now() - 24 * 60 * 60 * 1000) // Last 24h + } + }, + orderBy: { createdAt: 'desc' }, + take: 3 + }) + + const last3Count = recentTrades.length + const winningTrades = recentTrades.filter((t: any) => (t.realizedPnL || 0) > 0) + const winRate = last3Count > 0 ? (winningTrades.length / last3Count) * 100 : 0 + const avgPnL = last3Count > 0 + ? recentTrades.reduce((sum: number, t: any) => sum + (t.realizedPnL || 0), 0) / last3Count + : 0 + + 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({ + atr: metrics.atr, + adx: metrics.adx, + rsi: metrics.rsi, + volumeRatio: metrics.volumeRatio, + pricePosition: metrics.pricePosition, + direction: direction as 'long' | 'short' + }) + + let finalScore = qualityResult.score + + // 4. Apply recent performance modifiers + if (last3Count >= 2 && avgPnL < -5) { + finalScore -= 20 + console.log(`⚠️ Recent trades losing (${avgPnL.toFixed(2)}% avg) - applying -20 penalty`) + } + + if (last3Count >= 2 && avgPnL > 5 && winRate >= 66) { + finalScore += 10 + console.log(`✨ Recent trades winning (${winRate.toFixed(0)}% WR) - applying +10 bonus`) + } + + // 5. Penalize if using stale/no data + if (dataSource === 'fallback_historical') { + finalScore -= 5 + console.log(`⚠️ Using historical data - applying -5 penalty`) + } else if (dataSource === 'no_data') { + finalScore -= 10 + console.log(`⚠️ No market data available - applying -10 penalty`) + } + + // 6. Determine if should enter + const MIN_REENTRY_SCORE = 55 + const should_enter = finalScore >= MIN_REENTRY_SCORE + + let reason = '' + if (!should_enter) { + if (dataSource === 'no_data') { + reason = `No market data available (score: ${finalScore})` + } else if (dataSource === 'fallback_historical') { + reason = `Using stale data (score: ${finalScore})` + } else if (finalScore < MIN_REENTRY_SCORE) { + reason = `Quality score too low (${finalScore} < ${MIN_REENTRY_SCORE})` + } + + if (last3Count >= 2 && avgPnL < -5) { + reason += `. Recent ${direction} trades losing (${avgPnL.toFixed(2)}% avg)` + } + } else { + reason = `Quality score acceptable (${finalScore}/${MIN_REENTRY_SCORE})` + + if (dataSource === 'tradingview_real') { + reason += ` [✅ FRESH TradingView data: ${dataAgeSeconds}s old]` + } else if (dataSource === 'fallback_historical') { + reason += ` [⚠️ Historical data - consider waiting for fresh signal]` + } else { + reason += ` [❌ No data - risky entry]` + } + + if (winRate >= 66 && last3Count >= 2) { + reason += `. Recent win rate: ${winRate.toFixed(0)}%` + } + } + + const response: ReentryAnalytics = { + should_enter, + score: finalScore, + reason, + data_source: dataSource, + data_age_seconds: dataAgeSeconds, + metrics: { + ...metrics, + recentTradeStats: { + last3Trades: last3Count, + winRate, + avgPnL + } + } + } + + console.log(`📊 Re-entry analysis complete:`, { + should_enter, + score: finalScore, + data_source: dataSource + }) + + return NextResponse.json(response) + + } catch (error) { + console.error('❌ Re-entry analysis error:', error) + return NextResponse.json( + { error: 'Internal server error' }, + { status: 500 } + ) + } +} diff --git a/app/api/trading/check-risk/route.ts b/app/api/trading/check-risk/route.ts index bb25f02..879d38a 100644 --- a/app/api/trading/check-risk/route.ts +++ b/app/api/trading/check-risk/route.ts @@ -15,7 +15,6 @@ import { scoreSignalQuality, SignalQualityResult } from '@/lib/trading/signal-qu export interface RiskCheckRequest { symbol: string direction: 'long' | 'short' - timeframe?: string // e.g., '5', '15', '60', '1D' // Optional context metrics from TradingView atr?: number adx?: number @@ -58,7 +57,6 @@ function shouldAllowScaling( pricePosition: newSignal.pricePosition, direction: newSignal.direction, minScore: config.minScaleQualityScore, - timeframe: newSignal.timeframe, }) // 2. Check quality score (higher bar than initial entry) @@ -148,9 +146,8 @@ export async function POST(request: NextRequest): Promise trade.symbol === body.symbol) if (existingPosition) { @@ -273,8 +270,7 @@ export async function POST(request: NextRequest): Promise 0) { - const coverage = (actualScaleNotional / scaleSize) * 100 - if (coverage < 99.5) { - console.log(`⚠️ Scale fill coverage: ${coverage.toFixed(2)}% of requested $${scaleSize.toFixed(2)}`) - } - } + const totalScaleAdded = (sameDirectionPosition.totalScaleAdded || 0) + scaleSize + const newTotalSize = sameDirectionPosition.currentSize + (scaleResult.fillSize || 0) // Update the trade tracking (simplified - just update the active trade object) sameDirectionPosition.timesScaled = timesScaled @@ -279,20 +287,20 @@ export async function POST(request: NextRequest): Promise setTimeout(resolve, 2000)) } - // Calculate requested position size with leverage - const requestedPositionSizeUSD = positionSize * leverage + // Calculate position size with leverage + const positionSizeUSD = positionSize * leverage console.log(`💰 Opening ${body.direction} position:`) console.log(` Symbol: ${driftSymbol}`) console.log(` Base size: $${positionSize}`) console.log(` Leverage: ${leverage}x`) - console.log(` Requested notional: $${requestedPositionSizeUSD}`) + console.log(` Total position: $${positionSizeUSD}`) // Open position const openResult = await openPosition({ symbol: driftSymbol, direction: body.direction, - sizeUSD: requestedPositionSizeUSD, + sizeUSD: positionSizeUSD, slippageTolerance: config.slippageTolerance, }) @@ -310,7 +318,7 @@ export async function POST(request: NextRequest): Promise 18 requirement // Phantom-specific fields status: 'phantom', isPhantom: true, - expectedSizeUSD: requestedPositionSizeUSD, + expectedSizeUSD: positionSizeUSD, actualSizeUSD: openResult.actualSizeUSD, phantomReason: 'ORACLE_PRICE_MISMATCH', // Likely cause based on logs }) @@ -365,7 +371,7 @@ export async function POST(request: NextRequest): Promise 0 ? actualPositionSizeUSD / entryPrice : 0) - const fillCoverage = requestedPositionSizeUSD > 0 - ? (actualPositionSizeUSD / requestedPositionSizeUSD) * 100 - : 100 - - console.log('📏 Fill results:') - console.log(` Filled base size: ${filledBaseSize.toFixed(4)} ${driftSymbol.split('-')[0]}`) - console.log(` Filled notional: $${actualPositionSizeUSD.toFixed(2)}`) - if (fillCoverage < 99.5) { - console.log(` ⚠️ Partial fill: ${fillCoverage.toFixed(2)}% of requested size`) - } const stopLossPrice = calculatePrice( entryPrice, @@ -420,15 +412,9 @@ export async function POST(request: NextRequest): Promise 18 requirement for extreme positions - expectedSizeUSD: requestedPositionSizeUSD, - actualSizeUSD: actualPositionSizeUSD, }) console.log(`💾 Trade saved with quality score: ${qualityResult.score}/100`) diff --git a/app/api/trading/market-data/route.ts b/app/api/trading/market-data/route.ts new file mode 100644 index 0000000..22355d4 --- /dev/null +++ b/app/api/trading/market-data/route.ts @@ -0,0 +1,145 @@ +import { NextRequest, NextResponse } from 'next/server' +import { getMarketDataCache } from '@/lib/trading/market-data-cache' + +/** + * Market Data Webhook Endpoint + * + * Receives real-time metrics from TradingView alerts. + * Called every 1-5 minutes per symbol to keep cache fresh. + * + * TradingView Alert Message (JSON): + * { + * "action": "market_data", + * "symbol": "{{ticker}}", + * "timeframe": "{{interval}}", + * "atr": {{ta.atr(14)}}, + * "adx": {{ta.dmi(14, 14)}}, + * "rsi": {{ta.rsi(14)}}, + * "volumeRatio": {{volume / ta.sma(volume, 20)}}, + * "pricePosition": {{(close - ta.lowest(low, 100)) / (ta.highest(high, 100) - ta.lowest(low, 100)) * 100}}, + * "currentPrice": {{close}}, + * "timestamp": {{timenow}} + * } + * + * Webhook URL: https://your-domain.com/api/trading/market-data + */ + +/** + * Normalize TradingView symbol format to Drift format + */ +function normalizeTradingViewSymbol(tvSymbol: string): string { + if (tvSymbol.includes('-PERP')) return tvSymbol + + const symbolMap: Record = { + 'SOLUSDT': 'SOL-PERP', + 'SOLUSD': 'SOL-PERP', + 'SOL': 'SOL-PERP', + 'ETHUSDT': 'ETH-PERP', + 'ETHUSD': 'ETH-PERP', + 'ETH': 'ETH-PERP', + 'BTCUSDT': 'BTC-PERP', + 'BTCUSD': 'BTC-PERP', + 'BTC': 'BTC-PERP' + } + + return symbolMap[tvSymbol.toUpperCase()] || `${tvSymbol.toUpperCase()}-PERP` +} + +export async function POST(request: NextRequest) { + try { + const body = await request.json() + + console.log('📡 Received market data webhook:', { + action: body.action, + symbol: body.symbol, + atr: body.atr, + adx: body.adx + }) + + // Validate it's a market data update + if (body.action !== 'market_data') { + console.log(`❌ Invalid action: ${body.action} (expected "market_data")`) + return NextResponse.json( + { error: 'Invalid action - expected "market_data"' }, + { status: 400 } + ) + } + + // Validate required fields + if (!body.symbol) { + return NextResponse.json( + { error: 'Missing symbol' }, + { status: 400 } + ) + } + + const driftSymbol = normalizeTradingViewSymbol(body.symbol) + + // Store in cache + const marketCache = getMarketDataCache() + marketCache.set(driftSymbol, { + symbol: driftSymbol, + atr: Number(body.atr) || 0, + adx: Number(body.adx) || 0, + rsi: Number(body.rsi) || 50, + volumeRatio: Number(body.volumeRatio) || 1.0, + pricePosition: Number(body.pricePosition) || 50, + currentPrice: Number(body.currentPrice) || 0, + timestamp: Date.now(), + timeframe: body.timeframe || '5' + }) + + console.log(`✅ Market data cached for ${driftSymbol}`) + + return NextResponse.json({ + success: true, + symbol: driftSymbol, + message: 'Market data cached successfully', + expiresInSeconds: 300 + }) + + } catch (error) { + console.error('❌ Market data webhook error:', error) + return NextResponse.json( + { error: 'Internal server error' }, + { status: 500 } + ) + } +} + +/** + * GET endpoint to view currently cached data (for debugging) + */ +export async function GET(request: NextRequest) { + try { + const marketCache = getMarketDataCache() + const availableSymbols = marketCache.getAvailableSymbols() + + const cacheData: Record = {} + + for (const symbol of availableSymbols) { + const data = marketCache.get(symbol) + if (data) { + const ageSeconds = marketCache.getDataAge(symbol) + cacheData[symbol] = { + ...data, + ageSeconds + } + } + } + + return NextResponse.json({ + success: true, + availableSymbols, + count: availableSymbols.length, + cache: cacheData + }) + + } catch (error) { + console.error('❌ Market data GET error:', error) + return NextResponse.json( + { error: 'Internal server error' }, + { status: 500 } + ) + } +} diff --git a/app/api/trading/test/route.ts b/app/api/trading/test/route.ts index 6e0c802..386d889 100644 --- a/app/api/trading/test/route.ts +++ b/app/api/trading/test/route.ts @@ -8,7 +8,7 @@ import { NextRequest, NextResponse } from 'next/server' import { initializeDriftService } from '@/lib/drift/client' import { openPosition, placeExitOrders } from '@/lib/drift/orders' -import { normalizeTradingViewSymbol, calculateDynamicTp2 } from '@/config/trading' +import { normalizeTradingViewSymbol } from '@/config/trading' import { getMergedConfig } from '@/config/trading' import { getInitializedPositionManager, ActiveTrade } from '@/lib/trading/position-manager' import { createTrade } from '@/lib/database/trades' @@ -96,13 +96,13 @@ export async function POST(request: NextRequest): Promise 0 ? actualPositionSizeUSD / entryPrice : 0) + const filledBaseSize = openResult.fillSize ?? (requestedPositionSizeUSD > 0 ? requestedPositionSizeUSD / entryPrice : 0) + const actualPositionSizeUSD = openResult.actualSizeUSD ?? (filledBaseSize * entryPrice) const fillCoverage = requestedPositionSizeUSD > 0 ? (actualPositionSizeUSD / requestedPositionSizeUSD) * 100 : 100 @@ -172,18 +170,9 @@ export async function POST(request: NextRequest): Promise= 55? → Execute trade +Score < 55? → Block (suggest --force) + ↓ +You: "long sol --force" → Override and execute +``` + +### 3. Performance Modifiers +- **-20 points**: Last 3 trades lost money (avgPnL < -5%) +- **+10 points**: Last 3 trades won (avgPnL > +5%, WR >= 66%) +- **-5 points**: Using stale data +- **-10 points**: No data available + +## 🚀 Setup Steps + +### Step 1: Deploy Updated Code +```bash +cd /home/icke/traderv4 + +# Build and restart +docker compose build trading-bot +docker compose up -d trading-bot + +# Restart Telegram bot +docker compose restart telegram-bot +``` + +### Step 2: Create TradingView Market Data Alerts + +For **each symbol** (SOL, ETH, BTC), create a separate alert: + +**Alert Name:** "Market Data - SOL 5min" + +**Condition:** +``` +ta.change(time("1")) +``` +(Fires every bar close on 1-5min chart) + +**Alert Message (JSON):** +```json +{ + "action": "market_data", + "symbol": "{{ticker}}", + "timeframe": "{{interval}}", + "atr": {{ta.atr(14)}}, + "adx": {{ta.dmi(14, 14)}}, + "rsi": {{ta.rsi(14)}}, + "volumeRatio": {{volume / ta.sma(volume, 20)}}, + "pricePosition": {{(close - ta.lowest(low, 100)) / (ta.highest(high, 100) - ta.lowest(low, 100)) * 100}}, + "currentPrice": {{close}} +} +``` + +**Webhook URL:** +``` +https://your-domain.com/api/trading/market-data +``` + +**Frequency:** Every 1-5 minutes (recommend 5min to save alert quota) + +**Repeat for:** SOL-PERP, ETH-PERP, BTC-PERP + +### Step 3: Test the System + +```bash +# Check if market data endpoint is accessible +curl http://localhost:3001/api/trading/market-data + +# Should return available symbols and cache data +``` + +### Step 4: Test via Telegram + +``` +You: "long sol" + +✅ Analytics check passed (68/100) +Data: tradingview_real (23s old) +Proceeding with LONG SOL... + +✅ OPENED LONG SOL +Entry: $162.45 +Size: $2100.00 @ 10x +TP1: $162.97 TP2: $163.59 SL: $160.00 +``` + +**Or if analytics blocks:** + +``` +You: "long sol" + +🛑 Analytics suggest NOT entering LONG SOL + +Reason: Recent long trades losing (-2.4% avg) +Score: 45/100 +Data: ✅ tradingview_real (23s old) + +Use `long sol --force` to override +``` + +**Override with --force:** + +``` +You: "long sol --force" + +⚠️ Skipping analytics check... + +✅ OPENED LONG SOL (FORCED) +Entry: $162.45 +... +``` + +## 📊 View Cached Data + +```bash +# Check what's in cache +curl http://localhost:3001/api/trading/market-data + +# Response shows: +{ + "success": true, + "availableSymbols": ["SOL-PERP", "ETH-PERP"], + "count": 2, + "cache": { + "SOL-PERP": { + "atr": 0.45, + "adx": 32.1, + "rsi": 58.3, + "ageSeconds": 23 + } + } +} +``` + +## 🔧 Configuration + +### Adjust Thresholds (if needed) + +Edit `app/api/analytics/reentry-check/route.ts`: + +```typescript +const MIN_REENTRY_SCORE = 55 // Lower = more permissive + +// Performance modifiers +if (last3Count >= 2 && avgPnL < -5) { + finalScore -= 20 // Penalty for losing streak +} + +if (last3Count >= 2 && avgPnL > 5 && winRate >= 66) { + finalScore += 10 // Bonus for winning streak +} +``` + +### Cache Expiry + +Edit `lib/trading/market-data-cache.ts`: + +```typescript +private readonly MAX_AGE_MS = 5 * 60 * 1000 // 5 minutes +``` + +## 🎯 Benefits + +✅ **Prevents revenge trading** - Blocks entry after consecutive losses +✅ **Uses real data** - Fresh TradingView metrics, not guessed +✅ **Data-driven** - Considers recent performance, not just current signal +✅ **Override capability** - `--force` flag for manual judgment +✅ **Fail-open** - If analytics fails, trade proceeds (not overly restrictive) +✅ **Transparent** - Shows data age and source in responses + +## 📈 Next Steps + +1. **Monitor effectiveness:** + - Track how many trades are blocked + - Compare win rate of allowed vs forced trades + - Adjust thresholds based on data + +2. **Add more symbols:** + - Create market data alerts for any new symbols + - System auto-adapts to new cache entries + +3. **Phase 2 (Future):** + - Time-based cooldown (no re-entry within 10min of exit) + - Trend reversal detection (check if price crossed MA) + - Volatility spike filter (ATR expansion = risky) + +## 🐛 Troubleshooting + +**No fresh data available:** +- Check TradingView alerts are firing +- Verify webhook URL is correct +- Check Docker logs: `docker logs -f trading-bot-v4` + +**Analytics check fails:** +- Trade proceeds anyway (fail-open design) +- Check logs for error details +- Verify Prisma database connection + +**--force always needed:** +- Lower MIN_REENTRY_SCORE threshold +- Check if TradingView alerts are updating cache +- Review penalty logic (may be too aggressive) + +## 📝 Files Created/Modified + +**New Files:** +- `lib/trading/market-data-cache.ts` - Cache service +- `app/api/trading/market-data/route.ts` - Webhook endpoint +- `app/api/analytics/reentry-check/route.ts` - Validation logic + +**Modified Files:** +- `app/api/trading/execute/route.ts` - Auto-cache metrics +- `telegram_command_bot.py` - Pre-execution analytics check +- `.github/copilot-instructions.md` - Documentation + +--- + +**Ready to use!** Send `long sol` in Telegram to test the system. diff --git a/lib/database/trades.ts b/lib/database/trades.ts index 2aa52e0..d139c5b 100644 --- a/lib/database/trades.ts +++ b/lib/database/trades.ts @@ -52,7 +52,6 @@ export interface CreateTradeParams { volumeAtEntry?: number pricePositionAtEntry?: number signalQualityScore?: number - signalQualityVersion?: string // Track which scoring logic version was used // Phantom trade fields status?: string isPhantom?: boolean @@ -76,7 +75,6 @@ export interface UpdateTradeStateParams { maxAdverseExcursion?: number maxFavorablePrice?: number maxAdversePrice?: number - runnerTrailingPercent?: number } export interface UpdateTradeExitParams { @@ -237,7 +235,6 @@ export async function updateTradeState(params: UpdateTradeStateParams) { maxAdverseExcursion: params.maxAdverseExcursion, maxFavorablePrice: params.maxFavorablePrice, maxAdversePrice: params.maxAdversePrice, - runnerTrailingPercent: params.runnerTrailingPercent, lastUpdate: new Date().toISOString(), } } diff --git a/lib/drift/orders.ts b/lib/drift/orders.ts index e0c739d..9965595 100644 --- a/lib/drift/orders.ts +++ b/lib/drift/orders.ts @@ -27,7 +27,6 @@ export interface OpenPositionResult { transactionSignature?: string fillPrice?: number fillSize?: number - fillNotionalUSD?: number slippage?: number error?: string isPhantom?: boolean // Position opened but size mismatch detected @@ -46,8 +45,6 @@ export interface ClosePositionResult { closePrice?: number closedSize?: number realizedPnL?: number - fullyClosed?: boolean - remainingSize?: number error?: string } @@ -127,7 +124,6 @@ export async function openPosition( transactionSignature: mockTxSig, fillPrice: oraclePrice, fillSize: baseAssetSize, - fillNotionalUSD: baseAssetSize * oraclePrice, slippage: 0, } } @@ -183,22 +179,19 @@ export async function openPosition( if (position && position.side !== 'none') { const fillPrice = position.entryPrice - const filledBaseSize = Math.abs(position.size) - const fillNotionalUSD = filledBaseSize * fillPrice const slippage = Math.abs((fillPrice - oraclePrice) / oraclePrice) * 100 // CRITICAL: Validate actual position size vs expected // Phantom trade detection: Check if position is significantly smaller than expected + const actualSizeUSD = position.size * fillPrice const expectedSizeUSD = params.sizeUSD - const sizeRatio = expectedSizeUSD > 0 ? fillNotionalUSD / expectedSizeUSD : 1 + const sizeRatio = actualSizeUSD / expectedSizeUSD console.log(`💰 Fill details:`) console.log(` Fill price: $${fillPrice.toFixed(4)}`) - console.log(` Filled base size: ${filledBaseSize.toFixed(4)} ${params.symbol.split('-')[0]}`) - console.log(` Filled notional: $${fillNotionalUSD.toFixed(2)}`) console.log(` Slippage: ${slippage.toFixed(3)}%`) console.log(` Expected size: $${expectedSizeUSD.toFixed(2)}`) - console.log(` Actual size: $${fillNotionalUSD.toFixed(2)}`) + console.log(` Actual size: $${actualSizeUSD.toFixed(2)}`) console.log(` Size ratio: ${(sizeRatio * 100).toFixed(1)}%`) // Flag as phantom if actual size is less than 50% of expected @@ -207,7 +200,7 @@ export async function openPosition( if (isPhantom) { console.error(`🚨 PHANTOM POSITION DETECTED!`) console.error(` Expected: $${expectedSizeUSD.toFixed(2)}`) - console.error(` Actual: $${fillNotionalUSD.toFixed(2)}`) + console.error(` Actual: $${actualSizeUSD.toFixed(2)}`) console.error(` This indicates the order was rejected or partially filled by Drift`) } @@ -215,11 +208,10 @@ export async function openPosition( success: true, transactionSignature: txSig, fillPrice, - fillSize: filledBaseSize, - fillNotionalUSD, + fillSize: position.size, // Use actual size from Drift, not calculated slippage, isPhantom, - actualSizeUSD: fillNotionalUSD, + actualSizeUSD, } } else { // Position not found yet (may be DRY_RUN mode) @@ -231,7 +223,6 @@ export async function openPosition( transactionSignature: txSig, fillPrice: oraclePrice, fillSize: baseAssetSize, - fillNotionalUSD: baseAssetSize * oraclePrice, slippage: 0, } } @@ -500,24 +491,19 @@ export async function closePosition( } // Calculate size to close - const sizeToClose = position.size * (params.percentToClose / 100) - const remainingSize = position.size - sizeToClose + let sizeToClose = position.size * (params.percentToClose / 100) - // CRITICAL: Check if remaining position would be below Drift minimum - // If so, Drift will force-close the entire position anyway - // Better to detect this upfront and return fullyClosed=true - const willForceFullClose = remainingSize > 0 && remainingSize < marketConfig.minOrderSize - - if (willForceFullClose && params.percentToClose < 100) { - console.log(`⚠️ WARNING: Remaining size ${remainingSize.toFixed(4)} would be below Drift minimum ${marketConfig.minOrderSize}`) - console.log(` Drift will force-close entire position. Proceeding with 100% close.`) - console.log(` 💡 TIP: Increase position size or decrease TP2 close % to enable runner`) + // CRITICAL FIX: If calculated size is below minimum, close 100% instead + // This prevents "runner" positions from being too small to close + if (sizeToClose < marketConfig.minOrderSize) { + console.log(`⚠️ Calculated close size ${sizeToClose.toFixed(4)} is below minimum ${marketConfig.minOrderSize}`) + console.log(` Forcing 100% close to avoid Drift rejection`) + sizeToClose = position.size // Close entire position } console.log(`📝 Close order details:`) console.log(` Current position: ${position.size.toFixed(4)} ${position.side}`) console.log(` Closing: ${params.percentToClose}% (${sizeToClose.toFixed(4)})`) - console.log(` Remaining after close: ${remainingSize.toFixed(4)}`) console.log(` Entry price: $${position.entryPrice.toFixed(4)}`) console.log(` Unrealized P&L: $${position.unrealizedPnL.toFixed(2)}`) @@ -532,18 +518,10 @@ export async function closePosition( console.log('🧪 DRY RUN MODE: Simulating close order (not executing on blockchain)') // Calculate realized P&L with leverage (default 10x in dry run) - // For LONG: profit when exit > entry → (exit - entry) / entry - // For SHORT: profit when exit < entry → (entry - exit) / entry - const priceDiff = position.side === 'long' - ? (oraclePrice - position.entryPrice) // Long: profit when price rises - : (position.entryPrice - oraclePrice) // Short: profit when price falls - - const profitPercent = (priceDiff / position.entryPrice) * 100 + const profitPercent = ((oraclePrice - position.entryPrice) / position.entryPrice) * 100 * (position.side === 'long' ? 1 : -1) const closedNotional = sizeToClose * oraclePrice - const leverage = 10 - const collateral = closedNotional / leverage - const realizedPnL = collateral * (profitPercent / 100) * leverage - const accountPnLPercent = profitPercent * leverage + const realizedPnL = (closedNotional * profitPercent) / 100 + const accountPnLPercent = profitPercent * 10 // display using default leverage const mockTxSig = `DRY_RUN_CLOSE_${Date.now()}_${Math.random().toString(36).substring(7)}` @@ -591,13 +569,8 @@ export async function closePosition( console.log('✅ Transaction confirmed on-chain') // Calculate realized P&L with leverage - // For LONG: profit when exit > entry → (exit - entry) / entry - // For SHORT: profit when exit < entry → (entry - exit) / entry - const priceDiff = position.side === 'long' - ? (oraclePrice - position.entryPrice) // Long: profit when price rises - : (position.entryPrice - oraclePrice) // Short: profit when price falls - - const profitPercent = (priceDiff / position.entryPrice) * 100 + // CRITICAL: P&L must account for leverage and be calculated on USD notional, not base asset size + const profitPercent = ((oraclePrice - position.entryPrice) / position.entryPrice) * 100 * (position.side === 'long' ? 1 : -1) // Get leverage from user account (defaults to 10x if not found) let leverage = 10 @@ -611,10 +584,9 @@ export async function closePosition( console.log('⚠️ Could not determine leverage from account, using 10x default') } - // Calculate closed notional value (USD) and actual P&L with leverage + // Calculate closed notional value (USD) const closedNotional = sizeToClose * oraclePrice - const collateral = closedNotional / leverage - const realizedPnL = collateral * (profitPercent / 100) * leverage // Leveraged P&L + const realizedPnL = (closedNotional * profitPercent) / 100 const accountPnLPercent = profitPercent * leverage console.log(`💰 Close details:`) @@ -623,21 +595,13 @@ export async function closePosition( console.log(` Closed notional: $${closedNotional.toFixed(2)}`) console.log(` Realized P&L: $${realizedPnL.toFixed(2)}`) - // Check remaining position size after close - const updatedPosition = await driftService.getPosition(marketConfig.driftMarketIndex) - const actualRemainingSize = updatedPosition ? Math.abs(updatedPosition.size) : 0 - const fullyClosed = !updatedPosition || actualRemainingSize === 0 || willForceFullClose - - if (fullyClosed) { + // If closing 100%, cancel all remaining orders for this market + if (params.percentToClose === 100) { console.log('🗑️ Position fully closed, cancelling remaining orders...') const cancelResult = await cancelAllOrders(params.symbol) - if (cancelResult.success && (cancelResult.cancelledCount || 0) > 0) { + if (cancelResult.success && cancelResult.cancelledCount! > 0) { console.log(`✅ Cancelled ${cancelResult.cancelledCount} orders`) } - } else if (params.percentToClose === 100) { - console.log( - `⚠️ Requested 100% close but ${actualRemainingSize.toFixed(4)} base remains on-chain` - ) } return { @@ -646,8 +610,6 @@ export async function closePosition( closePrice: oraclePrice, closedSize: sizeToClose, realizedPnL, - fullyClosed, - remainingSize: actualRemainingSize, } } catch (error) { diff --git a/lib/trading/market-data-cache.ts b/lib/trading/market-data-cache.ts new file mode 100644 index 0000000..134f0fc --- /dev/null +++ b/lib/trading/market-data-cache.ts @@ -0,0 +1,117 @@ +/** + * Market Data Cache Service + * + * Purpose: Stores real-time TradingView metrics for manual trade validation. + * Data flows: TradingView → /api/trading/market-data → Cache → Re-entry checks + * + * Cache expiry: 5 minutes (configurable) + */ + +export interface MarketMetrics { + symbol: string // "SOL-PERP", "ETH-PERP", "BTC-PERP" + atr: number // Average True Range (volatility %) + adx: number // Average Directional Index (trend strength) + rsi: number // Relative Strength Index (momentum) + volumeRatio: number // Current volume / average volume + pricePosition: number // Position in recent range (0-100%) + currentPrice: number // Latest close price + timestamp: number // Unix timestamp (ms) + timeframe: string // "5" for 5min, "60" for 1h, etc. +} + +class MarketDataCache { + private cache: Map = new Map() + private readonly MAX_AGE_MS = 5 * 60 * 1000 // 5 minutes + + /** + * Store fresh market data from TradingView + */ + set(symbol: string, metrics: MarketMetrics): void { + this.cache.set(symbol, metrics) + console.log( + `📊 Cached market data for ${symbol}: ` + + `ADX=${metrics.adx.toFixed(1)} ` + + `ATR=${metrics.atr.toFixed(2)}% ` + + `RSI=${metrics.rsi.toFixed(1)} ` + + `Vol=${metrics.volumeRatio.toFixed(2)}x` + ) + } + + /** + * Retrieve cached data if still fresh (<5min old) + * Returns null if stale or missing + */ + get(symbol: string): MarketMetrics | null { + const data = this.cache.get(symbol) + + if (!data) { + console.log(`⚠️ No cached data for ${symbol}`) + return null + } + + const ageSeconds = Math.round((Date.now() - data.timestamp) / 1000) + + if (Date.now() - data.timestamp > this.MAX_AGE_MS) { + console.log(`⏰ Cached data for ${symbol} is stale (${ageSeconds}s old, max 300s)`) + return null + } + + console.log(`✅ Using fresh TradingView data for ${symbol} (${ageSeconds}s old)`) + return data + } + + /** + * Check if fresh data exists without retrieving it + */ + has(symbol: string): boolean { + const data = this.cache.get(symbol) + if (!data) return false + + return Date.now() - data.timestamp <= this.MAX_AGE_MS + } + + /** + * Get all cached symbols with fresh data + */ + getAvailableSymbols(): string[] { + const now = Date.now() + const freshSymbols: string[] = [] + + for (const [symbol, data] of this.cache.entries()) { + if (now - data.timestamp <= this.MAX_AGE_MS) { + freshSymbols.push(symbol) + } + } + + return freshSymbols + } + + /** + * Get age of cached data in seconds (for debugging) + */ + getDataAge(symbol: string): number | null { + const data = this.cache.get(symbol) + if (!data) return null + + return Math.round((Date.now() - data.timestamp) / 1000) + } + + /** + * Clear all cached data (for testing) + */ + clear(): void { + this.cache.clear() + console.log('🗑️ Market data cache cleared') + } +} + +// Singleton instance +let marketDataCache: MarketDataCache | null = null + +export function getMarketDataCache(): MarketDataCache { + if (!marketDataCache) { + marketDataCache = new MarketDataCache() + console.log('🔧 Initialized Market Data Cache (5min expiry)') + } + return marketDataCache +} diff --git a/lib/trading/position-manager.ts b/lib/trading/position-manager.ts index 9c14624..b51e051 100644 --- a/lib/trading/position-manager.ts +++ b/lib/trading/position-manager.ts @@ -35,7 +35,6 @@ export interface ActiveTrade { slMovedToBreakeven: boolean slMovedToProfit: boolean trailingStopActive: boolean - runnerTrailingPercent?: number // Latest dynamic trailing percent applied // P&L tracking realizedPnL: number @@ -53,7 +52,6 @@ export interface ActiveTrade { originalAdx?: number // ADX at initial entry (for scaling validation) timesScaled?: number // How many times position has been scaled totalScaleAdded?: number // Total USD added through scaling - atrAtEntry?: number // ATR (absolute) when trade was opened // Monitoring priceCheckCount: number @@ -119,7 +117,6 @@ export class PositionManager { slMovedToBreakeven: pmState?.slMovedToBreakeven ?? false, slMovedToProfit: pmState?.slMovedToProfit ?? false, trailingStopActive: pmState?.trailingStopActive ?? false, - runnerTrailingPercent: pmState?.runnerTrailingPercent, realizedPnL: pmState?.realizedPnL ?? 0, unrealizedPnL: pmState?.unrealizedPnL ?? 0, peakPnL: pmState?.peakPnL ?? 0, @@ -128,7 +125,6 @@ export class PositionManager { maxAdverseExcursion: pmState?.maxAdverseExcursion ?? 0, maxFavorablePrice: pmState?.maxFavorablePrice ?? dbTrade.entryPrice, maxAdversePrice: pmState?.maxAdversePrice ?? dbTrade.entryPrice, - atrAtEntry: dbTrade.atrAtEntry ?? undefined, priceCheckCount: 0, lastPrice: pmState?.lastPrice ?? dbTrade.entryPrice, lastUpdateTime: Date.now(), @@ -136,12 +132,6 @@ export class PositionManager { this.activeTrades.set(activeTrade.id, activeTrade) console.log(`✅ Restored trade: ${activeTrade.symbol} ${activeTrade.direction} at $${activeTrade.entryPrice}`) - - // Consistency check: if TP1 hit but SL not moved to breakeven, fix it now - if (activeTrade.tp1Hit && !activeTrade.slMovedToBreakeven) { - console.log(`🔧 Detected inconsistent state: TP1 hit but SL not at breakeven. Fixing now...`) - await this.handlePostTp1Adjustments(activeTrade, 'recovery after restore') - } } if (this.activeTrades.size > 0) { @@ -213,22 +203,6 @@ export class PositionManager { return Array.from(this.activeTrades.values()) } - async reconcileTrade(symbol: string): Promise { - const trade = Array.from(this.activeTrades.values()).find(t => t.symbol === symbol) - if (!trade) { - return - } - - try { - const driftService = getDriftService() - const marketConfig = getMarketConfig(symbol) - const oraclePrice = await driftService.getOraclePrice(marketConfig.driftMarketIndex) - await this.checkTradeConditions(trade, oraclePrice) - } catch (error) { - console.error(`⚠️ Failed to reconcile trade for ${symbol}:`, error) - } - } - /** * Get specific trade */ @@ -359,7 +333,12 @@ export class PositionManager { trade.tp1Hit = true trade.currentSize = positionSizeUSD - await this.handlePostTp1Adjustments(trade, 'on-chain TP1 detection') + // Move SL to breakeven after TP1 + trade.stopLossPrice = trade.entryPrice + trade.slMovedToBreakeven = true + console.log(`🛡️ Stop loss moved to breakeven: $${trade.stopLossPrice.toFixed(4)}`) + + await this.saveTradeState(trade) } else if (trade.tp1Hit && !trade.tp2Hit && reductionPercent >= 85) { // TP2 fired (total should be ~95% closed, 5% runner left) @@ -367,22 +346,19 @@ export class PositionManager { trade.tp2Hit = true trade.currentSize = positionSizeUSD trade.trailingStopActive = true - trade.runnerTrailingPercent = this.getRunnerTrailingPercent(trade) - console.log( - `🏃 Runner active: $${positionSizeUSD.toFixed(2)} with trailing buffer ${trade.runnerTrailingPercent?.toFixed(3)}%` - ) + console.log(`🏃 Runner active: $${positionSizeUSD.toFixed(2)} with ${this.config.trailingStopPercent}% trailing stop`) await this.saveTradeState(trade) + // CRITICAL: Don't return early! Continue monitoring the runner position + // The trailing stop logic at line 732 needs to run + } else { // Partial fill detected but unclear which TP - just update size console.log(`⚠️ Unknown partial fill detected - updating tracked size to $${positionSizeUSD.toFixed(2)}`) trade.currentSize = positionSizeUSD await this.saveTradeState(trade) } - - // Continue monitoring the remaining position - return } // CRITICAL: Check for entry price mismatch (NEW position opened) @@ -404,10 +380,10 @@ export class PositionManager { trade.lastPrice, trade.direction ) - const accountPnL = profitPercent * trade.leverage - const estimatedPnL = (trade.currentSize * accountPnL) / 100 + const accountPnLPercent = profitPercent * trade.leverage + const estimatedPnL = (trade.currentSize * profitPercent) / 100 - console.log(`💰 Estimated P&L for lost trade: ${profitPercent.toFixed(2)}% price → ${accountPnL.toFixed(2)}% account → $${estimatedPnL.toFixed(2)} realized`) + console.log(`💰 Estimated P&L for lost trade: ${profitPercent.toFixed(2)}% price → ${accountPnLPercent.toFixed(2)}% account → $${estimatedPnL.toFixed(2)} realized`) try { await updateTradeExit({ @@ -466,41 +442,22 @@ export class PositionManager { // CRITICAL: Use trade state flags, not current price (on-chain orders filled in the past!) let exitReason: 'TP1' | 'TP2' | 'SL' | 'SOFT_SL' | 'HARD_SL' = 'SL' - // Calculate P&L first (set to 0 for phantom trades) - let realizedPnL = 0 - let exitPrice = currentPrice - + // Include any previously realized profit (e.g., from TP1 partial close) + const previouslyRealized = trade.realizedPnL + let runnerRealized = 0 + let runnerProfitPercent = 0 if (!wasPhantom) { - // For external closures, try to estimate a more realistic exit price - // Manual closures may happen at significantly different prices than current market - const unrealizedPnL = trade.unrealizedPnL || 0 - const positionSizeUSD = trade.positionSize - - if (Math.abs(unrealizedPnL) > 1 && positionSizeUSD > 0) { - // If we have meaningful unrealized P&L, back-calculate the likely exit price - // This is more accurate than using volatile current market price - const impliedProfitPercent = (unrealizedPnL / positionSizeUSD) * 100 / trade.leverage - exitPrice = trade.direction === 'long' - ? trade.entryPrice * (1 + impliedProfitPercent / 100) - : trade.entryPrice * (1 - impliedProfitPercent / 100) - - console.log(`📊 Estimated exit price based on unrealized P&L:`) - console.log(` Unrealized P&L: $${unrealizedPnL.toFixed(2)}`) - console.log(` Market price: $${currentPrice.toFixed(6)}`) - console.log(` Estimated exit: $${exitPrice.toFixed(6)}`) - - realizedPnL = unrealizedPnL - } else { - // Fallback to current price calculation - const profitPercent = this.calculateProfitPercent( - trade.entryPrice, - currentPrice, - trade.direction - ) - const accountPnL = profitPercent * trade.leverage - realizedPnL = (sizeForPnL * accountPnL) / 100 - } + runnerProfitPercent = this.calculateProfitPercent( + trade.entryPrice, + currentPrice, + trade.direction + ) + runnerRealized = (sizeForPnL * runnerProfitPercent) / 100 } + + const totalRealizedPnL = previouslyRealized + runnerRealized + trade.realizedPnL = totalRealizedPnL + console.log(` Realized P&L snapshot → Previous: $${previouslyRealized.toFixed(2)} | Runner: $${runnerRealized.toFixed(2)} (Δ${runnerProfitPercent.toFixed(2)}%) | Total: $${totalRealizedPnL.toFixed(2)}`) // Determine exit reason from trade state and P&L if (trade.tp2Hit) { @@ -509,14 +466,14 @@ export class PositionManager { } else if (trade.tp1Hit) { // TP1 was hit, position should be 25% size, but now fully closed // This means either TP2 filled or runner got stopped out - exitReason = realizedPnL > 0 ? 'TP2' : 'SL' + exitReason = totalRealizedPnL > 0 ? 'TP2' : 'SL' } else { // No TPs hit yet - either SL or TP1 filled just now // Use P&L to determine: positive = TP, negative = SL - if (realizedPnL > trade.positionSize * 0.005) { + if (totalRealizedPnL > trade.positionSize * 0.005) { // More than 0.5% profit - must be TP1 exitReason = 'TP1' - } else if (realizedPnL < 0) { + } else if (totalRealizedPnL < 0) { // Loss - must be SL exitReason = 'SL' } @@ -528,9 +485,9 @@ export class PositionManager { try { await updateTradeExit({ positionId: trade.positionId, - exitPrice: exitPrice, // Use estimated exit price, not current market price + exitPrice: currentPrice, exitReason, - realizedPnL, + realizedPnL: totalRealizedPnL, exitOrderTx: 'ON_CHAIN_ORDER', holdTimeSeconds, maxDrawdown: Math.abs(Math.min(0, trade.maxAdverseExcursion)), @@ -540,7 +497,7 @@ export class PositionManager { maxFavorablePrice: trade.maxFavorablePrice, maxAdversePrice: trade.maxAdversePrice, }) - console.log(`💾 External closure recorded: ${exitReason} at $${exitPrice.toFixed(6)} | P&L: $${realizedPnL.toFixed(2)}`) + console.log(`💾 External closure recorded: ${exitReason} at $${currentPrice} | P&L: $${totalRealizedPnL.toFixed(2)}`) } catch (dbError) { console.error('❌ Failed to save external closure:', dbError) } @@ -551,31 +508,15 @@ export class PositionManager { } // Position exists but size mismatch (partial close by TP1?) - const onChainBaseSize = Math.abs(position.size) - const onChainSizeUSD = onChainBaseSize * currentPrice - const trackedSizeUSD = trade.currentSize - - if (trackedSizeUSD > 0 && onChainSizeUSD < trackedSizeUSD * 0.95) { // 5% tolerance - const expectedBaseSize = trackedSizeUSD / currentPrice - console.log(`⚠️ Position size mismatch: tracking $${trackedSizeUSD.toFixed(2)} (~${expectedBaseSize.toFixed(4)} units) but on-chain shows $${onChainSizeUSD.toFixed(2)} (${onChainBaseSize.toFixed(4)} units)`) + if (position.size < trade.currentSize * 0.95) { // 5% tolerance + console.log(`⚠️ Position size mismatch: expected ${trade.currentSize}, got ${position.size}`) // CRITICAL: If mismatch is extreme (>50%), this is a phantom trade - const sizeRatio = trackedSizeUSD > 0 ? onChainSizeUSD / trackedSizeUSD : 0 + const sizeRatio = (position.size * currentPrice) / trade.currentSize if (sizeRatio < 0.5) { - const tradeAgeSeconds = (Date.now() - trade.entryTime) / 1000 - const probablyPartialRunner = trade.tp1Hit || tradeAgeSeconds > 60 - - if (probablyPartialRunner) { - console.log(`🛠️ Detected stray remainder (${(sizeRatio * 100).toFixed(1)}%) after on-chain exit - forcing market close`) - trade.currentSize = onChainSizeUSD - await this.saveTradeState(trade) - await this.executeExit(trade, 100, 'manual', currentPrice) - return - } - console.log(`🚨 EXTREME SIZE MISMATCH (${(sizeRatio * 100).toFixed(1)}%) - Closing phantom trade`) - console.log(` Expected: $${trackedSizeUSD.toFixed(2)}`) - console.log(` Actual: $${onChainSizeUSD.toFixed(2)}`) + console.log(` Expected: $${trade.currentSize.toFixed(2)}`) + console.log(` Actual: $${(position.size * currentPrice).toFixed(2)}`) // Close as phantom trade try { @@ -603,15 +544,10 @@ export class PositionManager { return } - // Update current size to match reality and run TP1 adjustments if needed - trade.currentSize = onChainSizeUSD - if (!trade.tp1Hit) { - trade.tp1Hit = true - await this.handlePostTp1Adjustments(trade, 'on-chain TP1 size sync') - } else { - await this.saveTradeState(trade) - } - return + // Update current size to match reality (convert base asset size to USD using current price) + trade.currentSize = position.size * currentPrice + trade.tp1Hit = true + await this.saveTradeState(trade) } } catch (error) { @@ -636,8 +572,8 @@ export class PositionManager { trade.direction ) - const accountPnL = profitPercent * trade.leverage - trade.unrealizedPnL = (trade.currentSize * accountPnL) / 100 + const accountPnL = profitPercent * trade.leverage + trade.unrealizedPnL = (trade.currentSize * profitPercent) / 100 // Track peak P&L (MFE - Maximum Favorable Excursion) if (trade.unrealizedPnL > trade.peakPnL) { @@ -702,7 +638,56 @@ export class PositionManager { // Move SL based on breakEvenTriggerPercent setting trade.tp1Hit = true trade.currentSize = trade.positionSize * ((100 - this.config.takeProfit1SizePercent) / 100) - await this.handlePostTp1Adjustments(trade, 'software TP1 execution') + const newStopLossPrice = this.calculatePrice( + trade.entryPrice, + this.config.breakEvenTriggerPercent, // Use configured breakeven level + trade.direction + ) + trade.stopLossPrice = newStopLossPrice + trade.slMovedToBreakeven = true + + console.log(`🔒 SL moved to +${this.config.breakEvenTriggerPercent}% (${this.config.takeProfit1SizePercent}% closed, ${100 - this.config.takeProfit1SizePercent}% remaining): ${newStopLossPrice.toFixed(4)}`) + + // CRITICAL: Cancel old on-chain SL orders and place new ones at updated price + try { + console.log('🗑️ Cancelling old stop loss orders...') + const { cancelAllOrders, placeExitOrders } = await import('../drift/orders') + const cancelResult = await cancelAllOrders(trade.symbol) + if (cancelResult.success) { + console.log(`✅ Cancelled ${cancelResult.cancelledCount || 0} old orders`) + + // Place new SL orders at breakeven/profit level for remaining position + console.log(`🛡️ Placing new SL orders at $${newStopLossPrice.toFixed(4)} for remaining position...`) + const exitOrdersResult = await placeExitOrders({ + symbol: trade.symbol, + positionSizeUSD: trade.currentSize, + entryPrice: trade.entryPrice, + tp1Price: trade.tp2Price, // Only TP2 remains + tp2Price: trade.tp2Price, // Dummy, won't be used + stopLossPrice: newStopLossPrice, + tp1SizePercent: 100, // Close remaining 25% at TP2 + tp2SizePercent: 0, + direction: trade.direction, + useDualStops: this.config.useDualStops, + softStopPrice: trade.direction === 'long' + ? newStopLossPrice * 1.005 // 0.5% above for long + : newStopLossPrice * 0.995, // 0.5% below for short + hardStopPrice: newStopLossPrice, + }) + + if (exitOrdersResult.success) { + console.log('✅ New SL orders placed on-chain at updated price') + } else { + console.error('❌ Failed to place new SL orders:', exitOrdersResult.error) + } + } + } catch (error) { + console.error('❌ Failed to update on-chain SL orders:', error) + // Don't fail the TP1 exit if SL update fails - software monitoring will handle it + } + + // Save state after TP1 + await this.saveTradeState(trade) return } @@ -727,39 +712,42 @@ export class PositionManager { await this.saveTradeState(trade) } - // 5. TP2 Hit - Activate runner (no close, just start trailing) + // 5. Take profit 2 (remaining position) if (trade.tp1Hit && !trade.tp2Hit && this.shouldTakeProfit2(currentPrice, trade)) { - console.log(`🎊 TP2 HIT: ${trade.symbol} at ${profitPercent.toFixed(2)}% - Activating 25% runner!`) + console.log(`🎊 TP2 HIT: ${trade.symbol} at ${profitPercent.toFixed(2)}%`) - // Mark TP2 as hit and activate trailing stop on full remaining 25% - trade.tp2Hit = true - trade.peakPrice = currentPrice - trade.runnerTrailingPercent = this.getRunnerTrailingPercent(trade) + // Calculate how much to close based on TP2 size percent + const percentToClose = this.config.takeProfit2SizePercent - console.log( - `🏃 Runner activated on full remaining position: ${((trade.currentSize / trade.positionSize) * 100).toFixed(1)}% | trailing buffer ${trade.runnerTrailingPercent?.toFixed(3)}%` - ) + await this.executeExit(trade, percentToClose, 'TP2', currentPrice) - // Save state after TP2 activation - await this.saveTradeState(trade) + // If some position remains, mark TP2 as hit and activate trailing stop + if (percentToClose < 100) { + trade.tp2Hit = true + trade.currentSize = trade.currentSize * ((100 - percentToClose) / 100) + + console.log(`🏃 Runner activated: ${((trade.currentSize / trade.positionSize) * 100).toFixed(1)}% remaining with trailing stop`) + + // Save state after TP2 + await this.saveTradeState(trade) + } return - } // 6. Trailing stop for runner (after TP2 activation) + } + + // 6. Trailing stop for runner (after TP2) if (trade.tp2Hit && this.config.useTrailingStop) { // Check if trailing stop should be activated if (!trade.trailingStopActive && profitPercent >= this.config.trailingStopActivation) { trade.trailingStopActive = true - trade.runnerTrailingPercent = this.getRunnerTrailingPercent(trade) console.log(`🎯 Trailing stop activated at +${profitPercent.toFixed(2)}%`) } // If trailing stop is active, adjust SL dynamically if (trade.trailingStopActive) { - const trailingPercent = this.getRunnerTrailingPercent(trade) - trade.runnerTrailingPercent = trailingPercent const trailingStopPrice = this.calculatePrice( trade.peakPrice, - -trailingPercent, // Trail below peak + -this.config.trailingStopPercent, // Trail below peak trade.direction ) @@ -772,7 +760,7 @@ export class PositionManager { const oldSL = trade.stopLossPrice trade.stopLossPrice = trailingStopPrice - console.log(`📈 Trailing SL updated: ${oldSL.toFixed(4)} → ${trailingStopPrice.toFixed(4)} (${trailingPercent.toFixed(3)}% below peak $${trade.peakPrice.toFixed(4)})`) + console.log(`📈 Trailing SL updated: ${oldSL.toFixed(4)} → ${trailingStopPrice.toFixed(4)} (${this.config.trailingStopPercent}% below peak $${trade.peakPrice.toFixed(4)})`) // Save state after trailing SL update (every 10 updates to avoid spam) if (trade.priceCheckCount % 10 === 0) { @@ -813,35 +801,18 @@ export class PositionManager { return } - const closePriceForCalc = result.closePrice || currentPrice - const closedSizeBase = result.closedSize || 0 - const closedUSD = closedSizeBase * closePriceForCalc - const wasForcedFullClose = !!result.fullyClosed && percentToClose < 100 - const treatAsFullClose = percentToClose >= 100 || result.fullyClosed - - // Calculate actual P&L based on entry vs exit price - const profitPercent = this.calculateProfitPercent(trade.entryPrice, closePriceForCalc, trade.direction) - const actualRealizedPnL = (closedUSD * profitPercent) / 100 - // Update trade state - if (treatAsFullClose) { - trade.realizedPnL += actualRealizedPnL - trade.currentSize = 0 - trade.trailingStopActive = false - - if (reason === 'TP2') { - trade.tp2Hit = true - } - if (reason === 'TP1') { - trade.tp1Hit = true - } - + if (percentToClose >= 100) { + // Full close - remove from monitoring + trade.realizedPnL += result.realizedPnL || 0 + + // Save to database (only for valid exit reasons) if (reason !== 'error') { try { const holdTimeSeconds = Math.floor((Date.now() - trade.entryTime) / 1000) await updateTradeExit({ positionId: trade.positionId, - exitPrice: closePriceForCalc, + exitPrice: result.closePrice || currentPrice, exitReason: reason as 'TP1' | 'TP2' | 'SL' | 'SOFT_SL' | 'HARD_SL' | 'manual' | 'emergency', realizedPnL: trade.realizedPnL, exitOrderTx: result.transactionSignature || 'MANUAL_CLOSE', @@ -856,23 +827,25 @@ export class PositionManager { console.log('💾 Trade saved to database') } catch (dbError) { console.error('❌ Failed to save trade exit to database:', dbError) + // Don't fail the close if database fails } } - + await this.removeTrade(trade.id) - const closeLabel = wasForcedFullClose - ? '✅ Forced full close (below Drift minimum)' - : '✅ Position closed' - console.log(`${closeLabel} | P&L: $${trade.realizedPnL.toFixed(2)} | Reason: ${reason}`) + console.log(`✅ Position closed | P&L: $${trade.realizedPnL.toFixed(2)} | Reason: ${reason}`) } else { - // Partial close (TP1) - calculate P&L for partial amount - const partialRealizedPnL = (closedUSD * profitPercent) / 100 - trade.realizedPnL += partialRealizedPnL + // Partial close (TP1) + trade.realizedPnL += result.realizedPnL || 0 + // result.closedSize is returned in base asset units (e.g., SOL), convert to USD using closePrice + const closePriceForCalc = result.closePrice || currentPrice + const closedSizeBase = result.closedSize || 0 + const closedUSD = closedSizeBase * closePriceForCalc trade.currentSize = Math.max(0, trade.currentSize - closedUSD) - console.log( - `✅ Partial close executed | Realized: $${partialRealizedPnL.toFixed(2)} | Closed (base): ${closedSizeBase.toFixed(6)} | Closed (USD): $${closedUSD.toFixed(2)} | Remaining USD: $${trade.currentSize.toFixed(2)}` - ) + console.log(`✅ Partial close executed | Realized: $${(result.realizedPnL || 0).toFixed(2)} | Closed (base): ${closedSizeBase.toFixed(6)} | Closed (USD): $${closedUSD.toFixed(2)} | Remaining USD: $${trade.currentSize.toFixed(2)}`) + + // Persist updated trade state so analytics reflect partial profits immediately + await this.saveTradeState(trade) } // TODO: Send notification @@ -962,131 +935,6 @@ export class PositionManager { console.log('✅ All positions closed') } - refreshConfig(): void { - this.config = getMergedConfig() - console.log('⚙️ Position manager config refreshed from environment') - } - - private getRunnerTrailingPercent(trade: ActiveTrade): number { - const fallbackPercent = this.config.trailingStopPercent - const atrValue = trade.atrAtEntry ?? 0 - const entryPrice = trade.entryPrice - - if (atrValue <= 0 || entryPrice <= 0 || !Number.isFinite(entryPrice)) { - return fallbackPercent - } - - const atrPercentOfPrice = (atrValue / entryPrice) * 100 - if (!Number.isFinite(atrPercentOfPrice) || atrPercentOfPrice <= 0) { - return fallbackPercent - } - - const rawPercent = atrPercentOfPrice * this.config.trailingStopAtrMultiplier - const boundedPercent = Math.min( - this.config.trailingStopMaxPercent, - Math.max(this.config.trailingStopMinPercent, rawPercent) - ) - - return boundedPercent > 0 ? boundedPercent : fallbackPercent - } - - private async handlePostTp1Adjustments(trade: ActiveTrade, context: string): Promise { - if (trade.currentSize <= 0) { - console.log(`⚠️ Skipping TP1 adjustments for ${trade.symbol} (${context}) because current size is $${trade.currentSize.toFixed(2)}`) - await this.saveTradeState(trade) - return - } - - const newStopLossPrice = this.calculatePrice( - trade.entryPrice, - this.config.breakEvenTriggerPercent, - trade.direction - ) - - trade.stopLossPrice = newStopLossPrice - trade.slMovedToBreakeven = true - - console.log(`🔒 (${context}) SL moved to +${this.config.breakEvenTriggerPercent}% (${this.config.takeProfit1SizePercent}% closed, ${100 - this.config.takeProfit1SizePercent}% remaining): ${newStopLossPrice.toFixed(4)}`) - - await this.refreshExitOrders(trade, { - stopLossPrice: newStopLossPrice, - tp1Price: trade.tp2Price, - tp1SizePercent: 100, - tp2Price: trade.tp2Price, - tp2SizePercent: 0, - context, - }) - - await this.saveTradeState(trade) - } - - private async refreshExitOrders( - trade: ActiveTrade, - options: { - stopLossPrice: number - tp1Price: number - tp1SizePercent: number - tp2Price?: number - tp2SizePercent?: number - context: string - } - ): Promise { - if (trade.currentSize <= 0) { - console.log(`⚠️ Skipping exit order refresh for ${trade.symbol} (${options.context}) because tracked size is zero`) - return - } - - try { - console.log(`🗑️ (${options.context}) Cancelling existing exit orders before refresh...`) - const { cancelAllOrders, placeExitOrders } = await import('../drift/orders') - const cancelResult = await cancelAllOrders(trade.symbol) - if (cancelResult.success) { - console.log(`✅ (${options.context}) Cancelled ${cancelResult.cancelledCount || 0} old orders`) - } else { - console.warn(`⚠️ (${options.context}) Failed to cancel old orders: ${cancelResult.error}`) - } - - const tp2Price = options.tp2Price ?? options.tp1Price - const tp2SizePercent = options.tp2SizePercent ?? 0 - - const refreshParams: any = { - symbol: trade.symbol, - positionSizeUSD: trade.currentSize, - entryPrice: trade.entryPrice, - tp1Price: options.tp1Price, - tp2Price, - stopLossPrice: options.stopLossPrice, - tp1SizePercent: options.tp1SizePercent, - tp2SizePercent, - direction: trade.direction, - useDualStops: this.config.useDualStops, - } - - if (this.config.useDualStops) { - const softStopBuffer = this.config.softStopBuffer ?? 0.4 - const softStopPrice = trade.direction === 'long' - ? options.stopLossPrice * (1 + softStopBuffer / 100) - : options.stopLossPrice * (1 - softStopBuffer / 100) - - refreshParams.softStopPrice = softStopPrice - refreshParams.softStopBuffer = softStopBuffer - refreshParams.hardStopPrice = options.stopLossPrice - } - - console.log(`🛡️ (${options.context}) Placing refreshed exit orders: size=$${trade.currentSize.toFixed(2)} SL=${options.stopLossPrice.toFixed(4)} TP=${options.tp1Price.toFixed(4)}`) - const exitOrdersResult = await placeExitOrders(refreshParams) - - if (exitOrdersResult.success) { - console.log(`✅ (${options.context}) Exit orders refreshed on-chain`) - } else { - console.error(`❌ (${options.context}) Failed to place refreshed exit orders: ${exitOrdersResult.error}`) - } - } catch (error) { - console.error(`❌ (${options.context}) Error refreshing exit orders:`, error) - // Monitoring loop will still enforce SL logic even if on-chain refresh fails - } - } - /** * Save trade state to database (for persistence across restarts) */ @@ -1103,7 +951,6 @@ export class PositionManager { unrealizedPnL: trade.unrealizedPnL, peakPnL: trade.peakPnL, lastPrice: trade.lastPrice, - runnerTrailingPercent: trade.runnerTrailingPercent, }) } catch (error) { console.error('❌ Failed to save trade state:', error) @@ -1111,6 +958,14 @@ export class PositionManager { } } + /** + * Reload configuration from merged sources (used after settings updates) + */ + refreshConfig(partial?: Partial): void { + this.config = getMergedConfig(partial) + console.log('🔄 Position Manager config refreshed') + } + /** * Get monitoring status */ diff --git a/lib/trading/signal-quality.ts b/lib/trading/signal-quality.ts index 468d999..8093e81 100644 --- a/lib/trading/signal-quality.ts +++ b/lib/trading/signal-quality.ts @@ -14,18 +14,14 @@ export interface SignalQualityResult { /** * Calculate signal quality score based on technical indicators * - * TIMEFRAME-AWARE SCORING: - * 5min charts naturally have lower ADX/ATR than higher timeframes - * * Scoring breakdown: * - Base: 50 points - * - ATR (volatility): -20 to +10 points (5min: 0.25-0.7% is healthy) - * - ADX (trend strength): -15 to +15 points (5min: 15+ is trending) + * - 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 - * - Anti-chop filter: -20 points (5min only, extreme chop) * * Total range: ~15-115 points (realistically 30-100) * Threshold: 60 points minimum for execution @@ -38,92 +34,38 @@ export function scoreSignalQuality(params: { pricePosition: number direction: 'long' | 'short' minScore?: number // Configurable minimum score threshold - timeframe?: string // e.g., '5', '15', '60', '1D' }): SignalQualityResult { let score = 50 // Base score const reasons: string[] = [] - - // Detect 5-minute timeframe - const is5min = params.timeframe === '5' || params.timeframe === 'manual' - // ATR check - TIMEFRAME AWARE + // ATR check (volatility gate: 0.15% - 2.5%) if (params.atr > 0) { - if (is5min) { - // 5min: lower thresholds, more lenient - if (params.atr < 0.2) { - score -= 15 - reasons.push(`ATR too low (${params.atr.toFixed(2)}% - dead market)`) - } else if (params.atr > 1.5) { - score -= 20 - reasons.push(`ATR too high (${params.atr.toFixed(2)}% - too volatile)`) - } else if (params.atr >= 0.2 && params.atr < 0.35) { - score += 5 - reasons.push(`ATR acceptable (${params.atr.toFixed(2)}%)`) - } else { - score += 10 - reasons.push(`ATR healthy (${params.atr.toFixed(2)}%)`) - } + 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 { - // Higher timeframes: stricter requirements - 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)}%)`) - } + score += 10 + reasons.push(`ATR healthy (${params.atr.toFixed(2)}%)`) } } - // ADX check - TIMEFRAME AWARE + // ADX check (trend strength: want >18) if (params.adx > 0) { - if (is5min) { - // 5min: ADX 15+ is actually trending, 20+ is strong - // High volume can compensate for lower ADX in breakouts/breakdowns - const hasStrongVolume = params.volumeRatio > 1.2 - - if (params.adx > 22) { - score += 15 - reasons.push(`Strong 5min trend (ADX ${params.adx.toFixed(1)})`) - } else if (params.adx < 12) { - // Reduce penalty if strong volume present (breakdown/breakout in progress) - if (hasStrongVolume) { - score -= 5 - reasons.push(`Lower 5min ADX (${params.adx.toFixed(1)}) but strong volume compensates`) - } else { - score -= 15 - reasons.push(`Weak 5min trend (ADX ${params.adx.toFixed(1)})`) - } - } else { - score += 5 - reasons.push(`Moderate 5min trend (ADX ${params.adx.toFixed(1)})`) - } + 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 { - // Higher timeframes: stricter ADX requirements - const hasStrongVolume = params.volumeRatio > 1.2 - - if (params.adx > 25) { - score += 15 - reasons.push(`Strong trend (ADX ${params.adx.toFixed(1)})`) - } else if (params.adx < 18) { - // Reduce penalty if strong volume present - if (hasStrongVolume) { - score -= 5 - reasons.push(`Lower ADX (${params.adx.toFixed(1)}) but strong volume compensates`) - } else { - score -= 15 - reasons.push(`Weak trend (ADX ${params.adx.toFixed(1)})`) - } - } else { - score += 5 - reasons.push(`Moderate trend (ADX ${params.adx.toFixed(1)})`) - } + score += 5 + reasons.push(`Moderate trend (ADX ${params.adx.toFixed(1)})`) } } @@ -162,7 +104,7 @@ export function scoreSignalQuality(params: { } } - // Price position check (avoid chasing vs breakout/breakdown detection) + // 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 @@ -173,35 +115,14 @@ export function scoreSignalQuality(params: { score -= 15 reasons.push(`Price near top of range (${params.pricePosition.toFixed(0)}%) - risky long`) } - } else if (params.direction === 'short' && params.pricePosition < 15) { - // Shorts near range bottom (< 15%) require strong confirmation - // Require STRONG trend (ADX > 18) to avoid false breakdowns in choppy ranges - // OR very bearish RSI (< 35) indicating strong momentum continuation - const hasStrongTrend = params.adx > 18 - const isVeryBearish = params.rsi > 0 && params.rsi < 35 - const hasGoodVolume = params.volumeRatio > 1.2 - - if ((hasGoodVolume && hasStrongTrend) || isVeryBearish) { + } 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(`Valid breakdown at range bottom (${params.pricePosition.toFixed(0)}%, vol ${params.volumeRatio.toFixed(2)}x, ADX ${params.adx.toFixed(1)}, RSI ${params.rsi.toFixed(1)})`) + 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 (${params.pricePosition.toFixed(0)}%) - need ADX > 18 or RSI < 35 for breakdown`) - } - } else if (params.direction === 'long' && params.pricePosition < 15) { - // Longs near range bottom (< 15%) require strong reversal confirmation - // Require STRONG trend (ADX > 18) to avoid catching falling knives - // OR very bullish RSI (> 60) after bounce showing momentum shift - const hasStrongTrend = params.adx > 18 - const isVeryBullish = params.rsi > 0 && params.rsi > 60 - const hasGoodVolume = params.volumeRatio > 1.2 - - if ((hasGoodVolume && hasStrongTrend) || isVeryBullish) { - score += 5 - reasons.push(`Potential reversal at bottom (${params.pricePosition.toFixed(0)}%, vol ${params.volumeRatio.toFixed(2)}x, ADX ${params.adx.toFixed(1)}, RSI ${params.rsi.toFixed(1)})`) - } else { - score -= 15 - reasons.push(`Price near bottom (${params.pricePosition.toFixed(0)}%) - need ADX > 18 or RSI > 60 for reversal`) + reasons.push(`Price near bottom of range (${params.pricePosition.toFixed(0)}%) - risky short`) } } else { score += 5 @@ -214,12 +135,6 @@ export function scoreSignalQuality(params: { score += 10 reasons.push(`Volume breakout compensates for low ATR`) } - - // ANTI-CHOP FILTER for 5min (extreme penalty for sideways chop) - if (is5min && params.adx < 10 && params.atr < 0.25 && params.volumeRatio < 0.9) { - score -= 20 - reasons.push(`⛔ Extreme chop detected (ADX ${params.adx.toFixed(1)}, ATR ${params.atr.toFixed(2)}%, Vol ${params.volumeRatio.toFixed(2)}x)`) - } const minScore = params.minScore || 60 const passed = score >= minScore diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 4f3cbc5..4802665 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -102,12 +102,6 @@ model Trade { signalStrength String? // "strong", "moderate", "weak" timeframe String? // "5", "15", "60" - // Signal quality logic version tracking - signalQualityVersion String? @default("v1") // Track which scoring logic was used - // v1: Original logic with price position < 5% threshold - // v2: Added volume compensation for low ADX (2025-11-07) - // v3: Stricter - price position < 15% requires ADX > 18 (2025-11-07) - // Status status String @default("open") // "open", "closed", "failed", "phantom" isTestTrade Boolean @default(false) // Flag test trades for exclusion from analytics diff --git a/telegram_command_bot.py b/telegram_command_bot.py index 279067f..b06a1dc 100644 --- a/telegram_command_bot.py +++ b/telegram_command_bot.py @@ -530,7 +530,7 @@ async def trade_command(update: Update, context: ContextTypes.DEFAULT_TYPE): async def manual_trade_handler(update: Update, context: ContextTypes.DEFAULT_TYPE): - """Execute manual long/short commands sent as plain text.""" + """Execute manual long/short commands sent as plain text with analytics validation.""" if update.message is None: return @@ -541,6 +541,12 @@ async def manual_trade_handler(update: Update, context: ContextTypes.DEFAULT_TYP text = update.message.text.strip().lower() parts = text.split() + + # Check for --force flag + force_trade = '--force' in parts + if force_trade: + parts.remove('--force') + if len(parts) != 2: return @@ -553,6 +559,76 @@ async def manual_trade_handler(update: Update, context: ContextTypes.DEFAULT_TYP if not symbol_info: return + # Convert to Drift format for analytics check + drift_symbol_map = { + 'sol': 'SOL-PERP', + 'eth': 'ETH-PERP', + 'btc': 'BTC-PERP' + } + drift_symbol = drift_symbol_map.get(symbol_key) + + # 🆕 PHASE 1: Check analytics before executing (unless forced) + if not force_trade: + try: + print(f"🔍 Checking re-entry analytics for {direction.upper()} {drift_symbol}", flush=True) + + analytics_response = requests.post( + f"{TRADING_BOT_URL}/api/analytics/reentry-check", + json={'symbol': drift_symbol, 'direction': direction}, + timeout=10 + ) + + if analytics_response.ok: + analytics = analytics_response.json() + + if not analytics.get('should_enter'): + # Build rejection message with data source info + data_source = analytics.get('data_source', 'unknown') + data_age = analytics.get('data_age_seconds') + + data_emoji = { + 'tradingview_real': '✅', + 'fallback_historical': '⚠️', + 'no_data': '❌' + } + data_icon = data_emoji.get(data_source, '❓') + + data_age_text = f" ({data_age}s old)" if data_age else "" + + message = ( + f"🛑 *Analytics suggest NOT entering {direction.upper()} {symbol_info['label']}*\n\n" + f"*Reason:* {analytics.get('reason', 'Unknown')}\n" + f"*Score:* {analytics.get('score', 0)}/100\n" + f"*Data:* {data_icon} {data_source}{data_age_text}\n\n" + f"Use `{text} --force` to override" + ) + + await update.message.reply_text(message, parse_mode='Markdown') + print(f"❌ Trade blocked by analytics (score: {analytics.get('score')})", flush=True) + return + + # Analytics passed - show confirmation + data_age = analytics.get('data_age_seconds') + data_source = analytics.get('data_source', 'unknown') + data_age_text = f" ({data_age}s old)" if data_age else "" + + confirm_message = ( + f"✅ *Analytics check passed ({analytics.get('score')}/100)*\n" + f"Data: {data_source}{data_age_text}\n" + f"Proceeding with {direction.upper()} {symbol_info['label']}..." + ) + + await update.message.reply_text(confirm_message, parse_mode='Markdown') + print(f"✅ Analytics passed (score: {analytics.get('score')})", flush=True) + else: + # Analytics endpoint failed - proceed with trade (fail-open) + print(f"⚠️ Analytics check failed ({analytics_response.status_code}) - proceeding anyway", flush=True) + + except Exception as analytics_error: + # Analytics check error - proceed with trade (fail-open) + print(f"⚠️ Analytics error: {analytics_error} - proceeding anyway", flush=True) + + # Execute the trade metrics = MANUAL_METRICS[direction] payload = { @@ -568,7 +644,7 @@ async def manual_trade_handler(update: Update, context: ContextTypes.DEFAULT_TYP } try: - print(f"🚀 Manual trade: {direction.upper()} {symbol_info['label']}", flush=True) + print(f"🚀 Manual trade: {direction.upper()} {symbol_info['label']}{' (FORCED)' if force_trade else ''}", flush=True) response = requests.post( f"{TRADING_BOT_URL}/api/trading/execute", @@ -609,8 +685,10 @@ async def manual_trade_handler(update: Update, context: ContextTypes.DEFAULT_TYP tp2_text = f"${tp2:.4f}" if tp2 is not None else 'n/a' sl_text = f"${sl:.4f}" if sl is not None else 'n/a' + force_indicator = " (FORCED)" if force_trade else "" + success_message = ( - f"✅ OPENED {direction.upper()} {symbol_info['label']}\n" + f"✅ OPENED {direction.upper()} {symbol_info['label']}{force_indicator}\n" f"Entry: {entry_text}\n" f"Size: {size_text}\n" f"TP1: {tp1_text}\nTP2: {tp2_text}\nSL: {sl_text}"