feat: ATR-based trailing stop + rate limit monitoring
MAJOR FIXES: - ATR-based trailing stop for runners (was fixed 0.3%, now adapts to volatility) - Fixes runners with +7-9% MFE exiting for losses - Typical improvement: 2.24x more room (0.3% → 0.67% at 0.45% ATR) - Enhanced rate limit logging with database tracking - New /api/analytics/rate-limits endpoint for monitoring DETAILS: - Position Manager: Calculate trailing as (atrAtEntry / price × 100) × multiplier - Config: TRAILING_STOP_ATR_MULTIPLIER=1.5, MIN=0.25%, MAX=0.9% - Settings UI: Added ATR multiplier controls - Rate limits: Log hits/recoveries/exhaustions to SystemEvent table - Documentation: ATR_TRAILING_STOP_FIX.md + RATE_LIMIT_MONITORING.md IMPACT: - Runners can now capture big moves (like morning's $172→$162 SOL drop) - Rate limit visibility prevents silent failures - Data-driven optimization for RPC endpoint health
This commit is contained in:
@@ -632,19 +632,76 @@ async function retryWithBackoff<T>(
|
||||
maxRetries: number = 3,
|
||||
baseDelay: number = 2000
|
||||
): Promise<T> {
|
||||
const startTime = Date.now()
|
||||
|
||||
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
||||
try {
|
||||
return await fn()
|
||||
const result = await fn()
|
||||
|
||||
// Log successful execution time for rate limit monitoring
|
||||
if (attempt > 0) {
|
||||
const totalTime = Date.now() - startTime
|
||||
console.log(`✅ Retry successful after ${totalTime}ms (${attempt} retries)`)
|
||||
|
||||
// Log to database for analytics
|
||||
try {
|
||||
const { logSystemEvent } = await import('../database/trades')
|
||||
await logSystemEvent('rate_limit_recovered', 'Drift RPC rate limit recovered after retries', {
|
||||
retriesNeeded: attempt,
|
||||
totalTimeMs: totalTime,
|
||||
recoveredAt: new Date().toISOString(),
|
||||
})
|
||||
} catch (dbError) {
|
||||
console.error('Failed to log rate limit recovery:', dbError)
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error)
|
||||
const isRateLimit = errorMessage.includes('429') || errorMessage.includes('rate limit')
|
||||
|
||||
if (!isRateLimit || attempt === maxRetries) {
|
||||
// Log final failure with full context
|
||||
if (isRateLimit && attempt === maxRetries) {
|
||||
const totalTime = Date.now() - startTime
|
||||
console.error(`❌ RATE LIMIT EXHAUSTED: Failed after ${maxRetries} retries and ${totalTime}ms`)
|
||||
console.error(` Error: ${errorMessage}`)
|
||||
|
||||
// Log to database for analytics
|
||||
try {
|
||||
const { logSystemEvent } = await import('../database/trades')
|
||||
await logSystemEvent('rate_limit_exhausted', 'Drift RPC rate limit exceeded max retries', {
|
||||
maxRetries,
|
||||
totalTimeMs: totalTime,
|
||||
errorMessage: errorMessage.substring(0, 500),
|
||||
failedAt: new Date().toISOString(),
|
||||
})
|
||||
} catch (dbError) {
|
||||
console.error('Failed to log rate limit exhaustion:', dbError)
|
||||
}
|
||||
}
|
||||
throw error
|
||||
}
|
||||
|
||||
const delay = baseDelay * Math.pow(2, attempt)
|
||||
console.log(`⏳ Rate limited, retrying in ${delay / 1000}s... (attempt ${attempt + 1}/${maxRetries})`)
|
||||
console.log(`⏳ Rate limited (429), retrying in ${delay / 1000}s... (attempt ${attempt + 1}/${maxRetries})`)
|
||||
console.log(` Error context: ${errorMessage.substring(0, 100)}`)
|
||||
|
||||
// Log rate limit hit to database
|
||||
try {
|
||||
const { logSystemEvent } = await import('../database/trades')
|
||||
await logSystemEvent('rate_limit_hit', 'Drift RPC rate limit encountered', {
|
||||
attempt: attempt + 1,
|
||||
maxRetries,
|
||||
delayMs: delay,
|
||||
errorSnippet: errorMessage.substring(0, 200),
|
||||
hitAt: new Date().toISOString(),
|
||||
})
|
||||
} catch (dbError) {
|
||||
console.error('Failed to log rate limit hit:', dbError)
|
||||
}
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, delay))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ export interface ActiveTrade {
|
||||
entryTime: number
|
||||
positionSize: number
|
||||
leverage: number
|
||||
atrAtEntry?: number // ATR value at entry for ATR-based trailing stop
|
||||
|
||||
// Targets
|
||||
stopLossPrice: number
|
||||
@@ -761,6 +762,22 @@ export class PositionManager {
|
||||
// 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.tp2Hit = true
|
||||
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, mark TP2 as hit and activate trailing stop
|
||||
@@ -787,9 +804,34 @@ export class PositionManager {
|
||||
|
||||
// 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,
|
||||
-this.config.trailingStopPercent, // Trail below peak
|
||||
-trailingDistancePercent, // Trail below peak
|
||||
trade.direction
|
||||
)
|
||||
|
||||
@@ -802,7 +844,7 @@ export class PositionManager {
|
||||
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)})`)
|
||||
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) {
|
||||
|
||||
Reference in New Issue
Block a user