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:
4
.env
4
.env
@@ -388,7 +388,7 @@ NEW_RELIC_LICENSE_KEY=
|
|||||||
# - PHASE_2_COMPLETE_REPORT.md - Feature summary
|
# - PHASE_2_COMPLETE_REPORT.md - Feature summary
|
||||||
|
|
||||||
USE_TRAILING_STOP=true
|
USE_TRAILING_STOP=true
|
||||||
TRAILING_STOP_PERCENT=0.3
|
TRAILING_STOP_PERCENT=0.5
|
||||||
TRAILING_STOP_ACTIVATION=0.4
|
TRAILING_STOP_ACTIVATION=0.4
|
||||||
MIN_QUALITY_SCORE=60
|
MIN_QUALITY_SCORE=60
|
||||||
SOLANA_ENABLED=true
|
SOLANA_ENABLED=true
|
||||||
@@ -411,5 +411,5 @@ TRAILING_STOP_MIN_PERCENT=0.25
|
|||||||
TRAILING_STOP_MAX_PERCENT=0.9
|
TRAILING_STOP_MAX_PERCENT=0.9
|
||||||
USE_PERCENTAGE_SIZE=false
|
USE_PERCENTAGE_SIZE=false
|
||||||
|
|
||||||
BREAKEVEN_TRIGGER_PERCENT=0
|
BREAKEVEN_TRIGGER_PERCENT=0.4
|
||||||
ATR_MULTIPLIER_FOR_TP2=2
|
ATR_MULTIPLIER_FOR_TP2=2
|
||||||
@@ -620,6 +620,9 @@ export async function POST(request: NextRequest): Promise<NextResponse<ExecuteTr
|
|||||||
tp2Price,
|
tp2Price,
|
||||||
emergencyStopPrice,
|
emergencyStopPrice,
|
||||||
currentSize: positionSizeUSD,
|
currentSize: positionSizeUSD,
|
||||||
|
originalPositionSize: positionSizeUSD, // Store original size for accurate P&L
|
||||||
|
takeProfitPrice1: tp1Price,
|
||||||
|
takeProfitPrice2: tp2Price,
|
||||||
tp1Hit: false,
|
tp1Hit: false,
|
||||||
tp2Hit: false,
|
tp2Hit: false,
|
||||||
slMovedToBreakeven: false,
|
slMovedToBreakeven: false,
|
||||||
|
|||||||
@@ -117,6 +117,9 @@ export async function POST(request: NextRequest): Promise<NextResponse> {
|
|||||||
tp2Price: tp2Price,
|
tp2Price: tp2Price,
|
||||||
emergencyStopPrice: emergencyStopPrice,
|
emergencyStopPrice: emergencyStopPrice,
|
||||||
currentSize: positionSizeUSD,
|
currentSize: positionSizeUSD,
|
||||||
|
originalPositionSize: positionSizeUSD, // Store original size for P&L
|
||||||
|
takeProfitPrice1: tp1Price,
|
||||||
|
takeProfitPrice2: tp2Price,
|
||||||
tp1Hit: false,
|
tp1Hit: false,
|
||||||
tp2Hit: false,
|
tp2Hit: false,
|
||||||
slMovedToBreakeven: false,
|
slMovedToBreakeven: false,
|
||||||
|
|||||||
@@ -170,6 +170,9 @@ export async function POST(request: NextRequest): Promise<NextResponse<TestTrade
|
|||||||
tp2Price,
|
tp2Price,
|
||||||
emergencyStopPrice,
|
emergencyStopPrice,
|
||||||
currentSize: positionSizeUSD,
|
currentSize: positionSizeUSD,
|
||||||
|
originalPositionSize: positionSizeUSD, // Store original size for P&L
|
||||||
|
takeProfitPrice1: tp1Price,
|
||||||
|
takeProfitPrice2: tp2Price,
|
||||||
tp1Hit: false,
|
tp1Hit: false,
|
||||||
tp2Hit: false,
|
tp2Hit: false,
|
||||||
slMovedToBreakeven: false,
|
slMovedToBreakeven: false,
|
||||||
|
|||||||
@@ -221,6 +221,9 @@ export async function POST(request: NextRequest): Promise<NextResponse<TestTrade
|
|||||||
tp2Price,
|
tp2Price,
|
||||||
emergencyStopPrice,
|
emergencyStopPrice,
|
||||||
currentSize: actualPositionSizeUSD,
|
currentSize: actualPositionSizeUSD,
|
||||||
|
originalPositionSize: actualPositionSizeUSD, // Store original size for P&L
|
||||||
|
takeProfitPrice1: tp1Price,
|
||||||
|
takeProfitPrice2: tp2Price,
|
||||||
tp1Hit: false,
|
tp1Hit: false,
|
||||||
tp2Hit: false,
|
tp2Hit: false,
|
||||||
slMovedToBreakeven: false,
|
slMovedToBreakeven: false,
|
||||||
|
|||||||
@@ -32,6 +32,9 @@ export interface ActiveTrade {
|
|||||||
|
|
||||||
// State
|
// State
|
||||||
currentSize: number // Changes after TP1
|
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
|
tp1Hit: boolean
|
||||||
tp2Hit: boolean
|
tp2Hit: boolean
|
||||||
slMovedToBreakeven: boolean
|
slMovedToBreakeven: boolean
|
||||||
@@ -119,6 +122,9 @@ export class PositionManager {
|
|||||||
tp2Price: dbTrade.takeProfit2Price,
|
tp2Price: dbTrade.takeProfit2Price,
|
||||||
emergencyStopPrice: dbTrade.stopLossPrice * (dbTrade.direction === 'long' ? 0.98 : 1.02),
|
emergencyStopPrice: dbTrade.stopLossPrice * (dbTrade.direction === 'long' ? 0.98 : 1.02),
|
||||||
currentSize: pmState?.currentSize ?? dbTrade.positionSizeUSD,
|
currentSize: pmState?.currentSize ?? dbTrade.positionSizeUSD,
|
||||||
|
originalPositionSize: dbTrade.positionSizeUSD, // Store original size for P&L
|
||||||
|
takeProfitPrice1: dbTrade.takeProfit1Price,
|
||||||
|
takeProfitPrice2: dbTrade.takeProfit2Price,
|
||||||
tp1Hit: pmState?.tp1Hit ?? false,
|
tp1Hit: pmState?.tp1Hit ?? false,
|
||||||
tp2Hit: pmState?.tp2Hit ?? false,
|
tp2Hit: pmState?.tp2Hit ?? false,
|
||||||
slMovedToBreakeven: pmState?.slMovedToBreakeven ?? false,
|
slMovedToBreakeven: pmState?.slMovedToBreakeven ?? false,
|
||||||
@@ -155,6 +161,84 @@ export class PositionManager {
|
|||||||
this.initialized = true
|
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
|
* Add a new trade to monitor
|
||||||
*/
|
*/
|
||||||
@@ -295,15 +379,17 @@ export class PositionManager {
|
|||||||
private async handleExternalClosure(trade: ActiveTrade, reason: string): Promise<void> {
|
private async handleExternalClosure(trade: ActiveTrade, reason: string): Promise<void> {
|
||||||
console.log(`🧹 Handling external closure: ${trade.symbol} (${reason})`)
|
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(
|
const profitPercent = this.calculateProfitPercent(
|
||||||
trade.entryPrice,
|
trade.entryPrice,
|
||||||
trade.lastPrice,
|
trade.lastPrice,
|
||||||
trade.direction
|
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
|
// Remove from monitoring FIRST to prevent race conditions
|
||||||
const tradeId = trade.id
|
const tradeId = trade.id
|
||||||
@@ -507,8 +593,21 @@ export class PositionManager {
|
|||||||
const reductionPercent = ((trackedSizeUSD - positionSizeUSD) / trade.positionSize) * 100
|
const reductionPercent = ((trackedSizeUSD - positionSizeUSD) / trade.positionSize) * 100
|
||||||
|
|
||||||
if (!trade.tp1Hit && reductionPercent >= (this.config.takeProfit1SizePercent * 0.8)) {
|
if (!trade.tp1Hit && reductionPercent >= (this.config.takeProfit1SizePercent * 0.8)) {
|
||||||
// TP1 fired (should be ~75% reduction)
|
// CRITICAL: Validate price is actually at TP1 before marking as TP1 hit
|
||||||
console.log(`🎯 TP1 detected as filled! Reduction: ${reductionPercent.toFixed(1)}%`)
|
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.tp1Hit = true
|
||||||
trade.currentSize = positionSizeUSD
|
trade.currentSize = positionSizeUSD
|
||||||
|
|
||||||
@@ -660,9 +759,9 @@ export class PositionManager {
|
|||||||
// Position Manager detected it, causing zero P&L bug
|
// Position Manager detected it, causing zero P&L bug
|
||||||
// HOWEVER: If this was a phantom trade (extreme size mismatch), set P&L to 0
|
// 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
|
// 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
|
// - 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
|
// 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
|
// 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)
|
// Include any previously realized profit (e.g., from TP1 partial close)
|
||||||
const previouslyRealized = trade.realizedPnL
|
const previouslyRealized = trade.realizedPnL
|
||||||
|
|
||||||
let runnerRealized = 0
|
let runnerRealized = 0
|
||||||
let runnerProfitPercent = 0
|
let runnerProfitPercent = 0
|
||||||
if (!wasPhantom) {
|
if (!wasPhantom) {
|
||||||
@@ -693,10 +793,10 @@ export class PositionManager {
|
|||||||
)
|
)
|
||||||
runnerRealized = (sizeForPnL * runnerProfitPercent) / 100
|
runnerRealized = (sizeForPnL * runnerProfitPercent) / 100
|
||||||
}
|
}
|
||||||
|
|
||||||
const totalRealizedPnL = previouslyRealized + runnerRealized
|
const totalRealizedPnL = previouslyRealized + runnerRealized
|
||||||
trade.realizedPnL = totalRealizedPnL
|
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
|
// Determine exit reason from trade state and P&L
|
||||||
if (trade.tp2Hit) {
|
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 {
|
private shouldStopLoss(price: number, trade: ActiveTrade): boolean {
|
||||||
if (trade.direction === 'long') {
|
if (trade.direction === 'long') {
|
||||||
return price <= trade.stopLossPrice
|
return price <= trade.stopLossPrice
|
||||||
|
|||||||
Reference in New Issue
Block a user