"""交易区间检测:仅负责 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