本地重绘统一冻结视窗;次/次次周期 Trend 上涨下跌使用独立颜色。 Co-authored-by: Cursor <cursoragent@cursor.com>
1111 lines
40 KiB
JavaScript
1111 lines
40 KiB
JavaScript
/* ui.js */
|
||
|
||
/** Trading OS 可消费的威科夫 Cycle 摘要(Confirmed + Live 分区;cycles[0]=ACTIVE) */
|
||
function buildWyckoffCycleSummaryPayload(w, tf) {
|
||
if (!w) return null;
|
||
const cycles = (w.cycles && w.cycles.length)
|
||
? w.cycles
|
||
: (w.trading_range ? [{
|
||
id: 0, status: 'ACTIVE', role: 'latest', lifecycle: w.lifecycle || 'UNKNOWN',
|
||
trading_range: w.trading_range, bias: w.bias,
|
||
phases: w.phases || [], events: w.events || [],
|
||
confirmed: { phases: w.phases || [], events: w.events || [] },
|
||
live: w.live || null,
|
||
confidence: { overall: null },
|
||
period: {
|
||
start_time: w.trading_range.start_time,
|
||
end_time: w.trading_range.end_time,
|
||
bars: w.trading_range.bars
|
||
}
|
||
}] : []);
|
||
if (!cycles.length) return null;
|
||
const active = cycles[0]; // 禁止 cycles[-1]
|
||
const confirmed = active.confirmed || {
|
||
phases: active.phases || w.phases || [],
|
||
events: active.events || w.events || []
|
||
};
|
||
const live = active.live || w.live || null;
|
||
const cPhases = confirmed.phases || [];
|
||
const cEvents = confirmed.events || [];
|
||
const lastPhase = cPhases.length ? cPhases[cPhases.length - 1] : null;
|
||
const lastEvent = cEvents.length ? cEvents[cEvents.length - 1] : null;
|
||
const tr = active.trading_range || {};
|
||
const prev = cycles.length > 1 ? cycles[1] : null;
|
||
const biasLabel = ({
|
||
accumulation: 'Accumulation',
|
||
distribution: 'Distribution',
|
||
unknown: 'Unknown'
|
||
})[active.bias] || (active.bias || 'Unknown');
|
||
const liveCand = (live && live.event_candidates && live.event_candidates[0]) || null;
|
||
const liveConf = live && live.confidence ? live.confidence.overall : null;
|
||
return {
|
||
symbol: (typeof currentData !== 'undefined' && currentData && currentData.symbol) || $('#symbol').val() || '',
|
||
timeframe: (tf || w.timeframe || $('#timeframe').val() || '').toString().toUpperCase(),
|
||
active: {
|
||
cycle_id: active.id != null ? active.id : 0,
|
||
status: active.status || 'ACTIVE',
|
||
lifecycle: active.lifecycle || (live && live.lifecycle) || 'UNKNOWN',
|
||
structure: biasLabel,
|
||
phase_confirmed: lastPhase ? String(lastPhase.phase || '') : null,
|
||
event_confirmed: lastEvent ? String(lastEvent.type || '') : null,
|
||
phase_candidate: live ? live.phase_candidate : null,
|
||
event_candidate: liveCand ? liveCand.type : null,
|
||
event_candidate_confidence: liveCand ? liveCand.confidence : null,
|
||
next_expected: live ? live.next_expected : null,
|
||
range: {
|
||
low: tr.low,
|
||
high: tr.high,
|
||
start_time: (active.period && active.period.start_time) || tr.start_time,
|
||
end_time: (active.period && active.period.end_time) || tr.end_time,
|
||
bars: (active.period && active.period.bars) != null ? active.period.bars : tr.bars
|
||
},
|
||
confidence_confirmed: (active.confidence && active.confidence.overall != null)
|
||
? active.confidence.overall
|
||
: null,
|
||
confidence_live: liveConf
|
||
},
|
||
confirmed_history: cycles.slice(1, 4).map(function(c) {
|
||
const evs = ((c.confirmed && c.confirmed.events) || c.events || [])
|
||
.map(function(e) { return e.type; }).filter(Boolean);
|
||
return {
|
||
cycle_id: c.id,
|
||
structure: ({
|
||
accumulation: 'Accumulation',
|
||
distribution: 'Distribution',
|
||
unknown: 'Unknown'
|
||
})[c.bias] || c.bias,
|
||
events: evs,
|
||
lifecycle: c.lifecycle || 'COMPLETED'
|
||
};
|
||
}),
|
||
live: live,
|
||
cycle_count: cycles.length
|
||
};
|
||
}
|
||
|
||
function _wrLayerTogglesOn(prefix) {
|
||
// prefix: Main | Element | SubSub
|
||
return $('#show' + prefix + 'WrRange').is(':checked')
|
||
|| $('#show' + prefix + 'WrPhases').is(':checked')
|
||
|| $('#show' + prefix + 'WrEvents').is(':checked')
|
||
|| $('#show' + prefix + 'WrVP').is(':checked');
|
||
}
|
||
|
||
/** 面板展示用中文(机器可读 payload 仍保留英文原值) */
|
||
function _wcsLifecycleZh(v) {
|
||
return ({
|
||
UNKNOWN: '未知',
|
||
FORMING: '形成中',
|
||
CONFIRMED: '已确认',
|
||
COMPLETED: '已完成',
|
||
ACTIVE: '当前'
|
||
})[v] || v || '未知';
|
||
}
|
||
|
||
function _wcsStructureZh(v) {
|
||
if (!v) return '—';
|
||
const key = String(v).toLowerCase();
|
||
return ({
|
||
accumulation: '吸筹',
|
||
distribution: '派发',
|
||
unknown: '未知'
|
||
})[key] || ({
|
||
Accumulation: '吸筹',
|
||
Distribution: '派发',
|
||
Unknown: '未知'
|
||
})[v] || v;
|
||
}
|
||
|
||
function _wcsEventZh(v) {
|
||
if (v == null || v === '') return '—';
|
||
return ({
|
||
Spring: '弹簧',
|
||
UTAD: '上升后派发',
|
||
SOS: '强势信号',
|
||
SOW: '弱势信号',
|
||
LPS: '最后支撑',
|
||
LPSY: '最后供应',
|
||
Test: '回测',
|
||
PSY: '初步供应',
|
||
BC: '买气高潮',
|
||
AR: '自动回落',
|
||
ST: '二次测试',
|
||
SC: '卖气高潮'
|
||
})[v] || v;
|
||
}
|
||
|
||
function _htmlWyckoffSummaryBlock(payload, blockClass) {
|
||
if (!payload || !payload.active) return '';
|
||
const a = payload.active;
|
||
const fmtPx = function(v) {
|
||
if (v == null || isNaN(Number(v))) return '—';
|
||
const n = Number(v);
|
||
return n >= 1000 ? n.toFixed(1) : n.toFixed(4);
|
||
};
|
||
const pct = function(v) {
|
||
if (v == null || isNaN(Number(v))) return '—';
|
||
return Math.round(Number(v) * 100) + '%';
|
||
};
|
||
let html = '<div class="wcs-block ' + (blockClass || '') + '">';
|
||
html += '<div class="wcs-title">' + (payload.symbol || '') + ' '
|
||
+ (payload.timeframe || '') + '</div>';
|
||
html += '<div><span class="wcs-badge">当前 C' + a.cycle_id + '</span> '
|
||
+ '<span class="wcs-badge" style="background:#fff8c5;color:#9a6700;">'
|
||
+ _wcsLifecycleZh(a.lifecycle) + '</span></div>';
|
||
html += '<div class="wcs-active">';
|
||
html += '<div class="wcs-row"><span class="wcs-k">结构</span><span class="wcs-v">'
|
||
+ _wcsStructureZh(a.structure) + '</span></div>';
|
||
html += '<div class="wcs-row"><span class="wcs-k">阶段</span><span class="wcs-v">'
|
||
+ (a.phase_candidate
|
||
? ('阶段 ' + a.phase_candidate + '(候选)')
|
||
: (a.phase_confirmed ? ('阶段 ' + a.phase_confirmed) : '—'))
|
||
+ '</span></div>';
|
||
html += '<div class="wcs-row"><span class="wcs-k">事件</span><span class="wcs-v">'
|
||
+ (a.event_candidate
|
||
? (_wcsEventZh(a.event_candidate) + '(候选)')
|
||
: _wcsEventZh(a.event_confirmed))
|
||
+ '</span></div>';
|
||
if (a.event_confirmed && a.event_candidate) {
|
||
html += '<div class="wcs-row"><span class="wcs-k">已确认</span><span class="wcs-v">'
|
||
+ _wcsEventZh(a.event_confirmed) + '</span></div>';
|
||
}
|
||
html += '<div class="wcs-row"><span class="wcs-k">区间</span><span class="wcs-v">'
|
||
+ fmtPx(a.range && a.range.low) + ' – ' + fmtPx(a.range && a.range.high) + '</span></div>';
|
||
html += '<div class="wcs-row"><span class="wcs-k">置信度</span><span class="wcs-v">'
|
||
+ pct(a.confidence_live != null ? a.confidence_live : a.confidence_confirmed) + '</span></div>';
|
||
if (a.next_expected) {
|
||
html += '<div class="wcs-row"><span class="wcs-k">下一步</span><span class="wcs-v">'
|
||
+ _wcsEventZh(a.next_expected) + '</span></div>';
|
||
}
|
||
html += '</div>';
|
||
if (payload.confirmed_history && payload.confirmed_history.length) {
|
||
html += '<div class="wcs-prev"><div style="margin-bottom:2px;">已确认历史</div>';
|
||
payload.confirmed_history.forEach(function(h) {
|
||
const ev = (h.events && h.events.length)
|
||
? h.events.map(_wcsEventZh).join('、')
|
||
: '—';
|
||
html += '<div>C' + h.cycle_id + ' ' + _wcsStructureZh(h.structure) + ' · ' + ev + '</div>';
|
||
});
|
||
html += '</div>';
|
||
}
|
||
html += '</div>';
|
||
return html;
|
||
}
|
||
|
||
function renderWyckoffCycleSummary() {
|
||
const $el = $('#wyckoffCycleSummary');
|
||
if (!$el.length) return;
|
||
if (!currentData) {
|
||
$el.hide().empty();
|
||
window.wyckoffCycleSummary = null;
|
||
return;
|
||
}
|
||
const layers = [];
|
||
if (_wrLayerTogglesOn('Main') && currentData.wyckoff) {
|
||
layers.push({
|
||
key: 'main',
|
||
cls: 'wcs-main',
|
||
payload: buildWyckoffCycleSummaryPayload(
|
||
currentData.wyckoff,
|
||
currentData.timeframe || currentData.wyckoff.timeframe || $('#timeframe').val()
|
||
)
|
||
});
|
||
}
|
||
if (_wrLayerTogglesOn('Element') && currentData.element_wyckoff) {
|
||
layers.push({
|
||
key: 'element',
|
||
cls: 'wcs-element',
|
||
payload: buildWyckoffCycleSummaryPayload(
|
||
currentData.element_wyckoff,
|
||
currentData.element_timeframe || currentData.element_wyckoff.timeframe || $('#elementTimeframe').val()
|
||
)
|
||
});
|
||
}
|
||
if (_wrLayerTogglesOn('SubSub') && currentData.sub_sub_wyckoff) {
|
||
layers.push({
|
||
key: 'sub_sub',
|
||
cls: 'wcs-subsub',
|
||
payload: buildWyckoffCycleSummaryPayload(
|
||
currentData.sub_sub_wyckoff,
|
||
currentData.sub_sub_timeframe || currentData.sub_sub_wyckoff.timeframe || $('#subSubTimeframe').val()
|
||
)
|
||
});
|
||
}
|
||
const valid = layers.filter(function(L) { return L.payload && L.payload.active; });
|
||
if (!valid.length) {
|
||
$el.hide().empty();
|
||
window.wyckoffCycleSummary = null;
|
||
return;
|
||
}
|
||
const bag = {};
|
||
let html = '';
|
||
valid.forEach(function(L) {
|
||
bag[L.key] = L.payload;
|
||
html += _htmlWyckoffSummaryBlock(L.payload, L.cls);
|
||
});
|
||
window.wyckoffCycleSummary = bag;
|
||
$el.html(html).show();
|
||
}
|
||
|
||
function loadSymbols() {
|
||
$.get('/api/symbols', function(data) {
|
||
if (Array.isArray(data)) {
|
||
const $select = $('#symbol');
|
||
const currentSymbol = $select.val(); // 保存当前选中的值
|
||
$select.empty();
|
||
|
||
data.forEach(function(symbol) {
|
||
$select.append($('<option>', {
|
||
value: symbol,
|
||
text: symbol
|
||
}));
|
||
});
|
||
|
||
// 如果有保存的选中值,恢复它
|
||
if (currentSymbol && data.includes(currentSymbol)) {
|
||
$select.val(currentSymbol);
|
||
} else {
|
||
// 设置默认值为BTC/USDT:USDT
|
||
$select.val('BTC/USDT:USDT');
|
||
}
|
||
}
|
||
});
|
||
}
|
||
|
||
// 设置默认时间范围:最近 1 个月
|
||
function setDefaultTimeRange() {
|
||
const now = new Date();
|
||
const daysBack = 30;
|
||
const start = new Date(now.getTime() - (daysBack * 24 * 60 * 60 * 1000));
|
||
|
||
// 格式化为datetime-local输入框所需的格式 YYYY-MM-DDThh:mm
|
||
$('#end_time').val(formatDatetimeLocal(now));
|
||
$('#start_time').val(formatDatetimeLocal(start));
|
||
}
|
||
// 格式化日期为datetime-local输入框格式
|
||
function formatDatetimeLocal(date) {
|
||
const year = date.getFullYear();
|
||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||
const day = String(date.getDate()).padStart(2, '0');
|
||
const hours = String(date.getHours()).padStart(2, '0');
|
||
const minutes = String(date.getMinutes()).padStart(2, '0');
|
||
|
||
return `${year}-${month}-${day}T${hours}:${minutes}`;
|
||
}
|
||
// 页面加载时初始化
|
||
$(document).ready(function() {
|
||
// 从本地存储中恢复时区设置
|
||
const savedTimezone = localStorage.getItem('selectedTimezone');
|
||
if (savedTimezone) {
|
||
$('#timezone').val(savedTimezone);
|
||
console.log('从本地存储恢复时区设置:', savedTimezone);
|
||
}
|
||
|
||
// 初始化数据源切换:先按数据源重新拉取周期元信息,再切换 UI
|
||
$('#dataSource').on('change', function() {
|
||
const dataSource = $(this).val();
|
||
const apiSrc = dataSource === 'a_stock' ? 'a_stock' : 'crypto';
|
||
$.getJSON('/api/chart_metadata', { source: apiSrc })
|
||
.done(function(meta) {
|
||
applyChartMetadata(meta);
|
||
})
|
||
.always(function() {
|
||
if (dataSource === 'crypto') {
|
||
$('#cryptoSymbolContainer').show();
|
||
$('#astockSymbolContainer').hide();
|
||
if (window.astockStatusInterval) {
|
||
clearInterval(window.astockStatusInterval);
|
||
window.astockStatusInterval = null;
|
||
}
|
||
loadSymbols();
|
||
} else if (dataSource === 'a_stock') {
|
||
$('#cryptoSymbolContainer').hide();
|
||
$('#astockSymbolContainer').show();
|
||
loadAStockSymbols();
|
||
startAStockStatusUpdater();
|
||
}
|
||
});
|
||
});
|
||
|
||
// 检查初始数据源设置
|
||
const initialDataSource = $('#dataSource').val();
|
||
if (initialDataSource === 'a_stock') {
|
||
$.getJSON('/api/chart_metadata', { source: 'a_stock' })
|
||
.done(function(meta) {
|
||
applyChartMetadata(meta);
|
||
})
|
||
.always(function() {
|
||
loadAStockSymbols();
|
||
startAStockStatusUpdater();
|
||
// A 股:metadata 完成后再拉数(下方不再重复 updateChart)
|
||
setTimeout(function() {
|
||
analyzeChart({ reason: 'astock-init' });
|
||
}, 300);
|
||
});
|
||
}
|
||
// 加密货币:统一在文末单次 updateChart,避免重复请求
|
||
|
||
// 初始化交易对下拉菜单
|
||
$('#symbol').val('BTC/USDT:USDT');
|
||
$('#astockSymbol').val('000001');
|
||
if (initialDataSource !== 'a_stock') {
|
||
const mainDefault = window.DEFAULT_MAIN_TIMEFRAME || $('#timeframe option:first').val();
|
||
const elementDefault = window.DEFAULT_ELEMENT_TIMEFRAME || $('#elementTimeframe option:first').val();
|
||
if (mainDefault) {
|
||
$('#timeframe').val(mainDefault);
|
||
}
|
||
if (elementDefault) {
|
||
$('#elementTimeframe').val(elementDefault);
|
||
}
|
||
}
|
||
|
||
// 测试打印时区偏移量
|
||
console.log('当前时区偏移量 (UTC+8):', getTimezoneOffset('Asia/Shanghai'));
|
||
console.log('当前时区偏移量 (UTC):', getTimezoneOffset('UTC'));
|
||
|
||
const now = new Date();
|
||
console.log('当前时间UTC:', now.toUTCString());
|
||
console.log('当前时间本地:', now.toString());
|
||
console.log('当前时间戳(秒):', now.getTime()/1000);
|
||
console.log('UTC时间戳:', Math.floor(now.getTime()/1000));
|
||
|
||
// 设置默认时间范围
|
||
setDefaultTimeRange();
|
||
|
||
// 默认禁用买卖点显示
|
||
$('#showTradePoints').prop('checked', false);
|
||
|
||
// 尝试加载更多交易对
|
||
loadSymbols();
|
||
|
||
// 初始化图表:默认加密货币延迟拉取;若首屏为 A 股则在 chart_metadata 完成后再 analyze
|
||
if (initialDataSource !== 'a_stock') {
|
||
setTimeout(function() {
|
||
analyzeChart({ reason: 'crypto-init' });
|
||
}, 500);
|
||
}
|
||
|
||
// 初始化自动刷新功能
|
||
initAutoRefresh();
|
||
|
||
// 确保在文档加载完成后初始化时区设置
|
||
// 默认设置为Shanghai时区
|
||
if (!$('#timezone').val()) {
|
||
$('#timezone').val('Asia/Shanghai');
|
||
}
|
||
|
||
// 记录当前时区设置
|
||
console.log('页面加载完成,当前时区设置:', $('#timezone').val());
|
||
|
||
// 添加自定义事件处理 - 让时区选择变更立即生效
|
||
$('#timezone').on('change', function() {
|
||
const newTimezone = $(this).val();
|
||
console.log('时区已更改为:', newTimezone);
|
||
|
||
// 保存到本地存储,下次访问时自动使用
|
||
localStorage.setItem('selectedTimezone', newTimezone);
|
||
|
||
// 如果已有数据,重新渲染图表和表格
|
||
if (currentData) {
|
||
// 先销毁现有图表实例
|
||
if (tvWidget.mainChart) {
|
||
try {
|
||
// 清理EMA52系列
|
||
clearEMA52Series();
|
||
// 销毁主图表及其关联的线系列
|
||
tvWidget.mainChart = null;
|
||
tvWidget.volumeChart = null;
|
||
tvWidget.atrChart = null;
|
||
tvWidget.macdChart = null;
|
||
// 重置系列数据
|
||
tvWidget.series = {
|
||
candleSeries: null,
|
||
lineSeries: null,
|
||
barSeries: null,
|
||
areaSeries: null,
|
||
baselineSeries: null,
|
||
renkoSeries: null,
|
||
volumeSeries: null,
|
||
atrLineSeries: null,
|
||
macdLineSeries: null,
|
||
signalLineSeries: null,
|
||
histogramSeries: null,
|
||
mainBiSeries: [],
|
||
mainSegSeries: [],
|
||
mainZsSeries: [],
|
||
mainUncompletedZsSeries: [],
|
||
elementBiSeries: [],
|
||
elementSegSeries: [],
|
||
elementZsSeries: [],
|
||
elementUncompletedZsSeries: [],
|
||
tradePointSeries: [],
|
||
mainBollingerSeries: [],
|
||
elementBollingerSeries: [],
|
||
maSeries: [], // 添加均线系列
|
||
bbSeries: [], // 添加布林带系列
|
||
ema52Series: [] // 添加EMA52系列数组
|
||
};
|
||
} catch (e) {
|
||
console.error('销毁图表错误:', e);
|
||
}
|
||
}
|
||
|
||
// 使用新的时区重新初始化图表
|
||
initTradingView($('#symbol').val(), $('#timeframe').val());
|
||
|
||
// 重新渲染图表数据
|
||
renderChart();
|
||
|
||
// 更新表格
|
||
updateTables(currentData);
|
||
}
|
||
});
|
||
|
||
// 页面加载完成后初始化
|
||
$(document).ready(function() {
|
||
// 设置默认的筛选时间(最近7天)
|
||
const now = new Date();
|
||
const weekAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
|
||
|
||
|
||
|
||
// 添加页面滚动事件监听器,清除十字线延长线
|
||
$(window).on('scroll', function() {
|
||
try {
|
||
// 清除所有十字线延长线,防止它们跟着页面滚动
|
||
const existingVolumeLines = document.querySelectorAll('.volume-crosshair-line');
|
||
existingVolumeLines.forEach(line => line.remove());
|
||
const existingAtrLines = document.querySelectorAll('.atr-crosshair-line');
|
||
existingAtrLines.forEach(line => line.remove());
|
||
const existingMacdLines = document.querySelectorAll('.macd-crosshair-line');
|
||
existingMacdLines.forEach(line => line.remove());
|
||
const existingChanMacdLines = document.querySelectorAll('.chanmacd-crosshair-line');
|
||
existingChanMacdLines.forEach(line => line.remove());
|
||
} catch (e) {
|
||
console.debug('清除滚动中的十字线时出错:', e);
|
||
}
|
||
});
|
||
|
||
|
||
});
|
||
});
|
||
// 自动刷新相关变量
|
||
let autoRefreshTimer = null;
|
||
let nextRefreshTime = null;
|
||
let autoRefreshTick = 0;
|
||
/** 自动刷新时,缠论全量重算间隔(毫秒);时间戳见 window._lastFullAnalyzeAt */
|
||
const AUTO_FULL_ANALYZE_MS = 60 * 1000;
|
||
|
||
// 初始化自动刷新功能
|
||
function initAutoRefresh() {
|
||
// 监听自动刷新勾选框变化
|
||
$('#autoRefresh').change(function() {
|
||
if ($(this).is(':checked')) {
|
||
startAutoRefresh();
|
||
} else {
|
||
stopAutoRefresh();
|
||
}
|
||
});
|
||
|
||
// 监听刷新频率变化
|
||
$('#refreshInterval').change(function() {
|
||
if ($('#autoRefresh').is(':checked')) {
|
||
// 如果自动刷新已开启,重启定时器
|
||
stopAutoRefresh();
|
||
startAutoRefresh();
|
||
}
|
||
});
|
||
}
|
||
// 开始自动刷新
|
||
function startAutoRefresh() {
|
||
// 停止已有的刷新定时器
|
||
stopAutoRefresh();
|
||
|
||
// 获取刷新频率(分钟)
|
||
const interval = parseFloat($('#refreshInterval').val()) || (5 / 60);
|
||
const intervalMs = interval * 60 * 1000;
|
||
|
||
console.log(`开始自动刷新,频率: ${interval}分钟 (${intervalMs}毫秒);缠论全量每 ${AUTO_FULL_ANALYZE_MS / 1000}s`);
|
||
|
||
// 计算下次刷新时间
|
||
nextRefreshTime = new Date(Date.now() + intervalMs);
|
||
updateNextRefreshTimeDisplay();
|
||
|
||
// 启动定时器
|
||
autoRefreshTick = 0;
|
||
|
||
// 进入实时模式:结束时间=现在,立刻走自动刷新全量(视窗由 autoRefreshChart 内 freeze 冻结)
|
||
updateEndTimeToNow();
|
||
autoRefreshChart({
|
||
mode: 'full',
|
||
incremental: false,
|
||
forceFullRebuild: true,
|
||
reason: 'auto-refresh-start'
|
||
});
|
||
|
||
autoRefreshTimer = setInterval(function() {
|
||
// 自动刷新专用:结束时间推进到现在
|
||
updateEndTimeToNow();
|
||
|
||
autoRefreshTick += 1;
|
||
const now = Date.now();
|
||
const lastFull = window._lastFullAnalyzeAt || 0;
|
||
const tf = $('#timeframe').val() || '4h';
|
||
const needFull = !lastFull || (now - lastFull >= AUTO_FULL_ANALYZE_MS) ||
|
||
(typeof isLiveBaselineStale === 'function' && isLiveBaselineStale(tf));
|
||
|
||
if (needFull) {
|
||
console.log('自动刷新 tick → live 全量');
|
||
autoRefreshChart({ mode: 'full', incremental: false, forceFullRebuild: true });
|
||
} else {
|
||
console.log('自动刷新 tick → recent 尾部');
|
||
autoRefreshChart({ mode: 'recent' });
|
||
}
|
||
|
||
nextRefreshTime = new Date(Date.now() + intervalMs);
|
||
updateNextRefreshTimeDisplay();
|
||
}, intervalMs);
|
||
|
||
// 启动倒计时显示
|
||
startCountdownDisplay();
|
||
|
||
// 显示下次刷新时间
|
||
$('#nextRefreshTime').show();
|
||
}
|
||
|
||
// 更新结束时间为当前时间
|
||
function updateEndTimeToNow() {
|
||
const now = new Date();
|
||
$('#end_time').val(formatDatetimeLocal(now));
|
||
console.log('已更新结束时间为当前时间:', formatDatetimeLocal(now));
|
||
}
|
||
|
||
// 停止自动刷新
|
||
function stopAutoRefresh() {
|
||
if (autoRefreshTimer) {
|
||
clearInterval(autoRefreshTimer);
|
||
autoRefreshTimer = null;
|
||
}
|
||
|
||
// 停止倒计时显示
|
||
clearInterval(countdownTimer);
|
||
countdownTimer = null;
|
||
|
||
// 隐藏下次刷新时间
|
||
$('#nextRefreshTime').hide();
|
||
}
|
||
|
||
// 更新下次刷新时间显示
|
||
function updateNextRefreshTimeDisplay() {
|
||
if (!nextRefreshTime) return;
|
||
|
||
const timeStr = nextRefreshTime.toLocaleTimeString();
|
||
$('#nextRefreshTime').text(`下次刷新: ${timeStr}`);
|
||
}
|
||
// 倒计时定时器
|
||
let countdownTimer = null;
|
||
|
||
// 启动倒计时显示
|
||
function startCountdownDisplay() {
|
||
// 清除已有的倒计时
|
||
if (countdownTimer) {
|
||
clearInterval(countdownTimer);
|
||
}
|
||
|
||
// 启动新的倒计时,每秒更新一次
|
||
countdownTimer = setInterval(function() {
|
||
if (!nextRefreshTime) return;
|
||
|
||
const now = new Date();
|
||
const diffMs = nextRefreshTime - now;
|
||
|
||
if (diffMs <= 0) {
|
||
// 已经到达或超过刷新时间,等待刷新发生
|
||
$('#nextRefreshTime').text('正在刷新...');
|
||
} else {
|
||
// 计算剩余时间
|
||
const diffSec = Math.floor(diffMs / 1000);
|
||
|
||
// 如果时间超过1分钟,显示分和秒
|
||
if (diffSec >= 60) {
|
||
const minutes = Math.floor(diffSec / 60);
|
||
const seconds = diffSec % 60;
|
||
// 格式化显示
|
||
const timeStr = `${minutes}分${seconds.toString().padStart(2, '0')}秒后刷新`;
|
||
$('#nextRefreshTime').text(timeStr);
|
||
} else {
|
||
// 少于1分钟只显示秒数
|
||
const timeStr = `${diffSec}秒后刷新`;
|
||
$('#nextRefreshTime').text(timeStr);
|
||
}
|
||
}
|
||
}, 1000);
|
||
}
|
||
|
||
// 将时间周期映射到数值(保留此函数以供后端API调用)
|
||
function mapTimeframeToInterval(timeframe) {
|
||
const mapping = {
|
||
'1m': '1',
|
||
'3m': '3',
|
||
'5m': '5',
|
||
'15m': '15',
|
||
'30m': '30',
|
||
'1h': '60',
|
||
'2h': '120',
|
||
'4h': '240',
|
||
'6h': '360',
|
||
'8h': '480',
|
||
'12h': '720',
|
||
'1d': 'D',
|
||
'3d': '3D',
|
||
'1w': 'W',
|
||
'1M': 'M'
|
||
};
|
||
return mapping[timeframe] || '5';
|
||
}
|
||
|
||
// 只重绘分形元素(笔、线段、中枢),保留现有的K线、MACD和成交量
|
||
function redrawFractalElements() {
|
||
if (!tvWidget || !tvWidget.mainChart) return;
|
||
|
||
// 确保使用主周期的K线和MACD数据
|
||
if (currentData.original_kline_data) {
|
||
currentData.kline_data = currentData.original_kline_data;
|
||
}
|
||
if (currentData.original_macd) {
|
||
currentData.macd = currentData.original_macd;
|
||
}
|
||
delete currentData.original_kline_data;
|
||
delete currentData.original_macd;
|
||
|
||
if (typeof reinitTradingViewPreservingViewport === 'function') {
|
||
reinitTradingViewPreservingViewport();
|
||
} else {
|
||
initTradingView($('#symbol').val(), $('#timeframe').val());
|
||
}
|
||
}
|
||
// 只更新分形元素(笔、线段、中枢)的表格数据
|
||
function updateFractalTables() {
|
||
if (!currentData) return;
|
||
|
||
const data = currentData;
|
||
|
||
// 笔数据表更新
|
||
if (tables.bi) {
|
||
tables.bi.clear().destroy();
|
||
}
|
||
|
||
// 使用小周期笔数据(如果存在)
|
||
const biData = data.element_bi_list || data.bi_list;
|
||
const biSource = data.element_bi_list ? '元素周期' : '主周期';
|
||
console.log(`表格显示${biSource}笔数据,共${biData ? biData.length : 0}条`);
|
||
|
||
tables.bi = $('#biTable').DataTable({
|
||
data: biData || [],
|
||
order: [[0, 'desc']],
|
||
pageLength: 25,
|
||
columns: [
|
||
{ data: 'start_time', render: formatTime },
|
||
{ data: 'end_time', render: formatTime },
|
||
{ data: 'sure_time', render: formatConfirmTime },
|
||
{ data: 'start_price', render: formatPrice },
|
||
{ data: 'end_price', render: formatPrice },
|
||
{ data: 'direction', render: formatDirection },
|
||
{ data: 'macd_div', render: formatMacdValue }
|
||
]
|
||
});
|
||
|
||
// 线段数据表更新
|
||
if (tables.seg) {
|
||
tables.seg.clear().destroy();
|
||
}
|
||
|
||
// 使用小周期线段数据(如果存在)
|
||
const segData = data.element_seg_list || data.seg_list;
|
||
const segSource = data.element_seg_list ? '元素周期' : '主周期';
|
||
console.log(`表格显示${segSource}线段数据,共${segData ? segData.length : 0}条`);
|
||
|
||
tables.seg = $('#segTable').DataTable({
|
||
data: segData || [],
|
||
order: [[0, 'desc']],
|
||
pageLength: 25,
|
||
columns: [
|
||
{ data: 'start_time', render: formatTime },
|
||
{ data: 'end_time', render: formatTime },
|
||
{ data: 'sure_time', render: formatConfirmTime },
|
||
{ data: 'start_price', render: formatPrice },
|
||
{ data: 'end_price', render: formatPrice },
|
||
{ data: 'direction', render: formatDirection }
|
||
]
|
||
});
|
||
|
||
// 中枢数据表更新
|
||
if (tables.zs) {
|
||
tables.zs.clear().destroy();
|
||
}
|
||
|
||
// 使用小周期中枢数据(如果存在)
|
||
const zsData = data.element_zs_list || data.zs_list;
|
||
const zsSource = data.element_zs_list ? '元素周期' : '主周期';
|
||
console.log(`表格显示${zsSource}中枢数据,共${zsData ? zsData.length : 0}条`);
|
||
|
||
tables.zs = $('#zsTable').DataTable({
|
||
data: zsData || [],
|
||
order: [[0, 'desc']],
|
||
pageLength: 25,
|
||
columns: [
|
||
{ data: 'start_time', render: formatTime },
|
||
{ data: 'end_time', render: formatTime },
|
||
{ data: 'zg', render: formatPrice },
|
||
{ data: 'zd', render: formatPrice }
|
||
]
|
||
});
|
||
|
||
// 更新数据源信息
|
||
setupDataSourceInfo(data);
|
||
}
|
||
|
||
// 刷新图表并更新表格
|
||
function refreshChart(data, options) {
|
||
// 检查是否接收到数据
|
||
if (!data) {
|
||
console.error('未收到数据,无法刷新图表');
|
||
return;
|
||
}
|
||
|
||
if (data.element_timeframe) {
|
||
$('#elementTimeframe').val(data.element_timeframe);
|
||
}
|
||
|
||
options = options || {};
|
||
const preferIncremental = !!options.incremental;
|
||
const chartsReady = tvWidget && tvWidget.state && tvWidget.state.isInitialized && tvWidget.mainChart;
|
||
|
||
// 自动刷新:增量更新,避免每次销毁/重建 Lightweight Charts
|
||
if (preferIncremental && chartsReady) {
|
||
try {
|
||
// 视窗已在请求发出时 freezeChartViewportBeforeRequest 冻结,勿在此重拍(会弄错 barCount)
|
||
updateTradingViewData({ tailOnly: !!options.skipTables });
|
||
// recent-tail 刷新结构未变,跳过表格重绘以提速
|
||
if (!options.skipTables) {
|
||
updateTables(data);
|
||
}
|
||
if (currentData && currentData.ema52_dict) {
|
||
updateEMA52Display(currentData);
|
||
}
|
||
return;
|
||
} catch (e) {
|
||
console.warn('增量刷新失败,回退全量重建:', e);
|
||
}
|
||
}
|
||
|
||
// 全量重建:优先用请求前冻结的视窗(分析按钮在请求发出时已 capture)
|
||
if (typeof ensurePendingChartViewportBeforeInit === 'function') {
|
||
ensurePendingChartViewportBeforeInit();
|
||
if (window._preserveViewOnRefresh) {
|
||
console.log('📌 全量重建:使用请求前冻结视窗');
|
||
} else if (window._pendingRestoreView) {
|
||
console.log('📌 使用已保存图表视图');
|
||
}
|
||
} else if (window._preserveViewOnRefresh) {
|
||
window._pendingRestoreView = window._preserveViewOnRefresh;
|
||
console.log('📌 全量重建:使用请求前冻结视窗');
|
||
} else if (!window._pendingRestoreView && tvWidget && tvWidget.mainChart) {
|
||
try {
|
||
window._pendingRestoreView = captureChartViewState(tvWidget.mainChart);
|
||
console.log('📌 保存图表视图:', JSON.stringify(window._pendingRestoreView));
|
||
} catch (e) {
|
||
console.warn('保存图表视图失败:', e);
|
||
window._pendingRestoreView = null;
|
||
}
|
||
} else if (window._pendingRestoreView) {
|
||
console.log('📌 使用已保存图表视图:', JSON.stringify(window._pendingRestoreView));
|
||
}
|
||
|
||
initTradingView($('#symbol').val(), $('#timeframe').val());
|
||
|
||
// 更新表格数据
|
||
updateTables(data);
|
||
|
||
if (currentData && currentData.ema52_dict) {
|
||
updateEMA52Display(currentData);
|
||
}
|
||
|
||
}
|
||
|
||
function refreshChartOnly() {
|
||
// 仅使用当前数据刷新图表显示,不从服务器加载新数据
|
||
if (currentData) {
|
||
console.log('仅刷新图表显示,不重新获取数据');
|
||
refreshChart(currentData);
|
||
} else {
|
||
console.log('没有当前数据,无法刷新显示');
|
||
}
|
||
}
|
||
|
||
// 绑定主周期MACD背离显示开关
|
||
$('#showMainMacdDiv').change(function() {
|
||
refreshChartOnly();
|
||
});
|
||
|
||
// 绑定次周期MACD背离显示开关
|
||
$('#showElementMacdDiv').change(function() {
|
||
refreshChartOnly();
|
||
});
|
||
|
||
// 绑定分型类型显示开关(与笔一致:全量重建,避免增量路径标记未对齐)
|
||
$('#showKlcFxType').change(function() {
|
||
updateChartDisplay();
|
||
});
|
||
|
||
// 绑定小周期分型显示开关
|
||
$('#showElementKlcFxType').change(function() {
|
||
updateChartDisplay();
|
||
});
|
||
|
||
|
||
// 绑定布林带显示变更事件
|
||
$('#showMainBollinger').change(function() {
|
||
updateChartDisplay();
|
||
});
|
||
|
||
$('#showElementBollinger').change(function() {
|
||
updateChartDisplay();
|
||
});
|
||
|
||
// K线周期切换由 macd_ui.js 统一走 updateChartDisplay(勿再绑 refreshChart,会重复且易漏对齐)
|
||
|
||
// 绑定主图U显示开关
|
||
$('#toggleUOnMain').change(function() {
|
||
window.showUOnMain = $('#toggleUOnMain').is(':checked');
|
||
refreshChartOnly();
|
||
});
|
||
|
||
// 次周期 U 显示开关
|
||
$('#toggleUOnElement').change(function() {
|
||
window.showUOnElement = $('#toggleUOnElement').is(':checked');
|
||
refreshChartOnly();
|
||
});
|
||
|
||
// 买卖点显示开关
|
||
$('#showMainBsp').change(function() {
|
||
updateChartDisplay();
|
||
});
|
||
$('#showElementBsp').change(function() {
|
||
updateChartDisplay();
|
||
});
|
||
|
||
// 在控制台输出当前显示状态
|
||
console.log('当前显示状态:', {
|
||
'showOriginalKline': $('#showOriginalKline').is(':checked'),
|
||
'showMainBi': $('#showMainBi').is(':checked'),
|
||
'showMainSeg': $('#showMainSeg').is(':checked'),
|
||
'showMainZs': $('#showMainZs').is(':checked'),
|
||
'showVolume': false,
|
||
'showMacd': $('#showMacd').is(':checked'),
|
||
'showKlcFxType': $('#showKlcFxType').is(':checked'),
|
||
'showKluFxType': $('#showKluFxType').is(':checked'),
|
||
'showElementKlcFxType': $('#showElementKlcFxType').is(':checked'),
|
||
'showElementKluFxType': $('#showElementKluFxType').is(':checked'),
|
||
'showTradePoints': $('#showTradePoints').is(':checked'),
|
||
'showMainBollinger': $('#showMainBollinger').is(':checked'),
|
||
'showElementBollinger': $('#showElementBollinger').is(':checked'),
|
||
'timeframe': $('#timeframe').val(),
|
||
'elementTimeframe': $('#elementTimeframe').val(),
|
||
'timezone': $('#timezone').val(),
|
||
'start_time': $('#start_time').val(),
|
||
'end_time': $('#end_time').val()
|
||
});
|
||
|
||
// 初始化提示工具
|
||
var tooltipTriggerList = [].slice.call(document.querySelectorAll('[data-bs-toggle="tooltip"]'))
|
||
var tooltipList = tooltipTriggerList.map(function (tooltipTriggerEl) {
|
||
return new bootstrap.Tooltip(tooltipTriggerEl)
|
||
})
|
||
|
||
|
||
// 获取A股股票列表(全市场,来自 /api/a_stocks)
|
||
function loadAStockSymbols() {
|
||
const $select = $('#astockSymbol');
|
||
const currentSymbol = $select.val();
|
||
$select.prop('disabled', true);
|
||
$.get('/api/a_stocks', function(data) {
|
||
$select.prop('disabled', false);
|
||
if (!Array.isArray(data)) {
|
||
console.error('加载A股股票列表失败: 返回非数组', data);
|
||
return;
|
||
}
|
||
$select.empty();
|
||
data.forEach(function(stock) {
|
||
$select.append($('<option>', {
|
||
value: stock.symbol,
|
||
text: stock.symbol + ' - ' + (stock.name || '')
|
||
}));
|
||
});
|
||
if (currentSymbol && data.some(stock => stock.symbol === currentSymbol)) {
|
||
$select.val(currentSymbol);
|
||
} else {
|
||
$select.val('000001');
|
||
}
|
||
}).fail(function(xhr) {
|
||
$select.prop('disabled', false);
|
||
console.error('加载A股股票列表失败', xhr && xhr.status);
|
||
});
|
||
}
|
||
// 检测交易对类型并返回相应的配置
|
||
function getSymbolConfig(symbol) {
|
||
const isAStock = symbol && symbol.length === 6 && /^\d+$/.test(symbol);
|
||
|
||
if (isAStock) {
|
||
return {
|
||
type: 'a_stock',
|
||
displayName: symbol,
|
||
tradingSessions: [
|
||
// A股交易时间配置
|
||
{ start: '09:30', end: '11:30' }, // 上午
|
||
{ start: '13:00', end: '15:00' } // 下午
|
||
],
|
||
timezone: 'Asia/Shanghai',
|
||
// A股的交易日配置(周一到周五,除节假日)
|
||
tradingDays: [1, 2, 3, 4, 5] // 1=周一, 7=周日
|
||
};
|
||
} else {
|
||
return {
|
||
type: 'crypto',
|
||
displayName: symbol,
|
||
tradingSessions: [
|
||
{ start: '00:00', end: '23:59' } // 24小时交易
|
||
],
|
||
timezone: 'UTC',
|
||
tradingDays: [1, 2, 3, 4, 5, 6, 7] // 7天交易
|
||
};
|
||
}
|
||
}
|
||
|
||
// 根据交易对类型调整图表配置
|
||
function adjustChartForSymbolType(chartOptions, symbolConfig) {
|
||
if (symbolConfig.type === 'a_stock') {
|
||
// A股特殊配置
|
||
chartOptions.timeScale = {
|
||
...chartOptions.timeScale,
|
||
// 禁用非交易时间的显示
|
||
borderVisible: true,
|
||
borderColor: '#ddd',
|
||
// 自定义时间格式化,只显示交易时间
|
||
timeVisible: true,
|
||
// 添加A股特定的时间范围限制
|
||
rightOffset: 12,
|
||
barSpacing: 6,
|
||
minBarSpacing: 3,
|
||
};
|
||
|
||
// 添加A股交易时间提示
|
||
chartOptions.layout = {
|
||
...chartOptions.layout,
|
||
fontSize: 12,
|
||
fontFamily: 'Arial, sans-serif'
|
||
};
|
||
}
|
||
|
||
return chartOptions;
|
||
}
|
||
// 过滤非交易时间的数据(仅用于显示优化)
|
||
function filterTradingHours(data, symbolConfig) {
|
||
if (symbolConfig.type !== 'a_stock') {
|
||
return data; // 非A股数据不需要过滤
|
||
}
|
||
|
||
return data.filter(item => {
|
||
const date = new Date(item.time * 1000);
|
||
const hour = date.getHours();
|
||
const minute = date.getMinutes();
|
||
const timeStr = `${hour.toString().padStart(2, '0')}:${minute.toString().padStart(2, '0')}`;
|
||
|
||
// 检查是否在交易时间内
|
||
return symbolConfig.tradingSessions.some(session => {
|
||
return timeStr >= session.start && timeStr <= session.end;
|
||
});
|
||
});
|
||
}
|
||
|
||
// 更新A股交易时间状态
|
||
function updateAStockTradingStatus() {
|
||
const now = new Date();
|
||
const chinaTime = new Date(now.toLocaleString("en-US", {timeZone: "Asia/Shanghai"}));
|
||
const hour = chinaTime.getHours();
|
||
const minute = chinaTime.getMinutes();
|
||
const dayOfWeek = chinaTime.getDay(); // 0=周日, 1=周一, ..., 6=周六
|
||
|
||
const statusElement = document.getElementById('tradingTimeStatus');
|
||
if (!statusElement) return;
|
||
|
||
// 检查是否为交易日(周一到周五)
|
||
const isTradingDay = dayOfWeek >= 1 && dayOfWeek <= 5;
|
||
|
||
if (!isTradingDay) {
|
||
statusElement.className = 'badge bg-secondary';
|
||
statusElement.textContent = '非交易日';
|
||
return;
|
||
}
|
||
|
||
// 检查是否在交易时间内
|
||
const currentTime = hour * 60 + minute; // 转换为分钟
|
||
const morningStart = 9 * 60 + 30; // 09:30
|
||
const morningEnd = 11 * 60 + 30; // 11:30
|
||
const afternoonStart = 13 * 60; // 13:00
|
||
const afternoonEnd = 15 * 60; // 15:00
|
||
|
||
let status = '';
|
||
let className = '';
|
||
|
||
if (currentTime >= morningStart && currentTime <= morningEnd) {
|
||
status = '上午交易中';
|
||
className = 'badge bg-success';
|
||
} else if (currentTime >= afternoonStart && currentTime <= afternoonEnd) {
|
||
status = '下午交易中';
|
||
className = 'badge bg-success';
|
||
} else if (currentTime > morningEnd && currentTime < afternoonStart) {
|
||
status = '午间休市';
|
||
className = 'badge bg-warning';
|
||
} else if (currentTime < morningStart) {
|
||
status = '开盘前';
|
||
className = 'badge bg-info';
|
||
} else if (currentTime > afternoonEnd) {
|
||
status = '收盘后';
|
||
className = 'badge bg-dark';
|
||
} else {
|
||
status = '非交易时间';
|
||
className = 'badge bg-secondary';
|
||
}
|
||
|
||
statusElement.className = className;
|
||
statusElement.textContent = status;
|
||
}
|
||
|
||
// 启动A股交易时间状态更新
|
||
function startAStockStatusUpdater() {
|
||
// 如果已经有定时器在运行,先清除
|
||
if (window.astockStatusInterval) {
|
||
clearInterval(window.astockStatusInterval);
|
||
}
|
||
|
||
// 立即更新一次
|
||
updateAStockTradingStatus();
|
||
|
||
// 每30秒更新一次
|
||
window.astockStatusInterval = setInterval(updateAStockTradingStatus, 30000);
|
||
console.log('A股交易时间状态更新器已启动');
|
||
}
|
||
|
||
|
||
|
||
// 均线系统全局变量
|
||
var movingAverages = []; // 存储所有均线配置
|
||
var maIdCounter = 0; // 均线ID计数器
|
||
|
||
// 布林带系统全局变量
|
||
var bollingerBands = []; // 存储所有布林带配置
|
||
var bbIdCounter = 0; // 布林带ID计数器
|
||
|
||
// 清理EMA52系列
|