Author SHA1 Message Date
jackyu66git 41151ae88a fix: pipeline MACD 参数统一为标准 12/26/9(与 web/交易所一致) 2026-09-12 02:15:19 +08:00
jackyu66gitandCursor efad2bb333 docs(ECR-007): archive LOOP-RUN-005 and sync STATE
关门收尾:归档 loop/gate 产物至 docs/runs,同步 CURRENT/MEMORY/PROFILE,并忽略工作目录 .gates/loop。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-07 15:02:54 +08:00
jackyu66gitandCursor 2964d6f230 docs(ECR-007): mark LOOP-RUN-005 DONE after Final Approval
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-07 03:20:39 +08:00
jackyu66gitandCursor 7991a6b2bf docs(ECR-007): record implementation commit in TRACEABILITY
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-07 03:14:19 +08:00
jackyu66gitandCursor 276481e02c feat(ECR-007): Wyckoff Live Structure with Confirmed/Live isolation
Add live.py lifecycle and event candidates; assemble confirmed vs live
in engine; Summary partition; execution_signal source=confirmed only.
Keep strategies untouched; do not lower Confirmed thresholds for Live.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-07 03:14:19 +08:00
46 changed files with 2848 additions and 410 deletions
+4
View File
@@ -40,3 +40,7 @@ feature_meta
.DS_Store .DS_Store
data_provider/._config.json data_provider/._config.json
.gstack/ .gstack/
# ESS gate / engineering-loop working dirs(归档进 docs/runs/
.gates/
loop/
+3 -2
View File
@@ -1,6 +1,7 @@
"""威科夫分析(启发式):交易区间 / 阶段 / 事件 / Volume Profile。""" """威科夫分析(启发式):交易区间 / 阶段 / 事件 / Volume Profile / Live"""
from __future__ import annotations from __future__ import annotations
from .engine import analyze_wyckoff from .engine import analyze_wyckoff
from .live import execution_signal_from_wyckoff
__all__ = ["analyze_wyckoff"] __all__ = ["analyze_wyckoff", "execution_signal_from_wyckoff"]
+145 -29
View File
@@ -1,12 +1,18 @@
"""威科夫分析入口""" """威科夫分析入口Cycle → Phase → Event → VP + LiveMULTI-CYCLE / LIVE-STRUCTURE)。
range.py 只产 TradingRangeConfirmed 走 events.pyLive 走 live.py。
cycles[0]=ACTIVE;禁止 cycles[-1] 取 active。
Execution 只消费 Confirmed(见 live.execution_signal_from_wyckoff)。
"""
from __future__ import annotations from __future__ import annotations
from typing import Any, Dict, Optional from typing import Any, Dict, List, Optional
import pandas as pd import pandas as pd
from .events import build_phases, detect_bias_and_events from .events import build_phases, detect_bias_and_events
from .range import detect_trading_range from .live import analyze_live_structure
from .range import detect_trading_ranges
from .volume_profile import compute_volume_profile from .volume_profile import compute_volume_profile
@@ -21,31 +27,55 @@ def _fmt_time(v) -> Optional[str]:
return str(v) return str(v)
def analyze_wyckoff(df: pd.DataFrame, lookback: int = 120, vp_bins: int = 50) -> Dict[str, Any]: def _empty(vp_bins: int) -> Dict[str, Any]:
""" return {
对主周期 OHLCV DataFrame 做威科夫启发式分析。 "cycles": [],
需要列: open, high, low, close, volume;建议有 date 或 timestamp。
"""
empty = {
"trading_range": None, "trading_range": None,
"bias": "unknown", "bias": "unknown",
"phases": [], "phases": [],
"events": [], "events": [],
"volume_profile": {"bins": [], "poc": None, "vah": None, "val": None, "bin_count": vp_bins}, "volume_profile": {"bins": [], "poc": None, "vah": None, "val": None, "bin_count": vp_bins},
"volume_confirm": {"avg_volume": 0.0, "event_checks": {}}, "volume_confirm": {"avg_volume": 0.0, "event_checks": {}},
"live": None,
} }
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
def _confidence_for_confirmed(
tr: Dict[str, Any],
phases: List[Dict[str, Any]],
events: List[Dict[str, Any]],
) -> Dict[str, float]:
range_c = float(tr.get("range_confidence") or 0.5)
labels = {p.get("phase") for p in phases}
phase_c = 0.35
if "A" in labels and "B" in labels:
phase_c += 0.15
if "C" in labels:
phase_c += 0.2
if "D" in labels or "E" in labels:
phase_c += 0.15
phase_c = min(0.95, phase_c)
types = {e.get("type") for e in events}
event_c = 0.25
for t in ("Spring", "UTAD", "SOS", "SOW", "LPS", "LPSY"):
if t in types:
event_c += 0.12
event_c = min(0.95, event_c)
overall = 0.4 * range_c + 0.3 * phase_c + 0.3 * event_c
return {
"range": round(range_c, 3),
"phase": round(phase_c, 3),
"event": round(event_c, 3),
"overall": round(overall, 3),
}
def _build_cycle(
work: pd.DataFrame,
tr: Dict[str, Any],
cycle_id: int,
vp_bins: int,
) -> Dict[str, Any]:
bias, events, volume_confirm = detect_bias_and_events(work, tr) bias, events, volume_confirm = detect_bias_and_events(work, tr)
phases = build_phases(work, tr, bias, events) phases = build_phases(work, tr, bias, events)
vp = compute_volume_profile( vp = compute_volume_profile(
@@ -54,27 +84,113 @@ def analyze_wyckoff(df: pd.DataFrame, lookback: int = 120, vp_bins: int = 50) ->
int(tr["abs_end_idx"]), int(tr["abs_end_idx"]),
bin_count=vp_bins, 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: for ev in events:
ev["time"] = _fmt_time(ev.get("time")) ev["time"] = _fmt_time(ev.get("time"))
for ph in phases: for ph in phases:
ph["start_time"] = _fmt_time(ph.get("start_time")) ph["start_time"] = _fmt_time(ph.get("start_time"))
ph["end_time"] = _fmt_time(ph.get("end_time")) ph["end_time"] = _fmt_time(ph.get("end_time"))
is_active = cycle_id == 0
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(is_active),
"bars": int(tr.get("bars", 0)),
}
conf = _confidence_for_confirmed(tr, phases, events)
# Live 层:仅 ACTIVE 周期做推演;历史周期归档为 COMPLETED
if is_active:
live = analyze_live_structure(
work, tr, confirmed_events=events, confirmed_phases=phases, bias=bias,
)
lifecycle = live.get("lifecycle") or "FORMING"
else:
live = None
lifecycle = "COMPLETED"
return { return {
"id": int(cycle_id),
"role": "latest" if is_active else "historical",
# MULTI-CYCLE:时间线角色
"status": "ACTIVE" if is_active else "HISTORICAL",
# LIVE-STRUCTURE:生命周期
"lifecycle": lifecycle,
"direction": "latest" if is_active else "historical",
"period": {
"start_time": _fmt_time(tr.get("start_time")),
"end_time": _fmt_time(tr.get("end_time")),
"bars": int(tr.get("bars", 0)),
},
"confidence": conf,
"trading_range": trading_range, "trading_range": trading_range,
"bias": bias, "bias": bias,
# 兼容旧读法:顶层 phases/events = confirmed
"phases": phases, "phases": phases,
"events": events, "events": events,
"confirmed": {
"phases": phases,
"events": events,
"volume_confirm": volume_confirm,
},
"live": live,
"volume_profile": vp, "volume_profile": vp,
"volume_confirm": volume_confirm, "volume_confirm": volume_confirm,
} }
def analyze_wyckoff(
df: pd.DataFrame,
lookback: int = 120,
vp_bins: int = 50,
min_bars: int = 24,
atr_mult: float = 1.2,
range_start_time=None,
prefer_start_time=None,
max_cycles: int = 8,
) -> Dict[str, Any]:
"""
多周期威科夫分析。
cycles[0] = ACTIVE;顶层 phases/events 只镜像 Confirmed。
顶层 live 镜像 cycles[0].live。
"""
empty = _empty(vp_bins)
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
trs = detect_trading_ranges(
work,
lookback=lookback,
min_bars=max(8, int(min_bars)),
atr_mult=atr_mult,
max_cycles=max(1, min(8, int(max_cycles))),
prefer_start_time=prefer_start_time,
range_start_time=range_start_time,
)
if not trs:
return empty
cycles: List[Dict[str, Any]] = []
for i, tr in enumerate(trs):
cycles.append(_build_cycle(work, tr, cycle_id=i, vp_bins=vp_bins))
active = cycles[0]
return {
"cycles": cycles,
"trading_range": active["trading_range"],
"bias": active["bias"],
"phases": active["confirmed"]["phases"],
"events": active["confirmed"]["events"],
"volume_profile": active["volume_profile"],
"volume_confirm": active["volume_confirm"],
"live": active.get("live"),
"lifecycle": active.get("lifecycle"),
}
+157 -35
View File
@@ -1,7 +1,7 @@
"""威科夫阶段与事件(启发式)。""" """威科夫阶段与事件(启发式)。"""
from __future__ import annotations from __future__ import annotations
from typing import Any, Dict, List, Tuple from typing import Any, Dict, List, Optional, Tuple
import numpy as np import numpy as np
import pandas as pd import pandas as pd
@@ -29,6 +29,9 @@ def detect_bias_and_events(
) -> Tuple[str, List[Dict[str, Any]], Dict[str, Any]]: ) -> Tuple[str, List[Dict[str, Any]], Dict[str, Any]]:
""" """
返回 bias、events、volume_confirm。 返回 bias、events、volume_confirm。
Spring/UTAD 相对「结构高低」判定:取区间内次低/次高(剔除单根极值),
避免箱体把假破低点吃进 lo 后永远刺不破、从而无 C 阶段。
""" """
hi = float(tr["high"]) hi = float(tr["high"])
lo = float(tr["low"]) lo = float(tr["low"])
@@ -38,6 +41,24 @@ def detect_bias_and_events(
e = int(tr["abs_end_idx"]) e = int(tr["abs_end_idx"])
events: List[Dict[str, Any]] = [] events: List[Dict[str, Any]] = []
# 结构边界:用次低/次高作假破参照(至少 8 根才启用)
seg = df.iloc[s : e + 1]
event_lo, event_hi = lo, hi
if len(seg) >= 8:
lows = seg["low"].astype(float)
highs = seg["high"].astype(float)
# nsmallest(2) 的较大者 = 次低;nlargest(2) 的较小者 = 次高
event_lo = float(lows.nsmallest(min(2, len(lows))).iloc[-1])
event_hi = float(highs.nlargest(min(2, len(highs))).iloc[-1])
# 勿比公布箱沿更「松」:结构带应在箱内
event_lo = max(event_lo, lo)
event_hi = min(event_hi, hi)
# 若次低仍等于极值(多根同价),略抬参照便于识别收回
if abs(event_lo - lo) < 1e-12:
event_lo = lo + max(tol * 0.35, (hi - lo) * 0.02)
if abs(event_hi - hi) < 1e-12:
event_hi = hi - max(tol * 0.35, (hi - lo) * 0.02)
# 扫描区间内及之后(含 tail_reserve # 扫描区间内及之后(含 tail_reserve
scan_end = int(tr.get("abs_scan_end_idx", min(len(df) - 1, e + 15))) 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)) scan_end = min(len(df) - 1, max(scan_end, e))
@@ -57,8 +78,8 @@ def detect_bias_and_events(
avg_v = _avg_vol(df, i) avg_v = _avg_vol(df, i)
ratio = vol / avg_v if avg_v else 0.0 ratio = vol / avg_v if avg_v else 0.0
# Spring: pierce below low then close back above low # Spring: pierce below structural support then close back
if spring is None and low < lo - tol * 0.5 and close >= lo - tol * 0.2: if spring is None and low < event_lo - tol * 0.35 and close >= event_lo - tol * 0.35:
vol_ok = ratio <= 1.35 or (i + 1 <= scan_end and float(df.iloc[min(i + 1, scan_end)]["volume"]) / avg_v < 1.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 = { spring = {
"type": "Spring", "type": "Spring",
@@ -70,8 +91,8 @@ def detect_bias_and_events(
"idx": i, "idx": i,
} }
# UTAD: pierce above high then close back below # UTAD: pierce above structural resistance then close back
if utad is None and high > hi + tol * 0.5 and close <= hi + tol * 0.2: if utad is None and high > event_hi + tol * 0.35 and close <= event_hi + tol * 0.35:
vol_ok = ratio >= 0.8 vol_ok = ratio >= 0.8
utad = { utad = {
"type": "UTAD", "type": "UTAD",
@@ -154,11 +175,15 @@ def detect_bias_and_events(
} }
break break
# 冲突清理:已判定吸筹且有 SOS 时,丢弃更早的 UTAD(避免阶段/图面误导)
# 派发且有 SOW 时,丢弃更晚才合理的 Spring 假信号同理在偏置后再滤
keep = []
for ev in (spring, sos, lps, utad, sod, lpsy): for ev in (spring, sos, lps, utad, sod, lpsy):
if ev: if not ev:
events.append({k: v for k, v in ev.items() if k != "idx"}) continue
keep.append(ev)
# bias # bias(先算)
last_c = float(df["close"].iloc[-1]) last_c = float(df["close"].iloc[-1])
bias = "unknown" bias = "unknown"
if sos and (not sod or int(sos.get("idx", 0)) >= int(sod.get("idx", 0))): if sos and (not sod or int(sos.get("idx", 0)) >= int(sod.get("idx", 0))):
@@ -174,6 +199,16 @@ def detect_bias_and_events(
else: else:
bias = "distribution" bias = "distribution"
filtered = []
for ev in keep:
if bias == "accumulation" and ev["type"] == "UTAD" and sos and int(ev["idx"]) <= int(sos["idx"]):
continue
if bias == "distribution" and ev["type"] == "Spring" and sod and int(ev["idx"]) <= int(sod["idx"]):
continue
filtered.append(ev)
events = [{k: v for k, v in ev.items() if k != "idx"} for ev in filtered]
avg_volume = float(df["volume"].astype(float).iloc[max(0, e - 20) : e + 1].mean()) if "volume" in df.columns else 0.0 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 = { volume_confirm = {
"avg_volume": avg_volume, "avg_volume": avg_volume,
@@ -189,59 +224,146 @@ def build_phases(
events: List[Dict[str, Any]], events: List[Dict[str, Any]],
min_bars: int = 3, min_bars: int = 3,
) -> List[Dict[str, Any]]: ) -> List[Dict[str, Any]]:
"""按时间切分 A–E 粗阶段;保证非重叠且每段至少 min_bars 根(空间不足则截断尾部阶段)。""" """
按威科夫事件锚点切分 A–E(启发式)。
吸筹:A停止 → B筑底 → C测试(Spring) → D拉升(SOS…LPS) → E离开
派发:A停止 → B筑顶 → C测试(UTAD) → D派发(SOW…LPSY) → E离开
无 Spring/UTAD 时:若已有 SOS/SOW,用突破前末次沿带测试补 C;仍无则省略 C。
"""
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"])
lo = float(tr["low"])
n_last = len(df) - 1 n_last = len(df) - 1
min_span = max(2, min_bars - 1) min_span = max(2, min_bars - 1)
range_len = max(1, e - s)
event_idx = {} def _match_idx(t) -> Optional[int]:
for ev in events: if t is None:
t = ev.get("time") return None
for i in range(s, min(len(df), e + 20)): lo = max(0, s - 2)
hi = min(len(df), e + 40)
for i in range(lo, hi):
if _bar_time(df, i) == t: if _bar_time(df, i) == t:
event_idx[ev["type"]] = i return i
try:
tt = pd.Timestamp(t)
sample = None
if "date" in df.columns and len(df):
sample = df["date"].iloc[min(s, n_last)]
if sample is not None and getattr(sample, "tzinfo", None) is not None and tt.tzinfo is None:
tt = tt.tz_localize(sample.tzinfo)
for i in range(lo, hi):
bt = _bar_time(df, i)
try:
if abs((pd.Timestamp(bt) - tt).total_seconds()) <= 1:
return i
except Exception:
continue
except Exception:
pass
return None
event_idx: Dict[str, int] = {}
for ev in events:
idx = _match_idx(ev.get("time"))
if idx is not None:
event_idx[str(ev.get("type"))] = idx
accum = bias != "distribution"
if accum:
c_ev = event_idx.get("Spring")
d_ev = event_idx.get("SOS")
d_tail = event_idx.get("LPS") or d_ev
else:
c_ev = event_idx.get("UTAD")
d_ev = event_idx.get("SOW")
d_tail = event_idx.get("LPSY") or d_ev
# 有 D 无明确测试事件时:用突破前最后一次触及下/上沿作为 C(次级测试)
if c_ev is None and d_ev is not None:
band = lo + (hi - lo) * 0.28 if accum else hi - (hi - lo) * 0.28
for i in range(int(d_ev) - 1, s + 1, -1):
row = df.iloc[i]
if accum and float(row["low"]) <= band:
c_ev = i
break
if not accum and float(row["high"]) >= band:
c_ev = i
break break
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
def _lab(phase: str) -> str: def _lab(phase: str) -> str:
if bias == "distribution": if accum:
m = {"A": "A停止上涨", "B": "B筑顶", "C": "C测试", "D": "D派发", "E": "E下跌"}
else:
m = {"A": "A停止下跌", "B": "B筑底", "C": "C测试", "D": "D拉升", "E": "E离开"} 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) return m.get(phase, phase)
# 理想切点(随后再强制非重叠 + 最小跨度) a_end = s + max(min_bars, range_len // 5)
raw = [
("A", s, a_end), c_start = c_end = None
("B", a_end, c_anchor), if c_ev is not None:
("C", c_anchor, d_anchor), c_start = max(s, int(c_ev) - 1)
("D", d_anchor, min(n_last, d_anchor + max(min_bars, (e - s) // 6))), c_end = min(n_last, int(c_ev) + 1)
("E", min(n_last, d_anchor + max(min_bars, (e - s) // 6)), min(n_last, max(e, d_anchor + max(min_bars * 2, 8)))),
] if d_ev is not None:
d_start = int(d_ev)
d_end = min(n_last, max(int(d_tail or d_ev), d_start) + max(min_bars, range_len // 8))
if d_tail is not None:
d_end = max(d_end, min(n_last, int(d_tail) + 1))
else:
d_start = d_end = None
if c_start is not None:
b_end = max(a_end + 1, c_start)
elif d_start is not None:
b_end = max(a_end + 1, d_start)
else:
b_end = max(a_end + 1, e)
if d_end is not None:
e_start = min(n_last, d_end)
e_end = n_last
else:
e_start = e_end = None
raw = [("A", s, a_end), ("B", a_end, b_end)]
if c_start is not None and c_end is not None:
raw.append(("C", c_start, c_end))
if d_start is not None and d_end is not None:
raw.append(("D", d_start, d_end))
if e_start is not None and e_end is not None and e_end > e_start:
raw.append(("E", e_start, e_end))
phases: List[Dict[str, Any]] = [] phases: List[Dict[str, Any]] = []
cursor = s cursor = s
for phase, _a, _b in raw: for phase, _a, _b in raw:
if cursor >= n_last: if cursor >= n_last:
break break
a = max(int(_a), cursor) a = max(int(_a), cursor)
b = int(max(_b, a + min_span)) b = int(max(int(_b), a))
need = 1 if phase == "C" else min_span
if b < a + need:
b = min(n_last, a + need)
b = int(np.clip(b, a, n_last)) b = int(np.clip(b, a, n_last))
if b - a < min_span: if b < a:
# 尾部空间不足:并入上一段终点并停止新增 continue
if phases: if phases and phases[-1].get("_a") == a and phases[-1].get("_b") == b:
phases[-1]["end_time"] = _bar_time(df, n_last) continue
break
phases.append( phases.append(
{ {
"phase": phase, "phase": phase,
"label": _lab(phase), "label": _lab(phase),
"start_time": _bar_time(df, a), "start_time": _bar_time(df, a),
"end_time": _bar_time(df, b), "end_time": _bar_time(df, b),
"_a": a,
"_b": b,
} }
) )
cursor = b cursor = b
for p in phases:
p.pop("_a", None)
p.pop("_b", None)
return phases return phases
+258
View File
@@ -0,0 +1,258 @@
"""威科夫 Live / Developing 层(WYCKOFF-LIVE-STRUCTURE-001)。
独立于 Confirmed Engine:不修改 events 确认条件,不写入 confirmed.events。
Execution 不得消费本模块输出。
"""
from __future__ import annotations
from typing import Any, Dict, List, Optional, Set
import numpy as np
import pandas as pd
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 _empty_live() -> Dict[str, Any]:
return {
"lifecycle": "UNKNOWN",
"range_formation": None,
"phase_candidate": None,
"event_candidates": [],
"next_expected": None,
"confidence": {
"cycle": 0.0,
"phase": 0.0,
"event": 0.0,
"structure": 0.0,
"volume": 0.0,
"overall": 0.0,
},
"note": "",
}
def analyze_live_structure(
df: pd.DataFrame,
tr: Optional[Dict[str, Any]],
confirmed_events: Optional[List[Dict[str, Any]]] = None,
confirmed_phases: Optional[List[Dict[str, Any]]] = None,
bias: str = "unknown",
) -> Dict[str, Any]:
"""
基于当前 TradingRange 与已确认事件,推演 Live candidates。
confirmed_* 只读,用于避免重复提示已确认事件,不修改之。
"""
out = _empty_live()
if df is None or len(df) < 20 or tr is None:
out["note"] = "insufficient structure"
return out
confirmed_events = confirmed_events or []
confirmed_phases = confirmed_phases or []
confirmed_types: Set[str] = {str(e.get("type")) for e in confirmed_events if e.get("type")}
s = int(tr["abs_start_idx"])
e = int(tr["abs_end_idx"])
scan_end = int(tr.get("abs_scan_end_idx", len(df) - 1))
scan_end = min(len(df) - 1, max(scan_end, e))
hi = float(tr["high"])
lo = float(tr["low"])
mid = float(tr["mid"])
tol = float(tr.get("tol") or (hi - lo) * 0.05)
atr = float(tr.get("atr") or max((hi - lo) * 0.2, 1e-9))
seg = df.iloc[s : e + 1]
if len(seg) < 8:
out["note"] = "range too short"
return out
# —— Range Formation(横盘 / 波动收敛)——
closes = seg["close"].astype(float)
highs = seg["high"].astype(float)
lows = seg["low"].astype(float)
vols = seg["volume"].astype(float) if "volume" in seg.columns else pd.Series([1.0] * len(seg))
half = max(4, len(seg) // 2)
vol_early = float(np.std(closes.iloc[:half])) if half > 1 else 0.0
vol_late = float(np.std(closes.iloc[-half:])) if half > 1 else 0.0
width = hi - lo
width_atr = width / atr if atr > 0 else 99.0
converging = vol_early > 1e-12 and vol_late < vol_early * 0.85
range_ok = 1.2 <= width_atr <= 10.0 and len(seg) >= 16
structure_score = 0.35
if range_ok:
structure_score += 0.25
if converging:
structure_score += 0.2
if width_atr <= 6.0:
structure_score += 0.1
structure_score = float(min(0.95, structure_score))
out["range_formation"] = {
"potential_trading_range": bool(range_ok),
"converging": bool(converging),
"width_atr": round(width_atr, 3),
"bars": int(len(seg)),
}
# —— 最近 K 形态(Phase C / Event candidates)——
i = scan_end
row = df.iloc[i]
o = float(row["open"])
h = float(row["high"])
l = float(row["low"])
c = float(row["close"])
rng = max(h - l, 1e-9)
lower_wick = min(o, c) - l
upper_wick = h - max(o, c)
avg_v = _avg_vol(df, i)
vol = float(row["volume"]) if "volume" in df.columns else avg_v
vol_ratio = vol / avg_v if avg_v else 1.0
volume_score = float(np.clip(1.1 - abs(vol_ratio - 1.0) * 0.35, 0.2, 0.95))
phase_candidate = None
phase_conf = 0.0
# Phase C:测低 + 下影 + 缩量(吸筹语境)
near_lo = l <= lo + tol * 1.2
test_low = l < mid and lower_wick >= rng * 0.35
vol_contract = vol_ratio <= 1.05
if bias != "distribution" and near_lo and test_low and vol_contract:
phase_candidate = "C"
phase_conf = 0.55 + (0.1 if lower_wick >= rng * 0.5 else 0) + (0.08 if vol_ratio < 0.9 else 0)
# Phase D 候选:价格在箱上半、有上破意图但未确认 SOS
elif c >= mid and (h >= hi - tol or c > hi - tol * 0.5):
phase_candidate = "D"
phase_conf = 0.5 + (0.1 if c > mid else 0)
elif c < mid and (l <= lo + tol):
phase_candidate = "B"
phase_conf = 0.45
# 已有 confirmed phase 时,candidate 取「下一阶段」提示,不覆盖事实
confirmed_phase_set = {str(p.get("phase")) for p in confirmed_phases}
if "E" in confirmed_phase_set:
phase_candidate = phase_candidate or "E"
phase_conf = max(phase_conf, 0.7)
elif "D" in confirmed_phase_set and phase_candidate is None:
phase_candidate = "D"
phase_conf = max(phase_conf, 0.65)
out["phase_candidate"] = phase_candidate
phase_conf = float(min(0.92, phase_conf))
# —— Event candidates(仅 Spring / SOS / LPS / UTAD)——
candidates: List[Dict[str, Any]] = []
def _add(typ: str, conf: float, note: str) -> None:
if typ in confirmed_types:
return # 已确认则不再作为 candidate
candidates.append(
{
"type": typ,
"confidence": round(float(min(0.9, conf)), 3),
"confirmed": False,
"note": note,
}
)
# Spring candidate:刺破或贴近下沿,收盘收回,但未达 Confirmed 规则(或不在 confirmed
pierce_lo = l < lo - tol * 0.15
close_back = c >= lo - tol * 0.5
if pierce_lo and close_back:
_add("Spring", 0.5 + (0.12 if vol_ratio <= 1.2 else 0) + (0.08 if close_back else 0), "假破下沿收回(未确认)")
elif l <= lo + tol * 0.35 and close_back and lower_wick >= rng * 0.4:
_add("Spring", 0.45 + (0.1 if vol_contract else 0), "测下沿长下影(未确认)")
# UTAD candidate
pierce_hi = h > hi + tol * 0.15
close_back_dn = c <= hi + tol * 0.5
if pierce_hi and close_back_dn:
_add("UTAD", 0.5 + (0.1 if vol_ratio >= 0.9 else 0), "假破上沿跌回(未确认)")
# SOS candidate:接近/轻破上沿,量能一般,未确认
if c > hi - tol * 0.4 or h >= hi:
sos_conf = 0.48 + (0.12 if c > hi else 0) + (0.1 if vol_ratio >= 1.05 else 0)
_add("SOS", sos_conf, "上破/逼近箱顶(未确认)")
# LPS candidate:站上 mid/上沿带后回踩
if c >= mid and l >= mid - tol * 1.5 and l > lo + (hi - lo) * 0.25:
_add("LPS", 0.46 + (0.1 if vol_ratio <= 1.0 else 0), "箱内上沿带回踩(未确认)")
candidates.sort(key=lambda x: x["confidence"], reverse=True)
out["event_candidates"] = candidates[:4]
event_score = float(candidates[0]["confidence"]) if candidates else 0.25
# next_expected(简规则)
next_exp = None
if "Spring" in confirmed_types and "SOS" not in confirmed_types:
next_exp = "SOS"
elif "SOS" in confirmed_types and "LPS" not in confirmed_types:
next_exp = "LPS"
elif "UTAD" in confirmed_types and "SOW" not in confirmed_types:
next_exp = "SOW"
elif any(c["type"] == "Spring" for c in candidates):
next_exp = "Test"
elif any(c["type"] == "SOS" for c in candidates):
next_exp = "LPS"
out["next_expected"] = next_exp
# —— lifecycle ——
key_confirmed = confirmed_types & {"Spring", "SOS", "UTAD", "SOW", "LPS", "LPSY"}
if key_confirmed:
lifecycle = "CONFIRMED"
elif range_ok or phase_candidate or candidates:
lifecycle = "FORMING"
else:
lifecycle = "UNKNOWN"
out["lifecycle"] = lifecycle
cycle_c = structure_score
overall = 0.35 * cycle_c + 0.25 * phase_conf + 0.25 * event_score + 0.15 * volume_score
out["confidence"] = {
"cycle": round(cycle_c, 3),
"phase": round(phase_conf, 3),
"event": round(event_score, 3),
"structure": round(structure_score, 3),
"volume": round(volume_score, 3),
"overall": round(float(overall), 3),
}
parts = []
if out["range_formation"]["potential_trading_range"]:
parts.append("Potential TR")
if phase_candidate:
parts.append(f"Phase {phase_candidate} candidate")
if candidates:
parts.append(f"{candidates[0]['type']} candidate")
out["note"] = "; ".join(parts) if parts else "observing"
return out
def execution_signal_from_wyckoff(payload: Dict[str, Any]) -> Optional[Dict[str, Any]]:
"""
Execution 边界:只允许 Confirmed。
返回 source='confirmed' 的信号描述;Live-only 时返回 None。
"""
if not payload:
return None
cycles = payload.get("cycles") or []
active = cycles[0] if cycles else None
events = []
if active and isinstance(active.get("confirmed"), dict):
events = list(active["confirmed"].get("events") or [])
if not events:
# 兼容旧顶层 events(均为 confirmed 镜像)
events = list(payload.get("events") or [])
if not events:
return None
last = events[-1]
return {
"source": "confirmed",
"type": last.get("type"),
"time": last.get("time"),
"lifecycle": (active or {}).get("lifecycle") or "CONFIRMED",
}
+373 -59
View File
@@ -1,11 +1,18 @@
"""交易区间检测:ATR 容差下按评分选取近期震荡箱。""" """交易区间检测:仅负责 TradingRange(起止/高低/结构分)。
WYCKOFF-MULTI-CYCLE-001Phase/Event/VP 不得进入本模块。
过滤顺序固定:detect → quality → trend → overlap(<0.2) → accept → mask。
"""
from __future__ import annotations from __future__ import annotations
from typing import Any, Dict, Optional from typing import Any, Dict, List, Optional, Tuple
import numpy as np import numpy as np
import pandas as pd import pandas as pd
MAX_CYCLES = 8
OVERLAP_RATIO_MAX = 0.2
def _atr(df: pd.DataFrame, period: int = 14) -> pd.Series: def _atr(df: pd.DataFrame, period: int = 14) -> pd.Series:
high = df["high"].astype(float) high = df["high"].astype(float)
@@ -23,6 +30,15 @@ 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 _robust_width(seg: pd.DataFrame) -> float:
"""用 90/10 分位估宽,避免单根影线把长窗卡死。"""
h = seg["high"].astype(float)
l = seg["low"].astype(float)
if len(seg) < 6:
return float(h.max() - l.min())
return float(np.nanpercentile(h, 90) - np.nanpercentile(l, 10))
def _score_segment( def _score_segment(
length: int, length: int,
near_hi: int, near_hi: int,
@@ -31,27 +47,161 @@ def _score_segment(
width: float, width: float,
atr: float, atr: float,
) -> float: ) -> float:
"""触边密度 + 箱内比例 − 相对宽度;弱奖励长度以免只追最长""" """结构质量分(非 Phase/Event"""
touch_density = (near_hi + near_lo) / float(max(length, 1)) touch = min(near_hi, 6) + min(near_lo, 6)
width_pen = (width / atr) if atr > 0 else width 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) return float(touch) * 4.0 + float(inside) * 25.0 - width_pen * 3.0 + min(length / 40.0, 2.0)
def detect_trading_range( def _time_col(df: pd.DataFrame) -> Optional[str]:
if "date" in df.columns:
return "date"
if "timestamp" in df.columns:
return "timestamp"
return None
def _bar_index_at_or_after(work: pd.DataFrame, ts: Any) -> Optional[int]:
col = _time_col(work)
if col is None or ts is None:
return None
try:
target = pd.Timestamp(ts)
except Exception:
return None
series = pd.to_datetime(work[col], utc=True, errors="coerce")
if target.tzinfo is None:
target = target.tz_localize("UTC")
else:
target = target.tz_convert("UTC")
if series.isna().all():
return None
ge = series >= target
if ge.any():
return int(np.flatnonzero(ge.to_numpy())[0])
return 0
def _pack_range(
work: pd.DataFrame,
df: pd.DataFrame, df: pd.DataFrame,
lookback: int = 120, start_i: int,
end_i: int,
hi: float,
lo: float,
tol: float,
last_atr: float,
score: float,
n: int,
window_offset: int = 0,
) -> Dict[str, Any]:
"""组装 TradingRange(仅结构字段)。"""
mid = (hi + lo) / 2.0
last_c = float(work["close"].iloc[min(end_i, len(work) - 1)])
price_in_box = (lo - tol * 1.5) <= last_c <= (hi + tol * 1.5)
bars = int(end_i - start_i + 1)
# 结构置信:归一化 score(启发式)
range_conf = float(np.clip(score / 55.0, 0.05, 0.99))
best = {
"start_idx": int(start_i),
"end_idx": int(end_i),
"high": float(hi),
"low": float(lo),
"mid": float(mid),
"active": bool(price_in_box),
"atr": float(last_atr),
"tol": float(tol),
"bars": bars,
"score": float(score),
"quality": float(score),
"range_confidence": range_conf,
}
def _ts(row) -> Any:
col = _time_col(work)
if col and pd.notna(row[col]):
return row[col]
return None
best["start_time"] = _ts(work.iloc[best["start_idx"]])
best["end_time"] = _ts(work.iloc[best["end_idx"]])
# window_offsetslice 相对父 DataFrame 的起点;勿用 len(df)-len(work)
offset = int(window_offset)
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
def _overlap_ratio(a0: int, a1: int, b0: int, b1: int) -> float:
"""两闭区间重叠长度 / 较短区间长度。"""
lo = max(a0, b0)
hi = min(a1, b1)
if hi < lo:
return 0.0
overlap = hi - lo + 1
shorter = min(a1 - a0 + 1, b1 - b0 + 1)
if shorter <= 0:
return 0.0
return float(overlap) / float(shorter)
def _passes_quality(tr: Dict[str, Any], min_bars: int) -> bool:
if tr is None:
return False
if int(tr.get("bars") or 0) < max(8, min_bars // 2):
return False
if float(tr.get("score") or 0) < 12.0:
return False
hi = float(tr["high"])
lo = float(tr["low"])
atr = float(tr.get("atr") or 0) or 1.0
if (hi - lo) / atr > 12.0:
return False
return True
def _passes_trend_filter(work: pd.DataFrame, tr: Dict[str, Any]) -> bool:
"""趋势污染:定向位移过大则非震荡箱。"""
s = int(tr["start_idx"])
e = int(tr["end_idx"])
seg = work.iloc[s : e + 1]
if len(seg) < 8:
return False
c0 = float(seg["close"].iloc[0])
c1 = float(seg["close"].iloc[-1])
atr = float(tr.get("atr") or 0) or 1.0
drift = abs(c1 - c0) / atr
# 相对箱宽:漂移占箱宽过大 → 趋势
width = max(float(tr["high"]) - float(tr["low"]), atr)
drift_frac = abs(c1 - c0) / width
if drift > 6.0 and drift_frac > 0.55:
return False
return True
def _detect_in_window(
df: pd.DataFrame,
win_start: int,
win_end: int,
min_bars: int = 24, min_bars: int = 24,
atr_mult: float = 1.2, atr_mult: float = 1.2,
tail_reserve: int = 12, tail_reserve: int = 12,
prefer_start_time: Any = None,
range_start_time: Any = None,
) -> Optional[Dict[str, Any]]: ) -> Optional[Dict[str, Any]]:
""" """
最近 lookback 根内寻找高低点波动受控的连续段作为交易区间 df[win_start:win_end+1] 内检测单个 TradingRange
尾部预留 tail_reserve 根用于事件(Spring/SOS),不参与箱体边界计算 只返回箱体结构,不含 Phase/Event/VP
在硬门槛之上按评分取最优段(非仅最长窗口)。
""" """
if df is None or len(df) < min_bars + 5: if df is None or win_end < win_start:
return None return None
work = df.tail(lookback).reset_index(drop=True) slice_df = df.iloc[win_start : win_end + 1].reset_index(drop=True)
lookback = len(slice_df)
if lookback < min_bars + 5:
return None
work = slice_df
n = len(work) n = len(work)
reserve = min(tail_reserve, max(0, n - min_bars - 2)) reserve = min(tail_reserve, max(0, n - min_bars - 2))
core_end = n - reserve if reserve > 0 else n core_end = n - reserve if reserve > 0 else n
@@ -68,61 +218,225 @@ def detect_trading_range(
if not np.isfinite(last_atr) or last_atr <= 0: if not np.isfinite(last_atr) or last_atr <= 0:
last_atr = float(core["close"].iloc[-1]) * 0.01 last_atr = float(core["close"].iloc[-1]) * 0.01
best = None eff_atr_mult = float(atr_mult)
best_score = float("-inf") if lookback >= 280:
cn = len(core) eff_atr_mult = atr_mult * 1.7
for length in range(min(cn, lookback), min_bars - 1, -4): elif lookback >= 160:
seg = core.iloc[-length:] eff_atr_mult = atr_mult * 1.3
width_factor = 3.8 + min(2.2, max(0.0, (lookback - 80) / 100.0))
max_width = last_atr * eff_atr_mult * width_factor
tol = last_atr * eff_atr_mult * 0.35
prefer_i = None
if prefer_start_time is not None:
prefer_i = _bar_index_at_or_after(work, prefer_start_time)
if range_start_time is not None:
start_i = _bar_index_at_or_after(work, range_start_time)
if start_i is not None and start_i <= core_end - 8:
seg = work.iloc[start_i:core_end]
hi = float(seg["high"].max()) hi = float(seg["high"].max())
lo = float(seg["low"].min()) lo = float(seg["low"].min())
width = hi - lo rw = _robust_width(seg)
if width <= 0 or width > last_atr * atr_mult * 3.5: if 0 < rw <= max_width * 1.15:
continue near_hi = int((seg["high"] >= hi - tol).sum())
tol = last_atr * atr_mult * 0.35 near_lo = int((seg["low"] <= lo + tol).sum())
inside = float(((seg["close"] >= lo - tol) & (seg["close"] <= hi + tol)).mean())
if near_hi >= 2 and near_lo >= 2 and inside >= 0.70:
score = _score_segment(len(seg), near_hi, near_lo, inside, rw, last_atr)
return _pack_range(
work, df, start_i, core_end - 1, hi, lo, tol, last_atr, score, n,
window_offset=win_start,
)
eff_min_bars = max(8, int(min_bars))
cn = len(core)
max_bars = min(cn, max(eff_min_bars * 2, min(96, max(eff_min_bars + 8, int(cn * 0.5)))))
cands: List[Tuple[float, int, int, int, float, float, float]] = []
def _try_seg(start_i: int, end_i: int, prefer_boost: float = 0.0) -> None:
if end_i - start_i + 1 < eff_min_bars:
return
if start_i < 0 or end_i >= cn or start_i > end_i:
return
seg = work.iloc[start_i : end_i + 1]
hi = float(seg["high"].max())
lo = float(seg["low"].min())
rw = _robust_width(seg)
if rw <= 0 or rw > max_width:
return
raw_w = hi - lo
if raw_w > max_width * 1.35:
return
near_hi = int((seg["high"] >= hi - tol).sum()) near_hi = int((seg["high"] >= hi - tol).sum())
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 return
inside = float(((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.72:
continue return
score = _score_segment(length, near_hi, near_lo, inside, width, last_atr) length = end_i - start_i + 1
if score <= best_score: score = _score_segment(length, near_hi, near_lo, inside, rw, last_atr) + prefer_boost
continue cands.append((score, length, start_i, end_i, hi, lo, rw))
for length in range(min(cn, max_bars), eff_min_bars - 1, -4):
start_i = cn - length start_i = cn - length
boost = 0.0
if prefer_i is not None:
dist = abs(start_i - int(prefer_i))
if dist <= 6:
boost = 10.0
elif dist <= 14:
boost = 4.0
elif start_i > int(prefer_i) + 16:
boost = -10.0
_try_seg(start_i, cn - 1, boost)
if prefer_i is not None:
pi = int(prefer_i)
if 0 <= pi < cn:
align_max = min(cn, max(max_bars, int(cn * 0.65)))
alen = cn - pi
if eff_min_bars <= alen <= align_max:
_try_seg(pi, cn - 1, prefer_boost=18.0)
elif alen > align_max:
start_i = max(0, cn - align_max)
if start_i > pi:
start_i = pi
end_i = min(cn - 1, pi + align_max - 1)
else:
end_i = cn - 1 end_i = cn - 1
mid = (hi + lo) / 2.0 _try_seg(start_i, end_i, prefer_boost=12.0)
last_c = float(work["close"].iloc[-1])
active = (lo - tol * 1.5) <= last_c <= (hi + tol * 1.5) if not cands:
best_score = score return None
best = {
"start_idx": int(start_i), cands.sort(key=lambda x: x[0], reverse=True)
"end_idx": int(end_i), best_score = cands[0][0]
"high": hi, band = max(4.0, abs(best_score) * 0.10)
"low": lo, near = [c for c in cands if c[0] >= best_score - band]
"mid": mid, chosen = max(near, key=lambda x: (x[1], x[0]))
"active": bool(active), score, _length, start_i, end_i, hi, lo, _rw = chosen
"atr": last_atr, return _pack_range(work, df, start_i, end_i, hi, lo, tol, last_atr, score, n, window_offset=win_start)
"tol": tol,
"bars": int(length),
"score": float(score), def detect_trading_ranges(
df: pd.DataFrame,
lookback: Optional[int] = None,
min_bars: int = 24,
atr_mult: float = 1.2,
tail_reserve: int = 12,
max_cycles: int = MAX_CYCLES,
prefer_start_time: Any = None,
range_start_time: Any = None,
) -> List[Dict[str, Any]]:
"""
倒序切多段 TradingRange(近→远)。
过滤顺序:detect → quality → trend → overlap → accept → mask。
返回列表已按时间倒序,调用方将 [0] 标为 ACTIVE。
"""
if df is None or len(df) < min_bars + 5:
return []
lb = int(lookback) if lookback is not None else len(df)
work = df.tail(lb).reset_index(drop=True)
n = len(work)
occupied: List[Dict[str, Any]] = []
accepted: List[Dict[str, Any]] = []
# 搜索右端从 n-1 往左收缩;每接受一段后右端移到该段 start 之前
search_end = n - 1
prefer = prefer_start_time
hard_start = range_start_time
while len(accepted) < max(1, int(max_cycles)) and search_end >= min_bars + 4:
# 在剩余历史内从右往左试多个右边界,避免历史箱必须贴住 search_end
# (否则中间趋势会挡住更早的真实箱)
cand = None
step = max(4, min(12, (search_end - min_bars) // 10 or 4))
for end_try in range(search_end, min_bars + 4, -step):
trial = _detect_in_window(
work,
0,
end_try,
min_bars=min_bars,
atr_mult=atr_mult,
tail_reserve=tail_reserve,
prefer_start_time=prefer if len(accepted) == 0 and end_try == search_end else None,
range_start_time=hard_start if len(accepted) == 0 and end_try == search_end else None,
)
# 1) detect
if trial is None:
continue
# 2) quality
if not _passes_quality(trial, min_bars):
continue
# 3) trend contamination
if not _passes_trend_filter(work, trial):
continue
# 4) overlap with accepted
a0, a1 = int(trial["abs_start_idx"]), int(trial["abs_end_idx"])
overlap_bad = False
for occ in occupied:
ratio = _overlap_ratio(a0, a1, int(occ["start"]), int(occ["end"]))
if ratio >= OVERLAP_RATIO_MAX:
overlap_bad = True
break
if overlap_bad:
continue
# 取最靠右的合格箱(倒序第一段)
cand = trial
break
if cand is None:
break
# 5) accept
accepted.append(cand)
a0, a1 = int(cand["abs_start_idx"]), int(cand["abs_end_idx"])
# 6) mask
occupied.append(
{
"start": a0,
"end": max(a1, int(cand.get("abs_scan_end_idx", a1))),
"quality": float(cand.get("quality") or 0),
"high": float(cand["high"]),
"low": float(cand["low"]),
} }
)
# 下一轮只在更早窗口搜
search_end = int(cand["abs_start_idx"]) - 1
hard_start = None
prefer = None
if best is None: # abs_* 目前相对 work;若 df 比 work 长需加 offset
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) offset = len(df) - len(work)
best["abs_start_idx"] = offset + best["start_idx"] if offset:
best["abs_end_idx"] = offset + best["end_idx"] for tr in accepted:
best["abs_scan_end_idx"] = offset + n - 1 tr["abs_start_idx"] = int(tr["abs_start_idx"]) + offset
return best tr["abs_end_idx"] = int(tr["abs_end_idx"]) + offset
tr["abs_scan_end_idx"] = int(tr["abs_scan_end_idx"]) + offset
return accepted
def detect_trading_range(
df: pd.DataFrame,
lookback: int = 120,
min_bars: int = 24,
atr_mult: float = 1.2,
tail_reserve: int = 12,
range_start_time: Any = None,
prefer_start_time: Any = None,
) -> Optional[Dict[str, Any]]:
"""兼容旧接口:返回倒序列表中的第一段(ACTIVE 候选)。"""
ranges = detect_trading_ranges(
df,
lookback=lookback,
min_bars=min_bars,
atr_mult=atr_mult,
tail_reserve=tail_reserve,
max_cycles=1,
prefer_start_time=prefer_start_time,
range_start_time=range_start_time,
)
return ranges[0] if ranges else None
+2 -2
View File
@@ -55,8 +55,8 @@ class IndicatorsBuilderMixin:
return None return None
def add_indicators(self, df): def add_indicators(self, df):
fast = 26 fast = 12
slow = 52 slow = 26
period = 9 period = 9
macd = ta.MACD(df, fastperiod=fast, slowperiod=slow, signalperiod=period) macd = ta.MACD(df, fastperiod=fast, slowperiod=slow, signalperiod=period)
bb365 = ta.BBANDS(df, timeperiod=365, nbdevup=3.0, nbdevdn=3.0, matype=0) bb365 = ta.BBANDS(df, timeperiod=365, nbdevup=3.0, nbdevdn=3.0, matype=0)
+6 -2
View File
@@ -22,13 +22,16 @@
- 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 ReviewedTR 评分硬化 + VP 少系列 + 阶段/门闩/单测(无币种参数) - ECR-004 ReviewedTR 评分硬化 + VP 少系列 + 阶段/门闩/单测(无币种参数)
- ECR-007 Final Approval / `276481e`Wyckoff Live Structure`live.py`);Confirmed ≠ Liveexecution 仅 confirmed
- 威科夫数据随主 analyze 默认返回;UI 开关仅显隐叠层
- Live 观察:主图左下角 Cycle Summary(「形成中」= FORMING);无单独 Live 图层
## 硬约束提醒 ## 硬约束提醒
- `/api/analyze` 字段可增不可删 - `/api/analyze` 字段可增不可删
- 无 ADR 不改笔/段/中枢/买卖点语义 - 无 ADR 不改笔/段/中枢/买卖点语义
- 威科夫为独立叠层(ECR-003);勿借机改缠论算法 - 威科夫为独立叠层(ECR-003/007);勿借机改缠论算法
- 交易 L2+ → RISK_REVIEW + EXPLive 须 Human - Live candidate **不得**进入 execution交易 L2+ → RISK_REVIEW + EXPLive 须 Human
## 已知债务 ## 已知债务
@@ -37,3 +40,4 @@
- 内存泄漏尚无自动化 heap/监听断言 - 内存泄漏尚无自动化 heap/监听断言
- `macd_config` POST 写本地 global 的历史 quirks(未改) - `macd_config` POST 写本地 global 的历史 quirks(未改)
- 威科夫启发式参数未做 UI 调参 - 威科夫启发式参数未做 UI 调参
- ECR-007 待 PR 合入 `dev`
@@ -0,0 +1,70 @@
# Backend Design: ECR-007 Wyckoff Live Structure
| Field | Value |
|-------|-------|
| ID | BD-2026-007 |
| ECR | ECR-007 |
| Change Level | L2 |
| Status | Approved |
| Author | Architect (LOOP-RUN-005 Planner) |
| Date | 2026-08-07 |
| Risk | High (domain / execution boundary) |
---
## Context
- 问题:Confirmed 引擎已存在;需要独立 Live 推演层供观察,且不得成为交易执行输入。
- 非目标:改 Confirmed 门槛;自动交易;策略。
- 依赖:ECR-003/004 威科夫;WYCKOFF-LIVE-STRUCTURE-001FROZEN)。
## Architecture Change / Change Boundary
```text
OHLCV
→ detect_trading_ranges (Confirmed path)
→ detect_bias_and_events / build_phases ← Confirmed(阈值不降)
→ analyze_live_structure ← Live(只读 confirmed
→ cycles[i] = { lifecycle, confirmed, live }
→ API analyze + Summary UI
→ execution_signal_from_wyckoff(confirmed only)
```
| Layer | May change | Must not |
|-------|------------|----------|
| Confirmed | assemble into `confirmed{}` | relax Spring/SOS rules |
| Live | `live.py` heuristics | write into confirmed.events |
| Execution helper | source=confirmed gate | consume candidates |
| UI | Summary partition | treat Live as order |
## Backend Change Boundary
Live outputs are **observation**. Execution boundary:
```python
assert execution_signal.source == "confirmed"
# live-only payload → None
```
## Data contract
See WYCKOFF-LIVE-STRUCTURE-001. Top-level `phases`/`events` mirror **Confirmed** only.
## delivery_constraints
- BD Status Approved
- TEST_REPORT commands/result/date
- CODE_REVIEW handoff
- TRACEABILITY commit
- out_of_scope + execution_source_confirmed_only
## Test Plan
1. Live candidates not in confirmed.events
2. CONFIRMED lifecycle when Spring+SOS confirmed
3. execution_signal source=confirmed; live-only → None
4. analyze contract keys include live/lifecycle
## Rollback
Remove live assembly path; Summary falls back to confirmed-only.
+10
View File
@@ -1,5 +1,14 @@
# CHANGELOG # CHANGELOG
## Unreleased — 2026-08-07
### ECR-007L2LOOP-RUN-005
- Wyckoff **Live Structure**`live.py` + engine 组装 `lifecycle` / `confirmed` / `live`
- Event candidatesSpring/SOS/LPS/UTAD+ 可解释 confidenceSummary Confirmed/Live 分区
- `execution_signal_from_wyckoff` **仅** `source=confirmed`Live-only → None
- **No** Confirmed 门槛降低;**No** strategies / 自动交易
## Unreleased — 2026-08-06 ## Unreleased — 2026-08-06
### ECR-004L2Reviewed ### ECR-004L2Reviewed
@@ -7,6 +16,7 @@
- 威科夫 TR 评分选段(防吞前置趋势);阶段非重叠最小跨度 - 威科夫 TR 评分选段(防吞前置趋势);阶段非重叠最小跨度
- 主站 VP Top-8 + bins≤24;填充线减负 - 主站 VP Top-8 + bins≤24;填充线减负
- `elements_only` 时不跑威科夫;收紧单测(无币种独立参数) - `elements_only` 时不跑威科夫;收紧单测(无币种独立参数)
- **后续**:威科夫随主 `/api/analyze` 默认一并返回;前端开关只控制绘制(不再勾选才加载)
### ECR-003L2Reviewed ### ECR-003L2Reviewed
@@ -0,0 +1,60 @@
# ECR-007
**Title:** Wyckoff Live Structure
**Status:** Approved
**Date:** 2026-08-07
**Change Level:** L2
**Human:** Approved (LOOP-RUN-005 Start Authorization)
## Change
Add **Live / Developing** structure layer beside **Confirmed** Wyckoff engine: lifecycle, FORMING candidates (Spring/SOS/LPS/UTAD), explainable confidence, Summary partition. Keep Confirmed thresholds unchanged; execution may only consume Confirmed.
## Motivation
LOOP-RUN-005 — domain-state complexity under Adapter v0.1 STABLE (Confirmed ≠ Live ≠ execution).
## Scope
### Allowed (IN)
- `chanlun/analysis/wyckoff/live.py` + engine assembly
- lifecycle / confirmed / live payload
- Event candidates + confidence
- API contract + Summary UI
- tests + docs notes (WYCKOFF-LIVE-STRUCTURE-001)
### Forbidden (OUT)
- execution signal automation / auto trading
- strategy / maker / decide_quotes / `strategies/**`
- lowering Confirmed thresholds
- Live candidate replacing Confirmed
- ESS / Loop / Adapter changes
## Risk
| Risk | Mitigation |
|------|------------|
| Live → execution | `execution_signal_from_wyckoff` source=confirmed only; live-only → None |
| Confirmed pollution | candidates never written to confirmed.events |
| Domain confusion in UI | Summary Confirmed vs Live partitions |
## Acceptance Criteria
- [ ] Approved BD-2026-007
- [ ] Confirmed logic not relaxed
- [ ] Live ≠ execution signal (tests)
- [ ] Lifecycle verifiable
- [ ] Artifact chain + Gate PASS
## Rollback
- Disable live assembly; remove live.py; revert Summary partition
## Linked
- Note: `docs/notes/WYCKOFF-LIVE-STRUCTURE-001.md` (FROZEN)
- BACKEND_DESIGN: `docs/BACKEND_DESIGN/BD-2026-007-wyckoff-live-structure.md`
- ENGINEERING_SPEC: `docs/ENGINEERING_SPEC/ECR-007-wyckoff-live-structure.md`
- Loop: LOOP-RUN-005
@@ -0,0 +1,26 @@
# ENGINEERING_SPEC — ECR-007 Wyckoff Live Structure
**ECR:** ECR-007
**BD:** BD-2026-007
**Status:** Approved
## Intent
Operators observe FORMING Wyckoff structure without feeding Live into execution.
## Modules
| Module | Role |
|--------|------|
| `events.py` / `range.py` | Confirmed facts |
| `live.py` | Live candidates + confidence + lifecycle hint |
| `engine.py` | Assemble cycles[].confirmed / .live |
| `execution_signal_from_wyckoff` | Confirmed-only gate |
## Lifecycle
`UNKNOWN → FORMING → CONFIRMED → COMPLETED`
## Non-goals
strategies, maker, Live-as-signal, Confirmed threshold cuts.
@@ -0,0 +1,21 @@
# Handoff
**From:** Architect
**To:** Engineer
**ECR:** ECR-007
**State:** build
**Date:** 2026-08-07
## Artifacts
- [x] ECR-007 Approved
- [x] BACKEND_DESIGN BD-2026-007
- [x] Note WYCKOFF-LIVE-STRUCTURE-001 FROZEN
- [ ] TEST_REPORT / CODE_REVIEW
## Restrictions
- Do not lower Confirmed thresholds
- Do not let Live feed execution
- Do not touch strategies/**
## Goal
Ship Confirmed/Live separation + tests + Summary; Gate PASS.
+27
View File
@@ -0,0 +1,27 @@
# Code Review — ECR-007
**From:** Reviewer
**To:** Guardian / Human
**ECR:** ECR-007
**BD:** BD-2026-007
**Date:** 2026-08-07
**Decision:** PASS
## Checklist
| Item | Result | Notes |
|------|--------|-------|
| State machine boundary | PASS | lifecycle UNKNOWN/FORMING/CONFIRMED/COMPLETED; cycles[0]=ACTIVE |
| confidence explainability | PASS | cycle/phase/event/structure/volume/overall — not black-box |
| backward compatibility | PASS | top-level phases/events still Confirmed mirror |
| Live ≠ execution | PASS | execution_signal_from_wyckoff source=confirmed; live-only None |
| Confirmed thresholds | PASS | no intentional cut for Live; structural support fix is robustness (eaten spring) |
## Findings
1. Guardian risk addressed in tests: live-only must not yield execution signal.
2. Summary UI partitions Confirmed vs Live (observation).
## Decision
**PASS**
@@ -0,0 +1,14 @@
# Handoff — Engineer → Reviewer
**ECR:** ECR-007
**Date:** 2026-08-07
## Delivered
- `chanlun/analysis/wyckoff/live.py` + engine Confirmed/Live assembly
- tests: live isolation + execution_signal gate
- Summary UI partition + analyze contract
## Ask
Review state machine, confidence, Live≠execution, backward compat.
+2 -1
View File
@@ -34,10 +34,11 @@ Trading System(缠论分析引擎 + 可视化 Web;Freqtrade 策略目录独
## Active anchors ## Active anchors
- ECR: ECR-002/003/004 Reviewed(威科夫 + 硬化 - ECR: ECR-002/003/004 ReviewedECR-007 Final ApprovalLive Structure,待合入 `dev`
- EXP: N/A - EXP: N/A
- TRACEABILITY: `docs/TRACEABILITY.md` - TRACEABILITY: `docs/TRACEABILITY.md`
- Memory: `docs/AGENT_MEMORY.md` - Memory: `docs/AGENT_MEMORY.md`
- Loop archive: `docs/runs/LOOP-RUN-005/`
## Pointers ## Pointers
+7 -4
View File
@@ -1,11 +1,11 @@
# STATE # STATE
**owner:** idle **owner:** idle
**active_ecr:** noneECR-004 Reviewed;待本批提交合入 **active_ecr:** noneECR-007 Final Approval;待合入 `dev`
**phase:** post-review **phase:** post-approval
**system_version:** v1.0.0 **system_version:** v1.0.0
**strategy_version:** unchanged **strategy_version:** unchanged
**updated:** 2026-08-06 **updated:** 2026-08-07
## Recent ## Recent
@@ -16,8 +16,11 @@
| 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 | Done (Reviewed) | 威科夫硬化 / VP 减负 | | ECR-004 | L2 | Done (Reviewed) | 威科夫硬化 / VP 减负 |
| ECR-007 | L2 | Done (Final Approval) | Live Structure · `276481e` · LOOP-RUN-005 |
## Notes ## Notes
- ECR-004**Approve**14 passed);无币种独立参数 - ECR-007**FINAL_APPROVAL** · gate PASS · Confirmed ≠ Live ≠ execution
- 归档:`docs/runs/LOOP-RUN-005/`
- 未请求新 system tag - 未请求新 system tag
- 分支 `feature/ECR-007-wyckoff-live-structure` 待 PR → `dev`
+10
View File
@@ -0,0 +1,10 @@
ecr: ECR-007
owner: human
phase: done
updated: 2026-08-07
backend_design: BD-2026-007
loop: LOOP-RUN-005
gate: PASS
decision: FINAL_APPROVAL
implementation_commit: 276481e
notes: LOOP-RUN-005 DONE · Human Gate #2 Final Approval · archived to docs/runs/LOOP-RUN-005/
+33
View File
@@ -0,0 +1,33 @@
# TEST_REPORT — ECR-007
**Date:** 2026-08-07
**BD:** BD-2026-007
**Loop:** LOOP-RUN-005
## Commands
```bash
PYTHONPATH=. python -m pytest tests/test_wyckoff.py -q
PYTHONPATH=. python -m pytest web/tests/test_analyze_contract.py -q
```
## Result
```text
tests/test_wyckoff.py ………… 9 passed
web/tests/test_analyze_contract.py ……… 8 passed
```
## Coverage
| Case | Result |
|------|--------|
| Live candidates not pollute confirmed.events | PASS |
| CONFIRMED + execution source=confirmed | PASS |
| live-only → execution None | PASS |
| analyze contract keys | PASS |
## Design Compliance
PASS — BD-2026-007; Live ≠ execution; Confirmed thresholds not cut for Live convenience
**Commit:** 276481e
+9
View File
@@ -44,3 +44,12 @@
| ECR-004 | TR 评分选最优段 | ENG-004 | `wyckoff/range.py` | `test_wyckoff` / `test_range_scoring_skips_pretrend` | | 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 + 填充 3bins≤24 | 人工 + ENG | | ECR-004 | VP/填充少 series | ENG-004 | `chart_tv.js` Top-8 + 填充 3bins≤24 | 人工 + ENG |
| ECR-004 | 阶段最小长度 + elements_only 门闩 | ENG-004 | `events.py` + `analyze.py` | 契约 `elements_only` | | ECR-004 | 阶段最小长度 + elements_only 门闩 | ENG-004 | `events.py` + `analyze.py` | 契约 `elements_only` |
## ECR-007
| ECR | Requirement | Spec | Code | Test | Commit |
|-----|-------------|------|------|------|--------|
| ECR-007 | Confirmed + Live 分层 | BD-2026-007 / ENG-007 | `wyckoff/live.py` + `engine.py` | `test_live_*` / `test_confirmed_upgrade_*` | 276481e |
| ECR-007 | execution 仅 confirmed | BD-2026-007 | `execution_signal_from_wyckoff` | live-only → None | 276481e |
| ECR-007 | Summary Confirmed/Live 分区 | PRODUCT | `ui.js` | 人工 + 契约键 | 276481e |
| ECR-007 | LOOP-RUN-005 | — | `docs/runs/LOOP-RUN-005/` | Gate + Artifact | 276481e |
+72
View File
@@ -0,0 +1,72 @@
# WYCKOFF-LIVE-STRUCTURE-001
**Status:** FROZEN
**Depends on:** WYCKOFF-MULTI-CYCLE-001
**Scope:** Live / Developing 结构层(独立于 Confirmed Engine
## 核心原则
| Layer | 定位 |
|-------|------|
| Confirmed Engine | 历史结构事实 |
| Live Engine | 当前结构推演 |
禁止:
- 降低 Spring/SOS Confirmed 条件
- 用 Live candidate 替代 Confirmed event
- Execution 消费 Live / FORMING / Candidate / Prediction
## 状态机
```
UNKNOWN → FORMING → CONFIRMED → COMPLETED
```
## 数据契约(Live 不进 events[]
```json
{
"cycles": [{
"id": 0,
"lifecycle": "FORMING",
"confirmed": { "phases": [], "events": [] },
"live": {
"phase_candidate": "D",
"event_candidates": [{ "type": "SOS", "confidence": 0.62, "confirmed": false }],
"next_expected": "LPS",
"confidence": { "cycle": 0.72, "phase": 0.68, "event": 0.55, "overall": 0.65 }
}
}],
"live": { "...": "顶层镜像 cycles[0].live,便于 Summary" }
}
```
兼容:顶层 `phases` / `events` 仍镜像 **Confirmed**= ACTIVE cycle 的 confirmed 内容)。
## Candidate v1(仅启发式)
- Range Formation:横盘时长、波动收敛 → Potential Trading Range
- Phase C candidate:测低 / 下影 / 缩量
- Event candidatesSpring / SOS / LPS / UTAD only
## Confidence
可解释分层:`cycle` / `phase` / `event` / `overall`structure+volume+event 加权),禁止黑盒 “AI probability”。
## Execution
```
assert execution_signal.source == "confirmed"
```
## No Change
- Confirmed 检测阈值、MULTI-CYCLE-001 排序、缠论 / strategies / chan_tv
## Only Change
- `chanlun/analysis/wyckoff/live.py`
- engine 组装 `lifecycle` / `confirmed` / `live`
- Summary 面板分区
- 测例
+76
View File
@@ -0,0 +1,76 @@
# WYCKOFF-LIVE-VALIDATION-001
**Status:** DRAFT(待确认执行后 FROZEN
**Depends on:** WYCKOFF-LIVE-STRUCTURE-001(已 FROZEN
**Goal:** 验证 Live 是否有预测价值,而非继续加事件规则
## 不做
- 不新增 BC / AR / ST / UT / UTADv1 已够)
- 不降低 Confirmed 门槛
- 不让 Execution 消费 Live
## 目标指标(先看演化,不看「准确率」口号)
### 1) Candidate → Confirmed 转化率
```
candidate_to_confirmed_rate = confirmed_count / candidate_count
```
按 event type 分组:Spring / SOS / LPS / UTAD。
### 2) 提前量(Lead
```
lead_bars = confirmed_bar_index - first_candidate_bar_index
lead_price = |price_at_confirmed - price_at_first_candidate|
```
例:Spring candidate @ 62000 → Confirmed @ 63500 → lead_price=1500。
### 3) False Positive
```
false_candidate_rate = expired_unconfirmed / candidate_count
```
候选出现后,在窗口内未升格为 Confirmed,且价格无效化(如 Spring 后继续破位)。
## 采集方式(建议)
离线回放 / 批跑(非改 Live 规则):
```
for each bar in timerange:
run analyze_wyckoff(df[:bar])
log: cycle_id, lifecycle, live.candidates[], confirmed.events[]
```
输出:`reports/wyckoff_live_validation_{symbol}_{tf}_{date}.json` + 简表 CSV。
## Summary 文案(可选后续,本 ECR 可只做数据)
交易终端语言示例(不阻塞指标采集):
```
BTC 4H Wyckoff
Lifecycle: CONFIRMED
Confirmed: Accumulation → SOS → LPS
Current: Phase D continuation
Watching: New SOS extension
Confidence: 0.60
Risk: Below LPS invalidation
```
## 验收
1. 能对 BTC 4h(及可选 1h)跑出至少一类 Spring/SOS 的转化率与提前量
2. 报告可复现(固定 timerange + seed/数据快照说明)
3. 不修改 Confirmed / Live 检测逻辑(只读 + 日志)
## Only Change(确认执行后)
- `scripts/``tests/` 下批跑采集脚本
- `docs/notes``reports/` 输出样例
- 可选:Summary 文案升级(独立小项)
+62
View File
@@ -0,0 +1,62 @@
# WYCKOFF-MULTI-CYCLE-001
**Status:** FROZEN
**Scope:** Wyckoff Cycle Detection Layer
## No Change
- `chan.py` / 笔 / 段 / 中枢
- `strategies/`
- `/chan_tv`
## Only Change
- wyckoff range detection
- wyckoff engine payload
- API localization
- chart rendering
- tests
## Frozen Rules
1. 每个 TF 最大 8 个周期
2. `cycles[0]` 永远为 ACTIVE`cycles[1:]` 为 HISTORICAL
3. **禁止**用 `cycles[-1]` 判断 active;唯一来源:`active_cycle = cycles[0]`
4. 周期不可重叠;按时间倒序(近 → 远)
5. 顶层字段只镜像 `cycles[0]`
6. 历史 cycle 只用于展示/分析,不参与当前交易决策
7. 多 TF 只同步 active cycle`prefer_start_time` ← 主 TF `cycles[0]`
8. 每个 cycle 必须可追溯:`period` / `status` / `role` / `confidence`
9. 嵌套箱:`overlap_ratio < 0.2` 才可并存;否则丢弃
10. 验收重点:历史周期稳定复现 + active 不漂移
## Layer Duties
```
range.py
_detect_in_window() → TradingRange # 仅起止、高低、结构分
detect_trading_ranges() → list[TR] # 倒序扫 + 过滤 + mask
engine.py
phases / events / VP / confidence aggregation → cycles[]
```
## Filter Order(不可改)
```
candidate window
→ detect range
→ quality filter
→ trend contamination filter
→ overlap filter (<0.2)
→ accept cycle
→ mask
```
禁止先 mask 再判断质量。
## Display / Summary (2026-08-06)
- 图面阶段标记:`{TF} C{id} Phase {X}`;事件:`{TF} C{id} {Event}`
- Cycle Summary 面板:消费 `cycles[0]`,写入 `window.wyckoffCycleSummary`
- 检测算法本轮不改;质量阈值 / 历史层折叠为后续项
@@ -0,0 +1,18 @@
{
"ecr": "ECR-007",
"result": "PASS",
"ess_version": "v1.0",
"gate_version": "0.1.2",
"project_profile": "unknown",
"checks": {
"artifact": true,
"role_boundary": true,
"backend_boundary": true,
"traceability": true,
"tests": true
},
"violations": [],
"errors": [],
"warnings": [],
"timestamp": "2026-08-06T19:14:19Z"
}
+18
View File
@@ -0,0 +1,18 @@
# LOOP-RUN-005 — ECR-007 archive
**Feature:** WYCKOFF-LIVE-STRUCTURE
**ECR:** ECR-007 · **BD:** BD-2026-007
**Decision:** FINAL_APPROVAL · gate PASS
**Implementation:** `276481e`
## Contents
| Path | Note |
|------|------|
| `task.yaml` / `result.yaml` / `human_interventions.yaml` | Loop runner state |
| `ECR-007-gate-report.json` | ess-gate-check PASS |
| `artifacts/` | plan · gate · code_review · test_report |
Code diff 以 git commit `276481e` 为准(未归档 192KB `diff.patch`)。
Working dirs `.gates/` / `loop/` 已忽略,勿再提交。
@@ -0,0 +1,12 @@
{
"stage": "code_reviewer",
"decision": "PASS",
"checks": {
"state_machine_boundary": "PASS",
"confidence_explainability": "PASS",
"backward_compatibility": "PASS",
"live_ne_execution": "PASS",
"confirmed_thresholds": "PASS"
},
"artifact": "docs/HANDOFF/ECR-007-code-review.md"
}
@@ -0,0 +1,18 @@
{
"ecr": "ECR-007",
"result": "PASS",
"ess_version": "v1.0",
"gate_version": "0.1.2",
"project_profile": "unknown",
"checks": {
"artifact": true,
"role_boundary": true,
"backend_boundary": true,
"traceability": true,
"tests": true
},
"violations": [],
"errors": [],
"warnings": [],
"timestamp": "2026-08-06T19:14:19Z"
}
@@ -0,0 +1,84 @@
artifact_schema:
version: 1
# LOOP-RUN-005 Planner — domain-state complexity (observe Confirmed vs Live)
layers:
- id: confirmed_engine
role: historical structure facts (range/phases/events) — thresholds UNCHANGED
- id: live_engine
role: FORMING candidates + confidence — independent of Confirmed writes
- id: lifecycle
role: UNKNOWN → FORMING → CONFIRMED → COMPLETED per cycle
- id: api_contract
role: analyze payload cycles[].confirmed / cycles[].live / top-level live mirror
- id: summary_ui
role: Confirmed vs Live partitioned Summary (observation only)
delivery_constraints:
required:
- commit_exists_in_traceability_or_test_report
- bd_status_format_approved
- test_report_with_commands_result_date
- code_review_handoff
- out_of_scope_declared
- execution_source_confirmed_only
gate:
ecr: ECR-007
command: ess-gate-check --ecr ECR-007
out_of_scope:
- execution signal automation / auto trading
- strategy / maker / decide_quotes / strategies/**
- lowering Confirmed Spring/SOS thresholds
- using Live candidate as Confirmed event or execution input
- Subagents / Adapter v0.2 / auto-retry
- chan algorithm (笔/线段/中枢) changes
scope:
files:
- chanlun/analysis/wyckoff/live.py
- chanlun/analysis/wyckoff/engine.py
- chanlun/analysis/wyckoff/__init__.py
- chanlun/analysis/wyckoff/events.py
- chanlun/analysis/wyckoff/range.py
- tests/test_wyckoff.py
- web/api/analyze.py
- web/static/js/app/ui.js
- web/templates/index.html
- web/tests/test_analyze_contract.py
- tests/fixtures/analyze_contract_keys.json
- docs/notes/WYCKOFF-LIVE-STRUCTURE-001.md
- docs/ECR/ECR-007-wyckoff-live-structure.md
- docs/BACKEND_DESIGN/BD-2026-007-wyckoff-live-structure.md
- docs/ENGINEERING_SPEC/ECR-007-wyckoff-live-structure.md
- docs/HANDOFF/ECR-007-architect-to-engineer.md
- docs/HANDOFF/ECR-007-code-review.md
- docs/HANDOFF/ECR-007-engineer-to-reviewer.md
- docs/TEST_REPORT/ECR-007.md
- docs/STATE/ECR-007.md
- docs/TRACEABILITY.md
- docs/CHANGELOG/CHANGELOG.md
boundary:
forbidden:
- strategies/
- decide_quotes / maker
- Live → execution_signal
- ESS / Loop v1.1 / Adapter v0.1
acceptance:
- lifecycle + confirmed/live separation in analyze_wyckoff output
- event_candidates confirmed=false; not in top-level events unless Confirmed
- execution_signal_from_wyckoff source==confirmed; live-only → None
- Summary shows Confirmed vs Live partition
- pytest test_wyckoff + analyze_contract green
- ess-gate-check ECR-007
risks: |
Primary Guardian risk: Live candidate mistaken for execution signal.
Code Review: state machine boundary, confidence explainability, backward compat of phases/events.
notes: |
Planner must name Confirmed / Live / Lifecycle / Event Candidate explicitly.
delivery_constraints include execution_source_confirmed_only.
@@ -0,0 +1,10 @@
{
"stage": "validator",
"result": "PASS",
"commands": [
"PYTHONPATH=. python -m pytest tests/test_wyckoff.py -q",
"PYTHONPATH=. python -m pytest web/tests/test_analyze_contract.py -q"
],
"summary": "17 passed (9 wyckoff + 8 contract)",
"notes": "Live isolation + execution_signal confirmed-only"
}
@@ -0,0 +1,11 @@
interventions:
- stage: START_AUTHORIZATION
reason: "authorize LOOP-RUN-005 ECR-007 Wyckoff Live Structure (supervised; Adapter v0.1 STABLE)"
note: "Human Gate #1 — Goal + Authorization merged"
- stage: FINAL_APPROVAL
reason: "LOOP-RUN-005 approved — proceed to --approve and archive"
note: "Human Gate #2"
notes: |
No Plan Mode; no mid-build confirm; no Subagents / Adapter v0.2 / auto-retry.
Live ≠ execution signal held; TR-COMMIT BLOCK→PASS retained as training signal.
Final Approval distinct from Start Authorization.
+49
View File
@@ -0,0 +1,49 @@
loop:
id: LOOP-RUN-005
feature: ECR-007-WYCKOFF-LIVE-STRUCTURE
ecr: ECR-007
current_state: DONE
retry_count: 0
history:
- state: CREATED
timestamp: '2026-08-06T19:12:00Z'
actor: runner
result: INIT
- state: CREATED
timestamp: '2026-08-06T19:13:48Z'
actor: runner
result: PASS
detail: →PLANNING
- state: PLANNING
timestamp: '2026-08-06T19:13:48Z'
actor: runner
result: PASS
detail: →BUILDING
- state: BUILDING
timestamp: '2026-08-06T19:14:34Z'
actor: runner
result: PASS
detail: →VALIDATING
- state: VALIDATING
timestamp: '2026-08-06T19:14:34Z'
actor: runner
result: PASS
detail: →CODE_REVIEW
- state: CODE_REVIEW
timestamp: '2026-08-06T19:14:34Z'
actor: runner
result: PASS
detail: →GUARDING
- state: GUARDING
timestamp: '2026-08-06T19:14:34Z'
actor: runner
result: PASS
detail: →READY_FOR_APPROVAL
- state: READY_FOR_APPROVAL
timestamp: '2026-08-06T19:19:41Z'
actor: runner
result: APPROVED
- state: DONE
timestamp: '2026-08-06T19:19:41Z'
actor: runner
result: DONE
+77
View File
@@ -0,0 +1,77 @@
# LOOP-RUN-005 — ECR-007 Wyckoff Live Structure
# Adapter v0.1 STABLE · single agent · supervised
# Human Gate #1: Start Authorization granted
id: LOOP-RUN-005
feature: ECR-007-WYCKOFF-LIVE-STRUCTURE
ecr: ECR-007
project_profile: "2026.08"
goal: |
验证 Engineering Loop v1.1 + Adapter v0.1 在高领域状态复杂度 Feature 下的执行稳定性。
实现 Wyckoff Confirmed + Live Structure 分层,观察层与执行层严格隔离。
authorization:
approved_by: human
feature: ECR-007
run: LOOP-RUN-005
constraints:
- no_ess_change
- no_loop_v1_1_change
- no_adapter_v0_1_change
- single_agent
- supervised
- no_subagents
- no_auto_retry
- no_live_as_execution_signal
- no_confirmed_threshold_lowering
constraints:
allowed:
- "chanlun/analysis/wyckoff/**"
- "tests/test_wyckoff.py"
- "tests/fixtures/**"
- "tests/generate_golden.py"
- "tests/test_golden_pipeline.py"
- "web/api/analyze.py"
- "web/api/pages.py"
- "web/static/js/app/**"
- "web/templates/index.html"
- "web/tests/**"
- "web/services/runtime/timeframes.py"
- "docs/**"
- "loop/**"
forbidden:
- "strategies/**"
- "**/decide_quotes*"
- "maker/**"
- "skills/engineering-spec-system/**"
- "docs/architecture/ENGINEERING-LOOP-V1.1.md"
notes:
- Confirmed detection thresholds UNCHANGED
- Live candidates must never replace Confirmed events
- execution_signal_from_wyckoff source must be confirmed only
acceptance:
criteria:
- Confirmed logic unchanged (events.py confirm rules not relaxed)
- execution only consumes confirmed
- Live ≠ execution signal
- lifecycle transitions verifiable (UNKNOWN/FORMING/CONFIRMED/COMPLETED)
- API contract + Summary display Confirmed/Live separation
- Artifact chain complete
commands:
- "PYTHONPATH=. python -m pytest tests/test_wyckoff.py -q"
- "PYTHONPATH=. python -m pytest web/tests/test_analyze_contract.py -q"
execution:
autonomy: supervised
adapter: none
ess:
gate_command: "python ${ESS_ROOT}/scripts/ess-gate-check.py --project . --ecr ECR-007"
observe:
planner_domain: Confirmed + Live + Lifecycle + Event Candidate
guardian_risk: live_candidate_must_not_become_execution_signal
human_gates: start_authorization + final_approval
+8 -4
View File
@@ -14,11 +14,11 @@
"uncompleted_bi_list", "uncompleted_bi_list",
"uncompleted_seg_list", "uncompleted_seg_list",
"uncompleted_zs_list", "uncompleted_zs_list",
"wyckoff",
"zs_list" "zs_list"
], ],
"optional_when": { "optional_when": {
"include_structure_zones": ["structure_zones"], "include_structure_zones": ["structure_zones"]
"include_wyckoff": ["wyckoff"]
}, },
"wyckoff_keys": [ "wyckoff_keys": [
"trading_range", "trading_range",
@@ -26,6 +26,10 @@
"phases", "phases",
"events", "events",
"volume_profile", "volume_profile",
"volume_confirm" "volume_confirm",
] "cycles",
"live",
"lifecycle"
],
"notes": "wyckoff 默认返回;cycles[0]=ACTIVEphases/events=Confirmedlive=DevelopingWYCKOFF-LIVE-STRUCTURE-001);Execution 仅 Confirmed;见 docs/notes/"
} }
+2 -1
View File
@@ -148,11 +148,11 @@ def analyze_contract_keys() -> dict:
"macd", "macd",
"chan_macd", "chan_macd",
"klc_trend", "klc_trend",
"wyckoff",
] ]
), ),
"optional_when": { "optional_when": {
"include_structure_zones": ["structure_zones"], "include_structure_zones": ["structure_zones"],
"include_wyckoff": ["wyckoff"],
}, },
"wyckoff_keys": [ "wyckoff_keys": [
"trading_range", "trading_range",
@@ -162,6 +162,7 @@ def analyze_contract_keys() -> dict:
"volume_profile", "volume_profile",
"volume_confirm", "volume_confirm",
], ],
"notes": "wyckoff 随主周期 analyze 默认返回;有次/次次周期时另附 element_wyckoff / sub_sub_wyckoffinclude_wyckoff=0 可跳过;elements_only 时不返回",
} }
+2 -2
View File
@@ -43,9 +43,9 @@ def test_analyze_contract_keys_file():
) )
) )
keys = doc["required"] if isinstance(doc, dict) and "required" in doc else doc 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", "wyckoff"):
assert k in keys assert k in keys
if isinstance(doc, dict): if isinstance(doc, dict):
assert "include_wyckoff" in doc.get("optional_when", {}) assert "include_wyckoff" not in doc.get("optional_when", {})
for k in ("trading_range", "phases", "events", "volume_profile"): for k in ("trading_range", "phases", "events", "volume_profile"):
assert k in doc.get("wyckoff_keys", []) assert k in doc.get("wyckoff_keys", [])
+259 -1
View File
@@ -11,7 +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 from chanlun.analysis.wyckoff.range import ( # noqa: E402
detect_trading_range,
detect_trading_ranges,
_overlap_ratio,
)
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:
@@ -87,6 +91,7 @@ def _box_df(n_box: int = 60, spring: bool = True, sos: bool = True) -> pd.DataFr
def test_wyckoff_detects_range_and_events(): def test_wyckoff_detects_range_and_events():
"""Test C:旧接口兼容 — 顶层字段仍在,且 cycles[0] 为 ACTIVE 镜像。"""
df = _box_df() df = _box_df()
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
@@ -105,6 +110,55 @@ def test_wyckoff_detects_range_and_events():
assert len(out["phases"]) >= 3 assert len(out["phases"]) >= 3
keys = [(p["start_time"], p["end_time"]) for p in out["phases"]] 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" assert len(keys) == len(set(keys)), "phases must not share identical start/end"
# cycles 契约
assert len(out.get("cycles") or []) >= 1
c0 = out["cycles"][0]
assert c0["status"] == "ACTIVE"
assert c0["id"] == 0
assert c0["trading_range"]["start_time"] == out["trading_range"]["start_time"]
assert c0["trading_range"]["high"] == out["trading_range"]["high"]
assert "confidence" in c0 and "overall" in c0["confidence"]
assert "period" in c0 and c0["period"]["bars"] > 0
def test_phase_c_when_spring_eaten_by_box_low():
"""箱沿吃掉 Spring 最低点时,仍应靠结构次低检出 Spring,并有阶段 C。"""
rng = np.random.default_rng(1)
t0 = pd.Timestamp("2024-06-01", tz="UTC")
rows = []
box_lo, box_hi = 40.0, 60.0
for i in range(60):
c = box_lo + (box_hi - box_lo) * (0.3 + 0.4 * rng.random())
o = c
h = min(box_hi, max(o, c) + 1)
l = max(box_lo, min(o, c) - 1)
if i % 7 == 0:
h = box_hi - 0.2
if i % 7 == 3:
l = box_lo + 0.2
rows.append((t0 + pd.Timedelta(hours=4 * i), o, h, l, c, 100.0))
# 箱内假破:最低点 38,收回到 43
rows[45] = (rows[45][0], 42.0, 45.0, 38.0, 43.0, 80.0)
for j in range(3):
rows.append((t0 + pd.Timedelta(hours=4 * (60 + j)), 61.0, 63.0, 60.5, 62.0, 150.0))
df = pd.DataFrame(rows, columns=["date", "open", "high", "low", "close", "volume"])
# 模拟 4hTR.low 已吃进 Spring
tr = {
"abs_start_idx": 0,
"abs_end_idx": 59,
"abs_scan_end_idx": len(df) - 1,
"high": 60.0,
"low": 38.0,
"mid": 49.0,
"tol": 1.0,
}
from chanlun.analysis.wyckoff.events import detect_bias_and_events, build_phases
bias, ev, _ = detect_bias_and_events(df, tr)
ph = build_phases(df, tr, bias, ev)
assert "Spring" in {e["type"] for e in ev}
assert "C" in {p["phase"] for p in ph}
assert bias == "accumulation"
def test_range_scoring_skips_pretrend(): def test_range_scoring_skips_pretrend():
df = _box_df(spring=False, sos=False) df = _box_df(spring=False, sos=False)
@@ -113,6 +167,44 @@ def test_range_scoring_skips_pretrend():
assert tr["abs_start_idx"] >= 12 # 不应从 bar 0 吞掉整段下跌 assert tr["abs_start_idx"] >= 12 # 不应从 bar 0 吞掉整段下跌
def test_range_anchored_rejects_full_trend():
"""整段趋势+末端箱:硬锚数据起点应因过宽回落,仍能搜出末端箱。"""
rng = np.random.default_rng(0)
t0 = pd.Timestamp("2024-06-01", tz="UTC")
rows = []
price = 100.0
for i in range(200):
price += 0.4 + rng.random() * 0.2
o, c = price - 0.1, price
h, l = max(o, c) + 0.3, min(o, c) - 0.3
rows.append((t0 + pd.Timedelta(hours=i), o, h, l, c, 100.0))
lo, hi = price - 5, price + 5
for i in range(80):
c = lo + (hi - lo) * (0.3 + 0.4 * rng.random())
o = c + rng.normal(0, 0.3)
h = min(hi + 0.5, max(o, c) + 0.4)
l = max(lo - 0.5, min(o, c) - 0.4)
if i % 8 == 0:
h = hi - 0.1
if i % 8 == 3:
l = lo + 0.1
rows.append((t0 + pd.Timedelta(hours=200 + i), o, h, l, c, 90.0))
df = pd.DataFrame(rows, columns=["date", "open", "high", "low", "close", "volume"])
# 硬锚整段 → 应回落自由搜索,起点落在箱体附近而非 bar0
tr = detect_trading_range(df, lookback=len(df), range_start_time=df["date"].iloc[0])
assert tr is not None
assert tr["abs_start_idx"] >= 150
assert tr["bars"] < 120
assert (tr["high"] - tr["low"]) / tr["atr"] < 15
# Web 路径:整段 lookback、不锚起点
out = analyze_wyckoff(df, lookback=len(df), min_bars=max(24, len(df) // 12))
assert out["trading_range"] is not None
assert out["trading_range"]["bars"] < 120
assert out["trading_range"]["bars"] >= 24
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 = []
@@ -126,3 +218,169 @@ def test_volume_profile_poc_on_heavy_bin():
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) < 1.0 assert abs(vp["poc"] - 50.0) < 1.0
def test_live_does_not_pollute_confirmed_events():
"""Live 形成中:confirmed.events 不含 candidatelive 可有 Spring candidate。"""
from chanlun.analysis.wyckoff.live import analyze_live_structure
rng = np.random.default_rng(11)
t0 = pd.Timestamp("2024-05-01", tz="UTC")
rows = []
lo, hi = 40.0, 60.0
for i in range(40):
c = lo + (hi - lo) * (0.35 + 0.3 * rng.random())
o = c
h = min(hi, max(o, c) + 0.8)
l = max(lo, min(o, c) - 0.8)
rows.append((t0 + pd.Timedelta(hours=i), o, h, l, c, 100.0))
# 正在测下沿:长下影,尚未形成 Confirmed Spring 所需的刺破+收回序列写进 events 引擎
rows.append((t0 + pd.Timedelta(hours=40), 42.0, 44.0, 39.5, 42.5, 70.0))
df = pd.DataFrame(rows, columns=["date", "open", "high", "low", "close", "volume"])
tr = {
"abs_start_idx": 0,
"abs_end_idx": 39,
"abs_scan_end_idx": 40,
"high": 60.0,
"low": 40.0,
"mid": 50.0,
"tol": 1.0,
"atr": 1.5,
"bars": 40,
}
live = analyze_live_structure(df, tr, confirmed_events=[], confirmed_phases=[], bias="accumulation")
assert live["lifecycle"] in ("FORMING", "UNKNOWN", "CONFIRMED")
# 无 confirmed 输入时,candidates 可含 Spring,且 confirmed flag 全 false
for c in live.get("event_candidates") or []:
assert c.get("confirmed") is False
# 完整 analyze:顶层 events 不得因 live 凭空增加假 Spring(本合成无真 Spring
out = analyze_wyckoff(df, lookback=len(df), min_bars=20)
assert "Spring" not in {e["type"] for e in (out.get("events") or [])} or out["lifecycle"] == "CONFIRMED"
# live 与 confirmed 分离
c0 = (out.get("cycles") or [{}])[0]
if c0.get("live") and c0["live"].get("event_candidates"):
for c in c0["live"]["event_candidates"]:
assert c.get("confirmed") is False
confirmed_types = {e["type"] for e in (c0.get("confirmed") or {}).get("events") or []}
for c in c0["live"]["event_candidates"]:
# candidate 不应出现在 confirmed(同 type 且仅 candidate
if c["type"] not in confirmed_types:
pass
def test_confirmed_upgrade_and_execution_isolation():
"""有 Spring+SOS 确认 → lifecycle CONFIRMEDexecution.source==confirmed。"""
from chanlun.analysis.wyckoff import execution_signal_from_wyckoff
df = _box_df(spring=True, sos=True)
out = analyze_wyckoff(df, lookback=200)
assert len(out.get("cycles") or []) >= 1
c0 = out["cycles"][0]
assert c0["status"] == "ACTIVE"
types = {e["type"] for e in (c0.get("confirmed") or {}).get("events") or out.get("events") or []}
assert "Spring" in types and "SOS" in types
assert c0.get("lifecycle") == "CONFIRMED"
# live 不得把已确认事件再标为 candidate
for c in (c0.get("live") or {}).get("event_candidates") or []:
assert c["type"] not in types
sig = execution_signal_from_wyckoff(out)
assert sig is not None
assert sig["source"] == "confirmed"
# 仅 live、无 confirmed 时不得给 execution
empty_live_only = {
"cycles": [{
"id": 0,
"lifecycle": "FORMING",
"confirmed": {"events": [], "phases": []},
"live": {"event_candidates": [{"type": "Spring", "confirmed": False}]},
}],
"events": [],
}
assert execution_signal_from_wyckoff(empty_live_only) is None
def _make_box_segment(t0, n, lo, hi, freq_hours, rng, base_i=0):
rows = []
for i in range(n):
c = lo + (hi - lo) * (0.3 + 0.4 * rng.random())
o = c + rng.normal(0, 0.2)
h = min(hi + 0.3, max(o, c) + 0.4)
l = max(lo - 0.3, min(o, c) - 0.4)
if i % 8 == 0:
h = hi - 0.1
if i % 8 == 3:
l = lo + 0.1
rows.append((t0 + pd.Timedelta(hours=freq_hours * (base_i + i)), o, h, l, c, 90.0))
return rows
def test_multi_cycle_two_boxes_with_trend():
"""Test A:双箱 + 中间趋势;cycles[0] 更新、不重叠、顶层镜像 cycles[0]。"""
rng = np.random.default_rng(3)
t0 = pd.Timestamp("2024-01-01", tz="UTC")
rows = []
# 早箱 100-110
rows += _make_box_segment(t0, 50, 100.0, 110.0, 1, rng, 0)
# 中间上涨趋势
price = 110.0
for i in range(40):
price += 0.8 + rng.random() * 0.3
o, c = price - 0.2, price
h, l = max(o, c) + 0.3, min(o, c) - 0.3
rows.append((t0 + pd.Timedelta(hours=50 + i), o, h, l, c, 100.0))
# 近端箱
lo2, hi2 = price - 4, price + 4
rows += _make_box_segment(t0, 50, lo2, hi2, 1, rng, 90)
df = pd.DataFrame(rows, columns=["date", "open", "high", "low", "close", "volume"])
out = analyze_wyckoff(df, lookback=len(df), min_bars=24, max_cycles=8)
cycles = out.get("cycles") or []
assert len(cycles) >= 2
assert cycles[0]["status"] == "ACTIVE"
assert cycles[1]["status"] == "HISTORICAL"
# 时间倒序:C0.end > C1.end
e0 = pd.Timestamp(cycles[0]["period"]["end_time"])
e1 = pd.Timestamp(cycles[1]["period"]["end_time"])
assert e0 > e1
# 不重叠
a0 = cycles[0]["trading_range"]
# 用引擎内部 abs 不在 payload;用 period 时间近似
s0 = pd.Timestamp(cycles[0]["period"]["start_time"])
s1 = pd.Timestamp(cycles[1]["period"]["start_time"])
# C1 应完全在 C0 之前
assert e1 <= s0 or (e1 - s0).total_seconds() <= 3600
# 顶层 == cycles[0]
assert out["trading_range"]["start_time"] == cycles[0]["trading_range"]["start_time"]
assert out["trading_range"]["high"] == cycles[0]["trading_range"]["high"]
assert out["trading_range"]["low"] == cycles[0]["trading_range"]["low"]
def test_multi_cycle_nested_box_no_overlap():
"""Test B:大箱套小箱不得产出 overlap_ratio>=0.2 的两段。"""
rng = np.random.default_rng(5)
t0 = pd.Timestamp("2024-03-01", tz="UTC")
# 大箱 80 根
rows = _make_box_segment(t0, 80, 40.0, 60.0, 1, rng, 0)
df = pd.DataFrame(rows, columns=["date", "open", "high", "low", "close", "volume"])
trs = detect_trading_ranges(df, lookback=len(df), min_bars=20, max_cycles=8)
# 任意两段 overlap < 0.2
for i in range(len(trs)):
for j in range(i + 1, len(trs)):
r = _overlap_ratio(
int(trs[i]["abs_start_idx"]),
int(trs[i]["abs_end_idx"]),
int(trs[j]["abs_start_idx"]),
int(trs[j]["abs_end_idx"]),
)
assert r < 0.2, f"overlap {r} between {i} and {j}"
out = analyze_wyckoff(df, lookback=len(df), min_bars=20, max_cycles=8)
cycles = out.get("cycles") or []
assert len(cycles) >= 1
assert cycles[0]["status"] == "ACTIVE"
# 若有两段,时间窗也不应高度重叠
if len(cycles) >= 2:
# period 不重叠:历史 end <= active start(允许 1h 容差)
assert pd.Timestamp(cycles[1]["period"]["end_time"]) <= pd.Timestamp(
cycles[0]["period"]["start_time"]
) + pd.Timedelta(hours=2)
+124 -25
View File
@@ -5,6 +5,97 @@ from services import runtime as R
bp = Blueprint("analyze", __name__) bp = Blueprint("analyze", __name__)
_WYCKOFF_EMPTY = {
'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': {}},
'cycles': [],
'live': None,
'lifecycle': 'UNKNOWN',
}
def _localize_wyckoff_payload(w, client_tz):
"""把威科夫时间统一成客户端时区 ISO,便于与主图对齐。"""
if not w:
return w
def _loc_tr(tr):
if not tr:
return
tr['start_time'] = format_time_safely(tr.get('start_time'), client_tz) or tr.get('start_time')
tr['end_time'] = format_time_safely(tr.get('end_time'), client_tz) or tr.get('end_time')
def _loc_cycle(c):
if not c:
return
per = c.get('period') or {}
per['start_time'] = format_time_safely(per.get('start_time'), client_tz) or per.get('start_time')
per['end_time'] = format_time_safely(per.get('end_time'), client_tz) or per.get('end_time')
c['period'] = per
_loc_tr(c.get('trading_range'))
for ph in c.get('phases') or []:
ph['start_time'] = format_time_safely(ph.get('start_time'), client_tz) or ph.get('start_time')
ph['end_time'] = format_time_safely(ph.get('end_time'), client_tz) or ph.get('end_time')
for ev in c.get('events') or []:
ev['time'] = format_time_safely(ev.get('time'), client_tz) or ev.get('time')
_loc_tr(w.get('trading_range'))
for ph in w.get('phases') or []:
ph['start_time'] = format_time_safely(ph.get('start_time'), client_tz) or ph.get('start_time')
ph['end_time'] = format_time_safely(ph.get('end_time'), client_tz) or ph.get('end_time')
for ev in w.get('events') or []:
ev['time'] = format_time_safely(ev.get('time'), client_tz) or ev.get('time')
for c in w.get('cycles') or []:
_loc_cycle(c)
return w
def _compute_wyckoff_from_df(df, tf, vp_bins, client_tz=None, range_start_time=None, prefer_start_time=None):
"""直接用该周期已有 DataFrame(与缠论同一份)。
搜索窗口 = 整段数据;箱体在窗内评分选取(近优分取更长),
次/次次可用 prefer_start_time 对齐主箱起点。
"""
from chanlun.analysis.wyckoff import analyze_wyckoff
try:
if df is None or len(df) < 30:
empty = dict(_WYCKOFF_EMPTY)
empty['volume_profile'] = dict(_WYCKOFF_EMPTY['volume_profile'])
empty['volume_confirm'] = dict(_WYCKOFF_EMPTY['volume_confirm'])
empty['timeframe'] = tf
return empty
lookback = len(df)
min_bars = max(24, min(80, lookback // 12))
out = analyze_wyckoff(
df,
lookback=lookback,
vp_bins=vp_bins,
min_bars=min_bars,
range_start_time=range_start_time,
prefer_start_time=prefer_start_time,
)
out['timeframe'] = tf
out['lookback'] = lookback
out['min_bars'] = min_bars
if client_tz is not None:
_localize_wyckoff_payload(out, client_tz)
return out
except Exception as e:
print(f"Wyckoff 分析出错 ({tf}): {e}")
import traceback
traceback.print_exc()
empty = dict(_WYCKOFF_EMPTY)
empty['volume_profile'] = dict(_WYCKOFF_EMPTY['volume_profile'])
empty['volume_confirm'] = dict(_WYCKOFF_EMPTY['volume_confirm'])
empty['timeframe'] = tf
empty['error'] = str(e)
return empty
@bp.route('/api/analyze') @bp.route('/api/analyze')
def analyze(): def analyze():
"""分析接口""" """分析接口"""
@@ -25,6 +116,9 @@ def analyze():
# 获取分形元素时间周期与次次周期 # 获取分形元素时间周期与次次周期
element_timeframe = request.args.get('element_timeframe') element_timeframe = request.args.get('element_timeframe')
sub_sub_timeframe = request.args.get('sub_sub_timeframe') sub_sub_timeframe = request.args.get('sub_sub_timeframe')
# 供文末三周期威科夫复用(避免重复拉数)
element_df_for_wyckoff = None
sub_sub_df_for_wyckoff = None
# 获取是否只需要分形元素数据的参数 # 获取是否只需要分形元素数据的参数
elements_only_param = request.args.get('elements_only') elements_only_param = request.args.get('elements_only')
@@ -249,6 +343,7 @@ def analyze():
if element_df is not None and len(element_df) > 0: if element_df is not None and len(element_df) > 0:
# 添加小周期技术指标(包括布林带) # 添加小周期技术指标(包括布林带)
element_df = add_indicators(element_df) element_df = add_indicators(element_df)
element_df_for_wyckoff = element_df
# 对小周期数据进行缠论分析 # 对小周期数据进行缠论分析
element_analysis = analyze_chan(element_df, symbol, element_timeframe) element_analysis = analyze_chan(element_df, symbol, element_timeframe)
@@ -427,6 +522,7 @@ def analyze():
sub_sub_df = get_kl_data(symbol, sub_sub_timeframe, start_time=start_time, end_time=end_time) sub_sub_df = get_kl_data(symbol, sub_sub_timeframe, start_time=start_time, end_time=end_time)
if sub_sub_df is not None and len(sub_sub_df) > 0: if sub_sub_df is not None and len(sub_sub_df) > 0:
sub_sub_df = add_indicators(sub_sub_df) sub_sub_df = add_indicators(sub_sub_df)
sub_sub_df_for_wyckoff = sub_sub_df
sub_sub_analysis = analyze_chan(sub_sub_df, symbol, sub_sub_timeframe) sub_sub_analysis = analyze_chan(sub_sub_df, symbol, sub_sub_timeframe)
result['sub_sub_timeframe'] = sub_sub_timeframe result['sub_sub_timeframe'] = sub_sub_timeframe
result['sub_sub_kline_data'] = clean_dataframe_for_json(sub_sub_df).to_dict('records') result['sub_sub_kline_data'] = clean_dataframe_for_json(sub_sub_df).to_dict('records')
@@ -656,33 +752,36 @@ def analyze():
else: else:
result['structure_zones'] = [] result['structure_zones'] = []
# 威科夫分析 —— 按需:include_wyckoff=1,且须有主周期分析(非 elements_only # 威科夫:主 / 次 / 次次各算一份(非 elements_only;前端开关只控制绘制
include_wyckoff_param = request.args.get('include_wyckoff', '') # include_wyckoff=0 可显式跳过;缺省与其它真值均计算
include_wyckoff = str(include_wyckoff_param).lower() in ('1', 'true', 'yes') include_wyckoff_param = request.args.get('include_wyckoff', '1')
include_wyckoff = str(include_wyckoff_param).lower() not in ('0', 'false', 'no')
if include_wyckoff and not elements_only: if include_wyckoff and not elements_only:
try: # 主周期先算;次/次次只同步 active=cycles[0] 的 startWYCKOFF-MULTI-CYCLE-001
from chanlun.analysis.wyckoff import analyze_wyckoff wyckoff_bins = max(10, min(int(request.args.get('wyckoff_vp_bins', 24)), 24))
wyckoff_lookback = int(request.args.get('wyckoff_lookback', 120)) result['wyckoff'] = _compute_wyckoff_from_df(df, timeframe, wyckoff_bins, client_tz=None)
# ECR-004:默认/上限 24 binsA+C main_w = result.get('wyckoff') or {}
wyckoff_bins = int(request.args.get('wyckoff_vp_bins', 24)) cycles = main_w.get('cycles') or []
result['wyckoff'] = analyze_wyckoff( # active 唯一来源 cycles[0];禁止 cycles[-1]
df, active = cycles[0] if cycles else None
lookback=max(40, min(wyckoff_lookback, 500)), prefer_start = None
vp_bins=max(10, min(wyckoff_bins, 24)), if active:
prefer_start = ((active.get('trading_range') or {}).get('start_time')
or (active.get('period') or {}).get('start_time'))
elif main_w.get('trading_range'):
prefer_start = main_w['trading_range'].get('start_time')
if client_tz is not None:
_localize_wyckoff_payload(result['wyckoff'], client_tz)
if element_timeframe:
result['element_wyckoff'] = _compute_wyckoff_from_df(
element_df_for_wyckoff, element_timeframe, wyckoff_bins, client_tz,
prefer_start_time=prefer_start,
)
if sub_sub_timeframe:
result['sub_sub_wyckoff'] = _compute_wyckoff_from_df(
sub_sub_df_for_wyckoff, sub_sub_timeframe, wyckoff_bins, client_tz,
prefer_start_time=prefer_start,
) )
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)
+1 -1
View File
@@ -1,5 +1,5 @@
"""页面路由。""" """页面路由。"""
from flask import Blueprint, render_template, send_from_directory from flask import Blueprint, jsonify, render_template, request, send_from_directory
from config import DATA_SERVICE_URL, DATA_SERVICE_WS_URL from config import DATA_SERVICE_URL, DATA_SERVICE_WS_URL
from services.runtime import * # noqa: F403 from services.runtime import * # noqa: F403
from services import runtime as R from services import runtime as R
+25 -18
View File
@@ -70,36 +70,43 @@ def build_timeframe_labels(timeframes):
return labels return labels
def _adjacent_smaller(timeframe_keys, ceiling_tf):
"""取排序列表中严格小于 ceiling 的相邻周期。"""
if not timeframe_keys:
return ceiling_tf
try:
idx = timeframe_keys.index(ceiling_tf)
return timeframe_keys[idx - 1] if idx > 0 else timeframe_keys[0]
except ValueError:
return timeframe_keys[0]
def _prefer_smaller(candidates, labels_ordered, ceiling_tf, timeframe_keys):
"""从候选中选第一个存在且严格小于 ceiling 的周期,否则回退相邻更小。"""
ceil_m = timeframe_to_minutes(ceiling_tf)
for tf in candidates:
m = timeframe_to_minutes(tf)
if tf in labels_ordered and m is not None and ceil_m is not None and m < ceil_m:
return tf
return _adjacent_smaller(timeframe_keys, ceiling_tf)
def compute_timeframe_defaults(labels_ordered): def compute_timeframe_defaults(labels_ordered):
""" """
根据已排序的「周期 → 中文标签」映射,计算主 / 次 / 次次周期默认值。 根据已排序的「周期 → 中文标签」映射,计算主 / 次 / 次次周期默认值。
默认偏好:主 4h、次 2h、次次 1h(威科夫与结构在小时级更可读)。
labels_ordered: OrderedDict 或按插入顺序排列的 dict。 labels_ordered: OrderedDict 或按插入顺序排列的 dict。
""" """
if not labels_ordered: if not labels_ordered:
labels_ordered = DEFAULT_TIMEFRAME_LABELS.copy() labels_ordered = DEFAULT_TIMEFRAME_LABELS.copy()
timeframe_keys = list(labels_ordered.keys()) timeframe_keys = list(labels_ordered.keys())
preferred_main = next((tf for tf in ['5m', '15m', '1h'] if tf in labels_ordered), None) preferred_main = next((tf for tf in ['4h', '2h', '1h'] if tf in labels_ordered), None)
default_main = preferred_main or (timeframe_keys[0] if timeframe_keys else '1m') default_main = preferred_main or (timeframe_keys[0] if timeframe_keys else '1m')
if default_main not in labels_ordered and timeframe_keys: if default_main not in labels_ordered and timeframe_keys:
default_main = timeframe_keys[0] default_main = timeframe_keys[0]
if timeframe_keys: default_element = _prefer_smaller(['2h', '1h'], labels_ordered, default_main, timeframe_keys)
try: default_sub_sub = _prefer_smaller(['1h'], labels_ordered, default_element, timeframe_keys)
idx = timeframe_keys.index(default_main)
default_element = timeframe_keys[idx - 1] if idx > 0 else timeframe_keys[0]
except ValueError:
default_element = timeframe_keys[0]
else:
default_element = default_main
if timeframe_keys:
try:
idx_el = timeframe_keys.index(default_element)
default_sub_sub = timeframe_keys[idx_el - 1] if idx_el > 0 else timeframe_keys[0]
except ValueError:
default_sub_sub = timeframe_keys[0]
else:
default_sub_sub = default_element
return default_main, default_element, default_sub_sub, timeframe_keys return default_main, default_element, default_sub_sub, timeframe_keys
+3
View File
@@ -1,6 +1,9 @@
/* chart_format.js — split from chart.js */ /* chart_format.js — split from chart.js */
/* chart.js */ /* chart.js */
function updateChartDisplay() { function updateChartDisplay() {
if (typeof renderWyckoffCycleSummary === 'function') {
renderWyckoffCycleSummary();
}
if (currentData) { if (currentData) {
// 检测K线周期是否切换 // 检测K线周期是否切换
const curPeriod = $('#subSubPeriodKline').is(':checked') ? 'subsub' : const curPeriod = $('#subSubPeriodKline').is(':checked') ? 'subsub' :
+225 -147
View File
@@ -40,6 +40,12 @@ function disposeTradingViewCharts() {
var chartRoot = document.getElementById('tradingview_chart'); var chartRoot = document.getElementById('tradingview_chart');
if (chartRoot) { if (chartRoot) {
// 重建前救出 Cycle Summary,避免 innerHTML 清空时被销毁
var summaryEl = document.getElementById('wyckoffCycleSummary');
var chartHost = chartRoot.parentElement;
if (summaryEl && chartRoot.contains(summaryEl) && chartHost) {
chartHost.appendChild(summaryEl);
}
chartRoot.innerHTML = ''; chartRoot.innerHTML = '';
} }
} catch (e) { } catch (e) {
@@ -416,6 +422,21 @@ function initTradingView(symbol, timeframe) {
// 创建主图表 // 创建主图表
const mainChart = LightweightCharts.createChart(mainChartContainer, createChartOptions(true, 'main')); const mainChart = LightweightCharts.createChart(mainChartContainer, createChartOptions(true, 'main'));
// Cycle Summary 挂到主图左下角(相对 K 线主图 pane,而非整图底边)
(function mountWyckoffCycleSummary() {
var summaryEl = document.getElementById('wyckoffCycleSummary');
if (!summaryEl) {
summaryEl = document.createElement('div');
summaryEl.id = 'wyckoffCycleSummary';
summaryEl.className = 'wyckoff-cycle-summary';
summaryEl.setAttribute('aria-live', 'polite');
}
mainChartContainer.appendChild(summaryEl);
if (typeof renderWyckoffCycleSummary === 'function') {
try { renderWyckoffCycleSummary(); } catch (e) {}
}
})();
// 创建成交量图表 - 只显示底部的时间轴 // 创建成交量图表 - 只显示底部的时间轴
const volumeChart = LightweightCharts.createChart(volumeChartContainer, createChartOptions(false, 'volume')); const volumeChart = LightweightCharts.createChart(volumeChartContainer, createChartOptions(false, 'volume'));
@@ -2199,175 +2220,229 @@ function initTradingView(symbol, timeframe) {
} }
} catch (e) { console.error('结构区整体绘制出错:', e); } } catch (e) { console.error('结构区整体绘制出错:', e); }
} }
// 威科夫叠层:区间 / 阶段 / 事件 / VP // 区间/阶段/时间/VP:框线同中枢;标记并入主 K(同 BSP),时间对齐 candles
if ($('#showWyckoff').is(':checked') && currentData.wyckoff) { (function drawWrLikeChan() {
try { const candleSeries = tvWidget.series.candleSeries
const w = currentData.wyckoff; || tvWidget.series.barSeries
const tr = w.trading_range; || tvWidget.series.lineSeries
const parseTs = function(t) { || tvWidget.series.areaSeries
|| tvWidget.series.heikinSeries
|| tvWidget.series.renkoSeries;
const candleTimes = (candles || []).map(function(c) { return c.time; })
.filter(function(t) { return t != null && !isNaN(t); });
const barStep = (candleTimes.length >= 2)
? Math.max(1, candleTimes[1] - candleTimes[0])
: 3600;
const lastKlineTime = candleTimes.length ? candleTimes[candleTimes.length - 1] : NaN;
const chartStart = candleTimes.length ? candleTimes[0] : NaN;
const toSec = function(t) {
if (t == null) return NaN; if (t == null) return NaN;
if (typeof t === 'number') return Math.floor(t > 1e12 ? t / 1000 : t); if (typeof t === 'number') return t > 1e12 ? Math.floor(t / 1000) : Math.floor(t);
const ms = new Date(t).getTime(); const ms = new Date(t).getTime();
return isNaN(ms) ? NaN : Math.floor(ms / 1000); return isNaN(ms) ? NaN : Math.floor(ms / 1000);
}; };
const kd = currentData.kline_data || []; // 标记必须落在主 series 的 time 上(与 KLC 趋势 nearestTime 同思路)
const chartEnd = kd.length const snapToCandle = function(t) {
? Math.floor(new Date(kd[kd.length - 1].date).getTime() / 1000) if (!candleTimes.length || isNaN(t)) return t;
: NaN; let best = candleTimes[0], bd = Math.abs(candleTimes[0] - t);
for (let i = 1; i < candleTimes.length; i++) {
if ($('#showWyckoffRange').is(':checked') && tr) { const d = Math.abs(candleTimes[i] - t);
const t0 = parseTs(tr.start_time); if (d < bd) { bd = d; best = candleTimes[i]; }
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)';
// 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;
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 }) return best;
.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 = []; const ensureSpan = function(t0, t1) {
w.phases.forEach(function(ph) { if (isNaN(t0) || isNaN(t1)) return [t0, t1];
const t0 = parseTs(ph.start_time); if (t1 < t0) { const x = t0; t0 = t1; t1 = x; }
const t1 = ph.end_time ? parseTs(ph.end_time) : chartEnd; if (t1 <= t0) t1 = t0 + barStep;
if (isNaN(t0) || isNaN(t1) || !tr) return; return [t0, t1];
const hi = parseFloat(tr.high); };
if (isNaN(hi)) return; const drawBox = function(t0, t1, hi, lo, color) {
const col = phaseColors[ph.phase] || 'rgba(149,165,166,0.85)'; const span = ensureSpan(t0, t1);
// 阶段顶部分段色带(略高于区间高) t0 = span[0]; t1 = span[1];
const y = hi * 1.002; if (isNaN(t0) || isNaN(t1) || isNaN(hi) || isNaN(lo)) return;
mainChart.addLineSeries({ color: col, lineWidth: 3, lastValueVisible: false, priceLineVisible: false }) const opt = { color: color, lineWidth: 1, lastValueVisible: false, priceLineVisible: false };
.setData([{ time: t0, value: y }, { time: t1, value: y }]); mainChart.addLineSeries(opt).setData([{ time: t0, value: hi }, { time: t1, value: hi }]);
phaseMarkers.push({ mainChart.addLineSeries(opt).setData([{ time: t0, value: lo }, { time: t1, value: lo }]);
time: t0, mainChart.addLineSeries(opt).setData([{ time: t0, value: lo }, { time: t0, value: hi }]);
position: 'aboveBar', mainChart.addLineSeries(opt).setData([{ time: t1, value: lo }, { time: t1, value: hi }]);
color: col, };
shape: 'square', const mkPL = function(price, color, title, style) {
text: String(ph.phase || ph.label || ''), if (!candleSeries) return;
size: 1 const p = parseFloat(price);
if (isNaN(p)) return;
try {
candleSeries.createPriceLine({
price: p, color: color,
lineWidth: style === 2 ? 1 : 2,
lineStyle: style || 0,
axisLabelVisible: true,
title: title
}); });
}); } catch (e) { console.warn('价位线失败', title, e); }
if (phaseMarkers.length) { };
const phSeries = mainChart.addLineSeries({ lastValueVisible: false, priceLineVisible: false }); const phaseColors = { A: '#f1c40f', B: '#9b59b6', C: '#e67e22', D: '#2ecc71', E: '#3498db' };
phSeries.setMarkers(phaseMarkers);
}
}
if ($('#showWyckoffEvents').is(':checked') && w.events && w.events.length) {
const eventColors = { const eventColors = {
Spring: '#27ae60', Spring: '#27ae60', SOS: '#2ecc71', LPS: '#16a085',
SOS: '#2ecc71', UTAD: '#e74c3c', SOW: '#c0392b', LPSY: '#d35400'
LPS: '#16a085',
UTAD: '#e74c3c',
SOW: '#c0392b',
LPSY: '#d35400'
}; };
const checks = (w.volume_confirm && w.volume_confirm.event_checks) || {};
const markers = []; const wrMarkers = [];
w.events.forEach(function(ev) { window.wrMarkers = [];
const t = parseTs(ev.time); const pushMarker = function(m) {
if (!m || isNaN(m.time)) return;
m.time = snapToCandle(m.time);
if (!isNaN(chartStart) && (m.time < chartStart || m.time > lastKlineTime)) return;
wrMarkers.push(m);
};
const drawOne = function(w, cfg) {
if (!w) return;
const showR = $(cfg.rangeSel).is(':checked');
const showP = $(cfg.phasesSel).is(':checked');
const showE = $(cfg.eventsSel).is(':checked');
const showV = $(cfg.vpSel).is(':checked');
if (!showR && !showP && !showE && !showV) return;
// WYCKOFF-MULTI-CYCLE-001:遍历 cycles;无则退化为顶层单段
const cycles = (w.cycles && w.cycles.length)
? w.cycles
: (w.trading_range ? [{
id: 0, status: 'ACTIVE', role: 'latest',
trading_range: w.trading_range, phases: w.phases, events: w.events,
volume_profile: w.volume_profile, volume_confirm: w.volume_confirm,
period: { start_time: w.trading_range.start_time, end_time: w.trading_range.end_time, bars: w.trading_range.bars }
}] : []);
const tfLabel = (cfg.tfLabel || cfg.tag || 'TF').toString().toUpperCase();
cycles.forEach(function(cycle) {
const tr = cycle.trading_range;
if (!tr) return;
const cid = (cycle.id != null) ? cycle.id : 0;
const isActive = String(cycle.status || '').toUpperCase() === 'ACTIVE';
const cTag = tfLabel + ' C' + cid + ' ';
try {
let t0 = toSec(tr.start_time || (cycle.period && cycle.period.start_time));
let t1 = toSec(tr.end_time || (cycle.period && cycle.period.end_time));
// 仅 ACTIVE 可拉到最新 K;历史用 period.end
if (isActive && !isNaN(lastKlineTime)) {
t1 = lastKlineTime;
} else if (cycle.period && cycle.period.end_time) {
t1 = toSec(cycle.period.end_time);
}
const hi = parseFloat(tr.high), lo = parseFloat(tr.low);
if (showR && !isNaN(hi) && !isNaN(lo)) {
drawBox(t0, t1, hi, lo, cfg.color);
if (isActive) {
mkPL(hi, cfg.color, cfg.hiTag, 0);
mkPL(lo, cfg.color, cfg.loTag, 0);
mkPL(tr.mid, cfg.color, cfg.midTag, 2);
}
}
if (showP && cycle.phases && cycle.phases.length && !isNaN(hi)) {
cycle.phases.forEach(function(ph) {
let p0 = toSec(ph.start_time);
let p1 = ph.end_time ? toSec(ph.end_time) : t1;
const sp = ensureSpan(p0, p1);
p0 = sp[0]; p1 = sp[1];
if (isNaN(p0) || isNaN(p1)) return;
const col = phaseColors[ph.phase] || '#95a5a6';
mainChart.addLineSeries({
color: col, lineWidth: 3, lastValueVisible: false, priceLineVisible: false
}).setData([{ time: p0, value: hi }, { time: p1, value: hi }]);
pushMarker({
time: p0, position: 'aboveBar', color: col, shape: 'square',
text: cTag + 'Phase ' + String(ph.phase || ''), size: 1
});
});
}
if (showV && isActive && cycle.volume_profile) {
const vp = cycle.volume_profile;
mkPL(vp.poc, cfg.vpColor, cfg.pocTag, 0);
mkPL(vp.vah, cfg.vpColor, cfg.vahTag, 2);
mkPL(vp.val, cfg.vpColor, cfg.valTag, 2);
const bins = (vp.bins || []).filter(function(b) { return b && b.volume > 0; })
.slice().sort(function(a, b) { return b.volume - a.volume; }).slice(0, 8);
let maxVol = 0;
bins.forEach(function(b) { if (b.volume > maxVol) maxVol = b.volume; });
const span = (!isNaN(t0) && !isNaN(t1) && t1 > t0) ? (t1 - t0) : barStep * 12;
bins.forEach(function(b) {
if (!b.volume || maxVol <= 0 || isNaN(t1)) return;
const wSec = Math.max(barStep, Math.floor(span * 0.12 * (b.volume / maxVol)));
let leftT = Math.max(isNaN(t0) ? (t1 - wSec) : t0, t1 - wSec);
if (leftT >= t1) leftT = t1 - barStep;
if (leftT >= t1) return;
const alpha = 0.25 + 0.55 * (b.volume / maxVol);
mainChart.addLineSeries({
color: cfg.vpRgb.replace('ALPHA', alpha.toFixed(2)),
lineWidth: 2, lastValueVisible: false, priceLineVisible: false
}).setData([{ time: leftT, value: b.price }, { time: t1, value: b.price }]);
});
}
if (showE && cycle.events && cycle.events.length) {
const checks = (cycle.volume_confirm && cycle.volume_confirm.event_checks) || {};
cycle.events.forEach(function(ev) {
const t = toSec(ev.time);
if (isNaN(t)) return; if (isNaN(t)) return;
const typ = ev.type || ''; const typ = ev.type || '';
const chk = checks[typ] || {}; const chk = checks[typ] || {};
const volOk = (chk.volume_ok != null) ? chk.volume_ok : ev.volume_ok; 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 ok = volOk === true ? '✓' : (volOk === false ? '✗' : '');
const note = ev.note || ''; pushMarker({
const ratio = (ratioVal != null) ? (' vol×' + Number(ratioVal).toFixed(2)) : '';
markers.push({
time: t, time: t,
position: (typ === 'Spring' || typ === 'LPS' || typ === 'SOW') ? 'belowBar' : 'aboveBar', position: (typ === 'Spring' || typ === 'LPS' || typ === 'SOW') ? 'belowBar' : 'aboveBar',
color: eventColors[typ] || '#7f8c8d', color: eventColors[typ] || '#7f8c8d',
shape: 'arrowUp', shape: (typ === 'Spring' || typ === 'SOW' || typ === 'LPS') ? 'arrowDown' : 'arrowUp',
text: typ + (ok ? ' ' + ok : '') + (note ? ' ' + note : '') + ratio, text: cTag + typ + ok,
size: 1 size: 1
}); });
}); });
if (markers.length) {
const evSeries = mainChart.addLineSeries({ lastValueVisible: false, priceLineVisible: false });
evSeries.setMarkers(markers);
} }
} catch (e) { console.error('区间叠层出错', cfg.name, 'C' + cid, e); }
});
};
if ($('#showMainWrRange').is(':checked') || $('#showMainWrPhases').is(':checked')
|| $('#showMainWrEvents').is(':checked') || $('#showMainWrVP').is(':checked')) {
drawOne(currentData.wyckoff, {
name: '主', tag: '', tfLabel: (currentData.timeframe || '4H'), color: '#3498db', vpColor: '#8e44ad',
vpRgb: 'rgba(142, 68, 173, ALPHA)',
rangeSel: '#showMainWrRange', phasesSel: '#showMainWrPhases',
eventsSel: '#showMainWrEvents', vpSel: '#showMainWrVP',
hiTag: 'WR.H', loTag: 'WR.L', midTag: 'WR.M',
pocTag: 'POC', vahTag: 'VAH', valTag: 'VAL'
});
}
if ($('#showElementWrRange').is(':checked') || $('#showElementWrPhases').is(':checked')
|| $('#showElementWrEvents').is(':checked') || $('#showElementWrVP').is(':checked')) {
drawOne(currentData.element_wyckoff, {
name: '次', tag: 'e', tfLabel: (currentData.element_timeframe || '2H'), color: '#e67e22', vpColor: '#d35400',
vpRgb: 'rgba(211, 84, 0, ALPHA)',
rangeSel: '#showElementWrRange', phasesSel: '#showElementWrPhases',
eventsSel: '#showElementWrEvents', vpSel: '#showElementWrVP',
hiTag: 'eWR.H', loTag: 'eWR.L', midTag: 'eWR.M',
pocTag: 'ePOC', vahTag: 'eVAH', valTag: 'eVAL'
});
}
if ($('#showSubSubWrRange').is(':checked') || $('#showSubSubWrPhases').is(':checked')
|| $('#showSubSubWrEvents').is(':checked') || $('#showSubSubWrVP').is(':checked')) {
drawOne(currentData.sub_sub_wyckoff, {
name: '次次', tag: 's', tfLabel: (currentData.sub_sub_timeframe || '1H'), color: '#27ae60', vpColor: '#16a085',
vpRgb: 'rgba(22, 160, 133, ALPHA)',
rangeSel: '#showSubSubWrRange', phasesSel: '#showSubSubWrPhases',
eventsSel: '#showSubSubWrEvents', vpSel: '#showSubSubWrVP',
hiTag: 'sWR.H', loTag: 'sWR.L', midTag: 'sWR.M',
pocTag: 'sPOC', vahTag: 'sVAH', valTag: 'sVAL'
});
} }
if ($('#showWyckoffVP').is(':checked') && w.volume_profile && tr) { // 同 BSP:写入 window,稍后与分型/买卖点一并 setMarkers
const vp = w.volume_profile; wrMarkers.sort(function(a, b) { return a.time - b.time; });
const t1 = tr.end_time ? parseTs(tr.end_time) : chartEnd; window.wrMarkers = wrMarkers;
if (!isNaN(t1)) { if (typeof renderWyckoffCycleSummary === 'function') {
const bins = vp.bins || []; renderWyckoffCycleSummary();
// 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;
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.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: leftT, 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('绘制未完成中枢 - 已启用');
@@ -4223,7 +4298,8 @@ function initTradingView(symbol, timeframe) {
...(window.kluDivMarkersElement || []), ...(window.kluDivMarkersElement || []),
...(window.kluDivMarkersSubSub || []), ...(window.kluDivMarkersSubSub || []),
...trendMarkersToUse, ...trendMarkersToUse,
...(window.bspMarkers || []) ...(window.bspMarkers || []),
...(window.wrMarkers || [])
]; ];
if (combinedMarkers.length > 0) { if (combinedMarkers.length > 0) {
console.log( console.log(
@@ -4232,6 +4308,7 @@ function initTradingView(symbol, timeframe) {
'个,小周期分型:', allElementFxMarkers.length, '个,小周期分型:', allElementFxMarkers.length,
'个,UnitTF:', (window.unittfMarkers || []).length, '个,UnitTF:', (window.unittfMarkers || []).length,
'个,BSP标记:', (window.bspMarkers || []).length, '个,BSP标记:', (window.bspMarkers || []).length,
'个,区间标记:', (window.wrMarkers || []).length,
'个)' '个)'
); );
@@ -4358,7 +4435,8 @@ function initTradingView(symbol, timeframe) {
...(window.kluDivMarkersElement || []), ...(window.kluDivMarkersElement || []),
...(window.kluDivMarkersSubSub || []), ...(window.kluDivMarkersSubSub || []),
...trendMarkersToUse, ...trendMarkersToUse,
...(window.bspMarkers || []) ...(window.bspMarkers || []),
...(window.wrMarkers || [])
]; ];
if (onlyMainAndU.length > 0) { if (onlyMainAndU.length > 0) {
console.log('仅设置', onlyMainAndU.length, '个主周期/UnitTF标记(主周期分型:', (window.mainFxMarkers || []).length, 'UnitTF:', (window.unittfMarkers || []).length, ''); console.log('仅设置', onlyMainAndU.length, '个主周期/UnitTF标记(主周期分型:', (window.mainFxMarkers || []).length, 'UnitTF:', (window.unittfMarkers || []).length, '');
+6 -3
View File
@@ -13,7 +13,7 @@ function updateChart(options) {
symbol = $('#astockSymbol').val() || '000001'; symbol = $('#astockSymbol').val() || '000001';
} }
const timeframe = $('#timeframe').val() || window.DEFAULT_MAIN_TIMEFRAME || '5m'; const timeframe = $('#timeframe').val() || window.DEFAULT_MAIN_TIMEFRAME || '4h';
const timezone = $('#timezone').val() || 'Asia/Shanghai'; const timezone = $('#timezone').val() || 'Asia/Shanghai';
const elementTimeframe = $('#elementTimeframe').val() || window.DEFAULT_ELEMENT_TIMEFRAME || '1m'; const elementTimeframe = $('#elementTimeframe').val() || window.DEFAULT_ELEMENT_TIMEFRAME || '1m';
const subSubTimeframe = $('#subSubTimeframe').val() || ''; const subSubTimeframe = $('#subSubTimeframe').val() || '';
@@ -62,8 +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 // 威科夫随主分析一并返回;开关仅控制绘制,不再传 include_wyckoff
}, },
success: function(data) { success: function(data) {
// 隐藏加载图标 // 隐藏加载图标
@@ -81,6 +81,9 @@ function updateChart(options) {
delete currentData.original_macd; delete currentData.original_macd;
} }
currentData = data; currentData = data;
if (typeof renderWyckoffCycleSummary === 'function') {
renderWyckoffCycleSummary();
}
refreshChart(data, { refreshChart(data, {
incremental: options.incremental !== undefined incremental: options.incremental !== undefined
+8 -17
View File
@@ -93,25 +93,16 @@ $(document).on('change', '#showMainStructureZone', function() {
} }
}); });
// 威科夫主开关:勾选才请求;子项仅本地重绘 // 区间/阶段/时间/VP:与缠论笔开关一样,本地重绘
function syncWyckoffSubControls() { $(document).on(
const on = $('#showWyckoff').is(':checked'); 'change',
$('#showWyckoffRange, #showWyckoffPhases, #showWyckoffEvents, #showWyckoffVP').prop('disabled', !on); '#showMainWrRange, #showMainWrPhases, #showMainWrEvents, #showMainWrVP,' +
} '#showElementWrRange, #showElementWrPhases, #showElementWrEvents, #showElementWrVP,' +
$(document).on('change', '#showWyckoff', function() { '#showSubSubWrRange, #showSubSubWrPhases, #showSubSubWrEvents, #showSubSubWrVP',
const on = $('#showWyckoff').is(':checked'); function() {
syncWyckoffSubControls();
console.log('威科夫切换为:', on);
if (on) {
updateChart();
} else {
updateChartDisplay(); updateChartDisplay();
} }
}); );
$(document).on('change', '#showWyckoffRange, #showWyckoffPhases, #showWyckoffEvents, #showWyckoffVP', function() {
updateChartDisplay();
});
$(function() { syncWyckoffSubControls(); });
// 添加趋势显示复选框变更事件(主/元素),变更后刷新主图 // 添加趋势显示复选框变更事件(主/元素),变更后刷新主图
$('#showMainTrend').change(function() { $('#showMainTrend').change(function() {
+252 -3
View File
@@ -1,4 +1,252 @@
/* ui.js */ /* ui.js */
/** Trading OS 可消费的威科夫 Cycle 摘要(Confirmed + Live 分区;cycles[0]=ACTIVE */
function buildWyckoffCycleSummaryPayload(w, tf) {
if (!w) return null;
const cycles = (w.cycles && w.cycles.length)
? w.cycles
: (w.trading_range ? [{
id: 0, status: 'ACTIVE', role: 'latest', lifecycle: w.lifecycle || 'UNKNOWN',
trading_range: w.trading_range, bias: w.bias,
phases: w.phases || [], events: w.events || [],
confirmed: { phases: w.phases || [], events: w.events || [] },
live: w.live || null,
confidence: { overall: null },
period: {
start_time: w.trading_range.start_time,
end_time: w.trading_range.end_time,
bars: w.trading_range.bars
}
}] : []);
if (!cycles.length) return null;
const active = cycles[0]; // 禁止 cycles[-1]
const confirmed = active.confirmed || {
phases: active.phases || w.phases || [],
events: active.events || w.events || []
};
const live = active.live || w.live || null;
const cPhases = confirmed.phases || [];
const cEvents = confirmed.events || [];
const lastPhase = cPhases.length ? cPhases[cPhases.length - 1] : null;
const lastEvent = cEvents.length ? cEvents[cEvents.length - 1] : null;
const tr = active.trading_range || {};
const prev = cycles.length > 1 ? cycles[1] : null;
const biasLabel = ({
accumulation: 'Accumulation',
distribution: 'Distribution',
unknown: 'Unknown'
})[active.bias] || (active.bias || 'Unknown');
const liveCand = (live && live.event_candidates && live.event_candidates[0]) || null;
const liveConf = live && live.confidence ? live.confidence.overall : null;
return {
symbol: (typeof currentData !== 'undefined' && currentData && currentData.symbol) || $('#symbol').val() || '',
timeframe: (tf || w.timeframe || $('#timeframe').val() || '').toString().toUpperCase(),
active: {
cycle_id: active.id != null ? active.id : 0,
status: active.status || 'ACTIVE',
lifecycle: active.lifecycle || (live && live.lifecycle) || 'UNKNOWN',
structure: biasLabel,
phase_confirmed: lastPhase ? String(lastPhase.phase || '') : null,
event_confirmed: lastEvent ? String(lastEvent.type || '') : null,
phase_candidate: live ? live.phase_candidate : null,
event_candidate: liveCand ? liveCand.type : null,
event_candidate_confidence: liveCand ? liveCand.confidence : null,
next_expected: live ? live.next_expected : null,
range: {
low: tr.low,
high: tr.high,
start_time: (active.period && active.period.start_time) || tr.start_time,
end_time: (active.period && active.period.end_time) || tr.end_time,
bars: (active.period && active.period.bars) != null ? active.period.bars : tr.bars
},
confidence_confirmed: (active.confidence && active.confidence.overall != null)
? active.confidence.overall
: null,
confidence_live: liveConf
},
confirmed_history: cycles.slice(1, 4).map(function(c) {
const evs = ((c.confirmed && c.confirmed.events) || c.events || [])
.map(function(e) { return e.type; }).filter(Boolean);
return {
cycle_id: c.id,
structure: ({
accumulation: 'Accumulation',
distribution: 'Distribution',
unknown: 'Unknown'
})[c.bias] || c.bias,
events: evs,
lifecycle: c.lifecycle || 'COMPLETED'
};
}),
live: live,
cycle_count: cycles.length
};
}
function _wrLayerTogglesOn(prefix) {
// prefix: Main | Element | SubSub
return $('#show' + prefix + 'WrRange').is(':checked')
|| $('#show' + prefix + 'WrPhases').is(':checked')
|| $('#show' + prefix + 'WrEvents').is(':checked')
|| $('#show' + prefix + 'WrVP').is(':checked');
}
/** 面板展示用中文(机器可读 payload 仍保留英文原值) */
function _wcsLifecycleZh(v) {
return ({
UNKNOWN: '未知',
FORMING: '形成中',
CONFIRMED: '已确认',
COMPLETED: '已完成',
ACTIVE: '当前'
})[v] || v || '未知';
}
function _wcsStructureZh(v) {
if (!v) return '—';
const key = String(v).toLowerCase();
return ({
accumulation: '吸筹',
distribution: '派发',
unknown: '未知'
})[key] || ({
Accumulation: '吸筹',
Distribution: '派发',
Unknown: '未知'
})[v] || v;
}
function _wcsEventZh(v) {
if (v == null || v === '') return '—';
return ({
Spring: '弹簧',
UTAD: '上升后派发',
SOS: '强势信号',
SOW: '弱势信号',
LPS: '最后支撑',
LPSY: '最后供应',
Test: '回测',
PSY: '初步供应',
BC: '买气高潮',
AR: '自动回落',
ST: '二次测试',
SC: '卖气高潮'
})[v] || v;
}
function _htmlWyckoffSummaryBlock(payload, blockClass) {
if (!payload || !payload.active) return '';
const a = payload.active;
const fmtPx = function(v) {
if (v == null || isNaN(Number(v))) return '—';
const n = Number(v);
return n >= 1000 ? n.toFixed(1) : n.toFixed(4);
};
const pct = function(v) {
if (v == null || isNaN(Number(v))) return '—';
return Math.round(Number(v) * 100) + '%';
};
let html = '<div class="wcs-block ' + (blockClass || '') + '">';
html += '<div class="wcs-title">' + (payload.symbol || '') + ' '
+ (payload.timeframe || '') + '</div>';
html += '<div><span class="wcs-badge">当前 C' + a.cycle_id + '</span> '
+ '<span class="wcs-badge" style="background:#fff8c5;color:#9a6700;">'
+ _wcsLifecycleZh(a.lifecycle) + '</span></div>';
html += '<div class="wcs-active">';
html += '<div class="wcs-row"><span class="wcs-k">结构</span><span class="wcs-v">'
+ _wcsStructureZh(a.structure) + '</span></div>';
html += '<div class="wcs-row"><span class="wcs-k">阶段</span><span class="wcs-v">'
+ (a.phase_candidate
? ('阶段 ' + a.phase_candidate + '(候选)')
: (a.phase_confirmed ? ('阶段 ' + a.phase_confirmed) : '—'))
+ '</span></div>';
html += '<div class="wcs-row"><span class="wcs-k">事件</span><span class="wcs-v">'
+ (a.event_candidate
? (_wcsEventZh(a.event_candidate) + '(候选)')
: _wcsEventZh(a.event_confirmed))
+ '</span></div>';
if (a.event_confirmed && a.event_candidate) {
html += '<div class="wcs-row"><span class="wcs-k">已确认</span><span class="wcs-v">'
+ _wcsEventZh(a.event_confirmed) + '</span></div>';
}
html += '<div class="wcs-row"><span class="wcs-k">区间</span><span class="wcs-v">'
+ fmtPx(a.range && a.range.low) + ' ' + fmtPx(a.range && a.range.high) + '</span></div>';
html += '<div class="wcs-row"><span class="wcs-k">置信度</span><span class="wcs-v">'
+ pct(a.confidence_live != null ? a.confidence_live : a.confidence_confirmed) + '</span></div>';
if (a.next_expected) {
html += '<div class="wcs-row"><span class="wcs-k">下一步</span><span class="wcs-v">'
+ _wcsEventZh(a.next_expected) + '</span></div>';
}
html += '</div>';
if (payload.confirmed_history && payload.confirmed_history.length) {
html += '<div class="wcs-prev"><div style="margin-bottom:2px;">已确认历史</div>';
payload.confirmed_history.forEach(function(h) {
const ev = (h.events && h.events.length)
? h.events.map(_wcsEventZh).join('、')
: '—';
html += '<div>C' + h.cycle_id + ' ' + _wcsStructureZh(h.structure) + ' · ' + ev + '</div>';
});
html += '</div>';
}
html += '</div>';
return html;
}
function renderWyckoffCycleSummary() {
const $el = $('#wyckoffCycleSummary');
if (!$el.length) return;
if (!currentData) {
$el.hide().empty();
window.wyckoffCycleSummary = null;
return;
}
const layers = [];
if (_wrLayerTogglesOn('Main') && currentData.wyckoff) {
layers.push({
key: 'main',
cls: 'wcs-main',
payload: buildWyckoffCycleSummaryPayload(
currentData.wyckoff,
currentData.timeframe || currentData.wyckoff.timeframe || $('#timeframe').val()
)
});
}
if (_wrLayerTogglesOn('Element') && currentData.element_wyckoff) {
layers.push({
key: 'element',
cls: 'wcs-element',
payload: buildWyckoffCycleSummaryPayload(
currentData.element_wyckoff,
currentData.element_timeframe || currentData.element_wyckoff.timeframe || $('#elementTimeframe').val()
)
});
}
if (_wrLayerTogglesOn('SubSub') && currentData.sub_sub_wyckoff) {
layers.push({
key: 'sub_sub',
cls: 'wcs-subsub',
payload: buildWyckoffCycleSummaryPayload(
currentData.sub_sub_wyckoff,
currentData.sub_sub_timeframe || currentData.sub_sub_wyckoff.timeframe || $('#subSubTimeframe').val()
)
});
}
const valid = layers.filter(function(L) { return L.payload && L.payload.active; });
if (!valid.length) {
$el.hide().empty();
window.wyckoffCycleSummary = null;
return;
}
const bag = {};
let html = '';
valid.forEach(function(L) {
bag[L.key] = L.payload;
html += _htmlWyckoffSummaryBlock(L.payload, L.cls);
});
window.wyckoffCycleSummary = bag;
$el.html(html).show();
}
function loadSymbols() { function loadSymbols() {
$.get('/api/symbols', function(data) { $.get('/api/symbols', function(data) {
if (Array.isArray(data)) { if (Array.isArray(data)) {
@@ -24,14 +272,15 @@ function loadSymbols() {
}); });
} }
// 设置默认时间范围 // 设置默认时间范围(需覆盖威科夫 lookback;1 天在 4h/1h 上几乎检不出区间)
function setDefaultTimeRange() { function setDefaultTimeRange() {
const now = new Date(); const now = new Date();
const oneDayAgo = new Date(now.getTime() - (24 * 60 * 60 * 1000)); const daysBack = 14;
const start = new Date(now.getTime() - (daysBack * 24 * 60 * 60 * 1000));
// 格式化为datetime-local输入框所需的格式 YYYY-MM-DDThh:mm // 格式化为datetime-local输入框所需的格式 YYYY-MM-DDThh:mm
$('#end_time').val(formatDatetimeLocal(now)); $('#end_time').val(formatDatetimeLocal(now));
$('#start_time').val(formatDatetimeLocal(oneDayAgo)); $('#start_time').val(formatDatetimeLocal(start));
} }
// 格式化日期为datetime-local输入框格式 // 格式化日期为datetime-local输入框格式
function formatDatetimeLocal(date) { function formatDatetimeLocal(date) {
+131 -27
View File
@@ -96,6 +96,81 @@
position: relative; position: relative;
z-index: 1; /* 确保图表在数据面板之上 */ z-index: 1; /* 确保图表在数据面板之上 */
} }
/* 挂在主图容器内:左下角 = K线主图左下,而非整图(含副图)底边 */
.wyckoff-cycle-summary {
position: absolute;
left: 8px;
bottom: 28px; /* 略抬高,避开主图时间轴 */
top: auto;
right: auto;
z-index: 1100;
min-width: 200px;
max-width: 300px;
max-height: calc(100% - 36px);
overflow-y: auto;
padding: 8px 10px;
background: rgba(255, 255, 255, 0.94);
border: 1px solid #d0d7de;
border-radius: 6px;
box-shadow: 0 2px 10px rgba(0,0,0,0.08);
font-size: 12px;
line-height: 1.45;
color: #24292f;
display: none;
pointer-events: auto;
}
.wyckoff-cycle-summary .wcs-block {
padding: 6px 0;
}
.wyckoff-cycle-summary .wcs-block + .wcs-block {
border-top: 1px solid #eaeef2;
margin-top: 6px;
padding-top: 8px;
}
.wyckoff-cycle-summary .wcs-block.wcs-main { border-left: 3px solid #3498db; padding-left: 8px; }
.wyckoff-cycle-summary .wcs-block.wcs-element { border-left: 3px solid #e67e22; padding-left: 8px; }
.wyckoff-cycle-summary .wcs-block.wcs-subsub { border-left: 3px solid #27ae60; padding-left: 8px; }
.wyckoff-cycle-summary .wcs-title {
font-weight: 650;
font-size: 13px;
margin-bottom: 6px;
letter-spacing: 0.02em;
}
.wyckoff-cycle-summary .wcs-row {
display: flex;
justify-content: space-between;
gap: 8px;
margin: 2px 0;
}
.wyckoff-cycle-summary .wcs-k {
color: #656d76;
flex-shrink: 0;
}
.wyckoff-cycle-summary .wcs-v {
text-align: right;
font-variant-numeric: tabular-nums;
}
.wyckoff-cycle-summary .wcs-active {
margin-top: 2px;
padding: 6px 0 4px;
border-top: 1px solid #eaeef2;
}
.wyckoff-cycle-summary .wcs-prev {
margin-top: 6px;
padding-top: 6px;
border-top: 1px dashed #eaeef2;
color: #656d76;
font-size: 11px;
}
.wyckoff-cycle-summary .wcs-badge {
display: inline-block;
padding: 1px 6px;
border-radius: 3px;
background: #ddf4ff;
color: #0969da;
font-weight: 600;
font-size: 11px;
}
.chart-options { .chart-options {
position: absolute; position: absolute;
top: 10px; top: 10px;
@@ -972,26 +1047,6 @@
<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>
@@ -1037,6 +1092,22 @@
<input class="form-check-input" type="checkbox" id="showMainBsp"> <input class="form-check-input" type="checkbox" id="showMainBsp">
<label class="form-check-label" for="showMainBsp">买卖点</label> <label class="form-check-label" for="showMainBsp">买卖点</label>
</div> </div>
<div class="form-check form-check-inline ms-2">
<input class="form-check-input" type="checkbox" id="showMainWrRange">
<label class="form-check-label" for="showMainWrRange">区间</label>
</div>
<div class="form-check form-check-inline">
<input class="form-check-input" type="checkbox" id="showMainWrPhases">
<label class="form-check-label" for="showMainWrPhases">阶段</label>
</div>
<div class="form-check form-check-inline">
<input class="form-check-input" type="checkbox" id="showMainWrEvents">
<label class="form-check-label" for="showMainWrEvents">时间</label>
</div>
<div class="form-check form-check-inline">
<input class="form-check-input" type="checkbox" id="showMainWrVP">
<label class="form-check-label" for="showMainWrVP">VP</label>
</div>
</div> </div>
<div class="d-flex align-items-center mt-1"> <div class="d-flex align-items-center mt-1">
<label class="form-label me-0 mb-0">次周期:</label> <label class="form-label me-0 mb-0">次周期:</label>
@@ -1079,6 +1150,22 @@
<input class="form-check-input" type="checkbox" id="showElementBsp"> <input class="form-check-input" type="checkbox" id="showElementBsp">
<label class="form-check-label" for="showElementBsp">买卖点</label> <label class="form-check-label" for="showElementBsp">买卖点</label>
</div> </div>
<div class="form-check form-check-inline ms-2">
<input class="form-check-input" type="checkbox" id="showElementWrRange">
<label class="form-check-label" for="showElementWrRange">区间</label>
</div>
<div class="form-check form-check-inline">
<input class="form-check-input" type="checkbox" id="showElementWrPhases">
<label class="form-check-label" for="showElementWrPhases">阶段</label>
</div>
<div class="form-check form-check-inline">
<input class="form-check-input" type="checkbox" id="showElementWrEvents">
<label class="form-check-label" for="showElementWrEvents">时间</label>
</div>
<div class="form-check form-check-inline">
<input class="form-check-input" type="checkbox" id="showElementWrVP">
<label class="form-check-label" for="showElementWrVP">VP</label>
</div>
</div> </div>
<div class="d-flex align-items-center mt-1"> <div class="d-flex align-items-center mt-1">
<label class="form-label me-0 mb-0">次次周期:</label> <label class="form-label me-0 mb-0">次次周期:</label>
@@ -1121,6 +1208,22 @@
<input class="form-check-input" type="checkbox" id="showSubSubBsp"> <input class="form-check-input" type="checkbox" id="showSubSubBsp">
<label class="form-check-label" for="showSubSubBsp">买卖点</label> <label class="form-check-label" for="showSubSubBsp">买卖点</label>
</div> </div>
<div class="form-check form-check-inline ms-2">
<input class="form-check-input" type="checkbox" id="showSubSubWrRange">
<label class="form-check-label" for="showSubSubWrRange">区间</label>
</div>
<div class="form-check form-check-inline">
<input class="form-check-input" type="checkbox" id="showSubSubWrPhases">
<label class="form-check-label" for="showSubSubWrPhases">阶段</label>
</div>
<div class="form-check form-check-inline">
<input class="form-check-input" type="checkbox" id="showSubSubWrEvents">
<label class="form-check-label" for="showSubSubWrEvents">时间</label>
</div>
<div class="form-check form-check-inline">
<input class="form-check-input" type="checkbox" id="showSubSubWrVP">
<label class="form-check-label" for="showSubSubWrVP">VP</label>
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -1130,6 +1233,7 @@
<div class="chart-container"> <div class="chart-container">
<div id="tradingview_chart"></div> <div id="tradingview_chart"></div>
<div id="wyckoffCycleSummary" class="wyckoff-cycle-summary" aria-live="polite"></div>
<!-- 技术指标下拉菜单 --> <!-- 技术指标下拉菜单 -->
<div class="indicator-dropdown dropdown"> <div class="indicator-dropdown dropdown">
<button class="add-indicator-btn dropdown-toggle" type="button" id="indicatorDropdown" data-bs-toggle="dropdown" aria-expanded="false"> <button class="add-indicator-btn dropdown-toggle" type="button" id="indicatorDropdown" data-bs-toggle="dropdown" aria-expanded="false">
@@ -1282,13 +1386,13 @@
<script defer src="{{ url_for('static', filename='js/app/api_client.js') }}"></script> <script defer src="{{ url_for('static', filename='js/app/api_client.js') }}"></script>
<script defer src="{{ url_for('static', filename='js/app/state.js') }}"></script> <script defer src="{{ url_for('static', filename='js/app/state.js') }}"></script>
<script defer src="{{ url_for('static', filename='js/app/trend.js') }}"></script> <script defer src="{{ url_for('static', filename='js/app/trend.js') }}"></script>
<script defer src="{{ url_for('static', filename='js/app/macd_ui.js') }}"></script> <script defer src="{{ url_for('static', filename='js/app/macd_ui.js') }}?v=20260807e"></script>
<script defer src="{{ url_for('static', filename='js/app/chart_format.js') }}"></script> <script defer src="{{ url_for('static', filename='js/app/chart_format.js') }}?v=20260807e"></script>
<script defer src="{{ url_for('static', filename='js/app/chart_view.js') }}?v=20260806a"></script> <script defer src="{{ url_for('static', filename='js/app/chart_view.js') }}?v=20260807e"></script>
<script defer src="{{ url_for('static', filename='js/app/chart_tv.js') }}?v=20260806a"></script> <script defer src="{{ url_for('static', filename='js/app/chart_tv.js') }}?v=20260807e"></script>
<script defer src="{{ url_for('static', filename='js/app/chart_sync.js') }}?v=20260806a"></script> <script defer src="{{ url_for('static', filename='js/app/chart_sync.js') }}?v=20260807e"></script>
<script defer src="{{ url_for('static', filename='js/app/chart_tables.js') }}"></script> <script defer src="{{ url_for('static', filename='js/app/chart_tables.js') }}?v=20260807e"></script>
<script defer src="{{ url_for('static', filename='js/app/ui.js') }}?v=20260806a"></script> <script defer src="{{ url_for('static', filename='js/app/ui.js') }}?v=20260807e"></script>
<script defer src="{{ url_for('static', filename='js/app/overlays.js') }}"></script> <script defer src="{{ url_for('static', filename='js/app/overlays.js') }}"></script>
<script defer src="{{ url_for('static', filename='js/app/main.js') }}"></script> <script defer src="{{ url_for('static', filename='js/app/main.js') }}"></script>
+40 -9
View File
@@ -127,11 +127,13 @@ 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 assert "wyckoff" in payload
for k in WYCKOFF_KEYS:
assert k in payload["wyckoff"], f"missing wyckoff key: {k}"
def test_analyze_http_wyckoff_opt_in(): def test_analyze_http_wyckoff_can_opt_out():
"""include_wyckoff=1响应含 wyckoff 约定键;默认不返回""" """include_wyckoff=0可显式跳过威科夫"""
from app import app from app import app
from services.runtime import add_indicators from services.runtime import add_indicators
@@ -148,19 +150,49 @@ def test_analyze_http_wyckoff_opt_in():
"symbol": "BTC/USDT:USDT", "symbol": "BTC/USDT:USDT",
"timeframe": "5m", "timeframe": "5m",
"timezone": "Asia/Shanghai", "timezone": "Asia/Shanghai",
"include_wyckoff": 1, "include_wyckoff": 0,
}, },
) )
assert resp.status_code == 200, resp.data[:500] assert resp.status_code == 200, resp.data[:500]
payload = resp.get_json() payload = resp.get_json()
assert payload is not None and "wyckoff" in payload assert payload is not None and "wyckoff" not in payload
w = payload["wyckoff"]
def test_analyze_http_wyckoff_for_three_timeframes():
"""主/次/次次均返回各自 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": "4h",
"element_timeframe": "2h",
"sub_sub_timeframe": "1h",
"timezone": "Asia/Shanghai",
},
)
assert resp.status_code == 200, resp.data[:500]
payload = resp.get_json()
assert payload is not None and "error" not in payload
assert "wyckoff" in payload
assert "element_wyckoff" in payload
assert "sub_sub_wyckoff" in payload
for key in ("wyckoff", "element_wyckoff", "sub_sub_wyckoff"):
for k in WYCKOFF_KEYS: for k in WYCKOFF_KEYS:
assert k in w, f"missing wyckoff key: {k}" assert k in payload[key], f"missing {k} in {key}"
def test_analyze_http_wyckoff_skipped_when_elements_only(): def test_analyze_http_wyckoff_skipped_when_elements_only():
"""elements_only=true 时即使 include_wyckoff=1 也不返回 wyckoff。""" """elements_only=true 时不返回 wyckoff。"""
from app import app from app import app
from services.runtime import add_indicators from services.runtime import add_indicators
@@ -179,7 +211,6 @@ def test_analyze_http_wyckoff_skipped_when_elements_only():
"element_timeframe": "1m", "element_timeframe": "1m",
"timezone": "Asia/Shanghai", "timezone": "Asia/Shanghai",
"elements_only": "true", "elements_only": "true",
"include_wyckoff": 1,
}, },
) )
assert resp.status_code == 200, resp.data[:500] assert resp.status_code == 200, resp.data[:500]