feat: Add live position monitor to analytics dashboard

FEATURE: Real-time position monitoring with auto-refresh every 3 seconds

Implementation:
- New LivePosition interface for real-time trade data
- Auto-refresh hook fetches from /api/trading/positions every 3s
- Displays when Position Manager has active trades
- Shows: P&L (realized + unrealized), current price, TP/SL status, position age

Live Display Includes:
- Header: Symbol, direction (LONG/SHORT), leverage, age, price checks
- Real-time P&L: Profit %, account P&L %, color-coded green/red
- Price Info: Entry, current, position size (with % after TP1), total P&L
- Exit Targets: TP1 (✓ when hit), TP2/Runner, SL (@ B/E when moved)
- P&L Breakdown: Realized, unrealized, peak P&L

Technical:
- Added NEXT_PUBLIC_API_SECRET_KEY to .env for frontend auth
- Positions endpoint requires Bearer token authorization
- Updates every 3s via useEffect interval
- Only shows when monitoring.isActive && positions.length > 0

User Experience:
- Live pulsing green dot indicator
- Auto-updates without page refresh
- Position size shows % remaining after TP1 hit
- SL shows '@ B/E' badge when moved to breakeven
- Color-coded P&L (green profit, red loss)

Files:
- app/analytics/page.tsx: Live position monitor section + auto-refresh
- .env: Added NEXT_PUBLIC_API_SECRET_KEY

User Request: 'i would like to see a live status on the analytics page about an open position'
This commit is contained in:
mindesbunister
2025-11-15 18:29:33 +01:00
parent c6b34c45c4
commit be36d6aa86
2 changed files with 195 additions and 0 deletions

6
.env
View File

@@ -23,8 +23,14 @@ DRIFT_PROGRAM_ID=dRiftyHA39MWEi3m9aunc5MzRF1JYuBsbn6VPcn33UH
# API secret key for authenticating n8n webhook requests # API secret key for authenticating n8n webhook requests
# Generate with: openssl rand -hex 32 # Generate with: openssl rand -hex 32
# Or: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))" # Or: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
# API authentication secret for endpoints
# Generate with: openssl rand -hex 32
API_SECRET_KEY=2a344f0149442c857fb56c038c0c7d1b113883b830bec792c76f1e0efa15d6bb API_SECRET_KEY=2a344f0149442c857fb56c038c0c7d1b113883b830bec792c76f1e0efa15d6bb
# Public API key for frontend (must start with NEXT_PUBLIC_)
# This is safe to expose to the browser - it's for authenticated endpoints only
NEXT_PUBLIC_API_SECRET_KEY=2a344f0149442c857fb56c038c0c7d1b113883b830bec792c76f1e0efa15d6bb
# ================================ # ================================
# REQUIRED - SOLANA RPC ENDPOINT # REQUIRED - SOLANA RPC ENDPOINT
# ================================ # ================================

View File

