fix: ECR-004 威科夫区间评分硬化与 VP 绘图减负(已审)
评分选 TR、阶段最小跨度、elements_only 门闩、Top-8 VP;无币种独立参数。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -187,29 +187,25 @@ def build_phases(
|
|||||||
tr: Dict[str, Any],
|
tr: Dict[str, Any],
|
||||||
bias: str,
|
bias: str,
|
||||||
events: List[Dict[str, Any]],
|
events: List[Dict[str, Any]],
|
||||||
|
min_bars: int = 3,
|
||||||
) -> List[Dict[str, Any]]:
|
) -> List[Dict[str, Any]]:
|
||||||
"""按时间切分 A–E 粗阶段。"""
|
"""按时间切分 A–E 粗阶段;保证非重叠且每段至少 min_bars 根(空间不足则截断尾部阶段)。"""
|
||||||
s = int(tr["abs_start_idx"])
|
s = int(tr["abs_start_idx"])
|
||||||
e = int(tr["abs_end_idx"])
|
e = int(tr["abs_end_idx"])
|
||||||
hi = float(tr["high"])
|
n_last = len(df) - 1
|
||||||
lo = float(tr["low"])
|
min_span = max(2, min_bars - 1)
|
||||||
mid = float(tr["mid"])
|
|
||||||
tol = float(tr.get("tol") or (hi - lo) * 0.05)
|
|
||||||
|
|
||||||
event_idx = {}
|
event_idx = {}
|
||||||
for ev in events:
|
for ev in events:
|
||||||
# 找回 idx 近似:按时间匹配
|
|
||||||
t = ev.get("time")
|
t = ev.get("time")
|
||||||
for i in range(s, min(len(df), e + 20)):
|
for i in range(s, min(len(df), e + 20)):
|
||||||
if _bar_time(df, i) == t:
|
if _bar_time(df, i) == t:
|
||||||
event_idx[ev["type"]] = i
|
event_idx[ev["type"]] = i
|
||||||
break
|
break
|
||||||
|
|
||||||
# 分段点
|
a_end = s + max(min_bars, (e - s) // 5)
|
||||||
a_end = s + max(3, (e - s) // 5)
|
|
||||||
c_anchor = event_idx.get("Spring") or event_idx.get("UTAD") or (s + (e - s) // 2)
|
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
|
d_anchor = event_idx.get("SOS") or event_idx.get("SOW") or e
|
||||||
e_start = d_anchor
|
|
||||||
|
|
||||||
def _lab(phase: str) -> str:
|
def _lab(phase: str) -> str:
|
||||||
if bias == "distribution":
|
if bias == "distribution":
|
||||||
@@ -218,17 +214,27 @@ def build_phases(
|
|||||||
m = {"A": "A停止下跌", "B": "B筑底", "C": "C测试", "D": "D拉升", "E": "E离开"}
|
m = {"A": "A停止下跌", "B": "B筑底", "C": "C测试", "D": "D拉升", "E": "E离开"}
|
||||||
return m.get(phase, phase)
|
return m.get(phase, phase)
|
||||||
|
|
||||||
cuts = [
|
# 理想切点(随后再强制非重叠 + 最小跨度)
|
||||||
|
raw = [
|
||||||
("A", s, a_end),
|
("A", s, a_end),
|
||||||
("B", a_end, max(a_end + 1, c_anchor)),
|
("B", a_end, c_anchor),
|
||||||
("C", max(a_end + 1, c_anchor), max(c_anchor + 1, d_anchor)),
|
("C", c_anchor, d_anchor),
|
||||||
("D", max(c_anchor + 1, d_anchor), max(d_anchor + 1, min(len(df) - 1, e_start + max(3, (e - s) // 6)))),
|
("D", d_anchor, min(n_last, d_anchor + max(min_bars, (e - s) // 6))),
|
||||||
("E", max(d_anchor, e_start), min(len(df) - 1, max(e, e_start + 5))),
|
("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 = []
|
phases: List[Dict[str, Any]] = []
|
||||||
for phase, a, b in cuts:
|
cursor = s
|
||||||
a = int(np.clip(a, 0, len(df) - 1))
|
for phase, _a, _b in raw:
|
||||||
b = int(np.clip(b, a, len(df) - 1))
|
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(
|
phases.append(
|
||||||
{
|
{
|
||||||
"phase": phase,
|
"phase": phase,
|
||||||
@@ -237,4 +243,5 @@ def build_phases(
|
|||||||
"end_time": _bar_time(df, b),
|
"end_time": _bar_time(df, b),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
cursor = b
|
||||||
return phases
|
return phases
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"""交易区间检测:ATR 容差下的近期震荡箱。"""
|
"""交易区间检测:ATR 容差下按评分选取近期震荡箱。"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from typing import Any, Dict, Optional
|
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()
|
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(
|
def detect_trading_range(
|
||||||
df: pd.DataFrame,
|
df: pd.DataFrame,
|
||||||
lookback: int = 120,
|
lookback: int = 120,
|
||||||
@@ -33,6 +47,7 @@ def detect_trading_range(
|
|||||||
"""
|
"""
|
||||||
在最近 lookback 根内寻找高低点波动受控的连续段作为交易区间。
|
在最近 lookback 根内寻找高低点波动受控的连续段作为交易区间。
|
||||||
尾部预留 tail_reserve 根用于事件(Spring/SOS),不参与箱体边界计算。
|
尾部预留 tail_reserve 根用于事件(Spring/SOS),不参与箱体边界计算。
|
||||||
|
在硬门槛之上按评分取最优段(非仅最长窗口)。
|
||||||
"""
|
"""
|
||||||
if df is None or len(df) < min_bars + 5:
|
if df is None or len(df) < min_bars + 5:
|
||||||
return None
|
return None
|
||||||
@@ -54,6 +69,7 @@ def detect_trading_range(
|
|||||||
last_atr = float(core["close"].iloc[-1]) * 0.01
|
last_atr = float(core["close"].iloc[-1]) * 0.01
|
||||||
|
|
||||||
best = None
|
best = None
|
||||||
|
best_score = float("-inf")
|
||||||
cn = len(core)
|
cn = len(core)
|
||||||
for length in range(min(cn, lookback), min_bars - 1, -4):
|
for length in range(min(cn, lookback), min_bars - 1, -4):
|
||||||
seg = core.iloc[-length:]
|
seg = core.iloc[-length:]
|
||||||
@@ -67,14 +83,18 @@ def detect_trading_range(
|
|||||||
near_lo = int((seg["low"] <= lo + tol).sum())
|
near_lo = int((seg["low"] <= lo + tol).sum())
|
||||||
if near_hi < 2 or near_lo < 2:
|
if near_hi < 2 or near_lo < 2:
|
||||||
continue
|
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:
|
if inside < 0.75:
|
||||||
continue
|
continue
|
||||||
|
score = _score_segment(length, near_hi, near_lo, inside, width, last_atr)
|
||||||
|
if score <= best_score:
|
||||||
|
continue
|
||||||
start_i = cn - length
|
start_i = cn - length
|
||||||
end_i = cn - 1
|
end_i = cn - 1
|
||||||
mid = (hi + lo) / 2.0
|
mid = (hi + lo) / 2.0
|
||||||
last_c = float(work["close"].iloc[-1])
|
last_c = float(work["close"].iloc[-1])
|
||||||
active = (lo - tol * 1.5) <= last_c <= (hi + tol * 1.5)
|
active = (lo - tol * 1.5) <= last_c <= (hi + tol * 1.5)
|
||||||
|
best_score = score
|
||||||
best = {
|
best = {
|
||||||
"start_idx": int(start_i),
|
"start_idx": int(start_i),
|
||||||
"end_idx": int(end_i),
|
"end_idx": int(end_i),
|
||||||
@@ -85,8 +105,8 @@ def detect_trading_range(
|
|||||||
"atr": last_atr,
|
"atr": last_atr,
|
||||||
"tol": tol,
|
"tol": tol,
|
||||||
"bars": int(length),
|
"bars": int(length),
|
||||||
|
"score": float(score),
|
||||||
}
|
}
|
||||||
break
|
|
||||||
|
|
||||||
if best is None:
|
if best is None:
|
||||||
return None
|
return None
|
||||||
|
|||||||
@@ -21,7 +21,7 @@
|
|||||||
- IDEA-002 / `9f1e736`:主站内存泄漏 dispose、首屏单次 analyze、ChanMACD 复用、chan_tv 体验
|
- IDEA-002 / `9f1e736`:主站内存泄漏 dispose、首屏单次 analyze、ChanMACD 复用、chan_tv 体验
|
||||||
- ECR-002 Reviewed:拆 `web/services/runtime/`、加深 analyze 契约
|
- ECR-002 Reviewed:拆 `web/services/runtime/`、加深 analyze 契约
|
||||||
- ECR-003 Reviewed:主站威科夫叠层(`chanlun/analysis/wyckoff/` + `include_wyckoff`)→ `081a57a`
|
- ECR-003 Reviewed:主站威科夫叠层(`chanlun/analysis/wyckoff/` + `include_wyckoff`)→ `081a57a`
|
||||||
- ECR-004 Draft:TR 评分硬化 + VP 少系列 + 阶段/门闩/单测(IDEA-005)
|
- ECR-004 Reviewed:TR 评分硬化 + VP 少系列 + 阶段/门闩/单测(无币种参数)
|
||||||
|
|
||||||
## 硬约束提醒
|
## 硬约束提醒
|
||||||
|
|
||||||
|
|||||||
@@ -2,9 +2,11 @@
|
|||||||
|
|
||||||
## Unreleased — 2026-08-06
|
## 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)
|
### ECR-003(L2,Reviewed)
|
||||||
|
|
||||||
|
|||||||
@@ -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**
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
# ECR-004
|
# ECR-004
|
||||||
|
|
||||||
**Title:** 威科夫区间评分硬化与主站 VP 绘图减负
|
**Title:** 威科夫区间评分硬化与主站 VP 绘图减负
|
||||||
**Status:** Draft(待 Human/Architect Approve 后编码)
|
**Status:** Done (Reviewed)
|
||||||
**Date:** 2026-08-06
|
**Date:** 2026-08-06
|
||||||
**Change Level:** L2
|
**Change Level:** L2
|
||||||
|
|
||||||
@@ -19,7 +19,7 @@
|
|||||||
|
|
||||||
- `chanlun/analysis/wyckoff/range.py` / `events.py`(阶段)启发式与单测
|
- `chanlun/analysis/wyckoff/range.py` / `events.py`(阶段)启发式与单测
|
||||||
- `web/static/js/app/chart_tv.js` 威科夫 VP/填充绘制路径
|
- `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` 键)
|
- ESS 文档与契约测试补充断言(不删既有 `wyckoff` 键)
|
||||||
|
|
||||||
### Forbidden
|
### Forbidden
|
||||||
@@ -28,23 +28,29 @@
|
|||||||
- `config/` / `strategies/`
|
- `config/` / `strategies/`
|
||||||
- `/chan_tv`
|
- `/chan_tv`
|
||||||
- 新数据源 / 订单流
|
- 新数据源 / 订单流
|
||||||
|
- **按币种独立参数表**(全局 ATR 相对即可;当前以 BTC 场景验证)
|
||||||
|
|
||||||
|
## Decisions(Approve 时锁定)
|
||||||
|
|
||||||
|
- VP:**A+C**(前端 Top-N 有量 bin + 服务端 bins 上限 24)
|
||||||
|
- 不做 per-symbol 参数
|
||||||
|
|
||||||
## Risk
|
## Risk
|
||||||
|
|
||||||
| Risk | Mitigation |
|
| Risk | Mitigation |
|
||||||
|------|------------|
|
|------|------------|
|
||||||
| TR 结果相对 003 漂移 | 合成夹具锁定高低与起点;文档标明启发式迭代 |
|
| TR 结果相对 003 漂移 | 合成夹具锁定高低与起点;文档标明启发式迭代 |
|
||||||
| 前端 VP 观感变化 | 保留 POC/VAH/VAL;密度可用更少线表示 |
|
| 前端 VP 观感变化 | 保留 POC/VAH/VAL;密度用 Top-N |
|
||||||
| 回归 | 扩展 `tests/test_wyckoff.py` + 既有契约套件 |
|
| 回归 | 扩展 `tests/test_wyckoff.py` + 既有契约套件 |
|
||||||
|
|
||||||
## Acceptance Criteria
|
## Acceptance Criteria
|
||||||
|
|
||||||
- [ ] 合成箱体夹具:`trading_range` 高低接近箱体边界,起点不落入明显前置趋势段
|
- [x] 合成箱体夹具:`trading_range` 高低接近箱体边界,起点不落入明显前置趋势段
|
||||||
- [ ] 开启 VP 时主图新增 series 数显著低于「每 bin 一条」(目标:填充+VP ≤ ~15 或等价合并策略)
|
- [x] 开启 VP 时主图新增 series 数显著低于「每 bin 一条」(目标:填充+VP ≤ ~15 或等价合并策略)
|
||||||
- [ ] 阶段输出满足最小跨度或合并退化段;文档说明规则
|
- [x] 阶段输出满足最小跨度或合并退化段;文档说明规则
|
||||||
- [ ] `elements_only=true` 即使 `include_wyckoff=1` 也不返回 `wyckoff`(或明确文档例外——实现选前者)
|
- [x] `elements_only=true` 即使 `include_wyckoff=1` 也不返回 `wyckoff`
|
||||||
- [ ] golden 缠论基线不变;相关 pytest 绿
|
- [x] golden 缠论基线不变;相关 pytest 绿
|
||||||
- [ ] TEST/IMPL/CHANGELOG/TRACEABILITY + CODE_REVIEW
|
- [x] TEST/IMPL/CHANGELOG/TRACEABILITY + CODE_REVIEW
|
||||||
|
|
||||||
## Rollback
|
## Rollback
|
||||||
|
|
||||||
@@ -60,3 +66,4 @@
|
|||||||
- 上游: `docs/CODE_REVIEW/ECR-003.md` Findings 1–5
|
- 上游: `docs/CODE_REVIEW/ECR-003.md` Findings 1–5
|
||||||
- PRODUCT_SPEC / ENGINEERING_SPEC: 同目录 ECR-004-*
|
- PRODUCT_SPEC / ENGINEERING_SPEC: 同目录 ECR-004-*
|
||||||
- TRACEABILITY: Yes
|
- TRACEABILITY: Yes
|
||||||
|
- CODE_REVIEW: `docs/CODE_REVIEW/ECR-004.md` — Approve
|
||||||
|
|||||||
@@ -1,45 +1,43 @@
|
|||||||
# ENGINEERING_SPEC — ECR-004
|
# ENGINEERING_SPEC — ECR-004
|
||||||
|
|
||||||
**Status:** Draft
|
**Status:** Approved(实现锁定:评分选段;VP=A+C;无币种参数)
|
||||||
**Date:** 2026-08-06
|
**Date:** 2026-08-06
|
||||||
|
|
||||||
## Range scoring
|
## Range scoring
|
||||||
|
|
||||||
替换「仅取最长合格窗口」:
|
替换「仅取最长合格窗口」:
|
||||||
|
|
||||||
1. 仍在 `lookback` + `tail_reserve` 框架内扫描候选段(步长可保留)。
|
1. 仍在 `lookback` + `tail_reserve` 框架内扫描候选段(步长 -4)。
|
||||||
2. 对每段计算分数,例如:
|
2. 硬门槛不变:near_hi/lo≥2、inside≥0.75、宽度上限等。
|
||||||
- `touch_score` = near_hi + near_lo(触边上沿/下沿次数)
|
3. 分数:`touch_density*50 + inside*30 - (width/ATR)*3 + min(length/40, 2)`,取最高。
|
||||||
- `width_penalty` = width / ATR(过宽扣分)
|
4. 单测:`low∈[38,42]`、`high∈[58,62]`,起点不早于箱体(容差 8 根);`abs_start_idx >= 12`。
|
||||||
- `recency_bonus` = 段终点靠近 core 末
|
|
||||||
- 可选:段内 close 在 [low,high] 比例
|
|
||||||
3. 取分数最高且满足既有硬门槛(near_hi/lo≥2、inside≥0.75 等)的段。
|
|
||||||
4. 单测:`_box_df` 断言 `low∈[38,42]`、`high∈[58,62]`,且 `start` 不早于箱体起始(允许小容差)。
|
|
||||||
|
|
||||||
## Phases
|
## Phases
|
||||||
|
|
||||||
- 为 A–E 设最小 bar 数(如 ≥3);不足则与相邻段合并或跳过空标签。
|
- 非重叠链式切分;每段至少 `min_bars=3`。
|
||||||
- 避免 D/E 完全同起止仍输出两条重复色带。
|
- 尾部空间不足则延长上一段并停止新增(避免 D/E 完全重合双画)。
|
||||||
|
|
||||||
## API gate
|
## API gate
|
||||||
|
|
||||||
```text
|
```text
|
||||||
if include_wyckoff and not elements_only:
|
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 仍全画
|
- 填充线 6→3
|
||||||
- **B**:用少量水平线 + 透明度映射,或单 series 多段(若 Lightweight 版本支持)
|
- 有量 bin 按 volume Top-8 绘制 + POC/VAH/VAL
|
||||||
- **C**:服务端默认 `vp_bins` 上限降到 24,前端再 Top-N
|
- 目标:区间填充+边框+VP ≈ ≤15 series 量级
|
||||||
|
|
||||||
区间填充线可从 6 降到 3。
|
|
||||||
|
|
||||||
## Tests
|
## Tests
|
||||||
|
|
||||||
- 扩展 `tests/test_wyckoff.py`
|
- `tests/test_wyckoff.py` 收紧
|
||||||
- 契约:`elements_only=true&include_wyckoff=1` 无 `wyckoff`
|
- `elements_only=true&include_wyckoff=1` 无 `wyckoff`
|
||||||
- 不改 golden 缠论 JSON
|
- 不改 golden 缠论 JSON
|
||||||
|
|
||||||
|
## Non-goals
|
||||||
|
|
||||||
|
- 按币种独立参数(全局 ATR 相对;以 BTC 场景验证)
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
# HANDOFF — ECR-004 engineer → reviewer
|
||||||
|
|
||||||
|
**Date:** 2026-08-06
|
||||||
|
|
||||||
|
已实现并自测 14 passed。请对照 `docs/CODE_REVIEW/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 相关套件)。
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
# PRODUCT_SPEC — ECR-004
|
# PRODUCT_SPEC — ECR-004
|
||||||
|
|
||||||
**Status:** Draft
|
**Status:** Approved
|
||||||
**Date:** 2026-08-06
|
**Date:** 2026-08-06
|
||||||
|
|
||||||
## Goal
|
## Goal
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ Trading System(缠论分析引擎 + 可视化 Web;Freqtrade 策略目录独
|
|||||||
|
|
||||||
## Active anchors
|
## Active anchors
|
||||||
|
|
||||||
- ECR: ECR-004 Draft(威科夫硬化);ECR-002/003 Reviewed
|
- ECR: ECR-002/003/004 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`
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
# STATE
|
# STATE
|
||||||
|
|
||||||
**owner:** architect
|
**owner:** idle
|
||||||
**active_ecr:** ECR-004
|
**active_ecr:** none(ECR-004 Reviewed;待本批提交合入)
|
||||||
**phase:** draft_spec(待 Approve 后实现)
|
**phase:** post-review
|
||||||
**system_version:** v1.0.0
|
**system_version:** v1.0.0
|
||||||
**strategy_version:** unchanged
|
**strategy_version:** unchanged
|
||||||
**updated:** 2026-08-06
|
**updated:** 2026-08-06
|
||||||
@@ -15,10 +15,9 @@
|
|||||||
| IDEA-002 | L1 | Done | `9f1e736` |
|
| IDEA-002 | L1 | Done | `9f1e736` |
|
||||||
| ECR-002 | L3 | Done (Reviewed) | runtime 包拆分 |
|
| ECR-002 | L3 | Done (Reviewed) | runtime 包拆分 |
|
||||||
| ECR-003 | L2 | Done (Reviewed) | `081a57a` 主站威科夫 |
|
| ECR-003 | L2 | Done (Reviewed) | `081a57a` 主站威科夫 |
|
||||||
| ECR-004 | L2 | Draft | 威科夫硬化 / VP 减负;见 IDEA-005 |
|
| ECR-004 | L2 | Done (Reviewed) | 威科夫硬化 / VP 减负 |
|
||||||
|
|
||||||
## Notes
|
## Notes
|
||||||
|
|
||||||
- ECR-003 已合入;Findings → ECR-004
|
- ECR-004:**Approve**(14 passed);无币种独立参数
|
||||||
- ECR-004 待 Human/Architect **Approve** 后再编码
|
|
||||||
- 未请求新 system tag
|
- 未请求新 system tag
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
task_id: ECR-004
|
task_id: ECR-004
|
||||||
title: 威科夫区间评分硬化与主站 VP 绘图减负
|
title: 威科夫区间评分硬化与主站 VP 绘图减负
|
||||||
status: draft
|
status: done_reviewed
|
||||||
change_level: L2
|
change_level: L2
|
||||||
ecr: docs/ECR/ECR-004-wyckoff-harden.md
|
ecr: docs/ECR/ECR-004-wyckoff-harden.md
|
||||||
idea: docs/IDEA/IDEA-005-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.
|
||||||
|
|||||||
@@ -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 填充实现。
|
||||||
@@ -37,10 +37,10 @@
|
|||||||
| ECR-003 | 主站 Lightweight 叠层 | PRODUCT-003 | `index.html` `chart_tv.js` `chart_view.js` | 人工 + 开关接线 |
|
| 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-003 | 契约可选键文档 | ENG-003 | `analyze_contract_keys.json` | golden keys file 断言 |
|
||||||
|
|
||||||
## ECR-004(Draft)
|
## ECR-004
|
||||||
|
|
||||||
| ECR | Requirement | Spec | Code | Test |
|
| ECR | Requirement | Spec | Code | Test |
|
||||||
|-----|-------------|------|------|------|
|
|-----|-------------|------|------|------|
|
||||||
| ECR-004 | TR 评分选最优段 | ENG-004 | TBD `wyckoff/range.py` | 收紧 `test_wyckoff` |
|
| ECR-004 | TR 评分选最优段 | ENG-004 | `wyckoff/range.py` | `test_wyckoff` / `test_range_scoring_skips_pretrend` |
|
||||||
| ECR-004 | VP/填充少 series | ENG-004 | TBD `chart_tv.js` | 人工 / 约定上限 |
|
| ECR-004 | VP/填充少 series | ENG-004 | `chart_tv.js` Top-8 + 填充 3;bins≤24 | 人工 + ENG |
|
||||||
| ECR-004 | 阶段最小长度 + elements_only 门闩 | ENG-004 | TBD events + analyze | 契约补充 |
|
| ECR-004 | 阶段最小长度 + elements_only 门闩 | ENG-004 | `events.py` + `analyze.py` | 契约 `elements_only` |
|
||||||
|
|||||||
+22
-11
@@ -1,4 +1,4 @@
|
|||||||
"""威科夫引擎单测:合成震荡箱 + Spring/SOS + VP POC。"""
|
"""威科夫引擎单测:合成震荡箱 + Spring/SOS + VP POC(ECR-004 收紧)。"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import sys
|
import sys
|
||||||
@@ -11,10 +11,11 @@ ROOT = Path(__file__).resolve().parents[1]
|
|||||||
sys.path.insert(0, str(ROOT))
|
sys.path.insert(0, str(ROOT))
|
||||||
|
|
||||||
from chanlun.analysis.wyckoff import analyze_wyckoff # noqa: E402
|
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:
|
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)
|
rng = np.random.default_rng(7)
|
||||||
rows = []
|
rows = []
|
||||||
t0 = pd.Timestamp("2024-06-01", tz="UTC")
|
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)
|
o = c + rng.normal(0, 0.5)
|
||||||
h = min(hi + 0.5, max(o, c) + abs(rng.normal(0.5, 0.2)))
|
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)))
|
l = max(lo - 0.5, min(o, c) - abs(rng.normal(0.5, 0.2)))
|
||||||
# 触及边界
|
|
||||||
if i % 7 == 0:
|
if i % 7 == 0:
|
||||||
h = hi - 0.1
|
h = hi - 0.1
|
||||||
if i % 7 == 3:
|
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
|
base = 20 + n_box
|
||||||
if spring:
|
if spring:
|
||||||
# 假破下沿
|
|
||||||
rows.append(
|
rows.append(
|
||||||
(
|
(
|
||||||
t0 + pd.Timedelta(minutes=5 * base),
|
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
|
base += 1
|
||||||
# LPS 缩量回踩
|
|
||||||
rows.append(
|
rows.append(
|
||||||
(
|
(
|
||||||
t0 + pd.Timedelta(minutes=5 * base),
|
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 pd.DataFrame(rows, columns=["date", "open", "high", "low", "close", "volume"])
|
||||||
return df
|
|
||||||
|
|
||||||
|
|
||||||
def test_wyckoff_detects_range_and_events():
|
def test_wyckoff_detects_range_and_events():
|
||||||
@@ -94,15 +91,29 @@ def test_wyckoff_detects_range_and_events():
|
|||||||
out = analyze_wyckoff(df, lookback=200)
|
out = analyze_wyckoff(df, lookback=200)
|
||||||
assert out["trading_range"] is not None
|
assert out["trading_range"] is not None
|
||||||
tr = out["trading_range"]
|
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"]}
|
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 out["bias"] in ("accumulation", "distribution", "unknown")
|
||||||
assert len(out["phases"]) >= 3
|
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():
|
def test_volume_profile_poc_on_heavy_bin():
|
||||||
# 平坦箱 + 中间价放量
|
|
||||||
dates = pd.date_range("2024-01-01", periods=40, freq="5min", tz="UTC")
|
dates = pd.date_range("2024-01-01", periods=40, freq="5min", tz="UTC")
|
||||||
rows = []
|
rows = []
|
||||||
for i, d in enumerate(dates):
|
for i, d in enumerate(dates):
|
||||||
@@ -114,4 +125,4 @@ def test_volume_profile_poc_on_heavy_bin():
|
|||||||
vp = out["volume_profile"]
|
vp = out["volume_profile"]
|
||||||
assert vp["poc"] is not None
|
assert vp["poc"] is not None
|
||||||
assert vp["vah"] is not None and vp["val"] 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
|
||||||
|
|||||||
+5
-4
@@ -656,18 +656,19 @@ def analyze():
|
|||||||
else:
|
else:
|
||||||
result['structure_zones'] = []
|
result['structure_zones'] = []
|
||||||
|
|
||||||
# 威科夫分析 —— 按需:include_wyckoff=1
|
# 威科夫分析 —— 按需:include_wyckoff=1,且须有主周期分析(非 elements_only)
|
||||||
include_wyckoff_param = request.args.get('include_wyckoff', '')
|
include_wyckoff_param = request.args.get('include_wyckoff', '')
|
||||||
include_wyckoff = str(include_wyckoff_param).lower() in ('1', 'true', 'yes')
|
include_wyckoff = str(include_wyckoff_param).lower() in ('1', 'true', 'yes')
|
||||||
if include_wyckoff:
|
if include_wyckoff and not elements_only:
|
||||||
try:
|
try:
|
||||||
from chanlun.analysis.wyckoff import analyze_wyckoff
|
from chanlun.analysis.wyckoff import analyze_wyckoff
|
||||||
wyckoff_lookback = int(request.args.get('wyckoff_lookback', 120))
|
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(
|
result['wyckoff'] = analyze_wyckoff(
|
||||||
df,
|
df,
|
||||||
lookback=max(40, min(wyckoff_lookback, 500)),
|
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:
|
except Exception as e:
|
||||||
print(f"Wyckoff 分析出错: {e}")
|
print(f"Wyckoff 分析出错: {e}")
|
||||||
|
|||||||
@@ -2222,7 +2222,8 @@ function initTradingView(symbol, timeframe) {
|
|||||||
if (!isNaN(t0) && !isNaN(t1) && !isNaN(hi) && !isNaN(lo)) {
|
if (!isNaN(t0) && !isNaN(t1) && !isNaN(hi) && !isNaN(lo)) {
|
||||||
const fill = 'rgba(52, 152, 219, 0.07)';
|
const fill = 'rgba(52, 152, 219, 0.07)';
|
||||||
const border = 'rgba(52, 152, 219, 0.75)';
|
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);
|
const step = (hi - lo) / (fillLines + 1);
|
||||||
for (let fi = 1; fi <= fillLines; fi++) {
|
for (let fi = 1; fi <= fillLines; fi++) {
|
||||||
const fy = lo + step * fi;
|
const fy = lo + step * fi;
|
||||||
@@ -2320,20 +2321,29 @@ function initTradingView(symbol, timeframe) {
|
|||||||
const t1 = tr.end_time ? parseTs(tr.end_time) : chartEnd;
|
const t1 = tr.end_time ? parseTs(tr.end_time) : chartEnd;
|
||||||
if (!isNaN(t1)) {
|
if (!isNaN(t1)) {
|
||||||
const bins = vp.bins || [];
|
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;
|
let maxVol = 0;
|
||||||
bins.forEach(function(b) { if (b.volume > maxVol) maxVol = b.volume; });
|
ranked.forEach(function(b) { if (b.volume > maxVol) maxVol = b.volume; });
|
||||||
const maxWidthSec = Math.max(60, Math.floor((t1 - parseTs(tr.start_time)) * 0.15));
|
const tStart = parseTs(tr.start_time);
|
||||||
bins.forEach(function(b) {
|
const maxWidthSec = Math.max(60, Math.floor((t1 - (isNaN(tStart) ? t1 : tStart)) * 0.15));
|
||||||
|
ranked.forEach(function(b) {
|
||||||
if (!b.volume || maxVol <= 0) return;
|
if (!b.volume || maxVol <= 0) return;
|
||||||
const wSec = Math.max(1, Math.floor(maxWidthSec * (b.volume / maxVol)));
|
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({
|
mainChart.addLineSeries({
|
||||||
color: 'rgba(142, 68, 173, ' + alpha.toFixed(2) + ')',
|
color: 'rgba(142, 68, 173, ' + alpha.toFixed(2) + ')',
|
||||||
lineWidth: 1,
|
lineWidth: 1,
|
||||||
lastValueVisible: false,
|
lastValueVisible: false,
|
||||||
priceLineVisible: false
|
priceLineVisible: false
|
||||||
}).setData([
|
}).setData([
|
||||||
{ time: t1 - wSec, value: b.price },
|
{ time: leftT, value: b.price },
|
||||||
{ time: t1, value: b.price }
|
{ time: t1, value: b.price }
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -157,3 +157,32 @@ def test_analyze_http_wyckoff_opt_in():
|
|||||||
w = payload["wyckoff"]
|
w = payload["wyckoff"]
|
||||||
for k in WYCKOFF_KEYS:
|
for k in WYCKOFF_KEYS:
|
||||||
assert k in w, f"missing wyckoff key: {k}"
|
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
|
||||||
|
|||||||
Reference in New Issue
Block a user