Răsfoiți Sursa

使用plotly

skyffire 6 luni în urmă
părinte
comite
e2e5a7c544
3 a modificat fișierele cu 461 adăugiri și 580 ștergeri
  1. 207 205
      price_checker.py
  2. 0 375
      templates/index.html
  3. 254 0
      templates/index_plotly.html

+ 207 - 205
price_checker.py

@@ -3,281 +3,283 @@ import decimal
 import time
 import threading
 import json
-from flask import Flask, render_template, jsonify
+from flask import Flask, render_template, jsonify  # jsonify 仍然有用,但/data可能直接返回Plotly JSON字符串
 from collections import deque
 import logging
 
-# --- 配置部分 ---
-# OpenOcean
-IN_TOKEN_ADDRESS_BSC = '0x55d398326f99059ff775485246999027b3197955'  # USDT on BSC
-OUT_TOKEN_ADDRESS_BSC = '0x8F0528cE5eF7B51152A59745bEfDD91D97091d2F'
-AMOUNT_TO_QUERY_HUMAN = decimal.Decimal('1000')  # 查询的USDT数量
+import plotly  # 引入 Plotly
+import plotly.graph_objects as go
+from plotly.utils import PlotlyJSONEncoder  # 用于将Plotly图表序列化为JSON
 
-# Gate.io 现货交易对
-GATEIO_SPOT_PAIR = 'ALPACA_USDT'  # 示例, 请确保存在且与OUT_TOKEN_ADDRESS_BSC对应
-
-# Gate.io USDT 结算永续合约名称
-GATEIO_FUTURES_CONTRACT = 'ALPACA_USDT'  # 示例, 请确保存在且与OUT_TOKEN_ADDRESS_BSC对应
-
-# 代理配置
+# --- 配置部分 (与之前相同) ---
+IN_TOKEN_ADDRESS_BSC = '0x55d398326f99059ff775485246999027b3197955'
+OUT_TOKEN_ADDRESS_BSC = '0x6894CDe390a3f51155ea41Ed24a33A4827d3063D'  # CAT
+AMOUNT_TO_QUERY_HUMAN = decimal.Decimal('1000')
+GATEIO_SPOT_PAIR = 'CAT_USDT'
+GATEIO_FUTURES_CONTRACT = 'CAT_USDT'
 proxies = None
-# PROXY_HOST = '127.0.0.1'
-# PROXY_PORT = '7890'
-# proxies = {
-#     'http': f'http://{PROXY_HOST}:{PROXY_PORT}',
-#     'https': f'http://{PROXY_HOST}:{PROXY_PORT}',
-# }
-
 decimal.getcontext().prec = 36
 
 
-# --- 价格获取函数 ---
+# --- 价格获取函数 (与之前相同, 省略以保持简洁) ---
 def get_openocean_price_bsc(in_token_addr, out_token_addr, human_amount_in_decimal_for_request, gas_price=3):
-    # (代码与之前相同, 返回 {"price_usdt_per_out_token": calculated_price} 或 {"error": ...})
-    chain = 'bsc'
+    chain = 'bsc';
     url = f'https://open-api.openocean.finance/v4/{chain}/quote'
-    params = {
-        'inTokenAddress': in_token_addr,
-        'outTokenAddress': out_token_addr,
-        'amount': str(human_amount_in_decimal_for_request),
-        'gasPrice': gas_price,
-    }
+    params = {'inTokenAddress': in_token_addr, 'outTokenAddress': out_token_addr,
+              'amount': str(human_amount_in_decimal_for_request), 'gasPrice': gas_price}
     try:
-        response = requests.get(url, params=params, proxies=proxies, timeout=10)
-        response.raise_for_status()
-        data = response.json()
+        r = requests.get(url, params=params, proxies=proxies, timeout=10);
+        r.raise_for_status();
+        data = r.json()
         if data.get('code') == 200 and data.get('data'):
-            api_data = data['data']
-            required_keys = ['inToken', 'outToken', 'inAmount', 'outAmount']
-            if not all(key in api_data for key in required_keys) or \
-                    api_data['inToken'].get('decimals') is None or \
-                    api_data['outToken'].get('decimals') is None:
-                return {"error": "OO API响应中缺少必要的数据"}
-
-            in_token_decimals = int(api_data['inToken']['decimals'])
-            out_token_decimals = int(api_data['outToken']['decimals'])
-            atomic_in_amount = decimal.Decimal(api_data['inAmount'])
-            atomic_out_amount = decimal.Decimal(api_data['outAmount'])
-            human_in_amount_used = atomic_in_amount / (decimal.Decimal('10') ** in_token_decimals)
-            human_out_amount_received = atomic_out_amount / (decimal.Decimal('10') ** out_token_decimals)
-            if human_out_amount_received == 0: return {"error": "OO 计算的输出数量为零"}
-            calculated_price = human_in_amount_used / human_out_amount_received
-            return {"price_usdt_per_out_token": calculated_price}
+            d = data['data'];
+            req = ['inToken', 'outToken', 'inAmount', 'outAmount']
+            if not all(k in d for k in req) or d['inToken'].get('decimals') is None or d['outToken'].get(
+                'decimals') is None: return {"error": "OO API缺少数据"}
+            in_dec, out_dec = int(d['inToken']['decimals']), int(d['outToken']['decimals'])
+            atomic_in, atomic_out = decimal.Decimal(d['inAmount']), decimal.Decimal(d['outAmount'])
+            h_in, h_out = atomic_in / (10 ** in_dec), atomic_out / (10 ** out_dec)
+            if h_out == 0: return {"error": "OO输出为0"}
+            return {"price_usdt_per_out_token": h_in / h_out}
         else:
-            error_message = data.get('message', 'N/A') if data else '无响应数据'
-            error_code = data.get('code', 'N/A') if data else 'N/A'
-            return {"error": f"OO API 错误 - Code: {error_code}, Msg: {error_message}"}
-    except requests.exceptions.RequestException as e:
-        return {"error": f"OO请求失败: {e}"}
+            return {
+                "error": f"OO API错误 - Code:{data.get('code', 'N/A')}, Msg:{data.get('msg', data.get('message', 'N/A')) if isinstance(data, dict) else '格式错误'}"}
     except Exception as e:
-        return {"error": f"OO意外错误: {e}"}
+        return {"error": f"OO请求错误: {e}"}
 
 
 def get_gateio_spot_price(pair_symbol):
-    # (代码与之前相同, 返回 {"price_base_in_quote": decimal.Decimal(last_price_str)} 或 {"error": ...})
-    url = f'https://api.gateio.ws/api/v4/spot/tickers'
+    url = f'https://api.gateio.ws/api/v4/spot/tickers';
     params = {'currency_pair': pair_symbol}
     try:
-        response = requests.get(url, params=params, proxies=proxies, timeout=10)
-        response.raise_for_status()
-        data = response.json()
-        if isinstance(data, list) and len(data) > 0:
-            ticker_data = data[0]
-            if ticker_data.get('currency_pair') == pair_symbol:
-                last_price_str = ticker_data.get('last')
-                if last_price_str:
-                    return {"price_base_in_quote": decimal.Decimal(last_price_str)}
-                else:
-                    return {"error": "Gate现货未找到last price"}
+        r = requests.get(url, params=params, proxies=proxies, timeout=10);
+        r.raise_for_status();
+        data = r.json()
+        if isinstance(data, list) and data:
+            td = data[0]
+            if td.get('currency_pair') == pair_symbol and td.get('last'):
+                return {"price_base_in_quote": decimal.Decimal(td['last'])}
             else:
-                return {"error": f"Gate现货交易对不匹配"}
+                return {"error": f"Gate现货数据不匹配(Req:{pair_symbol},Res:{td.get('currency_pair')})或无价格"}
+        elif isinstance(data, dict) and data.get('label'):
+            return {"error": f"Gate现货API错误: {data.get('label')}-{data.get('message', '')}"}
         else:
