From d3188ca83ced13a1ed45053c3494a319e6fc149d Mon Sep 17 00:00:00 2001 From: jackyu66git Date: Thu, 6 Aug 2026 18:46:08 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20ECR-004=20=E5=A8=81=E7=A7=91=E5=A4=AB?= =?UTF-8?q?=E5=8C=BA=E9=97=B4=E8=AF=84=E5=88=86=E7=A1=AC=E5=8C=96=E4=B8=8E?= =?UTF-8?q?=20VP=20=E7=BB=98=E5=9B=BE=E5=87=8F=E8=B4=9F=EF=BC=88=E5=B7=B2?= =?UTF-8?q?=E5=AE=A1=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 评分选 TR、阶段最小跨度、elements_only 门闩、Top-8 VP;无币种独立参数。 Co-authored-by: Cursor --- chanlun/analysis/wyckoff/events.py | 43 +++++++++++-------- chanlun/analysis/wyckoff/range.py | 26 +++++++++-- docs/AGENT_MEMORY.md | 2 +- docs/CHANGELOG/CHANGELOG.md | 6 ++- docs/CODE_REVIEW/ECR-004.md | 43 +++++++++++++++++++ docs/ECR/ECR-004-wyckoff-harden.md | 25 +++++++---- .../ECR-004-wyckoff-harden.md | 40 ++++++++--------- docs/HANDOFF/ECR-004-engineer-to-reviewer.md | 5 +++ docs/IMPLEMENTATION_REPORT/ECR-004.md | 24 +++++++++++ docs/PRODUCT_SPEC/ECR-004-wyckoff-harden.md | 2 +- docs/PROJECT_PROFILE.md | 2 +- docs/STATE/CURRENT.md | 11 +++-- docs/TASKS/TASK-004-ECR004.yaml | 5 ++- docs/TEST_REPORT/ECR-004.md | 29 +++++++++++++ docs/TRACEABILITY.md | 8 ++-- tests/test_wyckoff.py | 33 +++++++++----- web/api/analyze.py | 9 ++-- web/static/js/app/chart_tv.js | 22 +++++++--- web/tests/test_analyze_contract.py | 29 +++++++++++++ 19 files changed, 275 insertions(+), 89 deletions(-) create mode 100644 docs/CODE_REVIEW/ECR-004.md create mode 100644 docs/HANDOFF/ECR-004-engineer-to-reviewer.md create mode 100644 docs/IMPLEMENTATION_REPORT/ECR-004.md create mode 100644 docs/TEST_REPORT/ECR-004.md diff --git a/chanlun/analysis/wyckoff/events.py b/chanlun/analysis/wyckoff/events.py index e708b0f..615bb70 100644 --- a/chanlun/analysis/wyckoff/events.py +++ b/chanlun/analysis/wyckoff/events.py @@ -187,29 +187,25 @@ def build_phases( tr: Dict[str, Any], bias: str, events: List[Dict[str, Any]], + min_bars: int = 3, ) -> List[Dict[str, Any]]: - """按时间切分 A–E 粗阶段。""" + """按时间切分 A–E 粗阶段;保证非重叠且每段至少 min_bars 根(空间不足则截断尾部阶段)。""" 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) + n_last = len(df) - 1 + min_span = max(2, min_bars - 1) 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) + a_end = s + max(min_bars, (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": @@ -218,17 +214,27 @@ def build_phases( m = {"A": "A停止下跌", "B": "B筑底", "C": "C测试", "D": "D拉升", "E": "E离开"} return m.get(phase, phase) - cuts = [ + # 理想切点(随后再强制非重叠 + 最小跨度) + raw = [ ("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))), + ("B", a_end, c_anchor), + ("C", c_anchor, d_anchor), + ("D", d_anchor, min(n_last, d_anchor + max(min_bars, (e - s) // 6))), + ("E", min(n_last, d_anchor + max(min_bars, (e - s) // 6)), min(n_last, max(e, d_anchor + max(min_bars * 2, 8)))), ] - 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: List[Dict[str, Any]] = [] + cursor = s + for phase, _a, _b in raw: + if cursor >= n_last: + break + a = max(int(_a), cursor) + b = int(max(_b, a + min_span)) + b = int(np.clip(b, a, n_last)) + if b - a < min_span: + # 尾部空间不足:并入上一段终点并停止新增 + if phases: + phases[-1]["end_time"] = _bar_time(df, n_last) + break phases.append( { "phase": phase, @@ -237,4 +243,5 @@ def build_phases( "end_time": _bar_time(df, b), } ) + cursor = b return phases diff --git a/chanlun/analysis/wyckoff/range.py b/chanlun/analysis/wyckoff/range.py index 5ec9e7d..5ca4287 100644 --- a/chanlun/analysis/wyckoff/range.py +++ b/chanlun/analysis/wyckoff/range.py @@ -1,4 +1,4 @@ -"""交易区间检测:ATR 容差下的近期震荡箱。""" +"""交易区间检测:ATR 容差下按评分选取近期震荡箱。""" from __future__ import annotations from typing import Any, Dict, Optional @@ -23,6 +23,20 @@ def _atr(df: pd.DataFrame, period: int = 14) -> pd.Series: return tr.rolling(period, min_periods=max(3, period // 2)).mean() +def _score_segment( + length: int, + near_hi: int, + near_lo: int, + inside: float, + width: float, + atr: float, +) -> float: + """触边密度 + 箱内比例 − 相对宽度;弱奖励长度以免只追最长。""" + touch_density = (near_hi + near_lo) / float(max(length, 1)) + width_pen = (width / atr) if atr > 0 else width + return touch_density * 50.0 + float(inside) * 30.0 - width_pen * 3.0 + min(length / 40.0, 2.0) + + def detect_trading_range( df: pd.DataFrame, lookback: int = 120, @@ -33,6 +47,7 @@ def detect_trading_range( """ 在最近 lookback 根内寻找高低点波动受控的连续段作为交易区间。 尾部预留 tail_reserve 根用于事件(Spring/SOS),不参与箱体边界计算。 + 在硬门槛之上按评分取最优段(非仅最长窗口)。 """ if df is None or len(df) < min_bars + 5: return None @@ -54,6 +69,7 @@ def detect_trading_range( last_atr = float(core["close"].iloc[-1]) * 0.01 best = None + best_score = float("-inf") cn = len(core) for length in range(min(cn, lookback), min_bars - 1, -4): seg = core.iloc[-length:] @@ -67,14 +83,18 @@ def detect_trading_range( 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() + inside = float(((seg["close"] >= lo - tol) & (seg["close"] <= hi + tol)).mean()) if inside < 0.75: continue + score = _score_segment(length, near_hi, near_lo, inside, width, last_atr) + if score <= best_score: + 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_score = score best = { "start_idx": int(start_i), "end_idx": int(end_i), @@ -85,8 +105,8 @@ def detect_trading_range( "atr": last_atr, "tol": tol, "bars": int(length), + "score": float(score), } - break if best is None: return None diff --git a/docs/AGENT_MEMORY.md b/docs/AGENT_MEMORY.md index cf833d8..5bac0fe 100644 --- a/docs/AGENT_MEMORY.md +++ b/docs/AGENT_MEMORY.md @@ -21,7 +21,7 @@ - IDEA-002 / `9f1e736`:主站内存泄漏 dispose、首屏单次 analyze、ChanMACD 复用、chan_tv 体验 - ECR-002 Reviewed:拆 `web/services/runtime/`、加深 analyze 契约 - ECR-003 Reviewed:主站威科夫叠层(`chanlun/analysis/wyckoff/` + `include_wyckoff`)→ `081a57a` -- ECR-004 Draft:TR 评分硬化 + VP 少系列 + 阶段/门闩/单测(IDEA-005) +- ECR-004 Reviewed:TR 评分硬化 + VP 少系列 + 阶段/门闩/单测(无币种参数) ## 硬约束提醒 diff --git a/docs/CHANGELOG/CHANGELOG.md b/docs/CHANGELOG/CHANGELOG.md index e17af80..08fbe91 100644 --- a/docs/CHANGELOG/CHANGELOG.md +++ b/docs/CHANGELOG/CHANGELOG.md @@ -2,9 +2,11 @@ ## Unreleased — 2026-08-06 -### ECR-004(L2,Draft) +### ECR-004(L2,Reviewed) -- 计划:威科夫 TR 评分硬化、主站 VP 绘图减负、阶段/门闩/单测(见 `docs/ECR/ECR-004-wyckoff-harden.md`) +- 威科夫 TR 评分选段(防吞前置趋势);阶段非重叠最小跨度 +- 主站 VP Top-8 + bins≤24;填充线减负 +- `elements_only` 时不跑威科夫;收紧单测(无币种独立参数) ### ECR-003(L2,Reviewed) diff --git a/docs/CODE_REVIEW/ECR-004.md b/docs/CODE_REVIEW/ECR-004.md new file mode 100644 index 0000000..1b4a98e --- /dev/null +++ b/docs/CODE_REVIEW/ECR-004.md @@ -0,0 +1,43 @@ +# CODE_REVIEW — ECR-004 + +**Role:** REVIEWER +**Date:** 2026-08-06 +**Scope:** 威科夫硬化(评分 TR / 阶段 / VP 减负 / API 门闩) +**Decision:** Approve + +## Evidence + +- `chanlun/analysis/wyckoff/range.py`、`events.py` +- `web/api/analyze.py`、`chart_tv.js` +- `tests/test_wyckoff.py`、`web/tests/test_analyze_contract.py` +- IMPL / TEST / ENG SPEC + +## Acceptance ↔ Evidence + +| Acceptance | Verdict | Evidence | +|------------|---------|----------| +| 合成 TR 边界与起点 | PASS | `abs_start≥12`;low/high∈箱体带 | +| VP series 减负 | PASS | Top-8 + 填充 3 + levels;bins≤24 | +| 阶段最小跨度/不重合 | PASS | 链式切分;单测 unique keys | +| elements_only 门闩 | PASS | 契约测试 | +| golden 不变 | PASS | golden 套件 | +| 无币种参数 / 无策略改动 | PASS | diff 范围 | + +## 复跑 + +```text +→ 14 passed +``` + +## Findings + +### Non-blocking + +1. 尾部短时可能只输出 A–C(无 D/E)——符合「空间不足截断」规则。 +2. 评分权重仍为启发式,实盘 BTC 以外未矩阵验证(本 ECR 明确不做币种表)。 + +### No blockers + +## Decision + +**Approve** diff --git a/docs/ECR/ECR-004-wyckoff-harden.md b/docs/ECR/ECR-004-wyckoff-harden.md index 1744c69..61eb83b 100644 --- a/docs/ECR/ECR-004-wyckoff-harden.md +++ b/docs/ECR/ECR-004-wyckoff-harden.md @@ -1,7 +1,7 @@ # ECR-004 **Title:** 威科夫区间评分硬化与主站 VP 绘图减负 -**Status:** Draft(待 Human/Architect Approve 后编码) +**Status:** Done (Reviewed) **Date:** 2026-08-06 **Change Level:** L2 @@ -19,7 +19,7 @@ - `chanlun/analysis/wyckoff/range.py` / `events.py`(阶段)启发式与单测 - `web/static/js/app/chart_tv.js` 威科夫 VP/填充绘制路径 -- `web/api/analyze.py`:`elements_only` 时不跑威科夫;可选调低默认 `vp_bins` 上限 +- `web/api/analyze.py`:`elements_only` 时不跑威科夫;默认 `vp_bins` 上限 24 - ESS 文档与契约测试补充断言(不删既有 `wyckoff` 键) ### Forbidden @@ -28,23 +28,29 @@ - `config/` / `strategies/` - `/chan_tv` - 新数据源 / 订单流 +- **按币种独立参数表**(全局 ATR 相对即可;当前以 BTC 场景验证) + +## Decisions(Approve 时锁定) + +- VP:**A+C**(前端 Top-N 有量 bin + 服务端 bins 上限 24) +- 不做 per-symbol 参数 ## Risk | Risk | Mitigation | |------|------------| | TR 结果相对 003 漂移 | 合成夹具锁定高低与起点;文档标明启发式迭代 | -| 前端 VP 观感变化 | 保留 POC/VAH/VAL;密度可用更少线表示 | +| 前端 VP 观感变化 | 保留 POC/VAH/VAL;密度用 Top-N | | 回归 | 扩展 `tests/test_wyckoff.py` + 既有契约套件 | ## Acceptance Criteria -- [ ] 合成箱体夹具:`trading_range` 高低接近箱体边界,起点不落入明显前置趋势段 -- [ ] 开启 VP 时主图新增 series 数显著低于「每 bin 一条」(目标:填充+VP ≤ ~15 或等价合并策略) -- [ ] 阶段输出满足最小跨度或合并退化段;文档说明规则 -- [ ] `elements_only=true` 即使 `include_wyckoff=1` 也不返回 `wyckoff`(或明确文档例外——实现选前者) -- [ ] golden 缠论基线不变;相关 pytest 绿 -- [ ] TEST/IMPL/CHANGELOG/TRACEABILITY + CODE_REVIEW +- [x] 合成箱体夹具:`trading_range` 高低接近箱体边界,起点不落入明显前置趋势段 +- [x] 开启 VP 时主图新增 series 数显著低于「每 bin 一条」(目标:填充+VP ≤ ~15 或等价合并策略) +- [x] 阶段输出满足最小跨度或合并退化段;文档说明规则 +- [x] `elements_only=true` 即使 `include_wyckoff=1` 也不返回 `wyckoff` +- [x] golden 缠论基线不变;相关 pytest 绿 +- [x] TEST/IMPL/CHANGELOG/TRACEABILITY + CODE_REVIEW ## Rollback @@ -60,3 +66,4 @@ - 上游: `docs/CODE_REVIEW/ECR-003.md` Findings 1–5 - PRODUCT_SPEC / ENGINEERING_SPEC: 同目录 ECR-004-* - TRACEABILITY: Yes +- CODE_REVIEW: `docs/CODE_REVIEW/ECR-004.md` — Approve diff --git a/docs/ENGINEERING_SPEC/ECR-004-wyckoff-harden.md b/docs/ENGINEERING_SPEC/ECR-004-wyckoff-harden.md index 743299f..55f376e 100644 --- a/docs/ENGINEERING_SPEC/ECR-004-wyckoff-harden.md +++ b/docs/ENGINEERING_SPEC/ECR-004-wyckoff-harden.md @@ -1,45 +1,43 @@ # ENGINEERING_SPEC — ECR-004 -**Status:** Draft +**Status:** Approved(实现锁定:评分选段;VP=A+C;无币种参数) **Date:** 2026-08-06 ## Range scoring 替换「仅取最长合格窗口」: -1. 仍在 `lookback` + `tail_reserve` 框架内扫描候选段(步长可保留)。 -2. 对每段计算分数,例如: - - `touch_score` = near_hi + near_lo(触边上沿/下沿次数) - - `width_penalty` = width / ATR(过宽扣分) - - `recency_bonus` = 段终点靠近 core 末 - - 可选:段内 close 在 [low,high] 比例 -3. 取分数最高且满足既有硬门槛(near_hi/lo≥2、inside≥0.75 等)的段。 -4. 单测:`_box_df` 断言 `low∈[38,42]`、`high∈[58,62]`,且 `start` 不早于箱体起始(允许小容差)。 +1. 仍在 `lookback` + `tail_reserve` 框架内扫描候选段(步长 -4)。 +2. 硬门槛不变:near_hi/lo≥2、inside≥0.75、宽度上限等。 +3. 分数:`touch_density*50 + inside*30 - (width/ATR)*3 + min(length/40, 2)`,取最高。 +4. 单测:`low∈[38,42]`、`high∈[58,62]`,起点不早于箱体(容差 8 根);`abs_start_idx >= 12`。 ## Phases -- 为 A–E 设最小 bar 数(如 ≥3);不足则与相邻段合并或跳过空标签。 -- 避免 D/E 完全同起止仍输出两条重复色带。 +- 非重叠链式切分;每段至少 `min_bars=3`。 +- 尾部空间不足则延长上一段并停止新增(避免 D/E 完全重合双画)。 ## API gate ```text if include_wyckoff and not elements_only: - result["wyckoff"] = analyze_wyckoff(...) + result["wyckoff"] = analyze_wyckoff(..., vp_bins∈[10,24]) ``` -## Frontend VP +默认 `wyckoff_vp_bins=24`,上限 24。 -任选其一(ENG 实现时定一种并写进 IMPL): +## Frontend VP(A+C) -- **A**:只画 volume>0 的 bin,且最多 N 条(按 volume 取 Top-N),POC/VAH/VAL 仍全画 -- **B**:用少量水平线 + 透明度映射,或单 series 多段(若 Lightweight 版本支持) -- **C**:服务端默认 `vp_bins` 上限降到 24,前端再 Top-N - -区间填充线可从 6 降到 3。 +- 填充线 6→3 +- 有量 bin 按 volume Top-8 绘制 + POC/VAH/VAL +- 目标:区间填充+边框+VP ≈ ≤15 series 量级 ## Tests -- 扩展 `tests/test_wyckoff.py` -- 契约:`elements_only=true&include_wyckoff=1` 无 `wyckoff` +- `tests/test_wyckoff.py` 收紧 +- `elements_only=true&include_wyckoff=1` 无 `wyckoff` - 不改 golden 缠论 JSON + +## Non-goals + +- 按币种独立参数(全局 ATR 相对;以 BTC 场景验证) diff --git a/docs/HANDOFF/ECR-004-engineer-to-reviewer.md b/docs/HANDOFF/ECR-004-engineer-to-reviewer.md new file mode 100644 index 0000000..16bd659 --- /dev/null +++ b/docs/HANDOFF/ECR-004-engineer-to-reviewer.md @@ -0,0 +1,5 @@ +# HANDOFF — ECR-004 engineer → reviewer + +**Date:** 2026-08-06 + +已实现并自测 14 passed。请对照 `docs/CODE_REVIEW/ECR-004.md`。 diff --git a/docs/IMPLEMENTATION_REPORT/ECR-004.md b/docs/IMPLEMENTATION_REPORT/ECR-004.md new file mode 100644 index 0000000..babd55d --- /dev/null +++ b/docs/IMPLEMENTATION_REPORT/ECR-004.md @@ -0,0 +1,24 @@ +# IMPLEMENTATION_REPORT — ECR-004 + +**Date:** 2026-08-06 +**Status:** Implemented +**Change Level:** L2 + +## What changed + +| Area | Change | +|------|--------| +| `wyckoff/range.py` | 硬门槛上按触边密度/箱内比/宽度评分选最优段(非最长) | +| `wyckoff/events.py` `build_phases` | 非重叠 + 最小跨度;尾部不足则截断 | +| `web/api/analyze.py` | `include_wyckoff and not elements_only`;`vp_bins` 默认/上限 24 | +| `chart_tv.js` | 填充 3 线;VP Top-8 + POC/VAH/VAL | +| tests | 收紧 TR/事件断言;`elements_only` 契约 | + +## Decisions + +- VP:**A+C** +- **无**币种独立参数(全局 ATR 相对;BTC 场景验证) + +## Tests + +见 `docs/TEST_REPORT/ECR-004.md`(14 passed 相关套件)。 diff --git a/docs/PRODUCT_SPEC/ECR-004-wyckoff-harden.md b/docs/PRODUCT_SPEC/ECR-004-wyckoff-harden.md index 2d1571f..29325dd 100644 --- a/docs/PRODUCT_SPEC/ECR-004-wyckoff-harden.md +++ b/docs/PRODUCT_SPEC/ECR-004-wyckoff-harden.md @@ -1,6 +1,6 @@ # PRODUCT_SPEC — ECR-004 -**Status:** Draft +**Status:** Approved **Date:** 2026-08-06 ## Goal diff --git a/docs/PROJECT_PROFILE.md b/docs/PROJECT_PROFILE.md index 153aad0..db7cb96 100644 --- a/docs/PROJECT_PROFILE.md +++ b/docs/PROJECT_PROFILE.md @@ -34,7 +34,7 @@ Trading System(缠论分析引擎 + 可视化 Web;Freqtrade 策略目录独 ## Active anchors -- ECR: ECR-004 Draft(威科夫硬化);ECR-002/003 Reviewed +- ECR: ECR-002/003/004 Reviewed(威科夫 + 硬化) - EXP: N/A - TRACEABILITY: `docs/TRACEABILITY.md` - Memory: `docs/AGENT_MEMORY.md` diff --git a/docs/STATE/CURRENT.md b/docs/STATE/CURRENT.md index 169766b..ba73cc9 100644 --- a/docs/STATE/CURRENT.md +++ b/docs/STATE/CURRENT.md @@ -1,8 +1,8 @@ # STATE -**owner:** architect -**active_ecr:** ECR-004 -**phase:** draft_spec(待 Approve 后实现) +**owner:** idle +**active_ecr:** none(ECR-004 Reviewed;待本批提交合入) +**phase:** post-review **system_version:** v1.0.0 **strategy_version:** unchanged **updated:** 2026-08-06 @@ -15,10 +15,9 @@ | IDEA-002 | L1 | Done | `9f1e736` | | ECR-002 | L3 | Done (Reviewed) | runtime 包拆分 | | ECR-003 | L2 | Done (Reviewed) | `081a57a` 主站威科夫 | -| ECR-004 | L2 | Draft | 威科夫硬化 / VP 减负;见 IDEA-005 | +| ECR-004 | L2 | Done (Reviewed) | 威科夫硬化 / VP 减负 | ## Notes -- ECR-003 已合入;Findings → ECR-004 -- ECR-004 待 Human/Architect **Approve** 后再编码 +- ECR-004:**Approve**(14 passed);无币种独立参数 - 未请求新 system tag diff --git a/docs/TASKS/TASK-004-ECR004.yaml b/docs/TASKS/TASK-004-ECR004.yaml index 9ac8ae2..5260e34 100644 --- a/docs/TASKS/TASK-004-ECR004.yaml +++ b/docs/TASKS/TASK-004-ECR004.yaml @@ -1,7 +1,8 @@ task_id: ECR-004 title: 威科夫区间评分硬化与主站 VP 绘图减负 -status: draft +status: done_reviewed change_level: L2 ecr: docs/ECR/ECR-004-wyckoff-harden.md idea: docs/IDEA/IDEA-005-wyckoff-harden.md -notes: Follow-up to ECR-003 CODE_REVIEW Findings 1-5. Await Approve then implement. +code_review: docs/CODE_REVIEW/ECR-004.md +notes: A+C VP; no per-symbol params; BTC-oriented validation. Approve 2026-08-06. diff --git a/docs/TEST_REPORT/ECR-004.md b/docs/TEST_REPORT/ECR-004.md new file mode 100644 index 0000000..41a8af2 --- /dev/null +++ b/docs/TEST_REPORT/ECR-004.md @@ -0,0 +1,29 @@ +# TEST_REPORT — ECR-004 + +**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 + +**14 passed** + +| Suite | Coverage | +|-------|----------| +| `test_wyckoff` | TR 边界/起点、Spring+SOS、阶段不重合、VP POC | +| golden | 缠论基线不变 | +| analyze contract | opt-in wyckoff;`elements_only` 跳过 wyckoff | + +## Notes + +- 合成夹具下 `abs_start_idx=20`(箱体起点),高低≈40.1/59.9。 +- 主站 VP series 减负无自动化计数;按 ENG Top-8+3 填充实现。 diff --git a/docs/TRACEABILITY.md b/docs/TRACEABILITY.md index 3bd9d48..0f67d7e 100644 --- a/docs/TRACEABILITY.md +++ b/docs/TRACEABILITY.md @@ -37,10 +37,10 @@ | ECR-003 | 主站 Lightweight 叠层 | PRODUCT-003 | `index.html` `chart_tv.js` `chart_view.js` | 人工 + 开关接线 | | ECR-003 | 契约可选键文档 | ENG-003 | `analyze_contract_keys.json` | golden keys file 断言 | -## ECR-004(Draft) +## ECR-004 | ECR | Requirement | Spec | Code | Test | |-----|-------------|------|------|------| -| ECR-004 | TR 评分选最优段 | ENG-004 | TBD `wyckoff/range.py` | 收紧 `test_wyckoff` | -| ECR-004 | VP/填充少 series | ENG-004 | TBD `chart_tv.js` | 人工 / 约定上限 | -| ECR-004 | 阶段最小长度 + elements_only 门闩 | ENG-004 | TBD events + analyze | 契约补充 | +| ECR-004 | TR 评分选最优段 | ENG-004 | `wyckoff/range.py` | `test_wyckoff` / `test_range_scoring_skips_pretrend` | +| ECR-004 | VP/填充少 series | ENG-004 | `chart_tv.js` Top-8 + 填充 3;bins≤24 | 人工 + ENG | +| ECR-004 | 阶段最小长度 + elements_only 门闩 | ENG-004 | `events.py` + `analyze.py` | 契约 `elements_only` | diff --git a/tests/test_wyckoff.py b/tests/test_wyckoff.py index 233e46a..7b12320 100644 --- a/tests/test_wyckoff.py +++ b/tests/test_wyckoff.py @@ -1,4 +1,4 @@ -"""威科夫引擎单测:合成震荡箱 + Spring/SOS + VP POC。""" +"""威科夫引擎单测:合成震荡箱 + Spring/SOS + VP POC(ECR-004 收紧)。""" from __future__ import annotations import sys @@ -11,10 +11,11 @@ ROOT = Path(__file__).resolve().parents[1] sys.path.insert(0, str(ROOT)) from chanlun.analysis.wyckoff import analyze_wyckoff # noqa: E402 +from chanlun.analysis.wyckoff.range import detect_trading_range # noqa: E402 def _box_df(n_box: int = 60, spring: bool = True, sos: bool = True) -> pd.DataFrame: - """构造明显箱体:40~60,可选假破与上破。""" + """构造明显箱体:40~60,前 20 根下跌趋势,可选假破与上破。""" rng = np.random.default_rng(7) rows = [] t0 = pd.Timestamp("2024-06-01", tz="UTC") @@ -32,7 +33,6 @@ def _box_df(n_box: int = 60, spring: bool = True, sos: bool = True) -> pd.DataFr 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: @@ -49,7 +49,6 @@ def _box_df(n_box: int = 60, spring: bool = True, sos: bool = True) -> pd.DataFr ) base = 20 + n_box if spring: - # 假破下沿 rows.append( ( t0 + pd.Timedelta(minutes=5 * base), @@ -73,7 +72,6 @@ def _box_df(n_box: int = 60, spring: bool = True, sos: bool = True) -> pd.DataFr ) ) base += 1 - # LPS 缩量回踩 rows.append( ( t0 + pd.Timedelta(minutes=5 * base), @@ -85,8 +83,7 @@ def _box_df(n_box: int = 60, spring: bool = True, sos: bool = True) -> pd.DataFr ) ) - df = pd.DataFrame(rows, columns=["date", "open", "high", "low", "close", "volume"]) - return df + return pd.DataFrame(rows, columns=["date", "open", "high", "low", "close", "volume"]) def test_wyckoff_detects_range_and_events(): @@ -94,15 +91,29 @@ def test_wyckoff_detects_range_and_events(): out = analyze_wyckoff(df, lookback=200) assert out["trading_range"] is not None tr = out["trading_range"] - assert tr["high"] > tr["low"] + assert 38.0 <= tr["low"] <= 42.0 + assert 58.0 <= tr["high"] <= 62.0 + # 起点不应落入前 20 根下跌段(允许少量 overlap) + box_start = df["date"].iloc[20] + assert tr["start_time"] is not None + start_ts = pd.Timestamp(tr["start_time"]) + assert start_ts >= box_start - pd.Timedelta(minutes=5 * 8) types = {e["type"] for e in out["events"]} - assert "Spring" in types or "SOS" in types + assert "Spring" in types + assert "SOS" in types assert out["bias"] in ("accumulation", "distribution", "unknown") assert len(out["phases"]) >= 3 + keys = [(p["start_time"], p["end_time"]) for p in out["phases"]] + assert len(keys) == len(set(keys)), "phases must not share identical start/end" + +def test_range_scoring_skips_pretrend(): + df = _box_df(spring=False, sos=False) + tr = detect_trading_range(df, lookback=200) + assert tr is not None + assert tr["abs_start_idx"] >= 12 # 不应从 bar 0 吞掉整段下跌 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): @@ -114,4 +125,4 @@ def test_volume_profile_poc_on_heavy_bin(): 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 + assert abs(vp["poc"] - 50.0) < 1.0 diff --git a/web/api/analyze.py b/web/api/analyze.py index 49eab60..7c01a45 100644 --- a/web/api/analyze.py +++ b/web/api/analyze.py @@ -656,18 +656,19 @@ def analyze(): else: result['structure_zones'] = [] - # 威科夫分析 —— 按需:include_wyckoff=1 + # 威科夫分析 —— 按需:include_wyckoff=1,且须有主周期分析(非 elements_only) include_wyckoff_param = request.args.get('include_wyckoff', '') include_wyckoff = str(include_wyckoff_param).lower() in ('1', 'true', 'yes') - if include_wyckoff: + if include_wyckoff and not elements_only: 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)) + # ECR-004:默认/上限 24 bins(A+C) + wyckoff_bins = int(request.args.get('wyckoff_vp_bins', 24)) result['wyckoff'] = analyze_wyckoff( df, lookback=max(40, min(wyckoff_lookback, 500)), - vp_bins=max(10, min(wyckoff_bins, 100)), + vp_bins=max(10, min(wyckoff_bins, 24)), ) except Exception as e: print(f"Wyckoff 分析出错: {e}") diff --git a/web/static/js/app/chart_tv.js b/web/static/js/app/chart_tv.js index fcfbaba..749bb0c 100644 --- a/web/static/js/app/chart_tv.js +++ b/web/static/js/app/chart_tv.js @@ -2222,7 +2222,8 @@ function initTradingView(symbol, timeframe) { 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; + // ECR-004:填充线 6→3,减 series + const fillLines = 3; const step = (hi - lo) / (fillLines + 1); for (let fi = 1; fi <= fillLines; fi++) { const fy = lo + step * fi; @@ -2320,20 +2321,29 @@ function initTradingView(symbol, timeframe) { const t1 = tr.end_time ? parseTs(tr.end_time) : chartEnd; if (!isNaN(t1)) { const bins = vp.bins || []; + // ECR-004 A+C:只画有量 Top-N,避免每 bin 一条 series + const TOP_N = 8; + const ranked = bins + .filter(function(b) { return b && b.volume > 0; }) + .slice() + .sort(function(a, b) { return b.volume - a.volume; }) + .slice(0, TOP_N); let maxVol = 0; - 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) { + ranked.forEach(function(b) { if (b.volume > maxVol) maxVol = b.volume; }); + const tStart = parseTs(tr.start_time); + const maxWidthSec = Math.max(60, Math.floor((t1 - (isNaN(tStart) ? t1 : tStart)) * 0.15)); + ranked.forEach(function(b) { if (!b.volume || maxVol <= 0) return; const wSec = Math.max(1, Math.floor(maxWidthSec * (b.volume / maxVol))); - const alpha = 0.15 + 0.55 * (b.volume / maxVol); + const alpha = 0.2 + 0.55 * (b.volume / maxVol); + const leftT = Math.max(isNaN(tStart) ? (t1 - wSec) : tStart, t1 - wSec); mainChart.addLineSeries({ color: 'rgba(142, 68, 173, ' + alpha.toFixed(2) + ')', lineWidth: 1, lastValueVisible: false, priceLineVisible: false }).setData([ - { time: t1 - wSec, value: b.price }, + { time: leftT, value: b.price }, { time: t1, value: b.price } ]); }); diff --git a/web/tests/test_analyze_contract.py b/web/tests/test_analyze_contract.py index 18058a6..13bc213 100644 --- a/web/tests/test_analyze_contract.py +++ b/web/tests/test_analyze_contract.py @@ -157,3 +157,32 @@ def test_analyze_http_wyckoff_opt_in(): w = payload["wyckoff"] for k in WYCKOFF_KEYS: assert k in w, f"missing wyckoff key: {k}" + + +def test_analyze_http_wyckoff_skipped_when_elements_only(): + """elements_only=true 时即使 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", + "element_timeframe": "1m", + "timezone": "Asia/Shanghai", + "elements_only": "true", + "include_wyckoff": 1, + }, + ) + assert resp.status_code == 200, resp.data[:500] + payload = resp.get_json() + assert payload is not None + assert "wyckoff" not in payload