Files
trading_bot_v4/lib/trading/position-manager.ts
mindesbunister 324e5ba002 refactor: Rename breakEvenTriggerPercent to profitLockAfterTP1Percent for clarity
- Renamed config variable to accurately reflect behavior (locks profit, not breakeven)
- Updated log messages to say 'lock +X% profit' instead of misleading 'breakeven'
- Maintains backwards compatibility (accepts old BREAKEVEN_TRIGGER_PERCENT env var)
- Updated .env with new variable name and explanatory comment

Why: Config was named 'breakeven' but actually locks profit at entry ± X%
For SHORT at $141.51 with 0.3% lock: SL moves to $141.08 (not breakeven $141.51)
This protects remaining runner position after TP1 by allowing small profit giveback

Files changed:
- config/trading.ts: Interface + default + env parsing
- lib/trading/position-manager.ts: Usage + log message
- .env: Variable rename with migration comment
2025-11-15 11:06:44 +01:00

1150 lines
45 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Position Manager
*
* Tracks active trades and manages automatic exits
*/
import { getDriftService } from '../drift/client'
import { closePosition } from '../drift/orders'
import { getPythPriceMonitor, PriceUpdate } from '../pyth/price-monitor'
import { getMergedConfig, TradingConfig, getMarketConfig } from '../../config/trading'
import { updateTradeExit, updateTradeState, getOpenTrades } from '../database/trades'
export interface ActiveTrade {
id: string
positionId: string // Transaction signature
symbol: string
direction: 'long' | 'short'
// Entry details
entryPrice: number
entryTime: number
positionSize: number
leverage: number
atrAtEntry?: number // ATR value at entry for ATR-based trailing stop
// Targets
stopLossPrice: number
tp1Price: number
tp2Price: number
emergencyStopPrice: number
// State
currentSize: number // Changes after TP1
tp1Hit: boolean
tp2Hit: boolean
slMovedToBreakeven: boolean
slMovedToProfit: boolean
trailingStopActive: boolean
// P&L tracking
realizedPnL: number
unrealizedPnL: number
peakPnL: number
peakPrice: number // Track highest price reached (for trailing)
// MAE/MFE tracking
maxFavorableExcursion: number // Best profit % reached
maxAdverseExcursion: number // Worst loss % reached
maxFavorablePrice: number // Price at best profit
maxAdversePrice: number // Price at worst loss
// Position scaling tracking
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
// Monitoring
priceCheckCount: number
lastPrice: number
lastUpdateTime: number
}
export interface ExitResult {
success: boolean
reason: 'TP1' | 'TP2' | 'SL' | 'SOFT_SL' | 'HARD_SL' | 'emergency' | 'manual' | 'error'
closePrice?: number
closedSize?: number
realizedPnL?: number
transactionSignature?: string
error?: string
}
export class PositionManager {
private activeTrades: Map<string, ActiveTrade> = new Map()
private config: TradingConfig
private isMonitoring: boolean = false
private initialized: boolean = false
constructor(config?: Partial<TradingConfig>) {
this.config = getMergedConfig(config)
console.log('✅ Position manager created')
}
/**
* Initialize and restore active trades from database
*/
async initialize(): Promise<void> {
if (this.initialized) {
return
}
console.log('🔄 Restoring active trades from database...')
try {
const openTrades = await getOpenTrades()
for (const dbTrade of openTrades) {
// Extract Position Manager state from configSnapshot
const pmState = (dbTrade.configSnapshot as any)?.positionManagerState
// Reconstruct ActiveTrade object
const activeTrade: ActiveTrade = {
id: dbTrade.id,
positionId: dbTrade.positionId,
symbol: dbTrade.symbol,
direction: dbTrade.direction as 'long' | 'short',
entryPrice: dbTrade.entryPrice,
entryTime: dbTrade.entryTime.getTime(),
positionSize: dbTrade.positionSizeUSD,
leverage: dbTrade.leverage,
stopLossPrice: pmState?.stopLossPrice ?? dbTrade.stopLossPrice,
tp1Price: dbTrade.takeProfit1Price,
tp2Price: dbTrade.takeProfit2Price,
emergencyStopPrice: dbTrade.stopLossPrice * (dbTrade.direction === 'long' ? 0.98 : 1.02),
currentSize: pmState?.currentSize ?? dbTrade.positionSizeUSD,
tp1Hit: pmState?.tp1Hit ?? false,
tp2Hit: pmState?.tp2Hit ?? false,
slMovedToBreakeven: pmState?.slMovedToBreakeven ?? false,
slMovedToProfit: pmState?.slMovedToProfit ?? false,
trailingStopActive: pmState?.trailingStopActive ?? false,
realizedPnL: pmState?.realizedPnL ?? 0,
unrealizedPnL: pmState?.unrealizedPnL ?? 0,
peakPnL: pmState?.peakPnL ?? 0,
peakPrice: pmState?.peakPrice ?? dbTrade.entryPrice,
maxFavorableExcursion: pmState?.maxFavorableExcursion ?? 0,
maxAdverseExcursion: pmState?.maxAdverseExcursion ?? 0,
maxFavorablePrice: pmState?.maxFavorablePrice ?? dbTrade.entryPrice,
maxAdversePrice: pmState?.maxAdversePrice ?? dbTrade.entryPrice,
priceCheckCount: 0,
lastPrice: pmState?.lastPrice ?? dbTrade.entryPrice,
lastUpdateTime: Date.now(),
}
this.activeTrades.set(activeTrade.id, activeTrade)
console.log(`✅ Restored trade: ${activeTrade.symbol} ${activeTrade.direction} at $${activeTrade.entryPrice}`)
}
if (this.activeTrades.size > 0) {
console.log(`🎯 Restored ${this.activeTrades.size} active trades`)
await this.startMonitoring()
} else {
console.log('✅ No active trades to restore')
}
} catch (error) {
console.error('❌ Failed to restore active trades:', error)
}
this.initialized = true
}
/**
* Add a new trade to monitor
*/
async addTrade(trade: ActiveTrade): Promise<void> {
console.log(`📊 Adding trade to monitor: ${trade.symbol} ${trade.direction}`)
this.activeTrades.set(trade.id, trade)
// Note: Initial state is saved by the API endpoint that creates the trade
// We don't save here to avoid race condition (trade may not be in DB yet)
console.log(`✅ Trade added. Active trades: ${this.activeTrades.size}`)
// Start monitoring if not already running
if (!this.isMonitoring && this.activeTrades.size > 0) {
await this.startMonitoring()
}
}
/**
* Remove a trade from monitoring
*/
async removeTrade(tradeId: string): Promise<void> {
const trade = this.activeTrades.get(tradeId)
if (trade) {
console.log(`🗑️ Removing trade: ${trade.symbol}`)
// Cancel all orders for this symbol (cleanup orphaned orders)
try {
const { cancelAllOrders } = await import('../drift/orders')
const cancelResult = await cancelAllOrders(trade.symbol)
if (cancelResult.success && cancelResult.cancelledCount! > 0) {
console.log(`✅ Cancelled ${cancelResult.cancelledCount} orphaned orders`)
}
} catch (error) {
console.error('❌ Failed to cancel orders during trade removal:', error)
// Continue with removal even if cancel fails
}
this.activeTrades.delete(tradeId)
// Stop monitoring if no more trades
if (this.activeTrades.size === 0 && this.isMonitoring) {
this.stopMonitoring()
}
}
}
/**
* Get all active trades
*/
getActiveTrades(): ActiveTrade[] {
return Array.from(this.activeTrades.values())
}
/**
* Get specific trade
*/
getTrade(tradeId: string): ActiveTrade | null {
return this.activeTrades.get(tradeId) || null
}
/**
* Start price monitoring for all active trades
*/
private async startMonitoring(): Promise<void> {
if (this.isMonitoring) {
return
}
// Get unique symbols from active trades
const symbols = [...new Set(
Array.from(this.activeTrades.values()).map(trade => trade.symbol)
)]
if (symbols.length === 0) {
return
}
console.log('🚀 Starting price monitoring for:', symbols)
const priceMonitor = getPythPriceMonitor()
await priceMonitor.start({
symbols,
onPriceUpdate: async (update: PriceUpdate) => {
await this.handlePriceUpdate(update)
},
onError: (error: Error) => {
console.error('❌ Price monitor error:', error)
},
})
this.isMonitoring = true
console.log('✅ Position monitoring active')
}
/**
* Stop price monitoring
*/
private async stopMonitoring(): Promise<void> {
if (!this.isMonitoring) {
return
}
console.log('🛑 Stopping position monitoring...')
const priceMonitor = getPythPriceMonitor()
await priceMonitor.stop()
this.isMonitoring = false
console.log('✅ Position monitoring stopped')
}
/**
* Handle price update for all relevant trades
*/
private async handlePriceUpdate(update: PriceUpdate): Promise<void> {
// Find all trades for this symbol
const tradesForSymbol = Array.from(this.activeTrades.values())
.filter(trade => trade.symbol === update.symbol)
for (const trade of tradesForSymbol) {
try {
await this.checkTradeConditions(trade, update.price)
} catch (error) {
console.error(`❌ Error checking trade ${trade.id}:`, error)
}
}
}
/**
* Check if any exit conditions are met for a trade
*/
private async checkTradeConditions(
trade: ActiveTrade,
currentPrice: number
): Promise<void> {
// CRITICAL: Update lastPrice FIRST so /status always shows current price
// (even if function returns early due to position checks)
trade.lastPrice = currentPrice
trade.lastUpdateTime = Date.now()
trade.priceCheckCount++
// CRITICAL: First check if on-chain position still exists
// (may have been closed by TP/SL orders without us knowing)
try {
const driftService = getDriftService()
// Skip position verification if Drift service isn't initialized yet
// (happens briefly after restart while service initializes)
if (!driftService || !(driftService as any).isInitialized) {
// Service still initializing, skip this check cycle
return
}
const marketConfig = getMarketConfig(trade.symbol)
const position = await driftService.getPosition(marketConfig.driftMarketIndex)
// Calculate trade age in seconds
const tradeAgeSeconds = (Date.now() - trade.entryTime) / 1000
if (position === null || position.size === 0) {
// IMPORTANT: Skip "external closure" detection for NEW trades (<30 seconds old)
// Drift positions may not be immediately visible after opening due to blockchain delays
if (tradeAgeSeconds < 30) {
console.log(`⏳ Trade ${trade.symbol} is new (${tradeAgeSeconds.toFixed(1)}s old) - skipping external closure check`)
return // Skip this check cycle, position might still be propagating
}
// Position closed externally (by on-chain TP/SL order or manual closure)
console.log(`⚠️ Position ${trade.symbol} was closed externally (by on-chain order)`)
} else {
// Position exists - check if size changed (TP1/TP2 filled)
// CRITICAL FIX: position.size from Drift SDK is base asset tokens, must convert to USD
const positionSizeUSD = Math.abs(position.size) * currentPrice // Convert tokens to USD
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 reduced: tracking $${trackedSizeUSD.toFixed(2)} → found $${positionSizeUSD.toFixed(2)}`)
// Detect which TP filled based on size reduction
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)}%`)
trade.tp1Hit = true
trade.currentSize = positionSizeUSD
// 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)
console.log(`🎯 TP2 detected as filled! Reduction: ${reductionPercent.toFixed(1)}%`)
trade.tp2Hit = true
trade.currentSize = positionSizeUSD
trade.trailingStopActive = true
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)
}
}
// CRITICAL: Check for entry price mismatch (NEW position opened)
// This can happen if user manually closed and opened a new position
// Only check if we haven't detected TP fills (entry price changes after partial closes on Drift)
if (!trade.tp1Hit && !trade.tp2Hit) {
const entryPriceDiff = Math.abs(position.entryPrice - trade.entryPrice)
const entryPriceDiffPercent = (entryPriceDiff / trade.entryPrice) * 100
if (entryPriceDiffPercent > 0.5) {
// Entry prices differ by >0.5% - this is a DIFFERENT position
console.log(`⚠️ Position ${trade.symbol} entry mismatch: tracking $${trade.entryPrice.toFixed(4)} but found $${position.entryPrice.toFixed(4)}`)
console.log(`🗑️ This is a different/newer position - removing old trade from monitoring`)
// Mark the old trade as closed (we lost track of it)
// Calculate approximate P&L using last known price
const profitPercent = this.calculateProfitPercent(
trade.entryPrice,
trade.lastPrice,
trade.direction
)
const accountPnLPercent = profitPercent * trade.leverage
const estimatedPnL = (trade.currentSize * profitPercent) / 100
console.log(`💰 Estimated P&L for lost trade: ${profitPercent.toFixed(2)}% price → ${accountPnLPercent.toFixed(2)}% account → $${estimatedPnL.toFixed(2)} realized`)
try {
await updateTradeExit({
positionId: trade.positionId,
exitPrice: trade.lastPrice,
exitReason: 'SOFT_SL', // Unknown - just mark as closed
realizedPnL: estimatedPnL,
exitOrderTx: 'UNKNOWN_CLOSURE',
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(`💾 Old trade marked as closed (lost tracking) with estimated P&L: $${estimatedPnL.toFixed(2)}`)
} catch (dbError) {
console.error('❌ Failed to save lost trade closure:', dbError)
}
// Remove from monitoring WITHOUT cancelling orders (they belong to the new position!)
console.log(`🗑️ Removing old trade WITHOUT cancelling orders`)
this.activeTrades.delete(trade.id)
if (this.activeTrades.size === 0 && this.isMonitoring) {
this.stopMonitoring()
}
return
}
}
}
if (position === null || position.size === 0) {
// CRITICAL: Use original position size for P&L calculation on external closures
// 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
// 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
const wasPhantom = trade.currentSize > 0 && (trade.currentSize / trade.positionSize) < 0.5
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(` 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%)`)
}
// 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'
// Include any previously realized profit (e.g., from TP1 partial close)
const previouslyRealized = trade.realizedPnL
let runnerRealized = 0
let runnerProfitPercent = 0
if (!wasPhantom) {
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) {
// TP2 was hit, full position closed (runner stopped or hit target)
exitReason = 'TP2'
} 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 = 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'
} else if (totalRealizedPnL < 0) {
// Loss - must be SL
exitReason = 'SL'
}
// else: small profit/loss near breakeven, default to SL (could be manual close)
}
// Update database - CRITICAL: Only update once per trade!
const holdTimeSeconds = Math.floor((Date.now() - trade.entryTime) / 1000)
// CRITICAL BUG FIX: Mark trade as processed IMMEDIATELY to prevent duplicate updates
// Remove from monitoring BEFORE database update to prevent race condition
const tradeId = trade.id
// VERIFICATION: Check if already removed (would indicate duplicate processing attempt)
if (!this.activeTrades.has(tradeId)) {
console.log(`⚠️ DUPLICATE PROCESSING PREVENTED: Trade ${tradeId} already removed from monitoring`)
console.log(` This is the bug fix working - without it, we'd update DB again with compounded P&L`)
return // Already processed, don't update DB again
}
this.activeTrades.delete(tradeId)
console.log(`🗑️ Removed trade ${tradeId} from monitoring (BEFORE DB update to prevent duplicates)`)
console.log(` Active trades remaining: ${this.activeTrades.size}`)
try {
await updateTradeExit({
positionId: trade.positionId,
exitPrice: currentPrice,
exitReason,
realizedPnL: totalRealizedPnL,
exitOrderTx: 'ON_CHAIN_ORDER',
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(`💾 External closure recorded: ${exitReason} at $${currentPrice} | P&L: $${totalRealizedPnL.toFixed(2)}`)
} catch (dbError) {
console.error('❌ Failed to save external closure:', dbError)
}
// Stop monitoring if no more trades
if (this.activeTrades.size === 0 && this.isMonitoring) {
this.stopMonitoring()
}
return
}
// CRITICAL: Convert position.size (base asset tokens) to USD for comparison
const positionSizeUSD = Math.abs(position.size) * currentPrice
// Position exists but size mismatch (partial close by TP1?)
if (positionSizeUSD < trade.currentSize * 0.95) { // 5% tolerance
console.log(`⚠️ Position size mismatch: expected $${trade.currentSize.toFixed(2)}, got $${positionSizeUSD.toFixed(2)}`)
// 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 = positionSizeUSD / trade.currentSize
if (sizeRatio < 0.5) {
console.log(`🚨 EXTREME SIZE MISMATCH (${(sizeRatio * 100).toFixed(1)}%) - Closing phantom trade`)
console.log(` Expected: $${trade.currentSize.toFixed(2)}`)
console.log(` Actual: $${positionSizeUSD.toFixed(2)}`)
// Close as phantom trade
try {
const holdTimeSeconds = Math.floor((Date.now() - trade.entryTime) / 1000)
await updateTradeExit({
positionId: trade.positionId,
exitPrice: currentPrice,
exitReason: 'manual',
realizedPnL: 0,
exitOrderTx: 'AUTO_CLEANUP',
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(`💾 Phantom trade closed`)
} catch (dbError) {
console.error('❌ Failed to close phantom trade:', dbError)
}
await this.removeTrade(trade.id)
return
}
// Update current size to match reality (already in USD)
trade.currentSize = positionSizeUSD
trade.tp1Hit = true
await this.saveTradeState(trade)
}
} catch (error) {
// If we can't check position, continue with monitoring (don't want to false-positive)
// This can happen briefly during startup while Drift service initializes
if ((error as Error).message?.includes('not initialized')) {
// Silent - expected during initialization
} else {
console.error(`⚠️ Could not verify on-chain position for ${trade.symbol}:`, error)
}
}
// Calculate P&L
const profitPercent = this.calculateProfitPercent(
trade.entryPrice,
currentPrice,
trade.direction
)
const accountPnL = profitPercent * trade.leverage
trade.unrealizedPnL = (trade.currentSize * profitPercent) / 100
// Track peak P&L (MFE - Maximum Favorable Excursion)
if (trade.unrealizedPnL > trade.peakPnL) {
trade.peakPnL = trade.unrealizedPnL
}
// Track MAE/MFE (account percentage, not USD)
if (accountPnL > trade.maxFavorableExcursion) {
trade.maxFavorableExcursion = accountPnL
trade.maxFavorablePrice = currentPrice
}
if (accountPnL < trade.maxAdverseExcursion) {
trade.maxAdverseExcursion = accountPnL
trade.maxAdversePrice = currentPrice
}
// Track peak price for trailing stop
if (trade.direction === 'long') {
if (currentPrice > trade.peakPrice) {
trade.peakPrice = currentPrice
}
} else {
if (currentPrice < trade.peakPrice || trade.peakPrice === 0) {
trade.peakPrice = currentPrice
}
}
// Log status every 10 checks (~20 seconds)
if (trade.priceCheckCount % 10 === 0) {
console.log(
`📊 ${trade.symbol} | ` +
`Price: ${currentPrice.toFixed(4)} | ` +
`P&L: ${profitPercent.toFixed(2)}% (${accountPnL.toFixed(1)}% acct) | ` +
`Unrealized: $${trade.unrealizedPnL.toFixed(2)} | ` +
`Peak: $${trade.peakPnL.toFixed(2)} | ` +
`MFE: ${trade.maxFavorableExcursion.toFixed(2)}% | ` +
`MAE: ${trade.maxAdverseExcursion.toFixed(2)}%`
)
}
// Check exit conditions (in order of priority)
// 1. Emergency stop (-2%)
if (this.shouldEmergencyStop(currentPrice, trade)) {
console.log(`🚨 EMERGENCY STOP: ${trade.symbol}`)
await this.executeExit(trade, 100, 'emergency', currentPrice)
return
}
// 2. Stop loss
if (!trade.tp1Hit && this.shouldStopLoss(currentPrice, trade)) {
console.log(`🔴 STOP LOSS: ${trade.symbol} at ${profitPercent.toFixed(2)}%`)
await this.executeExit(trade, 100, 'SL', currentPrice)
return
}
// 3. Take profit 1 (closes configured %)
if (!trade.tp1Hit && this.shouldTakeProfit1(currentPrice, trade)) {
console.log(`🎉 TP1 HIT: ${trade.symbol} at ${profitPercent.toFixed(2)}%`)
// CRITICAL: Set flag BEFORE async executeExit to prevent race condition
// Multiple monitoring loops can trigger TP1 simultaneously if we wait until after
trade.tp1Hit = true
await this.executeExit(trade, this.config.takeProfit1SizePercent, 'TP1', currentPrice)
trade.currentSize = trade.positionSize * ((100 - this.config.takeProfit1SizePercent) / 100)
const newStopLossPrice = this.calculatePrice(
trade.entryPrice,
this.config.profitLockAfterTP1Percent, // Lock profit on remaining position
trade.direction
)
trade.stopLossPrice = newStopLossPrice
trade.slMovedToBreakeven = true
console.log(`🔒 SL moved to lock +${this.config.profitLockAfterTP1Percent}% profit (${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
// BUT: Only if this is the ONLY active trade on this symbol
// Multiple positions on same symbol = can't distinguish which orders belong to which trade
try {
const otherTradesOnSymbol = Array.from(this.activeTrades.values()).filter(
t => t.symbol === trade.symbol && t.id !== trade.id
)
if (otherTradesOnSymbol.length > 0) {
console.log(`⚠️ Multiple trades on ${trade.symbol} detected (${otherTradesOnSymbol.length + 1} total)`)
console.log(`⚠️ Skipping order cancellation to avoid wiping other positions' orders`)
console.log(`⚠️ Relying on Position Manager software monitoring for remaining ${100 - this.config.takeProfit1SizePercent}%`)
} else {
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
}
// 4. Profit lock trigger
if (
trade.tp1Hit &&
!trade.slMovedToProfit &&
profitPercent >= this.config.profitLockTriggerPercent
) {
console.log(`🔐 Profit lock trigger: ${trade.symbol}`)
trade.stopLossPrice = this.calculatePrice(
trade.entryPrice,
this.config.profitLockPercent,
trade.direction
)
trade.slMovedToProfit = true
console.log(`🎯 SL moved to +${this.config.profitLockPercent}%: ${trade.stopLossPrice.toFixed(4)}`)
// Save state after profit lock
await this.saveTradeState(trade)
}
// 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)}%`)
// CRITICAL: Set flag BEFORE any async operations to prevent race condition
trade.tp2Hit = true
// Calculate how much to close based on TP2 size percent
const percentToClose = this.config.takeProfit2SizePercent
// CRITICAL FIX: If percentToClose is 0, don't call executeExit (would close 100% due to minOrderSize)
// Instead, just mark TP2 as hit and activate trailing stop on full remaining position
if (percentToClose === 0) {
trade.trailingStopActive = true // Activate trailing stop immediately
console.log(`🏃 TP2-as-Runner activated: ${((trade.currentSize / trade.positionSize) * 100).toFixed(1)}% remaining with trailing stop`)
console.log(`📊 No position closed at TP2 - full ${trade.currentSize.toFixed(2)} USD remains as runner`)
// Save state after TP2
await this.saveTradeState(trade)
return
}
// If percentToClose > 0, execute partial close
await this.executeExit(trade, percentToClose, 'TP2', currentPrice)
// If some position remains, update size and activate trailing stop
if (percentToClose < 100) {
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)
if (trade.tp2Hit && this.config.useTrailingStop) {
// Check if trailing stop should be activated
if (!trade.trailingStopActive && profitPercent >= this.config.trailingStopActivation) {
trade.trailingStopActive = true
console.log(`🎯 Trailing stop activated at +${profitPercent.toFixed(2)}%`)
}
// If trailing stop is active, adjust SL dynamically
if (trade.trailingStopActive) {
// Calculate ATR-based trailing distance
let trailingDistancePercent: number
if (trade.atrAtEntry && trade.atrAtEntry > 0) {
// ATR-based: Use ATR% * multiplier
const atrPercent = (trade.atrAtEntry / currentPrice) * 100
const rawDistance = atrPercent * this.config.trailingStopAtrMultiplier
// Clamp between min and max
trailingDistancePercent = Math.max(
this.config.trailingStopMinPercent,
Math.min(this.config.trailingStopMaxPercent, rawDistance)
)
console.log(`📊 ATR-based trailing: ${trade.atrAtEntry.toFixed(4)} (${atrPercent.toFixed(2)}%) × ${this.config.trailingStopAtrMultiplier}x = ${trailingDistancePercent.toFixed(2)}%`)
} else {
// Fallback to configured legacy percent with min/max clamping
trailingDistancePercent = Math.max(
this.config.trailingStopMinPercent,
Math.min(this.config.trailingStopMaxPercent, this.config.trailingStopPercent)
)
console.log(`⚠️ No ATR data, using fallback: ${trailingDistancePercent.toFixed(2)}%`)
}
const trailingStopPrice = this.calculatePrice(
trade.peakPrice,
-trailingDistancePercent, // Trail below peak
trade.direction
)
// Only move SL up (for long) or down (for short), never backwards
const shouldUpdate = trade.direction === 'long'
? trailingStopPrice > trade.stopLossPrice
: trailingStopPrice < trade.stopLossPrice
if (shouldUpdate) {
const oldSL = trade.stopLossPrice
trade.stopLossPrice = trailingStopPrice
console.log(`📈 Trailing SL updated: ${oldSL.toFixed(4)}${trailingStopPrice.toFixed(4)} (${trailingDistancePercent.toFixed(2)}% below peak $${trade.peakPrice.toFixed(4)})`)
// Save state after trailing SL update (every 10 updates to avoid spam)
if (trade.priceCheckCount % 10 === 0) {
await this.saveTradeState(trade)
}
}
// Check if trailing stop hit
if (this.shouldStopLoss(currentPrice, trade)) {
console.log(`🔴 TRAILING STOP HIT: ${trade.symbol} at ${profitPercent.toFixed(2)}%`)
await this.executeExit(trade, 100, 'SL', currentPrice)
return
}
}
}
}
/**
* Execute exit (close position)
*
* Rate limit handling: If 429 error occurs, marks trade for retry
* instead of removing it from monitoring (prevents orphaned positions)
*/
private async executeExit(
trade: ActiveTrade,
percentToClose: number,
reason: ExitResult['reason'],
currentPrice: number
): Promise<void> {
try {
console.log(`🔴 Executing ${reason} for ${trade.symbol} (${percentToClose}%)`)
const result = await closePosition({
symbol: trade.symbol,
percentToClose,
slippageTolerance: this.config.slippageTolerance,
})
if (!result.success) {
const errorMsg = result.error || 'Unknown error'
// Check if it's a rate limit error
if (errorMsg.includes('429') || errorMsg.toLowerCase().includes('rate limit')) {
console.error(`⚠️ Rate limited while closing ${trade.symbol} - will retry on next price update`)
// DON'T remove trade from monitoring - let it retry naturally
// The retry logic in closePosition() already handled 3 attempts
// Next price update will trigger another exit attempt
return
}
console.error(`❌ Failed to close ${trade.symbol}:`, errorMsg)
return
}
// Update trade state
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: result.closePrice || currentPrice,
exitReason: reason as 'TP1' | 'TP2' | 'SL' | 'SOFT_SL' | 'HARD_SL' | 'manual' | 'emergency',
realizedPnL: trade.realizedPnL,
exitOrderTx: result.transactionSignature || 'MANUAL_CLOSE',
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('💾 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)
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: $${(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
} catch (error) {
console.error(`❌ Error executing exit for ${trade.symbol}:`, error)
}
}
/**
* Decision helpers
*/
private shouldEmergencyStop(price: number, trade: ActiveTrade): boolean {
if (trade.direction === 'long') {
return price <= trade.emergencyStopPrice
} else {
return price >= trade.emergencyStopPrice
}
}
private shouldStopLoss(price: number, trade: ActiveTrade): boolean {
if (trade.direction === 'long') {
return price <= trade.stopLossPrice
} else {
return price >= trade.stopLossPrice
}
}
private shouldTakeProfit1(price: number, trade: ActiveTrade): boolean {
if (trade.direction === 'long') {
return price >= trade.tp1Price
} else {
return price <= trade.tp1Price
}
}
private shouldTakeProfit2(price: number, trade: ActiveTrade): boolean {
if (trade.direction === 'long') {
return price >= trade.tp2Price
} else {
return price <= trade.tp2Price
}
}
/**
* Calculate profit percentage
*/
private calculateProfitPercent(
entryPrice: number,
currentPrice: number,
direction: 'long' | 'short'
): number {
if (direction === 'long') {
return ((currentPrice - entryPrice) / entryPrice) * 100
} else {
return ((entryPrice - currentPrice) / entryPrice) * 100
}
}
/**
* Calculate price based on percentage
*/
private calculatePrice(
entryPrice: number,
percent: number,
direction: 'long' | 'short'
): number {
if (direction === 'long') {
return entryPrice * (1 + percent / 100)
} else {
return entryPrice * (1 - percent / 100)
}
}
/**
* Emergency close all positions
*/
async closeAll(): Promise<void> {
console.log('🚨 EMERGENCY: Closing all positions')
const trades = Array.from(this.activeTrades.values())
for (const trade of trades) {
await this.executeExit(trade, 100, 'emergency', trade.lastPrice)
}
console.log('✅ All positions closed')
}
/**
* Save trade state to database (for persistence across restarts)
*/
private async saveTradeState(trade: ActiveTrade): Promise<void> {
try {
await updateTradeState({
positionId: trade.positionId,
currentSize: trade.currentSize,
tp1Hit: trade.tp1Hit,
slMovedToBreakeven: trade.slMovedToBreakeven,
slMovedToProfit: trade.slMovedToProfit,
stopLossPrice: trade.stopLossPrice,
realizedPnL: trade.realizedPnL,
unrealizedPnL: trade.unrealizedPnL,
peakPnL: trade.peakPnL,
lastPrice: trade.lastPrice,
})
} catch (error) {
console.error('❌ Failed to save trade state:', error)
// Don't throw - state save is non-critical
}
}
/**
* 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
*/
getStatus(): {
isMonitoring: boolean
activeTradesCount: number
symbols: string[]
} {
const symbols = [...new Set(
Array.from(this.activeTrades.values()).map(t => t.symbol)
)]
return {
isMonitoring: this.isMonitoring,
activeTradesCount: this.activeTrades.size,
symbols,
}
}
}
// Singleton instance
let positionManagerInstance: PositionManager | null = null
let initPromise: Promise<void> | null = null
export function getPositionManager(): PositionManager {
if (!positionManagerInstance) {
positionManagerInstance = new PositionManager()
// Initialize asynchronously (restore trades from database)
if (!initPromise) {
initPromise = positionManagerInstance.initialize().catch(error => {
console.error('❌ Failed to initialize Position Manager:', error)
})
}
}
return positionManagerInstance
}
export async function getInitializedPositionManager(): Promise<PositionManager> {
const manager = getPositionManager()
if (initPromise) {
await initPromise
}
return manager
}