"""威科夫引擎单测:合成震荡箱 + Spring/SOS + VP POC(ECR-004 收紧)。""" from __future__ import annotations import sys from pathlib import Path import numpy as np import pandas as pd ROOT = Path(__file__).resolve().parents[1] sys.path.insert(0, str(ROOT)) from chanlun.analysis.wyckoff import analyze_wyckoff # noqa: E402 from chanlun.analysis.wyckoff.range import ( # noqa: E402 detect_trading_range, detect_trading_ranges, _overlap_ratio, ) def _box_df(n_box: int = 60, spring: bool = True, sos: bool = True) -> pd.DataFrame: """构造明显箱体:40~60,前 20 根下跌趋势,可选假破与上破。""" rng = np.random.default_rng(7) rows = [] t0 = pd.Timestamp("2024-06-01", tz="UTC") price = 50.0 # 进入箱体前下跌 for i in range(20): price -= 0.3 + rng.random() * 0.1 o, c = price + 0.2, price h, l = max(o, c) + 0.15, min(o, c) - 0.15 rows.append((t0 + pd.Timedelta(minutes=5 * i), o, h, l, c, 100 + rng.random() * 20)) # 箱体 40-60 lo, hi = 40.0, 60.0 for i in range(n_box): c = lo + (hi - lo) * (0.3 + 0.4 * rng.random()) o = c + rng.normal(0, 0.5) h = min(hi + 0.5, max(o, c) + abs(rng.normal(0.5, 0.2))) l = max(lo - 0.5, min(o, c) - abs(rng.normal(0.5, 0.2))) if i % 7 == 0: h = hi - 0.1 if i % 7 == 3: l = lo + 0.1 rows.append( ( t0 + pd.Timedelta(minutes=5 * (20 + i)), o, h, l, c, 80 + rng.random() * 40, ) ) base = 20 + n_box if spring: rows.append( ( t0 + pd.Timedelta(minutes=5 * base), 42.0, 43.0, 37.0, 41.5, 90.0, ) ) base += 1 if sos: rows.append( ( t0 + pd.Timedelta(minutes=5 * base), 58.0, 66.0, 57.0, 64.0, 220.0, ) ) base += 1 rows.append( ( t0 + pd.Timedelta(minutes=5 * base), 62.0, 63.0, 59.5, 61.0, 70.0, ) ) return pd.DataFrame(rows, columns=["date", "open", "high", "low", "close", "volume"]) def test_wyckoff_detects_range_and_events(): """Test C:旧接口兼容 — 顶层字段仍在,且 cycles[0] 为 ACTIVE 镜像。""" df = _box_df() out = analyze_wyckoff(df, lookback=200) assert out["trading_range"] is not None tr = out["trading_range"] assert 38.0 <= tr["low"] <= 42.0 assert 58.0 <= tr["high"] <= 62.0 # 起点不应落入前 20 根下跌段(允许少量 overlap) box_start = df["date"].iloc[20] assert tr["start_time"] is not None start_ts = pd.Timestamp(tr["start_time"]) assert start_ts >= box_start - pd.Timedelta(minutes=5 * 8) types = {e["type"] for e in out["events"]} assert "Spring" in types assert "SOS" in types assert out["bias"] in ("accumulation", "distribution", "unknown") assert len(out["phases"]) >= 3 keys = [(p["start_time"], p["end_time"]) for p in out["phases"]] assert len(keys) == len(set(keys)), "phases must not share identical start/end" # cycles 契约 assert len(out.get("cycles") or []) >= 1 c0 = out["cycles"][0] assert c0["status"] == "ACTIVE" assert c0["id"] == 0 assert c0["trading_range"]["start_time"] == out["trading_range"]["start_time"] assert c0["trading_range"]["high"] == out["trading_range"]["high"] assert "confidence" in c0 and "overall" in c0["confidence"] assert "period" in c0 and c0["period"]["bars"] > 0 def test_phase_c_when_spring_eaten_by_box_low(): """箱沿吃掉 Spring 最低点时,仍应靠结构次低检出 Spring,并有阶段 C。""" rng = np.random.default_rng(1) t0 = pd.Timestamp("2024-06-01", tz="UTC") rows = [] box_lo, box_hi = 40.0, 60.0 for i in range(60): c = box_lo + (box_hi - box_lo) * (0.3 + 0.4 * rng.random()) o = c h = min(box_hi, max(o, c) + 1) l = max(box_lo, min(o, c) - 1) if i % 7 == 0: h = box_hi - 0.2 if i % 7 == 3: l = box_lo + 0.2 rows.append((t0 + pd.Timedelta(hours=4 * i), o, h, l, c, 100.0)) # 箱内假破:最低点 38,收回到 43 rows[45] = (rows[45][0], 42.0, 45.0, 38.0, 43.0, 80.0) for j in range(3): rows.append((t0 + pd.Timedelta(hours=4 * (60 + j)), 61.0, 63.0, 60.5, 62.0, 150.0)) df = pd.DataFrame(rows, columns=["date", "open", "high", "low", "close", "volume"]) # 模拟 4h:TR.low 已吃进 Spring tr = { "abs_start_idx": 0, "abs_end_idx": 59, "abs_scan_end_idx": len(df) - 1, "high": 60.0, "low": 38.0, "mid": 49.0, "tol": 1.0, } from chanlun.analysis.wyckoff.events import detect_bias_and_events, build_phases bias, ev, _ = detect_bias_and_events(df, tr) ph = build_phases(df, tr, bias, ev) assert "Spring" in {e["type"] for e in ev} assert "C" in {p["phase"] for p in ph} assert bias == "accumulation" def test_range_scoring_skips_pretrend(): df = _box_df(spring=False, sos=False) tr = detect_trading_range(df, lookback=200) assert tr is not None assert tr["abs_start_idx"] >= 12 # 不应从 bar 0 吞掉整段下跌 def test_range_anchored_rejects_full_trend(): """整段趋势+末端箱:硬锚数据起点应因过宽回落,仍能搜出末端箱。""" rng = np.random.default_rng(0) t0 = pd.Timestamp("2024-06-01", tz="UTC") rows = [] price = 100.0 for i in range(200): price += 0.4 + rng.random() * 0.2 o, c = price - 0.1, price h, l = max(o, c) + 0.3, min(o, c) - 0.3 rows.append((t0 + pd.Timedelta(hours=i), o, h, l, c, 100.0)) lo, hi = price - 5, price + 5 for i in range(80): c = lo + (hi - lo) * (0.3 + 0.4 * rng.random()) o = c + rng.normal(0, 0.3) h = min(hi + 0.5, max(o, c) + 0.4) l = max(lo - 0.5, min(o, c) - 0.4) if i % 8 == 0: h = hi - 0.1 if i % 8 == 3: l = lo + 0.1 rows.append((t0 + pd.Timedelta(hours=200 + i), o, h, l, c, 90.0)) df = pd.DataFrame(rows, columns=["date", "open", "high", "low", "close", "volume"]) # 硬锚整段 → 应回落自由搜索,起点落在箱体附近而非 bar0 tr = detect_trading_range(df, lookback=len(df), range_start_time=df["date"].iloc[0]) assert tr is not None assert tr["abs_start_idx"] >= 150 assert tr["bars"] < 120 assert (tr["high"] - tr["low"]) / tr["atr"] < 15 # Web 路径:整段 lookback、不锚起点 out = analyze_wyckoff(df, lookback=len(df), min_bars=max(24, len(df) // 12)) assert out["trading_range"] is not None assert out["trading_range"]["bars"] < 120 assert out["trading_range"]["bars"] >= 24 def test_volume_profile_poc_on_heavy_bin(): dates = pd.date_range("2024-01-01", periods=40, freq="5min", tz="UTC") rows = [] for i, d in enumerate(dates): c = 50.0 + (i % 5) * 0.1 vol = 1000.0 if 49.8 <= c <= 50.2 else 10.0 rows.append((d, c, c + 0.2, c - 0.2, c, vol)) df = pd.DataFrame(rows, columns=["date", "open", "high", "low", "close", "volume"]) out = analyze_wyckoff(df, lookback=80, vp_bins=20) vp = out["volume_profile"] assert vp["poc"] is not None assert vp["vah"] is not None and vp["val"] is not None assert abs(vp["poc"] - 50.0) < 1.0 def test_live_does_not_pollute_confirmed_events(): """Live 形成中:confirmed.events 不含 candidate;live 可有 Spring candidate。""" from chanlun.analysis.wyckoff.live import analyze_live_structure rng = np.random.default_rng(11) t0 = pd.Timestamp("2024-05-01", tz="UTC") rows = [] lo, hi = 40.0, 60.0 for i in range(40): c = lo + (hi - lo) * (0.35 + 0.3 * rng.random()) o = c h = min(hi, max(o, c) + 0.8) l = max(lo, min(o, c) - 0.8) rows.append((t0 + pd.Timedelta(hours=i), o, h, l, c, 100.0)) # 正在测下沿:长下影,尚未形成 Confirmed Spring 所需的刺破+收回序列写进 events 引擎 rows.append((t0 + pd.Timedelta(hours=40), 42.0, 44.0, 39.5, 42.5, 70.0)) df = pd.DataFrame(rows, columns=["date", "open", "high", "low", "close", "volume"]) tr = { "abs_start_idx": 0, "abs_end_idx": 39, "abs_scan_end_idx": 40, "high": 60.0, "low": 40.0, "mid": 50.0, "tol": 1.0, "atr": 1.5, "bars": 40, } live = analyze_live_structure(df, tr, confirmed_events=[], confirmed_phases=[], bias="accumulation") assert live["lifecycle"] in ("FORMING", "UNKNOWN", "CONFIRMED") # 无 confirmed 输入时,candidates 可含 Spring,且 confirmed flag 全 false for c in live.get("event_candidates") or []: assert c.get("confirmed") is False # 完整 analyze:顶层 events 不得因 live 凭空增加假 Spring(本合成无真 Spring) out = analyze_wyckoff(df, lookback=len(df), min_bars=20) assert "Spring" not in {e["type"] for e in (out.get("events") or [])} or out["lifecycle"] == "CONFIRMED" # live 与 confirmed 分离 c0 = (out.get("cycles") or [{}])[0] if c0.get("live") and c0["live"].get("event_candidates"): for c in c0["live"]["event_candidates"]: assert c.get("confirmed") is False confirmed_types = {e["type"] for e in (c0.get("confirmed") or {}).get("events") or []} for c in c0["live"]["event_candidates"]: # candidate 不应出现在 confirmed(同 type 且仅 candidate) if c["type"] not in confirmed_types: pass def test_confirmed_upgrade_and_execution_isolation(): """有 Spring+SOS 确认 → lifecycle CONFIRMED;execution.source==confirmed。""" from chanlun.analysis.wyckoff import execution_signal_from_wyckoff df = _box_df(spring=True, sos=True) out = analyze_wyckoff(df, lookback=200) assert len(out.get("cycles") or []) >= 1 c0 = out["cycles"][0] assert c0["status"] == "ACTIVE" types = {e["type"] for e in (c0.get("confirmed") or {}).get("events") or out.get("events") or []} assert "Spring" in types and "SOS" in types assert c0.get("lifecycle") == "CONFIRMED" # live 不得把已确认事件再标为 candidate for c in (c0.get("live") or {}).get("event_candidates") or []: assert c["type"] not in types sig = execution_signal_from_wyckoff(out) assert sig is not None assert sig["source"] == "confirmed" # 仅 live、无 confirmed 时不得给 execution empty_live_only = { "cycles": [{ "id": 0, "lifecycle": "FORMING", "confirmed": {"events": [], "phases": []}, "live": {"event_candidates": [{"type": "Spring", "confirmed": False}]}, }], "events": [], } assert execution_signal_from_wyckoff(empty_live_only) is None def _make_box_segment(t0, n, lo, hi, freq_hours, rng, base_i=0): rows = [] for i in range(n): c = lo + (hi - lo) * (0.3 + 0.4 * rng.random()) o = c + rng.normal(0, 0.2) h = min(hi + 0.3, max(o, c) + 0.4) l = max(lo - 0.3, min(o, c) - 0.4) if i % 8 == 0: h = hi - 0.1 if i % 8 == 3: l = lo + 0.1 rows.append((t0 + pd.Timedelta(hours=freq_hours * (base_i + i)), o, h, l, c, 90.0)) return rows def test_multi_cycle_two_boxes_with_trend(): """Test A:双箱 + 中间趋势;cycles[0] 更新、不重叠、顶层镜像 cycles[0]。""" rng = np.random.default_rng(3) t0 = pd.Timestamp("2024-01-01", tz="UTC") rows = [] # 早箱 100-110 rows += _make_box_segment(t0, 50, 100.0, 110.0, 1, rng, 0) # 中间上涨趋势 price = 110.0 for i in range(40): price += 0.8 + rng.random() * 0.3 o, c = price - 0.2, price h, l = max(o, c) + 0.3, min(o, c) - 0.3 rows.append((t0 + pd.Timedelta(hours=50 + i), o, h, l, c, 100.0)) # 近端箱 lo2, hi2 = price - 4, price + 4 rows += _make_box_segment(t0, 50, lo2, hi2, 1, rng, 90) df = pd.DataFrame(rows, columns=["date", "open", "high", "low", "close", "volume"]) out = analyze_wyckoff(df, lookback=len(df), min_bars=24, max_cycles=8) cycles = out.get("cycles") or [] assert len(cycles) >= 2 assert cycles[0]["status"] == "ACTIVE" assert cycles[1]["status"] == "HISTORICAL" # 时间倒序:C0.end > C1.end e0 = pd.Timestamp(cycles[0]["period"]["end_time"]) e1 = pd.Timestamp(cycles[1]["period"]["end_time"]) assert e0 > e1 # 不重叠 a0 = cycles[0]["trading_range"] # 用引擎内部 abs 不在 payload;用 period 时间近似 s0 = pd.Timestamp(cycles[0]["period"]["start_time"]) s1 = pd.Timestamp(cycles[1]["period"]["start_time"]) # C1 应完全在 C0 之前 assert e1 <= s0 or (e1 - s0).total_seconds() <= 3600 # 顶层 == cycles[0] assert out["trading_range"]["start_time"] == cycles[0]["trading_range"]["start_time"] assert out["trading_range"]["high"] == cycles[0]["trading_range"]["high"] assert out["trading_range"]["low"] == cycles[0]["trading_range"]["low"] def test_multi_cycle_nested_box_no_overlap(): """Test B:大箱套小箱不得产出 overlap_ratio>=0.2 的两段。""" rng = np.random.default_rng(5) t0 = pd.Timestamp("2024-03-01", tz="UTC") # 大箱 80 根 rows = _make_box_segment(t0, 80, 40.0, 60.0, 1, rng, 0) df = pd.DataFrame(rows, columns=["date", "open", "high", "low", "close", "volume"]) trs = detect_trading_ranges(df, lookback=len(df), min_bars=20, max_cycles=8) # 任意两段 overlap < 0.2 for i in range(len(trs)): for j in range(i + 1, len(trs)): r = _overlap_ratio( int(trs[i]["abs_start_idx"]), int(trs[i]["abs_end_idx"]), int(trs[j]["abs_start_idx"]), int(trs[j]["abs_end_idx"]), ) assert r < 0.2, f"overlap {r} between {i} and {j}" out = analyze_wyckoff(df, lookback=len(df), min_bars=20, max_cycles=8) cycles = out.get("cycles") or [] assert len(cycles) >= 1 assert cycles[0]["status"] == "ACTIVE" # 若有两段,时间窗也不应高度重叠 if len(cycles) >= 2: # period 不重叠:历史 end <= active start(允许 1h 容差) assert pd.Timestamp(cycles[1]["period"]["end_time"]) <= pd.Timestamp( cycles[0]["period"]["start_time"] ) + pd.Timedelta(hours=2)