✨ Features Added: - Real-time event subscription using Drift SDK EventSubscriber - Periodic fallback monitoring for position changes - Interactive UI controls for starting/stopping monitoring - Comprehensive data source status tracking - Multi-source trade aggregation and deduplication 🔧 Backend Implementation: - EventSubscriber integration with OrderActionRecord events - Fallback to periodic monitoring (30s intervals) if events fail - Real-time trade cache management (last 100 trades) - Enhanced data availability status with monitoring state - Improved trade history from 5+ different API sources 🎨 Frontend Enhancements: - Live monitoring toggle button (🔴 Start Live / 🟢 Live) - Real-time status panel showing active monitoring state - Trade counter and last activity timestamps - Clear cache functionality for real-time trades - Enhanced status modal with monitoring details 🔗 API Endpoints: - POST /api/drift/realtime-monitoring - Control monitoring - GET /api/drift/realtime-monitoring - Check status - GET /api/drift/data-status - Enhanced with monitoring state 🐳 Docker Integration: - Updated container configuration for persistent monitoring - Environment variable support for real-time features - Database persistence for captured trades 💾 Database & Storage: - Automatic storage of real-time detected trades - Deduplication logic to prevent synthetic/duplicate trades - Persistent cache across container restarts 🚀 Usage: - Click 'Start Live' button in Trading History panel - Monitor will attempt EventSubscriber, fallback to periodic checks - All future trades automatically captured and stored - Status panel shows monitoring state and trade statistics This implements comprehensive real-time trading monitoring for Drift Protocol with robust fallback mechanisms and professional UI integration.
470 lines
18 KiB
TypeScript
470 lines
18 KiB
TypeScript
"use client"
|
||
import React, { useEffect, useState } from 'react'
|
||
|
||
interface Trade {
|
||
id: string
|
||
symbol: string
|
||
side: string
|
||
amount: number
|
||
price: number
|
||
status: string
|
||
executedAt: string
|
||
pnl?: number
|
||
}
|
||
|
||
interface DataSource {
|
||
name: string
|
||
available: boolean
|
||
description: string
|
||
}
|
||
|
||
interface DataStatus {
|
||
status: string
|
||
sources: DataSource[]
|
||
recommendations: string[]
|
||
}
|
||
|
||
interface RealtimeStatus {
|
||
isActive: boolean
|
||
tradesCount: number
|
||
lastTradeTime?: string
|
||
}
|
||
|
||
export default function TradingHistory() {
|
||
const [trades, setTrades] = useState<Trade[]>([])
|
||
const [loading, setLoading] = useState(true)
|
||
const [isClient, setIsClient] = useState(false)
|
||
const [error, setError] = useState<string | null>(null)
|
||
const [message, setMessage] = useState<string>('')
|
||
const [syncing, setSyncing] = useState(false)
|
||
const [dataStatus, setDataStatus] = useState<DataStatus | null>(null)
|
||
const [showStatus, setShowStatus] = useState(false)
|
||
const [realtimeStatus, setRealtimeStatus] = useState<RealtimeStatus | null>(null)
|
||
const [realtimeLoading, setRealtimeLoading] = useState(false)
|
||
|
||
useEffect(() => {
|
||
setIsClient(true)
|
||
}, [])
|
||
|
||
const formatTime = (dateString: string) => {
|
||
if (!isClient) return '--:--:--'
|
||
return new Date(dateString).toLocaleTimeString()
|
||
}
|
||
|
||
const formatDate = (dateString: string) => {
|
||
if (!isClient) return '--/--/--'
|
||
return new Date(dateString).toLocaleDateString()
|
||
}
|
||
|
||
const handleSync = async () => {
|
||
try {
|
||
setSyncing(true)
|
||
const response = await fetch('/api/drift/sync-trades', {
|
||
method: 'POST'
|
||
})
|
||
|
||
if (response.ok) {
|
||
const data = await response.json()
|
||
setMessage(data.message || 'Sync completed')
|
||
// Refresh the trading history after sync
|
||
await fetchTrades()
|
||
} else {
|
||
setError('Failed to sync trades')
|
||
}
|
||
} catch (error) {
|
||
console.error('Sync error:', error)
|
||
setError('Error during sync')
|
||
} finally {
|
||
setSyncing(false)
|
||
}
|
||
}
|
||
|
||
const fetchTrades = async () => {
|
||
try {
|
||
setError(null)
|
||
setMessage('')
|
||
|
||
// Try Drift trading history first
|
||
const driftRes = await fetch('/api/drift/trading-history')
|
||
if (driftRes.ok) {
|
||
const data = await driftRes.json()
|
||
if (data.success && data.trades) {
|
||
setTrades(data.trades)
|
||
setMessage(data.message || `Found ${data.trades.length} trade(s)`)
|
||
} else {
|
||
setTrades([])
|
||
setMessage(data.message || 'No trades available')
|
||
if (data.error) {
|
||
setError(data.error)
|
||
}
|
||
}
|
||
} else {
|
||
// API failed - try fallback to local database
|
||
const res = await fetch('/api/trading-history')
|
||
if (res.ok) {
|
||
const data = await res.json()
|
||
setTrades(data || [])
|
||
setMessage(data?.length > 0 ? `Found ${data.length} local trade(s)` : 'No trading history available')
|
||
} else {
|
||
// Both APIs failed - show empty state
|
||
setTrades([])
|
||
setError('Failed to load trading history from both sources')
|
||
setMessage('Unable to fetch trading history. Please try again.')
|
||
}
|
||
}
|
||
} catch (error) {
|
||
console.error('Failed to fetch trades:', error)
|
||
setTrades([])
|
||
setError('Network error while fetching trades')
|
||
setMessage('Check your connection and try again.')
|
||
}
|
||
}
|
||
|
||
const fetchDataStatus = async () => {
|
||
try {
|
||
const response = await fetch('/api/drift/data-status')
|
||
if (response.ok) {
|
||
const status = await response.json()
|
||
setDataStatus(status)
|
||
}
|
||
} catch (error) {
|
||
console.error('Failed to fetch data status:', error)
|
||
}
|
||
}
|
||
|
||
const fetchRealtimeStatus = async () => {
|
||
try {
|
||
const response = await fetch('/api/drift/realtime-monitoring')
|
||
if (response.ok) {
|
||
const data = await response.json()
|
||
setRealtimeStatus(data.status)
|
||
}
|
||
} catch (error) {
|
||
console.error('Failed to fetch realtime status:', error)
|
||
}
|
||
}
|
||
|
||
const handleRealtimeToggle = async () => {
|
||
try {
|
||
setRealtimeLoading(true)
|
||
const action = realtimeStatus?.isActive ? 'stop' : 'start'
|
||
|
||
const response = await fetch('/api/drift/realtime-monitoring', {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json'
|
||
},
|
||
body: JSON.stringify({ action })
|
||
})
|
||
|
||
if (response.ok) {
|
||
const data = await response.json()
|
||
setRealtimeStatus(data.status)
|
||
setMessage(data.message)
|
||
|
||
// If we started monitoring, refresh trades after a moment
|
||
if (action === 'start') {
|
||
setTimeout(async () => {
|
||
await fetchTrades()
|
||
}, 2000)
|
||
}
|
||
} else {
|
||
const errorData = await response.json()
|
||
setError(errorData.error || 'Failed to toggle real-time monitoring')
|
||
}
|
||
} catch (error) {
|
||
console.error('Real-time toggle error:', error)
|
||
setError('Error toggling real-time monitoring')
|
||
} finally {
|
||
setRealtimeLoading(false)
|
||
}
|
||
}
|
||
|
||
const handleClearRealtimeCache = async () => {
|
||
try {
|
||
const response = await fetch('/api/drift/realtime-monitoring', {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json'
|
||
},
|
||
body: JSON.stringify({ action: 'clear' })
|
||
})
|
||
|
||
if (response.ok) {
|
||
const data = await response.json()
|
||
setRealtimeStatus(data.status)
|
||
setMessage(data.message)
|
||
await fetchTrades() // Refresh to show cleared state
|
||
}
|
||
} catch (error) {
|
||
console.error('Clear cache error:', error)
|
||
setError('Error clearing real-time cache')
|
||
}
|
||
}
|
||
|
||
useEffect(() => {
|
||
async function loadTrades() {
|
||
setLoading(true)
|
||
await fetchTrades()
|
||
await fetchDataStatus()
|
||
await fetchRealtimeStatus()
|
||
setLoading(false)
|
||
}
|
||
loadTrades()
|
||
}, [])
|
||
|
||
const getSideColor = (side: string) => {
|
||
return side.toLowerCase() === 'buy' ? 'text-green-400' : 'text-red-400'
|
||
}
|
||
|
||
const getStatusColor = (status: string) => {
|
||
switch (status.toLowerCase()) {
|
||
case 'filled': return 'text-green-400'
|
||
case 'pending': return 'text-yellow-400'
|
||
case 'cancelled': return 'text-red-400'
|
||
default: return 'text-gray-400'
|
||
}
|
||
}
|
||
|
||
const getPnLColor = (pnl?: number) => {
|
||
if (!pnl) return 'text-gray-400'
|
||
return pnl >= 0 ? 'text-green-400' : 'text-red-400'
|
||
}
|
||
|
||
return (
|
||
<div className="card card-gradient">
|
||
<div className="flex items-center justify-between mb-6">
|
||
<h2 className="text-lg font-bold text-white flex items-center">
|
||
<span className="w-8 h-8 bg-gradient-to-br from-purple-400 to-violet-600 rounded-lg flex items-center justify-center mr-3">
|
||
📊
|
||
</span>
|
||
Trading History
|
||
</h2>
|
||
<div className="flex items-center gap-3">
|
||
<span className="text-xs text-gray-400">{message || `Latest ${trades.length} trades`}</span>
|
||
<button
|
||
onClick={() => setShowStatus(!showStatus)}
|
||
className="text-xs text-gray-400 hover:text-gray-300 transition-colors"
|
||
title="Show data source status"
|
||
>
|
||
ℹ️ Status
|
||
</button>
|
||
<button
|
||
onClick={handleSync}
|
||
disabled={syncing || loading}
|
||
className="text-xs text-blue-400 hover:text-blue-300 transition-colors disabled:text-gray-500"
|
||
title="Sync with Drift to check for new trades"
|
||
>
|
||
{syncing ? '🔄' : '🔄 Sync'}
|
||
</button>
|
||
<button
|
||
onClick={handleRealtimeToggle}
|
||
disabled={realtimeLoading || loading}
|
||
className={`text-xs transition-colors disabled:text-gray-500 ${
|
||
realtimeStatus?.isActive
|
||
? 'text-green-400 hover:text-green-300'
|
||
: 'text-yellow-400 hover:text-yellow-300'
|
||
}`}
|
||
title={realtimeStatus?.isActive ? 'Stop real-time monitoring' : 'Start real-time monitoring'}
|
||
>
|
||
{realtimeLoading ? '⏳' : realtimeStatus?.isActive ? '🟢 Live' : '🔴 Start Live'}
|
||
</button>
|
||
{realtimeStatus?.isActive && realtimeStatus.tradesCount > 0 && (
|
||
<button
|
||
onClick={handleClearRealtimeCache}
|
||
className="text-xs text-orange-400 hover:text-orange-300 transition-colors"
|
||
title="Clear real-time trades cache"
|
||
>
|
||
🗑️ Clear
|
||
</button>
|
||
)}
|
||
<button
|
||
onClick={() => window.location.reload()}
|
||
className="text-xs text-gray-400 hover:text-gray-300 transition-colors"
|
||
title="Refresh page"
|
||
>
|
||
↻
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{showStatus && dataStatus && (
|
||
<div className="mb-6 p-4 bg-gray-800/50 rounded-lg border border-gray-700">
|
||
<h3 className="text-sm font-semibold text-white mb-3">Data Source Status: {dataStatus.status}</h3>
|
||
|
||
{/* Real-time Monitoring Status */}
|
||
{realtimeStatus && (
|
||
<div className="mb-4 p-3 bg-gray-700/30 rounded border-l-4 border-blue-500">
|
||
<div className="flex items-center justify-between mb-2">
|
||
<h4 className="text-sm font-medium text-white">Real-time Monitoring</h4>
|
||
<span className={`text-xs px-2 py-1 rounded ${
|
||
realtimeStatus.isActive ? 'bg-green-500/20 text-green-400' : 'bg-gray-500/20 text-gray-400'
|
||
}`}>
|
||
{realtimeStatus.isActive ? '🟢 ACTIVE' : '🔴 INACTIVE'}
|
||
</span>
|
||
</div>
|
||
<div className="grid grid-cols-2 gap-4 text-xs">
|
||
<div>
|
||
<span className="text-gray-400">Tracked Trades:</span>
|
||
<span className="ml-2 text-white">{realtimeStatus.tradesCount}</span>
|
||
</div>
|
||
<div>
|
||
<span className="text-gray-400">Last Activity:</span>
|
||
<span className="ml-2 text-white">
|
||
{realtimeStatus.lastTradeTime
|
||
? formatTime(realtimeStatus.lastTradeTime)
|
||
: 'None'
|
||
}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
{realtimeStatus.isActive && (
|
||
<p className="text-xs text-blue-400 mt-2">
|
||
📡 Monitoring for new trades automatically
|
||
</p>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 mb-4">
|
||
{dataStatus.sources.map((source, index) => (
|
||
<div key={index} className="flex items-center justify-between p-2 bg-gray-700/50 rounded">
|
||
<div>
|
||
<span className="text-sm font-medium text-white">{source.name}</span>
|
||
<p className="text-xs text-gray-400">{source.description}</p>
|
||
</div>
|
||
<span className={`text-xs px-2 py-1 rounded ${
|
||
source.available ? 'bg-green-500/20 text-green-400' : 'bg-red-500/20 text-red-400'
|
||
}`}>
|
||
{source.available ? 'Available' : 'Limited'}
|
||
</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
|
||
<div className="border-t border-gray-600 pt-3">
|
||
<h4 className="text-sm font-medium text-white mb-2">Important Notes:</h4>
|
||
<ul className="space-y-1">
|
||
{dataStatus.recommendations.map((rec, index) => (
|
||
<li key={index} className="text-xs text-gray-400 flex items-start">
|
||
<span className="mr-2">•</span>
|
||
<span>{rec}</span>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{loading ? (
|
||
<div className="flex items-center justify-center py-8">
|
||
<div className="spinner"></div>
|
||
<span className="ml-2 text-gray-400">Loading trades...</span>
|
||
</div>
|
||
) : error ? (
|
||
<div className="text-center py-8">
|
||
<div className="w-16 h-16 bg-red-500/20 rounded-full flex items-center justify-center mx-auto mb-4">
|
||
<span className="text-red-400 text-2xl">⚠️</span>
|
||
</div>
|
||
<p className="text-red-400 font-medium">Error Loading Trades</p>
|
||
<p className="text-gray-500 text-sm mt-2">{error}</p>
|
||
<p className="text-gray-500 text-sm">{message}</p>
|
||
<button
|
||
onClick={() => window.location.reload()}
|
||
className="mt-4 px-4 py-2 bg-red-500/20 text-red-400 rounded-lg hover:bg-red-500/30 transition-colors"
|
||
>
|
||
Retry
|
||
</button>
|
||
</div>
|
||
) : trades.length === 0 ? (
|
||
<div className="text-center py-8">
|
||
<div className="w-16 h-16 bg-gray-700/50 rounded-full flex items-center justify-center mx-auto mb-4">
|
||
<span className="text-gray-400 text-2xl">📈</span>
|
||
</div>
|
||
<p className="text-gray-400 font-medium">No Trading History Available</p>
|
||
<p className="text-gray-500 text-sm mt-2">{message || 'Your completed trades will appear here'}</p>
|
||
<div className="mt-4 space-y-2 text-xs text-gray-500">
|
||
<p>💡 <strong>Why no history?</strong></p>
|
||
<ul className="ml-4 space-y-1">
|
||
<li>• Complete historical trading data is not publicly accessible via Drift APIs</li>
|
||
<li>• Only current positions and recent fills are available</li>
|
||
<li>• Historical S3 data stopped updating in January 2025</li>
|
||
</ul>
|
||
<p className="mt-3">📋 <strong>For full trade history:</strong></p>
|
||
<ul className="ml-4 space-y-1">
|
||
<li>• Visit the official <a href="https://app.drift.trade" target="_blank" rel="noopener noreferrer" className="text-blue-400 hover:text-blue-300">Drift app</a></li>
|
||
<li>• Enable real-time monitoring for future trades</li>
|
||
<li>• Click "Status" above for detailed data source information</li>
|
||
</ul>
|
||
</div>
|
||
</div>
|
||
) : (
|
||
<div className="overflow-hidden">
|
||
<div className="overflow-x-auto">
|
||
<table className="w-full">
|
||
<thead>
|
||
<tr className="border-b border-gray-700">
|
||
<th className="text-left py-3 px-4 text-gray-400 font-medium text-sm">Asset</th>
|
||
<th className="text-left py-3 px-4 text-gray-400 font-medium text-sm">Side</th>
|
||
<th className="text-right py-3 px-4 text-gray-400 font-medium text-sm">Amount</th>
|
||
<th className="text-right py-3 px-4 text-gray-400 font-medium text-sm">Price</th>
|
||
<th className="text-center py-3 px-4 text-gray-400 font-medium text-sm">Status</th>
|
||
<th className="text-right py-3 px-4 text-gray-400 font-medium text-sm">P&L</th>
|
||
<th className="text-right py-3 px-4 text-gray-400 font-medium text-sm">Time</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{trades.map((trade, index) => (
|
||
<tr key={trade.id} className="border-b border-gray-800/50 hover:bg-gray-800/30 transition-colors">
|
||
<td className="py-4 px-4">
|
||
<div className="flex items-center">
|
||
<div className="w-8 h-8 bg-gradient-to-br from-orange-400 to-orange-600 rounded-full flex items-center justify-center mr-3">
|
||
<span className="text-white text-xs font-bold">
|
||
{trade.symbol.slice(0, 2)}
|
||
</span>
|
||
</div>
|
||
<span className="font-medium text-white">{trade.symbol}</span>
|
||
</div>
|
||
</td>
|
||
<td className="py-4 px-4">
|
||
<span className={`font-semibold ${getSideColor(trade.side)}`}>
|
||
{trade.side}
|
||
</span>
|
||
</td>
|
||
<td className="py-4 px-4 text-right font-mono text-gray-300">
|
||
{trade.amount}
|
||
</td>
|
||
<td className="py-4 px-4 text-right font-mono text-gray-300">
|
||
${trade.price.toLocaleString()}
|
||
</td>
|
||
<td className="py-4 px-4 text-center">
|
||
<span className={`inline-flex items-center px-2 py-1 rounded-full text-xs font-medium ${
|
||
trade.status.toLowerCase() === 'filled' ? 'bg-green-100 text-green-800' :
|
||
trade.status.toLowerCase() === 'pending' ? 'bg-yellow-100 text-yellow-800' :
|
||
'bg-red-100 text-red-800'
|
||
}`}>
|
||
{trade.status}
|
||
</span>
|
||
</td>
|
||
<td className="py-4 px-4 text-right">
|
||
<span className={`font-mono font-semibold ${getPnLColor(trade.pnl)}`}>
|
||
{trade.pnl ? `${trade.pnl >= 0 ? '+' : ''}$${trade.pnl.toFixed(2)}` : '--'}
|
||
</span>
|
||
</td>
|
||
<td className="py-4 px-4 text-right text-xs text-gray-400">
|
||
<div className="text-right">
|
||
<div>{formatTime(trade.executedAt)}</div>
|
||
<div className="text-gray-500">{formatDate(trade.executedAt)}</div>
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|