"""威科夫阶段与事件(启发式)。""" from __future__ import annotations from typing import Any, Dict, List, 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。 """ 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]] = [] # 扫描区间内及之后(含 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 low then close back above low if spring is None and low < lo - tol * 0.5 and close >= lo - tol * 0.2: 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 high then close back below if utad is None and high > hi + tol * 0.5 and close <= hi + tol * 0.2: 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 for ev in (spring, sos, lps, utad, sod, lpsy): if ev: events.append({k: v for k, v in ev.items() if k != "idx"}) # 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" 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]], ) -> List[Dict[str, Any]]: """按时间切分 A–E 粗阶段。""" s = int(tr["abs_start_idx"]) e = int(tr["abs_end_idx"]) hi = float(tr["high"]) lo = float(tr["low"]) mid = float(tr["mid"]) tol = float(tr.get("tol") or (hi - lo) * 0.05) event_idx = {} for ev in events: # 找回 idx 近似:按时间匹配 t = ev.get("time") for i in range(s, min(len(df), e + 20)): if _bar_time(df, i) == t: event_idx[ev["type"]] = i break # 分段点 a_end = s + max(3, (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 e_start = d_anchor def _lab(phase: str) -> str: if bias == "distribution": 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) cuts = [ ("A", s, a_end), ("B", a_end, max(a_end + 1, c_anchor)), ("C", max(a_end + 1, c_anchor), max(c_anchor + 1, d_anchor)), ("D", max(c_anchor + 1, d_anchor), max(d_anchor + 1, min(len(df) - 1, e_start + max(3, (e - s) // 6)))), ("E", max(d_anchor, e_start), min(len(df) - 1, max(e, e_start + 5))), ] phases = [] for phase, a, b in cuts: a = int(np.clip(a, 0, len(df) - 1)) b = int(np.clip(b, a, len(df) - 1)) phases.append( { "phase": phase, "label": _lab(phase), "start_time": _bar_time(df, a), "end_time": _bar_time(df, b), } ) return phases