Kaynağa Gözat

feat(dashboard): 添加24小时价格缩略图和详细数据查看功能

新增/api/thumbnail和/api/range接口用于获取价格数据
在首页添加缩略图图表,支持点击查看详细数据
skyfffire 1 hafta önce
ebeveyn
işleme
1cd12aa0a5
2 değiştirilmiş dosya ile 269 ekleme ve 3 silme
  1. 140 0
      src/dashboard/app.py
  2. 129 3
      src/dashboard/static/index.html

+ 140 - 0
src/dashboard/app.py

@@ -281,6 +281,146 @@ def get_symbol_stats(symbol):
         logger.error(f"获取{symbol}统计信息失败: {str(e)}")
         return jsonify({"error": str(e)}), 500
 
+@app.route('/api/thumbnail/<symbol>', methods=['GET'])
+def get_thumbnail_data(symbol):
+    """获取24小时缩略数据(每分钟采样一次)"""
+    try:
+        conn = get_db_connection()
+        if not conn:
+            return jsonify({"error": "数据库连接失败"}), 500
+        
+        cursor = conn.cursor()
+        table_name = f"{QUESTDB_TABLE_PREFIX}_{symbol}"
+        
+        # 使用SAMPLE BY语法按分钟采样
+        query = f"""
+            SELECT 
+                timestamp,
+                first(lighter_mark_price) as lighter_mark_price,
+                first(binance_mark_price) as binance_mark_price
+            FROM {table_name}
+            WHERE timestamp >= dateadd('h', -24, now())
+            SAMPLE BY 1m ALIGN TO CALENDAR
+            ORDER BY timestamp ASC
+        """
+        
+        cursor.execute(query)
+        rows = cursor.fetchall()
+        
+        # 转换为JSON格式
+        data = []
+        shanghai_tz = pytz.timezone('Asia/Shanghai')
+        for row in rows:
+            timestamp = row["timestamp"]
+            if timestamp and timestamp.tzinfo is None:
+                timestamp = pytz.utc.localize(timestamp)
+            if timestamp:
+                timestamp = timestamp.astimezone(shanghai_tz)
+            data.append({
+                "timestamp": timestamp.isoformat() if timestamp else None,
+                "lighter_mark_price": float(row["lighter_mark_price"]) if row["lighter_mark_price"] else None,
+                "binance_mark_price": float(row["binance_mark_price"]) if row["binance_mark_price"] else None
+            })
+        
+        cursor.close()
+        conn.close()
+        
+        return jsonify({
+            "symbol": symbol,
+            "data": data,
+            "count": len(data)
+        })
+        
+    except Exception as e:
+        logger.error(f"获取{symbol}缩略数据失败: {str(e)}")
+        return jsonify({"error": str(e)}), 500
+
+@app.route('/api/range/<symbol>', methods=['GET'])
+def get_range_data(symbol):
+    """获取指定时间点前后各1800条数据(约1小时)"""
+    try:
+        # 获取中心时间戳参数
+        center_time = request.args.get('timestamp')  # ISO格式时间字符串
+        if not center_time:
+            return jsonify({"error": "缺少timestamp参数"}), 400
+        
+        conn = get_db_connection()
+        if not conn:
+            return jsonify({"error": "数据库连接失败"}), 500
+        
+        cursor = conn.cursor()
+        table_name = f"{QUESTDB_TABLE_PREFIX}_{symbol}"
+        
+        # 获取中心时间前后各1800条数据
+        query = f"""
+            WITH center_data AS (
+                SELECT * FROM {table_name}
+                WHERE timestamp <= '{center_time}'
+                ORDER BY timestamp DESC
+                LIMIT 1800
+            ),
+            after_data AS (
+                SELECT * FROM {table_name}
+                WHERE timestamp > '{center_time}'
+                ORDER BY timestamp ASC
+                LIMIT 1800
+            )
+            SELECT 
+                timestamp,
+                binance_mark_price,
+                binance_price,
+                lighter_mark_price,
+                lighter_price,
+                (lighter_mark_price - binance_mark_price) as mark_price_diff,
+                (lighter_price - binance_price) as price_diff,
+                ((lighter_mark_price - binance_mark_price) / binance_mark_price * 100) as mark_price_diff_pct,
+                ((lighter_price - binance_price) / binance_price * 100) as price_diff_pct
+            FROM (
+                SELECT * FROM center_data
+                UNION ALL
+                SELECT * FROM after_data
+            )
+            ORDER BY timestamp ASC
+        """
+        
+        cursor.execute(query)
+        rows = cursor.fetchall()
+        
+        # 转换为JSON格式
+        data = []
+        shanghai_tz = pytz.timezone('Asia/Shanghai')
+        for row in rows:
+            timestamp = row["timestamp"]
+            if timestamp and timestamp.tzinfo is None:
+                timestamp = pytz.utc.localize(timestamp)
+            if timestamp:
+                timestamp = timestamp.astimezone(shanghai_tz)
+            data.append({
+                "timestamp": timestamp.isoformat() if timestamp else None,
+                "binance_mark_price": float(row["binance_mark_price"]) if row["binance_mark_price"] else None,
+                "binance_price": float(row["binance_price"]) if row["binance_price"] else None,
+                "lighter_mark_price": float(row["lighter_mark_price"]) if row["lighter_mark_price"] else None,
+                "lighter_price": float(row["lighter_price"]) if row["lighter_price"] else None,
+                "mark_price_diff": float(row["mark_price_diff"]) if row["mark_price_diff"] else None,
+                "price_diff": float(row["price_diff"]) if row["price_diff"] else None,
+                "mark_price_diff_pct": float(row["mark_price_diff_pct"]) if row["mark_price_diff_pct"] else None,
+                "price_diff_pct": float(row["price_diff_pct"]) if row["price_diff_pct"] else None
+            })
+        
+        cursor.close()
+        conn.close()
+        
+        return jsonify({
+            "symbol": symbol,
+            "center_time": center_time,
+            "data": data,
+            "count": len(data)
+        })
+        
+    except Exception as e:
+        logger.error(f"获取{symbol}范围数据失败: {str(e)}")
+        return jsonify({"error": str(e)}), 500
+
 @app.route('/health', methods=['GET'])
 def health_check():
     """健康检查接口"""

