Bug: Position Manager was comparing ANY position on the symbol to the trade being tracked, without verifying entry price match. When a new position opened, it would think the old tracked trade 'closed externally' and cancel ALL orders - including the new position's exit orders. Fix: Added entry price verification (0.5% tolerance). If position entry price doesn't match the tracked trade, mark the old trade as 'lost tracking' and remove from monitoring WITHOUT cancelling orders (they belong to the new position). This prevents the catastrophic scenario where exit orders are repeatedly cancelled, leaving positions unprotected.
791 lines
25 KiB
TypeScript
791 lines
25 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)
|
|
|
|
// 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,
|
|
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)
|
|
|
|
if (position === null || position.size === 0) {
|
|
// Position closed externally (by on-chain TP/SL order)
|
|
console.log(`⚠️ Position ${trade.symbol} was closed externally (by on-chain order)`)
|
|
} else {
|
|
// CRITICAL: Verify this position matches the trade we're tracking
|
|
// Different entry price means this is a NEW position, not ours
|
|
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)
|
|
try {
|
|
await updateTradeExit({
|
|
positionId: trade.positionId,
|
|
exitPrice: trade.lastPrice,
|
|
exitReason: 'SOFT_SL', // Unknown - just mark as closed
|
|
realizedPnL: 0,
|
|
exitOrderTx: 'UNKNOWN_CLOSURE',
|
|
holdTimeSeconds: Math.floor((Date.now() - trade.entryTime) / 1000),
|
|
maxDrawdown: 0,
|
|
maxGain: trade.peakPnL,
|
|
})
|
|
console.log(`💾 Old trade marked as closed (lost tracking)`)
|
|
} 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) {
|
|
|
|
// Save currentSize before it becomes 0
|
|
const sizeBeforeClosure = trade.currentSize
|
|
|
|
// Determine exit reason based on price
|
|
let exitReason: 'TP1' | 'TP2' | 'SL' | 'SOFT_SL' | 'HARD_SL' = 'SL'
|
|
|
|
if (trade.direction === 'long') {
|
|
if (currentPrice >= trade.tp2Price) {
|
|
exitReason = 'TP2'
|
|
} else if (currentPrice >= trade.tp1Price) {
|
|
exitReason = 'TP1'
|
|
} else if (currentPrice <= trade.stopLossPrice) {
|
|
exitReason = 'HARD_SL' // Assume hard stop if below SL
|
|
}
|
|
} else {
|
|
// Short
|
|
if (currentPrice <= trade.tp2Price) {
|
|
exitReason = 'TP2'
|
|
} else if (currentPrice <= trade.tp1Price) {
|
|
exitReason = 'TP1'
|
|
} else if (currentPrice >= trade.stopLossPrice) {
|
|
exitReason = 'HARD_SL' // Assume hard stop if above SL
|
|
}
|
|
}
|
|
|
|
// Calculate final P&L using size BEFORE closure
|
|
const profitPercent = this.calculateProfitPercent(
|
|
trade.entryPrice,
|
|
currentPrice,
|
|
trade.direction
|
|
)
|
|
const accountPnL = profitPercent * trade.leverage
|
|
const realizedPnL = (sizeBeforeClosure * accountPnL) / 100
|
|
|
|
// 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: 0,
|
|
maxGain: trade.peakPnL,
|
|
})
|
|
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}`)
|
|
// Update current size to match reality
|
|
trade.currentSize = position.size * (trade.positionSize / trade.currentSize) // Convert to USD
|
|
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
|
|
if (trade.unrealizedPnL > trade.peakPnL) {
|
|
trade.peakPnL = trade.unrealizedPnL
|
|
}
|
|
|
|
// 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)}`
|
|
)
|
|
}
|
|
|
|
// 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)
|
|
trade.stopLossPrice = this.calculatePrice(
|
|
trade.entryPrice,
|
|
this.config.breakEvenTriggerPercent, // Use configured breakeven level
|
|
trade.direction
|
|
)
|
|
trade.slMovedToBreakeven = true
|
|
|
|
console.log(`🔒 SL moved to +${this.config.breakEvenTriggerPercent}% (${this.config.takeProfit1SizePercent}% closed, ${100 - this.config.takeProfit1SizePercent}% remaining): ${trade.stopLossPrice.toFixed(4)}`)
|
|
|
|
// 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 && 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: 0, // TODO: Track this
|
|
maxGain: trade.peakPnL,
|
|
})
|
|
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
|
|
trade.currentSize -= result.closedSize || 0
|
|
|
|
console.log(`✅ 50% closed | Realized: $${result.realizedPnL?.toFixed(2)} | Remaining: ${trade.currentSize}`)
|
|
}
|
|
|
|
// 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
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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
|
|
}
|