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/chanlun/pipeline/builders/incremental.py b/chanlun/pipeline/builders/incremental.py new file mode 100644 index 0000000..42e99fe --- /dev/null +++ b/chanlun/pipeline/builders/incremental.py @@ -0,0 +1,171 @@ +"""增量更新:新K只追加 KLU/KLC,笔与笔中枢在当前列表上重算。 + +不改 init_TF_DF 的整段语义。笔必须整表重扫:最后一笔 is_sure 允许收回 +(OWN_CHAN_ZS_001 上 60 天出现 7 次)。笔中枢用 cal_bi_zs_list_pure。 +""" +from __future__ import annotations + +from datetime import datetime + +import pandas as pd +from pandas import DataFrame +from technical.util import resample_to_interval + +from chanlun.core.ChanEnum import Chan_FX_TYPE, Chan_KLC_FX, Chan_KLC_STATE +from chanlun.core.ChanKLU import ChanKLU + + +class IncrementalBuilderMixin: + def init_stream(self, df, interval=1, timeframe=None): + """用历史K线初始化流式状态,之后用 append_bar / replace_last_bar。""" + if df is None or df.empty: + raise ValueError("DataFrame for stream is empty.") + if "date" not in df.columns: + raise ValueError(f"DataFrame missing 'date' column. Columns: {df.columns.tolist()}") + self.timeframe = timeframe + self.interval = interval + if interval == 1: + self.dataframe = df.copy() + else: + self.dataframe = resample_to_interval(df, interval) + self.dataframe = self.add_indicators(self.dataframe) + self.klu_list = [] + self.klc_list = [] + self.bi_list = [] + self.bi_zs_list = [] + self.seg_list = [] + self.zs_list = [] + self.bsp_list = [] + self.klc_fx_list = [] + self.big_zs_list = [] + self._klc_feed_last_klu = None + for i in range(len(self.dataframe)): + self._append_row_at(i, rebuild=False) + self.rebuild_bi_zs() + return self + + def append_bar(self, row): + """追加一根已收盘K线。同一时间戳则改为替换最后一根。""" + self._ensure_stream_state() + item = self._normalize_row(row) + if self.klu_list and self.klu_list[-1].time == self._row_time_str(item): + return self.replace_last_bar(item) + self._append_item_to_dataframe(item) + self.dataframe = self.add_indicators(self.dataframe) + self._append_row_at(len(self.dataframe) - 1, rebuild=True) + return self + + def replace_last_bar(self, row): + """更新最后一根K(未完成K线走新OHLC)。包含关系从 KLU 列表重放。""" + self._ensure_stream_state() + if not self.klu_list: + return self.append_bar(row) + item = self._normalize_row(row) + idx = self.dataframe.index[-1] + for key, val in item.items(): + self.dataframe.at[idx, key] = val + self.dataframe = self.add_indicators(self.dataframe) + self._apply_item_to_klu(self.klu_list[-1], self.dataframe.iloc[-1]) + self._rebuild_klc_from_klu() + self.rebuild_bi_zs() + return self + + def rebuild_bi_zs(self): + """在当前 KLC 上重算笔 + cal_bi_zs_list_pure。会先清分型标记。""" + self._reset_klc_bi_marks(self.klc_list) + self.bi_list = self.cal_bi_list(self.klc_list) if self.klc_list else [] + self.bi_zs_list = self.cal_bi_zs_list_pure(self.bi_list) if self.bi_list else [] + return self.bi_zs_list + + def _ensure_stream_state(self): + if not hasattr(self, "klu_list") or self.klu_list is None: + self.klu_list = [] + if not hasattr(self, "klc_list") or self.klc_list is None: + self.klc_list = [] + if not hasattr(self, "dataframe") or self.dataframe is None: + self.dataframe = DataFrame( + columns=["date", "open", "high", "low", "close", "volume"] + ) + if not hasattr(self, "_klc_feed_last_klu"): + self._klc_feed_last_klu = self.klu_list[-1] if self.klu_list else None + if not hasattr(self, "bi_zs_list"): + self.bi_zs_list = [] + + def _rebuild_klc_from_klu(self): + self.klc_list = [] + last_klu = None + for klu in self.klu_list: + self._push_klu_into_klc_list(self.klc_list, klu, last_klu) + last_klu = klu + self._klc_feed_last_klu = last_klu + + def _append_row_at(self, idx, rebuild=True): + item = self.dataframe.iloc[idx] + klu = self._klu_from_item(item, idx) + if self.klu_list: + self.klu_list[-1].set_next(klu) + klu.set_pre(self.klu_list[-1]) + self._push_klu_into_klc_list(self.klc_list, klu, self._klc_feed_last_klu) + self._klc_feed_last_klu = klu + self.klu_list.append(klu) + if rebuild: + self.rebuild_bi_zs() + + def _klu_from_item(self, item, idx): + klu = ChanKLU( + self._item_time_str(item), + item["open"], + item["high"], + item["low"], + item["close"], + item["volume"], + ) + klu.set_idx(idx) + if not hasattr(klu, "ema13"): + klu.ema13 = 0 + if "macd" in item: + klu.set_indicators(item) + return klu + + def _apply_item_to_klu(self, klu, item): + klu.time = self._item_time_str(item) + klu.open = item["open"] + klu.high = item["high"] + klu.low = item["low"] + klu.close = item["close"] + klu.volume = item["volume"] + klu.range = klu.high - klu.low + klu.body = abs(klu.close - klu.open) + if "macd" in item: + klu.set_indicators(item) + + def _reset_klc_bi_marks(self, klc_list): + for klc in klc_list: + klc.fx = Chan_FX_TYPE.UNKNOWN + klc.klc_fx_type = Chan_KLC_FX.UNKNOWN + klc.klc_state = Chan_KLC_STATE.UNKNOWN + klc.bi = None + klc.fx_confirmed = False + + def _item_time_str(self, item): + date = item["date"] + if hasattr(date, "to_pydatetime"): + date = date.to_pydatetime() + if isinstance(date, datetime): + return date.strftime("%Y-%m-%d %H:%M:%S") + return str(date) + + def _row_time_str(self, item): + return self._item_time_str(item) + + def _normalize_row(self, row): + if isinstance(row, pd.Series): + return row + return pd.Series(row) + + def _append_item_to_dataframe(self, item): + row_df = DataFrame([item]) + if self.dataframe is None or self.dataframe.empty: + self.dataframe = row_df + else: + self.dataframe = pd.concat([self.dataframe, row_df], ignore_index=True) diff --git a/chanlun/pipeline/builders/kline.py b/chanlun/pipeline/builders/kline.py index 6330179..c9e2172 100644 --- a/chanlun/pipeline/builders/kline.py +++ b/chanlun/pipeline/builders/kline.py @@ -86,7 +86,8 @@ class KlineBuilderMixin: return Chan_FX_TYPE.UNKNOWN def check_fx(self, klc): - if klc.pre and klc.next: + # 右K未完成(仍在包含合并)时不分型:否则确认笔会随 next 扩区间被 check_*_fx 收回 + if klc.pre and klc.next and klc.next.end_klu is not None: if klc.high > klc.pre.high and klc.high > klc.next.high and klc.low > klc.pre.low and klc.low > klc.next.low: #if (klc.close > klc.ema52 or klc.next.close > klc.next.ema52) and klc.macd > 0: klc.set_fx(Chan_FX_TYPE.TOP) @@ -171,6 +172,43 @@ class KlineBuilderMixin: def get_kl_data(self, dataframe:DataFrame): return self.cal_kl_data(dataframe) + def _push_klu_into_klc_list(self, klc_list, klu, last_klu): + """把一根 KLU 并入包含K线列表。与 get_klc_list 的几何规则相同。""" + if len(klc_list) > 0: + last_klc = klc_list[-1] + if klu.exception: + ddir = Chan_KLINE_DIR.DOWN + if last_klc.high < klu.high: + ddir = Chan_KLINE_DIR.UP + klc = ChanKLC(klu, index=len(klc_list), ddir=ddir) + klc.high = klu.close if klu.close > klu.open else klu.open + klc.low = klu.open if klu.close > klu.open else klu.close + klc_list.append(klc) + last_klc.set_next(klc) + klc.set_pre(last_klc) + last_klc.set_end_klu(last_klu) + klc.set_pre_fx() + else: + included = last_klc.check_klu_included(klu) + if not included: + ddir = Chan_KLINE_DIR.DOWN + if last_klc.high < klu.high: + ddir = Chan_KLINE_DIR.UP + klc = ChanKLC(klu, index=len(klc_list), ddir=ddir) + klc_list.append(klc) + last_klc.set_next(klc) + klc.set_pre(last_klc) + last_klc.set_end_klu(last_klu) + klc.set_pre_fx() + else: + last_klc.add_klu(klu) + else: + ddir = Chan_KLINE_DIR.UP + if klu.open > klu.close: + ddir = Chan_KLINE_DIR.DOWN + klc = ChanKLC(klu, 0, ddir) + klc_list.append(klc) + def get_klc_list(self, klu_list): klc_list = [] last_klu = None @@ -198,41 +236,7 @@ class KlineBuilderMixin: ema_down_list.append(ema_down_count) #print(last_klu.time, ema_down_count, "DOWN END") ema_down_count = 0 - if len(klc_list) > 0: - last_klc = klc_list[-1] - if klu.exception: - ddir = Chan_KLINE_DIR.DOWN - if last_klc.high < klu.high: - ddir = Chan_KLINE_DIR.UP - klc = ChanKLC(klu, index=len(klc_list), ddir=ddir) - klc.high = klu.close if klu.close > klu.open else klu.open - klc.low = klu.open if klu.close > klu.open else klu.close - klc_list.append(klc) - last_klc.set_next(klc) - klc.set_pre(last_klc) - last_klc.set_end_klu(last_klu) - klc.set_pre_fx() - #print(klu.time, klu.high, klu.low, klu.close, klu.open, klu.exception) - else: - included = last_klc.check_klu_included(klu) - if not included: - ddir = Chan_KLINE_DIR.DOWN - if last_klc.high < klu.high: - ddir = Chan_KLINE_DIR.UP - klc = ChanKLC(klu, index=len(klc_list), ddir=ddir) - klc_list.append(klc) - last_klc.set_next(klc) - klc.set_pre(last_klc) - last_klc.set_end_klu(last_klu) - klc.set_pre_fx() - else: - last_klc.add_klu(klu) - else: - ddir = Chan_KLINE_DIR.UP - if klu.open > klu.close: - ddir = Chan_KLINE_DIR.DOWN - klc = ChanKLC(klu, 0, ddir) - klc_list.append(klc) + self._push_klu_into_klc_list(klc_list, klu, last_klu) last_klu = klu klc_list = self.cal_trend(klc_list) #print(ema52_up_list, ema52_down_list) diff --git a/chanlun/pipeline/builders/zs.py b/chanlun/pipeline/builders/zs.py index c1a1a67..084b83d 100644 --- a/chanlun/pipeline/builders/zs.py +++ b/chanlun/pipeline/builders/zs.py @@ -355,9 +355,12 @@ class ZsBuilderMixin: return bi_zs_list def get_zs_range(bis): - zg = min(bi.high for bi in bis) - zd = max(bi.low for bi in bis) - return zg, zd + bis_list = bis[0:3] + zg = min(bi.high for bi in bis_list) + zd = max(bi.low for bi in bis_list) + dd = min(bi.low for bi in bis_list) + gg = max(bi.high for bi in bis_list) + return zg, zd, dd, gg def is_bi_overlap_range(bi, zg, zd): return bi.high >= zd and bi.low <= zg @@ -375,8 +378,8 @@ class ZsBuilderMixin: zs.bi_list = list(bis) for bi in zs.bi_list: bi.set_bi_zs(zs) - zs.set_gg(max(bi.high for bi in zs.bi_list)) - zs.set_dd(min(bi.low for bi in zs.bi_list)) + #zs.set_gg(max(bi.high for bi in zs.bi_list)) + #zs.set_dd(min(bi.low for bi in zs.bi_list)) zs.classify_zs() last_zs = None @@ -394,7 +397,7 @@ class ZsBuilderMixin: start_idx += 1 continue - zg, zd = get_zs_range([bi1, bi2, bi3]) + zg, zd, dd, gg = get_zs_range([bi1, bi2, bi3]) if zg <= zd: start_idx += 1 continue @@ -420,6 +423,8 @@ class ZsBuilderMixin: zs = ChanBIZS(bi1, len(bi_zs_list), zs_dir) zs.set_zg(zg) zs.set_zd(zd) + zs.set_dd(dd) + zs.set_gg(gg) set_zs_bi_list(zs, bis_for_zs) zs.set_end_bi(bis_for_zs[-1], bis_for_zs[-1].sure_time) diff --git a/chanlun/pipeline/orchestrator.py b/chanlun/pipeline/orchestrator.py index abde109..ce32177 100644 --- a/chanlun/pipeline/orchestrator.py +++ b/chanlun/pipeline/orchestrator.py @@ -178,6 +178,15 @@ class ChanLun(): def cal_bi_zs_list(self, bi_list): #return self.tf_df.cal_bi_zs(bi_list) return self.tf_df.cal_bi_zs_list(bi_list) + def cal_bi_zs_list_pure(self, bi_list): + return self.tf_df.cal_bi_zs_list_pure(bi_list) + def init_stream(self, dataframe, interval=1, timeframe=None): + self.tf_df.init_stream(dataframe, interval, timeframe) + return self.tf_df + def append_bar(self, row): + return self.tf_df.append_bar(row) + def replace_last_bar(self, row): + return self.tf_df.replace_last_bar(row) def get_bi_zs_list(self, bi_list): return self.tf_df.get_bi_zs_list(bi_list) def get_decimal(self, value): diff --git a/chanlun/pipeline/timeframe.py b/chanlun/pipeline/timeframe.py index f837f6b..842a0d5 100644 --- a/chanlun/pipeline/timeframe.py +++ b/chanlun/pipeline/timeframe.py @@ -31,12 +31,13 @@ from chanlun.core.ChanZS import ChanZS, ChanZS_Big from chanlun.indicators.ChanMACD import ChanMACD from chanlun.pipeline.builders.bi import BiBuilderMixin from chanlun.pipeline.builders.bsp import BspBuilderMixin +from chanlun.pipeline.builders.incremental import IncrementalBuilderMixin from chanlun.pipeline.builders.indicators import IndicatorsBuilderMixin from chanlun.pipeline.builders.kline import KlineBuilderMixin from chanlun.pipeline.builders.seg import SegBuilderMixin from chanlun.pipeline.builders.zs import ZsBuilderMixin -class TF_DF(IndicatorsBuilderMixin, KlineBuilderMixin, BiBuilderMixin, SegBuilderMixin, ZsBuilderMixin, BspBuilderMixin): +class TF_DF(IndicatorsBuilderMixin, KlineBuilderMixin, BiBuilderMixin, SegBuilderMixin, ZsBuilderMixin, BspBuilderMixin, IncrementalBuilderMixin): def __init__(self, df=None, interval=0, timeframe=None): if df is not None: self.init_TF_DF(df, interval, timeframe) @@ -59,12 +60,14 @@ class TF_DF(IndicatorsBuilderMixin, KlineBuilderMixin, BiBuilderMixin, SegBuilde self.klc_list = [] self.bi_list = [] self.zs_list = [] + self.bi_zs_list = [] self.bsp_list = [] self.seg_list = [] self.klc_fx_list = [] self.klu_list = self.cal_kl_data(self.dataframe) self.klc_list = self.get_klc_list(self.klu_list) self.bi_list = self.cal_bi_list(self.klc_list) + self.bi_zs_list = self.cal_bi_zs_list_pure(self.bi_list) self.seg_list = self.get_seg_list(self.bi_list) self.zs_list = self.get_zs_list(self.bi_list, self.seg_list) self.big_zs_list = self.get_big_zs_list(self.zs_list) diff --git a/chanlun/tests/__init__.py b/chanlun/tests/__init__.py new file mode 100644 index 0000000..9d48db4 --- /dev/null +++ b/chanlun/tests/__init__.py @@ -0,0 +1 @@ +from __future__ import annotations diff --git a/chanlun/tests/test_incremental.py b/chanlun/tests/test_incremental.py new file mode 100644 index 0000000..8a8e140 --- /dev/null +++ b/chanlun/tests/test_incremental.py @@ -0,0 +1,141 @@ +from __future__ import annotations + +import sys +import unittest +from pathlib import Path + +import pandas as pd + +_CHAN = Path(__file__).resolve().parents[2] +if str(_CHAN) not in sys.path: + sys.path.insert(0, str(_CHAN)) + +from chanlun.pipeline.timeframe import TF_DF # noqa: E402 + + +def _zigzag_df(n=160, step=8): + dates = pd.date_range("2024-01-01", periods=n, freq="5min") + rows = [] + price = 100.0 + for i, date in enumerate(dates): + up = (i // step) % 2 == 0 + if up: + o = price + c = price + 1.5 + h = c + 0.3 + l = o - 0.2 + else: + o = price + c = price - 1.5 + h = o + 0.2 + l = c - 0.3 + price = c + rows.append( + { + "date": date, + "open": o, + "high": h, + "low": l, + "close": c, + "volume": 1.0, + } + ) + return pd.DataFrame(rows) + + +def _sure_bi_key(bi): + return (str(bi.start_time), bi.dir.name, round(float(bi.high), 6), round(float(bi.low), 6)) + + +def _zs_key(zs): + return ( + str(zs.start_time), + round(float(zs.zg), 6), + round(float(zs.zd), 6), + len(zs.bi_list), + ) + + +class TestIncremental(unittest.TestCase): + def test_init_stream_matches_batch_push(self): + df = _zigzag_df() + stream = TF_DF() + stream.init_stream(df, 1, "5m") + + batch = TF_DF() + indexed = batch.add_indicators(df.copy()) + klu = batch.cal_kl_data(indexed) + klc = [] + last = None + for k in klu: + batch._push_klu_into_klc_list(klc, k, last) + last = k + batch.klc_list = klc + batch.rebuild_bi_zs() + + self.assertEqual(len(stream.klu_list), len(klu)) + self.assertEqual(len(stream.klc_list), len(klc)) + self.assertEqual( + [_sure_bi_key(b) for b in stream.bi_list if b.is_sure], + [_sure_bi_key(b) for b in batch.bi_list if b.is_sure], + ) + self.assertEqual( + [_zs_key(z) for z in stream.bi_zs_list], + [_zs_key(z) for z in batch.bi_zs_list], + ) + + def test_append_bar_matches_init_stream(self): + df = _zigzag_df() + stream = TF_DF() + stream.init_stream(df, 1, "5m") + + inc = TF_DF() + for _, row in df.iterrows(): + inc.append_bar(row) + + self.assertEqual(len(inc.klu_list), len(stream.klu_list)) + self.assertEqual(len(inc.klc_list), len(stream.klc_list)) + self.assertEqual( + [_sure_bi_key(b) for b in inc.bi_list if b.is_sure], + [_sure_bi_key(b) for b in stream.bi_list if b.is_sure], + ) + self.assertEqual( + [_zs_key(z) for z in inc.bi_zs_list], + [_zs_key(z) for z in stream.bi_zs_list], + ) + + def test_replace_last_bar_keeps_count(self): + df = _zigzag_df(n=80) + tf = TF_DF() + tf.init_stream(df, 1, "5m") + n_klu = len(tf.klu_list) + last = df.iloc[-1].copy() + last["close"] = float(last["close"]) + 0.01 + last["high"] = max(float(last["high"]), float(last["close"])) + tf.replace_last_bar(last) + self.assertEqual(len(tf.klu_list), n_klu) + self.assertGreater(len(tf.klc_list), 0) + + def test_check_fx_skips_forming_right_wing(self): + from types import SimpleNamespace + + from chanlun.core.ChanEnum import Chan_FX_TYPE + + tf = TF_DF() + pre = SimpleNamespace(high=10, low=8) + nxt_open = SimpleNamespace(high=11, low=7, end_klu=None) + nxt_done = SimpleNamespace(high=11, low=7, end_klu=object()) + center = SimpleNamespace( + pre=pre, + next=nxt_open, + high=12, + low=9, + set_fx=lambda *_a, **_k: None, + ) + self.assertEqual(tf.check_fx(center), Chan_FX_TYPE.UNKNOWN) + center.next = nxt_done + self.assertEqual(tf.check_fx(center), Chan_FX_TYPE.TOP) + + +if __name__ == "__main__": + unittest.main() diff --git a/config/BTC_Maker_Micro_Scalper.json b/config/BTC_Maker_Micro_Scalper.json new file mode 100644 index 0000000..3079e19 --- /dev/null +++ b/config/BTC_Maker_Micro_Scalper.json @@ -0,0 +1,98 @@ +{ + "$schema": "https://schema.freqtrade.io/schema.json", + "strategy": "BTC_Maker_Micro_Scalper", + "max_open_trades": 1, + "stake_currency": "USDT", + "stake_amount": "unlimited", + "tradable_balance_ratio": 0.99, + "fiat_display_currency": "USD", + "dry_run": true, + "db_url": "sqlite:///tradesv3.btc_maker_micro_scalper.sqlite", + "dry_run_wallet": 10000, + "cancel_open_orders_on_exit": true, + "trading_mode": "futures", + "margin_mode": "isolated", + "can_short": true, + "timeframe": "1m", + "process_only_new_candles": true, + "fee": 0.00016, + "unfilledtimeout": { + "entry": 1, + "exit": 1, + "exit_timeout_count": 3, + "unit": "minutes" + }, + "order_types": { + "entry": "limit", + "exit": "limit", + "stoploss": "limit", + "stoploss_on_exchange": false + }, + "order_time_in_force": { + "entry": "GTC", + "exit": "GTC" + }, + "entry_pricing": { + "price_side": "same", + "use_order_book": true, + "order_book_top": 1, + "price_last_balance": 0.0, + "check_depth_of_market": { + "enabled": false, + "bids_to_ask_delta": 1 + } + }, + "exit_pricing": { + "price_side": "same", + "use_order_book": true, + "order_book_top": 1 + }, + "exchange": { + "name": "binance", + "key": "YOUR_BINANCE_API_KEY", + "secret": "YOUR_BINANCE_API_SECRET", + "ccxt_config": { + "proxies": { + "http": "http://127.0.0.1:7897", + "https": "http://127.0.0.1:7897" + } + }, + "ccxt_async_config": { + "aiohttp_proxy": "http://127.0.0.1:7897" + }, + "pair_whitelist": [ + "BTC/USDT:USDT" + ], + "pair_blacklist": [ + "BNB/.*" + ] + }, + "pairlists": [ + { + "method": "StaticPairList" + } + ], + "telegram": { + "enabled": false, + "token": "", + "chat_id": "" + }, + "api_server": { + "enabled": false, + "listen_ip_address": "127.0.0.1", + "listen_port": 8821, + "verbosity": "error", + "enable_openapi": false, + "jwt_secret_key": "change_me_mms_v1", + "ws_token": "change_me_mms_ws", + "CORS_origins": [], + "username": "freqtrader", + "password": "FreqTrade007" + }, + "bot_name": "BTC_Maker_Micro_Scalper", + "initial_state": "running", + "force_entry_enable": false, + "internals": { + "process_throttle_secs": 1 + } +} diff --git a/config/BTC_Maker_Micro_Scalper_v11.json b/config/BTC_Maker_Micro_Scalper_v11.json new file mode 100644 index 0000000..3e3421d --- /dev/null +++ b/config/BTC_Maker_Micro_Scalper_v11.json @@ -0,0 +1,98 @@ +{ + "$schema": "https://schema.freqtrade.io/schema.json", + "strategy": "BTC_Maker_Micro_Scalper_v11", + "max_open_trades": 1, + "stake_currency": "USDT", + "stake_amount": "unlimited", + "tradable_balance_ratio": 0.99, + "fiat_display_currency": "USD", + "dry_run": true, + "db_url": "sqlite:///tradesv3.btc_maker_micro_scalper_v11.sqlite", + "dry_run_wallet": 10000, + "cancel_open_orders_on_exit": true, + "trading_mode": "futures", + "margin_mode": "isolated", + "can_short": true, + "timeframe": "1m", + "process_only_new_candles": true, + "fee": 0.00016, + "unfilledtimeout": { + "entry": 3, + "exit": 2, + "exit_timeout_count": 3, + "unit": "minutes" + }, + "order_types": { + "entry": "limit", + "exit": "limit", + "stoploss": "limit", + "stoploss_on_exchange": false + }, + "order_time_in_force": { + "entry": "GTC", + "exit": "GTC" + }, + "entry_pricing": { + "price_side": "same", + "use_order_book": true, + "order_book_top": 1, + "price_last_balance": 0.0, + "check_depth_of_market": { + "enabled": false, + "bids_to_ask_delta": 1 + } + }, + "exit_pricing": { + "price_side": "same", + "use_order_book": true, + "order_book_top": 1 + }, + "exchange": { + "name": "binance", + "key": "YOUR_BINANCE_API_KEY", + "secret": "YOUR_BINANCE_API_SECRET", + "ccxt_config": { + "proxies": { + "http": "http://127.0.0.1:7897", + "https": "http://127.0.0.1:7897" + } + }, + "ccxt_async_config": { + "aiohttp_proxy": "http://127.0.0.1:7897" + }, + "pair_whitelist": [ + "BTC/USDT:USDT" + ], + "pair_blacklist": [ + "BNB/.*" + ] + }, + "pairlists": [ + { + "method": "StaticPairList" + } + ], + "telegram": { + "enabled": false, + "token": "", + "chat_id": "" + }, + "api_server": { + "enabled": false, + "listen_ip_address": "127.0.0.1", + "listen_port": 8822, + "verbosity": "error", + "enable_openapi": false, + "jwt_secret_key": "change_me_mms_v11", + "ws_token": "change_me_mms_v11_ws", + "CORS_origins": [], + "username": "freqtrader", + "password": "FreqTrade007" + }, + "bot_name": "BTC_Maker_Micro_Scalper_v11", + "initial_state": "running", + "force_entry_enable": false, + "internals": { + "process_throttle_secs": 1 + } +} diff --git a/config/ChanLun_BTC_15.json b/config/ChanLun_BTC_15.json index bf50e85..db22f65 100644 --- a/config/ChanLun_BTC_15.json +++ b/config/ChanLun_BTC_15.json @@ -39,8 +39,15 @@ "name": "binance", "key": "hvoXanRExQvcN4tyGFvEnsSF4gqxXp6ZJnBu5lnhvlVuHaDbj2PhLBQGCLkkyeI8", "secret": "3UKA2oyDj7OoXrausmnaLwLlNfXmlNf2imBdmQqqKHArcJfk6X9xjaUF19wzu82l", - "ccxt_config": {}, - "ccxt_async_config": {}, + "ccxt_config": { + "proxies": { + "http": "http://127.0.0.1:7897", + "https": "http://127.0.0.1:7897" + } + }, + "ccxt_async_config": { + "aiohttp_proxy": "http://127.0.0.1:7897" + }, "pair_whitelist": [ "BTC/USDT:USDT" ], diff --git a/config/MakerEdgeProbe.json b/config/MakerEdgeProbe.json new file mode 100644 index 0000000..383dba9 --- /dev/null +++ b/config/MakerEdgeProbe.json @@ -0,0 +1,91 @@ +{ + "$schema": "https://schema.freqtrade.io/schema.json", + "strategy": "MakerEdgeProbe", + "max_open_trades": 1, + "stake_currency": "USDT", + "stake_amount": "unlimited", + "tradable_balance_ratio": 0.99, + "fiat_display_currency": "USD", + "dry_run": true, + "db_url": "sqlite:///tradesv3.maker_edge_probe.sqlite", + "dry_run_wallet": 10000, + "cancel_open_orders_on_exit": true, + "trading_mode": "futures", + "margin_mode": "isolated", + "can_short": true, + "timeframe": "1m", + "process_only_new_candles": false, + "fee": 0.00016, + "unfilledtimeout": { + "entry": 3, + "exit": 2, + "exit_timeout_count": 3, + "unit": "minutes" + }, + "order_types": { + "entry": "limit", + "exit": "limit", + "stoploss": "market", + "stoploss_on_exchange": false + }, + "order_time_in_force": { + "entry": "GTC", + "exit": "GTC" + }, + "entry_pricing": { + "price_side": "same", + "use_order_book": true, + "order_book_top": 1 + }, + "exit_pricing": { + "price_side": "same", + "use_order_book": true, + "order_book_top": 1 + }, + "exchange": { + "name": "binance", + "key": "YOUR_BINANCE_API_KEY", + "secret": "YOUR_BINANCE_API_SECRET", + "ccxt_config": { + "proxies": { + "http": "http://127.0.0.1:7897", + "https": "http://127.0.0.1:7897" + } + }, + "ccxt_async_config": { + "aiohttp_proxy": "http://127.0.0.1:7897" + }, + "pair_whitelist": [ + "BTC/USDT:USDT" + ], + "pair_blacklist": [ + "BNB/.*" + ] + }, + "pairlists": [ + { + "method": "StaticPairList" + } + ], + "telegram": { + "enabled": false + }, + "api_server": { + "enabled": true, + "listen_ip_address": "127.0.0.1", + "listen_port": 8823, + "verbosity": "error", + "enable_openapi": false, + "jwt_secret_key": "maker_edge_probe_change_me", + "ws_token": "maker_edge_probe_ws", + "CORS_origins": [], + "username": "freqtrader", + "password": "FreqTrade007" + }, + "bot_name": "MakerEdgeProbe", + "initial_state": "running", + "force_entry_enable": false, + "internals": { + "process_throttle_secs": 2 + } +} diff --git a/config/Turtle_BTC.json b/config/Turtle_BTC.json new file mode 100644 index 0000000..971b2bf --- /dev/null +++ b/config/Turtle_BTC.json @@ -0,0 +1,86 @@ +{ + "$schema": "https://schema.freqtrade.io/schema.json", + "max_open_trades": 1, + "stake_currency": "USDT", + "stake_amount": "unlimited", + "tradable_balance_ratio": 0.99, + "fiat_display_currency": "USD", + "dry_run": true, + "db_url": "sqlite:///tradesv3.turtle_btc.sqlite", + "dry_run_wallet": 10000, + "cancel_open_orders_on_exit": true, + "trading_mode": "futures", + "margin_mode": "isolated", + "can_short": true, + "timeframe": "15m", + "process_only_new_candles": true, + "unfilledtimeout": { + "entry": 15, + "exit": 15, + "exit_timeout_count": 5, + "unit": "minutes" + }, + "entry_pricing": { + "price_side": "same", + "use_order_book": true, + "order_book_top": 1, + "price_last_balance": 0.0, + "check_depth_of_market": { + "enabled": false, + "bids_to_ask_delta": 1 + } + }, + "exit_pricing": { + "price_side": "same", + "use_order_book": true, + "order_book_top": 1 + }, + "exchange": { + "name": "binance", + "key": "", + "secret": "", + "ccxt_config": { + "proxies": { + "http": "http://127.0.0.1:7897", + "https": "http://127.0.0.1:7897" + } + }, + "ccxt_async_config": { + "aiohttp_proxy": "http://127.0.0.1:7897" + }, + "pair_whitelist": [ + "BTC/USDT:USDT" + ], + "pair_blacklist": [ + "BNB/.*" + ] + }, + "pairlists": [ + { + "method": "StaticPairList" + } + ], + "telegram": { + "enabled": false, + "token": "", + "chat_id": "" + }, + "api_server": { + "enabled": false, + "listen_ip_address": "127.0.0.1", + "listen_port": 8822, + "verbosity": "error", + "enable_openapi": false, + "jwt_secret_key": "turtle-btc-change-me", + "ws_token": "turtle-btc-ws-change-me", + "CORS_origins": [], + "username": "freqtrader", + "password": "FreqTrade007" + }, + "bot_name": "turtle_btc", + "initial_state": "running", + "force_entry_enable": false, + "internals": { + "process_throttle_secs": 5 + } +} diff --git a/config/Wyckoff_BTC.json b/config/Wyckoff_BTC.json new file mode 100644 index 0000000..10fff12 --- /dev/null +++ b/config/Wyckoff_BTC.json @@ -0,0 +1,86 @@ +{ + "$schema": "https://schema.freqtrade.io/schema.json", + "max_open_trades": 1, + "stake_currency": "USDT", + "stake_amount": "unlimited", + "tradable_balance_ratio": 0.99, + "fiat_display_currency": "USD", + "dry_run": true, + "db_url": "sqlite:///tradesv3.wyckoff_btc.sqlite", + "dry_run_wallet": 10000, + "cancel_open_orders_on_exit": true, + "trading_mode": "futures", + "margin_mode": "isolated", + "can_short": true, + "timeframe": "1h", + "process_only_new_candles": true, + "unfilledtimeout": { + "entry": 60, + "exit": 60, + "exit_timeout_count": 5, + "unit": "minutes" + }, + "entry_pricing": { + "price_side": "same", + "use_order_book": true, + "order_book_top": 1, + "price_last_balance": 0.0, + "check_depth_of_market": { + "enabled": false, + "bids_to_ask_delta": 1 + } + }, + "exit_pricing": { + "price_side": "same", + "use_order_book": true, + "order_book_top": 1 + }, + "exchange": { + "name": "binance", + "key": "", + "secret": "", + "ccxt_config": { + "proxies": { + "http": "http://127.0.0.1:7897", + "https": "http://127.0.0.1:7897" + } + }, + "ccxt_async_config": { + "aiohttp_proxy": "http://127.0.0.1:7897" + }, + "pair_whitelist": [ + "BTC/USDT:USDT" + ], + "pair_blacklist": [ + "BNB/.*" + ] + }, + "pairlists": [ + { + "method": "StaticPairList" + } + ], + "telegram": { + "enabled": false, + "token": "", + "chat_id": "" + }, + "api_server": { + "enabled": false, + "listen_ip_address": "127.0.0.1", + "listen_port": 8823, + "verbosity": "error", + "enable_openapi": false, + "jwt_secret_key": "wyckoff-btc-change-me", + "ws_token": "wyckoff-btc-ws-change-me", + "CORS_origins": [], + "username": "freqtrader", + "password": "FreqTrade007" + }, + "bot_name": "wyckoff_btc", + "initial_state": "running", + "force_entry_enable": false, + "internals": { + "process_throttle_secs": 5 + } +} diff --git a/config/Wyckoff_BTC_GATED.json b/config/Wyckoff_BTC_GATED.json new file mode 100644 index 0000000..5bcb482 --- /dev/null +++ b/config/Wyckoff_BTC_GATED.json @@ -0,0 +1,86 @@ +{ + "$schema": "https://schema.freqtrade.io/schema.json", + "max_open_trades": 1, + "stake_currency": "USDT", + "stake_amount": "unlimited", + "tradable_balance_ratio": 0.99, + "fiat_display_currency": "USD", + "dry_run": true, + "db_url": "sqlite:///tradesv3.wyckoff_btc_gated.sqlite", + "dry_run_wallet": 10000, + "cancel_open_orders_on_exit": true, + "trading_mode": "futures", + "margin_mode": "isolated", + "can_short": true, + "timeframe": "1h", + "process_only_new_candles": true, + "unfilledtimeout": { + "entry": 60, + "exit": 60, + "exit_timeout_count": 5, + "unit": "minutes" + }, + "entry_pricing": { + "price_side": "same", + "use_order_book": true, + "order_book_top": 1, + "price_last_balance": 0.0, + "check_depth_of_market": { + "enabled": false, + "bids_to_ask_delta": 1 + } + }, + "exit_pricing": { + "price_side": "same", + "use_order_book": true, + "order_book_top": 1 + }, + "exchange": { + "name": "binance", + "key": "", + "secret": "", + "ccxt_config": { + "proxies": { + "http": "http://127.0.0.1:7897", + "https": "http://127.0.0.1:7897" + } + }, + "ccxt_async_config": { + "aiohttp_proxy": "http://127.0.0.1:7897" + }, + "pair_whitelist": [ + "BTC/USDT:USDT" + ], + "pair_blacklist": [ + "BNB/.*" + ] + }, + "pairlists": [ + { + "method": "StaticPairList" + } + ], + "telegram": { + "enabled": false, + "token": "", + "chat_id": "" + }, + "api_server": { + "enabled": false, + "listen_ip_address": "127.0.0.1", + "listen_port": 8825, + "verbosity": "error", + "enable_openapi": false, + "jwt_secret_key": "wyckoff-gated-change-me", + "ws_token": "wyckoff-gated-ws-change-me", + "CORS_origins": [], + "username": "freqtrader", + "password": "FreqTrade007" + }, + "bot_name": "wyckoff_btc_gated", + "initial_state": "running", + "force_entry_enable": false, + "internals": { + "process_throttle_secs": 5 + } +} diff --git a/config/Wyckoff_BTC_LPS.json b/config/Wyckoff_BTC_LPS.json new file mode 100644 index 0000000..a092d17 --- /dev/null +++ b/config/Wyckoff_BTC_LPS.json @@ -0,0 +1,86 @@ +{ + "$schema": "https://schema.freqtrade.io/schema.json", + "max_open_trades": 1, + "stake_currency": "USDT", + "stake_amount": "unlimited", + "tradable_balance_ratio": 0.99, + "fiat_display_currency": "USD", + "dry_run": true, + "db_url": "sqlite:///tradesv3.wyckoff_btc_lps.sqlite", + "dry_run_wallet": 10000, + "cancel_open_orders_on_exit": true, + "trading_mode": "futures", + "margin_mode": "isolated", + "can_short": true, + "timeframe": "1h", + "process_only_new_candles": true, + "unfilledtimeout": { + "entry": 60, + "exit": 60, + "exit_timeout_count": 5, + "unit": "minutes" + }, + "entry_pricing": { + "price_side": "same", + "use_order_book": true, + "order_book_top": 1, + "price_last_balance": 0.0, + "check_depth_of_market": { + "enabled": false, + "bids_to_ask_delta": 1 + } + }, + "exit_pricing": { + "price_side": "same", + "use_order_book": true, + "order_book_top": 1 + }, + "exchange": { + "name": "binance", + "key": "", + "secret": "", + "ccxt_config": { + "proxies": { + "http": "http://127.0.0.1:7897", + "https": "http://127.0.0.1:7897" + } + }, + "ccxt_async_config": { + "aiohttp_proxy": "http://127.0.0.1:7897" + }, + "pair_whitelist": [ + "BTC/USDT:USDT" + ], + "pair_blacklist": [ + "BNB/.*" + ] + }, + "pairlists": [ + { + "method": "StaticPairList" + } + ], + "telegram": { + "enabled": false, + "token": "", + "chat_id": "" + }, + "api_server": { + "enabled": false, + "listen_ip_address": "127.0.0.1", + "listen_port": 8824, + "verbosity": "error", + "enable_openapi": false, + "jwt_secret_key": "wyckoff-lps-change-me", + "ws_token": "wyckoff-lps-ws-change-me", + "CORS_origins": [], + "username": "freqtrader", + "password": "FreqTrade007" + }, + "bot_name": "wyckoff_btc_lps", + "initial_state": "running", + "force_entry_enable": false, + "internals": { + "process_throttle_secs": 5 + } +} diff --git a/config/Wyckoff_BTC_V1_BASELINE.json b/config/Wyckoff_BTC_V1_BASELINE.json new file mode 100644 index 0000000..a4fc369 --- /dev/null +++ b/config/Wyckoff_BTC_V1_BASELINE.json @@ -0,0 +1,86 @@ +{ + "$schema": "https://schema.freqtrade.io/schema.json", + "max_open_trades": 1, + "stake_currency": "USDT", + "stake_amount": "unlimited", + "tradable_balance_ratio": 0.99, + "fiat_display_currency": "USD", + "dry_run": true, + "db_url": "sqlite:///tradesv3.wyckoff_btc_v1_baseline.sqlite", + "dry_run_wallet": 10000, + "cancel_open_orders_on_exit": true, + "trading_mode": "futures", + "margin_mode": "isolated", + "can_short": true, + "timeframe": "1h", + "process_only_new_candles": true, + "unfilledtimeout": { + "entry": 60, + "exit": 60, + "exit_timeout_count": 5, + "unit": "minutes" + }, + "entry_pricing": { + "price_side": "same", + "use_order_book": true, + "order_book_top": 1, + "price_last_balance": 0.0, + "check_depth_of_market": { + "enabled": false, + "bids_to_ask_delta": 1 + } + }, + "exit_pricing": { + "price_side": "same", + "use_order_book": true, + "order_book_top": 1 + }, + "exchange": { + "name": "binance", + "key": "", + "secret": "", + "ccxt_config": { + "proxies": { + "http": "http://127.0.0.1:7897", + "https": "http://127.0.0.1:7897" + } + }, + "ccxt_async_config": { + "aiohttp_proxy": "http://127.0.0.1:7897" + }, + "pair_whitelist": [ + "BTC/USDT:USDT" + ], + "pair_blacklist": [ + "BNB/.*" + ] + }, + "pairlists": [ + { + "method": "StaticPairList" + } + ], + "telegram": { + "enabled": false, + "token": "", + "chat_id": "" + }, + "api_server": { + "enabled": false, + "listen_ip_address": "127.0.0.1", + "listen_port": 8823, + "verbosity": "error", + "enable_openapi": false, + "jwt_secret_key": "wyckoff-v1-baseline-change-me", + "ws_token": "wyckoff-v1-baseline-ws-change-me", + "CORS_origins": [], + "username": "freqtrader", + "password": "FreqTrade007" + }, + "bot_name": "wyckoff_btc_v1_baseline", + "initial_state": "running", + "force_entry_enable": false, + "internals": { + "process_throttle_secs": 5 + } +} diff --git a/crypto_wyckoff/__init__.py b/crypto_wyckoff/__init__.py new file mode 100644 index 0000000..0ee5dcd --- /dev/null +++ b/crypto_wyckoff/__init__.py @@ -0,0 +1,5 @@ +"""crypto_wyckoff — multi-TF screener for crypto (ported from A_Share_DP Architecture v1.0).""" + +from crypto_wyckoff.version import ARCHITECTURE_VERSION, WYCKOFF_ENGINE_VERSION + +__all__ = ["WYCKOFF_ENGINE_VERSION", "ARCHITECTURE_VERSION"] diff --git a/crypto_wyckoff/annotate.py b/crypto_wyckoff/annotate.py new file mode 100644 index 0000000..0abb9b5 --- /dev/null +++ b/crypto_wyckoff/annotate.py @@ -0,0 +1,342 @@ +"""Walk-forward Wyckoff phase/event annotations for chart overlay.""" + +from __future__ import annotations + +from datetime import date + +from crypto_wyckoff.domain_models import OHLCVFrame, WyckoffCycle, WyckoffEvent, WyckoffPhase +from crypto_wyckoff.cycle import CycleEngine +from crypto_wyckoff.event import EventEngine +from crypto_wyckoff.features import FeatureEngine +from crypto_wyckoff.phase import PhaseEngine + +_MIN_BARS = {"1d": 40, "1w": 26, "1M": 18} + +_NOTABLE_EVENTS = { + WyckoffEvent.PS.value, + WyckoffEvent.SC.value, + WyckoffEvent.AR.value, + WyckoffEvent.ST.value, + WyckoffEvent.SPRING.value, + WyckoffEvent.TEST.value, + WyckoffEvent.SOS.value, + WyckoffEvent.LPS.value, + WyckoffEvent.JUMP.value, + WyckoffEvent.BACKUP.value, + WyckoffEvent.BC.value, + WyckoffEvent.UTAD.value, + WyckoffEvent.SOW.value, + WyckoffEvent.LPSY.value, +} + + +def _slice_frame(frame: OHLCVFrame, end_idx: int) -> OHLCVFrame: + n = end_idx + 1 + return OHLCVFrame( + ts_code=frame.ts_code, + timeframe=frame.timeframe, + trade_dates=frame.trade_dates[:n], + open=frame.open[:n], + high=frame.high[:n], + low=frame.low[:n], + close=frame.close[:n], + volume=frame.volume[:n], + amount=frame.amount[:n] if frame.amount else [], + ) + + +def _compress_phases(points: list[tuple[str, str]]) -> list[dict]: + """points: [(date_iso, phase), ...] → segments.""" + if not points: + return [] + segs: list[dict] = [] + start, phase = points[0] + prev = start + for d, p in points[1:]: + if p != phase: + segs.append({"start": start, "end": prev, "phase": phase}) + start, phase = d, p + prev = d + segs.append({"start": start, "end": prev, "phase": phase}) + return segs + + +def annotate_frame( + frame: OHLCVFrame, + step: int | None = None, + *, + role: str | None = None, +) -> dict: + """Pure annotation: phase bands + event markers + latest levels. + + ``role`` is the D/W/M rule alias (1d/1w/1M). Defaults to frame.timeframe. + ``step`` defaults by role to keep interactive charts snappy. + """ + tf = role or frame.timeframe + min_bars = _MIN_BARS.get(tf, 30) + if step is None: + step = {"1d": 2, "1w": 1, "1M": 1}.get(tf, 2) + + empty = { + "phases": [], + "events": [], + "levels": {}, + "bars": len(frame), + "timeframe": tf, + } + if frame.empty or len(frame) < min_bars: + return empty + + feat_eng = FeatureEngine() + cycle_eng = CycleEngine() + phase_eng = PhaseEngine() + event_eng = EventEngine() + + phase_points: list[tuple[str, str]] = [] + events: list[dict] = [] + last_event: str | None = None + levels: dict = {} + + # Ensure last bar is always evaluated + indices = list(range(min_bars - 1, len(frame), step)) + if indices[-1] != len(frame) - 1: + indices.append(len(frame) - 1) + + for i in indices: + sub = _slice_frame(frame, i) + f = feat_eng.run(sub, tf) + c = cycle_eng.run(f, tf) + p = phase_eng.run(c, f, tf) + e = event_eng.run(c, p, f, tf) + + d = str(frame.trade_dates[i])[:10] + phase = p.payload.get("phase") or WyckoffPhase.NONE.value + phase_points.append((d, phase)) + + cur = e.payload.get("current_event") or WyckoffEvent.NONE.value + if cur in _NOTABLE_EVENTS and cur != last_event: + events.append({ + "date": d, + "event": cur, + "price": float(frame.close[i]), + "low": float(frame.low[i]), + "high": float(frame.high[i]), + }) + last_event = cur + elif cur == WyckoffEvent.NONE.value: + last_event = None + + if i == len(frame) - 1 and not f.payload.get("insufficient"): + levels = { + k: f.payload.get(k) + for k in ( + "range_high", "range_low", "ma20", "ma60", + "swing_high", "swing_low", "close", + ) + if f.payload.get(k) is not None + } + levels["phase"] = phase + levels["cycle"] = c.payload.get("cycle") + levels["current_event"] = cur + + return { + "phases": _compress_phases(phase_points), + "events": events, + "levels": levels, + "bars": len(frame), + "timeframe": tf, + } + + +_RANGE_CYCLES = { + WyckoffCycle.ACCUMULATION.value, + WyckoffCycle.RE_ACCUMULATION.value, + WyckoffCycle.DISTRIBUTION.value, + WyckoffCycle.RE_DISTRIBUTION.value, +} + + +def _build_range_zones( + price_frame: OHLCVFrame, + cycle_segs: list[dict], + levels: dict | None = None, +) -> list[dict]: + """Build price boxes (high/low × date span) for accum/distrib ranges.""" + if price_frame.empty: + return [] + dates = [str(d)[:10] for d in price_frame.trade_dates] + highs = price_frame.high + lows = price_frame.low + zones: list[dict] = [] + + for seg in cycle_segs or []: + cy = seg.get("cycle") + if cy not in _RANGE_CYCLES: + continue + start, end = seg["start"], seg["end"] + idxs = [i for i, d in enumerate(dates) if start <= d <= end] + if not idxs: + # weekly bar date may sit between daily bars — take nearest window + i0 = next((i for i, d in enumerate(dates) if d >= start), None) + if i0 is None: + continue + i1 = next((i for i, d in enumerate(dates) if d > end), len(dates)) - 1 + idxs = list(range(i0, max(i0, i1) + 1)) + if not idxs: + continue + # pad short weekly hits to at least ~1 week of dailies for visibility + if len(idxs) < 5 and idxs[-1] + 1 < len(dates): + extra = min(5 - len(idxs), len(dates) - 1 - idxs[-1]) + idxs = list(range(idxs[0], idxs[-1] + 1 + max(0, extra))) + hi = max(highs[i] for i in idxs) + lo = min(lows[i] for i in idxs) + if hi <= lo: + continue + zones.append({ + "kind": cy, + "start": dates[idxs[0]], + "end": dates[idxs[-1]], + "high": float(hi), + "low": float(lo), + "current": False, + }) + + # Always expose the latest trading-range box from feature snapshot + levels = levels or {} + rh, rl = levels.get("range_high"), levels.get("range_low") + if rh is not None and rl is not None and float(rh) > float(rl): + look = min(60, len(dates)) + cy = levels.get("cycle") or "Unknown" + if cy not in _RANGE_CYCLES: + # Phase B/C in a range → treat as accumulation-style TR for display + ph = levels.get("phase") or "" + if ph in ("A", "B", "C"): + cy = WyckoffCycle.ACCUMULATION.value + elif ph in ("D", "E") and float(levels.get("close") or 0) < float(rh): + cy = WyckoffCycle.ACCUMULATION.value + else: + cy = "Range" + zones.append({ + "kind": cy, + "start": dates[-look], + "end": dates[-1], + "high": float(rh), + "low": float(rl), + "current": True, + }) + + return zones + + +def annotate_symbol( + ts_code: str, + freq: str, + end_date: date | None = None, + lookback: int = 180, + *, + combo_id: str | None = None, +) -> dict: + """IO + annotate for one symbol (used by API). + + For the combo *low* chart, phase bands come from **mid** structure, + while event markers / levels come from the low TF. + """ + from crypto_wyckoff.combos import ROLE_HIGH, ROLE_LOW, ROLE_MID, get_combo + from crypto_wyckoff.io import load_frame + + combo = get_combo(combo_id) + allowed = {combo["low"], combo["mid"], combo["high"]} + if freq not in allowed: + raise ValueError(f"freq {freq} not in combo {combo['id']} ({combo['label']})") + empty = { + "ts_code": ts_code, + "freq": freq, + "phases": [], + "events": [], + "levels": {}, + "zones": [], + "bars": 0, + "phase_source": freq, + "cycles": [], + "combo_id": combo["id"], + } + _ = end_date + + if freq == combo["low"]: + low = load_frame(ts_code, combo["low"], lookback) + mid = load_frame(ts_code, combo["mid"], max(60, lookback // 3)) + if low is None: + return empty + d_ann = annotate_frame(low, role=ROLE_LOW) + w_ann = annotate_frame(mid, role=ROLE_MID) if mid is not None else {"phases": []} + cycles = _cycle_segments(mid, role=ROLE_MID) if mid is not None else [] + levels = d_ann.get("levels") or {} + if cycles: + levels = {**levels, "cycle": cycles[-1].get("cycle") or levels.get("cycle")} + for p in reversed(w_ann.get("phases") or []): + if p.get("phase") not in (None, "None"): + levels = {**levels, "phase": p["phase"]} + break + return { + "ts_code": ts_code, + "freq": freq, + "end_date": low.trade_dates[-1].isoformat() if low.trade_dates else None, + "phases": w_ann.get("phases") or [], + "events": d_ann.get("events") or [], + "levels": d_ann.get("levels") or {}, + "zones": _build_range_zones(low, cycles, levels), + "bars": d_ann.get("bars", 0), + "phase_source": combo["mid"], + "cycles": cycles, + "combo_id": combo["id"], + } + + role = ROLE_MID if freq == combo["mid"] else ROLE_HIGH + frame = load_frame(ts_code, freq, lookback) + if frame is None: + return empty + out = annotate_frame(frame, role=role) + out["ts_code"] = ts_code + out["freq"] = freq + out["end_date"] = frame.trade_dates[-1].isoformat() if frame.trade_dates else None + out["phase_source"] = freq + out["cycles"] = _cycle_segments(frame, role=ROLE_HIGH if role == ROLE_HIGH else ROLE_MID) + out["zones"] = _build_range_zones(frame, out["cycles"], out.get("levels") or {}) + out["combo_id"] = combo["id"] + if role == ROLE_HIGH: + if not any(p.get("phase") not in (None, "None") for p in out["phases"]): + out["phases"] = [ + {"start": c["start"], "end": c["end"], "phase": c["cycle"]} + for c in out["cycles"] + if c.get("cycle") and c["cycle"] != "Unknown" + ] + return out + + +def _cycle_segments( + frame: OHLCVFrame, + step: int | None = None, + *, + role: str | None = None, +) -> list[dict]: + """Walk-forward cycle labels compressed to segments.""" + tf = role or frame.timeframe + min_bars = _MIN_BARS.get(tf, 30) + if step is None: + step = {"1d": 3, "1w": 1, "1M": 1}.get(tf, 2) + if frame.empty or len(frame) < min_bars: + return [] + + feat_eng = FeatureEngine() + cycle_eng = CycleEngine() + points: list[tuple[str, str]] = [] + indices = list(range(min_bars - 1, len(frame), step)) + if indices[-1] != len(frame) - 1: + indices.append(len(frame) - 1) + for i in indices: + sub = _slice_frame(frame, i) + f = feat_eng.run(sub, tf) + c = cycle_eng.run(f, tf) + points.append((str(frame.trade_dates[i])[:10], c.payload.get("cycle") or "Unknown")) + segs = _compress_phases(points) + return [{"start": s["start"], "end": s["end"], "cycle": s["phase"]} for s in segs] diff --git a/crypto_wyckoff/combos.py b/crypto_wyckoff/combos.py new file mode 100644 index 0000000..8348483 --- /dev/null +++ b/crypto_wyckoff/combos.py @@ -0,0 +1,248 @@ +"""Multi-timeframe combo presets for Crypto Wyckoff Screener. + +Roles (engine rule aliases stay D/W/M): + high → Cycle (rules as 1M) + mid → Phase (rules as 1w) + low → Event (rules as 1d) + +Actual bar TFs come from the combo (e.g. 8h/4h/1h). +""" + +from __future__ import annotations + +import json +import re +import threading +from copy import deepcopy +from pathlib import Path +from typing import Any + +from crypto_wyckoff.io import DATA_DIR, ensure_dirs + +ROLE_LOW = "1d" +ROLE_MID = "1w" +ROLE_HIGH = "1M" + +# Minutes for ordering / validation (provider labels) +_TF_MINUTES: dict[str, int] = { + "1m": 1, "2m": 2, "3m": 3, "4m": 4, "5m": 5, + "10m": 10, "15m": 15, "20m": 20, "25m": 25, "30m": 30, "45m": 45, + "1h": 60, "2h": 120, "3h": 180, "4h": 240, "5h": 300, + "6h": 360, "7h": 420, "8h": 480, "9h": 540, "10h": 600, + "11h": 660, "12h": 720, "16h": 960, "20h": 1200, + "1d": 1440, "2d": 2880, "3d": 4320, "4d": 5760, "5d": 7200, "6d": 8640, + "1w": 10080, "2w": 20160, "3w": 30240, + "1M": 43200, +} + +# TFs we allow in custom combos (provider-backed + local 1M) +ALLOWED_TFS: tuple[str, ...] = ( + "1h", "2h", "3h", "4h", "6h", "8h", "12h", + "1d", "2d", "3d", "1w", "1M", +) + +BUILTIN: list[dict[str, Any]] = [ + { + "id": "h8_4_1", + "label": "8h / 4h / 1h", + "high": "8h", + "mid": "4h", + "low": "1h", + "builtin": True, + }, + { + "id": "d_w_m", + "label": "1d / 1w / 1M", + "high": "1M", + "mid": "1w", + "low": "1d", + "builtin": True, + }, +] + +_COMBOS_FILE = DATA_DIR / "combos.json" +_lock = threading.Lock() +_cache: list[dict[str, Any]] | None = None + + +def tf_minutes(tf: str) -> int | None: + if tf in _TF_MINUTES: + return _TF_MINUTES[tf] + # tolerate provider typo "10" → skip + m = re.fullmatch(r"(\d+)([mhdwM])", tf) + if not m: + return None + n, u = int(m.group(1)), m.group(2) + mult = {"m": 1, "h": 60, "d": 1440, "w": 10080, "M": 43200}[u] + return n * mult + + +def combo_id_for(high: str, mid: str, low: str) -> str: + def _tok(t: str) -> str: + return t.replace("/", "_") + + return f"{_tok(high)}_{_tok(mid)}_{_tok(low)}" + + +def validate_combo(high: str, mid: str, low: str) -> str | None: + """Return error message or None if ok.""" + for tf in (high, mid, low): + if tf not in ALLOWED_TFS: + return f"不支持的周期: {tf}" + if len({high, mid, low}) < 3: + return "高/中/低周期必须互不相同" + hm, mm, lm = tf_minutes(high), tf_minutes(mid), tf_minutes(low) + if hm is None or mm is None or lm is None: + return "无法解析周期长度" + if not (hm > mm > lm): + return "须满足 高 > 中 > 低(例如 8h > 4h > 1h)" + return None + + +def _normalize(row: dict[str, Any]) -> dict[str, Any] | None: + high, mid, low = row.get("high"), row.get("mid"), row.get("low") + if not high or not mid or not low: + return None + err = validate_combo(str(high), str(mid), str(low)) + if err: + return None + cid = str(row.get("id") or combo_id_for(high, mid, low)) + label = str(row.get("label") or f"{high} / {mid} / {low}") + return { + "id": cid, + "label": label, + "high": str(high), + "mid": str(mid), + "low": str(low), + "builtin": bool(row.get("builtin", False)), + } + + +def _load_raw() -> list[dict[str, Any]]: + ensure_dirs() + if not _COMBOS_FILE.exists(): + return deepcopy(BUILTIN) + try: + data = json.loads(_COMBOS_FILE.read_text(encoding="utf-8")) + items = data.get("combos") if isinstance(data, dict) else data + if not isinstance(items, list): + return deepcopy(BUILTIN) + except (OSError, json.JSONDecodeError): + return deepcopy(BUILTIN) + + out: list[dict[str, Any]] = [] + seen: set[str] = set() + for b in BUILTIN: + out.append(deepcopy(b)) + seen.add(b["id"]) + for row in items: + if not isinstance(row, dict): + continue + norm = _normalize(row) + if not norm or norm["id"] in seen: + continue + if norm["id"] in {b["id"] for b in BUILTIN}: + continue + norm["builtin"] = False + out.append(norm) + seen.add(norm["id"]) + return out + + +def _save(combos: list[dict[str, Any]]) -> None: + ensure_dirs() + custom = [c for c in combos if not c.get("builtin")] + payload = {"combos": custom} + tmp = _COMBOS_FILE.with_suffix(".tmp") + tmp.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8") + tmp.replace(_COMBOS_FILE) + + +def list_combos() -> list[dict[str, Any]]: + global _cache + with _lock: + if _cache is None: + _cache = _load_raw() + return deepcopy(_cache) + + +def get_combo(combo_id: str | None) -> dict[str, Any]: + combos = list_combos() + if combo_id: + for c in combos: + if c["id"] == combo_id: + return deepcopy(c) + return deepcopy(combos[0]) + + +def add_combo(high: str, mid: str, low: str, label: str | None = None) -> dict[str, Any]: + err = validate_combo(high, mid, low) + if err: + raise ValueError(err) + cid = combo_id_for(high, mid, low) + row = { + "id": cid, + "label": label or f"{high} / {mid} / {low}", + "high": high, + "mid": mid, + "low": low, + "builtin": False, + } + with _lock: + combos = _load_raw() + for c in combos: + if c["id"] == cid or (c["high"], c["mid"], c["low"]) == (high, mid, low): + _cache = combos + return deepcopy(c) + combos.append(row) + _save(combos) + _cache = combos + return deepcopy(row) + + +def delete_combo(combo_id: str) -> bool: + with _lock: + combos = _load_raw() + kept: list[dict[str, Any]] = [] + removed = False + for c in combos: + if c["id"] == combo_id: + if c.get("builtin"): + raise ValueError("内置组合不可删除") + removed = True + continue + kept.append(c) + if removed: + _save(kept) + _cache = kept + return removed + + +def all_tfs_for_combos(combos: list[dict[str, Any]] | None = None) -> list[str]: + """Unique TFs needed by active combos (stable order).""" + rows = combos if combos is not None else list_combos() + seen: list[str] = [] + for c in rows: + for k in ("low", "mid", "high"): + tf = c[k] + if tf not in seen: + seen.append(tf) + return seen + + +def lookback_for(tf: str) -> int: + defaults = { + "1h": 500, + "2h": 400, + "3h": 350, + "4h": 300, + "6h": 280, + "8h": 250, + "12h": 220, + "1d": 250, + "2d": 200, + "3d": 180, + "1w": 104, + "1M": 60, + } + return defaults.get(tf, 200) diff --git a/crypto_wyckoff/cycle.py b/crypto_wyckoff/cycle.py new file mode 100644 index 0000000..95d7722 --- /dev/null +++ b/crypto_wyckoff/cycle.py @@ -0,0 +1,102 @@ +"""Cycle Engine — monthly/weekly macro cycle via Rule Registry.""" + +from __future__ import annotations + +from crypto_wyckoff.domain_models import EngineResult, WyckoffCycle +from crypto_wyckoff.rules.base import RuleHit +from crypto_wyckoff.rules.registry import rule_registry + + +def _resolve_range_conflict(hits: list[RuleHit], features: dict) -> list[RuleHit]: + """Accumulation vs Distribution overlap → mutually exclusive by MA120 position.""" + accum = [h for h in hits if h.cycle == WyckoffCycle.ACCUMULATION.value] + dist = [h for h in hits if h.cycle == WyckoffCycle.DISTRIBUTION.value] + if not (accum and dist): + return hits + + close = float(features.get("close") or 0) + ma120 = float(features.get("ma120") or close) or close + others = [ + h for h in hits + if h.cycle not in (WyckoffCycle.ACCUMULATION.value, WyckoffCycle.DISTRIBUTION.value) + ] + # Below MA120 → accumulation; above → distribution; equal band uses relative position + if close < ma120 * 0.995: + return others + accum + if close > ma120 * 1.005: + return others + dist + # Tight band: keep higher confidence only + best_a = max(accum, key=lambda h: h.confidence) + best_d = max(dist, key=lambda h: h.confidence) + return others + ([best_a] if best_a.confidence >= best_d.confidence else [best_d]) + + +class CycleEngine: + name = "Cycle" + version = "1.0.0" + + def run(self, feature: EngineResult, timeframe: str) -> EngineResult: + features = feature.payload + if features.get("insufficient"): + return EngineResult( + name=self.name, + version=self.version, + confidence=15.0, + score=40.0, + reasons=[f"{timeframe} 数据不足,Cycle=Unknown"], + warnings=["insufficient_features"], + payload={ + "cycle": WyckoffCycle.UNKNOWN.value, + "timeframe": timeframe, + "trend_score": 40.0, + }, + ) + + context = {"features": features, "timeframe": timeframe} + hits: list[RuleHit] = [] + for rule in rule_registry.by_category("cycle", timeframe): + hit = rule.evaluate(context) + if hit and hit.cycle: + hits.append(hit) + + hits = _resolve_range_conflict(hits, features) + + if not hits: + return EngineResult( + name=self.name, + version=self.version, + confidence=30.0, + score=40.0, + reasons=["无匹配周期规则,标记 Unknown"], + payload={ + "cycle": WyckoffCycle.UNKNOWN.value, + "timeframe": timeframe, + "trend_score": 40.0, + }, + ) + + best = max(hits, key=lambda h: h.confidence) + trend_score = best.score + if best.cycle == WyckoffCycle.MARKUP.value: + trend_score = max(trend_score, 75.0) + elif best.cycle == WyckoffCycle.ACCUMULATION.value: + trend_score = max(60.0, trend_score * 0.9) + elif best.cycle == WyckoffCycle.DISTRIBUTION.value: + trend_score = min(45.0, 100 - trend_score * 0.5) + elif best.cycle == WyckoffCycle.MARKDOWN.value: + trend_score = min(30.0, 100 - trend_score) + + return EngineResult( + name=self.name, + version=self.version, + confidence=best.confidence, + score=trend_score, + reasons=best.reasons, + metrics=best.metrics, + payload={ + "cycle": best.cycle, + "timeframe": timeframe, + "rule_id": best.rule_id, + "trend_score": trend_score, + }, + ) diff --git a/crypto_wyckoff/decision.py b/crypto_wyckoff/decision.py new file mode 100644 index 0000000..96fc96a --- /dev/null +++ b/crypto_wyckoff/decision.py @@ -0,0 +1,195 @@ +"""Decision Engine — multi-timeframe fusion and tradability (Architecture v1.0).""" + +from __future__ import annotations + +from crypto_wyckoff.domain_models import ( + DecisionSignal, + EngineResult, + RiskLevel, + WyckoffCycle, + WyckoffEvent, + WyckoffPhase, +) + +BULL_CYCLES = { + WyckoffCycle.ACCUMULATION.value, + WyckoffCycle.RE_ACCUMULATION.value, + WyckoffCycle.MARKUP.value, +} +BEAR_CYCLES = { + WyckoffCycle.DISTRIBUTION.value, + WyckoffCycle.RE_DISTRIBUTION.value, + WyckoffCycle.MARKDOWN.value, +} + + +class DecisionEngine: + name = "Decision" + version = "1.0.0" + + def run( + self, + monthly_cycle: EngineResult, + weekly_cycle: EngineResult, + weekly_phase: EngineResult, + weekly_event: EngineResult, + daily_event: EngineResult, + daily_signal: EngineResult, + ) -> EngineResult: + m_cycle = monthly_cycle.payload.get("cycle", WyckoffCycle.UNKNOWN.value) + w_cycle = weekly_cycle.payload.get("cycle", WyckoffCycle.UNKNOWN.value) + w_phase = weekly_phase.payload.get("phase", WyckoffPhase.NONE.value) + w_event = weekly_event.payload.get("current_event", WyckoffEvent.NONE.value) + d_event = daily_event.payload.get("current_event", WyckoffEvent.NONE.value) + + trend_score = float(monthly_cycle.payload.get("trend_score", monthly_cycle.score)) + structure_score = float(weekly_phase.payload.get("structure_score", weekly_phase.score)) + entry_score = float(daily_event.payload.get("entry_score", daily_event.score)) + + overall_score = 0.30 * trend_score + 0.30 * structure_score + 0.40 * entry_score + + reasons: list[str] = [] + warnings: list[str] = [] + alignment = 50.0 + + m_bull = m_cycle in BULL_CYCLES + m_bear = m_cycle in BEAR_CYCLES + w_bull = w_cycle in BULL_CYCLES + d_bullish_event = d_event in { + WyckoffEvent.SPRING.value, + WyckoffEvent.TEST.value, + WyckoffEvent.SOS.value, + WyckoffEvent.LPS.value, + WyckoffEvent.JUMP.value, + WyckoffEvent.BACKUP.value, + } + d_bearish_event = d_event in { + WyckoffEvent.UTAD.value, + WyckoffEvent.SOW.value, + WyckoffEvent.LPSY.value, + } + + # Alignment scoring + if m_bull and w_bull and d_bullish_event: + alignment = 92.0 + reasons.append("✓ 月/周多头结构与日线多头事件一致") + elif m_bull and d_bullish_event: + alignment = 78.0 + reasons.append("✓ 月线支持,日线有入场事件") + if not w_bull: + warnings.append("周线结构未完全确认") + alignment -= 8 + elif m_bear and d_bullish_event: + alignment = 35.0 + reasons.append("✗ 月线派发/下跌,日线弹簧可能只是反弹") + elif m_bear and d_bearish_event: + alignment = 85.0 + reasons.append("✓ 空头多周期一致") + else: + alignment = 55.0 + reasons.append("○ 多周期部分一致,需观察") + + if w_phase in (WyckoffPhase.D.value, WyckoffPhase.E.value) and m_bull: + alignment = min(98.0, alignment + 6) + reasons.append(f"✓ 周线阶段 {w_phase} 结构成熟({w_event})") + active = daily_event.payload.get("active_events") or daily_event.payload.get("recent_events") or [] + if d_event == WyckoffEvent.SPRING.value and len(active) >= 3: + alignment = min(98.0, alignment + 4) + reasons.append("✓ 日线多重事件同时确认") + + # Decision signal — hard gate on monthly bear + daily spring + decision = DecisionSignal.WATCH.value + risk = RiskLevel.MEDIUM.value + + if m_bear and d_event == WyckoffEvent.SPRING.value: + decision = DecisionSignal.WATCH.value + risk = RiskLevel.HIGH.value + overall_score = min(overall_score, 55.0) + reasons.append("→ 决策:观察(月线不支持,禁止追日线弹簧)") + elif m_bear and d_bullish_event: + decision = DecisionSignal.AVOID.value + risk = RiskLevel.HIGH.value + overall_score = min(overall_score, 48.0) + reasons.append("→ 决策:回避(逆大周期多头事件)") + elif ( + m_bull + and w_phase in (WyckoffPhase.D.value, WyckoffPhase.E.value, WyckoffPhase.C.value) + and d_event in (WyckoffEvent.SPRING.value, WyckoffEvent.LPS.value, WyckoffEvent.SOS.value) + and alignment >= 85 + and overall_score >= 80 + ): + decision = DecisionSignal.STRONG_BUY.value + risk = RiskLevel.LOW.value + reasons.append("→ 决策:强烈买入(三级共振)") + elif m_bull and d_bullish_event and overall_score >= 68 and alignment >= 70: + decision = DecisionSignal.BUY.value + risk = RiskLevel.LOW.value if alignment >= 80 else RiskLevel.MEDIUM.value + reasons.append("→ 决策:买入") + elif m_bear and d_bearish_event and overall_score >= 65: + decision = DecisionSignal.SELL.value + risk = RiskLevel.MEDIUM.value + reasons.append("→ 决策:卖出") + else: + decision = DecisionSignal.WATCH.value + reasons.append("→ 决策:观察") + + # Stars from score + alignment + combo = 0.6 * overall_score + 0.4 * alignment + if combo >= 90: + stars = 5 + elif combo >= 80: + stars = 4 + elif combo >= 65: + stars = 3 + elif combo >= 50: + stars = 2 + else: + stars = 1 + + overall_confidence = ( + 0.25 * monthly_cycle.confidence + + 0.25 * weekly_phase.confidence + + 0.25 * daily_event.confidence + + 0.25 * daily_signal.confidence + ) + # Weak event pulls overall down + if daily_event.confidence < 60: + overall_confidence = min(overall_confidence, daily_event.confidence + 15) + + return EngineResult( + name=self.name, + version=self.version, + confidence=overall_confidence, + score=overall_score, + reasons=reasons, + warnings=warnings, + metrics={ + "trend_score": trend_score, + "structure_score": structure_score, + "entry_score": entry_score, + "alignment": alignment, + "stars": stars, + }, + payload={ + "decision_signal": decision, + "alignment": alignment, + "stars": stars, + "risk": risk, + "overall_score": overall_score, + "overall_confidence": overall_confidence, + "trend_score": trend_score, + "structure_score": structure_score, + "entry_score": entry_score, + "m_cycle": m_cycle, + "w_cycle": w_cycle, + "w_phase": w_phase, + "w_event": w_event, + "d_event": d_event, + # Facts preserved — never overwritten + "facts": { + "monthly": {"cycle": m_cycle}, + "weekly": {"cycle": w_cycle, "phase": w_phase, "event": w_event}, + "daily": {"event": d_event}, + }, + }, + ) diff --git a/crypto_wyckoff/domain_models.py b/crypto_wyckoff/domain_models.py new file mode 100644 index 0000000..e441447 --- /dev/null +++ b/crypto_wyckoff/domain_models.py @@ -0,0 +1,154 @@ +"""Wyckoff Screener domain models — Architecture v1.0 frozen contracts.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from datetime import date, datetime +from enum import Enum +from typing import Any, Optional + + +class WyckoffCycle(str, Enum): + ACCUMULATION = "Accumulation" + RE_ACCUMULATION = "ReAccumulation" + MARKUP = "Markup" + DISTRIBUTION = "Distribution" + RE_DISTRIBUTION = "ReDistribution" + MARKDOWN = "Markdown" + UNKNOWN = "Unknown" + + +class WyckoffPhase(str, Enum): + A = "A" + B = "B" + C = "C" + D = "D" + E = "E" + NONE = "None" + + +class WyckoffEvent(str, Enum): + PS = "PS" + SC = "SC" + AR = "AR" + ST = "ST" + SPRING = "Spring" + TEST = "Test" + SOS = "SOS" + LPS = "LPS" + JUMP = "Jump" + BACKUP = "Backup" + BC = "BC" + UTAD = "UTAD" + SOW = "SOW" + LPSY = "LPSY" + NONE = "None" + + +class DecisionSignal(str, Enum): + STRONG_BUY = "StrongBuy" + BUY = "Buy" + WATCH = "Watch" + AVOID = "Avoid" + SELL = "Sell" + + +class RiskLevel(str, Enum): + LOW = "Low" + MEDIUM = "Medium" + HIGH = "High" + + +@dataclass +class EngineResult: + """Unified result envelope for every Wyckoff engine (v1.0 contract).""" + + name: str + version: str = "1.0.0" + confidence: float = 0.0 + score: float = 0.0 + reasons: list[str] = field(default_factory=list) + warnings: list[str] = field(default_factory=list) + metrics: dict[str, Any] = field(default_factory=dict) + payload: dict[str, Any] = field(default_factory=dict) + + def to_dict(self) -> dict[str, Any]: + return { + "name": self.name, + "version": self.version, + "confidence": self.confidence, + "score": self.score, + "reasons": self.reasons, + "warnings": self.warnings, + "metrics": self.metrics, + "payload": self.payload, + } + + +@dataclass +class OHLCVFrame: + """In-memory OHLCV for one symbol one timeframe. Engines never touch DB.""" + + ts_code: str + timeframe: str # "1d" | "1w" | "1M" + trade_dates: list[date] + open: list[float] + high: list[float] + low: list[float] + close: list[float] + volume: list[float] + amount: list[float] = field(default_factory=list) + + def __len__(self) -> int: + return len(self.close) + + @property + def empty(self) -> bool: + return len(self.close) == 0 + + +@dataclass +class WyckoffScanRow: + """Persisted scan row for wyckoff_scan table.""" + + trade_date: date + ts_code: str + name: str = "" + industry: str = "" + engine_version: str = "v1.0.0" + combo_id: str = "d_w_m" + + m_cycle: str = WyckoffCycle.UNKNOWN.value + cycle_confidence: float = 0.0 + trend_score: float = 0.0 + + w_cycle: str = WyckoffCycle.UNKNOWN.value + w_phase: str = WyckoffPhase.NONE.value + w_current_event: str = WyckoffEvent.NONE.value + w_recent_events_json: str = "[]" + phase_confidence: float = 0.0 + structure_score: float = 0.0 + + d_current_event: str = WyckoffEvent.NONE.value + d_recent_events_json: str = "[]" + event_confidence: float = 0.0 + entry_score: float = 0.0 + + entry: Optional[float] = None + stop: Optional[float] = None + target1: Optional[float] = None + target2: Optional[float] = None + rr: Optional[float] = None + + alignment: float = 0.0 + stars: int = 1 + decision_signal: str = DecisionSignal.WATCH.value + signal_confidence: float = 0.0 + overall_confidence: float = 0.0 + overall_score: float = 0.0 + risk: str = RiskLevel.MEDIUM.value + reasons_json: str = "[]" + + feature_snapshot_json: str = "{}" + markers_json: str = "[]" + scanned_at: datetime = field(default_factory=datetime.now) diff --git a/crypto_wyckoff/event.py b/crypto_wyckoff/event.py new file mode 100644 index 0000000..e9c09dc --- /dev/null +++ b/crypto_wyckoff/event.py @@ -0,0 +1,149 @@ +"""Event Engine — active concurrent events via Rule Registry. + +Note: `active_events` are rules that fire on the latest bar snapshot, +NOT a historical SC→AR→ST timeline. Do not present as chronological chain. +""" + +from __future__ import annotations + +from crypto_wyckoff.domain_models import EngineResult, WyckoffEvent +from crypto_wyckoff.rules.registry import rule_registry + +# Display order only (not temporal history) +_DISPLAY_ORDER = [ + WyckoffEvent.PS.value, + WyckoffEvent.SC.value, + WyckoffEvent.AR.value, + WyckoffEvent.ST.value, + WyckoffEvent.SPRING.value, + WyckoffEvent.TEST.value, + WyckoffEvent.SOS.value, + WyckoffEvent.LPS.value, + WyckoffEvent.JUMP.value, + WyckoffEvent.BACKUP.value, + WyckoffEvent.BC.value, + WyckoffEvent.UTAD.value, + WyckoffEvent.SOW.value, + WyckoffEvent.LPSY.value, +] + +# Dominant event: highest confidence wins; ties broken by this priority +_DOMINANCE_PRIORITY = [ + WyckoffEvent.SOS.value, + WyckoffEvent.LPS.value, + WyckoffEvent.UTAD.value, + WyckoffEvent.SPRING.value, + WyckoffEvent.JUMP.value, + WyckoffEvent.BACKUP.value, + WyckoffEvent.TEST.value, + WyckoffEvent.SC.value, + WyckoffEvent.SOW.value, + WyckoffEvent.AR.value, + WyckoffEvent.ST.value, +] + + +class EventEngine: + name = "Event" + version = "1.0.0" + + def run( + self, + cycle: EngineResult, + phase: EngineResult, + feature: EngineResult, + timeframe: str, + ) -> EngineResult: + if feature.payload.get("insufficient"): + return EngineResult( + name=self.name, + version=self.version, + confidence=20.0, + score=30.0, + reasons=["特征不足,跳过事件识别"], + warnings=["insufficient_features"], + payload={ + "current_event": WyckoffEvent.NONE.value, + "active_events": [], + "recent_events": [], # alias for DB/API compat; same as active_events + "timeframe": timeframe, + "entry_score": 30.0, + }, + ) + + context = { + "features": feature.payload, + "cycle": cycle.payload, + "phase": phase.payload, + "timeframe": timeframe, + } + hits = [] + for rule in rule_registry.by_category("event", timeframe): + hit = rule.evaluate(context) + if hit and hit.event: + hits.append(hit) + + if not hits: + return EngineResult( + name=self.name, + version=self.version, + confidence=35.0, + score=40.0, + reasons=["无显著事件"], + payload={ + "current_event": WyckoffEvent.NONE.value, + "active_events": [], + "recent_events": [], + "timeframe": timeframe, + "entry_score": 40.0, + }, + ) + + by_event: dict[str, float] = {} + reasons: list[str] = [] + metrics: dict = {} + for h in hits: + prev = by_event.get(h.event, -1.0) + if h.confidence >= prev: + by_event[h.event] = h.confidence + reasons.extend(h.reasons) + metrics.update(h.metrics) + + active = [e for e in _DISPLAY_ORDER if e in by_event] + for e in by_event: + if e not in active: + active.append(e) + + # Dominant = max confidence; tie-break by dominance priority index + def _dom_key(ev: str) -> tuple: + conf = by_event[ev] + try: + prio = _DOMINANCE_PRIORITY.index(ev) + except ValueError: + prio = 99 + return (conf, -prio) + + current = max(by_event.keys(), key=_dom_key) + event_conf = by_event[current] + co_bonus = min(12.0, max(0, len(active) - 1) * 3) + entry_score = min(98.0, event_conf + co_bonus) + if current == WyckoffEvent.SPRING.value and WyckoffEvent.TEST.value in by_event: + entry_score = min(98.0, entry_score + 5) + + return EngineResult( + name=self.name, + version=self.version, + confidence=event_conf, + score=entry_score, + reasons=list(dict.fromkeys(reasons))[:8], + warnings=["active_events_are_concurrent_not_timeline"], + metrics=metrics, + payload={ + "current_event": current, + "active_events": active, + "recent_events": active, # persisted column name; semantic = active + "event_scores": by_event, + "timeframe": timeframe, + "entry_score": entry_score, + }, + ) diff --git a/crypto_wyckoff/features.py b/crypto_wyckoff/features.py new file mode 100644 index 0000000..69e85c7 --- /dev/null +++ b/crypto_wyckoff/features.py @@ -0,0 +1,206 @@ +"""Feature Engine — pure function over OHLCVFrame → EngineResult(FeatureSnapshot).""" + +from __future__ import annotations + +from typing import Any + +import numpy as np + +from crypto_wyckoff.domain_models import EngineResult, OHLCVFrame + + +def _sma(arr: np.ndarray, n: int) -> float: + if len(arr) < n: + return float(arr[-1]) if len(arr) else 0.0 + return float(np.mean(arr[-n:])) + + +def _atr(high: np.ndarray, low: np.ndarray, close: np.ndarray, n: int = 14) -> float: + if len(close) < 2: + return 0.0 + prev_close = close[:-1] + tr = np.maximum(high[1:] - low[1:], np.maximum(np.abs(high[1:] - prev_close), np.abs(low[1:] - prev_close))) + if len(tr) < n: + return float(np.mean(tr)) if len(tr) else 0.0 + return float(np.mean(tr[-n:])) + + +def _adx(high: np.ndarray, low: np.ndarray, close: np.ndarray, n: int = 14) -> float: + """Simplified ADX approximation.""" + if len(close) < n + 2: + return 15.0 + up = high[1:] - high[:-1] + down = low[:-1] - low[1:] + plus_dm = np.where((up > down) & (up > 0), up, 0.0) + minus_dm = np.where((down > up) & (down > 0), down, 0.0) + tr = np.maximum(high[1:] - low[1:], np.maximum(np.abs(high[1:] - close[:-1]), np.abs(low[1:] - close[:-1]))) + atr = np.mean(tr[-n:]) or 1e-9 + plus_di = 100 * np.mean(plus_dm[-n:]) / atr + minus_di = 100 * np.mean(minus_dm[-n:]) / atr + denom = plus_di + minus_di + if denom < 1e-9: + return 10.0 + dx = 100 * abs(plus_di - minus_di) / denom + return float(min(60.0, dx)) + + +def compute_feature_snapshot(frame: OHLCVFrame) -> dict[str, Any]: + """Compute technical snapshot dict from OHLCV (no I/O).""" + if frame.empty or len(frame) < 5: + return {"ts_code": frame.ts_code, "timeframe": frame.timeframe, "bars": len(frame)} + + close = np.asarray(frame.close, dtype=float) + high = np.asarray(frame.high, dtype=float) + low = np.asarray(frame.low, dtype=float) + volume = np.asarray(frame.volume, dtype=float) + open_ = np.asarray(frame.open, dtype=float) + + ma20 = _sma(close, 20) + ma60 = _sma(close, 60) + ma120 = _sma(close, min(120, len(close))) + atr = _atr(high, low, close, 14) + vol_ma20 = _sma(volume, 20) or 1e-9 + volume_ratio = float(volume[-1] / vol_ma20) + + look = min(60, len(close)) + window_h = high[-look:] + window_l = low[-look:] + range_high = float(np.max(window_h)) + range_low = float(np.min(window_l)) + rng = max(range_high - range_low, 1e-9) + range_pct_60 = float(rng / close[-1]) if close[-1] else 0.0 + range_position = float((close[-1] - range_low) / rng) + + # Spring / UTAD hints + pierce_below = max(0.0, (range_low - low[-1]) / close[-1]) if close[-1] else 0.0 + # if previous bars broke below and last close back in range + prior_low = float(np.min(low[-6:-1])) if len(low) >= 6 else float(low[-2]) + pierce_below = max(pierce_below, max(0.0, (range_low - prior_low) / close[-1])) + close_back_in_range = 1.0 if close[-1] >= range_low else 0.0 + reclaim_speed = 0.0 + if pierce_below > 0 and close[-1] >= range_low: + reclaim_speed = min(1.0, (close[-1] - low[-1]) / max(atr, 1e-9) / 2) + + pierce_above = max(0.0, (high[-1] - range_high) / close[-1]) + fail_back = 1.0 if pierce_above > 0 and close[-1] <= range_high else 0.0 + breakout_above = 1.0 if close[-1] > range_high and volume_ratio >= 1.0 else -1.0 + + # pullback hold: close near ma20 from above after being higher + pullback_hold = 0.0 + if len(close) >= 5 and close[-1] > ma20 and close[-3] > close[-1] and (close[-1] - ma20) / max(atr, 1e-9) < 1.5: + pullback_hold = 0.8 + + ma60_prev = _sma(close[:-5], 60) if len(close) > 65 else ma60 + ma60_slope = (ma60 - ma60_prev) / max(abs(ma60_prev), 1e-9) + + # volume trend: recent 10 vs prior 10 + if len(volume) >= 20: + volume_trend = float(np.mean(volume[-10:]) / (np.mean(volume[-20:-10]) + 1e-9) - 1.0) + else: + volume_trend = 0.0 + + bar_range_atr = float((high[-1] - low[-1]) / max(atr, 1e-9)) + bounce_from_low = float((close[-1] - float(np.min(low[-10:]))) / close[-1]) if close[-1] else 0.0 + gap_up_pct = float((open_[-1] - close[-2]) / close[-2]) if len(close) >= 2 and close[-2] else 0.0 + after_strength = 0.0 + if len(close) >= 4 and close[-3] > close[-4]: + after_strength = 0.7 + + spring_score_hint = 0.0 + if pierce_below >= 0.002 and close_back_in_range: + spring_score_hint = min(90.0, 50 + pierce_below * 1500 + reclaim_speed * 20) + utad_score_hint = min(90.0, 50 + pierce_above * 1500) if pierce_above >= 0.002 and fail_back else 0.0 + + # swing + swing_high = float(np.max(high[-20:])) if len(high) >= 5 else float(high[-1]) + swing_low = float(np.min(low[-20:])) if len(low) >= 5 else float(low[-1]) + + return { + "ts_code": frame.ts_code, + "timeframe": frame.timeframe, + "bars": len(frame), + "close": float(close[-1]), + "open": float(open_[-1]), + "high": float(high[-1]), + "low": float(low[-1]), + "volume": float(volume[-1]), + "ma20": ma20, + "ma60": ma60, + "ma120": ma120, + "ma60_slope": float(ma60_slope), + "atr": atr, + "adx": _adx(high, low, close), + "volume_ma20": float(vol_ma20), + "volume_ratio": volume_ratio, + "volume_trend": volume_trend, + "range_high": range_high, + "range_low": range_low, + "range_pct_60": range_pct_60, + "range_position": range_position, + "pierce_below_range": pierce_below, + "pierce_above_range": pierce_above, + "close_back_in_range": close_back_in_range, + "reclaim_speed": reclaim_speed, + "fail_back_into_range": fail_back, + "breakout_above_range": breakout_above, + "pullback_hold": pullback_hold, + "bar_range_atr": bar_range_atr, + "bounce_from_low": bounce_from_low, + "gap_up_pct": gap_up_pct, + "after_strength": after_strength, + "spring_score_hint": spring_score_hint, + "utad_score_hint": utad_score_hint, + "swing_high": swing_high, + "swing_low": swing_low, + "trade_date": str(frame.trade_dates[-1]) if frame.trade_dates else None, + } + + +# Minimum bars before a timeframe is considered usable (no cross-TF borrow) +_MIN_BARS = {"1d": 40, "1w": 26, "1M": 18} + + +class FeatureEngine: + """Pure Feature Engine — no database access.""" + + name = "Feature" + version = "1.0.0" + + def run(self, frame: OHLCVFrame | None, timeframe: str | None = None) -> EngineResult: + tf = timeframe or (frame.timeframe if frame else "1d") + min_bars = _MIN_BARS.get(tf, 30) + + if frame is None or frame.empty or len(frame) < min_bars: + bars = 0 if frame is None or frame.empty else len(frame) + return EngineResult( + name=self.name, + version=self.version, + confidence=10.0, + score=10.0, + reasons=[f"{tf} bars={bars} < min={min_bars},标记 insufficient"], + warnings=["insufficient_features"], + metrics={"bars": bars, "min_bars": min_bars}, + payload={ + "ts_code": getattr(frame, "ts_code", ""), + "timeframe": tf, + "bars": bars, + "insufficient": True, + }, + ) + + snap = compute_feature_snapshot(frame) + snap["insufficient"] = False + conf = 90.0 if snap.get("bars", 0) >= 60 else 50.0 + min(40.0, snap.get("bars", 0) * 0.5) + warnings = [] + if snap.get("bars", 0) < 60: + warnings.append("bars偏少,特征可靠性中等") + return EngineResult( + name=self.name, + version=self.version, + confidence=conf, + score=conf, + reasons=[f"computed {snap.get('bars', 0)} bars {tf}"], + warnings=warnings, + metrics={"bars": snap.get("bars", 0)}, + payload=snap, + ) diff --git a/crypto_wyckoff/io.py b/crypto_wyckoff/io.py new file mode 100644 index 0000000..5662a01 --- /dev/null +++ b/crypto_wyckoff/io.py @@ -0,0 +1,363 @@ +"""Paths + OHLCV cache + DATA_SERVICE fetch (crypto continuous calendar).""" + +from __future__ import annotations + +import json +import logging +import os +import sqlite3 +import time +from datetime import date, datetime, timezone +from pathlib import Path +from typing import Iterable + +import requests + +from crypto_wyckoff.domain_models import OHLCVFrame + +logger = logging.getLogger(__name__) + +_REPO_ROOT = Path(__file__).resolve().parents[1] +DATA_DIR = Path(os.environ.get("CRYPTO_WYCKOFF_DATA", str(_REPO_ROOT / "data" / "crypto_wyckoff"))) +BARS_DB = DATA_DIR / "bars.sqlite" +SCAN_DB = DATA_DIR / "scan.sqlite" + +DATA_SERVICE_URL = os.environ.get( + "DATA_SERVICE_URL", + os.environ.get("DATASVC_URL", "https://provider.jackyu66.com"), +).rstrip("/") + +# Continuous crypto: bar counts (not A-share weekend-padded calendar multipliers) +# Provider has many TFs; 1M is resampled locally from daily UTC months. +LOOKBACK = { + "1h": 500, + "2h": 400, + "4h": 300, + "6h": 280, + "8h": 250, + "12h": 220, + "1d": 250, + "1w": 104, + "1M": 60, +} +# Default D/W/M stack (kept for compat); combos may request more TFs from provider. +TF_PROVIDER = ("1h", "4h", "8h", "1d", "1w") +TF_LIST = ("1d", "1w", "1M") +LOCAL_ONLY_TFS = frozenset({"1M"}) + + +def ensure_dirs() -> None: + DATA_DIR.mkdir(parents=True, exist_ok=True) + + +def _symbol_key(symbol: str) -> str: + return symbol.replace("/", "_").replace(":", "_") + + +def _bars_conn() -> sqlite3.Connection: + ensure_dirs() + conn = sqlite3.connect(str(BARS_DB), timeout=60) + conn.execute( + """ + CREATE TABLE IF NOT EXISTS bars ( + symbol TEXT NOT NULL, + tf TEXT NOT NULL, + ts INTEGER NOT NULL, + open REAL, high REAL, low REAL, close REAL, volume REAL, + PRIMARY KEY (symbol, tf, ts) + ) + """ + ) + conn.execute("CREATE INDEX IF NOT EXISTS idx_bars_sym_tf ON bars(symbol, tf)") + return conn + + +def fetch_candles( + symbol: str, + tf: str, + *, + limit: int | None = None, + start_ms: int | None = None, + end_ms: int | None = None, + timeout: float = 15.0, +) -> list[dict]: + params: dict = {"symbol": symbol, "tf": tf} + if limit is not None: + params["limit"] = int(limit) + if start_ms is not None: + params["start"] = int(start_ms) + if end_ms is not None: + params["end"] = int(end_ms) + resp = requests.get(f"{DATA_SERVICE_URL}/api/candles", params=params, timeout=timeout) + resp.raise_for_status() + data = resp.json() + if not isinstance(data, list): + return [] + out = [] + for row in data: + try: + ts = int(float(row["timestamp"])) + out.append( + { + "ts": ts, + "open": float(row["open"]), + "high": float(row["high"]), + "low": float(row["low"]), + "close": float(row["close"]), + "volume": float(row.get("volume") or 0), + } + ) + except (KeyError, TypeError, ValueError): + continue + out.sort(key=lambda r: r["ts"]) + return out + + +def upsert_bars(symbol: str, tf: str, rows: list[dict]) -> int: + if not rows: + return 0 + conn = _bars_conn() + try: + conn.executemany( + """ + INSERT INTO bars(symbol, tf, ts, open, high, low, close, volume) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(symbol, tf, ts) DO UPDATE SET + open=excluded.open, high=excluded.high, low=excluded.low, + close=excluded.close, volume=excluded.volume + """, + [ + (symbol, tf, r["ts"], r["open"], r["high"], r["low"], r["close"], r["volume"]) + for r in rows + ], + ) + conn.commit() + return len(rows) + finally: + conn.close() + + +def is_intraday_tf(tf: str) -> bool: + """True for minute/hour TFs that need clock time on charts.""" + t = (tf or "").strip() + return t.endswith("m") or t.endswith("h") + + +def load_bars_with_ts( + symbol: str, tf: str, lookback: int | None = None +) -> list[dict]: + """Return OHLCV rows with UTC ms ts (for chart labels). + + ``datetime`` is wall-clock in Asia/Shanghai (UTC+8) for display. + """ + from zoneinfo import ZoneInfo + + tz_cn = ZoneInfo("Asia/Shanghai") + if lookback is None: + try: + from crypto_wyckoff.combos import lookback_for + + lookback = lookback_for(tf) + except Exception: + lookback = LOOKBACK.get(tf, 100) + lookback = lookback or LOOKBACK.get(tf, 100) + conn = _bars_conn() + try: + cur = conn.execute( + """ + SELECT ts, open, high, low, close, volume FROM bars + WHERE symbol=? AND tf=? + ORDER BY ts DESC LIMIT ? + """, + (symbol, tf, lookback), + ) + rows = list(reversed(cur.fetchall())) + finally: + conn.close() + out = [] + for ts, o, h, l, c, v in rows: + dt_utc = datetime.fromtimestamp(ts / 1000.0, tz=timezone.utc) + dt_cn = dt_utc.astimezone(tz_cn) + out.append( + { + "ts": int(ts), + "datetime": dt_cn.strftime("%Y-%m-%dT%H:%M:%S+08:00"), + "date": dt_cn.strftime("%Y-%m-%d"), + "open": o, + "high": h, + "low": l, + "close": c, + "volume": v, + } + ) + return out + + +def load_frame(symbol: str, tf: str, lookback: int | None = None) -> OHLCVFrame | None: + rows = load_bars_with_ts(symbol, tf, lookback) + if not rows: + return None + return OHLCVFrame( + ts_code=symbol, + timeframe=tf, + trade_dates=[ + datetime.fromtimestamp(r["ts"] / 1000.0, tz=timezone.utc).date() for r in rows + ], + open=[r["open"] for r in rows], + high=[r["high"] for r in rows], + low=[r["low"] for r in rows], + close=[r["close"] for r in rows], + volume=[r["volume"] for r in rows], + ) + + +def bar_count(symbol: str, tf: str) -> int: + conn = _bars_conn() + try: + cur = conn.execute( + "SELECT COUNT(*) FROM bars WHERE symbol=? AND tf=?", (symbol, tf) + ) + return int(cur.fetchone()[0]) + finally: + conn.close() + + +def rebuild_monthly_from_daily(symbol: str) -> int: + """Aggregate UTC calendar-month OHLCV from local daily bars (provider has no 1M).""" + conn = _bars_conn() + try: + cur = conn.execute( + """ + SELECT ts, open, high, low, close, volume FROM bars + WHERE symbol=? AND tf='1d' ORDER BY ts ASC + """, + (symbol,), + ) + daily = cur.fetchall() + finally: + conn.close() + if not daily: + return 0 + + months: dict[tuple[int, int], dict] = {} + for ts, o, h, l, c, v in daily: + dt = datetime.fromtimestamp(ts / 1000.0, tz=timezone.utc) + key = (dt.year, dt.month) + # month bar open timestamp = first day 00:00 UTC + month_ts = int(datetime(dt.year, dt.month, 1, tzinfo=timezone.utc).timestamp() * 1000) + if key not in months: + months[key] = { + "ts": month_ts, + "open": o, + "high": h, + "low": l, + "close": c, + "volume": v or 0.0, + } + else: + m = months[key] + m["high"] = max(m["high"], h) + m["low"] = min(m["low"], l) + m["close"] = c + m["volume"] = (m["volume"] or 0) + (v or 0) + + rows = sorted(months.values(), key=lambda r: r["ts"]) + # drop stale months then upsert + conn = _bars_conn() + try: + conn.execute("DELETE FROM bars WHERE symbol=? AND tf='1M'", (symbol,)) + conn.commit() + finally: + conn.close() + return upsert_bars(symbol, "1M", rows) + + +def backfill_symbol(symbol: str, tfs: Iterable[str] = TF_LIST) -> dict: + """Pull history for requested TFs; monthly derived from daily when needed.""" + wanted = list(dict.fromkeys(tfs)) + stats: dict = {} + need_monthly = "1M" in wanted + if need_monthly and "1d" not in wanted: + wanted = ["1d", *wanted] + + for tf in wanted: + if tf in LOCAL_ONLY_TFS: + continue + need = LOOKBACK.get(tf, 100) + if tf == "1d" and need_monthly: + need = max(need, LOOKBACK["1M"] * 31) + try: + rows = fetch_candles(symbol, tf, limit=need) + n = upsert_bars(symbol, tf, rows) + stats[tf] = n + except Exception as e: + logger.warning("backfill %s %s failed: %s", symbol, tf, e) + stats[tf] = 0 + time.sleep(0.05) + + if need_monthly: + try: + stats["1M"] = rebuild_monthly_from_daily(symbol) + except Exception as e: + logger.warning("monthly rebuild %s failed: %s", symbol, e) + stats["1M"] = 0 + return stats + + +def tip_update_symbol(symbol: str, tfs: Iterable[str] = TF_LIST) -> bool: + """Update forming tip bars (limit=3). Returns True if any bar changed.""" + wanted = list(dict.fromkeys(tfs)) + changed = False + for tf in wanted: + if tf in LOCAL_ONLY_TFS: + continue + try: + rows = fetch_candles(symbol, tf, limit=3) + if not rows: + continue + before = _tip_fingerprint(symbol, tf) + upsert_bars(symbol, tf, rows) + after = _tip_fingerprint(symbol, tf) + if before != after: + changed = True + except Exception as e: + logger.debug("tip %s %s: %s", symbol, tf, e) + time.sleep(0.02) + if "1M" in wanted: + before_m = _tip_fingerprint(symbol, "1M") + try: + rebuild_monthly_from_daily(symbol) + except Exception as e: + logger.debug("monthly tip %s: %s", symbol, e) + after_m = _tip_fingerprint(symbol, "1M") + if before_m != after_m: + changed = True + return changed + + +def _tip_fingerprint(symbol: str, tf: str) -> tuple | None: + conn = _bars_conn() + try: + cur = conn.execute( + """ + SELECT ts, open, high, low, close, volume FROM bars + WHERE symbol=? AND tf=? ORDER BY ts DESC LIMIT 1 + """, + (symbol, tf), + ) + row = cur.fetchone() + return tuple(row) if row else None + finally: + conn.close() + + +def fetch_symbols_from_provider() -> list[str]: + try: + resp = requests.get(f"{DATA_SERVICE_URL}/health", timeout=8) + resp.raise_for_status() + payload = resp.json() + symbols = payload.get("symbols") or payload.get("symbol_list") or [] + return [s for s in symbols if isinstance(s, str)] + except Exception as e: + logger.warning("health symbols failed: %s", e) + return [] diff --git a/crypto_wyckoff/phase.py b/crypto_wyckoff/phase.py new file mode 100644 index 0000000..be78050 --- /dev/null +++ b/crypto_wyckoff/phase.py @@ -0,0 +1,78 @@ +"""Phase Engine — Phase A–E via Rule Registry.""" + +from __future__ import annotations + +from crypto_wyckoff.domain_models import EngineResult, WyckoffPhase +from crypto_wyckoff.rules.registry import rule_registry + + +class PhaseEngine: + name = "Phase" + version = "1.0.0" + + def run(self, cycle: EngineResult, feature: EngineResult, timeframe: str) -> EngineResult: + if feature.payload.get("insufficient") or cycle.payload.get("cycle") == "Unknown": + return EngineResult( + name=self.name, + version=self.version, + confidence=20.0, + score=30.0, + reasons=["数据/周期不足,Phase=None"], + warnings=["insufficient_features"], + payload={ + "phase": WyckoffPhase.NONE.value, + "timeframe": timeframe, + "cycle": cycle.payload.get("cycle"), + "structure_score": 30.0, + }, + ) + + context = { + "features": feature.payload, + "cycle": cycle.payload, + "timeframe": timeframe, + } + hits = [] + for rule in rule_registry.by_category("phase", timeframe): + hit = rule.evaluate(context) + if hit and hit.phase: + hits.append(hit) + + if not hits: + return EngineResult( + name=self.name, + version=self.version, + confidence=40.0, + score=cycle.score * 0.5, + reasons=["未识别明确 Phase"], + payload={ + "phase": WyckoffPhase.NONE.value, + "timeframe": timeframe, + "cycle": cycle.payload.get("cycle"), + "structure_score": cycle.score * 0.5, + }, + ) + + best = max(hits, key=lambda h: h.confidence) + structure_score = best.score + # Phase D/E stronger structure + if best.phase in (WyckoffPhase.D.value, WyckoffPhase.E.value): + structure_score = max(structure_score, 80.0) + elif best.phase == WyckoffPhase.C.value: + structure_score = max(structure_score, 72.0) + + return EngineResult( + name=self.name, + version=self.version, + confidence=best.confidence, + score=structure_score, + reasons=best.reasons, + metrics=best.metrics, + payload={ + "phase": best.phase, + "timeframe": timeframe, + "cycle": cycle.payload.get("cycle"), + "rule_id": best.rule_id, + "structure_score": structure_score, + }, + ) diff --git a/crypto_wyckoff/pipeline.py b/crypto_wyckoff/pipeline.py new file mode 100644 index 0000000..41f9968 --- /dev/null +++ b/crypto_wyckoff/pipeline.py @@ -0,0 +1,181 @@ +"""Scan pipeline: load local frames → engines → store (per TF combo).""" + +from __future__ import annotations + +import json +import logging +from datetime import date, datetime, timezone + +from crypto_wyckoff.combos import ROLE_HIGH, ROLE_LOW, ROLE_MID, get_combo, lookback_for +from crypto_wyckoff.cycle import CycleEngine +from crypto_wyckoff.decision import DecisionEngine +from crypto_wyckoff.domain_models import WyckoffScanRow +from crypto_wyckoff.event import EventEngine +from crypto_wyckoff.features import FeatureEngine +from crypto_wyckoff.io import load_frame +from crypto_wyckoff.phase import PhaseEngine +from crypto_wyckoff.plan import PlanEngine +from crypto_wyckoff.signal import SignalEngine +from crypto_wyckoff.store import upsert_row +from crypto_wyckoff.symbols_cn import display_name_cn +from crypto_wyckoff.version import WYCKOFF_ENGINE_VERSION + +logger = logging.getLogger(__name__) + + +def analyze_symbol( + low_frame, + mid_frame, + high_frame, + *, + feature_eng: FeatureEngine, + cycle_eng: CycleEngine, + phase_eng: PhaseEngine, + event_eng: EventEngine, + signal_eng: SignalEngine, + decision_eng: DecisionEngine, + plan_eng: PlanEngine, +) -> dict: + """Run engines with D/W/M *role* aliases so existing rules match. + + Frames may be any TF combo (e.g. 1h/4h/8h); rules still see 1d/1w/1M roles. + """ + f_d = feature_eng.run(low_frame, ROLE_LOW) + f_w = feature_eng.run(mid_frame, ROLE_MID) + f_m = feature_eng.run(high_frame, ROLE_HIGH) + + c_m = cycle_eng.run(f_m, ROLE_HIGH) + c_w = cycle_eng.run(f_w, ROLE_MID) + + p_w = phase_eng.run(c_w, f_w, ROLE_MID) + p_d = phase_eng.run(c_w, f_d, ROLE_LOW) + + e_w = event_eng.run(c_w, p_w, f_w, ROLE_MID) + e_d = event_eng.run(c_w, p_d, f_d, ROLE_LOW) + + s_d = signal_eng.run(e_d, p_d) + decision = decision_eng.run(c_m, c_w, p_w, e_w, e_d, s_d) + plan = plan_eng.run(f_d, decision) + + return { + "f_d": f_d, "f_w": f_w, "f_m": f_m, + "c_m": c_m, "c_w": c_w, "p_w": p_w, + "e_w": e_w, "e_d": e_d, "s_d": s_d, + "decision": decision, "plan": plan, + } + + +def _to_row( + trade_date: date, + symbol: str, + result: dict, + *, + combo_id: str, + combo_label: str, +) -> WyckoffScanRow: + d = result["decision"] + p = result["plan"] + c_m, c_w, p_w = result["c_m"], result["c_w"], result["p_w"] + e_w, e_d, s_d = result["e_w"], result["e_d"], result["s_d"] + f_d, f_w, f_m = result["f_d"], result["f_w"], result["f_m"] + + snapshot = { + "combo_id": combo_id, + "combo_label": combo_label, + "daily": {k: f_d.payload.get(k) for k in ( + "ma20", "ma60", "ma120", "atr", "adx", "volume_ratio", + "range_high", "range_low", "swing_high", "swing_low", "close", + )}, + "weekly": {k: f_w.payload.get(k) for k in ("ma20", "ma60", "adx", "close")}, + "monthly": {k: f_m.payload.get(k) for k in ("ma20", "ma60", "adx", "close")}, + } + markers = [] + for key, typ in (("entry", "entry"), ("stop", "stop"), ("target1", "target1"), ("target2", "target2")): + if p.payload.get(key) is not None: + markers.append({"type": typ, "price": p.payload[key]}) + + return WyckoffScanRow( + trade_date=trade_date, + ts_code=symbol, + name=display_name_cn(symbol), + industry="crypto", + engine_version=WYCKOFF_ENGINE_VERSION, + m_cycle=c_m.payload.get("cycle", "Unknown"), + cycle_confidence=c_m.confidence, + trend_score=float(d.payload.get("trend_score", c_m.score)), + w_cycle=c_w.payload.get("cycle", "Unknown"), + w_phase=p_w.payload.get("phase", "None"), + w_current_event=e_w.payload.get("current_event", "None"), + w_recent_events_json=json.dumps( + e_w.payload.get("active_events") or e_w.payload.get("recent_events") or [], + ensure_ascii=False, + ), + phase_confidence=p_w.confidence, + structure_score=float(d.payload.get("structure_score", p_w.score)), + d_current_event=e_d.payload.get("current_event", "None"), + d_recent_events_json=json.dumps( + e_d.payload.get("active_events") or e_d.payload.get("recent_events") or [], + ensure_ascii=False, + ), + event_confidence=e_d.confidence, + entry_score=float(d.payload.get("entry_score", e_d.score)), + entry=p.payload.get("entry"), + stop=p.payload.get("stop"), + target1=p.payload.get("target1"), + target2=p.payload.get("target2"), + rr=p.payload.get("rr"), + alignment=float(d.payload.get("alignment", 0)), + stars=int(d.payload.get("stars", 1)), + decision_signal=d.payload.get("decision_signal", "Watch"), + signal_confidence=s_d.confidence, + overall_confidence=float(d.payload.get("overall_confidence", d.confidence)), + overall_score=float(d.payload.get("overall_score", d.score)), + risk=d.payload.get("risk", "Medium"), + reasons_json=json.dumps(d.reasons + d.warnings, ensure_ascii=False), + feature_snapshot_json=json.dumps(snapshot, ensure_ascii=False), + markers_json=json.dumps(markers, ensure_ascii=False), + scanned_at=datetime.now(timezone.utc), + combo_id=combo_id, + ) + + +_ENGINES = None + + +def _engines(): + global _ENGINES + if _ENGINES is None: + _ENGINES = { + "feature_eng": FeatureEngine(), + "cycle_eng": CycleEngine(), + "phase_eng": PhaseEngine(), + "event_eng": EventEngine(), + "signal_eng": SignalEngine(), + "decision_eng": DecisionEngine(), + "plan_eng": PlanEngine(), + } + return _ENGINES + + +def analyze_and_store( + symbol: str, + trade_date: date | None = None, + *, + combo_id: str | None = None, +) -> WyckoffScanRow | None: + eng = _engines() + combo = get_combo(combo_id) + low_tf, mid_tf, high_tf = combo["low"], combo["mid"], combo["high"] + + low = load_frame(symbol, low_tf, lookback_for(low_tf)) + mid = load_frame(symbol, mid_tf, lookback_for(mid_tf)) + high = load_frame(symbol, high_tf, lookback_for(high_tf)) + if low is None or len(low) < 40: + return None + result = analyze_symbol(low, mid, high, **eng) + td = trade_date or ( + low.trade_dates[-1] if low.trade_dates else datetime.now(timezone.utc).date() + ) + row = _to_row(td, symbol, result, combo_id=combo["id"], combo_label=combo["label"]) + upsert_row(row) + return row diff --git a/crypto_wyckoff/plan.py b/crypto_wyckoff/plan.py new file mode 100644 index 0000000..ed113fc --- /dev/null +++ b/crypto_wyckoff/plan.py @@ -0,0 +1,78 @@ +"""Plan Engine — Entry / Stop / Target / RR only when Decision is tradable.""" + +from __future__ import annotations + +from crypto_wyckoff.domain_models import DecisionSignal, EngineResult + + +_TRADABLE = { + DecisionSignal.STRONG_BUY.value, + DecisionSignal.BUY.value, + DecisionSignal.SELL.value, +} + + +class PlanEngine: + name = "Plan" + version = "1.0.0" + + def run(self, daily_feature: EngineResult, decision: EngineResult) -> EngineResult: + f = daily_feature.payload + close = float(f.get("close") or 0) + atr = float(f.get("atr") or 0) or close * 0.02 + swing_low = float(f.get("swing_low") or close - 2 * atr) + swing_high = float(f.get("swing_high") or close + 2 * atr) + range_high = float(f.get("range_high") or swing_high) + signal = decision.payload.get("decision_signal", DecisionSignal.WATCH.value) + + entry = stop = t1 = t2 = rr = None + reasons: list[str] = [] + + if signal not in _TRADABLE or close <= 0: + reasons.append(f"无交易计划(信号={signal})") + return EngineResult( + name=self.name, + version=self.version, + confidence=decision.confidence, + score=decision.score, + reasons=reasons, + payload={ + "entry": None, + "stop": None, + "target1": None, + "target2": None, + "rr": None, + }, + ) + + if signal in (DecisionSignal.STRONG_BUY.value, DecisionSignal.BUY.value): + entry = round(close, 4) + stop = round(min(swing_low, close - 1.5 * atr), 4) + risk = max(entry - stop, 1e-6) + t1 = round(entry + 2.0 * risk, 4) + t2 = round(max(range_high, entry + 3.0 * risk), 4) + rr = round((t1 - entry) / risk, 2) + reasons.append(f"入场={entry} 止损={stop} 目标一={t1} 盈亏比={rr}") + else: # Sell + entry = round(close, 4) + stop = round(max(swing_high, close + 1.5 * atr), 4) + risk = max(stop - entry, 1e-6) + t1 = round(entry - 2.0 * risk, 4) + t2 = round(entry - 3.0 * risk, 4) + rr = round((entry - t1) / risk, 2) + reasons.append(f"做空计划 入场={entry} 止损={stop} 目标一={t1}") + + return EngineResult( + name=self.name, + version=self.version, + confidence=decision.confidence, + score=decision.score, + reasons=reasons, + payload={ + "entry": entry, + "stop": stop, + "target1": t1, + "target2": t2, + "rr": rr, + }, + ) diff --git a/crypto_wyckoff/rules/__init__.py b/crypto_wyckoff/rules/__init__.py new file mode 100644 index 0000000..88d3abc --- /dev/null +++ b/crypto_wyckoff/rules/__init__.py @@ -0,0 +1,3 @@ +from crypto_wyckoff.rules.registry import rule_registry + +__all__ = ["rule_registry"] diff --git a/crypto_wyckoff/rules/base.py b/crypto_wyckoff/rules/base.py new file mode 100644 index 0000000..2060660 --- /dev/null +++ b/crypto_wyckoff/rules/base.py @@ -0,0 +1,33 @@ +"""Rule protocol for Wyckoff Rule Registry.""" + +from __future__ import annotations + +from abc import ABC, abstractmethod +from dataclasses import dataclass, field +from typing import Any + + +@dataclass +class RuleHit: + """A single rule match.""" + + rule_id: str + event: str | None = None + phase: str | None = None + cycle: str | None = None + confidence: float = 0.0 + score: float = 0.0 + reasons: list[str] = field(default_factory=list) + metrics: dict[str, Any] = field(default_factory=dict) + + +class WyckoffRule(ABC): + """Pluggable rule. Engines iterate registry; never hardcode rule lists.""" + + rule_id: str + category: str # cycle | phase | event + timeframes: tuple[str, ...] = ("1d", "1w", "1M") + + @abstractmethod + def evaluate(self, context: dict[str, Any]) -> RuleHit | None: + """Return RuleHit if matched, else None. Pure — no I/O.""" diff --git a/crypto_wyckoff/rules/cycle_rules.py b/crypto_wyckoff/rules/cycle_rules.py new file mode 100644 index 0000000..8ff1545 --- /dev/null +++ b/crypto_wyckoff/rules/cycle_rules.py @@ -0,0 +1,126 @@ +"""Cycle classification rules (monthly / weekly).""" + +from __future__ import annotations + +from typing import Any + +from crypto_wyckoff.domain_models import WyckoffCycle +from crypto_wyckoff.rules.base import RuleHit, WyckoffRule + + +def _f(ctx: dict[str, Any], key: str, default: float = 0.0) -> float: + v = ctx.get("features", {}).get(key, default) + try: + return float(v) if v is not None else default + except (TypeError, ValueError): + return default + + +class MarkupCycleRule(WyckoffRule): + rule_id = "cycle_markup" + category = "cycle" + timeframes = ("1M", "1w") + + def evaluate(self, context: dict[str, Any]) -> RuleHit | None: + close = _f(context, "close") + ma20 = _f(context, "ma20") + ma60 = _f(context, "ma60") + ma120 = _f(context, "ma120") + adx = _f(context, "adx") + slope = _f(context, "ma60_slope") + if close > ma20 > ma60 and (ma60 >= ma120 or slope > 0) and adx >= 18: + conf = min(95.0, 55 + adx + (10 if close > ma120 else 0)) + return RuleHit( + rule_id=self.rule_id, + cycle=WyckoffCycle.MARKUP.value, + confidence=conf, + score=conf, + reasons=["价格位于均线多头排列", f"ADX={adx:.1f}"], + metrics={"adx": adx, "slope": slope}, + ) + return None + + +class MarkdownCycleRule(WyckoffRule): + rule_id = "cycle_markdown" + category = "cycle" + timeframes = ("1M", "1w") + + def evaluate(self, context: dict[str, Any]) -> RuleHit | None: + close = _f(context, "close") + ma20 = _f(context, "ma20") + ma60 = _f(context, "ma60") + ma120 = _f(context, "ma120") + adx = _f(context, "adx") + slope = _f(context, "ma60_slope") + if close < ma20 < ma60 and (ma60 <= ma120 or slope < 0) and adx >= 18: + conf = min(95.0, 55 + adx + (10 if close < ma120 else 0)) + return RuleHit( + rule_id=self.rule_id, + cycle=WyckoffCycle.MARKDOWN.value, + confidence=conf, + score=conf, + reasons=["价格位于均线空头排列", f"ADX={adx:.1f}"], + metrics={"adx": adx}, + ) + return None + + +class AccumulationCycleRule(WyckoffRule): + rule_id = "cycle_accumulation" + category = "cycle" + timeframes = ("1M", "1w") + + def evaluate(self, context: dict[str, Any]) -> RuleHit | None: + adx = _f(context, "adx") + range_pct = _f(context, "range_pct_60") + close = _f(context, "close") + ma120 = _f(context, "ma120") + vol_trend = _f(context, "volume_trend") + # Range-bound after decline: strictly at/below MA120 (mutually exclusive vs Distribution) + if adx < 22 and range_pct < 0.28 and close <= ma120: + conf = 60 + (10 if vol_trend > 0 else 0) + (10 if close < ma120 else 0) + return RuleHit( + rule_id=self.rule_id, + cycle=WyckoffCycle.ACCUMULATION.value, + confidence=min(90.0, conf), + score=min(90.0, conf), + reasons=["低趋势强度区间震荡", "疑似吸筹区间"], + metrics={"adx": adx, "range_pct_60": range_pct}, + ) + return None + + +class DistributionCycleRule(WyckoffRule): + rule_id = "cycle_distribution" + category = "cycle" + timeframes = ("1M", "1w") + + def evaluate(self, context: dict[str, Any]) -> RuleHit | None: + adx = _f(context, "adx") + range_pct = _f(context, "range_pct_60") + close = _f(context, "close") + ma120 = _f(context, "ma120") + vol_trend = _f(context, "volume_trend") + # Range-bound near highs: strictly above MA120 (mutually exclusive vs Accumulation) + if adx < 22 and range_pct < 0.28 and close > ma120: + conf = 60 + (10 if vol_trend < 0 else 0) + (10 if close > ma120 else 0) + return RuleHit( + rule_id=self.rule_id, + cycle=WyckoffCycle.DISTRIBUTION.value, + confidence=min(90.0, conf), + score=min(90.0, conf), + reasons=["高位低趋势震荡", "疑似派发区间"], + metrics={"adx": adx, "range_pct_60": range_pct}, + ) + return None + + +def build_rules() -> list[WyckoffRule]: + # Order: trend cycles first (more decisive), then range cycles + return [ + MarkupCycleRule(), + MarkdownCycleRule(), + AccumulationCycleRule(), + DistributionCycleRule(), + ] diff --git a/crypto_wyckoff/rules/event_rules.py b/crypto_wyckoff/rules/event_rules.py new file mode 100644 index 0000000..2490359 --- /dev/null +++ b/crypto_wyckoff/rules/event_rules.py @@ -0,0 +1,254 @@ +"""Event rules: Spring/SOS/LPS/UTAD/SC/AR/ST/...""" + +from __future__ import annotations + +from typing import Any + +from crypto_wyckoff.domain_models import WyckoffCycle, WyckoffEvent, WyckoffPhase +from crypto_wyckoff.rules.base import RuleHit, WyckoffRule + + +def _f(ctx: dict[str, Any], key: str, default: float = 0.0) -> float: + v = ctx.get("features", {}).get(key, default) + try: + return float(v) if v is not None else default + except (TypeError, ValueError): + return default + + +def _cycle(ctx: dict[str, Any]) -> str: + return (ctx.get("cycle") or {}).get("cycle") or "" + + +def _phase(ctx: dict[str, Any]) -> str: + return (ctx.get("phase") or {}).get("phase") or "" + + +class SpringRule(WyckoffRule): + rule_id = "event_spring" + category = "event" + timeframes = ("1d",) + + def evaluate(self, context: dict[str, Any]) -> RuleHit | None: + cycle = _cycle(context) + if cycle not in (WyckoffCycle.ACCUMULATION.value, WyckoffCycle.RE_ACCUMULATION.value, + WyckoffCycle.MARKUP.value): + # Allow spring only in accumulative contexts; Decision will filter MTF + if cycle == WyckoffCycle.DISTRIBUTION.value: + pass # still detect for facts but lower confidence + pierce = _f(context, "pierce_below_range") + reclaim = _f(context, "reclaim_speed") + vol_ratio = _f(context, "volume_ratio") + close_in_range = _f(context, "close_back_in_range") + if pierce >= 0.002 and close_in_range >= 0.5 and reclaim >= 0.3: + strength = min(98.0, 50 + pierce * 2000 + reclaim * 20 + (15 if vol_ratio < 1.2 else 5)) + return RuleHit( + rule_id=self.rule_id, + event=WyckoffEvent.SPRING.value, + confidence=strength, + score=strength, + reasons=[ + f"跌破区间后收回 (pierce={pierce:.3%})", + f"回收速度={reclaim:.2f}", + f"量比={vol_ratio:.2f}", + ], + metrics={"pierce": pierce, "reclaim": reclaim, "volume_ratio": vol_ratio}, + ) + return None + + +class TestRule(WyckoffRule): + rule_id = "event_test" + category = "event" + timeframes = ("1d", "1w") + + def evaluate(self, context: dict[str, Any]) -> RuleHit | None: + pos = _f(context, "range_position") + vol_ratio = _f(context, "volume_ratio") + near_low = pos < 0.2 + if near_low and vol_ratio < 0.85: + return RuleHit( + rule_id=self.rule_id, + event=WyckoffEvent.TEST.value, + confidence=68.0, + score=65.0, + reasons=["低位缩量回测"], + ) + return None + + +class SOSRule(WyckoffRule): + rule_id = "event_sos" + category = "event" + timeframes = ("1d", "1w") + + def evaluate(self, context: dict[str, Any]) -> RuleHit | None: + breakout = _f(context, "breakout_above_range") + vol_ratio = _f(context, "volume_ratio") + close = _f(context, "close") + ma20 = _f(context, "ma20") + if breakout >= 0.0 and vol_ratio >= 1.2 and close > ma20: + conf = min(95.0, 70 + vol_ratio * 8) + return RuleHit( + rule_id=self.rule_id, + event=WyckoffEvent.SOS.value, + confidence=conf, + score=conf, + reasons=["放量突破区间上沿 (SOS)"], + metrics={"vol_ratio": vol_ratio}, + ) + return None + + +class LPSRule(WyckoffRule): + rule_id = "event_lps" + category = "event" + timeframes = ("1d", "1w") + + def evaluate(self, context: dict[str, Any]) -> RuleHit | None: + # Pullback hold above broken range / MA20 after prior strength + pullback = _f(context, "pullback_hold") + vol_ratio = _f(context, "volume_ratio") + above_ma = _f(context, "close") > _f(context, "ma20") + if pullback >= 0.5 and above_ma and vol_ratio <= 1.1: + return RuleHit( + rule_id=self.rule_id, + event=WyckoffEvent.LPS.value, + confidence=74.0, + score=76.0, + reasons=["突破后缩量回踩支撑 (LPS)"], + ) + return None + + +class SCRule(WyckoffRule): + rule_id = "event_sc" + category = "event" + timeframes = ("1w", "1d") + + def evaluate(self, context: dict[str, Any]) -> RuleHit | None: + vol_ratio = _f(context, "volume_ratio") + bar_range = _f(context, "bar_range_atr") + pos = _f(context, "range_position") + if vol_ratio >= 1.8 and bar_range >= 1.5 and pos < 0.35: + return RuleHit( + rule_id=self.rule_id, + event=WyckoffEvent.SC.value, + confidence=72.0, + score=70.0, + reasons=["低位放量宽幅,疑似 Selling Climax"], + ) + return None + + +class ARRule(WyckoffRule): + rule_id = "event_ar" + category = "event" + timeframes = ("1w", "1d") + + def evaluate(self, context: dict[str, Any]) -> RuleHit | None: + # Automatic rally: bounce from lows + bounce = _f(context, "bounce_from_low") + if bounce >= 0.04: + return RuleHit( + rule_id=self.rule_id, + event=WyckoffEvent.AR.value, + confidence=65.0, + score=62.0, + reasons=["低点后自动反弹 (AR)"], + ) + return None + + +class STRule(WyckoffRule): + rule_id = "event_st" + category = "event" + timeframes = ("1w", "1d") + + def evaluate(self, context: dict[str, Any]) -> RuleHit | None: + pos = _f(context, "range_position") + vol_ratio = _f(context, "volume_ratio") + if 0.15 < pos < 0.45 and vol_ratio < 1.0: + return RuleHit( + rule_id=self.rule_id, + event=WyckoffEvent.ST.value, + confidence=60.0, + score=58.0, + reasons=["次级测试 (ST)"], + ) + return None + + +class UTADRule(WyckoffRule): + rule_id = "event_utad" + category = "event" + timeframes = ("1w", "1d") + + def evaluate(self, context: dict[str, Any]) -> RuleHit | None: + cycle = _cycle(context) + pierce_up = _f(context, "pierce_above_range") + fail = _f(context, "fail_back_into_range") + if cycle in (WyckoffCycle.DISTRIBUTION.value, WyckoffCycle.RE_DISTRIBUTION.value, + WyckoffCycle.MARKUP.value): + if pierce_up >= 0.002 and fail >= 0.5: + return RuleHit( + rule_id=self.rule_id, + event=WyckoffEvent.UTAD.value, + confidence=76.0, + score=74.0, + reasons=["冲高失败回到区间 (UTAD)"], + ) + return None + + +class JumpRule(WyckoffRule): + rule_id = "event_jump" + category = "event" + timeframes = ("1d",) + + def evaluate(self, context: dict[str, Any]) -> RuleHit | None: + gap = _f(context, "gap_up_pct") + vol_ratio = _f(context, "volume_ratio") + if gap >= 0.03 and vol_ratio >= 1.3: + return RuleHit( + rule_id=self.rule_id, + event=WyckoffEvent.JUMP.value, + confidence=70.0, + score=72.0, + reasons=["放量向上跳跃 (Jump)"], + ) + return None + + +class BackupRule(WyckoffRule): + rule_id = "event_backup" + category = "event" + timeframes = ("1d",) + + def evaluate(self, context: dict[str, Any]) -> RuleHit | None: + pullback = _f(context, "pullback_hold") + after_jump = _f(context, "after_strength") + if after_jump >= 0.5 and pullback >= 0.5: + return RuleHit( + rule_id=self.rule_id, + event=WyckoffEvent.BACKUP.value, + confidence=68.0, + score=70.0, + reasons=["跳跃后回踩 (Backup)"], + ) + return None + + +def build_rules() -> list[WyckoffRule]: + return [ + SpringRule(), + UTADRule(), + SOSRule(), + LPSRule(), + SCRule(), + JumpRule(), + BackupRule(), + TestRule(), + ARRule(), + STRule(), + ] diff --git a/crypto_wyckoff/rules/phase_rules.py b/crypto_wyckoff/rules/phase_rules.py new file mode 100644 index 0000000..b32c100 --- /dev/null +++ b/crypto_wyckoff/rules/phase_rules.py @@ -0,0 +1,163 @@ +"""Phase A–E rules (primarily weekly).""" + +from __future__ import annotations + +from typing import Any + +from crypto_wyckoff.domain_models import WyckoffCycle, WyckoffPhase +from crypto_wyckoff.rules.base import RuleHit, WyckoffRule + + +def _f(ctx: dict[str, Any], key: str, default: float = 0.0) -> float: + v = ctx.get("features", {}).get(key, default) + try: + return float(v) if v is not None else default + except (TypeError, ValueError): + return default + + +def _cycle(ctx: dict[str, Any]) -> str: + return (ctx.get("cycle") or {}).get("cycle") or WyckoffCycle.UNKNOWN.value + + +class PhaseARule(WyckoffRule): + rule_id = "phase_a" + category = "phase" + timeframes = ("1w", "1d") + + def evaluate(self, context: dict[str, Any]) -> RuleHit | None: + cycle = _cycle(context) + if cycle not in (WyckoffCycle.ACCUMULATION.value, WyckoffCycle.DISTRIBUTION.value, + WyckoffCycle.RE_ACCUMULATION.value, WyckoffCycle.RE_DISTRIBUTION.value): + return None + # Stopping action: high vol + large range recently, still range-bound + vol_ratio = _f(context, "volume_ratio") + range_last = _f(context, "bar_range_atr") + if vol_ratio >= 1.4 and range_last >= 1.2: + return RuleHit( + rule_id=self.rule_id, + phase=WyckoffPhase.A.value, + confidence=70.0, + score=65.0, + reasons=["放量宽幅波动,疑似 Phase A 停止行为"], + ) + return None + + +class PhaseBRule(WyckoffRule): + rule_id = "phase_b" + category = "phase" + timeframes = ("1w", "1d") + + def evaluate(self, context: dict[str, Any]) -> RuleHit | None: + cycle = _cycle(context) + if cycle not in (WyckoffCycle.ACCUMULATION.value, WyckoffCycle.DISTRIBUTION.value): + return None + adx = _f(context, "adx") + range_pct = _f(context, "range_pct_60") + pos = _f(context, "range_position") # 0=low 1=high of range + if adx < 20 and 0.25 < pos < 0.75 and range_pct < 0.30: + return RuleHit( + rule_id=self.rule_id, + phase=WyckoffPhase.B.value, + confidence=72.0, + score=68.0, + reasons=["区间中部震荡,疑似 Phase B 建仓/派发"], + ) + return None + + +class PhaseCRule(WyckoffRule): + rule_id = "phase_c" + category = "phase" + timeframes = ("1w", "1d") + + def evaluate(self, context: dict[str, Any]) -> RuleHit | None: + cycle = _cycle(context) + pos = _f(context, "range_position") + spring_like = _f(context, "spring_score_hint") + utad_like = _f(context, "utad_score_hint") + if cycle in (WyckoffCycle.ACCUMULATION.value, WyckoffCycle.RE_ACCUMULATION.value): + if pos < 0.25 or spring_like >= 50: + return RuleHit( + rule_id=self.rule_id, + phase=WyckoffPhase.C.value, + confidence=75.0 + min(15.0, spring_like * 0.15), + score=78.0, + reasons=["区间低位测试,疑似 Phase C (Spring/Test)"], + ) + if cycle in (WyckoffCycle.DISTRIBUTION.value, WyckoffCycle.RE_DISTRIBUTION.value): + if pos > 0.75 or utad_like >= 50: + return RuleHit( + rule_id=self.rule_id, + phase=WyckoffPhase.C.value, + confidence=75.0, + score=78.0, + reasons=["区间高位测试,疑似 Phase C (UTAD)"], + ) + return None + + +class PhaseDRule(WyckoffRule): + rule_id = "phase_d" + category = "phase" + timeframes = ("1w", "1d") + + def evaluate(self, context: dict[str, Any]) -> RuleHit | None: + cycle = _cycle(context) + close = _f(context, "close") + ma20 = _f(context, "ma20") + range_high = _f(context, "range_high") + range_low = _f(context, "range_low") + vol_ratio = _f(context, "volume_ratio") + if cycle in (WyckoffCycle.ACCUMULATION.value, WyckoffCycle.RE_ACCUMULATION.value): + if close > ma20 and range_high > 0 and close >= range_high * 0.98 and vol_ratio >= 1.1: + return RuleHit( + rule_id=self.rule_id, + phase=WyckoffPhase.D.value, + confidence=80.0, + score=82.0, + reasons=["突破区间上沿放量,疑似 Phase D SOS"], + ) + if cycle in (WyckoffCycle.DISTRIBUTION.value, WyckoffCycle.RE_DISTRIBUTION.value): + if close < ma20 and range_low > 0 and close <= range_low * 1.02: + return RuleHit( + rule_id=self.rule_id, + phase=WyckoffPhase.D.value, + confidence=80.0, + score=82.0, + reasons=["跌破区间下沿,疑似 Phase D SOW"], + ) + return None + + +class PhaseERule(WyckoffRule): + rule_id = "phase_e" + category = "phase" + timeframes = ("1w", "1d") + + def evaluate(self, context: dict[str, Any]) -> RuleHit | None: + cycle = _cycle(context) + # Markup/Markdown already imply trend continuation (Phase E of prior structure) + if cycle == WyckoffCycle.MARKUP.value: + return RuleHit( + rule_id=self.rule_id, + phase=WyckoffPhase.E.value, + confidence=78.0, + score=80.0, + reasons=["趋势上行,对应 Phase E Markup"], + ) + if cycle == WyckoffCycle.MARKDOWN.value: + return RuleHit( + rule_id=self.rule_id, + phase=WyckoffPhase.E.value, + confidence=78.0, + score=80.0, + reasons=["趋势下行,对应 Phase E Markdown"], + ) + return None + + +def build_rules() -> list[WyckoffRule]: + # More specific phases first + return [PhaseDRule(), PhaseCRule(), PhaseARule(), PhaseBRule(), PhaseERule()] diff --git a/crypto_wyckoff/rules/registry.py b/crypto_wyckoff/rules/registry.py new file mode 100644 index 0000000..5bca7f8 --- /dev/null +++ b/crypto_wyckoff/rules/registry.py @@ -0,0 +1,39 @@ +"""Rule Registry — register Wyckoff rules without modifying engines.""" + +from __future__ import annotations + +from crypto_wyckoff.rules.base import WyckoffRule + + +class RuleRegistry: + def __init__(self) -> None: + self._rules: dict[str, WyckoffRule] = {} + + def register(self, rule: WyckoffRule) -> None: + self._rules[rule.rule_id] = rule + + def get(self, rule_id: str) -> WyckoffRule | None: + return self._rules.get(rule_id) + + def by_category(self, category: str, timeframe: str | None = None) -> list[WyckoffRule]: + out = [r for r in self._rules.values() if r.category == category] + if timeframe: + out = [r for r in out if timeframe in r.timeframes] + return out + + def all(self) -> list[WyckoffRule]: + return list(self._rules.values()) + + +rule_registry = RuleRegistry() + + +def _register_defaults() -> None: + from crypto_wyckoff.rules import cycle_rules, event_rules, phase_rules + + for mod in (cycle_rules, phase_rules, event_rules): + for rule in mod.build_rules(): + rule_registry.register(rule) + + +_register_defaults() diff --git a/crypto_wyckoff/scheduler.py b/crypto_wyckoff/scheduler.py new file mode 100644 index 0000000..95b72b5 --- /dev/null +++ b/crypto_wyckoff/scheduler.py @@ -0,0 +1,127 @@ +"""Background tip + scan scheduler for crypto wyckoff (all enabled combos).""" + +from __future__ import annotations + +import logging +import threading +from datetime import datetime, timezone + +from crypto_wyckoff.combos import all_tfs_for_combos, list_combos +from crypto_wyckoff.io import ( + backfill_symbol, + bar_count, + fetch_symbols_from_provider, + tip_update_symbol, +) +from crypto_wyckoff.pipeline import analyze_and_store + +logger = logging.getLogger(__name__) + +_thread: threading.Thread | None = None +_stop = threading.Event() +_status: dict = { + "running": False, + "last_tick_at": None, + "last_error": None, + "symbols_total": 0, + "symbols_scanned": 0, + "tick_interval_sec": 60, + "backfill_done": False, +} +_status_lock = threading.Lock() + + +def _set(**kwargs): + with _status_lock: + _status.update(kwargs) + + +def get_status() -> dict: + with _status_lock: + return dict(_status) + + +def run_tick(max_symbols: int | None = None, force_rescan: bool = False) -> dict: + """One cycle: refresh symbols, tip-update, analyze each combo.""" + symbols = fetch_symbols_from_provider() + if max_symbols: + symbols = symbols[:max_symbols] + combos = list_combos() + tfs = all_tfs_for_combos(combos) + _set(symbols_total=len(symbols), running=True, last_error=None) + scanned = 0 + errors = 0 + changed_n = 0 + + for i, sym in enumerate(symbols): + try: + # Prefer low-TF of first combo for "enough history" gate + low0 = combos[0]["low"] if combos else "1d" + if bar_count(sym, low0) < 40: + backfill_symbol(sym, tfs) + tip_changed = tip_update_symbol(sym, tfs) + if tip_changed: + changed_n += 1 + if force_rescan or tip_changed: + for combo in combos: + row = analyze_and_store(sym, combo_id=combo["id"]) + if row: + scanned += 1 + except Exception as e: + errors += 1 + if errors <= 5: + logger.warning("tick %s: %s", sym, e) + _set(last_error=str(e)) + if (i + 1) % 25 == 0: + _set(symbols_scanned=scanned) + logger.info("wyckoff tick progress %s/%s scanned=%s", i + 1, len(symbols), scanned) + + _set( + running=False, + symbols_scanned=scanned, + last_tick_at=datetime.now(timezone.utc).isoformat(), + backfill_done=True, + ) + return { + "symbols": len(symbols), + "scanned": scanned, + "changed_tips": changed_n, + "errors": errors, + "combos": [c["id"] for c in combos], + "tfs": tfs, + } + + +def _loop(interval: int, max_symbols: int | None): + try: + run_tick(max_symbols=max_symbols, force_rescan=True) + except Exception as e: + logger.exception("initial tick failed: %s", e) + _set(last_error=str(e), running=False) + while not _stop.wait(interval): + try: + # Tip-driven: only force full rescan when tips change is handled inside + run_tick(max_symbols=max_symbols, force_rescan=False) + except Exception as e: + logger.exception("tick failed: %s", e) + _set(last_error=str(e), running=False) + + +def start_scheduler(interval_sec: int = 60, max_symbols: int | None = None) -> None: + global _thread + if _thread and _thread.is_alive(): + return + _stop.clear() + _set(tick_interval_sec=interval_sec) + _thread = threading.Thread( + target=_loop, + args=(interval_sec, max_symbols), + name="crypto-wyckoff-scheduler", + daemon=True, + ) + _thread.start() + logger.info("crypto wyckoff scheduler started interval=%ss", interval_sec) + + +def stop_scheduler() -> None: + _stop.set() diff --git a/crypto_wyckoff/signal.py b/crypto_wyckoff/signal.py new file mode 100644 index 0000000..dbcbbcf --- /dev/null +++ b/crypto_wyckoff/signal.py @@ -0,0 +1,35 @@ +"""Signal Engine — timeframe-local status labels only (not tradability).""" + +from __future__ import annotations + +from crypto_wyckoff.domain_models import EngineResult, WyckoffEvent + + +class SignalEngine: + """Maps local Event/Phase into a status label. Decision decides tradability.""" + + name = "Signal" + version = "1.0.0" + + def run(self, event: EngineResult, phase: EngineResult | None = None) -> EngineResult: + current = event.payload.get("current_event", WyckoffEvent.NONE.value) + conf = event.confidence + label = current # status label mirrors event for V1 + reasons = [f"本地事件标签: {label}"] + if phase and phase.payload.get("phase"): + reasons.append(f"本地阶段: {phase.payload.get('phase')}") + + return EngineResult( + name=self.name, + version=self.version, + confidence=conf, + score=event.score, + reasons=reasons, + payload={ + "signal_label": label, + "current_event": current, + "phase": (phase.payload.get("phase") if phase else None), + "active_events": event.payload.get("active_events") + or event.payload.get("recent_events", []), + }, + ) diff --git a/crypto_wyckoff/store.py b/crypto_wyckoff/store.py new file mode 100644 index 0000000..540d3ec --- /dev/null +++ b/crypto_wyckoff/store.py @@ -0,0 +1,236 @@ +"""SQLite persistence for crypto wyckoff scan rows (per combo).""" + +from __future__ import annotations + +import sqlite3 +from datetime import datetime +from typing import Any + +from crypto_wyckoff.domain_models import WyckoffScanRow +from crypto_wyckoff.io import SCAN_DB, ensure_dirs + +_COLS = [ + "trade_date", "combo_id", "ts_code", "name", "industry", "engine_version", + "m_cycle", "cycle_confidence", "trend_score", + "w_cycle", "w_phase", "w_current_event", "w_recent_events_json", + "phase_confidence", "structure_score", + "d_current_event", "d_recent_events_json", "event_confidence", "entry_score", + "entry", "stop", "target1", "target2", "rr", + "alignment", "stars", "decision_signal", "signal_confidence", + "overall_confidence", "overall_score", "risk", "reasons_json", + "feature_snapshot_json", "markers_json", "scanned_at", +] + +_CREATE_SQL = """ +CREATE TABLE IF NOT EXISTS wyckoff_scan ( + trade_date TEXT NOT NULL, + combo_id TEXT NOT NULL DEFAULT 'd_w_m', + ts_code TEXT NOT NULL, + name TEXT DEFAULT '', + industry TEXT DEFAULT '', + engine_version TEXT, + m_cycle TEXT, cycle_confidence REAL, trend_score REAL, + w_cycle TEXT, w_phase TEXT, w_current_event TEXT, w_recent_events_json TEXT, + phase_confidence REAL, structure_score REAL, + d_current_event TEXT, d_recent_events_json TEXT, event_confidence REAL, entry_score REAL, + entry REAL, stop REAL, target1 REAL, target2 REAL, rr REAL, + alignment REAL, stars INTEGER, decision_signal TEXT, signal_confidence REAL, + overall_confidence REAL, overall_score REAL, risk TEXT, reasons_json TEXT, + feature_snapshot_json TEXT, markers_json TEXT, scanned_at TEXT, + PRIMARY KEY (trade_date, combo_id, ts_code) +) +""" + + +def _migrate(c: sqlite3.Connection) -> None: + cur = c.execute( + "SELECT name FROM sqlite_master WHERE type='table' AND name='wyckoff_scan'" + ) + if not cur.fetchone(): + c.execute(_CREATE_SQL) + c.execute( + "CREATE INDEX IF NOT EXISTS idx_cw_score " + "ON wyckoff_scan(trade_date, combo_id, overall_score DESC)" + ) + return + + cols = {r[1] for r in c.execute("PRAGMA table_info(wyckoff_scan)")} + if "combo_id" in cols: + c.execute( + "CREATE INDEX IF NOT EXISTS idx_cw_score " + "ON wyckoff_scan(trade_date, combo_id, overall_score DESC)" + ) + return + + # Legacy PK (trade_date, ts_code) → add combo_id via table rebuild + c.execute("ALTER TABLE wyckoff_scan RENAME TO wyckoff_scan_old") + c.execute(_CREATE_SQL) + old_cols = [r[1] for r in c.execute("PRAGMA table_info(wyckoff_scan_old)")] + shared = [col for col in _COLS if col != "combo_id" and col in old_cols] + col_sql = ",".join(shared) + c.execute( + f""" + INSERT INTO wyckoff_scan (combo_id, {col_sql}) + SELECT 'd_w_m', {col_sql} FROM wyckoff_scan_old + """ + ) + c.execute("DROP TABLE wyckoff_scan_old") + c.execute( + "CREATE INDEX IF NOT EXISTS idx_cw_score " + "ON wyckoff_scan(trade_date, combo_id, overall_score DESC)" + ) + + +def _conn() -> sqlite3.Connection: + ensure_dirs() + c = sqlite3.connect(str(SCAN_DB), timeout=60) + c.row_factory = sqlite3.Row + _migrate(c) + c.commit() + return c + + +def upsert_row(row: WyckoffScanRow) -> None: + combo_id = getattr(row, "combo_id", None) or "d_w_m" + vals = ( + row.trade_date.isoformat() if hasattr(row.trade_date, "isoformat") else str(row.trade_date), + combo_id, + row.ts_code, row.name, row.industry, row.engine_version, + row.m_cycle, row.cycle_confidence, row.trend_score, + row.w_cycle, row.w_phase, row.w_current_event, row.w_recent_events_json, + row.phase_confidence, row.structure_score, + row.d_current_event, row.d_recent_events_json, row.event_confidence, row.entry_score, + row.entry, row.stop, row.target1, row.target2, row.rr, + row.alignment, row.stars, row.decision_signal, row.signal_confidence, + row.overall_confidence, row.overall_score, row.risk, row.reasons_json, + row.feature_snapshot_json, row.markers_json, + row.scanned_at.isoformat() if isinstance(row.scanned_at, datetime) else str(row.scanned_at), + ) + c = _conn() + try: + placeholders = ",".join("?" * len(_COLS)) + col_sql = ",".join(_COLS) + updates = ",".join( + f"{col}=excluded.{col}" + for col in _COLS + if col not in ("trade_date", "combo_id", "ts_code") + ) + c.execute( + f""" + INSERT INTO wyckoff_scan ({col_sql}) VALUES ({placeholders}) + ON CONFLICT(trade_date, combo_id, ts_code) DO UPDATE SET {updates} + """, + vals, + ) + c.commit() + finally: + c.close() + + +def latest_trade_date(combo_id: str | None = None) -> str | None: + c = _conn() + try: + if combo_id: + cur = c.execute( + "SELECT MAX(trade_date) FROM wyckoff_scan WHERE combo_id=?", + (combo_id,), + ) + else: + cur = c.execute("SELECT MAX(trade_date) FROM wyckoff_scan") + row = cur.fetchone() + return row[0] if row and row[0] else None + finally: + c.close() + + +def count_for_date(trade_date: str | None = None, combo_id: str | None = None) -> int: + td = trade_date or latest_trade_date(combo_id) + if not td: + return 0 + c = _conn() + try: + if combo_id: + cur = c.execute( + "SELECT COUNT(*) FROM wyckoff_scan WHERE trade_date=? AND combo_id=?", + (td, combo_id), + ) + else: + cur = c.execute("SELECT COUNT(*) FROM wyckoff_scan WHERE trade_date=?", (td,)) + return int(cur.fetchone()[0]) + finally: + c.close() + + +def query_scan( + *, + trade_date: str | None = None, + combo_id: str | None = None, + m_cycle: str | None = None, + w_phase: str | None = None, + d_event: str | None = None, + decision_signal: str | None = None, + min_overall_score: float | None = None, + min_alignment: float | None = None, + sort: str = "overall_score", + limit: int = 100, + offset: int = 0, +) -> list[dict[str, Any]]: + cid = combo_id or "d_w_m" + td = trade_date or latest_trade_date(cid) + if not td: + return [] + sort_col = sort if sort in { + "overall_score", "alignment", "entry_score", "trend_score", "structure_score", "stars" + } else "overall_score" + clauses = ["trade_date=?", "combo_id=?"] + args: list[Any] = [td, cid] + if m_cycle: + clauses.append("m_cycle=?") + args.append(m_cycle) + if w_phase: + clauses.append("w_phase=?") + args.append(w_phase) + if d_event: + clauses.append("d_current_event=?") + args.append(d_event) + if decision_signal: + clauses.append("decision_signal=?") + args.append(decision_signal) + if min_overall_score is not None: + clauses.append("overall_score>=?") + args.append(min_overall_score) + if min_alignment is not None: + clauses.append("alignment>=?") + args.append(min_alignment) + where = " AND ".join(clauses) + args.extend([limit, offset]) + c = _conn() + try: + cur = c.execute( + f"SELECT * FROM wyckoff_scan WHERE {where} ORDER BY {sort_col} DESC LIMIT ? OFFSET ?", + args, + ) + return [dict(r) for r in cur.fetchall()] + finally: + c.close() + + +def get_symbol( + ts_code: str, + trade_date: str | None = None, + combo_id: str | None = None, +) -> dict[str, Any] | None: + cid = combo_id or "d_w_m" + td = trade_date or latest_trade_date(cid) + if not td: + return None + c = _conn() + try: + cur = c.execute( + "SELECT * FROM wyckoff_scan WHERE trade_date=? AND combo_id=? AND ts_code=?", + (td, cid, ts_code), + ) + row = cur.fetchone() + return dict(row) if row else None + finally: + c.close() diff --git a/crypto_wyckoff/symbols_cn.py b/crypto_wyckoff/symbols_cn.py new file mode 100644 index 0000000..58a81fd --- /dev/null +++ b/crypto_wyckoff/symbols_cn.py @@ -0,0 +1,51 @@ +"""Crypto symbol → Chinese display name for screener UI.""" + +from __future__ import annotations + +# Base asset → 中文名(覆盖 provider 当前币对;未知则回退 base) +_BASE_CN: dict[str, str] = { + "BTC": "比特币", + "ETH": "以太坊", + "SOL": "索拉纳", + "XAU": "黄金", + "XAG": "白银", + "SAGA": "Saga", + "CL": "原油", + "ZEC": "大零币", + "XRP": "瑞波币", + "DOGE": "狗狗币", + "BNB": "币安币", + "SUI": "Sui", + "BILL": "Bill", + "BZ": "BZ", + "LAB": "Lab", + "TON": "通联币", + "CRCL": "Circle", + "SNDK": "SNDK", + "1000PEPE": "千倍佩佩", + "PEPE": "佩佩", + "CHIP": "CHIP", + "WIF": "狗帽子", +} + + +def base_asset(symbol: str) -> str: + """BTC/USDT:USDT → BTC;1000PEPE/USDT:USDT → 1000PEPE.""" + s = (symbol or "").strip() + if not s: + return "" + head = s.split(":")[0] + return head.split("/")[0].upper() if "/" in head else head.upper() + + +def display_name_cn(symbol: str) -> str: + base = base_asset(symbol) + if not base: + return symbol or "" + return _BASE_CN.get(base, base) + + +def symbol_name_map(symbols: list[str] | None = None) -> dict[str, str]: + if not symbols: + return {f"{k}/USDT:USDT": v for k, v in _BASE_CN.items()} + return {s: display_name_cn(s) for s in symbols} diff --git a/crypto_wyckoff/version.py b/crypto_wyckoff/version.py new file mode 100644 index 0000000..c4d7977 --- /dev/null +++ b/crypto_wyckoff/version.py @@ -0,0 +1,4 @@ +"""Wyckoff Screener engine version — bump when rules change.""" + +WYCKOFF_ENGINE_VERSION = "v1.0.0" +ARCHITECTURE_VERSION = "1.0" diff --git a/engine/__init__.py b/engine/__init__.py new file mode 100644 index 0000000..cc2ed90 --- /dev/null +++ b/engine/__init__.py @@ -0,0 +1,9 @@ +"""Wyckoff research engines — Decision / Market State(不改 Spring Baseline 信号定义)。""" + +from .market_state import compute_market_state_8h, spring_gate_mask, utad_gate_mask + +__all__ = [ + "compute_market_state_8h", + "spring_gate_mask", + "utad_gate_mask", +] diff --git a/engine/market_state.py b/engine/market_state.py new file mode 100644 index 0000000..6c38906 --- /dev/null +++ b/engine/market_state.py @@ -0,0 +1,155 @@ +""" +Market State Engine v1 — 因果可计算(无未来函数) + +仅使用截至当前 8h K 线已收盘信息: + EMA50/200、ADX、EMA slope、价格相对 MA200 距离 + +输出 0–100 分数 + 主导状态标签(argmax),供 Decision Gate 使用。 +禁止用事后涨跌路径标注 cycle。 +""" +from __future__ import annotations + +import numpy as np +import pandas as pd +import talib.abstract as ta + + +def _clip01(x: pd.Series) -> pd.Series: + return x.clip(lower=0.0, upper=1.0) + + +def compute_market_state_8h(df: pd.DataFrame) -> pd.DataFrame: + """ + 在原生 8h OHLCV 上计算状态分数。 + 返回列: accumulation_score, markup_score, distribution_score, + markdown_score, range_score, market_state, allow_spring, allow_utad + """ + out = df.copy() + out["ema50"] = ta.EMA(out, timeperiod=50) + out["ema200"] = ta.EMA(out, timeperiod=200) + out["adx"] = ta.ADX(out, timeperiod=14) + + # slope: 过去 6 根 8h(约 2 天),仅用历史 + out["ema_slope"] = (out["ema50"] - out["ema50"].shift(6)) / out["ema50"].shift(6).replace(0, np.nan) + out["dist_ema200"] = (out["close"] - out["ema200"]) / out["ema200"].replace(0, np.nan) + + bull = (out["close"] > out["ema200"]) & (out["ema50"] > out["ema200"]) + bear = (out["close"] < out["ema200"]) & (out["ema50"] < out["ema200"]) + range_m = (~bull) & (~bear) + + slope = out["ema_slope"].fillna(0.0) + dist = out["dist_ema200"].fillna(0.0) + adx = out["adx"].fillna(0.0) + + # ---- 分数:连续、因果、可解释 ---- + # accumulation: 仍处熊偏结构,但下跌斜率缓和 / 略抬升(吸筹语境) + accum = ( + 0.45 * bear.astype(float) + + 0.35 * _clip01((slope + 0.02) / 0.04) # slope 从 -2%→+2% 映射 + + 0.20 * _clip01((0.05 + dist) / 0.10) # 仍在 MA200 下方但不极端深 + ) * 100.0 + + # markup: 牛偏 + 正斜率 + 价格在 MA200 上方 + markup = ( + 0.40 * bull.astype(float) + + 0.35 * _clip01(slope / 0.02) + + 0.25 * _clip01(dist / 0.08) + ) * 100.0 + + # distribution: 牛偏但斜率走平/向下(顶部语境) + distrib = ( + 0.40 * bull.astype(float) + + 0.40 * _clip01((-slope) / 0.015) + + 0.20 * _clip01((0.12 - dist.abs()) / 0.12) + ) * 100.0 + + # markdown: 熊偏 + 明显负斜率 + markdown = ( + 0.45 * bear.astype(float) + + 0.40 * _clip01((-slope) / 0.02) + + 0.15 * _clip01((-dist) / 0.10) + ) * 100.0 + + # range: 非明确牛熊,或 ADX 偏低 + range_s = ( + 0.50 * range_m.astype(float) + + 0.30 * _clip01((22.0 - adx) / 22.0) + + 0.20 * (1.0 - bull.astype(float)) * (1.0 - bear.astype(float)) + ) * 100.0 + + out["accumulation_score"] = accum.clip(0, 100) + out["markup_score"] = markup.clip(0, 100) + out["distribution_score"] = distrib.clip(0, 100) + out["markdown_score"] = markdown.clip(0, 100) + out["range_score"] = range_s.clip(0, 100) + + # 主导状态:与归因研究同一套因果规则(非事后路径标注) + # bear+非急跌斜率 → accumulation;bull+正斜率 → markup;… + state = np.full(len(out), "range", dtype=object) + state[(bear) & (slope < -0.01)] = "markdown" + state[(bear) & (slope >= -0.01)] = "accumulation" + state[(bull) & (slope > 0.005)] = "markup" + state[(bull) & (slope <= 0.005)] = "distribution" + out["market_state"] = state + + # 默认 Gate v1.1:状态集合(soft 阈值由 apply_decision_gate 覆盖) + out = apply_decision_gate(out, mode="state_set") + return out + + +def apply_decision_gate( + df: pd.DataFrame, + *, + mode: str = "state_set", + q_sum: float = 100.0, + q_bad: float = 55.0, +) -> pd.DataFrame: + """ + Decision Gate(因果)。 + + mode: + - state_set: state ∈ {accumulation, markup} / UTAD 镜像 + - soft_sum: state_set 且 (accum+markup) >= q_sum + - soft_bad_cap: state_set 且 max(distrib, range, markdown) <= q_bad + """ + out = df.copy() + state = out["market_state"] + spring_state = state.isin(["accumulation", "markup"]) + utad_state = state.isin(["distribution", "markdown"]) + + good_sum = out["accumulation_score"] + out["markup_score"] + bad_max = out[["distribution_score", "range_score", "markdown_score"]].max(axis=1) + # UTAD 镜像:good = distrib+markdown;bad = accum/range + utad_good_sum = out["distribution_score"] + out["markdown_score"] + utad_bad_max = out[["accumulation_score", "range_score", "markup_score"]].max(axis=1) + + if mode == "state_set": + out["allow_spring"] = spring_state + out["allow_utad"] = utad_state + elif mode == "soft_sum": + out["allow_spring"] = spring_state & (good_sum >= float(q_sum)) + out["allow_utad"] = utad_state & (utad_good_sum >= float(q_sum)) + elif mode == "soft_bad_cap": + out["allow_spring"] = spring_state & (bad_max <= float(q_bad)) + out["allow_utad"] = utad_state & (utad_bad_max <= float(q_bad)) + else: + raise ValueError(f"unknown gate mode: {mode}") + + out["gate_mode"] = mode + out["gate_q_sum"] = float(q_sum) + out["gate_q_bad"] = float(q_bad) + return out + + +def spring_gate_mask(dataframe: pd.DataFrame, suffix: str = "_8h") -> pd.Series: + col = f"allow_spring{suffix}" + if col not in dataframe.columns: + return pd.Series(True, index=dataframe.index) + return dataframe[col].fillna(False).astype(bool) + + +def utad_gate_mask(dataframe: pd.DataFrame, suffix: str = "_8h") -> pd.Series: + col = f"allow_utad{suffix}" + if col not in dataframe.columns: + return pd.Series(True, index=dataframe.index) + return dataframe[col].fillna(False).astype(bool) diff --git a/research/DRY_RUN_DECISION_CHECKLIST.md b/research/DRY_RUN_DECISION_CHECKLIST.md new file mode 100644 index 0000000..a859782 --- /dev/null +++ b/research/DRY_RUN_DECISION_CHECKLIST.md @@ -0,0 +1,155 @@ +# Dry-Run Decision Checklist — GATED_V1_1_LOCKED + +```text +Purpose: 上线前不改规则,只验执行链 +Stack: Market State → Decision → Frozen Signal +Version: GATED_V1_1_LOCKED +Mode: dry-run / monitoring only +``` + +研究线已收手。本清单是 **operational acceptance**,不是新实验。 + +--- + +## Locked defaults(不可在 dry-run 中改动) + +| Item | Value | +|------|--------| +| Strategy | `Wyckoff_BTC_GATED` | +| Spring | `V1_BASELINE` FROZEN | +| Gate | `market_state in {accumulation, markup}` → allow Spring | +| Soft-score | rejected | +| Range | observe only(非交易规则) | +| gate_version | `GATED_V1_1_LOCKED` | + +--- + +## 1. 信号一致性 + +上线前逐项勾选: + +- [ ] 同一根 entry candle 上,`market_state` **只使用已收盘 8h** 数据(无 lookahead;merge 后读的是上一根已完成 bias bar) +- [ ] `allow_spring == True` **仅当** `market_state ∈ {accumulation, markup}` +- [ ] `allow_spring == False` 当 `market_state ∈ {distribution, markdown, range}` 或缺失 +- [ ] Baseline 产生 `SPRING_LONG` 且 Gate block 时:**不下单** +- [ ] 同上 blocked 事件:**写入决策日志**(见 §2),与 kept 同 schema +- [ ] UTAD(若启用)镜像:`allow_utad` 仅 `{distribution, markdown}`;本清单以 Spring 为主 + +快速自检(可在 dry-run 启动后抽查最近 N 条日志): + +```text +assert gate_version == "GATED_V1_1_LOCKED" +assert allow ⇒ market_state in {accumulation, markup} +assert market_state == "distribution" ⇒ allow == false +assert block ⇒ order_not_sent +``` + +--- + +## 2. 日志字段(每条候选信号一行) + +必需字段: + +| Field | Example / notes | +|-------|-----------------| +| `timestamp` | entry candle open/close time(UTC) | +| `pair` | e.g. `BTC/USDT:USDT` | +| `signal_type` | `SPRING_LONG` / `UTAD_SHORT` | +| `market_state` | accumulation \| markup \| distribution \| markdown \| range \| missing | +| `allow` | `true` / `false` | +| `gate_version` | `GATED_V1_1_LOCKED` | +| `baseline_signal` | `SPRING_LONG`(Gate 前 Baseline 标签) | +| `block_reason` | `not_in_allow_set` \| `state_missing` \| `state_lag` \| `""` if allow | + +推荐附加(便于监控,非规则): + +| Field | Notes | +|-------|--------| +| `bias_bar_time` | 决策所用已收盘 8h bar 时间 | +| `accumulation_score` … `range_score` | 诊断用,**不参与默认 Gate** | +| `would_enter` | Baseline 是否曾置 `enter_long=1` | +| `order_sent` | dry-run 下应为 `allow` 的结果 | + +Blocked 必须落盘;禁止静默丢弃。 + +--- + +## 3. Dry-run 监控指标 + +周期性汇总(建议日 / 周): + +| Metric | 关注点 | +|--------|--------| +| `kept_n` / `blocked_n` | 量级是否合理,非零且非异常尖刺 | +| blocked domain 分布 | **尤其 `distribution` 应仍为主要 block 源** | +| kept trade PF / expectancy | 参考,不强求 > ungated baseline | +| max DD(kept / 账户) | 应相对 ungated 历史继续偏低 | +| range share among blocked | 仅观察;上升不自动改规则 | + +### 2023+ OOS 参考阈值(研究窗,非调参目标) + +| | Gated(研究) | 解读 | +|--|---------------|------| +| PF | ~1.34(baseline ~1.45) | **不强求超过 baseline** | +| DD | ~3.4%(baseline ~7.9%) | **DD 应继续低** | +| full DD | ~9.6% vs ~26% | 结构性降 DD 仍是成功标准 | + +Dry-run 短期 PF 波动 **不触发规则变更**。 + +--- + +## 4. 报警条件 + +| Severity | Condition | Action | +|----------|-----------|--------| +| P0 | `market_state` 缺失或滞后(bias bar 过旧 / merge 失败) | 停新开仓,查数据链 | +| P0 | Gate 放行且 `market_state ∉ {accumulation, markup}` | 立即停机排查;视为执行链 bug | +| P0 | `distribution` 被放行 Spring | 同上 | +| P1 | blocked 样本中 `range` **长期主导** 且 kept PF/expectancy 同步恶化 | 记观察票;**不改规则**,升级人工 review | +| P2 | kept/blocked 比为 0 或异常尖刺(数据空洞) | 查 feed / 时区 / 8h 对齐 | + +报警只服务执行完整性,不服务「再优化一次 Gate」。 + +--- + +## 5. 不允许事项(硬禁) + +- 不调 Spring(TF / ATR / stoploss / entry 形态) +- 不调 soft-score,不把 soft-score 接回默认路径 +- 不全样本扫 Gate 阈值 / 状态集合 +- 不因短期 dry-run PF 调规则 +- 不因 `range` 小样本表现把 range 升格为交易域 +- 不默认合并 ETH/SOL 进生产路径 +- 不复活 LPS 分支 + +违反任一条 = 退出 dry-run,回到研究流程(需新证据包)。 + +--- + +## 6. Go / No-Go(dry-run → 有限实盘) + +**Go**(全部满足): + +- [ ] §1 信号一致性全部勾选 +- [ ] §2 日志字段齐全,blocked 可见 +- [ ] §4 无未关闭的 P0 +- [ ] 监控窗内 blocked 仍以坏域为主(distribution 不消失为噪音) +- [ ] 规则文件与运行配置仍为 `GATED_V1_1_LOCKED` / `state_set` + +**No-Go**: + +- 任一 P0 +- 日志无法区分 kept vs blocked +- 发现非因果 8h 状态 +- 有人为改动 Spring / Gate 默认值 + +--- + +## Related + +- Status: `research/SYSTEM_STATUS.md` +- Boundary: `research/VALIDITY_BOUNDARY.md` +- Strategy: `strategies/Wyckoff_BTC_GATED.py` +- State engine: `engine/market_state.py` +- Audit evidence: `scripts/wyckoff_negative_domain_audit_result.json` +- Robustness: `scripts/wyckoff_gate_robustness_slices_result.json` diff --git a/research/SYSTEM_STATUS.md b/research/SYSTEM_STATUS.md new file mode 100644 index 0000000..ff8bed7 --- /dev/null +++ b/research/SYSTEM_STATUS.md @@ -0,0 +1,63 @@ +# Wyckoff BTC System v1 — Decision Rule Locked + +``` +Architecture: Market State → Decision → Signal + +Spring: FROZEN +Gate v1.1: LOCKED DEFAULT Decision rule (PASS) +Soft-score: REJECTED (no increment) +Hard-score: REJECTED + +Minimal rule: + market_state in {accumulation, markup} -> allow Spring + else -> block Spring + +Primary invalidation domain: distribution +range: observation bucket only (NOT a trading rule) + +Validity: DEFINED +Confidence: MEDIUM / defined-domain PASS +Status: DEFAULT RULES FROZEN +Next: dry-run / monitoring only(见 operational checklist) +``` + +## Operational + +上线前不改规则,只验执行链: + +→ [`DRY_RUN_DECISION_CHECKLIST.md`](./DRY_RUN_DECISION_CHECKLIST.md) + +覆盖:信号一致性 · 日志字段 · dry-run 监控 · 报警 · 硬禁 · Go/No-Go。 + +## Locked stack + +| Layer | File | Status | +|-------|------|--------| +| Signal | `Wyckoff_BTC_V1_BASELINE.py` | FROZEN | +| State | `engine/market_state.py` | causal v1.1 | +| Decision | `Wyckoff_BTC_GATED.py` | **LOCKED state_set** | +| Boundary | `VALIDITY_BOUNDARY.md` | active | + +## Robustness slices (blocked Spring, by year/era) + +证据:`scripts/wyckoff_gate_robustness_slices_result.json` + +| Slice | blocked n | dist share | top blocked | blocked PF | +|-------|-----------|------------|-------------|------------| +| 2020 | 3 | **1.00** | distribution | 0.73 | +| 2021 | 4 | **0.75** | distribution | 0.31 | +| 2022 | 1 | 1.00 | distribution | 0 | +| 2023 | 1 | 1.00 | distribution | 0 | +| 2024 | 3 | 0.33 | distribution+range | 0 | +| 2025 | 1 | 0 | range (obs) | n=1 win | +| pre_2023 | 8 | **0.875** | distribution | 0.37 | +| 2023plus | 5 | 0.40 | distribution+range | 1.22 | + +Verdict: **distribution 归因在多数有样本切片上稳定**(PASS)。 +2023+ / 2024–25 中 range 占比上升 → 保持 **观察标签**,不升格为交易规则。 + +## Do not + +- 调 Spring / soft-score / Gate 阈值 +- 因 range 小样本正 PF 开放 range 交易 +- 复活 LPS / 默认跨资产 diff --git a/research/VALIDITY_BOUNDARY.md b/research/VALIDITY_BOUNDARY.md new file mode 100644 index 0000000..4780d1f --- /dev/null +++ b/research/VALIDITY_BOUNDARY.md @@ -0,0 +1,85 @@ +# Validity Boundary — Market-State Gated Spring + +## Definition (hard) + +```text +market_state in {accumulation, markup} -> allow Spring +else -> block Spring +``` + +Spring 信号本体 = `V1_BASELINE`(FROZEN)。 +Gate = Decision 层默认规则(state_set v1.1 = **PASS**)。 + +Soft-score / hard-score 阈值 **不进入默认规则**。 + +## Validity statement + +Spring has positive expectancy under: + +1. BTC market +2. Causal `market_state ∈ {accumulation, markup}` +3. 8h / 4h / 1h alignment +4. Trend-compatible (range already blocked in Baseline) + +Invalid under: + +1. `distribution` +2. `range` +3. `markdown`(对 SPRING_LONG) +4. Ungated global trading + +## Causal state (entry-time only) + +``` +bear & ema_slope >= -1% → accumulation +bull & ema_slope > +0.5% → markup +bull & ema_slope <= +0.5% → distribution +bear & ema_slope < -1% → markdown +else → range +``` + +## Gate performance (net fee+slip) + +| Window | Baseline | Gated state_set | +|--------|----------|-----------------| +| 2023+ | n=20 PF 1.45 DD 7.9% | n=7 PF **1.34** DD **3.4%** | +| full | n=47 PF 0.74 DD 26% | n=17 PF **0.92** DD **9.6%** | + +Confidence: **MEDIUM / defined-domain PASS**(full PF 仍 < 1)。 + +## Negative-domain audit + +`scripts/wyckoff_negative_domain_audit_result.json` + +对 Baseline 全部 `SPRING_LONG`(n=28)按因果状态拆 kept/blocked: + +| | n | PF | 含义 | +|--|---|-----|------| +| Kept | 15 | 1.09 | 全部在 markup | +| Blocked | 13 | 0.58 | **100% bad domain** | +| Blocked × distribution | 9 | **0.38** | 主杀伤区 | +| Blocked × range | 4 | 1.14 | 样本小,非干净杀伤 | + +→ Gate 主要过滤 **distribution 结构性失效**,符合威科夫「Spring 是吸筹事件而非形态」的边界叙事。 + +## Default stack(LOCKED) + +``` +8h causal market_state + ↓ +Decision: state_set Gate v1.1 ← LOCKED + ↓ +Frozen V1_BASELINE Spring / UTAD +``` + +## Year/era robustness(冻结前确认) + +`scripts/wyckoff_gate_robustness_slices_result.json` + +- pre_2023 blocked:distribution share **87.5%**,blocked PF 0.37 +- 多数年份 blocked 以 distribution 为首 +- 2023+ blocked:distribution + range 并存;range **仅观察**,不改规则 +- 不因 2023+ blocked 弱正 PF 或 range n=4 回滚 Gate + +**Primary invalidation domain = distribution(稳定)** +**range = observation bucket only** diff --git a/research/baseline_v1/README.md b/research/baseline_v1/README.md new file mode 100644 index 0000000..89458e3 --- /dev/null +++ b/research/baseline_v1/README.md @@ -0,0 +1,19 @@ +# Spring Baseline V1 — FROZEN SNAPSHOT + +勿改本目录文件。可运行副本在: + +- `strategies/Wyckoff_BTC_V1_BASELINE.py` +- `config/Wyckoff_BTC_V1_BASELINE.json` + +## Evidence (cost-adjusted) + +| Window | Profit | n | DD | Net PF | +|--------|--------|---|-----|--------| +| Train | +1.66% | 12 | 3.6% | 1.17 | +| Validate | +9.99% | 6 | 1.8% | 6.20 | +| Test | +0.85% | 2 | 0.7% | 2.18 | +| Full | +12.74% | 20 | 3.6% | 2.02 | +| fee+slip 5bps | +6.78% | 20 | — | **1.45** | + +Status: **PASS + Limited Evidence** (N=20) +Next: Phase3 → N≥50(延历史 / 多品种),不改规则。 diff --git a/research/baseline_v1/Wyckoff_BTC_V1_BASELINE.json b/research/baseline_v1/Wyckoff_BTC_V1_BASELINE.json new file mode 100644 index 0000000..a4fc369 --- /dev/null +++ b/research/baseline_v1/Wyckoff_BTC_V1_BASELINE.json @@ -0,0 +1,86 @@ +{ + "$schema": "https://schema.freqtrade.io/schema.json", + "max_open_trades": 1, + "stake_currency": "USDT", + "stake_amount": "unlimited", + "tradable_balance_ratio": 0.99, + "fiat_display_currency": "USD", + "dry_run": true, + "db_url": "sqlite:///tradesv3.wyckoff_btc_v1_baseline.sqlite", + "dry_run_wallet": 10000, + "cancel_open_orders_on_exit": true, + "trading_mode": "futures", + "margin_mode": "isolated", + "can_short": true, + "timeframe": "1h", + "process_only_new_candles": true, + "unfilledtimeout": { + "entry": 60, + "exit": 60, + "exit_timeout_count": 5, + "unit": "minutes" + }, + "entry_pricing": { + "price_side": "same", + "use_order_book": true, + "order_book_top": 1, + "price_last_balance": 0.0, + "check_depth_of_market": { + "enabled": false, + "bids_to_ask_delta": 1 + } + }, + "exit_pricing": { + "price_side": "same", + "use_order_book": true, + "order_book_top": 1 + }, + "exchange": { + "name": "binance", + "key": "", + "secret": "", + "ccxt_config": { + "proxies": { + "http": "http://127.0.0.1:7897", + "https": "http://127.0.0.1:7897" + } + }, + "ccxt_async_config": { + "aiohttp_proxy": "http://127.0.0.1:7897" + }, + "pair_whitelist": [ + "BTC/USDT:USDT" + ], + "pair_blacklist": [ + "BNB/.*" + ] + }, + "pairlists": [ + { + "method": "StaticPairList" + } + ], + "telegram": { + "enabled": false, + "token": "", + "chat_id": "" + }, + "api_server": { + "enabled": false, + "listen_ip_address": "127.0.0.1", + "listen_port": 8823, + "verbosity": "error", + "enable_openapi": false, + "jwt_secret_key": "wyckoff-v1-baseline-change-me", + "ws_token": "wyckoff-v1-baseline-ws-change-me", + "CORS_origins": [], + "username": "freqtrader", + "password": "FreqTrade007" + }, + "bot_name": "wyckoff_btc_v1_baseline", + "initial_state": "running", + "force_entry_enable": false, + "internals": { + "process_throttle_secs": 5 + } +} diff --git a/research/baseline_v1/Wyckoff_BTC_V1_BASELINE.py b/research/baseline_v1/Wyckoff_BTC_V1_BASELINE.py new file mode 100644 index 0000000..c4091ed --- /dev/null +++ b/research/baseline_v1/Wyckoff_BTC_V1_BASELINE.py @@ -0,0 +1,368 @@ +# --- Do not remove these libs --- +""" +Wyckoff BTC V1.0 BASELINE — FROZEN + +Status: BASELINE FROZEN +Evidence: PASS (+ Limited Evidence, N=20) +Cost Adjusted: PASS (net PF 1.45 @ fee+slip 5bps) +Risk: small sample — 目标积累 N>=50 再谈规模 + +Branch A: Spring Reversal + 8h bias + 4h structure + 1h Spring/UTAD + Range disabled(regime_mode=trend) + ATR + 结构止损 + setup_type: SPRING / UTAD + +证据: user_data/Chan/scripts/wyckoff_v1_baseline_phase2.json +LPS 是独立 Setup 研究,禁止并入本文件调参。 +""" +from freqtrade.strategy import ( + IStrategy, IntParameter, DecimalParameter, CategoricalParameter, + merge_informative_pair, stoploss_from_open, stoploss_from_absolute, +) +from freqtrade.persistence import Trade +import talib.abstract as ta +from pandas import DataFrame +import pandas as pd +import numpy as np +from datetime import datetime +from typing import Optional +import logging + +logger = logging.getLogger(__name__) + +# freqtrade backtesting -c ./user_data/Chan/config/Wyckoff_BTC_V1_BASELINE.json \ +# --strategy Wyckoff_BTC_V1_BASELINE --strategy-path ./user_data/Chan/strategies --timerange=20230101- + + +class Wyckoff_BTC_V1_BASELINE(IStrategy): + """冻结基线:Spring 反转。禁止继续调参;对比实验请用独立分支。""" + INTERFACE_VERSION = 3 + STRATEGY_VERSION = "V1.0_BASELINE" + SETUP_FAMILY = "SPRING" + + timeframe = "1h" + structure_timeframe = "4h" + bias_timeframe: Optional[str] = "8h" + use_bias_filter = True + # trend = bull|bear only(Range disabled — 理论一致性约束,非调参) + regime_mode: str = "trend" + + can_short = True + process_only_new_candles = True + startup_candle_count = 220 + + minimal_roi = { + "0": 0.10, + "1440": 0.05, + "4320": 0.025, + "10080": 0, + } + stoploss = -0.10 + use_custom_stoploss = True + trailing_stop = True + trailing_stop_positive = 0.02 + trailing_stop_positive_offset = 0.04 + trailing_only_offset_is_reached = True + use_exit_signal = True + exit_profit_only = False + + # ---- 冻结默认值(optimize=False)---- + range_lookback = IntParameter(12, 48, default=24, space="buy", optimize=False) + spring_pierce_pct = DecimalParameter(0.001, 0.012, default=0.004, decimals=3, space="buy", optimize=False) + vol_spike_mult = DecimalParameter(1.1, 2.5, default=1.8, decimals=1, space="buy", optimize=False) + adx_min = IntParameter(10, 28, default=14, space="buy", optimize=False) + tr_pos_long_max = DecimalParameter(0.35, 0.55, default=0.45, decimals=2, space="buy", optimize=False) + tr_pos_short_min = DecimalParameter(0.45, 0.65, default=0.55, decimals=2, space="buy", optimize=False) + atr_sl_mult = DecimalParameter(1.2, 3.5, default=1.5, decimals=1, space="sell", optimize=False) + atr_sl_min = DecimalParameter(0.012, 0.04, default=0.018, decimals=3, space="sell", optimize=False) + atr_sl_max = DecimalParameter(0.05, 0.12, default=0.08, decimals=2, space="sell", optimize=False) + time_stop_hours = IntParameter(48, 240, default=120, space="sell", optimize=False) + + # Branch A:仅 Spring / UTAD + use_spring_sig = CategoricalParameter([True, False], default=True, space="buy", optimize=False) + use_utad_sig = CategoricalParameter([True, False], default=True, space="buy", optimize=False) + use_sos_sig = CategoricalParameter([True, False], default=False, space="buy", optimize=False) + use_sow_sig = CategoricalParameter([True, False], default=False, space="buy", optimize=False) + + lev = 1.0 + + def informative_pairs(self): + pairs = self.dp.current_whitelist() if self.dp else [] + tfs = {self.structure_timeframe} + if self.bias_timeframe and self.use_bias_filter: + tfs.add(self.bias_timeframe) + return [(pair, tf) for pair in pairs for tf in tfs] + + def _add_wyckoff_structure(self, df: DataFrame) -> DataFrame: + lb = int(self.range_lookback.value) + + df["atr"] = ta.ATR(df, timeperiod=14) + df["ema50"] = ta.EMA(df, timeperiod=50) + df["ema200"] = ta.EMA(df, timeperiod=200) + df["adx"] = ta.ADX(df, timeperiod=14) + df["rsi"] = ta.RSI(df, timeperiod=14) + df["volume_ma"] = ta.SMA(df, timeperiod=20, price="volume") + + df["tr_high"] = df["high"].rolling(lb).max() + df["tr_low"] = df["low"].rolling(lb).min() + df["tr_mid"] = (df["tr_high"] + df["tr_low"]) / 2.0 + df["tr_width"] = (df["tr_high"] - df["tr_low"]) / df["tr_mid"].replace(0, np.nan) + df["tr_width_ma"] = df["tr_width"].rolling(lb).mean() + + rng = (df["tr_high"] - df["tr_low"]).replace(0, np.nan) + df["tr_pos"] = (df["close"] - df["tr_low"]) / rng + + df["in_range"] = (df["tr_width"] < df["tr_width_ma"] * 1.35) & (df["adx"] < 28) + df["ema50_slope"] = df["ema50"] - df["ema50"].shift(8) + df["prior_down"] = df["ema50_slope"].shift(lb) < 0 + df["prior_up"] = df["ema50_slope"].shift(lb) > 0 + + down_bar = df["close"] < df["open"] + up_bar = df["close"] > df["open"] + vol_down = np.where(down_bar, df["volume"], np.nan) + vol_up = np.where(up_bar, df["volume"], np.nan) + df["vol_down_ma"] = pd.Series(vol_down, index=df.index).rolling(10, min_periods=3).mean() + df["vol_up_ma"] = pd.Series(vol_up, index=df.index).rolling(10, min_periods=3).mean() + df["effort_absorb"] = ( + df["vol_down_ma"].notna() + & df["vol_up_ma"].notna() + & (df["vol_up_ma"] > df["vol_down_ma"] * 1.05) + ) + + df["accum_ctx"] = ( + df["in_range"] + & (df["prior_down"] | (df["close"] < df["ema50"])) + & (df["tr_pos"] < float(self.tr_pos_long_max.value)) + ) + df["distrib_ctx"] = ( + df["in_range"] + & (df["prior_up"] | (df["close"] > df["ema50"])) + & (df["tr_pos"] > float(self.tr_pos_short_min.value)) + ) + df["bull_bias"] = (df["close"] > df["ema200"]) & (df["ema50"] > df["ema200"]) + df["bear_bias"] = (df["close"] < df["ema200"]) & (df["ema50"] < df["ema200"]) + df["vol_spike"] = df["volume"] > df["volume_ma"] * float(self.vol_spike_mult.value) + return df + + def _merge_tf(self, dataframe: DataFrame, pair: str, tf: str) -> DataFrame: + inf = self.dp.get_pair_dataframe(pair=pair, timeframe=tf) + inf = self._add_wyckoff_structure(inf) + keep = [ + "date", "atr", "ema50", "ema200", "adx", "rsi", + "tr_high", "tr_low", "tr_mid", "tr_width", "tr_pos", + "in_range", "accum_ctx", "distrib_ctx", + "vol_spike", "effort_absorb", "prior_down", "prior_up", + "bull_bias", "bear_bias", + ] + inf = inf[[c for c in keep if c in inf.columns]].copy() + return merge_informative_pair(dataframe, inf, self.timeframe, tf, ffill=True) + + def populate_indicators(self, dataframe: DataFrame, metadata: dict) -> DataFrame: + pair = metadata["pair"] + stf = self.structure_timeframe + dataframe = self._merge_tf(dataframe, pair, stf) + + btf = self.bias_timeframe + if btf and self.use_bias_filter and btf != stf: + dataframe = self._merge_tf(dataframe, pair, btf) + + ss = f"_{stf}" + dataframe["atr"] = ta.ATR(dataframe, timeperiod=14) + dataframe["ema21"] = ta.EMA(dataframe, timeperiod=21) + dataframe["ema50"] = ta.EMA(dataframe, timeperiod=50) + dataframe["rsi"] = ta.RSI(dataframe, timeperiod=14) + dataframe["volume_ma"] = ta.SMA(dataframe, timeperiod=20, price="volume") + dataframe["vol_ok"] = dataframe["volume"] > dataframe["volume_ma"] * float(self.vol_spike_mult.value) + + tr_high = dataframe[f"tr_high{ss}"] + tr_low = dataframe[f"tr_low{ss}"] + pierce = float(self.spring_pierce_pct.value) + + accum_soft = ( + dataframe[f"accum_ctx{ss}"].fillna(False).astype(bool) + | ( + dataframe[f"in_range{ss}"].fillna(False).astype(bool) + & dataframe[f"prior_down{ss}"].fillna(False).astype(bool) + & (dataframe[f"tr_pos{ss}"] < float(self.tr_pos_long_max.value)) + ) + ) + distrib_soft = ( + dataframe[f"distrib_ctx{ss}"].fillna(False).astype(bool) + | ( + dataframe[f"in_range{ss}"].fillna(False).astype(bool) + & dataframe[f"prior_up{ss}"].fillna(False).astype(bool) + & (dataframe[f"tr_pos{ss}"] > float(self.tr_pos_short_min.value)) + ) + ) + + if btf and self.use_bias_filter: + bs = f"_{btf}" if btf != stf else ss + if f"bear_bias{bs}" in dataframe.columns: + dataframe["bias_long_ok"] = ~dataframe[f"bear_bias{bs}"].fillna(False).astype(bool) + dataframe["bias_short_ok"] = ~dataframe[f"bull_bias{bs}"].fillna(False).astype(bool) + else: + dataframe["bias_long_ok"] = True + dataframe["bias_short_ok"] = True + else: + dataframe["bias_long_ok"] = True + dataframe["bias_short_ok"] = True + + vol_mild = dataframe["volume"] > dataframe["volume_ma"] * max(1.1, float(self.vol_spike_mult.value) * 0.85) + + dataframe["spring"] = ( + tr_low.notna() + & (dataframe["low"] < tr_low * (1.0 - pierce)) + & (dataframe["close"] > tr_low) + & (dataframe["close"] > dataframe["open"]) + & accum_soft + & vol_mild + & (dataframe["rsi"] < 58) + & dataframe["bias_long_ok"] + ) + dataframe["utad"] = ( + tr_high.notna() + & (dataframe["high"] > tr_high * (1.0 + pierce)) + & (dataframe["close"] < tr_high) + & (dataframe["close"] < dataframe["open"]) + & distrib_soft + & vol_mild + & (dataframe["rsi"] > 42) + & dataframe["bias_short_ok"] + ) + # 基线不进 SOS/SOW;保留列供 exit 参考 + dataframe["sos"] = False + dataframe["sow"] = False + + for col in ["spring", "utad", "sos", "sow", "vol_ok", "bias_long_ok", "bias_short_ok"]: + dataframe[col] = dataframe[col].fillna(False).astype(bool) + dataframe["setup_type"] = "" + dataframe.loc[dataframe["spring"], "setup_type"] = "SPRING_LONG" + dataframe.loc[dataframe["utad"], "setup_type"] = "UTAD_SHORT" + return dataframe + + def populate_entry_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame: + dataframe["enter_long"] = 0 + dataframe["enter_short"] = 0 + dataframe["enter_tag"] = "" + + vol_ok = dataframe["volume"] > 0 + + # 分开标签:禁止把 SPRING / UTAD 混成同一统计桶 + if bool(self.use_spring_sig.value): + cond = vol_ok & dataframe["spring"] + dataframe.loc[cond, ["enter_long", "enter_tag"]] = (1, "SPRING_LONG") + + if bool(self.use_utad_sig.value): + cond = vol_ok & dataframe["utad"] + dataframe.loc[cond, ["enter_short", "enter_tag"]] = (1, "UTAD_SHORT") + + self._apply_regime_filter(dataframe) + return dataframe + + def _apply_regime_filter(self, dataframe: DataFrame) -> None: + rm = getattr(self, "regime_mode", "all") + if rm == "all" or not self.bias_timeframe: + return + bs = f"_{self.bias_timeframe}" + bc, ec = f"bull_bias{bs}", f"bear_bias{bs}" + if bc not in dataframe.columns or ec not in dataframe.columns: + return + bull = dataframe[bc].fillna(False).astype(bool) + bear = dataframe[ec].fillna(False).astype(bool) + both = bull & bear + bull, bear = bull & ~both, bear & ~both + range_m = (~bull) & (~bear) + if rm == "bull": + mask = ~bull + elif rm == "bear": + mask = ~bear + elif rm == "range": + mask = ~range_m + elif rm == "trend": + mask = range_m # Range disabled + else: + return + dataframe.loc[mask, ["enter_long", "enter_short"]] = (0, 0) + dataframe.loc[mask, "enter_tag"] = "" + + def populate_exit_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame: + dataframe["exit_long"] = 0 + dataframe["exit_short"] = 0 + dataframe["exit_tag"] = "" + ss = f"_{self.structure_timeframe}" + + exit_long = dataframe["utad"] | ( + dataframe[f"distrib_ctx{ss}"].fillna(False).astype(bool) + & (dataframe["close"] < dataframe["ema21"]) + & (dataframe["rsi"] < 45) + ) + exit_short = dataframe["spring"] | ( + dataframe[f"accum_ctx{ss}"].fillna(False).astype(bool) + & (dataframe["close"] > dataframe["ema21"]) + & (dataframe["rsi"] > 55) + ) + dataframe.loc[exit_long, ["exit_long", "exit_tag"]] = (1, "wyckoff_phase_flip") + dataframe.loc[exit_short, ["exit_short", "exit_tag"]] = (1, "wyckoff_phase_flip") + return dataframe + + def custom_stoploss( + self, pair: str, trade: Trade, current_time: datetime, + current_rate: float, current_profit: float, after_fill: bool, **kwargs, + ) -> Optional[float]: + dataframe, _ = self.dp.get_analyzed_dataframe(pair, self.timeframe) + if dataframe.empty: + return None + last = dataframe.iloc[-1] + atr = float(last["atr"]) if pd.notna(last["atr"]) else 0.0 + if atr <= 0 or trade.open_rate <= 0: + return None + + atr_dist = float(self.atr_sl_mult.value) * atr + tag = trade.enter_tag or "" + buffer = atr * 0.15 + + if after_fill and trade.get_custom_data("struct_stop") is None: + if trade.is_short: + trade.set_custom_data("struct_stop", float(last["high"]) + buffer) + else: + trade.set_custom_data("struct_stop", float(last["low"]) - buffer) + + struct = trade.get_custom_data("struct_stop") + if trade.is_short: + atr_stop = trade.open_rate + atr_dist + stop_price = min(atr_stop, float(struct)) if struct is not None else atr_stop + else: + atr_stop = trade.open_rate - atr_dist + stop_price = max(atr_stop, float(struct)) if struct is not None else atr_stop + + raw = abs(trade.open_rate - stop_price) / trade.open_rate + raw = min(max(raw, float(self.atr_sl_min.value)), float(self.atr_sl_max.value)) + if struct is not None and tag in ( + "SPRING_LONG", "UTAD_SHORT", "SPRING", "UTAD", "wyckoff_spring", "wyckoff_utad", + ): + sl = stoploss_from_absolute( + stop_price, current_rate, is_short=trade.is_short, leverage=trade.leverage + ) + return sl if sl and sl > 0 else None + return stoploss_from_open( + -raw, current_profit, is_short=trade.is_short, leverage=trade.leverage + ) or None + + def custom_exit( + self, pair: str, trade: Trade, current_time: datetime, + current_rate: float, current_profit: float, **kwargs, + ) -> Optional[str]: + hours = (current_time - trade.open_date_utc).total_seconds() / 3600 + if hours > float(self.time_stop_hours.value) and current_profit < 0: + return "wyckoff_time_stop" + if hours > float(self.time_stop_hours.value) * 2: + return "wyckoff_time_stop_max" + return None + + def leverage( + self, pair: str, current_time: datetime, current_rate: float, + proposed_leverage: float, max_leverage: float, entry_tag: Optional[str], + side: str, **kwargs, + ) -> float: + return min(self.lev, max_leverage) diff --git a/research/baseline_v1/wyckoff_phase2_compare_result.json b/research/baseline_v1/wyckoff_phase2_compare_result.json new file mode 100644 index 0000000..b49d034 --- /dev/null +++ b/research/baseline_v1/wyckoff_phase2_compare_result.json @@ -0,0 +1,460 @@ +{ + "branches": { + "Spring_V1": { + "wfo": { + "train": { + "timerange": "20230101-20250101", + "profit_pct": 1.6587295176, + "trades": 12, + "dd_pct": 3.644907735100005, + "pf": 1.1700179329477578, + "winrate": 25.0, + "final": 10165.87295176, + "fee_used": 0.0005, + "regime_loaded": "trend" + }, + "validate": { + "timerange": "20250101-20260101", + "profit_pct": 9.990534148400002, + "trades": 6, + "dd_pct": 1.797834787912851, + "pf": 6.201791679101682, + "winrate": 66.66666666666666, + "final": 10999.05341484, + "fee_used": 0.0005, + "regime_loaded": "trend" + }, + "test": { + "timerange": "20260101-", + "profit_pct": 0.8458820224000001, + "trades": 2, + "dd_pct": 0.7197049309999966, + "pf": 2.175317808681236, + "winrate": 50.0, + "final": 10084.58820224, + "fee_used": 0.0005, + "regime_loaded": "trend" + }, + "full": { + "timerange": "20230101-", + "profit_pct": 12.7374753063, + "trades": 20, + "dd_pct": 3.644907735100005, + "pf": 2.0183507402435503, + "winrate": 40.0, + "final": 11273.74753063, + "fee_used": 0.0005, + "regime_loaded": "trend" + } + }, + "regimes": { + "trend": { + "profit_pct": 12.7374753063, + "trades": 20, + "dd_pct": 3.644907735100005, + "pf": 2.0183507402435503, + "winrate": 40.0, + "final": 11273.74753063, + "fee_used": 0.0005, + "regime_loaded": "trend" + }, + "bull": { + "profit_pct": 8.166882314399999, + "trades": 12, + "dd_pct": 3.4837023928902555, + "pf": 1.9398544482027922, + "winrate": 33.33333333333333, + "final": 10816.688231439999, + "fee_used": 0.0005, + "regime_loaded": "bull" + }, + "bear": { + "profit_pct": 4.2503064875000005, + "trades": 8, + "dd_pct": 3.173714645599994, + "pf": 2.084295240772406, + "winrate": 50.0, + "final": 10425.03064875, + "fee_used": 0.0005, + "regime_loaded": "bear" + }, + "range": { + "profit_pct": -4.3352539371, + "trades": 6, + "dd_pct": 4.404162180500007, + "pf": 0.15746188404490422, + "winrate": 16.666666666666664, + "final": 9566.47460629, + "fee_used": 0.0005, + "regime_loaded": "range" + }, + "all": { + "profit_pct": 7.831216539699999, + "trades": 26, + "dd_pct": 7.883451762900004, + "pf": 1.454582067425369, + "winrate": 34.61538461538461, + "final": 10783.12165397, + "fee_used": 0.0005, + "regime_loaded": "all" + } + }, + "cost_stress": { + "fee_5bps": { + "profit_pct": 12.7374753063, + "trades": 20, + "dd_pct": 3.644907735100005, + "pf": 2.0183507402435503, + "winrate": 40.0, + "final": 11273.74753063, + "fee_used": 0.0005, + "regime_loaded": "trend" + }, + "fee_5bps+slip_5bps": { + "profit_pct": 6.782772099999998, + "trades": 20, + "dd_pct": 7.851805397900007, + "pf": 1.4511324473780693, + "winrate": 35.0, + "final": 10678.27721, + "fee_used": 0.001, + "regime_loaded": "trend" + }, + "fee_10bps+slip_10bps": { + "profit_pct": 3.182909279400001, + "trades": 20, + "dd_pct": 9.126146157700004, + "pf": 1.1834520309921508, + "winrate": 35.0, + "final": 10318.29092794, + "fee_used": 0.002, + "regime_loaded": "trend" + } + }, + "target": { + "pf": 1.3, + "dd": 10.0, + "note": "Spring: PF>1.3 DD<10%" + }, + "verdict": { + "full_pf": 2.0183507402435503, + "full_dd": 3.644907735100005, + "trades_per_year": 5.555555555555555, + "net_mid_pf": 1.4511324473780693, + "target_pf_ok": true, + "target_dd_ok": true + } + }, + "LPS_V1": { + "wfo": { + "train": { + "timerange": "20230101-20250101", + "profit_pct": -1.2518571096, + "trades": 1, + "dd_pct": 1.251857109600005, + "pf": 0.0, + "winrate": 0.0, + "final": 9874.81428904, + "fee_used": 0.0005, + "regime_loaded": "trend" + }, + "validate": { + "timerange": "20250101-20260101", + "profit_pct": -0.24613064569999998, + "trades": 1, + "dd_pct": 0.24613064569999552, + "pf": 0.0, + "winrate": 0.0, + "final": 9975.38693543, + "fee_used": 0.0005, + "regime_loaded": "trend" + }, + "test": { + "timerange": "20260101-", + "profit_pct": 0.0, + "trades": 0, + "dd_pct": 0.0, + "pf": 0.0, + "winrate": 0.0, + "final": 10000.0, + "fee_used": 0.0005, + "regime_loaded": "trend" + }, + "full": { + "timerange": "20230101-", + "profit_pct": -1.4952529704, + "trades": 2, + "dd_pct": 1.4952529703999973, + "pf": 0.0, + "winrate": 0.0, + "final": 9850.47470296, + "fee_used": 0.0005, + "regime_loaded": "trend" + } + }, + "regimes": { + "trend": { + "profit_pct": -1.4952529704, + "trades": 2, + "dd_pct": 1.4952529703999973, + "pf": 0.0, + "winrate": 0.0, + "final": 9850.47470296, + "fee_used": 0.0005, + "regime_loaded": "trend" + }, + "bull": { + "profit_pct": -1.4952529704, + "trades": 2, + "dd_pct": 1.4952529703999973, + "pf": 0.0, + "winrate": 0.0, + "final": 9850.47470296, + "fee_used": 0.0005, + "regime_loaded": "bull" + }, + "bear": { + "profit_pct": 0.0, + "trades": 0, + "dd_pct": 0.0, + "pf": 0.0, + "winrate": 0.0, + "final": 10000.0, + "fee_used": 0.0005, + "regime_loaded": "bear" + }, + "range": { + "profit_pct": 0.0, + "trades": 0, + "dd_pct": 0.0, + "pf": 0.0, + "winrate": 0.0, + "final": 10000.0, + "fee_used": 0.0005, + "regime_loaded": "range" + }, + "all": { + "profit_pct": -1.4952529704, + "trades": 2, + "dd_pct": 1.4952529703999973, + "pf": 0.0, + "winrate": 0.0, + "final": 9850.47470296, + "fee_used": 0.0005, + "regime_loaded": "all" + } + }, + "cost_stress": { + "fee_5bps": { + "profit_pct": -1.4952529704, + "trades": 2, + "dd_pct": 1.4952529703999973, + "pf": 0.0, + "winrate": 0.0, + "final": 9850.47470296, + "fee_used": 0.0005, + "regime_loaded": "trend" + }, + "fee_5bps+slip_5bps": { + "profit_pct": -1.6902037039, + "trades": 2, + "dd_pct": 1.6902037039000062, + "pf": 0.0, + "winrate": 0.0, + "final": 9830.97962961, + "fee_used": 0.001, + "regime_loaded": "trend" + }, + "fee_10bps+slip_10bps": { + "profit_pct": -2.0801051709, + "trades": 2, + "dd_pct": 2.080105170900006, + "pf": 0.0, + "winrate": 0.0, + "final": 9791.98948291, + "fee_used": 0.002, + "regime_loaded": "trend" + } + }, + "target": { + "pf": 1.2, + "dd": 15.0, + "note": "LPS: PF>1.2, 次数增加" + }, + "version": "LPS_V1.1", + "verdict": { + "full_pf": 0.0, + "full_dd": 1.4952529703999973, + "trades_per_year": 0.5555555555555556, + "net_mid_pf": 0.0, + "target_pf_ok": false, + "target_dd_ok": true + } + }, + "LPS_V2": { + "version": "LPS_V2", + "wfo": { + "train": { + "timerange": "20230101-20250101", + "profit_pct": -3.5049591933000004, + "trades": 3, + "dd_pct": 3.504959193300001, + "pf": 0.0, + "winrate": 0.0, + "final": 9649.50408067, + "fee_used": 0.0005, + "regime_loaded": "trend" + }, + "validate": { + "timerange": "20250101-20260101", + "profit_pct": -1.6143743830000001, + "trades": 2, + "dd_pct": 1.614374382999995, + "pf": 0.0, + "winrate": 0.0, + "final": 9838.5625617, + "fee_used": 0.0005, + "regime_loaded": "trend" + }, + "test": { + "timerange": "20260101-", + "profit_pct": 1.2174468187999996, + "trades": 2, + "dd_pct": 1.4924489317000007, + "pf": 1.81573767312309, + "winrate": 50.0, + "final": 10121.74468188, + "fee_used": 0.0005, + "regime_loaded": "trend" + }, + "full": { + "timerange": "20230101-", + "profit_pct": -3.9055710949000004, + "trades": 7, + "dd_pct": 6.479119889400008, + "pf": 0.39720654015221873, + "winrate": 14.285714285714285, + "final": 9609.44289051, + "fee_used": 0.0005, + "regime_loaded": "trend" + } + }, + "regimes": { + "trend": { + "profit_pct": -3.9055710949000004, + "trades": 7, + "dd_pct": 6.479119889400008, + "pf": 0.39720654015221873, + "winrate": 14.285714285714285, + "final": 9609.44289051, + "fee_used": 0.0005, + "regime_loaded": "trend" + }, + "bull": { + "profit_pct": -5.0599199851, + "trades": 5, + "dd_pct": 5.059919985100005, + "pf": 0.0, + "winrate": 0.0, + "final": 9494.00800149, + "fee_used": 0.0005, + "regime_loaded": "bull" + }, + "bear": { + "profit_pct": 1.2174468187999996, + "trades": 2, + "dd_pct": 1.4924489317000007, + "pf": 1.81573767312309, + "winrate": 50.0, + "final": 10121.74468188, + "fee_used": 0.0005, + "regime_loaded": "bear" + }, + "range": { + "profit_pct": 0.0, + "trades": 0, + "dd_pct": 0.0, + "pf": 0.0, + "winrate": 0.0, + "final": 10000.0, + "fee_used": 0.0005, + "regime_loaded": "range" + }, + "all": { + "profit_pct": -3.9055710949000004, + "trades": 7, + "dd_pct": 6.479119889400008, + "pf": 0.39720654015221873, + "winrate": 14.285714285714285, + "final": 9609.44289051, + "fee_used": 0.0005, + "regime_loaded": "all" + } + }, + "cost_stress": { + "fee_5bps": { + "profit_pct": -3.9055710949000004, + "trades": 7, + "dd_pct": 6.479119889400008, + "pf": 0.39720654015221873, + "winrate": 14.285714285714285, + "final": 9609.44289051, + "fee_used": 0.0005, + "regime_loaded": "trend" + }, + "fee_5bps+slip_5bps": { + "profit_pct": -4.5549168852, + "trades": 7, + "dd_pct": 7.020877735199993, + "pf": 0.3512325585213674, + "winrate": 14.285714285714285, + "final": 9544.50831148, + "fee_used": 0.001, + "regime_loaded": "trend" + }, + "fee_10bps+slip_10bps": { + "profit_pct": -5.371762636800001, + "trades": 7, + "dd_pct": 8.1297357765, + "pf": 0.33924511392759654, + "winrate": 14.285714285714285, + "final": 9462.82373632, + "fee_used": 0.002, + "regime_loaded": "trend" + } + }, + "target": { + "pf": 1.2, + "dd": 15.0, + "note": "LPS V2: 4h SOS→1h LPS; PF>1.2; ~5-15/yr" + }, + "verdict": { + "full_pf": 0.39720654015221873, + "full_dd": 6.479119889400008, + "trades_per_year": 1.9444444444444444, + "net_mid_pf": 0.3512325585213674, + "target_pf_ok": false, + "target_dd_ok": true, + "freq_ok": false, + "regime_logic_ok": true, + "status": "FAIL", + "hypothesis": "4h native SOS → 1h LPS" + } + } + }, + "portfolio_note": { + "spring_tpy": 5.555555555555555, + "lps_tpy": 0.5555555555555556, + "sum_tpy_approx": 6.111111111111111, + "combined_target_tpy": "15-25", + "lps_status": "FAIL", + "spring_status": "PASS" + }, + "system_status": { + "spring": "BASELINE FROZEN / PASS + Limited Evidence", + "lps": "FAIL", + "spring_tpy": 5.555555555555555, + "lps_tpy": 1.9444444444444444, + "next": "若 LPS PASS → 组合层;否则 Spring-only" + } +} \ No newline at end of file diff --git a/research/baseline_v1/wyckoff_v1_baseline_phase2.json b/research/baseline_v1/wyckoff_v1_baseline_phase2.json new file mode 100644 index 0000000..6698af0 --- /dev/null +++ b/research/baseline_v1/wyckoff_v1_baseline_phase2.json @@ -0,0 +1,141 @@ +{ + "note": "V1 BASELINE frozen; Range disabled; Spring/UTAD only; net cost included", + "wfo": { + "train": { + "timerange": "20230101-20250101", + "profit_pct": 1.6587295176, + "trades": 12, + "dd_pct": 3.644907735100005, + "pf": 1.1700179329477578, + "winrate": 25.0, + "final": 10165.87295176, + "fee_used": 0.0005, + "regime_loaded": "trend" + }, + "validate": { + "timerange": "20250101-20260101", + "profit_pct": 9.990534148400002, + "trades": 6, + "dd_pct": 1.797834787912851, + "pf": 6.201791679101682, + "winrate": 66.66666666666666, + "final": 10999.05341484, + "fee_used": 0.0005, + "regime_loaded": "trend" + }, + "test": { + "timerange": "20260101-", + "profit_pct": 0.8458820224000001, + "trades": 2, + "dd_pct": 0.7197049309999966, + "pf": 2.175317808681236, + "winrate": 50.0, + "final": 10084.58820224, + "fee_used": 0.0005, + "regime_loaded": "trend" + }, + "full": { + "timerange": "20230101-", + "profit_pct": 12.7374753063, + "trades": 20, + "dd_pct": 3.644907735100005, + "pf": 2.0183507402435503, + "winrate": 40.0, + "final": 11273.74753063, + "fee_used": 0.0005, + "regime_loaded": "trend" + } + }, + "regimes": { + "trend": { + "profit_pct": 12.7374753063, + "trades": 20, + "dd_pct": 3.644907735100005, + "pf": 2.0183507402435503, + "winrate": 40.0, + "final": 11273.74753063, + "fee_used": 0.0005, + "regime_loaded": "trend" + }, + "bull": { + "profit_pct": 8.166882314399999, + "trades": 12, + "dd_pct": 3.4837023928902555, + "pf": 1.9398544482027922, + "winrate": 33.33333333333333, + "final": 10816.688231439999, + "fee_used": 0.0005, + "regime_loaded": "bull" + }, + "bear": { + "profit_pct": 4.2503064875000005, + "trades": 8, + "dd_pct": 3.173714645599994, + "pf": 2.084295240772406, + "winrate": 50.0, + "final": 10425.03064875, + "fee_used": 0.0005, + "regime_loaded": "bear" + }, + "range": { + "profit_pct": -4.3352539371, + "trades": 6, + "dd_pct": 4.404162180500007, + "pf": 0.15746188404490422, + "winrate": 16.666666666666664, + "final": 9566.47460629, + "fee_used": 0.0005, + "regime_loaded": "range" + }, + "all": { + "profit_pct": 7.831216539699999, + "trades": 26, + "dd_pct": 7.883451762900004, + "pf": 1.454582067425369, + "winrate": 34.61538461538461, + "final": 10783.12165397, + "fee_used": 0.0005, + "regime_loaded": "all" + } + }, + "cost_stress": { + "fee_5bps": { + "profit_pct": 12.7374753063, + "trades": 20, + "dd_pct": 3.644907735100005, + "pf": 2.0183507402435503, + "winrate": 40.0, + "final": 11273.74753063, + "fee_used": 0.0005, + "regime_loaded": "trend" + }, + "fee_5bps+slip_5bps": { + "profit_pct": 6.782772099999998, + "trades": 20, + "dd_pct": 7.851805397900007, + "pf": 1.4511324473780693, + "winrate": 35.0, + "final": 10678.27721, + "fee_used": 0.001, + "regime_loaded": "trend" + }, + "fee_10bps+slip_10bps": { + "profit_pct": 3.182909279400001, + "trades": 20, + "dd_pct": 9.126146157700004, + "pf": 1.1834520309921508, + "winrate": 35.0, + "final": 10318.29092794, + "fee_used": 0.002, + "regime_loaded": "trend" + } + }, + "verdict": { + "full_pf": 2.0183507402435503, + "full_dd": 3.644907735100005, + "trades_per_year": 5.555555555555555, + "net_mid_pf": 1.4511324473780693, + "target_pf_ok": true, + "target_dd_ok": true + } +} \ No newline at end of file diff --git a/research/lps_v1_1_failed/REJECT.md b/research/lps_v1_1_failed/REJECT.md new file mode 100644 index 0000000..483e045 --- /dev/null +++ b/research/lps_v1_1_failed/REJECT.md @@ -0,0 +1,14 @@ +# LPS V1.1 — REJECTED + +## Hypothesis + +在 V1 上收紧:严格 8h bias + 吸筹前置窗口 + 每事件首次回踩 + +## Result + +- Full: **-1.50%**, n=**2**, 全亏 +- 过滤方向正确,但过度收缩 → 无统计意义 + +## Reject reason + +无法同时满足「理论纯度」与「可交易样本」。确认问题在事件定义,继续收紧无意义。 diff --git a/research/lps_v1_failed/REJECT.md b/research/lps_v1_failed/REJECT.md new file mode 100644 index 0000000..e4a2c76 --- /dev/null +++ b/research/lps_v1_failed/REJECT.md @@ -0,0 +1,15 @@ +# LPS V1 — REJECTED + +## Hypothesis + +1h 侦测突破 + 回踩 = Wyckoff LPS(趋势跟随) + +## Result + +- Full: **-18.92%**, n=133, PF **0.73** +- Regime anomaly: **trend 亏、range 赚**(反理论) + +## Reject reason + +捕获的是普通突破回踩噪音,不是 Accumulation → Markup 下的 Composite Operator LPS。 +定义错误,不是参数问题。 diff --git a/research/lps_v2_failed/REJECT.md b/research/lps_v2_failed/REJECT.md new file mode 100644 index 0000000..243505a --- /dev/null +++ b/research/lps_v2_failed/REJECT.md @@ -0,0 +1,43 @@ +# LPS V2 — REJECTED(归档,不再救援) + +## Hypothesis + +**4h 原生 SOS Confirm → 1h LPS Entry** +大级别事件、小级别执行(非 1h 假突破) + +## Implementation + +见 `Wyckoff_BTC_LPS_V2.py` + +4h SOS: 实体收盘离开区间 + vol>MA*1.5 + close strength>0.7 + 3 根 hold +1h LPS: 首次回踩 + 0.5~1.5 ATR + volprev high + +## Result + +| Window | Profit | n | PF | +|--------|--------|---|-----| +| Train | -3.50% | 3 | 0 | +| Validate | -1.61% | 2 | 0 | +| Test | +1.22% | 2 | 1.82 | +| Full | **-3.91%** | 7 | **0.40** | +| fee+slip | -4.55% | 7 | **0.35** | + +证据文件: `wyckoff_lps_v2_phase2_result.json` + +## Funnel + +``` +4h sos_raw 183 → confirmed 123 → 1h LPS 7 +``` + +SOS 识别有产出;**SOS→LPS 映射无稳定边际**。 + +## Reject reason + +在 BTC 永续当前结构下,传统股票式 SOS→LPS→Markup 假设不成立: +突破后常不给标准 LPS,或首次回踩已破坏结构。 +样本少/成本/Regime 均非主因。**停止优化本假设。** + +## Reopen only if + +成交量分布 / 订单流 / 资金费率等新信息源进入假设。 diff --git a/research/lps_v2_failed/Wyckoff_BTC_LPS_V2.py b/research/lps_v2_failed/Wyckoff_BTC_LPS_V2.py new file mode 100644 index 0000000..53bf64f --- /dev/null +++ b/research/lps_v2_failed/Wyckoff_BTC_LPS_V2.py @@ -0,0 +1,492 @@ +# --- Do not remove these libs --- +""" +Wyckoff BTC — Branch B: LPS Trend Continuation(独立 Setup 研究) + +Status: RESEARCH +Spring V1: BASELINE FROZEN(禁止改动 / 禁止与本分支合并调参) + +LPS V2 假设(验证中): + 4h 原生 SOS Confirm → 1h LPS Entry + 不是 1h 假突破回踩 + +4h SOS: + ① close > range_high(实体收盘离开区间,非 wick) + ② volume > MA20 * 1.5 + ③ close strength (close-low)/(high-low) > 0.7 + ④ 随后 3 根 4h close 仍 > breakout_level + +1h LPS: + 第一次回踩 breakout_level + 回踩深度 0.5~1.5 ATR(1h) + volume_4h < sos_break_volume + 转强: close > previous high + +setup_type / enter_tag: LPS / LPSY +regime_mode=trend(Range disabled) +""" +from freqtrade.strategy import ( + IStrategy, IntParameter, DecimalParameter, CategoricalParameter, + merge_informative_pair, stoploss_from_open, stoploss_from_absolute, +) +from freqtrade.persistence import Trade +import talib.abstract as ta +from pandas import DataFrame +import pandas as pd +import numpy as np +from datetime import datetime +from typing import Optional +import logging + +logger = logging.getLogger(__name__) + +# freqtrade backtesting -c ./user_data/Chan/config/Wyckoff_BTC_LPS.json \ +# --strategy Wyckoff_BTC_LPS --strategy-path ./user_data/Chan/strategies --timerange=20230101- + + +class Wyckoff_BTC_LPS(IStrategy): + """LPS V2: 4h 原生 SOS → 1h LPS。不与 Spring 混用。""" + INTERFACE_VERSION = 3 + STRATEGY_VERSION = "LPS_V2" + SETUP_FAMILY = "LPS" + + timeframe = "1h" + structure_timeframe = "4h" + bias_timeframe: Optional[str] = "8h" + use_bias_filter = True + regime_mode: str = "trend" + + can_short = True + process_only_new_candles = True + startup_candle_count = 220 + + minimal_roi = { + "0": 0.12, + "1440": 0.06, + "4320": 0.03, + "10080": 0, + } + stoploss = -0.10 + use_custom_stoploss = True + trailing_stop = True + trailing_stop_positive = 0.025 + trailing_stop_positive_offset = 0.05 + trailing_only_offset_is_reached = True + use_exit_signal = True + exit_profit_only = False + + # ---- 固定规则(不做 hyperopt)---- + range_lookback = IntParameter(12, 48, default=24, space="buy", optimize=False) + sos_vol_mult = DecimalParameter(1.2, 2.5, default=1.5, decimals=1, space="buy", optimize=False) + sos_close_strength = DecimalParameter(0.55, 0.90, default=0.70, decimals=2, space="buy", optimize=False) + sos_hold_bars_4h = IntParameter(1, 6, default=3, space="buy", optimize=False) + lps_pb_atr_min = DecimalParameter(0.3, 1.0, default=0.5, decimals=1, space="buy", optimize=False) + lps_pb_atr_max = DecimalParameter(1.0, 2.5, default=1.5, decimals=1, space="buy", optimize=False) + lps_max_age_1h = IntParameter(12, 120, default=72, space="buy", optimize=False) + + atr_sl_mult = DecimalParameter(1.2, 3.5, default=1.5, decimals=1, space="sell", optimize=False) + atr_sl_min = DecimalParameter(0.012, 0.04, default=0.018, decimals=3, space="sell", optimize=False) + atr_sl_max = DecimalParameter(0.05, 0.12, default=0.08, decimals=2, space="sell", optimize=False) + time_stop_hours = IntParameter(48, 240, default=168, space="sell", optimize=False) + + use_lps_long = CategoricalParameter([True, False], default=True, space="buy", optimize=False) + use_lps_short = CategoricalParameter([True, False], default=True, space="buy", optimize=False) + + lev = 1.0 + + def informative_pairs(self): + pairs = self.dp.current_whitelist() if self.dp else [] + tfs = {self.structure_timeframe} + if self.bias_timeframe and self.use_bias_filter: + tfs.add(self.bias_timeframe) + return [(pair, tf) for pair in pairs for tf in tfs] + + def _add_bias_tf(self, df: DataFrame) -> DataFrame: + df = df.copy() + df["ema50"] = ta.EMA(df, timeperiod=50) + df["ema200"] = ta.EMA(df, timeperiod=200) + df["bull_bias"] = (df["close"] > df["ema200"]) & (df["ema50"] > df["ema200"]) + df["bear_bias"] = (df["close"] < df["ema200"]) & (df["ema50"] < df["ema200"]) + return df + + def _add_sos_structure_4h(self, df: DataFrame) -> DataFrame: + """在 4h 原生计算 SOS / SOW(含 hold 确认,无前视进场)。""" + df = df.copy() + lb = int(self.range_lookback.value) + hold = int(self.sos_hold_bars_4h.value) + vol_m = float(self.sos_vol_mult.value) + strength_min = float(self.sos_close_strength.value) + + df["atr"] = ta.ATR(df, timeperiod=14) + df["volume_ma"] = ta.SMA(df, timeperiod=20, price="volume") + df["ema50"] = ta.EMA(df, timeperiod=50) + df["ema200"] = ta.EMA(df, timeperiod=200) + df["adx"] = ta.ADX(df, timeperiod=14) + + # 区间用「突破前」边界:shift(1) 的 rolling,避免当根抬高 + df["range_high"] = df["high"].rolling(lb).max().shift(1) + df["range_low"] = df["low"].rolling(lb).min().shift(1) + + bar_range = (df["high"] - df["low"]).replace(0, np.nan) + df["close_strength"] = (df["close"] - df["low"]) / bar_range + df["close_weakness"] = (df["high"] - df["close"]) / bar_range + + vol_ok = df["volume"] > df["volume_ma"] * vol_m + + # ① 实体收盘离开区间 ② 放量 ③ Effort Result + sos_raw = ( + df["range_high"].notna() + & (df["close"] > df["range_high"]) + & (df["close"].shift(1) <= df["range_high"]) + & vol_ok + & (df["close_strength"] > strength_min) + ) + sow_raw = ( + df["range_low"].notna() + & (df["close"] < df["range_low"]) + & (df["close"].shift(1) >= df["range_low"]) + & vol_ok + & (df["close_weakness"] > strength_min) + ) + + # 事件位:突破当根冻结 + sos_level = df["range_high"].where(sos_raw) + sos_vol = df["volume"].where(sos_raw) + sos_origin = df["range_low"].where(sos_raw) + sow_level = df["range_low"].where(sow_raw) + sow_vol = df["volume"].where(sow_raw) + sow_origin = df["range_high"].where(sow_raw) + + # ④ Hold:突破后 hold 根 4h 收盘仍在突破侧 → 在第 hold 根确认(无前视) + sos_confirmed = sos_raw.shift(hold).fillna(False) + sow_confirmed = sow_raw.shift(hold).fillna(False) + for k in range(hold): + sos_confirmed = sos_confirmed & (df["close"].shift(k) > sos_level.shift(hold)) + sow_confirmed = sow_confirmed & (df["close"].shift(k) < sow_level.shift(hold)) + + # 确认当根带出冻结字段,再 ffill 供 1h 使用 + df["sos_raw"] = sos_raw.fillna(False) + df["sow_raw"] = sow_raw.fillna(False) + df["sos_confirmed"] = sos_confirmed.fillna(False) + df["sow_confirmed"] = sow_confirmed.fillna(False) + + df["sos_break_level"] = sos_level.shift(hold).where(df["sos_confirmed"]) + df["sos_break_volume"] = sos_vol.shift(hold).where(df["sos_confirmed"]) + df["sos_origin"] = sos_origin.shift(hold).where(df["sos_confirmed"]) + df["sow_break_level"] = sow_level.shift(hold).where(df["sow_confirmed"]) + df["sow_break_volume"] = sow_vol.shift(hold).where(df["sow_confirmed"]) + df["sow_origin"] = sow_origin.shift(hold).where(df["sow_confirmed"]) + + df["sos_break_level"] = df["sos_break_level"].ffill() + df["sos_break_volume"] = df["sos_break_volume"].ffill() + df["sos_origin"] = df["sos_origin"].ffill() + df["sow_break_level"] = df["sow_break_level"].ffill() + df["sow_break_volume"] = df["sow_break_volume"].ffill() + df["sow_origin"] = df["sow_origin"].ffill() + + df["bull_bias"] = (df["close"] > df["ema200"]) & (df["ema50"] > df["ema200"]) + df["bear_bias"] = (df["close"] < df["ema200"]) & (df["ema50"] < df["ema200"]) + return df + + @staticmethod + def _bars_since(event: pd.Series) -> pd.Series: + ev = event.fillna(False).astype(bool).to_numpy() + out = np.full(len(ev), np.nan) + c = np.nan + for i, e in enumerate(ev): + if e: + c = 0.0 + elif not np.isnan(c): + c += 1.0 + out[i] = c + return pd.Series(out, index=event.index) + + @staticmethod + def _expanding_max_since(event: pd.Series, value: pd.Series) -> pd.Series: + """每个 event 之后对 value 做分段累计 max。""" + ev = event.fillna(False).astype(bool).to_numpy() + vals = value.to_numpy(dtype=float) + out = np.full(len(ev), np.nan) + cur = np.nan + active = False + for i in range(len(ev)): + if ev[i]: + active = True + cur = vals[i] + elif active: + if not np.isnan(vals[i]): + cur = vals[i] if np.isnan(cur) else max(cur, vals[i]) + out[i] = cur if active else np.nan + return pd.Series(out, index=event.index) + + @staticmethod + def _expanding_min_since(event: pd.Series, value: pd.Series) -> pd.Series: + ev = event.fillna(False).astype(bool).to_numpy() + vals = value.to_numpy(dtype=float) + out = np.full(len(ev), np.nan) + cur = np.nan + active = False + for i in range(len(ev)): + if ev[i]: + active = True + cur = vals[i] + elif active: + if not np.isnan(vals[i]): + cur = vals[i] if np.isnan(cur) else min(cur, vals[i]) + out[i] = cur if active else np.nan + return pd.Series(out, index=event.index) + + def populate_indicators(self, dataframe: DataFrame, metadata: dict) -> DataFrame: + pair = metadata["pair"] + stf = self.structure_timeframe + btf = self.bias_timeframe + + inf4 = self.dp.get_pair_dataframe(pair=pair, timeframe=stf) + inf4 = self._add_sos_structure_4h(inf4) + keep4 = [ + "date", "atr", "adx", "volume", + "range_high", "range_low", "close_strength", + "sos_raw", "sow_raw", "sos_confirmed", "sow_confirmed", + "sos_break_level", "sos_break_volume", "sos_origin", + "sow_break_level", "sow_break_volume", "sow_origin", + "bull_bias", "bear_bias", + ] + inf4 = inf4[[c for c in keep4 if c in inf4.columns]].copy() + dataframe = merge_informative_pair(dataframe, inf4, self.timeframe, stf, ffill=True) + + if btf and self.use_bias_filter and btf != stf: + infb = self.dp.get_pair_dataframe(pair=pair, timeframe=btf) + infb = self._add_bias_tf(infb) + infb = infb[["date", "bull_bias", "bear_bias", "ema50", "ema200"]].copy() + dataframe = merge_informative_pair(dataframe, infb, self.timeframe, btf, ffill=True) + + ss = f"_{stf}" + bs = f"_{btf}" if btf and btf != stf else ss + + dataframe["atr"] = ta.ATR(dataframe, timeperiod=14) + dataframe["ema21"] = ta.EMA(dataframe, timeperiod=21) + dataframe["ema50"] = ta.EMA(dataframe, timeperiod=50) + dataframe["volume_ma"] = ta.SMA(dataframe, timeperiod=20, price="volume") + + # 8h bias(优先);否则退回 4h bias + if f"bull_bias{bs}" in dataframe.columns: + bull = dataframe[f"bull_bias{bs}"].fillna(False).astype(bool) + bear = dataframe[f"bear_bias{bs}"].fillna(False).astype(bool) + else: + bull = dataframe[f"bull_bias{ss}"].fillna(False).astype(bool) + bear = dataframe[f"bear_bias{ss}"].fillna(False).astype(bool) + dataframe["bias_long_ok"] = bull + dataframe["bias_short_ok"] = bear + + sos_conf = dataframe[f"sos_confirmed{ss}"].fillna(False).astype(bool) + sow_conf = dataframe[f"sow_confirmed{ss}"].fillna(False).astype(bool) + # 确认沿上升沿:4h 确认映射到 1h 后的首次 True + sos_event = sos_conf & ~sos_conf.shift(1).fillna(False) + sow_event = sow_conf & ~sow_conf.shift(1).fillna(False) + + sos_level = dataframe[f"sos_break_level{ss}"] + sos_bvol = dataframe[f"sos_break_volume{ss}"] + sos_origin = dataframe[f"sos_origin{ss}"] + sow_level = dataframe[f"sow_break_level{ss}"] + sow_bvol = dataframe[f"sow_break_volume{ss}"] + sow_origin = dataframe[f"sow_origin{ss}"] + vol4 = dataframe[f"volume{ss}"] + + sos_age = self._bars_since(sos_event) + sow_age = self._bars_since(sow_event) + post_high = self._expanding_max_since(sos_event, dataframe["high"]) + post_low = self._expanding_min_since(sow_event, dataframe["low"]) + + atr = dataframe["atr"] + pb_min = float(self.lps_pb_atr_min.value) + pb_max = float(self.lps_pb_atr_max.value) + max_age = float(self.lps_max_age_1h.value) + + # 回踩深度:SOS 后高点回撤的 ATR 倍数 + retrace_long = (post_high - dataframe["low"]) / atr.replace(0, np.nan) + retrace_short = (dataframe["high"] - post_low) / atr.replace(0, np.nan) + + near_sos = dataframe["low"] <= (sos_level + atr * 0.35) + near_sow = dataframe["high"] >= (sow_level - atr * 0.35) + vol_dry_long = vol4 < sos_bvol + vol_dry_short = vol4 < sow_bvol + reclaim_long = dataframe["close"] > dataframe["high"].shift(1) + reclaim_short = dataframe["close"] < dataframe["low"].shift(1) + + first_near_long = near_sos & ~near_sos.shift(1).fillna(False) + first_near_short = near_sow & ~near_sow.shift(1).fillna(False) + + alive_long = ( + sos_age.notna() + & (sos_age >= 1) + & (sos_age <= max_age) + & (dataframe["close"] > sos_origin) + ) + alive_short = ( + sow_age.notna() + & (sow_age >= 1) + & (sow_age <= max_age) + & (dataframe["close"] < sow_origin) + ) + + dataframe["lps"] = ( + alive_long + & first_near_long + & retrace_long.between(pb_min, pb_max) + & (dataframe["low"] > sos_origin) + & (dataframe["close"] >= sos_level * 0.995) + & vol_dry_long + & reclaim_long + & dataframe["bias_long_ok"] + ) + dataframe["lpsy"] = ( + alive_short + & first_near_short + & retrace_short.between(pb_min, pb_max) + & (dataframe["high"] < sow_origin) + & (dataframe["close"] <= sow_level * 1.005) + & vol_dry_short + & reclaim_short + & dataframe["bias_short_ok"] + ) + + dataframe["sos"] = sos_event + dataframe["sow"] = sow_event + dataframe["sos_level"] = sos_level + dataframe["sos_origin"] = sos_origin + dataframe["sow_level"] = sow_level + dataframe["sow_origin"] = sow_origin + dataframe["sos_age"] = sos_age + dataframe["sow_age"] = sow_age + + for col in ["lps", "lpsy", "bias_long_ok", "bias_short_ok", "sos", "sow"]: + dataframe[col] = dataframe[col].fillna(False).astype(bool) + + dataframe["setup_type"] = "" + dataframe.loc[dataframe["lps"], "setup_type"] = "LPS" + dataframe.loc[dataframe["lpsy"], "setup_type"] = "LPSY" + return dataframe + + def populate_entry_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame: + dataframe["enter_long"] = 0 + dataframe["enter_short"] = 0 + dataframe["enter_tag"] = "" + vol_ok = dataframe["volume"] > 0 + + if bool(self.use_lps_long.value): + cond = vol_ok & dataframe["lps"] + dataframe.loc[cond, ["enter_long", "enter_tag"]] = (1, "LPS") + + if bool(self.use_lps_short.value): + cond = vol_ok & dataframe["lpsy"] + dataframe.loc[cond, ["enter_short", "enter_tag"]] = (1, "LPSY") + + self._apply_regime_filter(dataframe) + return dataframe + + def _apply_regime_filter(self, dataframe: DataFrame) -> None: + rm = getattr(self, "regime_mode", "all") + if rm == "all" or not self.bias_timeframe: + return + bs = f"_{self.bias_timeframe}" + bc, ec = f"bull_bias{bs}", f"bear_bias{bs}" + if bc not in dataframe.columns or ec not in dataframe.columns: + return + bull = dataframe[bc].fillna(False).astype(bool) + bear = dataframe[ec].fillna(False).astype(bool) + both = bull & bear + bull, bear = bull & ~both, bear & ~both + range_m = (~bull) & (~bear) + if rm == "bull": + mask = ~bull + elif rm == "bear": + mask = ~bear + elif rm == "range": + mask = ~range_m + elif rm == "trend": + mask = range_m + else: + return + dataframe.loc[mask, ["enter_long", "enter_short"]] = (0, 0) + dataframe.loc[mask, "enter_tag"] = "" + + def populate_exit_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame: + dataframe["exit_long"] = 0 + dataframe["exit_short"] = 0 + dataframe["exit_tag"] = "" + # 结构失效:收盘跌破 SOS 突破位 / 升破 SOW 突破位 + exit_long = ( + dataframe["sos_level"].notna() + & (dataframe["close"] < dataframe["sos_level"]) + & (dataframe["close"] < dataframe["ema21"]) + ) | dataframe["sow"] + exit_short = ( + dataframe["sow_level"].notna() + & (dataframe["close"] > dataframe["sow_level"]) + & (dataframe["close"] > dataframe["ema21"]) + ) | dataframe["sos"] + dataframe.loc[exit_long.fillna(False), ["exit_long", "exit_tag"]] = (1, "lps_structure_fail") + dataframe.loc[exit_short.fillna(False), ["exit_short", "exit_tag"]] = (1, "lps_structure_fail") + return dataframe + + def custom_stoploss( + self, pair: str, trade: Trade, current_time: datetime, + current_rate: float, current_profit: float, after_fill: bool, **kwargs, + ) -> Optional[float]: + dataframe, _ = self.dp.get_analyzed_dataframe(pair, self.timeframe) + if dataframe.empty: + return None + last = dataframe.iloc[-1] + atr = float(last["atr"]) if pd.notna(last["atr"]) else 0.0 + if atr <= 0 or trade.open_rate <= 0: + return None + + atr_dist = float(self.atr_sl_mult.value) * atr + tag = trade.enter_tag or "" + buffer = atr * 0.15 + + if after_fill and trade.get_custom_data("struct_stop") is None: + if tag == "LPS" and pd.notna(last.get("sos_origin")): + trade.set_custom_data("struct_stop", float(last["sos_origin"]) - buffer) + elif tag == "LPSY" and pd.notna(last.get("sow_origin")): + trade.set_custom_data("struct_stop", float(last["sow_origin"]) + buffer) + elif trade.is_short: + trade.set_custom_data("struct_stop", float(last["high"]) + buffer) + else: + trade.set_custom_data("struct_stop", float(last["low"]) - buffer) + + struct = trade.get_custom_data("struct_stop") + if trade.is_short: + atr_stop = trade.open_rate + atr_dist + stop_price = min(atr_stop, float(struct)) if struct is not None else atr_stop + else: + atr_stop = trade.open_rate - atr_dist + stop_price = max(atr_stop, float(struct)) if struct is not None else atr_stop + + raw = abs(trade.open_rate - stop_price) / trade.open_rate + raw = min(max(raw, float(self.atr_sl_min.value)), float(self.atr_sl_max.value)) + if struct is not None and tag in ("LPS", "LPSY"): + sl = stoploss_from_absolute( + stop_price, current_rate, is_short=trade.is_short, leverage=trade.leverage + ) + return sl if sl and sl > 0 else None + return stoploss_from_open( + -raw, current_profit, is_short=trade.is_short, leverage=trade.leverage + ) or None + + def custom_exit( + self, pair: str, trade: Trade, current_time: datetime, + current_rate: float, current_profit: float, **kwargs, + ) -> Optional[str]: + hours = (current_time - trade.open_date_utc).total_seconds() / 3600 + if hours > float(self.time_stop_hours.value) and current_profit < 0: + return "wyckoff_time_stop" + if hours > float(self.time_stop_hours.value) * 2: + return "wyckoff_time_stop_max" + return None + + def leverage( + self, pair: str, current_time: datetime, current_rate: float, + proposed_leverage: float, max_leverage: float, entry_tag: Optional[str], + side: str, **kwargs, + ) -> float: + return min(self.lev, max_leverage) diff --git a/research/lps_v2_failed/wyckoff_lps_v2_phase2_result.json b/research/lps_v2_failed/wyckoff_lps_v2_phase2_result.json new file mode 100644 index 0000000..10d2753 --- /dev/null +++ b/research/lps_v2_failed/wyckoff_lps_v2_phase2_result.json @@ -0,0 +1,150 @@ +{ + "version": "LPS_V2", + "wfo": { + "train": { + "timerange": "20230101-20250101", + "profit_pct": -3.5049591933000004, + "trades": 3, + "dd_pct": 3.504959193300001, + "pf": 0.0, + "winrate": 0.0, + "final": 9649.50408067, + "fee_used": 0.0005, + "regime_loaded": "trend" + }, + "validate": { + "timerange": "20250101-20260101", + "profit_pct": -1.6143743830000001, + "trades": 2, + "dd_pct": 1.614374382999995, + "pf": 0.0, + "winrate": 0.0, + "final": 9838.5625617, + "fee_used": 0.0005, + "regime_loaded": "trend" + }, + "test": { + "timerange": "20260101-", + "profit_pct": 1.2174468187999996, + "trades": 2, + "dd_pct": 1.4924489317000007, + "pf": 1.81573767312309, + "winrate": 50.0, + "final": 10121.74468188, + "fee_used": 0.0005, + "regime_loaded": "trend" + }, + "full": { + "timerange": "20230101-", + "profit_pct": -3.9055710949000004, + "trades": 7, + "dd_pct": 6.479119889400008, + "pf": 0.39720654015221873, + "winrate": 14.285714285714285, + "final": 9609.44289051, + "fee_used": 0.0005, + "regime_loaded": "trend" + } + }, + "regimes": { + "trend": { + "profit_pct": -3.9055710949000004, + "trades": 7, + "dd_pct": 6.479119889400008, + "pf": 0.39720654015221873, + "winrate": 14.285714285714285, + "final": 9609.44289051, + "fee_used": 0.0005, + "regime_loaded": "trend" + }, + "bull": { + "profit_pct": -5.0599199851, + "trades": 5, + "dd_pct": 5.059919985100005, + "pf": 0.0, + "winrate": 0.0, + "final": 9494.00800149, + "fee_used": 0.0005, + "regime_loaded": "bull" + }, + "bear": { + "profit_pct": 1.2174468187999996, + "trades": 2, + "dd_pct": 1.4924489317000007, + "pf": 1.81573767312309, + "winrate": 50.0, + "final": 10121.74468188, + "fee_used": 0.0005, + "regime_loaded": "bear" + }, + "range": { + "profit_pct": 0.0, + "trades": 0, + "dd_pct": 0.0, + "pf": 0.0, + "winrate": 0.0, + "final": 10000.0, + "fee_used": 0.0005, + "regime_loaded": "range" + }, + "all": { + "profit_pct": -3.9055710949000004, + "trades": 7, + "dd_pct": 6.479119889400008, + "pf": 0.39720654015221873, + "winrate": 14.285714285714285, + "final": 9609.44289051, + "fee_used": 0.0005, + "regime_loaded": "all" + } + }, + "cost_stress": { + "fee_5bps": { + "profit_pct": -3.9055710949000004, + "trades": 7, + "dd_pct": 6.479119889400008, + "pf": 0.39720654015221873, + "winrate": 14.285714285714285, + "final": 9609.44289051, + "fee_used": 0.0005, + "regime_loaded": "trend" + }, + "fee_5bps+slip_5bps": { + "profit_pct": -4.5549168852, + "trades": 7, + "dd_pct": 7.020877735199993, + "pf": 0.3512325585213674, + "winrate": 14.285714285714285, + "final": 9544.50831148, + "fee_used": 0.001, + "regime_loaded": "trend" + }, + "fee_10bps+slip_10bps": { + "profit_pct": -5.371762636800001, + "trades": 7, + "dd_pct": 8.1297357765, + "pf": 0.33924511392759654, + "winrate": 14.285714285714285, + "final": 9462.82373632, + "fee_used": 0.002, + "regime_loaded": "trend" + } + }, + "target": { + "pf": 1.2, + "dd": 15.0, + "note": "LPS V2: 4h SOS→1h LPS; PF>1.2; ~5-15/yr" + }, + "verdict": { + "full_pf": 0.39720654015221873, + "full_dd": 6.479119889400008, + "trades_per_year": 1.9444444444444444, + "net_mid_pf": 0.3512325585213674, + "target_pf_ok": false, + "target_dd_ok": true, + "freq_ok": false, + "regime_logic_ok": true, + "status": "FAIL", + "hypothesis": "4h native SOS → 1h LPS" + } +} \ No newline at end of file diff --git a/scripts/wyckoff_gate_oos.py b/scripts/wyckoff_gate_oos.py new file mode 100644 index 0000000..b8240a2 --- /dev/null +++ b/scripts/wyckoff_gate_oos.py @@ -0,0 +1,221 @@ +#!/usr/bin/env python3 +""" +Market State Gate OOS — Baseline vs Gated(Spring 冻结) + +比较: + A) Wyckoff_BTC_V1_BASELINE — Spring always (within trend regime) + B) Wyckoff_BTC_GATED — Spring only when causal state gate opens + +阈值先验固定,不对 2023+ 做网格搜索。 + +指标: net PF / DD / n / worst year / max consecutive losses +""" +from __future__ import annotations + +import json +import logging +import sys +from pathlib import Path +from typing import Any + +import numpy as np + +ROOT = Path(__file__).resolve().parents[3] +sys.path.insert(0, str(ROOT)) + +from user_data.Chan.scripts.wyckoff_tf_grid import install_offline_markets # noqa: E402 + +OUT = ROOT / "user_data/Chan/scripts/wyckoff_gate_oos_result.json" +PAIR = "BTC/USDT:USDT" + +WINDOWS = [ + ("define_pre2023", "20190901-20230101"), # 观察区(不调参) + ("oos_2023plus", "20230101-"), + ("full", "20190901-"), + ("y2020", "20200101-20210101"), + ("y2021", "20210101-20220101"), + ("y2022", "20220101-20230101"), + ("y2023", "20230101-20240101"), + ("y2024", "20240101-20250101"), + ("y2025", "20250101-20260101"), +] + +STRATS = [ + { + "name": "baseline", + "strategy": "Wyckoff_BTC_V1_BASELINE", + "config": ROOT / "user_data/Chan/config/Wyckoff_BTC_V1_BASELINE.json", + }, + { + "name": "gated", + "strategy": "Wyckoff_BTC_GATED", + "config": ROOT / "user_data/Chan/config/Wyckoff_BTC_GATED.json", + }, +] + + +def _max_consecutive_losses(profits: list[float]) -> int: + best = cur = 0 + for p in profits: + if p <= 0: + cur += 1 + best = max(best, cur) + else: + cur = 0 + return best + + +def _worst_year(trades: list[dict]) -> dict[str, Any]: + by_y: dict[str, float] = {} + for t in trades: + ed = t.get("open_date") or t.get("entry_date") or "" + y = str(ed)[:4] + if len(y) < 4: + continue + by_y[y] = by_y.get(y, 0.0) + float(t.get("profit_ratio") or 0.0) * 100 + if not by_y: + return {"year": None, "sum_pct": 0.0} + y, v = min(by_y.items(), key=lambda x: x[1]) + return {"year": y, "sum_pct": round(v, 2)} + + +def run_one(strategy: str, config_path: Path, timerange: str) -> dict[str, Any]: + from freqtrade.configuration import Configuration + from freqtrade.enums import RunMode + from freqtrade.optimize.backtesting import Backtesting + from freqtrade.persistence import LocalTrade + import freqtrade.optimize.optimize_reports.bt_output as bt_output + + bt_output.show_backtest_results = lambda *a, **k: None # type: ignore + for mod in list(sys.modules): + if "Wyckoff_BTC" in mod: + del sys.modules[mod] + + config = Configuration.from_files([str(config_path)]) + config.update( + { + "strategy": strategy, + "strategy_path": str(ROOT / "user_data/Chan/strategies"), + "timerange": timerange, + "timeframe": "1h", + "export": "none", + "runmode": RunMode.BACKTEST, + "datadir": ROOT / "user_data/data/binance", + "user_data_dir": ROOT / "user_data", + "enable_protections": False, + "fee": 0.0010, # 5bps fee + 5bps slip + "exchange": { + **config.get("exchange", {}), + "name": "binance", + "pair_whitelist": [PAIR], + }, + } + ) + bt = Backtesting(config) + bt.start() + st = bt.results["strategy"].get(strategy) or list(bt.results["strategy"].values())[0] + profit = st.get("profit_total_pct") + if profit is None: + profit = float(st.get("profit_total") or 0) * 100 + + trade_rows = [] + profits = [] + for t in LocalTrade.bt_trades: + pr = float(t.close_profit or 0.0) + profits.append(pr) + trade_rows.append( + { + "open_date": t.open_date_utc.isoformat() if t.open_date_utc else "", + "enter_tag": t.enter_tag or "", + "profit_ratio": pr, + } + ) + + return { + "timerange": timerange, + "profit_pct": float(profit), + "trades": int(st.get("total_trades") or 0), + "dd_pct": float(st.get("max_drawdown_account") or 0) * 100, + "pf": float(st.get("profit_factor") or 0), + "winrate": float(st.get("winrate") or 0) * 100, + "max_consec_loss": _max_consecutive_losses(profits), + "worst_year": _worst_year(trade_rows), + } + + +def main() -> None: + logging.getLogger("freqtrade").setLevel(logging.ERROR) + install_offline_markets([PAIR]) + + results: dict[str, Any] = { + "pair": PAIR, + "fee_model": "fee 5bps + slip 5bps", + "gate": { + "version": "v1.1_state_set", + "spring": "market_state ∈ {accumulation, markup}", + "utad": "market_state ∈ {distribution, markdown}", + "note": "Causal 8h EMA/slope rules (= attribution labels). Scores kept for observability. Not grid-searched on 2023+.", + "v1_score_threshold": "FAILED OOS (destroyed 2023+ PF 1.45→0.67); archived as too misaligned", + }, + "windows": {}, + "verdict": {}, + } + + print("===== Market State Gate OOS (BTC) =====", flush=True) + for wname, tr in WINDOWS: + print(f"\n--- {wname} {tr} ---", flush=True) + block = {} + for s in STRATS: + r = run_one(s["strategy"], s["config"], tr) + block[s["name"]] = r + print( + f" {s['name']:<9} profit={r['profit_pct']:>7.2f}% n={r['trades']:<3} " + f"dd={r['dd_pct']:.1f}% pf={r['pf']:.2f} " + f"mcl={r['max_consec_loss']} worst={r['worst_year']}", + flush=True, + ) + # delta gated - baseline + b, g = block["baseline"], block["gated"] + block["delta_gated_minus_baseline"] = { + "pf": round(g["pf"] - b["pf"], 3), + "dd_pct": round(g["dd_pct"] - b["dd_pct"], 3), + "trades": g["trades"] - b["trades"], + "profit_pct": round(g["profit_pct"] - b["profit_pct"], 3), + "max_consec_loss": g["max_consec_loss"] - b["max_consec_loss"], + } + results["windows"][wname] = block + + oos_b = results["windows"]["oos_2023plus"]["baseline"] + oos_g = results["windows"]["oos_2023plus"]["gated"] + full_b = results["windows"]["full"]["baseline"] + full_g = results["windows"]["full"]["gated"] + pre_b = results["windows"]["define_pre2023"]["baseline"] + pre_g = results["windows"]["define_pre2023"]["gated"] + + results["verdict"] = { + "oos_gated_pf_ge_baseline": oos_g["pf"] >= oos_b["pf"] - 1e-9, + "oos_gated_pf_ge_1_2": oos_g["pf"] >= 1.2, + "oos_gated_dd_le_baseline": oos_g["dd_pct"] <= oos_b["dd_pct"] + 1e-9, + "full_gated_pf_gt_baseline": full_g["pf"] > full_b["pf"], + "pre2023_not_catastrophically_worse": pre_g["pf"] >= pre_b["pf"] - 0.15, + "status": ( + "PASS" + if ( + oos_g["pf"] >= 1.2 + and oos_g["dd_pct"] <= oos_b["dd_pct"] + 0.5 + and full_g["pf"] > full_b["pf"] + ) + else "PARTIAL" + if (oos_g["pf"] >= oos_b["pf"] and full_g["pf"] >= full_b["pf"]) + else "FAIL" + ), + "note": "Gate must not destroy 2023+ edge; should improve or stabilize full-sample robustness.", + } + print("\n===== Verdict =====") + print(json.dumps(results["verdict"], indent=2, ensure_ascii=False)) + OUT.write_text(json.dumps(results, indent=2, ensure_ascii=False)) + print(f"Saved {OUT}") + + +if __name__ == "__main__": + main() diff --git a/scripts/wyckoff_gate_robustness_slices.py b/scripts/wyckoff_gate_robustness_slices.py new file mode 100644 index 0000000..b1a8ed5 --- /dev/null +++ b/scripts/wyckoff_gate_robustness_slices.py @@ -0,0 +1,240 @@ +#!/usr/bin/env python3 +""" +Gate v1.1 冻结前小范围稳健性确认(不改 Spring / 不调 soft-score) + +在 Baseline SPRING_LONG 全集上: + - 按年份、era 切片 + - 看 blocked 是否仍主要来自 distribution + - kept vs blocked 的 PF 关系是否稳定 + +range 只作观察桶,不改交易规则。 +""" +from __future__ import annotations + +import json +import logging +import sys +from collections import defaultdict +from pathlib import Path +from typing import Any + +import numpy as np +import pandas as pd + +ROOT = Path(__file__).resolve().parents[3] +sys.path.insert(0, str(ROOT)) +sys.path.insert(0, str(ROOT / "user_data/Chan")) + +from engine.market_state import compute_market_state_8h # noqa: E402 +from user_data.Chan.scripts.wyckoff_tf_grid import install_offline_markets # noqa: E402 + +AUDIT = ROOT / "user_data/Chan/scripts/wyckoff_negative_domain_audit_result.json" +OUT = ROOT / "user_data/Chan/scripts/wyckoff_gate_robustness_slices_result.json" +PAIR = "BTC/USDT:USDT" +CFG = ROOT / "user_data/Chan/config/Wyckoff_BTC_V1_BASELINE.json" + + +def _pf(ps: list[float]) -> float: + wins = [p for p in ps if p > 0] + losses = [-p for p in ps if p <= 0] + gw, gl = sum(wins), sum(losses) + if gl <= 0: + return 999.0 if gw > 0 else 0.0 + return gw / gl + + +def _stats(ps: list[float]) -> dict[str, Any]: + if not ps: + return {"n": 0, "pf": 0.0, "sum_pct": 0.0, "winrate": 0.0} + return { + "n": len(ps), + "pf": round(_pf(ps), 3), + "sum_pct": round(100.0 * float(np.sum(ps)), 2), + "winrate": round(100.0 * sum(1 for p in ps if p > 0) / len(ps), 1), + } + + +def load_annotated_springs() -> list[dict[str, Any]]: + """复用 audit 逻辑,产出逐笔 annotated SPRING。""" + from freqtrade.configuration import Configuration + from freqtrade.enums import RunMode + from freqtrade.optimize.backtesting import Backtesting + from freqtrade.persistence import LocalTrade + import freqtrade.optimize.optimize_reports.bt_output as bt_output + + bt_output.show_backtest_results = lambda *a, **k: None # type: ignore + for mod in list(sys.modules): + if "Wyckoff_BTC" in mod: + del sys.modules[mod] + + cfg = Configuration.from_files([str(CFG)]) + cfg.update( + { + "strategy": "Wyckoff_BTC_V1_BASELINE", + "strategy_path": str(ROOT / "user_data/Chan/strategies"), + "timerange": "20190901-", + "timeframe": "1h", + "export": "none", + "runmode": RunMode.BACKTEST, + "datadir": ROOT / "user_data/data/binance", + "user_data_dir": ROOT / "user_data", + "enable_protections": False, + "fee": 0.0010, + "exchange": { + **cfg.get("exchange", {}), + "name": "binance", + "pair_whitelist": [PAIR], + }, + } + ) + bt = Backtesting(cfg) + bt.start() + + h8 = pd.read_feather(ROOT / "user_data/data/binance/futures/BTC_USDT_USDT-8h-futures.feather") + h8["date"] = pd.to_datetime(h8["date"], utc=True) + h8 = compute_market_state_8h(h8).set_index("date").sort_index() + + rows = [] + for t in LocalTrade.bt_trades: + if "SPRING" not in (t.enter_tag or ""): + continue + ed = pd.Timestamp(t.open_date_utc) + if ed.tzinfo is None: + ed = ed.tz_localize("UTC") + idx = h8.index.get_indexer([ed], method="ffill")[0] + if idx < 0: + continue + st = h8.iloc[idx] + state = str(st["market_state"]) + rows.append( + { + "entry_date": ed.isoformat(), + "year": str(ed.year), + "era": "2023plus" if ed >= pd.Timestamp("2023-01-01", tz="UTC") else "pre_2023", + "market_state": state, + "allow_spring": bool(st["allow_spring"]), + "profit_ratio": float(t.close_profit or 0.0), + } + ) + return rows + + +def slice_report(rows: list[dict], key: str) -> dict[str, Any]: + out: dict[str, Any] = {} + groups: dict[str, list[dict]] = defaultdict(list) + for r in rows: + groups[str(r[key])].append(r) + for k, rs in sorted(groups.items()): + kept = [x for x in rs if x["allow_spring"]] + blocked = [x for x in rs if not x["allow_spring"]] + b_by_state: dict[str, list[float]] = defaultdict(list) + for x in blocked: + b_by_state[x["market_state"]].append(x["profit_ratio"]) + blocked_states = {s: _stats(ps) for s, ps in b_by_state.items()} + dist_n = blocked_states.get("distribution", {}).get("n", 0) + blocked_n = len(blocked) + out[k] = { + "n_total": len(rs), + "kept": _stats([x["profit_ratio"] for x in kept]), + "blocked": _stats([x["profit_ratio"] for x in blocked]), + "blocked_by_state": blocked_states, + "blocked_distribution_share": round(dist_n / blocked_n, 3) if blocked_n else None, + "blocked_all_bad": ( + all(s in ("distribution", "markdown", "range") for s in blocked_states) + if blocked_n + else True + ), + } + return out + + +def main() -> None: + logging.getLogger("freqtrade").setLevel(logging.ERROR) + install_offline_markets([PAIR]) + + print("===== Annotate SPRING_LONG =====", flush=True) + rows = load_annotated_springs() + print(f" n={len(rows)}", flush=True) + + by_year = slice_report(rows, "year") + by_era = slice_report(rows, "era") + + # 稳定性:有 blocked 的切片里,distribution 是否为第一大来源 + dist_primary = [] + for label, block in {**{f"year:{k}": v for k, v in by_year.items()}, **{f"era:{k}": v for k, v in by_era.items()}}.items(): + bn = block["blocked"]["n"] + if bn < 2: + continue + states = block["blocked_by_state"] + top = max(states.items(), key=lambda x: x[1]["n"])[0] if states else None + dist_primary.append( + { + "slice": label, + "blocked_n": bn, + "top_blocked_state": top, + "distribution_share": block["blocked_distribution_share"], + "blocked_pf": block["blocked"]["pf"], + "kept_pf": block["kept"]["pf"], + } + ) + + n_slices = len(dist_primary) + n_dist_top = sum(1 for x in dist_primary if x["top_blocked_state"] == "distribution") + n_dist_ge_50 = sum( + 1 for x in dist_primary if (x["distribution_share"] or 0) >= 0.5 + ) + + result = { + "n_spring": len(rows), + "by_year": by_year, + "by_era": by_era, + "slice_summaries": dist_primary, + "range_observation_only": { + "note": "range 不作交易规则;仅观察 blocked 中的占比与 PF", + "blocked_range_global": _stats( + [r["profit_ratio"] for r in rows if (not r["allow_spring"] and r["market_state"] == "range")] + ), + }, + "verdict": { + "slices_with_blocked_ge_2": n_slices, + "distribution_is_top_blocked_state": n_dist_top, + "distribution_share_ge_50pct_slices": n_dist_ge_50, + "distribution_attribution_stable": ( + n_slices > 0 and (n_dist_top / n_slices) >= 0.6 + ), + "status": ( + "PASS" + if n_slices > 0 and (n_dist_top / n_slices) >= 0.6 + else "PARTIAL" + if n_dist_ge_50 >= max(1, n_slices // 2) + else "FAIL" + ), + "note": "PASS = across year/era slices, blocked mass still led by distribution.", + }, + } + + print("\n===== By year (blocked focus) =====", flush=True) + for y, b in by_year.items(): + print( + f" {y}: total={b['n_total']} kept_pf={b['kept']['pf']} " + f"blocked_n={b['blocked']['n']} blocked_pf={b['blocked']['pf']} " + f"dist_share={b['blocked_distribution_share']} states={list(b['blocked_by_state'])}", + flush=True, + ) + print("\n===== By era =====", flush=True) + for e, b in by_era.items(): + print( + f" {e}: total={b['n_total']} kept_pf={b['kept']['pf']} " + f"blocked_n={b['blocked']['n']} blocked_pf={b['blocked']['pf']} " + f"dist_share={b['blocked_distribution_share']} states={list(b['blocked_by_state'])}", + flush=True, + ) + print("\n===== Verdict =====", flush=True) + print(json.dumps(result["verdict"], indent=2, ensure_ascii=False)) + + OUT.write_text(json.dumps(result, indent=2, ensure_ascii=False)) + print(f"\nSaved {OUT}") + + +if __name__ == "__main__": + main() diff --git a/scripts/wyckoff_negative_domain_audit.py b/scripts/wyckoff_negative_domain_audit.py new file mode 100644 index 0000000..45536cc --- /dev/null +++ b/scripts/wyckoff_negative_domain_audit.py @@ -0,0 +1,239 @@ +#!/usr/bin/env python3 +""" +Negative-domain audit + +问题:Gate 拦掉的 Spring,是否集中死在 distribution | markdown | range(结构性错误), + 而不是偶然删掉赚钱样本? + +方法(Spring 冻结,Gate=state_set): + 1) 跑 Baseline,取出全部 SPRING_LONG 成交 + 2) 用因果 8h market_state 标注入场时状态 + 3) 按 allow_spring 分成 kept vs blocked + 4) 比较各域 n / PF / winrate / sum% + +判定: + - blocked 主要落在 bad domains + - blocked 整体 PF << kept(或明显更差) + - kept 域仍以 accumulation|markup 为主 +""" +from __future__ import annotations + +import json +import logging +import sys +from collections import defaultdict +from pathlib import Path +from typing import Any + +import numpy as np +import pandas as pd + +ROOT = Path(__file__).resolve().parents[3] +sys.path.insert(0, str(ROOT)) +sys.path.insert(0, str(ROOT / "user_data/Chan")) + +from engine.market_state import compute_market_state_8h # noqa: E402 +from user_data.Chan.scripts.wyckoff_tf_grid import install_offline_markets # noqa: E402 + +OUT = ROOT / "user_data/Chan/scripts/wyckoff_negative_domain_audit_result.json" +PAIR = "BTC/USDT:USDT" +CFG = ROOT / "user_data/Chan/config/Wyckoff_BTC_V1_BASELINE.json" +GOOD = {"accumulation", "markup"} +BAD = {"distribution", "markdown", "range"} + + +def _pf(ps: list[float]) -> float: + wins = [p for p in ps if p > 0] + losses = [-p for p in ps if p <= 0] + gw, gl = sum(wins), sum(losses) + if gl <= 0: + return 999.0 if gw > 0 else 0.0 + return gw / gl + + +def _stats(ps: list[float]) -> dict[str, Any]: + if not ps: + return {"n": 0, "pf": 0.0, "winrate": 0.0, "sum_pct": 0.0, "avg_pct": 0.0} + return { + "n": len(ps), + "pf": round(_pf(ps), 3), + "winrate": round(100.0 * sum(1 for p in ps if p > 0) / len(ps), 1), + "sum_pct": round(100.0 * float(np.sum(ps)), 2), + "avg_pct": round(100.0 * float(np.mean(ps)), 2), + } + + +def run_baseline_spring_trades() -> list[dict[str, Any]]: + from freqtrade.configuration import Configuration + from freqtrade.enums import RunMode + from freqtrade.optimize.backtesting import Backtesting + from freqtrade.persistence import LocalTrade + import freqtrade.optimize.optimize_reports.bt_output as bt_output + + bt_output.show_backtest_results = lambda *a, **k: None # type: ignore + for mod in list(sys.modules): + if "Wyckoff_BTC" in mod: + del sys.modules[mod] + + cfg = Configuration.from_files([str(CFG)]) + cfg.update( + { + "strategy": "Wyckoff_BTC_V1_BASELINE", + "strategy_path": str(ROOT / "user_data/Chan/strategies"), + "timerange": "20190901-", + "timeframe": "1h", + "export": "none", + "runmode": RunMode.BACKTEST, + "datadir": ROOT / "user_data/data/binance", + "user_data_dir": ROOT / "user_data", + "enable_protections": False, + "fee": 0.0010, + "exchange": { + **cfg.get("exchange", {}), + "name": "binance", + "pair_whitelist": [PAIR], + }, + } + ) + bt = Backtesting(cfg) + bt.start() + rows = [] + for t in LocalTrade.bt_trades: + tag = t.enter_tag or "" + if "SPRING" not in tag: + continue + rows.append( + { + "entry_date": t.open_date_utc.isoformat() if t.open_date_utc else "", + "exit_date": t.close_date_utc.isoformat() if t.close_date_utc else "", + "enter_tag": tag, + "profit_ratio": float(t.close_profit or 0.0), + "era": ( + "2023plus" + if t.open_date_utc and t.open_date_utc >= pd.Timestamp("2023-01-01", tz="UTC") + else "pre_2023" + ), + } + ) + return rows + + +def annotate(trades: list[dict[str, Any]]) -> list[dict[str, Any]]: + h8 = pd.read_feather(ROOT / "user_data/data/binance/futures/BTC_USDT_USDT-8h-futures.feather") + h8["date"] = pd.to_datetime(h8["date"], utc=True) + h8 = compute_market_state_8h(h8).set_index("date").sort_index() + + out = [] + for t in trades: + ed = pd.Timestamp(t["entry_date"]) + if ed.tzinfo is None: + ed = ed.tz_localize("UTC") + idx = h8.index.get_indexer([ed], method="ffill")[0] + if idx < 0: + continue + row = h8.iloc[idx] + state = str(row["market_state"]) + allowed = bool(row["allow_spring"]) + rec = { + **t, + "market_state": state, + "allow_spring": allowed, + "domain": "good" if state in GOOD else ("bad" if state in BAD else "other"), + "accumulation_score": float(row["accumulation_score"]), + "markup_score": float(row["markup_score"]), + "distribution_score": float(row["distribution_score"]), + "markdown_score": float(row["markdown_score"]), + "range_score": float(row["range_score"]), + } + out.append(rec) + return out + + +def bucket(rows: list[dict], key: str) -> dict[str, Any]: + g: dict[str, list[float]] = defaultdict(list) + for r in rows: + g[str(r[key])].append(float(r["profit_ratio"])) + return {k: _stats(v) for k, v in sorted(g.items(), key=lambda x: -len(x[1]))} + + +def main() -> None: + logging.getLogger("freqtrade").setLevel(logging.ERROR) + install_offline_markets([PAIR]) + + print("===== Baseline SPRING_LONG trades =====", flush=True) + raw = run_baseline_spring_trades() + print(f" spring trades={len(raw)}", flush=True) + rows = annotate(raw) + kept = [r for r in rows if r["allow_spring"]] + blocked = [r for r in rows if not r["allow_spring"]] + + result: dict[str, Any] = { + "pair": PAIR, + "fee_model": "fee5bps+slip5bps", + "n_spring_total": len(rows), + "n_kept": len(kept), + "n_blocked": len(blocked), + "kept": { + "overall": _stats([r["profit_ratio"] for r in kept]), + "by_state": bucket(kept, "market_state"), + "by_era": bucket(kept, "era"), + }, + "blocked": { + "overall": _stats([r["profit_ratio"] for r in blocked]), + "by_state": bucket(blocked, "market_state"), + "by_era": bucket(blocked, "era"), + "by_domain": bucket(blocked, "domain"), + }, + "blocked_share_by_state": {}, + "verdict": {}, + } + + # blocked 状态占比 + if blocked: + for st, stt in result["blocked"]["by_state"].items(): + result["blocked_share_by_state"][st] = round(stt["n"] / len(blocked), 3) + + bad_n = sum(result["blocked"]["by_state"].get(s, {}).get("n", 0) for s in BAD) + blocked_bad_share = (bad_n / len(blocked)) if blocked else 0.0 + kept_good_share = 0.0 + if kept: + kg = sum(1 for r in kept if r["market_state"] in GOOD) + kept_good_share = kg / len(kept) + + bk = result["blocked"]["overall"] + kp = result["kept"]["overall"] + result["verdict"] = { + "blocked_mostly_bad_domain": blocked_bad_share >= 0.8, + "blocked_bad_share": round(blocked_bad_share, 3), + "kept_mostly_good_domain": kept_good_share >= 0.95, + "kept_good_share": round(kept_good_share, 3), + "blocked_pf_worse_than_kept": bk["pf"] < kp["pf"], + "blocked_pf": bk["pf"], + "kept_pf": kp["pf"], + "status": ( + "PASS" + if ( + blocked_bad_share >= 0.8 + and kept_good_share >= 0.95 + and bk["pf"] < kp["pf"] + ) + else "PARTIAL" + if (blocked_bad_share >= 0.7 and bk["pf"] <= kp["pf"]) + else "FAIL" + ), + "note": "PASS = Gate filters structural bad domains, not random sample deletion.", + } + + print("\n===== KEPT (allow_spring) =====", flush=True) + print(json.dumps(result["kept"], indent=2, ensure_ascii=False)) + print("\n===== BLOCKED =====", flush=True) + print(json.dumps(result["blocked"], indent=2, ensure_ascii=False)) + print("\n===== Verdict =====", flush=True) + print(json.dumps(result["verdict"], indent=2, ensure_ascii=False)) + + OUT.write_text(json.dumps(result, indent=2, ensure_ascii=False)) + print(f"\nSaved {OUT}") + + +if __name__ == "__main__": + main() diff --git a/scripts/wyckoff_param_grid.py b/scripts/wyckoff_param_grid.py new file mode 100644 index 0000000..dced324 --- /dev/null +++ b/scripts/wyckoff_param_grid.py @@ -0,0 +1,145 @@ +#!/usr/bin/env python3 +"""在最优周期 1h/4h/8h 上扫 ATR 与关键参数。""" +from __future__ import annotations + +import itertools +import json +import logging +import re +import sys +from pathlib import Path +from typing import Any + +ROOT = Path(__file__).resolve().parents[3] +sys.path.insert(0, str(ROOT)) + +from user_data.Chan.scripts.wyckoff_tf_grid import ( # noqa: E402 + STRAT_PATH, + install_offline_markets, + patch_strategy, + run_one, +) + +# 参数名 -> (正则匹配赋值行前缀, 候选值列表) +PARAM_GRID = { + "atr_sl_mult": ( + r'^(\tatr_sl_mult = DecimalParameter\([^\n]*default=)([0-9.]+)', + [1.5, 2.0, 2.5, 3.0], + ), + "vol_spike_mult": ( + r'^(\tvol_spike_mult = DecimalParameter\([^\n]*default=)([0-9.]+)', + [1.2, 1.4, 1.8], + ), + "spring_pierce_pct": ( + r'^(\tspring_pierce_pct = DecimalParameter\([^\n]*default=)([0-9.]+)', + [0.002, 0.004, 0.008], + ), + "range_lookback": ( + r'^(\trange_lookback = IntParameter\([^\n]*default=)([0-9]+)', + [18, 24, 36], + ), +} + + +def set_defaults(text: str, values: dict[str, Any]) -> str: + for key, (pat, _) in PARAM_GRID.items(): + val = values[key] + text = re.sub(pat, rf"\g<1>{val}", text, count=1, flags=re.M) + return text + + +def main() -> None: + logging.getLogger("freqtrade").setLevel(logging.ERROR) + timerange = sys.argv[1] if len(sys.argv) > 1 else "20240101-" + install_offline_markets() + orig = STRAT_PATH.read_text() + + keys = list(PARAM_GRID.keys()) + combos = list(itertools.product(*[PARAM_GRID[k][1] for k in keys])) + # 全组合太多:改为坐标下降式 — 先基线,再逐参扫描 + base = {k: PARAM_GRID[k][1][len(PARAM_GRID[k][1]) // 2] for k in keys} + # 确保与当前文件接近的中心点 + base.update( + { + "atr_sl_mult": 2.0, + "vol_spike_mult": 1.4, + "spring_pierce_pct": 0.004, + "range_lookback": 24, + } + ) + + trials = [dict(base)] + for k in keys: + for v in PARAM_GRID[k][1]: + if v == base[k]: + continue + t = dict(base) + t[k] = v + trials.append(t) + + rows = [] + try: + patch_strategy("1h", "4h", "8h") + for i, vals in enumerate(trials): + text = set_defaults(STRAT_PATH.read_text(), vals) + STRAT_PATH.write_text(text) + label = ",".join(f"{k}={vals[k]}" for k in keys) + print(f"[{i+1}/{len(trials)}] {label}", flush=True) + try: + res = run_one("1h", timerange) + res.update(vals) + res["label"] = label + res["ok"] = True + except Exception as e: + res = {"ok": False, "error": str(e), "label": label, **vals} + rows.append(res) + if res.get("ok"): + print( + f" -> profit={res['profit_pct']:.2f}% trades={res['trades']} " + f"dd={res['dd_pct']:.2f}% pf={res['pf']:.2f}", + flush=True, + ) + else: + print(f" FAILED {res.get('error')}", flush=True) + finally: + STRAT_PATH.write_text(orig) + + ok = [r for r in rows if r.get("ok")] + ok.sort(key=lambda r: (r["profit_pct"], r["pf"]), reverse=True) + print("\n========== PARAM RANKING ==========") + for r in ok[:10]: + print( + f"{r['profit_pct']:>7.2f}% pf={r['pf']:.2f} dd={r['dd_pct']:.1f}% " + f"n={r['trades']:<3} {r['label']}" + ) + out = ROOT / "user_data/Chan/scripts/wyckoff_param_grid_result.txt" + out.write_text(json.dumps({"timerange": timerange, "rows": rows}, indent=2)) + print(f"\nSaved {out}") + if ok: + best = ok[0] + print("\nBEST params:", {k: best[k] for k in keys}) + # 写回最优 default + text = set_defaults(orig, {k: best[k] for k in keys}) + # 保持最优周期 + text2 = text + text2 = re.sub(r'^(\ttimeframe = ).*$', r'\g<1>"1h"', text2, count=1, flags=re.M) + text2 = re.sub( + r'^(\tstructure_timeframe = ).*$', r'\g<1>"4h"', text2, count=1, flags=re.M + ) + text2 = re.sub( + r'^(\tbias_timeframe: Optional\[str\] = ).*$', + r'\g<1>"8h"', + text2, + count=1, + flags=re.M, + ) + STRAT_PATH.write_text(text2) + print("Wrote best defaults into Wyckoff_BTC.py") + # 长周期验证 + print("\nValidate 20230101- ...", flush=True) + res = run_one("1h", "20230101-") + print(res) + + +if __name__ == "__main__": + main() diff --git a/scripts/wyckoff_phase2_compare.py b/scripts/wyckoff_phase2_compare.py new file mode 100644 index 0000000..0ca9973 --- /dev/null +++ b/scripts/wyckoff_phase2_compare.py @@ -0,0 +1,206 @@ +#!/usr/bin/env python3 +""" +Wyckoff Phase2 对比:同一数据 / 同一成本 / 同一 WFO / 同一 Regime + +对比: + - Wyckoff_BTC_V1_BASELINE (Spring, range off) + - Wyckoff_BTC_LPS (LPS continuation, range off) + +统一看 net PF(fee 计入)。 +""" +from __future__ import annotations + +import json +import logging +import sys +from pathlib import Path +from typing import Any, Optional + +ROOT = Path(__file__).resolve().parents[3] +sys.path.insert(0, str(ROOT)) + +from user_data.Chan.scripts.wyckoff_tf_grid import install_offline_markets # noqa: E402 + +OUT = ROOT / "user_data/Chan/scripts/wyckoff_phase2_compare_result.json" + +WFO = [ + ("train", "20230101-20250101"), + ("validate", "20250101-20260101"), + ("test", "20260101-"), + ("full", "20230101-"), +] + +BRANCHES = [ + { + "name": "Spring_V1", + "strategy": "Wyckoff_BTC_V1_BASELINE", + "config": ROOT / "user_data/Chan/config/Wyckoff_BTC_V1_BASELINE.json", + "target": {"pf": 1.3, "dd": 10.0, "note": "Spring: PF>1.3 DD<10%"}, + }, + { + "name": "LPS_V2", + "strategy": "Wyckoff_BTC_LPS", + "config": ROOT / "user_data/Chan/config/Wyckoff_BTC_LPS.json", + "target": {"pf": 1.2, "dd": 15.0, "note": "LPS V2: 4h SOS→1h LPS; PF>1.2; ~5-15/yr"}, + }, +] + + +def run_bt( + strategy: str, + config_path: Path, + timerange: str, + *, + fee: float = 0.0005, + extra_cost: float = 0.0, + regime: Optional[str] = None, +) -> dict[str, Any]: + from freqtrade.configuration import Configuration + from freqtrade.enums import RunMode + from freqtrade.optimize.backtesting import Backtesting + import freqtrade.optimize.optimize_reports.bt_output as bt_output + + bt_output.show_backtest_results = lambda *a, **k: None # type: ignore + + for mod in list(sys.modules): + if strategy in mod or "Wyckoff_BTC" in mod: + del sys.modules[mod] + + # 可选:临时改 regime_mode(写文件) + strat_path = ROOT / "user_data/Chan/strategies" / f"{strategy}.py" + orig = None + if regime is not None: + import re + orig = strat_path.read_text() + text2, n = re.subn( + r'^(\tregime_mode: str = )".*"', + rf'\g<1>"{regime}"', + orig, + count=1, + flags=re.M, + ) + if n == 0: + raise RuntimeError(f"regime_mode not found in {strategy}") + strat_path.write_text(text2) + pycache = strat_path.parent / "__pycache__" + if pycache.is_dir(): + for p in pycache.glob(f"{strategy}*.pyc"): + p.unlink(missing_ok=True) + + try: + config = Configuration.from_files([str(config_path)]) + config.update( + { + "strategy": strategy, + "strategy_path": str(ROOT / "user_data/Chan/strategies"), + "timerange": timerange, + "timeframe": "1h", + "export": "none", + "runmode": RunMode.BACKTEST, + "datadir": ROOT / "user_data/data/binance", + "user_data_dir": ROOT / "user_data", + "enable_protections": False, + "fee": fee + extra_cost, + } + ) + bt = Backtesting(config) + loaded = getattr(bt.strategylist[0], "regime_mode", None) + bt.start() + st = bt.results["strategy"].get(strategy) or list(bt.results["strategy"].values())[0] + profit = st.get("profit_total_pct") + if profit is None: + profit = float(st.get("profit_total") or 0) * 100 + return { + "profit_pct": float(profit), + "trades": int(st.get("total_trades") or 0), + "dd_pct": float(st.get("max_drawdown_account") or 0) * 100, + "pf": float(st.get("profit_factor") or 0), + "winrate": float(st.get("winrate") or 0) * 100, + "final": float(st.get("final_balance") or 0), + "fee_used": config["fee"], + "regime_loaded": loaded, + } + finally: + if orig is not None: + strat_path.write_text(orig) + + +def main() -> None: + logging.getLogger("freqtrade").setLevel(logging.ERROR) + install_offline_markets() + results: dict[str, Any] = {"branches": {}} + + for br in BRANCHES: + name = br["name"] + print(f"\n===== {name} ({br['strategy']}) =====", flush=True) + block: dict[str, Any] = {"wfo": {}, "regimes": {}, "cost_stress": {}, "target": br["target"]} + + print("--- WFO ---", flush=True) + for wname, tr in WFO: + r = run_bt(br["strategy"], br["config"], tr) + block["wfo"][wname] = {"timerange": tr, **r} + print( + f" {wname:<8} profit={r['profit_pct']:>7.2f}% n={r['trades']:<3} " + f"dd={r['dd_pct']:.1f}% pf={r['pf']:.2f} wr={r['winrate']:.1f}%", + flush=True, + ) + + print("--- Regime ---", flush=True) + for mode in ["trend", "bull", "bear", "range", "all"]: + r = run_bt(br["strategy"], br["config"], "20230101-", regime=mode) + block["regimes"][mode] = r + print( + f" {mode:<6} profit={r['profit_pct']:>7.2f}% n={r['trades']:<3} " + f"pf={r['pf']:.2f} (loaded={r['regime_loaded']})", + flush=True, + ) + + print("--- Cost (net PF) ---", flush=True) + for label, fee, extra in [ + ("fee_5bps", 0.0005, 0.0), + ("fee_5bps+slip_5bps", 0.0005, 0.0005), + ("fee_10bps+slip_10bps", 0.0010, 0.0010), + ]: + r = run_bt(br["strategy"], br["config"], "20230101-", fee=fee, extra_cost=extra) + block["cost_stress"][label] = r + flag = "OK" if r["pf"] >= br["target"]["pf"] else ("WEAK" if r["pf"] >= 1.0 else "FAIL") + print( + f" {label:<22} profit={r['profit_pct']:>7.2f}% n={r['trades']:<3} " + f"pf={r['pf']:.2f} [{flag}]", + flush=True, + ) + + full = block["wfo"]["full"] + mid = block["cost_stress"]["fee_5bps+slip_5bps"] + years = 3.6 # ~2023→2026.6 + tpy = full["trades"] / years if years else 0 + block["verdict"] = { + "full_pf": full["pf"], + "full_dd": full["dd_pct"], + "trades_per_year": tpy, + "net_mid_pf": mid["pf"], + "target_pf_ok": mid["pf"] >= br["target"]["pf"], + "target_dd_ok": full["dd_pct"] <= br["target"]["dd"], + } + results["branches"][name] = block + print(f"Verdict: {json.dumps(block['verdict'], ensure_ascii=False)}", flush=True) + + # 组合粗估:独立回测不可简单相加;只报告各自频率目标 + s = results["branches"]["Spring_V1"]["verdict"] + l = results["branches"]["LPS_V2"]["verdict"] + results["portfolio_note"] = { + "spring_tpy": s["trades_per_year"], + "lps_tpy": l["trades_per_year"], + "sum_tpy_approx": s["trades_per_year"] + l["trades_per_year"], + "combined_target_tpy": "10-20", + "warning": "频率可近似相加;PF/收益不可相加,需另做组合回测;Spring 冻结勿改", + } + print("\n===== Portfolio note =====") + print(json.dumps(results["portfolio_note"], ensure_ascii=False, indent=2)) + + OUT.write_text(json.dumps(results, indent=2, ensure_ascii=False)) + print(f"\nSaved {OUT}") + + +if __name__ == "__main__": + main() diff --git a/scripts/wyckoff_phase2_lps_v2.py b/scripts/wyckoff_phase2_lps_v2.py new file mode 100644 index 0000000..be207a9 --- /dev/null +++ b/scripts/wyckoff_phase2_lps_v2.py @@ -0,0 +1,113 @@ +#!/usr/bin/env python3 +""" +LPS V2 单独 Phase2(不改 Spring、不合并组合) + +同一 WFO / Regime / 成本模型。 +目标: net PF > 1.2;频率约 5-15/year。 +""" +from __future__ import annotations + +import json +import logging +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[3] +sys.path.insert(0, str(ROOT)) + +from user_data.Chan.scripts.wyckoff_phase2_compare import ( # noqa: E402 + BRANCHES, + WFO, + install_offline_markets, + run_bt, +) + +OUT = ROOT / "user_data/Chan/scripts/wyckoff_lps_v2_phase2_result.json" +COMPARE = ROOT / "user_data/Chan/scripts/wyckoff_phase2_compare_result.json" + + +def main() -> None: + logging.getLogger("freqtrade").setLevel(logging.ERROR) + install_offline_markets() + br = next(b for b in BRANCHES if b["name"] == "LPS_V2") + print(f"===== {br['name']} ({br['strategy']}) — LPS-only Phase2 =====", flush=True) + block = {"version": "LPS_V2", "wfo": {}, "regimes": {}, "cost_stress": {}, "target": br["target"]} + + print("--- WFO ---", flush=True) + for wname, tr in WFO: + r = run_bt(br["strategy"], br["config"], tr) + block["wfo"][wname] = {"timerange": tr, **r} + print( + f" {wname:<8} profit={r['profit_pct']:>7.2f}% n={r['trades']:<3} " + f"dd={r['dd_pct']:.1f}% pf={r['pf']:.2f} wr={r['winrate']:.1f}%", + flush=True, + ) + + print("--- Regime ---", flush=True) + for mode in ["trend", "bull", "bear", "range", "all"]: + r = run_bt(br["strategy"], br["config"], "20230101-", regime=mode) + block["regimes"][mode] = r + print( + f" {mode:<6} profit={r['profit_pct']:>7.2f}% n={r['trades']:<3} " + f"pf={r['pf']:.2f} (loaded={r['regime_loaded']})", + flush=True, + ) + + print("--- Cost (net PF) ---", flush=True) + for label, fee, extra in [ + ("fee_5bps", 0.0005, 0.0), + ("fee_5bps+slip_5bps", 0.0005, 0.0005), + ("fee_10bps+slip_10bps", 0.0010, 0.0010), + ]: + r = run_bt(br["strategy"], br["config"], "20230101-", fee=fee, extra_cost=extra) + block["cost_stress"][label] = r + flag = "OK" if r["pf"] >= br["target"]["pf"] else ("WEAK" if r["pf"] >= 1.0 else "FAIL") + print( + f" {label:<22} profit={r['profit_pct']:>7.2f}% n={r['trades']:<3} " + f"pf={r['pf']:.2f} [{flag}]", + flush=True, + ) + + full = block["wfo"]["full"] + mid = block["cost_stress"]["fee_5bps+slip_5bps"] + tpy = full["trades"] / 3.6 + trend_pf = block["regimes"]["trend"]["pf"] + range_pf = block["regimes"]["range"]["pf"] + block["verdict"] = { + "full_pf": full["pf"], + "full_dd": full["dd_pct"], + "trades_per_year": tpy, + "net_mid_pf": mid["pf"], + "target_pf_ok": mid["pf"] >= br["target"]["pf"], + "target_dd_ok": full["dd_pct"] <= br["target"]["dd"], + "freq_ok": 5.0 <= tpy <= 15.0, + "regime_logic_ok": trend_pf >= range_pf, # 趋势应不差于横盘 + "status": "PASS" if (mid["pf"] >= br["target"]["pf"] and full["dd_pct"] <= br["target"]["dd"]) else "FAIL", + "hypothesis": "4h native SOS → 1h LPS", + } + print("\n===== Verdict =====") + print(json.dumps(block["verdict"], ensure_ascii=False, indent=2)) + + OUT.write_text(json.dumps(block, indent=2, ensure_ascii=False)) + # 合并进 compare 结果(保留 Spring,覆盖 LPS) + if COMPARE.exists(): + prev = json.loads(COMPARE.read_text()) + else: + prev = {"branches": {}} + prev.setdefault("branches", {})["LPS_V2"] = block + # 清理旧 LPS_V1 key 的活跃地位,保留作历史可手动看 + spring = prev["branches"].get("Spring_V1", {}).get("verdict", {}) + prev["system_status"] = { + "spring": "BASELINE FROZEN / PASS + Limited Evidence", + "lps": block["verdict"]["status"], + "spring_tpy": spring.get("trades_per_year"), + "lps_tpy": tpy, + "next": "若 LPS PASS → 组合层;否则 Spring-only", + } + COMPARE.write_text(json.dumps(prev, indent=2, ensure_ascii=False)) + print(f"\nSaved {OUT}") + print("system_status:", json.dumps(prev["system_status"], ensure_ascii=False)) + + +if __name__ == "__main__": + main() diff --git a/scripts/wyckoff_phase2_robustness.py b/scripts/wyckoff_phase2_robustness.py new file mode 100644 index 0000000..1af5d8e --- /dev/null +++ b/scripts/wyckoff_phase2_robustness.py @@ -0,0 +1,185 @@ +#!/usr/bin/env python3 +""" +Wyckoff Phase 2:鲁棒性验证(固定当前参数,不再扫参) + +1) Walk-Forward:Train 2023-2024 / Validate 2025 / Test 2026 +2) 市场状态拆分:bull / bear / range(8h EMA200 语境) +3) 成本压力:抬高手续费 + 滑点后是否仍 PF>1.3 +""" +from __future__ import annotations + +import json +import logging +import re +import sys +from pathlib import Path +from typing import Any, Optional + +ROOT = Path(__file__).resolve().parents[3] +sys.path.insert(0, str(ROOT)) + +from user_data.Chan.scripts.wyckoff_tf_grid import ( # noqa: E402 + CONFIG_PATH, + STRAT_PATH, + install_offline_markets, + patch_strategy, +) + +OUT = ROOT / "user_data/Chan/scripts/wyckoff_phase2_result.json" + +WFO = [ + ("train", "20230101-20250101"), + ("validate", "20250101-20260101"), + ("test", "20260101-"), + ("full", "20230101-"), +] + + +def set_regime(mode: str) -> None: + text = STRAT_PATH.read_text() + text2, n = re.subn( + r'^(\tregime_mode: str = )".*"', + rf'\g<1>"{mode}"', + text, + count=1, + flags=re.M, + ) + if n == 0: + raise RuntimeError("regime_mode not found in strategy") + STRAT_PATH.write_text(text2) + # 清掉 bytecode,避免连续切换时读到旧 class 属性 + pycache = STRAT_PATH.parent / "__pycache__" + if pycache.is_dir(): + for p in pycache.glob("Wyckoff_BTC*.pyc"): + p.unlink(missing_ok=True) + + +def run_bt( + timerange: str, + *, + fee: Optional[float] = None, + extra_cost: float = 0.0, + regime: Optional[str] = None, +) -> dict[str, Any]: + from freqtrade.configuration import Configuration + from freqtrade.enums import RunMode + from freqtrade.optimize.backtesting import Backtesting + import freqtrade.optimize.optimize_reports.bt_output as bt_output + + bt_output.show_backtest_results = lambda *a, **k: None # type: ignore + + if regime is not None: + set_regime(regime) + + for mod in list(sys.modules): + if "Wyckoff_BTC" in mod: + del sys.modules[mod] + + config = Configuration.from_files([str(CONFIG_PATH)]) + config.update( + { + "strategy": "Wyckoff_BTC", + "strategy_path": str(ROOT / "user_data/Chan/strategies"), + "timerange": timerange, + "timeframe": "1h", + "export": "none", + "runmode": RunMode.BACKTEST, + "datadir": ROOT / "user_data/data/binance", + "user_data_dir": ROOT / "user_data", + "enable_protections": False, + } + ) + base_fee = 0.0005 if fee is None else fee + config["fee"] = base_fee + extra_cost + + bt = Backtesting(config) + loaded_regime = getattr(bt.strategylist[0], "regime_mode", None) + bt.start() + st = bt.results["strategy"].get("Wyckoff_BTC") or list(bt.results["strategy"].values())[0] + profit = st.get("profit_total_pct") + if profit is None: + profit = float(st.get("profit_total") or 0) * 100 + return { + "profit_pct": float(profit), + "trades": int(st.get("total_trades") or 0), + "dd_pct": float(st.get("max_drawdown_account") or 0) * 100, + "pf": float(st.get("profit_factor") or 0), + "winrate": float(st.get("winrate") or 0) * 100, + "final": float(st.get("final_balance") or 0), + "fee_used": config["fee"], + "regime_loaded": loaded_regime, + } + + +def main() -> None: + logging.getLogger("freqtrade").setLevel(logging.ERROR) + install_offline_markets() + orig = STRAT_PATH.read_text() + results: dict[str, Any] = {"wfo": {}, "regimes": {}, "cost_stress": {}} + + try: + patch_strategy("1h", "4h", "8h") + set_regime("all") + + print("===== 1) Walk-Forward (fixed params, no re-opt) =====") + for name, tr in WFO: + r = run_bt(tr) + results["wfo"][name] = {"timerange": tr, **r} + print( + f" {name:<8} {tr:<22} profit={r['profit_pct']:>7.2f}% " + f"n={r['trades']:<3} dd={r['dd_pct']:.1f}% pf={r['pf']:.2f} wr={r['winrate']:.1f}%", + flush=True, + ) + + print("\n===== 2) Regime split (20230101-) =====") + for mode in ["all", "bull", "bear", "range"]: + r = run_bt("20230101-", regime=mode) + results["regimes"][mode] = r + print( + f" {mode:<6} profit={r['profit_pct']:>7.2f}% n={r['trades']:<3} " + f"dd={r['dd_pct']:.1f}% pf={r['pf']:.2f} wr={r['winrate']:.1f}% " + f"(loaded={r.get('regime_loaded')})", + flush=True, + ) + set_regime("all") + + print("\n===== 3) Cost stress (20230101-) =====") + for label, fee, extra in [ + ("fee_5bps", 0.0005, 0.0), + ("fee_10bps", 0.0010, 0.0), + ("fee_5bps+slip_5bps", 0.0005, 0.0005), + ("fee_10bps+slip_10bps", 0.0010, 0.0010), + ]: + r = run_bt("20230101-", fee=fee, extra_cost=extra) + results["cost_stress"][label] = r + flag = "OK" if r["pf"] >= 1.3 else ("WEAK" if r["pf"] >= 1.0 else "FAIL") + print( + f" {label:<22} profit={r['profit_pct']:>7.2f}% n={r['trades']:<3} " + f"pf={r['pf']:.2f} [{flag}]", + flush=True, + ) + + wfo = results["wfo"] + results["verdict"] = { + "validate_profit_ok": wfo["validate"]["profit_pct"] > 0, + "validate_pf_ge_1": wfo["validate"]["pf"] >= 1.0, + "test_pf_ge_1": wfo["test"]["pf"] >= 1.0, + "cost_mid_pf_ge_1_3": results["cost_stress"]["fee_5bps+slip_5bps"]["pf"] >= 1.3, + "next": [ + "若 validate/test 稳定 → paper / 小资金", + "若仅 train 好 → 参数过拟合,冻结开发", + "可并行加 SOS/LPS 趋势跟随以提高频率", + ], + } + print("\n===== Verdict =====") + print(json.dumps(results["verdict"], ensure_ascii=False, indent=2)) + finally: + STRAT_PATH.write_text(orig) + print("\nRestored strategy file", flush=True) + + OUT.write_text(json.dumps(results, indent=2, ensure_ascii=False)) + print(f"Saved {OUT}") + + +if __name__ == "__main__": + main() diff --git a/scripts/wyckoff_phase3_evidence.py b/scripts/wyckoff_phase3_evidence.py new file mode 100644 index 0000000..900874c --- /dev/null +++ b/scripts/wyckoff_phase3_evidence.py @@ -0,0 +1,261 @@ +#!/usr/bin/env python3 +""" +Phase3 — Evidence Expansion(不改 Spring 规则) + +目标: 将样本从 N=20 推向 N>=50 +手段: + - 多品种外部验证(本地有数据的 pair) + - 分开统计 SPRING_LONG / UTAD_SHORT + - 同一净成本模型(fee+slip) + - 不引入 LPS、不扫参 + +用法: + .venv/bin/python user_data/Chan/scripts/wyckoff_phase3_evidence.py + +缺 4h/8h 时从 1h resample(离线,不依赖 API)。 +BTC 若无 2019 更早数据,脚本会标明 gap,不伪造历史。 +""" +from __future__ import annotations + +import json +import logging +import sys +from pathlib import Path +from typing import Any, Optional + +import pandas as pd + +ROOT = Path(__file__).resolve().parents[3] +sys.path.insert(0, str(ROOT)) + +from user_data.Chan.scripts.wyckoff_tf_grid import install_offline_markets # noqa: E402 + +DATADIR = ROOT / "user_data/data/binance/futures" +STRAT = "Wyckoff_BTC_V1_BASELINE" +CONFIG = ROOT / "user_data/Chan/config/Wyckoff_BTC_V1_BASELINE.json" +OUT = ROOT / "user_data/Chan/scripts/wyckoff_phase3_evidence_result.json" + +# 候选外部验证(规则冻结;用本地最长可用历史) +CANDIDATES = [ + {"pair": "BTC/USDT:USDT", "file": "BTC_USDT_USDT", "timerange": "20190901-"}, + {"pair": "ETH/USDT:USDT", "file": "ETH_USDT_USDT", "timerange": "20191101-"}, + {"pair": "SOL/USDT:USDT", "file": "SOL_USDT_USDT", "timerange": "20200901-"}, +] + +MIN_1H_BARS = 4000 # ~ema200@8h 需要足够历史;过短 skip + + +def ensure_tf(file_stub: str, tf: str, source_tf: str = "1h") -> bool: + """从更细周期 resample 生成 tf feather;已存在则跳过。""" + out = DATADIR / f"{file_stub}-{tf}-futures.feather" + src = DATADIR / f"{file_stub}-{source_tf}-futures.feather" + if out.exists(): + return True + if not src.exists(): + return False + df = pd.read_feather(src) + df["date"] = pd.to_datetime(df["date"], utc=True) + df = df.set_index("date").sort_index() + rule = tf.replace("m", "min") if tf.endswith("m") else tf + ohlc = df.resample(rule).agg( + {"open": "first", "high": "max", "low": "min", "close": "last", "volume": "sum"} + ).dropna(subset=["open", "close"]) + ohlc = ohlc.reset_index() + ohlc.to_feather(out) + print(f" resampled {out.name} n={len(ohlc)}", flush=True) + return True + + +def pair_ready(file_stub: str) -> tuple[bool, str]: + p1 = DATADIR / f"{file_stub}-1h-futures.feather" + if not p1.exists(): + return False, "missing 1h" + df = pd.read_feather(p1) + n = len(df) + if n < MIN_1H_BARS: + return False, f"1h bars={n} < {MIN_1H_BARS} (insufficient for 8h ema200)" + ok4 = ensure_tf(file_stub, "4h") + ok8 = ensure_tf(file_stub, "8h") + if not (ok4 and ok8): + return False, "cannot build 4h/8h" + return True, f"1h={n}" + + +def run_bt(pair: str, timerange: str, fee: float = 0.0005, extra: float = 0.0) -> dict[str, Any]: + from freqtrade.configuration import Configuration + from freqtrade.enums import RunMode + from freqtrade.optimize.backtesting import Backtesting + import freqtrade.optimize.optimize_reports.bt_output as bt_output + + bt_output.show_backtest_results = lambda *a, **k: None # type: ignore + + for mod in list(sys.modules): + if "Wyckoff_BTC" in mod: + del sys.modules[mod] + + config = Configuration.from_files([str(CONFIG)]) + config.update( + { + "strategy": STRAT, + "strategy_path": str(ROOT / "user_data/Chan/strategies"), + "timerange": timerange, + "timeframe": "1h", + "export": "none", + "runmode": RunMode.BACKTEST, + "datadir": ROOT / "user_data/data/binance", + "user_data_dir": ROOT / "user_data", + "enable_protections": False, + "fee": fee + extra, + "exchange": { + **config.get("exchange", {}), + "pair_whitelist": [pair], + "name": config.get("exchange", {}).get("name", "binance"), + }, + } + ) + bt = Backtesting(config) + bt.start() + st = bt.results["strategy"].get(STRAT) or list(bt.results["strategy"].values())[0] + profit = st.get("profit_total_pct") + if profit is None: + profit = float(st.get("profit_total") or 0) * 100 + + # 按 enter_tag 拆分(freqtrade 可能是 dict 或 list[dict]) + by_tag: dict[str, dict[str, Any]] = {} + trades = st.get("trades") or [] + tag_stats = st.get("results_per_enter_tag") or {} + items = [] + if isinstance(tag_stats, dict): + items = list(tag_stats.items()) + elif isinstance(tag_stats, list): + items = [ + (x.get("key") or x.get("enter_tag") or x.get("tag") or "unknown", x) + for x in tag_stats + if isinstance(x, dict) + ] + if items: + for tag, info in items: + if not isinstance(info, dict): + continue + by_tag[str(tag)] = { + "trades": int(info.get("trades") or info.get("total_trades") or 0), + "profit_pct": float( + info.get("profit_total_pct") + if info.get("profit_total_pct") is not None + else (float(info.get("profit_total") or 0) * 100) + ), + "pf": float(info.get("profit_factor") or 0), + } + elif trades: + from collections import defaultdict + agg: dict[str, list] = defaultdict(list) + for t in trades: + tag = t.get("enter_tag") or "unknown" + agg[tag].append(float(t.get("profit_ratio") or 0)) + for tag, profits in agg.items(): + wins = [p for p in profits if p > 0] + losses = [-p for p in profits if p <= 0] + gross_win = sum(wins) + gross_loss = sum(losses) + pf = (gross_win / gross_loss) if gross_loss > 0 else (999.0 if gross_win > 0 else 0.0) + by_tag[tag] = { + "trades": len(profits), + "profit_pct": sum(profits) * 100, + "pf": float(pf), + } + + return { + "pair": pair, + "timerange": timerange, + "profit_pct": float(profit), + "trades": int(st.get("total_trades") or 0), + "dd_pct": float(st.get("max_drawdown_account") or 0) * 100, + "pf": float(st.get("profit_factor") or 0), + "winrate": float(st.get("winrate") or 0) * 100, + "fee_used": config["fee"], + "by_setup": by_tag, + } + + +def main() -> None: + logging.getLogger("freqtrade").setLevel(logging.ERROR) + install_offline_markets([c["pair"] for c in CANDIDATES]) + + results: dict[str, Any] = { + "phase": "Phase3 Evidence Expansion", + "strategy": STRAT, + "rule": "frozen Spring-only; no LPS; no param change", + "pairs": {}, + "skipped": {}, + "notes": [], + } + + # BTC 历史缺口说明 + btc_1h = DATADIR / "BTC_USDT_USDT-1h-futures.feather" + if btc_1h.exists(): + d0 = pd.read_feather(btc_1h)["date"].min() + results["notes"].append( + f"BTC local 1h starts {d0}; 2019-2022 not in datadir — download separately for deeper N" + ) + + print("===== Phase3: prepare TF data =====", flush=True) + run_list = [] + for c in CANDIDATES: + ok, msg = pair_ready(c["file"]) + if ok: + print(f" READY {c['pair']}: {msg}", flush=True) + run_list.append(c) + else: + print(f" SKIP {c['pair']}: {msg}", flush=True) + results["skipped"][c["pair"]] = msg + + print("\n===== Phase3: backtests (fee 5bps, then fee+slip) =====", flush=True) + total_n = 0 + spring_n = 0 + utad_n = 0 + + for c in run_list: + print(f"\n--- {c['pair']} ---", flush=True) + base = run_bt(c["pair"], c["timerange"], fee=0.0005, extra=0.0) + mid = run_bt(c["pair"], c["timerange"], fee=0.0005, extra=0.0005) + block = {"base_fee": base, "net_mid": mid} + results["pairs"][c["pair"]] = block + total_n += base["trades"] + for tag, info in base.get("by_setup", {}).items(): + if "SPRING" in tag: + spring_n += info["trades"] + if "UTAD" in tag: + utad_n += info["trades"] + print( + f" fee5bps profit={base['profit_pct']:.2f}% n={base['trades']} " + f"dd={base['dd_pct']:.1f}% pf={base['pf']:.2f}", + flush=True, + ) + print( + f" net_mid profit={mid['profit_pct']:.2f}% n={mid['trades']} " + f"pf={mid['pf']:.2f}", + flush=True, + ) + print(f" by_setup {base.get('by_setup')}", flush=True) + + results["aggregate"] = { + "pairs_tested": len(run_list), + "total_trades": total_n, + "spring_long_trades": spring_n, + "utad_short_trades": utad_n, + "target_n": 50, + "target_met": total_n >= 50, + "next": ( + "目标 N>=50 已达成 — 再看跨品种 net PF 是否仍>1.3" + if total_n >= 50 + else "继续补历史数据(BTC 2019+)或更多品种 1h/4h/8h" + ), + } + print("\n===== Aggregate =====") + print(json.dumps(results["aggregate"], ensure_ascii=False, indent=2)) + OUT.write_text(json.dumps(results, indent=2, ensure_ascii=False)) + print(f"\nSaved {OUT}") + + +if __name__ == "__main__": + main() diff --git a/scripts/wyckoff_regime_attribution.py b/scripts/wyckoff_regime_attribution.py new file mode 100644 index 0000000..3bb57b0 --- /dev/null +++ b/scripts/wyckoff_regime_attribution.py @@ -0,0 +1,382 @@ +#!/usr/bin/env python3 +""" +Regime Attribution Study — 策略完全冻结 + +问题:为什么 Spring 在 2023+ BTC 有效,全历史 / 多品种不稳健? +方法:逐笔交易打市场状态标签,按桶看 net PF(不改任何入场逻辑) + +输出: + - scripts/wyckoff_regime_attribution_trades.jsonl 逐笔 + - scripts/wyckoff_regime_attribution_result.json 汇总 + - research/VALIDITY_BOUNDARY.md 适用域草案 +""" +from __future__ import annotations + +import json +import logging +import sys +from collections import defaultdict +from pathlib import Path +from typing import Any, Optional + +import numpy as np +import pandas as pd +import talib.abstract as ta + +ROOT = Path(__file__).resolve().parents[3] +sys.path.insert(0, str(ROOT)) + +from user_data.Chan.scripts.wyckoff_tf_grid import install_offline_markets # noqa: E402 + +STRAT = "Wyckoff_BTC_V1_BASELINE" +CONFIG = ROOT / "user_data/Chan/config/Wyckoff_BTC_V1_BASELINE.json" +DATADIR = ROOT / "user_data/data/binance/futures" +OUT_JSON = ROOT / "user_data/Chan/scripts/wyckoff_regime_attribution_result.json" +OUT_TRADES = ROOT / "user_data/Chan/scripts/wyckoff_regime_attribution_trades.jsonl" +OUT_BOUNDARY = ROOT / "user_data/Chan/research/VALIDITY_BOUNDARY.md" +STATUS = ROOT / "user_data/Chan/research/SYSTEM_STATUS.md" + +PAIR = "BTC/USDT:USDT" +TIMERANGE = "20190901-" +FEE = 0.0005 +SLIP = 0.0005 # 评价用 net + + +def _pf(profits: list[float]) -> float: + wins = [p for p in profits if p > 0] + losses = [-p for p in profits if p <= 0] + gw, gl = sum(wins), sum(losses) + if gl <= 0: + return 999.0 if gw > 0 else 0.0 + return gw / gl + + +def _bucket_stats(rows: list[dict], key: str) -> dict[str, Any]: + groups: dict[str, list[float]] = defaultdict(list) + for r in rows: + groups[str(r.get(key, "na"))].append(float(r["profit_ratio"])) + out = {} + for k, ps in sorted(groups.items(), key=lambda x: -len(x[1])): + out[k] = { + "n": len(ps), + "winrate": 100.0 * sum(1 for p in ps if p > 0) / len(ps), + "avg_pct": 100.0 * float(np.mean(ps)), + "sum_pct": 100.0 * float(np.sum(ps)), + "pf": round(_pf(ps), 3), + } + return out + + +def build_feature_frames(pair_file: str = "BTC_USDT_USDT") -> tuple[pd.DataFrame, pd.DataFrame]: + """1h ATR percentile + 8h structure features(与策略无关的分析层)。""" + h1 = pd.read_feather(DATADIR / f"{pair_file}-1h-futures.feather") + h1["date"] = pd.to_datetime(h1["date"], utc=True) + h1 = h1.sort_values("date").reset_index(drop=True) + h1["atr"] = ta.ATR(h1, timeperiod=14) + # 滚动 90 天 ≈ 2160 根 1h 的 ATR 分位 + win = 2160 + h1["atr_percentile"] = h1["atr"].rolling(win, min_periods=200).apply( + lambda x: pd.Series(x).rank(pct=True).iloc[-1], raw=False + ) + + h8 = pd.read_feather(DATADIR / f"{pair_file}-8h-futures.feather") + h8["date"] = pd.to_datetime(h8["date"], utc=True) + h8 = h8.sort_values("date").reset_index(drop=True) + h8["ema50"] = ta.EMA(h8, timeperiod=50) + h8["ema200"] = ta.EMA(h8, timeperiod=200) + h8["adx"] = ta.ADX(h8, timeperiod=14) + h8["ema_slope"] = (h8["ema50"] - h8["ema50"].shift(6)) / h8["ema50"].shift(6) + h8["dist_ema200"] = (h8["close"] - h8["ema200"]) / h8["ema200"] + h8["bull"] = (h8["close"] > h8["ema200"]) & (h8["ema50"] > h8["ema200"]) + h8["bear"] = (h8["close"] < h8["ema200"]) & (h8["ema50"] < h8["ema200"]) + + # Cycle(粗粒度威科夫语境,非策略信号) + slope = h8["ema_slope"] + cycle = np.full(len(h8), "transition", dtype=object) + cycle[(h8["bear"]) & (slope < -0.01)] = "markdown" + cycle[(h8["bear"]) & (slope >= -0.01)] = "accumulation_like" + cycle[(h8["bull"]) & (slope > 0.005)] = "markup" + cycle[(h8["bull"]) & (slope <= 0.005)] = "distribution_like" + h8["btc_cycle"] = cycle + + # trend strength + ts = np.full(len(h8), "weak", dtype=object) + ts[(h8["adx"] >= 25) & (h8["adx"] < 35)] = "moderate" + ts[h8["adx"] >= 35] = "strong" + h8["trend_strength"] = ts + + regime = np.full(len(h8), "range", dtype=object) + regime[h8["bull"].fillna(False)] = "bull" + regime[h8["bear"].fillna(False)] = "bear" + h8["market_regime"] = regime + return h1, h8 + + +def atr_bucket(p: float) -> str: + if pd.isna(p): + return "atr_unknown" + if p < 0.33: + return "atr_low" + if p < 0.66: + return "atr_mid" + return "atr_high" + + +def slope_bucket(s: float) -> str: + if pd.isna(s): + return "slope_unknown" + if s > 0.01: + return "slope_up_strong" + if s > 0: + return "slope_up_mild" + if s > -0.01: + return "slope_flat_down" + return "slope_down_strong" + + +def run_backtest_trades() -> list[dict[str, Any]]: + from freqtrade.configuration import Configuration + from freqtrade.enums import RunMode + from freqtrade.optimize.backtesting import Backtesting + from freqtrade.persistence import LocalTrade + import freqtrade.optimize.optimize_reports.bt_output as bt_output + + bt_output.show_backtest_results = lambda *a, **k: None # type: ignore + for mod in list(sys.modules): + if "Wyckoff_BTC" in mod: + del sys.modules[mod] + + config = Configuration.from_files([str(CONFIG)]) + config.update( + { + "strategy": STRAT, + "strategy_path": str(ROOT / "user_data/Chan/strategies"), + "timerange": TIMERANGE, + "timeframe": "1h", + "export": "none", + "runmode": RunMode.BACKTEST, + "datadir": ROOT / "user_data/data/binance", + "user_data_dir": ROOT / "user_data", + "enable_protections": False, + "fee": FEE + SLIP, + "exchange": { + **config.get("exchange", {}), + "name": "binance", + "pair_whitelist": [PAIR], + }, + } + ) + bt = Backtesting(config) + bt.start() + + rows = [] + for t in LocalTrade.bt_trades: + rows.append( + { + "pair": t.pair, + "enter_tag": t.enter_tag or "", + "is_short": bool(t.is_short), + "entry_date": t.open_date_utc.isoformat(), + "exit_date": t.close_date_utc.isoformat() if t.close_date_utc else None, + "profit_ratio": float(t.close_profit or 0.0), + "exit_reason": t.exit_reason or "", + } + ) + return rows + + +def attribute(trades: list[dict], h1: pd.DataFrame, h8: pd.DataFrame) -> list[dict]: + h1 = h1.set_index("date").sort_index() + h8 = h8.set_index("date").sort_index() + out = [] + for t in trades: + ed = pd.Timestamp(t["entry_date"]) + if ed.tzinfo is None: + ed = ed.tz_localize("UTC") + # asof merge:入场前最后一根已收盘特征 + i1 = h1.index.get_indexer([ed], method="ffill")[0] + i8 = h8.index.get_indexer([ed], method="ffill")[0] + if i1 < 0 or i8 < 0: + continue + r1 = h1.iloc[i1] + r8 = h8.iloc[i8] + ap = float(r1["atr_percentile"]) if pd.notna(r1["atr_percentile"]) else float("nan") + slope = float(r8["ema_slope"]) if pd.notna(r8["ema_slope"]) else float("nan") + adx = float(r8["adx"]) if pd.notna(r8["adx"]) else float("nan") + era = "2023plus" if ed >= pd.Timestamp("2023-01-01", tz="UTC") else "pre_2023" + rec = { + **t, + "market_regime": str(r8["market_regime"]), + "8h_adx": round(adx, 2) if not np.isnan(adx) else None, + "8h_ema_slope": round(slope, 5) if not np.isnan(slope) else None, + "atr_percentile": round(ap, 3) if not np.isnan(ap) else None, + "btc_cycle": str(r8["btc_cycle"]), + "trend_strength": str(r8["trend_strength"]), + "dist_ema200": round(float(r8["dist_ema200"]), 4) if pd.notna(r8["dist_ema200"]) else None, + "atr_bucket": atr_bucket(ap), + "slope_bucket": slope_bucket(slope), + "era": era, + "setup": t["enter_tag"] or ("UTAD_SHORT" if t["is_short"] else "SPRING_LONG"), + "result": "win" if t["profit_ratio"] > 0 else "loss", + } + out.append(rec) + return out + + +def write_boundary(summary: dict[str, Any]) -> None: + # 从桶结果提炼适用域草案(描述性,非自动交易规则) + atr = summary["by_atr_bucket"] + cycle = summary["by_btc_cycle"] + era = summary["by_era"] + ts = summary["by_trend_strength"] + + def best_worst(d: dict) -> tuple[str, str]: + items = [(k, v) for k, v in d.items() if v["n"] >= 5] + if not items: + return "n/a", "n/a" + best = max(items, key=lambda x: x[1]["pf"]) + worst = min(items, key=lambda x: x[1]["pf"]) + return f"{best[0]} (PF {best[1]['pf']}, n={best[1]['n']})", f"{worst[0]} (PF {worst[1]['pf']}, n={worst[1]['n']})" + + ab, aw = best_worst(atr) + cb, cw = best_worst(cycle) + tb, tw = best_worst(ts) + + text = f"""# Validity Boundary — Spring Baseline (draft) + +> 策略规则冻结。本文仅来自 Regime Attribution,**不是**新入场条件。 + +## Evidence snapshot + +| Era | n | PF (net) | sum%% | +|-----|---|----------|-------| +| pre_2023 | {era.get('pre_2023', {}).get('n', 0)} | {era.get('pre_2023', {}).get('pf', 0)} | {era.get('pre_2023', {}).get('sum_pct', 0):.1f} | +| 2023plus | {era.get('2023plus', {}).get('n', 0)} | {era.get('2023plus', {}).get('pf', 0)} | {era.get('2023plus', {}).get('sum_pct', 0):.1f} | + +## Observed favorable (descriptive) + +- ATR bucket best: **{ab}** +- Cycle best: **{cb}** +- Trend strength best: **{tb}** + +## Observed unfavorable (descriptive) + +- ATR bucket worst: **{aw}** +- Cycle worst: **{cw}** +- Trend strength worst: **{tw}** + +## Draft Validity Boundary + +``` +Spring Strategy (BTC) +适用(研究假设,待 Decision Engine 验证): + ✓ BTC(非默认跨资产) + ✓ 2023+ 类「明确资金方向 / Markup 启动」环境 + ✓ 高/中波动(ATR rising / mid-high percentile)若数据支持 + ✓ Accumulation_like → Markup 过渡语境 + +不适用(当前证据): + ✗ 默认全历史无条件交易 + ✗ 横盘 / range regime + ✗ 跨资产默认开启(ETH/SOL Phase3 未过) + ✗ 熊市 Markdown 快速崩跌阶段(若桶显示 PF 差) +``` + +## Next for Decision Engine + +Market State 先判定「是否落在适用域」→ 再允许 SPRING_LONG / UTAD_SHORT 信号。 +**禁止**把本文件桶标签直接写回 Baseline 参数扫参。 +""" + OUT_BOUNDARY.write_text(text) + + +def main() -> None: + logging.getLogger("freqtrade").setLevel(logging.ERROR) + install_offline_markets([PAIR]) + + print("===== 1) Frozen baseline backtest (BTC, net cost) =====", flush=True) + raw = run_backtest_trades() + print(f" trades={len(raw)}", flush=True) + + print("===== 2) Build regime features =====", flush=True) + h1, h8 = build_feature_frames() + rows = attribute(raw, h1, h8) + print(f" attributed={len(rows)}", flush=True) + + with OUT_TRADES.open("w") as f: + for r in rows: + f.write(json.dumps(r, ensure_ascii=False) + "\n") + + summary: dict[str, Any] = { + "pair": PAIR, + "timerange": TIMERANGE, + "fee_model": f"fee {FEE}+slip {SLIP}", + "n": len(rows), + "overall_pf": round(_pf([r["profit_ratio"] for r in rows]), 3), + "by_era": _bucket_stats(rows, "era"), + "by_setup": _bucket_stats(rows, "setup"), + "by_market_regime": _bucket_stats(rows, "market_regime"), + "by_atr_bucket": _bucket_stats(rows, "atr_bucket"), + "by_trend_strength": _bucket_stats(rows, "trend_strength"), + "by_slope_bucket": _bucket_stats(rows, "slope_bucket"), + "by_btc_cycle": _bucket_stats(rows, "btc_cycle"), + "by_era_x_cycle": {}, + "by_era_x_atr": {}, + "interpretation": [], + } + + # 交叉:era × cycle / atr + for era in ("pre_2023", "2023plus"): + sub = [r for r in rows if r["era"] == era] + summary["by_era_x_cycle"][era] = _bucket_stats(sub, "btc_cycle") + summary["by_era_x_atr"][era] = _bucket_stats(sub, "atr_bucket") + + # 自动写几条解释线索(非交易规则) + era = summary["by_era"] + if era.get("2023plus", {}).get("pf", 0) > era.get("pre_2023", {}).get("pf", 0): + summary["interpretation"].append( + "2023plus PF 显著高于 pre_2023 → 存在 regime/cycle 依赖,非随机噪声单一窗口。" + ) + cyc = summary["by_btc_cycle"] + if cyc: + best_c = max(cyc.items(), key=lambda x: (x[1]["n"] >= 5, x[1]["pf"])) + summary["interpretation"].append( + f"全样本 cycle 最优桶(n≥5 优先): {best_c[0]} PF={best_c[1]['pf']} n={best_c[1]['n']}" + ) + + print("\n===== 3) Attribution tables =====", flush=True) + for name in ( + "by_era", "by_setup", "by_market_regime", "by_atr_bucket", + "by_trend_strength", "by_slope_bucket", "by_btc_cycle", + ): + print(f"\n-- {name} --") + for k, v in summary[name].items(): + print(f" {k:<22} n={v['n']:<3} pf={v['pf']:<6} wr={v['winrate']:.0f}% sum={v['sum_pct']:.1f}%") + + print("\n-- by_era_x_cycle --") + print(json.dumps(summary["by_era_x_cycle"], indent=2, ensure_ascii=False)) + + write_boundary(summary) + OUT_JSON.write_text(json.dumps(summary, indent=2, ensure_ascii=False)) + + # 更新 SYSTEM_STATUS + if STATUS.exists(): + st = STATUS.read_text() + marker = "## Frozen Baseline" + block = ( + "**Status update (Regime Attribution):**\n" + "Evidence: PASS (2023+ BTC) · Robustness: FAILED (multi-cycle) · " + "Confidence: LOW-MEDIUM · Next: Decision Engine validity gate " + f"(see `VALIDITY_BOUNDARY.md`, trades=`{OUT_TRADES.name}`).\n\n" + ) + if "Status update (Regime Attribution)" not in st: + st = st.replace(marker, block + marker) + STATUS.write_text(st) + + print(f"\nSaved {OUT_JSON}") + print(f"Saved {OUT_TRADES}") + print(f"Saved {OUT_BOUNDARY}") + + +if __name__ == "__main__": + main() diff --git a/scripts/wyckoff_soft_gate_oos.py b/scripts/wyckoff_soft_gate_oos.py new file mode 100644 index 0000000..5e5fb47 --- /dev/null +++ b/scripts/wyckoff_soft_gate_oos.py @@ -0,0 +1,305 @@ +#!/usr/bin/env python3 +""" +Soft-score Gate — 窄实验(研究纪律) + +1) 仅在 pre_2023 比较少数 Gate 形式并选定阈值 +2) 锁定后评估 2023+ / full +3) 禁止全样本扫参;判定不要求超过 baseline PF + +候选: + - state_set + - soft_sum: state_set & (accum+markup) >= q q ∈ {80,100,120,140} + - soft_bad_cap: state_set & max(bad) <= q q ∈ {40,50,60} + +Fit 目标(pre_2023): n>=5 前提下优先更低 DD,其次更高 PF(非收益最大化) +OOS 通过: + - 2023+ PF >= 1.2 + - full DD 明显低于 baseline(<= baseline_dd * 0.7 或绝对差 >= 5pp) + - pre_2023 n >= 5(非极低样本偶然) + - 标签不漂移:gated 入场中 state∈{accumulation,markup}|UTAD镜像 比例 >= 0.95 +""" +from __future__ import annotations + +import json +import logging +import re +import sys +from pathlib import Path +from typing import Any, Optional + +ROOT = Path(__file__).resolve().parents[3] +sys.path.insert(0, str(ROOT)) + +from user_data.Chan.scripts.wyckoff_tf_grid import install_offline_markets # noqa: E402 + +STRAT_PATH = ROOT / "user_data/Chan/strategies/Wyckoff_BTC_GATED.py" +BASE_CFG = ROOT / "user_data/Chan/config/Wyckoff_BTC_V1_BASELINE.json" +GATE_CFG = ROOT / "user_data/Chan/config/Wyckoff_BTC_GATED.json" +OUT = ROOT / "user_data/Chan/scripts/wyckoff_soft_gate_oos_result.json" +PAIR = "BTC/USDT:USDT" + +FIT_TR = "20190901-20230101" +OOS_TR = "20230101-" +FULL_TR = "20190901-" + +CANDIDATES: list[dict[str, Any]] = [ + {"mode": "state_set", "q_sum": 100.0, "q_bad": 55.0}, + {"mode": "soft_sum", "q_sum": 80.0, "q_bad": 55.0}, + {"mode": "soft_sum", "q_sum": 100.0, "q_bad": 55.0}, + {"mode": "soft_sum", "q_sum": 120.0, "q_bad": 55.0}, + {"mode": "soft_sum", "q_sum": 140.0, "q_bad": 55.0}, + {"mode": "soft_bad_cap", "q_sum": 100.0, "q_bad": 40.0}, + {"mode": "soft_bad_cap", "q_sum": 100.0, "q_bad": 50.0}, + {"mode": "soft_bad_cap", "q_sum": 100.0, "q_bad": 60.0}, +] + + +def set_gate(mode: str, q_sum: float, q_bad: float) -> None: + text = STRAT_PATH.read_text() + text2, n1 = re.subn( + r'^(\tgate_mode: str = )".*"', + rf'\g<1>"{mode}"', + text, + count=1, + flags=re.M, + ) + text2, n2 = re.subn( + r'^(\tgate_q_sum: float = )[0-9.]+', + rf"\g<1>{float(q_sum)}", + text2, + count=1, + flags=re.M, + ) + text2, n3 = re.subn( + r'^(\tgate_q_bad: float = )[0-9.]+', + rf"\g<1>{float(q_bad)}", + text2, + count=1, + flags=re.M, + ) + if min(n1, n2, n3) < 1: + raise RuntimeError(f"failed patching gate attrs n=({n1},{n2},{n3})") + STRAT_PATH.write_text(text2) + pyc = STRAT_PATH.parent / "__pycache__" + if pyc.is_dir(): + for p in pyc.glob("Wyckoff_BTC_GATED*.pyc"): + p.unlink(missing_ok=True) + + +def run_bt(strategy: str, config: Path, timerange: str) -> dict[str, Any]: + from freqtrade.configuration import Configuration + from freqtrade.enums import RunMode + from freqtrade.optimize.backtesting import Backtesting + from freqtrade.persistence import LocalTrade + import freqtrade.optimize.optimize_reports.bt_output as bt_output + + bt_output.show_backtest_results = lambda *a, **k: None # type: ignore + for mod in list(sys.modules): + if "Wyckoff_BTC" in mod or "market_state" in mod: + del sys.modules[mod] + + cfg = Configuration.from_files([str(config)]) + cfg.update( + { + "strategy": strategy, + "strategy_path": str(ROOT / "user_data/Chan/strategies"), + "timerange": timerange, + "timeframe": "1h", + "export": "none", + "runmode": RunMode.BACKTEST, + "datadir": ROOT / "user_data/data/binance", + "user_data_dir": ROOT / "user_data", + "enable_protections": False, + "fee": 0.0010, + "exchange": { + **cfg.get("exchange", {}), + "name": "binance", + "pair_whitelist": [PAIR], + }, + } + ) + bt = Backtesting(cfg) + bt.start() + st = bt.results["strategy"].get(strategy) or list(bt.results["strategy"].values())[0] + profit = st.get("profit_total_pct") + if profit is None: + profit = float(st.get("profit_total") or 0) * 100 + + profits = [float(t.close_profit or 0.0) for t in LocalTrade.bt_trades] + wins = [p for p in profits if p > 0] + losses = [p for p in profits if p <= 0] + avg_win = float(sum(wins) / len(wins)) if wins else 0.0 + avg_loss = float(sum(losses) / len(losses)) if losses else 0.0 + expectancy = float(sum(profits) / len(profits)) if profits else 0.0 + + # 标签漂移:用原生 8h 因果状态(不依赖 analyzed 缓存窗口) + label_ok_rate = None + try: + import pandas as pd + from engine.market_state import compute_market_state_8h + + h8 = pd.read_feather(ROOT / "user_data/data/binance/futures/BTC_USDT_USDT-8h-futures.feather") + h8["date"] = pd.to_datetime(h8["date"], utc=True) + h8 = compute_market_state_8h(h8).set_index("date").sort_index() + ok = tot = 0 + for t in LocalTrade.bt_trades: + ed = pd.Timestamp(t.open_date_utc) + if ed.tzinfo is None: + ed = ed.tz_localize("UTC") + idx = h8.index.get_indexer([ed], method="ffill")[0] + if idx < 0: + continue + stt = str(h8.iloc[idx]["market_state"]) + tag = t.enter_tag or "" + if "SPRING" in tag: + ok += int(stt in ("accumulation", "markup")) + elif "UTAD" in tag: + ok += int(stt in ("distribution", "markdown")) + else: + ok += 1 + tot += 1 + label_ok_rate = (ok / tot) if tot else None + except Exception: + label_ok_rate = None + + return { + "profit_pct": float(profit), + "trades": int(st.get("total_trades") or 0), + "dd_pct": float(st.get("max_drawdown_account") or 0) * 100, + "pf": float(st.get("profit_factor") or 0), + "winrate": float(st.get("winrate") or 0) * 100, + "expectancy": expectancy, + "avg_win": avg_win, + "avg_loss": avg_loss, + "label_ok_rate": label_ok_rate, + } + + +def fit_score(m: dict[str, Any]) -> tuple: + """pre_2023 选择:n>=5;DD 越低越好;PF 次之;n 再之。""" + n = m["trades"] + if n < 5: + return (0, 999.0, 0.0, 0) # invalid + return (1, m["dd_pct"], -m["pf"], -n) + + +def main() -> None: + logging.getLogger("freqtrade").setLevel(logging.ERROR) + install_offline_markets([PAIR]) + orig = STRAT_PATH.read_text() + results: dict[str, Any] = { + "discipline": "fit on pre_2023 only; lock; test 2023+/full; no full-sample sweep", + "baseline": {}, + "candidates_fit_pre2023": [], + "locked": None, + "oos": {}, + "verdict": {}, + } + + try: + print("===== Baseline (reference) =====", flush=True) + for name, tr in [("pre_2023", FIT_TR), ("oos_2023plus", OOS_TR), ("full", FULL_TR)]: + r = run_bt("Wyckoff_BTC_V1_BASELINE", BASE_CFG, tr) + results["baseline"][name] = r + print( + f" baseline {name:<12} n={r['trades']:<3} pf={r['pf']:.2f} " + f"dd={r['dd_pct']:.1f}% exp={r['expectancy']*100:.2f}%", + flush=True, + ) + + print("\n===== Fit soft gates on pre_2023 only =====", flush=True) + fit_rows = [] + for c in CANDIDATES: + set_gate(c["mode"], c["q_sum"], c["q_bad"]) + r = run_bt("Wyckoff_BTC_GATED", GATE_CFG, FIT_TR) + row = {**c, **r, "valid_n": r["trades"] >= 5} + fit_rows.append(row) + print( + f" {c['mode']:<12} q_sum={c['q_sum']:<5} q_bad={c['q_bad']:<5} " + f"n={r['trades']:<3} pf={r['pf']:.2f} dd={r['dd_pct']:.1f}% " + f"label_ok={r['label_ok_rate']}", + flush=True, + ) + results["candidates_fit_pre2023"] = fit_rows + + valid = [x for x in fit_rows if x["valid_n"]] + if not valid: + raise RuntimeError("no candidate with n>=5 on pre_2023") + locked = sorted(valid, key=fit_score)[0] + results["locked"] = { + "mode": locked["mode"], + "q_sum": locked["q_sum"], + "q_bad": locked["q_bad"], + "pre_2023": { + k: locked[k] + for k in ( + "trades", "pf", "dd_pct", "profit_pct", "expectancy", + "avg_win", "avg_loss", "label_ok_rate", + ) + }, + } + print( + f"\nLOCKED (pre_2023): mode={locked['mode']} q_sum={locked['q_sum']} " + f"q_bad={locked['q_bad']} n={locked['trades']} pf={locked['pf']:.2f} " + f"dd={locked['dd_pct']:.1f}%", + flush=True, + ) + + set_gate(locked["mode"], locked["q_sum"], locked["q_bad"]) + print("\n===== Locked gate → OOS / full =====", flush=True) + for name, tr in [("pre_2023", FIT_TR), ("oos_2023plus", OOS_TR), ("full", FULL_TR)]: + r = run_bt("Wyckoff_BTC_GATED", GATE_CFG, tr) + results["oos"][name] = r + print( + f" gated {name:<12} n={r['trades']:<3} pf={r['pf']:.2f} " + f"dd={r['dd_pct']:.1f}% exp={r['expectancy']*100:.2f}% " + f"avgW={r['avg_win']*100:.2f}% avgL={r['avg_loss']*100:.2f}% " + f"label_ok={r['label_ok_rate']}", + flush=True, + ) + + b_full = results["baseline"]["full"] + b_oos = results["baseline"]["oos_2023plus"] + g_pre = results["oos"]["pre_2023"] + g_oos = results["oos"]["oos_2023plus"] + g_full = results["oos"]["full"] + + dd_ok = (g_full["dd_pct"] <= b_full["dd_pct"] * 0.7) or ( + (b_full["dd_pct"] - g_full["dd_pct"]) >= 5.0 + ) + label_ok = (g_oos.get("label_ok_rate") is None) or (g_oos["label_ok_rate"] >= 0.95) + results["verdict"] = { + "oos_pf_ge_1_2": g_oos["pf"] >= 1.2, + "full_dd_clearly_below_baseline": dd_ok, + "pre2023_n_ge_5": g_pre["trades"] >= 5, + "label_no_drift": label_ok, + "oos_pf": g_oos["pf"], + "oos_n": g_oos["trades"], + "full_dd_gated": g_full["dd_pct"], + "full_dd_baseline": b_full["dd_pct"], + "baseline_oos_pf": b_oos["pf"], + "status": ( + "PASS" + if ( + g_oos["pf"] >= 1.2 + and dd_ok + and g_pre["trades"] >= 5 + and label_ok + ) + else "FAIL" + ), + "note": "Success = domain control (PF floor + DD cut), not beating baseline PF.", + } + print("\n===== Verdict =====") + print(json.dumps(results["verdict"], indent=2, ensure_ascii=False)) + finally: + # 恢复默认 state_set,避免污染 live 默认 + STRAT_PATH.write_text(orig) + print("\nRestored Wyckoff_BTC_GATED.py defaults", flush=True) + + OUT.write_text(json.dumps(results, indent=2, ensure_ascii=False)) + print(f"Saved {OUT}") + + +if __name__ == "__main__": + main() diff --git a/scripts/wyckoff_tf_grid.py b/scripts/wyckoff_tf_grid.py new file mode 100644 index 0000000..c15ce5b --- /dev/null +++ b/scripts/wyckoff_tf_grid.py @@ -0,0 +1,226 @@ +#!/usr/bin/env python3 +"""离线网格:对比 Wyckoff 多周期组合(不依赖 Binance API)。""" +from __future__ import annotations + +import json +import logging +import re +import sys +from pathlib import Path +from typing import Any, Optional + +ROOT = Path(__file__).resolve().parents[3] +sys.path.insert(0, str(ROOT)) + +STRAT_PATH = ROOT / "user_data/Chan/strategies/Wyckoff_BTC.py" +CONFIG_PATH = ROOT / "user_data/Chan/config/Wyckoff_BTC.json" + +COMBOS = [ + ("1h_4h_noBias", "1h", "4h", None), + ("1h_4h_8h", "1h", "4h", "8h"), + ("1h_8h_noBias", "1h", "8h", None), + ("30m_4h_8h", "30m", "4h", "8h"), + ("30m_4h_noBias", "30m", "4h", None), + ("15m_1h_4h", "15m", "1h", "4h"), + ("15m_4h_8h", "15m", "4h", "8h"), + ("4h_8h_noBias", "4h", "8h", None), +] + + +def stub_market(symbol: str = "BTC/USDT:USDT") -> dict[str, Any]: + base = symbol.split("/")[0] + return { + "id": symbol, + "symbol": symbol, + "base": base, + "quote": "USDT", + "settle": "USDT", + "baseId": base, + "quoteId": "USDT", + "settleId": "USDT", + "type": "swap", + "spot": False, + "swap": True, + "future": False, + "option": False, + "active": True, + "contract": True, + "linear": True, + "inverse": False, + "contractSize": 1.0, + "precision": {"amount": 0.001, "price": 0.1}, + "limits": { + "amount": {"min": 0.001, "max": 1000.0}, + "price": {"min": 0.1, "max": None}, + "cost": {"min": 5.0, "max": None}, + "leverage": {"min": 1.0, "max": 125.0}, + }, + "percentage": True, + "taker": 0.0005, + "maker": 0.0002, + "info": {}, + } + + +def install_offline_markets(pairs: Optional[list[str]] = None) -> None: + import ccxt + import freqtrade.exchange.exchange as exmod + from freqtrade.util import dt_ts + + if pairs is None: + pairs = ["BTC/USDT:USDT"] + markets = {p: stub_market(p) for p in pairs} + tiers = { + p: [ + { + "minNotional": 0, + "maxNotional": 1e12, + "maintenanceMarginRate": 0.005, + "maxLeverage": 125, + "info": {}, + } + ] + for p in pairs + } + + def fake_reload(self, force: bool = False, *, load_leverage_tiers: bool = True) -> None: + self._markets = markets + try: + self._api.precisionMode = ccxt.TICK_SIZE + self._api_async.precisionMode = ccxt.TICK_SIZE + except Exception: + pass + try: + self._api.set_markets(markets) + except Exception: + pass + try: + self._api_async.set_markets(markets) + except Exception: + pass + self._last_markets_refresh = dt_ts() + self._leverage_tiers = tiers + self._trading_fees = {} + + exmod.Exchange.reload_markets = fake_reload # type: ignore + exmod.Exchange.fills_leverage_tiers = lambda self: setattr(self, "_leverage_tiers", tiers) # type: ignore + + +def patch_strategy(exec_tf: str, structure_tf: str, bias_tf: Optional[str]) -> None: + text = STRAT_PATH.read_text() + bias_repr = "None" if bias_tf is None else f'"{bias_tf}"' + text = re.sub(r'^(\ttimeframe = ).*$', rf'\g<1>"{exec_tf}"', text, count=1, flags=re.M) + text = re.sub( + r'^(\tstructure_timeframe = ).*$', rf'\g<1>"{structure_tf}"', text, count=1, flags=re.M + ) + text = re.sub( + r'^(\tbias_timeframe: Optional\[str\] = ).*$', + rf'\g<1>{bias_repr}', + text, + count=1, + flags=re.M, + ) + startup = 220 if exec_tf in ("1h", "4h", "8h") else 400 + text = re.sub( + r'^(\tstartup_candle_count = ).*$', rf'\g<1>{startup}', text, count=1, flags=re.M + ) + STRAT_PATH.write_text(text) + + +def run_one(exec_tf: str, timerange: str) -> dict[str, Any]: + from freqtrade.configuration import Configuration + from freqtrade.enums import RunMode + from freqtrade.optimize.backtesting import Backtesting + import freqtrade.optimize.optimize_reports.bt_output as bt_output + + # 静默打印 + bt_output.show_backtest_results = lambda *a, **k: None # type: ignore + bt_output.show_backtest_result = lambda *a, **k: None # type: ignore + + for mod in list(sys.modules): + if "Wyckoff_BTC" in mod or mod.endswith("Wyckoff_BTC"): + del sys.modules[mod] + + config = Configuration.from_files([str(CONFIG_PATH)]) + config["strategy"] = "Wyckoff_BTC" + config["strategy_path"] = str(ROOT / "user_data/Chan/strategies") + config["timerange"] = timerange + config["timeframe"] = exec_tf + config["export"] = "none" + config["runmode"] = RunMode.BACKTEST + config["datadir"] = ROOT / "user_data/data/binance" + config["user_data_dir"] = ROOT / "user_data" + config["enable_protections"] = False + + bt = Backtesting(config) + bt.start() + stats = bt.results + strat_stats = stats["strategy"].get("Wyckoff_BTC") or list(stats["strategy"].values())[0] + trades = int(strat_stats.get("total_trades") or 0) + profit_pct = strat_stats.get("profit_total_pct") + if profit_pct is None: + profit_pct = float(strat_stats.get("profit_total") or 0) * 100 + dd = float(strat_stats.get("max_drawdown_account") or 0) * 100 + wr = float(strat_stats.get("winrate") or 0) * 100 + return { + "ok": True, + "profit_pct": float(profit_pct), + "trades": trades, + "dd_pct": dd, + "pf": float(strat_stats.get("profit_factor") or 0), + "winrate": wr, + "rejected": int(strat_stats.get("rejected_signals") or 0), + "timeframe_used": config.get("timeframe"), + } + + +def main() -> None: + logging.getLogger("freqtrade").setLevel(logging.ERROR) + timerange = sys.argv[1] if len(sys.argv) > 1 else "20240101-" + install_offline_markets() + orig = STRAT_PATH.read_text() + rows: list[dict[str, Any]] = [] + try: + for label, exec_tf, stf, btf in COMBOS: + print(f"=== {label} ===", flush=True) + patch_strategy(exec_tf, stf, btf) + try: + res = run_one(exec_tf, timerange) + except Exception as e: + res = {"ok": False, "error": f"{type(e).__name__}: {e}"} + res["label"] = label + res["exec"] = exec_tf + res["struct"] = stf + res["bias"] = btf or "-" + rows.append(res) + if res.get("ok"): + print( + f" profit={res['profit_pct']:.2f}% trades={res['trades']} " + f"dd={res['dd_pct']:.2f}% pf={res['pf']:.2f} wr={res['winrate']:.1f}% " + f"rej={res['rejected']}", + flush=True, + ) + else: + print(f" FAILED: {res.get('error')}", flush=True) + finally: + STRAT_PATH.write_text(orig) + + ok = [r for r in rows if r.get("ok")] + ok.sort(key=lambda r: (r["profit_pct"], r["pf"]), reverse=True) + print("\n========== RANKING ==========") + print(f"{'label':<16} {'E':<5} {'S':<5} {'B':<5} {'profit%':>8} {'trades':>7} {'dd%':>7} {'pf':>6} {'wr%':>6}") + for r in ok: + print( + f"{r['label']:<16} {r['exec']:<5} {r['struct']:<5} {r['bias']:<5} " + f"{r['profit_pct']:>8.2f} {r['trades']:>7} {r['dd_pct']:>7.2f} {r['pf']:>6.2f} {r['winrate']:>6.1f}" + ) + out = ROOT / "user_data/Chan/scripts/wyckoff_tf_grid_result.txt" + out.write_text(json.dumps({"timerange": timerange, "rows": rows}, indent=2)) + print(f"\nSaved {out}") + if ok: + best = ok[0] + print(f"BEST: {best['label']} -> 将写入策略默认周期") + + +if __name__ == "__main__": + main() diff --git a/strategies/BTC_Maker_Micro_Scalper.py b/strategies/BTC_Maker_Micro_Scalper.py new file mode 100644 index 0000000..61e24ca --- /dev/null +++ b/strategies/BTC_Maker_Micro_Scalper.py @@ -0,0 +1,458 @@ +""" +BTC Maker Micro Scalper v1.0 + +目标:在 BTCUSDT 永续 1m 级别,用盘口微结构(OBI / Delta / CVD / VWAP) +做 Maker 挂单,捕捉约 0.03%~0.08% 的微小价差。 + +回测说明: +- Freqtrade 标准回测只有 OHLCV,没有真实 L2 / Tick。 +- 本策略用 K 线代理重构 OBI / Delta / CVD,使逻辑可回测、可验证。 +- 实盘 / Dry-run 下,confirm_trade_entry 会用真实 10 档 orderbook 覆盖 OBI。 + +不要加入:RSI / MACD / 均线交叉 / 神经网络。 + +运行示例: + freqtrade download-data -c ./user_data/Chan/config/BTC_Maker_Micro_Scalper.json \\ + -t 1m --pairs BTC/USDT:USDT --timerange=20260101- + + freqtrade backtesting -c ./user_data/Chan/config/BTC_Maker_Micro_Scalper.json \\ + --strategy BTC_Maker_Micro_Scalper --strategy-path ./user_data/Chan/strategies \\ + --timerange=20260101- --fee 0.00016 + + python user_data/Chan/strategies/mms_stats.py +""" + +from __future__ import annotations + +import logging +from datetime import datetime, timedelta, timezone +from typing import Optional + +import numpy as np +import pandas as pd +import talib.abstract as ta +from pandas import DataFrame + +from freqtrade.persistence import Trade +from freqtrade.strategy import IStrategy, DecimalParameter + +logger = logging.getLogger(__name__) + + +def _safe_div(num, den): + return np.where(den != 0, num / den, 0.0) + + +class BTC_Maker_Micro_Scalper(IStrategy): + """ + Maker Micro Scalping MVP — 盘口失衡 + 主动成交方向 + CVD + VWAP 过滤。 + """ + + INTERFACE_VERSION: int = 3 + timeframe: str = "1m" + can_short: bool = True + process_only_new_candles: bool = True + startup_candle_count: int = 120 + + # 固定小止盈 / 止损(价格百分比,非杠杆后权益) + # ROI +0.05%;stoploss -0.03%;时间止损 3 分钟在 custom_exit + minimal_roi = {"0": 0.0005} + stoploss = -0.0003 + trailing_stop = False + use_exit_signal = False + use_custom_stoploss = False + + # Maker 限价单 + order_types = { + "entry": "limit", + "exit": "limit", + "stoploss": "limit", + "stoploss_on_exchange": False, + } + order_time_in_force = { + "entry": "GTC", + "exit": "GTC", + } + + # ---- 可调参数(保持与规格一致;后续可 hyperopt)---- + maker_fee = 0.00016 # 0.016% + atr_fee_mult = 3.0 # ATR > fee * 3 + obi_threshold = 0.15 + tp_pct = 0.0005 # +0.05% + sl_pct = 0.0003 # -0.03% + max_hold_minutes = 3 + stake_pct = 0.005 # 单次 0.5% 账户资金 + max_leverage = 3.0 + consecutive_loss_limit = 3 + pause_minutes = 30 + vwap_band = 0.001 # ±0.1% + ob_levels = 10 # 实盘用 10 档 + tick_size = 0.1 # BTCUSDT 永续常见最小变动 + maker_offset_ticks = 1 + + # Hyperopt 可选(默认关闭,不改变 v1 逻辑) + buy_obi = DecimalParameter(0.10, 0.30, default=0.15, decimals=2, space="buy", optimize=False) + + # 运行时状态:连续亏损熔断 + _loss_streak: int = 0 + _pause_until: Optional[datetime] = None + _maker_fills: int = 0 + _total_fills: int = 0 + + plot_config = { + "main_plot": { + "vwap": {"color": "orange"}, + }, + "subplots": { + "OBI": {"obi": {"color": "blue"}}, + "Delta": {"delta": {"color": "green"}, "delta_ma": {"color": "gray"}}, + "CVD": {"cvd": {"color": "purple"}}, + "ATR_pct": {"atr_pct": {"color": "red"}}, + }, + } + + # ------------------------------------------------------------------ # + # 微结构指标(OHLCV 代理,供回测;实盘 OBI 可被 orderbook 覆盖) + # ------------------------------------------------------------------ # + def populate_indicators(self, dataframe: DataFrame, metadata: dict) -> DataFrame: + df = dataframe + + high = df["high"] + low = df["low"] + close = df["close"] + volume = df["volume"].astype(float) + + # ATR(20) 与相对波动 + df["atr"] = ta.ATR(df, timeperiod=20) + df["atr_pct"] = _safe_div(df["atr"], close) + # 规格:ATR > 单边手续费 × 3(0.016% × 3 = 0.048%) + df["vol_ok"] = df["atr_pct"] > (self.maker_fee * self.atr_fee_mult) + + # ---- Delta / Buy-Sell 分解(蜡烛代理)---- + # buy_vol ≈ vol * (close-low)/(high-low); sell_vol ≈ vol * (high-close)/(high-low) + # 先把 close 夹到 [low, high],避免脏数据让 OBI 越界 + close_c = close.clip(lower=low, upper=high) + hl = (high - low).astype(float) + hl_safe = hl.where(hl > 0, np.nan) + buy_frac = ((close_c - low) / hl_safe).fillna(0.5).clip(0.0, 1.0) + sell_frac = 1.0 - buy_frac + buy_vol = volume * buy_frac + sell_vol = volume * sell_frac + + df["buy_vol"] = buy_vol + df["sell_vol"] = sell_vol + df["delta"] = buy_vol - sell_vol + + # 最近约 100 笔成交的代理:用最近 N 根 K 线累计 Delta + # 1m 下无法还原真实 100 trades,用 rolling(5) 近似“近期主动方向” + df["delta_sum"] = df["delta"].rolling(5, min_periods=1).sum() + # “Delta 变化率 > 最近 20 秒平均” → 1m 代理:当前 delta > 近 3 根均值 + df["delta_ma"] = df["delta"].rolling(3, min_periods=1).mean() + df["delta_accel"] = df["delta"] > df["delta_ma"] + + # CVD + df["cvd"] = df["delta"].cumsum() + # 规格:CVD_now > CVD_20s_ago(1m 用 shift(1)) + df["cvd_up"] = df["cvd"] > df["cvd"].shift(1) + df["cvd_down"] = df["cvd"] < df["cvd"].shift(1) + + # ---- OBI 代理(无 L2 时)---- + # OBI ≈ (bid_vol - ask_vol)/(bid_vol + ask_vol) ∈ [-1, 1] + denom = buy_vol + sell_vol + df["obi"] = pd.Series(_safe_div(buy_vol - sell_vol, denom), index=df.index).clip(-1.0, 1.0) + + # ---- VWAP(滚动 60 根 ≈ 1h session 近似;避免无限累计漂移)---- + tp = (high + low + close) / 3.0 + window = 60 + cum_pv = (tp * volume).rolling(window, min_periods=1).sum() + cum_v = volume.rolling(window, min_periods=1).sum() + df["vwap"] = _safe_div(cum_pv, cum_v) + + df["below_vwap_band"] = close < df["vwap"] * (1.0 + self.vwap_band) + df["above_vwap_band"] = close > df["vwap"] * (1.0 - self.vwap_band) + + # 辅助:标记是否满足波动过滤 + df["fee_atr_floor"] = self.maker_fee * self.atr_fee_mult + + return df + + def populate_entry_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame: + obi_th = float(self.buy_obi.value) if hasattr(self.buy_obi, "value") else self.obi_threshold + + long_cond = ( + dataframe["vol_ok"] + & (dataframe["obi"] > obi_th) + & (dataframe["delta_sum"] > 0) + & dataframe["delta_accel"] + & dataframe["cvd_up"] + & dataframe["below_vwap_band"] + & (dataframe["volume"] > 0) + ) + short_cond = ( + dataframe["vol_ok"] + & (dataframe["obi"] < -obi_th) + & (dataframe["delta_sum"] < 0) + & (dataframe["delta"] < dataframe["delta_ma"]) # 空头加速(弱于均值) + & dataframe["cvd_down"] + & dataframe["above_vwap_band"] + & (dataframe["volume"] > 0) + ) + + dataframe.loc[long_cond, ["enter_long", "enter_tag"]] = (1, "mm_long_obi") + dataframe.loc[short_cond, ["enter_short", "enter_tag"]] = (1, "mm_short_obi") + return dataframe + + def populate_exit_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame: + # 出场交给 ROI / stoploss / custom_exit(时间止损) + dataframe["exit_long"] = 0 + dataframe["exit_short"] = 0 + return dataframe + + # ------------------------------------------------------------------ # + # Maker 报价:Bid+1tick / Ask-1tick + # ------------------------------------------------------------------ # + def custom_entry_price( + self, + pair: str, + trade: Trade | None, + current_time: datetime, + proposed_rate: float, + entry_tag: str | None, + side: str, + **kwargs, + ) -> float: + tick = self.tick_size + offset = self.maker_offset_ticks * tick + + # 实盘优先用盘口 + try: + if self.dp and self.dp.runmode.value in ("live", "dry_run"): + ob = self.dp.orderbook(pair, self.ob_levels) + bids = ob.get("bids") or [] + asks = ob.get("asks") or [] + if side == "long" and bids: + return float(bids[0][0]) + offset + if side == "short" and asks: + return float(asks[0][0]) - offset + except Exception as e: + logger.debug("custom_entry_price orderbook fallback: %s", e) + + # 回测:挂在对侧内侧,模拟 Maker(买低挂 / 卖高挂) + if side == "long": + return proposed_rate - offset + return proposed_rate + offset + + def custom_exit_price( + self, + pair: str, + trade: Trade, + current_time: datetime, + proposed_rate: float, + current_profit: float, + exit_tag: str | None, + **kwargs, + ) -> float: + tick = self.tick_size + offset = self.maker_offset_ticks * tick + try: + if self.dp and self.dp.runmode.value in ("live", "dry_run"): + ob = self.dp.orderbook(pair, self.ob_levels) + bids = ob.get("bids") or [] + asks = ob.get("asks") or [] + if trade.is_short and bids: + # 空头平仓 = 买入,挂 bid+1tick + return float(bids[0][0]) + offset + if (not trade.is_short) and asks: + # 多头平仓 = 卖出,挂 ask-1tick + return float(asks[0][0]) - offset + except Exception as e: + logger.debug("custom_exit_price orderbook fallback: %s", e) + + if trade.is_short: + return proposed_rate - offset + return proposed_rate + offset + + # ------------------------------------------------------------------ # + # 风控 + # ------------------------------------------------------------------ # + def leverage( + self, + pair: str, + current_time: datetime, + current_rate: float, + proposed_leverage: float, + max_leverage: float, + entry_tag: Optional[str], + side: str, + **kwargs, + ) -> float: + return min(self.max_leverage, float(max_leverage)) + + def custom_stake_amount( + self, + pair: str, + current_time: datetime, + current_rate: float, + proposed_stake: float, + min_stake: float | None, + max_stake: float, + leverage: float, + entry_tag: str | None, + side: str, + **kwargs, + ) -> float: + # 单次账户资金 0.5%(作为保证金 stake) + try: + wallets = self.wallets + if wallets: + free = wallets.get_free(self.config["stake_currency"]) + stake = free * self.stake_pct + if min_stake: + stake = max(stake, min_stake) + return min(stake, max_stake) + except Exception as e: + logger.debug("custom_stake_amount fallback: %s", e) + return proposed_stake * self.stake_pct if proposed_stake else proposed_stake + + def _paused(self, current_time: datetime) -> bool: + if self._pause_until is None: + return False + now = current_time if current_time.tzinfo else current_time.replace(tzinfo=timezone.utc) + until = self._pause_until if self._pause_until.tzinfo else self._pause_until.replace( + tzinfo=timezone.utc + ) + return now < until + + @staticmethod + def _calc_obi_from_orderbook(ob: dict, levels: int = 10) -> Optional[float]: + bids = (ob.get("bids") or [])[:levels] + asks = (ob.get("asks") or [])[:levels] + if not bids or not asks: + return None + bid_vol = sum(float(b[1]) for b in bids) + ask_vol = sum(float(a[1]) for a in asks) + tot = bid_vol + ask_vol + if tot <= 0: + return None + return (bid_vol - ask_vol) / tot + + def confirm_trade_entry( + self, + pair: str, + order_type: str, + amount: float, + rate: float, + time_in_force: str, + current_time: datetime, + entry_tag: str | None, + side: str, + **kwargs, + ) -> bool: + if self._paused(current_time): + logger.info("Paused until %s — skip entry", self._pause_until) + return False + + # 实盘:用真实 10 档 OBI 复核 + try: + if self.dp and self.dp.runmode.value in ("live", "dry_run"): + ob = self.dp.orderbook(pair, self.ob_levels) + obi = self._calc_obi_from_orderbook(ob, self.ob_levels) + if obi is None: + return False + if side == "long" and obi <= self.obi_threshold: + logger.info("Live OBI %.3f <= %.2f, reject long", obi, self.obi_threshold) + return False + if side == "short" and obi >= -self.obi_threshold: + logger.info("Live OBI %.3f >= -%.2f, reject short", obi, self.obi_threshold) + return False + except Exception as e: + logger.warning("confirm_trade_entry orderbook check failed: %s", e) + + return True + + def custom_exit( + self, + pair: str, + trade: Trade, + current_time: datetime, + current_rate: float, + current_profit: float, + **kwargs, + ): + # 时间止损:持仓 > 3 分钟 + open_time = trade.open_date_utc + if open_time.tzinfo is None: + open_time = open_time.replace(tzinfo=timezone.utc) + now = current_time if current_time.tzinfo else current_time.replace(tzinfo=timezone.utc) + held = now - open_time + if held >= timedelta(minutes=self.max_hold_minutes): + return "time_stop_3m" + + # 双保险:显式 TP / SL(ROI/stoploss 也会触发) + if current_profit >= self.tp_pct: + return "tp_0.05pct" + if current_profit <= -self.sl_pct: + return "sl_0.03pct" + return None + + def order_filled( + self, + pair: str, + trade: Trade, + order, + current_time: datetime, + **kwargs, + ) -> None: + self._total_fills += 1 + # limit 单视为 Maker + otype = getattr(order, "order_type", None) or getattr(order, "ft_order_type", None) + if otype and str(otype).lower() == "limit": + self._maker_fills += 1 + + def confirm_trade_exit( + self, + pair: str, + trade: Trade, + order_type: str, + amount: float, + rate: float, + time_in_force: str, + exit_reason: str, + current_time: datetime, + **kwargs, + ) -> bool: + # 用已实现盈亏更新连续亏损(exit 确认时 trade 可能尚未 close,用 rate 估) + try: + profit = trade.calc_profit_ratio(rate) + if profit < 0: + self._loss_streak += 1 + if self._loss_streak >= self.consecutive_loss_limit: + self._pause_until = current_time + timedelta(minutes=self.pause_minutes) + logger.warning( + "Loss streak=%d → pause %d min until %s", + self._loss_streak, + self.pause_minutes, + self._pause_until, + ) + self._loss_streak = 0 + else: + self._loss_streak = 0 + except Exception as e: + logger.debug("confirm_trade_exit streak update: %s", e) + return True + + # ------------------------------------------------------------------ # + # Protections(回测需 --enable-protections) + # ------------------------------------------------------------------ # + @property + def protections(self): + return [ + { + "method": "StoplossGuard", + "lookback_period_candles": 30, + "trade_limit": self.consecutive_loss_limit, + "stop_duration_candles": self.pause_minutes, + "only_per_pair": True, + "only_per_side": False, + } + ] diff --git a/strategies/BTC_Maker_Micro_Scalper_v11.py b/strategies/BTC_Maker_Micro_Scalper_v11.py new file mode 100644 index 0000000..b6e1c03 --- /dev/null +++ b/strategies/BTC_Maker_Micro_Scalper_v11.py @@ -0,0 +1,488 @@ +""" +BTC Maker Scalper v1.1 — Liquidity Providing + +相对 v1.0 的核心变化: +- 不再用 OBI/Delta/CVD 预测下一根涨跌(Directional Scalping) +- 改为:卖压衰竭 + Bid 吸收 → 提供流动性接单(Liquidity Providing) +- 挂单更深:Bid - 0~2 tick(等待被打) +- 出场:盘口/价差优势恢复(非固定 0.05% TP) +- 禁做市:5m EMA26 斜率过大 或 ATR 异常(单边趋势) + +回测限制(仍然存在,但模型目标不同): +- OHLCV 无法完美模拟 Maker 成交时点;本版用更严过滤降频到 ~10-30 笔/天量级做压力测试 +- 实盘用 orderbook 复核吸收/挂价 + +运行: + freqtrade backtesting -c ./user_data/Chan/config/BTC_Maker_Micro_Scalper_v11.json \\ + --strategy BTC_Maker_Micro_Scalper_v11 --strategy-path ./user_data/Chan/strategies \\ + --timerange=20260701-20260708 --fee 0.00016 --enable-protections +""" + +from __future__ import annotations + +import logging +from datetime import datetime, timedelta, timezone +from typing import Optional + +import numpy as np +import pandas as pd +import talib.abstract as ta +from pandas import DataFrame + +from freqtrade.persistence import Trade +from freqtrade.strategy import IStrategy + +logger = logging.getLogger(__name__) + + +def _safe_div(num, den, fill=0.0): + out = np.where((den is not None) & (den != 0), num / den, fill) + return out + + +class BTC_Maker_Micro_Scalper_v11(IStrategy): + """ + v1.1 Liquidity Providing:卖压衰竭 + 吸收 → Maker 接单;趋势中禁做市。 + """ + + INTERFACE_VERSION: int = 3 + timeframe = "1m" + can_short = True + process_only_new_candles = True + startup_candle_count = 200 + + # 不用固定小 ROI;出场交给 custom_exit(价差/优势恢复) + # 给一个很宽的 ROI 兜底,避免永远不走 ROI 路径也能被时间/恢复逻辑平掉 + minimal_roi = {"0": 0.01} + # 硬止损仍保留,但比 v1 更宽松一点,避免“小止盈大止损”结构;主出场是恢复 + stoploss = -0.0015 # -0.15% profit_ratio 硬止损(含杠杆后仍需观察) + trailing_stop = False + use_exit_signal = True + exit_profit_only = False + use_custom_stoploss = False + + order_types = { + "entry": "limit", + "exit": "limit", + "stoploss": "limit", + "stoploss_on_exchange": False, + } + order_time_in_force = {"entry": "GTC", "exit": "GTC"} + + # ---- 费用 / 风控 ---- + maker_fee = 0.00016 + stake_pct = 0.005 + max_leverage = 2.0 # v1.1 更克制 + consecutive_loss_limit = 10 + pause_minutes = 30 + max_hold_minutes = 5 + + # ---- 微结构代理窗口(1m 近似 20s/100trades)---- + sell_window = 3 # 近端卖量 + sell_ref_window = 8 # 更长对比窗:必须“先有卖压再衰竭” + absorb_lookback = 5 + min_absorb_ratio = 18.0 # 吸收要足够强(模型阈值,不是 OBI 调参) + tick_size = 0.1 + maker_depth_ticks = 2 # Bid - 2 tick / Ask + 2 tick + exhaust_ratio = 0.70 # 近端卖量 < 参考窗 * 70% + prior_sell_mult = 1.2 # 衰竭前参考窗卖量须高于更长均量(真有过卖压) + + # ---- 禁做市(趋势)---- + ema_slope_thr = 0.00018 # 更早禁止单边做市 + atr_spike_mult = 1.8 + min_atr_pct = 0.00035 + + # 目标退出:相对入场的“优势恢复”幅度(价格) + edge_exit_pct = 0.00025 + adverse_exit_pct = 0.0006 + cooldown_minutes = 8 # 降频到验收带附近 + + ob_levels = 10 + + _loss_streak: int = 0 + _pause_until: Optional[datetime] = None + _last_entry_time: Optional[datetime] = None + + plot_config = { + "main_plot": { + "ema26_1m": {"color": "gray"}, + }, + "subplots": { + "SellVol": { + "sell_vol": {"color": "red"}, + "sell_vol_ma": {"color": "orange"}, + }, + "Absorb": {"absorb_ratio": {"color": "blue"}}, + "TrendBlock": {"trend_block": {"color": "black"}}, + }, + } + + def populate_indicators(self, dataframe: DataFrame, metadata: dict) -> DataFrame: + df = dataframe.copy() + high, low, close, volume = df["high"], df["low"], df["close"], df["volume"].astype(float) + + close_c = close.clip(lower=low, upper=high) + hl = (high - low).astype(float) + hl_safe = hl.where(hl > 0, np.nan) + buy_frac = ((close_c - low) / hl_safe).fillna(0.5).clip(0.0, 1.0) + sell_frac = 1.0 - buy_frac + buy_vol = volume * buy_frac + sell_vol = volume * sell_frac + df["buy_vol"] = buy_vol + df["sell_vol"] = sell_vol + df["delta"] = buy_vol - sell_vol + + # ---- A. 主动卖压衰竭(Long)---- + # 先有卖压(ref 高),再衰竭(近端下降),且价格不创新低 + df["sell_vol_ma"] = sell_vol.rolling(self.sell_window, min_periods=1).mean() + df["sell_vol_ref"] = sell_vol.rolling(self.sell_ref_window, min_periods=1).mean() + sell_baseline = sell_vol.rolling(30, min_periods=10).mean() + df["sell_exhaust"] = ( + (df["sell_vol_ref"] > sell_baseline * self.prior_sell_mult) + & (df["sell_vol_ma"] < df["sell_vol_ref"] * self.exhaust_ratio) + & (low >= low.rolling(self.sell_ref_window, min_periods=1).min().shift(1)) + ) + + # 主动买压衰竭(Short 对称) + df["buy_vol_ma"] = buy_vol.rolling(self.sell_window, min_periods=1).mean() + df["buy_vol_ref"] = buy_vol.rolling(self.sell_ref_window, min_periods=1).mean() + buy_baseline = buy_vol.rolling(30, min_periods=10).mean() + df["buy_exhaust"] = ( + (df["buy_vol_ref"] > buy_baseline * self.prior_sell_mult) + & (df["buy_vol_ma"] < df["buy_vol_ref"] * self.exhaust_ratio) + & (high <= high.rolling(self.sell_ref_window, min_periods=1).max().shift(1)) + ) + + # ---- B. Bid 吸收:成交卖量 / 价格跌幅 ---- + # 价格跌幅用 lookback 内低点相对起点跌幅(百分比,避免除零) + px_drop = (close.shift(self.absorb_lookback) - low).clip(lower=0) + px_drop_pct = (px_drop / close.shift(self.absorb_lookback)).replace(0, np.nan) + sell_sum = sell_vol.rolling(self.absorb_lookback, min_periods=1).sum() + # absorb_ratio = 卖量 / (跌幅% * 10000) 标准化到可读量级;跌不动时放大 + df["absorb_ratio"] = (sell_sum / (px_drop_pct * 10000.0)).replace( + [np.inf, -np.inf], np.nan + ).fillna(0.0) + + # 价格几乎不跌但有大量卖出 → 吸收极强:给高分 + flat_sell = (px_drop_pct.fillna(0) < 0.00005) & (sell_sum > sell_sum.rolling(20).median()) + df.loc[flat_sell.fillna(False), "absorb_ratio"] = df.loc[ + flat_sell.fillna(False), "absorb_ratio" + ].clip(lower=self.min_absorb_ratio * 1.5) + + # Ask 吸收(Short):买量 / 上涨幅度 + px_up = (high - close.shift(self.absorb_lookback)).clip(lower=0) + px_up_pct = (px_up / close.shift(self.absorb_lookback)).replace(0, np.nan) + buy_sum = buy_vol.rolling(self.absorb_lookback, min_periods=1).sum() + df["absorb_ratio_ask"] = (buy_sum / (px_up_pct * 10000.0)).replace( + [np.inf, -np.inf], np.nan + ).fillna(0.0) + flat_buy = (px_up_pct.fillna(0) < 0.00005) & (buy_sum > buy_sum.rolling(20).median()) + df.loc[flat_buy.fillna(False), "absorb_ratio_ask"] = df.loc[ + flat_buy.fillna(False), "absorb_ratio_ask" + ].clip(lower=self.min_absorb_ratio * 1.5) + + df["bid_absorb"] = df["absorb_ratio"] >= self.min_absorb_ratio + df["ask_absorb"] = df["absorb_ratio_ask"] >= self.min_absorb_ratio + + # ---- 波动与 ATR ---- + df["atr"] = ta.ATR(df, timeperiod=20) + df["atr_pct"] = (df["atr"] / close).replace([np.inf, -np.inf], np.nan).fillna(0.0) + atr_med = df["atr_pct"].rolling(60, min_periods=20).median() + df["atr_spike"] = df["atr_pct"] > (atr_med * self.atr_spike_mult) + df["atr_ok"] = (df["atr_pct"] >= self.min_atr_pct) & (~df["atr_spike"]) + + # ---- 禁做市:趋势(EMA26 斜率,1m 上 5 根≈5m 变化代理)---- + df["ema26_1m"] = ta.EMA(df, timeperiod=26) + df["ema26_slope"] = ( + (df["ema26_1m"] - df["ema26_1m"].shift(5)) / close + ).replace([np.inf, -np.inf], np.nan).fillna(0.0) + df["trend_block"] = df["ema26_slope"].abs() > self.ema_slope_thr + + # 微结构“可做市”综合 + df["mm_regime"] = df["atr_ok"] & (~df["trend_block"]) + + # 中价 / 伪价差 + df["mid"] = (high + low) / 2.0 + df["range_pct"] = (hl / close).replace([np.inf, -np.inf], np.nan).fillna(0.0) + + return df + + def populate_entry_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame: + df = dataframe + + # Long:卖压衰竭 + Bid 吸收 + 非趋势 + long_cond = ( + df["mm_regime"] + & df["sell_exhaust"] + & df["bid_absorb"] + & (df["volume"] > 0) + # 额外:近端 delta 不再恶化(卖压减弱) + & (df["delta"] > df["delta"].shift(1)) + ) + + # Short:买压衰竭 + Ask 吸收 + 非趋势 + short_cond = ( + df["mm_regime"] + & df["buy_exhaust"] + & df["ask_absorb"] + & (df["volume"] > 0) + & (df["delta"] < df["delta"].shift(1)) + ) + + df.loc[long_cond, ["enter_long", "enter_tag"]] = (1, "lp_bid_absorb") + df.loc[short_cond, ["enter_short", "enter_tag"]] = (1, "lp_ask_absorb") + return df + + def populate_exit_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame: + """信号层只做趋势禁做市强平;主出场交给 custom_exit。""" + df = dataframe + df["exit_long"] = 0 + df["exit_short"] = 0 + df.loc[df["trend_block"], ["exit_long", "exit_tag"]] = (1, "trend_block") + df.loc[df["trend_block"], ["exit_short", "exit_tag"]] = (1, "trend_block") + return df + + # ------------------------------------------------------------------ # + # Maker 报价:Bid - depth ticks / Ask + depth ticks + # ------------------------------------------------------------------ # + def custom_entry_price( + self, + pair: str, + trade: Trade | None, + current_time: datetime, + proposed_rate: float, + entry_tag: str | None, + side: str, + **kwargs, + ) -> float: + offset = self.maker_depth_ticks * self.tick_size + try: + if self.dp and self.dp.runmode.value in ("live", "dry_run"): + ob = self.dp.orderbook(pair, self.ob_levels) + bids = ob.get("bids") or [] + asks = ob.get("asks") or [] + if side == "long" and bids: + return max(float(bids[0][0]) - offset, self.tick_size) + if side == "short" and asks: + return float(asks[0][0]) + offset + except Exception as e: + logger.debug("v11 entry price ob fallback: %s", e) + + # 回测:挂得更深,降低“虚假即时成交”概率(仍不完美) + if side == "long": + return proposed_rate - offset + return proposed_rate + offset + + def custom_exit_price( + self, + pair: str, + trade: Trade, + current_time: datetime, + proposed_rate: float, + current_profit: float, + exit_tag: str | None, + **kwargs, + ) -> float: + offset = 1 * self.tick_size + try: + if self.dp and self.dp.runmode.value in ("live", "dry_run"): + ob = self.dp.orderbook(pair, self.ob_levels) + bids = ob.get("bids") or [] + asks = ob.get("asks") or [] + # 出场尽量 Maker:多头卖 Ask-1;空头买 Bid+1 + if trade.is_short and bids: + return float(bids[0][0]) + offset + if (not trade.is_short) and asks: + return float(asks[0][0]) - offset + except Exception as e: + logger.debug("v11 exit price ob fallback: %s", e) + if trade.is_short: + return proposed_rate - offset + return proposed_rate + offset + + def leverage( + self, + pair: str, + current_time: datetime, + current_rate: float, + proposed_leverage: float, + max_leverage: float, + entry_tag: Optional[str], + side: str, + **kwargs, + ) -> float: + return min(self.max_leverage, float(max_leverage)) + + def custom_stake_amount( + self, + pair: str, + current_time: datetime, + current_rate: float, + proposed_stake: float, + min_stake: float | None, + max_stake: float, + leverage: float, + entry_tag: str | None, + side: str, + **kwargs, + ) -> float: + try: + if self.wallets: + free = self.wallets.get_free(self.config["stake_currency"]) + stake = free * self.stake_pct + if min_stake: + stake = max(stake, min_stake) + return min(stake, max_stake) + except Exception: + pass + return min(proposed_stake * self.stake_pct, max_stake) if proposed_stake else proposed_stake + + def _paused(self, current_time: datetime) -> bool: + if self._pause_until is None: + return False + now = current_time if current_time.tzinfo else current_time.replace(tzinfo=timezone.utc) + until = ( + self._pause_until + if self._pause_until.tzinfo + else self._pause_until.replace(tzinfo=timezone.utc) + ) + return now < until + + def _in_cooldown(self, current_time: datetime) -> bool: + if self._last_entry_time is None: + return False + now = current_time if current_time.tzinfo else current_time.replace(tzinfo=timezone.utc) + last = ( + self._last_entry_time + if self._last_entry_time.tzinfo + else self._last_entry_time.replace(tzinfo=timezone.utc) + ) + return (now - last) < timedelta(minutes=self.cooldown_minutes) + + def confirm_trade_entry( + self, + pair: str, + order_type: str, + amount: float, + rate: float, + time_in_force: str, + current_time: datetime, + entry_tag: str | None, + side: str, + **kwargs, + ) -> bool: + if self._paused(current_time) or self._in_cooldown(current_time): + return False + + # 实盘:趋势禁做市 + 盘口复核(卖一/买一厚度) + try: + dataframe, _ = self.dp.get_analyzed_dataframe(pair, self.timeframe) + if dataframe is not None and len(dataframe): + last = dataframe.iloc[-1] + if bool(last.get("trend_block", False)) or (not bool(last.get("mm_regime", False))): + return False + + if self.dp.runmode.value in ("live", "dry_run"): + ob = self.dp.orderbook(pair, self.ob_levels) + bids = ob.get("bids") or [] + asks = ob.get("asks") or [] + if not bids or not asks: + return False + # 简单吸收代理:同价位附近挂单厚度 + bid_vol = sum(float(b[1]) for b in bids[:3]) + ask_vol = sum(float(a[1]) for a in asks[:3]) + if side == "long" and bid_vol < ask_vol * 0.8: + # Bid 不够厚,吸收叙事弱 + return False + if side == "short" and ask_vol < bid_vol * 0.8: + return False + except Exception as e: + logger.debug("v11 confirm entry: %s", e) + + self._last_entry_time = current_time + return True + + def custom_exit( + self, + pair: str, + trade: Trade, + current_time: datetime, + current_rate: float, + current_profit: float, + **kwargs, + ): + open_time = trade.open_date_utc + if open_time.tzinfo is None: + open_time = open_time.replace(tzinfo=timezone.utc) + now = current_time if current_time.tzinfo else current_time.replace(tzinfo=timezone.utc) + if now - open_time >= timedelta(minutes=self.max_hold_minutes): + return "time_stop_5m" + + # 优势恢复出场(替代固定 0.05% TP) + # long: 价格相对开仓上涨 edge_exit_pct;short: 下跌 edge_exit_pct + # current_profit 已是 stake 利润率(含杠杆),换算成“价格优势”用 open_rate 更稳 + entry = trade.open_rate + if not trade.is_short: + edge = (current_rate - entry) / entry + if edge >= self.edge_exit_pct: + return "spread_edge_restore" + if edge <= -self.adverse_exit_pct: + return "adverse_move" + else: + edge = (entry - current_rate) / entry + if edge >= self.edge_exit_pct: + return "spread_edge_restore" + if edge <= -self.adverse_exit_pct: + return "adverse_move" + + # 重新进入趋势禁做市 → 立刻撤流动性 + try: + dataframe, _ = self.dp.get_analyzed_dataframe(pair, self.timeframe) + if dataframe is not None and len(dataframe): + if bool(dataframe.iloc[-1].get("trend_block", False)): + return "trend_block_exit" + except Exception: + pass + + return None + + def confirm_trade_exit( + self, + pair: str, + trade: Trade, + order_type: str, + amount: float, + rate: float, + time_in_force: str, + exit_reason: str, + current_time: datetime, + **kwargs, + ) -> bool: + try: + profit = trade.calc_profit_ratio(rate) + if profit < 0: + self._loss_streak += 1 + if self._loss_streak >= self.consecutive_loss_limit: + self._pause_until = current_time + timedelta(minutes=self.pause_minutes) + self._loss_streak = 0 + else: + self._loss_streak = 0 + except Exception: + pass + return True + + @property + def protections(self): + return [ + { + "method": "CooldownPeriod", + "stop_duration_candles": int(self.cooldown_minutes), + }, + { + "method": "StoplossGuard", + "lookback_period_candles": 60, + "trade_limit": self.consecutive_loss_limit, + "stop_duration_candles": self.pause_minutes, + "only_per_pair": True, + }, + ] diff --git a/strategies/ChanLun_BTC_15.py b/strategies/ChanLun_BTC_15.py index 1522f85..108839c 100644 --- a/strategies/ChanLun_BTC_15.py +++ b/strategies/ChanLun_BTC_15.py @@ -77,7 +77,7 @@ class ChanLun_BTC_15(IStrategy): trailing_only_offset_is_reached = False position_adjustment_enable = True - startup_candle_count = 100 + startup_candle_count = 1000 time5 = 5 time15 = 15 @@ -88,21 +88,14 @@ class ChanLun_BTC_15(IStrategy): time5 = 1440 last_time = datetime.now() def populate_indicators(self, dataframe: DataFrame, metadata: dict) -> DataFrame: - tf_df_5 = TF_DF(dataframe, self.time5, '5m') - tf_df_15 = TF_DF(dataframe, self.time15, '15m') - tf_df_30 = TF_DF(dataframe, self.time30, '30m') - tf_df_60 = TF_DF(dataframe, self.time60, '60m') - tf_df_4h = TF_DF(dataframe, self.time4h, '4h') - tf_df_1d = TF_DF(dataframe, self.time1d, '1d') - + df_5m = resample_to_interval(dataframe, self.time5) + df_15m = resample_to_interval(dataframe, self.time15) + dataframe = TF_DF.add_indicators(dataframe) + df_5m = TF_DF.add_indicators(df_5m) + df_15m = TF_DF.add_indicators(df_15m) - - dataframe = resampled_merge(dataframe, tf_df_5.dataframe) - dataframe = resampled_merge(dataframe, tf_df_15.dataframe) - dataframe = resampled_merge(dataframe, tf_df_30.dataframe) - dataframe = resampled_merge(dataframe, tf_df_60.dataframe) - dataframe = resampled_merge(dataframe, tf_df_4h.dataframe) - dataframe = resampled_merge(dataframe, tf_df_1d.dataframe) + dataframe = resampled_merge(dataframe, df_5m) + dataframe = resampled_merge(dataframe, df_15m) return dataframe def custom_entry_price(self, pair: str, trade: Trade | None, current_time: datetime, proposed_rate: float, diff --git a/strategies/MakerEdgeProbe.py b/strategies/MakerEdgeProbe.py new file mode 100644 index 0000000..4c6ecd7 --- /dev/null +++ b/strategies/MakerEdgeProbe.py @@ -0,0 +1,434 @@ +""" +MakerEdgeProbe — Freqtrade Dry-run 探针(过渡用)。 + +正式 Maker / L2 / Edge 采集已迁移到: + nautilus_mm/ (NautilusTrader,独立 .venv) + +本策略仍可用于 Freqtrade 侧对照;新开发请走 nautilus_mm。 + +运行 Nautilus: + cd nautilus_mm && ./scripts/run_probe.sh + +分析: + cd nautilus_mm && ./scripts/analyze.sh +""" + +from __future__ import annotations + +import logging +import time +from datetime import datetime, timedelta, timezone +from typing import Optional + +import numpy as np +import talib.abstract as ta +from pandas import DataFrame + +from freqtrade.persistence import Trade, Order +from freqtrade.strategy import IStrategy + +from maker_edge_logger import MakerEdgeLogger + +logger = logging.getLogger(__name__) + + +class MakerEdgeProbe(IStrategy): + INTERFACE_VERSION = 3 + timeframe = "1m" + can_short = True + process_only_new_candles = False + startup_candle_count = 60 + + minimal_roi = {"0": 0.01} + stoploss = -0.002 + trailing_stop = False + use_exit_signal = False + + order_types = { + "entry": "limit", + "exit": "limit", + "stoploss": "market", + "stoploss_on_exchange": False, + } + order_time_in_force = {"entry": "GTC", "exit": "GTC"} + + tick_size = 0.1 + quote_depth_ticks = 1 + max_leverage = 1.0 + stake_pct = 0.003 + max_hold_minutes = 5 + edge_exit_pct = 0.0002 + adverse_exit_pct = 0.0008 + cooldown_minutes = 5 + ob_levels = 10 + trade_lookback = 100 + ema_slope_thr = 0.0002 + book_sample_every_sec = 2.0 + + _logger: MakerEdgeLogger | None = None + _last_mid: float | None = None + _last_book_sample: float = 0.0 + _last_entry_time: Optional[datetime] = None + _recent_high: float = 0.0 + _recent_low: float = 0.0 + _pending_quote_id: Optional[str] = None + _fill_by_trade: dict[int, str] = {} + + def bot_start(self, **kwargs) -> None: + self._logger = MakerEdgeLogger(levels=self.ob_levels) + self._fill_by_trade = {} + logger.info("MakerEdgeProbe started. log_dir=%s", self._logger.log_dir) + + def _get_logger(self) -> MakerEdgeLogger: + if self._logger is None: + self._logger = MakerEdgeLogger(levels=self.ob_levels) + return self._logger + + def _fetch_trades(self, pair: str) -> list: + try: + ex = self.dp._exchange + if ex is None: + return [] + api = getattr(ex, "_api", None) or getattr(ex, "api", None) + if api is None: + return [] + return api.fetch_trades(pair, limit=self.trade_lookback) or [] + except Exception as e: + logger.debug("fetch_trades failed: %s", e) + return [] + + def _inventory(self) -> float: + try: + inv = 0.0 + for t in Trade.get_open_trades(): + amt = float(t.amount or 0.0) + inv += -amt if t.is_short else amt + return inv + except Exception: + return 0.0 + + def _market_state(self, pair: str) -> dict: + state = { + "trend_state": "UNKNOWN", + "atr_pct": None, + "volatility_regime": "UNKNOWN", + "ema_slope": None, + } + try: + df, _ = self.dp.get_analyzed_dataframe(pair, self.timeframe) + if df is None or len(df) == 0: + return state + last = df.iloc[-1] + slope = float(last.get("ema_slope") or 0.0) + atr_pct = float(last.get("atr_pct") or 0.0) + state["ema_slope"] = slope + state["atr_pct"] = atr_pct + if bool(last.get("trend_block", False)): + state["trend_state"] = "TREND_UP" if slope > 0 else "TREND_DOWN" + else: + state["trend_state"] = "RANGE" + # 波动分位代理 + if "atr_pct" in df.columns: + med = float(df["atr_pct"].tail(60).median() or 0) + if atr_pct > med * 1.8: + state["volatility_regime"] = "HIGH" + elif atr_pct < med * 0.7: + state["volatility_regime"] = "LOW" + else: + state["volatility_regime"] = "NORMAL" + except Exception: + pass + return state + + def _snapshot(self, pair: str): + ob = self.dp.orderbook(pair, self.ob_levels) + trades = self._fetch_trades(pair) + snap = MakerEdgeLogger.snapshot_from_orderbook( + ob, + levels=self.ob_levels, + recent_trades=trades, + last_mid=self._last_mid, + liq_proxy_low=self._recent_low or None, + liq_proxy_high=self._recent_high or None, + ) + if snap.mid: + self._last_mid = snap.mid + return snap + + def bot_loop_start(self, current_time: datetime, **kwargs) -> None: + if self.dp.runmode.value not in ("live", "dry_run"): + return + pair = self.config["exchange"]["pair_whitelist"][0] + try: + snap = self._snapshot(pair) + tick = self.dp.ticker(pair) or {} + last = float(tick.get("last") or tick.get("close") or 0.0) or snap.mid + + df, _ = self.dp.get_analyzed_dataframe(pair, self.timeframe) + if df is not None and len(df): + self._recent_high = float(df.iloc[-1].get("roll_high") or self._recent_high or last) + self._recent_low = float(df.iloc[-1].get("roll_low") or self._recent_low or last) + + lg = self._get_logger() + now = time.time() + # 盘口历史(成交前5s恶化检测依赖此) + if now - self._last_book_sample >= self.book_sample_every_sec: + self._last_book_sample = now + lg.record_book(snap, now=now) + + if last: + lg.update_paths(pair, last, now=now) + except Exception as e: + logger.warning("bot_loop_start probe error: %s", e) + + def populate_indicators(self, dataframe: DataFrame, metadata: dict) -> DataFrame: + df = dataframe + close, high, low = df["close"], df["high"], df["low"] + volume = df["volume"].astype(float) + + close_c = close.clip(lower=low, upper=high) + hl = (high - low).replace(0, np.nan) + buy_frac = ((close_c - low) / hl).fillna(0.5).clip(0, 1) + sell_vol = volume * (1.0 - buy_frac) + buy_vol = volume * buy_frac + df["sell_vol"] = sell_vol + df["buy_vol"] = buy_vol + df["delta"] = buy_vol - sell_vol + + vol_ma = volume.rolling(20, min_periods=5).mean() + df["shock_sell"] = (sell_vol > vol_ma * 3) & (df["delta"] < 0) + df["shock_buy"] = (buy_vol > vol_ma * 3) & (df["delta"] > 0) + + drop = (close.shift(3) - low).clip(lower=0) / close.shift(3) + up = (high - close.shift(3)).clip(lower=0) / close.shift(3) + df["de_sell"] = (sell_vol.rolling(3).sum() / (drop.replace(0, np.nan) * 1e4)).replace( + [np.inf, -np.inf], np.nan + ).fillna(0) + df["de_buy"] = (buy_vol.rolling(3).sum() / (up.replace(0, np.nan) * 1e4)).replace( + [np.inf, -np.inf], np.nan + ).fillna(0) + + df["ema26"] = ta.EMA(df, timeperiod=26) + df["ema_slope"] = ((df["ema26"] - df["ema26"].shift(5)) / close).fillna(0) + df["trend_block"] = df["ema_slope"].abs() > self.ema_slope_thr + df["atr"] = ta.ATR(df, timeperiod=20) + df["atr_pct"] = (df["atr"] / close).fillna(0) + + s_ma, s_ref = sell_vol.rolling(3).mean(), sell_vol.rolling(8).mean() + df["sell_exhaust"] = (s_ma < s_ref * 0.75) & (low >= low.rolling(8).min().shift(1)) + b_ma, b_ref = buy_vol.rolling(3).mean(), buy_vol.rolling(8).mean() + df["buy_exhaust"] = (b_ma < b_ref * 0.75) & (high <= high.rolling(8).max().shift(1)) + + df["roll_high"] = high.rolling(60, min_periods=10).max() + df["roll_low"] = low.rolling(60, min_periods=10).min() + return df + + def populate_entry_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame: + df = dataframe + long_c = ( + (~df["trend_block"]) + & df["shock_sell"].rolling(5).max().astype(bool) + & (df["de_sell"] > 10) + & df["sell_exhaust"] + ) + short_c = ( + (~df["trend_block"]) + & df["shock_buy"].rolling(5).max().astype(bool) + & (df["de_buy"] > 10) + & df["buy_exhaust"] + ) + df.loc[long_c, ["enter_long", "enter_tag"]] = (1, "probe_bid_lp") + df.loc[short_c, ["enter_short", "enter_tag"]] = (1, "probe_ask_lp") + return df + + def populate_exit_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame: + dataframe["exit_long"] = 0 + dataframe["exit_short"] = 0 + return dataframe + + def custom_entry_price( + self, + pair: str, + trade: Trade | None, + current_time: datetime, + proposed_rate: float, + entry_tag: str | None, + side: str, + **kwargs, + ) -> float: + offset = self.quote_depth_ticks * self.tick_size + try: + snap = self._snapshot(pair) + price = snap.best_bid - offset if side == "long" else snap.best_ask + offset + state = self._market_state(pair) + qid = self._get_logger().create_quote( + pair=pair, + side="bid" if side == "long" else "ask", + quote_price=price, + inventory=self._inventory(), + snap=snap, + reason=entry_tag or "entry", + trade_id=trade.id if trade else None, + state=state, + ) + self._pending_quote_id = qid + return price + except Exception as e: + logger.debug("custom_entry_price: %s", e) + return proposed_rate - offset if side == "long" else proposed_rate + offset + + def confirm_trade_entry( + self, + pair: str, + order_type: str, + amount: float, + rate: float, + time_in_force: str, + current_time: datetime, + entry_tag: str | None, + side: str, + **kwargs, + ) -> bool: + if self._last_entry_time: + last = self._last_entry_time + if last.tzinfo is None: + last = last.replace(tzinfo=timezone.utc) + now = current_time if current_time.tzinfo else current_time.replace(tzinfo=timezone.utc) + if now - last < timedelta(minutes=self.cooldown_minutes): + return False + try: + df, _ = self.dp.get_analyzed_dataframe(pair, self.timeframe) + if df is not None and len(df) and bool(df.iloc[-1].get("trend_block", False)): + return False + snap = self._snapshot(pair) + if side == "long" and snap.bid_depth_1 < snap.ask_depth_1 * 0.7: + return False + if side == "short" and snap.ask_depth_1 < snap.bid_depth_1 * 0.7: + return False + except Exception: + pass + self._last_entry_time = current_time + return True + + def check_entry_timeout( + self, pair: str, trade: Trade, order: Order, current_time: datetime, **kwargs + ) -> bool: + """超时撤单 → 记录 quote_cancel(坏时间未成交 vs 被动成交的对照)。""" + try: + snap = self._snapshot(pair) + self._get_logger().cancel_quote( + quote_id=self._pending_quote_id, + trade_id=trade.id, + reason="entry_timeout", + snap=snap, + ) + except Exception as e: + logger.debug("cancel_quote on timeout: %s", e) + # False = 不额外强制取消;交给 unfilledtimeout 配置。若要立刻取消返回 True + return False + + def order_filled( + self, + pair: str, + trade: Trade, + order: Order, + current_time: datetime, + **kwargs, + ) -> None: + try: + lg = self._get_logger() + # 入场成交 + if order.ft_order_side == trade.entry_side: + snap = self._snapshot(pair) + side = "short" if trade.is_short else "long" + # 粗分 fill_reason:time_to_fill 在 logger 内算;这里标 maker_hit + # 若成交前5s盘口已恶化 → toxic_passive 候选 + det = lg.book_deterioration(side) + fill_reason = "toxic_passive" if det.get("pre_5s_deteriorated") else "maker_hit" + if self._pending_quote_id: + lg.bind_trade(self._pending_quote_id, trade.id) + fill_id = lg.log_fill( + pair=pair, + side=side, + fill_price=float(order.safe_price or trade.open_rate), + amount=float(order.safe_filled or order.safe_amount or 0), + inventory=self._inventory(), + snap=snap, + order_type=str(getattr(order, "order_type", None) or "limit"), + quote_id=self._pending_quote_id, + trade_id=trade.id, + fill_reason=fill_reason, + state=self._market_state(pair), + extra={"entry_tag": trade.enter_tag}, + ) + self._fill_by_trade[trade.id] = fill_id + self._pending_quote_id = None + else: + # 出场:把 exit_reason 挂到入场 fill,供 H2 + fill_id = self._fill_by_trade.get(trade.id) + reason = trade.exit_reason or getattr(order, "ft_order_tag", None) or "exit" + if fill_id: + lg.attach_exit_reason(fill_id, str(reason)) + except Exception as e: + logger.warning("order_filled log error: %s", e) + + def custom_exit( + self, + pair: str, + trade: Trade, + current_time: datetime, + current_rate: float, + current_profit: float, + **kwargs, + ): + open_time = trade.open_date_utc + if open_time.tzinfo is None: + open_time = open_time.replace(tzinfo=timezone.utc) + now = current_time if current_time.tzinfo else current_time.replace(tzinfo=timezone.utc) + if now - open_time >= timedelta(minutes=self.max_hold_minutes): + return "probe_time" + entry = trade.open_rate + edge = ( + (current_rate - entry) / entry + if not trade.is_short + else (entry - current_rate) / entry + ) + if edge >= self.edge_exit_pct: + return "probe_edge_restore" + if edge <= -self.adverse_exit_pct: + return "probe_adverse" + # 趋势切换 → 撤流动性思维 + try: + st = self._market_state(pair) + if st.get("trend_state") in ("TREND_UP", "TREND_DOWN"): + # 持仓方向与趋势相反时更危险 + if (not trade.is_short and st["trend_state"] == "TREND_DOWN") or ( + trade.is_short and st["trend_state"] == "TREND_UP" + ): + return "probe_trend_cancel" + except Exception: + pass + return None + + def leverage( + self, pair: str, current_time: datetime, current_rate: float, + proposed_leverage: float, max_leverage: float, entry_tag: Optional[str], + side: str, **kwargs, + ) -> float: + return min(self.max_leverage, float(max_leverage)) + + def custom_stake_amount( + self, pair: str, current_time: datetime, current_rate: float, + proposed_stake: float, min_stake: Optional[float], max_stake: float, + leverage: float, entry_tag: Optional[str], side: str, **kwargs, + ) -> float: + try: + if self.wallets: + free = self.wallets.get_free(self.config["stake_currency"]) + stake = free * self.stake_pct + if min_stake: + stake = max(stake, min_stake) + return min(stake, max_stake) + except Exception: + pass + return min(proposed_stake * self.stake_pct, max_stake) diff --git a/strategies/Turtle_BTC.py b/strategies/Turtle_BTC.py new file mode 100644 index 0000000..4e984b8 --- /dev/null +++ b/strategies/Turtle_BTC.py @@ -0,0 +1,449 @@ +# --- Do not remove these libs --- +from freqtrade.strategy import IStrategy, IntParameter, DecimalParameter, stoploss_from_absolute +from freqtrade.persistence import Trade +import talib.abstract as ta +from pandas import DataFrame +import pandas as pd +import numpy as np +from datetime import datetime +from typing import Optional +import logging + +logger = logging.getLogger(__name__) + +# freqtrade trade -c ./user_data/Chan/config/Turtle_BTC.json --strategy Turtle_BTC --strategy-path ./user_data/Chan/strategies +# freqtrade backtesting -c ./user_data/Chan/config/Turtle_BTC.json --strategy Turtle_BTC --strategy-path ./user_data/Chan/strategies --timerange=20251201- +# freqtrade download-data -c ./user_data/Chan/config/Turtle_BTC.json -t 15m --pairs BTC/USDT:USDT --timerange=20240101- + + +class Turtle_BTC(IStrategy): + """ + 海龟交易法 (Turtle Trading) - 15m 优化版 + + 相对经典日线参数,15m 上做了适配: + - 通道周期拉长(约 1日 / 2日),降低噪音假突破 + - EMA200 趋势过滤:只做顺势方向 + - ADX 过滤:只在有趋势时开仓 + - 突破用「向上/向下穿越」,避免通道内反复信号 + - 单单元保证金上限,避免低波动时仓位占满账户 + - 系统2 优先、系统1 补漏(S1 带赢利跳过过滤) + - trade_side 可限制只做多/只做空(默认 short,适配近段下跌市) + """ + INTERFACE_VERSION = 3 + timeframe = "15m" + can_short = True + process_only_new_candles = True + # 需覆盖 S2 入场周期 + EMA200 + startup_candle_count = 250 + + minimal_roi = {"0": 100} + stoploss = -0.99 + use_custom_stoploss = True + trailing_stop = False + use_exit_signal = False + exit_profit_only = False + ignore_roi_if_entry_signal = True + + position_adjustment_enable = True + max_entry_position_adjustment = 3 # 首仓 + 3 加仓 = 4 单元 + + # ---- 15m 适配后的默认周期(约 1日 / 2日)---- + # 96 根 15m ≈ 1 天;192 根 ≈ 2 天 + entry_period_s1 = IntParameter(48, 144, default=96, space="buy", optimize=True) + exit_period_s1 = IntParameter(24, 96, default=48, space="sell", optimize=True) + entry_period_s2 = IntParameter(120, 288, default=192, space="buy", optimize=True) + exit_period_s2 = IntParameter(48, 144, default=96, space="sell", optimize=True) + atr_period = IntParameter(14, 40, default=20, space="buy", optimize=False) + stop_atr_mult = DecimalParameter(1.5, 3.5, default=2.0, decimals=1, space="sell", optimize=True) + pyramid_atr_mult = DecimalParameter(0.3, 1.0, default=0.5, decimals=1, space="buy", optimize=True) + risk_per_unit = DecimalParameter(0.005, 0.02, default=0.01, decimals=3, space="buy", optimize=False) + adx_threshold = IntParameter(15, 35, default=20, space="buy", optimize=True) + # 单单元保证金占可用资金上限(防止 15m 低波动时打满仓) + max_unit_stake_pct = DecimalParameter(0.15, 0.40, default=0.25, decimals=2, space="buy", optimize=False) + + lev = 1.0 + use_s1_win_skip = True + use_system1 = True + use_system2 = True + # 趋势 / 强度过滤 + use_ema_filter = True + use_adx_filter = True + # None=双向;可用 "long" / "short" 限制单边(勿用单段行情曲线拟合) + trade_side: Optional[str] = None + ema_period = 200 + + def populate_indicators(self, dataframe: DataFrame, metadata: dict) -> DataFrame: + ep1 = int(self.entry_period_s1.value) + xp1 = int(self.exit_period_s1.value) + ep2 = int(self.entry_period_s2.value) + xp2 = int(self.exit_period_s2.value) + atr_n = int(self.atr_period.value) + + dataframe["atr"] = ta.ATR(dataframe, timeperiod=atr_n) + dataframe["n"] = dataframe["atr"] + dataframe["ema_trend"] = ta.EMA(dataframe, timeperiod=self.ema_period) + dataframe["adx"] = ta.ADX(dataframe, timeperiod=14) + dataframe["volume_ma"] = ta.SMA(dataframe, timeperiod=20, price="volume") + + # 唐奇安通道(shift 1 防 lookahead) + dataframe["dc_high_s1"] = dataframe["high"].rolling(ep1).max().shift(1) + dataframe["dc_low_s1"] = dataframe["low"].rolling(ep1).min().shift(1) + dataframe["dc_exit_high_s1"] = dataframe["high"].rolling(xp1).max().shift(1) + dataframe["dc_exit_low_s1"] = dataframe["low"].rolling(xp1).min().shift(1) + + dataframe["dc_high_s2"] = dataframe["high"].rolling(ep2).max().shift(1) + dataframe["dc_low_s2"] = dataframe["low"].rolling(ep2).min().shift(1) + dataframe["dc_exit_high_s2"] = dataframe["high"].rolling(xp2).max().shift(1) + dataframe["dc_exit_low_s2"] = dataframe["low"].rolling(xp2).min().shift(1) + + # 穿越突破(只在刚突破那根触发) + dataframe["break_up_s1"] = ( + (dataframe["close"] > dataframe["dc_high_s1"]) + & (dataframe["close"].shift(1) <= dataframe["dc_high_s1"].shift(1)) + ) + dataframe["break_dn_s1"] = ( + (dataframe["close"] < dataframe["dc_low_s1"]) + & (dataframe["close"].shift(1) >= dataframe["dc_low_s1"].shift(1)) + ) + dataframe["break_up_s2"] = ( + (dataframe["close"] > dataframe["dc_high_s2"]) + & (dataframe["close"].shift(1) <= dataframe["dc_high_s2"].shift(1)) + ) + dataframe["break_dn_s2"] = ( + (dataframe["close"] < dataframe["dc_low_s2"]) + & (dataframe["close"].shift(1) >= dataframe["dc_low_s2"].shift(1)) + ) + + # 顺势过滤:价格相对 EMA200 + dataframe["trend_long"] = dataframe["close"] > dataframe["ema_trend"] + dataframe["trend_short"] = dataframe["close"] < dataframe["ema_trend"] + dataframe["adx_ok"] = dataframe["adx"] >= float(self.adx_threshold.value) + dataframe["vol_ok"] = dataframe["volume"] > dataframe["volume_ma"] * 0.8 + + if self.use_s1_win_skip: + dataframe["skip_s1_long"] = self._s1_skip_mask( + dataframe, long=True, exit_col="dc_exit_low_s1" + ) + dataframe["skip_s1_short"] = self._s1_skip_mask( + dataframe, long=False, exit_col="dc_exit_high_s1" + ) + else: + dataframe["skip_s1_long"] = False + dataframe["skip_s1_short"] = False + + return dataframe + + @staticmethod + def _s1_skip_mask(dataframe: DataFrame, long: bool, exit_col: str) -> pd.Series: + """系统1:上次同向突破盈利则跳过下一次。""" + n = len(dataframe) + skip = np.zeros(n, dtype=bool) + in_trade = False + entry_price = 0.0 + last_was_win = False + closes = dataframe["close"].to_numpy() + breaks = (dataframe["break_up_s1"] if long else dataframe["break_dn_s1"]).fillna(False).to_numpy() + exits = dataframe[exit_col].to_numpy() + + for i in range(n): + if np.isnan(exits[i]) or np.isnan(closes[i]): + continue + if in_trade: + hit_exit = closes[i] < exits[i] if long else closes[i] > exits[i] + if hit_exit: + pnl = (closes[i] - entry_price) if long else (entry_price - closes[i]) + last_was_win = pnl > 0 + in_trade = False + elif breaks[i]: + if last_was_win: + skip[i] = True + last_was_win = False + else: + in_trade = True + entry_price = closes[i] + return pd.Series(skip, index=dataframe.index) + + def _entry_filters(self, dataframe: DataFrame, long: bool) -> pd.Series: + base = ( + (dataframe["volume"] > 0) + & dataframe["atr"].notna() + & (dataframe["atr"] > 0) + & dataframe["vol_ok"] + ) + if self.use_ema_filter: + base &= dataframe["trend_long"] if long else dataframe["trend_short"] + if self.use_adx_filter: + base &= dataframe["adx_ok"] + return base + + def populate_entry_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame: + dataframe["enter_long"] = 0 + dataframe["enter_short"] = 0 + dataframe["enter_tag"] = "" + + allow_long = self.trade_side in (None, "long") + allow_short = self.trade_side in (None, "short") + long_base = self._entry_filters(dataframe, long=True) if allow_long else False + short_base = self._entry_filters(dataframe, long=False) if allow_short else False + + # 系统2优先(更稳),系统1补漏 + if self.use_system2: + if allow_long: + long_s2 = long_base & dataframe["break_up_s2"] + dataframe.loc[long_s2, ["enter_long", "enter_tag"]] = (1, "turtle_s2_long") + if allow_short: + short_s2 = short_base & dataframe["break_dn_s2"] + dataframe.loc[short_s2, ["enter_short", "enter_tag"]] = (1, "turtle_s2_short") + + if self.use_system1: + if allow_long: + long_s1 = ( + long_base & dataframe["break_up_s1"] + & (~dataframe["skip_s1_long"]) + & (dataframe["enter_long"] != 1) + ) + dataframe.loc[long_s1, ["enter_long", "enter_tag"]] = (1, "turtle_s1_long") + if allow_short: + short_s1 = ( + short_base & dataframe["break_dn_s1"] + & (~dataframe["skip_s1_short"]) + & (dataframe["enter_short"] != 1) + ) + dataframe.loc[short_s1, ["enter_short", "enter_tag"]] = (1, "turtle_s1_short") + + return dataframe + + def populate_exit_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame: + dataframe["exit_long"] = 0 + dataframe["exit_short"] = 0 + return dataframe + + def custom_exit( + self, + pair: str, + trade: Trade, + current_time: datetime, + current_rate: float, + current_profit: float, + **kwargs, + ) -> Optional[str]: + """按入场系统使用对应退出通道;用 close 与 current_rate 双确认。""" + dataframe, _ = self.dp.get_analyzed_dataframe(pair, self.timeframe) + if dataframe.empty: + return None + last = dataframe.iloc[-1] + tag = trade.enter_tag or "" + price = min(float(last["close"]), current_rate) if not trade.is_short else max(float(last["close"]), current_rate) + + if trade.is_short: + if "s1" in tag and price > float(last["dc_exit_high_s1"]): + return "turtle_s1_exit" + if "s2" in tag and price > float(last["dc_exit_high_s2"]): + return "turtle_s2_exit" + else: + if "s1" in tag and price < float(last["dc_exit_low_s1"]): + return "turtle_s1_exit" + if "s2" in tag and price < float(last["dc_exit_low_s2"]): + return "turtle_s2_exit" + return None + + def custom_stake_amount( + self, + pair: str, + current_time: datetime, + current_rate: float, + proposed_stake: float, + min_stake: Optional[float], + max_stake: float, + leverage: float, + entry_tag: Optional[str], + side: str, + **kwargs, + ) -> float: + dataframe, _ = self.dp.get_analyzed_dataframe(pair, self.timeframe) + if dataframe.empty: + return proposed_stake + + last = dataframe.iloc[-1] + atr = float(last["atr"]) if pd.notna(last["atr"]) else 0.0 + if atr <= 0 or current_rate <= 0: + return proposed_stake + + wallets = self.wallets + available = wallets.get_total(self.config["stake_currency"]) if wallets else max_stake + risk_amount = available * float(self.risk_per_unit.value) + stop_dist = float(self.stop_atr_mult.value) * atr + notional = risk_amount * current_rate / stop_dist + stake = notional / max(leverage, 1.0) + + # 单单元上限,避免低波动打满仓 + stake = min(stake, available * float(self.max_unit_stake_pct.value)) + + if min_stake is not None: + stake = max(stake, min_stake) + stake = min(stake, max_stake) + return stake + + def adjust_trade_position( + self, + trade: Trade, + current_time: datetime, + current_rate: float, + current_profit: float, + min_stake: Optional[float], + max_stake: float, + current_entry_rate: float, + current_exit_rate: float, + current_entry_profit: float, + current_exit_profit: float, + **kwargs, + ): + """每朝有利方向 0.5N 加仓,最多 4 单元;有挂单时不加。""" + if trade.has_open_orders: + return None + if trade.nr_of_successful_entries >= (1 + self.max_entry_position_adjustment): + return None + + dataframe, _ = self.dp.get_analyzed_dataframe(trade.pair, self.timeframe) + if dataframe.empty: + return None + last = dataframe.iloc[-1] + atr = float(last["atr"]) if pd.notna(last["atr"]) else 0.0 + if atr <= 0: + return None + + entry_n = trade.get_custom_data("entry_n") + if entry_n is None: + entry_n = atr + trade.set_custom_data("entry_n", entry_n) + + last_entry_price = trade.get_custom_data("last_entry_price") + if last_entry_price is None: + last_entry_price = trade.open_rate + trade.set_custom_data("last_entry_price", last_entry_price) + + # 已规划的下一单元序号(从第 2 单元起) + next_unit = trade.nr_of_successful_entries + 1 + step = float(self.pyramid_atr_mult.value) * float(entry_n) + # 相对首仓(或记录的单元锚定价)计算阈值,避免 after_fill 用均价漂移 + anchor = float(trade.get_custom_data("unit1_price") or trade.open_rate) + # 第 n 单元触发价 = 首仓 ± (n-1)*0.5N + offset = (next_unit - 1) * step + + if trade.is_short: + trigger = anchor - offset + if current_rate > trigger: + return None + else: + trigger = anchor + offset + if current_rate < trigger: + return None + + stake = self.custom_stake_amount( + pair=trade.pair, + current_time=current_time, + current_rate=current_rate, + proposed_stake=max_stake, + min_stake=min_stake, + max_stake=max_stake, + leverage=trade.leverage, + entry_tag=trade.enter_tag, + side="short" if trade.is_short else "long", + ) + if stake <= 0: + return None + + return stake, f"turtle_pyramid_{next_unit}" + + def custom_stoploss( + self, + pair: str, + trade: Trade, + current_time: datetime, + current_rate: float, + current_profit: float, + after_fill: bool, + **kwargs, + ) -> Optional[float]: + """ + 止损 = 最近一单元入场价 ± 2N。 + 加仓后整体移到新单元的 2N(海龟原版)。 + """ + dataframe, _ = self.dp.get_analyzed_dataframe(pair, self.timeframe) + if dataframe.empty: + return None + + last = dataframe.iloc[-1] + atr = float(last["atr"]) if pd.notna(last["atr"]) else 0.0 + + if after_fill: + filled = trade.nr_of_successful_entries + if filled <= 1: + trade.set_custom_data("unit1_price", current_rate) + trade.set_custom_data("last_entry_price", current_rate) + if atr > 0: + trade.set_custom_data("entry_n", atr) + else: + # 加仓:用本次成交价作为最新单元锚点 + trade.set_custom_data("last_entry_price", current_rate) + + entry_n = trade.get_custom_data("entry_n") + n = float(entry_n) if entry_n is not None else atr + if n <= 0: + return None + + last_entry = trade.get_custom_data("last_entry_price") or trade.open_rate + mult = float(self.stop_atr_mult.value) + + if trade.is_short: + stop_price = float(last_entry) + mult * n + else: + stop_price = float(last_entry) - mult * n + + sl = stoploss_from_absolute( + stop_price, current_rate, is_short=trade.is_short, leverage=trade.leverage + ) + # 0 表示止损已在价格不利侧之外,保持不变 + return sl if sl > 0 else None + + def confirm_trade_entry( + self, + pair: str, + order_type: str, + amount: float, + rate: float, + time_in_force: str, + current_time: datetime, + entry_tag: Optional[str], + side: str, + **kwargs, + ) -> bool: + dataframe, _ = self.dp.get_analyzed_dataframe(pair, self.timeframe) + if dataframe.empty: + return False + row = dataframe.iloc[-1] + if pd.isna(row["atr"]) or row["atr"] <= 0: + return False + if self.trade_side is not None and side != self.trade_side: + return False + if self.use_ema_filter: + if side == "long" and not bool(row["trend_long"]): + return False + if side == "short" and not bool(row["trend_short"]): + return False + if self.use_adx_filter and not bool(row["adx_ok"]): + return False + return True + + def leverage( + self, + pair: str, + current_time: datetime, + current_rate: float, + proposed_leverage: float, + max_leverage: float, + entry_tag: Optional[str], + side: str, + **kwargs, + ) -> float: + return min(self.lev, max_leverage) diff --git a/strategies/Wyckoff_BTC.py b/strategies/Wyckoff_BTC.py new file mode 100644 index 0000000..6b45a00 --- /dev/null +++ b/strategies/Wyckoff_BTC.py @@ -0,0 +1,368 @@ +# --- Do not remove these libs --- +""" +Wyckoff BTC V1.0 BASELINE — FROZEN + +Status: BASELINE FROZEN (live alias of V1_BASELINE) +Evidence: PASS (+ Limited Evidence, N=20) +Cost Adjusted: PASS (net PF 1.45 @ fee+slip 5bps) +Risk: small sample — 目标积累 N>=50 再谈规模 + +Branch A: Spring Reversal + 8h bias + 4h structure + 1h Spring/UTAD + Range disabled(regime_mode=trend) + ATR + 结构止损 + setup_type: SPRING / UTAD + +证据: user_data/Chan/scripts/wyckoff_v1_baseline_phase2.json +LPS 是独立 Setup 研究,禁止并入本文件调参。 +""" +from freqtrade.strategy import ( + IStrategy, IntParameter, DecimalParameter, CategoricalParameter, + merge_informative_pair, stoploss_from_open, stoploss_from_absolute, +) +from freqtrade.persistence import Trade +import talib.abstract as ta +from pandas import DataFrame +import pandas as pd +import numpy as np +from datetime import datetime +from typing import Optional +import logging + +logger = logging.getLogger(__name__) + +# freqtrade backtesting -c ./user_data/Chan/config/Wyckoff_BTC.json \ +# --strategy Wyckoff_BTC --strategy-path ./user_data/Chan/strategies --timerange=20230101- + + +class Wyckoff_BTC(IStrategy): + """Live alias of V1_BASELINE — 改规则请复制新文件,勿直接改 Baseline。""" + INTERFACE_VERSION = 3 + STRATEGY_VERSION = "V1.0_SPRING" + SETUP_FAMILY = "SPRING" + + timeframe = "1h" + structure_timeframe = "4h" + bias_timeframe: Optional[str] = "8h" + use_bias_filter = True + # trend = bull|bear only(Range disabled — 理论一致性约束,非调参) + regime_mode: str = "trend" + + can_short = True + process_only_new_candles = True + startup_candle_count = 220 + + minimal_roi = { + "0": 0.10, + "1440": 0.05, + "4320": 0.025, + "10080": 0, + } + stoploss = -0.10 + use_custom_stoploss = True + trailing_stop = True + trailing_stop_positive = 0.02 + trailing_stop_positive_offset = 0.04 + trailing_only_offset_is_reached = True + use_exit_signal = True + exit_profit_only = False + + # ---- 冻结默认值(optimize=False)---- + range_lookback = IntParameter(12, 48, default=24, space="buy", optimize=False) + spring_pierce_pct = DecimalParameter(0.001, 0.012, default=0.004, decimals=3, space="buy", optimize=False) + vol_spike_mult = DecimalParameter(1.1, 2.5, default=1.8, decimals=1, space="buy", optimize=False) + adx_min = IntParameter(10, 28, default=14, space="buy", optimize=False) + tr_pos_long_max = DecimalParameter(0.35, 0.55, default=0.45, decimals=2, space="buy", optimize=False) + tr_pos_short_min = DecimalParameter(0.45, 0.65, default=0.55, decimals=2, space="buy", optimize=False) + atr_sl_mult = DecimalParameter(1.2, 3.5, default=1.5, decimals=1, space="sell", optimize=False) + atr_sl_min = DecimalParameter(0.012, 0.04, default=0.018, decimals=3, space="sell", optimize=False) + atr_sl_max = DecimalParameter(0.05, 0.12, default=0.08, decimals=2, space="sell", optimize=False) + time_stop_hours = IntParameter(48, 240, default=120, space="sell", optimize=False) + + # Branch A:仅 Spring / UTAD + use_spring_sig = CategoricalParameter([True, False], default=True, space="buy", optimize=False) + use_utad_sig = CategoricalParameter([True, False], default=True, space="buy", optimize=False) + use_sos_sig = CategoricalParameter([True, False], default=False, space="buy", optimize=False) + use_sow_sig = CategoricalParameter([True, False], default=False, space="buy", optimize=False) + + lev = 1.0 + + def informative_pairs(self): + pairs = self.dp.current_whitelist() if self.dp else [] + tfs = {self.structure_timeframe} + if self.bias_timeframe and self.use_bias_filter: + tfs.add(self.bias_timeframe) + return [(pair, tf) for pair in pairs for tf in tfs] + + def _add_wyckoff_structure(self, df: DataFrame) -> DataFrame: + lb = int(self.range_lookback.value) + + df["atr"] = ta.ATR(df, timeperiod=14) + df["ema50"] = ta.EMA(df, timeperiod=50) + df["ema200"] = ta.EMA(df, timeperiod=200) + df["adx"] = ta.ADX(df, timeperiod=14) + df["rsi"] = ta.RSI(df, timeperiod=14) + df["volume_ma"] = ta.SMA(df, timeperiod=20, price="volume") + + df["tr_high"] = df["high"].rolling(lb).max() + df["tr_low"] = df["low"].rolling(lb).min() + df["tr_mid"] = (df["tr_high"] + df["tr_low"]) / 2.0 + df["tr_width"] = (df["tr_high"] - df["tr_low"]) / df["tr_mid"].replace(0, np.nan) + df["tr_width_ma"] = df["tr_width"].rolling(lb).mean() + + rng = (df["tr_high"] - df["tr_low"]).replace(0, np.nan) + df["tr_pos"] = (df["close"] - df["tr_low"]) / rng + + df["in_range"] = (df["tr_width"] < df["tr_width_ma"] * 1.35) & (df["adx"] < 28) + df["ema50_slope"] = df["ema50"] - df["ema50"].shift(8) + df["prior_down"] = df["ema50_slope"].shift(lb) < 0 + df["prior_up"] = df["ema50_slope"].shift(lb) > 0 + + down_bar = df["close"] < df["open"] + up_bar = df["close"] > df["open"] + vol_down = np.where(down_bar, df["volume"], np.nan) + vol_up = np.where(up_bar, df["volume"], np.nan) + df["vol_down_ma"] = pd.Series(vol_down, index=df.index).rolling(10, min_periods=3).mean() + df["vol_up_ma"] = pd.Series(vol_up, index=df.index).rolling(10, min_periods=3).mean() + df["effort_absorb"] = ( + df["vol_down_ma"].notna() + & df["vol_up_ma"].notna() + & (df["vol_up_ma"] > df["vol_down_ma"] * 1.05) + ) + + df["accum_ctx"] = ( + df["in_range"] + & (df["prior_down"] | (df["close"] < df["ema50"])) + & (df["tr_pos"] < float(self.tr_pos_long_max.value)) + ) + df["distrib_ctx"] = ( + df["in_range"] + & (df["prior_up"] | (df["close"] > df["ema50"])) + & (df["tr_pos"] > float(self.tr_pos_short_min.value)) + ) + df["bull_bias"] = (df["close"] > df["ema200"]) & (df["ema50"] > df["ema200"]) + df["bear_bias"] = (df["close"] < df["ema200"]) & (df["ema50"] < df["ema200"]) + df["vol_spike"] = df["volume"] > df["volume_ma"] * float(self.vol_spike_mult.value) + return df + + def _merge_tf(self, dataframe: DataFrame, pair: str, tf: str) -> DataFrame: + inf = self.dp.get_pair_dataframe(pair=pair, timeframe=tf) + inf = self._add_wyckoff_structure(inf) + keep = [ + "date", "atr", "ema50", "ema200", "adx", "rsi", + "tr_high", "tr_low", "tr_mid", "tr_width", "tr_pos", + "in_range", "accum_ctx", "distrib_ctx", + "vol_spike", "effort_absorb", "prior_down", "prior_up", + "bull_bias", "bear_bias", + ] + inf = inf[[c for c in keep if c in inf.columns]].copy() + return merge_informative_pair(dataframe, inf, self.timeframe, tf, ffill=True) + + def populate_indicators(self, dataframe: DataFrame, metadata: dict) -> DataFrame: + pair = metadata["pair"] + stf = self.structure_timeframe + dataframe = self._merge_tf(dataframe, pair, stf) + + btf = self.bias_timeframe + if btf and self.use_bias_filter and btf != stf: + dataframe = self._merge_tf(dataframe, pair, btf) + + ss = f"_{stf}" + dataframe["atr"] = ta.ATR(dataframe, timeperiod=14) + dataframe["ema21"] = ta.EMA(dataframe, timeperiod=21) + dataframe["ema50"] = ta.EMA(dataframe, timeperiod=50) + dataframe["rsi"] = ta.RSI(dataframe, timeperiod=14) + dataframe["volume_ma"] = ta.SMA(dataframe, timeperiod=20, price="volume") + dataframe["vol_ok"] = dataframe["volume"] > dataframe["volume_ma"] * float(self.vol_spike_mult.value) + + tr_high = dataframe[f"tr_high{ss}"] + tr_low = dataframe[f"tr_low{ss}"] + pierce = float(self.spring_pierce_pct.value) + + accum_soft = ( + dataframe[f"accum_ctx{ss}"].fillna(False).astype(bool) + | ( + dataframe[f"in_range{ss}"].fillna(False).astype(bool) + & dataframe[f"prior_down{ss}"].fillna(False).astype(bool) + & (dataframe[f"tr_pos{ss}"] < float(self.tr_pos_long_max.value)) + ) + ) + distrib_soft = ( + dataframe[f"distrib_ctx{ss}"].fillna(False).astype(bool) + | ( + dataframe[f"in_range{ss}"].fillna(False).astype(bool) + & dataframe[f"prior_up{ss}"].fillna(False).astype(bool) + & (dataframe[f"tr_pos{ss}"] > float(self.tr_pos_short_min.value)) + ) + ) + + if btf and self.use_bias_filter: + bs = f"_{btf}" if btf != stf else ss + if f"bear_bias{bs}" in dataframe.columns: + dataframe["bias_long_ok"] = ~dataframe[f"bear_bias{bs}"].fillna(False).astype(bool) + dataframe["bias_short_ok"] = ~dataframe[f"bull_bias{bs}"].fillna(False).astype(bool) + else: + dataframe["bias_long_ok"] = True + dataframe["bias_short_ok"] = True + else: + dataframe["bias_long_ok"] = True + dataframe["bias_short_ok"] = True + + vol_mild = dataframe["volume"] > dataframe["volume_ma"] * max(1.1, float(self.vol_spike_mult.value) * 0.85) + + dataframe["spring"] = ( + tr_low.notna() + & (dataframe["low"] < tr_low * (1.0 - pierce)) + & (dataframe["close"] > tr_low) + & (dataframe["close"] > dataframe["open"]) + & accum_soft + & vol_mild + & (dataframe["rsi"] < 58) + & dataframe["bias_long_ok"] + ) + dataframe["utad"] = ( + tr_high.notna() + & (dataframe["high"] > tr_high * (1.0 + pierce)) + & (dataframe["close"] < tr_high) + & (dataframe["close"] < dataframe["open"]) + & distrib_soft + & vol_mild + & (dataframe["rsi"] > 42) + & dataframe["bias_short_ok"] + ) + # 基线不进 SOS/SOW;保留列供 exit 参考 + dataframe["sos"] = False + dataframe["sow"] = False + + for col in ["spring", "utad", "sos", "sow", "vol_ok", "bias_long_ok", "bias_short_ok"]: + dataframe[col] = dataframe[col].fillna(False).astype(bool) + dataframe["setup_type"] = "" + dataframe.loc[dataframe["spring"], "setup_type"] = "SPRING_LONG" + dataframe.loc[dataframe["utad"], "setup_type"] = "UTAD_SHORT" + return dataframe + + def populate_entry_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame: + dataframe["enter_long"] = 0 + dataframe["enter_short"] = 0 + dataframe["enter_tag"] = "" + + vol_ok = dataframe["volume"] > 0 + + # 分开标签:禁止把 SPRING / UTAD 混成同一统计桶 + if bool(self.use_spring_sig.value): + cond = vol_ok & dataframe["spring"] + dataframe.loc[cond, ["enter_long", "enter_tag"]] = (1, "SPRING_LONG") + + if bool(self.use_utad_sig.value): + cond = vol_ok & dataframe["utad"] + dataframe.loc[cond, ["enter_short", "enter_tag"]] = (1, "UTAD_SHORT") + + self._apply_regime_filter(dataframe) + return dataframe + + def _apply_regime_filter(self, dataframe: DataFrame) -> None: + rm = getattr(self, "regime_mode", "all") + if rm == "all" or not self.bias_timeframe: + return + bs = f"_{self.bias_timeframe}" + bc, ec = f"bull_bias{bs}", f"bear_bias{bs}" + if bc not in dataframe.columns or ec not in dataframe.columns: + return + bull = dataframe[bc].fillna(False).astype(bool) + bear = dataframe[ec].fillna(False).astype(bool) + both = bull & bear + bull, bear = bull & ~both, bear & ~both + range_m = (~bull) & (~bear) + if rm == "bull": + mask = ~bull + elif rm == "bear": + mask = ~bear + elif rm == "range": + mask = ~range_m + elif rm == "trend": + mask = range_m # Range disabled + else: + return + dataframe.loc[mask, ["enter_long", "enter_short"]] = (0, 0) + dataframe.loc[mask, "enter_tag"] = "" + + def populate_exit_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame: + dataframe["exit_long"] = 0 + dataframe["exit_short"] = 0 + dataframe["exit_tag"] = "" + ss = f"_{self.structure_timeframe}" + + exit_long = dataframe["utad"] | ( + dataframe[f"distrib_ctx{ss}"].fillna(False).astype(bool) + & (dataframe["close"] < dataframe["ema21"]) + & (dataframe["rsi"] < 45) + ) + exit_short = dataframe["spring"] | ( + dataframe[f"accum_ctx{ss}"].fillna(False).astype(bool) + & (dataframe["close"] > dataframe["ema21"]) + & (dataframe["rsi"] > 55) + ) + dataframe.loc[exit_long, ["exit_long", "exit_tag"]] = (1, "wyckoff_phase_flip") + dataframe.loc[exit_short, ["exit_short", "exit_tag"]] = (1, "wyckoff_phase_flip") + return dataframe + + def custom_stoploss( + self, pair: str, trade: Trade, current_time: datetime, + current_rate: float, current_profit: float, after_fill: bool, **kwargs, + ) -> Optional[float]: + dataframe, _ = self.dp.get_analyzed_dataframe(pair, self.timeframe) + if dataframe.empty: + return None + last = dataframe.iloc[-1] + atr = float(last["atr"]) if pd.notna(last["atr"]) else 0.0 + if atr <= 0 or trade.open_rate <= 0: + return None + + atr_dist = float(self.atr_sl_mult.value) * atr + tag = trade.enter_tag or "" + buffer = atr * 0.15 + + if after_fill and trade.get_custom_data("struct_stop") is None: + if trade.is_short: + trade.set_custom_data("struct_stop", float(last["high"]) + buffer) + else: + trade.set_custom_data("struct_stop", float(last["low"]) - buffer) + + struct = trade.get_custom_data("struct_stop") + if trade.is_short: + atr_stop = trade.open_rate + atr_dist + stop_price = min(atr_stop, float(struct)) if struct is not None else atr_stop + else: + atr_stop = trade.open_rate - atr_dist + stop_price = max(atr_stop, float(struct)) if struct is not None else atr_stop + + raw = abs(trade.open_rate - stop_price) / trade.open_rate + raw = min(max(raw, float(self.atr_sl_min.value)), float(self.atr_sl_max.value)) + if struct is not None and tag in ( + "SPRING_LONG", "UTAD_SHORT", "SPRING", "UTAD", "wyckoff_spring", "wyckoff_utad", + ): + sl = stoploss_from_absolute( + stop_price, current_rate, is_short=trade.is_short, leverage=trade.leverage + ) + return sl if sl and sl > 0 else None + return stoploss_from_open( + -raw, current_profit, is_short=trade.is_short, leverage=trade.leverage + ) or None + + def custom_exit( + self, pair: str, trade: Trade, current_time: datetime, + current_rate: float, current_profit: float, **kwargs, + ) -> Optional[str]: + hours = (current_time - trade.open_date_utc).total_seconds() / 3600 + if hours > float(self.time_stop_hours.value) and current_profit < 0: + return "wyckoff_time_stop" + if hours > float(self.time_stop_hours.value) * 2: + return "wyckoff_time_stop_max" + return None + + def leverage( + self, pair: str, current_time: datetime, current_rate: float, + proposed_leverage: float, max_leverage: float, entry_tag: Optional[str], + side: str, **kwargs, + ) -> float: + return min(self.lev, max_leverage) diff --git a/strategies/Wyckoff_BTC_GATED.py b/strategies/Wyckoff_BTC_GATED.py new file mode 100644 index 0000000..3df6cfb --- /dev/null +++ b/strategies/Wyckoff_BTC_GATED.py @@ -0,0 +1,197 @@ +# --- Do not remove these libs --- +""" +Wyckoff BTC — Market-State Gated Spring(Decision Layer) + +Spring = V1_BASELINE(FROZEN) +Gate v1.1 = LOCKED default Decision rule: + market_state in {accumulation, markup} -> allow Spring + else -> block + +Soft-score 不进默认规则。勿改 Spring;勿全样本扫 Gate。 +""" +from __future__ import annotations + +import json +import logging +import sys +from pathlib import Path + +from pandas import DataFrame +import pandas as pd + +_CHAN = Path(__file__).resolve().parents[1] +if str(_CHAN) not in sys.path: + sys.path.insert(0, str(_CHAN)) + +from engine.market_state import apply_decision_gate, compute_market_state_8h # noqa: E402 +from freqtrade.strategy import merge_informative_pair # noqa: E402 + +from Wyckoff_BTC_V1_BASELINE import Wyckoff_BTC_V1_BASELINE # noqa: E402 + +logger = logging.getLogger(__name__) + + +class Wyckoff_BTC_GATED(Wyckoff_BTC_V1_BASELINE): + """Baseline Spring + causal Market State Gate。""" + + STRATEGY_VERSION = "GATED_V1_1_LOCKED" + SETUP_FAMILY = "SPRING_GATED" + + # LOCKED default — 研究脚本可临时改写,跑完必须恢复 + gate_mode: str = "state_set" + gate_q_sum: float = 100.0 + gate_q_bad: float = 55.0 + decision_log_enabled: bool = True + decision_log_path: str = str(_CHAN / "logs" / "wyckoff_decision_events.jsonl") + + def populate_indicators(self, dataframe: DataFrame, metadata: dict) -> DataFrame: + dataframe = super().populate_indicators(dataframe, metadata) + pair = metadata["pair"] + btf = self.bias_timeframe or "8h" + + raw8 = self.dp.get_pair_dataframe(pair=pair, timeframe=btf) + st8 = compute_market_state_8h(raw8) + # 覆盖默认门闩为当前 class 配置(可能已被脚本锁定) + st8 = apply_decision_gate( + st8, + mode=str(self.gate_mode), + q_sum=float(self.gate_q_sum), + q_bad=float(self.gate_q_bad), + ) + keep = [ + "date", + "accumulation_score", + "markup_score", + "distribution_score", + "markdown_score", + "range_score", + "market_state", + "allow_spring", + "allow_utad", + "ema_slope", + "dist_ema200", + ] + st8 = st8[[c for c in keep if c in st8.columns]].copy() + dataframe = merge_informative_pair(dataframe, st8, self.timeframe, btf, ffill=True) + return dataframe + + def populate_entry_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame: + dataframe = super().populate_entry_trend(dataframe, metadata) + + bs = f"_{self.bias_timeframe or '8h'}" + allow_s = dataframe.get(f"allow_spring{bs}") + allow_u = dataframe.get(f"allow_utad{bs}") + if allow_s is None or allow_u is None: + return dataframe + + allow_s = allow_s.fillna(False).astype(bool) + allow_u = allow_u.fillna(False).astype(bool) + + block_long = (dataframe["enter_long"] == 1) & (~allow_s) + block_short = (dataframe["enter_short"] == 1) & (~allow_u) + self._log_decision_events(dataframe, metadata, allow_s, allow_u, bs) + dataframe.loc[block_long, ["enter_long", "enter_tag"]] = (0, "") + dataframe.loc[block_short, ["enter_short", "enter_tag"]] = (0, "") + return dataframe + + def _decision_log_active(self) -> bool: + if not bool(getattr(self, "decision_log_enabled", True)): + return False + config = getattr(self, "config", {}) or {} + runmode = config.get("runmode") + runmode_value = getattr(runmode, "value", str(runmode) if runmode is not None else "") + if runmode_value: + return runmode_value == "dry_run" + return bool(config.get("dry_run", False)) + + def _log_decision_events( + self, + dataframe: DataFrame, + metadata: dict, + allow_s: pd.Series, + allow_u: pd.Series, + bias_suffix: str, + ) -> None: + if not self._decision_log_active(): + return + + pair = metadata.get("pair", "") + long_candidates = dataframe["enter_long"] == 1 + short_candidates = dataframe["enter_short"] == 1 + if not bool(long_candidates.any() or short_candidates.any()): + return + + seen = getattr(self, "_decision_log_seen", None) + if seen is None: + seen = set() + self._decision_log_seen = seen + + events = [] + for idx in dataframe.index[long_candidates]: + events.append(self._decision_event(dataframe.loc[idx], pair, "SPRING_LONG", bool(allow_s.loc[idx]), bias_suffix)) + for idx in dataframe.index[short_candidates]: + events.append(self._decision_event(dataframe.loc[idx], pair, "UTAD_SHORT", bool(allow_u.loc[idx]), bias_suffix)) + + path = Path(str(getattr(self, "decision_log_path", ""))).expanduser() + try: + path.parent.mkdir(parents=True, exist_ok=True) + with path.open("a", encoding="utf-8") as handle: + for event in events: + key = ( + event["timestamp"], + event["pair"], + event["signal_type"], + event["gate_version"], + ) + if key in seen: + continue + seen.add(key) + handle.write(json.dumps(event, ensure_ascii=False, sort_keys=True) + "\n") + except OSError as exc: + logger.warning("Decision log write failed: %s", exc) + + def _decision_event(self, row: pd.Series, pair: str, signal_type: str, allow: bool, bias_suffix: str) -> dict: + state_col = f"market_state{bias_suffix}" + bias_time_col = f"date{bias_suffix}" + state = self._json_value(row.get(state_col)) + bias_bar_time = self._json_value(row.get(bias_time_col)) + state_missing = state in (None, "", "missing") + block_reason = "" if allow else ("state_missing" if state_missing else "not_in_allow_set") + + event = { + "timestamp": self._json_value(row.get("date")), + "pair": pair, + "signal_type": signal_type, + "market_state": state if not state_missing else "missing", + "allow": bool(allow), + "gate_version": self.STRATEGY_VERSION, + "baseline_signal": signal_type, + "block_reason": block_reason, + "bias_bar_time": bias_bar_time, + "would_enter": True, + "order_sent": bool(allow), + } + for score in [ + "accumulation_score", + "markup_score", + "distribution_score", + "markdown_score", + "range_score", + ]: + event[score] = self._json_value(row.get(f"{score}{bias_suffix}")) + return event + + @staticmethod + def _json_value(value): + if value is None: + return None + try: + if pd.isna(value): + return None + except (TypeError, ValueError): + pass + if hasattr(value, "isoformat"): + return value.isoformat() + if hasattr(value, "item"): + return value.item() + return value diff --git a/strategies/Wyckoff_BTC_LPS.py b/strategies/Wyckoff_BTC_LPS.py new file mode 100644 index 0000000..1992b77 --- /dev/null +++ b/strategies/Wyckoff_BTC_LPS.py @@ -0,0 +1,42 @@ +# --- Do not remove these libs --- +""" +ARCHIVED — LPS 研究分支已冻结,禁止用于 dry-run / 生产。 + +见: + user_data/Chan/research/SYSTEM_STATUS.md + user_data/Chan/research/lps_v1_failed/REJECT.md + user_data/Chan/research/lps_v1_1_failed/REJECT.md + user_data/Chan/research/lps_v2_failed/REJECT.md + user_data/Chan/research/lps_v2_failed/Wyckoff_BTC_LPS_V2.py + +Baseline: Wyckoff_BTC_V1_BASELINE(Spring-only) +""" +from freqtrade.strategy import IStrategy +from pandas import DataFrame + + +class Wyckoff_BTC_LPS(IStrategy): + """Stub: LPS archived. Use Wyckoff_BTC_V1_BASELINE.""" + INTERFACE_VERSION = 3 + STRATEGY_VERSION = "ARCHIVED" + timeframe = "1h" + can_short = True + startup_candle_count = 20 + minimal_roi = {"0": 1} + stoploss = -0.99 + + def populate_indicators(self, dataframe: DataFrame, metadata: dict) -> DataFrame: + raise RuntimeError( + "LPS research archived (V1/V1.1/V2 all REJECTED). " + "Use Wyckoff_BTC_V1_BASELINE. See user_data/Chan/research/" + ) + + def populate_entry_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame: + dataframe["enter_long"] = 0 + dataframe["enter_short"] = 0 + return dataframe + + def populate_exit_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame: + dataframe["exit_long"] = 0 + dataframe["exit_short"] = 0 + return dataframe diff --git a/strategies/Wyckoff_BTC_V1_BASELINE.py b/strategies/Wyckoff_BTC_V1_BASELINE.py new file mode 100644 index 0000000..c4091ed --- /dev/null +++ b/strategies/Wyckoff_BTC_V1_BASELINE.py @@ -0,0 +1,368 @@ +# --- Do not remove these libs --- +""" +Wyckoff BTC V1.0 BASELINE — FROZEN + +Status: BASELINE FROZEN +Evidence: PASS (+ Limited Evidence, N=20) +Cost Adjusted: PASS (net PF 1.45 @ fee+slip 5bps) +Risk: small sample — 目标积累 N>=50 再谈规模 + +Branch A: Spring Reversal + 8h bias + 4h structure + 1h Spring/UTAD + Range disabled(regime_mode=trend) + ATR + 结构止损 + setup_type: SPRING / UTAD + +证据: user_data/Chan/scripts/wyckoff_v1_baseline_phase2.json +LPS 是独立 Setup 研究,禁止并入本文件调参。 +""" +from freqtrade.strategy import ( + IStrategy, IntParameter, DecimalParameter, CategoricalParameter, + merge_informative_pair, stoploss_from_open, stoploss_from_absolute, +) +from freqtrade.persistence import Trade +import talib.abstract as ta +from pandas import DataFrame +import pandas as pd +import numpy as np +from datetime import datetime +from typing import Optional +import logging + +logger = logging.getLogger(__name__) + +# freqtrade backtesting -c ./user_data/Chan/config/Wyckoff_BTC_V1_BASELINE.json \ +# --strategy Wyckoff_BTC_V1_BASELINE --strategy-path ./user_data/Chan/strategies --timerange=20230101- + + +class Wyckoff_BTC_V1_BASELINE(IStrategy): + """冻结基线:Spring 反转。禁止继续调参;对比实验请用独立分支。""" + INTERFACE_VERSION = 3 + STRATEGY_VERSION = "V1.0_BASELINE" + SETUP_FAMILY = "SPRING" + + timeframe = "1h" + structure_timeframe = "4h" + bias_timeframe: Optional[str] = "8h" + use_bias_filter = True + # trend = bull|bear only(Range disabled — 理论一致性约束,非调参) + regime_mode: str = "trend" + + can_short = True + process_only_new_candles = True + startup_candle_count = 220 + + minimal_roi = { + "0": 0.10, + "1440": 0.05, + "4320": 0.025, + "10080": 0, + } + stoploss = -0.10 + use_custom_stoploss = True + trailing_stop = True + trailing_stop_positive = 0.02 + trailing_stop_positive_offset = 0.04 + trailing_only_offset_is_reached = True + use_exit_signal = True + exit_profit_only = False + + # ---- 冻结默认值(optimize=False)---- + range_lookback = IntParameter(12, 48, default=24, space="buy", optimize=False) + spring_pierce_pct = DecimalParameter(0.001, 0.012, default=0.004, decimals=3, space="buy", optimize=False) + vol_spike_mult = DecimalParameter(1.1, 2.5, default=1.8, decimals=1, space="buy", optimize=False) + adx_min = IntParameter(10, 28, default=14, space="buy", optimize=False) + tr_pos_long_max = DecimalParameter(0.35, 0.55, default=0.45, decimals=2, space="buy", optimize=False) + tr_pos_short_min = DecimalParameter(0.45, 0.65, default=0.55, decimals=2, space="buy", optimize=False) + atr_sl_mult = DecimalParameter(1.2, 3.5, default=1.5, decimals=1, space="sell", optimize=False) + atr_sl_min = DecimalParameter(0.012, 0.04, default=0.018, decimals=3, space="sell", optimize=False) + atr_sl_max = DecimalParameter(0.05, 0.12, default=0.08, decimals=2, space="sell", optimize=False) + time_stop_hours = IntParameter(48, 240, default=120, space="sell", optimize=False) + + # Branch A:仅 Spring / UTAD + use_spring_sig = CategoricalParameter([True, False], default=True, space="buy", optimize=False) + use_utad_sig = CategoricalParameter([True, False], default=True, space="buy", optimize=False) + use_sos_sig = CategoricalParameter([True, False], default=False, space="buy", optimize=False) + use_sow_sig = CategoricalParameter([True, False], default=False, space="buy", optimize=False) + + lev = 1.0 + + def informative_pairs(self): + pairs = self.dp.current_whitelist() if self.dp else [] + tfs = {self.structure_timeframe} + if self.bias_timeframe and self.use_bias_filter: + tfs.add(self.bias_timeframe) + return [(pair, tf) for pair in pairs for tf in tfs] + + def _add_wyckoff_structure(self, df: DataFrame) -> DataFrame: + lb = int(self.range_lookback.value) + + df["atr"] = ta.ATR(df, timeperiod=14) + df["ema50"] = ta.EMA(df, timeperiod=50) + df["ema200"] = ta.EMA(df, timeperiod=200) + df["adx"] = ta.ADX(df, timeperiod=14) + df["rsi"] = ta.RSI(df, timeperiod=14) + df["volume_ma"] = ta.SMA(df, timeperiod=20, price="volume") + + df["tr_high"] = df["high"].rolling(lb).max() + df["tr_low"] = df["low"].rolling(lb).min() + df["tr_mid"] = (df["tr_high"] + df["tr_low"]) / 2.0 + df["tr_width"] = (df["tr_high"] - df["tr_low"]) / df["tr_mid"].replace(0, np.nan) + df["tr_width_ma"] = df["tr_width"].rolling(lb).mean() + + rng = (df["tr_high"] - df["tr_low"]).replace(0, np.nan) + df["tr_pos"] = (df["close"] - df["tr_low"]) / rng + + df["in_range"] = (df["tr_width"] < df["tr_width_ma"] * 1.35) & (df["adx"] < 28) + df["ema50_slope"] = df["ema50"] - df["ema50"].shift(8) + df["prior_down"] = df["ema50_slope"].shift(lb) < 0 + df["prior_up"] = df["ema50_slope"].shift(lb) > 0 + + down_bar = df["close"] < df["open"] + up_bar = df["close"] > df["open"] + vol_down = np.where(down_bar, df["volume"], np.nan) + vol_up = np.where(up_bar, df["volume"], np.nan) + df["vol_down_ma"] = pd.Series(vol_down, index=df.index).rolling(10, min_periods=3).mean() + df["vol_up_ma"] = pd.Series(vol_up, index=df.index).rolling(10, min_periods=3).mean() + df["effort_absorb"] = ( + df["vol_down_ma"].notna() + & df["vol_up_ma"].notna() + & (df["vol_up_ma"] > df["vol_down_ma"] * 1.05) + ) + + df["accum_ctx"] = ( + df["in_range"] + & (df["prior_down"] | (df["close"] < df["ema50"])) + & (df["tr_pos"] < float(self.tr_pos_long_max.value)) + ) + df["distrib_ctx"] = ( + df["in_range"] + & (df["prior_up"] | (df["close"] > df["ema50"])) + & (df["tr_pos"] > float(self.tr_pos_short_min.value)) + ) + df["bull_bias"] = (df["close"] > df["ema200"]) & (df["ema50"] > df["ema200"]) + df["bear_bias"] = (df["close"] < df["ema200"]) & (df["ema50"] < df["ema200"]) + df["vol_spike"] = df["volume"] > df["volume_ma"] * float(self.vol_spike_mult.value) + return df + + def _merge_tf(self, dataframe: DataFrame, pair: str, tf: str) -> DataFrame: + inf = self.dp.get_pair_dataframe(pair=pair, timeframe=tf) + inf = self._add_wyckoff_structure(inf) + keep = [ + "date", "atr", "ema50", "ema200", "adx", "rsi", + "tr_high", "tr_low", "tr_mid", "tr_width", "tr_pos", + "in_range", "accum_ctx", "distrib_ctx", + "vol_spike", "effort_absorb", "prior_down", "prior_up", + "bull_bias", "bear_bias", + ] + inf = inf[[c for c in keep if c in inf.columns]].copy() + return merge_informative_pair(dataframe, inf, self.timeframe, tf, ffill=True) + + def populate_indicators(self, dataframe: DataFrame, metadata: dict) -> DataFrame: + pair = metadata["pair"] + stf = self.structure_timeframe + dataframe = self._merge_tf(dataframe, pair, stf) + + btf = self.bias_timeframe + if btf and self.use_bias_filter and btf != stf: + dataframe = self._merge_tf(dataframe, pair, btf) + + ss = f"_{stf}" + dataframe["atr"] = ta.ATR(dataframe, timeperiod=14) + dataframe["ema21"] = ta.EMA(dataframe, timeperiod=21) + dataframe["ema50"] = ta.EMA(dataframe, timeperiod=50) + dataframe["rsi"] = ta.RSI(dataframe, timeperiod=14) + dataframe["volume_ma"] = ta.SMA(dataframe, timeperiod=20, price="volume") + dataframe["vol_ok"] = dataframe["volume"] > dataframe["volume_ma"] * float(self.vol_spike_mult.value) + + tr_high = dataframe[f"tr_high{ss}"] + tr_low = dataframe[f"tr_low{ss}"] + pierce = float(self.spring_pierce_pct.value) + + accum_soft = ( + dataframe[f"accum_ctx{ss}"].fillna(False).astype(bool) + | ( + dataframe[f"in_range{ss}"].fillna(False).astype(bool) + & dataframe[f"prior_down{ss}"].fillna(False).astype(bool) + & (dataframe[f"tr_pos{ss}"] < float(self.tr_pos_long_max.value)) + ) + ) + distrib_soft = ( + dataframe[f"distrib_ctx{ss}"].fillna(False).astype(bool) + | ( + dataframe[f"in_range{ss}"].fillna(False).astype(bool) + & dataframe[f"prior_up{ss}"].fillna(False).astype(bool) + & (dataframe[f"tr_pos{ss}"] > float(self.tr_pos_short_min.value)) + ) + ) + + if btf and self.use_bias_filter: + bs = f"_{btf}" if btf != stf else ss + if f"bear_bias{bs}" in dataframe.columns: + dataframe["bias_long_ok"] = ~dataframe[f"bear_bias{bs}"].fillna(False).astype(bool) + dataframe["bias_short_ok"] = ~dataframe[f"bull_bias{bs}"].fillna(False).astype(bool) + else: + dataframe["bias_long_ok"] = True + dataframe["bias_short_ok"] = True + else: + dataframe["bias_long_ok"] = True + dataframe["bias_short_ok"] = True + + vol_mild = dataframe["volume"] > dataframe["volume_ma"] * max(1.1, float(self.vol_spike_mult.value) * 0.85) + + dataframe["spring"] = ( + tr_low.notna() + & (dataframe["low"] < tr_low * (1.0 - pierce)) + & (dataframe["close"] > tr_low) + & (dataframe["close"] > dataframe["open"]) + & accum_soft + & vol_mild + & (dataframe["rsi"] < 58) + & dataframe["bias_long_ok"] + ) + dataframe["utad"] = ( + tr_high.notna() + & (dataframe["high"] > tr_high * (1.0 + pierce)) + & (dataframe["close"] < tr_high) + & (dataframe["close"] < dataframe["open"]) + & distrib_soft + & vol_mild + & (dataframe["rsi"] > 42) + & dataframe["bias_short_ok"] + ) + # 基线不进 SOS/SOW;保留列供 exit 参考 + dataframe["sos"] = False + dataframe["sow"] = False + + for col in ["spring", "utad", "sos", "sow", "vol_ok", "bias_long_ok", "bias_short_ok"]: + dataframe[col] = dataframe[col].fillna(False).astype(bool) + dataframe["setup_type"] = "" + dataframe.loc[dataframe["spring"], "setup_type"] = "SPRING_LONG" + dataframe.loc[dataframe["utad"], "setup_type"] = "UTAD_SHORT" + return dataframe + + def populate_entry_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame: + dataframe["enter_long"] = 0 + dataframe["enter_short"] = 0 + dataframe["enter_tag"] = "" + + vol_ok = dataframe["volume"] > 0 + + # 分开标签:禁止把 SPRING / UTAD 混成同一统计桶 + if bool(self.use_spring_sig.value): + cond = vol_ok & dataframe["spring"] + dataframe.loc[cond, ["enter_long", "enter_tag"]] = (1, "SPRING_LONG") + + if bool(self.use_utad_sig.value): + cond = vol_ok & dataframe["utad"] + dataframe.loc[cond, ["enter_short", "enter_tag"]] = (1, "UTAD_SHORT") + + self._apply_regime_filter(dataframe) + return dataframe + + def _apply_regime_filter(self, dataframe: DataFrame) -> None: + rm = getattr(self, "regime_mode", "all") + if rm == "all" or not self.bias_timeframe: + return + bs = f"_{self.bias_timeframe}" + bc, ec = f"bull_bias{bs}", f"bear_bias{bs}" + if bc not in dataframe.columns or ec not in dataframe.columns: + return + bull = dataframe[bc].fillna(False).astype(bool) + bear = dataframe[ec].fillna(False).astype(bool) + both = bull & bear + bull, bear = bull & ~both, bear & ~both + range_m = (~bull) & (~bear) + if rm == "bull": + mask = ~bull + elif rm == "bear": + mask = ~bear + elif rm == "range": + mask = ~range_m + elif rm == "trend": + mask = range_m # Range disabled + else: + return + dataframe.loc[mask, ["enter_long", "enter_short"]] = (0, 0) + dataframe.loc[mask, "enter_tag"] = "" + + def populate_exit_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame: + dataframe["exit_long"] = 0 + dataframe["exit_short"] = 0 + dataframe["exit_tag"] = "" + ss = f"_{self.structure_timeframe}" + + exit_long = dataframe["utad"] | ( + dataframe[f"distrib_ctx{ss}"].fillna(False).astype(bool) + & (dataframe["close"] < dataframe["ema21"]) + & (dataframe["rsi"] < 45) + ) + exit_short = dataframe["spring"] | ( + dataframe[f"accum_ctx{ss}"].fillna(False).astype(bool) + & (dataframe["close"] > dataframe["ema21"]) + & (dataframe["rsi"] > 55) + ) + dataframe.loc[exit_long, ["exit_long", "exit_tag"]] = (1, "wyckoff_phase_flip") + dataframe.loc[exit_short, ["exit_short", "exit_tag"]] = (1, "wyckoff_phase_flip") + return dataframe + + def custom_stoploss( + self, pair: str, trade: Trade, current_time: datetime, + current_rate: float, current_profit: float, after_fill: bool, **kwargs, + ) -> Optional[float]: + dataframe, _ = self.dp.get_analyzed_dataframe(pair, self.timeframe) + if dataframe.empty: + return None + last = dataframe.iloc[-1] + atr = float(last["atr"]) if pd.notna(last["atr"]) else 0.0 + if atr <= 0 or trade.open_rate <= 0: + return None + + atr_dist = float(self.atr_sl_mult.value) * atr + tag = trade.enter_tag or "" + buffer = atr * 0.15 + + if after_fill and trade.get_custom_data("struct_stop") is None: + if trade.is_short: + trade.set_custom_data("struct_stop", float(last["high"]) + buffer) + else: + trade.set_custom_data("struct_stop", float(last["low"]) - buffer) + + struct = trade.get_custom_data("struct_stop") + if trade.is_short: + atr_stop = trade.open_rate + atr_dist + stop_price = min(atr_stop, float(struct)) if struct is not None else atr_stop + else: + atr_stop = trade.open_rate - atr_dist + stop_price = max(atr_stop, float(struct)) if struct is not None else atr_stop + + raw = abs(trade.open_rate - stop_price) / trade.open_rate + raw = min(max(raw, float(self.atr_sl_min.value)), float(self.atr_sl_max.value)) + if struct is not None and tag in ( + "SPRING_LONG", "UTAD_SHORT", "SPRING", "UTAD", "wyckoff_spring", "wyckoff_utad", + ): + sl = stoploss_from_absolute( + stop_price, current_rate, is_short=trade.is_short, leverage=trade.leverage + ) + return sl if sl and sl > 0 else None + return stoploss_from_open( + -raw, current_profit, is_short=trade.is_short, leverage=trade.leverage + ) or None + + def custom_exit( + self, pair: str, trade: Trade, current_time: datetime, + current_rate: float, current_profit: float, **kwargs, + ) -> Optional[str]: + hours = (current_time - trade.open_date_utc).total_seconds() / 3600 + if hours > float(self.time_stop_hours.value) and current_profit < 0: + return "wyckoff_time_stop" + if hours > float(self.time_stop_hours.value) * 2: + return "wyckoff_time_stop_max" + return None + + def leverage( + self, pair: str, current_time: datetime, current_rate: float, + proposed_leverage: float, max_leverage: float, entry_tag: Optional[str], + side: str, **kwargs, + ) -> float: + return min(self.lev, max_leverage) diff --git a/strategies/analyze_maker_edge.py b/strategies/analyze_maker_edge.py new file mode 100644 index 0000000..cb339bf --- /dev/null +++ b/strategies/analyze_maker_edge.py @@ -0,0 +1,321 @@ +#!/usr/bin/env python3 +""" +Maker Edge Report v0.1 + +章节: +1. Fill Quality +2. Adverse Selection +3. MAE/MFE (Price + Time) +4. State Attribution +5. Spread Capture / Quote Lifecycle + +假设: + H1: P(ret_30s 有利) > 50% + H2: restore exit 优于 all fills + H3: 亏损集中在某类状态 → 应撤单而非止损 + +用法: + python user_data/Chan/strategies/analyze_maker_edge.py + python user_data/Chan/strategies/analyze_maker_edge.py --report + python user_data/Chan/strategies/analyze_maker_edge.py --min-fills 500 +""" + +from __future__ import annotations + +import argparse +import json +from pathlib import Path + +import numpy as np +import pandas as pd + + +def load_events(log_dir: Path) -> pd.DataFrame: + rows = [] + files = sorted(log_dir.glob("*.jsonl")) + if not files: + raise FileNotFoundError(f"No jsonl in {log_dir}") + for f in files: + for line in f.read_text(encoding="utf-8").splitlines(): + line = line.strip() + if not line: + continue + rows.append(json.loads(line)) + return pd.DataFrame(rows) + + +def _fav_ret(side: pd.Series, fill: pd.Series, px: pd.Series) -> pd.Series: + """多头:价格涨为正;空头:价格跌为正。""" + raw = (px - fill) / fill + return np.where(side == "long", raw, -raw) + + +def report(df: pd.DataFrame, min_fills: int = 500, out_path: Path | None = None) -> None: + fills = df[df["event"] == "fill"].copy() if "event" in df.columns else pd.DataFrame() + paths = df[df["event"] == "fill_path"].copy() if "event" in df.columns else pd.DataFrame() + created = df[df["event"] == "quote_created"].copy() if "event" in df.columns else pd.DataFrame() + canceled = df[df["event"] == "quote_canceled"].copy() if "event" in df.columns else pd.DataFrame() + qfilled = df[df["event"] == "quote_filled"].copy() if "event" in df.columns else pd.DataFrame() + exits = df[df["event"] == "fill_exit"].copy() if "event" in df.columns else pd.DataFrame() + + lines: list[str] = [] + + def p(s: str = ""): + lines.append(s) + print(s) + + p("=" * 72) + p("Maker Edge Report v0.1") + p("=" * 72) + p(f"quote_created : {len(created)}") + p(f"quote_canceled: {len(canceled)}") + p(f"quote_filled : {len(qfilled)}") + p(f"fills : {len(fills)}") + p(f"fill_paths : {len(paths)} (需成交后≥5m)") + p(f"target fills : ≥{min_fills} [{'OK' if len(fills) >= min_fills else 'COLLECTING'}]") + + if fills.empty: + p("\n尚无 fill。先跑 Dry-run 探针。") + return + + # merge exit_reason onto paths + if not exits.empty and not paths.empty and "fill_id" in exits.columns: + er = exits.drop_duplicates("fill_id").set_index("fill_id")["exit_reason"] + if "exit_reason" not in paths.columns or paths["exit_reason"].isna().all(): + paths = paths.merge(er.rename("exit_reason_x"), left_on="fill_id", right_index=True, how="left") + if "exit_reason" not in paths.columns: + paths["exit_reason"] = paths.get("exit_reason_x") + else: + paths["exit_reason"] = paths["exit_reason"].fillna(paths.get("exit_reason_x")) + + # merge fill meta into paths + if not paths.empty: + cols = [ + c + for c in [ + "side", + "fill_price", + "fill_reason", + "time_to_fill", + "trend_state", + "volatility_regime", + "pre_5s_deteriorated", + "obi", + "trade_imbalance", + "spread", + "entry_tag", + ] + if c in fills.columns + ] + if cols and "fill_id" in fills.columns: + meta = fills.drop_duplicates("fill_id")[["fill_id"] + cols] + paths = paths.merge(meta, on="fill_id", how="left", suffixes=("", "_f")) + + # -------------------- 1. Fill Quality -------------------- + p("\n" + "-" * 72) + p("1. Fill Quality") + p("-" * 72) + if "time_to_fill" in fills.columns: + ttf = fills["time_to_fill"].dropna() + if len(ttf): + p( + f"time_to_fill mean={ttf.mean():.1f}s median={ttf.median():.1f}s " + f"p90={ttf.quantile(0.9):.1f}s" + ) + fast = fills[fills["time_to_fill"].fillna(1e9) <= 10] + slow = fills[fills["time_to_fill"].fillna(0) > 30] + p(f"fast fills (≤10s): {len(fast)} slow fills (>30s): {len(slow)}") + if "fill_reason" in fills.columns: + p("fill_reason: " + str(fills["fill_reason"].value_counts().to_dict())) + if "pre_5s_deteriorated" in fills.columns: + det = fills["pre_5s_deteriorated"].fillna(False).astype(bool) + p(f"pre_5s book deteriorated: {det.mean()*100:.1f}% of fills") + + n_created = max(len(created), 1) + p(f"fill rate (filled/created): {len(qfilled)/n_created*100:.1f}%") + if len(canceled): + p(f"cancel rate: {len(canceled)/n_created*100:.1f}%") + + # -------------------- 2. Adverse Selection -------------------- + p("\n" + "-" * 72) + p("2. Adverse Selection (fill 后收益分布)") + p("-" * 72) + if paths.empty: + p("等待 fill_path 完成(成交后 ≥5 分钟)…") + else: + side = paths["side"] if "side" in paths.columns else paths.get("side_f") + fp = paths["fill_price"] + for label, col in [ + ("10s", "after_10s_price"), + ("30s", "after_30s_price"), + ("1m", "after_1m_price"), + ("5m", "after_5m_price"), + ]: + if col not in paths.columns: + continue + fav = pd.Series(_fav_ret(side, fp, paths[col]), index=paths.index) + p( + f" +{label:3s} mean={fav.mean()*100:+.4f}% " + f"median={fav.median()*100:+.4f}% " + f"P(fav)={ (fav>0).mean()*100:.1f}% n={fav.notna().sum()}" + ) + # toxic: 10s 立刻不利 + if "after_10s_price" in paths.columns: + fav10 = pd.Series(_fav_ret(side, fp, paths["after_10s_price"]), index=paths.index) + p(f" toxic@10s (fav<0): { (fav10<0).mean()*100:.1f}% → 接毒比例") + + # -------------------- 3. MAE / MFE -------------------- + p("\n" + "-" * 72) + p("3. MAE / MFE (Price + Time)") + p("-" * 72) + if not paths.empty: + if "price_mae" in paths.columns: + p( + f"Price MAE mean={paths['price_mae'].mean():+.2f} " + f"Price MFE mean={paths['price_mfe'].mean():+.2f}" + ) + for h in ["10s", "30s", "1m", "5m"]: + mae_c, mfe_c = f"mae_{h}", f"mfe_{h}" + if mae_c in paths.columns and mfe_c in paths.columns: + p( + f" Time@{h:3s} MAE={paths[mae_c].mean()*100:+.4f}% " + f"MFE={paths[mfe_c].mean()*100:+.4f}%" + ) + if "mae_5m" in paths.columns and "mfe_5m" in paths.columns: + ratio = paths["mfe_5m"].mean() / abs(paths["mae_5m"].mean()) if paths["mae_5m"].mean() != 0 else np.nan + p(f" MFE/|MAE| @5m = {ratio:.2f}") + + # -------------------- 4. State Attribution -------------------- + p("\n" + "-" * 72) + p("4. State Attribution (亏损集中在哪?)") + p("-" * 72) + if not paths.empty and "after_5m_price" in paths.columns: + side = paths["side"] if "side" in paths.columns else paths.get("side_f") + fav5 = pd.Series(_fav_ret(side, paths["fill_price"], paths["after_5m_price"]), index=paths.index) + paths = paths.copy() + paths["_fav5"] = fav5 + paths["_loss"] = fav5 < 0 + loss_rate = float(paths["_loss"].mean()) + p(f"overall loss@5m: {loss_rate*100:.1f}%") + + for col in ["trend_state", "volatility_regime", "fill_reason", "pre_5s_deteriorated"]: + c = col if col in paths.columns else (col + "_f" if col + "_f" in paths.columns else None) + if not c: + continue + p(f"\n by {c}:") + g = paths.groupby(c).agg( + n=("_fav5", "count"), + loss_rate=("_loss", "mean"), + mean_ret=("_fav5", "mean"), + ) + for idx, row in g.iterrows(): + p( + f" {idx}: n={int(row['n'])} loss={row['loss_rate']*100:.1f}% " + f"E[ret]={row['mean_ret']*100:+.4f}%" + ) + + # -------------------- 5. Spread Capture / Lifecycle -------------------- + p("\n" + "-" * 72) + p("5. Spread Capture / Quote Lifecycle") + p("-" * 72) + if "spread" in fills.columns and fills["spread"].notna().any(): + mid = (fills.get("bid_price", 0) + fills.get("ask_price", 0)) / 2 + # 简化:相对价差 + p(f"spread at fill mean={fills['spread'].mean():.4f} ({(fills['spread']/fills['fill_price']).mean()*100:.5f}%)") + if not paths.empty and "after_30s_price" in paths.columns: + side = paths["side"] if "side" in paths.columns else paths.get("side_f") + fav30 = pd.Series(_fav_ret(side, paths["fill_price"], paths["after_30s_price"]), index=paths.index) + p(f"mean edge@30s (proxy spread capture): {fav30.mean()*100:+.4f}%") + + # -------------------- Hypotheses -------------------- + p("\n" + "-" * 72) + p("Hypotheses") + p("-" * 72) + + # H1 + h1 = None + if not paths.empty and "after_30s_price" in paths.columns: + side = paths["side"] if "side" in paths.columns else paths.get("side_f") + fav30 = pd.Series(_fav_ret(side, paths["fill_price"], paths["after_30s_price"]), index=paths.index) + h1 = float((fav30 > 0).mean()) + p(f"H1 P(fav@30s)>50%: {h1*100:.1f}% [{'PASS' if h1>0.5 else 'FAIL'}]") + else: + p("H1: insufficient fill_path with after_30s") + + # H2 restore vs all + if not paths.empty and "after_5m_price" in paths.columns: + side = paths["side"] if "side" in paths.columns else paths.get("side_f") + fav5 = pd.Series(_fav_ret(side, paths["fill_price"], paths["after_5m_price"]), index=paths.index) + er_col = "exit_reason" if "exit_reason" in paths.columns else None + if er_col and paths[er_col].notna().any(): + restore_mask = paths[er_col].astype(str).str.contains("restore", case=False, na=False) + if restore_mask.any(): + r_all = float(fav5.mean()) + r_res = float(fav5[restore_mask].mean()) + verdict = ( + "PASS" + if r_res > r_all + 1e-12 + else ("INCONCLUSIVE" if abs(r_res - r_all) < 1e-12 else "FAIL") + ) + p( + f"H2 restore vs all @5m: restore={r_res*100:+.4f}% all={r_all*100:+.4f}% " + f"[{verdict}] n_restore={int(restore_mask.sum())}" + ) + else: + p("H2: no restore exits tagged yet") + else: + p("H2: exit_reason not linked yet (need closed trades)") + else: + p("H2: waiting for paths") + + # H3 concentrated losses + if not paths.empty and "_loss" in paths.columns and paths["_loss"].any(): + losses = paths[paths["_loss"]] + for col in ["trend_state", "volatility_regime", "fill_reason"]: + c = col if col in losses.columns else None + if c and losses[c].notna().any(): + top = losses[c].value_counts(normalize=True).head(1) + if len(top): + k, v = top.index[0], float(top.iloc[0]) + p(f"H3 loss concentration: {v*100:.1f}% of losses in {c}={k} " + f"[{'ACTION: cancel in this state' if v>=0.5 else 'diffuse'}]") + else: + p("H3: need completed paths with losses") + + p("\n" + "=" * 72) + p("Next: accumulate ≥500 fills (ideal 1000) before designing quote model / v1.2.") + p("=" * 72) + + if out_path: + out_path.parent.mkdir(parents=True, exist_ok=True) + out_path.write_text("\n".join(lines) + "\n", encoding="utf-8") + print(f"\nReport saved: {out_path}") + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument( + "--dir", + type=str, + default=str(Path(__file__).resolve().parents[2] / "logs" / "maker_edge"), + ) + ap.add_argument("--min-fills", type=int, default=500) + ap.add_argument("--report", action="store_true", help="also write markdown/txt report") + args = ap.parse_args() + log_dir = Path(args.dir) + if not log_dir.exists(): + print(f"日志目录不存在: {log_dir}") + return + try: + df = load_events(log_dir) + except FileNotFoundError as e: + print(e) + return + out = None + if args.report: + out = Path(__file__).resolve().parents[2] / "logs" / "maker_edge" / "Maker_Edge_Report_v0.1.txt" + report(df, min_fills=args.min_fills, out_path=out) + + +if __name__ == "__main__": + main() diff --git a/strategies/maker_edge_logger.py b/strategies/maker_edge_logger.py new file mode 100644 index 0000000..e8ce9a7 --- /dev/null +++ b/strategies/maker_edge_logger.py @@ -0,0 +1,566 @@ +""" +Maker Edge 事件记录器(Dry-run / Live)— Execution Reality Layer + +事件: +- quote_created / quote_canceled / quote_filled (报价生命周期) +- book_tick (可选心跳,用于成交前5s盘口) +- fill (成交瞬间 + 盘口状态) +- fill_path (10s/30s/1m/5m + Price/Time MAE/MFE) + +输出:user_data/logs/maker_edge/YYYYMMDD.jsonl +""" + +from __future__ import annotations + +import json +import logging +import time +import uuid +from collections import deque +from dataclasses import dataclass, field +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Optional + +logger = logging.getLogger(__name__) + + +def _utc_now() -> datetime: + return datetime.now(timezone.utc) + + +def _iso(ts: datetime | float | None = None) -> str: + if ts is None: + t = _utc_now() + elif isinstance(ts, (int, float)): + t = datetime.fromtimestamp(ts, tz=timezone.utc) + else: + t = ts if ts.tzinfo else ts.replace(tzinfo=timezone.utc) + return t.isoformat() + + +@dataclass +class MicroSnapshot: + best_bid: float = 0.0 + best_ask: float = 0.0 + mid: float = 0.0 + spread: float = 0.0 + bid_depth_1: float = 0.0 + ask_depth_1: float = 0.0 + bid_depth_5: float = 0.0 + ask_depth_5: float = 0.0 + bid_depth: float = 0.0 # top-N + ask_depth: float = 0.0 + obi: float = 0.0 + delta: float = 0.0 + trade_imbalance: float = 0.0 # (buy-sell)/(buy+sell) on recent trades + delta_efficiency: float = 0.0 + liquidation_distance: float = 0.0 + + def to_book_fields(self) -> dict[str, float]: + return { + "bid_price": self.best_bid, + "ask_price": self.best_ask, + "mid": self.mid, + "spread": self.spread, + "bid_depth_1": self.bid_depth_1, + "ask_depth_1": self.ask_depth_1, + "bid_depth_5": self.bid_depth_5, + "ask_depth_5": self.ask_depth_5, + "bid_depth": self.bid_depth, + "ask_depth": self.ask_depth, + "obi": self.obi, + "delta": self.delta, + "trade_imbalance": self.trade_imbalance, + "delta_efficiency": self.delta_efficiency, + "liquidation_distance": self.liquidation_distance, + # 兼容旧字段 + "buy1_depth": self.bid_depth_1, + "sell1_depth": self.ask_depth_1, + } + + +@dataclass +class ActiveQuote: + quote_id: str + pair: str + side: str # bid / ask + quote_price: float + created_ts: float + reason: str = "" + trade_id: Optional[int] = None + status: str = "open" # open / filled / canceled + + +@dataclass +class PendingFillPath: + fill_id: str + pair: str + side: str + fill_price: float + fill_ts: float + quote_id: Optional[str] = None + exit_reason: Optional[str] = None + # horizon prices + after_10s_price: Optional[float] = None + after_30s_price: Optional[float] = None + after_1m_price: Optional[float] = None + after_5m_price: Optional[float] = None + # running extrema + min_price: float = 0.0 + max_price: float = 0.0 + # time-MAE: worst adverse excursion seen by each horizon (signed, adverse negative for long) + mae_10s: Optional[float] = None + mae_30s: Optional[float] = None + mae_1m: Optional[float] = None + mae_5m: Optional[float] = None + mfe_10s: Optional[float] = None + mfe_30s: Optional[float] = None + mfe_1m: Optional[float] = None + mfe_5m: Optional[float] = None + done: bool = False + + def __post_init__(self): + self.min_price = self.fill_price + self.max_price = self.fill_price + + def signed_excursions(self) -> tuple[float, float]: + """Return (mae, mfe) at current min/max. mae<=0 adverse, mfe>=0 favorable.""" + if self.side == "long": + mae = (self.min_price - self.fill_price) / self.fill_price + mfe = (self.max_price - self.fill_price) / self.fill_price + else: + mae = (self.fill_price - self.max_price) / self.fill_price + mfe = (self.fill_price - self.min_price) / self.fill_price + return mae, mfe + + +class MakerEdgeLogger: + def __init__( + self, + log_dir: str | Path | None = None, + levels: int = 10, + book_history_sec: float = 30.0, + ): + root = Path(__file__).resolve().parents[2] + self.log_dir = Path(log_dir) if log_dir else root / "logs" / "maker_edge" + self.log_dir.mkdir(parents=True, exist_ok=True) + self.levels = levels + self.book_history_sec = book_history_sec + self._pending: dict[str, PendingFillPath] = {} + self._quotes: dict[str, ActiveQuote] = {} # quote_id -> ActiveQuote + self._quotes_by_trade: dict[int, str] = {} # trade_id -> quote_id + self._book_hist: deque[tuple[float, MicroSnapshot]] = deque(maxlen=2000) + + def _file(self) -> Path: + return self.log_dir / f"{_utc_now().strftime('%Y%m%d')}.jsonl" + + def write(self, event: dict[str, Any]) -> None: + event.setdefault("ts", _iso()) + event.setdefault("ts_epoch", time.time()) + with self._file().open("a", encoding="utf-8") as f: + f.write(json.dumps(event, ensure_ascii=False, default=str) + "\n") + + # ------------------------------------------------------------------ # + # Snapshot + # ------------------------------------------------------------------ # + @staticmethod + def snapshot_from_orderbook( + ob: dict, + levels: int = 10, + recent_trades: list | None = None, + last_mid: float | None = None, + liq_proxy_low: float | None = None, + liq_proxy_high: float | None = None, + ) -> MicroSnapshot: + bids = (ob.get("bids") or [])[:levels] + asks = (ob.get("asks") or [])[:levels] + if not bids or not asks: + return MicroSnapshot() + + best_bid = float(bids[0][0]) + best_ask = float(asks[0][0]) + mid = (best_bid + best_ask) / 2.0 + spread = best_ask - best_bid + + def depth(levels_side, n): + return sum(float(x[1]) for x in levels_side[:n]) + + bid_depth_1 = depth(bids, 1) + ask_depth_1 = depth(asks, 1) + bid_depth_5 = depth(bids, 5) + ask_depth_5 = depth(asks, 5) + bid_depth = depth(bids, levels) + ask_depth = depth(asks, levels) + tot = bid_depth + ask_depth + obi = ((bid_depth - ask_depth) / tot) if tot > 0 else 0.0 + + buy_v = sell_v = 0.0 + if recent_trades: + for t in recent_trades: + amt = float(t.get("amount") or t.get("qty") or 0.0) + side = (t.get("side") or "").lower() + if side in ("buy", "b"): + buy_v += amt + elif side in ("sell", "s"): + sell_v += amt + delta = buy_v - sell_v + timb_den = buy_v + sell_v + trade_imbalance = ((buy_v - sell_v) / timb_den) if timb_den > 0 else 0.0 + + de = 0.0 + if last_mid and mid and abs(delta) > 1e-12: + de = ((mid - last_mid) / last_mid) / delta + + liq_dist = 0.0 + if liq_proxy_low and liq_proxy_high and mid: + rng = liq_proxy_high - liq_proxy_low + if rng > 0: + liq_dist = ((mid - liq_proxy_low) / rng) * 2 - 1 + + return MicroSnapshot( + best_bid=best_bid, + best_ask=best_ask, + mid=mid, + spread=spread, + bid_depth_1=bid_depth_1, + ask_depth_1=ask_depth_1, + bid_depth_5=bid_depth_5, + ask_depth_5=ask_depth_5, + bid_depth=bid_depth, + ask_depth=ask_depth, + obi=obi, + delta=delta, + trade_imbalance=trade_imbalance, + delta_efficiency=de, + liquidation_distance=liq_dist, + ) + + def record_book(self, snap: MicroSnapshot, now: float | None = None) -> None: + now = now or time.time() + self._book_hist.append((now, snap)) + # trim old + cutoff = now - self.book_history_sec + while self._book_hist and self._book_hist[0][0] < cutoff: + self._book_hist.popleft() + + def book_at(self, target_ts: float) -> Optional[MicroSnapshot]: + """取最接近 target_ts 的历史盘口(用于成交前5s)。""" + if not self._book_hist: + return None + best = min(self._book_hist, key=lambda x: abs(x[0] - target_ts)) + return best[1] + + def book_deterioration(self, side: str, now: float | None = None, lookback: float = 5.0) -> dict: + """ + 成交前 lookback 秒盘口是否恶化。 + long: bid_depth 下降 / ask_depth 上升 / mid 下跌 → 恶化 + """ + now = now or time.time() + cur = self.book_at(now) + past = self.book_at(now - lookback) + if not cur or not past or past.mid <= 0: + return {"book_ok": False} + mid_chg = (cur.mid - past.mid) / past.mid + bid5_chg = (cur.bid_depth_5 - past.bid_depth_5) / past.bid_depth_5 if past.bid_depth_5 else 0.0 + ask5_chg = (cur.ask_depth_5 - past.ask_depth_5) / past.ask_depth_5 if past.ask_depth_5 else 0.0 + obi_chg = cur.obi - past.obi + if side == "long": + deteriorated = (mid_chg < -0.00005) or (bid5_chg < -0.15) or (obi_chg < -0.1) + else: + deteriorated = (mid_chg > 0.00005) or (ask5_chg < -0.15) or (obi_chg > 0.1) + return { + "book_ok": True, + "pre_5s_mid_chg": mid_chg, + "pre_5s_bid_depth_5_chg": bid5_chg, + "pre_5s_ask_depth_5_chg": ask5_chg, + "pre_5s_obi_chg": obi_chg, + "pre_5s_deteriorated": bool(deteriorated), + "pre_5s_bid_depth_1": past.bid_depth_1, + "pre_5s_ask_depth_1": past.ask_depth_1, + "pre_5s_bid_depth_5": past.bid_depth_5, + "pre_5s_ask_depth_5": past.ask_depth_5, + "pre_5s_obi": past.obi, + "pre_5s_spread": past.spread, + "pre_5s_trade_imbalance": past.trade_imbalance, + } + + # ------------------------------------------------------------------ # + # Quote lifecycle + # ------------------------------------------------------------------ # + def create_quote( + self, + pair: str, + side: str, + quote_price: float, + inventory: float, + snap: MicroSnapshot, + reason: str = "", + trade_id: Optional[int] = None, + state: dict | None = None, + ) -> str: + qid = uuid.uuid4().hex[:16] + now = time.time() + q = ActiveQuote( + quote_id=qid, + pair=pair, + side=side, + quote_price=quote_price, + created_ts=now, + reason=reason, + trade_id=trade_id, + status="open", + ) + self._quotes[qid] = q + if trade_id is not None: + self._quotes_by_trade[trade_id] = qid + + ev = { + "event": "quote_created", + "quote_id": qid, + "pair": pair, + "side": side, + "quote_price": quote_price, + "quote_created_time": _iso(now), + "quote_created_epoch": now, + "inventory": inventory, + "reason": reason, + "trade_id": trade_id, + "status": "open", + "filled": False, + } + ev.update(snap.to_book_fields()) + if state: + ev.update(state) + self.write(ev) + return qid + + def cancel_quote( + self, + quote_id: str | None = None, + trade_id: Optional[int] = None, + reason: str = "timeout", + snap: MicroSnapshot | None = None, + ) -> None: + q = None + if quote_id and quote_id in self._quotes: + q = self._quotes[quote_id] + elif trade_id is not None and trade_id in self._quotes_by_trade: + q = self._quotes.get(self._quotes_by_trade[trade_id]) + if q is None or q.status != "open": + return + + now = time.time() + q.status = "canceled" + ev = { + "event": "quote_canceled", + "quote_id": q.quote_id, + "pair": q.pair, + "side": q.side, + "quote_price": q.quote_price, + "quote_created_time": _iso(q.created_ts), + "quote_cancel_time": _iso(now), + "quote_cancel_epoch": now, + "time_alive_sec": now - q.created_ts, + "cancel_reason": reason, + "filled": False, + "status": "canceled", + "trade_id": q.trade_id, + } + if snap: + ev.update(snap.to_book_fields()) + self.write(ev) + + def bind_trade(self, quote_id: str, trade_id: int) -> None: + if quote_id in self._quotes: + self._quotes[quote_id].trade_id = trade_id + self._quotes_by_trade[trade_id] = quote_id + + # ------------------------------------------------------------------ # + # Fill + path + # ------------------------------------------------------------------ # + def log_fill( + self, + pair: str, + side: str, + fill_price: float, + amount: float, + inventory: float, + snap: MicroSnapshot, + order_type: str = "limit", + quote_id: str | None = None, + trade_id: Optional[int] = None, + fill_reason: str = "maker_hit", + state: dict | None = None, + extra: dict | None = None, + ) -> str: + now = time.time() + fill_id = uuid.uuid4().hex[:16] + + # resolve quote lifecycle + q: Optional[ActiveQuote] = None + if quote_id and quote_id in self._quotes: + q = self._quotes[quote_id] + elif trade_id is not None and trade_id in self._quotes_by_trade: + q = self._quotes.get(self._quotes_by_trade[trade_id]) + + time_to_fill = None + quote_created_time = None + quote_price = fill_price + if q is not None: + q.status = "filled" + time_to_fill = now - q.created_ts + quote_created_time = _iso(q.created_ts) + quote_price = q.quote_price + quote_id = q.quote_id + + det = self.book_deterioration(side, now=now, lookback=5.0) + + ev = { + "event": "fill", + "fill_id": fill_id, + "quote_id": quote_id, + "pair": pair, + "side": side, + "fill_price": fill_price, + "quote_price": quote_price, + "amount": amount, + "inventory": inventory, + "order_type": order_type, + "fill_reason": fill_reason, + "quote_created_time": quote_created_time, + "quote_fill_time": _iso(now), + "time_to_fill": time_to_fill, + "trade_id": trade_id, + "filled": True, + } + ev.update(snap.to_book_fields()) + ev.update(det) + if state: + ev.update(state) + if extra: + ev.update(extra) + self.write(ev) + + # also emit quote_filled lifecycle event + if q is not None: + self.write( + { + "event": "quote_filled", + "quote_id": q.quote_id, + "fill_id": fill_id, + "pair": pair, + "side": q.side, + "quote_price": q.quote_price, + "quote_created_time": _iso(q.created_ts), + "quote_fill_time": _iso(now), + "time_to_fill": time_to_fill, + "fill_reason": fill_reason, + "filled": True, + "status": "filled", + "trade_id": trade_id, + **snap.to_book_fields(), + **det, + } + ) + + self._pending[fill_id] = PendingFillPath( + fill_id=fill_id, + pair=pair, + side=side, + fill_price=fill_price, + fill_ts=now, + quote_id=quote_id, + ) + return fill_id + + def attach_exit_reason(self, fill_id: str, exit_reason: str) -> None: + if fill_id in self._pending: + self._pending[fill_id].exit_reason = exit_reason + # also write lightweight annotation + self.write( + { + "event": "fill_exit", + "fill_id": fill_id, + "exit_reason": exit_reason, + } + ) + + def update_paths(self, pair: str, last_price: float, now: float | None = None) -> None: + now = now or time.time() + finished = [] + for fid, p in self._pending.items(): + if p.pair != pair or p.done: + continue + p.min_price = min(p.min_price, last_price) + p.max_price = max(p.max_price, last_price) + mae, mfe = p.signed_excursions() + age = now - p.fill_ts + + def mark(horizon_attr_price, horizon_mae, horizon_mfe, sec, price_val): + if getattr(p, horizon_attr_price) is None and age >= sec: + setattr(p, horizon_attr_price, price_val) + setattr(p, horizon_mae, mae) + setattr(p, horizon_mfe, mfe) + + mark("after_10s_price", "mae_10s", "mfe_10s", 10, last_price) + mark("after_30s_price", "mae_30s", "mfe_30s", 30, last_price) + mark("after_1m_price", "mae_1m", "mfe_1m", 60, last_price) + + if p.after_5m_price is None and age >= 300: + p.after_5m_price = last_price + p.mae_5m = mae + p.mfe_5m = mfe + p.done = True + # Price MAE absolute + if p.side == "long": + price_mae = p.min_price - p.fill_price + price_mfe = p.max_price - p.fill_price + else: + price_mae = p.fill_price - p.max_price # negative if adverse up + price_mfe = p.fill_price - p.min_price + + self.write( + { + "event": "fill_path", + "fill_id": p.fill_id, + "quote_id": p.quote_id, + "pair": p.pair, + "side": p.side, + "fill_price": p.fill_price, + "exit_reason": p.exit_reason, + "after_10s_price": p.after_10s_price, + "after_30s_price": p.after_30s_price, + "after_1m_price": p.after_1m_price, + "after_5m_price": p.after_5m_price, + "min_price": p.min_price, + "max_price": p.max_price, + # percent + "mae_10s": p.mae_10s, + "mae_30s": p.mae_30s, + "mae_1m": p.mae_1m, + "mae_5m": p.mae_5m, + "mfe_10s": p.mfe_10s, + "mfe_30s": p.mfe_30s, + "mfe_1m": p.mfe_1m, + "mfe_5m": p.mfe_5m, + # absolute price + "price_mae": price_mae, + "price_mfe": price_mfe, + "price_mae_pct": mae, + "price_mfe_pct": mfe, + } + ) + finished.append(fid) + + for fid in finished: + self._pending.pop(fid, None) + + @property + def pending_count(self) -> int: + return len(self._pending) + + # 兼容旧 API + def log_quote(self, *args, **kwargs): + """Deprecated wrapper → create_quote for live quotes; heartbeat uses book only.""" + return self.create_quote(*args, **kwargs) diff --git a/strategies/mms_stats.py b/strategies/mms_stats.py new file mode 100644 index 0000000..f7655ea --- /dev/null +++ b/strategies/mms_stats.py @@ -0,0 +1,246 @@ +#!/usr/bin/env python3 +""" +BTC Maker Micro Scalper — 回测结果统计 + +重点指标:Net Expectancy(不是胜率) + E = 胜率×平均盈利 - 失败率×平均亏损 - 手续费 - 滑点 + +用法: + python user_data/Chan/strategies/mms_stats.py + python user_data/Chan/strategies/mms_stats.py --file user_data/backtest_results/xxx.zip + python user_data/Chan/strategies/mms_stats.py --slippage 0.00005 +""" + +from __future__ import annotations + +import argparse +import json +import zipfile +from pathlib import Path +from typing import Any + +import numpy as np +import pandas as pd + + +def _latest_backtest(results_dir: Path) -> Path | None: + zips = sorted(results_dir.glob("backtest-result-*.zip"), key=lambda p: p.stat().st_mtime) + return zips[-1] if zips else None + + +def _load_trades(path: Path) -> tuple[pd.DataFrame, dict[str, Any]]: + meta: dict[str, Any] = {} + if path.suffix == ".zip": + with zipfile.ZipFile(path, "r") as zf: + names = zf.namelist() + # prefer meta + trades json inside zip + trade_name = next((n for n in names if n.endswith(".json") and "meta" not in n), None) + meta_name = next((n for n in names if n.endswith(".meta.json")), None) + if meta_name: + meta = json.loads(zf.read(meta_name)) + if not trade_name: + raise FileNotFoundError(f"No trades json in {path}") + payload = json.loads(zf.read(trade_name)) + else: + payload = json.loads(path.read_text()) + + # Freqtrade formats: {"strategy": {"BTC_...": {"trades": [...]}}} + # or flat list / {"trades": [...]} + trades = None + if isinstance(payload, list): + trades = payload + elif isinstance(payload, dict): + if "trades" in payload: + trades = payload["trades"] + elif isinstance(payload.get("strategy"), dict): + # freqtrade zip: {"strategy": {"BTC_Maker_Micro_Scalper": {"trades": [...]}}} + for name, v in payload["strategy"].items(): + if isinstance(v, dict) and "trades" in v: + trades = v["trades"] + meta.setdefault("strategy", name) + break + if trades is None: + for _k, v in payload.items(): + if isinstance(v, dict) and "trades" in v: + trades = v["trades"] + meta.setdefault("strategy", _k) + break + if trades is None: + raise ValueError(f"Cannot parse trades from {path}") + + df = pd.DataFrame(trades) + return df, meta + + +def summarize(df: pd.DataFrame, fee_rate: float = 0.00016, slippage: float = 0.0) -> dict[str, Any]: + if df.empty: + return {"error": "no trades"} + + # profit_ratio is net of fees in freqtrade; also keep absolute + profit_col = "profit_ratio" if "profit_ratio" in df.columns else "close_profit" + profits = df[profit_col].astype(float) + + wins = profits[profits > 0] + losses = profits[profits <= 0] + n = len(profits) + win_rate = len(wins) / n if n else 0.0 + loss_rate = 1.0 - win_rate + avg_win = float(wins.mean()) if len(wins) else 0.0 + avg_loss = float(losses.mean()) if len(losses) else 0.0 # negative or 0 + avg_loss_abs = abs(avg_loss) + + gross_profit = float(wins.sum()) if len(wins) else 0.0 + gross_loss = float((-losses).sum()) if len(losses) else 0.0 + profit_factor = (gross_profit / gross_loss) if gross_loss > 0 else float("inf") + + # 手续费:freqtrade 的 profit 已扣费;这里单独估算双边 maker 占比 + # 每笔双边 fee ≈ 2 * fee_rate(相对名义) + fee_per_trade = 2.0 * fee_rate + total_fee_est = n * fee_per_trade + # 滑点假设(每边) + slip_per_trade = 2.0 * slippage + total_slip_est = n * slip_per_trade + + # Net Expectancy(每笔期望,比率) + # E = WR*avg_win - LR*avg_loss_abs - fee - slip + expectancy = win_rate * avg_win - loss_rate * avg_loss_abs - fee_per_trade - slip_per_trade + + # 注意:若 profit_ratio 已含手续费,上式 fee 会双重扣除。 + # 提供两个版本: + # 1) E_raw:用毛期望再减 fee/slip(假设 profit 含 fee → 用 E_from_net) + # 2) E_from_net:直接用已实现平均利润(已含 fee)再减额外滑点假设 + e_from_net = float(profits.mean()) - slip_per_trade + + # 最大回撤(权益曲线,相对) + equity = (1.0 + profits).cumprod() + peak = equity.cummax() + dd = (equity - peak) / peak + max_dd = float(dd.min()) if len(dd) else 0.0 + + # 持仓时间 + hold_min = None + if "open_date" in df.columns and "close_date" in df.columns: + od = pd.to_datetime(df["open_date"], utc=True) + cd = pd.to_datetime(df["close_date"], utc=True) + hold_min = float(((cd - od).dt.total_seconds() / 60.0).mean()) + + # Maker 成交率:若有 order_type / is_short 等字段无法直接得,默认限价策略按 100% 标注 + maker_rate = 1.0 + if "exit_reason" in df.columns: + # 无法精确时保持 1.0;实盘可从策略 _maker_fills 导出 + pass + + # 手续费占毛利 + fee_share = None + if "fee_open" in df.columns and "fee_close" in df.columns: + fees = df["fee_open"].astype(float).fillna(0) + df["fee_close"].astype(float).fillna(0) + abs_pnl = df.get("profit_abs", profits).astype(float).abs().sum() + fee_share = float(fees.sum() / abs_pnl) if abs_pnl else None + total_fee_est = float(fees.sum()) + + return { + "total_trades": n, + "win_rate": win_rate, + "avg_win": avg_win, + "avg_loss": avg_loss, + "profit_factor": profit_factor, + "max_drawdown": max_dd, + "fee_est_total_ratio_units": total_fee_est, + "fee_share_of_abs_pnl": fee_share, + "maker_fill_rate_assumed": maker_rate, + "avg_hold_minutes": hold_min, + "net_expectancy_from_realized": e_from_net, + "net_expectancy_formula_rebuild": expectancy, + "total_profit_ratio_sum": float(profits.sum()), + "avg_profit": float(profits.mean()), + "slippage_assumed_per_side": slippage, + "note": ( + "优先看 net_expectancy_from_realized(已含 freqtrade 手续费)。" + "net_expectancy_formula_rebuild 会再减一遍 fee,仅作分解参考。" + ), + } + + +def print_report(stats: dict[str, Any], source: str) -> None: + print("=" * 60) + print("BTC Maker Micro Scalper — Backtest Stats") + print(f"source: {source}") + print("=" * 60) + if "error" in stats: + print(stats["error"]) + return + + def pct(x): + return f"{x * 100:.4f}%" if x is not None else "n/a" + + print(f"总交易次数 : {stats['total_trades']}") + print(f"胜率 : {pct(stats['win_rate'])} (勿作为主指标)") + print(f"平均盈利 : {pct(stats['avg_win'])}") + print(f"平均亏损 : {pct(stats['avg_loss'])}") + print(f"Profit Factor : {stats['profit_factor']:.4f}") + print(f"最大回撤 : {pct(stats['max_drawdown'])}") + print(f"手续费占比(abs pnl) : {stats['fee_share_of_abs_pnl']}") + print(f"Maker成交率(假设) : {pct(stats['maker_fill_rate_assumed'])}") + print(f"平均持仓时间(分钟) : {stats['avg_hold_minutes']}") + print("-" * 60) + print(f"Net Expectancy/笔 : {pct(stats['net_expectancy_from_realized'])} ★主指标") + print(f"公式重建 E(参考) : {pct(stats['net_expectancy_formula_rebuild'])}") + print(f"累计收益(比率和) : {pct(stats['total_profit_ratio_sum'])}") + print(f"平均单笔 : {pct(stats['avg_profit'])}") + print("-" * 60) + print(stats["note"]) + print("=" * 60) + + +def export_equity_csv(df: pd.DataFrame, out: Path) -> None: + if df.empty or "profit_ratio" not in df.columns: + return + profits = df["profit_ratio"].astype(float) + equity = (1.0 + profits).cumprod() + out_df = pd.DataFrame({ + "close_date": df.get("close_date"), + "profit_ratio": profits, + "equity": equity, + }) + out.parent.mkdir(parents=True, exist_ok=True) + out_df.to_csv(out, index=False) + print(f"净收益曲线已导出: {out}") + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("--file", type=str, default=None, help="backtest zip/json path") + ap.add_argument("--slippage", type=float, default=0.0, help="per-side slippage ratio") + ap.add_argument("--fee", type=float, default=0.00016, help="per-side maker fee ratio") + ap.add_argument( + "--equity-out", + type=str, + default="user_data/plot/mms_equity.csv", + help="equity curve csv", + ) + args = ap.parse_args() + + root = Path(__file__).resolve().parents[3] # freqtrade root + results_dir = root / "user_data" / "backtest_results" + + path = Path(args.file) if args.file else _latest_backtest(results_dir) + if path is None or not path.exists(): + print("未找到回测结果。请先运行 backtesting,或用 --file 指定。") + print( + "示例:\n" + " freqtrade backtesting -c ./user_data/Chan/config/BTC_Maker_Micro_Scalper.json \\\n" + " --strategy BTC_Maker_Micro_Scalper --strategy-path ./user_data/Chan/strategies \\\n" + " --timerange=20260101- --fee 0.00016 --enable-protections" + ) + return + + df, meta = _load_trades(path) + stats = summarize(df, fee_rate=args.fee, slippage=args.slippage) + print_report(stats, str(path)) + if meta: + print(f"meta keys: {list(meta.keys())[:8]}") + export_equity_csv(df, root / args.equity_out) + + +if __name__ == "__main__": + main() diff --git a/web/api/analyze.py b/web/api/analyze.py index 7c01a45..5c3931a 100644 --- a/web/api/analyze.py +++ b/web/api/analyze.py @@ -2,9 +2,103 @@ from flask import Blueprint, jsonify, request from services.runtime import * # noqa: F403 from services import runtime as R +# import * 不会带出下划线私有名;结构区缓存需显式导入 +from services.runtime.state import _zone_cache +from services.runtime.timeframes import _zone_cache_ttl 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 +119,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 +346,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 +525,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 +755,110 @@ 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) + + +def _serialize_kl_tail(df, limit: int): + """只序列化最近 limit 根,供自动刷新增量合并。""" + if df is None or getattr(df, "empty", True): + return [] + tail = df.tail(limit) + clean = clean_dataframe_for_json(tail) + records = clean.to_dict("records") + for row in records: + d = row.get("date") + if hasattr(d, "isoformat"): + try: + row["date"] = d.isoformat() + except Exception: + row["date"] = str(d) + # timestamp 统一成 int ms,便于前端按 key 合并 + ts = row.get("timestamp") + if ts is not None: + try: + row["timestamp"] = int(ts) + except (TypeError, ValueError): + pass + elif hasattr(d, "timestamp"): + try: + row["timestamp"] = int(d.timestamp() * 1000) + except Exception: + pass + return records + + +@bp.route("/api/klines/recent") +def klines_recent(): + """轻量拉取最近 N 根 K 线(不做缠论/威科夫),供主站自动刷新增量。""" + symbol = (request.args.get("symbol") or "").strip() + if not symbol: + return jsonify({"error": "交易对不能为空"}), 400 + + timeframe = request.args.get("timeframe", "5m") + try: + limit = int(request.args.get("limit", 2)) + except (TypeError, ValueError): + limit = 2 + limit = max(1, min(limit, 20)) + + element_timeframe = request.args.get("element_timeframe") or None + sub_sub_timeframe = request.args.get("sub_sub_timeframe") or None + + # 只取尾部:不传 start/end,避免全量窗口回拉 + df = get_kl_data(symbol, timeframe, limit=limit) + if df is None: + return jsonify({"error": "获取数据失败"}), 502 + if len(df) == 0: + return jsonify({"error": "没有数据"}), 404 + + result = { + "partial": True, + "symbol": symbol, + "timeframe": timeframe, + "limit": limit, + "kline_data": _serialize_kl_tail(df, limit), + } + + if element_timeframe: + edf = get_kl_data(symbol, element_timeframe, limit=limit) + result["element_timeframe"] = element_timeframe + result["element_kline_data"] = _serialize_kl_tail(edf, limit) if edf is not None else [] + + if sub_sub_timeframe: + sdf = get_kl_data(symbol, sub_sub_timeframe, limit=limit) + result["sub_sub_timeframe"] = sub_sub_timeframe + result["sub_sub_kline_data"] = _serialize_kl_tail(sdf, limit) if sdf is not None else [] 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/api/wyckoff_crypto.py b/web/api/wyckoff_crypto.py new file mode 100644 index 0000000..8ee3e1c --- /dev/null +++ b/web/api/wyckoff_crypto.py @@ -0,0 +1,236 @@ +"""Crypto Wyckoff Screener API + page (independent of /api/analyze).""" + +from __future__ import annotations + +import os +import threading + +from flask import Blueprint, jsonify, render_template, request + +from crypto_wyckoff.combos import ( + ALLOWED_TFS, + add_combo, + delete_combo, + get_combo, + list_combos, +) +from crypto_wyckoff.domain_models import DecisionSignal, WyckoffCycle, WyckoffEvent, WyckoffPhase +from crypto_wyckoff.scheduler import get_status, run_tick, start_scheduler +from crypto_wyckoff import store as wyckoff_store +from crypto_wyckoff.symbols_cn import display_name_cn, symbol_name_map +from crypto_wyckoff.version import ARCHITECTURE_VERSION, WYCKOFF_ENGINE_VERSION + +bp = Blueprint("wyckoff_crypto", __name__) + +_scheduler_started = False +_sched_lock = threading.Lock() + + +def ensure_scheduler() -> None: + global _scheduler_started + with _sched_lock: + if _scheduler_started: + return + if os.environ.get("CRYPTO_WYCKOFF_DISABLE", "").lower() in ("1", "true", "yes"): + return + interval = int(os.environ.get("CRYPTO_WYCKOFF_INTERVAL", "60")) + max_sym = os.environ.get("CRYPTO_WYCKOFF_MAX_SYMBOLS") + max_symbols = int(max_sym) if max_sym else None + start_scheduler(interval_sec=interval, max_symbols=max_symbols) + _scheduler_started = True + + +def _safe_int(raw, default: int, *, lo: int | None = None, hi: int | None = None) -> int: + try: + v = int(raw) + except (TypeError, ValueError): + v = default + if lo is not None: + v = max(lo, v) + if hi is not None: + v = min(hi, v) + return v + + +@bp.route("/wyckoff_crypto") +def page(): + ensure_scheduler() + return render_template("wyckoff_crypto.html") + + +@bp.route("/api/wyckoff_crypto/meta") +def meta(): + ensure_scheduler() + combo_id = request.args.get("combo_id") + combo = get_combo(combo_id) + latest = wyckoff_store.latest_trade_date(combo["id"]) + return jsonify( + { + "architecture_version": ARCHITECTURE_VERSION, + "engine_version": WYCKOFF_ENGINE_VERSION, + "latest_trade_date": latest, + "scan_count": wyckoff_store.count_for_date(latest, combo["id"]), + "cycles": [c.value for c in WyckoffCycle], + "phases": [p.value for p in WyckoffPhase], + "events": [e.value for e in WyckoffEvent], + "decision_signals": [s.value for s in DecisionSignal], + "timezone": "Asia/Shanghai", + "utc_offset": "+08:00", + "timeframes": [combo["low"], combo["mid"], combo["high"]], + "combo": combo, + "combos": list_combos(), + "allowed_tfs": list(ALLOWED_TFS), + "symbol_names": symbol_name_map(), + "default_symbol": "BTC/USDT:USDT", + "status": get_status(), + } + ) + + +@bp.route("/api/wyckoff_crypto/combos", methods=["GET"]) +def combos_list(): + ensure_scheduler() + return jsonify({"combos": list_combos(), "allowed_tfs": list(ALLOWED_TFS)}) + + +@bp.route("/api/wyckoff_crypto/combos", methods=["POST"]) +def combos_add(): + ensure_scheduler() + body = request.get_json(silent=True) or {} + high = (body.get("high") or request.args.get("high") or "").strip() + mid = (body.get("mid") or request.args.get("mid") or "").strip() + low = (body.get("low") or request.args.get("low") or "").strip() + label = (body.get("label") or request.args.get("label") or "").strip() or None + try: + row = add_combo(high, mid, low, label=label) + except ValueError as e: + return jsonify({"error": str(e)}), 400 + return jsonify({"ok": True, "combo": row, "combos": list_combos()}) + + +@bp.route("/api/wyckoff_crypto/combos/", methods=["DELETE"]) +def combos_delete(combo_id: str): + ensure_scheduler() + try: + removed = delete_combo(combo_id) + except ValueError as e: + return jsonify({"error": str(e)}), 400 + if not removed: + return jsonify({"error": "not_found"}), 404 + return jsonify({"ok": True, "combos": list_combos()}) + + +@bp.route("/api/wyckoff_crypto/status") +def status(): + ensure_scheduler() + return jsonify(get_status()) + + +@bp.route("/api/wyckoff_crypto/scan") +def scan(): + ensure_scheduler() + combo = get_combo(request.args.get("combo_id")) + rows = wyckoff_store.query_scan( + trade_date=request.args.get("trade_date"), + combo_id=combo["id"], + m_cycle=request.args.get("m_cycle"), + w_phase=request.args.get("w_phase"), + d_event=request.args.get("d_event"), + decision_signal=request.args.get("decision_signal"), + min_overall_score=_float_or_none(request.args.get("min_overall_score")), + min_alignment=_float_or_none(request.args.get("min_alignment")), + sort=request.args.get("sort") or "overall_score", + limit=_safe_int(request.args.get("limit"), 100, lo=1, hi=500), + offset=_safe_int(request.args.get("offset"), 0, lo=0), + ) + for row in rows: + row["name"] = display_name_cn(row.get("ts_code") or "") + return jsonify({"rows": rows, "count": len(rows), "combo": combo}) + + +@bp.route("/api/wyckoff_crypto/symbol/") +def symbol_detail(symbol: str): + ensure_scheduler() + combo = get_combo(request.args.get("combo_id")) + row = wyckoff_store.get_symbol(symbol, request.args.get("trade_date"), combo["id"]) + if not row: + return jsonify({"error": "not_found"}), 404 + return jsonify(row) + + +@bp.route("/api/wyckoff_crypto/tick", methods=["POST"]) +def manual_tick(): + """Manual one-shot tick (debug). Optional JSON/query max_symbols.""" + ensure_scheduler() + body = request.get_json(silent=True) or {} + max_sym = request.args.get("max_symbols") or body.get("max_symbols") + max_symbols = int(max_sym) if max_sym not in (None, "") else None + + def _job(): + try: + run_tick(max_symbols=max_symbols, force_rescan=True) + except Exception: + pass + + threading.Thread(target=_job, daemon=True).start() + return jsonify({"ok": True, "started": True}) + + +@bp.route("/api/wyckoff_crypto/klines") +def klines(): + """Local cached OHLCV for chart (combo TFs).""" + ensure_scheduler() + from crypto_wyckoff.io import is_intraday_tf, load_bars_with_ts + + symbol = request.args.get("symbol") or "" + combo = get_combo(request.args.get("combo_id")) + allowed = {combo["low"], combo["mid"], combo["high"]} + tf = request.args.get("tf") or combo["low"] + limit = _safe_int(request.args.get("limit"), 180, lo=1, hi=500) + if not symbol or tf not in allowed: + return jsonify({"error": "bad_request", "allowed": sorted(allowed)}), 400 + items = load_bars_with_ts(symbol, tf, lookback=limit) + return jsonify({ + "items": items, + "symbol": symbol, + "tf": tf, + "count": len(items), + "intraday": is_intraday_tf(tf), + "combo": combo, + }) + + +@bp.route("/api/wyckoff_crypto/overlay") +def overlay(): + """Phase/event overlay for chart.""" + ensure_scheduler() + from crypto_wyckoff.annotate import annotate_symbol + + symbol = request.args.get("symbol") or "" + combo = get_combo(request.args.get("combo_id")) + allowed = {combo["low"], combo["mid"], combo["high"]} + tf = request.args.get("tf") or combo["low"] + bars = _safe_int(request.args.get("bars"), 180, lo=20, hi=400) + if not symbol or tf not in allowed: + return jsonify({"error": "bad_request", "allowed": sorted(allowed)}), 400 + try: + data = annotate_symbol(symbol, freq=tf, lookback=bars, combo_id=combo["id"]) + except Exception: + return jsonify({ + "error": "overlay_failed", + "phases": [], + "events": [], + "levels": {}, + "zones": [], + "combo_id": combo["id"], + }), 500 + return jsonify(data) + + +def _float_or_none(v): + if v in (None, ""): + return None + try: + return float(v) + except (TypeError, ValueError): + return None diff --git a/web/app.py b/web/app.py index 8a46e31..8393f64 100644 --- a/web/app.py +++ b/web/app.py @@ -15,6 +15,7 @@ from api.analyze import bp as analyze_bp from api.pages import bp as pages_bp from api.symbols import bp as symbols_bp from api.trend import bp as trend_bp +from api.wyckoff_crypto import bp as wyckoff_crypto_bp, ensure_scheduler def create_app() -> Flask: @@ -23,6 +24,12 @@ def create_app() -> Flask: app.register_blueprint(analyze_bp) app.register_blueprint(symbols_bp) app.register_blueprint(trend_bp) + app.register_blueprint(wyckoff_crypto_bp) + # Start crypto wyckoff tip scheduler (daemon); disable with CRYPTO_WYCKOFF_DISABLE=1 + try: + ensure_scheduler() + except Exception: + pass return app diff --git a/web/services/runtime/timeframes.py b/web/services/runtime/timeframes.py index 3a9d61a..deb6a1d 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、次 1h、次次 15m。 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', '1h', '15m'] 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(['1h', '15m'], labels_ordered, default_main, timeframe_keys) + default_sub_sub = _prefer_smaller(['15m', '5m'], 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_sync.js b/web/static/js/app/chart_sync.js index 23c9c62..892062b 100644 --- a/web/static/js/app/chart_sync.js +++ b/web/static/js/app/chart_sync.js @@ -1,5 +1,7 @@ /* chart_sync.js — split from chart.js */ -function updateTradingViewData() { +function updateTradingViewData(options) { + options = options || {}; + const tailOnly = !!options.tailOnly; try { console.log('增量更新图表数据'); @@ -9,11 +11,34 @@ function updateTradingViewData() { return; } - // 保存当前的可视范围 + // 优先用请求前冻结的视窗;否则现场拍(自动刷新短间隔 delta≈0,两种都稳) + const frozen = window._preserveViewOnRefresh; + const oldBarCount = window._preserveViewBarCount || 0; + let savedScrollPosition = null; + let savedVisibleRange = null; + let savedLogicalRange = null; if (tvWidget.mainChart) { - tvWidget.state.visibleRange = tvWidget.mainChart.timeScale().getVisibleRange(); - tvWidget.state.logicalRange = tvWidget.mainChart.timeScale().getVisibleLogicalRange(); + const ts = tvWidget.mainChart.timeScale(); + if (frozen) { + savedVisibleRange = frozen.visibleRange; + savedLogicalRange = frozen.logicalRange; + savedScrollPosition = (typeof frozen.scrollPosition === 'number') ? frozen.scrollPosition : null; + } else { + try { savedVisibleRange = ts.getVisibleRange(); } catch (e) {} + try { savedLogicalRange = ts.getVisibleLogicalRange(); } catch (e) {} + try { + savedScrollPosition = ts.scrollPosition ? ts.scrollPosition() : null; + } catch (e) {} + } + if (tvWidget.state) { + tvWidget.state.visibleRange = savedVisibleRange; + tvWidget.state.logicalRange = savedLogicalRange; + } } + window._preserveViewOnRefresh = null; + window._preserveViewBarCount = 0; + // setData 会触发 timeRange 回调;期间禁止 sync 写回 state(否则会把已跳回左侧的视窗当成「要恢复的目标」) + window._preserveViewDuringUpdate = true; // 检查是否显示原始K线 const showOriginalKline = $('#showOriginalKline').is(':checked'); @@ -71,11 +96,32 @@ function updateTradingViewData() { }; }); } + + // LWC 不允许 null/NaN;时间用整秒,避免 Line 渲染抛 Value is null + candles = (candles || []).filter(function (c) { + return c && c.time != null && + isFinite(Number(c.open)) && isFinite(Number(c.high)) && + isFinite(Number(c.low)) && isFinite(Number(c.close)); + }).map(function (c) { + return { + time: Math.floor(Number(c.time)), + open: Number(c.open), + high: Number(c.high), + low: Number(c.low), + close: Number(c.close) + }; + }); + + const newBarCount = candles.length; + const barDelta = (oldBarCount > 0 && newBarCount > 0) ? (newBarCount - oldBarCount) : 0; + const firstBarTime = newBarCount > 0 ? candles[0].time : null; + const lastBarTime = newBarCount > 0 ? candles[newBarCount - 1].time : null; + const clampedVisibleRange = clampVisibleRangeToBarTimes(savedVisibleRange, firstBarTime, lastBarTime); // 更新主系列数据(根据klineType) const klineType = ($('#klineType').val() || (showOriginalKline ? 'candlestick' : 'line')); if (klineType === 'candlestick' && tvWidget.series.candleSeries) { - tvWidget.series.candleSeries.setData(candles); + applySeriesDataTail(tvWidget.series.candleSeries, candles, tailOnly); } else if (klineType === 'renko' && tvWidget.series.renkoSeries) { const bricks = buildRenkoFromCandles(candles); tvWidget.series.renkoSeries.setData(bricks); @@ -83,26 +129,30 @@ function updateTradingViewData() { const hk = buildHeikinFromCandles(candles); tvWidget.series.heikinSeries.setData(hk); } else if (klineType === 'bar' && tvWidget.series.barSeries) { - tvWidget.series.barSeries.setData(candles); + applySeriesDataTail(tvWidget.series.barSeries, candles, tailOnly); } else if (klineType === 'line' && tvWidget.series.lineSeries) { const lineData = candles.map(c => ({ time: c.time, value: c.close })); - tvWidget.series.lineSeries.setData(lineData); + applySeriesDataTail(tvWidget.series.lineSeries, lineData, tailOnly); } else if (klineType === 'area' && tvWidget.series.areaSeries) { const areaData = candles.map(c => ({ time: c.time, value: c.close })); - tvWidget.series.areaSeries.setData(areaData); + applySeriesDataTail(tvWidget.series.areaSeries, areaData, tailOnly); } else if (klineType === 'baseline' && tvWidget.series.baselineSeries) { const baseData = candles.map(c => ({ time: c.time, value: c.close })); - tvWidget.series.baselineSeries.setData(baseData); + applySeriesDataTail(tvWidget.series.baselineSeries, baseData, tailOnly); } else if (klineType === 'klc' && tvWidget.series.klcSeries) { const klcCandles = buildKLCFromAnalysis(currentData); - tvWidget.series.klcSeries.setData(klcCandles); + if (tailOnly) { + applySeriesDataTail(tvWidget.series.klcSeries, klcCandles, true); + } else { + tvWidget.series.klcSeries.setData(klcCandles); + } } - // 更新均线数据 - addMovingAveragesToChart(candles); - - // 更新布林带数据 - addBollingerBandsToChart(candles); + // 尾部刷新不重算均线/布林带(removeSeries 会触发视窗跳动) + if (!tailOnly) { + addMovingAveragesToChart(candles); + addBollingerBandsToChart(candles); + } // 更新成交量数据 let volumes = []; @@ -136,9 +186,11 @@ function updateTradingViewData() { } if (tvWidget.series.volumeSeries) { - tvWidget.series.volumeSeries.setData(volumes); + applySeriesDataTail(tvWidget.series.volumeSeries, volumes, tailOnly); } + // 尾部刷新不重拉 ATR/MACD(setData 会触发视窗跳到最右) + if (!tailOnly) { // 更新ATR数据 if (tvWidget.series.atrLineSeries) { const atrData = []; @@ -262,6 +314,7 @@ function updateTradingViewData() { console.warn('更新ChanMACD标注失败:', e); } } + } // 不再调用 redrawFractalElements():它会全量 initTradingView, // 与增量更新叠加会导致图表反复重建、内存暴涨。 @@ -270,27 +323,98 @@ function updateTradingViewData() { // 更新EMA52显示 updateEMA52Display(currentData); - // 恢复之前的可视范围 - 优先使用visibleRange以确保时间轴对齐 + // 与自动刷新一致:增量更新绝不碰 barSpacing(缩放本来就留在图表实例上)。 + // 一写 barSpacing,LWC 会按右边缘重锚 → 放大往右、缩小往左。 + // 这里只在 setData 之后把位置扳回刷新前的 logical / time 窗口。 if (tvWidget.mainChart) { - if (tvWidget.state.visibleRange) { - console.log('🔄 恢复可见范围:', tvWidget.state.visibleRange); - tvWidget.mainChart.timeScale().setVisibleRange(tvWidget.state.visibleRange); - if (tvWidget.volumeChart) tvWidget.volumeChart.timeScale().setVisibleRange(tvWidget.state.visibleRange); - if (tvWidget.atrChart) tvWidget.atrChart.timeScale().setVisibleRange(tvWidget.state.visibleRange); - if (tvWidget.macdChart) tvWidget.macdChart.timeScale().setVisibleRange(tvWidget.state.visibleRange); - if (tvWidget.chanMacdChart) tvWidget.chanMacdChart.timeScale().setVisibleRange(tvWidget.state.visibleRange); - } else if (tvWidget.state.logicalRange) { - console.log('🔄 恢复逻辑范围:', tvWidget.state.logicalRange); - tvWidget.mainChart.timeScale().setVisibleLogicalRange(tvWidget.state.logicalRange); - if (tvWidget.volumeChart) tvWidget.volumeChart.timeScale().setVisibleLogicalRange(tvWidget.state.logicalRange); - if (tvWidget.atrChart) tvWidget.atrChart.timeScale().setVisibleLogicalRange(tvWidget.state.logicalRange); - if (tvWidget.macdChart) tvWidget.macdChart.timeScale().setVisibleLogicalRange(tvWidget.state.logicalRange); - if (tvWidget.chanMacdChart) tvWidget.chanMacdChart.timeScale().setVisibleLogicalRange(tvWidget.state.logicalRange); - } + const charts = [ + tvWidget.mainChart, + tvWidget.volumeChart, + tvWidget.atrChart, + tvWidget.macdChart, + tvWidget.chanMacdChart + ].filter(Boolean); + + const vr = clampedVisibleRange || savedVisibleRange; + const lr = savedLogicalRange; + const savedScroll = savedScrollPosition; + const mainChartRef = tvWidget.mainChart; + + const applyPosition = function (tag) { + let ok = false; + // scrollToPosition 最稳;setVisibleRange 的 to 常含右侧空白会锚到最右 + if (typeof savedScroll === 'number' && mainChartRef) { + try { + const pos = savedScroll + (barDelta || 0); + mainChartRef.timeScale().scrollToPosition(pos, false); + const lrNow = mainChartRef.timeScale().getVisibleLogicalRange(); + if (lrNow) { + charts.forEach(c => { + try { c.timeScale().setVisibleLogicalRange(lrNow); } catch (e) {} + }); + ok = true; + console.log('🔄 恢复位置 scroll' + (tag || '') + ':', pos); + } + } catch (e) {} + } + if (!ok && lr && lr.from !== undefined && lr.to !== undefined && newBarCount > 0) { + const span = Math.max(1, lr.to - lr.from); + let to = lr.to; + let from = lr.from; + const maxTo = newBarCount - 1 + 8; + if (to > maxTo) { + to = maxTo; + from = to - span; + } + if (from < -8) { + from = -8; + to = from + span; + } + const clamped = { from: from, to: to }; + charts.forEach(c => { + try { + c.timeScale().setVisibleLogicalRange(clamped); + ok = true; + } catch (e) {} + }); + if (ok) console.log('🔄 恢复位置 logical' + (tag || '') + ':', clamped); + } + if (!ok && vr && vr.from !== undefined && vr.to !== undefined) { + charts.forEach(c => { + try { + c.timeScale().setVisibleRange(vr); + ok = true; + } catch (e) {} + }); + if (ok) console.log('🔄 恢复位置 time' + (tag || '') + ':', vr); + } + }; + + const finishPreserve = function () { + window._preserveViewDuringUpdate = false; + if (tvWidget.mainChart && tvWidget.state) { + try { + const ts = tvWidget.mainChart.timeScale(); + tvWidget.state.logicalRange = ts.getVisibleLogicalRange(); + tvWidget.state.visibleRange = ts.getVisibleRange(); + } catch (e) {} + } + }; + + applyPosition(''); + setTimeout(function () { applyPosition('@0'); }, 0); + setTimeout(function () { applyPosition('@50'); }, 50); + setTimeout(function () { + applyPosition('@150'); + finishPreserve(); + }, 150); + } else { + window._preserveViewDuringUpdate = false; } console.log('增量更新图表完成'); } catch (e) { + window._preserveViewDuringUpdate = false; console.error('增量更新图表错误,回退到完全重绘:', e); // 出错时回退到完全重绘 initTradingView($('#symbol').val(), $('#timeframe').val()); @@ -316,7 +440,7 @@ function bindSyncEvents(mainChartContainer, volumeChartContainer, atrChartContai // 同步图表的时间范围 function syncCharts(sourceChart, sourceContainer) { - if (syncInProgress) return; + if (syncInProgress || window._preserveViewDuringUpdate) return; syncInProgress = true; diff --git a/web/static/js/app/chart_tv.js b/web/static/js/app/chart_tv.js index 749bb0c..4990e08 100644 --- a/web/static/js/app/chart_tv.js +++ b/web/static/js/app/chart_tv.js @@ -1,4616 +1,26 @@ -/* chart_tv.js — split from chart.js */ - -/** 释放 Lightweight Charts 实例、DOM 与全局事件,避免自动刷新内存泄漏 */ -function disposeTradingViewCharts() { - try { - if (window._tvInitCleanups && Array.isArray(window._tvInitCleanups)) { - window._tvInitCleanups.forEach(function (fn) { try { fn(); } catch (e) {} }); - } - window._tvInitCleanups = []; - if (window._bindSyncCleanups && Array.isArray(window._bindSyncCleanups)) { - window._bindSyncCleanups.forEach(function (fn) { try { fn(); } catch (e) {} }); - } - window._bindSyncCleanups = []; - if (window._tooltipCleanups && Array.isArray(window._tooltipCleanups)) { - window._tooltipCleanups.forEach(function (fn) { try { fn(); } catch (e) {} }); - } - window._tooltipCleanups = []; - - document.querySelectorAll( - '.volume-crosshair-line, .atr-crosshair-line, .macd-crosshair-line, .chanmacd-crosshair-line' - ).forEach(function (el) { try { el.remove(); } catch (e) {} }); - - if (typeof clearEMA52Series === 'function') { - try { clearEMA52Series(); } catch (e) {} - } - - if (tvWidget) { - ['mainChart', 'volumeChart', 'macdChart', 'chanMacdChart', 'atrChart'].forEach(function (key) { - try { - if (tvWidget[key] && typeof tvWidget[key].remove === 'function') { - tvWidget[key].remove(); - } - } catch (e) {} - tvWidget[key] = null; - }); - if (tvWidget.state) { - tvWidget.state.isInitialized = false; - } - } - - var chartRoot = document.getElementById('tradingview_chart'); - if (chartRoot) { - chartRoot.innerHTML = ''; - } - } catch (e) { - console.warn('disposeTradingViewCharts 失败(可忽略):', e); - } -} +/* chart_tv.js — facade: initTradingView orchestrates split modules */ function initTradingView(symbol, timeframe) { try { - // 每次重建前完整释放,防止自动刷新导致 GPU/监听器泄漏 disposeTradingViewCharts(); console.log('初始化TradingView图表:', symbol, timeframe); - - // 获取当前交易对的配置 - const symbolConfig = getSymbolConfig(symbol); - console.log('交易对配置:', symbolConfig); - - // 检查数据是否存在 - if (!currentData || !currentData.kline_data) { - console.error('数据加载失败或不存在'); + + var ctx = { + symbol: symbol, + timeframe: timeframe + }; + + chartTvBuildShell(ctx); + if (!ctx.mainChart) { return; } - - // 检查使用哪一档K线数据:次次周期 / 小周期 / 主周期 - const useSubSubPeriod = $('#subSubPeriodKline').is(':checked') && - currentData.sub_sub_kline_data && - Array.isArray(currentData.sub_sub_kline_data); - const useElementPeriod = $('#elementPeriodKline').is(':checked') && - currentData.element_kline_data && - Array.isArray(currentData.element_kline_data); - - // 输出K线周期选择状态 - const klinePeriodLabel = useSubSubPeriod ? '次次周期' : (useElementPeriod ? '小周期' : '主周期'); - console.log('K线周期选择:', klinePeriodLabel); - console.log('当前选择时区:', $('#timezone').val()); - console.log('交易对类型:', symbolConfig.type); - - let candles = []; - const klineDataSource = useSubSubPeriod ? (currentData.sub_sub_kline_data || []) : (useElementPeriod ? (currentData.element_kline_data || []) : (currentData.kline_data || [])); - - if (useSubSubPeriod || useElementPeriod) { - if (!klineDataSource.length) { - console.error(useSubSubPeriod ? '次次周期K线数据不存在或为空' : '小周期K线数据不存在或为空', klineDataSource); - return; - } - candles = klineDataSource.map((kline) => { - const date = new Date(kline.date); - const timestamp = date.getTime() / 1000; - return { - time: timestamp, - open: parseFloat(kline.open), - high: parseFloat(kline.high), - low: parseFloat(kline.low), - close: parseFloat(kline.close), - }; - }); - } else { - if (!currentData.kline_data || !Array.isArray(currentData.kline_data)) { - console.error('主周期K线数据不存在或不是数组:', currentData.kline_data); - return; - } - candles = currentData.kline_data.map((kline) => { - const date = new Date(kline.date); - const timestamp = date.getTime() / 1000; - return { - time: timestamp, - open: parseFloat(kline.open), - high: parseFloat(kline.high), - low: parseFloat(kline.low), - close: parseFloat(kline.close), - }; - }); - } - - // 根据交易对类型过滤数据(仅用于显示优化) - if (symbolConfig.type === 'a_stock' && timeframe.includes('m')) { - // 对于A股分钟级数据,过滤非交易时间 - const originalLength = candles.length; - candles = filterTradingHours(candles, symbolConfig); - console.log(`A股数据过滤: ${originalLength} -> ${candles.length} 条记录`); - } - // 重置图表对象(容器已在 disposeTradingViewCharts 清空) - tvWidget = { - mainChart: null, - volumeChart: null, - macdChart: null, - series: { - candleSeries: null, - lineSeries: null, - volumeSeries: null, - macdLineSeries: null, - signalLineSeries: null, - histogramSeries: null, - mainBiSeries: [], - mainUncompletedBiSeries: [], - mainSegSeries: [], - mainUncompletedSegSeries: [], - mainZsSeries: [], - mainUncompletedZsSeries: [], - elementBiSeries: [], - elementUncompletedBiSeries: [], - elementSegSeries: [], - elementUncompletedSegSeries: [], - elementZsSeries: [], - elementUncompletedZsSeries: [], - subSubBiSeries: [], - subSubUncompletedBiSeries: [], - subSubSegSeries: [], - subSubUncompletedSegSeries: [], - subSubZsSeries: [], - subSubUncompletedZsSeries: [], - tradePointSeries: [], - mainBollingerSeries: [], - elementBollingerSeries: [], - maSeries: [], // 添加均线系列数组 - bbSeries: [], // 添加布林带系列数组 - ema52Series: [] // 添加EMA52系列数组 - }, - state: { - isInitialized: false, - visibleRange: null, - logicalRange: null - } - }; - - // 设置父容器样式 - const container = document.getElementById('tradingview_chart'); - container.style.position = 'relative'; - container.style.width = '100%'; - container.style.height = '100%'; - - // 是否显示MACD - const showMacd = $('#showMacd').is(':checked'); - const showOriginalKline = $('#showOriginalKline').is(':checked'); - - // 创建主图容器 - const mainChartContainer = document.createElement('div'); - mainChartContainer.style.width = '100%'; - mainChartContainer.style.position = 'absolute'; - mainChartContainer.style.top = '0'; - mainChartContainer.style.left = '0'; - mainChartContainer.style.right = '0'; - - // 创建成交量副图容器 - const volumeChartContainer = document.createElement('div'); - volumeChartContainer.style.width = '100%'; - volumeChartContainer.style.position = 'absolute'; - volumeChartContainer.style.left = '0'; - volumeChartContainer.style.right = '0'; - volumeChartContainer.style.borderTop = '1px solid #e0e0e0'; - - // 添加ATR图表容器 - const atrChartContainer = document.createElement('div'); - atrChartContainer.style.width = '100%'; - atrChartContainer.style.position = 'absolute'; - atrChartContainer.style.left = '0'; - atrChartContainer.style.right = '0'; - atrChartContainer.style.borderTop = '1px solid #e0e0e0'; + chartTvRenderIndicators(ctx); + chartTvRenderChan(ctx); + chartTvRenderOverlays(ctx); + chartTvFinalize(ctx); - // 如果需要显示MACD,创建MACD容器 - let macdChartContainer = null; - let chanMacdChartContainer = null; - if (showMacd) { - // 仅显示新的 ChanMACD 图:让其占用原 MACD+ChanMACD 的整体高度 - // 新布局:主图(40%) → ChanMACD(30%) → 成交量(17.5%) → ATR(12.5%) - mainChartContainer.style.height = '40%'; - - // 隐藏旧 MACD 容器(不创建) - // 创建 ChanMACD 容器占据原 MACD+ChanMACD 高度(30%) - chanMacdChartContainer = document.createElement('div'); - chanMacdChartContainer.style.width = '100%'; - chanMacdChartContainer.style.height = '30%'; - chanMacdChartContainer.style.position = 'absolute'; - chanMacdChartContainer.style.top = '40%'; - chanMacdChartContainer.style.left = '0'; - chanMacdChartContainer.style.right = '0'; - chanMacdChartContainer.style.borderTop = '1px solid #e0e0e0'; - chanMacdChartContainer.style.zIndex = '10'; - // 水印:便于区分是新的 ChanMACD 子图 - const chanMacdWatermark = document.createElement('div'); - chanMacdWatermark.textContent = 'ChanMACD'; - chanMacdWatermark.style.position = 'absolute'; - chanMacdWatermark.style.top = '4px'; - chanMacdWatermark.style.left = '8px'; - chanMacdWatermark.style.fontSize = '11px'; - chanMacdWatermark.style.color = '#888'; - chanMacdWatermark.style.pointerEvents = 'none'; - chanMacdChartContainer.appendChild(chanMacdWatermark); - - // 成交量位于 ChanMACD 之下 - volumeChartContainer.style.top = '70%'; - volumeChartContainer.style.height = '17.5%'; - - // ATR 位于最底部 - atrChartContainer.style.top = '87.5%'; - atrChartContainer.style.height = '12.5%'; - } else { - // 不显示MACD时的高度 - 主图、成交量图和ATR图分配 - mainChartContainer.style.height = '55%'; // 主图占55% - volumeChartContainer.style.top = '55%'; - volumeChartContainer.style.height = '22.5%'; // 成交量图占22.5% - - atrChartContainer.style.top = '77.5%'; // ATR图从77.5%位置开始 - atrChartContainer.style.height = '22.5%'; // ATR图占22.5% - } - - container.appendChild(mainChartContainer); - container.appendChild(volumeChartContainer); - container.appendChild(atrChartContainer); - if (showMacd) { - // 只追加新的 ChanMACD 容器 - container.appendChild(chanMacdChartContainer); - } - - // 防止同步过程中的无限循环(实际同步由 bindSyncEvents 负责) - - // 创建统一的图表选项 - const createChartOptions = (showTimeScale = true, chartType = 'main') => { - // 根据图表类型确定高度 - let chartHeight; - if (chartType === 'main') { - chartHeight = mainChartContainer.clientHeight; - } else if (chartType === 'volume') { - chartHeight = volumeChartContainer.clientHeight; - } else if (chartType === 'atr') { - chartHeight = atrChartContainer.clientHeight; - } else if (chartType === 'macd') { - chartHeight = macdChartContainer ? macdChartContainer.clientHeight : 0; - } else if (chartType === 'chanmacd') { - chartHeight = chanMacdChartContainer ? chanMacdChartContainer.clientHeight : 0; - } else { - chartHeight = mainChartContainer.clientHeight; - } - - const baseOptions = { - width: mainChartContainer.clientWidth, - height: chartHeight, - layout: { - background: { color: '#ffffff' }, - textColor: '#333', - }, - grid: { - vertLines: { color: '#f0f0f0' }, - horzLines: { color: '#f0f0f0' }, - }, - crosshair: { - mode: LightweightCharts.CrosshairMode.Normal, - // 添加十字线工具提示本地化配置 - horzLine: { - labelVisible: true, - }, - vertLine: { - labelVisible: true, - // 自定义时间格式化 - labelFormatter: (time) => { - const selectedTimezone = $('#timezone').val(); - try { - const date = new Date(time * 1000); - if (symbolConfig.type === 'a_stock') { - // A股使用中国时区格式 - return date.toLocaleString('zh-CN', { - timeZone: 'Asia/Shanghai', - year: 'numeric', - month: '2-digit', - day: '2-digit', - hour: '2-digit', - minute: '2-digit', - second: '2-digit' - }); - } else { - return date.toLocaleString('zh-CN', { - timeZone: selectedTimezone, - year: 'numeric', - month: '2-digit', - day: '2-digit', - hour: '2-digit', - minute: '2-digit', - second: '2-digit' - }); - } - } catch (e) { - console.error('十字线时间格式化错误:', e); - return new Date(time * 1000).toLocaleString(); - } - }, - }, - }, - rightPriceScale: { - borderColor: '#ddd', - scaleMargins: { - top: 0.1, - bottom: 0.1, - }, - // 为标签留出更多空间,防止遮挡 - minimumWidth: 80, - }, - // 添加左边距配置 - leftPriceScale: { - visible: false, - }, - // 添加本地化选项,确保所有时间显示都使用选定的时区 - localization: { - timeFormatter: (time) => { - const selectedTimezone = $('#timezone').val(); - try { - const date = new Date(time * 1000); - if (symbolConfig.type === 'a_stock') { - // A股使用中国时区格式 - return date.toLocaleString('zh-CN', { - timeZone: 'Asia/Shanghai', - year: 'numeric', - month: '2-digit', - day: '2-digit', - hour: '2-digit', - minute: '2-digit', - second: '2-digit' - }); - } else { - return date.toLocaleString('zh-CN', { - timeZone: selectedTimezone, - year: 'numeric', - month: '2-digit', - day: '2-digit', - hour: '2-digit', - minute: '2-digit', - second: '2-digit' - }); - } - } catch (e) { - console.error('全局时间格式化错误:', e); - return new Date(time * 1000).toLocaleString(); - } - } - }, - timeScale: { - timeVisible: true, - secondsVisible: false, - visible: showTimeScale, - borderColor: '#ddd', - barSpacing: symbolConfig.type === 'a_stock' ? 6 : 10, - // 确保所有图表使用相同的边距设置 - rightOffset: 12, - // 移除可能影响拖动的固定边缘设置 - // fixLeftEdge: true, - // fixRightEdge: true, - lockVisibleTimeRangeOnResize: true, - tickMarkFormatter: (time) => { - const selectedTimezone = symbolConfig.type === 'a_stock' ? 'Asia/Shanghai' : $('#timezone').val(); - try { - // 使用完整的配置确保时区正确应用 - const date = new Date(time * 1000); - console.log('格式化时间:', time, '转换为:', date.toISOString(), '时区:', selectedTimezone); - - return date.toLocaleString('zh-CN', { - timeZone: selectedTimezone, - month: 'numeric', - day: 'numeric', - hour: '2-digit', - minute: '2-digit', - }); - } catch (e) { - console.error('时间格式化错误:', e); - // 如果时区格式化失败,返回简单格式 - return new Date(time * 1000).toLocaleString(); - } - }, - }, - }; - - // 根据交易对类型调整配置 - return adjustChartForSymbolType(baseOptions, symbolConfig); - }; - - // 创建主图表 - const mainChart = LightweightCharts.createChart(mainChartContainer, createChartOptions(true, 'main')); - - // 创建成交量图表 - 只显示底部的时间轴 - const volumeChart = LightweightCharts.createChart(volumeChartContainer, createChartOptions(false, 'volume')); - - // 创建ATR图表 - const atrChart = LightweightCharts.createChart(atrChartContainer, createChartOptions(false, 'atr')); - - // 创建MACD图表(如果需要):仅创建新的 ChanMACD 图 - let macdChart = null; - let chanMacdChart = null; - if (showMacd) { - chanMacdChart = LightweightCharts.createChart(chanMacdChartContainer, createChartOptions(false, 'chanmacd')); - } - - // 创建主价格系列并设置数据(支持多种图表类型) - (function(){ - const klineType = ($('#klineType').val() || 'candlestick'); - // 先清空旧的主系列引用 - tvWidget.series.candleSeries = null; - tvWidget.series.lineSeries = null; - tvWidget.series.barSeries = null; - tvWidget.series.areaSeries = null; - tvWidget.series.baselineSeries = null; - tvWidget.series.renkoSeries = null; - tvWidget.series.heikinSeries = null; - - if (klineType === 'candlestick') { - const series = mainChart.addCandlestickSeries({ - upColor: '#28a745', - downColor: '#dc3545', - borderVisible: false, - wickUpColor: '#28a745', - wickDownColor: '#dc3545', - }); - series.setData(candles); - tvWidget.series.candleSeries = series; - } else if (klineType === 'renko') { - const series = mainChart.addCandlestickSeries({ - upColor: '#28a745', - downColor: '#dc3545', - borderVisible: false, - wickUpColor: '#28a745', - wickDownColor: '#dc3545', - }); - const bricks = buildRenkoFromCandles(candles); - series.setData(bricks); - tvWidget.series.renkoSeries = series; - } else if (klineType === 'heikin') { - const series = mainChart.addCandlestickSeries({ - upColor: '#28a745', - downColor: '#dc3545', - borderVisible: false, - wickUpColor: '#28a745', - wickDownColor: '#dc3545', - }); - const hk = buildHeikinFromCandles(candles); - series.setData(hk); - tvWidget.series.heikinSeries = series; - } else if (klineType === 'bar') { - const series = mainChart.addBarSeries({ - upColor: '#28a745', - downColor: '#dc3545', - thinBars: false - }); - series.setData(candles); - tvWidget.series.barSeries = series; - } else if (klineType === 'line') { - const series = mainChart.addLineSeries({ - color: '#2962FF', - lineWidth: 2, - crosshairMarkerVisible: true, - lastValueVisible: true, - priceLineVisible: true, - }); - const lineData = candles.map(c => ({ time: c.time, value: c.close })); - series.setData(lineData); - tvWidget.series.lineSeries = series; - } else if (klineType === 'area') { - const series = mainChart.addAreaSeries({ - topColor: 'rgba(41, 98, 255, 0.4)', - bottomColor: 'rgba(41, 98, 255, 0.0)', - lineColor: '#2962FF', - lineWidth: 2, - }); - const areaData = candles.map(c => ({ time: c.time, value: c.close })); - series.setData(areaData); - tvWidget.series.areaSeries = series; - } else if (klineType === 'baseline') { - const series = mainChart.addBaselineSeries({ - baseValue: { type: 'price', price: candles.length ? candles[candles.length - 1].close : 0 }, - topLineColor: '#26a69a', - bottomLineColor: '#ef5350', - topFillColor1: 'rgba(38, 166, 154, 0.28)', - topFillColor2: 'rgba(38, 166, 154, 0.05)', - bottomFillColor1: 'rgba(239, 83, 80, 0.28)', - bottomFillColor2: 'rgba(239, 83, 80, 0.05)' - }); - const baseData = candles.map(c => ({ time: c.time, value: c.close })); - series.setData(baseData); - tvWidget.series.baselineSeries = series; - } else if (klineType === 'klc') { - // KLC显示模式 - 使用蜡烛线显示KLC数据 - const series = mainChart.addCandlestickSeries({ - upColor: '#28a745', - downColor: '#dc3545', - borderVisible: false, - wickUpColor: '#28a745', - wickDownColor: '#dc3545', - }); - // 使用KLC数据创建蜡烛图 - const klcCandles = buildKLCFromAnalysis(currentData); - series.setData(klcCandles); - tvWidget.series.klcSeries = series; - } - })(); - - // 转换成交量数据 - 与K线周期一致 - let volumes = []; - const volumeDataSource = useSubSubPeriod ? (currentData.sub_sub_kline_data || []) : (useElementPeriod ? currentData.element_kline_data : currentData.kline_data); - - console.log('成交量数据源选择:', klinePeriodLabel); - console.log('成交量数据长度:', volumeDataSource.length); - - if (volumeDataSource && Array.isArray(volumeDataSource)) { - volumes = volumeDataSource.map(kline => { - // 使用与K线和MACD完全相同的时间戳计算方式 - const timestamp = Math.floor(new Date(kline.date).getTime() / 1000); - return { - time: timestamp, - value: parseFloat(kline.volume), - color: parseFloat(kline.close) >= parseFloat(kline.open) ? 'rgba(40, 167, 69, 0.5)' : 'rgba(220, 53, 69, 0.5)', - }; - }); - - console.log('处理后的成交量数据点数:', volumes.length); - } - - // 添加成交量图表 - const volumeSeries = volumeChart.addHistogramSeries({ - color: '#26a69a', - priceFormat: { - type: 'volume', - }, - title: '成交量', - }); - volumeSeries.setData(volumes); - tvWidget.series.volumeSeries = volumeSeries; - - // 添加ATR图表 - const atrLineSeries = atrChart.addLineSeries({ - color: '#FF9800', - lineWidth: 2, - title: 'ATR', - lastValueVisible: false, - priceLineVisible: false, - }); - - // 准备ATR数据 - const atrData = []; - const atrKlineDataSource = useSubSubPeriod ? (currentData.sub_sub_kline_data || []) : (useElementPeriod ? currentData.element_kline_data : currentData.kline_data); - const atrDataSource = useSubSubPeriod ? (currentData.sub_sub_atr || currentData.atr) : (useElementPeriod ? (currentData.element_atr || currentData.atr) : currentData.atr); - - console.log('ATR数据源选择:', klinePeriodLabel); - console.log('ATR数据长度:', atrDataSource ? atrDataSource.length : 0); - console.log('K线数据长度:', atrKlineDataSource ? atrKlineDataSource.length : 0); - - if (atrDataSource && Array.isArray(atrDataSource) && atrKlineDataSource && Array.isArray(atrKlineDataSource)) { - // 关键修复:为每个K线时间点都创建ATR数据点,包括没有ATR值的前期数据 - for (let i = 0; i < atrKlineDataSource.length; i++) { - const kline = atrKlineDataSource[i]; - const timestamp = Math.floor(new Date(kline.date).getTime() / 1000); - - // 为每个时间点都添加数据以保持时间轴对齐,但ATR为0时不显示 - if (atrDataSource[i] !== undefined) { - if (atrDataSource[i] > 0) { - // ATR有效值,正常显示 - atrData.push({ - time: timestamp, - value: atrDataSource[i] - }); - } else { - // ATR为0,添加时间点但不显示线条(使用undefined作为value) - atrData.push({ - time: timestamp, - value: undefined - }); - } - } - } - - console.log('处理后的ATR数据点数:', atrData.length); - console.log('ATR数据样本:', atrData.slice(0, 5)); - } - console.log('处理后的ATR数据点数:', atrData.length); - atrLineSeries.setData(atrData); - tvWidget.series.atrLineSeries = atrLineSeries; - - // 旧 MACD 图已移除,不再绘制(保留占位但彻底禁用) - if (FEATURES.legacyMacd && showMacd && currentData.macd && currentData.kline_data && Array.isArray(currentData.kline_data)) { - // 创建MACD线 - const macdLineSeries = macdChart.addLineSeries({ - color: '#2962FF', - lineWidth: 1, - title: 'MACD', - lastValueVisible: false, // 禁用最后值标签,防止遮挡 - priceLineVisible: false, // 禁用价格线 - }); - - // 创建信号线 - const signalLineSeries = macdChart.addLineSeries({ - color: '#FF6B6B', - lineWidth: 1, - title: 'Signal', - lastValueVisible: false, // 禁用最后值标签,防止遮挡 - priceLineVisible: false, // 禁用价格线 - }); - - // 创建直方图 - const histogramSeries = macdChart.addHistogramSeries({ - color: '#26a69a', - title: 'Histogram', - priceFormat: { - type: 'price', - precision: 4, - }, - }); - - // 提取MACD数据 - 使用和K线数据相同的时间处理逻辑 - const macdData = []; - const signalData = []; - const histogramData = []; - - // 使用与K线数据相同的数据源来确保时间对齐 - const klineDataSource = useSubSubPeriod ? (currentData.sub_sub_kline_data || []) : (useElementPeriod ? currentData.element_kline_data : currentData.kline_data); - const macdDataSource = useElementPeriod ? - (currentData.element_macd || currentData.macd) : // 如果有次周期MACD数据则使用,否则使用主周期 - currentData.macd; // 主周期使用主周期MACD数据 - - console.log('MACD数据源选择:', useElementPeriod ? '次周期' : '主周期'); - console.log('K线数据长度:', klineDataSource.length); - console.log('MACD数据:', macdDataSource); - - for (let i = 0; i < klineDataSource.length; i++) { - const kline = klineDataSource[i]; - // 使用与K线完全相同的时间戳计算方式 - const timestamp = Math.floor(new Date(kline.date).getTime() / 1000); - - if (macdDataSource && macdDataSource.macd && macdDataSource.macd[i] !== undefined) { - macdData.push({ - time: timestamp, - value: macdDataSource.macd[i] - }); - - signalData.push({ - time: timestamp, - value: macdDataSource.signal[i] - }); - - // 设置直方图颜色 - const histValue = macdDataSource.histogram[i]; - histogramData.push({ - time: timestamp, - value: histValue, - color: histValue >= 0 ? 'rgba(40, 167, 69, 0.5)' : 'rgba(220, 53, 69, 0.5)' - }); - } - } - - console.log('处理后的MACD数据点数:', macdData.length); - - macdLineSeries.setData(macdData); - signalLineSeries.setData(signalData); - histogramSeries.setData(histogramData); - - tvWidget.series.macdLineSeries = macdLineSeries; - tvWidget.series.signalLineSeries = signalLineSeries; - tvWidget.series.histogramSeries = histogramSeries; - } - // 添加ChanMACD图表 - console.log('ChanMACD图表创建条件检查:', { - showMacd: showMacd, - chanMacdChart: !!chanMacdChart, - hasMacd: !!currentData.macd, - hasKlineData: !!currentData.kline_data, - isArray: Array.isArray(currentData.kline_data) - }); - // 在创建 ChanMACD 前,确保一次性同步 U 显示开关到全局(默认不显示) - if (typeof window.showUOnMain === 'undefined') { - window.showUOnMain = $('#toggleUOnMain').is(':checked'); - } - if (typeof window.showUOnElement === 'undefined') { - window.showUOnElement = $('#toggleUOnElement').is(':checked'); - } - if (typeof window.showUOnSubSub === 'undefined') { - window.showUOnSubSub = $('#toggleUOnSubSub').is(':checked'); - } - if (showMacd && chanMacdChart && ((useSubSubPeriod && currentData.sub_sub_macd) || (useElementPeriod && currentData.element_macd) || currentData.macd) && (useSubSubPeriod ? (currentData.sub_sub_kline_data || []) : (useElementPeriod ? currentData.element_kline_data : currentData.kline_data))) { - console.log('✅ 开始创建 ChanMACD 系列'); - // 创建ChanMACD线系列 - const chanMacdLineSeries = chanMacdChart.addLineSeries({ - color: '#2962FF', - lineWidth: 1, - title: 'ChanMACD', - lastValueVisible: false, - priceLineVisible: false, - }); - - // 创建ChanMACD信号线系列 - const chanMacdSignalSeries = chanMacdChart.addLineSeries({ - color: '#FF6B6B', - lineWidth: 1, - title: 'ChanSignal', - lastValueVisible: false, - priceLineVisible: false, - }); - - // 创建ChanMACD柱状图系列 - const chanMacdHistSeries = chanMacdChart.addHistogramSeries({ - color: '#26a69a', - title: 'ChanHistogram', - priceFormat: { - type: 'price', - precision: 4, - }, - }); - - // 设置ChanMACD图表的字体大小 - chanMacdChart.applyOptions({ - layout: { - fontSize: 10, // 设置更小的字体大小 - }, - rightPriceScale: { - fontSize: 10, // 设置右侧价格轴的字体大小 - }, - timeScale: { - fontSize: 10, // 设置时间轴的字体大小 - }, - }); - - // 使用与主图一致的数据源(小周期开启时使用小周期MACD与K线) - const klineDataSource = useSubSubPeriod ? (currentData.sub_sub_kline_data || []) : (useElementPeriod ? currentData.element_kline_data : currentData.kline_data); - const macdDataSource = useSubSubPeriod ? (currentData.sub_sub_macd || currentData.macd) : (useElementPeriod ? (currentData.element_macd || currentData.macd) : currentData.macd); - - // 准备ChanMACD数据 - const chanMacdData = []; - const chanSignalData = []; - const chanHistData = []; - - console.log('ChanMACD数据源检查:', { - klineDataSourceLength: klineDataSource.length, - macdDataSource: !!macdDataSource, - macdLength: macdDataSource ? macdDataSource.macd.length : 0 - }); - - console.log('ChanMACD数据源检查:', { - klineDataSourceLength: klineDataSource.length, - macdDataSource: !!macdDataSource, - macdLength: macdDataSource ? macdDataSource.macd.length : 0 - }); - - for (let i = 0; i < klineDataSource.length; i++) { - const kline = klineDataSource[i]; - if (kline && kline.date && - i < macdDataSource.macd.length && - macdDataSource.macd[i] !== null && macdDataSource.macd[i] !== undefined) { - - // 使用与K线完全相同的时间戳计算方式 - const timestamp = Math.floor(new Date(kline.date).getTime() / 1000); - - chanMacdData.push({ - time: timestamp, - value: macdDataSource.macd[i] - }); - - chanSignalData.push({ - time: timestamp, - value: macdDataSource.signal[i] - }); - - chanHistData.push({ - time: timestamp, - value: macdDataSource.histogram[i], - color: macdDataSource.histogram[i] >= 0 ? 'rgba(40, 167, 69, 0.5)' : 'rgba(220, 53, 69, 0.5)' - }); - } - } - - console.log('ChanMACD数据处理完成:', { - chanMacdDataLength: chanMacdData.length, - chanSignalDataLength: chanSignalData.length, - chanHistDataLength: chanHistData.length, - sampleData: chanMacdData.length > 0 ? chanMacdData[0] : null - }); - - // 设置ChanMACD数据 - console.log('ChanMACD数据长度:', chanMacdData.length, chanSignalData.length, chanHistData.length); - - if (chanMacdData.length > 0) { - chanMacdLineSeries.setData(chanMacdData); - chanMacdSignalSeries.setData(chanSignalData); - chanMacdHistSeries.setData(chanHistData); - console.log('✅ ChanMACD数据设置成功'); - } else { - console.warn('⚠️ ChanMACD数据为空,无法设置数据'); - } - - // 保存到tvWidget - tvWidget.series.chanMacdLineSeries = chanMacdLineSeries; - tvWidget.series.chanMacdSignalSeries = chanMacdSignalSeries; - tvWidget.series.chanMacdHistSeries = chanMacdHistSeries; - - console.log('✅ ChanMACD图表系列已保存到tvWidget'); - - // 添加ChanMACD分析标注 - // 根据主/次周期开关与各自的"显示U"独立控制 - const cm = useSubSubPeriod ? (currentData.sub_sub_chan_macd || currentData.chan_macd) : (useElementPeriod ? (currentData.element_chan_macd || currentData.chan_macd) : currentData.chan_macd); - // 默认不显示,必须用户勾选对应复选框 - const allowU = useSubSubPeriod ? !!window.showUOnSubSub : (useElementPeriod ? !!window.showUOnElement : !!window.showUOnMain); - if (cm && allowU) { - console.log('添加ChanMACD分析标注:', { - segListLength: cm.seg_list ? cm.seg_list.length : 0, - unittfListLength: cm.unittf_list ? cm.unittf_list.length : 0, - histsetListLength: cm.histset_list ? cm.histset_list.length : 0 - }); - - // 详细检查段数据 - if (cm.seg_list && cm.seg_list.length > 0) { - console.log('段数据详情:', cm.seg_list.slice(0, 3)); // 显示前3个段 - } else { - console.log('⚠️ 段数据为空或不存在'); - } - - addAllChanMacdMarkers( - cm.seg_list || [], - cm.unittf_list || [], - cm.histset_list || [], - { - high_position_list: cm.high_position_list || [], - high_empty_list: cm.high_empty_list || [], - low_position_list: cm.low_position_list || [], - low_empty_list: cm.low_empty_list || [], - return_zero_list: cm.return_zero_list || [], - cross0_up_list: cm.cross0_up_list || [], - cross0_down_list: cm.cross0_down_list || [] - } - ); - - // 同时从主/次周期的 klu_list 提取 SD/CD 标记,分别使用不同样式 - try { - const mainCm = currentData.chan_macd || {}; - const elementCm = currentData.element_chan_macd || {}; - - const mainMarkers = []; - const elementMarkers = []; - - // 基于时间构建 MACD 值映射,便于按时间快速获取对应的 MACD 值 - const buildMacdTimeMap = (macdObj, klineArr) => { - const map = new Map(); - if (!macdObj || !klineArr || !Array.isArray(klineArr)) return map; - for (let i = 0; i < klineArr.length; i++) { - const k = klineArr[i]; - if (!k || !k.date) continue; - const t = Math.floor(new Date(k.date).getTime() / 1000); - const val = (macdObj.macd && macdObj.macd[i] !== undefined && macdObj.macd[i] !== null) ? macdObj.macd[i] : null; - map.set(t, val); - } - return map; - }; - const mainMacdMap = buildMacdTimeMap(currentData.macd, currentData.kline_data); - const elementMacdMap = buildMacdTimeMap( - (currentData.element_macd || currentData.macd), - (currentData.element_kline_data || currentData.kline_data) - ); - - // 主周期 U 标记(蓝/橙,与原样式一致) - if (window.showUOnMain && Array.isArray(mainCm.klu_list)) { - mainCm.klu_list.forEach((item) => { - if (!item || !item.time) return; - const ts = Math.floor(new Date(item.time).getTime() / 1000); - if (isNaN(ts)) return; - if (Number(item.separate_div) > 0) { - const macdVal = mainMacdMap.get(ts); - const posSd = (macdVal > 0) ? 'aboveBar' : (macdVal < 0) ? 'belowBar' : 'aboveBar'; - mainMarkers.push({ time: ts, position: posSd, color: '#03a9f4', shape: 'arrowUp', text: `SD${Number(item.separate_div)}`, size: 0.6 }); - } - if (item.continue_div === true) { - const macdVal = mainMacdMap.get(ts); - const posCd = (macdVal > 0) ? 'aboveBar' : (macdVal < 0) ? 'belowBar' : 'belowBar'; - mainMarkers.push({ time: ts, position: posCd, color: '#ff9800', shape: 'arrowDown', text: 'CD', size: 0.6 }); - } - if (item.near0_return && Number(item.near0_return) > 0) { - mainMarkers.push({ time: ts, position: 'belowBar', color: '#8bc34a', shape: 'circle', text: `${Number(item.near0_return)}`, size: 0.6 }); - } - }); - } - - // 次周期 U 标记(使用不同配色以区分) - if (window.showUOnElement && Array.isArray(elementCm.klu_list)) { - elementCm.klu_list.forEach((item) => { - if (!item || !item.time) return; - const ts = Math.floor(new Date(item.time).getTime() / 1000); - if (isNaN(ts)) return; - if (Number(item.separate_div) > 0) { - const macdVal = elementMacdMap.get(ts); - const posSd = (macdVal > 0) ? 'aboveBar' : (macdVal < 0) ? 'belowBar' : 'aboveBar'; - elementMarkers.push({ time: ts, position: posSd, color: '#9c27b0', shape: 'arrowUp', text: `SD${Number(item.separate_div)}`, size: 0.6 }); - } - if (item.continue_div === true) { - const macdVal = elementMacdMap.get(ts); - const posCd = (macdVal > 0) ? 'aboveBar' : (macdVal < 0) ? 'belowBar' : 'belowBar'; - elementMarkers.push({ time: ts, position: posCd, color: '#4caf50', shape: 'arrowDown', text: 'CD', size: 0.6 }); - } - if (item.near0_return && Number(item.near0_return) > 0) { - elementMarkers.push({ time: ts, position: 'belowBar', color: '#009688', shape: 'circle', text: `${Number(item.near0_return)}`, size: 0.6 }); - } - }); - } - - const subSubCm = currentData.sub_sub_chan_macd || {}; - const subSubMarkers = []; - const subSubMacdMap = buildMacdTimeMap(currentData.macd, currentData.kline_data); - if (window.showUOnSubSub && Array.isArray(subSubCm.klu_list)) { - subSubCm.klu_list.forEach((item) => { - if (!item || !item.time) return; - const ts = Math.floor(new Date(item.time).getTime() / 1000); - if (isNaN(ts)) return; - if (Number(item.separate_div) > 0) { - const macdVal = subSubMacdMap.get(ts); - const posSd = (macdVal > 0) ? 'aboveBar' : (macdVal < 0) ? 'belowBar' : 'aboveBar'; - subSubMarkers.push({ time: ts, position: posSd, color: '#00897b', shape: 'arrowUp', text: `SD${Number(item.separate_div)}`, size: 0.6 }); - } - if (item.continue_div === true) { - const macdVal = subSubMacdMap.get(ts); - const posCd = (macdVal > 0) ? 'aboveBar' : (macdVal < 0) ? 'belowBar' : 'belowBar'; - subSubMarkers.push({ time: ts, position: posCd, color: '#26a69a', shape: 'arrowDown', text: 'CD', size: 0.6 }); - } - if (item.near0_return && Number(item.near0_return) > 0) { - subSubMarkers.push({ time: ts, position: 'belowBar', color: '#00695c', shape: 'circle', text: `${Number(item.near0_return)}`, size: 0.6 }); - } - }); - } - window.kluDivMarkersSubSub = subSubMarkers; - - // 保存到全局,供主图合并标记使用 - window.kluDivMarkersMain = mainMarkers; - window.kluDivMarkersElement = elementMarkers; - } catch (e) { - console.warn('处理 KLU 背驰标记出错:', e); - window.kluDivMarkersMain = []; - window.kluDivMarkersElement = []; - window.kluDivMarkersSubSub = []; - } - } else { - console.log('⚠️ 没有ChanMACD分析数据'); - // 无数据时清空本次的 KLU 背驰标记 - window.kluDivMarkersMain = []; - window.kluDivMarkersElement = []; - window.kluDivMarkersSubSub = []; - } - } else { - console.log('⚠️ ChanMACD图表创建条件不满足'); - } - // 独立于当前显示周期:计算主/次周期 SD/CD 标记(用于主图合并显示) - try { - const mainCmAll = currentData.chan_macd || {}; - const elementCmAll = currentData.element_chan_macd || {}; - const mainMarkersAll = []; - const elementMarkersAll = []; - - // 构建 MACD 时间映射,用于依据 MACD 正负决定 SD/CD 的显示上下位置 - const buildMacdTimeMapAll = (macdObj, klineArr) => { - const map = new Map(); - if (!macdObj || !klineArr || !Array.isArray(klineArr)) return map; - for (let i = 0; i < klineArr.length; i++) { - const k = klineArr[i]; - if (!k || !k.date) continue; - const t = Math.floor(new Date(k.date).getTime() / 1000); - const val = (macdObj.macd && macdObj.macd[i] !== undefined && macdObj.macd[i] !== null) ? macdObj.macd[i] : null; - map.set(t, val); - } - return map; - }; - const mainMacdMapAll = buildMacdTimeMapAll(currentData.macd, currentData.kline_data); - const elementMacdMapAll = buildMacdTimeMapAll( - (currentData.element_macd || currentData.macd), - (currentData.element_kline_data || currentData.kline_data) - ); - if ((typeof window.showUOnMain === 'undefined' ? false : window.showUOnMain) && Array.isArray(mainCmAll.klu_list)) { - mainCmAll.klu_list.forEach((item) => { - if (!item || !item.time) return; - const ts = Math.floor(new Date(item.time).getTime() / 1000); - if (isNaN(ts)) return; - if (Number(item.separate_div) > 0) { - const macdVal = mainMacdMapAll.get(ts); - const posSd = (macdVal > 0) ? 'aboveBar' : (macdVal < 0) ? 'belowBar' : 'aboveBar'; - mainMarkersAll.push({ time: ts, position: posSd, color: '#03a9f4', shape: 'arrowUp', text: `SD${Number(item.separate_div)}`, size: 0.6 }); - } - if (item.continue_div === true) { - const macdVal = mainMacdMapAll.get(ts); - const posCd = (macdVal > 0) ? 'aboveBar' : (macdVal < 0) ? 'belowBar' : 'belowBar'; - mainMarkersAll.push({ time: ts, position: posCd, color: '#ff9800', shape: 'arrowDown', text: 'CD', size: 0.6 }); - } - if (item.near0_return && Number(item.near0_return) > 0) { - mainMarkersAll.push({ time: ts, position: 'belowBar', color: '#8bc34a', shape: 'circle', text: `${Number(item.near0_return)}`, size: 0.6 }); - } - }); - } - if ((typeof window.showUOnElement === 'undefined' ? false : window.showUOnElement) && Array.isArray(elementCmAll.klu_list)) { - elementCmAll.klu_list.forEach((item) => { - if (!item || !item.time) return; - const ts = Math.floor(new Date(item.time).getTime() / 1000); - if (isNaN(ts)) return; - if (Number(item.separate_div) > 0) { - const macdVal = elementMacdMapAll.get(ts); - const posSd = (macdVal > 0) ? 'aboveBar' : (macdVal < 0) ? 'belowBar' : 'aboveBar'; - elementMarkersAll.push({ time: ts, position: posSd, color: '#9c27b0', shape: 'arrowUp', text: `SD${Number(item.separate_div)}`, size: 0.6 }); - } - if (item.continue_div === true) { - const macdVal = elementMacdMapAll.get(ts); - const posCd = (macdVal > 0) ? 'aboveBar' : (macdVal < 0) ? 'belowBar' : 'belowBar'; - elementMarkersAll.push({ time: ts, position: posCd, color: '#4caf50', shape: 'arrowDown', text: 'CD', size: 0.6 }); - } - if (item.near0_return && Number(item.near0_return) > 0) { - elementMarkersAll.push({ time: ts, position: 'belowBar', color: '#009688', shape: 'circle', text: `${Number(item.near0_return)}`, size: 0.6 }); - } - }); - } - window.kluDivMarkersMain = mainMarkersAll; - window.kluDivMarkersElement = elementMarkersAll; - const subSubCmAll = currentData.sub_sub_chan_macd || {}; - const subSubMarkersAll = []; - if (window.showUOnSubSub && Array.isArray(subSubCmAll.klu_list)) { - subSubCmAll.klu_list.forEach((item) => { - if (!item || !item.time) return; - const ts = Math.floor(new Date(item.time).getTime() / 1000); - if (isNaN(ts)) return; - if (Number(item.separate_div) > 0) { - subSubMarkersAll.push({ time: ts, position: 'aboveBar', color: '#00897b', shape: 'arrowUp', text: `SD${Number(item.separate_div)}`, size: 0.6 }); - } - if (item.continue_div === true) { - subSubMarkersAll.push({ time: ts, position: 'belowBar', color: '#26a69a', shape: 'arrowDown', text: 'CD', size: 0.6 }); - } - if (item.near0_return && Number(item.near0_return) > 0) { - subSubMarkersAll.push({ time: ts, position: 'belowBar', color: '#00695c', shape: 'circle', text: `${Number(item.near0_return)}`, size: 0.6 }); - } - }); - } - window.kluDivMarkersSubSub = subSubMarkersAll; - } catch (e) { - console.warn('独立计算 KLU 背驰标记出错:', e); - window.kluDivMarkersMain = []; - window.kluDivMarkersElement = []; - window.kluDivMarkersSubSub = []; - } - - // 图表同步事件统一由文末 bindSyncEvents 注册(带 cleanup),此处不再重复 addEventListener, - // 否则每次自动刷新/重建都会在 document/window 上堆积监听导致内存泄漏。 - // 显示笔的绘制 - 分别处理主周期、次周期和次次周期 - if ($('#showMainBi').is(':checked') || $('#showElementBi').is(':checked') || $('#showSubSubBi').is(':checked')) { - console.log('绘制笔 - 已启用'); - let biLines = []; - - // 主周期笔 - if ($('#showMainBi').is(':checked') && currentData.bi_list && currentData.bi_list.length > 0) { - console.log(`绘制主周期笔数据,共${currentData.bi_list.length}条`); - - // 清空已有的主周期笔系列 - tvWidget.series.mainBiSeries = []; - tvWidget.series.mainUncompletedBiSeries = []; - - // 遍历处理每个笔 - currentData.bi_list.forEach(function(bi) { - try { - // 直接使用UTC时间戳(秒) - const startTime = Math.floor(new Date(bi.start_time).getTime() / 1000); - const endTime = Math.floor(new Date(bi.end_time).getTime() / 1000); - - if (isNaN(startTime) || isNaN(endTime)) { - console.error('主周期笔时间转换错误:', bi.start_time, bi.end_time); - return; - } - - const startPrice = parseFloat(bi.start_price); - const endPrice = parseFloat(bi.end_price); - - if (isNaN(startPrice) || isNaN(endPrice)) { - console.error('主周期笔价格转换错误:', bi.start_price, bi.end_price); - return; - } - - // 添加线段 - biLines.push({ - startTime: startTime, - endTime: endTime, - startPrice: startPrice, - endPrice: endPrice, - color: bi.direction === 1 ? '#dc3545' : '#28a745', // 主周期笔颜色 - lineWidth: 1, - }); - - // 添加到图表对象 - tvWidget.series.mainBiSeries.push({ - time: startTime, - value: startPrice, - color: bi.direction === 1 ? '#dc3545' : '#28a745', - lineWidth: 1 - }); - - // 在笔的末端添加macd_div值标记 - if (bi.macd_div && bi.macd_div !== 0 && $('#showMainMacdDiv').is(':checked')) { - console.log(`添加主周期macd_div标记: ${bi.macd_div.toFixed(2)}, 在时间点: ${endTime}`); - - const macdDivLabel = mainChart.addLineSeries({ - lastValueVisible: false, - priceLineVisible: false, - color: 'transparent', // 设置为透明色 - lineWidth: 0, // 线宽为0 - }); - - // 添加一个透明的数据点用于承载标记 - macdDivLabel.setData([ - { time: endTime, value: endPrice } - ]); - - // 主周期MACD背离标记根据笔方向显示,远离K线避免与分型重叠 - const markerPosition = bi.direction === 1 ? 'aboveBar' : 'belowBar'; - const textColor = bi.macd_div > 0 ? '#dc3545' : '#28a745'; - - // 只使用标记,不添加数据点 - macdDivLabel.setMarkers([ - { - time: endTime, - position: markerPosition, - color: textColor, - text: `${bi.macd_div.toFixed(2)}`, // 添加M前缀区分 - size: 0.6, // 更小的尺寸,远离分型标记 - } - ]); - } - } catch (e) { - console.error('主周期笔处理出错:', e); - } - }); - } - - // 次周期笔 - if ($('#showElementBi').is(':checked') && currentData.element_bi_list && currentData.element_bi_list.length > 0) { - console.log(`绘制次周期笔数据,共${currentData.element_bi_list.length}条`); - - // 清空已有的次周期笔系列 - tvWidget.series.elementBiSeries = []; - tvWidget.series.elementUncompletedBiSeries = []; - - currentData.element_bi_list.forEach(function(bi) { - try { - // 直接使用UTC时间戳(秒) - const startTime = Math.floor(new Date(bi.start_time).getTime() / 1000); - const endTime = Math.floor(new Date(bi.end_time).getTime() / 1000); - - if (isNaN(startTime) || isNaN(endTime)) { - console.error('次周期笔时间转换错误:', bi.start_time, bi.end_time); - return; - } - - const startPrice = parseFloat(bi.start_price); - const endPrice = parseFloat(bi.end_price); - - if (isNaN(startPrice) || isNaN(endPrice)) { - console.error('次周期笔价格转换错误:', bi.start_price, bi.end_price); - return; - } - - // 添加线段 - biLines.push({ - startTime: startTime, - endTime: endTime, - startPrice: startPrice, - endPrice: endPrice, - color: bi.direction === 1 ? '#9c27b0' : '#673ab7', // 次周期笔颜色 - lineWidth: 1, - }); - - // 添加到图表对象 - tvWidget.series.elementBiSeries.push({ - time: startTime, - value: startPrice, - color: bi.direction === 1 ? '#9c27b0' : '#673ab7', - lineWidth: 1 - }); - - // 在笔的末端添加macd_div值标记 - if (bi.macd_div && bi.macd_div !== 0 && $('#showElementMacdDiv').is(':checked')) { - console.log(`添加元素周期macd_div标记: ${bi.macd_div.toFixed(2)}, 在时间点: ${endTime}`); - - const macdDivLabel = mainChart.addLineSeries({ - lastValueVisible: false, - priceLineVisible: false, - color: 'transparent', // 设置为透明色 - lineWidth: 0, // 线宽为0 - }); - - // 添加一个透明的数据点用于承载标记 - macdDivLabel.setData([ - { time: endTime, value: endPrice } - ]); - - // 次周期MACD背离标记使用不同位置,进一步避免重叠 - const markerPosition = bi.direction === 1 ? 'aboveBar' : 'belowBar'; - const textColor = bi.macd_div > 0 ? '#9c27b0' : '#673ab7'; - - // 只使用标记,不添加数据点 - macdDivLabel.setMarkers([ - { - time: endTime, - position: markerPosition, - color: textColor, - text: `${bi.macd_div.toFixed(2)}`, // 添加E前缀区分次周期 - size: 0.4, // 更小的尺寸,让分型标记有更多空间 - } - ]); - } - } catch (e) { - console.error('次周期笔处理出错:', e); - } - }); - } - - // 绘制未完成笔 - 主周期 - if ($('#showMainBi').is(':checked') && currentData.uncompleted_bi_list && currentData.uncompleted_bi_list.length > 0) { - console.log(`绘制主周期未完成笔数据,共${currentData.uncompleted_bi_list.length}条`); - - currentData.uncompleted_bi_list.forEach(function(bi) { - try { - // 直接使用UTC时间戳(秒) - const startTime = Math.floor(new Date(bi.start_time).getTime() / 1000); - // 未完成笔的结束时间设为当前K线的最后时间 - const endTime = Math.floor(new Date(currentData.kline_data[currentData.kline_data.length-1].date).getTime() / 1000); - - if (isNaN(startTime) || isNaN(endTime)) { - console.error('主周期未完成笔时间转换错误:', bi.start_time); - return; - } - - const startPrice = parseFloat(bi.start_price); - - if (isNaN(startPrice)) { - console.error('主周期未完成笔价格转换错误:', bi.start_price); - return; - } - - // 根据笔的方向确定终点价格 - let endPrice; - const latestKline = currentData.kline_data[currentData.kline_data.length-1]; - if (bi.direction === 1) { - // 向上笔,终点为最新K线的最高点 - endPrice = parseFloat(latestKline.high); - } else { - // 向下笔,终点为最新K线的最低点 - endPrice = parseFloat(latestKline.low); - } - - // 添加未完成笔(红色虚线) - biLines.push({ - startTime: startTime, - endTime: endTime, - startPrice: startPrice, - endPrice: endPrice, - color: '#FF0000', // 红色 - lineWidth: 1, - lineStyle: 2 // 虚线 - }); - - // 添加到图表对象 - tvWidget.series.mainUncompletedBiSeries.push({ - time: startTime, - value: startPrice, - color: '#FF0000', - lineWidth: 1, - lineStyle: 2 - }); - } catch (e) { - console.error('主周期未完成笔处理出错:', e); - } - }); - } - - // 绘制未完成笔 - 次周期 - if ($('#showElementBi').is(':checked') && currentData.element_uncompleted_bi_list && currentData.element_uncompleted_bi_list.length > 0) { - console.log(`绘制次周期未完成笔数据,共${currentData.element_uncompleted_bi_list.length}条`); - - currentData.element_uncompleted_bi_list.forEach(function(bi) { - try { - // 直接使用UTC时间戳(秒) - const startTime = Math.floor(new Date(bi.start_time).getTime() / 1000); - // 未完成笔的结束时间设为当前K线的最后时间 - const endTime = Math.floor(new Date(currentData.kline_data[currentData.kline_data.length-1].date).getTime() / 1000); - - if (isNaN(startTime) || isNaN(endTime)) { - console.error('次周期未完成笔时间转换错误:', bi.start_time); - return; - } - - const startPrice = parseFloat(bi.start_price); - - if (isNaN(startPrice)) { - console.error('次周期未完成笔价格转换错误:', bi.start_price); - return; - } - - // 根据笔的方向确定终点价格 - let endPrice; - const latestKline = currentData.kline_data[currentData.kline_data.length-1]; - if (bi.direction === 1) { - // 向上笔,终点为最新K线的最高点 - endPrice = parseFloat(latestKline.high); - } else { - // 向下笔,终点为最新K线的最低点 - endPrice = parseFloat(latestKline.low); - } - - // 添加未完成笔(红色虚线) - biLines.push({ - startTime: startTime, - endTime: endTime, - startPrice: startPrice, - endPrice: endPrice, - color: '#FF0000', // 红色 - lineWidth: 1, - lineStyle: 2 // 虚线 - }); - - // 添加到图表对象 - tvWidget.series.elementUncompletedBiSeries.push({ - time: startTime, - value: startPrice, - color: '#FF0000', - lineWidth: 1, - lineStyle: 2 - }); - } catch (e) { - console.error('次周期未完成笔处理出错:', e); - } - }); - } - - // 次次周期笔 - if ($('#showSubSubBi').is(':checked') && currentData.sub_sub_bi_list && currentData.sub_sub_bi_list.length > 0) { - tvWidget.series.subSubBiSeries = []; - tvWidget.series.subSubUncompletedBiSeries = []; - currentData.sub_sub_bi_list.forEach(function(bi) { - try { - const startTime = Math.floor(new Date(bi.start_time).getTime() / 1000); - const endTime = bi.end_time ? Math.floor(new Date(bi.end_time).getTime() / 1000) : 0; - if (isNaN(startTime) || !endTime) return; - const startPrice = parseFloat(bi.start_price); - const endPrice = parseFloat(bi.end_price); - if (isNaN(startPrice) || isNaN(endPrice)) return; - biLines.push({ - startTime: startTime, endTime: endTime, startPrice: startPrice, endPrice: endPrice, - color: bi.direction === 1 ? '#00897b' : '#26a69a', lineWidth: 1, lineStyle: 0 - }); - tvWidget.series.subSubBiSeries.push({ time: startTime, value: startPrice, color: '#00897b', lineWidth: 1 }); - } catch (e) { console.error('次次周期笔处理出错:', e); } - }); - } - // 次次周期未完成笔 - if ($('#showSubSubBi').is(':checked') && currentData.sub_sub_uncompleted_bi_list && currentData.sub_sub_uncompleted_bi_list.length > 0) { - if (!tvWidget.series.subSubBiSeries) tvWidget.series.subSubBiSeries = []; - if (!tvWidget.series.subSubUncompletedBiSeries) tvWidget.series.subSubUncompletedBiSeries = []; - const klineData = currentData.kline_data || []; - const lastTime = klineData.length ? Math.floor(new Date(klineData[klineData.length-1].date).getTime() / 1000) : 0; - currentData.sub_sub_uncompleted_bi_list.forEach(function(bi) { - try { - const startTime = Math.floor(new Date(bi.start_time).getTime() / 1000); - if (isNaN(startTime) || !lastTime) return; - const startPrice = parseFloat(bi.start_price); - if (isNaN(startPrice)) return; - const lastK = klineData[klineData.length-1]; - const endPrice = bi.direction === 1 ? parseFloat(lastK.high) : parseFloat(lastK.low); - biLines.push({ - startTime: startTime, endTime: lastTime, startPrice: startPrice, endPrice: endPrice, - color: '#00695c', lineWidth: 1, lineStyle: 2 - }); - tvWidget.series.subSubUncompletedBiSeries.push({ time: startTime, value: startPrice, color: '#00695c', lineWidth: 1, lineStyle: 2 }); - } catch (e) { console.error('次次周期未完成笔处理出错:', e); } - }); - } - - // 添加所有笔到图表 - // 1m 等小周期叠加到大周期时,笔数量会非常大;每条笔创建一个 series 会导致主图渲染退化 - // 这里限制绘制数量,优先保留最新笔,避免把主K线和其他元素“挤没” - const MAX_BI_LINE_SERIES = 800; - if (biLines.length > MAX_BI_LINE_SERIES) { - console.warn(`BI线条过多(${biLines.length}),仅绘制最新 ${MAX_BI_LINE_SERIES} 条以保障主图稳定`); - } - const linesToDraw = biLines.length > MAX_BI_LINE_SERIES - ? biLines.slice(-MAX_BI_LINE_SERIES) - : biLines; - - linesToDraw.forEach(line => { - try { - if (!Number.isFinite(line.startTime) || !Number.isFinite(line.endTime)) return; - if (!Number.isFinite(line.startPrice) || !Number.isFinite(line.endPrice)) return; - if (line.endTime <= line.startTime) return; - - const lineSeries = mainChart.addLineSeries({ - color: line.color, - lineWidth: line.lineWidth, - lineStyle: line.lineStyle || 0, // 支持虚线样式 - lastValueVisible: false, - priceLineVisible: false, - }); - - lineSeries.setData([ - { time: line.startTime, value: line.startPrice }, - { time: line.endTime, value: line.endPrice } - ]); - } catch (e) { - console.warn('绘制BI线段失败,已跳过单条异常数据:', e); - } - }); - } else { - console.log('绘制笔 - 已禁用'); - } - // 显示线段的绘制 - 分别处理主周期、次周期和次次周期 - if ($('#showMainSeg').is(':checked') || $('#showElementSeg').is(':checked') || $('#showSubSubSeg').is(':checked')) { - console.log('绘制线段 - 已启用'); - let segLines = []; - - // 主周期线段 - if ($('#showMainSeg').is(':checked') && currentData.seg_list && currentData.seg_list.length > 0) { - console.log(`绘制主周期线段数据,共${currentData.seg_list.length}条`); - - // 清空已有的主周期线段系列 - tvWidget.series.mainSegSeries = []; - tvWidget.series.mainUncompletedSegSeries = []; - - currentData.seg_list.forEach(function(seg) { - try { - // 直接使用UTC时间戳(秒) - const startTime = Math.floor(new Date(seg.start_time).getTime() / 1000); - const endTime = Math.floor(new Date(seg.end_time).getTime() / 1000); - - if (isNaN(startTime) || isNaN(endTime)) { - console.error('主周期线段时间转换错误:', seg.start_time, seg.end_time); - return; - } - - const startPrice = parseFloat(seg.start_price); - const endPrice = parseFloat(seg.end_price); - - if (isNaN(startPrice) || isNaN(endPrice)) { - console.error('主周期线段价格转换错误:', seg.start_price, seg.end_price); - return; - } - - // 添加线段 - segLines.push({ - startTime: startTime, - endTime: endTime, - startPrice: startPrice, - endPrice: endPrice, - color: seg.direction === 1 ? '#FF6B6B' : '#4CAF50', // 主周期线段颜色 - lineWidth: 2, - }); - - // 添加到图表对象 - tvWidget.series.mainSegSeries.push({ - time: startTime, - value: startPrice, - color: seg.direction === 1 ? '#FF6B6B' : '#4CAF50', - lineWidth: 2 - }); - } catch (e) { - console.error('主周期线段处理出错:', e); - } - }); - } - - // 次周期线段 - if ($('#showElementSeg').is(':checked') && currentData.element_seg_list && currentData.element_seg_list.length > 0) { - console.log(`绘制次周期线段数据,共${currentData.element_seg_list.length}条`); - - // 清空已有的次周期线段系列 - tvWidget.series.elementSegSeries = []; - tvWidget.series.elementUncompletedSegSeries = []; - - currentData.element_seg_list.forEach(function(seg) { - try { - // 直接使用UTC时间戳(秒) - const startTime = Math.floor(new Date(seg.start_time).getTime() / 1000); - const endTime = Math.floor(new Date(seg.end_time).getTime() / 1000); - - if (isNaN(startTime) || isNaN(endTime)) { - console.error('次周期线段时间转换错误:', seg.start_time, seg.end_time); - return; - } - - const startPrice = parseFloat(seg.start_price); - const endPrice = parseFloat(seg.end_price); - - if (isNaN(startPrice) || isNaN(endPrice)) { - console.error('次周期线段价格转换错误:', seg.start_price, seg.end_price); - return; - } - - // 添加线段 - segLines.push({ - startTime: startTime, - endTime: endTime, - startPrice: startPrice, - endPrice: endPrice, - color: seg.direction === 1 ? '#673ab7' : '#9c27b0', // 次周期线段颜色 - lineWidth: 2, - }); - - // 添加到图表对象 - tvWidget.series.elementSegSeries.push({ - time: startTime, - value: startPrice, - color: seg.direction === 1 ? '#673ab7' : '#9c27b0', - lineWidth: 2 - }); - } catch (e) { - console.error('次周期线段处理出错:', e); - } - }); - } - - // 绘制未完成线段 - 主周期 - if ($('#showMainSeg').is(':checked') && currentData.uncompleted_seg_list && currentData.uncompleted_seg_list.length > 0) { - console.log(`绘制主周期未完成线段数据,共${currentData.uncompleted_seg_list.length}条`); - - currentData.uncompleted_seg_list.forEach(function(seg) { - try { - // 直接使用UTC时间戳(秒) - const startTime = Math.floor(new Date(seg.start_time).getTime() / 1000); - - if (isNaN(startTime)) { - console.error('主周期未完成线段时间转换错误:', seg.start_time); - return; - } - - const startPrice = parseFloat(seg.start_price); - - if (isNaN(startPrice)) { - console.error('主周期未完成线段价格转换错误:', seg.start_price); - return; - } - - let endTime, endPrice; - - if (seg.end_time && seg.end_price) { - // 有结束时间和价格的未完成线段(倒数第二个等) - endTime = Math.floor(new Date(seg.end_time).getTime() / 1000); - endPrice = parseFloat(seg.end_price); - - if (isNaN(endTime) || isNaN(endPrice)) { - console.error('主周期未完成线段结束时间或价格转换错误:', seg.end_time, seg.end_price); - return; - } - } else { - // 没有结束时间和价格的未完成线段(最后一个) - endTime = Math.floor(new Date(currentData.kline_data[currentData.kline_data.length-1].date).getTime() / 1000); - - if (isNaN(endTime)) { - console.error('主周期未完成线段结束时间转换错误'); - return; - } - - // 根据线段的方向确定终点价格 - const latestKline = currentData.kline_data[currentData.kline_data.length-1]; - if (seg.direction === 1) { - // 向上线段,终点为最新K线的最高点 - endPrice = parseFloat(latestKline.high); - } else { - // 向下线段,终点为最新K线的最低点 - endPrice = parseFloat(latestKline.low); - } - } - - // 添加未完成线段(红色虚线) - segLines.push({ - startTime: startTime, - endTime: endTime, - startPrice: startPrice, - endPrice: endPrice, - color: '#FF0000', // 红色 - lineWidth: 2, - lineStyle: 2 // 虚线 - }); - - // 添加到图表对象 - tvWidget.series.mainUncompletedSegSeries.push({ - time: startTime, - value: startPrice, - color: '#FF0000', - lineWidth: 2, - lineStyle: 2 - }); - } catch (e) { - console.error('主周期未完成线段处理出错:', e); - } - }); - } - - // 绘制未完成线段 - 次周期 - if ($('#showElementSeg').is(':checked') && currentData.element_uncompleted_seg_list && currentData.element_uncompleted_seg_list.length > 0) { - console.log(`绘制次周期未完成线段数据,共${currentData.element_uncompleted_seg_list.length}条`); - - currentData.element_uncompleted_seg_list.forEach(function(seg) { - try { - // 直接使用UTC时间戳(秒) - const startTime = Math.floor(new Date(seg.start_time).getTime() / 1000); - - if (isNaN(startTime)) { - console.error('次周期未完成线段时间转换错误:', seg.start_time); - return; - } - - const startPrice = parseFloat(seg.start_price); - - if (isNaN(startPrice)) { - console.error('次周期未完成线段价格转换错误:', seg.start_price); - return; - } - - let endTime, endPrice; - - if (seg.end_time && seg.end_price) { - // 有结束时间和价格的未完成线段(倒数第二个等) - endTime = Math.floor(new Date(seg.end_time).getTime() / 1000); - endPrice = parseFloat(seg.end_price); - - if (isNaN(endTime) || isNaN(endPrice)) { - console.error('次周期未完成线段结束时间或价格转换错误:', seg.end_time, seg.end_price); - return; - } - } else { - // 没有结束时间和价格的未完成线段(最后一个) - endTime = Math.floor(new Date(currentData.kline_data[currentData.kline_data.length-1].date).getTime() / 1000); - - if (isNaN(endTime)) { - console.error('次周期未完成线段结束时间转换错误'); - return; - } - - // 根据线段的方向确定终点价格 - const latestKline = currentData.kline_data[currentData.kline_data.length-1]; - if (seg.direction === 1) { - // 向上线段,终点为最新K线的最高点 - endPrice = parseFloat(latestKline.high); - } else { - // 向下线段,终点为最新K线的最低点 - endPrice = parseFloat(latestKline.low); - } - } - - // 添加未完成线段(红色虚线) - segLines.push({ - startTime: startTime, - endTime: endTime, - startPrice: startPrice, - endPrice: endPrice, - color: '#FF0000', // 红色 - lineWidth: 2, - lineStyle: 2 // 虚线 - }); - - // 添加到图表对象 - tvWidget.series.elementUncompletedSegSeries.push({ - time: startTime, - value: startPrice, - color: '#FF0000', - lineWidth: 2, - lineStyle: 2 - }); - } catch (e) { - console.error('次周期未完成线段处理出错:', e); - } - }); - } - - // 次次周期线段 - if ($('#showSubSubSeg').is(':checked') && currentData.sub_sub_seg_list && currentData.sub_sub_seg_list.length > 0) { - tvWidget.series.subSubSegSeries = []; - tvWidget.series.subSubUncompletedSegSeries = []; - currentData.sub_sub_seg_list.forEach(function(seg) { - try { - const startTime = Math.floor(new Date(seg.start_time).getTime() / 1000); - const endTime = seg.end_time ? Math.floor(new Date(seg.end_time).getTime() / 1000) : 0; - if (isNaN(startTime) || !endTime) return; - const startPrice = parseFloat(seg.start_price); - const endPrice = parseFloat(seg.end_price); - if (isNaN(startPrice) || isNaN(endPrice)) return; - segLines.push({ - startTime: startTime, endTime: endTime, startPrice: startPrice, endPrice: endPrice, - color: seg.direction === 1 ? '#00897b' : '#26a69a', lineWidth: 2, lineStyle: 0 - }); - tvWidget.series.subSubSegSeries.push({ time: startTime, value: startPrice, color: '#00897b', lineWidth: 2 }); - } catch (e) { console.error('次次周期线段处理出错:', e); } - }); - } - // 次次周期未完成线段 - if ($('#showSubSubSeg').is(':checked') && currentData.sub_sub_uncompleted_seg_list && currentData.sub_sub_uncompleted_seg_list.length > 0) { - if (!tvWidget.series.subSubUncompletedSegSeries) tvWidget.series.subSubUncompletedSegSeries = []; - const klineDataSeg = currentData.kline_data || []; - const lastTimeSeg = klineDataSeg.length ? Math.floor(new Date(klineDataSeg[klineDataSeg.length-1].date).getTime() / 1000) : 0; - currentData.sub_sub_uncompleted_seg_list.forEach(function(seg) { - try { - const startTime = Math.floor(new Date(seg.start_time).getTime() / 1000); - if (isNaN(startTime) || !lastTimeSeg) return; - const startPrice = parseFloat(seg.start_price); - if (isNaN(startPrice)) return; - let endTime = lastTimeSeg, endPrice; - if (seg.end_time && seg.end_price) { - endTime = Math.floor(new Date(seg.end_time).getTime() / 1000); - endPrice = parseFloat(seg.end_price); - } else { - const lastK = klineDataSeg[klineDataSeg.length-1]; - endPrice = seg.direction === 1 ? parseFloat(lastK.high) : parseFloat(lastK.low); - } - segLines.push({ - startTime: startTime, endTime: endTime, startPrice: startPrice, endPrice: endPrice, - color: '#00695c', lineWidth: 2, lineStyle: 2 - }); - tvWidget.series.subSubUncompletedSegSeries.push({ time: startTime, value: startPrice, color: '#00695c', lineWidth: 2 }); - } catch (e) { console.error('次次周期未完成线段处理出错:', e); } - }); - } - - // 添加所有线段到图表 - segLines.forEach(line => { - const lineSeries = mainChart.addLineSeries({ - color: line.color, - lineWidth: line.lineWidth, - lineStyle: line.lineStyle || 0, // 支持虚线样式 - lastValueVisible: false, - priceLineVisible: false, - }); - - lineSeries.setData([ - { time: line.startTime, value: line.startPrice }, - { time: line.endTime, value: line.endPrice } - ]); - }); - } else { - console.log('绘制线段 - 已禁用'); - } - // 显示中枢的绘制 - 分别处理主周期、次周期和次次周期(包含BI中枢,沿用同样样式与开关) - if ($('#showMainZs').is(':checked') || $('#showElementZs').is(':checked') || $('#showSubSubZs').is(':checked')) { - console.log('绘制中枢 - 已启用'); - - // 主周期中枢 - if ($('#showMainZs').is(':checked') && currentData.zs_list && currentData.zs_list.length > 0) { - console.log(`绘制主周期中枢数据,共${currentData.zs_list.length}条`); - - currentData.zs_list.forEach(function(zs) { - try { - // 直接使用UTC时间戳(秒) - const startTime = Math.floor(new Date(zs.start_time).getTime() / 1000); - const endTime = Math.floor(new Date(zs.end_time).getTime() / 1000); - - if (isNaN(startTime) || isNaN(endTime)) { - console.error('主周期中枢时间转换错误:', zs.start_time, zs.end_time); - return; - } - - const zg = parseFloat(zs.zg); // 中枢上沿 - const zd = parseFloat(zs.zd); // 中枢下沿 - const gg = parseFloat(zs.gg); // 中枢高高 - const dd = parseFloat(zs.dd); // 中枢低低 - - if (isNaN(zg) || isNaN(zd)) { - console.error('主周期中枢价格转换错误:', zs.zg, zs.zd); - return; - } - - // 创建中枢上边界 - const topSeries = mainChart.addLineSeries({ - color: '#F1C40F', // 主周期中枢颜色 - lineWidth: 1, - lastValueVisible: false, - priceLineVisible: false, - }); - - topSeries.setData([ - { time: startTime, value: zg }, - { time: endTime, value: zg } - ]); - - // 为下边界创建另一条线 - const bottomSeries = mainChart.addLineSeries({ - color: '#F1C40F', // 主周期中枢颜色 - lineWidth: 1, - lastValueVisible: false, - priceLineVisible: false, - }); - - bottomSeries.setData([ - { time: startTime, value: zd }, - { time: endTime, value: zd } - ]); - - // 添加左边界 - const leftSeries = mainChart.addLineSeries({ - color: '#F1C40F', // 主周期中枢颜色 - lineWidth: 1, - lastValueVisible: false, - priceLineVisible: false, - }); - - leftSeries.setData([ - { time: startTime, value: zd }, - { time: startTime, value: zg } - ]); - - // 添加右边界 - const rightSeries = mainChart.addLineSeries({ - color: '#F1C40F', // 主周期中枢颜色 - lineWidth: 1, - lastValueVisible: false, - priceLineVisible: false, - }); - - rightSeries.setData([ - { time: endTime, value: zd }, - { time: endTime, value: zg } - ]); - - // 绘制gg线(中枢高高) - if (!isNaN(gg) && gg > 0) { - const ggSeries = mainChart.addLineSeries({ - color: '#F1C40F', // 使用中枢自己的颜色 - lineWidth: 1, - lastValueVisible: false, - priceLineVisible: false, - }); - - ggSeries.setData([ - { time: startTime, value: gg }, - { time: endTime, value: gg } - ]); - } - - // 绘制dd线(中枢低低) - if (!isNaN(dd) && dd > 0) { - const ddSeries = mainChart.addLineSeries({ - color: '#F1C40F', // 使用中枢自己的颜色 - lineWidth: 1, - lastValueVisible: false, - priceLineVisible: false, - }); - - ddSeries.setData([ - { time: startTime, value: dd }, - { time: endTime, value: dd } - ]); - } - - // 添加到图表对象 - tvWidget.series.mainZsSeries.push({ - time: startTime, - value: zg, - color: '#F1C40F', - lineWidth: 1 - }); - tvWidget.series.mainZsSeries.push({ - time: endTime, - value: zg, - color: '#F1C40F', - lineWidth: 1 - }); - tvWidget.series.mainZsSeries.push({ - time: startTime, - value: zd, - color: '#F1C40F', - lineWidth: 1 - }); - tvWidget.series.mainZsSeries.push({ - time: endTime, - value: zd, - color: '#F1C40F', - lineWidth: 1 - }); - } catch (e) { - console.error('主周期中枢处理出错:', e); - } - }); - } - - // 次周期中枢 - if ($('#showElementZs').is(':checked') && currentData.element_zs_list && currentData.element_zs_list.length > 0) { - console.log(`绘制次周期中枢数据,共${currentData.element_zs_list.length}条`); - - currentData.element_zs_list.forEach(function(zs) { - try { - // 直接使用UTC时间戳(秒) - const startTime = Math.floor(new Date(zs.start_time).getTime() / 1000); - const endTime = Math.floor(new Date(zs.end_time).getTime() / 1000); - - if (isNaN(startTime) || isNaN(endTime)) { - console.error('次周期中枢时间转换错误:', zs.start_time, zs.end_time); - return; - } - - const zg = parseFloat(zs.zg); // 中枢上沿 - const zd = parseFloat(zs.zd); // 中枢下沿 - const gg = parseFloat(zs.gg); // 中枢高高 - const dd = parseFloat(zs.dd); // 中枢低低 - - if (isNaN(zg) || isNaN(zd)) { - console.error('次周期中枢价格转换错误:', zs.zg, zs.zd); - return; - } - - // 创建中枢上边界 - const topSeries = mainChart.addLineSeries({ - color: '#3f51b5', // 次周期中枢颜色 - lineWidth: 1, - lastValueVisible: false, - priceLineVisible: false, - }); - - topSeries.setData([ - { time: startTime, value: zg }, - { time: endTime, value: zg } - ]); - - // 为下边界创建另一条线 - const bottomSeries = mainChart.addLineSeries({ - color: '#3f51b5', // 次周期中枢颜色 - lineWidth: 1, - lastValueVisible: false, - priceLineVisible: false, - }); - - bottomSeries.setData([ - { time: startTime, value: zd }, - { time: endTime, value: zd } - ]); - - // 添加左边界 - const leftSeries = mainChart.addLineSeries({ - color: '#3f51b5', // 次周期中枢颜色 - lineWidth: 1, - lastValueVisible: false, - priceLineVisible: false, - }); - - leftSeries.setData([ - { time: startTime, value: zd }, - { time: startTime, value: zg } - ]); - - // 添加右边界 - const rightSeries = mainChart.addLineSeries({ - color: '#3f51b5', // 次周期中枢颜色 - lineWidth: 1, - lastValueVisible: false, - priceLineVisible: false, - }); - - rightSeries.setData([ - { time: endTime, value: zd }, - { time: endTime, value: zg } - ]); - - // 绘制gg线(中枢高高) - if (!isNaN(gg) && gg > 0) { - const ggSeries = mainChart.addLineSeries({ - color: '#3f51b5', // 使用次周期中枢自己的颜色 - lineWidth: 1, - lastValueVisible: false, - priceLineVisible: false, - }); - - ggSeries.setData([ - { time: startTime, value: gg }, - { time: endTime, value: gg } - ]); - } - - // 绘制dd线(中枢低低) - if (!isNaN(dd) && dd > 0) { - const ddSeries = mainChart.addLineSeries({ - color: '#3f51b5', // 使用次周期中枢自己的颜色 - lineWidth: 1, - lastValueVisible: false, - priceLineVisible: false, - }); - - ddSeries.setData([ - { time: startTime, value: dd }, - { time: endTime, value: dd } - ]); - } - - // 添加到图表对象 - tvWidget.series.elementZsSeries.push({ - time: startTime, - value: zg, - color: '#3f51b5', - lineWidth: 1 - }); - tvWidget.series.elementZsSeries.push({ - time: endTime, - value: zg, - color: '#3f51b5', - lineWidth: 1 - }); - tvWidget.series.elementZsSeries.push({ - time: startTime, - value: zd, - color: '#3f51b5', - lineWidth: 1 - }); - tvWidget.series.elementZsSeries.push({ - time: endTime, - value: zd, - color: '#3f51b5', - lineWidth: 1 - }); - } catch (e) { - console.error('次周期中枢处理出错:', e); - } - }); - } - // 次次周期SEG中枢 - if ($('#showSubSubZs').is(':checked') && currentData.sub_sub_zs_list && currentData.sub_sub_zs_list.length > 0) { - const subSubZsColor = '#00897b'; - currentData.sub_sub_zs_list.forEach(function(zs) { - try { - const startTime = Math.floor(new Date(zs.start_time).getTime() / 1000); - const endTime = zs.end_time ? Math.floor(new Date(zs.end_time).getTime() / 1000) : 0; - if (isNaN(startTime) || !endTime) return; - const zg = parseFloat(zs.zg); const zd = parseFloat(zs.zd); const gg = parseFloat(zs.gg); const dd = parseFloat(zs.dd); - if (isNaN(zg) || isNaN(zd)) return; - mainChart.addLineSeries({ color: subSubZsColor, lineWidth: 1, lastValueVisible: false, priceLineVisible: false }).setData([{ time: startTime, value: zg }, { time: endTime, value: zg }]); - mainChart.addLineSeries({ color: subSubZsColor, lineWidth: 1, lastValueVisible: false, priceLineVisible: false }).setData([{ time: startTime, value: zd }, { time: endTime, value: zd }]); - mainChart.addLineSeries({ color: subSubZsColor, lineWidth: 1, lastValueVisible: false, priceLineVisible: false }).setData([{ time: startTime, value: zd }, { time: startTime, value: zg }]); - mainChart.addLineSeries({ color: subSubZsColor, lineWidth: 1, lastValueVisible: false, priceLineVisible: false }).setData([{ time: endTime, value: zd }, { time: endTime, value: zg }]); - if (!isNaN(gg) && gg > 0) mainChart.addLineSeries({ color: subSubZsColor, lineWidth: 1, lastValueVisible: false, priceLineVisible: false }).setData([{ time: startTime, value: gg }, { time: endTime, value: gg }]); - if (!isNaN(dd) && dd > 0) mainChart.addLineSeries({ color: subSubZsColor, lineWidth: 1, lastValueVisible: false, priceLineVisible: false }).setData([{ time: startTime, value: dd }, { time: endTime, value: dd }]); - } catch (e) { console.error('次次周期中枢处理出错:', e); } - }); - } - } else { - console.log('绘制中枢 - 已禁用'); - } - // BI中枢(已完成)- 使用独立的BI开关 - if ($('#showMainBiZs').is(':checked') && currentData.bi_zs_list && currentData.bi_zs_list.length > 0) { - try { - console.log(`绘制主周期BI中枢数据,共${currentData.bi_zs_list.length}条`); - } catch (e) {} - currentData.bi_zs_list.forEach(function(zs) { - try { - const startTime = Math.floor(new Date(zs.start_time).getTime() / 1000); - const endTime = zs.end_time ? Math.floor(new Date(zs.end_time).getTime() / 1000) : Math.floor(new Date(currentData.kline_data[currentData.kline_data.length-1].date).getTime() / 1000); - if (isNaN(startTime) || isNaN(endTime)) { return; } - const zg = parseFloat(zs.zg); const zd = parseFloat(zs.zd); const gg = parseFloat(zs.gg); const dd = parseFloat(zs.dd); - if (isNaN(zg) || isNaN(zd)) { return; } - const color = '#F1C40F'; - const topSeries = mainChart.addLineSeries({ color, lineWidth: 1, lastValueVisible: false, priceLineVisible: false }); - topSeries.setData([{ time: startTime, value: zg }, { time: endTime, value: zg }]); - const bottomSeries = mainChart.addLineSeries({ color, lineWidth: 1, lastValueVisible: false, priceLineVisible: false }); - bottomSeries.setData([{ time: startTime, value: zd }, { time: endTime, value: zd }]); - const leftSeries = mainChart.addLineSeries({ color, lineWidth: 1, lastValueVisible: false, priceLineVisible: false }); - leftSeries.setData([{ time: startTime, value: zd }, { time: startTime, value: zg }]); - const rightSeries = mainChart.addLineSeries({ color, lineWidth: 1, lastValueVisible: false, priceLineVisible: false }); - rightSeries.setData([{ time: endTime, value: zd }, { time: endTime, value: zg }]); - if (!isNaN(gg) && gg > 0) { - const ggSeries = mainChart.addLineSeries({ color, lineWidth: 1, lastValueVisible: false, priceLineVisible: false }); - ggSeries.setData([{ time: startTime, value: gg }, { time: endTime, value: gg }]); - } - if (!isNaN(dd) && dd > 0) { - const ddSeries = mainChart.addLineSeries({ color, lineWidth: 1, lastValueVisible: false, priceLineVisible: false }); - ddSeries.setData([{ time: startTime, value: dd }, { time: endTime, value: dd }]); - } - } catch (e) { console.error('主周期BI中枢处理出错:', e); } - }); - } - if ($('#showSubSubBiZs').is(':checked') && currentData.sub_sub_bi_zs_list && currentData.sub_sub_bi_zs_list.length > 0) { - currentData.sub_sub_bi_zs_list.forEach(function(zs) { - try { - const startTime = Math.floor(new Date(zs.start_time).getTime() / 1000); - const kd = currentData.kline_data || []; - const endTime = zs.end_time ? Math.floor(new Date(zs.end_time).getTime() / 1000) : (kd.length ? Math.floor(new Date(kd[kd.length-1].date).getTime() / 1000) : 0); - if (isNaN(startTime) || !endTime) return; - const zg = parseFloat(zs.zg); const zd = parseFloat(zs.zd); const gg = parseFloat(zs.gg); const dd = parseFloat(zs.dd); - if (isNaN(zg) || isNaN(zd)) return; - const color = '#00897b'; - mainChart.addLineSeries({ color, lineWidth: 1, lastValueVisible: false, priceLineVisible: false }).setData([{ time: startTime, value: zg }, { time: endTime, value: zg }]); - mainChart.addLineSeries({ color, lineWidth: 1, lastValueVisible: false, priceLineVisible: false }).setData([{ time: startTime, value: zd }, { time: endTime, value: zd }]); - mainChart.addLineSeries({ color, lineWidth: 1, lastValueVisible: false, priceLineVisible: false }).setData([{ time: startTime, value: zd }, { time: startTime, value: zg }]); - mainChart.addLineSeries({ color, lineWidth: 1, lastValueVisible: false, priceLineVisible: false }).setData([{ time: endTime, value: zd }, { time: endTime, value: zg }]); - if (!isNaN(gg) && gg > 0) mainChart.addLineSeries({ color, lineWidth: 1, lastValueVisible: false, priceLineVisible: false }).setData([{ time: startTime, value: gg }, { time: endTime, value: gg }]); - if (!isNaN(dd) && dd > 0) mainChart.addLineSeries({ color, lineWidth: 1, lastValueVisible: false, priceLineVisible: false }).setData([{ time: startTime, value: dd }, { time: endTime, value: dd }]); - } catch (e) { console.error('次次周期BI中枢处理出错:', e); } - }); - } - if ($('#showElementBiZs').is(':checked') && currentData.element_bi_zs_list && currentData.element_bi_zs_list.length > 0) { - try { - console.log(`绘制次周期BI中枢数据,共${currentData.element_bi_zs_list.length}条`); - } catch (e) {} - currentData.element_bi_zs_list.forEach(function(zs) { - try { - const startTime = Math.floor(new Date(zs.start_time).getTime() / 1000); - const endTime = zs.end_time ? Math.floor(new Date(zs.end_time).getTime() / 1000) : Math.floor(new Date(currentData.kline_data[currentData.kline_data.length-1].date).getTime() / 1000); - if (isNaN(startTime) || isNaN(endTime)) { return; } - const zg = parseFloat(zs.zg); const zd = parseFloat(zs.zd); const gg = parseFloat(zs.gg); const dd = parseFloat(zs.dd); - if (isNaN(zg) || isNaN(zd)) { return; } - const color = '#3f51b5'; - const topSeries = mainChart.addLineSeries({ color, lineWidth: 1, lastValueVisible: false, priceLineVisible: false }); - topSeries.setData([{ time: startTime, value: zg }, { time: endTime, value: zg }]); - const bottomSeries = mainChart.addLineSeries({ color, lineWidth: 1, lastValueVisible: false, priceLineVisible: false }); - bottomSeries.setData([{ time: startTime, value: zd }, { time: endTime, value: zd }]); - const leftSeries = mainChart.addLineSeries({ color, lineWidth: 1, lastValueVisible: false, priceLineVisible: false }); - leftSeries.setData([{ time: startTime, value: zd }, { time: startTime, value: zg }]); - const rightSeries = mainChart.addLineSeries({ color, lineWidth: 1, lastValueVisible: false, priceLineVisible: false }); - rightSeries.setData([{ time: endTime, value: zd }, { time: endTime, value: zg }]); - if (!isNaN(gg) && gg > 0) { - const ggSeries = mainChart.addLineSeries({ color, lineWidth: 1, lastValueVisible: false, priceLineVisible: false }); - ggSeries.setData([{ time: startTime, value: gg }, { time: endTime, value: gg }]); - } - if (!isNaN(dd) && dd > 0) { - const ddSeries = mainChart.addLineSeries({ color, lineWidth: 1, lastValueVisible: false, priceLineVisible: false }); - ddSeries.setData([{ time: startTime, value: dd }, { time: endTime, value: dd }]); - } - } catch (e) { console.error('次周期BI中枢处理出错:', e); } - }); - } - // 结构价值区绘制(半透明填充区 + 边框) - if ($('#showMainStructureZone').is(':checked') && currentData.structure_zones && currentData.structure_zones.length > 0) { - try { - const kd = currentData.kline_data || []; - if (kd.length > 0) { - const chartStart = Math.floor(new Date(kd[0].date).getTime() / 1000); - const chartEnd = Math.floor(new Date(kd[kd.length-1].date).getTime() / 1000); - // 计算可见价格范围,过滤超出范围的区间 - let priceMin = Infinity, priceMax = -Infinity; - kd.forEach(function(k) { - const hi = parseFloat(k.high), lo = parseFloat(k.low); - if (!isNaN(hi) && hi > priceMax) priceMax = hi; - if (!isNaN(lo) && lo < priceMin) priceMin = lo; - }); - const priceMargin = (priceMax - priceMin) * 0.05; - priceMin -= priceMargin; - priceMax += priceMargin; - let drawnCount = 0; - currentData.structure_zones.forEach(function(zone) { - try { - // 跳过完全超出可视价格范围的区间 - if (zone.upper < priceMin || zone.lower > priceMax) return; - const fillColor = zone.zone_type === 'support' ? 'rgba(46, 204, 113, 0.08)' : - zone.zone_type === 'resistance' ? 'rgba(231, 76, 60, 0.08)' : - 'rgba(149, 165, 166, 0.06)'; - const borderColor = zone.zone_type === 'support' ? 'rgba(46, 204, 113, 0.7)' : - zone.zone_type === 'resistance' ? 'rgba(231, 76, 60, 0.7)' : - 'rgba(149, 165, 166, 0.6)'; - // 填充区:在上下边界之间画多条半透明线模拟填充 - const fillLines = 8; - const step = (zone.upper - zone.lower) / (fillLines + 1); - for (let fi = 1; fi <= fillLines; fi++) { - const fy = zone.lower + step * fi; - mainChart.addLineSeries({ color: fillColor, lineWidth: 2, lineStyle: 0, lastValueVisible: false, priceLineVisible: false }) - .setData([{ time: chartStart, value: fy }, { time: chartEnd, value: fy }]); - } - // 上边界(粗线) - mainChart.addLineSeries({ color: borderColor, lineWidth: 2, lineStyle: 0, lastValueVisible: false, priceLineVisible: false }) - .setData([{ time: chartStart, value: zone.upper }, { time: chartEnd, value: zone.upper }]); - // 下边界(粗线) - mainChart.addLineSeries({ color: borderColor, lineWidth: 2, lineStyle: 0, lastValueVisible: false, priceLineVisible: false }) - .setData([{ time: chartStart, value: zone.lower }, { time: chartEnd, value: zone.lower }]); - // 中心线(虚线) - mainChart.addLineSeries({ color: borderColor, lineWidth: 1, lineStyle: 2, lastValueVisible: false, priceLineVisible: false }) - .setData([{ time: chartStart, value: zone.center }, { time: chartEnd, value: zone.center }]); - drawnCount++; - } catch (e) { console.error('结构区绘制出错:', e); } - }); - console.log(`结构区: 共${currentData.structure_zones.length}个, 绘制${drawnCount}个 (可见价格范围: ${priceMin.toFixed(0)}-${priceMax.toFixed(0)})`); - } - } catch (e) { console.error('结构区整体绘制出错:', e); } - } - // 威科夫叠层:区间 / 阶段 / 事件 / VP - if ($('#showWyckoff').is(':checked') && currentData.wyckoff) { - try { - const w = currentData.wyckoff; - const tr = w.trading_range; - const parseTs = function(t) { - if (t == null) return NaN; - if (typeof t === 'number') return Math.floor(t > 1e12 ? t / 1000 : t); - const ms = new Date(t).getTime(); - return isNaN(ms) ? NaN : Math.floor(ms / 1000); - }; - const kd = currentData.kline_data || []; - const chartEnd = kd.length - ? Math.floor(new Date(kd[kd.length - 1].date).getTime() / 1000) - : NaN; - - if ($('#showWyckoffRange').is(':checked') && tr) { - const t0 = parseTs(tr.start_time); - const t1 = tr.end_time ? parseTs(tr.end_time) : chartEnd; - const hi = parseFloat(tr.high), lo = parseFloat(tr.low), mid = parseFloat(tr.mid); - if (!isNaN(t0) && !isNaN(t1) && !isNaN(hi) && !isNaN(lo)) { - const fill = 'rgba(52, 152, 219, 0.07)'; - const border = 'rgba(52, 152, 219, 0.75)'; - // 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 }]); - } - } - - if ($('#showWyckoffPhases').is(':checked') && w.phases && w.phases.length) { - const phaseColors = { - A: 'rgba(241, 196, 15, 0.85)', - B: 'rgba(155, 89, 182, 0.85)', - C: 'rgba(230, 126, 34, 0.85)', - D: 'rgba(46, 204, 113, 0.85)', - E: 'rgba(52, 152, 219, 0.85)' - }; - const phaseMarkers = []; - w.phases.forEach(function(ph) { - const t0 = parseTs(ph.start_time); - const t1 = ph.end_time ? parseTs(ph.end_time) : chartEnd; - if (isNaN(t0) || isNaN(t1) || !tr) return; - const hi = parseFloat(tr.high); - if (isNaN(hi)) return; - const col = phaseColors[ph.phase] || 'rgba(149,165,166,0.85)'; - // 阶段顶部分段色带(略高于区间高) - const y = hi * 1.002; - mainChart.addLineSeries({ color: col, lineWidth: 3, lastValueVisible: false, priceLineVisible: false }) - .setData([{ time: t0, value: y }, { time: t1, value: y }]); - phaseMarkers.push({ - time: t0, - position: 'aboveBar', - color: col, - shape: 'square', - text: String(ph.phase || ph.label || ''), - size: 1 - }); - }); - if (phaseMarkers.length) { - const phSeries = mainChart.addLineSeries({ lastValueVisible: false, priceLineVisible: false }); - phSeries.setMarkers(phaseMarkers); - } - } - - if ($('#showWyckoffEvents').is(':checked') && w.events && w.events.length) { - const eventColors = { - Spring: '#27ae60', - SOS: '#2ecc71', - LPS: '#16a085', - UTAD: '#e74c3c', - SOW: '#c0392b', - LPSY: '#d35400' - }; - const checks = (w.volume_confirm && w.volume_confirm.event_checks) || {}; - const markers = []; - w.events.forEach(function(ev) { - const t = parseTs(ev.time); - if (isNaN(t)) return; - const typ = ev.type || ''; - const chk = checks[typ] || {}; - const volOk = (chk.volume_ok != null) ? chk.volume_ok : ev.volume_ok; - const ratioVal = (chk.volume_ratio != null) ? chk.volume_ratio : ev.volume_ratio; - const ok = volOk === true ? '✓' : (volOk === false ? '✗' : ''); - const note = ev.note || ''; - const ratio = (ratioVal != null) ? (' vol×' + Number(ratioVal).toFixed(2)) : ''; - markers.push({ - time: t, - position: (typ === 'Spring' || typ === 'LPS' || typ === 'SOW') ? 'belowBar' : 'aboveBar', - color: eventColors[typ] || '#7f8c8d', - shape: 'arrowUp', - text: typ + (ok ? ' ' + ok : '') + (note ? ' ' + note : '') + ratio, - size: 1 - }); - }); - if (markers.length) { - const evSeries = mainChart.addLineSeries({ lastValueVisible: false, priceLineVisible: false }); - evSeries.setMarkers(markers); - } - } - - if ($('#showWyckoffVP').is(':checked') && w.volume_profile && tr) { - const vp = w.volume_profile; - const t1 = tr.end_time ? parseTs(tr.end_time) : chartEnd; - if (!isNaN(t1)) { - const bins = vp.bins || []; - // ECR-004 A+C:只画有量 Top-N,避免每 bin 一条 series - const TOP_N = 8; - const ranked = bins - .filter(function(b) { return b && b.volume > 0; }) - .slice() - .sort(function(a, b) { return b.volume - a.volume; }) - .slice(0, TOP_N); - let maxVol = 0; - ranked.forEach(function(b) { if (b.volume > maxVol) maxVol = b.volume; }); - const tStart = parseTs(tr.start_time); - const maxWidthSec = Math.max(60, Math.floor((t1 - (isNaN(tStart) ? t1 : tStart)) * 0.15)); - ranked.forEach(function(b) { - if (!b.volume || maxVol <= 0) return; - const wSec = Math.max(1, Math.floor(maxWidthSec * (b.volume / maxVol))); - const alpha = 0.2 + 0.55 * (b.volume / maxVol); - const leftT = Math.max(isNaN(tStart) ? (t1 - wSec) : tStart, t1 - wSec); - mainChart.addLineSeries({ - color: 'rgba(142, 68, 173, ' + alpha.toFixed(2) + ')', - lineWidth: 1, - lastValueVisible: false, - priceLineVisible: false - }).setData([ - { time: leftT, value: b.price }, - { time: t1, value: b.price } - ]); - }); - const levels = [ - { p: vp.poc, c: 'rgba(142, 68, 173, 0.95)', w: 2, style: 0 }, - { p: vp.vah, c: 'rgba(155, 89, 182, 0.7)', w: 1, style: 2 }, - { p: vp.val, c: 'rgba(155, 89, 182, 0.7)', w: 1, style: 2 } - ]; - const t0 = parseTs(tr.start_time); - levels.forEach(function(lv) { - const p = parseFloat(lv.p); - if (isNaN(p) || isNaN(t0)) return; - mainChart.addLineSeries({ - color: lv.c, - lineWidth: lv.w, - lineStyle: lv.style, - lastValueVisible: false, - priceLineVisible: false - }).setData([{ time: t0, value: p }, { time: t1, value: p }]); - }); - } - } - } catch (e) { console.error('威科夫绘制出错:', e); } - } - // 显示未完成中枢 - 分别处理主周期、次周期和次次周期 - if ($('#showMainZs').is(':checked') || $('#showElementZs').is(':checked') || $('#showSubSubZs').is(':checked') || $('#showSubSubBiZs').is(':checked')) { - console.log('绘制未完成中枢 - 已启用'); - // 显示BI中枢绘制(沿用中枢样式) - if ($('#showMainBiZs').is(':checked') || $('#showElementBiZs').is(':checked') || $('#showSubSubBiZs').is(':checked')) { - console.log('绘制BI中枢 - 已启用'); - // 主周期 BI 中枢 - console.log('主BI开关:', $('#showMainBiZs').is(':checked'), '数据长度:', currentData.bi_zs_list ? currentData.bi_zs_list.length : 0); - if ($('#showMainBiZs').is(':checked') && currentData.bi_zs_list && currentData.bi_zs_list.length > 0) { - console.log(`绘制主周期BI中枢数据,共${currentData.bi_zs_list.length}条`); - currentData.bi_zs_list.forEach(function(zs) { - try { - const startTime = Math.floor(new Date(zs.start_time).getTime() / 1000); - const endTime = zs.end_time ? Math.floor(new Date(zs.end_time).getTime() / 1000) : Math.floor(new Date(currentData.kline_data[currentData.kline_data.length-1].date).getTime() / 1000); - if (isNaN(startTime) || isNaN(endTime)) { return; } - const zg = parseFloat(zs.zg), zd = parseFloat(zs.zd), gg = parseFloat(zs.gg), dd = parseFloat(zs.dd); - if (isNaN(zg) || isNaN(zd)) { return; } - const color = '#9C27B0'; // 主周期BI中枢颜色(紫色) - const topSeries = mainChart.addLineSeries({ color, lineWidth: 1, lastValueVisible: false, priceLineVisible: false }); - topSeries.setData([{ time: startTime, value: zg }, { time: endTime, value: zg }]); - const bottomSeries = mainChart.addLineSeries({ color, lineWidth: 1, lastValueVisible: false, priceLineVisible: false }); - bottomSeries.setData([{ time: startTime, value: zd }, { time: endTime, value: zd }]); - const leftSeries = mainChart.addLineSeries({ color, lineWidth: 1, lastValueVisible: false, priceLineVisible: false }); - leftSeries.setData([{ time: startTime, value: zd }, { time: startTime, value: zg }]); - const rightSeries = mainChart.addLineSeries({ color, lineWidth: 1, lastValueVisible: false, priceLineVisible: false }); - rightSeries.setData([{ time: endTime, value: zd }, { time: endTime, value: zg }]); - if (!isNaN(gg) && gg > 0) { - const ggSeries = mainChart.addLineSeries({ color, lineWidth: 1, lastValueVisible: false, priceLineVisible: false }); - ggSeries.setData([{ time: startTime, value: gg }, { time: endTime, value: gg }]); - } - if (!isNaN(dd) && dd > 0) { - const ddSeries = mainChart.addLineSeries({ color, lineWidth: 1, lastValueVisible: false, priceLineVisible: false }); - ddSeries.setData([{ time: startTime, value: dd }, { time: endTime, value: dd }]); - } - } catch (e) { console.error('主周期BI中枢处理出错:', e); } - }); - } - // 主周期 未完成 BI 中枢 - console.log('主未完成BI长度:', currentData.uncompleted_bi_zs_list ? currentData.uncompleted_bi_zs_list.length : 0); - if ($('#showMainBiZs').is(':checked') && currentData.uncompleted_bi_zs_list && currentData.uncompleted_bi_zs_list.length > 0) { - console.log(`绘制主周期未完成BI中枢数据,共${currentData.uncompleted_bi_zs_list.length}条`); - currentData.uncompleted_bi_zs_list.forEach(function(zs) { - try { - const startTime = Math.floor(new Date(zs.start_time).getTime() / 1000); - const endTime = Math.floor(new Date(currentData.kline_data[currentData.kline_data.length-1].date).getTime() / 1000); - if (isNaN(startTime) || isNaN(endTime)) { return; } - const zg = parseFloat(zs.zg), zd = parseFloat(zs.zd), gg = parseFloat(zs.gg), dd = parseFloat(zs.dd); - if (isNaN(zg) || isNaN(zd)) { return; } - const color = '#9C27B0'; - const topSeries = mainChart.addLineSeries({ color, lineWidth: 1, lastValueVisible: false, priceLineVisible: false }); - topSeries.setData([{ time: startTime, value: zg }, { time: endTime, value: zg }]); - const bottomSeries = mainChart.addLineSeries({ color, lineWidth: 1, lastValueVisible: false, priceLineVisible: false }); - bottomSeries.setData([{ time: startTime, value: zd }, { time: endTime, value: zd }]); - const leftSeries = mainChart.addLineSeries({ color, lineWidth: 1, lastValueVisible: false, priceLineVisible: false }); - leftSeries.setData([{ time: startTime, value: zd }, { time: startTime, value: zg }]); - if (!isNaN(gg) && gg > 0) { - const ggSeries = mainChart.addLineSeries({ color, lineWidth: 1, lastValueVisible: false, priceLineVisible: false }); - ggSeries.setData([{ time: startTime, value: gg }, { time: endTime, value: gg }]); - } - if (!isNaN(dd) && dd > 0) { - const ddSeries = mainChart.addLineSeries({ color, lineWidth: 1, lastValueVisible: false, priceLineVisible: false }); - ddSeries.setData([{ time: startTime, value: dd }, { time: endTime, value: dd }]); - } - } catch (e) { console.error('主周期未完成BI中枢处理出错:', e); } - }); - } - // 次周期 BI 中枢 - console.log('次BI开关:', $('#showElementBiZs').is(':checked'), '数据长度:', currentData.element_bi_zs_list ? currentData.element_bi_zs_list.length : 0); - if ($('#showElementBiZs').is(':checked') && currentData.element_bi_zs_list && currentData.element_bi_zs_list.length > 0) { - console.log(`绘制次周期BI中枢数据,共${currentData.element_bi_zs_list.length}条`); - currentData.element_bi_zs_list.forEach(function(zs) { - try { - const startTime = Math.floor(new Date(zs.start_time).getTime() / 1000); - const endTime = zs.end_time ? Math.floor(new Date(zs.end_time).getTime() / 1000) : Math.floor(new Date(currentData.kline_data[currentData.kline_data.length-1].date).getTime() / 1000); - if (isNaN(startTime) || isNaN(endTime)) { return; } - const zg = parseFloat(zs.zg), zd = parseFloat(zs.zd), gg = parseFloat(zs.gg), dd = parseFloat(zs.dd); - if (isNaN(zg) || isNaN(zd)) { return; } - const color = '#8BC34A'; // 次周期BI中枢颜色(绿) - const topSeries = mainChart.addLineSeries({ color, lineWidth: 1, lastValueVisible: false, priceLineVisible: false }); - topSeries.setData([{ time: startTime, value: zg }, { time: endTime, value: zg }]); - const bottomSeries = mainChart.addLineSeries({ color, lineWidth: 1, lastValueVisible: false, priceLineVisible: false }); - bottomSeries.setData([{ time: startTime, value: zd }, { time: endTime, value: zd }]); - const leftSeries = mainChart.addLineSeries({ color, lineWidth: 1, lastValueVisible: false, priceLineVisible: false }); - leftSeries.setData([{ time: startTime, value: zd }, { time: startTime, value: zg }]); - const rightSeries = mainChart.addLineSeries({ color, lineWidth: 1, lastValueVisible: false, priceLineVisible: false }); - rightSeries.setData([{ time: endTime, value: zd }, { time: endTime, value: zg }]); - if (!isNaN(gg) && gg > 0) { - const ggSeries = mainChart.addLineSeries({ color, lineWidth: 1, lastValueVisible: false, priceLineVisible: false }); - ggSeries.setData([{ time: startTime, value: gg }, { time: endTime, value: gg }]); - } - if (!isNaN(dd) && dd > 0) { - const ddSeries = mainChart.addLineSeries({ color, lineWidth: 1, lastValueVisible: false, priceLineVisible: false }); - ddSeries.setData([{ time: startTime, value: dd }, { time: endTime, value: dd }]); - } - } catch (e) { console.error('次周期BI中枢处理出错:', e); } - }); - } - // 次周期 未完成 BI 中枢 - console.log('次未完成BI长度:', currentData.element_uncompleted_bi_zs_list ? currentData.element_uncompleted_bi_zs_list.length : 0); - if ($('#showElementBiZs').is(':checked') && currentData.element_uncompleted_bi_zs_list && currentData.element_uncompleted_bi_zs_list.length > 0) { - console.log(`绘制次周期未完成BI中枢数据,共${currentData.element_uncompleted_bi_zs_list.length}条`); - currentData.element_uncompleted_bi_zs_list.forEach(function(zs) { - try { - const startTime = Math.floor(new Date(zs.start_time).getTime() / 1000); - const endTime = Math.floor(new Date(currentData.kline_data[currentData.kline_data.length-1].date).getTime() / 1000); - if (isNaN(startTime) || isNaN(endTime)) { return; } - const zg = parseFloat(zs.zg), zd = parseFloat(zs.zd), gg = parseFloat(zs.gg), dd = parseFloat(zs.dd); - if (isNaN(zg) || isNaN(zd)) { return; } - const color = '#8BC34A'; - const topSeries = mainChart.addLineSeries({ color, lineWidth: 1, lastValueVisible: false, priceLineVisible: false }); - topSeries.setData([{ time: startTime, value: zg }, { time: endTime, value: zg }]); - const bottomSeries = mainChart.addLineSeries({ color, lineWidth: 1, lastValueVisible: false, priceLineVisible: false }); - bottomSeries.setData([{ time: startTime, value: zd }, { time: endTime, value: zd }]); - const leftSeries = mainChart.addLineSeries({ color, lineWidth: 1, lastValueVisible: false, priceLineVisible: false }); - leftSeries.setData([{ time: startTime, value: zd }, { time: startTime, value: zg }]); - if (!isNaN(gg) && gg > 0) { - const ggSeries = mainChart.addLineSeries({ color, lineWidth: 1, lastValueVisible: false, priceLineVisible: false }); - ggSeries.setData([{ time: startTime, value: gg }, { time: endTime, value: gg }]); - } - if (!isNaN(dd) && dd > 0) { - const ddSeries = mainChart.addLineSeries({ color, lineWidth: 1, lastValueVisible: false, priceLineVisible: false }); - ddSeries.setData([{ time: startTime, value: dd }, { time: endTime, value: dd }]); - } - } catch (e) { console.error('次周期未完成BI中枢处理出错:', e); } - }); - } - // 次次周期 未完成 BI 中枢 - if ($('#showSubSubBiZs').is(':checked') && currentData.sub_sub_uncompleted_bi_zs_list && currentData.sub_sub_uncompleted_bi_zs_list.length > 0) { - const kdBi = currentData.kline_data || []; - const endTimeBi = kdBi.length ? Math.floor(new Date(kdBi[kdBi.length-1].date).getTime() / 1000) : 0; - currentData.sub_sub_uncompleted_bi_zs_list.forEach(function(zs) { - try { - const startTime = Math.floor(new Date(zs.start_time).getTime() / 1000); - if (isNaN(startTime) || !endTimeBi) return; - const zg = parseFloat(zs.zg), zd = parseFloat(zs.zd), gg = parseFloat(zs.gg), dd = parseFloat(zs.dd); - if (isNaN(zg) || isNaN(zd)) return; - const color = '#00897b'; - mainChart.addLineSeries({ color, lineWidth: 1, lastValueVisible: false, priceLineVisible: false }).setData([{ time: startTime, value: zg }, { time: endTimeBi, value: zg }]); - mainChart.addLineSeries({ color, lineWidth: 1, lastValueVisible: false, priceLineVisible: false }).setData([{ time: startTime, value: zd }, { time: endTimeBi, value: zd }]); - mainChart.addLineSeries({ color, lineWidth: 1, lastValueVisible: false, priceLineVisible: false }).setData([{ time: startTime, value: zd }, { time: startTime, value: zg }]); - if (!isNaN(gg) && gg > 0) mainChart.addLineSeries({ color, lineWidth: 1, lastValueVisible: false, priceLineVisible: false }).setData([{ time: startTime, value: gg }, { time: endTimeBi, value: gg }]); - if (!isNaN(dd) && dd > 0) mainChart.addLineSeries({ color, lineWidth: 1, lastValueVisible: false, priceLineVisible: false }).setData([{ time: startTime, value: dd }, { time: endTimeBi, value: dd }]); - } catch (e) { console.error('次次周期未完成BI中枢处理出错:', e); } - }); - } - } - // 主周期未完成中枢 - if ($('#showMainZs').is(':checked') && currentData.uncompleted_zs_list && currentData.uncompleted_zs_list.length > 0) { - console.log(`绘制主周期未完成中枢数据,共${currentData.uncompleted_zs_list.length}条`); - - currentData.uncompleted_zs_list.forEach(function(zs) { - try { - // 直接使用UTC时间戳(秒) - const startTime = Math.floor(new Date(zs.start_time).getTime() / 1000); - // 未完成中枢的结束时间设为当前K线的最后时间 - const endTime = Math.floor(new Date(currentData.kline_data[currentData.kline_data.length-1].date).getTime() / 1000); - - if (isNaN(startTime) || isNaN(endTime)) { - console.error('主周期未完成中枢时间转换错误:', zs.start_time); - return; - } - - const zg = parseFloat(zs.zg); // 中枢上沿 - const zd = parseFloat(zs.zd); // 中枢下沿 - const gg = parseFloat(zs.gg); // 中枢高高 - const dd = parseFloat(zs.dd); // 中枢低低 - - if (isNaN(zg) || isNaN(zd)) { - console.error('主周期未完成中枢价格转换错误:', zs.zg, zs.zd); - return; - } - - // 创建未完成中枢上边界 - const topSeries = mainChart.addLineSeries({ - color: '#F1C40F', // 主周期中枢颜色 - lineWidth: 1, - lastValueVisible: false, - priceLineVisible: false, - }); - - topSeries.setData([ - { time: startTime, value: zg }, - { time: endTime, value: zg } - ]); - - // 为下边界创建另一条线 - const bottomSeries = mainChart.addLineSeries({ - color: '#F1C40F', // 主周期中枢颜色 - lineWidth: 1, - lastValueVisible: false, - priceLineVisible: false, - }); - - bottomSeries.setData([ - { time: startTime, value: zd }, - { time: endTime, value: zd } - ]); - - // 添加左边界 - const leftSeries = mainChart.addLineSeries({ - color: '#F1C40F', // 主周期中枢颜色 - lineWidth: 1, - lastValueVisible: false, - priceLineVisible: false, - }); - - leftSeries.setData([ - { time: startTime, value: zd }, - { time: startTime, value: zg } - ]); - - // 添加一个标记,标识这是未完成中枢 - const markerSeries = mainChart.addLineSeries({ - lastValueVisible: false, - priceLineVisible: false, - }); - - markerSeries.setMarkers([ - { - time: startTime, - position: 'aboveBar', - color: '#F1C40F', - shape: 'circle', - text: '未完', - size: 1 - } - ]); - - // 绘制gg线(中枢高高) - if (!isNaN(gg) && gg > 0) { - const ggSeries = mainChart.addLineSeries({ - color: '#F1C40F', // 使用中枢自己的颜色 - lineWidth: 1, - lastValueVisible: false, - priceLineVisible: false, - }); - - ggSeries.setData([ - { time: startTime, value: gg }, - { time: endTime, value: gg } - ]); - } - - // 绘制dd线(中枢低低) - if (!isNaN(dd) && dd > 0) { - const ddSeries = mainChart.addLineSeries({ - color: '#F1C40F', // 使用中枢自己的颜色 - lineWidth: 1, - lastValueVisible: false, - priceLineVisible: false, - }); - - ddSeries.setData([ - { time: startTime, value: dd }, - { time: endTime, value: dd } - ]); - } - - // 添加到图表对象 - tvWidget.series.mainUncompletedZsSeries.push({ - time: startTime, - value: zg, - color: '#F1C40F', - lineWidth: 1 - }); - tvWidget.series.mainUncompletedZsSeries.push({ - time: endTime, - value: zg, - color: '#F1C40F', - lineWidth: 1 - }); - tvWidget.series.mainUncompletedZsSeries.push({ - time: startTime, - value: zd, - color: '#F1C40F', - lineWidth: 1 - }); - tvWidget.series.mainUncompletedZsSeries.push({ - time: endTime, - value: zd, - color: '#F1C40F', - lineWidth: 1 - }); - } catch (e) { - console.error('主周期未完成中枢处理出错:', e); - } - }); - } - - // 次周期未完成中枢 - if ($('#showElementZs').is(':checked') && currentData.element_uncompleted_zs_list && currentData.element_uncompleted_zs_list.length > 0) { - console.log(`绘制次周期未完成中枢数据,共${currentData.element_uncompleted_zs_list.length}条`); - - currentData.element_uncompleted_zs_list.forEach(function(zs) { - try { - // 直接使用UTC时间戳(秒) - const startTime = Math.floor(new Date(zs.start_time).getTime() / 1000); - // 未完成中枢的结束时间设为当前K线的最后时间 - const endTime = Math.floor(new Date(currentData.kline_data[currentData.kline_data.length-1].date).getTime() / 1000); - - if (isNaN(startTime) || isNaN(endTime)) { - console.error('次周期未完成中枢时间转换错误:', zs.start_time); - return; - } - - const zg = parseFloat(zs.zg); // 中枢上沿 - const zd = parseFloat(zs.zd); // 中枢下沿 - const gg = parseFloat(zs.gg); // 中枢高高 - const dd = parseFloat(zs.dd); // 中枢低低 - - if (isNaN(zg) || isNaN(zd)) { - console.error('次周期未完成中枢价格转换错误:', zs.zg, zs.zd); - return; - } - - // 创建未完成中枢上边界 - const topSeries = mainChart.addLineSeries({ - color: '#3f51b5', // 次周期中枢颜色 - lineWidth: 1, - lastValueVisible: false, - priceLineVisible: false, - }); - - topSeries.setData([ - { time: startTime, value: zg }, - { time: endTime, value: zg } - ]); - - // 为下边界创建另一条线 - const bottomSeries = mainChart.addLineSeries({ - color: '#3f51b5', // 次周期中枢颜色 - lineWidth: 1, - lastValueVisible: false, - priceLineVisible: false, - }); - - bottomSeries.setData([ - { time: startTime, value: zd }, - { time: endTime, value: zd } - ]); - - // 添加左边界 - const leftSeries = mainChart.addLineSeries({ - color: '#3f51b5', // 次周期中枢颜色 - lineWidth: 1, - lastValueVisible: false, - priceLineVisible: false, - }); - - leftSeries.setData([ - { time: startTime, value: zd }, - { time: startTime, value: zg } - ]); - - // 添加一个标记,标识这是未完成中枢 - const markerSeries = mainChart.addLineSeries({ - lastValueVisible: false, - priceLineVisible: false, - }); - - markerSeries.setMarkers([ - { - time: startTime, - position: 'aboveBar', - color: '#3f51b5', - shape: 'circle', - text: '未完', - size: 1 - } - ]); - - // 绘制gg线(中枢高高) - if (!isNaN(gg) && gg > 0) { - const ggSeries = mainChart.addLineSeries({ - color: '#3f51b5', // 使用次周期中枢自己的颜色 - lineWidth: 1, - lastValueVisible: false, - priceLineVisible: false, - }); - - ggSeries.setData([ - { time: startTime, value: gg }, - { time: endTime, value: gg } - ]); - } - - // 绘制dd线(中枢低低) - if (!isNaN(dd) && dd > 0) { - const ddSeries = mainChart.addLineSeries({ - color: '#3f51b5', // 使用次周期中枢自己的颜色 - lineWidth: 1, - lastValueVisible: false, - priceLineVisible: false, - }); - - ddSeries.setData([ - { time: startTime, value: dd }, - { time: endTime, value: dd } - ]); - } - - // 添加到图表对象 - tvWidget.series.elementUncompletedZsSeries.push({ - time: startTime, - value: zg, - color: '#3f51b5', - lineWidth: 1 - }); - tvWidget.series.elementUncompletedZsSeries.push({ - time: endTime, - value: zg, - color: '#3f51b5', - lineWidth: 1 - }); - tvWidget.series.elementUncompletedZsSeries.push({ - time: startTime, - value: zd, - color: '#3f51b5', - lineWidth: 1 - }); - tvWidget.series.elementUncompletedZsSeries.push({ - time: endTime, - value: zd, - color: '#3f51b5', - lineWidth: 1 - }); - } catch (e) { - console.error('次周期未完成中枢处理出错:', e); - } - }); - } - // 次次周期未完成SEG中枢 - if ($('#showSubSubZs').is(':checked') && currentData.sub_sub_uncompleted_zs_list && currentData.sub_sub_uncompleted_zs_list.length > 0) { - const kdZs = currentData.kline_data || []; - const endTimeZs = kdZs.length ? Math.floor(new Date(kdZs[kdZs.length-1].date).getTime() / 1000) : 0; - const subSubUZsColor = '#00897b'; - currentData.sub_sub_uncompleted_zs_list.forEach(function(zs) { - try { - const startTime = Math.floor(new Date(zs.start_time).getTime() / 1000); - if (isNaN(startTime) || !endTimeZs) return; - const zg = parseFloat(zs.zg); const zd = parseFloat(zs.zd); const gg = parseFloat(zs.gg); const dd = parseFloat(zs.dd); - if (isNaN(zg) || isNaN(zd)) return; - mainChart.addLineSeries({ color: subSubUZsColor, lineWidth: 1, lastValueVisible: false, priceLineVisible: false }).setData([{ time: startTime, value: zg }, { time: endTimeZs, value: zg }]); - mainChart.addLineSeries({ color: subSubUZsColor, lineWidth: 1, lastValueVisible: false, priceLineVisible: false }).setData([{ time: startTime, value: zd }, { time: endTimeZs, value: zd }]); - mainChart.addLineSeries({ color: subSubUZsColor, lineWidth: 1, lastValueVisible: false, priceLineVisible: false }).setData([{ time: startTime, value: zd }, { time: startTime, value: zg }]); - if (!isNaN(gg) && gg > 0) mainChart.addLineSeries({ color: subSubUZsColor, lineWidth: 1, lastValueVisible: false, priceLineVisible: false }).setData([{ time: startTime, value: gg }, { time: endTimeZs, value: gg }]); - if (!isNaN(dd) && dd > 0) mainChart.addLineSeries({ color: subSubUZsColor, lineWidth: 1, lastValueVisible: false, priceLineVisible: false }).setData([{ time: startTime, value: dd }, { time: endTimeZs, value: dd }]); - } catch (e) { console.error('次次周期未完成中枢处理出错:', e); } - }); - } - } else { - console.log('绘制未完成中枢 - 已禁用'); - } - // 未完成BI中枢 - 使用独立的BI开关 - if ($('#showMainBiZs').is(':checked') && currentData.uncompleted_bi_zs_list && currentData.uncompleted_bi_zs_list.length > 0) { - try { console.log(`绘制主周期未完成BI中枢数据,共${currentData.uncompleted_bi_zs_list.length}条`); } catch (e) {} - currentData.uncompleted_bi_zs_list.forEach(function(zs) { - try { - const startTime = Math.floor(new Date(zs.start_time).getTime() / 1000); - const endTime = Math.floor(new Date(currentData.kline_data[currentData.kline_data.length-1].date).getTime() / 1000); - if (isNaN(startTime) || isNaN(endTime)) { return; } - const zg = parseFloat(zs.zg); const zd = parseFloat(zs.zd); const gg = parseFloat(zs.gg); const dd = parseFloat(zs.dd); - if (isNaN(zg) || isNaN(zd)) { return; } - const color = '#F1C40F'; - const topSeries = mainChart.addLineSeries({ color, lineWidth: 1, lastValueVisible: false, priceLineVisible: false }); - topSeries.setData([{ time: startTime, value: zg }, { time: endTime, value: zg }]); - const bottomSeries = mainChart.addLineSeries({ color, lineWidth: 1, lastValueVisible: false, priceLineVisible: false }); - bottomSeries.setData([{ time: startTime, value: zd }, { time: endTime, value: zd }]); - const leftSeries = mainChart.addLineSeries({ color, lineWidth: 1, lastValueVisible: false, priceLineVisible: false }); - leftSeries.setData([{ time: startTime, value: zd }, { time: startTime, value: zg }]); - if (!isNaN(gg) && gg > 0) { - const ggSeries = mainChart.addLineSeries({ color, lineWidth: 1, lastValueVisible: false, priceLineVisible: false }); - ggSeries.setData([{ time: startTime, value: gg }, { time: endTime, value: gg }]); - } - if (!isNaN(dd) && dd > 0) { - const ddSeries = mainChart.addLineSeries({ color, lineWidth: 1, lastValueVisible: false, priceLineVisible: false }); - ddSeries.setData([{ time: startTime, value: dd }, { time: endTime, value: dd }]); - } - } catch (e) { console.error('主周期未完成BI中枢处理出错:', e); } - }); - } - // 次周期 未完成 BI 中枢 - if ($('#showElementBiZs').is(':checked') && currentData.element_uncompleted_bi_zs_list && currentData.element_uncompleted_bi_zs_list.length > 0) { - try { console.log(`绘制次周期未完成BI中枢数据,共${currentData.element_uncompleted_bi_zs_list.length}条`); } catch (e) {} - currentData.element_uncompleted_bi_zs_list.forEach(function(zs) { - try { - const startTime = Math.floor(new Date(zs.start_time).getTime() / 1000); - const endTime = Math.floor(new Date(currentData.kline_data[currentData.kline_data.length-1].date).getTime() / 1000); - if (isNaN(startTime) || isNaN(endTime)) { return; } - const zg = parseFloat(zs.zg); const zd = parseFloat(zs.zd); const gg = parseFloat(zs.gg); const dd = parseFloat(zs.dd); - if (isNaN(zg) || isNaN(zd)) { return; } - const color = '#3f51b5'; - const topSeries = mainChart.addLineSeries({ color, lineWidth: 1, lastValueVisible: false, priceLineVisible: false }); - topSeries.setData([{ time: startTime, value: zg }, { time: endTime, value: zg }]); - const bottomSeries = mainChart.addLineSeries({ color, lineWidth: 1, lastValueVisible: false, priceLineVisible: false }); - bottomSeries.setData([{ time: startTime, value: zd }, { time: endTime, value: zd }]); - const leftSeries = mainChart.addLineSeries({ color, lineWidth: 1, lastValueVisible: false, priceLineVisible: false }); - leftSeries.setData([{ time: startTime, value: zd }, { time: startTime, value: zg }]); - if (!isNaN(gg) && gg > 0) { - const ggSeries = mainChart.addLineSeries({ color, lineWidth: 1, lastValueVisible: false, priceLineVisible: false }); - ggSeries.setData([{ time: startTime, value: gg }, { time: endTime, value: gg }]); - } - if (!isNaN(dd) && dd > 0) { - const ddSeries = mainChart.addLineSeries({ color, lineWidth: 1, lastValueVisible: false, priceLineVisible: false }); - ddSeries.setData([{ time: startTime, value: dd }, { time: endTime, value: dd }]); - } - } catch (e) { console.error('次周期未完成BI中枢处理出错:', e); } - }); - } - // 添加买卖点标记(新版:基于 bsp_list / element_bsp_list / sub_sub_bsp_list,按与 KLC 分型相同方式合并到主图标记) - if ($('#showMainBsp').is(':checked') || $('#showElementBsp').is(':checked') || $('#showSubSubBsp').is(':checked')) { - console.log('绘制买卖点(BSP) - 已启用'); - - // BSP 样式定义 - const BSP_STYLE = { - 'BSP1_BUY': { color: '#FF1744', text: 'B1', position: 'belowBar', size: 0.5 }, - 'BSP2_BUY': { color: '#F50057', text: 'B2', position: 'belowBar', size: 0.5 }, - 'BSP3_BUY': { color: '#D500F9', text: 'B3', position: 'belowBar', size: 0.5 }, - 'BSP1_SELL': { color: '#00E676', text: 'S1', position: 'aboveBar', size: 0.5 }, - 'BSP2_SELL': { color: '#00B0FF', text: 'S2', position: 'aboveBar', size: 0.5 }, - 'BSP3_SELL': { color: '#8B4513', text: 'S3', position: 'aboveBar', size: 0.5 }, - }; - - const getBspStyleKey = (bsp) => { - // 统一 BSP key: - // - type 可能是 "BSP1"/"BSP2"/"BSP3", - // - 也可能是后端给的 "B1"/"B2"/"B3" 或 "S1"/"S2"/"S3" - // 最终都映射为 "BSP1_BUY" / "BSP1_SELL" 这类 key,方便复用现有样式定义 - let type = (bsp.type || '').toUpperCase(); - const dir = (bsp.dir || '').toUpperCase(); - - // 若是 "B1" / "B2" / "B3" 或 "S1" / "S2" / "S3" 形式,则提取数字并映射成 "BSP{n}" - const simpleMatch = type.match(/^([BS])(\d)$/); - if (simpleMatch) { - const n = simpleMatch[2]; // "1" / "2" / "3" - type = 'BSP' + n; - } - - return type + '_' + dir; - }; - - // 收集所有 BSP 标记 - const allBspMarkers = []; - - // 主周期买卖点 - // 兼容不同字段命名:优先使用 bsp_list,若不存在则尝试 bsp - const mainBspList = currentData.bsp_list || currentData.bsp || []; - // 调试:打印前几条主周期 BSP 的 key,方便排查样式不匹配问题 - if (mainBspList.length > 0) { - console.log( - '主周期 BSP 示例 (前5条):', - mainBspList.slice(0, 5).map(b => ({ - raw_type: b.type, - raw_dir: b.dir, - key: getBspStyleKey(b) - })) - ); - } - if ($('#showMainBsp').is(':checked') && mainBspList.length > 0) { - console.log(`绘制主周期买卖点,共${mainBspList.length}条`); - mainBspList.forEach(function(bsp) { - try { - const ts = Math.floor(new Date(bsp.time).getTime() / 1000); - if (isNaN(ts)) return; - const key = getBspStyleKey(bsp); - const style = BSP_STYLE[key] || { color: '#999', shape: 'circle', text: '?', position: 'inBar' }; - const sureText = bsp.is_sure ? '' : '?'; - allBspMarkers.push({ - time: ts, - position: style.position, - color: style.color, - shape: style.shape, - text: style.text + sureText, - size: 2 - }); - } catch (e) { - console.error('主周期BSP处理出错:', e); - } - }); - } - - // 次周期买卖点 - // 兼容不同字段命名:优先使用 element_bsp_list,若不存在则尝试 element_bsp - const elementBspList = currentData.element_bsp_list || currentData.element_bsp || []; - // 调试:打印前几条次周期 BSP 的 key - if (elementBspList.length > 0) { - console.log( - '次周期 BSP 示例 (前5条):', - elementBspList.slice(0, 5).map(b => ({ - raw_type: b.type, - raw_dir: b.dir, - key: getBspStyleKey(b) - })) - ); - } - if ($('#showElementBsp').is(':checked') && elementBspList.length > 0) { - console.log(`绘制次周期买卖点,共${elementBspList.length}条`); - elementBspList.forEach(function(bsp) { - try { - const ts = Math.floor(new Date(bsp.time).getTime() / 1000); - if (isNaN(ts)) return; - const key = getBspStyleKey(bsp); - const style = BSP_STYLE[key] || { color: '#999', shape: 'circle', text: '?', position: 'inBar' }; - const sureText = bsp.is_sure ? '' : '?'; - // 次周期使用稍小的标记和不同前缀以区分 - allBspMarkers.push({ - time: ts, - position: style.position, - color: style.color, - shape: style.shape, - text: 'e' + style.text + sureText, - size: 1 - }); - } catch (e) { - console.error('次周期BSP处理出错:', e); - } - }); - } - - // 次次周期买卖点 - const subSubBspList = currentData.sub_sub_bsp_list || []; - if ($('#showSubSubBsp').is(':checked') && subSubBspList.length > 0) { - subSubBspList.forEach(function(bsp) { - try { - const ts = Math.floor(new Date(bsp.time).getTime() / 1000); - if (isNaN(ts)) return; - const key = getBspStyleKey(bsp); - const style = BSP_STYLE[key] || { color: '#999', shape: 'circle', text: '?', position: 'inBar' }; - const sureText = bsp.is_sure ? '' : '?'; - allBspMarkers.push({ - time: ts, - position: style.position, - color: '#00897b', - shape: style.shape, - text: 's' + (style.text || '?') + sureText, - size: 1 - }); - } catch (e) { - console.error('次次周期BSP处理出错:', e); - } - }); - } - - // 将 BSP 标记挂到全局,后面与 KLC 分型等标记一起合并到主系列上 - if (allBspMarkers.length > 0) { - // 按时间排序(lightweight-charts 要求标记按时间升序) - allBspMarkers.sort((a, b) => a.time - b.time); - window.bspMarkers = allBspMarkers; - console.log(`准备合并 ${allBspMarkers.length} 个BSP标记到主图标记中`); - } else { - window.bspMarkers = []; - } - } else { - // 关闭 BSP 显示时,清空全局 BSP 标记 - window.bspMarkers = []; - } - - // 添加买卖点标记(旧版,保留兼容) - // 这里为了与主面板上的「买卖点」开关保持一致, - // 同时响应顶部的 `#showMainBsp` 复选框 - if ($('#showTradePoints').is(':checked') || $('#showMainBsp').is(':checked')) { - console.log('绘制买卖点 - 已启用(来源: showTradePoints / showMainBsp)'); - - // 优先使用小周期数据,如果不存在则使用主周期数据 - const tradePointsData = currentData.element_trade_points || currentData.trade_points; - console.log(`绘制${currentData.element_trade_points ? '元素周期' : '主周期'}买卖点数据,共${tradePointsData ? tradePointsData.length : 0}条`); - - // 调试信息 - 输出完整的买卖点数据 - if (tradePointsData && tradePointsData.length > 0) { - console.log("买卖点数据样例:", tradePointsData[0]); - - // 检查数据格式,如果time不是标准格式,进行格式化处理 - const checkDataFormat = () => { - for (let i = 0; i < tradePointsData.length; i++) { - if (tradePointsData[i].time) { - // 确保时间是标准格式 - try { - const timeValue = new Date(tradePointsData[i].time); - if (isNaN(timeValue.getTime())) { - console.error(`买卖点 #${i} 时间格式无效:`, tradePointsData[i].time); - } - } catch (e) { - console.error(`买卖点 #${i} 时间格式异常:`, e); - } - } else { - console.error(`买卖点 #${i} 缺少时间属性`); - } - } - }; - - // 执行格式检查 - checkDataFormat(); - - // 对买卖点按时间排序,用于后续优化显示 - const sortedPoints = [...tradePointsData].sort((a, b) => { - return new Date(a.time) - new Date(b.time); - }); - - // 记录已处理的时间点 - 按类型分开计数 - const processedTimes = {}; - - // 创建买卖点标记系列 - const buyMarkers = []; - const sellMarkers = []; - - // 计数器,追踪成功和失败的处理次数 - let successCount = 0; - let errorCount = 0; - - sortedPoints.forEach(function(point, index) { - try { - // 检查所有必要的属性是否存在且有效 - if (!point.time || !point.price || point.type === undefined) { - console.error(`买卖点 #${index} 数据不完整:`, point); - errorCount++; - return; - } - - const time = Math.floor(new Date(point.time).getTime() / 1000); - const price = parseFloat(point.price); - const type = parseInt(point.type); - - if (isNaN(time) || isNaN(price) || isNaN(type)) { - console.error(`买卖点 #${index} 数据格式错误:`, - { time: isNaN(time), price: isNaN(price), type: isNaN(type) }, point); - errorCount++; - return; - } - - // 获取买卖点样式 - const style = TRADE_POINT_STYLE[type] || { - color: '#999999', - shape: 'circle', - text: '?', - size: 1 - }; - - // 初始化该时间点的类型计数器 - if (!processedTimes[time]) { - processedTimes[time] = {}; - } - - // 优化:检查是否有相同时间点和相同类型的标记,如果有,进行类型内的偏移 - let stackIndex = 0; - if (processedTimes[time][type]) { - // 已经有相同时间和类型的标记,记录堆叠索引 - stackIndex = processedTimes[time][type]; - processedTimes[time][type]++; - } else { - // 第一次出现这个时间点的这个类型 - processedTimes[time][type] = 1; - } - - // 为不同类型的买卖点获取基础垂直偏移系数 - const baseOffset = TRADE_POINT_OFFSET[type] || 0; - - // 创建标记对象,包含额外的信息用于悬停提示 - const marker = { - time: time, - position: 'inBar', // 改为在K线内部显示,不影响数据 - color: style.color, - shape: style.shape, - text: style.text, - size: style.size, - // 记录堆叠索引 - stackIndex: stackIndex, - // 添加悬停提示的数据 - tooltip: `${point.desc || (type > 0 ? '买点' : '卖点')}
- 时间: ${formatTime(point.time)}
- 价格: ${price.toFixed(2)}`, - // 额外添加基础类型偏移 - baseOffset: baseOffset, - // 添加边框 - borderColor: 'white', - borderWidth: 1, - // 添加价格偏移系数 - pricePercentOffset: PRICE_PERCENT_OFFSET[type] || 0, - // 保存实际价格用于计算 - price: price, - // 保存类型 - type: type - }; - - // 区分买卖点 - if (type > 0) { - buyMarkers.push(marker); - } else { - sellMarkers.push(marker); - } - - successCount++; - } catch (e) { - console.error(`处理买卖点 #${index} 出错:`, e, point); - errorCount++; - } - }); - - console.log(`买卖点处理完成: 成功=${successCount}, 失败=${errorCount}, 买点=${buyMarkers.length}, 卖点=${sellMarkers.length}`); - - // 分别添加买卖点标记 - if (buyMarkers.length > 0) { - const buyMarkersSeries = mainChart.addLineSeries({ - lastValueVisible: false, - priceLineVisible: false, - lineVisible: false, - color: 'transparent', - title: '买点' - }); - - // 使用主K线的收盘价作为基准数据,保证买点标记与价格在同一纵轴范围 - if (Array.isArray(candles) && candles.length > 0) { - const baseData = candles.map(c => ({ time: c.time, value: c.close })); - buyMarkersSeries.setData(baseData); - } else { - // 兜底:至少一个数据点,避免报错 - buyMarkersSeries.setData([{ time: buyMarkers[0].time, value: buyMarkers[0].price || 0 }]); - } - - try { - // 设置买点标记:文字在价格上方,仅显示文字不显示形状 - buyMarkersSeries.setMarkers( - buyMarkers.map(marker => { - // 使用实际价格位置,买点显示在K线上方 - return { - ...marker, - position: 'aboveBar', // 买点:价格上方 - price: marker.price, - // 隐藏形状,仅保留文字 - size: 0, - color: 'rgba(0, 0, 0, 0)' - }; - }) - ); - console.log(`成功添加 ${buyMarkers.length} 个买点标记`); - } catch (e) { - console.error("设置买点标记时出错:", e); - } - } - - if (sellMarkers.length > 0) { - const sellMarkersSeries = mainChart.addLineSeries({ - lastValueVisible: false, - priceLineVisible: false, - lineVisible: false, - color: 'transparent', - title: '卖点' - }); - - // 使用主K线的收盘价作为基准数据,保证卖点标记与价格在同一纵轴范围 - if (Array.isArray(candles) && candles.length > 0) { - const baseData = candles.map(c => ({ time: c.time, value: c.close })); - sellMarkersSeries.setData(baseData); - } else { - // 兜底:至少一个数据点,避免报错 - sellMarkersSeries.setData([{ time: sellMarkers[0].time, value: sellMarkers[0].price || 0 }]); - } - - try { - // 设置卖点标记:文字在价格下方,仅显示文字不显示形状 - sellMarkersSeries.setMarkers( - sellMarkers.map(marker => { - // 使用实际价格位置,卖点显示在K线下方 - return { - ...marker, - position: 'belowBar', // 卖点:价格下方 - price: marker.price, - // 隐藏形状,仅保留文字 - size: 0, - color: 'rgba(0, 0, 0, 0)' - }; - }) - ); - console.log(`成功添加 ${sellMarkers.length} 个卖点标记`); - } catch (e) { - console.error("设置卖点标记时出错:", e); - } - } - - // 添加鼠标悬停事件显示提示 - mainChart.subscribeCrosshairMove(param => { - // 十字线同步到其他图表 - 通过DOM元素绘制垂直线实现虚线延长效果 - if (param.time && param.point && volumeChart) { - try { - // 清除之前的十字线标记 - const existingVolumeLines = document.querySelectorAll('.volume-crosshair-line'); - existingVolumeLines.forEach(line => line.remove()); - const existingAtrLines = document.querySelectorAll('.atr-crosshair-line'); - existingAtrLines.forEach(line => line.remove()); - const existingMacdLines = document.querySelectorAll('.macd-crosshair-line'); - existingMacdLines.forEach(line => line.remove()); - const existingChanMacdLines = document.querySelectorAll('.chanmacd-crosshair-line'); - existingChanMacdLines.forEach(line => line.remove()); - - // 获取时间对应的坐标位置 - const mainTimeCoordinate = mainChart.timeScale().timeToCoordinate(param.time); - if (mainTimeCoordinate !== null) { - // 获取主图容器的位置 - const mainChartRect = mainChartContainer.getBoundingClientRect(); - - // 在交易量图上绘制垂直线 - const volumeTimeCoordinate = volumeChart.timeScale().timeToCoordinate(param.time); - if (volumeTimeCoordinate !== null) { - const volumeChartRect = volumeChartContainer.getBoundingClientRect(); - const volumeLine = document.createElement('div'); - volumeLine.className = 'volume-crosshair-line'; - volumeLine.style.position = 'fixed'; // 改为fixed定位 - volumeLine.style.left = (volumeChartRect.left + volumeTimeCoordinate) + 'px'; - volumeLine.style.top = volumeChartRect.top + 'px'; - volumeLine.style.width = '1px'; - volumeLine.style.height = volumeChartRect.height + 'px'; - volumeLine.style.backgroundColor = 'rgba(128, 128, 128, 0.5)'; - volumeLine.style.borderLeft = '1px dashed rgba(128, 128, 128, 0.5)'; - volumeLine.style.pointerEvents = 'none'; - volumeLine.style.zIndex = '1000'; - document.body.appendChild(volumeLine); - } - - // 在ATR图上绘制垂直线 - if (atrChart && atrChartContainer) { - const atrTimeCoordinate = atrChart.timeScale().timeToCoordinate(param.time); - if (atrTimeCoordinate !== null) { - const atrChartRect = atrChartContainer.getBoundingClientRect(); - const atrLine = document.createElement('div'); - atrLine.className = 'atr-crosshair-line'; - atrLine.style.position = 'fixed'; // 改为fixed定位 - atrLine.style.left = (atrChartRect.left + atrTimeCoordinate) + 'px'; - atrLine.style.top = atrChartRect.top + 'px'; - atrLine.style.width = '1px'; - atrLine.style.height = atrChartRect.height + 'px'; - atrLine.style.backgroundColor = 'rgba(128, 128, 128, 0.5)'; - atrLine.style.borderLeft = '1px dashed rgba(128, 128, 128, 0.5)'; - atrLine.style.pointerEvents = 'none'; - atrLine.style.zIndex = '1000'; - document.body.appendChild(atrLine); - } - } - - // 如果有MACD图,也在MACD图上绘制垂直线 - if (showMacd && macdChart && macdChartContainer) { - const macdTimeCoordinate = macdChart.timeScale().timeToCoordinate(param.time); - if (macdTimeCoordinate !== null) { - const macdChartRect = macdChartContainer.getBoundingClientRect(); - const macdLine = document.createElement('div'); - macdLine.className = 'macd-crosshair-line'; - macdLine.style.position = 'fixed'; // 改为fixed定位 - macdLine.style.left = (macdChartRect.left + macdTimeCoordinate) + 'px'; - macdLine.style.top = macdChartRect.top + 'px'; - macdLine.style.width = '1px'; - macdLine.style.height = macdChartRect.height + 'px'; - macdLine.style.backgroundColor = 'rgba(128, 128, 128, 0.5)'; - macdLine.style.borderLeft = '1px dashed rgba(128, 128, 128, 0.5)'; - macdLine.style.pointerEvents = 'none'; - macdLine.style.zIndex = '1000'; - document.body.appendChild(macdLine); - } - } - - // 如果有ChanMACD图,也在ChanMACD图上绘制垂直线 - if (showMacd && chanMacdChart && chanMacdChartContainer) { - const chanMacdTimeCoordinate = chanMacdChart.timeScale().timeToCoordinate(param.time); - if (chanMacdTimeCoordinate !== null) { - const chanMacdChartRect = chanMacdChartContainer.getBoundingClientRect(); - console.log('ChanMACD图表位置:', { - left: chanMacdChartRect.left, - top: chanMacdChartRect.top, - width: chanMacdChartRect.width, - height: chanMacdChartRect.height, - timeCoordinate: chanMacdTimeCoordinate - }); - const chanMacdLine = document.createElement('div'); - chanMacdLine.className = 'chanmacd-crosshair-line'; - chanMacdLine.style.position = 'fixed'; - chanMacdLine.style.left = (chanMacdChartRect.left + chanMacdTimeCoordinate) + 'px'; - chanMacdLine.style.top = chanMacdChartRect.top + 'px'; - chanMacdLine.style.width = '1px'; - chanMacdLine.style.height = chanMacdChartRect.height + 'px'; - chanMacdLine.style.backgroundColor = 'rgba(128, 128, 128, 0.5)'; - chanMacdLine.style.borderLeft = '1px dashed rgba(128, 128, 128, 0.5)'; - chanMacdLine.style.pointerEvents = 'none'; - chanMacdLine.style.zIndex = '1000'; - document.body.appendChild(chanMacdLine); - console.log('ChanMACD垂直线已创建,位置:', chanMacdLine.style.left, chanMacdLine.style.top); - } else { - console.log('ChanMACD时间坐标为空'); - } - } else { - console.log('ChanMACD图表条件不满足:', { - showMacd: showMacd, - hasChanMacdChart: !!chanMacdChart, - hasChanMacdChartContainer: !!chanMacdChartContainer - }); - } - - - } - } catch (e) { - console.debug('十字线同步出错:', e); - } - } else { - // 当十字线离开时,清除垂直线 - try { - const existingVolumeLines = document.querySelectorAll('.volume-crosshair-line'); - existingVolumeLines.forEach(line => line.remove()); - const existingAtrLines = document.querySelectorAll('.atr-crosshair-line'); - existingAtrLines.forEach(line => line.remove()); - const existingMacdLines = document.querySelectorAll('.macd-crosshair-line'); - existingMacdLines.forEach(line => line.remove()); - const existingChanMacdLines = document.querySelectorAll('.chanmacd-crosshair-line'); - existingChanMacdLines.forEach(line => line.remove()); - } catch (e) { - console.debug('清除十字线时出错:', e); - } - } - - if (param.time && param.point) { - const timeStr = param.time; - const markers = [...buyMarkers, ...sellMarkers].filter(m => m.time === timeStr); - - // 同时检查分型标记 - const fxMarkers = (window.fxMarkers || []).filter(m => m.time === timeStr); - const allMarkers = [...markers, ...fxMarkers]; - - // 显示时区调试信息 - if (window.debugMode) { - const timezone = $('#timezone').val(); - const formattedTime = formatTimeWithTimezone(timeStr * 1000, timezone); - - // 获取当前价格 - 通过param.seriesPrices获取 - let priceInfo = ''; - if (param.seriesPrices && param.seriesPrices.size > 0) { - // 依次从当前可能的主系列中获取价格 - if (tvWidget.series.candleSeries && param.seriesPrices.get(tvWidget.series.candleSeries)) { - const price = param.seriesPrices.get(tvWidget.series.candleSeries); - priceInfo = `价格: ${price.toFixed(2)}`; - } else if (tvWidget.series.renkoSeries && param.seriesPrices.get(tvWidget.series.renkoSeries)) { - const price = param.seriesPrices.get(tvWidget.series.renkoSeries); - priceInfo = `价格: ${price.toFixed(2)}`; - } else if (tvWidget.series.heikinSeries && param.seriesPrices.get(tvWidget.series.heikinSeries)) { - const price = param.seriesPrices.get(tvWidget.series.heikinSeries); - priceInfo = `价格: ${price.toFixed(2)}`; - } else if (tvWidget.series.barSeries && param.seriesPrices.get(tvWidget.series.barSeries)) { - const price = param.seriesPrices.get(tvWidget.series.barSeries); - priceInfo = `价格: ${price.toFixed(2)}`; - } else if (tvWidget.series.lineSeries && param.seriesPrices.get(tvWidget.series.lineSeries)) { - const price = param.seriesPrices.get(tvWidget.series.lineSeries); - priceInfo = `价格: ${price.toFixed(2)}`; - } else if (tvWidget.series.areaSeries && param.seriesPrices.get(tvWidget.series.areaSeries)) { - const price = param.seriesPrices.get(tvWidget.series.areaSeries); - priceInfo = `价格: ${price.toFixed(2)}`; - } else if (tvWidget.series.baselineSeries && param.seriesPrices.get(tvWidget.series.baselineSeries)) { - const price = param.seriesPrices.get(tvWidget.series.baselineSeries); - priceInfo = `价格: ${price.toFixed(2)}`; - } - // 如果没有蜡烛图系列价格,尝试从区域图系列获取 - else if (tvWidget.series.areaSeries && param.seriesPrices.get(tvWidget.series.areaSeries)) { - const price = param.seriesPrices.get(tvWidget.series.areaSeries); - priceInfo = `价格: ${price.toFixed(2)}`; - } - // 如果没有蜡烛图系列价格,尝试从基线图系列获取 - else if (tvWidget.series.baselineSeries && param.seriesPrices.get(tvWidget.series.baselineSeries)) { - const price = param.seriesPrices.get(tvWidget.series.baselineSeries); - priceInfo = `价格: ${price.toFixed(2)}`; - } - } - - // 仅记录最简短的调试信息 - console.debug(`十字线: ${timeStr} -> ${formattedTime} (${timezone})`); - - // 显示自定义时区工具提示,包含价格信息 - crosshairTooltip.innerHTML = `
时间: ${formattedTime}
` + - (priceInfo ? `
${priceInfo}
` : ''); - crosshairTooltip.style.display = 'block'; - crosshairTooltip.style.left = (param.point.x + 15) + 'px'; - crosshairTooltip.style.top = (param.point.y - 30) + 'px'; - } - - if (allMarkers.length > 0) { - // 有买卖点或分型标记,显示自定义提示 - const tooltips = allMarkers.map(m => m.tooltip).join('

'); - tooltipElement.innerHTML = tooltips; - tooltipElement.style.display = 'block'; - tooltipElement.style.left = (param.point.x + 15) + 'px'; - tooltipElement.style.top = (param.point.y + 15) + 'px'; - } else { - // 隐藏提示 - tooltipElement.style.display = 'none'; - } - } else { - // 隐藏提示 - tooltipElement.style.display = 'none'; - crosshairTooltip.style.display = 'none'; - } - }); - - // 处理图表缩放、平移等事件,隐藏提示 - mainChart.timeScale().subscribeVisibleTimeRangeChange(() => { - tooltipElement.style.display = 'none'; - crosshairTooltip.style.display = 'none'; - }); - } - } else { - console.log('绘制买卖点 - 已禁用'); - } - // 绘制布林带 - if ($('#showMainBollinger').is(':checked') || $('#showElementBollinger').is(':checked')) { - console.log('绘制布林带 - 已启用'); - - // 主周期布林带 - if ($('#showMainBollinger').is(':checked') && currentData.bollinger && currentData.bollinger.upper && currentData.bollinger.lower && currentData.bollinger.middle) { - console.log(`绘制主周期布林带数据,共${currentData.bollinger.upper.length}条`); - - // 准备布林带数据 - const upperBandData = []; - const lowerBandData = []; - const middleBandData = []; - - // 主周期布林带始终使用主周期K线数据作为时间源 - const mainKlineData = currentData.kline_data; - - for (let i = 0; i < mainKlineData.length && i < currentData.bollinger.upper.length; i++) { - const kline = mainKlineData[i]; - const timestamp = Math.floor(new Date(kline.date).getTime() / 1000); - - // 只添加非0的有效数据点 - if (currentData.bollinger.upper[i] && currentData.bollinger.upper[i] !== 0) { - upperBandData.push({ - time: timestamp, - value: currentData.bollinger.upper[i] - }); - } - - if (currentData.bollinger.lower[i] && currentData.bollinger.lower[i] !== 0) { - lowerBandData.push({ - time: timestamp, - value: currentData.bollinger.lower[i] - }); - } - - if (currentData.bollinger.middle[i] && currentData.bollinger.middle[i] !== 0) { - middleBandData.push({ - time: timestamp, - value: currentData.bollinger.middle[i] - }); - } - } - - // 创建布林带上轨 - const upperBandSeries = mainChart.addLineSeries({ - color: '#2196F3', - lineWidth: 1, - lineStyle: 2, // 虚线 - lastValueVisible: false, - priceLineVisible: false, - title: '布林上轨' - }); - upperBandSeries.setData(upperBandData); - - // 创建布林带下轨 - const lowerBandSeries = mainChart.addLineSeries({ - color: '#2196F3', - lineWidth: 1, - lineStyle: 2, // 虚线 - lastValueVisible: false, - priceLineVisible: false, - title: '布林下轨' - }); - lowerBandSeries.setData(lowerBandData); - - // 创建布林带中轨(移动平均线) - const middleBandSeries = mainChart.addLineSeries({ - color: '#FF9800', - lineWidth: 1, - lastValueVisible: false, - priceLineVisible: false, - title: '布林中轨' - }); - middleBandSeries.setData(middleBandData); - - // 保存到tvWidget.series对象 - tvWidget.series.mainBollingerSeries.push(upperBandSeries); - tvWidget.series.mainBollingerSeries.push(lowerBandSeries); - tvWidget.series.mainBollingerSeries.push(middleBandSeries); - - console.log('主周期布林带绘制完成'); - } - - // 次周期布林带 - if ($('#showElementBollinger').is(':checked') && currentData.element_bollinger && currentData.element_bollinger.upper && currentData.element_bollinger.lower && currentData.element_bollinger.middle) { - console.log(`绘制次周期布林带数据,共${currentData.element_bollinger.upper.length}条`); - - // 准备次周期布林带数据 - const elementUpperBandData = []; - const elementLowerBandData = []; - const elementMiddleBandData = []; - - // 使用次周期K线数据 - const elementKlineData = currentData.element_kline_data || currentData.kline_data; - - for (let i = 0; i < elementKlineData.length && i < currentData.element_bollinger.upper.length; i++) { - const kline = elementKlineData[i]; - const timestamp = Math.floor(new Date(kline.date).getTime() / 1000); - - // 只添加非0的有效数据点 - if (currentData.element_bollinger.upper[i] && currentData.element_bollinger.upper[i] !== 0) { - elementUpperBandData.push({ - time: timestamp, - value: currentData.element_bollinger.upper[i] - }); - } - - if (currentData.element_bollinger.lower[i] && currentData.element_bollinger.lower[i] !== 0) { - elementLowerBandData.push({ - time: timestamp, - value: currentData.element_bollinger.lower[i] - }); - } - - if (currentData.element_bollinger.middle[i] && currentData.element_bollinger.middle[i] !== 0) { - elementMiddleBandData.push({ - time: timestamp, - value: currentData.element_bollinger.middle[i] - }); - } - } - - // 创建次周期布林带上轨 - const elementUpperBandSeries = mainChart.addLineSeries({ - color: '#9C27B0', - lineWidth: 1, - lineStyle: 2, // 虚线 - lastValueVisible: false, - priceLineVisible: false, - title: '次周期布林上轨' - }); - elementUpperBandSeries.setData(elementUpperBandData); - - // 创建次周期布林带下轨 - const elementLowerBandSeries = mainChart.addLineSeries({ - color: '#9C27B0', - lineWidth: 1, - lineStyle: 2, // 虚线 - lastValueVisible: false, - priceLineVisible: false, - title: '次周期布林下轨' - }); - elementLowerBandSeries.setData(elementLowerBandData); - - // 创建次周期布林带中轨 - const elementMiddleBandSeries = mainChart.addLineSeries({ - color: '#E91E63', - lineWidth: 1, - lastValueVisible: false, - priceLineVisible: false, - title: '次周期布林中轨' - }); - elementMiddleBandSeries.setData(elementMiddleBandData); - - // 保存到tvWidget.series对象 - tvWidget.series.elementBollingerSeries.push(elementUpperBandSeries); - tvWidget.series.elementBollingerSeries.push(elementLowerBandSeries); - tvWidget.series.elementBollingerSeries.push(elementMiddleBandSeries); - - console.log('次周期布林带绘制完成'); - } - } else { - console.log('绘制布林带 - 已禁用'); - } - - // 绘制分型类型标签 - console.log('=== 开始检查分型显示条件 ==='); - console.log('showKlcFxType勾选状态:', $('#showKlcFxType').is(':checked')); - console.log('showKluFxType勾选状态:', $('#showKluFxType').is(':checked')); - console.log('currentData.klc_fx_info存在:', !!currentData.klc_fx_info); - console.log('currentData.klu_fx_info存在:', !!currentData.klu_fx_info); - console.log('currentData.klc_fx_info长度:', currentData.klc_fx_info ? currentData.klc_fx_info.length : 'undefined'); - console.log('currentData.klu_fx_info长度:', currentData.klu_fx_info ? currentData.klu_fx_info.length : 'undefined'); - if (currentData.klc_fx_info && currentData.klc_fx_info.length > 0) { - console.log('前3个klc分型数据样本:', currentData.klc_fx_info.slice(0, 3)); - } - if (currentData.klu_fx_info && currentData.klu_fx_info.length > 0) { - console.log('前3个klu分型数据样本:', currentData.klu_fx_info.slice(0, 3)); - } - // 收集所有主周期分型标记 - const allMainFxMarkers = []; - const mainFxMarkers = []; // 用于tooltip支持 - // 处理主周期KLC分型 - if ($('#showKlcFxType').is(':checked') && currentData.klc_fx_info && currentData.klc_fx_info.length > 0) { - console.log(`绘制主周期K线合并分型标签,共${currentData.klc_fx_info.length}条`); - - currentData.klc_fx_info.forEach(function(fx) { - try { - // 直接使用UTC时间戳(秒) - const timestamp = Math.floor(new Date(fx.time).getTime() / 1000); - const price = parseFloat(fx.price); - - if (isNaN(timestamp) || isNaN(price)) { - console.error('主周期KLC分型时间或价格转换错误:', fx.time, fx.price); - return; - } - // 主周期 KLC - // 确定颜色和位置 - const color = fx.is_bottom ? '#28a745' : '#dc3545'; // 底分型绿色,顶分型红色 - - // 根据强度等级调整颜色强度 - let strengthColor = color; - - // 构建显示文本,包含分型类型和强度信息 - let displayText = `${fx.fx_strength.toFixed(1)}`; - if (fx.fx_strength < 1.0) { // 降低阈值,让更多分型显示 - displayText = fx.fx_strength >= 0.8 ? '' : '' // 0.8以上显示点,0.8以下不显示文本 - } - displayText = fx.fx_type.replace("TOP", "").replace("BOTTOM", "").replace("11", "").replace("21", "").replace("31", "").replace("41", "").replace("51", "").replace("01", ""); - // 添加标记配置 - const markerConfig = { - time: timestamp, - position: fx.is_bottom ? 'belowBar' : 'aboveBar', - color: strengthColor, - shape: 'triangle', - text: displayText, - size: 2 // 调整尺寸,强分型稍大,普通分型更小 - }; - - allMainFxMarkers.push(markerConfig); - - // 画虚线分型框(根据 start/end + high/low) - if (fx.start_time && fx.end_time && fx.high !== null && fx.high !== undefined && fx.low !== null && fx.low !== undefined) { - const startTs = Math.floor(new Date(fx.start_time).getTime() / 1000); - const endTs = Math.floor(new Date(fx.end_time).getTime() / 1000); - const high = parseFloat(fx.high); - const low = parseFloat(fx.low); - - if (!isNaN(startTs) && !isNaN(endTs) && !isNaN(high) && !isNaN(low)) { - const boxHigh = Math.max(high, low); - const boxLow = Math.min(high, low); - const boxColor = strengthColor; - - const topSeries = mainChart.addLineSeries({ - color: boxColor, - lineWidth: 1, - lineStyle: 2, // 虚线 - lastValueVisible: false, - priceLineVisible: false, - crosshairMarkerVisible: false, - }); - topSeries.setData([{ time: startTs, value: boxHigh }, { time: endTs, value: boxHigh }]); - - const bottomSeries = mainChart.addLineSeries({ - color: boxColor, - lineWidth: 1, - lineStyle: 2, // 虚线 - lastValueVisible: false, - priceLineVisible: false, - crosshairMarkerVisible: false, - }); - bottomSeries.setData([{ time: startTs, value: boxLow }, { time: endTs, value: boxLow }]); - - const leftSeries = mainChart.addLineSeries({ - color: boxColor, - lineWidth: 1, - lineStyle: 2, // 虚线 - lastValueVisible: false, - priceLineVisible: false, - crosshairMarkerVisible: false, - }); - // 左边竖线:同一 time 上下两个点(和你已有ZS绘制写法保持一致) - leftSeries.setData([{ time: startTs, value: boxLow }, { time: startTs, value: boxHigh }]); - - const rightSeries = mainChart.addLineSeries({ - color: boxColor, - lineWidth: 1, - lineStyle: 2, // 虚线 - lastValueVisible: false, - priceLineVisible: false, - crosshairMarkerVisible: false, - }); - rightSeries.setData([{ time: endTs, value: boxLow }, { time: endTs, value: boxHigh }]); - - if (!tvWidget.series.mainKlcFxBoxSeries) tvWidget.series.mainKlcFxBoxSeries = []; - tvWidget.series.mainKlcFxBoxSeries.push(topSeries, bottomSeries, leftSeries, rightSeries); - } - } - - // 创建分型标记对象,包含tooltip信息 - const fxMarker = { - time: timestamp, - tooltip: `
- 主周期${fx.is_bottom ? '底分型' : '顶分型'}(合): ${fx.fx_type}
- 强度分数: ${fx.fx_strength}分
- 强度等级: ${fx.fx_strength_level}
- 是否强分型: ${fx.is_strong_fx ? '是' : '否'}
- 价格: ${price.toFixed(4)}
- 时间: ${fx.time} -
` - }; - - mainFxMarkers.push(fxMarker); - - } catch (e) { - console.error('绘制主周期KLC分型标签出错:', e); - } - }); - } - - // 处理主周期KLU分型 - if ($('#showKluFxType').is(':checked') && currentData.klu_fx_info && currentData.klu_fx_info.length > 0) { - console.log(`绘制主周期K线未合并分型标签,共${currentData.klu_fx_info.length}条`); - - currentData.klu_fx_info.forEach(function(fx) { - try { - // 直接使用UTC时间戳(秒) - const timestamp = Math.floor(new Date(fx.time).getTime() / 1000); - const price = parseFloat(fx.price); - - if (isNaN(timestamp) || isNaN(price)) { - console.error('主周期KLU分型时间或价格转换错误:', fx.time, fx.price); - return; - } - - // 主周期 KLU - const color = fx.is_bottom ? '#17a2b8' : '#fd7e14'; // 底分型用青色,顶分型用橙色 - - // 根据强度等级调整颜色强度 - let strengthColor = color; - if (fx.is_strong_fx) { - // 强分型使用更亮的颜色 - strengthColor = fx.is_bottom ? '#20c997' : '#fd7e14'; - } - - // 构建显示文本,包含分型类型和强度信息 - let displayText = `${fx.fx_strength.toFixed(1)}`; - if (fx.fx_strength < 1.0) { // 降低阈值,让更多分型显示 - displayText = fx.fx_strength >= 1.5 ? '' : '' // 0.8以上显示点,0.8以下不显示文本 - } - displayText = fx.fx_type.replace("TOP", "").replace("BOTTOM", "").replace("11", ""); - // 添加标记配置 - const markerConfig = { - time: timestamp, - position: fx.is_bottom ? 'belowBar' : 'aboveBar', - color: strengthColor, - // shape: 'triangle', // 使用三角形区分KLU分型 - text: displayText, - size: fx.is_strong_fx ? 0.8 : 0.5 // KLU分型稍小一些 - }; - - allMainFxMarkers.push(markerConfig); - - // 创建分型标记对象,包含tooltip信息 - const fxMarker = { - time: timestamp, - tooltip: `
- 主周期${fx.is_bottom ? '底分型' : '顶分型'}(原): ${fx.fx_type}
- 强度分数: ${fx.fx_strength}分
- 强度等级: ${fx.fx_strength_level}
- 是否强分型: ${fx.is_strong_fx ? '是' : '否'}
- 价格: ${price.toFixed(4)}
- 时间: ${fx.time} -
` - }; - - mainFxMarkers.push(fxMarker); - - } catch (e) { - console.error('绘制主周期KLU分型标签出错:', e); - } - }); - } - - // 暂存主周期分型标记 - window.mainFxMarkers = allMainFxMarkers; - - // 将分型标记添加到全局markers中以支持tooltip功能 - if (window.fxMarkers) { - window.fxMarkers = [...window.fxMarkers, ...mainFxMarkers]; - } else { - window.fxMarkers = mainFxMarkers; - } - - // 检查是否有任何主周期分型数据 - const hasMainFxData = ($('#showKlcFxType').is(':checked') && currentData.klc_fx_info && currentData.klc_fx_info.length > 0) || - ($('#showKluFxType').is(':checked') && currentData.klu_fx_info && currentData.klu_fx_info.length > 0); - - if (!hasMainFxData) { - console.log('绘制主周期分型标记 - 已禁用或无数据'); - // 清空主周期分型标记 - window.mainFxMarkers = []; - window.fxMarkers = []; - } - // 绘制小周期分型标记(含次次周期) - if (($('#showElementKlcFxType').is(':checked') && currentData.element_klc_fx_info && currentData.element_klc_fx_info.length > 0) || - ($('#showElementKluFxType').is(':checked') && currentData.element_klu_fx_info && currentData.element_klu_fx_info.length > 0) || - ($('#showSubSubKlcFxType').is(':checked') && currentData.sub_sub_klc_fx_info && currentData.sub_sub_klc_fx_info.length > 0)) { - - // 收集所有小周期分型标记 - const allElementFxMarkers = []; - const elementFxMarkers = []; // 用于tooltip支持 - - // 处理小周期KLC分型 - if ($('#showElementKlcFxType').is(':checked') && currentData.element_klc_fx_info && currentData.element_klc_fx_info.length > 0) { - console.log(`绘制小周期K线合并分型标记,共${currentData.element_klc_fx_info.length}条`); - - currentData.element_klc_fx_info.forEach(function(fx) { - try { - // 直接使用UTC时间戳(秒) - const timestamp = Math.floor(new Date(fx.time).getTime() / 1000); - const price = parseFloat(fx.price); - - if (isNaN(timestamp) || isNaN(price)) { - console.error('小周期KLC分型时间或价格转换错误:', fx.time, fx.price); - return; - } - - // 小周期 KLC - let strengthColor = fx.is_bottom ? '#11116B' : '#222222'; // 底分型用珊瑚红,顶分型用薄荷绿 - let displayText = `${fx.fx_strength.toFixed(1)}`; - // 构建小周期分型显示文本 - if (fx.fx_strength < 1.0){ // 调整小周期阈值 - displayText = fx.fx_strength >= 0.6 ? '' : '' // 0.6以上显示点 - } - displayText = fx.fx_type.replace("TOP", "").replace("BOTTOM", "").replace("11", "").replace("21", "").replace("3", "").replace("41", "").replace("51", "").replace("0", ""); - // 小周期分型标记配置 - const markerConfig = { - time: timestamp, - position: fx.is_bottom ? 'belowBar' : 'aboveBar', - color: strengthColor, - shape: 'triangle', - text: displayText, - size: fx.is_strong_fx ? 0.8 : 0.6 // 小周期标记整体更小一些 - }; - - allElementFxMarkers.push(markerConfig); - - // 画虚线分型框(小周期) - if (fx.start_time && fx.end_time && fx.high !== null && fx.high !== undefined && fx.low !== null && fx.low !== undefined) { - const startTs = Math.floor(new Date(fx.start_time).getTime() / 1000); - const endTs = Math.floor(new Date(fx.end_time).getTime() / 1000); - const high = parseFloat(fx.high); - const low = parseFloat(fx.low); - - if (!isNaN(startTs) && !isNaN(endTs) && !isNaN(high) && !isNaN(low)) { - const boxHigh = Math.max(high, low); - const boxLow = Math.min(high, low); - const boxColor = strengthColor; - - const topSeries = mainChart.addLineSeries({ - color: boxColor, - lineWidth: 1, - lineStyle: 2, - lastValueVisible: false, - priceLineVisible: false, - crosshairMarkerVisible: false, - }); - topSeries.setData([{ time: startTs, value: boxHigh }, { time: endTs, value: boxHigh }]); - - const bottomSeries = mainChart.addLineSeries({ - color: boxColor, - lineWidth: 1, - lineStyle: 2, - lastValueVisible: false, - priceLineVisible: false, - crosshairMarkerVisible: false, - }); - bottomSeries.setData([{ time: startTs, value: boxLow }, { time: endTs, value: boxLow }]); - - const leftSeries = mainChart.addLineSeries({ - color: boxColor, - lineWidth: 1, - lineStyle: 2, - lastValueVisible: false, - priceLineVisible: false, - crosshairMarkerVisible: false, - }); - leftSeries.setData([{ time: startTs, value: boxLow }, { time: startTs, value: boxHigh }]); - - const rightSeries = mainChart.addLineSeries({ - color: boxColor, - lineWidth: 1, - lineStyle: 2, - lastValueVisible: false, - priceLineVisible: false, - crosshairMarkerVisible: false, - }); - rightSeries.setData([{ time: endTs, value: boxLow }, { time: endTs, value: boxHigh }]); - - if (!tvWidget.series.elementKlcFxBoxSeries) tvWidget.series.elementKlcFxBoxSeries = []; - tvWidget.series.elementKlcFxBoxSeries.push(topSeries, bottomSeries, leftSeries, rightSeries); - } - } - - // 创建小周期分型标记对象,包含tooltip信息 - const elementFxMarker = { - time: timestamp, - tooltip: `
- 小周期${fx.is_bottom ? '底分型' : '顶分型'}(合): ${fx.fx_type}
- 强度分数: ${fx.fx_strength}分
- 强度等级: ${fx.fx_strength_level}
- 是否强分型: ${fx.is_strong_fx ? '是' : '否'}
- 价格: ${price.toFixed(4)}
- 时间: ${fx.time} -
` - }; - - elementFxMarkers.push(elementFxMarker); - - } catch (e) { - console.error('绘制小周期KLC分型标记出错:', e); - } - }); - } - - // 处理小周期KLU分型 - if ($('#showElementKluFxType').is(':checked') && currentData.element_klu_fx_info && currentData.element_klu_fx_info.length > 0) { - console.log(`绘制小周期K线未合并分型标记,共${currentData.element_klu_fx_info.length}条`); - - currentData.element_klu_fx_info.forEach(function(fx) { - try { - // 直接使用UTC时间戳(秒) - const timestamp = Math.floor(new Date(fx.time).getTime() / 1000); - const price = parseFloat(fx.price); - - if (isNaN(timestamp) || isNaN(price)) { - console.error('小周期KLU分型时间或价格转换错误:', fx.time, fx.price); - return; - } - - // 小周期 KLU - let strengthColor = fx.is_bottom ? '#9A8C98' : '#F2CC8F'; // 底分型用灰紫色,顶分型用浅黄色 - let displayText = `${fx.fx_strength.toFixed(1)}`; - // 构建小周期分型显示文本 - if (fx.fx_strength < 2.0){ // 调整小周期阈值 - displayText = fx.fx_strength >= 1.5 ? '' : '' // 0.6以上显示点 - } - displayText = fx.fx_type.replace("TOP", "").replace("BOTTOM", "").replace("11", ""); - // 小周期KLU分型标记配置 - const markerConfig = { - time: timestamp, - position: fx.is_bottom ? 'belowBar' : 'aboveBar', - color: strengthColor, - // shape: 'triangle', // 使用三角形区分KLU分型 - text: displayText, - size: fx.is_strong_fx ? 0.7 : 0.5 // 小周期KLU标记更小一些 - }; - - allElementFxMarkers.push(markerConfig); - - // 创建小周期分型标记对象,包含tooltip信息 - const elementFxMarker = { - time: timestamp, - tooltip: `
- 小周期${fx.is_bottom ? '底分型' : '顶分型'}(原): ${fx.fx_type}
- 强度分数: ${fx.fx_strength}分
- 强度等级: ${fx.fx_strength_level}
- 是否强分型: ${fx.is_strong_fx ? '是' : '否'}
- 价格: ${price.toFixed(4)}
- 时间: ${fx.time} -
` - }; - - elementFxMarkers.push(elementFxMarker); - - } catch (e) { - console.error('绘制小周期KLU分型标记出错:', e); - } - }); - } - - // 次次周期KLC分型 - if ($('#showSubSubKlcFxType').is(':checked') && currentData.sub_sub_klc_fx_info && currentData.sub_sub_klc_fx_info.length > 0) { - currentData.sub_sub_klc_fx_info.forEach(function(fx) { - try { - const timestamp = Math.floor(new Date(fx.time).getTime() / 1000); - const price = parseFloat(fx.price); - if (isNaN(timestamp) || isNaN(price)) return; - const strengthColor = '#00897b'; - let displayText = (fx.fx_type || '').replace("TOP", "").replace("BOTTOM", "").replace("11", "").replace("21", "").replace("3", "").replace("41", "").replace("51", "").replace("0", ""); - const markerConfig = { - time: timestamp, - position: fx.is_bottom ? 'belowBar' : 'aboveBar', - color: strengthColor, - shape: 'triangle', - text: displayText, - size: (fx.is_strong_fx ? 0.6 : 0.5) - }; - allElementFxMarkers.push(markerConfig); - - // 画虚线分型框(次次周期) - if (fx.start_time && fx.end_time && fx.high !== null && fx.high !== undefined && fx.low !== null && fx.low !== undefined) { - const startTs = Math.floor(new Date(fx.start_time).getTime() / 1000); - const endTs = Math.floor(new Date(fx.end_time).getTime() / 1000); - const high = parseFloat(fx.high); - const low = parseFloat(fx.low); - - if (!isNaN(startTs) && !isNaN(endTs) && !isNaN(high) && !isNaN(low)) { - const boxHigh = Math.max(high, low); - const boxLow = Math.min(high, low); - const boxColor = strengthColor; - - const topSeries = mainChart.addLineSeries({ - color: boxColor, - lineWidth: 1, - lineStyle: 2, - lastValueVisible: false, - priceLineVisible: false, - crosshairMarkerVisible: false, - }); - topSeries.setData([{ time: startTs, value: boxHigh }, { time: endTs, value: boxHigh }]); - - const bottomSeries = mainChart.addLineSeries({ - color: boxColor, - lineWidth: 1, - lineStyle: 2, - lastValueVisible: false, - priceLineVisible: false, - crosshairMarkerVisible: false, - }); - bottomSeries.setData([{ time: startTs, value: boxLow }, { time: endTs, value: boxLow }]); - - const leftSeries = mainChart.addLineSeries({ - color: boxColor, - lineWidth: 1, - lineStyle: 2, - lastValueVisible: false, - priceLineVisible: false, - crosshairMarkerVisible: false, - }); - leftSeries.setData([{ time: startTs, value: boxLow }, { time: startTs, value: boxHigh }]); - - const rightSeries = mainChart.addLineSeries({ - color: boxColor, - lineWidth: 1, - lineStyle: 2, - lastValueVisible: false, - priceLineVisible: false, - crosshairMarkerVisible: false, - }); - rightSeries.setData([{ time: endTs, value: boxLow }, { time: endTs, value: boxHigh }]); - - if (!tvWidget.series.subSubKlcFxBoxSeries) tvWidget.series.subSubKlcFxBoxSeries = []; - tvWidget.series.subSubKlcFxBoxSeries.push(topSeries, bottomSeries, leftSeries, rightSeries); - } - } - } catch (e) { console.error('绘制次次周期KLC分型标记出错:', e); } - }); - } - - // 将小周期分型标记添加到全局markers中以支持tooltip功能 - if (window.fxMarkers) { - window.fxMarkers = [...window.fxMarkers, ...elementFxMarkers]; - } else { - window.fxMarkers = elementFxMarkers; - } - - // 基于后端提供的 KLC 趋势生成标记(不进行任何计算) - let klcTrendMarkers = []; - try { - if (currentData.klc_trend && currentData.klc_trend.length > 0) { - console.log('KLC趋势点数量:', currentData.klc_trend.length, currentData.klc_trend.slice(0, 3)); - // 当前图表的bar时间集合(秒)用于对齐标记到最近的K线 - const seriesTimes = (typeof candles !== 'undefined' && Array.isArray(candles)) ? new Set(candles.map(c => c.time)) : new Set(); - const nearestTime = (target) => { - if (!Array.isArray(candles) || candles.length === 0) return target; - // 简单线性查找(数据量通常可接受),必要时可替换为二分 - let best = candles[0].time; - let bestDiff = Math.abs(best - target); - for (let i = 1; i < candles.length; i++) { - const t = candles[i].time; - const d = Math.abs(t - target); - if (d < bestDiff) { best = t; bestDiff = d; } - } - return best; - }; - - klcTrendMarkers = currentData.klc_trend.map(t => { - const ts = Math.floor(new Date(t.time).getTime() / 1000); - const trendRaw = (t.trend || '').toString().toUpperCase(); - let timeAligned = seriesTimes.has(ts) ? ts : nearestTime(ts); - let marker = { time: timeAligned, position: 'inBar', color: '#9E9E9E', shape: 'square', size: 0.8 }; - if (trendRaw === 'UP') { - marker = { time: timeAligned, position: 'aboveBar', color: '#00C853', shape: 'arrowUp', size: 0.5 }; - } else if (trendRaw === 'DOWN') { - marker = { time: timeAligned, position: 'belowBar', color: '#D32F2F', shape: 'arrowDown', size: 0.5 }; - } else if (trendRaw === 'FLAT') { - marker = { time: timeAligned, position: 'inBar', color: '#9E9E9E', shape: 'circle', size: 0.8 }; - } else { - // UNKNOWN 或其他 - marker = { time: timeAligned, position: 'inBar', color: '#2196F3', shape: 'square', size: 0.8 }; - } - return marker; - }); - console.log('KLC趋势标记(对齐后)示例:', klcTrendMarkers.slice(0, 5)); - } - } catch (e) { - klcTrendMarkers = []; - } - // 暴露到全局以便调试或后续合并 - window.klcTrendMarkers = klcTrendMarkers; - - // 无论当前显示主/小周期,只要勾选对应Trend,就叠加出来 - let trendMarkersToUse = []; - if ($('#showMainTrend').is(':checked')) { - trendMarkersToUse = trendMarkersToUse.concat(window.klcTrendMarkers || []); - } - if ($('#showElementTrend').is(':checked') && currentData.element_klc_trend) { - const candlesTimes = (typeof candles !== 'undefined' && Array.isArray(candles)) ? new Set(candles.map(c => c.time)) : new Set(); - const nearestTime = (target) => { - if (!Array.isArray(candles) || candles.length === 0) return target; - let best = candles[0].time, bestDiff = Math.abs(best - target); - for (let i = 1; i < candles.length; i++) { - const t = candles[i].time, d = Math.abs(t - target); - if (d < bestDiff) { best = t; bestDiff = d; } - } - return best; - }; - const elementMarkers = currentData.element_klc_trend.map(t => { - const ts = Math.floor(new Date(t.time).getTime() / 1000); - const trendRaw = (t.trend || '').toString().toUpperCase(); - const timeAligned = candlesTimes.has(ts) ? ts : nearestTime(ts); - if (trendRaw === 'UP') return { time: timeAligned, position: 'aboveBar', color: '#00C853', shape: 'arrowUp', size: 0.5 }; - if (trendRaw === 'DOWN') return { time: timeAligned, position: 'belowBar', color: '#D32F2F', shape: 'arrowDown', size: 0.5 }; - if (trendRaw === 'FLAT') return { time: timeAligned, position: 'inBar', color: '#9E9E9E', shape: 'circle', size: 0.8 }; - return { time: timeAligned, position: 'inBar', color: '#2196F3', shape: 'square', size: 0.8 }; - }); - trendMarkersToUse = trendMarkersToUse.concat(elementMarkers); - } - if ($('#showSubSubTrend').is(':checked') && currentData.sub_sub_klc_trend && currentData.sub_sub_klc_trend.length > 0) { - const candlesTimesSs = (typeof candles !== 'undefined' && Array.isArray(candles)) ? new Set(candles.map(c => c.time)) : new Set(); - const nearestTimeSs = (target) => { - if (!Array.isArray(candles) || candles.length === 0) return target; - let best = candles[0].time, bestDiff = Math.abs(best - target); - for (let i = 1; i < candles.length; i++) { - const t = candles[i].time, d = Math.abs(t - target); - if (d < bestDiff) { best = t; bestDiff = d; } - } - return best; - }; - const subSubColor = '#00897b'; - const subSubMarkers = currentData.sub_sub_klc_trend.map(t => { - const ts = Math.floor(new Date(t.time).getTime() / 1000); - const trendRaw = (t.trend || '').toString().toUpperCase(); - const timeAligned = candlesTimesSs.has(ts) ? ts : nearestTimeSs(ts); - if (trendRaw === 'UP') return { time: timeAligned, position: 'aboveBar', color: subSubColor, shape: 'arrowUp', size: 0.4 }; - if (trendRaw === 'DOWN') return { time: timeAligned, position: 'belowBar', color: subSubColor, shape: 'arrowDown', size: 0.4 }; - if (trendRaw === 'FLAT') return { time: timeAligned, position: 'inBar', color: subSubColor, shape: 'circle', size: 0.5 }; - return { time: timeAligned, position: 'inBar', color: subSubColor, shape: 'square', size: 0.5 }; - }); - trendMarkersToUse = trendMarkersToUse.concat(subSubMarkers); - } - - // 合并标记并设置 - const combinedMarkers = [ - ...(window.mainFxMarkers || []), - ...allElementFxMarkers, - ...(window.kluDivMarkersMain || []), - ...(window.kluDivMarkersElement || []), - ...(window.kluDivMarkersSubSub || []), - ...trendMarkersToUse, - ...(window.bspMarkers || []) - ]; - if (combinedMarkers.length > 0) { - console.log( - '合并设置', combinedMarkers.length, '个标记(主周期分型:', - (window.mainFxMarkers || []).length, - '个,小周期分型:', allElementFxMarkers.length, - '个,UnitTF:', (window.unittfMarkers || []).length, - '个,BSP标记:', (window.bspMarkers || []).length, - '个)' - ); - - // 根据当前主系列类型设置标记 - const klineType = ($('#klineType').val() || (showOriginalKline ? 'candlestick' : 'line')); - let targetSeries = null; - if (klineType === 'candlestick') targetSeries = tvWidget.series.candleSeries; - else if (klineType === 'renko') targetSeries = tvWidget.series.renkoSeries; - else if (klineType === 'heikin') targetSeries = tvWidget.series.heikinSeries; - else if (klineType === 'bar') targetSeries = tvWidget.series.barSeries; - else if (klineType === 'line') targetSeries = tvWidget.series.lineSeries; - else if (klineType === 'area') targetSeries = tvWidget.series.areaSeries; - else if (klineType === 'baseline') targetSeries = tvWidget.series.baselineSeries; - else if (klineType === 'klc') targetSeries = tvWidget.series.klcSeries; - if (targetSeries) { - try { - targetSeries.setMarkers(combinedMarkers); - } catch (e) { - console.warn('设置主系列标记失败(可能series已释放):', e); - } - } else { - console.log('未找到主数据系列,无法设置标记'); - } - } - - } else { - console.log('绘制小周期分型标记 - 已禁用或无数据'); - - // 计算并缓存KLC趋势标记(即使未启用小周期分型,也应显示趋势) - try { - let klcTrendMarkers = []; - if (currentData.klc_trend && currentData.klc_trend.length > 0) { - console.log('KLC趋势点数量:', currentData.klc_trend.length, currentData.klc_trend.slice(0, 3)); - const seriesTimes = (typeof candles !== 'undefined' && Array.isArray(candles)) ? new Set(candles.map(c => c.time)) : new Set(); - const nearestTime = (target) => { - if (!Array.isArray(candles) || candles.length === 0) return target; - let best = candles[0].time; - let bestDiff = Math.abs(best - target); - for (let i = 1; i < candles.length; i++) { - const t = candles[i].time; - const d = Math.abs(t - target); - if (d < bestDiff) { best = t; bestDiff = d; } - } - return best; - }; - klcTrendMarkers = currentData.klc_trend.map(t => { - const ts = Math.floor(new Date(t.time).getTime() / 1000); - const trendRaw = (t.trend || '').toString().toUpperCase(); - const timeAligned = seriesTimes.has(ts) ? ts : nearestTime(ts); - if (trendRaw === 'UP') { - return { time: timeAligned, position: 'aboveBar', color: '#00C853', shape: 'arrowUp', size: 0.5 }; - } else if (trendRaw === 'DOWN') { - return { time: timeAligned, position: 'belowBar', color: '#D32F2F', shape: 'arrowDown', size: 0.5 }; - } else if (trendRaw === 'FLAT') { - return { time: timeAligned, position: 'inBar', color: '#9E9E9E', shape: 'circle', size: 0.8 }; - } else { - return { time: timeAligned, position: 'inBar', color: '#2196F3', shape: 'square', size: 0.8 }; - } - }); - console.log('KLC趋势标记(对齐后)示例:', klcTrendMarkers.slice(0, 5)); - } - window.klcTrendMarkers = klcTrendMarkers; - } catch (e) { - window.klcTrendMarkers = []; - } - - // 与上方一致:勾选哪个Trend就显示哪个 - let trendMarkersToUse = []; - if ($('#showMainTrend').is(':checked')) { - trendMarkersToUse = trendMarkersToUse.concat(window.klcTrendMarkers || []); - } - if ($('#showElementTrend').is(':checked') && currentData.element_klc_trend) { - const candlesTimes = (typeof candles !== 'undefined' && Array.isArray(candles)) ? new Set(candles.map(c => c.time)) : new Set(); - const nearestTime = (target) => { - if (!Array.isArray(candles) || candles.length === 0) return target; - let best = candles[0].time, bestDiff = Math.abs(best - target); - for (let i = 1; i < candles.length; i++) { - const t = candles[i].time, d = Math.abs(t - target); - if (d < bestDiff) { best = t; bestDiff = d; } - } - return best; - }; - const elementMarkers = currentData.element_klc_trend.map(t => { - const ts = Math.floor(new Date(t.time).getTime() / 1000); - const trendRaw = (t.trend || '').toString().toUpperCase(); - const timeAligned = candlesTimes.has(ts) ? ts : nearestTime(ts); - if (trendRaw === 'UP') return { time: timeAligned, position: 'aboveBar', color: '#00C853', shape: 'arrowUp', size: 0.5 }; - if (trendRaw === 'DOWN') return { time: timeAligned, position: 'belowBar', color: '#D32F2F', shape: 'arrowDown', size: 0.5 }; - if (trendRaw === 'FLAT') return { time: timeAligned, position: 'inBar', color: '#9E9E9E', shape: 'circle', size: 0.8 }; - return { time: timeAligned, position: 'inBar', color: '#2196F3', shape: 'square', size: 0.8 }; - }); - trendMarkersToUse = trendMarkersToUse.concat(elementMarkers); - } - if ($('#showSubSubTrend').is(':checked') && currentData.sub_sub_klc_trend && currentData.sub_sub_klc_trend.length > 0) { - const candlesTimesSs2 = (typeof candles !== 'undefined' && Array.isArray(candles)) ? new Set(candles.map(c => c.time)) : new Set(); - const nearestTimeSs2 = (target) => { - if (!Array.isArray(candles) || candles.length === 0) return target; - let best = candles[0].time, bestDiff = Math.abs(best - target); - for (let i = 1; i < candles.length; i++) { - const t = candles[i].time, d = Math.abs(t - target); - if (d < bestDiff) { best = t; bestDiff = d; } - } - return best; - }; - const subSubColor2 = '#00897b'; - const subSubMarkers2 = currentData.sub_sub_klc_trend.map(t => { - const ts = Math.floor(new Date(t.time).getTime() / 1000); - const trendRaw = (t.trend || '').toString().toUpperCase(); - const timeAligned = candlesTimesSs2.has(ts) ? ts : nearestTimeSs2(ts); - if (trendRaw === 'UP') return { time: timeAligned, position: 'aboveBar', color: subSubColor2, shape: 'arrowUp', size: 0.4 }; - if (trendRaw === 'DOWN') return { time: timeAligned, position: 'belowBar', color: subSubColor2, shape: 'arrowDown', size: 0.4 }; - if (trendRaw === 'FLAT') return { time: timeAligned, position: 'inBar', color: subSubColor2, shape: 'circle', size: 0.5 }; - return { time: timeAligned, position: 'inBar', color: subSubColor2, shape: 'square', size: 0.5 }; - }); - trendMarkersToUse = trendMarkersToUse.concat(subSubMarkers2); - } - // 这里的 onlyMainAndU 实际上是「最终要挂到主K线上」的一组标记 - // 之前没有把 window.bspMarkers 合进去,导致上面已经合并了 BSP 标记, - // 但在这里再次调用 setMarkers 时把 BSP 覆盖掉了,从而前端看不到买卖点。 - // 修复:把 BSP 标记一并合并进来。 - const onlyMainAndU = [ - ...(window.mainFxMarkers || []), - ...(window.kluDivMarkersMain || []), - ...(window.kluDivMarkersElement || []), - ...(window.kluDivMarkersSubSub || []), - ...trendMarkersToUse, - ...(window.bspMarkers || []) - ]; - if (onlyMainAndU.length > 0) { - console.log('仅设置', onlyMainAndU.length, '个主周期/UnitTF标记(主周期分型:', (window.mainFxMarkers || []).length, ',UnitTF:', (window.unittfMarkers || []).length, ')'); - - // 根据当前主系列类型设置标记 - const klineType2 = ($('#klineType').val() || (showOriginalKline ? 'candlestick' : 'line')); - let targetSeries2 = null; - if (klineType2 === 'candlestick') targetSeries2 = tvWidget.series.candleSeries; - else if (klineType2 === 'renko') targetSeries2 = tvWidget.series.renkoSeries; - else if (klineType2 === 'heikin') targetSeries2 = tvWidget.series.heikinSeries; - else if (klineType2 === 'bar') targetSeries2 = tvWidget.series.barSeries; - else if (klineType2 === 'line') targetSeries2 = tvWidget.series.lineSeries; - else if (klineType2 === 'area') targetSeries2 = tvWidget.series.areaSeries; - else if (klineType2 === 'baseline') targetSeries2 = tvWidget.series.baselineSeries; - else if (klineType2 === 'klc') targetSeries2 = tvWidget.series.klcSeries; - if (targetSeries2) { - try { - targetSeries2.setMarkers(onlyMainAndU); - } catch (e) { - console.warn('设置主系列标记失败(可能series已释放):', e); - } - } else { - console.log('未找到主数据系列,无法设置标记'); - } - } else { - console.log('没有分型标记需要显示,清空图表标记'); - // 清空图表上的所有主系列标记 - const klineType3 = ($('#klineType').val() || (showOriginalKline ? 'candlestick' : 'line')); - let targetSeries3 = null; - if (klineType3 === 'candlestick') targetSeries3 = tvWidget.series.candleSeries; - else if (klineType3 === 'renko') targetSeries3 = tvWidget.series.renkoSeries; - else if (klineType3 === 'heikin') targetSeries3 = tvWidget.series.heikinSeries; - else if (klineType3 === 'bar') targetSeries3 = tvWidget.series.barSeries; - else if (klineType3 === 'line') targetSeries3 = tvWidget.series.lineSeries; - else if (klineType3 === 'area') targetSeries3 = tvWidget.series.areaSeries; - else if (klineType3 === 'baseline') targetSeries3 = tvWidget.series.baselineSeries; - else if (klineType3 === 'klc') targetSeries3 = tvWidget.series.klcSeries; - if (targetSeries3) { - try { - targetSeries3.setMarkers([]); - } catch (e) { - console.warn('清空主系列标记失败(可能series已释放):', e); - } - } - } - } - - // 同步所有图表的时间轴配置 - const syncTimeScaleSettings = () => { - // 获取主图表的时间轴设置 - const mainTimeScale = mainChart.timeScale(); - const baseOptions = { - timeVisible: true, - secondsVisible: false, - borderColor: '#ddd', - barSpacing: symbolConfig.type === 'a_stock' ? 6 : 10, - rightOffset: 12, - lockVisibleTimeRangeOnResize: true, - // 关键:确保所有图表边缘行为完全一致 - fixLeftEdge: false, - fixRightEdge: false, - // 确保时间刻度行为一致 - ticksVisible: true, - minimumHeight: 0, - }; - - console.log('🔧 同步时间轴设置:', baseOptions); - - // 应用相同的设置到所有图表 - mainChart.timeScale().applyOptions(baseOptions); - volumeChart.timeScale().applyOptions(baseOptions); - atrChart.timeScale().applyOptions(baseOptions); - if (showMacd && macdChart) { - macdChart.timeScale().applyOptions(baseOptions); - } - }; - - // 首先同步时间轴设置 - syncTimeScaleSettings(); - - // 仅在没有待恢复视图时,设置默认可见范围 - const totalBars = candles ? candles.length : 0; - const visibleBarsCount = 200; - const hasPendingRestoreView = !!window._pendingRestoreView; - if (!hasPendingRestoreView) { - // 显示最近 200 根K线而非全部挤压(避免K线过多时重叠) - if (totalBars > visibleBarsCount) { - const rangeFrom = totalBars - visibleBarsCount; - const rangeTo = totalBars + 12; - mainChart.timeScale().setVisibleLogicalRange({ from: rangeFrom, to: rangeTo }); - } else { - mainChart.timeScale().fitContent(); - } - } - - // 立即同步其他图表到主图表的范围 - setTimeout(() => { - const logRange = mainChart.timeScale().getVisibleLogicalRange(); - if (logRange) { - console.log('🔧 同步可见范围:', logRange); - volumeChart.timeScale().setVisibleLogicalRange(logRange); - atrChart.timeScale().setVisibleLogicalRange(logRange); - if (showMacd && macdChart) { - macdChart.timeScale().setVisibleLogicalRange(logRange); - } - if (showMacd && chanMacdChart) { - chanMacdChart.timeScale().setVisibleLogicalRange(logRange); - } - console.log('🔧 时间轴同步完成'); - } - }, 50); - - // 保存图表对象 - tvWidget.mainChart = mainChart; - tvWidget.volumeChart = volumeChart; - tvWidget.atrChart = atrChart; - tvWidget.macdChart = macdChart; - tvWidget.chanMacdChart = chanMacdChart; - tvWidget.state.isInitialized = true; - // 注册窗口卸载时释放资源,避免GPU内存泄漏 - window.onbeforeunload = function() { - try { - if (tvWidget && tvWidget.state && tvWidget.state.isInitialized) { - if (tvWidget.mainChart && typeof tvWidget.mainChart.remove === 'function') tvWidget.mainChart.remove(); - if (tvWidget.volumeChart && typeof tvWidget.volumeChart.remove === 'function') tvWidget.volumeChart.remove(); - if (tvWidget.macdChart && typeof tvWidget.macdChart.remove === 'function') tvWidget.macdChart.remove(); - if (tvWidget.chanMacdChart && typeof tvWidget.chanMacdChart.remove === 'function') tvWidget.chanMacdChart.remove(); - if (tvWidget.atrChart && typeof tvWidget.atrChart.remove === 'function') tvWidget.atrChart.remove(); - } - } catch (e) {} - }; - - // 初始化默认均线/布林带配置(仅在首次初始化时) - if (!hasInitializedDefaultMAs && movingAverages.length === 0) { - console.log('初始化默认均线与布林带指标'); - - if (typeof maIdCounter !== 'number' || !Number.isFinite(maIdCounter)) { - maIdCounter = 0; - } - if (typeof bbIdCounter !== 'number' || !Number.isFinite(bbIdCounter)) { - bbIdCounter = 0; - } - - const defaultMAs = [ - { type: 'EMA', length: 13, color: '#800080', name: 'EMA13', visible: true }, // 紫色 - { type: 'EMA', length: 26, color: '#FF8C00', name: 'EMA26', visible: true }, // 橙色 - { type: 'EMA', length: 52, color: '#000000', name: 'EMA52', visible: false }, // 黑色 - { type: 'EMA', length: 104, color: '#1E90FF', name: 'EMA104', visible: false }, // 蓝色 - { type: 'EMA', length: 156, color: '#F700FF', name: 'EMA156', visible: false } // 粉色 - ]; - - defaultMAs.forEach(ma => { - const config = { - id: ++maIdCounter, - type: ma.type, - length: ma.length, - source: 'close', - smoothType: 'none', - smoothLength: 3, - lineWidth: 1, // 1px线宽 - lineStyle: 0, // 实线 - color: ma.color, - visible: ma.visible - }; - - movingAverages.push(config); - console.log(`添加默认${ma.name}:`, ma.color); - }); - - if (bollingerBands.length === 0) { - const defaultBB = { - id: ++bbIdCounter, - type: 'Bollinger Bands', - length: 20, - upperMultiplier: 2, - lowerMultiplier: 2, - source: 'close', - lineWidth: 1, - lineStyle: 0, - upperColor: '#ff6b6b', - middleColor: '#ffffff', - lowerColor: '#ff6b6b', - visible: false - }; - bollingerBands.push(defaultBB); - console.log('添加默认布林带: BB(20, 2, 2)'); - } - - console.log('默认指标配置完成,当前均线数量', movingAverages.length, '布林带数量', bollingerBands.length); - hasInitializedDefaultMAs = true; - } - - // 添加均线到图表 - addMovingAveragesToChart(candles); - - // 添加布林带到图表 - addBollingerBandsToChart(candles); - - // 更新技术指标面板显示 - updateIndicatorPanel(); - - // 绑定同步事件 - bindSyncEvents(mainChartContainer, volumeChartContainer, atrChartContainer, macdChartContainer, chanMacdChartContainer, mainChart, volumeChart, atrChart, macdChart, chanMacdChart, showMacd); - - // 最终确保所有图表时间轴对齐(同时恢复刷新前保存的缩放/位置) - setTimeout(() => { - const allCharts = [mainChart, volumeChart, atrChart]; - if (showMacd && macdChart) allCharts.push(macdChart); - if (showMacd && chanMacdChart) allCharts.push(chanMacdChart); - - // 检查是否有待恢复的视图(缩放 + 位置) - const pending = window._pendingRestoreView; - window._pendingRestoreView = null; - - if (pending) { - // 恢复刷新前的缩放和位置(优先可见范围/逻辑范围,最后回退到滚动位置) - console.log('📌 恢复图表视图:', JSON.stringify(pending)); - restoreChartViewState(allCharts, pending); - } else { - // 无保存视图,正常同步主图到子图 - const visibleRange = mainChart.timeScale().getVisibleRange(); - if (visibleRange) { - console.log('🔧 最终同步可见范围:', visibleRange); - [volumeChart, atrChart].concat( - showMacd && macdChart ? [macdChart] : [], - showMacd && chanMacdChart ? [chanMacdChart] : [] - ).forEach(c => { - try { c.timeScale().setVisibleRange(visibleRange); } catch(e) {} - }); - } - } - console.log('🔧 最终时间轴对齐完成'); - }, 150); - // 只有在时间输入框都为空时才设置图表默认时间范围 - if (!$('#start_time').val() && !$('#end_time').val()) { - setDefaultTimeRange(); - } - - // 添加买卖点提示 - // 初始化 tooltip 与 U 显示状态 - window.showUOnMain = $('#toggleUOnMain').is(':checked'); - window.showUOnElement = $('#toggleUOnElement').is(':checked'); - setupTooltip(mainChart, [], [], mainChartContainer, volumeChartContainer, atrChartContainer, macdChartContainer, chanMacdChartContainer, volumeChart, atrChart, macdChart, chanMacdChart, showMacd); - - // 更新EMA52显示 - if (currentData) { - updateEMA52Display(currentData); - } - console.log('图表初始化完成'); } catch (e) { console.error('图表初始化错误:', e); } } -// 增量更新图表数据 diff --git a/web/static/js/app/chart_tv_chan.js b/web/static/js/app/chart_tv_chan.js new file mode 100644 index 0000000..35dc2e0 --- /dev/null +++ b/web/static/js/app/chart_tv_chan.js @@ -0,0 +1,1103 @@ +/* chart_tv_chan.js — BI / SEG / ZS */ + +function chartTvRenderChan(ctx) { + var symbol = ctx.symbol; + var timeframe = ctx.timeframe; + var symbolConfig = ctx.symbolConfig; + var useSubSubPeriod = ctx.useSubSubPeriod; + var useElementPeriod = ctx.useElementPeriod; + var klinePeriodLabel = ctx.klinePeriodLabel; + var candles = ctx.candles; + var klineDataSource = ctx.klineDataSource; + var container = ctx.container; + var showMacd = ctx.showMacd; + var showOriginalKline = ctx.showOriginalKline; + var mainChartContainer = ctx.mainChartContainer; + var volumeChartContainer = ctx.volumeChartContainer; + var atrChartContainer = ctx.atrChartContainer; + var macdChartContainer = ctx.macdChartContainer; + var chanMacdChartContainer = ctx.chanMacdChartContainer; + var mainChart = ctx.mainChart; + var volumeChart = ctx.volumeChart; + var atrChart = ctx.atrChart; + var macdChart = ctx.macdChart; + var chanMacdChart = ctx.chanMacdChart; + var createChartOptions = ctx.createChartOptions; + // 图表同步事件统一由文末 bindSyncEvents 注册(带 cleanup),此处不再重复 addEventListener, + // 否则每次自动刷新/重建都会在 document/window 上堆积监听导致内存泄漏。 + // 显示笔的绘制 - 分别处理主周期、次周期和次次周期 + if ($('#showMainBi').is(':checked') || $('#showElementBi').is(':checked') || $('#showSubSubBi').is(':checked')) { + console.log('绘制笔 - 已启用'); + let biLines = []; + + // 主周期笔 + if ($('#showMainBi').is(':checked') && currentData.bi_list && currentData.bi_list.length > 0) { + console.log(`绘制主周期笔数据,共${currentData.bi_list.length}条`); + + // 清空已有的主周期笔系列 + tvWidget.series.mainBiSeries = []; + tvWidget.series.mainUncompletedBiSeries = []; + + // 遍历处理每个笔 + currentData.bi_list.forEach(function(bi) { + try { + // 直接使用UTC时间戳(秒) + const startTime = Math.floor(new Date(bi.start_time).getTime() / 1000); + const endTime = Math.floor(new Date(bi.end_time).getTime() / 1000); + + if (isNaN(startTime) || isNaN(endTime)) { + console.error('主周期笔时间转换错误:', bi.start_time, bi.end_time); + return; + } + + const startPrice = parseFloat(bi.start_price); + const endPrice = parseFloat(bi.end_price); + + if (isNaN(startPrice) || isNaN(endPrice)) { + console.error('主周期笔价格转换错误:', bi.start_price, bi.end_price); + return; + } + + // 添加线段 + biLines.push({ + startTime: startTime, + endTime: endTime, + startPrice: startPrice, + endPrice: endPrice, + color: bi.direction === 1 ? '#dc3545' : '#28a745', // 主周期笔颜色 + lineWidth: 1, + }); + + // 添加到图表对象 + tvWidget.series.mainBiSeries.push({ + time: startTime, + value: startPrice, + color: bi.direction === 1 ? '#dc3545' : '#28a745', + lineWidth: 1 + }); + + // 在笔的末端添加macd_div值标记 + if (bi.macd_div && bi.macd_div !== 0 && $('#showMainMacdDiv').is(':checked')) { + console.log(`添加主周期macd_div标记: ${bi.macd_div.toFixed(2)}, 在时间点: ${endTime}`); + + const macdDivLabel = mainChart.addLineSeries({ + lastValueVisible: false, + priceLineVisible: false, + color: 'transparent', // 设置为透明色 + lineWidth: 0, // 线宽为0 + }); + + // 添加一个透明的数据点用于承载标记 + macdDivLabel.setData([ + { time: endTime, value: endPrice } + ]); + + // 主周期MACD背离标记根据笔方向显示,远离K线避免与分型重叠 + const markerPosition = bi.direction === 1 ? 'aboveBar' : 'belowBar'; + const textColor = bi.macd_div > 0 ? '#dc3545' : '#28a745'; + + // 只使用标记,不添加数据点 + macdDivLabel.setMarkers([ + { + time: endTime, + position: markerPosition, + color: textColor, + text: `${bi.macd_div.toFixed(2)}`, // 添加M前缀区分 + size: 0.6, // 更小的尺寸,远离分型标记 + } + ]); + } + } catch (e) { + console.error('主周期笔处理出错:', e); + } + }); + } + + // 次周期笔 + if ($('#showElementBi').is(':checked') && currentData.element_bi_list && currentData.element_bi_list.length > 0) { + console.log(`绘制次周期笔数据,共${currentData.element_bi_list.length}条`); + + // 清空已有的次周期笔系列 + tvWidget.series.elementBiSeries = []; + tvWidget.series.elementUncompletedBiSeries = []; + + currentData.element_bi_list.forEach(function(bi) { + try { + // 直接使用UTC时间戳(秒) + const startTime = Math.floor(new Date(bi.start_time).getTime() / 1000); + const endTime = Math.floor(new Date(bi.end_time).getTime() / 1000); + + if (isNaN(startTime) || isNaN(endTime)) { + console.error('次周期笔时间转换错误:', bi.start_time, bi.end_time); + return; + } + + const startPrice = parseFloat(bi.start_price); + const endPrice = parseFloat(bi.end_price); + + if (isNaN(startPrice) || isNaN(endPrice)) { + console.error('次周期笔价格转换错误:', bi.start_price, bi.end_price); + return; + } + + // 添加线段 + biLines.push({ + startTime: startTime, + endTime: endTime, + startPrice: startPrice, + endPrice: endPrice, + color: bi.direction === 1 ? '#9c27b0' : '#673ab7', // 次周期笔颜色 + lineWidth: 1, + }); + + // 添加到图表对象 + tvWidget.series.elementBiSeries.push({ + time: startTime, + value: startPrice, + color: bi.direction === 1 ? '#9c27b0' : '#673ab7', + lineWidth: 1 + }); + + // 在笔的末端添加macd_div值标记 + if (bi.macd_div && bi.macd_div !== 0 && $('#showElementMacdDiv').is(':checked')) { + console.log(`添加元素周期macd_div标记: ${bi.macd_div.toFixed(2)}, 在时间点: ${endTime}`); + + const macdDivLabel = mainChart.addLineSeries({ + lastValueVisible: false, + priceLineVisible: false, + color: 'transparent', // 设置为透明色 + lineWidth: 0, // 线宽为0 + }); + + // 添加一个透明的数据点用于承载标记 + macdDivLabel.setData([ + { time: endTime, value: endPrice } + ]); + + // 次周期MACD背离标记使用不同位置,进一步避免重叠 + const markerPosition = bi.direction === 1 ? 'aboveBar' : 'belowBar'; + const textColor = bi.macd_div > 0 ? '#9c27b0' : '#673ab7'; + + // 只使用标记,不添加数据点 + macdDivLabel.setMarkers([ + { + time: endTime, + position: markerPosition, + color: textColor, + text: `${bi.macd_div.toFixed(2)}`, // 添加E前缀区分次周期 + size: 0.4, // 更小的尺寸,让分型标记有更多空间 + } + ]); + } + } catch (e) { + console.error('次周期笔处理出错:', e); + } + }); + } + + // 绘制未完成笔 - 主周期 + if ($('#showMainBi').is(':checked') && currentData.uncompleted_bi_list && currentData.uncompleted_bi_list.length > 0) { + console.log(`绘制主周期未完成笔数据,共${currentData.uncompleted_bi_list.length}条`); + + currentData.uncompleted_bi_list.forEach(function(bi) { + try { + // 直接使用UTC时间戳(秒) + const startTime = Math.floor(new Date(bi.start_time).getTime() / 1000); + // 未完成笔的结束时间设为当前K线的最后时间 + const endTime = Math.floor(new Date(currentData.kline_data[currentData.kline_data.length-1].date).getTime() / 1000); + + if (isNaN(startTime) || isNaN(endTime)) { + console.error('主周期未完成笔时间转换错误:', bi.start_time); + return; + } + + const startPrice = parseFloat(bi.start_price); + + if (isNaN(startPrice)) { + console.error('主周期未完成笔价格转换错误:', bi.start_price); + return; + } + + // 根据笔的方向确定终点价格 + let endPrice; + const latestKline = currentData.kline_data[currentData.kline_data.length-1]; + if (bi.direction === 1) { + // 向上笔,终点为最新K线的最高点 + endPrice = parseFloat(latestKline.high); + } else { + // 向下笔,终点为最新K线的最低点 + endPrice = parseFloat(latestKline.low); + } + + // 添加未完成笔(红色虚线) + biLines.push({ + startTime: startTime, + endTime: endTime, + startPrice: startPrice, + endPrice: endPrice, + color: '#FF0000', // 红色 + lineWidth: 1, + lineStyle: 2 // 虚线 + }); + + // 添加到图表对象 + tvWidget.series.mainUncompletedBiSeries.push({ + time: startTime, + value: startPrice, + color: '#FF0000', + lineWidth: 1, + lineStyle: 2 + }); + } catch (e) { + console.error('主周期未完成笔处理出错:', e); + } + }); + } + + // 绘制未完成笔 - 次周期 + if ($('#showElementBi').is(':checked') && currentData.element_uncompleted_bi_list && currentData.element_uncompleted_bi_list.length > 0) { + console.log(`绘制次周期未完成笔数据,共${currentData.element_uncompleted_bi_list.length}条`); + + currentData.element_uncompleted_bi_list.forEach(function(bi) { + try { + // 直接使用UTC时间戳(秒) + const startTime = Math.floor(new Date(bi.start_time).getTime() / 1000); + // 未完成笔的结束时间设为当前K线的最后时间 + const endTime = Math.floor(new Date(currentData.kline_data[currentData.kline_data.length-1].date).getTime() / 1000); + + if (isNaN(startTime) || isNaN(endTime)) { + console.error('次周期未完成笔时间转换错误:', bi.start_time); + return; + } + + const startPrice = parseFloat(bi.start_price); + + if (isNaN(startPrice)) { + console.error('次周期未完成笔价格转换错误:', bi.start_price); + return; + } + + // 根据笔的方向确定终点价格 + let endPrice; + const latestKline = currentData.kline_data[currentData.kline_data.length-1]; + if (bi.direction === 1) { + // 向上笔,终点为最新K线的最高点 + endPrice = parseFloat(latestKline.high); + } else { + // 向下笔,终点为最新K线的最低点 + endPrice = parseFloat(latestKline.low); + } + + // 添加未完成笔(红色虚线) + biLines.push({ + startTime: startTime, + endTime: endTime, + startPrice: startPrice, + endPrice: endPrice, + color: '#FF0000', // 红色 + lineWidth: 1, + lineStyle: 2 // 虚线 + }); + + // 添加到图表对象 + tvWidget.series.elementUncompletedBiSeries.push({ + time: startTime, + value: startPrice, + color: '#FF0000', + lineWidth: 1, + lineStyle: 2 + }); + } catch (e) { + console.error('次周期未完成笔处理出错:', e); + } + }); + } + + // 次次周期笔 + if ($('#showSubSubBi').is(':checked') && currentData.sub_sub_bi_list && currentData.sub_sub_bi_list.length > 0) { + tvWidget.series.subSubBiSeries = []; + tvWidget.series.subSubUncompletedBiSeries = []; + currentData.sub_sub_bi_list.forEach(function(bi) { + try { + const startTime = Math.floor(new Date(bi.start_time).getTime() / 1000); + const endTime = bi.end_time ? Math.floor(new Date(bi.end_time).getTime() / 1000) : 0; + if (isNaN(startTime) || !endTime) return; + const startPrice = parseFloat(bi.start_price); + const endPrice = parseFloat(bi.end_price); + if (isNaN(startPrice) || isNaN(endPrice)) return; + biLines.push({ + startTime: startTime, endTime: endTime, startPrice: startPrice, endPrice: endPrice, + color: bi.direction === 1 ? '#00897b' : '#26a69a', lineWidth: 1, lineStyle: 0 + }); + tvWidget.series.subSubBiSeries.push({ time: startTime, value: startPrice, color: '#00897b', lineWidth: 1 }); + } catch (e) { console.error('次次周期笔处理出错:', e); } + }); + } + // 次次周期未完成笔 + if ($('#showSubSubBi').is(':checked') && currentData.sub_sub_uncompleted_bi_list && currentData.sub_sub_uncompleted_bi_list.length > 0) { + if (!tvWidget.series.subSubBiSeries) tvWidget.series.subSubBiSeries = []; + if (!tvWidget.series.subSubUncompletedBiSeries) tvWidget.series.subSubUncompletedBiSeries = []; + const klineData = currentData.kline_data || []; + const lastTime = klineData.length ? Math.floor(new Date(klineData[klineData.length-1].date).getTime() / 1000) : 0; + currentData.sub_sub_uncompleted_bi_list.forEach(function(bi) { + try { + const startTime = Math.floor(new Date(bi.start_time).getTime() / 1000); + if (isNaN(startTime) || !lastTime) return; + const startPrice = parseFloat(bi.start_price); + if (isNaN(startPrice)) return; + const lastK = klineData[klineData.length-1]; + const endPrice = bi.direction === 1 ? parseFloat(lastK.high) : parseFloat(lastK.low); + biLines.push({ + startTime: startTime, endTime: lastTime, startPrice: startPrice, endPrice: endPrice, + color: '#00695c', lineWidth: 1, lineStyle: 2 + }); + tvWidget.series.subSubUncompletedBiSeries.push({ time: startTime, value: startPrice, color: '#00695c', lineWidth: 1, lineStyle: 2 }); + } catch (e) { console.error('次次周期未完成笔处理出错:', e); } + }); + } + + // 添加所有笔到图表 + // 1m 等小周期叠加到大周期时,笔数量会非常大;每条笔创建一个 series 会导致主图渲染退化 + // 这里限制绘制数量,优先保留最新笔,避免把主K线和其他元素“挤没” + const MAX_BI_LINE_SERIES = 800; + if (biLines.length > MAX_BI_LINE_SERIES) { + console.warn(`BI线条过多(${biLines.length}),仅绘制最新 ${MAX_BI_LINE_SERIES} 条以保障主图稳定`); + } + const linesToDraw = biLines.length > MAX_BI_LINE_SERIES + ? biLines.slice(-MAX_BI_LINE_SERIES) + : biLines; + + linesToDraw.forEach(line => { + try { + if (!Number.isFinite(line.startTime) || !Number.isFinite(line.endTime)) return; + if (!Number.isFinite(line.startPrice) || !Number.isFinite(line.endPrice)) return; + if (line.endTime <= line.startTime) return; + + const lineSeries = mainChart.addLineSeries({ + color: line.color, + lineWidth: line.lineWidth, + lineStyle: line.lineStyle || 0, // 支持虚线样式 + lastValueVisible: false, + priceLineVisible: false, + }); + + lineSeries.setData([ + { time: line.startTime, value: line.startPrice }, + { time: line.endTime, value: line.endPrice } + ]); + } catch (e) { + console.warn('绘制BI线段失败,已跳过单条异常数据:', e); + } + }); + } else { + console.log('绘制笔 - 已禁用'); + } + // 显示线段的绘制 - 分别处理主周期、次周期和次次周期 + if ($('#showMainSeg').is(':checked') || $('#showElementSeg').is(':checked') || $('#showSubSubSeg').is(':checked')) { + console.log('绘制线段 - 已启用'); + let segLines = []; + + // 主周期线段 + if ($('#showMainSeg').is(':checked') && currentData.seg_list && currentData.seg_list.length > 0) { + console.log(`绘制主周期线段数据,共${currentData.seg_list.length}条`); + + // 清空已有的主周期线段系列 + tvWidget.series.mainSegSeries = []; + tvWidget.series.mainUncompletedSegSeries = []; + + currentData.seg_list.forEach(function(seg) { + try { + // 直接使用UTC时间戳(秒) + const startTime = Math.floor(new Date(seg.start_time).getTime() / 1000); + const endTime = Math.floor(new Date(seg.end_time).getTime() / 1000); + + if (isNaN(startTime) || isNaN(endTime)) { + console.error('主周期线段时间转换错误:', seg.start_time, seg.end_time); + return; + } + + const startPrice = parseFloat(seg.start_price); + const endPrice = parseFloat(seg.end_price); + + if (isNaN(startPrice) || isNaN(endPrice)) { + console.error('主周期线段价格转换错误:', seg.start_price, seg.end_price); + return; + } + + // 添加线段 + segLines.push({ + startTime: startTime, + endTime: endTime, + startPrice: startPrice, + endPrice: endPrice, + color: seg.direction === 1 ? '#FF6B6B' : '#4CAF50', // 主周期线段颜色 + lineWidth: 2, + }); + + // 添加到图表对象 + tvWidget.series.mainSegSeries.push({ + time: startTime, + value: startPrice, + color: seg.direction === 1 ? '#FF6B6B' : '#4CAF50', + lineWidth: 2 + }); + } catch (e) { + console.error('主周期线段处理出错:', e); + } + }); + } + + // 次周期线段 + if ($('#showElementSeg').is(':checked') && currentData.element_seg_list && currentData.element_seg_list.length > 0) { + console.log(`绘制次周期线段数据,共${currentData.element_seg_list.length}条`); + + // 清空已有的次周期线段系列 + tvWidget.series.elementSegSeries = []; + tvWidget.series.elementUncompletedSegSeries = []; + + currentData.element_seg_list.forEach(function(seg) { + try { + // 直接使用UTC时间戳(秒) + const startTime = Math.floor(new Date(seg.start_time).getTime() / 1000); + const endTime = Math.floor(new Date(seg.end_time).getTime() / 1000); + + if (isNaN(startTime) || isNaN(endTime)) { + console.error('次周期线段时间转换错误:', seg.start_time, seg.end_time); + return; + } + + const startPrice = parseFloat(seg.start_price); + const endPrice = parseFloat(seg.end_price); + + if (isNaN(startPrice) || isNaN(endPrice)) { + console.error('次周期线段价格转换错误:', seg.start_price, seg.end_price); + return; + } + + // 添加线段 + segLines.push({ + startTime: startTime, + endTime: endTime, + startPrice: startPrice, + endPrice: endPrice, + color: seg.direction === 1 ? '#673ab7' : '#9c27b0', // 次周期线段颜色 + lineWidth: 2, + }); + + // 添加到图表对象 + tvWidget.series.elementSegSeries.push({ + time: startTime, + value: startPrice, + color: seg.direction === 1 ? '#673ab7' : '#9c27b0', + lineWidth: 2 + }); + } catch (e) { + console.error('次周期线段处理出错:', e); + } + }); + } + + // 绘制未完成线段 - 主周期 + if ($('#showMainSeg').is(':checked') && currentData.uncompleted_seg_list && currentData.uncompleted_seg_list.length > 0) { + console.log(`绘制主周期未完成线段数据,共${currentData.uncompleted_seg_list.length}条`); + + currentData.uncompleted_seg_list.forEach(function(seg) { + try { + // 直接使用UTC时间戳(秒) + const startTime = Math.floor(new Date(seg.start_time).getTime() / 1000); + + if (isNaN(startTime)) { + console.error('主周期未完成线段时间转换错误:', seg.start_time); + return; + } + + const startPrice = parseFloat(seg.start_price); + + if (isNaN(startPrice)) { + console.error('主周期未完成线段价格转换错误:', seg.start_price); + return; + } + + let endTime, endPrice; + + if (seg.end_time && seg.end_price) { + // 有结束时间和价格的未完成线段(倒数第二个等) + endTime = Math.floor(new Date(seg.end_time).getTime() / 1000); + endPrice = parseFloat(seg.end_price); + + if (isNaN(endTime) || isNaN(endPrice)) { + console.error('主周期未完成线段结束时间或价格转换错误:', seg.end_time, seg.end_price); + return; + } + } else { + // 没有结束时间和价格的未完成线段(最后一个) + endTime = Math.floor(new Date(currentData.kline_data[currentData.kline_data.length-1].date).getTime() / 1000); + + if (isNaN(endTime)) { + console.error('主周期未完成线段结束时间转换错误'); + return; + } + + // 根据线段的方向确定终点价格 + const latestKline = currentData.kline_data[currentData.kline_data.length-1]; + if (seg.direction === 1) { + // 向上线段,终点为最新K线的最高点 + endPrice = parseFloat(latestKline.high); + } else { + // 向下线段,终点为最新K线的最低点 + endPrice = parseFloat(latestKline.low); + } + } + + // 添加未完成线段(红色虚线) + segLines.push({ + startTime: startTime, + endTime: endTime, + startPrice: startPrice, + endPrice: endPrice, + color: '#FF0000', // 红色 + lineWidth: 2, + lineStyle: 2 // 虚线 + }); + + // 添加到图表对象 + tvWidget.series.mainUncompletedSegSeries.push({ + time: startTime, + value: startPrice, + color: '#FF0000', + lineWidth: 2, + lineStyle: 2 + }); + } catch (e) { + console.error('主周期未完成线段处理出错:', e); + } + }); + } + + // 绘制未完成线段 - 次周期 + if ($('#showElementSeg').is(':checked') && currentData.element_uncompleted_seg_list && currentData.element_uncompleted_seg_list.length > 0) { + console.log(`绘制次周期未完成线段数据,共${currentData.element_uncompleted_seg_list.length}条`); + + currentData.element_uncompleted_seg_list.forEach(function(seg) { + try { + // 直接使用UTC时间戳(秒) + const startTime = Math.floor(new Date(seg.start_time).getTime() / 1000); + + if (isNaN(startTime)) { + console.error('次周期未完成线段时间转换错误:', seg.start_time); + return; + } + + const startPrice = parseFloat(seg.start_price); + + if (isNaN(startPrice)) { + console.error('次周期未完成线段价格转换错误:', seg.start_price); + return; + } + + let endTime, endPrice; + + if (seg.end_time && seg.end_price) { + // 有结束时间和价格的未完成线段(倒数第二个等) + endTime = Math.floor(new Date(seg.end_time).getTime() / 1000); + endPrice = parseFloat(seg.end_price); + + if (isNaN(endTime) || isNaN(endPrice)) { + console.error('次周期未完成线段结束时间或价格转换错误:', seg.end_time, seg.end_price); + return; + } + } else { + // 没有结束时间和价格的未完成线段(最后一个) + endTime = Math.floor(new Date(currentData.kline_data[currentData.kline_data.length-1].date).getTime() / 1000); + + if (isNaN(endTime)) { + console.error('次周期未完成线段结束时间转换错误'); + return; + } + + // 根据线段的方向确定终点价格 + const latestKline = currentData.kline_data[currentData.kline_data.length-1]; + if (seg.direction === 1) { + // 向上线段,终点为最新K线的最高点 + endPrice = parseFloat(latestKline.high); + } else { + // 向下线段,终点为最新K线的最低点 + endPrice = parseFloat(latestKline.low); + } + } + + // 添加未完成线段(红色虚线) + segLines.push({ + startTime: startTime, + endTime: endTime, + startPrice: startPrice, + endPrice: endPrice, + color: '#FF0000', // 红色 + lineWidth: 2, + lineStyle: 2 // 虚线 + }); + + // 添加到图表对象 + tvWidget.series.elementUncompletedSegSeries.push({ + time: startTime, + value: startPrice, + color: '#FF0000', + lineWidth: 2, + lineStyle: 2 + }); + } catch (e) { + console.error('次周期未完成线段处理出错:', e); + } + }); + } + + // 次次周期线段 + if ($('#showSubSubSeg').is(':checked') && currentData.sub_sub_seg_list && currentData.sub_sub_seg_list.length > 0) { + tvWidget.series.subSubSegSeries = []; + tvWidget.series.subSubUncompletedSegSeries = []; + currentData.sub_sub_seg_list.forEach(function(seg) { + try { + const startTime = Math.floor(new Date(seg.start_time).getTime() / 1000); + const endTime = seg.end_time ? Math.floor(new Date(seg.end_time).getTime() / 1000) : 0; + if (isNaN(startTime) || !endTime) return; + const startPrice = parseFloat(seg.start_price); + const endPrice = parseFloat(seg.end_price); + if (isNaN(startPrice) || isNaN(endPrice)) return; + segLines.push({ + startTime: startTime, endTime: endTime, startPrice: startPrice, endPrice: endPrice, + color: seg.direction === 1 ? '#00897b' : '#26a69a', lineWidth: 2, lineStyle: 0 + }); + tvWidget.series.subSubSegSeries.push({ time: startTime, value: startPrice, color: '#00897b', lineWidth: 2 }); + } catch (e) { console.error('次次周期线段处理出错:', e); } + }); + } + // 次次周期未完成线段 + if ($('#showSubSubSeg').is(':checked') && currentData.sub_sub_uncompleted_seg_list && currentData.sub_sub_uncompleted_seg_list.length > 0) { + if (!tvWidget.series.subSubUncompletedSegSeries) tvWidget.series.subSubUncompletedSegSeries = []; + const klineDataSeg = currentData.kline_data || []; + const lastTimeSeg = klineDataSeg.length ? Math.floor(new Date(klineDataSeg[klineDataSeg.length-1].date).getTime() / 1000) : 0; + currentData.sub_sub_uncompleted_seg_list.forEach(function(seg) { + try { + const startTime = Math.floor(new Date(seg.start_time).getTime() / 1000); + if (isNaN(startTime) || !lastTimeSeg) return; + const startPrice = parseFloat(seg.start_price); + if (isNaN(startPrice)) return; + let endTime = lastTimeSeg, endPrice; + if (seg.end_time && seg.end_price) { + endTime = Math.floor(new Date(seg.end_time).getTime() / 1000); + endPrice = parseFloat(seg.end_price); + } else { + const lastK = klineDataSeg[klineDataSeg.length-1]; + endPrice = seg.direction === 1 ? parseFloat(lastK.high) : parseFloat(lastK.low); + } + segLines.push({ + startTime: startTime, endTime: endTime, startPrice: startPrice, endPrice: endPrice, + color: '#00695c', lineWidth: 2, lineStyle: 2 + }); + tvWidget.series.subSubUncompletedSegSeries.push({ time: startTime, value: startPrice, color: '#00695c', lineWidth: 2 }); + } catch (e) { console.error('次次周期未完成线段处理出错:', e); } + }); + } + + // 添加所有线段到图表 + segLines.forEach(line => { + const lineSeries = mainChart.addLineSeries({ + color: line.color, + lineWidth: line.lineWidth, + lineStyle: line.lineStyle || 0, // 支持虚线样式 + lastValueVisible: false, + priceLineVisible: false, + }); + + lineSeries.setData([ + { time: line.startTime, value: line.startPrice }, + { time: line.endTime, value: line.endPrice } + ]); + }); + } else { + console.log('绘制线段 - 已禁用'); + } + // 显示中枢的绘制 - 分别处理主周期、次周期和次次周期(包含BI中枢,沿用同样样式与开关) + if ($('#showMainZs').is(':checked') || $('#showElementZs').is(':checked') || $('#showSubSubZs').is(':checked')) { + console.log('绘制中枢 - 已启用'); + + // 主周期中枢 + if ($('#showMainZs').is(':checked') && currentData.zs_list && currentData.zs_list.length > 0) { + console.log(`绘制主周期中枢数据,共${currentData.zs_list.length}条`); + + currentData.zs_list.forEach(function(zs) { + try { + // 直接使用UTC时间戳(秒) + const startTime = Math.floor(new Date(zs.start_time).getTime() / 1000); + const endTime = Math.floor(new Date(zs.end_time).getTime() / 1000); + + if (isNaN(startTime) || isNaN(endTime)) { + console.error('主周期中枢时间转换错误:', zs.start_time, zs.end_time); + return; + } + + const zg = parseFloat(zs.zg); // 中枢上沿 + const zd = parseFloat(zs.zd); // 中枢下沿 + const gg = parseFloat(zs.gg); // 中枢高高 + const dd = parseFloat(zs.dd); // 中枢低低 + + if (isNaN(zg) || isNaN(zd)) { + console.error('主周期中枢价格转换错误:', zs.zg, zs.zd); + return; + } + + // 创建中枢上边界 + const topSeries = mainChart.addLineSeries({ + color: '#F1C40F', // 主周期中枢颜色 + lineWidth: 1, + lastValueVisible: false, + priceLineVisible: false, + }); + + topSeries.setData([ + { time: startTime, value: zg }, + { time: endTime, value: zg } + ]); + + // 为下边界创建另一条线 + const bottomSeries = mainChart.addLineSeries({ + color: '#F1C40F', // 主周期中枢颜色 + lineWidth: 1, + lastValueVisible: false, + priceLineVisible: false, + }); + + bottomSeries.setData([ + { time: startTime, value: zd }, + { time: endTime, value: zd } + ]); + + // 添加左边界 + const leftSeries = mainChart.addLineSeries({ + color: '#F1C40F', // 主周期中枢颜色 + lineWidth: 1, + lastValueVisible: false, + priceLineVisible: false, + }); + + leftSeries.setData([ + { time: startTime, value: zd }, + { time: startTime, value: zg } + ]); + + // 添加右边界 + const rightSeries = mainChart.addLineSeries({ + color: '#F1C40F', // 主周期中枢颜色 + lineWidth: 1, + lastValueVisible: false, + priceLineVisible: false, + }); + + rightSeries.setData([ + { time: endTime, value: zd }, + { time: endTime, value: zg } + ]); + + // 绘制gg线(中枢高高) + if (!isNaN(gg) && gg > 0) { + const ggSeries = mainChart.addLineSeries({ + color: '#F1C40F', // 使用中枢自己的颜色 + lineWidth: 1, + lastValueVisible: false, + priceLineVisible: false, + }); + + ggSeries.setData([ + { time: startTime, value: gg }, + { time: endTime, value: gg } + ]); + } + + // 绘制dd线(中枢低低) + if (!isNaN(dd) && dd > 0) { + const ddSeries = mainChart.addLineSeries({ + color: '#F1C40F', // 使用中枢自己的颜色 + lineWidth: 1, + lastValueVisible: false, + priceLineVisible: false, + }); + + ddSeries.setData([ + { time: startTime, value: dd }, + { time: endTime, value: dd } + ]); + } + + // 添加到图表对象 + tvWidget.series.mainZsSeries.push({ + time: startTime, + value: zg, + color: '#F1C40F', + lineWidth: 1 + }); + tvWidget.series.mainZsSeries.push({ + time: endTime, + value: zg, + color: '#F1C40F', + lineWidth: 1 + }); + tvWidget.series.mainZsSeries.push({ + time: startTime, + value: zd, + color: '#F1C40F', + lineWidth: 1 + }); + tvWidget.series.mainZsSeries.push({ + time: endTime, + value: zd, + color: '#F1C40F', + lineWidth: 1 + }); + } catch (e) { + console.error('主周期中枢处理出错:', e); + } + }); + } + + // 次周期中枢 + if ($('#showElementZs').is(':checked') && currentData.element_zs_list && currentData.element_zs_list.length > 0) { + console.log(`绘制次周期中枢数据,共${currentData.element_zs_list.length}条`); + + currentData.element_zs_list.forEach(function(zs) { + try { + // 直接使用UTC时间戳(秒) + const startTime = Math.floor(new Date(zs.start_time).getTime() / 1000); + const endTime = Math.floor(new Date(zs.end_time).getTime() / 1000); + + if (isNaN(startTime) || isNaN(endTime)) { + console.error('次周期中枢时间转换错误:', zs.start_time, zs.end_time); + return; + } + + const zg = parseFloat(zs.zg); // 中枢上沿 + const zd = parseFloat(zs.zd); // 中枢下沿 + const gg = parseFloat(zs.gg); // 中枢高高 + const dd = parseFloat(zs.dd); // 中枢低低 + + if (isNaN(zg) || isNaN(zd)) { + console.error('次周期中枢价格转换错误:', zs.zg, zs.zd); + return; + } + + // 创建中枢上边界 + const topSeries = mainChart.addLineSeries({ + color: '#3f51b5', // 次周期中枢颜色 + lineWidth: 1, + lastValueVisible: false, + priceLineVisible: false, + }); + + topSeries.setData([ + { time: startTime, value: zg }, + { time: endTime, value: zg } + ]); + + // 为下边界创建另一条线 + const bottomSeries = mainChart.addLineSeries({ + color: '#3f51b5', // 次周期中枢颜色 + lineWidth: 1, + lastValueVisible: false, + priceLineVisible: false, + }); + + bottomSeries.setData([ + { time: startTime, value: zd }, + { time: endTime, value: zd } + ]); + + // 添加左边界 + const leftSeries = mainChart.addLineSeries({ + color: '#3f51b5', // 次周期中枢颜色 + lineWidth: 1, + lastValueVisible: false, + priceLineVisible: false, + }); + + leftSeries.setData([ + { time: startTime, value: zd }, + { time: startTime, value: zg } + ]); + + // 添加右边界 + const rightSeries = mainChart.addLineSeries({ + color: '#3f51b5', // 次周期中枢颜色 + lineWidth: 1, + lastValueVisible: false, + priceLineVisible: false, + }); + + rightSeries.setData([ + { time: endTime, value: zd }, + { time: endTime, value: zg } + ]); + + // 绘制gg线(中枢高高) + if (!isNaN(gg) && gg > 0) { + const ggSeries = mainChart.addLineSeries({ + color: '#3f51b5', // 使用次周期中枢自己的颜色 + lineWidth: 1, + lastValueVisible: false, + priceLineVisible: false, + }); + + ggSeries.setData([ + { time: startTime, value: gg }, + { time: endTime, value: gg } + ]); + } + + // 绘制dd线(中枢低低) + if (!isNaN(dd) && dd > 0) { + const ddSeries = mainChart.addLineSeries({ + color: '#3f51b5', // 使用次周期中枢自己的颜色 + lineWidth: 1, + lastValueVisible: false, + priceLineVisible: false, + }); + + ddSeries.setData([ + { time: startTime, value: dd }, + { time: endTime, value: dd } + ]); + } + + // 添加到图表对象 + tvWidget.series.elementZsSeries.push({ + time: startTime, + value: zg, + color: '#3f51b5', + lineWidth: 1 + }); + tvWidget.series.elementZsSeries.push({ + time: endTime, + value: zg, + color: '#3f51b5', + lineWidth: 1 + }); + tvWidget.series.elementZsSeries.push({ + time: startTime, + value: zd, + color: '#3f51b5', + lineWidth: 1 + }); + tvWidget.series.elementZsSeries.push({ + time: endTime, + value: zd, + color: '#3f51b5', + lineWidth: 1 + }); + } catch (e) { + console.error('次周期中枢处理出错:', e); + } + }); + } + // 次次周期SEG中枢 + if ($('#showSubSubZs').is(':checked') && currentData.sub_sub_zs_list && currentData.sub_sub_zs_list.length > 0) { + const subSubZsColor = '#00897b'; + currentData.sub_sub_zs_list.forEach(function(zs) { + try { + const startTime = Math.floor(new Date(zs.start_time).getTime() / 1000); + const endTime = zs.end_time ? Math.floor(new Date(zs.end_time).getTime() / 1000) : 0; + if (isNaN(startTime) || !endTime) return; + const zg = parseFloat(zs.zg); const zd = parseFloat(zs.zd); const gg = parseFloat(zs.gg); const dd = parseFloat(zs.dd); + if (isNaN(zg) || isNaN(zd)) return; + mainChart.addLineSeries({ color: subSubZsColor, lineWidth: 1, lastValueVisible: false, priceLineVisible: false }).setData([{ time: startTime, value: zg }, { time: endTime, value: zg }]); + mainChart.addLineSeries({ color: subSubZsColor, lineWidth: 1, lastValueVisible: false, priceLineVisible: false }).setData([{ time: startTime, value: zd }, { time: endTime, value: zd }]); + mainChart.addLineSeries({ color: subSubZsColor, lineWidth: 1, lastValueVisible: false, priceLineVisible: false }).setData([{ time: startTime, value: zd }, { time: startTime, value: zg }]); + mainChart.addLineSeries({ color: subSubZsColor, lineWidth: 1, lastValueVisible: false, priceLineVisible: false }).setData([{ time: endTime, value: zd }, { time: endTime, value: zg }]); + if (!isNaN(gg) && gg > 0) mainChart.addLineSeries({ color: subSubZsColor, lineWidth: 1, lastValueVisible: false, priceLineVisible: false }).setData([{ time: startTime, value: gg }, { time: endTime, value: gg }]); + if (!isNaN(dd) && dd > 0) mainChart.addLineSeries({ color: subSubZsColor, lineWidth: 1, lastValueVisible: false, priceLineVisible: false }).setData([{ time: startTime, value: dd }, { time: endTime, value: dd }]); + } catch (e) { console.error('次次周期中枢处理出错:', e); } + }); + } + } else { + console.log('绘制中枢 - 已禁用'); + } + // BI中枢(已完成)- 使用独立的BI开关 + if ($('#showMainBiZs').is(':checked') && currentData.bi_zs_list && currentData.bi_zs_list.length > 0) { + try { + console.log(`绘制主周期BI中枢数据,共${currentData.bi_zs_list.length}条`); + } catch (e) {} + currentData.bi_zs_list.forEach(function(zs) { + try { + const startTime = Math.floor(new Date(zs.start_time).getTime() / 1000); + const endTime = zs.end_time ? Math.floor(new Date(zs.end_time).getTime() / 1000) : Math.floor(new Date(currentData.kline_data[currentData.kline_data.length-1].date).getTime() / 1000); + if (isNaN(startTime) || isNaN(endTime)) { return; } + const zg = parseFloat(zs.zg); const zd = parseFloat(zs.zd); const gg = parseFloat(zs.gg); const dd = parseFloat(zs.dd); + if (isNaN(zg) || isNaN(zd)) { return; } + const color = '#F1C40F'; + const topSeries = mainChart.addLineSeries({ color, lineWidth: 1, lastValueVisible: false, priceLineVisible: false }); + topSeries.setData([{ time: startTime, value: zg }, { time: endTime, value: zg }]); + const bottomSeries = mainChart.addLineSeries({ color, lineWidth: 1, lastValueVisible: false, priceLineVisible: false }); + bottomSeries.setData([{ time: startTime, value: zd }, { time: endTime, value: zd }]); + const leftSeries = mainChart.addLineSeries({ color, lineWidth: 1, lastValueVisible: false, priceLineVisible: false }); + leftSeries.setData([{ time: startTime, value: zd }, { time: startTime, value: zg }]); + const rightSeries = mainChart.addLineSeries({ color, lineWidth: 1, lastValueVisible: false, priceLineVisible: false }); + rightSeries.setData([{ time: endTime, value: zd }, { time: endTime, value: zg }]); + if (!isNaN(gg) && gg > 0) { + const ggSeries = mainChart.addLineSeries({ color, lineWidth: 1, lastValueVisible: false, priceLineVisible: false }); + ggSeries.setData([{ time: startTime, value: gg }, { time: endTime, value: gg }]); + } + if (!isNaN(dd) && dd > 0) { + const ddSeries = mainChart.addLineSeries({ color, lineWidth: 1, lastValueVisible: false, priceLineVisible: false }); + ddSeries.setData([{ time: startTime, value: dd }, { time: endTime, value: dd }]); + } + } catch (e) { console.error('主周期BI中枢处理出错:', e); } + }); + } + if ($('#showSubSubBiZs').is(':checked') && currentData.sub_sub_bi_zs_list && currentData.sub_sub_bi_zs_list.length > 0) { + currentData.sub_sub_bi_zs_list.forEach(function(zs) { + try { + const startTime = Math.floor(new Date(zs.start_time).getTime() / 1000); + const kd = currentData.kline_data || []; + const endTime = zs.end_time ? Math.floor(new Date(zs.end_time).getTime() / 1000) : (kd.length ? Math.floor(new Date(kd[kd.length-1].date).getTime() / 1000) : 0); + if (isNaN(startTime) || !endTime) return; + const zg = parseFloat(zs.zg); const zd = parseFloat(zs.zd); const gg = parseFloat(zs.gg); const dd = parseFloat(zs.dd); + if (isNaN(zg) || isNaN(zd)) return; + const color = '#00897b'; + mainChart.addLineSeries({ color, lineWidth: 1, lastValueVisible: false, priceLineVisible: false }).setData([{ time: startTime, value: zg }, { time: endTime, value: zg }]); + mainChart.addLineSeries({ color, lineWidth: 1, lastValueVisible: false, priceLineVisible: false }).setData([{ time: startTime, value: zd }, { time: endTime, value: zd }]); + mainChart.addLineSeries({ color, lineWidth: 1, lastValueVisible: false, priceLineVisible: false }).setData([{ time: startTime, value: zd }, { time: startTime, value: zg }]); + mainChart.addLineSeries({ color, lineWidth: 1, lastValueVisible: false, priceLineVisible: false }).setData([{ time: endTime, value: zd }, { time: endTime, value: zg }]); + if (!isNaN(gg) && gg > 0) mainChart.addLineSeries({ color, lineWidth: 1, lastValueVisible: false, priceLineVisible: false }).setData([{ time: startTime, value: gg }, { time: endTime, value: gg }]); + if (!isNaN(dd) && dd > 0) mainChart.addLineSeries({ color, lineWidth: 1, lastValueVisible: false, priceLineVisible: false }).setData([{ time: startTime, value: dd }, { time: endTime, value: dd }]); + } catch (e) { console.error('次次周期BI中枢处理出错:', e); } + }); + } + if ($('#showElementBiZs').is(':checked') && currentData.element_bi_zs_list && currentData.element_bi_zs_list.length > 0) { + try { + console.log(`绘制次周期BI中枢数据,共${currentData.element_bi_zs_list.length}条`); + } catch (e) {} + currentData.element_bi_zs_list.forEach(function(zs) { + try { + const startTime = Math.floor(new Date(zs.start_time).getTime() / 1000); + const endTime = zs.end_time ? Math.floor(new Date(zs.end_time).getTime() / 1000) : Math.floor(new Date(currentData.kline_data[currentData.kline_data.length-1].date).getTime() / 1000); + if (isNaN(startTime) || isNaN(endTime)) { return; } + const zg = parseFloat(zs.zg); const zd = parseFloat(zs.zd); const gg = parseFloat(zs.gg); const dd = parseFloat(zs.dd); + if (isNaN(zg) || isNaN(zd)) { return; } + const color = '#3f51b5'; + const topSeries = mainChart.addLineSeries({ color, lineWidth: 1, lastValueVisible: false, priceLineVisible: false }); + topSeries.setData([{ time: startTime, value: zg }, { time: endTime, value: zg }]); + const bottomSeries = mainChart.addLineSeries({ color, lineWidth: 1, lastValueVisible: false, priceLineVisible: false }); + bottomSeries.setData([{ time: startTime, value: zd }, { time: endTime, value: zd }]); + const leftSeries = mainChart.addLineSeries({ color, lineWidth: 1, lastValueVisible: false, priceLineVisible: false }); + leftSeries.setData([{ time: startTime, value: zd }, { time: startTime, value: zg }]); + const rightSeries = mainChart.addLineSeries({ color, lineWidth: 1, lastValueVisible: false, priceLineVisible: false }); + rightSeries.setData([{ time: endTime, value: zd }, { time: endTime, value: zg }]); + if (!isNaN(gg) && gg > 0) { + const ggSeries = mainChart.addLineSeries({ color, lineWidth: 1, lastValueVisible: false, priceLineVisible: false }); + ggSeries.setData([{ time: startTime, value: gg }, { time: endTime, value: gg }]); + } + if (!isNaN(dd) && dd > 0) { + const ddSeries = mainChart.addLineSeries({ color, lineWidth: 1, lastValueVisible: false, priceLineVisible: false }); + ddSeries.setData([{ time: startTime, value: dd }, { time: endTime, value: dd }]); + } + } catch (e) { console.error('次周期BI中枢处理出错:', e); } + }); + } +} diff --git a/web/static/js/app/chart_tv_finalize.js b/web/static/js/app/chart_tv_finalize.js new file mode 100644 index 0000000..cdaac8f --- /dev/null +++ b/web/static/js/app/chart_tv_finalize.js @@ -0,0 +1,239 @@ +/* chart_tv_finalize.js — time sync / bindSync / view restore / tooltip */ + +function chartTvFinalize(ctx) { + var symbol = ctx.symbol; + var timeframe = ctx.timeframe; + var symbolConfig = ctx.symbolConfig; + var useSubSubPeriod = ctx.useSubSubPeriod; + var useElementPeriod = ctx.useElementPeriod; + var klinePeriodLabel = ctx.klinePeriodLabel; + var candles = ctx.candles; + var klineDataSource = ctx.klineDataSource; + var container = ctx.container; + var showMacd = ctx.showMacd; + var showOriginalKline = ctx.showOriginalKline; + var mainChartContainer = ctx.mainChartContainer; + var volumeChartContainer = ctx.volumeChartContainer; + var atrChartContainer = ctx.atrChartContainer; + var macdChartContainer = ctx.macdChartContainer; + var chanMacdChartContainer = ctx.chanMacdChartContainer; + var mainChart = ctx.mainChart; + var volumeChart = ctx.volumeChart; + var atrChart = ctx.atrChart; + var macdChart = ctx.macdChart; + var chanMacdChart = ctx.chanMacdChart; + var createChartOptions = ctx.createChartOptions; + // 同步所有图表的时间轴配置 + const hasPendingRestoreView = !!window._pendingRestoreView; + const pendingView = window._pendingRestoreView; + const syncTimeScaleSettings = () => { + const baseOptions = { + timeVisible: true, + secondsVisible: false, + borderColor: '#ddd', + lockVisibleTimeRangeOnResize: true, + // 关键:确保所有图表边缘行为完全一致 + fixLeftEdge: false, + fixRightEdge: false, + // 确保时间刻度行为一致 + ticksVisible: true, + minimumHeight: 0, + }; + // 有待恢复视图时不要先写 barSpacing/rightOffset(会钉右缘导致图往右偏), + // 交给后面 setVisibleRange 一次锁定位置+缩放。 + if (!pendingView) { + baseOptions.barSpacing = symbolConfig.type === 'a_stock' ? 6 : 10; + baseOptions.rightOffset = 12; + } + + console.log('🔧 同步时间轴设置:', baseOptions); + + // 应用相同的设置到所有图表 + mainChart.timeScale().applyOptions(baseOptions); + volumeChart.timeScale().applyOptions(baseOptions); + atrChart.timeScale().applyOptions(baseOptions); + if (showMacd && macdChart) { + macdChart.timeScale().applyOptions(baseOptions); + } + }; + + // 首先同步时间轴设置 + syncTimeScaleSettings(); + + // 仅在没有待恢复视图时,设置默认可见范围 + const totalBars = candles ? candles.length : 0; + const visibleBarsCount = 200; + const allChartsNow = [mainChart, volumeChart, atrChart] + .concat(showMacd && macdChart ? [macdChart] : []) + .concat(showMacd && chanMacdChart ? [chanMacdChart] : []); + if (hasPendingRestoreView && pendingView) { + restoreChartViewState(allChartsNow, pendingView, { preferTime: true }); + } else { + // 显示最近 200 根K线而非全部挤压(避免K线过多时重叠) + if (totalBars > visibleBarsCount) { + const rangeFrom = totalBars - visibleBarsCount; + const rangeTo = totalBars + 12; + mainChart.timeScale().setVisibleLogicalRange({ from: rangeFrom, to: rangeTo }); + } else { + mainChart.timeScale().fitContent(); + } + } + + // 立即同步其他图表到主图表的范围(无 pending 时) + setTimeout(() => { + if (window._pendingRestoreView) { + restoreChartViewState(allChartsNow, window._pendingRestoreView, { preferTime: true }); + return; + } + const logRange = mainChart.timeScale().getVisibleLogicalRange(); + if (logRange) { + console.log('🔧 同步可见范围:', logRange); + volumeChart.timeScale().setVisibleLogicalRange(logRange); + atrChart.timeScale().setVisibleLogicalRange(logRange); + if (showMacd && macdChart) { + macdChart.timeScale().setVisibleLogicalRange(logRange); + } + if (showMacd && chanMacdChart) { + chanMacdChart.timeScale().setVisibleLogicalRange(logRange); + } + console.log('🔧 时间轴同步完成'); + } + }, 50); + + // 保存图表对象 + tvWidget.mainChart = mainChart; + tvWidget.volumeChart = volumeChart; + tvWidget.atrChart = atrChart; + tvWidget.macdChart = macdChart; + tvWidget.chanMacdChart = chanMacdChart; + tvWidget.state.isInitialized = true; + // 注册窗口卸载时释放资源,避免GPU内存泄漏 + window.onbeforeunload = function() { + try { + if (tvWidget && tvWidget.state && tvWidget.state.isInitialized) { + if (tvWidget.mainChart && typeof tvWidget.mainChart.remove === 'function') tvWidget.mainChart.remove(); + if (tvWidget.volumeChart && typeof tvWidget.volumeChart.remove === 'function') tvWidget.volumeChart.remove(); + if (tvWidget.macdChart && typeof tvWidget.macdChart.remove === 'function') tvWidget.macdChart.remove(); + if (tvWidget.chanMacdChart && typeof tvWidget.chanMacdChart.remove === 'function') tvWidget.chanMacdChart.remove(); + if (tvWidget.atrChart && typeof tvWidget.atrChart.remove === 'function') tvWidget.atrChart.remove(); + } + } catch (e) {} + }; + + // 初始化默认均线/布林带配置(仅在首次初始化时) + if (!hasInitializedDefaultMAs && movingAverages.length === 0) { + console.log('初始化默认均线与布林带指标'); + + if (typeof maIdCounter !== 'number' || !Number.isFinite(maIdCounter)) { + maIdCounter = 0; + } + if (typeof bbIdCounter !== 'number' || !Number.isFinite(bbIdCounter)) { + bbIdCounter = 0; + } + + const defaultMAs = [ + { type: 'EMA', length: 26, color: '#FF8C00', name: 'EMA26', visible: false }, // 橙色 + { type: 'EMA', length: 52, color: '#000000', name: 'EMA52', visible: true }, // 黑色 · 默认开 + { type: 'SMA', length: 30, color: '#1E90FF', name: 'MA30', visible: true }, // 蓝色 · 默认开 + { type: 'SMA', length: 250, color: '#800080', name: 'MA250', visible: true } // 紫色 · 默认开 + ]; + + defaultMAs.forEach(ma => { + const config = { + id: ++maIdCounter, + type: ma.type, + length: ma.length, + source: 'close', + smoothType: 'none', + smoothLength: 3, + lineWidth: 1, // 1px线宽 + lineStyle: 0, // 实线 + color: ma.color, + visible: ma.visible + }; + + movingAverages.push(config); + console.log(`添加默认${ma.name}:`, ma.color); + }); + + if (bollingerBands.length === 0) { + const defaultBB = { + id: ++bbIdCounter, + type: 'Bollinger Bands', + length: 20, + upperMultiplier: 2, + lowerMultiplier: 2, + source: 'close', + lineWidth: 1, + lineStyle: 0, + upperColor: '#ff6b6b', + middleColor: '#ffffff', + lowerColor: '#ff6b6b', + visible: false + }; + bollingerBands.push(defaultBB); + console.log('添加默认布林带: BB(20, 2, 2)'); + } + + console.log('默认指标配置完成,当前均线数量', movingAverages.length, '布林带数量', bollingerBands.length); + hasInitializedDefaultMAs = true; + } + + // 添加均线到图表 + addMovingAveragesToChart(candles); + + // 添加布林带到图表 + addBollingerBandsToChart(candles); + + // 更新技术指标面板显示 + updateIndicatorPanel(); + + // 绑定同步事件 + bindSyncEvents(mainChartContainer, volumeChartContainer, atrChartContainer, macdChartContainer, chanMacdChartContainer, mainChart, volumeChart, atrChart, macdChart, chanMacdChart, showMacd); + + // 最终确保所有图表时间轴对齐(同时恢复刷新前保存的缩放/位置) + setTimeout(() => { + const allCharts = [mainChart, volumeChart, atrChart]; + if (showMacd && macdChart) allCharts.push(macdChart); + if (showMacd && chanMacdChart) allCharts.push(chanMacdChart); + + // 检查是否有待恢复的视图(缩放 + 位置) + const pending = window._pendingRestoreView; + window._pendingRestoreView = null; + + if (pending) { + // 恢复刷新前的缩放和位置(时间范围优先,避免数据滑动后逻辑索引错位) + console.log('📌 恢复图表视图:', JSON.stringify(pending)); + restoreChartViewState(allCharts, pending, { preferTime: true }); + } else { + // 无保存视图,正常同步主图到子图 + const visibleRange = mainChart.timeScale().getVisibleRange(); + if (visibleRange) { + console.log('🔧 最终同步可见范围:', visibleRange); + [volumeChart, atrChart].concat( + showMacd && macdChart ? [macdChart] : [], + showMacd && chanMacdChart ? [chanMacdChart] : [] + ).forEach(c => { + try { c.timeScale().setVisibleRange(visibleRange); } catch(e) {} + }); + } + } + console.log('🔧 最终时间轴对齐完成'); + }, 150); + // 只有在时间输入框都为空时才设置图表默认时间范围 + if (!$('#start_time').val() && !$('#end_time').val()) { + setDefaultTimeRange(); + } + + // 添加买卖点提示 + // 初始化 tooltip 与 U 显示状态 + window.showUOnMain = $('#toggleUOnMain').is(':checked'); + window.showUOnElement = $('#toggleUOnElement').is(':checked'); + setupTooltip(mainChart, [], [], mainChartContainer, volumeChartContainer, atrChartContainer, macdChartContainer, chanMacdChartContainer, volumeChart, atrChart, macdChart, chanMacdChart, showMacd); + + // 更新EMA52显示 + if (currentData) { + updateEMA52Display(currentData); + } + +} diff --git a/web/static/js/app/chart_tv_indicators.js b/web/static/js/app/chart_tv_indicators.js new file mode 100644 index 0000000..eb6f5b2 --- /dev/null +++ b/web/static/js/app/chart_tv_indicators.js @@ -0,0 +1,564 @@ +/* chart_tv_indicators.js — volume / ATR / ChanMACD */ + +function chartTvRenderIndicators(ctx) { + var symbol = ctx.symbol; + var timeframe = ctx.timeframe; + var symbolConfig = ctx.symbolConfig; + var useSubSubPeriod = ctx.useSubSubPeriod; + var useElementPeriod = ctx.useElementPeriod; + var klinePeriodLabel = ctx.klinePeriodLabel; + var candles = ctx.candles; + var klineDataSource = ctx.klineDataSource; + var container = ctx.container; + var showMacd = ctx.showMacd; + var showOriginalKline = ctx.showOriginalKline; + var mainChartContainer = ctx.mainChartContainer; + var volumeChartContainer = ctx.volumeChartContainer; + var atrChartContainer = ctx.atrChartContainer; + var macdChartContainer = ctx.macdChartContainer; + var chanMacdChartContainer = ctx.chanMacdChartContainer; + var mainChart = ctx.mainChart; + var volumeChart = ctx.volumeChart; + var atrChart = ctx.atrChart; + var macdChart = ctx.macdChart; + var chanMacdChart = ctx.chanMacdChart; + var createChartOptions = ctx.createChartOptions; + // 转换成交量数据 - 与K线周期一致 + let volumes = []; + const volumeDataSource = useSubSubPeriod ? (currentData.sub_sub_kline_data || []) : (useElementPeriod ? currentData.element_kline_data : currentData.kline_data); + + console.log('成交量数据源选择:', klinePeriodLabel); + console.log('成交量数据长度:', volumeDataSource.length); + + if (volumeDataSource && Array.isArray(volumeDataSource)) { + volumes = volumeDataSource.map(kline => { + // 使用与K线和MACD完全相同的时间戳计算方式 + const timestamp = Math.floor(new Date(kline.date).getTime() / 1000); + return { + time: timestamp, + value: parseFloat(kline.volume), + color: parseFloat(kline.close) >= parseFloat(kline.open) ? 'rgba(40, 167, 69, 0.5)' : 'rgba(220, 53, 69, 0.5)', + }; + }); + + console.log('处理后的成交量数据点数:', volumes.length); + } + + // 添加成交量图表 + const volumeSeries = volumeChart.addHistogramSeries({ + color: '#26a69a', + priceFormat: { + type: 'volume', + }, + title: '成交量', + }); + volumeSeries.setData(volumes); + tvWidget.series.volumeSeries = volumeSeries; + + // 添加ATR图表 + const atrLineSeries = atrChart.addLineSeries({ + color: '#FF9800', + lineWidth: 2, + title: 'ATR', + lastValueVisible: false, + priceLineVisible: false, + }); + + // 准备ATR数据 + const atrData = []; + const atrKlineDataSource = useSubSubPeriod ? (currentData.sub_sub_kline_data || []) : (useElementPeriod ? currentData.element_kline_data : currentData.kline_data); + const atrDataSource = useSubSubPeriod ? (currentData.sub_sub_atr || currentData.atr) : (useElementPeriod ? (currentData.element_atr || currentData.atr) : currentData.atr); + + console.log('ATR数据源选择:', klinePeriodLabel); + console.log('ATR数据长度:', atrDataSource ? atrDataSource.length : 0); + console.log('K线数据长度:', atrKlineDataSource ? atrKlineDataSource.length : 0); + + if (atrDataSource && Array.isArray(atrDataSource) && atrKlineDataSource && Array.isArray(atrKlineDataSource)) { + // 关键修复:为每个K线时间点都创建ATR数据点,包括没有ATR值的前期数据 + for (let i = 0; i < atrKlineDataSource.length; i++) { + const kline = atrKlineDataSource[i]; + const timestamp = Math.floor(new Date(kline.date).getTime() / 1000); + + // 为每个时间点都添加数据以保持时间轴对齐,但ATR为0时不显示 + if (atrDataSource[i] !== undefined) { + if (atrDataSource[i] > 0) { + // ATR有效值,正常显示 + atrData.push({ + time: timestamp, + value: atrDataSource[i] + }); + } else { + // ATR为0,添加时间点但不显示线条(使用undefined作为value) + atrData.push({ + time: timestamp, + value: undefined + }); + } + } + } + + console.log('处理后的ATR数据点数:', atrData.length); + console.log('ATR数据样本:', atrData.slice(0, 5)); + } + console.log('处理后的ATR数据点数:', atrData.length); + atrLineSeries.setData(atrData); + tvWidget.series.atrLineSeries = atrLineSeries; + + // 旧 MACD 图已移除,不再绘制(保留占位但彻底禁用) + if (FEATURES.legacyMacd && showMacd && currentData.macd && currentData.kline_data && Array.isArray(currentData.kline_data)) { + // 创建MACD线 + const macdLineSeries = macdChart.addLineSeries({ + color: '#2962FF', + lineWidth: 1, + title: 'MACD', + lastValueVisible: false, // 禁用最后值标签,防止遮挡 + priceLineVisible: false, // 禁用价格线 + }); + + // 创建信号线 + const signalLineSeries = macdChart.addLineSeries({ + color: '#FF6B6B', + lineWidth: 1, + title: 'Signal', + lastValueVisible: false, // 禁用最后值标签,防止遮挡 + priceLineVisible: false, // 禁用价格线 + }); + + // 创建直方图 + const histogramSeries = macdChart.addHistogramSeries({ + color: '#26a69a', + title: 'Histogram', + priceFormat: { + type: 'price', + precision: 4, + }, + }); + + // 提取MACD数据 - 使用和K线数据相同的时间处理逻辑 + const macdData = []; + const signalData = []; + const histogramData = []; + + // 使用与K线数据相同的数据源来确保时间对齐 + const klineDataSource = useSubSubPeriod ? (currentData.sub_sub_kline_data || []) : (useElementPeriod ? currentData.element_kline_data : currentData.kline_data); + const macdDataSource = useElementPeriod ? + (currentData.element_macd || currentData.macd) : // 如果有次周期MACD数据则使用,否则使用主周期 + currentData.macd; // 主周期使用主周期MACD数据 + + console.log('MACD数据源选择:', useElementPeriod ? '次周期' : '主周期'); + console.log('K线数据长度:', klineDataSource.length); + console.log('MACD数据:', macdDataSource); + + for (let i = 0; i < klineDataSource.length; i++) { + const kline = klineDataSource[i]; + // 使用与K线完全相同的时间戳计算方式 + const timestamp = Math.floor(new Date(kline.date).getTime() / 1000); + + if (macdDataSource && macdDataSource.macd && macdDataSource.macd[i] !== undefined) { + macdData.push({ + time: timestamp, + value: macdDataSource.macd[i] + }); + + signalData.push({ + time: timestamp, + value: macdDataSource.signal[i] + }); + + // 设置直方图颜色 + const histValue = macdDataSource.histogram[i]; + histogramData.push({ + time: timestamp, + value: histValue, + color: histValue >= 0 ? 'rgba(40, 167, 69, 0.5)' : 'rgba(220, 53, 69, 0.5)' + }); + } + } + + console.log('处理后的MACD数据点数:', macdData.length); + + macdLineSeries.setData(macdData); + signalLineSeries.setData(signalData); + histogramSeries.setData(histogramData); + + tvWidget.series.macdLineSeries = macdLineSeries; + tvWidget.series.signalLineSeries = signalLineSeries; + tvWidget.series.histogramSeries = histogramSeries; + } + // 添加ChanMACD图表 + console.log('ChanMACD图表创建条件检查:', { + showMacd: showMacd, + chanMacdChart: !!chanMacdChart, + hasMacd: !!currentData.macd, + hasKlineData: !!currentData.kline_data, + isArray: Array.isArray(currentData.kline_data) + }); + // 在创建 ChanMACD 前,确保一次性同步 U 显示开关到全局(默认不显示) + if (typeof window.showUOnMain === 'undefined') { + window.showUOnMain = $('#toggleUOnMain').is(':checked'); + } + if (typeof window.showUOnElement === 'undefined') { + window.showUOnElement = $('#toggleUOnElement').is(':checked'); + } + if (typeof window.showUOnSubSub === 'undefined') { + window.showUOnSubSub = $('#toggleUOnSubSub').is(':checked'); + } + if (showMacd && chanMacdChart && ((useSubSubPeriod && currentData.sub_sub_macd) || (useElementPeriod && currentData.element_macd) || currentData.macd) && (useSubSubPeriod ? (currentData.sub_sub_kline_data || []) : (useElementPeriod ? currentData.element_kline_data : currentData.kline_data))) { + console.log('✅ 开始创建 ChanMACD 系列'); + // 创建ChanMACD线系列 + const chanMacdLineSeries = chanMacdChart.addLineSeries({ + color: '#2962FF', + lineWidth: 1, + title: 'ChanMACD', + lastValueVisible: false, + priceLineVisible: false, + }); + + // 创建ChanMACD信号线系列 + const chanMacdSignalSeries = chanMacdChart.addLineSeries({ + color: '#FF6B6B', + lineWidth: 1, + title: 'ChanSignal', + lastValueVisible: false, + priceLineVisible: false, + }); + + // 创建ChanMACD柱状图系列 + const chanMacdHistSeries = chanMacdChart.addHistogramSeries({ + color: '#26a69a', + title: 'ChanHistogram', + priceFormat: { + type: 'price', + precision: 4, + }, + }); + + // 设置ChanMACD图表的字体大小 + chanMacdChart.applyOptions({ + layout: { + fontSize: 10, // 设置更小的字体大小 + }, + rightPriceScale: { + fontSize: 10, // 设置右侧价格轴的字体大小 + }, + timeScale: { + fontSize: 10, // 设置时间轴的字体大小 + }, + }); + + // 使用与主图一致的数据源(小周期开启时使用小周期MACD与K线) + const klineDataSource = useSubSubPeriod ? (currentData.sub_sub_kline_data || []) : (useElementPeriod ? currentData.element_kline_data : currentData.kline_data); + const macdDataSource = useSubSubPeriod ? (currentData.sub_sub_macd || currentData.macd) : (useElementPeriod ? (currentData.element_macd || currentData.macd) : currentData.macd); + + // 准备ChanMACD数据 + const chanMacdData = []; + const chanSignalData = []; + const chanHistData = []; + + console.log('ChanMACD数据源检查:', { + klineDataSourceLength: klineDataSource.length, + macdDataSource: !!macdDataSource, + macdLength: macdDataSource ? macdDataSource.macd.length : 0 + }); + + console.log('ChanMACD数据源检查:', { + klineDataSourceLength: klineDataSource.length, + macdDataSource: !!macdDataSource, + macdLength: macdDataSource ? macdDataSource.macd.length : 0 + }); + + for (let i = 0; i < klineDataSource.length; i++) { + const kline = klineDataSource[i]; + if (kline && kline.date && + i < macdDataSource.macd.length && + macdDataSource.macd[i] !== null && macdDataSource.macd[i] !== undefined) { + + // 使用与K线完全相同的时间戳计算方式 + const timestamp = Math.floor(new Date(kline.date).getTime() / 1000); + + chanMacdData.push({ + time: timestamp, + value: macdDataSource.macd[i] + }); + + chanSignalData.push({ + time: timestamp, + value: macdDataSource.signal[i] + }); + + chanHistData.push({ + time: timestamp, + value: macdDataSource.histogram[i], + color: macdDataSource.histogram[i] >= 0 ? 'rgba(40, 167, 69, 0.5)' : 'rgba(220, 53, 69, 0.5)' + }); + } + } + + console.log('ChanMACD数据处理完成:', { + chanMacdDataLength: chanMacdData.length, + chanSignalDataLength: chanSignalData.length, + chanHistDataLength: chanHistData.length, + sampleData: chanMacdData.length > 0 ? chanMacdData[0] : null + }); + + // 设置ChanMACD数据 + console.log('ChanMACD数据长度:', chanMacdData.length, chanSignalData.length, chanHistData.length); + + if (chanMacdData.length > 0) { + chanMacdLineSeries.setData(chanMacdData); + chanMacdSignalSeries.setData(chanSignalData); + chanMacdHistSeries.setData(chanHistData); + console.log('✅ ChanMACD数据设置成功'); + } else { + console.warn('⚠️ ChanMACD数据为空,无法设置数据'); + } + + // 保存到tvWidget + tvWidget.series.chanMacdLineSeries = chanMacdLineSeries; + tvWidget.series.chanMacdSignalSeries = chanMacdSignalSeries; + tvWidget.series.chanMacdHistSeries = chanMacdHistSeries; + + console.log('✅ ChanMACD图表系列已保存到tvWidget'); + + // 添加ChanMACD分析标注 + // 根据主/次周期开关与各自的"显示U"独立控制 + const cm = useSubSubPeriod ? (currentData.sub_sub_chan_macd || currentData.chan_macd) : (useElementPeriod ? (currentData.element_chan_macd || currentData.chan_macd) : currentData.chan_macd); + // 默认不显示,必须用户勾选对应复选框 + const allowU = useSubSubPeriod ? !!window.showUOnSubSub : (useElementPeriod ? !!window.showUOnElement : !!window.showUOnMain); + if (cm && allowU) { + console.log('添加ChanMACD分析标注:', { + segListLength: cm.seg_list ? cm.seg_list.length : 0, + unittfListLength: cm.unittf_list ? cm.unittf_list.length : 0, + histsetListLength: cm.histset_list ? cm.histset_list.length : 0 + }); + + // 详细检查段数据 + if (cm.seg_list && cm.seg_list.length > 0) { + console.log('段数据详情:', cm.seg_list.slice(0, 3)); // 显示前3个段 + } else { + console.log('⚠️ 段数据为空或不存在'); + } + + addAllChanMacdMarkers( + cm.seg_list || [], + cm.unittf_list || [], + cm.histset_list || [], + { + high_position_list: cm.high_position_list || [], + high_empty_list: cm.high_empty_list || [], + low_position_list: cm.low_position_list || [], + low_empty_list: cm.low_empty_list || [], + return_zero_list: cm.return_zero_list || [], + cross0_up_list: cm.cross0_up_list || [], + cross0_down_list: cm.cross0_down_list || [] + } + ); + + // 同时从主/次周期的 klu_list 提取 SD/CD 标记,分别使用不同样式 + try { + const mainCm = currentData.chan_macd || {}; + const elementCm = currentData.element_chan_macd || {}; + + const mainMarkers = []; + const elementMarkers = []; + + // 基于时间构建 MACD 值映射,便于按时间快速获取对应的 MACD 值 + const buildMacdTimeMap = (macdObj, klineArr) => { + const map = new Map(); + if (!macdObj || !klineArr || !Array.isArray(klineArr)) return map; + for (let i = 0; i < klineArr.length; i++) { + const k = klineArr[i]; + if (!k || !k.date) continue; + const t = Math.floor(new Date(k.date).getTime() / 1000); + const val = (macdObj.macd && macdObj.macd[i] !== undefined && macdObj.macd[i] !== null) ? macdObj.macd[i] : null; + map.set(t, val); + } + return map; + }; + const mainMacdMap = buildMacdTimeMap(currentData.macd, currentData.kline_data); + const elementMacdMap = buildMacdTimeMap( + (currentData.element_macd || currentData.macd), + (currentData.element_kline_data || currentData.kline_data) + ); + + // 主周期 U 标记(蓝/橙,与原样式一致) + if (window.showUOnMain && Array.isArray(mainCm.klu_list)) { + mainCm.klu_list.forEach((item) => { + if (!item || !item.time) return; + const ts = Math.floor(new Date(item.time).getTime() / 1000); + if (isNaN(ts)) return; + if (Number(item.separate_div) > 0) { + const macdVal = mainMacdMap.get(ts); + const posSd = (macdVal > 0) ? 'aboveBar' : (macdVal < 0) ? 'belowBar' : 'aboveBar'; + mainMarkers.push({ time: ts, position: posSd, color: '#03a9f4', shape: 'arrowUp', text: `SD${Number(item.separate_div)}`, size: 0.6 }); + } + if (item.continue_div === true) { + const macdVal = mainMacdMap.get(ts); + const posCd = (macdVal > 0) ? 'aboveBar' : (macdVal < 0) ? 'belowBar' : 'belowBar'; + mainMarkers.push({ time: ts, position: posCd, color: '#ff9800', shape: 'arrowDown', text: 'CD', size: 0.6 }); + } + if (item.near0_return && Number(item.near0_return) > 0) { + mainMarkers.push({ time: ts, position: 'belowBar', color: '#8bc34a', shape: 'circle', text: `${Number(item.near0_return)}`, size: 0.6 }); + } + }); + } + + // 次周期 U 标记(使用不同配色以区分) + if (window.showUOnElement && Array.isArray(elementCm.klu_list)) { + elementCm.klu_list.forEach((item) => { + if (!item || !item.time) return; + const ts = Math.floor(new Date(item.time).getTime() / 1000); + if (isNaN(ts)) return; + if (Number(item.separate_div) > 0) { + const macdVal = elementMacdMap.get(ts); + const posSd = (macdVal > 0) ? 'aboveBar' : (macdVal < 0) ? 'belowBar' : 'aboveBar'; + elementMarkers.push({ time: ts, position: posSd, color: '#9c27b0', shape: 'arrowUp', text: `SD${Number(item.separate_div)}`, size: 0.6 }); + } + if (item.continue_div === true) { + const macdVal = elementMacdMap.get(ts); + const posCd = (macdVal > 0) ? 'aboveBar' : (macdVal < 0) ? 'belowBar' : 'belowBar'; + elementMarkers.push({ time: ts, position: posCd, color: '#4caf50', shape: 'arrowDown', text: 'CD', size: 0.6 }); + } + if (item.near0_return && Number(item.near0_return) > 0) { + elementMarkers.push({ time: ts, position: 'belowBar', color: '#009688', shape: 'circle', text: `${Number(item.near0_return)}`, size: 0.6 }); + } + }); + } + + const subSubCm = currentData.sub_sub_chan_macd || {}; + const subSubMarkers = []; + const subSubMacdMap = buildMacdTimeMap(currentData.macd, currentData.kline_data); + if (window.showUOnSubSub && Array.isArray(subSubCm.klu_list)) { + subSubCm.klu_list.forEach((item) => { + if (!item || !item.time) return; + const ts = Math.floor(new Date(item.time).getTime() / 1000); + if (isNaN(ts)) return; + if (Number(item.separate_div) > 0) { + const macdVal = subSubMacdMap.get(ts); + const posSd = (macdVal > 0) ? 'aboveBar' : (macdVal < 0) ? 'belowBar' : 'aboveBar'; + subSubMarkers.push({ time: ts, position: posSd, color: '#00897b', shape: 'arrowUp', text: `SD${Number(item.separate_div)}`, size: 0.6 }); + } + if (item.continue_div === true) { + const macdVal = subSubMacdMap.get(ts); + const posCd = (macdVal > 0) ? 'aboveBar' : (macdVal < 0) ? 'belowBar' : 'belowBar'; + subSubMarkers.push({ time: ts, position: posCd, color: '#26a69a', shape: 'arrowDown', text: 'CD', size: 0.6 }); + } + if (item.near0_return && Number(item.near0_return) > 0) { + subSubMarkers.push({ time: ts, position: 'belowBar', color: '#00695c', shape: 'circle', text: `${Number(item.near0_return)}`, size: 0.6 }); + } + }); + } + window.kluDivMarkersSubSub = subSubMarkers; + + // 保存到全局,供主图合并标记使用 + window.kluDivMarkersMain = mainMarkers; + window.kluDivMarkersElement = elementMarkers; + } catch (e) { + console.warn('处理 KLU 背驰标记出错:', e); + window.kluDivMarkersMain = []; + window.kluDivMarkersElement = []; + window.kluDivMarkersSubSub = []; + } + } else { + console.log('⚠️ 没有ChanMACD分析数据'); + // 无数据时清空本次的 KLU 背驰标记 + window.kluDivMarkersMain = []; + window.kluDivMarkersElement = []; + window.kluDivMarkersSubSub = []; + } + } else { + console.log('⚠️ ChanMACD图表创建条件不满足'); + } + // 独立于当前显示周期:计算主/次周期 SD/CD 标记(用于主图合并显示) + try { + const mainCmAll = currentData.chan_macd || {}; + const elementCmAll = currentData.element_chan_macd || {}; + const mainMarkersAll = []; + const elementMarkersAll = []; + + // 构建 MACD 时间映射,用于依据 MACD 正负决定 SD/CD 的显示上下位置 + const buildMacdTimeMapAll = (macdObj, klineArr) => { + const map = new Map(); + if (!macdObj || !klineArr || !Array.isArray(klineArr)) return map; + for (let i = 0; i < klineArr.length; i++) { + const k = klineArr[i]; + if (!k || !k.date) continue; + const t = Math.floor(new Date(k.date).getTime() / 1000); + const val = (macdObj.macd && macdObj.macd[i] !== undefined && macdObj.macd[i] !== null) ? macdObj.macd[i] : null; + map.set(t, val); + } + return map; + }; + const mainMacdMapAll = buildMacdTimeMapAll(currentData.macd, currentData.kline_data); + const elementMacdMapAll = buildMacdTimeMapAll( + (currentData.element_macd || currentData.macd), + (currentData.element_kline_data || currentData.kline_data) + ); + if ((typeof window.showUOnMain === 'undefined' ? false : window.showUOnMain) && Array.isArray(mainCmAll.klu_list)) { + mainCmAll.klu_list.forEach((item) => { + if (!item || !item.time) return; + const ts = Math.floor(new Date(item.time).getTime() / 1000); + if (isNaN(ts)) return; + if (Number(item.separate_div) > 0) { + const macdVal = mainMacdMapAll.get(ts); + const posSd = (macdVal > 0) ? 'aboveBar' : (macdVal < 0) ? 'belowBar' : 'aboveBar'; + mainMarkersAll.push({ time: ts, position: posSd, color: '#03a9f4', shape: 'arrowUp', text: `SD${Number(item.separate_div)}`, size: 0.6 }); + } + if (item.continue_div === true) { + const macdVal = mainMacdMapAll.get(ts); + const posCd = (macdVal > 0) ? 'aboveBar' : (macdVal < 0) ? 'belowBar' : 'belowBar'; + mainMarkersAll.push({ time: ts, position: posCd, color: '#ff9800', shape: 'arrowDown', text: 'CD', size: 0.6 }); + } + if (item.near0_return && Number(item.near0_return) > 0) { + mainMarkersAll.push({ time: ts, position: 'belowBar', color: '#8bc34a', shape: 'circle', text: `${Number(item.near0_return)}`, size: 0.6 }); + } + }); + } + if ((typeof window.showUOnElement === 'undefined' ? false : window.showUOnElement) && Array.isArray(elementCmAll.klu_list)) { + elementCmAll.klu_list.forEach((item) => { + if (!item || !item.time) return; + const ts = Math.floor(new Date(item.time).getTime() / 1000); + if (isNaN(ts)) return; + if (Number(item.separate_div) > 0) { + const macdVal = elementMacdMapAll.get(ts); + const posSd = (macdVal > 0) ? 'aboveBar' : (macdVal < 0) ? 'belowBar' : 'aboveBar'; + elementMarkersAll.push({ time: ts, position: posSd, color: '#9c27b0', shape: 'arrowUp', text: `SD${Number(item.separate_div)}`, size: 0.6 }); + } + if (item.continue_div === true) { + const macdVal = elementMacdMapAll.get(ts); + const posCd = (macdVal > 0) ? 'aboveBar' : (macdVal < 0) ? 'belowBar' : 'belowBar'; + elementMarkersAll.push({ time: ts, position: posCd, color: '#4caf50', shape: 'arrowDown', text: 'CD', size: 0.6 }); + } + if (item.near0_return && Number(item.near0_return) > 0) { + elementMarkersAll.push({ time: ts, position: 'belowBar', color: '#009688', shape: 'circle', text: `${Number(item.near0_return)}`, size: 0.6 }); + } + }); + } + window.kluDivMarkersMain = mainMarkersAll; + window.kluDivMarkersElement = elementMarkersAll; + const subSubCmAll = currentData.sub_sub_chan_macd || {}; + const subSubMarkersAll = []; + if (window.showUOnSubSub && Array.isArray(subSubCmAll.klu_list)) { + subSubCmAll.klu_list.forEach((item) => { + if (!item || !item.time) return; + const ts = Math.floor(new Date(item.time).getTime() / 1000); + if (isNaN(ts)) return; + if (Number(item.separate_div) > 0) { + subSubMarkersAll.push({ time: ts, position: 'aboveBar', color: '#00897b', shape: 'arrowUp', text: `SD${Number(item.separate_div)}`, size: 0.6 }); + } + if (item.continue_div === true) { + subSubMarkersAll.push({ time: ts, position: 'belowBar', color: '#26a69a', shape: 'arrowDown', text: 'CD', size: 0.6 }); + } + if (item.near0_return && Number(item.near0_return) > 0) { + subSubMarkersAll.push({ time: ts, position: 'belowBar', color: '#00695c', shape: 'circle', text: `${Number(item.near0_return)}`, size: 0.6 }); + } + }); + } + window.kluDivMarkersSubSub = subSubMarkersAll; + } catch (e) { + console.warn('独立计算 KLU 背驰标记出错:', e); + window.kluDivMarkersMain = []; + window.kluDivMarkersElement = []; + window.kluDivMarkersSubSub = []; + } +} diff --git a/web/static/js/app/chart_tv_lifecycle.js b/web/static/js/app/chart_tv_lifecycle.js new file mode 100644 index 0000000..a17a7b6 --- /dev/null +++ b/web/static/js/app/chart_tv_lifecycle.js @@ -0,0 +1,54 @@ +/* chart_tv_lifecycle.js — dispose Lightweight Charts / DOM / listeners */ + +/** 释放 Lightweight Charts 实例、DOM 与全局事件,避免自动刷新内存泄漏 */ +function disposeTradingViewCharts() { + try { + if (window._tvInitCleanups && Array.isArray(window._tvInitCleanups)) { + window._tvInitCleanups.forEach(function (fn) { try { fn(); } catch (e) {} }); + } + window._tvInitCleanups = []; + if (window._bindSyncCleanups && Array.isArray(window._bindSyncCleanups)) { + window._bindSyncCleanups.forEach(function (fn) { try { fn(); } catch (e) {} }); + } + window._bindSyncCleanups = []; + if (window._tooltipCleanups && Array.isArray(window._tooltipCleanups)) { + window._tooltipCleanups.forEach(function (fn) { try { fn(); } catch (e) {} }); + } + window._tooltipCleanups = []; + + document.querySelectorAll( + '.volume-crosshair-line, .atr-crosshair-line, .macd-crosshair-line, .chanmacd-crosshair-line' + ).forEach(function (el) { try { el.remove(); } catch (e) {} }); + + if (typeof clearEMA52Series === 'function') { + try { clearEMA52Series(); } catch (e) {} + } + + if (tvWidget) { + ['mainChart', 'volumeChart', 'macdChart', 'chanMacdChart', 'atrChart'].forEach(function (key) { + try { + if (tvWidget[key] && typeof tvWidget[key].remove === 'function') { + tvWidget[key].remove(); + } + } catch (e) {} + tvWidget[key] = null; + }); + if (tvWidget.state) { + tvWidget.state.isInitialized = false; + } + } + + 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) { + console.warn('disposeTradingViewCharts 失败(可忽略):', e); + } +} diff --git a/web/static/js/app/chart_tv_overlays.js b/web/static/js/app/chart_tv_overlays.js new file mode 100644 index 0000000..222ab3c --- /dev/null +++ b/web/static/js/app/chart_tv_overlays.js @@ -0,0 +1,2464 @@ +/* chart_tv_overlays.js — structure zones / wyckoff / BSP / FX / bollinger */ + +/** 标记 time 必须落在主 series 的 K 线 time 上,否则 LWC 会抛 Value is null */ +function alignMarkersToCandles(markers, candles) { + if (!Array.isArray(markers) || !markers.length) return []; + if (!Array.isArray(candles) || !candles.length) return []; + var times = []; + for (var i = 0; i < candles.length; i++) { + var ct = candles[i] && candles[i].time; + if (ct == null || !isFinite(Number(ct))) continue; + times.push(Math.floor(Number(ct))); + } + if (!times.length) return []; + var set = {}; + for (var j = 0; j < times.length; j++) set[times[j]] = true; + var nearest = function (target) { + var best = times[0]; + var bestDiff = Math.abs(best - target); + // 两端夹逼:大数据量时比全扫略好 + var lo = 0, hi = times.length - 1; + while (lo <= hi) { + var mid = (lo + hi) >> 1; + var t = times[mid]; + var d = Math.abs(t - target); + if (d < bestDiff) { best = t; bestDiff = d; } + if (t < target) lo = mid + 1; + else hi = mid - 1; + } + if (lo < times.length) { + var d2 = Math.abs(times[lo] - target); + if (d2 < bestDiff) best = times[lo]; + } + if (hi >= 0) { + var d3 = Math.abs(times[hi] - target); + if (d3 < bestDiff) best = times[hi]; + } + return best; + }; + var out = []; + for (var k = 0; k < markers.length; k++) { + var m = markers[k]; + if (!m || m.time == null || !isFinite(Number(m.time))) continue; + var t0 = Math.floor(Number(m.time)); + var aligned = set[t0] ? t0 : nearest(t0); + var copy = Object.assign({}, m, { time: aligned }); + out.push(copy); + } + return out; +} + +function safeOverlayLineSetData(series, points) { + if (!series || typeof series.setData !== 'function' || !Array.isArray(points) || points.length < 2) return; + try { + var a = points[0], b = points[1]; + if (!a || !b || a.time == null || b.time == null) return; + var t0 = Math.floor(Number(a.time)); + var t1 = Math.floor(Number(b.time)); + var v0 = Number(a.value); + var v1 = Number(b.value); + if (!isFinite(t0) || !isFinite(t1) || !isFinite(v0) || !isFinite(v1)) return; + // 竖边不用折线(任意时间差都会斜),改走 canvas + if (t0 === t1) return; + if (t0 > t1) { + series.setData([{ time: t1, value: v1 }, { time: t0, value: v0 }]); + } else { + series.setData([{ time: t0, value: v0 }, { time: t1, value: v1 }]); + } + } catch (e) { + console.warn('叠层线 setData 跳过:', e && e.message ? e.message : e); + } +} + +function pushFxBoxVertical(time, lo, hi, color) { + if (!window._fxBoxVerticals) window._fxBoxVerticals = []; + var t = Math.floor(Number(time)); + var a = Number(lo), b = Number(hi); + if (!isFinite(t) || !isFinite(a) || !isFinite(b) || a === b) return; + window._fxBoxVerticals.push({ + time: t, + lo: Math.min(a, b), + hi: Math.max(a, b), + color: color || '#888' + }); +} + +function getMainPriceSeries() { + if (!window.tvWidget || !tvWidget.series) return null; + var s = tvWidget.series; + return s.candleSeries || s.klcSeries || s.barSeries || s.heikinSeries || s.renkoSeries || + s.lineSeries || s.areaSeries || s.baselineSeries || null; +} + +function syncFxBoxVerticalOverlay(mainChart, mainChartContainer) { + if (!mainChart || !mainChartContainer) return; + if (typeof window._fxBoxOverlayCleanup === 'function') { + try { window._fxBoxOverlayCleanup(); } catch (e) {} + window._fxBoxOverlayCleanup = null; + } + var canvas = mainChartContainer.querySelector('.fx-box-vert-overlay'); + if (!canvas) { + canvas = document.createElement('canvas'); + canvas.className = 'fx-box-vert-overlay'; + canvas.style.cssText = 'position:absolute;left:0;top:0;width:100%;height:100%;pointer-events:none;z-index:6;'; + if (getComputedStyle(mainChartContainer).position === 'static') { + mainChartContainer.style.position = 'relative'; + } + mainChartContainer.appendChild(canvas); + } + var redraw = function () { + var boxes = window._fxBoxVerticals || []; + var series = getMainPriceSeries(); + var rect = mainChartContainer.getBoundingClientRect(); + var dpr = window.devicePixelRatio || 1; + canvas.width = Math.max(1, Math.floor(rect.width * dpr)); + canvas.height = Math.max(1, Math.floor(rect.height * dpr)); + canvas.style.width = rect.width + 'px'; + canvas.style.height = rect.height + 'px'; + var ctx2 = canvas.getContext('2d'); + if (!ctx2) return; + ctx2.setTransform(dpr, 0, 0, dpr, 0, 0); + ctx2.clearRect(0, 0, rect.width, rect.height); + if (!series || !boxes.length) return; + var ts = mainChart.timeScale(); + for (var i = 0; i < boxes.length; i++) { + var box = boxes[i]; + var x = ts.timeToCoordinate(box.time); + var y1 = series.priceToCoordinate(box.hi); + var y2 = series.priceToCoordinate(box.lo); + if (x == null || y1 == null || y2 == null) continue; + ctx2.beginPath(); + ctx2.strokeStyle = box.color; + ctx2.lineWidth = 1; + ctx2.setLineDash([4, 3]); + ctx2.moveTo(Math.round(x) + 0.5, y1); + ctx2.lineTo(Math.round(x) + 0.5, y2); + ctx2.stroke(); + } + ctx2.setLineDash([]); + }; + var onRange = function () { requestAnimationFrame(redraw); }; + try { mainChart.timeScale().subscribeVisibleLogicalRangeChange(onRange); } catch (e) {} + try { mainChart.timeScale().subscribeVisibleTimeRangeChange(onRange); } catch (e) {} + var ro = null; + if (typeof ResizeObserver !== 'undefined') { + ro = new ResizeObserver(onRange); + ro.observe(mainChartContainer); + } + window._fxBoxOverlayCleanup = function () { + try { mainChart.timeScale().unsubscribeVisibleLogicalRangeChange(onRange); } catch (e) {} + try { mainChart.timeScale().unsubscribeVisibleTimeRangeChange(onRange); } catch (e) {} + if (ro) try { ro.disconnect(); } catch (e) {} + try { if (canvas && canvas.parentNode) canvas.parentNode.removeChild(canvas); } catch (e) {} + }; + if (!window._tvInitCleanups) window._tvInitCleanups = []; + window._tvInitCleanups.push(window._fxBoxOverlayCleanup); + requestAnimationFrame(redraw); + setTimeout(redraw, 50); +} + +function chartTvRenderOverlays(ctx) { + window._fxBoxVerticals = []; + var symbol = ctx.symbol; + var timeframe = ctx.timeframe; + var symbolConfig = ctx.symbolConfig; + var useSubSubPeriod = ctx.useSubSubPeriod; + var useElementPeriod = ctx.useElementPeriod; + var klinePeriodLabel = ctx.klinePeriodLabel; + var candles = ctx.candles; + var klineDataSource = ctx.klineDataSource; + var container = ctx.container; + var showMacd = ctx.showMacd; + var showOriginalKline = ctx.showOriginalKline; + var mainChartContainer = ctx.mainChartContainer; + var volumeChartContainer = ctx.volumeChartContainer; + var atrChartContainer = ctx.atrChartContainer; + var macdChartContainer = ctx.macdChartContainer; + var chanMacdChartContainer = ctx.chanMacdChartContainer; + var mainChart = ctx.mainChart; + var volumeChart = ctx.volumeChart; + var atrChart = ctx.atrChart; + var macdChart = ctx.macdChart; + var chanMacdChart = ctx.chanMacdChart; + var createChartOptions = ctx.createChartOptions; + // 结构价值区绘制(半透明填充区 + 边框) + if ($('#showMainStructureZone').is(':checked') && currentData.structure_zones && currentData.structure_zones.length > 0) { + try { + const kd = currentData.kline_data || []; + if (kd.length > 0) { + const chartStart = Math.floor(new Date(kd[0].date).getTime() / 1000); + const chartEnd = Math.floor(new Date(kd[kd.length-1].date).getTime() / 1000); + // 计算可见价格范围,过滤超出范围的区间 + let priceMin = Infinity, priceMax = -Infinity; + kd.forEach(function(k) { + const hi = parseFloat(k.high), lo = parseFloat(k.low); + if (!isNaN(hi) && hi > priceMax) priceMax = hi; + if (!isNaN(lo) && lo < priceMin) priceMin = lo; + }); + const priceMargin = (priceMax - priceMin) * 0.05; + priceMin -= priceMargin; + priceMax += priceMargin; + let drawnCount = 0; + currentData.structure_zones.forEach(function(zone) { + try { + // 跳过完全超出可视价格范围的区间 + if (zone.upper < priceMin || zone.lower > priceMax) return; + const fillColor = zone.zone_type === 'support' ? 'rgba(46, 204, 113, 0.08)' : + zone.zone_type === 'resistance' ? 'rgba(231, 76, 60, 0.08)' : + 'rgba(149, 165, 166, 0.06)'; + const borderColor = zone.zone_type === 'support' ? 'rgba(46, 204, 113, 0.7)' : + zone.zone_type === 'resistance' ? 'rgba(231, 76, 60, 0.7)' : + 'rgba(149, 165, 166, 0.6)'; + // 填充区:在上下边界之间画多条半透明线模拟填充 + const fillLines = 8; + const step = (zone.upper - zone.lower) / (fillLines + 1); + for (let fi = 1; fi <= fillLines; fi++) { + const fy = zone.lower + step * fi; + mainChart.addLineSeries({ color: fillColor, lineWidth: 2, lineStyle: 0, lastValueVisible: false, priceLineVisible: false }) + .setData([{ time: chartStart, value: fy }, { time: chartEnd, value: fy }]); + } + // 上边界(粗线) + mainChart.addLineSeries({ color: borderColor, lineWidth: 2, lineStyle: 0, lastValueVisible: false, priceLineVisible: false }) + .setData([{ time: chartStart, value: zone.upper }, { time: chartEnd, value: zone.upper }]); + // 下边界(粗线) + mainChart.addLineSeries({ color: borderColor, lineWidth: 2, lineStyle: 0, lastValueVisible: false, priceLineVisible: false }) + .setData([{ time: chartStart, value: zone.lower }, { time: chartEnd, value: zone.lower }]); + // 中心线(虚线) + mainChart.addLineSeries({ color: borderColor, lineWidth: 1, lineStyle: 2, lastValueVisible: false, priceLineVisible: false }) + .setData([{ time: chartStart, value: zone.center }, { time: chartEnd, value: zone.center }]); + drawnCount++; + } catch (e) { console.error('结构区绘制出错:', e); } + }); + console.log(`结构区: 共${currentData.structure_zones.length}个, 绘制${drawnCount}个 (可见价格范围: ${priceMin.toFixed(0)}-${priceMax.toFixed(0)})`); + } + } catch (e) { console.error('结构区整体绘制出错:', e); } + } + // 区间/阶段/时间/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; + + 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]; } + } + 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 + }); + } 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' + }; + + 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); + }; + + 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('绘制未完成中枢 - 已启用'); + // 显示BI中枢绘制(沿用中枢样式) + if ($('#showMainBiZs').is(':checked') || $('#showElementBiZs').is(':checked') || $('#showSubSubBiZs').is(':checked')) { + console.log('绘制BI中枢 - 已启用'); + // 主周期 BI 中枢 + console.log('主BI开关:', $('#showMainBiZs').is(':checked'), '数据长度:', currentData.bi_zs_list ? currentData.bi_zs_list.length : 0); + if ($('#showMainBiZs').is(':checked') && currentData.bi_zs_list && currentData.bi_zs_list.length > 0) { + console.log(`绘制主周期BI中枢数据,共${currentData.bi_zs_list.length}条`); + currentData.bi_zs_list.forEach(function(zs) { + try { + const startTime = Math.floor(new Date(zs.start_time).getTime() / 1000); + const endTime = zs.end_time ? Math.floor(new Date(zs.end_time).getTime() / 1000) : Math.floor(new Date(currentData.kline_data[currentData.kline_data.length-1].date).getTime() / 1000); + if (isNaN(startTime) || isNaN(endTime)) { return; } + const zg = parseFloat(zs.zg), zd = parseFloat(zs.zd), gg = parseFloat(zs.gg), dd = parseFloat(zs.dd); + if (isNaN(zg) || isNaN(zd)) { return; } + const color = '#9C27B0'; // 主周期BI中枢颜色(紫色) + const topSeries = mainChart.addLineSeries({ color, lineWidth: 1, lastValueVisible: false, priceLineVisible: false }); + topSeries.setData([{ time: startTime, value: zg }, { time: endTime, value: zg }]); + const bottomSeries = mainChart.addLineSeries({ color, lineWidth: 1, lastValueVisible: false, priceLineVisible: false }); + bottomSeries.setData([{ time: startTime, value: zd }, { time: endTime, value: zd }]); + const leftSeries = mainChart.addLineSeries({ color, lineWidth: 1, lastValueVisible: false, priceLineVisible: false }); + leftSeries.setData([{ time: startTime, value: zd }, { time: startTime, value: zg }]); + const rightSeries = mainChart.addLineSeries({ color, lineWidth: 1, lastValueVisible: false, priceLineVisible: false }); + rightSeries.setData([{ time: endTime, value: zd }, { time: endTime, value: zg }]); + if (!isNaN(gg) && gg > 0) { + const ggSeries = mainChart.addLineSeries({ color, lineWidth: 1, lastValueVisible: false, priceLineVisible: false }); + ggSeries.setData([{ time: startTime, value: gg }, { time: endTime, value: gg }]); + } + if (!isNaN(dd) && dd > 0) { + const ddSeries = mainChart.addLineSeries({ color, lineWidth: 1, lastValueVisible: false, priceLineVisible: false }); + ddSeries.setData([{ time: startTime, value: dd }, { time: endTime, value: dd }]); + } + } catch (e) { console.error('主周期BI中枢处理出错:', e); } + }); + } + // 主周期 未完成 BI 中枢 + console.log('主未完成BI长度:', currentData.uncompleted_bi_zs_list ? currentData.uncompleted_bi_zs_list.length : 0); + if ($('#showMainBiZs').is(':checked') && currentData.uncompleted_bi_zs_list && currentData.uncompleted_bi_zs_list.length > 0) { + console.log(`绘制主周期未完成BI中枢数据,共${currentData.uncompleted_bi_zs_list.length}条`); + currentData.uncompleted_bi_zs_list.forEach(function(zs) { + try { + const startTime = Math.floor(new Date(zs.start_time).getTime() / 1000); + const endTime = Math.floor(new Date(currentData.kline_data[currentData.kline_data.length-1].date).getTime() / 1000); + if (isNaN(startTime) || isNaN(endTime)) { return; } + const zg = parseFloat(zs.zg), zd = parseFloat(zs.zd), gg = parseFloat(zs.gg), dd = parseFloat(zs.dd); + if (isNaN(zg) || isNaN(zd)) { return; } + const color = '#9C27B0'; + const topSeries = mainChart.addLineSeries({ color, lineWidth: 1, lastValueVisible: false, priceLineVisible: false }); + topSeries.setData([{ time: startTime, value: zg }, { time: endTime, value: zg }]); + const bottomSeries = mainChart.addLineSeries({ color, lineWidth: 1, lastValueVisible: false, priceLineVisible: false }); + bottomSeries.setData([{ time: startTime, value: zd }, { time: endTime, value: zd }]); + const leftSeries = mainChart.addLineSeries({ color, lineWidth: 1, lastValueVisible: false, priceLineVisible: false }); + leftSeries.setData([{ time: startTime, value: zd }, { time: startTime, value: zg }]); + if (!isNaN(gg) && gg > 0) { + const ggSeries = mainChart.addLineSeries({ color, lineWidth: 1, lastValueVisible: false, priceLineVisible: false }); + ggSeries.setData([{ time: startTime, value: gg }, { time: endTime, value: gg }]); + } + if (!isNaN(dd) && dd > 0) { + const ddSeries = mainChart.addLineSeries({ color, lineWidth: 1, lastValueVisible: false, priceLineVisible: false }); + ddSeries.setData([{ time: startTime, value: dd }, { time: endTime, value: dd }]); + } + } catch (e) { console.error('主周期未完成BI中枢处理出错:', e); } + }); + } + // 次周期 BI 中枢 + console.log('次BI开关:', $('#showElementBiZs').is(':checked'), '数据长度:', currentData.element_bi_zs_list ? currentData.element_bi_zs_list.length : 0); + if ($('#showElementBiZs').is(':checked') && currentData.element_bi_zs_list && currentData.element_bi_zs_list.length > 0) { + console.log(`绘制次周期BI中枢数据,共${currentData.element_bi_zs_list.length}条`); + currentData.element_bi_zs_list.forEach(function(zs) { + try { + const startTime = Math.floor(new Date(zs.start_time).getTime() / 1000); + const endTime = zs.end_time ? Math.floor(new Date(zs.end_time).getTime() / 1000) : Math.floor(new Date(currentData.kline_data[currentData.kline_data.length-1].date).getTime() / 1000); + if (isNaN(startTime) || isNaN(endTime)) { return; } + const zg = parseFloat(zs.zg), zd = parseFloat(zs.zd), gg = parseFloat(zs.gg), dd = parseFloat(zs.dd); + if (isNaN(zg) || isNaN(zd)) { return; } + const color = '#8BC34A'; // 次周期BI中枢颜色(绿) + const topSeries = mainChart.addLineSeries({ color, lineWidth: 1, lastValueVisible: false, priceLineVisible: false }); + topSeries.setData([{ time: startTime, value: zg }, { time: endTime, value: zg }]); + const bottomSeries = mainChart.addLineSeries({ color, lineWidth: 1, lastValueVisible: false, priceLineVisible: false }); + bottomSeries.setData([{ time: startTime, value: zd }, { time: endTime, value: zd }]); + const leftSeries = mainChart.addLineSeries({ color, lineWidth: 1, lastValueVisible: false, priceLineVisible: false }); + leftSeries.setData([{ time: startTime, value: zd }, { time: startTime, value: zg }]); + const rightSeries = mainChart.addLineSeries({ color, lineWidth: 1, lastValueVisible: false, priceLineVisible: false }); + rightSeries.setData([{ time: endTime, value: zd }, { time: endTime, value: zg }]); + if (!isNaN(gg) && gg > 0) { + const ggSeries = mainChart.addLineSeries({ color, lineWidth: 1, lastValueVisible: false, priceLineVisible: false }); + ggSeries.setData([{ time: startTime, value: gg }, { time: endTime, value: gg }]); + } + if (!isNaN(dd) && dd > 0) { + const ddSeries = mainChart.addLineSeries({ color, lineWidth: 1, lastValueVisible: false, priceLineVisible: false }); + ddSeries.setData([{ time: startTime, value: dd }, { time: endTime, value: dd }]); + } + } catch (e) { console.error('次周期BI中枢处理出错:', e); } + }); + } + // 次周期 未完成 BI 中枢 + console.log('次未完成BI长度:', currentData.element_uncompleted_bi_zs_list ? currentData.element_uncompleted_bi_zs_list.length : 0); + if ($('#showElementBiZs').is(':checked') && currentData.element_uncompleted_bi_zs_list && currentData.element_uncompleted_bi_zs_list.length > 0) { + console.log(`绘制次周期未完成BI中枢数据,共${currentData.element_uncompleted_bi_zs_list.length}条`); + currentData.element_uncompleted_bi_zs_list.forEach(function(zs) { + try { + const startTime = Math.floor(new Date(zs.start_time).getTime() / 1000); + const endTime = Math.floor(new Date(currentData.kline_data[currentData.kline_data.length-1].date).getTime() / 1000); + if (isNaN(startTime) || isNaN(endTime)) { return; } + const zg = parseFloat(zs.zg), zd = parseFloat(zs.zd), gg = parseFloat(zs.gg), dd = parseFloat(zs.dd); + if (isNaN(zg) || isNaN(zd)) { return; } + const color = '#8BC34A'; + const topSeries = mainChart.addLineSeries({ color, lineWidth: 1, lastValueVisible: false, priceLineVisible: false }); + topSeries.setData([{ time: startTime, value: zg }, { time: endTime, value: zg }]); + const bottomSeries = mainChart.addLineSeries({ color, lineWidth: 1, lastValueVisible: false, priceLineVisible: false }); + bottomSeries.setData([{ time: startTime, value: zd }, { time: endTime, value: zd }]); + const leftSeries = mainChart.addLineSeries({ color, lineWidth: 1, lastValueVisible: false, priceLineVisible: false }); + leftSeries.setData([{ time: startTime, value: zd }, { time: startTime, value: zg }]); + if (!isNaN(gg) && gg > 0) { + const ggSeries = mainChart.addLineSeries({ color, lineWidth: 1, lastValueVisible: false, priceLineVisible: false }); + ggSeries.setData([{ time: startTime, value: gg }, { time: endTime, value: gg }]); + } + if (!isNaN(dd) && dd > 0) { + const ddSeries = mainChart.addLineSeries({ color, lineWidth: 1, lastValueVisible: false, priceLineVisible: false }); + ddSeries.setData([{ time: startTime, value: dd }, { time: endTime, value: dd }]); + } + } catch (e) { console.error('次周期未完成BI中枢处理出错:', e); } + }); + } + // 次次周期 未完成 BI 中枢 + if ($('#showSubSubBiZs').is(':checked') && currentData.sub_sub_uncompleted_bi_zs_list && currentData.sub_sub_uncompleted_bi_zs_list.length > 0) { + const kdBi = currentData.kline_data || []; + const endTimeBi = kdBi.length ? Math.floor(new Date(kdBi[kdBi.length-1].date).getTime() / 1000) : 0; + currentData.sub_sub_uncompleted_bi_zs_list.forEach(function(zs) { + try { + const startTime = Math.floor(new Date(zs.start_time).getTime() / 1000); + if (isNaN(startTime) || !endTimeBi) return; + const zg = parseFloat(zs.zg), zd = parseFloat(zs.zd), gg = parseFloat(zs.gg), dd = parseFloat(zs.dd); + if (isNaN(zg) || isNaN(zd)) return; + const color = '#00897b'; + mainChart.addLineSeries({ color, lineWidth: 1, lastValueVisible: false, priceLineVisible: false }).setData([{ time: startTime, value: zg }, { time: endTimeBi, value: zg }]); + mainChart.addLineSeries({ color, lineWidth: 1, lastValueVisible: false, priceLineVisible: false }).setData([{ time: startTime, value: zd }, { time: endTimeBi, value: zd }]); + mainChart.addLineSeries({ color, lineWidth: 1, lastValueVisible: false, priceLineVisible: false }).setData([{ time: startTime, value: zd }, { time: startTime, value: zg }]); + if (!isNaN(gg) && gg > 0) mainChart.addLineSeries({ color, lineWidth: 1, lastValueVisible: false, priceLineVisible: false }).setData([{ time: startTime, value: gg }, { time: endTimeBi, value: gg }]); + if (!isNaN(dd) && dd > 0) mainChart.addLineSeries({ color, lineWidth: 1, lastValueVisible: false, priceLineVisible: false }).setData([{ time: startTime, value: dd }, { time: endTimeBi, value: dd }]); + } catch (e) { console.error('次次周期未完成BI中枢处理出错:', e); } + }); + } + } + // 主周期未完成中枢 + if ($('#showMainZs').is(':checked') && currentData.uncompleted_zs_list && currentData.uncompleted_zs_list.length > 0) { + console.log(`绘制主周期未完成中枢数据,共${currentData.uncompleted_zs_list.length}条`); + + currentData.uncompleted_zs_list.forEach(function(zs) { + try { + // 直接使用UTC时间戳(秒) + const startTime = Math.floor(new Date(zs.start_time).getTime() / 1000); + // 未完成中枢的结束时间设为当前K线的最后时间 + const endTime = Math.floor(new Date(currentData.kline_data[currentData.kline_data.length-1].date).getTime() / 1000); + + if (isNaN(startTime) || isNaN(endTime)) { + console.error('主周期未完成中枢时间转换错误:', zs.start_time); + return; + } + + const zg = parseFloat(zs.zg); // 中枢上沿 + const zd = parseFloat(zs.zd); // 中枢下沿 + const gg = parseFloat(zs.gg); // 中枢高高 + const dd = parseFloat(zs.dd); // 中枢低低 + + if (isNaN(zg) || isNaN(zd)) { + console.error('主周期未完成中枢价格转换错误:', zs.zg, zs.zd); + return; + } + + // 创建未完成中枢上边界 + const topSeries = mainChart.addLineSeries({ + color: '#F1C40F', // 主周期中枢颜色 + lineWidth: 1, + lastValueVisible: false, + priceLineVisible: false, + }); + + topSeries.setData([ + { time: startTime, value: zg }, + { time: endTime, value: zg } + ]); + + // 为下边界创建另一条线 + const bottomSeries = mainChart.addLineSeries({ + color: '#F1C40F', // 主周期中枢颜色 + lineWidth: 1, + lastValueVisible: false, + priceLineVisible: false, + }); + + bottomSeries.setData([ + { time: startTime, value: zd }, + { time: endTime, value: zd } + ]); + + // 添加左边界 + const leftSeries = mainChart.addLineSeries({ + color: '#F1C40F', // 主周期中枢颜色 + lineWidth: 1, + lastValueVisible: false, + priceLineVisible: false, + }); + + leftSeries.setData([ + { time: startTime, value: zd }, + { time: startTime, value: zg } + ]); + + // 添加一个标记,标识这是未完成中枢 + const markerSeries = mainChart.addLineSeries({ + lastValueVisible: false, + priceLineVisible: false, + }); + + markerSeries.setMarkers([ + { + time: startTime, + position: 'aboveBar', + color: '#F1C40F', + shape: 'circle', + text: '未完', + size: 1 + } + ]); + + // 绘制gg线(中枢高高) + if (!isNaN(gg) && gg > 0) { + const ggSeries = mainChart.addLineSeries({ + color: '#F1C40F', // 使用中枢自己的颜色 + lineWidth: 1, + lastValueVisible: false, + priceLineVisible: false, + }); + + ggSeries.setData([ + { time: startTime, value: gg }, + { time: endTime, value: gg } + ]); + } + + // 绘制dd线(中枢低低) + if (!isNaN(dd) && dd > 0) { + const ddSeries = mainChart.addLineSeries({ + color: '#F1C40F', // 使用中枢自己的颜色 + lineWidth: 1, + lastValueVisible: false, + priceLineVisible: false, + }); + + ddSeries.setData([ + { time: startTime, value: dd }, + { time: endTime, value: dd } + ]); + } + + // 添加到图表对象 + tvWidget.series.mainUncompletedZsSeries.push({ + time: startTime, + value: zg, + color: '#F1C40F', + lineWidth: 1 + }); + tvWidget.series.mainUncompletedZsSeries.push({ + time: endTime, + value: zg, + color: '#F1C40F', + lineWidth: 1 + }); + tvWidget.series.mainUncompletedZsSeries.push({ + time: startTime, + value: zd, + color: '#F1C40F', + lineWidth: 1 + }); + tvWidget.series.mainUncompletedZsSeries.push({ + time: endTime, + value: zd, + color: '#F1C40F', + lineWidth: 1 + }); + } catch (e) { + console.error('主周期未完成中枢处理出错:', e); + } + }); + } + + // 次周期未完成中枢 + if ($('#showElementZs').is(':checked') && currentData.element_uncompleted_zs_list && currentData.element_uncompleted_zs_list.length > 0) { + console.log(`绘制次周期未完成中枢数据,共${currentData.element_uncompleted_zs_list.length}条`); + + currentData.element_uncompleted_zs_list.forEach(function(zs) { + try { + // 直接使用UTC时间戳(秒) + const startTime = Math.floor(new Date(zs.start_time).getTime() / 1000); + // 未完成中枢的结束时间设为当前K线的最后时间 + const endTime = Math.floor(new Date(currentData.kline_data[currentData.kline_data.length-1].date).getTime() / 1000); + + if (isNaN(startTime) || isNaN(endTime)) { + console.error('次周期未完成中枢时间转换错误:', zs.start_time); + return; + } + + const zg = parseFloat(zs.zg); // 中枢上沿 + const zd = parseFloat(zs.zd); // 中枢下沿 + const gg = parseFloat(zs.gg); // 中枢高高 + const dd = parseFloat(zs.dd); // 中枢低低 + + if (isNaN(zg) || isNaN(zd)) { + console.error('次周期未完成中枢价格转换错误:', zs.zg, zs.zd); + return; + } + + // 创建未完成中枢上边界 + const topSeries = mainChart.addLineSeries({ + color: '#3f51b5', // 次周期中枢颜色 + lineWidth: 1, + lastValueVisible: false, + priceLineVisible: false, + }); + + topSeries.setData([ + { time: startTime, value: zg }, + { time: endTime, value: zg } + ]); + + // 为下边界创建另一条线 + const bottomSeries = mainChart.addLineSeries({ + color: '#3f51b5', // 次周期中枢颜色 + lineWidth: 1, + lastValueVisible: false, + priceLineVisible: false, + }); + + bottomSeries.setData([ + { time: startTime, value: zd }, + { time: endTime, value: zd } + ]); + + // 添加左边界 + const leftSeries = mainChart.addLineSeries({ + color: '#3f51b5', // 次周期中枢颜色 + lineWidth: 1, + lastValueVisible: false, + priceLineVisible: false, + }); + + leftSeries.setData([ + { time: startTime, value: zd }, + { time: startTime, value: zg } + ]); + + // 添加一个标记,标识这是未完成中枢 + const markerSeries = mainChart.addLineSeries({ + lastValueVisible: false, + priceLineVisible: false, + }); + + markerSeries.setMarkers([ + { + time: startTime, + position: 'aboveBar', + color: '#3f51b5', + shape: 'circle', + text: '未完', + size: 1 + } + ]); + + // 绘制gg线(中枢高高) + if (!isNaN(gg) && gg > 0) { + const ggSeries = mainChart.addLineSeries({ + color: '#3f51b5', // 使用次周期中枢自己的颜色 + lineWidth: 1, + lastValueVisible: false, + priceLineVisible: false, + }); + + ggSeries.setData([ + { time: startTime, value: gg }, + { time: endTime, value: gg } + ]); + } + + // 绘制dd线(中枢低低) + if (!isNaN(dd) && dd > 0) { + const ddSeries = mainChart.addLineSeries({ + color: '#3f51b5', // 使用次周期中枢自己的颜色 + lineWidth: 1, + lastValueVisible: false, + priceLineVisible: false, + }); + + ddSeries.setData([ + { time: startTime, value: dd }, + { time: endTime, value: dd } + ]); + } + + // 添加到图表对象 + tvWidget.series.elementUncompletedZsSeries.push({ + time: startTime, + value: zg, + color: '#3f51b5', + lineWidth: 1 + }); + tvWidget.series.elementUncompletedZsSeries.push({ + time: endTime, + value: zg, + color: '#3f51b5', + lineWidth: 1 + }); + tvWidget.series.elementUncompletedZsSeries.push({ + time: startTime, + value: zd, + color: '#3f51b5', + lineWidth: 1 + }); + tvWidget.series.elementUncompletedZsSeries.push({ + time: endTime, + value: zd, + color: '#3f51b5', + lineWidth: 1 + }); + } catch (e) { + console.error('次周期未完成中枢处理出错:', e); + } + }); + } + // 次次周期未完成SEG中枢 + if ($('#showSubSubZs').is(':checked') && currentData.sub_sub_uncompleted_zs_list && currentData.sub_sub_uncompleted_zs_list.length > 0) { + const kdZs = currentData.kline_data || []; + const endTimeZs = kdZs.length ? Math.floor(new Date(kdZs[kdZs.length-1].date).getTime() / 1000) : 0; + const subSubUZsColor = '#00897b'; + currentData.sub_sub_uncompleted_zs_list.forEach(function(zs) { + try { + const startTime = Math.floor(new Date(zs.start_time).getTime() / 1000); + if (isNaN(startTime) || !endTimeZs) return; + const zg = parseFloat(zs.zg); const zd = parseFloat(zs.zd); const gg = parseFloat(zs.gg); const dd = parseFloat(zs.dd); + if (isNaN(zg) || isNaN(zd)) return; + mainChart.addLineSeries({ color: subSubUZsColor, lineWidth: 1, lastValueVisible: false, priceLineVisible: false }).setData([{ time: startTime, value: zg }, { time: endTimeZs, value: zg }]); + mainChart.addLineSeries({ color: subSubUZsColor, lineWidth: 1, lastValueVisible: false, priceLineVisible: false }).setData([{ time: startTime, value: zd }, { time: endTimeZs, value: zd }]); + mainChart.addLineSeries({ color: subSubUZsColor, lineWidth: 1, lastValueVisible: false, priceLineVisible: false }).setData([{ time: startTime, value: zd }, { time: startTime, value: zg }]); + if (!isNaN(gg) && gg > 0) mainChart.addLineSeries({ color: subSubUZsColor, lineWidth: 1, lastValueVisible: false, priceLineVisible: false }).setData([{ time: startTime, value: gg }, { time: endTimeZs, value: gg }]); + if (!isNaN(dd) && dd > 0) mainChart.addLineSeries({ color: subSubUZsColor, lineWidth: 1, lastValueVisible: false, priceLineVisible: false }).setData([{ time: startTime, value: dd }, { time: endTimeZs, value: dd }]); + } catch (e) { console.error('次次周期未完成中枢处理出错:', e); } + }); + } + } else { + console.log('绘制未完成中枢 - 已禁用'); + } + // 未完成BI中枢 - 使用独立的BI开关 + if ($('#showMainBiZs').is(':checked') && currentData.uncompleted_bi_zs_list && currentData.uncompleted_bi_zs_list.length > 0) { + try { console.log(`绘制主周期未完成BI中枢数据,共${currentData.uncompleted_bi_zs_list.length}条`); } catch (e) {} + currentData.uncompleted_bi_zs_list.forEach(function(zs) { + try { + const startTime = Math.floor(new Date(zs.start_time).getTime() / 1000); + const endTime = Math.floor(new Date(currentData.kline_data[currentData.kline_data.length-1].date).getTime() / 1000); + if (isNaN(startTime) || isNaN(endTime)) { return; } + const zg = parseFloat(zs.zg); const zd = parseFloat(zs.zd); const gg = parseFloat(zs.gg); const dd = parseFloat(zs.dd); + if (isNaN(zg) || isNaN(zd)) { return; } + const color = '#F1C40F'; + const topSeries = mainChart.addLineSeries({ color, lineWidth: 1, lastValueVisible: false, priceLineVisible: false }); + topSeries.setData([{ time: startTime, value: zg }, { time: endTime, value: zg }]); + const bottomSeries = mainChart.addLineSeries({ color, lineWidth: 1, lastValueVisible: false, priceLineVisible: false }); + bottomSeries.setData([{ time: startTime, value: zd }, { time: endTime, value: zd }]); + const leftSeries = mainChart.addLineSeries({ color, lineWidth: 1, lastValueVisible: false, priceLineVisible: false }); + leftSeries.setData([{ time: startTime, value: zd }, { time: startTime, value: zg }]); + if (!isNaN(gg) && gg > 0) { + const ggSeries = mainChart.addLineSeries({ color, lineWidth: 1, lastValueVisible: false, priceLineVisible: false }); + ggSeries.setData([{ time: startTime, value: gg }, { time: endTime, value: gg }]); + } + if (!isNaN(dd) && dd > 0) { + const ddSeries = mainChart.addLineSeries({ color, lineWidth: 1, lastValueVisible: false, priceLineVisible: false }); + ddSeries.setData([{ time: startTime, value: dd }, { time: endTime, value: dd }]); + } + } catch (e) { console.error('主周期未完成BI中枢处理出错:', e); } + }); + } + // 次周期 未完成 BI 中枢 + if ($('#showElementBiZs').is(':checked') && currentData.element_uncompleted_bi_zs_list && currentData.element_uncompleted_bi_zs_list.length > 0) { + try { console.log(`绘制次周期未完成BI中枢数据,共${currentData.element_uncompleted_bi_zs_list.length}条`); } catch (e) {} + currentData.element_uncompleted_bi_zs_list.forEach(function(zs) { + try { + const startTime = Math.floor(new Date(zs.start_time).getTime() / 1000); + const endTime = Math.floor(new Date(currentData.kline_data[currentData.kline_data.length-1].date).getTime() / 1000); + if (isNaN(startTime) || isNaN(endTime)) { return; } + const zg = parseFloat(zs.zg); const zd = parseFloat(zs.zd); const gg = parseFloat(zs.gg); const dd = parseFloat(zs.dd); + if (isNaN(zg) || isNaN(zd)) { return; } + const color = '#3f51b5'; + const topSeries = mainChart.addLineSeries({ color, lineWidth: 1, lastValueVisible: false, priceLineVisible: false }); + topSeries.setData([{ time: startTime, value: zg }, { time: endTime, value: zg }]); + const bottomSeries = mainChart.addLineSeries({ color, lineWidth: 1, lastValueVisible: false, priceLineVisible: false }); + bottomSeries.setData([{ time: startTime, value: zd }, { time: endTime, value: zd }]); + const leftSeries = mainChart.addLineSeries({ color, lineWidth: 1, lastValueVisible: false, priceLineVisible: false }); + leftSeries.setData([{ time: startTime, value: zd }, { time: startTime, value: zg }]); + if (!isNaN(gg) && gg > 0) { + const ggSeries = mainChart.addLineSeries({ color, lineWidth: 1, lastValueVisible: false, priceLineVisible: false }); + ggSeries.setData([{ time: startTime, value: gg }, { time: endTime, value: gg }]); + } + if (!isNaN(dd) && dd > 0) { + const ddSeries = mainChart.addLineSeries({ color, lineWidth: 1, lastValueVisible: false, priceLineVisible: false }); + ddSeries.setData([{ time: startTime, value: dd }, { time: endTime, value: dd }]); + } + } catch (e) { console.error('次周期未完成BI中枢处理出错:', e); } + }); + } + // 添加买卖点标记(新版:基于 bsp_list / element_bsp_list / sub_sub_bsp_list,按与 KLC 分型相同方式合并到主图标记) + if ($('#showMainBsp').is(':checked') || $('#showElementBsp').is(':checked') || $('#showSubSubBsp').is(':checked')) { + console.log('绘制买卖点(BSP) - 已启用'); + + // BSP 样式定义 + const BSP_STYLE = { + 'BSP1_BUY': { color: '#FF1744', text: 'B1', position: 'belowBar', size: 0.5 }, + 'BSP2_BUY': { color: '#F50057', text: 'B2', position: 'belowBar', size: 0.5 }, + 'BSP3_BUY': { color: '#D500F9', text: 'B3', position: 'belowBar', size: 0.5 }, + 'BSP1_SELL': { color: '#00E676', text: 'S1', position: 'aboveBar', size: 0.5 }, + 'BSP2_SELL': { color: '#00B0FF', text: 'S2', position: 'aboveBar', size: 0.5 }, + 'BSP3_SELL': { color: '#8B4513', text: 'S3', position: 'aboveBar', size: 0.5 }, + }; + + const getBspStyleKey = (bsp) => { + // 统一 BSP key: + // - type 可能是 "BSP1"/"BSP2"/"BSP3", + // - 也可能是后端给的 "B1"/"B2"/"B3" 或 "S1"/"S2"/"S3" + // 最终都映射为 "BSP1_BUY" / "BSP1_SELL" 这类 key,方便复用现有样式定义 + let type = (bsp.type || '').toUpperCase(); + const dir = (bsp.dir || '').toUpperCase(); + + // 若是 "B1" / "B2" / "B3" 或 "S1" / "S2" / "S3" 形式,则提取数字并映射成 "BSP{n}" + const simpleMatch = type.match(/^([BS])(\d)$/); + if (simpleMatch) { + const n = simpleMatch[2]; // "1" / "2" / "3" + type = 'BSP' + n; + } + + return type + '_' + dir; + }; + + // 收集所有 BSP 标记 + const allBspMarkers = []; + + // 主周期买卖点 + // 兼容不同字段命名:优先使用 bsp_list,若不存在则尝试 bsp + const mainBspList = currentData.bsp_list || currentData.bsp || []; + // 调试:打印前几条主周期 BSP 的 key,方便排查样式不匹配问题 + if (mainBspList.length > 0) { + console.log( + '主周期 BSP 示例 (前5条):', + mainBspList.slice(0, 5).map(b => ({ + raw_type: b.type, + raw_dir: b.dir, + key: getBspStyleKey(b) + })) + ); + } + if ($('#showMainBsp').is(':checked') && mainBspList.length > 0) { + console.log(`绘制主周期买卖点,共${mainBspList.length}条`); + mainBspList.forEach(function(bsp) { + try { + const ts = Math.floor(new Date(bsp.time).getTime() / 1000); + if (isNaN(ts)) return; + const key = getBspStyleKey(bsp); + const style = BSP_STYLE[key] || { color: '#999', shape: 'circle', text: '?', position: 'inBar' }; + const sureText = bsp.is_sure ? '' : '?'; + allBspMarkers.push({ + time: ts, + position: style.position, + color: style.color, + shape: style.shape, + text: style.text + sureText, + size: 2 + }); + } catch (e) { + console.error('主周期BSP处理出错:', e); + } + }); + } + + // 次周期买卖点 + // 兼容不同字段命名:优先使用 element_bsp_list,若不存在则尝试 element_bsp + const elementBspList = currentData.element_bsp_list || currentData.element_bsp || []; + // 调试:打印前几条次周期 BSP 的 key + if (elementBspList.length > 0) { + console.log( + '次周期 BSP 示例 (前5条):', + elementBspList.slice(0, 5).map(b => ({ + raw_type: b.type, + raw_dir: b.dir, + key: getBspStyleKey(b) + })) + ); + } + if ($('#showElementBsp').is(':checked') && elementBspList.length > 0) { + console.log(`绘制次周期买卖点,共${elementBspList.length}条`); + elementBspList.forEach(function(bsp) { + try { + const ts = Math.floor(new Date(bsp.time).getTime() / 1000); + if (isNaN(ts)) return; + const key = getBspStyleKey(bsp); + const style = BSP_STYLE[key] || { color: '#999', shape: 'circle', text: '?', position: 'inBar' }; + const sureText = bsp.is_sure ? '' : '?'; + // 次周期使用稍小的标记和不同前缀以区分 + allBspMarkers.push({ + time: ts, + position: style.position, + color: style.color, + shape: style.shape, + text: 'e' + style.text + sureText, + size: 1 + }); + } catch (e) { + console.error('次周期BSP处理出错:', e); + } + }); + } + + // 次次周期买卖点 + const subSubBspList = currentData.sub_sub_bsp_list || []; + if ($('#showSubSubBsp').is(':checked') && subSubBspList.length > 0) { + subSubBspList.forEach(function(bsp) { + try { + const ts = Math.floor(new Date(bsp.time).getTime() / 1000); + if (isNaN(ts)) return; + const key = getBspStyleKey(bsp); + const style = BSP_STYLE[key] || { color: '#999', shape: 'circle', text: '?', position: 'inBar' }; + const sureText = bsp.is_sure ? '' : '?'; + allBspMarkers.push({ + time: ts, + position: style.position, + color: '#00897b', + shape: style.shape, + text: 's' + (style.text || '?') + sureText, + size: 1 + }); + } catch (e) { + console.error('次次周期BSP处理出错:', e); + } + }); + } + + // 将 BSP 标记挂到全局,后面与 KLC 分型等标记一起合并到主系列上 + if (allBspMarkers.length > 0) { + // 按时间排序(lightweight-charts 要求标记按时间升序) + allBspMarkers.sort((a, b) => a.time - b.time); + window.bspMarkers = allBspMarkers; + console.log(`准备合并 ${allBspMarkers.length} 个BSP标记到主图标记中`); + } else { + window.bspMarkers = []; + } + } else { + // 关闭 BSP 显示时,清空全局 BSP 标记 + window.bspMarkers = []; + } + + // 添加买卖点标记(旧版,保留兼容) + // 这里为了与主面板上的「买卖点」开关保持一致, + // 同时响应顶部的 `#showMainBsp` 复选框 + if ($('#showTradePoints').is(':checked') || $('#showMainBsp').is(':checked')) { + console.log('绘制买卖点 - 已启用(来源: showTradePoints / showMainBsp)'); + + // 优先使用小周期数据,如果不存在则使用主周期数据 + const tradePointsData = currentData.element_trade_points || currentData.trade_points; + console.log(`绘制${currentData.element_trade_points ? '元素周期' : '主周期'}买卖点数据,共${tradePointsData ? tradePointsData.length : 0}条`); + + // 调试信息 - 输出完整的买卖点数据 + if (tradePointsData && tradePointsData.length > 0) { + console.log("买卖点数据样例:", tradePointsData[0]); + + // 检查数据格式,如果time不是标准格式,进行格式化处理 + const checkDataFormat = () => { + for (let i = 0; i < tradePointsData.length; i++) { + if (tradePointsData[i].time) { + // 确保时间是标准格式 + try { + const timeValue = new Date(tradePointsData[i].time); + if (isNaN(timeValue.getTime())) { + console.error(`买卖点 #${i} 时间格式无效:`, tradePointsData[i].time); + } + } catch (e) { + console.error(`买卖点 #${i} 时间格式异常:`, e); + } + } else { + console.error(`买卖点 #${i} 缺少时间属性`); + } + } + }; + + // 执行格式检查 + checkDataFormat(); + + // 对买卖点按时间排序,用于后续优化显示 + const sortedPoints = [...tradePointsData].sort((a, b) => { + return new Date(a.time) - new Date(b.time); + }); + + // 记录已处理的时间点 - 按类型分开计数 + const processedTimes = {}; + + // 创建买卖点标记系列 + const buyMarkers = []; + const sellMarkers = []; + + // 计数器,追踪成功和失败的处理次数 + let successCount = 0; + let errorCount = 0; + + sortedPoints.forEach(function(point, index) { + try { + // 检查所有必要的属性是否存在且有效 + if (!point.time || !point.price || point.type === undefined) { + console.error(`买卖点 #${index} 数据不完整:`, point); + errorCount++; + return; + } + + const time = Math.floor(new Date(point.time).getTime() / 1000); + const price = parseFloat(point.price); + const type = parseInt(point.type); + + if (isNaN(time) || isNaN(price) || isNaN(type)) { + console.error(`买卖点 #${index} 数据格式错误:`, + { time: isNaN(time), price: isNaN(price), type: isNaN(type) }, point); + errorCount++; + return; + } + + // 获取买卖点样式 + const style = TRADE_POINT_STYLE[type] || { + color: '#999999', + shape: 'circle', + text: '?', + size: 1 + }; + + // 初始化该时间点的类型计数器 + if (!processedTimes[time]) { + processedTimes[time] = {}; + } + + // 优化:检查是否有相同时间点和相同类型的标记,如果有,进行类型内的偏移 + let stackIndex = 0; + if (processedTimes[time][type]) { + // 已经有相同时间和类型的标记,记录堆叠索引 + stackIndex = processedTimes[time][type]; + processedTimes[time][type]++; + } else { + // 第一次出现这个时间点的这个类型 + processedTimes[time][type] = 1; + } + + // 为不同类型的买卖点获取基础垂直偏移系数 + const baseOffset = TRADE_POINT_OFFSET[type] || 0; + + // 创建标记对象,包含额外的信息用于悬停提示 + const marker = { + time: time, + position: 'inBar', // 改为在K线内部显示,不影响数据 + color: style.color, + shape: style.shape, + text: style.text, + size: style.size, + // 记录堆叠索引 + stackIndex: stackIndex, + // 添加悬停提示的数据 + tooltip: `${point.desc || (type > 0 ? '买点' : '卖点')}
+ 时间: ${formatTime(point.time)}
+ 价格: ${price.toFixed(2)}`, + // 额外添加基础类型偏移 + baseOffset: baseOffset, + // 添加边框 + borderColor: 'white', + borderWidth: 1, + // 添加价格偏移系数 + pricePercentOffset: PRICE_PERCENT_OFFSET[type] || 0, + // 保存实际价格用于计算 + price: price, + // 保存类型 + type: type + }; + + // 区分买卖点 + if (type > 0) { + buyMarkers.push(marker); + } else { + sellMarkers.push(marker); + } + + successCount++; + } catch (e) { + console.error(`处理买卖点 #${index} 出错:`, e, point); + errorCount++; + } + }); + + console.log(`买卖点处理完成: 成功=${successCount}, 失败=${errorCount}, 买点=${buyMarkers.length}, 卖点=${sellMarkers.length}`); + + // 分别添加买卖点标记 + if (buyMarkers.length > 0) { + const buyMarkersSeries = mainChart.addLineSeries({ + lastValueVisible: false, + priceLineVisible: false, + lineVisible: false, + color: 'transparent', + title: '买点' + }); + + // 使用主K线的收盘价作为基准数据,保证买点标记与价格在同一纵轴范围 + if (Array.isArray(candles) && candles.length > 0) { + const baseData = candles.map(c => ({ time: c.time, value: c.close })); + buyMarkersSeries.setData(baseData); + } else { + // 兜底:至少一个数据点,避免报错 + buyMarkersSeries.setData([{ time: buyMarkers[0].time, value: buyMarkers[0].price || 0 }]); + } + + try { + // 设置买点标记:文字在价格上方,仅显示文字不显示形状 + buyMarkersSeries.setMarkers( + buyMarkers.map(marker => { + // 使用实际价格位置,买点显示在K线上方 + return { + ...marker, + position: 'aboveBar', // 买点:价格上方 + price: marker.price, + // 隐藏形状,仅保留文字 + size: 0, + color: 'rgba(0, 0, 0, 0)' + }; + }) + ); + console.log(`成功添加 ${buyMarkers.length} 个买点标记`); + } catch (e) { + console.error("设置买点标记时出错:", e); + } + } + + if (sellMarkers.length > 0) { + const sellMarkersSeries = mainChart.addLineSeries({ + lastValueVisible: false, + priceLineVisible: false, + lineVisible: false, + color: 'transparent', + title: '卖点' + }); + + // 使用主K线的收盘价作为基准数据,保证卖点标记与价格在同一纵轴范围 + if (Array.isArray(candles) && candles.length > 0) { + const baseData = candles.map(c => ({ time: c.time, value: c.close })); + sellMarkersSeries.setData(baseData); + } else { + // 兜底:至少一个数据点,避免报错 + sellMarkersSeries.setData([{ time: sellMarkers[0].time, value: sellMarkers[0].price || 0 }]); + } + + try { + // 设置卖点标记:文字在价格下方,仅显示文字不显示形状 + sellMarkersSeries.setMarkers( + sellMarkers.map(marker => { + // 使用实际价格位置,卖点显示在K线下方 + return { + ...marker, + position: 'belowBar', // 卖点:价格下方 + price: marker.price, + // 隐藏形状,仅保留文字 + size: 0, + color: 'rgba(0, 0, 0, 0)' + }; + }) + ); + console.log(`成功添加 ${sellMarkers.length} 个卖点标记`); + } catch (e) { + console.error("设置卖点标记时出错:", e); + } + } + + // 添加鼠标悬停事件显示提示 + mainChart.subscribeCrosshairMove(param => { + // 十字线同步到其他图表 - 通过DOM元素绘制垂直线实现虚线延长效果 + if (param.time && param.point && volumeChart) { + try { + // 清除之前的十字线标记 + const existingVolumeLines = document.querySelectorAll('.volume-crosshair-line'); + existingVolumeLines.forEach(line => line.remove()); + const existingAtrLines = document.querySelectorAll('.atr-crosshair-line'); + existingAtrLines.forEach(line => line.remove()); + const existingMacdLines = document.querySelectorAll('.macd-crosshair-line'); + existingMacdLines.forEach(line => line.remove()); + const existingChanMacdLines = document.querySelectorAll('.chanmacd-crosshair-line'); + existingChanMacdLines.forEach(line => line.remove()); + + // 获取时间对应的坐标位置 + const mainTimeCoordinate = mainChart.timeScale().timeToCoordinate(param.time); + if (mainTimeCoordinate !== null) { + // 获取主图容器的位置 + const mainChartRect = mainChartContainer.getBoundingClientRect(); + + // 在交易量图上绘制垂直线 + const volumeTimeCoordinate = volumeChart.timeScale().timeToCoordinate(param.time); + if (volumeTimeCoordinate !== null) { + const volumeChartRect = volumeChartContainer.getBoundingClientRect(); + const volumeLine = document.createElement('div'); + volumeLine.className = 'volume-crosshair-line'; + volumeLine.style.position = 'fixed'; // 改为fixed定位 + volumeLine.style.left = (volumeChartRect.left + volumeTimeCoordinate) + 'px'; + volumeLine.style.top = volumeChartRect.top + 'px'; + volumeLine.style.width = '1px'; + volumeLine.style.height = volumeChartRect.height + 'px'; + volumeLine.style.backgroundColor = 'rgba(128, 128, 128, 0.5)'; + volumeLine.style.borderLeft = '1px dashed rgba(128, 128, 128, 0.5)'; + volumeLine.style.pointerEvents = 'none'; + volumeLine.style.zIndex = '1000'; + document.body.appendChild(volumeLine); + } + + // 在ATR图上绘制垂直线 + if (atrChart && atrChartContainer) { + const atrTimeCoordinate = atrChart.timeScale().timeToCoordinate(param.time); + if (atrTimeCoordinate !== null) { + const atrChartRect = atrChartContainer.getBoundingClientRect(); + const atrLine = document.createElement('div'); + atrLine.className = 'atr-crosshair-line'; + atrLine.style.position = 'fixed'; // 改为fixed定位 + atrLine.style.left = (atrChartRect.left + atrTimeCoordinate) + 'px'; + atrLine.style.top = atrChartRect.top + 'px'; + atrLine.style.width = '1px'; + atrLine.style.height = atrChartRect.height + 'px'; + atrLine.style.backgroundColor = 'rgba(128, 128, 128, 0.5)'; + atrLine.style.borderLeft = '1px dashed rgba(128, 128, 128, 0.5)'; + atrLine.style.pointerEvents = 'none'; + atrLine.style.zIndex = '1000'; + document.body.appendChild(atrLine); + } + } + + // 如果有MACD图,也在MACD图上绘制垂直线 + if (showMacd && macdChart && macdChartContainer) { + const macdTimeCoordinate = macdChart.timeScale().timeToCoordinate(param.time); + if (macdTimeCoordinate !== null) { + const macdChartRect = macdChartContainer.getBoundingClientRect(); + const macdLine = document.createElement('div'); + macdLine.className = 'macd-crosshair-line'; + macdLine.style.position = 'fixed'; // 改为fixed定位 + macdLine.style.left = (macdChartRect.left + macdTimeCoordinate) + 'px'; + macdLine.style.top = macdChartRect.top + 'px'; + macdLine.style.width = '1px'; + macdLine.style.height = macdChartRect.height + 'px'; + macdLine.style.backgroundColor = 'rgba(128, 128, 128, 0.5)'; + macdLine.style.borderLeft = '1px dashed rgba(128, 128, 128, 0.5)'; + macdLine.style.pointerEvents = 'none'; + macdLine.style.zIndex = '1000'; + document.body.appendChild(macdLine); + } + } + + // 如果有ChanMACD图,也在ChanMACD图上绘制垂直线 + if (showMacd && chanMacdChart && chanMacdChartContainer) { + const chanMacdTimeCoordinate = chanMacdChart.timeScale().timeToCoordinate(param.time); + if (chanMacdTimeCoordinate !== null) { + const chanMacdChartRect = chanMacdChartContainer.getBoundingClientRect(); + console.log('ChanMACD图表位置:', { + left: chanMacdChartRect.left, + top: chanMacdChartRect.top, + width: chanMacdChartRect.width, + height: chanMacdChartRect.height, + timeCoordinate: chanMacdTimeCoordinate + }); + const chanMacdLine = document.createElement('div'); + chanMacdLine.className = 'chanmacd-crosshair-line'; + chanMacdLine.style.position = 'fixed'; + chanMacdLine.style.left = (chanMacdChartRect.left + chanMacdTimeCoordinate) + 'px'; + chanMacdLine.style.top = chanMacdChartRect.top + 'px'; + chanMacdLine.style.width = '1px'; + chanMacdLine.style.height = chanMacdChartRect.height + 'px'; + chanMacdLine.style.backgroundColor = 'rgba(128, 128, 128, 0.5)'; + chanMacdLine.style.borderLeft = '1px dashed rgba(128, 128, 128, 0.5)'; + chanMacdLine.style.pointerEvents = 'none'; + chanMacdLine.style.zIndex = '1000'; + document.body.appendChild(chanMacdLine); + console.log('ChanMACD垂直线已创建,位置:', chanMacdLine.style.left, chanMacdLine.style.top); + } else { + console.log('ChanMACD时间坐标为空'); + } + } else { + console.log('ChanMACD图表条件不满足:', { + showMacd: showMacd, + hasChanMacdChart: !!chanMacdChart, + hasChanMacdChartContainer: !!chanMacdChartContainer + }); + } + + + } + } catch (e) { + console.debug('十字线同步出错:', e); + } + } else { + // 当十字线离开时,清除垂直线 + try { + const existingVolumeLines = document.querySelectorAll('.volume-crosshair-line'); + existingVolumeLines.forEach(line => line.remove()); + const existingAtrLines = document.querySelectorAll('.atr-crosshair-line'); + existingAtrLines.forEach(line => line.remove()); + const existingMacdLines = document.querySelectorAll('.macd-crosshair-line'); + existingMacdLines.forEach(line => line.remove()); + const existingChanMacdLines = document.querySelectorAll('.chanmacd-crosshair-line'); + existingChanMacdLines.forEach(line => line.remove()); + } catch (e) { + console.debug('清除十字线时出错:', e); + } + } + + if (param.time && param.point) { + const timeStr = param.time; + const markers = [...buyMarkers, ...sellMarkers].filter(m => m.time === timeStr); + + // 同时检查分型标记 + const fxMarkers = (window.fxMarkers || []).filter(m => m.time === timeStr); + const allMarkers = [...markers, ...fxMarkers]; + + // 显示时区调试信息 + if (window.debugMode) { + const timezone = $('#timezone').val(); + const formattedTime = formatTimeWithTimezone(timeStr * 1000, timezone); + + // 获取当前价格 - 通过param.seriesPrices获取 + let priceInfo = ''; + if (param.seriesPrices && param.seriesPrices.size > 0) { + // 依次从当前可能的主系列中获取价格 + if (tvWidget.series.candleSeries && param.seriesPrices.get(tvWidget.series.candleSeries)) { + const price = param.seriesPrices.get(tvWidget.series.candleSeries); + priceInfo = `价格: ${price.toFixed(2)}`; + } else if (tvWidget.series.renkoSeries && param.seriesPrices.get(tvWidget.series.renkoSeries)) { + const price = param.seriesPrices.get(tvWidget.series.renkoSeries); + priceInfo = `价格: ${price.toFixed(2)}`; + } else if (tvWidget.series.heikinSeries && param.seriesPrices.get(tvWidget.series.heikinSeries)) { + const price = param.seriesPrices.get(tvWidget.series.heikinSeries); + priceInfo = `价格: ${price.toFixed(2)}`; + } else if (tvWidget.series.barSeries && param.seriesPrices.get(tvWidget.series.barSeries)) { + const price = param.seriesPrices.get(tvWidget.series.barSeries); + priceInfo = `价格: ${price.toFixed(2)}`; + } else if (tvWidget.series.lineSeries && param.seriesPrices.get(tvWidget.series.lineSeries)) { + const price = param.seriesPrices.get(tvWidget.series.lineSeries); + priceInfo = `价格: ${price.toFixed(2)}`; + } else if (tvWidget.series.areaSeries && param.seriesPrices.get(tvWidget.series.areaSeries)) { + const price = param.seriesPrices.get(tvWidget.series.areaSeries); + priceInfo = `价格: ${price.toFixed(2)}`; + } else if (tvWidget.series.baselineSeries && param.seriesPrices.get(tvWidget.series.baselineSeries)) { + const price = param.seriesPrices.get(tvWidget.series.baselineSeries); + priceInfo = `价格: ${price.toFixed(2)}`; + } + // 如果没有蜡烛图系列价格,尝试从区域图系列获取 + else if (tvWidget.series.areaSeries && param.seriesPrices.get(tvWidget.series.areaSeries)) { + const price = param.seriesPrices.get(tvWidget.series.areaSeries); + priceInfo = `价格: ${price.toFixed(2)}`; + } + // 如果没有蜡烛图系列价格,尝试从基线图系列获取 + else if (tvWidget.series.baselineSeries && param.seriesPrices.get(tvWidget.series.baselineSeries)) { + const price = param.seriesPrices.get(tvWidget.series.baselineSeries); + priceInfo = `价格: ${price.toFixed(2)}`; + } + } + + // 仅记录最简短的调试信息 + console.debug(`十字线: ${timeStr} -> ${formattedTime} (${timezone})`); + + // 显示自定义时区工具提示,包含价格信息 + crosshairTooltip.innerHTML = `
时间: ${formattedTime}
` + + (priceInfo ? `
${priceInfo}
` : ''); + crosshairTooltip.style.display = 'block'; + crosshairTooltip.style.left = (param.point.x + 15) + 'px'; + crosshairTooltip.style.top = (param.point.y - 30) + 'px'; + } + + if (allMarkers.length > 0) { + // 有买卖点或分型标记,显示自定义提示 + const tooltips = allMarkers.map(m => m.tooltip).join('

'); + tooltipElement.innerHTML = tooltips; + tooltipElement.style.display = 'block'; + tooltipElement.style.left = (param.point.x + 15) + 'px'; + tooltipElement.style.top = (param.point.y + 15) + 'px'; + } else { + // 隐藏提示 + tooltipElement.style.display = 'none'; + } + } else { + // 隐藏提示 + tooltipElement.style.display = 'none'; + crosshairTooltip.style.display = 'none'; + } + }); + + // 处理图表缩放、平移等事件,隐藏提示 + mainChart.timeScale().subscribeVisibleTimeRangeChange(() => { + tooltipElement.style.display = 'none'; + crosshairTooltip.style.display = 'none'; + }); + } + } else { + console.log('绘制买卖点 - 已禁用'); + } + // 绘制布林带 + if ($('#showMainBollinger').is(':checked') || $('#showElementBollinger').is(':checked')) { + console.log('绘制布林带 - 已启用'); + + // 主周期布林带 + if ($('#showMainBollinger').is(':checked') && currentData.bollinger && currentData.bollinger.upper && currentData.bollinger.lower && currentData.bollinger.middle) { + console.log(`绘制主周期布林带数据,共${currentData.bollinger.upper.length}条`); + + // 准备布林带数据 + const upperBandData = []; + const lowerBandData = []; + const middleBandData = []; + + // 主周期布林带始终使用主周期K线数据作为时间源 + const mainKlineData = currentData.kline_data; + + for (let i = 0; i < mainKlineData.length && i < currentData.bollinger.upper.length; i++) { + const kline = mainKlineData[i]; + const timestamp = Math.floor(new Date(kline.date).getTime() / 1000); + + // 只添加非0的有效数据点 + if (currentData.bollinger.upper[i] && currentData.bollinger.upper[i] !== 0) { + upperBandData.push({ + time: timestamp, + value: currentData.bollinger.upper[i] + }); + } + + if (currentData.bollinger.lower[i] && currentData.bollinger.lower[i] !== 0) { + lowerBandData.push({ + time: timestamp, + value: currentData.bollinger.lower[i] + }); + } + + if (currentData.bollinger.middle[i] && currentData.bollinger.middle[i] !== 0) { + middleBandData.push({ + time: timestamp, + value: currentData.bollinger.middle[i] + }); + } + } + + // 创建布林带上轨 + const upperBandSeries = mainChart.addLineSeries({ + color: '#2196F3', + lineWidth: 1, + lineStyle: 2, // 虚线 + lastValueVisible: false, + priceLineVisible: false, + title: '布林上轨' + }); + upperBandSeries.setData(upperBandData); + + // 创建布林带下轨 + const lowerBandSeries = mainChart.addLineSeries({ + color: '#2196F3', + lineWidth: 1, + lineStyle: 2, // 虚线 + lastValueVisible: false, + priceLineVisible: false, + title: '布林下轨' + }); + lowerBandSeries.setData(lowerBandData); + + // 创建布林带中轨(移动平均线) + const middleBandSeries = mainChart.addLineSeries({ + color: '#FF9800', + lineWidth: 1, + lastValueVisible: false, + priceLineVisible: false, + title: '布林中轨' + }); + middleBandSeries.setData(middleBandData); + + // 保存到tvWidget.series对象 + tvWidget.series.mainBollingerSeries.push(upperBandSeries); + tvWidget.series.mainBollingerSeries.push(lowerBandSeries); + tvWidget.series.mainBollingerSeries.push(middleBandSeries); + + console.log('主周期布林带绘制完成'); + } + + // 次周期布林带 + if ($('#showElementBollinger').is(':checked') && currentData.element_bollinger && currentData.element_bollinger.upper && currentData.element_bollinger.lower && currentData.element_bollinger.middle) { + console.log(`绘制次周期布林带数据,共${currentData.element_bollinger.upper.length}条`); + + // 准备次周期布林带数据 + const elementUpperBandData = []; + const elementLowerBandData = []; + const elementMiddleBandData = []; + + // 使用次周期K线数据 + const elementKlineData = currentData.element_kline_data || currentData.kline_data; + + for (let i = 0; i < elementKlineData.length && i < currentData.element_bollinger.upper.length; i++) { + const kline = elementKlineData[i]; + const timestamp = Math.floor(new Date(kline.date).getTime() / 1000); + + // 只添加非0的有效数据点 + if (currentData.element_bollinger.upper[i] && currentData.element_bollinger.upper[i] !== 0) { + elementUpperBandData.push({ + time: timestamp, + value: currentData.element_bollinger.upper[i] + }); + } + + if (currentData.element_bollinger.lower[i] && currentData.element_bollinger.lower[i] !== 0) { + elementLowerBandData.push({ + time: timestamp, + value: currentData.element_bollinger.lower[i] + }); + } + + if (currentData.element_bollinger.middle[i] && currentData.element_bollinger.middle[i] !== 0) { + elementMiddleBandData.push({ + time: timestamp, + value: currentData.element_bollinger.middle[i] + }); + } + } + + // 创建次周期布林带上轨 + const elementUpperBandSeries = mainChart.addLineSeries({ + color: '#9C27B0', + lineWidth: 1, + lineStyle: 2, // 虚线 + lastValueVisible: false, + priceLineVisible: false, + title: '次周期布林上轨' + }); + elementUpperBandSeries.setData(elementUpperBandData); + + // 创建次周期布林带下轨 + const elementLowerBandSeries = mainChart.addLineSeries({ + color: '#9C27B0', + lineWidth: 1, + lineStyle: 2, // 虚线 + lastValueVisible: false, + priceLineVisible: false, + title: '次周期布林下轨' + }); + elementLowerBandSeries.setData(elementLowerBandData); + + // 创建次周期布林带中轨 + const elementMiddleBandSeries = mainChart.addLineSeries({ + color: '#E91E63', + lineWidth: 1, + lastValueVisible: false, + priceLineVisible: false, + title: '次周期布林中轨' + }); + elementMiddleBandSeries.setData(elementMiddleBandData); + + // 保存到tvWidget.series对象 + tvWidget.series.elementBollingerSeries.push(elementUpperBandSeries); + tvWidget.series.elementBollingerSeries.push(elementLowerBandSeries); + tvWidget.series.elementBollingerSeries.push(elementMiddleBandSeries); + + console.log('次周期布林带绘制完成'); + } + } else { + console.log('绘制布林带 - 已禁用'); + } + + // 绘制分型类型标签 + console.log('=== 开始检查分型显示条件 ==='); + console.log('showKlcFxType勾选状态:', $('#showKlcFxType').is(':checked')); + console.log('showKluFxType勾选状态:', $('#showKluFxType').is(':checked')); + console.log('currentData.klc_fx_info存在:', !!currentData.klc_fx_info); + console.log('currentData.klu_fx_info存在:', !!currentData.klu_fx_info); + console.log('currentData.klc_fx_info长度:', currentData.klc_fx_info ? currentData.klc_fx_info.length : 'undefined'); + console.log('currentData.klu_fx_info长度:', currentData.klu_fx_info ? currentData.klu_fx_info.length : 'undefined'); + if (currentData.klc_fx_info && currentData.klc_fx_info.length > 0) { + console.log('前3个klc分型数据样本:', currentData.klc_fx_info.slice(0, 3)); + } + if (currentData.klu_fx_info && currentData.klu_fx_info.length > 0) { + console.log('前3个klu分型数据样本:', currentData.klu_fx_info.slice(0, 3)); + } + // 收集所有主周期分型标记 + const allMainFxMarkers = []; + const mainFxMarkers = []; // 用于tooltip支持 + // 处理主周期KLC分型 + if ($('#showKlcFxType').is(':checked') && currentData.klc_fx_info && currentData.klc_fx_info.length > 0) { + console.log(`绘制主周期K线合并分型标签,共${currentData.klc_fx_info.length}条`); + + currentData.klc_fx_info.forEach(function(fx) { + try { + // 直接使用UTC时间戳(秒) + const timestamp = Math.floor(new Date(fx.time).getTime() / 1000); + const price = parseFloat(fx.price); + + if (isNaN(timestamp) || isNaN(price)) { + console.error('主周期KLC分型时间或价格转换错误:', fx.time, fx.price); + return; + } + // 主周期 KLC + // 确定颜色和位置 + const color = fx.is_bottom ? '#28a745' : '#dc3545'; // 底分型绿色,顶分型红色 + + // 根据强度等级调整颜色强度 + let strengthColor = color; + + // 构建显示文本,包含分型类型和强度信息 + let displayText = `${fx.fx_strength.toFixed(1)}`; + if (fx.fx_strength < 1.0) { // 降低阈值,让更多分型显示 + displayText = fx.fx_strength >= 0.8 ? '' : '' // 0.8以上显示点,0.8以下不显示文本 + } + displayText = fx.fx_type.replace("TOP", "").replace("BOTTOM", "").replace("11", "").replace("21", "").replace("31", "").replace("41", "").replace("51", "").replace("01", ""); + // 添加标记配置 + const markerConfig = { + time: timestamp, + position: fx.is_bottom ? 'belowBar' : 'aboveBar', + color: strengthColor, + shape: 'triangle', + text: displayText, + size: 2 // 调整尺寸,强分型稍大,普通分型更小 + }; + + allMainFxMarkers.push(markerConfig); + + // 画虚线分型框(根据 start/end + high/low) + if (fx.start_time && fx.end_time && fx.high !== null && fx.high !== undefined && fx.low !== null && fx.low !== undefined) { + const startTs = Math.floor(new Date(fx.start_time).getTime() / 1000); + const endTs = Math.floor(new Date(fx.end_time).getTime() / 1000); + const high = parseFloat(fx.high); + const low = parseFloat(fx.low); + + if (!isNaN(startTs) && !isNaN(endTs) && !isNaN(high) && !isNaN(low)) { + const boxHigh = Math.max(high, low); + const boxLow = Math.min(high, low); + const boxColor = strengthColor; + + const topSeries = mainChart.addLineSeries({ + color: boxColor, + lineWidth: 1, + lineStyle: 2, // 虚线 + lastValueVisible: false, + priceLineVisible: false, + crosshairMarkerVisible: false, + }); + safeOverlayLineSetData(topSeries, [{ time: startTs, value: boxHigh }, { time: endTs, value: boxHigh }]); + + const bottomSeries = mainChart.addLineSeries({ + color: boxColor, + lineWidth: 1, + lineStyle: 2, // 虚线 + lastValueVisible: false, + priceLineVisible: false, + crosshairMarkerVisible: false, + }); + safeOverlayLineSetData(bottomSeries, [{ time: startTs, value: boxLow }, { time: endTs, value: boxLow }]); + + pushFxBoxVertical(startTs, boxLow, boxHigh, boxColor); + pushFxBoxVertical(endTs, boxLow, boxHigh, boxColor); + + if (!tvWidget.series.mainKlcFxBoxSeries) tvWidget.series.mainKlcFxBoxSeries = []; + tvWidget.series.mainKlcFxBoxSeries.push(topSeries, bottomSeries); + } + } + + // 创建分型标记对象,包含tooltip信息 + const fxMarker = { + time: timestamp, + tooltip: `
+ 主周期${fx.is_bottom ? '底分型' : '顶分型'}(合): ${fx.fx_type}
+ 强度分数: ${fx.fx_strength}分
+ 强度等级: ${fx.fx_strength_level}
+ 是否强分型: ${fx.is_strong_fx ? '是' : '否'}
+ 价格: ${price.toFixed(4)}
+ 时间: ${fx.time} +
` + }; + + mainFxMarkers.push(fxMarker); + + } catch (e) { + console.error('绘制主周期KLC分型标签出错:', e); + } + }); + } + + // 处理主周期KLU分型 + if ($('#showKluFxType').is(':checked') && currentData.klu_fx_info && currentData.klu_fx_info.length > 0) { + console.log(`绘制主周期K线未合并分型标签,共${currentData.klu_fx_info.length}条`); + + currentData.klu_fx_info.forEach(function(fx) { + try { + // 直接使用UTC时间戳(秒) + const timestamp = Math.floor(new Date(fx.time).getTime() / 1000); + const price = parseFloat(fx.price); + + if (isNaN(timestamp) || isNaN(price)) { + console.error('主周期KLU分型时间或价格转换错误:', fx.time, fx.price); + return; + } + + // 主周期 KLU + const color = fx.is_bottom ? '#17a2b8' : '#fd7e14'; // 底分型用青色,顶分型用橙色 + + // 根据强度等级调整颜色强度 + let strengthColor = color; + if (fx.is_strong_fx) { + // 强分型使用更亮的颜色 + strengthColor = fx.is_bottom ? '#20c997' : '#fd7e14'; + } + + // 构建显示文本,包含分型类型和强度信息 + let displayText = `${fx.fx_strength.toFixed(1)}`; + if (fx.fx_strength < 1.0) { // 降低阈值,让更多分型显示 + displayText = fx.fx_strength >= 1.5 ? '' : '' // 0.8以上显示点,0.8以下不显示文本 + } + displayText = fx.fx_type.replace("TOP", "").replace("BOTTOM", "").replace("11", ""); + // 添加标记配置 + const markerConfig = { + time: timestamp, + position: fx.is_bottom ? 'belowBar' : 'aboveBar', + color: strengthColor, + // shape: 'triangle', // 使用三角形区分KLU分型 + text: displayText, + size: fx.is_strong_fx ? 0.8 : 0.5 // KLU分型稍小一些 + }; + + allMainFxMarkers.push(markerConfig); + + // 创建分型标记对象,包含tooltip信息 + const fxMarker = { + time: timestamp, + tooltip: `
+ 主周期${fx.is_bottom ? '底分型' : '顶分型'}(原): ${fx.fx_type}
+ 强度分数: ${fx.fx_strength}分
+ 强度等级: ${fx.fx_strength_level}
+ 是否强分型: ${fx.is_strong_fx ? '是' : '否'}
+ 价格: ${price.toFixed(4)}
+ 时间: ${fx.time} +
` + }; + + mainFxMarkers.push(fxMarker); + + } catch (e) { + console.error('绘制主周期KLU分型标签出错:', e); + } + }); + } + + // 暂存主周期分型标记 + window.mainFxMarkers = allMainFxMarkers; + + // 将分型标记添加到全局markers中以支持tooltip功能 + if (window.fxMarkers) { + window.fxMarkers = [...window.fxMarkers, ...mainFxMarkers]; + } else { + window.fxMarkers = mainFxMarkers; + } + + // 检查是否有任何主周期分型数据 + const hasMainFxData = ($('#showKlcFxType').is(':checked') && currentData.klc_fx_info && currentData.klc_fx_info.length > 0) || + ($('#showKluFxType').is(':checked') && currentData.klu_fx_info && currentData.klu_fx_info.length > 0); + + if (!hasMainFxData) { + console.log('绘制主周期分型标记 - 已禁用或无数据'); + // 清空主周期分型标记 + window.mainFxMarkers = []; + window.fxMarkers = []; + } + // 绘制小周期分型标记(含次次周期) + if (($('#showElementKlcFxType').is(':checked') && currentData.element_klc_fx_info && currentData.element_klc_fx_info.length > 0) || + ($('#showElementKluFxType').is(':checked') && currentData.element_klu_fx_info && currentData.element_klu_fx_info.length > 0) || + ($('#showSubSubKlcFxType').is(':checked') && currentData.sub_sub_klc_fx_info && currentData.sub_sub_klc_fx_info.length > 0)) { + + // 收集所有小周期分型标记 + const allElementFxMarkers = []; + const elementFxMarkers = []; // 用于tooltip支持 + + // 处理小周期KLC分型 + if ($('#showElementKlcFxType').is(':checked') && currentData.element_klc_fx_info && currentData.element_klc_fx_info.length > 0) { + console.log(`绘制小周期K线合并分型标记,共${currentData.element_klc_fx_info.length}条`); + + currentData.element_klc_fx_info.forEach(function(fx) { + try { + // 直接使用UTC时间戳(秒) + const timestamp = Math.floor(new Date(fx.time).getTime() / 1000); + const price = parseFloat(fx.price); + + if (isNaN(timestamp) || isNaN(price)) { + console.error('小周期KLC分型时间或价格转换错误:', fx.time, fx.price); + return; + } + + // 小周期 KLC + let strengthColor = fx.is_bottom ? '#11116B' : '#222222'; // 底分型用珊瑚红,顶分型用薄荷绿 + let displayText = `${fx.fx_strength.toFixed(1)}`; + // 构建小周期分型显示文本 + if (fx.fx_strength < 1.0){ // 调整小周期阈值 + displayText = fx.fx_strength >= 0.6 ? '' : '' // 0.6以上显示点 + } + displayText = fx.fx_type.replace("TOP", "").replace("BOTTOM", "").replace("11", "").replace("21", "").replace("3", "").replace("41", "").replace("51", "").replace("0", ""); + // 小周期分型标记配置 + const markerConfig = { + time: timestamp, + position: fx.is_bottom ? 'belowBar' : 'aboveBar', + color: strengthColor, + shape: 'triangle', + text: displayText, + size: fx.is_strong_fx ? 0.8 : 0.6 // 小周期标记整体更小一些 + }; + + allElementFxMarkers.push(markerConfig); + + // 画虚线分型框(小周期) + if (fx.start_time && fx.end_time && fx.high !== null && fx.high !== undefined && fx.low !== null && fx.low !== undefined) { + const startTs = Math.floor(new Date(fx.start_time).getTime() / 1000); + const endTs = Math.floor(new Date(fx.end_time).getTime() / 1000); + const high = parseFloat(fx.high); + const low = parseFloat(fx.low); + + if (!isNaN(startTs) && !isNaN(endTs) && !isNaN(high) && !isNaN(low)) { + const boxHigh = Math.max(high, low); + const boxLow = Math.min(high, low); + const boxColor = strengthColor; + + const topSeries = mainChart.addLineSeries({ + color: boxColor, + lineWidth: 1, + lineStyle: 2, + lastValueVisible: false, + priceLineVisible: false, + crosshairMarkerVisible: false, + }); + safeOverlayLineSetData(topSeries, [{ time: startTs, value: boxHigh }, { time: endTs, value: boxHigh }]); + + const bottomSeries = mainChart.addLineSeries({ + color: boxColor, + lineWidth: 1, + lineStyle: 2, + lastValueVisible: false, + priceLineVisible: false, + crosshairMarkerVisible: false, + }); + safeOverlayLineSetData(bottomSeries, [{ time: startTs, value: boxLow }, { time: endTs, value: boxLow }]); + + pushFxBoxVertical(startTs, boxLow, boxHigh, boxColor); + pushFxBoxVertical(endTs, boxLow, boxHigh, boxColor); + + if (!tvWidget.series.elementKlcFxBoxSeries) tvWidget.series.elementKlcFxBoxSeries = []; + tvWidget.series.elementKlcFxBoxSeries.push(topSeries, bottomSeries); + } + } + + // 创建小周期分型标记对象,包含tooltip信息 + const elementFxMarker = { + time: timestamp, + tooltip: `
+ 小周期${fx.is_bottom ? '底分型' : '顶分型'}(合): ${fx.fx_type}
+ 强度分数: ${fx.fx_strength}分
+ 强度等级: ${fx.fx_strength_level}
+ 是否强分型: ${fx.is_strong_fx ? '是' : '否'}
+ 价格: ${price.toFixed(4)}
+ 时间: ${fx.time} +
` + }; + + elementFxMarkers.push(elementFxMarker); + + } catch (e) { + console.error('绘制小周期KLC分型标记出错:', e); + } + }); + } + + // 处理小周期KLU分型 + if ($('#showElementKluFxType').is(':checked') && currentData.element_klu_fx_info && currentData.element_klu_fx_info.length > 0) { + console.log(`绘制小周期K线未合并分型标记,共${currentData.element_klu_fx_info.length}条`); + + currentData.element_klu_fx_info.forEach(function(fx) { + try { + // 直接使用UTC时间戳(秒) + const timestamp = Math.floor(new Date(fx.time).getTime() / 1000); + const price = parseFloat(fx.price); + + if (isNaN(timestamp) || isNaN(price)) { + console.error('小周期KLU分型时间或价格转换错误:', fx.time, fx.price); + return; + } + + // 小周期 KLU + let strengthColor = fx.is_bottom ? '#9A8C98' : '#F2CC8F'; // 底分型用灰紫色,顶分型用浅黄色 + let displayText = `${fx.fx_strength.toFixed(1)}`; + // 构建小周期分型显示文本 + if (fx.fx_strength < 2.0){ // 调整小周期阈值 + displayText = fx.fx_strength >= 1.5 ? '' : '' // 0.6以上显示点 + } + displayText = fx.fx_type.replace("TOP", "").replace("BOTTOM", "").replace("11", ""); + // 小周期KLU分型标记配置 + const markerConfig = { + time: timestamp, + position: fx.is_bottom ? 'belowBar' : 'aboveBar', + color: strengthColor, + // shape: 'triangle', // 使用三角形区分KLU分型 + text: displayText, + size: fx.is_strong_fx ? 0.7 : 0.5 // 小周期KLU标记更小一些 + }; + + allElementFxMarkers.push(markerConfig); + + // 创建小周期分型标记对象,包含tooltip信息 + const elementFxMarker = { + time: timestamp, + tooltip: `
+ 小周期${fx.is_bottom ? '底分型' : '顶分型'}(原): ${fx.fx_type}
+ 强度分数: ${fx.fx_strength}分
+ 强度等级: ${fx.fx_strength_level}
+ 是否强分型: ${fx.is_strong_fx ? '是' : '否'}
+ 价格: ${price.toFixed(4)}
+ 时间: ${fx.time} +
` + }; + + elementFxMarkers.push(elementFxMarker); + + } catch (e) { + console.error('绘制小周期KLU分型标记出错:', e); + } + }); + } + + // 次次周期KLC分型 + if ($('#showSubSubKlcFxType').is(':checked') && currentData.sub_sub_klc_fx_info && currentData.sub_sub_klc_fx_info.length > 0) { + currentData.sub_sub_klc_fx_info.forEach(function(fx) { + try { + const timestamp = Math.floor(new Date(fx.time).getTime() / 1000); + const price = parseFloat(fx.price); + if (isNaN(timestamp) || isNaN(price)) return; + const strengthColor = '#00897b'; + let displayText = (fx.fx_type || '').replace("TOP", "").replace("BOTTOM", "").replace("11", "").replace("21", "").replace("3", "").replace("41", "").replace("51", "").replace("0", ""); + const markerConfig = { + time: timestamp, + position: fx.is_bottom ? 'belowBar' : 'aboveBar', + color: strengthColor, + shape: 'triangle', + text: displayText, + size: (fx.is_strong_fx ? 0.6 : 0.5) + }; + allElementFxMarkers.push(markerConfig); + + // 画虚线分型框(次次周期) + if (fx.start_time && fx.end_time && fx.high !== null && fx.high !== undefined && fx.low !== null && fx.low !== undefined) { + const startTs = Math.floor(new Date(fx.start_time).getTime() / 1000); + const endTs = Math.floor(new Date(fx.end_time).getTime() / 1000); + const high = parseFloat(fx.high); + const low = parseFloat(fx.low); + + if (!isNaN(startTs) && !isNaN(endTs) && !isNaN(high) && !isNaN(low)) { + const boxHigh = Math.max(high, low); + const boxLow = Math.min(high, low); + const boxColor = strengthColor; + + const topSeries = mainChart.addLineSeries({ + color: boxColor, + lineWidth: 1, + lineStyle: 2, + lastValueVisible: false, + priceLineVisible: false, + crosshairMarkerVisible: false, + }); + safeOverlayLineSetData(topSeries, [{ time: startTs, value: boxHigh }, { time: endTs, value: boxHigh }]); + + const bottomSeries = mainChart.addLineSeries({ + color: boxColor, + lineWidth: 1, + lineStyle: 2, + lastValueVisible: false, + priceLineVisible: false, + crosshairMarkerVisible: false, + }); + safeOverlayLineSetData(bottomSeries, [{ time: startTs, value: boxLow }, { time: endTs, value: boxLow }]); + + const leftSeries = mainChart.addLineSeries({ + color: boxColor, + lineWidth: 1, + lineStyle: 2, + lastValueVisible: false, + priceLineVisible: false, + crosshairMarkerVisible: false, + }); + safeOverlayLineSetData(leftSeries, [{ time: startTs, value: boxLow }, { time: startTs, value: boxHigh }]); + + const rightSeries = mainChart.addLineSeries({ + color: boxColor, + lineWidth: 1, + lineStyle: 2, + lastValueVisible: false, + priceLineVisible: false, + crosshairMarkerVisible: false, + }); + safeOverlayLineSetData(rightSeries, [{ time: endTs, value: boxLow }, { time: endTs, value: boxHigh }]); + + if (!tvWidget.series.subSubKlcFxBoxSeries) tvWidget.series.subSubKlcFxBoxSeries = []; + tvWidget.series.subSubKlcFxBoxSeries.push(topSeries, bottomSeries, leftSeries, rightSeries); + } + } + } catch (e) { console.error('绘制次次周期KLC分型标记出错:', e); } + }); + } + + // 将小周期分型标记添加到全局markers中以支持tooltip功能 + if (window.fxMarkers) { + window.fxMarkers = [...window.fxMarkers, ...elementFxMarkers]; + } else { + window.fxMarkers = elementFxMarkers; + } + + // 基于后端提供的 KLC 趋势生成标记(不进行任何计算) + let klcTrendMarkers = []; + try { + if (currentData.klc_trend && currentData.klc_trend.length > 0) { + console.log('KLC趋势点数量:', currentData.klc_trend.length, currentData.klc_trend.slice(0, 3)); + // 当前图表的bar时间集合(秒)用于对齐标记到最近的K线 + const seriesTimes = (typeof candles !== 'undefined' && Array.isArray(candles)) ? new Set(candles.map(c => c.time)) : new Set(); + const nearestTime = (target) => { + if (!Array.isArray(candles) || candles.length === 0) return target; + // 简单线性查找(数据量通常可接受),必要时可替换为二分 + let best = candles[0].time; + let bestDiff = Math.abs(best - target); + for (let i = 1; i < candles.length; i++) { + const t = candles[i].time; + const d = Math.abs(t - target); + if (d < bestDiff) { best = t; bestDiff = d; } + } + return best; + }; + + klcTrendMarkers = currentData.klc_trend.map(t => { + const ts = Math.floor(new Date(t.time).getTime() / 1000); + const trendRaw = (t.trend || '').toString().toUpperCase(); + let timeAligned = seriesTimes.has(ts) ? ts : nearestTime(ts); + let marker = { time: timeAligned, position: 'inBar', color: '#9E9E9E', shape: 'square', size: 0.8 }; + if (trendRaw === 'UP') { + marker = { time: timeAligned, position: 'aboveBar', color: '#00C853', shape: 'arrowUp', size: 0.5 }; + } else if (trendRaw === 'DOWN') { + marker = { time: timeAligned, position: 'belowBar', color: '#D32F2F', shape: 'arrowDown', size: 0.5 }; + } else if (trendRaw === 'FLAT') { + marker = { time: timeAligned, position: 'inBar', color: '#9E9E9E', shape: 'circle', size: 0.8 }; + } else { + // UNKNOWN 或其他 + marker = { time: timeAligned, position: 'inBar', color: '#2196F3', shape: 'square', size: 0.8 }; + } + return marker; + }); + console.log('KLC趋势标记(对齐后)示例:', klcTrendMarkers.slice(0, 5)); + } + } catch (e) { + klcTrendMarkers = []; + } + // 暴露到全局以便调试或后续合并 + window.klcTrendMarkers = klcTrendMarkers; + + // 无论当前显示主/小周期,只要勾选对应Trend,就叠加出来 + let trendMarkersToUse = []; + if ($('#showMainTrend').is(':checked')) { + trendMarkersToUse = trendMarkersToUse.concat(window.klcTrendMarkers || []); + } + if ($('#showElementTrend').is(':checked') && currentData.element_klc_trend) { + const candlesTimes = (typeof candles !== 'undefined' && Array.isArray(candles)) ? new Set(candles.map(c => c.time)) : new Set(); + const nearestTime = (target) => { + if (!Array.isArray(candles) || candles.length === 0) return target; + let best = candles[0].time, bestDiff = Math.abs(best - target); + for (let i = 1; i < candles.length; i++) { + const t = candles[i].time, d = Math.abs(t - target); + if (d < bestDiff) { best = t; bestDiff = d; } + } + return best; + }; + const elementMarkers = currentData.element_klc_trend.map(t => { + const ts = Math.floor(new Date(t.time).getTime() / 1000); + const trendRaw = (t.trend || '').toString().toUpperCase(); + const timeAligned = candlesTimes.has(ts) ? ts : nearestTime(ts); + if (trendRaw === 'UP') return { time: timeAligned, position: 'aboveBar', color: '#00C853', shape: 'arrowUp', size: 0.5 }; + if (trendRaw === 'DOWN') return { time: timeAligned, position: 'belowBar', color: '#D32F2F', shape: 'arrowDown', size: 0.5 }; + if (trendRaw === 'FLAT') return { time: timeAligned, position: 'inBar', color: '#9E9E9E', shape: 'circle', size: 0.8 }; + return { time: timeAligned, position: 'inBar', color: '#2196F3', shape: 'square', size: 0.8 }; + }); + trendMarkersToUse = trendMarkersToUse.concat(elementMarkers); + } + if ($('#showSubSubTrend').is(':checked') && currentData.sub_sub_klc_trend && currentData.sub_sub_klc_trend.length > 0) { + const candlesTimesSs = (typeof candles !== 'undefined' && Array.isArray(candles)) ? new Set(candles.map(c => c.time)) : new Set(); + const nearestTimeSs = (target) => { + if (!Array.isArray(candles) || candles.length === 0) return target; + let best = candles[0].time, bestDiff = Math.abs(best - target); + for (let i = 1; i < candles.length; i++) { + const t = candles[i].time, d = Math.abs(t - target); + if (d < bestDiff) { best = t; bestDiff = d; } + } + return best; + }; + const subSubColor = '#00897b'; + const subSubMarkers = currentData.sub_sub_klc_trend.map(t => { + const ts = Math.floor(new Date(t.time).getTime() / 1000); + const trendRaw = (t.trend || '').toString().toUpperCase(); + const timeAligned = candlesTimesSs.has(ts) ? ts : nearestTimeSs(ts); + if (trendRaw === 'UP') return { time: timeAligned, position: 'aboveBar', color: subSubColor, shape: 'arrowUp', size: 0.4 }; + if (trendRaw === 'DOWN') return { time: timeAligned, position: 'belowBar', color: subSubColor, shape: 'arrowDown', size: 0.4 }; + if (trendRaw === 'FLAT') return { time: timeAligned, position: 'inBar', color: subSubColor, shape: 'circle', size: 0.5 }; + return { time: timeAligned, position: 'inBar', color: subSubColor, shape: 'square', size: 0.5 }; + }); + trendMarkersToUse = trendMarkersToUse.concat(subSubMarkers); + } + + // 合并标记并设置 + const combinedMarkers = [ + ...(window.mainFxMarkers || []), + ...allElementFxMarkers, + ...(window.kluDivMarkersMain || []), + ...(window.kluDivMarkersElement || []), + ...(window.kluDivMarkersSubSub || []), + ...trendMarkersToUse, + ...(window.bspMarkers || []), + ...(window.wrMarkers || []) + ]; + if (combinedMarkers.length > 0) { + console.log( + '合并设置', combinedMarkers.length, '个标记(主周期分型:', + (window.mainFxMarkers || []).length, + '个,小周期分型:', allElementFxMarkers.length, + '个,UnitTF:', (window.unittfMarkers || []).length, + '个,BSP标记:', (window.bspMarkers || []).length, + '个,区间标记:', (window.wrMarkers || []).length, + '个)' + ); + + // 根据当前主系列类型设置标记 + const klineType = ($('#klineType').val() || (showOriginalKline ? 'candlestick' : 'line')); + let targetSeries = null; + if (klineType === 'candlestick') targetSeries = tvWidget.series.candleSeries; + else if (klineType === 'renko') targetSeries = tvWidget.series.renkoSeries; + else if (klineType === 'heikin') targetSeries = tvWidget.series.heikinSeries; + else if (klineType === 'bar') targetSeries = tvWidget.series.barSeries; + else if (klineType === 'line') targetSeries = tvWidget.series.lineSeries; + else if (klineType === 'area') targetSeries = tvWidget.series.areaSeries; + else if (klineType === 'baseline') targetSeries = tvWidget.series.baselineSeries; + else if (klineType === 'klc') targetSeries = tvWidget.series.klcSeries; + if (targetSeries) { + try { + targetSeries.setMarkers(alignMarkersToCandles(combinedMarkers, candles)); + } catch (e) { + console.warn('设置主系列标记失败(可能series已释放):', e); + } + } else { + console.log('未找到主数据系列,无法设置标记'); + } + } + + } else { + console.log('绘制小周期分型标记 - 已禁用或无数据'); + + // 计算并缓存KLC趋势标记(即使未启用小周期分型,也应显示趋势) + try { + let klcTrendMarkers = []; + if (currentData.klc_trend && currentData.klc_trend.length > 0) { + console.log('KLC趋势点数量:', currentData.klc_trend.length, currentData.klc_trend.slice(0, 3)); + const seriesTimes = (typeof candles !== 'undefined' && Array.isArray(candles)) ? new Set(candles.map(c => c.time)) : new Set(); + const nearestTime = (target) => { + if (!Array.isArray(candles) || candles.length === 0) return target; + let best = candles[0].time; + let bestDiff = Math.abs(best - target); + for (let i = 1; i < candles.length; i++) { + const t = candles[i].time; + const d = Math.abs(t - target); + if (d < bestDiff) { best = t; bestDiff = d; } + } + return best; + }; + klcTrendMarkers = currentData.klc_trend.map(t => { + const ts = Math.floor(new Date(t.time).getTime() / 1000); + const trendRaw = (t.trend || '').toString().toUpperCase(); + const timeAligned = seriesTimes.has(ts) ? ts : nearestTime(ts); + if (trendRaw === 'UP') { + return { time: timeAligned, position: 'aboveBar', color: '#00C853', shape: 'arrowUp', size: 0.5 }; + } else if (trendRaw === 'DOWN') { + return { time: timeAligned, position: 'belowBar', color: '#D32F2F', shape: 'arrowDown', size: 0.5 }; + } else if (trendRaw === 'FLAT') { + return { time: timeAligned, position: 'inBar', color: '#9E9E9E', shape: 'circle', size: 0.8 }; + } else { + return { time: timeAligned, position: 'inBar', color: '#2196F3', shape: 'square', size: 0.8 }; + } + }); + console.log('KLC趋势标记(对齐后)示例:', klcTrendMarkers.slice(0, 5)); + } + window.klcTrendMarkers = klcTrendMarkers; + } catch (e) { + window.klcTrendMarkers = []; + } + + // 与上方一致:勾选哪个Trend就显示哪个 + let trendMarkersToUse = []; + if ($('#showMainTrend').is(':checked')) { + trendMarkersToUse = trendMarkersToUse.concat(window.klcTrendMarkers || []); + } + if ($('#showElementTrend').is(':checked') && currentData.element_klc_trend) { + const candlesTimes = (typeof candles !== 'undefined' && Array.isArray(candles)) ? new Set(candles.map(c => c.time)) : new Set(); + const nearestTime = (target) => { + if (!Array.isArray(candles) || candles.length === 0) return target; + let best = candles[0].time, bestDiff = Math.abs(best - target); + for (let i = 1; i < candles.length; i++) { + const t = candles[i].time, d = Math.abs(t - target); + if (d < bestDiff) { best = t; bestDiff = d; } + } + return best; + }; + const elementMarkers = currentData.element_klc_trend.map(t => { + const ts = Math.floor(new Date(t.time).getTime() / 1000); + const trendRaw = (t.trend || '').toString().toUpperCase(); + const timeAligned = candlesTimes.has(ts) ? ts : nearestTime(ts); + if (trendRaw === 'UP') return { time: timeAligned, position: 'aboveBar', color: '#00C853', shape: 'arrowUp', size: 0.5 }; + if (trendRaw === 'DOWN') return { time: timeAligned, position: 'belowBar', color: '#D32F2F', shape: 'arrowDown', size: 0.5 }; + if (trendRaw === 'FLAT') return { time: timeAligned, position: 'inBar', color: '#9E9E9E', shape: 'circle', size: 0.8 }; + return { time: timeAligned, position: 'inBar', color: '#2196F3', shape: 'square', size: 0.8 }; + }); + trendMarkersToUse = trendMarkersToUse.concat(elementMarkers); + } + if ($('#showSubSubTrend').is(':checked') && currentData.sub_sub_klc_trend && currentData.sub_sub_klc_trend.length > 0) { + const candlesTimesSs2 = (typeof candles !== 'undefined' && Array.isArray(candles)) ? new Set(candles.map(c => c.time)) : new Set(); + const nearestTimeSs2 = (target) => { + if (!Array.isArray(candles) || candles.length === 0) return target; + let best = candles[0].time, bestDiff = Math.abs(best - target); + for (let i = 1; i < candles.length; i++) { + const t = candles[i].time, d = Math.abs(t - target); + if (d < bestDiff) { best = t; bestDiff = d; } + } + return best; + }; + const subSubColor2 = '#00897b'; + const subSubMarkers2 = currentData.sub_sub_klc_trend.map(t => { + const ts = Math.floor(new Date(t.time).getTime() / 1000); + const trendRaw = (t.trend || '').toString().toUpperCase(); + const timeAligned = candlesTimesSs2.has(ts) ? ts : nearestTimeSs2(ts); + if (trendRaw === 'UP') return { time: timeAligned, position: 'aboveBar', color: subSubColor2, shape: 'arrowUp', size: 0.4 }; + if (trendRaw === 'DOWN') return { time: timeAligned, position: 'belowBar', color: subSubColor2, shape: 'arrowDown', size: 0.4 }; + if (trendRaw === 'FLAT') return { time: timeAligned, position: 'inBar', color: subSubColor2, shape: 'circle', size: 0.5 }; + return { time: timeAligned, position: 'inBar', color: subSubColor2, shape: 'square', size: 0.5 }; + }); + trendMarkersToUse = trendMarkersToUse.concat(subSubMarkers2); + } + // 这里的 onlyMainAndU 实际上是「最终要挂到主K线上」的一组标记 + // 之前没有把 window.bspMarkers 合进去,导致上面已经合并了 BSP 标记, + // 但在这里再次调用 setMarkers 时把 BSP 覆盖掉了,从而前端看不到买卖点。 + // 修复:把 BSP 标记一并合并进来。 + const onlyMainAndU = [ + ...(window.mainFxMarkers || []), + ...(window.kluDivMarkersMain || []), + ...(window.kluDivMarkersElement || []), + ...(window.kluDivMarkersSubSub || []), + ...trendMarkersToUse, + ...(window.bspMarkers || []), + ...(window.wrMarkers || []) + ]; + if (onlyMainAndU.length > 0) { + console.log('仅设置', onlyMainAndU.length, '个主周期/UnitTF标记(主周期分型:', (window.mainFxMarkers || []).length, ',UnitTF:', (window.unittfMarkers || []).length, ')'); + + // 根据当前主系列类型设置标记 + const klineType2 = ($('#klineType').val() || (showOriginalKline ? 'candlestick' : 'line')); + let targetSeries2 = null; + if (klineType2 === 'candlestick') targetSeries2 = tvWidget.series.candleSeries; + else if (klineType2 === 'renko') targetSeries2 = tvWidget.series.renkoSeries; + else if (klineType2 === 'heikin') targetSeries2 = tvWidget.series.heikinSeries; + else if (klineType2 === 'bar') targetSeries2 = tvWidget.series.barSeries; + else if (klineType2 === 'line') targetSeries2 = tvWidget.series.lineSeries; + else if (klineType2 === 'area') targetSeries2 = tvWidget.series.areaSeries; + else if (klineType2 === 'baseline') targetSeries2 = tvWidget.series.baselineSeries; + else if (klineType2 === 'klc') targetSeries2 = tvWidget.series.klcSeries; + if (targetSeries2) { + try { + targetSeries2.setMarkers(alignMarkersToCandles(onlyMainAndU, candles)); + } catch (e) { + console.warn('设置主系列标记失败(可能series已释放):', e); + } + } else { + console.log('未找到主数据系列,无法设置标记'); + } + } else { + console.log('没有分型标记需要显示,清空图表标记'); + // 清空图表上的所有主系列标记 + const klineType3 = ($('#klineType').val() || (showOriginalKline ? 'candlestick' : 'line')); + let targetSeries3 = null; + if (klineType3 === 'candlestick') targetSeries3 = tvWidget.series.candleSeries; + else if (klineType3 === 'renko') targetSeries3 = tvWidget.series.renkoSeries; + else if (klineType3 === 'heikin') targetSeries3 = tvWidget.series.heikinSeries; + else if (klineType3 === 'bar') targetSeries3 = tvWidget.series.barSeries; + else if (klineType3 === 'line') targetSeries3 = tvWidget.series.lineSeries; + else if (klineType3 === 'area') targetSeries3 = tvWidget.series.areaSeries; + else if (klineType3 === 'baseline') targetSeries3 = tvWidget.series.baselineSeries; + else if (klineType3 === 'klc') targetSeries3 = tvWidget.series.klcSeries; + if (targetSeries3) { + try { + targetSeries3.setMarkers([]); + } catch (e) { + console.warn('清空主系列标记失败(可能series已释放):', e); + } + } + } + } +} diff --git a/web/static/js/app/chart_tv_shell.js b/web/static/js/app/chart_tv_shell.js new file mode 100644 index 0000000..97b53b5 --- /dev/null +++ b/web/static/js/app/chart_tv_shell.js @@ -0,0 +1,519 @@ +/* chart_tv_shell.js — containers, charts, main price series */ + +function chartTvBuildShell(ctx) { + var symbol = ctx.symbol; + var timeframe = ctx.timeframe; + + // 获取当前交易对的配置 + const symbolConfig = getSymbolConfig(symbol); + console.log('交易对配置:', symbolConfig); + + // 检查数据是否存在 + if (!currentData || !currentData.kline_data) { + console.error('数据加载失败或不存在'); + return; + } + + // 检查使用哪一档K线数据:次次周期 / 小周期 / 主周期 + const useSubSubPeriod = $('#subSubPeriodKline').is(':checked') && + currentData.sub_sub_kline_data && + Array.isArray(currentData.sub_sub_kline_data); + const useElementPeriod = $('#elementPeriodKline').is(':checked') && + currentData.element_kline_data && + Array.isArray(currentData.element_kline_data); + + // 输出K线周期选择状态 + const klinePeriodLabel = useSubSubPeriod ? '次次周期' : (useElementPeriod ? '小周期' : '主周期'); + console.log('K线周期选择:', klinePeriodLabel); + console.log('当前选择时区:', $('#timezone').val()); + console.log('交易对类型:', symbolConfig.type); + + let candles = []; + const klineDataSource = useSubSubPeriod ? (currentData.sub_sub_kline_data || []) : (useElementPeriod ? (currentData.element_kline_data || []) : (currentData.kline_data || [])); + + if (useSubSubPeriod || useElementPeriod) { + if (!klineDataSource.length) { + console.error(useSubSubPeriod ? '次次周期K线数据不存在或为空' : '小周期K线数据不存在或为空', klineDataSource); + return; + } + candles = klineDataSource.map((kline) => { + const date = new Date(kline.date); + const timestamp = Math.floor(date.getTime() / 1000); + return { + time: timestamp, + open: parseFloat(kline.open), + high: parseFloat(kline.high), + low: parseFloat(kline.low), + close: parseFloat(kline.close), + }; + }).filter((c) => isFinite(c.time) && isFinite(c.open) && isFinite(c.high) && isFinite(c.low) && isFinite(c.close)); + } else { + if (!currentData.kline_data || !Array.isArray(currentData.kline_data)) { + console.error('主周期K线数据不存在或不是数组:', currentData.kline_data); + return; + } + candles = currentData.kline_data.map((kline) => { + const date = new Date(kline.date); + const timestamp = Math.floor(date.getTime() / 1000); + return { + time: timestamp, + open: parseFloat(kline.open), + high: parseFloat(kline.high), + low: parseFloat(kline.low), + close: parseFloat(kline.close), + }; + }).filter((c) => isFinite(c.time) && isFinite(c.open) && isFinite(c.high) && isFinite(c.low) && isFinite(c.close)); + } + + // 根据交易对类型过滤数据(仅用于显示优化) + if (symbolConfig.type === 'a_stock' && timeframe.includes('m')) { + // 对于A股分钟级数据,过滤非交易时间 + const originalLength = candles.length; + candles = filterTradingHours(candles, symbolConfig); + console.log(`A股数据过滤: ${originalLength} -> ${candles.length} 条记录`); + } + // 重置图表对象(容器已在 disposeTradingViewCharts 清空) + tvWidget = { + mainChart: null, + volumeChart: null, + macdChart: null, + series: { + candleSeries: null, + lineSeries: null, + volumeSeries: null, + macdLineSeries: null, + signalLineSeries: null, + histogramSeries: null, + mainBiSeries: [], + mainUncompletedBiSeries: [], + mainSegSeries: [], + mainUncompletedSegSeries: [], + mainZsSeries: [], + mainUncompletedZsSeries: [], + elementBiSeries: [], + elementUncompletedBiSeries: [], + elementSegSeries: [], + elementUncompletedSegSeries: [], + elementZsSeries: [], + elementUncompletedZsSeries: [], + subSubBiSeries: [], + subSubUncompletedBiSeries: [], + subSubSegSeries: [], + subSubUncompletedSegSeries: [], + subSubZsSeries: [], + subSubUncompletedZsSeries: [], + tradePointSeries: [], + mainBollingerSeries: [], + elementBollingerSeries: [], + maSeries: [], // 添加均线系列数组 + bbSeries: [], // 添加布林带系列数组 + ema52Series: [] // 添加EMA52系列数组 + }, + state: { + isInitialized: false, + visibleRange: null, + logicalRange: null + } + }; + + // 设置父容器样式 + const container = document.getElementById('tradingview_chart'); + container.style.position = 'relative'; + container.style.width = '100%'; + container.style.height = '100%'; + + // 是否显示MACD + const showMacd = $('#showMacd').is(':checked'); + const showOriginalKline = $('#showOriginalKline').is(':checked'); + + // 创建主图容器 + const mainChartContainer = document.createElement('div'); + mainChartContainer.style.width = '100%'; + mainChartContainer.style.position = 'absolute'; + mainChartContainer.style.top = '0'; + mainChartContainer.style.left = '0'; + mainChartContainer.style.right = '0'; + + // 创建成交量副图容器 + const volumeChartContainer = document.createElement('div'); + volumeChartContainer.style.width = '100%'; + volumeChartContainer.style.position = 'absolute'; + volumeChartContainer.style.left = '0'; + volumeChartContainer.style.right = '0'; + volumeChartContainer.style.borderTop = '1px solid #e0e0e0'; + + // 添加ATR图表容器 + const atrChartContainer = document.createElement('div'); + atrChartContainer.style.width = '100%'; + atrChartContainer.style.position = 'absolute'; + atrChartContainer.style.left = '0'; + atrChartContainer.style.right = '0'; + atrChartContainer.style.borderTop = '1px solid #e0e0e0'; + + // 如果需要显示MACD,创建MACD容器 + let macdChartContainer = null; + let chanMacdChartContainer = null; + if (showMacd) { + // 仅显示新的 ChanMACD 图:让其占用原 MACD+ChanMACD 的整体高度 + // 新布局:主图(40%) → ChanMACD(30%) → 成交量(17.5%) → ATR(12.5%) + mainChartContainer.style.height = '40%'; + + // 隐藏旧 MACD 容器(不创建) + // 创建 ChanMACD 容器占据原 MACD+ChanMACD 高度(30%) + chanMacdChartContainer = document.createElement('div'); + chanMacdChartContainer.style.width = '100%'; + chanMacdChartContainer.style.height = '30%'; + chanMacdChartContainer.style.position = 'absolute'; + chanMacdChartContainer.style.top = '40%'; + chanMacdChartContainer.style.left = '0'; + chanMacdChartContainer.style.right = '0'; + chanMacdChartContainer.style.borderTop = '1px solid #e0e0e0'; + chanMacdChartContainer.style.zIndex = '10'; + // 水印:便于区分是新的 ChanMACD 子图 + const chanMacdWatermark = document.createElement('div'); + chanMacdWatermark.textContent = 'ChanMACD'; + chanMacdWatermark.style.position = 'absolute'; + chanMacdWatermark.style.top = '4px'; + chanMacdWatermark.style.left = '8px'; + chanMacdWatermark.style.fontSize = '11px'; + chanMacdWatermark.style.color = '#888'; + chanMacdWatermark.style.pointerEvents = 'none'; + chanMacdChartContainer.appendChild(chanMacdWatermark); + + // 成交量位于 ChanMACD 之下 + volumeChartContainer.style.top = '70%'; + volumeChartContainer.style.height = '17.5%'; + + // ATR 位于最底部 + atrChartContainer.style.top = '87.5%'; + atrChartContainer.style.height = '12.5%'; + } else { + // 不显示MACD时的高度 - 主图、成交量图和ATR图分配 + mainChartContainer.style.height = '55%'; // 主图占55% + volumeChartContainer.style.top = '55%'; + volumeChartContainer.style.height = '22.5%'; // 成交量图占22.5% + + atrChartContainer.style.top = '77.5%'; // ATR图从77.5%位置开始 + atrChartContainer.style.height = '22.5%'; // ATR图占22.5% + } + + container.appendChild(mainChartContainer); + container.appendChild(volumeChartContainer); + container.appendChild(atrChartContainer); + if (showMacd) { + // 只追加新的 ChanMACD 容器 + container.appendChild(chanMacdChartContainer); + } + + // 防止同步过程中的无限循环(实际同步由 bindSyncEvents 负责) + + // 创建统一的图表选项 + const createChartOptions = (showTimeScale = true, chartType = 'main') => { + // 根据图表类型确定高度 + let chartHeight; + if (chartType === 'main') { + chartHeight = mainChartContainer.clientHeight; + } else if (chartType === 'volume') { + chartHeight = volumeChartContainer.clientHeight; + } else if (chartType === 'atr') { + chartHeight = atrChartContainer.clientHeight; + } else if (chartType === 'macd') { + chartHeight = macdChartContainer ? macdChartContainer.clientHeight : 0; + } else if (chartType === 'chanmacd') { + chartHeight = chanMacdChartContainer ? chanMacdChartContainer.clientHeight : 0; + } else { + chartHeight = mainChartContainer.clientHeight; + } + + const baseOptions = { + width: mainChartContainer.clientWidth, + height: chartHeight, + layout: { + background: { color: '#ffffff' }, + textColor: '#333', + }, + grid: { + vertLines: { color: '#f0f0f0' }, + horzLines: { color: '#f0f0f0' }, + }, + crosshair: { + mode: LightweightCharts.CrosshairMode.Normal, + // 添加十字线工具提示本地化配置 + horzLine: { + labelVisible: true, + }, + vertLine: { + labelVisible: true, + // 自定义时间格式化 + labelFormatter: (time) => { + const selectedTimezone = $('#timezone').val(); + try { + const date = new Date(time * 1000); + if (symbolConfig.type === 'a_stock') { + // A股使用中国时区格式 + return date.toLocaleString('zh-CN', { + timeZone: 'Asia/Shanghai', + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit' + }); + } else { + return date.toLocaleString('zh-CN', { + timeZone: selectedTimezone, + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit' + }); + } + } catch (e) { + console.error('十字线时间格式化错误:', e); + return new Date(time * 1000).toLocaleString(); + } + }, + }, + }, + rightPriceScale: { + borderColor: '#ddd', + scaleMargins: { + top: 0.1, + bottom: 0.1, + }, + // 为标签留出更多空间,防止遮挡 + minimumWidth: 80, + }, + // 添加左边距配置 + leftPriceScale: { + visible: false, + }, + // 添加本地化选项,确保所有时间显示都使用选定的时区 + localization: { + timeFormatter: (time) => { + const selectedTimezone = $('#timezone').val(); + try { + const date = new Date(time * 1000); + if (symbolConfig.type === 'a_stock') { + // A股使用中国时区格式 + return date.toLocaleString('zh-CN', { + timeZone: 'Asia/Shanghai', + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit' + }); + } else { + return date.toLocaleString('zh-CN', { + timeZone: selectedTimezone, + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit' + }); + } + } catch (e) { + console.error('全局时间格式化错误:', e); + return new Date(time * 1000).toLocaleString(); + } + } + }, + timeScale: { + timeVisible: true, + secondsVisible: false, + visible: showTimeScale, + borderColor: '#ddd', + barSpacing: symbolConfig.type === 'a_stock' ? 6 : 10, + // 确保所有图表使用相同的边距设置 + rightOffset: 12, + // 移除可能影响拖动的固定边缘设置 + // fixLeftEdge: true, + // fixRightEdge: true, + lockVisibleTimeRangeOnResize: true, + tickMarkFormatter: (time) => { + const selectedTimezone = symbolConfig.type === 'a_stock' ? 'Asia/Shanghai' : $('#timezone').val(); + try { + // 使用完整的配置确保时区正确应用 + const date = new Date(time * 1000); + console.log('格式化时间:', time, '转换为:', date.toISOString(), '时区:', selectedTimezone); + + return date.toLocaleString('zh-CN', { + timeZone: selectedTimezone, + month: 'numeric', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + }); + } catch (e) { + console.error('时间格式化错误:', e); + // 如果时区格式化失败,返回简单格式 + return new Date(time * 1000).toLocaleString(); + } + }, + }, + }; + + // 根据交易对类型调整配置 + return adjustChartForSymbolType(baseOptions, symbolConfig); + }; + + // 创建主图表 + 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')); + + // 创建ATR图表 + const atrChart = LightweightCharts.createChart(atrChartContainer, createChartOptions(false, 'atr')); + + // 创建MACD图表(如果需要):仅创建新的 ChanMACD 图 + let macdChart = null; + let chanMacdChart = null; + if (showMacd) { + chanMacdChart = LightweightCharts.createChart(chanMacdChartContainer, createChartOptions(false, 'chanmacd')); + } + + // 创建主价格系列并设置数据(支持多种图表类型) + (function(){ + const klineType = ($('#klineType').val() || 'candlestick'); + // 先清空旧的主系列引用 + tvWidget.series.candleSeries = null; + tvWidget.series.lineSeries = null; + tvWidget.series.barSeries = null; + tvWidget.series.areaSeries = null; + tvWidget.series.baselineSeries = null; + tvWidget.series.renkoSeries = null; + tvWidget.series.heikinSeries = null; + + if (klineType === 'candlestick') { + const series = mainChart.addCandlestickSeries({ + upColor: '#28a745', + downColor: '#dc3545', + borderVisible: false, + wickUpColor: '#28a745', + wickDownColor: '#dc3545', + }); + series.setData(candles); + tvWidget.series.candleSeries = series; + } else if (klineType === 'renko') { + const series = mainChart.addCandlestickSeries({ + upColor: '#28a745', + downColor: '#dc3545', + borderVisible: false, + wickUpColor: '#28a745', + wickDownColor: '#dc3545', + }); + const bricks = buildRenkoFromCandles(candles); + series.setData(bricks); + tvWidget.series.renkoSeries = series; + } else if (klineType === 'heikin') { + const series = mainChart.addCandlestickSeries({ + upColor: '#28a745', + downColor: '#dc3545', + borderVisible: false, + wickUpColor: '#28a745', + wickDownColor: '#dc3545', + }); + const hk = buildHeikinFromCandles(candles); + series.setData(hk); + tvWidget.series.heikinSeries = series; + } else if (klineType === 'bar') { + const series = mainChart.addBarSeries({ + upColor: '#28a745', + downColor: '#dc3545', + thinBars: false + }); + series.setData(candles); + tvWidget.series.barSeries = series; + } else if (klineType === 'line') { + const series = mainChart.addLineSeries({ + color: '#2962FF', + lineWidth: 2, + crosshairMarkerVisible: true, + lastValueVisible: true, + priceLineVisible: true, + }); + const lineData = candles.map(c => ({ time: c.time, value: c.close })); + series.setData(lineData); + tvWidget.series.lineSeries = series; + } else if (klineType === 'area') { + const series = mainChart.addAreaSeries({ + topColor: 'rgba(41, 98, 255, 0.4)', + bottomColor: 'rgba(41, 98, 255, 0.0)', + lineColor: '#2962FF', + lineWidth: 2, + }); + const areaData = candles.map(c => ({ time: c.time, value: c.close })); + series.setData(areaData); + tvWidget.series.areaSeries = series; + } else if (klineType === 'baseline') { + const series = mainChart.addBaselineSeries({ + baseValue: { type: 'price', price: candles.length ? candles[candles.length - 1].close : 0 }, + topLineColor: '#26a69a', + bottomLineColor: '#ef5350', + topFillColor1: 'rgba(38, 166, 154, 0.28)', + topFillColor2: 'rgba(38, 166, 154, 0.05)', + bottomFillColor1: 'rgba(239, 83, 80, 0.28)', + bottomFillColor2: 'rgba(239, 83, 80, 0.05)' + }); + const baseData = candles.map(c => ({ time: c.time, value: c.close })); + series.setData(baseData); + tvWidget.series.baselineSeries = series; + } else if (klineType === 'klc') { + // KLC显示模式 - 使用蜡烛线显示KLC数据 + const series = mainChart.addCandlestickSeries({ + upColor: '#28a745', + downColor: '#dc3545', + borderVisible: false, + wickUpColor: '#28a745', + wickDownColor: '#dc3545', + }); + // 使用KLC数据创建蜡烛图 + const klcCandles = buildKLCFromAnalysis(currentData); + series.setData(klcCandles); + tvWidget.series.klcSeries = series; + } + })(); + ctx.symbolConfig = symbolConfig; + ctx.useSubSubPeriod = useSubSubPeriod; + ctx.useElementPeriod = useElementPeriod; + ctx.klinePeriodLabel = klinePeriodLabel; + ctx.candles = candles; + ctx.klineDataSource = klineDataSource; + ctx.container = container; + ctx.showMacd = showMacd; + ctx.showOriginalKline = showOriginalKline; + ctx.mainChartContainer = mainChartContainer; + ctx.volumeChartContainer = volumeChartContainer; + ctx.atrChartContainer = atrChartContainer; + ctx.macdChartContainer = macdChartContainer; + ctx.chanMacdChartContainer = chanMacdChartContainer; + ctx.mainChart = mainChart; + ctx.volumeChart = volumeChart; + ctx.atrChart = atrChart; + ctx.macdChart = macdChart; + ctx.chanMacdChart = chanMacdChart; + ctx.createChartOptions = createChartOptions; +} diff --git a/web/static/js/app/chart_view.js b/web/static/js/app/chart_view.js index 97c0d96..8b4f890 100644 --- a/web/static/js/app/chart_view.js +++ b/web/static/js/app/chart_view.js @@ -1,4 +1,46 @@ /* chart_view.js — split from chart.js */ + +/** 用尾部 N 根合并进已有 K 线(同 timestamp 覆盖,更新则追加) */ +function mergeKlineTail(existing, incoming) { + if (!Array.isArray(incoming) || !incoming.length) { + return Array.isArray(existing) ? existing : []; + } + if (!Array.isArray(existing) || !existing.length) { + return incoming.slice(); + } + const out = existing.slice(); + const barTs = (row) => { + if (row && row.timestamp != null && row.timestamp !== '') { + const n = Number(row.timestamp); + if (!Number.isNaN(n)) return n; + } + const t = row && row.date != null ? new Date(row.date).getTime() : NaN; + return Number.isNaN(t) ? null : t; + }; + for (let i = 0; i < incoming.length; i++) { + const row = incoming[i]; + const ts = barTs(row); + if (ts == null) continue; + let idx = -1; + const scanFrom = Math.max(0, out.length - 8); + for (let j = out.length - 1; j >= scanFrom; j--) { + if (barTs(out[j]) === ts) { + idx = j; + break; + } + } + if (idx >= 0) { + out[idx] = Object.assign({}, out[idx], row); + } else { + const lastTs = barTs(out[out.length - 1]); + if (lastTs == null || ts > lastTs) { + out.push(row); + } + } + } + return out; +} + function updateChart(options) { options = options || {}; // 只显示旋转加载图标 @@ -13,7 +55,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() || ''; @@ -47,9 +89,88 @@ function updateChart(options) { if (options.fromAutoRefresh && window._analyzeXhr && window._analyzeXhr.readyState !== 4) { try { window._analyzeXhr.abort(); } catch (e) {} } + + // 请求发出前冻结视窗(与自动刷新同一套;避免等响应时/setData 后 logical 索引漂移) + try { + if (tvWidget && tvWidget.mainChart) { + window._preserveViewOnRefresh = captureChartViewState(tvWidget.mainChart); + const prev = currentData && ( + ($('#subSubPeriodKline').is(':checked') && currentData.sub_sub_kline_data) || + ($('#elementPeriodKline').is(':checked') && currentData.element_kline_data) || + currentData.kline_data + ); + window._preserveViewBarCount = Array.isArray(prev) ? prev.length : 0; + console.log('📌 刷新前冻结视窗 bars=', window._preserveViewBarCount, window._preserveViewOnRefresh); + } + } catch (e) { + window._preserveViewOnRefresh = null; + window._preserveViewBarCount = 0; + } + + const requestId = ++lastRequestId; + const chartsReady = !!(tvWidget && tvWidget.state && tvWidget.state.isInitialized && tvWidget.mainChart); + const hasBaseline = !!(currentData && Array.isArray(currentData.kline_data) && currentData.kline_data.length); + const baselineSymbol = (currentData && currentData.symbol) || window._lastChartSymbol || ''; + // 自动刷新常态:只拉最近 2 根;换币对后基线不一致则禁止尾部合并(否则会叠旧缠论) + // fullAnalyze(约每 1 分钟)走全量 analyze 更新缠论 + const useRecentTail = !!( + options.fromAutoRefresh && + !options.fullAnalyze && + chartsReady && + hasBaseline && + baselineSymbol && + baselineSymbol === symbol + ); + + if (useRecentTail) { + console.log('自动刷新 → /api/klines/recent limit=2'); + window._analyzeXhr = $.ajax({ + url: '/api/klines/recent', + data: { + symbol: symbol, + timeframe: timeframe, + limit: 2, + element_timeframe: elementTimeframe || undefined, + sub_sub_timeframe: subSubTimeframe || undefined + }, + success: function(partial) { + $('#refreshLoadingSpinner').hide(); + if (requestId !== lastRequestId) return; + if (!partial || !Array.isArray(partial.kline_data)) { + console.warn('recent 响应无效,回退全量 analyze'); + updateChart({ incremental: true, reason: 'recent-fallback' }); + return; + } + currentData.kline_data = mergeKlineTail(currentData.kline_data, partial.kline_data); + if (Array.isArray(partial.element_kline_data)) { + currentData.element_kline_data = mergeKlineTail( + currentData.element_kline_data, partial.element_kline_data + ); + if (partial.element_timeframe) { + currentData.element_timeframe = partial.element_timeframe; + } + } + if (Array.isArray(partial.sub_sub_kline_data)) { + currentData.sub_sub_kline_data = mergeKlineTail( + currentData.sub_sub_kline_data, partial.sub_sub_kline_data + ); + if (partial.sub_sub_timeframe) { + currentData.sub_sub_timeframe = partial.sub_sub_timeframe; + } + } + refreshChart(currentData, { incremental: true, skipTables: true }); + }, + error: function(jqXHR, textStatus, errorThrown) { + $('#refreshLoadingSpinner').hide(); + if (textStatus === 'abort') return; + console.warn('recent 失败,回退全量 analyze:', errorThrown); + updateChart({ incremental: true, reason: 'recent-error-fallback' }); + } + }); + return; + } - // 发送请求 - const requestId = ++lastRequestId; // 标记本次请求 + // 手动 / 首拉:全量 analyze window._analyzeXhr = $.ajax({ url: '/api/analyze', data: { @@ -62,8 +183,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) { // 隐藏加载图标 @@ -75,18 +196,31 @@ function updateChart(options) { } // 保存当前数据 + const prevSymbol = (currentData && currentData.symbol) || window._lastChartSymbol || ''; if (currentData) { // 覆盖前断开旧引用,帮助GC尽快回收 delete currentData.original_kline_data; delete currentData.original_macd; } currentData = data; + window._lastChartSymbol = symbol; + window._lastFullAnalyzeAt = Date.now(); + if (typeof renderWyckoffCycleSummary === 'function') { + renderWyckoffCycleSummary(); + } - refreshChart(data, { - incremental: options.incremental !== undefined - ? !!options.incremental - : !!options.fromAutoRefresh - }); + // 有图则增量;笔/段/中枢/结构区只在全量 init 绘制 + // 换币对 / 手动分析 / 结构区:必须全量重建,否则会残留旧币对叠层 + const ready = !!(tvWidget && tvWidget.state && tvWidget.state.isInitialized && tvWidget.mainChart); + const structureZonesOn = $('#showMainStructureZone').is(':checked'); + const symbolChanged = !!(prevSymbol && prevSymbol !== symbol); + let wantIncremental = options.incremental !== undefined + ? !!options.incremental + : (ready || !!options.fromAutoRefresh); + if (structureZonesOn || options.fullAnalyze || symbolChanged || options.incremental === false) { + wantIncremental = false; + } + refreshChart(data, { incremental: wantIncremental }); }, error: function(jqXHR, textStatus, errorThrown) { // 隐藏加载图标 @@ -117,49 +251,113 @@ function captureChartViewState(chart) { }; } -function restoreChartViewState(charts, viewState) { +/** 将可见时间窗口限制在真实 K 线范围内,避免 to 落在右侧空白区导致锚到最右 */ +function clampVisibleRangeToBarTimes(vr, firstTime, lastTime) { + if (!vr || vr.from === undefined || vr.to === undefined) return vr; + if (firstTime == null || lastTime == null) return vr; + const f = Number(firstTime); + const l = Number(lastTime); + if (!isFinite(f) || !isFinite(l)) return vr; + let from = Number(vr.from); + let to = Number(vr.to); + const span = Math.max(1, to - from); + if (to > l) { + to = l; + from = to - span; + } + if (from < f) { + from = f; + to = from + span; + } + return { from: from, to: to }; +} + +/** 尾部合并时用 update 代替 setData,避免 LWC 重置滚动位置 */ +function applySeriesDataTail(series, points, tailOnly) { + if (!series || typeof series.setData !== 'function' || !Array.isArray(points) || !points.length) { + return; + } + if (tailOnly && typeof series.update === 'function' && points.length > 2) { + points.slice(-4).forEach(function (p) { + try { series.update(p); } catch (e) {} + }); + return; + } + series.setData(points); +} + +function restoreChartViewState(charts, viewState, options) { + // 全量重建备用:先缩放,再位置;不要在位置前写 rightOffset(会右边缘锚定) if (!viewState || !Array.isArray(charts) || charts.length === 0) return; + options = options || {}; + const preferTime = !!options.preferTime; const validCharts = charts.filter(c => c && c.timeScale); if (validCharts.length === 0) return; + const mainChart = validCharts[0]; validCharts.forEach(c => { try { - const optionsPatch = {}; - if (typeof viewState.barSpacing === 'number') optionsPatch.barSpacing = viewState.barSpacing; - if (typeof viewState.rightOffset === 'number') optionsPatch.rightOffset = viewState.rightOffset; - if (Object.keys(optionsPatch).length) { - c.timeScale().applyOptions(optionsPatch); + if (typeof viewState.barSpacing === 'number') { + c.timeScale().applyOptions({ barSpacing: viewState.barSpacing }); } } catch (e) {} }); let restored = false; - // 优先按逻辑范围恢复(对新数据更稳健) - if (viewState.logicalRange && viewState.logicalRange.from !== undefined && viewState.logicalRange.to !== undefined) { - validCharts.forEach(c => { - try { - c.timeScale().setVisibleLogicalRange(viewState.logicalRange); + const syncFromMain = function () { + try { + const lrNow = mainChart.timeScale().getVisibleLogicalRange(); + if (lrNow) { + validCharts.forEach(c => { + try { c.timeScale().setVisibleLogicalRange(lrNow); } catch (e) {} + }); restored = true; - } catch (e) {} - }); + } + } catch (e) {} + }; + + if (typeof viewState.scrollPosition === 'number') { + try { + mainChart.timeScale().scrollToPosition(viewState.scrollPosition, false); + syncFromMain(); + } catch (e) {} } - // 逻辑范围失败时,回退到时间可见范围 - if (!restored && viewState.visibleRange && viewState.visibleRange.from !== undefined && viewState.visibleRange.to !== undefined) { + const tryVisibleRange = function () { + if (!viewState.visibleRange || viewState.visibleRange.from === undefined || viewState.visibleRange.to === undefined) { + return false; + } validCharts.forEach(c => { try { c.timeScale().setVisibleRange(viewState.visibleRange); restored = true; } catch (e) {} }); - } + return restored; + }; - // 最后回退到滚动位置 - if (!restored && typeof viewState.scrollPosition === 'number') { + const tryLogicalRange = function () { + if (!viewState.logicalRange || viewState.logicalRange.from === undefined || viewState.logicalRange.to === undefined) { + return false; + } validCharts.forEach(c => { - try { c.timeScale().scrollToPosition(viewState.scrollPosition, false); } catch (e) {} + try { + c.timeScale().setVisibleLogicalRange(viewState.logicalRange); + restored = true; + } catch (e) {} }); + return restored; + }; + + if (!restored) { + if (preferTime) { + tryVisibleRange(); + if (!restored) tryLogicalRange(); + } else { + tryLogicalRange(); + if (!restored) tryVisibleRange(); + } } } // 初始化图表 diff --git a/web/static/js/app/macd_ui.js b/web/static/js/app/macd_ui.js index f32f491..6f94d4b 100644 --- a/web/static/js/app/macd_ui.js +++ b/web/static/js/app/macd_ui.js @@ -85,33 +85,24 @@ $(document).on('change', '#showMainBiZs', function() { $(document).on('change', '#showMainStructureZone', function() { const on = $('#showMainStructureZone').is(':checked'); console.log('结构区切换为:', on); - // 勾选后才向服务器请求多周期结构区数据;取消勾选仅重绘,不重复拉取 + // 勾选后才向服务器请求多周期结构区数据;结构区叠层只在全量 init 里绘制,必须 incremental:false if (on) { - updateChart(); + updateChart({ incremental: false }); } else { updateChartDisplay(); } }); -// 威科夫主开关:勾选才请求;子项仅本地重绘 -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..fb6d4f9 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() { }); } -// 设置默认时间范围 +// 设置默认时间范围:最近 1 个月 function setDefaultTimeRange() { const now = new Date(); - const oneDayAgo = new Date(now.getTime() - (24 * 60 * 60 * 1000)); + const daysBack = 30; + 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) { @@ -244,6 +493,9 @@ $(document).ready(function() { let autoRefreshTimer = null; let nextRefreshTime = null; let autoRefreshTick = 0; +/** 自动刷新时,缠论全量重算间隔(毫秒);时间戳见 window._lastFullAnalyzeAt */ +const AUTO_FULL_ANALYZE_MS = 60 * 1000; + // 初始化自动刷新功能 function initAutoRefresh() { // 监听自动刷新勾选框变化 @@ -270,10 +522,10 @@ function startAutoRefresh() { stopAutoRefresh(); // 获取刷新频率(分钟) - const interval = parseFloat($('#refreshInterval').val()) || 5; + const interval = parseFloat($('#refreshInterval').val()) || (5 / 60); const intervalMs = interval * 60 * 1000; - console.log(`开始自动刷新,频率: ${interval}分钟 (${intervalMs}毫秒)`); + console.log(`开始自动刷新,频率: ${interval}分钟 (${intervalMs}毫秒);缠论全量每 ${AUTO_FULL_ANALYZE_MS / 1000}s`); // 计算下次刷新时间 nextRefreshTime = new Date(Date.now() + intervalMs); @@ -282,16 +534,27 @@ function startAutoRefresh() { // 启动定时器 autoRefreshTick = 0; autoRefreshTimer = setInterval(function() { - // 更新结束时间为当前时间 + // 更新结束时间显示(仅 UI) updateEndTimeToNow(); - // 多数周期增量更新;每隔若干次全量重建以刷新笔/段/中枢(dispose 已防泄漏) autoRefreshTick += 1; - const fullRebuild = (autoRefreshTick % 6) === 0; - updateChart({ - fromAutoRefresh: true, - incremental: !fullRebuild - }); + const now = Date.now(); + const lastFull = window._lastFullAnalyzeAt || 0; + const needFullAnalyze = !lastFull || (now - lastFull >= AUTO_FULL_ANALYZE_MS); + // 常态:/api/klines/recent 合并尾部 K;满 1 分钟:全量 /api/analyze 刷新缠论 + if (needFullAnalyze) { + console.log('自动刷新 → 全量缠论 analyze(距上次', lastFull ? Math.round((now - lastFull) / 1000) + 's' : '首次', ')'); + updateChart({ + fromAutoRefresh: true, + fullAnalyze: true, + incremental: true + }); + } else { + updateChart({ + fromAutoRefresh: true, + incremental: true + }); + } // 更新下次刷新时间 nextRefreshTime = new Date(Date.now() + intervalMs); @@ -535,15 +798,26 @@ function refreshChart(data, options) { // 自动刷新:增量更新,避免每次销毁/重建 Lightweight Charts if (preferIncremental && chartsReady) { try { - if (tvWidget.mainChart) { + // 数据到达后再冻结视窗(比请求发出时更准;避免用到过期 scroll) + if (tvWidget.mainChart && typeof captureChartViewState === 'function') { try { - window._pendingRestoreView = captureChartViewState(tvWidget.mainChart); + window._preserveViewOnRefresh = captureChartViewState(tvWidget.mainChart); + const prev = currentData && ( + ($('#subSubPeriodKline').is(':checked') && currentData.sub_sub_kline_data) || + ($('#elementPeriodKline').is(':checked') && currentData.element_kline_data) || + currentData.kline_data + ); + window._preserveViewBarCount = Array.isArray(prev) ? prev.length : 0; } catch (e) { - window._pendingRestoreView = null; + window._preserveViewOnRefresh = null; + window._preserveViewBarCount = 0; } } - updateTradingViewData(); - updateTables(data); + updateTradingViewData({ tailOnly: !!options.skipTables }); + // recent-tail 刷新结构未变,跳过表格重绘以提速 + if (!options.skipTables) { + updateTables(data); + } if (currentData && currentData.ema52_dict) { updateEMA52Display(currentData); } @@ -555,7 +829,7 @@ function refreshChart(data, options) { // 保存当前缩放(barSpacing)和滚动位置(scrollPosition)到 window // tvWidget 会在 initTradingView 内被重建,所以必须存到 window 上 - if (tvWidget && tvWidget.mainChart) { + if (!window._pendingRestoreView && tvWidget && tvWidget.mainChart) { try { window._pendingRestoreView = captureChartViewState(tvWidget.mainChart); console.log('📌 保存图表视图:', JSON.stringify(window._pendingRestoreView)); @@ -563,6 +837,8 @@ function refreshChart(data, options) { console.warn('保存图表视图失败:', e); window._pendingRestoreView = null; } + } else if (window._pendingRestoreView) { + console.log('📌 使用已保存图表视图:', JSON.stringify(window._pendingRestoreView)); } initTradingView($('#symbol').val(), $('#timeframe').val()); @@ -596,14 +872,14 @@ $('#showElementMacdDiv').change(function() { refreshChartOnly(); }); -// 绑定分型类型显示开关 +// 绑定分型类型显示开关(与笔一致:全量重建,避免增量路径标记未对齐) $('#showKlcFxType').change(function() { - refreshChartOnly(); + updateChartDisplay(); }); // 绑定小周期分型显示开关 $('#showElementKlcFxType').change(function() { - refreshChart(currentData); + updateChartDisplay(); }); @@ -616,10 +892,7 @@ $('#showElementBollinger').change(function() { updateChartDisplay(); }); -// 绑定K线周期切换 -$('input[name="klinePeriod"]').change(function() { - refreshChart(currentData); -}); +// K线周期切换由 macd_ui.js 统一走 updateChartDisplay(勿再绑 refreshChart,会重复且易漏对齐) // 绑定主图U显示开关 $('#toggleUOnMain').change(function() { diff --git a/web/static/js/charts.js b/web/static/js/charts.js index 53ac532..835efdf 100644 --- a/web/static/js/charts.js +++ b/web/static/js/charts.js @@ -4,6 +4,16 @@ window.App.Charts = (function() { // 依赖 Indicators const Indicators = (window.App && window.App.Indicators) || {}; + function sanitizeLinePoints(points) { + if (!Array.isArray(points)) return []; + return points.filter(function (p) { + return p && p.time != null && p.value != null && + isFinite(Number(p.time)) && isFinite(Number(p.value)); + }).map(function (p) { + return { time: Math.floor(Number(p.time)), value: Number(p.value) }; + }); + } + function addMovingAveragesToChart(candleData) { if (!window.tvWidget || !tvWidget.mainChart || !candleData || candleData.length === 0) return; if (!window.movingAverages) return; @@ -21,6 +31,8 @@ window.App.Charts = (function() { try { const maData = Indicators.calculateMA(candleData, maConfig.type, maConfig.length, maConfig.source); const smoothedData = maConfig.smoothType !== 'none' ? (window.applySmoothToMA ? window.applySmoothToMA(maData, maConfig.smoothType, maConfig.smoothLength) : maData) : maData; + const cleanData = sanitizeLinePoints(smoothedData); + if (!cleanData.length) return; const maSeries = tvWidget.mainChart.addLineSeries({ color: maConfig.color, lineWidth: maConfig.lineWidth || 2, @@ -30,8 +42,8 @@ window.App.Charts = (function() { priceLineVisible: false, crosshairMarkerVisible: true, }); - maSeries.setData(smoothedData); - maConfig.data = smoothedData; + maSeries.setData(cleanData); + maConfig.data = cleanData; tvWidget.series.maSeries.push(maSeries); } catch(e) {} }); @@ -51,12 +63,16 @@ window.App.Charts = (function() { if (!bbConfig.visible) return; try { const bbData = Indicators.calculateBB(candleData, bbConfig.length, bbConfig.upperMultiplier, bbConfig.lowerMultiplier, bbConfig.source); + const upper = sanitizeLinePoints(bbData.map(item => ({ time: item.time, value: item.upper }))); + const middle = sanitizeLinePoints(bbData.map(item => ({ time: item.time, value: item.middle }))); + const lower = sanitizeLinePoints(bbData.map(item => ({ time: item.time, value: item.lower }))); + if (!upper.length || !middle.length || !lower.length) return; const upperSeries = tvWidget.mainChart.addLineSeries({ color: bbConfig.upperColor, lineWidth: bbConfig.lineWidth || 2, lineStyle: bbConfig.lineStyle || 0, lastValueVisible: false, priceLineVisible: false, crosshairMarkerVisible: true }); const middleSeries = tvWidget.mainChart.addLineSeries({ color: bbConfig.middleColor, lineWidth: bbConfig.lineWidth || 2, lineStyle: bbConfig.lineStyle || 0, lastValueVisible: false, priceLineVisible: false, crosshairMarkerVisible: true }); const lowerSeries = tvWidget.mainChart.addLineSeries({ color: bbConfig.lowerColor, lineWidth: bbConfig.lineWidth || 2, lineStyle: bbConfig.lineStyle || 0, lastValueVisible: false, priceLineVisible: false, crosshairMarkerVisible: true }); - upperSeries.setData(bbData.map(item => ({ time: item.time, value: item.upper }))); - middleSeries.setData(bbData.map(item => ({ time: item.time, value: item.middle }))); - lowerSeries.setData(bbData.map(item => ({ time: item.time, value: item.lower }))); + upperSeries.setData(upper); + middleSeries.setData(middle); + lowerSeries.setData(lower); bbConfig.data = bbData; tvWidget.series.bbSeries.push(upperSeries, middleSeries, lowerSeries); } catch(e) {} diff --git a/web/templates/index.html b/web/templates/index.html index 20df06f..4dc2ab0 100644 --- a/web/templates/index.html +++ b/web/templates/index.html @@ -22,8 +22,8 @@ - - + + - - - - - - - - - - - - + + + + + + + + + + + + + + + + + +
diff --git a/web/templates/wyckoff_crypto.html b/web/templates/wyckoff_crypto.html new file mode 100644 index 0000000..7c3eb34 --- /dev/null +++ b/web/templates/wyckoff_crypto.html @@ -0,0 +1,1045 @@ + + + + + +Crypto Wyckoff Screener + + + +
+
+

Crypto Wyckoff Screener

+
加载中…
+
+
+ ← 主站 + · 周期组合 · UTC+8 · tip +
+
+
+ + + + + + + + + + + +
+
+ + + + + + + +
+
+
+

扫描

+
+ + + + + + + + +
Symbol决策高周期中阶段低事件得分一致
+
+ +
+
+
+

+ K 线 + +

+
+ +
+
+
选择左侧标的查看 K 线
+
+
+
+
+

详情

+
点选左侧一行
+
+
+ + + diff --git a/web/tests/test_analyze_contract.py b/web/tests/test_analyze_contract.py index 13bc213..7e6ff69 100644 --- a/web/tests/test_analyze_contract.py +++ b/web/tests/test_analyze_contract.py @@ -64,11 +64,33 @@ def test_analyze_route_registered(): rules = {r.rule for r in app.url_map.iter_rules()} assert "/api/analyze" in rules + assert "/api/klines/recent" in rules assert "/api/chart_metadata" in rules assert "/" in rules assert "/chan_tv" in rules +def test_klines_recent_returns_tail_only(): + from app import app + + df = make_ohlcv(n=30) + # analyze 蓝图 star-import 后绑定在 api.analyze 命名空间 + with patch("api.analyze.get_kl_data", return_value=df): + client = app.test_client() + resp = client.get( + "/api/klines/recent", + query_string={"symbol": "BTC/USDT:USDT", "timeframe": "5m", "limit": 2}, + ) + assert resp.status_code == 200 + body = resp.get_json() + assert body.get("partial") is True + assert body.get("limit") == 2 + assert isinstance(body.get("kline_data"), list) + assert len(body["kline_data"]) == 2 + assert "bi_list" not in body + assert "wyckoff" not in body + + def test_contract_keys_stable(): assert "bi_list" in CONTRACT_KEYS and "seg_list" in CONTRACT_KEYS for k in ("kline_data", "macd", "zs_list", "bsp_list", "chan_macd"): @@ -127,11 +149,13 @@ def test_analyze_http_contract_with_mocked_kl(): assert payload is not None and "error" not in payload missing = [k for k in CONTRACT_KEYS if k not in payload] assert not missing, f"missing contract keys: {missing}" - assert "wyckoff" not in payload + assert "wyckoff" in payload + for k in WYCKOFF_KEYS: + assert k in payload["wyckoff"], f"missing wyckoff key: {k}" -def test_analyze_http_wyckoff_opt_in(): - """include_wyckoff=1 时响应含 wyckoff 约定键;默认不返回。""" +def test_analyze_http_wyckoff_can_opt_out(): + """include_wyckoff=0 时可显式跳过威科夫。""" from app import app from services.runtime import add_indicators @@ -148,19 +172,49 @@ def test_analyze_http_wyckoff_opt_in(): "symbol": "BTC/USDT:USDT", "timeframe": "5m", "timezone": "Asia/Shanghai", - "include_wyckoff": 1, + "include_wyckoff": 0, }, ) assert resp.status_code == 200, resp.data[:500] payload = resp.get_json() - assert payload is not None and "wyckoff" in payload - w = payload["wyckoff"] - for k in WYCKOFF_KEYS: - assert k in w, f"missing wyckoff key: {k}" + assert payload is not None and "wyckoff" not in payload + + +def test_analyze_http_wyckoff_for_three_timeframes(): + """主/次/次次均返回各自 wyckoff 载荷。""" + from app import app + from services.runtime import add_indicators + + df = add_indicators(make_ohlcv(300)) + df = df.copy() + if "timestamp" not in df.columns: + df["timestamp"] = (pd.to_datetime(df["date"]).astype("int64") // 10**6).astype("int64") + + with patch("api.analyze.get_kl_data", return_value=df): + client = app.test_client() + resp = client.get( + "/api/analyze", + query_string={ + "symbol": "BTC/USDT:USDT", + "timeframe": "4h", + "element_timeframe": "2h", + "sub_sub_timeframe": "1h", + "timezone": "Asia/Shanghai", + }, + ) + assert resp.status_code == 200, resp.data[:500] + payload = resp.get_json() + assert payload is not None and "error" not in payload + assert "wyckoff" in payload + assert "element_wyckoff" in payload + assert "sub_sub_wyckoff" in payload + for key in ("wyckoff", "element_wyckoff", "sub_sub_wyckoff"): + for k in WYCKOFF_KEYS: + assert k in payload[key], f"missing {k} in {key}" def test_analyze_http_wyckoff_skipped_when_elements_only(): - """elements_only=true 时即使 include_wyckoff=1 也不返回 wyckoff。""" + """elements_only=true 时不返回 wyckoff。""" from app import app from services.runtime import add_indicators @@ -179,7 +233,6 @@ def test_analyze_http_wyckoff_skipped_when_elements_only(): "element_timeframe": "1m", "timezone": "Asia/Shanghai", "elements_only": "true", - "include_wyckoff": 1, }, ) assert resp.status_code == 200, resp.data[:500] diff --git a/web/tests/test_wyckoff_crypto_routes.py b/web/tests/test_wyckoff_crypto_routes.py new file mode 100644 index 0000000..44614bd --- /dev/null +++ b/web/tests/test_wyckoff_crypto_routes.py @@ -0,0 +1,123 @@ +"""ECR-009: page/API smoke without requiring live provider during assert.""" + +from __future__ import annotations + +import os +import sys + +import pytest + +# Ensure repo root + web on path like app.py +_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +_WEB = os.path.join(_ROOT, "web") +for p in (_ROOT, _WEB): + if p not in sys.path: + sys.path.insert(0, p) + +os.environ.setdefault("CRYPTO_WYCKOFF_DISABLE", "1") + + +@pytest.fixture() +def client(): + from app import create_app + + app = create_app() + app.config["TESTING"] = True + with app.test_client() as c: + yield c + + +def test_wyckoff_crypto_page_ok(client): + resp = client.get("/wyckoff_crypto") + assert resp.status_code == 200 + assert b"Crypto Wyckoff Screener" in resp.data + assert b"fCombo" in resp.data + assert b"chartCanvas" in resp.data + + +def test_wyckoff_crypto_meta_ok(client): + resp = client.get("/api/wyckoff_crypto/meta") + assert resp.status_code == 200 + data = resp.get_json() + assert "engine_version" in data + assert data.get("combo", {}).get("id") == "h8_4_1" + assert data["combo"]["low"] == "1h" + ids = {c["id"] for c in data.get("combos") or []} + assert "h8_4_1" in ids and "d_w_m" in ids + + +def test_wyckoff_crypto_scan_ok(client): + resp = client.get("/api/wyckoff_crypto/scan?limit=5&combo_id=h8_4_1") + assert resp.status_code == 200 + data = resp.get_json() + assert "rows" in data + assert data.get("combo", {}).get("id") == "h8_4_1" + + +def test_wyckoff_crypto_klines_bad_request(client): + resp = client.get("/api/wyckoff_crypto/klines") + assert resp.status_code == 400 + + +def test_wyckoff_crypto_klines_ok(client): + resp = client.get( + "/api/wyckoff_crypto/klines?symbol=BTC/USDT:USDT&tf=1h&limit=10&combo_id=h8_4_1" + ) + assert resp.status_code == 200 + data = resp.get_json() + assert "items" in data + assert data.get("tf") == "1h" + assert data.get("intraday") is True + if data["items"]: + assert "datetime" in data["items"][0] + assert "ts" in data["items"][0] + assert "T" in data["items"][0]["datetime"] + assert "+08:00" in data["items"][0]["datetime"] + + +def test_wyckoff_crypto_klines_bad_limit_ok(client): + resp = client.get( + "/api/wyckoff_crypto/klines?symbol=BTC/USDT:USDT&tf=1h&limit=abc&combo_id=h8_4_1" + ) + assert resp.status_code == 200 + + +def test_wyckoff_crypto_overlay_ok(client): + resp = client.get( + "/api/wyckoff_crypto/overlay?symbol=BTC/USDT:USDT&tf=1h&bars=60&combo_id=h8_4_1" + ) + assert resp.status_code == 200 + data = resp.get_json() + assert "phases" in data + assert "events" in data + + +def test_combos_add_and_list(client, tmp_path, monkeypatch): + from crypto_wyckoff import combos as cm + + monkeypatch.setattr(cm, "_COMBOS_FILE", tmp_path / "combos.json") + monkeypatch.setattr(cm, "_cache", None) + + resp = client.get("/api/wyckoff_crypto/combos") + assert resp.status_code == 200 + assert len(resp.get_json()["combos"]) >= 2 + + bad = client.post( + "/api/wyckoff_crypto/combos", + json={"high": "1h", "mid": "4h", "low": "8h"}, + ) + assert bad.status_code == 400 + + ok = client.post( + "/api/wyckoff_crypto/combos", + json={"high": "12h", "mid": "4h", "low": "1h", "label": "12h/4h/1h"}, + ) + assert ok.status_code == 200 + cid = ok.get_json()["combo"]["id"] + assert cid == "12h_4h_1h" + + deleted = client.delete(f"/api/wyckoff_crypto/combos/{cid}") + assert deleted.status_code == 200 + + builtin = client.delete("/api/wyckoff_crypto/combos/h8_4_1") + assert builtin.status_code == 400