feat: Add Telegram notifications for position closures
Implemented direct Telegram notifications when Position Manager closes positions: - New helper: lib/notifications/telegram.ts with sendPositionClosedNotification() - Integrated into Position Manager's executeExit() for all closure types - Also sends notifications for ghost position cleanups Notification includes: - Symbol, direction, entry/exit prices - P&L amount and percentage - Position size and hold time - Exit reason (TP1, TP2, SL, manual, ghost cleanup, etc.) - MAE/MFE stats (max gain/drawdown during trade) User request: Receive P&L notifications on position closures via Telegram bot Previously: Only opening notifications via n8n workflow Now: All closures (TP/SL/manual/ghost) send notifications directly
This commit is contained in:
109
lib/notifications/telegram.ts
Normal file
109
lib/notifications/telegram.ts
Normal file
@@ -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<void> {
|
||||||
|
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`
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ import { closePosition } from '../drift/orders'
|
|||||||
import { getPythPriceMonitor, PriceUpdate } from '../pyth/price-monitor'
|
import { getPythPriceMonitor, PriceUpdate } from '../pyth/price-monitor'
|
||||||
import { getMergedConfig, TradingConfig, getMarketConfig } from '../../config/trading'
|
import { getMergedConfig, TradingConfig, getMarketConfig } from '../../config/trading'
|
||||||
import { updateTradeExit, updateTradeState, getOpenTrades } from '../database/trades'
|
import { updateTradeExit, updateTradeState, getOpenTrades } from '../database/trades'
|
||||||
|
import { sendPositionClosedNotification } from '../notifications/telegram'
|
||||||
|
|
||||||
export interface ActiveTrade {
|
export interface ActiveTrade {
|
||||||
id: string
|
id: string
|
||||||
@@ -328,6 +329,20 @@ export class PositionManager {
|
|||||||
maxAdversePrice: trade.maxAdversePrice,
|
maxAdversePrice: trade.maxAdversePrice,
|
||||||
})
|
})
|
||||||
console.log(`💾 Ghost closure saved to database`)
|
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) {
|
} catch (dbError) {
|
||||||
console.error('❌ Failed to save ghost closure:', dbError)
|
console.error('❌ Failed to save ghost closure:', dbError)
|
||||||
}
|
}
|
||||||
@@ -1214,6 +1229,20 @@ export class PositionManager {
|
|||||||
|
|
||||||
await this.removeTrade(trade.id)
|
await this.removeTrade(trade.id)
|
||||||
console.log(`✅ Position closed | P&L: $${trade.realizedPnL.toFixed(2)} | Reason: ${reason}`)
|
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 {
|
} else {
|
||||||
// Partial close (TP1)
|
// Partial close (TP1)
|
||||||
trade.realizedPnL += result.realizedPnL || 0
|
trade.realizedPnL += result.realizedPnL || 0
|
||||||
|
|||||||
Reference in New Issue
Block a user