CRITICAL FIX: Use ?? instead of || for tp2SizePercent to allow 0 value

BUG FOUND:
Line 558: tp2SizePercent: config.takeProfit2SizePercent || 100

When config.takeProfit2SizePercent = 0 (TP2-as-runner system), JavaScript's ||
operator treats 0 as falsy and falls back to 100, causing TP2 to close 100%
of remaining position instead of activating trailing stop.

IMPACT:
- On-chain orders placed correctly (line 481 uses ?? correctly)
- Position Manager reads from DB and expects TP2 to close position
- Result: User sees TWO take-profit orders instead of runner system

FIX:
Changed both tp1SizePercent and tp2SizePercent to use ?? operator:
- tp1SizePercent: config.takeProfit1SizePercent ?? 75
- tp2SizePercent: config.takeProfit2SizePercent ?? 0

This allows 0 value to be saved correctly for TP2-as-runner system.

VERIFICATION NEEDED:
Current open SHORT position in database has tp2SizePercent=100 from before
this fix. Next trade will use correct runner system.
This commit is contained in:
mindesbunister
2025-11-10 19:46:03 +01:00
parent 089308a07e
commit c3a053df63
9 changed files with 411 additions and 529 deletions

View File

@@ -293,8 +293,8 @@ export async function placeExitOrders(options: PlaceExitOrdersOptions): Promise<
// For orders that close a long, the order direction should be SHORT (sell)
const orderDirection = options.direction === 'long' ? PositionDirection.SHORT : PositionDirection.LONG
// Place TP1 LIMIT reduce-only (skip if tp1Price is 0 - runner system)
if (tp1USD > 0 && options.tp1Price > 0) {
// Place TP1 LIMIT reduce-only
if (tp1USD > 0) {
const baseAmount = usdToBase(tp1USD)
if (baseAmount >= Math.floor(marketConfig.minOrderSize * 1e9)) {
const orderParams: any = {
@@ -315,8 +315,8 @@ export async function placeExitOrders(options: PlaceExitOrdersOptions): Promise<
}
}
// Place TP2 LIMIT reduce-only (skip if tp2Price is 0 - runner system)
if (tp2USD > 0 && options.tp2Price > 0) {
// Place TP2 LIMIT reduce-only
if (tp2USD > 0) {
const baseAmount = usdToBase(tp2USD)
if (baseAmount >= Math.floor(marketConfig.minOrderSize * 1e9)) {
const orderParams: any = {
@@ -517,23 +517,19 @@ export async function closePosition(
if (isDryRun) {
console.log('🧪 DRY RUN MODE: Simulating close order (not executing on blockchain)')
// Calculate realized P&L with leverage
// Calculate realized P&L with leverage (default 10x in dry run)
const profitPercent = ((oraclePrice - position.entryPrice) / position.entryPrice) * 100 * (position.side === 'long' ? 1 : -1)
const closedNotional = sizeToClose * oraclePrice
const realizedPnL = (closedNotional * profitPercent) / 100
const accountPnLPercent = profitPercent * 10 // display using default leverage
// CRITICAL FIX: closedNotional is leveraged position size, must calculate P&L on collateral
const leverage = 10 // Default for dry run
const collateralUsed = closedNotional / leverage
const accountPnLPercent = profitPercent * leverage
const realizedPnL = (collateralUsed * accountPnLPercent) / 100
const mockTxSig = `DRY_RUN_CLOSE_${Date.now()}_${Math.random().toString(36).substring(7)}`
console.log(`💰 Simulated close:`)
console.log(` Close price: $${oraclePrice.toFixed(4)}`)
console.log(` Profit %: ${profitPercent.toFixed(3)}% → Account P&L (${leverage}x): ${accountPnLPercent.toFixed(2)}%`)
console.log(` Profit %: ${profitPercent.toFixed(3)}% → Account P&L (10x): ${accountPnLPercent.toFixed(2)}%`)
console.log(` Realized P&L: $${realizedPnL.toFixed(2)}`)
const mockTxSig = `DRY_RUN_CLOSE_${Date.now()}_${Math.random().toString(36).substring(7)}`
return {
success: true,
transactionSignature: mockTxSig,
@@ -573,7 +569,7 @@ export async function closePosition(
console.log('✅ Transaction confirmed on-chain')
// Calculate realized P&L with leverage
// CRITICAL: P&L must account for leverage and be calculated on collateral, not notional
// CRITICAL: P&L must account for leverage and be calculated on USD notional, not base asset size
const profitPercent = ((oraclePrice - position.entryPrice) / position.entryPrice) * 100 * (position.side === 'long' ? 1 : -1)
// Get leverage from user account (defaults to 10x if not found)
@@ -588,11 +584,10 @@ export async function closePosition(
console.log('⚠️ Could not determine leverage from account, using 10x default')
}
// Calculate closed notional value (USD) and convert to collateral
// Calculate closed notional value (USD)
const closedNotional = sizeToClose * oraclePrice
const collateralUsed = closedNotional / leverage // CRITICAL FIX: Calculate P&L on collateral
const accountPnLPercent = profitPercent * leverage // Account P&L includes leverage
const realizedPnL = (collateralUsed * accountPnLPercent) / 100
const realizedPnL = (closedNotional * profitPercent) / 100
const accountPnLPercent = profitPercent * leverage
console.log(`💰 Close details:`)
console.log(` Close price: $${oraclePrice.toFixed(4)}`)

View File

@@ -35,7 +35,6 @@ export interface ActiveTrade {
slMovedToBreakeven: boolean
slMovedToProfit: boolean
trailingStopActive: boolean
runnerTrailingPercent?: number // Latest dynamic trailing percent applied
// P&L tracking
realizedPnL: number
@@ -53,7 +52,6 @@ export interface ActiveTrade {
originalAdx?: number // ADX at initial entry (for scaling validation)
timesScaled?: number // How many times position has been scaled
totalScaleAdded?: number // Total USD added through scaling
atrAtEntry?: number // ATR (absolute) when trade was opened
// Monitoring
priceCheckCount: number
@@ -119,7 +117,6 @@ export class PositionManager {
slMovedToBreakeven: pmState?.slMovedToBreakeven ?? false,
slMovedToProfit: pmState?.slMovedToProfit ?? false,
trailingStopActive: pmState?.trailingStopActive ?? false,
runnerTrailingPercent: pmState?.runnerTrailingPercent,
realizedPnL: pmState?.realizedPnL ?? 0,
unrealizedPnL: pmState?.unrealizedPnL ?? 0,
peakPnL: pmState?.peakPnL ?? 0,
@@ -128,7 +125,6 @@ export class PositionManager {
maxAdverseExcursion: pmState?.maxAdverseExcursion ?? 0,
maxFavorablePrice: pmState?.maxFavorablePrice ?? dbTrade.entryPrice,
maxAdversePrice: pmState?.maxAdversePrice ?? dbTrade.entryPrice,
atrAtEntry: dbTrade.atrAtEntry ?? undefined,
priceCheckCount: 0,
lastPrice: pmState?.lastPrice ?? dbTrade.entryPrice,
lastUpdateTime: Date.now(),
@@ -136,12 +132,6 @@ export class PositionManager {
this.activeTrades.set(activeTrade.id, activeTrade)
console.log(`✅ Restored trade: ${activeTrade.symbol} ${activeTrade.direction} at $${activeTrade.entryPrice}`)
// Consistency check: if TP1 hit but SL not moved to breakeven, fix it now
if (activeTrade.tp1Hit && !activeTrade.slMovedToBreakeven) {
console.log(`🔧 Detected inconsistent state: TP1 hit but SL not at breakeven. Fixing now...`)
await this.handlePostTp1Adjustments(activeTrade, 'recovery after restore')
}
}
if (this.activeTrades.size > 0) {
@@ -213,22 +203,6 @@ export class PositionManager {
return Array.from(this.activeTrades.values())
}
async reconcileTrade(symbol: string): Promise<void> {
const trade = Array.from(this.activeTrades.values()).find(t => t.symbol === symbol)
if (!trade) {
return
}
try {
const driftService = getDriftService()
const marketConfig = getMarketConfig(symbol)
const oraclePrice = await driftService.getOraclePrice(marketConfig.driftMarketIndex)
await this.checkTradeConditions(trade, oraclePrice)
} catch (error) {
console.error(`⚠️ Failed to reconcile trade for ${symbol}:`, error)
}
}
/**
* Get specific trade
*/
@@ -342,13 +316,16 @@ export class PositionManager {
console.log(`⚠️ Position ${trade.symbol} was closed externally (by on-chain order)`)
} else {
// Position exists - check if size changed (TP1/TP2 filled)
const positionSizeUSD = position.size * currentPrice
// CRITICAL FIX: position.size from Drift SDK is already in USD notional value
const positionSizeUSD = Math.abs(position.size) // Drift SDK returns negative for shorts
const trackedSizeUSD = trade.currentSize
const sizeDiffPercent = Math.abs(positionSizeUSD - trackedSizeUSD) / trackedSizeUSD * 100
console.log(`📊 Position check: Drift=$${positionSizeUSD.toFixed(2)} Tracked=$${trackedSizeUSD.toFixed(2)} Diff=${sizeDiffPercent.toFixed(1)}%`)
// If position size reduced significantly, TP orders likely filled
if (positionSizeUSD < trackedSizeUSD * 0.9 && sizeDiffPercent > 10) {
console.log(`📊 Position size changed: tracking $${trackedSizeUSD.toFixed(2)} but found $${positionSizeUSD.toFixed(2)}`)
console.log(` Position size reduced: tracking $${trackedSizeUSD.toFixed(2)} found $${positionSizeUSD.toFixed(2)}`)
// Detect which TP filled based on size reduction
const reductionPercent = ((trackedSizeUSD - positionSizeUSD) / trade.positionSize) * 100
@@ -359,7 +336,12 @@ export class PositionManager {
trade.tp1Hit = true
trade.currentSize = positionSizeUSD
await this.handlePostTp1Adjustments(trade, 'on-chain TP1 detection')
// Move SL to breakeven after TP1
trade.stopLossPrice = trade.entryPrice
trade.slMovedToBreakeven = true
console.log(`🛡️ Stop loss moved to breakeven: $${trade.stopLossPrice.toFixed(4)}`)
await this.saveTradeState(trade)
} else if (trade.tp1Hit && !trade.tp2Hit && reductionPercent >= 85) {
// TP2 fired (total should be ~95% closed, 5% runner left)
@@ -367,22 +349,19 @@ export class PositionManager {
trade.tp2Hit = true
trade.currentSize = positionSizeUSD
trade.trailingStopActive = true
trade.runnerTrailingPercent = this.getRunnerTrailingPercent(trade)
console.log(
`🏃 Runner active: $${positionSizeUSD.toFixed(2)} with trailing buffer ${trade.runnerTrailingPercent?.toFixed(3)}%`
)
console.log(`🏃 Runner active: $${positionSizeUSD.toFixed(2)} with ${this.config.trailingStopPercent}% trailing stop`)
await this.saveTradeState(trade)
// CRITICAL: Don't return early! Continue monitoring the runner position
// The trailing stop logic at line 732 needs to run
} else {
// Partial fill detected but unclear which TP - just update size
console.log(`⚠️ Unknown partial fill detected - updating tracked size to $${positionSizeUSD.toFixed(2)}`)
trade.currentSize = positionSizeUSD
await this.saveTradeState(trade)
}
// Continue monitoring the remaining position
return
}
// CRITICAL: Check for entry price mismatch (NEW position opened)
@@ -404,10 +383,10 @@ export class PositionManager {
trade.lastPrice,
trade.direction
)
const accountPnL = profitPercent * trade.leverage
const estimatedPnL = (trade.currentSize * accountPnL) / 100
const accountPnLPercent = profitPercent * trade.leverage
const estimatedPnL = (trade.currentSize * profitPercent) / 100
console.log(`💰 Estimated P&L for lost trade: ${profitPercent.toFixed(2)}% price → ${accountPnL.toFixed(2)}% account → $${estimatedPnL.toFixed(2)} realized`)
console.log(`💰 Estimated P&L for lost trade: ${profitPercent.toFixed(2)}% price → ${accountPnLPercent.toFixed(2)}% account → $${estimatedPnL.toFixed(2)} realized`)
try {
await updateTradeExit({
@@ -448,7 +427,10 @@ export class PositionManager {
// trade.currentSize may already be 0 if on-chain orders closed the position before
// Position Manager detected it, causing zero P&L bug
// HOWEVER: If this was a phantom trade (extreme size mismatch), set P&L to 0
const sizeForPnL = trade.currentSize > 0 ? trade.currentSize : trade.positionSize
// 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=true: Runner closure, calculate on tracked remaining size
const sizeForPnL = trade.tp1Hit ? trade.currentSize : trade.positionSize
// 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
@@ -457,7 +439,8 @@ export class PositionManager {
console.log(`📊 External closure detected - Position size tracking:`)
console.log(` Original size: $${trade.positionSize.toFixed(2)}`)
console.log(` Tracked current size: $${trade.currentSize.toFixed(2)}`)
console.log(` Using for P&L calc: $${sizeForPnL.toFixed(2)}`)
console.log(` TP1 hit: ${trade.tp1Hit}`)
console.log(` Using for P&L calc: $${sizeForPnL.toFixed(2)} (${trade.tp1Hit ? 'runner' : 'full position'})`)
if (wasPhantom) {
console.log(` ⚠️ PHANTOM TRADE: Setting P&L to 0 (size mismatch >50%)`)
}
@@ -466,41 +449,22 @@ export class PositionManager {
// 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'
// Calculate P&L first (set to 0 for phantom trades)
let realizedPnL = 0
let exitPrice = currentPrice
// Include any previously realized profit (e.g., from TP1 partial close)
const previouslyRealized = trade.realizedPnL
let runnerRealized = 0
let runnerProfitPercent = 0
if (!wasPhantom) {
// For external closures, try to estimate a more realistic exit price
// Manual closures may happen at significantly different prices than current market
const unrealizedPnL = trade.unrealizedPnL || 0
const positionSizeUSD = trade.positionSize
if (Math.abs(unrealizedPnL) > 1 && positionSizeUSD > 0) {
// If we have meaningful unrealized P&L, back-calculate the likely exit price
// This is more accurate than using volatile current market price
const impliedProfitPercent = (unrealizedPnL / positionSizeUSD) * 100 / trade.leverage
exitPrice = trade.direction === 'long'
? trade.entryPrice * (1 + impliedProfitPercent / 100)
: trade.entryPrice * (1 - impliedProfitPercent / 100)
console.log(`📊 Estimated exit price based on unrealized P&L:`)
console.log(` Unrealized P&L: $${unrealizedPnL.toFixed(2)}`)
console.log(` Market price: $${currentPrice.toFixed(6)}`)
console.log(` Estimated exit: $${exitPrice.toFixed(6)}`)
realizedPnL = unrealizedPnL
} else {
// Fallback to current price calculation
const profitPercent = this.calculateProfitPercent(
trade.entryPrice,
currentPrice,
trade.direction
)
const accountPnL = profitPercent * trade.leverage
realizedPnL = (sizeForPnL * accountPnL) / 100
}
runnerProfitPercent = this.calculateProfitPercent(
trade.entryPrice,
currentPrice,
trade.direction
)
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)}`)
// Determine exit reason from trade state and P&L
if (trade.tp2Hit) {
@@ -509,14 +473,14 @@ export class PositionManager {
} else if (trade.tp1Hit) {
// TP1 was hit, position should be 25% size, but now fully closed
// This means either TP2 filled or runner got stopped out
exitReason = realizedPnL > 0 ? 'TP2' : 'SL'
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 (realizedPnL > trade.positionSize * 0.005) {
if (totalRealizedPnL > trade.positionSize * 0.005) {
// More than 0.5% profit - must be TP1
exitReason = 'TP1'
} else if (realizedPnL < 0) {
} else if (totalRealizedPnL < 0) {
// Loss - must be SL
exitReason = 'SL'
}
@@ -528,9 +492,9 @@ export class PositionManager {
try {
await updateTradeExit({
positionId: trade.positionId,
exitPrice: exitPrice, // Use estimated exit price, not current market price
exitPrice: currentPrice,
exitReason,
realizedPnL,
realizedPnL: totalRealizedPnL,
exitOrderTx: 'ON_CHAIN_ORDER',
holdTimeSeconds,
maxDrawdown: Math.abs(Math.min(0, trade.maxAdverseExcursion)),
@@ -540,7 +504,7 @@ export class PositionManager {
maxFavorablePrice: trade.maxFavorablePrice,
maxAdversePrice: trade.maxAdversePrice,
})
console.log(`💾 External closure recorded: ${exitReason} at $${exitPrice.toFixed(6)} | P&L: $${realizedPnL.toFixed(2)}`)
console.log(`💾 External closure recorded: ${exitReason} at $${currentPrice} | P&L: $${totalRealizedPnL.toFixed(2)}`)
} catch (dbError) {
console.error('❌ Failed to save external closure:', dbError)
}
@@ -551,31 +515,50 @@ export class PositionManager {
}
// Position exists but size mismatch (partial close by TP1?)
const onChainBaseSize = Math.abs(position.size)
const onChainSizeUSD = onChainBaseSize * currentPrice
const trackedSizeUSD = trade.currentSize
if (trackedSizeUSD > 0 && onChainSizeUSD < trackedSizeUSD * 0.95) { // 5% tolerance
const expectedBaseSize = trackedSizeUSD / currentPrice
console.log(`⚠️ Position size mismatch: tracking $${trackedSizeUSD.toFixed(2)} (~${expectedBaseSize.toFixed(4)} units) but on-chain shows $${onChainSizeUSD.toFixed(2)} (${onChainBaseSize.toFixed(4)} units)`)
if (position.size < trade.currentSize * 0.95) { // 5% tolerance
console.log(`⚠️ Position size mismatch: expected ${trade.currentSize}, got ${position.size}`)
// CRITICAL: Check if position direction changed (signal flip, not TP1!)
const positionDirection = position.side === 'long' ? 'long' : 'short'
if (positionDirection !== trade.direction) {
console.log(`🔄 DIRECTION CHANGE DETECTED: ${trade.direction}${positionDirection}`)
console.log(` This is a signal flip, not TP1! Closing old position as manual.`)
// Calculate actual P&L on full position
const profitPercent = this.calculateProfitPercent(trade.entryPrice, currentPrice, trade.direction)
const actualPnL = (trade.positionSize * profitPercent) / 100
try {
const holdTimeSeconds = Math.floor((Date.now() - trade.entryTime) / 1000)
await updateTradeExit({
positionId: trade.positionId,
exitPrice: currentPrice,
exitReason: 'manual',
realizedPnL: actualPnL,
exitOrderTx: 'SIGNAL_FLIP',
holdTimeSeconds,
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(`💾 Signal flip closure recorded: P&L $${actualPnL.toFixed(2)}`)
} catch (dbError) {
console.error('❌ Failed to save signal flip closure:', dbError)
}
await this.removeTrade(trade.id)
return
}
// CRITICAL: If mismatch is extreme (>50%), this is a phantom trade
const sizeRatio = trackedSizeUSD > 0 ? onChainSizeUSD / trackedSizeUSD : 0
const sizeRatio = (position.size * currentPrice) / trade.currentSize
if (sizeRatio < 0.5) {
const tradeAgeSeconds = (Date.now() - trade.entryTime) / 1000
const probablyPartialRunner = trade.tp1Hit || tradeAgeSeconds > 60
if (probablyPartialRunner) {
console.log(`🛠️ Detected stray remainder (${(sizeRatio * 100).toFixed(1)}%) after on-chain exit - forcing market close`)
trade.currentSize = onChainSizeUSD
await this.saveTradeState(trade)
await this.executeExit(trade, 100, 'manual', currentPrice)
return
}
console.log(`🚨 EXTREME SIZE MISMATCH (${(sizeRatio * 100).toFixed(1)}%) - Closing phantom trade`)
console.log(` Expected: $${trackedSizeUSD.toFixed(2)}`)
console.log(` Actual: $${onChainSizeUSD.toFixed(2)}`)
console.log(` Expected: $${trade.currentSize.toFixed(2)}`)
console.log(` Actual: $${(position.size * currentPrice).toFixed(2)}`)
// Close as phantom trade
try {
@@ -603,15 +586,10 @@ export class PositionManager {
return
}
// Update current size to match reality and run TP1 adjustments if needed
trade.currentSize = onChainSizeUSD
if (!trade.tp1Hit) {
trade.tp1Hit = true
await this.handlePostTp1Adjustments(trade, 'on-chain TP1 size sync')
} else {
await this.saveTradeState(trade)
}
return
// Update current size to match reality (convert base asset size to USD using current price)
trade.currentSize = position.size * currentPrice
trade.tp1Hit = true
await this.saveTradeState(trade)
}
} catch (error) {
@@ -636,8 +614,8 @@ export class PositionManager {
trade.direction
)
const accountPnL = profitPercent * trade.leverage
trade.unrealizedPnL = (trade.currentSize * accountPnL) / 100
const accountPnL = profitPercent * trade.leverage
trade.unrealizedPnL = (trade.currentSize * profitPercent) / 100
// Track peak P&L (MFE - Maximum Favorable Excursion)
if (trade.unrealizedPnL > trade.peakPnL) {
@@ -702,7 +680,56 @@ export class PositionManager {
// Move SL based on breakEvenTriggerPercent setting
trade.tp1Hit = true
trade.currentSize = trade.positionSize * ((100 - this.config.takeProfit1SizePercent) / 100)
await this.handlePostTp1Adjustments(trade, 'software TP1 execution')
const newStopLossPrice = this.calculatePrice(
trade.entryPrice,
this.config.breakEvenTriggerPercent, // Use configured breakeven level
trade.direction
)
trade.stopLossPrice = newStopLossPrice
trade.slMovedToBreakeven = true
console.log(`🔒 SL moved to +${this.config.breakEvenTriggerPercent}% (${this.config.takeProfit1SizePercent}% closed, ${100 - this.config.takeProfit1SizePercent}% remaining): ${newStopLossPrice.toFixed(4)}`)
// CRITICAL: Cancel old on-chain SL orders and place new ones at updated price
try {
console.log('🗑️ Cancelling old stop loss orders...')
const { cancelAllOrders, placeExitOrders } = await import('../drift/orders')
const cancelResult = await cancelAllOrders(trade.symbol)
if (cancelResult.success) {
console.log(`✅ Cancelled ${cancelResult.cancelledCount || 0} old orders`)
// Place new SL orders at breakeven/profit level for remaining position
console.log(`🛡️ Placing new SL orders at $${newStopLossPrice.toFixed(4)} for remaining position...`)
const exitOrdersResult = await placeExitOrders({
symbol: trade.symbol,
positionSizeUSD: trade.currentSize,
entryPrice: trade.entryPrice,
tp1Price: trade.tp2Price, // Only TP2 remains
tp2Price: trade.tp2Price, // Dummy, won't be used
stopLossPrice: newStopLossPrice,
tp1SizePercent: 100, // Close remaining 25% at TP2
tp2SizePercent: 0,
direction: trade.direction,
useDualStops: this.config.useDualStops,
softStopPrice: trade.direction === 'long'
? newStopLossPrice * 1.005 // 0.5% above for long
: newStopLossPrice * 0.995, // 0.5% below for short
hardStopPrice: newStopLossPrice,
})
if (exitOrdersResult.success) {
console.log('✅ New SL orders placed on-chain at updated price')
} else {
console.error('❌ Failed to place new SL orders:', exitOrdersResult.error)
}
}
} catch (error) {
console.error('❌ Failed to update on-chain SL orders:', error)
// Don't fail the TP1 exit if SL update fails - software monitoring will handle it
}
// Save state after TP1
await this.saveTradeState(trade)
return
}
@@ -727,39 +754,42 @@ export class PositionManager {
await this.saveTradeState(trade)
}
// 5. TP2 Hit - Activate runner (no close, just start trailing)
// 5. Take profit 2 (remaining position)
if (trade.tp1Hit && !trade.tp2Hit && this.shouldTakeProfit2(currentPrice, trade)) {
console.log(`🎊 TP2 HIT: ${trade.symbol} at ${profitPercent.toFixed(2)}% - Activating 25% runner!`)
console.log(`🎊 TP2 HIT: ${trade.symbol} at ${profitPercent.toFixed(2)}%`)
// Mark TP2 as hit and activate trailing stop on full remaining 25%
trade.tp2Hit = true
trade.peakPrice = currentPrice
trade.runnerTrailingPercent = this.getRunnerTrailingPercent(trade)
// Calculate how much to close based on TP2 size percent
const percentToClose = this.config.takeProfit2SizePercent
console.log(
`🏃 Runner activated on full remaining position: ${((trade.currentSize / trade.positionSize) * 100).toFixed(1)}% | trailing buffer ${trade.runnerTrailingPercent?.toFixed(3)}%`
)
await this.executeExit(trade, percentToClose, 'TP2', currentPrice)
// Save state after TP2 activation
await this.saveTradeState(trade)
// If some position remains, mark TP2 as hit and activate trailing stop
if (percentToClose < 100) {
trade.tp2Hit = true
trade.currentSize = trade.currentSize * ((100 - percentToClose) / 100)
console.log(`🏃 Runner activated: ${((trade.currentSize / trade.positionSize) * 100).toFixed(1)}% remaining with trailing stop`)
// Save state after TP2
await this.saveTradeState(trade)
}
return
} // 6. Trailing stop for runner (after TP2 activation)
}
// 6. Trailing stop for runner (after TP2)
if (trade.tp2Hit && this.config.useTrailingStop) {
// Check if trailing stop should be activated
if (!trade.trailingStopActive && profitPercent >= this.config.trailingStopActivation) {
trade.trailingStopActive = true
trade.runnerTrailingPercent = this.getRunnerTrailingPercent(trade)
console.log(`🎯 Trailing stop activated at +${profitPercent.toFixed(2)}%`)
}
// If trailing stop is active, adjust SL dynamically
if (trade.trailingStopActive) {
const trailingPercent = this.getRunnerTrailingPercent(trade)
trade.runnerTrailingPercent = trailingPercent
const trailingStopPrice = this.calculatePrice(
trade.peakPrice,
-trailingPercent, // Trail below peak
-this.config.trailingStopPercent, // Trail below peak
trade.direction
)
@@ -772,7 +802,7 @@ export class PositionManager {
const oldSL = trade.stopLossPrice
trade.stopLossPrice = trailingStopPrice
console.log(`📈 Trailing SL updated: ${oldSL.toFixed(4)}${trailingStopPrice.toFixed(4)} (${trailingPercent.toFixed(3)}% below peak $${trade.peakPrice.toFixed(4)})`)
console.log(`📈 Trailing SL updated: ${oldSL.toFixed(4)}${trailingStopPrice.toFixed(4)} (${this.config.trailingStopPercent}% below peak $${trade.peakPrice.toFixed(4)})`)
// Save state after trailing SL update (every 10 updates to avoid spam)
if (trade.priceCheckCount % 10 === 0) {
@@ -813,37 +843,18 @@ export class PositionManager {
return
}
const closePriceForCalc = result.closePrice || currentPrice
const closedSizeBase = result.closedSize || 0
const closedUSD = closedSizeBase * closePriceForCalc
const treatAsFullClose = percentToClose >= 100
// Calculate actual P&L based on entry vs exit price
// CRITICAL: closedUSD is NOTIONAL value (with leverage), must calculate based on collateral
const profitPercent = this.calculateProfitPercent(trade.entryPrice, closePriceForCalc, trade.direction)
const collateralUSD = closedUSD / trade.leverage // Convert notional to actual collateral used
const accountPnLPercent = profitPercent * trade.leverage // Account P&L includes leverage effect
const actualRealizedPnL = (collateralUSD * accountPnLPercent) / 100
// Update trade state
if (treatAsFullClose) {
trade.realizedPnL += actualRealizedPnL
trade.currentSize = 0
trade.trailingStopActive = false
if (reason === 'TP2') {
trade.tp2Hit = true
}
if (reason === 'TP1') {
trade.tp1Hit = true
}
if (percentToClose >= 100) {
// Full close - remove from monitoring
trade.realizedPnL += result.realizedPnL || 0
// Save to database (only for valid exit reasons)
if (reason !== 'error') {
try {
const holdTimeSeconds = Math.floor((Date.now() - trade.entryTime) / 1000)
await updateTradeExit({
positionId: trade.positionId,
exitPrice: closePriceForCalc,
exitPrice: result.closePrice || currentPrice,
exitReason: reason as 'TP1' | 'TP2' | 'SL' | 'SOFT_SL' | 'HARD_SL' | 'manual' | 'emergency',
realizedPnL: trade.realizedPnL,
exitOrderTx: result.transactionSignature || 'MANUAL_CLOSE',
@@ -858,23 +869,25 @@ export class PositionManager {
console.log('💾 Trade saved to database')
} catch (dbError) {
console.error('❌ Failed to save trade exit to database:', dbError)
// Don't fail the close if database fails
}
}
await this.removeTrade(trade.id)
console.log(`✅ Position closed | P&L: $${trade.realizedPnL.toFixed(2)} | Reason: ${reason}`)
} else {
// Partial close (TP1) - calculate P&L for partial amount
// CRITICAL: Same fix as above - closedUSD is notional, must use collateral
const partialCollateralUSD = closedUSD / trade.leverage
const partialAccountPnL = profitPercent * trade.leverage
const partialRealizedPnL = (partialCollateralUSD * partialAccountPnL) / 100
trade.realizedPnL += partialRealizedPnL
// Partial close (TP1)
trade.realizedPnL += result.realizedPnL || 0
// result.closedSize is returned in base asset units (e.g., SOL), convert to USD using closePrice
const closePriceForCalc = result.closePrice || currentPrice
const closedSizeBase = result.closedSize || 0
const closedUSD = closedSizeBase * closePriceForCalc
trade.currentSize = Math.max(0, trade.currentSize - closedUSD)
console.log(
`✅ Partial close executed | Realized: $${partialRealizedPnL.toFixed(2)} | Closed (base): ${closedSizeBase.toFixed(6)} | Closed (USD): $${closedUSD.toFixed(2)} | Remaining USD: $${trade.currentSize.toFixed(2)}`
)
console.log(`✅ Partial close executed | Realized: $${(result.realizedPnL || 0).toFixed(2)} | Closed (base): ${closedSizeBase.toFixed(6)} | Closed (USD): $${closedUSD.toFixed(2)} | Remaining USD: $${trade.currentSize.toFixed(2)}`)
// Persist updated trade state so analytics reflect partial profits immediately
await this.saveTradeState(trade)
}
// TODO: Send notification
@@ -964,150 +977,6 @@ export class PositionManager {
console.log('✅ All positions closed')
}
refreshConfig(): void {
this.config = getMergedConfig()
console.log('⚙️ Position manager config refreshed from environment')
}
private getRunnerTrailingPercent(trade: ActiveTrade): number {
const fallbackPercent = this.config.trailingStopPercent
const atrValue = trade.atrAtEntry ?? 0
const entryPrice = trade.entryPrice
if (atrValue <= 0 || entryPrice <= 0 || !Number.isFinite(entryPrice)) {
return fallbackPercent
}
const atrPercentOfPrice = (atrValue / entryPrice) * 100
if (!Number.isFinite(atrPercentOfPrice) || atrPercentOfPrice <= 0) {
return fallbackPercent
}
const rawPercent = atrPercentOfPrice * this.config.trailingStopAtrMultiplier
const boundedPercent = Math.min(
this.config.trailingStopMaxPercent,
Math.max(this.config.trailingStopMinPercent, rawPercent)
)
return boundedPercent > 0 ? boundedPercent : fallbackPercent
}
private async handlePostTp1Adjustments(trade: ActiveTrade, context: string): Promise<void> {
if (trade.currentSize <= 0) {
console.log(`⚠️ Skipping TP1 adjustments for ${trade.symbol} (${context}) because current size is $${trade.currentSize.toFixed(2)}`)
await this.saveTradeState(trade)
return
}
const newStopLossPrice = this.calculatePrice(
trade.entryPrice,
this.config.breakEvenTriggerPercent,
trade.direction
)
trade.stopLossPrice = newStopLossPrice
trade.slMovedToBreakeven = true
console.log(`🔒 (${context}) SL moved to +${this.config.breakEvenTriggerPercent}% (${this.config.takeProfit1SizePercent}% closed, ${100 - this.config.takeProfit1SizePercent}% remaining): ${newStopLossPrice.toFixed(4)}`)
// CRITICAL FIX: For runner system (tp2SizePercent=0), don't place any TP orders
// The remaining 25% should only have stop loss and be managed by software trailing stop
const shouldPlaceTpOrders = this.config.takeProfit2SizePercent > 0
if (shouldPlaceTpOrders) {
// Traditional system: place TP2 order for remaining position
await this.refreshExitOrders(trade, {
stopLossPrice: newStopLossPrice,
tp1Price: trade.tp2Price,
tp1SizePercent: 100,
tp2Price: trade.tp2Price,
tp2SizePercent: 0,
context,
})
} else {
// Runner system: Only place stop loss, no TP orders
// The 25% runner will be managed by software trailing stop
console.log(`🏃 Runner system active - placing ONLY stop loss at breakeven, no TP orders`)
await this.refreshExitOrders(trade, {
stopLossPrice: newStopLossPrice,
tp1Price: 0, // No TP1 order
tp1SizePercent: 0,
tp2Price: 0, // No TP2 order
tp2SizePercent: 0,
context,
})
}
await this.saveTradeState(trade)
}
private async refreshExitOrders(
trade: ActiveTrade,
options: {
stopLossPrice: number
tp1Price: number
tp1SizePercent: number
tp2Price?: number
tp2SizePercent?: number
context: string
}
): Promise<void> {
if (trade.currentSize <= 0) {
console.log(`⚠️ Skipping exit order refresh for ${trade.symbol} (${options.context}) because tracked size is zero`)
return
}
try {
console.log(`🗑️ (${options.context}) Cancelling existing exit orders before refresh...`)
const { cancelAllOrders, placeExitOrders } = await import('../drift/orders')
const cancelResult = await cancelAllOrders(trade.symbol)
if (cancelResult.success) {
console.log(`✅ (${options.context}) Cancelled ${cancelResult.cancelledCount || 0} old orders`)
} else {
console.warn(`⚠️ (${options.context}) Failed to cancel old orders: ${cancelResult.error}`)
}
const tp2Price = options.tp2Price ?? options.tp1Price
const tp2SizePercent = options.tp2SizePercent ?? 0
const refreshParams: any = {
symbol: trade.symbol,
positionSizeUSD: trade.currentSize,
entryPrice: trade.entryPrice,
tp1Price: options.tp1Price,
tp2Price,
stopLossPrice: options.stopLossPrice,
tp1SizePercent: options.tp1SizePercent,
tp2SizePercent,
direction: trade.direction,
useDualStops: this.config.useDualStops,
}
if (this.config.useDualStops) {
const softStopBuffer = this.config.softStopBuffer ?? 0.4
const softStopPrice = trade.direction === 'long'
? options.stopLossPrice * (1 + softStopBuffer / 100)
: options.stopLossPrice * (1 - softStopBuffer / 100)
refreshParams.softStopPrice = softStopPrice
refreshParams.softStopBuffer = softStopBuffer
refreshParams.hardStopPrice = options.stopLossPrice
}
console.log(`🛡️ (${options.context}) Placing refreshed exit orders: size=$${trade.currentSize.toFixed(2)} SL=${options.stopLossPrice.toFixed(4)} TP=${options.tp1Price.toFixed(4)}`)
const exitOrdersResult = await placeExitOrders(refreshParams)
if (exitOrdersResult.success) {
console.log(`✅ (${options.context}) Exit orders refreshed on-chain`)
} else {
console.error(`❌ (${options.context}) Failed to place refreshed exit orders: ${exitOrdersResult.error}`)
}
} catch (error) {
console.error(`❌ (${options.context}) Error refreshing exit orders:`, error)
// Monitoring loop will still enforce SL logic even if on-chain refresh fails
}
}
/**
* Save trade state to database (for persistence across restarts)
*/
@@ -1131,6 +1000,14 @@ export class PositionManager {
}
}
/**
* Reload configuration from merged sources (used after settings updates)
*/
refreshConfig(partial?: Partial<TradingConfig>): void {
this.config = getMergedConfig(partial)
console.log('🔄 Position Manager config refreshed')
}
/**
* Get monitoring status
*/

View File

@@ -140,16 +140,8 @@ export function scoreSignalQuality(params: {
}
// Price position check (avoid chasing vs breakout detection)
// CRITICAL: Low price position (< 40%) + weak trend (ADX < 25) = range-bound chop
if (params.pricePosition > 0) {
const isWeakTrend = params.adx > 0 && params.adx < 25
const isLowInRange = params.pricePosition < 40
// ANTI-CHOP: Heavily penalize range-bound entries
if (isLowInRange && isWeakTrend) {
score -= 25
reasons.push(`⚠️ RANGE-BOUND CHOP: Low position (${params.pricePosition.toFixed(0)}%) + weak trend (ADX ${params.adx.toFixed(1)}) = high whipsaw risk`)
} else if (params.direction === 'long' && params.pricePosition > 95) {
if (params.direction === 'long' && params.pricePosition > 95) {
// High volume breakout at range top can be good
if (params.volumeRatio > 1.4) {
score += 5
@@ -173,15 +165,13 @@ export function scoreSignalQuality(params: {
}
}
// Volume breakout bonus - ONLY if trend is strong enough (not choppy)
// Removed old logic that conflicted with anti-chop filter
// Old bonus was rewarding high volume even during choppy markets
if (params.volumeRatio > 1.8 && params.atr < 0.6 && params.adx > 18) {
// Volume breakout bonus (high volume can override other weaknesses)
if (params.volumeRatio > 1.8 && params.atr < 0.6) {
score += 10
reasons.push(`Volume breakout compensates for low ATR (ADX ${params.adx.toFixed(1)} confirms trend)`)
reasons.push(`Volume breakout compensates for low ATR`)
}
const minScore = params.minScore || 65
const minScore = params.minScore || 60
const passed = score >= minScore
return {