|
|
|
|
@@ -32,6 +32,9 @@ export interface ActiveTrade {
|
|
|
|
|
|
|
|
|
|
// State
|
|
|
|
|
currentSize: number // Changes after TP1
|
|
|
|
|
originalPositionSize: number // Original entry size for accurate P&L on manual closes
|
|
|
|
|
takeProfitPrice1?: number // TP1 price for validation
|
|
|
|
|
takeProfitPrice2?: number // TP2 price for validation
|
|
|
|
|
tp1Hit: boolean
|
|
|
|
|
tp2Hit: boolean
|
|
|
|
|
slMovedToBreakeven: boolean
|
|
|
|
|
@@ -119,6 +122,9 @@ export class PositionManager {
|
|
|
|
|
tp2Price: dbTrade.takeProfit2Price,
|
|
|
|
|
emergencyStopPrice: dbTrade.stopLossPrice * (dbTrade.direction === 'long' ? 0.98 : 1.02),
|
|
|
|
|
currentSize: pmState?.currentSize ?? dbTrade.positionSizeUSD,
|
|
|
|
|
originalPositionSize: dbTrade.positionSizeUSD, // Store original size for P&L
|
|
|
|
|
takeProfitPrice1: dbTrade.takeProfit1Price,
|
|
|
|
|
takeProfitPrice2: dbTrade.takeProfit2Price,
|
|
|
|
|
tp1Hit: pmState?.tp1Hit ?? false,
|
|
|
|
|
tp2Hit: pmState?.tp2Hit ?? false,
|
|
|
|
|
slMovedToBreakeven: pmState?.slMovedToBreakeven ?? false,
|
|
|
|
|
@@ -155,6 +161,84 @@ export class PositionManager {
|
|
|
|
|
this.initialized = true
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Handle manual closures with proper exit reason detection
|
|
|
|
|
* Called when size reduction detected but price NOT at TP1 level
|
|
|
|
|
*/
|
|
|
|
|
private async handleManualClosure(
|
|
|
|
|
trade: ActiveTrade,
|
|
|
|
|
currentPrice: number,
|
|
|
|
|
remainingSize: number
|
|
|
|
|
): Promise<void> {
|
|
|
|
|
console.log(`👤 Processing manual closure for ${trade.symbol}`)
|
|
|
|
|
|
|
|
|
|
// Determine exit reason based on price levels
|
|
|
|
|
let exitReason: 'TP1' | 'TP2' | 'SL' | 'SOFT_SL' | 'HARD_SL' | 'manual' | 'emergency' = 'manual'
|
|
|
|
|
const profitPercent = this.calculateProfitPercent(trade.entryPrice, currentPrice, trade.direction)
|
|
|
|
|
|
|
|
|
|
// Check if price is at TP2 or SL levels
|
|
|
|
|
const isAtTP2 = this.isPriceAtTarget(currentPrice, trade.takeProfitPrice2 || 0)
|
|
|
|
|
const isAtSL = this.isPriceAtTarget(currentPrice, trade.stopLossPrice || 0)
|
|
|
|
|
|
|
|
|
|
if (isAtTP2 && trade.tp1Hit) {
|
|
|
|
|
exitReason = 'TP2'
|
|
|
|
|
console.log(`✅ Manual closure was TP2 (price at target)`)
|
|
|
|
|
} else if (isAtSL) {
|
|
|
|
|
exitReason = 'SL'
|
|
|
|
|
console.log(`🛑 Manual closure was SL (price at target)`)
|
|
|
|
|
} else {
|
|
|
|
|
console.log(`👤 Manual closure confirmed (price not at any target)`)
|
|
|
|
|
console.log(` Current: $${currentPrice.toFixed(4)}, TP1: $${trade.takeProfitPrice1?.toFixed(4)}, TP2: $${trade.takeProfitPrice2?.toFixed(4)}, SL: $${trade.stopLossPrice?.toFixed(4)}`)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// CRITICAL: Calculate P&L using originalPositionSize for accuracy
|
|
|
|
|
const realizedPnL = (trade.originalPositionSize * profitPercent) / 100
|
|
|
|
|
console.log(`💰 Manual close P&L: ${profitPercent.toFixed(2)}% on $${trade.originalPositionSize.toFixed(2)} = $${realizedPnL.toFixed(2)}`)
|
|
|
|
|
|
|
|
|
|
// Remove from monitoring FIRST (prevent race conditions)
|
|
|
|
|
this.activeTrades.delete(trade.id)
|
|
|
|
|
|
|
|
|
|
// Update database
|
|
|
|
|
try {
|
|
|
|
|
await updateTradeExit({
|
|
|
|
|
positionId: trade.positionId,
|
|
|
|
|
exitPrice: currentPrice,
|
|
|
|
|
exitReason,
|
|
|
|
|
realizedPnL,
|
|
|
|
|
exitOrderTx: 'Manual closure detected',
|
|
|
|
|
holdTimeSeconds: Math.floor((Date.now() - trade.entryTime) / 1000),
|
|
|
|
|
maxDrawdown: Math.abs(Math.min(0, trade.maxAdverseExcursion)),
|
|
|
|
|
maxGain: Math.max(0, trade.maxFavorableExcursion),
|
|
|
|
|
maxFavorableExcursion: trade.maxFavorableExcursion,
|
|
|
|
|
maxAdverseExcursion: trade.maxAdverseExcursion,
|
|
|
|
|
maxFavorablePrice: trade.maxFavorablePrice,
|
|
|
|
|
maxAdversePrice: trade.maxAdversePrice,
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
console.log(`✅ Manual closure recorded: ${trade.symbol} ${exitReason} P&L: $${realizedPnL.toFixed(2)}`)
|
|
|
|
|
|
|
|
|
|
// Send Telegram notification
|
|
|
|
|
await sendPositionClosedNotification({
|
|
|
|
|
symbol: trade.symbol,
|
|
|
|
|
direction: trade.direction,
|
|
|
|
|
entryPrice: trade.entryPrice,
|
|
|
|
|
exitPrice: currentPrice,
|
|
|
|
|
positionSize: trade.originalPositionSize,
|
|
|
|
|
realizedPnL,
|
|
|
|
|
exitReason,
|
|
|
|
|
holdTimeSeconds: Math.floor((Date.now() - trade.entryTime) / 1000),
|
|
|
|
|
maxGain: Math.max(0, trade.maxFavorableExcursion),
|
|
|
|
|
maxDrawdown: Math.abs(Math.min(0, trade.maxAdverseExcursion)),
|
|
|
|
|
})
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error('❌ Failed to save manual closure:', error)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (this.activeTrades.size === 0) {
|
|
|
|
|
this.stopMonitoring()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Add a new trade to monitor
|
|
|
|
|
*/
|
|
|
|
|
@@ -295,15 +379,17 @@ export class PositionManager {
|
|
|
|
|
private async handleExternalClosure(trade: ActiveTrade, reason: string): Promise<void> {
|
|
|
|
|
console.log(`🧹 Handling external closure: ${trade.symbol} (${reason})`)
|
|
|
|
|
|
|
|
|
|
// Calculate approximate P&L using last known price
|
|
|
|
|
// CRITICAL: Calculate P&L using originalPositionSize for accuracy
|
|
|
|
|
// currentSize may be stale if Drift propagation was interrupted
|
|
|
|
|
const profitPercent = this.calculateProfitPercent(
|
|
|
|
|
trade.entryPrice,
|
|
|
|
|
trade.lastPrice,
|
|
|
|
|
trade.direction
|
|
|
|
|
)
|
|
|
|
|
const estimatedPnL = (trade.currentSize * profitPercent) / 100
|
|
|
|
|
const sizeForPnL = trade.originalPositionSize // Use original, not currentSize
|
|
|
|
|
const estimatedPnL = (sizeForPnL * profitPercent) / 100
|
|
|
|
|
|
|
|
|
|
console.log(`💰 Estimated P&L: ${profitPercent.toFixed(2)}% → $${estimatedPnL.toFixed(2)}`)
|
|
|
|
|
console.log(`💰 Estimated P&L: ${profitPercent.toFixed(2)}% on $${sizeForPnL.toFixed(2)} → $${estimatedPnL.toFixed(2)}`)
|
|
|
|
|
|
|
|
|
|
// Remove from monitoring FIRST to prevent race conditions
|
|
|
|
|
const tradeId = trade.id
|
|
|
|
|
@@ -507,8 +593,21 @@ export class PositionManager {
|
|
|
|
|
const reductionPercent = ((trackedSizeUSD - positionSizeUSD) / trade.positionSize) * 100
|
|
|
|
|
|
|
|
|
|
if (!trade.tp1Hit && reductionPercent >= (this.config.takeProfit1SizePercent * 0.8)) {
|
|
|
|
|
// TP1 fired (should be ~75% reduction)
|
|
|
|
|
console.log(`🎯 TP1 detected as filled! Reduction: ${reductionPercent.toFixed(1)}%`)
|
|
|
|
|
// CRITICAL: Validate price is actually at TP1 before marking as TP1 hit
|
|
|
|
|
const isPriceAtTP1 = this.isPriceAtTarget(currentPrice, trade.takeProfitPrice1 || 0, 0.002)
|
|
|
|
|
|
|
|
|
|
if (!isPriceAtTP1) {
|
|
|
|
|
console.log(`⚠️ Size reduction detected (${reductionPercent.toFixed(1)}%) but price NOT at TP1`)
|
|
|
|
|
console.log(` Current: $${currentPrice.toFixed(4)}, TP1: $${trade.takeProfitPrice1?.toFixed(4) || 'N/A'}`)
|
|
|
|
|
console.log(` This is likely a MANUAL CLOSE or external order, not TP1`)
|
|
|
|
|
|
|
|
|
|
// Handle as external closure with proper exit reason detection
|
|
|
|
|
await this.handleManualClosure(trade, currentPrice, positionSizeUSD)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// TP1 fired (price validated at target)
|
|
|
|
|
console.log(`🎯 TP1 detected as filled! Reduction: ${reductionPercent.toFixed(1)}%, price at TP1 target`)
|
|
|
|
|
trade.tp1Hit = true
|
|
|
|
|
trade.currentSize = positionSizeUSD
|
|
|
|
|
|
|
|
|
|
@@ -660,9 +759,9 @@ export class PositionManager {
|
|
|
|
|
// Position Manager detected it, causing zero P&L bug
|
|
|
|
|
// HOWEVER: If this was a phantom trade (extreme size mismatch), set P&L to 0
|
|
|
|
|
// CRITICAL FIX: Use tp1Hit flag to determine which size to use for P&L calculation
|
|
|
|
|
// - If tp1Hit=false: First closure, calculate on full position size
|
|
|
|
|
// - If tp1Hit=false: First closure, calculate on full position size (use originalPositionSize)
|
|
|
|
|
// - If tp1Hit=true: Runner closure, calculate on tracked remaining size
|
|
|
|
|
const sizeForPnL = trade.tp1Hit ? trade.currentSize : trade.positionSize
|
|
|
|
|
const sizeForPnL = trade.tp1Hit ? trade.currentSize : trade.originalPositionSize
|
|
|
|
|
|
|
|
|
|
// Check if this was a phantom trade by looking at the last known on-chain size
|
|
|
|
|
// If last on-chain size was <50% of expected, this is a phantom
|
|
|
|
|
@@ -683,6 +782,7 @@ export class PositionManager {
|
|
|
|
|
|
|
|
|
|
// Include any previously realized profit (e.g., from TP1 partial close)
|
|
|
|
|
const previouslyRealized = trade.realizedPnL
|
|
|
|
|
|
|
|
|
|
let runnerRealized = 0
|
|
|
|
|
let runnerProfitPercent = 0
|
|
|
|
|
if (!wasPhantom) {
|
|
|
|
|
@@ -693,10 +793,10 @@ export class PositionManager {
|
|
|
|
|
)
|
|
|
|
|
runnerRealized = (sizeForPnL * runnerProfitPercent) / 100
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const totalRealizedPnL = previouslyRealized + runnerRealized
|
|
|
|
|
trade.realizedPnL = totalRealizedPnL
|
|
|
|
|
console.log(` Realized P&L snapshot → Previous: $${previouslyRealized.toFixed(2)} | Runner: $${runnerRealized.toFixed(2)} (Δ${runnerProfitPercent.toFixed(2)}%) | Total: $${totalRealizedPnL.toFixed(2)}`)
|
|
|
|
|
console.log(` Realized P&L snapshot → Previous: $${previouslyRealized.toFixed(2)} | Runner: $${runnerRealized.toFixed(2)} (Δ${runnerProfitPercent.toFixed(2)}%) on $${sizeForPnL.toFixed(2)} | Total: $${totalRealizedPnL.toFixed(2)}`)
|
|
|
|
|
|
|
|
|
|
// Determine exit reason from trade state and P&L
|
|
|
|
|
if (trade.tp2Hit) {
|
|
|
|
|
@@ -1324,6 +1424,16 @@ export class PositionManager {
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Check if current price is at a target price within tolerance
|
|
|
|
|
* Used to validate TP/SL hits vs manual closes
|
|
|
|
|
*/
|
|
|
|
|
private isPriceAtTarget(currentPrice: number, targetPrice: number, tolerance: number = 0.002): boolean {
|
|
|
|
|
if (!targetPrice || targetPrice === 0) return false
|
|
|
|
|
const diff = Math.abs(currentPrice - targetPrice) / targetPrice
|
|
|
|
|
return diff <= tolerance
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private shouldStopLoss(price: number, trade: ActiveTrade): boolean {
|
|
|
|
|
if (trade.direction === 'long') {
|
|
|
|
|
return price <= trade.stopLossPrice
|
|
|
|
|
|