feat(ECR-007): Wyckoff Live Structure with Confirmed/Live isolation
Add live.py lifecycle and event candidates; assemble confirmed vs live in engine; Summary partition; execution_signal source=confirmed only. Keep strategies untouched; do not lower Confirmed thresholds for Live. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+124
-25
@@ -5,6 +5,97 @@ from services import runtime as R
|
||||
|
||||
bp = Blueprint("analyze", __name__)
|
||||
|
||||
_WYCKOFF_EMPTY = {
|
||||
'trading_range': None,
|
||||
'bias': 'unknown',
|
||||
'phases': [],
|
||||
'events': [],
|
||||
'volume_profile': {'bins': [], 'poc': None, 'vah': None, 'val': None, 'bin_count': 0},
|
||||
'volume_confirm': {'avg_volume': 0.0, 'event_checks': {}},
|
||||
'cycles': [],
|
||||
'live': None,
|
||||
'lifecycle': 'UNKNOWN',
|
||||
}
|
||||
|
||||
|
||||
def _localize_wyckoff_payload(w, client_tz):
|
||||
"""把威科夫时间统一成客户端时区 ISO,便于与主图对齐。"""
|
||||
if not w:
|
||||
return w
|
||||
|
||||
def _loc_tr(tr):
|
||||
if not tr:
|
||||
return
|
||||
tr['start_time'] = format_time_safely(tr.get('start_time'), client_tz) or tr.get('start_time')
|
||||
tr['end_time'] = format_time_safely(tr.get('end_time'), client_tz) or tr.get('end_time')
|
||||
|
||||
def _loc_cycle(c):
|
||||
if not c:
|
||||
return
|
||||
per = c.get('period') or {}
|
||||
per['start_time'] = format_time_safely(per.get('start_time'), client_tz) or per.get('start_time')
|
||||
per['end_time'] = format_time_safely(per.get('end_time'), client_tz) or per.get('end_time')
|
||||
c['period'] = per
|
||||
_loc_tr(c.get('trading_range'))
|
||||
for ph in c.get('phases') or []:
|
||||
ph['start_time'] = format_time_safely(ph.get('start_time'), client_tz) or ph.get('start_time')
|
||||
ph['end_time'] = format_time_safely(ph.get('end_time'), client_tz) or ph.get('end_time')
|
||||
for ev in c.get('events') or []:
|
||||
ev['time'] = format_time_safely(ev.get('time'), client_tz) or ev.get('time')
|
||||
|
||||
_loc_tr(w.get('trading_range'))
|
||||
for ph in w.get('phases') or []:
|
||||
ph['start_time'] = format_time_safely(ph.get('start_time'), client_tz) or ph.get('start_time')
|
||||
ph['end_time'] = format_time_safely(ph.get('end_time'), client_tz) or ph.get('end_time')
|
||||
for ev in w.get('events') or []:
|
||||
ev['time'] = format_time_safely(ev.get('time'), client_tz) or ev.get('time')
|
||||
for c in w.get('cycles') or []:
|
||||
_loc_cycle(c)
|
||||
return w
|
||||
|
||||
|
||||
def _compute_wyckoff_from_df(df, tf, vp_bins, client_tz=None, range_start_time=None, prefer_start_time=None):
|
||||
"""直接用该周期已有 DataFrame(与缠论同一份)。
|
||||
搜索窗口 = 整段数据;箱体在窗内评分选取(近优分取更长),
|
||||
次/次次可用 prefer_start_time 对齐主箱起点。
|
||||
"""
|
||||
from chanlun.analysis.wyckoff import analyze_wyckoff
|
||||
|
||||
try:
|
||||
if df is None or len(df) < 30:
|
||||
empty = dict(_WYCKOFF_EMPTY)
|
||||
empty['volume_profile'] = dict(_WYCKOFF_EMPTY['volume_profile'])
|
||||
empty['volume_confirm'] = dict(_WYCKOFF_EMPTY['volume_confirm'])
|
||||
empty['timeframe'] = tf
|
||||
return empty
|
||||
lookback = len(df)
|
||||
min_bars = max(24, min(80, lookback // 12))
|
||||
out = analyze_wyckoff(
|
||||
df,
|
||||
lookback=lookback,
|
||||
vp_bins=vp_bins,
|
||||
min_bars=min_bars,
|
||||
range_start_time=range_start_time,
|
||||
prefer_start_time=prefer_start_time,
|
||||
)
|
||||
out['timeframe'] = tf
|
||||
out['lookback'] = lookback
|
||||
out['min_bars'] = min_bars
|
||||
if client_tz is not None:
|
||||
_localize_wyckoff_payload(out, client_tz)
|
||||
return out
|
||||
except Exception as e:
|
||||
print(f"Wyckoff 分析出错 ({tf}): {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
empty = dict(_WYCKOFF_EMPTY)
|
||||
empty['volume_profile'] = dict(_WYCKOFF_EMPTY['volume_profile'])
|
||||
empty['volume_confirm'] = dict(_WYCKOFF_EMPTY['volume_confirm'])
|
||||
empty['timeframe'] = tf
|
||||
empty['error'] = str(e)
|
||||
return empty
|
||||
|
||||
|
||||
@bp.route('/api/analyze')
|
||||
def analyze():
|
||||
"""分析接口"""
|
||||
@@ -25,6 +116,9 @@ def analyze():
|
||||
# 获取分形元素时间周期与次次周期
|
||||
element_timeframe = request.args.get('element_timeframe')
|
||||
sub_sub_timeframe = request.args.get('sub_sub_timeframe')
|
||||
# 供文末三周期威科夫复用(避免重复拉数)
|
||||
element_df_for_wyckoff = None
|
||||
sub_sub_df_for_wyckoff = None
|
||||
|
||||
# 获取是否只需要分形元素数据的参数
|
||||
elements_only_param = request.args.get('elements_only')
|
||||
@@ -249,6 +343,7 @@ def analyze():
|
||||
if element_df is not None and len(element_df) > 0:
|
||||
# 添加小周期技术指标(包括布林带)
|
||||
element_df = add_indicators(element_df)
|
||||
element_df_for_wyckoff = element_df
|
||||
|
||||
# 对小周期数据进行缠论分析
|
||||
element_analysis = analyze_chan(element_df, symbol, element_timeframe)
|
||||
@@ -427,6 +522,7 @@ def analyze():
|
||||
sub_sub_df = get_kl_data(symbol, sub_sub_timeframe, start_time=start_time, end_time=end_time)
|
||||
if sub_sub_df is not None and len(sub_sub_df) > 0:
|
||||
sub_sub_df = add_indicators(sub_sub_df)
|
||||
sub_sub_df_for_wyckoff = sub_sub_df
|
||||
sub_sub_analysis = analyze_chan(sub_sub_df, symbol, sub_sub_timeframe)
|
||||
result['sub_sub_timeframe'] = sub_sub_timeframe
|
||||
result['sub_sub_kline_data'] = clean_dataframe_for_json(sub_sub_df).to_dict('records')
|
||||
@@ -656,33 +752,36 @@ def analyze():
|
||||
else:
|
||||
result['structure_zones'] = []
|
||||
|
||||
# 威科夫分析 —— 按需:include_wyckoff=1,且须有主周期分析(非 elements_only)
|
||||
include_wyckoff_param = request.args.get('include_wyckoff', '')
|
||||
include_wyckoff = str(include_wyckoff_param).lower() in ('1', 'true', 'yes')
|
||||
# 威科夫:主 / 次 / 次次各算一份(非 elements_only);前端开关只控制绘制
|
||||
# include_wyckoff=0 可显式跳过;缺省与其它真值均计算
|
||||
include_wyckoff_param = request.args.get('include_wyckoff', '1')
|
||||
include_wyckoff = str(include_wyckoff_param).lower() not in ('0', 'false', 'no')
|
||||
if include_wyckoff and not elements_only:
|
||||
try:
|
||||
from chanlun.analysis.wyckoff import analyze_wyckoff
|
||||
wyckoff_lookback = int(request.args.get('wyckoff_lookback', 120))
|
||||
# ECR-004:默认/上限 24 bins(A+C)
|
||||
wyckoff_bins = int(request.args.get('wyckoff_vp_bins', 24))
|
||||
result['wyckoff'] = analyze_wyckoff(
|
||||
df,
|
||||
lookback=max(40, min(wyckoff_lookback, 500)),
|
||||
vp_bins=max(10, min(wyckoff_bins, 24)),
|
||||
# 主周期先算;次/次次只同步 active=cycles[0] 的 start(WYCKOFF-MULTI-CYCLE-001)
|
||||
wyckoff_bins = max(10, min(int(request.args.get('wyckoff_vp_bins', 24)), 24))
|
||||
result['wyckoff'] = _compute_wyckoff_from_df(df, timeframe, wyckoff_bins, client_tz=None)
|
||||
main_w = result.get('wyckoff') or {}
|
||||
cycles = main_w.get('cycles') or []
|
||||
# active 唯一来源 cycles[0];禁止 cycles[-1]
|
||||
active = cycles[0] if cycles else None
|
||||
prefer_start = None
|
||||
if active:
|
||||
prefer_start = ((active.get('trading_range') or {}).get('start_time')
|
||||
or (active.get('period') or {}).get('start_time'))
|
||||
elif main_w.get('trading_range'):
|
||||
prefer_start = main_w['trading_range'].get('start_time')
|
||||
if client_tz is not None:
|
||||
_localize_wyckoff_payload(result['wyckoff'], client_tz)
|
||||
if element_timeframe:
|
||||
result['element_wyckoff'] = _compute_wyckoff_from_df(
|
||||
element_df_for_wyckoff, element_timeframe, wyckoff_bins, client_tz,
|
||||
prefer_start_time=prefer_start,
|
||||
)
|
||||
if sub_sub_timeframe:
|
||||
result['sub_sub_wyckoff'] = _compute_wyckoff_from_df(
|
||||
sub_sub_df_for_wyckoff, sub_sub_timeframe, wyckoff_bins, client_tz,
|
||||
prefer_start_time=prefer_start,
|
||||
)
|
||||
except Exception as e:
|
||||
print(f"Wyckoff 分析出错: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
result['wyckoff'] = {
|
||||
'trading_range': None,
|
||||
'bias': 'unknown',
|
||||
'phases': [],
|
||||
'events': [],
|
||||
'volume_profile': {'bins': [], 'poc': None, 'vah': None, 'val': None, 'bin_count': 0},
|
||||
'volume_confirm': {'avg_volume': 0.0, 'event_checks': {}},
|
||||
'error': str(e),
|
||||
}
|
||||
|
||||
return jsonify(result)
|
||||
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
"""页面路由。"""
|
||||
from flask import Blueprint, render_template, send_from_directory
|
||||
from flask import Blueprint, jsonify, render_template, request, send_from_directory
|
||||
from config import DATA_SERVICE_URL, DATA_SERVICE_WS_URL
|
||||
from services.runtime import * # noqa: F403
|
||||
from services import runtime as R
|
||||
|
||||
@@ -70,36 +70,43 @@ def build_timeframe_labels(timeframes):
|
||||
return labels
|
||||
|
||||
|
||||
def _adjacent_smaller(timeframe_keys, ceiling_tf):
|
||||
"""取排序列表中严格小于 ceiling 的相邻周期。"""
|
||||
if not timeframe_keys:
|
||||
return ceiling_tf
|
||||
try:
|
||||
idx = timeframe_keys.index(ceiling_tf)
|
||||
return timeframe_keys[idx - 1] if idx > 0 else timeframe_keys[0]
|
||||
except ValueError:
|
||||
return timeframe_keys[0]
|
||||
|
||||
|
||||
def _prefer_smaller(candidates, labels_ordered, ceiling_tf, timeframe_keys):
|
||||
"""从候选中选第一个存在且严格小于 ceiling 的周期,否则回退相邻更小。"""
|
||||
ceil_m = timeframe_to_minutes(ceiling_tf)
|
||||
for tf in candidates:
|
||||
m = timeframe_to_minutes(tf)
|
||||
if tf in labels_ordered and m is not None and ceil_m is not None and m < ceil_m:
|
||||
return tf
|
||||
return _adjacent_smaller(timeframe_keys, ceiling_tf)
|
||||
|
||||
|
||||
def compute_timeframe_defaults(labels_ordered):
|
||||
"""
|
||||
根据已排序的「周期 → 中文标签」映射,计算主 / 次 / 次次周期默认值。
|
||||
默认偏好:主 4h、次 2h、次次 1h(威科夫与结构在小时级更可读)。
|
||||
labels_ordered: OrderedDict 或按插入顺序排列的 dict。
|
||||
"""
|
||||
if not labels_ordered:
|
||||
labels_ordered = DEFAULT_TIMEFRAME_LABELS.copy()
|
||||
timeframe_keys = list(labels_ordered.keys())
|
||||
preferred_main = next((tf for tf in ['5m', '15m', '1h'] if tf in labels_ordered), None)
|
||||
preferred_main = next((tf for tf in ['4h', '2h', '1h'] if tf in labels_ordered), None)
|
||||
default_main = preferred_main or (timeframe_keys[0] if timeframe_keys else '1m')
|
||||
if default_main not in labels_ordered and timeframe_keys:
|
||||
default_main = timeframe_keys[0]
|
||||
|
||||
if timeframe_keys:
|
||||
try:
|
||||
idx = timeframe_keys.index(default_main)
|
||||
default_element = timeframe_keys[idx - 1] if idx > 0 else timeframe_keys[0]
|
||||
except ValueError:
|
||||
default_element = timeframe_keys[0]
|
||||
else:
|
||||
default_element = default_main
|
||||
|
||||
if timeframe_keys:
|
||||
try:
|
||||
idx_el = timeframe_keys.index(default_element)
|
||||
default_sub_sub = timeframe_keys[idx_el - 1] if idx_el > 0 else timeframe_keys[0]
|
||||
except ValueError:
|
||||
default_sub_sub = timeframe_keys[0]
|
||||
else:
|
||||
default_sub_sub = default_element
|
||||
default_element = _prefer_smaller(['2h', '1h'], labels_ordered, default_main, timeframe_keys)
|
||||
default_sub_sub = _prefer_smaller(['1h'], labels_ordered, default_element, timeframe_keys)
|
||||
|
||||
return default_main, default_element, default_sub_sub, timeframe_keys
|
||||
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
/* chart_format.js — split from chart.js */
|
||||
/* chart.js */
|
||||
function updateChartDisplay() {
|
||||
if (typeof renderWyckoffCycleSummary === 'function') {
|
||||
renderWyckoffCycleSummary();
|
||||
}
|
||||
if (currentData) {
|
||||
// 检测K线周期是否切换
|
||||
const curPeriod = $('#subSubPeriodKline').is(':checked') ? 'subsub' :
|
||||
|
||||
+244
-166
@@ -40,6 +40,12 @@ function disposeTradingViewCharts() {
|
||||
|
||||
var chartRoot = document.getElementById('tradingview_chart');
|
||||
if (chartRoot) {
|
||||
// 重建前救出 Cycle Summary,避免 innerHTML 清空时被销毁
|
||||
var summaryEl = document.getElementById('wyckoffCycleSummary');
|
||||
var chartHost = chartRoot.parentElement;
|
||||
if (summaryEl && chartRoot.contains(summaryEl) && chartHost) {
|
||||
chartHost.appendChild(summaryEl);
|
||||
}
|
||||
chartRoot.innerHTML = '';
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -415,6 +421,21 @@ function initTradingView(symbol, timeframe) {
|
||||
|
||||
// 创建主图表
|
||||
const mainChart = LightweightCharts.createChart(mainChartContainer, createChartOptions(true, 'main'));
|
||||
|
||||
// Cycle Summary 挂到主图左下角(相对 K 线主图 pane,而非整图底边)
|
||||
(function mountWyckoffCycleSummary() {
|
||||
var summaryEl = document.getElementById('wyckoffCycleSummary');
|
||||
if (!summaryEl) {
|
||||
summaryEl = document.createElement('div');
|
||||
summaryEl.id = 'wyckoffCycleSummary';
|
||||
summaryEl.className = 'wyckoff-cycle-summary';
|
||||
summaryEl.setAttribute('aria-live', 'polite');
|
||||
}
|
||||
mainChartContainer.appendChild(summaryEl);
|
||||
if (typeof renderWyckoffCycleSummary === 'function') {
|
||||
try { renderWyckoffCycleSummary(); } catch (e) {}
|
||||
}
|
||||
})();
|
||||
|
||||
// 创建成交量图表 - 只显示底部的时间轴
|
||||
const volumeChart = LightweightCharts.createChart(volumeChartContainer, createChartOptions(false, 'volume'));
|
||||
@@ -2199,175 +2220,229 @@ function initTradingView(symbol, timeframe) {
|
||||
}
|
||||
} catch (e) { console.error('结构区整体绘制出错:', e); }
|
||||
}
|
||||
// 威科夫叠层:区间 / 阶段 / 事件 / VP
|
||||
if ($('#showWyckoff').is(':checked') && currentData.wyckoff) {
|
||||
try {
|
||||
const w = currentData.wyckoff;
|
||||
const tr = w.trading_range;
|
||||
const parseTs = function(t) {
|
||||
if (t == null) return NaN;
|
||||
if (typeof t === 'number') return Math.floor(t > 1e12 ? t / 1000 : t);
|
||||
const ms = new Date(t).getTime();
|
||||
return isNaN(ms) ? NaN : Math.floor(ms / 1000);
|
||||
};
|
||||
const kd = currentData.kline_data || [];
|
||||
const chartEnd = kd.length
|
||||
? Math.floor(new Date(kd[kd.length - 1].date).getTime() / 1000)
|
||||
: NaN;
|
||||
// 区间/阶段/时间/VP:框线同中枢;标记并入主 K(同 BSP),时间对齐 candles
|
||||
(function drawWrLikeChan() {
|
||||
const candleSeries = tvWidget.series.candleSeries
|
||||
|| tvWidget.series.barSeries
|
||||
|| tvWidget.series.lineSeries
|
||||
|| tvWidget.series.areaSeries
|
||||
|| tvWidget.series.heikinSeries
|
||||
|| tvWidget.series.renkoSeries;
|
||||
const candleTimes = (candles || []).map(function(c) { return c.time; })
|
||||
.filter(function(t) { return t != null && !isNaN(t); });
|
||||
const barStep = (candleTimes.length >= 2)
|
||||
? Math.max(1, candleTimes[1] - candleTimes[0])
|
||||
: 3600;
|
||||
const lastKlineTime = candleTimes.length ? candleTimes[candleTimes.length - 1] : NaN;
|
||||
const chartStart = candleTimes.length ? candleTimes[0] : NaN;
|
||||
|
||||
if ($('#showWyckoffRange').is(':checked') && tr) {
|
||||
const t0 = parseTs(tr.start_time);
|
||||
const t1 = tr.end_time ? parseTs(tr.end_time) : chartEnd;
|
||||
const hi = parseFloat(tr.high), lo = parseFloat(tr.low), mid = parseFloat(tr.mid);
|
||||
if (!isNaN(t0) && !isNaN(t1) && !isNaN(hi) && !isNaN(lo)) {
|
||||
const fill = 'rgba(52, 152, 219, 0.07)';
|
||||
const border = 'rgba(52, 152, 219, 0.75)';
|
||||
// ECR-004:填充线 6→3,减 series
|
||||
const fillLines = 3;
|
||||
const step = (hi - lo) / (fillLines + 1);
|
||||
for (let fi = 1; fi <= fillLines; fi++) {
|
||||
const fy = lo + step * fi;
|
||||
mainChart.addLineSeries({ color: fill, lineWidth: 2, lastValueVisible: false, priceLineVisible: false })
|
||||
.setData([{ time: t0, value: fy }, { time: t1, value: fy }]);
|
||||
}
|
||||
mainChart.addLineSeries({ color: border, lineWidth: 2, lastValueVisible: false, priceLineVisible: false })
|
||||
.setData([{ time: t0, value: hi }, { time: t1, value: hi }]);
|
||||
mainChart.addLineSeries({ color: border, lineWidth: 2, lastValueVisible: false, priceLineVisible: false })
|
||||
.setData([{ time: t0, value: lo }, { time: t1, value: lo }]);
|
||||
if (!isNaN(mid)) {
|
||||
mainChart.addLineSeries({ color: border, lineWidth: 1, lineStyle: 2, lastValueVisible: false, priceLineVisible: false })
|
||||
.setData([{ time: t0, value: mid }, { time: t1, value: mid }]);
|
||||
}
|
||||
mainChart.addLineSeries({ color: border, lineWidth: 1, lastValueVisible: false, priceLineVisible: false })
|
||||
.setData([{ time: t0, value: lo }, { time: t0, value: hi }]);
|
||||
mainChart.addLineSeries({ color: border, lineWidth: 1, lastValueVisible: false, priceLineVisible: false })
|
||||
.setData([{ time: t1, value: lo }, { time: t1, value: hi }]);
|
||||
}
|
||||
const toSec = function(t) {
|
||||
if (t == null) return NaN;
|
||||
if (typeof t === 'number') return t > 1e12 ? Math.floor(t / 1000) : Math.floor(t);
|
||||
const ms = new Date(t).getTime();
|
||||
return isNaN(ms) ? NaN : Math.floor(ms / 1000);
|
||||
};
|
||||
// 标记必须落在主 series 的 time 上(与 KLC 趋势 nearestTime 同思路)
|
||||
const snapToCandle = function(t) {
|
||||
if (!candleTimes.length || isNaN(t)) return t;
|
||||
let best = candleTimes[0], bd = Math.abs(candleTimes[0] - t);
|
||||
for (let i = 1; i < candleTimes.length; i++) {
|
||||
const d = Math.abs(candleTimes[i] - t);
|
||||
if (d < bd) { bd = d; best = candleTimes[i]; }
|
||||
}
|
||||
|
||||
if ($('#showWyckoffPhases').is(':checked') && w.phases && w.phases.length) {
|
||||
const phaseColors = {
|
||||
A: 'rgba(241, 196, 15, 0.85)',
|
||||
B: 'rgba(155, 89, 182, 0.85)',
|
||||
C: 'rgba(230, 126, 34, 0.85)',
|
||||
D: 'rgba(46, 204, 113, 0.85)',
|
||||
E: 'rgba(52, 152, 219, 0.85)'
|
||||
};
|
||||
const phaseMarkers = [];
|
||||
w.phases.forEach(function(ph) {
|
||||
const t0 = parseTs(ph.start_time);
|
||||
const t1 = ph.end_time ? parseTs(ph.end_time) : chartEnd;
|
||||
if (isNaN(t0) || isNaN(t1) || !tr) return;
|
||||
const hi = parseFloat(tr.high);
|
||||
if (isNaN(hi)) return;
|
||||
const col = phaseColors[ph.phase] || 'rgba(149,165,166,0.85)';
|
||||
// 阶段顶部分段色带(略高于区间高)
|
||||
const y = hi * 1.002;
|
||||
mainChart.addLineSeries({ color: col, lineWidth: 3, lastValueVisible: false, priceLineVisible: false })
|
||||
.setData([{ time: t0, value: y }, { time: t1, value: y }]);
|
||||
phaseMarkers.push({
|
||||
time: t0,
|
||||
position: 'aboveBar',
|
||||
color: col,
|
||||
shape: 'square',
|
||||
text: String(ph.phase || ph.label || ''),
|
||||
size: 1
|
||||
});
|
||||
return best;
|
||||
};
|
||||
const ensureSpan = function(t0, t1) {
|
||||
if (isNaN(t0) || isNaN(t1)) return [t0, t1];
|
||||
if (t1 < t0) { const x = t0; t0 = t1; t1 = x; }
|
||||
if (t1 <= t0) t1 = t0 + barStep;
|
||||
return [t0, t1];
|
||||
};
|
||||
const drawBox = function(t0, t1, hi, lo, color) {
|
||||
const span = ensureSpan(t0, t1);
|
||||
t0 = span[0]; t1 = span[1];
|
||||
if (isNaN(t0) || isNaN(t1) || isNaN(hi) || isNaN(lo)) return;
|
||||
const opt = { color: color, lineWidth: 1, lastValueVisible: false, priceLineVisible: false };
|
||||
mainChart.addLineSeries(opt).setData([{ time: t0, value: hi }, { time: t1, value: hi }]);
|
||||
mainChart.addLineSeries(opt).setData([{ time: t0, value: lo }, { time: t1, value: lo }]);
|
||||
mainChart.addLineSeries(opt).setData([{ time: t0, value: lo }, { time: t0, value: hi }]);
|
||||
mainChart.addLineSeries(opt).setData([{ time: t1, value: lo }, { time: t1, value: hi }]);
|
||||
};
|
||||
const mkPL = function(price, color, title, style) {
|
||||
if (!candleSeries) return;
|
||||
const p = parseFloat(price);
|
||||
if (isNaN(p)) return;
|
||||
try {
|
||||
candleSeries.createPriceLine({
|
||||
price: p, color: color,
|
||||
lineWidth: style === 2 ? 1 : 2,
|
||||
lineStyle: style || 0,
|
||||
axisLabelVisible: true,
|
||||
title: title
|
||||
});
|
||||
if (phaseMarkers.length) {
|
||||
const phSeries = mainChart.addLineSeries({ lastValueVisible: false, priceLineVisible: false });
|
||||
phSeries.setMarkers(phaseMarkers);
|
||||
}
|
||||
}
|
||||
} catch (e) { console.warn('价位线失败', title, e); }
|
||||
};
|
||||
const phaseColors = { A: '#f1c40f', B: '#9b59b6', C: '#e67e22', D: '#2ecc71', E: '#3498db' };
|
||||
const eventColors = {
|
||||
Spring: '#27ae60', SOS: '#2ecc71', LPS: '#16a085',
|
||||
UTAD: '#e74c3c', SOW: '#c0392b', LPSY: '#d35400'
|
||||
};
|
||||
|
||||
if ($('#showWyckoffEvents').is(':checked') && w.events && w.events.length) {
|
||||
const eventColors = {
|
||||
Spring: '#27ae60',
|
||||
SOS: '#2ecc71',
|
||||
LPS: '#16a085',
|
||||
UTAD: '#e74c3c',
|
||||
SOW: '#c0392b',
|
||||
LPSY: '#d35400'
|
||||
};
|
||||
const checks = (w.volume_confirm && w.volume_confirm.event_checks) || {};
|
||||
const markers = [];
|
||||
w.events.forEach(function(ev) {
|
||||
const t = parseTs(ev.time);
|
||||
if (isNaN(t)) return;
|
||||
const typ = ev.type || '';
|
||||
const chk = checks[typ] || {};
|
||||
const volOk = (chk.volume_ok != null) ? chk.volume_ok : ev.volume_ok;
|
||||
const ratioVal = (chk.volume_ratio != null) ? chk.volume_ratio : ev.volume_ratio;
|
||||
const ok = volOk === true ? '✓' : (volOk === false ? '✗' : '');
|
||||
const note = ev.note || '';
|
||||
const ratio = (ratioVal != null) ? (' vol×' + Number(ratioVal).toFixed(2)) : '';
|
||||
markers.push({
|
||||
time: t,
|
||||
position: (typ === 'Spring' || typ === 'LPS' || typ === 'SOW') ? 'belowBar' : 'aboveBar',
|
||||
color: eventColors[typ] || '#7f8c8d',
|
||||
shape: 'arrowUp',
|
||||
text: typ + (ok ? ' ' + ok : '') + (note ? ' ' + note : '') + ratio,
|
||||
size: 1
|
||||
});
|
||||
});
|
||||
if (markers.length) {
|
||||
const evSeries = mainChart.addLineSeries({ lastValueVisible: false, priceLineVisible: false });
|
||||
evSeries.setMarkers(markers);
|
||||
}
|
||||
}
|
||||
const wrMarkers = [];
|
||||
window.wrMarkers = [];
|
||||
const pushMarker = function(m) {
|
||||
if (!m || isNaN(m.time)) return;
|
||||
m.time = snapToCandle(m.time);
|
||||
if (!isNaN(chartStart) && (m.time < chartStart || m.time > lastKlineTime)) return;
|
||||
wrMarkers.push(m);
|
||||
};
|
||||
|
||||
if ($('#showWyckoffVP').is(':checked') && w.volume_profile && tr) {
|
||||
const vp = w.volume_profile;
|
||||
const t1 = tr.end_time ? parseTs(tr.end_time) : chartEnd;
|
||||
if (!isNaN(t1)) {
|
||||
const bins = vp.bins || [];
|
||||
// ECR-004 A+C:只画有量 Top-N,避免每 bin 一条 series
|
||||
const TOP_N = 8;
|
||||
const ranked = bins
|
||||
.filter(function(b) { return b && b.volume > 0; })
|
||||
.slice()
|
||||
.sort(function(a, b) { return b.volume - a.volume; })
|
||||
.slice(0, TOP_N);
|
||||
let maxVol = 0;
|
||||
ranked.forEach(function(b) { if (b.volume > maxVol) maxVol = b.volume; });
|
||||
const tStart = parseTs(tr.start_time);
|
||||
const maxWidthSec = Math.max(60, Math.floor((t1 - (isNaN(tStart) ? t1 : tStart)) * 0.15));
|
||||
ranked.forEach(function(b) {
|
||||
if (!b.volume || maxVol <= 0) return;
|
||||
const wSec = Math.max(1, Math.floor(maxWidthSec * (b.volume / maxVol)));
|
||||
const alpha = 0.2 + 0.55 * (b.volume / maxVol);
|
||||
const leftT = Math.max(isNaN(tStart) ? (t1 - wSec) : tStart, t1 - wSec);
|
||||
mainChart.addLineSeries({
|
||||
color: 'rgba(142, 68, 173, ' + alpha.toFixed(2) + ')',
|
||||
lineWidth: 1,
|
||||
lastValueVisible: false,
|
||||
priceLineVisible: false
|
||||
}).setData([
|
||||
{ time: leftT, value: b.price },
|
||||
{ time: t1, value: b.price }
|
||||
]);
|
||||
});
|
||||
const levels = [
|
||||
{ p: vp.poc, c: 'rgba(142, 68, 173, 0.95)', w: 2, style: 0 },
|
||||
{ p: vp.vah, c: 'rgba(155, 89, 182, 0.7)', w: 1, style: 2 },
|
||||
{ p: vp.val, c: 'rgba(155, 89, 182, 0.7)', w: 1, style: 2 }
|
||||
];
|
||||
const t0 = parseTs(tr.start_time);
|
||||
levels.forEach(function(lv) {
|
||||
const p = parseFloat(lv.p);
|
||||
if (isNaN(p) || isNaN(t0)) return;
|
||||
mainChart.addLineSeries({
|
||||
color: lv.c,
|
||||
lineWidth: lv.w,
|
||||
lineStyle: lv.style,
|
||||
lastValueVisible: false,
|
||||
priceLineVisible: false
|
||||
}).setData([{ time: t0, value: p }, { time: t1, value: p }]);
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (e) { console.error('威科夫绘制出错:', e); }
|
||||
}
|
||||
const drawOne = function(w, cfg) {
|
||||
if (!w) return;
|
||||
const showR = $(cfg.rangeSel).is(':checked');
|
||||
const showP = $(cfg.phasesSel).is(':checked');
|
||||
const showE = $(cfg.eventsSel).is(':checked');
|
||||
const showV = $(cfg.vpSel).is(':checked');
|
||||
if (!showR && !showP && !showE && !showV) return;
|
||||
// WYCKOFF-MULTI-CYCLE-001:遍历 cycles;无则退化为顶层单段
|
||||
const cycles = (w.cycles && w.cycles.length)
|
||||
? w.cycles
|
||||
: (w.trading_range ? [{
|
||||
id: 0, status: 'ACTIVE', role: 'latest',
|
||||
trading_range: w.trading_range, phases: w.phases, events: w.events,
|
||||
volume_profile: w.volume_profile, volume_confirm: w.volume_confirm,
|
||||
period: { start_time: w.trading_range.start_time, end_time: w.trading_range.end_time, bars: w.trading_range.bars }
|
||||
}] : []);
|
||||
const tfLabel = (cfg.tfLabel || cfg.tag || 'TF').toString().toUpperCase();
|
||||
|
||||
cycles.forEach(function(cycle) {
|
||||
const tr = cycle.trading_range;
|
||||
if (!tr) return;
|
||||
const cid = (cycle.id != null) ? cycle.id : 0;
|
||||
const isActive = String(cycle.status || '').toUpperCase() === 'ACTIVE';
|
||||
const cTag = tfLabel + ' C' + cid + ' ';
|
||||
try {
|
||||
let t0 = toSec(tr.start_time || (cycle.period && cycle.period.start_time));
|
||||
let t1 = toSec(tr.end_time || (cycle.period && cycle.period.end_time));
|
||||
// 仅 ACTIVE 可拉到最新 K;历史用 period.end
|
||||
if (isActive && !isNaN(lastKlineTime)) {
|
||||
t1 = lastKlineTime;
|
||||
} else if (cycle.period && cycle.period.end_time) {
|
||||
t1 = toSec(cycle.period.end_time);
|
||||
}
|
||||
const hi = parseFloat(tr.high), lo = parseFloat(tr.low);
|
||||
if (showR && !isNaN(hi) && !isNaN(lo)) {
|
||||
drawBox(t0, t1, hi, lo, cfg.color);
|
||||
if (isActive) {
|
||||
mkPL(hi, cfg.color, cfg.hiTag, 0);
|
||||
mkPL(lo, cfg.color, cfg.loTag, 0);
|
||||
mkPL(tr.mid, cfg.color, cfg.midTag, 2);
|
||||
}
|
||||
}
|
||||
if (showP && cycle.phases && cycle.phases.length && !isNaN(hi)) {
|
||||
cycle.phases.forEach(function(ph) {
|
||||
let p0 = toSec(ph.start_time);
|
||||
let p1 = ph.end_time ? toSec(ph.end_time) : t1;
|
||||
const sp = ensureSpan(p0, p1);
|
||||
p0 = sp[0]; p1 = sp[1];
|
||||
if (isNaN(p0) || isNaN(p1)) return;
|
||||
const col = phaseColors[ph.phase] || '#95a5a6';
|
||||
mainChart.addLineSeries({
|
||||
color: col, lineWidth: 3, lastValueVisible: false, priceLineVisible: false
|
||||
}).setData([{ time: p0, value: hi }, { time: p1, value: hi }]);
|
||||
pushMarker({
|
||||
time: p0, position: 'aboveBar', color: col, shape: 'square',
|
||||
text: cTag + 'Phase ' + String(ph.phase || ''), size: 1
|
||||
});
|
||||
});
|
||||
}
|
||||
if (showV && isActive && cycle.volume_profile) {
|
||||
const vp = cycle.volume_profile;
|
||||
mkPL(vp.poc, cfg.vpColor, cfg.pocTag, 0);
|
||||
mkPL(vp.vah, cfg.vpColor, cfg.vahTag, 2);
|
||||
mkPL(vp.val, cfg.vpColor, cfg.valTag, 2);
|
||||
const bins = (vp.bins || []).filter(function(b) { return b && b.volume > 0; })
|
||||
.slice().sort(function(a, b) { return b.volume - a.volume; }).slice(0, 8);
|
||||
let maxVol = 0;
|
||||
bins.forEach(function(b) { if (b.volume > maxVol) maxVol = b.volume; });
|
||||
const span = (!isNaN(t0) && !isNaN(t1) && t1 > t0) ? (t1 - t0) : barStep * 12;
|
||||
bins.forEach(function(b) {
|
||||
if (!b.volume || maxVol <= 0 || isNaN(t1)) return;
|
||||
const wSec = Math.max(barStep, Math.floor(span * 0.12 * (b.volume / maxVol)));
|
||||
let leftT = Math.max(isNaN(t0) ? (t1 - wSec) : t0, t1 - wSec);
|
||||
if (leftT >= t1) leftT = t1 - barStep;
|
||||
if (leftT >= t1) return;
|
||||
const alpha = 0.25 + 0.55 * (b.volume / maxVol);
|
||||
mainChart.addLineSeries({
|
||||
color: cfg.vpRgb.replace('ALPHA', alpha.toFixed(2)),
|
||||
lineWidth: 2, lastValueVisible: false, priceLineVisible: false
|
||||
}).setData([{ time: leftT, value: b.price }, { time: t1, value: b.price }]);
|
||||
});
|
||||
}
|
||||
if (showE && cycle.events && cycle.events.length) {
|
||||
const checks = (cycle.volume_confirm && cycle.volume_confirm.event_checks) || {};
|
||||
cycle.events.forEach(function(ev) {
|
||||
const t = toSec(ev.time);
|
||||
if (isNaN(t)) return;
|
||||
const typ = ev.type || '';
|
||||
const chk = checks[typ] || {};
|
||||
const volOk = (chk.volume_ok != null) ? chk.volume_ok : ev.volume_ok;
|
||||
const ok = volOk === true ? '✓' : (volOk === false ? '✗' : '');
|
||||
pushMarker({
|
||||
time: t,
|
||||
position: (typ === 'Spring' || typ === 'LPS' || typ === 'SOW') ? 'belowBar' : 'aboveBar',
|
||||
color: eventColors[typ] || '#7f8c8d',
|
||||
shape: (typ === 'Spring' || typ === 'SOW' || typ === 'LPS') ? 'arrowDown' : 'arrowUp',
|
||||
text: cTag + typ + ok,
|
||||
size: 1
|
||||
});
|
||||
});
|
||||
}
|
||||
} catch (e) { console.error('区间叠层出错', cfg.name, 'C' + cid, e); }
|
||||
});
|
||||
};
|
||||
|
||||
if ($('#showMainWrRange').is(':checked') || $('#showMainWrPhases').is(':checked')
|
||||
|| $('#showMainWrEvents').is(':checked') || $('#showMainWrVP').is(':checked')) {
|
||||
drawOne(currentData.wyckoff, {
|
||||
name: '主', tag: '', tfLabel: (currentData.timeframe || '4H'), color: '#3498db', vpColor: '#8e44ad',
|
||||
vpRgb: 'rgba(142, 68, 173, ALPHA)',
|
||||
rangeSel: '#showMainWrRange', phasesSel: '#showMainWrPhases',
|
||||
eventsSel: '#showMainWrEvents', vpSel: '#showMainWrVP',
|
||||
hiTag: 'WR.H', loTag: 'WR.L', midTag: 'WR.M',
|
||||
pocTag: 'POC', vahTag: 'VAH', valTag: 'VAL'
|
||||
});
|
||||
}
|
||||
if ($('#showElementWrRange').is(':checked') || $('#showElementWrPhases').is(':checked')
|
||||
|| $('#showElementWrEvents').is(':checked') || $('#showElementWrVP').is(':checked')) {
|
||||
drawOne(currentData.element_wyckoff, {
|
||||
name: '次', tag: 'e', tfLabel: (currentData.element_timeframe || '2H'), color: '#e67e22', vpColor: '#d35400',
|
||||
vpRgb: 'rgba(211, 84, 0, ALPHA)',
|
||||
rangeSel: '#showElementWrRange', phasesSel: '#showElementWrPhases',
|
||||
eventsSel: '#showElementWrEvents', vpSel: '#showElementWrVP',
|
||||
hiTag: 'eWR.H', loTag: 'eWR.L', midTag: 'eWR.M',
|
||||
pocTag: 'ePOC', vahTag: 'eVAH', valTag: 'eVAL'
|
||||
});
|
||||
}
|
||||
if ($('#showSubSubWrRange').is(':checked') || $('#showSubSubWrPhases').is(':checked')
|
||||
|| $('#showSubSubWrEvents').is(':checked') || $('#showSubSubWrVP').is(':checked')) {
|
||||
drawOne(currentData.sub_sub_wyckoff, {
|
||||
name: '次次', tag: 's', tfLabel: (currentData.sub_sub_timeframe || '1H'), color: '#27ae60', vpColor: '#16a085',
|
||||
vpRgb: 'rgba(22, 160, 133, ALPHA)',
|
||||
rangeSel: '#showSubSubWrRange', phasesSel: '#showSubSubWrPhases',
|
||||
eventsSel: '#showSubSubWrEvents', vpSel: '#showSubSubWrVP',
|
||||
hiTag: 'sWR.H', loTag: 'sWR.L', midTag: 'sWR.M',
|
||||
pocTag: 'sPOC', vahTag: 'sVAH', valTag: 'sVAL'
|
||||
});
|
||||
}
|
||||
|
||||
// 同 BSP:写入 window,稍后与分型/买卖点一并 setMarkers
|
||||
wrMarkers.sort(function(a, b) { return a.time - b.time; });
|
||||
window.wrMarkers = wrMarkers;
|
||||
if (typeof renderWyckoffCycleSummary === 'function') {
|
||||
renderWyckoffCycleSummary();
|
||||
}
|
||||
})();
|
||||
// 显示未完成中枢 - 分别处理主周期、次周期和次次周期
|
||||
if ($('#showMainZs').is(':checked') || $('#showElementZs').is(':checked') || $('#showSubSubZs').is(':checked') || $('#showSubSubBiZs').is(':checked')) {
|
||||
console.log('绘制未完成中枢 - 已启用');
|
||||
@@ -4223,7 +4298,8 @@ function initTradingView(symbol, timeframe) {
|
||||
...(window.kluDivMarkersElement || []),
|
||||
...(window.kluDivMarkersSubSub || []),
|
||||
...trendMarkersToUse,
|
||||
...(window.bspMarkers || [])
|
||||
...(window.bspMarkers || []),
|
||||
...(window.wrMarkers || [])
|
||||
];
|
||||
if (combinedMarkers.length > 0) {
|
||||
console.log(
|
||||
@@ -4232,6 +4308,7 @@ function initTradingView(symbol, timeframe) {
|
||||
'个,小周期分型:', allElementFxMarkers.length,
|
||||
'个,UnitTF:', (window.unittfMarkers || []).length,
|
||||
'个,BSP标记:', (window.bspMarkers || []).length,
|
||||
'个,区间标记:', (window.wrMarkers || []).length,
|
||||
'个)'
|
||||
);
|
||||
|
||||
@@ -4358,7 +4435,8 @@ function initTradingView(symbol, timeframe) {
|
||||
...(window.kluDivMarkersElement || []),
|
||||
...(window.kluDivMarkersSubSub || []),
|
||||
...trendMarkersToUse,
|
||||
...(window.bspMarkers || [])
|
||||
...(window.bspMarkers || []),
|
||||
...(window.wrMarkers || [])
|
||||
];
|
||||
if (onlyMainAndU.length > 0) {
|
||||
console.log('仅设置', onlyMainAndU.length, '个主周期/UnitTF标记(主周期分型:', (window.mainFxMarkers || []).length, ',UnitTF:', (window.unittfMarkers || []).length, ')');
|
||||
|
||||
@@ -13,7 +13,7 @@ function updateChart(options) {
|
||||
symbol = $('#astockSymbol').val() || '000001';
|
||||
}
|
||||
|
||||
const timeframe = $('#timeframe').val() || window.DEFAULT_MAIN_TIMEFRAME || '5m';
|
||||
const timeframe = $('#timeframe').val() || window.DEFAULT_MAIN_TIMEFRAME || '4h';
|
||||
const timezone = $('#timezone').val() || 'Asia/Shanghai';
|
||||
const elementTimeframe = $('#elementTimeframe').val() || window.DEFAULT_ELEMENT_TIMEFRAME || '1m';
|
||||
const subSubTimeframe = $('#subSubTimeframe').val() || '';
|
||||
@@ -62,8 +62,8 @@ function updateChart(options) {
|
||||
end_time: endTimeMs,
|
||||
elements_only: false,
|
||||
zone_kl_lines: parseInt($('#zoneKlLines').val()) || 1000,
|
||||
include_structure_zones: $('#showMainStructureZone').is(':checked') ? 1 : 0,
|
||||
include_wyckoff: $('#showWyckoff').is(':checked') ? 1 : 0
|
||||
include_structure_zones: $('#showMainStructureZone').is(':checked') ? 1 : 0
|
||||
// 威科夫随主分析一并返回;开关仅控制绘制,不再传 include_wyckoff
|
||||
},
|
||||
success: function(data) {
|
||||
// 隐藏加载图标
|
||||
@@ -81,6 +81,9 @@ function updateChart(options) {
|
||||
delete currentData.original_macd;
|
||||
}
|
||||
currentData = data;
|
||||
if (typeof renderWyckoffCycleSummary === 'function') {
|
||||
renderWyckoffCycleSummary();
|
||||
}
|
||||
|
||||
refreshChart(data, {
|
||||
incremental: options.incremental !== undefined
|
||||
|
||||
@@ -93,25 +93,16 @@ $(document).on('change', '#showMainStructureZone', function() {
|
||||
}
|
||||
});
|
||||
|
||||
// 威科夫主开关:勾选才请求;子项仅本地重绘
|
||||
function syncWyckoffSubControls() {
|
||||
const on = $('#showWyckoff').is(':checked');
|
||||
$('#showWyckoffRange, #showWyckoffPhases, #showWyckoffEvents, #showWyckoffVP').prop('disabled', !on);
|
||||
}
|
||||
$(document).on('change', '#showWyckoff', function() {
|
||||
const on = $('#showWyckoff').is(':checked');
|
||||
syncWyckoffSubControls();
|
||||
console.log('威科夫切换为:', on);
|
||||
if (on) {
|
||||
updateChart();
|
||||
} else {
|
||||
// 区间/阶段/时间/VP:与缠论笔开关一样,本地重绘
|
||||
$(document).on(
|
||||
'change',
|
||||
'#showMainWrRange, #showMainWrPhases, #showMainWrEvents, #showMainWrVP,' +
|
||||
'#showElementWrRange, #showElementWrPhases, #showElementWrEvents, #showElementWrVP,' +
|
||||
'#showSubSubWrRange, #showSubSubWrPhases, #showSubSubWrEvents, #showSubSubWrVP',
|
||||
function() {
|
||||
updateChartDisplay();
|
||||
}
|
||||
});
|
||||
$(document).on('change', '#showWyckoffRange, #showWyckoffPhases, #showWyckoffEvents, #showWyckoffVP', function() {
|
||||
updateChartDisplay();
|
||||
});
|
||||
$(function() { syncWyckoffSubControls(); });
|
||||
);
|
||||
|
||||
// 添加趋势显示复选框变更事件(主/元素),变更后刷新主图
|
||||
$('#showMainTrend').change(function() {
|
||||
|
||||
+252
-3
@@ -1,4 +1,252 @@
|
||||
/* ui.js */
|
||||
|
||||
/** Trading OS 可消费的威科夫 Cycle 摘要(Confirmed + Live 分区;cycles[0]=ACTIVE) */
|
||||
function buildWyckoffCycleSummaryPayload(w, tf) {
|
||||
if (!w) return null;
|
||||
const cycles = (w.cycles && w.cycles.length)
|
||||
? w.cycles
|
||||
: (w.trading_range ? [{
|
||||
id: 0, status: 'ACTIVE', role: 'latest', lifecycle: w.lifecycle || 'UNKNOWN',
|
||||
trading_range: w.trading_range, bias: w.bias,
|
||||
phases: w.phases || [], events: w.events || [],
|
||||
confirmed: { phases: w.phases || [], events: w.events || [] },
|
||||
live: w.live || null,
|
||||
confidence: { overall: null },
|
||||
period: {
|
||||
start_time: w.trading_range.start_time,
|
||||
end_time: w.trading_range.end_time,
|
||||
bars: w.trading_range.bars
|
||||
}
|
||||
}] : []);
|
||||
if (!cycles.length) return null;
|
||||
const active = cycles[0]; // 禁止 cycles[-1]
|
||||
const confirmed = active.confirmed || {
|
||||
phases: active.phases || w.phases || [],
|
||||
events: active.events || w.events || []
|
||||
};
|
||||
const live = active.live || w.live || null;
|
||||
const cPhases = confirmed.phases || [];
|
||||
const cEvents = confirmed.events || [];
|
||||
const lastPhase = cPhases.length ? cPhases[cPhases.length - 1] : null;
|
||||
const lastEvent = cEvents.length ? cEvents[cEvents.length - 1] : null;
|
||||
const tr = active.trading_range || {};
|
||||
const prev = cycles.length > 1 ? cycles[1] : null;
|
||||
const biasLabel = ({
|
||||
accumulation: 'Accumulation',
|
||||
distribution: 'Distribution',
|
||||
unknown: 'Unknown'
|
||||
})[active.bias] || (active.bias || 'Unknown');
|
||||
const liveCand = (live && live.event_candidates && live.event_candidates[0]) || null;
|
||||
const liveConf = live && live.confidence ? live.confidence.overall : null;
|
||||
return {
|
||||
symbol: (typeof currentData !== 'undefined' && currentData && currentData.symbol) || $('#symbol').val() || '',
|
||||
timeframe: (tf || w.timeframe || $('#timeframe').val() || '').toString().toUpperCase(),
|
||||
active: {
|
||||
cycle_id: active.id != null ? active.id : 0,
|
||||
status: active.status || 'ACTIVE',
|
||||
lifecycle: active.lifecycle || (live && live.lifecycle) || 'UNKNOWN',
|
||||
structure: biasLabel,
|
||||
phase_confirmed: lastPhase ? String(lastPhase.phase || '') : null,
|
||||
event_confirmed: lastEvent ? String(lastEvent.type || '') : null,
|
||||
phase_candidate: live ? live.phase_candidate : null,
|
||||
event_candidate: liveCand ? liveCand.type : null,
|
||||
event_candidate_confidence: liveCand ? liveCand.confidence : null,
|
||||
next_expected: live ? live.next_expected : null,
|
||||
range: {
|
||||
low: tr.low,
|
||||
high: tr.high,
|
||||
start_time: (active.period && active.period.start_time) || tr.start_time,
|
||||
end_time: (active.period && active.period.end_time) || tr.end_time,
|
||||
bars: (active.period && active.period.bars) != null ? active.period.bars : tr.bars
|
||||
},
|
||||
confidence_confirmed: (active.confidence && active.confidence.overall != null)
|
||||
? active.confidence.overall
|
||||
: null,
|
||||
confidence_live: liveConf
|
||||
},
|
||||
confirmed_history: cycles.slice(1, 4).map(function(c) {
|
||||
const evs = ((c.confirmed && c.confirmed.events) || c.events || [])
|
||||
.map(function(e) { return e.type; }).filter(Boolean);
|
||||
return {
|
||||
cycle_id: c.id,
|
||||
structure: ({
|
||||
accumulation: 'Accumulation',
|
||||
distribution: 'Distribution',
|
||||
unknown: 'Unknown'
|
||||
})[c.bias] || c.bias,
|
||||
events: evs,
|
||||
lifecycle: c.lifecycle || 'COMPLETED'
|
||||
};
|
||||
}),
|
||||
live: live,
|
||||
cycle_count: cycles.length
|
||||
};
|
||||
}
|
||||
|
||||
function _wrLayerTogglesOn(prefix) {
|
||||
// prefix: Main | Element | SubSub
|
||||
return $('#show' + prefix + 'WrRange').is(':checked')
|
||||
|| $('#show' + prefix + 'WrPhases').is(':checked')
|
||||
|| $('#show' + prefix + 'WrEvents').is(':checked')
|
||||
|| $('#show' + prefix + 'WrVP').is(':checked');
|
||||
}
|
||||
|
||||
/** 面板展示用中文(机器可读 payload 仍保留英文原值) */
|
||||
function _wcsLifecycleZh(v) {
|
||||
return ({
|
||||
UNKNOWN: '未知',
|
||||
FORMING: '形成中',
|
||||
CONFIRMED: '已确认',
|
||||
COMPLETED: '已完成',
|
||||
ACTIVE: '当前'
|
||||
})[v] || v || '未知';
|
||||
}
|
||||
|
||||
function _wcsStructureZh(v) {
|
||||
if (!v) return '—';
|
||||
const key = String(v).toLowerCase();
|
||||
return ({
|
||||
accumulation: '吸筹',
|
||||
distribution: '派发',
|
||||
unknown: '未知'
|
||||
})[key] || ({
|
||||
Accumulation: '吸筹',
|
||||
Distribution: '派发',
|
||||
Unknown: '未知'
|
||||
})[v] || v;
|
||||
}
|
||||
|
||||
function _wcsEventZh(v) {
|
||||
if (v == null || v === '') return '—';
|
||||
return ({
|
||||
Spring: '弹簧',
|
||||
UTAD: '上升后派发',
|
||||
SOS: '强势信号',
|
||||
SOW: '弱势信号',
|
||||
LPS: '最后支撑',
|
||||
LPSY: '最后供应',
|
||||
Test: '回测',
|
||||
PSY: '初步供应',
|
||||
BC: '买气高潮',
|
||||
AR: '自动回落',
|
||||
ST: '二次测试',
|
||||
SC: '卖气高潮'
|
||||
})[v] || v;
|
||||
}
|
||||
|
||||
function _htmlWyckoffSummaryBlock(payload, blockClass) {
|
||||
if (!payload || !payload.active) return '';
|
||||
const a = payload.active;
|
||||
const fmtPx = function(v) {
|
||||
if (v == null || isNaN(Number(v))) return '—';
|
||||
const n = Number(v);
|
||||
return n >= 1000 ? n.toFixed(1) : n.toFixed(4);
|
||||
};
|
||||
const pct = function(v) {
|
||||
if (v == null || isNaN(Number(v))) return '—';
|
||||
return Math.round(Number(v) * 100) + '%';
|
||||
};
|
||||
let html = '<div class="wcs-block ' + (blockClass || '') + '">';
|
||||
html += '<div class="wcs-title">' + (payload.symbol || '') + ' '
|
||||
+ (payload.timeframe || '') + '</div>';
|
||||
html += '<div><span class="wcs-badge">当前 C' + a.cycle_id + '</span> '
|
||||
+ '<span class="wcs-badge" style="background:#fff8c5;color:#9a6700;">'
|
||||
+ _wcsLifecycleZh(a.lifecycle) + '</span></div>';
|
||||
html += '<div class="wcs-active">';
|
||||
html += '<div class="wcs-row"><span class="wcs-k">结构</span><span class="wcs-v">'
|
||||
+ _wcsStructureZh(a.structure) + '</span></div>';
|
||||
html += '<div class="wcs-row"><span class="wcs-k">阶段</span><span class="wcs-v">'
|
||||
+ (a.phase_candidate
|
||||
? ('阶段 ' + a.phase_candidate + '(候选)')
|
||||
: (a.phase_confirmed ? ('阶段 ' + a.phase_confirmed) : '—'))
|
||||
+ '</span></div>';
|
||||
html += '<div class="wcs-row"><span class="wcs-k">事件</span><span class="wcs-v">'
|
||||
+ (a.event_candidate
|
||||
? (_wcsEventZh(a.event_candidate) + '(候选)')
|
||||
: _wcsEventZh(a.event_confirmed))
|
||||
+ '</span></div>';
|
||||
if (a.event_confirmed && a.event_candidate) {
|
||||
html += '<div class="wcs-row"><span class="wcs-k">已确认</span><span class="wcs-v">'
|
||||
+ _wcsEventZh(a.event_confirmed) + '</span></div>';
|
||||
}
|
||||
html += '<div class="wcs-row"><span class="wcs-k">区间</span><span class="wcs-v">'
|
||||
+ fmtPx(a.range && a.range.low) + ' – ' + fmtPx(a.range && a.range.high) + '</span></div>';
|
||||
html += '<div class="wcs-row"><span class="wcs-k">置信度</span><span class="wcs-v">'
|
||||
+ pct(a.confidence_live != null ? a.confidence_live : a.confidence_confirmed) + '</span></div>';
|
||||
if (a.next_expected) {
|
||||
html += '<div class="wcs-row"><span class="wcs-k">下一步</span><span class="wcs-v">'
|
||||
+ _wcsEventZh(a.next_expected) + '</span></div>';
|
||||
}
|
||||
html += '</div>';
|
||||
if (payload.confirmed_history && payload.confirmed_history.length) {
|
||||
html += '<div class="wcs-prev"><div style="margin-bottom:2px;">已确认历史</div>';
|
||||
payload.confirmed_history.forEach(function(h) {
|
||||
const ev = (h.events && h.events.length)
|
||||
? h.events.map(_wcsEventZh).join('、')
|
||||
: '—';
|
||||
html += '<div>C' + h.cycle_id + ' ' + _wcsStructureZh(h.structure) + ' · ' + ev + '</div>';
|
||||
});
|
||||
html += '</div>';
|
||||
}
|
||||
html += '</div>';
|
||||
return html;
|
||||
}
|
||||
|
||||
function renderWyckoffCycleSummary() {
|
||||
const $el = $('#wyckoffCycleSummary');
|
||||
if (!$el.length) return;
|
||||
if (!currentData) {
|
||||
$el.hide().empty();
|
||||
window.wyckoffCycleSummary = null;
|
||||
return;
|
||||
}
|
||||
const layers = [];
|
||||
if (_wrLayerTogglesOn('Main') && currentData.wyckoff) {
|
||||
layers.push({
|
||||
key: 'main',
|
||||
cls: 'wcs-main',
|
||||
payload: buildWyckoffCycleSummaryPayload(
|
||||
currentData.wyckoff,
|
||||
currentData.timeframe || currentData.wyckoff.timeframe || $('#timeframe').val()
|
||||
)
|
||||
});
|
||||
}
|
||||
if (_wrLayerTogglesOn('Element') && currentData.element_wyckoff) {
|
||||
layers.push({
|
||||
key: 'element',
|
||||
cls: 'wcs-element',
|
||||
payload: buildWyckoffCycleSummaryPayload(
|
||||
currentData.element_wyckoff,
|
||||
currentData.element_timeframe || currentData.element_wyckoff.timeframe || $('#elementTimeframe').val()
|
||||
)
|
||||
});
|
||||
}
|
||||
if (_wrLayerTogglesOn('SubSub') && currentData.sub_sub_wyckoff) {
|
||||
layers.push({
|
||||
key: 'sub_sub',
|
||||
cls: 'wcs-subsub',
|
||||
payload: buildWyckoffCycleSummaryPayload(
|
||||
currentData.sub_sub_wyckoff,
|
||||
currentData.sub_sub_timeframe || currentData.sub_sub_wyckoff.timeframe || $('#subSubTimeframe').val()
|
||||
)
|
||||
});
|
||||
}
|
||||
const valid = layers.filter(function(L) { return L.payload && L.payload.active; });
|
||||
if (!valid.length) {
|
||||
$el.hide().empty();
|
||||
window.wyckoffCycleSummary = null;
|
||||
return;
|
||||
}
|
||||
const bag = {};
|
||||
let html = '';
|
||||
valid.forEach(function(L) {
|
||||
bag[L.key] = L.payload;
|
||||
html += _htmlWyckoffSummaryBlock(L.payload, L.cls);
|
||||
});
|
||||
window.wyckoffCycleSummary = bag;
|
||||
$el.html(html).show();
|
||||
}
|
||||
|
||||
function loadSymbols() {
|
||||
$.get('/api/symbols', function(data) {
|
||||
if (Array.isArray(data)) {
|
||||
@@ -24,14 +272,15 @@ function loadSymbols() {
|
||||
});
|
||||
}
|
||||
|
||||
// 设置默认时间范围
|
||||
// 设置默认时间范围(需覆盖威科夫 lookback;1 天在 4h/1h 上几乎检不出区间)
|
||||
function setDefaultTimeRange() {
|
||||
const now = new Date();
|
||||
const oneDayAgo = new Date(now.getTime() - (24 * 60 * 60 * 1000));
|
||||
const daysBack = 14;
|
||||
const start = new Date(now.getTime() - (daysBack * 24 * 60 * 60 * 1000));
|
||||
|
||||
// 格式化为datetime-local输入框所需的格式 YYYY-MM-DDThh:mm
|
||||
$('#end_time').val(formatDatetimeLocal(now));
|
||||
$('#start_time').val(formatDatetimeLocal(oneDayAgo));
|
||||
$('#start_time').val(formatDatetimeLocal(start));
|
||||
}
|
||||
// 格式化日期为datetime-local输入框格式
|
||||
function formatDatetimeLocal(date) {
|
||||
|
||||
+131
-27
@@ -96,6 +96,81 @@
|
||||
position: relative;
|
||||
z-index: 1; /* 确保图表在数据面板之上 */
|
||||
}
|
||||
/* 挂在主图容器内:左下角 = K线主图左下,而非整图(含副图)底边 */
|
||||
.wyckoff-cycle-summary {
|
||||
position: absolute;
|
||||
left: 8px;
|
||||
bottom: 28px; /* 略抬高,避开主图时间轴 */
|
||||
top: auto;
|
||||
right: auto;
|
||||
z-index: 1100;
|
||||
min-width: 200px;
|
||||
max-width: 300px;
|
||||
max-height: calc(100% - 36px);
|
||||
overflow-y: auto;
|
||||
padding: 8px 10px;
|
||||
background: rgba(255, 255, 255, 0.94);
|
||||
border: 1px solid #d0d7de;
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 2px 10px rgba(0,0,0,0.08);
|
||||
font-size: 12px;
|
||||
line-height: 1.45;
|
||||
color: #24292f;
|
||||
display: none;
|
||||
pointer-events: auto;
|
||||
}
|
||||
.wyckoff-cycle-summary .wcs-block {
|
||||
padding: 6px 0;
|
||||
}
|
||||
.wyckoff-cycle-summary .wcs-block + .wcs-block {
|
||||
border-top: 1px solid #eaeef2;
|
||||
margin-top: 6px;
|
||||
padding-top: 8px;
|
||||
}
|
||||
.wyckoff-cycle-summary .wcs-block.wcs-main { border-left: 3px solid #3498db; padding-left: 8px; }
|
||||
.wyckoff-cycle-summary .wcs-block.wcs-element { border-left: 3px solid #e67e22; padding-left: 8px; }
|
||||
.wyckoff-cycle-summary .wcs-block.wcs-subsub { border-left: 3px solid #27ae60; padding-left: 8px; }
|
||||
.wyckoff-cycle-summary .wcs-title {
|
||||
font-weight: 650;
|
||||
font-size: 13px;
|
||||
margin-bottom: 6px;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
.wyckoff-cycle-summary .wcs-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
margin: 2px 0;
|
||||
}
|
||||
.wyckoff-cycle-summary .wcs-k {
|
||||
color: #656d76;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.wyckoff-cycle-summary .wcs-v {
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.wyckoff-cycle-summary .wcs-active {
|
||||
margin-top: 2px;
|
||||
padding: 6px 0 4px;
|
||||
border-top: 1px solid #eaeef2;
|
||||
}
|
||||
.wyckoff-cycle-summary .wcs-prev {
|
||||
margin-top: 6px;
|
||||
padding-top: 6px;
|
||||
border-top: 1px dashed #eaeef2;
|
||||
color: #656d76;
|
||||
font-size: 11px;
|
||||
}
|
||||
.wyckoff-cycle-summary .wcs-badge {
|
||||
display: inline-block;
|
||||
padding: 1px 6px;
|
||||
border-radius: 3px;
|
||||
background: #ddf4ff;
|
||||
color: #0969da;
|
||||
font-weight: 600;
|
||||
font-size: 11px;
|
||||
}
|
||||
.chart-options {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
@@ -972,26 +1047,6 @@
|
||||
<label class="form-check-label" for="showMainStructureZone">结构区</label>
|
||||
</div>
|
||||
<input type="number" id="zoneKlLines" class="form-control form-control-sm" value="1000" min="100" max="5000" step="100" style="width:80px;" title="结构区K线数量">
|
||||
<div class="form-check form-check-inline me-1 ms-2">
|
||||
<input class="form-check-input" type="checkbox" id="showWyckoff">
|
||||
<label class="form-check-label" for="showWyckoff">威科夫</label>
|
||||
</div>
|
||||
<div class="form-check form-check-inline me-1">
|
||||
<input class="form-check-input" type="checkbox" id="showWyckoffRange" checked disabled>
|
||||
<label class="form-check-label" for="showWyckoffRange">区间</label>
|
||||
</div>
|
||||
<div class="form-check form-check-inline me-1">
|
||||
<input class="form-check-input" type="checkbox" id="showWyckoffPhases" checked disabled>
|
||||
<label class="form-check-label" for="showWyckoffPhases">阶段</label>
|
||||
</div>
|
||||
<div class="form-check form-check-inline me-1">
|
||||
<input class="form-check-input" type="checkbox" id="showWyckoffEvents" checked disabled>
|
||||
<label class="form-check-label" for="showWyckoffEvents">事件</label>
|
||||
</div>
|
||||
<div class="form-check form-check-inline me-1">
|
||||
<input class="form-check-input" type="checkbox" id="showWyckoffVP" checked disabled>
|
||||
<label class="form-check-label" for="showWyckoffVP">VP</label>
|
||||
</div>
|
||||
<span id="nextRefreshTime" class="text-muted" style="display:none;font-size:0.85rem;"></span>
|
||||
<div id="refreshLoadingSpinner" class="loading-spinner ms-2" style="display:none;"></div>
|
||||
</div>
|
||||
@@ -1037,6 +1092,22 @@
|
||||
<input class="form-check-input" type="checkbox" id="showMainBsp">
|
||||
<label class="form-check-label" for="showMainBsp">买卖点</label>
|
||||
</div>
|
||||
<div class="form-check form-check-inline ms-2">
|
||||
<input class="form-check-input" type="checkbox" id="showMainWrRange">
|
||||
<label class="form-check-label" for="showMainWrRange">区间</label>
|
||||
</div>
|
||||
<div class="form-check form-check-inline">
|
||||
<input class="form-check-input" type="checkbox" id="showMainWrPhases">
|
||||
<label class="form-check-label" for="showMainWrPhases">阶段</label>
|
||||
</div>
|
||||
<div class="form-check form-check-inline">
|
||||
<input class="form-check-input" type="checkbox" id="showMainWrEvents">
|
||||
<label class="form-check-label" for="showMainWrEvents">时间</label>
|
||||
</div>
|
||||
<div class="form-check form-check-inline">
|
||||
<input class="form-check-input" type="checkbox" id="showMainWrVP">
|
||||
<label class="form-check-label" for="showMainWrVP">VP</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-flex align-items-center mt-1">
|
||||
<label class="form-label me-0 mb-0">次周期:</label>
|
||||
@@ -1079,6 +1150,22 @@
|
||||
<input class="form-check-input" type="checkbox" id="showElementBsp">
|
||||
<label class="form-check-label" for="showElementBsp">买卖点</label>
|
||||
</div>
|
||||
<div class="form-check form-check-inline ms-2">
|
||||
<input class="form-check-input" type="checkbox" id="showElementWrRange">
|
||||
<label class="form-check-label" for="showElementWrRange">区间</label>
|
||||
</div>
|
||||
<div class="form-check form-check-inline">
|
||||
<input class="form-check-input" type="checkbox" id="showElementWrPhases">
|
||||
<label class="form-check-label" for="showElementWrPhases">阶段</label>
|
||||
</div>
|
||||
<div class="form-check form-check-inline">
|
||||
<input class="form-check-input" type="checkbox" id="showElementWrEvents">
|
||||
<label class="form-check-label" for="showElementWrEvents">时间</label>
|
||||
</div>
|
||||
<div class="form-check form-check-inline">
|
||||
<input class="form-check-input" type="checkbox" id="showElementWrVP">
|
||||
<label class="form-check-label" for="showElementWrVP">VP</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-flex align-items-center mt-1">
|
||||
<label class="form-label me-0 mb-0">次次周期:</label>
|
||||
@@ -1121,6 +1208,22 @@
|
||||
<input class="form-check-input" type="checkbox" id="showSubSubBsp">
|
||||
<label class="form-check-label" for="showSubSubBsp">买卖点</label>
|
||||
</div>
|
||||
<div class="form-check form-check-inline ms-2">
|
||||
<input class="form-check-input" type="checkbox" id="showSubSubWrRange">
|
||||
<label class="form-check-label" for="showSubSubWrRange">区间</label>
|
||||
</div>
|
||||
<div class="form-check form-check-inline">
|
||||
<input class="form-check-input" type="checkbox" id="showSubSubWrPhases">
|
||||
<label class="form-check-label" for="showSubSubWrPhases">阶段</label>
|
||||
</div>
|
||||
<div class="form-check form-check-inline">
|
||||
<input class="form-check-input" type="checkbox" id="showSubSubWrEvents">
|
||||
<label class="form-check-label" for="showSubSubWrEvents">时间</label>
|
||||
</div>
|
||||
<div class="form-check form-check-inline">
|
||||
<input class="form-check-input" type="checkbox" id="showSubSubWrVP">
|
||||
<label class="form-check-label" for="showSubSubWrVP">VP</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1130,6 +1233,7 @@
|
||||
|
||||
<div class="chart-container">
|
||||
<div id="tradingview_chart"></div>
|
||||
<div id="wyckoffCycleSummary" class="wyckoff-cycle-summary" aria-live="polite"></div>
|
||||
<!-- 技术指标下拉菜单 -->
|
||||
<div class="indicator-dropdown dropdown">
|
||||
<button class="add-indicator-btn dropdown-toggle" type="button" id="indicatorDropdown" data-bs-toggle="dropdown" aria-expanded="false">
|
||||
@@ -1282,13 +1386,13 @@
|
||||
<script defer src="{{ url_for('static', filename='js/app/api_client.js') }}"></script>
|
||||
<script defer src="{{ url_for('static', filename='js/app/state.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/chart_format.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') }}?v=20260806a"></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/ui.js') }}?v=20260806a"></script>
|
||||
<script defer src="{{ url_for('static', filename='js/app/macd_ui.js') }}?v=20260807e"></script>
|
||||
<script defer src="{{ url_for('static', filename='js/app/chart_format.js') }}?v=20260807e"></script>
|
||||
<script defer src="{{ url_for('static', filename='js/app/chart_view.js') }}?v=20260807e"></script>
|
||||
<script defer src="{{ url_for('static', filename='js/app/chart_tv.js') }}?v=20260807e"></script>
|
||||
<script defer src="{{ url_for('static', filename='js/app/chart_sync.js') }}?v=20260807e"></script>
|
||||
<script defer src="{{ url_for('static', filename='js/app/chart_tables.js') }}?v=20260807e"></script>
|
||||
<script defer src="{{ url_for('static', filename='js/app/ui.js') }}?v=20260807e"></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>
|
||||
|
||||
|
||||
@@ -127,11 +127,13 @@ def test_analyze_http_contract_with_mocked_kl():
|
||||
assert payload is not None and "error" not in payload
|
||||
missing = [k for k in CONTRACT_KEYS if k not in payload]
|
||||
assert not missing, f"missing contract keys: {missing}"
|
||||
assert "wyckoff" not in payload
|
||||
assert "wyckoff" in payload
|
||||
for k in WYCKOFF_KEYS:
|
||||
assert k in payload["wyckoff"], f"missing wyckoff key: {k}"
|
||||
|
||||
|
||||
def test_analyze_http_wyckoff_opt_in():
|
||||
"""include_wyckoff=1 时响应含 wyckoff 约定键;默认不返回。"""
|
||||
def test_analyze_http_wyckoff_can_opt_out():
|
||||
"""include_wyckoff=0 时可显式跳过威科夫。"""
|
||||
from app import app
|
||||
from services.runtime import add_indicators
|
||||
|
||||
@@ -148,19 +150,49 @@ def test_analyze_http_wyckoff_opt_in():
|
||||
"symbol": "BTC/USDT:USDT",
|
||||
"timeframe": "5m",
|
||||
"timezone": "Asia/Shanghai",
|
||||
"include_wyckoff": 1,
|
||||
"include_wyckoff": 0,
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 200, resp.data[:500]
|
||||
payload = resp.get_json()
|
||||
assert payload is not None and "wyckoff" in payload
|
||||
w = payload["wyckoff"]
|
||||
for k in WYCKOFF_KEYS:
|
||||
assert k in w, f"missing wyckoff key: {k}"
|
||||
assert payload is not None and "wyckoff" not in payload
|
||||
|
||||
|
||||
def test_analyze_http_wyckoff_for_three_timeframes():
|
||||
"""主/次/次次均返回各自 wyckoff 载荷。"""
|
||||
from app import app
|
||||
from services.runtime import add_indicators
|
||||
|
||||
df = add_indicators(make_ohlcv(300))
|
||||
df = df.copy()
|
||||
if "timestamp" not in df.columns:
|
||||
df["timestamp"] = (pd.to_datetime(df["date"]).astype("int64") // 10**6).astype("int64")
|
||||
|
||||
with patch("api.analyze.get_kl_data", return_value=df):
|
||||
client = app.test_client()
|
||||
resp = client.get(
|
||||
"/api/analyze",
|
||||
query_string={
|
||||
"symbol": "BTC/USDT:USDT",
|
||||
"timeframe": "4h",
|
||||
"element_timeframe": "2h",
|
||||
"sub_sub_timeframe": "1h",
|
||||
"timezone": "Asia/Shanghai",
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 200, resp.data[:500]
|
||||
payload = resp.get_json()
|
||||
assert payload is not None and "error" not in payload
|
||||
assert "wyckoff" in payload
|
||||
assert "element_wyckoff" in payload
|
||||
assert "sub_sub_wyckoff" in payload
|
||||
for key in ("wyckoff", "element_wyckoff", "sub_sub_wyckoff"):
|
||||
for k in WYCKOFF_KEYS:
|
||||
assert k in payload[key], f"missing {k} in {key}"
|
||||
|
||||
|
||||
def test_analyze_http_wyckoff_skipped_when_elements_only():
|
||||
"""elements_only=true 时即使 include_wyckoff=1 也不返回 wyckoff。"""
|
||||
"""elements_only=true 时不返回 wyckoff。"""
|
||||
from app import app
|
||||
from services.runtime import add_indicators
|
||||
|
||||
@@ -179,7 +211,6 @@ def test_analyze_http_wyckoff_skipped_when_elements_only():
|
||||
"element_timeframe": "1m",
|
||||
"timezone": "Asia/Shanghai",
|
||||
"elements_only": "true",
|
||||
"include_wyckoff": 1,
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 200, resp.data[:500]
|
||||
|
||||
Reference in New Issue
Block a user