From 276481e02ce26aad1c4ae75b78e9310dbdb0b2bc Mon Sep 17 00:00:00 2001 From: jackyu66git Date: Fri, 7 Aug 2026 03:14:19 +0800 Subject: [PATCH] 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 --- chanlun/analysis/wyckoff/__init__.py | 5 +- chanlun/analysis/wyckoff/engine.py | 174 +++++-- chanlun/analysis/wyckoff/events.py | 192 ++++++-- chanlun/analysis/wyckoff/live.py | 258 +++++++++++ chanlun/analysis/wyckoff/range.py | 428 +++++++++++++++--- docs/AGENT_MEMORY.md | 1 + .../BD-2026-007-wyckoff-live-structure.md | 70 +++ docs/CHANGELOG/CHANGELOG.md | 10 + docs/ECR/ECR-007-wyckoff-live-structure.md | 60 +++ .../ECR-007-wyckoff-live-structure.md | 26 ++ docs/HANDOFF/ECR-007-architect-to-engineer.md | 21 + docs/HANDOFF/ECR-007-code-review.md | 27 ++ docs/HANDOFF/ECR-007-engineer-to-reviewer.md | 14 + docs/STATE/ECR-007.md | 7 + docs/TEST_REPORT/ECR-007.md | 32 ++ docs/TRACEABILITY.md | 9 + docs/notes/WYCKOFF-LIVE-STRUCTURE-001.md | 72 +++ docs/notes/WYCKOFF-LIVE-VALIDATION-001.md | 76 ++++ docs/notes/WYCKOFF-MULTI-CYCLE-001.md | 62 +++ tests/fixtures/analyze_contract_keys.json | 12 +- tests/generate_golden.py | 3 +- tests/test_golden_pipeline.py | 4 +- tests/test_wyckoff.py | 260 ++++++++++- web/api/analyze.py | 149 +++++- web/api/pages.py | 2 +- web/services/runtime/timeframes.py | 43 +- web/static/js/app/chart_format.js | 3 + web/static/js/app/chart_tv.js | 410 ++++++++++------- web/static/js/app/chart_view.js | 9 +- web/static/js/app/macd_ui.js | 25 +- web/static/js/app/ui.js | 255 ++++++++++- web/templates/index.html | 158 +++++-- web/tests/test_analyze_contract.py | 51 ++- 33 files changed, 2527 insertions(+), 401 deletions(-) create mode 100644 chanlun/analysis/wyckoff/live.py create mode 100644 docs/BACKEND_DESIGN/BD-2026-007-wyckoff-live-structure.md create mode 100644 docs/ECR/ECR-007-wyckoff-live-structure.md create mode 100644 docs/ENGINEERING_SPEC/ECR-007-wyckoff-live-structure.md create mode 100644 docs/HANDOFF/ECR-007-architect-to-engineer.md create mode 100644 docs/HANDOFF/ECR-007-code-review.md create mode 100644 docs/HANDOFF/ECR-007-engineer-to-reviewer.md create mode 100644 docs/STATE/ECR-007.md create mode 100644 docs/TEST_REPORT/ECR-007.md create mode 100644 docs/notes/WYCKOFF-LIVE-STRUCTURE-001.md create mode 100644 docs/notes/WYCKOFF-LIVE-VALIDATION-001.md create mode 100644 docs/notes/WYCKOFF-MULTI-CYCLE-001.md diff --git a/chanlun/analysis/wyckoff/__init__.py b/chanlun/analysis/wyckoff/__init__.py index 545f61a..82b5922 100644 --- a/chanlun/analysis/wyckoff/__init__.py +++ b/chanlun/analysis/wyckoff/__init__.py @@ -1,6 +1,7 @@ -"""威科夫分析(启发式):交易区间 / 阶段 / 事件 / Volume Profile。""" +"""威科夫分析(启发式):交易区间 / 阶段 / 事件 / Volume Profile / Live。""" from __future__ import annotations from .engine import analyze_wyckoff +from .live import execution_signal_from_wyckoff -__all__ = ["analyze_wyckoff"] +__all__ = ["analyze_wyckoff", "execution_signal_from_wyckoff"] diff --git a/chanlun/analysis/wyckoff/engine.py b/chanlun/analysis/wyckoff/engine.py index ce45e5e..e9c1da9 100644 --- a/chanlun/analysis/wyckoff/engine.py +++ b/chanlun/analysis/wyckoff/engine.py @@ -1,12 +1,18 @@ -"""威科夫分析入口。""" +"""威科夫分析入口:Cycle → Phase → Event → VP + Live(MULTI-CYCLE / LIVE-STRUCTURE)。 + +range.py 只产 TradingRange;Confirmed 走 events.py;Live 走 live.py。 +cycles[0]=ACTIVE;禁止 cycles[-1] 取 active。 +Execution 只消费 Confirmed(见 live.execution_signal_from_wyckoff)。 +""" from __future__ import annotations -from typing import Any, Dict, Optional +from typing import Any, Dict, List, Optional import pandas as pd 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 @@ -21,31 +27,55 @@ def _fmt_time(v) -> Optional[str]: return str(v) -def analyze_wyckoff(df: pd.DataFrame, lookback: int = 120, vp_bins: int = 50) -> Dict[str, Any]: - """ - 对主周期 OHLCV DataFrame 做威科夫启发式分析。 - 需要列: open, high, low, close, volume;建议有 date 或 timestamp。 - """ - empty = { +def _empty(vp_bins: int) -> Dict[str, Any]: + return { + "cycles": [], "trading_range": None, "bias": "unknown", "phases": [], "events": [], "volume_profile": {"bins": [], "poc": None, "vah": None, "val": None, "bin_count": vp_bins}, "volume_confirm": {"avg_volume": 0.0, "event_checks": {}}, + "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) phases = build_phases(work, tr, bias, events) 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"]), bin_count=vp_bins, ) - - trading_range = { - "start_time": _fmt_time(tr.get("start_time")), - "end_time": _fmt_time(tr.get("end_time")), - "high": float(tr["high"]), - "low": float(tr["low"]), - "mid": float(tr["mid"]), - "active": bool(tr.get("active", True)), - "bars": int(tr.get("bars", 0)), - } for ev in events: ev["time"] = _fmt_time(ev.get("time")) for ph in phases: ph["start_time"] = _fmt_time(ph.get("start_time")) ph["end_time"] = _fmt_time(ph.get("end_time")) + 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 { + "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, "bias": bias, + # 兼容旧读法:顶层 phases/events = confirmed "phases": phases, "events": events, + "confirmed": { + "phases": phases, + "events": events, + "volume_confirm": volume_confirm, + }, + "live": live, "volume_profile": vp, "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"), + } diff --git a/chanlun/analysis/wyckoff/events.py b/chanlun/analysis/wyckoff/events.py index 615bb70..5cd4b50 100644 --- a/chanlun/analysis/wyckoff/events.py +++ b/chanlun/analysis/wyckoff/events.py @@ -1,7 +1,7 @@ """威科夫阶段与事件(启发式)。""" from __future__ import annotations -from typing import Any, Dict, List, Tuple +from typing import Any, Dict, List, Optional, Tuple import numpy as np import pandas as pd @@ -29,6 +29,9 @@ def detect_bias_and_events( ) -> Tuple[str, List[Dict[str, Any]], Dict[str, Any]]: """ 返回 bias、events、volume_confirm。 + + Spring/UTAD 相对「结构高低」判定:取区间内次低/次高(剔除单根极值), + 避免箱体把假破低点吃进 lo 后永远刺不破、从而无 C 阶段。 """ hi = float(tr["high"]) lo = float(tr["low"]) @@ -38,6 +41,24 @@ def detect_bias_and_events( e = int(tr["abs_end_idx"]) 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) 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)) @@ -57,8 +78,8 @@ def detect_bias_and_events( avg_v = _avg_vol(df, i) ratio = vol / avg_v if avg_v else 0.0 - # Spring: pierce below low then close back above low - if spring is None and low < lo - tol * 0.5 and close >= lo - tol * 0.2: + # Spring: pierce below structural support then close back + 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) spring = { "type": "Spring", @@ -70,8 +91,8 @@ def detect_bias_and_events( "idx": i, } - # UTAD: pierce above high then close back below - if utad is None and high > hi + tol * 0.5 and close <= hi + tol * 0.2: + # UTAD: pierce above structural resistance then close back + if utad is None and high > event_hi + tol * 0.35 and close <= event_hi + tol * 0.35: vol_ok = ratio >= 0.8 utad = { "type": "UTAD", @@ -154,11 +175,15 @@ def detect_bias_and_events( } break + # 冲突清理:已判定吸筹且有 SOS 时,丢弃更早的 UTAD(避免阶段/图面误导) + # 派发且有 SOW 时,丢弃更晚才合理的 Spring 假信号同理在偏置后再滤 + keep = [] for ev in (spring, sos, lps, utad, sod, lpsy): - if ev: - events.append({k: v for k, v in ev.items() if k != "idx"}) + if not ev: + continue + keep.append(ev) - # bias + # bias(先算) last_c = float(df["close"].iloc[-1]) bias = "unknown" if sos and (not sod or int(sos.get("idx", 0)) >= int(sod.get("idx", 0))): @@ -174,6 +199,16 @@ def detect_bias_and_events( else: 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 volume_confirm = { "avg_volume": avg_volume, @@ -189,59 +224,146 @@ def build_phases( events: List[Dict[str, Any]], min_bars: int = 3, ) -> 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"]) e = int(tr["abs_end_idx"]) + hi = float(tr["high"]) + lo = float(tr["low"]) n_last = len(df) - 1 min_span = max(2, min_bars - 1) + range_len = max(1, e - s) - event_idx = {} - for ev in events: - t = ev.get("time") - for i in range(s, min(len(df), e + 20)): + def _match_idx(t) -> Optional[int]: + if t is None: + return None + lo = max(0, s - 2) + hi = min(len(df), e + 40) + for i in range(lo, hi): 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 - 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: - if bias == "distribution": - m = {"A": "A停止上涨", "B": "B筑顶", "C": "C测试", "D": "D派发", "E": "E下跌"} - else: + 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离开"} return m.get(phase, phase) - # 理想切点(随后再强制非重叠 + 最小跨度) - raw = [ - ("A", s, a_end), - ("B", a_end, c_anchor), - ("C", c_anchor, d_anchor), - ("D", d_anchor, min(n_last, d_anchor + max(min_bars, (e - s) // 6))), - ("E", min(n_last, d_anchor + max(min_bars, (e - s) // 6)), min(n_last, max(e, d_anchor + max(min_bars * 2, 8)))), - ] + a_end = s + max(min_bars, range_len // 5) + + c_start = c_end = None + if c_ev is not None: + c_start = max(s, int(c_ev) - 1) + c_end = min(n_last, int(c_ev) + 1) + + 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]] = [] cursor = s for phase, _a, _b in raw: if cursor >= n_last: break a = max(int(_a), cursor) - b = int(max(_b, a + min_span)) + b = int(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)) - if b - a < min_span: - # 尾部空间不足:并入上一段终点并停止新增 - if phases: - phases[-1]["end_time"] = _bar_time(df, n_last) - break + if b < a: + continue + if phases and phases[-1].get("_a") == a and phases[-1].get("_b") == b: + continue phases.append( { "phase": phase, "label": _lab(phase), "start_time": _bar_time(df, a), "end_time": _bar_time(df, b), + "_a": a, + "_b": b, } ) cursor = b + for p in phases: + p.pop("_a", None) + p.pop("_b", None) return phases diff --git a/chanlun/analysis/wyckoff/live.py b/chanlun/analysis/wyckoff/live.py new file mode 100644 index 0000000..e2e338e --- /dev/null +++ b/chanlun/analysis/wyckoff/live.py @@ -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", + } diff --git a/chanlun/analysis/wyckoff/range.py b/chanlun/analysis/wyckoff/range.py index 5ca4287..91f7dd5 100644 --- a/chanlun/analysis/wyckoff/range.py +++ b/chanlun/analysis/wyckoff/range.py @@ -1,11 +1,18 @@ -"""交易区间检测:ATR 容差下按评分选取近期震荡箱。""" +"""交易区间检测:仅负责 TradingRange(起止/高低/结构分)。 + +WYCKOFF-MULTI-CYCLE-001:Phase/Event/VP 不得进入本模块。 +过滤顺序固定:detect → quality → trend → overlap(<0.2) → accept → mask。 +""" from __future__ import annotations -from typing import Any, Dict, Optional +from typing import Any, Dict, List, Optional, Tuple import numpy as np import pandas as pd +MAX_CYCLES = 8 +OVERLAP_RATIO_MAX = 0.2 + def _atr(df: pd.DataFrame, period: int = 14) -> pd.Series: 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() +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( length: int, near_hi: int, @@ -31,27 +47,161 @@ def _score_segment( width: float, atr: float, ) -> float: - """触边密度 + 箱内比例 − 相对宽度;弱奖励长度以免只追最长。""" - touch_density = (near_hi + near_lo) / float(max(length, 1)) + """结构质量分(非 Phase/Event)。""" + touch = min(near_hi, 6) + min(near_lo, 6) 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, - 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_offset:slice 相对父 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, atr_mult: float = 1.2, tail_reserve: int = 12, + prefer_start_time: Any = None, + range_start_time: Any = None, ) -> Optional[Dict[str, Any]]: """ - 在最近 lookback 根内寻找高低点波动受控的连续段作为交易区间。 - 尾部预留 tail_reserve 根用于事件(Spring/SOS),不参与箱体边界计算。 - 在硬门槛之上按评分取最优段(非仅最长窗口)。 + 在 df[win_start:win_end+1] 内检测单个 TradingRange。 + 只返回箱体结构,不含 Phase/Event/VP。 """ - if df is None or len(df) < min_bars + 5: + if df is None or win_end < win_start: 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) reserve = min(tail_reserve, max(0, n - min_bars - 2)) 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: last_atr = float(core["close"].iloc[-1]) * 0.01 - best = None - best_score = float("-inf") + eff_atr_mult = float(atr_mult) + if lookback >= 280: + eff_atr_mult = atr_mult * 1.7 + elif lookback >= 160: + 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()) + lo = float(seg["low"].min()) + rw = _robust_width(seg) + if 0 < rw <= max_width * 1.15: + near_hi = int((seg["high"] >= hi - tol).sum()) + 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) - for length in range(min(cn, lookback), min_bars - 1, -4): - seg = core.iloc[-length:] + 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()) - width = hi - lo - if width <= 0 or width > last_atr * atr_mult * 3.5: - continue - tol = last_atr * atr_mult * 0.35 + 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_lo = int((seg["low"] <= lo + tol).sum()) if near_hi < 2 or near_lo < 2: - continue + return inside = float(((seg["close"] >= lo - tol) & (seg["close"] <= hi + tol)).mean()) - if inside < 0.75: - continue - score = _score_segment(length, near_hi, near_lo, inside, width, last_atr) - if score <= best_score: - continue + if inside < 0.72: + return + length = end_i - start_i + 1 + score = _score_segment(length, near_hi, near_lo, inside, rw, last_atr) + prefer_boost + 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 - end_i = cn - 1 - mid = (hi + lo) / 2.0 - last_c = float(work["close"].iloc[-1]) - active = (lo - tol * 1.5) <= last_c <= (hi + tol * 1.5) - best_score = score - best = { - "start_idx": int(start_i), - "end_idx": int(end_i), - "high": hi, - "low": lo, - "mid": mid, - "active": bool(active), - "atr": last_atr, - "tol": tol, - "bars": int(length), - "score": float(score), - } + 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 best is None: + 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 + _try_seg(start_i, end_i, prefer_boost=12.0) + + if not cands: 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 + cands.sort(key=lambda x: x[0], reverse=True) + best_score = cands[0][0] + band = max(4.0, abs(best_score) * 0.10) + near = [c for c in cands if c[0] >= best_score - band] + chosen = max(near, key=lambda x: (x[1], x[0])) + score, _length, start_i, end_i, hi, lo, _rw = chosen + return _pack_range(work, df, start_i, end_i, hi, lo, tol, last_atr, score, n, window_offset=win_start) - best["start_time"] = _ts(work.iloc[best["start_idx"]]) - # 区间时间结束取 core 末,事件可落在其后 - best["end_time"] = _ts(work.iloc[best["end_idx"]]) + +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 + + # abs_* 目前相对 work;若 df 比 work 长需加 offset offset = len(df) - len(work) - best["abs_start_idx"] = offset + best["start_idx"] - best["abs_end_idx"] = offset + best["end_idx"] - best["abs_scan_end_idx"] = offset + n - 1 - return best + if offset: + for tr in accepted: + tr["abs_start_idx"] = int(tr["abs_start_idx"]) + offset + 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 diff --git a/docs/AGENT_MEMORY.md b/docs/AGENT_MEMORY.md index 5bac0fe..a496c50 100644 --- a/docs/AGENT_MEMORY.md +++ b/docs/AGENT_MEMORY.md @@ -22,6 +22,7 @@ - ECR-002 Reviewed:拆 `web/services/runtime/`、加深 analyze 契约 - ECR-003 Reviewed:主站威科夫叠层(`chanlun/analysis/wyckoff/` + `include_wyckoff`)→ `081a57a` - ECR-004 Reviewed:TR 评分硬化 + VP 少系列 + 阶段/门闩/单测(无币种参数) +- 威科夫数据随主 analyze 默认返回;UI 开关仅显隐叠层 ## 硬约束提醒 diff --git a/docs/BACKEND_DESIGN/BD-2026-007-wyckoff-live-structure.md b/docs/BACKEND_DESIGN/BD-2026-007-wyckoff-live-structure.md new file mode 100644 index 0000000..ac9c3b2 --- /dev/null +++ b/docs/BACKEND_DESIGN/BD-2026-007-wyckoff-live-structure.md @@ -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-001(FROZEN)。 + +## 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. diff --git a/docs/CHANGELOG/CHANGELOG.md b/docs/CHANGELOG/CHANGELOG.md index 08fbe91..85dd457 100644 --- a/docs/CHANGELOG/CHANGELOG.md +++ b/docs/CHANGELOG/CHANGELOG.md @@ -1,5 +1,14 @@ # CHANGELOG +## Unreleased — 2026-08-07 + +### ECR-007(L2,LOOP-RUN-005) + +- Wyckoff **Live Structure**:`live.py` + engine 组装 `lifecycle` / `confirmed` / `live` +- Event candidates(Spring/SOS/LPS/UTAD)+ 可解释 confidence;Summary Confirmed/Live 分区 +- `execution_signal_from_wyckoff` **仅** `source=confirmed`;Live-only → None +- **No** Confirmed 门槛降低;**No** strategies / 自动交易 + ## Unreleased — 2026-08-06 ### ECR-004(L2,Reviewed) @@ -7,6 +16,7 @@ - 威科夫 TR 评分选段(防吞前置趋势);阶段非重叠最小跨度 - 主站 VP Top-8 + bins≤24;填充线减负 - `elements_only` 时不跑威科夫;收紧单测(无币种独立参数) +- **后续**:威科夫随主 `/api/analyze` 默认一并返回;前端开关只控制绘制(不再勾选才加载) ### ECR-003(L2,Reviewed) diff --git a/docs/ECR/ECR-007-wyckoff-live-structure.md b/docs/ECR/ECR-007-wyckoff-live-structure.md new file mode 100644 index 0000000..9f32464 --- /dev/null +++ b/docs/ECR/ECR-007-wyckoff-live-structure.md @@ -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 diff --git a/docs/ENGINEERING_SPEC/ECR-007-wyckoff-live-structure.md b/docs/ENGINEERING_SPEC/ECR-007-wyckoff-live-structure.md new file mode 100644 index 0000000..2be6c74 --- /dev/null +++ b/docs/ENGINEERING_SPEC/ECR-007-wyckoff-live-structure.md @@ -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. diff --git a/docs/HANDOFF/ECR-007-architect-to-engineer.md b/docs/HANDOFF/ECR-007-architect-to-engineer.md new file mode 100644 index 0000000..acd701e --- /dev/null +++ b/docs/HANDOFF/ECR-007-architect-to-engineer.md @@ -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. diff --git a/docs/HANDOFF/ECR-007-code-review.md b/docs/HANDOFF/ECR-007-code-review.md new file mode 100644 index 0000000..f0ef31c --- /dev/null +++ b/docs/HANDOFF/ECR-007-code-review.md @@ -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** diff --git a/docs/HANDOFF/ECR-007-engineer-to-reviewer.md b/docs/HANDOFF/ECR-007-engineer-to-reviewer.md new file mode 100644 index 0000000..f0cfcbc --- /dev/null +++ b/docs/HANDOFF/ECR-007-engineer-to-reviewer.md @@ -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. diff --git a/docs/STATE/ECR-007.md b/docs/STATE/ECR-007.md new file mode 100644 index 0000000..a1bf321 --- /dev/null +++ b/docs/STATE/ECR-007.md @@ -0,0 +1,7 @@ +ecr: ECR-007 +owner: human +phase: build +updated: 2026-08-07 +backend_design: BD-2026-007 +loop: LOOP-RUN-005 +notes: Start Authorization granted; Adapter v0.1 STABLE. diff --git a/docs/TEST_REPORT/ECR-007.md b/docs/TEST_REPORT/ECR-007.md new file mode 100644 index 0000000..5586750 --- /dev/null +++ b/docs/TEST_REPORT/ECR-007.md @@ -0,0 +1,32 @@ +# 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 diff --git a/docs/TRACEABILITY.md b/docs/TRACEABILITY.md index 0f67d7e..2a0dbfe 100644 --- a/docs/TRACEABILITY.md +++ b/docs/TRACEABILITY.md @@ -44,3 +44,12 @@ | ECR-004 | TR 评分选最优段 | ENG-004 | `wyckoff/range.py` | `test_wyckoff` / `test_range_scoring_skips_pretrend` | | ECR-004 | VP/填充少 series | ENG-004 | `chart_tv.js` Top-8 + 填充 3;bins≤24 | 人工 + ENG | | ECR-004 | 阶段最小长度 + elements_only 门闩 | ENG-004 | `events.py` + `analyze.py` | 契约 `elements_only` | + +## 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_*` | (填) | +| ECR-007 | execution 仅 confirmed | BD-2026-007 | `execution_signal_from_wyckoff` | live-only → None | (填) | +| ECR-007 | Summary Confirmed/Live 分区 | PRODUCT | `ui.js` | 人工 + 契约键 | (填) | +| ECR-007 | LOOP-RUN-005 | — | — | Gate + Artifact | (填) | diff --git a/docs/notes/WYCKOFF-LIVE-STRUCTURE-001.md b/docs/notes/WYCKOFF-LIVE-STRUCTURE-001.md new file mode 100644 index 0000000..e0d61bf --- /dev/null +++ b/docs/notes/WYCKOFF-LIVE-STRUCTURE-001.md @@ -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 candidates:Spring / 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 面板分区 +- 测例 diff --git a/docs/notes/WYCKOFF-LIVE-VALIDATION-001.md b/docs/notes/WYCKOFF-LIVE-VALIDATION-001.md new file mode 100644 index 0000000..15e215b --- /dev/null +++ b/docs/notes/WYCKOFF-LIVE-VALIDATION-001.md @@ -0,0 +1,76 @@ +# WYCKOFF-LIVE-VALIDATION-001 + +**Status:** DRAFT(待确认执行后 FROZEN) +**Depends on:** WYCKOFF-LIVE-STRUCTURE-001(已 FROZEN) +**Goal:** 验证 Live 是否有预测价值,而非继续加事件规则 + +## 不做 + +- 不新增 BC / AR / ST / UT / UTAD(v1 已够) +- 不降低 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 文案升级(独立小项) diff --git a/docs/notes/WYCKOFF-MULTI-CYCLE-001.md b/docs/notes/WYCKOFF-MULTI-CYCLE-001.md new file mode 100644 index 0000000..7254a27 --- /dev/null +++ b/docs/notes/WYCKOFF-MULTI-CYCLE-001.md @@ -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` +- 检测算法本轮不改;质量阈值 / 历史层折叠为后续项 diff --git a/tests/fixtures/analyze_contract_keys.json b/tests/fixtures/analyze_contract_keys.json index c801735..9609fe5 100644 --- a/tests/fixtures/analyze_contract_keys.json +++ b/tests/fixtures/analyze_contract_keys.json @@ -14,11 +14,11 @@ "uncompleted_bi_list", "uncompleted_seg_list", "uncompleted_zs_list", + "wyckoff", "zs_list" ], "optional_when": { - "include_structure_zones": ["structure_zones"], - "include_wyckoff": ["wyckoff"] + "include_structure_zones": ["structure_zones"] }, "wyckoff_keys": [ "trading_range", @@ -26,6 +26,10 @@ "phases", "events", "volume_profile", - "volume_confirm" - ] + "volume_confirm", + "cycles", + "live", + "lifecycle" + ], + "notes": "wyckoff 默认返回;cycles[0]=ACTIVE;phases/events=Confirmed;live=Developing(WYCKOFF-LIVE-STRUCTURE-001);Execution 仅 Confirmed;见 docs/notes/" } diff --git a/tests/generate_golden.py b/tests/generate_golden.py index 8a09a98..9269466 100644 --- a/tests/generate_golden.py +++ b/tests/generate_golden.py @@ -148,11 +148,11 @@ def analyze_contract_keys() -> dict: "macd", "chan_macd", "klc_trend", + "wyckoff", ] ), "optional_when": { "include_structure_zones": ["structure_zones"], - "include_wyckoff": ["wyckoff"], }, "wyckoff_keys": [ "trading_range", @@ -162,6 +162,7 @@ def analyze_contract_keys() -> dict: "volume_profile", "volume_confirm", ], + "notes": "wyckoff 随主周期 analyze 默认返回;有次/次次周期时另附 element_wyckoff / sub_sub_wyckoff;include_wyckoff=0 可跳过;elements_only 时不返回", } diff --git a/tests/test_golden_pipeline.py b/tests/test_golden_pipeline.py index 9bbb1bd..daa86a8 100644 --- a/tests/test_golden_pipeline.py +++ b/tests/test_golden_pipeline.py @@ -43,9 +43,9 @@ def test_analyze_contract_keys_file(): ) ) 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 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"): assert k in doc.get("wyckoff_keys", []) diff --git a/tests/test_wyckoff.py b/tests/test_wyckoff.py index 7b12320..c3440e7 100644 --- a/tests/test_wyckoff.py +++ b/tests/test_wyckoff.py @@ -11,7 +11,11 @@ ROOT = Path(__file__).resolve().parents[1] sys.path.insert(0, str(ROOT)) from chanlun.analysis.wyckoff import analyze_wyckoff # noqa: E402 -from chanlun.analysis.wyckoff.range import detect_trading_range # noqa: E402 +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: @@ -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(): + """Test C:旧接口兼容 — 顶层字段仍在,且 cycles[0] 为 ACTIVE 镜像。""" df = _box_df() out = analyze_wyckoff(df, lookback=200) assert out["trading_range"] is not None @@ -105,6 +110,55 @@ def test_wyckoff_detects_range_and_events(): assert len(out["phases"]) >= 3 keys = [(p["start_time"], p["end_time"]) for p in out["phases"]] assert len(keys) == len(set(keys)), "phases must not share identical start/end" + # 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"]) + # 模拟 4h:TR.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(): 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 吞掉整段下跌 +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(): dates = pd.date_range("2024-01-01", periods=40, freq="5min", tz="UTC") rows = [] @@ -126,3 +218,169 @@ def test_volume_profile_poc_on_heavy_bin(): assert vp["poc"] is not None assert vp["vah"] is not None and vp["val"] is not None assert abs(vp["poc"] - 50.0) < 1.0 + + +def test_live_does_not_pollute_confirmed_events(): + """Live 形成中:confirmed.events 不含 candidate;live 可有 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 CONFIRMED;execution.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) diff --git a/web/api/analyze.py b/web/api/analyze.py index 7c01a45..fdb6fa7 100644 --- a/web/api/analyze.py +++ b/web/api/analyze.py @@ -5,6 +5,97 @@ from services import runtime as R 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') def analyze(): """分析接口""" @@ -25,6 +116,9 @@ def analyze(): # 获取分形元素时间周期与次次周期 element_timeframe = request.args.get('element_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') @@ -249,6 +343,7 @@ def analyze(): if element_df is not None and len(element_df) > 0: # 添加小周期技术指标(包括布林带) element_df = add_indicators(element_df) + element_df_for_wyckoff = element_df # 对小周期数据进行缠论分析 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) if sub_sub_df is not None and len(sub_sub_df) > 0: 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) result['sub_sub_timeframe'] = sub_sub_timeframe result['sub_sub_kline_data'] = clean_dataframe_for_json(sub_sub_df).to_dict('records') @@ -656,33 +752,36 @@ def analyze(): else: result['structure_zones'] = [] - # 威科夫分析 —— 按需:include_wyckoff=1,且须有主周期分析(非 elements_only) - include_wyckoff_param = request.args.get('include_wyckoff', '') - include_wyckoff = str(include_wyckoff_param).lower() in ('1', 'true', 'yes') + # 威科夫:主 / 次 / 次次各算一份(非 elements_only);前端开关只控制绘制 + # include_wyckoff=0 可显式跳过;缺省与其它真值均计算 + 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: - try: - from chanlun.analysis.wyckoff import analyze_wyckoff - wyckoff_lookback = int(request.args.get('wyckoff_lookback', 120)) - # ECR-004:默认/上限 24 bins(A+C) - wyckoff_bins = int(request.args.get('wyckoff_vp_bins', 24)) - result['wyckoff'] = analyze_wyckoff( - df, - lookback=max(40, min(wyckoff_lookback, 500)), - vp_bins=max(10, min(wyckoff_bins, 24)), + # 主周期先算;次/次次只同步 active=cycles[0] 的 start(WYCKOFF-MULTI-CYCLE-001) + wyckoff_bins = max(10, min(int(request.args.get('wyckoff_vp_bins', 24)), 24)) + result['wyckoff'] = _compute_wyckoff_from_df(df, timeframe, wyckoff_bins, client_tz=None) + main_w = result.get('wyckoff') or {} + cycles = main_w.get('cycles') or [] + # active 唯一来源 cycles[0];禁止 cycles[-1] + active = cycles[0] if cycles else None + prefer_start = None + 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) diff --git a/web/api/pages.py b/web/api/pages.py index 20c03bc..0ee0edb 100644 --- a/web/api/pages.py +++ b/web/api/pages.py @@ -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 services.runtime import * # noqa: F403 from services import runtime as R diff --git a/web/services/runtime/timeframes.py b/web/services/runtime/timeframes.py index 3a9d61a..df52024 100644 --- a/web/services/runtime/timeframes.py +++ b/web/services/runtime/timeframes.py @@ -70,36 +70,43 @@ def build_timeframe_labels(timeframes): 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): """ 根据已排序的「周期 → 中文标签」映射,计算主 / 次 / 次次周期默认值。 + 默认偏好:主 4h、次 2h、次次 1h(威科夫与结构在小时级更可读)。 labels_ordered: OrderedDict 或按插入顺序排列的 dict。 """ if not labels_ordered: labels_ordered = DEFAULT_TIMEFRAME_LABELS.copy() 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') if default_main not in labels_ordered and timeframe_keys: default_main = timeframe_keys[0] - if timeframe_keys: - try: - 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 + default_element = _prefer_smaller(['2h', '1h'], labels_ordered, default_main, timeframe_keys) + default_sub_sub = _prefer_smaller(['1h'], labels_ordered, default_element, timeframe_keys) return default_main, default_element, default_sub_sub, timeframe_keys diff --git a/web/static/js/app/chart_format.js b/web/static/js/app/chart_format.js index 89b3b65..d2b4de5 100644 --- a/web/static/js/app/chart_format.js +++ b/web/static/js/app/chart_format.js @@ -1,6 +1,9 @@ /* chart_format.js — split from chart.js */ /* chart.js */ function updateChartDisplay() { + if (typeof renderWyckoffCycleSummary === 'function') { + renderWyckoffCycleSummary(); + } if (currentData) { // 检测K线周期是否切换 const curPeriod = $('#subSubPeriodKline').is(':checked') ? 'subsub' : diff --git a/web/static/js/app/chart_tv.js b/web/static/js/app/chart_tv.js index 749bb0c..24f2c3e 100644 --- a/web/static/js/app/chart_tv.js +++ b/web/static/js/app/chart_tv.js @@ -40,6 +40,12 @@ function disposeTradingViewCharts() { var chartRoot = document.getElementById('tradingview_chart'); 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 = ''; } } catch (e) { @@ -415,6 +421,21 @@ function initTradingView(symbol, timeframe) { // 创建主图表 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')); @@ -2199,175 +2220,229 @@ function initTradingView(symbol, timeframe) { } } catch (e) { console.error('结构区整体绘制出错:', e); } } - // 威科夫叠层:区间 / 阶段 / 事件 / VP - if ($('#showWyckoff').is(':checked') && currentData.wyckoff) { - try { - const w = currentData.wyckoff; - const tr = w.trading_range; - const parseTs = function(t) { - if (t == null) return NaN; - if (typeof t === 'number') return Math.floor(t > 1e12 ? t / 1000 : t); - const ms = new Date(t).getTime(); - return isNaN(ms) ? NaN : Math.floor(ms / 1000); - }; - const kd = currentData.kline_data || []; - const chartEnd = kd.length - ? Math.floor(new Date(kd[kd.length - 1].date).getTime() / 1000) - : NaN; + // 区间/阶段/时间/VP:框线同中枢;标记并入主 K(同 BSP),时间对齐 candles + (function drawWrLikeChan() { + const candleSeries = tvWidget.series.candleSeries + || tvWidget.series.barSeries + || tvWidget.series.lineSeries + || 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; - if ($('#showWyckoffRange').is(':checked') && tr) { - const t0 = parseTs(tr.start_time); - const t1 = tr.end_time ? parseTs(tr.end_time) : chartEnd; - const hi = parseFloat(tr.high), lo = parseFloat(tr.low), mid = parseFloat(tr.mid); - if (!isNaN(t0) && !isNaN(t1) && !isNaN(hi) && !isNaN(lo)) { - const fill = 'rgba(52, 152, 219, 0.07)'; - const border = 'rgba(52, 152, 219, 0.75)'; - // 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 }) - .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 }]); - } + const toSec = function(t) { + if (t == null) return NaN; + if (typeof t === 'number') return t > 1e12 ? Math.floor(t / 1000) : Math.floor(t); + const ms = new Date(t).getTime(); + return isNaN(ms) ? NaN : Math.floor(ms / 1000); + }; + // 标记必须落在主 series 的 time 上(与 KLC 趋势 nearestTime 同思路) + const snapToCandle = function(t) { + if (!candleTimes.length || isNaN(t)) return t; + let best = candleTimes[0], bd = Math.abs(candleTimes[0] - t); + for (let i = 1; i < candleTimes.length; i++) { + const d = Math.abs(candleTimes[i] - t); + if (d < bd) { bd = d; best = candleTimes[i]; } } - - if ($('#showWyckoffPhases').is(':checked') && w.phases && w.phases.length) { - const phaseColors = { - A: 'rgba(241, 196, 15, 0.85)', - B: 'rgba(155, 89, 182, 0.85)', - C: 'rgba(230, 126, 34, 0.85)', - D: 'rgba(46, 204, 113, 0.85)', - E: 'rgba(52, 152, 219, 0.85)' - }; - const phaseMarkers = []; - w.phases.forEach(function(ph) { - const t0 = parseTs(ph.start_time); - const t1 = ph.end_time ? parseTs(ph.end_time) : chartEnd; - if (isNaN(t0) || isNaN(t1) || !tr) return; - const hi = parseFloat(tr.high); - if (isNaN(hi)) return; - const col = phaseColors[ph.phase] || 'rgba(149,165,166,0.85)'; - // 阶段顶部分段色带(略高于区间高) - const y = hi * 1.002; - mainChart.addLineSeries({ color: col, lineWidth: 3, lastValueVisible: false, priceLineVisible: false }) - .setData([{ time: t0, value: y }, { time: t1, value: y }]); - phaseMarkers.push({ - time: t0, - position: 'aboveBar', - color: col, - shape: 'square', - text: String(ph.phase || ph.label || ''), - size: 1 - }); + return best; + }; + const ensureSpan = function(t0, t1) { + if (isNaN(t0) || isNaN(t1)) return [t0, t1]; + if (t1 < t0) { const x = t0; t0 = t1; t1 = x; } + if (t1 <= t0) t1 = t0 + barStep; + return [t0, t1]; + }; + const drawBox = function(t0, t1, hi, lo, color) { + const span = ensureSpan(t0, t1); + t0 = span[0]; t1 = span[1]; + if (isNaN(t0) || isNaN(t1) || isNaN(hi) || isNaN(lo)) return; + const opt = { color: color, lineWidth: 1, lastValueVisible: false, priceLineVisible: false }; + mainChart.addLineSeries(opt).setData([{ time: t0, value: hi }, { time: t1, value: hi }]); + mainChart.addLineSeries(opt).setData([{ time: t0, value: lo }, { time: t1, value: lo }]); + mainChart.addLineSeries(opt).setData([{ time: t0, value: lo }, { time: t0, value: hi }]); + mainChart.addLineSeries(opt).setData([{ time: t1, value: lo }, { time: t1, value: hi }]); + }; + const mkPL = function(price, color, title, style) { + if (!candleSeries) return; + 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 }); - if (phaseMarkers.length) { - const phSeries = mainChart.addLineSeries({ lastValueVisible: false, priceLineVisible: false }); - phSeries.setMarkers(phaseMarkers); - } - } + } catch (e) { console.warn('价位线失败', title, e); } + }; + const phaseColors = { A: '#f1c40f', B: '#9b59b6', C: '#e67e22', D: '#2ecc71', E: '#3498db' }; + const eventColors = { + Spring: '#27ae60', SOS: '#2ecc71', LPS: '#16a085', + UTAD: '#e74c3c', SOW: '#c0392b', LPSY: '#d35400' + }; - if ($('#showWyckoffEvents').is(':checked') && w.events && w.events.length) { - const eventColors = { - Spring: '#27ae60', - SOS: '#2ecc71', - LPS: '#16a085', - UTAD: '#e74c3c', - SOW: '#c0392b', - LPSY: '#d35400' - }; - const checks = (w.volume_confirm && w.volume_confirm.event_checks) || {}; - const markers = []; - w.events.forEach(function(ev) { - const t = parseTs(ev.time); - if (isNaN(t)) return; - const typ = ev.type || ''; - const chk = checks[typ] || {}; - const volOk = (chk.volume_ok != null) ? chk.volume_ok : ev.volume_ok; - const ratioVal = (chk.volume_ratio != null) ? chk.volume_ratio : ev.volume_ratio; - const ok = volOk === true ? '✓' : (volOk === false ? '✗' : ''); - const note = ev.note || ''; - const ratio = (ratioVal != null) ? (' vol×' + Number(ratioVal).toFixed(2)) : ''; - markers.push({ - time: t, - position: (typ === 'Spring' || typ === 'LPS' || typ === 'SOW') ? 'belowBar' : 'aboveBar', - color: eventColors[typ] || '#7f8c8d', - shape: 'arrowUp', - text: typ + (ok ? ' ' + ok : '') + (note ? ' ' + note : '') + ratio, - size: 1 - }); - }); - if (markers.length) { - const evSeries = mainChart.addLineSeries({ lastValueVisible: false, priceLineVisible: false }); - evSeries.setMarkers(markers); - } - } + const wrMarkers = []; + window.wrMarkers = []; + 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); + }; - if ($('#showWyckoffVP').is(':checked') && w.volume_profile && tr) { - const vp = w.volume_profile; - const t1 = tr.end_time ? parseTs(tr.end_time) : chartEnd; - if (!isNaN(t1)) { - const bins = vp.bins || []; - // 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); } - } + 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; + const typ = ev.type || ''; + const chk = checks[typ] || {}; + const volOk = (chk.volume_ok != null) ? chk.volume_ok : ev.volume_ok; + const ok = volOk === true ? '✓' : (volOk === false ? '✗' : ''); + pushMarker({ + time: t, + position: (typ === 'Spring' || typ === 'LPS' || typ === 'SOW') ? 'belowBar' : 'aboveBar', + color: eventColors[typ] || '#7f8c8d', + shape: (typ === 'Spring' || typ === 'SOW' || typ === 'LPS') ? 'arrowDown' : 'arrowUp', + text: cTag + typ + ok, + size: 1 + }); + }); + } + } 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' + }); + } + + // 同 BSP:写入 window,稍后与分型/买卖点一并 setMarkers + wrMarkers.sort(function(a, b) { return a.time - b.time; }); + window.wrMarkers = wrMarkers; + if (typeof renderWyckoffCycleSummary === 'function') { + renderWyckoffCycleSummary(); + } + })(); // 显示未完成中枢 - 分别处理主周期、次周期和次次周期 if ($('#showMainZs').is(':checked') || $('#showElementZs').is(':checked') || $('#showSubSubZs').is(':checked') || $('#showSubSubBiZs').is(':checked')) { console.log('绘制未完成中枢 - 已启用'); @@ -4223,7 +4298,8 @@ function initTradingView(symbol, timeframe) { ...(window.kluDivMarkersElement || []), ...(window.kluDivMarkersSubSub || []), ...trendMarkersToUse, - ...(window.bspMarkers || []) + ...(window.bspMarkers || []), + ...(window.wrMarkers || []) ]; if (combinedMarkers.length > 0) { console.log( @@ -4232,6 +4308,7 @@ function initTradingView(symbol, timeframe) { '个,小周期分型:', allElementFxMarkers.length, '个,UnitTF:', (window.unittfMarkers || []).length, '个,BSP标记:', (window.bspMarkers || []).length, + '个,区间标记:', (window.wrMarkers || []).length, '个)' ); @@ -4358,7 +4435,8 @@ function initTradingView(symbol, timeframe) { ...(window.kluDivMarkersElement || []), ...(window.kluDivMarkersSubSub || []), ...trendMarkersToUse, - ...(window.bspMarkers || []) + ...(window.bspMarkers || []), + ...(window.wrMarkers || []) ]; if (onlyMainAndU.length > 0) { console.log('仅设置', onlyMainAndU.length, '个主周期/UnitTF标记(主周期分型:', (window.mainFxMarkers || []).length, ',UnitTF:', (window.unittfMarkers || []).length, ')'); diff --git a/web/static/js/app/chart_view.js b/web/static/js/app/chart_view.js index 97c0d96..abac451 100644 --- a/web/static/js/app/chart_view.js +++ b/web/static/js/app/chart_view.js @@ -13,7 +13,7 @@ function updateChart(options) { 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 elementTimeframe = $('#elementTimeframe').val() || window.DEFAULT_ELEMENT_TIMEFRAME || '1m'; const subSubTimeframe = $('#subSubTimeframe').val() || ''; @@ -62,8 +62,8 @@ function updateChart(options) { end_time: endTimeMs, elements_only: false, zone_kl_lines: parseInt($('#zoneKlLines').val()) || 1000, - include_structure_zones: $('#showMainStructureZone').is(':checked') ? 1 : 0, - include_wyckoff: $('#showWyckoff').is(':checked') ? 1 : 0 + include_structure_zones: $('#showMainStructureZone').is(':checked') ? 1 : 0 + // 威科夫随主分析一并返回;开关仅控制绘制,不再传 include_wyckoff }, success: function(data) { // 隐藏加载图标 @@ -81,6 +81,9 @@ function updateChart(options) { delete currentData.original_macd; } currentData = data; + if (typeof renderWyckoffCycleSummary === 'function') { + renderWyckoffCycleSummary(); + } refreshChart(data, { incremental: options.incremental !== undefined diff --git a/web/static/js/app/macd_ui.js b/web/static/js/app/macd_ui.js index f32f491..5d9bfe2 100644 --- a/web/static/js/app/macd_ui.js +++ b/web/static/js/app/macd_ui.js @@ -93,25 +93,16 @@ $(document).on('change', '#showMainStructureZone', function() { } }); -// 威科夫主开关:勾选才请求;子项仅本地重绘 -function syncWyckoffSubControls() { - const on = $('#showWyckoff').is(':checked'); - $('#showWyckoffRange, #showWyckoffPhases, #showWyckoffEvents, #showWyckoffVP').prop('disabled', !on); -} -$(document).on('change', '#showWyckoff', function() { - const on = $('#showWyckoff').is(':checked'); - syncWyckoffSubControls(); - console.log('威科夫切换为:', on); - if (on) { - updateChart(); - } else { +// 区间/阶段/时间/VP:与缠论笔开关一样,本地重绘 +$(document).on( + 'change', + '#showMainWrRange, #showMainWrPhases, #showMainWrEvents, #showMainWrVP,' + + '#showElementWrRange, #showElementWrPhases, #showElementWrEvents, #showElementWrVP,' + + '#showSubSubWrRange, #showSubSubWrPhases, #showSubSubWrEvents, #showSubSubWrVP', + function() { updateChartDisplay(); } -}); -$(document).on('change', '#showWyckoffRange, #showWyckoffPhases, #showWyckoffEvents, #showWyckoffVP', function() { - updateChartDisplay(); -}); -$(function() { syncWyckoffSubControls(); }); +); // 添加趋势显示复选框变更事件(主/元素),变更后刷新主图 $('#showMainTrend').change(function() { diff --git a/web/static/js/app/ui.js b/web/static/js/app/ui.js index e34f1fe..9c3d294 100644 --- a/web/static/js/app/ui.js +++ b/web/static/js/app/ui.js @@ -1,4 +1,252 @@ /* 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 = '
'; + html += '
' + (payload.symbol || '') + ' ' + + (payload.timeframe || '') + '
'; + html += '
当前 C' + a.cycle_id + ' ' + + '' + + _wcsLifecycleZh(a.lifecycle) + '
'; + html += '
'; + html += '
结构' + + _wcsStructureZh(a.structure) + '
'; + html += '
阶段' + + (a.phase_candidate + ? ('阶段 ' + a.phase_candidate + '(候选)') + : (a.phase_confirmed ? ('阶段 ' + a.phase_confirmed) : '—')) + + '
'; + html += '
事件' + + (a.event_candidate + ? (_wcsEventZh(a.event_candidate) + '(候选)') + : _wcsEventZh(a.event_confirmed)) + + '
'; + if (a.event_confirmed && a.event_candidate) { + html += '
已确认' + + _wcsEventZh(a.event_confirmed) + '
'; + } + html += '
区间' + + fmtPx(a.range && a.range.low) + ' – ' + fmtPx(a.range && a.range.high) + '
'; + html += '
置信度' + + pct(a.confidence_live != null ? a.confidence_live : a.confidence_confirmed) + '
'; + if (a.next_expected) { + html += '
下一步' + + _wcsEventZh(a.next_expected) + '
'; + } + html += '
'; + if (payload.confirmed_history && payload.confirmed_history.length) { + html += '
已确认历史
'; + payload.confirmed_history.forEach(function(h) { + const ev = (h.events && h.events.length) + ? h.events.map(_wcsEventZh).join('、') + : '—'; + html += '
C' + h.cycle_id + ' ' + _wcsStructureZh(h.structure) + ' · ' + ev + '
'; + }); + html += '
'; + } + html += '
'; + 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() { $.get('/api/symbols', function(data) { if (Array.isArray(data)) { @@ -24,14 +272,15 @@ function loadSymbols() { }); } -// 设置默认时间范围 +// 设置默认时间范围(需覆盖威科夫 lookback;1 天在 4h/1h 上几乎检不出区间) function setDefaultTimeRange() { 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 $('#end_time').val(formatDatetimeLocal(now)); - $('#start_time').val(formatDatetimeLocal(oneDayAgo)); + $('#start_time').val(formatDatetimeLocal(start)); } // 格式化日期为datetime-local输入框格式 function formatDatetimeLocal(date) { diff --git a/web/templates/index.html b/web/templates/index.html index 20df06f..22ce1a8 100644 --- a/web/templates/index.html +++ b/web/templates/index.html @@ -96,6 +96,81 @@ position: relative; 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 { position: absolute; top: 10px; @@ -972,26 +1047,6 @@ -
- - -
-
- - -
-
- - -
-
- - -
-
- - -
@@ -1037,6 +1092,22 @@ +
+ + +
+
+ + +
+
+ + +
+
+ + +
@@ -1079,6 +1150,22 @@
+
+ + +
+
+ + +
+
+ + +
+
+ + +
@@ -1121,6 +1208,22 @@
+
+ + +
+
+ + +
+
+ + +
+
+ + +
@@ -1130,6 +1233,7 @@
+