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 ccxt # type: ignore
import pandas as pd # 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 from fastapi.middleware.cors import CORSMiddleware
import uvicorn import uvicorn
from technical.util import resample_to_interval from technical.util import resample_to_interval
@@ -47,6 +47,7 @@ DEFAULT_LIMIT = 500
RECENT_CANDLE_LIMIT = 10 RECENT_CANDLE_LIMIT = 10
RECENT_FETCH_INTERVAL = 5 # 后台刷新循环休眠秒数 RECENT_FETCH_INTERVAL = 5 # 后台刷新循环休眠秒数
PERSIST_INTERVAL = 600 # 全量落盘周期(秒) PERSIST_INTERVAL = 600 # 全量落盘周期(秒)
WS_UPDATE_CANDLE_COUNT = 2 # WebSocket 增量推送最近 K 线根数
logger = logging.getLogger("data_provider") logger = logging.getLogger("data_provider")
@@ -122,6 +123,82 @@ def timeframe_to_minutes(tf: str) -> Optional[int]:
return value * multiplier 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: class DataProvider:
"""封装交易所连接、本地 CSV、内存缓存、断线恢复与衍生周期聚合。""" """封装交易所连接、本地 CSV、内存缓存、断线恢复与衍生周期聚合。"""
@@ -165,6 +242,7 @@ class DataProvider:
self._resume_file: Path = self.data_dir / "resume_since.json" self._resume_file: Path = self.data_dir / "resume_since.json"
# 尝试加载历史恢复点 # 尝试加载历史恢复点
self._load_resume_since() self._load_resume_since()
self._update_callbacks: List = []
def _load_config(self) -> Dict[str, object]: def _load_config(self) -> Dict[str, object]:
"""读取 JSON 配置文件。""" """读取 JSON 配置文件。"""
@@ -458,6 +536,18 @@ class DataProvider:
# 同步写盘 # 同步写盘
self._save_resume_since() 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: def start_background_workers(self) -> None:
"""启动增量刷新线程与周期性落盘线程。""" """启动增量刷新线程与周期性落盘线程。"""
if self._fetch_thread and self._fetch_thread.is_alive(): if self._fetch_thread and self._fetch_thread.is_alive():
@@ -499,6 +589,7 @@ class DataProvider:
current = self.data.setdefault(symbol, {}).get(timeframe, []) current = self.data.setdefault(symbol, {}).get(timeframe, [])
merged = self._merge_candles(timeframe, current, history) merged = self._merge_candles(timeframe, current, history)
self.data[symbol][timeframe] = merged self.data[symbol][timeframe] = merged
self._notify_update(symbol, timeframe)
self._clear_resume_since(symbol, timeframe) self._clear_resume_since(symbol, timeframe)
else: else:
# 正常增量获取最近若干根K线 # 正常增量获取最近若干根K线
@@ -513,6 +604,7 @@ class DataProvider:
current = self.data.setdefault(symbol, {}).get(timeframe, []) current = self.data.setdefault(symbol, {}).get(timeframe, [])
merged = self._merge_candles(timeframe, current, candles) merged = self._merge_candles(timeframe, current, candles)
self.data[symbol][timeframe] = merged self.data[symbol][timeframe] = merged
self._notify_update(symbol, timeframe)
except ccxt.BaseError as exc: except ccxt.BaseError as exc:
logger.error("更新最新K线失败 (%s %s): %s", symbol, timeframe, exc) logger.error("更新最新K线失败 (%s %s): %s", symbol, timeframe, exc)
# 记录应当从何时恢复拉取,避免重连后从当前时间开始导致丢K # 记录应当从何时恢复拉取,避免重连后从当前时间开始导致丢K
@@ -664,11 +756,38 @@ class DataProvider:
def create_app(provider: DataProvider) -> FastAPI: 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 @asynccontextmanager
async def lifespan(app: FastAPI): async def lifespan(app: FastAPI):
loop = asyncio.get_running_loop() loop = asyncio.get_running_loop()
ws_manager.set_loop(loop)
provider.on_update(_on_data_update)
await loop.run_in_executor(None, provider.initialize) await loop.run_in_executor(None, provider.initialize)
provider.start_background_workers() provider.start_background_workers()
try: try:
@@ -734,6 +853,74 @@ def create_app(provider: DataProvider) -> FastAPI:
"ready": provider.is_ready(), "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 return app
+108 -253
View File
@@ -1173,9 +1173,43 @@
// 不在前端截断数据,保留完整历史,避免K线数量限制 // 不在前端截断数据,保留完整历史,避免K线数量限制
function trimDataInPlace(payload, maxLen = 2000) { function trimDataInPlace(payload, maxLen = 2000) {
if (!payload || typeof payload !== 'object') return; if (!payload || typeof payload !== 'object') return;
// 仅清理可能保留的旧快照引用
delete payload.original_kline_data; delete payload.original_kline_data;
delete payload.original_macd; 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 trendDetailTable = null;
let trendChart = null; let trendChart = null;
@@ -2120,15 +2154,6 @@
} }
currentData = data; 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); refreshChart(data);
}, },
error: function(jqXHR, textStatus, errorThrown) { error: function(jqXHR, textStatus, errorThrown) {
@@ -7010,7 +7035,12 @@
} }
} }
function bindSyncEvents(mainChartContainer, volumeChartContainer, atrChartContainer, macdChartContainer, chanMacdChartContainer, mainChart, volumeChart, atrChart, macdChart, chanMacdChart, showMacd) { 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; let syncInProgress = false;
// 用于跟踪所有图表的拖动状态 - 在函数内部定义以确保作用域正确 // 用于跟踪所有图表的拖动状态 - 在函数内部定义以确保作用域正确
@@ -7024,161 +7054,70 @@
// 同步图表的时间范围 // 同步图表的时间范围
function syncCharts(sourceChart, sourceContainer) { function syncCharts(sourceChart, sourceContainer) {
// 防止无限循环 - 使用更精确的检查 if (syncInProgress) return;
if (syncInProgress) {
console.log('🔄 同步正在进行中,跳过此次同步');
return;
}
syncInProgress = true; syncInProgress = true;
console.log('🚀 开始同步图表,来源:',
sourceChart === mainChart ? '主图' :
sourceChart === volumeChart ? '成交量图' :
sourceChart === atrChart ? 'ATR图' : 'MACD图');
try { try {
if (sourceChart && sourceChart.timeScale) { if (sourceChart && sourceChart.timeScale) {
const logicalRange = sourceChart.timeScale().getVisibleLogicalRange(); const logicalRange = sourceChart.timeScale().getVisibleLogicalRange();
if (logicalRange && logicalRange.from !== undefined && logicalRange.to !== undefined) { if (logicalRange && logicalRange.from !== undefined && logicalRange.to !== undefined) {
console.log('📊 同步时间范围:', logicalRange);
// 同步主图
if (sourceChart !== mainChart && mainChart && mainChart.timeScale) { if (sourceChart !== mainChart && mainChart && mainChart.timeScale) {
try { try { mainChart.timeScale().setVisibleLogicalRange(logicalRange); } catch (e) {}
mainChart.timeScale().setVisibleLogicalRange(logicalRange);
console.log('✅ 主图同步完成');
} catch (e) {
console.error('❌ 主图同步失败:', e);
}
} }
// 同步成交量图
if (sourceChart !== volumeChart && volumeChart && volumeChart.timeScale) { if (sourceChart !== volumeChart && volumeChart && volumeChart.timeScale) {
try { try { volumeChart.timeScale().setVisibleLogicalRange(logicalRange); } catch (e) {}
volumeChart.timeScale().setVisibleLogicalRange(logicalRange);
console.log('✅ 成交量图同步完成');
} catch (e) {
console.error('❌ 成交量图同步失败:', e);
}
} }
// 同步ATR图
if (sourceChart !== atrChart && atrChart && atrChart.timeScale) { if (sourceChart !== atrChart && atrChart && atrChart.timeScale) {
try { try { atrChart.timeScale().setVisibleLogicalRange(logicalRange); } catch (e) {}
atrChart.timeScale().setVisibleLogicalRange(logicalRange);
console.log('✅ ATR图同步完成');
} catch (e) {
console.error('❌ ATR图同步失败:', e);
}
} }
// 同步MACD图
if (showMacd && macdChart && sourceChart !== macdChart && macdChart.timeScale) { if (showMacd && macdChart && sourceChart !== macdChart && macdChart.timeScale) {
try { try { macdChart.timeScale().setVisibleLogicalRange(logicalRange); } catch (e) {}
macdChart.timeScale().setVisibleLogicalRange(logicalRange);
console.log('✅ MACD图同步完成');
} catch (e) {
console.error('❌ MACD图同步失败:', e);
}
} }
// 同步ChanMACD图
if (showMacd && chanMacdChart && sourceChart !== chanMacdChart && chanMacdChart.timeScale) { if (showMacd && chanMacdChart && sourceChart !== chanMacdChart && chanMacdChart.timeScale) {
try { try { chanMacdChart.timeScale().setVisibleLogicalRange(logicalRange); } catch (e) {}
chanMacdChart.timeScale().setVisibleLogicalRange(logicalRange);
console.log('✅ ChanMACD图同步完成');
} catch (e) {
console.error('❌ ChanMACD图同步失败:', e);
}
} }
// 保存当前的可见范围到全局状态
if (tvWidget && tvWidget.state) { if (tvWidget && tvWidget.state) {
tvWidget.state.logicalRange = logicalRange; tvWidget.state.logicalRange = logicalRange;
// 同时保存可见范围以确保精确对齐 try { tvWidget.state.visibleRange = sourceChart.timeScale().getVisibleRange(); } catch (e) {}
try {
const visibleRange = sourceChart.timeScale().getVisibleRange();
tvWidget.state.visibleRange = visibleRange;
console.log('💾 保存状态 - 逻辑范围:', logicalRange, '可见范围:', visibleRange);
} catch (e) {
console.warn('⚠️ 保存可见范围失败:', e);
}
} }
} else {
console.warn('⚠️ 无效的逻辑范围:', logicalRange);
} }
} else {
console.warn('⚠️ 无效的源图表或时间刻度');
} }
} catch (e) { } catch (e) {
console.error('💥 同步图表出错:', e); console.error('同步图表出错:', e);
} }
// 立即重置同步标志,提高响应速度 setTimeout(() => { syncInProgress = false; }, 1);
setTimeout(() => {
syncInProgress = false;
console.log('🔓 同步标志已重置');
}, 1);
} }
// 为每个图表添加事件监听 // 为每个图表添加事件监听
const addChartSyncEvents = (chartContainer, chart) => { const addChartSyncEvents = (chartContainer, chart) => {
console.log('为图表添加同步事件监听:',
chart === mainChart ? '主图' :
chart === volumeChart ? '成交量图' :
chart === atrChart ? 'ATR图' :
chart === macdChart ? 'MACD图' :
chart === chanMacdChart ? 'ChanMACD图' : '未知图表');
// 确定当前图表类型
const chartType = chart === mainChart ? 'main' : const chartType = chart === mainChart ? 'main' :
chart === volumeChart ? 'volume' : chart === volumeChart ? 'volume' :
chart === atrChart ? 'atr' : chart === atrChart ? 'atr' :
chart === macdChart ? 'macd' : chart === macdChart ? 'macd' :
chart === chanMacdChart ? 'chanmacd' : 'unknown'; chart === chanMacdChart ? 'chanmacd' : 'unknown';
// 使用LightweightCharts内置的时间范围变化事件(这是最可靠的方法) const timeRangeHandler = () => {
chart.timeScale().subscribeVisibleTimeRangeChange(() => {
// 使用图表特定的同步标志防止递归
if (!syncInProgress) { if (!syncInProgress) {
console.log('✅ 检测到时间范围变化,触发同步:', chartType, '当前范围:', chart.timeScale().getVisibleLogicalRange());
syncCharts(chart, chartContainer); 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; let isScrolling = false;
// 鼠标按下事件 const mousedownHandler = () => { localDragStates[chartType] = true; };
chartContainer.addEventListener('mousedown', (e) => { const mouseupHandler = () => { localDragStates[chartType] = false; };
localDragStates[chartType] = true; const mouseleaveHandler = () => { localDragStates[chartType] = false; };
console.log('鼠标按下开始拖动:', chartType); const wheelHandler = () => {
});
// 鼠标抬起事件
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) => {
if (!isScrolling) { if (!isScrolling) {
isScrolling = true; isScrolling = true;
console.log('滚轮缩放:', chartType);
setTimeout(() => { setTimeout(() => {
if (!syncInProgress) { if (!syncInProgress) {
syncCharts(chart, chartContainer); syncCharts(chart, chartContainer);
@@ -7186,6 +7125,17 @@
isScrolling = false; isScrolling = false;
}, 50); }, 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); addChartSyncEvents(chanMacdChartContainer, chanMacdChart);
} }
// 窗口大小变化时重绘图表 // 窗口大小变化时重绘图表 — 使用可清理的方式注册
window.addEventListener('resize', () => { const resizeHandler = () => {
// 调整主图大小
if (mainChart && mainChartContainer) { if (mainChart && mainChartContainer) {
mainChart.applyOptions({ mainChart.applyOptions({ width: mainChartContainer.clientWidth, height: mainChartContainer.clientHeight });
width: mainChartContainer.clientWidth,
height: mainChartContainer.clientHeight
});
} }
// 调整成交量图大小
if (volumeChart && volumeChartContainer) { if (volumeChart && volumeChartContainer) {
volumeChart.applyOptions({ volumeChart.applyOptions({ width: volumeChartContainer.clientWidth, height: volumeChartContainer.clientHeight });
width: volumeChartContainer.clientWidth,
height: volumeChartContainer.clientHeight
});
} }
// 调整ATR图大小
if (atrChart && atrChartContainer) { if (atrChart && atrChartContainer) {
atrChart.applyOptions({ atrChart.applyOptions({ width: atrChartContainer.clientWidth, height: atrChartContainer.clientHeight });
width: atrChartContainer.clientWidth,
height: atrChartContainer.clientHeight
});
} }
// 调整MACD图大小
if (showMacd && macdChart && macdChartContainer) { if (showMacd && macdChart && macdChartContainer) {
macdChart.applyOptions({ macdChart.applyOptions({ width: macdChartContainer.clientWidth, height: macdChartContainer.clientHeight });
width: macdChartContainer.clientWidth,
height: macdChartContainer.clientHeight
});
} }
// 调整ChanMACD图大小
if (showMacd && chanMacdChart && chanMacdChartContainer) { if (showMacd && chanMacdChart && chanMacdChartContainer) {
chanMacdChart.applyOptions({ chanMacdChart.applyOptions({ width: chanMacdChartContainer.clientWidth, height: chanMacdChartContainer.clientHeight });
width: chanMacdChartContainer.clientWidth,
height: chanMacdChartContainer.clientHeight
});
} }
setTimeout(() => { if (mainChart) syncCharts(mainChart, mainChartContainer); }, 200);
// 重新同步 - 使用主图作为同步源 };
setTimeout(() => { window.addEventListener('resize', resizeHandler);
if (mainChart) { window._bindSyncCleanups.push(() => { window.removeEventListener('resize', resizeHandler); });
syncCharts(mainChart, mainChartContainer);
}
}, 200);
});
} }
function setupTooltip(mainChart, buyMarkers = [], sellMarkers = [], mainChartContainer, volumeChartContainer, atrChartContainer, macdChartContainer, chanMacdChartContainer, volumeChart, atrChart, macdChart, chanMacdChart, showMacd) { 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; window.debugMode = true;
// 初始化 U 显示状态(主/次周期分开控制) // 初始化 U 显示状态(主/次周期分开控制)
const isShowUMain = $('#toggleUOnMain').is(':checked'); const isShowUMain = $('#toggleUOnMain').is(':checked');
@@ -7295,7 +7222,7 @@
// 添加鼠标悬停事件显示提示 // 添加鼠标悬停事件显示提示
if (mainChart) { if (mainChart) {
mainChart.subscribeCrosshairMove(param => { const crosshairHandler = (param) => {
// 十字线同步到其他图表 - 通过DOM元素绘制垂直线实现虚线延长效果 // 十字线同步到其他图表 - 通过DOM元素绘制垂直线实现虚线延长效果
if (param.time && param.point && volumeChart) { if (param.time && param.point && volumeChart) {
try { try {
@@ -7378,13 +7305,6 @@
const chanMacdTimeCoordinate = chanMacdChart.timeScale().timeToCoordinate(param.time); const chanMacdTimeCoordinate = chanMacdChart.timeScale().timeToCoordinate(param.time);
if (chanMacdTimeCoordinate !== null) { if (chanMacdTimeCoordinate !== null) {
const chanMacdChartRect = chanMacdChartContainer.getBoundingClientRect(); 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'); const chanMacdLine = document.createElement('div');
chanMacdLine.className = 'chanmacd-crosshair-line'; chanMacdLine.className = 'chanmacd-crosshair-line';
chanMacdLine.style.position = 'fixed'; chanMacdLine.style.position = 'fixed';
@@ -7397,16 +7317,7 @@
chanMacdLine.style.pointerEvents = 'none'; chanMacdLine.style.pointerEvents = 'none';
chanMacdLine.style.zIndex = '1000'; chanMacdLine.style.zIndex = '1000';
document.body.appendChild(chanMacdLine); 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) { } catch (e) {
@@ -7479,9 +7390,6 @@
} }
} }
// 仅记录最简短的调试信息
console.debug(`十字线: ${timeStr} -> ${formattedTime} (${timezone})`);
// 显示自定义时区工具提示,包含价格信息 // 显示自定义时区工具提示,包含价格信息
crosshairTooltip.innerHTML = `<div style="font-weight:bold">时间: ${formattedTime}</div>` + crosshairTooltip.innerHTML = `<div style="font-weight:bold">时间: ${formattedTime}</div>` +
(priceInfo ? `<div>${priceInfo}</div>` : ''); (priceInfo ? `<div>${priceInfo}</div>` : '');
@@ -7506,12 +7414,20 @@
tooltipElement.style.display = 'none'; tooltipElement.style.display = 'none';
crosshairTooltip.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'; tooltipElement.style.display = 'none';
crosshairTooltip.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和成交量 // 只重绘分形元素(笔、线段、中枢),保留现有的K线、MACD和成交量
function redrawFractalElements() { function redrawFractalElements() {
// 检查图表是否已初始化 if (!tvWidget || !tvWidget.mainChart) return;
if (!tvWidget || !tvWidget.mainChart) {
console.error('图表未初始化,无法重绘分形元素');
return;
}
console.log('重绘分形元素 - 开始');
console.log('- 显示笔:', $('#showMainBi').is(':checked'));
console.log('- 显示线段:', $('#showMainSeg').is(':checked'));
console.log('- 显示中枢:', $('#showMainZs').is(':checked'));
// 保存当前的图表可见范围
const mainChart = tvWidget.mainChart; const mainChart = tvWidget.mainChart;
const visibleRange = mainChart.timeScale().getVisibleRange();
const logicalRange = mainChart.timeScale().getVisibleLogicalRange(); const logicalRange = mainChart.timeScale().getVisibleLogicalRange();
const visibleRange = mainChart.timeScale().getVisibleRange();
// 获取当前图表设置 // 确保使用主周期的K线和MACD数据
const showMacd = $('#showMacd').is(':checked'); if (currentData.original_kline_data) {
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) {
// 如果已经保存了原始数据,恢复它们
currentData.kline_data = 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()); initTradingView($('#symbol').val(), $('#timeframe').val());
// 恢复原始可见范围
setTimeout(() => { setTimeout(() => {
if (tvWidget && tvWidget.mainChart) { if (tvWidget && tvWidget.mainChart) {
console.log('恢复图表可见范围...');
if (logicalRange) { if (logicalRange) {
tvWidget.mainChart.timeScale().setVisibleLogicalRange(logicalRange); tvWidget.mainChart.timeScale().setVisibleLogicalRange(logicalRange);
if (tvWidget.volumeChart) tvWidget.volumeChart.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.macdChart) tvWidget.macdChart.timeScale().setVisibleRange(visibleRange);
if (tvWidget.chanMacdChart) tvWidget.chanMacdChart.timeScale().setVisibleRange(visibleRange); if (tvWidget.chanMacdChart) tvWidget.chanMacdChart.timeScale().setVisibleRange(visibleRange);
} }
console.log('图表可见范围已恢复(包括ATR图表)');
} else {
console.error('恢复图表可见范围失败 - 图表未初始化');
} }
}, 200); }, 200);
console.log('重绘分形元素 - 完成');
} }
// 只更新分形元素(笔、线段、中枢)的表格数据 // 只更新分形元素(笔、线段、中枢)的表格数据
function updateFractalTables() { function updateFractalTables() {
@@ -8424,33 +8301,11 @@
return; return;
} }
console.log('API返回数据:', data);
console.log('K线数据点数:', data.kline_data ? data.kline_data.length : 0);
// 检查是否包含小周期数据
if (data.element_timeframe) { 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); $('#elementTimeframe').val(data.element_timeframe);
} else {
console.log('未提供小周期数据,使用主周期数据');
} }
// 保存原始K线和MACD数据,用于后续参考 initTradingView($('#symbol').val(), $('#timeframe').val());
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();
}
// 更新表格数据 // 更新表格数据
updateTables(data); updateTables(data);