feat: ECR-003 主站威科夫分析与图表叠层(已审)
独立 wyckoff 引擎 + 按需 include_wyckoff;主站 Lightweight 绘制区间/阶段/事件/VP。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,6 @@
|
|||||||
|
"""威科夫分析(启发式):交易区间 / 阶段 / 事件 / Volume Profile。"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from .engine import analyze_wyckoff
|
||||||
|
|
||||||
|
__all__ = ["analyze_wyckoff"]
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
"""交易区间检测:ATR 容差下的近期震荡箱。"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any, Dict, Optional
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
|
||||||
|
def _atr(df: pd.DataFrame, period: int = 14) -> pd.Series:
|
||||||
|
high = df["high"].astype(float)
|
||||||
|
low = df["low"].astype(float)
|
||||||
|
close = df["close"].astype(float)
|
||||||
|
prev_close = close.shift(1)
|
||||||
|
tr = pd.concat(
|
||||||
|
[
|
||||||
|
(high - low).abs(),
|
||||||
|
(high - prev_close).abs(),
|
||||||
|
(low - prev_close).abs(),
|
||||||
|
],
|
||||||
|
axis=1,
|
||||||
|
).max(axis=1)
|
||||||
|
return tr.rolling(period, min_periods=max(3, period // 2)).mean()
|
||||||
|
|
||||||
|
|
||||||
|
def detect_trading_range(
|
||||||
|
df: pd.DataFrame,
|
||||||
|
lookback: int = 120,
|
||||||
|
min_bars: int = 24,
|
||||||
|
atr_mult: float = 1.2,
|
||||||
|
tail_reserve: int = 12,
|
||||||
|
) -> Optional[Dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
在最近 lookback 根内寻找高低点波动受控的连续段作为交易区间。
|
||||||
|
尾部预留 tail_reserve 根用于事件(Spring/SOS),不参与箱体边界计算。
|
||||||
|
"""
|
||||||
|
if df is None or len(df) < min_bars + 5:
|
||||||
|
return None
|
||||||
|
work = df.tail(lookback).reset_index(drop=True)
|
||||||
|
n = len(work)
|
||||||
|
reserve = min(tail_reserve, max(0, n - min_bars - 2))
|
||||||
|
core_end = n - reserve if reserve > 0 else n
|
||||||
|
core = work.iloc[:core_end]
|
||||||
|
if len(core) < min_bars:
|
||||||
|
core = work
|
||||||
|
core_end = n
|
||||||
|
reserve = 0
|
||||||
|
|
||||||
|
atr = _atr(work)
|
||||||
|
last_atr = float(atr.iloc[core_end - 1]) if atr.notna().iloc[:core_end].any() else float(
|
||||||
|
(core["high"] - core["low"]).mean()
|
||||||
|
)
|
||||||
|
if not np.isfinite(last_atr) or last_atr <= 0:
|
||||||
|
last_atr = float(core["close"].iloc[-1]) * 0.01
|
||||||
|
|
||||||
|
best = None
|
||||||
|
cn = len(core)
|
||||||
|
for length in range(min(cn, lookback), min_bars - 1, -4):
|
||||||
|
seg = core.iloc[-length:]
|
||||||
|
hi = float(seg["high"].max())
|
||||||
|
lo = float(seg["low"].min())
|
||||||
|
width = hi - lo
|
||||||
|
if width <= 0 or width > last_atr * atr_mult * 3.5:
|
||||||
|
continue
|
||||||
|
tol = last_atr * atr_mult * 0.35
|
||||||
|
near_hi = int((seg["high"] >= hi - tol).sum())
|
||||||
|
near_lo = int((seg["low"] <= lo + tol).sum())
|
||||||
|
if near_hi < 2 or near_lo < 2:
|
||||||
|
continue
|
||||||
|
inside = ((seg["close"] >= lo - tol) & (seg["close"] <= hi + tol)).mean()
|
||||||
|
if inside < 0.75:
|
||||||
|
continue
|
||||||
|
start_i = cn - length
|
||||||
|
end_i = cn - 1
|
||||||
|
mid = (hi + lo) / 2.0
|
||||||
|
last_c = float(work["close"].iloc[-1])
|
||||||
|
active = (lo - tol * 1.5) <= last_c <= (hi + tol * 1.5)
|
||||||
|
best = {
|
||||||
|
"start_idx": int(start_i),
|
||||||
|
"end_idx": int(end_i),
|
||||||
|
"high": hi,
|
||||||
|
"low": lo,
|
||||||
|
"mid": mid,
|
||||||
|
"active": bool(active),
|
||||||
|
"atr": last_atr,
|
||||||
|
"tol": tol,
|
||||||
|
"bars": int(length),
|
||||||
|
}
|
||||||
|
break
|
||||||
|
|
||||||
|
if best is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _ts(row) -> Any:
|
||||||
|
if "date" in work.columns and pd.notna(row["date"]):
|
||||||
|
return row["date"]
|
||||||
|
if "timestamp" in work.columns:
|
||||||
|
return row["timestamp"]
|
||||||
|
return None
|
||||||
|
|
||||||
|
best["start_time"] = _ts(work.iloc[best["start_idx"]])
|
||||||
|
# 区间时间结束取 core 末,事件可落在其后
|
||||||
|
best["end_time"] = _ts(work.iloc[best["end_idx"]])
|
||||||
|
offset = len(df) - len(work)
|
||||||
|
best["abs_start_idx"] = offset + best["start_idx"]
|
||||||
|
best["abs_end_idx"] = offset + best["end_idx"]
|
||||||
|
best["abs_scan_end_idx"] = offset + n - 1
|
||||||
|
return best
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
"""区间内 Volume Profile。"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any, Dict, List
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
|
||||||
|
def compute_volume_profile(
|
||||||
|
df: pd.DataFrame,
|
||||||
|
start_idx: int,
|
||||||
|
end_idx: int,
|
||||||
|
bin_count: int = 50,
|
||||||
|
value_area_pct: float = 0.70,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
seg = df.iloc[start_idx : end_idx + 1]
|
||||||
|
if seg.empty:
|
||||||
|
return {"bins": [], "poc": None, "vah": None, "val": None, "bin_count": bin_count}
|
||||||
|
|
||||||
|
typical = (seg["high"].astype(float) + seg["low"].astype(float) + seg["close"].astype(float)) / 3.0
|
||||||
|
vol = seg["volume"].astype(float).fillna(0.0)
|
||||||
|
lo = float(seg["low"].min())
|
||||||
|
hi = float(seg["high"].max())
|
||||||
|
if not np.isfinite(lo) or not np.isfinite(hi) or hi <= lo:
|
||||||
|
mid = float(seg["close"].iloc[-1])
|
||||||
|
return {
|
||||||
|
"bins": [{"price": mid, "volume": float(vol.sum())}],
|
||||||
|
"poc": mid,
|
||||||
|
"vah": mid,
|
||||||
|
"val": mid,
|
||||||
|
"bin_count": 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
edges = np.linspace(lo, hi, bin_count + 1)
|
||||||
|
# 右开最后一桶闭合
|
||||||
|
idx = np.clip(np.digitize(typical.values, edges) - 1, 0, bin_count - 1)
|
||||||
|
vols = np.zeros(bin_count, dtype=float)
|
||||||
|
for i, v in zip(idx, vol.values):
|
||||||
|
vols[i] += float(v)
|
||||||
|
|
||||||
|
centers = (edges[:-1] + edges[1:]) / 2.0
|
||||||
|
poc_i = int(np.argmax(vols)) if vols.sum() > 0 else bin_count // 2
|
||||||
|
poc = float(centers[poc_i])
|
||||||
|
|
||||||
|
# Value Area:从 POC 向两侧扩展直到累计 >= value_area_pct
|
||||||
|
total = float(vols.sum()) or 1.0
|
||||||
|
target = total * value_area_pct
|
||||||
|
left = right = poc_i
|
||||||
|
acc = float(vols[poc_i])
|
||||||
|
while acc < target and (left > 0 or right < bin_count - 1):
|
||||||
|
left_v = vols[left - 1] if left > 0 else -1.0
|
||||||
|
right_v = vols[right + 1] if right < bin_count - 1 else -1.0
|
||||||
|
if right_v >= left_v and right < bin_count - 1:
|
||||||
|
right += 1
|
||||||
|
acc += float(vols[right])
|
||||||
|
elif left > 0:
|
||||||
|
left -= 1
|
||||||
|
acc += float(vols[left])
|
||||||
|
else:
|
||||||
|
break
|
||||||
|
|
||||||
|
bins: List[Dict[str, float]] = [
|
||||||
|
{"price": float(centers[i]), "volume": float(vols[i])} for i in range(bin_count)
|
||||||
|
]
|
||||||
|
return {
|
||||||
|
"bins": bins,
|
||||||
|
"poc": poc,
|
||||||
|
"vah": float(centers[right]),
|
||||||
|
"val": float(centers[left]),
|
||||||
|
"bin_count": bin_count,
|
||||||
|
}
|
||||||
@@ -19,18 +19,20 @@
|
|||||||
## 近期变更
|
## 近期变更
|
||||||
|
|
||||||
- IDEA-002 / `9f1e736`:主站内存泄漏 dispose、首屏单次 analyze、ChanMACD 复用、chan_tv 体验
|
- IDEA-002 / `9f1e736`:主站内存泄漏 dispose、首屏单次 analyze、ChanMACD 复用、chan_tv 体验
|
||||||
- ECR-002 Draft:拆 `web/services/runtime.py`、加深 analyze 契约
|
- ECR-002 Reviewed:拆 `web/services/runtime/`、加深 analyze 契约
|
||||||
|
- ECR-003 Reviewed:主站威科夫叠层(`chanlun/analysis/wyckoff/` + `include_wyckoff`)
|
||||||
|
|
||||||
## 硬约束提醒
|
## 硬约束提醒
|
||||||
|
|
||||||
- `/api/analyze` 字段可增不可删
|
- `/api/analyze` 字段可增不可删
|
||||||
- 无 ADR 不改笔/段/中枢/买卖点语义
|
- 无 ADR 不改笔/段/中枢/买卖点语义
|
||||||
|
- 威科夫为独立叠层(ECR-003);勿借机改缠论算法
|
||||||
- 交易 L2+ → RISK_REVIEW + EXP;Live 须 Human
|
- 交易 L2+ → RISK_REVIEW + EXP;Live 须 Human
|
||||||
|
|
||||||
## 已知债务
|
## 已知债务
|
||||||
|
|
||||||
- ~~`runtime.py` 仍过大 → ECR-002~~ **已拆包**(待 CODE_REVIEW)
|
|
||||||
- `chart_tv.js` 单体巨大 → 后续可选 ECR
|
- `chart_tv.js` 单体巨大 → 后续可选 ECR
|
||||||
- analyze 契约已加深(mock HTTP);可再加固定 JSON 快照文件
|
- analyze 契约已加深(mock HTTP + wyckoff opt-in);可再加固定 JSON 快照文件
|
||||||
- 内存泄漏尚无自动化 heap/监听断言
|
- 内存泄漏尚无自动化 heap/监听断言
|
||||||
- `macd_config` POST 写本地 global 的历史 quirks(未改)
|
- `macd_config` POST 写本地 global 的历史 quirks(未改)
|
||||||
|
- 威科夫启发式参数未做 UI 调参
|
||||||
|
|||||||
@@ -2,7 +2,14 @@
|
|||||||
|
|
||||||
## Unreleased — 2026-08-06
|
## 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)
|
- 拆分 `web/services/runtime.py` 为包 `web/services/runtime/`(state / timeframes / market_data / indicators / analyze / serialize)
|
||||||
- 加深 analyze 契约测试(mock HTTP + analyze_chan 键集 + serialize JSON)
|
- 加深 analyze 契约测试(mock HTTP + analyze_chan 键集 + serialize JSON)
|
||||||
|
|||||||
@@ -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 绘图优化。
|
||||||
@@ -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
|
||||||
@@ -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` 绘制。
|
||||||
@@ -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 可见。
|
||||||
@@ -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
|
||||||
@@ -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)后续可调
|
||||||
@@ -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
|
||||||
|
|
||||||
|
人工可在合成/实盘图上辨认区间与事件;自动化单测覆盖核心检出。
|
||||||
@@ -25,6 +25,7 @@ Trading System(缠论分析引擎 + 可视化 Web;Freqtrade 策略目录独
|
|||||||
- 无 ECR 破坏 `/api/analyze` JSON 契约(可增不可删)
|
- 无 ECR 破坏 `/api/analyze` JSON 契约(可增不可删)
|
||||||
- 引入 Kafka / MongoDB / 微服务拆分(除非新 ADR)
|
- 引入 Kafka / MongoDB / 微服务拆分(除非新 ADR)
|
||||||
- 本轮引入 Vite/React/TS 构建流水线
|
- 本轮引入 Vite/React/TS 构建流水线
|
||||||
|
- 威科夫等**独立分析叠层**须走 ECR(可增 API 字段);不得借机改缠论算法
|
||||||
|
|
||||||
## Versioning
|
## Versioning
|
||||||
|
|
||||||
@@ -33,8 +34,8 @@ Trading System(缠论分析引擎 + 可视化 Web;Freqtrade 策略目录独
|
|||||||
|
|
||||||
## Active anchors
|
## Active anchors
|
||||||
|
|
||||||
- ECR: ECR-001 Released;ECR-002 Draft
|
- ECR: ECR-002 / ECR-003 Reviewed(主站威科夫)
|
||||||
- EXP: N/A(当前无进行中的交易行为实验)
|
- EXP: N/A
|
||||||
- TRACEABILITY: `docs/TRACEABILITY.md`
|
- TRACEABILITY: `docs/TRACEABILITY.md`
|
||||||
- Memory: `docs/AGENT_MEMORY.md`
|
- Memory: `docs/AGENT_MEMORY.md`
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
# RISK_REVIEW — ECR-003
|
||||||
|
|
||||||
|
**Status:** N/A
|
||||||
|
**Date:** 2026-08-06
|
||||||
|
|
||||||
|
展示用威科夫分析叠层,不改 Freqtrade 策略或 Live 下单。启发式误标风险由 UI 开关与文档说明缓解。
|
||||||
|
|
||||||
|
**Conclusion:** N/A(非交易执行变更)
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
# STATE
|
# STATE
|
||||||
|
|
||||||
**owner:** idle
|
**owner:** idle
|
||||||
**active_ecr:** none(ECR-002 Reviewed;待合并提交)
|
**active_ecr:** none
|
||||||
**phase:** post-review
|
**phase:** idle
|
||||||
**system_version:** v1.0.0
|
**system_version:** v1.0.0
|
||||||
**strategy_version:** unchanged
|
**strategy_version:** unchanged
|
||||||
**updated:** 2026-08-06
|
**updated:** 2026-08-06
|
||||||
@@ -13,10 +13,10 @@
|
|||||||
|----|-------|--------|------|
|
|----|-------|--------|------|
|
||||||
| ECR-001 | L3 | Released `v1.0.0` | |
|
| ECR-001 | L3 | Released `v1.0.0` | |
|
||||||
| IDEA-002 | L1 | Done | `9f1e736` |
|
| IDEA-002 | L1 | Done | `9f1e736` |
|
||||||
| ECR-002 | L3 | Done (Reviewed) | runtime 包拆分;见 `docs/CODE_REVIEW/ECR-002.md` |
|
| ECR-002 | L3 | Done (Reviewed) | runtime 包拆分 |
|
||||||
|
| ECR-003 | L2 | Done (Reviewed) | 主站威科夫;待本提交合入 |
|
||||||
|
|
||||||
## Notes
|
## Notes
|
||||||
|
|
||||||
- CODE_REVIEW:**Approve**(13 passed;非阻断项见 review Findings)
|
- ECR-003 CODE_REVIEW:**Approve**;Findings 另开 ECR-004
|
||||||
- 工作区仍有未提交实现;合并后可清 active_ecr
|
- 未请求新 system tag(仍 Unreleased 文档累计)
|
||||||
- 未请求新 system tag
|
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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 内容。
|
||||||
@@ -27,3 +27,12 @@
|
|||||||
| ECR-002 | 加深 analyze 契约 | ENG-002 | `web/tests/test_analyze_contract.py` | mock HTTP + 键快照 |
|
| ECR-002 | 加深 analyze 契约 | ENG-002 | `web/tests/test_analyze_contract.py` | mock HTTP + 键快照 |
|
||||||
| ECR-002 | TF_DF 全量 init 冒烟 | ENG-002 | — | `tests/test_tf_df_init.py` |
|
| ECR-002 | TF_DF 全量 init 冒烟 | ENG-002 | — | `tests/test_tf_df_init.py` |
|
||||||
| ECR-002 | chart_tv 拆分(可选) | ENG-002 | 未做 | — |
|
| ECR-002 | chart_tv 拆分(可选) | ENG-002 | 未做 | — |
|
||||||
|
|
||||||
|
## ECR-003
|
||||||
|
|
||||||
|
| ECR | Requirement | Spec | Code | Test |
|
||||||
|
|-----|-------------|------|------|------|
|
||||||
|
| ECR-003 | 威科夫引擎(区间/阶段/事件/VP) | ENG-003 | `chanlun/analysis/wyckoff/` | `tests/test_wyckoff.py` |
|
||||||
|
| ECR-003 | analyze 按需 `include_wyckoff` | ENG-003 | `web/api/analyze.py` | `test_analyze_http_wyckoff_opt_in` |
|
||||||
|
| ECR-003 | 主站 Lightweight 叠层 | PRODUCT-003 | `index.html` `chart_tv.js` `chart_view.js` | 人工 + 开关接线 |
|
||||||
|
| ECR-003 | 契约可选键文档 | ENG-003 | `analyze_contract_keys.json` | golden keys file 断言 |
|
||||||
|
|||||||
+31
-17
@@ -1,17 +1,31 @@
|
|||||||
[
|
{
|
||||||
"bi_list",
|
"required": [
|
||||||
"bi_zs_list",
|
"bi_list",
|
||||||
"bsp_list",
|
"bi_zs_list",
|
||||||
"chan_macd",
|
"bsp_list",
|
||||||
"klc_fx_info",
|
"chan_macd",
|
||||||
"klc_list",
|
"klc_fx_info",
|
||||||
"klc_trend",
|
"klc_list",
|
||||||
"kline_data",
|
"klc_trend",
|
||||||
"macd",
|
"kline_data",
|
||||||
"seg_list",
|
"macd",
|
||||||
"timezone",
|
"seg_list",
|
||||||
"uncompleted_bi_list",
|
"timezone",
|
||||||
"uncompleted_seg_list",
|
"uncompleted_bi_list",
|
||||||
"uncompleted_zs_list",
|
"uncompleted_seg_list",
|
||||||
"zs_list"
|
"uncompleted_zs_list",
|
||||||
]
|
"zs_list"
|
||||||
|
],
|
||||||
|
"optional_when": {
|
||||||
|
"include_structure_zones": ["structure_zones"],
|
||||||
|
"include_wyckoff": ["wyckoff"]
|
||||||
|
},
|
||||||
|
"wyckoff_keys": [
|
||||||
|
"trading_range",
|
||||||
|
"bias",
|
||||||
|
"phases",
|
||||||
|
"events",
|
||||||
|
"volume_profile",
|
||||||
|
"volume_confirm"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|||||||
+34
-20
@@ -128,27 +128,41 @@ def serialize_pipeline(tf) -> dict:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def analyze_contract_keys() -> list:
|
def analyze_contract_keys() -> dict:
|
||||||
"""文档化 /api/analyze 主周期关键字段(契约冒烟用)。"""
|
"""文档化 /api/analyze 主周期关键字段(契约冒烟用)。"""
|
||||||
return sorted(
|
return {
|
||||||
[
|
"required": sorted(
|
||||||
"timezone",
|
[
|
||||||
"kline_data",
|
"timezone",
|
||||||
"klc_list",
|
"kline_data",
|
||||||
"bi_list",
|
"klc_list",
|
||||||
"uncompleted_bi_list",
|
"bi_list",
|
||||||
"seg_list",
|
"uncompleted_bi_list",
|
||||||
"uncompleted_seg_list",
|
"seg_list",
|
||||||
"zs_list",
|
"uncompleted_seg_list",
|
||||||
"uncompleted_zs_list",
|
"zs_list",
|
||||||
"bi_zs_list",
|
"uncompleted_zs_list",
|
||||||
"bsp_list",
|
"bi_zs_list",
|
||||||
"klc_fx_info",
|
"bsp_list",
|
||||||
"macd",
|
"klc_fx_info",
|
||||||
"chan_macd",
|
"macd",
|
||||||
"klc_trend",
|
"chan_macd",
|
||||||
]
|
"klc_trend",
|
||||||
)
|
]
|
||||||
|
),
|
||||||
|
"optional_when": {
|
||||||
|
"include_structure_zones": ["structure_zones"],
|
||||||
|
"include_wyckoff": ["wyckoff"],
|
||||||
|
},
|
||||||
|
"wyckoff_keys": [
|
||||||
|
"trading_range",
|
||||||
|
"bias",
|
||||||
|
"phases",
|
||||||
|
"events",
|
||||||
|
"volume_profile",
|
||||||
|
"volume_confirm",
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def run_pipeline(df: pd.DataFrame):
|
def run_pipeline(df: pd.DataFrame):
|
||||||
|
|||||||
@@ -37,10 +37,15 @@ def test_compat_shim_still_works():
|
|||||||
|
|
||||||
|
|
||||||
def test_analyze_contract_keys_file():
|
def test_analyze_contract_keys_file():
|
||||||
keys = json.loads(
|
doc = json.loads(
|
||||||
(ROOT / "tests" / "fixtures" / "analyze_contract_keys.json").read_text(
|
(ROOT / "tests" / "fixtures" / "analyze_contract_keys.json").read_text(
|
||||||
encoding="utf-8"
|
encoding="utf-8"
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
keys = doc["required"] if isinstance(doc, dict) and "required" in doc else doc
|
||||||
for k in ("kline_data", "bi_list", "seg_list", "zs_list", "bsp_list"):
|
for k in ("kline_data", "bi_list", "seg_list", "zs_list", "bsp_list"):
|
||||||
assert k in keys
|
assert k in keys
|
||||||
|
if isinstance(doc, dict):
|
||||||
|
assert "include_wyckoff" in doc.get("optional_when", {})
|
||||||
|
for k in ("trading_range", "phases", "events", "volume_profile"):
|
||||||
|
assert k in doc.get("wyckoff_keys", [])
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -656,5 +656,32 @@ def analyze():
|
|||||||
else:
|
else:
|
||||||
result['structure_zones'] = []
|
result['structure_zones'] = []
|
||||||
|
|
||||||
|
# 威科夫分析 —— 按需:include_wyckoff=1
|
||||||
|
include_wyckoff_param = request.args.get('include_wyckoff', '')
|
||||||
|
include_wyckoff = str(include_wyckoff_param).lower() in ('1', 'true', 'yes')
|
||||||
|
if include_wyckoff:
|
||||||
|
try:
|
||||||
|
from chanlun.analysis.wyckoff import analyze_wyckoff
|
||||||
|
wyckoff_lookback = int(request.args.get('wyckoff_lookback', 120))
|
||||||
|
wyckoff_bins = int(request.args.get('wyckoff_vp_bins', 50))
|
||||||
|
result['wyckoff'] = analyze_wyckoff(
|
||||||
|
df,
|
||||||
|
lookback=max(40, min(wyckoff_lookback, 500)),
|
||||||
|
vp_bins=max(10, min(wyckoff_bins, 100)),
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Wyckoff 分析出错: {e}")
|
||||||
|
import traceback
|
||||||
|
traceback.print_exc()
|
||||||
|
result['wyckoff'] = {
|
||||||
|
'trading_range': None,
|
||||||
|
'bias': 'unknown',
|
||||||
|
'phases': [],
|
||||||
|
'events': [],
|
||||||
|
'volume_profile': {'bins': [], 'poc': None, 'vah': None, 'val': None, 'bin_count': 0},
|
||||||
|
'volume_confirm': {'avg_volume': 0.0, 'event_checks': {}},
|
||||||
|
'error': str(e),
|
||||||
|
}
|
||||||
|
|
||||||
return jsonify(result)
|
return jsonify(result)
|
||||||
|
|
||||||
|
|||||||
@@ -2199,6 +2199,165 @@ function initTradingView(symbol, timeframe) {
|
|||||||
}
|
}
|
||||||
} catch (e) { console.error('结构区整体绘制出错:', e); }
|
} catch (e) { console.error('结构区整体绘制出错:', e); }
|
||||||
}
|
}
|
||||||
|
// 威科夫叠层:区间 / 阶段 / 事件 / VP
|
||||||
|
if ($('#showWyckoff').is(':checked') && currentData.wyckoff) {
|
||||||
|
try {
|
||||||
|
const w = currentData.wyckoff;
|
||||||
|
const tr = w.trading_range;
|
||||||
|
const parseTs = function(t) {
|
||||||
|
if (t == null) return NaN;
|
||||||
|
if (typeof t === 'number') return Math.floor(t > 1e12 ? t / 1000 : t);
|
||||||
|
const ms = new Date(t).getTime();
|
||||||
|
return isNaN(ms) ? NaN : Math.floor(ms / 1000);
|
||||||
|
};
|
||||||
|
const kd = currentData.kline_data || [];
|
||||||
|
const chartEnd = kd.length
|
||||||
|
? Math.floor(new Date(kd[kd.length - 1].date).getTime() / 1000)
|
||||||
|
: NaN;
|
||||||
|
|
||||||
|
if ($('#showWyckoffRange').is(':checked') && tr) {
|
||||||
|
const t0 = parseTs(tr.start_time);
|
||||||
|
const t1 = tr.end_time ? parseTs(tr.end_time) : chartEnd;
|
||||||
|
const hi = parseFloat(tr.high), lo = parseFloat(tr.low), mid = parseFloat(tr.mid);
|
||||||
|
if (!isNaN(t0) && !isNaN(t1) && !isNaN(hi) && !isNaN(lo)) {
|
||||||
|
const fill = 'rgba(52, 152, 219, 0.07)';
|
||||||
|
const border = 'rgba(52, 152, 219, 0.75)';
|
||||||
|
const fillLines = 6;
|
||||||
|
const step = (hi - lo) / (fillLines + 1);
|
||||||
|
for (let fi = 1; fi <= fillLines; fi++) {
|
||||||
|
const fy = lo + step * fi;
|
||||||
|
mainChart.addLineSeries({ color: fill, lineWidth: 2, lastValueVisible: false, priceLineVisible: false })
|
||||||
|
.setData([{ time: t0, value: fy }, { time: t1, value: fy }]);
|
||||||
|
}
|
||||||
|
mainChart.addLineSeries({ color: border, lineWidth: 2, lastValueVisible: false, priceLineVisible: false })
|
||||||
|
.setData([{ time: t0, value: hi }, { time: t1, value: hi }]);
|
||||||
|
mainChart.addLineSeries({ color: border, lineWidth: 2, lastValueVisible: false, priceLineVisible: false })
|
||||||
|
.setData([{ time: t0, value: lo }, { time: t1, value: lo }]);
|
||||||
|
if (!isNaN(mid)) {
|
||||||
|
mainChart.addLineSeries({ color: border, lineWidth: 1, lineStyle: 2, lastValueVisible: false, priceLineVisible: false })
|
||||||
|
.setData([{ time: t0, value: mid }, { time: t1, value: mid }]);
|
||||||
|
}
|
||||||
|
mainChart.addLineSeries({ color: border, lineWidth: 1, lastValueVisible: false, priceLineVisible: false })
|
||||||
|
.setData([{ time: t0, value: lo }, { time: t0, value: hi }]);
|
||||||
|
mainChart.addLineSeries({ color: border, lineWidth: 1, lastValueVisible: false, priceLineVisible: false })
|
||||||
|
.setData([{ time: t1, value: lo }, { time: t1, value: hi }]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($('#showWyckoffPhases').is(':checked') && w.phases && w.phases.length) {
|
||||||
|
const phaseColors = {
|
||||||
|
A: 'rgba(241, 196, 15, 0.85)',
|
||||||
|
B: 'rgba(155, 89, 182, 0.85)',
|
||||||
|
C: 'rgba(230, 126, 34, 0.85)',
|
||||||
|
D: 'rgba(46, 204, 113, 0.85)',
|
||||||
|
E: 'rgba(52, 152, 219, 0.85)'
|
||||||
|
};
|
||||||
|
const phaseMarkers = [];
|
||||||
|
w.phases.forEach(function(ph) {
|
||||||
|
const t0 = parseTs(ph.start_time);
|
||||||
|
const t1 = ph.end_time ? parseTs(ph.end_time) : chartEnd;
|
||||||
|
if (isNaN(t0) || isNaN(t1) || !tr) return;
|
||||||
|
const hi = parseFloat(tr.high);
|
||||||
|
if (isNaN(hi)) return;
|
||||||
|
const col = phaseColors[ph.phase] || 'rgba(149,165,166,0.85)';
|
||||||
|
// 阶段顶部分段色带(略高于区间高)
|
||||||
|
const y = hi * 1.002;
|
||||||
|
mainChart.addLineSeries({ color: col, lineWidth: 3, lastValueVisible: false, priceLineVisible: false })
|
||||||
|
.setData([{ time: t0, value: y }, { time: t1, value: y }]);
|
||||||
|
phaseMarkers.push({
|
||||||
|
time: t0,
|
||||||
|
position: 'aboveBar',
|
||||||
|
color: col,
|
||||||
|
shape: 'square',
|
||||||
|
text: String(ph.phase || ph.label || ''),
|
||||||
|
size: 1
|
||||||
|
});
|
||||||
|
});
|
||||||
|
if (phaseMarkers.length) {
|
||||||
|
const phSeries = mainChart.addLineSeries({ lastValueVisible: false, priceLineVisible: false });
|
||||||
|
phSeries.setMarkers(phaseMarkers);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($('#showWyckoffEvents').is(':checked') && w.events && w.events.length) {
|
||||||
|
const eventColors = {
|
||||||
|
Spring: '#27ae60',
|
||||||
|
SOS: '#2ecc71',
|
||||||
|
LPS: '#16a085',
|
||||||
|
UTAD: '#e74c3c',
|
||||||
|
SOW: '#c0392b',
|
||||||
|
LPSY: '#d35400'
|
||||||
|
};
|
||||||
|
const checks = (w.volume_confirm && w.volume_confirm.event_checks) || {};
|
||||||
|
const markers = [];
|
||||||
|
w.events.forEach(function(ev) {
|
||||||
|
const t = parseTs(ev.time);
|
||||||
|
if (isNaN(t)) return;
|
||||||
|
const typ = ev.type || '';
|
||||||
|
const chk = checks[typ] || {};
|
||||||
|
const volOk = (chk.volume_ok != null) ? chk.volume_ok : ev.volume_ok;
|
||||||
|
const ratioVal = (chk.volume_ratio != null) ? chk.volume_ratio : ev.volume_ratio;
|
||||||
|
const ok = volOk === true ? '✓' : (volOk === false ? '✗' : '');
|
||||||
|
const note = ev.note || '';
|
||||||
|
const ratio = (ratioVal != null) ? (' vol×' + Number(ratioVal).toFixed(2)) : '';
|
||||||
|
markers.push({
|
||||||
|
time: t,
|
||||||
|
position: (typ === 'Spring' || typ === 'LPS' || typ === 'SOW') ? 'belowBar' : 'aboveBar',
|
||||||
|
color: eventColors[typ] || '#7f8c8d',
|
||||||
|
shape: 'arrowUp',
|
||||||
|
text: typ + (ok ? ' ' + ok : '') + (note ? ' ' + note : '') + ratio,
|
||||||
|
size: 1
|
||||||
|
});
|
||||||
|
});
|
||||||
|
if (markers.length) {
|
||||||
|
const evSeries = mainChart.addLineSeries({ lastValueVisible: false, priceLineVisible: false });
|
||||||
|
evSeries.setMarkers(markers);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($('#showWyckoffVP').is(':checked') && w.volume_profile && tr) {
|
||||||
|
const vp = w.volume_profile;
|
||||||
|
const t1 = tr.end_time ? parseTs(tr.end_time) : chartEnd;
|
||||||
|
if (!isNaN(t1)) {
|
||||||
|
const bins = vp.bins || [];
|
||||||
|
let maxVol = 0;
|
||||||
|
bins.forEach(function(b) { if (b.volume > maxVol) maxVol = b.volume; });
|
||||||
|
const maxWidthSec = Math.max(60, Math.floor((t1 - parseTs(tr.start_time)) * 0.15));
|
||||||
|
bins.forEach(function(b) {
|
||||||
|
if (!b.volume || maxVol <= 0) return;
|
||||||
|
const wSec = Math.max(1, Math.floor(maxWidthSec * (b.volume / maxVol)));
|
||||||
|
const alpha = 0.15 + 0.55 * (b.volume / maxVol);
|
||||||
|
mainChart.addLineSeries({
|
||||||
|
color: 'rgba(142, 68, 173, ' + alpha.toFixed(2) + ')',
|
||||||
|
lineWidth: 1,
|
||||||
|
lastValueVisible: false,
|
||||||
|
priceLineVisible: false
|
||||||
|
}).setData([
|
||||||
|
{ time: t1 - wSec, value: b.price },
|
||||||
|
{ time: t1, value: b.price }
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
const levels = [
|
||||||
|
{ p: vp.poc, c: 'rgba(142, 68, 173, 0.95)', w: 2, style: 0 },
|
||||||
|
{ p: vp.vah, c: 'rgba(155, 89, 182, 0.7)', w: 1, style: 2 },
|
||||||
|
{ p: vp.val, c: 'rgba(155, 89, 182, 0.7)', w: 1, style: 2 }
|
||||||
|
];
|
||||||
|
const t0 = parseTs(tr.start_time);
|
||||||
|
levels.forEach(function(lv) {
|
||||||
|
const p = parseFloat(lv.p);
|
||||||
|
if (isNaN(p) || isNaN(t0)) return;
|
||||||
|
mainChart.addLineSeries({
|
||||||
|
color: lv.c,
|
||||||
|
lineWidth: lv.w,
|
||||||
|
lineStyle: lv.style,
|
||||||
|
lastValueVisible: false,
|
||||||
|
priceLineVisible: false
|
||||||
|
}).setData([{ time: t0, value: p }, { time: t1, value: p }]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) { console.error('威科夫绘制出错:', e); }
|
||||||
|
}
|
||||||
// 显示未完成中枢 - 分别处理主周期、次周期和次次周期
|
// 显示未完成中枢 - 分别处理主周期、次周期和次次周期
|
||||||
if ($('#showMainZs').is(':checked') || $('#showElementZs').is(':checked') || $('#showSubSubZs').is(':checked') || $('#showSubSubBiZs').is(':checked')) {
|
if ($('#showMainZs').is(':checked') || $('#showElementZs').is(':checked') || $('#showSubSubZs').is(':checked') || $('#showSubSubBiZs').is(':checked')) {
|
||||||
console.log('绘制未完成中枢 - 已启用');
|
console.log('绘制未完成中枢 - 已启用');
|
||||||
|
|||||||
@@ -62,7 +62,8 @@ function updateChart(options) {
|
|||||||
end_time: endTimeMs,
|
end_time: endTimeMs,
|
||||||
elements_only: false,
|
elements_only: false,
|
||||||
zone_kl_lines: parseInt($('#zoneKlLines').val()) || 1000,
|
zone_kl_lines: parseInt($('#zoneKlLines').val()) || 1000,
|
||||||
include_structure_zones: $('#showMainStructureZone').is(':checked') ? 1 : 0
|
include_structure_zones: $('#showMainStructureZone').is(':checked') ? 1 : 0,
|
||||||
|
include_wyckoff: $('#showWyckoff').is(':checked') ? 1 : 0
|
||||||
},
|
},
|
||||||
success: function(data) {
|
success: function(data) {
|
||||||
// 隐藏加载图标
|
// 隐藏加载图标
|
||||||
|
|||||||
@@ -93,6 +93,26 @@ $(document).on('change', '#showMainStructureZone', function() {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 威科夫主开关:勾选才请求;子项仅本地重绘
|
||||||
|
function syncWyckoffSubControls() {
|
||||||
|
const on = $('#showWyckoff').is(':checked');
|
||||||
|
$('#showWyckoffRange, #showWyckoffPhases, #showWyckoffEvents, #showWyckoffVP').prop('disabled', !on);
|
||||||
|
}
|
||||||
|
$(document).on('change', '#showWyckoff', function() {
|
||||||
|
const on = $('#showWyckoff').is(':checked');
|
||||||
|
syncWyckoffSubControls();
|
||||||
|
console.log('威科夫切换为:', on);
|
||||||
|
if (on) {
|
||||||
|
updateChart();
|
||||||
|
} else {
|
||||||
|
updateChartDisplay();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
$(document).on('change', '#showWyckoffRange, #showWyckoffPhases, #showWyckoffEvents, #showWyckoffVP', function() {
|
||||||
|
updateChartDisplay();
|
||||||
|
});
|
||||||
|
$(function() { syncWyckoffSubControls(); });
|
||||||
|
|
||||||
// 添加趋势显示复选框变更事件(主/元素),变更后刷新主图
|
// 添加趋势显示复选框变更事件(主/元素),变更后刷新主图
|
||||||
$('#showMainTrend').change(function() {
|
$('#showMainTrend').change(function() {
|
||||||
updateChartDisplay();
|
updateChartDisplay();
|
||||||
|
|||||||
@@ -972,6 +972,26 @@
|
|||||||
<label class="form-check-label" for="showMainStructureZone">结构区</label>
|
<label class="form-check-label" for="showMainStructureZone">结构区</label>
|
||||||
</div>
|
</div>
|
||||||
<input type="number" id="zoneKlLines" class="form-control form-control-sm" value="1000" min="100" max="5000" step="100" style="width:80px;" title="结构区K线数量">
|
<input type="number" id="zoneKlLines" class="form-control form-control-sm" value="1000" min="100" max="5000" step="100" style="width:80px;" title="结构区K线数量">
|
||||||
|
<div class="form-check form-check-inline me-1 ms-2">
|
||||||
|
<input class="form-check-input" type="checkbox" id="showWyckoff">
|
||||||
|
<label class="form-check-label" for="showWyckoff">威科夫</label>
|
||||||
|
</div>
|
||||||
|
<div class="form-check form-check-inline me-1">
|
||||||
|
<input class="form-check-input" type="checkbox" id="showWyckoffRange" checked disabled>
|
||||||
|
<label class="form-check-label" for="showWyckoffRange">区间</label>
|
||||||
|
</div>
|
||||||
|
<div class="form-check form-check-inline me-1">
|
||||||
|
<input class="form-check-input" type="checkbox" id="showWyckoffPhases" checked disabled>
|
||||||
|
<label class="form-check-label" for="showWyckoffPhases">阶段</label>
|
||||||
|
</div>
|
||||||
|
<div class="form-check form-check-inline me-1">
|
||||||
|
<input class="form-check-input" type="checkbox" id="showWyckoffEvents" checked disabled>
|
||||||
|
<label class="form-check-label" for="showWyckoffEvents">事件</label>
|
||||||
|
</div>
|
||||||
|
<div class="form-check form-check-inline me-1">
|
||||||
|
<input class="form-check-input" type="checkbox" id="showWyckoffVP" checked disabled>
|
||||||
|
<label class="form-check-label" for="showWyckoffVP">VP</label>
|
||||||
|
</div>
|
||||||
<span id="nextRefreshTime" class="text-muted" style="display:none;font-size:0.85rem;"></span>
|
<span id="nextRefreshTime" class="text-muted" style="display:none;font-size:0.85rem;"></span>
|
||||||
<div id="refreshLoadingSpinner" class="loading-spinner ms-2" style="display:none;"></div>
|
<div id="refreshLoadingSpinner" class="loading-spinner ms-2" style="display:none;"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -16,9 +16,19 @@ sys.path.insert(0, str(ROOT / "web"))
|
|||||||
from tests.generate_golden import make_ohlcv # noqa: E402
|
from tests.generate_golden import make_ohlcv # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
CONTRACT_KEYS = json.loads(
|
_CONTRACT_DOC = json.loads(
|
||||||
(ROOT / "tests" / "fixtures" / "analyze_contract_keys.json").read_text(encoding="utf-8")
|
(ROOT / "tests" / "fixtures" / "analyze_contract_keys.json").read_text(encoding="utf-8")
|
||||||
)
|
)
|
||||||
|
CONTRACT_KEYS = (
|
||||||
|
_CONTRACT_DOC["required"]
|
||||||
|
if isinstance(_CONTRACT_DOC, dict) and "required" in _CONTRACT_DOC
|
||||||
|
else _CONTRACT_DOC
|
||||||
|
)
|
||||||
|
WYCKOFF_KEYS = (
|
||||||
|
_CONTRACT_DOC.get("wyckoff_keys", [])
|
||||||
|
if isinstance(_CONTRACT_DOC, dict)
|
||||||
|
else []
|
||||||
|
)
|
||||||
|
|
||||||
# analyze_chan 直接返回的对象字段(未序列化前)
|
# analyze_chan 直接返回的对象字段(未序列化前)
|
||||||
ANALYZE_CHAN_KEYS = {
|
ANALYZE_CHAN_KEYS = {
|
||||||
@@ -117,3 +127,33 @@ def test_analyze_http_contract_with_mocked_kl():
|
|||||||
assert payload is not None and "error" not in payload
|
assert payload is not None and "error" not in payload
|
||||||
missing = [k for k in CONTRACT_KEYS if k not in payload]
|
missing = [k for k in CONTRACT_KEYS if k not in payload]
|
||||||
assert not missing, f"missing contract keys: {missing}"
|
assert not missing, f"missing contract keys: {missing}"
|
||||||
|
assert "wyckoff" not in payload
|
||||||
|
|
||||||
|
|
||||||
|
def test_analyze_http_wyckoff_opt_in():
|
||||||
|
"""include_wyckoff=1 时响应含 wyckoff 约定键;默认不返回。"""
|
||||||
|
from app import app
|
||||||
|
from services.runtime import add_indicators
|
||||||
|
|
||||||
|
df = add_indicators(make_ohlcv(300))
|
||||||
|
df = df.copy()
|
||||||
|
if "timestamp" not in df.columns:
|
||||||
|
df["timestamp"] = (pd.to_datetime(df["date"]).astype("int64") // 10**6).astype("int64")
|
||||||
|
|
||||||
|
with patch("api.analyze.get_kl_data", return_value=df):
|
||||||
|
client = app.test_client()
|
||||||
|
resp = client.get(
|
||||||
|
"/api/analyze",
|
||||||
|
query_string={
|
||||||
|
"symbol": "BTC/USDT:USDT",
|
||||||
|
"timeframe": "5m",
|
||||||
|
"timezone": "Asia/Shanghai",
|
||||||
|
"include_wyckoff": 1,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200, resp.data[:500]
|
||||||
|
payload = resp.get_json()
|
||||||
|
assert payload is not None and "wyckoff" in payload
|
||||||
|
w = payload["wyckoff"]
|
||||||
|
for k in WYCKOFF_KEYS:
|
||||||
|
assert k in w, f"missing wyckoff key: {k}"
|
||||||
|
|||||||
Reference in New Issue
Block a user