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>
443 lines
12 KiB
Python
443 lines
12 KiB
Python
"""交易区间检测:仅负责 TradingRange(起止/高低/结构分)。
|
||
|
||
WYCKOFF-MULTI-CYCLE-001:Phase/Event/VP 不得进入本模块。
|
||
过滤顺序固定:detect → quality → trend → overlap(<0.2) → accept → mask。
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
from typing import Any, Dict, List, Optional, Tuple
|
||
|
||
import numpy as np
|
||
import pandas as pd
|
||
|
||
MAX_CYCLES = 8
|
||
OVERLAP_RATIO_MAX = 0.2
|
||
|
||
|
||
def _atr(df: pd.DataFrame, period: int = 14) -> pd.Series:
|
||
high = df["high"].astype(float)
|
||
low = df["low"].astype(float)
|
||
close = df["close"].astype(float)
|
||
prev_close = close.shift(1)
|
||
tr = pd.concat(
|
||
[
|
||
(high - low).abs(),
|
||
(high - prev_close).abs(),
|
||
(low - prev_close).abs(),
|
||
],
|
||
axis=1,
|
||
).max(axis=1)
|
||
return tr.rolling(period, min_periods=max(3, period // 2)).mean()
|
||
|
||
|
||
def _robust_width(seg: pd.DataFrame) -> float:
|
||
"""用 90/10 分位估宽,避免单根影线把长窗卡死。"""
|
||
h = seg["high"].astype(float)
|
||
l = seg["low"].astype(float)
|
||
if len(seg) < 6:
|
||
return float(h.max() - l.min())
|
||
return float(np.nanpercentile(h, 90) - np.nanpercentile(l, 10))
|
||
|
||
|
||
def _score_segment(
|
||
length: int,
|
||
near_hi: int,
|
||
near_lo: int,
|
||
inside: float,
|
||
width: float,
|
||
atr: float,
|
||
) -> float:
|
||
"""结构质量分(非 Phase/Event)。"""
|
||
touch = min(near_hi, 6) + min(near_lo, 6)
|
||
width_pen = (width / atr) if atr > 0 else width
|
||
return float(touch) * 4.0 + float(inside) * 25.0 - width_pen * 3.0 + min(length / 40.0, 2.0)
|
||
|
||
|
||
def _time_col(df: pd.DataFrame) -> Optional[str]:
|
||
if "date" in df.columns:
|
||
return "date"
|
||
if "timestamp" in df.columns:
|
||
return "timestamp"
|
||
return None
|
||
|
||
|
||
def _bar_index_at_or_after(work: pd.DataFrame, ts: Any) -> Optional[int]:
|
||
col = _time_col(work)
|
||
if col is None or ts is None:
|
||
return None
|
||
try:
|
||
target = pd.Timestamp(ts)
|
||
except Exception:
|
||
return None
|
||
series = pd.to_datetime(work[col], utc=True, errors="coerce")
|
||
if target.tzinfo is None:
|
||
target = target.tz_localize("UTC")
|
||
else:
|
||
target = target.tz_convert("UTC")
|
||
if series.isna().all():
|
||
return None
|
||
ge = series >= target
|
||
if ge.any():
|
||
return int(np.flatnonzero(ge.to_numpy())[0])
|
||
return 0
|
||
|
||
|
||
def _pack_range(
|
||
work: pd.DataFrame,
|
||
df: pd.DataFrame,
|
||
start_i: int,
|
||
end_i: int,
|
||
hi: float,
|
||
lo: float,
|
||
tol: float,
|
||
last_atr: float,
|
||
score: float,
|
||
n: int,
|
||
window_offset: int = 0,
|
||
) -> Dict[str, Any]:
|
||
"""组装 TradingRange(仅结构字段)。"""
|
||
mid = (hi + lo) / 2.0
|
||
last_c = float(work["close"].iloc[min(end_i, len(work) - 1)])
|
||
price_in_box = (lo - tol * 1.5) <= last_c <= (hi + tol * 1.5)
|
||
bars = int(end_i - start_i + 1)
|
||
# 结构置信:归一化 score(启发式)
|
||
range_conf = float(np.clip(score / 55.0, 0.05, 0.99))
|
||
best = {
|
||
"start_idx": int(start_i),
|
||
"end_idx": int(end_i),
|
||
"high": float(hi),
|
||
"low": float(lo),
|
||
"mid": float(mid),
|
||
"active": bool(price_in_box),
|
||
"atr": float(last_atr),
|
||
"tol": float(tol),
|
||
"bars": bars,
|
||
"score": float(score),
|
||
"quality": float(score),
|
||
"range_confidence": range_conf,
|
||
}
|
||
|
||
def _ts(row) -> Any:
|
||
col = _time_col(work)
|
||
if col and pd.notna(row[col]):
|
||
return row[col]
|
||
return None
|
||
|
||
best["start_time"] = _ts(work.iloc[best["start_idx"]])
|
||
best["end_time"] = _ts(work.iloc[best["end_idx"]])
|
||
# window_offset:slice 相对父 DataFrame 的起点;勿用 len(df)-len(work)
|
||
offset = int(window_offset)
|
||
best["abs_start_idx"] = offset + best["start_idx"]
|
||
best["abs_end_idx"] = offset + best["end_idx"]
|
||
best["abs_scan_end_idx"] = offset + n - 1
|
||
return best
|
||
|
||
|
||
def _overlap_ratio(a0: int, a1: int, b0: int, b1: int) -> float:
|
||
"""两闭区间重叠长度 / 较短区间长度。"""
|
||
lo = max(a0, b0)
|
||
hi = min(a1, b1)
|
||
if hi < lo:
|
||
return 0.0
|
||
overlap = hi - lo + 1
|
||
shorter = min(a1 - a0 + 1, b1 - b0 + 1)
|
||
if shorter <= 0:
|
||
return 0.0
|
||
return float(overlap) / float(shorter)
|
||
|
||
|
||
def _passes_quality(tr: Dict[str, Any], min_bars: int) -> bool:
|
||
if tr is None:
|
||
return False
|
||
if int(tr.get("bars") or 0) < max(8, min_bars // 2):
|
||
return False
|
||
if float(tr.get("score") or 0) < 12.0:
|
||
return False
|
||
hi = float(tr["high"])
|
||
lo = float(tr["low"])
|
||
atr = float(tr.get("atr") or 0) or 1.0
|
||
if (hi - lo) / atr > 12.0:
|
||
return False
|
||
return True
|
||
|
||
|
||
def _passes_trend_filter(work: pd.DataFrame, tr: Dict[str, Any]) -> bool:
|
||
"""趋势污染:定向位移过大则非震荡箱。"""
|
||
s = int(tr["start_idx"])
|
||
e = int(tr["end_idx"])
|
||
seg = work.iloc[s : e + 1]
|
||
if len(seg) < 8:
|
||
return False
|
||
c0 = float(seg["close"].iloc[0])
|
||
c1 = float(seg["close"].iloc[-1])
|
||
atr = float(tr.get("atr") or 0) or 1.0
|
||
drift = abs(c1 - c0) / atr
|
||
# 相对箱宽:漂移占箱宽过大 → 趋势
|
||
width = max(float(tr["high"]) - float(tr["low"]), atr)
|
||
drift_frac = abs(c1 - c0) / width
|
||
if drift > 6.0 and drift_frac > 0.55:
|
||
return False
|
||
return True
|
||
|
||
|
||
def _detect_in_window(
|
||
df: pd.DataFrame,
|
||
win_start: int,
|
||
win_end: int,
|
||
min_bars: int = 24,
|
||
atr_mult: float = 1.2,
|
||
tail_reserve: int = 12,
|
||
prefer_start_time: Any = None,
|
||
range_start_time: Any = None,
|
||
) -> Optional[Dict[str, Any]]:
|
||
"""
|
||
在 df[win_start:win_end+1] 内检测单个 TradingRange。
|
||
只返回箱体结构,不含 Phase/Event/VP。
|
||
"""
|
||
if df is None or win_end < win_start:
|
||
return None
|
||
slice_df = df.iloc[win_start : win_end + 1].reset_index(drop=True)
|
||
lookback = len(slice_df)
|
||
if lookback < min_bars + 5:
|
||
return None
|
||
|
||
work = slice_df
|
||
n = len(work)
|
||
reserve = min(tail_reserve, max(0, n - min_bars - 2))
|
||
core_end = n - reserve if reserve > 0 else n
|
||
core = work.iloc[:core_end]
|
||
if len(core) < min_bars:
|
||
core = work
|
||
core_end = n
|
||
reserve = 0
|
||
|
||
atr = _atr(work)
|
||
last_atr = float(atr.iloc[core_end - 1]) if atr.notna().iloc[:core_end].any() else float(
|
||
(core["high"] - core["low"]).mean()
|
||
)
|
||
if not np.isfinite(last_atr) or last_atr <= 0:
|
||
last_atr = float(core["close"].iloc[-1]) * 0.01
|
||
|
||
eff_atr_mult = float(atr_mult)
|
||
if lookback >= 280:
|
||
eff_atr_mult = atr_mult * 1.7
|
||
elif lookback >= 160:
|
||
eff_atr_mult = atr_mult * 1.3
|
||
width_factor = 3.8 + min(2.2, max(0.0, (lookback - 80) / 100.0))
|
||
max_width = last_atr * eff_atr_mult * width_factor
|
||
tol = last_atr * eff_atr_mult * 0.35
|
||
|
||
prefer_i = None
|
||
if prefer_start_time is not None:
|
||
prefer_i = _bar_index_at_or_after(work, prefer_start_time)
|
||
|
||
if range_start_time is not None:
|
||
start_i = _bar_index_at_or_after(work, range_start_time)
|
||
if start_i is not None and start_i <= core_end - 8:
|
||
seg = work.iloc[start_i:core_end]
|
||
hi = float(seg["high"].max())
|
||
lo = float(seg["low"].min())
|
||
rw = _robust_width(seg)
|
||
if 0 < rw <= max_width * 1.15:
|
||
near_hi = int((seg["high"] >= hi - tol).sum())
|
||
near_lo = int((seg["low"] <= lo + tol).sum())
|
||
inside = float(((seg["close"] >= lo - tol) & (seg["close"] <= hi + tol)).mean())
|
||
if near_hi >= 2 and near_lo >= 2 and inside >= 0.70:
|
||
score = _score_segment(len(seg), near_hi, near_lo, inside, rw, last_atr)
|
||
return _pack_range(
|
||
work, df, start_i, core_end - 1, hi, lo, tol, last_atr, score, n,
|
||
window_offset=win_start,
|
||
)
|
||
|
||
eff_min_bars = max(8, int(min_bars))
|
||
cn = len(core)
|
||
max_bars = min(cn, max(eff_min_bars * 2, min(96, max(eff_min_bars + 8, int(cn * 0.5)))))
|
||
cands: List[Tuple[float, int, int, int, float, float, float]] = []
|
||
|
||
def _try_seg(start_i: int, end_i: int, prefer_boost: float = 0.0) -> None:
|
||
if end_i - start_i + 1 < eff_min_bars:
|
||
return
|
||
if start_i < 0 or end_i >= cn or start_i > end_i:
|
||
return
|
||
seg = work.iloc[start_i : end_i + 1]
|
||
hi = float(seg["high"].max())
|
||
lo = float(seg["low"].min())
|
||
rw = _robust_width(seg)
|
||
if rw <= 0 or rw > max_width:
|
||
return
|
||
raw_w = hi - lo
|
||
if raw_w > max_width * 1.35:
|
||
return
|
||
near_hi = int((seg["high"] >= hi - tol).sum())
|
||
near_lo = int((seg["low"] <= lo + tol).sum())
|
||
if near_hi < 2 or near_lo < 2:
|
||
return
|
||
inside = float(((seg["close"] >= lo - tol) & (seg["close"] <= hi + tol)).mean())
|
||
if inside < 0.72:
|
||
return
|
||
length = end_i - start_i + 1
|
||
score = _score_segment(length, near_hi, near_lo, inside, rw, last_atr) + prefer_boost
|
||
cands.append((score, length, start_i, end_i, hi, lo, rw))
|
||
|
||
for length in range(min(cn, max_bars), eff_min_bars - 1, -4):
|
||
start_i = cn - length
|
||
boost = 0.0
|
||
if prefer_i is not None:
|
||
dist = abs(start_i - int(prefer_i))
|
||
if dist <= 6:
|
||
boost = 10.0
|
||
elif dist <= 14:
|
||
boost = 4.0
|
||
elif start_i > int(prefer_i) + 16:
|
||
boost = -10.0
|
||
_try_seg(start_i, cn - 1, boost)
|
||
|
||
if prefer_i is not None:
|
||
pi = int(prefer_i)
|
||
if 0 <= pi < cn:
|
||
align_max = min(cn, max(max_bars, int(cn * 0.65)))
|
||
alen = cn - pi
|
||
if eff_min_bars <= alen <= align_max:
|
||
_try_seg(pi, cn - 1, prefer_boost=18.0)
|
||
elif alen > align_max:
|
||
start_i = max(0, cn - align_max)
|
||
if start_i > pi:
|
||
start_i = pi
|
||
end_i = min(cn - 1, pi + align_max - 1)
|
||
else:
|
||
end_i = cn - 1
|
||
_try_seg(start_i, end_i, prefer_boost=12.0)
|
||
|
||
if not cands:
|
||
return None
|
||
|
||
cands.sort(key=lambda x: x[0], reverse=True)
|
||
best_score = cands[0][0]
|
||
band = max(4.0, abs(best_score) * 0.10)
|
||
near = [c for c in cands if c[0] >= best_score - band]
|
||
chosen = max(near, key=lambda x: (x[1], x[0]))
|
||
score, _length, start_i, end_i, hi, lo, _rw = chosen
|
||
return _pack_range(work, df, start_i, end_i, hi, lo, tol, last_atr, score, n, window_offset=win_start)
|
||
|
||
|
||
def detect_trading_ranges(
|
||
df: pd.DataFrame,
|
||
lookback: Optional[int] = None,
|
||
min_bars: int = 24,
|
||
atr_mult: float = 1.2,
|
||
tail_reserve: int = 12,
|
||
max_cycles: int = MAX_CYCLES,
|
||
prefer_start_time: Any = None,
|
||
range_start_time: Any = None,
|
||
) -> List[Dict[str, Any]]:
|
||
"""
|
||
倒序切多段 TradingRange(近→远)。
|
||
过滤顺序:detect → quality → trend → overlap → accept → mask。
|
||
返回列表已按时间倒序,调用方将 [0] 标为 ACTIVE。
|
||
"""
|
||
if df is None or len(df) < min_bars + 5:
|
||
return []
|
||
lb = int(lookback) if lookback is not None else len(df)
|
||
work = df.tail(lb).reset_index(drop=True)
|
||
n = len(work)
|
||
occupied: List[Dict[str, Any]] = []
|
||
accepted: List[Dict[str, Any]] = []
|
||
|
||
# 搜索右端从 n-1 往左收缩;每接受一段后右端移到该段 start 之前
|
||
search_end = n - 1
|
||
prefer = prefer_start_time
|
||
hard_start = range_start_time
|
||
|
||
while len(accepted) < max(1, int(max_cycles)) and search_end >= min_bars + 4:
|
||
# 在剩余历史内从右往左试多个右边界,避免历史箱必须贴住 search_end
|
||
# (否则中间趋势会挡住更早的真实箱)
|
||
cand = None
|
||
step = max(4, min(12, (search_end - min_bars) // 10 or 4))
|
||
for end_try in range(search_end, min_bars + 4, -step):
|
||
trial = _detect_in_window(
|
||
work,
|
||
0,
|
||
end_try,
|
||
min_bars=min_bars,
|
||
atr_mult=atr_mult,
|
||
tail_reserve=tail_reserve,
|
||
prefer_start_time=prefer if len(accepted) == 0 and end_try == search_end else None,
|
||
range_start_time=hard_start if len(accepted) == 0 and end_try == search_end else None,
|
||
)
|
||
# 1) detect
|
||
if trial is None:
|
||
continue
|
||
# 2) quality
|
||
if not _passes_quality(trial, min_bars):
|
||
continue
|
||
# 3) trend contamination
|
||
if not _passes_trend_filter(work, trial):
|
||
continue
|
||
# 4) overlap with accepted
|
||
a0, a1 = int(trial["abs_start_idx"]), int(trial["abs_end_idx"])
|
||
overlap_bad = False
|
||
for occ in occupied:
|
||
ratio = _overlap_ratio(a0, a1, int(occ["start"]), int(occ["end"]))
|
||
if ratio >= OVERLAP_RATIO_MAX:
|
||
overlap_bad = True
|
||
break
|
||
if overlap_bad:
|
||
continue
|
||
# 取最靠右的合格箱(倒序第一段)
|
||
cand = trial
|
||
break
|
||
|
||
if cand is None:
|
||
break
|
||
|
||
# 5) accept
|
||
accepted.append(cand)
|
||
a0, a1 = int(cand["abs_start_idx"]), int(cand["abs_end_idx"])
|
||
# 6) mask
|
||
occupied.append(
|
||
{
|
||
"start": a0,
|
||
"end": max(a1, int(cand.get("abs_scan_end_idx", a1))),
|
||
"quality": float(cand.get("quality") or 0),
|
||
"high": float(cand["high"]),
|
||
"low": float(cand["low"]),
|
||
}
|
||
)
|
||
# 下一轮只在更早窗口搜
|
||
search_end = int(cand["abs_start_idx"]) - 1
|
||
hard_start = None
|
||
prefer = None
|
||
|
||
# abs_* 目前相对 work;若 df 比 work 长需加 offset
|
||
offset = len(df) - len(work)
|
||
if offset:
|
||
for tr in accepted:
|
||
tr["abs_start_idx"] = int(tr["abs_start_idx"]) + offset
|
||
tr["abs_end_idx"] = int(tr["abs_end_idx"]) + offset
|
||
tr["abs_scan_end_idx"] = int(tr["abs_scan_end_idx"]) + offset
|
||
|
||
return accepted
|
||
|
||
|
||
def detect_trading_range(
|
||
df: pd.DataFrame,
|
||
lookback: int = 120,
|
||
min_bars: int = 24,
|
||
atr_mult: float = 1.2,
|
||
tail_reserve: int = 12,
|
||
range_start_time: Any = None,
|
||
prefer_start_time: Any = None,
|
||
) -> Optional[Dict[str, Any]]:
|
||
"""兼容旧接口:返回倒序列表中的第一段(ACTIVE 候选)。"""
|
||
ranges = detect_trading_ranges(
|
||
df,
|
||
lookback=lookback,
|
||
min_bars=min_bars,
|
||
atr_mult=atr_mult,
|
||
tail_reserve=tail_reserve,
|
||
max_cycles=1,
|
||
prefer_start_time=prefer_start_time,
|
||
range_start_time=range_start_time,
|
||
)
|
||
return ranges[0] if ranges else None
|