-            error_msg = "未知错误或交易对不存在"
-            if isinstance(data, dict) and data.get('label'):
-                error_msg = f"{data.get('label')}: {data.get('message', '')}"
-            return {"error": f"Gate现货 API 数据格式错误或未找到交易对 {pair_symbol}. Msg: {error_msg}"}
-    except requests.exceptions.RequestException as e:
-        return {"error": f"Gate现货请求失败: {e}"}
+            return {"error": f"Gate现货API数据格式错误或未找到 {pair_symbol}"}
     except Exception as e:
-        return {"error": f"Gate现货意外错误: {e}"}
+        return {"error": f"Gate现货请求错误: {e}"}
 
 
 def get_gateio_futures_price(contract_symbol, settle_currency='usdt'):
-    # (代码与之前相同, 返回 {"price_settle_per_base_asset": decimal.Decimal(last_price_str)} 或 {"error": ...})
-    url = f'https://api.gateio.ws/api/v4/futures/{settle_currency}/tickers'
+    url = f'https://api.gateio.ws/api/v4/futures/{settle_currency}/tickers';
     params = {'contract': contract_symbol}
     try:
-        response = requests.get(url, params=params, proxies=proxies, timeout=10)
-        response.raise_for_status()
-        data = response.json()
-        if isinstance(data, list) and len(data) > 0:
-            ticker_data = data[0]
-            if ticker_data.get('contract') == contract_symbol:
-                last_price_str = ticker_data.get('last')
-                if last_price_str:
-                    return {"price_settle_per_base_asset": decimal.Decimal(last_price_str)}
-                else:
-                    return {"error": f"Gate期货 ({contract_symbol}) 未找到 'last' price"}
+        r = requests.get(url, params=params, proxies=proxies, timeout=10);
+        r.raise_for_status();
+        data = r.json()
+        if isinstance(data, list) and data:
+            td = data[0]
+            if td.get('contract') == contract_symbol and td.get('last'):
+                return {"price_settle_per_base_asset": decimal.Decimal(td['last'])}
             else:
-                return {"error": f"Gate期货 API 返回合约与请求 ({contract_symbol}) 不匹配"}
+                return {"error": f"Gate期货数据不匹配(Req:{contract_symbol},Res:{td.get('contract')})或无价格"}
+        elif isinstance(data, dict) and data.get('label'):
+            return {"error": f"Gate期货API错误: {data.get('label')}-{data.get('message', '')}"}
         else:
-            error_msg = "未知错误或合约不存在"
-            if isinstance(data, dict) and data.get('label'):
-                error_msg = f"{data.get('label')}: {data.get('message', '')}"
-            return {"error": f"Gate期货 API 数据格式错误或未找到合约 {contract_symbol}. Msg: {error_msg}"}
-    except requests.exceptions.RequestException as e:
-        return {"error": f"Gate期货请求失败: {e}"}
+            return {"error": f"Gate期货API数据格式错误或未找到 {contract_symbol}"}
     except Exception as e:
-        return {"error": f"Gate期货意外错误: {e}"}
+        return {"error": f"Gate期货请求错误: {e}"}
 
 
 app = Flask(__name__)
