fix: Correct TP1 detection for on-chain order fills

Problem: When TP1 order fills on-chain and runner closes quickly,
Position Manager detects entire position gone but doesn't know TP1 filled.
Result: Marks trade as 'SL' instead of 'TP1', closes 100% instead of partial.

Root cause: Position Manager monitoring loop only knows about trade state
flags (tp1Hit), not actual Drift order fill history. When both TP1 and
runner close before next monitoring cycle, tp1Hit=false but position gone.

Fix: Use profit percentage to infer exit reason instead of trade flags
- Profit >1.2%: TP2 range
- Profit 0.3-1.2%: TP1 range
- Profit <0.3%: SL/breakeven range

Always calculate P&L on full originalPositionSize for external closures.
Exit reason logic determines what actually triggered based on P&L amount.

Example from Nov 19 08:40 CET trade:
- Entry $140.17, Exit $140.85 = 0.48% profit
- Old: Marked as 'SL' (tp1Hit=false, didn't know TP1 filled)
- New: Will mark as 'TP1' (profit in TP1 range)

Lines changed: lib/trading/position-manager.ts:760-835
This commit is contained in:
mindesbunister
2025-11-19 09:03:12 +01:00
parent 120a4b499e
commit de57c9634c

View File

@@ -760,10 +760,10 @@ export class PositionManager {
// trade.currentSize may already be 0 if on-chain orders closed the position before // trade.currentSize may already be 0 if on-chain orders closed the position before
// 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
// - If tp1Hit=false: First closure, calculate on full position size (use originalPositionSize) // Use full position size for P&L calculation since we don't know if TP1 filled
// - If tp1Hit=true: Runner closure, calculate on tracked remaining size // The exit reason logic will determine TP1 vs SL based on profit amount
const sizeForPnL = trade.tp1Hit ? trade.currentSize : trade.originalPositionSize const sizeForPnL = 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
@@ -772,20 +772,15 @@ export class PositionManager {
console.log(`📊 External closure detected - Position size tracking:`) console.log(`📊 External closure detected - Position size tracking:`)
console.log(` Original size: $${trade.positionSize.toFixed(2)}`) console.log(` Original size: $${trade.positionSize.toFixed(2)}`)
console.log(` Tracked current size: $${trade.currentSize.toFixed(2)}`) console.log(` Tracked current size: $${trade.currentSize.toFixed(2)}`)
console.log(` TP1 hit: ${trade.tp1Hit}`) console.log(` Using for P&L calc: $${sizeForPnL.toFixed(2)} (full position - exit reason will determine TP1 vs SL)`)
console.log(` Using for P&L calc: $${sizeForPnL.toFixed(2)} (${trade.tp1Hit ? 'runner' : 'full position'})`)
if (wasPhantom) { if (wasPhantom) {
console.log(` ⚠️ PHANTOM TRADE: Setting P&L to 0 (size mismatch >50%)`) console.log(` ⚠️ PHANTOM TRADE: Setting P&L to 0 (size mismatch >50%)`)
} }
// Determine exit reason based on TP flags and realized P&L
// CRITICAL: Use trade state flags, not current price (on-chain orders filled in the past!)
let exitReason: 'TP1' | 'TP2' | 'SL' | 'SOFT_SL' | 'HARD_SL' = 'SL'
// CRITICAL: Calculate P&L for THIS close only, do NOT add previouslyRealized // CRITICAL: Calculate P&L for THIS close only, do NOT add previouslyRealized
// Previous bug: Added trade.realizedPnL which could be from prior detection cycles // Previous bug: Added trade.realizedPnL which could be from prior detection cycles
// This caused 10x P&L inflation when same trade detected multiple times // This caused 10x P&L inflation when same trade detected multiple times
// FIX: Calculate ONLY the runner P&L for this specific closure // FIX: Calculate ONLY the P&L for this specific closure
let runnerRealized = 0 let runnerRealized = 0
let runnerProfitPercent = 0 let runnerProfitPercent = 0
if (!wasPhantom) { if (!wasPhantom) {
@@ -797,33 +792,25 @@ export class PositionManager {
runnerRealized = (sizeForPnL * runnerProfitPercent) / 100 runnerRealized = (sizeForPnL * runnerProfitPercent) / 100
} }
// For external closures, we DON'T know if TP1 already hit, so calculate full position P&L
// Database will have correct previouslyRealized if TP1 was hit
// For external closures, we DON'T know if TP1 already hit, so calculate full position P&L
// Database will have correct previouslyRealized if TP1 was hit
const totalRealizedPnL = runnerRealized const totalRealizedPnL = runnerRealized
console.log(` External closure P&L → ${runnerProfitPercent.toFixed(2)}% on $${sizeForPnL.toFixed(2)} = $${totalRealizedPnL.toFixed(2)}`) console.log(` External closure P&L → ${runnerProfitPercent.toFixed(2)}% on $${sizeForPnL.toFixed(2)} = $${totalRealizedPnL.toFixed(2)}`)
// Determine exit reason from P&L percentage
// Use actual profit percent to determine what order filled
let exitReason: 'TP1' | 'TP2' | 'SL' | 'SOFT_SL' | 'HARD_SL' = 'SL'
// Determine exit reason from trade state and P&L if (runnerProfitPercent > 0.3) {
if (trade.tp2Hit) { // Positive profit - was a TP order
// TP2 was hit, full position closed (runner stopped or hit target) if (runnerProfitPercent >= 1.2) {
exitReason = 'TP2' // Large profit (>1.2%) - TP2 range
} else if (trade.tp1Hit) { exitReason = 'TP2'
// TP1 was hit, position should be 25% size, but now fully closed } else {
// This means either TP2 filled or runner got stopped out // Moderate profit (0.3-1.2%) - TP1 range
exitReason = totalRealizedPnL > 0 ? 'TP2' : 'SL'
} else {
// No TPs hit yet - either SL or TP1 filled just now
// Use P&L to determine: positive = TP, negative = SL
if (totalRealizedPnL > trade.positionSize * 0.005) {
// More than 0.5% profit - must be TP1
exitReason = 'TP1' exitReason = 'TP1'
} else if (totalRealizedPnL < 0) {
// Loss - must be SL
exitReason = 'SL'
} }
// else: small profit/loss near breakeven, default to SL (could be manual close) } else {
// Negative or tiny profit - was SL
exitReason = 'SL'
} }
// Update database - CRITICAL: Only update once per trade! // Update database - CRITICAL: Only update once per trade!