@@ -90,11 +90,46 @@ interface VersionComparison {
descriptions: Record<string, string> descriptions: Record<string, string>
} }
interface LivePosition {
id: string
symbol: string
direction: 'long' | 'short'
entryPrice: number
currentPrice: number
entryTime: string
positionSize: number
currentSize: number
leverage: number
stopLoss: number
takeProfit1: number
takeProfit2: number
tp1Hit: boolean
slMovedToBreakeven: boolean
realizedPnL: number
unrealizedPnL: number
peakPnL: number
profitPercent: number
accountPnL: number
priceChecks: number
ageMinutes: number
}
interface LivePositionsData {
success: boolean
monitoring: {
isActive: boolean
tradeCount: number
symbols: string[]
}
positions: LivePosition[]
}
export default function AnalyticsPage() { export default function AnalyticsPage() {
const [stats, setStats] = useState<Stats | null>(null) const [stats, setStats] = useState<Stats | null>(null)
const [positions, setPositions] = useState<PositionSummary | null>(null) const [positions, setPositions] = useState<PositionSummary | null>(null)
const [lastTrade, setLastTrade] = useState<LastTrade | null>(null) const [lastTrade, setLastTrade] = useState<LastTrade | null>(null)
const [versionComparison, setVersionComparison] = useState<VersionComparison | null>(null) const [versionComparison, setVersionComparison] = useState<VersionComparison | null>(null)
const [livePositions, setLivePositions] = useState<LivePositionsData | null>(null)
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [selectedDays, setSelectedDays] = useState(30) const [selectedDays, setSelectedDays] = useState(30)
@@ -102,6 +137,17 @@ export default function AnalyticsPage() {
loadData() loadData()
}, [selectedDays]) }, [selectedDays])
// Auto-refresh live positions every 3 seconds
useEffect(() => {
loadLivePositions() // Initial load
const interval = setInterval(() => {
loadLivePositions()
}, 3000)
return () => clearInterval(interval)
}, [])
const loadData = async () => { const loadData = async () => {
setLoading(true) setLoading(true)
try { try {
@@ -127,6 +173,20 @@ export default function AnalyticsPage() {
setLoading(false) setLoading(false)
} }
const loadLivePositions = async () => {
try {
const res = await fetch('/api/trading/positions', {
headers: {
'Authorization': `Bearer ${process.env.NEXT_PUBLIC_API_SECRET_KEY || 'your-secret-key-here'}`
}
})
const data = await res.json()
setLivePositions(data)
} catch (error) {
console.error('Failed to load live positions:', error)
}
}
const clearManuallyClosed = async () => { const clearManuallyClosed = async () => {
if (!confirm('Clear all open trades from database? Use this if you manually closed positions in Drift UI.')) { if (!confirm('Clear all open trades from database? Use this if you manually closed positions in Drift UI.')) {
return return
@@ -207,6 +267,135 @@ export default function AnalyticsPage() {
</div> </div>
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8"> <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
{/* Live Position Monitor */}
{livePositions && livePositions.monitoring.isActive && livePositions.positions.length > 0 && (
<div className="mb-8">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center space-x-3">
<div className="flex items-center space-x-2">
<div className="w-3 h-3 bg-green-500 rounded-full animate-pulse"></div>
<h2 className="text-xl font-bold text-white">🔴 LIVE Position Monitor</h2>
</div>
<span className="text-xs text-gray-500">(updates every 3s)</span>
</div>
</div>
{livePositions.positions.map((pos) => {
const isProfitable = pos.profitPercent >= 0
const totalPnL = pos.realizedPnL + pos.unrealizedPnL
return (
<div key={pos.id} className="bg-gradient-to-br from-gray-800/80 to-gray-900/80 backdrop-blur-sm rounded-xl p-6 border-2 border-blue-500/50 shadow-xl mb-4">
{/* Header */}
<div className="flex items-start justify-between mb-6">
<div className="flex items-center space-x-4">
<div className="text-4xl">{pos.direction === 'long' ? '📈' : '📉'}</div>
<div>
<div className="flex items-center space-x-3">
<h3 className="text-2xl font-bold text-white">{pos.symbol}</h3>
<span className={`px-3 py-1 rounded-full text-sm font-bold ${pos.direction === 'long' ? 'bg-green-500/20 text-green-400' : 'bg-red-500/20 text-red-400'}`}>
{pos.direction.toUpperCase()}
</span>
<span className="px-3 py-1 rounded-full text-sm font-bold bg-purple-500/20 text-purple-400">
{pos.leverage}x
</span>
</div>
<div className="text-sm text-gray-400 mt-1">
Open for {pos.ageMinutes}m {pos.priceChecks} price checks
</div>
</div>
</div>
<div className="text-right">
<div className={`text-3xl font-bold ${isProfitable ? 'text-green-400' : 'text-red-400'}`}>
{isProfitable ? '+' : ''}{pos.profitPercent.toFixed(2)}%
</div>
<div className={`text-sm ${isProfitable ? 'text-green-400' : 'text-red-400'}`}>
{isProfitable ? '+' : ''}{pos.accountPnL.toFixed(2)}% account
</div>
</div>
</div>
{/* Price Info */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
<div className="bg-gray-700/30 rounded-lg p-4">
<div className="text-xs text-gray-400 mb-1">Entry Price</div>
<div className="text-lg font-bold text-white">${pos.entryPrice.toFixed(2)}</div>
</div>
<div className="bg-gray-700/30 rounded-lg p-4">
<div className="text-xs text-gray-400 mb-1">Current Price</div>
<div className={`text-lg font-bold ${isProfitable ? 'text-green-400' : 'text-red-400'}`}>
${pos.currentPrice.toFixed(2)}
</div>
</div>
<div className="bg-gray-700/30 rounded-lg p-4">
<div className="text-xs text-gray-400 mb-1">Position Size</div>
<div className="text-lg font-bold text-white">
${pos.currentSize.toFixed(2)}
{pos.tp1Hit && pos.currentSize < pos.positionSize && (
<span className="text-xs text-yellow-400 ml-2">({((pos.currentSize/pos.positionSize)*100).toFixed(0)}%)</span>
)}
</div>
</div>
<div className="bg-gray-700/30 rounded-lg p-4">
<div className="text-xs text-gray-400 mb-1">Total P&L</div>
<div className={`text-lg font-bold ${totalPnL >= 0 ? 'text-green-400' : 'text-red-400'}`}>
${totalPnL.toFixed(2)}
</div>
</div>
</div>
{/* Exit Targets */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-4">
<div className={`rounded-lg p-4 border-2 ${pos.tp1Hit ? 'bg-green-900/30 border-green-500' : 'bg-gray-700/20 border-gray-600'}`}>
<div className="flex items-center justify-between mb-2">
<div className="text-xs text-gray-400">TP1 Target</div>
{pos.tp1Hit && <span className="text-xs font-bold text-green-400"> HIT</span>}
</div>
<div className="text-lg font-bold text-white">${pos.takeProfit1.toFixed(2)}</div>
</div>
<div className="bg-gray-700/20 rounded-lg p-4 border-2 border-gray-600">
<div className="text-xs text-gray-400 mb-2">TP2 / Runner</div>
<div className="text-lg font-bold text-white">${pos.takeProfit2.toFixed(2)}</div>
</div>
<div className={`rounded-lg p-4 border-2 ${pos.slMovedToBreakeven ? 'bg-blue-900/30 border-blue-500' : 'bg-gray-700/20 border-gray-600'}`}>
<div className="flex items-center justify-between mb-2">
<div className="text-xs text-gray-400">Stop Loss</div>
{pos.slMovedToBreakeven && <span className="text-xs font-bold text-blue-400">@ B/E</span>}
</div>
<div className="text-lg font-bold text-white">${pos.stopLoss.toFixed(2)}</div>
</div>
</div>
{/* P&L Breakdown */}
<div className="bg-gray-700/20 rounded-lg p-4">
<div className="grid grid-cols-3 gap-4 text-center">
<div>
<div className="text-xs text-gray-400 mb-1">Realized P&L</div>
<div className={`font-bold ${pos.realizedPnL >= 0 ? 'text-green-400' : 'text-red-400'}`}>
${pos.realizedPnL.toFixed(2)}
</div>
</div>
<div>
<div className="text-xs text-gray-400 mb-1">Unrealized P&L</div>
<div className={`font-bold ${pos.unrealizedPnL >= 0 ? 'text-green-400' : 'text-red-400'}`}>
${pos.unrealizedPnL.toFixed(2)}
</div>
</div>
<div>
<div className="text-xs text-gray-400 mb-1">Peak P&L</div>
<div className={`font-bold ${pos.peakPnL >= 0 ? 'text-green-400' : 'text-red-400'}`}>
${pos.peakPnL.toFixed(2)}
</div>
</div>
</div>
</div>
</div>
)
})}
</div>
)}
{/* Position Summary */} {/* Position Summary */}
{positions && ( {positions && (
<div className="mb-8"> <div className="mb-8">