feat: ECR-003 主站威科夫分析与图表叠层(已审)

独立 wyckoff 引擎 + 按需 include_wyckoff;主站 Lightweight 绘制区间/阶段/事件/VP。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
jackyu66git
2026-08-06 18:33:57 +08:00
co-authored by Cursor
parent df27b4dde8
commit 081a57a90e
30 changed files with 1330 additions and 52 deletions
+27
View File
@@ -656,5 +656,32 @@ def analyze():
else:
result['structure_zones'] = []
# 威科夫分析 —— 按需:include_wyckoff=1
include_wyckoff_param = request.args.get('include_wyckoff', '')
include_wyckoff = str(include_wyckoff_param).lower() in ('1', 'true', 'yes')
if include_wyckoff:
try:
from chanlun.analysis.wyckoff import analyze_wyckoff
wyckoff_lookback = int(request.args.get('wyckoff_lookback', 120))
wyckoff_bins = int(request.args.get('wyckoff_vp_bins', 50))
result['wyckoff'] = analyze_wyckoff(
df,
lookback=max(40, min(wyckoff_lookback, 500)),
vp_bins=max(10, min(wyckoff_bins, 100)),
)
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)
+159
View File
@@ -2199,6 +2199,165 @@ 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;
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)';
const fillLines = 6;
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 }]);
}
}
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
});
});
if (phaseMarkers.length) {
const phSeries = mainChart.addLineSeries({ lastValueVisible: false, priceLineVisible: false });
phSeries.setMarkers(phaseMarkers);
}
}
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);
}
}
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 || [];
let maxVol = 0;
bins.forEach(function(b) { if (b.volume > maxVol) maxVol = b.volume; });
const maxWidthSec = Math.max(60, Math.floor((t1 - parseTs(tr.start_time)) * 0.15));
bins.forEach(function(b) {
if (!b.volume || maxVol <= 0) return;
const wSec = Math.max(1, Math.floor(maxWidthSec * (b.volume / maxVol)));
const alpha = 0.15 + 0.55 * (b.volume / maxVol);
mainChart.addLineSeries({
color: 'rgba(142, 68, 173, ' + alpha.toFixed(2) + ')',
lineWidth: 1,
lastValueVisible: false,
priceLineVisible: false
}).setData([
{ time: t1 - wSec, 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); }
}
// 显示未完成中枢 - 分别处理主周期、次周期和次次周期
if ($('#showMainZs').is(':checked') || $('#showElementZs').is(':checked') || $('#showSubSubZs').is(':checked') || $('#showSubSubBiZs').is(':checked')) {
console.log('绘制未完成中枢 - 已启用');
+2 -1
View File
@@ -62,7 +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_structure_zones: $('#showMainStructureZone').is(':checked') ? 1 : 0,
include_wyckoff: $('#showWyckoff').is(':checked') ? 1 : 0
},
success: function(data) {
// 隐藏加载图标
+20
View File
@@ -93,6 +93,26 @@ $(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 {
updateChartDisplay();
}
});
$(document).on('change', '#showWyckoffRange, #showWyckoffPhases, #showWyckoffEvents, #showWyckoffVP', function() {
updateChartDisplay();
});
$(function() { syncWyckoffSubControls(); });
// 添加趋势显示复选框变更事件(主/元素),变更后刷新主图
$('#showMainTrend').change(function() {
updateChartDisplay();
+20
View File
@@ -972,6 +972,26 @@
<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>
+41 -1
View File
@@ -16,9 +16,19 @@ sys.path.insert(0, str(ROOT / "web"))
from tests.generate_golden import make_ohlcv # noqa: E402
CONTRACT_KEYS = json.loads(
_CONTRACT_DOC = json.loads(
(ROOT / "tests" / "fixtures" / "analyze_contract_keys.json").read_text(encoding="utf-8")
)
CONTRACT_KEYS = (
_CONTRACT_DOC["required"]
if isinstance(_CONTRACT_DOC, dict) and "required" in _CONTRACT_DOC
else _CONTRACT_DOC
)
WYCKOFF_KEYS = (
_CONTRACT_DOC.get("wyckoff_keys", [])
if isinstance(_CONTRACT_DOC, dict)
else []
)
# analyze_chan 直接返回的对象字段(未序列化前)
ANALYZE_CHAN_KEYS = {
@@ -117,3 +127,33 @@ 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
def test_analyze_http_wyckoff_opt_in():
"""include_wyckoff=1 时响应含 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": "5m",
"timezone": "Asia/Shanghai",
"include_wyckoff": 1,
},
)
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}"