Files
trading_bot_v4/lib/trading/position-manager.ts
mindesbunister 65e6a8efed Phase 1: Add MAE/MFE tracking and analytics schema
- Added 20+ analytics fields to Trade model (MAE/MFE, fill tracking, timing, market context, slippage)
- Implemented real-time MAE/MFE tracking in Position Manager (updates every 5s)
- Enhanced database schema with comprehensive trade analytics
- Updated all API endpoints to initialize MAE/MFE fields
- Modified updateTradeState() to persist MAE/MFE in configSnapshot

Database changes:
- maxFavorableExcursion/maxAdverseExcursion track best/worst profit %
- maxFavorablePrice/maxAdversePrice track exact price levels
- Fill tracking: tp1Filled, tp2Filled, softSlFilled, hardSlFilled
- Timing metrics: timeToTp1, timeToTp2, timeToSl
- Market context: atrAtEntry, adxAtEntry, volumeAtEntry, fundingRateAtEntry, basisAtEntry
- Slippage tracking: expectedEntryPrice, entrySlippagePct, expectedExitPrice, exitSlippagePct

Position Manager changes:
- Track MAE/MFE on every price check (2s interval)
- Throttled database updates (5s interval) via updateTradeMetrics()
- Persist MAE/MFE in trade state snapshots for recovery

Next: Phase 2 (market context capture) or Phase 3 (analytics API)
2025-10-29 20:34:03 +01:00

819 lines
26 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 (Maximum Adverse/Favorable Excursion)
maxFavorableExcursion: number // Best profit % reached
maxAdverseExcursion: number // Worst drawdown % reached
maxFavorablePrice: number // Best price hit
maxAdversePrice: number // Worst price hit
lastDbMetricsUpdate: number // Last time we updated MAE/MFE in DB (throttle to 5s)
// 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,
lastDbMetricsUpdate: Date.now(),
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)`)
// 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 or TP2?)
if (position.size < trade.currentSize * 0.95) { // 5% tolerance
console.log(`⚠️ Position size mismatch: expected ${trade.currentSize}, got ${position.size}`)
// Determine if this was TP1 or TP2 based on size
const remainingPercent = (position.size / trade.positionSize) * 100
if (!trade.tp1Hit && remainingPercent < 30) {
// First partial close, likely TP1 (should leave ~25%)
trade.tp1Hit = true
console.log(`✅ TP1 detected on-chain (${remainingPercent.toFixed(1)}% remaining)`)
} else if (trade.tp1Hit && !trade.tp2Hit && remainingPercent < 10) {
// Second partial close, likely TP2 (should leave ~5% runner)
trade.tp2Hit = true
console.log(`✅ TP2 detected on-chain (${remainingPercent.toFixed(1)}% runner remaining)`)
}
// Update current size to match reality
trade.currentSize = position.size * (trade.positionSize / trade.currentSize) // Convert to USD
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 MAE/MFE (Maximum Adverse/Favorable Excursion)
if (profitPercent > trade.maxFavorableExcursion) {
trade.maxFavorableExcursion = profitPercent
trade.maxFavorablePrice = currentPrice
}
if (profitPercent < trade.maxAdverseExcursion) {
trade.maxAdverseExcursion = profitPercent
trade.maxAdversePrice = currentPrice
}
// Update MAE/MFE in database (throttled to every 5 seconds to avoid spam)
if (Date.now() - trade.lastDbMetricsUpdate > 5000) {
await this.updateTradeMetrics(trade)
trade.lastDbMetricsUpdate = Date.now()
}
// 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 && !trade.tp2Hit && 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,
maxFavorableExcursion: trade.maxFavorableExcursion,
maxAdverseExcursion: trade.maxAdverseExcursion,
maxFavorablePrice: trade.maxFavorablePrice,
maxAdversePrice: trade.maxAdversePrice,
})
} 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,
}
}
/**
* Update MAE/MFE metrics in database (throttled)
*/
private async updateTradeMetrics(trade: ActiveTrade): Promise<void> {
try {
const { getPrismaClient } = await import('../database/trades')
const prisma = getPrismaClient()
await prisma.trade.update({
where: { id: trade.id },
data: {
maxFavorableExcursion: trade.maxFavorableExcursion,
maxAdverseExcursion: trade.maxAdverseExcursion,
maxFavorablePrice: trade.maxFavorablePrice,
maxAdversePrice: trade.maxAdversePrice,
},
})
} catch (error) {
// Silent failure to avoid disrupting monitoring loop
console.error('Failed to update trade metrics:', error)
}
}
}
// 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
}