feat: ECR-003 主站威科夫分析与图表叠层(已审)
独立 wyckoff 引擎 + 按需 include_wyckoff;主站 Lightweight 绘制区间/阶段/事件/VP。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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)
|
||||
|
||||
|
||||
@@ -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('绘制未完成中枢 - 已启用');
|
||||
|
||||
@@ -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) {
|
||||
// 隐藏加载图标
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}"
|
||||
|
||||
Reference in New Issue
Block a user