+ 129 - 3
src/dashboard/static/index.html

@@ -270,6 +270,14 @@
             <!-- 数据卡片已移除 -->
 
             <div id="chartsContainer" style="display: none;">
+                <!-- 24小时缩略图 -->
+                <div class="chart-container">
+                    <h3>24小时价格缩略图 (点击查看详情)</h3>
+                    <div class="chart-wrapper" style="height: 200px;">
+                        <canvas id="thumbnailChart"></canvas>
+                    </div>
+                </div>
+
                 <div class="chart-container">
                     <h3>价格对比图</h3>
                     <div class="chart-wrapper">
@@ -292,6 +300,8 @@
     <script>
         let priceChart = null;
         let diffChart = null;
+        let thumbnailChart = null;
+        let thumbnailData = null;
         let autoRefreshInterval = null;
         // 动态设置API基础URL
         let API_BASE = '';
@@ -363,16 +373,19 @@
             showLoading();
             
             try {
-                // 并行加载数据和统计信息
-                const [dataResponse, statsResponse, latestResponse] = await Promise.all([
+                // 并行加载数据、统计信息和24小时缩略图
+                const [dataResponse, statsResponse, latestResponse, thumbnailResponse] = await Promise.all([
                     axios.get(`${API_BASE}/data/${symbol}?hours=${hours}&limit=1000`),
                     axios.get(`${API_BASE}/stats/${symbol}?hours=${hours}`),
-                    axios.get(`${API_BASE}/latest/${symbol}`)
+                    axios.get(`${API_BASE}/latest/${symbol}`),
+                    axios.get(`${API_BASE}/thumbnail/${symbol}`)
                 ]);
 
                 const data = dataResponse.data.data;
+                thumbnailData = thumbnailResponse.data.data;
                 // 数据卡片相关代码已移除
                 
+                updateThumbnailChart(thumbnailData, symbol);
                 updateCharts(data, symbol);
                 updateLastUpdateTime();
                 
@@ -388,6 +401,95 @@
 
         // 数据卡片相关函数已移除
 
+        function updateThumbnailChart(data, symbol) {
+            if (!data || data.length === 0) {
+                return;
+            }
+
+            const ctx = document.getElementById('thumbnailChart').getContext('2d');
+            
+            if (thumbnailChart) {
+                thumbnailChart.destroy();
+            }
+
+            const labels = data.map(item => {
+                const date = new Date(item.timestamp);
+                return date.toLocaleTimeString('zh-CN', { 
+                    timeZone: 'Asia/Shanghai',
+                    hour: '2-digit',
+                    minute: '2-digit'
+                });
+            });
+
+            thumbnailChart = new Chart(ctx, {
+                type: 'line',
+                data: {
+                    labels: labels,
+                    datasets: [
+                        {
+                            label: 'Binance 价格',
+                            data: data.map(item => item.binance_mark_price),
+                            borderColor: '#ff6384',
+                            backgroundColor: 'rgba(255, 99, 132, 0.1)',
+                            tension: 0.1,
+                            pointRadius: 0,
+                            borderWidth: 1
+                        },
+                        {
+                            label: 'Lighter 价格',
+                            data: data.map(item => item.lighter_mark_price),
+                            borderColor: '#36a2eb',
+                            backgroundColor: 'rgba(54, 162, 235, 0.1)',
+                            tension: 0.1,
+                            pointRadius: 0,
+                            borderWidth: 1
+                        }
+                    ]
+                },
+                options: {
+                    responsive: true,
+                    maintainAspectRatio: false,
+                    interaction: {
+                        mode: 'index',
+                        intersect: false
+                    },
+                    plugins: {
+                        legend: {
+                            display: true,
+                            position: 'top'
+                        },
+                        tooltip: {
+                            callbacks: {
+                                title: function(context) {
+                                    const index = context[0].dataIndex;
+                                    const timestamp = data[index].timestamp;
+                                    return new Date(timestamp).toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai' });
+                                }
+                            }
+                        }
+                    },
+                    scales: {
+                        x: {
+                            display: true,
+                            ticks: {
+                                maxTicksLimit: 12
+                            }
+                        },
+                        y: {
+                            display: true
+                        }
+                    },
+                    onClick: (event, elements) => {
+                        if (elements.length > 0) {
+                            const index = elements[0].index;
+                            const clickedTimestamp = data[index].timestamp;
+                            loadDetailData(symbol, clickedTimestamp);
+                        }
+                    }
+                }
+            });
+        }
+
         function updateCharts(data, symbol) {
             if (!data || data.length === 0) {
                 showError('没有可用的数据');
@@ -618,6 +720,30 @@
             });
         }
 
+        async function loadDetailData(symbol, centerTimestamp) {
+            try {
+                showLoading();
+                const response = await fetch(`${API_BASE}/api/range/${symbol}?timestamp=${centerTimestamp}`);
+                
+                if (!response.ok) {
+                    throw new Error(`HTTP error! status: ${response.status}`);
+                }
+                
+                const data = await response.json();
+                
+                if (data.error) {
+                    throw new Error(data.error);
+                }
+                
+                updateCharts(data.data, symbol);
+                updateLastUpdateTime();
+                document.getElementById('loading').style.display = 'none';
+            } catch (error) {
+                console.error('Error loading detail data:', error);
+                showError('加载详细数据失败: ' + error.message);
+            }
+        }
+
         function setupAutoRefresh() {
             const interval = parseInt(document.getElementById('autoRefresh').value);