feat(ECR-007): Wyckoff Live Structure with Confirmed/Live isolation
Add live.py lifecycle and event candidates; assemble confirmed vs live in engine; Summary partition; execution_signal source=confirmed only. Keep strategies untouched; do not lower Confirmed thresholds for Live. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+8
-4
@@ -14,11 +14,11 @@
|
||||
"uncompleted_bi_list",
|
||||
"uncompleted_seg_list",
|
||||
"uncompleted_zs_list",
|
||||
"wyckoff",
|
||||
"zs_list"
|
||||
],
|
||||
"optional_when": {
|
||||
"include_structure_zones": ["structure_zones"],
|
||||
"include_wyckoff": ["wyckoff"]
|
||||
"include_structure_zones": ["structure_zones"]
|
||||
},
|
||||
"wyckoff_keys": [
|
||||
"trading_range",
|
||||
@@ -26,6 +26,10 @@
|
||||
"phases",
|
||||
"events",
|
||||
"volume_profile",
|
||||
"volume_confirm"
|
||||
]
|
||||
"volume_confirm",
|
||||
"cycles",
|
||||
"live",
|
||||
"lifecycle"
|
||||
],
|
||||
"notes": "wyckoff 默认返回;cycles[0]=ACTIVE;phases/events=Confirmed;live=Developing(WYCKOFF-LIVE-STRUCTURE-001);Execution 仅 Confirmed;见 docs/notes/"
|
||||
}
|
||||
|
||||
@@ -148,11 +148,11 @@ def analyze_contract_keys() -> dict:
|
||||
"macd",
|
||||
"chan_macd",
|
||||
"klc_trend",
|
||||
"wyckoff",
|
||||
]
|
||||
),
|
||||
"optional_when": {
|
||||
"include_structure_zones": ["structure_zones"],
|
||||
"include_wyckoff": ["wyckoff"],
|
||||
},
|
||||
"wyckoff_keys": [
|
||||
"trading_range",
|
||||
@@ -162,6 +162,7 @@ def analyze_contract_keys() -> dict:
|
||||
"volume_profile",
|
||||
"volume_confirm",
|
||||
],
|
||||
"notes": "wyckoff 随主周期 analyze 默认返回;有次/次次周期时另附 element_wyckoff / sub_sub_wyckoff;include_wyckoff=0 可跳过;elements_only 时不返回",
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -43,9 +43,9 @@ def test_analyze_contract_keys_file():
|
||||
)
|
||||
)
|
||||
keys = doc["required"] if isinstance(doc, dict) and "required" in doc else doc
|
||||
for k in ("kline_data", "bi_list", "seg_list", "zs_list", "bsp_list"):
|
||||
for k in ("kline_data", "bi_list", "seg_list", "zs_list", "bsp_list", "wyckoff"):
|
||||
assert k in keys
|
||||
if isinstance(doc, dict):
|
||||
assert "include_wyckoff" in doc.get("optional_when", {})
|
||||
assert "include_wyckoff" not in doc.get("optional_when", {})
|
||||
for k in ("trading_range", "phases", "events", "volume_profile"):
|
||||
assert k in doc.get("wyckoff_keys", [])
|
||||
|
||||
+259
-1
@@ -11,7 +11,11 @@ ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from chanlun.analysis.wyckoff import analyze_wyckoff # noqa: E402
|
||||
from chanlun.analysis.wyckoff.range import detect_trading_range # noqa: E402
|
||||
from chanlun.analysis.wyckoff.range import ( # noqa: E402
|
||||
detect_trading_range,
|
||||
detect_trading_ranges,
|
||||
_overlap_ratio,
|
||||
)
|
||||
|
||||
|
||||
def _box_df(n_box: int = 60, spring: bool = True, sos: bool = True) -> pd.DataFrame:
|
||||
@@ -87,6 +91,7 @@ def _box_df(n_box: int = 60, spring: bool = True, sos: bool = True) -> pd.DataFr
|
||||
|
||||
|
||||
def test_wyckoff_detects_range_and_events():
|
||||
"""Test C:旧接口兼容 — 顶层字段仍在,且 cycles[0] 为 ACTIVE 镜像。"""
|
||||
df = _box_df()
|
||||
out = analyze_wyckoff(df, lookback=200)
|
||||
assert out["trading_range"] is not None
|
||||
@@ -105,6 +110,55 @@ def test_wyckoff_detects_range_and_events():
|
||||
assert len(out["phases"]) >= 3
|
||||
keys = [(p["start_time"], p["end_time"]) for p in out["phases"]]
|
||||
assert len(keys) == len(set(keys)), "phases must not share identical start/end"
|
||||
# cycles 契约
|
||||
assert len(out.get("cycles") or []) >= 1
|
||||
c0 = out["cycles"][0]
|
||||
assert c0["status"] == "ACTIVE"
|
||||
assert c0["id"] == 0
|
||||
assert c0["trading_range"]["start_time"] == out["trading_range"]["start_time"]
|
||||
assert c0["trading_range"]["high"] == out["trading_range"]["high"]
|
||||
assert "confidence" in c0 and "overall" in c0["confidence"]
|
||||
assert "period" in c0 and c0["period"]["bars"] > 0
|
||||
|
||||
def test_phase_c_when_spring_eaten_by_box_low():
|
||||
"""箱沿吃掉 Spring 最低点时,仍应靠结构次低检出 Spring,并有阶段 C。"""
|
||||
rng = np.random.default_rng(1)
|
||||
t0 = pd.Timestamp("2024-06-01", tz="UTC")
|
||||
rows = []
|
||||
box_lo, box_hi = 40.0, 60.0
|
||||
for i in range(60):
|
||||
c = box_lo + (box_hi - box_lo) * (0.3 + 0.4 * rng.random())
|
||||
o = c
|
||||
h = min(box_hi, max(o, c) + 1)
|
||||
l = max(box_lo, min(o, c) - 1)
|
||||
if i % 7 == 0:
|
||||
h = box_hi - 0.2
|
||||
if i % 7 == 3:
|
||||
l = box_lo + 0.2
|
||||
rows.append((t0 + pd.Timedelta(hours=4 * i), o, h, l, c, 100.0))
|
||||
# 箱内假破:最低点 38,收回到 43
|
||||
rows[45] = (rows[45][0], 42.0, 45.0, 38.0, 43.0, 80.0)
|
||||
for j in range(3):
|
||||
rows.append((t0 + pd.Timedelta(hours=4 * (60 + j)), 61.0, 63.0, 60.5, 62.0, 150.0))
|
||||
df = pd.DataFrame(rows, columns=["date", "open", "high", "low", "close", "volume"])
|
||||
# 模拟 4h:TR.low 已吃进 Spring
|
||||
tr = {
|
||||
"abs_start_idx": 0,
|
||||
"abs_end_idx": 59,
|
||||
"abs_scan_end_idx": len(df) - 1,
|
||||
"high": 60.0,
|
||||
"low": 38.0,
|
||||
"mid": 49.0,
|
||||
"tol": 1.0,
|
||||
}
|
||||
from chanlun.analysis.wyckoff.events import detect_bias_and_events, build_phases
|
||||
|
||||
bias, ev, _ = detect_bias_and_events(df, tr)
|
||||
ph = build_phases(df, tr, bias, ev)
|
||||
assert "Spring" in {e["type"] for e in ev}
|
||||
assert "C" in {p["phase"] for p in ph}
|
||||
assert bias == "accumulation"
|
||||
|
||||
|
||||
def test_range_scoring_skips_pretrend():
|
||||
df = _box_df(spring=False, sos=False)
|
||||
@@ -113,6 +167,44 @@ def test_range_scoring_skips_pretrend():
|
||||
assert tr["abs_start_idx"] >= 12 # 不应从 bar 0 吞掉整段下跌
|
||||
|
||||
|
||||
def test_range_anchored_rejects_full_trend():
|
||||
"""整段趋势+末端箱:硬锚数据起点应因过宽回落,仍能搜出末端箱。"""
|
||||
rng = np.random.default_rng(0)
|
||||
t0 = pd.Timestamp("2024-06-01", tz="UTC")
|
||||
rows = []
|
||||
price = 100.0
|
||||
for i in range(200):
|
||||
price += 0.4 + rng.random() * 0.2
|
||||
o, c = price - 0.1, price
|
||||
h, l = max(o, c) + 0.3, min(o, c) - 0.3
|
||||
rows.append((t0 + pd.Timedelta(hours=i), o, h, l, c, 100.0))
|
||||
lo, hi = price - 5, price + 5
|
||||
for i in range(80):
|
||||
c = lo + (hi - lo) * (0.3 + 0.4 * rng.random())
|
||||
o = c + rng.normal(0, 0.3)
|
||||
h = min(hi + 0.5, max(o, c) + 0.4)
|
||||
l = max(lo - 0.5, min(o, c) - 0.4)
|
||||
if i % 8 == 0:
|
||||
h = hi - 0.1
|
||||
if i % 8 == 3:
|
||||
l = lo + 0.1
|
||||
rows.append((t0 + pd.Timedelta(hours=200 + i), o, h, l, c, 90.0))
|
||||
df = pd.DataFrame(rows, columns=["date", "open", "high", "low", "close", "volume"])
|
||||
|
||||
# 硬锚整段 → 应回落自由搜索,起点落在箱体附近而非 bar0
|
||||
tr = detect_trading_range(df, lookback=len(df), range_start_time=df["date"].iloc[0])
|
||||
assert tr is not None
|
||||
assert tr["abs_start_idx"] >= 150
|
||||
assert tr["bars"] < 120
|
||||
assert (tr["high"] - tr["low"]) / tr["atr"] < 15
|
||||
|
||||
# Web 路径:整段 lookback、不锚起点
|
||||
out = analyze_wyckoff(df, lookback=len(df), min_bars=max(24, len(df) // 12))
|
||||
assert out["trading_range"] is not None
|
||||
assert out["trading_range"]["bars"] < 120
|
||||
assert out["trading_range"]["bars"] >= 24
|
||||
|
||||
|
||||
def test_volume_profile_poc_on_heavy_bin():
|
||||
dates = pd.date_range("2024-01-01", periods=40, freq="5min", tz="UTC")
|
||||
rows = []
|
||||
@@ -126,3 +218,169 @@ def test_volume_profile_poc_on_heavy_bin():
|
||||
assert vp["poc"] is not None
|
||||
assert vp["vah"] is not None and vp["val"] is not None
|
||||
assert abs(vp["poc"] - 50.0) < 1.0
|
||||
|
||||
|
||||
def test_live_does_not_pollute_confirmed_events():
|
||||
"""Live 形成中:confirmed.events 不含 candidate;live 可有 Spring candidate。"""
|
||||
from chanlun.analysis.wyckoff.live import analyze_live_structure
|
||||
|
||||
rng = np.random.default_rng(11)
|
||||
t0 = pd.Timestamp("2024-05-01", tz="UTC")
|
||||
rows = []
|
||||
lo, hi = 40.0, 60.0
|
||||
for i in range(40):
|
||||
c = lo + (hi - lo) * (0.35 + 0.3 * rng.random())
|
||||
o = c
|
||||
h = min(hi, max(o, c) + 0.8)
|
||||
l = max(lo, min(o, c) - 0.8)
|
||||
rows.append((t0 + pd.Timedelta(hours=i), o, h, l, c, 100.0))
|
||||
# 正在测下沿:长下影,尚未形成 Confirmed Spring 所需的刺破+收回序列写进 events 引擎
|
||||
rows.append((t0 + pd.Timedelta(hours=40), 42.0, 44.0, 39.5, 42.5, 70.0))
|
||||
df = pd.DataFrame(rows, columns=["date", "open", "high", "low", "close", "volume"])
|
||||
tr = {
|
||||
"abs_start_idx": 0,
|
||||
"abs_end_idx": 39,
|
||||
"abs_scan_end_idx": 40,
|
||||
"high": 60.0,
|
||||
"low": 40.0,
|
||||
"mid": 50.0,
|
||||
"tol": 1.0,
|
||||
"atr": 1.5,
|
||||
"bars": 40,
|
||||
}
|
||||
live = analyze_live_structure(df, tr, confirmed_events=[], confirmed_phases=[], bias="accumulation")
|
||||
assert live["lifecycle"] in ("FORMING", "UNKNOWN", "CONFIRMED")
|
||||
# 无 confirmed 输入时,candidates 可含 Spring,且 confirmed flag 全 false
|
||||
for c in live.get("event_candidates") or []:
|
||||
assert c.get("confirmed") is False
|
||||
# 完整 analyze:顶层 events 不得因 live 凭空增加假 Spring(本合成无真 Spring)
|
||||
out = analyze_wyckoff(df, lookback=len(df), min_bars=20)
|
||||
assert "Spring" not in {e["type"] for e in (out.get("events") or [])} or out["lifecycle"] == "CONFIRMED"
|
||||
# live 与 confirmed 分离
|
||||
c0 = (out.get("cycles") or [{}])[0]
|
||||
if c0.get("live") and c0["live"].get("event_candidates"):
|
||||
for c in c0["live"]["event_candidates"]:
|
||||
assert c.get("confirmed") is False
|
||||
confirmed_types = {e["type"] for e in (c0.get("confirmed") or {}).get("events") or []}
|
||||
for c in c0["live"]["event_candidates"]:
|
||||
# candidate 不应出现在 confirmed(同 type 且仅 candidate)
|
||||
if c["type"] not in confirmed_types:
|
||||
pass
|
||||
|
||||
|
||||
def test_confirmed_upgrade_and_execution_isolation():
|
||||
"""有 Spring+SOS 确认 → lifecycle CONFIRMED;execution.source==confirmed。"""
|
||||
from chanlun.analysis.wyckoff import execution_signal_from_wyckoff
|
||||
|
||||
df = _box_df(spring=True, sos=True)
|
||||
out = analyze_wyckoff(df, lookback=200)
|
||||
assert len(out.get("cycles") or []) >= 1
|
||||
c0 = out["cycles"][0]
|
||||
assert c0["status"] == "ACTIVE"
|
||||
types = {e["type"] for e in (c0.get("confirmed") or {}).get("events") or out.get("events") or []}
|
||||
assert "Spring" in types and "SOS" in types
|
||||
assert c0.get("lifecycle") == "CONFIRMED"
|
||||
# live 不得把已确认事件再标为 candidate
|
||||
for c in (c0.get("live") or {}).get("event_candidates") or []:
|
||||
assert c["type"] not in types
|
||||
sig = execution_signal_from_wyckoff(out)
|
||||
assert sig is not None
|
||||
assert sig["source"] == "confirmed"
|
||||
# 仅 live、无 confirmed 时不得给 execution
|
||||
empty_live_only = {
|
||||
"cycles": [{
|
||||
"id": 0,
|
||||
"lifecycle": "FORMING",
|
||||
"confirmed": {"events": [], "phases": []},
|
||||
"live": {"event_candidates": [{"type": "Spring", "confirmed": False}]},
|
||||
}],
|
||||
"events": [],
|
||||
}
|
||||
assert execution_signal_from_wyckoff(empty_live_only) is None
|
||||
|
||||
|
||||
def _make_box_segment(t0, n, lo, hi, freq_hours, rng, base_i=0):
|
||||
rows = []
|
||||
for i in range(n):
|
||||
c = lo + (hi - lo) * (0.3 + 0.4 * rng.random())
|
||||
o = c + rng.normal(0, 0.2)
|
||||
h = min(hi + 0.3, max(o, c) + 0.4)
|
||||
l = max(lo - 0.3, min(o, c) - 0.4)
|
||||
if i % 8 == 0:
|
||||
h = hi - 0.1
|
||||
if i % 8 == 3:
|
||||
l = lo + 0.1
|
||||
rows.append((t0 + pd.Timedelta(hours=freq_hours * (base_i + i)), o, h, l, c, 90.0))
|
||||
return rows
|
||||
|
||||
|
||||
def test_multi_cycle_two_boxes_with_trend():
|
||||
"""Test A:双箱 + 中间趋势;cycles[0] 更新、不重叠、顶层镜像 cycles[0]。"""
|
||||
rng = np.random.default_rng(3)
|
||||
t0 = pd.Timestamp("2024-01-01", tz="UTC")
|
||||
rows = []
|
||||
# 早箱 100-110
|
||||
rows += _make_box_segment(t0, 50, 100.0, 110.0, 1, rng, 0)
|
||||
# 中间上涨趋势
|
||||
price = 110.0
|
||||
for i in range(40):
|
||||
price += 0.8 + rng.random() * 0.3
|
||||
o, c = price - 0.2, price
|
||||
h, l = max(o, c) + 0.3, min(o, c) - 0.3
|
||||
rows.append((t0 + pd.Timedelta(hours=50 + i), o, h, l, c, 100.0))
|
||||
# 近端箱
|
||||
lo2, hi2 = price - 4, price + 4
|
||||
rows += _make_box_segment(t0, 50, lo2, hi2, 1, rng, 90)
|
||||
df = pd.DataFrame(rows, columns=["date", "open", "high", "low", "close", "volume"])
|
||||
|
||||
out = analyze_wyckoff(df, lookback=len(df), min_bars=24, max_cycles=8)
|
||||
cycles = out.get("cycles") or []
|
||||
assert len(cycles) >= 2
|
||||
assert cycles[0]["status"] == "ACTIVE"
|
||||
assert cycles[1]["status"] == "HISTORICAL"
|
||||
# 时间倒序:C0.end > C1.end
|
||||
e0 = pd.Timestamp(cycles[0]["period"]["end_time"])
|
||||
e1 = pd.Timestamp(cycles[1]["period"]["end_time"])
|
||||
assert e0 > e1
|
||||
# 不重叠
|
||||
a0 = cycles[0]["trading_range"]
|
||||
# 用引擎内部 abs 不在 payload;用 period 时间近似
|
||||
s0 = pd.Timestamp(cycles[0]["period"]["start_time"])
|
||||
s1 = pd.Timestamp(cycles[1]["period"]["start_time"])
|
||||
# C1 应完全在 C0 之前
|
||||
assert e1 <= s0 or (e1 - s0).total_seconds() <= 3600
|
||||
# 顶层 == cycles[0]
|
||||
assert out["trading_range"]["start_time"] == cycles[0]["trading_range"]["start_time"]
|
||||
assert out["trading_range"]["high"] == cycles[0]["trading_range"]["high"]
|
||||
assert out["trading_range"]["low"] == cycles[0]["trading_range"]["low"]
|
||||
|
||||
|
||||
def test_multi_cycle_nested_box_no_overlap():
|
||||
"""Test B:大箱套小箱不得产出 overlap_ratio>=0.2 的两段。"""
|
||||
rng = np.random.default_rng(5)
|
||||
t0 = pd.Timestamp("2024-03-01", tz="UTC")
|
||||
# 大箱 80 根
|
||||
rows = _make_box_segment(t0, 80, 40.0, 60.0, 1, rng, 0)
|
||||
df = pd.DataFrame(rows, columns=["date", "open", "high", "low", "close", "volume"])
|
||||
trs = detect_trading_ranges(df, lookback=len(df), min_bars=20, max_cycles=8)
|
||||
# 任意两段 overlap < 0.2
|
||||
for i in range(len(trs)):
|
||||
for j in range(i + 1, len(trs)):
|
||||
r = _overlap_ratio(
|
||||
int(trs[i]["abs_start_idx"]),
|
||||
int(trs[i]["abs_end_idx"]),
|
||||
int(trs[j]["abs_start_idx"]),
|
||||
int(trs[j]["abs_end_idx"]),
|
||||
)
|
||||
assert r < 0.2, f"overlap {r} between {i} and {j}"
|
||||
|
||||
out = analyze_wyckoff(df, lookback=len(df), min_bars=20, max_cycles=8)
|
||||
cycles = out.get("cycles") or []
|
||||
assert len(cycles) >= 1
|
||||
assert cycles[0]["status"] == "ACTIVE"
|
||||
# 若有两段,时间窗也不应高度重叠
|
||||
if len(cycles) >= 2:
|
||||
# period 不重叠:历史 end <= active start(允许 1h 容差)
|
||||
assert pd.Timestamp(cycles[1]["period"]["end_time"]) <= pd.Timestamp(
|
||||
cycles[0]["period"]["start_time"]
|
||||
) + pd.Timedelta(hours=2)
|
||||
|
||||
Reference in New Issue
Block a user