critical: Fix P&L calculation and TP1 false detection bugs
- Add originalPositionSize tracking to prevent stale size usage - Add price validation to TP1 detection (prevents manual closes misidentified as TP1) - Fix external closure P&L to use originalPositionSize not currentSize - Add handleManualClosure method for proper exit reason detection - Add isPriceAtTarget helper for TP/SL price validation (0.2% tolerance) - Update all ActiveTrade creation points (execute, test, sync-positions, test-db) Bug fixes: - Manual close at 42.34 was detected as TP1 (target 40.71) - FIXED - P&L showed -$1.71 instead of actual -$2.92 - FIXED - Exit reason showed SL instead of manual - FIXED Root cause: Position Manager detected size reduction without validating price was actually at TP1 level. Used stale currentSize for P&L calculation. Files modified: - lib/trading/position-manager.ts (core fixes) - app/api/trading/execute/route.ts - app/api/trading/test/route.ts - app/api/trading/sync-positions/route.ts - app/api/trading/test-db/route.ts
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user