feat: Implement re-entry analytics system with fresh TradingView data

- Add market data cache service (5min expiry) for storing TradingView metrics
- Create /api/trading/market-data webhook endpoint for continuous data updates
- Add /api/analytics/reentry-check endpoint for validating manual trades
- Update execute endpoint to auto-cache metrics from incoming signals
- Enhance Telegram bot with pre-execution analytics validation
- Support --force flag to override analytics blocks
- Use fresh ADX/ATR/RSI data when available, fallback to historical
- Apply performance modifiers: -20 for losing streaks, +10 for winning
- Minimum re-entry score 55 (vs 60 for new signals)
- Fail-open design: proceeds if analytics unavailable
- Show data freshness and source in Telegram responses
- Add comprehensive setup guide in docs/guides/REENTRY_ANALYTICS_QUICKSTART.md

Phase 1 implementation for smart manual trade validation.
This commit is contained in:
mindesbunister
2025-11-07 20:40:07 +01:00
parent 6d5991172a
commit 9b767342dc
14 changed files with 1150 additions and 568 deletions

View File

@@ -27,7 +27,6 @@ export interface OpenPositionResult {
transactionSignature?: string
fillPrice?: number
fillSize?: number
fillNotionalUSD?: number
slippage?: number
error?: string
isPhantom?: boolean // Position opened but size mismatch detected
@@ -46,8 +45,6 @@ export interface ClosePositionResult {
closePrice?: number
closedSize?: number
realizedPnL?: number
fullyClosed?: boolean
remainingSize?: number
error?: string
}
@@ -127,7 +124,6 @@ export async function openPosition(
transactionSignature: mockTxSig,
fillPrice: oraclePrice,
fillSize: baseAssetSize,
fillNotionalUSD: baseAssetSize * oraclePrice,
slippage: 0,
}
}
@@ -183,22 +179,19 @@ export async function openPosition(
if (position && position.side !== 'none') {
const fillPrice = position.entryPrice
const filledBaseSize = Math.abs(position.size)
const fillNotionalUSD = filledBaseSize * fillPrice
const slippage = Math.abs((fillPrice - oraclePrice) / oraclePrice) * 100
// CRITICAL: Validate actual position size vs expected
// Phantom trade detection: Check if position is significantly smaller than expected
const actualSizeUSD = position.size * fillPrice
const expectedSizeUSD = params.sizeUSD
const sizeRatio = expectedSizeUSD > 0 ? fillNotionalUSD / expectedSizeUSD : 1
const sizeRatio = actualSizeUSD / expectedSizeUSD
console.log(`💰 Fill details:`)
console.log(` Fill price: $${fillPrice.toFixed(4)}`)
console.log(` Filled base size: ${filledBaseSize.toFixed(4)} ${params.symbol.split('-')[0]}`)
console.log(` Filled notional: $${fillNotionalUSD.toFixed(2)}`)
console.log(` Slippage: ${slippage.toFixed(3)}%`)
console.log(` Expected size: $${expectedSizeUSD.toFixed(2)}`)
console.log(` Actual size: $${fillNotionalUSD.toFixed(2)}`)
console.log(` Actual size: $${actualSizeUSD.toFixed(2)}`)
console.log(` Size ratio: ${(sizeRatio * 100).toFixed(1)}%`)
// Flag as phantom if actual size is less than 50% of expected
@@ -207,7 +200,7 @@ export async function openPosition(
if (isPhantom) {
console.error(`🚨 PHANTOM POSITION DETECTED!`)
console.error(` Expected: $${expectedSizeUSD.toFixed(2)}`)
console.error(` Actual: $${fillNotionalUSD.toFixed(2)}`)
console.error(` Actual: $${actualSizeUSD.toFixed(2)}`)
console.error(` This indicates the order was rejected or partially filled by Drift`)
}
@@ -215,11 +208,10 @@ export async function openPosition(
success: true,
transactionSignature: txSig,
fillPrice,
fillSize: filledBaseSize,
fillNotionalUSD,
fillSize: position.size, // Use actual size from Drift, not calculated
slippage,
isPhantom,
actualSizeUSD: fillNotionalUSD,
actualSizeUSD,
}
} else {
// Position not found yet (may be DRY_RUN mode)
@@ -231,7 +223,6 @@ export async function openPosition(
transactionSignature: txSig,
fillPrice: oraclePrice,
fillSize: baseAssetSize,
fillNotionalUSD: baseAssetSize * oraclePrice,
slippage: 0,
}
}
@@ -500,24 +491,19 @@ export async function closePosition(
}
// Calculate size to close
const sizeToClose = position.size * (params.percentToClose / 100)
const remainingSize = position.size - sizeToClose
let sizeToClose = position.size * (params.percentToClose / 100)
// CRITICAL: Check if remaining position would be below Drift minimum
// If so, Drift will force-close the entire position anyway
// Better to detect this upfront and return fullyClosed=true
const willForceFullClose = remainingSize > 0 && remainingSize < marketConfig.minOrderSize
if (willForceFullClose && params.percentToClose < 100) {
console.log(`⚠️ WARNING: Remaining size ${remainingSize.toFixed(4)} would be below Drift minimum ${marketConfig.minOrderSize}`)
console.log(` Drift will force-close entire position. Proceeding with 100% close.`)
console.log(` 💡 TIP: Increase position size or decrease TP2 close % to enable runner`)
// CRITICAL FIX: If calculated size is below minimum, close 100% instead
// This prevents "runner" positions from being too small to close
if (sizeToClose < marketConfig.minOrderSize) {
console.log(`⚠️ Calculated close size ${sizeToClose.toFixed(4)} is below minimum ${marketConfig.minOrderSize}`)
console.log(` Forcing 100% close to avoid Drift rejection`)
sizeToClose = position.size // Close entire position
}
console.log(`📝 Close order details:`)
console.log(` Current position: ${position.size.toFixed(4)} ${position.side}`)
console.log(` Closing: ${params.percentToClose}% (${sizeToClose.toFixed(4)})`)
console.log(` Remaining after close: ${remainingSize.toFixed(4)}`)
console.log(` Entry price: $${position.entryPrice.toFixed(4)}`)
console.log(` Unrealized P&L: $${position.unrealizedPnL.toFixed(2)}`)
@@ -532,18 +518,10 @@ export async function closePosition(
console.log('🧪 DRY RUN MODE: Simulating close order (not executing on blockchain)')
// Calculate realized P&L with leverage (default 10x in dry run)
// For LONG: profit when exit > entry → (exit - entry) / entry
// For SHORT: profit when exit < entry → (entry - exit) / entry
const priceDiff = position.side === 'long'
? (oraclePrice - position.entryPrice) // Long: profit when price rises
: (position.entryPrice - oraclePrice) // Short: profit when price falls
const profitPercent = (priceDiff / position.entryPrice) * 100
const profitPercent = ((oraclePrice - position.entryPrice) / position.entryPrice) * 100 * (position.side === 'long' ? 1 : -1)
const closedNotional = sizeToClose * oraclePrice
const leverage = 10
const collateral = closedNotional / leverage
const realizedPnL = collateral * (profitPercent / 100) * leverage
const accountPnLPercent = profitPercent * leverage
const realizedPnL = (closedNotional * profitPercent) / 100
const accountPnLPercent = profitPercent * 10 // display using default leverage
const mockTxSig = `DRY_RUN_CLOSE_${Date.now()}_${Math.random().toString(36).substring(7)}`
@@ -591,13 +569,8 @@ export async function closePosition(
console.log('✅ Transaction confirmed on-chain')
// Calculate realized P&L with leverage
// For LONG: profit when exit > entry → (exit - entry) / entry
// For SHORT: profit when exit < entry → (entry - exit) / entry
const priceDiff = position.side === 'long'
? (oraclePrice - position.entryPrice) // Long: profit when price rises
: (position.entryPrice - oraclePrice) // Short: profit when price falls
const profitPercent = (priceDiff / position.entryPrice) * 100
// CRITICAL: P&L must account for leverage and be calculated on USD notional, not base asset size
const profitPercent = ((oraclePrice - position.entryPrice) / position.entryPrice) * 100 * (position.side === 'long' ? 1 : -1)
// Get leverage from user account (defaults to 10x if not found)
let leverage = 10
@@ -611,10 +584,9 @@ export async function closePosition(
console.log('⚠️ Could not determine leverage from account, using 10x default')
}
// Calculate closed notional value (USD) and actual P&L with leverage
// Calculate closed notional value (USD)
const closedNotional = sizeToClose * oraclePrice
const collateral = closedNotional / leverage
const realizedPnL = collateral * (profitPercent / 100) * leverage // Leveraged P&L
const realizedPnL = (closedNotional * profitPercent) / 100
const accountPnLPercent = profitPercent * leverage
console.log(`💰 Close details:`)
@@ -623,21 +595,13 @@ export async function closePosition(
console.log(` Closed notional: $${closedNotional.toFixed(2)}`)
console.log(` Realized P&L: $${realizedPnL.toFixed(2)}`)
// Check remaining position size after close
const updatedPosition = await driftService.getPosition(marketConfig.driftMarketIndex)
const actualRemainingSize = updatedPosition ? Math.abs(updatedPosition.size) : 0
const fullyClosed = !updatedPosition || actualRemainingSize === 0 || willForceFullClose
if (fullyClosed) {
// If closing 100%, cancel all remaining orders for this market
if (params.percentToClose === 100) {
console.log('🗑️ Position fully closed, cancelling remaining orders...')
const cancelResult = await cancelAllOrders(params.symbol)
if (cancelResult.success && (cancelResult.cancelledCount || 0) > 0) {
if (cancelResult.success && cancelResult.cancelledCount! > 0) {
console.log(`✅ Cancelled ${cancelResult.cancelledCount} orders`)
}
} else if (params.percentToClose === 100) {
console.log(
`⚠️ Requested 100% close but ${actualRemainingSize.toFixed(4)} base remains on-chain`
)
}
return {
@@ -646,8 +610,6 @@ export async function closePosition(
closePrice: oraclePrice,
closedSize: sizeToClose,
realizedPnL,
fullyClosed,
remainingSize: actualRemainingSize,
}
} catch (error) {