diff --git a/chanlun/analysis/wyckoff/__init__.py b/chanlun/analysis/wyckoff/__init__.py new file mode 100644 index 0000000..545f61a --- /dev/null +++ b/chanlun/analysis/wyckoff/__init__.py @@ -0,0 +1,6 @@ +"""威科夫分析(启发式):交易区间 / 阶段 / 事件 / Volume Profile。""" +from __future__ import annotations + +from .engine import analyze_wyckoff + +__all__ = ["analyze_wyckoff"] diff --git a/chanlun/analysis/wyckoff/engine.py b/chanlun/analysis/wyckoff/engine.py new file mode 100644 index 0000000..ce45e5e --- /dev/null +++ b/chanlun/analysis/wyckoff/engine.py @@ -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, + } diff --git a/chanlun/analysis/wyckoff/events.py b/chanlun/analysis/wyckoff/events.py new file mode 100644 index 0000000..e708b0f --- /dev/null +++ b/chanlun/analysis/wyckoff/events.py @@ -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]]: + """按时间切分 A–E 粗阶段。""" + 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 diff --git a/chanlun/analysis/wyckoff/range.py b/chanlun/analysis/wyckoff/range.py new file mode 100644 index 0000000..5ec9e7d --- /dev/null +++ b/chanlun/analysis/wyckoff/range.py @@ -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 diff --git a/chanlun/analysis/wyckoff/volume_profile.py b/chanlun/analysis/wyckoff/volume_profile.py new file mode 100644 index 0000000..5e31485 --- /dev/null +++ b/chanlun/analysis/wyckoff/volume_profile.py @@ -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, + } diff --git a/docs/AGENT_MEMORY.md b/docs/AGENT_MEMORY.md index ef8095d..9a36a35 100644 --- a/docs/AGENT_MEMORY.md +++ b/docs/AGENT_MEMORY.md @@ -19,18 +19,20 @@ ## 近期变更 - 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` 字段可增不可删 - 无 ADR 不改笔/段/中枢/买卖点语义 +- 威科夫为独立叠层(ECR-003);勿借机改缠论算法 - 交易 L2+ → RISK_REVIEW + EXP;Live 须 Human ## 已知债务 -- ~~`runtime.py` 仍过大 → ECR-002~~ **已拆包**(待 CODE_REVIEW) - `chart_tv.js` 单体巨大 → 后续可选 ECR -- analyze 契约已加深(mock HTTP);可再加固定 JSON 快照文件 +- analyze 契约已加深(mock HTTP + wyckoff opt-in);可再加固定 JSON 快照文件 - 内存泄漏尚无自动化 heap/监听断言 - `macd_config` POST 写本地 global 的历史 quirks(未改) +- 威科夫启发式参数未做 UI 调参 diff --git a/docs/CHANGELOG/CHANGELOG.md b/docs/CHANGELOG/CHANGELOG.md index 4733b54..0412446 100644 --- a/docs/CHANGELOG/CHANGELOG.md +++ b/docs/CHANGELOG/CHANGELOG.md @@ -2,7 +2,14 @@ ## Unreleased — 2026-08-06 -### ECR-002(L3,待 Review) +### ECR-003(L2,Reviewed) + +- 新增 `chanlun/analysis/wyckoff/`:交易区间、阶段 A–E、Spring/SOS/LPS/UTAD 等事件、区间 VP(POC/VAH/VAL)、量能确认 +- `/api/analyze` 按需 `include_wyckoff=1` 返回顶层 `wyckoff` +- 主站「威科夫」开关与 Lightweight 叠层(区间/阶段/事件/VP) +- 单测与 analyze 契约 opt-in 断言 + +### ECR-002(L3,Reviewed) - 拆分 `web/services/runtime.py` 为包 `web/services/runtime/`(state / timeframes / market_data / indicators / analyze / serialize) - 加深 analyze 契约测试(mock HTTP + analyze_chan 键集 + serialize JSON) diff --git a/docs/CODE_REVIEW/ECR-003.md b/docs/CODE_REVIEW/ECR-003.md new file mode 100644 index 0000000..5549ba7 --- /dev/null +++ b/docs/CODE_REVIEW/ECR-003.md @@ -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` +- ESS:ECR/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 绘图优化。 diff --git a/docs/ECR/ECR-003-wyckoff-main.md b/docs/ECR/ECR-003-wyckoff-main.md new file mode 100644 index 0000000..113cd5e --- /dev/null +++ b/docs/ECR/ECR-003-wyckoff-main.md @@ -0,0 +1,62 @@ +# ECR-003 + +**Title:** 主站威科夫分析与图表展示 +**Status:** Done (Reviewed) +**Date:** 2026-08-06 +**Change Level:** L2 + +## Change + +在主站 `/` 增加威科夫交易区间、阶段(A–E)、关键事件(Spring/SOS/LPS/UTAD 等)、区间内简易 VP(POC/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 diff --git a/docs/ENGINEERING_SPEC/ECR-003-wyckoff-main.md b/docs/ENGINEERING_SPEC/ECR-003-wyckoff-main.md new file mode 100644 index 0000000..65467a2 --- /dev/null +++ b/docs/ENGINEERING_SPEC/ECR-003-wyckoff-main.md @@ -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` — A–E 状态机 +- `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. LPS:SOS 后回踩不破 mid/high 带且缩量。 +6. UTAD:上破后跌回区间内(派发)。 +7. VP:typical=(H+L+C)/3,volume 加权分桶,VA≈70% 围绕 POC。 + +## Frontend + +主站 checkbox + `chart_view` 传参;`chart_tv.js` 绘制。 diff --git a/docs/HANDOFF/ECR-003-engineer-to-reviewer.md b/docs/HANDOFF/ECR-003-engineer-to-reviewer.md new file mode 100644 index 0000000..cf85257 --- /dev/null +++ b/docs/HANDOFF/ECR-003-engineer-to-reviewer.md @@ -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 可见。 diff --git a/docs/IDEA/IDEA-004-wyckoff-main.md b/docs/IDEA/IDEA-004-wyckoff-main.md new file mode 100644 index 0000000..8357268 --- /dev/null +++ b/docs/IDEA/IDEA-004-wyckoff-main.md @@ -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 diff --git a/docs/IMPLEMENTATION_REPORT/ECR-003.md b/docs/IMPLEMENTATION_REPORT/ECR-003.md new file mode 100644 index 0000000..a95c750 --- /dev/null +++ b/docs/IMPLEMENTATION_REPORT/ECR-003.md @@ -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/`:交易区间、A–E 阶段、Spring/SOS/LPS/UTAD/SOW/LPSY、区间 VP(POC/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)后续可调 diff --git a/docs/PRODUCT_SPEC/ECR-003-wyckoff-main.md b/docs/PRODUCT_SPEC/ECR-003-wyckoff-main.md new file mode 100644 index 0000000..f57d452 --- /dev/null +++ b/docs/PRODUCT_SPEC/ECR-003-wyckoff-main.md @@ -0,0 +1,24 @@ +# PRODUCT_SPEC — ECR-003 + +**Status:** Approved +**Date:** 2026-08-06 + +## Goal + +主站用户可在主周期图上开关查看威科夫:**交易区间、阶段、事件、Volume Profile(POC/VAH/VAL)与事件量能确认**。 + +## User stories + +1. 勾选「威科夫」后重新分析,图上出现交易区间框。 +2. 可见阶段分段/标签(Accumulation/Distribution + A–E)。 +3. 可见 Spring / SOS / LPS / UTAD(及派发对称事件)标记。 +4. 可选 VP 水平密度与 POC/VAH/VAL 线。 +5. 取消勾选后不再请求威科夫计算(或仅隐藏叠层)。 + +## Non-goals + +- chan_tv、策略下单、订单流 footprint。 + +## Success + +人工可在合成/实盘图上辨认区间与事件;自动化单测覆盖核心检出。 diff --git a/docs/PROJECT_PROFILE.md b/docs/PROJECT_PROFILE.md index c66f632..41883a6 100644 --- a/docs/PROJECT_PROFILE.md +++ b/docs/PROJECT_PROFILE.md @@ -25,6 +25,7 @@ Trading System(缠论分析引擎 + 可视化 Web;Freqtrade 策略目录独 - 无 ECR 破坏 `/api/analyze` JSON 契约(可增不可删) - 引入 Kafka / MongoDB / 微服务拆分(除非新 ADR) - 本轮引入 Vite/React/TS 构建流水线 +- 威科夫等**独立分析叠层**须走 ECR(可增 API 字段);不得借机改缠论算法 ## Versioning @@ -33,8 +34,8 @@ Trading System(缠论分析引擎 + 可视化 Web;Freqtrade 策略目录独 ## Active anchors -- ECR: ECR-001 Released;ECR-002 Draft -- EXP: N/A(当前无进行中的交易行为实验) +- ECR: ECR-002 / ECR-003 Reviewed(主站威科夫) +- EXP: N/A - TRACEABILITY: `docs/TRACEABILITY.md` - Memory: `docs/AGENT_MEMORY.md` diff --git a/docs/RISK_REVIEW/ECR-003.md b/docs/RISK_REVIEW/ECR-003.md new file mode 100644 index 0000000..53e0123 --- /dev/null +++ b/docs/RISK_REVIEW/ECR-003.md @@ -0,0 +1,8 @@ +# RISK_REVIEW — ECR-003 + +**Status:** N/A +**Date:** 2026-08-06 + +展示用威科夫分析叠层,不改 Freqtrade 策略或 Live 下单。启发式误标风险由 UI 开关与文档说明缓解。 + +**Conclusion:** N/A(非交易执行变更) diff --git a/docs/STATE/CURRENT.md b/docs/STATE/CURRENT.md index 99fce1e..c1b6c0c 100644 --- a/docs/STATE/CURRENT.md +++ b/docs/STATE/CURRENT.md @@ -1,8 +1,8 @@ # STATE **owner:** idle -**active_ecr:** none(ECR-002 Reviewed;待合并提交) -**phase:** post-review +**active_ecr:** none +**phase:** idle **system_version:** v1.0.0 **strategy_version:** unchanged **updated:** 2026-08-06 @@ -13,10 +13,10 @@ |----|-------|--------|------| | ECR-001 | L3 | Released `v1.0.0` | | | 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 -- CODE_REVIEW:**Approve**(13 passed;非阻断项见 review Findings) -- 工作区仍有未提交实现;合并后可清 active_ecr -- 未请求新 system tag +- ECR-003 CODE_REVIEW:**Approve**;Findings 另开 ECR-004 +- 未请求新 system tag(仍 Unreleased 文档累计) diff --git a/docs/TASKS/TASK-003-ECR003.yaml b/docs/TASKS/TASK-003-ECR003.yaml new file mode 100644 index 0000000..8f2b770 --- /dev/null +++ b/docs/TASKS/TASK-003-ECR003.yaml @@ -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. diff --git a/docs/TEST_REPORT/ECR-003.md b/docs/TEST_REPORT/ECR-003.md new file mode 100644 index 0000000..c4135be --- /dev/null +++ b/docs/TEST_REPORT/ECR-003.md @@ -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 内容。 diff --git a/docs/TRACEABILITY.md b/docs/TRACEABILITY.md index 2213f71..d47da5b 100644 --- a/docs/TRACEABILITY.md +++ b/docs/TRACEABILITY.md @@ -27,3 +27,12 @@ | 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 | 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 断言 | diff --git a/tests/fixtures/analyze_contract_keys.json b/tests/fixtures/analyze_contract_keys.json index e2f11f4..c801735 100644 --- a/tests/fixtures/analyze_contract_keys.json +++ b/tests/fixtures/analyze_contract_keys.json @@ -1,17 +1,31 @@ -[ - "bi_list", - "bi_zs_list", - "bsp_list", - "chan_macd", - "klc_fx_info", - "klc_list", - "klc_trend", - "kline_data", - "macd", - "seg_list", - "timezone", - "uncompleted_bi_list", - "uncompleted_seg_list", - "uncompleted_zs_list", - "zs_list" -] \ No newline at end of file +{ + "required": [ + "bi_list", + "bi_zs_list", + "bsp_list", + "chan_macd", + "klc_fx_info", + "klc_list", + "klc_trend", + "kline_data", + "macd", + "seg_list", + "timezone", + "uncompleted_bi_list", + "uncompleted_seg_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" + ] +} diff --git a/tests/generate_golden.py b/tests/generate_golden.py index debd113..8a09a98 100644 --- a/tests/generate_golden.py +++ b/tests/generate_golden.py @@ -128,27 +128,41 @@ def serialize_pipeline(tf) -> dict: } -def analyze_contract_keys() -> list: +def analyze_contract_keys() -> dict: """文档化 /api/analyze 主周期关键字段(契约冒烟用)。""" - return sorted( - [ - "timezone", - "kline_data", - "klc_list", - "bi_list", - "uncompleted_bi_list", - "seg_list", - "uncompleted_seg_list", - "zs_list", - "uncompleted_zs_list", - "bi_zs_list", - "bsp_list", - "klc_fx_info", - "macd", - "chan_macd", - "klc_trend", - ] - ) + return { + "required": sorted( + [ + "timezone", + "kline_data", + "klc_list", + "bi_list", + "uncompleted_bi_list", + "seg_list", + "uncompleted_seg_list", + "zs_list", + "uncompleted_zs_list", + "bi_zs_list", + "bsp_list", + "klc_fx_info", + "macd", + "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): diff --git a/tests/test_golden_pipeline.py b/tests/test_golden_pipeline.py index 8f94d36..9bbb1bd 100644 --- a/tests/test_golden_pipeline.py +++ b/tests/test_golden_pipeline.py @@ -37,10 +37,15 @@ def test_compat_shim_still_works(): def test_analyze_contract_keys_file(): - keys = json.loads( + doc = json.loads( (ROOT / "tests" / "fixtures" / "analyze_contract_keys.json").read_text( 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"): 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", []) diff --git a/tests/test_wyckoff.py b/tests/test_wyckoff.py new file mode 100644 index 0000000..233e46a --- /dev/null +++ b/tests/test_wyckoff.py @@ -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 diff --git a/web/api/analyze.py b/web/api/analyze.py index 3660f1f..49eab60 100644 --- a/web/api/analyze.py +++ b/web/api/analyze.py @@ -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) diff --git a/web/static/js/app/chart_tv.js b/web/static/js/app/chart_tv.js index 25284ef..fcfbaba 100644 --- a/web/static/js/app/chart_tv.js +++ b/web/static/js/app/chart_tv.js @@ -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('绘制未完成中枢 - 已启用'); diff --git a/web/static/js/app/chart_view.js b/web/static/js/app/chart_view.js index d546353..97c0d96 100644 --- a/web/static/js/app/chart_view.js +++ b/web/static/js/app/chart_view.js @@ -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) { // 隐藏加载图标 diff --git a/web/static/js/app/macd_ui.js b/web/static/js/app/macd_ui.js index 326bc78..f32f491 100644 --- a/web/static/js/app/macd_ui.js +++ b/web/static/js/app/macd_ui.js @@ -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(); diff --git a/web/templates/index.html b/web/templates/index.html index cbe16ef..20df06f 100644 --- a/web/templates/index.html +++ b/web/templates/index.html @@ -972,6 +972,26 @@ +
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
diff --git a/web/tests/test_analyze_contract.py b/web/tests/test_analyze_contract.py index 9d5f44c..18058a6 100644 --- a/web/tests/test_analyze_contract.py +++ b/web/tests/test_analyze_contract.py @@ -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}"