diff --git a/web/templates/index.html b/web/templates/index.html
index c9662fa..7fe99ee 100644
--- a/web/templates/index.html
+++ b/web/templates/index.html
@@ -18,7 +18,7 @@
body {
font-family: "Helvetica Neue", Arial, "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif;
margin: 0;
- padding: 20px;
+ padding: 10px;
background-color: #f8f9fa;
color: #333;
}
@@ -26,18 +26,17 @@
max-width: 100%;
margin: 0 auto;
background-color: white;
- padding: 20px;
+ padding: 5px;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
}
.header {
- margin-bottom: 20px;
- padding-bottom: 10px;
- border-bottom: 1px solid #eee;
+ margin-bottom: 0px;
+ padding-bottom: 0px;
}
.controls {
- margin-bottom: 20px;
- padding: 20px;
+ margin-bottom: 10px;
+ padding: 10px;
background-color: #f8f9fa;
border-radius: 8px;
box-shadow: 0 1px 3px rgba(0,0,0,0.05);
@@ -50,7 +49,7 @@
.chart-container {
width: 100%;
height: 600px;
- margin-top: 20px;
+ margin-top: 10px;
border: 1px solid #e9ecef;
border-radius: 8px;
padding: 10px;
@@ -74,10 +73,10 @@
box-shadow: 0 1px 4px rgba(0,0,0,0.1);
}
.data-container {
- margin-top: 30px;
+ margin-top: 10px;
}
.nav-tabs {
- margin-bottom: 15px;
+ margin-bottom: 10px;
}
.table-container {
overflow-x: auto;
@@ -215,12 +214,22 @@
margin-left: 8px;
vertical-align: middle;
}
+
+ /* 数据回放相关样式 */
+ #replayStatus {
+ min-width: 120px;
+ }
+
+ #replayProgress {
+ border-radius: 4px;
+ overflow: hidden;
+ }
@@ -266,7 +275,7 @@
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -3871,6 +3902,514 @@
var tooltipList = tooltipTriggerList.map(function (tooltipTriggerEl) {
return new bootstrap.Tooltip(tooltipTriggerEl)
})
+
+ // 数据回放相关变量
+ let replayTimer = null;
+ let replayData = null;
+ let currentReplayIndex = 0;
+ let totalReplaySteps = 0;
+ let isReplaying = false;
+ let replayStartTime = null;
+ let replayEndTime = null;
+
+ // 数据回放功能初始化
+ $(document).ready(function() {
+ // 开始回放按钮
+ $('#startReplay').click(function() {
+ startDataReplay();
+ });
+
+ // 暂停回放按钮
+ $('#pauseReplay').click(function() {
+ pauseDataReplay();
+ });
+
+ // 停止回放按钮
+ $('#stopReplay').click(function() {
+ stopDataReplay();
+ });
+ });
+
+ // 开始数据回放
+ function startDataReplay() {
+ // 如果自动刷新开启,需要先关闭
+ if ($('#autoRefresh').is(':checked')) {
+ $('#autoRefresh').prop('checked', false).change();
+ }
+
+ // 检查是否设置了时间范围
+ if (!$('#start_time').val() || !$('#end_time').val()) {
+ alert('请先设置开始时间和结束时间');
+ return;
+ }
+
+ // 如果已经在回放中,且是暂停状态,则继续回放
+ if (isReplaying && replayTimer === null) {
+ continueDataReplay();
+ return;
+ }
+
+ // 停止任何现有的回放
+ stopDataReplay();
+
+ // 获取回放参数
+ replayStartTime = new Date($('#start_time').val()).getTime();
+ replayEndTime = new Date($('#end_time').val()).getTime();
+
+ // 检查时间范围是否有效
+ if (replayStartTime >= replayEndTime) {
+ alert('开始时间必须早于结束时间');
+ return;
+ }
+
+ // 获取当前参数
+ const symbol = $('#symbol').val() || 'SOL/USDT:USDT';
+ const timeframe = $('#timeframe').val() || '5m';
+ const timezone = $('#timezone').val() || 'Asia/Shanghai';
+ const elementTimeframe = $('#elementTimeframe').val() || '1m';
+
+ // 显示回放状态
+ $('#replayStatus').text('正在加载数据...');
+ $('#replayProgress').show();
+ $('.progress-bar').css('width', '0%');
+
+ // 切换按钮状态
+ $('#startReplay').hide();
+ $('#stopReplay').show();
+
+ // 尝试使用当前已有数据
+ if (currentData && currentData.kline_data && currentData.kline_data.length > 0) {
+ const currentStart = new Date(currentData.kline_data[0].date).getTime();
+ const currentEnd = new Date(currentData.kline_data[currentData.kline_data.length - 1].date).getTime();
+
+ // 检查当前数据是否覆盖所需的时间范围
+ if (currentStart <= replayStartTime && currentEnd >= replayEndTime) {
+ console.log('使用当前数据进行回放,无需重新请求');
+ replayData = currentData;
+ initReplay();
+ return;
+ }
+ }
+
+ // 请求整个时间范围的数据
+ $.ajax({
+ url: '/api/analyze',
+ data: {
+ symbol: symbol,
+ timeframe: timeframe,
+ timezone: timezone,
+ element_timeframe: elementTimeframe,
+ start_time: replayStartTime,
+ end_time: replayEndTime,
+ elements_only: false
+ },
+ success: function(data) {
+ // 检查是否有有效数据
+ if (!data || !data.kline_data || data.kline_data.length === 0) {
+ $('#replayStatus').text('服务器返回的数据无效');
+ $('#replayProgress').hide();
+ $('#startReplay').show();
+ $('#stopReplay').hide();
+ console.error('服务器返回的数据无效:', data);
+ return;
+ }
+
+ // 保存完整数据
+ replayData = data;
+
+ // 初始化回放
+ initReplay();
+ },
+ error: function(jqXHR, textStatus, errorThrown) {
+ $('#replayStatus').text('加载数据失败');
+ $('#replayProgress').hide();
+ $('#startReplay').show();
+ $('#stopReplay').hide();
+ alert('加载数据失败: ' + (jqXHR.responseJSON?.error || errorThrown));
+ }
+ });
+ }
+
+ // 初始化回放
+ function initReplay() {
+ if (!replayData || !replayData.kline_data || replayData.kline_data.length === 0) {
+ $('#replayStatus').text('没有可回放的数据');
+ $('#replayProgress').hide();
+ $('#startReplay').show();
+ $('#stopReplay').hide();
+ return;
+ }
+
+ // 设置回放变量
+ isReplaying = true;
+ currentReplayIndex = 0;
+ totalReplaySteps = replayData.kline_data.length;
+
+ // 更新回放状态
+ $('#replayStatus').text(`准备回放 (0/${totalReplaySteps})`);
+ $('.progress-bar').css('width', '0%');
+
+ // 准备初始数据
+ prepareInitialReplayData();
+
+ // 开始回放
+ continueDataReplay();
+ }
+
+ // 准备初始回放数据
+ function prepareInitialReplayData() {
+ // 创建初始数据集,只包含第一根K线
+ const initialData = $.extend(true, {}, replayData);
+
+ // 确保第一根K线数据有效
+ if (!replayData.kline_data || replayData.kline_data.length === 0) {
+ console.error('无有效K线数据用于回放');
+ $('#replayStatus').text('无有效数据用于回放');
+ $('#replayProgress').hide();
+ return;
+ }
+
+ initialData.kline_data = [replayData.kline_data[0]];
+
+ // 确保初始K线数据的所有字段都有值
+ const firstKline = initialData.kline_data[0];
+ if (firstKline) {
+ // 确保K线数据的每个字段都是有效的数值
+ ['open', 'high', 'low', 'close', 'volume'].forEach(field => {
+ if (firstKline[field] === null || firstKline[field] === undefined || isNaN(firstKline[field])) {
+ console.warn(`K线数据的${field}字段无效,设置为默认值`);
+ firstKline[field] = field === 'volume' ? 0 : firstKline.close || 0;
+ }
+ });
+ }
+
+ // 清空其他数据列表,因为还没有处理
+ initialData.bi_list = [];
+ initialData.seg_list = [];
+ initialData.zs_list = [];
+ initialData.uncompleted_zs_list = [];
+ initialData.element_bi_list = [];
+ initialData.element_seg_list = [];
+ initialData.element_zs_list = [];
+ initialData.element_uncompleted_zs_list = [];
+ initialData.macd_divergence = [];
+ initialData.klc_fx_type = [];
+
+ // 初始化MACD数据
+ if (initialData.macd && initialData.macd.length > 0) {
+ initialData.macd = [initialData.macd[0]];
+
+ // 确保MACD数据有效
+ const firstMacd = initialData.macd[0];
+ if (firstMacd) {
+ ['dif', 'dea', 'macd'].forEach(field => {
+ if (firstMacd[field] === null || firstMacd[field] === undefined || isNaN(firstMacd[field])) {
+ console.warn(`MACD数据的${field}字段无效,设置为0`);
+ firstMacd[field] = 0;
+ }
+ });
+ }
+ }
+
+ // 更新图表,固定坐标轴范围
+ currentData = initialData;
+
+ // 清除当前图表
+ if (tvWidget.state.isInitialized) {
+ // 保存当前的图表可见范围以供后续使用
+ try {
+ tvWidget.state.visibleRange = tvWidget.mainChart.timeScale().getVisibleRange();
+ tvWidget.state.logicalRange = tvWidget.mainChart.timeScale().getVisibleLogicalRange();
+ } catch (e) {
+ console.error('保存图表范围失败', e);
+ }
+ }
+
+ // 刷新图表
+ refreshChart(initialData);
+
+ // 固定y轴范围
+ fixYAxisRange();
+ }
+
+ // 固定y轴范围,保持图表不因数据变化而变化
+ function fixYAxisRange() {
+ if (!tvWidget.mainChart) return;
+
+ // 获取全部数据的最高价和最低价,为了适当的范围
+ let minPrice = Infinity;
+ let maxPrice = -Infinity;
+
+ // 计算整个数据集的价格范围
+ replayData.kline_data.forEach(kline => {
+ const high = parseFloat(kline.high);
+ const low = parseFloat(kline.low);
+
+ if (high > maxPrice) maxPrice = high;
+ if (low < minPrice) minPrice = low;
+ });
+
+ // 添加一些边距(约5%)
+ const priceRange = maxPrice - minPrice;
+ maxPrice += priceRange * 0.05;
+ minPrice -= priceRange * 0.05;
+
+ // 修复: 使用正确的API设置固定价格范围
+ try {
+ // 禁用自动缩放
+ tvWidget.mainChart.priceScale().applyOptions({
+ autoScale: false,
+ scaleMargins: {
+ top: 0.1,
+ bottom: 0.2
+ },
+ // 直接在options中设置最小和最大值
+ minimumValue: minPrice,
+ maximumValue: maxPrice
+ });
+ } catch (e) {
+ console.error('设置Y轴范围失败:', e);
+ }
+ }
+
+ // 继续回放
+ function continueDataReplay() {
+ if (!isReplaying || !replayData) return;
+
+ // 获取回放速度
+ const interval = parseFloat($('#replayInterval').val()) * 1000 || 1000;
+
+ // 切换按钮状态
+ $('#startReplay').hide();
+ $('#pauseReplay').show();
+ $('#stopReplay').show();
+
+ // 开始回放定时器
+ replayTimer = setInterval(replayNextStep, interval);
+ }
+
+ // 回放下一步
+ function replayNextStep() {
+ if (!isReplaying || currentReplayIndex >= totalReplaySteps) {
+ stopDataReplay();
+ return;
+ }
+
+ // 更新回放状态
+ currentReplayIndex++;
+ updateReplayStatus();
+
+ // 创建截止到当前索引的数据子集
+ const subsetData = createDataSubset(currentReplayIndex);
+
+ // 更新图表
+ currentData = subsetData;
+ refreshChart(subsetData);
+
+ // 固定Y轴范围
+ fixYAxisRange();
+
+ // 保持最新数据可见
+ keepLatestDataVisible();
+
+ // 如果到达最后一步,停止回放
+ if (currentReplayIndex >= totalReplaySteps) {
+ $('#replayStatus').text('回放完成');
+ pauseDataReplay();
+ }
+ }
+
+ // 保持最新数据可见
+ function keepLatestDataVisible() {
+ if (!tvWidget.mainChart) return;
+
+ try {
+ // 获取当前数据的最后一个时间点
+ if (currentData && currentData.kline_data && currentData.kline_data.length > 0) {
+ const lastBar = currentData.kline_data[currentData.kline_data.length - 1];
+ const lastTime = Math.floor(new Date(lastBar.date).getTime() / 1000);
+
+ // 计算时间范围
+ const barCount = Math.min(50, currentData.kline_data.length); // 显示最近的50根K线或所有K线
+ const firstVisibleIndex = Math.max(0, currentData.kline_data.length - barCount);
+ const firstVisibleBar = currentData.kline_data[firstVisibleIndex];
+ const firstTime = Math.floor(new Date(firstVisibleBar.date).getTime() / 1000);
+
+ // 设置时间轴范围
+ tvWidget.mainChart.timeScale().setVisibleRange({
+ from: firstTime,
+ to: lastTime
+ });
+ }
+ } catch (e) {
+ console.error('保持最新数据可见失败:', e);
+ }
+ }
+
+ // 创建数据子集
+ function createDataSubset(endIndex) {
+ // 创建数据副本
+ const subsetData = $.extend(true, {}, replayData);
+
+ // 截取K线数据
+ subsetData.kline_data = replayData.kline_data.slice(0, endIndex);
+
+ // 验证K线数据
+ subsetData.kline_data.forEach((kline, index) => {
+ ['open', 'high', 'low', 'close', 'volume'].forEach(field => {
+ if (kline[field] === null || kline[field] === undefined || isNaN(kline[field])) {
+ console.warn(`K线数据[${index}]的${field}字段无效,使用替代值`);
+ // 对于价格字段,尝试使用其他价格字段,或者前一个K线的对应值
+ if (field !== 'volume') {
+ if (field === 'open' && kline.close !== null && !isNaN(kline.close)) {
+ kline[field] = kline.close;
+ } else if (index > 0) {
+ kline[field] = subsetData.kline_data[index-1][field] || 0;
+ } else {
+ kline[field] = 0;
+ }
+ } else {
+ kline[field] = 0; // 对于成交量,设为0
+ }
+ }
+ });
+ });
+
+ // 处理其他数据结构(笔、线段、中枢等)
+ // 我们需要根据当前K线的时间来过滤其他数据结构
+ try {
+ const currentEndTime = new Date(subsetData.kline_data[endIndex - 1].date).getTime();
+
+ // 过滤笔列表
+ if (subsetData.bi_list && subsetData.bi_list.length) {
+ subsetData.bi_list = filterDataByTime(replayData.bi_list, currentEndTime);
+ }
+
+ // 过滤线段列表
+ if (subsetData.seg_list && subsetData.seg_list.length) {
+ subsetData.seg_list = filterDataByTime(replayData.seg_list, currentEndTime);
+ }
+
+ // 过滤中枢列表
+ if (subsetData.zs_list && subsetData.zs_list.length) {
+ subsetData.zs_list = filterDataByTime(replayData.zs_list, currentEndTime);
+ }
+
+ // 过滤未完成中枢列表
+ if (subsetData.uncompleted_zs_list && subsetData.uncompleted_zs_list.length) {
+ subsetData.uncompleted_zs_list = filterDataByTime(replayData.uncompleted_zs_list, currentEndTime);
+ }
+
+ // 过滤小周期数据
+ if (subsetData.element_bi_list && subsetData.element_bi_list.length) {
+ subsetData.element_bi_list = filterDataByTime(replayData.element_bi_list, currentEndTime);
+ }
+
+ if (subsetData.element_seg_list && subsetData.element_seg_list.length) {
+ subsetData.element_seg_list = filterDataByTime(replayData.element_seg_list, currentEndTime);
+ }
+
+ if (subsetData.element_zs_list && subsetData.element_zs_list.length) {
+ subsetData.element_zs_list = filterDataByTime(replayData.element_zs_list, currentEndTime);
+ }
+
+ if (subsetData.element_uncompleted_zs_list && subsetData.element_uncompleted_zs_list.length) {
+ subsetData.element_uncompleted_zs_list = filterDataByTime(replayData.element_uncompleted_zs_list, currentEndTime);
+ }
+
+ // 过滤MACD
+ if (subsetData.macd && subsetData.macd.length) {
+ subsetData.macd = replayData.macd.slice(0, endIndex);
+
+ // 验证MACD数据
+ subsetData.macd.forEach((item, index) => {
+ ['dif', 'dea', 'macd'].forEach(field => {
+ if (item[field] === null || item[field] === undefined || isNaN(item[field])) {
+ console.warn(`MACD数据[${index}]的${field}字段无效,设置为0`);
+ item[field] = 0;
+ }
+ });
+ });
+ }
+
+ // 过滤MACD背离
+ if (subsetData.macd_divergence && subsetData.macd_divergence.length) {
+ subsetData.macd_divergence = filterDataByTime(replayData.macd_divergence, currentEndTime);
+ }
+
+ // 过滤分型类型
+ if (subsetData.klc_fx_type && subsetData.klc_fx_type.length) {
+ subsetData.klc_fx_type = filterDataByTime(replayData.klc_fx_type, currentEndTime, 'time');
+ }
+ } catch (e) {
+ console.error('过滤数据时出错:', e);
+ }
+
+ return subsetData;
+ }
+
+ // 根据时间过滤数据
+ function filterDataByTime(dataList, endTime, timeField = 'end_time') {
+ if (!dataList || !Array.isArray(dataList)) return [];
+
+ return dataList.filter(item => {
+ const itemTime = new Date(item[timeField]).getTime();
+ return itemTime <= endTime;
+ });
+ }
+
+ // 更新回放状态显示
+ function updateReplayStatus() {
+ const percent = Math.floor((currentReplayIndex / totalReplaySteps) * 100);
+ $('#replayStatus').text(`回放中 (${currentReplayIndex}/${totalReplaySteps})`);
+ $('.progress-bar').css('width', `${percent}%`);
+ }
+
+ // 暂停回放
+ function pauseDataReplay() {
+ if (replayTimer) {
+ clearInterval(replayTimer);
+ replayTimer = null;
+ }
+
+ // 切换按钮状态
+ $('#pauseReplay').hide();
+ $('#startReplay').show();
+
+ if (currentReplayIndex >= totalReplaySteps) {
+ $('#replayStatus').text('回放完成');
+ } else {
+ $('#replayStatus').text(`已暂停 (${currentReplayIndex}/${totalReplaySteps})`);
+ }
+ }
+
+ // 停止回放
+ function stopDataReplay() {
+ // 清除定时器
+ if (replayTimer) {
+ clearInterval(replayTimer);
+ replayTimer = null;
+ }
+
+ // 重置回放状态
+ isReplaying = false;
+ currentReplayIndex = 0;
+ replayData = null;
+
+ // 更新UI
+ $('#replayStatus').text('');
+ $('#replayProgress').hide();
+ $('#pauseReplay').hide();
+ $('#stopReplay').hide();
+ $('#startReplay').show();
+
+ // 恢复图表自动缩放
+ if (tvWidget.mainChart) {
+ tvWidget.mainChart.priceScale().applyOptions({
+ autoScale: true
+ });
+ }
+ }