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],
|
||||
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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 少系列 + 阶段/门闩/单测(无币种参数)
|
||||
|
||||
## 硬约束提醒
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
**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
|
||||
|
||||
@@ -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 场景验证)
|
||||
|
||||
@@ -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
|
||||
|
||||
**Status:** Draft
|
||||
**Status:** Approved
|
||||
**Date:** 2026-08-06
|
||||
|
||||
## Goal
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 | 契约可选键文档 | 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` |
|
||||
|
||||
+22
-11
@@ -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
|
||||
|
||||
+5
-4
@@ -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}")
|
||||
|
||||
@@ -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 }
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user