Просмотр исходного кода

feat(dashboard): 新增价格差异分析数据面板

实现完整的Lead-Lag数据面板功能,包括:
- 后端Flask API服务,提供币对数据查询接口
- 前端可视化界面,展示价格对比和差异图表
- 实时数据统计和自动刷新功能
- 完整的安装和使用文档
skyfffire 1 неделя назад
Родитель
Сommit
405b230e35
4 измененных файлов с 1090 добавлено и 0 удалено
  1. 142 0
      src/dashboard/README.md
  2. 284 0
      src/dashboard/app.py
  3. 4 0
      src/dashboard/requirements.txt
  4. 660 0
      src/dashboard/static/index.html

+ 142 - 0
src/dashboard/README.md

@@ -0,0 +1,142 @@
+# Lead-Lag 数据面板
+
+这是一个用于展示 Lighter 和 Binance 交易所价格差异的数据面板。
+
+## 功能特性
+
+- **币对选择**: 支持从下拉菜单选择不同的交易对
+- **实时数据**: 显示最新的价格差异数据
+- **历史数据**: 支持查看不同时间范围的历史数据(1小时到7天)
+- **数据可视化**: 提供价格对比图和价格差异图
+- **统计信息**: 显示平均价格差、最大最小值等统计数据
+- **自动刷新**: 支持自动刷新数据(5秒到1分钟间隔)
+
+## 技术架构
+
+### 后端 (Flask API)
+- **框架**: Flask + Flask-CORS
+- **数据库**: QuestDB (通过 PostgreSQL 协议连接)
+- **API接口**:
+  - `GET /api/symbols` - 获取可用币对列表
+  - `GET /api/data/<symbol>` - 获取历史数据
+  - `GET /api/latest/<symbol>` - 获取最新数据
+  - `GET /api/stats/<symbol>` - 获取统计信息
+  - `GET /health` - 健康检查
+
+### 前端 (HTML + JavaScript)
+- **图表库**: Chart.js
+- **HTTP客户端**: Axios
+- **响应式设计**: 支持移动端和桌面端
+- **实时更新**: 支持自动刷新功能
+
+## 安装和运行
+
+### 1. 安装依赖
+
+```bash
+cd src/dashboard
+pip install -r requirements.txt
+```
+
+### 2. 确保 QuestDB 运行
+
+确保 QuestDB 服务正在运行,并且可以通过以下配置访问:
+- 主机: 127.0.0.1
+- 端口: 8812 (PostgreSQL wire protocol)
+- 用户: admin
+- 密码: quest
+- 数据库: qdb
+
+### 3. 启动后端服务
+
+```bash
+python app.py
+```
+
+后端服务将在 `http://localhost:5000` 启动。
+
+### 4. 访问前端界面
+
+在浏览器中打开 `static/index.html` 文件,或者通过 Web 服务器访问。
+
+## 数据结构
+
+面板从 QuestDB 中读取由 `market_data_recorder.py` 写入的数据。数据表结构如下:
+
+```sql
+-- 表名格式: lighter_binance_{symbol}
+-- 例如: lighter_binance_BTC, lighter_binance_ETH
+
+CREATE TABLE lighter_binance_BTC (
+    timestamp TIMESTAMP,
+    binance_mark_price DOUBLE,
+    binance_price DOUBLE,
+    lighter_mark_price DOUBLE,
+    lighter_price DOUBLE
+);
+```
+
+## 配置说明
+
+### 后端配置 (app.py)
+
+```python
+# QuestDB配置
+QUESTDB_HOST = "127.0.0.1"
+QUESTDB_PORT = 8812
+QUESTDB_USER = "admin"
+QUESTDB_PASSWORD = "quest"
+QUESTDB_DATABASE = "qdb"
+QUESTDB_TABLE_PREFIX = "lighter_binance"
+```
+
+### 前端配置 (index.html)
+
+```javascript
+// API基础地址
+const API_BASE = 'http://localhost:5000/api';
+```
+
+## 使用说明
+
+1. **选择币对**: 从下拉菜单中选择要查看的交易对
+2. **设置时间范围**: 选择要查看的历史数据时间范围
+3. **配置自动刷新**: 设置自动刷新间隔(可选)
+4. **查看数据**: 
+   - 顶部卡片显示当前实时数据和统计信息
+   - 价格对比图显示四条价格线的走势
+   - 价格差异图显示绝对差异和百分比差异
+
+## 故障排除
+
+### 常见问题
+
+1. **无法连接数据库**
+   - 检查 QuestDB 是否正在运行
+   - 验证连接配置是否正确
+   - 确认防火墙设置
+
+2. **没有数据显示**
+   - 确认 `market_data_recorder.py` 正在运行并写入数据
+   - 检查表名是否正确(`lighter_binance_{symbol}` 格式)
+   - 验证数据是否存在于数据库中
+
+3. **跨域问题**
+   - 确保后端启用了 CORS
+   - 检查前端 API 地址配置
+
+### 日志查看
+
+后端日志会显示在控制台中,包括:
+- API 请求信息
+- 数据库连接状态
+- 错误信息
+
+## 扩展功能
+
+可以考虑添加的功能:
+- 价格预警功能
+- 数据导出功能
+- 更多统计指标
+- 多交易所对比
+- 移动端 App

