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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user