fix(web): 自动刷新保留 K 线视窗;威科夫与图表增量更新

自动刷新改用 tail update 与 scrollToPosition 恢复视窗,避免 setData 后跳到最右;拆分 chart_tv 模块并扩展 analyze/recent API。同步威科夫分析、pipeline 增量构建及相关策略与配置。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
jackyu66git
2026-08-25 22:57:43 +08:00
co-authored by Cursor
parent 1e60ab3bfa
commit 8ee11317d3
104 changed files with 21452 additions and 4988 deletions
+298 -25
View File
@@ -1,4 +1,252 @@
/* 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)) {
@@ -24,14 +272,15 @@ function loadSymbols() {
});
}
// 设置默认时间范围
// 设置默认时间范围:最近 1 个月
function setDefaultTimeRange() {
const now = new Date();
const oneDayAgo = new Date(now.getTime() - (24 * 60 * 60 * 1000));
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(oneDayAgo));
$('#start_time').val(formatDatetimeLocal(start));
}
// 格式化日期为datetime-local输入框格式
function formatDatetimeLocal(date) {
@@ -244,6 +493,9 @@ $(document).ready(function() {
let autoRefreshTimer = null;
let nextRefreshTime = null;
let autoRefreshTick = 0;
/** 自动刷新时,缠论全量重算间隔(毫秒);时间戳见 window._lastFullAnalyzeAt */
const AUTO_FULL_ANALYZE_MS = 60 * 1000;
// 初始化自动刷新功能
function initAutoRefresh() {
// 监听自动刷新勾选框变化
@@ -270,10 +522,10 @@ function startAutoRefresh() {
stopAutoRefresh();
// 获取刷新频率(分钟)
const interval = parseFloat($('#refreshInterval').val()) || 5;
const interval = parseFloat($('#refreshInterval').val()) || (5 / 60);
const intervalMs = interval * 60 * 1000;
console.log(`开始自动刷新,频率: ${interval}分钟 (${intervalMs}毫秒)`);
console.log(`开始自动刷新,频率: ${interval}分钟 (${intervalMs}毫秒);缠论全量每 ${AUTO_FULL_ANALYZE_MS / 1000}s`);
// 计算下次刷新时间
nextRefreshTime = new Date(Date.now() + intervalMs);
@@ -282,16 +534,27 @@ function startAutoRefresh() {
// 启动定时器
autoRefreshTick = 0;
autoRefreshTimer = setInterval(function() {
// 更新结束时间为当前时间
// 更新结束时间显示(仅 UI
updateEndTimeToNow();
// 多数周期增量更新;每隔若干次全量重建以刷新笔/段/中枢(dispose 已防泄漏)
autoRefreshTick += 1;
const fullRebuild = (autoRefreshTick % 6) === 0;
updateChart({
fromAutoRefresh: true,
incremental: !fullRebuild
});
const now = Date.now();
const lastFull = window._lastFullAnalyzeAt || 0;
const needFullAnalyze = !lastFull || (now - lastFull >= AUTO_FULL_ANALYZE_MS);
// 常态:/api/klines/recent 合并尾部 K;满 1 分钟:全量 /api/analyze 刷新缠论
if (needFullAnalyze) {
console.log('自动刷新 → 全量缠论 analyze(距上次', lastFull ? Math.round((now - lastFull) / 1000) + 's' : '首次', '');
updateChart({
fromAutoRefresh: true,
fullAnalyze: true,
incremental: true
});
} else {
updateChart({
fromAutoRefresh: true,
incremental: true
});
}
// 更新下次刷新时间
nextRefreshTime = new Date(Date.now() + intervalMs);
@@ -535,15 +798,26 @@ function refreshChart(data, options) {
// 自动刷新:增量更新,避免每次销毁/重建 Lightweight Charts
if (preferIncremental && chartsReady) {
try {
if (tvWidget.mainChart) {
// 数据到达后再冻结视窗(比请求发出时更准;避免用到过期 scroll)
if (tvWidget.mainChart && typeof captureChartViewState === 'function') {
try {
window._pendingRestoreView = captureChartViewState(tvWidget.mainChart);
window._preserveViewOnRefresh = captureChartViewState(tvWidget.mainChart);
const prev = currentData && (
($('#subSubPeriodKline').is(':checked') && currentData.sub_sub_kline_data) ||
($('#elementPeriodKline').is(':checked') && currentData.element_kline_data) ||
currentData.kline_data
);
window._preserveViewBarCount = Array.isArray(prev) ? prev.length : 0;
} catch (e) {
window._pendingRestoreView = null;
window._preserveViewOnRefresh = null;
window._preserveViewBarCount = 0;
}
}
updateTradingViewData();
updateTables(data);
updateTradingViewData({ tailOnly: !!options.skipTables });
// recent-tail 刷新结构未变,跳过表格重绘以提速
if (!options.skipTables) {
updateTables(data);
}
if (currentData && currentData.ema52_dict) {
updateEMA52Display(currentData);
}
@@ -555,7 +829,7 @@ function refreshChart(data, options) {
// 保存当前缩放(barSpacing)和滚动位置(scrollPosition)到 window
// tvWidget 会在 initTradingView 内被重建,所以必须存到 window 上
if (tvWidget && tvWidget.mainChart) {
if (!window._pendingRestoreView && tvWidget && tvWidget.mainChart) {
try {
window._pendingRestoreView = captureChartViewState(tvWidget.mainChart);
console.log('📌 保存图表视图:', JSON.stringify(window._pendingRestoreView));
@@ -563,6 +837,8 @@ function refreshChart(data, options) {
console.warn('保存图表视图失败:', e);
window._pendingRestoreView = null;
}
} else if (window._pendingRestoreView) {
console.log('📌 使用已保存图表视图:', JSON.stringify(window._pendingRestoreView));
}
initTradingView($('#symbol').val(), $('#timeframe').val());
@@ -596,14 +872,14 @@ $('#showElementMacdDiv').change(function() {
refreshChartOnly();
});
// 绑定分型类型显示开关
// 绑定分型类型显示开关(与笔一致:全量重建,避免增量路径标记未对齐)
$('#showKlcFxType').change(function() {
refreshChartOnly();
updateChartDisplay();
});
// 绑定小周期分型显示开关
$('#showElementKlcFxType').change(function() {
refreshChart(currentData);
updateChartDisplay();
});
@@ -616,10 +892,7 @@ $('#showElementBollinger').change(function() {
updateChartDisplay();
});
// 绑定K线周期切换
$('input[name="klinePeriod"]').change(function() {
refreshChart(currentData);
});
// K线周期切换由 macd_ui.js 统一走 updateChartDisplay(勿再绑 refreshChart,会重复且易漏对齐)
// 绑定主图U显示开关
$('#toggleUOnMain').change(function() {