web端进行优化,减少内存开销,data provider提供websocket服务
This commit is contained in:
+189
-2
@@ -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
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user