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
+6
View File
@@ -0,0 +1,6 @@
"""威科夫分析(启发式):交易区间 / 阶段 / 事件 / Volume Profile。"""
from __future__ import annotations
from .engine import analyze_wyckoff
__all__ = ["analyze_wyckoff"]
+80
View File
@@ -0,0 +1,80 @@
"""威科夫分析入口。"""
from __future__ import annotations
from typing import Any, Dict, Optional
import pandas as pd
from .events import build_phases, detect_bias_and_events
from .range import detect_trading_range
from .volume_profile import compute_volume_profile
def _fmt_time(v) -> Optional[str]:
if v is None:
return None
if hasattr(v, "isoformat"):
try:
return v.isoformat()
except Exception:
pass
return str(v)
def analyze_wyckoff(df: pd.DataFrame, lookback: int = 120, vp_bins: int = 50) -> Dict[str, Any]:
"""
对主周期 OHLCV DataFrame 做威科夫启发式分析。
需要列: open, high, low, close, volume;建议有 date 或 timestamp。
"""
empty = {
"trading_range": None,
"bias": "unknown",
"phases": [],
"events": [],
"volume_profile": {"bins": [], "poc": None, "vah": None, "val": None, "bin_count": vp_bins},
"volume_confirm": {"avg_volume": 0.0, "event_checks": {}},
}
if df is None or len(df) < 30:
return empty
if not all(c in df.columns for c in ("open", "high", "low", "close")):
return empty
work = df.copy()
if "volume" not in work.columns:
work["volume"] = 1.0
tr = detect_trading_range(work, lookback=lookback)
if tr is None:
return empty
bias, events, volume_confirm = detect_bias_and_events(work, tr)
phases = build_phases(work, tr, bias, events)
vp = compute_volume_profile(
work,
int(tr["abs_start_idx"]),
int(tr["abs_end_idx"]),
bin_count=vp_bins,
)
trading_range = {
"start_time": _fmt_time(tr.get("start_time")),
"end_time": _fmt_time(tr.get("end_time")),
"high": float(tr["high"]),
"low": float(tr["low"]),
"mid": float(tr["mid"]),
"active": bool(tr.get("active", True)),
"bars": int(tr.get("bars", 0)),
}
for ev in events:
ev["time"] = _fmt_time(ev.get("time"))
for ph in phases:
ph["start_time"] = _fmt_time(ph.get("start_time"))
ph["end_time"] = _fmt_time(ph.get("end_time"))
return {
"trading_range": trading_range,
"bias": bias,
"phases": phases,
"events": events,
"volume_profile": vp,
"volume_confirm": volume_confirm,
}
+240
View File
@@ -0,0 +1,240 @@
"""威科夫阶段与事件(启发式)。"""
from __future__ import annotations
from typing import Any, Dict, List, Tuple
import numpy as np
import pandas as pd
def _bar_time(df: pd.DataFrame, i: int):
row = df.iloc[i]
if "date" in df.columns and pd.notna(row["date"]):
return row["date"]
if "timestamp" in df.columns:
return row["timestamp"]
return i
def _avg_vol(df: pd.DataFrame, i: int, win: int = 20) -> float:
a = max(0, i - win + 1)
v = df["volume"].astype(float).iloc[a : i + 1]
m = float(v.mean()) if len(v) else 0.0
return m if m > 0 else 1.0
def detect_bias_and_events(
df: pd.DataFrame,
tr: Dict[str, Any],
) -> Tuple[str, List[Dict[str, Any]], Dict[str, Any]]:
"""
返回 bias、events、volume_confirm。
"""
hi = float(tr["high"])
lo = float(tr["low"])
mid = float(tr["mid"])
tol = float(tr.get("tol") or (hi - lo) * 0.05)
s = int(tr["abs_start_idx"])
e = int(tr["abs_end_idx"])
events: List[Dict[str, Any]] = []
# 扫描区间内及之后(含 tail_reserve
scan_end = int(tr.get("abs_scan_end_idx", min(len(df) - 1, e + 15)))
scan_end = min(len(df) - 1, max(scan_end, e))
spring = None
utad = None
sos = None
sod = None # sign of weakness / distribution breakdown
lps = None
lpsy = None
for i in range(s + 2, scan_end + 1):
row = df.iloc[i]
low = float(row["low"])
high = float(row["high"])
close = float(row["close"])
vol = float(row["volume"]) if "volume" in df.columns else 0.0
avg_v = _avg_vol(df, i)
ratio = vol / avg_v if avg_v else 0.0
# Spring: pierce below low then close back above low
if spring is None and low < lo - tol * 0.5 and close >= lo - tol * 0.2:
vol_ok = ratio <= 1.35 or (i + 1 <= scan_end and float(df.iloc[min(i + 1, scan_end)]["volume"]) / avg_v < 1.2)
spring = {
"type": "Spring",
"time": _bar_time(df, i),
"price": low,
"note": "假破下沿后收回",
"volume_ratio": round(ratio, 3),
"volume_ok": bool(vol_ok),
"idx": i,
}
# UTAD: pierce above high then close back below
if utad is None and high > hi + tol * 0.5 and close <= hi + tol * 0.2:
vol_ok = ratio >= 0.8
utad = {
"type": "UTAD",
"time": _bar_time(df, i),
"price": high,
"note": "假破上沿后跌回",
"volume_ratio": round(ratio, 3),
"volume_ok": bool(vol_ok),
"idx": i,
}
# SOS: close above high with volume
if sos is None and close > hi + tol * 0.15:
vol_ok = ratio >= 1.15
sos = {
"type": "SOS",
"time": _bar_time(df, i),
"price": close,
"note": "放量上破交易区间",
"volume_ratio": round(ratio, 3),
"volume_ok": bool(vol_ok),
"idx": i,
}
# SOW / breakdown
if sod is None and close < lo - tol * 0.15:
vol_ok = ratio >= 1.15
sod = {
"type": "SOW",
"time": _bar_time(df, i),
"price": close,
"note": "放量下破交易区间",
"volume_ratio": round(ratio, 3),
"volume_ok": bool(vol_ok),
"idx": i,
}
# LPS after SOS: pullback that holds above mid/high-band with lighter volume
if sos is not None:
si = int(sos["idx"])
for i in range(si + 1, min(len(df), si + 25)):
row = df.iloc[i]
low = float(row["low"])
close = float(row["close"])
vol = float(row["volume"]) if "volume" in df.columns else 0.0
avg_v = _avg_vol(df, i)
ratio = vol / avg_v if avg_v else 0.0
if low >= mid - tol and close >= hi - tol * 2:
vol_ok = ratio <= 1.05
lps = {
"type": "LPS",
"time": _bar_time(df, i),
"price": low,
"note": "突破后缩量回踩不破",
"volume_ratio": round(ratio, 3),
"volume_ok": bool(vol_ok),
"idx": i,
}
break
if sod is not None:
si = int(sod["idx"])
for i in range(si + 1, min(len(df), si + 25)):
row = df.iloc[i]
high = float(row["high"])
close = float(row["close"])
vol = float(row["volume"]) if "volume" in df.columns else 0.0
avg_v = _avg_vol(df, i)
ratio = vol / avg_v if avg_v else 0.0
if high <= mid + tol and close <= lo + tol * 2:
vol_ok = ratio <= 1.05
lpsy = {
"type": "LPSY",
"time": _bar_time(df, i),
"price": high,
"note": "下跌突破后缩量反抽不过",
"volume_ratio": round(ratio, 3),
"volume_ok": bool(vol_ok),
"idx": i,
}
break
for ev in (spring, sos, lps, utad, sod, lpsy):
if ev:
events.append({k: v for k, v in ev.items() if k != "idx"})
# bias
last_c = float(df["close"].iloc[-1])
bias = "unknown"
if sos and (not sod or int(sos.get("idx", 0)) >= int(sod.get("idx", 0))):
bias = "accumulation"
elif sod and (not sos or int(sod.get("idx", 0)) > int(sos.get("idx", 0))):
bias = "distribution"
elif spring and not utad:
bias = "accumulation"
elif utad and not spring:
bias = "distribution"
elif last_c >= mid:
bias = "accumulation"
else:
bias = "distribution"
avg_volume = float(df["volume"].astype(float).iloc[max(0, e - 20) : e + 1].mean()) if "volume" in df.columns else 0.0
volume_confirm = {
"avg_volume": avg_volume,
"event_checks": {ev["type"]: {"volume_ok": ev.get("volume_ok"), "volume_ratio": ev.get("volume_ratio")} for ev in events},
}
return bias, events, volume_confirm
def build_phases(
df: pd.DataFrame,
tr: Dict[str, Any],
bias: str,
events: List[Dict[str, Any]],
) -> List[Dict[str, Any]]:
"""按时间切分 AE 粗阶段。"""
s = int(tr["abs_start_idx"])
e = int(tr["abs_end_idx"])
hi = float(tr["high"])
lo = float(tr["low"])
mid = float(tr["mid"])
tol = float(tr.get("tol") or (hi - lo) * 0.05)
event_idx = {}
for ev in events:
# 找回 idx 近似:按时间匹配
t = ev.get("time")
for i in range(s, min(len(df), e + 20)):
if _bar_time(df, i) == t:
event_idx[ev["type"]] = i
break
# 分段点
a_end = s + max(3, (e - s) // 5)
c_anchor = event_idx.get("Spring") or event_idx.get("UTAD") or (s + (e - s) // 2)
d_anchor = event_idx.get("SOS") or event_idx.get("SOW") or e
e_start = d_anchor
def _lab(phase: str) -> str:
if bias == "distribution":
m = {"A": "A停止上涨", "B": "B筑顶", "C": "C测试", "D": "D派发", "E": "E下跌"}
else:
m = {"A": "A停止下跌", "B": "B筑底", "C": "C测试", "D": "D拉升", "E": "E离开"}
return m.get(phase, phase)
cuts = [
("A", s, a_end),
("B", a_end, max(a_end + 1, c_anchor)),
("C", max(a_end + 1, c_anchor), max(c_anchor + 1, d_anchor)),
("D", max(c_anchor + 1, d_anchor), max(d_anchor + 1, min(len(df) - 1, e_start + max(3, (e - s) // 6)))),
("E", max(d_anchor, e_start), min(len(df) - 1, max(e, e_start + 5))),
]
phases = []
for phase, a, b in cuts:
a = int(np.clip(a, 0, len(df) - 1))
b = int(np.clip(b, a, len(df) - 1))
phases.append(
{
"phase": phase,
"label": _lab(phase),
"start_time": _bar_time(df, a),
"end_time": _bar_time(df, b),
}
)
return phases
+108
View File
@@ -0,0 +1,108 @@
"""交易区间检测:ATR 容差下的近期震荡箱。"""
from __future__ import annotations
from typing import Any, Dict, Optional
import numpy as np
import pandas as pd
def _atr(df: pd.DataFrame, period: int = 14) -> pd.Series:
high = df["high"].astype(float)
low = df["low"].astype(float)
close = df["close"].astype(float)
prev_close = close.shift(1)
tr = pd.concat(
[
(high - low).abs(),
(high - prev_close).abs(),
(low - prev_close).abs(),
],
axis=1,
).max(axis=1)
return tr.rolling(period, min_periods=max(3, period // 2)).mean()
def detect_trading_range(
df: pd.DataFrame,
lookback: int = 120,
min_bars: int = 24,
atr_mult: float = 1.2,
tail_reserve: int = 12,
) -> Optional[Dict[str, Any]]:
"""
在最近 lookback 根内寻找高低点波动受控的连续段作为交易区间。
尾部预留 tail_reserve 根用于事件(Spring/SOS),不参与箱体边界计算。
"""
if df is None or len(df) < min_bars + 5:
return None
work = df.tail(lookback).reset_index(drop=True)
n = len(work)
reserve = min(tail_reserve, max(0, n - min_bars - 2))
core_end = n - reserve if reserve > 0 else n
core = work.iloc[:core_end]
if len(core) < min_bars:
core = work
core_end = n
reserve = 0
atr = _atr(work)
last_atr = float(atr.iloc[core_end - 1]) if atr.notna().iloc[:core_end].any() else float(
(core["high"] - core["low"]).mean()
)
if not np.isfinite(last_atr) or last_atr <= 0:
last_atr = float(core["close"].iloc[-1]) * 0.01
best = None
cn = len(core)
for length in range(min(cn, lookback), min_bars - 1, -4):
seg = core.iloc[-length:]
hi = float(seg["high"].max())
lo = float(seg["low"].min())
width = hi - lo
if width <= 0 or width > last_atr * atr_mult * 3.5:
continue
tol = last_atr * atr_mult * 0.35
near_hi = int((seg["high"] >= hi - tol).sum())
near_lo = int((seg["low"] <= lo + tol).sum())
if near_hi < 2 or near_lo < 2:
continue
inside = ((seg["close"] >= lo - tol) & (seg["close"] <= hi + tol)).mean()
if inside < 0.75:
continue
start_i = cn - length
end_i = cn - 1
mid = (hi + lo) / 2.0
last_c = float(work["close"].iloc[-1])
active = (lo - tol * 1.5) <= last_c <= (hi + tol * 1.5)
best = {
"start_idx": int(start_i),
"end_idx": int(end_i),
"high": hi,
"low": lo,
"mid": mid,
"active": bool(active),
"atr": last_atr,
"tol": tol,
"bars": int(length),
}
break
if best is None:
return None
def _ts(row) -> Any:
if "date" in work.columns and pd.notna(row["date"]):
return row["date"]
if "timestamp" in work.columns:
return row["timestamp"]
return None
best["start_time"] = _ts(work.iloc[best["start_idx"]])
# 区间时间结束取 core 末,事件可落在其后
best["end_time"] = _ts(work.iloc[best["end_idx"]])
offset = len(df) - len(work)
best["abs_start_idx"] = offset + best["start_idx"]
best["abs_end_idx"] = offset + best["end_idx"]
best["abs_scan_end_idx"] = offset + n - 1
return best
@@ -0,0 +1,72 @@
"""区间内 Volume Profile。"""
from __future__ import annotations
from typing import Any, Dict, List
import numpy as np
import pandas as pd
def compute_volume_profile(
df: pd.DataFrame,
start_idx: int,
end_idx: int,
bin_count: int = 50,
value_area_pct: float = 0.70,
) -> Dict[str, Any]:
seg = df.iloc[start_idx : end_idx + 1]
if seg.empty:
return {"bins": [], "poc": None, "vah": None, "val": None, "bin_count": bin_count}
typical = (seg["high"].astype(float) + seg["low"].astype(float) + seg["close"].astype(float)) / 3.0
vol = seg["volume"].astype(float).fillna(0.0)
lo = float(seg["low"].min())
hi = float(seg["high"].max())
if not np.isfinite(lo) or not np.isfinite(hi) or hi <= lo:
mid = float(seg["close"].iloc[-1])
return {
"bins": [{"price": mid, "volume": float(vol.sum())}],
"poc": mid,
"vah": mid,
"val": mid,
"bin_count": 1,
}
edges = np.linspace(lo, hi, bin_count + 1)
# 右开最后一桶闭合
idx = np.clip(np.digitize(typical.values, edges) - 1, 0, bin_count - 1)
vols = np.zeros(bin_count, dtype=float)
for i, v in zip(idx, vol.values):
vols[i] += float(v)
centers = (edges[:-1] + edges[1:]) / 2.0
poc_i = int(np.argmax(vols)) if vols.sum() > 0 else bin_count // 2
poc = float(centers[poc_i])
# Value Area:从 POC 向两侧扩展直到累计 >= value_area_pct
total = float(vols.sum()) or 1.0
target = total * value_area_pct
left = right = poc_i
acc = float(vols[poc_i])
while acc < target and (left > 0 or right < bin_count - 1):
left_v = vols[left - 1] if left > 0 else -1.0
right_v = vols[right + 1] if right < bin_count - 1 else -1.0
if right_v >= left_v and right < bin_count - 1:
right += 1
acc += float(vols[right])
elif left > 0:
left -= 1
acc += float(vols[left])
else:
break
bins: List[Dict[str, float]] = [
{"price": float(centers[i]), "volume": float(vols[i])} for i in range(bin_count)
]
return {
"bins": bins,
"poc": poc,
"vah": float(centers[right]),
"val": float(centers[left]),
"bin_count": bin_count,
}
+5 -3
View File
@@ -19,18 +19,20 @@
## 近期变更 ## 近期变更
- IDEA-002 / `9f1e736`:主站内存泄漏 dispose、首屏单次 analyze、ChanMACD 复用、chan_tv 体验 - IDEA-002 / `9f1e736`:主站内存泄漏 dispose、首屏单次 analyze、ChanMACD 复用、chan_tv 体验
- ECR-002 Draft:拆 `web/services/runtime.py`、加深 analyze 契约 - ECR-002 Reviewed:拆 `web/services/runtime/`、加深 analyze 契约
- ECR-003 Reviewed:主站威科夫叠层(`chanlun/analysis/wyckoff/` + `include_wyckoff`
## 硬约束提醒 ## 硬约束提醒
- `/api/analyze` 字段可增不可删 - `/api/analyze` 字段可增不可删
- 无 ADR 不改笔/段/中枢/买卖点语义 - 无 ADR 不改笔/段/中枢/买卖点语义
- 威科夫为独立叠层(ECR-003);勿借机改缠论算法
- 交易 L2+ → RISK_REVIEW + EXPLive 须 Human - 交易 L2+ → RISK_REVIEW + EXPLive 须 Human
## 已知债务 ## 已知债务
- ~~`runtime.py` 仍过大 → ECR-002~~ **已拆包**(待 CODE_REVIEW
- `chart_tv.js` 单体巨大 → 后续可选 ECR - `chart_tv.js` 单体巨大 → 后续可选 ECR
- analyze 契约已加深(mock HTTP);可再加固定 JSON 快照文件 - analyze 契约已加深(mock HTTP + wyckoff opt-in);可再加固定 JSON 快照文件
- 内存泄漏尚无自动化 heap/监听断言 - 内存泄漏尚无自动化 heap/监听断言
- `macd_config` POST 写本地 global 的历史 quirks(未改) - `macd_config` POST 写本地 global 的历史 quirks(未改)
- 威科夫启发式参数未做 UI 调参
+8 -1
View File
@@ -2,7 +2,14 @@
## Unreleased — 2026-08-06 ## Unreleased — 2026-08-06
### ECR-002L3,待 Review ### ECR-003L2Reviewed
- 新增 `chanlun/analysis/wyckoff/`:交易区间、阶段 AE、Spring/SOS/LPS/UTAD 等事件、区间 VPPOC/VAH/VAL)、量能确认
- `/api/analyze` 按需 `include_wyckoff=1` 返回顶层 `wyckoff`
- 主站「威科夫」开关与 Lightweight 叠层(区间/阶段/事件/VP
- 单测与 analyze 契约 opt-in 断言
### ECR-002L3Reviewed
- 拆分 `web/services/runtime.py` 为包 `web/services/runtime/`state / timeframes / market_data / indicators / analyze / serialize - 拆分 `web/services/runtime.py` 为包 `web/services/runtime/`state / timeframes / market_data / indicators / analyze / serialize
- 加深 analyze 契约测试(mock HTTP + analyze_chan 键集 + serialize JSON - 加深 analyze 契约测试(mock HTTP + analyze_chan 键集 + serialize JSON
+77
View File
@@ -0,0 +1,77 @@
# CODE_REVIEW — ECR-003
**Role:** REVIEWER
**Date:** 2026-08-06
**Scope:** 工作区未提交 ECR-003(相对 `origin/dev` @ `df27b4d`
**Decision:** Approve(带非阻断 Findings;建议合并前勿提交 `.DS_Store`
## Evidence loaded
- `chanlun/analysis/wyckoff/{engine,range,events,volume_profile}.py`
- `web/api/analyze.py``include_wyckoff`
- `web/templates/index.html``chart_view.js``macd_ui.js``chart_tv.js` 威科夫块
- `tests/test_wyckoff.py``web/tests/test_analyze_contract.py`
- ESSECR/PRODUCT/ENG/IMPL/TEST/HANDOFF
## Acceptance ↔ Evidence
| Acceptance | Verdict | Evidence |
|------------|---------|----------|
| `include_wyckoff=1` 返回约定键;默认不强制 | PASS | 契约测试;默认无 `wyckoff` 键 |
| 合成 TR + 事件;VP POC | PASS | `test_wyckoff.py`12 相关套件全绿) |
| 主站可开关绘制 | PASS | 主开关按需拉取;子项本地重绘 |
| golden 不变 | PASS | `test_golden_pipeline` |
| 未改缠论算法 / strategies / chan_tv | PASS | diff 范围核对 |
| ESS 闭环 | PASS | IMPL/TEST/TRACE/CHANGELOG/本文件 |
## 复跑
```text
PYTHONPATH=.:web python -m pytest \
tests/test_wyckoff.py tests/test_golden_pipeline.py \
web/tests/test_analyze_contract.py -q
→ 12 passed
```
## Findings
### Important(不挡 Approve,建议跟进)
1. **交易区间易吞并前置趋势**
`detect_trading_range` 从最长窗口向下搜,合成夹具下 `abs_start_idx=0`,箱体前下跌段被算进 TR。单测只断言「有区间 + 有事件」,未锁定高低/起点。
*建议:* 用「宽度/触边密度」评分取最优段,或要求近端触边;测试断言 `high≈60/low≈40` 与起点靠近箱体。
2. **VP 叠层系列数偏多,可能加压自动刷新内存**
开启 VP 时约每个 bin 一条 `addLineSeries`(默认 ~50),再加区间填充/阶段。与 IDEA-002 内存修复同路径全量重建时放大。
*建议:* 只画非零 bin 或合并为少量 series / histogram;或限制 `vp_bins` 上限到 24。
### Medium
3. **阶段 C–E 在事件扎堆时常退化重叠**
夹具输出中 D/E 起止几乎相同;状态机按事件锚点硬切,缺少最小阶段长度。展示可用,语义偏弱。
4. **`elements_only=true` 仍可能跑威科夫**
威科夫挂在路由末尾,不依赖 `not elements_only`。主站当前不这么发,但契约上奇怪;建议与主周期分析同门闩。
5. **单测断言偏松**
`Spring in types or SOS``abs(poc-50)<2` 对回归保护不足。
### Low
6. 失败时 `wyckoff.error` 回传异常字符串(与结构区 print 风格一致,信息暴露轻微)。
7. 事件 marker 一律 `arrowUp`(跌破类也可 `arrowDown`)。
8. 工作区 `.DS_Store` 脏文件——**勿纳入 commit**。
### No blockers
未发现:契约删键、缠论语义改动、策略/config 改动、未鉴权危险写操作、主站误引 WS。
## Decision
**Approve**
可合并提交(排除 `.DS_Store`)。Important #1/#2 可开后续 L1/L2,不阻塞本 ECR 着陆。
## Next owner
`engineer` / Human — commit(勿含 `.DS_Store`);可选跟进 TR 评分与 VP 绘图优化。
+62
View File
@@ -0,0 +1,62 @@
# ECR-003
**Title:** 主站威科夫分析与图表展示
**Status:** Done (Reviewed)
**Date:** 2026-08-06
**Change Level:** L2
## Change
在主站 `/` 增加威科夫交易区间、阶段(A–E)、关键事件(Spring/SOS/LPS/UTAD 等)、区间内简易 VPPOC/VAH/VAL)与量能确认;按需接入 `/api/analyze`
## Motivation
用户需要在缠论图上叠加威科夫结构解读;与现有结构区语义分离。
## Scope
### Allowed
- 新建 `chanlun/analysis/wyckoff/`
- `/api/analyze` 增加可选 `include_wyckoff` 与响应字段 `wyckoff`(可增不可删既有字段)
- 主站 UI 开关与 Lightweight 绘图
- 单测 + ESS 文档
### Forbidden
- 修改笔/段/中枢/买卖点算法语义
-`config/` / `strategies/`
- `/chan_tv` Study
- Vite/React、主站 WebSocket 实时(另 ECR
## Risk
| Risk | Mitigation |
|------|------------|
| 启发式误标 | 规格写明启发式;UI 可关;单测合成形态 |
| 负载 | 默认关闭,勾选才计算 |
| 与结构区混淆 | 独立开关与字段名 |
## Acceptance Criteria
- [x] `include_wyckoff=1` 返回约定 `wyckoff` 键;默认不强制计算
- [x] 合成 fixture:能检出 TR + 至少一类事件;VP POC 可测
- [x] 主站可开关绘制区间/阶段/事件/VP
- [x] golden 缠论基线不变
- [x] TEST/IMPL/CHANGELOG/TRACEABILITY + CODE_REVIEW
## Rollback
`git revert`;关闭 UI 开关即可无图面影响。
## Risk Review
- `docs/RISK_REVIEW/ECR-003.md` — N/A(展示分析,非 Live 策略)
## Linked
- IDEA: `docs/IDEA/IDEA-004-wyckoff-main.md`
- PRODUCT_SPEC / ENGINEERING_SPEC: 同目录 ECR-003-*
- EXPERIMENT: N/A
- TRACEABILITY: Yes
- CODE_REVIEW: `docs/CODE_REVIEW/ECR-003.md` — Approve
@@ -0,0 +1,46 @@
# ENGINEERING_SPEC — ECR-003
**Status:** Approved
**Date:** 2026-08-06
## Package
`chanlun/analysis/wyckoff/`
- `engine.py``analyze_wyckoff(df) -> dict`
- `range.py` — 交易区间检测(ATR 容差震荡箱)
- `phases.py` — AE 状态机
- `events.py` — Spring/SOS/LPS/UTAD(及 distribution 对称)
- `volume_profile.py` — 区间内分桶 VP
- `__init__.py` — 导出 `analyze_wyckoff`
## API
`GET /api/analyze?include_wyckoff=1``result["wyckoff"]`
```json
{
"trading_range": {"start_time","end_time","high","low","mid","active"},
"bias": "accumulation|distribution|unknown",
"phases": [{"phase","label","start_time","end_time"}],
"events": [{"type","time","price","note","volume_ratio","volume_ok"}],
"volume_profile": {"bins":[{"price","volume"}],"poc","vah","val","bin_count"},
"volume_confirm": {"avg_volume","event_checks":{}}
}
```
默认 `include_wyckoff` 假:可不返回或返回 `null`(实现选:不返回键以减负)。
## Detection heuristics
1. ATR(14) 容差;扫描最近窗口找高低点接近的连续段作为 TR。
2. 阶段:价格在 TR 内相对位置 + 假破/真破时间序。
3. Spring:下破 TR.low 后收回且收盘回到区间内;量能相对均量判断。
4. SOS:收盘站上 TR.high 且放量。
5. LPSSOS 后回踩不破 mid/high 带且缩量。
6. UTAD:上破后跌回区间内(派发)。
7. VPtypical=(H+L+C)/3volume 加权分桶,VA≈70% 围绕 POC。
## Frontend
主站 checkbox + `chart_view` 传参;`chart_tv.js` 绘制。
@@ -0,0 +1,27 @@
# HANDOFF — ECR-003 engineer → reviewer
**Date:** 2026-08-06
**From:** engineer
**To:** reviewer
## Summary
主站威科夫 L2:独立分析包 + 按需 API + Lightweight 叠层。
## Artifacts
- IMPL: `docs/IMPLEMENTATION_REPORT/ECR-003.md`
- TEST: `docs/TEST_REPORT/ECR-003.md`
- SPEC: PRODUCT / ENG `docs/*/ECR-003-wyckoff-main.md`
- RISK: N/A(展示分析)
## How to verify
```bash
PYTHONPATH=.:web python -m pytest \
tests/test_wyckoff.py \
tests/test_golden_pipeline.py \
web/tests/test_analyze_contract.py -q
```
主站勾选「威科夫」→ 区间/阶段/事件/VP 可见。
+26
View File
@@ -0,0 +1,26 @@
# Idea: 主站威科夫分析与图表展示
## Problem
主站仅有缠论叠层与结构价值区,缺少威科夫交易区间、阶段与关键事件的可解释展示。
## Observation
仓库无 Wyckoff 模块;`ChanZone` 是中枢+EMA 聚类,语义不同。主站 Lightweight 已有按需 `include_structure_zones` 模式可复用。
## Hypothesis
独立 `chanlun/analysis/wyckoff` + `/api/analyze?include_wyckoff=1` + 主站开关绘图,可在不碰缠论算法的前提下交付区间/阶段/事件/VP。
## Expected Impact
主站可叠加威科夫结构,辅助研判;与结构区开关并存。
## Change Level Guess
**L2**(新市场结构语义与图面;不改 strategies → EXP N/A
## Next
- [x] ECR-003
- [ ] 实现 + 测试 + Review
+30
View File
@@ -0,0 +1,30 @@
# IMPLEMENTATION_REPORT — ECR-003
**Date:** 2026-08-06
**Status:** Implemented
**Change Level:** L2
## What changed
| Area | Change |
|------|--------|
| Engine | 新建 `chanlun/analysis/wyckoff/`:交易区间、AE 阶段、Spring/SOS/LPS/UTAD/SOW/LPSY、区间 VPPOC/VAH/VAL)、量能确认 |
| API | `/api/analyze` 按需 `include_wyckoff=1` 返回顶层 `wyckoff`;默认可不计算 |
| Contract | `analyze_contract_keys.json` 扩展为 required + optional_when |
| UI | 主站「威科夫」及子项开关;Lightweight 绘制区间/阶段/事件/VP |
| Tests | `tests/test_wyckoff.py`;契约 HTTP opt-in |
## Compatibility
- 缠论算法与 golden 基线未改
- `/api/analyze` 既有字段未删;`wyckoff` 仅 opt-in
- 未改 `config/` / `strategies/`;未改 `/chan_tv`
## Tests
`docs/TEST_REPORT/ECR-003.md`
## Follow-ups
- CODE_REVIEW Approve
- 启发式参数(ATR 容差、lookback)后续可调
+24
View File
@@ -0,0 +1,24 @@
# PRODUCT_SPEC — ECR-003
**Status:** Approved
**Date:** 2026-08-06
## Goal
主站用户可在主周期图上开关查看威科夫:**交易区间、阶段、事件、Volume ProfilePOC/VAH/VAL)与事件量能确认**。
## User stories
1. 勾选「威科夫」后重新分析,图上出现交易区间框。
2. 可见阶段分段/标签(Accumulation/Distribution + AE)。
3. 可见 Spring / SOS / LPS / UTAD(及派发对称事件)标记。
4. 可选 VP 水平密度与 POC/VAH/VAL 线。
5. 取消勾选后不再请求威科夫计算(或仅隐藏叠层)。
## Non-goals
- chan_tv、策略下单、订单流 footprint。
## Success
人工可在合成/实盘图上辨认区间与事件;自动化单测覆盖核心检出。
+3 -2
View File
@@ -25,6 +25,7 @@ Trading System(缠论分析引擎 + 可视化 Web;Freqtrade 策略目录独
- 无 ECR 破坏 `/api/analyze` JSON 契约(可增不可删) - 无 ECR 破坏 `/api/analyze` JSON 契约(可增不可删)
- 引入 Kafka / MongoDB / 微服务拆分(除非新 ADR) - 引入 Kafka / MongoDB / 微服务拆分(除非新 ADR)
- 本轮引入 Vite/React/TS 构建流水线 - 本轮引入 Vite/React/TS 构建流水线
- 威科夫等**独立分析叠层**须走 ECR(可增 API 字段);不得借机改缠论算法
## Versioning ## Versioning
@@ -33,8 +34,8 @@ Trading System(缠论分析引擎 + 可视化 Web;Freqtrade 策略目录独
## Active anchors ## Active anchors
- ECR: ECR-001 ReleasedECR-002 Draft - ECR: ECR-002 / ECR-003 Reviewed(主站威科夫)
- EXP: N/A(当前无进行中的交易行为实验) - EXP: N/A
- TRACEABILITY: `docs/TRACEABILITY.md` - TRACEABILITY: `docs/TRACEABILITY.md`
- Memory: `docs/AGENT_MEMORY.md` - Memory: `docs/AGENT_MEMORY.md`
+8
View File
@@ -0,0 +1,8 @@
# RISK_REVIEW — ECR-003
**Status:** N/A
**Date:** 2026-08-06
展示用威科夫分析叠层,不改 Freqtrade 策略或 Live 下单。启发式误标风险由 UI 开关与文档说明缓解。
**Conclusion:** N/A(非交易执行变更)
+6 -6
View File
@@ -1,8 +1,8 @@
# STATE # STATE
**owner:** idle **owner:** idle
**active_ecr:** noneECR-002 Reviewed;待合并提交) **active_ecr:** none
**phase:** post-review **phase:** idle
**system_version:** v1.0.0 **system_version:** v1.0.0
**strategy_version:** unchanged **strategy_version:** unchanged
**updated:** 2026-08-06 **updated:** 2026-08-06
@@ -13,10 +13,10 @@
|----|-------|--------|------| |----|-------|--------|------|
| ECR-001 | L3 | Released `v1.0.0` | | | ECR-001 | L3 | Released `v1.0.0` | |
| IDEA-002 | L1 | Done | `9f1e736` | | IDEA-002 | L1 | Done | `9f1e736` |
| ECR-002 | L3 | Done (Reviewed) | runtime 包拆分;见 `docs/CODE_REVIEW/ECR-002.md` | | ECR-002 | L3 | Done (Reviewed) | runtime 包拆分 |
| ECR-003 | L2 | Done (Reviewed) | 主站威科夫;待本提交合入 |
## Notes ## Notes
- CODE_REVIEW**Approve**13 passed;非阻断项见 review Findings - ECR-003 CODE_REVIEW**Approve**Findings 另开 ECR-004
- 工作区仍有未提交实现;合并后可清 active_ecr - 未请求新 system tag(仍 Unreleased 文档累计)
- 未请求新 system tag
+7
View File
@@ -0,0 +1,7 @@
task_id: ECR-003
title: 主站威科夫分析与图表展示
status: done_reviewed
change_level: L2
ecr: docs/ECR/ECR-003-wyckoff-main.md
code_review: docs/CODE_REVIEW/ECR-003.md
notes: Main site only; independent of ChanZone. Approve 2026-08-06.
+29
View File
@@ -0,0 +1,29 @@
# TEST_REPORT — ECR-003
**Date:** 2026-08-06
**Level:** L2
## Command
```bash
PYTHONPATH=.:web python -m pytest \
tests/test_wyckoff.py \
tests/test_golden_pipeline.py \
web/tests/test_analyze_contract.py \
-q
```
## Result
**12 passed**
| Suite | Coverage |
|-------|----------|
| `test_wyckoff` | 合成箱体 TR + 事件;VP POC |
| golden / package / shim / contract keys file | 缠论基线 + 契约文档含 wyckoff optional |
| `test_analyze_contract` | 默认无 `wyckoff``include_wyckoff=1` 含约定键 |
## Notes
- 主站 UI 绘图无自动化;人工勾选「威科夫」验证叠层。
- 未改 golden JSON 内容。
+9
View File
@@ -27,3 +27,12 @@
| ECR-002 | 加深 analyze 契约 | ENG-002 | `web/tests/test_analyze_contract.py` | mock HTTP + 键快照 | | ECR-002 | 加深 analyze 契约 | ENG-002 | `web/tests/test_analyze_contract.py` | mock HTTP + 键快照 |
| ECR-002 | TF_DF 全量 init 冒烟 | ENG-002 | — | `tests/test_tf_df_init.py` | | ECR-002 | TF_DF 全量 init 冒烟 | ENG-002 | — | `tests/test_tf_df_init.py` |
| ECR-002 | chart_tv 拆分(可选) | ENG-002 | 未做 | — | | ECR-002 | chart_tv 拆分(可选) | ENG-002 | 未做 | — |
## ECR-003
| ECR | Requirement | Spec | Code | Test |
|-----|-------------|------|------|------|
| ECR-003 | 威科夫引擎(区间/阶段/事件/VP | ENG-003 | `chanlun/analysis/wyckoff/` | `tests/test_wyckoff.py` |
| ECR-003 | analyze 按需 `include_wyckoff` | ENG-003 | `web/api/analyze.py` | `test_analyze_http_wyckoff_opt_in` |
| ECR-003 | 主站 Lightweight 叠层 | PRODUCT-003 | `index.html` `chart_tv.js` `chart_view.js` | 人工 + 开关接线 |
| ECR-003 | 契约可选键文档 | ENG-003 | `analyze_contract_keys.json` | golden keys file 断言 |
+31 -17
View File
@@ -1,17 +1,31 @@
[ {
"bi_list", "required": [
"bi_zs_list", "bi_list",
"bsp_list", "bi_zs_list",
"chan_macd", "bsp_list",
"klc_fx_info", "chan_macd",
"klc_list", "klc_fx_info",
"klc_trend", "klc_list",
"kline_data", "klc_trend",
"macd", "kline_data",
"seg_list", "macd",
"timezone", "seg_list",
"uncompleted_bi_list", "timezone",
"uncompleted_seg_list", "uncompleted_bi_list",
"uncompleted_zs_list", "uncompleted_seg_list",
"zs_list" "uncompleted_zs_list",
] "zs_list"
],
"optional_when": {
"include_structure_zones": ["structure_zones"],
"include_wyckoff": ["wyckoff"]
},
"wyckoff_keys": [
"trading_range",
"bias",
"phases",
"events",
"volume_profile",
"volume_confirm"
]
}
+34 -20
View File
@@ -128,27 +128,41 @@ def serialize_pipeline(tf) -> dict:
} }
def analyze_contract_keys() -> list: def analyze_contract_keys() -> dict:
"""文档化 /api/analyze 主周期关键字段(契约冒烟用)。""" """文档化 /api/analyze 主周期关键字段(契约冒烟用)。"""
return sorted( return {
[ "required": sorted(
"timezone", [
"kline_data", "timezone",
"klc_list", "kline_data",
"bi_list", "klc_list",
"uncompleted_bi_list", "bi_list",
"seg_list", "uncompleted_bi_list",
"uncompleted_seg_list", "seg_list",
"zs_list", "uncompleted_seg_list",
"uncompleted_zs_list", "zs_list",
"bi_zs_list", "uncompleted_zs_list",
"bsp_list", "bi_zs_list",
"klc_fx_info", "bsp_list",
"macd", "klc_fx_info",
"chan_macd", "macd",
"klc_trend", "chan_macd",
] "klc_trend",
) ]
),
"optional_when": {
"include_structure_zones": ["structure_zones"],
"include_wyckoff": ["wyckoff"],
},
"wyckoff_keys": [
"trading_range",
"bias",
"phases",
"events",
"volume_profile",
"volume_confirm",
],
}
def run_pipeline(df: pd.DataFrame): def run_pipeline(df: pd.DataFrame):
+6 -1
View File
@@ -37,10 +37,15 @@ def test_compat_shim_still_works():
def test_analyze_contract_keys_file(): def test_analyze_contract_keys_file():
keys = json.loads( doc = json.loads(
(ROOT / "tests" / "fixtures" / "analyze_contract_keys.json").read_text( (ROOT / "tests" / "fixtures" / "analyze_contract_keys.json").read_text(
encoding="utf-8" encoding="utf-8"
) )
) )
keys = doc["required"] if isinstance(doc, dict) and "required" in doc else doc
for k in ("kline_data", "bi_list", "seg_list", "zs_list", "bsp_list"): for k in ("kline_data", "bi_list", "seg_list", "zs_list", "bsp_list"):
assert k in keys assert k in keys
if isinstance(doc, dict):
assert "include_wyckoff" in doc.get("optional_when", {})
for k in ("trading_range", "phases", "events", "volume_profile"):
assert k in doc.get("wyckoff_keys", [])
+117
View File
@@ -0,0 +1,117 @@
"""威科夫引擎单测:合成震荡箱 + Spring/SOS + VP POC。"""
from __future__ import annotations
import sys
from pathlib import Path
import numpy as np
import pandas as pd
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT))
from chanlun.analysis.wyckoff import analyze_wyckoff # noqa: E402
def _box_df(n_box: int = 60, spring: bool = True, sos: bool = True) -> pd.DataFrame:
"""构造明显箱体:40~60,可选假破与上破。"""
rng = np.random.default_rng(7)
rows = []
t0 = pd.Timestamp("2024-06-01", tz="UTC")
price = 50.0
# 进入箱体前下跌
for i in range(20):
price -= 0.3 + rng.random() * 0.1
o, c = price + 0.2, price
h, l = max(o, c) + 0.15, min(o, c) - 0.15
rows.append((t0 + pd.Timedelta(minutes=5 * i), o, h, l, c, 100 + rng.random() * 20))
# 箱体 40-60
lo, hi = 40.0, 60.0
for i in range(n_box):
c = lo + (hi - lo) * (0.3 + 0.4 * rng.random())
o = c + rng.normal(0, 0.5)
h = min(hi + 0.5, max(o, c) + abs(rng.normal(0.5, 0.2)))
l = max(lo - 0.5, min(o, c) - abs(rng.normal(0.5, 0.2)))
# 触及边界
if i % 7 == 0:
h = hi - 0.1
if i % 7 == 3:
l = lo + 0.1
rows.append(
(
t0 + pd.Timedelta(minutes=5 * (20 + i)),
o,
h,
l,
c,
80 + rng.random() * 40,
)
)
base = 20 + n_box
if spring:
# 假破下沿
rows.append(
(
t0 + pd.Timedelta(minutes=5 * base),
42.0,
43.0,
37.0,
41.5,
90.0,
)
)
base += 1
if sos:
rows.append(
(
t0 + pd.Timedelta(minutes=5 * base),
58.0,
66.0,
57.0,
64.0,
220.0,
)
)
base += 1
# LPS 缩量回踩
rows.append(
(
t0 + pd.Timedelta(minutes=5 * base),
62.0,
63.0,
59.5,
61.0,
70.0,
)
)
df = pd.DataFrame(rows, columns=["date", "open", "high", "low", "close", "volume"])
return df
def test_wyckoff_detects_range_and_events():
df = _box_df()
out = analyze_wyckoff(df, lookback=200)
assert out["trading_range"] is not None
tr = out["trading_range"]
assert tr["high"] > tr["low"]
types = {e["type"] for e in out["events"]}
assert "Spring" in types or "SOS" in types
assert out["bias"] in ("accumulation", "distribution", "unknown")
assert len(out["phases"]) >= 3
def test_volume_profile_poc_on_heavy_bin():
# 平坦箱 + 中间价放量
dates = pd.date_range("2024-01-01", periods=40, freq="5min", tz="UTC")
rows = []
for i, d in enumerate(dates):
c = 50.0 + (i % 5) * 0.1
vol = 1000.0 if 49.8 <= c <= 50.2 else 10.0
rows.append((d, c, c + 0.2, c - 0.2, c, vol))
df = pd.DataFrame(rows, columns=["date", "open", "high", "low", "close", "volume"])
out = analyze_wyckoff(df, lookback=80, vp_bins=20)
vp = out["volume_profile"]
assert vp["poc"] is not None
assert vp["vah"] is not None and vp["val"] is not None
assert abs(vp["poc"] - 50.0) < 2.0
+27
View File
@@ -656,5 +656,32 @@ def analyze():
else: else:
result['structure_zones'] = [] 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) return jsonify(result)
+159
View File
@@ -2199,6 +2199,165 @@ function initTradingView(symbol, timeframe) {
} }
} catch (e) { console.error('结构区整体绘制出错:', e); } } 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')) { if ($('#showMainZs').is(':checked') || $('#showElementZs').is(':checked') || $('#showSubSubZs').is(':checked') || $('#showSubSubBiZs').is(':checked')) {
console.log('绘制未完成中枢 - 已启用'); console.log('绘制未完成中枢 - 已启用');
+2 -1
View File
@@ -62,7 +62,8 @@ function updateChart(options) {
end_time: endTimeMs, end_time: endTimeMs,
elements_only: false, elements_only: false,
zone_kl_lines: parseInt($('#zoneKlLines').val()) || 1000, 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) { 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() { $('#showMainTrend').change(function() {
updateChartDisplay(); updateChartDisplay();
+20
View File
@@ -972,6 +972,26 @@
<label class="form-check-label" for="showMainStructureZone">结构区</label> <label class="form-check-label" for="showMainStructureZone">结构区</label>
</div> </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线数量"> <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> <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 id="refreshLoadingSpinner" class="loading-spinner ms-2" style="display:none;"></div>
</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 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") (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 直接返回的对象字段(未序列化前)
ANALYZE_CHAN_KEYS = { 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 assert payload is not None and "error" not in payload
missing = [k for k in CONTRACT_KEYS if k not in payload] missing = [k for k in CONTRACT_KEYS if k not in payload]
assert not missing, f"missing contract keys: {missing}" 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}"