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:
6
.env
6
.env
@@ -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
|
||||||
# ================================
|
# ================================
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
Reference in New Issue
Block a user