From 5d5868d8025773274a4f30a30427653299901bac Mon Sep 17 00:00:00 2001 From: mindesbunister Date: Sat, 13 Dec 2025 17:24:38 +0100 Subject: [PATCH] critical: Fix Smart Validation Queue blockReason mismatch (Bug #84) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root Cause: check-risk endpoint passes blockReason='SMART_VALIDATION_QUEUED' but addSignal() only accepted 'QUALITY_SCORE_TOO_LOW' → signals blocked but never queued Impact: Quality 85 LONG signal at 08:40:03 saved to database but never monitored User missed validation opportunity when price moved favorably Fix: Accept both blockReason variants in addSignal() validation check Evidence: - Database record cmj41pdqu0101pf07mith5s4c has blockReason='SMART_VALIDATION_QUEUED' - No logs showing addSignal() execution (would log 'ā° Smart validation queued') - check-risk code line 451 passes 'SMART_VALIDATION_QUEUED' - addSignal() line 76 rejected signals != 'QUALITY_SCORE_TOO_LOW' Result: Quality 50-89 signals will now be properly queued for validation --- docker-compose.telegram-bot.yml | 6 + lib/health/position-manager-health.ts | 89 ++++++++- lib/trading/position-manager.ts | 127 +++++++++--- lib/trading/smart-validation-queue.ts | 4 +- lib/trading/sync-helper.ts | 266 ++++++++++++++++++++++++++ scripts/place-exit-orders.ts | 82 ++++++++ telegram_command_bot.py | 70 +++---- 7 files changed, 575 insertions(+), 69 deletions(-) create mode 100644 lib/trading/sync-helper.ts create mode 100644 scripts/place-exit-orders.ts diff --git a/docker-compose.telegram-bot.yml b/docker-compose.telegram-bot.yml index 3776b90..bfb7189 100644 --- a/docker-compose.telegram-bot.yml +++ b/docker-compose.telegram-bot.yml @@ -19,6 +19,12 @@ services: - TELEGRAM_CHAT_ID=579304651 - TRADING_BOT_URL=${TRADING_BOT_URL:-http://trading-bot-v4:3000} - API_SECRET_KEY=${API_SECRET_KEY} + healthcheck: + test: ["CMD", "python3", "-c", "import sys; sys.exit(0)"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 10s networks: - traderv4_trading-net diff --git a/lib/health/position-manager-health.ts b/lib/health/position-manager-health.ts index d166804..3d96b04 100644 --- a/lib/health/position-manager-health.ts +++ b/lib/health/position-manager-health.ts @@ -28,6 +28,65 @@ export interface HealthCheckResult { driftPositions: number unprotectedPositions: number } + autoSyncTriggered?: boolean +} + +// Cooldown to prevent sync spam (5 minutes) +let lastAutoSyncTime = 0 +const AUTO_SYNC_COOLDOWN_MS = 5 * 60 * 1000 + +/** + * Automatically trigger position sync when untracked positions detected + * + * CRITICAL: This prevents the $1,000 loss scenario where Telegram bot + * opens positions but Position Manager never tracks them. + * + * Cooldown: 5 minutes between auto-syncs to prevent spam + */ +async function autoSyncUntrackedPositions(): Promise { + const now = Date.now() + + // Check cooldown + if (now - lastAutoSyncTime < AUTO_SYNC_COOLDOWN_MS) { + const remainingSeconds = Math.ceil((AUTO_SYNC_COOLDOWN_MS - (now - lastAutoSyncTime)) / 1000) + console.log(`ā³ Auto-sync cooldown active (${remainingSeconds}s remaining)`) + return false + } + + try { + console.log('šŸ”„ AUTO-SYNC TRIGGERED: Untracked positions detected, syncing with Drift...') + + // Call the sync endpoint internally + const pm = await getInitializedPositionManager() + const driftService = getDriftService() + const driftPositions = await driftService.getAllPositions() + + console.log(`šŸ“Š Found ${driftPositions.length} positions on Drift`) + + let added = 0 + for (const driftPos of driftPositions) { + const isTracked = Array.from((pm as any).activeTrades.values()).some( + (t: any) => t.symbol === driftPos.symbol + ) + + if (!isTracked && Math.abs(driftPos.size) > 0) { + console.log(`āž• Auto-adding ${driftPos.symbol} to Position Manager`) + + // Import sync logic + const { syncSinglePosition } = await import('../trading/sync-helper') + await syncSinglePosition(driftPos, pm) + added++ + } + } + + lastAutoSyncTime = now + console.log(`āœ… AUTO-SYNC COMPLETE: Added ${added} position(s) to monitoring`) + + return true + } catch (error) { + console.error('āŒ Auto-sync failed:', error) + return false + } } /** @@ -95,24 +154,42 @@ export async function checkPositionManagerHealth(): Promise { if (pmActiveTrades !== driftPositions) { warnings.push(`āš ļø WARNING: Position Manager has ${pmActiveTrades} trades, Drift has ${driftPositions} positions`) warnings.push(` Possible untracked position or external closure`) + + // AUTO-SYNC: If Drift has MORE positions than PM, sync automatically + if (driftPositions > pmActiveTrades) { + console.log(`🚨 UNTRACKED POSITIONS DETECTED: Drift has ${driftPositions}, PM has ${pmActiveTrades}`) + const synced = await autoSyncUntrackedPositions() + + if (synced) { + warnings.push(`āœ… AUTO-SYNC: Triggered position sync to restore protection`) + // Re-check PM state after sync + pmActiveTrades = (pm as any).activeTrades?.size || 0 + } + } } - // Check for unprotected positions (no SL/TP orders) + // Check for unprotected positions + // NOTE: Synced/placeholder positions (signalSource='autosync') have NULL signatures in DB + // but orders exist on Drift. Position Manager monitoring provides backup protection. let unprotectedPositions = 0 for (const trade of dbTrades) { - if (!trade.slOrderTx && !trade.softStopOrderTx && !trade.hardStopOrderTx) { + const hasDbSignatures = !!(trade.slOrderTx || trade.softStopOrderTx || trade.hardStopOrderTx) + const isSyncedPosition = trade.signalSource === 'autosync' || trade.timeframe === 'sync' + + if (!hasDbSignatures && !isSyncedPosition) { + // This is NOT a synced position but has no SL orders - CRITICAL unprotectedPositions++ issues.push(`āŒ CRITICAL: Position ${trade.symbol} (${trade.id}) has NO STOP LOSS ORDERS!`) issues.push(` Entry: $${trade.entryPrice}, Size: $${trade.positionSizeUSD}`) issues.push(` This is the silent SL placement failure bug`) } - if (!trade.tp1OrderTx) { - warnings.push(`āš ļø Position ${trade.symbol} missing TP1 order`) + if (!trade.tp1OrderTx && !isSyncedPosition) { + warnings.push(`āš ļø Position ${trade.symbol} missing TP1 order (not synced)`) } - if (!trade.tp2OrderTx) { - warnings.push(`āš ļø Position ${trade.symbol} missing TP2 order`) + if (!trade.tp2OrderTx && !isSyncedPosition) { + warnings.push(`āš ļø Position ${trade.symbol} missing TP2 order (not synced)`) } } diff --git a/lib/trading/position-manager.ts b/lib/trading/position-manager.ts index b05b808..5261fce 100644 --- a/lib/trading/position-manager.ts +++ b/lib/trading/position-manager.ts @@ -811,38 +811,109 @@ export class PositionManager { trade.slMovedToBreakeven = true logger.log(`šŸ›”ļø Stop loss moved to: $${trade.stopLossPrice.toFixed(4)}`) - // CRITICAL: Update on-chain orders to reflect new SL at breakeven - try { - const { cancelAllOrders, placeExitOrders } = await import('../drift/orders') + // CRITICAL FIX (Dec 12, 2025): Check if we have order signatures + // Auto-synced positions may have NULL signatures, need fallback + const { updateTradeState } = await import('../database/trades') + const dbTrade = await this.prisma.trade.findUnique({ + where: { id: trade.id }, + select: { slOrderTx: true, softStopOrderTx: true, hardStopOrderTx: true } + }) + + const hasOrderSignatures = dbTrade && ( + dbTrade.slOrderTx || dbTrade.softStopOrderTx || dbTrade.hardStopOrderTx + ) + + if (!hasOrderSignatures) { + logger.log(`āš ļø No order signatures found - auto-synced position detected`) + logger.log(`šŸ”§ FALLBACK: Placing fresh SL order at breakeven $${trade.stopLossPrice.toFixed(4)}`) - logger.log(`šŸ”„ Cancelling old exit orders...`) - const cancelResult = await cancelAllOrders(trade.symbol) - if (cancelResult.success) { - logger.log(`āœ… Cancelled ${cancelResult.cancelledCount} old orders`) + // Place fresh SL order without trying to cancel (no signatures to cancel) + const { placeExitOrders } = await import('../drift/orders') + + try { + const placeResult = await placeExitOrders({ + symbol: trade.symbol, + positionSizeUSD: trade.currentSize, + entryPrice: trade.entryPrice, + tp1Price: trade.tp2Price || trade.entryPrice * (trade.direction === 'long' ? 1.02 : 0.98), + tp2Price: trade.tp2Price || trade.entryPrice * (trade.direction === 'long' ? 1.04 : 0.96), + stopLossPrice: trade.stopLossPrice, + tp1SizePercent: 0, // Already hit, don't place TP1 + tp2SizePercent: trade.tp2SizePercent || 0, + direction: trade.direction, + useDualStops: this.config.useDualStops, + softStopPrice: this.config.useDualStops ? trade.stopLossPrice : undefined, + hardStopPrice: this.config.useDualStops + ? (trade.direction === 'long' ? trade.stopLossPrice * 0.99 : trade.stopLossPrice * 1.01) + : undefined, + }) + + if (placeResult.success && placeResult.signatures) { + logger.log(`āœ… Fresh SL order placed successfully`) + + // Update database with new order signatures + const updateData: any = { + stopLossPrice: trade.stopLossPrice, + } + + if (this.config.useDualStops && placeResult.signatures.length >= 2) { + updateData.softStopOrderTx = placeResult.signatures[0] + updateData.hardStopOrderTx = placeResult.signatures[1] + logger.log(`šŸ’¾ Recorded dual SL signatures: soft=${placeResult.signatures[0].slice(0,8)}... hard=${placeResult.signatures[1].slice(0,8)}...`) + } else if (placeResult.signatures.length >= 1) { + updateData.slOrderTx = placeResult.signatures[0] + logger.log(`šŸ’¾ Recorded SL signature: ${placeResult.signatures[0].slice(0,8)}...`) + } + + await updateTradeState({ + id: trade.id, + ...updateData + }) + logger.log(`āœ… Database updated with new SL order signatures`) + } else { + console.error(`āŒ Failed to place fresh SL order:`, placeResult.error) + logger.log(`āš ļø CRITICAL: Runner has NO STOP LOSS PROTECTION - manual intervention needed`) + } + } catch (placeError) { + console.error(`āŒ Error placing fresh SL order:`, placeError) + logger.log(`āš ļø CRITICAL: Runner has NO STOP LOSS PROTECTION - manual intervention needed`) } + } else { + // Normal flow: Has order signatures, can cancel and replace + logger.log(`āœ… Order signatures found - normal order update flow`) - logger.log(`šŸ›”ļø Placing new exit orders with SL at breakeven...`) - const orderResult = await placeExitOrders({ - symbol: trade.symbol, - direction: trade.direction, - entryPrice: trade.entryPrice, - positionSizeUSD: trade.currentSize, // Runner size - stopLossPrice: trade.stopLossPrice, // At breakeven now - tp1Price: trade.tp2Price, // TP2 becomes new TP1 for runner - tp2Price: 0, // No TP2 for runner - tp1SizePercent: 0, // Close 0% at TP2 (activates trailing) - tp2SizePercent: 0, // No TP2 - softStopPrice: 0, - hardStopPrice: 0, - }) - - if (orderResult.success) { - logger.log(`āœ… Exit orders updated with SL at breakeven`) - } else { - console.error(`āŒ Failed to update exit orders:`, orderResult.error) + try { + const { cancelAllOrders, placeExitOrders } = await import('../drift/orders') + + logger.log(`šŸ”„ Cancelling old exit orders...`) + const cancelResult = await cancelAllOrders(trade.symbol) + if (cancelResult.success) { + logger.log(`āœ… Cancelled ${cancelResult.cancelledCount} old orders`) + } + + logger.log(`šŸ›”ļø Placing new exit orders with SL at breakeven...`) + const orderResult = await placeExitOrders({ + symbol: trade.symbol, + direction: trade.direction, + entryPrice: trade.entryPrice, + positionSizeUSD: trade.currentSize, // Runner size + stopLossPrice: trade.stopLossPrice, // At breakeven now + tp1Price: trade.tp2Price, // TP2 becomes new TP1 for runner + tp2Price: 0, // No TP2 for runner + tp1SizePercent: 0, // Close 0% at TP2 (activates trailing) + tp2SizePercent: 0, // No TP2 + softStopPrice: 0, + hardStopPrice: 0, + }) + + if (orderResult.success) { + logger.log(`āœ… Exit orders updated with SL at breakeven`) + } else { + console.error(`āŒ Failed to update exit orders:`, orderResult.error) + } + } catch (error) { + console.error(`āŒ Failed to update on-chain orders after TP1:`, error) } - } catch (error) { - console.error(`āŒ Failed to update on-chain orders after TP1:`, error) } await this.saveTradeState(trade) diff --git a/lib/trading/smart-validation-queue.ts b/lib/trading/smart-validation-queue.ts index 31fa29c..627a0f1 100644 --- a/lib/trading/smart-validation-queue.ts +++ b/lib/trading/smart-validation-queue.ts @@ -73,7 +73,9 @@ class SmartValidationQueue { const config = getMergedConfig() // Only queue signals blocked for quality (not cooldown, rate limits, etc.) - if (params.blockReason !== 'QUALITY_SCORE_TOO_LOW') { + // CRITICAL FIX (Dec 13, 2025): Accept both QUALITY_SCORE_TOO_LOW and SMART_VALIDATION_QUEUED + // Bug: check-risk sends 'SMART_VALIDATION_QUEUED' but addSignal() only accepted 'QUALITY_SCORE_TOO_LOW' + if (params.blockReason !== 'QUALITY_SCORE_TOO_LOW' && params.blockReason !== 'SMART_VALIDATION_QUEUED') { return null } diff --git a/lib/trading/sync-helper.ts b/lib/trading/sync-helper.ts new file mode 100644 index 0000000..e4f83de --- /dev/null +++ b/lib/trading/sync-helper.ts @@ -0,0 +1,266 @@ +/** + * Position Sync Helper + * + * Extracted from sync-positions endpoint to allow internal reuse + * by health monitor for automatic position synchronization. + * + * Created: Dec 12, 2025 + * Enhanced: Dec 12, 2025 - Added order signature discovery for synced positions + */ + +import { getDriftService } from '../drift/client' +import { getPrismaClient } from '../database/trades' +import { getMergedConfig } from '@/config/trading' +import { OrderType, OrderTriggerCondition } from '@drift-labs/sdk' + +/** + * Query Drift for existing orders on a position and extract signatures + * CRITICAL FIX (Dec 12, 2025): Auto-synced positions need order signatures + * so Position Manager can update orders after TP1/TP2 events + */ +async function discoverExistingOrders(symbol: string, marketIndex: number): Promise<{ + tp1OrderTx?: string + tp2OrderTx?: string + slOrderTx?: string + softStopOrderTx?: string + hardStopOrderTx?: string +}> { + try { + const driftService = getDriftService() + const driftClient = driftService.getClient() + + // Get all open orders for this user + const orders = driftClient.getOpenOrders() + + // Filter for orders on this market that are reduce-only + const marketOrders = orders.filter(order => + order.marketIndex === marketIndex && + order.reduceOnly === true + ) + + const signatures: any = {} + + for (const order of marketOrders) { + // Try to extract transaction signature from order reference + // Note: Drift SDK may not expose TX signatures directly on orders + // This is a best-effort attempt to recover order references + const orderRefStr = order.orderId?.toString() || '' + + if (order.orderType === OrderType.LIMIT) { + // TP1 or TP2 (LIMIT orders) + // Higher trigger = TP for long, lower trigger = TP for short + if (!signatures.tp1OrderTx) { + signatures.tp1OrderTx = orderRefStr + console.log(`šŸ” Found potential TP1 order: ${orderRefStr.slice(0, 8)}...`) + } else if (!signatures.tp2OrderTx) { + signatures.tp2OrderTx = orderRefStr + console.log(`šŸ” Found potential TP2 order: ${orderRefStr.slice(0, 8)}...`) + } + } else if (order.orderType === OrderType.TRIGGER_MARKET) { + // Hard stop loss (TRIGGER_MARKET) + if (!signatures.slOrderTx && !signatures.hardStopOrderTx) { + signatures.hardStopOrderTx = orderRefStr + console.log(`šŸ” Found hard SL order: ${orderRefStr.slice(0, 8)}...`) + } + } else if (order.orderType === OrderType.TRIGGER_LIMIT) { + // Soft stop loss (TRIGGER_LIMIT) + if (!signatures.softStopOrderTx) { + signatures.softStopOrderTx = orderRefStr + console.log(`šŸ” Found soft SL order: ${orderRefStr.slice(0, 8)}...`) + } + } + } + + // Log what we found + const foundCount = Object.keys(signatures).filter(k => signatures[k]).length + if (foundCount > 0) { + console.log(`āœ… Discovered ${foundCount} existing order(s) for ${symbol}`) + } else { + console.warn(`āš ļø No existing orders found for ${symbol} - PM won't be able to update orders after events`) + } + + return signatures + } catch (error) { + console.error(`āŒ Error discovering orders for ${symbol}:`, error) + return {} // Return empty object on error (fail gracefully) + } +} + +/** + * Sync a single Drift position to Position Manager + */ +export async function syncSinglePosition(driftPos: any, positionManager: any): Promise { + const driftService = getDriftService() + const prisma = getPrismaClient() + const config = getMergedConfig() + + 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 (Drift size is tokens) + const positionSizeUSD = Math.abs(driftPos.size) * currentPrice + + // Try to find an existing open trade in the database for this symbol + const existingTrade = await prisma.trade.findFirst({ + where: { + symbol: driftPos.symbol, + status: 'open', + }, + orderBy: { entryTime: 'desc' }, + }) + + const normalizeDirection = (dir: string): 'long' | 'short' => + dir === 'long' ? 'long' : 'short' + + const buildActiveTradeFromDb = (dbTrade: any): any => { + const pmState = (dbTrade.configSnapshot as any)?.positionManagerState + + return { + id: dbTrade.id, + positionId: dbTrade.positionId, + symbol: dbTrade.symbol, + direction: normalizeDirection(dbTrade.direction), + entryPrice: dbTrade.entryPrice, + entryTime: dbTrade.entryTime.getTime(), + positionSize: dbTrade.positionSizeUSD, + leverage: dbTrade.leverage, + stopLossPrice: pmState?.stopLossPrice ?? dbTrade.stopLossPrice, + tp1Price: dbTrade.takeProfit1Price, + tp2Price: dbTrade.takeProfit2Price, + emergencyStopPrice: dbTrade.stopLossPrice * (dbTrade.direction === 'long' ? 0.98 : 1.02), + currentSize: pmState?.currentSize ?? dbTrade.positionSizeUSD, + originalPositionSize: dbTrade.positionSizeUSD, + takeProfitPrice1: dbTrade.takeProfit1Price, + takeProfitPrice2: dbTrade.takeProfit2Price, + tp1Hit: pmState?.tp1Hit ?? false, + tp2Hit: pmState?.tp2Hit ?? false, + slMovedToBreakeven: pmState?.slMovedToBreakeven ?? false, + slMovedToProfit: pmState?.slMovedToProfit ?? false, + trailingStopActive: pmState?.trailingStopActive ?? false, + trailingStopDistance: pmState?.trailingStopDistance, + realizedPnL: pmState?.realizedPnL ?? 0, + unrealizedPnL: pmState?.unrealizedPnL ?? 0, + peakPnL: pmState?.peakPnL ?? 0, + peakPrice: pmState?.peakPrice ?? dbTrade.entryPrice, + maxFavorableExcursion: pmState?.maxFavorableExcursion ?? 0, + maxAdverseExcursion: pmState?.maxAdverseExcursion ?? 0, + maxFavorablePrice: pmState?.maxFavorablePrice ?? dbTrade.entryPrice, + maxAdversePrice: pmState?.maxAdversePrice ?? dbTrade.entryPrice, + originalAdx: dbTrade.adxAtEntry, + timesScaled: pmState?.timesScaled ?? 0, + totalScaleAdded: pmState?.totalScaleAdded ?? 0, + atrAtEntry: dbTrade.atrAtEntry, + // TP2 runner configuration (CRITICAL FIX - Dec 12, 2025) + tp1SizePercent: pmState?.tp1SizePercent ?? dbTrade.tp1SizePercent ?? config.takeProfit1SizePercent, + tp2SizePercent: pmState?.tp2SizePercent ?? dbTrade.tp2SizePercent ?? config.takeProfit2SizePercent, + runnerTrailingPercent: pmState?.runnerTrailingPercent, + priceCheckCount: 0, + lastPrice: pmState?.lastPrice ?? dbTrade.entryPrice, + lastUpdateTime: Date.now(), + } + } + + let activeTrade + + if (existingTrade) { + console.log(`šŸ”— Found existing open trade in DB for ${driftPos.symbol}, attaching to Position Manager`) + activeTrade = buildActiveTradeFromDb(existingTrade) + } else { + console.warn(`āš ļø No open DB trade found for ${driftPos.symbol}. Creating synced placeholder to restore protection.`) + const now = new Date() + const syntheticPositionId = `autosync-${now.getTime()}-${driftPos.marketIndex}` + + // CRITICAL FIX (Dec 12, 2025): Query Drift for existing orders + // Auto-synced positions need order signatures so PM can update orders after TP1/TP2 events + console.log(`šŸ” Querying Drift for existing orders on ${driftPos.symbol}...`) + const existingOrders = await discoverExistingOrders(driftPos.symbol, driftPos.marketIndex) + + const placeholderTrade = await prisma.trade.create({ + data: { + positionId: syntheticPositionId, + symbol: driftPos.symbol, + direction, + entryPrice, + entryTime: now, + positionSizeUSD, + collateralUSD: positionSizeUSD / config.leverage, + leverage: config.leverage, + stopLossPrice, + takeProfit1Price: tp1Price, + takeProfit2Price: tp2Price, + tp1SizePercent: config.takeProfit1SizePercent, + tp2SizePercent: + config.useTp2AsTriggerOnly && (config.takeProfit2SizePercent ?? 0) <= 0 + ? 0 + : (config.takeProfit2SizePercent ?? 0), + status: 'open', + signalSource: 'autosync', + timeframe: 'sync', + // CRITICAL FIX (Dec 12, 2025): Record discovered order signatures + tp1OrderTx: existingOrders.tp1OrderTx || null, + tp2OrderTx: existingOrders.tp2OrderTx || null, + slOrderTx: existingOrders.slOrderTx || null, + softStopOrderTx: existingOrders.softStopOrderTx || null, + hardStopOrderTx: existingOrders.hardStopOrderTx || null, + configSnapshot: { + source: 'health-monitor-autosync', + syncedAt: now.toISOString(), + discoveredOrders: existingOrders, // Store for debugging + positionManagerState: { + currentSize: positionSizeUSD, + tp1Hit: false, + tp2Hit: false, + slMovedToBreakeven: false, + slMovedToProfit: false, + trailingStopActive: false, + stopLossPrice, + realizedPnL: 0, + unrealizedPnL: driftPos.unrealizedPnL ?? 0, + peakPnL: driftPos.unrealizedPnL ?? 0, + peakPrice: entryPrice, + lastPrice: currentPrice, + maxFavorableExcursion: 0, + maxAdverseExcursion: 0, + maxFavorablePrice: entryPrice, + maxAdversePrice: entryPrice, + lastUpdate: now.toISOString(), + // TP2 runner configuration (CRITICAL FIX - Dec 12, 2025) + tp1SizePercent: config.takeProfit1SizePercent, + tp2SizePercent: config.takeProfit2SizePercent, + trailingStopDistance: undefined, + runnerTrailingPercent: undefined, + }, + }, + entryOrderTx: syntheticPositionId, + }, + }) + + activeTrade = buildActiveTradeFromDb(placeholderTrade) + } + + await positionManager.addTrade(activeTrade) + console.log(`āœ… Synced ${driftPos.symbol} to Position Manager`) + } catch (error) { + console.error(`āŒ Failed to sync ${driftPos.symbol}:`, error) + throw error + } +} diff --git a/scripts/place-exit-orders.ts b/scripts/place-exit-orders.ts new file mode 100644 index 0000000..cd72fd4 --- /dev/null +++ b/scripts/place-exit-orders.ts @@ -0,0 +1,82 @@ +#!/usr/bin/env ts-node + +/** + * Place fresh TP1 + SL orders for existing position + * Does NOT close or modify the position itself + */ + +import { initializeDriftService } from '../lib/drift/client' +import { placeExitOrders } from '../lib/drift/orders' +import { getPythClient } from '../lib/pyth/client' + +async function placeExitOrdersForPosition() { + try { + console.log('šŸ”§ Initializing services...') + + // Initialize Drift + const driftService = await initializeDriftService() + const driftClient = driftService.getDriftClient() + + // Get current position + const position = await driftService.getPosition(0) // SOL-PERP market index + if (!position || position.baseAssetAmount.toString() === '0') { + console.error('āŒ No open SOL-PERP position found') + process.exit(1) + } + + console.log(`āœ… Found position: ${position.baseAssetAmount.toString()} base units`) + + // Get current price + const pythClient = getPythClient() + const currentPrice = await pythClient.getPrice('SOL-PERP') + console.log(`šŸ’° Current SOL price: $${currentPrice}`) + + // Position details from database + const entryPrice = 131.7993991266376 + const positionSizeUSD = 903.1306122 + const tp1Price = 132.8537943196507 + const stopLossPrice = 129.822408139738 + const tp1SizePercent = 60 // Close 60% at TP1 + + console.log(`\nšŸ“Š Position Configuration:`) + console.log(` Entry: $${entryPrice}`) + console.log(` Size: $${positionSizeUSD} (${(positionSizeUSD / currentPrice).toFixed(2)} SOL)`) + console.log(` TP1: $${tp1Price} (+${((tp1Price / entryPrice - 1) * 100).toFixed(2)}%)`) + console.log(` SL: $${stopLossPrice} (${((stopLossPrice / entryPrice - 1) * 100).toFixed(2)}%)`) + console.log(` TP1 will close: ${tp1SizePercent}%`) + console.log(` Runner: ${100 - tp1SizePercent}% (software-monitored, no on-chain TP2)`) + + // Place orders + console.log(`\nšŸ”Ø Placing exit orders...`) + const result = await placeExitOrders({ + symbol: 'SOL-PERP', + direction: 'long', + entryPrice: entryPrice, + positionSizeUSD: positionSizeUSD, + tp1Price: tp1Price, + tp1SizePercent: tp1SizePercent, + tp2Price: 134.171788310917, // Not used (tp2SizePercent = 0) + tp2SizePercent: 0, // No TP2 order (runner only) + stopLossPrice: stopLossPrice, + useDualStops: false, // Single SL only + }) + + if (result.success) { + console.log(`\nāœ… Orders placed successfully!`) + console.log(` Signatures:`, result.signatures) + console.log(`\nšŸ“ Update database with these signatures:`) + console.log(` tp1OrderTx: ${result.signatures[0]}`) + console.log(` slOrderTx: ${result.signatures[1]}`) + console.log(` tp2OrderTx: NULL (runner-only, no on-chain order)`) + } else { + console.error(`\nāŒ Failed to place orders:`, result.error) + process.exit(1) + } + + } catch (error) { + console.error('āŒ Error:', error) + process.exit(1) + } +} + +placeExitOrdersForPosition() diff --git a/telegram_command_bot.py b/telegram_command_bot.py index ca7cbf8..f72fbae 100644 --- a/telegram_command_bot.py +++ b/telegram_command_bot.py @@ -745,14 +745,14 @@ async def manual_trade_handler(update: Update, context: ContextTypes.DEFAULT_TYP 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" + 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') + await update.message.reply_text(message) print(f"āŒ Trade blocked by analytics (score: {analytics.get('score')})", flush=True) return @@ -762,12 +762,12 @@ async def manual_trade_handler(update: Update, context: ContextTypes.DEFAULT_TYP data_age_text = f" ({data_age}s old)" if data_age else "" confirm_message = ( - f"āœ… *Analytics check passed ({analytics.get('score')}/100)*\n" + 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') + await update.message.reply_text(confirm_message) print(f"āœ… Analytics passed (score: {analytics.get('score')})", flush=True) else: # Analytics endpoint failed - proceed with trade (fail-open) @@ -783,9 +783,8 @@ async def manual_trade_handler(update: Update, context: ContextTypes.DEFAULT_TYP # Send waiting message to user await update.message.reply_text( - f"ā³ *Waiting for next 1-minute datapoint...*\n" - f"Will execute with fresh ATR (max 90s)", - parse_mode='Markdown' + f"ā³ Waiting for next 1-minute datapoint...\n" + f"Will execute with fresh ATR (max 90s)" ) # Poll for fresh data (new timestamp = new datapoint arrived) @@ -813,29 +812,26 @@ async def manual_trade_handler(update: Update, context: ContextTypes.DEFAULT_TYP print(f"āœ… Using fresh metrics: ATR={metrics['atr']:.4f}, ADX={metrics['adx']:.1f}, RSI={metrics['rsi']:.1f} ({data_age}s old)", flush=True) await update.message.reply_text( - f"āœ… *Fresh data received*\n" + f"āœ… Fresh data received\n" f"ATR: {metrics['atr']:.4f} | ADX: {metrics['adx']:.1f} | RSI: {metrics['rsi']:.1f}\n" - f"Executing {direction.upper()} {symbol_info['label']}...", - parse_mode='Markdown' + f"Executing {direction.upper()} {symbol_info['label']}..." ) else: print(f"āš ļø Fresh data invalid (ATR={fresh_atr}), using preset metrics", flush=True) await update.message.reply_text( - f"āš ļø *Fresh data invalid*\n" + f"āš ļø Fresh data invalid\n" f"Using preset ATR: {metrics['atr']}\n" - f"Executing {direction.upper()} {symbol_info['label']}...", - parse_mode='Markdown' + f"Executing {direction.upper()} {symbol_info['label']}..." ) else: # Timeout - fallback to preset with warning print(f"āš ļø Timeout waiting for fresh data - using preset metrics: ATR={metrics['atr']}", flush=True) await update.message.reply_text( - f"āš ļø *Timeout waiting for fresh data*\n" + f"āš ļø Timeout waiting for fresh data\n" f"Using preset ATR: {metrics['atr']}\n" - f"Executing {direction.upper()} {symbol_info['label']}...", - parse_mode='Markdown' + f"Executing {direction.upper()} {symbol_info['label']}..." ) # Execute the trade with fresh or fallback metrics @@ -865,17 +861,22 @@ async def manual_trade_handler(update: Update, context: ContextTypes.DEFAULT_TYP # Parse JSON even for error responses to get detailed error messages try: + print(f"šŸ” Parsing JSON response...", flush=True) data = response.json() - except Exception: + print(f"āœ… JSON parsed successfully", flush=True) + except Exception as e: + print(f"āŒ JSON parse error: {e}", flush=True) await update.message.reply_text(f"āŒ Execution error ({response.status_code})") return if not data.get('success'): # CRITICAL: Show detailed error message (may contain "CLOSE POSITION MANUALLY") message = data.get('message') or data.get('error') or 'Trade rejected' + print(f"āŒ Trade failed: {message}", flush=True) await update.message.reply_text(f"āŒ {message}") return + print(f"āœ… Trade success, extracting data...", flush=True) entry_price = data.get('entryPrice') notional = data.get('positionSize') leverage = data.get('leverage') @@ -902,12 +903,15 @@ async def manual_trade_handler(update: Update, context: ContextTypes.DEFAULT_TYP f"TP1: {tp1_text}\nTP2: {tp2_text}\nSL: {sl_text}" ) + print(f"šŸ“¤ Sending success message to user...", flush=True) await update.message.reply_text(success_message) + print(f"āœ… Success message sent!", flush=True) except Exception as exc: print(f"āŒ Manual trade failed: {exc}", flush=True) await update.message.reply_text(f"āŒ Error: {exc}") + async def main(): """Start the bot""" @@ -929,7 +933,7 @@ async def main(): # Create application application = Application.builder().token(TELEGRAM_BOT_TOKEN).build() - # Add command handlers + # Add handlers application.add_handler(CommandHandler("help", help_command)) application.add_handler(CommandHandler("status", status_command)) application.add_handler(CommandHandler("close", close_command)) @@ -951,10 +955,7 @@ async def main(): manual_trade_handler, )) - # Initialize the application first - await application.initialize() - - # Register bot commands for autocomplete (works in Telegram AND Matrix bridges) + # Set bot commands for autocomplete commands = [ BotCommand("help", "Show all available commands"), BotCommand("status", "Show open positions"), @@ -970,23 +971,24 @@ async def main(): BotCommand("sellfart", "Sell FARTCOIN (shortcut)"), ] await application.bot.set_my_commands(commands) - print("āœ… Bot commands registered for autocomplete (Telegram + Matrix)", flush=True) + print("āœ… Bot commands registered for autocomplete", flush=True) - # Start polling print("\nšŸ¤– Bot ready! Send commands to your Telegram.\n", flush=True) + + # Start the bot with proper async pattern + await application.initialize() await application.start() await application.updater.start_polling(allowed_updates=Update.ALL_TYPES) - # Run until stopped + # Keep running until stopped try: await asyncio.Event().wait() except (KeyboardInterrupt, SystemExit): pass - - # Cleanup - await application.updater.stop() - await application.stop() - await application.shutdown() + finally: + await application.updater.stop() + await application.stop() + await application.shutdown() if __name__ == '__main__': asyncio.run(main())