From 056440bf8fda766de710cf767a9e35f796f74bde Mon Sep 17 00:00:00 2001 From: mindesbunister Date: Sat, 1 Nov 2025 17:00:37 +0100 Subject: [PATCH] feat: add quality score display and timezone fixes - Add qualityScore to ExecuteTradeResponse interface and response object - Update analytics page to always show Signal Quality card (N/A if unavailable) - Fix n8n workflow to pass context metrics and qualityScore to execute endpoint - Fix timezone in Telegram notifications (Europe/Berlin) - Fix symbol normalization in /api/trading/close endpoint - Update Drift ETH-PERP minimum order size (0.002 ETH not 0.01) - Add transaction confirmation to closePosition() to prevent phantom closes - Add 30-second grace period for new trades in Position Manager - Fix execution order: database save before Position Manager.addTrade() - Update copilot instructions with transaction confirmation pattern --- .github/copilot-instructions.md | 41 ++++++++++++++++++++++++++-- app/analytics/page.tsx | 39 +++++++++++++------------- app/api/trading/close/route.ts | 11 +++++--- app/api/trading/execute/route.ts | 14 ++++++---- app/api/trading/test/route.ts | 14 +++++----- config/trading.ts | 8 +++--- lib/drift/orders.ts | 14 ++++++++-- lib/trading/position-manager.ts | 10 +++++++ workflows/trading/Money_Machine.json | 8 +++--- 9 files changed, 110 insertions(+), 49 deletions(-) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 647a50c..2ceda08 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -34,6 +34,7 @@ await positionManager.addTrade(activeTrade) - Closes positions via `closePosition()` market orders when targets hit - Acts as backup if on-chain orders don't fill - State persistence: Saves to database, restores on restart via `configSnapshot.positionManagerState` +- **Grace period for new trades:** Skips "external closure" detection for positions <30 seconds old (Drift positions take 5-10s to propagate) ### 2. Drift Client (`lib/drift/client.ts`) **Purpose:** Solana/Drift Protocol SDK wrapper for order execution @@ -47,7 +48,25 @@ const health = await driftService.getAccountHealth() **Wallet handling:** Supports both JSON array `[91,24,...]` and base58 string formats from Phantom wallet ### 3. Order Placement (`lib/drift/orders.ts`) -**Critical function:** `placeExitOrders()` - places TP/SL orders on-chain +**Critical functions:** +- `openPosition()` - Opens market position with transaction confirmation +- `closePosition()` - Closes position with transaction confirmation +- `placeExitOrders()` - Places TP/SL orders on-chain + +**CRITICAL: Transaction Confirmation Pattern** +Both `openPosition()` and `closePosition()` MUST confirm transactions on-chain: +```typescript +const txSig = await driftClient.placePerpOrder(orderParams) +console.log('ā³ Confirming transaction on-chain...') +const connection = driftService.getConnection() +const confirmation = await connection.confirmTransaction(txSig, 'confirmed') + +if (confirmation.value.err) { + throw new Error(`Transaction failed: ${JSON.stringify(confirmation.value.err)}`) +} +console.log('āœ… Transaction confirmed on-chain') +``` +Without this, the SDK returns signatures for transactions that never execute, causing phantom trades/closes. **Dual Stop System** (USE_DUAL_STOPS=true): ```typescript @@ -253,7 +272,7 @@ docker exec trading-bot-postgres psql -U postgres -d trading_bot_v4 -c "\dt" 2. **Wrong DATABASE_URL:** Container runtime needs `trading-bot-postgres`, Prisma CLI from host needs `localhost:5432` -3. **Symbol format mismatch:** Always normalize with `normalizeTradingViewSymbol()` before calling Drift +3. **Symbol format mismatch:** Always normalize with `normalizeTradingViewSymbol()` before calling Drift (applies to ALL endpoints including `/api/trading/close`) 4. **Missing reduce-only flag:** Exit orders without `reduceOnly: true` can accidentally open new positions @@ -268,6 +287,24 @@ docker exec trading-bot-postgres psql -U postgres -d trading_bot_v4 -c "\dt" - `TAKE_PROFIT_2_SIZE_PERCENT=80` means "close 80% of REMAINING" (not of original) - Actual runner size = (100 - TP1%) Ɨ (100 - TP2%) / 100 = 5% with defaults +9. **Transaction confirmation CRITICAL:** Both `openPosition()` AND `closePosition()` MUST call `connection.confirmTransaction()` after `placePerpOrder()`. Without this, the SDK returns transaction signatures that aren't confirmed on-chain, causing "phantom trades" or "phantom closes". Always check `confirmation.value.err` before proceeding. + +10. **Execution order matters:** When creating trades via API endpoints, the order MUST be: + 1. Open position + place exit orders + 2. Save to database (`createTrade()`) + 3. Add to Position Manager (`positionManager.addTrade()`) + + If Position Manager is added before database save, race conditions occur where monitoring checks before the trade exists in DB. + +11. **New trade grace period:** Position Manager skips "external closure" detection for trades <30 seconds old because Drift positions take 5-10 seconds to propagate after opening. Without this grace period, new positions are immediately detected as "closed externally" and cancelled. + +12. **Drift minimum position sizes:** Actual minimums differ from documentation: + - SOL-PERP: 0.1 SOL (~$5-15 depending on price) + - ETH-PERP: 0.002 ETH (~$7-8 at $4000/ETH) - NOT 0.01 ETH + - BTC-PERP: 0.0001 BTC (~$10-12 at $100k/BTC) + + Always calculate: `minOrderSize Ɨ currentPrice` must exceed Drift's $4 minimum. Add buffer for price movement. + ## File Conventions - **API routes:** `app/api/[feature]/[action]/route.ts` (Next.js 15 App Router) diff --git a/app/analytics/page.tsx b/app/analytics/page.tsx index 1d9c5a4..8819635 100644 --- a/app/analytics/page.tsx +++ b/app/analytics/page.tsx @@ -313,28 +313,27 @@ export default function AnalyticsPage() { - {lastTrade.signalQualityScore !== undefined ? ( -
-
Signal Quality
-
= 80 ? 'text-green-400' : lastTrade.signalQualityScore >= 70 ? 'text-yellow-400' : 'text-orange-400'}`}> - {lastTrade.signalQualityScore}/100 -
-
- {lastTrade.signalQualityScore >= 80 ? 'Excellent' : lastTrade.signalQualityScore >= 70 ? 'Good' : 'Marginal'} -
-
- ) : lastTrade.exitTime && lastTrade.exitPrice ? ( -
-
Exit
-
${lastTrade.exitPrice.toFixed(4)}
-
- {new Date(lastTrade.exitTime).toLocaleString()} -
-
- ) : null} +
+
Signal Quality
+ {lastTrade.signalQualityScore !== undefined ? ( + <> +
= 80 ? 'text-green-400' : lastTrade.signalQualityScore >= 70 ? 'text-yellow-400' : 'text-orange-400'}`}> + {lastTrade.signalQualityScore}/100 +
+
+ {lastTrade.signalQualityScore >= 80 ? 'Excellent' : lastTrade.signalQualityScore >= 70 ? 'Good' : 'Marginal'} +
+ + ) : ( + <> +
N/A
+
No score available
+ + )} +
- {lastTrade.exitTime && lastTrade.exitPrice && lastTrade.signalQualityScore !== undefined && ( + {lastTrade.exitTime && lastTrade.exitPrice && (
Exit
diff --git a/app/api/trading/close/route.ts b/app/api/trading/close/route.ts index ce744da..811b167 100644 --- a/app/api/trading/close/route.ts +++ b/app/api/trading/close/route.ts @@ -7,12 +7,13 @@ import { NextRequest, NextResponse } from 'next/server' import { closePosition } from '@/lib/drift/orders' import { initializeDriftService } from '@/lib/drift/client' +import { normalizeTradingViewSymbol } from '@/config/trading' export const dynamic = 'force-dynamic' export const runtime = 'nodejs' interface CloseRequest { - symbol: string // e.g., 'SOL-PERP' + symbol: string // e.g., 'SOL-PERP' or 'SOLUSDT' percentToClose?: number // 0-100, default 100 (close entire position) } @@ -46,14 +47,16 @@ export async function POST(request: NextRequest) { ) } - console.log(`šŸ“Š Closing position: ${symbol} (${percentToClose}%)`) + // Normalize symbol (SOLUSDT -> SOL-PERP) + const driftSymbol = normalizeTradingViewSymbol(symbol) + console.log(`šŸ“Š Closing position: ${driftSymbol} (${percentToClose}%)`) // Initialize Drift service if not already initialized await initializeDriftService() // Close position const result = await closePosition({ - symbol, + symbol: driftSymbol, percentToClose, slippageTolerance: 1.0, }) @@ -72,7 +75,7 @@ export async function POST(request: NextRequest) { return NextResponse.json({ success: true, transactionSignature: result.transactionSignature, - symbol, + symbol: driftSymbol, closePrice: result.closePrice, closedSize: result.closedSize, realizedPnL: result.realizedPnL, diff --git a/app/api/trading/execute/route.ts b/app/api/trading/execute/route.ts index 762f9b2..3fc69b1 100644 --- a/app/api/trading/execute/route.ts +++ b/app/api/trading/execute/route.ts @@ -25,6 +25,7 @@ export interface ExecuteTradeRequest { rsi?: number volumeRatio?: number pricePosition?: number + qualityScore?: number // Calculated by check-risk endpoint } export interface ExecuteTradeResponse { @@ -43,6 +44,7 @@ export interface ExecuteTradeResponse { tp2Percent?: number entrySlippage?: number timestamp?: string + qualityScore?: number // Signal quality score (0-100) error?: string message?: string } @@ -281,11 +283,6 @@ export async function POST(request: NextRequest): Promise = { symbol: 'ETH-PERP', driftMarketIndex: 2, pythPriceFeedId: '0xff61491a931112ddf1bd8147cd1b641375f79f5825126d665480874634fd0ace', - minOrderSize: 0.01, // 0.01 ETH minimum + minOrderSize: 0.002, // 0.002 ETH minimum (~$7-8 for safety above $4 Drift minimum) tickSize: 0.01, - // DATA COLLECTION MODE: Minimal risk (Drift minimum 0.01 ETH = ~$38) - positionSize: 40, // $40 base capital (meets exchange minimum) - leverage: 1, // 1x leverage = $40 total exposure + // DATA COLLECTION MODE: Minimal risk + positionSize: 8, // $8 base capital (ensures 0.002 ETH at ~$4000/ETH) + leverage: 1, // 1x leverage = $8 total exposure }, } diff --git a/lib/drift/orders.ts b/lib/drift/orders.ts index 1a9bbae..396721b 100644 --- a/lib/drift/orders.ts +++ b/lib/drift/orders.ts @@ -522,9 +522,17 @@ export async function closePosition( console.log(`āœ… Close order placed! Transaction: ${txSig}`) - // Wait for confirmation (transaction is likely already confirmed by placeAndTakePerpOrder) - console.log('ā³ Waiting for transaction confirmation...') - console.log('āœ… Transaction confirmed') + // CRITICAL: Confirm transaction on-chain to prevent phantom closes + console.log('ā³ Confirming transaction on-chain...') + const connection = driftService.getConnection() + const confirmation = await connection.confirmTransaction(txSig, 'confirmed') + + if (confirmation.value.err) { + console.error('āŒ Transaction failed on-chain:', confirmation.value.err) + throw new Error(`Transaction failed: ${JSON.stringify(confirmation.value.err)}`) + } + + console.log('āœ… Transaction confirmed on-chain') // Calculate realized P&L const pnlPerUnit = oraclePrice - position.entryPrice diff --git a/lib/trading/position-manager.ts b/lib/trading/position-manager.ts index 86082d3..5ca5ec3 100644 --- a/lib/trading/position-manager.ts +++ b/lib/trading/position-manager.ts @@ -286,7 +286,17 @@ export class PositionManager { const marketConfig = getMarketConfig(trade.symbol) const position = await driftService.getPosition(marketConfig.driftMarketIndex) + // Calculate trade age in seconds + const tradeAgeSeconds = (Date.now() - trade.entryTime) / 1000 + if (position === null || position.size === 0) { + // IMPORTANT: Skip "external closure" detection for NEW trades (<30 seconds old) + // Drift positions may not be immediately visible after opening due to blockchain delays + if (tradeAgeSeconds < 30) { + console.log(`ā³ Trade ${trade.symbol} is new (${tradeAgeSeconds.toFixed(1)}s old) - skipping external closure check`) + return // Skip this check cycle, position might still be propagating + } + // Position closed externally (by on-chain TP/SL order) console.log(`āš ļø Position ${trade.symbol} was closed externally (by on-chain order)`) } else { diff --git a/workflows/trading/Money_Machine.json b/workflows/trading/Money_Machine.json index ed77b02..daa2ebb 100644 --- a/workflows/trading/Money_Machine.json +++ b/workflows/trading/Money_Machine.json @@ -151,7 +151,7 @@ }, "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}", + "jsonBody": "={\n \"symbol\": \"{{ $('Parse Signal Enhanced').item.json.symbol }}\",\n \"direction\": \"{{ $('Parse Signal Enhanced').item.json.direction }}\",\n \"timeframe\": \"{{ $('Parse Signal Enhanced').item.json.timeframe }}\",\n \"signalStrength\": \"strong\",\n \"atr\": {{ $('Parse Signal Enhanced').item.json.atr }},\n \"adx\": {{ $('Parse Signal Enhanced').item.json.adx }},\n \"rsi\": {{ $('Parse Signal Enhanced').item.json.rsi }},\n \"volumeRatio\": {{ $('Parse Signal Enhanced').item.json.volumeRatio }},\n \"pricePosition\": {{ $('Parse Signal Enhanced').item.json.pricePosition }},\n \"qualityScore\": {{ $('Check Risk').item.json.qualityScore }}\n}", "options": { "timeout": 120000 } @@ -197,7 +197,7 @@ "values": [ { "name": "message", - "stringValue": "={{ `🟢 TRADE OPENED\n\nšŸ“Š Symbol: ${$('Parse Signal').item.json.symbol}\n${$('Parse Signal').item.json.direction === 'long' ? 'šŸ“ˆ' : 'šŸ“‰'} Direction: ${$('Parse Signal').item.json.direction.toUpperCase()}\n\nšŸ’µ Position: $${$('Execute Trade').item.json.positionSize}\n⚔ Leverage: ${$('Execute Trade').item.json.leverage}x\n\nšŸ’° Entry: $${$('Execute Trade').item.json.entryPrice.toFixed(4)}\nšŸŽÆ TP1: $${$('Execute Trade').item.json.takeProfit1.toFixed(4)} (${$('Execute Trade').item.json.tp1Percent}%)\nšŸŽÆ TP2: $${$('Execute Trade').item.json.takeProfit2.toFixed(4)} (${$('Execute Trade').item.json.tp2Percent}%)\nšŸ›‘ SL: $${$('Execute Trade').item.json.stopLoss.toFixed(4)} (${$('Execute Trade').item.json.stopLossPercent}%)\n\nā° ${$now.toFormat('HH:mm:ss')}\nāœ… Position monitored` }}" + "stringValue": "={{ `🟢 TRADE OPENED\n\nšŸ“Š Symbol: ${$('Parse Signal Enhanced').item.json.symbol}\n${$('Parse Signal Enhanced').item.json.direction === 'long' ? 'šŸ“ˆ' : 'šŸ“‰'} Direction: ${$('Parse Signal Enhanced').item.json.direction.toUpperCase()}\n\nšŸ’µ Position: $${$('Execute Trade').item.json.positionSize}\n⚔ Leverage: ${$('Execute Trade').item.json.leverage}x${$('Execute Trade').item.json.qualityScore ? `\n⭐ Quality: ${$('Execute Trade').item.json.qualityScore}/100` : ''}\n\nšŸ’° Entry: $${$('Execute Trade').item.json.entryPrice.toFixed(4)}\nšŸŽÆ TP1: $${$('Execute Trade').item.json.takeProfit1.toFixed(4)} (${$('Execute Trade').item.json.tp1Percent}%)\nšŸŽÆ TP2: $${$('Execute Trade').item.json.takeProfit2.toFixed(4)} (${$('Execute Trade').item.json.tp2Percent}%)\nšŸ›‘ SL: $${$('Execute Trade').item.json.stopLoss.toFixed(4)} (${$('Execute Trade').item.json.stopLossPercent}%)\n\nā° ${$now.setZone('Europe/Berlin').toFormat('HH:mm:ss')}\nāœ… Position monitored` }}" } ] }, @@ -218,7 +218,7 @@ "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') }}" + "stringValue": "šŸ”“ TRADE FAILED\\n\\n{{ $('Parse Signal').item.json.rawMessage }}\\n\\nāŒ Error: {{ $json.error || $json.message }}\\nā° {{ $now.setZone('Europe/Berlin').toFormat('HH:mm') }}" } ] }, @@ -242,7 +242,7 @@ { "id": "risk_message", "name": "message", - "value": "={{ 'āš ļø TRADE BLOCKED\\n\\n' + $('Parse Signal Enhanced').item.json.rawMessage + '\\n\\nšŸ›‘ Reason: ' + $json.reason + '\\nšŸ“‹ Details: ' + ($json.details || 'N/A') + '\\n\\nšŸ“Š Quality Score: ' + ($json.qualityScore || 'N/A') + '/100' + ($json.qualityReasons && $json.qualityReasons.length > 0 ? '\\nāš ļø Issues:\\n • ' + $json.qualityReasons.join('\\n • ') : '') + '\\n\\nā° ' + $now.format('HH:mm:ss') }}", + "value": "={{ 'āš ļø TRADE BLOCKED\\n\\n' + $('Parse Signal Enhanced').item.json.rawMessage + '\\n\\nšŸ›‘ Reason: ' + $json.reason + '\\nšŸ“‹ Details: ' + ($json.details || 'N/A') + '\\n\\nšŸ“Š Quality Score: ' + ($json.qualityScore || 'N/A') + '/100' + ($json.qualityReasons && $json.qualityReasons.length > 0 ? '\\nāš ļø Issues:\\n • ' + $json.qualityReasons.join('\\n • ') : '') + '\\n\\nā° ' + $now.setZone('Europe/Berlin').toFormat('HH:mm:ss') }}", "type": "string" } ]