refactor: 缠论引擎包化与 Web 分层(ECR-001)

将根目录引擎迁入 chanlun/ 并保留兼容 shim;拆分 TF_DF 与 web 服务;
前端模块化;strategies 改用 chanlun 导入;补充 ESS 文档与 golden 回归。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
jackyu66git
2026-08-05 18:48:20 +08:00
co-authored by Cursor
parent e2e45bc1bc
commit 74dec4e50b
160 changed files with 25576 additions and 23104 deletions
+21
View File
@@ -0,0 +1,21 @@
/* Chan web API client helpers */
window.ChanApi = {
analyze: function(params) {
const q = new URLSearchParams(params);
return fetch('/api/analyze?' + q.toString()).then(r => r.json());
},
chartMetadata: function() {
return fetch('/api/chart_metadata').then(r => r.json());
},
symbols: function() {
return fetch('/api/symbols').then(r => r.json());
},
macdConfig: function(body) {
if (body === undefined) return fetch('/api/macd_config').then(r => r.json());
return fetch('/api/macd_config', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(body)
}).then(r => r.json());
}
};
File diff suppressed because it is too large Load Diff
+496
View File
@@ -0,0 +1,496 @@
/**
* 缠论自定义指标 — TradingView Advanced Chart
*
* 从 chanIndicator.ts 转换为 vanilla JS。
* 在 K 线上叠加:笔/段(实线+虚线)、中枢(填色区域)、买卖点(文字标签)。
*
* 依赖:
* window.chanLookupHolder — 当前 Chan 结构数据
* window.commitChanLookup — 累积合并新数据
* window.makeChanIndicator — 创建 TV study 定义
*/
(function () {
'use strict'
// ---- BSP 子类型枚举 ----
var BSP_SUBTYPES = ['T1', 'T1P', 'T2', 'T2S', 'T3A', 'T3B']
// ---- 全局状态: chanLookupHolder ----
window.chanLookupHolder = {
current: null,
key: null,
}
/**
* 累积/替换 chanLookup
* 同 key 累积合并(历史区间的 BSP 标签持续保留)
* 不同 key 整个替换
*/
window.commitChanLookup = function (fresh, key) {
var holder = window.chanLookupHolder
if (holder.key !== key || !holder.current) {
holder.current = fresh
holder.key = key
return
}
// 同 key 合并
var target = holder.current.byTimeMs
fresh.byTimeMs.forEach(function (e, t) {
var existed = target.get(t)
if (existed) {
Object.assign(existed, e)
} else {
target.set(t, e)
}
})
}
// ---- 工具函数 ----
function lowerBound(arr, v) {
var lo = 0, hi = arr.length
while (lo < hi) {
var mid = (lo + hi) >> 1
if (arr[mid] < v) lo = mid + 1
else hi = mid
}
return lo
}
function upperBound(arr, v) {
var lo = 0, hi = arr.length
while (lo < hi) {
var mid = (lo + hi) >> 1
if (arr[mid] <= v) lo = mid + 1
else hi = mid
}
return lo
}
/**
* 构建 ChanLookup:将 Chan 结构数据映射到每个 bar 的指标值
*
* @param {Object} slice - ChanSlice {bis, segs, zs, segzs, bsps, seg_bsps}
* @param {Array} bars - OHLCV bars [{t: ms, h, l}, ...]
* @returns {Object} {byTimeMs: Map<ms, BarEntry>}
*/
window.buildChanLookup = function (slice, bars) {
var byTimeMs = new Map()
function ensure(tsMs) {
// tsMs 已是毫秒(来自 data_provider 的 timestamp),无需再转换
var key = tsMs
var e = byTimeMs.get(key)
if (!e) {
e = {}
byTimeMs.set(key, e)
}
return e
}
var sortedBarTimes = bars.map(function (b) { return b.t }).sort(function (a, b) { return a - b })
// 线性插值填充笔/段到每个 bar
function fillLine(t0, t1, p0, p1, field) {
var lo = lowerBound(sortedBarTimes, t0)
var hi = upperBound(sortedBarTimes, t1)
var span = hi - 1 - lo
if (span <= 0) {
if (lo < sortedBarTimes.length) ensure(sortedBarTimes[lo])[field] = p0
return
}
var step = (p1 - p0) / span
for (var i = lo; i < hi; i++) {
ensure(sortedBarTimes[i])[field] = p0 + step * (i - lo)
}
}
// 笔
if (slice.bis) {
slice.bis.forEach(function (b) {
fillLine(b.t0, b.t1, b.p0, b.p1, b.sure ? 'bi' : 'bi_pending')
})
}
// 段
if (slice.segs) {
slice.segs.forEach(function (s) {
fillLine(s.t0, s.t1, s.p0, s.p1, s.sure ? 'seg' : 'seg_pending')
})
}
// 中枢填充:区间内每根 bar 写入 top/bottom
function fillZs(t0, t1, high, low, topField, botField) {
var lo = lowerBound(sortedBarTimes, t0)
var hi = upperBound(sortedBarTimes, t1)
for (var i = lo; i < hi; i++) {
var e = ensure(sortedBarTimes[i])
e[topField] = high
e[botField] = low
}
}
if (slice.zs) {
slice.zs.forEach(function (z) {
fillZs(z.t0, z.t1, z.high || z.zg, z.low || z.zd, 'zs_top', 'zs_bottom')
})
}
if (slice.segzs) {
slice.segzs.forEach(function (z) {
fillZs(z.t0, z.t1, z.high || z.zg, z.low || z.zd, 'segzs_top', 'segzs_bottom')
})
}
// BSP 买卖点标记
function placeBsps(list, prefix) {
if (!list) return
list.forEach(function (bsp) {
var dir = bsp.is_buy ? 'buy' : 'sell'
var e = ensure(bsp.t)
var types = bsp.types || []
types.forEach(function (raw) {
var t = String(raw).toUpperCase()
if (BSP_SUBTYPES.indexOf(t) === -1) return
var key = prefix + '_' + dir + '_' + t
e[key] = 1
})
})
}
placeBsps(slice.bsps, 'bi_bsp')
placeBsps(slice.seg_bsps, 'seg_bsp')
return { byTimeMs: byTimeMs }
}
// ---- 样式持久化 ----
function currentTheme() {
try { return localStorage.getItem('chart-theme') || 'light' }
catch (e) { return 'light' }
}
function chanStyleKey() {
return 'chan-indicator-styles-v7-' + currentTheme()
}
function loadSavedChanStyles() {
try {
var raw = localStorage.getItem(chanStyleKey())
return raw ? JSON.parse(raw) : null
} catch (e) {
return null
}
}
window.saveChanStyles = function (sv) {
try {
localStorage.setItem(chanStyleKey(), JSON.stringify({
styles: sv && sv.styles ? sv.styles : {},
filledAreasStyle: sv && sv.filledAreasStyle ? sv.filledAreasStyle : {},
}))
} catch (e) { /* ignore */ }
}
// ---- 主体:创建 TV 自定义指标定义 ----
window.makeChanIndicator = function () {
var saved = loadSavedChanStyles()
var isDark = currentTheme() === 'dark'
var biColor = isDark ? '#ffffff' : '#000000'
var segColor = isDark ? '#42a5f5' : '#1565c0'
function mergeStyle(id, base) {
var savedStyle = (saved && saved.styles && saved.styles[id]) || {}
var merged = {}
var keys = Object.keys(base).concat(Object.keys(savedStyle))
keys.forEach(function (k) {
if (k in savedStyle) merged[k] = savedStyle[k]
else merged[k] = base[k]
})
return merged
}
function mergeFill(id, base) {
var savedFill = (saved && saved.filledAreasStyle && saved.filledAreasStyle[id]) || {}
var merged = {}
var keys = Object.keys(base).concat(Object.keys(savedFill))
keys.forEach(function (k) {
if (k in savedFill) merged[k] = savedFill[k]
else merged[k] = base[k]
})
return merged
}
// 构建 plots 数组
var plots = [
{ id: 'bi', type: 'line' },
{ id: 'bi_pending', type: 'line' },
{ id: 'seg', type: 'line' },
{ id: 'seg_pending', type: 'line' },
{ id: 'zs_top', type: 'line' },
{ id: 'zs_bottom', type: 'line' },
{ id: 'segzs_top', type: 'line' },
{ id: 'segzs_bottom', type: 'line' },
]
BSP_SUBTYPES.forEach(function (t) {
plots.push({ id: 'bi_bsp_buy_' + t, type: 'chars' })
plots.push({ id: 'bi_bsp_sell_' + t, type: 'chars' })
plots.push({ id: 'seg_bsp_buy_' + t, type: 'chars' })
plots.push({ id: 'seg_bsp_sell_' + t, type: 'chars' })
})
// 构建 styles 对象
// bi_pending/seg_pending: 虚线(linestyle:2),加粗 + 高亮色,确保末完成笔/段清晰可见
var pendingBiColor = isDark ? '#ff9800' : '#e65100' // orange
var pendingSegColor = isDark ? '#e040fb' : '#aa00ff' // purple
var styles = {
bi: mergeStyle('bi', {
linestyle: 0, linewidth: 1, plottype: 0, trackPrice: false,
transparency: 0, visible: true, color: biColor, display: 3,
}),
bi_pending: mergeStyle('bi_pending', {
linestyle: 2, linewidth: 2, plottype: 0, trackPrice: false,
transparency: 0, visible: true, color: pendingBiColor, display: 3,
}),
seg: mergeStyle('seg', {
linestyle: 0, linewidth: 3, plottype: 0, trackPrice: false,
transparency: 0, visible: true, color: segColor, display: 3,
}),
seg_pending: mergeStyle('seg_pending', {
linestyle: 2, linewidth: 4, plottype: 0, trackPrice: false,
transparency: 0, visible: true, color: pendingSegColor, display: 3,
}),
zs_top: mergeStyle('zs_top', {
linestyle: 0, linewidth: 0, plottype: 0, trackPrice: false,
transparency: 100, visible: false, color: '#e4eaf1', display: 0,
}),
zs_bottom: mergeStyle('zs_bottom', {
linestyle: 0, linewidth: 0, plottype: 0, trackPrice: false,
transparency: 100, visible: false, color: '#1565c0', display: 0,
}),
segzs_top: mergeStyle('segzs_top', {
linestyle: 0, linewidth: 0, plottype: 0, trackPrice: false,
transparency: 100, visible: false, color: '#ef6c00', display: 0,
}),
segzs_bottom: mergeStyle('segzs_bottom', {
linestyle: 0, linewidth: 0, plottype: 0, trackPrice: false,
transparency: 100, visible: false, color: '#ef6c00', display: 0,
}),
}
// BSP 样式
BSP_SUBTYPES.forEach(function (t) {
styles['bi_bsp_buy_' + t] = mergeStyle('bi_bsp_buy_' + t, {
char: '●', location: 'BelowBar', visible: true, size: 'large',
color: '#d32f2f', display: 3,
})
styles['bi_bsp_sell_' + t] = mergeStyle('bi_bsp_sell_' + t, {
char: '●', location: 'AboveBar', visible: true, size: 'large',
color: '#2e7d32', display: 3,
})
styles['seg_bsp_buy_' + t] = mergeStyle('seg_bsp_buy_' + t, {
char: '●', location: 'BelowBar', visible: true, size: 'large',
color: '#d32f2f', display: 3,
})
styles['seg_bsp_sell_' + t] = mergeStyle('seg_bsp_sell_' + t, {
char: '●', location: 'AboveBar', visible: true, size: 'large',
color: '#2e7d32', display: 3,
})
})
// 构建 style titles
var styleTitles = {
bi: { title: '笔', histogramBase: 0 },
bi_pending: { title: '笔(虚)', histogramBase: 0 },
seg: { title: '段', histogramBase: 0 },
seg_pending: { title: '段(虚)', histogramBase: 0 },
zs_top: { title: '中枢上沿', histogramBase: 0, isHidden: true },
zs_bottom: { title: '中枢下沿', histogramBase: 0, isHidden: true },
segzs_top: { title: '段中枢上沿', histogramBase: 0, isHidden: true },
segzs_bottom: { title: '段中枢下沿', histogramBase: 0, isHidden: true },
}
BSP_SUBTYPES.forEach(function (t) {
// 类型名映射:T1/T2/T3A 是买点, T1P/T2S/T3B 是卖点
var typeInfo = {
T1: { cls: '一', side: 'buy', num: '1' },
T1P: { cls: '一', side: 'sell', num: '1' },
T2: { cls: '二', side: 'buy', num: '2' },
T2S: { cls: '二', side: 'sell', num: '2' },
T3A: { cls: '三', side: 'buy', num: '3' },
T3B: { cls: '三', side: 'sell', num: '3' },
}[t] || { cls: '', side: '', num: '' }
var buyText = 'B' + typeInfo.num
var sellText = 'S' + typeInfo.num
var isBuyType = typeInfo.side === 'buy'
var isSellType = typeInfo.side === 'sell'
// 笔中枢 BSP:全部可见
styleTitles['bi_bsp_buy_' + t] = {
title: '笔·' + typeInfo.cls + '类买点',
isHidden: !isBuyType,
text: buyText,
}
styleTitles['bi_bsp_sell_' + t] = {
title: '笔·' + typeInfo.cls + '类卖点',
isHidden: !isSellType,
text: sellText,
}
// 段中枢 BSP:只有一类买卖点有实际数据
var segBuyVisible = t === 'T1'
var segSellVisible = t === 'T1P'
styleTitles['seg_bsp_buy_' + t] = {
title: '段·一类买点',
isHidden: !segBuyVisible,
text: '段B1',
}
styleTitles['seg_bsp_sell_' + t] = {
title: '段·一类卖点',
isHidden: !segSellVisible,
text: '段S1',
}
})
return {
name: '缠论',
metainfo: {
_metainfoVersion: 53,
id: 'Chan@tv-basicstudies-5',
scriptIdPart: '',
description: 'Chan 缠论',
shortDescription: '缠论',
is_hidden_study: false,
isCustomIndicator: true,
is_price_study: true,
linkedToSeries: true,
format: { type: 'inherit' },
plots: plots,
filledAreas: [
{ id: 'zs_fill', objAId: 'zs_top', objBId: 'zs_bottom', type: 'plot_plot',
title: '中枢', isHidden: false },
{ id: 'segzs_fill', objAId: 'segzs_top', objBId: 'segzs_bottom', type: 'plot_plot',
title: '段中枢', isHidden: false },
],
defaults: {
styles: styles,
filledAreasStyle: {
zs_fill: mergeFill('zs_fill', { color: '#f1d96a', visible: true, transparency: 75 }),
segzs_fill: mergeFill('segzs_fill', { color: '#6361f7', visible: true, transparency: 75 }),
},
precision: 2,
inputs: { epoch: 0 },
},
styles: styleTitles,
inputs: [
{ id: 'epoch', name: 'epoch', type: 'integer', defval: 0, isHidden: true },
],
},
constructor: function () {
var self = this
this.init = function (ctx) {
self._context = ctx
}
this.main = function (context) {
// 32 个 plot: 8 结构 + 24 BSP
var NANS = new Array(32).fill(NaN)
// v31: sniffing pass 时 context.symbol.time 为 NaN
var t = context.symbol.time
if (isNaN(t)) return NANS
var lookup = window.chanLookupHolder.current
if (!lookup) return NANS
var e = lookup.byTimeMs.get(t)
if (!e) return NANS
var out = [
e.bi != null ? e.bi : NaN,
e.bi_pending != null ? e.bi_pending : NaN,
e.seg != null ? e.seg : NaN,
e.seg_pending != null ? e.seg_pending : NaN,
e.zs_top != null ? e.zs_top : NaN,
e.zs_bottom != null ? e.zs_bottom : NaN,
e.segzs_top != null ? e.segzs_top : NaN,
e.segzs_bottom != null ? e.segzs_bottom : NaN,
]
BSP_SUBTYPES.forEach(function (sub) {
out.push(
e['bi_bsp_buy_' + sub] != null ? e['bi_bsp_buy_' + sub] : NaN,
e['bi_bsp_sell_' + sub] != null ? e['bi_bsp_sell_' + sub] : NaN,
e['seg_bsp_buy_' + sub] != null ? e['seg_bsp_buy_' + sub] : NaN,
e['seg_bsp_sell_' + sub] != null ? e['seg_bsp_sell_' + sub] : NaN
)
})
return out
}
},
}
}
// ---- Epoch bump 机制 ----
var chanEpoch = 0
var CHAN_STUDY_DESC = 'Chan 缠论'
/**
* 确保缠论 study 存在并通过 epoch bump 触发重绘。
* 与 TradingViewChart.tsx 中 ensureAndPokeChanStudy 逻辑一致。
*/
window.ensureAndPokeChanStudy = function (chart) {
try {
var studies = chart.getAllStudies ? chart.getAllStudies() : []
var existingId = null
for (var i = 0; i < studies.length; i++) {
if (studies[i].name === CHAN_STUDY_DESC) {
existingId = studies[i].id
break
}
}
chanEpoch += 1
if (existingId) {
try {
var api = chart.getStudyById(existingId)
if (api && api.setInputValues) {
api.setInputValues([{ id: 'epoch', value: chanEpoch }])
}
} catch (err) {
console.warn('setInputValues Chan failed', err)
}
return
}
// 新建 study — 必须是 chart.createStudy(...) 保持 this 绑定!
if (!chart.createStudy) return
var result = chart.createStudy(CHAN_STUDY_DESC, false, false, { epoch: chanEpoch })
// createStudy 返回 Promise<string>
if (result && typeof result.then === 'function') {
result.then(function (id) {
if (!id) {
console.warn('[缠论] createStudy 返回空 id(指标未注册成功)')
return
}
console.log('[缠论] study 已创建', id)
try {
var studyApi = chart.getStudyById(id)
if (studyApi && studyApi.bringToFront) studyApi.bringToFront()
} catch (err) {
console.warn('bringToFront Chan failed', err)
}
}).catch(function (err) {
console.warn('createStudy Chan failed', err)
})
} else if (result) {
// 同步返回(兜底)
console.log('[缠论] study 已创建 (sync)', result)
}
} catch (e) {
console.error('ensureAndPokeChanStudy error', e)
}
}
})()
+1
View File
@@ -0,0 +1 @@
/* chart.js split into chart_format/view/tv/sync/tables — see index.html load order */
+85
View File
@@ -0,0 +1,85 @@
/* chart_format.js — split from chart.js */
/* chart.js */
function updateChartDisplay() {
if (currentData) {
// 检测K线周期是否切换
const curPeriod = $('#subSubPeriodKline').is(':checked') ? 'subsub' :
($('#elementPeriodKline').is(':checked') ? 'element' : 'main');
const periodChanged = (curPeriod !== _lastKlinePeriod);
_lastKlinePeriod = curPeriod;
// 保存当前的可见范围(周期切换时不保留,避免范围越界)
if (!periodChanged && tvWidget && tvWidget.mainChart) {
try {
window._pendingRestoreView = captureChartViewState(tvWidget.mainChart);
} catch (e) {
window._pendingRestoreView = null;
}
}
console.log('更新图表显示');
// 重新初始化图表(initTradingView 内部会在最终同步时读取 _pendingRestoreView
initTradingView($('#symbol').val(), $('#timeframe').val());
}
}
// 确保所有时间处理都使用UTC时间,包括表格数据显示
function formatTime(timeStr) {
if (!timeStr) return '';
try {
// 使用用户选择的时区
const timezone = $('#timezone').val();
const date = new Date(timeStr);
// 添加调试信息
console.debug('表格时间格式化:', timeStr, '->',
date.toISOString(), '使用时区:', timezone);
// 使用toLocaleString带时区参数
return date.toLocaleString('zh-CN', {
timeZone: timezone,
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
});
} catch (e) {
console.error('时间格式化错误:', e, timeStr);
// 如果格式化失败,返回原始时间字符串
return timeStr;
}
}
// 专门用于确认时间的格式化函数,处理可能为空的情况
function formatConfirmTime(timeStr) {
if (!timeStr || timeStr === null || timeStr === 'null' || timeStr === '') {
return '<span class="text-muted">未确认</span>';
}
return formatTime(timeStr);
}
function formatDirection(direction) {
const dirText = direction === 1 ? '向上' : '向下';
const dirClass = direction === 1 ? 'direction-up' : 'direction-down';
return '<span class="' + dirClass + '">' + dirText + '</span>';
}
function formatPrice(price) {
return price !== null ? parseFloat(price).toFixed(2) : '';
}
function formatMacdValue(value) {
const numValue = parseFloat(value);
const valueClass = numValue >= 0 ? 'positive' : 'negative';
return '<span class="' + valueClass + '">' + numValue.toFixed(4) + '</span>';
}
function formatTradePointType(type) {
const typeText = type > 0 ? `${Math.abs(type)}` : `${Math.abs(type)}`;
const typeClass = type > 0 ? 'direction-up' : 'direction-down';
return '<span class="' + typeClass + '">' + typeText + '</span>';
}
+712
View File
@@ -0,0 +1,712 @@
/* chart_sync.js — split from chart.js */
function updateTradingViewData() {
try {
console.log('增量更新图表数据');
// 检查 currentData 是否存在
if (!currentData) {
console.error('currentData为空,无法更新图表');
return;
}
// 保存当前的可视范围
if (tvWidget.mainChart) {
tvWidget.state.visibleRange = tvWidget.mainChart.timeScale().getVisibleRange();
tvWidget.state.logicalRange = tvWidget.mainChart.timeScale().getVisibleLogicalRange();
}
// 检查是否显示原始K线
const showOriginalKline = $('#showOriginalKline').is(':checked');
// 检查是否使用次次周期 / 小周期数据
const useSubSubPeriod = $('#subSubPeriodKline').is(':checked') &&
currentData.sub_sub_timeframe &&
currentData.sub_sub_kline_data &&
Array.isArray(currentData.sub_sub_kline_data);
const useElementPeriod = !useSubSubPeriod &&
$('#elementPeriodKline').is(':checked') &&
currentData.element_timeframe &&
currentData.element_kline_data &&
Array.isArray(currentData.element_kline_data);
// 转换K线数据
let candles = [];
if (useSubSubPeriod) {
console.log('使用次次周期K线数据');
candles = currentData.sub_sub_kline_data.map((kline) => {
const date = new Date(kline.date);
const timestamp = date.getTime() / 1000;
return {
time: timestamp,
open: parseFloat(kline.open),
high: parseFloat(kline.high),
low: parseFloat(kline.low),
close: parseFloat(kline.close),
};
});
} else if (useElementPeriod) {
console.log('使用小周期K线数据');
candles = currentData.element_kline_data.map((kline) => {
const date = new Date(kline.date);
const timestamp = date.getTime() / 1000;
return {
time: timestamp,
open: parseFloat(kline.open),
high: parseFloat(kline.high),
low: parseFloat(kline.low),
close: parseFloat(kline.close),
};
});
} else if (currentData.kline_data && Array.isArray(currentData.kline_data)) {
console.log('使用主周期K线数据');
candles = currentData.kline_data.map((kline) => {
const date = new Date(kline.date);
const timestamp = date.getTime() / 1000;
return {
time: timestamp,
open: parseFloat(kline.open),
high: parseFloat(kline.high),
low: parseFloat(kline.low),
close: parseFloat(kline.close),
};
});
}
// 更新主系列数据(根据klineType)
const klineType = ($('#klineType').val() || (showOriginalKline ? 'candlestick' : 'line'));
if (klineType === 'candlestick' && tvWidget.series.candleSeries) {
tvWidget.series.candleSeries.setData(candles);
} else if (klineType === 'renko' && tvWidget.series.renkoSeries) {
const bricks = buildRenkoFromCandles(candles);
tvWidget.series.renkoSeries.setData(bricks);
} else if (klineType === 'heikin' && tvWidget.series.heikinSeries) {
const hk = buildHeikinFromCandles(candles);
tvWidget.series.heikinSeries.setData(hk);
} else if (klineType === 'bar' && tvWidget.series.barSeries) {
tvWidget.series.barSeries.setData(candles);
} else if (klineType === 'line' && tvWidget.series.lineSeries) {
const lineData = candles.map(c => ({ time: c.time, value: c.close }));
tvWidget.series.lineSeries.setData(lineData);
} else if (klineType === 'area' && tvWidget.series.areaSeries) {
const areaData = candles.map(c => ({ time: c.time, value: c.close }));
tvWidget.series.areaSeries.setData(areaData);
} else if (klineType === 'baseline' && tvWidget.series.baselineSeries) {
const baseData = candles.map(c => ({ time: c.time, value: c.close }));
tvWidget.series.baselineSeries.setData(baseData);
} else if (klineType === 'klc' && tvWidget.series.klcSeries) {
const klcCandles = buildKLCFromAnalysis(currentData);
tvWidget.series.klcSeries.setData(klcCandles);
}
// 更新均线数据
addMovingAveragesToChart(candles);
// 更新布林带数据
addBollingerBandsToChart(candles);
// 更新成交量数据
let volumes = [];
if (useSubSubPeriod && currentData.sub_sub_kline_data && Array.isArray(currentData.sub_sub_kline_data)) {
volumes = currentData.sub_sub_kline_data.map(kline => {
const timestamp = Math.floor(new Date(kline.date).getTime() / 1000);
return {
time: timestamp,
value: parseFloat(kline.volume),
color: parseFloat(kline.close) >= parseFloat(kline.open) ? 'rgba(40, 167, 69, 0.5)' : 'rgba(220, 53, 69, 0.5)',
};
});
} else if (useElementPeriod && currentData.element_kline_data && Array.isArray(currentData.element_kline_data)) {
volumes = currentData.element_kline_data.map(kline => {
const timestamp = Math.floor(new Date(kline.date).getTime() / 1000);
return {
time: timestamp,
value: parseFloat(kline.volume),
color: parseFloat(kline.close) >= parseFloat(kline.open) ? 'rgba(40, 167, 69, 0.5)' : 'rgba(220, 53, 69, 0.5)',
};
});
} else if (currentData.kline_data && Array.isArray(currentData.kline_data)) {
volumes = currentData.kline_data.map(kline => {
const timestamp = Math.floor(new Date(kline.date).getTime() / 1000);
return {
time: timestamp,
value: parseFloat(kline.volume),
color: parseFloat(kline.close) >= parseFloat(kline.open) ? 'rgba(40, 167, 69, 0.5)' : 'rgba(220, 53, 69, 0.5)',
};
});
}
if (tvWidget.series.volumeSeries) {
tvWidget.series.volumeSeries.setData(volumes);
}
// 更新ATR数据
if (tvWidget.series.atrLineSeries) {
const atrData = [];
const atrDataSource = useSubSubPeriod ?
(currentData.sub_sub_atr || currentData.atr) :
(useElementPeriod ? (currentData.element_atr || currentData.atr) : currentData.atr);
if (atrDataSource && Array.isArray(atrDataSource)) {
const klineDataSource = useSubSubPeriod ? (currentData.sub_sub_kline_data || []) : (useElementPeriod ? currentData.element_kline_data : currentData.kline_data);
// 修复:为每个K线时间点都创建ATR数据点,包括没有ATR值的前期数据
for (let i = 0; i < klineDataSource.length; i++) {
const kline = klineDataSource[i];
const timestamp = Math.floor(new Date(kline.date).getTime() / 1000);
// 为每个时间点都添加数据以保持时间轴对齐,但ATR为0时不显示
if (atrDataSource[i] !== undefined) {
if (atrDataSource[i] > 0) {
// ATR有效值,正常显示
atrData.push({
time: timestamp,
value: atrDataSource[i]
});
} else {
// ATR为0,添加时间点但不显示线条(使用undefined作为value
atrData.push({
time: timestamp,
value: undefined
});
}
}
}
console.log('🔄 增量更新ATR数据点数:', atrData.length);
}
tvWidget.series.atrLineSeries.setData(atrData);
}
// 更新MACD数据
if (tvWidget.series.macdLineSeries && currentData.macd && currentData.kline_data && Array.isArray(currentData.kline_data)) {
// 提取MACD数据
const macdData = [];
const signalData = [];
const histogramData = [];
for (let i = 0; i < currentData.kline_data.length; i++) {
const kline = currentData.kline_data[i];
const timestamp = Math.floor(new Date(kline.date).getTime() / 1000);
if (currentData.macd && currentData.macd.macd && currentData.macd.macd[i] !== undefined) {
macdData.push({
time: timestamp,
value: currentData.macd.macd[i]
});
signalData.push({
time: timestamp,
value: currentData.macd.signal[i]
});
// 设置直方图颜色
const histValue = currentData.macd.histogram[i];
histogramData.push({
time: timestamp,
value: histValue,
color: histValue >= 0 ? 'rgba(40, 167, 69, 0.5)' : 'rgba(220, 53, 69, 0.5)'
});
}
}
tvWidget.series.macdLineSeries.setData(macdData);
tvWidget.series.signalLineSeries.setData(signalData);
tvWidget.series.histogramSeries.setData(histogramData);
}
// 更新 ChanMACD 数据与自定义标注
if (tvWidget.series.chanMacdLineSeries && ((useSubSubPeriod && currentData.sub_sub_macd) || (useElementPeriod && currentData.element_macd) || currentData.macd) && (useSubSubPeriod ? (currentData.sub_sub_kline_data || []) : (useElementPeriod ? currentData.element_kline_data : currentData.kline_data))) {
const klineDataSource = useSubSubPeriod ? (currentData.sub_sub_kline_data || []) : (useElementPeriod ? currentData.element_kline_data : currentData.kline_data);
const macdDataSource = useSubSubPeriod ? (currentData.sub_sub_macd || currentData.macd) : (useElementPeriod ? (currentData.element_macd || currentData.macd) : currentData.macd);
if (macdDataSource && macdDataSource.macd && macdDataSource.signal && macdDataSource.histogram) {
const chanMacdData = [];
const chanSignalData = [];
const chanHistData = [];
for (let i = 0; i < klineDataSource.length; i++) {
const kline = klineDataSource[i];
if (kline && kline.date && i < macdDataSource.macd.length && macdDataSource.macd[i] !== null && macdDataSource.macd[i] !== undefined) {
const timestamp = Math.floor(new Date(kline.date).getTime() / 1000);
chanMacdData.push({ time: timestamp, value: macdDataSource.macd[i] });
chanSignalData.push({ time: timestamp, value: macdDataSource.signal[i] });
chanHistData.push({ time: timestamp, value: macdDataSource.histogram[i], color: macdDataSource.histogram[i] >= 0 ? 'rgba(40, 167, 69, 0.5)' : 'rgba(220, 53, 69, 0.5)' });
}
}
if (chanMacdData.length > 0) {
tvWidget.series.chanMacdLineSeries.setData(chanMacdData);
tvWidget.series.chanMacdSignalSeries.setData(chanSignalData);
tvWidget.series.chanMacdHistSeries.setData(chanHistData);
}
}
// 重新应用自定义标注(段/UnitTF/HistSet/状态点)
try {
if (typeof clearChanMacdMarkers === 'function') clearChanMacdMarkers();
const cm = useSubSubPeriod ? (currentData.sub_sub_chan_macd || currentData.chan_macd) : (useElementPeriod ? (currentData.element_chan_macd || currentData.chan_macd) : currentData.chan_macd);
const allowU = useSubSubPeriod ? !!window.showUOnSubSub : (useElementPeriod ? !!window.showUOnElement : !!window.showUOnMain);
if (cm && allowU) {
addAllChanMacdMarkers(
cm.seg_list || [],
cm.unittf_list || [],
cm.histset_list || [],
{
high_position_list: cm.high_position_list || [],
high_empty_list: cm.high_empty_list || [],
low_position_list: cm.low_position_list || [],
low_empty_list: cm.low_empty_list || [],
return_zero_list: cm.return_zero_list || [],
cross0_up_list: cm.cross0_up_list || [],
cross0_down_list: cm.cross0_down_list || []
}
);
}
} catch (e) {
console.warn('更新ChanMACD标注失败:', e);
}
}
// 重新显示笔、线段和中枢等图形
redrawFractalElements();
// 更新EMA52显示
updateEMA52Display(currentData);
// 恢复之前的可视范围 - 优先使用visibleRange以确保时间轴对齐
if (tvWidget.mainChart) {
if (tvWidget.state.visibleRange) {
console.log('🔄 恢复可见范围:', tvWidget.state.visibleRange);
tvWidget.mainChart.timeScale().setVisibleRange(tvWidget.state.visibleRange);
if (tvWidget.volumeChart) tvWidget.volumeChart.timeScale().setVisibleRange(tvWidget.state.visibleRange);
if (tvWidget.atrChart) tvWidget.atrChart.timeScale().setVisibleRange(tvWidget.state.visibleRange);
if (tvWidget.macdChart) tvWidget.macdChart.timeScale().setVisibleRange(tvWidget.state.visibleRange);
if (tvWidget.chanMacdChart) tvWidget.chanMacdChart.timeScale().setVisibleRange(tvWidget.state.visibleRange);
} else if (tvWidget.state.logicalRange) {
console.log('🔄 恢复逻辑范围:', tvWidget.state.logicalRange);
tvWidget.mainChart.timeScale().setVisibleLogicalRange(tvWidget.state.logicalRange);
if (tvWidget.volumeChart) tvWidget.volumeChart.timeScale().setVisibleLogicalRange(tvWidget.state.logicalRange);
if (tvWidget.atrChart) tvWidget.atrChart.timeScale().setVisibleLogicalRange(tvWidget.state.logicalRange);
if (tvWidget.macdChart) tvWidget.macdChart.timeScale().setVisibleLogicalRange(tvWidget.state.logicalRange);
if (tvWidget.chanMacdChart) tvWidget.chanMacdChart.timeScale().setVisibleLogicalRange(tvWidget.state.logicalRange);
}
}
console.log('增量更新图表完成');
} catch (e) {
console.error('增量更新图表错误,回退到完全重绘:', e);
// 出错时回退到完全重绘
initTradingView($('#symbol').val(), $('#timeframe').val());
}
}
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;
// 用于跟踪所有图表的拖动状态 - 在函数内部定义以确保作用域正确
let localDragStates = {
main: false,
volume: false,
atr: false,
macd: false,
chanmacd: false
};
// 同步图表的时间范围
function syncCharts(sourceChart, sourceContainer) {
if (syncInProgress) return;
syncInProgress = true;
try {
if (sourceChart && sourceChart.timeScale) {
const logicalRange = sourceChart.timeScale().getVisibleLogicalRange();
if (logicalRange && logicalRange.from !== undefined && logicalRange.to !== undefined) {
if (sourceChart !== mainChart && mainChart && mainChart.timeScale) {
try { mainChart.timeScale().setVisibleLogicalRange(logicalRange); } catch (e) {}
}
if (sourceChart !== volumeChart && volumeChart && volumeChart.timeScale) {
try { volumeChart.timeScale().setVisibleLogicalRange(logicalRange); } catch (e) {}
}
if (sourceChart !== atrChart && atrChart && atrChart.timeScale) {
try { atrChart.timeScale().setVisibleLogicalRange(logicalRange); } catch (e) {}
}
if (showMacd && macdChart && sourceChart !== macdChart && macdChart.timeScale) {
try { macdChart.timeScale().setVisibleLogicalRange(logicalRange); } catch (e) {}
}
if (showMacd && chanMacdChart && sourceChart !== chanMacdChart && chanMacdChart.timeScale) {
try { chanMacdChart.timeScale().setVisibleLogicalRange(logicalRange); } catch (e) {}
}
if (tvWidget && tvWidget.state) {
tvWidget.state.logicalRange = logicalRange;
try { tvWidget.state.visibleRange = sourceChart.timeScale().getVisibleRange(); } catch (e) {}
}
}
}
} catch (e) {
console.error('同步图表出错:', e);
}
setTimeout(() => { syncInProgress = false; }, 1);
}
// 为每个图表添加事件监听
const addChartSyncEvents = (chartContainer, chart) => {
const chartType = chart === mainChart ? 'main' :
chart === volumeChart ? 'volume' :
chart === atrChart ? 'atr' :
chart === macdChart ? 'macd' :
chart === chanMacdChart ? 'chanmacd' : 'unknown';
const timeRangeHandler = () => {
if (!syncInProgress) {
syncCharts(chart, chartContainer);
}
};
chart.timeScale().subscribeVisibleTimeRangeChange(timeRangeHandler);
window._bindSyncCleanups.push(() => {
try { chart.timeScale().unsubscribeVisibleTimeRangeChange(timeRangeHandler); } catch(e) {}
});
let isScrolling = false;
const mousedownHandler = () => { localDragStates[chartType] = true; };
const mouseupHandler = () => { localDragStates[chartType] = false; };
const mouseleaveHandler = () => { localDragStates[chartType] = false; };
const wheelHandler = () => {
if (!isScrolling) {
isScrolling = true;
setTimeout(() => {
if (!syncInProgress) {
syncCharts(chart, chartContainer);
}
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);
});
};
// 添加事件监听
if (mainChartContainer && mainChart) {
addChartSyncEvents(mainChartContainer, mainChart);
}
if (volumeChartContainer && volumeChart) {
addChartSyncEvents(volumeChartContainer, volumeChart);
}
if (atrChartContainer && atrChart) {
addChartSyncEvents(atrChartContainer, atrChart);
}
if (showMacd && macdChartContainer && macdChart) {
addChartSyncEvents(macdChartContainer, macdChart);
}
if (showMacd && chanMacdChartContainer && chanMacdChart) {
addChartSyncEvents(chanMacdChartContainer, chanMacdChart);
}
// 窗口大小变化时重绘图表 — 使用可清理的方式注册
const resizeHandler = () => {
if (mainChart && mainChartContainer) {
mainChart.applyOptions({ width: mainChartContainer.clientWidth, height: mainChartContainer.clientHeight });
}
if (volumeChart && volumeChartContainer) {
volumeChart.applyOptions({ width: volumeChartContainer.clientWidth, height: volumeChartContainer.clientHeight });
}
if (atrChart && atrChartContainer) {
atrChart.applyOptions({ width: atrChartContainer.clientWidth, height: atrChartContainer.clientHeight });
}
if (showMacd && macdChart && macdChartContainer) {
macdChart.applyOptions({ width: macdChartContainer.clientWidth, height: macdChartContainer.clientHeight });
}
if (showMacd && chanMacdChart && chanMacdChartContainer) {
chanMacdChart.applyOptions({ width: chanMacdChartContainer.clientWidth, height: chanMacdChartContainer.clientHeight });
}
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');
const isShowUElement = $('#toggleUOnElement').is(':checked');
window.showUOnMain = isShowUMain;
window.showUOnElement = isShowUElement;
if (!isShowUMain && !isShowUElement) {
// 隐藏时清空子图上的 U 标记
if (tvWidget.series && tvWidget.series.chanMacdLineSeries) {
try { tvWidget.series.chanMacdLineSeries.setMarkers([]); } catch (e) {}
}
if (tvWidget.series && tvWidget.series.chanMacdSignalSeries) {
try { tvWidget.series.chanMacdSignalSeries.setMarkers([]); } catch (e) {}
}
}
// 添加买卖点悬浮提示元素
const tooltipElement = document.createElement('div');
tooltipElement.className = 'point-tooltip';
// document.body.appendChild(tooltipElement);
// 添加自定义十字线信息显示
const crosshairTooltip = document.createElement('div');
crosshairTooltip.className = 'crosshair-tooltip';
crosshairTooltip.style.position = 'absolute';
crosshairTooltip.style.backgroundColor = 'rgba(0, 0, 0, 0.7)';
crosshairTooltip.style.color = 'white';
crosshairTooltip.style.padding = '5px 10px';
crosshairTooltip.style.borderRadius = '4px';
crosshairTooltip.style.fontSize = '12px';
crosshairTooltip.style.zIndex = '1000';
crosshairTooltip.style.pointerEvents = 'none';
crosshairTooltip.style.display = 'none';
// document.body.appendChild(crosshairTooltip);
// 添加鼠标悬停事件显示提示
if (mainChart) {
const crosshairHandler = (param) => {
// 十字线同步到其他图表 - 通过DOM元素绘制垂直线实现虚线延长效果
if (param.time && param.point && volumeChart) {
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());
// 获取时间对应的坐标位置
const mainTimeCoordinate = mainChart.timeScale().timeToCoordinate(param.time);
if (mainTimeCoordinate !== null) {
// 获取主图容器的位置
const mainChartRect = mainChartContainer.getBoundingClientRect();
// 在交易量图上绘制垂直线
const volumeTimeCoordinate = volumeChart.timeScale().timeToCoordinate(param.time);
if (volumeTimeCoordinate !== null) {
const volumeChartRect = volumeChartContainer.getBoundingClientRect();
const volumeLine = document.createElement('div');
volumeLine.className = 'volume-crosshair-line';
volumeLine.style.position = 'fixed'; // 改为fixed定位
volumeLine.style.left = (volumeChartRect.left + volumeTimeCoordinate) + 'px';
volumeLine.style.top = volumeChartRect.top + 'px';
volumeLine.style.width = '1px';
volumeLine.style.height = volumeChartRect.height + 'px';
volumeLine.style.backgroundColor = 'rgba(128, 128, 128, 0.5)';
volumeLine.style.borderLeft = '1px dashed rgba(128, 128, 128, 0.5)';
volumeLine.style.pointerEvents = 'none';
volumeLine.style.zIndex = '1000';
document.body.appendChild(volumeLine);
}
// 在ATR图上绘制垂直线
if (atrChart && atrChartContainer) {
const atrTimeCoordinate = atrChart.timeScale().timeToCoordinate(param.time);
if (atrTimeCoordinate !== null) {
const atrChartRect = atrChartContainer.getBoundingClientRect();
const atrLine = document.createElement('div');
atrLine.className = 'atr-crosshair-line';
atrLine.style.position = 'fixed'; // 改为fixed定位
atrLine.style.left = (atrChartRect.left + atrTimeCoordinate) + 'px';
atrLine.style.top = atrChartRect.top + 'px';
atrLine.style.width = '1px';
atrLine.style.height = atrChartRect.height + 'px';
atrLine.style.backgroundColor = 'rgba(128, 128, 128, 0.5)';
atrLine.style.borderLeft = '1px dashed rgba(128, 128, 128, 0.5)';
atrLine.style.pointerEvents = 'none';
atrLine.style.zIndex = '1000';
document.body.appendChild(atrLine);
}
}
// 如果有MACD图,也在MACD图上绘制垂直线
if (showMacd && macdChart && macdChartContainer) {
const macdTimeCoordinate = macdChart.timeScale().timeToCoordinate(param.time);
if (macdTimeCoordinate !== null) {
const macdChartRect = macdChartContainer.getBoundingClientRect();
const macdLine = document.createElement('div');
macdLine.className = 'macd-crosshair-line';
macdLine.style.position = 'fixed'; // 改为fixed定位
macdLine.style.left = (macdChartRect.left + macdTimeCoordinate) + 'px';
macdLine.style.top = macdChartRect.top + 'px';
macdLine.style.width = '1px';
macdLine.style.height = macdChartRect.height + 'px';
macdLine.style.backgroundColor = 'rgba(128, 128, 128, 0.5)';
macdLine.style.borderLeft = '1px dashed rgba(128, 128, 128, 0.5)';
macdLine.style.pointerEvents = 'none';
macdLine.style.zIndex = '1000';
document.body.appendChild(macdLine);
}
}
// 如果有ChanMACD图,也在ChanMACD图上绘制垂直线
if (showMacd && chanMacdChart && chanMacdChartContainer) {
const chanMacdTimeCoordinate = chanMacdChart.timeScale().timeToCoordinate(param.time);
if (chanMacdTimeCoordinate !== null) {
const chanMacdChartRect = chanMacdChartContainer.getBoundingClientRect();
const chanMacdLine = document.createElement('div');
chanMacdLine.className = 'chanmacd-crosshair-line';
chanMacdLine.style.position = 'fixed';
chanMacdLine.style.left = (chanMacdChartRect.left + chanMacdTimeCoordinate) + 'px';
chanMacdLine.style.top = chanMacdChartRect.top + 'px';
chanMacdLine.style.width = '1px';
chanMacdLine.style.height = chanMacdChartRect.height + 'px';
chanMacdLine.style.backgroundColor = 'rgba(128, 128, 128, 0.5)';
chanMacdLine.style.borderLeft = '1px dashed rgba(128, 128, 128, 0.5)';
chanMacdLine.style.pointerEvents = 'none';
chanMacdLine.style.zIndex = '1000';
document.body.appendChild(chanMacdLine);
}
}
}
} catch (e) {
console.debug('十字线同步出错:', e);
}
} else {
// 当十字线离开时,清除垂直线
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);
}
}
if (param.time && param.point) {
const timeStr = param.time;
const markers = [...buyMarkers, ...sellMarkers].filter(m => m.time === timeStr);
// 同时检查分型标记
const fxMarkers = (window.fxMarkers || []).filter(m => m.time === timeStr);
const allMarkers = [...markers, ...fxMarkers];
// 显示时区调试信息
if (window.debugMode) {
const timezone = $('#timezone').val();
const formattedTime = formatTimeWithTimezone(timeStr * 1000, timezone);
// 获取当前价格 - 通过param.seriesPrices获取
let priceInfo = '';
if (param.seriesPrices && param.seriesPrices.size > 0) {
// 依次从当前可能的主系列中获取价格
if (tvWidget.series.candleSeries && param.seriesPrices.get(tvWidget.series.candleSeries)) {
const price = param.seriesPrices.get(tvWidget.series.candleSeries);
priceInfo = `价格: ${price.toFixed(2)}`;
} else if (tvWidget.series.renkoSeries && param.seriesPrices.get(tvWidget.series.renkoSeries)) {
const price = param.seriesPrices.get(tvWidget.series.renkoSeries);
priceInfo = `价格: ${price.toFixed(2)}`;
} else if (tvWidget.series.heikinSeries && param.seriesPrices.get(tvWidget.series.heikinSeries)) {
const price = param.seriesPrices.get(tvWidget.series.heikinSeries);
priceInfo = `价格: ${price.toFixed(2)}`;
} else if (tvWidget.series.barSeries && param.seriesPrices.get(tvWidget.series.barSeries)) {
const price = param.seriesPrices.get(tvWidget.series.barSeries);
priceInfo = `价格: ${price.toFixed(2)}`;
} else if (tvWidget.series.lineSeries && param.seriesPrices.get(tvWidget.series.lineSeries)) {
const price = param.seriesPrices.get(tvWidget.series.lineSeries);
priceInfo = `价格: ${price.toFixed(2)}`;
} else if (tvWidget.series.areaSeries && param.seriesPrices.get(tvWidget.series.areaSeries)) {
const price = param.seriesPrices.get(tvWidget.series.areaSeries);
priceInfo = `价格: ${price.toFixed(2)}`;
} else if (tvWidget.series.baselineSeries && param.seriesPrices.get(tvWidget.series.baselineSeries)) {
const price = param.seriesPrices.get(tvWidget.series.baselineSeries);
priceInfo = `价格: ${price.toFixed(2)}`;
}
// 如果没有蜡烛图系列价格,尝试从区域图系列获取
else if (tvWidget.series.areaSeries && param.seriesPrices.get(tvWidget.series.areaSeries)) {
const price = param.seriesPrices.get(tvWidget.series.areaSeries);
priceInfo = `价格: ${price.toFixed(2)}`;
}
// 如果没有蜡烛图系列价格,尝试从基线图系列获取
else if (tvWidget.series.baselineSeries && param.seriesPrices.get(tvWidget.series.baselineSeries)) {
const price = param.seriesPrices.get(tvWidget.series.baselineSeries);
priceInfo = `价格: ${price.toFixed(2)}`;
}
}
// 显示自定义时区工具提示,包含价格信息
crosshairTooltip.innerHTML = `<div style="font-weight:bold">时间: ${formattedTime}</div>` +
(priceInfo ? `<div>${priceInfo}</div>` : '');
crosshairTooltip.style.display = 'block';
crosshairTooltip.style.left = (param.point.x + 15) + 'px';
crosshairTooltip.style.top = (param.point.y - 30) + 'px';
}
if (allMarkers.length > 0) {
// 有买卖点或分型标记,显示自定义提示
const tooltips = allMarkers.map(m => m.tooltip).join('<br><hr style="margin: 5px 0;">');
tooltipElement.innerHTML = tooltips;
tooltipElement.style.display = 'block';
tooltipElement.style.left = (param.point.x + 15) + 'px';
tooltipElement.style.top = (param.point.y + 15) + 'px';
} else {
// 隐藏提示
tooltipElement.style.display = 'none';
}
} else {
// 隐藏提示
tooltipElement.style.display = 'none';
crosshairTooltip.style.display = 'none';
}
};
mainChart.subscribeCrosshairMove(crosshairHandler);
window._tooltipCleanups.push(() => {
try { mainChart.unsubscribeCrosshairMove(crosshairHandler); } catch(e) {}
});
// 处理图表缩放、平移等事件,隐藏提示
const hideTooltipHandler = () => {
tooltipElement.style.display = 'none';
crosshairTooltip.style.display = 'none';
};
mainChart.timeScale().subscribeVisibleTimeRangeChange(hideTooltipHandler);
window._tooltipCleanups.push(() => {
try { mainChart.timeScale().unsubscribeVisibleTimeRangeChange(hideTooltipHandler); } catch(e) {}
});
}
}
// 辅助函数:使用指定时区格式化时间戳
function formatTimeWithTimezone(timestamp, timezone) {
try {
return new Date(timestamp).toLocaleString('zh-CN', {
timeZone: timezone,
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
});
} catch (e) {
console.error('时区格式化错误:', e);
return new Date(timestamp).toLocaleString();
}
}
+356
View File
@@ -0,0 +1,356 @@
/* chart_tables.js — split from chart.js */
function updateTables(currentData) {
// 检查数据有效性
if (!currentData) {
console.error('updateTables: 传入的数据为空');
return;
}
const data = currentData;
const useSubSubPeriod = $('#subSubPeriodKline').is(':checked');
const useElementPeriod = $('#elementPeriodKline').is(':checked');
const periodLabel = useSubSubPeriod ? '次次周期' : (useElementPeriod ? '小周期' : '主周期');
console.log('数据表显示周期选择:', periodLabel);
// 笔数据表更新
if (tables.bi) {
tables.bi.clear().destroy();
}
let biData, biSource;
if (useSubSubPeriod && data.sub_sub_bi_list && data.sub_sub_bi_list.length > 0) {
biData = data.sub_sub_bi_list;
biSource = '次次周期';
} else if (useElementPeriod && data.element_bi_list && data.element_bi_list.length > 0) {
biData = data.element_bi_list;
biSource = '小周期';
} else {
biData = data.bi_list;
biSource = '主周期';
}
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();
}
let segData, segSource, uncompletedSegData;
if (useSubSubPeriod && data.sub_sub_seg_list && data.sub_sub_seg_list.length > 0) {
segData = data.sub_sub_seg_list;
uncompletedSegData = data.sub_sub_uncompleted_seg_list || [];
segSource = '次次周期';
} else if (useElementPeriod && data.element_seg_list && data.element_seg_list.length > 0) {
segData = data.element_seg_list;
uncompletedSegData = data.element_uncompleted_seg_list || [];
segSource = '小周期';
} else {
segData = data.seg_list;
uncompletedSegData = data.uncompleted_seg_list || [];
segSource = '主周期';
}
// 合并已完成和未完成的线段数据
let allSegData = [];
if (segData && segData.length > 0) {
allSegData = allSegData.concat(segData.map(seg => ({...seg, status: '已完成'})));
}
if (uncompletedSegData && uncompletedSegData.length > 0) {
allSegData = allSegData.concat(uncompletedSegData.map(seg => ({...seg, status: '未完成'})));
}
console.log(`表格显示${segSource}线段数据,已完成${segData ? segData.length : 0}条,未完成${uncompletedSegData ? uncompletedSegData.length : 0}条,总计${allSegData.length}`);
tables.seg = $('#segTable').DataTable({
data: allSegData,
order: [[0, 'desc']],
pageLength: 25,
columns: [
{ data: 'start_time', render: formatTime },
{ data: 'end_time', render: function(data, type, row) {
if (data === null || data === undefined) {
return type === 'display' ? '<span style="color: red;">未完成</span>' : '';
}
return formatTime(data, type, row);
}},
{ data: 'sure_time', render: formatConfirmTime },
{ data: 'start_price', render: formatPrice },
{ data: 'end_price', render: function(data, type, row) {
if (data === null || data === undefined) {
return type === 'display' ? '<span style="color: red;">未完成</span>' : '';
}
return formatPrice(data, type, row);
}},
{ data: 'direction', render: formatDirection },
{ data: 'status', render: function(data, type, row) {
if (type === 'display') {
const color = data === '已完成' ? 'green' : 'red';
return `<span style="color: ${color}; font-weight: bold;">${data}</span>`;
}
return data;
}}
]
});
// 中枢数据表更新
if (tables.zs) {
tables.zs.clear().destroy();
}
let zsData, zsSource;
if (useSubSubPeriod && data.sub_sub_zs_list && data.sub_sub_zs_list.length > 0) {
zsData = data.sub_sub_zs_list;
zsSource = '次次周期';
} else if (useElementPeriod && data.element_zs_list && data.element_zs_list.length > 0) {
zsData = data.element_zs_list;
zsSource = '小周期';
} else {
zsData = data.zs_list;
zsSource = '主周期';
}
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 }
]
});
// 买卖点数据表更新
if (tables.tradePoints) {
tables.tradePoints.clear().destroy();
}
let tradePointsData, tradePointsSource;
if (useSubSubPeriod && data.sub_sub_bsp_list && data.sub_sub_bsp_list.length > 0) {
tradePointsData = data.sub_sub_bsp_list;
tradePointsSource = '次次周期';
} else if (useElementPeriod && (data.element_trade_points && data.element_trade_points.length > 0 || data.element_bsp_list && data.element_bsp_list.length > 0)) {
tradePointsData = data.element_trade_points || data.element_bsp_list;
tradePointsSource = '小周期';
} else {
tradePointsData = data.trade_points || data.bsp_list;
tradePointsSource = '主周期';
}
console.log(`表格显示${tradePointsSource}买卖点数据,共${tradePointsData ? tradePointsData.length : 0}`);
tables.tradePoints = $('#tradePointsTable').DataTable({
data: tradePointsData || [],
order: [[0, 'desc']],
pageLength: 25,
columns: [
{ data: 'time', render: formatTime },
{ data: 'price', render: formatPrice },
{ data: 'type', render: formatTradePointType },
{ data: 'desc' }
]
});
// 更新数据源信息显示
const selectedPeriod = useSubSubPeriod ? '次次周期' : (useElementPeriod ? '小周期' : '主周期');
const timeframe = useSubSubPeriod && data.sub_sub_timeframe ? data.sub_sub_timeframe : (useElementPeriod && data.element_timeframe ? data.element_timeframe : $('#timeframe').val());
$('#dataSourceText').html(`当前显示的是<strong>${selectedPeriod} (${timeframe})</strong> 数据`);
// K线数据表更新
if (tables.kline) {
tables.kline.clear().destroy();
}
// 根据用户选择决定使用哪个周期的K线数据
let klineData, klineSource;
if (useSubSubPeriod && data.sub_sub_kline_data && data.sub_sub_kline_data.length > 0) {
klineData = data.sub_sub_kline_data;
klineSource = '次次周期';
} else if (useElementPeriod && data.element_kline_data && data.element_kline_data.length > 0) {
klineData = data.element_kline_data;
klineSource = '小周期';
} else {
klineData = data.kline_data;
klineSource = '主周期';
}
console.log(`表格显示${klineSource}K线数据,共${klineData ? klineData.length : 0}`);
tables.kline = $('#klineTable').DataTable({
data: klineData || [],
order: [[0, 'desc']],
pageLength: 25,
columns: [
{ data: 'date', render: function(data) { return formatTime(data); } },
{ data: 'open', render: formatPrice },
{ data: 'high', render: formatPrice },
{ data: 'low', render: formatPrice },
{ data: 'close', render: formatPrice },
{ data: 'volume', render: function(data) { return parseInt(data).toLocaleString(); } }
]
});
// 未完成中枢数据表更新
if (tables.uncompletedZs) {
tables.uncompletedZs.clear().destroy();
}
let uncompletedZsData, uncompletedZsSource;
if (useSubSubPeriod && data.sub_sub_uncompleted_zs_list && data.sub_sub_uncompleted_zs_list.length > 0) {
uncompletedZsData = data.sub_sub_uncompleted_zs_list;
uncompletedZsSource = '次次周期';
} else if (useElementPeriod && data.element_uncompleted_zs_list && data.element_uncompleted_zs_list.length > 0) {
uncompletedZsData = data.element_uncompleted_zs_list;
uncompletedZsSource = '小周期';
} else {
uncompletedZsData = data.uncompleted_zs_list;
uncompletedZsSource = '主周期';
}
console.log(`表格显示${uncompletedZsSource}未完成中枢数据,共${uncompletedZsData ? uncompletedZsData.length : 0}`);
tables.uncompletedZs = $('#uncompletedZsTable').DataTable({
data: uncompletedZsData || [],
order: [[0, 'desc']],
pageLength: 25,
columns: [
{ data: 'start_time', render: formatTime },
{ data: 'zg', render: formatPrice },
{ data: 'zd', render: formatPrice }
]
});
// MACD数据表更新
if (tables.macd) {
tables.macd.clear().destroy();
}
// 根据用户选择决定使用哪个周期的MACD数据
let macdDisplayData = [];
let macdSource;
if (useElementPeriod && data.element_kline_data && data.element_macd) {
// 使用小周期数据
macdSource = '小周期';
macdDisplayData = data.element_kline_data.map((item, index) => {
return {
time: item.date,
close: item.close,
macd: data.element_macd.macd[index],
signal: data.element_macd.signal[index],
histogram: data.element_macd.histogram[index]
};
});
} else if (data.kline_data && data.macd) {
// 使用主周期数据
macdSource = '主周期';
macdDisplayData = data.kline_data.map((item, index) => {
return {
time: item.date,
close: item.close,
macd: data.macd.macd[index],
signal: data.macd.signal[index],
histogram: data.macd.histogram[index]
};
});
}
console.log(`表格显示${macdSource}MACD数据,共${macdDisplayData.length}`);
tables.macd = $('#macdTable').DataTable({
data: macdDisplayData,
order: [[0, 'desc']],
pageLength: 25,
columns: [
{ data: 'time', render: formatTime },
{ data: 'close', render: formatPrice },
{ data: 'macd', render: formatMacdValue },
{ data: 'signal', render: formatMacdValue },
{ data: 'histogram', render: formatMacdValue }
]
});
// 更新数据源信息
setupDataSourceInfo(data);
}
// 设置数据源信息显示
function setupDataSourceInfo(data) {
const useSubSubPeriod = $('#subSubPeriodKline').is(':checked');
const useElementPeriod = $('#elementPeriodKline').is(':checked');
const mainTimeframe = $('#timeframe').val();
const elementTimeframe = data.element_timeframe || mainTimeframe;
const subSubTimeframe = data.sub_sub_timeframe || elementTimeframe;
$('#kline-tab, #macd-tab').off('click').on('click', function() {
$('.data-source-info').show();
if (useSubSubPeriod && data.sub_sub_kline_data && data.sub_sub_kline_data.length > 0) {
$('#dataSourceText').html(`当前显示的是<strong>次次周期 (${subSubTimeframe})</strong> 数据`);
} else if (useElementPeriod && data.element_kline_data && data.element_kline_data.length > 0) {
$('#dataSourceText').html(`当前显示的是<strong>小周期 (${elementTimeframe})</strong> 数据`);
} else {
$('#dataSourceText').html(`当前显示的是<strong>主周期 (${mainTimeframe})</strong> 数据`);
}
});
$('#bi-tab').off('click').on('click', function() {
$('.data-source-info').show();
if (useSubSubPeriod && data.sub_sub_bi_list && data.sub_sub_bi_list.length > 0) {
$('#dataSourceText').html(`当前显示的是<strong>次次周期 (${subSubTimeframe})</strong> 笔数据`);
} else if (useElementPeriod && data.element_bi_list && data.element_bi_list.length > 0) {
$('#dataSourceText').html(`当前显示的是<strong>小周期 (${elementTimeframe})</strong> 笔数据`);
} else {
$('#dataSourceText').html(`当前显示的是<strong>主周期 (${mainTimeframe})</strong> 笔数据`);
}
});
$('#seg-tab').off('click').on('click', function() {
$('.data-source-info').show();
if (useSubSubPeriod && data.sub_sub_seg_list && data.sub_sub_seg_list.length > 0) {
$('#dataSourceText').html(`当前显示的是<strong>次次周期 (${subSubTimeframe})</strong> 线段数据`);
} else if (useElementPeriod && data.element_seg_list && data.element_seg_list.length > 0) {
$('#dataSourceText').html(`当前显示的是<strong>小周期 (${elementTimeframe})</strong> 线段数据`);
} else {
$('#dataSourceText').html(`当前显示的是<strong>主周期 (${mainTimeframe})</strong> 线段数据`);
}
});
$('#zs-tab').off('click').on('click', function() {
$('.data-source-info').show();
if (useSubSubPeriod && data.sub_sub_zs_list && data.sub_sub_zs_list.length > 0) {
$('#dataSourceText').html(`当前显示的是<strong>次次周期 (${subSubTimeframe})</strong> 中枢数据`);
} else if (useElementPeriod && data.element_zs_list && data.element_zs_list.length > 0) {
$('#dataSourceText').html(`当前显示的是<strong>小周期 (${elementTimeframe})</strong> 中枢数据`);
} else {
$('#dataSourceText').html(`当前显示的是<strong>主周期 (${mainTimeframe})</strong> 中枢数据`);
}
});
$('#trade-points-tab').off('click').on('click', function() {
$('.data-source-info').show();
if (useSubSubPeriod && data.sub_sub_bsp_list && data.sub_sub_bsp_list.length > 0) {
$('#dataSourceText').html(`当前显示的是<strong>次次周期 (${subSubTimeframe})</strong> 买卖点数据`);
} else if (useElementPeriod && data.element_trade_points && data.element_trade_points.length > 0) {
$('#dataSourceText').html(`当前显示的是<strong>小周期 (${elementTimeframe})</strong> 买卖点数据`);
} else {
$('#dataSourceText').html(`当前显示的是<strong>主周期 (${mainTimeframe})</strong> 买卖点数据`);
}
});
// 初始触发当前标签的点击事件
$('.nav-link.active').trigger('click');
}
// 获取可用交易对
File diff suppressed because it is too large Load Diff
+148
View File
@@ -0,0 +1,148 @@
/* chart_view.js — split from chart.js */
function updateChart() {
// 只显示旋转加载图标
$('#refreshLoadingSpinner').show();
// 获取参数
const dataSource = $('#dataSource').val() || 'crypto';
let symbol;
if (dataSource === 'crypto') {
symbol = $('#symbol').val() || 'BTC/USDT:USDT';
} else {
symbol = $('#astockSymbol').val() || '000001';
}
const timeframe = $('#timeframe').val() || window.DEFAULT_MAIN_TIMEFRAME || '5m';
const timezone = $('#timezone').val() || 'Asia/Shanghai';
const elementTimeframe = $('#elementTimeframe').val() || window.DEFAULT_ELEMENT_TIMEFRAME || '1m';
const subSubTimeframe = $('#subSubTimeframe').val() || '';
// 确保时区参数有效
console.log('更新图表使用时区:', timezone);
console.log('数据源:', dataSource, '交易对/股票:', symbol);
// 如果symbol为空,不发送请求
if (!symbol) {
console.error('交易对/股票代码不能为空');
$('#refreshLoadingSpinner').hide();
return;
}
console.log(`更新图表: symbol=${symbol}, timeframe=${timeframe}, elementTimeframe=${elementTimeframe}, timezone=${timezone}`);
// 获取开始和结束时间(如果已设置)
let startTimeMs = null;
let endTimeMs = null;
if ($('#start_time').val()) {
startTimeMs = new Date($('#start_time').val()).getTime();
}
if ($('#end_time').val()) {
endTimeMs = new Date($('#end_time').val()).getTime();
}
// 发送请求
const requestId = ++lastRequestId; // 标记本次请求
$.ajax({
url: '/api/analyze',
data: {
symbol: symbol,
timeframe: timeframe,
timezone: timezone,
element_timeframe: elementTimeframe,
sub_sub_timeframe: subSubTimeframe || undefined,
start_time: startTimeMs,
end_time: endTimeMs,
elements_only: false,
zone_kl_lines: parseInt($('#zoneKlLines').val()) || 1000,
include_structure_zones: $('#showMainStructureZone').is(':checked') ? 1 : 0
},
success: function(data) {
// 隐藏加载图标
$('#refreshLoadingSpinner').hide();
// 忽略过期响应
if (requestId !== lastRequestId) {
return;
}
// 保存当前数据
if (currentData) {
// 覆盖前断开旧引用,帮助GC尽快回收
delete currentData.original_kline_data;
delete currentData.original_macd;
}
currentData = data;
refreshChart(data);
},
error: function(jqXHR, textStatus, errorThrown) {
// 隐藏加载图标
$('#refreshLoadingSpinner').hide();
// 显示错误信息
console.error('加载数据失败:', errorThrown);
alert('加载数据失败: ' + (jqXHR.responseJSON?.error || errorThrown));
}
});
}
function captureChartViewState(chart) {
if (!chart || !chart.timeScale) return null;
const ts = chart.timeScale();
const tsOptions = ts.options ? ts.options() : {};
return {
barSpacing: tsOptions.barSpacing,
rightOffset: tsOptions.rightOffset,
scrollPosition: ts.scrollPosition ? ts.scrollPosition() : null,
visibleRange: ts.getVisibleRange ? ts.getVisibleRange() : null,
logicalRange: ts.getVisibleLogicalRange ? ts.getVisibleLogicalRange() : null
};
}
function restoreChartViewState(charts, viewState) {
if (!viewState || !Array.isArray(charts) || charts.length === 0) return;
const validCharts = charts.filter(c => c && c.timeScale);
if (validCharts.length === 0) return;
validCharts.forEach(c => {
try {
const optionsPatch = {};
if (typeof viewState.barSpacing === 'number') optionsPatch.barSpacing = viewState.barSpacing;
if (typeof viewState.rightOffset === 'number') optionsPatch.rightOffset = viewState.rightOffset;
if (Object.keys(optionsPatch).length) {
c.timeScale().applyOptions(optionsPatch);
}
} catch (e) {}
});
let restored = false;
// 优先按逻辑范围恢复(对新数据更稳健)
if (viewState.logicalRange && viewState.logicalRange.from !== undefined && viewState.logicalRange.to !== undefined) {
validCharts.forEach(c => {
try {
c.timeScale().setVisibleLogicalRange(viewState.logicalRange);
restored = true;
} catch (e) {}
});
}
// 逻辑范围失败时,回退到时间可见范围
if (!restored && viewState.visibleRange && viewState.visibleRange.from !== undefined && viewState.visibleRange.to !== undefined) {
validCharts.forEach(c => {
try {
c.timeScale().setVisibleRange(viewState.visibleRange);
restored = true;
} catch (e) {}
});
}
// 最后回退到滚动位置
if (!restored && typeof viewState.scrollPosition === 'number') {
validCharts.forEach(c => {
try { c.timeScale().scrollToPosition(viewState.scrollPosition, false); } catch (e) {}
});
}
}
// 初始化图表
+306
View File
@@ -0,0 +1,306 @@
/**
* TradingView Datafeed — 对接 Data Provider 微服务
*
* 数据源: http://103.179.242.166
* - GET /timeframes → 可用周期
* - GET /api/candles → 历史 OHLCV
* - WS /ws → 实时 K 线推送
*
* 实现 IDatafeedChartApi 核心接口:
* onReady, resolveSymbol, getBars, subscribeBars, unsubscribeBars
*/
var ChanTVDatafeed = (function () {
'use strict'
// 默认 data_provider 地址,可通过 URL param 覆盖
var DATA_HOST = 'http://103.179.242.166'
// ---- resolution <-> timeframe 转换 ----
var RES_TO_TF = {
'1': '1m', '3': '3m', '5': '5m', '10': '10m', '15': '15m', '30': '30m',
'60': '1h', '120': '2h', '240': '4h', '360': '6h', '480': '8h',
'720': '12h',
'D': '1d', '1D': '1d',
'3D': '3d',
'W': '1w', '1W': '1w',
'M': '1M', '1M': '1M',
}
function resToTf(resolution) {
var r = String(resolution)
return RES_TO_TF[r] || r
}
// ---- WebSocket 管理 ----
var ws = null
var wsReconnectTimer = null
var wsSubs = {} // listenerGuid -> { symbol, tf, onTick, lastTickTime }
var wsUrl = DATA_HOST.replace(/^http/, 'ws') + '/ws'
function wsConnect() {
if (ws && (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING)) return
try {
ws = new WebSocket(wsUrl)
} catch (e) {
console.warn('[TV Datafeed] WS 连接失败', e)
scheduleReconnect()
return
}
ws.onopen = function () {
console.log('[TV Datafeed] WS 已连接')
// 重新订阅
Object.keys(wsSubs).forEach(function (guid) {
var sub = wsSubs[guid]
sendWS({ action: 'subscribe', symbol: sub.symbol, timeframe: sub.tf })
})
}
ws.onmessage = function (evt) {
try {
var msg = JSON.parse(evt.data)
var bars = msg.data || msg.bars // data_provider 用 'data' 字段
if ((msg.type === 'kline' || msg.type === 'candles') && bars && bars.length > 0) {
// 只推送最新一根 bar,避免历史快照造成时间顺序冲突
// 按时间升序排列取最后一个
var sorted = bars.slice().sort(function (a, b) { return (a.timestamp || 0) - (b.timestamp || 0) })
var latest = sorted[sorted.length - 1]
// 广播给所有匹配的 subscriber
Object.keys(wsSubs).forEach(function (guid) {
var sub = wsSubs[guid]
if (sub.symbol === msg.symbol && sub.tf === msg.timeframe) {
// 跳过已处理过的时间戳
if (sub.lastTickTime && latest.timestamp <= sub.lastTickTime) return
try {
sub.onTick({
time: latest.timestamp,
open: latest.open,
high: latest.high,
low: latest.low,
close: latest.close,
volume: latest.volume,
})
sub.lastTickTime = latest.timestamp
} catch (e) { /* ignore */ }
}
})
}
} catch (e) {
// ignore parse errors
}
}
ws.onclose = function () {
console.log('[TV Datafeed] WS 断开')
ws = null
scheduleReconnect()
}
ws.onerror = function () {
// onclose 会跟着触发
}
}
function scheduleReconnect() {
if (wsReconnectTimer) return
wsReconnectTimer = setTimeout(function () {
wsReconnectTimer = null
wsConnect()
}, 3000)
}
function sendWS(data) {
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify(data))
}
}
// ---- Datafeed API ----
/**
* 主配置:返回支持的 resolutions、exchanges 等
*/
function onReady(callback) {
// 使用固定 resolutions(避免 /timeframes 502 阻塞初始化)
var supported = ['1', '5', '15', '30', '60', '120', '240', 'D', 'W']
console.log('[TV Datafeed] onReady — supported_resolutions:', supported)
setTimeout(function () {
callback({
supported_resolutions: supported,
supports_marks: false,
supports_timescale_marks: false,
supports_time: true,
exchanges: [{ value: 'BINANCE', name: 'Binance', desc: 'Binance Futures' }],
symbols_types: [{ name: 'Crypto', value: 'crypto' }],
})
}, 0)
}
/**
* 解析 symbol'BINANCE:BTC/USDT:USDT' → 分离 exchange 和 symbol
*/
function resolveSymbol(symbolName, onResolve, onError) {
var name = String(symbolName)
var exchange = 'BINANCE'
var symbol = name
// 解析 EXCHANGE:SYMBOL 格式
// 如果第一段不含 '/',就是交易所名;否则整串就是 symbol
// 例: 'BINANCE:BTC/USDT:USDT' → exchange=BINANCE, sym=BTC/USDT:USDT
// 'BTC/USDT:USDT' → exchange=BINANCE, sym=BTC/USDT:USDT
// 'BTC/USDT' → exchange=BINANCE, sym=BTC/USDT:USDT
var firstColon = name.indexOf(':')
if (firstColon >= 0) {
var prefix = name.substring(0, firstColon)
if (prefix.indexOf('/') === -1) {
// 第一段是交易所名(如 'BINANCE'
exchange = prefix
symbol = name.substring(firstColon + 1)
}
// 否则第一段含 '/'(如 'BTC/USDT'),整串就是 symbol
}
// data_provider 用 BTC/USDT:USDT 格式(需要 :USDT 后缀)
var dpSymbol = symbol
if (dpSymbol.indexOf(':USDT') === -1 && dpSymbol.indexOf('/USDT') >= 0) {
dpSymbol = dpSymbol + ':USDT'
}
console.log('[TV Datafeed] resolveSymbol', name, '→ exchange:', exchange, 'symbol:', symbol, 'dp:', dpSymbol)
// TV 要求异步回调(setTimeout 0
setTimeout(function () {
onResolve({
name: name,
ticker: name,
description: symbol,
exchange: exchange,
type: 'crypto',
session: '24x7',
timezone: 'Asia/Shanghai',
minmov: 1,
pricescale: 100,
has_intraday: true,
has_seconds: false,
has_daily: true,
has_weekly_and_monthly: true,
supported_resolutions: ['1', '5', '15', '30', '60', '120', '240', 'D', 'W'],
intraday_multipliers: ['1', '5', '15', '30', '60', '120', '240'],
volume_precision: 2,
_dpSymbol: dpSymbol,
})
}, 0)
}
/**
* 获取历史 bars
*/
function getBars(symbolInfo, resolution, periodParams, onResult, onError) {
var tf = resToTf(resolution)
var symbol = symbolInfo._dpSymbol || symbolInfo.ticker.split(':').slice(1).join(':')
// 确保 symbol 是 data_provider 格式
if (symbol.indexOf(':USDT') === -1 && symbol.indexOf('/USDT') >= 0) {
symbol = symbol + ':USDT'
}
var params = 'symbol=' + encodeURIComponent(symbol) + '&tf=' + encodeURIComponent(tf)
// periodParams.from / to 是秒,data_provider 需要毫秒
if (periodParams.from) {
params += '&start=' + (periodParams.from * 1000)
}
if (periodParams.to) {
params += '&end=' + (periodParams.to * 1000)
}
if (periodParams.firstDataRequest) {
// 首次请求多取一些数据供缠论计算
params += '&limit=1000'
}
var url = DATA_HOST + '/api/candles?' + params
console.log('[TV Datafeed] getBars', symbol, tf, '→', url)
fetch(url)
.then(function (r) {
if (!r.ok) throw new Error('HTTP ' + r.status)
return r.json()
})
.then(function (data) {
console.log('[TV Datafeed] getBars 返回', data.length, '条')
if (!Array.isArray(data) || data.length === 0) {
onResult([], { noData: true })
return
}
// 按时间升序排列并去重,避免跨请求重叠导致时间顺序冲突
var seen = {}
var bars = []
data.forEach(function (d) {
if (!seen[d.timestamp]) {
seen[d.timestamp] = true
bars.push({
time: d.timestamp, // ms
open: d.open,
high: d.high,
low: d.low,
close: d.close,
volume: d.volume,
})
}
})
bars.sort(function (a, b) { return a.time - b.time })
// 传 noData: false 表示还有更多历史数据
onResult(bars, { noData: false })
})
.catch(function (err) {
console.error('[TV Datafeed] getBars 失败', err)
onError(err.message || '获取数据失败')
})
}
/**
* 订阅实时数据(通过 WebSocket)
*/
function subscribeBars(symbolInfo, resolution, onTick, listenerGuid) {
var tf = resToTf(resolution)
var symbol = symbolInfo._dpSymbol || symbolInfo.ticker.split(':').slice(1).join(':')
if (symbol.indexOf(':USDT') === -1 && symbol.indexOf('/USDT') >= 0) {
symbol = symbol + ':USDT'
}
wsSubs[listenerGuid] = { symbol: symbol, tf: tf, onTick: onTick }
// 确保 WS 已连接
wsConnect()
// 如果已连接,立即订阅
if (ws && ws.readyState === WebSocket.OPEN) {
sendWS({ action: 'subscribe', symbol: symbol, timeframe: tf })
}
// 否则等 WS onopen 时会重新订阅所有
}
/**
* 取消订阅
*/
function unsubscribeBars(listenerGuid) {
var sub = wsSubs[listenerGuid]
if (sub) {
sendWS({ action: 'unsubscribe', symbol: sub.symbol, timeframe: sub.tf })
delete wsSubs[listenerGuid]
}
}
// ---- 导出 ----
return {
onReady: onReady,
resolveSymbol: resolveSymbol,
getBars: getBars,
subscribeBars: subscribeBars,
unsubscribeBars: unsubscribeBars,
}
})()
+258
View File
@@ -0,0 +1,258 @@
/* macd_ui.js */
function showMacdConfig() {
$.get('/api/macd_config', function(data) {
$('#macdFastPeriod').val(data.fast);
$('#macdSlowPeriod').val(data.slow);
$('#macdSignalPeriod').val(data.signal);
$('#macdConfigModal').css('display', 'flex');
});
}
function hideMacdConfig() {
$('#macdConfigModal').css('display', 'none');
}
function resetMacdConfig() {
$('#macdFastPeriod').val(24);
$('#macdSlowPeriod').val(52);
$('#macdSignalPeriod').val(9);
}
function saveMacdConfig() {
const fast = parseInt($('#macdFastPeriod').val());
const slow = parseInt($('#macdSlowPeriod').val());
const signal = parseInt($('#macdSignalPeriod').val());
if (fast >= slow) {
alert('快线周期必须小于慢线周期');
return;
}
if (fast < 2 || slow < 2 || signal < 2) {
alert('周期值必须大于等于2');
return;
}
$.ajax({
url: '/api/macd_config',
method: 'POST',
contentType: 'application/json',
data: JSON.stringify({ fast: fast, slow: slow, signal: signal }),
success: function() {
hideMacdConfig();
updateChart();
},
error: function() {
alert('保存MACD参数失败');
}
});
}
$(document).on('click', '#macdConfigModal', function(e) {
if (e.target === this) hideMacdConfig();
});
// 添加原始K线复选框变更事件
$('#showOriginalKline').change(function() {
updateChartDisplay();
});
// 添加K线形态下拉变更事件(同步隐藏的原始K线开关并重绘)
$('#klineType').change(function() {
const type = $(this).val();
$('#showOriginalKline').prop('checked', type === 'candlestick');
updateChartDisplay();
});
// 添加笔复选框变更事件
$('#showMainBi').change(function() {
updateChartDisplay();
});
// 添加线段复选框变更事件
$('#showMainSeg').change(function() {
updateChartDisplay();
});
// 添加中枢复选框变更事件
$('#showMainZs').change(function() {
updateChartDisplay();
});
// 添加主周期BI中枢复选框变更事件(委托绑定,避免DOM更新后失效)
console.log('初始化BI中枢事件绑定');
$(document).on('change', '#showMainBiZs', function() {
console.log('主BI中枢切换为:', $('#showMainBiZs').is(':checked'));
updateChartDisplay();
});
// 结构价值区复选框变更事件
$(document).on('change', '#showMainStructureZone', function() {
const on = $('#showMainStructureZone').is(':checked');
console.log('结构区切换为:', on);
// 勾选后才向服务器请求多周期结构区数据;取消勾选仅重绘,不重复拉取
if (on) {
updateChart();
} else {
updateChartDisplay();
}
});
// 添加趋势显示复选框变更事件(主/元素),变更后刷新主图
$('#showMainTrend').change(function() {
updateChartDisplay();
});
$('#showElementTrend').change(function() {
updateChartDisplay();
});
// 添加买卖点复选框变更事件
$('#showElementBi').change(function() {
updateChartDisplay();
});
// 添加线段复选框变更事件
$('#showElementSeg').change(function() {
updateChartDisplay();
});
// 添加中枢复选框变更事件
$('#showElementZs').change(function() {
updateChartDisplay();
});
// 添加次周期BI中枢复选框变更事件(委托绑定,避免DOM更新后失效)
$(document).on('change', '#showElementBiZs', function() {
console.log('次BI中枢切换为:', $('#showElementBiZs').is(':checked'));
updateChartDisplay();
});
// 次次周期显示开关变更事件
$('#showSubSubBi, #showSubSubSeg, #showSubSubZs, #showSubSubBiZs, #showSubSubKlcFxType, #showSubSubTrend, #showSubSubBsp').change(function() {
updateChartDisplay();
});
$(document).on('change', '#toggleUOnSubSub', function() {
window.showUOnSubSub = $('#toggleUOnSubSub').is(':checked');
updateChartDisplay();
});
// 买卖点复选框已移除
// 趋势开关已移除
// 添加K线周期切换事件监听器
$('input[name="klinePeriod"]').change(function() {
console.log('K线周期切换:', $(this).attr('id'), $(this).is(':checked'));
updateChartDisplay();
// 更新数据源信息
if (currentData) {
setupDataSourceInfo(currentData);
}
});
// 当选择不同的元素时间周期时
$('#elementTimeframe').change(function() {
const elementTimeframe = $(this).val();
const mainTimeframe = $('#timeframe').val();
// 检查选择的元素时间周期是否小于等于主周期
if (compareTimeframes(elementTimeframe, mainTimeframe) > 0) {
alert('元素时间周期必须小于或等于主图表时间周期。');
setSmallerOrEqualTimeframe(); // 重置为最大的小于等于时间周期
return;
}
// 次次周期必须小于等于次周期
ensureSubSubLteElement();
console.log(`当前选择的元素时间周期: ${elementTimeframe},需要点击分析按钮来应用更改`);
});
// 次次周期变更时校验 <= 次周期
$('#subSubTimeframe').change(function() {
const subSub = $(this).val();
const elementTf = $('#elementTimeframe').val();
if (compareTimeframes(subSub, elementTf) > 0) {
alert('次次周期必须小于或等于次周期。');
ensureSubSubLteElement();
return;
}
});
function ensureSubSubLteElement() {
const timeframes = window.AVAILABLE_TIMEFRAMES || [];
const elementTf = $('#elementTimeframe').val();
const subSubTf = $('#subSubTimeframe').val();
if (compareTimeframes(subSubTf, elementTf) > 0) {
const idxEl = timeframes.indexOf(elementTf);
const validSubSub = idxEl > 0 ? timeframes[idxEl - 1] : timeframes[0];
$('#subSubTimeframe').val(validSubSub || elementTf);
}
}
/** 应用 /api/chart_metadata 返回的周期列表(切换 crypto / A股 时拉取) */
function applyChartMetadata(meta) {
if (!meta || meta.error || !Array.isArray(meta.timeframe_keys) || meta.timeframe_keys.length === 0) {
return;
}
window.AVAILABLE_TIMEFRAMES = meta.timeframe_keys;
window.DEFAULT_MAIN_TIMEFRAME = meta.default_main;
window.DEFAULT_ELEMENT_TIMEFRAME = meta.default_element;
window.DEFAULT_SUB_SUB_TIMEFRAME = meta.default_sub_sub;
const labels = meta.timeframes || {};
function refill(selId, preferredVal) {
const $el = $(selId);
const cur = $el.val();
$el.empty();
meta.timeframe_keys.forEach(function(k) {
$el.append($('<option>', { value: k, text: labels[k] || k }));
});
const pick = (cur && meta.timeframe_keys.indexOf(cur) >= 0) ? cur : preferredVal;
if (pick && meta.timeframe_keys.indexOf(pick) >= 0) {
$el.val(pick);
} else {
$el.val(meta.timeframe_keys[0]);
}
}
refill('#timeframe', meta.default_main);
refill('#elementTimeframe', meta.default_element);
refill('#subSubTimeframe', meta.default_sub_sub);
const mainTf = $('#timeframe').val();
if (compareTimeframes($('#elementTimeframe').val(), mainTf) > 0) {
setSmallestLargerTimeframe(mainTf);
}
ensureSubSubLteElement();
}
// 比较两个时间周期的大小
function compareTimeframes(tf1, tf2) {
const v1 = window.timeframeToMs(tf1);
const v2 = window.timeframeToMs(tf2);
if (v1 === null || v2 === null) {
return 0;
}
return v1 - v2;
}
// 设置比主周期小的最大周期
function setSmallestLargerTimeframe(mainTimeframe) {
const timeframes = window.AVAILABLE_TIMEFRAMES || [];
const mainIndex = timeframes.indexOf(mainTimeframe);
if (mainIndex > 0) {
$('#elementTimeframe').val(timeframes[mainIndex - 1]);
} else {
$('#elementTimeframe').val(timeframes[0]);
}
}
// 设置小于或等于主周期的时间周期
function setSmallerOrEqualTimeframe(mainTimeframe) {
const timeframes = window.AVAILABLE_TIMEFRAMES || [];
const mainIndex = timeframes.indexOf(mainTimeframe);
// 默认选择相同的时间周期
$('#elementTimeframe').val(mainTimeframe);
}
// 当主时间周期变更时,确保分形元素时间周期、次次周期正确
$('#timeframe').change(function() {
const mainTimeframe = $(this).val();
const elementTimeframe = $('#elementTimeframe').val();
if (compareTimeframes(elementTimeframe, mainTimeframe) > 0) {
setSmallerOrEqualTimeframe(mainTimeframe);
}
ensureSubSubLteElement();
});
let _lastKlinePeriod = 'main';
+443
View File
@@ -0,0 +1,443 @@
/* main.js */
$(document).ready(function() {
// 初始化技术指标下拉菜单
initIndicatorDropdown();
// 设置默认的筛选时间(最近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);
}
});
});
// ====== ChanMACD图表相关函数 ======
// 清除ChanMACD标注
function clearChanMacdMarkers() {
// 清除所有系列的标记
if (tvWidget.series.chanMacdLineSeries) {
tvWidget.series.chanMacdLineSeries.setMarkers([]);
}
if (tvWidget.series.chanMacdSignalSeries) {
tvWidget.series.chanMacdSignalSeries.setMarkers([]);
}
if (tvWidget.series.chanMacdHistSeries) {
tvWidget.series.chanMacdHistSeries.setMarkers([]);
}
// 清空全局UnitTF标记,避免旧数据残留影响主图合并
window.unittfMarkers = [];
}
// 添加所有ChanMACD标记
function addAllChanMacdMarkers(segList, unittfList, histsetList, stateMarkers) {
const macdMarkers = [];
const signalMarkers = [];
const histMarkers = [];
const boundaryMarkers = [];
const uTooltipMarkers = [];
// 添加段标记到MACD线
console.log('处理段标记,段数量:', segList.length);
segList.forEach((seg, index) => {
console.log(`${index}:`, {
start_time: seg.start_time,
end_time: seg.end_time,
seg_dir: seg.seg_dir,
has_start: !!seg.start_time,
has_end: !!seg.end_time
});
if (!seg.start_time) {
console.log(`${index}没有开始时间,跳过`);
return;
}
const startTime = new Date(seg.start_time).getTime() / 1000;
console.log(`${index}开始时间戳:`, startTime);
macdMarkers.push({
time: startTime,
position: 'aboveBar',
color: seg.seg_dir === 'ABOVE' ? '#e91e63' : '#4caf50',
shape: 'square',
text: `S${index}`,
size: 0.5
});
if (seg.end_time) {
const endTime = new Date(seg.end_time).getTime() / 1000;
console.log(`${index}结束时间戳:`, endTime);
macdMarkers.push({
time: endTime,
position: 'aboveBar',
color: seg.seg_dir === 'ABOVE' ? '#e91e63' : '#4caf50',
shape: 'square',
text: `S${index}E`,
size: 0.5
});
}
});
console.log('生成的段标记数量:', macdMarkers.length);
// 添加UnitTF标记(用于U
console.log('DEBUG: U 源数据条数:', Array.isArray(unittfList) ? unittfList.length : 'not array');
unittfList.forEach((unittf, index) => {
if (!unittf.start_time || unittf.invalid) return;
const startTime = new Date(unittf.start_time).getTime() / 1000;
if (index === 0) {
console.log('DEBUG: U0 示例:', unittf);
}
const startMarker = {
time: startTime,
position: unittf.dir > 0 ? 'aboveBar' : 'belowBar',
color: unittf.dir > 0 ? '#ff9800' : '#9c27b0',
shape: 'circle',
text: `U${index}`,
size: 0.5
};
signalMarkers.push(startMarker);
// tooltip(开始)
uTooltipMarkers.push({
time: startTime,
tooltip: `<div style="color: ${startMarker.color}; font-weight: bold;">
UnitTF(${unittf.dir > 0 ? '正区' : '负区'}) 开始<br>
峰值: ${unittf.peak_abs ?? '-'} 长度: ${unittf.length ?? '-'}<br>
类型: ${unittf.start_type ?? '-'}<br>
时间: ${unittf.start_time}
</div>`
});
if (unittf.end_time) {
const endTime = new Date(unittf.end_time).getTime() / 1000;
const endMarker = {
time: endTime,
position: unittf.dir > 0 ? 'aboveBar' : 'belowBar',
color: unittf.dir > 0 ? '#ff9800' : '#9c27b0',
shape: 'circle',
text: `U${index}E`,
size: 0.5
};
signalMarkers.push(endMarker);
// tooltip(结束)
uTooltipMarkers.push({
time: endTime,
tooltip: `<div style="color: ${endMarker.color}; font-weight: bold;">
UnitTF(${unittf.dir > 0 ? '正区' : '负区'}) 结束<br>
峰值: ${unittf.peak_abs ?? '-'} 长度: ${unittf.length ?? '-'}<br>
类型: ${unittf.end_type ?? '-'}<br>
时间: ${unittf.end_time}
</div>`
});
}
});
// 识别"U 结束与新 U 开始同一根K"的边界,并显示合成标记(即使前一个U是 invalid 也显示边界)
for (let i = 0; i + 1 < unittfList.length; i++) {
const cur = unittfList[i];
const nxt = unittfList[i + 1];
if (!cur.end_time || !nxt.start_time) continue;
const tEnd = new Date(cur.end_time).getTime();
const tStart = new Date(nxt.start_time).getTime();
if (!isNaN(tEnd) && tEnd === tStart) {
const ts = Math.floor(tEnd / 1000);
const color = nxt.dir > 0 ? '#ffb74d' : '#ba68c8';
const marker = {
time: ts,
position: nxt.dir > 0 ? 'aboveBar' : 'belowBar',
color: color,
shape: 'square',
text: 'U↔',
size: 0.6
};
boundaryMarkers.push(marker);
uTooltipMarkers.push({
time: ts,
tooltip: `<div style="color: ${color}; font-weight: bold;">\n U 结束 + 新 U 开始 (边界)<br>\n 结束方向: ${cur.dir > 0 ? '正区' : '负区'} → 新方向: ${nxt.dir > 0 ? '正区' : '负区'}<br>\n 时间: ${nxt.start_time}\n </div>`
});
}
}
// 添加HistSet标记到Histogram
histsetList.forEach((histset, index) => {
if (!histset.start_time) return;
const startTime = new Date(histset.start_time).getTime() / 1000;
if (false) {
histMarkers.push({
time: startTime,
position: histset.histset_dir === 'ABOVE' ? 'aboveBar' : 'belowBar',
color: histset.histset_dir === 'ABOVE' ? '#4caf50' : '#f44336',
shape: 'arrowUp',
text: `H${index}`,
size: 0.5
});
if (histset.end_time) {
const endTime = new Date(histset.end_time).getTime() / 1000;
histMarkers.push({
time: endTime,
position: histset.histset_dir === 'ABOVE' ? 'aboveBar' : 'belowBar',
color: histset.histset_dir === 'ABOVE' ? '#4caf50' : '#f44336',
shape: 'arrowDown',
text: `H${index}E`,
size: 0.5
});
}
}
});
// 设置所有标记
console.log('设置段标记到图表,标记数量:', macdMarkers.length);
if (tvWidget.series.chanMacdLineSeries && macdMarkers.length > 0) {
tvWidget.series.chanMacdLineSeries.setMarkers(macdMarkers);
console.log('✅ 段标记已设置到chanMacdLineSeries');
} else {
console.log('⚠️ 无法设置段标记:', {
hasSeries: !!tvWidget.series.chanMacdLineSeries,
markersLength: macdMarkers.length
});
}
// 保存到全局,供主图与分型一起统一合并绘制(仅在开关开启时)
console.log('DEBUG: U 标记数量:', signalMarkers.length);
const allowUMerge = (window.showUOnMain && window.showUOnElement);
window.unittfMarkers = allowUMerge ? [...signalMarkers, ...boundaryMarkers] : [];
if (uTooltipMarkers.length > 0) {
if (window.fxMarkers) {
window.fxMarkers = [ ...window.fxMarkers, ...uTooltipMarkers ];
} else {
window.fxMarkers = uTooltipMarkers;
}
}
// 同时在ChanMACD的Signal子图上标注U
if (tvWidget.series.chanMacdSignalSeries && (signalMarkers.length > 0 || boundaryMarkers.length > 0)) {
tvWidget.series.chanMacdSignalSeries.setMarkers([...signalMarkers, ...boundaryMarkers]);
}
if (tvWidget.series.chanMacdHistSeries && histMarkers.length > 0) {
tvWidget.series.chanMacdHistSeries.setMarkers(histMarkers);
}
// 添加状态标记
if (stateMarkers) {
addStateMarkers(stateMarkers);
}
}
// 添加状态标记
function addStateMarkers(stateMarkers) {
const stateMarkersList = [];
const stateTooltips = [];
// 调试信息
console.log('DEBUG: 状态标记数据:', stateMarkers);
console.log('DEBUG: 高位列表长度:', stateMarkers.high_position_list ? stateMarkers.high_position_list.length : 0);
console.log('DEBUG: 低位列表长度:', stateMarkers.low_position_list ? stateMarkers.low_position_list.length : 0);
console.log('DEBUG: 高位空列表长度:', stateMarkers.high_empty_list ? stateMarkers.high_empty_list.length : 0);
console.log('DEBUG: 低位空列表长度:', stateMarkers.low_empty_list ? stateMarkers.low_empty_list.length : 0);
// HP/HPE:仅使用高位术语(正负两侧统一展示为HP/HPE)
const hpHeTemp = [];
let hpIdx = 0; // 高位峰值计数
let heIdx = 0; // 高位空(HPE)计数
(stateMarkers.high_position_list || []).forEach(m => {
if (!m.time) return;
const t = new Date(m.time).getTime()/1000;
const color = '#e91e63';
hpHeTemp.push({ t, position: 'belowBar', color, text: `HP${hpIdx}` });
stateTooltips.push({
time: t,
tooltip: `<div style="color:${color};font-weight:bold;">HP${hpIdx} 峰值<br>MACD:${(m.macd??'').toFixed?.(4)||m.macd}<br>SIGNAL:${(m.signal??'').toFixed?.(4)||m.signal}<br>HIST:${(m.macdhist??'').toFixed?.(4)||m.macdhist}</div>`
});
hpIdx++;
});
(stateMarkers.low_position_list || []).forEach(m => {
if (!m.time) return;
const t = new Date(m.time).getTime()/1000;
const color = '#4caf50';
// 低位峰值也统一标记为 HP(按需求不使用 LP)
hpHeTemp.push({ t, position: 'aboveBar', color, text: `HP${hpIdx}` });
stateTooltips.push({
time: t,
tooltip: `<div style=\"color:${color};font-weight:bold;\">HP${hpIdx} 峰值(正区)<br>MACD:${(m.macd??'').toFixed?.(4)||m.macd}<br>SIGNAL:${(m.signal??'').toFixed?.(4)||m.signal}<br>HIST:${(m.macdhist??'').toFixed?.(4)||m.macdhist}</div>`
});
hpIdx++;
});
(stateMarkers.high_empty_list || []).forEach(m => {
if (!m.time) return;
const t = new Date(m.time).getTime()/1000;
const color = '#ff9800';
hpHeTemp.push({ t, position: 'belowBar', color, text: `HPE${heIdx}` });
stateTooltips.push({
time: t,
tooltip: `<div style="color:${color};font-weight:bold;">HPE${heIdx} 黄白交叉<br>MACD:${(m.macd??'').toFixed?.(4)||m.macd}<br>SIGNAL:${(m.signal??'').toFixed?.(4)||m.signal}<br>HIST:${(m.macdhist??'').toFixed?.(4)||m.macdhist}</div>`
});
heIdx++;
});
(stateMarkers.low_empty_list || []).forEach(m => {
if (!m.time) return;
const t = new Date(m.time).getTime()/1000;
const color = '#17a2b8';
// 低位空也统一标记为 HPE(按需求不使用 LPE)
hpHeTemp.push({ t, position: 'aboveBar', color, text: `HPE${heIdx}` });
stateTooltips.push({
time: t,
tooltip: `<div style=\"color:${color};font-weight:bold;\">HPE${heIdx} 黄白交叉(正区)<br>MACD:${(m.macd??'').toFixed?.(4)||m.macd}<br>SIGNAL:${(m.signal??'').toFixed?.(4)||m.signal}<br>HIST:${(m.macdhist??'').toFixed?.(4)||m.macdhist}</div>`
});
heIdx++;
});
hpHeTemp.sort((a,b)=>a.t-b.t).forEach(it => {
stateMarkersList.push({
time: it.t,
position: it.position,
color: it.color,
shape: 'diamond',
text: it.text,
size: 0.65
});
});
// 设置状态标记到 MACD 线
if (tvWidget.series.chanMacdLineSeries && stateMarkersList.length > 0) {
tvWidget.series.chanMacdLineSeries.setMarkers(stateMarkersList);
console.log('✅ 状态标记已设置到MACD线,数量:', stateMarkersList.length);
}
// 将状态标记的 tooltip 合并入全局,主图悬浮可见
if (stateTooltips.length > 0) {
if (window.fxMarkers) {
window.fxMarkers = [...window.fxMarkers, ...stateTooltips];
} else {
window.fxMarkers = stateTooltips;
}
}
}
// 保留原函数用于向后兼容(但不使用)
function addChanMacdSegMarkers(segList) {
const markers = [];
segList.forEach((seg, index) => {
if (!seg.start_time) return;
const startTime = new Date(seg.start_time).getTime() / 1000;
if (false){
// 添加起点标记
markers.push({
time: startTime,
position: 'aboveBar',
color: seg.seg_dir === 'ABOVE' ? '#e91e63' : '#4caf50',
shape: 'square',
text: `S${index}`,
size: 0.5
});
// 如果有结束时间,添加结束标记
if (seg.end_time) {
const endTime = new Date(seg.end_time).getTime() / 1000;
markers.push({
time: endTime,
position: 'aboveBar',
color: seg.seg_dir === 'ABOVE' ? '#e91e63' : '#4caf50',
shape: 'square',
text: `S${index}E`,
size: 0.5
});
}
}
});
// 设置标记到MACD线上
if (tvWidget.series.chanMacdLineSeries && markers.length > 0) {
tvWidget.series.chanMacdLineSeries.setMarkers(markers);
}
}
// 添加UnitTF标注
function addChanMacdUnitTFMarkers(unittfList) {
const markers = [];
unittfList.forEach((unittf, index) => {
if (!unittf.start_time || unittf.invalid) return;
const startTime = new Date(unittf.start_time).getTime() / 1000;
// 添加起点标记
markers.push({
time: startTime,
position: 'belowBar',
color: unittf.dir > 0 ? '#ff9800' : '#9c27b0',
shape: 'circle',
text: `U${index}`,
size: 0.5
});
// 如果有结束时间,添加结束标记
if (unittf.end_time) {
const endTime = new Date(unittf.end_time).getTime() / 1000;
markers.push({
time: endTime,
position: 'belowBar',
color: unittf.dir > 0 ? '#ff9800' : '#9c27b0',
shape: 'circle',
text: `U${index}E`,
size: 0.5
});
}
});
// 设置标记到信号线上
if (tvWidget.series.chanMacdSignalSeries && markers.length > 0) {
tvWidget.series.chanMacdSignalSeries.setMarkers(markers);
}
}
// 添加HistSet标注
function addChanMacdHistSetMarkers(histsetList) {
const markers = [];
histsetList.forEach((histset, index) => {
if (!histset.start_time) return;
const startTime = new Date(histset.start_time).getTime() / 1000;
// 添加起点标记
markers.push({
time: startTime,
position: histset.histset_dir === 'ABOVE' ? 'aboveBar' : 'belowBar',
color: histset.histset_dir === 'ABOVE' ? '#4caf50' : '#f44336',
shape: 'arrowUp',
text: `H${index}`,
size: 0.5
});
// 如果有结束时间,添加结束标记
if (histset.end_time) {
const endTime = new Date(histset.end_time).getTime() / 1000;
markers.push({
time: endTime,
position: histset.histset_dir === 'ABOVE' ? 'aboveBar' : 'belowBar',
color: histset.histset_dir === 'ABOVE' ? '#4caf50' : '#f44336',
shape: 'arrowDown',
text: `H${index}E`,
size: 0.5
});
}
});
// 设置标记到柱状图上
if (tvWidget.series.chanMacdHistSeries && markers.length > 0) {
tvWidget.series.chanMacdHistSeries.setMarkers(markers);
}
}
File diff suppressed because it is too large Load Diff
+16
View File
@@ -0,0 +1,16 @@
/* state.js */
var currentData = null;
var lastRequestId = 0; // 防止过期响应覆盖新数据
var FEATURES = {
trendFilter: false, // 趋势筛选/趋势小图等
dataReplay: false, // 数据回放功能
legacyMacd: false // 旧MACD(已弃用)
};
var tables = {};
// ======= 趋势筛选(币对) =======
var trendTable = null;
var trendDetailTable = null;
var trendChart = null;
+571
View File
@@ -0,0 +1,571 @@
/* trend.js */
function initTrendTables() {
if (!FEATURES.trendFilter) return; // 未启用则跳过初始化
if (!trendTable) {
trendTable = $('#trendFilterTable').DataTable({
paging: true,
searching: false,
info: true,
order: [[4, 'desc']],
});
}
if (!trendDetailTable) {
trendDetailTable = $('#trendDetailTable').DataTable({
paging: true,
searching: false,
info: true,
order: [[0, 'desc']],
});
}
}
function bindTrendControls() {
// 双向绑定强度滑块与数字框
$('#trendMinStrength').on('input change', function(){
$('#trendMinStrengthNum').val($(this).val());
});
$('#trendMinStrengthNum').on('input change', function(){
let v = Math.max(0, Math.min(100, parseFloat($(this).val()||0)));
$(this).val(v);
$('#trendMinStrength').val(v);
});
// 周期变化时,自动填充当前时间回溯300根K线的时间范围
$('#trendTimeframe').on('change', function(){
const tf = $(this).val();
const step = window.timeframeToMs(tf) || (60*60*1000);
const now = new Date();
const endMs = now.getTime();
const startMs = endMs - 300 * step;
const toLocal = (ms) => new Date(ms - new Date(ms).getTimezoneOffset()*60000).toISOString().slice(0,16);
$('#trendEnd').val(toLocal(endMs));
$('#trendStart').val(toLocal(startMs));
});
$('#btnTrendFilter').on('click', async function(){
await runTrendFilter();
});
}
async function runTrendFilter() {
if (!FEATURES.trendFilter) return; // 未启用则早退
initTrendTables();
trendTable.clear().draw();
const timeframe = $('#trendTimeframe').val();
const direction = $('#trendDirection').val();
const stage = $('#trendStage').val();
const minStrength = $('#trendMinStrength').val();
const symbols = $('#trendSymbols').val();
let start = $('#trendStart').val();
let end = $('#trendEnd').val();
// 前端必须提供时间范围:若为空,自动以当前时间回溯300根
if (!start || !end) {
const step = window.timeframeToMs(timeframe) || (60*60*1000);
const now = Date.now();
const startMsAuto = now - 300 * step;
const toLocal = (ms) => new Date(ms - new Date(ms).getTimezoneOffset()*60000).toISOString().slice(0,16);
if (!end) $('#trendEnd').val(toLocal(now));
if (!start) $('#trendStart').val(toLocal(startMsAuto));
start = $('#trendStart').val();
end = $('#trendEnd').val();
}
let startMs = start ? new Date(start).getTime() : '';
let endMs = end ? new Date(end).getTime() : '';
const params = $.param({
timeframe: timeframe,
direction: direction || '',
stage: stage || '',
min_strength: minStrength,
symbols: symbols || '',
start_time: startMs || '',
end_time: endMs || ''
});
// 显示筛选状态
$('#trendFilterStatus').show();
try {
const res = await $.getJSON(`/api/trend_filter?${params}`);
// 初筛后端结果,再次用前端方向筛选(避免后端噪声)
const dirVal = $('#trendDirection').val();
const rows = (res.results || [])
.filter(r => {
if (!dirVal) return true;
return r.direction === dirVal;
})
.map(r => [
r.symbol,
new Date(r.time).toLocaleString('zh-CN', { timeZone: $('#timezone').val() || 'Asia/Shanghai' }),
r.direction === 'bull' ? '多头' : (r.direction === 'bear' ? '空头' : '盘整'),
r.stage === 'early' ? '初期' : (r.stage === 'mid' ? '中期' : '末期'),
r.strength,
r.close,
r.ema5,
r.ema10,
r.ema26,
r.ema52,
`<button class="btn btn-sm btn-outline-primary" data-symbol="${r.symbol}" data-timeframe="${timeframe}">查看</button>`
]);
trendTable.rows.add(rows).draw();
// 绑定查看按钮
$('#trendFilterTable').off('click', 'button').on('click', 'button', function(){
const sym = $(this).data('symbol');
const tf = $(this).data('timeframe');
loadTrendDetail(sym, tf, startMs, endMs);
});
// 精细化阶段判定(前端基于明细重算)
refineTrendStages(Array.from(new Set((res.results||[]).map(r => r.symbol))).slice(0, 20), timeframe, startMs, endMs);
} catch (e) {
alert('趋势筛选失败: ' + e);
} finally {
$('#trendFilterStatus').hide();
}
}
async function loadTrendDetail(symbol, timeframe, startMs, endMs) {
const params = $.param({
symbol: symbol,
timeframe: timeframe,
start_time: startMs || '',
end_time: endMs || '',
timezone: $('#timezone').val() || 'Asia/Shanghai'
});
// 显示详情加载状态
$('#trendDetailStatus').show();
try {
const data = await $.getJSON(`/api/trend_detail?${params}`);
// 填表
trendDetailTable.clear();
(data.kline_data || []).forEach(row => {
trendDetailTable.row.add([
new Date(row.timestamp).toLocaleString('zh-CN', { timeZone: data.timezone }),
row.open, row.high, row.low, row.close, row.volume,
row.ema5, row.ema10, row.ema26, row.ema52
]);
});
trendDetailTable.draw();
// 画图
drawTrendChart(data);
} catch (e) {
alert('加载趋势详情失败: ' + e);
} finally {
$('#trendDetailStatus').hide();
}
}
function drawTrendChart(data) {
const container = document.getElementById('trendChartContainer');
if (!container) return;
container.innerHTML = '';
const chart = LightweightCharts.createChart(container, {
layout: { background: { color: '#ffffff' }, textColor: '#333' },
rightPriceScale: { visible: true },
timeScale: { timeVisible: true, secondsVisible: false },
crosshair: { mode: LightweightCharts.CrosshairMode.Normal },
grid: { vertLines: { color: '#eee' }, horzLines: { color: '#eee' } },
autoSize: true
});
trendChart = chart;
const candle = chart.addCandlestickSeries();
// 关闭均线的价格线与最后值标签,仅保留K线的当前价格虚线
const ema5 = chart.addLineSeries({ color: '#ff0000', lineWidth: 2, lastValueVisible: false, priceLineVisible: false });
const ema10 = chart.addLineSeries({ color: '#2962FF', lineWidth: 2, lastValueVisible: false, priceLineVisible: false });
const ema26 = chart.addLineSeries({ color: '#008000', lineWidth: 2, lastValueVisible: false, priceLineVisible: false });
const ema52 = chart.addLineSeries({ color: '#800080', lineWidth: 2, lastValueVisible: false, priceLineVisible: false });
const k = (data.kline_data || []).map(r => ({
time: Math.floor(r.timestamp / 1000),
open: Number(r.open), high: Number(r.high), low: Number(r.low), close: Number(r.close)
}));
candle.setData(k);
// 前端过滤均线前导缺失/无效值,避免绘制为0
const sanitizeMA = (field) => {
const rows = data.kline_data || [];
const out = [];
let started = false;
for (let i = 0; i < rows.length; i++) {
const r = rows[i];
const raw = r[field];
const v = Number(raw);
const valid = Number.isFinite(v) && v > 0;
if (!started) {
if (!valid) continue;
started = true;
}
if (!valid) continue;
out.push({ time: Math.floor(r.timestamp / 1000), value: v });
}
return out;
};
ema5.setData(sanitizeMA('ema5'));
ema10.setData(sanitizeMA('ema10'));
ema26.setData(sanitizeMA('ema26'));
ema52.setData(sanitizeMA('ema52'));
// 趋势线(使用返回的拟合参数)
const trend = data.trend_line || null;
if (trend && k.length > 1) {
const L = Math.min(trend.length, k.length);
const startIdx = k.length - L;
const lineData = [];
for (let i = 0; i < L; i++) {
const y = trend.slope * i + trend.intercept;
const point = { time: k[startIdx + i].time, value: y };
lineData.push(point);
}
const trendSeries = chart.addLineSeries({ color: '#ffa500', lineWidth: 2, lineStyle: 0, lastValueVisible: false, priceLineVisible: false });
trendSeries.setData(lineData);
}
}
// ===== 前端精细化阶段判定 =====
function computeEMA() {
if (window.App && window.App.Indicators && typeof window.App.Indicators.computeEMA === 'function') {
return window.App.Indicators.computeEMA.apply(null, arguments);
}
console.warn('computeEMA 未就绪,返回空数组');
return [];
}
function computeMACDSeries(close) {
const ema12 = computeEMA(close, 12);
const ema26 = computeEMA(close, 26);
const macd = close.map((_, i) => (ema12[i] != null && ema26[i] != null) ? (ema12[i] - ema26[i]) : null);
const signal = computeEMA(macd.map(v => v ?? null), 9);
const hist = macd.map((v, i) => (v != null && signal[i] != null) ? (v - signal[i]) : null);
return { macd, signal, hist };
}
function slope(series, win) {
const n = series.length;
const k = Math.min(win, n);
if (k < 3) return 0;
const y = series.slice(n - k).filter(v => v != null && isFinite(v));
if (y.length < 3) return 0;
const x = [...Array(y.length).keys()];
const xm = x.reduce((a,b)=>a+b,0)/x.length;
const ym = y.reduce((a,b)=>a+b,0)/y.length;
let num = 0, den = 0;
for (let i=0;i<x.length;i++){ num += (x[i]-xm)*(y[i]-ym); den += (x[i]-xm)*(x[i]-xm); }
return den ? num/den : 0;
}
function classifyStageFrontend(kline, directionHint) {
const close = kline.map(r => Number(r.close));
const ema26 = computeEMA(close, 26);
const ema52 = computeEMA(close, 52);
const last = close[close.length-1];
const e26 = ema26[ema26.length-1];
const e52 = ema52[ema52.length-1];
const s26 = slope(ema26, 20);
const s52 = slope(ema52, 30);
const dist52 = (e52 && isFinite(e52)) ? (last - e52)/e52 : 0;
const { hist } = computeMACDSeries(close);
const recent = hist.slice(-9).filter(v => v != null);
const earlier = hist.slice(-18, -9).filter(v => v != null);
const growth = (recent.length && earlier.length) ? (avgAbs(recent) - avgAbs(earlier)) : 0;
function avgAbs(arr){ return arr.reduce((a,b)=>a+Math.abs(b),0)/arr.length; }
let direction = directionHint;
if (!direction) {
if (e26 > e52 && s26 > 0 && s52 > 0) direction = 'bull';
else if (e26 < e52 && s26 < 0 && s52 < 0) direction = 'bear';
else direction = 'sideways';
}
let stage = 'early';
const ad = Math.abs(dist52);
if (direction === 'bull') {
if (ad < 0.03 && growth > 0) stage = 'early';
else if (ad < 0.10 && (growth >= 0 || s26 > 0)) stage = 'mid';
else stage = 'late';
} else if (direction === 'bear') {
if (ad < 0.03 && growth > 0) stage = 'early';
else if (ad < 0.10 && (growth >= 0 || s26 < 0)) stage = 'mid';
else stage = 'late';
} else {
stage = 'early';
}
return { direction, stage };
}
async function refineTrendStages(symbols, timeframe, startMs, endMs) {
if (!symbols || symbols.length === 0) return;
// 在表头上方提示
const info = $('<div class="text-muted mb-2" id="refineInfo">正在优化阶段判定...</div>');
$('#trendFilterTable').before(info);
const tz = $('#timezone').val() || 'Asia/Shanghai';
const selectedDir = $('#trendDirection').val(); // bull/bear/sideways/''
for (const sym of symbols) {
try {
const params = $.param({ symbol: sym, timeframe, start_time: startMs, end_time: endMs, timezone: tz });
const data = await $.getJSON(`/api/trend_detail?${params}`);
const { direction, stage } = classifyStageFrontend(data.kline_data || [], null);
// 若与选择的方向不一致,则在前端移除该行,避免"选择多头仍出现空头/盘整"
if (selectedDir && direction !== selectedDir) {
if (trendTable) {
trendTable.rows().every(function(){
const rowData = this.data();
if (rowData && rowData[0] === sym) {
this.remove();
}
});
trendTable.draw(false);
}
continue;
}
// 否则更新该行方向与阶段展示
$('#trendFilterTable tbody tr').each(function(){
const tds = $(this).find('td');
if (tds.eq(0).text() === sym) {
tds.eq(2).text(direction === 'bull' ? '多头' : (direction === 'bear' ? '空头' : '盘整'));
tds.eq(3).text(stage === 'early' ? '初期' : stage === 'mid' ? '中期' : '末期');
}
});
} catch(e) {
// 忽略单个失败
}
}
info.remove();
}
// 页面初始化时绑定控件
$(function(){
initTrendTables();
bindTrendControls();
});
var tvWidget = {
mainChart: null,
volumeChart: null,
macdChart: null,
chanMacdChart: null, // 新增ChanMACD图表
series: {
candleSeries: null,
barSeries: null,
lineSeries: 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: [],
subSubBiSeries: [],
subSubSegSeries: [],
subSubZsSeries: [],
subSubUncompletedZsSeries: [],
mainBollingerSeries: [],
elementBollingerSeries: [],
maSeries: [], // 添加均线系列
bbSeries: [], // 添加布林带系列
ema52Series: [], // 添加EMA52系列数组
chanMacdLineSeries: null, // ChanMACD线
chanMacdSignalSeries: null, // ChanMACD信号线
chanMacdHistSeries: null, // ChanMACD柱状图
chanMacdSegSeries: [], // ChanMACD段
chanMacdUnitTFSeries: [], // ChanMACD UnitTF
chanMacdHistSetSeries: [] // ChanMACD HistSet
},
state: {
isInitialized: false,
visibleRange: null,
logicalRange: null
}
};
// 默认EMA初始化哨兵,防止删除后再次被自动添加
var hasInitializedDefaultMAs = false;
// 买卖点类型定义
const TRADE_POINT_TYPE = {
BUY1: 1, // 一类买点
BUY2: 2, // 二类买点
BUY3: 3, // 三类买点
SELL1: -1, // 一类卖点
SELL2: -2, // 二类卖点
SELL3: -3 // 三类卖点
};
// 买卖点样式定义
const TRADE_POINT_STYLE = {
[TRADE_POINT_TYPE.BUY1]: {color: '#FF1744', shape: 'arrowUp', text: '买1', size: 2},
[TRADE_POINT_TYPE.BUY2]: {color: '#F50057', shape: 'circle', text: '买2', size: 2},
[TRADE_POINT_TYPE.BUY3]: {color: '#D500F9', shape: 'square', text: '买3', size: 2},
[TRADE_POINT_TYPE.SELL1]: {color: '#00E676', shape: 'arrowDown', text: '卖1', size: 2},
[TRADE_POINT_TYPE.SELL2]: {color: '#00B0FF', shape: 'circle', text: '卖2', size: 2},
[TRADE_POINT_TYPE.SELL3]: {color: '#FFEA00', shape: 'square', text: '卖3', size: 2}
};
// 定义标记垂直偏移系数 - 合约市场通常波动较大,减小偏移防止显示在范围外
const TRADE_POINT_OFFSET = {
[TRADE_POINT_TYPE.BUY1]: 0, // 一类买点向下偏移2.0%的价格
[TRADE_POINT_TYPE.BUY2]: 0, // 二类买点向下偏移1.5%的价格
[TRADE_POINT_TYPE.BUY3]: 0, // 三类买点向下偏移1.0%的价格
[TRADE_POINT_TYPE.SELL1]: 0, // 一类卖点向上偏移2.0%的价格
[TRADE_POINT_TYPE.SELL2]: 0,// 二类卖点向上偏移1.5%的价格
[TRADE_POINT_TYPE.SELL3]: 0 // 三类卖点向上偏移1.0%的价格
};
// 更改为基于价格百分比的垂直偏移 - 合约市场适用的更小偏移
const PRICE_PERCENT_OFFSET = {
[TRADE_POINT_TYPE.BUY1]: 0, // 一类买点向下偏移价格的0.2%
[TRADE_POINT_TYPE.BUY2]: 0, // 二类买点向下偏移价格的0.15%
[TRADE_POINT_TYPE.BUY3]: 0, // 三类买点向下偏移价格的0.1%
[TRADE_POINT_TYPE.SELL1]: 0, // 一类卖点向上偏移价格的0.2%
[TRADE_POINT_TYPE.SELL2]: 0, // 二类卖点向上偏移价格的0.15%
[TRADE_POINT_TYPE.SELL3]: 0 // 三类卖点向上偏移价格的0.1%
};
// 对于高价格标的如BTC,设置零偏移,完全不影响价格显示
const USE_FIXED_OFFSET = true; // 是否使用固定偏移而非百分比
const PRICE_FIXED_OFFSET = {
[TRADE_POINT_TYPE.BUY1]: 0, // 一类买点零偏移
[TRADE_POINT_TYPE.BUY2]: 0, // 二类买点零偏移
[TRADE_POINT_TYPE.BUY3]: 0, // 三类买点零偏移
[TRADE_POINT_TYPE.SELL1]: 0, // 一类卖点零偏移
[TRADE_POINT_TYPE.SELL2]: 0, // 二类卖点零偏移
[TRADE_POINT_TYPE.SELL3]: 0 // 三类卖点零偏移
};
// 同一时间点的标记堆叠间距系数
const STACK_OFFSET_FACTOR = 5; // 增加堆叠标记的间距
// 添加CSS样式定义买卖点标记的样式
const styleElement = document.createElement('style');
styleElement.textContent = `
.point-tooltip {
position: absolute;
background: rgba(40, 40, 40, 0.9);
color: white;
padding: 8px 12px;
border-radius: 4px;
font-size: 12px;
z-index: 1000;
pointer-events: none;
max-width: 300px;
box-shadow: 0 2px 5px rgba(0,0,0,0.2);
display: none;
}
.buy-point {
color: #ff1744;
font-weight: bold;
}
.sell-point {
color: #00e676;
font-weight: bold;
}
.buy-marker {
background-color: #ff1744;
border: 2px solid white;
}
.sell-marker {
background-color: #00e676;
border: 2px solid white;
}
`;
document.head.appendChild(styleElement);
// 添加买卖点悬浮提示元素
const tooltipElement = document.createElement('div');
tooltipElement.className = 'point-tooltip';
// document.body.appendChild(tooltipElement);
// 添加自定义十字线信息显示
const crosshairTooltip = document.createElement('div');
crosshairTooltip.className = 'crosshair-tooltip';
crosshairTooltip.style.position = 'absolute';
crosshairTooltip.style.backgroundColor = 'rgba(0, 0, 0, 0.7)';
crosshairTooltip.style.color = 'white';
crosshairTooltip.style.padding = '5px 10px';
crosshairTooltip.style.borderRadius = '4px';
crosshairTooltip.style.fontSize = '12px';
crosshairTooltip.style.zIndex = '1000';
crosshairTooltip.style.pointerEvents = 'none';
crosshairTooltip.style.display = 'none';
// document.body.appendChild(crosshairTooltip);
// 助手函数:转换UTC时间到所选时区
function convertToTimezone(utcDate, timezone) {
return new Date(utcDate).toLocaleString('zh-CN', {
timeZone: timezone,
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
});
}
// 获取UTC时间戳(秒)
function getTimestamp(dateStr) {
return new Date(dateStr).getTime() / 1000;
}
// 获取时区偏移量(小时)
function getTimezoneOffset(timezone) {
// 手动定义已知时区的偏移量
const offsets = {
'UTC': 0,
'Asia/Shanghai': 8,
'America/New_York': -4, // 夏令时可能是-4,冬令时是-5
'Europe/London': 0, // 夏令时可能是+1,冬令时是0
'Europe/Berlin': 1, // 夏令时可能是+2,冬令时是+1
'Asia/Tokyo': 9
};
return offsets[timezone] || 0;
}
// 从日期字符串获取时间戳,应用时区偏移
function getAdjustedTimestamp(dateStr, applyOffset = true) {
const date = new Date(dateStr);
const timestamp = Math.floor(date.getTime() / 1000);
if (!applyOffset) {
return timestamp;
}
// 不再手动调整时区偏移,使用JavaScript的内置时区支持
return timestamp;
}
// 添加时区选择器变更事件
$('#timezone').change(function() {
if (currentData) {
// 重新渲染图表和数据表以使用新的时区
initTradingView($('#symbol').val(), $('#timeframe').val());
updateTables(currentData);
}
});
// 添加MACD复选框变更事件
$('#showMacd').change(function() {
updateChartDisplay();
});
+826
View File
@@ -0,0 +1,826 @@
/* ui.js */
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');
}
}
});
}
// 设置默认时间范围
function setDefaultTimeRange() {
const now = new Date();
const oneDayAgo = new Date(now.getTime() - (24 * 60 * 60 * 1000));
// 格式化为datetime-local输入框所需的格式 YYYY-MM-DDThh:mm
$('#end_time').val(formatDatetimeLocal(now));
$('#start_time').val(formatDatetimeLocal(oneDayAgo));
}
// 格式化日期为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();
setTimeout(function() {
updateChart();
}, 300);
});
} else {
setTimeout(function() {
updateChart();
}, 500);
}
// 初始化交易对下拉菜单
$('#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 完成后再 updateChart
if (initialDataSource !== 'a_stock') {
setTimeout(function() {
updateChart();
}, 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;
// 初始化自动刷新功能
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;
const intervalMs = interval * 60 * 1000;
console.log(`开始自动刷新,频率: ${interval}分钟 (${intervalMs}毫秒)`);
// 计算下次刷新时间
nextRefreshTime = new Date(Date.now() + intervalMs);
updateNextRefreshTimeDisplay();
// 启动定时器
autoRefreshTimer = setInterval(function() {
// 更新结束时间为当前时间
updateEndTimeToNow();
// 刷新图表
updateChart();
// 更新下次刷新时间
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;
const mainChart = tvWidget.mainChart;
const logicalRange = mainChart.timeScale().getVisibleLogicalRange();
const visibleRange = mainChart.timeScale().getVisibleRange();
// 确保使用主周期的K线和MACD数据
if (currentData.original_kline_data) {
currentData.kline_data = currentData.original_kline_data;
}
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) {
if (logicalRange) {
tvWidget.mainChart.timeScale().setVisibleLogicalRange(logicalRange);
if (tvWidget.volumeChart) tvWidget.volumeChart.timeScale().setVisibleLogicalRange(logicalRange);
if (tvWidget.atrChart) tvWidget.atrChart.timeScale().setVisibleLogicalRange(logicalRange);
if (tvWidget.macdChart) tvWidget.macdChart.timeScale().setVisibleLogicalRange(logicalRange);
if (tvWidget.chanMacdChart) tvWidget.chanMacdChart.timeScale().setVisibleLogicalRange(logicalRange);
} else if (visibleRange) {
tvWidget.mainChart.timeScale().setVisibleRange(visibleRange);
if (tvWidget.volumeChart) tvWidget.volumeChart.timeScale().setVisibleRange(visibleRange);
if (tvWidget.atrChart) tvWidget.atrChart.timeScale().setVisibleRange(visibleRange);
if (tvWidget.macdChart) tvWidget.macdChart.timeScale().setVisibleRange(visibleRange);
if (tvWidget.chanMacdChart) tvWidget.chanMacdChart.timeScale().setVisibleRange(visibleRange);
}
}
}, 200);
}
// 只更新分形元素(笔、线段、中枢)的表格数据
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) {
// 检查是否接收到数据
if (!data) {
console.error('未收到数据,无法刷新图表');
return;
}
if (data.element_timeframe) {
$('#elementTimeframe').val(data.element_timeframe);
}
// 保存当前缩放(barSpacing)和滚动位置(scrollPosition)到 window
// tvWidget 会在 initTradingView 内被重建,所以必须存到 window 上
if (tvWidget && tvWidget.mainChart) {
try {
window._pendingRestoreView = captureChartViewState(tvWidget.mainChart);
console.log('📌 保存图表视图:', JSON.stringify(window._pendingRestoreView));
} catch (e) {
console.warn('保存图表视图失败:', e);
window._pendingRestoreView = null;
}
}
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() {
refreshChartOnly();
});
// 绑定小周期分型显示开关
$('#showElementKlcFxType').change(function() {
refreshChart(currentData);
});
// 绑定布林带显示变更事件
$('#showMainBollinger').change(function() {
updateChartDisplay();
});
$('#showElementBollinger').change(function() {
updateChartDisplay();
});
// 绑定K线周期切换
$('input[name="klinePeriod"]').change(function() {
refreshChart(currentData);
});
// 绑定主图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系列