From c17539a7a5c7c4361adc874fc5b08f9cd3f81af3 Mon Sep 17 00:00:00 2001 From: jackyu66git Date: Sun, 5 Apr 2026 18:24:08 +0800 Subject: [PATCH] =?UTF-8?q?web=E7=AB=AF=E8=BF=9B=E8=A1=8C=E4=BC=98?= =?UTF-8?q?=E5=8C=96=EF=BC=8C=E5=87=8F=E5=B0=91=E5=86=85=E5=AD=98=E5=BC=80?= =?UTF-8?q?=E9=94=80=EF=BC=8Cdata=20provider=E6=8F=90=E4=BE=9Bwebsocket?= =?UTF-8?q?=E6=9C=8D=E5=8A=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- data_provider/main.py | 191 ++++++++++++++++++++- web/templates/index.html | 361 ++++++++++++--------------------------- 2 files changed, 297 insertions(+), 255 deletions(-) diff --git a/data_provider/main.py b/data_provider/main.py index c5200e0..7392878 100644 --- a/data_provider/main.py +++ b/data_provider/main.py @@ -17,7 +17,7 @@ from typing import Dict, Iterable, List, Optional import ccxt # type: ignore import pandas as pd # type: ignore -from fastapi import FastAPI, HTTPException, Query +from fastapi import FastAPI, HTTPException, Query, WebSocket, WebSocketDisconnect from fastapi.middleware.cors import CORSMiddleware import uvicorn from technical.util import resample_to_interval @@ -47,6 +47,7 @@ DEFAULT_LIMIT = 500 RECENT_CANDLE_LIMIT = 10 RECENT_FETCH_INTERVAL = 5 # 后台刷新循环休眠秒数 PERSIST_INTERVAL = 600 # 全量落盘周期(秒) +WS_UPDATE_CANDLE_COUNT = 2 # WebSocket 增量推送最近 K 线根数 logger = logging.getLogger("data_provider") @@ -122,6 +123,82 @@ def timeframe_to_minutes(tf: str) -> Optional[int]: return value * multiplier +class WebSocketManager: + """管理 WebSocket 连接及订阅,线程安全地广播 K 线更新。""" + + def __init__(self) -> None: + self._subscriptions: Dict[tuple, set] = {} + self._async_lock = asyncio.Lock() + self._loop: Optional[asyncio.AbstractEventLoop] = None + + def set_loop(self, loop: asyncio.AbstractEventLoop) -> None: + self._loop = loop + + async def connect(self, ws: WebSocket) -> None: + await ws.accept() + logger.info("WebSocket 客户端已连接") + + async def disconnect(self, ws: WebSocket) -> None: + async with self._async_lock: + for key in list(self._subscriptions): + self._subscriptions[key].discard(ws) + if not self._subscriptions[key]: + del self._subscriptions[key] + logger.info("WebSocket 客户端已断开") + + async def subscribe(self, ws: WebSocket, symbol: str, timeframe: str) -> None: + key = (symbol, timeframe) + async with self._async_lock: + self._subscriptions.setdefault(key, set()).add(ws) + logger.info("WebSocket 订阅: %s %s", symbol, timeframe) + + async def unsubscribe(self, ws: WebSocket, symbol: str, timeframe: str) -> None: + key = (symbol, timeframe) + async with self._async_lock: + if key in self._subscriptions: + self._subscriptions[key].discard(ws) + if not self._subscriptions[key]: + del self._subscriptions[key] + + def has_subscribers(self, symbol: str, timeframe: str) -> bool: + """非异步快速检查(供同步线程调用)。""" + return bool(self._subscriptions.get((symbol, timeframe))) + + async def broadcast( + self, symbol: str, timeframe: str, candles: List[Dict], msg_type: str = "kline", + ) -> None: + key = (symbol, timeframe) + async with self._async_lock: + subscribers = list(self._subscriptions.get(key, set())) + if not subscribers: + return + message = json.dumps( + {"type": msg_type, "symbol": symbol, "timeframe": timeframe, "data": candles}, + ensure_ascii=False, + ) + dead: list = [] + for ws in subscribers: + try: + await ws.send_text(message) + except Exception: + dead.append(ws) + if dead: + async with self._async_lock: + for ws in dead: + self._subscriptions.get(key, set()).discard(ws) + + def broadcast_from_thread( + self, symbol: str, timeframe: str, candles: List[Dict], msg_type: str = "kline", + ) -> None: + """供同步后台线程调用,将广播提交到 asyncio 事件循环。""" + if self._loop is None or self._loop.is_closed(): + return + asyncio.run_coroutine_threadsafe( + self.broadcast(symbol, timeframe, candles, msg_type), + self._loop, + ) + + class DataProvider: """封装交易所连接、本地 CSV、内存缓存、断线恢复与衍生周期聚合。""" @@ -165,6 +242,7 @@ class DataProvider: self._resume_file: Path = self.data_dir / "resume_since.json" # 尝试加载历史恢复点 self._load_resume_since() + self._update_callbacks: List = [] def _load_config(self) -> Dict[str, object]: """读取 JSON 配置文件。""" @@ -458,6 +536,18 @@ class DataProvider: # 同步写盘 self._save_resume_since() + def on_update(self, callback) -> None: + """注册数据更新回调(签名: callback(symbol, timeframe))。""" + self._update_callbacks.append(callback) + + def _notify_update(self, symbol: str, timeframe: str) -> None: + """通知所有回调:某 symbol/timeframe 数据已更新。""" + for cb in self._update_callbacks: + try: + cb(symbol, timeframe) + except Exception as exc: + logger.error("数据更新回调异常: %s", exc) + def start_background_workers(self) -> None: """启动增量刷新线程与周期性落盘线程。""" if self._fetch_thread and self._fetch_thread.is_alive(): @@ -499,6 +589,7 @@ class DataProvider: current = self.data.setdefault(symbol, {}).get(timeframe, []) merged = self._merge_candles(timeframe, current, history) self.data[symbol][timeframe] = merged + self._notify_update(symbol, timeframe) self._clear_resume_since(symbol, timeframe) else: # 正常增量获取最近若干根K线 @@ -513,6 +604,7 @@ class DataProvider: current = self.data.setdefault(symbol, {}).get(timeframe, []) merged = self._merge_candles(timeframe, current, candles) self.data[symbol][timeframe] = merged + self._notify_update(symbol, timeframe) except ccxt.BaseError as exc: logger.error("更新最新K线失败 (%s %s): %s", symbol, timeframe, exc) # 记录应当从何时恢复拉取,避免重连后从当前时间开始导致丢K @@ -664,11 +756,38 @@ class DataProvider: def create_app(provider: DataProvider) -> FastAPI: - """构造 FastAPI 应用:lifespan 内同步 initialize 并启动后台拉数。""" + """构造 FastAPI 应用:lifespan 内同步 initialize 并启动后台拉数;WebSocket 实时推送。""" + ws_manager = WebSocketManager() + + def _on_data_update(symbol: str, base_tf: str) -> None: + """后台刷新线程回调:广播基础及衍生周期更新给 WebSocket 订阅者。""" + with provider._lock: + base_data = list(provider.data.get(symbol, {}).get(base_tf, [])) + recent = base_data[-WS_UPDATE_CANDLE_COUNT:] if base_data else [] + if recent: + ws_manager.broadcast_from_thread(symbol, base_tf, recent) + for derived_tf, src_base in provider.derived_map.items(): + if src_base != base_tf or not ws_manager.has_subscribers(symbol, derived_tf): + continue + try: + target_min = timeframe_to_minutes(derived_tf) + if target_min is None: + continue + now_ms = int(time.time() * 1000) + window_ms = target_min * 60_000 * (WS_UPDATE_CANDLE_COUNT + 2) + derived = provider.get_klines( + symbol, derived_tf, start_time=now_ms - window_ms, limit=WS_UPDATE_CANDLE_COUNT, + ) + if derived: + ws_manager.broadcast_from_thread(symbol, derived_tf, derived) + except Exception as exc: + logger.debug("衍生周期广播失败 %s %s: %s", symbol, derived_tf, exc) @asynccontextmanager async def lifespan(app: FastAPI): loop = asyncio.get_running_loop() + ws_manager.set_loop(loop) + provider.on_update(_on_data_update) await loop.run_in_executor(None, provider.initialize) provider.start_background_workers() try: @@ -734,6 +853,74 @@ def create_app(provider: DataProvider) -> FastAPI: "ready": provider.is_ready(), } + @app.websocket("/ws") + async def websocket_endpoint(ws: WebSocket): + """WebSocket 实时 K 线推送。 + + 客户端发送 JSON: + {"action": "subscribe", "symbol": "BTC/USDT:USDT", "timeframe": "1m"} + {"action": "unsubscribe", "symbol": "BTC/USDT:USDT", "timeframe": "1m"} + {"action": "ping"} + 服务端推送: + {"type": "subscribed", "symbol": "...", "timeframe": "..."} + {"type": "snapshot", "symbol": "...", "timeframe": "...", "data": [...]} + {"type": "kline", "symbol": "...", "timeframe": "...", "data": [...]} + {"type": "pong"} + {"type": "error", "message": "..."} + """ + await ws_manager.connect(ws) + try: + while True: + raw = await ws.receive_text() + try: + msg = json.loads(raw) + except json.JSONDecodeError: + await ws.send_text(json.dumps({"type": "error", "message": "invalid JSON"})) + continue + + action = msg.get("action", "") + symbol = str(msg.get("symbol", "")).strip() + timeframe = str(msg.get("timeframe", "")).strip() + + if action == "ping": + await ws.send_text(json.dumps({"type": "pong"})) + + elif action == "subscribe": + if not symbol or not timeframe: + await ws.send_text(json.dumps( + {"type": "error", "message": "需要 symbol 和 timeframe 字段"} + )) + continue + await ws_manager.subscribe(ws, symbol, timeframe) + await ws.send_text(json.dumps( + {"type": "subscribed", "symbol": symbol, "timeframe": timeframe}, + ensure_ascii=False, + )) + try: + snapshot = provider.get_klines(symbol, timeframe, limit=DEFAULT_LIMIT) + if snapshot: + await ws.send_text(json.dumps( + {"type": "snapshot", "symbol": symbol, "timeframe": timeframe, "data": snapshot}, + ensure_ascii=False, + )) + except Exception as exc: + await ws.send_text(json.dumps({"type": "error", "message": str(exc)})) + + elif action == "unsubscribe": + await ws_manager.unsubscribe(ws, symbol, timeframe) + await ws.send_text(json.dumps( + {"type": "unsubscribed", "symbol": symbol, "timeframe": timeframe}, + ensure_ascii=False, + )) + + else: + await ws.send_text(json.dumps({"type": "error", "message": f"未知 action: {action}"})) + + except WebSocketDisconnect: + pass + finally: + await ws_manager.disconnect(ws) + return app diff --git a/web/templates/index.html b/web/templates/index.html index 4646ee0..060a485 100644 --- a/web/templates/index.html +++ b/web/templates/index.html @@ -1173,9 +1173,43 @@ // 不在前端截断数据,保留完整历史,避免K线数量限制 function trimDataInPlace(payload, maxLen = 2000) { if (!payload || typeof payload !== 'object') return; - // 仅清理可能保留的旧快照引用 delete payload.original_kline_data; delete payload.original_macd; + + // 截断大型数组,只保留最新 maxLen 条数据 + const arrayKeys = [ + 'kline_data', 'element_kline_data', 'sub_sub_kline_data', + 'bi_list', 'element_bi_list', 'sub_sub_bi_list', + 'seg_list', 'element_seg_list', 'sub_sub_seg_list', + 'zs_list', 'element_zs_list', 'sub_sub_zs_list', + 'uncompleted_bi_list', 'uncompleted_seg_list', 'uncompleted_zs_list', + 'element_uncompleted_bi_list', 'element_uncompleted_seg_list', 'element_uncompleted_zs_list', + 'bsp_list', 'element_bsp_list', 'sub_sub_bsp_list', + 'trade_points', 'element_trade_points' + ]; + for (const key of arrayKeys) { + if (Array.isArray(payload[key]) && payload[key].length > maxLen) { + payload[key] = payload[key].slice(-maxLen); + } + } + // 截断 MACD 子数组 + const macdKeys = ['macd', 'element_macd', 'sub_sub_macd']; + for (const mk of macdKeys) { + const m = payload[mk]; + if (m && typeof m === 'object') { + for (const sub of ['macd', 'signal', 'histogram']) { + if (Array.isArray(m[sub]) && m[sub].length > maxLen) { + m[sub] = m[sub].slice(-maxLen); + } + } + } + } + // 截断 ATR 数组 + for (const ak of ['atr', 'element_atr', 'sub_sub_atr']) { + if (Array.isArray(payload[ak]) && payload[ak].length > maxLen) { + payload[ak] = payload[ak].slice(-maxLen); + } + } } let trendDetailTable = null; let trendChart = null; @@ -2120,15 +2154,6 @@ } currentData = data; - // 检查和记录服务器返回的时区 - console.log('服务器返回的时区:', data.timezone || '未指定'); - // BI中枢调试输出 - console.log('主周期BI中枢(完成):', Array.isArray(data.bi_zs_list) ? data.bi_zs_list.length : 0); - console.log('主周期BI中枢(未完成):', Array.isArray(data.uncompleted_bi_zs_list) ? data.uncompleted_bi_zs_list.length : 0); - console.log('次周期BI中枢(完成):', Array.isArray(data.element_bi_zs_list) ? data.element_bi_zs_list.length : 0); - console.log('次周期BI中枢(未完成):', Array.isArray(data.element_uncompleted_bi_zs_list) ? data.element_uncompleted_bi_zs_list.length : 0); - - // 刷新图表 refreshChart(data); }, error: function(jqXHR, textStatus, errorThrown) { @@ -7010,7 +7035,12 @@ } } function bindSyncEvents(mainChartContainer, volumeChartContainer, atrChartContainer, macdChartContainer, chanMacdChartContainer, mainChart, volumeChart, atrChart, macdChart, chanMacdChart, showMacd) { - // 防止同步过程中的无限循环 + // 清理上一轮绑定的事件监听器,防止累积 + if (window._bindSyncCleanups) { + window._bindSyncCleanups.forEach(fn => { try { fn(); } catch(e) {} }); + } + window._bindSyncCleanups = []; + let syncInProgress = false; // 用于跟踪所有图表的拖动状态 - 在函数内部定义以确保作用域正确 @@ -7024,161 +7054,70 @@ // 同步图表的时间范围 function syncCharts(sourceChart, sourceContainer) { - // 防止无限循环 - 使用更精确的检查 - if (syncInProgress) { - console.log('🔄 同步正在进行中,跳过此次同步'); - return; - } + if (syncInProgress) return; syncInProgress = true; - console.log('🚀 开始同步图表,来源:', - sourceChart === mainChart ? '主图' : - sourceChart === volumeChart ? '成交量图' : - sourceChart === atrChart ? 'ATR图' : 'MACD图'); try { if (sourceChart && sourceChart.timeScale) { const logicalRange = sourceChart.timeScale().getVisibleLogicalRange(); if (logicalRange && logicalRange.from !== undefined && logicalRange.to !== undefined) { - console.log('📊 同步时间范围:', logicalRange); - - // 同步主图 if (sourceChart !== mainChart && mainChart && mainChart.timeScale) { - try { - mainChart.timeScale().setVisibleLogicalRange(logicalRange); - console.log('✅ 主图同步完成'); - } catch (e) { - console.error('❌ 主图同步失败:', e); - } + try { mainChart.timeScale().setVisibleLogicalRange(logicalRange); } catch (e) {} } - - // 同步成交量图 if (sourceChart !== volumeChart && volumeChart && volumeChart.timeScale) { - try { - volumeChart.timeScale().setVisibleLogicalRange(logicalRange); - console.log('✅ 成交量图同步完成'); - } catch (e) { - console.error('❌ 成交量图同步失败:', e); - } + try { volumeChart.timeScale().setVisibleLogicalRange(logicalRange); } catch (e) {} } - - // 同步ATR图 if (sourceChart !== atrChart && atrChart && atrChart.timeScale) { - try { - atrChart.timeScale().setVisibleLogicalRange(logicalRange); - console.log('✅ ATR图同步完成'); - } catch (e) { - console.error('❌ ATR图同步失败:', e); - } + try { atrChart.timeScale().setVisibleLogicalRange(logicalRange); } catch (e) {} } - - // 同步MACD图 if (showMacd && macdChart && sourceChart !== macdChart && macdChart.timeScale) { - try { - macdChart.timeScale().setVisibleLogicalRange(logicalRange); - console.log('✅ MACD图同步完成'); - } catch (e) { - console.error('❌ MACD图同步失败:', e); - } + try { macdChart.timeScale().setVisibleLogicalRange(logicalRange); } catch (e) {} } - - // 同步ChanMACD图 if (showMacd && chanMacdChart && sourceChart !== chanMacdChart && chanMacdChart.timeScale) { - try { - chanMacdChart.timeScale().setVisibleLogicalRange(logicalRange); - console.log('✅ ChanMACD图同步完成'); - } catch (e) { - console.error('❌ ChanMACD图同步失败:', e); - } + try { chanMacdChart.timeScale().setVisibleLogicalRange(logicalRange); } catch (e) {} } - // 保存当前的可见范围到全局状态 if (tvWidget && tvWidget.state) { tvWidget.state.logicalRange = logicalRange; - // 同时保存可见范围以确保精确对齐 - try { - const visibleRange = sourceChart.timeScale().getVisibleRange(); - tvWidget.state.visibleRange = visibleRange; - console.log('💾 保存状态 - 逻辑范围:', logicalRange, '可见范围:', visibleRange); - } catch (e) { - console.warn('⚠️ 保存可见范围失败:', e); - } + try { tvWidget.state.visibleRange = sourceChart.timeScale().getVisibleRange(); } catch (e) {} } - } else { - console.warn('⚠️ 无效的逻辑范围:', logicalRange); } - } else { - console.warn('⚠️ 无效的源图表或时间刻度'); } } catch (e) { - console.error('💥 同步图表出错:', e); + console.error('同步图表出错:', e); } - // 立即重置同步标志,提高响应速度 - setTimeout(() => { - syncInProgress = false; - console.log('🔓 同步标志已重置'); - }, 1); + setTimeout(() => { syncInProgress = false; }, 1); } // 为每个图表添加事件监听 const addChartSyncEvents = (chartContainer, chart) => { - console.log('为图表添加同步事件监听:', - chart === mainChart ? '主图' : - chart === volumeChart ? '成交量图' : - chart === atrChart ? 'ATR图' : - chart === macdChart ? 'MACD图' : - chart === chanMacdChart ? 'ChanMACD图' : '未知图表'); - - // 确定当前图表类型 const chartType = chart === mainChart ? 'main' : chart === volumeChart ? 'volume' : chart === atrChart ? 'atr' : chart === macdChart ? 'macd' : chart === chanMacdChart ? 'chanmacd' : 'unknown'; - // 使用LightweightCharts内置的时间范围变化事件(这是最可靠的方法) - chart.timeScale().subscribeVisibleTimeRangeChange(() => { - // 使用图表特定的同步标志防止递归 + const timeRangeHandler = () => { if (!syncInProgress) { - console.log('✅ 检测到时间范围变化,触发同步:', chartType, '当前范围:', chart.timeScale().getVisibleLogicalRange()); syncCharts(chart, chartContainer); - } else { - console.log('⏸️ 同步进行中,跳过时间范围变化事件:', chartType); } + }; + chart.timeScale().subscribeVisibleTimeRangeChange(timeRangeHandler); + window._bindSyncCleanups.push(() => { + try { chart.timeScale().unsubscribeVisibleTimeRangeChange(timeRangeHandler); } catch(e) {} }); - // 备用的DOM事件监听(用于调试和额外保障) let isScrolling = false; - // 鼠标按下事件 - chartContainer.addEventListener('mousedown', (e) => { - localDragStates[chartType] = true; - console.log('鼠标按下开始拖动:', chartType); - }); - - // 鼠标抬起事件 - chartContainer.addEventListener('mouseup', (e) => { - if (localDragStates[chartType]) { - localDragStates[chartType] = false; - console.log('鼠标抬起,结束拖动:', chartType); - } - }); - - // 鼠标离开事件 - chartContainer.addEventListener('mouseleave', (e) => { - if (localDragStates[chartType]) { - localDragStates[chartType] = false; - console.log('鼠标离开容器,结束拖动:', chartType); - } - }); - - // 滚轮缩放事件(保持原有逻辑) - chartContainer.addEventListener('wheel', (e) => { + const mousedownHandler = () => { localDragStates[chartType] = true; }; + const mouseupHandler = () => { localDragStates[chartType] = false; }; + const mouseleaveHandler = () => { localDragStates[chartType] = false; }; + const wheelHandler = () => { if (!isScrolling) { isScrolling = true; - console.log('滚轮缩放:', chartType); setTimeout(() => { if (!syncInProgress) { syncCharts(chart, chartContainer); @@ -7186,6 +7125,17 @@ isScrolling = false; }, 50); } + }; + + chartContainer.addEventListener('mousedown', mousedownHandler); + chartContainer.addEventListener('mouseup', mouseupHandler); + chartContainer.addEventListener('mouseleave', mouseleaveHandler); + chartContainer.addEventListener('wheel', wheelHandler); + window._bindSyncCleanups.push(() => { + chartContainer.removeEventListener('mousedown', mousedownHandler); + chartContainer.removeEventListener('mouseup', mouseupHandler); + chartContainer.removeEventListener('mouseleave', mouseleaveHandler); + chartContainer.removeEventListener('wheel', wheelHandler); }); }; @@ -7206,58 +7156,35 @@ addChartSyncEvents(chanMacdChartContainer, chanMacdChart); } - // 窗口大小变化时重绘图表 - window.addEventListener('resize', () => { - // 调整主图大小 + // 窗口大小变化时重绘图表 — 使用可清理的方式注册 + const resizeHandler = () => { if (mainChart && mainChartContainer) { - mainChart.applyOptions({ - width: mainChartContainer.clientWidth, - height: mainChartContainer.clientHeight - }); + mainChart.applyOptions({ width: mainChartContainer.clientWidth, height: mainChartContainer.clientHeight }); } - - // 调整成交量图大小 if (volumeChart && volumeChartContainer) { - volumeChart.applyOptions({ - width: volumeChartContainer.clientWidth, - height: volumeChartContainer.clientHeight - }); + volumeChart.applyOptions({ width: volumeChartContainer.clientWidth, height: volumeChartContainer.clientHeight }); } - - // 调整ATR图大小 if (atrChart && atrChartContainer) { - atrChart.applyOptions({ - width: atrChartContainer.clientWidth, - height: atrChartContainer.clientHeight - }); + atrChart.applyOptions({ width: atrChartContainer.clientWidth, height: atrChartContainer.clientHeight }); } - - // 调整MACD图大小 if (showMacd && macdChart && macdChartContainer) { - macdChart.applyOptions({ - width: macdChartContainer.clientWidth, - height: macdChartContainer.clientHeight - }); + macdChart.applyOptions({ width: macdChartContainer.clientWidth, height: macdChartContainer.clientHeight }); } - - // 调整ChanMACD图大小 if (showMacd && chanMacdChart && chanMacdChartContainer) { - chanMacdChart.applyOptions({ - width: chanMacdChartContainer.clientWidth, - height: chanMacdChartContainer.clientHeight - }); + chanMacdChart.applyOptions({ width: chanMacdChartContainer.clientWidth, height: chanMacdChartContainer.clientHeight }); } - - // 重新同步 - 使用主图作为同步源 - setTimeout(() => { - if (mainChart) { - syncCharts(mainChart, mainChartContainer); - } - }, 200); - }); + setTimeout(() => { if (mainChart) syncCharts(mainChart, mainChartContainer); }, 200); + }; + window.addEventListener('resize', resizeHandler); + window._bindSyncCleanups.push(() => { window.removeEventListener('resize', resizeHandler); }); } function setupTooltip(mainChart, buyMarkers = [], sellMarkers = [], mainChartContainer, volumeChartContainer, atrChartContainer, macdChartContainer, chanMacdChartContainer, volumeChart, atrChart, macdChart, chanMacdChart, showMacd) { - // 调试变量 + // 清理上一轮 tooltip 的事件订阅 + if (window._tooltipCleanups) { + window._tooltipCleanups.forEach(fn => { try { fn(); } catch(e) {} }); + } + window._tooltipCleanups = []; + window.debugMode = true; // 初始化 U 显示状态(主/次周期分开控制) const isShowUMain = $('#toggleUOnMain').is(':checked'); @@ -7295,7 +7222,7 @@ // 添加鼠标悬停事件显示提示 if (mainChart) { - mainChart.subscribeCrosshairMove(param => { + const crosshairHandler = (param) => { // 十字线同步到其他图表 - 通过DOM元素绘制垂直线实现虚线延长效果 if (param.time && param.point && volumeChart) { try { @@ -7378,13 +7305,6 @@ const chanMacdTimeCoordinate = chanMacdChart.timeScale().timeToCoordinate(param.time); if (chanMacdTimeCoordinate !== null) { const chanMacdChartRect = chanMacdChartContainer.getBoundingClientRect(); - console.log('ChanMACD图表位置(第二个位置):', { - left: chanMacdChartRect.left, - top: chanMacdChartRect.top, - width: chanMacdChartRect.width, - height: chanMacdChartRect.height, - timeCoordinate: chanMacdTimeCoordinate - }); const chanMacdLine = document.createElement('div'); chanMacdLine.className = 'chanmacd-crosshair-line'; chanMacdLine.style.position = 'fixed'; @@ -7397,16 +7317,7 @@ chanMacdLine.style.pointerEvents = 'none'; chanMacdLine.style.zIndex = '1000'; document.body.appendChild(chanMacdLine); - console.log('ChanMACD垂直线已创建(第二个位置),位置:', chanMacdLine.style.left, chanMacdLine.style.top); - } else { - console.log('ChanMACD时间坐标为空(第二个位置)'); } - } else { - console.log('ChanMACD图表条件不满足(第二个位置):', { - showMacd: showMacd, - hasChanMacdChart: !!chanMacdChart, - hasChanMacdChartContainer: !!chanMacdChartContainer - }); } } } catch (e) { @@ -7479,9 +7390,6 @@ } } - // 仅记录最简短的调试信息 - console.debug(`十字线: ${timeStr} -> ${formattedTime} (${timezone})`); - // 显示自定义时区工具提示,包含价格信息 crosshairTooltip.innerHTML = `
时间: ${formattedTime}
` + (priceInfo ? `
${priceInfo}
` : ''); @@ -7506,12 +7414,20 @@ tooltipElement.style.display = 'none'; crosshairTooltip.style.display = 'none'; } + }; + mainChart.subscribeCrosshairMove(crosshairHandler); + window._tooltipCleanups.push(() => { + try { mainChart.unsubscribeCrosshairMove(crosshairHandler); } catch(e) {} }); // 处理图表缩放、平移等事件,隐藏提示 - mainChart.timeScale().subscribeVisibleTimeRangeChange(() => { + const hideTooltipHandler = () => { tooltipElement.style.display = 'none'; crosshairTooltip.style.display = 'none'; + }; + mainChart.timeScale().subscribeVisibleTimeRangeChange(hideTooltipHandler); + window._tooltipCleanups.push(() => { + try { mainChart.timeScale().unsubscribeVisibleTimeRangeChange(hideTooltipHandler); } catch(e) {} }); } } @@ -8259,60 +8175,27 @@ // 只重绘分形元素(笔、线段、中枢),保留现有的K线、MACD和成交量 function redrawFractalElements() { - // 检查图表是否已初始化 - if (!tvWidget || !tvWidget.mainChart) { - console.error('图表未初始化,无法重绘分形元素'); - return; - } + if (!tvWidget || !tvWidget.mainChart) return; - console.log('重绘分形元素 - 开始'); - console.log('- 显示笔:', $('#showMainBi').is(':checked')); - console.log('- 显示线段:', $('#showMainSeg').is(':checked')); - console.log('- 显示中枢:', $('#showMainZs').is(':checked')); - - // 保存当前的图表可见范围 const mainChart = tvWidget.mainChart; - const visibleRange = mainChart.timeScale().getVisibleRange(); const logicalRange = mainChart.timeScale().getVisibleLogicalRange(); + const visibleRange = mainChart.timeScale().getVisibleRange(); - // 获取当前图表设置 - const showMacd = $('#showMacd').is(':checked'); - const showOriginalKline = $('#showOriginalKline').is(':checked'); - const showBi = $('#showMainBi').is(':checked'); - const showSeg = $('#showMainSeg').is(':checked'); - const showZs = $('#showMainZs').is(':checked'); - - // 保存原始K线和MACD数据,确保即使使用小级别,我们依然使用主周期的K线和MACD数据 - const originalKlineData = currentData.kline_data; - const originalMacdData = currentData.macd; - - // 重新初始化图表 - 这将清除所有系列并重新创建 - console.log('重新初始化图表...'); - - // 临时存储currentData中可能被修改的字段 - const tempCurrentData = { - kline_data: originalKlineData, - macd: originalMacdData - }; - - // 在重绘前确保K线和MACD数据不变 - if (currentData.original_kline_data && currentData.original_macd) { - // 如果已经保存了原始数据,恢复它们 + // 确保使用主周期的K线和MACD数据 + if (currentData.original_kline_data) { currentData.kline_data = currentData.original_kline_data; - currentData.macd = currentData.original_macd; - } else { - // 首次运行 - 保存原始数据 - currentData.original_kline_data = originalKlineData; - currentData.original_macd = originalMacdData; } + if (currentData.original_macd) { + currentData.macd = currentData.original_macd; + } + // 清除冗余引用,帮助GC回收 + delete currentData.original_kline_data; + delete currentData.original_macd; - // 调用初始化函数 initTradingView($('#symbol').val(), $('#timeframe').val()); - // 恢复原始可见范围 setTimeout(() => { if (tvWidget && tvWidget.mainChart) { - console.log('恢复图表可见范围...'); if (logicalRange) { tvWidget.mainChart.timeScale().setVisibleLogicalRange(logicalRange); if (tvWidget.volumeChart) tvWidget.volumeChart.timeScale().setVisibleLogicalRange(logicalRange); @@ -8326,14 +8209,8 @@ if (tvWidget.macdChart) tvWidget.macdChart.timeScale().setVisibleRange(visibleRange); if (tvWidget.chanMacdChart) tvWidget.chanMacdChart.timeScale().setVisibleRange(visibleRange); } - - console.log('图表可见范围已恢复(包括ATR图表)'); - } else { - console.error('恢复图表可见范围失败 - 图表未初始化'); } }, 200); - - console.log('重绘分形元素 - 完成'); } // 只更新分形元素(笔、线段、中枢)的表格数据 function updateFractalTables() { @@ -8424,33 +8301,11 @@ return; } - console.log('API返回数据:', data); - console.log('K线数据点数:', data.kline_data ? data.kline_data.length : 0); - - // 检查是否包含小周期数据 if (data.element_timeframe) { - console.log('小周期笔数据点数:', data.element_bi_list ? data.element_bi_list.length : 0); - console.log('小周期线段数据点数:', data.element_seg_list ? data.element_seg_list.length : 0); - console.log('小周期中枢数据点数:', data.element_zs_list ? data.element_zs_list.length : 0); - - // 更新小周期选择器 $('#elementTimeframe').val(data.element_timeframe); - } else { - console.log('未提供小周期数据,使用主周期数据'); } - // 保存原始K线和MACD数据,用于后续参考 - data.original_kline_data = data.kline_data; - data.original_macd = data.macd; - - // 如果图表未初始化,则创建图表,否则更新图表数据 - if (!tvWidget.state.isInitialized) { - console.log('图表尚未初始化,创建新图表'); - initTradingView($('#symbol').val(), $('#timeframe').val()); - } else { - console.log('图表已初始化,进行增量更新'); - updateTradingViewData(); - } + initTradingView($('#symbol').val(), $('#timeframe').val()); // 更新表格数据 updateTables(data);