独立 wyckoff 引擎 + 按需 include_wyckoff;主站 Lightweight 绘制区间/阶段/事件/VP。 Co-authored-by: Cursor <cursoragent@cursor.com>
241 lines
6.6 KiB
Python
241 lines
6.6 KiB
Python
"""威科夫阶段与事件(启发式)。"""
|
||
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
|