From 9bf83260c435a3c806e13eaefadee46f53192552 Mon Sep 17 00:00:00 2001 From: mindesbunister Date: Mon, 27 Oct 2025 23:27:48 +0100 Subject: [PATCH] Add /close command and auto-flip logic with order cleanup - Added /close Telegram command for full position closure - Updated /reduce to accept 10-100% (was 10-90%) - Implemented auto-flip logic: automatically closes opposite position when signal reverses - Fixed risk check to allow opposite direction trades (signal flips) - Enhanced Position Manager to cancel orders when removing trades - Added startup initialization for Position Manager (restores trades on restart) - Fixed analytics to show stopped-out trades (manual DB update for orphaned trade) - Updated reduce endpoint to route 100% closes through closePosition for proper cleanup - All position closures now guarantee TP/SL order cancellation on Drift --- app/api/trading/check-risk/route.ts | 37 +++++++--- app/api/trading/execute/route.ts | 35 ++++++++- app/api/trading/reduce-position/route.ts | 53 +++++++++++++- instrumentation.ts | 18 +++++ lib/startup/init-position-manager.ts | 33 +++++++++ lib/trading/position-manager.ts | 17 ++++- telegram_command_bot.py | 90 +++++++++++++++++++++++- 7 files changed, 264 insertions(+), 19 deletions(-) create mode 100644 instrumentation.ts create mode 100644 lib/startup/init-position-manager.ts diff --git a/app/api/trading/check-risk/route.ts b/app/api/trading/check-risk/route.ts index 61949c6..0c3d85d 100644 --- a/app/api/trading/check-risk/route.ts +++ b/app/api/trading/check-risk/route.ts @@ -45,20 +45,37 @@ export async function POST(request: NextRequest): Promise trade.symbol === body.symbol) + const existingPosition = existingTrades.find(trade => trade.symbol === body.symbol) - if (duplicatePosition) { - console.log('🚫 Risk check BLOCKED: Duplicate position exists', { + if (existingPosition) { + // Check if it's the SAME direction (duplicate - block it) + if (existingPosition.direction === body.direction) { + console.log('🚫 Risk check BLOCKED: Duplicate position (same direction)', { + symbol: body.symbol, + existingDirection: existingPosition.direction, + requestedDirection: body.direction, + existingEntry: existingPosition.entryPrice, + }) + + return NextResponse.json({ + allowed: false, + reason: 'Duplicate position', + details: `Already have ${existingPosition.direction} position on ${body.symbol} (entry: $${existingPosition.entryPrice})`, + }) + } + + // OPPOSITE direction - this is a signal flip/reversal (ALLOW IT) + console.log('🔄 Risk check: Signal flip detected', { symbol: body.symbol, - existingDirection: duplicatePosition.direction, - requestedDirection: body.direction, - existingEntry: duplicatePosition.entryPrice, + existingDirection: existingPosition.direction, + newDirection: body.direction, + note: 'Will close existing and open opposite', }) return NextResponse.json({ - allowed: false, - reason: 'Duplicate position', - details: `Already have ${duplicatePosition.direction} position on ${body.symbol} (entry: $${duplicatePosition.entryPrice})`, + allowed: true, + reason: 'Signal flip', + details: `Signal reversed from ${existingPosition.direction} to ${body.direction} - will flip position`, }) } @@ -68,7 +85,7 @@ export async function POST(request: NextRequest): Promise trade.symbol === driftSymbol && trade.direction !== body.direction + ) + + if (oppositePosition) { + console.log(`🔄 Signal flip detected! Closing ${oppositePosition.direction} to open ${body.direction}`) + + // Close opposite position + const { closePosition } = await import('@/lib/drift/orders') + const closeResult = await closePosition({ + symbol: driftSymbol, + percentToClose: 100, + slippageTolerance: config.slippageTolerance, + }) + + if (!closeResult.success) { + console.error('❌ Failed to close opposite position:', closeResult.error) + // Continue anyway - we'll try to open the new position + } else { + console.log(`✅ Closed ${oppositePosition.direction} position at $${closeResult.closePrice?.toFixed(4)} (P&L: $${closeResult.realizedPnL?.toFixed(2)})`) + + // Position Manager will handle cleanup (including order cancellation) + // The executeExit method already removes the trade and updates database + } + + // Small delay to ensure position is fully closed + await new Promise(resolve => setTimeout(resolve, 1000)) + } + // Calculate position size with leverage const positionSizeUSD = config.positionSize * config.leverage @@ -211,8 +243,7 @@ export async function POST(request: NextRequest): Promise 90) { + if (reducePercent < 10 || reducePercent > 100) { return NextResponse.json( { success: false, - message: 'Reduce percent must be between 10 and 90', + message: 'Reduce percent must be between 10 and 100', }, { status: 400 } ) } + // If reducing 100%, use the close endpoint logic instead + if (reducePercent === 100) { + // Get Position Manager + const positionManager = await getInitializedPositionManager() + const activeTrades = positionManager.getActiveTrades() + const trade = activeTrades.find(t => t.id === body.tradeId) + + if (!trade) { + return NextResponse.json( + { + success: false, + message: `Position ${body.tradeId} not found`, + }, + { status: 404 } + ) + } + + console.log(`🔴 Closing 100% of position: ${trade.symbol}`) + + // Initialize Drift service + await initializeDriftService() + + // Close entire position (this will automatically cancel all orders) + const closeResult = await closePosition({ + symbol: trade.symbol, + percentToClose: 100, + slippageTolerance: getMergedConfig().slippageTolerance, + }) + + if (!closeResult.success) { + throw new Error(`Failed to close position: ${closeResult.error}`) + } + + console.log(`✅ Position fully closed | P&L: $${closeResult.realizedPnL || 0}`) + console.log(`✅ All TP/SL orders cancelled automatically`) + + return NextResponse.json({ + success: true, + message: `Position closed 100%`, + closedSize: trade.positionSize, + remainingSize: 0, + closePrice: closeResult.closePrice, + realizedPnL: closeResult.realizedPnL, + newTP1: 0, + newTP2: 0, + newSL: 0, + }) + } + // Get current configuration const config = getMergedConfig() diff --git a/instrumentation.ts b/instrumentation.ts new file mode 100644 index 0000000..816b5d5 --- /dev/null +++ b/instrumentation.ts @@ -0,0 +1,18 @@ +/** + * Next.js Instrumentation Hook + * + * This file is automatically called when the Next.js server starts + * Use it to initialize services that need to run on startup + */ + +export async function register() { + if (process.env.NEXT_RUNTIME === 'nodejs') { + console.log('🎯 Server starting - initializing services...') + + // Initialize Position Manager to restore trades from database + const { initializePositionManagerOnStartup } = await import('./lib/startup/init-position-manager') + await initializePositionManagerOnStartup() + + console.log('✅ Server initialization complete') + } +} diff --git a/lib/startup/init-position-manager.ts b/lib/startup/init-position-manager.ts new file mode 100644 index 0000000..c2dce7b --- /dev/null +++ b/lib/startup/init-position-manager.ts @@ -0,0 +1,33 @@ +/** + * Position Manager Startup Initialization + * + * Ensures Position Manager starts monitoring on bot startup + * This prevents orphaned trades when the bot restarts + */ + +import { getInitializedPositionManager } from '../trading/position-manager' + +let initStarted = false + +export async function initializePositionManagerOnStartup() { + if (initStarted) { + return + } + + initStarted = true + + console.log('🚀 Initializing Position Manager on startup...') + + try { + const manager = await getInitializedPositionManager() + const status = manager.getStatus() + + console.log(`✅ Position Manager ready - ${status.activeTradesCount} active trades`) + + if (status.activeTradesCount > 0) { + console.log(`📊 Monitoring: ${status.symbols.join(', ')}`) + } + } catch (error) { + console.error('❌ Failed to initialize Position Manager on startup:', error) + } +} diff --git a/lib/trading/position-manager.ts b/lib/trading/position-manager.ts index 8e4a4b7..7e8441a 100644 --- a/lib/trading/position-manager.ts +++ b/lib/trading/position-manager.ts @@ -155,10 +155,23 @@ export class PositionManager { /** * Remove a trade from monitoring */ - removeTrade(tradeId: string): void { + async removeTrade(tradeId: string): Promise { const trade = this.activeTrades.get(tradeId) if (trade) { console.log(`🗑️ Removing trade: ${trade.symbol}`) + + // Cancel all orders for this symbol (cleanup orphaned orders) + try { + const { cancelAllOrders } = await import('../drift/orders') + const cancelResult = await cancelAllOrders(trade.symbol) + if (cancelResult.success && cancelResult.cancelledCount! > 0) { + console.log(`✅ Cancelled ${cancelResult.cancelledCount} orphaned orders`) + } + } catch (error) { + console.error('❌ Failed to cancel orders during trade removal:', error) + // Continue with removal even if cancel fails + } + this.activeTrades.delete(tradeId) // Stop monitoring if no more trades @@ -474,7 +487,7 @@ export class PositionManager { } } - this.removeTrade(trade.id) + await this.removeTrade(trade.id) console.log(`✅ Position closed | P&L: $${trade.realizedPnL.toFixed(2)} | Reason: ${reason}`) } else { // Partial close (TP1) diff --git a/telegram_command_bot.py b/telegram_command_bot.py index 077c395..8586ef6 100644 --- a/telegram_command_bot.py +++ b/telegram_command_bot.py @@ -232,8 +232,8 @@ async def reduce_command(update: Update, context: ContextTypes.DEFAULT_TYPE): if context.args and len(context.args) > 0: try: reduce_percent = int(context.args[0]) - if reduce_percent < 10 or reduce_percent > 90: - await update.message.reply_text("❌ Reduce percent must be between 10 and 90") + if reduce_percent < 10 or reduce_percent > 100: + await update.message.reply_text("❌ Reduce percent must be between 10 and 100") return except ValueError: await update.message.reply_text("❌ Invalid reduce percent. Usage: /reduce [percent]") @@ -284,8 +284,91 @@ async def reduce_command(update: Update, context: ContextTypes.DEFAULT_TYPE): print(f"❌ Error: {e}", flush=True) await update.message.reply_text(f"❌ Error: {str(e)}") +async def close_command(update: Update, context: ContextTypes.DEFAULT_TYPE): + """Handle /close command - close entire position and cancel all orders""" + + # Only process from YOUR chat + if update.message.chat_id != ALLOWED_CHAT_ID: + await update.message.reply_text("❌ Unauthorized") + return + + print(f"🔴 /close command received", flush=True) + + try: + # First, get the current open position + pos_response = requests.get( + f"{TRADING_BOT_URL}/api/trading/positions", + headers={'Authorization': f'Bearer {API_SECRET_KEY}'}, + timeout=10 + ) + + if not pos_response.ok: + await update.message.reply_text(f"❌ Error fetching positions: {pos_response.status_code}") + return + + pos_data = pos_response.json() + positions = pos_data.get('positions', []) + + if not positions: + await update.message.reply_text("❌ No open positions to close") + return + + if len(positions) > 1: + await update.message.reply_text("❌ Multiple positions open. Specify symbol or use /reduce") + return + + position = positions[0] + symbol = position['symbol'] + direction = position['direction'].upper() + entry = position['entryPrice'] + size = position['currentSize'] + + # Close position at market (100%) + response = requests.post( + f"{TRADING_BOT_URL}/api/trading/close", + headers={'Authorization': f'Bearer {API_SECRET_KEY}'}, + json={'symbol': symbol, 'percentToClose': 100}, + timeout=30 + ) + + print(f"📥 API Response: {response.status_code}", flush=True) + + if not response.ok: + data = response.json() + await update.message.reply_text(f"❌ Error: {data.get('message', 'Unknown error')}") + return + + data = response.json() + + if not data.get('success'): + await update.message.reply_text(f"❌ {data.get('message', 'Failed to close position')}") + return + + # Build success message + close_price = data.get('closePrice', 0) + realized_pnl = data.get('realizedPnL', 0) + + emoji = "💚" if realized_pnl > 0 else "❤️" if realized_pnl < 0 else "💛" + + message = f"{emoji} *Position Closed*\n\n" + message += f"*{symbol} {direction}*\n\n" + message += f"*Entry:* ${entry:.4f}\n" + message += f"*Exit:* ${close_price:.4f}\n" + message += f"*Size:* ${size:.2f}\n\n" + message += f"*P&L:* ${realized_pnl:.2f}\n\n" + message += f"✅ Position closed at market\n" + message += f"✅ All TP/SL orders cancelled" + + await update.message.reply_text(message, parse_mode='Markdown') + + print(f"✅ Position closed: {symbol} | P&L: ${realized_pnl:.2f}", flush=True) + + except Exception as e: + print(f"❌ Error: {e}", flush=True) + await update.message.reply_text(f"❌ Error: {str(e)}") + async def validate_command(update: Update, context: ContextTypes.DEFAULT_TYPE): - """Handle /validate command - check if positions match settings""" + """Handle /validate command - check position consistency""" # Only process from YOUR chat if update.message.chat_id != ALLOWED_CHAT_ID: @@ -434,6 +517,7 @@ def main(): # Add command handlers application.add_handler(CommandHandler("status", status_command)) + application.add_handler(CommandHandler("close", close_command)) application.add_handler(CommandHandler("validate", validate_command)) application.add_handler(CommandHandler("scale", scale_command)) application.add_handler(CommandHandler("reduce", reduce_command))