Files
Chan/chanlun/analysis/wyckoff/events.py
T
jackyu66gitandCursor 276481e02c 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>
2026-08-07 03:14:19 +08:00

370 lines
10 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""威科夫阶段与事件(启发式)。"""
from __future__ import annotations
from typing import Any, Dict, List, Optional, Tuple
import numpy as np
import pandas as pd
def _bar_time(df: pd.DataFrame, i: int):
row = df.iloc[i]
if "date" in df.columns and pd.notna(row["date"]):
return row["date"]
if "timestamp" in df.columns:
return row["timestamp"]
return i
def _avg_vol(df: pd.DataFrame, i: int, win: int = 20) -> float:
a = max(0, i - win + 1)
v = df["volume"].astype(float).iloc[a : i + 1]
m = float(v.mean()) if len(v) else 0.0
return m if m > 0 else 1.0
def detect_bias_and_events(
df: pd.DataFrame,
tr: Dict[str, Any],
) -> 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"])
mid = float(tr["mid"])
tol = float(tr.get("tol") or (hi - lo) * 0.05)
s = int(tr["abs_start_idx"])
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))
spring = None
utad = None
sos = None
sod = None # sign of weakness / distribution breakdown
lps = None
lpsy = None
for i in range(s + 2, scan_end + 1):
row = df.iloc[i]
low = float(row["low"])
high = float(row["high"])
close = float(row["close"])
vol = float(row["volume"]) if "volume" in df.columns else 0.0
avg_v = _avg_vol(df, i)
ratio = vol / avg_v if avg_v else 0.0
# 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",
"time": _bar_time(df, i),
"price": low,
"note": "假破下沿后收回",
"volume_ratio": round(ratio, 3),
"volume_ok": bool(vol_ok),
"idx": i,
}
# 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",
"time": _bar_time(df, i),
"price": high,
"note": "假破上沿后跌回",
"volume_ratio": round(ratio, 3),
"volume_ok": bool(vol_ok),
"idx": i,
}
# SOS: close above high with volume
if sos is None and close > hi + tol * 0.15:
vol_ok = ratio >= 1.15
sos = {
"type": "SOS",
"time": _bar_time(df, i),
"price": close,
"note": "放量上破交易区间",
"volume_ratio": round(ratio, 3),
"volume_ok": bool(vol_ok),
"idx": i,
}
# SOW / breakdown
if sod is None and close < lo - tol * 0.15:
vol_ok = ratio >= 1.15
sod = {
"type": "SOW",
"time": _bar_time(df, i),
"price": close,
"note": "放量下破交易区间",
"volume_ratio": round(ratio, 3),
"volume_ok": bool(vol_ok),
"idx": i,
}
# LPS after SOS: pullback that holds above mid/high-band with lighter volume
if sos is not None:
si = int(sos["idx"])
for i in range(si + 1, min(len(df), si + 25)):
row = df.iloc[i]
low = float(row["low"])
close = float(row["close"])
vol = float(row["volume"]) if "volume" in df.columns else 0.0
avg_v = _avg_vol(df, i)
ratio = vol / avg_v if avg_v else 0.0
if low >= mid - tol and close >= hi - tol * 2:
vol_ok = ratio <= 1.05
lps = {
"type": "LPS",
"time": _bar_time(df, i),
"price": low,
"note": "突破后缩量回踩不破",
"volume_ratio": round(ratio, 3),
"volume_ok": bool(vol_ok),
"idx": i,
}
break
if sod is not None:
si = int(sod["idx"])
for i in range(si + 1, min(len(df), si + 25)):
row = df.iloc[i]
high = float(row["high"])
close = float(row["close"])
vol = float(row["volume"]) if "volume" in df.columns else 0.0
avg_v = _avg_vol(df, i)
ratio = vol / avg_v if avg_v else 0.0
if high <= mid + tol and close <= lo + tol * 2:
vol_ok = ratio <= 1.05
lpsy = {
"type": "LPSY",
"time": _bar_time(df, i),
"price": high,
"note": "下跌突破后缩量反抽不过",
"volume_ratio": round(ratio, 3),
"volume_ok": bool(vol_ok),
"idx": i,
}
break
# 冲突清理:已判定吸筹且有 SOS 时,丢弃更早的 UTAD(避免阶段/图面误导)
# 派发且有 SOW 时,丢弃更晚才合理的 Spring 假信号同理在偏置后再滤
keep = []
for ev in (spring, sos, lps, utad, sod, lpsy):
if not ev:
continue
keep.append(ev)
# 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))):
bias = "accumulation"
elif sod and (not sos or int(sod.get("idx", 0)) > int(sos.get("idx", 0))):
bias = "distribution"
elif spring and not utad:
bias = "accumulation"
elif utad and not spring:
bias = "distribution"
elif last_c >= mid:
bias = "accumulation"
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,
"event_checks": {ev["type"]: {"volume_ok": ev.get("volume_ok"), "volume_ratio": ev.get("volume_ratio")} for ev in events},
}
return bias, events, volume_confirm
def build_phases(
df: pd.DataFrame,
tr: Dict[str, Any],
bias: str,
events: List[Dict[str, Any]],
min_bars: int = 3,
) -> List[Dict[str, Any]]:
"""
按威科夫事件锚点切分 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)
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:
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
def _lab(phase: str) -> str:
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)
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(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:
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