评分选 TR、阶段最小跨度、elements_only 门闩、Top-8 VP;无币种独立参数。 Co-authored-by: Cursor <cursoragent@cursor.com>
129 lines
3.4 KiB
Python
129 lines
3.4 KiB
Python
"""威科夫引擎单测:合成震荡箱 + 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
|