"""威科夫引擎单测:合成震荡箱 + 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 detect_trading_range # noqa: E402 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(): 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" 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_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