-
-MAX_HISTORY_POINTS = 86400
-latest_data = {
-    "oo_price": None,
-    "gate_spot_price": None,
-    "gate_futures_price": None,
-    "diff_oo_vs_spot_percentage": None,
-    "diff_oo_vs_futures_percentage": None,
-    "diff_spot_vs_futures_percentage": None,  # 基差
-    "oo_error": None,
-    "gate_spot_error": None,
-    "gate_futures_error": None,
-    "last_updated": None,
+log = logging.getLogger('werkzeug');
+log.setLevel(logging.ERROR)
+
+MAX_HISTORY_POINTS_PLOTLY = 300  # Plotly 图表显示的点数
+REFRESH_INTERVAL_SECONDS = 1  # 后端数据更新频率
+
+# 存储原始数据点,用于 Plotly 生成图表
+historical_data_points = deque(maxlen=MAX_HISTORY_POINTS_PLOTLY)
+# 存储最新的数值,用于表格展示 (这个结构可以保留)
+latest_values_for_table = {
+    "oo_price": "N/A", "gate_spot_price": "N/A", "gate_futures_price": "N/A",
+    "diff_oo_vs_spot_percentage": "N/A", "diff_oo_vs_futures_percentage": "N/A",
+    "diff_spot_vs_futures_percentage": "N/A",
+    "oo_error": None, "gate_spot_error": None, "gate_futures_error": None,
+    "last_updated": "N/A",
     "gate_spot_pair_name": GATEIO_SPOT_PAIR,
-    "gate_futures_contract_name": GATEIO_FUTURES_CONTRACT
+    "gate_futures_contract_name": GATEIO_FUTURES_CONTRACT,
+    "target_asset_symbol": GATEIO_SPOT_PAIR.split('_')[0]
 }
-# 历史价格: oo, spot, futures
-historical_prices = deque(maxlen=MAX_HISTORY_POINTS)
-# 历史价差: oo_vs_spot, oo_vs_futures, spot_vs_futures
-historical_diffs = deque(maxlen=MAX_HISTORY_POINTS)
-
-data_lock = threading.Lock()
+data_lock = threading.Lock()  # 保护共享数据
 
 
 def calculate_percentage_diff(price_a, price_b):
-    """(price_a - price_b) / price_b * 100, B为基准"""
-    if price_a is not None and price_b is not None and price_b > 0:
-        diff = price_a - price_b
-        return (diff / price_b) * 100
+    if price_a is not None and price_b is not None and isinstance(price_a, decimal.Decimal) and isinstance(price_b,
+                                                                                                           decimal.Decimal) and price_b > 0:
+        return ((price_a - price_b) / price_b) * 100
     return None
 
 
-def update_prices_periodically():
-    global latest_data, historical_prices, historical_diffs
+def update_data_for_plotly_and_table():
+    global historical_data_points, latest_values_for_table
+    print("数据更新线程启动...")
     while True:
-        fetch_time = time.strftime("%H:%M:%S")
+        fetch_time_full = time.strftime("%Y-%m-%d %H:%M:%S")
+        fetch_time_chart = time.strftime("%H:%M:%S")  # 或者使用 time.time() 获取更精确的unix时间戳
 
         oo_data = get_openocean_price_bsc(IN_TOKEN_ADDRESS_BSC, OUT_TOKEN_ADDRESS_BSC, AMOUNT_TO_QUERY_HUMAN)
         spot_data = get_gateio_spot_price(GATEIO_SPOT_PAIR)
         futures_data = get_gateio_futures_price(GATEIO_FUTURES_CONTRACT)
 
-        oo_price = oo_data.get("price_usdt_per_out_token")
-        spot_price = spot_data.get("price_base_in_quote")
-        futures_price = futures_data.get("price_settle_per_base_asset")
+        oo_price = oo_data.get("price_usdt_per_out_token")  # decimal or None
+        spot_price = spot_data.get("price_base_in_quote")  # decimal or None
+        futures_price = futures_data.get("price_settle_per_base_asset")  # decimal or None
 
         oo_err = oo_data.get("error")
         spot_err = spot_data.get("error")
         futures_err = futures_data.get("error")
 
-        # 价差计算
         diff_oo_spot_pct = calculate_percentage_diff(oo_price, spot_price)
         diff_oo_futures_pct = calculate_percentage_diff(oo_price, futures_price)
         diff_spot_futures_pct = calculate_percentage_diff(spot_price, futures_price)
 
-        # 存储历史价格
-        historical_prices.append({
-            "timestamp": fetch_time,
-            "oo": float(oo_price) if oo_price else None,
-            "spot": float(spot_price) if spot_price else None,
-            "futures": float(futures_price) if futures_price else None,
-        })
-        # 存储历史价差
-        historical_diffs.append({
-            "timestamp": fetch_time,
-            "oo_vs_spot": float(diff_oo_spot_pct) if diff_oo_spot_pct is not None else None,
-            "oo_vs_futures": float(diff_oo_futures_pct) if diff_oo_futures_pct is not None else None,
-            "spot_vs_futures": float(diff_spot_futures_pct) if diff_spot_futures_pct is not None else None,
-        })
+        current_point = {
+            "time": fetch_time_chart,  # 使用时间字符串作为X轴,Plotly可以处理
+            "oo_price": float(oo_price) if oo_price else None,
+            "spot_price": float(spot_price) if spot_price else None,
+            "futures_price": float(futures_price) if futures_price else None,
+            "diff_oo_spot": float(diff_oo_spot_pct) if diff_oo_spot_pct is not None else None,
+            "diff_oo_futures": float(diff_oo_futures_pct) if diff_oo_futures_pct is not None else None,
+            "diff_spot_futures": float(diff_spot_futures_pct) if diff_spot_futures_pct is not None else None,
+        }
 
         with data_lock:
-            latest_data["oo_price"] = str(oo_price) if oo_price else None
-            latest_data["gate_spot_price"] = str(spot_price) if spot_price else None
-            latest_data["gate_futures_price"] = str(futures_price) if futures_price else None
-            latest_data[
+            historical_data_points.append(current_point)
+
+            latest_values_for_table["oo_price"] = str(oo_price) if oo_price else "N/A"
+            latest_values_for_table["gate_spot_price"] = str(spot_price) if spot_price else "N/A"
+            latest_values_for_table["gate_futures_price"] = str(futures_price) if futures_price else "N/A"
+            latest_values_for_table[
                 "diff_oo_vs_spot_percentage"] = f"{diff_oo_spot_pct:+.4f}%" if diff_oo_spot_pct is not None else "N/A"
-            latest_data[
+            latest_values_for_table[
                 "diff_oo_vs_futures_percentage"] = f"{diff_oo_futures_pct:+.4f}%" if diff_oo_futures_pct is not None else "N/A"
-            latest_data[
+            latest_values_for_table[
                 "diff_spot_vs_futures_percentage"] = f"{diff_spot_futures_pct:+.4f}%" if diff_spot_futures_pct is not None else "N/A"
-            latest_data["oo_error"] = oo_err
-            latest_data["gate_spot_error"] = spot_err
-            latest_data["gate_futures_error"] = futures_err
-            latest_data["last_updated"] = time.strftime("%Y-%m-%d %H:%M:%S")
-            # gate_spot_pair_name & gate_futures_contract_name are set at init
+            latest_values_for_table["oo_error"] = oo_err
+            latest_values_for_table["gate_spot_error"] = spot_err
+            latest_values_for_table["gate_futures_error"] = futures_err
+            latest_values_for_table["last_updated"] = fetch_time_full
 
-        print(f"{fetch_time} | OO: {latest_data['oo_price']} (Err:{oo_err}) | "
-              f"Spot({GATEIO_SPOT_PAIR}): {latest_data['gate_spot_price']} (Err:{spot_err}) | "
-              f"Futures({GATEIO_FUTURES_CONTRACT}): {latest_data['gate_futures_price']} (Err:{futures_err}) | "
-              f"D(OoS):{latest_data['diff_oo_vs_spot_percentage']} D(OoF):{latest_data['diff_oo_vs_futures_percentage']} D(SpF):{latest_data['diff_spot_vs_futures_percentage']}")
+        # Log a summary, not too verbose
+        print(f"{fetch_time_chart} Update | "
+              f"OO:{'OK' if oo_price else 'Fail'} "
+              f"GS:{'OK' if spot_price else 'Fail'} "
+              f"GF:{'OK' if futures_price else 'Fail'}")
 
-        time.sleep(1)
+        time.sleep(REFRESH_INTERVAL_SECONDS)
 
 
 @app.route('/')
-def index():
-    app_config = {
-        "GATEIO_SPOT_PAIR": GATEIO_SPOT_PAIR,
-        "GATEIO_FUTURES_CONTRACT": GATEIO_FUTURES_CONTRACT,
-        # 也可以把 OUT_TOKEN_ADDRESS_BSC 对应的代币符号传过去,如果能确定的话
-        "TARGET_ASSET_SYMBOL": GATEIO_SPOT_PAIR.split('_')[0]  # 假设命名规则为 ASSET_USDT
-    }
-    return render_template('index.html', config=app_config)
-
-
-@app.route('/data')
-def get_data():
+def index_plotly():
+    # 将配置传递给模板,用于标题等
+    target_asset = GATEIO_SPOT_PAIR.split('_')[0]
+    return render_template('index_plotly.html',
+                           target_asset=target_asset,
+                           spot_pair=GATEIO_SPOT_PAIR,
+                           futures_contract=GATEIO_FUTURES_CONTRACT,
+                           refresh_interval_ms=REFRESH_INTERVAL_SECONDS * 1000)
+
+
+@app.route('/table-data')  # 新增一个专门用于表格数据的API
+def get_table_data():
     with data_lock:
-        response_data = {
-            "current": {**latest_data},  # Create a copy
-            "history": {
-                "prices": {
-                    "labels": [item['timestamp'] for item in historical_prices],
-                    "oo": [item['oo'] for item in historical_prices],
-                    "spot": [item['spot'] for item in historical_prices],
-                    "futures": [item['futures'] for item in historical_prices],
-                },
-                "diffs": {  # Changed from "difference" to "diffs" to hold multiple series
-                    "labels": [item['timestamp'] for item in historical_diffs],  # Should be same as price labels
-                    "oo_vs_spot": [item['oo_vs_spot'] for item in historical_diffs],
-                    "oo_vs_futures": [item['oo_vs_futures'] for item in historical_diffs],
-                    "spot_vs_futures": [item['spot_vs_futures'] for item in historical_diffs],
-                }
-            }
-        }
-        # Add config here too, so JS can access it if not from initial render_template
-        response_data["current"]["config_gate_spot_pair"] = GATEIO_SPOT_PAIR
-        response_data["current"]["config_gate_futures_contract"] = GATEIO_FUTURES_CONTRACT
-        response_data["current"]["config_target_asset_symbol"] = GATEIO_SPOT_PAIR.split('_')[0]
+        return jsonify(latest_values_for_table)
+
 
-        return jsonify(response_data)
+@app.route('/plotly-chart-data')  # 用于Plotly图表的JSON数据
+def get_plotly_chart_data():
+    with data_lock:
+        # 从 deque 转换为列表,方便 Plotly 处理
+        points = list(historical_data_points)
+        if not points:  # 如果没有数据点,返回空图表结构或错误提示
+            fig = go.Figure()
+            fig.update_layout(title_text="暂无数据", xaxis_title="时间", yaxis_title="价格/百分比")
+            return json.dumps(fig, cls=PlotlyJSONEncoder)
+
+        times = [p['time'] for p in points]
+
+        # --- 创建价格图 (主图) ---
+        fig_prices = go.Figure()
+        fig_prices.add_trace(go.Scatter(x=times, y=[p['oo_price'] for p in points], mode='lines', name='OpenOcean',
+                                        line=dict(color='rgb(75, 192, 192)')))
+        fig_prices.add_trace(go.Scatter(x=times, y=[p['spot_price'] for p in points], mode='lines',
+                                        name=f'Gate Spot ({GATEIO_SPOT_PAIR})', line=dict(color='rgb(255, 99, 132)')))
+        fig_prices.add_trace(go.Scatter(x=times, y=[p['futures_price'] for p in points], mode='lines',
+                                        name=f'Gate Futures ({GATEIO_FUTURES_CONTRACT})',
+                                        line=dict(color='rgb(54, 162, 235)')))
+
+        target_asset = GATEIO_SPOT_PAIR.split('_')[0]
+        fig_prices.update_layout(
+            title_text=f'{target_asset}/USDT 价格历史',
+            xaxis_title='时间',
+            yaxis_title=f'价格 ({target_asset}/USDT)',
+            legend_title_text='平台',
+            # height=400 # 可以为每个子图设置高度
+        )
+
+        # --- 创建价差图 (副图) ---
+        fig_diffs = go.Figure()
+        fig_diffs.add_trace(go.Scatter(x=times, y=[p['diff_oo_spot'] for p in points], mode='lines',
+                                       name=f'OO vs Spot ({GATEIO_SPOT_PAIR})', line=dict(color='rgb(255, 159, 64)')))
+        fig_diffs.add_trace(go.Scatter(x=times, y=[p['diff_oo_futures'] for p in points], mode='lines',
+                                       name=f'OO vs Futures ({GATEIO_FUTURES_CONTRACT})',
+                                       line=dict(color='rgb(153, 102, 255)')))
+        fig_diffs.add_trace(go.Scatter(x=times, y=[p['diff_spot_futures'] for p in points], mode='lines',
+                                       name=f'Spot ({GATEIO_SPOT_PAIR}) vs Futures ({GATEIO_FUTURES_CONTRACT})',
+                                       line=dict(color='rgb(75, 192, 75)')))
+
+        fig_diffs.update_layout(
+            title_text='价差百分比历史',
+            xaxis_title='时间',
+            yaxis_title='价差 (%)',
+            legend_title_text='对比',
+            yaxis_zeroline=True,  # 在y=0处画一条线
+            # height=300
+        )
+
+        # Plotly 不直接支持通过 fig.to_json() 合并具有完全独立Y轴的子图到一个figure中并保持良好交互性
+        # 最简单的方式是前端请求两个独立的图表JSON,然后分别渲染。
+        # 或者,我们可以使用 make_subplots,但共享X轴是关键。
+
+        # 为了简化前端,我们这里返回两个独立的Figure的JSON。前端需要能处理这个结构。
+        # 或者,更常见的是,前端请求两个不同的API端点来获取图表数据。
+        # 这里,我们将返回一个包含两个图表定义的字典。
+
+        combined_figure_data = {
+            "price_chart": json.loads(json.dumps(fig_prices, cls=PlotlyJSONEncoder)),  # 确保可JSON序列化
+            "diff_chart": json.loads(json.dumps(fig_diffs, cls=PlotlyJSONEncoder))
+        }
+        return jsonify(combined_figure_data)  # jsonify 会自动处理JSON序列化
 
 
 if __name__ == "__main__":
-    werkzeug_logger = logging.getLogger('werkzeug')
-    werkzeug_logger.setLevel(logging.ERROR)
-
-    print(f"监控 OpenOcean ({OUT_TOKEN_ADDRESS_BSC.split('0x')[-1][:6]}...) vs USDT")
+    print("应用启动...")
+    print(f"监控 OpenOcean (BSC代币: ...{OUT_TOKEN_ADDRESS_BSC[-6:]}) vs USDT")
     print(f"Gate.io 现货: {GATEIO_SPOT_PAIR}")
     print(f"Gate.io 期货: {GATEIO_FUTURES_CONTRACT}")
-    # Basic check for CAT example, adjust if your token is different
-    asset_symbol = GATEIO_SPOT_PAIR.split('_')[0].upper()
-    if asset_symbol == "CAT" and "0x6894CDe390a3f51155ea41Ed24a33A4827d3063D" not in OUT_TOKEN_ADDRESS_BSC:
-        print(f"[警告] 配置的资产符号 'CAT' 可能与 OpenOcean 输出代币地址不完全对应。")
-    elif asset_symbol not in GATEIO_FUTURES_CONTRACT.upper():
-        print(f"[警告] 现货交易对 '{GATEIO_SPOT_PAIR}' 的基础资产与期货合约 '{GATEIO_FUTURES_CONTRACT}' 可能不匹配。")
-
-    price_updater_thread = threading.Thread(target=update_prices_periodically, daemon=True)
-    price_updater_thread.start()
-    app.run(debug=True, host='0.0.0.0', port=5000, use_reloader=False)
+    # ... (启动时的配置检查可以保留) ...
+    spot_base = GATEIO_SPOT_PAIR.split('_')[0].upper()
+    futures_base = GATEIO_FUTURES_CONTRACT.split('_')[0].upper()
+    oo_asset_placeholder = "CAT" if "0x6894CDe390a3f51155ea41Ed24a33A4827d3063D" in OUT_TOKEN_ADDRESS_BSC else "TOKEN"
+
+    if oo_asset_placeholder != spot_base: print(
+        f"[警告] OO资产('{oo_asset_placeholder}')与Gate现货基础资产('{spot_base}')可能不匹配!")
+    if oo_asset_placeholder != futures_base: print(
+        f"[警告] OO资产('{oo_asset_placeholder}')与Gate期货基础资产('{futures_base}')可能不匹配!")
+    if spot_base != futures_base: print(
+        f"[警告] Gate现货基础资产('{spot_base}')与期货基础资产('{futures_base}')不匹配!")
+
+    data_thread = threading.Thread(target=update_data_for_plotly_and_table, daemon=True)
+    data_thread.start()
+    print(f"Flask 服务将在 http://0.0.0.0:5000 上运行 (刷新间隔: {REFRESH_INTERVAL_SECONDS}s)")
+    app.run(debug=False, host='0.0.0.0', port=5000, use_reloader=False)

+ 0 - 375
templates/index.html

@@ -1,375 +0,0 @@
-<!DOCTYPE html>
-<html lang="zh-CN">
-<head>
-    <meta charset="UTF-8">
-    <meta name="viewport" content="width=device-width, initial-scale=1.0">
-    <title>多平台价格与价差监控</title>
-    <script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
-    <script src="https://cdn.jsdelivr.net/npm/chartjs-plugin-zoom@2.0.1/dist/chartjs-plugin-zoom.min.js"></script>
-    <style>
-        body { font-family: Arial, sans-serif; margin: 20px; background-color: #f4f4f4; color: #333; }
-        .container { background-color: #fff; padding: 20px; border-radius: 8px; box-shadow: 0 0 10px rgba(0,0,0,0.1); margin-bottom: 20px; }
-        h1, h2 { text-align: center; color: #333; margin-top:10px; margin-bottom:15px; }
-        table { width: 100%; border-collapse: collapse; margin-top: 10px; margin-bottom: 15px; }
-        th, td { border: 1px solid #ddd; padding: 8px; text-align: left; font-size:0.85em; word-break: break-all; }
-        th { background-color: #e9e9e9; white-space: nowrap; }
-        .price-up { color: green; }
-        .price-down { color: red; }
-        .error-message { color: #c00; font-style: italic; }
-        .status-cell { min-width: 100px; } /* Give status cells some min width */
-        .timestamp { font-size: 0.9em; color: #666; text-align: right; margin-top: 10px; }
-        .chart-container { position: relative; height: 38vh; width: 95vw; margin: auto; margin-bottom: 10px; } /* Slightly smaller height for more charts if needed */
-        .controls-container { text-align: center; margin-bottom: 15px; margin-top: 5px; }
-        .control-button { background-color: #007bff; color: white; border: none; padding: 8px 15px; font-size: 14px; border-radius: 5px; cursor: pointer; margin:0 5px; }
-        .control-button:hover { background-color: #0056b3; }
-        .pause-button-active { background-color: #ffc107; color: #333; }
-        .pause-button-active:hover { background-color: #e0a800; }
-        .platform-name {font-weight: bold;}
-        #main-title { font-size: 1.8em; }
-        h2 { font-size: 1.3em; }
-    </style>
-</head>
-<body>
-    <div class="container">
-        <h1 id="main-title">价格监控</h1>
-        <div class="controls-container">
-            <button id="pause-resume-button" class="control-button">暂停刷新</button>
-        </div>
-        <table>
-            <thead>
-                <tr>
-                    <th>平台</th>
-                    <th>价格 (USDT)</th>
-                    <th class="status-cell">状态/错误</th>
-                </tr>
-            </thead>
-            <tbody>
-                <tr>
-                    <td class="platform-name">OpenOcean (BSC)</td>
-                    <td id="oo-price">加载中...</td>
-                    <td id="oo-status" class="status-cell"></td>
-                </tr>
-                <tr>
-                    <td class="platform-name" id="gate-spot-label">Gate.io 现货</td>
-                    <td id="gate-spot-price">加载中...</td>
-                    <td id="gate-spot-status" class="status-cell"></td>
-                </tr>
-                <tr>
-                    <td class="platform-name" id="gate-futures-label">Gate.io 期货</td>
-                    <td id="gate-futures-price">加载中...</td>
-                    <td id="gate-futures-status" class="status-cell"></td>
-                </tr>
-            </tbody>
-        </table>
-        <h2>价差百分比</h2>
-        <table>
-             <thead>
-                <tr>
-                    <th>对比</th>
-                    <th>价差 (%)</th>
-                </tr>
-            </thead>
-            <tbody>
-                <tr>
-                    <td id="diff-label-oo-spot">OO vs Gate.io 现货</td>
-                    <td id="diff-oo-vs-spot">计算中...</td>
-                </tr>
-                <tr>
-                    <td id="diff-label-oo-futures">OO vs Gate.io 期货</td>
-                    <td id="diff-oo-vs-futures">计算中...</td>
-                </tr>
-                <tr>
-                    <td id="diff-label-spot-futures">Gate.io 现货 vs 期货</td>
-                    <td id="diff-spot-vs-futures">计算中...</td>
-                </tr>
-            </tbody>
-        </table>
-        <div class="timestamp">最后更新: <span id="last-updated">N/A</span></div>
-    </div>
-
-    <div class="container">
-        <h2 id="price-chart-title">价格历史曲线</h2>
-        <div class="chart-container">
-            <canvas id="priceHistoryChart"></canvas>
-        </div>
-        <div class="controls-container">
-            <button id="reset-price-zoom-button" class="control-button">重置缩放</button>
-        </div>
-    </div>
-
-    <div class="container">
-        <h2 id="diff-chart-title">价差百分比历史曲线</h2>
-        <div class="chart-container">
-            <canvas id="diffPercentageChart"></canvas>
-        </div>
-        <div class="controls-container">
-            <button id="reset-diff-zoom-button" class="control-button">重置缩放</button>
-        </div>
-    </div>
-
-    <script>
-        let priceChartInstance = null;
-        let diffChartInstance = null;
-
-        let dataUpdateIntervalID = null;
-        let isPaused = false;
-        const REFRESH_INTERVAL_MS = 1000; // 1秒刷新一次
-        const pauseResumeButton = document.getElementById('pause-resume-button');
-        let isSyncingZoomPan = false;
-
-        const initialConfig = {
-            GATEIO_SPOT_PAIR: "{{ config.GATEIO_SPOT_PAIR if config else 'SPOT_USDT' }}",
-            GATEIO_FUTURES_CONTRACT: "{{ config.GATEIO_FUTURES_CONTRACT if config else 'FUTURES_USDT' }}",
-            TARGET_ASSET_SYMBOL: "{{ config.TARGET_ASSET_SYMBOL if config else 'ASSET' }}"
-        };
-        let currentGateSpotPair = initialConfig.GATEIO_SPOT_PAIR;
-        let currentGateFuturesContract = initialConfig.GATEIO_FUTURES_CONTRACT;
-        let currentAssetSymbol = initialConfig.TARGET_ASSET_SYMBOL;
-
-        function formatPrice(priceStr) {
-            if (priceStr === null || priceStr === undefined || priceStr === "N/A" || priceStr.toLowerCase() === "n/a") return "N/A";
-            const price = parseFloat(priceStr);
-            if (isNaN(price)) return "N/A";
-            if (price === 0) return "0.00000000"; // More precision for 0
-            if (Math.abs(price) < 0.0000001 && price !== 0) return price.toExponential(3);
-            if (Math.abs(price) < 0.001) return price.toFixed(8);
-            if (Math.abs(price) < 1) return price.toFixed(6);
-            return price.toFixed(4);
-        }
-
-        function formatPercentage(percStr) {
-            if (percStr === null || percStr === undefined || percStr === "N/A" || percStr.toLowerCase() === "n/a") return "N/A";
-            if (String(percStr).includes('%')) return percStr; // Already formatted
-            const perc = parseFloat(percStr);
-            if (isNaN(perc)) return "N/A";
-            return `${perc > 0 ? '+' : ''}${perc.toFixed(4)}%`;
-        }
-
-        function syncXAxes(sourceChart, targetChart) {
-            if (isSyncingZoomPan || !sourceChart || !targetChart) return;
-            isSyncingZoomPan = true;
-            const sourceXScale = sourceChart.scales.x;
-            const targetXScale = targetChart.scales.x;
-            if (sourceXScale && targetXScale && sourceXScale.min !== undefined && sourceXScale.max !== undefined) { // Ensure min/max are defined
-                targetXScale.options.min = sourceXScale.min;
-                targetXScale.options.max = sourceXScale.max;
-                targetChart.update('none');
-            }
-            requestAnimationFrame(() => { isSyncingZoomPan = false; });
-        }
-
-        function initializeChart(ctx, chartIdentifier, datasetsConfig, initialLabels, yAxisTitle, chartTitleText, isPriceChart = true) {
-            return new Chart(ctx, {
-                type: 'line',
-                data: { labels: initialLabels, datasets: datasetsConfig },
-                options: {
-                    responsive: true, maintainAspectRatio: false, animation: { duration: 0 }, // Disable animation for faster updates
-                    scales: {
-                        x: { title: { display: true, text: '时间' } },
-                        y: {
-                            title: { display: true, text: yAxisTitle },
-                            beginAtZero: !isPriceChart ? false : undefined,
-                             ticks: {
-                                callback: function(value) {
-                                    return isPriceChart ? formatPrice(String(value)) : parseFloat(value).toFixed(2) + '%';
-                                }
-                            }
-                        }
-                    },
-                    plugins: {
-                        legend: { position: 'top' }, title: { display: true, text: chartTitleText },
-                        tooltip: {
-                            mode: 'index', intersect: false,
-                            callbacks: {
-                                label: function(context) {
-                                    let label = context.dataset.label || '';
-                                    if (label) label += ': ';
-                                    if (context.parsed.y !== null) {
-                                        label += isPriceChart ? formatPrice(String(context.parsed.y)) : parseFloat(context.parsed.y).toFixed(4) + '%';
-                                    }
-                                    return label;
-                                }
-                            }
-                        },
-                        zoom: {
-                            pan: { enabled: true, mode: 'x', threshold: 5,
-                                onPanComplete({chart}) {
-                                    if (chartIdentifier === 'price' && diffChartInstance) syncXAxes(chart, diffChartInstance);
-                                    else if (chartIdentifier === 'diff' && priceChartInstance) syncXAxes(chart, priceChartInstance);
-                                }
-                            },
-                            zoom: { wheel: { enabled: true, speed: 0.1 }, pinch: { enabled: true },
-                                drag: { enabled: true, backgroundColor: 'rgba(0,123,255,0.25)'}, mode: 'x',
-                                onZoomComplete({chart}) {
-                                    if (chartIdentifier === 'price' && diffChartInstance) syncXAxes(chart, diffChartInstance);
-                                    else if (chartIdentifier === 'diff' && priceChartInstance) syncXAxes(chart, priceChartInstance);
-                                }
-                            }
-                        }
-                    },
-                    elements: { point:{ radius: 1.5, hoverRadius: 3 } }
-                }
-            });
-        }
-
-        function updateChartData(chartInstance, newLabels, newDatasetsDataArray) {
-            if (!chartInstance) return;
-            const currentXMin = chartInstance.scales.x.min;
-            const currentXMax = chartInstance.scales.x.max;
-            let isZoomed = (currentXMin !== undefined && currentXMax !== undefined);
-             if (isZoomed && chartInstance.data.labels.length > 0) {
-                 isZoomed = (currentXMin !== chartInstance.data.labels[0] || currentXMax !== chartInstance.data.labels[chartInstance.data.labels.length - 1]);
-            }
-
-            chartInstance.data.labels = newLabels;
-            newDatasetsDataArray.forEach((datasetData, index) => {
-                if(chartInstance.data.datasets[index]) {
-                    chartInstance.data.datasets[index].data = datasetData;
-                }
-            });
-
-            if (!isPaused) {
-                chartInstance.options.scales.x.min = undefined;
-                chartInstance.options.scales.x.max = undefined;
-            } else {
-                if(isZoomed) {
-                    chartInstance.options.scales.x.min = currentXMin;
-                    chartInstance.options.scales.x.max = currentXMax;
-                } else {
-                    chartInstance.options.scales.x.min = undefined;
-                    chartInstance.options.scales.x.max = undefined;
-                }
-            }
-            chartInstance.update('none'); // Use 'none' for no animation for faster update
-        }
-
-        function updateDisplayAndCharts() {
-            fetch('/data')
-                .then(response => response.json())
-                .then(data => {
-                    const current = data.current;
-                    currentGateSpotPair = current.config_gate_spot_pair || currentGateSpotPair;
-                    currentGateFuturesContract = current.config_gate_futures_contract || currentGateFuturesContract;
-                    currentAssetSymbol = current.config_target_asset_symbol || currentAssetSymbol;
-
-                    document.getElementById('main-title').textContent = `${currentAssetSymbol}/USDT 多平台价格监控`;
-                    document.getElementById('gate-spot-label').textContent = `Gate.io 现货 (${currentGateSpotPair})`;
-                    document.getElementById('gate-futures-label').textContent = `Gate.io 期货 (${currentGateFuturesContract})`;
-                    document.getElementById('diff-label-oo-spot').textContent = `OO vs Gate.io 现货 (${currentGateSpotPair})`;
-                    document.getElementById('diff-label-oo-futures').textContent = `OO vs Gate.io 期货 (${currentGateFuturesContract})`;
-                    document.getElementById('diff-label-spot-futures').textContent = `Gate.io 现货 (${currentGateSpotPair}) vs 期货 (${currentGateFuturesContract})`;
-
-                    const priceChartTitle = `${currentAssetSymbol}/USDT 价格历史`;
-                    const priceChartYLabel = `价格 (${currentAssetSymbol}/USDT)`;
-                    const diffChartTitleText = "价差百分比历史";
-
-                    if (priceChartInstance) {
-                        priceChartInstance.options.plugins.title.text = priceChartTitle;
-                        priceChartInstance.options.scales.y.title.text = priceChartYLabel;
-                    }
-                     if (diffChartInstance) {
-                        diffChartInstance.options.plugins.title.text = diffChartTitleText;
-                    }
-
-                    document.getElementById('oo-price').textContent = formatPrice(current.oo_price);
-                    document.getElementById('gate-spot-price').textContent = formatPrice(current.gate_spot_price);
-                    document.getElementById('gate-futures-price').textContent = formatPrice(current.gate_futures_price);
-
-                    document.getElementById('oo-status').textContent = current.oo_error ? current.oo_error : '正常';
-                    document.getElementById('gate-spot-status').textContent = current.gate_spot_error ? current.gate_spot_error : '正常';
-                    document.getElementById('gate-futures-status').textContent = current.gate_futures_error ? current.gate_futures_error : '正常';
-                    document.getElementById('oo-status').className = current.oo_error ? 'status-cell error-message' : 'status-cell';
-                    document.getElementById('gate-spot-status').className = current.gate_spot_error ? 'status-cell error-message' : 'status-cell';
-                    document.getElementById('gate-futures-status').className = current.gate_futures_error ? 'status-cell error-message' : 'status-cell';
-
-                    const diffOOSpotEl = document.getElementById('diff-oo-vs-spot');
-                    const diffOOFuturesEl = document.getElementById('diff-oo-vs-futures');
-                    const diffSpotFuturesEl = document.getElementById('diff-spot-vs-futures');
-                    diffOOSpotEl.textContent = formatPercentage(current.diff_oo_vs_spot_percentage);
-                    diffOOFuturesEl.textContent = formatPercentage(current.diff_oo_vs_futures_percentage);
-                    diffSpotFuturesEl.textContent = formatPercentage(current.diff_spot_vs_futures_percentage);
-                    [diffOOSpotEl, diffOOFuturesEl, diffSpotFuturesEl].forEach(el => {
-                        const valStr = el.textContent.replace('%','').replace('+','');
-                        const val = parseFloat(valStr);
-                        if (!isNaN(val)) { el.className = val > 0 ? 'price-up' : (val < 0 ? 'price-down' : ''); }
-                        else { el.className = ''; }
-                    });
-
-                    document.getElementById('last-updated').textContent = current.last_updated || "N/A";
-
-                    const history = data.history;
-
-                    const priceCtx = document.getElementById('priceHistoryChart').getContext('2d');
-                    const priceDatasets = [
-                        { label: 'OpenOcean', data: history.prices.oo, borderColor: 'rgb(75, 192, 192)', tension: 0.1, fill: false, borderWidth: 1.5 },
-                        { label: `Gate Spot (${currentGateSpotPair})`, data: history.prices.spot, borderColor: 'rgb(255, 99, 132)', tension: 0.1, fill: false, borderWidth: 1.5 },
-                        { label: `Gate Futures (${currentGateFuturesContract})`, data: history.prices.futures, borderColor: 'rgb(54, 162, 235)', tension: 0.1, fill: false, borderWidth: 1.5 }
-                    ];
-                    if (!priceChartInstance) {
-                        priceChartInstance = initializeChart(priceCtx, 'price', priceDatasets, history.prices.labels, priceChartYLabel, priceChartTitle, true);
-                    } else {
-                        priceChartInstance.data.datasets[1].label = `Gate Spot (${currentGateSpotPair})`;
-                        priceChartInstance.data.datasets[2].label = `Gate Futures (${currentGateFuturesContract})`;
-                        updateChartData(priceChartInstance, history.prices.labels, [history.prices.oo, history.prices.spot, history.prices.futures]);
-                    }
-
-                    const diffCtx = document.getElementById('diffPercentageChart').getContext('2d');
-                    const diffDatasets = [
-                        { label: `OO vs Spot (${currentGateSpotPair})`, data: history.diffs.oo_vs_spot, borderColor: 'rgb(255, 159, 64)', tension: 0.1, fill: false, borderWidth: 1.5 },
-                        { label: `OO vs Futures (${currentGateFuturesContract})`, data: history.diffs.oo_vs_futures, borderColor: 'rgb(153, 102, 255)', tension: 0.1, fill: false, borderWidth: 1.5 },
-                        { label: `Spot (${currentGateSpotPair}) vs Futures (${currentGateFuturesContract})`, data: history.diffs.spot_vs_futures, borderColor: 'rgb(75, 192, 75)', tension: 0.1, fill: false, borderWidth: 1.5 }
-                    ];
-                    if (!diffChartInstance) {
-                        diffChartInstance = initializeChart(diffCtx, 'diff', diffDatasets, history.diffs.labels, '价差 (%)', diffChartTitleText, false);
-                    } else {
-                        diffChartInstance.data.datasets[0].label = `OO vs Spot (${currentGateSpotPair})`;
-                        diffChartInstance.data.datasets[1].label = `OO vs Futures (${currentGateFuturesContract})`;
-                        diffChartInstance.data.datasets[2].label = `Spot (${currentGateSpotPair}) vs Futures (${currentGateFuturesContract})`;
-                        updateChartData(diffChartInstance, history.diffs.labels, [history.diffs.oo_vs_spot, history.diffs.oo_vs_futures, history.diffs.spot_vs_futures]);
-                    }
-                })
-                .catch(error => {
-                    console.error('Error fetching data for all platforms:', error);
-                });
-        }
-
-        function togglePauseResume() {
-            isPaused = !isPaused;
-            if (isPaused) {
-                clearInterval(dataUpdateIntervalID); dataUpdateIntervalID = null;
-                pauseResumeButton.textContent = '继续刷新'; pauseResumeButton.classList.add('pause-button-active');
-            } else {
-                pauseResumeButton.textContent = '暂停刷新'; pauseResumeButton.classList.remove('pause-button-active');
-                updateDisplayAndCharts(); // Refresh immediately on resume
-                dataUpdateIntervalID = setInterval(updateDisplayAndCharts, REFRESH_INTERVAL_MS);
-            }
-        }
-
-        function resetAllZooms() {
-            if (priceChartInstance) {
-                priceChartInstance.resetZoom(); priceChartInstance.options.scales.x.min = undefined; priceChartInstance.options.scales.x.max = undefined;
-            }
-            if (diffChartInstance) {
-                diffChartInstance.resetZoom(); diffChartInstance.options.scales.x.min = undefined; diffChartInstance.options.scales.x.max = undefined;
-            }
-            if(isPaused){ updateDisplayAndCharts(); } // If paused, manually trigger redraw
-            else { // If not paused, next interval will update, or force faster view update
-                if (priceChartInstance) priceChartInstance.update('none'); // 'none' to avoid animation interference
-                if (diffChartInstance) diffChartInstance.update('none');
-            }
-        }
-
-        pauseResumeButton.addEventListener('click', togglePauseResume);
-        document.getElementById('reset-price-zoom-button').addEventListener('click', resetAllZooms);
-        document.getElementById('reset-diff-zoom-button').addEventListener('click', resetAllZooms);
-
-        console.log("Frontend Initializing...");
-        console.log("Initial Config from Flask:", initialConfig);
-
-        updateDisplayAndCharts(); // Initial load
-        if (!isPaused) {
-            dataUpdateIntervalID = setInterval(updateDisplayAndCharts, REFRESH_INTERVAL_MS);
-        }
-    </script>
-</body>
-</html>

+ 254 - 0
templates/index_plotly.html

@@ -0,0 +1,254 @@
+<!DOCTYPE html>
+<html lang="zh-CN">
+<head>
+    <meta charset="UTF-8">
+    <meta name="viewport" content="width=device-width, initial-scale=1.0">
+    <title>多平台价格与价差监控 (Plotly)</title>
+    <script src='https://cdn.plot.ly/plotly-latest.min.js'></script>
+    <style>
+        body { font-family: Arial, sans-serif; margin: 20px; background-color: #f4f4f4; color: #333; }
+        .container { background-color: #fff; padding: 20px; border-radius: 8px; box-shadow: 0 0 10px rgba(0,0,0,0.1); margin-bottom: 20px; }
+        h1, h2 { text-align: center; color: #333; margin-top:10px; margin-bottom:15px; }
+        table { width: 100%; border-collapse: collapse; margin-top: 10px; margin-bottom: 15px; }
+        th, td { border: 1px solid #ddd; padding: 8px; text-align: left; font-size:0.85em; word-break: break-all;}
+        th { background-color: #e9e9e9; white-space: nowrap; }
+        .price-up { color: green; }
+        .price-down { color: red; }
+        .error-message { color: #c00; font-style: italic;}
+        .status-cell { min-width: 100px; }
+        .timestamp { font-size: 0.9em; color: #666; text-align: right; margin-top: 10px; }
+        .chart-container { width: 95%; height: 450px; margin: 20px auto; } /* Plotly chart div */
+        .controls-container { text-align: center; margin-bottom: 15px; margin-top: 5px; }
+        .control-button { background-color: #007bff; color: white; border: none; padding: 8px 15px; font-size: 14px; border-radius: 5px; cursor: pointer; margin:0 5px; }
+        .control-button:hover { background-color: #0056b3; }
+        .pause-button-active { background-color: #ffc107; color: #333; }
+        .platform-name {font-weight: bold;}
+        #main-title { font-size: 1.8em; }
+        h2 { font-size: 1.3em; }
+        .status-line { text-align: center; margin-top: 10px; font-style: italic; color: grey; }
+    </style>
+</head>
+<body>
+    <div class="container">
+        <h1 id="main-title">{{ target_asset }}/USDT 多平台价格监控</h1>
+        <div class="controls-container">
+            <button id="pause-resume-button" class="control-button">暂停刷新</button>
+        </div>
+        <table>
+            <thead>
+                <tr>
+                    <th>平台</th>
+                    <th>价格 (USDT)</th>
+                    <th class="status-cell">状态/错误</th>
+                </tr>
+            </thead>
+            <tbody>
+                <tr>
+                    <td class="platform-name">OpenOcean (BSC)</td>
+                    <td id="oo-price">加载中...</td>
+                    <td id="oo-status" class="status-cell"></td>
+                </tr>
+                <tr>
+                    <td class="platform-name" id="gate-spot-label">Gate.io 现货 ({{ spot_pair }})</td>
+                    <td id="gate-spot-price">加载中...</td>
+                    <td id="gate-spot-status" class="status-cell"></td>
+                </tr>
+                <tr>
+                    <td class="platform-name" id="gate-futures-label">Gate.io 期货 ({{ futures_contract }})</td>
+                    <td id="gate-futures-price">加载中...</td>
+                    <td id="gate-futures-status" class="status-cell"></td>
+                </tr>
+            </tbody>
+        </table>
+        <h2>价差百分比</h2>
+        <table>
+             <thead>
+                <tr>
+                    <th>对比</th>
+                    <th>价差 (%)</th>
+                </tr>
+            </thead>
+            <tbody>
+                <tr>
+                    <td id="diff-label-oo-spot">OO vs Gate.io 现货 ({{ spot_pair }})</td>
+                    <td id="diff-oo-vs-spot">计算中...</td>
+                </tr>
+                <tr>
+                    <td id="diff-label-oo-futures">OO vs Gate.io 期货 ({{ futures_contract }})</td>
+                    <td id="diff-oo-vs-futures">计算中...</td>
+                </tr>
+                <tr>
+                    <td id="diff-label-spot-futures">Gate.io 现货 ({{ spot_pair }}) vs 期货 ({{ futures_contract }})</td>
+                    <td id="diff-spot-vs-futures">计算中...</td>
+                </tr>
+            </tbody>
+        </table>
+        <div class="timestamp">最后更新: <span id="last-updated">N/A</span></div>
+    </div>
+
+    <div class="container">
+        <h2 id="price-chart-title">价格历史曲线</h2>
+        <div id='priceHistoryChartPlotly' class="chart-container"></div>
+        <div id="price-chart-status" class="status-line">加载价格图表...</div>
+    </div>
+
+    <div class="container">
+        <h2 id="diff-chart-title">价差百分比历史曲线</h2>
+        <div id='diffPercentageChartPlotly' class="chart-container"></div>
+        <div id="diff-chart-status" class="status-line">加载价差图表...</div>
+    </div>
+
+    <script type='text/javascript'>
+        const priceChartDiv = document.getElementById('priceHistoryChartPlotly');
+        const diffChartDiv = document.getElementById('diffPercentageChartPlotly');
+        const priceChartStatusDiv = document.getElementById('price-chart-status');
+        const diffChartStatusDiv = document.getElementById('diff-chart-status');
+        const pauseResumeButton = document.getElementById('pause-resume-button');
+
+        const refreshIntervalMs = {{ refresh_interval_ms }};
+        let dataUpdateIntervalID = null;
+        let isPaused = false;
+
+        let pricePlotInitialized = false;
+        let diffPlotInitialized = false;
+
+        // --- Helper function to format price (same as your Chart.js version) ---
+        function formatPriceForTable(priceStr) {
+            if (priceStr === null || priceStr === undefined || priceStr === "N/A" || String(priceStr).toLowerCase() === "n/a") return "N/A";
+            const price = parseFloat(priceStr);
+            if (isNaN(price)) return "N/A";
+            if (price === 0) return "0.00000000";
+            if (Math.abs(price) < 0.0000001 && price !== 0) return price.toExponential(3);
+            if (Math.abs(price) < 0.001) return price.toFixed(8);
+            if (Math.abs(price) < 1) return price.toFixed(6);
+            return price.toFixed(4);
+        }
+        function formatPercentageForTable(percStr) {
+            if (percStr === null || percStr === undefined || percStr === "N/A" || String(percStr).toLowerCase() === "n/a") return "N/A";
+            if (String(percStr).includes('%')) return percStr; // Already formatted
+            const perc = parseFloat(percStr);
+            if (isNaN(perc)) return "N/A";
+            return `${perc > 0 ? '+' : ''}${perc.toFixed(4)}%`;
+        }
+
+        async function updateTableData() {
+            if (isPaused) return; // Don't update table if paused (charts are also paused via their own update)
+            try {
+                const response = await fetch('/table-data');
+                if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
+                const data = await response.json();
+
+                document.getElementById('oo-price').textContent = formatPriceForTable(data.oo_price);
+                document.getElementById('gate-spot-price').textContent = formatPriceForTable(data.gate_spot_price);
+                document.getElementById('gate-futures-price').textContent = formatPriceForTable(data.gate_futures_price);
+
+                document.getElementById('oo-status').textContent = data.oo_error || '正常';
+                document.getElementById('gate-spot-status').textContent = data.gate_spot_error || '正常';
+                document.getElementById('gate-futures-status').textContent = data.gate_futures_error || '正常';
+                // Update error classes
+                document.getElementById('oo-status').className = data.oo_error ? 'status-cell error-message' : 'status-cell';
+                document.getElementById('gate-spot-status').className = data.gate_spot_error ? 'status-cell error-message' : 'status-cell';
+                document.getElementById('gate-futures-status').className = data.gate_futures_error ? 'status-cell error-message' : 'status-cell';
+
+                const diffOOSpotEl = document.getElementById('diff-oo-vs-spot');
+                const diffOOFuturesEl = document.getElementById('diff-oo-vs-futures');
+                const diffSpotFuturesEl = document.getElementById('diff-spot-vs-futures');
+
+                diffOOSpotEl.textContent = formatPercentageForTable(data.diff_oo_vs_spot_percentage);
+                diffOOFuturesEl.textContent = formatPercentageForTable(data.diff_oo_vs_futures_percentage);
+                diffSpotFuturesEl.textContent = formatPercentageForTable(data.diff_spot_vs_futures_percentage);
+
+                [diffOOSpotEl, diffOOFuturesEl, diffSpotFuturesEl].forEach(el => {
+                    const valStr = el.textContent.replace('%','').replace('+','');
+                    const val = parseFloat(valStr);
+                    if (!isNaN(val)) { el.className = val > 0 ? 'price-up' : (val < 0 ? 'price-down' : ''); }
+                     else { el.className = ''; }
+                });
+
+                document.getElementById('last-updated').textContent = data.last_updated || "N/A";
+
+            } catch (error) {
+                console.error('Error fetching table data:', error);
+                // Optionally update status for table data errors
+            }
+        }
+
+        async function updatePlotlyCharts() {
+            if(isPaused && (pricePlotInitialized || diffPlotInitialized) ) return; // Don't fetch new chart data if paused and already drawn once
+
+            try {
+                const response = await fetch('/plotly-chart-data');
+                if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
+
+                const chartData = await response.json(); // This now contains { price_chart: ..., diff_chart: ... }
+
+                if (chartData.price_chart && chartData.price_chart.data && chartData.price_chart.layout) {
+                    Plotly.react(priceChartDiv, chartData.price_chart.data, chartData.price_chart.layout, {responsive: true});
+                    if (!pricePlotInitialized) {
+                        console.log("Price chart initialized with Plotly.");
+                        pricePlotInitialized = true;
+                    }
+                    priceChartStatusDiv.textContent = `价格图表更新于: ${new Date().toLocaleTimeString()}`;
+                } else {
+                    console.error("Received invalid price_chart data structure");
+                    priceChartStatusDiv.textContent = "错误: 价格图表数据无效。";
+                }
+
+                if (chartData.diff_chart && chartData.diff_chart.data && chartData.diff_chart.layout) {
+                    Plotly.react(diffChartDiv, chartData.diff_chart.data, chartData.diff_chart.layout, {responsive: true});
+                     if (!diffPlotInitialized) {
+                        console.log("Diff chart initialized with Plotly.");
+                        diffPlotInitialized = true;
+                    }
+                    diffChartStatusDiv.textContent = `价差图表更新于: ${new Date().toLocaleTimeString()}`;
+                } else {
+                    console.error("Received invalid diff_chart data structure");
+                    diffChartStatusDiv.textContent = "错误: 价差图表数据无效。";
+                }
+
+            } catch (error) {
+                console.error('Error fetching or plotting Plotly data:', error);
+                priceChartStatusDiv.textContent = `图表更新错误: ${error.message}`;
+                diffChartStatusDiv.textContent = `图表更新错误: ${error.message}`;
+            }
+        }
+
+        function togglePauseResume() {
+            isPaused = !isPaused;
+            if (isPaused) {
+                if (dataUpdateIntervalID) clearInterval(dataUpdateIntervalID);
+                dataUpdateIntervalID = null;
+                pauseResumeButton.textContent = '继续刷新';
+                pauseResumeButton.classList.add('pause-button-active');
+                console.log("Data refresh PAUSED");
+            } else {
+                pauseResumeButton.textContent = '暂停刷新';
+                pauseResumeButton.classList.remove('pause-button-active');
+                // Refresh immediately on resume
+                updateTableData();
+                updatePlotlyCharts();
+                dataUpdateIntervalID = setInterval(() => {
+                    updateTableData();
+                    updatePlotlyCharts();
+                }, refreshIntervalMs);
+                console.log("Data refresh RESUMED");
+            }
+        }
+
+        pauseResumeButton.addEventListener('click', togglePauseResume);
+
+        // Initial load
+        console.log("Page loaded. Initializing data and charts...");
+        updateTableData();
+        updatePlotlyCharts();
+
+        if (!isPaused) {
+            dataUpdateIntervalID = setInterval(() => {
+                updateTableData();
+                updatePlotlyCharts();
+            }, refreshIntervalMs);
+        }
+
+    </script>
+</body>
+</html>