+ 284 - 0
src/dashboard/app.py

@@ -0,0 +1,284 @@
+#!/usr/bin/env python
+# -*- coding: utf-8 -*-
+
+"""
+数据面板后端API服务
+提供QuestDB数据查询接口,支持币对数据获取和实时更新
+"""
+
+from flask import Flask, jsonify, request
+from flask_cors import CORS
+import psycopg
+from psycopg.rows import dict_row
+from datetime import datetime, timedelta
+import logging
+import os
+
+# 配置日志
+logging.basicConfig(level=logging.INFO)
+logger = logging.getLogger(__name__)
+
+app = Flask(__name__)
+CORS(app)  # 允许跨域请求
+
+# QuestDB配置
+QUESTDB_HOST = "127.0.0.1"
+QUESTDB_PORT = 8812  # PostgreSQL wire protocol port
+QUESTDB_USER = "admin"
+QUESTDB_PASSWORD = "quest"
+QUESTDB_DATABASE = "qdb"
+QUESTDB_TABLE_PREFIX = "lighter_binance"
+
+def get_db_connection():
+    """获取数据库连接"""
+    try:
+        conn = psycopg.connect(
+            host=QUESTDB_HOST,
+            port=QUESTDB_PORT,
+            user=QUESTDB_USER,
+            password=QUESTDB_PASSWORD,
+            dbname=QUESTDB_DATABASE,
+            row_factory=dict_row
+        )
+        return conn
+    except Exception as e:
+        logger.error(f"数据库连接失败: {str(e)}")
+        return None
+
+@app.route('/api/symbols', methods=['GET'])
+def get_available_symbols():
+    """获取所有可用的币对列表"""
+    try:
+        conn = get_db_connection()
+        if not conn:
+            return jsonify({"error": "数据库连接失败"}), 500
+        
+        cursor = conn.cursor()
+        
+        # 查询所有以lighter_binance_开头的表
+        cursor.execute("""
+            SELECT table_name 
+            FROM information_schema.tables 
+            WHERE table_name LIKE %s
+        """, (f"{QUESTDB_TABLE_PREFIX}_%",))
+        
+        tables = cursor.fetchall()
+        
+        # 提取币对名称
+        symbols = []
+        for table in tables:
+            table_name = table['table_name']
+            symbol = table_name.replace(f"{QUESTDB_TABLE_PREFIX}_", "")
+            symbols.append(symbol)
+        
+        cursor.close()
+        conn.close()
+        
+        return jsonify({"symbols": sorted(symbols)})
+        
+    except Exception as e:
+        logger.error(f"获取币对列表失败: {str(e)}")
+        return jsonify({"error": str(e)}), 500
+
+@app.route('/api/data/<symbol>', methods=['GET'])
+def get_symbol_data(symbol):
+    """获取指定币对的历史数据"""
+    try:
+        # 获取查询参数
+        hours = request.args.get('hours', 1, type=int)  # 默认查询1小时数据
+        limit = request.args.get('limit', 1000, type=int)  # 默认限制1000条记录
+        
+        conn = get_db_connection()
+        if not conn:
+            return jsonify({"error": "数据库连接失败"}), 500
+        
+        cursor = conn.cursor()
+        
+        table_name = f"{QUESTDB_TABLE_PREFIX}_{symbol}"
+        
+        # 构建查询SQL (使用QuestDB语法)
+        query = f"""
+            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 {table_name}
+            WHERE timestamp >= dateadd('h', -{hours}, now())
+            ORDER BY timestamp DESC
+            LIMIT {limit}
+        """
+        
+        cursor.execute(query)
+        rows = cursor.fetchall()
+        
+        # 转换为JSON格式
+        data = []
+        for row in rows:
+            data.append({
+                "timestamp": row["timestamp"].isoformat() if row["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()
+        
+        # 反转数据顺序,使时间从早到晚
+        data.reverse()
+        
+        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/latest/<symbol>', methods=['GET'])
+def get_latest_data(symbol):
+    """获取指定币对的最新数据"""
+    try:
+        conn = get_db_connection()
+        if not conn:
+            return jsonify({"error": "数据库连接失败"}), 500
+        
+        cursor = conn.cursor()
+        
+        table_name = f"{QUESTDB_TABLE_PREFIX}_{symbol}"
+        
+        query = f"""
+            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 {table_name}
+            ORDER BY timestamp DESC
+            LIMIT 1
+        """
+        
+        cursor.execute(query)
+        row = cursor.fetchone()
+        
+        if not row:
+            return jsonify({"error": "没有找到数据"}), 404
+        
+        data = {
+            "symbol": symbol,
+            "timestamp": row["timestamp"].isoformat() if row["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(data)
+        
+    except Exception as e:
+        logger.error(f"获取{symbol}最新数据失败: {str(e)}")
+        return jsonify({"error": str(e)}), 500
+
+@app.route('/api/stats/<symbol>', methods=['GET'])
+def get_symbol_stats(symbol):
+    """获取指定币对的统计信息"""
+    try:
+        hours = request.args.get('hours', 24, type=int)  # 默认查询24小时统计
+        
+        conn = get_db_connection()
+        if not conn:
+            return jsonify({"error": "数据库连接失败"}), 500
+        
+        cursor = conn.cursor()
+        
+        table_name = f"{QUESTDB_TABLE_PREFIX}_{symbol}"
+        
+        query = f"""
+            SELECT 
+                COUNT(*) as record_count,
+                AVG(lighter_mark_price - binance_mark_price) as avg_mark_price_diff,
+                AVG(lighter_price - binance_price) as avg_price_diff,
+                AVG((lighter_mark_price - binance_mark_price) / binance_mark_price * 100) as avg_mark_price_diff_pct,
+                AVG((lighter_price - binance_price) / binance_price * 100) as avg_price_diff_pct,
+                MAX(lighter_mark_price - binance_mark_price) as max_mark_price_diff,
+                MIN(lighter_mark_price - binance_mark_price) as min_mark_price_diff,
+                MAX(lighter_price - binance_price) as max_price_diff,
+                MIN(lighter_price - binance_price) as min_price_diff,
+                STDDEV(lighter_mark_price - binance_mark_price) as stddev_mark_price_diff,
+                STDDEV(lighter_price - binance_price) as stddev_price_diff
+            FROM {table_name}
+            WHERE timestamp >= dateadd('h', -{hours}, now())
+        """
+        
+        cursor.execute(query)
+        row = cursor.fetchone()
+        
+        if not row:
+            return jsonify({"error": "没有找到数据"}), 404
+        
+        stats = {
+            "symbol": symbol,
+            "hours": hours,
+            "record_count": int(row["record_count"]) if row["record_count"] else 0,
+            "avg_mark_price_diff": float(row["avg_mark_price_diff"]) if row["avg_mark_price_diff"] else None,
+            "avg_price_diff": float(row["avg_price_diff"]) if row["avg_price_diff"] else None,
+            "avg_mark_price_diff_pct": float(row["avg_mark_price_diff_pct"]) if row["avg_mark_price_diff_pct"] else None,
+            "avg_price_diff_pct": float(row["avg_price_diff_pct"]) if row["avg_price_diff_pct"] else None,
+            "max_mark_price_diff": float(row["max_mark_price_diff"]) if row["max_mark_price_diff"] else None,
+            "min_mark_price_diff": float(row["min_mark_price_diff"]) if row["min_mark_price_diff"] else None,
+            "max_price_diff": float(row["max_price_diff"]) if row["max_price_diff"] else None,
+            "min_price_diff": float(row["min_price_diff"]) if row["min_price_diff"] else None,
+            "stddev_mark_price_diff": float(row["stddev_mark_price_diff"]) if row["stddev_mark_price_diff"] else None,
+            "stddev_price_diff": float(row["stddev_price_diff"]) if row["stddev_price_diff"] else None
+        }
+        
+        cursor.close()
+        conn.close()
+        
+        return jsonify(stats)
+        
+    except Exception as e:
+        logger.error(f"获取{symbol}统计信息失败: {str(e)}")
+        return jsonify({"error": str(e)}), 500
+
+@app.route('/health', methods=['GET'])
+def health_check():
+    """健康检查接口"""
+    try:
+        conn = get_db_connection()
+        if conn:
+            conn.close()
+            return jsonify({"status": "healthy", "database": "connected"})
+        else:
+            return jsonify({"status": "unhealthy", "database": "disconnected"}), 500
+    except Exception as e:
+        return jsonify({"status": "unhealthy", "error": str(e)}), 500
+
+if __name__ == '__main__':
+    logger.info("启动数据面板API服务...")
+    app.run(host='0.0.0.0', port=5000, debug=True)

+ 4 - 0
src/dashboard/requirements.txt

@@ -0,0 +1,4 @@
+Flask==2.3.3
+Flask-CORS==4.0.0
+psycopg[binary]==3.2.12
+requests==2.31.0

+ 660 - 0
src/dashboard/static/index.html

@@ -0,0 +1,660 @@
+<!DOCTYPE html>
+<html lang="zh-CN">
+<head>
+    <meta charset="UTF-8">
+    <meta name="viewport" content="width=device-width, initial-scale=1.0">
+    <title>Lead-Lag 数据面板</title>
+    <script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
+    <script src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"></script>
+    <style>
+        * {
+            margin: 0;
+            padding: 0;
+            box-sizing: border-box;
+        }
+
+        body {
+            font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
+            background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
+            min-height: 100vh;
+            padding: 20px;
+        }
+
+        .container {
+            max-width: 1400px;
+            margin: 0 auto;
+            background: rgba(255, 255, 255, 0.95);
+            border-radius: 20px;
+            box-shadow: 0 20px 40px rgba(0, 0, 0, 0.1);
+            overflow: hidden;
+        }
+
+        .header {
+            background: linear-gradient(135deg, #2c3e50 0%, #34495e 100%);
+            color: white;
+            padding: 30px;
+            text-align: center;
+        }
+
+        .header h1 {
+            font-size: 2.5em;
+            margin-bottom: 10px;
+            font-weight: 300;
+        }
+
+        .header p {
+            font-size: 1.1em;
+            opacity: 0.9;
+        }
+
+        .controls {
+            padding: 30px;
+            background: #f8f9fa;
+            border-bottom: 1px solid #e9ecef;
+        }
+
+        .control-group {
+            display: flex;
+            gap: 20px;
+            align-items: center;
+            flex-wrap: wrap;
+        }
+
+        .control-item {
+            display: flex;
+            flex-direction: column;
+            gap: 8px;
+        }
+
+        .control-item label {
+            font-weight: 600;
+            color: #495057;
+            font-size: 0.9em;
+        }
+
+        select, input {
+            padding: 12px 16px;
+            border: 2px solid #e9ecef;
+            border-radius: 8px;
+            font-size: 1em;
+            transition: all 0.3s ease;
+            background: white;
+        }
+
+        select:focus, input:focus {
+            outline: none;
+            border-color: #667eea;
+            box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
+        }
+
+        .btn {
+            padding: 12px 24px;
+            background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
+            color: white;
+            border: none;
+            border-radius: 8px;
+            cursor: pointer;
+            font-size: 1em;
+            font-weight: 600;
+            transition: all 0.3s ease;
+            margin-top: 24px;
+        }
+
+        .btn:hover {
+            transform: translateY(-2px);
+            box-shadow: 0 8px 25px rgba(102, 126, 234, 0.3);
+        }
+
+        .btn:disabled {
+            opacity: 0.6;
+            cursor: not-allowed;
+            transform: none;
+        }
+
+        .content {
+            padding: 30px;
+        }
+
+        .stats-grid {
+            display: grid;
+            grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
+            gap: 20px;
+            margin-bottom: 30px;
+        }
+
+        .stat-card {
+            background: white;
+            padding: 25px;
+            border-radius: 12px;
+            box-shadow: 0 4px 15px rgba(0, 0, 0, 0.08);
+            border-left: 4px solid #667eea;
+            transition: transform 0.3s ease;
+        }
+
+        .stat-card:hover {
+            transform: translateY(-5px);
+        }
+
+        .stat-card h3 {
+            color: #495057;
+            font-size: 0.9em;
+            margin-bottom: 10px;
+            text-transform: uppercase;
+            letter-spacing: 0.5px;
+        }
+
+        .stat-card .value {
+            font-size: 1.8em;
+            font-weight: 700;
+            color: #2c3e50;
+            margin-bottom: 5px;
+        }
+
+        .stat-card .unit {
+            font-size: 0.8em;
+            color: #6c757d;
+        }
+
+        .positive {
+            color: #28a745 !important;
+        }
+
+        .negative {
+            color: #dc3545 !important;
+        }
+
+        .chart-container {
+            background: white;
+            padding: 25px;
+            border-radius: 12px;
+            box-shadow: 0 4px 15px rgba(0, 0, 0, 0.08);
+            margin-bottom: 20px;
+        }
+
+        .chart-container h3 {
+            margin-bottom: 20px;
+            color: #495057;
+            font-size: 1.2em;
+        }
+
+        .chart-wrapper {
+            position: relative;
+            height: 400px;
+        }
+
+        .loading {
+            text-align: center;
+            padding: 50px;
+            color: #6c757d;
+            font-size: 1.1em;
+        }
+
+        .error {
+            background: #f8d7da;
+            color: #721c24;
+            padding: 15px;
+            border-radius: 8px;
+            margin: 20px 0;
+            border: 1px solid #f5c6cb;
+        }
+
+        .last-update {
+            text-align: center;
+            color: #6c757d;
+            font-size: 0.9em;
+            margin-top: 20px;
+            padding: 15px;
+            background: #f8f9fa;
+            border-radius: 8px;
+        }
+
+        @media (max-width: 768px) {
+            .control-group {
+                flex-direction: column;
+                align-items: stretch;
+            }
+
+            .stats-grid {
+                grid-template-columns: 1fr;
+            }
+
+            .header h1 {
+                font-size: 2em;
+            }
+        }
+    </style>
+</head>
+<body>
+    <div class="container">
+        <div class="header">
+            <h1>Lead-Lag 数据面板</h1>
+            <p>Lighter vs Binance 价格差异分析</p>
+        </div>
+
+        <div class="controls">
+            <div class="control-group">
+                <div class="control-item">
+                    <label for="symbolSelect">选择币对:</label>
+                    <select id="symbolSelect">
+                        <option value="">请选择币对...</option>
+                    </select>
+                </div>
+                <div class="control-item">
+                    <label for="timeRange">时间范围:</label>
+                    <select id="timeRange">
+                        <option value="1">1小时</option>
+                        <option value="6">6小时</option>
+                        <option value="24" selected>24小时</option>
+                        <option value="72">3天</option>
+                        <option value="168">7天</option>
+                    </select>
+                </div>
+                <div class="control-item">
+                    <label for="autoRefresh">自动刷新:</label>
+                    <select id="autoRefresh">
+                        <option value="0">关闭</option>
+                        <option value="5">5秒</option>
+                        <option value="10" selected>10秒</option>
+                        <option value="30">30秒</option>
+                        <option value="60">1分钟</option>
+                    </select>
+                </div>
+                <button class="btn" onclick="loadData()">刷新数据</button>
+            </div>
+        </div>
+
+        <div class="content">
+            <div id="loading" class="loading">请选择币对以查看数据</div>
+            <div id="error" class="error" style="display: none;"></div>
+            
+            <div id="statsContainer" style="display: none;">
+                <div class="stats-grid">
+                    <div class="stat-card">
+                        <h3>当前标记价格差</h3>
+                        <div class="value" id="currentMarkPriceDiff">--</div>
+                        <div class="unit">USDT</div>
+                    </div>
+                    <div class="stat-card">
+                        <h3>当前价格差</h3>
+                        <div class="value" id="currentPriceDiff">--</div>
+                        <div class="unit">USDT</div>
+                    </div>
+                    <div class="stat-card">
+                        <h3>标记价格差百分比</h3>
+                        <div class="value" id="currentMarkPriceDiffPct">--</div>
+                        <div class="unit">%</div>
+                    </div>
+                    <div class="stat-card">
+                        <h3>价格差百分比</h3>
+                        <div class="value" id="currentPriceDiffPct">--</div>
+                        <div class="unit">%</div>
+                    </div>
+                    <div class="stat-card">
+                        <h3>平均标记价格差</h3>
+                        <div class="value" id="avgMarkPriceDiff">--</div>
+                        <div class="unit">USDT</div>
+                    </div>
+                    <div class="stat-card">
+                        <h3>数据记录数</h3>
+                        <div class="value" id="recordCount">--</div>
+                        <div class="unit">条</div>
+                    </div>
+                </div>
+            </div>
+
+            <div id="chartsContainer" style="display: none;">
+                <div class="chart-container">
+                    <h3>价格对比图</h3>
+                    <div class="chart-wrapper">
+                        <canvas id="priceChart"></canvas>
+                    </div>
+                </div>
+
+                <div class="chart-container">
+                    <h3>价格差异图</h3>
+                    <div class="chart-wrapper">
+                        <canvas id="diffChart"></canvas>
+                    </div>
+                </div>
+            </div>
+
+            <div id="lastUpdate" class="last-update" style="display: none;"></div>
+        </div>
+    </div>
+
+    <script>
+        let priceChart = null;
+        let diffChart = null;
+        let autoRefreshInterval = null;
+        const API_BASE = 'http://localhost:5000/api';
+
+        // 初始化
+        document.addEventListener('DOMContentLoaded', function() {
+            loadSymbols();
+            setupEventListeners();
+        });
+
+        function setupEventListeners() {
+            document.getElementById('symbolSelect').addEventListener('change', function() {
+                if (this.value) {
+                    loadData();
+                }
+            });
+
+            document.getElementById('timeRange').addEventListener('change', function() {
+                const symbol = document.getElementById('symbolSelect').value;
+                if (symbol) {
+                    loadData();
+                }
+            });
+
+            document.getElementById('autoRefresh').addEventListener('change', function() {
+                setupAutoRefresh();
+            });
+        }
+
+        async function loadSymbols() {
+            try {
+                const response = await axios.get(`${API_BASE}/symbols`);
+                const symbols = response.data.symbols;
+                
+                const select = document.getElementById('symbolSelect');
+                select.innerHTML = '<option value="">请选择币对...</option>';
+                
+                symbols.forEach(symbol => {
+                    const option = document.createElement('option');
+                    option.value = symbol;
+                    option.textContent = symbol;
+                    select.appendChild(option);
+                });
+            } catch (error) {
+                showError('加载币对列表失败: ' + error.message);
+            }
+        }
+
+        async function loadData() {
+            const symbol = document.getElementById('symbolSelect').value;
+            const hours = document.getElementById('timeRange').value;
+            
+            if (!symbol) {
+                showError('请先选择币对');
+                return;
+            }
+
+            showLoading();
+            
+            try {
+                // 并行加载数据和统计信息
+                const [dataResponse, statsResponse, latestResponse] = 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}`)
+                ]);
+
+                const data = dataResponse.data.data;
+                const stats = statsResponse.data;
+                const latest = latestResponse.data;
+
+                updateStats(stats, latest);
+                updateCharts(data, symbol);
+                updateLastUpdateTime();
+                
+                document.getElementById('statsContainer').style.display = 'block';
+                document.getElementById('chartsContainer').style.display = 'block';
+                document.getElementById('lastUpdate').style.display = 'block';
+                document.getElementById('loading').style.display = 'none';
+                document.getElementById('error').style.display = 'none';
+
+            } catch (error) {
+                showError('加载数据失败: ' + error.message);
+            }
+        }
+
+        function updateStats(stats, latest) {
+            // 更新当前数据
+            updateStatValue('currentMarkPriceDiff', latest.mark_price_diff);
+            updateStatValue('currentPriceDiff', latest.price_diff);
+            updateStatValue('currentMarkPriceDiffPct', latest.mark_price_diff_pct, '%');
+            updateStatValue('currentPriceDiffPct', latest.price_diff_pct, '%');
+            
+            // 更新统计数据
+            updateStatValue('avgMarkPriceDiff', stats.avg_mark_price_diff);
+            document.getElementById('recordCount').textContent = stats.record_count || '--';
+        }
+
+        function updateStatValue(elementId, value, suffix = '') {
+            const element = document.getElementById(elementId);
+            if (value !== null && value !== undefined) {
+                const formattedValue = typeof value === 'number' ? value.toFixed(4) : value;
+                element.textContent = formattedValue + suffix;
+                element.className = 'value ' + (value > 0 ? 'positive' : value < 0 ? 'negative' : '');
+            } else {
+                element.textContent = '--';
+                element.className = 'value';
+            }
+        }
+
+        function updateCharts(data, symbol) {
+            if (!data || data.length === 0) {
+                showError('没有可用的数据');
+                return;
+            }
+
+            const labels = data.map(item => new Date(item.timestamp).toLocaleTimeString());
+            
+            // 价格对比图
+            updatePriceChart(labels, data, symbol);
+            
+            // 价格差异图
+            updateDiffChart(labels, data, symbol);
+        }
+
+        function updatePriceChart(labels, data, symbol) {
+            const ctx = document.getElementById('priceChart').getContext('2d');
+            
+            if (priceChart) {
+                priceChart.destroy();
+            }
+
+            priceChart = 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
+                        },
+                        {
+                            label: 'Lighter 标记价格',
+                            data: data.map(item => item.lighter_mark_price),
+                            borderColor: '#36a2eb',
+                            backgroundColor: 'rgba(54, 162, 235, 0.1)',
+                            tension: 0.1,
+                            pointRadius: 0
+                        },
+                        {
+                            label: 'Binance 最新价格',
+                            data: data.map(item => item.binance_price),
+                            borderColor: '#ffce56',
+                            backgroundColor: 'rgba(255, 206, 86, 0.1)',
+                            tension: 0.1,
+                            pointRadius: 0
+                        },
+                        {
+                            label: 'Lighter 最新价格',
+                            data: data.map(item => item.lighter_price),
+                            borderColor: '#4bc0c0',
+                            backgroundColor: 'rgba(75, 192, 192, 0.1)',
+                            tension: 0.1,
+                            pointRadius: 0
+                        }
+                    ]
+                },
+                options: {
+                    responsive: true,
+                    maintainAspectRatio: false,
+                    plugins: {
+                        title: {
+                            display: true,
+                            text: `${symbol} 价格对比`
+                        },
+                        legend: {
+                            position: 'top'
+                        }
+                    },
+                    scales: {
+                        y: {
+                            beginAtZero: false,
+                            title: {
+                                display: true,
+                                text: '价格 (USDT)'
+                            }
+                        },
+                        x: {
+                            title: {
+                                display: true,
+                                text: '时间'
+                            }
+                        }
+                    }
+                }
+            });
+        }
+
+        function updateDiffChart(labels, data, symbol) {
+            const ctx = document.getElementById('diffChart').getContext('2d');
+            
+            if (diffChart) {
+                diffChart.destroy();
+            }
+
+            diffChart = new Chart(ctx, {
+                type: 'line',
+                data: {
+                    labels: labels,
+                    datasets: [
+                        {
+                            label: '标记价格差 (USDT)',
+                            data: data.map(item => item.mark_price_diff),
+                            borderColor: '#ff6384',
+                            backgroundColor: 'rgba(255, 99, 132, 0.1)',
+                            tension: 0.1,
+                            pointRadius: 0,
+                            yAxisID: 'y'
+                        },
+                        {
+                            label: '价格差 (USDT)',
+                            data: data.map(item => item.price_diff),
+                            borderColor: '#36a2eb',
+                            backgroundColor: 'rgba(54, 162, 235, 0.1)',
+                            tension: 0.1,
+                            pointRadius: 0,
+                            yAxisID: 'y'
+                        },
+                        {
+                            label: '标记价格差 (%)',
+                            data: data.map(item => item.mark_price_diff_pct),
+                            borderColor: '#ffce56',
+                            backgroundColor: 'rgba(255, 206, 86, 0.1)',
+                            tension: 0.1,
+                            pointRadius: 0,
+                            yAxisID: 'y1'
+                        },
+                        {
+                            label: '价格差 (%)',
+                            data: data.map(item => item.price_diff_pct),
+                            borderColor: '#4bc0c0',
+                            backgroundColor: 'rgba(75, 192, 192, 0.1)',
+                            tension: 0.1,
+                            pointRadius: 0,
+                            yAxisID: 'y1'
+                        }
+                    ]
+                },
+                options: {
+                    responsive: true,
+                    maintainAspectRatio: false,
+                    plugins: {
+                        title: {
+                            display: true,
+                            text: `${symbol} 价格差异`
+                        },
+                        legend: {
+                            position: 'top'
+                        }
+                    },
+                    scales: {
+                        y: {
+                            type: 'linear',
+                            display: true,
+                            position: 'left',
+                            title: {
+                                display: true,
+                                text: '价格差 (USDT)'
+                            }
+                        },
+                        y1: {
+                            type: 'linear',
+                            display: true,
+                            position: 'right',
+                            title: {
+                                display: true,
+                                text: '价格差 (%)'
+                            },
+                            grid: {
+                                drawOnChartArea: false,
+                            },
+                        },
+                        x: {
+                            title: {
+                                display: true,
+                                text: '时间'
+                            }
+                        }
+                    }
+                }
+            });
+        }
+
+        function setupAutoRefresh() {
+            const interval = parseInt(document.getElementById('autoRefresh').value);
+            
+            if (autoRefreshInterval) {
+                clearInterval(autoRefreshInterval);
+                autoRefreshInterval = null;
+            }
+            
+            if (interval > 0) {
+                autoRefreshInterval = setInterval(() => {
+                    const symbol = document.getElementById('symbolSelect').value;
+                    if (symbol) {
+                        loadData();
+                    }
+                }, interval * 1000);
+            }
+        }
+
+        function showLoading() {
+            document.getElementById('loading').style.display = 'block';
+            document.getElementById('loading').textContent = '正在加载数据...';
+            document.getElementById('error').style.display = 'none';
+        }
+
+        function showError(message) {
+            document.getElementById('error').textContent = message;
+            document.getElementById('error').style.display = 'block';
+            document.getElementById('loading').style.display = 'none';
+        }
+
+        function updateLastUpdateTime() {
+            const now = new Date();
+            document.getElementById('lastUpdate').textContent = `最后更新: ${now.toLocaleString()}`;
+        }
+    </script>
+</body>
+</html>