fix: 修复主站自动刷新内存泄漏,并完善 chan_tv 图表体验
主站重建前完整 dispose、去掉重复 sync 监听,自动刷新默认增量更新;顺带消除首屏重复 analyze、复用 ChanMACD,以及全版 TV 指标/未完成中枢/布局本地缓存。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -174,8 +174,10 @@ class KlineBuilderMixin:
|
|||||||
def get_klc_list(self, klu_list):
|
def get_klc_list(self, klu_list):
|
||||||
klc_list = []
|
klc_list = []
|
||||||
last_klu = None
|
last_klu = None
|
||||||
|
# ChanMACD.__init__ 已调用 cal_macd_state,切勿再调一次(会重复堆积 seg/unittf)
|
||||||
macd = ChanMACD(klu_list)
|
macd = ChanMACD(klu_list)
|
||||||
klu_list = macd.cal_macd_state()
|
klu_list = macd.klu_list
|
||||||
|
self._last_chan_macd = macd
|
||||||
ema_up_list = []
|
ema_up_list = []
|
||||||
ema_down_list = []
|
ema_down_list = []
|
||||||
ema_up_count = 0
|
ema_up_count = 0
|
||||||
|
|||||||
@@ -68,8 +68,11 @@ class TF_DF(IndicatorsBuilderMixin, KlineBuilderMixin, BiBuilderMixin, SegBuilde
|
|||||||
self.seg_list = self.get_seg_list(self.bi_list)
|
self.seg_list = self.get_seg_list(self.bi_list)
|
||||||
self.zs_list = self.get_zs_list(self.bi_list, self.seg_list)
|
self.zs_list = self.get_zs_list(self.bi_list, self.seg_list)
|
||||||
self.big_zs_list = self.get_big_zs_list(self.zs_list)
|
self.big_zs_list = self.get_big_zs_list(self.zs_list)
|
||||||
self.chanmacd = ChanMACD(self.klu_list)
|
# get_klc_list 内已算过 ChanMACD,直接复用
|
||||||
self.klu_list = self.chanmacd.cal_macd_state()
|
self.chanmacd = getattr(self, '_last_chan_macd', None)
|
||||||
|
if self.chanmacd is None:
|
||||||
|
self.chanmacd = ChanMACD(self.klu_list)
|
||||||
|
self.klu_list = self.chanmacd.klu_list
|
||||||
|
|
||||||
|
|
||||||
def get_current_klc(self):
|
def get_current_klc(self):
|
||||||
|
|||||||
+6
-1
@@ -1,5 +1,6 @@
|
|||||||
"""页面路由。"""
|
"""页面路由。"""
|
||||||
from flask import Blueprint, render_template, send_from_directory
|
from flask import Blueprint, render_template, send_from_directory
|
||||||
|
from config import DATA_SERVICE_URL, DATA_SERVICE_WS_URL
|
||||||
from services.runtime import * # noqa: F403
|
from services.runtime import * # noqa: F403
|
||||||
from services import runtime as R
|
from services import runtime as R
|
||||||
|
|
||||||
@@ -8,7 +9,11 @@ bp = Blueprint("pages", __name__)
|
|||||||
@bp.route('/chan_tv')
|
@bp.route('/chan_tv')
|
||||||
def chan_tv():
|
def chan_tv():
|
||||||
"""缠论 TradingView 高级图表页面"""
|
"""缠论 TradingView 高级图表页面"""
|
||||||
return render_template('chan_tv.html')
|
return render_template(
|
||||||
|
'chan_tv.html',
|
||||||
|
data_service_url=DATA_SERVICE_URL,
|
||||||
|
data_service_ws_url=DATA_SERVICE_WS_URL,
|
||||||
|
)
|
||||||
|
|
||||||
@bp.route('/charting_library/<path:filename>')
|
@bp.route('/charting_library/<path:filename>')
|
||||||
def serve_charting_library(filename):
|
def serve_charting_library(filename):
|
||||||
|
|||||||
@@ -7,6 +7,11 @@ DATA_SERVICE_URL = os.environ.get(
|
|||||||
"DATA_SERVICE_URL",
|
"DATA_SERVICE_URL",
|
||||||
os.environ.get("DATASVC_URL", "https://provider.jackyu66.com"),
|
os.environ.get("DATASVC_URL", "https://provider.jackyu66.com"),
|
||||||
)
|
)
|
||||||
|
# WebSocket 与 REST 可能不同域名(nginx 反代)
|
||||||
|
DATA_SERVICE_WS_URL = os.environ.get(
|
||||||
|
"DATA_SERVICE_WS_URL",
|
||||||
|
"wss://jackyu66.com/ws",
|
||||||
|
)
|
||||||
ASHARE_DP_URL = os.environ.get("ASHARE_DP_URL", "http://103.179.242.166:8000")
|
ASHARE_DP_URL = os.environ.get("ASHARE_DP_URL", "http://103.179.242.166:8000")
|
||||||
|
|
||||||
# HTTP 代理:未设置则不走代理;可设 HTTP_PROXY/HTTPS_PROXY 或 CHAN_HTTP_PROXY
|
# HTTP 代理:未设置则不走代理;可设 HTTP_PROXY/HTTPS_PROXY 或 CHAN_HTTP_PROXY
|
||||||
|
|||||||
@@ -618,14 +618,16 @@ def analyze_chan(df, symbol=None, timeframe=None):
|
|||||||
bi.cal_macd_div()
|
bi.cal_macd_div()
|
||||||
#print(bi.start_time, bi.macd_hist, bi.macd_div)
|
#print(bi.start_time, bi.macd_hist, bi.macd_div)
|
||||||
|
|
||||||
# 添加ChanMACD分析
|
# 添加ChanMACD分析(复用 get_klc_list 内已算好的结果,避免同周期二次全量分析)
|
||||||
chan_macd = None
|
chan_macd = None
|
||||||
chan_macd_data = {}
|
chan_macd_data = {}
|
||||||
try:
|
try:
|
||||||
|
|
||||||
if klu_list and len(klu_list) > 0:
|
if klu_list and len(klu_list) > 0:
|
||||||
print(f"获取到KLU列表,长度: {len(klu_list)}")
|
print(f"获取到KLU列表,长度: {len(klu_list)}")
|
||||||
chan_macd = ChanMACD(klu_list)
|
chan_macd = getattr(chan, '_last_chan_macd', None)
|
||||||
|
if chan_macd is None:
|
||||||
|
chan_macd = ChanMACD(klu_list)
|
||||||
chan_macd_data = {
|
chan_macd_data = {
|
||||||
'seg_list': chan_macd.seg_list,
|
'seg_list': chan_macd.seg_list,
|
||||||
'unittf_list': chan_macd.unittf_list,
|
'unittf_list': chan_macd.unittf_list,
|
||||||
|
|||||||
@@ -981,19 +981,41 @@ function findBiCenters(biList) {
|
|||||||
var lows = biList_for_zs.map(function(bi) { return Math.min(bi.p0, bi.p1) })
|
var lows = biList_for_zs.map(function(bi) { return Math.min(bi.p0, bi.p1) })
|
||||||
gg = Math.max.apply(null, highs)
|
gg = Math.max.apply(null, highs)
|
||||||
dd = Math.min.apply(null, lows)
|
dd = Math.min.apply(null, lows)
|
||||||
endBiIdx = startIdx + addedAfterLeave.length
|
endBiIdx = startIdx + 2 + addedAfterLeave.length
|
||||||
|
}
|
||||||
|
|
||||||
|
var lastBiInCenter = biList_for_zs[biList_for_zs.length - 1]
|
||||||
|
// 是否已离开中枢:之后出现完全在 ZG 之上或 ZD 之下的确认笔 → 中枢完成
|
||||||
|
var zsSure = false
|
||||||
|
var lastInListIdx = -1
|
||||||
|
for (var li = 0; li < biList.length; li++) {
|
||||||
|
if (biList[li] === lastBiInCenter || (biList[li].t0 === lastBiInCenter.t0 && biList[li].t1 === lastBiInCenter.t1)) {
|
||||||
|
lastInListIdx = li
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (lastInListIdx < 0) lastInListIdx = endBiIdx
|
||||||
|
for (var j = lastInListIdx + 1; j < biList.length; j++) {
|
||||||
|
var leaveBi = biList[j]
|
||||||
|
if (!leaveBi.sure) break
|
||||||
|
var lbh = Math.max(leaveBi.p0, leaveBi.p1)
|
||||||
|
var lbl = Math.min(leaveBi.p0, leaveBi.p1)
|
||||||
|
if (lbl > zg || lbh < zd) {
|
||||||
|
zsSure = true
|
||||||
|
break
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var zs = {
|
var zs = {
|
||||||
t0: bi1.t0,
|
t0: bi1.t0,
|
||||||
t1: biList_for_zs[biList_for_zs.length - 1].t1,
|
t1: lastBiInCenter.t1,
|
||||||
high: zg, low: zd,
|
high: zg, low: zd,
|
||||||
zg: zg, zd: zd,
|
zg: zg, zd: zd,
|
||||||
gg: gg, dd: dd,
|
gg: gg, dd: dd,
|
||||||
is_sure: biList_for_zs[biList_for_zs.length - 1].sure,
|
is_sure: zsSure,
|
||||||
bi_count: biList_for_zs.length,
|
bi_count: biList_for_zs.length,
|
||||||
bi_list: biList_for_zs, // 中枢内的笔列表(按序)
|
bi_list: biList_for_zs,
|
||||||
start_bi_idx: startIdx, // 中枢首笔在总列表中的索引
|
start_bi_idx: startIdx,
|
||||||
dir: zsDir,
|
dir: zsDir,
|
||||||
pre: lastZs,
|
pre: lastZs,
|
||||||
next: null,
|
next: null,
|
||||||
@@ -1008,28 +1030,6 @@ function findBiCenters(biList) {
|
|||||||
startIdx = startIdx + 4 + (addedAfterLeave.length > 0 ? addedAfterLeave.length : 0)
|
startIdx = startIdx + 4 + (addedAfterLeave.length > 0 ? addedAfterLeave.length : 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 末中枢确认
|
|
||||||
if (lastZs && !lastZs.is_sure) {
|
|
||||||
var lastBiInZs = lastZs.bi_count > 0 ? biList_for_zs[biList_for_zs.length - 1] : null
|
|
||||||
if (lastBiInZs) {
|
|
||||||
var hasLeave = false
|
|
||||||
var lastBiIdx = biList.indexOf(lastBiInZs)
|
|
||||||
if (lastBiIdx >= 0) {
|
|
||||||
for (var i = lastBiIdx + 1; i < biList.length; i++) {
|
|
||||||
var bi = biList[i]
|
|
||||||
if (bi.sure) {
|
|
||||||
var bh = Math.max(bi.p0, bi.p1), bl = Math.min(bi.p0, bi.p1)
|
|
||||||
var leave = (bl > lastZs.zg && bh > lastZs.zg) || (bh < lastZs.zd && bl < lastZs.zd)
|
|
||||||
if (leave) { hasLeave = true; break }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (hasLeave && lastBiInZs.sure) {
|
|
||||||
lastZs.t1 = lastBiInZs.t1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return zsList
|
return zsList
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1119,16 +1119,37 @@ function findSegCenters(segs) {
|
|||||||
endSegIdx = startIdx + 2 + addedSegs.length
|
endSegIdx = startIdx + 2 + addedSegs.length
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var lastSegInCenter = segList_for_zs[segList_for_zs.length - 1]
|
||||||
|
var zsSure = false
|
||||||
|
var lastSegListIdx = -1
|
||||||
|
for (var lsi = 0; lsi < segs.length; lsi++) {
|
||||||
|
if (segs[lsi] === lastSegInCenter || (segs[lsi].t0 === lastSegInCenter.t0 && segs[lsi].t1 === lastSegInCenter.t1)) {
|
||||||
|
lastSegListIdx = lsi
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (lastSegListIdx < 0) lastSegListIdx = endSegIdx
|
||||||
|
for (var sj = lastSegListIdx + 1; sj < segs.length; sj++) {
|
||||||
|
var leaveSeg = segs[sj]
|
||||||
|
if (!leaveSeg.sure) break
|
||||||
|
var lsh = Math.max(leaveSeg.p0, leaveSeg.p1)
|
||||||
|
var lsl = Math.min(leaveSeg.p0, leaveSeg.p1)
|
||||||
|
if (lsl > zg || lsh < zd) {
|
||||||
|
zsSure = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
var zs = {
|
var zs = {
|
||||||
t0: s1.t0,
|
t0: s1.t0,
|
||||||
t1: segs[endSegIdx].t1,
|
t1: lastSegInCenter.t1,
|
||||||
high: zg, low: zd,
|
high: zg, low: zd,
|
||||||
zg: zg, zd: zd,
|
zg: zg, zd: zd,
|
||||||
gg: gg, dd: dd,
|
gg: gg, dd: dd,
|
||||||
is_sure: segs[endSegIdx].sure,
|
is_sure: zsSure,
|
||||||
seg_count: segList_for_zs.length,
|
seg_count: segList_for_zs.length,
|
||||||
seg_list: segList_for_zs, // 中枢内的段列表(按序)
|
seg_list: segList_for_zs,
|
||||||
start_seg_idx: startIdx, // 中枢首段在总列表中的索引
|
start_seg_idx: startIdx,
|
||||||
dir: zsDir,
|
dir: zsDir,
|
||||||
pre: lastZs,
|
pre: lastZs,
|
||||||
next: null,
|
next: null,
|
||||||
|
|||||||
@@ -121,6 +121,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 中枢填充:区间内每根 bar 写入 top/bottom
|
// 中枢填充:区间内每根 bar 写入 top/bottom
|
||||||
|
// 未完成中枢:右边界拉到最新 K(与主站 uncompleted_zs 一致)
|
||||||
function fillZs(t0, t1, high, low, topField, botField) {
|
function fillZs(t0, t1, high, low, topField, botField) {
|
||||||
var lo = lowerBound(sortedBarTimes, t0)
|
var lo = lowerBound(sortedBarTimes, t0)
|
||||||
var hi = upperBound(sortedBarTimes, t1)
|
var hi = upperBound(sortedBarTimes, t1)
|
||||||
@@ -131,14 +132,30 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var lastBarT = sortedBarTimes.length ? sortedBarTimes[sortedBarTimes.length - 1] : null
|
||||||
|
|
||||||
if (slice.zs) {
|
if (slice.zs) {
|
||||||
slice.zs.forEach(function (z) {
|
slice.zs.forEach(function (z) {
|
||||||
fillZs(z.t0, z.t1, z.high || z.zg, z.low || z.zd, 'zs_top', 'zs_bottom')
|
var t1 = z.t1
|
||||||
|
var sure = z.is_sure !== false && z.is_sure !== 0
|
||||||
|
if (!sure && lastBarT != null) t1 = Math.max(t1 || 0, lastBarT)
|
||||||
|
if (sure) {
|
||||||
|
fillZs(z.t0, t1, z.high || z.zg, z.low || z.zd, 'zs_top', 'zs_bottom')
|
||||||
|
} else {
|
||||||
|
fillZs(z.t0, t1, z.high || z.zg, z.low || z.zd, 'zs_pending_top', 'zs_pending_bottom')
|
||||||
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
if (slice.segzs) {
|
if (slice.segzs) {
|
||||||
slice.segzs.forEach(function (z) {
|
slice.segzs.forEach(function (z) {
|
||||||
fillZs(z.t0, z.t1, z.high || z.zg, z.low || z.zd, 'segzs_top', 'segzs_bottom')
|
var t1 = z.t1
|
||||||
|
var sure = z.is_sure !== false && z.is_sure !== 0
|
||||||
|
if (!sure && lastBarT != null) t1 = Math.max(t1 || 0, lastBarT)
|
||||||
|
if (sure) {
|
||||||
|
fillZs(z.t0, t1, z.high || z.zg, z.low || z.zd, 'segzs_top', 'segzs_bottom')
|
||||||
|
} else {
|
||||||
|
fillZs(z.t0, t1, z.high || z.zg, z.low || z.zd, 'segzs_pending_top', 'segzs_pending_bottom')
|
||||||
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -233,6 +250,10 @@
|
|||||||
{ id: 'zs_bottom', type: 'line' },
|
{ id: 'zs_bottom', type: 'line' },
|
||||||
{ id: 'segzs_top', type: 'line' },
|
{ id: 'segzs_top', type: 'line' },
|
||||||
{ id: 'segzs_bottom', type: 'line' },
|
{ id: 'segzs_bottom', type: 'line' },
|
||||||
|
{ id: 'zs_pending_top', type: 'line' },
|
||||||
|
{ id: 'zs_pending_bottom', type: 'line' },
|
||||||
|
{ id: 'segzs_pending_top', type: 'line' },
|
||||||
|
{ id: 'segzs_pending_bottom', type: 'line' },
|
||||||
]
|
]
|
||||||
|
|
||||||
BSP_SUBTYPES.forEach(function (t) {
|
BSP_SUBTYPES.forEach(function (t) {
|
||||||
@@ -279,6 +300,22 @@
|
|||||||
linestyle: 0, linewidth: 0, plottype: 0, trackPrice: false,
|
linestyle: 0, linewidth: 0, plottype: 0, trackPrice: false,
|
||||||
transparency: 100, visible: false, color: '#ef6c00', display: 0,
|
transparency: 100, visible: false, color: '#ef6c00', display: 0,
|
||||||
}),
|
}),
|
||||||
|
zs_pending_top: mergeStyle('zs_pending_top', {
|
||||||
|
linestyle: 0, linewidth: 0, plottype: 0, trackPrice: false,
|
||||||
|
transparency: 100, visible: false, color: '#f1c40f', display: 0,
|
||||||
|
}),
|
||||||
|
zs_pending_bottom: mergeStyle('zs_pending_bottom', {
|
||||||
|
linestyle: 0, linewidth: 0, plottype: 0, trackPrice: false,
|
||||||
|
transparency: 100, visible: false, color: '#f1c40f', display: 0,
|
||||||
|
}),
|
||||||
|
segzs_pending_top: mergeStyle('segzs_pending_top', {
|
||||||
|
linestyle: 0, linewidth: 0, plottype: 0, trackPrice: false,
|
||||||
|
transparency: 100, visible: false, color: '#9b59b6', display: 0,
|
||||||
|
}),
|
||||||
|
segzs_pending_bottom: mergeStyle('segzs_pending_bottom', {
|
||||||
|
linestyle: 0, linewidth: 0, plottype: 0, trackPrice: false,
|
||||||
|
transparency: 100, visible: false, color: '#9b59b6', display: 0,
|
||||||
|
}),
|
||||||
}
|
}
|
||||||
|
|
||||||
// BSP 样式
|
// BSP 样式
|
||||||
@@ -311,6 +348,10 @@
|
|||||||
zs_bottom: { title: '中枢下沿', histogramBase: 0, isHidden: true },
|
zs_bottom: { title: '中枢下沿', histogramBase: 0, isHidden: true },
|
||||||
segzs_top: { title: '段中枢上沿', histogramBase: 0, isHidden: true },
|
segzs_top: { title: '段中枢上沿', histogramBase: 0, isHidden: true },
|
||||||
segzs_bottom: { title: '段中枢下沿', histogramBase: 0, isHidden: true },
|
segzs_bottom: { title: '段中枢下沿', histogramBase: 0, isHidden: true },
|
||||||
|
zs_pending_top: { title: '未完成中枢上沿', histogramBase: 0, isHidden: true },
|
||||||
|
zs_pending_bottom: { title: '未完成中枢下沿', histogramBase: 0, isHidden: true },
|
||||||
|
segzs_pending_top: { title: '未完成段中枢上沿', histogramBase: 0, isHidden: true },
|
||||||
|
segzs_pending_bottom: { title: '未完成段中枢下沿', histogramBase: 0, isHidden: true },
|
||||||
}
|
}
|
||||||
|
|
||||||
BSP_SUBTYPES.forEach(function (t) {
|
BSP_SUBTYPES.forEach(function (t) {
|
||||||
@@ -358,7 +399,7 @@
|
|||||||
name: '缠论',
|
name: '缠论',
|
||||||
metainfo: {
|
metainfo: {
|
||||||
_metainfoVersion: 53,
|
_metainfoVersion: 53,
|
||||||
id: 'Chan@tv-basicstudies-5',
|
id: 'Chan@tv-basicstudies-6',
|
||||||
scriptIdPart: '',
|
scriptIdPart: '',
|
||||||
description: 'Chan 缠论',
|
description: 'Chan 缠论',
|
||||||
shortDescription: '缠论',
|
shortDescription: '缠论',
|
||||||
@@ -373,12 +414,18 @@
|
|||||||
title: '中枢', isHidden: false },
|
title: '中枢', isHidden: false },
|
||||||
{ id: 'segzs_fill', objAId: 'segzs_top', objBId: 'segzs_bottom', type: 'plot_plot',
|
{ id: 'segzs_fill', objAId: 'segzs_top', objBId: 'segzs_bottom', type: 'plot_plot',
|
||||||
title: '段中枢', isHidden: false },
|
title: '段中枢', isHidden: false },
|
||||||
|
{ id: 'zs_pending_fill', objAId: 'zs_pending_top', objBId: 'zs_pending_bottom', type: 'plot_plot',
|
||||||
|
title: '未完成中枢', isHidden: false },
|
||||||
|
{ id: 'segzs_pending_fill', objAId: 'segzs_pending_top', objBId: 'segzs_pending_bottom', type: 'plot_plot',
|
||||||
|
title: '未完成段中枢', isHidden: false },
|
||||||
],
|
],
|
||||||
defaults: {
|
defaults: {
|
||||||
styles: styles,
|
styles: styles,
|
||||||
filledAreasStyle: {
|
filledAreasStyle: {
|
||||||
zs_fill: mergeFill('zs_fill', { color: '#f1d96a', visible: true, transparency: 75 }),
|
zs_fill: mergeFill('zs_fill', { color: '#f1d96a', visible: true, transparency: 75 }),
|
||||||
segzs_fill: mergeFill('segzs_fill', { color: '#6361f7', visible: true, transparency: 75 }),
|
segzs_fill: mergeFill('segzs_fill', { color: '#6361f7', visible: true, transparency: 75 }),
|
||||||
|
zs_pending_fill: mergeFill('zs_pending_fill', { color: '#f1c40f', visible: true, transparency: 55 }),
|
||||||
|
segzs_pending_fill: mergeFill('segzs_pending_fill', { color: '#9b59b6', visible: true, transparency: 55 }),
|
||||||
},
|
},
|
||||||
precision: 2,
|
precision: 2,
|
||||||
inputs: { epoch: 0 },
|
inputs: { epoch: 0 },
|
||||||
@@ -394,8 +441,8 @@
|
|||||||
self._context = ctx
|
self._context = ctx
|
||||||
}
|
}
|
||||||
this.main = function (context) {
|
this.main = function (context) {
|
||||||
// 32 个 plot: 8 结构 + 24 BSP
|
// 36 个 plot: 12 结构 + 24 BSP
|
||||||
var NANS = new Array(32).fill(NaN)
|
var NANS = new Array(36).fill(NaN)
|
||||||
// v31: sniffing pass 时 context.symbol.time 为 NaN
|
// v31: sniffing pass 时 context.symbol.time 为 NaN
|
||||||
var t = context.symbol.time
|
var t = context.symbol.time
|
||||||
if (isNaN(t)) return NANS
|
if (isNaN(t)) return NANS
|
||||||
@@ -415,6 +462,10 @@
|
|||||||
e.zs_bottom != null ? e.zs_bottom : NaN,
|
e.zs_bottom != null ? e.zs_bottom : NaN,
|
||||||
e.segzs_top != null ? e.segzs_top : NaN,
|
e.segzs_top != null ? e.segzs_top : NaN,
|
||||||
e.segzs_bottom != null ? e.segzs_bottom : NaN,
|
e.segzs_bottom != null ? e.segzs_bottom : NaN,
|
||||||
|
e.zs_pending_top != null ? e.zs_pending_top : NaN,
|
||||||
|
e.zs_pending_bottom != null ? e.zs_pending_bottom : NaN,
|
||||||
|
e.segzs_pending_top != null ? e.segzs_pending_top : NaN,
|
||||||
|
e.segzs_pending_bottom != null ? e.segzs_pending_bottom : NaN,
|
||||||
]
|
]
|
||||||
|
|
||||||
BSP_SUBTYPES.forEach(function (sub) {
|
BSP_SUBTYPES.forEach(function (sub) {
|
||||||
|
|||||||
@@ -263,8 +263,9 @@ function updateTradingViewData() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 重新显示笔、线段和中枢等图形
|
// 不再调用 redrawFractalElements():它会全量 initTradingView,
|
||||||
redrawFractalElements();
|
// 与增量更新叠加会导致图表反复重建、内存暴涨。
|
||||||
|
// 笔/段/中枢仍随「手动刷新 / 全量 refreshChart」重建;自动刷新走增量路径。
|
||||||
|
|
||||||
// 更新EMA52显示
|
// 更新EMA52显示
|
||||||
updateEMA52Display(currentData);
|
updateEMA52Display(currentData);
|
||||||
|
|||||||
+54
-271
@@ -1,33 +1,56 @@
|
|||||||
/* chart_tv.js — split from chart.js */
|
/* chart_tv.js — split from chart.js */
|
||||||
|
|
||||||
|
/** 释放 Lightweight Charts 实例、DOM 与全局事件,避免自动刷新内存泄漏 */
|
||||||
|
function disposeTradingViewCharts() {
|
||||||
|
try {
|
||||||
|
if (window._tvInitCleanups && Array.isArray(window._tvInitCleanups)) {
|
||||||
|
window._tvInitCleanups.forEach(function (fn) { try { fn(); } catch (e) {} });
|
||||||
|
}
|
||||||
|
window._tvInitCleanups = [];
|
||||||
|
if (window._bindSyncCleanups && Array.isArray(window._bindSyncCleanups)) {
|
||||||
|
window._bindSyncCleanups.forEach(function (fn) { try { fn(); } catch (e) {} });
|
||||||
|
}
|
||||||
|
window._bindSyncCleanups = [];
|
||||||
|
if (window._tooltipCleanups && Array.isArray(window._tooltipCleanups)) {
|
||||||
|
window._tooltipCleanups.forEach(function (fn) { try { fn(); } catch (e) {} });
|
||||||
|
}
|
||||||
|
window._tooltipCleanups = [];
|
||||||
|
|
||||||
|
document.querySelectorAll(
|
||||||
|
'.volume-crosshair-line, .atr-crosshair-line, .macd-crosshair-line, .chanmacd-crosshair-line'
|
||||||
|
).forEach(function (el) { try { el.remove(); } catch (e) {} });
|
||||||
|
|
||||||
|
if (typeof clearEMA52Series === 'function') {
|
||||||
|
try { clearEMA52Series(); } catch (e) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tvWidget) {
|
||||||
|
['mainChart', 'volumeChart', 'macdChart', 'chanMacdChart', 'atrChart'].forEach(function (key) {
|
||||||
|
try {
|
||||||
|
if (tvWidget[key] && typeof tvWidget[key].remove === 'function') {
|
||||||
|
tvWidget[key].remove();
|
||||||
|
}
|
||||||
|
} catch (e) {}
|
||||||
|
tvWidget[key] = null;
|
||||||
|
});
|
||||||
|
if (tvWidget.state) {
|
||||||
|
tvWidget.state.isInitialized = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var chartRoot = document.getElementById('tradingview_chart');
|
||||||
|
if (chartRoot) {
|
||||||
|
chartRoot.innerHTML = '';
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('disposeTradingViewCharts 失败(可忽略):', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function initTradingView(symbol, timeframe) {
|
function initTradingView(symbol, timeframe) {
|
||||||
try {
|
try {
|
||||||
// 在重新初始化前,尝试释放旧图表与系列资源,避免 GPU 内存累积
|
// 每次重建前完整释放,防止自动刷新导致 GPU/监听器泄漏
|
||||||
try {
|
disposeTradingViewCharts();
|
||||||
if (tvWidget && tvWidget.state && tvWidget.state.isInitialized) {
|
|
||||||
// 主图
|
|
||||||
if (tvWidget.mainChart && typeof tvWidget.mainChart.remove === 'function') {
|
|
||||||
tvWidget.mainChart.remove();
|
|
||||||
}
|
|
||||||
// 成交量
|
|
||||||
if (tvWidget.volumeChart && typeof tvWidget.volumeChart.remove === 'function') {
|
|
||||||
tvWidget.volumeChart.remove();
|
|
||||||
}
|
|
||||||
// 旧 MACD(若存在)
|
|
||||||
if (tvWidget.macdChart && typeof tvWidget.macdChart.remove === 'function') {
|
|
||||||
tvWidget.macdChart.remove();
|
|
||||||
}
|
|
||||||
// 新 ChanMACD(若存在)
|
|
||||||
if (tvWidget.chanMacdChart && typeof tvWidget.chanMacdChart.remove === 'function') {
|
|
||||||
tvWidget.chanMacdChart.remove();
|
|
||||||
}
|
|
||||||
// ATR
|
|
||||||
if (tvWidget.atrChart && typeof tvWidget.atrChart.remove === 'function') {
|
|
||||||
tvWidget.atrChart.remove();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.warn('释放旧图表资源失败(可忽略):', e);
|
|
||||||
}
|
|
||||||
console.log('初始化TradingView图表:', symbol, timeframe);
|
console.log('初始化TradingView图表:', symbol, timeframe);
|
||||||
|
|
||||||
// 获取当前交易对的配置
|
// 获取当前交易对的配置
|
||||||
@@ -98,11 +121,7 @@ try {
|
|||||||
candles = filterTradingHours(candles, symbolConfig);
|
candles = filterTradingHours(candles, symbolConfig);
|
||||||
console.log(`A股数据过滤: ${originalLength} -> ${candles.length} 条记录`);
|
console.log(`A股数据过滤: ${originalLength} -> ${candles.length} 条记录`);
|
||||||
}
|
}
|
||||||
// 清除图表容器(释放旧 DOM 与 Canvas)
|
// 重置图表对象(容器已在 disposeTradingViewCharts 清空)
|
||||||
const chartRoot = document.getElementById('tradingview_chart');
|
|
||||||
if (chartRoot) chartRoot.innerHTML = '';
|
|
||||||
|
|
||||||
// 重置图表对象
|
|
||||||
tvWidget = {
|
tvWidget = {
|
||||||
mainChart: null,
|
mainChart: null,
|
||||||
volumeChart: null,
|
volumeChart: null,
|
||||||
@@ -235,8 +254,7 @@ try {
|
|||||||
container.appendChild(chanMacdChartContainer);
|
container.appendChild(chanMacdChartContainer);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 防止同步过程中的无限循环
|
// 防止同步过程中的无限循环(实际同步由 bindSyncEvents 负责)
|
||||||
let syncInProgress = false;
|
|
||||||
|
|
||||||
// 创建统一的图表选项
|
// 创建统一的图表选项
|
||||||
const createChartOptions = (showTimeScale = true, chartType = 'main') => {
|
const createChartOptions = (showTimeScale = true, chartType = 'main') => {
|
||||||
@@ -1052,243 +1070,8 @@ try {
|
|||||||
window.kluDivMarkersSubSub = [];
|
window.kluDivMarkersSubSub = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
// 实现三图联动滚动
|
// 图表同步事件统一由文末 bindSyncEvents 注册(带 cleanup),此处不再重复 addEventListener,
|
||||||
|
// 否则每次自动刷新/重建都会在 document/window 上堆积监听导致内存泄漏。
|
||||||
// 同步图表的时间范围
|
|
||||||
function syncCharts(sourceChart, sourceContainer) {
|
|
||||||
// 防止无限循环 - 使用更精确的检查
|
|
||||||
if (syncInProgress) {
|
|
||||||
console.log('🔄 同步正在进行中,跳过此次同步');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
syncInProgress = true;
|
|
||||||
console.log('🚀 开始同步图表,来源:',
|
|
||||||
sourceChart === mainChart ? '主图' :
|
|
||||||
sourceChart === volumeChart ? '成交量图' :
|
|
||||||
sourceChart === atrChart ? 'ATR图' :
|
|
||||||
sourceChart === macdChart ? 'MACD图' :
|
|
||||||
sourceChart === chanMacdChart ? 'ChanMACD图' : '未知图表');
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (sourceChart && sourceChart.timeScale) {
|
|
||||||
const logicalRange = sourceChart.timeScale().getVisibleLogicalRange();
|
|
||||||
|
|
||||||
if (logicalRange && logicalRange.from !== undefined && logicalRange.to !== undefined) {
|
|
||||||
console.log('📊 同步时间范围:', logicalRange);
|
|
||||||
|
|
||||||
// 同步主图
|
|
||||||
if (sourceChart !== mainChart && mainChart && mainChart.timeScale) {
|
|
||||||
try {
|
|
||||||
mainChart.timeScale().setVisibleLogicalRange(logicalRange);
|
|
||||||
console.log('✅ 主图同步完成');
|
|
||||||
} catch (e) {
|
|
||||||
console.error('❌ 主图同步失败:', e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 同步成交量图
|
|
||||||
if (sourceChart !== volumeChart && volumeChart && volumeChart.timeScale) {
|
|
||||||
try {
|
|
||||||
volumeChart.timeScale().setVisibleLogicalRange(logicalRange);
|
|
||||||
console.log('✅ 成交量图同步完成');
|
|
||||||
} catch (e) {
|
|
||||||
console.error('❌ 成交量图同步失败:', e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 同步ATR图
|
|
||||||
if (sourceChart !== atrChart && atrChart && atrChart.timeScale) {
|
|
||||||
try {
|
|
||||||
atrChart.timeScale().setVisibleLogicalRange(logicalRange);
|
|
||||||
console.log('✅ ATR图同步完成');
|
|
||||||
} catch (e) {
|
|
||||||
console.error('❌ ATR图同步失败:', e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 同步MACD图
|
|
||||||
if (showMacd && macdChart && sourceChart !== macdChart && macdChart.timeScale) {
|
|
||||||
try {
|
|
||||||
macdChart.timeScale().setVisibleLogicalRange(logicalRange);
|
|
||||||
console.log('✅ MACD图同步完成');
|
|
||||||
} catch (e) {
|
|
||||||
console.error('❌ MACD图同步失败:', e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 同步ChanMACD图
|
|
||||||
if (showMacd && chanMacdChart && sourceChart !== chanMacdChart && chanMacdChart.timeScale) {
|
|
||||||
try {
|
|
||||||
chanMacdChart.timeScale().setVisibleLogicalRange(logicalRange);
|
|
||||||
console.log('✅ ChanMACD图同步完成');
|
|
||||||
} catch (e) {
|
|
||||||
console.error('❌ ChanMACD图同步失败:', e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 保存当前的可见范围到全局状态
|
|
||||||
if (tvWidget && tvWidget.state) {
|
|
||||||
tvWidget.state.logicalRange = logicalRange;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
console.warn('⚠️ 无效的逻辑范围:', logicalRange);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
console.warn('⚠️ 无效的源图表或时间刻度');
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.error('💥 同步图表出错:', e);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 立即重置同步标志,提高响应速度
|
|
||||||
setTimeout(() => {
|
|
||||||
syncInProgress = false;
|
|
||||||
console.log('🔓 同步标志已重置');
|
|
||||||
}, 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 用于跟踪所有图表的拖动状态
|
|
||||||
let localDragStates = {
|
|
||||||
main: false,
|
|
||||||
volume: false,
|
|
||||||
atr: false,
|
|
||||||
macd: false,
|
|
||||||
chanmacd: false
|
|
||||||
};
|
|
||||||
|
|
||||||
// 全局鼠标抬起事件(只添加一次)
|
|
||||||
document.addEventListener('mouseup', () => {
|
|
||||||
// 重置所有拖动状态
|
|
||||||
Object.keys(localDragStates).forEach(key => {
|
|
||||||
if (localDragStates[key]) {
|
|
||||||
console.log(`全局鼠标抬起,重置${key}图表拖动状态`);
|
|
||||||
localDragStates[key] = false;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// 为每个图表添加事件监听
|
|
||||||
const addChartSyncEvents = (chartContainer, chart) => {
|
|
||||||
console.log('为图表添加同步事件监听:',
|
|
||||||
chart === mainChart ? '主图' :
|
|
||||||
chart === volumeChart ? '成交量图' :
|
|
||||||
chart === atrChart ? 'ATR图' :
|
|
||||||
chart === macdChart ? 'MACD图' :
|
|
||||||
chart === chanMacdChart ? 'ChanMACD图' : '未知图表');
|
|
||||||
|
|
||||||
// 确定当前图表类型
|
|
||||||
const chartType = chart === mainChart ? 'main' :
|
|
||||||
chart === volumeChart ? 'volume' :
|
|
||||||
chart === atrChart ? 'atr' :
|
|
||||||
chart === macdChart ? 'macd' :
|
|
||||||
chart === chanMacdChart ? 'chanmacd' : 'unknown';
|
|
||||||
|
|
||||||
// 使用LightweightCharts内置的时间范围变化事件(这是最可靠的方法)
|
|
||||||
chart.timeScale().subscribeVisibleTimeRangeChange(() => {
|
|
||||||
// 使用图表特定的同步标志防止递归
|
|
||||||
if (!syncInProgress) {
|
|
||||||
console.log('✅ 检测到时间范围变化,触发同步:', chartType, '当前范围:', chart.timeScale().getVisibleLogicalRange());
|
|
||||||
syncCharts(chart, chartContainer);
|
|
||||||
} else {
|
|
||||||
console.log('⏸️ 同步进行中,跳过时间范围变化事件:', chartType);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// 备用的DOM事件监听(用于调试和额外保障)
|
|
||||||
let isScrolling = false;
|
|
||||||
|
|
||||||
// 鼠标按下事件
|
|
||||||
chartContainer.addEventListener('mousedown', (e) => {
|
|
||||||
localDragStates[chartType] = true;
|
|
||||||
console.log('鼠标按下开始拖动:', chartType);
|
|
||||||
});
|
|
||||||
|
|
||||||
// 鼠标抬起事件
|
|
||||||
chartContainer.addEventListener('mouseup', (e) => {
|
|
||||||
if (localDragStates[chartType]) {
|
|
||||||
localDragStates[chartType] = false;
|
|
||||||
console.log('鼠标抬起,结束拖动:', chartType);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// 鼠标离开事件
|
|
||||||
chartContainer.addEventListener('mouseleave', (e) => {
|
|
||||||
if (localDragStates[chartType]) {
|
|
||||||
localDragStates[chartType] = false;
|
|
||||||
console.log('鼠标离开容器,结束拖动:', chartType);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// 滚轮缩放事件(保持原有逻辑)
|
|
||||||
chartContainer.addEventListener('wheel', (e) => {
|
|
||||||
if (!isScrolling) {
|
|
||||||
isScrolling = true;
|
|
||||||
console.log('滚轮缩放:', chartType);
|
|
||||||
setTimeout(() => {
|
|
||||||
if (!syncInProgress) {
|
|
||||||
syncCharts(chart, chartContainer);
|
|
||||||
}
|
|
||||||
isScrolling = false;
|
|
||||||
}, 50);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
// 添加事件监听
|
|
||||||
addChartSyncEvents(mainChartContainer, mainChart);
|
|
||||||
addChartSyncEvents(volumeChartContainer, volumeChart);
|
|
||||||
addChartSyncEvents(atrChartContainer, atrChart);
|
|
||||||
if (showMacd && macdChart) {
|
|
||||||
addChartSyncEvents(macdChartContainer, macdChart);
|
|
||||||
}
|
|
||||||
if (showMacd && chanMacdChart) {
|
|
||||||
addChartSyncEvents(chanMacdChartContainer, chanMacdChart);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 窗口大小变化时重绘图表
|
|
||||||
window.addEventListener('resize', () => {
|
|
||||||
// 调整主图大小
|
|
||||||
mainChart.applyOptions({
|
|
||||||
width: mainChartContainer.clientWidth,
|
|
||||||
height: mainChartContainer.clientHeight
|
|
||||||
});
|
|
||||||
|
|
||||||
// 调整成交量图大小
|
|
||||||
volumeChart.applyOptions({
|
|
||||||
width: volumeChartContainer.clientWidth,
|
|
||||||
height: volumeChartContainer.clientHeight
|
|
||||||
});
|
|
||||||
|
|
||||||
// 调整ATR图大小
|
|
||||||
atrChart.applyOptions({
|
|
||||||
width: atrChartContainer.clientWidth,
|
|
||||||
height: atrChartContainer.clientHeight
|
|
||||||
});
|
|
||||||
|
|
||||||
// 调整MACD图大小
|
|
||||||
if (showMacd && macdChart && macdChartContainer) {
|
|
||||||
macdChart.applyOptions({
|
|
||||||
width: macdChartContainer.clientWidth,
|
|
||||||
height: macdChartContainer.clientHeight
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// 调整ChanMACD图大小
|
|
||||||
if (showMacd && chanMacdChart && chanMacdChartContainer) {
|
|
||||||
chanMacdChart.applyOptions({
|
|
||||||
width: chanMacdChartContainer.clientWidth,
|
|
||||||
height: chanMacdChartContainer.clientHeight
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// 重新同步 - 使用主图作为同步源
|
|
||||||
setTimeout(() => {
|
|
||||||
if (mainChart) {
|
|
||||||
syncCharts(mainChart, mainChartContainer);
|
|
||||||
}
|
|
||||||
}, 200);
|
|
||||||
});
|
|
||||||
// 显示笔的绘制 - 分别处理主周期、次周期和次次周期
|
// 显示笔的绘制 - 分别处理主周期、次周期和次次周期
|
||||||
if ($('#showMainBi').is(':checked') || $('#showElementBi').is(':checked') || $('#showSubSubBi').is(':checked')) {
|
if ($('#showMainBi').is(':checked') || $('#showElementBi').is(':checked') || $('#showSubSubBi').is(':checked')) {
|
||||||
console.log('绘制笔 - 已启用');
|
console.log('绘制笔 - 已启用');
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
/* chart_view.js — split from chart.js */
|
/* chart_view.js — split from chart.js */
|
||||||
function updateChart() {
|
function updateChart(options) {
|
||||||
|
options = options || {};
|
||||||
// 只显示旋转加载图标
|
// 只显示旋转加载图标
|
||||||
$('#refreshLoadingSpinner').show();
|
$('#refreshLoadingSpinner').show();
|
||||||
|
|
||||||
@@ -18,7 +19,7 @@ function updateChart() {
|
|||||||
const subSubTimeframe = $('#subSubTimeframe').val() || '';
|
const subSubTimeframe = $('#subSubTimeframe').val() || '';
|
||||||
|
|
||||||
// 确保时区参数有效
|
// 确保时区参数有效
|
||||||
console.log('更新图表使用时区:', timezone);
|
console.log('更新图表使用时区:', timezone, 'reason:', options.reason || (options.fromAutoRefresh ? 'auto' : 'manual'));
|
||||||
console.log('数据源:', dataSource, '交易对/股票:', symbol);
|
console.log('数据源:', dataSource, '交易对/股票:', symbol);
|
||||||
|
|
||||||
// 如果symbol为空,不发送请求
|
// 如果symbol为空,不发送请求
|
||||||
@@ -42,9 +43,14 @@ function updateChart() {
|
|||||||
endTimeMs = new Date($('#end_time').val()).getTime();
|
endTimeMs = new Date($('#end_time').val()).getTime();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 自动刷新:取消进行中的上一请求,避免响应堆积
|
||||||
|
if (options.fromAutoRefresh && window._analyzeXhr && window._analyzeXhr.readyState !== 4) {
|
||||||
|
try { window._analyzeXhr.abort(); } catch (e) {}
|
||||||
|
}
|
||||||
|
|
||||||
// 发送请求
|
// 发送请求
|
||||||
const requestId = ++lastRequestId; // 标记本次请求
|
const requestId = ++lastRequestId; // 标记本次请求
|
||||||
$.ajax({
|
window._analyzeXhr = $.ajax({
|
||||||
url: '/api/analyze',
|
url: '/api/analyze',
|
||||||
data: {
|
data: {
|
||||||
symbol: symbol,
|
symbol: symbol,
|
||||||
@@ -75,15 +81,25 @@ function updateChart() {
|
|||||||
}
|
}
|
||||||
currentData = data;
|
currentData = data;
|
||||||
|
|
||||||
refreshChart(data);
|
refreshChart(data, {
|
||||||
|
incremental: options.incremental !== undefined
|
||||||
|
? !!options.incremental
|
||||||
|
: !!options.fromAutoRefresh
|
||||||
|
});
|
||||||
},
|
},
|
||||||
error: function(jqXHR, textStatus, errorThrown) {
|
error: function(jqXHR, textStatus, errorThrown) {
|
||||||
// 隐藏加载图标
|
// 隐藏加载图标
|
||||||
$('#refreshLoadingSpinner').hide();
|
$('#refreshLoadingSpinner').hide();
|
||||||
|
if (textStatus === 'abort') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// 显示错误信息
|
// 显示错误信息
|
||||||
console.error('加载数据失败:', errorThrown);
|
console.error('加载数据失败:', errorThrown);
|
||||||
alert('加载数据失败: ' + (jqXHR.responseJSON?.error || errorThrown));
|
// 自动刷新失败不弹窗打扰
|
||||||
|
if (!options.fromAutoRefresh) {
|
||||||
|
alert('加载数据失败: ' + (jqXHR.responseJSON?.error || errorThrown));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,20 +1,27 @@
|
|||||||
/**
|
/**
|
||||||
* TradingView Datafeed — 对接 Data Provider 微服务
|
* TradingView Datafeed — 对接 Data Provider 微服务
|
||||||
*
|
*
|
||||||
* 数据源: http://103.179.242.166
|
* REST: https://provider.jackyu66.com
|
||||||
* - GET /timeframes → 可用周期
|
* WS: wss://jackyu66.com/ws (可通过 window.DATA_SERVICE_* 或 URL 参数覆盖)
|
||||||
* - GET /api/candles → 历史 OHLCV
|
|
||||||
* - WS /ws → 实时 K 线推送
|
|
||||||
*
|
*
|
||||||
* 实现 IDatafeedChartApi 核心接口:
|
* 实时:subscribeBars → 收到 kline 后调用 onTick(由 Charting Library 增量更新,不重置缩放)
|
||||||
* onReady, resolveSymbol, getBars, subscribeBars, unsubscribeBars
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
var ChanTVDatafeed = (function () {
|
var ChanTVDatafeed = (function () {
|
||||||
'use strict'
|
'use strict'
|
||||||
|
|
||||||
// 默认 data_provider 地址,可通过 URL param 覆盖
|
function getParam(name) {
|
||||||
var DATA_HOST = 'http://103.179.242.166'
|
try {
|
||||||
|
var m = (new RegExp('[?&]' + name + '=([^&]*)')).exec(location.search)
|
||||||
|
return m ? decodeURIComponent(m[1]) : ''
|
||||||
|
} catch (e) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// REST 与 WS 可分离(nginx 反代)
|
||||||
|
var DATA_HOST = (window.DATA_SERVICE_URL || getParam('data_host') || 'https://provider.jackyu66.com').replace(/\/$/, '')
|
||||||
|
var WS_URL = (window.DATA_SERVICE_WS_URL || getParam('ws_url') || 'wss://jackyu66.com/ws').replace(/\/$/, '')
|
||||||
|
|
||||||
// ---- resolution <-> timeframe 转换 ----
|
// ---- resolution <-> timeframe 转换 ----
|
||||||
var RES_TO_TF = {
|
var RES_TO_TF = {
|
||||||
@@ -36,13 +43,12 @@ var ChanTVDatafeed = (function () {
|
|||||||
var ws = null
|
var ws = null
|
||||||
var wsReconnectTimer = null
|
var wsReconnectTimer = null
|
||||||
var wsSubs = {} // listenerGuid -> { symbol, tf, onTick, lastTickTime }
|
var wsSubs = {} // listenerGuid -> { symbol, tf, onTick, lastTickTime }
|
||||||
var wsUrl = DATA_HOST.replace(/^http/, 'ws') + '/ws'
|
|
||||||
|
|
||||||
function wsConnect() {
|
function wsConnect() {
|
||||||
if (ws && (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING)) return
|
if (ws && (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING)) return
|
||||||
|
|
||||||
try {
|
try {
|
||||||
ws = new WebSocket(wsUrl)
|
ws = new WebSocket(WS_URL)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn('[TV Datafeed] WS 连接失败', e)
|
console.warn('[TV Datafeed] WS 连接失败', e)
|
||||||
scheduleReconnect()
|
scheduleReconnect()
|
||||||
@@ -50,7 +56,7 @@ var ChanTVDatafeed = (function () {
|
|||||||
}
|
}
|
||||||
|
|
||||||
ws.onopen = function () {
|
ws.onopen = function () {
|
||||||
console.log('[TV Datafeed] WS 已连接')
|
console.log('[TV Datafeed] WS 已连接', WS_URL)
|
||||||
// 重新订阅
|
// 重新订阅
|
||||||
Object.keys(wsSubs).forEach(function (guid) {
|
Object.keys(wsSubs).forEach(function (guid) {
|
||||||
var sub = wsSubs[guid]
|
var sub = wsSubs[guid]
|
||||||
@@ -62,28 +68,35 @@ var ChanTVDatafeed = (function () {
|
|||||||
try {
|
try {
|
||||||
var msg = JSON.parse(evt.data)
|
var msg = JSON.parse(evt.data)
|
||||||
var bars = msg.data || msg.bars // data_provider 用 'data' 字段
|
var bars = msg.data || msg.bars // data_provider 用 'data' 字段
|
||||||
|
// 历史快照交给 getBars;实时只走 kline → onTick,避免冲掉缩放
|
||||||
|
if (msg.type === 'snapshot' || msg.type === 'subscribed') return
|
||||||
if ((msg.type === 'kline' || msg.type === 'candles') && bars && bars.length > 0) {
|
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 sorted = bars.slice().sort(function (a, b) { return (a.timestamp || 0) - (b.timestamp || 0) })
|
||||||
var latest = sorted[sorted.length - 1]
|
var latest = sorted[sorted.length - 1]
|
||||||
// 广播给所有匹配的 subscriber
|
if (!latest || latest.timestamp == null) return
|
||||||
Object.keys(wsSubs).forEach(function (guid) {
|
Object.keys(wsSubs).forEach(function (guid) {
|
||||||
var sub = wsSubs[guid]
|
var sub = wsSubs[guid]
|
||||||
if (sub.symbol === msg.symbol && sub.tf === msg.timeframe) {
|
if (sub.symbol === msg.symbol && sub.tf === msg.timeframe) {
|
||||||
// 跳过已处理过的时间戳
|
// 允许同 timestamp 更新未收盘棒(用 < 而不是 <=)
|
||||||
if (sub.lastTickTime && latest.timestamp <= sub.lastTickTime) return
|
if (sub.lastTickTime != null && latest.timestamp < sub.lastTickTime) return
|
||||||
|
var tick = {
|
||||||
|
time: latest.timestamp,
|
||||||
|
open: latest.open,
|
||||||
|
high: latest.high,
|
||||||
|
low: latest.low,
|
||||||
|
close: latest.close,
|
||||||
|
volume: latest.volume,
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
sub.onTick({
|
sub.onTick(tick)
|
||||||
time: latest.timestamp,
|
|
||||||
open: latest.open,
|
|
||||||
high: latest.high,
|
|
||||||
low: latest.low,
|
|
||||||
close: latest.close,
|
|
||||||
volume: latest.volume,
|
|
||||||
})
|
|
||||||
sub.lastTickTime = latest.timestamp
|
sub.lastTickTime = latest.timestamp
|
||||||
} catch (e) { /* ignore */ }
|
} catch (e) { /* ignore */ }
|
||||||
|
// 通知页面:更新缠论缓存(K 线由 TV onTick 处理,不重置缩放)
|
||||||
|
try {
|
||||||
|
if (window.ChanTvRealtime && typeof window.ChanTvRealtime.onBar === 'function') {
|
||||||
|
window.ChanTvRealtime.onBar(msg.symbol, msg.timeframe, latest)
|
||||||
|
}
|
||||||
|
} catch (e2) { /* ignore */ }
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
+37
-7
@@ -88,15 +88,13 @@ $(document).ready(function() {
|
|||||||
.always(function() {
|
.always(function() {
|
||||||
loadAStockSymbols();
|
loadAStockSymbols();
|
||||||
startAStockStatusUpdater();
|
startAStockStatusUpdater();
|
||||||
|
// A 股:metadata 完成后再拉数(下方不再重复 updateChart)
|
||||||
setTimeout(function() {
|
setTimeout(function() {
|
||||||
updateChart();
|
updateChart();
|
||||||
}, 300);
|
}, 300);
|
||||||
});
|
});
|
||||||
} else {
|
|
||||||
setTimeout(function() {
|
|
||||||
updateChart();
|
|
||||||
}, 500);
|
|
||||||
}
|
}
|
||||||
|
// 加密货币:统一在文末单次 updateChart,避免重复请求
|
||||||
|
|
||||||
// 初始化交易对下拉菜单
|
// 初始化交易对下拉菜单
|
||||||
$('#symbol').val('BTC/USDT:USDT');
|
$('#symbol').val('BTC/USDT:USDT');
|
||||||
@@ -245,6 +243,7 @@ $(document).ready(function() {
|
|||||||
// 自动刷新相关变量
|
// 自动刷新相关变量
|
||||||
let autoRefreshTimer = null;
|
let autoRefreshTimer = null;
|
||||||
let nextRefreshTime = null;
|
let nextRefreshTime = null;
|
||||||
|
let autoRefreshTick = 0;
|
||||||
// 初始化自动刷新功能
|
// 初始化自动刷新功能
|
||||||
function initAutoRefresh() {
|
function initAutoRefresh() {
|
||||||
// 监听自动刷新勾选框变化
|
// 监听自动刷新勾选框变化
|
||||||
@@ -281,12 +280,18 @@ function startAutoRefresh() {
|
|||||||
updateNextRefreshTimeDisplay();
|
updateNextRefreshTimeDisplay();
|
||||||
|
|
||||||
// 启动定时器
|
// 启动定时器
|
||||||
|
autoRefreshTick = 0;
|
||||||
autoRefreshTimer = setInterval(function() {
|
autoRefreshTimer = setInterval(function() {
|
||||||
// 更新结束时间为当前时间
|
// 更新结束时间为当前时间
|
||||||
updateEndTimeToNow();
|
updateEndTimeToNow();
|
||||||
|
|
||||||
// 刷新图表
|
// 多数周期增量更新;每隔若干次全量重建以刷新笔/段/中枢(dispose 已防泄漏)
|
||||||
updateChart();
|
autoRefreshTick += 1;
|
||||||
|
const fullRebuild = (autoRefreshTick % 6) === 0;
|
||||||
|
updateChart({
|
||||||
|
fromAutoRefresh: true,
|
||||||
|
incremental: !fullRebuild
|
||||||
|
});
|
||||||
|
|
||||||
// 更新下次刷新时间
|
// 更新下次刷新时间
|
||||||
nextRefreshTime = new Date(Date.now() + intervalMs);
|
nextRefreshTime = new Date(Date.now() + intervalMs);
|
||||||
@@ -512,7 +517,7 @@ function updateFractalTables() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 刷新图表并更新表格
|
// 刷新图表并更新表格
|
||||||
function refreshChart(data) {
|
function refreshChart(data, options) {
|
||||||
// 检查是否接收到数据
|
// 检查是否接收到数据
|
||||||
if (!data) {
|
if (!data) {
|
||||||
console.error('未收到数据,无法刷新图表');
|
console.error('未收到数据,无法刷新图表');
|
||||||
@@ -523,6 +528,31 @@ function refreshChart(data) {
|
|||||||
$('#elementTimeframe').val(data.element_timeframe);
|
$('#elementTimeframe').val(data.element_timeframe);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
options = options || {};
|
||||||
|
const preferIncremental = !!options.incremental;
|
||||||
|
const chartsReady = tvWidget && tvWidget.state && tvWidget.state.isInitialized && tvWidget.mainChart;
|
||||||
|
|
||||||
|
// 自动刷新:增量更新,避免每次销毁/重建 Lightweight Charts
|
||||||
|
if (preferIncremental && chartsReady) {
|
||||||
|
try {
|
||||||
|
if (tvWidget.mainChart) {
|
||||||
|
try {
|
||||||
|
window._pendingRestoreView = captureChartViewState(tvWidget.mainChart);
|
||||||
|
} catch (e) {
|
||||||
|
window._pendingRestoreView = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
updateTradingViewData();
|
||||||
|
updateTables(data);
|
||||||
|
if (currentData && currentData.ema52_dict) {
|
||||||
|
updateEMA52Display(currentData);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('增量刷新失败,回退全量重建:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 保存当前缩放(barSpacing)和滚动位置(scrollPosition)到 window
|
// 保存当前缩放(barSpacing)和滚动位置(scrollPosition)到 window
|
||||||
// tvWidget 会在 initTradingView 内被重建,所以必须存到 window 上
|
// tvWidget 会在 initTradingView 内被重建,所以必须存到 window 上
|
||||||
if (tvWidget && tvWidget.mainChart) {
|
if (tvWidget && tvWidget.mainChart) {
|
||||||
|
|||||||
+170
-314
@@ -119,7 +119,7 @@
|
|||||||
|
|
||||||
<!-- 控制栏 -->
|
<!-- 控制栏 -->
|
||||||
<div class="toolbar">
|
<div class="toolbar">
|
||||||
<span class="logo">缠<span>论</span></span>
|
<span class="logo">缠<span>论</span> <small style="font-weight:500;color:#888;font-size:12px;">全版</small></span>
|
||||||
<input id="symbol-input" type="text" value="BTC/USDT:USDT" title="交易对">
|
<input id="symbol-input" type="text" value="BTC/USDT:USDT" title="交易对">
|
||||||
<select id="tf-select" title="周期">
|
<select id="tf-select" title="周期">
|
||||||
<option value="1m">1m</option>
|
<option value="1m">1m</option>
|
||||||
@@ -135,6 +135,7 @@
|
|||||||
</select>
|
</select>
|
||||||
<button id="btn-reload" class="btn btn-reload" title="刷新数据">↻ 刷新</button>
|
<button id="btn-reload" class="btn btn-reload" title="刷新数据">↻ 刷新</button>
|
||||||
<button id="btn-theme" class="btn btn-theme" title="切换主题">🌙</button>
|
<button id="btn-theme" class="btn btn-theme" title="切换主题">🌙</button>
|
||||||
|
<span style="font-size:12px;color:#666;white-space:nowrap;">指标会自动存到本机,刷新后恢复</span>
|
||||||
<span class="status" id="status-bar">
|
<span class="status" id="status-bar">
|
||||||
<span class="dot yellow"></span> 初始化...
|
<span class="dot yellow"></span> 初始化...
|
||||||
</span>
|
</span>
|
||||||
@@ -147,26 +148,28 @@
|
|||||||
<!-- TV charting library -->
|
<!-- TV charting library -->
|
||||||
<script src="/charting_library/charting_library.js"></script>
|
<script src="/charting_library/charting_library.js"></script>
|
||||||
<!-- 自定义模块 -->
|
<!-- 自定义模块 -->
|
||||||
|
<script>
|
||||||
|
window.DATA_SERVICE_URL = "{{ data_service_url }}";
|
||||||
|
window.DATA_SERVICE_WS_URL = "{{ data_service_ws_url }}";
|
||||||
|
</script>
|
||||||
<script src="/static/js/app/api_client.js?v=1"></script>
|
<script src="/static/js/app/api_client.js?v=1"></script>
|
||||||
<script src="/static/js/app/chan_engine.js?v=4"></script>
|
<script src="/static/js/app/chan_engine.js?v=5"></script>
|
||||||
<script src="/static/js/app/datafeed.js?v=6"></script>
|
<script src="/static/js/app/datafeed.js?v=8"></script>
|
||||||
<script src="/static/js/app/chan_indicator.js?v=10"></script>
|
<script src="/static/js/app/chan_indicator.js?v=11"></script>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
(function () {
|
(function () {
|
||||||
'use strict'
|
'use strict'
|
||||||
|
|
||||||
// ---- 配置 ----
|
|
||||||
var DATA_HOST = 'http://103.179.242.166'
|
|
||||||
// WebSocket URL
|
|
||||||
var WS_URL = DATA_HOST.replace(/^http/, 'ws') + '/ws'
|
|
||||||
|
|
||||||
// ---- 读取 URL params ----
|
// ---- 读取 URL params ----
|
||||||
function getParam(name, def) {
|
function getParam(name, def) {
|
||||||
var m = (new RegExp('[?&]' + name + '=([^&]*)')).exec(location.search)
|
var m = (new RegExp('[?&]' + name + '=([^&]*)')).exec(location.search)
|
||||||
return m ? decodeURIComponent(m[1]) : def
|
return m ? decodeURIComponent(m[1]) : def
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- 配置(REST / WS 可分离)----
|
||||||
|
var DATA_HOST = (window.DATA_SERVICE_URL || getParam('data_host', 'https://provider.jackyu66.com')).replace(/\/$/, '')
|
||||||
|
|
||||||
var defaultSymbol = getParam('symbol', 'BTC/USDT:USDT')
|
var defaultSymbol = getParam('symbol', 'BTC/USDT:USDT')
|
||||||
var defaultTf = getParam('tf', '5m')
|
var defaultTf = getParam('tf', '5m')
|
||||||
|
|
||||||
@@ -191,7 +194,6 @@
|
|||||||
var lastChanKey = ''
|
var lastChanKey = ''
|
||||||
var fetchPromise = null // 防止并发请求
|
var fetchPromise = null // 防止并发请求
|
||||||
var computePending = false // 标记是否有待处理的计算
|
var computePending = false // 标记是否有待处理的计算
|
||||||
var computeTimeout = null // 防抖,避免 WS 洪水
|
|
||||||
|
|
||||||
// ---- 工具:resolution ↔ timeframe ----
|
// ---- 工具:resolution ↔ timeframe ----
|
||||||
function tfToRes(tf) {
|
function tfToRes(tf) {
|
||||||
@@ -378,6 +380,36 @@
|
|||||||
setStatus('ok')
|
setStatus('ok')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- 本地缓存图表布局(含已开指标)----
|
||||||
|
var CHART_STATE_KEY = 'chan_tv_chart_state_v1'
|
||||||
|
|
||||||
|
function loadSavedChartState() {
|
||||||
|
try {
|
||||||
|
var raw = localStorage.getItem(CHART_STATE_KEY)
|
||||||
|
if (!raw) return null
|
||||||
|
return JSON.parse(raw)
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[布局] 读取本地缓存失败', e)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function persistChartState() {
|
||||||
|
if (!widget || typeof widget.save !== 'function') return
|
||||||
|
try {
|
||||||
|
widget.save(function (state) {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(CHART_STATE_KEY, JSON.stringify(state))
|
||||||
|
console.log('[布局] 已缓存到本地(含指标)')
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[布局] 写入 localStorage 失败', e)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[布局] save 失败', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ---- 初始化 TV Widget ----
|
// ---- 初始化 TV Widget ----
|
||||||
function initTvWidget() {
|
function initTvWidget() {
|
||||||
var symbol = symbolInput.value.trim()
|
var symbol = symbolInput.value.trim()
|
||||||
@@ -394,15 +426,15 @@
|
|||||||
computeAndRefreshChan(true)
|
computeAndRefreshChan(true)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
// WS 重新订阅新周期
|
// WS 重新订阅由 datafeed.subscribeBars 自动处理
|
||||||
if (window._resubscribeChanWS) window._resubscribeChanWS()
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn('setSymbol failed', e)
|
console.warn('setSymbol failed', e)
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
widget = new TradingView.widget({
|
var savedState = loadSavedChartState()
|
||||||
|
var widgetOpts = {
|
||||||
container: chartContainer,
|
container: chartContainer,
|
||||||
library_path: '/charting_library/',
|
library_path: '/charting_library/',
|
||||||
datafeed: ChanTVDatafeed,
|
datafeed: ChanTVDatafeed,
|
||||||
@@ -413,31 +445,65 @@
|
|||||||
theme: getTheme(),
|
theme: getTheme(),
|
||||||
timezone: 'Asia/Shanghai',
|
timezone: 'Asia/Shanghai',
|
||||||
locale: 'zh',
|
locale: 'zh',
|
||||||
toolbar_bg: '#f8f9fa',
|
toolbar_bg: getTheme() === 'dark' ? '#131722' : '#f8f9fa',
|
||||||
client_id: 'chan-core',
|
client_id: 'chan-core',
|
||||||
user_id: 'local',
|
user_id: 'local',
|
||||||
|
auto_save_delay: 3, // 秒;变更后自动触发 onAutoSaveNeeded
|
||||||
|
// 仅注入缠论;其余全部走 TradingView 内置指标库(不设 studies_access = 不限制)
|
||||||
custom_indicators_getter: function () {
|
custom_indicators_getter: function () {
|
||||||
return Promise.resolve([makeChanIndicator()])
|
return Promise.resolve([makeChanIndicator()])
|
||||||
},
|
},
|
||||||
|
// 币安式:顶部可点「指标」添加全部内置指标;左侧绘图工具栏
|
||||||
|
// 注意:不要开 study_templates(无 charts_storage_url 会 404 打断工具栏)
|
||||||
disabled_features: [
|
disabled_features: [
|
||||||
'header_compare',
|
|
||||||
'header_saveload',
|
'header_saveload',
|
||||||
'study_templates',
|
'study_templates',
|
||||||
'create_volume_indicator_by_default',
|
'volume_force_overlay', // 关:成交量独立副图,不叠主图
|
||||||
'save_chart_properties_to_local_storage',
|
|
||||||
],
|
],
|
||||||
enabled_features: [
|
enabled_features: [
|
||||||
'hide_left_toolbar_by_default',
|
'header_widget',
|
||||||
|
'header_indicators',
|
||||||
|
'header_fullscreen_button',
|
||||||
|
'header_chart_type',
|
||||||
|
'header_resolutions',
|
||||||
|
'header_settings',
|
||||||
|
'header_undo_redo',
|
||||||
|
'header_screenshot',
|
||||||
|
'left_toolbar',
|
||||||
|
'control_bar',
|
||||||
|
'timeframes_toolbar',
|
||||||
|
'edit_buttons_in_legend',
|
||||||
|
'context_menus',
|
||||||
|
'legend_context_menu',
|
||||||
|
'pane_context_menu',
|
||||||
|
'scales_context_menu',
|
||||||
|
'show_object_tree',
|
||||||
'items_favoriting',
|
'items_favoriting',
|
||||||
|
'insert_indicator_dialog_shortcut',
|
||||||
|
'caption_buttons_text_if_possible',
|
||||||
|
'create_volume_indicator_by_default',
|
||||||
|
// 不用 volume_force_overlay:成交量单独副图,与主图分开
|
||||||
|
'display_legend_on_all_charts',
|
||||||
|
'use_localstorage_for_settings',
|
||||||
],
|
],
|
||||||
favorites: {
|
favorites: {
|
||||||
intervals: ['1', '5', '15', '30', '60', '240', 'D'],
|
intervals: ['1', '3', '5', '15', '30', '60', '240', 'D'],
|
||||||
chartTypes: ['Candles'],
|
chartTypes: ['Candles', 'Line', 'Area'],
|
||||||
|
indicators: [
|
||||||
|
'Moving Average',
|
||||||
|
'EMA Cross',
|
||||||
|
'MACD',
|
||||||
|
'Relative Strength Index',
|
||||||
|
'Bollinger Bands',
|
||||||
|
'Volume',
|
||||||
|
'Stochastic',
|
||||||
|
'Average True Range',
|
||||||
|
'缠论',
|
||||||
|
],
|
||||||
},
|
},
|
||||||
studies_overrides: {
|
studies_overrides: {
|
||||||
'macd.macd.display': 3,
|
'volume.volume.color.0': 'rgba(8, 153, 129, 0.4)',
|
||||||
'macd.signal.display': 3,
|
'volume.volume.color.1': 'rgba(239, 68, 68, 0.4)',
|
||||||
'macd.histogram.display': 3,
|
|
||||||
},
|
},
|
||||||
overrides: {
|
overrides: {
|
||||||
'paneProperties.background': getTheme() === 'light' ? '#ffffff' : '#131722',
|
'paneProperties.background': getTheme() === 'light' ? '#ffffff' : '#131722',
|
||||||
@@ -449,28 +515,54 @@
|
|||||||
'mainSeriesProperties.candleStyle.wickUpColor': '#ef4444',
|
'mainSeriesProperties.candleStyle.wickUpColor': '#ef4444',
|
||||||
'mainSeriesProperties.candleStyle.wickDownColor': '#089981',
|
'mainSeriesProperties.candleStyle.wickDownColor': '#089981',
|
||||||
},
|
},
|
||||||
})
|
}
|
||||||
|
// 有本地缓存则恢复布局(含已开指标);会覆盖构造时的 symbol/interval
|
||||||
|
if (savedState) {
|
||||||
|
widgetOpts.saved_data = savedState
|
||||||
|
console.log('[布局] 从本地恢复上次指标/布局')
|
||||||
|
}
|
||||||
|
|
||||||
|
widget = new TradingView.widget(widgetOpts)
|
||||||
|
|
||||||
widget.onChartReady(function () {
|
widget.onChartReady(function () {
|
||||||
chart = widget.activeChart()
|
chart = widget.activeChart()
|
||||||
window._chanChart = chart // for debugging
|
window._chanChart = chart // for debugging
|
||||||
if (!chart) return
|
if (!chart) return
|
||||||
|
|
||||||
// 创建 MACD
|
// 缓存恢复后,若工具栏交易对/周期与缓存不一致,切回工具栏选择(保留指标)
|
||||||
try {
|
try {
|
||||||
chart.createStudy('MACD', false, false)
|
var curSym = chart.symbol()
|
||||||
} catch (e) {
|
var curRes = chart.resolution()
|
||||||
console.warn('createStudy MACD failed', e)
|
var wantRes = tfToRes(tf)
|
||||||
}
|
if (curSym !== symbol || String(curRes) !== String(wantRes)) {
|
||||||
|
widget.setSymbol(symbol, wantRes, function () {
|
||||||
// 调整 MACD 副图大小
|
chart = widget.activeChart()
|
||||||
try {
|
})
|
||||||
var panes = chart.getPanes()
|
|
||||||
if (panes.length >= 2) {
|
|
||||||
panes[panes.length - 1].setHeight(150)
|
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn('resize MACD pane failed', e)
|
console.warn('同步工具栏 symbol/tf 失败', e)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 自动保存:加指标/改设置后写入 localStorage
|
||||||
|
try {
|
||||||
|
widget.subscribe('onAutoSaveNeeded', function () {
|
||||||
|
persistChartState()
|
||||||
|
})
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('subscribe onAutoSaveNeeded failed', e)
|
||||||
|
}
|
||||||
|
// 兜底:指标增删时也存一份
|
||||||
|
try {
|
||||||
|
widget.subscribe('study', function () {
|
||||||
|
setTimeout(persistChartState, 500)
|
||||||
|
})
|
||||||
|
} catch (e) { /* 部分版本无此事件 */ }
|
||||||
|
|
||||||
|
// 确保缠论指标挂上(自定义)
|
||||||
|
try {
|
||||||
|
ensureAndPokeChanStudy(chart)
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('ensure Chan study failed', e)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 监听数据加载完 → 刷新缠论
|
// 监听数据加载完 → 刷新缠论
|
||||||
@@ -479,7 +571,7 @@
|
|||||||
// 监听可视范围变化 → 刷新缠论
|
// 监听可视范围变化 → 刷新缠论
|
||||||
attachVisibleRangeListener(chart)
|
attachVisibleRangeListener(chart)
|
||||||
|
|
||||||
// 监听样式变化 → 持久化
|
// 监听样式变化 → 持久化缠论样式 + 整图布局
|
||||||
try {
|
try {
|
||||||
widget.subscribe('study_properties_changed', function (entityId) {
|
widget.subscribe('study_properties_changed', function (entityId) {
|
||||||
var studies = chart.getAllStudies ? chart.getAllStudies() : []
|
var studies = chart.getAllStudies ? chart.getAllStudies() : []
|
||||||
@@ -491,6 +583,7 @@
|
|||||||
var sv = api ? api.getStyleValues() : null
|
var sv = api ? api.getStyleValues() : null
|
||||||
if (sv) saveChanStyles(sv)
|
if (sv) saveChanStyles(sv)
|
||||||
}
|
}
|
||||||
|
persistChartState()
|
||||||
})
|
})
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn('subscribe study_properties_changed failed', e)
|
console.warn('subscribe study_properties_changed failed', e)
|
||||||
@@ -570,290 +663,53 @@
|
|||||||
setStatus('loading');
|
setStatus('loading');
|
||||||
initTvWidget();
|
initTvWidget();
|
||||||
|
|
||||||
// ---- WebSocket 实时 K 线 → 增量缠论更新 ----
|
// ---- 实时:只用 TV datafeed 的 onTick 更新 K(不重置缩放)----
|
||||||
(function () {
|
// 缠论缓存由 datafeed 回调同步;新棒出现(收盘)再重算缠论
|
||||||
var ws = null
|
;(function () {
|
||||||
var reconnectTimer = null
|
var chanDebounce = null
|
||||||
var pendingBars = [] // 收集待处理的新 bar
|
var lastTs = null
|
||||||
var lastWsMsgTime = 0 // 上次收到消息的时间(心跳检测)
|
|
||||||
var heartbeatTimer = null // 心跳超时检测
|
|
||||||
var backfillTimer = null // 回补延迟
|
|
||||||
|
|
||||||
function getTfMs(tf) {
|
window.ChanTvRealtime = {
|
||||||
var map = { '1m':60000, '3m':180000, '5m':300000, '15m':900000, '30m':1800000,
|
onBar: function (symbol, tf, bar) {
|
||||||
'1h':3600000, '2h':7200000, '4h':14400000, '6h':21600000, '8h':28800000,
|
if (!bar || bar.timestamp == null) return
|
||||||
'12h':43200000, '1d':86400000, '3d':259200000, '1w':604800000, '1M':2592000000 }
|
var curSym, curTf
|
||||||
return map[tf] || 300000
|
try { curSym = chart && chart.symbol() } catch (e) { curSym = symbolInput.value.trim() }
|
||||||
}
|
try { curTf = chart && resToTf(chart.resolution()) } catch (e) { curTf = tfSelect.value }
|
||||||
|
if (symbol !== curSym || tf !== curTf) return
|
||||||
|
|
||||||
function startHeartbeat(tf) {
|
var closed = false
|
||||||
stopHeartbeat()
|
if (cachedOhlcvBars && cachedOhlcvBars.length) {
|
||||||
// 期望每根 bar 至少收到一次数据,超时设为 3 倍周期 + 10 秒
|
var last = cachedOhlcvBars[cachedOhlcvBars.length - 1]
|
||||||
var interval = Math.min(getTfMs(tf) * 3 + 10000, 60000) // 最长 60s
|
if (bar.timestamp === last.timestamp) {
|
||||||
heartbeatTimer = setTimeout(function () {
|
last.open = bar.open; last.high = bar.high; last.low = bar.low
|
||||||
console.warn('[WS] 心跳超时,重连...')
|
last.close = bar.close; last.volume = bar.volume || 0
|
||||||
if (ws) { try { ws.close() } catch (e) { /* ignore */ } }
|
} else if (bar.timestamp > last.timestamp) {
|
||||||
reconnectWS()
|
|
||||||
}, interval)
|
|
||||||
}
|
|
||||||
|
|
||||||
function stopHeartbeat() {
|
|
||||||
if (heartbeatTimer) clearTimeout(heartbeatTimer)
|
|
||||||
heartbeatTimer = null
|
|
||||||
}
|
|
||||||
|
|
||||||
function touchHeartbeat() {
|
|
||||||
lastWsMsgTime = Date.now()
|
|
||||||
var tf
|
|
||||||
try { tf = resToTf(chart.resolution()) } catch (e) { tf = tfSelect.value }
|
|
||||||
startHeartbeat(tf)
|
|
||||||
}
|
|
||||||
|
|
||||||
function wsConnect() {
|
|
||||||
if (ws && (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING)) return
|
|
||||||
|
|
||||||
try { ws = new WebSocket(WS_URL) }
|
|
||||||
catch (e) { return }
|
|
||||||
|
|
||||||
ws.onopen = function () {
|
|
||||||
console.log('[WS] 已连接')
|
|
||||||
setStatus('ok')
|
|
||||||
var sym, tf
|
|
||||||
try { sym = chart.symbol() } catch (e) { sym = symbolInput.value.trim() }
|
|
||||||
try { tf = resToTf(chart.resolution()) } catch (e) { tf = tfSelect.value }
|
|
||||||
var subMsg = JSON.stringify({ action: 'subscribe', symbol: sym, timeframe: tf })
|
|
||||||
console.log('[WS] 发送订阅', subMsg)
|
|
||||||
ws.send(subMsg)
|
|
||||||
touchHeartbeat()
|
|
||||||
}
|
|
||||||
|
|
||||||
ws.onmessage = function (evt) {
|
|
||||||
touchHeartbeat()
|
|
||||||
try {
|
|
||||||
var msg = JSON.parse(evt.data)
|
|
||||||
var bars = msg.data || msg.bars
|
|
||||||
if (msg.type === 'subscribed') {
|
|
||||||
console.log('[WS] 已订阅', msg.symbol, msg.timeframe)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (msg.type === 'snapshot' && bars) {
|
|
||||||
console.log('[WS] 收到快照', bars.length, '根 bar')
|
|
||||||
// 仅在 REST 数据尚未到达且没有进行中的请求时才用快照初始化
|
|
||||||
if (cachedOhlcvBars.length === 0 && !fetchPromise) {
|
|
||||||
for (var i = 0; i < bars.length; i++) {
|
|
||||||
var b = bars[i]
|
|
||||||
cachedOhlcvBars.push({
|
|
||||||
timestamp: b.timestamp, datetime: b.datetime || new Date(b.timestamp).toISOString(),
|
|
||||||
open: b.open, high: b.high, low: b.low, close: b.close, volume: b.volume || 0,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
var dpS, tF
|
|
||||||
try { dpS = chart.symbol() } catch (e) { dpS = symbolInput.value.trim() }
|
|
||||||
try { tF = resToTf(chart.resolution()) } catch (e) { tF = tfSelect.value }
|
|
||||||
doChanCompute(cachedOhlcvBars, dpS, tF)
|
|
||||||
}
|
|
||||||
// 回补缺失数据:检查快照与缓存之间的缺口
|
|
||||||
scheduleBackfill(bars)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if ((msg.type === 'kline' || msg.type === 'candles') && bars && bars.length > 0) {
|
|
||||||
var curSym, curTf
|
|
||||||
try { curSym = chart.symbol() } catch (e) { curSym = symbolInput.value.trim() }
|
|
||||||
try { curTf = resToTf(chart.resolution()) } catch (e) { curTf = tfSelect.value }
|
|
||||||
if (msg.symbol !== curSym || msg.timeframe !== curTf) return
|
|
||||||
|
|
||||||
for (var i = 0; i < bars.length; i++) {
|
|
||||||
var bar = bars[i]
|
|
||||||
var exists = false
|
|
||||||
for (var j = cachedOhlcvBars.length - 1; j >= Math.max(0, cachedOhlcvBars.length - 100); j--) {
|
|
||||||
if (cachedOhlcvBars[j].timestamp === bar.timestamp) { exists = true; break }
|
|
||||||
}
|
|
||||||
if (exists) continue
|
|
||||||
// 也检查 pendingBars 去重
|
|
||||||
var inPending = false
|
|
||||||
for (var k = 0; k < pendingBars.length; k++) {
|
|
||||||
if (pendingBars[k].timestamp === bar.timestamp) { inPending = true; break }
|
|
||||||
}
|
|
||||||
if (inPending) continue
|
|
||||||
|
|
||||||
pendingBars.push({
|
|
||||||
timestamp: bar.timestamp, datetime: bar.datetime || new Date(bar.timestamp).toISOString(),
|
|
||||||
open: bar.open, high: bar.high, low: bar.low, close: bar.close, volume: bar.volume || 0,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
if (computeTimeout) clearTimeout(computeTimeout)
|
|
||||||
computeTimeout = setTimeout(flushPendingBars, 100)
|
|
||||||
}
|
|
||||||
} catch (e) { /* ignore */ }
|
|
||||||
}
|
|
||||||
|
|
||||||
ws.onclose = function () {
|
|
||||||
ws = null
|
|
||||||
// 断线前立即处理待处理数据
|
|
||||||
if (pendingBars.length > 0) {
|
|
||||||
flushPendingBars()
|
|
||||||
}
|
|
||||||
scheduleReconnect()
|
|
||||||
}
|
|
||||||
|
|
||||||
ws.onerror = function () { /* onclose fires next */ }
|
|
||||||
}
|
|
||||||
|
|
||||||
function scheduleReconnect() {
|
|
||||||
if (reconnectTimer) clearTimeout(reconnectTimer)
|
|
||||||
// 自适应重连延迟:短周期用短延迟
|
|
||||||
var tf, delay = 3000
|
|
||||||
try { tf = resToTf(chart.resolution()) } catch (e) { tf = tfSelect.value }
|
|
||||||
if (tf === '1m' || tf === '3m') delay = 1000
|
|
||||||
else if (tf === '5m' || tf === '15m') delay = 2000
|
|
||||||
reconnectTimer = setTimeout(reconnectWS, delay)
|
|
||||||
}
|
|
||||||
|
|
||||||
function reconnectWS() {
|
|
||||||
reconnectTimer = null
|
|
||||||
stopHeartbeat()
|
|
||||||
if (ws) { try { ws.close() } catch (e) { /* ignore */ } ws = null }
|
|
||||||
wsConnect()
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- 回补缺失数据 ----
|
|
||||||
function scheduleBackfill(snapshotBars) {
|
|
||||||
if (backfillTimer) clearTimeout(backfillTimer)
|
|
||||||
backfillTimer = setTimeout(function () { backfillGaps(snapshotBars) }, 500)
|
|
||||||
}
|
|
||||||
|
|
||||||
function backfillGaps(snapshotBars) {
|
|
||||||
if (cachedOhlcvBars.length === 0) return
|
|
||||||
|
|
||||||
// 按时间排序缓存
|
|
||||||
cachedOhlcvBars.sort(function (a, b) { return a.timestamp - b.timestamp })
|
|
||||||
var lastTs = cachedOhlcvBars[cachedOhlcvBars.length - 1].timestamp
|
|
||||||
|
|
||||||
var tf
|
|
||||||
try { tf = resToTf(chart.resolution()) } catch (e) { tf = tfSelect.value }
|
|
||||||
var periodMs = getTfMs(tf)
|
|
||||||
|
|
||||||
// 检查最后一根 bar 到当前时间的缺口
|
|
||||||
var now = Date.now()
|
|
||||||
var expectedBars = Math.floor((now - lastTs) / periodMs) - 1
|
|
||||||
if (expectedBars <= 1) return // 缺口 <= 1 根无需回补
|
|
||||||
|
|
||||||
console.log('[WS] 检测到数据缺口:', expectedBars, '根 bar, 回补中...')
|
|
||||||
|
|
||||||
// 从快照中提取缺失的 bar
|
|
||||||
if (snapshotBars && snapshotBars.length > 0) {
|
|
||||||
var filled = 0
|
|
||||||
for (var i = 0; i < snapshotBars.length; i++) {
|
|
||||||
var b = snapshotBars[i]
|
|
||||||
if (b.timestamp <= lastTs) continue
|
|
||||||
// 去重
|
|
||||||
var dup = false
|
|
||||||
for (var j = cachedOhlcvBars.length - 1; j >= Math.max(0, cachedOhlcvBars.length - 200); j--) {
|
|
||||||
if (cachedOhlcvBars[j].timestamp === b.timestamp) { dup = true; break }
|
|
||||||
}
|
|
||||||
if (dup) continue
|
|
||||||
cachedOhlcvBars.push({
|
|
||||||
timestamp: b.timestamp, datetime: b.datetime || new Date(b.timestamp).toISOString(),
|
|
||||||
open: b.open, high: b.high, low: b.low, close: b.close, volume: b.volume || 0,
|
|
||||||
})
|
|
||||||
filled++
|
|
||||||
}
|
|
||||||
if (filled > 0) {
|
|
||||||
console.log('[WS] 从快照回补', filled, '根 bar')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 如果快照不够,从 REST API 拉取
|
|
||||||
if (expectedBars > 10) {
|
|
||||||
console.log('[WS] 缺口较大,从 REST 回补...')
|
|
||||||
var dpSymbol
|
|
||||||
try { dpSymbol = chart.symbol() } catch (e) { dpSymbol = symbolInput.value.trim() }
|
|
||||||
var start = lastTs + periodMs
|
|
||||||
var url = DATA_HOST + '/api/candles?symbol=' + encodeURIComponent(dpSymbol) +
|
|
||||||
'&tf=' + encodeURIComponent(tf) + '&start=' + start + '&end=' + now + '&limit=500'
|
|
||||||
fetch(url).then(function (r) { return r.json() }).then(function (data) {
|
|
||||||
if (!Array.isArray(data)) return
|
|
||||||
var added = 0
|
|
||||||
for (var i = 0; i < data.length; i++) {
|
|
||||||
var d = data[i]
|
|
||||||
if (d.timestamp <= lastTs) continue
|
|
||||||
var dup = false
|
|
||||||
for (var j = cachedOhlcvBars.length - 1; j >= Math.max(0, cachedOhlcvBars.length - 200); j--) {
|
|
||||||
if (cachedOhlcvBars[j].timestamp === d.timestamp) { dup = true; break }
|
|
||||||
}
|
|
||||||
if (dup) continue
|
|
||||||
cachedOhlcvBars.push({
|
cachedOhlcvBars.push({
|
||||||
timestamp: d.timestamp, datetime: d.datetime || new Date(d.timestamp).toISOString(),
|
timestamp: bar.timestamp,
|
||||||
open: d.open, high: d.high, low: d.low, close: d.close, volume: d.volume || 0,
|
datetime: bar.datetime || new Date(bar.timestamp).toISOString(),
|
||||||
|
open: bar.open, high: bar.high, low: bar.low, close: bar.close,
|
||||||
|
volume: bar.volume || 0,
|
||||||
})
|
})
|
||||||
added++
|
if (cachedOhlcvBars.length > 5000) {
|
||||||
|
cachedOhlcvBars = cachedOhlcvBars.slice(cachedOhlcvBars.length - 5000)
|
||||||
|
}
|
||||||
|
closed = true
|
||||||
}
|
}
|
||||||
if (added > 0) {
|
}
|
||||||
cachedOhlcvBars.sort(function (a, b) { return a.timestamp - b.timestamp })
|
|
||||||
console.log('[WS] REST 回补', added, '根 bar')
|
// 仅在上一根走完时重算缠论(K/MACD 已由 TV 自带更新)
|
||||||
doChanCompute(cachedOhlcvBars, dpSymbol, tf)
|
if (closed || (lastTs != null && bar.timestamp > lastTs)) {
|
||||||
}
|
if (chanDebounce) clearTimeout(chanDebounce)
|
||||||
}).catch(function (err) { console.warn('[WS] REST 回补失败', err.message) })
|
chanDebounce = setTimeout(function () {
|
||||||
} else if (expectedBars > 1) {
|
chanDebounce = null
|
||||||
// 小缺口直接触发重算
|
if (cachedOhlcvBars && cachedOhlcvBars.length) {
|
||||||
cachedOhlcvBars.sort(function (a, b) { return a.timestamp - b.timestamp })
|
doChanCompute(cachedOhlcvBars, curSym, curTf)
|
||||||
var s, t
|
}
|
||||||
try { s = chart.symbol() } catch (e) { s = symbolInput.value.trim() }
|
}, 400)
|
||||||
try { t = resToTf(chart.resolution()) } catch (e) { t = tfSelect.value }
|
}
|
||||||
doChanCompute(cachedOhlcvBars, s, t)
|
lastTs = bar.timestamp
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function flushPendingBars() {
|
|
||||||
computeTimeout = null
|
|
||||||
if (pendingBars.length === 0) return
|
|
||||||
if (cachedOhlcvBars.length === 0) {
|
|
||||||
pendingBars = []
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('[WS] 增量更新', pendingBars.length, '根新 bar')
|
|
||||||
|
|
||||||
for (var i = 0; i < pendingBars.length; i++) {
|
|
||||||
cachedOhlcvBars.push(pendingBars[i])
|
|
||||||
}
|
|
||||||
pendingBars = []
|
|
||||||
|
|
||||||
// 去重并排序
|
|
||||||
var seen = {}
|
|
||||||
var deduped = []
|
|
||||||
for (var i = 0; i < cachedOhlcvBars.length; i++) {
|
|
||||||
var ts = cachedOhlcvBars[i].timestamp
|
|
||||||
if (!seen[ts]) { seen[ts] = true; deduped.push(cachedOhlcvBars[i]) }
|
|
||||||
}
|
|
||||||
deduped.sort(function (a, b) { return a.timestamp - b.timestamp })
|
|
||||||
cachedOhlcvBars = deduped
|
|
||||||
|
|
||||||
// 限制缓存大小(保留最近数据)
|
|
||||||
var maxBars = 5000
|
|
||||||
if (cachedOhlcvBars.length > maxBars) {
|
|
||||||
cachedOhlcvBars = cachedOhlcvBars.slice(cachedOhlcvBars.length - maxBars)
|
|
||||||
}
|
|
||||||
|
|
||||||
var dpSymbol, tf
|
|
||||||
try { dpSymbol = chart.symbol() } catch (e) { dpSymbol = null }
|
|
||||||
try { tf = resToTf(chart.resolution()) } catch (e) { tf = null }
|
|
||||||
if (!dpSymbol || !tf) return
|
|
||||||
|
|
||||||
doChanCompute(cachedOhlcvBars, dpSymbol, tf)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 延迟连接
|
|
||||||
setTimeout(wsConnect, 2000)
|
|
||||||
|
|
||||||
// 暴露给 initTvWidget
|
|
||||||
window._resubscribeChanWS = function () {
|
|
||||||
stopHeartbeat()
|
|
||||||
if (ws) { try { ws.close() } catch (e) { /* ignore */ } ws = null }
|
|
||||||
if (reconnectTimer) clearTimeout(reconnectTimer)
|
|
||||||
reconnectTimer = setTimeout(reconnectWS, 500)
|
|
||||||
}
|
|
||||||
})()
|
})()
|
||||||
})()
|
})()
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1264,11 +1264,11 @@
|
|||||||
<script defer src="{{ url_for('static', filename='js/app/trend.js') }}"></script>
|
<script defer src="{{ url_for('static', filename='js/app/trend.js') }}"></script>
|
||||||
<script defer src="{{ url_for('static', filename='js/app/macd_ui.js') }}"></script>
|
<script defer src="{{ url_for('static', filename='js/app/macd_ui.js') }}"></script>
|
||||||
<script defer src="{{ url_for('static', filename='js/app/chart_format.js') }}"></script>
|
<script defer src="{{ url_for('static', filename='js/app/chart_format.js') }}"></script>
|
||||||
<script defer src="{{ url_for('static', filename='js/app/chart_view.js') }}"></script>
|
<script defer src="{{ url_for('static', filename='js/app/chart_view.js') }}?v=20260806a"></script>
|
||||||
<script defer src="{{ url_for('static', filename='js/app/chart_tv.js') }}"></script>
|
<script defer src="{{ url_for('static', filename='js/app/chart_tv.js') }}?v=20260806a"></script>
|
||||||
<script defer src="{{ url_for('static', filename='js/app/chart_sync.js') }}"></script>
|
<script defer src="{{ url_for('static', filename='js/app/chart_sync.js') }}?v=20260806a"></script>
|
||||||
<script defer src="{{ url_for('static', filename='js/app/chart_tables.js') }}"></script>
|
<script defer src="{{ url_for('static', filename='js/app/chart_tables.js') }}"></script>
|
||||||
<script defer src="{{ url_for('static', filename='js/app/ui.js') }}"></script>
|
<script defer src="{{ url_for('static', filename='js/app/ui.js') }}?v=20260806a"></script>
|
||||||
<script defer src="{{ url_for('static', filename='js/app/overlays.js') }}"></script>
|
<script defer src="{{ url_for('static', filename='js/app/overlays.js') }}"></script>
|
||||||
<script defer src="{{ url_for('static', filename='js/app/main.js') }}"></script>
|
<script defer src="{{ url_for('static', filename='js/app/main.js') }}"></script>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user