1041 lines
38 KiB
TypeScript
1041 lines
38 KiB
TypeScript
/**
|
|
* 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
|
|
|
|
// 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: 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)
|
|
const positionSizeUSD = position.size * currentPrice
|
|
const trackedSizeUSD = trade.currentSize
|
|
const sizeDiffPercent = Math.abs(positionSizeUSD - trackedSizeUSD) / trackedSizeUSD * 100
|
|
|
|
// 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)}`)
|
|
|
|
// 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
|
|
|
|
await this.handlePostTp1Adjustments(trade, 'on-chain TP1 detection')
|
|
|
|
} 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)
|
|
|
|
} 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)
|
|
// 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 accountPnL = profitPercent * trade.leverage
|
|
const estimatedPnL = (trade.currentSize * accountPnL) / 100
|
|
|
|
console.log(`💰 Estimated P&L for lost trade: ${profitPercent.toFixed(2)}% price → ${accountPnL.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
|
|
const sizeForPnL = trade.currentSize > 0 ? 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(` Using for P&L calc: $${sizeForPnL.toFixed(2)}`)
|
|
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'
|
|
|
|
// Calculate P&L first (set to 0 for phantom trades)
|
|
let realizedPnL = 0
|
|
if (!wasPhantom) {
|
|
const profitPercent = this.calculateProfitPercent(
|
|
trade.entryPrice,
|
|
currentPrice,
|
|
trade.direction
|
|
)
|
|
const accountPnL = profitPercent * trade.leverage
|
|
realizedPnL = (sizeForPnL * accountPnL) / 100
|
|
}
|
|
|
|
// 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 = realizedPnL > 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) {
|
|
// More than 0.5% profit - must be TP1
|
|
exitReason = 'TP1'
|
|
} else if (realizedPnL < 0) {
|
|
// Loss - must be SL
|
|
exitReason = 'SL'
|
|
}
|
|
// else: small profit/loss near breakeven, default to SL (could be manual close)
|
|
}
|
|
|
|
// Update database
|
|
const holdTimeSeconds = Math.floor((Date.now() - trade.entryTime) / 1000)
|
|
try {
|
|
await updateTradeExit({
|
|
positionId: trade.positionId,
|
|
exitPrice: currentPrice,
|
|
exitReason,
|
|
realizedPnL,
|
|
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: $${realizedPnL.toFixed(2)}`)
|
|
} catch (dbError) {
|
|
console.error('❌ Failed to save external closure:', dbError)
|
|
}
|
|
|
|
// Remove from monitoring
|
|
await this.removeTrade(trade.id)
|
|
return
|
|
}
|
|
|
|
// Position exists but size mismatch (partial close by TP1?)
|
|
if (position.size < trade.currentSize * 0.95) { // 5% tolerance
|
|
console.log(`⚠️ Position size mismatch: expected ${trade.currentSize}, got ${position.size}`)
|
|
|
|
// CRITICAL: If mismatch is extreme (>50%), this is a phantom trade
|
|
const sizeRatio = (position.size * currentPrice) / 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: $${(position.size * currentPrice).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 (convert base asset size to USD using current price)
|
|
trade.currentSize = position.size * currentPrice
|
|
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)
|
|
}
|
|
}
|
|
|
|
// Update trade data
|
|
trade.lastPrice = currentPrice
|
|
trade.lastUpdateTime = Date.now()
|
|
trade.priceCheckCount++
|
|
|
|
// 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)}%`)
|
|
await this.executeExit(trade, this.config.takeProfit1SizePercent, 'TP1', currentPrice)
|
|
|
|
// 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')
|
|
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 && this.shouldTakeProfit2(currentPrice, trade)) {
|
|
console.log(`🎊 TP2 HIT: ${trade.symbol} at ${profitPercent.toFixed(2)}%`)
|
|
|
|
// Calculate how much to close based on TP2 size percent
|
|
const percentToClose = this.config.takeProfit2SizePercent
|
|
|
|
await this.executeExit(trade, percentToClose, 'TP2', currentPrice)
|
|
|
|
// 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)
|
|
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) {
|
|
const trailingStopPrice = this.calculatePrice(
|
|
trade.peakPrice,
|
|
-this.config.trailingStopPercent, // 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)} (${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) {
|
|
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)
|
|
*/
|
|
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) {
|
|
console.error(`❌ Failed to close ${trade.symbol}:`, result.error)
|
|
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)}`)
|
|
}
|
|
|
|
// 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')
|
|
}
|
|
|
|
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)}`)
|
|
|
|
await this.refreshExitOrders(trade, {
|
|
stopLossPrice: newStopLossPrice,
|
|
tp1Price: trade.tp2Price,
|
|
tp1SizePercent: 100,
|
|
tp2Price: trade.tp2Price,
|
|
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)
|
|
*/
|
|
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
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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
|
|
}
|