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:
+298
-25
@@ -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() {
|
||||
|
||||
Reference in New Issue
Block a user