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:
@@ -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
@@ -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)
|
||||
}
|
||||
}
|
||||
})()
|
||||
@@ -0,0 +1 @@
|
||||
/* chart.js split into chart_format/view/tv/sync/tables — see index.html load order */
|
||||
@@ -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>';
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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) {}
|
||||
});
|
||||
}
|
||||
}
|
||||
// 初始化图表
|
||||
@@ -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,
|
||||
}
|
||||
})()
|
||||
@@ -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';
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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系列
|
||||
Reference in New Issue
Block a user