diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 78b44f4..48f4a6d 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -251,6 +251,7 @@ const driftSymbol = normalizeTradingViewSymbol(body.symbol) - `/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/sync-positions` - **Re-sync Position Manager with actual Drift positions** (no auth, for recovery from partial fills/restarts) - `/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/version-comparison` - Compare performance across signal quality logic versions (v1/v2/v3) @@ -486,6 +487,16 @@ trade.realizedPnL += actualRealizedPnL // NOT: result.realizedPnL from SDK - Result: Win rate improved from 43.8% to 55.6%, profit per trade +86% - Implementation: `lib/trading/signal-quality.ts` checks both conditions before price position scoring +23. **Position Manager sync issues:** Partial fills from on-chain orders can cause Position Manager to lose tracking: + - Symptom: Database shows position "closed", but Drift shows position still open without stop loss + - Cause: On-chain orders partially fill (0.29 SOL × 3 times), Position Manager closes database record, but remainder stays open + - Impact: Remaining position has NO software-based stop loss protection (only relies on on-chain orders) + - Solution: Use `/api/trading/sync-positions` endpoint to re-sync Position Manager with actual Drift positions + - Access: Settings UI "Sync Positions" button (orange), or CLI `scripts/sync-positions.sh` + - When: After manual Telegram trades, bot restarts, rate limiting issues, or suspected tracking loss + - Recovery: Endpoint fetches actual Drift positions, re-adds missing ones to Position Manager with calculated TP/SL + - Documentation: See `docs/guides/POSITION_SYNC_GUIDE.md` for details + ## File Conventions - **API routes:** `app/api/[feature]/[action]/route.ts` (Next.js 15 App Router) diff --git a/POSITION_SYNC_QUICK_REF.md b/POSITION_SYNC_QUICK_REF.md new file mode 100644 index 0000000..708b09e --- /dev/null +++ b/POSITION_SYNC_QUICK_REF.md @@ -0,0 +1,61 @@ +# Position Sync - Quick Reference + +## 🚨 When to Use +- Position open on Drift but Position Manager shows 0 trades +- Database says "closed" but Drift shows position still open +- After manual Telegram trades with partial fills +- Bot restart lost in-memory tracking +- Rate limiting (429 errors) disrupted monitoring + +## ✅ Three Ways to Sync + +### 1. Settings UI (Easiest) +1. Go to http://localhost:3001/settings +2. Click the orange **"🔄 Sync Positions"** button (next to Restart Bot) +3. View results in green success message + +### 2. Terminal Script +```bash +cd /home/icke/traderv4 +bash scripts/sync-positions.sh +``` + +### 3. Direct API Call +```bash +source /home/icke/traderv4/.env +curl -X POST http://localhost:3001/api/trading/sync-positions \ + -H "Authorization: Bearer ${API_SECRET_KEY}" +``` + +## 📊 What It Does + +**Fetches** all open positions from Drift (SOL-PERP, BTC-PERP, ETH-PERP) + +**Compares** against Position Manager's tracked trades + +**Removes** tracking for positions closed externally + +**Adds** tracking for unmonitored positions with: +- Stop loss at configured % +- TP1/TP2 at configured % +- Emergency stop protection +- Trailing stop (if TP2 hit) +- MAE/MFE tracking + +**Result**: Dual-layer protection restored ✅ + +## 🎯 Your Current Situation + +- **Before Sync:** 4.93 SOL SHORT open, NO software protection +- **After Sync:** Position Manager monitors it every 2s with full TP/SL system + +## ⚠️ Limitations + +- Entry time unknown (assumes 1 hour ago - doesn't affect TP/SL) +- Signal quality metrics missing (only matters for scaling feature) +- Uses current config (not original config from when trade opened) +- Synthetic position ID (manual-{timestamp} instead of real TX) + +## 📖 Full Documentation + +See: `docs/guides/POSITION_SYNC_GUIDE.md` diff --git a/app/api/trading/sync-positions/route.ts b/app/api/trading/sync-positions/route.ts new file mode 100644 index 0000000..ff9e1e9 --- /dev/null +++ b/app/api/trading/sync-positions/route.ts @@ -0,0 +1,193 @@ +/** + * Sync Positions API Endpoint + * + * Re-synchronizes Position Manager with actual Drift positions + * Useful when: + * - Partial fills cause tracking issues + * - Bot restarts and loses in-memory state + * - Manual interventions on Drift + * - Database gets out of sync + * + * POST /api/trading/sync-positions + */ + +import { NextRequest, NextResponse } from 'next/server' +import { initializeDriftService, getDriftService } from '@/lib/drift/client' +import { getInitializedPositionManager } from '@/lib/trading/position-manager' +import { getPrismaClient } from '@/lib/database/trades' +import { getMergedConfig } from '@/config/trading' + +export async function POST(request: NextRequest): Promise { + try { + console.log('🔄 Position sync requested...') + + const config = getMergedConfig() + const driftService = await getDriftService() + const positionManager = await getInitializedPositionManager() + const prisma = getPrismaClient() + + // Get all current Drift positions + const driftPositions = await driftService.getAllPositions() + console.log(`📊 Found ${driftPositions.length} positions on Drift`) + + // Get all currently tracked positions + const trackedTrades = Array.from(positionManager.getActiveTrades().values()) + console.log(`📋 Position Manager tracking ${trackedTrades.length} trades`) + + const syncResults = { + drift_positions: driftPositions.length, + tracked_positions: trackedTrades.length, + added: [] as string[], + removed: [] as string[], + unchanged: [] as string[], + errors: [] as string[], + } + + // Step 1: Remove tracked positions that don't exist on Drift + for (const trade of trackedTrades) { + const existsOnDrift = driftPositions.some(p => p.symbol === trade.symbol) + + if (!existsOnDrift) { + console.log(`🗑️ Removing ${trade.symbol} (not on Drift)`) + await positionManager.removeTrade(trade.id) + syncResults.removed.push(trade.symbol) + + // Mark as closed in database + try { + await prisma.trade.update({ + where: { positionId: trade.positionId }, + data: { + status: 'closed', + exitReason: 'sync_cleanup', + exitTime: new Date(), + }, + }) + } catch (dbError) { + console.error(`❌ Failed to update database for ${trade.symbol}:`, dbError) + } + } else { + syncResults.unchanged.push(trade.symbol) + } + } + + // Step 2: Add Drift positions that aren't being tracked + for (const driftPos of driftPositions) { + const isTracked = trackedTrades.some(t => t.symbol === driftPos.symbol) + + if (!isTracked) { + console.log(`➕ Adding ${driftPos.symbol} to Position Manager`) + + try { + // Get current oracle price for this market + const currentPrice = await driftService.getOraclePrice(driftPos.marketIndex) + + // Calculate targets based on current config + const direction = driftPos.side + const entryPrice = driftPos.entryPrice + + // Calculate TP/SL prices + const calculatePrice = (entry: number, percent: number, dir: 'long' | 'short') => { + if (dir === 'long') { + return entry * (1 + percent / 100) + } else { + return entry * (1 - percent / 100) + } + } + + const stopLossPrice = calculatePrice(entryPrice, config.stopLossPercent, direction) + const tp1Price = calculatePrice(entryPrice, config.takeProfit1Percent, direction) + const tp2Price = calculatePrice(entryPrice, config.takeProfit2Percent, direction) + const emergencyStopPrice = calculatePrice(entryPrice, config.emergencyStopPercent, direction) + + // Calculate position size in USD + const positionSizeUSD = driftPos.size * currentPrice + + // Create ActiveTrade object + const activeTrade = { + id: `sync-${Date.now()}-${driftPos.symbol}`, + positionId: `manual-${Date.now()}`, // Synthetic ID since we don't have the original + symbol: driftPos.symbol, + direction: direction, + entryPrice: entryPrice, + entryTime: Date.now() - (60 * 60 * 1000), // Assume 1 hour ago (we don't know actual time) + positionSize: positionSizeUSD, + leverage: config.leverage, + stopLossPrice: stopLossPrice, + tp1Price: tp1Price, + tp2Price: tp2Price, + emergencyStopPrice: emergencyStopPrice, + currentSize: positionSizeUSD, + tp1Hit: false, + tp2Hit: false, + slMovedToBreakeven: false, + slMovedToProfit: false, + trailingStopActive: false, + realizedPnL: 0, + unrealizedPnL: driftPos.unrealizedPnL, + peakPnL: driftPos.unrealizedPnL, + peakPrice: currentPrice, + maxFavorableExcursion: 0, + maxAdverseExcursion: 0, + maxFavorablePrice: currentPrice, + maxAdversePrice: currentPrice, + originalAdx: undefined, + timesScaled: 0, + totalScaleAdded: 0, + atrAtEntry: undefined, + runnerTrailingPercent: undefined, + priceCheckCount: 0, + lastPrice: currentPrice, + lastUpdateTime: Date.now(), + } + + await positionManager.addTrade(activeTrade) + syncResults.added.push(driftPos.symbol) + + console.log(`✅ Added ${driftPos.symbol} to monitoring`) + } catch (error) { + console.error(`❌ Failed to add ${driftPos.symbol}:`, error) + syncResults.errors.push(`${driftPos.symbol}: ${error instanceof Error ? error.message : 'Unknown error'}`) + } + } + } + + const summary = { + success: true, + message: 'Position sync complete', + results: syncResults, + details: { + drift_positions: driftPositions.map(p => ({ + symbol: p.symbol, + direction: p.side, + size: p.size, + entry: p.entryPrice, + pnl: p.unrealizedPnL, + })), + now_tracking: Array.from(positionManager.getActiveTrades().values()).map(t => ({ + symbol: t.symbol, + direction: t.direction, + entry: t.entryPrice, + })), + }, + } + + console.log('✅ Position sync complete') + console.log(` Added: ${syncResults.added.length}`) + console.log(` Removed: ${syncResults.removed.length}`) + console.log(` Unchanged: ${syncResults.unchanged.length}`) + console.log(` Errors: ${syncResults.errors.length}`) + + return NextResponse.json(summary) + + } catch (error) { + console.error('❌ Position sync error:', error) + return NextResponse.json( + { + success: false, + error: 'Internal server error', + message: error instanceof Error ? error.message : 'Unknown error', + }, + { status: 500 } + ) + } +} diff --git a/app/settings/page.tsx b/app/settings/page.tsx index 48bbd1d..0db4265 100644 --- a/app/settings/page.tsx +++ b/app/settings/page.tsx @@ -125,6 +125,33 @@ export default function SettingsPage() { setRestarting(false) } + const syncPositions = async () => { + setLoading(true) + setMessage(null) + try { + const response = await fetch('/api/trading/sync-positions', { + method: 'POST', + }) + + const data = await response.json() + + if (data.success) { + const { results } = data + let msg = '✅ Position sync complete! ' + if (results.added.length > 0) msg += `Added: ${results.added.join(', ')}. ` + if (results.removed.length > 0) msg += `Removed: ${results.removed.join(', ')}. ` + if (results.unchanged.length > 0) msg += `Already tracking: ${results.unchanged.join(', ')}. ` + if (results.errors.length > 0) msg += `⚠️ Errors: ${results.errors.length}` + setMessage({ type: 'success', text: msg }) + } else { + setMessage({ type: 'error', text: `Sync failed: ${data.error || data.message}` }) + } + } catch (error) { + setMessage({ type: 'error', text: `Sync failed: ${error instanceof Error ? error.message : 'Unknown error'}` }) + } + setLoading(false) + } + const testTrade = async (direction: 'long' | 'short', symbol: string = 'SOLUSDT') => { if (!confirm(`⚠️ This will execute a REAL ${direction.toUpperCase()} trade on ${symbol} with current settings. Continue?`)) { return @@ -793,6 +820,14 @@ export default function SettingsPage() { > {restarting ? '🔄 Restarting...' : '🔄 Restart Bot'} +