diff --git a/lib/notifications/telegram.ts b/lib/notifications/telegram.ts new file mode 100644 index 0000000..162c48b --- /dev/null +++ b/lib/notifications/telegram.ts @@ -0,0 +1,109 @@ +/** + * Telegram notification utilities + * + * Sends direct notifications to Telegram bot without requiring n8n + */ + +interface TelegramNotificationOptions { + symbol: string + direction: 'long' | 'short' + entryPrice: number + exitPrice: number + positionSize: number + realizedPnL: number + exitReason: string + holdTimeSeconds: number + maxDrawdown?: number + maxGain?: number +} + +/** + * Send Telegram notification for position closure + */ +export async function sendPositionClosedNotification(options: TelegramNotificationOptions): Promise { + try { + const token = process.env.TELEGRAM_BOT_TOKEN + const chatId = process.env.TELEGRAM_CHAT_ID + + if (!token || !chatId) { + console.log('āš ļø Telegram credentials not configured, skipping notification') + return + } + + const profitEmoji = options.realizedPnL >= 0 ? 'šŸ’š' : 'šŸ”“' + const exitReasonEmoji = getExitReasonEmoji(options.exitReason) + const directionEmoji = options.direction === 'long' ? 'šŸ“ˆ' : 'šŸ“‰' + + const priceChange = ((options.exitPrice - options.entryPrice) / options.entryPrice * 100) * (options.direction === 'long' ? 1 : -1) + const holdTime = formatHoldTime(options.holdTimeSeconds) + + const message = `${exitReasonEmoji} POSITION CLOSED + +${directionEmoji} ${options.symbol} ${options.direction.toUpperCase()} + +šŸ’° P&L: $${options.realizedPnL.toFixed(2)} (${priceChange >= 0 ? '+' : ''}${priceChange.toFixed(2)}%) +šŸ“Š Size: $${options.positionSize.toFixed(2)} + +šŸ“ Entry: $${options.entryPrice.toFixed(2)} +šŸŽÆ Exit: $${options.exitPrice.toFixed(2)} + +ā± Hold Time: ${holdTime} +šŸ”š Exit: ${options.exitReason.toUpperCase()} +${options.maxGain ? `\nšŸ“ˆ Max Gain: +${options.maxGain.toFixed(2)}%` : ''} +${options.maxDrawdown ? `\nšŸ“‰ Max Drawdown: -${options.maxDrawdown.toFixed(2)}%` : ''}` + + const url = `https://api.telegram.org/bot${token}/sendMessage` + const response = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + chat_id: chatId, + text: message, + parse_mode: 'HTML' + }) + }) + + if (!response.ok) { + const errorData = await response.json() + console.error('āŒ Telegram notification failed:', errorData) + } else { + console.log('āœ… Telegram notification sent') + } + } catch (error) { + console.error('āŒ Error sending Telegram notification:', error) + // Don't throw - notification failure shouldn't break position closing + } +} + +/** + * Get emoji for exit reason + */ +function getExitReasonEmoji(reason: string): string { + const reasonUpper = reason.toUpperCase() + + if (reasonUpper.includes('TP1')) return 'šŸŽÆ' + if (reasonUpper.includes('TP2')) return 'šŸŽÆšŸŽÆ' + if (reasonUpper.includes('SL') || reasonUpper.includes('STOP')) return 'šŸ›‘' + if (reasonUpper.includes('MANUAL')) return 'šŸ‘¤' + if (reasonUpper.includes('EMERGENCY')) return '🚨' + if (reasonUpper.includes('GHOST')) return 'šŸ‘»' + + return 'āœ…' +} + +/** + * Format hold time in human-readable format + */ +function formatHoldTime(seconds: number): string { + const hours = Math.floor(seconds / 3600) + const minutes = Math.floor((seconds % 3600) / 60) + const secs = seconds % 60 + + if (hours > 0) { + return `${hours}h ${minutes}m` + } else if (minutes > 0) { + return `${minutes}m ${secs}s` + } else { + return `${secs}s` + } +} diff --git a/lib/trading/position-manager.ts b/lib/trading/position-manager.ts index b684d07..dae28a4 100644 --- a/lib/trading/position-manager.ts +++ b/lib/trading/position-manager.ts @@ -9,6 +9,7 @@ import { closePosition } from '../drift/orders' import { getPythPriceMonitor, PriceUpdate } from '../pyth/price-monitor' import { getMergedConfig, TradingConfig, getMarketConfig } from '../../config/trading' import { updateTradeExit, updateTradeState, getOpenTrades } from '../database/trades' +import { sendPositionClosedNotification } from '../notifications/telegram' export interface ActiveTrade { id: string @@ -328,6 +329,20 @@ export class PositionManager { maxAdversePrice: trade.maxAdversePrice, }) console.log(`šŸ’¾ Ghost closure saved to database`) + + // Send Telegram notification for ghost closure + await sendPositionClosedNotification({ + symbol: trade.symbol, + direction: trade.direction, + entryPrice: trade.entryPrice, + exitPrice: trade.lastPrice, + positionSize: trade.currentSize, + realizedPnL: estimatedPnL, + exitReason: reason, // e.g., "Ghost position cleanup", "Layer 2: Ghost detected via Drift API" + holdTimeSeconds: Math.floor((Date.now() - trade.entryTime) / 1000), + maxDrawdown: Math.abs(Math.min(0, trade.maxAdverseExcursion)), + maxGain: Math.max(0, trade.maxFavorableExcursion), + }) } catch (dbError) { console.error('āŒ Failed to save ghost closure:', dbError) } @@ -1214,6 +1229,20 @@ export class PositionManager { await this.removeTrade(trade.id) console.log(`āœ… Position closed | P&L: $${trade.realizedPnL.toFixed(2)} | Reason: ${reason}`) + + // Send Telegram notification + await sendPositionClosedNotification({ + symbol: trade.symbol, + direction: trade.direction, + entryPrice: trade.entryPrice, + exitPrice: result.closePrice || currentPrice, + positionSize: trade.positionSize, + realizedPnL: trade.realizedPnL, + exitReason: reason, + holdTimeSeconds: Math.floor((Date.now() - trade.entryTime) / 1000), + maxDrawdown: Math.abs(Math.min(0, trade.maxAdverseExcursion)), + maxGain: Math.max(0, trade.maxFavorableExcursion), + }) } else { // Partial close (TP1) trade.realizedPnL += result.realizedPnL || 0