Merge pull request #18 from jackyu66git/dev

web端进行优化,减少内存开销,data provider提供websocket服务
This commit is contained in:
jackyu66git
2026-04-05 18:24:40 +08:00
committed by GitHub
2 changed files with 297 additions and 255 deletions
+189 -2
View File
@@ -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
+108 -253
View File
@@ -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 = `<div style="font-weight:bold">时间: ${formattedTime}</div>` +
(priceInfo ? `<div>${priceInfo}</div>` : '');
@@ -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);